From b589ec04266d4cd842a98907faa2694b0f3c406d Mon Sep 17 00:00:00 2001 From: Jhoan Gallego Date: Thu, 1 Oct 2026 02:48:28 +0200 Subject: [PATCH 01/10] Windows: fix hidden island not waking on mouse hover When the island hides, the window shrinks to a 240x6 wake strip and the cursor poll thread parks. The poll tick that was running at that moment could still see the cursor away from the island and turn click-through (WS_EX_TRANSPARENT) back on. set_collapsed() clears the flag on the main thread, but the poll thread's setter is queued to the event loop and lands afterwards, so the strip ended up click-through: the mouse passed straight through it and the island could only be woken by Claude Code events, never by hovering the top of the screen. The poll thread now clears click-through itself when it parks. Its setters go through the same queue, so this one always lands after any flag the last tick set. --- windows/src-tauri/src/island.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/windows/src-tauri/src/island.rs b/windows/src-tauri/src/island.rs index 0a4fc87883..4287dac6a9 100644 --- a/windows/src-tauri/src/island.rs +++ b/windows/src-tauri/src/island.rs @@ -357,6 +357,16 @@ pub fn spawn_cursor_poll(app: AppHandle, gate: Arc) { let _ = win.emit("cursor", CursorPayload { x, y }); } + + // Parked: the window is (or is about to be) the wake strip, which must + // take the mouse. The tick that was running when the island collapsed + // may just have made it click-through, and setters from this thread are + // queued to the event loop — so clear the flag from here, behind it. + // Otherwise the hidden island can only be woken by Claude Code events. + if let Some(win) = window(&app) { + let _ = win.set_ignore_cursor_events(false); + } + gate.forget_ignore_state(); } }); } From 2bc101b99fe42d5440ff84bc3c8a71ba98efc65d Mon Sep 17 00:00:00 2001 From: Jhoan Gallego Date: Thu, 1 Oct 2026 02:48:28 +0200 Subject: [PATCH 02/10] Windows: stop Vite from watching the Cargo target dir The Cargo workspace root is windows/, so target/ sits inside Vite's root. During `tauri dev`, Vite's watcher tried to watch .pdb files cargo still held open and crashed with EBUSY, which made Tauri abort the beforeDevCommand. Ignore target/, src-tauri/ and hook/ in the dev server watcher. --- windows/vite.config.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/windows/vite.config.ts b/windows/vite.config.ts index 1ff3b65656..a97b3b78d1 100644 --- a/windows/vite.config.ts +++ b/windows/vite.config.ts @@ -45,7 +45,14 @@ function sharedSounds(): Plugin { export default defineConfig({ plugins: [sharedSounds()], clearScreen: false, - server: { port: 1420, strictPort: true, host: "127.0.0.1" }, + server: { + port: 1420, + strictPort: true, + host: "127.0.0.1", + // The Cargo workspace lives in windows/, so its target/ sits inside Vite's root. + // Watching it crashes Vite with EBUSY on Windows while cargo holds .pdb files open. + watch: { ignored: ["**/target/**", "**/src-tauri/**", "**/hook/**"] }, + }, envPrefix: ["VITE_", "TAURI_ENV_"], build: { target: "chrome110", From 71fb5293972e2515b7bf6dc2125a3bc16c31ebe2 Mon Sep 17 00:00:00 2001 From: Jhoan Gallego Date: Thu, 1 Oct 2026 02:50:25 +0200 Subject: [PATCH 03/10] Windows: bump @tauri-apps/api and cli to 2.12.1 Pins both to the patch release instead of a bare "^2", and syncs the lockfile with the 0.1.1 version already set in package.json. --- windows/package-lock.json | 108 +++++++++++++++++++------------------- windows/package.json | 4 +- 2 files changed, 56 insertions(+), 56 deletions(-) diff --git a/windows/package-lock.json b/windows/package-lock.json index 5e06f8bcb9..aff96aba33 100644 --- a/windows/package-lock.json +++ b/windows/package-lock.json @@ -1,17 +1,17 @@ { "name": "coucou-windows", - "version": "0.1.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "coucou-windows", - "version": "0.1.0", + "version": "0.1.1", "dependencies": { - "@tauri-apps/api": "^2" + "@tauri-apps/api": "^2.12.1" }, "devDependencies": { - "@tauri-apps/cli": "^2", + "@tauri-apps/cli": "^2.12.1", "typescript": "^5.6.0", "vite": "^6" } @@ -868,9 +868,9 @@ ] }, "node_modules/@tauri-apps/api": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.12.0.tgz", - "integrity": "sha512-fUSxFX8VABYe9W9K5ROdEaKVkNUFRcMSRofjruvaxztXA+I0XnQHcYHF8m/a6iExRUxsUf0HsFWhPBuH9IgzJw==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.12.1.tgz", + "integrity": "sha512-DeyFHa3wynpyoqTDikDEDGTJIq4LQ5USfolQGRmGIWT6JMADyxZBTDa5cAdT3tDg73rUXufPaCwN7aXBos4OnQ==", "license": "Apache-2.0 OR MIT", "funding": { "type": "opencollective", @@ -878,9 +878,9 @@ } }, "node_modules/@tauri-apps/cli": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.12.0.tgz", - "integrity": "sha512-GIShnAR4C16kSTA8NO02fo8fOw/I5z5sAUEB5S2HlPHFnBkbihL4c9a/MpkxOm1+q5S+tFd3X4QUjHWxfyGzpQ==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.12.1.tgz", + "integrity": "sha512-kEDEiGzG+yAc5FeLxtXpES/VN+F2C8H0r4gDVtfRLKxT9np9101d9REG/Kgo2lr0hKi0IpDsODiHnU3+naOmLg==", "dev": true, "license": "Apache-2.0 OR MIT", "bin": { @@ -894,23 +894,23 @@ "url": "https://opencollective.com/tauri" }, "optionalDependencies": { - "@tauri-apps/cli-darwin-arm64": "2.12.0", - "@tauri-apps/cli-darwin-x64": "2.12.0", - "@tauri-apps/cli-linux-arm-gnueabihf": "2.12.0", - "@tauri-apps/cli-linux-arm64-gnu": "2.12.0", - "@tauri-apps/cli-linux-arm64-musl": "2.12.0", - "@tauri-apps/cli-linux-riscv64-gnu": "2.12.0", - "@tauri-apps/cli-linux-x64-gnu": "2.12.0", - "@tauri-apps/cli-linux-x64-musl": "2.12.0", - "@tauri-apps/cli-win32-arm64-msvc": "2.12.0", - "@tauri-apps/cli-win32-ia32-msvc": "2.12.0", - "@tauri-apps/cli-win32-x64-msvc": "2.12.0" + "@tauri-apps/cli-darwin-arm64": "2.12.1", + "@tauri-apps/cli-darwin-x64": "2.12.1", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.12.1", + "@tauri-apps/cli-linux-arm64-gnu": "2.12.1", + "@tauri-apps/cli-linux-arm64-musl": "2.12.1", + "@tauri-apps/cli-linux-riscv64-gnu": "2.12.1", + "@tauri-apps/cli-linux-x64-gnu": "2.12.1", + "@tauri-apps/cli-linux-x64-musl": "2.12.1", + "@tauri-apps/cli-win32-arm64-msvc": "2.12.1", + "@tauri-apps/cli-win32-ia32-msvc": "2.12.1", + "@tauri-apps/cli-win32-x64-msvc": "2.12.1" } }, "node_modules/@tauri-apps/cli-darwin-arm64": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.12.0.tgz", - "integrity": "sha512-v5J+jISihLGtih1+3yWagbsL+5dbLZORu4vaSI0vhY0Heb+MAW+T/1s1RyKxrQrwEPGBG5kqwfDDTnSbmVkuqA==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.12.1.tgz", + "integrity": "sha512-8bMnbpJ2jKO/ufrOPL65EMHC3ptw7fzeraoQkwibyfGLqIl+UCWsC0152kmNbW6r9NvVQduXqAZF5mH1QgTWhw==", "cpu": [ "arm64" ], @@ -925,9 +925,9 @@ } }, "node_modules/@tauri-apps/cli-darwin-x64": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.12.0.tgz", - "integrity": "sha512-g27I07pfW/dMS8vcNqB9lamgKF0TqLlcy8e7t9qDGsB1iaeyjJWb2TgBF4tM9ZwLqPAZUXbsz28oXljO12WC4g==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.12.1.tgz", + "integrity": "sha512-G+2qte8GNqNlEZ9ftd21rbu/APVkl3r/nzNDI7LVxEoqJWVNe9GbEgQUMH1r4PHT20Z2UxR3KjCkltIb0e/Wjg==", "cpu": [ "x64" ], @@ -942,9 +942,9 @@ } }, "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.12.0.tgz", - "integrity": "sha512-gXyQ710fNc99YuxFxNyeX9PLSrIpbtgBBj7GQo9tSeRT6fIK40q3/gm7X5jqZo7mWLR2m0R/2ALd3vAWo0Z32Q==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.12.1.tgz", + "integrity": "sha512-5b3n4DaTW+A1Zntnh4Yn44VyLrlPEpB8kMOBRfWkzNnzolBk+AYhKIfKjCLmoC8Mob3ETodxAEOMkE2YPKEWzQ==", "cpu": [ "arm" ], @@ -959,9 +959,9 @@ } }, "node_modules/@tauri-apps/cli-linux-arm64-gnu": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.12.0.tgz", - "integrity": "sha512-H9cyXuq9WgxoHh2NZ+R+oN+W78x5UwaBDEp0/fmqUUkaBoioCuOZErM9xKiIV6bpNAhmw1RoWZ49u37q/vaiAg==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.12.1.tgz", + "integrity": "sha512-APArHhDu+8pXSCkCQmvaz1nD3d8WTmlCoSS/oG475TJ6AATBvyQOAmf5qELgsQ4s2+8MOsop0Zd699RKELF1Cw==", "cpu": [ "arm64" ], @@ -979,9 +979,9 @@ } }, "node_modules/@tauri-apps/cli-linux-arm64-musl": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.12.0.tgz", - "integrity": "sha512-5tFuk3JKMBa1M/iN4gN32OYlSfhY3aqb5PO3zCugxaT+sfecyMKpc3EM9VDbhmwWBE9QD4Tfj7wGhlqx2yy2pQ==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.12.1.tgz", + "integrity": "sha512-JaCxcLoV0oGtllVkLr4UAHWG/wTfkEunnUqnsEsGYOp0VRxnUwHg63wFBD0jU2ul5eFfSLIDS4RF2gfzsSo5Jg==", "cpu": [ "arm64" ], @@ -999,9 +999,9 @@ } }, "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.12.0.tgz", - "integrity": "sha512-LOrMyV+nb7pKq8P0BZtTBQuyzm1ORjn16R9MlupmxuT5LqxtuYJlw3VMwbaeXCToJ8jbP5u9JnkfmbqDlvETXw==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.12.1.tgz", + "integrity": "sha512-K5+VXM7+SGVDwZleA2+lMgvbTVuA5e1tkiVEawu1Yxj26ZyEfO8h8xTC1rBdRX2xgJDSwCk6vPUBCS6i+Z5slQ==", "cpu": [ "riscv64" ], @@ -1019,9 +1019,9 @@ } }, "node_modules/@tauri-apps/cli-linux-x64-gnu": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.12.0.tgz", - "integrity": "sha512-aMIkI+X9qvD2+k80QZB3tuOYT41/cEheikC510CwAIZ31KLXORsRASYYzM4mi1/58ICmtzSLFhCC08keir7COw==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.12.1.tgz", + "integrity": "sha512-Z1QGPr49HJZMktu+Spu6Q1b/f9ANadXvqZZZt2645ua1x1ev6prOgp85eiGv2jRzohFEMLo6aJyaLjLUX5KIvA==", "cpu": [ "x64" ], @@ -1039,9 +1039,9 @@ } }, "node_modules/@tauri-apps/cli-linux-x64-musl": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.12.0.tgz", - "integrity": "sha512-9gl3NhUbfuGhSKjeGCypKSdtoezOQRcDUrkhTWtLQEjnajets6peO6NadgI2DF0OzbiiFOzrnnIGMwBS+Ap6Iw==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.12.1.tgz", + "integrity": "sha512-nHuEhRPOMSJ/8RRhtMRxB9B9lVr7xxpe4DapBz8x1GIau3vdzjHHJ1hAfmw+PyayPLZ6XoaVIHPgI17rX6ii8w==", "cpu": [ "x64" ], @@ -1059,9 +1059,9 @@ } }, "node_modules/@tauri-apps/cli-win32-arm64-msvc": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.12.0.tgz", - "integrity": "sha512-1W+v86wsYLuyBDN0iDessD4CD9o+yNyO0m8Dp3+/vfjiZrJ/v7R7/aTRBeikaq2tLGgxeag0gxnFggBNBGw91A==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.12.1.tgz", + "integrity": "sha512-I0oPk2uGh86R8dAs2X5OxSP+/GqSc8jh1Px3l8EqrocSNivsRHDTh2k3PVOqdv163dlV6PIVvmhZAbH/lPfZrA==", "cpu": [ "arm64" ], @@ -1076,9 +1076,9 @@ } }, "node_modules/@tauri-apps/cli-win32-ia32-msvc": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.12.0.tgz", - "integrity": "sha512-SsBjvq4f7YaBGqcWoE3cycoItPf0SrQeqFqWWgfxoeZWAIz01E31LDSeZ2p/etJiIY0R9oi045chkFfb0O7WGw==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.12.1.tgz", + "integrity": "sha512-baNZRBfepAJ5qNFRMwzgBk94xQmkcmTZrNDjZjnzrq2vAaTC3NAhallb/zo3hyoCGQlMS13sdovktrg46NZz6Q==", "cpu": [ "ia32" ], @@ -1093,9 +1093,9 @@ } }, "node_modules/@tauri-apps/cli-win32-x64-msvc": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.12.0.tgz", - "integrity": "sha512-9zzDKYUH4TKKBgRdDdVQX8fiRO5gJ8hD1nGsHU9DOHG5Jpm3arjbu9Xg1frR20EusG3Sl8X0dppoO/4EoUXUGg==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.12.1.tgz", + "integrity": "sha512-dIFaKslrzcbesb+bBh+E1R9km241pHyj32w6D9FPtvQeYpRUFbiCLEa/2S+S0UnIytfxepp4P+X8IerGqnXeYg==", "cpu": [ "x64" ], diff --git a/windows/package.json b/windows/package.json index 22931d9bf9..ef35a6091d 100644 --- a/windows/package.json +++ b/windows/package.json @@ -14,11 +14,11 @@ "pack": "tauri build && node scripts/pack.mjs" }, "devDependencies": { - "@tauri-apps/cli": "^2", + "@tauri-apps/cli": "^2.12.1", "typescript": "^5.6.0", "vite": "^6" }, "dependencies": { - "@tauri-apps/api": "^2" + "@tauri-apps/api": "^2.12.1" } } From 05b072e65a4a929b7c59fe4f72c50d528e76dcef Mon Sep 17 00:00:00 2001 From: Jhoan Gallego Date: Thu, 1 Oct 2026 02:51:00 +0200 Subject: [PATCH 04/10] Windows: fix ticker steps overlapping on long commands The completed-step label sat at its static position after the shimmering one, so any label wider than the row wrapped it onto a second line: once a step completed, its text dropped right on top of the current one. Both layers are now stacked absolutely on the same 22 px line, each a single ellipsized line, with the full text in the row's tooltip. - Shell steps drop the `cd &&` / `cd ;` prefix Claude puts in front of most commands (the card already names the project) and collapse whitespace, instead of a blind 40-character cut. - The frame loop stays alive while the ticker is mid-scroll: it used to stop as soon as nothing else was moving, freezing two rows half-overlapped. - dev/ticker-preview: a harness that feeds the ticker fake steps, bursts and a stalled loop, like dev/upload-preview does for the drop sequence. --- windows/dev/ticker-preview.html | 24 ++++++++++ windows/dev/ticker-preview.ts | 80 +++++++++++++++++++++++++++++++++ windows/src/island/hooks.ts | 15 ++++++- windows/src/island/island.ts | 7 ++- windows/src/style.css | 20 ++++++++- windows/src/views/ticker.ts | 14 +++--- windows/src/views/views.ts | 5 +++ 7 files changed, 155 insertions(+), 10 deletions(-) create mode 100644 windows/dev/ticker-preview.html create mode 100644 windows/dev/ticker-preview.ts diff --git a/windows/dev/ticker-preview.html b/windows/dev/ticker-preview.html new file mode 100644 index 0000000000..297dd8463c --- /dev/null +++ b/windows/dev/ticker-preview.html @@ -0,0 +1,24 @@ + + + + + Coucou — ticker preview + + + +
+
+ + + + +
+ + + diff --git a/windows/dev/ticker-preview.ts b/windows/dev/ticker-preview.ts new file mode 100644 index 0000000000..3a4f4c123c --- /dev/null +++ b/windows/dev/ticker-preview.ts @@ -0,0 +1,80 @@ +// Dev harness: the overview ticker on its own, fed with fake Claude Code steps, +// so the scroll can be watched without a live session. Not part of the bundle. + +import "../src/style.css"; +import { Ticker } from "../src/views/ticker"; +import { h } from "../src/views/dom"; +import type { AgentTask } from "../src/core/state"; + +const SAMPLES = [ + "Exécute · git status --short && git log --oneline -5 && git diff --stat HEAD~3", + "Exécute · (Get-Command gh -ErrorAction SilentlyContinue).Source", + "Lit · ticker.ts", + "Modifie · style.css", + "Recherche · ensureRunning", + "Cherche · **/*.ts", + "Exécute · npm run build", +]; + +const task = { + id: "integration_claude", + name: "coucou", + source: "claudeCode", + state: "working", + color: "#f5f6f8", + steps: [] as string[], + stepIndex: 0, +} as unknown as AgentTask; + +const ticker = new Ticker(); +const who = h( + "div", + { class: "who" }, + h("span", { class: "name", text: "coucou" }), + h("span", { class: "tool", text: "Claude Code" }), +); +const card = h("div", { class: "card" }, h("div", { class: "card-body" }, who, ticker.el)); +document.getElementById("stage")!.append(card, h("div", { id: "bot" })); + +let n = 0; +function push(count: number) { + for (let i = 0; i < count; i++) task.steps.push(SAMPLES[n++ % SAMPLES.length]); + task.stepIndex = task.steps.length - 1; + ticker.sync(task); + kick(); +} + + +// Mirrors the island: the loop only runs while something says it is busy. +let running = false; +let stall = false; +function kick() { + if (running) return; + running = true; + requestAnimationFrame(frame); +} +function frame(now: number) { + ticker.tick(now); + if (!stall && ticker.animating) requestAnimationFrame(frame); + else running = false; +} + +push(1); + +// Manual clock for a hidden tab, where requestAnimationFrame never fires. +Object.assign(window, { ticker, push }); + +document.getElementById("one")!.onclick = () => push(1); +document.getElementById("burst")!.onclick = () => push(5); +document.getElementById("reset")!.onclick = () => { + task.steps = []; + task.stepIndex = 0; + n = 0; + ticker.sync(task); + push(1); +}; +document.getElementById("stall")!.onclick = () => { + stall = true; + push(1); + setTimeout(() => (stall = false), 50); +}; diff --git a/windows/src/island/hooks.ts b/windows/src/island/hooks.ts index 547b1679ea..a1dd443bad 100644 --- a/windows/src/island/hooks.ts +++ b/windows/src/island/hooks.ts @@ -59,11 +59,24 @@ const TOOL_LABELS: Record = { PowerShell: "Exécute", }; +/** + * The part of a shell command worth reading in one ticker line: whitespace and + * newlines collapsed, and the `cd &&` / `cd ;` prefix Claude + * puts in front of most commands dropped, since the card already names the + * project. The ticker ellipsizes, so this only caps pathological lengths. + */ +function commandSummary(cmd: string): string { + let s = cmd.replace(/\s+/g, " ").trim(); + const rest = s.replace(/^(?:cd|Set-Location|pushd)\s+("[^"]*"|'[^']*'|\S+)\s*(?:&&|;)\s*/i, ""); + if (rest) s = rest; + return s.slice(0, 120); +} + function stepLabel(tool: string, input: Record): string { const label = TOOL_LABELS[tool] ?? tool; const str = (k: string) => (typeof input[k] === "string" ? (input[k] as string) : null); const cmd = str("command"); - if (cmd) return `${label} · ${cmd.slice(0, 40)}`; + if (cmd) return `${label} · ${commandSummary(cmd)}`; const path = str("path"); if (path) return `${label} · ${lastPathComponent(path)}`; const file = str("file_path"); diff --git a/windows/src/island/island.ts b/windows/src/island/island.ts index 4a6cc6d14d..38a619b127 100644 --- a/windows/src/island/island.ts +++ b/windows/src/island/island.ts @@ -706,7 +706,8 @@ export class Island { this.viewsEl.classList.toggle("hidden-by-upload", uploadActive); tickMiniBots(dt); - this.views.get(State.view)?.tick?.(nowMs); + const view = this.views.get(State.view); + view?.tick?.(nowMs); if (UploadSeq.isActive) this.stepSequence(); this.updateCountdown(nowMs); @@ -722,7 +723,9 @@ export class Island { ? settling : settling || !this.botCx.settled || !this.botCy.settled || !this.botSize.settled || - greetingActive || this.engine.busy || UploadSeq.isActive; + greetingActive || this.engine.busy || UploadSeq.isActive || + // A ticker step frozen mid-scroll leaves two rows half-overlapping. + !!view?.animating; if (busy) { requestAnimationFrame(this.frame); diff --git a/windows/src/style.css b/windows/src/style.css index 1da05cf944..bfbeb20eca 100644 --- a/windows/src/style.css +++ b/windows/src/style.css @@ -461,20 +461,38 @@ body { } .ticker-row .tick-icon { + position: relative; width: 12px; + height: 22px; display: grid; place-items: center; flex: 0 0 12px; color: var(--dim-2); } +.ticker-row .tick-texts { + position: relative; + flex: 1 1 auto; + min-width: 0; + height: 22px; +} + +/* Two layers on one line: the shimmering current label and the dim completed + one, crossfaded by the ticker. Each is a single clipped, ellipsized line. */ .ticker-row .tick-text { - font: 500 13px var(--font); + position: absolute; + inset: 0; + display: block; + font: 500 13px/22px var(--font); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.ticker-row .tick-text.done { + color: var(--dim-3); +} + /* Shimmer sweep, 2.2 s — same as TickerShimmerText / ShimmeringText */ .shimmer { background: linear-gradient( diff --git a/windows/src/views/ticker.ts b/windows/src/views/ticker.ts index 72b18023ef..815a14d999 100644 --- a/windows/src/views/ticker.ts +++ b/windows/src/views/ticker.ts @@ -35,16 +35,17 @@ function makeRow(): Row { check.style.color = "#454850"; // the completed tick is dimmer than the chevron check.style.position = "absolute"; chevron.style.position = "absolute"; + // Both text layers are stacked absolutely on the same 22 px line. The dim one + // used to sit at its static position after the shimmer text, so any label + // wider than the row wrapped it onto a second line: once the step completed, + // its text dropped right on top of the current one. const shimmer = h("span", { class: "tick-text shimmer" }); - const dim = h("span", { - class: "tick-text", - style: "position:absolute;left:0;right:0;color:#6b7079", - }); + const dim = h("span", { class: "tick-text done" }); const el = h( "div", { class: "ticker-row" }, - h("span", { class: "tick-icon", style: "position:relative" }, chevron, check), - h("span", { style: "position:relative;flex:1 1 auto;min-width:0" }, shimmer, dim), + h("span", { class: "tick-icon" }, chevron, check), + h("span", { class: "tick-texts" }, shimmer, dim), ); return { el, chevron, check, shimmer, dim, text: "" }; } @@ -54,6 +55,7 @@ function setText(row: Row, text: string) { row.text = text; row.shimmer.textContent = text; row.dim.textContent = text; + row.el.title = text; // the full command, since the row ellipsizes it } /** diff --git a/windows/src/views/views.ts b/windows/src/views/views.ts index ac0ac7b6f7..7497fcd426 100644 --- a/windows/src/views/views.ts +++ b/windows/src/views/views.ts @@ -35,6 +35,8 @@ export interface ViewHost { focus?(): void; /** Called every frame while the view is on screen. */ tick?(nowMs: number): void; + /** True while `tick` still has motion to finish; keeps the frame loop alive. */ + readonly animating?: boolean; } // ── Shared pieces ───────────────────────────────────────────────────────────── @@ -163,6 +165,9 @@ function buildOverview(actions: ViewActions): ViewHost { tick(nowMs: number) { if (mode === "ticker") ticker.tick(nowMs); }, + get animating() { + return mode === "ticker" && ticker.animating; + }, sync() { const task = State.focusTask; if (task?.id !== lastFocus) { From 58966dae79ed5286df29fa28937fa791d1fccbd0 Mon Sep 17 00:00:00 2001 From: Jhoan Gallego Date: Thu, 1 Oct 2026 02:51:00 +0200 Subject: [PATCH 05/10] Windows: close the island when a file drag is abandoned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a dragged file left the island, the island stayed open on purpose — the file might come back — but nothing noticed when the drag was over. Dragging a file in and then dropping it elsewhere (or pressing Escape) left the drop zone up until the auto-close fired, up to 30 s later. - The Rust cursor poll emits `mouse-button` whenever the left button changes, checked before the "cursor hasn't moved" skip: an abandoned drag usually ends with the mouse standing still. - The island remembers where it was when a file first reached it. Once the file is out and the button is up, after a 250 ms grace so a late drop still wins, it goes back there: hidden (compact if the cursor is still over it), the view it was showing — a pending approval included — or compact. Leaving and coming back with the button held keeps the island open as before, and a drop on the island still runs the normal upload sequence. --- windows/src-tauri/src/island.rs | 24 +++++++----- windows/src/core/bridge.ts | 1 + windows/src/island/island.ts | 65 ++++++++++++++++++++++++++++++++- windows/src/main.ts | 1 + 4 files changed, 80 insertions(+), 11 deletions(-) diff --git a/windows/src-tauri/src/island.rs b/windows/src-tauri/src/island.rs index 4287dac6a9..e2889cb33a 100644 --- a/windows/src-tauri/src/island.rs +++ b/windows/src-tauri/src/island.rs @@ -312,6 +312,21 @@ pub fn spawn_cursor_poll(app: AppHandle, gate: Arc) { Ok(s) => (s.width as f64 / scale, s.height as f64 / scale), Err(_) => (PANEL_W, PANEL_H), }; + // Checked before the "cursor hasn't moved" skip: a drag abandoned + // elsewhere usually ends with the mouse standing still, and the + // island needs to hear about the release to close behind it. + let down = left_button_down(); + if down != was_down { + was_down = down; + if down { + // A press may be the start of a drag: make sure the drop + // target is ours before the file arrives. + let handle = app.clone(); + let _ = app.run_on_main_thread(move || unblock_webview_drops(&handle)); + } + let _ = win.emit("mouse-button", down); + } + if (x - last.0).abs() < 1.0 && (y - last.1).abs() < 1.0 { continue; } @@ -334,15 +349,6 @@ pub fn spawn_cursor_poll(app: AppHandle, gate: Arc) { // registered destinations whatever ignoresMouseEvents says. So while // a button is held anywhere over the panel, the whole panel takes // the mouse, which also makes the drop zone as forgiving as the Mac's. - // A press may be the start of a drag: make sure the drop target is - // ours before the file arrives. - let down = left_button_down(); - if down && !was_down { - let handle = app.clone(); - let _ = app.run_on_main_thread(move || unblock_webview_drops(&handle)); - } - was_down = down; - let dragging = down && x >= 0.0 && x <= size.0 diff --git a/windows/src/core/bridge.ts b/windows/src/core/bridge.ts index 2967337e79..835944d22c 100644 --- a/windows/src/core/bridge.ts +++ b/windows/src/core/bridge.ts @@ -139,6 +139,7 @@ async function callOrThrow(cmd: string, args?: Record): Prom export type BridgeEvent = | { name: "cursor"; payload: { x: number; y: number } } + | { name: "mouse-button"; payload: boolean } | { name: "tray"; payload: string } | { name: "hook"; payload: Record } | { name: "screen-changed"; payload: null }; diff --git a/windows/src/island/island.ts b/windows/src/island/island.ts index 38a619b127..6e8a77bc46 100644 --- a/windows/src/island/island.ts +++ b/windows/src/island/island.ts @@ -18,7 +18,7 @@ import { UploadCanvas } from "../upload/canvas"; import { USC, UploadSeq } from "../upload/sequence"; import { buildHeader, buildViews, type ViewActions, type ViewHost } from "../views/views"; import { h } from "../views/dom"; -import { IslandStateMachine } from "./fsm"; +import { IslandStateMachine, type FsmState } from "./fsm"; const BOT_OVERHANG = 40; /** Same margin as the Rust hit test (src-tauri/src/island.rs). */ @@ -88,6 +88,12 @@ export class Island { private uploadTens = 0; private uploadDone = false; + /** Where the island was when a file drag first reached it, to go back on a cancel. */ + private dragOrigin: { state: FsmState; view: IslandViewName } | null = null; + /** Left button, as reported by the Rust cursor poll. */ + private mouseDown = false; + private abandonTimer: number | null = null; + constructor(root: HTMLElement) { this.root = root; this.build(); @@ -348,6 +354,10 @@ export class Island { case "enter": case "over": { if (State.fileDragOver) return; + this.cancelAbandon(); + // A drag is a held button, even if the poll hasn't reported it yet. + this.mouseDown = true; + this.dragOrigin ??= { state: this.fsm.state, view: State.view }; State.fileDragOver = true; this.engine.animateMorph(1); // enterZone must run before the island expands, so the sequence is @@ -360,12 +370,17 @@ export class Island { if (!State.fileDragOver) return; State.fileDragOver = false; this.engine.animateMorph(0); - // The island deliberately stays open: the drag session is still alive. + // The island stays open while the drag session is alive — the file may + // come back. Once the button is up the drag is over, and waiting out + // the auto-close (up to 30 s) for a file that went elsewhere is no good. UploadSeq.exitZone(); + if (!this.mouseDown) this.scheduleAbandon(); State.notify(); break; } case "drop": { + this.cancelAbandon(); + this.dragOrigin = null; State.fileDragOver = false; const path = e.paths?.[0]; if (!path) { @@ -379,6 +394,52 @@ export class Island { } } + /** Left button pressed or released anywhere (Rust cursor poll). */ + onMouseButton(down: boolean) { + this.mouseDown = down; + if (down) this.cancelAbandon(); + else if (this.dragOrigin && !State.fileDragOver) this.scheduleAbandon(); + } + + /** + * The file went elsewhere (or the drag was cancelled). A short grace lets a + * late `drop` win, then the island goes back to where it was before the drag. + */ + private scheduleAbandon() { + this.cancelAbandon(); + this.abandonTimer = window.setTimeout(() => { + this.abandonTimer = null; + this.abandonDrag(); + }, 250); + } + + private cancelAbandon() { + if (this.abandonTimer != null) window.clearTimeout(this.abandonTimer); + this.abandonTimer = null; + } + + private abandonDrag() { + const origin = this.dragOrigin; + this.dragOrigin = null; + if (!origin || this.mouseDown || State.fileDragOver || UploadSeq.dropped) return; + // The user moved on to something else in the meantime: leave it alone. + if (State.mode !== "expanded" || State.view !== "upload") return; + void Bridge.log(`drag abandoned, back to ${origin.state}/${origin.view}`); + UploadSeq.deactivate(); + switch (origin.state) { + case "hidden": + // Still under the cursor (an Escape over the island): stay reachable. + if (this.wasInIsland) this.collapse(); + else this.fsm.forceHidden(); + break; + case "home": + this.setView(origin.view); + break; + default: + this.collapse(); + } + } + /** * Mochi eats the file. Nothing here waits on the file system: the copy into * the inbox runs in the background and swaps the path in when it lands, so a diff --git a/windows/src/main.ts b/windows/src/main.ts index 5f1c3348cc..d66ed07671 100644 --- a/windows/src/main.ts +++ b/windows/src/main.ts @@ -24,6 +24,7 @@ async function main() { State.loadIntegrationTasks(); await onEvent<{ x: number; y: number }>("cursor", ({ x, y }) => island.onCursor(x, y)); + await onEvent("mouse-button", (down) => island.onMouseButton(down)); /** Pause has to reach Rust too, or the pollers keep calling out. */ const setPaused = (on: boolean) => { From 9c60c704c3f047193582756c154d52737ae2c379 Mon Sep 17 00:00:00 2001 From: Jhoan Gallego Date: Thu, 1 Oct 2026 23:22:34 +0200 Subject: [PATCH 06/10] Windows: GitHub CI and pull requests, Google Calendar with reminders GitHub - The card now follows the CI of the branch of the last Claude Code session (read from its .git folder, worktrees included), the reviews asked of you, and your open pull requests with their review state. Pull requests idle for 30 days are left out. Stars and repositories remain as the fallback overview. - One GraphQL query per minute. Mochi speaks up when a CI run it watched finishes, on a new review of your pull requests, and on a new review request. Google Calendar (new pill) - Signs in with the user's own Desktop OAuth client: PKCE via Windows CNG, one-shot loopback listener, refresh token in the Credential Manager, Cancel for Google error pages that never redirect back. Read-only scopes: events and the calendar list. - Reads every calendar shown in Google Calendar, not just the primary one; rows take their calendar's colour, with Join for the meeting that's on. - Reminders ring when Google Calendar would: the event's own reminders, else its calendar's defaults, else the primary calendar's, else five minutes. They open a card with the event, time and place and Join / Open / OK, or wait as a badge when the island is busy. Also: attention events (amber badge), shared date helpers in time.rs, dev/integrations-preview.html to check the cards with fake data, and the privacy policy, README and INTEGRATIONS.md updated. --- docs/INTEGRATIONS.md | 20 + docs/privacy.html | 12 +- windows/README.md | 49 +- windows/dev/integrations-preview.html | 22 + windows/dev/integrations-preview.ts | 169 +++++ windows/src-tauri/Cargo.toml | 1 + windows/src-tauri/src/gcal.rs | 892 ++++++++++++++++++++++++++ windows/src-tauri/src/github.rs | 775 ++++++++++++++++++++++ windows/src-tauri/src/integrations.rs | 98 +-- windows/src-tauri/src/lib.rs | 23 + windows/src-tauri/src/pipe.rs | 5 + windows/src-tauri/src/secrets.rs | 5 + windows/src-tauri/src/time.rs | 79 +++ windows/src/core/bridge.ts | 19 +- windows/src/core/layout.ts | 2 + windows/src/core/state.ts | 10 +- windows/src/island/integrations.ts | 46 +- windows/src/island/island.ts | 11 +- windows/src/settings/main.ts | 100 ++- windows/src/style.css | 46 ++ windows/src/views/integrations.ts | 216 ++++++- windows/src/views/views.ts | 73 +++ 22 files changed, 2566 insertions(+), 107 deletions(-) create mode 100644 windows/dev/integrations-preview.html create mode 100644 windows/dev/integrations-preview.ts create mode 100644 windows/src-tauri/src/gcal.rs create mode 100644 windows/src-tauri/src/github.rs create mode 100644 windows/src-tauri/src/time.rs diff --git a/docs/INTEGRATIONS.md b/docs/INTEGRATIONS.md index d598601647..4d538fe942 100644 --- a/docs/INTEGRATIONS.md +++ b/docs/INTEGRATIONS.md @@ -156,3 +156,23 @@ Permissions : Enregistrement de l'écran (capture) et Automatisation (navigateur | Micro + Reconnaissance vocale (optionnel) | dictée | premier clic sur le micro | Aucune permission Accessibilité nécessaire. + +--- + +## 8. GitHub CI / PR et Google Calendar (Windows uniquement pour l'instant) + +Code : `windows/src-tauri/src/github.rs`, `windows/src-tauri/src/gcal.rs`, cartes dans `windows/src/views/integrations.ts`. + +### GitHub +- Même jeton que la carte macOS (`github-token`, scope `repo`). Une seule requête GraphQL par minute : dépôts + étoiles, PR ouvertes du compte (avec `latestReviews`), recherche `user-review-requested:@me`, et `statusCheckRollup` du dernier commit de la branche suivie. +- **Branche suivie** = celle du `cwd` de la dernière session Claude Code (chaque hook qui porte un `cwd`), lue directement dans `.git` : `HEAD`, puis `branch..remote` / `.merge` dans `config` (le `commondir` pour un worktree). Pas de processus `git`. +- Les PR sans activité depuis 30 jours sont ignorées (les PR de bots ouvertes au nom de l'utilisateur, typiquement Snyk). +- Événements, un au plus par poll, jamais au premier poll : CI passée de « en cours » à terminée sur la même branche et le même commit ; nouvelle review sur une de ses PR ; nouvelle review demandée. + +### Google Calendar +- Pas de serveur Coucou, donc pas de client OAuth partagé : l'utilisateur crée un client OAuth « Desktop app » dans son propre projet Google Cloud et colle l'ID + le secret dans les Réglages. Écran de consentement à passer « In production », sinon Google invalide le refresh token au bout de 7 jours. +- Flux installed-app : PKCE (S256, hash et aléa via CNG de Windows), écoute ponctuelle sur `127.0.0.1:`, consentement dans le navigateur, vérification de `state`. Seul le refresh token est gardé (Gestionnaire d'identifiants) ; le jeton d'accès reste en mémoire. +- Scopes `calendar.events.readonly` + `calendar.calendarlist.readonly`. Tous les agendas cochés dans Google Calendar (`selected`, plus le principal), pas seulement le principal — les événements vivent souvent dans un agenda partagé ou importé. Liste rafraîchie toutes les 10 min, événements des 7 jours à venir chaque minute, dédoublonnés et triés ; chaque ligne prend la couleur de son agenda. Un jeton antérieur au scope `calendarlist` (403) retombe sur l’agenda principal jusqu’à la prochaine connexion. Les événements refusés et les « working location » sont ignorés. +- Rappels aux mêmes moments que Google Calendar : rappels propres de l'événement (`reminders.overrides`, y compris « aucun »), sinon ceux de son agenda (`defaultReminders`), sinon ceux de l'agenda principal (les agendas importés n'en ont pas), sinon 5 min. Seul le dernier rappel échu sonne (après un redémarrage, pas de rafale), un par poll. Les événements « toute la journée » ne sonnent pas. +- Le rappel ouvre la vue `reminder` (titre, « dans 30 min », horaire, lieu ; **Join** si visio, **Open**, **OK**), son `approval`. Si l'île est occupée (chat, approbation, dépôt de fichier), il reste un badge ambre sur la pill Calendar. +- « Disconnect » révoque le jeton chez Google puis l'efface. diff --git a/docs/privacy.html b/docs/privacy.html index 2593e4e2b5..b1951e5282 100644 --- a/docs/privacy.html +++ b/docs/privacy.html @@ -5,7 +5,7 @@

Privacy Policy

-
Last updated: September 30, 2026
+
Last updated: October 1, 2026
In short: Coucou has no account, no analytics, no ads and no tracking. The developer receives none of your data. Everything stays on your computer (Mac or PC), except what you choose to send to the services you connect yourself.

1. Who is responsible

@@ -17,7 +17,7 @@

2. Data we collect

3. Data stored on your computer

- + @@ -29,6 +29,8 @@

4. Services you connect yourself

  • Anthropic (Claude API) — when you chat with Claude or ask about a file or a window, the text you type, the file content and/or the window context you chose are sent to Anthropic. Anthropic privacy policy.
  • Stripe, GitHub, Vercel, Resend, Notion, Cal.com, n8n — Coucou reads data from your own accounts (payments, repositories, deployments, emails, pages, bookings, workflow runs) to display them. Coucou only reads from them.
  • +
  • GitHub, on Windows — besides your repositories, Coucou reads your open pull requests, their reviews, the reviews requested from you, and the CI checks of the branch your last Claude Code session is on. It finds that branch by reading the project’s local .git folder; nothing about it is sent anywhere but GitHub.
  • +
  • Google Calendar, on Windows — once you connect your Google account with your own OAuth client, Coucou reads the list of your calendars and the events of the ones you show in Google Calendar, for the coming week (read-only access) to show them and remind you before a meeting. Google privacy policy.

We have no access to these requests or their responses.

@@ -54,7 +56,7 @@

10. Changes


Politique de confidentialité

-
Dernière mise à jour : 30 septembre 2026
+
Dernière mise à jour : 1er octobre 2026
En bref : Coucou n’a ni compte, ni statistiques, ni publicité, ni pistage. Le développeur ne reçoit aucune de tes données. Tout reste sur ton ordinateur (Mac ou PC), sauf ce que tu choisis d’envoyer aux services que tu connectes toi-même.

1. Responsable

@@ -66,7 +68,7 @@

2. Données collectées

3. Données stockées sur ton ordinateur

WhatWhereWhy
API keys and tokens you enter (Anthropic, Stripe, GitHub, Vercel, Resend, Notion, Cal.com, n8n)macOS Keychain, or Windows Credential ManagerTo call these services on your behalf
API keys and tokens you enter (Anthropic, Stripe, GitHub, Vercel, Resend, Notion, Cal.com, n8n, and on Windows your Google OAuth client and the sign-in token Google returns)macOS Keychain, or Windows Credential ManagerTo call these services on your behalf
Preferences (sound, delays, active integrations, shortcut)App preferences on your Mac, or %APPDATA%\Coucou on WindowsTo remember your settings
Files you drop on the notchA copy in the app’s own folder (on Windows: %LOCALAPPDATA%\Coucou)So you can ask a question about them or attach them to an email
Claude Code session events (project name, current step, permission requests)In memory onlyTo show them in the notch
- + @@ -78,6 +80,8 @@

4. Services que tu connectes toi-même

  • Anthropic (API Claude) : quand tu discutes avec Claude ou poses une question sur un fichier ou une fenêtre, le texte saisi, le contenu du fichier et/ou le contexte de la fenêtre choisie sont envoyés à Anthropic. Politique de confidentialité d’Anthropic.
  • Stripe, GitHub, Vercel, Resend, Notion, Cal.com, n8n : Coucou lit les données de tes propres comptes (paiements, dépôts, déploiements, e-mails, pages, rendez-vous, exécutions de workflows) pour les afficher. Coucou ne fait que lire.
  • +
  • GitHub, sous Windows : en plus de tes dépôts, Coucou lit tes pull requests ouvertes, leurs reviews, les reviews qu’on te demande, et les checks CI de la branche de ta dernière session Claude Code. Il trouve cette branche en lisant le dossier .git local du projet ; rien n’en part ailleurs que chez GitHub.
  • +
  • Google Calendar, sous Windows : une fois ton compte Google connecté avec ton propre client OAuth, Coucou lit la liste de tes agendas et les événements de ceux que tu affiches dans Google Calendar, pour la semaine à venir (accès en lecture seule) pour les afficher et te prévenir avant une réunion. Politique de confidentialité de Google.

Le développeur n’a accès ni à ces requêtes ni à leurs réponses.

diff --git a/windows/README.md b/windows/README.md index de57792c9c..843686dd32 100644 --- a/windows/README.md +++ b/windows/README.md @@ -77,6 +77,49 @@ only ask whether a key exists. Same for every integration key. No telemetry. The only network requests Coucou makes are to the services you configure yourself. +## GitHub + +A classic token with the `repo` scope. The card shows, most pressing first: + +- **CI of the branch you're on**: the branch of your last Claude Code session, + read from the project's `.git` folder (worktrees included). Passing, failing + (with the failing check), running, or not pushed yet. Click it to open the + failing job. +- **Reviews asked of you**. +- **Your open pull requests** and where their review stands. Pull requests idle + for more than 30 days are left out. + +Mochi speaks up when a CI run it watched finishes, when someone reviews one of +your pull requests, and when a review is asked of you. It checks once a minute, +with a single GraphQL query. + +## Google Calendar + +Coucou has no server, so there is no shared "Sign in with Google": it signs in +with your own OAuth client. + +1. In the [Google Cloud console](https://console.cloud.google.com/apis/credentials), + create a project and enable the **Google Calendar API**. +2. Set up the OAuth consent screen and publish it **In production**. Left in + *Testing*, Google signs you out every 7 days. +3. Create an **OAuth client ID** of type **Desktop app**. +4. In **Settings… → Google Calendar**, paste the client ID and secret, then click + **Connect Google account…** and approve in the browser. + +Coucou asks for read-only access to your events and your calendar list. It reads +every calendar you show in Google Calendar, shared and subscribed ones included, +and colours each row like its calendar. It keeps only the refresh token, +in the Credential Manager. The pill shows your next events with a **Join** button +for the meeting that's on. + +Mochi reminds you when Google Calendar would: at each event's own reminders, or +its calendar's defaults, or your main calendar's defaults for calendars that have +none (imported ones), or five minutes before as a last resort. The island opens +on a card with the event, its time and place, and **Join** / **Open** / **OK**. If +you are in the middle of a chat or an approval, it waits as a badge on the +Calendar pill instead. All-day events don't ring. +**Disconnect** revokes the access at Google. + ## Build it yourself You need [Rust](https://rustup.rs), [Node 20+](https://nodejs.org), and the @@ -93,7 +136,9 @@ npm run pack # builds the installer and drops it in windows/release/ `npm run dev` alone serves the front end in an ordinary browser, which is enough to work on the island's looks. It also serves `dev/upload-preview.html`, which replays the whole file-drop choreography on a loop — the one part of the UI that -otherwise needs a real drag from Explorer to see. Neither page ships in the app. +otherwise needs a real drag from Explorer to see — and +`dev/integrations-preview.html`, which feeds the GitHub and Calendar cards fake +data. None of these pages ship in the app. `npm run pack` leaves two files in `windows/release/`, the same names the release workflow publishes: @@ -146,3 +191,5 @@ problems. It stays on your machine. attach it as context, and jumping to a specific terminal window — "Open terminal" opens the working folder in VS Code when `code` is on your `PATH`. - Cal.com shows the next bookings as a list rather than the Mac's calendar. +- Windows only, for now: GitHub CI and pull requests (the Mac card shows stars + and repositories), and Google Calendar. diff --git a/windows/dev/integrations-preview.html b/windows/dev/integrations-preview.html new file mode 100644 index 0000000000..16e53a0b9d --- /dev/null +++ b/windows/dev/integrations-preview.html @@ -0,0 +1,22 @@ + + + + + Coucou — integration cards preview + + + +
+
+
+ + + diff --git a/windows/dev/integrations-preview.ts b/windows/dev/integrations-preview.ts new file mode 100644 index 0000000000..91c927147d --- /dev/null +++ b/windows/dev/integrations-preview.ts @@ -0,0 +1,169 @@ +// Dev harness: the overview with fake GitHub and Google Calendar data, so the +// cards can be checked (and screenshotted) without tokens or a live session. +// Not part of the app bundle. + +import "../src/style.css"; +import { State } from "../src/core/state"; +import { h } from "../src/views/dom"; +import { buildViews, type ViewActions } from "../src/views/views"; +import { tickMiniBots } from "../src/mochi/minibots"; + +const noop = () => {}; +const actions: ViewActions = { + setView: noop, collapse: noop, setFocus: (id) => State.setFocus(id), openTerminal: noop, + openTarget: noop, openUrl: noop, decide: noop, toggleSound: noop, setVolume: noop, + setAutoClose: noop, openSettingsWindow: noop, blip: noop, +}; + +State.settings.activeIntegrations = ["integration_github", "integration_gcal", "integration_vercel"]; +State.loadIntegrationTasks(); + +const views = buildViews(actions, noop); +const overview = views.get("overview")!; +const reminderView = views.get("reminder")!; +overview.el.classList.add("on"); +const viewsEl = h("div", { id: "views" }, overview.el, reminderView.el); +document.getElementById("stage")!.append(viewsEl); + +/** Which of the two views the stage shows. */ +function show(which: "overview" | "reminder") { + overview.el.classList.toggle("on", which === "overview"); + reminderView.el.classList.toggle("on", which === "reminder"); +} + +const min = 60_000; +const now = Date.now(); +const at = (offsetMin: number, lengthMin = 30) => ({ + startMs: now + offsetMin * min, + endMs: now + (offsetMin + lengthMin) * min, + start: new Date(now + offsetMin * min).toISOString(), +}); + +const loaded = (data: Record, error: string | null = null) => ({ + data, error, loaded: true, configured: true, +}); + +const GITHUB_BASE = { login: "jhoan", totalRepos: 14, totalStars: 37 }; + +const scenarios: Record void> = { + "GitHub · CI failing": () => { + State.integrations.integration_github = loaded({ + ...GITHUB_BASE, + branch: { repo: "Louis-CFM/coucou", branch: "feat/windows-github-ci-calendar", pushed: true, state: "FAILURE", failing: ["build-windows"], url: "", pr: 8 }, + requests: [{ repo: "Louis-CFM/coucou", number: 9, title: "Mac: approval view polish", url: "", author: "louis" }], + pulls: [ + { repo: "Louis-CFM/coucou", number: 8, title: "Windows: GitHub CI and Calendar", url: "", draft: false, decision: null }, + { repo: "Louis-CFM/coucou", number: 7, title: "Windows: fix wake strip", url: "", draft: false, decision: "APPROVED" }, + ], + }); + State.setFocus("integration_github"); + }, + "GitHub · running": () => { + State.integrations.integration_github = loaded({ + ...GITHUB_BASE, + branch: { repo: "Louis-CFM/coucou", branch: "fix/windows-wake-strip", pushed: true, state: "PENDING", failing: [], url: "", pr: 7 }, + requests: [], + pulls: [ + { repo: "Louis-CFM/coucou", number: 7, title: "Windows: fix wake strip", url: "", draft: false, decision: "CHANGES_REQUESTED" }, + { repo: "jhoan/dotfiles", number: 3, title: "Add PowerShell profile", url: "", draft: true, decision: null }, + ], + }); + State.setFocus("integration_github"); + }, + "GitHub · passing, no PR": () => { + State.integrations.integration_github = loaded({ + ...GITHUB_BASE, + branch: { repo: "Louis-CFM/coucou", branch: "main", pushed: true, state: "SUCCESS", failing: [], url: "", pr: null }, + requests: [], pulls: [], + }); + State.setFocus("integration_github"); + }, + "GitHub · nothing on": () => { + State.integrations.integration_github = loaded({ ...GITHUB_BASE, branch: null, requests: [], pulls: [] }); + State.setFocus("integration_github"); + }, + "Calendar · meeting in 4 min": () => { + State.integrations.integration_gcal = loaded({ + events: [ + { id: "a", title: "Daily standup", ...at(4, 15), allDay: false, meetUrl: "https://meet.google.com/x", url: "" }, + { id: "b", title: "1:1 with Louis", ...at(95), allDay: false, meetUrl: null, url: "" }, + { id: "c", title: "Release Coucou 0.2", ...at(60 * 24 * 2), allDay: false, meetUrl: null, url: "" }, + { id: "d", title: "Holiday", start: "2026-10-12", startMs: null, endMs: null, allDay: true, url: "" }, + ], + }); + State.setFocus("integration_gcal"); + }, + "Calendar · in a meeting": () => { + State.integrations.integration_gcal = loaded({ + events: [ + { id: "a", title: "Design review — notch island", ...at(-10, 45), allDay: false, meetUrl: "https://meet.google.com/x", url: "" }, + { id: "b", title: "Lunch", ...at(50, 60), allDay: false, meetUrl: null, url: "" }, + ], + }); + State.setFocus("integration_gcal"); + }, + "Calendar · empty": () => { + State.integrations.integration_gcal = loaded({ events: [] }); + State.setFocus("integration_gcal"); + }, + "Calendar · not connected": () => { + State.integrations.integration_gcal = { data: {}, error: null, loaded: true, configured: false }; + State.setFocus("integration_gcal"); + }, + "Calendar · sign-in expired": () => { + State.integrations.integration_gcal = { data: {}, error: "Google sign-in expired — reconnect in Settings", loaded: false, configured: true }; + State.setFocus("integration_gcal"); + }, + "Reminder · casino, 30 min": () => { + State.reminder = { + id: "poker", title: "THE ONE BOUNTY", ...at(30, 240), allDay: false, meetUrl: null, url: "https://calendar.google.com", + color: "#9fe1e7", calendar: "casino Zaragoza", + location: "Casino Zaragoza, C/ Marqués de Casa Jiménez, 11, Zaragoza, 50004, Spain", + }; + show("reminder"); + }, + "Reminder · video call, 2 min": () => { + State.reminder = { + id: "standup", title: "Daily standup with a much longer title than fits", ...at(2, 15), allDay: false, + meetUrl: "https://meet.google.com/x", url: "https://calendar.google.com", color: "#16a765", calendar: "jhoang956@gmail.com", + location: null, + }; + show("reminder"); + }, + "Badges: review asked + meeting": () => { + State.setFocus("integration_vercel"); + State.setPillBadge("integration_github", "approval"); + State.setPillBadge("integration_gcal", "approval"); + }, +}; + +const controls = document.getElementById("controls")!; +const caption = document.getElementById("caption")!; +for (const [name, run] of Object.entries(scenarios)) { + controls.append(h("button", { + text: name, + onclick: () => { + for (const t of State.tasks) t.pillBadge = null; + show("overview"); + run(); + caption.textContent = name; + State.notify(); + }, + })); +} + +State.subscribe(() => { + overview.sync(); + reminderView.sync(); +}); +scenarios["GitHub · CI failing"](); +caption.textContent = "GitHub · CI failing"; +State.notify(); + +let last = performance.now(); +function loop(t: number) { + tickMiniBots((t - last) / 1000); + last = t; + requestAnimationFrame(loop); +} +requestAnimationFrame(loop); diff --git a/windows/src-tauri/Cargo.toml b/windows/src-tauri/Cargo.toml index 0274d9f5c7..ec5b0d174c 100644 --- a/windows/src-tauri/Cargo.toml +++ b/windows/src-tauri/Cargo.toml @@ -26,6 +26,7 @@ windows = { version = "0.61", features = [ "Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", + "Win32_Security_Cryptography", "Win32_System_Ole", "Win32_System_SystemInformation", "Win32_System_Threading", diff --git a/windows/src-tauri/src/gcal.rs b/windows/src-tauri/src/gcal.rs new file mode 100644 index 0000000000..2a7c308e95 --- /dev/null +++ b/windows/src-tauri/src/gcal.rs @@ -0,0 +1,892 @@ +// Google Calendar — the next few events on your primary calendar, and a nudge +// five minutes before a meeting starts. +// +// There is no Coucou server, so there is no shared "Sign in with Google": the +// user creates a Desktop OAuth client in their own Google Cloud project and +// pastes its ID and secret in Settings. Connecting then runs the installed-app +// flow Google documents for desktop apps — PKCE, a one-shot listener on +// 127.0.0.1, the browser for consent — and keeps only the refresh token, in the +// Credential Manager. Access tokens live in memory for their hour. +// +// Read-only scope. Nothing is ever written to the calendar. + +use std::collections::HashSet; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{LazyLock, Mutex}; +use std::time::{Duration, Instant}; + +use serde_json::{json, Value}; +use tauri::AppHandle; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; + +use crate::integrations::{client, emit, IntegrationEvent, IntegrationUpdate}; +use crate::log; +use crate::secrets; +use crate::time::{now_secs, parse_rfc3339, rfc3339_utc}; + +const ID: &str = "integration_gcal"; +/// Read-only: events, and the list of calendars to read them from. +const SCOPE: &str = concat!( + "https://www.googleapis.com/auth/calendar.events.readonly ", + "https://www.googleapis.com/auth/calendar.calendarlist.readonly", +); +const AUTH_URL: &str = "https://accounts.google.com/o/oauth2/v2/auth"; +const TOKEN_URL: &str = "https://oauth2.googleapis.com/token"; +const REVOKE_URL: &str = "https://oauth2.googleapis.com/revoke"; + +/// Minutes before an event Mochi speaks up when neither the event, its +/// calendar nor your primary calendar sets any reminder. +const FALLBACK_LEAD: i64 = 5; +/// How long the browser has to come back with a code. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(5 * 60); + +const EXPIRED: &str = "Google sign-in expired — reconnect in Settings"; + +// ── Connecting ──────────────────────────────────────────────────────────────── + +/// Bumped by every Connect and Cancel click, so a newer attempt (or a cancel) +/// retires an older listener still waiting on a browser tab the user closed. +/// Google's own error pages — "access blocked" for an app in Testing whose user +/// isn't on the test list — never redirect back, so without this the wait could +/// only end at the timeout. +static ATTEMPT: AtomicU64 = AtomicU64::new(0); + +/// Settings → Cancel: stops waiting for Google within a second. +pub fn cancel() { + ATTEMPT.fetch_add(1, Ordering::SeqCst); +} + +pub async fn connect(app: AppHandle) -> Result<(), String> { + let (Some(client_id), Some(client_secret)) = + (secrets::get("gcal-client-id"), secrets::get("gcal-client-secret")) + else { + return Err("Save the client ID and client secret first.".into()); + }; + let attempt = ATTEMPT.fetch_add(1, Ordering::SeqCst) + 1; + + let listener = TcpListener::bind("127.0.0.1:0").await.map_err(|e| e.to_string())?; + let port = listener.local_addr().map_err(|e| e.to_string())?.port(); + let redirect = format!("http://127.0.0.1:{port}"); + + let verifier = b64url(&random_bytes(32)?); + let challenge = b64url(&sha256(verifier.as_bytes())?); + let state = b64url(&random_bytes(16)?); + + let url = format!( + "{AUTH_URL}?{}", + form(&[ + ("client_id", &client_id), + ("redirect_uri", &redirect), + ("response_type", "code"), + ("scope", SCOPE), + ("code_challenge", &challenge), + ("code_challenge_method", "S256"), + ("state", &state), + // A refresh token, every time — Google only hands one out on consent. + ("access_type", "offline"), + ("prompt", "consent"), + ]) + ); + crate::open_url(url); + log::line("gcal: waiting for the browser"); + + let code = wait_for_code(&listener, &state, attempt).await?; + drop(listener); + + let response = client() + .post(TOKEN_URL) + .header("Content-Type", "application/x-www-form-urlencoded") + .body(form(&[ + ("code", &code), + ("client_id", &client_id), + ("client_secret", &client_secret), + ("redirect_uri", &redirect), + ("grant_type", "authorization_code"), + ("code_verifier", &verifier), + ])) + .send() + .await + .map_err(|e| e.to_string())?; + let status = response.status(); + let body: Value = response.json().await.unwrap_or(json!({})); + if !status.is_success() { + return Err(google_error(&body).unwrap_or_else(|| format!("Google answered {status}"))); + } + let refresh = body["refresh_token"] + .as_str() + .ok_or("Google didn't return a refresh token.")?; + secrets::set("gcal-refresh-token", refresh)?; + remember_access(&body); + REMINDED.lock().unwrap().clear(); + *CALENDARS.lock().unwrap() = None; + log::line("gcal: connected"); + + poll(app).await; + Ok(()) +} + +/// Serves the one redirect Google sends back, checking `state`, and answers the +/// browser with a page saying it can be closed. Stray requests (a favicon, a +/// stale tab) get a 404 and the wait goes on. +async fn wait_for_code(listener: &TcpListener, state: &str, attempt: u64) -> Result { + let deadline = Instant::now() + CONNECT_TIMEOUT; + loop { + if ATTEMPT.load(Ordering::SeqCst) != attempt { + return Err("Sign-in cancelled.".into()); + } + if Instant::now() >= deadline { + return Err("Timed out waiting for Google.".into()); + } + let Ok(Ok((mut stream, _))) = + tokio::time::timeout(Duration::from_secs(1), listener.accept()).await + else { + continue; + }; + + let mut buf = Vec::with_capacity(2048); + let mut chunk = [0u8; 2048]; + while buf.len() < 16 * 1024 && !buf.windows(4).any(|w| w == b"\r\n\r\n") { + match tokio::time::timeout(Duration::from_secs(5), stream.read(&mut chunk)).await { + Ok(Ok(n)) if n > 0 => buf.extend_from_slice(&chunk[..n]), + _ => break, + } + } + let request = String::from_utf8_lossy(&buf); + let target = request + .lines() + .next() + .and_then(|l| l.strip_prefix("GET ")) + .and_then(|l| l.split(' ').next()) + .unwrap_or(""); + let (path, query) = target.split_once('?').unwrap_or((target, "")); + let params = parse_query(query); + let param = |k: &str| params.iter().find(|(key, _)| key == k).map(|(_, v)| v.as_str()); + + if path != "/" || param("state") != Some(state) { + let _ = respond(&mut stream, "404 Not Found", "Nothing here.").await; + continue; + } + if let Some(error) = param("error") { + let _ = respond(&mut stream, "200 OK", "Coucou wasn't given access. You can close this tab.").await; + return Err(format!("Google sign-in cancelled ({error}).")); + } + if let Some(code) = param("code") { + let _ = respond( + &mut stream, + "200 OK", + "Coucou is connected to Google Calendar. You can close this tab.", + ) + .await; + return Ok(code.to_string()); + } + let _ = respond(&mut stream, "400 Bad Request", "Missing code.").await; + } +} + +async fn respond(stream: &mut tokio::net::TcpStream, status: &str, message: &str) -> std::io::Result<()> { + let html = format!( + "Coucou\ +

{message}

" + ); + let reply = format!( + "HTTP/1.1 {status}\r\nContent-Type: text/html; charset=utf-8\r\n\ + Content-Length: {}\r\nConnection: close\r\nCache-Control: no-store\r\n\r\n{html}", + html.len() + ); + stream.write_all(reply.as_bytes()).await?; + stream.shutdown().await +} + +/// Revokes the grant at Google (best effort) and forgets the token. +pub async fn disconnect(app: AppHandle) -> Result<(), String> { + ATTEMPT.fetch_add(1, Ordering::SeqCst); + if let Some(refresh) = secrets::get("gcal-refresh-token") { + let _ = client() + .post(REVOKE_URL) + .header("Content-Type", "application/x-www-form-urlencoded") + .body(form(&[("token", &refresh)])) + .send() + .await; + } + secrets::clear("gcal-refresh-token")?; + *ACCESS.lock().unwrap() = None; + REMINDED.lock().unwrap().clear(); + *CALENDARS.lock().unwrap() = None; + emit(&app, IntegrationUpdate { id: ID, data: json!({}), error: None, event: None }); + Ok(()) +} + +// ── Access tokens ───────────────────────────────────────────────────────────── + +static ACCESS: Mutex> = Mutex::new(None); + +fn remember_access(body: &Value) { + if let Some(token) = body["access_token"].as_str() { + let ttl = body["expires_in"].as_u64().unwrap_or(3600); + // A minute early, so a token never expires between check and use. + let until = Instant::now() + Duration::from_secs(ttl.saturating_sub(60)); + *ACCESS.lock().unwrap() = Some((token.to_string(), until)); + } +} + +async fn access_token(refresh: &str) -> Result { + if let Some((token, until)) = ACCESS.lock().unwrap().clone() { + if Instant::now() < until { + return Ok(token); + } + } + let (Some(client_id), Some(client_secret)) = + (secrets::get("gcal-client-id"), secrets::get("gcal-client-secret")) + else { + return Err("Client ID or secret missing".into()); + }; + let response = client() + .post(TOKEN_URL) + .header("Content-Type", "application/x-www-form-urlencoded") + .body(form(&[ + ("client_id", &client_id), + ("client_secret", &client_secret), + ("refresh_token", refresh), + ("grant_type", "refresh_token"), + ])) + .send() + .await + .map_err(|_| String::new())?; // offline: say nothing, try again next poll + let status = response.status(); + let body: Value = response.json().await.unwrap_or(json!({})); + if !status.is_success() { + // invalid_grant: revoked, or the 7-day expiry of an OAuth app left in + // "Testing" — either way only a new sign-in fixes it. + if body["error"].as_str() == Some("invalid_grant") { + return Err(EXPIRED.into()); + } + return Err(google_error(&body).unwrap_or_else(|| format!("Google answered {status}"))); + } + remember_access(&body); + body["access_token"].as_str().map(str::to_string).ok_or_else(|| "No access token".into()) +} + +fn google_error(body: &Value) -> Option { + body.pointer("/error/message") + .or_else(|| body.get("error_description")) + .or_else(|| body.get("error")) + .and_then(Value::as_str) + .map(|s| s.chars().take(90).collect()) +} + +// ── Polling ─────────────────────────────────────────────────────────────────── + +/// "id@start" of every meeting already announced, so each is announced once +/// (and again if it is moved). +static REMINDED: LazyLock>> = LazyLock::new(|| Mutex::new(HashSet::new())); + +/// A calendar to read: the ones ticked in Google Calendar's sidebar. +#[derive(Clone, Debug)] +struct Calendar { + id: String, + name: String, + color: Option, + primary: bool, +} + +/// The calendar list barely changes: fetched every ten minutes, not every poll. +static CALENDARS: Mutex, Instant)>> = Mutex::new(None); +const CALENDARS_TTL: Duration = Duration::from_secs(10 * 60); + +/// GET with the cached access token, retrying once with a fresh one when it +/// turns out to have been revoked. `Err(None)`: offline, say nothing and try +/// again next poll. `Err(Some(_))`: a sign-in problem worth showing. +async fn get_json(refresh: &str, url: &str) -> Result<(u16, Value), Option> { + for _ in 0..2 { + let token = access_token(refresh).await.map_err(|e| Some(e).filter(|e| !e.is_empty()))?; + let response = client().get(url).bearer_auth(token).send().await.map_err(|_| None)?; + let status = response.status().as_u16(); + let json: Value = response.json().await.unwrap_or(json!({})); + if status == 401 { + *ACCESS.lock().unwrap() = None; + continue; + } + return Ok((status, json)); + } + Err(Some(EXPIRED.into())) +} + +/// Every calendar shown in Google Calendar — your own, shared and subscribed +/// ones alike — not just the primary one, which is often the emptiest. +async fn calendars(refresh: &str) -> Result, Option> { + if let Some((list, at)) = CALENDARS.lock().unwrap().clone() { + if at.elapsed() < CALENDARS_TTL { + return Ok(list); + } + } + let url = "https://www.googleapis.com/calendar/v3/users/me/calendarList?minAccessRole=reader&maxResults=50"; + let (status, json) = get_json(refresh, url).await?; + let list = if status == 403 { + // A token from before Coucou asked for the calendar list: the primary + // calendar is all it may read until the next Connect. + log::line("gcal: no calendar-list access, reading the primary calendar only"); + vec![Calendar { id: "primary".into(), name: String::new(), color: None, primary: true }] + } else if !(200..300).contains(&status) { + return Err(Some(google_error(&json).unwrap_or_else(|| format!("API error {status}")))); + } else { + json["items"] + .as_array() + .map(|items| { + items + .iter() + .filter(|c| { + let shown = c["selected"].as_bool() == Some(true) || c["primary"].as_bool() == Some(true); + shown && c["hidden"].as_bool() != Some(true) + }) + .filter_map(|c| { + Some(Calendar { + id: c["id"].as_str()?.to_string(), + // What you renamed it to, else its own name. + name: c["summaryOverride"] + .as_str() + .or_else(|| c["summary"].as_str()) + .unwrap_or_default() + .to_string(), + color: c["backgroundColor"].as_str().map(str::to_string), + primary: c["primary"].as_bool() == Some(true), + }) + }) + .collect::>() + }) + .unwrap_or_default() + }; + let mut list = list; + // The primary calendar first: its default reminders stand in for calendars + // that have none (an imported calendar never does). + list.sort_by_key(|c| !c.primary); + *CALENDARS.lock().unwrap() = Some((list.clone(), Instant::now())); + Ok(list) +} + +pub async fn poll(app: AppHandle) { + let Some(refresh) = secrets::get("gcal-refresh-token") else { return }; + let report = |error: String| { + emit(&app, IntegrationUpdate { id: ID, data: json!({}), error: Some(error), event: None }); + }; + + let list = match calendars(&refresh).await { + Ok(list) => list, + Err(None) => return, + Err(Some(e)) => return report(e), + }; + + let now = now_secs(); + let query = form(&[ + // Events ending after now: what's on right now counts. + ("timeMin", &rfc3339_utc(now)), + ("timeMax", &rfc3339_utc(now + 7 * 86_400)), + ("singleEvents", "true"), + ("orderBy", "startTime"), + ("maxResults", "10"), + ]); + + let mut events: Vec = Vec::new(); + let mut read_any = false; + let mut first_error = None; + // Your main calendar's default reminders; the last resort is FALLBACK_LEAD. + let mut primary_defaults: Vec = Vec::new(); + for cal in &list { + let url = format!("https://www.googleapis.com/calendar/v3/calendars/{}/events?{query}", pct(&cal.id)); + match get_json(&refresh, &url).await { + Ok((status, json)) if (200..300).contains(&status) => { + read_any = true; + let mut defaults = reminder_minutes(&json["defaultReminders"]); + if cal.primary { + primary_defaults = defaults.clone(); + } + if defaults.is_empty() { + defaults = if primary_defaults.is_empty() { vec![FALLBACK_LEAD] } else { primary_defaults.clone() }; + } + // The events response knows the calendar's real name even when + // the list was a fallback. + let mut cal = cal.clone(); + if cal.name.is_empty() { + cal.name = json["summary"].as_str().unwrap_or_default().to_string(); + } + if let Some(items) = json["items"].as_array() { + events.extend(items.iter().filter_map(|i| Event::parse(i, &cal, &defaults))); + } + } + Ok((status, json)) => { + // One unreadable calendar (an import gone stale) shouldn't hide + // the others. Typically "Google Calendar API has not been used…". + log::line(format!("gcal: calendar HTTP {status}")); + first_error.get_or_insert_with(|| google_error(&json).unwrap_or_else(|| format!("API error {status}"))); + } + Err(None) => return, + Err(Some(e)) => return report(e), + } + } + if !read_any { + if let Some(e) = first_error { + return report(e); + } + } + + // An invitation can sit in two calendars at once. + let mut seen = HashSet::new(); + events.retain(|e| seen.insert(e.id.clone())); + events.sort_by_key(Event::sort_key); + + let event = reminder(&events, now); + emit(&app, IntegrationUpdate { + id: ID, + data: json!({ "events": events.iter().take(8).map(Event::to_json).collect::>() }), + error: None, + event, + }); +} + +/// Minutes from a Calendar `reminders` list (`[{method, minutes}]`), ascending. +/// Email and pop-up alike: either way you asked to be told then. +fn reminder_minutes(list: &Value) -> Vec { + let mut out: Vec = list + .as_array() + .map(|l| l.iter().filter_map(|r| r["minutes"].as_i64()).filter(|m| *m >= 0).collect()) + .unwrap_or_default(); + out.sort_unstable(); + out.dedup(); + out +} + +/// The reminder that has just come due, the way Google Calendar itself would +/// ring it: at each of the event's reminder times. +/// +/// Only the latest reminder due is ever shown — after a restart, a meeting in +/// 20 minutes says "in 20 min" once, not its day-before and its 30-minute +/// reminders back to back. One per poll; a second meeting due at the same +/// moment comes a minute later. +fn reminder(events: &[Event], now: i64) -> Option { + let mut reminded = REMINDED.lock().unwrap(); + // Forget events that have dropped off the list, so the set stays small. + let live: HashSet = events.iter().map(Event::key).collect(); + reminded.retain(|k| live.iter().any(|l| k.starts_with(l.as_str()))); + + for e in events { + let Some(start) = e.start_secs else { continue }; + // A reminder "at the time of the event" still gets its minute. + if now >= start + 60 { + continue; + } + let due: Vec = e.reminders.iter().copied().filter(|m| start - m * 60 <= now).collect(); + let Some(latest) = due.iter().min().copied() else { continue }; + if reminded.contains(&format!("{}#{latest}", e.key())) { + continue; + } + for m in &due { + reminded.insert(format!("{}#{m}", e.key())); + } + let minutes = (start - now + 59).div_euclid(60).max(0); + let mut item = e.to_json(); + item["minutes"] = json!(minutes); + return Some(IntegrationEvent { + success: true, + label: e.title.clone(), + detail: Some(starts_in(minutes)), + attention: true, + item: Some(item), + }); + } + None +} + +fn starts_in(minutes: i64) -> String { + match minutes { + ..=0 => "Starting now".into(), + 1 => "Starts in a minute".into(), + 2..=59 => format!("Starts in {minutes} min"), + 60..=1439 => match (minutes / 60, minutes % 60) { + (h, 0) => format!("Starts in {h} h"), + (h, m) => format!("Starts in {h} h {m} min"), + }, + _ => match minutes / 1440 { + 1 => "Starts in a day".into(), + d => format!("Starts in {d} days"), + }, + } +} + +#[derive(Debug)] +struct Event { + id: String, + title: String, + /// RFC 3339 for timed events, YYYY-MM-DD for all-day ones. + start: String, + end: String, + start_secs: Option, + end_secs: Option, + all_day: bool, + meet_url: Option, + url: String, + /// The calendar's colour in Google Calendar, for the row's dot. + color: Option, + calendar: String, + location: Option, + /// Minutes before the start at which to speak up, ascending. Empty when the + /// event says "no notification". + reminders: Vec, +} + +impl Event { + /// `defaults`: the calendar's default reminders, already resolved to the + /// primary calendar's (or FALLBACK_LEAD) when it has none. + fn parse(item: &Value, calendar: &Calendar, defaults: &[i64]) -> Option { + if item["status"].as_str() == Some("cancelled") || item["eventType"].as_str() == Some("workingLocation") { + return None; + } + // Declined meetings aren't yours to go to. + let declined = item["attendees"].as_array().is_some_and(|a| { + a.iter().any(|p| p["self"].as_bool() == Some(true) && p["responseStatus"].as_str() == Some("declined")) + }); + if declined { + return None; + } + let when = |k: &str| -> (String, bool) { + match item[k]["dateTime"].as_str() { + Some(t) => (t.to_string(), false), + None => (item[k]["date"].as_str().unwrap_or_default().to_string(), true), + } + }; + let (start, all_day) = when("start"); + let (end, _) = when("end"); + if start.is_empty() { + return None; + } + let meet_url = item["hangoutLink"] + .as_str() + .map(str::to_string) + .or_else(|| { + item.pointer("/conferenceData/entryPoints")? + .as_array()? + .iter() + .find(|p| p["entryPointType"].as_str() == Some("video"))?["uri"] + .as_str() + .map(str::to_string) + }) + // Zoom and Teams links pasted into the location field. + .or_else(|| item["location"].as_str().filter(|l| l.starts_with("https://")).map(str::to_string)); + Some(Event { + id: item["id"].as_str().unwrap_or_default().to_string(), + title: item["summary"].as_str().filter(|s| !s.is_empty()).unwrap_or("(No title)").to_string(), + start_secs: if all_day { None } else { parse_rfc3339(&start) }, + end_secs: if all_day { None } else { parse_rfc3339(&end) }, + start, + end, + all_day, + meet_url, + url: item["htmlLink"].as_str().unwrap_or_default().to_string(), + color: calendar.color.clone(), + calendar: calendar.name.clone(), + location: item["location"].as_str().filter(|l| !l.is_empty()).map(str::to_string), + // Your own overrides on this event win — including "none at all". + reminders: if item["reminders"]["useDefault"].as_bool() == Some(false) { + reminder_minutes(&item["reminders"]["overrides"]) + } else { + defaults.to_vec() + }, + }) + } + + fn key(&self) -> String { + format!("{}@{}", self.id, self.start) + } + + /// Chronological across calendars; an all-day event counts from midnight UTC. + fn sort_key(&self) -> i64 { + self.start_secs + .or_else(|| parse_rfc3339(&format!("{}T00:00:00Z", self.start))) + .unwrap_or(i64::MAX) + } + + fn to_json(&self) -> Value { + json!({ + "id": self.id, + "title": self.title, + "start": self.start, + "end": self.end, + "startMs": self.start_secs.map(|s| s * 1000), + "endMs": self.end_secs.map(|s| s * 1000), + "allDay": self.all_day, + "meetUrl": self.meet_url, + "url": self.url, + "color": self.color, + "calendar": self.calendar, + "location": self.location, + }) + } +} + +// ── Small things std doesn't have ───────────────────────────────────────────── + +/// application/x-www-form-urlencoded, also good for query strings. +fn form(pairs: &[(&str, &str)]) -> String { + pairs.iter().map(|(k, v)| format!("{}={}", pct(k), pct(v))).collect::>().join("&") +} + +fn pct(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => out.push(b as char), + _ => out.push_str(&format!("%{b:02X}")), + } + } + out +} + +fn parse_query(q: &str) -> Vec<(String, String)> { + q.split('&') + .filter(|p| !p.is_empty()) + .map(|p| { + let (k, v) = p.split_once('=').unwrap_or((p, "")); + (pct_decode(k), pct_decode(v)) + }) + .collect() +} + +fn pct_decode(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'+' => out.push(b' '), + b'%' if i + 2 < bytes.len() => { + let hex = |b: u8| (b as char).to_digit(16); + match (hex(bytes[i + 1]), hex(bytes[i + 2])) { + (Some(hi), Some(lo)) => { + out.push((hi * 16 + lo) as u8); + i += 2; + } + _ => out.push(b'%'), + } + } + b => out.push(b), + } + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +fn b64url(bytes: &[u8]) -> String { + const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); + for chunk in bytes.chunks(3) { + let n = (chunk[0] as u32) << 16 | (*chunk.get(1).unwrap_or(&0) as u32) << 8 | *chunk.get(2).unwrap_or(&0) as u32; + let chars = chunk.len() + 1; + for i in 0..chars { + out.push(ALPHABET[(n >> (18 - 6 * i) & 63) as usize] as char); + } + } + out +} + +/// Windows' own CSPRNG (CNG), so PKCE needs no extra crate. +fn random_bytes(n: usize) -> Result, String> { + use windows::Win32::Security::Cryptography::{BCryptGenRandom, BCRYPT_USE_SYSTEM_PREFERRED_RNG}; + let mut buf = vec![0u8; n]; + unsafe { BCryptGenRandom(None, &mut buf, BCRYPT_USE_SYSTEM_PREFERRED_RNG) } + .ok() + .map_err(|e| e.to_string())?; + Ok(buf) +} + +fn sha256(data: &[u8]) -> Result, String> { + use windows::Win32::Security::Cryptography::{BCryptHash, BCRYPT_SHA256_ALG_HANDLE}; + let mut out = vec![0u8; 32]; + unsafe { BCryptHash(BCRYPT_SHA256_ALG_HANDLE, None, data, &mut out) } + .ok() + .map_err(|e| e.to_string())?; + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encodings() { + assert_eq!(b64url(b""), ""); + assert_eq!(b64url(b"f"), "Zg"); + assert_eq!(b64url(b"fo"), "Zm8"); + assert_eq!(b64url(b"foo"), "Zm9v"); + assert_eq!(b64url(&[0xfb, 0xff]), "-_8"); + assert_eq!(pct("a b/c:d"), "a%20b%2Fc%3Ad"); + assert_eq!(pct_decode("4%2F0Ab+x%zz%4"), "4/0Ab x%zz%4"); + let q = parse_query("state=abc&code=4%2F0AX&scope=x"); + assert_eq!(q[1], ("code".to_string(), "4/0AX".to_string())); + } + + #[test] + fn pkce_primitives() { + // FIPS 180-2's "abc" vector. + let hex: String = sha256(b"abc").unwrap().iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!(hex, "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); + let a = random_bytes(32).unwrap(); + assert_eq!(a.len(), 32); + assert_ne!(a, random_bytes(32).unwrap()); + // 32 bytes → the 43-character verifier RFC 7636 asks for. + assert_eq!(b64url(&a).len(), 43); + } + + /// The loopback listener: strays are ignored, the right redirect yields + /// its code, and Cancel ends the wait even though Google never came back. + #[test] + fn loopback_listener() { + use std::io::{Read, Write}; + let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap(); + rt.block_on(async { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + // One after the other, like a browser: the listener stops at the + // redirect it wants, so anything sent after it would go unanswered. + let browser = std::thread::spawn(move || { + ["/favicon.ico", "/?state=wrong&code=evil", "/?state=s3cr3t&code=4%2F0Abc&scope=x"] + .map(|path| { + let mut s = std::net::TcpStream::connect(("127.0.0.1", port)).unwrap(); + write!(s, "GET {path} HTTP/1.1\r\nHost: x\r\n\r\n").unwrap(); + let mut reply = String::new(); + let _ = s.read_to_string(&mut reply); + reply + }) + }); + + let attempt = ATTEMPT.fetch_add(1, Ordering::SeqCst) + 1; + let code = wait_for_code(&listener, "s3cr3t", attempt).await; + assert_eq!(code.as_deref(), Ok("4/0Abc")); + let [favicon, forged, real] = browser.join().unwrap(); + assert!(favicon.starts_with("HTTP/1.1 404")); + assert!(forged.starts_with("HTTP/1.1 404")); + assert!(real.contains("connected to Google Calendar")); + + // Google's "access blocked" page: no redirect ever arrives. + let attempt = ATTEMPT.fetch_add(1, Ordering::SeqCst) + 1; + std::thread::spawn(|| { + std::thread::sleep(Duration::from_millis(300)); + cancel(); + }); + let started = Instant::now(); + let result = wait_for_code(&listener, "s3cr3t", attempt).await; + assert_eq!(result, Err("Sign-in cancelled.".to_string())); + assert!(started.elapsed() < Duration::from_secs(3)); + }); + } + + fn cal() -> Calendar { + Calendar { id: "primary".into(), name: "Main".into(), color: Some("#16a765".into()), primary: true } + } + + #[test] + fn all_day_events_sort_among_timed_ones() { + let parse = |v: Value| Event::parse(&v, &cal(), &[5]).unwrap(); + let mut events = [ + parse(json!({ "id": "poker", "start": { "dateTime": "2026-10-02T17:30:00Z" }, "end": { "dateTime": "2026-10-02T21:30:00Z" } })), + parse(json!({ "id": "holiday", "start": { "date": "2026-10-02" }, "end": { "date": "2026-10-03" } })), + parse(json!({ "id": "today", "start": { "dateTime": "2026-10-01T19:00:00+02:00" }, "end": { "dateTime": "2026-10-01T20:00:00+02:00" } })), + ]; + events.sort_by_key(Event::sort_key); + assert_eq!(events.map(|e| e.id), ["today", "holiday", "poker"]); + } + + fn event(id: &str, start: &str) -> Value { + json!({ "id": id, "summary": id, "start": { "dateTime": start }, "end": { "dateTime": start } }) + } + + #[test] + fn events_are_filtered_and_parsed() { + let declined = json!({ + "id": "d", "start": { "dateTime": "2026-10-01T10:00:00Z" }, "end": { "dateTime": "2026-10-01T11:00:00Z" }, + "attendees": [{ "self": true, "responseStatus": "declined" }], + }); + assert!(Event::parse(&declined, &cal(), &[5]).is_none()); + let all_day = json!({ "id": "a", "start": { "date": "2026-10-02" }, "end": { "date": "2026-10-03" } }); + let e = Event::parse(&all_day, &cal(), &[5]).unwrap(); + assert!(e.all_day && e.start_secs.is_none()); + let meet = json!({ + "id": "m", "summary": "Standup", "start": { "dateTime": "2026-10-01T10:00:00Z" }, + "end": { "dateTime": "2026-10-01T10:15:00Z" }, + "conferenceData": { "entryPoints": [ + { "entryPointType": "phone", "uri": "tel:+1" }, + { "entryPointType": "video", "uri": "https://meet.google.com/abc" }, + ] }, + }); + assert_eq!(Event::parse(&meet, &cal(), &[5]).unwrap().meet_url.as_deref(), Some("https://meet.google.com/abc")); + } + + #[test] + fn reminder_minutes_are_read_and_sorted() { + let list = json!([{ "method": "popup", "minutes": 30 }, { "method": "email", "minutes": 1440 }, { "method": "popup", "minutes": 30 }]); + assert_eq!(reminder_minutes(&list), vec![30, 1440]); + assert_eq!(reminder_minutes(&Value::Null), Vec::::new()); + let mut e = event("x", "2026-10-02T17:30:00Z"); + e["reminders"] = json!({ "useDefault": false, "overrides": [] }); + // "No notification" on the event beats the calendar's defaults. + assert!(Event::parse(&e, &cal(), &[30]).unwrap().reminders.is_empty()); + e["reminders"] = json!({ "useDefault": false, "overrides": [{ "method": "popup", "minutes": 10 }] }); + assert_eq!(Event::parse(&e, &cal(), &[30]).unwrap().reminders, vec![10]); + e["reminders"] = json!({ "useDefault": true }); + assert_eq!(Event::parse(&e, &cal(), &[30, 1440]).unwrap().reminders, vec![30, 1440]); + } + + /// One test, run in order: REMINDED is shared. + #[test] + fn reminders_ring_like_google_calendar() { + let t = parse_rfc3339("2026-10-02T17:30:00Z").unwrap(); + let min = 60; + let poker = || vec![Event::parse(&event("poker", "2026-10-02T17:30:00Z"), &cal(), &[30, 1440]).unwrap()]; + + // A day ahead, then 30 minutes ahead; once each. + REMINDED.lock().unwrap().clear(); + let events = poker(); + assert!(reminder(&events, t - 25 * 60 * min).is_none()); + let e = reminder(&events, t - 1440 * min + 30).unwrap(); + assert_eq!(e.detail.as_deref(), Some("Starts in a day")); + assert!(reminder(&events, t - 1439 * min).is_none()); + assert!(reminder(&events, t - 31 * min).is_none()); + let e = reminder(&events, t - 30 * min).unwrap(); + assert_eq!((e.label.as_str(), e.detail.as_deref()), ("poker", Some("Starts in 30 min"))); + assert!(e.attention); + let item = e.item.unwrap(); + assert_eq!((item["minutes"].as_i64(), item["calendar"].as_str()), (Some(30), Some("Main"))); + assert!(reminder(&events, t - 29 * min).is_none()); + + // Started 20 minutes before the meeting: one reminder, not both. + REMINDED.lock().unwrap().clear(); + let events = poker(); + assert_eq!(reminder(&events, t - 20 * min).unwrap().detail.as_deref(), Some("Starts in 20 min")); + assert!(reminder(&events, t - 19 * min).is_none()); + + // "At the time of the event" still rings, within its minute. + REMINDED.lock().unwrap().clear(); + let events = vec![Event::parse(&event("standup", "2026-10-02T17:30:00Z"), &cal(), &[0]).unwrap()]; + assert!(reminder(&events, t - 30).is_none()); + assert_eq!(reminder(&events, t + 10).unwrap().detail.as_deref(), Some("Starting now")); + REMINDED.lock().unwrap().clear(); + assert!(reminder(&events, t + 90).is_none()); + + // Two due at once: the earlier one now, the other on the next poll. + REMINDED.lock().unwrap().clear(); + let events = vec![ + Event::parse(&event("a", "2026-10-02T17:30:00Z"), &cal(), &[30]).unwrap(), + Event::parse(&event("b", "2026-10-02T17:40:00Z"), &cal(), &[30]).unwrap(), + ]; + assert_eq!(reminder(&events, t - 5 * min).unwrap().label, "a"); + assert_eq!(reminder(&events, t - 4 * min).unwrap().label, "b"); + assert!(reminder(&events, t - 3 * min).is_none()); + } + + #[test] + fn starts_in_reads_naturally() { + assert_eq!(starts_in(1), "Starts in a minute"); + assert_eq!(starts_in(90), "Starts in 1 h 30 min"); + assert_eq!(starts_in(120), "Starts in 2 h"); + assert_eq!(starts_in(3000), "Starts in 2 days"); + } +} diff --git a/windows/src-tauri/src/github.rs b/windows/src-tauri/src/github.rs new file mode 100644 index 0000000000..cb2cf8abee --- /dev/null +++ b/windows/src-tauri/src/github.rs @@ -0,0 +1,775 @@ +// GitHub — CI for the branch you're on, and the pull requests waiting on you +// or on someone else. +// +// "The branch you're on" is the branch checked out in the folder of the last +// Claude Code session, read straight from its .git directory: no `git` process, +// no console flash, and nothing to configure. Everything else comes from one +// GraphQL query per poll, with the same token the macOS overview card uses. +// +// Events, at most one per poll, only for things that changed since the last +// poll: a CI run on that branch finishing, a new review on one of your pull +// requests, a new review requested from you. The first poll only fills the card. + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::sync::{LazyLock, Mutex}; + +use serde_json::{json, Value}; +use tauri::AppHandle; + +use crate::integrations::{client, emit, status_error, IntegrationEvent, IntegrationUpdate}; +use crate::log; +use crate::secrets; +use crate::time::{now_secs, parse_rfc3339}; + +const ID: &str = "integration_github"; +const STALE_AFTER_SECS: i64 = 30 * 86_400; + +// ── The session's branch ────────────────────────────────────────────────────── + +static SESSION_CWD: Mutex> = Mutex::new(None); + +/// Called for every Claude Code hook that carries a `cwd`. +pub fn note_cwd(cwd: &str) { + if cwd.is_empty() { + return; + } + *SESSION_CWD.lock().unwrap() = Some(PathBuf::from(cwd)); +} + +#[derive(Debug, PartialEq)] +struct BranchRef { + owner: String, + name: String, + /// The branch's name on GitHub (its upstream), which is what CI ran on. + branch: String, +} + +/// The GitHub repository and branch checked out in `cwd`, if there is one. +fn branch_of(cwd: &Path) -> Option { + let (git_dir, common_dir) = find_git_dir(cwd)?; + let head = std::fs::read_to_string(git_dir.join("HEAD")).ok()?; + // Detached HEAD (rebase, bisect, checkout of a tag): no branch to follow. + let local = head.trim().strip_prefix("ref: refs/heads/")?.to_string(); + let config = std::fs::read_to_string(common_dir.join("config")).ok()?; + resolve_upstream(&config, &local) +} + +/// `.git` is a directory in a normal checkout and a `gitdir: …` file in a +/// worktree, whose config then lives in the main repository (`commondir`). +fn find_git_dir(start: &Path) -> Option<(PathBuf, PathBuf)> { + for dir in start.ancestors() { + let dot_git = dir.join(".git"); + if dot_git.is_dir() { + return Some((dot_git.clone(), dot_git)); + } + if dot_git.is_file() { + let text = std::fs::read_to_string(&dot_git).ok()?; + let target = text.trim().strip_prefix("gitdir:")?.trim(); + let git_dir = dir.join(target); + let common = std::fs::read_to_string(git_dir.join("commondir")) + .map(|c| git_dir.join(c.trim())) + .unwrap_or_else(|_| git_dir.clone()); + return Some((git_dir, common)); + } + } + None +} + +/// Follows `branch..remote` / `.merge` to the GitHub repo and remote +/// branch name; a branch with no upstream is assumed to be pushed to origin +/// under the same name. +fn resolve_upstream(config: &str, local: &str) -> Option { + let sections = parse_git_config(config); + let get = |section: &str, sub: &str, key: &str| -> Option { + sections + .iter() + .find(|(s, n, k, _)| s == section && n == sub && k == key) + .map(|(_, _, _, v)| v.clone()) + }; + + let remote = get("branch", local, "remote").unwrap_or_else(|| "origin".into()); + let branch = get("branch", local, "merge") + .and_then(|m| m.strip_prefix("refs/heads/").map(str::to_string)) + .unwrap_or_else(|| local.to_string()); + + let url = get("remote", &remote, "url").or_else(|| { + // No such remote: take whichever remote points at GitHub. + sections + .iter() + .filter(|(s, _, k, _)| s == "remote" && k == "url") + .map(|(_, _, _, v)| v.clone()) + .find(|v| parse_github_url(v).is_some()) + })?; + let (owner, name) = parse_github_url(&url)?; + Some(BranchRef { owner, name, branch }) +} + +/// (section, subsection, key, value) — just enough of git-config(1) for +/// `[remote "x"]` and `[branch "x"]`. Section and key names are case-insensitive. +fn parse_git_config(text: &str) -> Vec<(String, String, String, String)> { + let mut out = Vec::new(); + let (mut section, mut sub) = (String::new(), String::new()); + for raw in text.lines() { + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') || line.starts_with(';') { + continue; + } + if let Some(header) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) { + match header.split_once(char::is_whitespace) { + Some((s, n)) => { + section = s.to_ascii_lowercase(); + sub = n.trim().trim_matches('"').to_string(); + } + None => { + section = header.to_ascii_lowercase(); + sub.clear(); + } + } + continue; + } + if let Some((k, v)) = line.split_once('=') { + let value = v.trim().trim_matches('"').to_string(); + out.push((section.clone(), sub.clone(), k.trim().to_ascii_lowercase(), value)); + } + } + out +} + +/// `https://github.com/o/r(.git)`, `git@github.com:o/r.git`, +/// `ssh://git@github.com/o/r.git` → (o, r). Anything not on github.com → None. +fn parse_github_url(url: &str) -> Option<(String, String)> { + let at = url.find("github.com")?; + let rest = &url[at + "github.com".len()..]; + let rest = rest.strip_prefix(':').or_else(|| rest.strip_prefix('/'))?; + let rest = rest.trim_end_matches('/'); + let rest = rest.strip_suffix(".git").unwrap_or(rest); + let (owner, name) = rest.split_once('/')?; + if owner.is_empty() || name.is_empty() || name.contains('/') { + return None; + } + Some((owner.to_string(), name.to_string())) +} + +// ── The query ───────────────────────────────────────────────────────────────── + +const QUERY: &str = r#" +query($owner: String!, $name: String!, $ref: String!, $withRepo: Boolean!) { + viewer { + login + repositories(ownerAffiliations: OWNER, first: 100, orderBy: {field: PUSHED_AT, direction: DESC}) { + totalCount + nodes { stargazerCount } + } + pullRequests(states: OPEN, first: 10, orderBy: {field: UPDATED_AT, direction: DESC}) { + nodes { + number title url isDraft headRefName reviewDecision updatedAt + repository { nameWithOwner } + latestReviews(first: 10) { nodes { id state author { login } } } + } + } + } + requests: search(query: "is:open is:pr user-review-requested:@me archived:false", type: ISSUE, first: 5) { + issueCount + nodes { ... on PullRequest { number title url author { login } repository { nameWithOwner } } } + } + repository(owner: $owner, name: $name) @include(if: $withRepo) { + nameWithOwner + ref(qualifiedName: $ref) { + target { + ... on Commit { + oid url committedDate + statusCheckRollup { + state + contexts(first: 50) { + nodes { + __typename + ... on CheckRun { name conclusion detailsUrl } + ... on StatusContext { context state targetUrl } + } + } + } + } + } + } + } +} +"#; + +pub async fn poll(app: AppHandle) { + let Some(token) = secrets::get("github-token") else { return }; + let cwd = SESSION_CWD.lock().unwrap().clone(); + let target = cwd.as_deref().and_then(branch_of); + + let variables = match &target { + Some(t) => json!({ + "owner": t.owner, "name": t.name, + "ref": format!("refs/heads/{}", t.branch), "withRepo": true, + }), + None => json!({ "owner": "", "name": "", "ref": "", "withRepo": false }), + }; + + let response = client() + .post("https://api.github.com/graphql") + .header("Authorization", format!("Bearer {token}")) + .header("User-Agent", "Coucou") + .json(&json!({ "query": QUERY, "variables": variables })) + .send() + .await; + let Ok(response) = response else { return }; + if !response.status().is_success() { + emit(&app, IntegrationUpdate { + id: ID, + data: json!({}), + error: Some(status_error(response.status().as_u16(), "Token lacks the needed scope")), + event: None, + }); + return; + } + let body: Value = response.json().await.unwrap_or(json!({})); + let Some(data) = body.get("data").filter(|d| !d.is_null()) else { + let message = body + .pointer("/errors/0/message") + .and_then(Value::as_str) + .unwrap_or("Unexpected answer from GitHub"); + log::line(format!("github graphql: {message}")); + emit(&app, IntegrationUpdate { + id: ID, + data: json!({}), + error: Some(message.chars().take(80).collect()), + event: None, + }); + return; + }; + + let snapshot = Snapshot::parse(data, target.as_ref()); + let event = MEMORY.lock().unwrap().diff(&snapshot); + emit(&app, IntegrationUpdate { id: ID, data: snapshot.to_json(), error: None, event }); +} + +// ── What the query says ─────────────────────────────────────────────────────── + +#[derive(Debug, Default)] +struct Snapshot { + login: String, + total_repos: i64, + total_stars: i64, + branch: Option, + pulls: Vec, + requests: Vec, + request_count: i64, +} + +#[derive(Debug)] +struct BranchState { + repo: String, + branch: String, + /// False when the branch only exists locally. + pushed: bool, + oid: String, + /// SUCCESS, FAILURE, ERROR, PENDING, EXPECTED — None when no check ran. + state: Option, + failing: Vec, + url: String, + committed_at: String, + pr: Option, +} + +#[derive(Debug)] +struct Pull { + repo: String, + number: i64, + title: String, + url: String, + draft: bool, + head: String, + /// APPROVED, CHANGES_REQUESTED, REVIEW_REQUIRED, or None. + decision: Option, + reviews: Vec, +} + +#[derive(Debug)] +struct Review { + id: String, + state: String, + author: String, +} + +#[derive(Debug)] +struct Request { + repo: String, + number: i64, + title: String, + url: String, + author: String, +} + +fn s(v: &Value, ptr: &str) -> String { + v.pointer(ptr).and_then(Value::as_str).unwrap_or_default().to_string() +} + +impl Snapshot { + fn parse(data: &Value, target: Option<&BranchRef>) -> Self { + let viewer = &data["viewer"]; + let login = s(viewer, "/login"); + + let repos = &viewer["repositories"]; + let total_stars = repos["nodes"] + .as_array() + .map(|l| l.iter().filter_map(|r| r["stargazerCount"].as_i64()).sum()) + .unwrap_or(0); + + // Pull requests nobody has touched in a month are abandoned, not "on + // the go" — bots opening them under your name leave plenty of those. + // A new review bumps updatedAt, so one coming back to life reappears. + let fresh_since = now_secs() - STALE_AFTER_SECS; + let pulls: Vec = viewer + .pointer("/pullRequests/nodes") + .and_then(Value::as_array) + .map(|list| { + list.iter() + .filter(|p| { + p["updatedAt"].as_str().and_then(parse_rfc3339).is_none_or(|t| t >= fresh_since) + }) + .map(|p| Pull { + repo: s(p, "/repository/nameWithOwner"), + number: p["number"].as_i64().unwrap_or(0), + title: s(p, "/title"), + url: s(p, "/url"), + draft: p["isDraft"].as_bool().unwrap_or(false), + head: s(p, "/headRefName"), + decision: p["reviewDecision"].as_str().map(str::to_string), + reviews: p + .pointer("/latestReviews/nodes") + .and_then(Value::as_array) + .map(|rs| { + rs.iter() + .map(|r| Review { + id: s(r, "/id"), + state: s(r, "/state"), + author: s(r, "/author/login"), + }) + .collect() + }) + .unwrap_or_default(), + }) + .collect() + }) + .unwrap_or_default(); + + let requests = data + .pointer("/requests/nodes") + .and_then(Value::as_array) + .map(|list| { + list.iter() + .filter(|n| n.get("url").is_some()) + .map(|n| Request { + repo: s(n, "/repository/nameWithOwner"), + number: n["number"].as_i64().unwrap_or(0), + title: s(n, "/title"), + url: s(n, "/url"), + author: s(n, "/author/login"), + }) + .collect() + }) + .unwrap_or_default(); + + let branch = target.and_then(|t| { + // A repository the token can't see comes back null: no CI row then. + let repo = data.get("repository").filter(|r| !r.is_null())?; + let name_with_owner = s(repo, "/nameWithOwner"); + let pr = pulls + .iter() + .find(|p| p.head == t.branch && p.repo.eq_ignore_ascii_case(&name_with_owner)) + .map(|p| p.number); + let commit = repo.pointer("/ref/target"); + let Some(commit) = commit.filter(|c| !c.is_null()) else { + return Some(BranchState { + repo: name_with_owner, + branch: t.branch.clone(), + pushed: false, + oid: String::new(), + state: None, + failing: vec![], + url: String::new(), + committed_at: String::new(), + pr, + }); + }; + let rollup = commit.get("statusCheckRollup").filter(|r| !r.is_null()); + let mut failing = Vec::new(); + let mut failing_url = None; + if let Some(contexts) = rollup.and_then(|r| r.pointer("/contexts/nodes")).and_then(Value::as_array) { + for c in contexts { + let (name, bad, url) = match c["__typename"].as_str() { + Some("CheckRun") => ( + s(c, "/name"), + matches!( + c["conclusion"].as_str(), + Some("FAILURE" | "TIMED_OUT" | "STARTUP_FAILURE" | "ACTION_REQUIRED") + ), + s(c, "/detailsUrl"), + ), + Some("StatusContext") => ( + s(c, "/context"), + matches!(c["state"].as_str(), Some("FAILURE" | "ERROR")), + s(c, "/targetUrl"), + ), + _ => continue, + }; + if bad { + if failing_url.is_none() && !url.is_empty() { + failing_url = Some(url); + } + failing.push(name); + } + } + } + Some(BranchState { + repo: name_with_owner, + branch: t.branch.clone(), + pushed: true, + oid: s(commit, "/oid"), + state: rollup.and_then(|r| r["state"].as_str()).map(str::to_string), + failing, + // Straight to the failing job when there is one; the commit page + // (which lists every check) otherwise. + url: failing_url.unwrap_or_else(|| s(commit, "/url")), + committed_at: s(commit, "/committedDate"), + pr, + }) + }); + + Snapshot { + login, + total_repos: repos["totalCount"].as_i64().unwrap_or(0), + total_stars, + branch, + pulls, + requests, + request_count: data.pointer("/requests/issueCount").and_then(Value::as_i64).unwrap_or(0), + } + } + + fn to_json(&self) -> Value { + let branch = self.branch.as_ref().map(|b| { + json!({ + "repo": b.repo, + "branch": b.branch, + "pushed": b.pushed, + "state": b.state, + "failing": b.failing, + "url": b.url, + "committedAt": b.committed_at, + "pr": b.pr, + }) + }); + let pulls: Vec = self + .pulls + .iter() + .take(5) + .map(|p| { + json!({ + "repo": p.repo, "number": p.number, "title": p.title, "url": p.url, + "draft": p.draft, "decision": p.decision, + }) + }) + .collect(); + let requests: Vec = self + .requests + .iter() + .map(|r| { + json!({ + "repo": r.repo, "number": r.number, "title": r.title, "url": r.url, + "author": r.author, + }) + }) + .collect(); + json!({ + "login": self.login, + "totalRepos": self.total_repos, + "totalStars": self.total_stars, + "branch": branch, + "pulls": pulls, + "requests": requests, + "requestCount": self.request_count, + }) + } +} + +// ── What changed since the last poll ────────────────────────────────────────── + +#[derive(Default)] +struct Memory { + /// (repo@branch@commit, rollup state) at the last poll. + ci: Option<(String, Option)>, + /// None until the first poll, which fills these without announcing anything. + reviews: Option>, + requests: Option>, +} + +static MEMORY: LazyLock> = LazyLock::new(|| Mutex::new(Memory::default())); + +fn is_pending(state: &Option) -> bool { + matches!(state.as_deref(), Some("PENDING" | "EXPECTED")) +} + +impl Memory { + /// Remembers this snapshot and returns the one event worth announcing. + fn diff(&mut self, now: &Snapshot) -> Option { + // (priority, event): lower wins. A red build beats everything else. + let mut candidates: Vec<(u8, IntegrationEvent)> = Vec::new(); + + // CI: only a run we watched go from pending to done. Switching to another + // repo or branch, or a commit that was already finished when first seen, + // says nothing — that's old news, not something that just happened. + if let Some(b) = now.branch.as_ref().filter(|b| b.pushed) { + let key = format!("{}@{}@{}", b.repo, b.branch, b.oid); + if let Some((prev_key, prev_state)) = &self.ci { + if *prev_key == key && is_pending(prev_state) { + match b.state.as_deref() { + Some("SUCCESS") => candidates.push((5, IntegrationEvent { + success: true, + label: format!("CI passed · {}", b.branch), + detail: Some(b.repo.clone()), + attention: false, item: None, + })), + Some("FAILURE" | "ERROR") => candidates.push((0, IntegrationEvent { + success: false, + label: format!("CI failed · {}", b.branch), + detail: Some(if b.failing.is_empty() { + b.repo.clone() + } else { + b.failing.join(", ") + }), + attention: false, item: None, + })), + _ => {} + } + } + } + self.ci = Some((key, b.state.clone())); + } + + // Reviews on your pull requests, by anyone but you. + let mut review_ids = HashSet::new(); + for p in &now.pulls { + for r in &p.reviews { + if r.author == now.login || r.id.is_empty() { + continue; + } + review_ids.insert(r.id.clone()); + if self.reviews.as_ref().is_none_or(|seen| seen.contains(&r.id)) { + continue; + } + let on = format!("{} on {}", r.author, p.title); + match r.state.as_str() { + "CHANGES_REQUESTED" => candidates.push((1, IntegrationEvent { + success: false, + label: format!("Changes requested · #{}", p.number), + detail: Some(on), + attention: false, item: None, + })), + "APPROVED" => candidates.push((4, IntegrationEvent { + success: true, + label: format!("Approved · #{}", p.number), + detail: Some(on), + attention: false, item: None, + })), + "COMMENTED" => candidates.push((3, IntegrationEvent { + success: true, + label: format!("New review · #{}", p.number), + detail: Some(on), + attention: true, item: None, + })), + _ => {} + } + } + } + self.reviews = Some(review_ids); + + // Reviews someone asked of you. + let request_urls: HashSet = now.requests.iter().map(|r| r.url.clone()).collect(); + if let Some(seen) = &self.requests { + for r in now.requests.iter().filter(|r| !seen.contains(&r.url)) { + candidates.push((2, IntegrationEvent { + success: true, + label: format!("Review requested · #{}", r.number), + detail: Some(format!("{}: {}", r.author, r.title)), + attention: true, item: None, + })); + } + } + self.requests = Some(request_urls); + + candidates.sort_by_key(|(p, _)| *p); + candidates.into_iter().next().map(|(_, e)| e) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn github_urls() { + let ok = |u: &str| parse_github_url(u).map(|(o, n)| format!("{o}/{n}")); + assert_eq!(ok("https://github.com/Louis-CFM/coucou.git").as_deref(), Some("Louis-CFM/coucou")); + assert_eq!(ok("https://github.com/Louis-CFM/coucou").as_deref(), Some("Louis-CFM/coucou")); + assert_eq!(ok("https://me@github.com/a/b/").as_deref(), Some("a/b")); + assert_eq!(ok("git@github.com:a/b.git").as_deref(), Some("a/b")); + assert_eq!(ok("ssh://git@github.com/a/b.git").as_deref(), Some("a/b")); + assert_eq!(ok("https://gitlab.com/a/b.git"), None); + assert_eq!(ok("https://github.com/a"), None); + } + + #[test] + fn upstream_from_config() { + let config = r#" +[core] + bare = false +[remote "origin"] + url = git@github.com:Louis-CFM/coucou.git + fetch = +refs/heads/*:refs/remotes/origin/* +[remote "fork"] + url = https://github.com/jhoan/coucou.git +[branch "main"] + remote = origin + merge = refs/heads/main +[branch "local-name"] + remote = fork + merge = refs/heads/remote-name +"#; + let main = resolve_upstream(config, "main").unwrap(); + assert_eq!((main.owner.as_str(), main.name.as_str(), main.branch.as_str()), ("Louis-CFM", "coucou", "main")); + let forked = resolve_upstream(config, "local-name").unwrap(); + assert_eq!((forked.owner.as_str(), forked.branch.as_str()), ("jhoan", "remote-name")); + // Never pushed: same name on origin. + let fresh = resolve_upstream(config, "feat/x").unwrap(); + assert_eq!((fresh.owner.as_str(), fresh.branch.as_str()), ("Louis-CFM", "feat/x")); + } + + #[test] + fn worktree_git_dir() { + let root = std::env::temp_dir().join(format!("coucou-gh-{}", std::process::id())); + let main_git = root.join("repo").join(".git"); + let wt_git = main_git.join("worktrees").join("wt"); + let wt = root.join("wt"); + std::fs::create_dir_all(&wt_git).unwrap(); + std::fs::create_dir_all(wt.join("sub")).unwrap(); + std::fs::write(main_git.join("config"), "[remote \"origin\"]\n\turl = https://github.com/a/b.git\n").unwrap(); + std::fs::write(wt_git.join("HEAD"), "ref: refs/heads/fix/thing\n").unwrap(); + std::fs::write(wt_git.join("commondir"), "../..\n").unwrap(); + std::fs::write(wt.join(".git"), format!("gitdir: {}\n", wt_git.display())).unwrap(); + + let got = branch_of(&wt.join("sub")).unwrap(); + assert_eq!(got, BranchRef { owner: "a".into(), name: "b".into(), branch: "fix/thing".into() }); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn parses_a_graphql_answer() { + let recent = crate::time::rfc3339_utc(now_secs() - 3600); + let data = json!({ + "viewer": { + "login": "me", + "repositories": { "totalCount": 2, "nodes": [{ "stargazerCount": 3 }, { "stargazerCount": 4 }] }, + "pullRequests": { "nodes": [ + { "number": 8, "title": "Calendar", "url": "u8", "isDraft": false, "headRefName": "feat/cal", + "reviewDecision": null, "updatedAt": recent, + "repository": { "nameWithOwner": "Louis-CFM/coucou" }, + "latestReviews": { "nodes": [{ "id": "r1", "state": "COMMENTED", "author": { "login": "louis" } }] } }, + { "number": 2, "title": "[Snyk] Fix", "url": "u2", "isDraft": false, "headRefName": "snyk-fix-1", + "reviewDecision": null, "updatedAt": "2025-12-14T14:33:19Z", + "repository": { "nameWithOwner": "me/old" }, "latestReviews": { "nodes": [] } }, + ] }, + }, + "requests": { "issueCount": 0, "nodes": [] }, + "repository": { + "nameWithOwner": "Louis-CFM/coucou", + "ref": { "target": { + "oid": "abc", "url": "commit-url", "committedDate": recent, + "statusCheckRollup": { "state": "FAILURE", "contexts": { "nodes": [ + { "__typename": "CheckRun", "name": "lint", "conclusion": "SUCCESS", "detailsUrl": "l" }, + { "__typename": "CheckRun", "name": "build", "conclusion": "FAILURE", "detailsUrl": "job-url" }, + ] } }, + } }, + }, + }); + let target = BranchRef { owner: "Louis-CFM".into(), name: "coucou".into(), branch: "feat/cal".into() }; + let s = Snapshot::parse(&data, Some(&target)); + assert_eq!((s.total_repos, s.total_stars), (2, 7)); + // The ten-month-old bot PR is gone. + assert_eq!(s.pulls.iter().map(|p| p.number).collect::>(), vec![8]); + let b = s.branch.unwrap(); + assert_eq!((b.state.as_deref(), b.failing.as_slice(), b.url.as_str(), b.pr), (Some("FAILURE"), &["build".to_string()][..], "job-url", Some(8))); + } + + fn snapshot(state: Option<&str>, reviews: &[(&str, &str)], requests: &[&str]) -> Snapshot { + Snapshot { + login: "me".into(), + branch: Some(BranchState { + repo: "a/b".into(), + branch: "fix".into(), + pushed: true, + oid: "abc".into(), + state: state.map(str::to_string), + failing: if state == Some("FAILURE") { vec!["build".into()] } else { vec![] }, + url: String::new(), + committed_at: String::new(), + pr: Some(7), + }), + pulls: vec![Pull { + repo: "a/b".into(), + number: 7, + title: "Fix".into(), + url: "u7".into(), + draft: false, + head: "fix".into(), + decision: None, + reviews: reviews + .iter() + .map(|(id, st)| Review { id: (*id).into(), state: (*st).into(), author: "louis".into() }) + .collect(), + }], + requests: requests + .iter() + .map(|u| Request { repo: "a/b".into(), number: 9, title: "T".into(), url: (*u).into(), author: "x".into() }) + .collect(), + ..Default::default() + } + } + + #[test] + fn first_poll_is_silent_then_changes_are_announced() { + let mut m = Memory::default(); + // Already red when first seen, an existing review, an existing request. + assert!(m.diff(&snapshot(Some("FAILURE"), &[("r1", "COMMENTED")], &["p1"])).is_none()); + // Same again: nothing new. + assert!(m.diff(&snapshot(Some("FAILURE"), &[("r1", "COMMENTED")], &["p1"])).is_none()); + + // A new review request. + let e = m.diff(&snapshot(Some("FAILURE"), &[("r1", "COMMENTED")], &["p1", "p2"])).unwrap(); + assert!(e.attention && e.label.starts_with("Review requested")); + + // An approval. + let e = m.diff(&snapshot(Some("FAILURE"), &[("r2", "APPROVED")], &["p1", "p2"])).unwrap(); + assert!(e.success && e.label == "Approved · #7"); + } + + #[test] + fn ci_is_announced_only_on_a_watched_transition() { + let mut m = Memory::default(); + assert!(m.diff(&snapshot(Some("PENDING"), &[], &[])).is_none()); + let e = m.diff(&snapshot(Some("FAILURE"), &[], &[])).unwrap(); + assert!(!e.success && e.label == "CI failed · fix" && e.detail.as_deref() == Some("build")); + // Still red on the next poll: already said. + assert!(m.diff(&snapshot(Some("FAILURE"), &[], &[])).is_none()); + + // A red build outranks an approval landing in the same poll. + let mut m = Memory::default(); + m.diff(&snapshot(Some("PENDING"), &[], &[])); + let e = m.diff(&snapshot(Some("FAILURE"), &[("r9", "APPROVED")], &[])).unwrap(); + assert!(e.label.starts_with("CI failed")); + } +} diff --git a/windows/src-tauri/src/integrations.rs b/windows/src-tauri/src/integrations.rs index d666b99779..05ccbfda3e 100644 --- a/windows/src-tauri/src/integrations.rs +++ b/windows/src-tauri/src/integrations.rs @@ -1,10 +1,13 @@ -// Integration pollers — the Rust side of StripePoller / GithubPoller / -// VercelPoller / N8nPoller / ResendPoller / NotionPoller / CalcomPoller. +// Integration pollers — the Rust side of StripePoller / VercelPoller / +// N8nPoller / ResendPoller / NotionPoller / CalcomPoller. // // Same endpoints, same first-run delays and intervals as the Swift pollers. Each // one emits an `integration` event; the island owns the badge, the sound and the // 60 s auto-clear, exactly as the Swift handlers do. // +// GitHub (CI and pull requests) and Google Calendar go further than macOS and +// live in their own modules, github.rs and gcal.rs. +// // Nothing is polled until its key exists in the Credential Manager, and no // request goes anywhere the user has not configured. @@ -39,13 +42,21 @@ pub struct IntegrationEvent { pub success: bool, pub label: String, pub detail: Option, + /// Something is waiting on the user (a review request, a meeting about to + /// start) rather than something that finished: amber badge, not green/red. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub attention: bool, + /// What the event is about, when the island opens a card for it (the + /// calendar event behind a reminder). + #[serde(skip_serializing_if = "Option::is_none")] + pub item: Option, } -fn emit(app: &AppHandle, update: IntegrationUpdate) { +pub(crate) fn emit(app: &AppHandle, update: IntegrationUpdate) { let _ = app.emit_to(WINDOW_LABEL, "integration", update); } -fn client() -> reqwest::Client { +pub(crate) fn client() -> reqwest::Client { reqwest::Client::builder() .timeout(TIMEOUT) .build() @@ -66,9 +77,13 @@ pub fn start(app: AppHandle) { spawn(app.clone(), "integration_vercel", 5, 30, poll_vercel); spawn(app.clone(), "integration_stripe", 6, 30, poll_stripe); spawn(app.clone(), "integration_resend", 6, 60, poll_resend); - spawn(app.clone(), "integration_github", 7, 300, poll_github); + // Every minute rather than macOS's five: a CI run or a review is only worth + // hearing about while it is still news. One GraphQL query per poll. + spawn(app.clone(), "integration_github", 7, 60, crate::github::poll); spawn(app.clone(), "integration_calcom", 8, 300, poll_calcom); - spawn(app, "integration_notion", 9, 300, poll_notion); + spawn(app.clone(), "integration_notion", 9, 300, poll_notion); + // Every minute so the five-minute meeting reminder can't be skipped over. + spawn(app, "integration_gcal", 4, 60, crate::gcal::poll); } /// True when the user has this integration switched on in settings. @@ -107,7 +122,8 @@ where pub async fn poll_once(app: AppHandle, id: &str) { match id { "integration_stripe" => poll_stripe(app).await, - "integration_github" => poll_github(app).await, + "integration_github" => crate::github::poll(app).await, + "integration_gcal" => crate::gcal::poll(app).await, "integration_vercel" => poll_vercel(app).await, "integration_n8n" => poll_n8n(app).await, "integration_resend" => poll_resend(app).await, @@ -133,7 +149,7 @@ fn is_new(key: &'static str, id: &str) -> bool { } } -fn status_error(code: u16, unauthorised_hint: &str) -> String { +pub(crate) fn status_error(code: u16, unauthorised_hint: &str) -> String { match code { 401 => "Invalid API key (401)".into(), 403 => unauthorised_hint.into(), @@ -249,7 +265,7 @@ async fn poll_stripe(app: AppHandle) { let cents = payments[0].get("amount").and_then(Value::as_i64).unwrap_or(0); format!("{:.2}", cents as f64 / 100.0) }); - Some(IntegrationEvent { success: true, label, detail: None }) + Some(IntegrationEvent { success: true, label, detail: None, attention: false, item: None }) } else { None }; @@ -262,67 +278,6 @@ async fn poll_stripe(app: AppHandle) { }); } -// ── GitHub ──────────────────────────────────────────────────────────────────── - -async fn poll_github(app: AppHandle) { - let Some(token) = secrets::get("github-token") else { return }; - let http = client(); - - let user = http - .get("https://api.github.com/user") - .header("Authorization", format!("Bearer {token}")) - .header("Accept", "application/vnd.github+json") - .header("User-Agent", "Coucou") - .send() - .await; - let Ok(response) = user else { return }; - if !response.status().is_success() { - emit(&app, IntegrationUpdate { - id: "integration_github", - data: json!({}), - error: Some(status_error(response.status().as_u16(), "Token lacks the needed scope")), - event: None, - }); - return; - } - let json: Value = response.json().await.unwrap_or(json!({})); - let public = json.get("public_repos").and_then(Value::as_i64).unwrap_or(0); - let private = json - .get("owned_private_repos") - .or_else(|| json.get("total_private_repos")) - .and_then(Value::as_i64) - .unwrap_or(0); - - let repos = http - .get("https://api.github.com/user/repos?per_page=100&affiliation=owner&sort=pushed") - .header("Authorization", format!("Bearer {token}")) - .header("Accept", "application/vnd.github+json") - .header("User-Agent", "Coucou") - .send() - .await; - let stars: i64 = match repos { - Ok(r) if r.status().is_success() => r - .json::() - .await - .ok() - .and_then(|v| v.as_array().cloned()) - .map(|list| { - list.iter() - .filter_map(|r| r.get("stargazers_count").and_then(Value::as_i64)) - .sum() - }) - .unwrap_or(0), - _ => 0, - }; - - emit(&app, IntegrationUpdate { - id: "integration_github", - data: json!({ "totalRepos": public + private, "totalStars": stars }), - error: None, - event: None, - }); -} - // ── Vercel ──────────────────────────────────────────────────────────────────── async fn poll_vercel(app: AppHandle) { @@ -384,6 +339,7 @@ async fn poll_vercel(app: AppHandle) { success, label: latest.get("projectName")?.as_str()?.to_string(), detail: None, + attention: false, item: None, }) }); @@ -691,7 +647,7 @@ async fn poll_n8n(app: AppHandle) { id: "integration_n8n", data: json!({ "workflow": name, "status": status }), error: None, - event: Some(IntegrationEvent { success, label: name, detail }), + event: Some(IntegrationEvent { success, label: name, detail, attention: false, item: None }), }); } diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index 5d19db6c13..8f24710e2b 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -2,6 +2,8 @@ mod claude; mod files; +mod gcal; +mod github; mod hooks; mod integrations; mod island; @@ -9,6 +11,7 @@ mod log; mod pipe; mod secrets; mod settings; +mod time; mod tray; mod win_user; @@ -293,6 +296,23 @@ async fn refresh_integration(app: AppHandle, id: String) { integrations::poll_once(app, &id).await; } +/// Settings → Google Calendar → Connect: opens Google's consent page and waits +/// for it to come back. Resolves once the refresh token is stored. +#[tauri::command] +async fn gcal_connect(app: AppHandle) -> Result<(), String> { + gcal::connect(app).await +} + +#[tauri::command] +fn gcal_cancel() { + gcal::cancel(); +} + +#[tauri::command] +async fn gcal_disconnect(app: AppHandle) -> Result<(), String> { + gcal::disconnect(app).await +} + /// Lets the island write to the same log as the Rust side. #[tauri::command] fn log_line(message: String) { @@ -404,6 +424,9 @@ pub fn run() { secret_set, secret_clear, refresh_integration, + gcal_connect, + gcal_cancel, + gcal_disconnect, open_n8n, open_settings_window, set_paused, diff --git a/windows/src-tauri/src/pipe.rs b/windows/src-tauri/src/pipe.rs index f8cd71c45b..93db5c27db 100644 --- a/windows/src-tauri/src/pipe.rs +++ b/windows/src-tauri/src/pipe.rs @@ -125,6 +125,11 @@ async fn handle(app: AppHandle, mut pipe: NamedPipeServer) { .unwrap_or_default() .to_string(); + // The GitHub card follows the branch of whichever session spoke last. + if let Some(cwd) = payload.get("cwd").and_then(Value::as_str) { + crate::github::note_cwd(cwd); + } + if event != "PermissionRequest" { log::line(format!("hook {event}")); let _ = app.emit_to(WINDOW_LABEL, "hook", payload); diff --git a/windows/src-tauri/src/secrets.rs b/windows/src-tauri/src/secrets.rs index 06296d1f23..06bb774e89 100644 --- a/windows/src-tauri/src/secrets.rs +++ b/windows/src-tauri/src/secrets.rs @@ -16,6 +16,11 @@ pub const KNOWN_KEYS: &[&str] = &[ "resend-api-key", "notion-api-key", "calcom-api-key", + // Google Calendar: the user's own Desktop OAuth client, and the refresh + // token Google returns once they have consented. + "gcal-client-id", + "gcal-client-secret", + "gcal-refresh-token", ]; fn entry(key: &str) -> Option { diff --git a/windows/src-tauri/src/time.rs b/windows/src-tauri/src/time.rs new file mode 100644 index 0000000000..977986c685 --- /dev/null +++ b/windows/src-tauri/src/time.rs @@ -0,0 +1,79 @@ +// Dates, the little Coucou needs of them: "now", and RFC 3339 both ways, in UTC. +// Enough for API timestamps without pulling in chrono. + +use std::time::{SystemTime, UNIX_EPOCH}; + +pub fn now_secs() -> i64 { + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0) +} + +/// Days since 1970-01-01 for a proleptic Gregorian date (H. Hinnant's algorithm). +fn days_from_civil(y: i64, m: i64, d: i64) -> i64 { + let y = if m <= 2 { y - 1 } else { y }; + let era = y.div_euclid(400); + let yoe = y - era * 400; + let mp = (m + 9) % 12; + let doy = (153 * mp + 2) / 5 + d - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146_097 + doe - 719_468 +} + +fn civil_from_days(z: i64) -> (i64, i64, i64) { + let z = z + 719_468; + let era = z.div_euclid(146_097); + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + (if m <= 2 { yoe + era * 400 + 1 } else { yoe + era * 400 }, m, d) +} + +pub fn rfc3339_utc(secs: i64) -> String { + let (y, m, d) = civil_from_days(secs.div_euclid(86_400)); + let t = secs.rem_euclid(86_400); + format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", t / 3600, t % 3600 / 60, t % 60) +} + +/// `2026-10-01T14:30:00+02:00`, `…Z`, with or without fractional seconds. +pub fn parse_rfc3339(s: &str) -> Option { + let num = |r: std::ops::Range| s.get(r)?.parse::().ok(); + let (y, mo, d) = (num(0..4)?, num(5..7)?, num(8..10)?); + let (h, mi, se) = (num(11..13)?, num(14..16)?, num(17..19)?); + let mut rest = s.get(19..)?; + if let Some(frac) = rest.strip_prefix('.') { + rest = frac.trim_start_matches(|c: char| c.is_ascii_digit()); + } + let offset = match rest { + "Z" | "z" => 0, + _ => { + let sign = match rest.chars().next()? { + '+' => 1, + '-' => -1, + _ => return None, + }; + let oh = rest.get(1..3)?.parse::().ok()?; + let om = rest.get(4..6)?.parse::().ok()?; + sign * (oh * 3600 + om * 60) + } + }; + Some(days_from_civil(y, mo, d) * 86_400 + h * 3600 + mi * 60 + se - offset) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dates_round_trip() { + assert_eq!(rfc3339_utc(0), "1970-01-01T00:00:00Z"); + // Checked against `date -u -d @1790000000`. + assert_eq!(rfc3339_utc(1_790_000_000), "2026-09-21T14:13:20Z"); + assert_eq!(parse_rfc3339("2026-09-21T14:13:20Z"), Some(1_790_000_000)); + assert_eq!(parse_rfc3339("2026-09-21T16:13:20+02:00"), Some(1_790_000_000)); + assert_eq!(parse_rfc3339("2026-09-21T09:13:20.250-05:00"), Some(1_790_000_000)); + assert_eq!(parse_rfc3339("2024-02-29T00:00:00Z").map(rfc3339_utc).as_deref(), Some("2024-02-29T00:00:00Z")); + assert_eq!(parse_rfc3339("2026-10-01"), None); + } +} diff --git a/windows/src/core/bridge.ts b/windows/src/core/bridge.ts index 835944d22c..c35f3642d4 100644 --- a/windows/src/core/bridge.ts +++ b/windows/src/core/bridge.ts @@ -94,6 +94,15 @@ export const Bridge = { refreshIntegration: (id: string) => call("refresh_integration", { id }), /** Opens the configured n8n instance in the browser. */ openN8n: () => call("open_n8n"), + /** + * Opens Google's consent page and resolves once the refresh token is stored + * (or rejects with the reason: cancelled, timed out, wrong client…). + */ + gcalConnect: () => callOrThrow("gcal_connect"), + /** Stops a pending gcalConnect, which then rejects with "Sign-in cancelled." */ + gcalCancel: () => call("gcal_cancel"), + /** Revokes the grant at Google and forgets the token. */ + gcalDisconnect: () => callOrThrow("gcal_disconnect"), /** Tray → Pause. Stops the integration pollers, not just the island. */ setPaused: (paused: boolean) => call("set_paused", { paused }), @@ -103,7 +112,15 @@ export interface IntegrationUpdate { id: string; data: Record; error: string | null; - event: { success: boolean; label: string; detail: string | null } | null; + event: { + success: boolean; + label: string; + detail: string | null; + /** Waiting on the user (a review request, a meeting about to start). */ + attention?: boolean; + /** What a card should show about it — the calendar event of a reminder. */ + item?: Record; + } | null; } export type ChatContext = diff --git a/windows/src/core/layout.ts b/windows/src/core/layout.ts index 6f70eea4c5..eea7b95826 100644 --- a/windows/src/core/layout.ts +++ b/windows/src/core/layout.ts @@ -11,6 +11,7 @@ export type IslandViewName = | "question" | "error" | "finished" + | "reminder" | "confused" | "upload" | "uploading" @@ -73,6 +74,7 @@ export const VIEW_LAYOUTS: Record = { question: { height: 160, botX: 62, botY: null, botDiameter: 56, agentMode: "column" }, error: { height: 160, botX: 62, botY: null, botDiameter: 58, agentMode: "column" }, finished: { height: 160, botX: 62, botY: null, botDiameter: 58, agentMode: "column" }, + reminder: { height: 160, botX: 62, botY: null, botDiameter: 58, agentMode: "column" }, confused: { height: 160, botX: 76, botY: null, botDiameter: 66, agentMode: "column" }, upload: { height: 176, botX: 140, botY: 104, botDiameter: 62, agentMode: "column" }, // botY 103 = bar top (42 + 58) + 3, so the dot really rides the bar. The Swift diff --git a/windows/src/core/state.ts b/windows/src/core/state.ts index e4988b8e7d..6381f3d9a1 100644 --- a/windows/src/core/state.ts +++ b/windows/src/core/state.ts @@ -56,7 +56,10 @@ const task = ( id, name, color, state: "idle", stepIndex: 0, steps: [], source, isIntegration: true, }); -/** AgentTask.integrationAgents — same ids, names and colours as macOS. */ +/** + * AgentTask.integrationAgents — same ids, names and colours as macOS, plus + * Google Calendar, which only exists on Windows for now. + */ export const INTEGRATION_AGENTS: AgentTask[] = [ task("integration_claude", "VS Code", "#F5F6F8", "claudeCode"), task("integration_resend", "Resend", "#22C55E", "n8n"), @@ -65,12 +68,13 @@ export const INTEGRATION_AGENTS: AgentTask[] = [ task("integration_github", "GitHub", "#F4505E", "n8n"), task("integration_notion", "Notion", "#8C8C8C", "n8n"), task("integration_calcom", "Cal.com", "#C9956A", "n8n"), + task("integration_gcal", "Calendar", "#4285F4", "n8n"), task("integration_stripe", "Stripe", "#0570DE", "n8n"), ]; export const TOGGLEABLE_INTEGRATION_IDS = [ "integration_resend", "integration_n8n", "integration_vercel", "integration_github", - "integration_notion", "integration_calcom", "integration_stripe", + "integration_notion", "integration_calcom", "integration_gcal", "integration_stripe", ]; /** What an integration poller last reported. */ @@ -137,6 +141,8 @@ class AppState { searchResult: SearchResult | null = null; chatHistory: ChatMessage[] = []; pendingApproval: ApprovalInfo | null = null; + /** The calendar event the reminder card is about (gcal.rs Event::to_json). */ + reminder: Record | null = null; integrations: Record = {}; diff --git a/windows/src/island/integrations.ts b/windows/src/island/integrations.ts index 862ffb14b0..a8bb4d81d6 100644 --- a/windows/src/island/integrations.ts +++ b/windows/src/island/integrations.ts @@ -16,6 +16,8 @@ const KEY_FOR: Record = { integration_resend: "resend-api-key", integration_notion: "notion-api-key", integration_calcom: "calcom-api-key", + // Connected once Google has handed back a refresh token. + integration_gcal: "gcal-refresh-token", }; const clearTimers = new Map(); @@ -48,23 +50,51 @@ function handle(island: Island, update: IntegrationUpdate) { data: update.error ? (previous?.data ?? {}) : update.data, error: update.error, loaded: update.error ? (previous?.loaded ?? false) : true, - configured: previous?.configured ?? true, + // Google is connected and disconnected from the settings window, which the + // key check at boot doesn't see: the poller's own answer says which it is + // (events, or an error such as an expired sign-in, versus the `{}` that + // disconnecting sends). + configured: + update.id === "integration_gcal" + ? update.error != null || Array.isArray(update.data.events) + : (previous?.configured ?? true), }; const event = update.event; if (event) { const task = State.tasks.find((t) => t.id === update.id); if (task) { - task.state = event.success ? "finished" : "error"; + // Something waiting on you (a review asked of you, a meeting about to + // start) reads as a question with the amber badge, not as done or broken. + const attention = event.attention === true; + task.state = attention ? "question" : event.success ? "finished" : "error"; task.steps = event.detail ? [event.label, event.detail] : [event.label]; task.stepIndex = task.steps.length - 1; if (State.focusId !== update.id) { - task.pillBadge = event.success ? "finished" : "error"; + task.pillBadge = attention ? "approval" : event.success ? "finished" : "error"; + } + + if (event.item && update.id === "integration_gcal") { + // A reminder is a message, not a badge: open a card that says what, + // when and where, with Join / Open. Unless you are in the middle of + // something — the chat, an approval, a file drop — then it waits as + // the amber badge on the Calendar pill. + Sound.play("approval"); + const busy: string[] = ["prompt", "approval", "question", "upload", "uploading", "choose", "mail"]; + if (State.mode === "expanded" && busy.includes(State.view)) { + island.reveal(); + } else { + State.reminder = event.item; + State.setFocus(update.id); + if (State.mode === "expanded") island.setView("reminder"); + else island.alert("reminder"); + } + } else { + Sound.play(attention ? "question" : event.success ? "finish" : "error"); + // Same as the Swift pollers: show the compact island so the badge is seen, + // but never steal the screen for a successful deploy. + island.reveal(); } - Sound.play(event.success ? "finish" : "error"); - // Same as the Swift pollers: show the compact island so the badge is seen, - // but never steal the screen for a successful deploy. - island.reveal(); const existing = clearTimers.get(update.id); if (existing != null) window.clearTimeout(existing); @@ -73,7 +103,7 @@ function handle(island: Island, update: IntegrationUpdate) { window.setTimeout(() => { clearTimers.delete(update.id); const t = State.tasks.find((x) => x.id === update.id); - if (!t || (t.state !== "finished" && t.state !== "error")) return; + if (!t || (t.state !== "finished" && t.state !== "error" && t.state !== "question")) return; t.state = "idle"; t.steps = []; t.stepIndex = 0; diff --git a/windows/src/island/island.ts b/windows/src/island/island.ts index 6e8a77bc46..394ed56a97 100644 --- a/windows/src/island/island.ts +++ b/windows/src/island/island.ts @@ -18,6 +18,7 @@ import { UploadCanvas } from "../upload/canvas"; import { USC, UploadSeq } from "../upload/sequence"; import { buildHeader, buildViews, type ViewActions, type ViewHost } from "../views/views"; import { h } from "../views/dom"; +import { OPEN_URLS } from "../views/integrations"; import { IslandStateMachine, type FsmState } from "./fsm"; const BOT_OVERHANG = 40; @@ -125,17 +126,9 @@ export class Island { openTarget: () => { const task = State.focusTask; if (!task) return; - const urls: Record = { - integration_resend: "https://resend.com/emails", - integration_vercel: "https://vercel.com/dashboard", - integration_github: "https://github.com", - integration_stripe: "https://dashboard.stripe.com/payments", - integration_notion: "https://notion.so", - integration_calcom: "https://app.cal.com/bookings", - }; if (task.id === "integration_claude") void Bridge.openInVSCode(task.sessionCwd ?? null); else if (task.id === "integration_n8n") void Bridge.openN8n(); - else if (urls[task.id]) void Bridge.openUrl(urls[task.id]); + else if (OPEN_URLS[task.id]) void Bridge.openUrl(OPEN_URLS[task.id]); }, openUrl: (url) => { if (url) void Bridge.openUrl(url); diff --git a/windows/src/settings/main.ts b/windows/src/settings/main.ts index 3ab9ab94a7..2cac38b582 100644 --- a/windows/src/settings/main.ts +++ b/windows/src/settings/main.ts @@ -262,13 +262,17 @@ interface IntegrationDef { color: string; /** Credential Manager keys, in the order they are shown. */ fields: { key: string; label: string; placeholder: string; secret: boolean }[]; + hint?: string; + /** Anything beyond key fields — Google's Connect button. */ + extra?: (present: Record) => HTMLElement; } const INTEGRATIONS: IntegrationDef[] = [ { id: "integration_stripe", name: "Stripe", color: "#0570DE", fields: [{ key: "stripe-api-key", label: "Secret key", placeholder: "sk_live_…", secret: true }] }, { id: "integration_github", name: "GitHub", color: "#F4505E", - fields: [{ key: "github-token", label: "Token", placeholder: "ghp_…", secret: true }] }, + fields: [{ key: "github-token", label: "Token", placeholder: "ghp_…", secret: true }], + hint: "A classic token with the repo scope. CI follows the branch of your last Claude Code session; reviews cover your open pull requests and the ones waiting on you." }, { id: "integration_vercel", name: "Vercel", color: "#7C5CFF", fields: [{ key: "vercel-token", label: "Token", placeholder: "…", secret: true }] }, { id: "integration_n8n", name: "n8n", color: "#F29B38", @@ -282,8 +286,99 @@ const INTEGRATIONS: IntegrationDef[] = [ fields: [{ key: "notion-api-key", label: "Integration token", placeholder: "ntn_…", secret: true }] }, { id: "integration_calcom", name: "Cal.com", color: "#C9956A", fields: [{ key: "calcom-api-key", label: "API key", placeholder: "cal_…", secret: true }] }, + { id: "integration_gcal", name: "Google Calendar", color: "#4285F4", + fields: [ + { key: "gcal-client-id", label: "Client ID", placeholder: "….apps.googleusercontent.com", secret: false }, + { key: "gcal-client-secret", label: "Client secret", placeholder: "GOCSPX-…", secret: true }, + ], + hint: "Coucou has no server, so it signs in with your own OAuth client: in Google Cloud, enable the Google Calendar API, create an OAuth client of type Desktop app, and paste its ID and secret here. Then publish the consent screen (In production): Google will warn that the app isn't verified — it's your own, continue. Left in Testing, only the test users listed there can sign in, and Google signs them out every 7 days. Read-only. Mochi reminds you at the times your events' own Google reminders say.", + extra: gcalConnectRow }, ]; +function gcalConnectRow(present: Record): HTMLElement { + const status = h("span", { class: "hint" }); + const dotEl = statusDot(false); + const connect = h("button", { class: "primary" }); + const cancel = h("button", { text: "Cancel" }); + const disconnect = h("button", { class: "danger", text: "Disconnect" }); + const feedback = h("div", {}); + const consoleLink = h("button", { + text: "Google Cloud console", + onclick: () => void Bridge.openUrl("https://console.cloud.google.com/apis/credentials"), + }); + + /** Which Connect click the screen belongs to: a stale one never redraws it. */ + let attempt = 0; + let waiting = false; + + function draw(connected: boolean) { + dotEl.style.background = connected ? "#22c55e" : "#f4505e"; + status.textContent = connected ? "Connected" : "Not connected"; + connect.disabled = waiting; + connect.textContent = waiting ? "Waiting for Google…" : connected ? "Reconnect…" : "Connect Google account…"; + cancel.style.display = waiting ? "" : "none"; + disconnect.style.display = connected && !waiting ? "" : "none"; + } + + connect.addEventListener("click", async () => { + clear(feedback); + if (!present["gcal-client-id"] || !present["gcal-client-secret"]) { + feedback.append(h("div", { class: "notice warn", text: "Save the client ID and client secret first." })); + return; + } + const mine = ++attempt; + waiting = true; + draw(present["gcal-refresh-token"] ?? false); + feedback.append(h("div", { + class: "hint", + // Google's "access blocked" page is a dead end: it never comes back here. + text: "Finish signing in in your browser. If Google says access is blocked, your address isn't a test user of the app yet: fix it in the console's OAuth consent screen, then Cancel and connect again.", + })); + let outcome: HTMLElement; + try { + await Bridge.gcalConnect(); + present["gcal-refresh-token"] = true; + outcome = h("div", { class: "notice ok", text: "Connected. Turn the Calendar pill on to see it next to Mochi." }); + } catch (err) { + outcome = h("div", { class: "notice err", text: String(err).replace(/^Error:\s*/, "") }); + } + if (mine !== attempt) return; // cancelled, or another attempt took over + waiting = false; + clear(feedback); + feedback.append(outcome); + draw(present["gcal-refresh-token"] ?? false); + }); + + // Takes effect at once here; Rust drops its listener within a second. + cancel.addEventListener("click", () => { + attempt++; + waiting = false; + void Bridge.gcalCancel(); + clear(feedback); + draw(present["gcal-refresh-token"] ?? false); + }); + + disconnect.addEventListener("click", async () => { + clear(feedback); + try { + await Bridge.gcalDisconnect(); + present["gcal-refresh-token"] = false; + feedback.append(h("div", { class: "notice ok", text: "Disconnected, and access revoked at Google." })); + } catch (err) { + feedback.append(h("div", { class: "notice err", text: `Could not disconnect: ${String(err)}` })); + } + draw(present["gcal-refresh-token"] ?? false); + }); + + draw(present["gcal-refresh-token"] ?? false); + return h( + "div", + { style: "display:flex;flex-direction:column;gap:6px" }, + h("div", { class: "row" }, h("label", { style: "min-width:104px", text: "Account" }), dotEl, status, connect, cancel, disconnect, consoleLink), + feedback, + ); +} + const MAX_ACTIVE = 4; function integrationsSection(present: Record): HTMLElement { @@ -341,6 +436,8 @@ function integrationsSection(present: Record): HTMLElement { ), ); } + if (def.extra) rows.append(def.extra(present)); + if (def.hint) rows.append(h("div", { class: "hint", text: def.hint })); list.append( h("div", { style: "display:flex;gap:12px;align-items:flex-start" }, @@ -434,6 +531,7 @@ async function main() { const keys = [ "stripe-api-key", "github-token", "vercel-token", "n8n-url", "n8n-api-key", "resend-api-key", "notion-api-key", "calcom-api-key", + "gcal-client-id", "gcal-client-secret", "gcal-refresh-token", ]; const present: Record = {}; for (const k of keys) present[k] = (await Bridge.secretPresent(k)) ?? false; diff --git a/windows/src/style.css b/windows/src/style.css index bfbeb20eca..2ea5b981e6 100644 --- a/windows/src/style.css +++ b/windows/src/style.css @@ -609,6 +609,12 @@ body { color: var(--red-text); } +.reminder-detail { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + .who-row { display: flex; align-items: center; @@ -1187,6 +1193,46 @@ input[type="range"]::-webkit-slider-thumb { flex: 0 0 auto; } +/* Rows that open something (GitHub, Calendar). */ +.int-go { + border: 0; + background: transparent; + font: inherit; + color: inherit; + text-align: left; + width: 100%; + cursor: default; +} + +.int-go:hover { + background: rgba(255, 255, 255, 0.05); +} + +.int-state { + font: 500 10px var(--font); + margin-left: auto; + padding-left: 4px; + flex: 0 0 auto; +} + +.int-join { + font: 600 10px var(--font); + padding: 1px 7px; + border-radius: 999px; + margin-left: auto; + flex: 0 0 auto; +} + +.int-join:hover { + filter: brightness(1.25); +} + +.int-star { + color: #f5a524; + display: grid; + place-items: center; +} + .int-more, .int-back { border: 0; diff --git a/windows/src/views/integrations.ts b/windows/src/views/integrations.ts index b8ad73c8fb..5dd3f84f4f 100644 --- a/windows/src/views/integrations.ts +++ b/windows/src/views/integrations.ts @@ -44,13 +44,15 @@ function arr(id: string, key: string): Record[] { // ── Not configured / idle ───────────────────────────────────────────────────── -const OPEN_URLS: Record = { +/** Where "Open …" and the card's ↗ button go. */ +export const OPEN_URLS: Record = { integration_resend: "https://resend.com/emails", integration_vercel: "https://vercel.com/dashboard", - integration_github: "https://github.com", + integration_github: "https://github.com/pulls", integration_stripe: "https://dashboard.stripe.com/payments", integration_notion: "https://notion.so", integration_calcom: "https://app.cal.com/bookings", + integration_gcal: "https://calendar.google.com", }; function idleCard(task: AgentTask, openSettings: () => void): HTMLElement { @@ -59,7 +61,12 @@ function idleCard(task: AgentTask, openSettings: () => void): HTMLElement { const error = info?.error ?? null; // The Claude Code pill is about hooks, not a key — the macOS wording would be // misleading here. - const missing = task.id === "integration_claude" ? "Hooks not installed" : "Key not configured"; + const missing = + task.id === "integration_claude" + ? "Hooks not installed" + : task.id === "integration_gcal" + ? "Google account not connected" + : "Key not configured"; const label = error ?? (configured ? "Connected · loading…" : missing); const statusColor = error || !configured ? "#F4505E" : "#22C55E"; @@ -211,21 +218,130 @@ function statRow(icon: string, color: string, label: string, value: string): HTM ); } +const GREEN = "#22C55E"; +const RED = "#F4505E"; +const AMBER = "#F5A524"; +const GREY = "#6B7079"; + +/** A list row that opens `url` — the GitHub and Calendar cards are all links. */ +function linkRow(accent: string, first: boolean, url: string, ...children: Node[]): HTMLElement { + const row = h( + "button", + { + class: first ? "int-row int-go first" : "int-row int-go", + onclick: () => { + if (url) void Bridge.openUrl(url); + }, + }, + dot(accent, 5), + ...children, + ); + if (first) row.style.background = `${accent}14`; + return row; +} + +function trailing(text: string, color: string): HTMLElement { + return h("span", { class: "int-state", style: `color:${color}`, text }); +} + +/** CI of the Claude Code session's branch: what its last commit's checks say. */ +function ciRow(b: Record, first: boolean): HTMLElement { + const state = b.state as string | null; + const [accent, label] = !b.pushed + ? [GREY, "not pushed"] + : state === "SUCCESS" + ? [GREEN, "passing"] + : state === "FAILURE" || state === "ERROR" + ? [RED, "failing"] + : state === "PENDING" || state === "EXPECTED" + ? [AMBER, "running"] + : [GREY, "no checks"]; + const failing = Array.isArray(b.failing) ? (b.failing as string[]) : []; + const cells: Node[] = [h("span", { class: "int-name", text: String(b.branch ?? "") })]; + // The branch name is what gets ellipsized; the PR number stays whole. + if (b.pr != null) cells.push(h("span", { class: "int-sub", style: "flex:0 0 auto", text: `#${b.pr}` })); + else if (failing.length) cells.push(h("span", { class: "int-sub", text: failing[0] })); + cells.push(trailing(label, accent)); + const url = String(b.url ?? "") || `https://github.com/${b.repo}/tree/${b.branch}`; + const row = linkRow(accent, first, url, ...cells); + row.title = `${b.repo} · ${b.branch}${failing.length ? `\nFailing: ${failing.join(", ")}` : ""}`; + return row; +} + function githubCard(): HTMLElement { const d = get("integration_github"); const stars = Number(d.totalStars ?? 0); const repos = Number(d.totalRepos ?? 0); const fmt = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)); + + const branch = d.branch as Record | null | undefined; + const requests = arr("integration_github", "requests"); + const pulls = arr("integration_github", "pulls").filter( + // The branch row already stands for its own pull request — unless a + // reviewer has had their say, which the CI row doesn't show. + (p) => + !(branch && branch.pr === p.number && p.repo === branch.repo) || + p.decision === "APPROVED" || p.decision === "CHANGES_REQUESTED", + ); + + // Three rows at most, most pressing first: your branch's CI, reviews people + // are waiting on from you, then your own pull requests. + const rows: HTMLElement[] = []; + if (branch) rows.push(ciRow(branch, true)); + for (const r of requests) { + if (rows.length >= 3) break; + rows.push( + linkRow( + AMBER, rows.length === 0, String(r.url ?? ""), + h("span", { class: "int-name", text: `#${r.number} ${r.title}` }), + trailing("review", AMBER), + ), + ); + } + for (const p of pulls) { + if (rows.length >= 3) break; + const [accent, label] = p.draft + ? [GREY, "draft"] + : p.decision === "APPROVED" + ? [GREEN, "approved"] + : p.decision === "CHANGES_REQUESTED" + ? [RED, "changes"] + : [GREY, "waiting"]; + rows.push( + linkRow( + accent, rows.length === 0, String(p.url ?? ""), + h("span", { class: "int-name", text: `#${p.number} ${p.title}` }), + trailing(label, accent), + ), + ); + } + + const starCount = h( + "span", + { class: "int-total", title: `${repos} repositories` }, + h("i", { class: "int-star" }, svg(ICONS.star, 9)), + h("span", { text: fmt(stars) }), + ); + + // Nothing on the go: the old overview, so the card is never empty. + if (rows.length === 0) { + return h( + "div", + { class: "int-card" }, + header(RED, "GitHub", "Overview"), + h( + "div", + { class: "int-stats" }, + statRow(ICONS.star, AMBER, "Total stars", fmt(stars)), + statRow(ICONS.stack, GREY, "Repositories", String(repos)), + ), + ); + } return h( "div", { class: "int-card" }, - header("#F4505E", "GitHub", "Overview"), - h( - "div", - { class: "int-stats" }, - statRow(ICONS.star, "#F5A524", "Total stars", fmt(stars)), - statRow(ICONS.stack, "#6B7079", "Repositories", String(repos)), - ), + header(RED, "GitHub", "Activity", starCount), + h("div", { class: "int-rows" }, ...rows), ); } @@ -315,6 +431,81 @@ function calcomCard(): HTMLElement { return h("div", { class: "int-card" }, header("#C9956A", "Cal.com", "Schedule"), rows); } +// ── Google Calendar ─────────────────────────────────────────────────────────── + +const GCAL = "#4285F4"; + +/** All-day events carry a bare YYYY-MM-DD: that day, at local midnight. */ +function eventStart(e: Record): Date { + if (typeof e.startMs === "number") return new Date(e.startMs); + const [y, m, d] = String(e.start).split("-").map(Number); + return new Date(y, (m ?? 1) - 1, d ?? 1); +} + +function sameDay(a: Date, b: Date): boolean { + return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); +} + +/** "14:30" today, "Fri 09:00" later in the week, "All day" / "Fri" for all-day. */ +function eventWhen(e: Record, now: Date): string { + const start = eventStart(e); + const today = sameDay(start, now); + const weekday = start.toLocaleDateString(undefined, { weekday: "short" }); + if (e.allDay) return today ? "All day" : weekday; + const time = start.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); + return today ? time : `${weekday} ${time}`; +} + +function gcalCard(): HTMLElement { + const now = new Date(); + const events = arr("integration_gcal", "events"); + // Meetings first; all-day events only fill what's left. + const ordered = [...events.filter((e) => !e.allDay), ...events.filter((e) => e.allDay)].slice(0, 3); + + const rows = h("div", { class: "int-rows tight" }); + if (ordered.length === 0) { + rows.append(h("div", { class: "int-empty", text: "Nothing on the calendar this week" })); + } + ordered.forEach((e, i) => { + const startMs = typeof e.startMs === "number" ? e.startMs : null; + const endMs = typeof e.endMs === "number" ? e.endMs : null; + const ongoing = startMs != null && endMs != null && startMs <= now.getTime() && now.getTime() < endMs; + const minutes = startMs != null ? Math.ceil((startMs - now.getTime()) / 60_000) : null; + const soon = minutes != null && minutes > 0 && minutes <= 60; + // Otherwise the calendar's own colour, as in Google Calendar. + const calColor = typeof e.color === "string" && /^#[0-9a-f]{6}$/i.test(e.color) ? e.color : GCAL; + const accent = ongoing ? GREEN : soon && minutes <= 5 ? AMBER : calColor; + + const cells: Node[] = [ + h("span", { class: "int-time", style: `color:${ongoing ? GREEN : "#8AB4F8"}`, text: ongoing ? "now" : eventWhen(e, now) }), + h("span", { class: "int-name", text: String(e.title ?? "") }), + ]; + // Join, for the meeting that is on or about to be: the one click that matters. + if (i === 0 && typeof e.meetUrl === "string" && (ongoing || (minutes != null && minutes <= 15))) { + const url = e.meetUrl; + cells.push( + h("span", { + class: "int-join", + role: "button", + style: `color:${accent};background:${accent}24`, + text: "Join", + onclick: (ev: Event) => { + ev.stopPropagation(); + void Bridge.openUrl(url); + }, + }), + ); + } else if (soon && !ongoing) { + cells.push(trailing(`in ${minutes}m`, minutes <= 5 ? AMBER : "#6B7079")); + } + rows.append(linkRow(accent, i === 0, String(e.url ?? ""), ...cells)); + }); + + const first = ordered[0]; + const kind = first && sameDay(eventStart(first), now) ? "Today" : "Upcoming"; + return h("div", { class: "int-card" }, header(GCAL, "Calendar", kind), rows); +} + // ── n8n ─────────────────────────────────────────────────────────────────────── function n8nCard(task: AgentTask, onDetail: () => void, openSettings: () => void): HTMLElement { @@ -398,6 +589,9 @@ export function hasIntegrationData(id: string): boolean { return arr(id, "pages").length > 0; case "integration_calcom": return info.loaded; + case "integration_gcal": + // Disconnecting sends `{}`: back to the idle card, not an empty agenda. + return Array.isArray(get(id).events); default: return false; } @@ -426,6 +620,8 @@ export function renderIntegrationCard(task: AgentTask, hooks: IntegrationCardHoo return notionCard(); case "integration_calcom": return calcomCard(); + case "integration_gcal": + return gcalCard(); default: return idleCard(task, hooks.openSettings); } diff --git a/windows/src/views/views.ts b/windows/src/views/views.ts index 7497fcd426..6520c4a647 100644 --- a/windows/src/views/views.ts +++ b/windows/src/views/views.ts @@ -208,6 +208,9 @@ function buildOverview(actions: ViewActions): ViewHost { task.id, detailOpen, task.state, task.steps.join("|"), info?.loaded, info?.error, info?.configured, JSON.stringify(info?.data ?? {}), + // The calendar says "now" and "in 12m": let it move with the clock + // even when a poll brings back the same events. + task.id === "integration_gcal" ? Math.floor(Date.now() / 60_000) : "", ].join("~"); if (key !== cardKey) { cardKey = key; @@ -385,6 +388,75 @@ function buildFinished(actions: ViewActions): ViewHost { }; } +// ── Calendar reminder ───────────────────────────────────────────────────────── + +/** "in 30 min", "in 1 h 15 min", "now" — from the live clock, not the poll's. */ +function startsIn(startMs: number): string { + const minutes = Math.ceil((startMs - Date.now()) / 60_000); + if (minutes <= 0) return "starting now"; + if (minutes < 60) return `in ${minutes} min`; + if (minutes < 1440) { + const h = Math.floor(minutes / 60); + const m = minutes % 60; + return m ? `in ${h} h ${m} min` : `in ${h} h`; + } + const d = Math.floor(minutes / 1440); + return d === 1 ? "tomorrow" : `in ${d} days`; +} + +function buildReminder(actions: ViewActions): ViewHost { + const who = h("div"); + const title = h("div", { class: "title" }); + const detail = h("div", { class: "sub reminder-detail" }); + const row = h("div", { class: "actions" }); + const el = h("div", { class: "view" }, card("indigo", stack(116, 16, who, title, detail, row))); + let rowKey = ""; + return { + el, + sync() { + const r = State.reminder; + if (!r) return; + const startMs = typeof r.startMs === "number" ? r.startMs : Date.now(); + const endMs = typeof r.endMs === "number" ? r.endMs : null; + const color = typeof r.color === "string" && /^#[0-9a-f]{6}$/i.test(r.color) ? r.color : "#4285F4"; + + clear(who); + who.append( + h("div", { class: "who-row" }, + dot(color, 8), + // Google names your primary calendar after your address: say Calendar. + h("span", { + class: "n", + text: typeof r.calendar === "string" && r.calendar && !r.calendar.includes("@") ? r.calendar : "Calendar", + }), + h("span", { text: startsIn(startMs) }), + ), + ); + title.textContent = String(r.title ?? ""); + + const time = (ms: number) => new Date(ms).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); + const when = endMs ? `${time(startMs)} – ${time(endMs)}` : time(startMs); + // The place, up to its first comma: "Casino Zaragoza", not the postcode. + const place = typeof r.location === "string" && !r.location.startsWith("https://") + ? r.location.split(",")[0] + : ""; + detail.textContent = place ? `${when} · ${place}` : when; + + // Built once per event: rebuilding between mouse-down and mouse-up + // would swallow the click. + const key = `${r.id}@${r.start}`; + if (key === rowKey) return; + rowKey = key; + clear(row); + const meet = typeof r.meetUrl === "string" ? r.meetUrl : ""; + const url = typeof r.url === "string" ? r.url : ""; + if (meet) row.append(btn("Join", "primary", () => { actions.openUrl(meet); actions.collapse(); })); + if (url) row.append(btn("Open", meet ? "secondary" : "primary", () => { actions.openUrl(url); actions.collapse(); })); + row.append(btn("OK", "secondary", () => actions.collapse())); + }, + }; +} + // ── Confused ────────────────────────────────────────────────────────────────── function buildConfused(): ViewHost { @@ -499,6 +571,7 @@ export function buildViews( map.set("question", buildQuestion()); map.set("error", buildError(actions)); map.set("finished", buildFinished(actions)); + map.set("reminder", buildReminder(actions)); map.set("confused", buildConfused()); map.set("note", buildNote()); map.set("settings", buildSettings(actions)); From 2731fd7892e1e079aa6661044113dfaa21b1f8c7 Mon Sep 17 00:00:00 2001 From: Jhoan Gallego Date: Thu, 1 Oct 2026 23:28:32 +0200 Subject: [PATCH 07/10] Windows: let Haiku use web search in the chat web_search_20260209 allows programmatic tool calling by default, which Haiku doesn't support, so the API rejected every chat request made with Haiku selected. Restrict the tool to direct calls for Haiku models only; Opus and Sonnet requests are unchanged. --- windows/src-tauri/src/claude.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/windows/src-tauri/src/claude.rs b/windows/src-tauri/src/claude.rs index 060a57c641..9229adefd6 100644 --- a/windows/src-tauri/src/claude.rs +++ b/windows/src-tauri/src/claude.rs @@ -105,11 +105,18 @@ pub async fn send( chat.push(json!({ "role": "user", "content": content })); + let mut web_search = json!({ "type": "web_search_20260209", "name": "web_search", "max_uses": 5 }); + // Haiku has no programmatic tool calling, which this web_search version + // asks for by default: the API rejects the request unless it is direct-only. + if model.starts_with("claude-haiku") { + web_search["allowed_callers"] = json!(["direct"]); + } + let body = json!({ "model": model, "max_tokens": MAX_TOKENS, "system": SYSTEM_PROMPT, - "tools": [{ "type": "web_search_20260209", "name": "web_search", "max_uses": 5 }], + "tools": [web_search], "fallbacks": "default", "messages": chat.snapshot(), }); From b012ed5a84b534b5b1cf86d2d7862fe679dc6df9 Mon Sep 17 00:00:00 2001 From: Jhoan Gallego Date: Mon, 5 Oct 2026 09:45:55 +0200 Subject: [PATCH 08/10] Mac: GitHub CI for the session's branch, pull requests and Google Calendar (WIP) Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 6 + CHANGELOG.md | 4 + .../NotchBuddy.xcodeproj/project.pbxproj | 26 +- .../Resources/CoucouAppStore.entitlements | 3 + NotchBuddy/Sources/App/AppDelegate.swift | 1 + NotchBuddy/Sources/App/AppState.swift | 69 ++- NotchBuddy/Sources/App/ClaudeService.swift | 3 + NotchBuddy/Sources/App/FileDropView.swift | 7 +- NotchBuddy/Sources/App/GcalPoller.swift | 348 +++++++++++++ NotchBuddy/Sources/App/GitHubActivity.swift | 424 ++++++++++++++++ NotchBuddy/Sources/App/GithubPoller.swift | 108 +++-- NotchBuddy/Sources/App/GoogleCalendar.swift | 459 ++++++++++++++++++ NotchBuddy/Sources/App/HookServer.swift | 45 +- NotchBuddy/Sources/App/IntegrationNews.swift | 129 +++++ .../Sources/App/IslandStateMachine.swift | 13 + NotchBuddy/Sources/App/IslandTypes.swift | 4 +- .../Sources/App/IslandViewContent.swift | 451 ++++++++++++++++- .../Sources/App/IslandWindowController.swift | 99 +++- NotchBuddy/Sources/App/SettingsView.swift | 148 ++++++ README.md | 4 +- docs/INTEGRATIONS.md | 14 +- docs/SPEC.md | 1 + docs/privacy.html | 16 +- scripts/test-github-activity.sh | 9 + scripts/test-google-calendar.sh | 9 + tests/GitHubActivityTests.swift | 214 ++++++++ tests/GoogleCalendarTests.swift | 251 ++++++++++ windows/README.md | 2 - windows/src-tauri/src/github.rs | 2 +- windows/src-tauri/src/integrations.rs | 4 +- windows/src/core/state.ts | 5 +- 31 files changed, 2788 insertions(+), 90 deletions(-) create mode 100644 NotchBuddy/Sources/App/GcalPoller.swift create mode 100644 NotchBuddy/Sources/App/GitHubActivity.swift create mode 100644 NotchBuddy/Sources/App/GoogleCalendar.swift create mode 100644 NotchBuddy/Sources/App/IntegrationNews.swift create mode 100755 scripts/test-github-activity.sh create mode 100755 scripts/test-google-calendar.sh create mode 100644 tests/GitHubActivityTests.swift create mode 100644 tests/GoogleCalendarTests.swift diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 07e363f65e..bb9851e382 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,6 +24,12 @@ jobs: - name: Test link filter run: bash scripts/test-safe-links.sh + - name: Test GitHub activity + run: bash scripts/test-github-activity.sh + + - name: Test Google Calendar + run: bash scripts/test-google-calendar.sh + - name: Build run: | xcodebuild \ diff --git a/CHANGELOG.md b/CHANGELOG.md index 5967b93856..8494eea43a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,3 +10,7 @@ - Windows build artifacts are now downloadable from a manual CI run — thanks @MysJofR - Any agent can talk to Mochi: tag a hook payload with `coucou_agent` (e.g. `nb-hook --agent my-agent`) and it gets its own pill in the island (#7, #9) — thanks @lacatu5 - Gemini CLI and Antigravity (agy) hook support on macOS: install from Settings and their sessions show up in the island — thanks @corefusiion +- GitHub, on macOS and Windows: the card follows the CI of your Claude Code session's branch, the reviews asked of you and your open pull requests; Mochi speaks up when a CI run finishes or a review comes in — thanks @JhoanG956 +- Google Calendar, on macOS and Windows: a new pill with your next events and Join for the meeting that's on, and a reminder card when Google Calendar would ring; signs in with your own read-only OAuth client — thanks @JhoanG956 +- Dragging a file onto the island and dropping it elsewhere (or pressing Escape) puts the island back the way it was, instead of leaving the drop zone open — thanks @JhoanG956 +- Shell steps in the ticker drop the `cd &&` prefix, so the command itself shows — thanks @JhoanG956 diff --git a/NotchBuddy/NotchBuddy.xcodeproj/project.pbxproj b/NotchBuddy/NotchBuddy.xcodeproj/project.pbxproj index 67afeee460..f9b6af82be 100644 --- a/NotchBuddy/NotchBuddy.xcodeproj/project.pbxproj +++ b/NotchBuddy/NotchBuddy.xcodeproj/project.pbxproj @@ -21,6 +21,7 @@ 186AB8904029767DAA5277CF /* IslandScreenGeometry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78BC1357785CA1B099A33969 /* IslandScreenGeometry.swift */; }; 1969590AFEE6CB21404346F1 /* CalcomPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3104222B3F17BA5E8DEA5277 /* CalcomPoller.swift */; }; 1F11EC4E6F604C612A1ADAD1 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2E4F18AA940EFF7E047B9F7 /* AppState.swift */; }; + 1F6F5652DDB4E75CD434ACF8 /* GoogleCalendar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60F56FCD2FB4EDA773F00C30 /* GoogleCalendar.swift */; }; 2132477000FD77F7EB234DAA /* FileDropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3232F258523DD8D09B670363 /* FileDropView.swift */; }; 2875610F701376BD65573386 /* WindowContextCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = E91B733ADC638E14E9BB6EFA /* WindowContextCapture.swift */; }; 2C798C3837A63B9BB561A1A0 /* HookServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79D8DD79C3DD9BBC584B3CB2 /* HookServer.swift */; }; @@ -30,6 +31,7 @@ 3756B7E1A55D9CEC8C4D34A4 /* IslandStateMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8212018F09EA1E96FBD63CD /* IslandStateMachine.swift */; }; 3F19756A396F125EC711247F /* IslandTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D3CE3617E9D336DDBA10F7A /* IslandTypes.swift */; }; 4117E7C8AA231AAE36E7509D /* UploadCanvasView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B241B043223AFBBFB7418A5E /* UploadCanvasView.swift */; }; + 4811B6D9CA21A22E303A7419 /* IntegrationNews.swift in Sources */ = {isa = PBXBuildFile; fileRef = E016B4D59A5BFA0B5A6D57AA /* IntegrationNews.swift */; }; 4B6AC00F22EC75E90E0157D5 /* AppLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F6D7015D6A77AA59323C0E7 /* AppLog.swift */; }; 4F1E0B3C838DA3CD6F864BC0 /* CalcomPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3104222B3F17BA5E8DEA5277 /* CalcomPoller.swift */; }; 56BE8C733F10A030A638FB60 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C1A7D5CBE2AC084A10C5FF3 /* SettingsView.swift */; }; @@ -38,8 +40,10 @@ 61CB7049490F61CFA1B36B64 /* AppLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F6D7015D6A77AA59323C0E7 /* AppLog.swift */; }; 61F73B27A7E3AFF584B2CC22 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2E4F18AA940EFF7E047B9F7 /* AppState.swift */; }; 62C4EE3F74DAA59764729E62 /* UploadCanvasView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B241B043223AFBBFB7418A5E /* UploadCanvasView.swift */; }; + 63093DC288B68F7F17249517 /* GitHubActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 168A9A73B519018FAB0AA465 /* GitHubActivity.swift */; }; 651FA3F09608DE5D02D61CCE /* BotCanvasView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A29F4C0B02BE91654D02AA0C /* BotCanvasView.swift */; }; 68A950E3C23FD612639D9776 /* IslandViewContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA02B08886939FF6FE0475DD /* IslandViewContent.swift */; }; + 6905D946C73F57F8BE5B852B /* GcalPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C754B64B7114DC5C6A33984 /* GcalPoller.swift */; }; 6FC65C190ED614C664C9BC17 /* SafeWebURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2FDED98F7F6C755A72C97A3 /* SafeWebURL.swift */; }; 78B3C6814431B2DD9D1F4949 /* SoundEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BE21C2B315D37AA70ED45E9 /* SoundEngine.swift */; }; 78FCEDC8F78B683AB10E6529 /* NotionPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE03E03D1804B0B92C5C2DAD /* NotionPoller.swift */; }; @@ -48,7 +52,10 @@ 8D1C01F283FA7806119A1C85 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C1A7D5CBE2AC084A10C5FF3 /* SettingsView.swift */; }; 967B525F7D3837F31D740D18 /* ResendPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61B5FDDE03F5F711146937E2 /* ResendPoller.swift */; }; 9DD56C95ECABDDCE23652D50 /* HookServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79D8DD79C3DD9BBC584B3CB2 /* HookServer.swift */; }; + AA9E15E26466C32F5D613599 /* GoogleCalendar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60F56FCD2FB4EDA773F00C30 /* GoogleCalendar.swift */; }; + AEFD43F6ECAAF9E8CDD8A6F4 /* IntegrationNews.swift in Sources */ = {isa = PBXBuildFile; fileRef = E016B4D59A5BFA0B5A6D57AA /* IntegrationNews.swift */; }; B3EC9CF3C8BD629C3530E8F1 /* sounds in Resources */ = {isa = PBXBuildFile; fileRef = C066E2B991AA4F56B2724263 /* sounds */; }; + B5D82540DC98DADCF3455E17 /* GcalPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C754B64B7114DC5C6A33984 /* GcalPoller.swift */; }; B605F976F4141435F27CF266 /* IslandRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15A1A8206F73376F4CAF2057 /* IslandRootView.swift */; }; C0E18D0F6F5F6D131E6066B8 /* GreetingCanvasView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C581F7B1A9F14B281B60063E /* GreetingCanvasView.swift */; }; C18227D148D99FE6EE3CC51A /* GithubPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CB8B6E5E9DA6164F979E88F /* GithubPoller.swift */; }; @@ -59,6 +66,7 @@ CA3321C14A11FED94E48B3E3 /* IslandRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15A1A8206F73376F4CAF2057 /* IslandRootView.swift */; }; CD8DECA66B9E09C97E469DB9 /* BotCanvasView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A29F4C0B02BE91654D02AA0C /* BotCanvasView.swift */; }; D82E93014C8087AE81511E40 /* IslandTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D3CE3617E9D336DDBA10F7A /* IslandTypes.swift */; }; + D9FF132804EB458F6FA69116 /* GitHubActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 168A9A73B519018FAB0AA465 /* GitHubActivity.swift */; }; DCAC390FF9EC65B945A15B24 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 506824380E9C80B9BC6DCE72 /* Assets.xcassets */; }; DE05D09A32117582AC93A24A /* NotchBuddyApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1BD8F3CAA60CF37640759E1 /* NotchBuddyApp.swift */; }; E4B9986674F4D425826F15A8 /* StripePoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EF78999D68D56ADE4CEFBE2 /* StripePoller.swift */; }; @@ -73,6 +81,7 @@ /* Begin PBXFileReference section */ 15A1A8206F73376F4CAF2057 /* IslandRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IslandRootView.swift; sourceTree = ""; }; + 168A9A73B519018FAB0AA465 /* GitHubActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitHubActivity.swift; sourceTree = ""; }; 192844E7299DEED5C2372067 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 1BE21C2B315D37AA70ED45E9 /* SoundEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundEngine.swift; sourceTree = ""; }; 2EF78999D68D56ADE4CEFBE2 /* StripePoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StripePoller.swift; sourceTree = ""; }; @@ -80,6 +89,7 @@ 3232F258523DD8D09B670363 /* FileDropView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDropView.swift; sourceTree = ""; }; 4DC2A07E42245A8CF3BBD2AB /* CoucouAppStore.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CoucouAppStore.app; sourceTree = BUILT_PRODUCTS_DIR; }; 506824380E9C80B9BC6DCE72 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 60F56FCD2FB4EDA773F00C30 /* GoogleCalendar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoogleCalendar.swift; sourceTree = ""; }; 61B5FDDE03F5F711146937E2 /* ResendPoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResendPoller.swift; sourceTree = ""; }; 6FD65943C32E150F1CD65C31 /* UploadSequenceEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadSequenceEngine.swift; sourceTree = ""; }; 71718D1B87A39A4879DD2A9D /* VercelPoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VercelPoller.swift; sourceTree = ""; }; @@ -88,6 +98,7 @@ 7C1A7D5CBE2AC084A10C5FF3 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 8CB8B6E5E9DA6164F979E88F /* GithubPoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GithubPoller.swift; sourceTree = ""; }; 8D3CE3617E9D336DDBA10F7A /* IslandTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IslandTypes.swift; sourceTree = ""; }; + 9C754B64B7114DC5C6A33984 /* GcalPoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GcalPoller.swift; sourceTree = ""; }; 9F6D7015D6A77AA59323C0E7 /* AppLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLog.swift; sourceTree = ""; }; A14A61070D293AB4BBA303F7 /* NotchBuddy.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = NotchBuddy.app; sourceTree = BUILT_PRODUCTS_DIR; }; A29F4C0B02BE91654D02AA0C /* BotCanvasView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BotCanvasView.swift; sourceTree = ""; }; @@ -102,6 +113,7 @@ D4540F630F8BA04AFFFA3979 /* BotEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BotEngine.swift; sourceTree = ""; }; D710BD7834228045869D0184 /* IslandWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IslandWindowController.swift; sourceTree = ""; }; D8212018F09EA1E96FBD63CD /* IslandStateMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IslandStateMachine.swift; sourceTree = ""; }; + E016B4D59A5BFA0B5A6D57AA /* IntegrationNews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationNews.swift; sourceTree = ""; }; E91B733ADC638E14E9BB6EFA /* WindowContextCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowContextCapture.swift; sourceTree = ""; }; F1BD8F3CAA60CF37640759E1 /* NotchBuddyApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchBuddyApp.swift; sourceTree = ""; }; FA02B08886939FF6FE0475DD /* IslandViewContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IslandViewContent.swift; sourceTree = ""; }; @@ -119,9 +131,13 @@ 3104222B3F17BA5E8DEA5277 /* CalcomPoller.swift */, A83D5410BD030B243ADDCFF4 /* ClaudeService.swift */, 3232F258523DD8D09B670363 /* FileDropView.swift */, + 9C754B64B7114DC5C6A33984 /* GcalPoller.swift */, + 168A9A73B519018FAB0AA465 /* GitHubActivity.swift */, 8CB8B6E5E9DA6164F979E88F /* GithubPoller.swift */, + 60F56FCD2FB4EDA773F00C30 /* GoogleCalendar.swift */, C581F7B1A9F14B281B60063E /* GreetingCanvasView.swift */, 79D8DD79C3DD9BBC584B3CB2 /* HookServer.swift */, + E016B4D59A5BFA0B5A6D57AA /* IntegrationNews.swift */, 15A1A8206F73376F4CAF2057 /* IslandRootView.swift */, 78BC1357785CA1B099A33969 /* IslandScreenGeometry.swift */, D8212018F09EA1E96FBD63CD /* IslandStateMachine.swift */, @@ -250,8 +266,8 @@ projectDirPath = ""; projectRoot = ""; targets = ( - 12E59C1D41BFA3D2F7CDD7F5 /* NotchBuddy */, 495922874E20462B9FC47E53 /* CoucouAppStore */, + 12E59C1D41BFA3D2F7CDD7F5 /* NotchBuddy */, ); }; /* End PBXProject section */ @@ -290,9 +306,13 @@ 4F1E0B3C838DA3CD6F864BC0 /* CalcomPoller.swift in Sources */, 2DEB459FBD0BA3CEAAA5FDE9 /* ClaudeService.swift in Sources */, E776C7D1DE5C62EA8D4BA3E3 /* FileDropView.swift in Sources */, + B5D82540DC98DADCF3455E17 /* GcalPoller.swift in Sources */, + D9FF132804EB458F6FA69116 /* GitHubActivity.swift in Sources */, C808B654C84D9F71DBF8493A /* GithubPoller.swift in Sources */, + AA9E15E26466C32F5D613599 /* GoogleCalendar.swift in Sources */, 8C32A0C3EAF536D3836F61D5 /* GreetingCanvasView.swift in Sources */, 2C798C3837A63B9BB561A1A0 /* HookServer.swift in Sources */, + 4811B6D9CA21A22E303A7419 /* IntegrationNews.swift in Sources */, CA3321C14A11FED94E48B3E3 /* IslandRootView.swift in Sources */, 186AB8904029767DAA5277CF /* IslandScreenGeometry.swift in Sources */, 5B9A00AFB0837731FA678E4E /* IslandStateMachine.swift in Sources */, @@ -326,9 +346,13 @@ 1969590AFEE6CB21404346F1 /* CalcomPoller.swift in Sources */, 0329E76BA4A8FEEA8A6D76E7 /* ClaudeService.swift in Sources */, 2132477000FD77F7EB234DAA /* FileDropView.swift in Sources */, + 6905D946C73F57F8BE5B852B /* GcalPoller.swift in Sources */, + 63093DC288B68F7F17249517 /* GitHubActivity.swift in Sources */, C18227D148D99FE6EE3CC51A /* GithubPoller.swift in Sources */, + 1F6F5652DDB4E75CD434ACF8 /* GoogleCalendar.swift in Sources */, C0E18D0F6F5F6D131E6066B8 /* GreetingCanvasView.swift in Sources */, 9DD56C95ECABDDCE23652D50 /* HookServer.swift in Sources */, + AEFD43F6ECAAF9E8CDD8A6F4 /* IntegrationNews.swift in Sources */, B605F976F4141435F27CF266 /* IslandRootView.swift in Sources */, 0D68A4C4E3422487DB7B2262 /* IslandScreenGeometry.swift in Sources */, 3756B7E1A55D9CEC8C4D34A4 /* IslandStateMachine.swift in Sources */, diff --git a/NotchBuddy/Resources/CoucouAppStore.entitlements b/NotchBuddy/Resources/CoucouAppStore.entitlements index 0fc6072483..acc431ca6c 100644 --- a/NotchBuddy/Resources/CoucouAppStore.entitlements +++ b/NotchBuddy/Resources/CoucouAppStore.entitlements @@ -6,6 +6,9 @@ com.apple.security.network.client + + com.apple.security.network.server + com.apple.security.files.user-selected.read-write com.apple.security.files.bookmarks.app-scope diff --git a/NotchBuddy/Sources/App/AppDelegate.swift b/NotchBuddy/Sources/App/AppDelegate.swift index f94af2bda7..1bfca7eea9 100644 --- a/NotchBuddy/Sources/App/AppDelegate.swift +++ b/NotchBuddy/Sources/App/AppDelegate.swift @@ -96,6 +96,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { StripePoller.shared.start() CalcomPoller.shared.start() NotionPoller.shared.start() + GcalPoller.shared.start() NotificationCenter.default.addObserver(self, selector: #selector(openSettings), name: .openFullSettings, object: nil) } diff --git a/NotchBuddy/Sources/App/AppState.swift b/NotchBuddy/Sources/App/AppState.swift index 7ce7f89058..99799fb37d 100644 --- a/NotchBuddy/Sources/App/AppState.swift +++ b/NotchBuddy/Sources/App/AppState.swift @@ -13,13 +13,14 @@ extension AgentTask { AgentTask(id: "integration_github", name: "GitHub", color: "#F4505E", state: .idle, steps: [], source: .n8n, isIntegration: true), AgentTask(id: "integration_notion", name: "Notion", color: "#8C8C8C", state: .idle, steps: [], source: .n8n, isIntegration: true), AgentTask(id: "integration_calcom", name: "Cal.com", color: "#C9956A", state: .idle, steps: [], source: .n8n, isIntegration: true), + AgentTask(id: "integration_gcal", name: "Calendar", color: "#4285F4", state: .idle, steps: [], source: .n8n, isIntegration: true), AgentTask(id: "integration_stripe", name: "Stripe", color: "#0570DE", state: .idle, steps: [], source: .n8n, isIntegration: true), ] /// IDs that can be toggled (VS Code is always on and excluded from this list) static let toggleableIntegrationIds: [String] = [ "integration_resend", "integration_n8n", "integration_vercel", "integration_github", - "integration_notion", "integration_calcom", "integration_stripe", + "integration_notion", "integration_calcom", "integration_gcal", "integration_stripe", ] } @@ -160,8 +161,15 @@ final class AppState: ObservableObject { @Published var resendEmails: [ResendEmail] = [] @Published var resendTotal: Int? = nil - // GitHub stats (populated by GithubPoller) - @Published var githubStats: GitHubStats? = nil + // GitHub: CI of the session's branch, reviews, pull requests (populated by GithubPoller) + @Published var githubActivity: GitHubActivity? = nil + @Published var githubError: String? = nil // last API error (nil = ok) + + // Google Calendar (populated by GcalPoller). nil = not connected or not loaded yet. + @Published var gcalEvents: [GcalEvent]? = nil + @Published var gcalError: String? = nil + // The event the reminder card is about + @Published var reminder: GcalEvent? = nil // Stripe (populated by StripePoller) @Published var stripePayments: [StripePayment] = [] @@ -302,6 +310,61 @@ final class AppState: ObservableObject { syncMode() } + // MARK: - Integration news (GitHub, Google Calendar) + + private var integrationClearWork: [String: DispatchWorkItem] = [:] + + /// Shows what a poller has to say on its pill: state, badge, sound, and the + /// compact island so the badge is seen. A calendar reminder opens its own card. + func announce(_ news: IntegrationNews, for id: String, reminder event: GcalEvent? = nil) { + guard let idx = tasks.firstIndex(where: { $0.id == id }) else { return } + // Something waiting on you (a review asked of you, a meeting about to + // start) reads as a question with the amber badge, not as done or broken. + tasks[idx].state = news.attention ? .question : news.success ? .finished : .error + tasks[idx].steps = news.detail.map { [news.label, $0] } ?? [news.label] + tasks[idx].stepIndex = tasks[idx].steps.count - 1 + if focusId != id { + tasks[idx].pillBadge = news.attention ? .approval : news.success ? .finished : .error + } + + if let event { + // A reminder is a message, not a badge: open a card that says what, + // when and where, with Join / Open. Unless you are in the middle of + // something — the chat, an approval, a file drop — then it waits as + // the amber badge on the Calendar pill. + SoundEngine.shared.play("approval") + let busy: Set = [.prompt, .approval, .question, .upload, .uploading, .choose, .mail] + if mode == .expanded && busy.contains(view) { + NotificationCenter.default.post(name: .hookReveal, object: nil) + } else { + reminder = event + setFocus(id) + if mode == .expanded { view = .reminder } + else { NotificationCenter.default.post(name: .islandAlert, object: IslandView.reminder) } + } + } else { + SoundEngine.shared.play(news.attention ? "question" : news.success ? "finish" : "error") + // Same as the other pollers: show the compact island so the badge is seen, + // but never steal the screen. + NotificationCenter.default.post(name: .hookReveal, object: nil) + } + + // Auto-clear the pill after 60 s (the card's data stays). + integrationClearWork[id]?.cancel() + let work = DispatchWorkItem { [weak self] in + guard let self else { return } + self.integrationClearWork[id] = nil + guard let i = self.tasks.firstIndex(where: { $0.id == id }), + [.finished, .error, .question].contains(self.tasks[i].state) else { return } + self.tasks[i].state = .idle + self.tasks[i].steps = [] + self.tasks[i].stepIndex = 0 + self.tasks[i].pillBadge = nil + } + integrationClearWork[id] = work + DispatchQueue.main.asyncAfter(deadline: .now() + 60, execute: work) + } + } // MARK: - Supporting types diff --git a/NotchBuddy/Sources/App/ClaudeService.swift b/NotchBuddy/Sources/App/ClaudeService.swift index 06dc9a543d..d1391a7173 100644 --- a/NotchBuddy/Sources/App/ClaudeService.swift +++ b/NotchBuddy/Sources/App/ClaudeService.swift @@ -69,6 +69,9 @@ final class KeychainStore: @unchecked Sendable { "stripe-api-key", "calcom-api-key", "notion-api-key", + // Google Calendar: the user's own Desktop OAuth client, and the refresh + // token Google returns once they have consented. + "gcal-client-id", "gcal-client-secret", "gcal-refresh-token", ] private init() { diff --git a/NotchBuddy/Sources/App/FileDropView.swift b/NotchBuddy/Sources/App/FileDropView.swift index 9a47a33dcd..fede9a7ba3 100644 --- a/NotchBuddy/Sources/App/FileDropView.swift +++ b/NotchBuddy/Sources/App/FileDropView.swift @@ -10,6 +10,8 @@ final class FileDropNSView: NSView { var onDragUpdated: ((CGPoint) -> Void)? var onDragExited: (() -> Void)? var onFilesDropped: (([URL]) -> Void)? + /// A drop with no file in it (text, a link, a file promise): the drag is over all the same. + var onDropRejected: (() -> Void)? override init(frame: NSRect) { super.init(frame: frame) @@ -34,7 +36,10 @@ final class FileDropNSView: NSView { guard let urls = sender.draggingPasteboard.readObjects( forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true] - ) as? [URL], !urls.isEmpty else { return false } + ) as? [URL], !urls.isEmpty else { + onDropRejected?() + return false + } onFilesDropped?(urls) return true } diff --git a/NotchBuddy/Sources/App/GcalPoller.swift b/NotchBuddy/Sources/App/GcalPoller.swift new file mode 100644 index 0000000000..e969e1193e --- /dev/null +++ b/NotchBuddy/Sources/App/GcalPoller.swift @@ -0,0 +1,348 @@ +import AppKit +import Foundation + +// MARK: - GcalPoller +// Google Calendar, the network side of GoogleCalendar.swift. +// +// There is no Coucou server, so there is no shared "Sign in with Google": the +// user creates a Desktop OAuth client in their own Google Cloud project and +// pastes its ID and secret in Settings. Connecting then runs the installed-app +// flow Google documents for desktop apps — PKCE, a one-shot listener on +// 127.0.0.1, the browser for consent — and keeps only the refresh token, in the +// Keychain. Access tokens live in memory for their hour. +// +// Polled every minute so a five-minute reminder can't be skipped over. + +final class GcalPoller: @unchecked Sendable { + static let shared = GcalPoller() + + private var timer: DispatchSourceTimer? + private let lock = NSLock() + + /// Bumped by every Connect and Cancel click, so a newer attempt (or a cancel) + /// retires an older listener still waiting on a browser tab the user closed. + /// Google's own error pages — "access blocked" for an app in Testing whose user + /// isn't on the test list — never redirect back, so without this the wait could + /// only end at the timeout. + private var attempt = 0 + private var access: (token: String, until: Date)? + /// The calendar list barely changes: fetched every ten minutes, not every poll. + private var calendars: (list: [GcalCalendar], at: Date)? + private var reminders = GcalReminders() + private var polling = false + /// A poll asked for while one was out (Connect finishing during a timer poll): + /// run once more when it's done, so the new sign-in is never left unread. + private var pollAgain = false + + private static let calendarsTTL: TimeInterval = 10 * 60 + /// How long the browser has to come back with a code. + private static let connectTimeout: TimeInterval = 5 * 60 + + private init() {} + + func start() { + guard timer == nil else { return } + let t = DispatchSource.makeTimerSource(queue: .global(qos: .background)) + t.schedule(deadline: .now() + 4, repeating: 60) + t.setEventHandler { + Task { + // A pill the user switched off makes no network calls at all. + guard await MainActor.run(body: { AppState.shared.activeIntegrations.contains("integration_gcal") }) + else { return } + await GcalPoller.shared.poll() + } + } + t.resume() + timer = t + } + + // MARK: - Connecting + + /// Settings → Cancel: stops waiting for Google within a second. + func cancel() { + lock.withLock { attempt += 1 } + } + + private func isCurrent(_ mine: Int) -> Bool { + lock.withLock { attempt == mine } + } + + /// Opens Google's consent page and returns once the refresh token is stored. + /// Throws a message to show: cancelled, timed out, wrong client… + func connect() async throws { + guard let clientId = KeychainStore.shared.get("gcal-client-id"), + let clientSecret = KeychainStore.shared.get("gcal-client-secret") else { + throw GcalProblem.shown("Save the client ID and client secret first.") + } + let mine = lock.withLock { () -> Int in attempt += 1; return attempt } + + let listener = try LoopbackListener() + defer { listener.close() } + let redirect = "http://127.0.0.1:\(listener.port)" + + let verifier = GcalOAuth.b64url(try GcalOAuth.randomBytes(32)) + let challenge = GcalOAuth.b64url(GcalOAuth.sha256(Array(verifier.utf8))) + let state = GcalOAuth.b64url(try GcalOAuth.randomBytes(16)) + + let query = GcalOAuth.form([ + ("client_id", clientId), + ("redirect_uri", redirect), + ("response_type", "code"), + ("scope", Gcal.scope), + ("code_challenge", challenge), + ("code_challenge_method", "S256"), + ("state", state), + // A refresh token, every time — Google only hands one out on consent. + ("access_type", "offline"), + ("prompt", "consent"), + ]) + guard let url = URL(string: "\(Gcal.authURL)?\(query)") else { + throw GcalProblem.shown("Could not build the Google sign-in link.") + } + await MainActor.run { _ = NSWorkspace.shared.open(url) } + log("gcal: waiting for the browser") + + let code = try await waitForCode(listener, state: state, attempt: mine) + + let (status, body) = try await post(Gcal.tokenURL, GcalOAuth.form([ + ("code", code), + ("client_id", clientId), + ("client_secret", clientSecret), + ("redirect_uri", redirect), + ("grant_type", "authorization_code"), + ("code_verifier", verifier), + ])) + guard (200..<300).contains(status) else { + throw GcalProblem.shown(GcalOAuth.googleError(body) ?? "Google answered \(status)") + } + guard let refresh = body["refresh_token"] as? String else { + throw GcalProblem.shown("Google didn't return a refresh token.") + } + KeychainStore.shared.set("gcal-refresh-token", value: refresh) + lock.withLock { + remember(body) + reminders.clear() + calendars = nil + } + log("gcal: connected") + + await poll() + } + + /// Waits on a background thread for the browser to come back (LoopbackListener.waitForCode). + private func waitForCode(_ listener: LoopbackListener, state: String, attempt mine: Int) async throws -> String { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + DispatchQueue.global(qos: .userInitiated).async { + let result = listener.waitForCode( + state: state, + deadline: Date().addingTimeInterval(Self.connectTimeout), + stillWanted: { self.isCurrent(mine) } + ) + cont.resume(with: result.mapError { $0 as Error }) + } + } + } + + /// Revokes the grant at Google (best effort) and forgets the token. + func disconnect() async { + cancel() + if let refresh = KeychainStore.shared.get("gcal-refresh-token") { + _ = try? await post(Gcal.revokeURL, GcalOAuth.form([("token", refresh)])) + } + KeychainStore.shared.remove("gcal-refresh-token") + lock.withLock { + access = nil + reminders.clear() + calendars = nil + } + await MainActor.run { + AppState.shared.gcalEvents = nil + AppState.shared.gcalError = nil + } + } + + // MARK: - Access tokens + + /// Call with `lock` held. + private func remember(_ body: [String: Any]) { + guard let token = body["access_token"] as? String else { return } + let ttl = jsonInt(body["expires_in"]) ?? 3600 + // A minute early, so a token never expires between check and use. + access = (token, Date().addingTimeInterval(TimeInterval(max(0, ttl - 60)))) + } + + private func accessToken(_ refresh: String) async throws -> String { + if let cached = lock.withLock({ access }), Date() < cached.until { return cached.token } + guard let clientId = KeychainStore.shared.get("gcal-client-id"), + let clientSecret = KeychainStore.shared.get("gcal-client-secret") else { + throw GcalProblem.shown("Client ID or secret missing") + } + let (status, body) = try await post(Gcal.tokenURL, GcalOAuth.form([ + ("client_id", clientId), + ("client_secret", clientSecret), + ("refresh_token", refresh), + ("grant_type", "refresh_token"), + ])) + guard (200..<300).contains(status) else { + // invalid_grant: revoked, or the 7-day expiry of an OAuth app left in + // "Testing" — either way only a new sign-in fixes it. + if body["error"] as? String == "invalid_grant" { throw GcalProblem.shown(Gcal.expired) } + throw GcalProblem.shown(GcalOAuth.googleError(body) ?? "Google answered \(status)") + } + lock.withLock { remember(body) } + guard let token = body["access_token"] as? String else { throw GcalProblem.shown("No access token") } + return token + } + + // MARK: - HTTP + + private func post(_ url: String, _ form: String) async throws -> (Int, [String: Any]) { + guard let u = URL(string: url) else { throw GcalProblem.offline } + var req = URLRequest(url: u, timeoutInterval: 15) + req.httpMethod = "POST" + req.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + req.httpBody = Data(form.utf8) + return try await send(req) + } + + private func send(_ req: URLRequest) async throws -> (Int, [String: Any]) { + guard let (data, response) = try? await URLSession.shared.data(for: req), + let http = response as? HTTPURLResponse else { throw GcalProblem.offline } + let json = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] ?? [:] + return (http.statusCode, json) + } + + /// GET with the cached access token, retrying once with a fresh one when it + /// turns out to have been revoked. + private func getJSON(_ refresh: String, _ url: String) async throws -> (Int, [String: Any]) { + for _ in 0..<2 { + let token = try await accessToken(refresh) + guard let u = URL(string: url) else { throw GcalProblem.offline } + var req = URLRequest(url: u, timeoutInterval: 15) + req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + let (status, json) = try await send(req) + if status == 401 { + lock.withLock { access = nil } + continue + } + return (status, json) + } + throw GcalProblem.shown(Gcal.expired) + } + + // MARK: - Polling + + /// Every calendar shown in Google Calendar — your own, shared and subscribed + /// ones alike — not just the primary one, which is often the emptiest. + private func calendarList(_ refresh: String) async throws -> [GcalCalendar] { + if let cached = lock.withLock({ calendars }), Date().timeIntervalSince(cached.at) < Self.calendarsTTL { + return cached.list + } + let url = "https://www.googleapis.com/calendar/v3/users/me/calendarList?minAccessRole=reader&maxResults=50" + let (status, json) = try await getJSON(refresh, url) + let list: [GcalCalendar] + if status == 403 { + // A token from before Coucou asked for the calendar list: the primary + // calendar is all it may read until the next Connect. + log("gcal: no calendar-list access, reading the primary calendar only") + list = [GcalCalendar(id: "primary", name: "", color: nil, primary: true)] + } else if !(200..<300).contains(status) { + throw GcalProblem.shown(GcalOAuth.googleError(json) ?? "API error \(status)") + } else { + list = GcalCalendar.parseList(json) + } + lock.withLock { calendars = (list, Date()) } + return list + } + + func pollNow() { + Task { await poll() } + } + + func poll() async { + guard KeychainStore.shared.get("gcal-refresh-token") != nil else { return } + guard lock.withLock({ () -> Bool in + if polling { pollAgain = true; return false } + polling = true + return true + }) else { return } + repeat { + await pollOnce() + } while lock.withLock({ () -> Bool in + let again = pollAgain + pollAgain = false + if !again { polling = false } + return again + }) + } + + private func pollOnce() async { + guard let refresh = KeychainStore.shared.get("gcal-refresh-token") else { return } + do { + let list = try await calendarList(refresh) + + let now = RFC3339.now() + let query = GcalOAuth.form([ + // Events ending after now: what's on right now counts. + ("timeMin", RFC3339.utc(now)), + ("timeMax", RFC3339.utc(now + 7 * 86_400)), + ("singleEvents", "true"), + ("orderBy", "startTime"), + ("maxResults", "10"), + ]) + + var events: [GcalEvent] = [] + var readAny = false + var firstError: String? + // Your main calendar's default reminders; the last resort is Gcal.fallbackLead. + var primaryDefaults: [Int] = [] + for var cal in list { + let url = "https://www.googleapis.com/calendar/v3/calendars/\(GcalOAuth.pct(cal.id))/events?\(query)" + let (status, json) = try await getJSON(refresh, url) + guard (200..<300).contains(status) else { + // One unreadable calendar (an import gone stale) shouldn't hide + // the others. Typically "Google Calendar API has not been used…". + log("gcal: calendar HTTP \(status)") + if firstError == nil { firstError = GcalOAuth.googleError(json) ?? "API error \(status)" } + continue + } + readAny = true + var defaults = reminderMinutes(json["defaultReminders"]) + if cal.primary { primaryDefaults = defaults } + if defaults.isEmpty { defaults = primaryDefaults.isEmpty ? [Gcal.fallbackLead] : primaryDefaults } + // The events response knows the calendar's real name even when + // the list was a fallback. + if cal.name.isEmpty { cal.name = json["summary"] as? String ?? "" } + events += (json["items"] as? [Any] ?? []).compactMap { + GcalEvent.parse($0, calendar: cal, defaults: defaults) + } + } + if !readAny, let firstError { throw GcalProblem.shown(firstError) } + + let merged = GcalEvent.merge(events) + let reminder = lock.withLock { reminders.next(merged, now: now) } + let shown = Array(merged.prefix(8)) + await MainActor.run { + let state = AppState.shared + // Disconnected while this poll was out: don't bring the card back. + guard KeychainStore.shared.get("gcal-refresh-token") != nil else { return } + state.gcalError = nil + state.gcalEvents = shown + if let reminder { + state.announce(reminder.news, for: "integration_gcal", reminder: reminder.event) + } + } + } catch GcalProblem.shown(let message) { + await MainActor.run { + guard KeychainStore.shared.get("gcal-refresh-token") != nil else { return } + AppState.shared.gcalError = message + } + } catch { + // Offline: say nothing, try again next poll. + } + } + + private func log(_ message: String) { + appendAppLog("integrations.log", message) + } +} diff --git a/NotchBuddy/Sources/App/GitHubActivity.swift b/NotchBuddy/Sources/App/GitHubActivity.swift new file mode 100644 index 0000000000..d365c859d1 --- /dev/null +++ b/NotchBuddy/Sources/App/GitHubActivity.swift @@ -0,0 +1,424 @@ +import Foundation + +// GitHub — CI for the branch you're on, and the pull requests waiting on you +// or on someone else. Same rules as windows/src-tauri/src/github.rs. +// +// "The branch you're on" is the branch checked out in the folder of the last +// Claude Code session, read straight from its .git directory: no `git` process +// and nothing to configure. Everything else comes from one GraphQL query per poll +// (GithubPoller). +// +// Events, at most one per poll, only for things that changed since the last +// poll: a CI run on that branch finishing, a new review on one of your pull +// requests, a new review requested from you. The first poll only fills the card. + +// MARK: - The session's branch + +struct GitHubBranchRef: Equatable, Sendable { + var owner: String + var name: String + /// The branch's name on GitHub (its upstream), which is what CI ran on. + var branch: String +} + +enum GitHubBranch { + /// The GitHub repository and branch checked out in `cwd`, if there is one. + static func of(cwd: URL) -> GitHubBranchRef? { + guard let (gitDir, commonDir) = findGitDir(cwd), + let head = try? String(contentsOf: gitDir.appendingPathComponent("HEAD"), encoding: .utf8) + else { return nil } + // Detached HEAD (rebase, bisect, checkout of a tag): no branch to follow. + let trimmed = head.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("ref: refs/heads/") else { return nil } + let local = String(trimmed.dropFirst("ref: refs/heads/".count)) + guard let config = try? String(contentsOf: commonDir.appendingPathComponent("config"), encoding: .utf8) + else { return nil } + return resolveUpstream(config: config, local: local) + } + + /// `.git` is a directory in a normal checkout and a `gitdir: …` file in a + /// worktree, whose config then lives in the main repository (`commondir`). + static func findGitDir(_ start: URL) -> (URL, URL)? { + let fm = FileManager.default + var dir = start.standardizedFileURL + while true { + let dotGit = dir.appendingPathComponent(".git") + var isDir: ObjCBool = false + if fm.fileExists(atPath: dotGit.path, isDirectory: &isDir) { + if isDir.boolValue { return (dotGit, dotGit) } + guard let text = try? String(contentsOf: dotGit, encoding: .utf8) else { return nil } + let line = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard line.hasPrefix("gitdir:") else { return nil } + let target = line.dropFirst("gitdir:".count).trimmingCharacters(in: .whitespaces) + let gitDir = target.hasPrefix("/") + ? URL(fileURLWithPath: target) + : dir.appendingPathComponent(target).standardizedFileURL + var common = gitDir + if let c = try? String(contentsOf: gitDir.appendingPathComponent("commondir"), encoding: .utf8) { + let rel = c.trimmingCharacters(in: .whitespacesAndNewlines) + common = rel.hasPrefix("/") + ? URL(fileURLWithPath: rel) + : gitDir.appendingPathComponent(rel).standardizedFileURL + } + return (gitDir, common) + } + let parent = dir.deletingLastPathComponent() + if parent.path == dir.path || dir.path == "/" { return nil } + dir = parent + } + } + + /// Follows `branch..remote` / `.merge` to the GitHub repo and remote + /// branch name; a branch with no upstream is assumed to be pushed to origin + /// under the same name. + static func resolveUpstream(config: String, local: String) -> GitHubBranchRef? { + let entries = parseGitConfig(config) + func get(_ section: String, _ sub: String, _ key: String) -> String? { + entries.first { $0.section == section && $0.sub == sub && $0.key == key }?.value + } + + let remote = get("branch", local, "remote") ?? "origin" + var branch = local + if let merge = get("branch", local, "merge"), merge.hasPrefix("refs/heads/") { + branch = String(merge.dropFirst("refs/heads/".count)) + } + + // No such remote: take whichever remote points at GitHub. + guard let url = get("remote", remote, "url") + ?? entries.first(where: { $0.section == "remote" && $0.key == "url" && parseGitHubURL($0.value) != nil })?.value, + let (owner, name) = parseGitHubURL(url) else { return nil } + return GitHubBranchRef(owner: owner, name: name, branch: branch) + } + + /// (section, subsection, key, value) — just enough of git-config(1) for + /// `[remote "x"]` and `[branch "x"]`. Section and key names are case-insensitive. + static func parseGitConfig(_ text: String) -> [(section: String, sub: String, key: String, value: String)] { + var out: [(section: String, sub: String, key: String, value: String)] = [] + var section = "", sub = "" + let quotes = CharacterSet(charactersIn: "\"") + for raw in text.components(separatedBy: .newlines) { + let line = raw.trimmingCharacters(in: .whitespaces) + if line.isEmpty || line.hasPrefix("#") || line.hasPrefix(";") { continue } + if line.hasPrefix("["), line.hasSuffix("]") { + let header = String(line.dropFirst().dropLast()) + if let space = header.firstIndex(where: { $0 == " " || $0 == "\t" }) { + section = header[.. [String: Any] { + guard let t = target else { return ["owner": "", "name": "", "ref": "", "withRepo": false] } + return ["owner": t.owner, "name": t.name, "ref": "refs/heads/\(t.branch)", "withRepo": true] + } +} + +// MARK: - What the query says + +struct GitHubActivity: Equatable, Sendable { + /// Pull requests nobody has touched in a month are abandoned, not "on the go". + static let staleAfterSecs = 30 * 86_400 + + var login = "" + var totalRepos = 0 + var totalStars = 0 + var branch: Branch? + var pulls: [Pull] = [] + var requests: [Request] = [] + var requestCount = 0 + + struct Branch: Equatable, Sendable { + var repo: String + var branch: String + /// False when the branch only exists locally. + var pushed: Bool + var oid: String + /// SUCCESS, FAILURE, ERROR, PENDING, EXPECTED — nil when no check ran. + var state: String? + var failing: [String] + var url: String + var committedAt: String + var pr: Int? + } + + struct Pull: Equatable, Sendable { + var repo: String + var number: Int + var title: String + var url: String + var draft: Bool + var head: String + /// APPROVED, CHANGES_REQUESTED, REVIEW_REQUIRED, or nil. + var decision: String? + var reviews: [Review] + } + + struct Review: Equatable, Sendable { + var id: String + var state: String + var author: String + } + + struct Request: Equatable, Sendable { + var repo: String + var number: Int + var title: String + var url: String + var author: String + } + + /// `data` is the GraphQL answer's `data` object. + static func parse(_ data: [String: Any], target: GitHubBranchRef?, now: Int = RFC3339.now()) -> GitHubActivity { + let viewer = data["viewer"] + var out = GitHubActivity() + out.login = jsonString(viewer, "login") + + let repos = jsonValue(viewer, "repositories") + out.totalRepos = jsonInt(jsonValue(repos, "totalCount")) ?? 0 + out.totalStars = (jsonValue(repos, "nodes") as? [Any] ?? []) + .compactMap { jsonInt(jsonValue($0, "stargazerCount")) } + .reduce(0, +) + + // Pull requests nobody has touched in a month are abandoned, not "on + // the go" — bots opening them under your name leave plenty of those. + // A new review bumps updatedAt, so one coming back to life reappears. + let freshSince = now - staleAfterSecs + out.pulls = (jsonValue(viewer, "pullRequests", "nodes") as? [Any] ?? []) + .filter { p in + guard let t = RFC3339.parse(jsonString(p, "updatedAt")) else { return true } + return t >= freshSince + } + .map { p in + Pull( + repo: jsonString(p, "repository", "nameWithOwner"), + number: jsonInt(jsonValue(p, "number")) ?? 0, + title: jsonString(p, "title"), + url: jsonString(p, "url"), + draft: jsonBool(jsonValue(p, "isDraft")) ?? false, + head: jsonString(p, "headRefName"), + decision: jsonValue(p, "reviewDecision") as? String, + reviews: (jsonValue(p, "latestReviews", "nodes") as? [Any] ?? []).map { r in + Review(id: jsonString(r, "id"), state: jsonString(r, "state"), + author: jsonString(r, "author", "login")) + } + ) + } + + out.requests = (jsonValue(data, "requests", "nodes") as? [Any] ?? []) + .filter { jsonValue($0, "url") != nil } + .map { n in + Request( + repo: jsonString(n, "repository", "nameWithOwner"), + number: jsonInt(jsonValue(n, "number")) ?? 0, + title: jsonString(n, "title"), + url: jsonString(n, "url"), + author: jsonString(n, "author", "login") + ) + } + out.requestCount = jsonInt(jsonValue(data, "requests", "issueCount")) ?? 0 + + // A repository the token can't see comes back null: no CI row then. + if let t = target, let repo = jsonValue(data, "repository") { + let nameWithOwner = jsonString(repo, "nameWithOwner") + let pr = out.pulls.first { + $0.head == t.branch && $0.repo.lowercased() == nameWithOwner.lowercased() + }?.number + if let commit = jsonValue(repo, "ref", "target") { + let rollup = jsonValue(commit, "statusCheckRollup") + var failing: [String] = [] + var failingURL: String? + for c in jsonValue(rollup, "contexts", "nodes") as? [Any] ?? [] { + let name: String, bad: Bool, url: String + switch jsonString(c, "__typename") { + case "CheckRun": + name = jsonString(c, "name") + bad = ["FAILURE", "TIMED_OUT", "STARTUP_FAILURE", "ACTION_REQUIRED"] + .contains(jsonString(c, "conclusion")) + url = jsonString(c, "detailsUrl") + case "StatusContext": + name = jsonString(c, "context") + bad = ["FAILURE", "ERROR"].contains(jsonString(c, "state")) + url = jsonString(c, "targetUrl") + default: + continue + } + if bad { + if failingURL == nil, !url.isEmpty { failingURL = url } + failing.append(name) + } + } + out.branch = Branch( + repo: nameWithOwner, branch: t.branch, pushed: true, + oid: jsonString(commit, "oid"), + state: jsonValue(rollup, "state") as? String, + failing: failing, + // Straight to the failing job when there is one; the commit page + // (which lists every check) otherwise. + url: failingURL ?? jsonString(commit, "url"), + committedAt: jsonString(commit, "committedDate"), + pr: pr + ) + } else { + out.branch = Branch(repo: nameWithOwner, branch: t.branch, pushed: false, oid: "", + state: nil, failing: [], url: "", committedAt: "", pr: pr) + } + } + return out + } +} + +// MARK: - What changed since the last poll + +struct GitHubMemory { + /// (repo@branch@commit, rollup state) at the last poll. + private var ci: (key: String, state: String?)? + /// nil until the first poll, which fills these without announcing anything. + private var reviews: Set? + private var requests: Set? + + private static func isPending(_ state: String?) -> Bool { + state == "PENDING" || state == "EXPECTED" + } + + /// Remembers this snapshot and returns the one event worth announcing. + mutating func diff(_ now: GitHubActivity) -> IntegrationNews? { + // (priority, event): lower wins. A red build beats everything else. + var candidates: [(Int, IntegrationNews)] = [] + + // CI: only a run we watched go from pending to done. Switching to another + // repo or branch, or a commit that was already finished when first seen, + // says nothing — that's old news, not something that just happened. + if let b = now.branch, b.pushed { + let key = "\(b.repo)@\(b.branch)@\(b.oid)" + if let prev = ci, prev.key == key, Self.isPending(prev.state) { + switch b.state { + case "SUCCESS": + candidates.append((5, IntegrationNews(success: true, label: "CI passed · \(b.branch)", + detail: b.repo))) + case "FAILURE", "ERROR": + candidates.append((0, IntegrationNews(success: false, label: "CI failed · \(b.branch)", + detail: b.failing.isEmpty ? b.repo : b.failing.joined(separator: ", ")))) + default: + break + } + } + ci = (key, b.state) + } + + // Reviews on your pull requests, by anyone but you. + var reviewIds = Set() + for p in now.pulls { + for r in p.reviews { + if r.author == now.login || r.id.isEmpty { continue } + reviewIds.insert(r.id) + guard let seen = reviews, !seen.contains(r.id) else { continue } + let on = "\(r.author) on \(p.title)" + switch r.state { + case "CHANGES_REQUESTED": + candidates.append((1, IntegrationNews(success: false, label: "Changes requested · #\(p.number)", detail: on))) + case "APPROVED": + candidates.append((4, IntegrationNews(success: true, label: "Approved · #\(p.number)", detail: on))) + case "COMMENTED": + candidates.append((3, IntegrationNews(success: true, label: "New review · #\(p.number)", detail: on, attention: true))) + default: + break + } + } + } + reviews = reviewIds + + // Reviews someone asked of you. + let requestURLs = Set(now.requests.map(\.url)) + if let seen = requests { + for r in now.requests where !seen.contains(r.url) { + candidates.append((2, IntegrationNews(success: true, label: "Review requested · #\(r.number)", + detail: "\(r.author): \(r.title)", attention: true))) + } + } + requests = requestURLs + + // The first of the most pressing ones, like a stable sort would give. + var best: (Int, IntegrationNews)? + for c in candidates where best == nil || c.0 < best!.0 { best = c } + return best?.1 + } +} diff --git a/NotchBuddy/Sources/App/GithubPoller.swift b/NotchBuddy/Sources/App/GithubPoller.swift index 7b51c4cc19..f2d3b5f748 100644 --- a/NotchBuddy/Sources/App/GithubPoller.swift +++ b/NotchBuddy/Sources/App/GithubPoller.swift @@ -1,61 +1,103 @@ import Foundation +// MARK: - GithubPoller +// CI of the branch of the last Claude Code session, the reviews asked of you, and +// your open pull requests — one GraphQL query per minute (GitHubActivity.swift has +// the rules). Every minute rather than every five: a CI run or a review is only +// worth hearing about while it is still news. + final class GithubPoller: @unchecked Sendable { static let shared = GithubPoller() private var timer: DispatchSourceTimer? + private let lock = NSLock() + private var sessionCwd: String? + private var memory = GitHubMemory() + private var polling = false private init() {} func start() { guard timer == nil else { return } let t = DispatchSource.makeTimerSource(queue: .global(qos: .background)) - t.schedule(deadline: .now() + 7, repeating: 300) // every 5 minutes - t.setEventHandler { [weak self] in self?.poll() } + t.schedule(deadline: .now() + 7, repeating: 60) // every minute + t.setEventHandler { + Task { @MainActor in + // A pill the user switched off makes no network calls at all. + guard AppState.shared.activeIntegrations.contains("integration_github") else { return } + DispatchQueue.global(qos: .background).async { GithubPoller.shared.poll() } + } + } t.resume() timer = t } - private func poll() { - guard let token = KeychainStore.shared.get("github-token") else { return } - fetchUser(token: token) + /// Called for every Claude Code hook that carries a `cwd`: the card follows + /// the branch of whichever session spoke last. + func noteCwd(_ cwd: String) { + guard !cwd.isEmpty else { return } + lock.withLock { sessionCwd = cwd } } - private func fetchUser(token: String) { - guard let url = URL(string: "https://api.github.com/user") else { return } - var req = URLRequest(url: url, timeoutInterval: 10) - req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - req.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + func pollNow() { DispatchQueue.global(qos: .userInitiated).async { self.poll() } } - URLSession.shared.dataTask(with: req) { [weak self] data, response, _ in - guard let self else { return } - let code = (response as? HTTPURLResponse)?.statusCode ?? 0 - guard let data, code == 200, - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return } - - let publicRepos = (json["public_repos"] as? Int) ?? 0 - let privateOwned = (json["owned_private_repos"] as? Int) - ?? (json["total_private_repos"] as? Int) - ?? 0 - let totalRepos = publicRepos + privateOwned + private func poll() { + guard let token = KeychainStore.shared.get("github-token") else { return } + guard lock.withLock({ () -> Bool in + if polling { return false } + polling = true + return true + }) else { return } - self.fetchStars(token: token, totalRepos: totalRepos) - }.resume() - } + let cwd = lock.withLock { sessionCwd } + let target = cwd.flatMap { GitHubBranch.of(cwd: URL(fileURLWithPath: $0)) } - private func fetchStars(token: String, totalRepos: Int) { - guard let url = URL(string: "https://api.github.com/user/repos?per_page=100&affiliation=owner&sort=pushed") else { return } + guard let url = URL(string: "https://api.github.com/graphql"), + let body = try? JSONSerialization.data(withJSONObject: [ + "query": GitHubQuery.text, + "variables": GitHubQuery.variables(for: target), + ]) else { + lock.withLock { polling = false } + return + } var req = URLRequest(url: url, timeoutInterval: 15) + req.httpMethod = "POST" req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - req.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.setValue("Coucou", forHTTPHeaderField: "User-Agent") + req.httpBody = body + + URLSession.shared.dataTask(with: req) { [weak self] data, response, _ in + guard let self else { return } + defer { self.lock.withLock { self.polling = false } } + // Offline: say nothing, try again next poll. + guard let http = response as? HTTPURLResponse else { return } - URLSession.shared.dataTask(with: req) { data, response, _ in - let code = (response as? HTTPURLResponse)?.statusCode ?? 0 - guard let data, code == 200, - let repos = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return } + guard (200..<300).contains(http.statusCode) else { + let message: String + switch http.statusCode { + case 401: message = "Invalid API key (401)" + case 403: message = "Token lacks the needed scope" + default: message = "API error \(http.statusCode)" + } + DispatchQueue.main.async { AppState.shared.githubError = message } + return + } - let totalStars = repos.reduce(0) { $0 + ((($1["stargazers_count"] as? Int) ?? 0)) } + let json = data.flatMap { (try? JSONSerialization.jsonObject(with: $0)) as? [String: Any] } ?? [:] + guard let answer = json["data"] as? [String: Any] else { + let message = (jsonValue((json["errors"] as? [Any])?.first, "message") as? String) + ?? "Unexpected answer from GitHub" + appendAppLog("integrations.log", "github graphql: \(message)") + DispatchQueue.main.async { AppState.shared.githubError = String(message.prefix(80)) } + return + } + let activity = GitHubActivity.parse(answer, target: target) + let news = self.lock.withLock { self.memory.diff(activity) } DispatchQueue.main.async { - AppState.shared.githubStats = GitHubStats(totalRepos: totalRepos, totalStars: totalStars) + let state = AppState.shared + state.githubError = nil + state.githubActivity = activity + if let news { state.announce(news, for: "integration_github") } } }.resume() } diff --git a/NotchBuddy/Sources/App/GoogleCalendar.swift b/NotchBuddy/Sources/App/GoogleCalendar.swift new file mode 100644 index 0000000000..990df414a7 --- /dev/null +++ b/NotchBuddy/Sources/App/GoogleCalendar.swift @@ -0,0 +1,459 @@ +import Darwin +import Foundation +import CryptoKit +import Security + +// Google Calendar — the next events of every calendar you show in Google Calendar, +// and a reminder when Google Calendar itself would ring. Same rules as +// windows/src-tauri/src/gcal.rs; the network side lives in GcalPoller. +// +// Read-only scopes. Nothing is ever written to the calendar. + +enum Gcal { + /// Read-only: events, and the list of calendars to read them from. + static let scope = "https://www.googleapis.com/auth/calendar.events.readonly " + + "https://www.googleapis.com/auth/calendar.calendarlist.readonly" + static let authURL = "https://accounts.google.com/o/oauth2/v2/auth" + static let tokenURL = "https://oauth2.googleapis.com/token" + static let revokeURL = "https://oauth2.googleapis.com/revoke" + + /// Minutes before an event Mochi speaks up when neither the event, its + /// calendar nor your primary calendar sets any reminder. + static let fallbackLead = 5 + + static let expired = "Google sign-in expired — reconnect in Settings" +} + +// MARK: - Calendars and events + +/// A calendar to read: the ones ticked in Google Calendar's sidebar. +struct GcalCalendar: Equatable, Sendable { + var id: String + var name: String + var color: String? + var primary: Bool + + /// The calendar list, keeping what Google Calendar shows; the primary calendar + /// first, since its default reminders stand in for calendars that have none. + static func parseList(_ json: [String: Any]) -> [GcalCalendar] { + let list: [GcalCalendar] = (json["items"] as? [Any] ?? []).compactMap { c in + let shown = jsonBool(jsonValue(c, "selected")) == true || jsonBool(jsonValue(c, "primary")) == true + guard shown, jsonBool(jsonValue(c, "hidden")) != true, + let id = jsonValue(c, "id") as? String else { return nil } + return GcalCalendar( + id: id, + // What you renamed it to, else its own name. + name: (jsonValue(c, "summaryOverride") as? String) ?? (jsonValue(c, "summary") as? String) ?? "", + color: jsonValue(c, "backgroundColor") as? String, + primary: jsonBool(jsonValue(c, "primary")) == true + ) + } + return list.filter(\.primary) + list.filter { !$0.primary } + } +} + +struct GcalEvent: Equatable, Sendable, Identifiable { + var id: String + var title: String + /// RFC 3339 for timed events, YYYY-MM-DD for all-day ones. + var start: String + var end: String + var startSecs: Int? + var endSecs: Int? + var allDay: Bool + var meetURL: String? + var url: String + /// The calendar's colour in Google Calendar, for the row's dot. + var color: String? + var calendar: String + var location: String? + /// Minutes before the start at which to speak up, ascending. Empty when the + /// event says "no notification". + var reminders: [Int] + + /// `defaults`: the calendar's default reminders, already resolved to the + /// primary calendar's (or Gcal.fallbackLead) when it has none. + static func parse(_ item: Any, calendar: GcalCalendar, defaults: [Int]) -> GcalEvent? { + if jsonString(item, "status") == "cancelled" || jsonString(item, "eventType") == "workingLocation" { + return nil + } + // Declined meetings aren't yours to go to. + let attendees = jsonValue(item, "attendees") as? [Any] ?? [] + if attendees.contains(where: { + jsonBool(jsonValue($0, "self")) == true && jsonString($0, "responseStatus") == "declined" + }) { return nil } + + func when(_ k: String) -> (String, Bool) { + if let t = jsonValue(item, k, "dateTime") as? String { return (t, false) } + return (jsonString(item, k, "date"), true) + } + let (start, allDay) = when("start") + let (end, _) = when("end") + if start.isEmpty { return nil } + + let video = (jsonValue(item, "conferenceData", "entryPoints") as? [Any] ?? []) + .first { jsonString($0, "entryPointType") == "video" } + let location = jsonValue(item, "location") as? String + let meetURL = (jsonValue(item, "hangoutLink") as? String) + ?? (video.flatMap { jsonValue($0, "uri") as? String }) + // Zoom and Teams links pasted into the location field. + ?? location.flatMap { $0.hasPrefix("https://") ? $0 : nil } + + let title = jsonString(item, "summary") + return GcalEvent( + id: jsonString(item, "id"), + title: title.isEmpty ? "(No title)" : title, + start: start, + end: end, + startSecs: allDay ? nil : RFC3339.parse(start), + endSecs: allDay ? nil : RFC3339.parse(end), + allDay: allDay, + meetURL: meetURL, + url: jsonString(item, "htmlLink"), + color: calendar.color, + calendar: calendar.name, + location: (location?.isEmpty ?? true) ? nil : location, + // Your own overrides on this event win — including "none at all". + reminders: jsonBool(jsonValue(item, "reminders", "useDefault")) == false + ? reminderMinutes(jsonValue(item, "reminders", "overrides")) + : defaults + ) + } + + var key: String { "\(id)@\(start)" } + + /// Chronological across calendars; an all-day event counts from midnight UTC. + var sortKey: Int { + startSecs ?? RFC3339.parse("\(start)T00:00:00Z") ?? Int.max + } + + /// An invitation can sit in two calendars at once: keep the first, then sort + /// chronologically (ties keep their order). + static func merge(_ events: [GcalEvent]) -> [GcalEvent] { + var seen = Set() + let unique = events.filter { seen.insert($0.id).inserted } + return unique.enumerated() + .sorted { ($0.element.sortKey, $0.offset) < ($1.element.sortKey, $1.offset) } + .map(\.element) + } +} + +/// Minutes from a Calendar `reminders` list (`[{method, minutes}]`), ascending. +/// Email and pop-up alike: either way you asked to be told then. +func reminderMinutes(_ list: Any?) -> [Int] { + let minutes = (list as? [Any] ?? []).compactMap { jsonInt(jsonValue($0, "minutes")) }.filter { $0 >= 0 } + return Array(Set(minutes)).sorted() +} + +// MARK: - Reminders + +struct GcalReminder: Equatable, Sendable { + var event: GcalEvent + /// Minutes until the start, rounded up. + var minutes: Int + var news: IntegrationNews +} + +/// "id@start#minutes" of every reminder already rung, so each rings once (and +/// again if the event is moved). +struct GcalReminders { + private(set) var reminded = Set() + + mutating func clear() { reminded.removeAll() } + + /// The reminder that has just come due, the way Google Calendar itself would + /// ring it: at each of the event's reminder times. + /// + /// Only the latest reminder due is ever shown — after a restart, a meeting in + /// 20 minutes says "in 20 min" once, not its day-before and its 30-minute + /// reminders back to back. One per poll; a second meeting due at the same + /// moment comes a minute later. + mutating func next(_ events: [GcalEvent], now: Int) -> GcalReminder? { + // Forget events that have dropped off the list, so the set stays small. + let live = events.map(\.key) + reminded = reminded.filter { k in live.contains { k.hasPrefix($0) } } + + for e in events { + guard let start = e.startSecs else { continue } + // A reminder "at the time of the event" still gets its minute. + if now >= start + 60 { continue } + let due = e.reminders.filter { start - $0 * 60 <= now } + guard let latest = due.min() else { continue } + if reminded.contains("\(e.key)#\(latest)") { continue } + for m in due { reminded.insert("\(e.key)#\(m)") } + let minutes = max(0, (start - now + 59) / 60) + return GcalReminder( + event: e, minutes: minutes, + news: IntegrationNews(success: true, label: e.title, detail: Self.startsIn(minutes), attention: true) + ) + } + return nil + } + + static func startsIn(_ minutes: Int) -> String { + switch minutes { + case ...0: return "Starting now" + case 1: return "Starts in a minute" + case 2...59: return "Starts in \(minutes) min" + case 60...1439: + let (h, m) = (minutes / 60, minutes % 60) + return m == 0 ? "Starts in \(h) h" : "Starts in \(h) h \(m) min" + default: + let d = minutes / 1440 + return d == 1 ? "Starts in a day" : "Starts in \(d) days" + } + } +} + +// MARK: - Signing in (installed-app flow) + +enum GcalOAuth { + /// application/x-www-form-urlencoded, also good for query strings. + static func form(_ pairs: [(String, String)]) -> String { + pairs.map { "\(pct($0.0))=\(pct($0.1))" }.joined(separator: "&") + } + + static func pct(_ s: String) -> String { + var out = "" + for b in s.utf8 { + switch b { + case UInt8(ascii: "A")...UInt8(ascii: "Z"), UInt8(ascii: "a")...UInt8(ascii: "z"), + UInt8(ascii: "0")...UInt8(ascii: "9"), + UInt8(ascii: "-"), UInt8(ascii: "."), UInt8(ascii: "_"), UInt8(ascii: "~"): + out.unicodeScalars.append(Unicode.Scalar(b)) + default: + out += String(format: "%%%02X", b) + } + } + return out + } + + static func parseQuery(_ q: String) -> [(String, String)] { + q.split(separator: "&", omittingEmptySubsequences: true).map { p in + if let eq = p.firstIndex(of: "=") { + return (pctDecode(String(p[.. String { + let bytes = Array(s.utf8) + var out: [UInt8] = [] + var i = 0 + func hex(_ b: UInt8) -> UInt8? { + switch b { + case UInt8(ascii: "0")...UInt8(ascii: "9"): return b - UInt8(ascii: "0") + case UInt8(ascii: "a")...UInt8(ascii: "f"): return b - UInt8(ascii: "a") + 10 + case UInt8(ascii: "A")...UInt8(ascii: "F"): return b - UInt8(ascii: "A") + 10 + default: return nil + } + } + while i < bytes.count { + switch bytes[i] { + case UInt8(ascii: "+"): + out.append(UInt8(ascii: " ")) + case UInt8(ascii: "%") where i + 2 < bytes.count: + if let hi = hex(bytes[i + 1]), let lo = hex(bytes[i + 2]) { + out.append(hi * 16 + lo) + i += 2 + } else { + out.append(UInt8(ascii: "%")) + } + case let b: + out.append(b) + } + i += 1 + } + return String(decoding: out, as: UTF8.self) + } + + static func b64url(_ bytes: [UInt8]) -> String { + Data(bytes).base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + static func sha256(_ data: [UInt8]) -> [UInt8] { + Array(SHA256.hash(data: data)) + } + + /// The system CSPRNG, for the PKCE verifier and the `state` check. + static func randomBytes(_ n: Int) throws -> [UInt8] { + var buf = [UInt8](repeating: 0, count: n) + guard SecRandomCopyBytes(kSecRandomDefault, n, &buf) == errSecSuccess else { + throw GcalProblem.shown("No randomness available for the sign-in.") + } + return buf + } + + /// What the loopback listener does with one request from the browser. + enum Answer: Equatable { + /// A stray request (a favicon, a stale tab, a forged state): 404, keep waiting. + case stray + /// Google redirected back without a code: missing parameter, keep waiting. + case badRequest + /// The user said no (or Google said no for them). + case denied(String) + case code(String) + } + + /// `firstLine` is the HTTP request line, e.g. `GET /?state=…&code=… HTTP/1.1`. + static func route(_ firstLine: String, state: String) -> Answer { + guard firstLine.hasPrefix("GET ") else { return .stray } + let target = firstLine.dropFirst(4).split(separator: " ", maxSplits: 1).first.map(String.init) ?? "" + let (path, query): (String, String) + if let q = target.firstIndex(of: "?") { + (path, query) = (String(target[.. String? { params.first { $0.0 == k }?.1 } + + if path != "/" || param("state") != state { return .stray } + if let error = param("error") { return .denied(error) } + if let code = param("code") { return .code(code) } + return .badRequest + } + + static func response(status: String, message: String) -> String { + let html = "Coucou" + + "

\(message)

" + return "HTTP/1.1 \(status)\r\nContent-Type: text/html; charset=utf-8\r\n" + + "Content-Length: \(html.utf8.count)\r\nConnection: close\r\nCache-Control: no-store\r\n\r\n\(html)" + } + + /// The message Google put in an error answer, shortened for the island. + static func googleError(_ body: [String: Any]) -> String? { + let message = (jsonValue(body, "error", "message") as? String) + ?? (body["error_description"] as? String) + ?? (body["error"] as? String) + return message.map { String($0.prefix(90)) } + } +} + +/// Why a Google request gave nothing to show. +enum GcalProblem: Error, Equatable { + /// Offline or timed out: say nothing, try again next poll. + case offline + /// A sign-in or API problem worth showing. + case shown(String) + + var message: String { + switch self { + case .offline: return "No connection" + case .shown(let m): return m + } + } +} + +// MARK: - One-shot loopback listener (127.0.0.1, a free port) + +final class LoopbackListener: @unchecked Sendable { + let port: UInt16 + private let fd: Int32 + private let closeLock = NSLock() + private var closed = false + + init() throws { + let fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { throw GcalProblem.shown("Could not open a local port for the sign-in.") } + var addr = sockaddr_in() + addr.sin_len = UInt8(MemoryLayout.size) + addr.sin_family = sa_family_t(AF_INET) + addr.sin_port = 0 + addr.sin_addr.s_addr = inet_addr("127.0.0.1") + let bound = withUnsafePointer(to: &addr) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.bind(fd, $0, socklen_t(MemoryLayout.size)) + } + } + guard bound == 0, Darwin.listen(fd, 4) == 0 else { + Darwin.close(fd) + throw GcalProblem.shown("Could not open a local port for the sign-in.") + } + var got = sockaddr_in() + var len = socklen_t(MemoryLayout.size) + _ = withUnsafeMutablePointer(to: &got) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { getsockname(fd, $0, &len) } + } + self.fd = fd + self.port = UInt16(bigEndian: got.sin_port) + } + + /// Serves the one redirect Google sends back, checking `state`, and answers the + /// browser with a page saying it can be closed. Stray requests (a favicon, a + /// stale tab) get a 404 and the wait goes on. Blocking: call off the main thread. + /// `stillWanted` is checked every second, so Cancel ends the wait even though + /// Google's "access blocked" page never redirects back. + func waitForCode(state: String, deadline: Date, stillWanted: () -> Bool) -> Result { + while true { + if !stillWanted() { return .failure(.shown("Sign-in cancelled.")) } + if Date() >= deadline { return .failure(.shown("Timed out waiting for Google.")) } + guard let client = accept(timeoutMs: 1000) else { continue } + switch GcalOAuth.route(Self.readRequestLine(client), state: state) { + case .stray: + Self.reply(client, GcalOAuth.response(status: "404 Not Found", message: "Nothing here.")) + case .badRequest: + Self.reply(client, GcalOAuth.response(status: "400 Bad Request", message: "Missing code.")) + case .denied(let error): + Self.reply(client, GcalOAuth.response( + status: "200 OK", message: "Coucou wasn't given access. You can close this tab.")) + return .failure(.shown("Google sign-in cancelled (\(error)).")) + case .code(let code): + Self.reply(client, GcalOAuth.response( + status: "200 OK", message: "Coucou is connected to Google Calendar. You can close this tab.")) + return .success(code) + } + } + } + + /// The next connection, or nil after `timeoutMs` without one. + func accept(timeoutMs: Int32) -> Int32? { + var pfd = pollfd(fd: fd, events: Int16(POLLIN), revents: 0) + guard Darwin.poll(&pfd, 1, timeoutMs) > 0 else { return nil } + let client = Darwin.accept(fd, nil, nil) + return client >= 0 ? client : nil + } + + /// Reads the request head (at most 16 KB, 5 s) and returns its first line. + static func readRequestLine(_ client: Int32) -> String { + var tv = timeval(tv_sec: 5, tv_usec: 0) + setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout.size)) + var on: Int32 = 1 + setsockopt(client, SOL_SOCKET, SO_NOSIGPIPE, &on, socklen_t(MemoryLayout.size)) + var head: [UInt8] = [] + var chunk = [UInt8](repeating: 0, count: 2048) + let end: [UInt8] = [13, 10, 13, 10] + while head.count < 16 * 1024, head.firstRange(of: end) == nil { + let n = recv(client, &chunk, chunk.count, 0) + if n <= 0 { break } + head.append(contentsOf: chunk[0.. Bool { + termProgram.lowercased().contains("vscode") || bundleId.lowercased().contains("vscode") + } + + private static func isShownHost(termProgram: String, bundleId: String) -> Bool { + isVSCode(termProgram: termProgram, bundleId: bundleId) + || bundleId == "com.anthropic.claudefordesktop" + } + // MARK: - Agent validation + dynamic pill /// Validates a coucou_agent name: lowercase, digits and hyphens, 1–24 chars. @@ -364,9 +378,7 @@ final class HookServer: @unchecked Sendable { let termProgram = payload["term_program"] as? String ?? "" let bundleId = payload["bundle_id"] as? String ?? "" - let isVSCode = termProgram.lowercased().contains("vscode") || - bundleId.lowercased().contains("vscode") - guard isVSCode else { + guard Self.isVSCode(termProgram: termProgram, bundleId: bundleId) else { Task.detached { [weak self] in self?.sendLine(fd: fd, text: #"{"permissionDecision":"ask"}"#) close(fd) @@ -374,6 +386,8 @@ final class HookServer: @unchecked Sendable { return } + GithubPoller.shared.noteCwd(cwd) + let tool = payload["tool_name"] as? String ?? "Tool" var command = tool if let input = payload["tool_input"] as? [String: Any] { @@ -515,8 +529,7 @@ final class HookServer: @unchecked Sendable { ] let label = labels[tool] ?? tool if let cmd = input["command"] as? String { - let short = String(cmd.prefix(40)) - return "\(label) · \(short)" + return "\(label) · \(Self.commandSummary(cmd))" } else if let path = input["path"] as? String { return "\(label) · \(URL(fileURLWithPath: path).lastPathComponent)" } else if let file = input["file_path"] as? String { @@ -527,6 +540,20 @@ final class HookServer: @unchecked Sendable { return label } + /// The part of a shell command worth reading in one ticker line: whitespace and + /// newlines collapsed, and the `cd &&` / `cd ;` prefix Claude + /// puts in front of most commands dropped, since the card already names the + /// project. The ticker ellipsizes, so this only caps pathological lengths. + static func commandSummary(_ cmd: String) -> String { + var s = cmd.split(whereSeparator: \.isWhitespace).joined(separator: " ") + let prefix = #"^(?:cd|Set-Location|pushd)\s+("[^"]*"|'[^']*'|\S+)\s*(?:&&|;)\s*"# + if let r = s.range(of: prefix, options: [.regularExpression, .caseInsensitive]) { + let rest = String(s[r.upperBound...]) + if !rest.isEmpty { s = rest } + } + return String(s.prefix(120)) + } + // MARK: - Logging private func nbLog(_ message: String) { diff --git a/NotchBuddy/Sources/App/IntegrationNews.swift b/NotchBuddy/Sources/App/IntegrationNews.swift new file mode 100644 index 0000000000..204b5a9384 --- /dev/null +++ b/NotchBuddy/Sources/App/IntegrationNews.swift @@ -0,0 +1,129 @@ +import Foundation + +// MARK: - Integration news +// What a poller has to say when something happened since its last poll — the Swift +// side of IntegrationEvent in windows/src-tauri/src/integrations.rs. AppState turns +// it into the pill's state, badge and sound (AppState.announce). + +struct IntegrationNews: Equatable, Sendable { + var success: Bool + var label: String + var detail: String? + /// Something is waiting on the user (a review request, a meeting about to + /// start) rather than something that finished: amber badge, not green/red. + var attention: Bool = false +} + +// MARK: - Dates +// RFC 3339 both ways, in whole seconds since 1970, UTC — the same rules as +// windows/src-tauri/src/time.rs, so both apps read API timestamps alike. + +enum RFC3339 { + static func now() -> Int { Int(Date().timeIntervalSince1970) } + + /// Days since 1970-01-01 for a proleptic Gregorian date (H. Hinnant's algorithm). + private static func daysFromCivil(_ y: Int, _ m: Int, _ d: Int) -> Int { + let y = m <= 2 ? y - 1 : y + let era = floorDiv(y, 400) + let yoe = y - era * 400 + let mp = (m + 9) % 12 + let doy = (153 * mp + 2) / 5 + d - 1 + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy + return era * 146_097 + doe - 719_468 + } + + private static func civilFromDays(_ z: Int) -> (Int, Int, Int) { + let z = z + 719_468 + let era = floorDiv(z, 146_097) + let doe = z - era * 146_097 + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365 + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100) + let mp = (5 * doy + 2) / 153 + let d = doy - (153 * mp + 2) / 5 + 1 + let m = mp < 10 ? mp + 3 : mp - 9 + return (m <= 2 ? yoe + era * 400 + 1 : yoe + era * 400, m, d) + } + + private static func floorDiv(_ a: Int, _ b: Int) -> Int { + a >= 0 ? a / b : -((-a + b - 1) / b) + } + + static func utc(_ secs: Int) -> String { + let (y, m, d) = civilFromDays(floorDiv(secs, 86_400)) + let t = secs - floorDiv(secs, 86_400) * 86_400 + return String(format: "%04d-%02d-%02dT%02d:%02d:%02dZ", y, m, d, t / 3600, t % 3600 / 60, t % 60) + } + + /// `2026-10-01T14:30:00+02:00`, `…Z`, with or without fractional seconds. + static func parse(_ s: String) -> Int? { + let chars = Array(s.utf8) + func num(_ r: Range) -> Int? { + guard r.upperBound <= chars.count else { return nil } + let slice = chars[r] + guard slice.allSatisfy({ $0 >= 48 && $0 <= 57 }) else { return nil } + return Int(String(decoding: slice, as: UTF8.self)) + } + guard let y = num(0..<4), let mo = num(5..<7), let d = num(8..<10), + let h = num(11..<13), let mi = num(14..<16), let se = num(17..<19) else { return nil } + var i = 19 + if i < chars.count, chars[i] == UInt8(ascii: ".") { + i += 1 + while i < chars.count, chars[i] >= 48, chars[i] <= 57 { i += 1 } + } + let rest = Array(chars[min(i, chars.count)...]) + let offset: Int + if rest == [UInt8(ascii: "Z")] || rest == [UInt8(ascii: "z")] { + offset = 0 + } else { + guard let first = rest.first else { return nil } + let sign: Int + switch first { + case UInt8(ascii: "+"): sign = 1 + case UInt8(ascii: "-"): sign = -1 + default: return nil + } + func part(_ r: Range) -> Int? { + guard r.upperBound <= rest.count else { return nil } + let slice = rest[r] + guard slice.allSatisfy({ $0 >= 48 && $0 <= 57 }) else { return nil } + return Int(String(decoding: slice, as: UTF8.self)) + } + guard let oh = part(1..<3), let om = part(4..<6) else { return nil } + offset = sign * (oh * 3600 + om * 60) + } + return daysFromCivil(y, mo, d) * 86_400 + h * 3600 + mi * 60 + se - offset + } +} + +// MARK: - Small JSON helpers (JSONSerialization trees) + +/// The value at `path` in nested dictionaries, e.g. `json(x, "repository", "nameWithOwner")`. +func jsonValue(_ root: Any?, _ path: String...) -> Any? { + var cur = root + for key in path { + guard let dict = cur as? [String: Any] else { return nil } + cur = dict[key] + } + return cur is NSNull ? nil : cur +} + +/// The string at `path`, or "" — like `s(v, "/a/b")` in the Rust pollers. +func jsonString(_ root: Any?, _ path: String...) -> String { + var cur = root + for key in path { + guard let dict = cur as? [String: Any] else { return "" } + cur = dict[key] + } + return cur as? String ?? "" +} + +/// Integers come back from JSONSerialization as NSNumber; booleans too, so they are excluded. +func jsonInt(_ value: Any?) -> Int? { + guard let n = value as? NSNumber, CFGetTypeID(n) != CFBooleanGetTypeID() else { return nil } + return n.intValue +} + +func jsonBool(_ value: Any?) -> Bool? { + guard let n = value as? NSNumber, CFGetTypeID(n) == CFBooleanGetTypeID() else { return nil } + return n.boolValue +} diff --git a/NotchBuddy/Sources/App/IslandStateMachine.swift b/NotchBuddy/Sources/App/IslandStateMachine.swift index 4c309bed9e..607621741a 100644 --- a/NotchBuddy/Sources/App/IslandStateMachine.swift +++ b/NotchBuddy/Sources/App/IslandStateMachine.swift @@ -119,6 +119,19 @@ final class IslandStateMachine { DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: item) } + /// Alert that should open the island the same way a click does (a calendar + /// reminder): straight to `.home`, so hovering it doesn't fold it back to compact. + func forceHome() { + cancelTimers() + transition(to: .home) + } + + /// A file drag that started from the hidden island ended elsewhere: back to hidden. + func forceHidden() { + cancelTimers() + transition(to: .hidden) + } + /// Non-alert work event: show compact from hidden (HookServer reveal) func reveal() { guard state == .hidden else { return } diff --git a/NotchBuddy/Sources/App/IslandTypes.swift b/NotchBuddy/Sources/App/IslandTypes.swift index e84838480a..97da3a5d7a 100644 --- a/NotchBuddy/Sources/App/IslandTypes.swift +++ b/NotchBuddy/Sources/App/IslandTypes.swift @@ -9,7 +9,7 @@ enum IslandMode: String, CaseIterable { // MARK: - Island View enum IslandView: String, CaseIterable { - case overview, empty, approval, question, error, finished + case overview, empty, approval, question, error, finished, reminder case confused, upload, uploading, choose, mail, prompt case searching, result, note, settings, greeting } @@ -96,6 +96,7 @@ enum IslandConst { .question: ViewLayout(height: 160, botX: 62, botY: nil, botDiameter: 56, agentMode: .column), .error: ViewLayout(height: 160, botX: 62, botY: nil, botDiameter: 58, agentMode: .column), .finished: ViewLayout(height: 160, botX: 62, botY: nil, botDiameter: 58, agentMode: .column), + .reminder: ViewLayout(height: 160, botX: 62, botY: nil, botDiameter: 58, agentMode: .column), .confused: ViewLayout(height: 160, botX: 76, botY: nil, botDiameter: 66, agentMode: .column), .upload: ViewLayout(height: 176, botX: 140, botY: 104, botDiameter: 62, agentMode: .column), .uploading: ViewLayout(height: 176, botX: 46, botY: 118, botDiameter: 20, agentMode: .none), @@ -139,6 +140,7 @@ enum IslandConst { .init(id: "integration_github", name: "GitHub", color: "#F4505E"), .init(id: "integration_notion", name: "Notion", color: "#8C8C8C"), .init(id: "integration_calcom", name: "Cal.com", color: "#C9956A"), + .init(id: "integration_gcal", name: "Calendar", color: "#4285F4"), .init(id: "integration_stripe", name: "Stripe", color: "#0570DE"), ] diff --git a/NotchBuddy/Sources/App/IslandViewContent.swift b/NotchBuddy/Sources/App/IslandViewContent.swift index 778f12169e..bf95a8909d 100644 --- a/NotchBuddy/Sources/App/IslandViewContent.swift +++ b/NotchBuddy/Sources/App/IslandViewContent.swift @@ -14,6 +14,7 @@ struct IslandViewContent: View { case .question: QuestionView(state: state) case .error: ErrorView(state: state) case .finished: FinishedView(state: state) + case .reminder: ReminderView(state: state) case .confused: ConfusedView() case .upload: UploadView(state: state) case .uploading: UploadingView(state: state) @@ -134,7 +135,7 @@ struct OverviewView: View { case "integration_vercel": NSWorkspace.shared.open(URL(string: "https://vercel.com/dashboard")!) case "integration_github": - NSWorkspace.shared.open(URL(string: "https://github.com")!) + NSWorkspace.shared.open(URL(string: "https://github.com/pulls")!) case "integration_n8n": if let urlStr = KeychainStore.shared.get("n8n-url"), let url = URL(string: urlStr) { NSWorkspace.shared.open(url) @@ -145,6 +146,8 @@ struct OverviewView: View { NSWorkspace.shared.open(URL(string: "https://notion.so")!) case "integration_calcom": NSWorkspace.shared.open(URL(string: "https://app.cal.com/bookings")!) + case "integration_gcal": + NSWorkspace.shared.open(URL(string: "https://calendar.google.com")!) default: // Non-integration real tasks if task.source == .n8n { @@ -318,6 +321,102 @@ struct FinishedView: View { } } +// MARK: - Calendar reminder + +struct ReminderView: View { + @ObservedObject var state: AppState + + var body: some View { + ZStack { + CardBackground(wash: .indigo) + if let r = state.reminder { + // Re-reads the clock every minute: "in 30 min" counts down. + TimelineView(.everyMinute) { context in + content(r, now: context.date) + } + } + } + } + + private func content(_ r: GcalEvent, now: Date) -> some View { + let start = r.startSecs.map { Date(timeIntervalSince1970: TimeInterval($0)) } ?? now + let end = r.endSecs.map { Date(timeIntervalSince1970: TimeInterval($0)) } + let time = { (d: Date) in d.formatted(date: .omitted, time: .shortened) } + let when = end.map { "\(time(start)) – \(time($0))" } ?? time(start) + // The place, up to its first comma: "Casino Zaragoza", not the postcode. + let place = r.location + .flatMap { $0.hasPrefix("https://") ? nil : $0.components(separatedBy: ",").first } + .flatMap { $0.isEmpty ? nil : $0 } + let detail = place.map { "\(when) · \($0)" } ?? when + let meet = safeWebURL(r.meetURL) + let open = safeWebURL(r.url) + + return VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 7) { + Circle().fill(gcalColor(r.color)).frame(width: 8, height: 8) + // Google names your primary calendar after your address: say Calendar. + Text(!r.calendar.isEmpty && !r.calendar.contains("@") ? r.calendar : "Calendar") + .font(.system(size: 12, weight: .semibold)).foregroundColor(Color(hex: "#F5F6F8")) + .lineLimit(1) + Text(startsIn(start, now: now)) + .font(.system(size: 12)).foregroundColor(Color(hex: "#8E939C")) + .lineLimit(1) + } + Text(r.title) + .font(.system(size: 15, weight: .semibold)) + .lineLimit(1).truncationMode(.tail) + Text(detail) + .font(.system(size: 12)) + .foregroundColor(Color(hex: "#9398A1")) + .lineLimit(1).truncationMode(.tail) + HStack(spacing: 8) { + if let meet { + PrimaryButton("Join") { openAndClose(meet) } + } + if let open { + if meet == nil { + PrimaryButton("Open") { openAndClose(open) } + } else { + SecondaryButton("Open") { openAndClose(open) } + } + } + SecondaryButton("OK") { + NotificationCenter.default.post(name: .islandCollapse, object: nil) + } + } + } + .padding(.leading, 116) + .padding(.trailing, 16) + .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func openAndClose(_ url: URL) { + NSWorkspace.shared.open(url) + NotificationCenter.default.post(name: .islandCollapse, object: nil) + } + + /// "in 30 min", "in 1 h 15 min", "starting now" — from the live clock, not the poll's. + private func startsIn(_ start: Date, now: Date) -> String { + let minutes = Int((start.timeIntervalSince(now) / 60).rounded(.up)) + if minutes <= 0 { return "starting now" } + if minutes < 60 { return "in \(minutes) min" } + if minutes < 1440 { + let (h, m) = (minutes / 60, minutes % 60) + return m == 0 ? "in \(h) h" : "in \(h) h \(m) min" + } + let d = minutes / 1440 + return d == 1 ? "tomorrow" : "in \(d) days" + } +} + +/// A calendar's colour as Google Calendar shows it, or Google blue. +func gcalColor(_ hex: String?) -> Color { + guard let hex, hex.count == 7, hex.hasPrefix("#"), + hex.dropFirst().allSatisfy(\.isHexDigit) else { return Color(hex: "#4285F4") } + return Color(hex: hex) +} + // MARK: - Confused struct ConfusedView: View { @@ -985,6 +1084,8 @@ struct IntegrationCardView: View { case "integration_stripe": return KeychainStore.shared.get("stripe-api-key") != nil case "integration_notion": return KeychainStore.shared.get("notion-api-key") != nil case "integration_calcom": return KeychainStore.shared.get("calcom-api-key") != nil + // Connected once Google has handed back a refresh token. + case "integration_gcal": return KeychainStore.shared.get("gcal-refresh-token") != nil default: return false } } @@ -997,10 +1098,11 @@ struct IntegrationCardView: View { if let s = KeychainStore.shared.get("n8n-url") { return URL(string: s) } return nil case "integration_vercel": return URL(string: "https://vercel.com/dashboard") - case "integration_github": return URL(string: "https://github.com") + case "integration_github": return URL(string: "https://github.com/pulls") case "integration_stripe": return URL(string: "https://dashboard.stripe.com/payments") case "integration_notion": return URL(string: "https://notion.so") case "integration_calcom": return URL(string: "https://app.cal.com/bookings") + case "integration_gcal": return URL(string: "https://calendar.google.com") default: return nil } } @@ -1026,9 +1128,14 @@ struct IntegrationCardView: View { task.id == "integration_resend" && !appState.resendEmails.isEmpty } - // GitHub with stats loaded + // GitHub once a poll answered (an error shows on the idle card instead) private var githubHasData: Bool { - task.id == "integration_github" && appState.githubStats != nil + task.id == "integration_github" && appState.githubActivity != nil && appState.githubError == nil + } + + // Google Calendar once connected and polled; disconnecting goes back to the idle card + private var gcalHasData: Bool { + task.id == "integration_gcal" && appState.gcalEvents != nil && appState.gcalError == nil } // Stripe: show card as soon as first poll completes (balance OR payments) @@ -1066,7 +1173,10 @@ struct IntegrationCardView: View { ResendCardView(emails: appState.resendEmails, total: appState.resendTotal) .transition(.opacity) } else if githubHasData { - GitHubStatsCardView(stats: appState.githubStats!) + GitHubCardView(activity: appState.githubActivity!) + .transition(.opacity) + } else if gcalHasData { + GcalCardView(events: appState.gcalEvents ?? []) .transition(.opacity) } else if stripeHasData { StripeCardView() @@ -1135,11 +1245,14 @@ struct IntegrationCardView: View { HStack(spacing: 5) { let stripeErr = task.id == "integration_stripe" ? appState.stripeError : task.id == "integration_calcom" ? appState.calcomError + : task.id == "integration_github" ? appState.githubError + : task.id == "integration_gcal" ? appState.gcalError : nil let dot = stripeErr != nil ? Color(hex: "#F4505E") : isConfigured ? Color(hex: "#22C55E") : Color(hex: "#F4505E") - let label = stripeErr ?? (isConfigured ? "Connected · loading…" : "Key not configured") + let missing = task.id == "integration_gcal" ? "Google account not connected" : "Key not configured" + let label = stripeErr ?? (isConfigured ? "Connected · loading…" : missing) Circle().fill(dot).frame(width: 5, height: 5) Text(label) .font(.system(size: 11)) @@ -1197,6 +1310,15 @@ struct IntegrationCardView: View { .foregroundColor(Color(hex: "#C9956A").opacity(0.85)) .buttonStyle(.plain) } + if (task.id == "integration_github" || task.id == "integration_gcal") && isConfigured { + Button("Refresh") { + if task.id == "integration_github" { GithubPoller.shared.pollNow() } + else { GcalPoller.shared.pollNow() } + } + .font(.system(size: 11, weight: .medium)) + .foregroundColor(Color(hex: task.color).opacity(0.85)) + .buttonStyle(.plain) + } if !isConfigured { Button("Settings…") { NotificationCenter.default.post(name: .openFullSettings, object: nil) @@ -1565,6 +1687,297 @@ private struct StatRow: View { } } +// MARK: - Integration list rows (GitHub, Calendar) + +private enum IntegrationColors { + static let green = "#22C55E" + static let red = "#F4505E" + static let amber = "#F5A524" + static let grey = "#6B7079" + static let gcal = "#4285F4" +} + +/// Highlighted first row + plain rows, like the Resend and Vercel lists. +private struct IntegrationRow: View { + let accent: Color + let first: Bool + @ViewBuilder let content: () -> Content + + var body: some View { + HStack(spacing: 5) { + Circle().fill(accent).frame(width: 5, height: 5) + content() + } + .padding(.horizontal, 8).padding(.vertical, 3) + .frame(maxWidth: .infinity, alignment: .leading) + .background(first ? accent.opacity(0.08) : Color.clear) + .clipShape(RoundedRectangle(cornerRadius: 5)) + .contentShape(Rectangle()) + } +} + +private struct IntegrationCardHeader: View { + let color: String + let name: String + let kind: String + @ViewBuilder let extra: () -> Extra + + var body: some View { + HStack(spacing: 6) { + Circle().fill(Color(hex: color)).frame(width: 7, height: 7) + Text(name) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(Color(hex: "#F5F6F8")) + Text(kind) + .font(.system(size: 11)) + .foregroundColor(Color(hex: "#8E939C")) + extra() + } + .padding(.top, 6) + .padding(.leading, 108) + .padding(.trailing, 36) + } +} + +// MARK: - GitHub Card View (CI, reviews, pull requests) + +struct GitHubCardView: View { + let activity: GitHubActivity + + private struct Row: Identifiable { + let id: String + let accent: String + let name: String + /// Shown after the name; `fixedSub` keeps it whole (a PR number) while the name ellipsizes. + var sub: String? = nil + var fixedSub = false + let trailing: String + let url: URL? + var help: String? = nil + } + + /// Three rows at most, most pressing first: your branch's CI, reviews people + /// are waiting on from you, then your own pull requests. + private var rows: [Row] { + typealias C = IntegrationColors + var rows: [Row] = [] + let branch = activity.branch + if let b = branch { + let (accent, label): (String, String) = + !b.pushed ? (C.grey, "not pushed") + : b.state == "SUCCESS" ? (C.green, "passing") + : b.state == "FAILURE" || b.state == "ERROR" ? (C.red, "failing") + : b.state == "PENDING" || b.state == "EXPECTED" ? (C.amber, "running") + : (C.grey, "no checks") + let url = safeWebURL(b.url.isEmpty ? "https://github.com/\(b.repo)/tree/\(b.branch)" : b.url) + let failing = b.failing.isEmpty ? "" : "\nFailing: \(b.failing.joined(separator: ", "))" + rows.append(Row( + id: "ci", accent: accent, name: b.branch, + // The branch name is what gets ellipsized; the PR number stays whole. + sub: b.pr.map { "#\($0)" } ?? b.failing.first, + fixedSub: b.pr != nil, + trailing: label, url: url, + help: "\(b.repo) · \(b.branch)\(failing)" + )) + } + for r in activity.requests where rows.count < 3 { + rows.append(Row(id: "request-\(r.url)", accent: C.amber, name: "#\(r.number) \(r.title)", + trailing: "review", url: safeWebURL(r.url))) + } + // The branch row already stands for its own pull request — unless a + // reviewer has had their say, which the CI row doesn't show. + let pulls = activity.pulls.prefix(5).filter { p in + !(branch != nil && branch?.pr == p.number && p.repo == branch?.repo) + || p.decision == "APPROVED" || p.decision == "CHANGES_REQUESTED" + } + for p in pulls where rows.count < 3 { + let (accent, label): (String, String) = + p.draft ? (C.grey, "draft") + : p.decision == "APPROVED" ? (C.green, "approved") + : p.decision == "CHANGES_REQUESTED" ? (C.red, "changes") + : (C.grey, "waiting") + rows.append(Row(id: "pull-\(p.url)", accent: accent, name: "#\(p.number) \(p.title)", + trailing: label, url: safeWebURL(p.url))) + } + return rows + } + + var body: some View { + let rows = self.rows + if rows.isEmpty { + // Nothing on the go: the overview, so the card is never empty. + GitHubStatsCardView(stats: GitHubStats(totalRepos: activity.totalRepos, + totalStars: activity.totalStars)) + } else { + VStack(alignment: .leading, spacing: 0) { + IntegrationCardHeader(color: IntegrationColors.red, name: "GitHub", kind: "Activity") { + HStack(spacing: 3) { + Image(systemName: "star.fill") + .font(.system(size: 8)) + .foregroundColor(Color(hex: IntegrationColors.amber)) + Text(formatCount(activity.totalStars)) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(Color(hex: "#C5C8CD")) + .monospacedDigit() + } + .help("\(activity.totalRepos) repositories") + } + + VStack(alignment: .leading, spacing: 3) { + ForEach(Array(rows.enumerated()), id: \.element.id) { i, row in + Button { + if let url = row.url { NSWorkspace.shared.open(url) } + } label: { + IntegrationRow(accent: Color(hex: row.accent), first: i == 0) { + Text(row.name) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(Color(hex: i == 0 ? "#C5C8CD" : "#9398A1")) + .lineLimit(1).truncationMode(.tail) + .layoutPriority(row.fixedSub ? 0 : 1) + if let sub = row.sub { + Text(sub) + .font(.system(size: 10)) + .foregroundColor(Color(hex: "#6B7079")) + .lineLimit(1).truncationMode(.tail) + .fixedSize(horizontal: row.fixedSub, vertical: false) + } + Spacer(minLength: 4) + Text(row.trailing) + .font(.system(size: 10, weight: .medium)) + .foregroundColor(Color(hex: row.accent)) + .fixedSize() + } + } + .buttonStyle(.plain) + .help(row.help ?? "") + } + } + .padding(.top, 5) + .padding(.leading, 108) + .padding(.trailing, 12) + } + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(.top, 4) + } + } + + private func formatCount(_ n: Int) -> String { + if n >= 1000 { return String(format: "%.1fk", Double(n) / 1000) } + return "\(n)" + } +} + +// MARK: - Google Calendar Card View + +struct GcalCardView: View { + let events: [GcalEvent] + + var body: some View { + // The rows say "now" and "in 12m": let them move with the clock even when + // a poll brings back the same events. + TimelineView(.everyMinute) { context in + content(now: context.date) + } + } + + private func content(now: Date) -> some View { + typealias C = IntegrationColors + // Meetings first; all-day events only fill what's left. + let ordered = Array((events.filter { !$0.allDay } + events.filter(\.allDay)).prefix(3)) + let kind = ordered.first.map { Calendar.current.isDate(Self.start(of: $0), inSameDayAs: now) } == true + ? "Today" : "Upcoming" + + return VStack(alignment: .leading, spacing: 0) { + IntegrationCardHeader(color: C.gcal, name: "Calendar", kind: kind) { EmptyView() } + + VStack(alignment: .leading, spacing: 3) { + if ordered.isEmpty { + Text("Nothing on the calendar this week") + .font(.system(size: 11)) + .foregroundColor(Color(hex: "#6B7079")) + .padding(.horizontal, 8).padding(.vertical, 3) + } + ForEach(Array(ordered.enumerated()), id: \.element.key) { i, e in + row(e, index: i, now: now) + } + } + .padding(.top, 5) + .padding(.leading, 108) + .padding(.trailing, 12) + } + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(.top, 4) + } + + private func row(_ e: GcalEvent, index i: Int, now: Date) -> some View { + typealias C = IntegrationColors + let nowSecs = Int(now.timeIntervalSince1970) + let ongoing = e.startSecs != nil && e.endSecs != nil && e.startSecs! <= nowSecs && nowSecs < e.endSecs! + let minutes = e.startSecs.map { Int((Double($0 - nowSecs) / 60).rounded(.up)) } + let soon = minutes.map { $0 > 0 && $0 <= 60 } ?? false + // Otherwise the calendar's own colour, as in Google Calendar. + let accent = ongoing ? Color(hex: C.green) + : soon && minutes! <= 5 ? Color(hex: C.amber) + : gcalColor(e.color) + // Join, for the meeting that is on or about to be: the one click that matters. + let join = i == 0 ? safeWebURL(e.meetURL) : nil + let showJoin = join != nil && (ongoing || (minutes.map { $0 <= 15 } ?? false)) + + return IntegrationRow(accent: accent, first: i == 0) { + Text(ongoing ? "now" : Self.when(e, now: now)) + .font(.system(size: 10.5, weight: .medium)) + .foregroundColor(Color(hex: ongoing ? C.green : "#8AB4F8")) + .monospacedDigit() + .fixedSize() + Text(e.title) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(Color(hex: i == 0 ? "#C5C8CD" : "#9398A1")) + .lineLimit(1).truncationMode(.tail) + .layoutPriority(1) + Spacer(minLength: 4) + if showJoin, let join { + Button { NSWorkspace.shared.open(join) } label: { + Text("Join") + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(accent) + .padding(.horizontal, 7).padding(.vertical, 1) + .background(accent.opacity(0.14)) + .clipShape(Capsule()) + .fixedSize() + } + .buttonStyle(.plain) + } else if soon && !ongoing, let m = minutes { + Text("in \(m)m") + .font(.system(size: 10)) + .foregroundColor(Color(hex: m <= 5 ? C.amber : "#6B7079")) + .fixedSize() + } + } + .onTapGesture { + if let url = safeWebURL(e.url) { NSWorkspace.shared.open(url) } + } + } + + /// All-day events carry a bare YYYY-MM-DD: that day, at local midnight. + static func start(of e: GcalEvent) -> Date { + if let s = e.startSecs { return Date(timeIntervalSince1970: TimeInterval(s)) } + let parts = e.start.split(separator: "-").compactMap { Int($0) } + var c = DateComponents() + c.year = parts.first; c.month = parts.count > 1 ? parts[1] : 1; c.day = parts.count > 2 ? parts[2] : 1 + return Calendar.current.date(from: c) ?? Date.distantFuture + } + + /// "14:30" today, "Fri 09:00" later in the week, "All day" / "Fri" for all-day. + static func when(_ e: GcalEvent, now: Date) -> String { + let start = start(of: e) + let today = Calendar.current.isDate(start, inSameDayAs: now) + let weekday = start.formatted(.dateTime.weekday(.abbreviated)) + if e.allDay { return today ? "All day" : weekday } + let time = start.formatted(date: .omitted, time: .shortened) + return today ? time : "\(weekday) \(time)" + } +} + // MARK: - Stripe Card View struct StripeCardView: View { @@ -2242,6 +2655,9 @@ struct TickerShimmerText: View { struct AgentPillsView: View { @ObservedObject var state: AppState @State private var swapping = false + // Hover lives here, not in each pill: at most one pill is highlighted, and a + // pill can't stay lit after the grid reshuffles under a still cursor. + @State private var hoveredId: String? = nil private var others: [AgentTask] { state.tasks.filter { $0.id != state.focusId } @@ -2261,8 +2677,19 @@ struct AgentPillsView: View { Spacer(minLength: 0) LazyVGrid(columns: columns, spacing: 4) { ForEach(displayTasks) { task in - AgentPill(task: task, state: state, swapping: $swapping) { + AgentPill(task: task, isHovered: hoveredId == task.id) { hovering in + if hovering { + // Ignore enters while the grid reshuffles, so the pill that + // slides under the cursor doesn't light up mid-swap. + guard !swapping else { return } + withAnimation(.spring(response: 0.2, dampingFraction: 0.7)) { hoveredId = task.id } + } else if hoveredId == task.id { + // Exits are never dropped, otherwise the pill stays stuck. + withAnimation(.spring(response: 0.2, dampingFraction: 0.7)) { hoveredId = nil } + } + } onTap: { swapping = true + hoveredId = nil state.setFocus(task.id) SoundEngine.shared.play("blip") DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { swapping = false } @@ -2278,10 +2705,9 @@ struct AgentPillsView: View { struct AgentPill: View { let task: AgentTask - @ObservedObject var state: AppState - @Binding var swapping: Bool + let isHovered: Bool + let onHover: (Bool) -> Void let onTap: () -> Void - @State private var isHovered = false // VS Code pill always shows "VS Code" label regardless of active project name private var displayName: String { @@ -2328,10 +2754,7 @@ struct AgentPill: View { .buttonStyle(.plain) .scaleEffect(isHovered ? 1.04 : 1.0) .brightness(isHovered ? 0.06 : 0) - .onHover { newHover in - guard !swapping else { return } - withAnimation(.spring(response: 0.2, dampingFraction: 0.7)) { isHovered = newHover } - } + .onHover { onHover($0) } } } diff --git a/NotchBuddy/Sources/App/IslandWindowController.swift b/NotchBuddy/Sources/App/IslandWindowController.swift index d506297cce..7bb1b471de 100644 --- a/NotchBuddy/Sources/App/IslandWindowController.swift +++ b/NotchBuddy/Sources/App/IslandWindowController.swift @@ -39,6 +39,12 @@ final class IslandWindowController: NSWindowController { private var highlightPanel: NSPanel? = nil private var highlightWindowPid: pid_t = 0 + // File drag: where the island was when a file first reached it, to go back there + // when the drag ends somewhere else (dropped elsewhere, or Escape). + private var dragOrigin: (state: IslandStateMachine.State, view: IslandView)? = nil + private var mouseDown = false + private var abandonWork: DispatchWorkItem? + // Notch real dimensions (set on init) private var notchW: CGFloat = IslandConst.notchWidth private var notchH: CGFloat = IslandConst.notchHeight @@ -101,6 +107,12 @@ final class IslandWindowController: NSWindowController { dropView.autoresizingMask = [.width, .height] dropView.onDragEntered = { [weak self] loc in Task { @MainActor in + if let self { + self.cancelAbandon() + // A drag is a held button, even if the poll hasn't reported it yet. + self.mouseDown = true + if self.dragOrigin == nil { self.dragOrigin = (self.fsm.state, self.state.view) } + } let iLoc = self?.windowToIsland(loc) ?? CGPoint(x: 320, y: 88) AppState.shared.fileDragOver = true // enterZone sets isActive=true BEFORE hookExpand triggers re-render, @@ -116,20 +128,37 @@ final class IslandWindowController: NSWindowController { UploadSequenceEngine.shared.updateCursor(x: iLoc.x, y: iLoc.y) } } - dropView.onDragExited = { + dropView.onDragExited = { [weak self] in Task { @MainActor in AppState.shared.fileDragOver = false - // Do NOT collapse — drag session still active; island stays open. + // The island stays open while the drag session is alive — the file may + // come back. Once the button is up the drag is over, and waiting out + // the auto-close for a file that went elsewhere is no good. NotificationCenter.default.post(name: .botMorphTo, object: CGFloat(0)) UploadSequenceEngine.shared.exitZone() + if let self, !self.mouseDown { self.scheduleAbandon() } } } - dropView.onFilesDropped = { urls in + dropView.onFilesDropped = { [weak self] urls in Task { @MainActor in + self?.cancelAbandon() + self?.dragOrigin = nil await FileDropHandler.handle(urls: urls, state: AppState.shared) } } + dropView.onDropRejected = { [weak self] in + Task { @MainActor in + guard let self else { return } + self.cancelAbandon() + self.dragOrigin = nil + AppState.shared.fileDragOver = false + NotificationCenter.default.post(name: .botMorphTo, object: CGFloat(0)) + // Leaving the upload view deactivates the drop sequence (IslandRootView). + if self.state.view == .upload { self.state.view = self.defaultView() } + } + } + container.addSubview(hosting) // z-bottom: SwiftUI + mouse events container.addSubview(dropView) // z-top: drag only (hitTest→nil, transparent to mouse) panel.contentView = container @@ -237,6 +266,15 @@ final class IslandWindowController: NSWindowController { AppState.shared.mousePosition = newPos } + // Left button pressed or released anywhere. Checked every frame, including + // when the cursor stands still: an abandoned drag usually ends that way. + let down = NSEvent.pressedMouseButtons & 1 != 0 + if down != mouseDown { + mouseDown = down + if down { cancelAbandon() } + else if dragOrigin != nil && !state.fileDragOver { scheduleAbandon() } + } + // AppState can hide the island by itself (last task ended): keep the FSM in step. if state.mode == .hidden && fsm.state == .petit { fsm.hiddenExternally() } @@ -315,6 +353,49 @@ final class IslandWindowController: NSWindowController { DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: item) } + // MARK: - Abandoned file drag + + /// The file went elsewhere (or the drag was cancelled). A short grace lets a + /// late drop win, then the island goes back to where it was before the drag. + private func scheduleAbandon() { + cancelAbandon() + let item = DispatchWorkItem { [weak self] in + self?.abandonWork = nil + self?.abandonDrag() + } + abandonWork = item + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: item) + } + + private func cancelAbandon() { + abandonWork?.cancel() + abandonWork = nil + } + + private func abandonDrag() { + guard let origin = dragOrigin else { return } + dragOrigin = nil + guard !mouseDown, !state.fileDragOver else { return } + // A drop landed, or the user moved on to something else in the meantime: leave it alone. + guard state.mode == .expanded, state.view == .upload else { return } + UploadSequenceEngine.shared.deactivate() + switch origin.state { + case .hidden: + // Still under the cursor (an Escape over the island): stay reachable. + if wasInIsland { + collapse() + } else { + fsm.forceHidden() + setMode(.hidden) + window?.resignKey() + } + case .home: + state.view = origin.view + case .petit, .coucou: + collapse() + } + } + // MARK: - Mode transitions private func modeLevel(_ m: IslandMode) -> Int { @@ -372,6 +453,17 @@ final class IslandWindowController: NSWindowController { self.expand(to: view) } + // Integration alerts (a calendar reminder): open through the FSM like a click, + // then show the alert's view. Auto-closes like any open island once the mouse is away. + NotificationCenter.default.addObserver(forName: .islandAlert, object: nil, queue: .main) { [weak self] note in + guard let self, let view = note.object as? IslandView else { return } + MainActor.assumeIsolated { + self.fsm.forceHome() + self.expand(to: view) + if !self.wasInIsland { self.fsm.mouseLeft() } + } + } + // Hook server compact reveal (non-alert work events: session start, tool use, etc.) NotificationCenter.default.addObserver(forName: .hookReveal, object: nil, queue: .main) { [weak self] _ in guard let self else { return } @@ -855,6 +947,7 @@ extension Notification.Name { static let islandCollapse = Notification.Name("notchBuddy.islandCollapse") static let openFullSettings = Notification.Name("notchBuddy.openFullSettings") static let hookReveal = Notification.Name("notchBuddy.hookReveal") + static let islandAlert = Notification.Name("notchBuddy.islandAlert") // Greeting ↔ IslandWindowController static let greetComplete = Notification.Name("notchBuddy.greetComplete") static let greetingHover = Notification.Name("notchBuddy.greetingHover") diff --git a/NotchBuddy/Sources/App/SettingsView.swift b/NotchBuddy/Sources/App/SettingsView.swift index ffb561807a..c120efd8b9 100644 --- a/NotchBuddy/Sources/App/SettingsView.swift +++ b/NotchBuddy/Sources/App/SettingsView.swift @@ -54,6 +54,8 @@ struct SettingsView: View { @State private var stripeKey: String = KeychainStore.shared.get("stripe-api-key") ?? "" @State private var calcomKey: String = KeychainStore.shared.get("calcom-api-key") ?? "" @State private var notionKey: String = KeychainStore.shared.get("notion-api-key") ?? "" + @State private var gcalClientId: String = KeychainStore.shared.get("gcal-client-id") ?? "" + @State private var gcalClientSecret: String = KeychainStore.shared.get("gcal-client-secret") ?? "" // Hotkey @State private var hotkeyFlags: UInt = AppState.shared.hotkeyFlags @@ -310,6 +312,10 @@ struct SettingsView: View { } SecureField("Personal Access Token", text: $githubToken) .textFieldStyle(.roundedBorder) + Text(Self.githubHint) + .font(.system(size: 11)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) } // Stripe @@ -342,6 +348,23 @@ struct SettingsView: View { .textFieldStyle(.roundedBorder) } + // Google Calendar + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 6) { + Circle().fill(Color(hex: "#4285F4")).frame(width: 8, height: 8) + Text("Google Calendar").font(.system(size: 12, weight: .semibold)) + } + TextField("Client ID (….apps.googleusercontent.com)", text: $gcalClientId) + .textFieldStyle(.roundedBorder) + SecureField("Client secret (GOCSPX-…)", text: $gcalClientSecret) + .textFieldStyle(.roundedBorder) + GcalConnectRow(clientId: gcalClientId, clientSecret: gcalClientSecret) + Text(Self.gcalHint) + .font(.system(size: 11)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Button("Save integrations") { saveIntegrations() } .buttonStyle(.borderedProminent) } @@ -489,6 +512,14 @@ struct SettingsView: View { .frame(minWidth: 420, maxWidth: .infinity, minHeight: 320, maxHeight: .infinity) } + #if APPSTORE + private static let githubHint = "A classic token with the repo scope. Reviews cover your open pull requests and the ones waiting on you. (CI of your Claude Code session's branch needs the direct-download version: the App Store sandbox can't read your project's .git folder.)" + #else + private static let githubHint = "A classic token with the repo scope. CI follows the branch of your last Claude Code session; reviews cover your open pull requests and the ones waiting on you." + #endif + + private static let gcalHint = "Coucou has no server, so it signs in with your own OAuth client: in Google Cloud, enable the Google Calendar API, create an OAuth client of type Desktop app, and paste its ID and secret here. Then publish the consent screen (In production): Google will warn that the app isn't verified — it's your own, continue. Left in Testing, only the test users listed there can sign in, and Google signs them out every 7 days. Read-only. Mochi reminds you at the times your events' own Google reminders say." + // MARK: - Actions private func applyCustomModel(_ value: String) { @@ -657,6 +688,8 @@ struct SettingsView: View { saveKey("stripe-api-key", value: stripeKey) saveKey("calcom-api-key", value: calcomKey) saveKey("notion-api-key", value: notionKey) + saveKey("gcal-client-id", value: gcalClientId.trimmingCharacters(in: .whitespacesAndNewlines)) + saveKey("gcal-client-secret", value: gcalClientSecret.trimmingCharacters(in: .whitespacesAndNewlines)) statusMessage = "✓ Integration keys saved." } @@ -741,6 +774,121 @@ struct SettingsView: View { // MARK: - Integration filter row (reusable for Vercel / n8n) +// MARK: - Google Calendar: Connect / Cancel / Disconnect + +struct GcalConnectRow: View { + /// What the fields say right now; Connect saves them, so "Save integrations" isn't needed first. + let clientId: String + let clientSecret: String + + @State private var connected = KeychainStore.shared.get("gcal-refresh-token") != nil + @State private var waiting = false + /// Which Connect click the screen belongs to: a stale one never redraws it. + @State private var attempt = 0 + @State private var feedback: Feedback? = nil + + struct Feedback { + enum Kind { case hint, ok, warn, err } + let kind: Kind + let text: String + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Circle() + .fill(Color(hex: connected ? "#22C55E" : "#F4505E")) + .frame(width: 7, height: 7) + Text(connected ? "Connected" : "Not connected") + .font(.system(size: 11)) + .foregroundColor(.secondary) + Spacer(minLength: 0) + } + HStack(spacing: 8) { + Button(waiting ? "Waiting for Google…" : connected ? "Reconnect…" : "Connect Google account…") { + connect() + } + .disabled(waiting) + if waiting { + Button("Cancel") { cancel() } + } + if connected && !waiting { + Button("Disconnect") { disconnect() } + } + Button("Google Cloud console") { + if let url = URL(string: "https://console.cloud.google.com/apis/credentials") { + NSWorkspace.shared.open(url) + } + } + .buttonStyle(.link) + } + .controlSize(.small) + if let feedback { + Text(feedback.text) + .font(.system(size: 11)) + .foregroundColor(color(feedback.kind)) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + private func color(_ kind: Feedback.Kind) -> Color { + switch kind { + case .hint: return .secondary + case .ok: return Color(hex: "#22C55E") + case .warn: return .orange + case .err: return .red + } + } + + private func connect() { + let id = clientId.trimmingCharacters(in: .whitespacesAndNewlines) + let secret = clientSecret.trimmingCharacters(in: .whitespacesAndNewlines) + if !id.isEmpty { KeychainStore.shared.set("gcal-client-id", value: id) } + if !secret.isEmpty { KeychainStore.shared.set("gcal-client-secret", value: secret) } + guard KeychainStore.shared.get("gcal-client-id") != nil, + KeychainStore.shared.get("gcal-client-secret") != nil else { + feedback = Feedback(kind: .warn, text: "Fill in the client ID and client secret first.") + return + } + attempt += 1 + let mine = attempt + waiting = true + // Google's "access blocked" page is a dead end: it never comes back here. + feedback = Feedback(kind: .hint, text: "Finish signing in in your browser. If Google says access is blocked, your address isn't a test user of the app yet: fix it in the console's OAuth consent screen, then Cancel and connect again.") + Task { @MainActor in + let outcome: Feedback + do { + try await GcalPoller.shared.connect() + outcome = Feedback(kind: .ok, text: "Connected. Turn the Calendar pill on (Active pills) to see it next to Mochi.") + } catch { + outcome = Feedback(kind: .err, text: (error as? GcalProblem)?.message ?? error.localizedDescription) + } + guard mine == attempt else { return } // cancelled, or another attempt took over + waiting = false + feedback = outcome + connected = KeychainStore.shared.get("gcal-refresh-token") != nil + } + } + + /// Takes effect at once here; the listener stops within a second. + private func cancel() { + attempt += 1 + waiting = false + GcalPoller.shared.cancel() + feedback = nil + } + + private func disconnect() { + feedback = nil + Task { @MainActor in + await GcalPoller.shared.disconnect() + connected = false + feedback = Feedback(kind: .ok, text: "Disconnected, and access revoked at Google.") + } + } +} + struct IntegrationFilterRow: View { let label: String let items: [String] diff --git a/README.md b/README.md index c153137e68..1c1e4e8604 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Meet **Mochi**: a soft little squircle with big eyes that pops out of your notch - 💬 **Ask Claude anything** — built-in chat, straight from the notch. Pick the model in Settings; the list comes from your Anthropic account. - 📎 **Drop a file on the notch** — Mochi turns into a box and swallows it, then ask a question about it or send it by email *(email: macOS, Mail.app)*. - 🪟 **Drag Mochi onto any window** — attach that window as context for Claude *(macOS)*. -- 🔌 **Integrations** — Stripe payments, n8n workflows, GitHub, Vercel deployments, Resend emails, Notion, Cal.com. Each one gets its own little colored Mochi. +- 🔌 **Integrations** — Stripe payments, n8n workflows, GitHub (CI of the branch you're on, reviews and pull requests), Vercel deployments, Resend emails, Notion, Cal.com, Google Calendar (with meeting reminders). Each one gets its own little colored Mochi. - 🎭 **A real character** — idle breathing, blinks, eyes on a sphere that follow your mouse, emotes, 28 handcrafted sounds, a greeting on launch. - 🫥 **Invisible when idle** — hides away when nothing is running, peeks out when you hover the notch (the top edge of the screen on Windows). - 🖥️ **Any Mac, notch or not** — on an iMac, a Mac mini, or a MacBook with its lid closed on an external display, Mochi sits in a small bar at the top of the screen. @@ -104,7 +104,7 @@ Click the Coucou icon in the menu bar (macOS) or in the system tray (Windows) | **Gemini CLI hooks** *(macOS)* | Gemini CLI sessions in the island | **Install hooks** in Settings → Gemini CLI — backs up `~/.gemini/settings.json` | | **Antigravity (agy) hooks** *(macOS)* | agy sessions in the island | **Install hooks** in Settings → Antigravity — backs up `~/.gemini/config/hooks.json` | | **Anthropic API key** | chat and questions about files | Keychain / Windows Credential Manager | -| Stripe, n8n, GitHub, Vercel, Resend, Notion, Cal.com | the integration pills | Keychain / Windows Credential Manager, all optional | +| Stripe, n8n, GitHub, Vercel, Resend, Notion, Cal.com, Google Calendar (your own OAuth client) | the integration pills | Keychain / Windows Credential Manager, all optional | If Coucou isn't running, the hook exits immediately: **Claude Code is never blocked.** diff --git a/docs/INTEGRATIONS.md b/docs/INTEGRATIONS.md index da0675e4d0..fd4ed3024e 100644 --- a/docs/INTEGRATIONS.md +++ b/docs/INTEGRATIONS.md @@ -161,20 +161,22 @@ Aucune permission Accessibilité nécessaire. --- -## 8. GitHub CI / PR et Google Calendar (Windows uniquement pour l'instant) +## 8. GitHub CI / PR et Google Calendar (macOS et Windows) -Code : `windows/src-tauri/src/github.rs`, `windows/src-tauri/src/gcal.rs`, cartes dans `windows/src/views/integrations.ts`. +Code macOS : `GitHubActivity.swift` (règles) + `GithubPoller.swift` (réseau), `GoogleCalendar.swift` (règles, PKCE, écoute locale) + `GcalPoller.swift` (connexion, poll), cartes `GitHubCardView` / `GcalCardView` / `ReminderView` dans `IslandViewContent.swift`. Tests : `scripts/test-github-activity.sh`, `scripts/test-google-calendar.sh`. +Code Windows : `windows/src-tauri/src/github.rs`, `windows/src-tauri/src/gcal.rs`, cartes dans `windows/src/views/integrations.ts`. +Mêmes règles sur les deux plateformes. ### GitHub -- Même jeton que la carte macOS (`github-token`, scope `repo`). Une seule requête GraphQL par minute : dépôts + étoiles, PR ouvertes du compte (avec `latestReviews`), recherche `user-review-requested:@me`, et `statusCheckRollup` du dernier commit de la branche suivie. -- **Branche suivie** = celle du `cwd` de la dernière session Claude Code (chaque hook qui porte un `cwd`), lue directement dans `.git` : `HEAD`, puis `branch..remote` / `.merge` dans `config` (le `commondir` pour un worktree). Pas de processus `git`. +- Jeton `github-token` (classic, scope `repo`). Une seule requête GraphQL par minute : dépôts + étoiles, PR ouvertes du compte (avec `latestReviews`), recherche `user-review-requested:@me`, et `statusCheckRollup` du dernier commit de la branche suivie. +- **Branche suivie** = celle du `cwd` de la dernière session Claude Code (chaque hook qui porte un `cwd` ; sur macOS, seulement les sessions que l'île affiche), lue directement dans `.git` : `HEAD`, puis `branch..remote` / `.merge` dans `config` (le `commondir` pour un worktree). Pas de processus `git`. Version App Store : le bac à sable interdit de lire le `.git` du projet, donc pas de ligne CI (les reviews et PR restent). - Les PR sans activité depuis 30 jours sont ignorées (les PR de bots ouvertes au nom de l'utilisateur, typiquement Snyk). - Événements, un au plus par poll, jamais au premier poll : CI passée de « en cours » à terminée sur la même branche et le même commit ; nouvelle review sur une de ses PR ; nouvelle review demandée. ### Google Calendar - Pas de serveur Coucou, donc pas de client OAuth partagé : l'utilisateur crée un client OAuth « Desktop app » dans son propre projet Google Cloud et colle l'ID + le secret dans les Réglages. Écran de consentement à passer « In production », sinon Google invalide le refresh token au bout de 7 jours. -- Flux installed-app : PKCE (S256, hash et aléa via CNG de Windows), écoute ponctuelle sur `127.0.0.1:`, consentement dans le navigateur, vérification de `state`. Seul le refresh token est gardé (Gestionnaire d'identifiants) ; le jeton d'accès reste en mémoire. +- Flux installed-app : PKCE (S256 ; hash et aléa via CryptoKit / `SecRandomCopyBytes` sur macOS, CNG sur Windows), écoute ponctuelle sur `127.0.0.1:`, consentement dans le navigateur, vérification de `state`. Seul le refresh token est gardé (Trousseau macOS / Gestionnaire d'identifiants Windows) ; le jeton d'accès reste en mémoire. La version App Store déclare `com.apple.security.network.server` pour cette écoute. - Scopes `calendar.events.readonly` + `calendar.calendarlist.readonly`. Tous les agendas cochés dans Google Calendar (`selected`, plus le principal), pas seulement le principal — les événements vivent souvent dans un agenda partagé ou importé. Liste rafraîchie toutes les 10 min, événements des 7 jours à venir chaque minute, dédoublonnés et triés ; chaque ligne prend la couleur de son agenda. Un jeton antérieur au scope `calendarlist` (403) retombe sur l’agenda principal jusqu’à la prochaine connexion. Les événements refusés et les « working location » sont ignorés. - Rappels aux mêmes moments que Google Calendar : rappels propres de l'événement (`reminders.overrides`, y compris « aucun »), sinon ceux de son agenda (`defaultReminders`), sinon ceux de l'agenda principal (les agendas importés n'en ont pas), sinon 5 min. Seul le dernier rappel échu sonne (après un redémarrage, pas de rafale), un par poll. Les événements « toute la journée » ne sonnent pas. -- Le rappel ouvre la vue `reminder` (titre, « dans 30 min », horaire, lieu ; **Join** si visio, **Open**, **OK**), son `approval`. Si l'île est occupée (chat, approbation, dépôt de fichier), il reste un badge ambre sur la pill Calendar. +- Le rappel ouvre la vue `reminder` (titre, « dans 30 min », horaire, lieu ; **Join** si visio, **Open**, **OK**), son `approval`, seulement si la pill Calendar est active. Si l'île est occupée (chat, approbation, dépôt de fichier), il reste un badge ambre sur la pill Calendar. - « Disconnect » révoque le jeton chez Google puis l'efface. diff --git a/docs/SPEC.md b/docs/SPEC.md index 8dca0adfb0..9ef981e430 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -70,6 +70,7 @@ rouge `rgba(244,80,94,.55)`, vert `rgba(52,211,153,.5)`, rose `rgba(244,114,182, | `question` | 196 | 62, 56 | agent + question + options en boutons | 05 | | `error` | 190 | 62, 58 | agent + outil, titre, détail en rouge `#FF8D97`, Relancer, Ouvrir dans n8n | 06 | | `finished` | 170 | 62, 58 | agent + résumé, Voir le terminal, OK | 07 | +| `reminder` | 160 | 62, 58 | rappel Google Calendar (voile indigo) : agenda + « dans 30 min », titre, horaire · lieu, Join (visio), Open, OK | — | | `confused` | 160 | 76, 66 | « Trop de claques d'un coup. » | 08 | | `upload` | 176 | 140, 62 | zone pointillée, « Dépose tes fichiers ici », étiquettes | 09 | | `uploading` | 150 | sur la barre, Ø 28 | « Envoi de fichier » + %, barre verte, le bonhomme est le curseur de la barre | 10 | diff --git a/docs/privacy.html b/docs/privacy.html index 64b109b2be..a43f04fd3f 100644 --- a/docs/privacy.html +++ b/docs/privacy.html @@ -5,7 +5,7 @@

Privacy Policy

-
Last updated: October 1, 2026
+
Last updated: October 2, 2026
In short: Coucou has no account, no analytics, no ads and no tracking. The developer receives none of your data. Everything stays on your computer (Mac or PC), except what you choose to send to the services you connect yourself.

1. Who is responsible

@@ -17,7 +17,7 @@

2. Data we collect

3. Data stored on your computer

QuoiOùPourquoi
Clés d’API et jetons saisis (Anthropic, Stripe, GitHub, Vercel, Resend, Notion, Cal.com, n8n)Trousseau macOS, ou Gestionnaire d’identifiants WindowsAppeler ces services pour toi
Clés d’API et jetons saisis (Anthropic, Stripe, GitHub, Vercel, Resend, Notion, Cal.com, n8n, et sous Windows ton client OAuth Google et le jeton de connexion que Google renvoie)Trousseau macOS, ou Gestionnaire d’identifiants WindowsAppeler ces services pour toi
Préférences (son, délais, intégrations actives, raccourci)Préférences de l’app sur ton Mac, ou %APPDATA%\Coucou sous WindowsGarder tes réglages
Fichiers déposés sur le notchUne copie dans le dossier de l’app (sous Windows : %LOCALAPPDATA%\Coucou)Pouvoir poser une question dessus ou les joindre à un mail
Événements des sessions Claude Code (nom du projet, étape en cours, demandes de permission)En mémoire uniquementLes afficher dans le notch
- + @@ -30,8 +30,8 @@

4. Services you connect yourself

  • Anthropic (Claude API) — when you chat with Claude or ask about a file or a window, the text you type, the file content and/or the window context you chose are sent to Anthropic. Anthropic privacy policy.
  • Stripe, GitHub, Vercel, Resend, Notion, Cal.com, n8n — Coucou reads data from your own accounts (payments, repositories, deployments, emails, pages, bookings, workflow runs) to display them. Coucou only reads from them.
  • -
  • GitHub, on Windows — besides your repositories, Coucou reads your open pull requests, their reviews, the reviews requested from you, and the CI checks of the branch your last Claude Code session is on. It finds that branch by reading the project’s local .git folder; nothing about it is sent anywhere but GitHub.
  • -
  • Google Calendar, on Windows — once you connect your Google account with your own OAuth client, Coucou reads the list of your calendars and the events of the ones you show in Google Calendar, for the coming week (read-only access) to show them and remind you before a meeting. Google privacy policy.
  • +
  • GitHub — besides your repositories, Coucou reads your open pull requests, their reviews, the reviews requested from you, and the CI checks of the branch your last Claude Code session is on. It finds that branch by reading the project’s local .git folder; nothing about it is sent anywhere but GitHub.
  • +
  • Google Calendar — once you connect your Google account with your own OAuth client, Coucou reads the list of your calendars and the events of the ones you show in Google Calendar, for the coming week (read-only access) to show them and remind you before a meeting. Google privacy policy.

We have no access to these requests or their responses.

@@ -57,7 +57,7 @@

10. Changes


Politique de confidentialité

-
Dernière mise à jour : 1er octobre 2026
+
Dernière mise à jour : 2 octobre 2026
En bref : Coucou n’a ni compte, ni statistiques, ni publicité, ni pistage. Le développeur ne reçoit aucune de tes données. Tout reste sur ton ordinateur (Mac ou PC), sauf ce que tu choisis d’envoyer aux services que tu connectes toi-même.

1. Responsable

@@ -69,7 +69,7 @@

2. Données collectées

3. Données stockées sur ton ordinateur

WhatWhereWhy
API keys and tokens you enter (Anthropic, Stripe, GitHub, Vercel, Resend, Notion, Cal.com, n8n, and on Windows your Google OAuth client and the sign-in token Google returns)macOS Keychain, or Windows Credential ManagerTo call these services on your behalf
API keys and tokens you enter (Anthropic, Stripe, GitHub, Vercel, Resend, Notion, Cal.com, n8n, and your Google OAuth client and the sign-in token Google returns)macOS Keychain, or Windows Credential ManagerTo call these services on your behalf
Preferences (sound, delays, active integrations, shortcut)App preferences on your Mac, or %APPDATA%\Coucou on WindowsTo remember your settings
Files you drop on the notchA copy in the app’s own folder (on Windows: %LOCALAPPDATA%\Coucou)So you can ask a question about them or attach them to an email
Claude Code session events (project name, current step, permission requests)In memory onlyTo show them in the notch
- + @@ -81,8 +81,8 @@

4. Services que tu connectes toi-même

  • Anthropic (API Claude) : quand tu discutes avec Claude ou poses une question sur un fichier ou une fenêtre, le texte saisi, le contenu du fichier et/ou le contexte de la fenêtre choisie sont envoyés à Anthropic. Politique de confidentialité d’Anthropic.
  • Stripe, GitHub, Vercel, Resend, Notion, Cal.com, n8n : Coucou lit les données de tes propres comptes (paiements, dépôts, déploiements, e-mails, pages, rendez-vous, exécutions de workflows) pour les afficher. Coucou ne fait que lire.
  • -
  • GitHub, sous Windows : en plus de tes dépôts, Coucou lit tes pull requests ouvertes, leurs reviews, les reviews qu’on te demande, et les checks CI de la branche de ta dernière session Claude Code. Il trouve cette branche en lisant le dossier .git local du projet ; rien n’en part ailleurs que chez GitHub.
  • -
  • Google Calendar, sous Windows : une fois ton compte Google connecté avec ton propre client OAuth, Coucou lit la liste de tes agendas et les événements de ceux que tu affiches dans Google Calendar, pour la semaine à venir (accès en lecture seule) pour les afficher et te prévenir avant une réunion. Politique de confidentialité de Google.
  • +
  • GitHub : en plus de tes dépôts, Coucou lit tes pull requests ouvertes, leurs reviews, les reviews qu’on te demande, et les checks CI de la branche de ta dernière session Claude Code. Il trouve cette branche en lisant le dossier .git local du projet ; rien n’en part ailleurs que chez GitHub.
  • +
  • Google Calendar : une fois ton compte Google connecté avec ton propre client OAuth, Coucou lit la liste de tes agendas et les événements de ceux que tu affiches dans Google Calendar, pour la semaine à venir (accès en lecture seule) pour les afficher et te prévenir avant une réunion. Politique de confidentialité de Google.

Le développeur n’a accès ni à ces requêtes ni à leurs réponses.

diff --git a/scripts/test-github-activity.sh b/scripts/test-github-activity.sh new file mode 100755 index 0000000000..5cc20ed8aa --- /dev/null +++ b/scripts/test-github-activity.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/coucou-github.XXXXXX")" +trap 'rm -rf "$TEST_DIR"' EXIT +swiftc NotchBuddy/Sources/App/IntegrationNews.swift \ + NotchBuddy/Sources/App/GitHubActivity.swift \ + tests/GitHubActivityTests.swift -o "$TEST_DIR/github-activity-tests" +"$TEST_DIR/github-activity-tests" diff --git a/scripts/test-google-calendar.sh b/scripts/test-google-calendar.sh new file mode 100755 index 0000000000..e85715f8d4 --- /dev/null +++ b/scripts/test-google-calendar.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/coucou-gcal.XXXXXX")" +trap 'rm -rf "$TEST_DIR"' EXIT +swiftc NotchBuddy/Sources/App/IntegrationNews.swift \ + NotchBuddy/Sources/App/GoogleCalendar.swift \ + tests/GoogleCalendarTests.swift -o "$TEST_DIR/google-calendar-tests" +"$TEST_DIR/google-calendar-tests" diff --git a/tests/GitHubActivityTests.swift b/tests/GitHubActivityTests.swift new file mode 100644 index 0000000000..ded39ecddd --- /dev/null +++ b/tests/GitHubActivityTests.swift @@ -0,0 +1,214 @@ +import Foundation + +// Same cases as the tests in windows/src-tauri/src/github.rs and time.rs. + +@main +enum GitHubActivityTests { + nonisolated(unsafe) static var passed = 0 + + static func check(_ condition: Bool, _ what: String, line: Int = #line) { + precondition(condition, "line \(line): \(what)") + passed += 1 + } + + static func main() { + dates() + githubURLs() + upstreamFromConfig() + worktreeGitDir() + parsesAGraphQLAnswer() + firstPollIsSilentThenChangesAreAnnounced() + ciIsAnnouncedOnlyOnAWatchedTransition() + print("GitHub activity: \(passed) checks passed") + } + + static func dates() { + check(RFC3339.utc(0) == "1970-01-01T00:00:00Z", "epoch") + check(RFC3339.utc(1_790_000_000) == "2026-09-21T14:13:20Z", "utc") + check(RFC3339.parse("2026-09-21T14:13:20Z") == 1_790_000_000, "Z") + check(RFC3339.parse("2026-09-21T16:13:20+02:00") == 1_790_000_000, "offset") + check(RFC3339.parse("2026-09-21T09:13:20.250-05:00") == 1_790_000_000, "fraction + negative offset") + check(RFC3339.parse("2024-02-29T00:00:00Z").map(RFC3339.utc) == "2024-02-29T00:00:00Z", "leap day") + check(RFC3339.parse("2026-10-01") == nil, "a date alone is not a time") + check(RFC3339.parse("") == nil, "empty") + } + + static func githubURLs() { + func ok(_ u: String) -> String? { GitHubBranch.parseGitHubURL(u).map { "\($0.0)/\($0.1)" } } + check(ok("https://github.com/Louis-CFM/coucou.git") == "Louis-CFM/coucou", "https .git") + check(ok("https://github.com/Louis-CFM/coucou") == "Louis-CFM/coucou", "https") + check(ok("https://me@github.com/a/b/") == "a/b", "user + trailing slash") + check(ok("git@github.com:a/b.git") == "a/b", "scp-like") + check(ok("ssh://git@github.com/a/b.git") == "a/b", "ssh") + check(ok("https://gitlab.com/a/b.git") == nil, "not GitHub") + check(ok("https://github.com/a") == nil, "no repo") + } + + static func upstreamFromConfig() { + let config = """ + [core] + \tbare = false + [remote "origin"] + \turl = git@github.com:Louis-CFM/coucou.git + \tfetch = +refs/heads/*:refs/remotes/origin/* + [remote "fork"] + \turl = https://github.com/jhoan/coucou.git + [branch "main"] + \tremote = origin + \tmerge = refs/heads/main + [branch "local-name"] + \tremote = fork + \tmerge = refs/heads/remote-name + """ + let main = GitHubBranch.resolveUpstream(config: config, local: "main") + check(main == GitHubBranchRef(owner: "Louis-CFM", name: "coucou", branch: "main"), "main") + let forked = GitHubBranch.resolveUpstream(config: config, local: "local-name") + check(forked?.owner == "jhoan" && forked?.branch == "remote-name", "fork upstream") + // Never pushed: same name on origin. + let fresh = GitHubBranch.resolveUpstream(config: config, local: "feat/x") + check(fresh?.owner == "Louis-CFM" && fresh?.branch == "feat/x", "never pushed") + // No origin at all: whichever remote points at GitHub. + let other = GitHubBranch.resolveUpstream(config: "[remote \"up\"]\n\turl = https://github.com/x/y\n", local: "dev") + check(other == GitHubBranchRef(owner: "x", name: "y", branch: "dev"), "any GitHub remote") + check(GitHubBranch.resolveUpstream(config: "[remote \"origin\"]\n\turl = https://gitlab.com/x/y\n", local: "dev") == nil, + "no GitHub remote") + } + + static func worktreeGitDir() { + let fm = FileManager.default + let root = fm.temporaryDirectory.appendingPathComponent("coucou-gh-\(ProcessInfo.processInfo.processIdentifier)") + defer { try? fm.removeItem(at: root) } + let mainGit = root.appendingPathComponent("repo/.git") + let wtGit = mainGit.appendingPathComponent("worktrees/wt") + let wt = root.appendingPathComponent("wt") + try! fm.createDirectory(at: wtGit, withIntermediateDirectories: true) + try! fm.createDirectory(at: wt.appendingPathComponent("sub"), withIntermediateDirectories: true) + try! "[remote \"origin\"]\n\turl = https://github.com/a/b.git\n" + .write(to: mainGit.appendingPathComponent("config"), atomically: true, encoding: .utf8) + try! "ref: refs/heads/fix/thing\n".write(to: wtGit.appendingPathComponent("HEAD"), atomically: true, encoding: .utf8) + try! "../..\n".write(to: wtGit.appendingPathComponent("commondir"), atomically: true, encoding: .utf8) + try! "gitdir: \(wtGit.path)\n".write(to: wt.appendingPathComponent(".git"), atomically: true, encoding: .utf8) + + let got = GitHubBranch.of(cwd: wt.appendingPathComponent("sub")) + check(got == GitHubBranchRef(owner: "a", name: "b", branch: "fix/thing"), "worktree") + + // The main checkout itself, and a detached HEAD. + try! "ref: refs/heads/main\n".write(to: mainGit.appendingPathComponent("HEAD"), atomically: true, encoding: .utf8) + check(GitHubBranch.of(cwd: root.appendingPathComponent("repo")) == GitHubBranchRef(owner: "a", name: "b", branch: "main"), + "normal checkout") + try! "4b825dc642cb6eb9a060e54bf8d69288fbee4904\n" + .write(to: mainGit.appendingPathComponent("HEAD"), atomically: true, encoding: .utf8) + check(GitHubBranch.of(cwd: root.appendingPathComponent("repo")) == nil, "detached HEAD") + check(GitHubBranch.of(cwd: URL(fileURLWithPath: "/")) == nil, "no repository") + } + + static func json(_ text: String) -> [String: Any] { + try! JSONSerialization.jsonObject(with: Data(text.utf8)) as! [String: Any] + } + + static func parsesAGraphQLAnswer() { + let recent = RFC3339.utc(RFC3339.now() - 3600) + let data = json(""" + { + "viewer": { + "login": "me", + "repositories": { "totalCount": 2, "nodes": [{ "stargazerCount": 3 }, { "stargazerCount": 4 }] }, + "pullRequests": { "nodes": [ + { "number": 8, "title": "Calendar", "url": "u8", "isDraft": false, "headRefName": "feat/cal", + "reviewDecision": null, "updatedAt": "\(recent)", + "repository": { "nameWithOwner": "Louis-CFM/coucou" }, + "latestReviews": { "nodes": [{ "id": "r1", "state": "COMMENTED", "author": { "login": "louis" } }] } }, + { "number": 2, "title": "[Snyk] Fix", "url": "u2", "isDraft": false, "headRefName": "snyk-fix-1", + "reviewDecision": null, "updatedAt": "2025-12-14T14:33:19Z", + "repository": { "nameWithOwner": "me/old" }, "latestReviews": { "nodes": [] } } + ] } + }, + "requests": { "issueCount": 1, "nodes": [ + { "number": 9, "title": "Docs", "url": "u9", "author": { "login": "x" }, "repository": { "nameWithOwner": "a/b" } }, + {} + ] }, + "repository": { + "nameWithOwner": "Louis-CFM/coucou", + "ref": { "target": { + "oid": "abc", "url": "commit-url", "committedDate": "\(recent)", + "statusCheckRollup": { "state": "FAILURE", "contexts": { "nodes": [ + { "__typename": "CheckRun", "name": "lint", "conclusion": "SUCCESS", "detailsUrl": "l" }, + { "__typename": "CheckRun", "name": "build", "conclusion": "FAILURE", "detailsUrl": "job-url" } + ] } } + } } + } + } + """) + let target = GitHubBranchRef(owner: "Louis-CFM", name: "coucou", branch: "feat/cal") + let s = GitHubActivity.parse(data, target: target) + check(s.login == "me" && s.totalRepos == 2 && s.totalStars == 7, "totals") + // The ten-month-old bot PR is gone. + check(s.pulls.map(\.number) == [8], "stale pull requests left out") + check(s.pulls.first?.reviews.first == GitHubActivity.Review(id: "r1", state: "COMMENTED", author: "louis"), "reviews") + check(s.requests.map(\.number) == [9] && s.requestCount == 1, "requests (non-PR nodes skipped)") + let b = s.branch + check(b?.state == "FAILURE" && b?.failing == ["build"] && b?.url == "job-url" && b?.pr == 8 && b?.pushed == true, + "branch CI") + + // A branch GitHub doesn't know yet, and a repository the token can't see. + let unpushed = GitHubActivity.parse(json(#"{"viewer": {}, "repository": {"nameWithOwner": "a/b", "ref": null}}"#), + target: GitHubBranchRef(owner: "a", name: "b", branch: "new")) + check(unpushed.branch?.pushed == false && unpushed.branch?.state == nil, "not pushed") + let hidden = GitHubActivity.parse(json(#"{"viewer": {}, "repository": null}"#), + target: GitHubBranchRef(owner: "a", name: "b", branch: "new")) + check(hidden.branch == nil, "repository not visible") + check(GitHubActivity.parse(data, target: nil).branch == nil, "no session branch") + } + + static func snapshot(_ state: String?, reviews: [(String, String)], requests: [String]) -> GitHubActivity { + var s = GitHubActivity() + s.login = "me" + s.branch = .init(repo: "a/b", branch: "fix", pushed: true, oid: "abc", state: state, + failing: state == "FAILURE" ? ["build"] : [], url: "", committedAt: "", pr: 7) + s.pulls = [.init(repo: "a/b", number: 7, title: "Fix", url: "u7", draft: false, head: "fix", decision: nil, + reviews: reviews.map { .init(id: $0.0, state: $0.1, author: "louis") })] + s.requests = requests.map { .init(repo: "a/b", number: 9, title: "T", url: $0, author: "x") } + return s + } + + static func firstPollIsSilentThenChangesAreAnnounced() { + var m = GitHubMemory() + // Already red when first seen, an existing review, an existing request. + check(m.diff(snapshot("FAILURE", reviews: [("r1", "COMMENTED")], requests: ["p1"])) == nil, "first poll silent") + // Same again: nothing new. + check(m.diff(snapshot("FAILURE", reviews: [("r1", "COMMENTED")], requests: ["p1"])) == nil, "nothing new") + + // A new review request. + let e = m.diff(snapshot("FAILURE", reviews: [("r1", "COMMENTED")], requests: ["p1", "p2"])) + check(e?.attention == true && e?.label.hasPrefix("Review requested") == true, "review requested") + + // An approval. + let a = m.diff(snapshot("FAILURE", reviews: [("r2", "APPROVED")], requests: ["p1", "p2"])) + check(a?.success == true && a?.label == "Approved · #7", "approval") + + // Your own review says nothing. + var mine = snapshot("FAILURE", reviews: [("r3", "COMMENTED")], requests: []) + mine.pulls[0].reviews[0].author = "me" + check(m.diff(mine) == nil, "own review") + } + + static func ciIsAnnouncedOnlyOnAWatchedTransition() { + var m = GitHubMemory() + check(m.diff(snapshot("PENDING", reviews: [], requests: [])) == nil, "pending first") + let e = m.diff(snapshot("FAILURE", reviews: [], requests: [])) + check(e?.success == false && e?.label == "CI failed · fix" && e?.detail == "build", "CI failed") + // Still red on the next poll: already said. + check(m.diff(snapshot("FAILURE", reviews: [], requests: [])) == nil, "said once") + + // A red build outranks an approval landing in the same poll. + var m2 = GitHubMemory() + _ = m2.diff(snapshot("PENDING", reviews: [], requests: [])) + let both = m2.diff(snapshot("FAILURE", reviews: [("r9", "APPROVED")], requests: [])) + check(both?.label.hasPrefix("CI failed") == true, "priority") + + // Green after pending. + var m3 = GitHubMemory() + _ = m3.diff(snapshot("PENDING", reviews: [], requests: [])) + check(m3.diff(snapshot("SUCCESS", reviews: [], requests: []))?.label == "CI passed · fix", "CI passed") + } +} diff --git a/tests/GoogleCalendarTests.swift b/tests/GoogleCalendarTests.swift new file mode 100644 index 0000000000..fa7e0acb2e --- /dev/null +++ b/tests/GoogleCalendarTests.swift @@ -0,0 +1,251 @@ +import Foundation + +// Same cases as the tests in windows/src-tauri/src/gcal.rs, plus the calendar list. + +@main +enum GoogleCalendarTests { + nonisolated(unsafe) static var passed = 0 + + static func check(_ condition: Bool, _ what: String, line: Int = #line) { + precondition(condition, "line \(line): \(what)") + passed += 1 + } + + static func main() { + encodings() + pkcePrimitives() + loopbackListener() + calendarList() + allDayEventsSortAmongTimedOnes() + eventsAreFilteredAndParsed() + reminderMinutesAreReadAndSorted() + remindersRingLikeGoogleCalendar() + startsInReadsNaturally() + print("Google Calendar: \(passed) checks passed") + } + + static func encodings() { + check(GcalOAuth.b64url([]) == "", "b64 empty") + check(GcalOAuth.b64url(Array("f".utf8)) == "Zg", "b64 f") + check(GcalOAuth.b64url(Array("fo".utf8)) == "Zm8", "b64 fo") + check(GcalOAuth.b64url(Array("foo".utf8)) == "Zm9v", "b64 foo") + check(GcalOAuth.b64url([0xfb, 0xff]) == "-_8", "b64 url alphabet") + check(GcalOAuth.pct("a b/c:d") == "a%20b%2Fc%3Ad", "pct") + check(GcalOAuth.pct("é") == "%C3%A9", "pct utf-8") + check(GcalOAuth.pctDecode("4%2F0Ab+x%zz%4") == "4/0Ab x%zz%4", "pct decode") + let q = GcalOAuth.parseQuery("state=abc&code=4%2F0AX&scope=x") + check(q.count == 3 && q[1].0 == "code" && q[1].1 == "4/0AX", "query") + check(GcalOAuth.form([("a", "1 2"), ("b", "x&y")]) == "a=1%202&b=x%26y", "form") + } + + static func pkcePrimitives() { + // FIPS 180-2's "abc" vector. + let hex = GcalOAuth.sha256(Array("abc".utf8)).map { String(format: "%02x", $0) }.joined() + check(hex == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", "sha256") + let a = try! GcalOAuth.randomBytes(32) + check(a.count == 32 && a != (try! GcalOAuth.randomBytes(32)), "random") + // 32 bytes → the 43-character verifier RFC 7636 asks for. + check(GcalOAuth.b64url(a).count == 43, "verifier length") + } + + /// The loopback listener: strays are ignored, the right redirect yields its + /// code, and Cancel ends the wait even though Google never came back. + static func loopbackListener() { + check(GcalOAuth.route("GET /?state=s&error=access_denied HTTP/1.1", state: "s") == .denied("access_denied"), "denied") + check(GcalOAuth.route("GET /?state=s HTTP/1.1", state: "s") == .badRequest, "no code") + check(GcalOAuth.route("POST /?state=s&code=c HTTP/1.1", state: "s") == .stray, "not a GET") + check(GcalOAuth.route("GET /other?state=s&code=c HTTP/1.1", state: "s") == .stray, "other path") + + let listener = try! LoopbackListener() + defer { listener.close() } + let port = listener.port + check(port > 0, "free port") + + // One after the other, like a browser: the listener stops at the redirect + // it wants, so anything sent after it would go unanswered. + nonisolated(unsafe) var replies: [String] = [] + let browser = Thread { + for path in ["/favicon.ico", "/?state=wrong&code=evil", "/?state=s3cr3t&code=4%2F0Abc&scope=x"] { + replies.append(get(port: port, path: path)) + } + } + browser.start() + let code = listener.waitForCode(state: "s3cr3t", deadline: Date().addingTimeInterval(10), stillWanted: { true }) + while !browser.isFinished { usleep(10_000) } + check(code == .success("4/0Abc"), "code") + check(replies.count == 3, "three replies") + check(replies[0].hasPrefix("HTTP/1.1 404"), "favicon 404") + check(replies[1].hasPrefix("HTTP/1.1 404"), "forged state 404") + check(replies[2].contains("connected to Google Calendar"), "success page") + + // Google's "access blocked" page: no redirect ever arrives. + let started = Date() + let cancelAt = started.addingTimeInterval(0.3) + let result = listener.waitForCode(state: "s3cr3t", deadline: started.addingTimeInterval(10), + stillWanted: { Date() < cancelAt }) + check(result == .failure(.shown("Sign-in cancelled.")), "cancel") + check(Date().timeIntervalSince(started) < 3, "cancel is quick") + + let timedOut = listener.waitForCode(state: "s3cr3t", deadline: Date().addingTimeInterval(0.2), stillWanted: { true }) + check(timedOut == .failure(.shown("Timed out waiting for Google.")), "timeout") + } + + /// A minimal HTTP GET over a raw socket, like the browser's redirect. + static func get(port: UInt16, path: String) -> String { + let fd = socket(AF_INET, SOCK_STREAM, 0) + defer { close(fd) } + var addr = sockaddr_in() + addr.sin_len = UInt8(MemoryLayout.size) + addr.sin_family = sa_family_t(AF_INET) + addr.sin_port = port.bigEndian + addr.sin_addr.s_addr = inet_addr("127.0.0.1") + let ok = withUnsafePointer(to: &addr) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { connect(fd, $0, socklen_t(MemoryLayout.size)) } + } + guard ok == 0 else { return "" } + let request = Array("GET \(path) HTTP/1.1\r\nHost: x\r\n\r\n".utf8) + _ = request.withUnsafeBytes { send(fd, $0.baseAddress!, request.count, 0) } + var reply: [UInt8] = [] + var buf = [UInt8](repeating: 0, count: 4096) + while true { + let n = recv(fd, &buf, buf.count, 0) + if n <= 0 { break } + reply += buf[0.. [String: Any] { + try! JSONSerialization.jsonObject(with: Data(text.utf8)) as! [String: Any] + } + + static func calendarList() { + let list = GcalCalendar.parseList(json(""" + { "items": [ + { "id": "team", "summary": "Team", "selected": true, "backgroundColor": "#16a765" }, + { "id": "hidden", "summary": "Hidden", "selected": true, "hidden": true }, + { "id": "unticked", "summary": "Unticked" }, + { "id": "me@x.com", "summary": "me@x.com", "summaryOverride": "Mine", "primary": true } + ] } + """)) + check(list.map(\.id) == ["me@x.com", "team"], "shown calendars, primary first") + check(list[0].name == "Mine" && list[0].primary && list[1].color == "#16a765", "names and colours") + } + + static let cal = GcalCalendar(id: "primary", name: "Main", color: "#16a765", primary: true) + + static func allDayEventsSortAmongTimedOnes() { + func parse(_ text: String) -> GcalEvent { GcalEvent.parse(json(text), calendar: cal, defaults: [5])! } + let events = GcalEvent.merge([ + parse(#"{"id": "poker", "start": {"dateTime": "2026-10-02T17:30:00Z"}, "end": {"dateTime": "2026-10-02T21:30:00Z"}}"#), + parse(#"{"id": "holiday", "start": {"date": "2026-10-02"}, "end": {"date": "2026-10-03"}}"#), + parse(#"{"id": "today", "start": {"dateTime": "2026-10-01T19:00:00+02:00"}, "end": {"dateTime": "2026-10-01T20:00:00+02:00"}}"#), + // The same invitation seen through a second calendar. + parse(#"{"id": "today", "start": {"dateTime": "2026-10-01T19:00:00+02:00"}, "end": {"dateTime": "2026-10-01T20:00:00+02:00"}}"#), + ]) + check(events.map(\.id) == ["today", "holiday", "poker"], "chronological, deduplicated") + } + + static func event(_ id: String, _ start: String) -> [String: Any] { + ["id": id, "summary": id, "start": ["dateTime": start], "end": ["dateTime": start]] + } + + static func eventsAreFilteredAndParsed() { + let declined = json(""" + { "id": "d", "start": { "dateTime": "2026-10-01T10:00:00Z" }, "end": { "dateTime": "2026-10-01T11:00:00Z" }, + "attendees": [{ "self": true, "responseStatus": "declined" }] } + """) + check(GcalEvent.parse(declined, calendar: cal, defaults: [5]) == nil, "declined") + check(GcalEvent.parse(json(#"{"id": "c", "status": "cancelled", "start": {"date": "2026-10-02"}}"#), + calendar: cal, defaults: [5]) == nil, "cancelled") + check(GcalEvent.parse(json(#"{"id": "w", "eventType": "workingLocation", "start": {"date": "2026-10-02"}}"#), + calendar: cal, defaults: [5]) == nil, "working location") + let allDay = GcalEvent.parse(json(#"{"id": "a", "start": {"date": "2026-10-02"}, "end": {"date": "2026-10-03"}}"#), + calendar: cal, defaults: [5])! + check(allDay.allDay && allDay.startSecs == nil && allDay.title == "(No title)", "all day") + let meet = GcalEvent.parse(json(""" + { "id": "m", "summary": "Standup", "start": { "dateTime": "2026-10-01T10:00:00Z" }, + "end": { "dateTime": "2026-10-01T10:15:00Z" }, "htmlLink": "https://calendar.google.com/x", + "conferenceData": { "entryPoints": [ + { "entryPointType": "phone", "uri": "tel:+1" }, + { "entryPointType": "video", "uri": "https://meet.google.com/abc" } + ] } } + """), calendar: cal, defaults: [5])! + check(meet.meetURL == "https://meet.google.com/abc" && meet.calendar == "Main" && meet.color == "#16a765", + "video link") + let zoom = GcalEvent.parse(json(""" + { "id": "z", "start": { "dateTime": "2026-10-01T10:00:00Z" }, "end": { "dateTime": "2026-10-01T10:15:00Z" }, + "location": "https://zoom.us/j/1" } + """), calendar: cal, defaults: [5])! + check(zoom.meetURL == "https://zoom.us/j/1", "link in the location") + } + + static func reminderMinutesAreReadAndSorted() { + let list = json(#"{"r": [{"method": "popup", "minutes": 30}, {"method": "email", "minutes": 1440}, {"method": "popup", "minutes": 30}]}"#)["r"] + check(reminderMinutes(list) == [30, 1440], "sorted, deduplicated") + check(reminderMinutes(nil) == [], "none") + var e = event("x", "2026-10-02T17:30:00Z") + e["reminders"] = ["useDefault": false, "overrides": [Any]()] + // "No notification" on the event beats the calendar's defaults. + check(GcalEvent.parse(e, calendar: cal, defaults: [30])!.reminders.isEmpty, "no notification") + e["reminders"] = ["useDefault": false, "overrides": [["method": "popup", "minutes": 10]]] + check(GcalEvent.parse(e, calendar: cal, defaults: [30])!.reminders == [10], "overrides") + e["reminders"] = ["useDefault": true] + check(GcalEvent.parse(e, calendar: cal, defaults: [30, 1440])!.reminders == [30, 1440], "defaults") + } + + static func remindersRingLikeGoogleCalendar() { + let t = RFC3339.parse("2026-10-02T17:30:00Z")! + let min = 60 + let poker = { [GcalEvent.parse(event("poker", "2026-10-02T17:30:00Z"), calendar: cal, defaults: [30, 1440])!] } + + // A day ahead, then 30 minutes ahead; once each. + var r = GcalReminders() + let events = poker() + check(r.next(events, now: t - 25 * 60 * min) == nil, "too early") + check(r.next(events, now: t - 1440 * min + 30)?.news.detail == "Starts in a day", "day before") + check(r.next(events, now: t - 1439 * min) == nil, "once") + check(r.next(events, now: t - 31 * min) == nil, "not yet") + let e = r.next(events, now: t - 30 * min) + check(e?.news.label == "poker" && e?.news.detail == "Starts in 30 min" && e?.news.attention == true, "30 min") + check(e?.minutes == 30 && e?.event.calendar == "Main", "the event") + check(r.next(events, now: t - 29 * min) == nil, "once again") + + // Started 20 minutes before the meeting: one reminder, not both. + r = GcalReminders() + check(r.next(events, now: t - 20 * min)?.news.detail == "Starts in 20 min", "late start") + check(r.next(events, now: t - 19 * min) == nil, "no burst") + + // "At the time of the event" still rings, within its minute. + r = GcalReminders() + let standup = [GcalEvent.parse(event("standup", "2026-10-02T17:30:00Z"), calendar: cal, defaults: [0])!] + check(r.next(standup, now: t - 30) == nil, "before") + check(r.next(standup, now: t + 10)?.news.detail == "Starting now", "at the time") + r = GcalReminders() + check(r.next(standup, now: t + 90) == nil, "too late") + + // Two due at once: the earlier one now, the other on the next poll. + r = GcalReminders() + let two = [ + GcalEvent.parse(event("a", "2026-10-02T17:30:00Z"), calendar: cal, defaults: [30])!, + GcalEvent.parse(event("b", "2026-10-02T17:40:00Z"), calendar: cal, defaults: [30])!, + ] + check(r.next(two, now: t - 5 * min)?.news.label == "a", "first") + check(r.next(two, now: t - 4 * min)?.news.label == "b", "second") + check(r.next(two, now: t - 3 * min) == nil, "done") + + // Moved: rings again. + r = GcalReminders() + _ = r.next(two, now: t - 5 * min) + let moved = [GcalEvent.parse(event("a", "2026-10-02T17:35:00Z"), calendar: cal, defaults: [30])!] + check(r.next(moved, now: t - 4 * min)?.news.label == "a", "moved event") + } + + static func startsInReadsNaturally() { + check(GcalReminders.startsIn(0) == "Starting now", "0") + check(GcalReminders.startsIn(1) == "Starts in a minute", "1") + check(GcalReminders.startsIn(90) == "Starts in 1 h 30 min", "90") + check(GcalReminders.startsIn(120) == "Starts in 2 h", "120") + check(GcalReminders.startsIn(3000) == "Starts in 2 days", "3000") + } +} diff --git a/windows/README.md b/windows/README.md index 843686dd32..5ba9f6d3e8 100644 --- a/windows/README.md +++ b/windows/README.md @@ -191,5 +191,3 @@ problems. It stays on your machine. attach it as context, and jumping to a specific terminal window — "Open terminal" opens the working folder in VS Code when `code` is on your `PATH`. - Cal.com shows the next bookings as a list rather than the Mac's calendar. -- Windows only, for now: GitHub CI and pull requests (the Mac card shows stars - and repositories), and Google Calendar. diff --git a/windows/src-tauri/src/github.rs b/windows/src-tauri/src/github.rs index cb2cf8abee..6537a5855e 100644 --- a/windows/src-tauri/src/github.rs +++ b/windows/src-tauri/src/github.rs @@ -4,7 +4,7 @@ // "The branch you're on" is the branch checked out in the folder of the last // Claude Code session, read straight from its .git directory: no `git` process, // no console flash, and nothing to configure. Everything else comes from one -// GraphQL query per poll, with the same token the macOS overview card uses. +// GraphQL query per poll. GitHubActivity.swift follows the same rules on macOS. // // Events, at most one per poll, only for things that changed since the last // poll: a CI run on that branch finishing, a new review on one of your pull diff --git a/windows/src-tauri/src/integrations.rs b/windows/src-tauri/src/integrations.rs index 05ccbfda3e..8e1641e019 100644 --- a/windows/src-tauri/src/integrations.rs +++ b/windows/src-tauri/src/integrations.rs @@ -5,8 +5,8 @@ // one emits an `integration` event; the island owns the badge, the sound and the // 60 s auto-clear, exactly as the Swift handlers do. // -// GitHub (CI and pull requests) and Google Calendar go further than macOS and -// live in their own modules, github.rs and gcal.rs. +// GitHub (CI and pull requests) and Google Calendar live in their own modules, +// github.rs and gcal.rs (GithubPoller / GcalPoller on macOS). // // Nothing is polled until its key exists in the Credential Manager, and no // request goes anywhere the user has not configured. diff --git a/windows/src/core/state.ts b/windows/src/core/state.ts index 6cf4fa0cc6..259da14f9b 100644 --- a/windows/src/core/state.ts +++ b/windows/src/core/state.ts @@ -56,10 +56,7 @@ const task = ( id, name, color, state: "idle", stepIndex: 0, steps: [], source, isIntegration: true, }); -/** - * AgentTask.integrationAgents — same ids, names and colours as macOS, plus - * Google Calendar, which only exists on Windows for now. - */ +/** AgentTask.integrationAgents — same ids, names and colours as macOS. */ export const INTEGRATION_AGENTS: AgentTask[] = [ task("integration_claude", "VS Code", "#F5F6F8", "claudeCode"), task("integration_resend", "Resend", "#22C55E", "n8n"), From f503049b6576ec89cd3041006a5d492be1582391 Mon Sep 17 00:00:00 2001 From: Jhoan Gallego Date: Mon, 5 Oct 2026 17:11:37 +0200 Subject: [PATCH 09/10] Linux: PKCE for the Google Calendar sign-in without CNG gcal.rs drew its random bytes and SHA-256 from Windows CNG, which doesn't exist on Linux, so the Tauri app stopped compiling there once the Calendar work met the Linux port. Linux now reads /dev/urandom and hashes with sha2 (already in the tree through Tauri); Windows keeps CNG. A test checks the RFC 7636 S256 example on whichever implementation the platform builds. Co-Authored-By: Claude Opus 5.5 --- windows/Cargo.lock | 1 + windows/src-tauri/Cargo.toml | 3 +++ windows/src-tauri/src/gcal.rs | 30 ++++++++++++++++++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/windows/Cargo.lock b/windows/Cargo.lock index 6278c75254..81e678fd32 100644 --- a/windows/Cargo.lock +++ b/windows/Cargo.lock @@ -618,6 +618,7 @@ dependencies = [ "reqwest 0.12.28", "serde", "serde_json", + "sha2", "tauri", "tauri-build", "tauri-plugin-autostart", diff --git a/windows/src-tauri/Cargo.toml b/windows/src-tauri/Cargo.toml index 1775dc040b..ec18ba99a2 100644 --- a/windows/src-tauri/Cargo.toml +++ b/windows/src-tauri/Cargo.toml @@ -43,3 +43,6 @@ libc = "0.2" # The GTK that Tauri already runs on, to reach its window for layer-shell and # the input region. libgtk-layer-shell itself is called directly (platform/linux.rs). gtk = "0.18" +# SHA-256 for the Google Calendar PKCE challenge (Windows uses CNG). Already in +# the dependency tree through Tauri. +sha2 = "0.10" diff --git a/windows/src-tauri/src/gcal.rs b/windows/src-tauri/src/gcal.rs index 2a7c308e95..8797acc8b8 100644 --- a/windows/src-tauri/src/gcal.rs +++ b/windows/src-tauri/src/gcal.rs @@ -689,6 +689,7 @@ fn b64url(bytes: &[u8]) -> String { } /// Windows' own CSPRNG (CNG), so PKCE needs no extra crate. +#[cfg(windows)] fn random_bytes(n: usize) -> Result, String> { use windows::Win32::Security::Cryptography::{BCryptGenRandom, BCRYPT_USE_SYSTEM_PREFERRED_RNG}; let mut buf = vec![0u8; n]; @@ -698,6 +699,7 @@ fn random_bytes(n: usize) -> Result, String> { Ok(buf) } +#[cfg(windows)] fn sha256(data: &[u8]) -> Result, String> { use windows::Win32::Security::Cryptography::{BCryptHash, BCRYPT_SHA256_ALG_HANDLE}; let mut out = vec![0u8; 32]; @@ -707,6 +709,24 @@ fn sha256(data: &[u8]) -> Result, String> { Ok(out) } +/// The kernel's CSPRNG on Linux. +#[cfg(target_os = "linux")] +fn random_bytes(n: usize) -> Result, String> { + use std::io::Read; + let mut buf = vec![0u8; n]; + std::fs::File::open("/dev/urandom") + .and_then(|mut f| f.read_exact(&mut buf)) + .map_err(|e| e.to_string())?; + Ok(buf) +} + +/// `sha2`, already in the tree through Tauri; Windows uses CNG instead. +#[cfg(target_os = "linux")] +fn sha256(data: &[u8]) -> Result, String> { + use sha2::{Digest, Sha256}; + Ok(Sha256::digest(data).to_vec()) +} + #[cfg(test)] mod tests { use super::*; @@ -724,6 +744,16 @@ mod tests { assert_eq!(q[1], ("code".to_string(), "4/0AX".to_string())); } + #[test] + fn pkce_primitives() { + // RFC 7636 appendix B: the S256 challenge of its example verifier + let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + assert_eq!(b64url(&sha256(verifier.as_bytes()).unwrap()), "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"); + let a = random_bytes(32).unwrap(); + assert_eq!(a.len(), 32); + assert_ne!(a, random_bytes(32).unwrap()); + } + #[test] fn pkce_primitives() { // FIPS 180-2's "abc" vector. From 5e821f4fb5e41c060dc63358a792dfbfee3c37de Mon Sep 17 00:00:00 2001 From: Jhoan Gallego Date: Mon, 5 Oct 2026 17:16:09 +0200 Subject: [PATCH 10/10] Linux: rename the RFC 7636 test so it doesn't clash with pkce_primitives Co-Authored-By: Claude Opus 5.5 --- windows/src-tauri/src/gcal.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/windows/src-tauri/src/gcal.rs b/windows/src-tauri/src/gcal.rs index 8797acc8b8..cb176814a5 100644 --- a/windows/src-tauri/src/gcal.rs +++ b/windows/src-tauri/src/gcal.rs @@ -745,13 +745,10 @@ mod tests { } #[test] - fn pkce_primitives() { - // RFC 7636 appendix B: the S256 challenge of its example verifier + fn pkce_s256_rfc7636() { + // RFC 7636 appendix B: the S256 challenge of its example verifier. let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; assert_eq!(b64url(&sha256(verifier.as_bytes()).unwrap()), "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"); - let a = random_bytes(32).unwrap(); - assert_eq!(a.len(), 32); - assert_ne!(a, random_bytes(32).unwrap()); } #[test]
QuoiOùPourquoi
Clés d’API et jetons saisis (Anthropic, Stripe, GitHub, Vercel, Resend, Notion, Cal.com, n8n, et sous Windows ton client OAuth Google et le jeton de connexion que Google renvoie)Trousseau macOS, ou Gestionnaire d’identifiants WindowsAppeler ces services pour toi
Clés d’API et jetons saisis (Anthropic, Stripe, GitHub, Vercel, Resend, Notion, Cal.com, n8n, et ton client OAuth Google et le jeton de connexion que Google renvoie)Trousseau macOS, ou Gestionnaire d’identifiants WindowsAppeler ces services pour toi
Préférences (son, délais, intégrations actives, raccourci)Préférences de l’app sur ton Mac, ou %APPDATA%\Coucou sous WindowsGarder tes réglages
Fichiers déposés sur le notchUne copie dans le dossier de l’app (sous Windows : %LOCALAPPDATA%\Coucou)Pouvoir poser une question dessus ou les joindre à un mail
Événements des sessions Claude Code (nom du projet, étape en cours, demandes de permission)En mémoire uniquementLes afficher dans le notch