From 9006070322cfbbc44f9083ca14683e4ab54a74a9 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 22 Jul 2026 15:19:15 -0300 Subject: [PATCH 1/2] Release KDE and Firefox profile support --- .gitignore | 6 + Cargo.lock | 89 ++- Cargo.toml | 6 +- README.md | 8 +- apps/desktop/package.json | 2 +- apps/desktop/src-tauri/Cargo.lock | 6 +- apps/desktop/src-tauri/Cargo.toml | 2 +- apps/desktop/src-tauri/src/commands.rs | 31 + apps/desktop/src-tauri/src/lib.rs | 3 + apps/desktop/src-tauri/tauri.conf.json | 5 +- apps/desktop/src/App.tsx | 317 ++++++++- apps/desktop/src/collection-controls.test.ts | 1 + apps/desktop/src/collection-controls.ts | 9 +- apps/desktop/src/daemon-api.ts | 16 + apps/desktop/src/local-removal.test.ts | 4 +- apps/desktop/src/local-removal.ts | 4 +- apps/desktop/src/source-status.test.ts | 23 +- apps/desktop/src/source-status.ts | 19 +- apps/desktop/src/styles.css | 66 +- apps/extension/firefox-identity.test.ts | 6 +- apps/extension/manifest.base.json | 2 +- apps/extension/package.json | 2 +- apps/extension/popup.html | 7 + apps/extension/src/popup.ts | 38 +- apps/extension/src/service-worker.ts | 77 ++- apps/extension/src/status.test.ts | 19 + apps/extension/src/status.ts | 65 +- apps/extension/vite.config.ts | 6 +- bins/daemon/Cargo.toml | 2 + bins/daemon/src/lib.rs | 291 ++++++-- bins/daemon/src/os_activity.rs | 311 +++++++-- bins/native-host/src/lib.rs | 8 +- bins/native-host/src/main.rs | 27 +- config/firefox-extension-identities.json | 2 +- crates/analytics/src/lib.rs | 3 + crates/client/src/bin/mindcanaryctl.rs | 190 +++++- crates/client/src/lib.rs | 36 + crates/protocol/src/lib.rs | 153 ++++- crates/storage/src/lib.rs | 636 ++++++++++++++++-- crates/test-support/src/lib.rs | 1 + docs/arch-install.md | 109 +++ docs/development.md | 58 +- docs/firefox-review.md | 60 ++ docs/known-limitations.md | 37 +- docs/roadmap.md | 20 +- fixtures/synthetic-browser.jsonl | 40 +- package.json | 4 +- packages/protocol-ts/package.json | 2 +- packages/protocol-ts/src/generated.ts | 44 ++ packaging/arch/PKGBUILD | 60 ++ packaging/arch/app.mindcanary.desktop.desktop | 10 + scripts/build-arch-package.sh | 34 + scripts/build-firefox-development-xpi.sh | 22 + scripts/build-firefox-release.sh | 42 ++ scripts/sign-firefox-release.sh | 38 ++ 55 files changed, 2810 insertions(+), 269 deletions(-) create mode 100644 docs/arch-install.md create mode 100644 docs/firefox-review.md create mode 100644 packaging/arch/PKGBUILD create mode 100644 packaging/arch/app.mindcanary.desktop.desktop create mode 100755 scripts/build-arch-package.sh create mode 100755 scripts/build-firefox-development-xpi.sh create mode 100755 scripts/build-firefox-release.sh create mode 100755 scripts/sign-firefox-release.sh diff --git a/.gitignore b/.gitignore index 0a9f4d0..771996a 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,9 @@ apps/desktop/src-tauri/binaries/ desktop-ui.md apps/desktop/public/reference-mark.png + +# Local Arch package artifacts +/packaging/arch/*.pkg.tar.zst +/packaging/arch/*.tar.gz +/packaging/arch/pkg/ +/packaging/arch/src/ diff --git a/Cargo.lock b/Cargo.lock index 9aacbf9..08851a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -448,6 +448,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "endi" version = "1.1.1" @@ -849,7 +855,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" dependencies = [ "cc", - "openssl-sys", "pkg-config", "vcpkg", ] @@ -893,7 +898,7 @@ dependencies = [ [[package]] name = "mindcanary-analytics" -version = "0.1.5" +version = "0.1.6" dependencies = [ "chrono", "mindcanary-protocol", @@ -904,7 +909,7 @@ dependencies = [ [[package]] name = "mindcanary-client" -version = "0.1.5" +version = "0.1.6" dependencies = [ "anyhow", "chrono", @@ -922,7 +927,7 @@ dependencies = [ [[package]] name = "mindcanary-native-host" -version = "0.1.5" +version = "0.1.6" dependencies = [ "anyhow", "chrono", @@ -940,7 +945,7 @@ dependencies = [ [[package]] name = "mindcanary-protocol" -version = "0.1.5" +version = "0.1.6" dependencies = [ "chrono", "chrono-tz", @@ -952,7 +957,7 @@ dependencies = [ [[package]] name = "mindcanary-storage" -version = "0.1.5" +version = "0.1.6" dependencies = [ "anyhow", "chrono", @@ -969,7 +974,7 @@ dependencies = [ [[package]] name = "mindcanary-test-support" -version = "0.1.5" +version = "0.1.6" dependencies = [ "anyhow", "chrono", @@ -982,7 +987,7 @@ dependencies = [ [[package]] name = "mindcanaryd" -version = "0.1.5" +version = "0.1.6" dependencies = [ "anyhow", "chrono", @@ -998,6 +1003,8 @@ dependencies = [ "tempfile", "tokio", "uuid", + "wayland-client", + "wayland-protocols", "zbus 5.5.0", ] @@ -1265,6 +1272,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.45" @@ -1829,6 +1845,63 @@ dependencies = [ "semver", ] +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "pkg-config", +] + [[package]] name = "windows-core" version = "0.62.2" diff --git a/Cargo.toml b/Cargo.toml index edec343..182b080 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ members = [ ] [workspace.package] -version = "0.1.5" +version = "0.1.6" edition = "2024" license = "AGPL-3.0-or-later" publish = false @@ -31,13 +31,15 @@ mindcanary-client = { path = "crates/client" } mindcanary-protocol = { path = "crates/protocol" } mindcanary-storage = { path = "crates/storage" } mindcanary-test-support = { path = "crates/test-support" } -rusqlite = { version = "0.39.0", default-features = false, features = ["bundled-sqlcipher-vendored-openssl"] } +rusqlite = { version = "0.39.0", default-features = false, features = ["bundled-sqlcipher"] } serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.140" tempfile = "3.20.0" thiserror = "2.0.12" tokio = { version = "1.45.1", features = ["io-util", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] } uuid = { version = "1.16.0", features = ["serde", "v7"] } +wayland-client = "0.31.14" +wayland-protocols = { version = "0.32.13", features = ["client", "staging"] } zeroize = { version = "1.8.1", features = ["zeroize_derive"] } zbus = { version = "=5.5.0", default-features = false, features = ["tokio"] } diff --git a/README.md b/README.md index 6559964..c760576 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ browser and operating-system connectors are optional. - daily history with explicit missing data; - descriptive comparisons against sustained windows of personal history; - optional aggregate browser signals such as tab switching and open-tab counts; -- optional GNOME/X11 active and idle time; +- optional GNOME/X11 or KDE Plasma/Wayland active and idle time; - encrypted local storage with the database key held by the OS keyring; - readable export, encrypted backup and restore, per-signal deletion, and complete app-owned local removal; @@ -39,9 +39,11 @@ creates and moves an export or backup. See the [privacy policy](docs/privacy-pol The packaged alpha currently targets Pop!_OS, Ubuntu, and similar Debian-based Linux systems. Download the `.deb` and its checksum from the latest GitHub prerelease, then follow the [Linux alpha install guide](docs/alpha-install.md). +For local Arch/CachyOS dogfooding, use the maintained +[Arch installation path](docs/arch-install.md). -The Chrome extension is optional and its ordinary-user store installation is -not part of this alpha. +Browser extensions are optional. Ordinary-user Chrome Web Store and Mozilla +Add-ons installation are not part of this alpha yet. ## Development diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 8f2d0ea..5c7321a 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@mindcanary/desktop", - "version": "0.1.5", + "version": "0.1.6", "license": "AGPL-3.0-or-later", "private": true, "type": "module", diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 742fd5a..3b99ea1 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -1854,7 +1854,7 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "mindcanary-client" -version = "0.1.5" +version = "0.1.6" dependencies = [ "anyhow", "clap", @@ -1867,7 +1867,7 @@ dependencies = [ [[package]] name = "mindcanary-desktop" -version = "0.1.5" +version = "0.1.6" dependencies = [ "anyhow", "mindcanary-client", @@ -1881,7 +1881,7 @@ dependencies = [ [[package]] name = "mindcanary-protocol" -version = "0.1.5" +version = "0.1.6" dependencies = [ "chrono", "chrono-tz", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 30e60dd..ded425e 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mindcanary-desktop" -version = "0.1.5" +version = "0.1.6" edition = "2024" license = "AGPL-3.0-or-later" publish = false diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index c117f79..00a8a86 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -114,6 +114,37 @@ pub async fn collection_settings() -> Result { .map_err(local_service_error) } +#[tauri::command] +pub async fn browser_connections() -> Result { + client() + .browser_connections() + .await + .map_err(local_service_error) +} + +#[tauri::command] +pub async fn set_browser_connection_label( + source_instance_id: uuid::Uuid, + label: String, +) -> Result { + client() + .set_browser_connection_label(source_instance_id, label) + .await + .map_err(local_service_error) +} + +#[tauri::command] +pub async fn set_browser_signal_collection( + source_instance_id: uuid::Uuid, + signal: SignalId, + enabled: bool, +) -> Result { + client() + .set_browser_signal_collection(source_instance_id, signal, enabled) + .await + .map_err(local_service_error) +} + #[tauri::command] pub async fn platform_capabilities() -> Result { client() diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 7e223fd..c7a83a8 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -17,6 +17,9 @@ pub fn run() { commands::daily_rhythm_insights, commands::daily_timeline, commands::collection_settings, + commands::browser_connections, + commands::set_browser_connection_label, + commands::set_browser_signal_collection, commands::platform_capabilities, commands::set_signal_collection, commands::prepare_delete_signal_records, diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index beb7c9f..6c9640e 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "mindcanary", - "version": "0.1.5", + "version": "0.1.6", "identifier": "app.mindcanary.desktop", "build": { "beforeDevCommand": "pnpm dev", @@ -10,10 +10,11 @@ "frontendDist": "../dist" }, "app": { + "enableGTKAppId": true, "windows": [ { "label": "main", - "title": "mindcanary 0.1.5", + "title": "mindcanary 0.1.6", "width": 1180, "height": 820, "minWidth": 860, diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index be5f717..4a4d10a 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -10,6 +10,7 @@ import { import { PROTOCOL_VERSION, type AnnotationRecord, + type BrowserConnection, type CheckInRecord, type ContextTag, type ProtocolResponse, @@ -46,7 +47,6 @@ import { type CheckInDraft, } from "./check-in"; import { - browserCollectionControls, enableBrowserStarterSet, enableOsActivityStarterSet, osActivityCollectionControls, @@ -56,6 +56,7 @@ import { toSignalDeletionConfirmation, toSignalDeletionResult, toSignalCollectionControls, + toSignalCollectionControlsFromSettings, type SignalDeletionConfirmationModel, type SignalCollectionControlModel, } from "./collection-controls"; @@ -143,6 +144,13 @@ export function App() { const [collection, setCollection] = useState( [], ); + const [browserProfiles, setBrowserProfiles] = useState( + [], + ); + const [selectedBrowserProfileId, setSelectedBrowserProfileId] = + useState(); + const [browserProfilesUnavailable, setBrowserProfilesUnavailable] = + useState(false); const [platform, setPlatform] = useState(); const [collectionUnavailable, setCollectionUnavailable] = useState(false); const [localServiceAutostart, setLocalServiceAutostart] = @@ -214,6 +222,7 @@ export function App() { timelineResponse, platformResponse, settingsResponse, + browserProfilesResponse, dataResponse, connectorResponse, autostartResponse, @@ -224,6 +233,7 @@ export function App() { daemonApi.timeline(), daemonApi.platformCapabilities(), daemonApi.collectionSettings(), + daemonApi.browserConnections(), daemonApi.localDataSummary(), daemonApi.chromeConnectorStatus(), daemonApi.localServiceAutostartStatus(), @@ -233,8 +243,12 @@ export function App() { health.status === "fulfilled" && health.value.type === "health"; setServiceState(serviceReady ? "ready" : "unavailable"); if (sourceStatusResponse.status === "fulfilled") { + const hasBrowserProfiles = + browserProfilesResponse.status === "fulfilled" && + browserProfilesResponse.value.type === "browser_connections" && + browserProfilesResponse.value.connections.length > 0; const connector = - connectorResponse.status === "fulfilled" + !hasBrowserProfiles && connectorResponse.status === "fulfilled" ? connectorResponse.value : undefined; setConnections( @@ -275,6 +289,22 @@ export function App() { } else { setCollectionUnavailable(true); } + if ( + browserProfilesResponse.status === "fulfilled" && + browserProfilesResponse.value.type === "browser_connections" + ) { + const nextProfiles = browserProfilesResponse.value.connections; + setBrowserProfiles(nextProfiles); + setSelectedBrowserProfileId((current) => + current !== undefined && + nextProfiles.some((profile) => profile.source_instance_id === current) + ? current + : nextProfiles[0]?.source_instance_id, + ); + setBrowserProfilesUnavailable(false); + } else { + setBrowserProfilesUnavailable(true); + } if (platformResponse.status === "fulfilled") { setPlatform(toPlatformCapabilityModel(platformResponse.value)); } else { @@ -374,6 +404,51 @@ export function App() { } } + function acceptBrowserProfiles(response: ProtocolResponse): void { + if (response.type !== "browser_connections") { + throw new TypeError("Unexpected browser connections response."); + } + setBrowserProfiles(response.connections); + } + + async function setBrowserSignal( + signal: SignalId, + enabled: boolean, + ): Promise { + if (selectedBrowserProfileId === undefined) return; + try { + acceptBrowserProfiles( + await daemonApi.setBrowserSignalCollection( + selectedBrowserProfileId, + signal, + enabled, + ), + ); + setNotice( + enabled + ? "Collection enabled for this browser profile." + : "Collection paused for this browser profile.", + ); + } catch { + setNotice("The local service could not update that browser profile."); + } + } + + async function renameBrowserProfile(label: string): Promise { + if (selectedBrowserProfileId === undefined) return; + try { + acceptBrowserProfiles( + await daemonApi.setBrowserConnectionLabel( + selectedBrowserProfileId, + label, + ), + ); + setNotice("Browser profile label saved locally."); + } catch { + setNotice("That browser profile label could not be saved."); + } + } + function openSupportDiagnostics(): void { if (appVersion === undefined) { setNotice("App version is not available yet."); @@ -410,6 +485,45 @@ export function App() { } } + async function enableBrowserProfileStarterSet(): Promise { + const selected = browserProfiles.find( + (profile) => profile.source_instance_id === selectedBrowserProfileId, + ); + if (selected === undefined) return; + let latestControls = toSignalCollectionControlsFromSettings( + selected.settings, + ); + const result = await enableBrowserStarterSet( + latestControls, + async (signal) => { + const response = await daemonApi.setBrowserSignalCollection( + selected.source_instance_id, + signal, + true, + ); + acceptBrowserProfiles(response); + if (response.type === "browser_connections") { + const updated = response.connections.find( + (profile) => + profile.source_instance_id === selected.source_instance_id, + ); + if (updated !== undefined) { + latestControls = toSignalCollectionControlsFromSettings( + updated.settings, + ); + } + } + }, + ); + setNotice( + result.failedSignals.length === 0 + ? result.attemptedCount === 0 + ? "This browser profile already has the starter set enabled." + : "Starter set enabled for this browser profile." + : `${result.enabledCount} browser profile signals enabled; review the remaining controls.`, + ); + } + async function enableOsActivitySet(): Promise { let latestControls = collection; const result = await enableOsActivityStarterSet( @@ -725,11 +839,18 @@ export function App() { serviceState, }); const browserConnection = browserConnectionItem(connections); - const chromeActionLabel = + const selectedBrowserProfile = browserProfiles.find( + (profile) => profile.source_instance_id === selectedBrowserProfileId, + ); + const selectedBrowserControls = + selectedBrowserProfile === undefined + ? [] + : toSignalCollectionControlsFromSettings(selectedBrowserProfile.settings); + const browserActionLabel = browserConnection?.action?.type === "connect_chrome" - ? "Connect Chrome context" - : "Enable Chrome context"; - const chromeActionDetail = + ? "Connect browser context" + : "Enable browser context"; + const browserActionDetail = browserConnection?.action?.type === "setup_command" ? "Development builds may still need the setup command shown in Sources." : "Uses the starter browser aggregates. No URLs, titles, page text, or history."; @@ -776,13 +897,17 @@ export function App() { {onboarding.show ? ( { + if (selectedBrowserProfile !== undefined) { + await enableBrowserProfileStarterSet(); + return; + } if (browserConnection?.action?.type === "connect_chrome") { await connectChrome(); } @@ -878,11 +1003,22 @@ export function App() { onConnectChrome={connectChrome} connectorConnecting={connectorConnecting} /> + @@ -1223,8 +1359,8 @@ function OnboardingFlow({

Optional browser context

Want browser context?

- mindcanary works with check-ins alone. Chrome adds browsing rhythm - summaries - never the pages you visit. + mindcanary works with check-ins alone. A browser extension adds + browsing rhythm summaries - never the pages you visit.

@@ -1514,6 +1650,119 @@ function ConnectionRow({ ); } +function BrowserProfilesPanel({ + profiles, + selectedId, + unavailable, + onRename, + onSelect, +}: { + profiles: BrowserConnection[]; + selectedId?: string; + unavailable: boolean; + onRename: (label: string) => Promise; + onSelect: (sourceInstanceId: string) => void; +}) { + const selected = profiles.find( + (profile) => profile.source_instance_id === selectedId, + ); + const [label, setLabel] = useState(selected?.label ?? ""); + const [saving, setSaving] = useState(false); + + useEffect(() => setLabel(selected?.label ?? ""), [selected?.label]); + + async function saveLabel(event: FormEvent): Promise { + event.preventDefault(); + const trimmed = label.trim(); + if (trimmed.length === 0 || trimmed === selected?.label) return; + setSaving(true); + await onRename(trimmed); + setSaving(false); + } + + return ( + + {unavailable ? ( + + ) : profiles.length === 0 ? ( + + ) : ( + <> +
+ {profiles.map((profile) => { + const selected = profile.source_instance_id === selectedId; + const browser = + profile.browser === "firefox" ? "Firefox" : "Chrome"; + return ( + + ); + })} +
+ {selected !== undefined && ( +
+ +
+ setLabel(event.target.value)} + value={label} + /> + +
+ + To add another one, open the extension in that browser profile. + It gets independent permissions here. + +
+ )} + + )} +
+ ); +} + +function enabledBrowserSignalCount(profile: BrowserConnection): number { + return profile.settings.filter((setting) => setting.enabled).length; +} + function ServiceStartupPanel({ status, updating, @@ -1670,7 +1919,7 @@ function TimelinePanel({ /> `${formatCompact(value)} tabs`} source={BROWSER_TIMELINE_SOURCE} title="Tabs retained across days" @@ -2442,15 +2691,23 @@ function ReadinessDetails({ items }: { items: ReadinessItemModel[] }) { function CollectionPanel({ controls, + browserConnection, + browserControls, + browserUnavailable, unavailable, onChange, + onBrowserChange, onEnableStarterSet, onEnableOsActivitySet, onPrepareDelete, }: { controls: SignalCollectionControlModel[]; + browserConnection?: BrowserConnection; + browserControls: SignalCollectionControlModel[]; + browserUnavailable: boolean; unavailable: boolean; onChange: (signal: SignalId, enabled: boolean) => Promise; + onBrowserChange: (signal: SignalId, enabled: boolean) => Promise; onEnableStarterSet: () => Promise; onEnableOsActivitySet: () => Promise; onPrepareDelete: (signal: SignalId) => Promise; @@ -2459,7 +2716,6 @@ function CollectionPanel({ const [reviewing, setReviewing] = useState(); const [starterPending, setStarterPending] = useState(false); const [osStarterPending, setOsStarterPending] = useState(false); - const browserControls = browserCollectionControls(controls); const osActivityControls = osActivityCollectionControls(controls); const osControls = osCollectionControls(controls); const starter = toBrowserStarterSetModel(browserControls); @@ -2471,6 +2727,14 @@ function CollectionPanel({ setPending(undefined); } + async function toggleBrowser( + control: SignalCollectionControlModel, + ): Promise { + setPending(control.signal); + await onBrowserChange(control.signal, !control.enabled); + setPending(undefined); + } + async function reviewDeletion( control: SignalCollectionControlModel, ): Promise { @@ -2545,16 +2809,23 @@ function CollectionPanel({ id="browser-signals-panel" compact eyebrow="Collection" - title="Browser signals" - description="Each signal is controlled individually. No URLs, titles, or page text." + title={ + browserConnection === undefined + ? "Browser signals" + : `${browserConnection.label} signals` + } + description="Settings apply only to the selected browser profile. No URLs, titles, or page text." > - {unavailable ? ( + {unavailable || browserUnavailable ? ( - ) : browserControls.length === 0 ? ( - + ) : browserConnection === undefined ? ( + ) : ( <>
@@ -2563,8 +2834,8 @@ function CollectionPanel({ {starter.labels.join(", ")}. {starter.statusText}. Browser aggregates appear after a - 15-minute period closes. Active and idle time may require - Chrome's optional idle permission. + 15-minute period closes. Active and idle time may require the + browser's optional idle permission.
+ diff --git a/apps/extension/src/popup.ts b/apps/extension/src/popup.ts index 01d230f..9c77236 100644 --- a/apps/extension/src/popup.ts +++ b/apps/extension/src/popup.ts @@ -26,12 +26,16 @@ const elements = { bucketProgress: requiredElement("bucket-progress") as HTMLProgressElement, bucketProgressText: requiredElement("bucket-progress-text"), settings: requiredElement("settings"), + dataCollectionPermission: requiredElement("data-collection-permission"), delivery: requiredElement("delivery"), idlePermission: requiredElement("idle-permission"), scrollPermission: requiredElement("scroll-permission"), setup: requiredElement("setup"), setupCommand: requiredElement("setup-command"), requestIdle: requiredElement("request-idle") as HTMLButtonElement, + requestDataCollection: requiredElement( + "request-data-collection", + ) as HTMLButtonElement, requestScrolling: requiredElement("request-scrolling") as HTMLButtonElement, resetQueueCard: requiredElement("queue-reset-card"), resetQueue: requiredElement("reset-queue") as HTMLButtonElement, @@ -49,6 +53,10 @@ elements.requestIdle.addEventListener("click", () => { void requestIdlePermission(); }); +elements.requestDataCollection.addEventListener("click", () => { + void requestDataCollectionPermission(); +}); + elements.requestScrolling.addEventListener("click", () => { void requestScrollingPermission(); }); @@ -77,10 +85,14 @@ async function renderStatus(type: PopupRequest["type"]): Promise { elements.bucketProgress.hidden = model.bucketProgressPercent === null; elements.bucketProgress.value = model.bucketProgressPercent ?? 0; elements.settings.textContent = model.settingsText; + elements.dataCollectionPermission.textContent = + model.dataCollectionPermissionText; elements.delivery.textContent = model.deliveryText; elements.idlePermission.textContent = model.idlePermissionText; elements.scrollPermission.textContent = model.scrollPermissionText; elements.requestIdle.hidden = !model.showIdlePermissionRequest; + elements.requestDataCollection.hidden = + !model.showDataCollectionPermissionRequest; elements.requestScrolling.hidden = !model.showScrollPermissionRequest; elements.resetQueueCard.hidden = !model.showQueueReset; elements.resetQueue.hidden = !model.showQueueReset; @@ -101,10 +113,12 @@ async function renderStatus(type: PopupRequest["type"]): Promise { elements.bucketProgress.hidden = true; elements.bucketProgress.value = 0; elements.settings.textContent = "-"; + elements.dataCollectionPermission.textContent = "-"; elements.delivery.textContent = "-"; elements.idlePermission.textContent = "-"; elements.scrollPermission.textContent = "-"; elements.requestIdle.hidden = true; + elements.requestDataCollection.hidden = true; elements.requestScrolling.hidden = true; elements.resetQueueCard.hidden = true; elements.resetQueue.hidden = true; @@ -119,10 +133,14 @@ async function renderStatus(type: PopupRequest["type"]): Promise { async function requestScrollingPermission(): Promise { elements.requestScrolling.disabled = true; try { - const granted = await extensionApi.permissions.request({ + const request = { permissions: ["scripting"], origins: ["https://x.com/*", "https://twitter.com/*"], - }); + ...(__MINDCANARY_BROWSER_TARGET__ === "firefox" + ? { data_collection: ["websiteActivity"] } + : {}), + } as FirefoxPermissionRequest; + const granted = await extensionApi.permissions.request(request); await renderStatus( granted ? "mindcanary.enable_scrolling" : "mindcanary.refresh_status", ); @@ -131,6 +149,18 @@ async function requestScrollingPermission(): Promise { } } +async function requestDataCollectionPermission(): Promise { + elements.requestDataCollection.disabled = true; + try { + await extensionApi.permissions.request({ + data_collection: ["technicalAndInteraction"], + } as FirefoxPermissionRequest); + } finally { + await renderStatus("mindcanary.refresh_status"); + elements.requestDataCollection.disabled = false; + } +} + async function requestIdlePermission(): Promise { elements.requestIdle.disabled = true; try { @@ -172,3 +202,7 @@ function renderSetupCommand(command: string | null): void { elements.setup.hidden = false; elements.setupCommand.textContent = command; } + +type FirefoxPermissionRequest = chrome.permissions.Permissions & { + data_collection?: Array<"technicalAndInteraction" | "websiteActivity">; +}; diff --git a/apps/extension/src/service-worker.ts b/apps/extension/src/service-worker.ts index aefa13c..ddda95c 100644 --- a/apps/extension/src/service-worker.ts +++ b/apps/extension/src/service-worker.ts @@ -54,6 +54,12 @@ const DELIVERY_STATUS_KEY = "deliveryStatus"; const SCROLL_CONTEXT_STATE_KEY = "scrollContextState"; const SCROLL_CONTENT_SCRIPT_ID = "mindcanary-continuous-scrolling"; const SCROLL_ORIGINS = ["https://x.com/*", "https://twitter.com/*"]; +type FirefoxDataCollectionPermission = + | "technicalAndInteraction" + | "websiteActivity"; +type FirefoxPermissionSnapshot = chrome.permissions.Permissions & { + data_collection?: FirefoxDataCollectionPermission[]; +}; interface CollectorState { sourceInstanceId: string; @@ -104,6 +110,14 @@ extensionApi.runtime.onStartup.addListener(() => { }); }); +extensionApi.permissions.onAdded.addListener(() => { + enqueue(refreshAfterPermissionChange); +}); + +extensionApi.permissions.onRemoved.addListener(() => { + enqueue(refreshAfterPermissionChange); +}); + extensionApi.tabs.onCreated.addListener((tab) => { if (tab.id !== undefined) { enqueueEvent({ type: "tab_created", atMs: Date.now(), tabId: tab.id }); @@ -497,9 +511,15 @@ function trimPendingBatches(collector: CollectorState): void { } async function refreshCollectionSettings(): Promise> { + if (!(await dataCollectionPermissionGranted("technicalAndInteraction"))) { + await storeSettingsStatus("permission_required"); + return storeEnabledSignals([]); + } + const collector = await loadCollectorState(); const request: ProtocolRequest = { type: "get_collection_settings", protocol_version: PROTOCOL_VERSION, + connector: browserConnectionIdentity(collector.sourceInstanceId), }; let response: ProtocolResponse; @@ -523,9 +543,16 @@ async function refreshCollectionSettings(): Promise> { } async function loadEnabledSignals(): Promise> { + if (!(await dataCollectionPermissionGranted("technicalAndInteraction"))) { + return new Set(); + } const result = await extensionApi.storage.session.get(ENABLED_SIGNALS_KEY); const enabled = result[ENABLED_SIGNALS_KEY] as SignalId[] | undefined; - return new Set(enabled ?? []); + const signals = new Set(enabled ?? []); + if (!(await dataCollectionPermissionGranted("websiteActivity"))) { + signals.delete(CONTINUOUS_SCROLLING_SIGNAL); + } + return signals; } async function storeEnabledSignals( @@ -577,10 +604,14 @@ async function reconcileScrollAdapter( } async function scrollingPermissionsGranted(): Promise { - return extensionApi.permissions.contains({ - permissions: ["scripting"], - origins: SCROLL_ORIGINS, - }); + const [browserPermissions, dataCollectionPermission] = await Promise.all([ + extensionApi.permissions.contains({ + permissions: ["scripting"], + origins: SCROLL_ORIGINS, + }), + dataCollectionPermissionGranted("websiteActivity"), + ]); + return browserPermissions && dataCollectionPermission; } async function loadScrollContextState( @@ -655,6 +686,9 @@ async function queueScrollBuckets( } async function deliverPendingBatches(): Promise { + if (!(await dataCollectionPermissionGranted("technicalAndInteraction"))) { + return; + } const collector = await loadCollectorState(); let delivered = 0; let deliveryStatus: DeliveryStatus["state"] = "ok"; @@ -663,6 +697,7 @@ async function deliverPendingBatches(): Promise { const request: ProtocolRequest = { type: "ingest_aggregate", protocol_version: PROTOCOL_VERSION, + connector: browserConnectionIdentity(collector.sourceInstanceId), batch, }; @@ -714,6 +749,13 @@ async function loadCollectorState(): Promise { return created; } +function browserConnectionIdentity(sourceInstanceId: string) { + return { + source_instance_id: sourceInstanceId, + browser: __MINDCANARY_BROWSER_TARGET__, + } as const; +} + async function saveCollectorState(state: CollectorState): Promise { await extensionApi.storage.local.set({ [COLLECTOR_STATE_KEY]: state }); } @@ -730,6 +772,7 @@ async function collectorStatus(): Promise { collector, enabledSignals, session, + technicalDataCollectionGranted, idlePermissionGranted, scrollPermissionGranted, ] = await Promise.all([ @@ -740,6 +783,7 @@ async function collectorStatus(): Promise { SETTINGS_STATUS_KEY, DELIVERY_STATUS_KEY, ]), + dataCollectionPermissionGranted("technicalAndInteraction"), idlePermissionIsGranted(), scrollingPermissionsGranted(), ]); @@ -755,6 +799,7 @@ async function collectorStatus(): Promise { extensionApi.runtime.id === __MINDCANARY_EXPECTED_EXTENSION_ID__, nativeHostName: NATIVE_HOST, enabledSignals: [...enabledSignals], + dataCollectionPermissionGranted: technicalDataCollectionGranted, idlePermissionGranted, scrollPermissionGranted, pendingBatchCount: collector.pendingBatches.length, @@ -788,6 +833,28 @@ async function idlePermissionIsGranted(): Promise { return extensionApi.permissions.contains({ permissions: ["idle"] }); } +async function dataCollectionPermissionGranted( + permission: FirefoxDataCollectionPermission, +): Promise { + if (__MINDCANARY_BROWSER_TARGET__ !== "firefox") { + return true; + } + const permissions = + (await extensionApi.permissions.getAll()) as FirefoxPermissionSnapshot; + return permissions.data_collection?.includes(permission) ?? false; +} + +async function refreshAfterPermissionChange(): Promise { + const enabledSignals = await refreshCollectionSettings(); + if (enabledSignals.size > 0) { + await reconcileSnapshot(enabledSignals); + } else { + await extensionApi.storage.session.remove(REDUCER_STATE_KEY); + await extensionApi.storage.local.remove(TAB_RETENTION_STATE_KEY); + } + await deliverPendingBatches(); +} + async function currentActivityState(): Promise { if (!(await idlePermissionIsGranted())) { return null; diff --git a/apps/extension/src/status.test.ts b/apps/extension/src/status.test.ts index c06d159..641b904 100644 --- a/apps/extension/src/status.test.ts +++ b/apps/extension/src/status.test.ts @@ -11,6 +11,7 @@ function status(overrides: Partial = {}): CollectorStatus { identityMatches: true, nativeHostName: "app.mindcanary.collector", enabledSignals: [], + dataCollectionPermissionGranted: true, idlePermissionGranted: false, scrollPermissionGranted: false, pendingBatchCount: 0, @@ -73,6 +74,24 @@ describe("collector status view model", () => { expect(model.setupCommand).toBeNull(); }); + it("keeps Firefox collection paused until built-in local data consent is granted", () => { + const model = toCollectorStatusViewModel( + status({ + browserTarget: "firefox", + dataCollectionPermissionGranted: false, + settingsStatus: { + state: "permission_required", + checkedAt: "2026-06-14T12:00:00Z", + }, + }), + ); + + expect(model.state).toBe("disabled"); + expect(model.headline).toContain("permission needed"); + expect(model.showDataCollectionPermissionRequest).toBe(true); + expect(model.nextActionText).toContain("Allow local aggregates"); + }); + it("asks for idle permission only when active or idle aggregates need it", () => { const model = toCollectorStatusViewModel( status({ diff --git a/apps/extension/src/status.ts b/apps/extension/src/status.ts index 01846ed..b06a1a3 100644 --- a/apps/extension/src/status.ts +++ b/apps/extension/src/status.ts @@ -1,6 +1,10 @@ import type { SignalId } from "@mindcanary/protocol"; -export type SettingsStatusState = "ok" | "unavailable" | "unexpected_response"; +export type SettingsStatusState = + | "ok" + | "permission_required" + | "unavailable" + | "unexpected_response"; export type DeliveryStatusState = "ok" | "unavailable" | "unexpected_response"; export interface SettingsStatus { @@ -28,6 +32,7 @@ export interface CollectorStatus { identityMatches: boolean; nativeHostName: string; enabledSignals: SignalId[]; + dataCollectionPermissionGranted: boolean; idlePermissionGranted: boolean; scrollPermissionGranted: boolean; pendingBatchCount: number; @@ -51,6 +56,8 @@ export interface CollectorStatusViewModel { bucketProgressText: string; deliveryText: string; settingsText: string; + dataCollectionPermissionText: string; + showDataCollectionPermissionRequest: boolean; idlePermissionText: string; showIdlePermissionRequest: boolean; scrollPermissionText: string; @@ -82,6 +89,15 @@ export function toCollectorStatusViewModel( const bucketProgressText = activeBucketStatusText(status.activeBucket); const settingsText = settingsStatusText(status.settingsStatus); const deliveryText = deliveryStatusText(status.deliveryStatus); + const showDataCollectionPermissionRequest = + status.browserTarget === "firefox" && + !status.dataCollectionPermissionGranted; + const dataCollectionPermissionText = + status.browserTarget !== "firefox" + ? "Firefox data consent is not applicable" + : status.dataCollectionPermissionGranted + ? "Local aggregate collection allowed" + : "Firefox permission for local technical and interaction aggregates is needed"; const needsIdlePermission = requiresIdlePermission(status.enabledSignals); const idlePermissionText = idlePermissionStatusText( needsIdlePermission, @@ -104,6 +120,7 @@ export function toCollectorStatusViewModel( const queueResetText = `Clear only the extension's unsent aggregate queue. Delivered local records and ${browserName} extension installation are unchanged.`; const nextActionText = nextActionStatusText( status, + showDataCollectionPermissionRequest, showIdlePermissionRequest, showScrollPermissionRequest, ); @@ -125,6 +142,36 @@ export function toCollectorStatusViewModel( bucketProgressText, deliveryText, settingsText, + dataCollectionPermissionText, + showDataCollectionPermissionRequest, + idlePermissionText, + showIdlePermissionRequest, + scrollPermissionText, + showScrollPermissionRequest, + showQueueReset, + queueResetText, + extensionIdText, + nativeHostText, + setupCommand: null, + }; + } + + if (showDataCollectionPermissionRequest) { + return { + state: "disabled", + headline: "Local aggregate permission needed", + detail: + "Firefox has not authorized technical and interaction aggregates to leave the extension for the local MindCanary service.", + nextActionText, + enabledSignalText, + pendingBatchText, + reducerText, + bucketProgressPercent, + bucketProgressText, + deliveryText, + settingsText, + dataCollectionPermissionText, + showDataCollectionPermissionRequest, idlePermissionText, showIdlePermissionRequest, scrollPermissionText, @@ -150,6 +197,8 @@ export function toCollectorStatusViewModel( bucketProgressText, deliveryText, settingsText, + dataCollectionPermissionText, + showDataCollectionPermissionRequest, idlePermissionText, showIdlePermissionRequest, scrollPermissionText, @@ -175,6 +224,8 @@ export function toCollectorStatusViewModel( bucketProgressText, deliveryText, settingsText, + dataCollectionPermissionText, + showDataCollectionPermissionRequest, idlePermissionText, showIdlePermissionRequest, scrollPermissionText, @@ -201,6 +252,8 @@ export function toCollectorStatusViewModel( bucketProgressText, deliveryText, settingsText, + dataCollectionPermissionText, + showDataCollectionPermissionRequest, idlePermissionText, showIdlePermissionRequest, scrollPermissionText, @@ -227,6 +280,8 @@ export function toCollectorStatusViewModel( bucketProgressText, deliveryText, settingsText, + dataCollectionPermissionText, + showDataCollectionPermissionRequest, idlePermissionText, showIdlePermissionRequest, scrollPermissionText, @@ -252,6 +307,8 @@ export function toCollectorStatusViewModel( bucketProgressText, deliveryText, settingsText, + dataCollectionPermissionText, + showDataCollectionPermissionRequest, idlePermissionText, showIdlePermissionRequest, scrollPermissionText, @@ -290,9 +347,13 @@ function queueStatusText(queued: number, dropped: number): string { function nextActionStatusText( status: CollectorStatus, + showDataCollectionPermissionRequest: boolean, showIdlePermissionRequest: boolean, showScrollPermissionRequest: boolean, ): string { + if (showDataCollectionPermissionRequest) { + return "Click Allow local aggregates to grant Firefox's built-in data consent."; + } if (status.settingsStatus?.state === "unavailable") { return "Start the local service, run the native-host setup command below, reload the extension, then refresh."; } @@ -333,6 +394,8 @@ function settingsStatusText(status: SettingsStatus | undefined): string { switch (status.state) { case "ok": return `Settings loaded at ${checkedAt}`; + case "permission_required": + return `Settings waiting for Firefox data consent at ${checkedAt}`; case "unavailable": return `Settings unavailable at ${checkedAt}`; case "unexpected_response": diff --git a/apps/extension/vite.config.ts b/apps/extension/vite.config.ts index 7d95609..266a607 100644 --- a/apps/extension/vite.config.ts +++ b/apps/extension/vite.config.ts @@ -109,7 +109,11 @@ function firefoxManifest( browser_specific_settings: { gecko: { id: extensionId, - strict_min_version: "121.0", + strict_min_version: "142.0", + data_collection_permissions: { + required: ["none"], + optional: ["technicalAndInteraction", "websiteActivity"], + }, }, }, }; diff --git a/bins/daemon/Cargo.toml b/bins/daemon/Cargo.toml index f93fcdd..3c8a3ba 100644 --- a/bins/daemon/Cargo.toml +++ b/bins/daemon/Cargo.toml @@ -19,6 +19,8 @@ mindcanary-storage.workspace = true serde_json.workspace = true tokio.workspace = true uuid.workspace = true +wayland-client.workspace = true +wayland-protocols.workspace = true zbus.workspace = true [dev-dependencies] diff --git a/bins/daemon/src/lib.rs b/bins/daemon/src/lib.rs index ab4289d..82ecba7 100644 --- a/bins/daemon/src/lib.rs +++ b/bins/daemon/src/lib.rs @@ -16,15 +16,16 @@ use mindcanary_analytics::{ analyze_insights, combine_daily_features, }; use mindcanary_protocol::{ - AggregateBatch, AnnotationRecord, CLEAR_LOCAL_RECORDS_CONFIRMATION_MINUTES, - DEFAULT_DAILY_RHYTHM_INSIGHT_LIMIT, DEFAULT_DAILY_TIMELINE_LIMIT, DailyBrowserTimeline, - DailyCheckInTimeline, DailyOsTimeline, DailyRhythmSummary, DailyTimelineDay, - DailyTimelineSummary, DesktopEnvironment, ErrorCode, LocalBackup, LocalBackupMetadata, - LocalDataExport, LocalDataSummary, MAX_FRAME_BYTES, OperatingSystem, PROTOCOL_VERSION, - PlatformCapabilities, PlatformCapability, PlatformCapabilityId, PlatformCapabilityStatus, - ProtocolRequest, ProtocolResponse, RhythmChangeDirection, RhythmDimensionReadiness, - RhythmEvidence, RhythmInsight, RhythmInsightDimension, RhythmReadinessStatus, ServiceStatus, - SessionType, SignalId, SourceHealth, SourceStatus, SourceType, ValidationError, + AggregateBatch, AnnotationRecord, BrowserConnectionIdentity, + CLEAR_LOCAL_RECORDS_CONFIRMATION_MINUTES, DEFAULT_DAILY_RHYTHM_INSIGHT_LIMIT, + DEFAULT_DAILY_TIMELINE_LIMIT, DailyBrowserTimeline, DailyCheckInTimeline, DailyOsTimeline, + DailyRhythmSummary, DailyTimelineDay, DailyTimelineSummary, DesktopEnvironment, ErrorCode, + LocalBackup, LocalBackupMetadata, LocalDataExport, LocalDataSummary, MAX_FRAME_BYTES, + OperatingSystem, PROTOCOL_VERSION, PlatformCapabilities, PlatformCapability, + PlatformCapabilityId, PlatformCapabilityStatus, ProtocolRequest, ProtocolResponse, + RhythmChangeDirection, RhythmDimensionReadiness, RhythmEvidence, RhythmInsight, + RhythmInsightDimension, RhythmReadinessStatus, ServiceStatus, SessionType, SignalId, + SourceHealth, SourceStatus, SourceType, ValidationError, }; use mindcanary_storage::{ DailyBrowserFeatures, DailyCheckInFeatures, DailyOsFeatures, EncryptedStore, @@ -115,13 +116,11 @@ impl DaemonState { } match request { - ProtocolRequest::Health { .. } => ProtocolResponse::Health { - protocol_version: PROTOCOL_VERSION, - service_version: env!("CARGO_PKG_VERSION").to_owned(), - status: ServiceStatus::Ready, - }, + ProtocolRequest::Health { .. } => health_response(), ProtocolRequest::GetSourceStatus { .. } => self.source_status(now), - ProtocolRequest::IngestAggregate { batch, .. } => self.ingest_aggregate(&batch, now), + ProtocolRequest::IngestAggregate { + connector, batch, .. + } => self.ingest_aggregate(connector, &batch, now), ProtocolRequest::SubmitCheckIn { check_in, .. } => self.submit_check_in(&check_in, now), ProtocolRequest::PrepareDeleteLatestCheckIn { local_date, .. } => { self.prepare_delete_latest_check_in(&local_date, now) @@ -144,7 +143,21 @@ impl DaemonState { self.daily_rhythm_insights(limit, now) } ProtocolRequest::GetDailyTimeline { limit, .. } => self.daily_timeline(limit, now), - ProtocolRequest::GetCollectionSettings { .. } => self.collection_settings(now), + ProtocolRequest::GetCollectionSettings { connector, .. } => { + self.collection_settings(connector, now) + } + ProtocolRequest::GetBrowserConnections { .. } => self.browser_connections(now), + ProtocolRequest::SetBrowserConnectionLabel { + source_instance_id, + label, + .. + } => self.set_browser_connection_label(source_instance_id, &label, now), + ProtocolRequest::SetBrowserSignalCollection { + source_instance_id, + signal, + enabled, + .. + } => self.set_browser_signal_collection(source_instance_id, signal, enabled, now), ProtocolRequest::GetPlatformCapabilities { .. } => { ProtocolResponse::PlatformCapabilities { protocol_version: PROTOCOL_VERSION, @@ -592,11 +605,24 @@ impl DaemonState { daily_timeline(&store, limit, now).unwrap_or_else(|error| storage_error_response(&error)) } - fn collection_settings(&self, now: DateTime) -> ProtocolResponse { - let Ok(store) = self.store.lock() else { + fn collection_settings( + &self, + connector: Option, + now: DateTime, + ) -> ProtocolResponse { + let Ok(mut store) = self.store.lock() else { return internal_error_response(); }; - match store.collection_settings(now) { + if let Some(connector) = connector { + if let Err(error) = store.register_browser_connection(connector, now) { + return storage_error_response(&error); + } + } + let settings = connector.map_or_else( + || store.collection_settings(now), + |connector| store.browser_collection_settings(connector.source_instance_id, now), + ); + match settings { Ok(settings) => ProtocolResponse::CollectionSettings { protocol_version: PROTOCOL_VERSION, settings, @@ -605,6 +631,67 @@ impl DaemonState { } } + fn browser_connections(&self, now: DateTime) -> ProtocolResponse { + let Ok(store) = self.store.lock() else { + return internal_error_response(); + }; + match store.browser_connections(now) { + Ok(connections) => ProtocolResponse::BrowserConnections { + protocol_version: PROTOCOL_VERSION, + generated_at: now, + connections, + }, + Err(error) => storage_error_response(&error), + } + } + + fn set_browser_connection_label( + &self, + source_instance_id: uuid::Uuid, + label: &str, + now: DateTime, + ) -> ProtocolResponse { + let Ok(mut store) = self.store.lock() else { + return internal_error_response(); + }; + if let Err(error) = store.set_browser_connection_label(source_instance_id, label) { + return storage_error_response(&error); + } + match store.browser_connections(now) { + Ok(connections) => ProtocolResponse::BrowserConnections { + protocol_version: PROTOCOL_VERSION, + generated_at: now, + connections, + }, + Err(error) => storage_error_response(&error), + } + } + + fn set_browser_signal_collection( + &self, + source_instance_id: uuid::Uuid, + signal: SignalId, + enabled: bool, + now: DateTime, + ) -> ProtocolResponse { + let Ok(mut store) = self.store.lock() else { + return internal_error_response(); + }; + if let Err(error) = + store.set_browser_signal_collection(source_instance_id, signal, enabled, now) + { + return storage_error_response(&error); + } + match store.browser_connections(now) { + Ok(connections) => ProtocolResponse::BrowserConnections { + protocol_version: PROTOCOL_VERSION, + generated_at: now, + connections, + }, + Err(error) => storage_error_response(&error), + } + } + fn source_status(&self, now: DateTime) -> ProtocolResponse { let capabilities = self.platform_capabilities(); let Ok(store) = self.store.lock() else { @@ -613,11 +700,21 @@ impl DaemonState { let statuses = match ( store.source_activity_timestamps(), store.collection_settings(now), + store.browser_connections(now), ) { - (Ok(activity), Ok(settings)) => { - source_statuses(activity, &settings, &capabilities, now) + (Ok(activity), Ok(settings), Ok(browser_connections)) => { + let browser_enabled = if browser_connections.is_empty() { + source_has_enabled_signals(&settings, SourceType::Browser) + } else { + browser_connections.iter().any(|connection| { + source_has_enabled_signals(&connection.settings, SourceType::Browser) + }) + }; + source_statuses(activity, browser_enabled, &settings, &capabilities, now) + } + (Err(error), _, _) | (_, Err(error), _) | (_, _, Err(error)) => { + return storage_error_response(&error); } - (Err(error), _) | (_, Err(error)) => return storage_error_response(&error), }; ProtocolResponse::SourceStatus { @@ -661,19 +758,42 @@ impl DaemonState { } } - fn ingest_aggregate(&self, batch: &AggregateBatch, now: DateTime) -> ProtocolResponse { + fn ingest_aggregate( + &self, + connector: Option, + batch: &AggregateBatch, + now: DateTime, + ) -> ProtocolResponse { let Ok(mut store) = self.store.lock() else { return internal_error_response(); }; + if let Some(connector) = connector { + if let Err(error) = store.register_browser_connection(connector, now) { + return storage_error_response(&error); + } + } let mut accepted_batch = batch.clone(); accepted_batch.metrics.clear(); for metric in &batch.metrics { - match store.signal_enabled_for_period( - metric.signal, - batch.period.start, - batch.period.end, - ) { + let enabled = connector.map_or_else( + || { + store.signal_enabled_for_period( + metric.signal, + batch.period.start, + batch.period.end, + ) + }, + |connector| { + store.browser_signal_enabled_for_period( + connector.source_instance_id, + metric.signal, + batch.period.start, + batch.period.end, + ) + }, + ); + match enabled { Ok(true) => accepted_batch.metrics.push(metric.clone()), Ok(false) => {} Err(error) => return storage_error_response(&error), @@ -855,11 +975,11 @@ fn random_confirmation_token() -> Result { fn source_statuses( activity: mindcanary_storage::SourceActivityTimestamps, + browser_enabled: bool, settings: &[mindcanary_protocol::SignalCollectionSetting], capabilities: &PlatformCapabilities, now: DateTime, ) -> Vec { - let browser_enabled = source_has_enabled_signals(settings, SourceType::Browser); let os_enabled = source_has_enabled_signals(settings, SourceType::Os); let os_available = capabilities.capabilities.iter().any(|capability| { capability.capability == PlatformCapabilityId::OsActiveIdleDuration @@ -978,7 +1098,7 @@ fn platform_capabilities_with_status( PlatformCapabilityStatus::Unavailable }, detail: if environment_status.0 == PlatformCapabilityStatus::Planned { - "GNOME/X11 environment detected; foreground categories still require a separate opt-in adapter." + "A supported local activity environment was detected; foreground categories still require a separate opt-in adapter." } else { environment_status.1 } @@ -999,19 +1119,19 @@ fn os_lifecycle_capability_status( match (runtime_status.lock_events, runtime_status.sleep_events) { (true, true) => ( PlatformCapabilityStatus::Available, - "GNOME lock events and Linux suspend/resume events are available; each signal remains off until explicitly enabled.", + "Desktop lock events and Linux suspend/resume events are available; each signal remains off until explicitly enabled.", ), (true, false) => ( PlatformCapabilityStatus::Planned, - "GNOME lock events are available, but Linux suspend/resume events are unavailable right now.", + "Desktop lock events are available, but Linux suspend/resume events are unavailable right now.", ), (false, true) => ( PlatformCapabilityStatus::Planned, - "Linux suspend/resume events are available, but GNOME lock events are unavailable right now.", + "Linux suspend/resume events are available, but desktop lock events are unavailable right now.", ), (false, false) => ( PlatformCapabilityStatus::Planned, - "GNOME/X11 environment detected; lifecycle event adapters have not connected in this process.", + "The supported desktop environment was detected, but lifecycle event adapters have not connected in this process.", ), } } @@ -1031,9 +1151,13 @@ fn os_session_capability_status( PlatformCapabilityStatus::Planned, "GNOME/X11 environment detected; lock and session events still require a separate adapter.", ), + (DesktopEnvironment::Kde, SessionType::Wayland) => ( + PlatformCapabilityStatus::Planned, + "KDE Plasma/Wayland environment detected; local activity adapters have not connected in this process.", + ), (_, SessionType::Wayland) => ( PlatformCapabilityStatus::Unavailable, - "Wayland support requires a separate adapter and is unavailable in this build.", + "This Wayland desktop does not have a supported local activity adapter.", ), _ => ( PlatformCapabilityStatus::Unavailable, @@ -1054,15 +1178,36 @@ fn os_activity_capability_status( match runtime_status { OsAdapterRuntimeStatus::NotStarted => ( PlatformCapabilityStatus::Planned, - "GNOME/X11 environment detected; the idle-time adapter has not started in this process.", + match (environment.desktop_environment, environment.session_type) { + (DesktopEnvironment::Kde, SessionType::Wayland) => { + "KDE Plasma/Wayland detected; the compositor idle adapter has not started in this process." + } + _ => { + "GNOME/X11 environment detected; the idle-time adapter has not started in this process." + } + }, ), OsAdapterRuntimeStatus::Available => ( PlatformCapabilityStatus::Available, - "GNOME/X11 idle-time adapter is available; collection remains off until explicitly enabled.", + match (environment.desktop_environment, environment.session_type) { + (DesktopEnvironment::Kde, SessionType::Wayland) => { + "KDE Plasma/Wayland compositor idle adapter is available; collection remains off until explicitly enabled." + } + _ => { + "GNOME/X11 idle-time adapter is available; collection remains off until explicitly enabled." + } + }, ), OsAdapterRuntimeStatus::Unavailable => ( PlatformCapabilityStatus::Unavailable, - "GNOME/X11 detected, but the local idle-time service is unavailable right now.", + match (environment.desktop_environment, environment.session_type) { + (DesktopEnvironment::Kde, SessionType::Wayland) => { + "KDE Plasma/Wayland detected, but the compositor idle protocol is unavailable right now." + } + _ => { + "GNOME/X11 detected, but the local idle-time service is unavailable right now." + } + }, ), } } @@ -1108,6 +1253,22 @@ fn current_session_type() -> SessionType { } } +const fn os_activity_backend(environment: PlatformEnvironment) -> Option { + match environment { + PlatformEnvironment { + operating_system: OperatingSystem::Linux, + desktop_environment: DesktopEnvironment::Gnome, + session_type: SessionType::X11, + } => Some(os_activity::Backend::GnomeX11), + PlatformEnvironment { + operating_system: OperatingSystem::Linux, + desktop_environment: DesktopEnvironment::Kde, + session_type: SessionType::Wayland, + } => Some(os_activity::Backend::KdeWayland), + _ => None, + } +} + fn local_data_summary(store: &EncryptedStore) -> Result { Ok(LocalDataSummary { aggregate_batch_count: store.aggregate_batch_count()?, @@ -1648,6 +1809,14 @@ const fn protocol_readiness_status(status: ReadinessStatus) -> RhythmReadinessSt } } +fn health_response() -> ProtocolResponse { + ProtocolResponse::Health { + protocol_version: PROTOCOL_VERSION, + service_version: env!("CARGO_PKG_VERSION").to_owned(), + status: ServiceStatus::Ready, + } +} + fn validation_error_response(error: &ValidationError) -> ProtocolResponse { let code = match error { ValidationError::UnsupportedProtocolVersion { .. } => ErrorCode::UnsupportedProtocolVersion, @@ -1673,7 +1842,9 @@ fn storage_error_response(error: &StorageError) -> ProtocolResponse { | StorageError::InvalidBackupTimestamp | StorageError::UnsupportedBackupFormat { .. } | StorageError::UnsupportedBackupSchema { .. } - | StorageError::RestoreRequiresEmptyRecords => ErrorCode::InvalidRequest, + | StorageError::RestoreRequiresEmptyRecords + | StorageError::BrowserFamilyConflict + | StorageError::UnknownBrowserConnection => ErrorCode::InvalidRequest, _ => ErrorCode::Internal, }; @@ -1724,6 +1895,8 @@ pub fn systemd_user_service_unit(daemon_path: &Path) -> Result { [Unit] Description=MindCanary local data daemon Documentation=https://github.com/mindcanary/mindcanary +StartLimitIntervalSec=60s +StartLimitBurst=3 [Service] Type=simple @@ -1823,14 +1996,9 @@ fn run_systemctl_user(args: [&str; N]) -> Result<()> { pub async fn run(socket_path: &Path, database_path: &Path) -> Result<()> { let state = std::sync::Arc::new(DaemonState::open(database_path)?); - if PlatformEnvironment::from_process() - == (PlatformEnvironment { - operating_system: OperatingSystem::Linux, - desktop_environment: DesktopEnvironment::Gnome, - session_type: SessionType::X11, - }) - { - tokio::spawn(os_activity::run(std::sync::Arc::clone(&state))); + let environment = PlatformEnvironment::from_process(); + if let Some(backend) = os_activity_backend(environment) { + tokio::spawn(os_activity::run(std::sync::Arc::clone(&state), backend)); } run_with_state(socket_path, state).await } @@ -1970,6 +2138,8 @@ mod tests { assert!(unit.contains("[Unit]\nDescription=MindCanary local data daemon")); assert!(unit.contains("ExecStart=\"/opt/Mind Canary/bin/mindcanaryd\"")); assert!(unit.contains("Restart=on-failure")); + assert!(unit.contains("StartLimitIntervalSec=60s")); + assert!(unit.contains("StartLimitBurst=3")); assert!(unit.contains("[Install]\nWantedBy=default.target")); assert!(!unit.contains("--socket")); assert!(!unit.contains("--database")); @@ -2058,6 +2228,7 @@ mod tests { fn request(source: Uuid, batch: Uuid, sequence: u64) -> ProtocolRequest { ProtocolRequest::IngestAggregate { protocol_version: PROTOCOL_VERSION, + connector: None, batch: AggregateBatch { batch_id: batch, source_instance_id: source, @@ -2356,7 +2527,30 @@ mod tests { } #[test] - fn classifies_wayland_as_unavailable_until_a_separate_adapter_exists() { + fn reports_active_idle_available_when_kde_wayland_adapter_connects() { + let environment = PlatformEnvironment { + operating_system: OperatingSystem::Linux, + desktop_environment: DesktopEnvironment::Kde, + session_type: SessionType::Wayland, + }; + let capabilities = + platform_capabilities_from(environment, OsAdapterRuntimeStatus::Available); + + let active_idle = capabilities + .capabilities + .iter() + .find(|capability| capability.capability == PlatformCapabilityId::OsActiveIdleDuration) + .expect("active-idle capability should be present"); + assert_eq!(active_idle.status, PlatformCapabilityStatus::Available); + assert!(active_idle.detail.contains("KDE Plasma/Wayland")); + assert_eq!( + os_activity_backend(environment), + Some(os_activity::Backend::KdeWayland) + ); + } + + #[test] + fn leaves_unsupported_wayland_desktops_unavailable() { let capabilities = platform_capabilities_from( PlatformEnvironment { operating_system: OperatingSystem::Linux, @@ -2381,6 +2575,7 @@ mod tests { let response = state.handle_request( ProtocolRequest::GetCollectionSettings { protocol_version: PROTOCOL_VERSION, + connector: None, }, now(), ); diff --git a/bins/daemon/src/os_activity.rs b/bins/daemon/src/os_activity.rs index 2aaa8c6..3ef65a1 100644 --- a/bins/daemon/src/os_activity.rs +++ b/bins/daemon/src/os_activity.rs @@ -10,7 +10,15 @@ use mindcanary_protocol::{ AggregateBatch, Metric, ObservationPeriod, PROTOCOL_VERSION, ProtocolRequest, ProtocolResponse, SignalId, }; +use tokio::sync::watch; use uuid::Uuid; +use wayland_client::{ + Connection, Dispatch, QueueHandle, + protocol::{wl_registry, wl_seat}, +}; +use wayland_protocols::ext::idle_notify::v1::client::{ + ext_idle_notification_v1, ext_idle_notifier_v1, +}; use super::{DaemonState, OsAdapterRuntimeStatus}; @@ -25,6 +33,12 @@ const MUTTER_IDLE_PATH: &str = "/org/gnome/Mutter/IdleMonitor/Core"; const MUTTER_IDLE_INTERFACE: &str = "org.gnome.Mutter.IdleMonitor"; const LIFECYCLE_RECONNECT_SECONDS: u64 = 5; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum Backend { + GnomeX11, + KdeWayland, +} + #[zbus::proxy( default_service = "org.gnome.ScreenSaver", default_path = "/org/gnome/ScreenSaver", @@ -35,6 +49,20 @@ trait GnomeScreenSaver { fn active_changed(&self, active: bool) -> zbus::Result<()>; } +mod freedesktop_screen_saver { + #[zbus::proxy( + default_service = "org.freedesktop.ScreenSaver", + default_path = "/ScreenSaver", + interface = "org.freedesktop.ScreenSaver" + )] + pub(super) trait FreedesktopScreenSaver { + #[zbus(signal)] + fn active_changed(&self, active: bool) -> zbus::Result<()>; + } +} + +use freedesktop_screen_saver::FreedesktopScreenSaverProxy; + #[zbus::proxy( default_service = "org.freedesktop.login1", default_path = "/org/freedesktop/login1", @@ -165,8 +193,177 @@ impl GnomeIdleMonitor { } } -pub(super) async fn run(state: Arc) { - tokio::spawn(run_lock_events(state.clone())); +struct KdeIdleMonitor { + activity: watch::Receiver>, +} + +impl KdeIdleMonitor { + async fn connect() -> Result { + let (sender, mut activity) = watch::channel(None); + tokio::task::spawn_blocking(move || { + if let Err(error) = run_wayland_idle_monitor(sender) { + eprintln!("os_activity_wayland_error: {error}"); + } + }); + tokio::time::timeout( + StdDuration::from_secs(DBUS_TIMEOUT_SECONDS), + activity.changed(), + ) + .await + .map_err(|_| ())? + .map_err(|_| ())?; + activity.borrow().ok_or(())?; + Ok(Self { activity }) + } + + fn state(&self) -> Result { + if self.activity.has_changed().is_err() { + return Err(()); + } + self.activity.borrow().ok_or(()) + } +} + +enum ActivityMonitor { + Gnome(GnomeIdleMonitor), + Kde(KdeIdleMonitor), +} + +impl ActivityMonitor { + async fn connect(backend: Backend) -> Result { + match backend { + Backend::GnomeX11 => { + let monitor = GnomeIdleMonitor::connect().await.map_err(|_| ())?; + read_gnome_activity(&monitor).await?; + Ok(Self::Gnome(monitor)) + } + Backend::KdeWayland => Ok(Self::Kde(KdeIdleMonitor::connect().await?)), + } + } + + async fn state(&self) -> Result { + match self { + Self::Gnome(monitor) => read_gnome_activity(monitor).await, + Self::Kde(monitor) => monitor.state(), + } + } +} + +struct WaylandIdleState { + activity: watch::Sender>, + notifier: Option, + seat: Option, + notification: Option, +} + +impl WaylandIdleState { + fn subscribe(&mut self, queue: &QueueHandle) { + if self.notification.is_some() { + return; + } + let (Some(notifier), Some(seat)) = (&self.notifier, &self.seat) else { + return; + }; + self.notification = Some(notifier.get_input_idle_notification( + u32::try_from(IDLE_THRESHOLD_MILLIS).expect("idle threshold fits Wayland protocol"), + seat, + queue, + (), + )); + let _ = self.activity.send(Some(ActivityState::Active)); + } +} + +fn run_wayland_idle_monitor(activity: watch::Sender>) -> anyhow::Result<()> { + let connection = Connection::connect_to_env()?; + let mut event_queue = connection.new_event_queue(); + let queue = event_queue.handle(); + connection.display().get_registry(&queue, ()); + let mut state = WaylandIdleState { + activity, + notifier: None, + seat: None, + notification: None, + }; + loop { + event_queue.blocking_dispatch(&mut state)?; + } +} + +impl Dispatch for WaylandIdleState { + fn event( + state: &mut Self, + registry: &wl_registry::WlRegistry, + event: wl_registry::Event, + (): &(), + _: &Connection, + queue: &QueueHandle, + ) { + let wl_registry::Event::Global { + name, + interface, + version, + } = event + else { + return; + }; + match interface.as_str() { + "ext_idle_notifier_v1" => { + state.notifier = Some(registry.bind(name, version.min(2), queue, ())); + } + "wl_seat" if state.seat.is_none() => { + state.seat = Some(registry.bind(name, version.min(9), queue, ())); + } + _ => return, + } + state.subscribe(queue); + } +} + +impl Dispatch for WaylandIdleState { + fn event( + _: &mut Self, + _: &wl_seat::WlSeat, + _: wl_seat::Event, + (): &(), + _: &Connection, + _: &QueueHandle, + ) { + } +} + +impl Dispatch for WaylandIdleState { + fn event( + _: &mut Self, + _: &ext_idle_notifier_v1::ExtIdleNotifierV1, + _: ext_idle_notifier_v1::Event, + (): &(), + _: &Connection, + _: &QueueHandle, + ) { + } +} + +impl Dispatch for WaylandIdleState { + fn event( + state: &mut Self, + _: &ext_idle_notification_v1::ExtIdleNotificationV1, + event: ext_idle_notification_v1::Event, + (): &(), + _: &Connection, + _: &QueueHandle, + ) { + let activity = match event { + ext_idle_notification_v1::Event::Idled => ActivityState::Idle, + ext_idle_notification_v1::Event::Resumed => ActivityState::Active, + _ => return, + }; + let _ = state.activity.send(Some(activity)); + } +} + +pub(super) async fn run(state: Arc, backend: Backend) { + tokio::spawn(run_lock_events(state.clone(), backend)); tokio::spawn(run_sleep_events(state.clone())); let source_instance_id = Uuid::now_v7(); let time_zone = local_time_zone(); @@ -182,33 +379,31 @@ pub(super) async fn run(state: Arc) { let enabled = enabled_signals(&state, now); if monitor.is_none() { - match GnomeIdleMonitor::connect().await { - Ok(candidate) => match read_idle_millis(&candidate).await { - Ok(idle_millis) => { + match ActivityMonitor::connect(backend).await { + Ok(candidate) => match candidate.state().await { + Ok(activity) => { set_runtime_status(&state, OsAdapterRuntimeStatus::Available); monitor = Some(candidate); if enabled.any() { - reducer = Some(ActivityReducer::new(now, activity_state(idle_millis))); + reducer = Some(ActivityReducer::new(now, activity)); } } Err(()) => { set_runtime_status(&state, OsAdapterRuntimeStatus::Unavailable); } }, - Err(_) => set_runtime_status(&state, OsAdapterRuntimeStatus::Unavailable), + Err(()) => set_runtime_status(&state, OsAdapterRuntimeStatus::Unavailable), } } else if !enabled.any() { reducer = None; } else { - let Ok(idle_millis) = read_idle_millis(monitor.as_ref().expect("monitor exists")).await - else { + let Ok(activity) = monitor.as_ref().expect("monitor exists").state().await else { set_runtime_status(&state, OsAdapterRuntimeStatus::Unavailable); monitor = None; reducer = None; continue; }; set_runtime_status(&state, OsAdapterRuntimeStatus::Available); - let activity = activity_state(idle_millis); let current = reducer.get_or_insert_with(|| ActivityReducer::new(now, activity)); for bucket in current.advance(now, activity) { let Some(next_sequence) = sequence.checked_add(1) else { @@ -230,41 +425,72 @@ pub(super) async fn run(state: Arc) { } } -async fn run_lock_events(state: Arc) { +async fn run_lock_events(state: Arc, backend: Backend) { loop { - let connected = async { - let connection = zbus::Connection::session().await?; - let proxy = GnomeScreenSaverProxy::new(&connection).await?; - let mut events = proxy.receive_active_changed().await?; - set_lifecycle_status(&state, Some(true), None); - let source_instance_id = Uuid::now_v7(); - let mut sequence = 0_u64; - while let Some(event) = events.next().await { - let Ok(arguments) = event.args() else { - continue; - }; - let Some(next_sequence) = sequence.checked_add(1) else { - break; - }; - sequence = next_sequence; - ingest_lifecycle_event( - &state, - lock_signal(arguments.active), - source_instance_id, - sequence, - &local_time_zone(), - Utc::now(), - ); - } - Ok::<(), zbus::Error>(()) - } - .await; + let connected = match backend { + Backend::GnomeX11 => run_gnome_lock_events(&state).await, + Backend::KdeWayland => run_freedesktop_lock_events(&state).await, + }; let _ = connected; set_lifecycle_status(&state, Some(false), None); tokio::time::sleep(StdDuration::from_secs(LIFECYCLE_RECONNECT_SECONDS)).await; } } +async fn run_gnome_lock_events(state: &DaemonState) -> zbus::Result<()> { + let connection = zbus::Connection::session().await?; + let proxy = GnomeScreenSaverProxy::new(&connection).await?; + let mut events = proxy.receive_active_changed().await?; + set_lifecycle_status(state, Some(true), None); + let source_instance_id = Uuid::now_v7(); + let mut sequence = 0_u64; + while let Some(event) = events.next().await { + let Ok(arguments) = event.args() else { + continue; + }; + let Some(next_sequence) = sequence.checked_add(1) else { + break; + }; + sequence = next_sequence; + ingest_lifecycle_event( + state, + lock_signal(arguments.active), + source_instance_id, + sequence, + &local_time_zone(), + Utc::now(), + ); + } + Ok(()) +} + +async fn run_freedesktop_lock_events(state: &DaemonState) -> zbus::Result<()> { + let connection = zbus::Connection::session().await?; + let proxy = FreedesktopScreenSaverProxy::new(&connection).await?; + let mut events = proxy.receive_active_changed().await?; + set_lifecycle_status(state, Some(true), None); + let source_instance_id = Uuid::now_v7(); + let mut sequence = 0_u64; + while let Some(event) = events.next().await { + let Ok(arguments) = event.args() else { + continue; + }; + let Some(next_sequence) = sequence.checked_add(1) else { + break; + }; + sequence = next_sequence; + ingest_lifecycle_event( + state, + lock_signal(arguments.active), + source_instance_id, + sequence, + &local_time_zone(), + Utc::now(), + ); + } + Ok(()) +} + async fn run_sleep_events(state: Arc) { loop { let connected = async { @@ -327,6 +553,7 @@ fn ingest_lifecycle_event( let response = state.handle_request( ProtocolRequest::IngestAggregate { protocol_version: PROTOCOL_VERSION, + connector: None, batch: AggregateBatch { batch_id: Uuid::now_v7(), source_instance_id, @@ -361,14 +588,15 @@ fn set_lifecycle_status( } } -async fn read_idle_millis(monitor: &GnomeIdleMonitor) -> Result { - tokio::time::timeout( +async fn read_gnome_activity(monitor: &GnomeIdleMonitor) -> Result { + let idle_millis = tokio::time::timeout( StdDuration::from_secs(DBUS_TIMEOUT_SECONDS), monitor.idle_millis(), ) .await .map_err(|_| ())? - .map_err(|_| ()) + .map_err(|_| ())?; + Ok(activity_state(idle_millis)) } fn enabled_signals(state: &DaemonState, now: DateTime) -> EnabledSignals { @@ -423,6 +651,7 @@ fn ingest_bucket( let response = state.handle_request( ProtocolRequest::IngestAggregate { protocol_version: PROTOCOL_VERSION, + connector: None, batch: AggregateBatch { batch_id: Uuid::now_v7(), source_instance_id, diff --git a/bins/native-host/src/lib.rs b/bins/native-host/src/lib.rs index 9b2936b..210054d 100644 --- a/bins/native-host/src/lib.rs +++ b/bins/native-host/src/lib.rs @@ -478,6 +478,7 @@ mod tests { fn parser_allows_read_only_collector_settings() { let payload = serde_json::to_vec(&ProtocolRequest::GetCollectionSettings { protocol_version: PROTOCOL_VERSION, + connector: None, }) .unwrap(); @@ -499,7 +500,7 @@ mod tests { } #[test] - fn configured_development_identity_is_valid_and_release_fails_closed() { + fn configured_browser_identities_are_distinct_and_valid() { assert_eq!( configured_extension_id(ExtensionChannel::Development).unwrap(), "agokdhalkipifklmbipkgmfakdcaekbj" @@ -509,7 +510,10 @@ mod tests { configured_firefox_extension_id(ExtensionChannel::Development).unwrap(), "development@mindcanary.local" ); - assert!(configured_firefox_extension_id(ExtensionChannel::Release).is_err()); + assert_eq!( + configured_firefox_extension_id(ExtensionChannel::Release).unwrap(), + "{32ac0030-d005-4ae6-a94e-41977bb53d6b}" + ); } #[test] diff --git a/bins/native-host/src/main.rs b/bins/native-host/src/main.rs index d180291..8c9fc49 100644 --- a/bins/native-host/src/main.rs +++ b/bins/native-host/src/main.rs @@ -50,8 +50,14 @@ struct Arguments { #[arg(long, hide = true)] manifest_dir: Option, - /// Caller origin supplied by Chrome. - origin: Option, + /// Browser-provided native-messaging launcher arguments. + /// + /// Chrome supplies the caller origin, while Firefox supplies the manifest path + /// followed by the extension ID. The manifest remains the authorization + /// boundary; these arguments are accepted only so both browsers can launch + /// the bridge. + #[arg(hide = true, num_args = 0..=2)] + launcher_args: Vec, } #[derive(Debug, Clone, Copy, ValueEnum)] @@ -177,3 +183,20 @@ async fn main() -> Result<()> { write_chrome_message(&mut output, &response) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_firefox_native_messaging_launcher_arguments() { + let arguments = Arguments::try_parse_from([ + "mindcanary-native-host", + "/home/tester/.mozilla/native-messaging-hosts/app.mindcanary.collector.json", + "development@mindcanary.local", + ]) + .expect("Firefox launcher arguments should parse"); + + assert_eq!(arguments.launcher_args.len(), 2); + } +} diff --git a/config/firefox-extension-identities.json b/config/firefox-extension-identities.json index b7af971..387c0e7 100644 --- a/config/firefox-extension-identities.json +++ b/config/firefox-extension-identities.json @@ -4,6 +4,6 @@ "extension_id": "development@mindcanary.local" }, "release": { - "extension_id": null + "extension_id": "{32ac0030-d005-4ae6-a94e-41977bb53d6b}" } } diff --git a/crates/analytics/src/lib.rs b/crates/analytics/src/lib.rs index de2df89..bf96bcb 100644 --- a/crates/analytics/src/lib.rs +++ b/crates/analytics/src/lib.rs @@ -1155,6 +1155,9 @@ mod tests { | ProtocolRequest::PrepareDeleteLatestCheckIn { .. } | ProtocolRequest::DeleteLatestCheckIn { .. } | ProtocolRequest::GetCollectionSettings { .. } + | ProtocolRequest::GetBrowserConnections { .. } + | ProtocolRequest::SetBrowserConnectionLabel { .. } + | ProtocolRequest::SetBrowserSignalCollection { .. } | ProtocolRequest::GetPlatformCapabilities { .. } | ProtocolRequest::SetSignalCollection { .. } | ProtocolRequest::PrepareDeleteSignalRecords { .. } diff --git a/crates/client/src/bin/mindcanaryctl.rs b/crates/client/src/bin/mindcanaryctl.rs index f42ea0a..229671d 100644 --- a/crates/client/src/bin/mindcanaryctl.rs +++ b/crates/client/src/bin/mindcanaryctl.rs @@ -1,11 +1,17 @@ -use std::path::{Path, PathBuf}; +use std::{ + fs::{self, OpenOptions}, + io::Write, + os::unix::fs::OpenOptionsExt, + path::{Path, PathBuf}, +}; use anyhow::{Context, Result, bail}; use clap::{Parser, Subcommand}; use mindcanary_client::{DaemonClient, default_socket_path}; use mindcanary_protocol::{ - LocalDataSummary, PROTOCOL_VERSION, ProtocolResponse, ServiceStatus, SignalCollectionSetting, - SignalId, SourceHealth, SourceStatus, SourceType, + DesktopEnvironment, LocalDataSummary, OperatingSystem, PROTOCOL_VERSION, PlatformCapabilityId, + PlatformCapabilityStatus, ProtocolResponse, ServiceStatus, SessionType, + SignalCollectionSetting, SignalId, SourceHealth, SourceStatus, SourceType, }; #[derive(Debug, Parser)] @@ -28,6 +34,8 @@ enum Command { Health, /// Show whether each local source is active, stale, disabled, or unavailable. SourceStatus, + /// Show local operating-system adapter capabilities. + Capabilities, /// List known aggregate signals. Signals, /// Show current per-signal collection settings. @@ -54,6 +62,15 @@ enum Command { #[arg(long)] directory: PathBuf, }, + /// Create and verify an encrypted backup, storing its recovery secret in a new mode-0600 file. + Backup { + /// Absolute path for the new encrypted backup. + #[arg(long)] + path: PathBuf, + /// Absolute path for a new file that will receive the one-time recovery secret. + #[arg(long)] + recovery_secret_file: PathBuf, + }, } #[tokio::main] @@ -69,6 +86,12 @@ async fn main() -> Result<()> { .await .context("request source status")?, )?, + Command::Capabilities => print_capabilities( + client + .platform_capabilities() + .await + .context("request platform capabilities")?, + )?, Command::Signals => print_signals(), Command::Settings => { print_settings( @@ -109,11 +132,108 @@ async fn main() -> Result<()> { Command::Export { directory } => { export_local_records(&client, &directory).await?; } + Command::Backup { + path, + recovery_secret_file, + } => { + create_local_backup(&client, &path, &recovery_secret_file).await?; + } } Ok(()) } +async fn create_local_backup( + client: &DaemonClient, + backup_path: &Path, + recovery_secret_path: &Path, +) -> Result<()> { + let backup_path = absolute_path_argument(backup_path, "backup path")?; + let recovery_secret_path = + absolute_path_argument(recovery_secret_path, "recovery-secret file")?; + let mut recovery_secret_file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(recovery_secret_path) + .context("reserve recovery-secret file")?; + + let mut recovery_secret_persisted = false; + let result = create_and_verify_backup( + client, + backup_path, + recovery_secret_path, + &mut recovery_secret_file, + &mut recovery_secret_persisted, + ) + .await; + if result.is_err() && !recovery_secret_persisted { + let _ = fs::remove_file(recovery_secret_path); + } + result +} + +async fn create_and_verify_backup( + client: &DaemonClient, + backup_path: &str, + recovery_secret_path: &str, + recovery_secret_file: &mut std::fs::File, + recovery_secret_persisted: &mut bool, +) -> Result<()> { + let prepared = client + .prepare_create_local_backup() + .await + .context("prepare encrypted local backup")?; + let ProtocolResponse::CreateLocalBackupConfirmation { + confirmation_token, .. + } = prepared + else { + bail!("daemon returned an unexpected backup confirmation response"); + }; + + let created = client + .create_local_backup(confirmation_token, backup_path.to_owned()) + .await + .context("create encrypted local backup")?; + let ProtocolResponse::LocalBackupCreated { backup, .. } = created else { + bail!("daemon returned an unexpected backup response"); + }; + + let persist_secret = recovery_secret_file + .write_all(backup.recovery_secret.as_bytes()) + .and_then(|()| recovery_secret_file.write_all(b"\n")) + .and_then(|()| recovery_secret_file.sync_all()); + if let Err(error) = persist_secret { + let _ = fs::remove_file(&backup.backup_path); + return Err(error).context("persist recovery-secret file; the unusable backup was removed"); + } + *recovery_secret_persisted = true; + + let verified = client + .verify_local_backup(backup.backup_path.clone(), backup.recovery_secret) + .await + .with_context(|| { + format!( + "verify encrypted local backup; backup and recovery secret remain at {} and {recovery_secret_path}", + backup.backup_path + ) + })?; + let ProtocolResponse::LocalBackupVerified { .. } = verified else { + bail!( + "daemon returned an unexpected backup verification response; backup and recovery secret remain at {} and {recovery_secret_path}", + backup.backup_path + ); + }; + + println!( + "Created and verified encrypted backup at {}", + backup.backup_path + ); + println!("Recovery secret saved to {recovery_secret_path}"); + print_local_data_summary(backup.summary); + Ok(()) +} + async fn export_local_records(client: &DaemonClient, directory: &Path) -> Result<()> { let export_directory = export_directory_argument(directory)?; let prepared = client @@ -135,10 +255,15 @@ async fn export_local_records(client: &DaemonClient, directory: &Path) -> Result } fn export_directory_argument(directory: &Path) -> Result { - if !directory.is_absolute() { - bail!("export directory must be absolute"); + absolute_path_argument(directory, "export directory").map(str::to_owned) +} + +fn absolute_path_argument<'a>(path: &'a Path, label: &str) -> Result<&'a str> { + if !path.is_absolute() { + bail!("{label} must be absolute"); } - Ok(directory.display().to_string()) + path.to_str() + .with_context(|| format!("{label} must be UTF-8")) } async fn set_signal_group( @@ -194,6 +319,48 @@ fn print_source_status(response: ProtocolResponse) -> Result<()> { Ok(()) } +fn print_capabilities(response: ProtocolResponse) -> Result<()> { + let ProtocolResponse::PlatformCapabilities { capabilities, .. } = response else { + bail!("daemon returned an unexpected platform capabilities response"); + }; + + let operating_system = match capabilities.operating_system { + OperatingSystem::Linux => "linux", + OperatingSystem::Macos => "macos", + OperatingSystem::Windows => "windows", + OperatingSystem::Other => "other", + }; + let desktop = match capabilities.desktop_environment { + DesktopEnvironment::Gnome => "gnome", + DesktopEnvironment::Kde => "kde", + DesktopEnvironment::Other => "other", + DesktopEnvironment::Unknown => "unknown", + }; + let session = match capabilities.session_type { + SessionType::X11 => "x11", + SessionType::Wayland => "wayland", + SessionType::Other => "other", + SessionType::Unknown => "unknown", + }; + println!("Platform: {operating_system} / {desktop} / {session}"); + for capability in capabilities.capabilities { + let name = match capability.capability { + PlatformCapabilityId::OsLockAndSessionEvents => "os.lock_and_session_events", + PlatformCapabilityId::OsActiveIdleDuration => "os.active_idle_duration", + PlatformCapabilityId::ForegroundApplicationCategory => { + "os.foreground_application_category" + } + }; + let status = match capability.status { + PlatformCapabilityStatus::Available => "available", + PlatformCapabilityStatus::Planned => "planned", + PlatformCapabilityStatus::Unavailable => "unavailable", + }; + println!("{name}: {status} ({})", capability.detail); + } + Ok(()) +} + fn source_status_line(status: SourceStatus) -> String { let source = match status.source { SourceType::Browser => "browser", @@ -377,4 +544,15 @@ mod tests { "/tmp/mindcanary-export" ); } + + #[test] + fn backup_paths_require_absolute_utf8_paths() { + let error = absolute_path_argument(Path::new("relative.mcbak"), "backup path").unwrap_err(); + assert!(error.to_string().contains("backup path must be absolute")); + + assert_eq!( + absolute_path_argument(Path::new("/tmp/history.mcbak"), "backup path").unwrap(), + "/tmp/history.mcbak" + ); + } } diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 314d32e..e77e29d 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -81,6 +81,42 @@ impl DaemonClient { pub async fn collection_settings(&self) -> Result { self.send(&ProtocolRequest::GetCollectionSettings { protocol_version: PROTOCOL_VERSION, + connector: None, + }) + .await + } + + pub async fn browser_connections(&self) -> Result { + self.send(&ProtocolRequest::GetBrowserConnections { + protocol_version: PROTOCOL_VERSION, + }) + .await + } + + pub async fn set_browser_connection_label( + &self, + source_instance_id: uuid::Uuid, + label: String, + ) -> Result { + self.send(&ProtocolRequest::SetBrowserConnectionLabel { + protocol_version: PROTOCOL_VERSION, + source_instance_id, + label, + }) + .await + } + + pub async fn set_browser_signal_collection( + &self, + source_instance_id: uuid::Uuid, + signal: SignalId, + enabled: bool, + ) -> Result { + self.send(&ProtocolRequest::SetBrowserSignalCollection { + protocol_version: PROTOCOL_VERSION, + source_instance_id, + signal, + enabled, }) .await } diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 710fd4b..414a737 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -17,6 +17,7 @@ pub const MAX_CHECK_IN_SCALE: u8 = 7; pub const MAX_SLEEP_MINUTES: u16 = 24 * 60; pub const MAX_CONTEXT_TAGS: usize = 8; pub const MAX_ANNOTATION_TEXT_BYTES: usize = 1_000; +pub const MAX_BROWSER_CONNECTION_LABEL_BYTES: usize = 80; pub const DEFAULT_DAILY_RHYTHM_INSIGHT_LIMIT: u16 = 20; pub const MAX_DAILY_RHYTHM_INSIGHT_LIMIT: u16 = 100; pub const DEFAULT_DAILY_TIMELINE_LIMIT: u16 = 30; @@ -34,6 +35,7 @@ pub enum ProtocolRequest { }, IngestAggregate { protocol_version: u16, + connector: Option, batch: AggregateBatch, }, SubmitCheckIn { @@ -72,6 +74,21 @@ pub enum ProtocolRequest { }, GetCollectionSettings { protocol_version: u16, + connector: Option, + }, + GetBrowserConnections { + protocol_version: u16, + }, + SetBrowserConnectionLabel { + protocol_version: u16, + source_instance_id: Uuid, + label: String, + }, + SetBrowserSignalCollection { + protocol_version: u16, + source_instance_id: Uuid, + signal: SignalId, + enabled: bool, }, GetPlatformCapabilities { protocol_version: u16, @@ -160,7 +177,16 @@ impl ProtocolRequest { | Self::GetDailyTimeline { protocol_version, .. } - | Self::GetCollectionSettings { protocol_version } + | Self::GetCollectionSettings { + protocol_version, .. + } + | Self::GetBrowserConnections { protocol_version } + | Self::SetBrowserConnectionLabel { + protocol_version, .. + } + | Self::SetBrowserSignalCollection { + protocol_version, .. + } | Self::GetPlatformCapabilities { protocol_version } | Self::SetSignalCollection { protocol_version, .. @@ -209,13 +235,34 @@ impl ProtocolRequest { } match self { - Self::IngestAggregate { batch, .. } => batch.validate_at(now)?, + Self::IngestAggregate { + connector, batch, .. + } => { + batch.validate_at(now)?; + if connector.is_some_and(|connector| { + connector.source_instance_id != batch.source_instance_id + || batch + .metrics + .iter() + .any(|metric| metric.signal.source_type() != SourceType::Browser) + }) { + return Err(ValidationError::InvalidBrowserConnection); + } + } Self::SubmitCheckIn { check_in, .. } => check_in.validate_at(now)?, Self::PrepareDeleteLatestCheckIn { local_date, .. } | Self::DeleteLatestCheckIn { local_date, .. } => validate_local_date(local_date)?, Self::SaveAnnotation { annotation, .. } => annotation.validate_at(now)?, Self::GetDailyRhythmInsights { limit, .. } => validate_insight_limit(*limit)?, Self::GetDailyTimeline { limit, .. } => validate_timeline_limit(*limit)?, + Self::SetBrowserConnectionLabel { label, .. } => { + validate_browser_connection_label(label)?; + } + Self::SetBrowserSignalCollection { signal, .. } + if signal.source_type() != SourceType::Browser => + { + return Err(ValidationError::InvalidBrowserSignal); + } Self::ExportLocalRecords { export_directory, .. } => validate_export_directory(export_directory)?, @@ -236,10 +283,12 @@ impl ProtocolRequest { Self::Health { .. } | Self::GetSourceStatus { .. } | Self::GetCollectionSettings { .. } + | Self::GetBrowserConnections { .. } | Self::PrepareDeleteAnnotation { .. } | Self::DeleteAnnotation { .. } | Self::GetPlatformCapabilities { .. } | Self::SetSignalCollection { .. } + | Self::SetBrowserSignalCollection { .. } | Self::PrepareDeleteSignalRecords { .. } | Self::DeleteSignalRecords { .. } | Self::GetLocalDataSummary { .. } @@ -301,6 +350,16 @@ fn validate_export_directory(value: &str) -> Result<(), ValidationError> { Ok(()) } +fn validate_browser_connection_label(value: &str) -> Result<(), ValidationError> { + if value.trim().is_empty() + || value.len() > MAX_BROWSER_CONNECTION_LABEL_BYTES + || value.chars().any(char::is_control) + { + return Err(ValidationError::InvalidBrowserConnectionLabel); + } + Ok(()) +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] pub enum ProtocolResponse { @@ -367,6 +426,11 @@ pub enum ProtocolResponse { protocol_version: u16, settings: Vec, }, + BrowserConnections { + protocol_version: u16, + generated_at: DateTime, + connections: Vec, + }, PlatformCapabilities { protocol_version: u16, capabilities: PlatformCapabilities, @@ -507,6 +571,32 @@ pub struct SignalCollectionSetting { pub changed_at: Option>, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BrowserFamily { + Chrome, + Firefox, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BrowserConnectionIdentity { + pub source_instance_id: Uuid, + pub browser: BrowserFamily, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BrowserConnection { + pub source_instance_id: Uuid, + pub browser: BrowserFamily, + pub label: String, + pub first_seen_at: DateTime, + pub last_seen_at: DateTime, + pub last_received_at: Option>, + pub settings: Vec, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct SignalRecordSummary { @@ -1240,6 +1330,14 @@ pub enum ValidationError { InvalidBackupPath, #[error("recovery secret must be non-empty")] InvalidRecoverySecret, + #[error("browser connection identity does not match its aggregate batch")] + InvalidBrowserConnection, + #[error( + "browser connection labels must contain between 1 and 80 bytes without control characters" + )] + InvalidBrowserConnectionLabel, + #[error("browser-profile settings can only target browser signals")] + InvalidBrowserSignal, } pub fn typescript_schema() -> String { @@ -1283,6 +1381,10 @@ pub fn typescript_schema() -> String { output, "export const MAX_ANNOTATION_TEXT_BYTES = {MAX_ANNOTATION_TEXT_BYTES} as const;\n" ); + let _ = writeln!( + output, + "export const MAX_BROWSER_CONNECTION_LABEL_BYTES = {MAX_BROWSER_CONNECTION_LABEL_BYTES} as const;\n" + ); output.push_str("export const SIGNAL_IDS = [\n"); for signal in SignalId::ALL { @@ -1326,6 +1428,13 @@ export interface AggregateBatch { metrics: Metric[]; } +export type BrowserFamily = "chrome" | "firefox"; + +export interface BrowserConnectionIdentity { + source_instance_id: string; + browser: BrowserFamily; +} + export interface CheckInRecord { check_in_id: string; occurred_at: string; @@ -1525,6 +1634,16 @@ export interface PlatformCapabilities { capabilities: PlatformCapability[]; } +export interface BrowserConnection { + source_instance_id: string; + browser: BrowserFamily; + label: string; + first_seen_at: string; + last_seen_at: string; + last_received_at?: string | null; + settings: SignalCollectionSetting[]; +} + "#; const TYPESCRIPT_PROTOCOL_TYPES: &str = r#"export type ProtocolRequest = @@ -1533,6 +1652,7 @@ const TYPESCRIPT_PROTOCOL_TYPES: &str = r#"export type ProtocolRequest = | { type: "ingest_aggregate"; protocol_version: typeof PROTOCOL_VERSION; + connector?: BrowserConnectionIdentity | null; batch: AggregateBatch; } | { @@ -1580,6 +1700,24 @@ const TYPESCRIPT_PROTOCOL_TYPES: &str = r#"export type ProtocolRequest = | { type: "get_collection_settings"; protocol_version: typeof PROTOCOL_VERSION; + connector?: BrowserConnectionIdentity | null; + } + | { + type: "get_browser_connections"; + protocol_version: typeof PROTOCOL_VERSION; + } + | { + type: "set_browser_connection_label"; + protocol_version: typeof PROTOCOL_VERSION; + source_instance_id: string; + label: string; + } + | { + type: "set_browser_signal_collection"; + protocol_version: typeof PROTOCOL_VERSION; + source_instance_id: string; + signal: SignalId; + enabled: boolean; } | { type: "get_platform_capabilities"; @@ -1753,6 +1891,12 @@ export type ProtocolResponse = protocol_version: typeof PROTOCOL_VERSION; settings: SignalCollectionSetting[]; } + | { + type: "browser_connections"; + protocol_version: typeof PROTOCOL_VERSION; + generated_at: string; + connections: BrowserConnection[]; + } | { type: "platform_capabilities"; protocol_version: typeof PROTOCOL_VERSION; @@ -1891,6 +2035,7 @@ mod tests { fn accepts_a_valid_aggregate() { let request = ProtocolRequest::IngestAggregate { protocol_version: PROTOCOL_VERSION, + connector: None, batch: valid_batch(), }; let now = Utc.with_ymd_and_hms(2026, 6, 14, 12, 20, 0).unwrap(); @@ -2050,13 +2195,15 @@ mod tests { assert!( ProtocolRequest::IngestAggregate { protocol_version: PROTOCOL_VERSION, + connector: None, batch: valid_batch() } .is_collector_request() ); assert!( ProtocolRequest::GetCollectionSettings { - protocol_version: PROTOCOL_VERSION + protocol_version: PROTOCOL_VERSION, + connector: None, } .is_collector_request() ); diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 506dc1a..f1aeb4f 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -9,8 +9,9 @@ use std::{ use chrono::{DateTime, Utc}; use mindcanary_protocol::{ - AggregateBatch, AnnotationRecord, CheckInRecord, ContextTag, IngestDisposition, - SignalCollectionSetting, SignalId, SignalRecordSummary, SourceType, + AggregateBatch, AnnotationRecord, BrowserConnection, BrowserConnectionIdentity, BrowserFamily, + CheckInRecord, ContextTag, IngestDisposition, SignalCollectionSetting, SignalId, + SignalRecordSummary, SourceType, }; use rusqlite::{Connection, OpenFlags, OptionalExtension, TransactionBehavior, params}; use thiserror::Error; @@ -18,10 +19,11 @@ use uuid::Uuid; use zeroize::ZeroizeOnDrop; const DATABASE_KEY_BYTES: usize = 32; -const CURRENT_SCHEMA_VERSION: i64 = 4; +const CURRENT_SCHEMA_VERSION: i64 = 5; pub const BACKUP_FORMAT_VERSION: i64 = 1; const KEYRING_SERVICE: &str = "app.mindcanary.database"; const KEYRING_USER: &str = "primary-v1"; +const KEYRING_TEXT_KEY_PREFIX: &str = "mindcanary-key-v1:"; #[derive(Clone, ZeroizeOnDrop)] pub struct DatabaseKey([u8; DATABASE_KEY_BYTES]); @@ -81,20 +83,56 @@ impl OsKeyringKeyProvider { fn entry() -> Result { keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER).map_err(StorageError::Keyring) } + + fn decode_secret(secret: &[u8]) -> Result { + if secret.len() == DATABASE_KEY_BYTES { + return DatabaseKey::try_from_slice(secret); + } + + let encoded = std::str::from_utf8(secret) + .map_err(|_| StorageError::InvalidKeyringKeyEncoding)? + .strip_prefix(KEYRING_TEXT_KEY_PREFIX) + .ok_or(StorageError::InvalidKeyringKeyEncoding)?; + if encoded.len() != DATABASE_KEY_BYTES * 2 { + return Err(StorageError::InvalidKeyringKeyEncoding); + } + + let mut bytes = [0_u8; DATABASE_KEY_BYTES]; + for (index, byte) in bytes.iter_mut().enumerate() { + let offset = index * 2; + *byte = u8::from_str_radix(&encoded[offset..offset + 2], 16) + .map_err(|_| StorageError::InvalidKeyringKeyEncoding)?; + } + Ok(DatabaseKey::from_bytes(bytes)) + } + + fn encode_secret(key: &DatabaseKey) -> zeroize::Zeroizing { + use fmt::Write as _; + + let mut encoded = zeroize::Zeroizing::new(String::with_capacity( + KEYRING_TEXT_KEY_PREFIX.len() + DATABASE_KEY_BYTES * 2, + )); + encoded.push_str(KEYRING_TEXT_KEY_PREFIX); + for byte in key.as_bytes() { + let _ = write!(encoded, "{byte:02x}"); + } + encoded + } } impl DatabaseKeyProvider for OsKeyringKeyProvider { fn load(&self) -> Result, StorageError> { match Self::entry()?.get_secret() { - Ok(secret) => DatabaseKey::try_from_slice(&secret).map(Some), + Ok(secret) => Self::decode_secret(&secret).map(Some), Err(keyring::Error::NoEntry) => Ok(None), Err(error) => Err(StorageError::Keyring(error)), } } fn store(&self, key: &DatabaseKey) -> Result<(), StorageError> { + let encoded = Self::encode_secret(key); Self::entry()? - .set_secret(key.as_bytes()) + .set_secret(encoded.as_bytes()) .map_err(StorageError::Keyring) } @@ -617,6 +655,218 @@ impl EncryptedStore { .collect() } + pub fn register_browser_connection( + &mut self, + connector: BrowserConnectionIdentity, + seen_at: DateTime, + ) -> Result<(), StorageError> { + let existing_browser = self + .connection + .query_row( + "SELECT browser FROM browser_connections WHERE source_instance_id = ?1", + [connector.source_instance_id.as_bytes().as_slice()], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(StorageError::Database)?; + if existing_browser + .as_deref() + .is_some_and(|stored| stored != browser_family_wire(connector.browser)) + { + return Err(StorageError::BrowserFamilyConflict); + } + + let is_new = existing_browser.is_none(); + let transaction = self + .connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(StorageError::Database)?; + transaction + .execute( + "INSERT INTO browser_connections ( + source_instance_id, browser, label, first_seen_at_ms, last_seen_at_ms + ) VALUES (?1, ?2, ?3, ?4, ?4) + ON CONFLICT(source_instance_id) DO UPDATE SET + last_seen_at_ms = MAX(last_seen_at_ms, excluded.last_seen_at_ms)", + params![ + connector.source_instance_id.as_bytes().as_slice(), + browser_family_wire(connector.browser), + default_browser_connection_label(connector.browser), + seen_at.timestamp_millis(), + ], + ) + .map_err(StorageError::Database)?; + if is_new { + transaction + .execute( + "INSERT INTO browser_signal_collection_transitions ( + source_instance_id, signal_id, effective_at_ms, enabled, created_at_ms + ) + SELECT ?1, signal_id, effective_at_ms, enabled, created_at_ms + FROM signal_collection_transitions + WHERE signal_id LIKE 'browser.%'", + [connector.source_instance_id.as_bytes().as_slice()], + ) + .map_err(StorageError::Database)?; + } + transaction.commit().map_err(StorageError::Database) + } + + pub fn browser_connections( + &self, + at: DateTime, + ) -> Result, StorageError> { + let mut statement = self + .connection + .prepare( + "SELECT + c.source_instance_id, + c.browser, + c.label, + c.first_seen_at_ms, + c.last_seen_at_ms, + MAX(b.received_at_ms) + FROM browser_connections c + LEFT JOIN aggregate_batches b + ON b.source_instance_id = c.source_instance_id + GROUP BY c.source_instance_id + ORDER BY c.last_seen_at_ms DESC, c.first_seen_at_ms", + ) + .map_err(StorageError::Database)?; + let rows = statement + .query_map([], |row| { + Ok(( + row.get::<_, Vec>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, i64>(3)?, + row.get::<_, i64>(4)?, + row.get::<_, Option>(5)?, + )) + }) + .map_err(StorageError::Database)?; + let mut connections = Vec::new(); + for row in rows { + let (source, browser, label, first_seen, last_seen, last_received) = + row.map_err(StorageError::Database)?; + let source_instance_id = uuid_from_blob(&source)?; + connections.push(BrowserConnection { + source_instance_id, + browser: browser_family_from_wire(&browser)?, + label, + first_seen_at: stored_timestamp(first_seen)?, + last_seen_at: stored_timestamp(last_seen)?, + last_received_at: last_received.map(stored_timestamp).transpose()?, + settings: self.browser_collection_settings(source_instance_id, at)?, + }); + } + Ok(connections) + } + + pub fn set_browser_connection_label( + &mut self, + source_instance_id: Uuid, + label: &str, + ) -> Result<(), StorageError> { + let changed = self + .connection + .execute( + "UPDATE browser_connections SET label = ?2 WHERE source_instance_id = ?1", + params![source_instance_id.as_bytes().as_slice(), label], + ) + .map_err(StorageError::Database)?; + if changed == 0 { + return Err(StorageError::UnknownBrowserConnection); + } + Ok(()) + } + + pub fn browser_collection_settings( + &self, + source_instance_id: Uuid, + at: DateTime, + ) -> Result, StorageError> { + SignalId::ALL + .into_iter() + .filter(|signal| signal.source_type() == SourceType::Browser) + .map(|signal| { + let transition = + self.latest_browser_signal_transition(source_instance_id, signal, at)?; + Ok(SignalCollectionSetting { + signal, + enabled: transition.is_some_and(|(_, enabled)| enabled), + changed_at: transition.map(|(changed_at, _)| changed_at), + }) + }) + .collect() + } + + pub fn set_browser_signal_collection( + &mut self, + source_instance_id: Uuid, + signal: SignalId, + enabled: bool, + effective_at: DateTime, + ) -> Result<(), StorageError> { + if !self.browser_connection_exists(source_instance_id)? { + return Err(StorageError::UnknownBrowserConnection); + } + if self + .latest_browser_signal_transition(source_instance_id, signal, effective_at)? + .is_some_and(|(_, current)| current == enabled) + { + return Ok(()); + } + self.connection + .execute( + "INSERT INTO browser_signal_collection_transitions ( + source_instance_id, signal_id, effective_at_ms, enabled, created_at_ms + ) VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(source_instance_id, signal_id, effective_at_ms) + DO UPDATE SET enabled = excluded.enabled, created_at_ms = excluded.created_at_ms", + params![ + source_instance_id.as_bytes().as_slice(), + signal.as_str(), + effective_at.timestamp_millis(), + i64::from(enabled), + Utc::now().timestamp_millis(), + ], + ) + .map_err(StorageError::Database)?; + Ok(()) + } + + pub fn browser_signal_enabled_for_period( + &self, + source_instance_id: Uuid, + signal: SignalId, + start: DateTime, + end: DateTime, + ) -> Result { + let enabled_at_start = self + .latest_browser_signal_transition(source_instance_id, signal, start)? + .is_some_and(|(_, enabled)| enabled); + if !enabled_at_start { + return Ok(false); + } + let transition_count = self + .connection + .query_row( + "SELECT COUNT(*) FROM browser_signal_collection_transitions + WHERE source_instance_id = ?1 AND signal_id = ?2 + AND effective_at_ms > ?3 AND effective_at_ms < ?4", + params![ + source_instance_id.as_bytes().as_slice(), + signal.as_str(), + start.timestamp_millis(), + end.timestamp_millis(), + ], + |row| row.get::<_, i64>(0), + ) + .map_err(StorageError::Database)?; + Ok(transition_count == 0) + } + pub fn source_activity_timestamps(&self) -> Result { let mut timestamps = SourceActivityTimestamps::default(); @@ -829,16 +1079,17 @@ impl EncryptedStore { let mut statement = self .connection .prepare( - "SELECT b.batch_id, b.period_start_ms, b.time_zone, m.signal_id, m.value + "SELECT b.period_start_ms, b.period_end_ms, b.time_zone, m.signal_id, m.value FROM aggregate_batches b JOIN aggregate_metrics m ON m.batch_id = b.batch_id + WHERE m.signal_id LIKE 'browser.%' ORDER BY b.period_start_ms, m.signal_id", ) .map_err(StorageError::Database)?; let rows = statement .query_map([], |row| { Ok(( - row.get::<_, Vec>(0)?, + row.get::<_, i64>(0)?, row.get::<_, i64>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?, @@ -847,9 +1098,9 @@ impl EncryptedStore { }) .map_err(StorageError::Database)?; - let mut days = BTreeMap::::new(); + let mut periods = BTreeMap::<(String, i64, i64), BrowserPeriodAccumulator>::new(); for row in rows { - let (batch_id, period_start_ms, time_zone, signal_id, value) = + let (period_start_ms, period_end_ms, time_zone, signal_id, value) = row.map_err(StorageError::Database)?; let timestamp = DateTime::::from_timestamp_millis(period_start_ms).ok_or( StorageError::InvalidStoredTimestamp { @@ -866,14 +1117,28 @@ impl EncryptedStore { .date_naive() .to_string(); + periods + .entry((local_date, period_start_ms, period_end_ms)) + .or_default() + .record(signal, value); + } + + let mut days = BTreeMap::::new(); + for ((local_date, period_start_ms, period_end_ms), period) in periods { + let duration_ms = period_end_ms + .checked_sub(period_start_ms) + .and_then(|duration| u64::try_from(duration).ok()) + .ok_or(StorageError::InvalidStoredPeriod)?; + let duration_seconds = std::time::Duration::from_millis(duration_ms).as_secs_f64(); days.entry(local_date) .or_default() - .record(batch_id, signal, value); + .record_period(&period, duration_seconds); } - days.into_iter() + Ok(days + .into_iter() .map(|(local_date, accumulator)| accumulator.into_daily(local_date)) - .collect() + .collect()) } pub fn daily_os_features(&self) -> Result, StorageError> { @@ -1155,6 +1420,8 @@ impl EncryptedStore { DELETE FROM check_in_context_tags; DELETE FROM check_ins; DELETE FROM signal_collection_transitions; + DELETE FROM browser_signal_collection_transitions; + DELETE FROM browser_connections; INSERT INTO aggregate_batches SELECT * FROM backup.aggregate_batches; INSERT INTO aggregate_metrics SELECT * FROM backup.aggregate_metrics; @@ -1165,7 +1432,11 @@ impl EncryptedStore { SELECT * FROM backup.signal_collection_transitions; INSERT INTO annotations SELECT * FROM backup.annotations; INSERT INTO annotation_context_tags - SELECT * FROM backup.annotation_context_tags;", + SELECT * FROM backup.annotation_context_tags; + INSERT INTO browser_connections + SELECT * FROM backup.browser_connections; + INSERT INTO browser_signal_collection_transitions + SELECT * FROM backup.browser_signal_collection_transitions;", ) .map_err(StorageError::Database)?; transaction.commit().map_err(StorageError::Database) @@ -1218,6 +1489,72 @@ impl EncryptedStore { }) .transpose() } + + fn browser_connection_exists(&self, source_instance_id: Uuid) -> Result { + self.connection + .query_row( + "SELECT 1 FROM browser_connections WHERE source_instance_id = ?1", + [source_instance_id.as_bytes().as_slice()], + |_| Ok(()), + ) + .optional() + .map(|value| value.is_some()) + .map_err(StorageError::Database) + } + + fn latest_browser_signal_transition( + &self, + source_instance_id: Uuid, + signal: SignalId, + at: DateTime, + ) -> Result, bool)>, StorageError> { + let transition = self + .connection + .query_row( + "SELECT effective_at_ms, enabled + FROM browser_signal_collection_transitions + WHERE source_instance_id = ?1 AND signal_id = ?2 AND effective_at_ms <= ?3 + ORDER BY effective_at_ms DESC LIMIT 1", + params![ + source_instance_id.as_bytes().as_slice(), + signal.as_str(), + at.timestamp_millis(), + ], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)), + ) + .optional() + .map_err(StorageError::Database)?; + transition + .map(|(timestamp_ms, enabled)| Ok((stored_timestamp(timestamp_ms)?, enabled != 0))) + .transpose() + } +} + +fn browser_family_wire(browser: BrowserFamily) -> &'static str { + match browser { + BrowserFamily::Chrome => "chrome", + BrowserFamily::Firefox => "firefox", + } +} + +fn browser_family_from_wire(browser: &str) -> Result { + match browser { + "chrome" => Ok(BrowserFamily::Chrome), + "firefox" => Ok(BrowserFamily::Firefox), + _ => Err(StorageError::InvalidStoredBrowserFamily(browser.to_owned())), + } +} + +fn default_browser_connection_label(browser: BrowserFamily) -> &'static str { + match browser { + BrowserFamily::Chrome => "Chrome profile", + BrowserFamily::Firefox => "Firefox profile", + } +} + +fn stored_timestamp(timestamp_ms: i64) -> Result, StorageError> { + DateTime::::from_timestamp_millis(timestamp_ms) + .ok_or(StorageError::InvalidStoredTimestamp { timestamp_ms }) } fn signal_record_summary_on( @@ -1270,7 +1607,7 @@ fn optional_u16(value: Option) -> Result, StorageError> { #[derive(Debug, Default)] struct DailyBrowserAccumulator { - batch_ids: BTreeSet>, + period_count: u64, open_tab_count_mean: MeanAccumulator, open_tab_count_max: Option, tab_switch_count: Option, @@ -1281,16 +1618,63 @@ struct DailyBrowserAccumulator { } impl DailyBrowserAccumulator { - fn record(&mut self, batch_id: Vec, signal: SignalId, value: f64) { - self.batch_ids.insert(batch_id); + fn record_period(&mut self, period: &BrowserPeriodAccumulator, duration_seconds: f64) { + self.period_count += 1; + if let Some(value) = period.open_tab_count_mean { + self.open_tab_count_mean.push(value); + } + if let Some(value) = period.open_tab_count_max { + self.open_tab_count_max = Some( + self.open_tab_count_max + .map_or(value, |current| current.max(value)), + ); + } + add_optional_sum(&mut self.tab_switch_count, period.tab_switch_count); + add_optional_sum( + &mut self.retained_across_day_count, + period.retained_across_day_count, + ); + add_optional_sum( + &mut self.continuous_scrolling_seconds, + period + .continuous_scrolling_seconds + .map(|value| value.min(duration_seconds)), + ); + add_optional_sum(&mut self.active_seconds, period.active_seconds); + add_optional_sum(&mut self.idle_seconds, period.idle_seconds); + } + + fn into_daily(self, local_date: String) -> DailyBrowserFeatures { + DailyBrowserFeatures { + local_date, + open_tab_count_mean: self.open_tab_count_mean.finish(), + open_tab_count_max: self.open_tab_count_max, + tab_switch_count: self.tab_switch_count, + retained_across_day_count: self.retained_across_day_count, + continuous_scrolling_seconds: self.continuous_scrolling_seconds, + active_seconds: self.active_seconds, + idle_seconds: self.idle_seconds, + aggregate_bucket_count: self.period_count, + } + } +} + +#[derive(Debug, Default)] +struct BrowserPeriodAccumulator { + open_tab_count_mean: Option, + open_tab_count_max: Option, + tab_switch_count: Option, + retained_across_day_count: Option, + continuous_scrolling_seconds: Option, + active_seconds: Option, + idle_seconds: Option, +} + +impl BrowserPeriodAccumulator { + fn record(&mut self, signal: SignalId, value: f64) { match signal { - SignalId::BrowserOpenTabCountMean => self.open_tab_count_mean.push(value), - SignalId::BrowserOpenTabCountMax => { - self.open_tab_count_max = Some( - self.open_tab_count_max - .map_or(value, |current| current.max(value)), - ); - } + SignalId::BrowserOpenTabCountMean => add_sum(&mut self.open_tab_count_mean, value), + SignalId::BrowserOpenTabCountMax => add_sum(&mut self.open_tab_count_max, value), SignalId::BrowserTabSwitchCount => add_sum(&mut self.tab_switch_count, value), SignalId::BrowserRetainedAcrossDayCount => { add_sum(&mut self.retained_across_day_count, value); @@ -1298,8 +1682,8 @@ impl DailyBrowserAccumulator { SignalId::BrowserContinuousScrollingSeconds => { add_sum(&mut self.continuous_scrolling_seconds, value); } - SignalId::BrowserActiveSeconds => add_sum(&mut self.active_seconds, value), - SignalId::BrowserIdleSeconds => add_sum(&mut self.idle_seconds, value), + SignalId::BrowserActiveSeconds => set_max(&mut self.active_seconds, value), + SignalId::BrowserIdleSeconds => set_max(&mut self.idle_seconds, value), SignalId::BrowserOpenTabCountMin | SignalId::BrowserTabOpenCount | SignalId::BrowserTabCloseCount @@ -1312,21 +1696,6 @@ impl DailyBrowserAccumulator { | SignalId::OsResumeCount => {} } } - - fn into_daily(self, local_date: String) -> Result { - Ok(DailyBrowserFeatures { - local_date, - open_tab_count_mean: self.open_tab_count_mean.finish(), - open_tab_count_max: self.open_tab_count_max, - tab_switch_count: self.tab_switch_count, - retained_across_day_count: self.retained_across_day_count, - continuous_scrolling_seconds: self.continuous_scrolling_seconds, - active_seconds: self.active_seconds, - idle_seconds: self.idle_seconds, - aggregate_bucket_count: u64::try_from(self.batch_ids.len()) - .map_err(|_| StorageError::InvalidStoredCount)?, - }) - } } #[derive(Debug, Default)] @@ -1404,6 +1773,16 @@ fn add_sum(slot: &mut Option, value: f64) { *slot = Some(slot.unwrap_or_default() + value); } +fn add_optional_sum(slot: &mut Option, value: Option) { + if let Some(value) = value { + add_sum(slot, value); + } +} + +fn set_max(slot: &mut Option, value: f64) { + *slot = Some(slot.map_or(value, |current| current.max(value))); +} + fn prepare_database_parent(path: &Path) -> Result<(), StorageError> { let parent = path.parent().ok_or(StorageError::MissingDatabaseParent)?; std::fs::create_dir_all(parent).map_err(StorageError::Io)?; @@ -1613,10 +1992,14 @@ fn configure_connection(connection: &Connection) -> Result<(), StorageError> { .map_err(StorageError::Database) } -fn migrate(connection: &mut Connection) -> Result<(), StorageError> { - let mut current_version: i64 = connection +fn stored_schema_version(connection: &Connection) -> Result { + connection .pragma_query_value(None, "user_version", |row| row.get(0)) - .map_err(StorageError::Database)?; + .map_err(StorageError::Database) +} + +fn migrate(connection: &mut Connection) -> Result<(), StorageError> { + let mut current_version = stored_schema_version(connection)?; if current_version > CURRENT_SCHEMA_VERSION { return Err(StorageError::UnsupportedSchemaVersion { @@ -1719,6 +2102,11 @@ fn migrate(connection: &mut Connection) -> Result<(), StorageError> { if current_version < 4 { migrate_annotations(connection)?; + current_version = 4; + } + + if current_version < 5 { + migrate_browser_connections(connection)?; } Ok(()) @@ -1785,6 +2173,43 @@ fn migrate_annotations(connection: &mut Connection) -> Result<(), StorageError> transaction.commit().map_err(StorageError::Database) } +fn migrate_browser_connections(connection: &mut Connection) -> Result<(), StorageError> { + let transaction = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(StorageError::Database)?; + transaction + .execute_batch( + "CREATE TABLE browser_connections ( + source_instance_id BLOB PRIMARY KEY NOT NULL CHECK(length(source_instance_id) = 16), + browser TEXT NOT NULL CHECK(browser IN ('chrome', 'firefox')), + label TEXT NOT NULL CHECK(length(label) BETWEEN 1 AND 80), + first_seen_at_ms INTEGER NOT NULL, + last_seen_at_ms INTEGER NOT NULL + ) STRICT; + + CREATE TABLE browser_signal_collection_transitions ( + source_instance_id BLOB NOT NULL CHECK(length(source_instance_id) = 16), + signal_id TEXT NOT NULL CHECK(length(signal_id) BETWEEN 1 AND 64), + effective_at_ms INTEGER NOT NULL, + enabled INTEGER NOT NULL CHECK(enabled IN (0, 1)), + created_at_ms INTEGER NOT NULL, + PRIMARY KEY(source_instance_id, signal_id, effective_at_ms), + FOREIGN KEY(source_instance_id) REFERENCES browser_connections(source_instance_id) + ON DELETE CASCADE + ) STRICT; + + CREATE INDEX browser_signal_collection_lookup_idx + ON browser_signal_collection_transitions( + source_instance_id, signal_id, effective_at_ms DESC + );", + ) + .map_err(StorageError::Database)?; + transaction + .pragma_update(None, "user_version", 5) + .map_err(StorageError::Database)?; + transaction.commit().map_err(StorageError::Database) +} + pub fn default_database_path() -> PathBuf { let data_root = std::env::var_os("XDG_DATA_HOME").map_or_else( || { @@ -1856,6 +2281,8 @@ pub enum StorageError { KeyGeneration, #[error("database key must contain exactly 32 bytes")] InvalidKeyLength, + #[error("OS-keyring database key has an invalid text encoding")] + InvalidKeyringKeyEncoding, #[error("encrypted database exists but its OS-keyring key is missing")] MissingDatabaseKey, #[error("OS keyring operation failed")] @@ -1908,10 +2335,18 @@ pub enum StorageError { InvalidStoredMinute, #[error("stored aggregate timestamp {timestamp_ms}ms was invalid")] InvalidStoredTimestamp { timestamp_ms: i64 }, + #[error("stored aggregate period was invalid")] + InvalidStoredPeriod, #[error("stored aggregate time zone {0:?} was invalid")] InvalidStoredTimeZone(String), #[error("stored aggregate signal {0:?} was not recognized")] InvalidStoredSignal(String), + #[error("stored browser family {0:?} was not recognized")] + InvalidStoredBrowserFamily(String), + #[error("browser connection UUID is already registered to another browser family")] + BrowserFamilyConflict, + #[error("browser connection was not found")] + UnknownBrowserConnection, #[error("stored local date {0:?} was invalid")] InvalidStoredLocalDate(String), #[error("stored check-in context tag {0:?} was not recognized")] @@ -1958,6 +2393,27 @@ mod tests { temp.path().join("profile").join("mindcanary.db") } + #[test] + fn keyring_keys_use_text_encoding_and_keep_legacy_binary_compatibility() { + let key = DatabaseKey::from_bytes([0xab; DATABASE_KEY_BYTES]); + let encoded = OsKeyringKeyProvider::encode_secret(&key); + + assert!(encoded.is_ascii()); + assert!(encoded.starts_with(KEYRING_TEXT_KEY_PREFIX)); + assert_eq!( + OsKeyringKeyProvider::decode_secret(encoded.as_bytes()) + .unwrap() + .as_bytes(), + key.as_bytes() + ); + assert_eq!( + OsKeyringKeyProvider::decode_secret(key.as_bytes()) + .unwrap() + .as_bytes(), + key.as_bytes() + ); + } + fn batch(source: Uuid, batch_id: Uuid, sequence: u64) -> AggregateBatch { batch_starting_at( source, @@ -2189,6 +2645,64 @@ mod tests { assert_eq!(setting.changed_at, Some(enabled_at)); } + #[test] + fn browser_profiles_keep_local_labels_and_signal_settings_independent() { + let temp = TempDir::new().unwrap(); + let provider = MemoryKeyProvider::default(); + let mut store = EncryptedStore::bootstrap(database_path(&temp), &provider).unwrap(); + let now = Utc.with_ymd_and_hms(2026, 6, 14, 12, 0, 0).unwrap(); + let firefox = BrowserConnectionIdentity { + source_instance_id: Uuid::now_v7(), + browser: BrowserFamily::Firefox, + }; + let chrome = BrowserConnectionIdentity { + source_instance_id: Uuid::now_v7(), + browser: BrowserFamily::Chrome, + }; + let signal = SignalId::BrowserTabSwitchCount; + + store.set_signal_collection(signal, true, now).unwrap(); + store.register_browser_connection(firefox, now).unwrap(); + store + .set_signal_collection(signal, false, now + chrono::Duration::seconds(1)) + .unwrap(); + store + .register_browser_connection(chrome, now + chrono::Duration::seconds(2)) + .unwrap(); + store + .set_browser_connection_label(firefox.source_instance_id, "Firefox personal") + .unwrap(); + + let connections = store + .browser_connections(now + chrono::Duration::minutes(1)) + .unwrap(); + let firefox = connections + .iter() + .find(|connection| connection.browser == BrowserFamily::Firefox) + .unwrap(); + let chrome = connections + .iter() + .find(|connection| connection.browser == BrowserFamily::Chrome) + .unwrap(); + assert_eq!(firefox.label, "Firefox personal"); + assert!( + firefox + .settings + .iter() + .find(|setting| setting.signal == signal) + .unwrap() + .enabled + ); + assert!( + !chrome + .settings + .iter() + .find(|setting| setting.signal == signal) + .unwrap() + .enabled + ); + } + #[test] fn signal_must_be_enabled_for_the_entire_observation_period() { let temp = TempDir::new().unwrap(); @@ -2304,6 +2818,42 @@ mod tests { ); } + #[test] + fn browser_projection_does_not_duplicate_device_activity_across_profiles() { + let temp = TempDir::new().unwrap(); + let provider = MemoryKeyProvider::default(); + let mut store = EncryptedStore::bootstrap(database_path(&temp), &provider).unwrap(); + let mut first = batch(Uuid::now_v7(), Uuid::now_v7(), 1); + first.metrics = vec![ + Metric { + signal: SignalId::BrowserActiveSeconds, + value: 600.0, + }, + Metric { + signal: SignalId::BrowserTabSwitchCount, + value: 8.0, + }, + ]; + let mut second = batch(Uuid::now_v7(), Uuid::now_v7(), 1); + second.metrics = vec![ + Metric { + signal: SignalId::BrowserActiveSeconds, + value: 590.0, + }, + Metric { + signal: SignalId::BrowserTabSwitchCount, + value: 5.0, + }, + ]; + store.ingest(&first).unwrap(); + store.ingest(&second).unwrap(); + + let day = store.daily_browser_features().unwrap().remove(0); + assert_eq!(day.active_seconds, Some(600.0)); + assert_eq!(day.tab_switch_count, Some(13.0)); + assert_eq!(day.aggregate_bucket_count, 1); + } + #[test] fn projects_daily_os_features_in_recorded_time_zone() { let temp = TempDir::new().unwrap(); diff --git a/crates/test-support/src/lib.rs b/crates/test-support/src/lib.rs index e5966f4..7708b09 100644 --- a/crates/test-support/src/lib.rs +++ b/crates/test-support/src/lib.rs @@ -28,6 +28,7 @@ pub fn synthetic_browser_requests() -> Vec { ProtocolRequest::IngestAggregate { protocol_version: PROTOCOL_VERSION, + connector: None, batch: AggregateBatch { batch_id: Uuid::from_u128( 0x0197_0000_0000_7000_9000_0000_0000_0000 + u128::from(sequence), diff --git a/docs/arch-install.md b/docs/arch-install.md new file mode 100644 index 0000000..9d64123 --- /dev/null +++ b/docs/arch-install.md @@ -0,0 +1,109 @@ +# Arch and CachyOS Installation + +Status: local-alpha dogfood path. + +This path installs release binaries. It does not launch Vite, Cargo, or +`tauri dev` when MindCanary starts. + +## Build and install the package + +From the repository root: + +```bash +./scripts/build-arch-package.sh +sudo pacman -U packaging/arch/mindcanary-0.1.6-1-x86_64.pkg.tar.zst +``` + +The package installs the desktop app at `/usr/bin/mindcanary`, the daemon and +native-message host under `/usr/lib/mindcanary`, and a matching Plasma desktop +entry and icon. + +The package uses the same `app.mindcanary.desktop` identity for Tauri's GTK +application ID, its desktop entry, and its installed icons. This gives the +packaged window the MindCanary icon on the tested CachyOS Plasma/Wayland setup. + +Install and enable the per-user daemon once: + +```bash +/usr/lib/mindcanary/mindcanaryd \ + --install-user-service \ + --daemon-path /usr/lib/mindcanary/mindcanaryd \ + --enable-now +``` + +Check the installed service and KDE adapter without reading journal content: + +```bash +mindcanaryctl health +mindcanaryctl capabilities +mindcanaryctl source-status +systemctl --user status mindcanaryd.service +``` + +On KDE Plasma/Wayland, `os.active_idle_duration` should report `available`. +Only complete aligned 15-minute buckets are stored, and each OS signal still +has to be explicitly enabled. + +## KDE Secret Service + +MindCanary asks the desktop Secret Service for its database key. KDE should +provide `org.freedesktop.secrets` through KWallet (`ksecretd`). If both +KWallet and GNOME Keyring are installed and GNOME Keyring claims that D-Bus +name first, the existing encrypted database can appear to have no key. + +Check the active provider without printing any secret: + +```bash +busctl --user status org.freedesktop.secrets +``` + +For a KDE-only session where GNOME Keyring is incorrectly winning, use a +per-user D-Bus activation override for `/usr/bin/ksecretd` and mask the +GNOME Keyring user service/socket. Back up the database and KWallet before +changing providers; never create a replacement key for an existing database. + +## Firefox native host + +The stable native host path is: + +```bash +/usr/lib/mindcanary/mindcanary-native-host \ + --install-manifest \ + --browser firefox \ + --channel development \ + --host-path /usr/lib/mindcanary/mindcanary-native-host +``` + +The development add-on ID is `development@mindcanary.local`. Mozilla release +Firefox removes temporary add-ons on restart and requires Mozilla signatures +for persistent extensions. Use a Mozilla-signed XPI for ordinary Firefox, or +an isolated Developer Edition/Nightly/ESR dogfood profile with +`xpinstall.signatures.required=false` for the unsigned development XPI. + +Build that XPI with: + +```bash +./scripts/build-firefox-development-xpi.sh +``` + +The result is `target/firefox/mindcanary-development.xpi`. + +The CachyOS dogfood workstation keeps the unsigned add-on isolated in Firefox +Developer Edition's `mindcanary-dogfood` profile. Its XPI is installed as +`extensions/development@mindcanary.local.xpi`, and the profile's `user.js` +contains only these development-install preferences: + +```javascript +user_pref("xpinstall.signatures.required", false); +user_pref("extensions.autoDisableScopes", 0); +``` + +Launch it from the application menu as **Firefox Developer Edition — +MindCanary**. The ordinary Firefox release profile is left untouched. Browser +aggregates are collected only while this dedicated Firefox instance is open; +after rebuilding the XPI, close that instance before replacing its installed +XPI and then restart it. + +Firefox and Chrome profiles can collect concurrently. Each profile appears in +**Sources** with independent signal settings; overlapping active/idle duration +is deduplicated per aligned period. diff --git a/docs/development.md b/docs/development.md index 32d5e3b..37c12b9 100644 --- a/docs/development.md +++ b/docs/development.md @@ -216,9 +216,9 @@ scripts/with-dev-profile.sh onboarding -- \ ``` This profile isolation is for daemon, desktop, check-in, and onboarding work. -The existing Chrome extension and native-host registration should be treated as -the dogfood connector path unless Chrome is also launched with the same -isolated config environment and the native-host manifest is reinstalled there. +Existing Firefox and Chrome extensions still target the personal daemon unless +their native-host launch environment is explicitly reconfigured for the +isolated profile. Override only the daemon socket location for narrower development: @@ -366,6 +366,12 @@ separately enabled continuous-scrolling adapter. There is no history permission or incognito access. The adapter is not part of the starter set and does not read routes or page content. +Firefox also declares Mozilla's optional built-in data permissions. General +local aggregates require `technicalAndInteraction`; the feed adapter additionally +requires `websiteActivity`. Collection stays paused when the applicable Firefox +consent is absent. These declarations describe data sent from the extension to +MindCanary's local native host, not to a remote service. + Build the Firefox development package separately: ```bash @@ -374,10 +380,35 @@ pnpm --filter @mindcanary/extension build:firefox It is written to `apps/extension/dist-firefox` with development add-on ID `development@mindcanary.local`. Firefox Manifest V3 uses a background script -rather than Chrome's service-worker manifest entry. Release builds fail closed -until a distinct Firefox release ID is configured in +rather than Chrome's service-worker manifest entry. The release channel uses +the distinct permanent ID `{32ac0030-d005-4ae6-a94e-41977bb53d6b}` from `config/firefox-extension-identities.json`. +Build and validate the reproducible unsigned release package with: + +```bash +pnpm release:firefox +``` + +The release XPI and Mozilla validator report are written under +`target/firefox`. Reviewer reproduction instructions live in +`docs/firefox-review.md`. + +For unlisted Mozilla signing, create personal AMO API credentials, keep them +only in the current shell environment, and run: + +```bash +export WEB_EXT_API_KEY='user:...' +export WEB_EXT_API_SECRET='...' +pnpm release:firefox:sign +unset WEB_EXT_API_KEY WEB_EXT_API_SECRET +``` + +The signing command refuses a dirty tree, archives the exact committed source, +uploads it for review, and downloads the signed XPI under +`target/firefox/signed`. Never add AMO credentials or a `web-ext` user config to +the repository. + The native host name is currently: ```text @@ -467,9 +498,10 @@ cargo run -p mindcanary-native-host -- \ Open `about:debugging#/runtime/this-firefox`, choose "Load Temporary Add-on", and select `apps/extension/dist-firefox/manifest.json`. Temporary installation and the native-message round trip require a running Firefox session and remain -a manual validation. Use only one browser connector at a time for now: browser -aggregate records do not yet carry connector provenance, so overlapping Chrome -and Firefox collection would double-count shared time. +a manual validation. Each extension profile registers its random local source +ID and browser family with the daemon. In **Sources**, give profiles useful +local labels and configure their signals independently; the extension never +reads the browser account name, email address, or profile path. Useful local control commands while the daemon is running: @@ -557,6 +589,16 @@ Build the first ordinary-user Linux package: pnpm package:linux ``` +Build the local Arch/CachyOS package: + +```bash +./scripts/build-arch-package.sh +``` + +The package is written under `packaging/arch/`. It contains release binaries, +not a `tauri dev` launcher. See [Arch and CachyOS Installation](arch-install.md) +for installation, KWallet, KDE Wayland activity, and Firefox notes. + The packaging command builds release versions of `mindcanaryd` and `mindcanary-native-host`, includes them under `/usr/lib/mindcanary`, and produces a Debian package under the Tauri bundle output directory. When the diff --git a/docs/firefox-review.md b/docs/firefox-review.md new file mode 100644 index 0000000..f4a55ce --- /dev/null +++ b/docs/firefox-review.md @@ -0,0 +1,60 @@ +# Firefox Add-on Reviewer Build + +This source archive reproduces the unsigned MindCanary Firefox extension +submitted to Mozilla for unlisted signing. + +## Build environment + +- Linux (the release was prepared on CachyOS; Mozilla's Ubuntu reviewer image + is supported) +- Node.js 22 or newer +- pnpm 10.8.1 through Corepack +- the `7z` command-line client + +No private package registry, web build service, account, or credential is +required. Dependencies come from the public npm registry and are pinned by +`pnpm-lock.yaml`. + +The maintainer signing workflow separately pins Mozilla's `web-ext` 10.5.0; +reviewers do not need it to reproduce the unsigned XPI. + +## Reproduce the extension + +From the source archive root: + +```bash +corepack enable +corepack prepare pnpm@10.8.1 --activate +pnpm install --frozen-lockfile +./scripts/build-firefox-release.sh +``` + +The unsigned package is written to +`target/firefox/mindcanary-0.1.6-firefox-unsigned.xpi`. Its unpacked files are +in `apps/extension/dist-firefox`. + +Vite transpiles and bundles the TypeScript sources. Source maps are included in +the extension. The build does not download or generate runtime code. + +## Functional notes + +MindCanary sends aggregate-only activity counters to the local native-messaging +host `app.mindcanary.collector`. It has no remote service, account, analytics, +or telemetry. It never collects URLs, page titles, page text, search terms, +message content, or raw browsing history. + +The `idle` permission and the two host permissions used by the optional +continuous-scrolling adapter are requested only after explicit user action. +Firefox's built-in `technicalAndInteraction` and `websiteActivity` data +permissions are also optional. Aggregate collection stays paused when local +technical-and-interaction consent is absent, and the scrolling adapter requires +both its narrow host permission and `websiteActivity` consent. The extension +remains useful without the idle and feed-site permissions. + +The signed build requires Firefox 142 or newer so the same built-in consent +declaration is understood on both Firefox desktop and Android validation paths. +MindCanary currently distributes and tests this connector for desktop only. + +The native host and desktop daemon are part of the same public repository, but +they are not required to inspect or reproduce the extension bundle. Without +the native host, the popup reports that the local host is unavailable. diff --git a/docs/known-limitations.md b/docs/known-limitations.md index 215af7a..d09526d 100644 --- a/docs/known-limitations.md +++ b/docs/known-limitations.md @@ -22,13 +22,17 @@ Status: local-alpha draft. ## Platform Limitations -- The first supported packaged target is Linux on Pop!_OS/Ubuntu-like systems. -- GNOME/X11 active/idle duration is the first OS adapter. -- Wayland, KDE, macOS, Windows, mobile, and foreground-app categories require - separate adapters and review. -- Lock/unlock and suspend/resume counts require the GNOME ScreenSaver and - logind event streams. They remain unavailable when those local services do - not connect. +- Packaged Linux builds cover Pop!_OS/Ubuntu-like systems and a locally built + Arch package. The Arch package is validated on CachyOS KDE Plasma/Wayland; + it is not yet published in an Arch repository or the AUR. +- Active/idle duration is supported on GNOME/X11 through Mutter and on KDE + Plasma/Wayland through the compositor's standard `ext_idle_notifier_v1` + input-idle protocol. Other Wayland compositors, macOS, Windows, mobile, and + foreground-app categories still require separate adapters and review. +- Lock/unlock counts require the desktop ScreenSaver event stream; KDE uses + `org.freedesktop.ScreenSaver` and GNOME uses `org.gnome.ScreenSaver`. + Suspend/resume counts require logind. Each signal remains separately opt-in + and unavailable when its local event stream does not connect. - The browser extension is optional. The desktop app remains useful with check-ins only. @@ -47,12 +51,19 @@ Status: local-alpha draft. - The extension retry queue is bounded. If the daemon is unavailable for long enough, old unsent batches are dropped and counted rather than retained indefinitely. -- Firefox builds and native-host manifests are implemented, but temporary - add-on installation and native-message delivery still need real-session - validation. Chrome remains the first packaged connector. -- Use only one browser connector at a time. Records do not yet carry connector - provenance, so overlapping Chrome and Firefox collection would double-count - shared activity. +- Firefox builds and native-message delivery are validated in a real KDE + session. Firefox's temporary add-on flow removes the extension on browser + restart. Persistent dogfood installation therefore requires either a + Mozilla-signed XPI or Firefox Developer Edition/Nightly/ESR with signature + enforcement explicitly disabled; ordinary Firefox release builds do not + accept the unsigned development XPI. +- Each extension installation/profile has a random local connection ID and a + user-owned label. MindCanary deliberately does not infer browser account + names, email addresses, or profile paths. New profiles appear after their + extension first reaches the local service. +- Multiple browser profiles may collect concurrently. Profile-specific counts + are combined, while overlapping browser active/idle duration uses the maximum + value for each aligned period so shared device time is not duplicated. - Continuous-scrolling collection is an optional aggregate-only adapter for `x.com` and `twitter.com`; it is not a general website tracker. diff --git a/docs/roadmap.md b/docs/roadmap.md index 6a38f9b..7a78251 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -8,7 +8,8 @@ app, productivity game, social network, or clinical control panel. - local encrypted history; - optional check-ins and annotations; -- optional aggregate Chrome and GNOME/X11 context; +- optional aggregate Chrome or Firefox browser-profile context; +- optional aggregate GNOME/X11 or KDE Plasma/Wayland computer context; - descriptive sustained-window baseline comparisons; - export, encrypted backup and restore, deletion, and local removal; and - no account, telemetry, cloud dependency, or AI-controlled scoring. @@ -19,12 +20,23 @@ app, productivity game, social network, or clinical control panel. - measure usefulness before and after baseline readiness; - identify tracking fatigue, alarming language, and privacy confusion; - improve onboarding, backup file selection, and recovery-secret handling; and -- stabilize the optional browser distribution path. +- stabilize the optional Chrome and Firefox distribution paths. + +## Deferred Follow-Ups (Keep Visible) + +- **Firefox signing:** create a release Firefox ID, build a source/reviewer + bundle, obtain an unlisted Mozilla-signed XPI, and ship the matching release + native-host manifest. Until then, persistent Firefox dogfooding stays on the + isolated Developer Edition profile. +- **Linux and macOS reach:** after the current GNOME/X11 and KDE + Plasma/Wayland paths settle, prioritize a generic X11 path for + Cinnamon/XFCE, a generic Wayland path where reliable idle/session APIs exist, + RPM-family packaging, and then macOS. Treat Flatpak and mobile as separate + permission/distribution projects rather than implied Linux variants. ## Later Candidates -- additional desktop operating systems; -- Firefox validation; +- the platform paths named in the deferred reach follow-up; - narrow local integrations selected through user research; - retroactive check-ins and specific check-in edit/delete controls; - user-selected schedule-aware baseline groupings; and diff --git a/fixtures/synthetic-browser.jsonl b/fixtures/synthetic-browser.jsonl index 3eead18..5d673cf 100644 --- a/fixtures/synthetic-browser.jsonl +++ b/fixtures/synthetic-browser.jsonl @@ -1,20 +1,20 @@ -{"type":"ingest_aggregate","protocol_version":1,"batch":{"batch_id":"01970000-0000-7000-9000-000000000000","source_instance_id":"01970000-0000-7000-8000-000000000001","sequence":0,"period":{"start":"2026-01-05T09:00:00Z","end":"2026-01-05T09:15:00Z","time_zone":"America/Sao_Paulo"},"metrics":[{"signal":"browser.open_tab_count_mean","value":8.0},{"signal":"browser.open_tab_count_max","value":8.0},{"signal":"browser.tab_switch_count","value":4.0},{"signal":"browser.active_seconds","value":480.0},{"signal":"browser.idle_seconds","value":300.0}]}} -{"type":"ingest_aggregate","protocol_version":1,"batch":{"batch_id":"01970000-0000-7000-9000-000000000001","source_instance_id":"01970000-0000-7000-8000-000000000001","sequence":1,"period":{"start":"2026-01-05T09:15:00Z","end":"2026-01-05T09:30:00Z","time_zone":"America/Sao_Paulo"},"metrics":[{"signal":"browser.open_tab_count_mean","value":9.0},{"signal":"browser.open_tab_count_max","value":9.0},{"signal":"browser.tab_switch_count","value":5.0},{"signal":"browser.active_seconds","value":480.0},{"signal":"browser.idle_seconds","value":300.0}]}} -{"type":"ingest_aggregate","protocol_version":1,"batch":{"batch_id":"01970000-0000-7000-9000-000000000002","source_instance_id":"01970000-0000-7000-8000-000000000001","sequence":2,"period":{"start":"2026-01-05T09:30:00Z","end":"2026-01-05T09:45:00Z","time_zone":"America/Sao_Paulo"},"metrics":[{"signal":"browser.open_tab_count_mean","value":10.0},{"signal":"browser.open_tab_count_max","value":10.0},{"signal":"browser.tab_switch_count","value":6.0},{"signal":"browser.active_seconds","value":480.0},{"signal":"browser.idle_seconds","value":300.0}]}} -{"type":"ingest_aggregate","protocol_version":1,"batch":{"batch_id":"01970000-0000-7000-9000-000000000003","source_instance_id":"01970000-0000-7000-8000-000000000001","sequence":3,"period":{"start":"2026-01-05T09:45:00Z","end":"2026-01-05T10:00:00Z","time_zone":"America/Sao_Paulo"},"metrics":[{"signal":"browser.open_tab_count_mean","value":11.0},{"signal":"browser.open_tab_count_max","value":11.0},{"signal":"browser.tab_switch_count","value":7.0},{"signal":"browser.active_seconds","value":480.0},{"signal":"browser.idle_seconds","value":300.0}]}} -{"type":"ingest_aggregate","protocol_version":1,"batch":{"batch_id":"01970000-0000-7000-9000-000000000004","source_instance_id":"01970000-0000-7000-8000-000000000001","sequence":4,"period":{"start":"2026-01-06T09:00:00Z","end":"2026-01-06T09:15:00Z","time_zone":"America/Sao_Paulo"},"metrics":[{"signal":"browser.open_tab_count_mean","value":8.0},{"signal":"browser.open_tab_count_max","value":8.0},{"signal":"browser.tab_switch_count","value":4.0},{"signal":"browser.active_seconds","value":480.0},{"signal":"browser.idle_seconds","value":300.0}]}} -{"type":"ingest_aggregate","protocol_version":1,"batch":{"batch_id":"01970000-0000-7000-9000-000000000005","source_instance_id":"01970000-0000-7000-8000-000000000001","sequence":5,"period":{"start":"2026-01-06T09:15:00Z","end":"2026-01-06T09:30:00Z","time_zone":"America/Sao_Paulo"},"metrics":[{"signal":"browser.open_tab_count_mean","value":9.0},{"signal":"browser.open_tab_count_max","value":9.0},{"signal":"browser.tab_switch_count","value":5.0},{"signal":"browser.active_seconds","value":480.0},{"signal":"browser.idle_seconds","value":300.0}]}} -{"type":"ingest_aggregate","protocol_version":1,"batch":{"batch_id":"01970000-0000-7000-9000-000000000006","source_instance_id":"01970000-0000-7000-8000-000000000001","sequence":6,"period":{"start":"2026-01-06T09:30:00Z","end":"2026-01-06T09:45:00Z","time_zone":"America/Sao_Paulo"},"metrics":[{"signal":"browser.open_tab_count_mean","value":10.0},{"signal":"browser.open_tab_count_max","value":10.0},{"signal":"browser.tab_switch_count","value":6.0},{"signal":"browser.active_seconds","value":480.0},{"signal":"browser.idle_seconds","value":300.0}]}} -{"type":"ingest_aggregate","protocol_version":1,"batch":{"batch_id":"01970000-0000-7000-9000-000000000007","source_instance_id":"01970000-0000-7000-8000-000000000001","sequence":7,"period":{"start":"2026-01-06T09:45:00Z","end":"2026-01-06T10:00:00Z","time_zone":"America/Sao_Paulo"},"metrics":[{"signal":"browser.open_tab_count_mean","value":11.0},{"signal":"browser.open_tab_count_max","value":11.0},{"signal":"browser.tab_switch_count","value":7.0},{"signal":"browser.active_seconds","value":480.0},{"signal":"browser.idle_seconds","value":300.0}]}} -{"type":"ingest_aggregate","protocol_version":1,"batch":{"batch_id":"01970000-0000-7000-9000-000000000008","source_instance_id":"01970000-0000-7000-8000-000000000001","sequence":8,"period":{"start":"2026-01-07T09:00:00Z","end":"2026-01-07T09:15:00Z","time_zone":"America/Sao_Paulo"},"metrics":[{"signal":"browser.open_tab_count_mean","value":8.0},{"signal":"browser.open_tab_count_max","value":8.0},{"signal":"browser.tab_switch_count","value":4.0},{"signal":"browser.active_seconds","value":480.0},{"signal":"browser.idle_seconds","value":300.0}]}} -{"type":"ingest_aggregate","protocol_version":1,"batch":{"batch_id":"01970000-0000-7000-9000-000000000009","source_instance_id":"01970000-0000-7000-8000-000000000001","sequence":9,"period":{"start":"2026-01-07T09:15:00Z","end":"2026-01-07T09:30:00Z","time_zone":"America/Sao_Paulo"},"metrics":[{"signal":"browser.open_tab_count_mean","value":9.0},{"signal":"browser.open_tab_count_max","value":9.0},{"signal":"browser.tab_switch_count","value":5.0},{"signal":"browser.active_seconds","value":480.0},{"signal":"browser.idle_seconds","value":300.0}]}} -{"type":"ingest_aggregate","protocol_version":1,"batch":{"batch_id":"01970000-0000-7000-9000-00000000000a","source_instance_id":"01970000-0000-7000-8000-000000000001","sequence":10,"period":{"start":"2026-01-07T09:30:00Z","end":"2026-01-07T09:45:00Z","time_zone":"America/Sao_Paulo"},"metrics":[{"signal":"browser.open_tab_count_mean","value":10.0},{"signal":"browser.open_tab_count_max","value":10.0},{"signal":"browser.tab_switch_count","value":6.0},{"signal":"browser.active_seconds","value":480.0},{"signal":"browser.idle_seconds","value":300.0}]}} -{"type":"ingest_aggregate","protocol_version":1,"batch":{"batch_id":"01970000-0000-7000-9000-00000000000b","source_instance_id":"01970000-0000-7000-8000-000000000001","sequence":11,"period":{"start":"2026-01-07T09:45:00Z","end":"2026-01-07T10:00:00Z","time_zone":"America/Sao_Paulo"},"metrics":[{"signal":"browser.open_tab_count_mean","value":11.0},{"signal":"browser.open_tab_count_max","value":11.0},{"signal":"browser.tab_switch_count","value":7.0},{"signal":"browser.active_seconds","value":480.0},{"signal":"browser.idle_seconds","value":300.0}]}} -{"type":"ingest_aggregate","protocol_version":1,"batch":{"batch_id":"01970000-0000-7000-9000-00000000000c","source_instance_id":"01970000-0000-7000-8000-000000000001","sequence":12,"period":{"start":"2026-01-08T09:00:00Z","end":"2026-01-08T09:15:00Z","time_zone":"America/Sao_Paulo"},"metrics":[{"signal":"browser.open_tab_count_mean","value":22.0},{"signal":"browser.open_tab_count_max","value":22.0},{"signal":"browser.tab_switch_count","value":12.0},{"signal":"browser.active_seconds","value":780.0},{"signal":"browser.idle_seconds","value":90.0}]}} -{"type":"ingest_aggregate","protocol_version":1,"batch":{"batch_id":"01970000-0000-7000-9000-00000000000d","source_instance_id":"01970000-0000-7000-8000-000000000001","sequence":13,"period":{"start":"2026-01-08T09:15:00Z","end":"2026-01-08T09:30:00Z","time_zone":"America/Sao_Paulo"},"metrics":[{"signal":"browser.open_tab_count_mean","value":23.0},{"signal":"browser.open_tab_count_max","value":23.0},{"signal":"browser.tab_switch_count","value":14.0},{"signal":"browser.active_seconds","value":780.0},{"signal":"browser.idle_seconds","value":90.0}]}} -{"type":"ingest_aggregate","protocol_version":1,"batch":{"batch_id":"01970000-0000-7000-9000-00000000000e","source_instance_id":"01970000-0000-7000-8000-000000000001","sequence":14,"period":{"start":"2026-01-08T09:30:00Z","end":"2026-01-08T09:45:00Z","time_zone":"America/Sao_Paulo"},"metrics":[{"signal":"browser.open_tab_count_mean","value":24.0},{"signal":"browser.open_tab_count_max","value":24.0},{"signal":"browser.tab_switch_count","value":16.0},{"signal":"browser.active_seconds","value":780.0},{"signal":"browser.idle_seconds","value":90.0}]}} -{"type":"ingest_aggregate","protocol_version":1,"batch":{"batch_id":"01970000-0000-7000-9000-00000000000f","source_instance_id":"01970000-0000-7000-8000-000000000001","sequence":15,"period":{"start":"2026-01-08T09:45:00Z","end":"2026-01-08T10:00:00Z","time_zone":"America/Sao_Paulo"},"metrics":[{"signal":"browser.open_tab_count_mean","value":25.0},{"signal":"browser.open_tab_count_max","value":25.0},{"signal":"browser.tab_switch_count","value":18.0},{"signal":"browser.active_seconds","value":780.0},{"signal":"browser.idle_seconds","value":90.0}]}} -{"type":"ingest_aggregate","protocol_version":1,"batch":{"batch_id":"01970000-0000-7000-9000-000000000010","source_instance_id":"01970000-0000-7000-8000-000000000001","sequence":16,"period":{"start":"2026-01-09T09:00:00Z","end":"2026-01-09T09:15:00Z","time_zone":"America/Sao_Paulo"},"metrics":[{"signal":"browser.open_tab_count_mean","value":24.0},{"signal":"browser.open_tab_count_max","value":24.0},{"signal":"browser.tab_switch_count","value":15.0},{"signal":"browser.active_seconds","value":780.0},{"signal":"browser.idle_seconds","value":90.0}]}} -{"type":"ingest_aggregate","protocol_version":1,"batch":{"batch_id":"01970000-0000-7000-9000-000000000011","source_instance_id":"01970000-0000-7000-8000-000000000001","sequence":17,"period":{"start":"2026-01-09T09:15:00Z","end":"2026-01-09T09:30:00Z","time_zone":"America/Sao_Paulo"},"metrics":[{"signal":"browser.open_tab_count_mean","value":25.0},{"signal":"browser.open_tab_count_max","value":25.0},{"signal":"browser.tab_switch_count","value":17.0},{"signal":"browser.active_seconds","value":780.0},{"signal":"browser.idle_seconds","value":90.0}]}} -{"type":"ingest_aggregate","protocol_version":1,"batch":{"batch_id":"01970000-0000-7000-9000-000000000012","source_instance_id":"01970000-0000-7000-8000-000000000001","sequence":18,"period":{"start":"2026-01-09T09:30:00Z","end":"2026-01-09T09:45:00Z","time_zone":"America/Sao_Paulo"},"metrics":[{"signal":"browser.open_tab_count_mean","value":26.0},{"signal":"browser.open_tab_count_max","value":26.0},{"signal":"browser.tab_switch_count","value":19.0},{"signal":"browser.active_seconds","value":780.0},{"signal":"browser.idle_seconds","value":90.0}]}} -{"type":"ingest_aggregate","protocol_version":1,"batch":{"batch_id":"01970000-0000-7000-9000-000000000013","source_instance_id":"01970000-0000-7000-8000-000000000001","sequence":19,"period":{"start":"2026-01-09T09:45:00Z","end":"2026-01-09T10:00:00Z","time_zone":"America/Sao_Paulo"},"metrics":[{"signal":"browser.open_tab_count_mean","value":27.0},{"signal":"browser.open_tab_count_max","value":27.0},{"signal":"browser.tab_switch_count","value":21.0},{"signal":"browser.active_seconds","value":780.0},{"signal":"browser.idle_seconds","value":90.0}]}} +{"type":"ingest_aggregate","protocol_version":1,"connector":null,"batch":{"batch_id":"01970000-0000-7000-9000-000000000000","source_instance_id":"01970000-0000-7000-8000-000000000001","sequence":0,"period":{"start":"2026-01-05T09:00:00Z","end":"2026-01-05T09:15:00Z","time_zone":"America/Sao_Paulo"},"metrics":[{"signal":"browser.open_tab_count_mean","value":8.0},{"signal":"browser.open_tab_count_max","value":8.0},{"signal":"browser.tab_switch_count","value":4.0},{"signal":"browser.active_seconds","value":480.0},{"signal":"browser.idle_seconds","value":300.0}]}} +{"type":"ingest_aggregate","protocol_version":1,"connector":null,"batch":{"batch_id":"01970000-0000-7000-9000-000000000001","source_instance_id":"01970000-0000-7000-8000-000000000001","sequence":1,"period":{"start":"2026-01-05T09:15:00Z","end":"2026-01-05T09:30:00Z","time_zone":"America/Sao_Paulo"},"metrics":[{"signal":"browser.open_tab_count_mean","value":9.0},{"signal":"browser.open_tab_count_max","value":9.0},{"signal":"browser.tab_switch_count","value":5.0},{"signal":"browser.active_seconds","value":480.0},{"signal":"browser.idle_seconds","value":300.0}]}} +{"type":"ingest_aggregate","protocol_version":1,"connector":null,"batch":{"batch_id":"01970000-0000-7000-9000-000000000002","source_instance_id":"01970000-0000-7000-8000-000000000001","sequence":2,"period":{"start":"2026-01-05T09:30:00Z","end":"2026-01-05T09:45:00Z","time_zone":"America/Sao_Paulo"},"metrics":[{"signal":"browser.open_tab_count_mean","value":10.0},{"signal":"browser.open_tab_count_max","value":10.0},{"signal":"browser.tab_switch_count","value":6.0},{"signal":"browser.active_seconds","value":480.0},{"signal":"browser.idle_seconds","value":300.0}]}} +{"type":"ingest_aggregate","protocol_version":1,"connector":null,"batch":{"batch_id":"01970000-0000-7000-9000-000000000003","source_instance_id":"01970000-0000-7000-8000-000000000001","sequence":3,"period":{"start":"2026-01-05T09:45:00Z","end":"2026-01-05T10:00:00Z","time_zone":"America/Sao_Paulo"},"metrics":[{"signal":"browser.open_tab_count_mean","value":11.0},{"signal":"browser.open_tab_count_max","value":11.0},{"signal":"browser.tab_switch_count","value":7.0},{"signal":"browser.active_seconds","value":480.0},{"signal":"browser.idle_seconds","value":300.0}]}} +{"type":"ingest_aggregate","protocol_version":1,"connector":null,"batch":{"batch_id":"01970000-0000-7000-9000-000000000004","source_instance_id":"01970000-0000-7000-8000-000000000001","sequence":4,"period":{"start":"2026-01-06T09:00:00Z","end":"2026-01-06T09:15:00Z","time_zone":"America/Sao_Paulo"},"metrics":[{"signal":"browser.open_tab_count_mean","value":8.0},{"signal":"browser.open_tab_count_max","value":8.0},{"signal":"browser.tab_switch_count","value":4.0},{"signal":"browser.active_seconds","value":480.0},{"signal":"browser.idle_seconds","value":300.0}]}} +{"type":"ingest_aggregate","protocol_version":1,"connector":null,"batch":{"batch_id":"01970000-0000-7000-9000-000000000005","source_instance_id":"01970000-0000-7000-8000-000000000001","sequence":5,"period":{"start":"2026-01-06T09:15:00Z","end":"2026-01-06T09:30:00Z","time_zone":"America/Sao_Paulo"},"metrics":[{"signal":"browser.open_tab_count_mean","value":9.0},{"signal":"browser.open_tab_count_max","value":9.0},{"signal":"browser.tab_switch_count","value":5.0},{"signal":"browser.active_seconds","value":480.0},{"signal":"browser.idle_seconds","value":300.0}]}} +{"type":"ingest_aggregate","protocol_version":1,"connector":null,"batch":{"batch_id":"01970000-0000-7000-9000-000000000006","source_instance_id":"01970000-0000-7000-8000-000000000001","sequence":6,"period":{"start":"2026-01-06T09:30:00Z","end":"2026-01-06T09:45:00Z","time_zone":"America/Sao_Paulo"},"metrics":[{"signal":"browser.open_tab_count_mean","value":10.0},{"signal":"browser.open_tab_count_max","value":10.0},{"signal":"browser.tab_switch_count","value":6.0},{"signal":"browser.active_seconds","value":480.0},{"signal":"browser.idle_seconds","value":300.0}]}} +{"type":"ingest_aggregate","protocol_version":1,"connector":null,"batch":{"batch_id":"01970000-0000-7000-9000-000000000007","source_instance_id":"01970000-0000-7000-8000-000000000001","sequence":7,"period":{"start":"2026-01-06T09:45:00Z","end":"2026-01-06T10:00:00Z","time_zone":"America/Sao_Paulo"},"metrics":[{"signal":"browser.open_tab_count_mean","value":11.0},{"signal":"browser.open_tab_count_max","value":11.0},{"signal":"browser.tab_switch_count","value":7.0},{"signal":"browser.active_seconds","value":480.0},{"signal":"browser.idle_seconds","value":300.0}]}} +{"type":"ingest_aggregate","protocol_version":1,"connector":null,"batch":{"batch_id":"01970000-0000-7000-9000-000000000008","source_instance_id":"01970000-0000-7000-8000-000000000001","sequence":8,"period":{"start":"2026-01-07T09:00:00Z","end":"2026-01-07T09:15:00Z","time_zone":"America/Sao_Paulo"},"metrics":[{"signal":"browser.open_tab_count_mean","value":8.0},{"signal":"browser.open_tab_count_max","value":8.0},{"signal":"browser.tab_switch_count","value":4.0},{"signal":"browser.active_seconds","value":480.0},{"signal":"browser.idle_seconds","value":300.0}]}} +{"type":"ingest_aggregate","protocol_version":1,"connector":null,"batch":{"batch_id":"01970000-0000-7000-9000-000000000009","source_instance_id":"01970000-0000-7000-8000-000000000001","sequence":9,"period":{"start":"2026-01-07T09:15:00Z","end":"2026-01-07T09:30:00Z","time_zone":"America/Sao_Paulo"},"metrics":[{"signal":"browser.open_tab_count_mean","value":9.0},{"signal":"browser.open_tab_count_max","value":9.0},{"signal":"browser.tab_switch_count","value":5.0},{"signal":"browser.active_seconds","value":480.0},{"signal":"browser.idle_seconds","value":300.0}]}} +{"type":"ingest_aggregate","protocol_version":1,"connector":null,"batch":{"batch_id":"01970000-0000-7000-9000-00000000000a","source_instance_id":"01970000-0000-7000-8000-000000000001","sequence":10,"period":{"start":"2026-01-07T09:30:00Z","end":"2026-01-07T09:45:00Z","time_zone":"America/Sao_Paulo"},"metrics":[{"signal":"browser.open_tab_count_mean","value":10.0},{"signal":"browser.open_tab_count_max","value":10.0},{"signal":"browser.tab_switch_count","value":6.0},{"signal":"browser.active_seconds","value":480.0},{"signal":"browser.idle_seconds","value":300.0}]}} +{"type":"ingest_aggregate","protocol_version":1,"connector":null,"batch":{"batch_id":"01970000-0000-7000-9000-00000000000b","source_instance_id":"01970000-0000-7000-8000-000000000001","sequence":11,"period":{"start":"2026-01-07T09:45:00Z","end":"2026-01-07T10:00:00Z","time_zone":"America/Sao_Paulo"},"metrics":[{"signal":"browser.open_tab_count_mean","value":11.0},{"signal":"browser.open_tab_count_max","value":11.0},{"signal":"browser.tab_switch_count","value":7.0},{"signal":"browser.active_seconds","value":480.0},{"signal":"browser.idle_seconds","value":300.0}]}} +{"type":"ingest_aggregate","protocol_version":1,"connector":null,"batch":{"batch_id":"01970000-0000-7000-9000-00000000000c","source_instance_id":"01970000-0000-7000-8000-000000000001","sequence":12,"period":{"start":"2026-01-08T09:00:00Z","end":"2026-01-08T09:15:00Z","time_zone":"America/Sao_Paulo"},"metrics":[{"signal":"browser.open_tab_count_mean","value":22.0},{"signal":"browser.open_tab_count_max","value":22.0},{"signal":"browser.tab_switch_count","value":12.0},{"signal":"browser.active_seconds","value":780.0},{"signal":"browser.idle_seconds","value":90.0}]}} +{"type":"ingest_aggregate","protocol_version":1,"connector":null,"batch":{"batch_id":"01970000-0000-7000-9000-00000000000d","source_instance_id":"01970000-0000-7000-8000-000000000001","sequence":13,"period":{"start":"2026-01-08T09:15:00Z","end":"2026-01-08T09:30:00Z","time_zone":"America/Sao_Paulo"},"metrics":[{"signal":"browser.open_tab_count_mean","value":23.0},{"signal":"browser.open_tab_count_max","value":23.0},{"signal":"browser.tab_switch_count","value":14.0},{"signal":"browser.active_seconds","value":780.0},{"signal":"browser.idle_seconds","value":90.0}]}} +{"type":"ingest_aggregate","protocol_version":1,"connector":null,"batch":{"batch_id":"01970000-0000-7000-9000-00000000000e","source_instance_id":"01970000-0000-7000-8000-000000000001","sequence":14,"period":{"start":"2026-01-08T09:30:00Z","end":"2026-01-08T09:45:00Z","time_zone":"America/Sao_Paulo"},"metrics":[{"signal":"browser.open_tab_count_mean","value":24.0},{"signal":"browser.open_tab_count_max","value":24.0},{"signal":"browser.tab_switch_count","value":16.0},{"signal":"browser.active_seconds","value":780.0},{"signal":"browser.idle_seconds","value":90.0}]}} +{"type":"ingest_aggregate","protocol_version":1,"connector":null,"batch":{"batch_id":"01970000-0000-7000-9000-00000000000f","source_instance_id":"01970000-0000-7000-8000-000000000001","sequence":15,"period":{"start":"2026-01-08T09:45:00Z","end":"2026-01-08T10:00:00Z","time_zone":"America/Sao_Paulo"},"metrics":[{"signal":"browser.open_tab_count_mean","value":25.0},{"signal":"browser.open_tab_count_max","value":25.0},{"signal":"browser.tab_switch_count","value":18.0},{"signal":"browser.active_seconds","value":780.0},{"signal":"browser.idle_seconds","value":90.0}]}} +{"type":"ingest_aggregate","protocol_version":1,"connector":null,"batch":{"batch_id":"01970000-0000-7000-9000-000000000010","source_instance_id":"01970000-0000-7000-8000-000000000001","sequence":16,"period":{"start":"2026-01-09T09:00:00Z","end":"2026-01-09T09:15:00Z","time_zone":"America/Sao_Paulo"},"metrics":[{"signal":"browser.open_tab_count_mean","value":24.0},{"signal":"browser.open_tab_count_max","value":24.0},{"signal":"browser.tab_switch_count","value":15.0},{"signal":"browser.active_seconds","value":780.0},{"signal":"browser.idle_seconds","value":90.0}]}} +{"type":"ingest_aggregate","protocol_version":1,"connector":null,"batch":{"batch_id":"01970000-0000-7000-9000-000000000011","source_instance_id":"01970000-0000-7000-8000-000000000001","sequence":17,"period":{"start":"2026-01-09T09:15:00Z","end":"2026-01-09T09:30:00Z","time_zone":"America/Sao_Paulo"},"metrics":[{"signal":"browser.open_tab_count_mean","value":25.0},{"signal":"browser.open_tab_count_max","value":25.0},{"signal":"browser.tab_switch_count","value":17.0},{"signal":"browser.active_seconds","value":780.0},{"signal":"browser.idle_seconds","value":90.0}]}} +{"type":"ingest_aggregate","protocol_version":1,"connector":null,"batch":{"batch_id":"01970000-0000-7000-9000-000000000012","source_instance_id":"01970000-0000-7000-8000-000000000001","sequence":18,"period":{"start":"2026-01-09T09:30:00Z","end":"2026-01-09T09:45:00Z","time_zone":"America/Sao_Paulo"},"metrics":[{"signal":"browser.open_tab_count_mean","value":26.0},{"signal":"browser.open_tab_count_max","value":26.0},{"signal":"browser.tab_switch_count","value":19.0},{"signal":"browser.active_seconds","value":780.0},{"signal":"browser.idle_seconds","value":90.0}]}} +{"type":"ingest_aggregate","protocol_version":1,"connector":null,"batch":{"batch_id":"01970000-0000-7000-9000-000000000013","source_instance_id":"01970000-0000-7000-8000-000000000001","sequence":19,"period":{"start":"2026-01-09T09:45:00Z","end":"2026-01-09T10:00:00Z","time_zone":"America/Sao_Paulo"},"metrics":[{"signal":"browser.open_tab_count_mean","value":27.0},{"signal":"browser.open_tab_count_max","value":27.0},{"signal":"browser.tab_switch_count","value":21.0},{"signal":"browser.active_seconds","value":780.0},{"signal":"browser.idle_seconds","value":90.0}]}} diff --git a/package.json b/package.json index abd1476..91819f0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mindcanary", - "version": "0.1.5", + "version": "0.1.6", "license": "AGPL-3.0-or-later", "private": true, "packageManager": "pnpm@10.8.1", @@ -25,6 +25,8 @@ "protocol:check": "bash scripts/check-protocol.sh", "reliability:local": "bash scripts/reliability-local.sh", "release:alpha": "bash scripts/prepare-linux-alpha-release.sh", + "release:firefox": "bash scripts/build-firefox-release.sh", + "release:firefox:sign": "bash scripts/sign-firefox-release.sh", "release:snapshot": "bash scripts/release-snapshot.sh", "smoke:chrome": "bash scripts/smoke-stable-chrome-connection.sh", "test": "pnpm -r --if-present test", diff --git a/packages/protocol-ts/package.json b/packages/protocol-ts/package.json index a837f9a..1f78a18 100644 --- a/packages/protocol-ts/package.json +++ b/packages/protocol-ts/package.json @@ -1,6 +1,6 @@ { "name": "@mindcanary/protocol", - "version": "0.1.3", + "version": "0.1.6", "license": "AGPL-3.0-or-later", "private": true, "type": "module", diff --git a/packages/protocol-ts/src/generated.ts b/packages/protocol-ts/src/generated.ts index b8e6eb1..a294a67 100644 --- a/packages/protocol-ts/src/generated.ts +++ b/packages/protocol-ts/src/generated.ts @@ -15,6 +15,8 @@ export const MAX_CONTEXT_TAGS = 8 as const; export const MAX_ANNOTATION_TEXT_BYTES = 1000 as const; +export const MAX_BROWSER_CONNECTION_LABEL_BYTES = 80 as const; + export const SIGNAL_IDS = [ "browser.tab_switch_count", "browser.open_tab_count_min", @@ -82,6 +84,13 @@ export interface AggregateBatch { metrics: Metric[]; } +export type BrowserFamily = "chrome" | "firefox"; + +export interface BrowserConnectionIdentity { + source_instance_id: string; + browser: BrowserFamily; +} + export interface CheckInRecord { check_in_id: string; occurred_at: string; @@ -281,12 +290,23 @@ export interface PlatformCapabilities { capabilities: PlatformCapability[]; } +export interface BrowserConnection { + source_instance_id: string; + browser: BrowserFamily; + label: string; + first_seen_at: string; + last_seen_at: string; + last_received_at?: string | null; + settings: SignalCollectionSetting[]; +} + export type ProtocolRequest = | { type: "health"; protocol_version: typeof PROTOCOL_VERSION } | { type: "get_source_status"; protocol_version: typeof PROTOCOL_VERSION } | { type: "ingest_aggregate"; protocol_version: typeof PROTOCOL_VERSION; + connector?: BrowserConnectionIdentity | null; batch: AggregateBatch; } | { @@ -334,6 +354,24 @@ export type ProtocolRequest = | { type: "get_collection_settings"; protocol_version: typeof PROTOCOL_VERSION; + connector?: BrowserConnectionIdentity | null; + } + | { + type: "get_browser_connections"; + protocol_version: typeof PROTOCOL_VERSION; + } + | { + type: "set_browser_connection_label"; + protocol_version: typeof PROTOCOL_VERSION; + source_instance_id: string; + label: string; + } + | { + type: "set_browser_signal_collection"; + protocol_version: typeof PROTOCOL_VERSION; + source_instance_id: string; + signal: SignalId; + enabled: boolean; } | { type: "get_platform_capabilities"; @@ -507,6 +545,12 @@ export type ProtocolResponse = protocol_version: typeof PROTOCOL_VERSION; settings: SignalCollectionSetting[]; } + | { + type: "browser_connections"; + protocol_version: typeof PROTOCOL_VERSION; + generated_at: string; + connections: BrowserConnection[]; + } | { type: "platform_capabilities"; protocol_version: typeof PROTOCOL_VERSION; diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD new file mode 100644 index 0000000..d51fd47 --- /dev/null +++ b/packaging/arch/PKGBUILD @@ -0,0 +1,60 @@ +pkgname=mindcanary +pkgver=0.1.6 +pkgrel=1 +pkgdesc="Private local journal for understanding personal rhythms" +arch=('x86_64') +url="https://github.com/ob1-s/mindcanary" +license=('AGPL-3.0-or-later') +depends=('openssl' 'webkit2gtk-4.1') +makedepends=('cargo' 'nodejs' 'pnpm') +optdepends=( + 'kwallet: persistent Secret Service storage on KDE Plasma' + 'gnome-keyring: persistent Secret Service storage on GNOME' +) +# CachyOS injects linker flags that break the vendored SQLCipher and D-Bus +# static archives. Rust release profiles and the upstream C build scripts still +# apply their own optimization and hardening defaults. +options=('!debug' '!buildflags') +source=("${pkgname}-${pkgver}.tar.gz") +sha256sums=("${MINDCANARY_SOURCE_SHA256:-SKIP}") + +build() { + cd "${srcdir}/${pkgname}-${pkgver}" + local cargo_home="${CARGO_HOME:-${HOME}/.cargo}" + export RUSTFLAGS="--remap-path-prefix=${srcdir}=. --remap-path-prefix=${cargo_home}/registry=/usr/src/cargo/registry" + pnpm install --frozen-lockfile + pnpm --filter @mindcanary/desktop tauri build --no-bundle --ci + cargo build --release --locked -p mindcanary-client --bin mindcanaryctl +} + +check() { + cd "${srcdir}/${pkgname}-${pkgver}" + local cargo_home="${CARGO_HOME:-${HOME}/.cargo}" + export RUSTFLAGS="--remap-path-prefix=${srcdir}=. --remap-path-prefix=${cargo_home}/registry=/usr/src/cargo/registry" + cargo test --locked -p mindcanary-storage -p mindcanary-native-host -p mindcanaryd +} + +package() { + local source_root="${srcdir}/${pkgname}-${pkgver}" + + install -Dm755 \ + "${source_root}/apps/desktop/src-tauri/target/release/mindcanary-desktop" \ + "${pkgdir}/usr/bin/mindcanary" + install -Dm755 "${source_root}/target/release/mindcanaryd" \ + "${pkgdir}/usr/lib/mindcanary/mindcanaryd" + install -Dm755 "${source_root}/target/release/mindcanary-native-host" \ + "${pkgdir}/usr/lib/mindcanary/mindcanary-native-host" + install -Dm755 "${source_root}/target/release/mindcanaryctl" \ + "${pkgdir}/usr/bin/mindcanaryctl" + + install -Dm644 "${source_root}/packaging/arch/app.mindcanary.desktop.desktop" \ + "${pkgdir}/usr/share/applications/app.mindcanary.desktop.desktop" + install -Dm644 "${source_root}/apps/desktop/src-tauri/icons/app-icon.svg" \ + "${pkgdir}/usr/share/icons/hicolor/scalable/apps/app.mindcanary.desktop.svg" + install -Dm644 "${source_root}/apps/desktop/src-tauri/icons/icon.png" \ + "${pkgdir}/usr/share/icons/hicolor/512x512/apps/app.mindcanary.desktop.png" + install -Dm644 "${source_root}/LICENSE" \ + "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" + install -Dm644 "${source_root}/NOTICE" \ + "${pkgdir}/usr/share/licenses/${pkgname}/NOTICE" +} diff --git a/packaging/arch/app.mindcanary.desktop.desktop b/packaging/arch/app.mindcanary.desktop.desktop new file mode 100644 index 0000000..8027df5 --- /dev/null +++ b/packaging/arch/app.mindcanary.desktop.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Type=Application +Name=MindCanary +Comment=Private local rhythms journal +Exec=/usr/bin/mindcanary +Icon=app.mindcanary.desktop +Terminal=false +Categories=Utility; +StartupNotify=true +StartupWMClass=app.mindcanary.desktop diff --git a/scripts/build-arch-package.sh b/scripts/build-arch-package.sh new file mode 100755 index 0000000..1ae8abe --- /dev/null +++ b/scripts/build-arch-package.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +package_dir="$project_root/packaging/arch" +package_version="$(sed -n 's/^version = "\([^"]*\)"/\1/p' "$project_root/Cargo.toml" | head -n 1)" +source_archive="$package_dir/mindcanary-$package_version.tar.gz" + +cd "$project_root" + +if [[ -z "$package_version" ]]; then + echo "Could not read the workspace package version" >&2 + exit 1 +fi + +if ! git diff --quiet --ignore-submodules -- || + ! git diff --cached --quiet --ignore-submodules -- || + [[ -n "$(git ls-files --others --exclude-standard)" ]]; then + echo "Arch release packages must be built from a clean committed tree" >&2 + exit 1 +fi + +git archive \ + --format=tar \ + --prefix="mindcanary-$package_version/" \ + HEAD | gzip -n >"$source_archive" + +MINDCANARY_SOURCE_SHA256="$(sha256sum "$source_archive" | cut -d ' ' -f 1)" +export MINDCANARY_SOURCE_SHA256 +SOURCE_DATE_EPOCH="$(git show -s --format=%ct HEAD)" +export SOURCE_DATE_EPOCH + +cd "$package_dir" +makepkg --force --cleanbuild --clean --noconfirm diff --git a/scripts/build-firefox-development-xpi.sh b/scripts/build-firefox-development-xpi.sh new file mode 100755 index 0000000..cca823d --- /dev/null +++ b/scripts/build-firefox-development-xpi.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +extension_dir="$project_root/apps/extension/dist-firefox" +output_dir="$project_root/target/firefox" +output_path="$output_dir/mindcanary-development.xpi" +temporary_path="$output_path.tmp" + +cd "$project_root" +pnpm --filter @mindcanary/extension build:firefox + +extension_id="$(jq -r '.browser_specific_settings.gecko.id' "$extension_dir/manifest.json")" +if [[ "$extension_id" != "development@mindcanary.local" ]]; then + echo "Unexpected Firefox development extension ID: $extension_id" >&2 + exit 1 +fi + +mkdir -p "$output_dir" +(cd "$extension_dir" && bsdtar --format zip -cf "$temporary_path" *) +mv -f "$temporary_path" "$output_path" +printf 'Built Firefox development XPI: %s\n' "$output_path" diff --git a/scripts/build-firefox-release.sh b/scripts/build-firefox-release.sh new file mode 100755 index 0000000..ca31196 --- /dev/null +++ b/scripts/build-firefox-release.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +extension_dir="$project_root/apps/extension/dist-firefox" +output_dir="$project_root/target/firefox" +version="$(jq -r '.version' "$project_root/apps/extension/manifest.base.json")" +output_path="$output_dir/mindcanary-$version-firefox-unsigned.xpi" +temporary_path="$output_path.tmp" +file_list="$output_dir/firefox-release-files.txt" + +cd "$project_root" +mkdir -p "$output_dir" +pnpm --filter @mindcanary/extension build:firefox:release +pnpm --silent dlx web-ext@10.5.0 lint \ + --source-dir "$extension_dir" \ + --output json >"$output_dir/lint.json" + +extension_id="$(jq -r '.browser_specific_settings.gecko.id' "$extension_dir/manifest.json")" +expected_id="$(jq -r '.release.extension_id' "$project_root/config/firefox-extension-identities.json")" +if [[ "$extension_id" != "$expected_id" || "$expected_id" == "null" ]]; then + echo "Unexpected Firefox release extension ID: $extension_id" >&2 + exit 1 +fi + +rm -f "$temporary_path" +find "$extension_dir" -exec touch -h -d '@315532800' {} + +( + cd "$extension_dir" + find . -type f -printf '%P\n' | LC_ALL=C sort >"$file_list" + TZ=UTC 7z a \ + -tzip \ + -mx=9 \ + -mmt=off \ + -mtc=off \ + -mtm=off \ + -mta=off \ + "$temporary_path" \ + "@$file_list" >/dev/null +) +mv -f "$temporary_path" "$output_path" +printf 'Built Firefox release XPI: %s\n' "$output_path" diff --git a/scripts/sign-firefox-release.sh b/scripts/sign-firefox-release.sh new file mode 100755 index 0000000..26af071 --- /dev/null +++ b/scripts/sign-firefox-release.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +output_dir="$project_root/target/firefox" +signed_dir="$output_dir/signed" +version="$(jq -r '.version' "$project_root/apps/extension/manifest.base.json")" +source_archive="$output_dir/mindcanary-$version-firefox-source.zip" + +cd "$project_root" + +if ! git diff --quiet --ignore-submodules -- || + ! git diff --cached --quiet --ignore-submodules -- || + [[ -n "$(git ls-files --others --exclude-standard)" ]]; then + echo "Firefox signing must use a clean committed tree" >&2 + exit 1 +fi + +./scripts/build-firefox-release.sh +mkdir -p "$output_dir" "$signed_dir" +git archive --format=zip --output="$source_archive" HEAD +printf 'Built Firefox reviewer source: %s\n' "$source_archive" + +if [[ -z "${WEB_EXT_API_KEY:-}" || -z "${WEB_EXT_API_SECRET:-}" ]]; then + echo "Mozilla signing requires WEB_EXT_API_KEY and WEB_EXT_API_SECRET" >&2 + echo "Create them in the AMO Developer Hub, export them in this shell, and rerun this command." >&2 + exit 2 +fi + +pnpm --silent dlx web-ext@10.5.0 sign \ + --source-dir "$project_root/apps/extension/dist-firefox" \ + --artifacts-dir "$signed_dir" \ + --channel unlisted \ + --upload-source-code "$source_archive" \ + --timeout 900000 + +printf 'Mozilla signed artifacts:\n' +find "$signed_dir" -maxdepth 1 -type f -name '*.xpi' -print From 6e3d3304c3c0752a9741d33cdfea3920ffda3947 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 22 Jul 2026 16:03:58 -0300 Subject: [PATCH 2/2] Resume interrupted Firefox signing downloads --- docs/development.md | 11 ++++ package.json | 1 + scripts/download-firefox-release.sh | 92 +++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+) create mode 100755 scripts/download-firefox-release.sh diff --git a/docs/development.md b/docs/development.md index 37c12b9..86fc530 100644 --- a/docs/development.md +++ b/docs/development.md @@ -409,6 +409,17 @@ uploads it for review, and downloads the signed XPI under `target/firefox/signed`. Never add AMO credentials or a `web-ext` user config to the repository. +If the connection drops after Mozilla accepts the version, do not change or +resubmit that version. With the same credentials still in the environment, use +the resumable download command instead: + +```bash +pnpm release:firefox:download +``` + +It polls the authenticated AMO version endpoint, downloads the signed XPI when +review completes, and verifies Mozilla's published SHA-256 digest when present. + The native host name is currently: ```text diff --git a/package.json b/package.json index 91819f0..a6813b2 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "reliability:local": "bash scripts/reliability-local.sh", "release:alpha": "bash scripts/prepare-linux-alpha-release.sh", "release:firefox": "bash scripts/build-firefox-release.sh", + "release:firefox:download": "bash scripts/download-firefox-release.sh", "release:firefox:sign": "bash scripts/sign-firefox-release.sh", "release:snapshot": "bash scripts/release-snapshot.sh", "smoke:chrome": "bash scripts/smoke-stable-chrome-connection.sh", diff --git a/scripts/download-firefox-release.sh b/scripts/download-firefox-release.sh new file mode 100755 index 0000000..93d1380 --- /dev/null +++ b/scripts/download-firefox-release.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +set -euo pipefail + +project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +output_dir="$project_root/target/firefox/signed" +version="$(jq -r '.version' "$project_root/apps/extension/manifest.base.json")" +extension_id="$(jq -r '.release.extension_id' "$project_root/config/firefox-extension-identities.json")" +version_url="https://addons.mozilla.org/api/v5/addons/addon/$extension_id/versions/v$version/" +status_path="$project_root/target/firefox/amo-version-$version.json" +output_path="$output_dir/mindcanary-$version-firefox-signed.xpi" +timeout_seconds="${AMO_POLL_TIMEOUT_SECONDS:-900}" +deadline=$((SECONDS + timeout_seconds)) + +if [[ -z "${WEB_EXT_API_KEY:-}" || -z "${WEB_EXT_API_SECRET:-}" ]]; then + echo "Mozilla download requires WEB_EXT_API_KEY and WEB_EXT_API_SECRET" >&2 + exit 2 +fi + +amo_jwt() { + node <<'NODE' +const crypto = require("node:crypto"); +const now = Math.floor(Date.now() / 1000); +const encode = (value) => Buffer.from(JSON.stringify(value)).toString("base64url"); +const header = encode({ alg: "HS256", typ: "JWT" }); +const payload = encode({ + iss: process.env.WEB_EXT_API_KEY, + jti: crypto.randomUUID(), + iat: now, + exp: now + 60, +}); +const body = `${header}.${payload}`; +const signature = crypto + .createHmac("sha256", process.env.WEB_EXT_API_SECRET) + .update(body) + .digest("base64url"); +process.stdout.write(`${body}.${signature}`); +NODE +} + +mkdir -p "$output_dir" + +while true; do + http_status="$(curl --globoff --silent --show-error \ + --output "$status_path.tmp" \ + --write-out '%{http_code}' \ + --header "Authorization: JWT $(amo_jwt)" \ + --header 'Accept: application/json' \ + --user-agent "mindcanary-release/$version" \ + "$version_url")" + mv -f "$status_path.tmp" "$status_path" + + if [[ "$http_status" != "200" ]]; then + echo "Mozilla version lookup failed with HTTP $http_status" >&2 + jq . "$status_path" >&2 || cat "$status_path" >&2 + exit 1 + fi + + signed="$(jq -r '.file.is_mozilla_signed_extension // false' "$status_path")" + file_status="$(jq -r '.file.status // "unknown"' "$status_path")" + if [[ "$signed" == "true" ]]; then + download_url="$(jq -r '.file.url' "$status_path")" + expected_hash="$(jq -r '.file.hash // empty' "$status_path")" + curl --globoff --fail --location --show-error \ + --header "Authorization: JWT $(amo_jwt)" \ + --user-agent "mindcanary-release/$version" \ + --output "$output_path.tmp" \ + "$download_url" + + if [[ "$expected_hash" == sha256:* ]]; then + printf '%s %s\n' "${expected_hash#sha256:}" "$output_path.tmp" | sha256sum --check --status + fi + mv -f "$output_path.tmp" "$output_path" + printf 'Downloaded Mozilla-signed Firefox XPI: %s\n' "$output_path" + sha256sum "$output_path" + exit 0 + fi + + if [[ "$file_status" == "disabled" ]]; then + echo "Mozilla did not approve Firefox version $version." >&2 + jq '{version, reviewed, approval_notes, file: {status: .file.status}}' "$status_path" >&2 + exit 1 + fi + + if (( SECONDS >= deadline )); then + echo "Mozilla version $version is still awaiting review after $timeout_seconds seconds." >&2 + echo "Rerun this command later; it will resume without uploading again." >&2 + exit 3 + fi + + printf 'Mozilla version %s is awaiting review; checking again in 10 seconds...\n' "$version" + sleep 10 +done