diff --git a/.github/scripts/changelog.mjs b/.github/scripts/changelog.mjs index 98ecbc6..b12ac9d 100644 --- a/.github/scripts/changelog.mjs +++ b/.github/scripts/changelog.mjs @@ -192,8 +192,8 @@ function previewBlock({ entry, version, bump, previous, released }) { ); } else { const from = previous.latest - ? ` — a ${bump} bump from v${previous.latest}` - : " — the first release"; + ? `: a ${bump} bump from v${previous.latest}` + : ": the first release"; lines.push(`Merging publishes **QuickNotch ${version}**${from}.`, ""); if (previous.latest) { lines.push("If another pull request merges first, this becomes the next number after it.", ""); @@ -212,7 +212,7 @@ function previewBlock({ entry, version, bump, previous, released }) { lines.push( `${previewGuidance(entry, released, previous)}`, "", - "This block is rewritten on every push — the text above it is yours.", + "This block is rewritten on every push. The text above it is yours.", "", PREVIEW_END ); @@ -223,7 +223,7 @@ function previewBlock({ entry, version, bump, previous, released }) { function previewGuidance(entry, released, previous) { if (released || !entry) return "Nothing to publish from this description."; if (!previous.latest) { - return "The first release is always 2.0.0 — labels steer the bump from the next one."; + return "The first release is always 2.0.0. Labels steer the bump from the next one."; } return ( "Bump: `release:major` (or `breaking`) for a major, `release:patch` (or `fix`, `bug`) for a patch," + @@ -450,11 +450,23 @@ async function previousFeed() { if (!managed) return { latest: null, releases: [], source: null }; const asset = managed.assets.find((candidate) => candidate.name === CHANGELOG_ASSET); - const response = await fetch(asset.browser_download_url, { - headers: { "User-Agent": "QuickNotch release planner" } - }); + + // Read the asset through the API, with the token. `browser_download_url` is + // the public link and answers a private repository with 404, which failed the + // preview on the first run against a release that exists. + const headers = { "User-Agent": "QuickNotch release planner" }; + let url = asset.browser_download_url; + if (TOKEN) { + url = asset.url; + headers.Accept = "application/octet-stream"; + headers.Authorization = `Bearer ${TOKEN}`; + headers["X-GitHub-Api-Version"] = "2022-11-28"; + } + const response = await fetch(url, { headers, redirect: "follow" }); if (!response.ok) { - throw new Error(`previous changelog -> HTTP ${response.status} (${managed.tag_name})`); + throw new Error( + `previous changelog -> HTTP ${response.status} (${managed.tag_name}, ${asset.name})` + ); } const feed = await response.json(); diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml new file mode 100644 index 0000000..90f13b1 --- /dev/null +++ b/.github/workflows/deploy-web.yml @@ -0,0 +1,82 @@ +name: Deploy site + +# Type-checks and builds the site, then deploys it to the `quicknotch` Worker on +# Cloudflare (quicknotch.app). +# +# The repository's own Cloudflare build is the primary path: it runs on every +# push to `main` from the repository root, with build command `npm run build` +# (which delegates to `web/`) and deploy command `npx wrangler deploy` (which +# reads the root `wrangler.toml`). This job is the fallback for when that +# integration is unavailable, so it deploys only when run by hand. +# +# Two secrets are required for the deploy step: +# CLOUDFLARE_API_TOKEN Workers Scripts: Edit permission +# CLOUDFLARE_ACCOUNT_ID the account that owns the zone +# Without them the build still runs (so a broken site fails CI) and the deploy +# step is skipped with a warning. +on: + push: + branches: [main] + paths: + - "web/**" + - "package.json" + - "wrangler.toml" + - ".github/workflows/deploy-web.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: deploy-web + cancel-in-progress: true + +jobs: + deploy: + name: Build and deploy + runs-on: ubuntu-latest + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Install + working-directory: web + run: npm ci --include=dev + + - name: Check + working-directory: web + run: npm run check + + - name: Build + working-directory: web + run: npm run build + + - name: Deploy credentials + id: credentials + shell: bash + run: | + if [ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]; then + echo "ready=false" >> "$GITHUB_OUTPUT" + echo "::notice::Push builds only validate the site; the Cloudflare build deploys it." + elif [ -n "${CLOUDFLARE_API_TOKEN}" ] && [ -n "${CLOUDFLARE_ACCOUNT_ID}" ]; then + echo "ready=true" >> "$GITHUB_OUTPUT" + else + echo "ready=false" >> "$GITHUB_OUTPUT" + echo "::warning::Cloudflare secrets are missing; the built site was not deployed." + fi + + - name: Deploy to Cloudflare + if: steps.credentials.outputs.ready == 'true' + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + command: deploy diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fcf14ea..123eb4b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -116,6 +116,12 @@ jobs: artifact: QuickNotch-macos-x64 runs-on: ${{ matrix.runner }} timeout-minutes: 60 + # The Calendar flow needs a Google OAuth client, which is not in the + # repository. CI injects both halves; the Gradle task turns them into + # `OAuthConfig`. + env: + QUICKNOTCH_GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_OAUTH_CLIENT_ID }} + QUICKNOTCH_GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_OAUTH_CLIENT_SECRET }} steps: - uses: actions/checkout@v4 with: @@ -128,6 +134,13 @@ jobs: - uses: gradle/actions/setup-gradle@v4 + - name: Google client + shell: bash + run: | + if [ -z "${QUICKNOTCH_GOOGLE_CLIENT_ID}" ] || [ -z "${QUICKNOTCH_GOOGLE_CLIENT_SECRET}" ]; then + echo "::warning::GOOGLE_OAUTH_CLIENT_ID and/or GOOGLE_OAUTH_CLIENT_SECRET are not set; installers will ship without Calendar sign-in." + fi + - name: Build installer (Windows) if: matrix.platform == 'windows' shell: pwsh @@ -232,3 +245,28 @@ jobs: --notes-file release-notes.md \ --latest fi + + - name: Publish the site changelog + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ needs.plan.outputs.version }} + run: | + set -euo pipefail + # The site imports the feed at build time, so the version and the + # history are prerendered. Commit the same JSON into the site source; + # the push redeploys the site on Cloudflare. + git clone --depth 1 "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" site + cp dist/changelog.json site/web/src/lib/data/changelog.json + cd site + if git diff --quiet -- web/src/lib/data/changelog.json; then + echo "Site changelog already current." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add web/src/lib/data/changelog.json + git commit -m "Publish the site changelog for $VERSION" + if ! git push origin HEAD:main; then + echo "::warning::Could not push the site changelog to main; deploy it by hand." + fi diff --git a/.gitignore b/.gitignore index 2f11cb3..79d1d55 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,16 @@ replay_pid* # Agent tooling state (local machine memory, never shared) /.serena/ + +# Local-only notes: the launch runbook and the agent onboarding doc stay on +# this machine. They are useful here and noise in a public repository. +/agents.md +/launch.md + +# The launch film's project and captures. Production material: the repository +# ships the app, the site and the release pipeline, not the 6 MB of stills and +# the rendered MP4 that live beside them. +/video/ + +# Wrangler login state and local build cache. Machine-specific, never shared. +/.wrangler/ diff --git a/LICENSE b/LICENSE index de80502..7ae66d4 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 StarkDroid +Copyright (c) 2025–2026 VelocityApps Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 38d5262..c0824f6 100644 --- a/README.md +++ b/README.md @@ -1,196 +1,161 @@ # QuickNotch -
+QuickNotch — one tiny notch. Apps, system, weather and more. -![QuickNotch Banner](https://raw.githubusercontent.com/StarkDroid/QuickNotch/main/banner.png) +**A floating notch and app dock for Windows and macOS.** Hover the pill at the top of +your screen and it opens into a dock of pinned apps; scroll and it turns into pages for +your machine, the sky, your day and your music. Free, MIT licensed, no account, no +telemetry. -**A floating notch and dock that pins apps so you can launch them from anywhere on Windows and macOS.** +[![Latest release](https://img.shields.io/github/v/release/StarkDroid/QuickNotch?label=latest&sort=semver&color=E8481C)](https://github.com/StarkDroid/QuickNotch/releases) +[![Downloads](https://img.shields.io/github/downloads/StarkDroid/QuickNotch/total?color=1F4FD8)](https://github.com/StarkDroid/QuickNotch/releases) +[![License: MIT](https://img.shields.io/badge/license-MIT-1C8F4C)](LICENSE) -[![Build & Release](https://github.com/StarkDroid/QuickNotch/actions/workflows/release.yml/badge.svg)](https://github.com/StarkDroid/QuickNotch/actions) +[![Windows 10 & 11](https://img.shields.io/badge/Windows-10%20%7C%2011-0078D6?logo=windows&logoColor=white)](#install) +[![macOS 12+](https://img.shields.io/badge/macOS-12%2B-000000?logo=apple&logoColor=white)](#install) [![Kotlin](https://img.shields.io/badge/Kotlin-2.1.10-7F52FF?logo=kotlin&logoColor=white)](https://kotlinlang.org) [![Compose Multiplatform](https://img.shields.io/badge/Compose_Multiplatform-1.7.3-4285F4?logo=jetpackcompose&logoColor=white)](https://www.jetbrains.com/lp/compose-multiplatform/) -[![Windows](https://img.shields.io/badge/Windows-10%20%7C%2011-0078D6?logo=windows&logoColor=white)](https://microsoft.com/windows) -[![macOS](https://img.shields.io/badge/macOS-12%2B-000000?logo=apple&logoColor=white)](https://www.apple.com/macos) -[![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) -
+**[Download](https://quicknotch.app/download)** · +[Website](https://quicknotch.app) · +[Changelog](https://quicknotch.app/changelog) · +[Releases](https://github.com/StarkDroid/QuickNotch/releases) · +[Report a bug](https://github.com/StarkDroid/QuickNotch/issues) + +[![Support me on Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/I2I41TRI2M) --- ## What it does -QuickNotch lives as a small pill at the top-centre of the screen. Hover it to expand a dock of pinned apps, click an icon to launch, then keep working. The same Jetpack Compose UI runs on both platforms; only scanning, icons, launch, and login items differ. - ---- +QuickNotch is one small pill pinned to the top-centre of the screen. Hover it and a dock +of pinned apps grows out of it, leaning toward the cursor so a click always lands where +you expect. Scroll over it and the same island turns into another page — no window to +arrange, no tab to find, nothing to dismiss. -## Features - -- **Floating notch** — Collapsed pill that morphs into a dock, anchored to the top-centre of the screen. It stays perfectly still while it expands and collapses (on macOS it sits above the menu bar, flush against the true top of the screen). -- **Dock fisheye wave** — Cosine-squared magnification and lift that track the cursor exactly; the wave radius is derived from icon size automatically. -- **Pin any app** — Search installed apps or browse for a custom executable / `.app` bundle. Reorder and remove from Settings. -- **Native icons** - - Windows: Win32 Shell jumbo / extra-large icons from `.exe` and `.lnk` files. - - macOS: `.icns` via `sips`, with Quick Look (`qlmanage`) as fallback. -- **Installed-app discovery** - - Windows: Start Menu, Desktop, system tools, and Registry uninstall entries. - - macOS: `/Applications`, `/System/Applications`, Utilities, and `~/Applications`. -- **Launch from anywhere** - - Windows: `cmd` / UWP AppsFolder / Desktop browse. - - macOS: `open` for bundles, files, and URLs. -- **Start at login** — Windows Run registry key, or a macOS LaunchAgent (`com.starkdroid.quicknotch`). -- **Tray / menu bar** — Show or hide the notch, open Settings, reset defaults, quit. -- **Settings** — Magnification, icon size, notch geometry, always-on-top, collapse-on-launch, labels. - -Pinned apps and config are stored as JSON: - -| Platform | Location | +| State | What you get | | :--- | :--- | -| Windows | `%APPDATA%\QuickNotch\` | -| macOS | `~/Library/Application Support/QuickNotch/` | +| **Quickview** (collapsed) | Date and time, weather, laptop and device battery, unread notifications. While music plays it becomes a vinyl disc with a marquee title and a live waveform. | +| **Apps** | Fisheye dock of pinned apps, one click to launch. | +| **System** | CPU, GPU, memory, storage, download and upload as live rings; click through to Task Manager or Activity Monitor. | +| **Weather** | Today plus a seven-day outlook, automatic by IP or pinned to a town you choose. | +| **Calendar** | Google Calendar for the day, with the next event ticking along in the pill. | +| **Music** | Cover art, progress, transport and volume — Windows media sessions, or MediaRemote on macOS. | +| **Gaming** (Windows) | A fullscreen game replaces the pill with FPS, CPU, GPU, ping and download; each cell can be switched off. | ---- +The notch sits flush above the menu bar on macOS and above fullscreen games on Windows, +and it stays quiet: the Dock icon (on macOS) and taskbar presence appear only while +Settings is open. -## Architecture +## Install -- **Language**: Kotlin 2.1.10 (Compose Multiplatform Desktop JVM) -- **UI**: Jetpack Compose 1.7.3 (Material 3) on both platforms — no SwiftUI or WinUI -- **Concurrency**: Kotlinx Coroutines (`kotlinx-coroutines-swing`) -- **Native**: JNA 5.16.0 for Win32; Kotlin + built-in macOS tools (`open`, `sips`, `qlmanage`, LaunchAgents) -- **Persistence**: Kotlinx Serialization JSON - -``` -QuickNotch/ -├── composeApp/ -│ ├── build.gradle.kts -│ └── src/desktopMain/kotlin/com/starkdroid/quicknotch/ -│ ├── model/ -│ │ ├── AppItem.kt -│ │ ├── NotchConfig.kt -│ │ └── FavoritesRepository.kt -│ ├── datasource/ -│ │ ├── HostPlatform.kt -│ │ ├── InstalledAppsScanner.kt # facade -│ │ ├── WindowsInstalledAppsScanner.kt -│ │ ├── MacInstalledAppsScanner.kt -│ │ ├── AppIconLoader.kt -│ │ ├── Win32ShellIconLoader.kt -│ │ ├── MacAppIconLoader.kt -│ │ ├── AppLauncher.kt -│ │ ├── StartupManager.kt # facade -│ │ ├── WindowsStartupManager.kt -│ │ └── MacStartupManager.kt -│ ├── ui/ -│ │ ├── FloatingNotchOverlay.kt # notch + fisheye dock -│ │ ├── OverlayGeometry.kt # footprint metrics + top-centre placement -│ │ ├── SettingsScreen.kt -│ │ └── NoiseGenerator.kt -│ ├── theme/ -│ └── main.kt # tray + overlay + settings windows -├── gradle/libs.versions.toml -├── web/ # SvelteKit marketing site (prerendered, static) -└── … -``` +Grab the installer for your machine from **[quicknotch.app/download](https://quicknotch.app/download)** +or the [latest release](https://github.com/StarkDroid/QuickNotch/releases/latest): ---- - -## Getting started +| Platform | File | +| :--- | :--- | +| Windows 10 & 11 (x64) | `.msi` (or `.exe`) | +| macOS 12+ Apple silicon | `-arm64.dmg` | +| macOS 12+ Intel | `-x64.dmg` | -### Prerequisites +The builds are not code-signed yet, so Windows SmartScreen and macOS Gatekeeper will ask +once — *More info → Run anyway* on Windows, right-click → *Open* on macOS. Once installed, +QuickNotch updates itself: it checks GitHub in the background and swaps in the next +release after you press **Restart & update** in **Settings → Changelog**. -- **JDK 17+** (Eclipse Temurin or Oracle recommended; Homebrew OpenJDK 17 also works) -- **Windows 10/11 (x64)** or **macOS 12+** (Apple Silicon and Intel) +## Build from source -### Run from source +You need **JDK 17+**. Everything else is downloaded by Gradle. macOS builds also compile +the small Objective-C bridges (`cc` is part of the Xcode command-line tools). ```bash git clone https://github.com/StarkDroid/QuickNotch.git cd QuickNotch -./gradlew :composeApp:run +./gradlew :composeApp:run # launch overlay + tray +./gradlew :composeApp:compileKotlinDesktop # fast type check +./gradlew :composeApp:detekt # static analysis (must be green) +./gradlew :composeApp:packageDistributionForCurrentOS # installer for this OS ``` -On first launch you get a default pin set (Explorer / Terminal / browsers on Windows; Finder, Safari, Terminal, Calculator, and similar on macOS). Hover the pill, or use the tray / menu bar extra to open Settings. +On Windows use `gradlew.bat`. Installers land in +`composeApp/build/compose/binaries/main/` (`.msi`/`.exe`, or `.dmg` plus +`app/QuickNotch.app` via `:composeApp:createDistributable`). -### Package for the current OS - -```bash -./gradlew :composeApp:packageDistributionForCurrentOS -``` +Run the app with `:composeApp:run`, not `java -jar` — the plain jar has no manifest and +the bridges are synced by the build. -Outputs land under `composeApp/build/compose/binaries/main/`: +Calendar sign-in needs a Google OAuth client, and this repository carries +neither half of it: the client id names this project's client, the secret +authenticates it. Official installers get both from CI. For a local build, put +them in the ignored `local.properties`: -| OS | Formats | -| :--- | :--- | -| Windows | `.msi`, `.exe` | -| macOS | `.dmg`, plus `app/QuickNotch.app` from `createDistributable` | - -```bash -./gradlew :composeApp:createDistributable -open composeApp/build/compose/binaries/main/app/QuickNotch.app # macOS +```properties +googleClientId=...apps.googleusercontent.com +googleClientSecret=GOCSPX-... ``` -On newer macOS releases, Skiko's Metal backend can crash when creating transparent overlay windows, so `main()` forces `-Dskiko.renderApi=SOFTWARE` at runtime on macOS only to keep the notch stable. Other platforms keep Skiko's default GPU renderer (DIRECT3D on Windows) — forcing the software pipeline app-wide is what made the full-size Settings window lag while the notch stayed smooth. +or export `QUICKNOTCH_GOOGLE_CLIENT_ID` and +`QUICKNOTCH_GOOGLE_CLIENT_SECRET` before building. Everything else works +without them; Calendar then reports that the build has no Google client. If you +are forking, create your own client in the Google Cloud console and use its +values. ---- +The website has its own build entry at the repository root: `npm run build` +installs and builds `web/`, and `npx wrangler deploy` publishes it to +`quicknotch.app`. Those are the same two commands Cloudflare runs, so see +`web/README.md` before changing either. + +## Project layout -## Releases and updates +``` +composeApp/ Kotlin + Compose Multiplatform desktop app + src/commonMain pure Kotlin: calendar + weather APIs, strings, glyphs + src/desktopMain overlay, dock, widgets, media, system probes, settings + native/ Win32 PowerShell bridges and macOS Objective-C bridges +web/ SvelteKit marketing site (static, prerendered, Cloudflare Workers) +docs/ banner sources for the README +config/ detekt configuration and baseline +``` -Every merged pull request that touches the app becomes a release. The workflow -in `.github/workflows/release.yml` plans the next version with -`.github/scripts/changelog.mjs`, builds the Windows installers and two macOS -DMGs (Apple silicon + Intel), and publishes them together with: +The app is documented where it lives: `main.kt` for the process and window model, +`system/` for the per-platform probes, `FloatingNotchOverlay` for the overlay +choreography, and the media, calendar and weather packages for the rest. The +comments carry the reasons, including the traps that cost a day. Read those before +changing anything under `ui/` or `system/`. -- **`changelog.json`** — the cumulative, user-facing history the app reads from - GitHub and shows in **Settings → Changelog**. It is fetched at runtime and - cached locally, never bundled into a build. -- **`SHA256SUMS.txt`** — checked by the in-app updater before it installs. +## How releases work -Versioning starts at **2.0.0** and is driven by labels on the merged PR: +Every merged pull request that touches the app becomes a release. The workflow plans the +next version from the PR's labels, builds the Windows installers and both macOS DMGs, +then publishes them with `SHA256SUMS.txt` and a cumulative `changelog.json` — the same +feed the app and the [website changelog](https://quicknotch.app/changelog) read. -| Labels | Bump | +| Label | Bump | | :--- | :--- | | `release:major`, `major`, `breaking` | major | | `release:patch`, `patch`, `fix`, `bug` | patch | -| anything else, or no label | minor | - -A PR that changes only `web/`, the docs or CI merges without producing a build. -The local build defaults to `2.0.0`; CI passes the published number with -`-PappVersion=x.y.z`, and the running binary reports it in **Settings → -Changelog**. - -While the PR is still open, `.github/workflows/changelog-preview.yml` runs the -same planner and rewrites a fenced **Changelog preview** block at the bottom of -the description — the version that will ship, the bump, and the exact sentences -users will read. Relabel the PR or edit its body and the block follows; the -author's own description is never touched, and the preview says plainly when a PR -does not touch the app and therefore ships nothing. - -The app updates itself: it downloads the package for the current platform in the -background, then a detached script waits for the JVM to exit after **Restart & -update**, installs the MSI (same upgrade UUID, higher version) or rsyncs the -DMG's bundle into place, and relaunches. A build started with `./gradlew run` -has no `jpackage.app-path`, so self-update is unavailable there and the button -opens the GitHub release page instead. - ---- +| anything else | minor | -## Marketing site +A PR that only touches `web/`, docs or CI merges without producing a build. Local builds +report `2.0.0` unless CI passes the published number with `-PappVersion=x.y.z`. -The landing page lives in `web/` as a separate SvelteKit workspace. It is built -independently of the Gradle build and produces a fully static, prerendered site -with scroll-driven animation (GSAP, Three.js, Lenis). +## Contributing -```bash -cd web -npm install -npm run dev # dev server -npm run build # static output in web/build/ -``` +1. Branch from `main`, keep one logical change per commit, and sign your commits + (`git commit -S`). +2. `./gradlew :composeApp:compileKotlinDesktop :composeApp:detekt` must pass; run + `:composeApp:run` and check the notch before you open the PR. +3. A feature ships on three surfaces — the overlay, the onboarding walkthrough and + `web/` — in the same pull request, all telling the same story. +4. Nothing is pushed for you: open the PR from your own fork or branch. -See [`web/README.md`](web/README.md) for the architecture, performance budget, -SEO setup and conventions. +Issues and ideas are welcome at +[github.com/StarkDroid/QuickNotch/issues](https://github.com/StarkDroid/QuickNotch/issues). ---- +## Licence -## License +MIT — see [LICENSE](LICENSE). Use it, fork it, ship your own notch. -This project is licensed under the MIT License — see the [LICENSE](LICENSE) file for details. +QuickNotch is free and has no upsell. If it earns a place on your screen and you feel +like saying thanks, [buy me a coffee](https://ko-fi.com/trishiraj) — it keeps the updates +coming and unlocks nothing. diff --git a/agents.md b/agents.md deleted file mode 100644 index 2cf2ed2..0000000 --- a/agents.md +++ /dev/null @@ -1,472 +0,0 @@ -# AGENTS.md — QuickNotch - -> Onboarding doc for a fresh agent. Read this first, then verify against code — -> the file a comment narrates is the source of truth. Keep this doc honest: -> update it in the same commit when a fact changes. - -## 1. What this is / what we're building - -**QuickNotch** is a floating notch-and-dock for **Windows 10/11** and **macOS 12+**: -a small pill pinned top-centre. Hover → expands into a dock of pinned apps -(fisheye magnification, launch on click) plus widget pages reached by wheel. - -- **Collapsed pill (QuickView):** live-media branch (vinyl disc, marquee title, - optional waveform analyser, volume + device-battery rings), idle-clock branch - (date/time + weather chip + laptop-battery ring + notification bell), idle dots, - drag-handle style, **gaming branch** (Windows only: a fullscreen game replaces the - pill with a live FPS/CPU/GPU/ping/download strip). Content-hugging: clock/dots shrink to - measured width; live media and the gaming HUD fill the configured width. -- **Expanded pages (wheel order):** Music (when live) → Apps → System (when on) → - Weather → Calendar. Music page: vinyl + title/artist—album + progress + transport - + rings. Calendar page: schedule view + ticker. Weather page: today + 7-day. - System page: one `LevelRing` per metric (glyph inside, live value under it) — - CPU, GPU (only where the OS reports an engine), memory, storage, download, upload. -- **macOS placement:** island sits flush at the true top of the screen, above the - menu bar (`NSStatusWindowLevel` 25, joins all Spaces, over full-screen apps). - Unreachable via AWT — an ObjC dylib parks it there. -- **Presence rule:** quiet agent while only the notch runs (macOS accessory policy - = no Dock icon/menu; Windows `WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE` = no - taskbar/Alt+Tab/focus steal). Dock icon + activation appear **only while the - Settings window is open**. Manual launch opens Settings; `--quicknotch-launched-at-login` - boot stays tray-quiet. -- `web/` is a separate static SvelteKit marketing site (also hosts - `/connect` + `/oauth/callback` for Calendar OAuth). Ignore unless asked. - -## 2. Stack & KMP integration (`gradle/libs.versions.toml`) - -| Thing | Version / note | -| --- | --- | -| Kotlin / CMP | 2.1.10 / Compose Multiplatform 1.7.3 (Desktop JVM), Material3, `materialIconsExtended` | -| Coroutines | core 1.10.1 + swing (AWT/EDT == render thread) | -| Serialization / HTTP / dates | serialization-json 1.7.3; **Ktor 3.1.3 CIO** + kotlinx-datetime 0.6.2 | -| Native | JNA 5.16.0 + jna-platform (Win32); PowerShell 5.1 bridges; macOS ObjC bridges via `cc` | -| Renderer | **Skiko 0.8.25 pinned** (whole `org.jetbrains.skiko` group). macOS METAL, Windows DIRECT3D | -| Build | plain KMP `jvm("desktop")`; no Android/iOS; detekt 1.23.8; `slf4j-nop` silences Ktor warnings | - -**Source-set boundary (load-bearing):** - -- `commonMain` = pure Kotlin only (calendar `CalendarApi`, weather `WeatherApi`). - No `java.*`, no `AppLog`. HTTP must be **Ktor CIO** — the jpackage runtime is a - jlink image with ~7 modules (`java.base/desktop/logging/prefs/xml`…); there is - **no `java.net.http`, no `jdk.httpserver`**. Anything needing those crashes only - in the installed app (`NoClassDefFoundError`, an `Error` — catch `Error` too). -- `desktopMain` = everything platform: `main.kt`, `datasource/`, `media/`, - `calendar/GoogleCalendar`, `weather/Weather`, `system/` (SystemMetrics + - per-platform probes), `ui/`, `model/`. -- OAuth loopback is a hand-rolled `ServerSocket` (single GET), never `HttpServer`. -- Resources: strings in `commonMain/composeResources/values/strings.xml` → `Res.string.x`; - glyphs in `drawable/*.svg` (solid `fill="currentColor"` silhouettes, tinted SrcIn). - **Each `Res` accessor needs its own explicit import** (`...generated.resources.`). -- Skiko: never set `skiko.renderApi` unconditionally (shadows env + marker file). - Order: `-Dskiko.renderApi` / `SKIKO_RENDER_API` → `software-render` marker file in - `AppPaths.configDir` → METAL default. Log line `skiko render backend = …` confirms. - -## 3. Commands - -```bash -./gradlew :composeApp:compileKotlinDesktop # fast check (gradlew.bat on Windows) -./gradlew :composeApp:desktopJar # full jar, also syncs/compiles bridges -./gradlew :composeApp:run # launch (overlay + tray); NOT java -jar (no manifest) -./gradlew :composeApp:detekt # must be green before commit -./gradlew :composeApp:packageDistributionForCurrentOS # .msi/.exe or .dmg -./gradlew :composeApp:packageDistributionForCurrentOS -PappVersion=2.1.0 # release build -SKIKO_RENDER_API=SOFTWARE ./gradlew :composeApp:run # one-off rasterizer A/B -``` - -Release builds take the version from CI: `-PappVersion=x.y.z` generates -`AppVersion.VERSION` (under `build/generated/appVersion`) and becomes the -jpackage version. A local build falls back to `2.0.0`; the tracked source never -holds the released number. - -Releases run on a merged PR (`.github/workflows/release.yml`): `plan` executes -`.github/scripts/changelog.mjs` (label-driven bump, sanitised entry, cumulative -`changelog.json`), `build` packages Windows + macOS arm64/x64 with -`-PappVersion`, `publish` attaches the installers, `SHA256SUMS.txt` and -`changelog.json` and marks the release latest. A PR touching only `web/`, docs -or CI skips the build. - -The same script runs on every push to an open PR -(`.github/workflows/changelog-preview.yml`, `MODE=preview` then `MODE=splice`): -it rewrites one marker-fenced block at the bottom of the description — the -version that will ship, the bump, and the exact user-facing lines — and leaves -the author's prose alone. It checks out the **base** commit, so a branch under -review never executes its own copy of the planner with a write token. A fork PR -is skipped (read-only token), same as the release. - -Bridge tasks (`composeApp/build.gradle.kts`, auto-run by jar/run): `syncWindowsBridge` -(`native/windows/*` → resources; edit source, never the copy), -`compileMacNowPlayingBridge` / `compileMacBtWatchBridge` / `compileMacPassthroughBridge` -/ `compileMacArtworkBridge` + `syncMacArtworkDriver` (macOS hosts only; absent resource = -controller stays silent by design). `compileMacGameWatchBridge` still builds the dormant -macOS game probe. Kill a run via the `MainKt|quicknotch` java process. -Install a DMG with `rsync -a` (`cp -R`/`ditto` leave empty bundles). - -## 4. Architecture - -### 4.1 Process & windows (`desktopMain/.../main.kt`) - -One `application {}`; `FavoritesRepository` + `MediaController` singletons. -Overlay = undecorated, transparent, `alwaysOnTop`, top-centred. -Settings = normal 880×660 window (`SettingsScreen`). Tray / menu-bar extra: show/hide, -Always-on-top toggle (mirrors Behavior tab), Settings, reset, quit. -Pollers on `Dispatchers.IO`, UI observes `StateFlow`s on EDT. `OverlayPager` is touched -from the AWT wheel listener and composition on the same thread — no locks. - -- **Fixed footprint, drawn growth.** The native window keeps one size (`NotchMetrics.window` - = widest of expanded dock and `GameHudWidth`) for life; open/collapse, a track starting - and a game starting are all drawn island growth inside it. Never resize the - transparent window per open/close (macOS white-flash/frame-skip). Position atomically - (`LaunchedEffect(config.style, targetSize)`); never re-place from live window dims. -- **macOS above-menu-bar** (`MacAppKit` + `passthrough_bridge.m` dylib, main-queue - marshalled): level 25 + `canJoinAllSpaces|stationary|fullScreenAuxiliary|ignoresCycle` - + top-gap `edgeGapDp`. Always-on-top off → level 0 below menu bar. ~700 ms reconcile - loop re-asserts (AWT/Compose re-parks 30pt low). AWT `alwaysOnTop` only reaches level 3 - and clamps y ≥ menu height — never use it for placement/math on macOS. -- **Dock presence** (`MacDockPresence`): accessory alone, Regular + one-shot - `activateIgnoringOtherApps` on Settings open, back to accessory on close. Reconcile - re-asserts **policy only**. Start/drive from `DisposableEffect`/`LaunchedEffect`, - never the composable body (body re-runs per media tick → focus steal + dead wheel). -- **Passthrough** (`WindowPassthrough`): clips the touch target to the **drawn island**. - Publish inline in `onGloballyPositioned` (the `LayoutCoordinates` instance is stable — - a keyed effect runs once and freezes the target). `localToWindow` is already physical - px — never multiply by density again. Windows: `SetWindowRgn` (+2px inflate, EDT). - macOS: cursor-gated `setIgnoresMouseEvents:` off-EDT (IO worker; EDT→main-queue - `dispatch_sync` at 33 Hz deadlocks) + delayed synthetic MOUSE_MOVED re-arm. Fail-soft: - handle/bridge missing → fully interactive. Native handle via reflected AWT peer + - `--add-opens` (`java.awt`, `sun.lwawt[.macosx]` / `sun.awt.windows`, host-gated). -- **Windows z-order** (`WindowsOverlayWindowPolicy`): AWT sets `WS_EX_TOPMOST` once; - a fullscreen game re-raises its own window into the band, leaving the HUD underneath - until an alt-tab re-shuffles it. Re-assert `SetWindowPos(HWND_TOPMOST)` on a 1.5 s - reconcile tick (250 ms while `GameDetector` has a session) with - `SWP_NOACTIVATE|SWP_NOMOVE|SWP_NOSIZE|SWP_ASYNCWINDOWPOS`. `HWND_TOPMOST` must be a - real `Pointer(-1L)` — `Pointer.createConstant(-1)` reaches the call as an invalid - handle (1400). True exclusive-fullscreen swap chains still cannot be overlaid; - borderless fullscreen (and Windows' fullscreen optimizations) is covered. - -### 4.2 Overlay: geometry, hover, pager, morph - -- `OverlayGeometry.kt`: `computeNotchMetrics` + `DockLayout` own footprints. - Island depth = `expandedNotchHeightDp`(70–86, default 78) + 6. Widths: music/weather/ - system pages 356dp, live-collapsed 236dp, gaming HUD 320dp (depth floor 34dp), - clock/dots hugged (floor 32dp + 16dp pad). `metrics.window` (max of expanded and - `GameHudWidth`) is the native window; `metrics.expanded` is no longer it. - `calendarConnected`/`weatherReady`/`systemReady` gate those pages and the width, so a - page with nothing behind it never becomes a dead stop on the wheel. - `gameActive` widens the collapsed pill and lifts its depth floor — and it is the - reason a game starting needs no window resize. - Indicator slots (+28dp each: device + laptop battery, notification bell, weather = 2) - come from `quickViewIndicatorSlots(...)` — **both** the window sizer and content must - use it or the right icon clips. -- `FloatingNotchOverlay.kt` (root: choreography only) + `OverlayHover.kt` - (`cursorOverNotch` real-cursor check; gate only suppresses collapse; re-poll until - confirmed out — one-shot wedges open) + `NotchMotion.kt` (springs/timings, - `PillAnimationFps` 30) + `OverlayPager.kt` (page/wheel state). -- Pager: Music-first while live, snap to 0 on collapse, one page per swipe burst - (momentum swallowed; re-arm only after ~100 ms quiet). Windows = AWT - `MouseWheelListener` (Compose never sees scroll on the unfocused overlay); - macOS = Compose `Scroll` event. Never both (double-count). Synthetic scroll never - reaches the overlay on macOS — test paging with a real device. -- Morph (`NowPlayingMorph.kt`): `SharedTransitionLayout`, keys disc/title/volume/ - battery. **Always `sharedElement`, never `sharedBounds`** — bounds renders both - copies mid-flight (two covers/titles). Pill stays visible until `showDock` when live - (`if (musicLive) !showDock else !isExpanded`); idle uses the curtain (quickview slides - down, dock scales up from 0.82 behind it). Animated island size is `State` read - **only in the layout phase** (`Modifier.animatedIslandSize`) + `snapshotFlow` to the - blur bridge — reading it in composition recomposes the whole tree per spring frame. -- Gaming quickview (`GameHud.kt` + `FloatingNotchOverlay`): while `GameDetector.game` - is non-null **and** `widgets.showGameHud`, the collapsed slot renders the HUD instead - of `QuickView` — same island, same collapse, no pointer target of its own (hover still - expands the dock). The HUD is a fill branch, so it disables width hugging - (`hugVisible`) and the playback outline (`showOutline`) it has no content for. - `transparentInGame` applies **only** while collapsed and only to the HUD - (`gameTransparent`): the body, the cell hairlines, the rim and the island shadow are - all dropped (`IslandSurface.gameTransparent` = transparent fill, transparent border, - width 0 — never a `NotchSurface` entry), leaving only the metric glyphs with their own - text shadow; the solid mode keeps the body and its dividers. Both blur carriers are - switched **off** rather than hidden (`blurActive`/`frostActive`) — otherwise the - macOS helper panel / Windows frost would repaint the body we just removed. Opening - the dock over a game always uses the user's real material. -- Inactive pages must not compose pointer modifiers at all (`.then(if (interactive) …)` / - `onClick = null`); `enabled = false` is still hit-testable and blocks pages beneath. - -### 4.3 Media, rings, calendar/weather/notifications - -- **Media** (`media/`): one `StateFlow`, one platform controller, JSON - wire `{state,src,srcId,title,artist,album,pos,dur,art,id}`. `publish()` extrapolates - pinned browser positions, caches art per content id, decodes **straight to ≤256px** - (`sourceSubsampling`) + derives `artTint` once on the worker. 4 s empty-grace hold - ends on a **timer** (`armHoldExpiry`), not the next line (bridge de-dups); all writes - via `publishLock`. Local 250 ms `nowMs` clock lives in the progress composable only. - Windows `smtc.ps1` (seek OK; `status ` skips re-transfer — - three-valued gate '0' wanted / '1' held / '2' tried-empty so artless sources are - asked once; stderr merged so one stream drains; 4 s deadline then - `destroyForcibly` so a winRT wedge frees the reader). macOS - `nowplaying-bridge` → `osascript -l JavaScript` (only `com.apple.*` contexts reach - MediaRemote ≥15.4; no seek). Art bytes **once per track** (`EmitArtOnce`, else null); - drain large stdout **during** `waitFor` (pipe buffer), small lines after. Late art via - `publishArtwork` (identity-gated, stale dropped). -- **Artwork (macOS):** JXA never returns bytes (entitlement). `MacArtworkFetcher` runs - `artwork-driver.pl` + `artwork-bridge.dylib` inside `/usr/bin/perl` (one query per - artless track). Music AppleScript fallback gated to `srcId com.apple.Music` only. -- **Rings** (`LevelRing.kt`, 288° arc, 20% bottom gap, takes a `Painter`; 22dp pill / - 28–30dp page): `SystemVolume` (2 s, one spawn `,`; page ring = mute toggle, - pill ring display-only), `AudioBattery` (WinRT presence + `DEVPKEY` level; macOS - `system_profiler` SPBluetooth 10 s + CoreAudio `btwatch-bridge` event poke; audio-class - + default-output only, no BLE ghosts), `SystemBattery` (`pmset`/`Win32_Battery`, 5 s, - `hasBattery` gates toggle, charging = active only), `SystemAccent` (3 s while picked). - Failed probe keeps last value; null hides. Every spawn: `waitFor(timeout)` + - `destroyForcibly`. Bridges arm `getppid()==1` suicide + startup - `sweepOrphanedBridges()` (leaked bridges poll MediaRemote forever → machine stalls). -- **Calendar** (`calendar/`, Ktor CIO in `commonMain`): Web-client OAuth via site - (`/connect` → Google → `/oauth/callback` → loopback; `client_secret` mandatory; - `state` = base64url `{port,nonce}` verified exactly; top-level navigation, not fetch; - `/connect` has no marketing Nav, `Seo` takes no props). `timeMin/timeMax` are RFC3339, - not epoch. Check `status.isSuccess()` before parsing (refusal ≠ empty day); sealed - `EventsResult`/`OAuthResult`; connected ≠ day-synced (`staleError`); refresh under mutex; - derived ticker loop starts unconditionally. Testing consent needs test-user status + - callback URI registered (propagation can take hours). -- **Weather** (`weather/`, `WeatherApi` commonMain): IP-geolocate + Open-Meteo, keyless, - metric feed, °F as render conversion. Half-hourly poll + `weather.json` cache; - Ready/Loading/Failed; page gated like calendar (no cache + failed = no page). - Optional manual place (`WidgetsConfig.weatherPlace`, null = automatic) picked from - Open-Meteo geocoding search in the Widgets tab; `Weather.setManualPlace` applies it - live, `start(place)` seeds it at boot. -- **Notifications (macOS):** `count(*)` on usernoted `record` (WAL-aware: fingerprint - db/-wal/-shm at 250 ms + 3 s backstop). Needs Full Disk Access (settings deep-links - the pane, re-pokes on focus); unreadable → hidden. Windows has no store → hidden. -- **System monitor** (`system/`, `WidgetsConfig.showSystem`, default on): one - `StateFlow`, poll **gated on the toggle** (`SystemMetrics.setEnabled` - → a switched-off widget spawns nothing and clears the snapshot). macOS runs exactly - one `/bin/sh` per 2 s tick (`sysctl hw.ncpu hw.memsize`, `ps -A -o %cpu=`, `vm_stat`, - `netstat -ibn`, `df -k /System/Volumes/Data`, `ioreg -c IOAccelerator`) ≈ 40 ms; - Windows runs one `system-metrics.ps1` per 4 s tick (`Get-Counter` PDH over a 1 s - sample for CPU/memory/GPU-engine/network, `DriveInfo` for the disk). Static facts - (total RAM, GPU name via `system_profiler` ≈ 300 ms) are read **once per run**, never - per tick. Readings: CPU = Σ`ps %cpu` ÷ cores; memory = active + wired + compressor; - storage = the **data** volume (the read-only system volume reads ~14% falsely); - GPU = highest `Device Utilization %` (macOS) / summed 3D-engine utilization clamped - to 100 (Windows) — no counter means no GPU tile (`gpuAvailable`). Throughput comes - from cumulative counters, so **both directions must share one delta** and the first - sample is always null. `emit()` refuses a sample that lands after the switch-off, so - a switched-off page can never show a frozen reading forever. Load metrics warm to `LoadHighTint` - ≥ 85; network rings use a log sweep (1 KB/s → 0 … 1 GB/s → 100) and the label carries - the exact rate. -- **Gaming (Windows only)** (`system/GameDetector.kt` + `FpsProbe.kt`, - `WidgetsConfig.showGameHud` default on, `NotchConfig.transparentInGame` default off): - one `StateFlow` is the entire trigger. `gamingActive` / `gameHudVisible` - gate on `HostPlatform.isWindows`, the detector is only *started* on Windows, and both - Settings switches live in the System widget accordion only on Windows — a Mac never - probes for, or offers, a HUD it cannot show. Detector runs only while - `showGameHud || transparentInGame` (`GameDetector.setEnabled`), and a session needs - `MissingProbesBeforeIdle` = 2 consecutive misses (~3 s) to clear, so a loading screen - or alt-tab cannot blink the HUD. Windows = **one long-lived** `game-detect.ps1` - printing `1|name|pid` / `0||` per 1.5 s tick (foreground rect vs `MonitorFromWindow` - rect, or `SHQueryUserNotificationState == QUNS_RUNNING_D3D_FULL_SCREEN`; shell - classes and explorer excluded; exits when its parent JVM disappears). **Never spawn - it per probe** — its `Add-Type` costs ~1 s in PS 5.1. FPS = `FpsProbe` on Windows - only: one PresentMon capture per 6 s (`--timed 2`, mean of `msBetweenPresents` → - `1000/mean`), its own coroutine so a 2 s capture never delays a system sample; it - tries `--` then `-` option spelling once and remembers the winner. Every failure — - no binary, refused ETW — yields **no FPS cell**, never a fabricated number. Latency is - `SystemSnapshot.pingMs`: one TCP handshake to `1.1.1.1:443` every other system tick - (4 s macOS / 8 s Windows, 1 s timeout, fail-soft), kept off the System page. A game - also force-enables `SystemMetrics` (`showSystem || gamingActive`) because the HUD needs - CPU/GPU — the System *page* stays off the wheel when its toggle is off. The one thing - the HUD cannot do is written directly under its switch: a true exclusive-fullscreen - swap chain cannot be overlaid (borderless fullscreen is the supported case). Each of - the five cells has its own switch (`WidgetsConfig.gameMetrics`, chips under the HUD - row); with every cell off `NotchConfig.gameHudEnabled` is false and the overlay, the - window sizer, the system poller and the detector all fall back to the ordinary pill — - never an empty strip. - The macOS detection path (`MacGameWatch.kt` + `gamewatch-bridge.dylib`) is kept in the - tree but no longer started — dormant until a macOS HUD exists. -- **Changelog & self-update** (`datasource/Changelog.kt`, `ReleaseApi.kt`, - `AppUpdates.kt`, `AppInstaller.kt`, `FileDigest.kt`, `ui/ChangelogDialog.kt`): - the settings entry is Changelog, not About. The feed comes from the release's - `changelog.json` (cached in `changelog.json`, never bundled), and the dialog - renders only versions `<= AppVersion.VERSION`; anything newer becomes an update - banner rather than fake history. `ReleaseApi` lists releases and takes the - newest carrying `changelog.json` — not `/releases/latest`, because the old - pre-changelog `v3.1.2` tag sorts above 2.x. `AppUpdates` polls every 6 h, - downloads the MSI (preferred) or the arch-matching DMG, verifies - `SHA256SUMS.txt`, and waits for "Restart & update" before quitting. The swap is - a detached script: macOS waits on the PID then `rsync`s the DMG's bundle; - Windows waits then runs `msiexec /i /qn` (3010 tolerated) under the same - upgrade UUID. No `jpackage.app-path` (a Gradle run) → the button opens the - release page instead. -- **Persistence:** `config.json` + `favorites.json` (+ `calendar.json`, `weather.json`) - in `%APPDATA%/QuickNotch` / `~/Library/Application Support/QuickNotch`. - `@Serializable` defaults (old files load); coalesced single-writer; `resetToDefaults` - keeps `onboardingDone`. -- **Blur:** `NotchSurface.TRANSLUCENT` (= serialised `BLUR`) = native blur + 18% scrim. - macOS = standalone island-sized helper panel below the overlay (never an AWT child — - CGS radius silently no-ops there), 250 ms frame/level/order/visibility sync; - `CAShapeLayer` masks (not `cornerRadius`), no rim (`blurScrim.border` transparent); - intensity drives radius + opacity. Windows has no usable compositor route — all - four were probed live against a striped backdrop: DWM blur-behind composites - behind the window (or is ignored on a layered one), `DWMWA_SYSTEMBACKDROP_TYPE` - acrylic draws a solid rect ignoring region + transparency, - `SetWindowCompositionAttribute` acrylic ignores `SetWindowRgn`, and - `WDA_EXCLUDEFROMCAPTURE` blacks a layered window's whole rect in every GDI - capture. So the frost IS a screen capture: a ~30 fps daemon (`BitBlt` - SRCCOPY|CAPTUREBLT → `GetDIBits` top-down 32bpp, clamped to the virtual screen, - inflated 3σ past the island) **inpaints the self-drawn hole** (island rect + - 5dp ring pad, replaced by the average of a horizontal lerp between the flanking - bands and a vertical blend between the rows above/below — no self-baking - ghost), runs a **CPU Gaussian** (3-pass box cascade, clamp-to-edge; sigma = - `(4 + 0.5×intensity)dp` — the slider drives an exact radius), crops to the - island rect and publishes an `ImageBitmap` to a `StateFlow`. The `WindowsFrost` - leaf inside the island Box draws it 1:1 under the legibility scrim, its own - recompose scope (a frame tick touches nothing else); the painted body skips - via `frostActive`. Capture is inflated 3σ so the rim's whole sample window is - real backdrop; the flush screen-top edge has nothing above to sample and gets - the reconstruction (invisible under the Gaussian). No exclusion flag — the - notch stays in screenshots/screen sharing. Intensity is still also the scrim - alpha. Missing bridge / any native failure → last frame or painted glass. - Single-instance file lock in `main()`. - -### 4.4 UI modules (`ui/`) - -Root choreography `FloatingNotchOverlay`; pill branches + strip `QuickView`; -pages + fisheye dock `DockContent`; vinyl/marquee/progress/transport/waveform -`MediaWidgets`; morph keys `NowPlayingMorph`; hover `OverlayHover`; springs/timings -`NotchMotion`; metrics `OverlayGeometry`; pager `OverlayPager`; rings `LevelRing`; -gaming HUD `GameHud` (value over caption cells, hairline dividers, shadow only in -transparent mode); clock/bell `QuickViewClock`; surfaces `IslandSurface`; skeleton `Shimmer`; -`CalendarWidget`, `WeatherWidget`, `SystemWidget` (System page: six ring tiles, -`systemRate`/`systemRateLevel`), `OnboardingScreen` (gates overlay on -`onboardingDone`), `PinnedAppsPanel`, settings `SettingsScreen`/`WidgetsTab`/ -`DockSizingTab`/`BehaviorTab`/`SettingsComponents` (shared chrome + `plainClickable` / -`trackHoverPress` helpers; tabs collect their own state — screen collects none). - -## 5. Design system - -Palette in `theme/Color.kt` (+ overlay-local `LiveAccent` etc. in `MediaWidgets.kt`). -Settings/onboarding are gradient-free solids. Glyphs = tinted SVGs only. -Reusable `LevelRing` (one ring, several alert rules: `lowTint`/`lowThreshold` for a -draining battery, `highTint`/`highThreshold` for a saturating load metric) / -`VinylDisc` (spins 12 s/turn while playing, frozen angle on pause; -page swap = `AnimatedContent` keyed on the bitmap, clipped at island) / `MarqueeText` -(scroll iff overflowing) / `TrackProgress` (inset lane, head iff seekable). -Waveform = faux analyser (2 sines/band + shared envelope, 8–40 bars by width, white or -`artTint`), 30 Hz `delay` + `nanoTime` deltas, pause ramps to a flat line then clock stops. -**Animation rules:** `delay`-ticks at ≤30 Hz, never `withFrameNanos`; move pixels via -`graphicsLayer`, never `Modifier.offset{}`; kill transitions whose visual is static; -resolve draw-table sizes in composition, never from `onSizeChanged` writes. - -## 6. Rules — DO / DON'T - -DO: strings → `strings.xml`; colors → `Color.kt`; glyphs → `drawable/*.svg`; -follow file-local `//`-narration style; read the file before editing; reuse -ring/vinyl/marquee/progress + settings helpers; gate pollers on `HostPlatform`, -failed probe keeps last value; high-frequency state collected in the drawing subtree, -frame clocks local to their composable; monotonic clocks for extrapolation. -Verify: `compileKotlinDesktop` → `desktopJar` → `:composeApp:run` → `:composeApp:detekt`. - -**Every feature ships on three surfaces — app, onboarding, web.** A new widget, -toggle or interaction is not finished until (a) it works in the overlay, (b) the -onboarding walkthrough can show it or set it where a first-run user would meet -it, and (c) `web/` tells the same story: the demo notch paints the same widget -with the same layout and labels, and the copy, feature list, keywords, FAQ and -structured data name it. Land all three in the same commit. A marketing demo of -a widget we do not ship — or an onboarding step describing one we removed — is a -bug, not a nicety. When the demo needs a visual the app has, match the app's -geometry (ring sweep, capsule shapes, dot sizes) rather than inventing one. -A feature that is **not** a wheel page (the gaming HUD is the collapsed pill's -own state) still needs all three surfaces. On the site the scroll tour is the -single place widgets are explained: a state is a `tour.steps` entry plus a -`NotchPanel` branch, never a new section. `NotchPanel` is the one mock — the -hero, the laptop screen and the static fallback all draw it — so the marketing -demo cannot drift from the app's geometry. The laptop arrives skewed and lands -frontal as the lid opens; its base is a solid front-elevation body at the hinge -with the machine's real thickness — never a deck, so no angle ever shows keys. -The page keeps exactly one tour, one -look band, pricing and FAQ; do not reintroduce per-widget sections, a download -section or a second demo. - -DON'T: force SOFTWARE renderer; set `skiko.renderApi` unconditionally; edit -`resources/windows/*.ps1` (edit `native/windows/`); `java -jar` the plain jar; resize the -overlay per open/close; re-place from live window dims; derive placement/math from -`locationOnScreen`/`localToScreen` without the CG-origin delta; `activateIgnoringOtherApps` -in a loop; native side effects in composable bodies; `dispatch_sync` to AppKit from EDT; -`sharedBounds` for same-content morphs; `enabled=false` for page input gating; second wheel -path on Windows; static WinRT stream-extension calls in `smtc.ps1` (pre-bind the -MethodInfo — see §7); per-tick persistence (repo coalesces); new strings/colors/glyphs inline; -`String.format` assumptions on `stringResource` (`%1$s` bare substitution — `%%` doubles); -scope-creep (new notch positions, Android/iOS) unless asked. - -## 7. Critical gotchas (distilled — full stories were in §10) - -- BT battery: macOS = `system_profiler` parse (plist DeviceCache is dead) + default-output - match; events from CoreAudio watcher, not bluetoothd; 10 s poll + `poke()`. -- Glyphs must be solid silhouettes (`fill-rule="evenodd"` for holes); strokes tint to ~1/3. -- Art: ≤256px subsample; bridge sends bytes once per id; Music fallback only for Music. -- SMTC art on Windows: the thumbnail stream is a raw `System.__ComObject`, and - PowerShell 5.1 cannot bind it to a static `WindowsRuntimeStreamExtensions` - method's `IInputStream` parameter ("Cannot find an overload" — its conversion - reads `GetInterfaces()`, which is empty on a COM RCW). Pre-bind the MethodInfo - and `Invoke` it: the CLR cast QIs and succeeds. -- Windows blur: no DWM route shapes frost on a layered window (blur-behind - composites behind / is ignored; both acrylics ignore region + transparency; - `WDA_EXCLUDEFROMCAPTURE` blacks a layered window out in every GDI capture) — - capture + CPU-Gaussian + inpaint the self-drawn hole instead, or the island - ghosts into its own backdrop. Inflate the capture 3σ (the rim's whole sample - window must be real backdrop) and clamp the BitBlt to the virtual screen - (the flush-top island always pokes above y=0; an unclamped blit fails - silently, every frame). `GetDIBits` needs the bitmap deselected first, and - 32bpp BI_RGB alpha is undefined — force opaque. -- Progress outline straddles the island edge from a padded wrapper sibling - (the island clip would cut its outer half); pad is 0 when the ring is off. -- Windows z-order: AWT's one-shot `alwaysOnTop` loses the topmost band to a - fullscreen game's own `SetWindowPos`; `WindowsOverlayWindowPolicy` re-asserts - `HWND_TOPMOST` with `SWP_NOACTIVATE`. Pass `Pointer(-1L)`, not - `Pointer.createConstant(-1)` — the latter arrives as an invalid handle (1400) - and the call silently no-ops. Exclusive fullscreen still cannot be overlaid. -- Pipe: small output read-after-`waitFor`; multi-100KB base64 drained concurrently. -- System monitor (macOS probe): `netstat` **must** be `-n` (without it every interface - address resolves through DNS and the probe blocked ~5 s); `netstat -ib` repeats a row - per address family and only the first carries the counters (de-dupe by interface, - skip `lo0`); `df` the `/System/Volumes/Data` volume (root reads ~14% falsely); - `ioreg -c IOAccelerator` gives GPU load only where the driver prints - `Device Utilization %`. On Windows the PDH sample itself costs ~1 s, so the poll is - 4 s and RAM total / GPU name come from one `-Static` run. -- Calendar: RFC3339 times; 403 body names a disabled API; Testing clients need test users; - loopback port must be `--strictPort`-stable and the URI identical in app + page + console. -- Gaming: `game-detect.ps1` is a **watcher, not a probe** — `Add-Type` costs ~1 s in - PowerShell 5.1, so a per-tick spawn burns a fifth of a core forever; one process, one - compile. macOS exposes no supported per-app frame rate, so FPS is Windows-only by - design (not a TODO), and `PresentMon.exe` is **not committed** (third-party binary — - drop it in `native/windows/`, see `README-presentmon.md`); without it the HUD drops the - FPS cell. Transparent-in-games must switch the blur carriers **off**, not merely hide - them, or the native panel/frost paints the body back. -- macOS `prev` restarts the track when position > ~3 s — verify via position, not title. -- Detekt 1.23.8 gates new code (`UnusedImports` on); old findings in `baseline.xml`, - re-cut with `detektBaseline` only after deliberate fixes; tunings: `MagicNumber` off, - `@Composable` exempt from `FunctionNaming`. -- Releases: GitHub's `/releases/latest` is semver-ordered, so the old `v3.1.2` - wins over every 2.x. `publish` must pass `gh release ... --latest`, and the app - must find the newest release whose assets include `changelog.json` instead of - trusting `/latest`. That asset is also the gate that keeps the old release from - looking like an update. macOS ships `-arm64` and `-x64` DMGs; `assetFor` picks - by `os.arch`. -- The PR preview block is not input: `withoutPreview` strips it before the body - is parsed, or a bullet the author deleted keeps resurfacing from the previous - run and the changelog slowly eats its own output. The splice is also a no-op - when the rendered block is identical, so a push that changes nothing a user - would read leaves the description (and its timeline) alone. -- Self-update only exists in an installed build: `jpackage.app-path` is absent - under `./gradlew run`. The detached script waits for the JVM PID, so the app - must quit *after* starting it; macOS uses `rsync -a` (never `cp -R`/`ditto`), - Windows tolerates MSI exit 3010. -- `git status` before any `stash` (shared branch); never push (user pushes); signed commits. - -## 8. Git workflow & scope - -Branch `main` (older work: `cursor/macos-compose-adaptation`). One logical change per -commit, imperative summary, GPG-sign (`git commit -S`), author stays the user, never push. -Before commit: compile + jar + smoke run + detekt green + resource sweep (unused -`Res.*`, zero `Brush.*Gradient` in settings/onboarding) + web/onboarding parity -(§6) — app, onboarding and `web/` in the same commit. End with clean `git status`; -note unverifiable paths honestly (macOS-only paths have no machine here: CGS/panel -blur, passthrough bridge, menu-bar presence; needs live eyes: morph smoothness, -waveform feel, blur look). diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index b8f86e3..550e74c 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -1,4 +1,5 @@ import org.jetbrains.compose.desktop.application.dsl.TargetFormat +import java.util.Properties plugins { alias(libs.plugins.kotlinMultiplatform) @@ -33,6 +34,30 @@ detekt { // updater compare against something meaningful. val appVersion = providers.gradleProperty("appVersion").getOrElse("2.0.0") +// Neither half of the Google OAuth client is in this repository. Both are read +// from the environment (what CI uses) or from a maintainer's ignored +// `local.properties`, then written into a generated source file. A build +// without them compiles and runs; Calendar then says the build has no client +// rather than opening a consent screen it cannot finish. +// +// The client id is public in OAuth's model (it travels in every consent URL), +// but it names this project's client, so it is injected too: a fork should +// bring its own rather than inherit one it cannot use. +val localProperties = Properties().apply { + val file = rootProject.file("local.properties") + if (file.isFile) file.inputStream().use { load(it) } +} + +fun oauthSetting(property: String, environment: String): String = + (providers.gradleProperty(property).orNull + ?: providers.environmentVariable(environment).orNull + ?: localProperties.getProperty(property)) + .orEmpty() + .trim() + +val googleClientId: String = oauthSetting("googleClientId", "QUICKNOTCH_GOOGLE_CLIENT_ID") +val googleClientSecret: String = oauthSetting("googleClientSecret", "QUICKNOTCH_GOOGLE_CLIENT_SECRET") + version = appVersion // `AppVersion.VERSION` is generated rather than hardcoded in a string resource: @@ -63,6 +88,46 @@ val generateAppVersion by tasks.registering { } } +// `OAuthConfig` holds the Google client the Calendar flow signs in with. The +// secret is injected here from the build environment instead of being written +// into a tracked file, so the repository can be public and the history shows no +// credential. +val generateOAuthConfig by tasks.registering { + val outputDir = layout.buildDirectory.dir("generated/oauthConfig") + val clientId = googleClientId + val clientSecret = googleClientSecret + inputs.property("clientId", clientId) + inputs.property("clientSecret", clientSecret) + outputs.dir(outputDir) + // The output carries a credential: keep it out of the build cache and any + // cache upload. + outputs.cacheIf { false } + doLast { + fun literal(value: String): String = value + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("$", "\\$") + val packageDir = outputDir.get().asFile.resolve("com/starkdroid/quicknotch") + packageDir.mkdirs() + packageDir.resolve("OAuthConfig.kt").writeText( + """ + package com.starkdroid.quicknotch + + /** + * The Google OAuth client this binary signs in with. Injected by the + * `generateOAuthConfig` Gradle task; never edit by hand. The client id + * is public, the secret comes from the build environment and is not + * in the repository. + */ + internal object OAuthConfig { + const val CLIENT_ID: String = "${literal(clientId)}" + const val CLIENT_SECRET: String = "${literal(clientSecret)}" + } + """.trimIndent() + "\n" + ) + } +} + // Host platform, used to gate platform-only JVM opens and launch flags (the // bridge tasks below derive their own copies inside onlyIf for the same check). val hostOsName = System.getProperty("os.name").lowercase() @@ -116,8 +181,10 @@ kotlin { // Generated version source: registered after the KMP block so the // `desktopMain` source set exists, and wired into every Kotlin compile. kotlin.sourceSets.getByName("desktopMain").kotlin.srcDir(layout.buildDirectory.dir("generated/appVersion")) +kotlin.sourceSets.getByName("desktopMain").kotlin.srcDir(layout.buildDirectory.dir("generated/oauthConfig")) tasks.matching { it.name.startsWith("compileKotlin") }.configureEach { dependsOn(generateAppVersion) + dependsOn(generateOAuthConfig) } // The media ("now playing") feature ships a small native helper per platform. @@ -329,10 +396,10 @@ compose.desktop { // Renderer selection is handled in `main()`: an explicit // -Dskiko.renderApi or SKIKO_RENDER_API wins, then the - // `software-render` marker file, then METAL — the ~20x-faster default - // (agents.md pitfall 28). Never set skiko.renderApi here: an - // unconditional assignment shadows both the env var and the marker - // and silently parks the settings window on the CPU rasterizer. + // `software-render` marker file, then METAL — the ~20x-faster default. + // Never set skiko.renderApi here: an unconditional assignment shadows + // both the env var and the marker and silently parks the settings + // window on the CPU rasterizer. nativeDistributions { targetFormats(TargetFormat.Exe, TargetFormat.Msi, TargetFormat.Dmg) @@ -364,7 +431,8 @@ compose.desktop { } // Skiko is pinned to a version whose macOS Metal backend actually works (see -// the `skiko` entry in gradle/libs.versions.toml and pitfall 28 in agents.md). +// the `skiko` entry in gradle/libs.versions.toml and the renderer notes in +// `main.kt`). // Every artifact in the group has to move together: skiko, skiko-awt and the // platform runtime jar the Compose plugin pulls in, otherwise the JNI layer // and the native half disagree. diff --git a/composeApp/native/macos/passthrough_bridge.m b/composeApp/native/macos/passthrough_bridge.m index eea84ad..d31232a 100644 --- a/composeApp/native/macos/passthrough_bridge.m +++ b/composeApp/native/macos/passthrough_bridge.m @@ -96,35 +96,32 @@ void quicknotch_set_frame_top_gap(void *nsWindow, double topGap) { NSScreen *screen = window.screen ?: NSScreen.mainScreen; NSRect s = screen.frame; NSRect f = window.frame; - // Cocoa origin is bottom-left; the top edge of the screen is at - // (s.origin.y + s.size.height). topGap == 0 parks the top at the very - // top edge; a positive gap floats the body off it (capsule styles). double topCocoaY = s.origin.y + s.size.height - topGap; NSRect target = NSMakeRect( s.origin.x + (s.size.width - f.size.width) / 2.0, topCocoaY - f.size.height, f.size.width, f.size.height); - if (!NSEqualRects(f, target)) { - [window setFrame:target display:NO]; + if (NSEqualRects(f, target)) { + return; } + // AppKit clamps a *move* of a level-0 window to the visible frame, so + // the notch could not be parked at the true top with "always on top" + // switched off. Raise it for one main-queue turn, move it, then put the + // level back. The requested off level is -1, which AppKit leaves alone, + // so the frame survives the restore and every app window still covers + // the island. + NSInteger restore = window.level; + if (restore < NSStatusWindowLevel) { + window.level = NSStatusWindowLevel; + } + dispatch_async(dispatch_get_main_queue(), ^{ + [window setFrame:target display:NO]; + window.level = restore; + }); }); } -// Height (points) of the menu-bar strip on the window's screen: the gap between -// the full frame and the visible frame. Used when the "always on top" toggle is -// OFF to park the island back below the menu bar instead of leaving it shifted -// up by a previous ON state. -double quicknotch_get_menu_bar_height(void *nsWindow) { - __block double height = 0.0; - NSWindow *window = (__bridge NSWindow *)nsWindow; - runOnMainSync(^{ - NSScreen *screen = window.screen ?: NSScreen.mainScreen; - height = screen.frame.size.height - screen.visibleFrame.size.height; - }); - return height; -} - // The window's top-left in CG screen coordinates (top-left origin, in points), // which is what AWT cannot know after the direct Cocoa moves above: AWT reports // its own cached virtual location (y=0 is "30pt below the top"), not the live diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 365f445..05a58e9 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -97,7 +97,8 @@ Sign-in didn't finish. Try connecting again. Couldn't reach Google. Showing the last known day. The Google Calendar API is not enabled for this project yet. Enable it in the Google Cloud console (APIs & Services → Library), then press Refresh. - This Google client is set up as a web app. Create a Desktop app client in the Google Cloud console and use its client ID instead. + Google rejected this build's client credentials. The Google client secret is missing, rotated, or has not reached this machine yet; rebuild with the current one. + This build has no Google client configured, so Calendar cannot connect. The build notes in the README show how to supply one. Google has not released this app yet. Add your Google account as a test user on the OAuth consent screen, then try again. Sign-in was cancelled. Nothing was connected. Google access was revoked. Reconnect to continue. @@ -225,12 +226,11 @@ Add apps from the list on the right. Changelog QuickNotch Desktop Version %1$s - Your apps, calendar, weather, music and live system stats — one flick away in a notch that never blocks the view, on Windows and macOS. It updates itself and keeps getting better: there’s plenty more in the pipeline. + Your apps, calendar, weather, music and system stats, in one small notch that stays out of the way. No release notes published yet. Fetching the latest notes from GitHub… Couldn’t reach GitHub. Check your connection and try again. Try again - Fetched from GitHub — never bundled with the build. Version %1$s is available. Version %1$s is downloaded and ready. Update now @@ -238,10 +238,13 @@ Add apps from the list on the right. Installing version %1$s… Downloading %1$s… You’re on the latest version. - Updates install in place — no installers to hunt down. + Updates install in place. No installer to download. This build can’t update itself. Download the latest version from GitHub. View release Update failed: %1$s - A newer version is on GitHub — open the release page to download it. - © 2026 VelocityApps. All rights reserved. + A newer version is on GitHub. Open the release page to download it. + Source on GitHub + Support on Ko-fi + Support QuickNotch + © 2026 VelocityApps · MIT licensed diff --git a/composeApp/src/commonMain/kotlin/com/starkdroid/quicknotch/calendar/CalendarApi.kt b/composeApp/src/commonMain/kotlin/com/starkdroid/quicknotch/calendar/CalendarApi.kt index 07b74ab..e551be4 100644 --- a/composeApp/src/commonMain/kotlin/com/starkdroid/quicknotch/calendar/CalendarApi.kt +++ b/composeApp/src/commonMain/kotlin/com/starkdroid/quicknotch/calendar/CalendarApi.kt @@ -18,21 +18,8 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive /** - * Every Google Calendar request the app makes, in one multiplatform place. - * - * Ktor rather than `HttpURLConnection` for one reason that is not taste, and one - * that is. The reason that is not taste: the runtime jpackage bundles is - * jlink-trimmed and contains neither `java.net.http` nor `jdk.httpserver`, so - * the tidier JDK options are simply absent from the shipped app — and a missing - * module is a `NoClassDefFoundError` at the first call, which is exactly how the - * OAuth loopback died silently (pitfall 34). CIO is ordinary bytecode, so it - * travels with the jars. The reason that is taste: this file has no `java.*` in - * it, so the network layer moves to a non-JVM target unchanged. - * - * Every call is `suspend` and returns a result type instead of throwing, because - * the caller has three genuinely different failures to tell apart — refused, - * unreachable, and succeeded — and a null return collapses them (which is how a - * rejected token exchange once reported whatever the previous failure was). + * Every Google Calendar request, over Ktor CIO because the jlink runtime + * has no java.net.http and a missing module is a NoClassDefFoundError. */ class CalendarApi( private val clientId: String, @@ -42,19 +29,11 @@ class CalendarApi( private val json = Json { ignoreUnknownKeys = true; isLenient = true } - // ── OAuth ─────────────────────────────────────────────────────────────── /** - * Trade the loopback code for tokens. - * - * The secret goes along despite this being the documented PKCE-no-secret - * desktop flow, because the endpoint now rejects every code grant without - * it — Desktop clients included (verified 2026-09 against a real code: the - * identical request without the secret answers - * `invalid_request: client_secret is missing`, and Google's own gcloud - * client behaves the same). PKCE stays: it is what binds the exchange to - * *this* app's authorization request. The secret by itself authorizes - * nothing — tokens still require a real user's consent. + * Trades the loopback code for tokens. Google now requires the client + * secret on every code grant, Desktop clients included; PKCE still binds + * the exchange to this request. */ suspend fun exchangeCode(code: String, redirect: String, verifier: String): OAuthResult { val response = postForm( @@ -93,26 +72,10 @@ class CalendarApi( return runCatching { json.parseToJsonElement(body).jsonObject.string("email") }.getOrNull() } - // ── events ────────────────────────────────────────────────────────────── /** - * Today's events from the primary calendar, already filtered down to what is - * worth showing: cancelled entries are gone, and so are unnamed ones — a - * "Busy" block would put a meaningless word on the pill's ticker, so those - * are left to the day's count alone. - * - * Null means the day could not be fetched at all, which the caller treats as - * "keep the last known day" rather than "the day is empty". - */ - /** - * Today's events, with the failure named rather than flattened into a null. - * - * The flattening cost a real bug: `timeMin` went out as epoch seconds, which - * Google's API answers with a 400 ("must be an RFC3339 timestamp") — and a - * null return made that indistinguishable from every other failure, so the - * widget showed "not connected" after a sign-in that had fully succeeded, - * and nothing was logged anywhere. The timestamps are RFC3339 now; the - * reason travels back to the caller, which logs it. + * Today's events with cancelled and unnamed entries removed. A failed + * fetch returns a named failure, never an empty day. */ suspend fun events(accessToken: String, fromMillis: Long): EventsResult { val timeMin = fromMillis @@ -130,16 +93,8 @@ class CalendarApi( } }.getOrNull() ?: return EventsResult.Unreachable - // A failed status is "we could not fetch the day", which is NOT the same - // as "the day is empty" and must not be reported as one. Google's refusal - // is a JSON body with no `items` key, so parsing alone would quietly turn - // a revoked token into an empty calendar and wipe the pill's contents - // instead of keeping the last known day. Null sends the caller down its - // existing "refresh once, then keep the last known day" path, which is - // also what catches a genuinely dead token. - // - // No logging here on purpose: this file is commonMain, so it has no - // business reaching for the desktop-only log file. + // A failed status is not an empty day: return a named failure so the + // caller keeps the last known day. if (!response.status.isSuccess()) { // Google's error body is the most useful thing in the response and // it costs nothing to read -- the 403 for a disabled API names the @@ -174,7 +129,6 @@ class CalendarApi( }.getOrElse { EventsResult.Malformed } } - // ── plumbing ──────────────────────────────────────────────────────────── private suspend fun postForm(endpoint: String, form: Map): JsonObject? = runCatching { val response = client.submitForm( @@ -227,11 +181,8 @@ sealed interface EventsResult { data class Ok(val events: List) : EventsResult /** - * Google answered, and said no. [detail] is Google's own message from the - * body, because the body is the diagnosis: a 403 whose body says "Calendar - * API has not been used in project ... before or it is disabled" is the - * project's API Library, not a token problem, and the two remedies are in - * completely different places. + * Google's own message from the body: a 403 naming a disabled Calendar + * API is a project setting, not a token problem. */ data class Http(val status: Int, val detail: String? = null) : EventsResult diff --git a/composeApp/src/commonMain/kotlin/com/starkdroid/quicknotch/calendar/CalendarEvent.kt b/composeApp/src/commonMain/kotlin/com/starkdroid/quicknotch/calendar/CalendarEvent.kt index 7b2f4ef..a9d704c 100644 --- a/composeApp/src/commonMain/kotlin/com/starkdroid/quicknotch/calendar/CalendarEvent.kt +++ b/composeApp/src/commonMain/kotlin/com/starkdroid/quicknotch/calendar/CalendarEvent.kt @@ -5,7 +5,6 @@ import kotlinx.datetime.Instant import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.atStartOfDayIn -import kotlinx.datetime.plus import kotlinx.datetime.todayIn import kotlinx.datetime.toLocalDateTime @@ -16,11 +15,8 @@ import kotlinx.datetime.toLocalDateTime */ /** - * How early before an event the ticker appears, so a 10:00 meeting is already - * on screen at 9:58 rather than popping in as it starts. There is - * deliberately no linger on the far side: the ticker is a notification, not a - * status -- it leaves when the event ends or when the user expands the notch - * over it, whichever comes first, and the next event is what brings it back. + * The ticker appears this early before an event and leaves when the event + * ends or the notch expands. */ const val TICKER_LEAD_MS = 2 * 60 * 1000L @@ -36,11 +32,8 @@ data class CalendarEvent( val allDay: Boolean ) { /** - * The ticker window: [TICKER_LEAD_MS] before the start until the event - * ends. No linger on the far side — the ticker is a notification, and a - * meeting that is over should not keep scrolling across the pill. All-day - * entries never tick — "Team offsite" scrolling all morning is noise, not - * information. + * TICKER_LEAD_MS before the start until the event ends; all-day entries + * never tick. */ fun isTickerLiveAt(nowMillis: Long): Boolean = !allDay && @@ -57,12 +50,8 @@ fun todayDate( ): LocalDate = Clock.System.todayIn(timeZone) /** - * Where a fetch begins: local midnight today. There is deliberately no upper - * bound -- the API has no "schedule mode" endpoint, but omitting timeMax *is* - * schedule-mode semantics: everything upcoming from this instant, ordered, - * capped by maxResults. One fetch serves the pill badge, the ticker and the - * schedule page, so there is exactly one thing to keep in sync with Google's - * bounds instead of three queries drifting apart. + * Fetch window starts at local midnight; no upper bound is schedule mode. + * One fetch feeds the badge, the ticker and the page. */ fun fetchFromMillis( timeZone: TimeZone = TimeZone.currentSystemDefault(), @@ -76,15 +65,6 @@ data class CalendarDateGroup( val isToday: Boolean ) -/** How many of these events fall on today. */ -fun List.todayCount( - timeZone: TimeZone = TimeZone.currentSystemDefault(), - today: LocalDate = Clock.System.todayIn(timeZone) -): Int = count { event -> - Instant.fromEpochMilliseconds(event.startMillis) - .toLocalDateTime(timeZone).date == today -} - /** * Today's events that are still upcoming -- running counts, ended does not. * This is the pill badge's number, and it matches the count Google Calendar @@ -100,20 +80,7 @@ fun List.todayUpcomingCount( } /** - * How many of these events are still upcoming -- running right now counts, one - * that ended does not. This is the number the pill badge shows, so a calendar - * whose today is empty still gets its icon and count on the notch. - */ -fun List.upcomingCount( - nowMillis: Long = Clock.System.now().toEpochMilliseconds() -): Int = count { it.endMillis >= nowMillis } - -/** - * The next [maxDates] dates with an event coming, soonest first -- four, one - * per date even when a date carries several, because the schedule row is about - * where the calendar is heading, not how dense today is. Today comes first - * when it has one still running or ahead; gaps are the point, and a quiet - * calendar should not show empty days. + * The next maxDates dates with an event, one per date and no empty days. */ fun List.upcomingDateGroups( timeZone: TimeZone = TimeZone.currentSystemDefault(), diff --git a/composeApp/src/commonMain/kotlin/com/starkdroid/quicknotch/weather/WeatherApi.kt b/composeApp/src/commonMain/kotlin/com/starkdroid/quicknotch/weather/WeatherApi.kt index ed033ea..43d8e1f 100644 --- a/composeApp/src/commonMain/kotlin/com/starkdroid/quicknotch/weather/WeatherApi.kt +++ b/composeApp/src/commonMain/kotlin/com/starkdroid/quicknotch/weather/WeatherApi.kt @@ -16,24 +16,9 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive /** - * Every weather request the app makes, in one multiplatform place. - * - * Open-Meteo rather than any keyed provider for one reason that is not taste: - * there is no key to provision, rotate, or leak out of a shipped binary — the - * forecast endpoint answers anonymous GETs, so a fresh install shows weather - * with zero setup, exactly like the notch itself needs no account. Ktor CIO - * for the same jlink reason as the calendar API (only `java.base`-level - * modules ship in the packaged runtime, so the transport must be ordinary - * bytecode). This file has no `java.*` in it. - * - * Location is IP-based (ip-api, city granularity, also keyless): the notch - * has no location-permission plumbing and must not ask for any. A VPN exit - * simply reports its own city, which is honest enough for a glanceable pill. - * - * Every call is `suspend` and returns a result type instead of throwing — a - * refusal, an unreachable network, and a malformed payload are three - * different answers, and the controller keeps the last known day for all of - * them rather than blanking the pill. + * Open-Meteo and ip-api, both keyless, over Ktor CIO for the jlink + * reason. Result types keep refusals, unreachable networks and bad + * payloads apart so the last known day can stand. */ class WeatherApi( private val client: HttpClient = defaultClient() @@ -41,7 +26,6 @@ class WeatherApi( private val json = Json { ignoreUnknownKeys = true; isLenient = true } - // ── location ───────────────────────────────────────────────────────────── /** * City-granularity position from the egress IP. Free tier is HTTP-only @@ -60,13 +44,10 @@ class WeatherApi( return LocationResult.Found(latitude = lat, longitude = lon, city = city) } - // ── forecast ───────────────────────────────────────────────────────────── /** - * Current conditions plus a 7-day outlook for one position. `fahrenheit` - * switches the temperature unit; everything else (%, UV index) is - * unitless. `timezone=auto` makes day boundaries local to the position, - * so "today" on the page is the place's today, not the machine's. + * Current conditions plus a 7-day outlook; timezone=auto makes today the + * place's day. */ suspend fun forecast(latitude: Double, longitude: Double, fahrenheit: Boolean): WeatherResult { val unit = if (fahrenheit) "&temperature_unit=fahrenheit" else "" @@ -123,13 +104,9 @@ class WeatherApi( ) } - // ── place search ───────────────────────────────────────────────────────── /** - * City/state search for the manual-location picker. Same keyless provider - * as the forecast (Open-Meteo geocoding), so picking another state needs - * no key either. `parameter()` does the URL-encoding; results carry a - * display label ("Austin, Texas, United States") plus coordinates. + * Keyless Open-Meteo geocoding for the manual-location picker. */ suspend fun searchPlaces(query: String): GeocodeResult { if (query.isBlank()) return GeocodeResult.Ok(emptyList()) diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/calendar/GoogleCalendar.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/calendar/GoogleCalendar.kt index ef032b7..f4496e8 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/calendar/GoogleCalendar.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/calendar/GoogleCalendar.kt @@ -1,5 +1,6 @@ package com.starkdroid.quicknotch.calendar +import com.starkdroid.quicknotch.OAuthConfig import com.starkdroid.quicknotch.datasource.AppLauncher import com.starkdroid.quicknotch.datasource.AppLog import com.starkdroid.quicknotch.datasource.AppPaths @@ -41,11 +42,7 @@ sealed interface CalendarState { /** A browser round-trip is in flight (waiting for the consent screen). */ data object Connecting : CalendarState - /** - * Connected. [events] is the most recent successful fetch — or the cached - * list at launch, until the first fetch lands. [staleError] marks a failed - * refresh that left a still-good list on screen. - */ + /** Connected; [events] is the last successful or cached list, [staleError] marks a failed refresh. */ data class Connected( val account: String, val events: List, @@ -57,80 +54,31 @@ sealed interface CalendarState { } /** - * Something failed. [reason] is a stable key the UI localises (never a raw - * server message, which would leak internals into the settings window). + * [reason] is a stable key the UI localises; a raw server message would leak internals into settings. */ data class Error(val reason: String) : CalendarState } /** - * Google Calendar, read-only, via the OAuth 2.0 authorization-code flow with - * PKCE and a loopback redirect — the shape Google documents for installed - * desktop apps, where no client secret exists and PKCE is what protects the - * code exchange. - * - * Everything runs in the poller's own IO scope: the consent round-trip blocks - * on a loopback socket, so it can never happen on the EDT. - * - * - Tokens live in `calendar.json` beside the config, and the last good event - * list in `calendar-events.json`, so a cold start shows today's plan before - * any network call completes. - * - The access token is refreshed on demand (expiry skew, or a 401 mid-flight) - * and a revoked refresh token drops straight back to [CalendarState.Disconnected] - * so the widget offers "Connect" instead of looping failures. - * - A failed refresh never clears a good list; it annotates it with - * [CalendarState.Connected.staleError]. + * Google Calendar, read-only, via OAuth with PKCE. Tokens and the last event list are cached on + * disk, and a revoked grant drops back to [CalendarState.Disconnected] instead of looping failures. */ object GoogleCalendar { - // **Web application** client id from Google Cloud (APIs & Services → - // Credentials → OAuth client ID → Web application). The type is forced, not - // preferred: a *Desktop app* client cannot register an https redirect URI at - // all (the console does not render the field for that type), and the loopback - // flow it does support is exactly what this app stopped doing in favour of the - // hosted consent page. Web clients are confidential, which is why the secret - // below is required rather than merely tolerated. - private const val CLIENT_ID = "317663226214-1cijquso00ocuj44pldu192bgg8jgqio.apps.googleusercontent.com" + // Web application client, injected at build time (the id is not committed). A Desktop client + // cannot register an https redirect URI at all. + private const val CLIENT_ID = OAuthConfig.CLIENT_ID /** - * Required because the client is a Web application (pitfall 36). It is - * extractable from any shipped binary, which is inherent to desktop OAuth — - * the same reason gcloud ships its own. It authorizes nothing alone: every - * token still needs a real user's interactive consent. Rotate it in the - * Cloud console if it ever leaks wider than this repo. + * Injected at build time; empty means the build says it has no Google client instead of starting + * a flow it cannot finish. A shipped binary can be made to give it up, but it authorizes nothing alone. */ - private const val CLIENT_SECRET = "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" + private const val CLIENT_SECRET = OAuthConfig.CLIENT_SECRET - /** - * The website owns the consent UI (it has its own editorial voice, and - * "connect QuickNotch to your Google Calendar" is a story the marketing site - * tells better than a settings row can). The desktop opens the connect page - * with the loopback intent encoded in `state`; the page sends the user to - * Google with `state` set to exactly that string. Google bounces the browser - * back to the *callback* page, which jumps the tab home with a top-level - * navigation to the app's loopback server, where the code lands and the - * exchange happens with the secret that never leaves this process. - * - * Only the callback path is a registered redirect URI — the connect page is - * an entry point, and registering it was never needed or possible. - * - * The origin is an environment variable, and the default is deliberately - * `vite dev` rather than the deployed site: the site does not exist yet, so - * testing this flow is the *current* job and should not need a flag to - * remember. Google exempts loopback origins from the HTTPS rule ("Redirect - * URIs must use the HTTPS scheme, not plain HTTP. Localhost URIs (including - * localhost IP address URIs) are exempt from this rule"), which is what makes - * `http://localhost:5173/oauth/callback` a legal redirect URI at all. - * - * The port is part of the registered URI, so the dev server must be started - * via `npm run dev:oauth` (`--strictPort`) — plain `npm run dev` silently - * falls through to 5174 if 5173 is busy, and every flow then fails as - * `redirect_uri_mismatch` for a reason that has nothing to do with the code. - * - * TO GO LIVE: set [PRODUCTION_WEB_BASE] as [DEFAULT_WEB_BASE], register - * `https://quicknotch.app/oauth/callback` on the client, and update - * `calendarAuth` in the site so its fallback matches. The env var still wins, - * so nothing else changes. - */ + private val hasClient: Boolean + get() = CLIENT_ID.isNotBlank() && CLIENT_SECRET.isNotBlank() + + // Only /oauth/callback is a registered redirect URI, and the port is part of it: the dev server + // must use `npm run dev:oauth` (--strictPort) or every flow fails as redirect_uri_mismatch. private const val WEB_BASE_ENV = "QUICKNOTCH_WEB_BASE" private const val PRODUCTION_WEB_BASE = "https://quicknotch.app" private const val DEFAULT_WEB_BASE = "http://localhost:5173" @@ -153,13 +101,7 @@ object GoogleCalendar { private const val CONNECT_TIMEOUT_MS = 5 * 60 * 1000L - /** - * Per-stage tracing for the consent handshake, the one flow that is opaque - * from the outside (a browser is involved, and a failure used to look the - * same as every other failure). On by default: it only ever prints while - * the user is deliberately connecting, and it is the difference between - * "it did not work" and a fixable sentence. - */ + /** Per-stage tracing for the consent handshake, the one flow that is opaque from outside. */ private const val VERBOSE = true /** Reason keys; the UI maps them to localized strings. */ @@ -169,35 +111,19 @@ object GoogleCalendar { /** The user closed the consent screen or declined the app. */ const val REASON_CANCELLED = "cancelled" /** - * Google refused because the OAuth consent screen is still in Testing and - * the signed-in account is not one of its test users. Arrives as - * `access_denied` like a plain decline, so it has to be told apart by the - * description or the user is told they cancelled something they did not. + * The consent screen is still in Testing and this account is not a test user; Google sends + * `access_denied`, same as a plain decline, so the two are told apart by the description. */ const val REASON_NOT_VERIFIED = "not_verified" - /** - * Google refused the token exchange over the client secret. The client is a - * Web application and does send one, so this is nearly always a secret that - * was rotated, mistyped, or has not propagated yet -- not a client-type - * mistake the way it was before the move to a Web client. Kept as its own - * reason because "everything visible worked, only the exchange was - * rejected" is baffling without naming the stage that failed. - */ - const val REASON_CLIENT_TYPE = "client_type" - /** - * Tokens exist and work, but the Calendar API itself is not enabled for the - * project, so every events read is denied with 403. The fix is the API - * Library in the Cloud console, not anything in this app -- and without a - * distinct reason the widget would say "couldn't reach Google" for a - * problem that has nothing to do with reaching Google. - */ + /** Google refused the token exchange over the client credential: a missing, rotated, or unpropagated secret. */ + const val REASON_CLIENT_CREDENTIALS = "client_credentials" + + /** This build has no Google client secret, so no code can be exchanged. */ + const val REASON_NOT_CONFIGURED = "not_configured" + /** Tokens work but the Calendar API is not enabled for the project, so every read gets a 403. */ const val REASON_API_DISABLED = "api_disabled" - /** - * All four Google calls, owned by the common (multiplatform) layer. One - * client for the process: it owns a connection pool and a selector thread, - * and a client per request would throw both away. - */ + /** One client for the process: it owns a connection pool and a selector thread. */ private val api = CalendarApi(CLIENT_ID, CLIENT_SECRET) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @@ -207,37 +133,21 @@ object GoogleCalendar { private val _state = MutableStateFlow(CalendarState.Disconnected) val state: StateFlow = _state.asStateFlow() - /** - * The event whose ticker is up right now, or null. Resolved here rather than - * in the overlay so "is an event live" is one rule next to the window that - * defines it — the notch only has to read a flow. - */ + /** The event whose ticker is up right now, resolved here so the notch only reads a flow. */ private val _ticker = MutableStateFlow(null) val ticker: StateFlow = _ticker.asStateFlow() /** - * "Now", sampled once a minute, so anything derived from the wall clock -- - * the pill badge, the schedule tiles -- decays as events end instead of - * waiting for the next fetch to notice. Minute granularity is finer than - * any calendar display needs, and one state write per minute change is - * nothing next to recomposing on every 1s loop pass (pitfall 29). + * "Now" sampled once a minute, so derived values decay as events end; one write per minute + * beats recomposing on every 1s loop pass. */ private val _minuteTick = MutableStateFlow(System.currentTimeMillis() / 60_000L * 60_000L) val minuteTick: StateFlow = _minuteTick.asStateFlow() - /** - * Events whose ticker the user has already seen, so expanding the notch - * over one dismisses it until the *next* event starts. Session-scoped on - * purpose: after a restart an in-progress event re-announces itself, which - * is the honest thing for a notification to do. - */ + // Session-scoped on purpose: after a restart an in-progress event re-announces itself. private val seenTickerEventIds = mutableSetOf() - /** - * Consume the ticker currently on the pill. Called when the notch expands - * over it: the detail lives on the calendar page the expansion lands on, - * and a dismissed ticker stays dismissed until a different event starts. - */ + /** Consume the ticker on the pill; it stays dismissed until a different event starts. */ fun dismissTicker() { _ticker.value?.let { seenTickerEventIds += it.id } _ticker.value = null @@ -248,37 +158,25 @@ object GoogleCalendar { private var cached: Tokens? = null - /** - * Why the last [connect] attempt gave up, as one of the REASON_ keys. A - * field rather than a return value because [authorize] is where the detail - * lives and it already returns null for every outcome. - */ + /** Why the last [connect] attempt gave up, as one of the REASON_ keys. */ private var connectFailure: String = REASON_AUTH private var connectJob: Job? = null private var pendingCallback: CompletableFuture? = null /** - * The exact `state` string this attempt sent Google. It carries the loopback - * port the site needs to jump the browser home, so the echoed value is the - * only thing the loopback server can trust as coming back from this flow — - * a callback without it (or with a stale one) is refused, never answered. + * The exact `state` string this attempt sent Google: it carries the loopback port, so a + * callback without it (or with a stale one) is refused. */ private var expectedState: String? = null private var cancelledByUser = false private var tickerJob: Job? = null private var pollJob: Job? = null - // ── lifecycle ─────────────────────────────────────────────────────────── - /** Start the poller. Idempotent; called once from the app. */ fun start() { if (pollJob != null) return - // The ticker loop is armed *before* the token check on purpose: it - // resolves against whatever is connected right now, and connecting is - // something the user does from settings long after launch. Started only - // on the "already connected at launch" path it would never run for that - // user — they would get the day and the badge, and a permanently empty - // ticker. It is cheap when idle: one delay, one equality check. + // Armed before the token check: connecting happens later from settings, so starting only on + // the already-connected path would leave the ticker permanently empty. startTicker() cached = readTokens() @@ -301,12 +199,7 @@ object GoogleCalendar { } } - /** - * A cheap 1s tick that only writes when the answer changes: event boundaries - * are all the ticker cares about, and an unchanged value costs no - * recomposition (pitfall 29's rule, applied to a flow rather than an - * animation). 1s is finer than the resolution any calendar UI needs. - */ + /** A 1s tick that writes only when the answer changes; an unchanged value costs no recomposition. */ private fun startTicker() { if (tickerJob != null) return tickerJob = scope.launch { @@ -323,16 +216,20 @@ object GoogleCalendar { } } - /** Run the web-hosted consent flow: open the connect page, wait for the - * site to bounce the code back to the loopback landing strip. */ + /** Open the web-hosted consent page and wait for the site to bounce the code back. */ fun connect() { if (_state.value is CalendarState.Connecting) return + if (!hasClient) { + // A build made without the injected client, not a user mistake: say + // what is missing before opening a browser that could never finish. + connectFailure = REASON_NOT_CONFIGURED + _state.value = CalendarState.Error(REASON_NOT_CONFIGURED) + AppLog.line("Google sign-in skipped: this build has no Google client") + return + } cancelledByUser = false - // Each attempt starts from the generic reason. Without this the widget - // reported whatever the *previous* failure was whenever a path returned - // null without setting one (a rejected token exchange, say), so a - // "not verified" from two clicks ago was still on screen after an - // unrelated one. + // Start each attempt from the generic reason: a path that returns null without setting one + // used to leave the previous attempt's failure on screen. connectFailure = REASON_AUTH connectJob = scope.launch { _state.value = CalendarState.Connecting @@ -349,11 +246,8 @@ object GoogleCalendar { cached = tokens writeTokens(tokens) log("token exchange succeeded") - // Connected is true the moment tokens exist; whether the first day - // fetch works is a separate state that layers on top. Landing here - // first is what keeps a slow or failing first fetch from reading as - // "sign-in didn't finish" after a sign-in that fully succeeded -- - // which is exactly what three console theories failed to explain. + // Connected the moment tokens exist: a slow or failing first fetch must not read as a + // sign-in that did not finish. _state.value = CalendarState.Connected( account = tokens.account.ifBlank { "Google" }, events = emptyList(), @@ -364,11 +258,7 @@ object GoogleCalendar { } } - /** - * Give up on a sign-in in progress. Without this the widget sat on a spinner - * for the full five-minute budget whenever the browser never showed up — - * which is indistinguishable, from the settings window, from a hang. - */ + /** Give up on a sign-in in progress, instead of spinning out the full five-minute budget. */ fun cancelConnect() { if (_state.value !is CalendarState.Connecting) return cancelledByUser = true @@ -386,9 +276,7 @@ object GoogleCalendar { runCatching { tokenFile.delete() } runCatching { eventsFile.delete() } _state.value = CalendarState.Disconnected - // Otherwise the pill keeps scrolling an event from a calendar we just - // unhooked; the loop itself keeps running and resolves null. Seen ids - // go too, so a reconnect starts the notifications fresh. + // Without this the pill keeps scrolling an event from the calendar just unhooked. seenTickerEventIds.clear() _ticker.value = null } @@ -398,13 +286,10 @@ object GoogleCalendar { scope.launch { refreshBlocking() } } - // ── OAuth ─────────────────────────────────────────────────────────────── /** - * The whole consent handshake, in one try so that *nothing* can leave the - * widget spinning: a throw while setting up the listener used to escape - * uncaught, kill the coroutine, and strand the state on [CalendarState.Connecting] - * with no log line and no way out. + * The whole consent handshake in one try: a throw while setting up the listener must not leave + * the state stranded on [CalendarState.Connecting]. */ private suspend fun authorize(): Tokens? { val verifier = randomUrlToken(64) @@ -415,18 +300,13 @@ object GoogleCalendar { var listenerThread: Thread? = null val redirect = webCallbackUrl return try { - // Landing strip on loopback: the site's callback page jumps the - // browser here (a *top-level* navigation, so no CORS/PNA dance) - // after Google has bounced the code back to the website. The code - // is exchanged in this process, because the client secret lives - // here and nowhere else. + // The site's callback page jumps the browser here with a top-level navigation, and the + // code is exchanged in this process because the client secret lives here. val server = ServerSocket(0, 1, InetAddress.getLoopbackAddress()) server.soTimeout = CONNECT_TIMEOUT_MS.toInt() listener = server - // The loopback port rides inside `state`: the consent round-trip - // goes through the website, and its callback page can only learn - // *which* port to jump home to from the exact echo Google returns. + // The loopback port rides inside `state`, the only echo Google returns. val state = base64Url( "{\"p\":${server.localPort},\"s\":\"${randomUrlToken(16)}\"}" .toByteArray(Charsets.UTF_8) @@ -455,8 +335,7 @@ object GoogleCalendar { "port" to server.localPort.toString(), "state" to state, "scope" to SCOPE, - // Offline so we get a refresh token at all. `prompt=consent` - // only on a fresh connect: without it Google silently + // Offline for a refresh token at all, plus prompt=consent: without it Google // withholds the refresh token for an already-authorised app. "access_type" to "offline", "prompt" to "consent", @@ -498,9 +377,7 @@ object GoogleCalendar { AppLog.line("Google sign-in failed: $e") null } catch (e: Error) { - // NoClassDefFoundError and friends are Errors, not Exceptions, so - // the clause above misses them entirely — which is exactly how a - // missing runtime module used to vanish without a trace. + // NoClassDefFoundError is an Error, not an Exception, so the clause above misses it entirely. connectFailure = REASON_AUTH log("consent died: $e") AppLog.line("Google sign-in died: $e") @@ -514,15 +391,8 @@ object GoogleCalendar { } /** - * Answer one loopback request and pull the answer out of its query string. - * - * Hand-rolled on a plain [ServerSocket] rather than - * `com.sun.net.httpserver.HttpServer`: jlink trims the runtime jpackage - * bundles, and `jdk.httpserver` is not in the image Compose ships, so - * referencing that class threw `NoClassDefFoundError` in the packaged app - * and nowhere else. `ServerSocket` is `java.base`, so it is always there. - * The whole exchange is four headers and a small page, which is all this - * needs to be. + * Hand-rolled on a plain [ServerSocket]: the jlink runtime has no `jdk.httpserver`, so HttpServer + * threw `NoClassDefFoundError` in the packaged app and nowhere else. */ private fun serveCallback(server: ServerSocket) { server.accept().use { socket -> @@ -552,11 +422,8 @@ object GoogleCalendar { val query = queryParams(target.substringAfter('?', "")) when { - // Google bounces the browser back with `error=...` when consent - // was declined, the client is unknown, or the grant is blocked. - // Read it instead of sitting out the whole timeout on a callback - // that will never carry a code — that wait is what made a - // declined sign-in and a broken one look identical. + // Google answers with `error=...` on a decline; read it instead of sitting out the + // whole timeout on a callback that can never carry a code. query["error"] != null -> { log("callback carried ${query.getValue("error")}") pendingCallback?.completeExceptionally( @@ -568,9 +435,7 @@ object GoogleCalendar { pendingCallback?.complete(query.getValue("code")) } query["code"] != null -> { - // A stale or forged jump: the site is meant to echo the - // exact state back. Refuse it rather than exchange a code - // that does not belong to this attempt. + // A stale or forged jump: refuse a code that does not belong to this attempt. log("callback carried a code but a mismatched state; refusing") pendingCallback?.completeExceptionally( GoogleAuthException("state_mismatch", "callback state did not match this attempt") @@ -581,11 +446,7 @@ object GoogleCalendar { } } - /** - * Trade Google's code for tokens. The request itself lives in [CalendarApi] - * (commonMain, Ktor); what stays here is the part that is genuinely - * platform-shaped: deciding what a refusal means for the widget. - */ + /** What stays here is the platform-shaped part: what a refusal means for the widget. */ private suspend fun exchangeCode(code: String, redirect: String, verifier: String): Tokens? = when (val result = api.exchangeCode(code, redirect, verifier)) { is OAuthResult.Tokens -> Tokens( @@ -596,10 +457,8 @@ object GoogleCalendar { ) is OAuthResult.Refused -> { connectFailure = classifyTokenRefusal(result) - // The client id rides along because it is the one thing that - // names the record Google is actually refusing: two consoles - // can show a "Desktop" client while the id in the binary belongs - // to another one entirely. + // The client id goes into the log: two consoles can show a "Desktop" client while + // the id in the binary belongs to another one. AppLog.line( "Google token exchange rejected the code for client ${resultForLog()} : " + "${result.error} ${result.description.orEmpty()}".trim() @@ -613,13 +472,11 @@ object GoogleCalendar { } } - /** - * Renew an access token. A null refresh token means we cannot renew, which - * is a dead connection rather than a transient one — the caller treats it as - * revoked and prompts again. - */ + /** A null refresh token means we cannot renew: a dead connection, so the caller treats it as revoked. */ private suspend fun refreshed(previous: Tokens): Tokens? { - val refresh = previous.refreshToken ?: return null + // Nothing to send without the build's secret; `revoked()` turns that into + // the "no client configured" message and keeps the stored grant. + val refresh = previous.refreshToken?.takeIf { hasClient } ?: return null return when (val result = api.refresh(refresh)) { is OAuthResult.Tokens -> previous.copy( accessToken = result.accessToken, @@ -636,12 +493,7 @@ object GoogleCalendar { } } - /** - * One day fetch, with every failure mode logged. This used to be a silent - * null and a 400 from a malformed query read as "not connected" after a - * sign-in that had entirely succeeded -- the log is what turns that class - * of bug from an afternoon into a sentence. - */ + /** One day fetch, with every failure mode logged. */ private suspend fun fetchDay(tokens: Tokens): FetchOutcome { return when (val result = api.events(tokens.accessToken, fetchFromMillis())) { is EventsResult.Ok -> { @@ -668,12 +520,7 @@ object GoogleCalendar { } } - /** - * A 403 whose body talks about a disabled or never-used API is the project - * missing the Calendar API enablement -- a console step, nothing in the - * app, and worth its own message because "couldn't reach Google" sends the - * user looking at their router. - */ + /** A 403 naming a disabled API is a missing console step, not a network problem. */ private fun classifyFetchFailure(status: Int, detail: String?): String { if (status == 403) { val d = detail?.lowercase().orEmpty() @@ -688,20 +535,10 @@ object GoogleCalendar { data class Failed(val reason: String) : FetchOutcome } - // ── fetching ──────────────────────────────────────────────────────────── - - /** - * One refresh at a time. The button can be clicked faster than a round trip - * finishes, and two overlapping refreshes would race the token file and, on - * a server that rotates refresh tokens, invalidate one of them. - */ + /** One refresh at a time: overlapping refreshes race the token file and a rotating server invalidates one. */ private val refreshMutex = Mutex() - /** - * Observable "a day fetch is running", for the spinner that replaces the - * Refresh chip and for the notch page's loading state. The fetch itself - * never blocks any UI: it runs on the IO scope and only this flag moves. - */ + /** Observable "a day fetch is running", for the spinner and the page's loading state. */ private val _refreshing = MutableStateFlow(false) val refreshing: StateFlow = _refreshing.asStateFlow() @@ -735,10 +572,8 @@ object GoogleCalendar { outcome = fetchDay(tokens) } if (outcome is FetchOutcome.Failed) { - // The caller may be mid-connect (state still Connecting), in which - // case there is no Connected state to keep stale -- but the tokens - // are real and on disk, so the honest state is Connected-with-a- - // stale-day rather than an error that reads as "sign-in failed". + // The caller may be mid-connect: the tokens are real and on disk, so the honest state + // is Connected with a stale day, not an error that reads as "sign-in failed". val previous = _state.value as? CalendarState.Connected _state.value = previous?.copy(staleError = outcome.reason) ?: CalendarState.Connected( account = tokens.account.ifBlank { "Google" }, @@ -766,13 +601,17 @@ object GoogleCalendar { /** Google withdrew access (refresh token dead): forget it so the UI can re-prompt. */ private fun revoked() { + if (!hasClient) { + // Not a revoked grant: this build cannot renew at all, so keep the + // stored tokens for a build that can. + _state.value = CalendarState.Error(REASON_NOT_CONFIGURED) + return + } cached = null runCatching { tokenFile.delete() } _state.value = CalendarState.Error(REASON_REVOKED) } - // ── token + event store ───────────────────────────────────────────────── - @Serializable private data class Tokens( val accessToken: String, @@ -829,16 +668,13 @@ object GoogleCalendar { } } - // ── helpers ───────────────────────────────────────────────────────────── /** Google answering a consent redirect with a refusal. */ private class GoogleAuthException(val error: String, val description: String?) : Exception(error) /** - * The id this build exchanges with, for correlating refusals with the console. - * A secret-shaped refusal is only interpretable next to it: the same error - * means a wrong client on an old build and a rotated or not-yet-propagated - * secret on this one. + * The client id this build exchanges with, so a refusal can be read + * against the console. */ private fun resultForLog(): String = CLIENT_ID @@ -846,15 +682,16 @@ object GoogleCalendar { val description = refusal.description?.lowercase().orEmpty() val wantsASecret = "client_secret" in description || (refusal.error == "invalid_client" && description.contains("secret")) - return if (wantsASecret) REASON_CLIENT_TYPE else REASON_AUTH + return when { + !wantsASecret -> REASON_AUTH + !hasClient -> REASON_NOT_CONFIGURED + else -> REASON_CLIENT_CREDENTIALS + } } /** - * Google's own copy for "this app is not verified yet" arrives as - * `access_denied`, the same code as a user pressing Cancel. The two are told - * apart by the description, because the remedy is completely different: one - * needs a console setting, the other needs the user to stop cancelling. - * Unrecognised refusals stay [REASON_AUTH] rather than guessing. + * Consent refusal and Cancel share access_denied; the description tells + * them apart. Unknown refusals stay REASON_AUTH. */ private fun classifyConsentRefusal(failure: GoogleAuthException): String { if (failure.error != "access_denied") return REASON_AUTH @@ -893,11 +730,8 @@ object GoogleCalendar { } /** - * The pill badge's number: today's events that are still upcoming -- running - * right now counts, one that already ended does not. That is both what the - * user reads on Google Calendar for the day ("2 upcoming events") and what the - * notch can still announce; a whole-window count would badge the pill with - * December while the day itself has nothing going on. + * Today's still-upcoming events: a running one counts, a finished one + * does not. */ fun calendarUpcomingCount(state: CalendarState, nowMillis: Long): Int = (state as? CalendarState.Connected)?.events?.todayUpcomingCount(nowMillis = nowMillis) ?: 0 diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/AppIconLoader.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/AppIconLoader.kt index ddb8067..34726cd 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/AppIconLoader.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/AppIconLoader.kt @@ -13,15 +13,11 @@ import kotlin.math.max import kotlin.math.roundToInt object AppIconLoader { - // Cached bitmaps are capped to this edge length. Both the dock and the - // settings rows draw far smaller, and on the software renderer a row that - // samples a full-resolution (up to 1024px) icon pays that resample cost - // every single frame — the main cause of scroll jank once icons loaded. + // Cached bitmaps are capped here: on the software renderer a row sampling a full-res icon pays + // the resample cost every frame, which is the main cause of scroll jank. internal const val cachedIconMaxPx = 128 - // Scanning a full installed-apps list fires one load per row at once; a - // little backpressure keeps dozens of native calls (or sips/quicklook - // processes) from piling up and timing out. + // Scan fires one load per row at once; cap the concurrent native calls so they do not pile up and time out. private val iconLoadGate = Semaphore( permits = minOf(8, maxOf(4, Runtime.getRuntime().availableProcessors() / 2)) ) @@ -77,11 +73,7 @@ object AppIconLoader { } } -/** - * Shrinks a freshly decoded icon to at most [AppIconLoader.cachedIconMaxPx] - * before it is cached and handed to the UI. Halving progressively keeps small - * sizes sharp (a single jump from 1024px aliases badly). - */ +/** Shrinks a decoded icon before it is cached; halving progressively keeps small sizes sharp. */ internal fun ImageBitmap.downscaleForCache(): ImageBitmap { if (width <= AppIconLoader.cachedIconMaxPx && height <= AppIconLoader.cachedIconMaxPx) return this diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/AppInstaller.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/AppInstaller.kt index fc2bedb..715702a 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/AppInstaller.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/AppInstaller.kt @@ -4,36 +4,17 @@ import java.io.File /** * Replaces the running install with a downloaded package. - * - * There is no third-party updater here on purpose. Compose Desktop ships a - * jpackage launcher, and jpackage already knows how to upgrade its own app: - * the Windows MSI carries the same upgrade UUID with a higher version, and the - * macOS DMG holds a bundle we can rsync into place. That is the whole update - * mechanism, and it needs no bootstrap process, no second copy of the app, and - * no code signing ceremony beyond what the installer already requires. - * - * The one thing a process cannot do is replace files it is running from, so the - * actual work happens in a tiny detached script that waits for this JVM's PID - * to disappear first. The app quits immediately after launching it; the script - * installs and relaunches. - * - * Fail-soft by design: no jpackage path (a Gradle `run`, a dev build) means - * self-update is simply unavailable, and every failure falls back to opening - * the downloaded package so the user can finish by hand. + * jpackage upgrades its own app (MSI upgrade UUID, DMG bundle rsync), and a process cannot + * replace files it runs from, so a detached script waits for this PID and installs. */ object AppInstaller { - /** - * jpackage sets `jpackage.app-path` for the launcher it generates. Its - * presence is also the honest answer to "is this a real install?": a - * gradle-run JVM must never try to replace itself. - */ + /** Set only by a jpackage launcher; a Gradle-run JVM has no path and must never replace itself. */ private val packagedPath: String? = System.getProperty("jpackage.app-path")?.takeIf { it.isNotBlank() } val canSelfUpdate: Boolean get() = packagedPath != null - /** Start the detached installer, or return false if we could not. */ fun launch(packageFile: File): Boolean = runCatching { require(packageFile.isFile) { "downloaded package is missing" } val log = File(AppPaths.configDir, "update.log") @@ -110,7 +91,7 @@ object AppInstaller { exit 1 fi # rsync, not cp -R/ditto: those leave an empty bundle behind on this - # exact layout (agents.md), which reads as "the update ate my app". + # exact layout, which reads as "the update ate my app". if ! rsync -a "${'$'}app/Contents/" "${'$'}target/Contents/"; then open "${'$'}pkg" exit 1 diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/AppLauncher.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/AppLauncher.kt index b1094ef..96d611c 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/AppLauncher.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/AppLauncher.kt @@ -7,9 +7,7 @@ import java.awt.Desktop import java.io.File import java.util.concurrent.TimeUnit -/** - * Launches pinned applications. Windows uses cmd/UWP shell; macOS uses `open`. - */ +/** Launches pinned applications; Windows uses cmd/UWP shell, macOS uses `open`. */ object AppLauncher { suspend fun launch(app: AppItem): Result = withContext(Dispatchers.IO) { @@ -98,11 +96,7 @@ object AppLauncher { builder.start() } - /** - * Brings a media provider forward by launch identifier (bundle id on macOS, - * e.g. `com.apple.Music`) or app name — used when the user clicks a now-playing - * album art. - */ + /** Brings a media provider forward by bundle id (macOS, e.g. `com.apple.Music`) or app name. */ fun openMediaProvider(sourceId: String, sourceName: String) { runCatching { if (HostPlatform.isMac) { @@ -118,16 +112,8 @@ object AppLauncher { } /** - * Open a URL in the user's browser. Public because the calendar's OAuth - * consent screen is just another link to hand to the OS, so it reuses this - * path rather than growing a second one. - * - * `open` goes first on purpose. [Desktop.browse] is the tidier API and was - * tried first, but it can return normally without a browser having actually - * opened — a no-op is indistinguishable from success from here, and this call - * sits in the middle of a handshake whose whole failure mode is silence. - * A spawned `open` at least has an exit code, and both outcomes are logged - * so a packaged app (which has no console anyone can read) leaves a trace. + * Opens a URL in the browser. `open` goes first because [Desktop.browse] can return normally + * without a browser having opened, and both outcomes are logged for the packaged app. */ fun browse(url: String) { if (HostPlatform.isMac) { @@ -146,11 +132,7 @@ object AppLauncher { } } - /** - * Spawn the OS hand-off and reap it, so a child is never left behind. - * Returns whether it looked like it worked: `open` exits non-zero when it - * cannot hand the URL over, which is a real answer rather than a guess. - */ + /** Spawns the hand-off and reaps it; `open` exits non-zero when it cannot pass the URL on. */ private fun spawnAndReap(vararg command: String): Boolean = runCatching { val process = ProcessBuilder(*command).start() // Drain the child's pipes so a chatty one cannot fill them and wedge. diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/AppLog.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/AppLog.kt index f078e3f..0f5847c 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/AppLog.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/AppLog.kt @@ -4,38 +4,23 @@ import java.io.File import java.time.LocalDateTime import java.time.format.DateTimeFormatter -/** - * Diagnostics that outlive the terminal. - * - * The startup lines ("skiko render backend = METAL", the passthrough state) all - * go to stderr, which is perfect for a Gradle run and worthless for the packaged - * app: a jpackage `.app` has no console anyone is watching, so a failure the user - * can only describe as "it did nothing" is also a failure we cannot read. The - * file lands in the same app-data dir as the config, so the user can open it and - * so can we, after the fact. - * - * Deliberately append-only and self-trimming rather than a logging framework: the - * app has no log file today, and the only thing that needs one is the handful of - * failures a user can hit but not see. - */ +/** Stderr is invisible in a packaged app, so diagnostics also land in quicknotch.log in the config dir. */ object AppLog { private const val FILE_NAME = "quicknotch.log" private const val MAX_BYTES = 256L * 1024L private val stamp = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") - /** The file behind [line]; also handy for pointing a user at. */ + /** The file behind [line]. */ val file: File get() = File(AppPaths.configDir, FILE_NAME) - /** Print to stderr (the Gradle-run case) *and* keep it on disk. */ fun line(message: String) { val text = "QuickNotch: $message" System.err.println(text) runCatching { val target = file target.parentFile?.mkdirs() - // Truncate rather than rotate: two files means guessing which half - // matters, and the interesting line is always the most recent one. + // Truncate rather than rotate: the interesting line is always the most recent. if (target.length() > MAX_BYTES) target.writeText("") target.appendText("${LocalDateTime.now().format(stamp)} $text\n") } diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/AppPaths.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/AppPaths.kt index 003af43..9035455 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/AppPaths.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/AppPaths.kt @@ -2,15 +2,7 @@ package com.starkdroid.quicknotch.datasource import java.io.File -/** - * The per-user app-data directory, in one place. - * - * `FavoritesRepository` used to compute this inline, which meant anything that - * needs the directory *before* the repository exists — the render-backend - * marker read at startup, for instance — had no way to reach it. Keeping the - * resolution here also means the path can never drift between the settings - * writer and the readers. - */ +/** The per-user app-data directory, resolved here so every writer and reader agrees on it. */ object AppPaths { val configDir: File by lazy { val dir = when { diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/AppUpdates.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/AppUpdates.kt index 13113a1..d0613b5 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/AppUpdates.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/AppUpdates.kt @@ -8,9 +8,7 @@ import io.ktor.client.request.header import io.ktor.client.statement.bodyAsChannel import io.ktor.http.HttpHeaders import io.ktor.http.isSuccess -import java.awt.Desktop import java.io.File -import java.net.URI import java.util.concurrent.atomic.AtomicBoolean import com.starkdroid.quicknotch.AppVersion import kotlinx.coroutines.CancellationException @@ -22,7 +20,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -/** What the updater knows right now. */ sealed interface UpdateState { data object Idle : UpdateState data object Checking : UpdateState @@ -40,14 +37,8 @@ sealed interface UpdateState { } /** - * The app-side updater: check GitHub, download the right package, hand it to - * [AppInstaller], and let the main process quit. - * - * Deliberately *not* a silent auto-installer. It downloads quietly, then waits - * for the user to press "Restart & update": swapping the binary out from under - * someone who is mid-song or mid-game is not a feature. Everything the user - * avoids is the manual part — finding the release, downloading the right - * installer, running it, relaunching. + * Checks GitHub, downloads the right package and hands it to [AppInstaller]. + * It never restarts on its own: the user presses Restart & update. */ object AppUpdates { @@ -91,11 +82,7 @@ object AppUpdates { } } - /** - * Ask GitHub for the latest release. [force] bypasses the debounce; the - * changelog panel uses the debounced form so opening it repeatedly does - * not hammer the API. - */ + /** Asks GitHub for the latest release; [force] bypasses the debounce the changelog panel uses. */ @Suppress("TooGenericExceptionCaught") fun check(force: Boolean = false) { val mayCheck = !busy() && @@ -146,15 +133,12 @@ object AppUpdates { fun openReleasePage() { val url = (_state.value as? UpdateState.Available)?.releaseUrl ?: ReleaseApi.RELEASES_PAGE - runCatching { Desktop.getDesktop().browse(URI(url)) } - .onFailure { AppLog.line("update: could not open browser: ${it.message}") } + ProjectLinks.open(url) } private suspend fun refresh() { _state.value = UpdateState.Checking - // The release carries the exact changelog asset URL, which beats the - // `/releases/latest/download/...` guess while the repository still has - // an old tag that sorts higher than 2.x. + // The release's exact changelog URL beats the `/releases/latest` guess, which finds the old tag. val release = ReleaseApi.latest() Changelog.refresh(release?.changelogUrl) @@ -175,18 +159,15 @@ object AppUpdates { asset = asset?.copy(sha256 = checksum), releaseUrl = release?.pageUrl ?: ReleaseApi.RELEASES_PAGE ) - // Fetch the package in the background: the only decision left to the - // user is when to restart. `download()` no-ops when this build cannot - // replace itself (a Gradle run) or the release has nothing for this - // platform, in which case the banner offers the release page instead. + // Fetch in the background; `download()` no-ops when this build cannot replace itself, + // in which case the banner offers the release page instead. download() } @Suppress("TooGenericExceptionCaught") private suspend fun runDownload(version: String, asset: UpdateAsset) { val directory = File(AppPaths.configDir, "updates").apply { mkdirs() } - // One package at a time: a stale download from an older version would - // only waste disk and confuse a later install. + // One package at a time: a stale download would only waste disk and confuse a later install. directory.listFiles()?.forEach { it.delete() } val target = File(directory, asset.name) diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/Changelog.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/Changelog.kt index a9457b3..817a006 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/Changelog.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/Changelog.kt @@ -17,18 +17,8 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json /** - * The changelog as published by the release workflow. - * - * The feed is deliberately *not* bundled with the app: a build ships without - * any release notes at all and reads them from GitHub, so the history a user - * sees is the real published history rather than whatever was frozen into the - * installer months ago. The URL is the `changelog.json` asset on the newest - * managed release (falling back to the `releases/latest` download path), which - * the workflow rewrites with the full cumulative list on every release. - * - * [releases] is newest-first. The UI keeps only entries at or below the running - * version — a newer entry is an *update*, not something this build should - * pretend to document (see `AppUpdates`). + * The changelog published by the release workflow, read from GitHub rather than bundled so + * the history is the real published one. [releases] is newest-first; the UI hides newer versions. */ @Serializable data class ChangelogFeed( @@ -55,7 +45,6 @@ data class ChangelogChange( val text: String = "" ) -/** What the changelog panel is currently showing. */ sealed interface ChangelogStatus { data object Idle : ChangelogStatus data object Loading : ChangelogStatus @@ -65,12 +54,7 @@ sealed interface ChangelogStatus { /** * Reads the published changelog and keeps a last-known-good copy on disk. - * - * Cached-on-disk matters more than it looks: the first thing the panel paints - * after a cold start should be the history the user saw last time, not a - * spinner. A failed refresh keeps that copy (and reports [ChangelogStatus.Offline] - * only when there is nothing cached at all), the same fail-soft rule the - * weather and system widgets follow. + * A failed refresh keeps the cache and reports Offline only when nothing is cached. */ object Changelog { @@ -95,7 +79,6 @@ object Changelog { private val _status = MutableStateFlow(ChangelogStatus.Idle) val status: StateFlow = _status - /** The cached copy, if there is one. Cheap enough to call on app start. */ fun loadCache() { val cached = runCatching { if (!cacheFile.isFile) return@runCatching null @@ -108,14 +91,14 @@ object Changelog { } /** - * Fetch the feed. A network failure is not an error the user must act on: - * keep the cached history and only surface [ChangelogStatus.Offline] when - * the panel would otherwise be empty. + * Fetch the feed; a failure keeps the cached history and only surfaces Offline when nothing is cached. */ suspend fun refresh(feedUrl: String? = null): Boolean = withContext(Dispatchers.IO) { _status.value = ChangelogStatus.Loading + // Name the URL in the failure log: a bare "HTTP 404" cannot tell a rate limit from a missing feed. + val url = feedUrl ?: FEED_URL val result = runCatching { - val response = client.get(feedUrl ?: FEED_URL) { + val response = client.get(url) { header("Accept", "application/json") header("User-Agent", "QuickNotch/${AppVersion.VERSION}") } @@ -136,7 +119,7 @@ object Changelog { } } }.onFailure { failure -> - AppLog.line("changelog refresh failed: ${failure.message}") + AppLog.line("changelog refresh failed for $url: ${failure.message}") _status.value = if (_feed.value.releases.isEmpty()) { ChangelogStatus.Offline } else { diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/FileDigest.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/FileDigest.kt index b9fa6c2..8322204 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/FileDigest.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/FileDigest.kt @@ -6,17 +6,10 @@ import java.io.File import java.io.FileOutputStream import java.security.MessageDigest -/** - * File-level plumbing for the updater, kept out of [AppUpdates] so that class - * stays a state machine rather than a download loop. - */ +/** File-level plumbing for the updater, kept out of [AppUpdates]. */ internal object FileDigest { - /** - * Verify a package against the release's `SHA256SUMS.txt`. A release - * without a checksum still installs (older releases, or a runner hiccup); - * a release *with* one that disagrees never does. - */ + /** A release without a checksum still installs; a mismatched checksum never does. */ fun verify(file: File, expected: String?) { if (expected.isNullOrBlank()) return val actual = sha256(file) @@ -41,10 +34,7 @@ internal object FileDigest { /** * Stream a response to disk, reporting coarse progress. - * - * The read is one `while` over a fresh read at the bottom — no `break`/`continue` - * — because both an EOF and a short read are normal here, and the progress - * update belongs on the one line that knows how much landed. + * The loop re-reads at the bottom: EOF and a short read are both normal here. */ internal suspend fun streamToFile( channel: ByteReadChannel, diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/InstalledAppsScanner.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/InstalledAppsScanner.kt index 471e44d..df39d5a 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/InstalledAppsScanner.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/InstalledAppsScanner.kt @@ -5,10 +5,7 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock object InstalledAppsScanner { - // The installed list only changes when apps are added/removed, and the - // underlying scan can be slow on Windows (registry + shell calls). Cache it - // in memory so opening Settings again is instant and never re-runs the - // blocking pass on the UI thread. + // Cached: the scan is slow on Windows (registry + shell calls) and only changes when apps do. private var cached: List? = null private val scanMutex = Mutex() @@ -29,8 +26,4 @@ object InstalledAppsScanner { } } } - - fun invalidateCache() { - cached = null - } } diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/MacAppIconLoader.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/MacAppIconLoader.kt index 8289d76..388a5bb 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/MacAppIconLoader.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/MacAppIconLoader.kt @@ -12,12 +12,8 @@ import java.util.concurrent.TimeUnit import javax.imageio.ImageIO /** - * Loads macOS application icons in-process from the `.icns` packed into each app - * bundle, so refreshing a full list never spawns dozens of `sips`/`qlmanage` - * sub-processes (those used to time out under concurrency and fall back to - * monograms). The large representations inside a modern `.icns` are bare PNG - * chunks that [ImageIO] can decode directly; the shell utilities remain only as - * a last resort for legacy JPEG2000-only archives or bundles without any `.icns`. + * Loads macOS app icons in-process from the `.icns` in each bundle, so a list refresh spawns no sips/qlmanage. + * Modern icons are bare PNG chunks [ImageIO] decodes; the shell tools remain a last resort. */ object MacAppIconLoader { private val cache = ConcurrentHashMap() @@ -78,13 +74,8 @@ object MacAppIconLoader { } /** - * Decodes a PNG representation embedded in an `.icns` archive with - * [ImageIO], avoiding a sub-process per app. Modern macOS icons store every - * scale as a bare PNG chunk; we take the smallest one that still covers the - * cache cap (decoding a 1024px PNG just to draw it at 38dp is the waste that - * made scrolling stutter) and fall back to the largest if the archive only - * ships smaller sizes. Legacy JPEG2000-only archives return null so - * [convertWithSips] can take over. + * Decodes the smallest PNG chunk that covers the cache cap (a 1024px decode for a 38dp draw is what + * made scrolling stutter); JPEG2000-only archives return null so [convertWithSips] takes over. */ private fun parseIcnsPng(icns: File?): ImageBitmap? { if (icns == null || !icns.isFile) return null @@ -99,8 +90,7 @@ object MacAppIconLoader { (bytes[2].toInt() and 0xFF) != 0x6E || (bytes[3].toInt() and 0xFF) != 0x73 ) return null - // Chunks appear in no particular order, so scan once and remember both - // the smallest chunk that meets the cap and the largest overall. + // Chunks appear in no order: scan once, keeping the smallest chunk that meets the cap and the largest. var idealSize = Int.MAX_VALUE var idealStart = -1 var idealEnd = -1 @@ -150,8 +140,7 @@ object MacAppIconLoader { } private fun convertWithSips(icns: File, size: Int = 128): ImageBitmap? { - // Include the source path: many apps ship an identical "AppIcon.icns" - // with the same timestamp, which used to collide on one cache entry. + // Include the source path: many apps ship an identical "AppIcon.icns" with the same timestamp. val out = File(iconCacheDir, "${icns.absolutePath.hashCode()}-${icns.name}-${icns.lastModified()}-$size.png") if (out.isFile && out.length() > 0) { return ImageIO.read(out)?.toComposeImageBitmap() diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/MacAppKit.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/MacAppKit.kt index dfde7d6..247dd62 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/MacAppKit.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/MacAppKit.kt @@ -16,38 +16,30 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch /** - * Thin JVM front for the compiled `passthrough_bridge.m` dylib, shared by the - * overlay's click-through (see `WindowPassthrough`), the notch's window policy - * (above-menu-bar placement + Spaces behaviour) and the Dock-presence toggle. - * - * The dylib exists only when the project was built on a macOS host; on other - * hosts every call here is a silent no-op so the Windows build degrades the same - * way the click-through already does (fail soft, never throws). - * - * All computed constants mirror the AppKit headers. Window/application geometry - * reads go through the bridge's main-thread-safe helpers so callers never have - * to care which JVM thread they run on. + * JVM front for `passthrough_bridge.m`: click-through, window policy and Dock presence. + * Without the dylib (a non-macOS build) every call is a silent no-op. */ internal object MacAppKit { - // ── AppKit constants (kept here so the JVM side never hardcodes magic) ────── - /** NSAccessory equivalent: no Dock icon, no app menu bar (pure agent). */ const val ACTIVATION_REGULAR = 0 /** Accessory agent: the notch's resting state. */ const val ACTIVATION_ACCESSORY = 1 - /** NSNormalWindowLevel. */ - const val LEVEL_NORMAL = 0 + /** One step below normal: every app window covers it, and unlike level 0 + * the menu bar does not clamp it back below its own strip. */ + const val LEVEL_BELOW_NORMAL = -1 /** NSStatusWindowLevel: the band the menu bar occupies — above every normal * app window and the menu bar itself, still below alert/modal levels. */ const val LEVEL_STATUS_BAR = 25 - /** NSFloatingWindowLevel (what AWT's alwaysOnTop reaches). */ - const val LEVEL_FLOATING = 3 - /** canJoinAllSpaces | stationary | fullScreenAuxiliary | ignoresCycle. */ + /** canJoinAllSpaces | stationary | ignoresCycle. */ + const val COLLECTION_DESKTOP: Long = + (1L shl 0) or (1L shl 4) or (1L shl 12) + + /** [COLLECTION_DESKTOP] plus fullScreenAuxiliary: rides over full-screen apps. */ const val COLLECTION_NOTCH: Long = - (1L shl 0) or (1L shl 4) or (1L shl 8) or (1L shl 12) + COLLECTION_DESKTOP or (1L shl 8) private val bridge: NativeLibrary? by lazy { runCatching { @@ -78,10 +70,6 @@ internal object MacAppKit { call("quicknotch_set_ignores_mouse_events", ptr, if (ignore) 1 else 0) } - /** Live `ignoresMouseEvents` read-back (diagnostics). */ - fun ignoresMouseEvents(ptr: Long): Boolean = - (fn("quicknotch_get_ignores_mouse_events")?.invokeInt(arrayOf(ptr)) ?: -1) == 1 - fun setWindowLevel(ptr: Long, level: Int) { call("quicknotch_set_window_level", ptr, level) } @@ -94,21 +82,9 @@ internal object MacAppKit { call("quicknotch_set_frame_top_gap", ptr, topGap) } - fun menuBarHeight(ptr: Long): Double { - return try { - fn("quicknotch_get_menu_bar_height")?.invokeDouble(arrayOf(ptr)) ?: 0.0 - } catch (_: Throwable) { - 0.0 - } - } - /** - * The window's top-left in *real* CG screen coordinates (top-left origin, - * points). AWT's `locationOnScreen` reports its own cached virtual position - * (AWT y=0 is 30pt below the true top), which drifts from the actual - * NSWindow frame once the above-menu-bar nudge moves it directly in Cocoa. - * Passthrough's cursor-over math needs this real origin. Null when the - * native side can't answer (bridge missing, window not mapped). + * The window's top-left in real CG screen coordinates (top-left origin, points). AWT's + * `locationOnScreen` reports a cached position whose y=0 sits 30pt below the true top. */ private val originXBuf: Memory by lazy { Memory(8) } private val originYBuf: Memory by lazy { Memory(8) } @@ -125,14 +101,8 @@ internal object MacAppKit { } /** - * How far AWT's cached `locationOnScreen` strays from the window's *real* - * CG origin (top-left origin, points). `Window.locationOnScreen` on macOS - * reflects AWT's own virtual placement (its y=0 sits at the bottom of the - * menu bar), which stops matching the live NSWindow frame the moment the - * overlay policy parks the window with direct Cocoa moves. Compose's - * `localToScreen` inherits that stale number, so screen-space hover checks - * must be shifted by this delta. Null = no reliable real origin (bridge - * missing / window not mapped) → callers fall back to AWT's coordinates. + * How far AWT's cached `locationOnScreen` strays from the window's real CG origin; screen-space + * hover checks must shift by this delta. Null means no reliable origin, fall back to AWT. */ fun screenOriginDelta(window: java.awt.Window): Point? { val ptr = resolveMacWindowPtr(window) ?: return null @@ -150,10 +120,8 @@ internal object MacAppKit { } /** - * Backdrop blur behind the island, via the island-sized helper window - * (created, positioned and torn down inside the bridge; the compositor - * radius + effect view inside it both follow `frame.intensity`). - * Fire-and-forget on AppKit's main queue; a no-op without the bridge. + * Backdrop blur via the island-sized helper window (the bridge owns it); fire-and-forget on + * AppKit's main queue, no-op without the bridge. */ fun setContentBlur(ptr: Long, enabled: Boolean, frame: BlurFrame) { call( @@ -174,20 +142,8 @@ internal object MacAppKit { } /** - * macOS-only per-overlay controller that gives the notch its above-menu-bar - * home: raised above the menu bar, flush against the true top of the screen - * (AWT cannot reach that position — it clamps placement to the visible area), - * joining every Space and surviving full-screen apps. - * - * AWT's z-order bookkeeping resets window levels whenever it reorders sibling - * windows, and each ComPOse placement re-parks the window 30pt low, so the - * policy keeps a slow reconcile loop that re-asserts level + frame every tick. - * The "always on top" toggle maps to level AND the parked vertical gap: - * - * • on — NSStatusWindowLevel (25, above the menu bar), island flush at the - * very top (floating styles keep their edge gap). - * • off — NSNormalWindowLevel (0), island parked just below the menu bar so - * regular windows can cover it again. + * macOS-only controller that parks the notch flush at the true top of the screen, above the menu bar, + * on every Space. AWT re-parks the window 30pt low, so a 700ms loop re-asserts level and frame. */ internal class MacOverlayWindowPolicy private constructor( private val window: Window @@ -215,8 +171,7 @@ internal class MacOverlayWindowPolicy private constructor( } } - /** Re-target the policy (new always-on-top state / style edge gap), applied - * on the next reconcile tick (≤ ~700ms) */ + /** Re-targets the policy; applied on the next reconcile tick. */ fun update(alwaysOnTop: Boolean, edgeGapDp: Int) { wantedAlwaysOnTop = alwaysOnTop wantedEdgeGapDp = edgeGapDp @@ -226,12 +181,18 @@ internal class MacOverlayWindowPolicy private constructor( private fun apply() { val ptr = nsWindowPtr if (ptr == 0L) return - MacAppKit.setCollectionBehavior(ptr, MacAppKit.COLLECTION_NOTCH) - MacAppKit.setWindowLevel(ptr, if (wantedAlwaysOnTop) MacAppKit.LEVEL_STATUS_BAR else MacAppKit.LEVEL_NORMAL) - // Off-state parks the island below the menu bar (AWT's default spot); - // on-state starts the gap at the true top of the screen. - val menuBar = if (wantedAlwaysOnTop) 0.0 else MacAppKit.menuBarHeight(ptr) - MacAppKit.setFrameTopGap(ptr, menuBar + wantedEdgeGapDp) + val onTop = wantedAlwaysOnTop + // Off drops the full-screen band too: an ordinary window should not ride above a game. + MacAppKit.setCollectionBehavior( + ptr, + if (onTop) MacAppKit.COLLECTION_NOTCH else MacAppKit.COLLECTION_DESKTOP + ) + MacAppKit.setWindowLevel( + ptr, + if (onTop) MacAppKit.LEVEL_STATUS_BAR else MacAppKit.LEVEL_BELOW_NORMAL + ) + // The frame is identical in both states: the toggle changes stacking, never position. + MacAppKit.setFrameTopGap(ptr, wantedEdgeGapDp.toDouble()) } override fun close() { @@ -253,36 +214,15 @@ internal class MacOverlayWindowPolicy private constructor( } /** - * macOS Dock/application presence tied to the Settings window: the notch's - * resting state is an accessory agent — no Dock icon, no app menu bar, no - * focus stealing, and the overlay can layer over full-screen apps. As soon as - * the Settings window is open the app flips to a regular application (Dock - * icon + app switcher) and is brought to the front **once** so launching/ - * settings feels like a real app; closing Settings returns it to the quiet tray - * agent. - * - * A reconcile loop re-asserts the activation *policy* every tick because AWT - * initialises the process as a regular application and can fight a one-shot - * flip mid-session. It deliberately never re-activates: `activateIgnoringOther - * Apps` called on each tick would keep yanking focus back to the Settings - * window and make every other app unclickable (the front-most app would snap - * back to QuickNotch within one tick). Activation is therefore a one-shot - * request consumed on the next policy pass, armed only when Settings transitions - * to visible. - * - * No-op off macOS. Mirrors the "the app belongs in the Dock only when the - * Settings page is open" requirement. + * Dock/application presence tied to the Settings window: accessory agent at rest, Regular plus a + * single activation while Settings is open. The loop re-asserts policy only, never activation. */ internal object MacDockPresence { @Volatile private var settingsVisible = false @Volatile private var running = false - /** - * One-shot focus request: armed when Settings opens, consumed by the next - * [applyPolicy] pass. Kept false during the steady state so the reconcile - * loop never steals focus from another application. - */ + /** One-shot focus request, armed when Settings opens and consumed by the next [applyPolicy]. */ @Volatile private var pendingActivate = false private var scope: CoroutineScope? = null @@ -308,9 +248,7 @@ internal object MacDockPresence { if (!running) return MacAppKit.setActivationPolicy(if (settingsVisible) MacAppKit.ACTIVATION_REGULAR else MacAppKit.ACTIVATION_ACCESSORY) if (settingsVisible && pendingActivate) { - // Front the app exactly once per open transition. An accessory app - // can't surface its (now regular) Settings window on its own, but - // re-firing this every tick would make other apps unclickable. + // Front the app once per open transition: re-firing every tick makes other apps unclickable. pendingActivate = false MacAppKit.activateApp() } @@ -318,8 +256,7 @@ internal object MacDockPresence { fun setSettingsVisible(visible: Boolean) { settingsVisible = visible - // Only a transition into "visible" asks for a single activation; staying - // open never re-activates (that's what blocked focus on other apps). + // Only the transition into visible arms an activation; staying open never re-fires it. if (visible) pendingActivate = true scope?.launch { applyPolicy() } } diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/MacGameWatch.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/MacGameWatch.kt index 1f4e426..daffc0a 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/MacGameWatch.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/MacGameWatch.kt @@ -7,22 +7,8 @@ import com.starkdroid.quicknotch.media.NativeResources import java.util.concurrent.ConcurrentHashMap /** - * Thin JVM front for `gamewatch_bridge.m` (compiled to `gamewatch-bridge.dylib` - * on macOS hosts), which answers one question from CoreGraphics: - * - * "Is a non-shell application covering the main display right now — and which?" - * - * That is the only fullscreen signal macOS exposes without Screen Recording or - * Accessibility permission. Window *titles* are gated on permission on modern - * macOS, but owner names and bounds are not, so the bridge reads those and - * nothing else: a layer-0 window within 2pt of the display bounds, owned by a - * process that is not ours and not a system shell (Dock, menu bar, Window - * Server, Finder, …). - * - * In-process and allocation-light: polling costs a few microseconds and spawns - * nothing, so the detector can run every 1.5 s for the app's whole lifetime. - * The dylib only exists when the project was built on a macOS host; elsewhere - * every call answers "no game" (fail soft, never throws). + * JVM front for `gamewatch-bridge.dylib`, which reads window owner names and bounds. + * Window titles are permission-gated on modern macOS, owner names are not. */ internal object MacGameWatch { @@ -41,17 +27,10 @@ internal object MacGameWatch { bridge?.let { lib -> runCatching { lib.getFunction(it) }.getOrNull() } } - // One reused buffer: the poller is single-threaded (one coroutine on IO) and - // the bridge only writes into it synchronously, so there is nothing to race - // with. Keeping it here means a steady-state poll allocates nothing. + // One reused buffer: the poller is single-threaded, so a steady-state poll allocates nothing. private val ownerBuffer: Memory by lazy { Memory(OwnerBufferBytes.toLong()) } - /** - * The owning app's name while a fullscreen window is on screen, null when - * the desktop is showing normally. Never throws: a missing bridge, a - * refused window-server connection or a malformed answer all read as "no - * game", which simply leaves the notch in its ordinary quickview. - */ + /** Owning app's name while a fullscreen window covers the display; null otherwise, never throws. */ fun fullscreenOwner(): String? = try { val found = fn("qn_fullscreen_game")?.invokeInt(arrayOf(ownerBuffer, OwnerBufferBytes)) ?: 0 if (found == 1) { diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/MacInstalledAppsScanner.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/MacInstalledAppsScanner.kt index 10ebc50..6b5a759 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/MacInstalledAppsScanner.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/MacInstalledAppsScanner.kt @@ -46,9 +46,7 @@ object MacInstalledAppsScanner { } private fun addBundle(bundle: File, map: MutableMap) { - // Read the plist once and share it across every key we need, instead of - // re-reading the whole file per key (DisplayName / Name / IconFile / - // Identifier) for every bundle in the scan. + // Read the plist once and share it across the DisplayName / Name / IconFile / Identifier lookups. val plistText = readPlistText(bundle) val displayName = plistString(plistText, "CFBundleDisplayName") ?: plistString(plistText, "CFBundleName") diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/MacStartupManager.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/MacStartupManager.kt index 2ca2acb..27f356b 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/MacStartupManager.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/MacStartupManager.kt @@ -11,8 +11,6 @@ object MacStartupManager { "Library/LaunchAgents/$LABEL.plist" ) - fun isAutoStartEnabled(): Boolean = plistFile.isFile - fun setAutoStartEnabled(enabled: Boolean): Boolean { return try { if (enabled) { @@ -47,9 +45,7 @@ object MacStartupManager { val command = info.command().orElse(null) ?: return emptyList() val args = info.arguments().orElse(emptyArray()).toList() val packagedApp = findEnclosingAppBundle(File(command)) - // Tag the login-item command with the marker so the app can tell a boot - // auto-start (stay quietly in the tray) from a manual launch (open the - // Settings window). + // The marker keeps a boot auto-start tray-quiet while a manual launch opens Settings. return if (packagedApp != null) { listOf("/usr/bin/open", "-a", packagedApp.absolutePath, "--args", StartupManager.LAUNCHED_AT_LOGIN_ARG) } else { diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/ProjectLinks.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/ProjectLinks.kt new file mode 100644 index 0000000..16556a3 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/ProjectLinks.kt @@ -0,0 +1,21 @@ +package com.starkdroid.quicknotch.datasource + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +/** The project's public addresses, in one place so the tray, changelog and banner cannot drift. */ +object ProjectLinks { + + const val REPOSITORY = "https://github.com/StarkDroid/QuickNotch" + const val RELEASES = "$REPOSITORY/releases/latest" + const val KOFI = "https://ko-fi.com/trishiraj" + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + /** Opens [url] via [AppLauncher.browse]: `Desktop.browse` returns on macOS without opening a browser. */ + fun open(url: String) { + scope.launch { AppLauncher.browse(url) } + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/ReleaseApi.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/ReleaseApi.kt index c70fd0f..4d03179 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/ReleaseApi.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/ReleaseApi.kt @@ -12,7 +12,6 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json -/** One downloadable file attached to a release. */ data class UpdateAsset( val name: String, val url: String, @@ -30,22 +29,14 @@ data class LatestRelease( ) /** - * Everything that talks to the GitHub releases API. - * - * Kept separate from [AppUpdates] so the decision logic ("is this newer?", - * "download/install") never doubles as an HTTP client, and so every request - * has one place to carry the User-Agent GitHub requires. - * - * A release only counts as *managed* when it carries the cumulative - * `changelog.json` asset. That is the marker the release workflow adds from - * 2.0.0 onward; it also keeps an older, pre-changelog release from looking like - * an update simply because its tag happens to sort higher. + * Everything that talks to the GitHub releases API, kept out of [AppUpdates]. + * A release is managed only when it carries `changelog.json`, which stops the old v3.1.2 tag from looking newer. */ object ReleaseApi { const val OWNER = "StarkDroid" const val REPO = "QuickNotch" - const val RELEASES_PAGE = "https://github.com/$OWNER/$REPO/releases/latest" + const val RELEASES_PAGE = ProjectLinks.RELEASES private const val RELEASES_URL = "https://api.github.com/repos/$OWNER/$REPO/releases?per_page=20" private const val CHECKSUMS = "SHA256SUMS.txt" @@ -70,18 +61,15 @@ object ReleaseApi { } if (!response.status.isSuccess()) return@runCatching null - // The list, not `/releases/latest`: this repository still carries a - // pre-changelog v3.1.2 whose tag sorts above every 2.x release, and - // `/latest` would keep returning it. The first *managed* release is - // the real newest one. + // The list, not `/releases/latest`: the pre-changelog v3.1.2 tag sorts + // above every 2.x, so take the first managed entry. val payload = json.decodeFromString>(response.bodyAsText()) payload.firstNotNullOfOrNull { it.toLatest() } }.getOrNull() private fun GitHubRelease.toLatest(): LatestRelease? { val mapped = assets.map { UpdateAsset(it.name, it.downloadUrl, it.size) } - // The changelog asset is the "this release is managed by the workflow" - // marker; see the class comment. + // The changelog asset is the "managed by the workflow" marker. val changelog = mapped.firstOrNull { it.name == CHANGELOG } return if (draft || prerelease || changelog == null) { null @@ -103,10 +91,7 @@ object ReleaseApi { ?: release.assets.firstOrNull { it.name.endsWith(".exe", ignoreCase = true) } } - /** - * Two DMGs ship per release (`-arm64.dmg` / `-x64.dmg`). Pick the one this - * machine can run; an unlabelled DMG is treated as universal. - */ + /** Two DMGs ship per release (`-arm64` / `-x64`); an unlabelled DMG counts as universal. */ private fun macAsset(release: LatestRelease): UpdateAsset? { val images = release.assets.filter { it.name.endsWith(".dmg", ignoreCase = true) } if (images.isEmpty()) return null @@ -124,10 +109,8 @@ object ReleaseApi { } /** - * Read the SHA-256 for [assetName] from the release's checksum file. A - * missing file is not fatal — the download still installs — but a present - * file is always checked, because a corrupted installer is worse than no - * installer. + * Reads the SHA-256 for [assetName] from the release's checksum file; a missing file is not fatal, + * a present one is always enforced. */ suspend fun checksumFor(release: LatestRelease, assetName: String): String? = runCatching { val checksums = release.assets.firstOrNull { it.name == CHECKSUMS } ?: return@runCatching null @@ -162,10 +145,8 @@ object ReleaseApi { } /** - * Semver comparison for the versions the workflow can produce (x.y.z, with an - * optional leading `v`). Deliberately tolerant: a malformed component counts as - * zero rather than throwing, because the updater must never take the app down - * over a tag it does not understand. + * Semver comparison for x.y.z tags; a malformed component counts as zero, since the updater must + * never take the app down over a tag it does not understand. */ object AppVersions { diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/StartupManager.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/StartupManager.kt index ccaddd3..db13d83 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/StartupManager.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/StartupManager.kt @@ -1,22 +1,9 @@ package com.starkdroid.quicknotch.datasource object StartupManager { - /** - * Command-line marker appended to the auto-start launch commands (the macOS - * LaunchAgent and the Windows Run-key value). `main` checks for it so a - * login/boot auto-start stays quietly behind the tray, while a fresh manual - * launch (no marker) opens the Settings window. - */ + /** Auto-start marker: a login launch stays tray-quiet, a manual launch opens Settings. */ const val LAUNCHED_AT_LOGIN_ARG = "--quicknotch-launched-at-login" - fun isAutoStartEnabled(): Boolean { - return if (HostPlatform.isMac) { - MacStartupManager.isAutoStartEnabled() - } else { - WindowsStartupManager.isAutoStartEnabled() - } - } - fun setAutoStartEnabled(enabled: Boolean): Boolean { return if (HostPlatform.isMac) { MacStartupManager.setAutoStartEnabled(enabled) diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/SystemAccent.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/SystemAccent.kt index 674452e..b794d24 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/SystemAccent.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/SystemAccent.kt @@ -7,49 +7,20 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -/** - * Reads the OS accent colour so the notch glass can tint itself to match the - * desktop — and keeps that tint live. macOS exposes the accent index via - * `defaults read -g AppleAccentColor`; Windows keeps the DWM accent as an ABGR - * DWORD in the registry, read through the packaged `accent.ps1` bridge - * (mirroring the other PowerShell probes). - * - * A failed probe yields null rather than an error — the caller then falls back - * to the accent-free translucent glass, so the notch never breaks because the - * desktop colour was unreadable. The value is published through [accent] as a - * [StateFlow][kotlinx.coroutines.flow.StateFlow]; a poller in the overlay - * re-reads the OS while the System Accent surface is chosen, so changing the - * macOS/Windows accent recolours the notch in place. Reads are deduped by the - * StateFlow, so composables only recompose when the colour actually changes. - */ +/** Reads the OS accent colour (macOS `defaults` index, Windows DWM DWORD via + * `accent.ps1`); a failed probe yields null. */ object SystemAccent { private val _accent = MutableStateFlow(null) /** Live accent colour; [refresh] publishes new OS values as they are read. */ val accent: StateFlow = _accent.asStateFlow() - /** Latest known accent colour, or null when the OS didn't report one. Never throws. */ - fun current(): Color? { - _accent.value?.let { return it } - refresh() - return _accent.value - } - - /** - * Re-reads the OS accent and publishes it. A failed probe keeps the - * previous value (the indicator only ever shows real data), matching the - * pass-through-failure behaviour of the other bridges. - */ + /** Re-reads the OS accent; a failed probe keeps the previous value. */ fun refresh() { val fresh = if (HostPlatform.isMac) readFromMacOS() else readFromWindows() if (fresh != null) _accent.value = fresh } - /** Drop the read state so the next [current]/[refresh] re-reads the OS. */ - fun invalidate() { - _accent.value = null - } - private fun readFromMacOS(): Color? { return runCatching { val process = ProcessBuilder("/usr/bin/defaults", "read", "-g", "AppleAccentColor") diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/SystemMonitor.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/SystemMonitor.kt index a032313..cf6e985 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/SystemMonitor.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/SystemMonitor.kt @@ -3,20 +3,7 @@ package com.starkdroid.quicknotch.datasource import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -/** - * Opens the operating system's own process inspector: Task Manager on Windows, - * Activity Monitor on macOS. - * - * The System page is a glance, not a debugger — the moment a ring looks wrong - * the question is "which process is that?", and the OS answers it better than a - * 34dp tile can. The notch stays a shortcut to the real tool rather than - * growing a task list of its own. - * - * Launched detached on the IO dispatcher and fail-soft: a machine where the - * inspector lives somewhere unusual (or is blocked by policy) simply does - * nothing, exactly like a failed app launch. `open -a` uses the bundle name, so - * a localized Activity Monitor in a non-English macOS still resolves. - */ +/** Opens the OS process inspector (Task Manager, Activity Monitor); a blocked or missing one is silent. */ object SystemMonitor { suspend fun open(): Result = withContext(Dispatchers.IO) { diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/Win32ShellIconLoader.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/Win32ShellIconLoader.kt index fd2faa9..f59b885 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/Win32ShellIconLoader.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/Win32ShellIconLoader.kt @@ -119,18 +119,13 @@ object Win32ShellIconLoader { if (cached != null) return@withContext cached val iconBitmap = try { - // Registry DisplayIcon strings are frequently junk ("%SystemRoot%\...") - // or stale, so always fall back to the executable's own file. + // DisplayIcon strings are often junk or stale, so always fall back to the executable's own file. val file = app.iconPath?.let { cleanPath(it) } ?: cleanPath(app.executablePath) if (file != null) { - // 1. Try Windows Shell ImageList (256x256 Jumbo, then 48x48 Extra Large) extractShellImageListIcon(file.absolutePath, SHIL_JUMBO) ?: extractShellImageListIcon(file.absolutePath, SHIL_EXTRALARGE) - // 2. Classic SHGetFileInfo + SHGFI_ICON (32x32, honours app overrides) ?: extractShellIcon(file.absolutePath) - // 3. Try User32 PrivateExtractIcons (128x128) ?: extractPrivateIcon(file.absolutePath, 128) - // 4. Fallback to Java Swing system icon rendered at 128x128 ?: run { val icon = fsv.getSystemIcon(file) iconToImageBitmap(icon, 128) @@ -217,11 +212,7 @@ object Win32ShellIconLoader { } } - /** - * Direct SHGetFileInfoW + SHGFI_ICON extraction. This resolves the shell - * overlay/association icon the same way Explorer does and is a good middle - * ground when the system image list is unavailable. - */ + /** SHGetFileInfoW + SHGFI_ICON: resolves the shell association icon the way Explorer does. */ private fun extractShellIcon(filePath: String): ImageBitmap? { return try { val sfi = SHFILEINFOW() diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/WindowBlur.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/WindowBlur.kt index 3d5aa08..a3b8928 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/WindowBlur.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/WindowBlur.kt @@ -23,59 +23,9 @@ import kotlin.math.roundToInt import kotlin.math.sqrt /** - * Real backdrop blur behind the notch island, per platform. - * - * macOS shows a dedicated helper window (see the - * `quicknotch_set_content_blur` export in `passthrough_bridge.m`), sized to - * the island rect while it animates; the intensity slider drives both its - * compositor blur radius and its effect view's opacity. - * - * Windows has no usable compositor route — every one was probed live on this - * machine against a striped backdrop: - * - * - `DwmEnableBlurBehindWindow` composites BEHIND the window, so an opaque - * helper just paints over it (the "black scrim" symptom), and on a - * layered/transparent window the call is ignored outright — the stripes - * stayed razor-sharp through it. - * - `DWMWA_SYSTEMBACKDROP_TYPE` acrylic draws a solid RECTANGLE, ignoring - * both transparency and the window region. - * - `SetWindowCompositionAttribute` acrylic likewise ignores `SetWindowRgn` - * (its frost painted straight over a region-clipped window). - * - `WDA_EXCLUDEFROMCAPTURE` excludes an OPAQUE probe window from capture - * cleanly, but on a per-pixel layered window (which is exactly what the - * overlay is) it blacks out the window's whole rect in every GDI capture — - * the black-hole failure mode again. - * - * So the honest "OS-level blur with our own corners" is a real screen - * capture, blurred with a real Gaussian (3-pass box cascade, Skia-quality, - * CPU, off the render thread) and drawn inside the overlay itself — which - * also makes the intensity slider do something exact: it drives the blur - * sigma. - * - * One native fact makes capture safe WITHOUT any exclusion flag (probed): a - * plain BitBlt returns the true composited desktop everywhere the overlay is - * transparent, and only the region where this window actually paints — the - * island rect plus the thin outline ring straddling its edge. The engine - * therefore REPLACES that self-drawn hole with the average of a horizontal - * lerp between the flanking bands and a vertical blend between the rows - * above and below (see [inpaintHole]); under a 4–24dp Gaussian the - * reconstruction is invisible, and there is no feedback loop of the island - * baking itself into its own backdrop. The capture is inflated three sigmas - * past the island so the blur's whole sample window at the rim is real - * backdrop. Side benefit of skipping the flag: the notch stays visible in - * screenshots and screen sharing. - * - * The island sits flush at the screen top, so the wanted capture rect always - * pokes above y=0 — BitBlt of a negative origin fails — the capture is - * clamped to the virtual screen and the frost simply starts at the screen - * edge (there is no backdrop above it to sample anyway). - * - * The engine runs a capture loop (~[CaptureFrameMs], daemon thread) while - * enabled, publishes each island-cropped frame to [frame] (collected only by - * the frost leaf in the overlay, so a frame tick recomposes nothing else), - * and stops the moment the surface changes away from Blurred. Fail-soft - * throughout: any native failure keeps the last frame (or none) and the - * island renders the painted surface underneath. + * Backdrop blur: a macOS helper panel; on Windows a clamped screen capture, + * the island hole inpainted, blurred on the CPU. Missing bridge keeps the + * last frame or painted glass. */ internal class WindowsBackdropBlur private constructor( private val overlayHwnd: WinDef.HWND, @@ -120,11 +70,8 @@ internal class WindowsBackdropBlur private constructor( } /** - * Tracks the drawn island's rect (window-client physical px — the same - * feed the click-through region uses), so the captured strip expands, - * collapses and re-centres exactly with the notch. Rect changes arrive on - * every layout pass during the spring, which is precisely when the - * capture must follow. + * Tracks the island rect in window-client pixels so the capture follows + * every layout pass. */ fun updateIsland(rect: Rectangle?) { lastRect = rect?.clone() as Rectangle? @@ -135,11 +82,8 @@ internal class WindowsBackdropBlur private constructor( } /** - * ~30 fps capture while enabled (the notch's own animation budget — - * PillAnimationFps). Idle polling while disabled or unmeasured, so - * re-enabling picks up within one tick instead of waiting for an event. - * Native-only work happens on this daemon thread; the only crossing is - * publishing an immutable [ImageBitmap]. + * ~30 fps while enabled, idle otherwise; the daemon publishes one + * immutable ImageBitmap. */ private fun captureLoop() { val thread = Thread({ @@ -211,10 +155,8 @@ internal class WindowsBackdropBlur private constructor( } /** - * One screen strip → opaque ARGB pixels. BitBlt from the screen DC (whole - * virtual desktop, layered windows below us included), then GetDIBits - * top-down 32bpp. Any native failure returns null and the previous frame - * stays up; nothing here throws. + * BitBlt to top-down 32bpp ARGB. A native failure returns null and the + * previous frame stays. */ private fun captureScreen(x: Int, y: Int, width: Int, height: Int): IntArray? = User32.INSTANCE.GetDC(null)?.let { screenDc -> @@ -261,16 +203,8 @@ internal class WindowsBackdropBlur private constructor( } /** - * Replaces the self-drawn hole (this window's own scrim + content, which - * the screen capture composited over the real backdrop) with the average - * of two reconstructions: a per-row horizontal lerp between the real - * columns flanking the hole, and a per-column vertical blend between the - * real rows above and below it. Either alone fails on one axis — the lerp - * smears window edges and the vertical stripes behind a flush-top island - * into a monotonic ramp; the vertical fill smears horizontals. Averaged, - * each fixes the other's blind spot, and the remainder vanishes under the - * Gaussian applied right after. A hole clipped by the screen edge blends - * against whichever side is real. + * Replaces the self-drawn hole with the average of a row lerp and a + * column blend; under the Gaussian the seam disappears. */ private fun inpaintHole(pixels: IntArray, cap: Rectangle, hole: Rectangle) { val x0 = hole.x.coerceIn(0, cap.width) @@ -284,11 +218,8 @@ internal class WindowsBackdropBlur private constructor( } /** - * Real Gaussian approximation: three separable box passes per axis - * converge to a Gaussian (the same trick Skia's own blur uses), with - * clamp-to-edge sampling so the strip border repeats instead of fading — - * the island edges stay fully opaque because the capture is inflated past - * them on purpose. + * Three separable box passes with clamp-to-edge sampling; the capture is + * inflated past the island. */ private fun blurArgb(pixels: IntArray, cap: Rectangle, sigmaPx: Float) { if (sigmaPx < 0.5f || cap.width < 3 || cap.height < 3) return @@ -450,13 +381,10 @@ private fun lerpRows(pixels: IntArray, cap: Rectangle, bounds: Rectangle) { } } -/** Vertical half of the hole reconstruction: each column becomes a blend - * between the real rows above and below the hole, then gets averaged with - * [lerpRows]'s result — each axis reconstructs what the other smeared. The - * flanking rows are real capture whenever the hole is inset; a hole flush - * with the capture top (the island sits at the screen edge, where there is - * no backdrop above to sample) falls back to the hole's own first row and - * still blends toward the real bottom row. */ +/** + * Blends each column between the real rows above and below, then averages + * with lerpRows. + */ private fun blendColumns(pixels: IntArray, cap: Rectangle, bounds: Rectangle) { val x0 = bounds.x val x1 = bounds.x + bounds.width @@ -509,10 +437,8 @@ private fun cropBuffer(pixels: IntArray, cap: Rectangle, crop: Rectangle): Buffe } /** - * Blur radius in dp for the intensity slider: a soft haze at 0 (the frost - * still reads, just gently) to deep acrylic-like diffusion at the max. This - * is the Gaussian sigma the capture thread blurs with, and it caps the - * capture inflation ([FrostInflateDp] must stay above this maximum). + * Gaussian sigma the capture thread blurs with; FrostInflateDp must stay + * above this maximum. */ private const val FrostRadiusBaseDp = 4f private const val FrostRadiusPerStepDp = 0.5f @@ -539,10 +465,7 @@ internal data class BlurFrame( ) /** - * macOS side of the backdrop blur: resolves the NSWindow once, then drives - * the helper blur window (created, island-sized, torn down inside the - * bridge). The frosted shape tracks the animated island, so it expands and - * collapses exactly with the notch. + * Drives the bridge's helper blur window, which tracks the animated island. */ internal class MacContentBlur(window: Window?) { diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/WindowPassthrough.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/WindowPassthrough.kt index 053caf5..49ce1ae 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/WindowPassthrough.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/WindowPassthrough.kt @@ -19,39 +19,15 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch /** - * Makes the transparent margin of the fixed-footprint overlay window - * click-through, so only the drawn island is a touch target and the desktop / - * foreground apps right around the collapsed pill keep receiving input. - * - * The overlay window is one fixed (expanded) rectangle for its whole life - * (see `NotchOverlayWindow`): that footprint is what swallowed clicks around - * the pill. Because AWT has no per-region mouse hit-testing, each platform - * clips input with its own native mechanism driven by a 30 ms poll: - * - * • Windows — `SetWindowRgn`: clipping the native window's hit-test region to - * exactly the island's rectangle makes Win32 skip clicks outside it, so they - * fall through to whatever is underneath for free. The region follows the - * island while it grows and shrinks, so the whole drag-into-the-dock area - * stays live during the open animation. - * • macOS — `[NSWindow setIgnoresMouseEvents:]`: there is no region hit-test, - * so the whole window is made mouse-transparent unless the real cursor - * (`MouseInfo`, which keeps working even on a transparent window) is over - * the island's rectangle. Re-arming also synthesizes a MOUSE_MOVED so - * Compose's hover machinery learns about a cursor that was already there. - * - * Both paths fail soft: if the native handle cannot be resolved (platform - * surprise, JDK internals moving, a security manager) the window simply keeps - * its old whole-footprint behaviour and the margin stays click-swallowing — - * the notch itself always works. + * Clips input to the drawn island: Windows `SetWindowRgn`, macOS cursor-gated `setIgnoresMouseEvents`. + * The overlay is one fixed rectangle, so without this the transparent margin swallows clicks. */ internal class WindowPassthrough private constructor( private val impl: WindowPassthroughPlatform ) : AutoCloseable { /** - * The island's touch-target rectangle in *window* (client) pixels, or null - * for "the entire window is interactive" (used until the overlay's hover - * box has been measured, and while the island covers the window). + * The island's touch target in window pixels, or null for "the whole window is interactive". */ @Volatile private var islandRect: Rectangle? = null @@ -59,13 +35,8 @@ internal class WindowPassthrough private constructor( private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) /** - * Whether the per-tick apply must run on the AWT event thread. Windows' - * `SetWindowRgn` is dispatched there out of courtesy to the window's owning - * thread; macOS must NOT ride the EDT — its native calls are marshalled - * onto AppKit's main queue inside the dylib, and hopping through the EDT - * first turned every tick into an EDT→main-queue `dispatch_sync` ping-pong - * (33 per second) that could deadlock both threads against each other and - * beachball the app while the UI was busy (music + settings). + * Windows dispatches `SetWindowRgn` on the EDT; macOS must not, because an EDT to main-queue + * `dispatch_sync` at 33 Hz can deadlock both threads. */ private val applyOnEventThread = HostPlatform.isWindows @@ -77,9 +48,7 @@ internal class WindowPassthrough private constructor( if (applyOnEventThread) { SwingUtilities.invokeLater { impl.apply(rect) } } else { - // Off-EDT: cursor math (MouseInfo, cached AWT geometry) - // and the dylib's own main-queue dispatch are all - // thread-safe from here. + // Off-EDT: cursor math and the dylib's main-queue dispatch are thread-safe here. impl.apply(rect) } } catch (_: Throwable) { @@ -103,11 +72,7 @@ internal class WindowPassthrough private constructor( } companion object { - /** - * Attaches the right platform implementation to [window], or returns - * null when the platform has no passthrough mechanism available. Never - * throws: probing native internals must not take the app down. - */ + /** Attaches the platform implementation, or null when none is available; never throws. */ fun install(window: Window): WindowPassthrough? { return try { when { @@ -123,23 +88,19 @@ internal class WindowPassthrough private constructor( } } -/** - * Per-OS strategy for clipping the window's interactive area to [rect] - * (window-client pixels); null means the whole window is interactive. - */ +/** Per-OS strategy for clipping the window to [rect] (window pixels); null means the whole window. */ internal interface WindowPassthroughPlatform { fun apply(rect: Rectangle?) fun close() } /** - * The AWT peer behind a [Window] is hidden behind module encapsulation; look it - * up with reflection so the actual peer type never leaks into compilation. + * The AWT peer is behind module encapsulation; read it by reflection so the real type never + * leaks into compilation. */ internal fun windowPeer(window: Window): Any? { return try { - // Component.getPeer() is package-private and there is no public - // accessor, so read the private `peer` field (+ --add-opens below). + // Component.getPeer() is package-private; read the private `peer` field (needs --add-opens). val field = java.awt.Component::class.java.getDeclaredField("peer") field.isAccessible = true field.get(window) @@ -170,10 +131,8 @@ internal fun resolvePeerLong(peer: Any?, methodName: String): Long? { } /** - * The macOS AWT peer (`sun.lwawt.macosx.CPlatformWindow`) holds its NSWindow - * in the protected `CFRetainedResource.ptr` field — there is no public - * `getNSWindowPtr` on JDK 17. Resolve it via `peer.getPlatformWindow()` and - * read that field by reflection. + * macOS `CPlatformWindow` keeps its NSWindow in the protected `CFRetainedResource.ptr` field, + * with no getter on JDK 17; resolve it via `getPlatformWindow` and reflection. */ internal fun resolveMacWindowPtr(window: Window): Long? { val peer = windowPeer(window) ?: return null @@ -200,18 +159,11 @@ internal fun resolveMacWindowPtr(window: Window): Long? { return null } -// ── Windows: SetWindowRgn clips both painting and hit-testing ───────────────── +// Windows: SetWindowRgn clips both painting and hit-testing. /** - * Windows impl. [SetWindowRgn] hands the region to the window (which frees it - * when replaced), so passing a fresh `CreateRectRgn` every tick is safe and the - * OS cleans up the previous one. A null region restores a fully interactive - * window. - * - * The window also gets the tool-window + no-activate extended styles once, so - * the notch never occupies a taskbar slot or steals focus (Windows' analogue of - * "the app belongs in the Dock only when the Settings page is open": the - * Settings window stays a normal taskbar window, the overlay does not). + * SetWindowRgn hands the region to the window, so a fresh CreateRectRgn + * per tick is safe and a null region makes the window interactive again. */ private class Win32Region(private val window: Window) : WindowPassthroughPlatform { private val hwnd = resolvePeerLong(windowPeer(window), "getHWnd").also { @@ -276,23 +228,12 @@ private class Win32Region(private val window: Window) : WindowPassthroughPlatfor } } -// ── macOS: whole-window mouse transparency, cursor-gated ────────────────────── +// macOS: whole-window mouse transparency, cursor-gated. /** - * macOS impl. `ignoresMouseEvents` has no per-region variant, so the window is - * kept mouse-transparent unless the real cursor sits over the island's - * rectangle; the toggle is a simple reconcile against `MouseInfo` each tick. - * - * The AppKit toggle runs through the compiled `passthrough_bridge.m` dylib via - * [MacAppKit] (`quicknotch_set_ignores_mouse_events`) rather than driving - * objc_msgSend straight from JVM land (void-return / BOOL-arg sends crash with - * SIGTRAP on Apple silicon). Missing native bridge = fail soft. - * - * The cursor-over math uses the *real* NSWindow origin from the bridge - * (`quicknotch_get_window_cg_origin`) because the overlay policy parks the - * window above the menu bar with direct Cocoa frame moves that AWT's cached - * `locationOnScreen` never sees — relying on AWT's coordinates would drift the - * island's hit target down by exactly the menu-bar height. + * ignoresMouseEvents has no per-region form, so the window stays transparent + * unless the real cursor is over the island. The cursor check uses the + * bridge's CG origin. */ private class MacClickThrough(private val window: Window) : WindowPassthroughPlatform { @@ -364,15 +305,8 @@ private class MacClickThrough(private val window: Window) : WindowPassthroughPla } catch (_: Throwable) { return false } - // Content origin = window origin + frame insets (macOS keeps shadow - // insets even for undecorated windows). Invariant now that the anchor - // is the real CG origin: `window.insets` stays the same regardless of - // how far above the menu bar the window actually parks. - // - // The rect arrives in PHYSICAL pixels (Compose px = dp × density) while - // the cursor and origin are in AWT logical points, so the rect is - // brought back to logical space with the graphics configuration's - // scale (1.0 on non-scaled displays — no-op there, correct on Retina). + // Convert the island rect from physical pixels to AWT points; the + // origin comes from the bridge, not locationOnScreen. val scale = try { window.graphicsConfiguration.defaultTransform.scaleX.toFloat() } catch (_: Throwable) { diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/WindowsOverlayWindowPolicy.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/WindowsOverlayWindowPolicy.kt index f4d99fc..d47d65c 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/WindowsOverlayWindowPolicy.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/WindowsOverlayWindowPolicy.kt @@ -15,33 +15,8 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch /** - * Keeps the Windows overlay in the topmost z-order band for the app's whole - * life, and much more aggressively while a fullscreen game owns the screen. - * - * AWT's `alwaysOnTop` sets `WS_EX_TOPMOST` exactly once, when the property is - * assigned. That is enough for a normal desktop, but a fullscreen game raises - * its own window into the same band on every mode switch / foreground grab, - * and Windows orders topmost windows by recency: the game ends up above the - * notch until some unrelated window change re-shuffles the band. The symptom - * is the one users report as "the HUD only shows after I alt-tab" — the notch - * is drawn, it is just underneath the game. - * - * Re-asserting [User32.SetWindowPos] with `HWND_TOPMOST` on a slow reconcile - * tick is the Windows twin of [MacOverlayWindowPolicy]'s level reconcile. The - * call carries `SWP_NOACTIVATE` (never steal focus from the game), - * `SWP_NOMOVE | SWP_NOSIZE` (AWT owns placement) and `SWP_ASYNCWINDOWPOS` (the - * native call never blocks this worker on the window's thread). The tick - * tightens to [GamingTickMs] while a game session is live so the HUD is back - * on top within a frame or two of the game re-raising itself. - * - * This cannot beat a true exclusive-fullscreen swap chain — Windows stops - * compositing every other window there, so no ordinary window can draw over - * it. Games running borderless (the common modern case, including Windows' - * "fullscreen optimizations") are covered, which is exactly where a topmost - * window is supposed to work. - * - * Off Windows this installs nothing. Fail-soft: an unresolvable HWND or a - * rejected `SetWindowPos` leaves AWT's original behaviour in place. + * Re-asserts `HWND_TOPMOST` on a reconcile tick: AWT sets `WS_EX_TOPMOST` once and a fullscreen + * game re-raises into that band, leaving the notch underneath. Exclusive fullscreen cannot be overlaid. */ internal class WindowsOverlayWindowPolicy private constructor( private val window: Window @@ -68,8 +43,7 @@ internal class WindowsOverlayWindowPolicy private constructor( init { if (handle == null) { - // The peer is normally there by the time the overlay composes; if - // not, `currentHandle()` retries on every tick rather than giving up. + // Peer is usually present by now; if not, currentHandle() retries on every tick. System.err.println("QuickNotch: Windows overlay z-order keeper waiting for HWND") } scope.launch { @@ -84,12 +58,7 @@ internal class WindowsOverlayWindowPolicy private constructor( } } - /** - * Re-targets the policy. [alwaysOnTop] mirrors the Behavior toggle; - * [gaming] is the raw detector session (the HUD, or the notch itself while - * a game owns the screen) and only changes how often the band is - * re-asserted. - */ + /** Re-targets the policy; [gaming] only changes how often the band is re-asserted. */ fun update(alwaysOnTop: Boolean, gaming: Boolean) { wantedAlwaysOnTop = alwaysOnTop this.gaming = gaming @@ -98,9 +67,7 @@ internal class WindowsOverlayWindowPolicy private constructor( private fun apply() { val wanted = wantedAlwaysOnTop - // With always-on-top off, leave AWT's z-order alone unless this policy - // previously raised the window itself. Re-asserting NOTOPMOST every - // tick would keep shoving the notch below every other window. + // Only undo our own raise: re-asserting NOTOPMOST every tick would keep shoving us below other windows. if (!wanted && !forcedTopmost) return val hwnd = currentHandle() ?: return val flags = WinUser.SWP_NOMOVE or WinUser.SWP_NOSIZE or WinUser.SWP_NOACTIVATE or @@ -138,11 +105,8 @@ internal class WindowsOverlayWindowPolicy private constructor( /** While a game owns the screen, recover from a game re-raise fast. */ private const val GamingTickMs = 250L - // `SetWindowPos` insert-after pseudo-handles (-1 = topmost, -2 = not). - // Deliberately `Pointer(-1L)`, not `Pointer.createConstant(-1)`: the - // latter's special constant representation reaches the native call as - // an invalid handle (SetWindowPos fails with ERROR_INVALID_WINDOW_HANDLE - // 1400), which is exactly the silent no-op this policy exists to avoid. + // `Pointer(-1L)`, not `Pointer.createConstant(-1)`: the latter arrives as an invalid handle + // (SetWindowPos fails 1400), the exact silent no-op this policy exists to prevent. private val HWND_TOPMOST = WinDef.HWND(Pointer(-1L)) private val HWND_NOTOPMOST = WinDef.HWND(Pointer(-2L)) diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/WindowsStartupManager.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/WindowsStartupManager.kt index 7e9a65f..65fe14c 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/WindowsStartupManager.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/datasource/WindowsStartupManager.kt @@ -9,20 +9,11 @@ object WindowsStartupManager { private val REGISTRY_RUN_KEY = listOf("Software", "Microsoft", "Windows", "CurrentVersion", "Run").joinToString(File.separator) private const val APP_KEY_NAME = "QuickNotch" - fun isAutoStartEnabled(): Boolean { - return try { - Advapi32Util.registryValueExists(WinReg.HKEY_CURRENT_USER, REGISTRY_RUN_KEY, APP_KEY_NAME) - } catch (_: Exception) { - false - } - } - fun setAutoStartEnabled(enabled: Boolean): Boolean { return try { if (enabled) { val exePath = getAppExecutablePath() - // The marker lets the app tell a boot auto-start (stay quietly - // in the tray) from a manual launch (open the Settings window). + // The marker keeps a boot auto-start tray-quiet while a manual launch opens Settings. Advapi32Util.registrySetStringValue( WinReg.HKEY_CURRENT_USER, REGISTRY_RUN_KEY, diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/main.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/main.kt index 4814853..1dfbde4 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/main.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/main.kt @@ -33,6 +33,7 @@ import com.starkdroid.quicknotch.datasource.StartupManager import com.starkdroid.quicknotch.datasource.UpdateState import com.starkdroid.quicknotch.datasource.MacDockPresence import com.starkdroid.quicknotch.datasource.MacOverlayWindowPolicy +import com.starkdroid.quicknotch.datasource.ProjectLinks import com.starkdroid.quicknotch.datasource.WindowPassthrough import com.starkdroid.quicknotch.datasource.WindowsOverlayWindowPolicy import com.starkdroid.quicknotch.generated.resources.Res @@ -42,6 +43,7 @@ import com.starkdroid.quicknotch.generated.resources.menu_always_on_top_checked import com.starkdroid.quicknotch.generated.resources.menu_hide_notch import com.starkdroid.quicknotch.generated.resources.menu_quit import com.starkdroid.quicknotch.generated.resources.menu_settings +import com.starkdroid.quicknotch.generated.resources.menu_support import com.starkdroid.quicknotch.generated.resources.menu_show_notch import com.starkdroid.quicknotch.generated.resources.menu_update_available import com.starkdroid.quicknotch.generated.resources.onboarding_window_title @@ -86,11 +88,8 @@ import org.jetbrains.compose.resources.stringResource private var instanceLockChannel: FileChannel? = null /** - * Lets exactly one notch exist. A second process would stack a second - * identical overlay (same position, same level) that reads as one frozen, - * misbehaving notch — e.g. a blurred box that ignores expand/collapse while - * the live instance underneath keeps working. Fails open: a lock hiccup must - * never prevent launch. + * Ensures one notch: a second process would stack an identical overlay that reads as a single + * frozen pill. Fails open, since a lock hiccup must never prevent launch. */ private fun takeInstanceLock(): Boolean = try { val lockFile = AppPaths.configDir.resolve("instance.lock") @@ -116,35 +115,19 @@ fun main(args: Array) { if (HostPlatform.isMac) { System.setProperty("apple.laf.useScreenMenuBar", "true") System.setProperty("apple.awt.application.appearance", "system") - // Skiko backend selection — the single biggest performance lever in the - // app. Measured on the settings list, one scroll frame costs ~70ms of - // process CPU on the SOFTWARE rasterizer (14fps, visibly unsmooth) - // versus ~3.4ms on Metal (vsync-capped 60fps), so macOS now defaults to - // METAL. The old comment here claimed Metal crashed on the transparent - // overlay window; that was skiko 0.8.18's MetalRedrawer running on the - // AWT EDT and tripping the macOS 26/27 main-thread assertion, fixed in - // the pinned skiko (agents.md pitfall 28). - // - // Precedence: an explicit -Dskiko.renderApi / SKIKO_RENDER_API wins, then - // the `software-render` marker file, then METAL. The marker exists - // because a backend problem is a native crash, so it must be fixable - // without a UI the crash may prevent from appearing. + // Skiko backend: an explicit -Dskiko.renderApi / SKIKO_RENDER_API wins, then the + // `software-render` marker file (a crash can hide the UI that would remove it), then METAL. val marker = AppPaths.configDir.resolve("software-render") val backend = System.getProperty("skiko.renderApi")?.takeIf { it.isNotBlank() } ?: System.getenv("SKIKO_RENDER_API")?.takeIf { it.isNotBlank() } ?: (if (marker.exists()) "SOFTWARE" else "METAL") System.setProperty("skiko.renderApi", backend) println("QuickNotch: skiko render backend = $backend${if (marker.exists()) " (marker file)" else ""}") - // Retro-clean bridges leaked by earlier runs (JVM death skips - // media.stop(); the bridges now watchdog themselves, but versions - // already in the wild do not — see NativeResources.sweepOrphanedBridges). + // Clean up bridges leaked by earlier runs: JVM death skips media.stop(). NativeResources.sweepOrphanedBridges() } - // A fresh manual launch (Spotlight/Dock/Finder/Launchpad) opens the Settings - // window alongside the notch so the app's presence is obvious and the user - // can configure it immediately. A login-item auto-start passes - // `--login-item` (written into the launch command by StartupManager) and - // stays quietly behind the tray instead — nobody asked for settings at boot. + // A manual launch opens Settings so the app is obvious; a login-item auto-start passes + // `--login-item` (written by StartupManager) and stays quietly behind the tray. val loginItemLaunch = args.contains(StartupManager.LAUNCHED_AT_LOGIN_ARG) application { @@ -152,15 +135,10 @@ fun main(args: Array) { val config by repository.config.collectAsState() val favorites by repository.favorites.collectAsState() - // One gaming signal for the whole app: the overlay swaps in the HUD, - // the window sizer reserves its width, and the system poller runs while - // it is up. `gamingActive` (not the raw session) is the gate, so a user - // who switches the HUD off pays nothing even though the detector still - // knows a game is running. + // One gaming signal for the whole app; `gamingActive` (not the raw session) is the gate, + // so a user who switches the HUD off pays nothing. val gameSession by GameDetector.game.collectAsState() - // Windows-only: the HUD's frame rate comes from PresentMon and the - // switches are hidden everywhere else, so a macOS session must never - // light up a feature the user cannot see or turn off. + // Windows-only: a macOS session must never light up a HUD the user cannot see or turn off. val gamingActive = HostPlatform.isWindows && gameSession != null && config.gameHudEnabled @@ -174,11 +152,8 @@ fun main(args: Array) { AudioBattery.start() SystemBattery.start() SystemMetrics.start() - // The gaming detector is started once and gated by its toggle: on - // macOS it is an in-process CoreGraphics call, on Windows one - // long-lived PowerShell watcher (see GameDetector). - // Gaming is a Windows feature end to end; starting the detector on a - // Mac would only probe for a HUD that is gated off below. + // Gaming is a Windows feature end to end; the detector is one long-lived PowerShell + // watcher (see GameDetector), and a Mac would only probe for a HUD gated off below. if (HostPlatform.isWindows) { GameDetector.start() GameDetector.logAvailability() @@ -187,13 +162,10 @@ fun main(args: Array) { // Calendar is opt-in and file-backed: with no token on disk this // returns immediately, so an unconnected user pays nothing. GoogleCalendar.start() - // Weather locates itself over IP and caches the day to disk, so a - // cold start paints the last known forecast on the first frame. - // A manual place from settings seeds the position instead. + // Weather caches the day to disk, so a cold start paints the last forecast on the + // first frame; a manual place from settings seeds the position instead. Weather.start(repository.config.value.widgets.weatherPlace) - // Update checks and the remote changelog are background work: the - // app is fully usable offline, and a check that lands after the - // user quit is harmless. + // Update checks and the remote changelog are background work; the app stays usable offline. AppUpdates.start() onDispose { media.stop() @@ -217,63 +189,43 @@ fun main(args: Array) { var isNotchVisible by remember { mutableStateOf(true) } var isExpanded by remember { mutableStateOf(false) } - // The installer script waits for this PID to vanish before it replaces - // the app, so quitting is part of the update, not an afterthought. + // The installer script waits for this PID to vanish before it replaces the app. val quittingForUpdate by AppUpdates.quitting.collectAsState() LaunchedEffect(quittingForUpdate) { if (quittingForUpdate) exitApplication() } - // Flip the Dock/app presence in-step with the Settings window: the - // first Settings open forces activation (an accessory agent can't bring - // a regular window forward on its own), and closing it returns the app - // to its quiet agent state. - // Onboarding counts as "settings open": the user is actively - // configuring, so the Dock presence and activation apply while it runs. + // Settings, or onboarding, forces activation: an accessory agent cannot bring a regular + // window forward on its own, and closing returns to the quiet agent state. LaunchedEffect(showSettings, onboardingDone) { MacDockPresence.setSettingsVisible(showSettings || !onboardingDone) } - // Manual weather place picked in settings replaces the IP position for - // the poller (cleared back to null = re-locate automatically). Keyed on - // the place itself, and the controller dedupes re-emits, so unrelated - // config writes never cost a refetch. + // A manual place replaces the IP position (null re-locates automatically); the controller + // dedupes re-emits, so unrelated config writes never cost a refetch. LaunchedEffect(config.widgets.weatherPlace) { Weather.setManualPlace(config.widgets.weatherPlace) } - // The system monitor only polls while its widget is switched on: a user - // who turns the page off pays no process spawns at all, rather than a - // poller idling in the background forever. The gaming HUD is the one - // exception — it needs CPU/GPU/latency, so a game switches the poller on - // even when the System page itself is off (the page stays off the wheel; - // only the readout is fed). + // The system monitor only polls while its widget is on; the gaming HUD is the exception, + // and a game switches the poller on even when the page stays off the wheel. LaunchedEffect(config.widgets.showSystem, gamingActive) { SystemMetrics.setEnabled(config.widgets.showSystem || gamingActive) } - // Detection only runs while the HUD can actually draw: its switch is on - // and at least one metric is selected. Switching the last metric off - // stops the probe just like switching the HUD off does. + // Detection runs only while the HUD can draw: its switch is on and at least one metric is selected. LaunchedEffect(config.gameHudEnabled) { GameDetector.setEnabled(config.gameHudEnabled) } - // FPS follows the detected game. A PresentMon capture takes ~2 s, so it - // lives in its own coroutine and never delays a system sample; the - // effect's cancellation is what stops the capture when the game ends. + // A PresentMon capture takes ~2 s, so it gets its own coroutine and never delays a system + // sample; cancelling the effect stops it when the game ends. LaunchedEffect(gameSession?.pid, gamingActive) { if (gamingActive) FpsProbe.follow(gameSession) } - // Dock presence is a function of the Settings window, never of the - // notch: the resting QuickNotch is a silent agent (no Dock icon, no app - // menu bar — the overlay layers over full-screen apps), and the Dock - // icon + app activation appear only while the Settings page is open. - // Started from a DisposableEffect(Unit), NOT the composition body: the - // body re-executes on every media/volume/battery tick, and a raw call - // here re-armed the one-shot activation on each recomposition — the app - // then yanked focus back to itself ~every second while music played. + // Started from DisposableEffect(Unit), never the composition body: the body re-runs on + // every media/volume/battery tick, and a raw call re-armed the activation and stole focus. DisposableEffect(Unit) { MacDockPresence.start() onDispose { MacDockPresence.stop() } @@ -309,6 +261,10 @@ fun main(args: Array) { Separator() Item(stringResource(Res.string.reset_defaults), onClick = { repository.resetToDefaults() }) Separator() + Item( + stringResource(Res.string.menu_support), + onClick = { ProjectLinks.open(ProjectLinks.KOFI) } + ) Item(stringResource(Res.string.menu_quit), onClick = ::exitApplication) } ) @@ -323,11 +279,8 @@ fun main(args: Array) { ) } - // The onboarding flow lives in this same window: first-run users open - // straight into it (even at login launch -- a fresh install has no - // login item yet), and finishing hands the window to the tabbed - // settings while the notch, which did not exist during onboarding, - // appears for the first time. + // First-run users open straight into onboarding (even at login launch); finishing hands + // the same window to settings and the notch appears for the first time. if (showSettings || !onboardingDone) { val settingsState = rememberWindowState( position = WindowPosition.Aligned(Alignment.Center), @@ -335,9 +288,8 @@ fun main(args: Array) { ) Window( onCloseRequest = { - // Closing the window mid-onboarding finishes the flow: the - // notch must exist no matter what, and the config the user - // already touched stays. + // Closing mid-onboarding finishes the flow: the notch must exist, and config + // the user already touched stays. showSettings = false if (!onboardingDone) repository.updateConfig { it.copy(onboardingDone = true) } }, @@ -353,11 +305,8 @@ fun main(args: Array) { DisposableEffect(settingsWindow) { val listener = object : WindowFocusListener { override fun windowGainedFocus(e: WindowEvent?) { - // The user just came back from System Settings - // (possibly after granting Full Disk Access): re-read - // the notification store right now instead of waiting - // for the next 3s poll tick, so the helper row swaps - // to "Send test notification" as soon as access lands. + // Re-read the notification store on focus, so granting Full Disk Access + // updates the helper row now instead of at the next 3s tick. NotificationCount.poke() } override fun windowLostFocus(e: WindowEvent?) { @@ -375,8 +324,7 @@ fun main(args: Array) { if (done) { SettingsScreen( repository = repository, - media = media, - onClose = { showSettings = false } + media = media ) } else { OnboardingScreen(repository = repository) @@ -389,23 +337,8 @@ fun main(args: Array) { } /** - * Hosts the transparent overlay window and pins it to the top of the screen. - * - * The native window adopts a single fixed footprint — the expanded dock size — - * and never resizes while the notch opens or collapses. On macOS a transparent - * AWT window that resizes flashes white / skips frames for a frame (a known - * Compose Desktop + Skiko issue), which reads as a blink; keeping the window - * size stable and drawing the growing/shrinking island inside it eliminates - * that entirely. The trade-off: the fixed window is wider/taller than the - * collapsed pill, so its transparent margin swallows clicks on the desktop - * directly around the pill until the notch expands. - * - * Size and position are both written to [androidx.compose.ui.window.WindowState] - * within a single effect so Compose applies them atomically in one pass — this is - * what keeps the notch perfectly still while it grows and shrinks. The effect - * re-runs only when the style or the target footprint changes (favourites count, - * the widened now-playing page), never in response to the window's own live - * dimensions, so there is no feedback loop and no second corrective placement. + * Hosts the transparent overlay on one fixed footprint: resizing it on macOS flashes white and + * skips frames, so the island grows inside a stable window. Size and position land in one effect. */ @Composable private fun NotchOverlayWindow( @@ -426,20 +359,15 @@ private fun NotchOverlayWindow( val favorites by repository.favorites.collectAsState() val calendarState by GoogleCalendar.state.collectAsState() val calendarTick by GoogleCalendar.minuteTick.collectAsState() - // The calendar page width is only reserved while the widget is switched - // on *and* connected — the same rule the overlay's pager follows, so a - // toggled-off calendar neither pages nor widens the window. + // Calendar width is reserved only while the widget is on and connected, the same rule the + // pager follows, so a toggled-off calendar neither pages nor widens the window. val calendarConnected = config.widgets.showCalendar && calendarState is CalendarState.Connected - // The native window and the overlay content must size the quickview - // identically, or the indicator slots (device + laptop battery + the - // notification bell, the calendar mail badge) clip off the far edge. Both - // derive the slot count through the same helper. + // Window and content must derive the indicator slot count through quickViewIndicatorSlots, + // or the right-edge icons clip. val calendarEventCount = if (config.widgets.showCalendar) calendarUpcomingCount(calendarState, calendarTick) else 0 - // The weather chip draws while a forecast is loaded; the expanded outlook - // page exists under the same condition the overlay uses, so both sizers - // claim the identical footprint. + // The chip and the outlook page share one condition, so both sizers claim the same footprint. val weatherState by Weather.state.collectAsState() val weatherFetching by Weather.fetching.collectAsState() val weatherData = (weatherState as? WeatherState.Ready)?.data @@ -449,9 +377,7 @@ private fun NotchOverlayWindow( // exists once there is a reading behind it. val systemState by SystemMetrics.state.collectAsState() val systemReady = config.widgets.showSystem && systemState != null - // The gaming HUD claims the same width the overlay draws it at. Collected - // here too because the native window must already be wide enough the - // instant a game starts — resizing the window then would flash on macOS. + // The window must already be wide enough the instant a game starts; resizing then would flash on macOS. val game by GameDetector.game.collectAsState() val gameHudActive = HostPlatform.isWindows && game != null && config.gameHudEnabled val indicatorSlots = quickViewIndicatorSlots( @@ -479,11 +405,8 @@ private fun NotchOverlayWindow( gameHudActive ) } - // The native window keeps the expanded footprint at all times (see class - // doc): opening/collapsing never resizes the transparent window, which is - // what would flash on macOS. The visible island animates inside it instead. - // `window` (not `expanded`) reserves the widest thing the island can draw, - // so the gaming HUD needs no resize either. + // `window` (not `expanded`) reserves the widest thing the island can draw, so the gaming HUD + // needs no resize either. val targetSize = metrics.window val overlayState = rememberWindowState( @@ -503,23 +426,15 @@ private fun NotchOverlayWindow( ) { val awtWindow = window - // Keep the fixed window's transparent margin click-through: only the - // drawn island is a touch target (SetWindowRgn on Windows, cursor-gated - // ignoresMouseEvents on macOS). Fails soft — no passthrough if the - // native handle can't be resolved. + // Keep the fixed window's transparent margin click-through: only the drawn island is a + // touch target. Fails soft, with no passthrough when the native handle cannot be resolved. val passthrough = remember(awtWindow) { WindowPassthrough.install(awtWindow) } DisposableEffect(awtWindow) { onDispose { passthrough?.close() } } - // macOS above-menu-bar placement: AWT can't reach the true top of the - // screen (it clamps to the visible area), so a direct Cocoa policy parks - // the island there — raised above the menu bar, joining every Space and - // surviving full-screen apps while "always on top" is on. The policy is - // re-asserted on a slow reconcile tick because Compose placement and - // AWT's z-order bookkeeping keep re-parking the window below the menu - // bar; `update` just retargets the flags it re-adheres to. Off-mac this - // installs nothing. + // AWT clamps to the visible area, so a Cocoa policy parks the island above the menu bar + // and re-asserts it on a reconcile tick, because Compose keeps re-parking the window. val macPolicy = remember(awtWindow) { MacOverlayWindowPolicy.install(awtWindow) } LaunchedEffect(config.alwaysOnTop, metrics.edgeGapDp) { macPolicy?.update(config.alwaysOnTop, metrics.edgeGapDp) @@ -528,13 +443,8 @@ private fun NotchOverlayWindow( onDispose { macPolicy?.close() } } - // Windows z-order keeper: AWT sets WS_EX_TOPMOST once, but a fullscreen - // game re-raises its own window into the same band and ends up above - // the notch — the HUD then only surfaces after an alt-tab re-shuffles - // the z-order. Re-assert topmost on a slow tick (fast while a game is - // detected) without activating or moving the window. Off-Windows it - // installs nothing; a true exclusive-fullscreen swap chain still cannot - // be overlaid, but borderless fullscreen (the common case) can. + // A fullscreen game re-raises its own window into the topmost band, so re-assert + // HWND_TOPMOST on a tick. Exclusive fullscreen still cannot be overlaid; borderless can. val windowsPolicy = remember(awtWindow) { WindowsOverlayWindowPolicy.install(awtWindow) } LaunchedEffect(config.alwaysOnTop, game != null) { windowsPolicy?.update(config.alwaysOnTop, game != null) @@ -543,16 +453,12 @@ private fun NotchOverlayWindow( onDispose { windowsPolicy?.close() } } - // Shared widget-pager state; on Windows the wheel listener below drives - // it straight from AWT because the unfocused overlay window never sees - // scroll through Compose's pointer pipeline (which macOS' trackpad does). + // On Windows the unfocused overlay never sees scroll through Compose, so AWT drives the + // pager directly; macOS keeps its Compose path. val density = LocalDensity.current val pager = remember { OverlayPager(with(density) { 30.dp.toPx() }) } - // Windows: one wheel notch on the whole overlay window maps onto one - // pager step through AWT (the layer that actually receives - // WM_MOUSEWHEEL) — independent of what is under the cursor or window - // focus. macOS keeps its Compose-path handling inside the overlay. + // One wheel notch maps onto one pager step straight from AWT, the layer that receives WM_MOUSEWHEEL. if (HostPlatform.isWindows) { DisposableEffect(awtWindow, pager) { val listener = MouseWheelListener { event -> @@ -564,11 +470,8 @@ private fun NotchOverlayWindow( } LaunchedEffect(config.style, targetSize) { - // Absolute placement is only honoured once the AWT window is mapped: - // an assignment made earlier is silently dropped and the window keeps - // its aligned placeholder position (top-centre) instead of hugging - // the screen edge. Wait for the window to show, give it a frame to - // finish its initial layout, then pin it to the edge atomically. + // Absolute placement is silently dropped before the window is mapped: wait for + // isShowing, let layout settle, then pin it to the edge. while (!awtWindow.isShowing) delay(10) delay(100) val graphicsConfiguration = awtWindow.graphicsConfiguration @@ -584,10 +487,8 @@ private fun NotchOverlayWindow( ) overlayState.size = targetSize overlayState.position = WindowPosition.Absolute(location.x.dp, location.y.dp) - // Compose just re-parked the window at AWT's visible-clamped spot - // (30pt low on macOS). Ask the Cocoa policy to re-adhere it to the - // true top right after the placement lands instead of waiting for - // its next reconcile tick. + // Compose just re-parked the window at AWT's visible-clamped spot (30pt low on macOS), + // so re-adhere to the true top instead of waiting for the next reconcile tick. delay(120) macPolicy?.update(config.alwaysOnTop, metrics.edgeGapDp) } @@ -610,13 +511,7 @@ private fun NotchOverlayWindow( } } -/** - * Loads the brand-mark icon shared by the tray and every window: the indigo - * rounded square with the white notch pill — the exact logo the site header - * shows next to "QuickNotch". Rendered from [quicknotch-icon.svg] into a taskbar - * and system-tray PNG kept under desktopMain resources; falls back to a - * hand-drawn equivalent if the resource is missing at runtime. - */ +/** The brand mark shared by the tray and every window, with a hand-drawn fallback if the resource is missing. */ private fun loadQuickNotchIcon(): ImageBitmap { return try { ClassLoader.getSystemResourceAsStream("quicknotch-tray.png")?.use { diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/media/AudioBattery.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/media/AudioBattery.kt index 9b8a5f6..590ecf2 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/media/AudioBattery.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/media/AudioBattery.kt @@ -23,25 +23,13 @@ import kotlinx.serialization.json.intOrNull import java.io.File import java.util.concurrent.TimeUnit -/** - * A connected audio device reporting a battery percentage (0-100). - * - * @param isAudio true when the OS data reports the device as part of a - * Bluetooth audio stack (Hands-Free / A2DP / AVRCP), which drives which - * glyph the level ring shows. Decided from real device metadata on both - * platforms; defaults to true as the least-bad fallback for unknown devices. - */ +/** A connected audio device reporting a battery percentage (0-100); [isAudio] picks the ring glyph. */ data class AudioDeviceBattery( /** Device name as reported by the OS; null when unknown. */ val name: String?, val levelPercent: Int, val isAudio: Boolean = true, - /** - * How many Bluetooth devices the OS reports as connected right now, - * including the one this ring reads its level from. The ring only shows a - * count badge above 1, so a single connected device looks exactly as it did - * before this field existed. - */ + /** Bluetooth devices the OS reports connected right now, including this one. */ val connectedCount: Int = 1 ) @@ -71,44 +59,13 @@ private val batteryJson: Json = Json { } /** - * Battery percentage of the connected Bluetooth audio device for the music - * widget (quickview chip + expanded page). - * - * Presence on Windows comes from the OS's live Bluetooth connection state - * ([battery.ps1] mode A: `Windows.Devices.Enumeration` + - * `BluetoothDevice.ConnectionStatus`), NOT from the battery property — PnP - * keeps reporting paired devices as present and hands out cached battery - * values for seconds after the device is gone. The connection probe therefore - * surfaces a connect or disconnect on the very next poll (≈1 s): - * - * - the 1 s probe asks "is a Bluetooth audio device connected right now" - * (a fast WinRT enumeration; the PnP instance walk is skipped entirely - * when nothing is connected, so a disconnect is effectively immediate); - * - the ~30 s refresh reads that device's battery level with a generous - * timeout (the OS may poll the headset over the air for many seconds) and - * only publishes while presence still says the device is connected; - * - the last-known level is persisted per device instance, so reconnecting - * the same device (or restarting the app) restores the chip instantly; - * - [poke] forces an immediate probe whenever the notch opens, and a device - * that just appeared kicks an immediate refresh so the level shows up - * right away. - * - * macOS has no public API for this; `system_profiler SPBluetoothDataType` - * reports the connected device's battery and services (≈100 ms, so it carries - * both presence and battery in the poll — at a slower 10 s cadence to keep the - * laptop quiet, with an instant re-probe when the notch opens/music goes live). - * The old Bluetooth preferences plist - * (~/Library/Preferences/com.apple.Bluetooth.plist) stopped carrying a - * DeviceCache on modern macOS, so it is kept only as a fallback for older - * systems. A failed or misparsed read simply leaves the value null and the UI - * hides the chip. + * Battery percentage of the connected Bluetooth audio device for the quickview chip. + * Windows must read presence from live WinRT connection state; the PnP battery property caches a dead device. */ internal object AudioBattery { private const val PresencePollMs = 1_000L - // system_profiler is more expensive than the Windows WinRT probe, so the - // macOS cadence is slower; a notch-open or music-live poke still probes - // immediately, so connect/disconnect reflects right away on expand. + // system_profiler costs more than the WinRT probe, so macOS polls slower. private const val MacPresencePollMs = 10_000L private const val LiveProbeTimeoutMs = 8_000L private const val BatteryRefreshMs = 30_000L @@ -122,10 +79,7 @@ internal object AudioBattery { @Volatile private var script: File? = null - // Identity + level currently believed. The level is deliberately kept - // across disconnects so reconnecting the same device restores instantly; - // it is only replaced by a successful read, and only used while the probe - // confirms the same instance is still connected. + // Level is kept across disconnects so reconnecting the same device restores instantly. @Volatile private var activeInstance: String? = null @Volatile @@ -135,17 +89,13 @@ internal object AudioBattery { @Volatile private var cachedName: String? = null - // Whether the active device is part of a Bluetooth audio stack; picked up - // from the probe alongside identity so the UI's level-ring glyph matches. + // Whether the active device is part of a Bluetooth audio stack; drives the ring glyph. @Volatile private var cachedAudio = false /** Connected-device count from the last presence probe (Windows). */ private var cachedCount = 1 - // True while the last probe (mode A, real connection state) saw an audio - // device connected. The slow refresh consults this before publishing — a - // mode-B snapshot alone cannot be trusted, because the battery property - // keeps answering for a device that is already unplugged. + // Last mode-A probe saw an audio device connected; mode B's battery answer outlives a disconnect. @Volatile private var isPresent = false @@ -175,15 +125,12 @@ internal object AudioBattery { batteryJob = active.launch { while (isActive) { refreshBattery() - // While no level has ever been read the refresh runs on a - // short retry cadence; once known, a slow ~30 s tick keeps - // the chip honest without hammering the OS. + // Retry fast until a level is known, then settle on the slow refresh cadence. delay(if (cachedLevel == null) BatteryFirstRetryMs else BatteryRefreshMs) } } } else { - // Events bridge connect/disconnect instantly; the 10 s poll above - // becomes a backstop for OSes that never post a notification. + // Events bridge connect/disconnect; the poll is only a backstop. startMacWatch() } } @@ -200,20 +147,8 @@ internal object AudioBattery { } /** - * Forces an immediate connectivity probe (and a battery re-read when a - * device is present), so the chip right after the notch opens is fresh. - */ - /** - * Force an immediate re-probe (notch opened, etc). - * - * Coalesced, because a probe is a *process launch*: on macOS it is - * `system_profiler`, which costs ~150ms of system-wide CPU. Without the - * guard, every track start (musicLive flipping) fired one exactly when the - * user was least likely to want a system-wide stall, and rapid open/close - * cycles stacked more on top. Probes that started within - * [pokeCooldownMs] are dropped instead: the 10s poll and the event-driven - * connect/disconnect bridge already keep the chip honest, so a poke more - * often than that buys nothing a user could see. + * Forces an immediate re-probe (notch opened, music went live). + * Coalesced on a 4 s cooldown: each probe is a process launch, ~150 ms of system CPU on macOS. */ fun poke() { val active = scope ?: return @@ -238,8 +173,6 @@ internal object AudioBattery { active.launch { refreshBattery() } } - // ── Probe (1 s cadence) ────────────────────────────────────────────────── - private fun probePresence() { if (!HostPlatform.isWindows) { probeMac() @@ -270,8 +203,7 @@ internal object AudioBattery { isPresent = true cachedName = snap.name?.takeIf { it.isNotBlank() } ?: cachedName cachedAudio = snap.audio - // A device that just appeared (or one we have no level for yet) - // kicks an immediate refresh so the percentage shows up quickly. + // A device that just appeared kicks an immediate refresh so the percentage shows up. if (appeared || cachedLevel == null) kickRefresh() val level = if (cachedInstance == instance) cachedLevel else null if (snap.count != null) cachedCount = snap.count @@ -281,8 +213,7 @@ internal object AudioBattery { null } } else { - // The WinRT connected set is empty — the device is gone. Hide now; - // the cached level is kept for an instant restore on reconnect. + // WinRT connected set is empty: hide now, keep the cached level for an instant restore. isPresent = false activeInstance = null cachedCount = 1 @@ -290,13 +221,10 @@ internal object AudioBattery { } } - // ── Refresh (Windows, ~30 s cadence) ───────────────────────────────────── - private fun refreshBattery() { if (!HostPlatform.isWindows) return val file = script ?: return - // Only ever probe a known device instance (mode B); the probe's mode A - // finds the connected device and drives identity + presence. + // Only a known instance is refreshed; the probe's mode A owns identity and presence. val instance = activeInstance ?: return val raw = runProcess( listOf( @@ -311,10 +239,8 @@ internal object AudioBattery { batteryJson.decodeFromString(BatteryProbe.serializer(), line) }.getOrNull() ?: return - // The probe has the final word on presence: mode B's own `present` - // merely reflects PnP presence, which a paired-but-off device still - // reports as true — and its battery answer can be a stale cached - // value. Nothing publishes once presence says the device is gone. + // Mode B's own `present` only reflects PnP presence and its battery can be cached, so + // nothing publishes once mode A says the device is gone. if (!isPresent || instance != activeInstance) return val level = snap.level ?: return // connected but no data yet: keep display @@ -326,25 +252,13 @@ internal object AudioBattery { _info.value = AudioDeviceBattery(cachedName, level, cachedAudio, cachedCount) } - // ── macOS (10 s cadence, system_profiler carries presence + battery) ──── - private fun probeMac(): Boolean { - // system_profiler carries presence + battery in one fast call. - // - // macOS, however, keeps recent earphones listed as Connected (an - // audio-capable BLE link) even while audio is actually playing through - // other speakers — so a headset ring must additionally be checked - // against the *active output*, or it reads as a phantom device. Plist - // remains a fallback for older systems that still write a DeviceCache; - // every path that finds nothing local ends by clearing _info so a - // disconnected ring can never linger. Returns true when a ring was - // published (the Bluetooth-change watcher uses that to decide whether - // to re-probe once the audio routing settles). + // macOS keeps recently used earphones listed as Connected even when audio plays through + // other speakers, so the candidate must match the active output. Returns true when published. val raw = runProcess(listOf("system_profiler", "SPBluetoothDataType"), MacProbeTimeoutMs) val candidate = raw?.let { parseSystemProfiler(it) } if (candidate != null && candidate.name != null) { - // A probe failure leaves the last value alone (no opinion); a clear - // "the default output is something else" hides the ring right away. + // A probe failure leaves the last value alone; a mismatch hides the ring right away. val activeOutput = macActiveOutputName() ?: return false if (activeOutput.isNotEmpty() && macNamesMatch(activeOutput, candidate.name!!)) { _info.value = candidate @@ -371,16 +285,9 @@ internal object AudioBattery { return parsed != null } - // ── macOS Bluetooth-change watcher ──────────────────────────────────────── - /** - * Supervises the `btwatch-bridge` helper: it watches CoreAudio default- - * output changes plus bluetoothd connect/disconnect notifications and - * prints one `AUDIO`/`BT` marker per event burst, letting the battery - * poller probe the stack the instant a device connects instead of up to - * the 10 s backstop cadence later. Restarts the process if it dies (like - * the now-playing bridge). Absent on non-mac builds — the poller simply - * keeps its slow cadence. + * `btwatch-bridge` watcher: an AUDIO/BT marker probes the stack at once instead of waiting + * up to the 10 s backstop. Restarts if the process dies; absent on non-mac builds. */ private fun startMacWatch() { val binary = NativeResources.extract("macos/btwatch-bridge", "") @@ -408,11 +315,8 @@ internal object AudioBattery { } /** - * A device connected/disconnected at the Bluetooth level. Probe now; if - * the immediate snapshot still found nothing routed, re-probe ~1.2 s later - * so macOS can finish registering the device as the audio output (that - * beats the previous "up to 10 s later"). Cheap: one or two system_profiler - * calls per actual Bluetooth connect/disconnect. + * A connect/disconnect that is not yet the audio output gets one re-probe ~1.2 s later, + * after macOS finishes registering it. */ private fun onMacWatchEvent() { val active = scope ?: return @@ -425,10 +329,8 @@ internal object AudioBattery { } /** - * Name of the device the OS currently routes audio to, or null when the - * probe failed outright (caller keeps the last value — no opinion). An - * empty string means the probe ran but no Default Output Device line - * exists, i.e. audio is definitely not routed to a named wireless device. + * Device the OS routes audio to, or null when the probe failed (keep the last value). + * Empty string means the probe ran and nothing is routed to a named device. */ private fun macActiveOutputName(): String? { val audioRaw = runProcess(listOf("system_profiler", "SPAudioDataType"), MacProbeTimeoutMs) ?: return null @@ -442,22 +344,14 @@ internal object AudioBattery { return x == y || x.contains(y) || y.contains(x) } - // system_profiler flags a headset as audio through its service list - // (A2DP is the music path; HFP/AVRCP/AACP tag the voice/control stacks) or - // through a headphone-style Minor Type line. Both are honoured so realme - // earbuds (usually only "Battery Level: nn%") and Apple headphones (with a - // full Services line) resolve to the headphone-glyph ring. + // system_profiler marks a headset as audio via its service list (A2DP music, HFP/AVRCP/AACP + // voice) or a headphone-style Minor Type; both are honoured. private val macAudioServices = setOf("HFP", "AVRCP", "A2DP", "AACP") private val macAudioMinorTypes = setOf("Headphones", "Headset", "Earbuds", "Audio") /** - * Parses `system_profiler SPBluetoothDataType`: walks the Connected: - * section, and picks the battery of the first device the OS classifies as - * audio. A headset exposing separate earbuds reports its Left/Right - * average; a device with a single percentage uses that; the case level is - * the last resort. `isAudio` is decided from the Services/Minor Type lines, - * not assumed — BLE-only idle links (AirPods in their case, Find My) never - * publish because their Services line carries no audio tags. + * Parses `system_profiler SPBluetoothDataType`: first connected device the OS classifies as + * audio. Earbuds report Left/Right (averaged), case level is the last resort. */ private fun parseSystemProfiler(raw: String): AudioDeviceBattery? { val header = raw.indexOf("Connected:") @@ -469,10 +363,8 @@ internal object AudioBattery { if (notConnected > sectionStart) notConnected else raw.length ) - // system_profiler indents the section header N spaces, device names N+4 - // and attributes N+8 — names carry the device label (no value), while - // attributes are always "Key: value" lines, so indentation alone splits - // them robustly regardless of the header's own indent. + // Device names are indented N+4 with no value, attributes N+8 as "Key: value", + // so indentation alone splits them regardless of the header's own indent. val headerIndent = countIndent(raw, header) val nameRegex = Regex("^\\s{${headerIndent + 4}}\\S.*:$") val attrRegex = Regex("^\\s+([^:]+):\\s+(.+)$") @@ -490,19 +382,14 @@ internal object AudioBattery { } } - // Only a device the OS classifies as audio is published: macOS also - // lists idle BLE links (e.g. AirPods in their case nearby, Find My) as - // "Connected" with a Services line that is BLE-only, and surfacing - // those as a bluetooth ring reads as a ghost device to the user. + // Only a device classified as audio is published; idle BLE links read as a ghost device. val candidates = devices.mapNotNull { device -> macDeviceLevel(device)?.let { (level, audio) -> Triple(device, level, audio) } } val pick = candidates.firstOrNull { it.third } ?: return null val name = pick.first.name.takeIf { it.isNotBlank() } - // Every entry under "Connected:" counts, audio or not: the badge answers - // "how many Bluetooth devices do I have around right now?", which is a - // question about the OS's connected set rather than about the one - // device whose battery this ring happens to show. + // The badge counts every entry under Connected, audio or not: it answers "how many + // Bluetooth devices are around", not which one this ring reads. return AudioDeviceBattery(name, pick.second, pick.third, devices.size) } @@ -530,10 +417,8 @@ internal object AudioBattery { } /** - * Name of the device marked as the current output in `SPAudioDataType`. - * Its section heading ("Devices:") sits at N spaces and device names at N+4 - * with "Key: value" attrs deeper — the same indent shape the Bluetooth - * parser relies on, so the same regexes apply. + * Parses the current output device from SPAudioDataType, the same indent + * shape as the Bluetooth parser. */ private fun macDefaultOutputDevice(raw: String): String? { val devices = raw.indexOf("Devices:") @@ -577,10 +462,8 @@ internal object AudioBattery { } /** - * Walks the Bluetooth preferences plist's DeviceCache for the first - * connected device reporting a numeric `BatteryPercent*` key. The plist's - * connection flags are written by the OS the moment devices pair/unpair, - * so connect/disconnect reflects on the very next poll. + * First connected device with a numeric BatteryPercent key in the + * Bluetooth DeviceCache plist. */ private fun parseMacPlist(raw: String): AudioDeviceBattery? { val root = runCatching { batteryJson.parseToJsonElement(raw) }.getOrNull() ?: return null @@ -615,7 +498,6 @@ internal object AudioBattery { return null } - // ── Shared helpers ──────────────────────────────────────────────────────── private fun runProcess(command: List, timeoutMs: Long): String? { return runCatching { diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/media/MacArtworkFetcher.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/media/MacArtworkFetcher.kt index 6fd077b..faec0d7 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/media/MacArtworkFetcher.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/media/MacArtworkFetcher.kt @@ -10,24 +10,9 @@ import java.util.concurrent.TimeUnit import kotlin.concurrent.thread /** - * Universal cover-art fetcher for macOS: browsers, Spotify, Music and every - * other MediaRemote client that publishes artwork. - * - * The poll loop's JXA context only ever sees metadata — scripted callers are - * denied the artwork bytes — so a track whose snapshot arrives without art - * gets exactly one entitled-helper run: `artwork-driver.pl` loads the tiny - * `artwork_bridge` dylib into `/usr/bin/perl` (whose `com.apple.perl*` - * identity the daemon honours) for a single full-access - * `MRMediaRemoteGetNowPlayingInfo` query. Found covers settle per content id; - * artless-but-matched answers retry on a bounded budget instead, because - * browsers publish the cover seconds after the metadata — settling the first - * empty answer would leave every switched-to track coverless. A reply that - * lands after a track change is dropped by - * [MediaControllerBase.publishArtwork] instead of pasting onto the new song. - * - * When the helper is absent (a jar built on a non-mac host carries no dylib) - * every fetch is a silent no-op and the Music.app AppleScript path inside the - * poll bridge remains the only artwork source — fail-soft by design. + * Scripted media contexts get metadata but never artwork, so one + * entitled perl helper query runs per artless track. A late reply is + * dropped by content id; a missing helper is a silent no-op. */ internal class MacArtworkFetcher( private val ioScope: CoroutineScope, @@ -49,10 +34,8 @@ internal class MacArtworkFetcher( private var inFlight = false /** - * Last helper *failure* (timeout/crash — not an artless track) and when it - * happened. Failures back off instead of settling, so a slow daemon gets - * retried by later polls rather than condemning the track to the - * placeholder for the rest of the song. + * Last helper failure and its time; failures back off instead of settling + * the track. */ @Volatile private var failedId: String? = null @@ -65,12 +48,8 @@ internal class MacArtworkFetcher( private var failedRetryMs: Long = FAILED_RETRY_MS /** - * Bounded second chances for a track the daemon reports *artless*: browsers - * publish the cover seconds after the metadata, so the first query of a - * fresh switch usually lands before the bytes exist. Retried on a slow - * cadence ([EMPTY_RETRY_MS], at most [EMPTY_MAX_TRIES] runs) and only then - * settled artless — a truly coverless source stops spawning helpers after - * about a minute instead of on every poll forever. + * Browsers publish art seconds after metadata, so an artless match + * retries on a slow bounded budget, then settles. */ @Volatile private var emptyId: String? = null @@ -90,10 +69,8 @@ internal class MacArtworkFetcher( get() = driver != null && helper != null /** - * Requests cover bytes for [contentId]. Called on every artless poll, but - * spawning is gated: one in flight at a time, failures back off, and empty - * answers get a bounded retry budget — the rest return without touching a - * process. + * Requests art for contentId: one run in flight, failures back off, empty + * answers retry on a budget. */ fun fetch(contentId: String) { if (!shouldFetch(contentId)) return @@ -143,13 +120,8 @@ internal class MacArtworkFetcher( } /** - * One perl run with a 12s deadline. The helper itself lingers up to ~7s - * inside the entitled process, reporting the moment the cover appears (or - * the daemon moves on) instead of answering a single blind query — that - * wait is where freshly switched tracks spend most of their coverless - * seconds. Artwork lines can exceed the OS pipe buffer, so a drainer - * consumes stdout while the deadline runs — reading only after `waitFor` - * would wedge the helper mid-write and turn every cover into a timeout. + * One perl run with a 12 s deadline; stdout is drained while waiting + * because artwork exceeds the pipe buffer and a read after wait deadlocks. */ private fun runHelper(): HelperOutcome { val script = driver @@ -180,10 +152,8 @@ internal class MacArtworkFetcher( } /** - * What one helper run concluded: did it answer, which track the daemon was - * actually on, and with what bytes. The track id gates the merge — without it - * a lagging daemon would settle the old cover (or artlessness) onto the new - * song after every switch. + * What one helper run concluded; the track id gates the merge so a lagging + * daemon cannot settle the wrong cover. */ private data class HelperOutcome(val finished: Boolean, val trackId: String?, val base64: String?) diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/media/MacNowPlayingController.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/media/MacNowPlayingController.kt index 7bb68e2..c2f1c45 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/media/MacNowPlayingController.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/media/MacNowPlayingController.kt @@ -6,27 +6,9 @@ import java.io.File import java.util.concurrent.TimeUnit /** - * macOS now-playing via the system MediaRemote service — the same "now - * playing" surface the media keys use, so any registered source (Music, - * Spotify, browsers, VLC, IINA, …) is covered, not just apps we script. - * - * Since macOS 15.4 the MediaRemote daemon only serves processes whose bundle - * id starts with "com.apple.", so a plain helper binary is silently denied. - * The compiled helper (`composeApp/native/macos/nowplaying_bridge.m`) is a - * supervisor that runs the private-class API (`MRNowPlayingRequest`, - * `MRNowPlayingController`) inside an `osascript -l JavaScript` subprocess — - * which is entitled — and streams one canonical JSON snapshot per change on - * stdout. Transport commands spawn a short-lived helper invocation the same - * way. - * - * Cover art needs its own channel: scripted contexts only ever see metadata, - * never the artwork bytes. Any snapshot that arrives without art (browsers, - * Spotify, Music alike) goes to [MacArtworkFetcher], which runs one - * entitled-helper query per track through `/usr/bin/perl` and fills the art - * in behind the poll loop. - * - * If the helpers are absent (non-mac build host) the controller simply never - * publishes a session. + * macOS now playing from MediaRemote through an osascript helper, the + * only context the daemon entitles since 15.4. Cover art comes from + * MacArtworkFetcher; without the helpers no session is published. */ internal class MacNowPlayingController : MediaControllerBase() { @@ -93,13 +75,8 @@ internal class MacNowPlayingController : MediaControllerBase() { override fun seekTo(positionMs: Long) = Unit /** - * Transport commands spawn a short-lived `osascript` helper (~100-300ms, - * 2s deadline) — that MUST NOT run on the calling thread: clicks arrive on - * the Compose UI thread, and blocking it made every press freeze the - * overlay + settings windows for the spawn duration (very visible on the - * SOFTWARE renderer, which paints both windows on that same thread). The - * command is fire-and-forget on the controller's IO scope; the session - * feed reflects the outcome on its own. + * Transport commands spawn an osascript helper and must not run on the + * UI thread, which would freeze both windows for the spawn. */ private fun send(command: String) { val binary = bridge ?: return diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/media/Media.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/media/Media.kt index 5272811..6a37c70 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/media/Media.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/media/Media.kt @@ -8,14 +8,8 @@ import androidx.compose.ui.graphics.ImageBitmap enum class PlaybackState { PLAYING, PAUSED, STOPPED, NONE } /** - * A single point-in-time sample of the system's active media session — the - * "now playing" surface the OS maintains for whichever app currently owns the - * media keys (Music/Spotify/browsers on macOS via MediaRemote; the SMTC - * session on Windows). - * - * [sampledAt] is a monotonic millisecond stamp taken when the sample was made; - * [displayedPositionMs] extrapolates forward from it so the progress and the - * vinyl can advance at frame rate without polling the controller. + * One now-playing sample. sampledAt is monotonic, so the position + * extrapolates at frame rate without polling. */ @Immutable data class MediaSession( diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/media/MediaController.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/media/MediaController.kt index 2782473..f779d10 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/media/MediaController.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/media/MediaController.kt @@ -64,21 +64,14 @@ internal object MediaJson { } /** - * How long an empty player holds the last session before clearing it. Covers - * the no-item beat between songs (the bridge polls at 1–2s) without turning a - * genuine stop into a lingering pill. The hold ends on a *clock* driven by - * [MediaControllerBase.armHoldExpiry], never only on the next bridge line: - * the macOS bridge de-dups identical lines, so a force-quit delivers exactly - * one empty sample inside the grace and then silence — a hold that only the - * next line could end would never end at all (pitfall 51). + * How long an empty player holds the last session. The hold ends on a + * timer because the bridge de-dups lines and may never send another. */ private const val EMPTY_GRACE_MS = 4_000L /** - * Shared snapshot decoding for every backend: JSON parsing, monotonic - * stamping, and artwork decoding cached by content id so a base64 blob is - * rasterized exactly once per track. All decoding runs on the caller's worker - * thread; the flow is published to the UI. + * JSON parsing, monotonic stamping and per-id art decoding shared by every + * backend. */ internal abstract class MediaControllerBase : MediaController { protected val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @@ -100,23 +93,15 @@ internal abstract class MediaControllerBase : MediaController { private var previousPlayingRawId: String? = null /** - * The last playable snapshot and when it arrived (monotonic ms). An empty - * player holds onto these for [EMPTY_GRACE_MS] instead of clearing the - * session at once — the bridge reports no item for a beat between songs, - * and without the hold the whole music page would unmount and remount on - * every track change. The hold is armed as a one-shot timer (see - * [armHoldExpiry]) because the bridge's line de-dup can swallow every - * later sample, leaving no line on which a re-check could ever run. + * Last playable snapshot and its arrival time, held for EMPTY_GRACE_MS + * so a track change does not unmount the music page. */ private var lastSnapshot: MediaSnapshot? = null private var lastPlayableAtMs: Long = 0L /** - * Serialises every session write. The poller and the hold-expiry timer run - * on different IO coroutines: a reap interleaved with a publish could - * re-write a session that was just cleared, and a late artwork merge could - * graft a cover onto a session the timer had already dropped. Lock order is - * always publishLock → artLock, never the reverse. + * Serialises session writes from the poller and the reap timer. Lock order + * is publishLock then artLock. */ private val publishLock = Any() @@ -124,14 +109,8 @@ internal abstract class MediaControllerBase : MediaController { private var holdExpiryJob: Job? = null /** - * Parses one bridge line into a playable snapshot. A playable line refreshes - * the hold and cancels any pending reap; anything else serves the held - * snapshot while the grace is open (arming the one-shot reap so the hold - * still ends when the bridge falls silent) and clears the session once it - * expires — so a track switch (or a corrupt sample) never blanks the pill, - * while a real stop lands a few seconds later. - * - * Called only while holding [publishLock]. + * Parses one bridge line under publishLock. A playable line refreshes + * the hold; anything else serves the held snapshot and arms the reap. */ private fun playableSnapshot(jsonLine: String): MediaSnapshot? { val snapshot = runCatching { @@ -158,16 +137,8 @@ internal abstract class MediaControllerBase : MediaController { } /** - * Starts the one-shot reap for the current hold: [EMPTY_GRACE_MS] after - * the last playable sample. A hold used to be re-evaluated only when the - * *next* bridge line arrived, but the macOS bridge de-dups identical lines - * — a force-quit delivers one empty sample inside the grace and then - * silence, so nothing ever re-ran the expiry check and the pill hung on - * the dead track forever. The deadline is therefore clock-driven: the hold - * still lasts exactly [EMPTY_GRACE_MS], but it *ends* even when no further - * line ever comes. The wait re-checks under [publishLock], so a playable - * line landing in the meantime (fresh grace, this job cancelled) wins the - * race instead of being reaped. + * Arms the clock-driven reap. The bridge de-dups identical lines, so a + * force-quit would otherwise leave the pill on the dead track forever. */ private fun armHoldExpiry() { if (holdExpiryJob?.isActive == true) return @@ -185,11 +156,8 @@ internal abstract class MediaControllerBase : MediaController { } /** - * Drops the held session and the pinned-position pins — the single place - * the "nothing is playing" state is written, so the reap timer and an - * expired line can never drift apart. Caller must hold [publishLock]; - * idempotent (a second empty line after the reap just rewrites the null - * the flow already holds). + * The only place the empty session is written, so the reap timer and an + * expired line cannot drift. Caller holds publishLock. */ private fun reapHeldSnapshot() { previousPlayingRawPosition = null @@ -199,28 +167,17 @@ internal abstract class MediaControllerBase : MediaController { } /** - * Decodes one bridge snapshot into the session flow. Returns the snapshot - * when it produced (or refreshed) a session, null when the line carried - * nothing playable — macOS uses the returned snapshot to decide whether - * the track still needs cover art from the entitled helper. The whole - * read-modify-write runs under [publishLock] so the hold-expiry timer can - * never reap between the hold check and the session write. + * Writes one snapshot under publishLock so the reap cannot fall between + * the hold check and the write; returns it when a session was produced. */ protected fun publish(jsonLine: String): MediaSnapshot? = synchronized(publishLock) { val snapshot = playableSnapshot(jsonLine) if (snapshot == null) return null val state = stateOf(snapshot) - // Browsers (Chrome/Edge) register with SMTC but do not pump timeline - // position updates while a track plays: `Position` stays pinned at its - // last change (the play-start offset) for the whole track, so every - // poll re-delivers the same raw value. Snapping the playhead to it each - // second resets the progress bar to ~0 and the extrapolation clock with - // it. When a playing sample repeats the previous playing sample's raw - // position for the same content, treat it as that pinned value and - // keep advancing from where the previous sample extrapolated to — - // players that genuinely advance (Spotify, Music, foobar) and seeks - // (which do update `Position`) never match and keep their raw value. + // Chrome and Edge pin Position while playing, so a repeated raw value + // for the same content is extrapolated instead of snapping the playhead + // back to the start. val sampledAt = System.nanoTime() / 1_000_000 val positionMs = extrapolatedPosition(state, snapshot, sampledAt) @@ -275,10 +232,8 @@ internal abstract class MediaControllerBase : MediaController { } /** - * Fills in cover art that arrived late — the macOS entitled helper answers - * one track behind the poll loop by design. Drops the bytes when the track - * has already moved on (or another source already supplied art), so a slow - * fetch can never paste a cover onto the wrong song. + * Fills in late cover art, dropping bytes when the track already moved on + * or another source supplied art. */ protected fun publishArtwork(contentId: String, base64: String) { if (base64.isBlank() || contentId.isBlank()) return @@ -295,10 +250,8 @@ internal abstract class MediaControllerBase : MediaController { } /** - * Decodes one base64 cover straight to display size and caches it under - * [contentId]. Blank input (or a failed decode) falls back to whatever the - * cache holds for that id, so a track the helper has no art for simply - * keeps the placeholder vinyl. + * Decodes base64 art straight to display size, cached by contentId; a + * blank or failed decode keeps the placeholder. */ private fun decodeArtworkBytes(base64: String?, contentId: String): DecodedArtwork { if (base64.isNullOrBlank()) return cachedArtwork(contentId) @@ -356,17 +309,8 @@ private fun stateOf(snapshot: MediaSnapshot): PlaybackState = } /** - * Dominant colour of a cover, for the waveform tint. - * - * A coarse 4-bit-per-channel histogram over a sampled grid (at most ~56×56 - * pixels regardless of the art's size), scored by `population × chroma` with a - * penalty for luma far from mid-grey: that picks the colour the cover actually - * *means* rather than the background black or a big pale field. Near-black and - * near-white bins are skipped outright, and a cover with no chroma at all - * returns null so the caller keeps its white default. The winner is then pushed - * into a legible band (never washed out, never so dark it merges with the - * island) because a dark cover's dominant colour would otherwise be invisible - * against the notch. + * Picks a cover's dominant colour by population and chroma, skipping + * near-black and near-white bins, then keeps it legible against the island. */ private fun dominantArtTint(img: BufferedImage): Color? { val w = img.width @@ -431,19 +375,8 @@ private fun dominantArtTint(img: BufferedImage): Color? { } /** - * Decode album art straight to the size we actually use. - * - * Artwork arrives as a 900–1200px JPEG, and the naive path (decode it, wrap it - * as a Compose bitmap, unwrap it again, scale it, wrap it once more) measured - * 19–49ms of pure copying *per track* — 131ms on a cold first track. That is - * the single heaviest thing the app does when a track starts, and it is enough - * to make every other app on the machine feel like it stuttered. - * - * ImageIO's `sourceSubsampling` lets the decoder skip pixels while reading, so a - * 1200px JPEG destined for a 256px slot costs a fraction of a full decode and - * never materialises the full-size bitmap at all. When the format cannot - * subsample (PNG, and anything whose reader rejects the hint) we fall back to a - * plain decode plus one bilinear step. + * Decodes art straight to 256 px with sourceSubsampling; a full decode + * and rescale was the heaviest work on track start. */ private fun decodeArtBytes(bytes: ByteArray): BufferedImage? { val stream = ImageIO.createImageInputStream(ByteArrayInputStream(bytes)) diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/media/NativeResources.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/media/NativeResources.kt index 0c62a97..d13c325 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/media/NativeResources.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/media/NativeResources.kt @@ -7,10 +7,8 @@ import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit /** - * Extracts a packaged native helper (compiled macOS bridge, Windows PowerShell - * script) from the jar resources to a temp file so it can be executed. The - * macOS binary only exists when the project was built on a macOS host; a - * missing resource is a normal outcome on other hosts and yields no session. + * Extracts a packaged helper from the jar to a temp file. A missing macOS + * resource is normal on other hosts. */ internal object NativeResources { @@ -31,22 +29,9 @@ internal object NativeResources { } /** - * Kills bridge processes orphaned by previous runs (their ppid is 1). The - * JVM can die without ever disposing the composition — SIGTERM and kill -9 - * alike skip `media.stop()` — and a leaked nowplaying bridge then keeps - * polling MediaRemote forever (one osascript spawn every 2s per instance; - * a dev machine accumulated ~90 over one uptime, ~19 live JXA polls at - * ~10% CPU each, and the machine-wide stall that caused is what surfaced - * as "the notch performance tanks"). The bridges now watch getppid() - * themselves (nowplaying_bridge.m / btwatch_bridge.m), but this - * retro-sweeps the leaks of already-shipped versions on startup. - * - * Only ppid == 1 is ever targeted, so this process's own children (fresh - * bridges, ppid == our pid) and every other app's processes are never - * touched; the `/quicknotch-` path fragment matches the extracted temp - * binaries (`quicknotch-`) and the JXA helper script - * (`quicknotch-bridge/nowplaying.jxa`) alike. Any in-flight osascript - * under a swept bridge is a one-shot that finishes on its own. + * Kills bridge processes with ppid 1 left by earlier runs; a leaked + * nowplaying bridge polls MediaRemote forever. Only /quicknotch- paths + * are touched. */ fun sweepOrphanedBridges() { if (!HostPlatform.isMac) return diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/media/NotificationCount.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/media/NotificationCount.kt index 34a4567..b55282a 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/media/NotificationCount.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/media/NotificationCount.kt @@ -16,41 +16,9 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch /** - * Notification count for the quickview's bell indicator — fed into a bell glyph - * + badge bubble at the far right of the pill. - * - * macOS has no public "notifications currently in Notification Center" value, but - * the Notification Center *store* (`~/Library/Group Containers/group.com.apple.usernoted/db2/db`) - * is the ledger that backs the center, and its `record` table is exactly the - * count of notifications usernoted currently holds for the user — each row is a - * delivered notification that hasn't yet been acknowledged. It is live in both - * directions: - * - * - **added**: a notification arriving posts a new `record` row (plus its app's - * per-app `delivered` UUID list via `db-wal`) the moment it's accepted; - * - **removed**: dismissing it from the Notification Center, opening the app, - * or a Clear All deletes the rows for the cleared apps (verified: a Clear All - * in-session dropped the store from 59 rows to 15). - * - * So `count(*)` on `record` is the real-time "how many notifications exist for - * me right now" number — the count present in the Notification Center. - * - * One deliberate honesty note: macOS keeps the notifications of apps that still - * hold a Dock-badge unread count (WhatsApp, Messages) *after* a Clear All — - * those rows are not purged until the app is read/dismissed, so the count will - * legitimately sit above zero after clearing until you deal with those apps, - * exactly like the Dock badges do. The count is what the OS is holding, added - * and removed, in real time. - * - * Reading the store is guarded by Full Disk Access, so until a user grants it - * the query fails and the count stays null — the indicator hides exactly like - * any other unreadable probe, and settings offers a "Grant Full Disk Access" - * button that deep-links into the Privacy pane (and re-pokes on focus regain). - * Once the store reads, the badge is live and nothing else is configured, so the - * settings row disappears. - * - * Windows exposes no such store, so the count stays null there too — the bell - * hides rather than showing a meaningless always-zero badge. + * Counts unread notifications from usernoted's record table. Full Disk + * Access is required; without it, or on Windows, the count is null and + * the bell hides. */ internal object NotificationCount { @@ -75,14 +43,8 @@ internal object NotificationCount { val active = CoroutineScope(SupervisorJob() + Dispatchers.IO) scope = active job = active.launch { - // Near-instant badge updates, not a blind N-second poll: usernoted's - // store file only changes when a notification actually arrives or is - // dismissed, and in WAL mode the write lands in `db-wal` (the main - // `db` is static between checkpoints). We stat the three store files - // on a tight 250 ms tick — a couple of syscalls, no sqlite spawn — - // and only run the full query when a signature actually changes. A - // 3s backstop still runs so a signal we miss (a same-size rewrite, a - // permission change from a fresh FDA grant) converges on its own. + // Fingerprint db, db-wal and db-shm every 250 ms; query only when one + // changes. A 3 s backstop catches anything missed. var lastSignature: Long? = null var ticks = 0L while (isActive) { @@ -107,10 +69,8 @@ internal object NotificationCount { } /** - * Forces an immediate store re-read instead of waiting for the next poll - * tick, so a Full Disk Access grant made in System Settings is picked up - * as soon as the user returns to the app (the settings window pokes on - * focus regain). No-op off macOS. + * Re-reads the store at once so a Full Disk Access grant is picked up + * without waiting for the poll. */ fun poke() { if (!HostPlatform.isMac) return @@ -121,10 +81,7 @@ internal object NotificationCount { } /** - * Opens the pane where the required permission is granted. macOS asks for - * Full Disk Access (to read the Notification Center store) and lands on that - * Privacy & Security pane; Windows has no equivalent grantable permission, - * so this is a no-op there. + * Deep-links the Full Disk Access pane; no-op off macOS. */ fun openPermissionSettings() { if (HostPlatform.isMac) { @@ -137,12 +94,8 @@ internal object NotificationCount { } /** - * A cheap fingerprint of the store so a change can be detected without - * reading it: (mtime, size, fileKey) hashed across `db`, `db-wal` and - * `db-shm`. Inserts/dismissals land in the WAL, checkpoints touch the main - * `db`, and `-shm` changes with every session regression. A null fingerprint - * means the files are unreadable (no Full Disk Access yet, or the store is - * gone) — the callers keep the last count and the backstop tick retries. + * (mtime, size, fileKey) across db, db-wal and db-shm. Null when the + * store is unreadable, so callers keep the last count. */ private fun storeSignature(): Long? { val dir = System.getProperty("user.home") + diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/media/SystemBattery.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/media/SystemBattery.kt index 4856117..1956327 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/media/SystemBattery.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/media/SystemBattery.kt @@ -32,16 +32,8 @@ private val systemBatteryJson: Json = Json { } /** - * The machine's own battery (a laptop): level 0-100 and whether the OS is - * actively charging it. Null while no battery exists (desktops), so the - * quickview indicator only ever appears on machines that can actually - * drain. - * - * Polling lives in the companion: it runs independently of [AudioBattery] - * (same ring design, different data source). Windows reads `Win32_Battery` - * through [system-battery.ps1]; macOS parses `pmset -g batt`. A failed or - * misparsed read leaves the last value alone, so the indicator only ever - * shows real data. + * The machine's battery level and charging state, polled from pmset or + * Win32_Battery. Null on desktops and on a failed read. */ data class SystemBattery( val levelPercent: Int, @@ -57,9 +49,8 @@ data class SystemBattery( val state: StateFlow = _state.asStateFlow() /** - * Whether the machine actually has a battery (a laptop vs. a desktop). - * Stays false until the first successful probe, then tracks presence; - * it gates the settings toggle so desktops never offer a dead control. + * One upcoming date: month and day over the count badge. Clicking it opens + * that date in Google Calendar. */ private val _hasBattery = MutableStateFlow(false) val hasBattery: StateFlow = _hasBattery.asStateFlow() @@ -125,7 +116,6 @@ data class SystemBattery( _state.value = SystemBattery(level.coerceIn(0, 100), snap.charging) } - // ── macOS (`pmset -g batt`) ──────────────────────────────────────────── // // Now drawing from 'AC Power' // -InternalBattery-0 (id=123) 87%; charging; 0:00 remaining present: true diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/media/SystemVolume.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/media/SystemVolume.kt index f1ad93c..a1c1e29 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/media/SystemVolume.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/media/SystemVolume.kt @@ -16,21 +16,8 @@ import java.io.File import java.util.concurrent.TimeUnit /** - * System master-volume percentage (0-100) plus the device mute flag, polled at - * a low cadence for the music widget. - * - * Neither backend's "now playing" feed exposes volume — SMTC and MediaRemote - * carry none — so this is read straight from the OS, independent of the media - * session flow. Windows uses a tiny PowerShell helper over the CoreAudio COM - * API (the same master volume the flyout shows); macOS uses AppleScript's - * `output volume` / `output muted`. Each poll spawns ONE process that answers - * both values as a single `","` line. A failed or misparsed - * query simply leaves both null and the widget hides the chip. - * - * [toggleMute] flips the OS mute flag from the volume ring's click listener; - * it is fire-and-forget (spawns on IO like every other native call) and pokes - * an immediate re-read so the ring reflects the change without waiting for the - * next poll tick. + * Master volume and mute read from the OS, since neither media feed + * exposes them. One process answers both; a failed read hides the chip. */ internal object SystemVolume { diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/media/WindowsSmtcController.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/media/WindowsSmtcController.kt index ee5b96b..228c630 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/media/WindowsSmtcController.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/media/WindowsSmtcController.kt @@ -8,27 +8,9 @@ import java.util.concurrent.Executors import java.util.concurrent.TimeUnit /** - * Windows now-playing via SMTC (System Media Transport Controls) — the OS - * media session every well-behaved player registers with (Start-menu media - * keys, volume flyout, notifications). Reaching WinRT from the JVM would mean - * COM vtable plumbing, so we bridge through PowerShell's supported WinRT type - * activation; the whole script is a plain text resource, no compilation step. - * It runs under Windows PowerShell 5.1 (the engine we launch, whose WinRT - * projection is the one that exposes System Media Transport Controls); - * PowerShell 7 dropped that projection, so `pwsh` is not used. - * - * One-shot poll per second: the script prints a single JSON line, identical in - * shape to the macOS nowplaying-bridge, so [MediaControllerBase] decodes both. Commands - * run the same script with a verb. At 1 Hz the process cost is negligible and - * never touches the UI thread. - * - * The status poll tells the script which track the app already holds art for, - * so the thumbnail transfer happens once per track instead of once per second - * (see `smtc.ps1`): steady-state polls carry metadata only, and the per-id art - * cache in [MediaControllerBase] covers the omission. The gate is three-valued — - * '1' art held, '2' a fetch already came back empty, '0' fetch wanted — so a - * source that publishes no art (stub thumbnails, metadata-only players) is - * asked exactly once instead of every second. + * Windows now playing from SMTC through Windows PowerShell 5.1. The + * status poll passes the held art id so a track's thumbnail is fetched + * once, and a three-valued gate stops artless sources being asked again. */ internal class WindowsSmtcController : MediaControllerBase() { @@ -74,10 +56,8 @@ internal class WindowsSmtcController : MediaControllerBase() { } /** - * What the next status poll may skip: the thumbnail when the session - * already shows art for this exact track ('1'), or when a fetch for it - * already came back empty ('2'). Passed as separate argv elements (never - * one joined string), so ids containing spaces arrive intact. + * The next poll's art gate: 1 held, 2 fetch came back empty, 0 wanted. + * Passed as separate argv. */ private fun statusArguments(): List = listOf("status", statusId, statusHaveArt) @@ -92,14 +72,8 @@ internal class WindowsSmtcController : MediaControllerBase() { } /** - * One script spawn, bounded on every side. The stdout drain runs on - * [bridgeReader] so a wedge (WinRT stalls do happen) surfaces as a timeout - * here instead of a coroutine parked forever on a pipe read: after - * [BRIDGE_TIMEOUT_MS] the give-up path kills the process, which closes its - * pipe and frees the reader thread for the next poll. Draining *while* - * waiting is not optional — an art transfer pushes a few hundred KB of - * base64 through the pipe, well past the ~64 KB kernel buffer, so a - * "wait, then read" order would deadlock every illustrated track. + * One script spawn with a deadline and a concurrent stdout drain; art + * transfers exceed the pipe buffer, so wait-then-read would deadlock. */ private fun invoke(arguments: List): String? { val file = script ?: return null @@ -133,10 +107,8 @@ internal class WindowsSmtcController : MediaControllerBase() { private const val BRIDGE_TIMEOUT_MS = 4_000L /** - * Single daemon thread shared by every spawn: it only ever drains a - * pipe to EOF, and the [WindowsSmtcController] poll loop plus transport - * commands are serialized behind it (a poll is at most one second - * apart, and a killed wedge frees the thread immediately). + * One daemon thread drains every spawn; a killed wedge frees it for the + * next poll. */ private val reader = Executors.newSingleThreadExecutor { runnable -> Thread(runnable, "quicknotch-smtc-io").apply { isDaemon = true } diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/model/FavoritesRepository.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/model/FavoritesRepository.kt index 80b6abd..84da5da 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/model/FavoritesRepository.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/model/FavoritesRepository.kt @@ -15,12 +15,8 @@ import kotlinx.serialization.json.Json import java.io.File /** - * Manages persistent storage of QuickNotch settings and pinned applications. - * - * In-memory state is the source of truth for the UI (each setter publishes the - * new value immediately); writes are handed to a single background writer that - * coalesces them, so dragging a slider persists exactly one final value instead - * of one file write per tick, and config/favorites never interleave. + * In-memory state is the source of truth; one background writer + * coalesces disk writes so a slider drag persists once. */ class FavoritesRepository( private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @@ -49,12 +45,8 @@ class FavoritesRepository( private val _favorites = MutableStateFlow(loadFavorites()) val favorites: StateFlow> = _favorites.asStateFlow() - // ── Coalescing persistence ──────────────────────────────────────────────── - // Setters record the latest value of each kind under a plain lock (guarding - // only the tiny assignments, never file IO) and wake the writer. The writer - // drains the pending snapshots sequentially, so rapid changes — e.g. slider - // drags at frame rate — fold into a single disk write and concurrent - // writes to the same file can never interleave (no torn JSON). + // Drag state renders from a local snapshot; one repository write commits + // the final position when the drag ends. private sealed interface PersistRequest { data class Config(val value: NotchConfig) : PersistRequest data class Favorites(val value: List) : PersistRequest @@ -67,7 +59,8 @@ class FavoritesRepository( init { scope.launch { - for (wakeSignal in persistWake) { + while (true) { + persistWake.receive() val configToSave = synchronized(persistLock) { pendingConfig.also { pendingConfig = null } } diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/model/NotchConfig.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/model/NotchConfig.kt index ca62458..b02075f 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/model/NotchConfig.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/model/NotchConfig.kt @@ -28,16 +28,8 @@ enum class NotchStyle( } /** - * The body's material. All entries resolve to brushes built once and cached, so - * none is rebuilt per frame while the island animates. [SYSTEM_ACCENT] is - * deliberately texture-free glass: a tinted body that lets the desktop bleed - * through the transparent window plus a smooth light sheen. - * - * [TRANSLUCENT] is the real thing: the OS blurs the desktop behind the island - * (compositor blur radius on macOS, DWM acrylic on Windows) under a dark - * scrim. Where the native bridge is missing it falls back to the painted - * translucent glass. (Serialised as "BLUR", which is what this entry has - * always been called on disk.) + * Island materials, built once as brushes. TRANSLUCENT is real OS blur + * with a painted fallback; it serialises as BLUR. */ @Serializable enum class NotchSurface { @@ -54,10 +46,7 @@ enum class NotchSurface { } /** - * What colour the collapsed pill's waveform bars take. [ALBUM_ART] derives a - * tint from the current cover once per track (see `dominantArtTint`); [WHITE] - * keeps them a constant white. A missing or unreadable cover falls back to - * white rather than inventing a colour. + * Album-art tint per track, or white; a missing cover falls back to white. */ @Serializable enum class WaveformTint { @@ -103,10 +92,8 @@ data class WidgetsConfig( */ val showCollapsedNotifications: Boolean = true, /** - * Equalizer bars under the collapsed title while a track is live. Lives in - * the title's own slot (no new indicator slot, so the footprint math is - * untouched) and hides the edge progress strip for as long as it is on. - * Never rendered in the expanded music page. + * Equalizer bars in the title slot, replacing the edge progress strip + * while on. The expanded page never draws them. */ val showCollapsedWaveform: Boolean = true, /** @@ -116,34 +103,24 @@ data class WidgetsConfig( */ val showCalendar: Boolean = false, /** - * Weather in the collapsed pill (condition glyph + temperature, left of - * the date) and as an expanded wheel page (today plus the 7-day outlook - * with UV index and precipitation chances). Zero-config: the position is - * IP-geolocated, so no account or setup is needed. + * Weather in the pill and as a wheel page; IP-located, so no setup. */ val showWeather: Boolean = true, /** - * System monitor as an expanded wheel page: CPU, GPU (when the OS reports - * one), memory, boot-volume fill and network throughput, each as the same - * ring the pill uses for volume/battery, with the live value beneath it. - * The poller only runs while this is on, so switching it off costs nothing. + * System page with CPU, GPU, memory, storage and network rings; the poller + * runs only while this is on. */ val showSystem: Boolean = true, /** - * Manual weather place picked from Open-Meteo geocoding (label + - * coordinates). Null means the automatic IP-geolocated position; a manual - * pick replaces it for the pill, the page, and the poll. Old configs load - * null, so existing installs keep their current behaviour. + * Manual place for the weather feed; null means IP location. Old configs + * load null. */ val weatherPlace: WeatherPlace? = null, /** Display temperatures in Fahrenheit instead of Celsius. */ val weatherFahrenheit: Boolean = false, /** - * Paint condition glyphs in per-condition colours (amber sun, blue rain, - * …) instead of the monochrome text tint. Purely a render choice — the - * same glyphs, only the tint changes. + * Tints condition glyphs per condition instead of the text tint. */ - /** Album-art-derived or constant white; see [WaveformTint]. */ val waveformTint: WaveformTint = WaveformTint.ALBUM_ART, /** * Which visualiser shape the collapsed bars take. Defaults to the @@ -157,25 +134,18 @@ data class WidgetsConfig( */ val showProgressBorder: Boolean = true, /** - * While the waveform is on, drop the song name from the collapsed pill: the - * live view then reads as disc + analyser + level rings only, and the bars - * get the whole title frame (18dp instead of a 7dp band). Meaningless with - * the waveform off, so the settings row appears only inside that section. + * Drops the song title while the waveform is on, giving the bars the full + * title frame. */ val waveformHidesTitle: Boolean = false, /** - * Gaming performance HUD: while a fullscreen game is detected, the - * collapsed quickview is replaced by live FPS (Windows, via PresentMon), - * CPU, GPU, latency and download rate. Detection only runs while this is - * on, so a user who never games pays no probe at all. + * Replaces the collapsed pill with FPS, CPU, GPU, latency and download + * while a game runs; detection runs only while on. */ val showGameHud: Boolean = true, /** - * Which readouts the gaming HUD draws. Every cell can be switched off on - * its own; with all five off [NotchConfig.gameHudEnabled] is false and the - * notch keeps its ordinary pill rather than showing an empty strip. FPS is - * still dropped when PresentMon is absent and GPU when the OS reports no - * engine, exactly as before — a cell can only show a true number. + * Each HUD cell can be switched off; with all five off the pill returns. + * FPS and GPU still drop when unavailable. */ val gameMetrics: GameMetrics = GameMetrics() ) @@ -221,11 +191,8 @@ enum class ClockDateFormat(val pattern: String) { } /** - * Persistent configuration for the floating notch and its dock. - * - * The fisheye magnification radius is no longer user-tunable: it is derived - * from [baseIconSizeDp] so the wave always tracks the cursor with icon-level - * precision (see `fisheyeRadiusPx`). + * Persistent configuration. The fisheye radius is derived from + * baseIconSizeDp, not user-tunable. */ @Immutable @Serializable @@ -233,19 +200,13 @@ data class NotchConfig( val style: NotchStyle = NotchStyle.NOTCH, /** - * Set once the first-run onboarding has been walked through (or skipped - * through): the notch overlay does not exist until then, because the user - * is still configuring the pill it would draw. Defaults false, so a config - * written before this field existed runs onboarding once on next launch. + * False until onboarding finishes; the overlay does not exist before that. */ val onboardingDone: Boolean = false, val surface: NotchSurface = NotchSurface.SOLID, /** - * Gaming-only transparency: while the gaming HUD is showing, the island - * drops its body, rim, dividers and shadow entirely, so only the readout's - * glyphs sit over the game. Deliberately separate from [surface]: Solid / - * Blurred / System accent are untouched, and this only ever applies while a - * game is detected. + * Drops the island body, rim and shadow while the gaming HUD shows; other + * surfaces are untouched. */ val transparentInGame: Boolean = false, /** @@ -278,11 +239,8 @@ data class NotchConfig( val themeDark: Boolean = true ) { /** - * True when the gaming HUD has a body to draw: its switch is on and at - * least one metric is selected. Every consumer (the overlay, the window - * sizer, the system poller and the detector) gates on this rather than the - * raw switch, so switching the last metric off returns the pill instead of - * leaving an empty island. + * True when the HUD is on and at least one metric is selected; every + * consumer gates on this. */ val gameHudEnabled: Boolean get() = widgets.showGameHud && widgets.gameMetrics.any diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/system/FpsProbe.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/system/FpsProbe.kt index 8a6f5ab..6ee8e3d 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/system/FpsProbe.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/system/FpsProbe.kt @@ -13,37 +13,9 @@ import kotlinx.coroutines.isActive import kotlin.math.roundToInt /** - * Frame rate for the gaming HUD, from Intel's PresentMon on Windows. - * - * There is no cross-platform API for another process's frame rate: the OS - * compositor does not publish one, and both major platforms refuse to hand out - * another app's GPU timings. PresentMon solves it the only way that works — it - * opens an ETW session on the graphics driver's present events — and ships as a - * single self-contained executable, so it can live beside the app and be run - * for the few seconds a sample needs. - * - * macOS has no equivalent through a supported API, so this probe is a permanent - * no-op there: the HUD simply has one fewer cell. Same on a Windows install - * whose binary is missing — the HUD drops the FPS cell rather than showing a - * fabricated number. - * - * ## Why a 2 s capture every 6 s - * - * PresentMon reports one row per presented frame; a rate needs a window of - * frames to average. Two seconds smooths shader-compile hitches while still - * tracking the scene, and sleeping between captures means the ETW overhead only - * exists while a sample is being taken. - * - * ## Unverified surface - * - * The binary is not bundled in this repository (it is a third-party release - * artifact — see `native/windows/README-presentmon.md`). PresentMon 2.x renamed - * every long option from `-process_id` to `--process_id` and rejects the other - * spelling, so the probe tries one style, and only if that yields no CSV - * header does it retry with the other and remember which worked. The parser is - * header-driven and case-insensitive, so either version's columns read. Every - * failure path — missing binary, refused ETW session, unexpected output — - * yields "no FPS", never a wrong number. + * FPS from PresentMon on Windows, one 2 s capture every 6 s. macOS and + * an install without the binary get no FPS cell rather than a made-up + * number. */ object FpsProbe { @@ -136,11 +108,8 @@ object FpsProbe { } /** - * PresentMon prints a CSV whose first line is the header. The frame-time - * column has been `msBetweenPresents` since 1.x (2.x prints the same name), - * so one case-insensitive lookup finds it; rows with a non-positive or - * unparsable frame time (the first row of a session, a dropped frame) are - * skipped rather than counted as zero. + * Header-driven CSV parse: msBetweenPresents is the same in 1.x and 2.x; + * non-positive rows are skipped. */ internal fun averageFps(csv: String): Int? { val lines = csv.lineSequence().filter { it.isNotBlank() }.toList() diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/system/GameDetector.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/system/GameDetector.kt index d8faa33..8b57d5f 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/system/GameDetector.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/system/GameDetector.kt @@ -25,28 +25,8 @@ data class GameSession( ) /** - * Watches for a fullscreen game and publishes it as one [StateFlow], which is - * the entire trigger for the gaming quickview: while this is non-null and the - * island is collapsed, the performance HUD replaces the pill. - * - * Two very different probes behind one contract: - * - * - **macOS** asks `MacGameWatch` (CoreGraphics, in-process) every 1.5 s. A - * layer-0 non-shell window covering the main display is the signal — the - * same thing the user sees when a game takes the screen. - * - **Windows** keeps one long-lived `game-detect.ps1` running and reads the - * line it prints per tick. Spawning the script per probe was the obvious - * shape and the wrong one: its `Add-Type` P/Invoke surface costs ~1 s to - * compile in PowerShell 5.1, so a per-tick spawn would burn a fifth of a - * core for the app's whole life. One process, one compile, one cheap loop — - * and the script exits by itself once this JVM is gone (see its header). - * - * A game that drops out for a single probe (a loading screen recreating its - * window, an alt-tab) does not clear the session: [MissingProbesBeforeIdle] - * consecutive misses are required, which is what keeps the HUD from blinking - * mid-match. The detector costs nothing while disabled — the Behavior toggle - * owns [setEnabled], and a switched-off detector runs no probe and reports no - * game. + * One StateFlow is the gaming trigger. Windows keeps one long-lived + * watcher; two misses clear a session. */ object GameDetector { @@ -121,11 +101,8 @@ object GameDetector { } /** - * Starts the Windows watcher once and translates its protocol into - * sessions. `1|name|pid` is a game; `0||` is a miss, held for - * [MissingProbesBeforeIdle] lines exactly like the macOS probe. A watcher - * that cannot start (script missing, PowerShell blocked by policy) leaves - * the detector silent rather than retrying in a loop. + * Translates game-detect.ps1 lines into sessions; a watcher that cannot + * start leaves the detector silent. */ private suspend fun watchWindows() { val script = NativeResources.extract(WindowsScriptResource, ".ps1") diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/system/MacSystemProbe.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/system/MacSystemProbe.kt index a331b97..53cc827 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/system/MacSystemProbe.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/system/MacSystemProbe.kt @@ -30,18 +30,8 @@ private val RamUsedKeys = listOf( private val GpuUtilisation = Regex("\"Device Utilization %\"=(\\d+)") /** - * macOS half of the system monitor. Everything comes from one `/bin/sh` round - * trip — measured at ~40 ms for the whole set, versus ~5 separate process - * spawns if each reading were its own command. `netstat` is numeric (`-n`): - * without it, macOS resolves every interface address through DNS and the probe - * blocked for 5 s in testing. - * - * There is no public CPU/GPU API reachable from the JVM here, so the readings - * are the same ones a shell-minded user would run: `ps` sums to the machine - * percentage, `vm_stat` + `hw.memsize` give memory, `df` gives the data - * volume's fill (the read-only system volume always looks nearly empty) and - * `ioreg`'s IOAccelerator statistics give GPU utilization where the driver - * publishes it. + * One /bin/sh per tick for CPU, memory, data-volume fill and GPU, about + * 40 ms. netstat must be -n, or DNS resolution stalls the probe. */ internal object MacSystemProbe { @@ -114,10 +104,8 @@ private fun sections(raw: String): Map> = .toMap() /** - * `ps %cpu` is per process, where 100 means one full core. Summing gives - * "cores × 100"-scale load, so dividing by the core count lands back on the - * machine-wide 0-100 the ring draws. It is the decaying recent average `ps` - * reports, which is exactly the calm reading a glanceable ring wants. + * ps %cpu is per process, so the sum is divided by the core count to get + * the 0-100 the ring draws. */ private fun cpuPercent(lines: List, cores: Int): Int? { if (cores <= 0 || lines.isEmpty()) return null @@ -140,10 +128,8 @@ private fun ramUsedBytes(lines: List, totalBytes: Long?): Long? { } /** - * Cumulative interface byte counters, summed over every real interface. - * `netstat -ib` prints several rows per interface (one per address family) and - * only the first carries the counters, so names are de-duplicated. Loopback is - * skipped: browser↔app traffic on `lo0` is not the network the user is watching. + * netstat -ib repeats a row per address family; de-duplicate by name and + * skip lo0. */ private fun parseNetCounters(lines: List): Pair? { val seen = mutableSetOf() diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/system/SystemMetrics.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/system/SystemMetrics.kt index 9d4d549..dc05da5 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/system/SystemMetrics.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/system/SystemMetrics.kt @@ -20,16 +20,8 @@ import java.util.concurrent.TimeUnit import kotlin.math.roundToInt /** - * One reading of the machine's own health, as the System widget page draws it: - * CPU, GPU (when the OS exposes one), memory, the boot volume and the network - * throughput since the previous sample. - * - * Every field is nullable on purpose. A metric the current OS will not tell us - * (GPU utilization on a Mac without `Device Utilization %`, the very first - * network sample, a failed probe) stays null and the page draws a dim ring with - * a dash instead of inventing a number. The poller merges each fresh sample - * over the last one, so a transient failure keeps the previous value rather - * than blanking the notch. + * One system reading. Nullable fields mean the OS did not answer; the + * poller merges over the last sample instead of blanking the page. */ data class SystemSnapshot( val cpuPercent: Int? = null, @@ -42,10 +34,8 @@ data class SystemSnapshot( val downBytesPerSec: Long = 0L, val upBytesPerSec: Long = 0L, /** - * Round-trip time of one TCP handshake to a public resolver, in ms. Used by - * the gaming HUD ("how is my connection to the match server") and kept off - * the System page, which is about load rather than reachability. Null until - * the first probe answers, and on a machine with no route out. + * TCP handshake round trip for the HUD; null until the first probe or + * with no route. */ val pingMs: Int? = null ) { @@ -66,10 +56,8 @@ data class SystemSnapshot( val gpuAvailable: Boolean get() = gpuPercent != null /** - * Whether this snapshot carries any reading at all. A probe can answer with - * an empty object (every counter on the machine failed), and such a - * snapshot must never reach the page: it would put a System page on the - * wheel that shows nothing but dashes. + * Whether the snapshot carries any reading; an all-null sample must not + * put an empty System page on the wheel. */ val hasAnyReading: Boolean get() = cpuPercent != null || gpuPercent != null || ramUsedBytes != null || @@ -93,15 +81,8 @@ internal data class SystemSample( ) /** - * Polls a handful of cheap OS readings into one [StateFlow] the notch observes. - * - * Cost is the whole design constraint here, because the notch polls for as long - * as the app runs: macOS runs exactly one `/bin/sh` per tick that shells out to - * `ps`, `vm_stat`, `netstat` and `ioreg` (≈40 ms of work together), and Windows - * runs one `system-metrics.ps1` per tick that samples PDH counters once. Batch - * modelling static facts (total RAM, GPU name, core count) is read once at - * start, never per tick. The loop is gated on [setEnabled], so a user who - * turns the widget off pays literally nothing. + * Polls one batched probe per tick and reads static facts once. Disabled + * means no process is spawned. */ object SystemMetrics { @@ -216,12 +197,8 @@ object SystemMetrics { } /** - * Cumulative interface counters to a per-second rate for **both** directions - * from one delta: [lastNet] may only advance once per sample, or the second - * direction would subtract a counter from itself and read as a permanent - * zero. The first sample has nothing to subtract from, and a counter that - * went backwards (an interface re-appearing, a sleep/wake cycle) is not a - * negative rate, so both yield null and the previous rate stands. + * Both network directions share one delta, so lastNet advances once per + * sample; the first sample and a backwards counter are null. */ private fun netRates(net: Pair?): Pair { val counters = net ?: return null to null @@ -268,10 +245,8 @@ object SystemMetrics { else MacSystemProbe.gpuName() /** - * Runs a one-shot helper and returns its trimmed stdout, or null when it - * times out / cannot start. Every spawn in this file goes through here so - * a wedged helper can never pin a poller thread: `destroyForcibly` on the - * timeout, and stderr merged so one stream drains. + * One-shot helper with a timeout and merged stderr; destroyForcibly on + * timeout. */ internal fun runProcess(command: List, timeoutMs: Long = ProbeTimeoutMs): String? = runCatching { @@ -291,19 +266,8 @@ private const val PingPort = 443 private const val PingTimeoutMs = 1_000 /** - * One TCP connect to a public resolver and back, in whole milliseconds. - * Chosen over ICMP because a raw socket needs root/Administrator while a TCP - * handshake is an ordinary outbound connection — and because a game's own - * traffic is TCP/UDP to a server, so a completed handshake measures the thing - * the player actually cares about (route + queueing) rather than a host that - * may deprioritise echo requests. - * - * File-level rather than a member of [SystemMetrics]: the poller already sits - * on detekt's function-count ceiling, and this shares nothing with it but the - * constants above. - * - * Fail-soft: no route, a firewall, a captive portal or a 1 s timeout all return - * null and the previous reading stands. + * One TCP handshake to a public resolver, since ICMP needs root. Null on + * timeout or no route. */ private fun probePingMs(): Int? = runCatching { val started = System.nanoTime() diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/system/WindowsSystemProbe.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/system/WindowsSystemProbe.kt index ef9666a..0d939f7 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/system/WindowsSystemProbe.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/system/WindowsSystemProbe.kt @@ -29,19 +29,8 @@ private val windowsJson: Json = Json { } /** - * Windows half of the system monitor. - * - * One `system-metrics.ps1` spawn per tick collects everything through PDH - * (`Get-Counter`, sampled over one second — CPU and the network rates are - * deltas, so they need two PDH samples and Get-Counter supplies both) plus - * `DriveInfo` for the boot volume. RAM *total* is a static fact and is read - * once at startup, so the per-tick script never pays for a second WMI query - * beyond the GPU-engine counter set. - * - * GPU utilization is the 3D engine's `Utilization Percentage` summed across GPU - * engine instances and clamped to 100 — the same number Task Manager shows as - * "GPU", approximated. Machines without GPU performance counters (no WDDM 2.0 - * engine provider) simply get no GPU tile. + * One system-metrics.ps1 per tick over PDH, plus DriveInfo for the disk. + * GPU is the summed 3D-engine utilization; no counters means no tile. */ internal object WindowsSystemProbe { diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/theme/Color.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/theme/Color.kt index 50b7f23..b286548 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/theme/Color.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/theme/Color.kt @@ -8,10 +8,7 @@ val NotchCard = Color(0xFF161C2B) val NotchBorder = Color(0x33FFFFFF) val AccentIndigo = Color(0xFF6366F1) -val AccentIndigoLight = Color(0xFF818CF8) val AccentCyan = Color(0xFF38BDF8) -val AccentPurple = Color(0xFFA855F7) -val AccentPink = Color(0xFFEC4899) val AccentGreen = Color(0xFF22C55E) val AccentRed = Color(0xFFEF4444) @@ -19,15 +16,9 @@ val TextPrimary = Color(0xFFF8FAFC) val TextSecondary = Color(0xFF94A3B8) val TextMuted = Color(0xFF64748B) -// ── Weather condition tints (the "colorful" icon style) ───────────────────── -// One signature colour per condition; the monochrome style ignores these and -// tints every glyph with the surrounding text colour instead. - -// ── Modern settings design tokens (all solid flats — no gradients) ─────────── -// Refined sidebar + card system for the settings window and onboarding flow. -// Every token is a flat solid so surfaces stay crisp on the CPU rasterizer and -// read as one calm dark system: deep app bg, slightly lifted sidebar, cards one -// step above, hairline borders, accent reserved for selection + primary action. +// Settings and onboarding surfaces. Every token is a flat solid: deep app +// background, a slightly lifted sidebar, cards one step above, hairline +// borders, and the accent reserved for selection and the primary action. val SettingsBg = Color(0xFF0B0D13) val SettingsSidebar = Color(0xFF0E121B) val SettingsCard = Color(0xFF141926) @@ -38,7 +29,6 @@ val SettingsBorderStrong = Color(0xFF2E3854) val SettingsSelected = Color(0xFF1B2340) val AccentSolid = Color(0xFF818CF8) val AccentSoft = Color(0x14818CF8) -val AccentInk = Color(0xFF14161F) val SwitchTrackOn = Color(0xFF6366F1) val SwitchTrackOff = Color(0xFF232B42) val SwitchThumbOn = Color(0xFFFFFFFF) diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/CalendarWidget.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/CalendarWidget.kt index 4bc84ab..e8fe1e2 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/CalendarWidget.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/CalendarWidget.kt @@ -56,7 +56,8 @@ import com.starkdroid.quicknotch.generated.resources.calendar_disconnect import com.starkdroid.quicknotch.generated.resources.calendar_error_api_disabled import com.starkdroid.quicknotch.generated.resources.calendar_error_auth import com.starkdroid.quicknotch.generated.resources.calendar_error_cancelled -import com.starkdroid.quicknotch.generated.resources.calendar_error_client_type +import com.starkdroid.quicknotch.generated.resources.calendar_error_client_credentials +import com.starkdroid.quicknotch.generated.resources.calendar_error_not_configured import com.starkdroid.quicknotch.generated.resources.calendar_error_not_verified import com.starkdroid.quicknotch.generated.resources.calendar_error_network import com.starkdroid.quicknotch.generated.resources.calendar_error_revoked @@ -87,12 +88,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch /** - * The QuickView calendar element: a calendar glyph badged with how many events - * the day has. Shares the ring-badge look ([CountBadge]) so it sits happily - * beside the notification bell. Hidden entirely when the day has nothing, - * rather than showing a lone glyph — and it claims an indicator slot only when - * it draws (see `quickViewIndicatorSlots`), so an empty day leaves the pill - * exactly as narrow as it was. + * Calendar glyph with the day's event count; hidden when empty, so it + * claims no indicator slot. */ @Composable internal fun CalendarDayIndicator( @@ -112,10 +109,8 @@ internal fun CalendarDayIndicator( } /** - * The Widgets-tab calendar widget: connect Google Calendar, see the account and - * sync age, and refresh on demand. Whether the notch shows the day at all lives - * on the accordion header (like every other widget) — the connection is the - * only thing configured in here. + * Connect, show the account and sync age, refresh. The widget toggle lives + * on the accordion header. */ @Composable internal fun CalendarWidget( @@ -176,7 +171,8 @@ private fun CalendarConnectRow( error != null -> stringResource( when (error.reason) { GoogleCalendar.REASON_NETWORK -> Res.string.calendar_error_network - GoogleCalendar.REASON_CLIENT_TYPE -> Res.string.calendar_error_client_type + GoogleCalendar.REASON_CLIENT_CREDENTIALS -> Res.string.calendar_error_client_credentials + GoogleCalendar.REASON_NOT_CONFIGURED -> Res.string.calendar_error_not_configured GoogleCalendar.REASON_NOT_VERIFIED -> Res.string.calendar_error_not_verified GoogleCalendar.REASON_CANCELLED -> Res.string.calendar_error_cancelled GoogleCalendar.REASON_REVOKED -> Res.string.calendar_error_revoked @@ -281,7 +277,8 @@ private fun relativeTime(millis: Long): String { private fun String.localized(): StringResource = when (this) { GoogleCalendar.REASON_NETWORK -> Res.string.calendar_error_network GoogleCalendar.REASON_CANCELLED -> Res.string.calendar_error_cancelled - GoogleCalendar.REASON_CLIENT_TYPE -> Res.string.calendar_error_client_type + GoogleCalendar.REASON_CLIENT_CREDENTIALS -> Res.string.calendar_error_client_credentials + GoogleCalendar.REASON_NOT_CONFIGURED -> Res.string.calendar_error_not_configured GoogleCalendar.REASON_NOT_VERIFIED -> Res.string.calendar_error_not_verified GoogleCalendar.REASON_API_DISABLED -> Res.string.calendar_error_api_disabled else -> Res.string.calendar_error_auth @@ -289,20 +286,8 @@ private fun String.localized(): StringResource = when (this) { /** - * The expanded notch's calendar page: the next four dates that actually have an - * event coming, soonest first, one tile each with its month-labelled date over - * a count badge. Rows cannot fit the island's ~84dp depth, so the dates run as - * a single row of tiles -- the wheel flips to this page like any other. - * - * Every date on it, today's block included, is a link into that day in Google - * Calendar: the row's whole job is "where is my calendar heading", and the place - * a user goes to act on that is the real calendar. The tiles are the only - * pointer targets on the page, so they hang off [interactive] exactly like the - * dock icons do -- a hit-testable node on an inactive (alpha 0) page would sit - * in the hit path of the page composed beneath it (pitfall 24). - * - * Loading is never blocking: while a fetch runs with nothing to show yet the - * page is a small spinner, and an empty-but-settled calendar says so in words. + * Upcoming days as a row of tiles, each a link into Google Calendar. + * Tiles take the pointer chain only when the page is interactive. */ @Composable internal fun CalendarNotchPage( @@ -376,35 +361,16 @@ internal fun CalendarNotchPage( } /** - * The web view of one day in Google Calendar, for handing a schedule date to - * the browser. - * - * `u/0` is Google's own documented form and is deliberate, not a shortcut: the - * number in that path is the account's position in the *browser's* sign-in - * order, and no API reports it (Google's own guidance on deep links is that the - * index cannot be derived). The tempting alternative, `?authuser=` with - * the address this app does know from userinfo, is worse in a way that is easy - * to miss: if that account is not signed into the browser, Google silently - * opens a *different* one rather than prompting, so the user lands in a - * plausible-looking wrong calendar. `u/0` is the default account, which is - * right often enough and never quietly wrong. - * - * Month and day go in unpadded, matching Google's own examples - * (`.../r/day/2023/4/1`). + * u/0 is Google's default-account path; authuser= silently opens + * another account when that one is not signed in. */ private fun googleDayUrl(date: kotlinx.datetime.LocalDate): String = "https://calendar.google.com/calendar/u/0/r/day/" + "${date.year}/${date.monthNumber}/${date.dayOfMonth}" /** - * Opens a date's web view in the browser, off the UI thread. - * - * [AppLauncher.browse] spawns `open` and then *waits* for it, with a ten second - * ceiling, so calling it straight from a click handler would stall the Compose - * thread for the whole spawn -- the same mistake the transport buttons used to - * make with `waitFor` on the macOS media commands. The OAuth path already runs - * on an IO scope and needs no help; a click handler arrives on the EDT, so the - * hop has to happen here. + * AppLauncher.browse waits for the spawn, so a click handler must hand it + * to the IO scope or freeze the Compose thread. */ @Composable private fun rememberDayOpener(): (kotlinx.datetime.LocalDate) -> Unit { @@ -417,14 +383,8 @@ private fun rememberDayOpener(): (kotlinx.datetime.LocalDate) -> Unit { } /** - * The shared affordance for a clickable date: a translucent capsule that fades - * in under the cursor plus the slight spring scale the transport buttons use, so - * the row reads as "these are links" rather than as a table of numbers. - * - * The whole pointer modifier chain is built only while [interactive] -- - * `clickable(enabled = false)` still registers a hit-testable node, so a - * disabled-but-present clickable would keep an inactive page owning the hit path - * of the page beneath it. + * Capsule hover and spring scale for a clickable date. The pointer chain + * exists only while interactive, since a disabled clickable still hit-tests. */ @OptIn(ExperimentalComposeUiApi::class) @Composable @@ -479,10 +439,8 @@ private fun Modifier.dateLink( } /** - * The schedule view's left anchor: the current date in the big size, the - * weekday and month beside it. Plain date -- no badge -- because the badge - * belongs to the upcoming dates and today's running event, if any, is what the - * pill's ticker already announced. + * Today's date, weekday and month; no badge, since the ticker already + * announced a running event. */ @Composable private fun CalendarTodayBlock( @@ -529,11 +487,8 @@ private fun CalendarTodayBlock( } } -/** - * One upcoming date in the schedule row: the day with its month -- "10 Oct", - * "20 Nov" -- over the count badge, centered. Dates here are never today; the - * big left block owns that. Clicking one opens that date in Google Calendar. - */ +// Resets only appearance, sizing and clock. Widgets and Behavior keep their +// own state, so a reset never undoes a user's widget choices. @Composable private fun CalendarDateTile( group: CalendarDateGroup, diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/ChangelogDialog.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/ChangelogDialog.kt index 5c695f9..1be28d0 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/ChangelogDialog.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/ChangelogDialog.kt @@ -53,10 +53,13 @@ import com.starkdroid.quicknotch.datasource.Changelog import com.starkdroid.quicknotch.datasource.ChangelogChange import com.starkdroid.quicknotch.datasource.ChangelogRelease import com.starkdroid.quicknotch.datasource.ChangelogStatus +import com.starkdroid.quicknotch.datasource.ProjectLinks import com.starkdroid.quicknotch.datasource.UpdateState import com.starkdroid.quicknotch.datasource.AppInstaller import com.starkdroid.quicknotch.generated.resources.Res import com.starkdroid.quicknotch.generated.resources.about_copyright +import com.starkdroid.quicknotch.generated.resources.support_kofi +import com.starkdroid.quicknotch.generated.resources.view_source import com.starkdroid.quicknotch.generated.resources.about_description import com.starkdroid.quicknotch.generated.resources.about_product_name import com.starkdroid.quicknotch.generated.resources.about_version @@ -69,7 +72,6 @@ import com.starkdroid.quicknotch.generated.resources.changelog_loading import com.starkdroid.quicknotch.generated.resources.changelog_no_asset import com.starkdroid.quicknotch.generated.resources.changelog_restart_update import com.starkdroid.quicknotch.generated.resources.changelog_retry -import com.starkdroid.quicknotch.generated.resources.changelog_source_note import com.starkdroid.quicknotch.generated.resources.changelog_update_available import com.starkdroid.quicknotch.generated.resources.changelog_update_failed import com.starkdroid.quicknotch.generated.resources.changelog_update_hint @@ -98,16 +100,8 @@ import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource /** - * The one "about" surface: what this app is, which version is running, what - * changed in every version up to it, and — when GitHub has something newer — - * the banner that downloads it in the background and installs it on restart. - * - * Two rules shape the content: - * - the release list stops at the running version. A newer changelog entry is - * not documentation yet; it is an update, and showing its notes as if the - * user already had it would be a lie. - * - the feed is fetched, never bundled. A dialog opened offline shows the last - * cached history and a small note instead of inventing a blank release. + * The changelog stops at the running version; offline it shows the + * cached history rather than inventing an empty release. */ @Composable internal fun ChangelogDialog(onDismiss: () -> Unit) { @@ -201,20 +195,27 @@ private fun ChangelogCardBody( } } - Spacer(Modifier.height(10.dp)) - Text( - text = stringResource(Res.string.changelog_source_note), - style = AppTypography.labelSmall, - color = TextMuted, - fontSize = 10.sp - ) - Spacer(Modifier.height(6.dp)) - Text( - text = stringResource(Res.string.about_copyright), - style = AppTypography.labelSmall, - color = TextMuted, - fontSize = 11.sp - ) + Spacer(Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(Res.string.about_copyright), + style = AppTypography.labelSmall, + color = TextMuted, + fontSize = 11.sp + ) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + SettingsLinkButton(stringResource(Res.string.view_source)) { + ProjectLinks.open(ProjectLinks.REPOSITORY) + } + SettingsLinkButton(stringResource(Res.string.support_kofi)) { + ProjectLinks.open(ProjectLinks.KOFI) + } + } + } } } @@ -302,7 +303,9 @@ private fun UpdateBanner(state: UpdateState) { text = stringResource(Res.string.changelog_up_to_date), style = AppTypography.labelSmall, color = TextMuted, - fontSize = 10.5.sp + fontSize = 10.5.sp, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() ) } } diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/DockContent.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/DockContent.kt index 73f120a..6c331a3 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/DockContent.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/DockContent.kt @@ -93,16 +93,12 @@ import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import kotlin.math.abs -// ── Widget pages ───────────────────────────────────────────────────────────── internal enum class WidgetPage { Apps, Music, Calendar, Weather, System } /** - * Renders every active widget page as an always-composed sibling so the pager - * never measures the music page while the window is animating. Inactive pages - * are pushed aside with a small alpha+slide layer transform (never a - * subtraction/recomposition), and only the page under the pointer receives - * input. + * Every active page stays composed so the pager never measures the music page mid-animation; + * inactive pages are offset with alpha+slide, and only the page under the pointer gets input. */ @Composable internal fun WidgetPages( @@ -160,9 +156,8 @@ internal fun WidgetPages( WidgetPage.Calendar -> CalendarNotchPage( state = calendarState, refreshing = calendarRefreshing, - // Same rule as the other two pages: only the page under - // the cursor may own the hit path, or an alpha-0 page - // keeps swallowing clicks meant for the page beneath it. + // Only the page under the cursor may own the hit path: an alpha-0 page + // would otherwise swallow clicks meant for the page beneath it. interactive = interactive && isActive ) WidgetPage.Weather -> WeatherNotchPage( @@ -232,12 +227,8 @@ private fun MusicPage( } /** - * Vinyl left in an album-tinted halo with a live-status dot, the metadata - * held in the middle over a thicker playhead strip, and the level rings - * stacked above a roomier transport cluster on the right. The title block - * crossfades on every track change so back-to-back songs glide instead of - * cutting. Every transport control stays visible here; collapsing the - * play/pause only affects the slim quickview. + * Vinyl left in an album-tinted halo with a live dot, metadata in the middle over a thicker + * playhead, rings above a roomier transport cluster; the title block crossfades on track change. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -258,8 +249,7 @@ private fun MusicContent( val duration = session.durationMs.coerceAtLeast(0L) val elapsed = session.displayedPositionMs(nowMs).coerceAtLeast(0L) .coerceAtMost(if (duration > 0L) duration else Long.MAX_VALUE) - // "Artist — Album" second line; either half alone still reads fine, and a - // track with neither keeps its single-line title layout. + // Subtitle joins artist and album; either half alone reads fine, a track with neither stays one line. val subtitle = remember(session.artist, session.album) { listOf(session.artist, session.album) .map { it.trim() } @@ -275,20 +265,13 @@ private fun MusicContent( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp) ) { - // The vinyl slot morphs between pill and page (discModifier on the - // slot), and swaps records on a track change: the old cover slides out - // through the notch's left edge (the Row above clips at the island - // bounds) and the new one slides in from the left with a springy - // settle. Keyed on the artwork itself, so the entrance fires exactly - // when the next cover has decoded — a cover that lands a poll late - // gets its own entrance instead of popping in. + // The vinyl slot swaps records on a track change: keyed on the artwork, so the entrance + // fires when the next cover decodes and a cover that lands a poll late still gets one. Box( modifier = discModifier.size(62.dp), contentAlignment = Alignment.Center ) { - // Ambient halo tinted by the cover's dominant colour (accent cyan - // with no cover), so the player picks up the album's mood without - // repainting the island itself. + // Ambient halo tinted by the cover's dominant colour (accent cyan with no cover). Box( modifier = Modifier .fillMaxSize() @@ -299,16 +282,8 @@ private fun MusicContent( AnimatedContent( targetState = session.artwork, transitionSpec = { - // A record-swap conveyor: both covers travel LEFT, so the - // eye reads one continuous motion — the old disc glides - // out through the notch's left edge (the Row above clips - // at the island bounds) while the new one slides in from - // beyond it and settles with a soft spring. Travel is 3x - // the disc width so the exit/enter are unmistakable, the - // exit fades only in its last third (the cover stays - // readable while it travels), and everything animates - // offset/alpha/scale — all graphicsLayer-backed, so the - // SOFTWARE renderer never relayouts mid-swap. + // Both covers travel left, the old one out through the clipped island edge and + // the new in from beyond it; offset/alpha/scale only, so SOFTWARE never relayouts. (slideInHorizontally( initialOffsetX = { -it * 3 }, animationSpec = spring( @@ -335,9 +310,8 @@ private fun MusicContent( art = art, playing = playing, size = 56.dp, - // null (not a no-op lambda) while the page is inactive: the - // clickable modifier only exists when onClick != null, so an - // inactive page contributes no hit-testable node at all. + // null while inactive: the clickable modifier only exists when onClick != null, + // so an inactive page contributes no hit node. onClick = if (interactive) { { AppLauncher.openMediaProvider(session.sourceId, session.source) } } else { @@ -345,9 +319,7 @@ private fun MusicContent( } ) } - // Live-status dot riding the disc's lower edge: accent while the - // record turns, dimmed white on pause. Zero layout cost — it - // overlaps the halo instead of claiming a row of its own. + // Live dot overlapping the halo (no layout cost): accent while the record turns, dimmed on pause. Box( modifier = Modifier .align(Alignment.BottomEnd) @@ -361,9 +333,8 @@ private fun MusicContent( ) } - // Title block floats in the space above the progress strip, and the - // strip hugs the bottom edge — mirroring the transport cluster's own - // bottom bias so both flanks settle at the same baseline. + // Title block floats above the progress strip, which hugs the bottom edge to share the + // transport cluster's baseline. Column( modifier = Modifier .weight(1f) @@ -427,12 +398,8 @@ private fun MusicContent( Spacer(modifier = Modifier.height(6.dp)) } - // Level indicators sit on top of the media controls, with the whole - // cluster riding near the bottom edge. Both rings use the exact same - // shapes as the quickview's, so volume and battery read - // identically everywhere — the collapsed-only toggles govern just the - // pill. Fixed stack is 30 + 4 + 36 + 4 = 74dp, matching the previous - // budget so the minimum island depth still fits. + // Both rings reuse the quickview's shapes so volume and battery read the same everywhere; + // the fixed stack of 30 + 4 + 36 + 4 = 74dp keeps the minimum island depth fitting. Column( modifier = Modifier.fillMaxHeight(), horizontalAlignment = Alignment.CenterHorizontally @@ -443,11 +410,8 @@ private fun MusicContent( horizontalArrangement = Arrangement.spacedBy(10.dp) ) { if (volumePercent != null) { - // The expanded volume ring doubles as the mute toggle: one - // click flips the OS device mute, and the ring empties to - // level 0 (with a dimmed glyph) while muted so the state - // reads at a glance. The sharedElement modifier rides the - // wrapping Box, so the morph bounds stay the ring's 30dp. + // The expanded ring doubles as the mute toggle: one click flips the OS mute and + // the ring empties to level 0 with a dimmed glyph. val mutedNow = volumeMuted == true val muteLabel = stringResource( if (mutedNow) Res.string.music_unmute else Res.string.music_mute @@ -503,11 +467,9 @@ private fun MusicContent( } } -// ── Dock (fisheye app launcher) ────────────────────────────────────────────── /** - * Warms the shared icon cache for the pinned list so the dock never shows a - * wall of shimmering placeholders after a cold start. + * Warms the shared icon cache so the dock never shows a wall of shimmering placeholders. */ @Composable internal fun LoadFavoriteIcons( @@ -562,9 +524,8 @@ private fun Dock( } } - // The dock lives in the tree even while hidden, so pointer tracking and icon - // clicks must be gated explicitly: an invisible dock must not swallow events - // meant for the quickview or launch apps from under the cursor. + // The dock stays in the tree while hidden, so pointer tracking and clicks must be gated: + // an invisible dock must not swallow events meant for the quickview. val pointerModifier = if (interactive) { Modifier .onPointerEvent(PointerEventType.Move) { change -> @@ -595,10 +556,8 @@ private fun Dock( } } - // Centred along the depth axis instead of pinned to the anchored edge: - // that removes the dead strip that used to pile up under the icons - // (labels off) or above the label (labels on), and keeps the label's - // descenders clear of the window border. + // Centred on the depth axis so no dead strip piles up under the icons or above the label, + // and the label's descenders stay clear of the window border. Column( modifier = Modifier.fillMaxSize().padding(dockPadding()), horizontalAlignment = Alignment.CenterHorizontally, @@ -614,8 +573,7 @@ private fun Dock( } /** - * Insets the dock away from the top screen edge so icons never touch the - * display border, and leaves extra room on the inner edge for the + * Insets the dock away from the top screen edge, with extra room on the inner edge for the * magnification lift. */ private fun dockPadding(): PaddingValues = @@ -635,10 +593,8 @@ private fun DockIcon( onPositioned: (Float) -> Unit, onClick: () -> Unit ) { - // Reading the pointer here (not in the dock body) keeps recomposition local - // to each icon as the cursor glides across the dock. derivedStateOf filters - // out repeated frames whose computed scale is unchanged (icons outside the - // fisheye radius publish 1f every tick, so they never invalidate). + // Read the pointer here, not in the dock body, so recomposition stays local to each icon; + // derivedStateOf drops frames whose scale is unchanged (icons outside the radius). val targetScale by remember(enabled, center, radiusPx, peakScale) { derivedStateOf { if (enabled) fisheyeScale(pointer.value, center, radiusPx, peakScale) else 1f @@ -664,11 +620,8 @@ private fun DockIcon( } .clip(RoundedCornerShape(11.dp)) .then( - // Only install the clickable while the dock page is - // interactive: `clickable(enabled = false)` still registers a - // hit-testable node, so a disabled icon would keep occupying - // the hit path of the page stacked beneath it. Dropping the - // modifier leaves an inactive page with no hit nodes. + // Only install the clickable while interactive: clickable(enabled = false) still + // hit-tests, so a disabled icon would occupy the hit path of the page beneath. if (enabled) { Modifier.clickable( interactionSource = interaction, @@ -711,9 +664,8 @@ private fun DockIcon( private fun DockLabel(nameState: State, config: NotchConfig) { val labelSize = (config.expandedNotchHeightDp * DockLabelFontSizeFactor).sp val lineHeight = (labelSize.value * 1.15f).sp - // The slot is always as tall as one line even with no name showing, so the - // icons never shift when the label appears; the snug line height plus the - // vertical padding guarantee descenders clear the window border. + // The slot is always one line tall so icons never shift when the label appears; the snug + // line height keeps descenders clear of the border. val slotHeight = with(LocalDensity.current) { lineHeight.toDp() } Box( modifier = Modifier diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/FloatingNotchOverlay.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/FloatingNotchOverlay.kt index 29e79e8..961c705 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/FloatingNotchOverlay.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/FloatingNotchOverlay.kt @@ -83,27 +83,8 @@ import kotlinx.coroutines.withTimeoutOrNull import java.awt.Point import kotlin.math.roundToInt -/** - * THE overlay: one composable that hosts the island, the quickview pill, the - * widget pages (music + pinned apps) and the collapse/expand choreography. - * - * The pieces live in focused sibling files — [QuickView] (collapsed pill), - * the widget pages/dock (`DockContent.kt`), the media widgets - * (`MediaWidgets.kt`), the shared-element morph (`NowPlayingMorph.kt`), the - * hover machinery (`OverlayHover.kt`) and the motion constants - * (`NotchMotion.kt`). This file only owns state and choreography. - * - * The native window keeps one fixed (expanded) footprint for its whole life - * (see `NotchOverlayWindow` in main.kt), so opening/collapsing never resizes a - * transparent window — expanding is purely drawn growth inside it (pitfall 17). - */ -/** - * Collapsed island width: hug the measured wrapping content when a hugging - * branch is showing (falling back to the configured width until the first - * measure lands), the configured width everywhere else. Bounded below against - * degenerate measures and above by the native window, so the island can never - * outgrow its window or collapse into a sliver. - */ +/** Root of the overlay: state and choreography; drawing lives in the sibling files. */ +/** Hugs the measured width, bounded by the fixed native window. */ private fun hugCollapsedWidth( hugVisible: Boolean, hugWidth: Dp?, @@ -134,50 +115,36 @@ internal fun FloatingNotchOverlay( /** Shared widget-pager state (also driven by the Windows AWT wheel listener). */ pager: OverlayPager, /** - * Reports the overlay's current touch-target rectangle in window-client - * pixels (the hover box = island + glow), or null until it has been sized. - * Lets the native window keep its transparent margin click-through, so the - * fixed footprint never swallows clicks on the app/desktop around the pill - * (see `WindowPassthrough`). + * Reports the touch target in window-client pixels so the window's transparent + * margin stays click-through; null until measured. */ onTouchTargetChange: (java.awt.Rectangle?) -> Unit = {}, /** - * The host AWT window (undecorated transparent overlay). Used on macOS to - * reconcile the screen-origin drift caused by the above-menu-bar placement. + * Host AWT window; on macOS used to reconcile the above-menu-bar screen-origin drift. */ awtWindow: java.awt.Window? = null ) { val config by repository.config.collectAsState() - // Two reads for the calendar, both rare: a connect/refresh emits a handful - // of times a day and the ticker only changes on an event boundary. Collected - // here because the pill is the only thing that draws them. + // Calendar reads are rare (a handful a day); collected here because the pill is the only drawer. val calendarState by GoogleCalendar.state.collectAsState() val calendarRefreshing by GoogleCalendar.refreshing.collectAsState() val calendarTick by GoogleCalendar.minuteTick.collectAsState() val calendarTicker by GoogleCalendar.ticker.collectAsState() val calendarEventCount = if (config.widgets.showCalendar) calendarUpcomingCount(calendarState, calendarTick) else 0 - // Weather for the quickview chip and the expanded outlook page. Collected - // here because the pill and the pager are the only drawers; the controller - // polls on its own half-hourly loop, so observing never triggers a fetch. + // Weather feeds the chip and the outlook page; the controller polls on its own, so observing never fetches. val weatherState by Weather.state.collectAsState() val weatherFetching by Weather.fetching.collectAsState() val weatherData = (weatherState as? WeatherState.Ready)?.data - // The page exists while a forecast is showing or arriving — never as an - // empty stop: a failed feed with no cache stays off the wheel entirely, - // the same rule the calendar page follows. + // A failed feed with no cache stays off the wheel, the same rule the calendar page follows. val weatherReady = config.widgets.showWeather && (weatherData != null || weatherFetching || weatherState is WeatherState.Loading) - // The System page joins the wheel only once there is a reading to draw: a - // widget that is switched off (or whose very first probe failed) must never - // become a dead stop between the dock and the outlook. + // The System page joins the wheel only once there is a reading, never as a dead stop. val systemState by SystemMetrics.state.collectAsState() val systemReady = config.widgets.showSystem && systemState != null - // Gaming: the detector publishes the fullscreen app it found (or null) and - // the HUD takes the pill's place while it lasts. FPS comes from a separate - // probe because a PresentMon capture takes seconds — it must never delay a - // system sample. + // The HUD takes the pill's place while a fullscreen game runs; FPS has its own probe + // so a PresentMon capture never delays a system sample. val game by GameDetector.game.collectAsState() val fps by FpsProbe.fps.collectAsState() // Windows-only, matching the HUD's switches (hidden elsewhere). @@ -186,20 +153,8 @@ internal fun FloatingNotchOverlay( val scope = rememberCoroutineScope() val density = LocalDensity.current - // AWT's `locationOnScreen` goes stale as soon as the macOS window policy - // parks the island above the menu bar with direct Cocoa moves (AWT's y=0 - // sits at the bottom of the menu bar and never learns about the real frame). - // Compose's `localToScreen` inherits that stale number, so screen-space - // collapse gating would find the island ~30pt below where it actually is. - // Track the live drift off-frame and shift the hover box by it; null = - // no drift known (non-mac, bridge missing) → gate on AWT's coordinates. - // - // The native read must NOT run on this composable's dispatcher: it - // dispatches synchronously onto AppKit's main queue, and a synchronous - // cross-queue hop from the UI thread is exactly the EDT↔AppKit ping-pong - // that can deadlock both when AWT is mid-upcall — the app-wide beachball. - // Polling on Dispatchers.IO keeps the hop on a worker while the resulting - // state write re-enters composition thread-safely. + // AWT screen coordinates go stale once macOS parks the island above the menu bar, so track the drift. + // The native read must stay off this dispatcher: a synchronous EDT to AppKit hop can deadlock both. var screenOriginDrift by remember { mutableStateOf(null) } LaunchedEffect(awtWindow) { if (awtWindow != null && HostPlatform.isMac) { @@ -218,14 +173,11 @@ internal fun FloatingNotchOverlay( val liveSession by media.session.collectAsState() val musicLive = isMusicLive(config, liveSession) - // Unread notification count for the quickview's bell + badge; null (the OS - // store unreadable, e.g. without Full Disk Access) hides the indicator. + // Unread notification count; null (store unreadable, e.g. no Full Disk Access) hides the bell. val notificationCount by NotificationCount.count.collectAsState() - // The System Accent surface tints itself to the desktop accent. While that - // surface is chosen, re-read the OS accent every few seconds so changing - // the macOS/Windows accent recolours the notch in place (deduped by the - // StateFlow, so only an actual colour change triggers a rebuild). + // Re-read the OS accent while the System Accent surface is chosen; the flow dedupes, + // so only a real colour change rebuilds. val accent by SystemAccent.accent.collectAsState() LaunchedEffect(config.surface) { if (config.surface != NotchSurface.SYSTEM_ACCENT) return@LaunchedEffect @@ -237,12 +189,8 @@ internal fun FloatingNotchOverlay( var showDock by pager.dockVisible - // Re-probe connectivity when the dock OPENS, so the battery chip never shows - // a stale read. Deliberately not keyed on musicLive: on macOS a probe is a - // `system_profiler` launch (~150ms of system-wide CPU) and firing one on - // every track start was a visible source of machine-wide stutter. The 10s - // poll plus the event-driven connect/disconnect bridge already keep the ring - // correct without it (pitfall 31). + // Re-probe on open so the battery chip is never stale. Not keyed on musicLive: + // a macOS probe launches system_profiler (~150ms) and stuttered on every track start. LaunchedEffect(isExpanded) { if (isExpanded) { AudioBattery.poke() @@ -251,46 +199,28 @@ internal fun FloatingNotchOverlay( } } - // Collapse is debounced so a cursor briefly clipping the edge does not - // immediately retract the dock. + // Collapse is debounced so a cursor briefly clipping the edge does not retract the dock. var collapseJob by remember { mutableStateOf(null) } - // Screen-anchored footprint of the hover zone (island + glow), kept alive by - // the hover Box below. It only fires hover Enter/Exit; the native gate and - // the collapse hysteresis both key off the tighter island footprint, so the - // outer glow band stays click-through for whatever sits underneath. + // Hover zone (island + glow): fires Enter/Exit only; the native gate and the collapse + // hysteresis use the tighter island footprint. var hoverCoordinates by remember { mutableStateOf(null) } - // The DRAWN island's footprint. This — not the wider hover box — is the - // native touch target: the fixed (expanded) window keeps a transparent - // margin around the collapsed pill for its whole life (pitfall 17: resizing - // a transparent window on macOS flashes), and the margin must stay - // click-through right up to the pill's edge so it never blocks the UI - // underneath (taskbar/menu-bar items sitting next to the notch). - // - // Published straight from `onGloballyPositioned` on the island itself — - // NOT from an effect keyed on the state: the coordinates object is a - // stable instance across layout passes, so a keyed effect would run - // exactly once and freeze the touch target at whatever the island's first - // size was (the collapsed pill) — leaving everything outside it - // click-through once the notch expanded. The callback itself fires on - // every position/size change, which is precisely when the target (and the - // Windows blur capture's island rect below) must be republished. + // The drawn island, not the wider hover box, is the native touch target: the window's + // margin must stay click-through right up to the pill's edge. + // Published inline from onGloballyPositioned: the coordinates instance is stable, so a + // keyed effect would run once and freeze the target at the collapsed size. var islandCoordinates by remember { mutableStateOf(null) } - // Installed before the touch-target publish below so that one - // `onGloballyPositioned` feed can drive both the native click-through - // region and (on Windows) the backdrop-blur capture daemon. + // Installed before the publish below so one onGloballyPositioned feed drives both the + // click-through region and the Windows blur capture. val windowsBlur = remember(awtWindow) { WindowsBackdropBlur.install(awtWindow) } val macBlur = remember(awtWindow) { MacContentBlur(awtWindow) } DisposableEffect(awtWindow) { onDispose { windowsBlur?.close() } } val calendarConnected = calendarState is CalendarState.Connected - // The page rides the wheel only while the widget is switched on *and* a - // connection backs it — the same rule the pill's calendar indicator and - // the native sizer follow (a toggled-off calendar must neither draw nor - // reserve a page). + // The page rides the wheel only while the widget is on and a connection backs it. val calendarReady = config.widgets.showCalendar && calendarConnected val pages = remember( config.widgets.pinnedAppsEnabled, @@ -302,12 +232,10 @@ internal fun FloatingNotchOverlay( buildList { if (musicLive) add(WidgetPage.Music) if (config.widgets.pinnedAppsEnabled) add(WidgetPage.Apps) - // System sits between the dock and the outlook: it is a "glance at - // the machine" page, not part of the day (weather/calendar) pair. + // System sits between the dock and the outlook: a glance at the machine, not part of the day pair. if (systemReady) add(WidgetPage.System) if (weatherReady) add(WidgetPage.Weather) - // Last so the wheel reaches it after the dock, and only when there - // is a connection behind it -- an empty page would be a dead stop. + // Only with a connection behind it: an empty page would be a dead stop. if (calendarReady) add(WidgetPage.Calendar) } } @@ -315,12 +243,7 @@ internal fun FloatingNotchOverlay( fun setHovered(hovered: Boolean) { collapseJob?.cancel() if (hovered) { - // A ticker on the pill is a notification, not a status: expanding - // the notch over it consumes the notification -- it will not come - // back for this event after the next collapse -- and the expansion - // lands directly on the calendar page the user was just teased - // with. Collapsing snaps the pager back to page 0 as usual, so the - // next expansion opens wherever the notch always opens. + // Expanding over a ticker consumes the notification and lands on the calendar page. if (calendarTicker != null) { GoogleCalendar.dismissTicker() pages.indexOf(WidgetPage.Calendar).takeIf { it >= 0 }?.let { index -> @@ -330,36 +253,14 @@ internal fun FloatingNotchOverlay( onExpandedChange(true) } else { collapseJob = scope.launch { - // Debounce the release, then watch the *real* cursor until it - // has truly left the notch. Transparent overlay windows re-state - // their hover region while resizing under a stationary cursor, - // so an Exit event alone is not trustworthy while content keeps - // changing under the pointer (e.g. a playing track ticking its - // progress) — collapsing straight away makes the notch flap - // open/closed. The gate below verifies the true pointer position - // and only ever suppresses a collapse, never forces an expand. - // - // It must *re-poll* rather than decide once: a single suppressed - // check is enough to wedge the notch open. During a fast expand - // the native window resizes and the hover footprint node can be - // transiently null or detached at the moment the check runs, so - // the first gate reprieve is often a stale one — and with a - // one-shot check nothing ever re-evaluates it (the pointer is - // already long gone, so no new Exit arrives). Keep watching until - // the cursor is confirmed out. + // An Exit is untrustworthy while content changes under a stationary cursor, so verify + // the real pointer, and re-poll: a one-shot check of a detached footprint wedges it open. while (true) { delay(config.collapseDelayMs) - // The gate uses the DRAWN island's footprint — the same - // rectangle the native click-through keeps interactive — so - // the collapse hysteresis can never disagree with the - // native gate (a cursor parked in the click-through glow - // band used to hold the notch open while the window there - // was already ignoring the mouse). + // Gate on the drawn island, the same rect the native click-through keeps interactive. val coords = islandCoordinates when { - // Unknown or still-detached footprint (window mid-resize): - // keep the notch open without flapping, but keep polling — - // this path no longer wedges. + // Footprint unknown or detached mid-resize: keep polling rather than wedge. coords == null || !coords.isAttached -> continue // Cursor confirmed out of the footprint: retract. !cursorOverNotch(coords, screenOriginDrift) -> { @@ -372,11 +273,8 @@ internal fun FloatingNotchOverlay( } } - // Expanded content is a set of always-composed, always-expanded widget - // pages; the wheel moves between them. Page switching is owned by a shared - // [OverlayPager]: the overlay renders - // from it, and (on Windows) an AWT-level wheel listener in `Main` drives it - // from outside the Compose pointer pipeline. One wheel notch = one page. + // Pages are always composed; the wheel moves between them and the pager is shared + // with the Windows AWT listener. One wheel notch = one page. LaunchedEffect(pages.size) { pager.pageCount = pages.size if (pager.pageIndex.value >= pages.size) { @@ -386,19 +284,8 @@ internal fun FloatingNotchOverlay( } val pagePosition = animateFloatAsState(pager.pageIndex.value.toFloat(), pageSpring(), label = "pagePosition") - // Wheel events reach the Compose pointer pipeline on macOS (trackpad and - // mouse alike), and the dock box fills the expanded window, so this one - // handler covers the whole notch there. Windows receives no scroll through - // that pipeline on its unfocused overlay window — AWT does — so the Compose - // handler is skipped on Windows to avoid every notch being counted twice. - // - // Compose reports scroll in `preciseWheelRotation` units (one wheel notch = - // 1.0 rotation), not pixels, and the pager accumulator is in the same pixel - // space the Windows AWT listener feeds it with rotation × stepPx. So scale - // the same way here: without it, a whole trackpad swipe lands far below the - // step threshold and several swipes are needed for one page flip. Sum both - // axes because Shift+scroll on macOS is reported on the x axis, which keeps - // this path insensitive to Shift exactly like the Windows listener is. + // macOS gets scroll through Compose, Windows only through AWT, so this handler is skipped there. + // Compose reports rotations, not pixels: scale and sum both axes, Shift+scroll lands on x. val composeScrollModifier = if (HostPlatform.isWindows) Modifier else Modifier.onPointerEvent(PointerEventType.Scroll) { event -> @@ -408,12 +295,8 @@ internal fun FloatingNotchOverlay( pager.consumeWheel(rotations * pager.stepPx) } - // Indicators shown beside the idle clock also widen the quickview (one - // indicator slot each), so date/time + device + laptop battery + the - // notification bell coexist instead of clipping. Derived through the same - // helper the native window sizer in main.kt uses, so both ever agree on - // the footprint. Gated to the idle branches: the drag-handle style renders - // no indicators and a live media pill grows to its own fixed width. + // Indicators widen the pill one slot each; derived through the helper the native sizer uses + // so both agree, and gated to the idle branches. val indicatorSlots = quickViewIndicatorSlots( config, musicLive, battery, deviceBattery, notificationCount, calendarEventCount, weatherVisible = weatherData != null @@ -440,61 +323,33 @@ internal fun FloatingNotchOverlay( gameHudVisible ) } - // Cached per surface choice *and* per accent value: the brushes are built - // once and the accented style is rebuilt only when the OS accent changes, - // so nothing here allocates while the island animates. - // (windowsBlur / macBlur are installed near the touch-target publish - // above — the Windows capture daemon tracks the island rect from the same - // feed.) - // - // The Blurred surface means real backdrop blur wherever the bridge - // answers — a compositor panel below the window on macOS, a captured + - // GPU/CPU-blurred strip drawn inside the window on Windows — falling back - // to painted glass where it does not. Only the scrim differs between the - // two, so it rides the same remember key as the surface. - - // The dock reveal is a layer animation (fade + scale) over the always - // expanded-size dock. Driving it through [MutableTransitionState] rather - // than raw alpha/scale floats lets the same visibility transition feed the - // shared-element morph (the disc/title fly between pill and detail) and the - // island's "hold expanded until the dock is done" gate below. + // Blurred means real backdrop blur where the bridge answers (macOS panel, Windows captured + // frost) and painted glass where it does not; only the scrim differs between the two. + + // The dock reveal rides MutableTransitionState so the same visibility transition feeds + // the shared-element morph and the hold-expanded gate below. val dockState = remember { MutableTransitionState(false) } dockState.targetState = showDock - // The island keeps the dock as its *sibling*, so the fading dock is not - // clipped by the shrinking pill; the pill holds its expanded size until the - // dock fade is done, then contracts. Everything sits top-centred inside the - // fixed (expanded) native window, so the collapsed pill nests into the - // window's top edge and needs no gutter. + // The dock is the island's sibling, so the fading dock is not clipped by the shrinking pill. val islandExpanded = isExpanded || dockState.currentState || dockState.targetState - // "Transparent in games" applies to the collapsed gaming HUD only: the - // expanded dock keeps whatever material the user picked, so opening it over - // a game still reads as a panel rather than floating numbers. Deliberately - // not a NotchSurface entry — the three user-picked materials keep their - // meaning everywhere else. + // Applies to the collapsed HUD only; the expanded dock keeps the picked material. Not a + // NotchSurface entry, so the three materials keep their meaning everywhere else. val gameTransparent = gameHudVisible && config.transparentInGame && !islandExpanded val blurNative = windowsBlur != null || macBlur.available - // No native blur behind a transparent island: the panel/frost would paint - // the body we just removed. Both carriers are switched off, not merely - // hidden, so the macOS helper panel stops compositing too. + // Both carriers are switched off, not hidden, or the panel/frost would paint back the body. val blurActive = !gameTransparent && config.surface == NotchSurface.TRANSLUCENT && blurNative val blurIntensity = config.blurRadius.coerceIn(BlurRadiusMin, BlurRadiusMax) - // Windows paints its frost INSIDE the window (the capture leaf below), so - // the painted body must get out of the way while frost is live; macOS - // keeps the painted scrim over the native panel. Same rule, two carriers. + // Windows paints its frost inside the window, so the painted body must get out of the way; + // macOS keeps the painted scrim over the native panel. val frostActive = blurActive && windowsBlur != null val surface = remember(config.surface, accent, blurActive, blurIntensity, gameTransparent) { IslandSurface.styleFor(config.surface, accent, blurActive, blurIntensity, gameTransparent) } - // Content-hugging collapsed pill: the wrapping branches (idle clock, idle - // dots) report their measured width, and the island shrinks to exactly - // that — the row already carries its own 2dp side insets, so the pill ends - // up with ~4px of black on each end and no slider dead air. Fill branches - // (live media, ticker, gaming HUD) keep the configured width; the native - // window never moves either way, so hugging is pure drawn growth like every - // expansion. + // Wrapping branches report their measured width and the island shrinks to exactly that; + // fill branches keep the configured width. Hugging is drawn growth, the window never moves. var hugWidth by remember { mutableStateOf(null) } val hugVisible = !musicLive && calendarTicker?.summary == null && !gameHudVisible && config.style != NotchStyle.DRAG_HANDLE @@ -507,28 +362,14 @@ internal fun FloatingNotchOverlay( ) val targetSize = if (islandExpanded) metrics.islandFor(true) else formulaCollapsed.copy(width = collapsedWidth) - // Kept as State (not `by`): every composition-phase read of these values - // recomposes this whole overlay scope on each spring frame, and the - // measured cost of that during open/close was the app's dominant jank - // source. The animated values are read only in layout/snapshot phases - // (`animatedSize` modifier, snapshotFlow below), so a spring tick now - // re-measures the island instead of re-running this body. + // Kept as State (not `by`): a composition-phase read recomposes this whole overlay on every + // spring frame, the dominant jank source. These are read only in layout/snapshot phases. val animatedWidth = animateDpAsState(targetSize.width, notchSizeSpring(), label = "notchWidth") val animatedHeight = animateDpAsState(targetSize.height, notchSizeSpring(), label = "notchHeight") val shape = notchShape(islandExpanded, config.style) - // Publishes the island's bounds in window-client pixels for the native - // click-through (see WindowPassthrough); null (not measured yet) means - // "whole window interactive", the fail-soft default. Lives here — below - // the shape inputs — because the same feed also drives the Windows - // backdrop-blur capture: the daemon strips the screen behind exactly this - // rect every frame, so the frost tracks the pill's stadium and the dock's - // rounded box through the same animated layout. - // - // `localToWindow` already returns PHYSICAL pixels (Compose px = dp × - // density), so no further scaling happens here — multiplying again used to - // double-scale the touch target on every display above 100%, turning the - // transparent margin into an invisible click-swallowing band. + // Publishes the island rect in window-client pixels for click-through; null means the whole + // window stays interactive. localToWindow is already physical px: scaling again double-scaled it. fun publishTouchTarget(coords: LayoutCoordinates?) { if (coords == null || !coords.isAttached) { onTouchTargetChange(null) @@ -547,39 +388,22 @@ internal fun FloatingNotchOverlay( windowsBlur?.updateIsland(rect) } - // Playback progress straddles the island's own outline — half over the - // island, half over the transparent margin — filling the notch's border - // with the playhead instead of reading as a bar. Shown while collapsed - // (the expanded page carries its own seekable progress), beside the - // visualiser or without it. Hoisted here because the outline's outer half - // needs a transparent margin around the island (see the padded wrapper - // below), and that margin must exist before the island is laid out. + // The progress outline straddles the island edge, so its outer half needs a margin that exists + // before the island is laid out. val liveNow = if (musicLive) liveSession else null - // Outline progress needs a live track, a collapsed pill and its own - // toggle on. It rides the island's edge, so it coexists with the - // visualiser (bars inside, ring outside) — the two toggles are - // independent. The gaming HUD replaces the pill's content, so the - // playback ring has nothing to belong to while it shows. + // Needs a live track, a collapsed pill and its toggle; the HUD leaves the ring nothing to belong to. val showOutline = !isExpanded && liveNow != null && config.widgets.showProgressBorder && !gameHudVisible val outlinePad = if (showOutline) OutlinePad else 0.dp - // Mirrors notchShape(): capsule rounds every corner, the flush style only - // the two facing the screen. + // Mirrors notchShape(): capsule rounds every corner, the flush style only the two facing the screen. val outlineRadius = notchCornerRadius(islandExpanded, config.style) - // The native blur follows the surface, not a toggle: picking Blurred - // enables it, any other surface clears it. The mac panel tracks the island - // while it animates; Windows runs its capture daemon against the same - // island feed (the frost leaf below draws what it strips). Both fail soft - // to the painted surfaces. + // Native blur follows the surface, not a toggle; both carriers fail soft to the painted surfaces. LaunchedEffect(blurActive, blurIntensity, islandExpanded, config.style) { windowsBlur?.setBlur(blurActive, blurIntensity) if (blurActive) { - // Per-frame geometry without per-frame composition: the effect's - // keys stay state-stable, and a snapshotFlow inside relays each - // spring tick to the helper. Keying the effect on the animated - // sizes (the old shape) restarted the coroutine — and recomposed - // the whole overlay — sixty times a second mid-animation. + // Per-frame geometry without per-frame composition: a snapshotFlow relays each spring tick. + // Keying the effect on the animated sizes restarted it and recomposed the overlay at 60 Hz. snapshotFlow { DpSize(animatedWidth.value, animatedHeight.value) } .collect { size -> macBlur.apply(true, size, islandExpanded, config.style, blurIntensity) @@ -589,14 +413,8 @@ internal fun FloatingNotchOverlay( } } - // Open: hold the dock hidden until the size spring has actually settled, - // then fade it in. Close: fade the dock out and let the island spring back. - // Waiting on the spring itself (with a timeout) rather than fixed delays - // guarantees the dock never appears mid-measure and the notch never gets - // stuck open-but-empty. The native window stays at its one fixed footprint - // the entire time (see NotchOverlayWindow) — "expanding" is purely drawn - // growth inside that fixed window, so the overlay's native surface is never - // resized and there is no resize flash on any platform. + // Hold the dock hidden until the size spring settles, then fade it in, so it never appears + // mid-measure. The native window keeps its footprint, so there is no resize flash. LaunchedEffect(isExpanded) { if (isExpanded) { val island = metrics.islandFor(true) @@ -608,11 +426,8 @@ internal fun FloatingNotchOverlay( } if (isExpanded) showDock = true } else { - // Snap the pager back to its leading page before the dock fades so - // the next expansion re-opens the live activity (music while a - // track is playing, otherwise the apps dock) instead of wherever - // the wheel was left — and so the shared-element morph reads the - // now-playing page's real bounds as the pill returns. + // Snap the pager to its leading page before the dock fades, so the next expansion + // re-opens the live activity and the morph reads the now-playing page's bounds. pager.pageIndex.value = 0 showDock = false withTimeoutOrNull(IslandSettleTimeoutMs) { @@ -625,26 +440,14 @@ internal fun FloatingNotchOverlay( } SharedTransitionLayout(modifier = modifier.fillMaxSize()) { - // Shared-element scope for the now-playing morph: the quickview and - // the detailed page render matching disc/title keys beneath it, so - // Compose can fly each element between the two surfaces. + // Shared-element scope: both surfaces render matching disc/title keys beneath it. val sharedScope = this Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter ) { - // Island background + quickview: the only thing that actually - // resizes. The dock is a sibling, not a child, so its layout is - // stable. - // - // The outer wrapper exists for the progress outline below: the - // ring straddles the island's edge (half over the island, half - // over the desktop, like the macOS border), and its outer half - // needs a transparent margin to paint into — the island itself is - // clipped to its shape, so anything outside would be cut. The pad - // is zero whenever the outline is off, so every other mode lays - // out exactly as before; the touch target still feeds from the - // inner island's coordinates, never the margin. + // Only the island resizes; the outer wrapper reserves the progress outline's margin, + // which the island clip would cut. The pad is zero whenever the outline is off. Box( modifier = Modifier.animatedIslandSize(animatedWidth, animatedHeight) { w, h -> DpSize(w + outlinePad * 2, h + outlinePad) @@ -659,11 +462,7 @@ internal fun FloatingNotchOverlay( publishTouchTarget(coords) } .clip(shape) - // Windows frost replaces the painted body: the captured - // backdrop is drawn by the leaf below and the scrim rides - // above it inside the same leaf, so the modifier-chain - // background would only double-tint. macOS and painted - // fallbacks keep the chain background exactly as before. + // Windows frost replaces the painted body, so the chain background would double-tint. .then( if (frostActive) { Modifier @@ -680,11 +479,8 @@ internal fun FloatingNotchOverlay( .border(surface.borderWidth.dp, surface.border, shape), contentAlignment = Alignment.Center ) { - // Windows only (frostActive ⇒ windowsBlur != null): the - // captured backdrop, already inpainted + Gaussian-blurred on - // the daemon thread and cropped to this island's rect, drawn - // 1:1 under the legibility scrim. Its own restart scope, so a - // 30 fps capture tick re-draws this leaf and nothing else. + // The captured strip, already inpainted and blurred on the daemon thread, drawn 1:1 + // under the scrim. Its own restart scope, so a 30 fps tick redraws this leaf only. val frostBlur = if (frostActive) windowsBlur else null if (frostBlur != null) { WindowsFrost( @@ -693,23 +489,15 @@ internal fun FloatingNotchOverlay( modifier = Modifier.matchParentSize() ) } - // Keep the quickview on screen until the dock is actually - // visible, so the two surfaces cross at the same instant. Live - // music does this so Compose can morph the shared disc/title - // between them (the pill retracts with a plain fade); the idle - // pill crosses too, but as a "curtain": it slides DOWN the - // growing island while the pinned apps scale up from behind it, - // which reads as the pill being pushed past the dock coming to - // the front. + // Keep the quickview up until the dock is visible so the two surfaces cross at once; + // idle slides down like a curtain while the dock scales up from behind it. val quickEnter = if (musicLive) fadeIn(tween(120)) else fadeIn(tween(120)) + slideInVertically(QuickViewSlideIn) { it * 3 } val quickExit = if (musicLive) fadeOut(tween(80)) else fadeOut(QuickViewFadeOut) + slideOutVertically(QuickViewSlideOut) { it * 3 } - // Waveform tint: the cover's dominant colour when asked for - // (derived once per track in the media layer), white otherwise - // and whenever there is no artwork to read. + // Cover's dominant colour when asked for, white otherwise and with no artwork. val waveformColor = if ( config.widgets.waveformTint == WaveformTint.ALBUM_ART ) { @@ -724,10 +512,7 @@ internal fun FloatingNotchOverlay( ) { val activeGame = game if (gameHudVisible && activeGame != null) { - // The HUD takes the pill's whole slot: same island, same - // collapse behaviour, one line of live numbers instead - // of clock/media. Hovering still expands the dock, so it - // installs no pointer target of its own. + // The HUD takes the pill's slot; hovering still expands the dock, so no pointer target. GameHud( game = activeGame, system = systemState, @@ -750,10 +535,7 @@ internal fun FloatingNotchOverlay( showVolume = config.widgets.showCollapsedVolume, showBattery = config.widgets.showCollapsedBattery, showDeviceBattery = config.widgets.showCollapsedDeviceBattery, - // Calendar stays out of the morph: it is a collapsed-only - // element, and the ticker borrows the media branch's slot - // rather than a shared key, so nothing about the now-playing - // morph changes when a meeting starts. + // Collapsed-only: the ticker borrows the media branch's slot, so the morph is unaffected. showCalendar = config.widgets.showCalendar, calendarEvent = calendarTicker?.summary, calendarEventCount = calendarEventCount, @@ -784,21 +566,14 @@ internal fun FloatingNotchOverlay( } } } - // Playback progress straddles the island's own outline — half - // over the island, half over the transparent margin the wrapper - // reserves — filling the notch's border with the playhead instead - // of reading as a bar. A sibling of the clipped island (not a - // child), so the outer half is never cut. The ink is the cover's - // dominant colour (accent cyan with no cover), matching the - // expanded player's tinting. The expanded page carries its own - // seekable progress, so the ring only shows while collapsed. - // Plain overlay, no pointer modifiers. + // A sibling of the clipped island so the outer half survives; ink is the cover tint, + // cyan with no cover. Shown only collapsed, the expanded page has its own seekable progress. if (showOutline && liveNow != null) { NotchProgressOutlineForSession( session = liveNow, accent = liveSession?.artTint ?: LiveAccent, - // Mirrors notchShape(): capsule rounds every corner, - // the flush style only the two facing the screen. + // Mirrors notchShape(): capsule rounds every corner, the flush style only the + // two facing the screen. spec = NotchOutlineSpec( radius = outlineRadius, roundTop = config.style.capsule, @@ -809,17 +584,8 @@ internal fun FloatingNotchOverlay( } } - // The dock is revealed with a fade + scale layer; its visibility - // state doubles as the shared-element transition scope, so the - // now-playing morph runs in lockstep with the reveal and the - // island's "hold expanded" gate above. - // - // Idle expansion uses a pronounced entry: the pinned apps scale up - // from a small size (+ fade) anchored near the top edge, rising from - // behind the quickview that is sliding down past them — the "curtain" - // cross. Live music keeps the gentle 0.97 pop (scale centred), since - // the dock there carries the music page that the pill's content is - // actively morphing into. + // Dock reveal: a fade + scale layer whose visibility doubles as the shared-element scope. + // Idle uses a pronounced curtain entry from the top edge; live music keeps the gentle pop. val dockReveal = dockRevealSpring() val dockEntryScale = if (musicLive) 0.97f else IdleDockEntryScale val dockOrigin = if (musicLive) TransformOrigin.Center else IdleDockOrigin @@ -899,22 +665,9 @@ internal fun FloatingNotchOverlay( } } - // Hover target is the island footprint plus a small glow — never the - // whole native window (the notch is top-centred, so the window and - // the island coincide). With the entire window as the hit target, the - // notch stayed glued open until the cursor had travelled far past the - // visible island; hugging the island means the notch retracts the - // moment the cursor leaves it. - // - // This box must stay the LAST sibling (topmost z-order): when - // siblings overlap, hit testing only considers the highest-z - // pointer-eligible one. On macOS the dock box carries a scroll - // handler, so sitting below it the hover area was shadowed and never - // entered. Sharing - // ([SharingHoverNode.sharePointerInputWithSiblings]) then walks the - // hit test back down to the dock subtree, so the hover tracks while - // the dock keeps its scroll handling and icon clicks. The box draws - // nothing, so being on top has no visual effect. + // Hover target is the island plus a small glow, never the whole window, so the notch + // retracts when the cursor leaves. Must stay the last sibling, sharing pointer input + // with the dock beneath so its scroll handling and icon clicks keep working. val glow = HoverGlow Box( modifier = Modifier @@ -929,19 +682,9 @@ internal fun FloatingNotchOverlay( } /** - * Windows backdrop frost: the captured strip behind the island, already - * inpainted (our own scrim/content replaced from the real band flanking it) - * and Gaussian-blurred on the capture daemon, cropped to this island's rect. - * Drawn 1:1 as the island's bottom layer, then the legibility scrim — the - * same legibility rule as macOS, where the native panel shows through and - * only the scrim is painted. - * - * The frame lives in a [StateFlow] read inside this leaf only: a 30 fps - * capture tick re-draws the island and never recomposes the overlay body. - * Frame null (daemon warming up, surface just flipped, native failure) draws - * nothing, so the island falls through to whatever background the caller left - * in the chain — painted glass on failure, a bare clipped shape for the - * frame-or-two before the first capture lands. + * Windows backdrop frost: the captured strip behind the island, already inpainted and blurred, + * drawn 1:1 under the scrim. The frame is read in this leaf only, so a capture tick redraws the + * island and never recomposes the overlay. */ @Composable private fun WindowsFrost( @@ -959,9 +702,7 @@ private fun WindowsFrost( modifier = Modifier.matchParentSize() ) } - // Scrim above the frost: bright wallpapers would otherwise wash the - // white quickview out. Deliberately light and intensity-driven — - // the frost supplies the texture, this only buys contrast. + // Scrim above the frost: bright wallpapers would otherwise wash the white quickview out. Box( modifier = Modifier .matchParentSize() @@ -971,14 +712,8 @@ private fun WindowsFrost( } /** - * Sizes the node from the given animated states, reading them in the LAYOUT - * phase rather than in composition. The island's springs update every frame - * while the notch opens or closes, and a composition-phase read at the - * overlay root (`.width(animatedWidth)` or as effect keys) recomposed the - * entire island + dock tree sixty times a second — measured as the dominant - * cost of open/close (98 of 235 render-thread samples landed in recompose). - * A State read inside the layout lambda invalidates only this node's - * measure/place, so a spring tick re-measures the island and nothing above. + * Sizes the node from the given animated states, reading them in the LAYOUT phase: a spring tick + * re-measures this node only instead of recomposing the island and dock tree every frame. */ private fun Modifier.animatedIslandSize( width: State, @@ -994,14 +729,12 @@ private fun Modifier.animatedIslandSize( layout(w, h) { child.place(0, 0) } } -// How often the System Accent surface re-reads the OS accent so a desktop -// accent change recolours the notch without a restart. +// How often the System Accent surface re-reads the OS accent. private const val SystemAccentPollMs = 3000L /** - * Transparent margin the island wrapper reserves around the island while the - * progress outline shows, so the ring's outer half has something to paint - * into. One stroke width each side: the ring straddles the edge exactly. + * Transparent margin the wrapper reserves while the progress outline shows, one stroke width + * each side, so the ring straddles the edge exactly. */ private val OutlinePad = 2.dp diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/GameHud.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/GameHud.kt index 9e1c8bf..b21236f 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/GameHud.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/GameHud.kt @@ -54,24 +54,8 @@ private val HudSidePadding = 10.dp private val HudDividerHeight = 14.dp /** - * The gaming quickview: a live performance strip that stands in for the pill - * while a fullscreen game owns the screen. - * - * Deliberately a *readout*, not a control surface — hovering still expands the - * dock, so nothing here needs to be clickable, and the whole strip stays one - * unbroken line of numbers. Cells are ordered by how often a player glances at - * them: frames, then the two loads, then the network. - * - * Missing readings drop their cell rather than showing a dash that never - * resolves: FPS is absent on macOS (no supported API) and on Windows installs - * without PresentMon, GPU is absent where the OS exposes no engine counter. - * That is the same rule the System page follows, and it keeps every number on - * screen true. - * - * [transparent] is the "transparent in games" mode: only the numbers remain. - * The body, the dividers and the island shadow are all dropped, so nothing but - * the metrics is drawn over the game; each glyph carries its own soft black - * shadow for legibility. The solid mode keeps the body and its hairlines. + * The gaming quickview: a read-only strip ordered frames, loads, network. + * A missing reading drops its cell instead of showing a dash. */ @Composable internal fun GameHud( diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/IslandSurface.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/IslandSurface.kt index bf8c8cb..f6df209 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/IslandSurface.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/IslandSurface.kt @@ -11,26 +11,9 @@ import com.starkdroid.quicknotch.datasource.BlurRadiusMin import com.starkdroid.quicknotch.model.NotchSurface /** - * The body material for the notch island. All entries resolve to brushes built - * once and cached, so switching never reallocates and steady-state rendering - * does no shader work beyond one or two flat paints — the island is repainted - * by the software renderer every animation frame, so nothing here may depend - * on size, time or per-pixel texture. - * - * [NotchSurface.TRANSLUCENT] and [NotchSurface.SYSTEM_ACCENT] are deliberately - * texture-free: a mottled or grainy overlay reads as paper, not glass. They - * instead compose two perfectly smooth gradients — a translucent tinted body - * that lets the real desktop bleed through the transparent window, plus a soft - * diagonal light sheen — which is what reads visually as glass. [NotchSurface.SYSTEM_ACCENT] is that same - * glass with the desktop's accent colour blended into the body, rim and a - * faint sheen glint. The accent is passed in by callers (see [SystemAccent]); - * the accented style is rebuilt only when that colour changes, so switching the - * desktop accent recolours the notch in place without a restart. - * - * A literal backdrop blur comes from the OS window, not from Compose: with - * the Blurred surface active, the native bridge blurs the desktop behind the - * island and the island itself draws only the dark scrim below for legibility. - * Where the bridge is missing, Blurred falls back to the painted glass. + * Island materials, brushes cached and size-independent because the + * software renderer repaints every frame. TRANSLUCENT and SYSTEM_ACCENT + * are smooth gradients, never textures; real blur comes from the window. */ internal object IslandSurface { @@ -68,14 +51,8 @@ internal object IslandSurface { ) /** - * Light dark scrim drawn over the natively blurred backdrop when the - * Blurred surface is active and the native bridge answered: the OS - * supplies the frosted texture, this supplies the contrast. Classic DWM - * blur (and the macOS panel) blur without adding light, so over a dark - * desktop the frost alone would read as flat black — the scrim stays - * deliberately light and a brighter-than-glass sheen lays the diagonal - * light play that sells the frost. The slider still drives depth: - * near-clear glass at 0, deep frost at the max. + * Dark scrim over the native blur for contrast, with the sheen that + * sells the frost; the slider drives its depth. */ private fun blurScrimFor(level: Int): Style { val clamped = level.coerceIn(BlurRadiusMin, BlurRadiusMax) @@ -99,11 +76,8 @@ internal object IslandSurface { private const val ScrimAlphaPerStep = 0.006f /** - * Brighter diagonal light play reserved for the blurred surface: over a - * dark desktop the frost alone has no luminosity to read by, and this is - * what keeps it glass instead of flat black. Stronger than [glassSheen] - * on purpose — the painted-glass styles carry their own bright body, the - * live frost does not. + * Brighter sheen for the blurred surface, which has no bright body of + * its own. */ private val blurSheen = Brush.linearGradient( 0f to Color.White.copy(alpha = 0.16f), @@ -120,12 +94,8 @@ internal object IslandSurface { @Volatile private var accentedFor: Color? = null /** - * Gaming-only transparency: nothing but the HUD's own glyphs. No body, no - * rim and no island shadow — the metrics are the whole surface (see - * `GameHud`); the game behind shows through untouched. Deliberately not a - * [NotchSurface] entry: it is never a user-picked material, only the gaming - * quickview's own state, so Solid/Blurred/System accent keep their meaning - * everywhere else. + * Gaming transparency: no body, rim or shadow, only the HUD glyphs. Not + * a NotchSurface entry because it is not a user material. */ private val gameTransparent = Style( fill = SolidColor(Color.Transparent), diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/LevelRing.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/LevelRing.kt index 32afd9a..d134bc1 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/LevelRing.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/LevelRing.kt @@ -65,16 +65,8 @@ private const val RingSweepDegrees = 288f private const val RingSweepAnimationMs = 260 /** - * The single, reusable level indicator used for both volume and battery - * everywhere in the notch: a flat [icon] glyph centred inside a circular - * progress ring that is left 20% open at the bottom. [level] (0–100) maps to - * the full 288° arc. A [lowTint]/[lowThreshold] pair lets callers flag - * low-level states (e.g. an amber, alert-icon battery at ≤20%) without - * forking this component. - * - * Sizing is entirely the caller's job via [modifier]; the ring fills the box - * and the glyph occupies [iconFraction] of its diameter. The sweep settles - * with a short animation so live value changes read smoothly. + * Reusable ring with a glyph inside and a 20% gap at the bottom. + * lowTint/lowThreshold flags a draining battery; sizing is the caller's. */ @Composable internal fun LevelRing( @@ -90,18 +82,13 @@ internal fun LevelRing( lowTint: Color? = null, lowThreshold: Int = BatteryLowThreshold, /** - * High-level alert tint, the mirror of [lowTint]: a load metric (CPU, GPU, - * memory, storage) turns warm once it crosses [highThreshold]. Null keeps - * the ring's normal colour at every level, so battery/volume rings are - * untouched. A low-level warning wins over a high one. + * Load-metric alert tint above highThreshold; null keeps the normal + * colour, and a low warning wins. */ highTint: Color? = null, highThreshold: Int = LoadHighThreshold, /** - * Optional count badge on the ring's top-right corner — how many devices the - * reading covers, when that is more than one (e.g. several Bluetooth devices - * connected while the ring shows one of their levels). Null or ≤ 1 draws - * nothing, so a single device looks exactly as it did before. + * Count badge on the ring; null or <= 1 draws nothing. */ badgeCount: Int? = null ) { @@ -165,20 +152,15 @@ internal fun LevelRing( } /** - * Volume glyph as a painter the level ring can draw — the packaged volume - * drawable, tinted at draw time with the ring's colour. A single glyph for - * every level: the ring's sweep already carries the level, so the icon never - * needs to swap. + * Volume glyph tinted at draw time; one glyph for every level, since the + * ring carries the level. */ @Composable internal fun volumePainter(): Painter = painterResource(Res.drawable.volume) /** - * Connected-device glyph as a painter: the headphone drawable for anything the - * OS classifies as an audio stack, the bluetooth drawable for every other - * device. Both come from packaged compose resources — no icon data lives in - * code. The ring's sweep already carries the level, so the mark only says which - * kind of thing the charge belongs to. + * Headphone glyph for audio stacks, bluetooth otherwise; both come from + * packaged resources. */ @Composable internal fun batteryPainter(isAudio: Boolean): Painter = @@ -186,10 +168,8 @@ internal fun batteryPainter(isAudio: Boolean): Painter = else painterResource(Res.drawable.bluetooth) /** - * Machine (laptop) battery glyph: the charging bolt while the OS is filling the - * battery, otherwise a battery silhouette whose fill tracks the level - * (full ≥ 80%, mid ≥ 3%, a low sliver below 3%). All four come from packaged - * compose resources. + * Charging bolt while charging, otherwise a battery silhouette whose fill + * tracks the level. */ @Composable internal fun systemBatteryPainter(level: Int, charging: Boolean): Painter = diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/MediaWidgets.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/MediaWidgets.kt index 065e411..c07bb92 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/MediaWidgets.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/MediaWidgets.kt @@ -85,19 +85,15 @@ import kotlin.math.floor import kotlin.math.roundToInt import kotlin.math.sin -// ── Palette ────────────────────────────────────────────────────────────────── internal val LiveAccent = Color(0xFF38BDF8) internal val VinylDiscFill = Color(0xFF14161C) internal val VinylRing = Color(0xFF343A45) -// ── Media state helpers ────────────────────────────────────────────────────── /** - * A widget only renders when its feature is enabled and its conditions hold: - * the now-playing page needs a live session and a capsule-friendly style - * (the drag handle is deliberately excluded from every live-activity surface, - * both collapsed and expanded). + * A live-activity surface needs its feature on and a style that is not the drag handle, + * which is excluded from every one of them, collapsed and expanded. */ internal fun isStyleEligibleForLiveActivity(config: NotchConfig): Boolean = config.style != NotchStyle.DRAG_HANDLE @@ -107,7 +103,6 @@ internal fun isMusicLive(config: NotchConfig, session: MediaSession?): Boolean = isStyleEligibleForLiveActivity(config) && session?.isActive == true -// ── Glyph tints ────────────────────────────────────────────────────────────── // Glyph colours brighten slightly over the translucent surfaces so they // stay legible, and stay near-white on the opaque one. @@ -121,15 +116,10 @@ internal fun islandTint(surface: NotchSurface): Color = Color.White.copy(alpha = 0.72f) else Color.White -// ── Vinyl disc ─────────────────────────────────────────────────────────────── /** - * The spinning record the live activity is named after: the track's album art - * clipped to a disc that rotates while playing — frozen at its current angle - * when paused. The rotation accumulates real frame deltas and only runs while - * playing: an `InfiniteTransition` here would keep invalidating the draw phase - * even with a paused track, burning a constant redraw of the whole overlay for - * nothing. Turns are pushed at [PillAnimationFps], not at display rate. + * The album art clipped to a disc that turns while playing and freezes on pause: turns are + * pushed at [PillAnimationFps], since an InfiniteTransition would redraw a paused track forever. */ @Composable internal fun VinylDisc( @@ -145,18 +135,11 @@ internal fun VinylDisc( var angle by remember { mutableFloatStateOf(0f) } val spinning = playing && animate if (spinning) { - // Accumulate frame deltas so pause/resume is seamless: cancelling the - // effect (spinning → false) simply stops the disc where it is, and a - // later resume continues from that exact angle. Real deltas are still - // summed across the skipped frames, so the [PillAnimationFps] write cap - // changes how often the pill repaints, never how fast it turns. + // Accumulate frame deltas so pause/resume is seamless: the effect stops on cancel and + // skipped frames still count, so PillAnimationFps caps writes, never speed. LaunchedEffect(spinning) { - // A `delay` tick, deliberately NOT `withFrameNanos`: awaiting the - // frame clock subscribes the whole scene to display-rate frames, so - // the pill kept taking 60 frames a second however rarely this loop - // wrote (measured: even a 10 writes/s cap still cost ~16% of a core — - // the subscription, not the write, was the cost). Monotonic deltas - // keep the turn rate exact regardless of scheduler jitter. + // A `delay` tick, not `withFrameNanos`: awaiting the frame clock subscribes the whole + // scene to display rate, which cost ~16% of a core even at a 10 writes/s cap. var last = System.nanoTime() while (true) { delay(PillAnimationStepMillis) @@ -216,17 +199,10 @@ internal fun VinylDisc( } } -// ── Marquee title ──────────────────────────────────────────────────────────── /** - * A single-line label that marquees when its text is wider than the available - * space: centred normally, then slowly scrolling right-to-left with a small - * gap before the loop. The rate scales with the overflow so short titles crawl - * and long ones glide. - * - * The scrolling transition exists only while the text actually overflows — - * a fitting title must not keep a 60fps animation alive (and, on the software - * renderer, a full-window redraw loop) just to sit centred. + * Marquees when the text is wider than the space, at a rate scaled to the overflow. + * The transition exists only while it overflows, so a fitting title keeps no loop alive. */ @Composable internal fun MarqueeText( @@ -264,11 +240,8 @@ internal fun MarqueeText( val durationMs = (travelPx * 1000 / 30).coerceIn(2500, 20000) val speedPxPerSecond = travelPx * 1000f / durationMs - // Same clock shape as the vinyl and the waveform: the transition used to run - // at display rate (an InfiniteTransition cannot be throttled), so a long - // title forced a full overlay repaint every frame. Travelling by real frame - // deltas and pushing at [PillAnimationFps] keeps the exact same speed while - // halving the repaints. + // Real frame deltas written at [PillAnimationFps]: the old InfiniteTransition ran at display + // rate, so a long title forced a full overlay repaint every frame. var scrollX by remember(text, travelPx) { mutableFloatStateOf(0f) } LaunchedEffect(needsMarquee, animate, travelPx, speedPxPerSecond) { if (!needsMarquee || !animate) { @@ -302,12 +275,8 @@ internal fun MarqueeText( maxLines = 1, softWrap = false, overflow = TextOverflow.Clip, - // graphicsLayer, not Modifier.offset { }: the offset lambda runs in - // the placement phase, so every tick of the scroll re-laid-out the - // pill's row. A layer translation is a draw-phase change — the same - // pixels, one invalidation instead of a layout pass. The scrollX - // state is likewise read inside the layer lambda, so each 30 Hz - // tick invalidates only the draw of this Text, never composition. + // graphicsLayer, not Modifier.offset { }: placement-phase offsets re-laid-out the row + // every tick. scrollX is read inside the layer lambda, so a tick invalidates only this draw. modifier = Modifier.graphicsLayer { translationX = if (needsMarquee) { scrollX @@ -319,7 +288,6 @@ internal fun MarqueeText( } } -// ── Time + progress ────────────────────────────────────────────────────────── internal fun formatMs(ms: Long): String { val totalSeconds = (ms / 1000L).coerceAtLeast(0L) @@ -347,15 +315,8 @@ internal fun TrackTimeLabel(ms: Long, frameHeight: Dp = 28.dp) { } /** - * A playhead strip: a slim rounded track, a solid fill in the album's colour, - * and — when seeking is available — a round head ringed in the same colour - * that rides the tip of the fill. Dragging or tapping (when the backend - * supports seeking) retargets the OS player; on macOS the strip is - * display-only, matching MediaRemote's limitations. - * - * [accent] is the cover's dominant colour (or the live accent with no cover): - * a plain Color background, so no brush is built per frame while the playhead - * advances. + * Playhead strip: a track, an album-coloured fill and a head when seeking is available; macOS + * stays display-only, matching MediaRemote. The accent is a plain Color, never a per-frame brush. */ @Composable internal fun TrackProgress( @@ -440,7 +401,6 @@ internal fun TrackProgress( } } -// ── Transport ──────────────────────────────────────────────────────────────── @OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) @Composable @@ -449,19 +409,14 @@ internal fun TransportControls( enabled: Boolean, media: MediaController, /** - * Cover's dominant colour (or the live accent with no cover) behind the - * play circle. A plain Color background read straight from the session — - * it changes per track, never per frame, so nothing is rebuilt while the - * playhead advances. + * Cover's dominant colour (or the live accent with no cover); a plain Color that changes + * per track, never per frame. */ accent: Color = LiveAccent ) { val tint = TextPrimary.copy(alpha = if (enabled) 0.9f else 0.3f) - // Optimistic play/pause: the OS player reacts through the media bridge's - // next poll (up to ~2s), and without this the icon sat on the old state - // for that whole window — read as "the button is laggy". The flip happens - // the instant the button is pressed and reconciles when the session state - // changes (or after 3s, if a command was lost). + // Optimistic play/pause: the bridge only reports back on its next poll (up to ~2s), so the + // flip lands on press and reconciles with the session state, or after 3s if the command was lost. var pendingPlay by remember { mutableStateOf(null) } LaunchedEffect(state) { pendingPlay = null } LaunchedEffect(pendingPlay) { @@ -471,20 +426,12 @@ internal fun TransportControls( } } val showPlaying = pendingPlay ?: (state == PlaybackState.PLAYING) - // Material3 inflates every IconButton to a 48dp touch target — on this - // tightly packed 30/36dp row (4dp apart) those targets overlapped each - // other and the neighbouring buttons, so clicks near an edge landed on the - // wrong control. Pin the targets to the visible buttons. Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) ) { - // Plain sized-Box clickables instead of IconButton: Material3's - // IconButton inflates its touch target to 48dp (overflowing its slot - // symmetrically), which on this tightly packed row made the drawn - // buttons and the hit geometry disagree — clicks near an edge landed - // in the neighbour's inflated zone or nowhere at all. With these, the - // hit area is exactly the drawn button. + // Plain sized-Box clickables instead of IconButton: Material3 inflates every IconButton + // to a 48dp target, which on this 4dp-apart row made clicks land on the neighbour. TransportButton( onClick = { media.previous() @@ -509,9 +456,7 @@ internal fun TransportControls( scaleX = playScale scaleY = playScale } - // clip BEFORE clickable: the hit area is exactly the drawn - // circle (clip also clips pointer input), so the whole icon - // plus its background is one clickable surface. + // clip before clickable: the hit area is exactly the drawn circle (clip clips input too). .clip(CircleShape) .background( accent.copy( @@ -561,8 +506,7 @@ internal fun TransportControls( } /** - * One flat transport button: a fixed-size hit target with a centred glyph — - * hit area = drawn area, no Material touch-target inflation. + * One flat transport button: hit area equals drawn area, no Material touch-target inflation. */ @OptIn(ExperimentalComposeUiApi::class) @Composable @@ -591,12 +535,8 @@ private fun TransportButton( scaleY = scale } .then( - // The pointer modifiers exist ONLY while the page is - // interactive. `clickable(enabled = false)` still registers a - // hit-testable node, so an inactive page (alpha 0, composed - // above the music page) would keep intercepting pointer input - // in its slot; dropping the modifiers entirely guarantees an - // inactive page contributes no hit node at all. + // Pointer modifiers exist only while the page is interactive: clickable(enabled = false) + // still hit-tests, so an inactive page would keep intercepting input in its slot. if (enabled) { Modifier .onPointerEvent(PointerEventType.Enter) { hovered = true } @@ -623,7 +563,6 @@ private fun TransportButton( } -// ── Waveform ───────────────────────────────────────────────────────────────── /** Default bar tint: the same brightness the collapsed title uses. */ internal val WaveformWhite = Color(0xEBFFFFFF) @@ -631,9 +570,8 @@ internal val WaveformWhite = Color(0xEBFFFFFF) private const val TAU = 6.2831855f /** - * Fraction of the band every bar keeps once the track is paused: a flat line - * across the middle of the lane, the way a real analyser bottoms out at - * silence. It is the resting state the bars fall to, not a placeholder. + * Fraction of the band every bar keeps when paused: a flat line across the middle, the resting + * state of a real analyser at silence. */ private const val WaveformFlatFraction = 0.12f @@ -641,34 +579,8 @@ private const val WaveformFlatFraction = 0.12f private const val WaveformRampSec = 0.25f /** - * The collapsed pill's faux analyser: side-by-side capsules that each grow and - * shrink around their vertical centre, so every bar breathes from both ends the - * way a real spectrum does. - * - * Deliberately NOT an FFT. Nothing in the media pipeline can hear the audio — - * `MediaRemote` (and SMTC on Windows) publish metadata only, and reading the - * system mix would need a ScreenCaptureKit stream plus a permission grant, so - * this is motion driven by playback state instead. It reads like a visualiser - * and costs nothing the pill was not already paying: - * - * - One coroutine for the whole bar set (not one per bar) and one state write - * per update, read inside the draw scope: the node invalidates its draw phase - * only, exactly like [shimmer] — no recomposition, no extra layer. - * - The clock shares the notch-wide [PillAnimationFps] tick: bars look fluid at - * 30 updates a second, and every update is exactly one overlay repaint. - * - `playing && animate` gates the loop, so a paused track freezes the bars - * (same contract as [VinylDisc], resumed from the frozen phase so it never - * jumps). Nothing freezes while the settings window merely has focus — a - * top-centred notch stays visible above it, so a still pill reads as broken. - * - * Heights come from two fast sines per band plus a shared loudness envelope, - * all parameters hashed deterministically from the band index: the pattern is - * stable across frames (it reads as one coherent wave, not per-bar noise), - * bass-weighted like a real spectrum, and allocates nothing per frame. - * - * [style] picks the bar geometry without touching the motion: [VisualiserStyle.WAVEFORM] - * draws centred capsules, [VisualiserStyle.BARS] anchors sharp rectangles of - * the same pitch to the bottom edge. + * Faux analyser: not an FFT, since the media pipeline carries metadata only, so the motion is + * driven by playback state. One coroutine for the bar set, one state write read in the draw scope. */ @Composable internal fun MusicWaveform( @@ -682,11 +594,8 @@ internal fun MusicWaveform( barPitch: Dp = 6.dp, style: VisualiserStyle = VisualiserStyle.WAVEFORM ) { - // The bar count is resolved in composition (not in the draw scope, and not - // from an onSizeChanged write): the band table and the draw loop must be - // built from the *same* value, or a width change lets the loop index past a - // table that has not been rebuilt yet — an ArrayIndexOutOfBounds that takes - // the whole app with it. + // Resolve the bar count in composition, not from an onSizeChanged write: the band table and + // the draw loop must share one value, or the index runs past a stale table. BoxWithConstraints(modifier) { val count = if (bars > 0) { bars @@ -718,9 +627,8 @@ private fun WaveformCanvas( modifier: Modifier = Modifier ) { var phase by remember { mutableFloatStateOf(0f) } - // 0 = flat line (paused), 1 = full motion. A paused track does not merely - // freeze: the bars fall to an even line, which is what a real level meter - // does when the signal stops and the only honest way to say "no audio". + // 0 = flat line (paused), 1 = full motion. A paused track falls to an even line, which is + // what a real level meter does at silence. var presence by remember { mutableFloatStateOf(0f) } LaunchedEffect(animate, playing) { if (!animate) { @@ -759,11 +667,8 @@ private fun WaveformCanvas( val loud = presence for (index in 0 until count) { val dynamic = (shape.level(index, t) * envelope).coerceIn(0.08f, 1f) - // At loud = 0 every bar collapses onto the same height, which is - // what draws the flat line; at 1 it is the untouched analyser shape. - // The bottom-anchored style settles onto a thin strip at the floor - // instead of a centred line — same pause contract, honest to its - // own anchor. + // At loud = 0 every bar collapses onto one height, which draws the flat line; the + // bottom-anchored style settles onto a thin floor strip instead. val level = WaveformFlatFraction + (dynamic - WaveformFlatFraction) * loud val barHeight = level * size.height val x = index * pitch + (pitch - barWidth) / 2f @@ -793,9 +698,8 @@ private fun WaveformCanvas( } /** - * Precomputed band parameters: two frequencies, their phases, and a bass-leaning - * weight per bar. All derived from a cheap deterministic hash of the index, so - * the set is identical on every machine and every launch. + * Precomputed band parameters (two frequencies, phases, a bass weight), hashed from the index so + * the pattern is identical on every machine. */ private class WaveformBands(private val count: Int) { private val freqA = FloatArray(count) @@ -814,7 +718,6 @@ private class WaveformBands(private val count: Int) { phaseA[index] = TAU * hash(index, 3) phaseB[index] = TAU * hash(index, 4) // Left bands (bass) sit taller, jittered so it is a slope, not a ramp. - // Wider spread than before so quiet and loud bars separate clearly. weight[index] = (1.12f - 0.60f * position) * (0.70f + 0.60f * hash(index, 5)) } } diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/NotchMotion.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/NotchMotion.kt index 0b215cf..e834626 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/NotchMotion.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/NotchMotion.kt @@ -13,7 +13,6 @@ import kotlin.math.PI import kotlin.math.abs import kotlin.math.cos -// ── Overlay motion ─────────────────────────────────────────────────────────── // Near-critically-damped springs mirror AppKit's default view animation: a // fast, confident response that settles without a rubbery bounce, so the notch // never overshoots its window footprint (which would clip) yet still feels @@ -46,16 +45,9 @@ internal fun pageSpring() = spring( internal val DockFadeIn = tween(170, easing = FastOutSlowInEasing) internal val DockFadeOut = tween(90) -// The idle "curtain" expansion moves: the quickview slides down the growing -// island while the pinned apps scale up from behind it, as if the dock surges -// forward under the sliding pill. Live music deliberately skips both — its -// content flies to the detail page through the shared-element morph, so the -// pill keeps its plain fade (sliding the source would fight the flight). -// -// The motion is deliberately gradual: the pill glides most of the way down the -// expanded island ({ it * 3 } ≈ three of its own heights, carrying it off the -// bottom edge) over a long, eased tween while the dock rises on a slow spring, -// so the whole cross reads as one unbroken descent rather than a snap-and-pop. +// Idle expansion slides the quickview down while the dock rises behind +// it. Live music skips this because the shared-element morph carries the +// content. internal val QuickViewSlideIn = tween(360, easing = FastOutSlowInEasing) internal val QuickViewSlideOut = tween(620, easing = FastOutSlowInEasing) internal val QuickViewFadeOut = tween(420) @@ -84,10 +76,8 @@ internal fun idleDockRevealSpring() = spring( ) /** - * Bounds spring for shared-element morphs. The quickview's disc/title and - * their detailed-view counterparts travel on the same near-critically-damped - * curve as the notch itself, so the two ends of the transition read as one - * continuous motion rather than a crossfade. + * The same near-critically-damped spring as the notch, so both ends of a + * morph read as one move. */ internal fun sharedBoundsSpring() = spring(dampingRatio = 0.9f, stiffness = 320f) @@ -98,10 +88,8 @@ internal const val FisheyeRadiusFactor = 1.5f internal val IconLift = 8.dp /** - * Extra hover tolerance around the island footprint (the quickview or the - * expanded dock). The hover hit area is the island plus this glow — never the - * whole native window — so the notch retracts as soon as the cursor leaves the - * visible island instead of staying glued open. + * Extra tolerance around the island; the hover footprint is the island plus + * this glow, never the window. */ internal val HoverGlow = 10.dp @@ -109,18 +97,14 @@ internal val HoverGlow = 10.dp internal const val DockLabelFontSizeFactor = 0.145f /** - * Hard ceiling on how long the choreography waits for the size spring to settle - * (in either direction) before acting anyway, so an interrupted animation can - * never leave the notch stuck. Comfortably past the spring's settle time, and - * past the dock fade that can delay the collapse spring starting. + * Hard limit on waiting for the size spring, so an interrupted animation + * cannot leave the notch stuck. */ internal const val IslandSettleTimeoutMs = 650L /** - * Cosine-squared falloff that peaks at [peakScale] when the cursor is exactly - * over an icon's centre and eases back to 1.0 at [radiusPx]. Because the peak - * sits on the centre and the radius scales with icon size, the magnified icon - * always lines up with the true cursor position. + * Cosine-squared falloff peaking at the cursor and easing to 1.0 at + * radiusPx. */ internal fun fisheyeScale( pointer: Float?, @@ -137,21 +121,10 @@ internal fun fisheyeScale( } /** - * Ceiling for the notch's continuous animations: the vinyl, the title marquee and - * the waveform all push at most this many updates per second. - * - * Display rate (60 or 120Hz) buys nothing here — a 12s-per-turn disc advances - * about 1° per frame at 30fps, and a 30px/s marquee moves 1px per frame, both - * visually identical to display rate — while every push is a full overlay - * repaint on the EDT the settings window also renders on. So the animations are - * *rate-capped* rather than frozen: a top-centred notch stays visible even with - * settings open (the window floats under it, never over it), and a notch whose - * disc and analyser have visibly stopped reads as broken. Freezing was the first - * attempt at this budget and it looked like a bug; throttling gets the same - * energy saving without lying to the user. + * Continuous animations are capped at this rate; a full overlay repaint + * is the cost, and freezing them looked like a bug. */ internal const val PillAnimationFps = 30f -internal const val PillAnimationStepSec = 1f / PillAnimationFps /** The matching [kotlinx.coroutines.delay] step, in milliseconds. */ internal val PillAnimationStepMillis = (1000L / PillAnimationFps).toLong() diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/NowPlayingMorph.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/NowPlayingMorph.kt index 1583056..2bcdd13 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/NowPlayingMorph.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/NowPlayingMorph.kt @@ -8,7 +8,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Rect -// ── Shared-element morph keys ──────────────────────────────────────────────── // The collapsed live-activity elements and their detailed now-playing page // counterparts share these keys, so Compose morphs each pair's bounds instead // of cutting between the two surfaces when the notch opens or closes. @@ -19,21 +18,8 @@ internal const val NowPlayingVolumeKey = "nowPlayingVolume" internal const val NowPlayingBatteryKey = "nowPlayingBattery" /** - * Flies one quickview element into its expanded-page counterpart (the vinyl - * disc, the marquee title, the level rings) under one shared moving frame. - * The pair are different composables, but they render the *same content* at - * different sizes — exactly the contract of - * [SharedTransitionScope.sharedElement], which renders ONLY the target content - * inside the transforming bounds. - * - * That single-content guarantee is why this must not be - * [SharedTransitionScope.sharedBounds]: sharedBounds deliberately keeps BOTH - * the entering and the exiting content visible inside the travelling frame - * (its enter/exit only style them), so the pill copy and the page copy - * double-draw over each other mid-flight — two covers, two titles (the - * marquees also scroll out of register), rings printed twice — before the - * morph settles. `sharedElement` suppresses the source and moves the target, - * so there is always exactly one album cover and one title on screen. + * Must be sharedElement, not sharedBounds: sharedBounds keeps both copies + * visible, so the pill and page double-draw during the morph. */ @OptIn(ExperimentalSharedTransitionApi::class) @Composable diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/OnboardingScreen.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/OnboardingScreen.kt index f7546f6..5e657b3 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/OnboardingScreen.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/OnboardingScreen.kt @@ -100,15 +100,8 @@ import com.starkdroid.quicknotch.generated.resources.style_drag_handle import com.starkdroid.quicknotch.generated.resources.style_floating_pill /** - * The first-run onboarding: four sequential screens -- welcome, pill style, - * Google Calendar, pinned apps -- walked one after the other, each skippable, - * and all of them styling a live preview of the notch carried between screens. - * The real overlay does not exist until the flow ends: the user is configuring - * the pill that would otherwise already be on screen. - * - * Every choice applies to the config immediately, so skipping a later step - * keeps the earlier ones -- "skip" means "I'll fine-tune in Settings", which is - * also why each screen says exactly that. + * Four skippable first-run screens styling a live notch preview. Choices + * apply immediately, so skipping a later step keeps earlier ones. */ private enum class OnboardingStep(val index: Int) { Welcome(0), @@ -603,7 +596,7 @@ private fun StyleCards( horizontalAlignment = Alignment.CenterHorizontally ) { // The pill itself, in this style, with a sample quickview row - // -- except the drag handle, whose collapsed design is a slim + // except the drag handle, whose collapsed design is a slim. // grabber bar with nothing else in it (QuickView draws exactly // that), so this card previews it honestly. Box( @@ -689,14 +682,8 @@ private fun StyleCards( } /** - * One "what the app can do" card: icon chip over a short promise on a solid - * surface. Row siblings share the tallest card's height (the row sizes itself - * to its tallest child and every card stretches to fill it), so wrapped - * labels never clip and all four boxes stay proportionate. - * - * [wide] is the gaming banner: the same chrome, laid out icon-beside-text - * across the whole column. The HUD is automatic, so it reads as a headline - * ("this happens on its own") rather than as a fifth square to configure. + * One capability card; row siblings stretch to the tallest, so wrapped + * labels do not clip. wide is the gaming banner. */ @Composable private fun FeatureCard( diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/OverlayGeometry.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/OverlayGeometry.kt index aa08c8d..8897a50 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/OverlayGeometry.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/OverlayGeometry.kt @@ -15,12 +15,8 @@ import java.awt.Point import java.awt.Rectangle /** - * Static layout constants shared by the dock renderer and the window sizer so - * the overlay window always matches the rendered content pixel-for-pixel. - * - * Along the dock axis every icon occupies `baseIconSize + 2 * IconPadding`. - * [AlongSafety] absorbs the fisheye magnification of the end icons so they - * never clip against the window edge, keeping the expanded footprint tight. + * Layout constants shared by the dock renderer and the window sizer, so + * the window matches the content. AlongSafety absorbs end-icon fisheye. */ internal object DockLayout { val IconPadding: Dp = 4.dp @@ -33,40 +29,27 @@ internal object DockLayout { const val DragHandleHeightDp: Int = 8 /** - * Collapsed-pill width slider bounds. The floor hugs a clock-only pill - * instead of stranding ~15dp of dead air on each side of it; indicator - * slots widen the footprint from whatever base is chosen, so a low base - * never clips. + * Collapsed-pill width bounds; indicator slots widen from whatever base is + * chosen. */ const val QuickViewWidthMinDp: Int = 110 const val QuickViewWidthMaxDp: Int = 190 /** - * Degenerate-measurement guard for the content-hugging pill: the island - * never contracts past this, so a zero-size first frame (or a future - * empty branch) never collapses it into a sliver. Well under every real - * branch's width, so it only ever binds on garbage input. + * Guard against a zero-size first frame; well under every real branch width. */ val HugMinWidth: Dp = 32.dp /** - * Breathing room the hugging island adds around the measured row, total - * across both ends (on top of the row's own insets) — enough that edge - * components never kiss the pill border, little enough that no dead air - * reads. + * Breathing room around the measured row, total across both ends. */ val HugPadding: Dp = 16.dp /** Width of a single icon slot along the dock axis. */ fun iconSlot(baseIconSize: Dp): Dp = baseIconSize + IconPadding * 2 - /** - * Along-axis footprint of the now-playing page when it is live. Sized so - * the vinyl, metadata and transport controls fit without clipping inside - * the expanded dock; the apps page is narrower, so the window widens to - * this whenever the music page is active. - */ - /** Five date tiles need their own room even when no dock or music page does. */ + /** Along-axis width of the live music page, wide enough for vinyl, metadata and transport. */ + /** Five date tiles need their own room when no dock or music page does. */ val CalendarPageWidth = 348.dp val NowPlayingWidth: Dp = 356.dp /** @@ -88,12 +71,8 @@ internal object DockLayout { val LiveCollapsedWidth: Dp = 236.dp /** - * Gaming HUD length while a fullscreen game is detected: five readouts - * (frames, CPU, GPU, latency, download) need more room than the pill, and - * the strip is a fixed-width fill branch like the live media pill. The - * *window* always reserves this width (see `NotchMetrics.window`) so - * starting a game never resizes the native window; only the drawn island - * grows. + * Fixed HUD width. The window reserves it always, so starting a game + * never resizes the native window. */ val GameHudWidth: Dp = 264.dp @@ -105,10 +84,7 @@ internal object DockLayout { val GameHudHeight: Dp = 30.dp /** - * Extra quickview length per indicator shown in the idle pill (22dp ring or - * bell + 6dp spacing). The idle pill widens by this per indicator so - * date/time + device battery + laptop battery + notifications all coexist - * without clipping, mirroring how the live pill already grows to its rings. + * Extra pill length per indicator: 22dp ring or bell plus 6dp spacing. */ val QuickViewIndicatorSlot: Dp = 28.dp } @@ -119,10 +95,6 @@ internal object DockLayout { */ @Immutable data class NotchMetrics( - /** Native window footprint when collapsed. */ - val collapsed: DpSize, - /** Native window footprint when expanded. */ - val expanded: DpSize, /** Visible island when expanded. */ val island: DpSize, /** Visible island when collapsed. */ @@ -130,10 +102,8 @@ data class NotchMetrics( /** Box the dock fills (the island itself for a top-centred notch). */ val dockArea: DpSize, /** - * Native window footprint. Always at least as wide as the widest thing the - * island can ever draw — the expanded dock *and* the gaming HUD — so - * starting a game widens the drawn island without resizing the window - * (a per-open/close resize is what flashes on macOS; see main.kt). + * Window footprint, at least the widest branch (dock or HUD), so a game + * never resizes the window. */ val window: DpSize = dockArea, /** @@ -143,22 +113,13 @@ data class NotchMetrics( */ val edgeGapDp: Int = 0 ) { - fun sizeFor(isExpanded: Boolean): DpSize = if (isExpanded) expanded else collapsed fun islandFor(isExpanded: Boolean): DpSize = if (isExpanded) island else islandCollapsed } /** - * Derives the overlay window footprint from configuration, pin count and - * whether the now-playing live activity is currently on. The expanded "along" - * length hugs the actual widget content instead of a fixed minimum, which - * removes the dead space that used to flank the icons. When the now-playing - * page is live it is wider than the apps page, so the window widens to that - * page — the apps page keeps its own (narrower) layout, centred. - * - * [indicatorSlots] is the count of indicators actually rendered in the idle - * quickview next to the clock (connected-device + laptop battery rings, plus the - * notification bell); the collapsed footprint grows by one slot per indicator so - * nothing is ever clipped by the configured pill width. + * Derives the window footprint from config, pin count and whether music + * is live. The expanded length hugs content, and indicatorSlots widens + * the collapsed pill per rendered indicator. */ fun computeNotchMetrics( config: NotchConfig, @@ -199,11 +160,7 @@ fun computeNotchMetrics( if (config.style == NotchStyle.DRAG_HANDLE) DockLayout.DragHandleHeightDp else config.quickViewHeightDp val islandCollapsedDepth = collapsedHeightDp.dp + DockLayout.CollapsedMargin - // The HUD stacks a value over a caption, so it keeps a floor of its own - // even when the quickview has been shrunk to a slim pill. - // The HUD is its own, slimmer strip: it may sit shallower than the pill - // rather than inheriting the quickview's depth. Its two lines of 11/6sp - // type still fit inside 30dp. + /** Collapsible widget card: header with icon, title, switch and chevron over an animated body. */ val collapsedDepth = if (gameActive) DockLayout.GameHudHeight else islandCollapsedDepth val islandExpanded = DpSize(alongExpanded, islandDepth) @@ -214,8 +171,6 @@ fun computeNotchMetrics( val window = DpSize(maxOf(alongExpanded, DockLayout.GameHudWidth), islandDepth) return NotchMetrics( - collapsed = islandCollapsed, - expanded = dockArea, island = islandExpanded, islandCollapsed = islandCollapsed, dockArea = dockArea, @@ -225,15 +180,8 @@ fun computeNotchMetrics( } /** - * How many indicator slots the idle quickview actually renders right now: the - * connected-device ring, the laptop ring (each gated by its own collapsed-only - * toggle), the notification bell (shown whenever the unread count is - * readable AND its toggle is on) and the weather chip (condition glyph + temperature, - * two slots wide). The window sizer and the overlay content MUST - * agree on this — it widens the collapsed footprint by one slot per indicator - * so nothing clips against the window edge. Gated to the idle branches: the - * drag-handle style renders no indicators and a live media pill grows to its - * own (fixed) width. + * Number of indicators the idle quickview renders; both the window sizer + * and the content must use this or the right icon clips. */ fun quickViewIndicatorSlots( config: NotchConfig, @@ -263,12 +211,8 @@ fun quickViewIndicatorSlots( } /** - * Computes the top-left screen coordinate (in AWT points) for the overlay - * window, hugging the top edge of the chosen screen and centred along it. On - * macOS the notch hugs the very top of the panel ([hugMenuBar]) so it reads as - * a real top-edge notch. [edgeGapPx] floats capsule styles off the edge by - * insetting the anchored coordinate, leaving the gap as ordinary desktop - * (click-through) rather than dead window area. + * Top-centre position in AWT points. On macOS the notch hugs the true + * top; edgeGapPx insets capsule styles. */ fun overlayScreenLocation( windowWidthPx: Int, @@ -303,10 +247,8 @@ internal fun notchShape(expanded: Boolean, style: NotchStyle): CornerBasedShape } /** - * The island's bottom-corner radius as a value, for everything that must agree - * with [notchShape] without being a shape: the outline progress ring and the - * mac blur panel. (The Windows capture doesn't need it — its frame is cropped - * to the island rect and clipped by the shape itself.) + * Corner radius as a value for the outline ring and the Mac blur panel, + * matching notchShape. */ internal fun notchCornerRadius(expanded: Boolean, style: NotchStyle): Dp { if (style.capsule) return if (expanded) 18.dp else 14.dp diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/OverlayHover.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/OverlayHover.kt index 3898904..ad3a1b5 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/OverlayHover.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/OverlayHover.kt @@ -13,21 +13,11 @@ import androidx.compose.ui.unit.IntSize import java.awt.MouseInfo import java.awt.Point -// ── Hover input sharing ──────────────────────────────────────────────────── /** - * Hover tracking lives on a small transparent sibling Box that sits on top of - * the dock. On macOS the dock Box carries a scroll handler and is therefore - * pointer-eligible, so a plain [Modifier.onPointerEvent] hover area would be - * shadowed by it: when siblings overlap, hit testing only adds the highest-z - * eligible one, and the dock is drawn above the hover area. - * - * This node opts into [sharePointerInputWithSiblings], so once the hover area - * is hit the hit test keeps walking down through the dock subtree and both - * stay on the pointer path — hover Enter/Exit still fires while the dock keeps - * its scroll handling and icon clicks. See the sibling ordering in - * [FloatingNotchOverlay] for why the hover area has to be on top for this to - * work. + * The hover area is a sibling above the dock. + * sharePointerInputWithSiblings keeps hover events flowing while the dock + * keeps scroll and clicks. */ internal class SharingHoverNode( var onHoverChanged: (Boolean) -> Unit @@ -66,19 +56,9 @@ internal fun Modifier.sharingHover(onHoverChanged: (Boolean) -> Unit): Modifier this.then(SharingHoverElement(onHoverChanged)) /** - * True while the cursor is inside the notch's current hover footprint - * (island + glow, in screen coordinates). Collapse is gated on this rather - * than on an Exit event alone because undecorated layered overlay windows - * re-state their hover region when they resize under a stationary cursor on - * macOS and Windows alike — with media ticking its progress under the pointer, - * trusting the raw event made the notch flap open/closed while hovering. - * - * [awtDrift] is the live difference between the window's real CG origin and - * AWT's cached `locationOnScreen` (see `MacAppKit.screenOriginDelta`): Compose's - * `localToScreen` rides the stale AWT position, which the above-menu-bar - * placement invalidates by exactly the menu-bar height. Null means no drift. - * - * Unknown footprint or pointer position errs toward keeping the notch open. + * True while the real cursor is inside the island. Collapse is gated on + * this because resize events flap the hover region; awtDrift corrects + * AWT's stale origin above the menu bar. */ internal fun cursorOverNotch(coordinates: LayoutCoordinates?, awtDrift: Point?): Boolean { val coords = coordinates ?: return true diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/OverlayPager.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/OverlayPager.kt index 33926cc..0dee0b1 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/OverlayPager.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/OverlayPager.kt @@ -5,22 +5,9 @@ import androidx.compose.runtime.mutableStateOf import kotlin.math.abs /** - * Page-switch state for the expanded dock's widget pager, shared between the - * overlay (which renders the pages and expands/collapses the dock) and — on - * Windows — an AWT-level wheel listener attached to the overlay window. - * - * The overlay window is unfocused and transparent, so it never receives wheel - * input through Compose's pointer pipeline on Windows; AWT is the layer that - * actually sees WM_MOUSEWHEEL. That listener is attached in `Main` and drives - * this state from the AWT event thread — the same thread Compose Desktop - * renders on, so no synchronization is needed. - * - * Gesture model: ONE continuous event stream flips at most ONE page. The - * burst locks on the first flip, swallows everything that follows (trackpad - * momentum included), and only releases when the stream has been quiet for - * [gestureGapNs] — which is exactly when the fingers have left the surface. - * A new swipe can therefore be switched instantly, and every swipe that - * actually delivers travel will always page exactly once. + * Pager state shared with the Windows AWT wheel listener, which runs on + * the render thread. One gesture flips one page and swallows momentum + * until gestureGapNs. */ internal class OverlayPager( /** Scroll pixels required to flip one page. */ @@ -47,10 +34,8 @@ internal class OverlayPager( var pageCount: Int = 1 /** - * Feeds one wheel sample (already in screen pixels) into the accumulator. - * A no-op while the dock is hidden or when there is nothing to page - * between. Fractional deltas accumulate until one step clears [stepPx]; - * each flip zeroes the travel, locks the burst, and the tail is swallowed. + * Accumulates wheel deltas; one step flips one page, then locks the burst + * and swallows the tail. */ fun consumeWheel(deltaPx: Float) { if (!dockVisible.value || pageCount <= 1 || deltaPx == 0f) return diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/PinnedAppsPanel.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/PinnedAppsPanel.kt index c2d7253..e87f90d 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/PinnedAppsPanel.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/PinnedAppsPanel.kt @@ -113,7 +113,7 @@ import java.util.UUID import javax.swing.JFileChooser import javax.swing.filechooser.FileNameExtensionFilter -// ── Pinned apps panel (Widgets tab → Pinned apps accordion) ────────────────── +// Pinned apps panel (Widgets tab → Pinned apps accordion). @Composable internal fun PinnedAppsTab( @@ -142,10 +142,8 @@ internal fun PinnedAppsTab( val pickMacFilter = stringResource(Res.string.pick_mac_filter) val pickWindowsFilter = stringResource(Res.string.pick_win_filter) - // Drag-to-reorder state. The list renders from a local snapshot so swaps - // apply synchronously under the finger; a single repository write commits - // the final position when the drag ends. Rows are equal height, but each - // measured height is tracked anyway so the swap threshold stays honest. + // The HUD is a slimmer strip with its own depth floor; two lines of 11/6sp + // type fit in 30dp. var draggingId by remember { mutableStateOf(null) } var dragStartIndex by remember { mutableStateOf(-1) } var dragOffset by remember { mutableStateOf(0f) } @@ -434,10 +432,8 @@ internal fun PinnedAppsTab( } /** - * Resolves an app icon with no visible pop-in while scrolling: the shared - * in-memory cache is read synchronously on first composition, and only a cold - * miss falls back to a background load. Combined with stable LazyColumn keys, - * rows keep their icons when recycled, so the list stays smooth. + * Reads the shared icon cache synchronously; only a cold miss loads in the + * background. */ @Composable internal fun rememberAppIcon(app: AppItem): ImageBitmap? { @@ -450,7 +446,7 @@ internal fun rememberAppIcon(app: AppItem): ImageBitmap? { return iconBitmap } -// ── Shared row building blocks (Pinned + Available rows) ───────────────────── +// Shared row building blocks (Pinned + Available rows). /** * The icon slot both list rows share: an elevated rounded box that crossfades diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/QuickView.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/QuickView.kt index 076b10b..0cee421 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/QuickView.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/QuickView.kt @@ -54,10 +54,8 @@ import com.starkdroid.quicknotch.model.NotchSurface import org.jetbrains.compose.resources.stringResource /** - * The collapsed island's content: three branches by state — live media (vinyl - * disc + marquee title + level rings, each gated by its own collapsed-only - * toggle), idle clock (date/time + optional rings), else the idle dots. The - * drag-handle style shows only a slim grabber line. + * Collapsed island: live media, idle clock with optional rings, or the idle + * dots. The drag style shows a grabber. */ @Composable internal fun QuickView( @@ -89,11 +87,8 @@ internal fun QuickView( */ waveformHidesTitle: Boolean = false, /** - * Calendar element (collapsed view only): [calendarEvent] is the live - * event's title for the ticker — null unless one is running right now — and - * [calendarEventCount] is how many events the day has, for the badged - * calendar glyph. Gated by the widget toggle in settings, which is also - * what decides whether a connection exists at all. + * Live event title for the ticker and the day's count for the badge; both + * are optional. */ showCalendar: Boolean = false, calendarEvent: String? = null, @@ -113,11 +108,8 @@ internal fun QuickView( batteryRingModifier: Modifier = Modifier, clock: ClockPrefs, /** - * Reports the measured width of the wrapping branches (idle clock, idle - * dots) so the overlay can hug the pill to its content. The fill branches - * (live media, ticker) never report: measuring them would read the island - * back into itself. The overlay clears staleness itself by only consuming - * the value while a wrapping branch is showing. + * Reports the width of wrapping branches only; measuring the fill branches + * would read the island into itself. */ onWrapWidthChange: (Dp?) -> Unit = {}, ) { @@ -132,12 +124,8 @@ internal fun QuickView( .background(handleTint(surface)) ) } else if (media != null) { - // Collapsed live activity: the disc hugs the left end, the title - // sits centred (marqueed when long) and the system volume degree - // plus the audio device's battery cap the right — each behind its - // own collapsed-only toggle. The pill is not interactive, so there - // is no transport control here; play/pause lives in the expanded - // page. + // Disc left, title centred, volume and device battery right; the pill is + // not interactive, so no transport here. Row( horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically, @@ -156,12 +144,8 @@ internal fun QuickView( size = 20.dp, modifier = discModifier ) - // The title shares the exact 22dp frame the level rings use, so - // the disc, text and rings all sit on one vertical centreline. - // With the waveform on, that same frame stacks the title over the - // bars — 13sp line + 1.5dp + 7dp of bars still fits inside it, so - // the group stays on the shared centreline and the collapsed - // footprint (and every morph key) is untouched. + // The title shares the rings' 22dp frame; with the waveform on, title and + // bars stack inside it. Box( modifier = Modifier .weight(1f) @@ -313,11 +297,8 @@ internal fun QuickView( } /** - * Reports a wrapping row's measured width to the island sizer. The wrapper - * sizes itself to the content (both branches wrap rather than fill), so the - * reported width is the content width including the row's own insets — the - * island adds nothing on top and the pill hugs with exactly the row's - * side padding on each end. + * Reports a wrapping row's content width, including its insets, to the + * island sizer. */ @Composable private fun WrapWidthReporter( @@ -336,10 +317,8 @@ private fun WrapWidthReporter( } /** - * 250 ms interpolation clock for the media progress displays. Localizing this - * state keeps the 4 Hz tick from recomposing the whole overlay tree — only - * the composable that owns the state (the pill strip, the music page's - * transport/progress cluster) recomposes on each tick. + * Local 250 ms clock; keeping it here stops the 4 Hz tick from recomposing + * the overlay. */ @Composable internal fun rememberMediaNowMs(playing: Boolean): Long { @@ -409,13 +388,8 @@ internal fun NotchProgressOutlineForSession( // Top edge of the island in canvas coords (flush, no top margin). val oy = 0f val top = if (spec.roundTop) r else 0f - // Perimeter from the top-left corner, clockwise down the left side, - // around the bottom and up to the top-right: the notch's visible - // outline, so playback fills the island's own border. Angle - // convention is Android's: 0° at 3 o'clock, increasing CLOCKWISE - // (y-down) — so both bottom quarters run 9→6 and 6→3 o'clock with - // a NEGATIVE sweep. A +90 here bulges the arcs to the wrong side - // and the following lineTo cuts diagonally through the middle. + // Perimeter from top-left clockwise; Android angles run clockwise, so the + // bottom quarters sweep negative. val path = Path().apply { moveTo(ox, oy + top) lineTo(ox, oy + h - r) diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/QuickViewClock.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/QuickViewClock.kt index 41d30ae..32a26bb 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/QuickViewClock.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/QuickViewClock.kt @@ -62,10 +62,7 @@ internal fun formatClockTime(clock24Hour: Boolean, now: LocalDateTime = LocalDat now.toLocalTime().format(DateTimeFormatter.ofPattern(if (clock24Hour) "HH:mm" else "h:mm a", Locale.getDefault())) /** - * The quickview idle content shown in place of the dots while nothing is - * playing: a live date and/or time, centred, marquee-free. The display refreshes - * exactly on the minute boundary so "9:41" flips at :00 rather than drifting - * a few seconds off the real minute. + * Idle date/time, refreshed on the minute boundary so it does not drift. */ @Composable internal fun IdleClockRow( @@ -182,11 +179,8 @@ internal fun IdleClockRow( } /** - * The quickview's notification indicator: the packaged bell silhouette tinted - * with the island colour, wearing a red badge bubble on its top-right corner - * that carries the unread [count] (capped at "99+"). Shown only while the - * count is readable — an unreadable store (e.g. Full Disk Access not granted) - * hides the whole indicator rather than showing a false "empty" state. + * Bell with an unread badge capped at 99+; hidden while the store is + * unreadable. */ @Composable internal fun NotificationIndicator( @@ -213,11 +207,8 @@ internal fun NotificationIndicator( } /** - * Renders a clock fragment centred inside the same fixed-height frame that the - * battery ring occupies, so the date, separator and time all share one - * vertical centreline with the ring regardless of the font's reported line - * box. The explicit tight line height keeps the default typography from - * inflating the box and pushing the glyphs off centre. + * Centres a clock fragment in the ring's frame; a tight line height keeps + * it on the centreline. */ @Composable private fun ClockTextFrame( diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/SettingsComponents.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/SettingsComponents.kt index 864b235..04504ef 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/SettingsComponents.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/SettingsComponents.kt @@ -53,7 +53,9 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -75,7 +77,6 @@ import com.starkdroid.quicknotch.theme.TextMuted import com.starkdroid.quicknotch.theme.TextPrimary import com.starkdroid.quicknotch.theme.TextSecondary -// ── Shared interaction helpers ─────────────────────────────────────────────── // Every hoverable settings/onboarding control used to redeclare the same two // booleans, the same four pointer handlers and the same ripple-free clickable. // They share one state shape and three helpers instead. @@ -109,6 +110,39 @@ internal fun Modifier.plainClickable(onClick: () -> Unit): Modifier = this.click onClick = onClick ) +/** + * Footer link with GhostButton chrome, so it reads as a link rather than a + * second Cancel. + */ +@OptIn(ExperimentalComposeUiApi::class) +@Composable +internal fun SettingsLinkButton(label: String, onClick: () -> Unit) { + val hoverPress = rememberHoverPressState() + val bg by animateColorAsState( + targetValue = if (hoverPress.hovered) SettingsCardHover else Color.Transparent, + animationSpec = tween(120), + label = "linkBg" + ) + Box( + modifier = Modifier + .clip(RoundedCornerShape(10.dp)) + .background(bg) + .trackHoverPress(hoverPress) + .pointerHoverIcon(PointerIcon.Hand) + .plainClickable(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 9.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = label, + style = AppTypography.labelSmall, + fontWeight = FontWeight.Medium, + color = AccentSolid, + fontSize = 11.sp + ) + } +} + /** Spring dip on press, optional lift on hover. One tuning for all controls. */ @Composable internal fun hoverPressScale( @@ -335,10 +369,8 @@ internal fun GhostButton( } /** - * A minimal, flat selection dropdown (label above, current value, popup menu) - * shared by the style and surface pickers. Popup rows are plain composables, so - * opening it costs one small popup — no preview textures are ever rendered. - * Hover highlights the field border; the chevron rotates open. + * Flat dropdown shared by the style and surface pickers; rows are plain + * composables, no preview textures. */ @OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class) @Composable @@ -582,7 +614,7 @@ internal fun MinimalistRectSwitch( } -// ── Shared section chrome (used by multiple tabs) ──────────────────────────── +// Shared section chrome (used by multiple tabs). /** * Card section header: icon chip beside title, optional subtitle underneath. @@ -628,10 +660,8 @@ internal fun SectionHeader(icon: ImageVector, title: String, subtitle: String? = } /** - * One cell in the quickview indicators grid (Volume / Audio Battery / - * Laptop Battery): a square toggle that previews the level-ring glyph and its - * level, with the label centered underneath. Hover lifts it, press dips it, - * and the border animates to the accent when on. + * One toggle cell in the indicators grid, previewing the ring glyph and + * level. */ @OptIn(ExperimentalComposeUiApi::class) @Composable @@ -659,12 +689,8 @@ internal fun IndicatorToggleBox( } /** - * The notifications entry of the same quickview indicators grid: identical - * chrome to [IndicatorToggleBox] but the preview is the real bell glyph — a - * notification has no level percentage to show inside a ring. Deliberately - * badge-free: a hardcoded count here would misrepresent the live Notification - * Center count the notch itself reads; the real badge only ever renders on the - * island. + * Notifications tile in the indicators grid. No badge here: a hardcoded + * count would misstate the live Notification Center value. */ @Composable internal fun NotificationToggleBox( @@ -748,11 +774,8 @@ private fun IndicatorCell( } /** - * Helper row under the indicators grid for the one thing that still needs the - * user's action: granting QuickNotch Full Disk Access so the quickview can read - * the macOS Notification Center count. A short hint on the left, a compact - * solid button on the right that deep-links straight to the Privacy & - * Security pane. Rendered only while the store is unreadable. + * Full Disk Access hint and deep link, shown only while the notification + * store is unreadable. */ @OptIn(ExperimentalComposeUiApi::class) @Composable @@ -873,9 +896,8 @@ internal fun BehaviorSwitchRow( } /** - * A collapsible widget card: header row (icon chip, title, enable switch, - * rotating chevron) over an animated content area. Used by the Widgets tab. - * The header lifts on hover and the chevron springs 180° when opened. + * One ring tile per metric. Network uses a log sweep with the exact rate as + * the label; no GPU counter means no tile. */ @OptIn(ExperimentalComposeUiApi::class) @Composable diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/SettingsScreen.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/SettingsScreen.kt index 6433ab5..18ba9a1 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/SettingsScreen.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/SettingsScreen.kt @@ -100,15 +100,10 @@ import org.jetbrains.compose.resources.stringResource @Composable fun SettingsScreen( repository: FavoritesRepository, - media: MediaController, - onClose: () -> Unit + media: MediaController ) { - // Deliberately NO screen-level config/favorites/session collects here: the - // settings window used to recompose end-to-end on every media heartbeat - // (a new MediaSession object every ~2s) and on every slider-drag frame, - // which read as low-fps scrolling. Each tab collects exactly the state it - // renders (see the tab signatures), so a media tick only touches the tiny - // now-playing status row and a slider drag only touches its own card. + // No screen-level state collects: each tab collects only what it renders, + // so a media tick touches one row. var selectedTab by remember { mutableStateOf(0) } val tabs = listOf( stringResource(Res.string.tab_dock_sizing), @@ -130,11 +125,9 @@ fun SettingsScreen( val calendarState by GoogleCalendar.state.collectAsState() val calendarRefreshing by GoogleCalendar.refreshing.collectAsState() - // Restore the appearance/sizing/clock defaults. This is the same set the tab - // used to own; it now lives here because the button sits in the content - // header, and the screen already holds the repository. Widgets and Behavior - // keep their own state (pinned apps, toggles) — resetting the notch's look - // must not silently undo a user's widget choices. + // Setters record the latest value under a lock that guards only the + // assignment; one writer drains the snapshots into a single disk write, so + // no JSON is torn. val onResetDefaults: () -> Unit = { repository.updateConfig { it.copy( @@ -182,7 +175,6 @@ fun SettingsScreen( .background(SettingsBg) ) { Row(modifier = Modifier.fillMaxSize()) { - // ── Sidebar ────────────────────────────────────────────────── Column( modifier = Modifier .width(232.dp) @@ -269,7 +261,6 @@ fun SettingsScreen( .background(SettingsBorder.copy(alpha = 0.7f)) ) - // ── Content pane ───────────────────────────────────────────── Column( modifier = Modifier .weight(1f) diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/Shimmer.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/Shimmer.kt index cf6030d..afa379f 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/Shimmer.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/Shimmer.kt @@ -23,12 +23,8 @@ private val ShimmerBase = Color(0xFF262830) private val ShimmerHighlight = Color(0xFF3A3F4C) /** - * Paints a slowly sweeping highlight over the modified content (used as an - * icon skeleton while the real bitmap loads in the background). - * - * The animated value is read inside [drawWithContent] on purpose: the state - * only invalidates the draw phase of this node, so a list of shimmering rows - * never recomposes while the user scrolls or while icons trickle in. + * Sweeping skeleton highlight. The animated value is read in + * drawWithContent so only the draw phase invalidates. */ @Composable fun Modifier.shimmer( @@ -66,10 +62,8 @@ fun Modifier.shimmer( } /** - * A standalone skeleton slot (icon-sized box) that shimmers. Prefer - * [Modifier.shimmer] when the placeholder already has a parent box to paint. - * The shimmer band already paints the base colour across the whole slot, so no - * separate background is applied (it would cover the band via drawContent). + * Icon-sized shimmer slot; the band paints the base colour, so no + * background is applied. */ @Composable fun ShimmerBox( diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/SystemWidget.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/SystemWidget.kt index b1ff992..6f055d7 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/SystemWidget.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/SystemWidget.kt @@ -64,20 +64,8 @@ private data class SystemTileSpec( val alert: Boolean ) -/** - * The expanded notch's System page: one level ring per metric, the metric's - * glyph in the middle and its live value under the ring — the same indicator - * language as the pill's volume/battery rings, scaled up and captioned. - * - * CPU, GPU, memory and storage map straight onto the ring's 0-100 sweep and - * warm to [LoadHighTint] as they saturate. The two network rings have no - * natural ceiling, so their sweep is a logarithmic 0..100 across 1 KB/s to - * 1 GB/s (see [systemRateLevel]) while the label carries the exact rate — the - * ring reads as "moving a little / a lot", the number as "how much". - * - * The GPU tile only exists where the OS reports engine utilization, and the - * caller keeps the whole page off the wheel until the first sample lands. - */ +/** Starts the poller once. A manual place seeds the position so the first + * fetch skips the IP lookup. */ @Composable internal fun SystemNotchPage( snapshot: SystemSnapshot?, @@ -261,10 +249,8 @@ private fun systemRateBand(value: Double): String { } /** - * Log sweep for the throughput rings: 1 KB/s → 0, 1 GB/s → 100, so idle - * traffic barely lifts the arc while a busy link fills it. A real ceiling does - * not exist (the link speed is not what is being measured), which is exactly - * why the value, not the arc, is the source of truth here. + * 1 KB/s maps to 0 and 1 GB/s to 100, so idle traffic barely moves the arc; + * the label carries the rate. */ internal fun systemRateLevel(bytesPerSec: Long): Int { val kb = bytesPerSec / 1024.0 diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/WeatherWidget.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/WeatherWidget.kt index 362b042..e694a86 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/WeatherWidget.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/WeatherWidget.kt @@ -112,10 +112,8 @@ import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource /** - * WMO weather-code groups to the packaged condition glyph. Solid - * `currentColor` silhouettes like every other ring/pill glyph, so they tint - * fully under `ColorFilter`-style SrcIn painting. Clear/partly resolve to the - * moon variants after dark via the feed's `is_day` flag. + * WMO groups to packaged condition glyphs; clear and partly resolve to the + * moon variant after dark. */ internal enum class WeatherCondition { CLEAR, PARTLY, CLOUDY, FOG, RAIN, SNOW, STORM; @@ -176,10 +174,8 @@ internal fun weatherTemp(celsius: Double, fahrenheit: Boolean): String { internal fun weatherTempUnitLetter(fahrenheit: Boolean): String = if (fahrenheit) "F" else "C" /** - * The quickview weather chip: condition glyph + temperature, drawn first in - * the idle clock row so it sits left of the date. Shown only while a forecast - * is loaded — an unlocated/failed feed hides the chip rather than showing a - * false "--°". + * Condition glyph and temperature, left of the date; hidden until a + * forecast loads. */ @Composable internal fun WeatherQuickChip( @@ -224,14 +220,8 @@ internal fun weatherChipDescription(data: WeatherData, fahrenheit: Boolean): Str ) /** - * The expanded notch's weather page: current conditions on the left (glyph, - * temperature, place, today's high/low plus precipitation chance and UV - * index), then one tile per outlook day. Mirrors the calendar page's shape — - * a schedule in a single row that fits the island's depth. - * - * Loading is never blocking: with nothing cached yet the page is a spinner, - * and a failed feed with no cache says so with a tap-to-retry chip instead - * of a dead stop. + * Current conditions plus one tile per outlook day, mirroring the + * calendar page. Loading and failure never block the page. */ @OptIn(ExperimentalComposeUiApi::class) @Composable @@ -470,10 +460,8 @@ private fun weatherDow(dateIso: String): String = runCatching { }.getOrNull() ?: dateIso /** - * The Widgets-tab weather card: live status (place + sync age), manual - * refresh, the location picker, and the temperature unit. Whether the notch - * shows it at all lives on the accordion header (like every other widget). - * No connection flow — the feed locates itself, so there is nothing to connect. + * Live status, refresh, location picker and unit. The widget toggle lives + * on the accordion header. */ @Composable internal fun WeatherSettings( @@ -578,10 +566,8 @@ private fun WeatherStatusRow( } /** - * Manual-location picker: the active place over a city/state search backed by - * Open-Meteo geocoding. Picking a result pins the pill + page to it; the reset - * chip drops back to the automatic IP position. Search is debounced so typing - * never fires a request per keystroke. + * Pins the feed to a searched place; reset returns to IP location, and + * search is debounced. */ @Composable private fun WeatherLocationSection( diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/WidgetsTab.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/WidgetsTab.kt index 4c693d6..8a67ccf 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/WidgetsTab.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/ui/WidgetsTab.kt @@ -211,12 +211,8 @@ internal fun WidgetsTab( } ) - // Mini-pill preview: the real bars component at a frozen - // phase, sitting in a dark stadium the size of the - // collapsed live pill (~220×34dp vs the real 236×34dp) - // and parked left — a notch sample, not a full-width - // banner, so the picker never drifts from what the pill - // draws. + // Mini-pill preview of the real bars at a frozen phase, parked left so the + // picker matches the pill. Box( modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterStart @@ -425,12 +421,9 @@ internal fun WidgetsTab( } /** - * The gaming HUD switches, inside the System widget: the HUD is fed by the - * same poller the System page uses, so it belongs with the widget it reads. - * Both rows are behaviour, not appearance: the HUD is automatic (a fullscreen - * game is the trigger) and transparency is a repaint of that HUD only, never a - * fourth material. The Windows exclusive-fullscreen caveat sits directly under - * the HUD switch it qualifies, inside the same accordion. + * The gaming HUD switches, next to the System widget because the HUD + * reads the same poller. The exclusive-fullscreen caveat sits under the + * switch. */ @Composable private fun GamingWidgetRows( @@ -560,10 +553,8 @@ private fun GameMetricChip(label: String, selected: Boolean, onToggle: (Boolean) } /** - * The widget's live status pill: a green dot when a media session is actively - * feeding the notch, otherwise the rendered state the user can compare against. - * Collects the session itself — the one state that changes every media - * heartbeat — so only this tiny row recomposes when a track plays. + * Green dot while media feeds the notch, otherwise the rendered state. + * Collects the session itself. */ @Composable internal fun NowPlayingStatus( diff --git a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/weather/Weather.kt b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/weather/Weather.kt index 83b93b5..d014877 100644 --- a/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/weather/Weather.kt +++ b/composeApp/src/desktopMain/kotlin/com/starkdroid/quicknotch/weather/Weather.kt @@ -20,14 +20,9 @@ import kotlinx.serialization.json.Json import kotlin.math.abs /** - * The app-wide weather feed: one IP-geolocated position plus its Open-Meteo - * forecast, polled on a half-hourly loop with a disk cache, observed by the - * quickview chip, the expanded page, and the settings card. - * - * Failure semantics mirror every other poller in the app: a failed fetch - * leaves the last known day in place (stale but real) and only a launch with - * no cache at all reports [WeatherState.Failed]. Nothing here throws past the - * scope — the loop survives any single bad round trip. + * One IP-located forecast on a half-hourly loop with a disk cache. A + * failed fetch keeps the last day; only a launch with no cache reports + * Failed. */ sealed interface WeatherState { data object Loading : WeatherState @@ -57,21 +52,15 @@ object Weather { private val cacheFile: File get() = File(AppPaths.configDir, "weather.json") - /** - * Start the poller. Idempotent; called once from the app. A manual place - * seeds the position up front so the first fetch never detours through the - * IP lookup; without one the cached fix (if any) wins as before. - */ + // Paint the cached day first, but only when it belongs to the active position. fun start(manualPlace: WeatherPlace? = null) { if (pollJob != null) return lastManualPlace = manualPlace if (manualPlace != null) { position = GeoPosition(manualPlace.latitude, manualPlace.longitude, manualPlace.name) } - // Show the cached day immediately so a cold start paints weather on - // the first frame, then refresh in the background — but only when the - // cache belongs to the active position. A stale fix from another city - // (e.g. after picking a new place) must never paint under the new one. + /** Whether the machine has a battery; gates the settings toggle so desktops + * never offer a dead control. */ readCache()?.let { cached -> val sameFix = position?.let { abs(it.latitude - cached.lat) < 0.005 && abs(it.longitude - cached.lon) < 0.005 @@ -107,11 +96,8 @@ object Weather { } /** - * Apply a config-side place change at runtime. A pick replaces the - * position outright; clearing it (null) drops the fix so the next fetch - * re-locates over IP. Either way one immediate fetch follows, under the - * same mutex as the settings refresh. Re-emits of the same place are - * ignored — config flows re-emit on every unrelated write. + * Applies a place change at runtime; null returns to IP location. One + * immediate fetch follows under the mutex. */ fun setManualPlace(place: WeatherPlace?) { if (place == lastManualPlace) return @@ -179,7 +165,6 @@ object Weather { } } - // ── disk cache ─────────────────────────────────────────────────────────── @Serializable private data class CachedWeather( diff --git a/config/detekt/baseline.xml b/config/detekt/baseline.xml index 3ceb12a..f6cdffb 100644 --- a/config/detekt/baseline.xml +++ b/config/detekt/baseline.xml @@ -9,12 +9,12 @@ CyclomaticComplexMethod:CalendarApi.kt$CalendarApi$suspend fun events(accessToken: String, fromMillis: Long): EventsResult CyclomaticComplexMethod:CalendarWidget.kt$@Composable private fun CalendarConnectRow( state: CalendarState, refreshing: Boolean, onConnect: () -> Unit, onCancel: () -> Unit, onDisconnect: () -> Unit, onRefresh: () -> Unit ) CyclomaticComplexMethod:DockContent.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun MusicContent( session: MediaSession, media: MediaController, battery: AudioDeviceBattery?, volumePercent: Int?, volumeMuted: Boolean?, interactive: Boolean, discModifier: Modifier, titleModifier: Modifier, volumeRingModifier: Modifier, batteryRingModifier: Modifier ) - CyclomaticComplexMethod:FloatingNotchOverlay.kt$@OptIn(ExperimentalComposeUiApi::class, ExperimentalSharedTransitionApi::class) @Composable internal fun FloatingNotchOverlay( repository: FavoritesRepository, media: MediaController, isExpanded: Boolean, onExpandedChange: (Boolean) -> Unit, modifier: Modifier = Modifier, /** System master-volume percentage (0-100); null hides the volume chip. */ volumePercent: Int? = null, /** Device mute flag; null when unreadable (no mute affordance). */ volumeMuted: Boolean? = null, /** Connected audio device battery (0-100); null hides the battery chip. */ battery: AudioDeviceBattery? = null, /** The machine's own (laptop) battery; null hides the quickview device ring. */ deviceBattery: SystemBattery? = null, /** Shared widget-pager state (also driven by the Windows AWT wheel listener). */ pager: OverlayPager, /** * Reports the overlay's current touch-target rectangle in window-client * pixels (the hover box = island + glow), or null until it has been sized. * Lets the native window keep its transparent margin click-through, so the * fixed footprint never swallows clicks on the app/desktop around the pill * (see `WindowPassthrough`). */ onTouchTargetChange: (java.awt.Rectangle?) -> Unit = {}, /** * The host AWT window (undecorated transparent overlay). Used on macOS to * reconcile the screen-origin drift caused by the above-menu-bar placement. */ awtWindow: java.awt.Window? = null ) + CyclomaticComplexMethod:FloatingNotchOverlay.kt$@OptIn(ExperimentalComposeUiApi::class, ExperimentalSharedTransitionApi::class) @Composable internal fun FloatingNotchOverlay( repository: FavoritesRepository, media: MediaController, isExpanded: Boolean, onExpandedChange: (Boolean) -> Unit, modifier: Modifier = Modifier, /** System master-volume percentage (0-100); null hides the volume chip. */ volumePercent: Int? = null, /** Device mute flag; null when unreadable (no mute affordance). */ volumeMuted: Boolean? = null, /** Connected audio device battery (0-100); null hides the battery chip. */ battery: AudioDeviceBattery? = null, /** The machine's own (laptop) battery; null hides the quickview device ring. */ deviceBattery: SystemBattery? = null, /** Shared widget-pager state (also driven by the Windows AWT wheel listener). */ pager: OverlayPager, /** * Reports the touch target in window-client pixels so the window's transparent * margin stays click-through; null until measured. */ onTouchTargetChange: (java.awt.Rectangle?) -> Unit = {}, /** * Host AWT window; on macOS used to reconcile the above-menu-bar screen-origin drift. */ awtWindow: java.awt.Window? = null ) CyclomaticComplexMethod:MacAppIconLoader.kt$MacAppIconLoader$private fun parseIcnsPng(icns: File?): ImageBitmap? CyclomaticComplexMethod:MediaController.kt$private fun dominantArtTint(img: BufferedImage): Color? CyclomaticComplexMethod:OnboardingScreen.kt$@Composable internal fun OnboardingScreen( repository: FavoritesRepository ) CyclomaticComplexMethod:PinnedAppsPanel.kt$@Composable internal fun PinnedAppsTab( favorites: List<AppItem>, scannedApps: List<AppItem>, searchQuery: String, isScanning: Boolean, onSearchQueryChange: (String) -> Unit, onAddFavorite: (AppItem) -> Unit, onRemoveFavorite: (String) -> Unit, onMove: (Int, Int) -> Unit, onResetDefaults: () -> Unit, fixedHeight: Boolean = false ) - CyclomaticComplexMethod:QuickView.kt$@Composable internal fun QuickView( style: NotchStyle, surface: NotchSurface, media: MediaSession?, volumePercent: Int?, /** Master mute flag; null when unreadable, which reads as "not muted". */ volumeMuted: Boolean? = null, battery: AudioDeviceBattery?, deviceBattery: SystemBattery?, notificationCount: Int?, showVolume: Boolean, showBattery: Boolean, showDeviceBattery: Boolean, /** * Equalizer bars under the title (live media only, collapsed only). The * caller also hides the outside progress border while this is on, so the * bars get the pill's full attention. */ showWaveform: Boolean = false, /** Album-art-derived or white; see [WaveformTint]. */ waveformColor: Color = WaveformWhite, /** Which bar geometry the collapsed visualiser draws; see [VisualiserStyle]. */ visualiserStyle: VisualiserStyle = VisualiserStyle.WAVEFORM, /** * Waveform-only layout: the song name is dropped and the analyser takes the * whole title frame, so the live pill reads as disc + bars + level rings. */ waveformHidesTitle: Boolean = false, /** * Calendar element (collapsed view only): [calendarEvent] is the live * event's title for the ticker — null unless one is running right now — and * [calendarEventCount] is how many events the day has, for the badged * calendar glyph. Gated by the widget toggle in settings, which is also * what decides whether a connection exists at all. */ showCalendar: Boolean = false, calendarEvent: String? = null, calendarEventCount: Int = 0, /** * Current-conditions chip (collapsed idle pill only): condition glyph + * temperature left of the date. Null until a forecast loads, which hides * the chip rather than showing a false reading. */ showWeather: Boolean = false, weather: WeatherData? = null, fahrenheit: Boolean = false, /** Shared-element morph modifiers for the live-activity disc/title/rings. */ discModifier: Modifier = Modifier, titleModifier: Modifier = Modifier, volumeRingModifier: Modifier = Modifier, batteryRingModifier: Modifier = Modifier, clock: ClockPrefs, /** * Reports the measured width of the wrapping branches (idle clock, idle * dots) so the overlay can hug the pill to its content. The fill branches * (live media, ticker) never report: measuring them would read the island * back into itself. The overlay clears staleness itself by only consuming * the value while a wrapping branch is showing. */ onWrapWidthChange: (Dp?) -> Unit = {}, ) + CyclomaticComplexMethod:QuickView.kt$@Composable internal fun QuickView( style: NotchStyle, surface: NotchSurface, media: MediaSession?, volumePercent: Int?, /** Master mute flag; null when unreadable, which reads as "not muted". */ volumeMuted: Boolean? = null, battery: AudioDeviceBattery?, deviceBattery: SystemBattery?, notificationCount: Int?, showVolume: Boolean, showBattery: Boolean, showDeviceBattery: Boolean, /** * Equalizer bars under the title (live media only, collapsed only). The * caller also hides the outside progress border while this is on, so the * bars get the pill's full attention. */ showWaveform: Boolean = false, /** Album-art-derived or white; see [WaveformTint]. */ waveformColor: Color = WaveformWhite, /** Which bar geometry the collapsed visualiser draws; see [VisualiserStyle]. */ visualiserStyle: VisualiserStyle = VisualiserStyle.WAVEFORM, /** * Waveform-only layout: the song name is dropped and the analyser takes the * whole title frame, so the live pill reads as disc + bars + level rings. */ waveformHidesTitle: Boolean = false, /** * Live event title for the ticker and the day's count for the badge; both * are optional. */ showCalendar: Boolean = false, calendarEvent: String? = null, calendarEventCount: Int = 0, /** * Current-conditions chip (collapsed idle pill only): condition glyph + * temperature left of the date. Null until a forecast loads, which hides * the chip rather than showing a false reading. */ showWeather: Boolean = false, weather: WeatherData? = null, fahrenheit: Boolean = false, /** Shared-element morph modifiers for the live-activity disc/title/rings. */ discModifier: Modifier = Modifier, titleModifier: Modifier = Modifier, volumeRingModifier: Modifier = Modifier, batteryRingModifier: Modifier = Modifier, clock: ClockPrefs, /** * Reports the width of wrapping branches only; measuring the fill branches * would read the island into itself. */ onWrapWidthChange: (Dp?) -> Unit = {}, ) CyclomaticComplexMethod:Win32ShellIconLoader.kt$Win32ShellIconLoader$private fun hIconToBufferedImage(hIcon: WinDef.HICON): BufferedImage? CyclomaticComplexMethod:WindowsInstalledAppsScanner.kt$WindowsInstalledAppsScanner$private fun scanRegistryUninstallEntries(map: MutableMap<String, AppItem>) CyclomaticComplexMethod:main.kt$fun main(args: Array<String>) @@ -33,25 +33,24 @@ LongMethod:DockContent.kt$@OptIn(ExperimentalComposeUiApi::class) @Composable private fun Dock( favorites: List<AppItem>, config: NotchConfig, iconBitmaps: Map<String, ImageBitmap>, density: Density, interactive: Boolean, onLaunch: (AppItem) -> Unit ) LongMethod:DockContent.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun MusicContent( session: MediaSession, media: MediaController, battery: AudioDeviceBattery?, volumePercent: Int?, volumeMuted: Boolean?, interactive: Boolean, discModifier: Modifier, titleModifier: Modifier, volumeRingModifier: Modifier, batteryRingModifier: Modifier ) LongMethod:DockSizingTab.kt$@Composable internal fun DockSizingTab( repository: FavoritesRepository ) - LongMethod:FloatingNotchOverlay.kt$@OptIn(ExperimentalComposeUiApi::class, ExperimentalSharedTransitionApi::class) @Composable internal fun FloatingNotchOverlay( repository: FavoritesRepository, media: MediaController, isExpanded: Boolean, onExpandedChange: (Boolean) -> Unit, modifier: Modifier = Modifier, /** System master-volume percentage (0-100); null hides the volume chip. */ volumePercent: Int? = null, /** Device mute flag; null when unreadable (no mute affordance). */ volumeMuted: Boolean? = null, /** Connected audio device battery (0-100); null hides the battery chip. */ battery: AudioDeviceBattery? = null, /** The machine's own (laptop) battery; null hides the quickview device ring. */ deviceBattery: SystemBattery? = null, /** Shared widget-pager state (also driven by the Windows AWT wheel listener). */ pager: OverlayPager, /** * Reports the overlay's current touch-target rectangle in window-client * pixels (the hover box = island + glow), or null until it has been sized. * Lets the native window keep its transparent margin click-through, so the * fixed footprint never swallows clicks on the app/desktop around the pill * (see `WindowPassthrough`). */ onTouchTargetChange: (java.awt.Rectangle?) -> Unit = {}, /** * The host AWT window (undecorated transparent overlay). Used on macOS to * reconcile the screen-origin drift caused by the above-menu-bar placement. */ awtWindow: java.awt.Window? = null ) + LongMethod:FloatingNotchOverlay.kt$@OptIn(ExperimentalComposeUiApi::class, ExperimentalSharedTransitionApi::class) @Composable internal fun FloatingNotchOverlay( repository: FavoritesRepository, media: MediaController, isExpanded: Boolean, onExpandedChange: (Boolean) -> Unit, modifier: Modifier = Modifier, /** System master-volume percentage (0-100); null hides the volume chip. */ volumePercent: Int? = null, /** Device mute flag; null when unreadable (no mute affordance). */ volumeMuted: Boolean? = null, /** Connected audio device battery (0-100); null hides the battery chip. */ battery: AudioDeviceBattery? = null, /** The machine's own (laptop) battery; null hides the quickview device ring. */ deviceBattery: SystemBattery? = null, /** Shared widget-pager state (also driven by the Windows AWT wheel listener). */ pager: OverlayPager, /** * Reports the touch target in window-client pixels so the window's transparent * margin stays click-through; null until measured. */ onTouchTargetChange: (java.awt.Rectangle?) -> Unit = {}, /** * Host AWT window; on macOS used to reconcile the above-menu-bar screen-origin drift. */ awtWindow: java.awt.Window? = null ) LongMethod:GoogleCalendar.kt$GoogleCalendar$private suspend fun authorize(): Tokens? LongMethod:MediaWidgets.kt$@Composable internal fun MarqueeText( text: String, color: Color, fontSize: TextUnit, fontWeight: FontWeight, modifier: Modifier = Modifier, lineHeight: TextUnit = TextUnit.Unspecified, // Callers can hold the scroll still (the settings preview does); the travel // itself ticks at [PillAnimationFps] in the draw phase. animate: Boolean = true ) LongMethod:MediaWidgets.kt$@Composable internal fun TrackProgress( fraction: Float, durationMs: Long, seekable: Boolean, enabled: Boolean, onSeek: (Long) -> Unit, modifier: Modifier = Modifier, accent: Color = LiveAccent ) LongMethod:MediaWidgets.kt$@Composable internal fun VinylDisc( art: ImageBitmap?, playing: Boolean, size: Dp, modifier: Modifier = Modifier, // Callers can stop the turn entirely (the settings preview does); the rate // itself is capped by [PillAnimationFps]. animate: Boolean = true, onClick: (() -> Unit)? = null ) LongMethod:MediaWidgets.kt$@Composable private fun WaveformCanvas( count: Int, shape: WaveformBands, playing: Boolean, animate: Boolean, color: Color, style: VisualiserStyle, modifier: Modifier = Modifier ) - LongMethod:MediaWidgets.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) @Composable internal fun TransportControls( state: PlaybackState, enabled: Boolean, media: MediaController, /** * Cover's dominant colour (or the live accent with no cover) behind the * play circle. A plain Color background read straight from the session — * it changes per track, never per frame, so nothing is rebuilt while the * playhead advances. */ accent: Color = LiveAccent ) + LongMethod:MediaWidgets.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) @Composable internal fun TransportControls( state: PlaybackState, enabled: Boolean, media: MediaController, /** * Cover's dominant colour (or the live accent with no cover); a plain Color that changes * per track, never per frame. */ accent: Color = LiveAccent ) LongMethod:OnboardingScreen.kt$@Composable internal fun OnboardingScreen( repository: FavoritesRepository ) LongMethod:OnboardingScreen.kt$@Composable private fun StyleCards( selected: NotchStyle, onSelect: (NotchStyle) -> Unit ) LongMethod:PinnedAppsPanel.kt$@Composable internal fun PinnedAppRow( item: AppItem, isDragging: Boolean, onDragStart: () -> Unit, onDrag: (Float) -> Unit, onDragEnd: () -> Unit, onRemove: () -> Unit, onHeightChanged: (Int) -> Unit, modifier: Modifier = Modifier ) LongMethod:PinnedAppsPanel.kt$@Composable internal fun PinnedAppsTab( favorites: List<AppItem>, scannedApps: List<AppItem>, searchQuery: String, isScanning: Boolean, onSearchQueryChange: (String) -> Unit, onAddFavorite: (AppItem) -> Unit, onRemoveFavorite: (String) -> Unit, onMove: (Int, Int) -> Unit, onResetDefaults: () -> Unit, fixedHeight: Boolean = false ) - LongMethod:QuickView.kt$@Composable internal fun QuickView( style: NotchStyle, surface: NotchSurface, media: MediaSession?, volumePercent: Int?, /** Master mute flag; null when unreadable, which reads as "not muted". */ volumeMuted: Boolean? = null, battery: AudioDeviceBattery?, deviceBattery: SystemBattery?, notificationCount: Int?, showVolume: Boolean, showBattery: Boolean, showDeviceBattery: Boolean, /** * Equalizer bars under the title (live media only, collapsed only). The * caller also hides the outside progress border while this is on, so the * bars get the pill's full attention. */ showWaveform: Boolean = false, /** Album-art-derived or white; see [WaveformTint]. */ waveformColor: Color = WaveformWhite, /** Which bar geometry the collapsed visualiser draws; see [VisualiserStyle]. */ visualiserStyle: VisualiserStyle = VisualiserStyle.WAVEFORM, /** * Waveform-only layout: the song name is dropped and the analyser takes the * whole title frame, so the live pill reads as disc + bars + level rings. */ waveformHidesTitle: Boolean = false, /** * Calendar element (collapsed view only): [calendarEvent] is the live * event's title for the ticker — null unless one is running right now — and * [calendarEventCount] is how many events the day has, for the badged * calendar glyph. Gated by the widget toggle in settings, which is also * what decides whether a connection exists at all. */ showCalendar: Boolean = false, calendarEvent: String? = null, calendarEventCount: Int = 0, /** * Current-conditions chip (collapsed idle pill only): condition glyph + * temperature left of the date. Null until a forecast loads, which hides * the chip rather than showing a false reading. */ showWeather: Boolean = false, weather: WeatherData? = null, fahrenheit: Boolean = false, /** Shared-element morph modifiers for the live-activity disc/title/rings. */ discModifier: Modifier = Modifier, titleModifier: Modifier = Modifier, volumeRingModifier: Modifier = Modifier, batteryRingModifier: Modifier = Modifier, clock: ClockPrefs, /** * Reports the measured width of the wrapping branches (idle clock, idle * dots) so the overlay can hug the pill to its content. The fill branches * (live media, ticker) never report: measuring them would read the island * back into itself. The overlay clears staleness itself by only consuming * the value while a wrapping branch is showing. */ onWrapWidthChange: (Dp?) -> Unit = {}, ) + LongMethod:QuickView.kt$@Composable internal fun QuickView( style: NotchStyle, surface: NotchSurface, media: MediaSession?, volumePercent: Int?, /** Master mute flag; null when unreadable, which reads as "not muted". */ volumeMuted: Boolean? = null, battery: AudioDeviceBattery?, deviceBattery: SystemBattery?, notificationCount: Int?, showVolume: Boolean, showBattery: Boolean, showDeviceBattery: Boolean, /** * Equalizer bars under the title (live media only, collapsed only). The * caller also hides the outside progress border while this is on, so the * bars get the pill's full attention. */ showWaveform: Boolean = false, /** Album-art-derived or white; see [WaveformTint]. */ waveformColor: Color = WaveformWhite, /** Which bar geometry the collapsed visualiser draws; see [VisualiserStyle]. */ visualiserStyle: VisualiserStyle = VisualiserStyle.WAVEFORM, /** * Waveform-only layout: the song name is dropped and the analyser takes the * whole title frame, so the live pill reads as disc + bars + level rings. */ waveformHidesTitle: Boolean = false, /** * Live event title for the ticker and the day's count for the badge; both * are optional. */ showCalendar: Boolean = false, calendarEvent: String? = null, calendarEventCount: Int = 0, /** * Current-conditions chip (collapsed idle pill only): condition glyph + * temperature left of the date. Null until a forecast loads, which hides * the chip rather than showing a false reading. */ showWeather: Boolean = false, weather: WeatherData? = null, fahrenheit: Boolean = false, /** Shared-element morph modifiers for the live-activity disc/title/rings. */ discModifier: Modifier = Modifier, titleModifier: Modifier = Modifier, volumeRingModifier: Modifier = Modifier, batteryRingModifier: Modifier = Modifier, clock: ClockPrefs, /** * Reports the width of wrapping branches only; measuring the fill branches * would read the island into itself. */ onWrapWidthChange: (Dp?) -> Unit = {}, ) LongMethod:QuickViewClock.kt$@Composable internal fun IdleClockRow( clock: ClockPrefs, tint: Color, modifier: Modifier = Modifier, battery: AudioDeviceBattery? = null, deviceBattery: SystemBattery? = null, notificationCount: Int? = null, calendarEventCount: Int = 0, weather: WeatherData? = null, fahrenheit: Boolean = false, ) LongMethod:SettingsComponents.kt$@OptIn(ExperimentalComposeUiApi::class) @Composable internal fun BehaviorSwitchRow( title: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit, icon: ImageVector? = null, subtitle: String? = null ) LongMethod:SettingsComponents.kt$@OptIn(ExperimentalComposeUiApi::class) @Composable internal fun SidebarNavItem( icon: ImageVector, label: String, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier ) LongMethod:SettingsComponents.kt$@OptIn(ExperimentalComposeUiApi::class) @Composable internal fun WidgetAccordion( icon: ImageVector, title: String, enabled: Boolean, onToggleEnabled: (Boolean) -> Unit, content: @Composable () -> Unit ) LongMethod:SettingsComponents.kt$@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class) @Composable internal fun <T> AppearanceDropdown( label: String, options: List<T>, selected: T, optionLabel: @Composable (T) -> String, onSelect: (T) -> Unit, modifier: Modifier = Modifier ) - LongMethod:SettingsScreen.kt$@Composable fun SettingsScreen( repository: FavoritesRepository, media: MediaController, onClose: () -> Unit ) - LongMethod:SettingsScreen.kt$@Composable private fun AboutDialog( onDismiss: () -> Unit ) + LongMethod:SettingsScreen.kt$@Composable fun SettingsScreen( repository: FavoritesRepository, media: MediaController ) LongMethod:WeatherWidget.kt$@Composable private fun WeatherCurrentBlock(data: WeatherData, fahrenheit: Boolean) LongMethod:WidgetsTab.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun WidgetsTab( repository: FavoritesRepository, media: MediaController, scannedApps: List<AppItem>, searchQuery: String, isScanning: Boolean, onSearchQueryChange: (String) -> Unit, calendarState: CalendarState, calendarRefreshing: Boolean, onConnectCalendar: () -> Unit, onCancelCalendar: () -> Unit, onDisconnectCalendar: () -> Unit, onRefreshCalendar: () -> Unit ) LongMethod:WindowsInstalledAppsScanner.kt$WindowsInstalledAppsScanner$private fun scanRegistryUninstallEntries(map: MutableMap<String, AppItem>) @@ -62,8 +61,8 @@ LongParameterList:DockContent.kt$( favorites: List<AppItem>, config: NotchConfig, iconBitmaps: Map<String, ImageBitmap>, density: Density, interactive: Boolean, onLaunch: (AppItem) -> Unit ) LongParameterList:DockContent.kt$( pages: List<WidgetPage>, pagePosition: State<Float>, favorites: List<AppItem>, config: NotchConfig, iconBitmaps: Map<String, ImageBitmap>, session: MediaSession?, media: MediaController, battery: AudioDeviceBattery?, volumePercent: Int?, volumeMuted: Boolean?, density: Density, interactive: Boolean, discModifier: Modifier, titleModifier: Modifier, volumeRingModifier: Modifier, batteryRingModifier: Modifier, calendarState: CalendarState?, calendarRefreshing: Boolean, weatherState: WeatherState?, weatherFetching: Boolean, fahrenheit: Boolean, /** Latest CPU/GPU/memory/storage/network reading for the System page. */ system: SystemSnapshot?, onRefreshWeather: () -> Unit, /** A click anywhere on the System page hands off to the OS task inspector. */ onOpenSystemMonitor: () -> Unit, onLaunch: (AppItem) -> Unit ) LongParameterList:DockContent.kt$( session: MediaSession, media: MediaController, battery: AudioDeviceBattery?, volumePercent: Int?, volumeMuted: Boolean?, interactive: Boolean, discModifier: Modifier, titleModifier: Modifier, volumeRingModifier: Modifier, batteryRingModifier: Modifier ) - LongParameterList:FloatingNotchOverlay.kt$( repository: FavoritesRepository, media: MediaController, isExpanded: Boolean, onExpandedChange: (Boolean) -> Unit, modifier: Modifier = Modifier, /** System master-volume percentage (0-100); null hides the volume chip. */ volumePercent: Int? = null, /** Device mute flag; null when unreadable (no mute affordance). */ volumeMuted: Boolean? = null, /** Connected audio device battery (0-100); null hides the battery chip. */ battery: AudioDeviceBattery? = null, /** The machine's own (laptop) battery; null hides the quickview device ring. */ deviceBattery: SystemBattery? = null, /** Shared widget-pager state (also driven by the Windows AWT wheel listener). */ pager: OverlayPager, /** * Reports the overlay's current touch-target rectangle in window-client * pixels (the hover box = island + glow), or null until it has been sized. * Lets the native window keep its transparent margin click-through, so the * fixed footprint never swallows clicks on the app/desktop around the pill * (see `WindowPassthrough`). */ onTouchTargetChange: (java.awt.Rectangle?) -> Unit = {}, /** * The host AWT window (undecorated transparent overlay). Used on macOS to * reconcile the screen-origin drift caused by the above-menu-bar placement. */ awtWindow: java.awt.Window? = null ) - LongParameterList:LevelRing.kt$( level: Int, icon: Painter, contentDescription: String, modifier: Modifier = Modifier, tint: Color = LevelRingGlyph, ringColor: Color = LevelRingGlyph, trackColor: Color = LevelRingTrack, strokeWidth: Dp = 2.5.dp, iconFraction: Float = 0.48f, lowTint: Color? = null, lowThreshold: Int = BatteryLowThreshold, /** * High-level alert tint, the mirror of [lowTint]: a load metric (CPU, GPU, * memory, storage) turns warm once it crosses [highThreshold]. Null keeps * the ring's normal colour at every level, so battery/volume rings are * untouched. A low-level warning wins over a high one. */ highTint: Color? = null, highThreshold: Int = LoadHighThreshold, /** * Optional count badge on the ring's top-right corner — how many devices the * reading covers, when that is more than one (e.g. several Bluetooth devices * connected while the ring shows one of their levels). Null or ≤ 1 draws * nothing, so a single device looks exactly as it did before. */ badgeCount: Int? = null ) + LongParameterList:FloatingNotchOverlay.kt$( repository: FavoritesRepository, media: MediaController, isExpanded: Boolean, onExpandedChange: (Boolean) -> Unit, modifier: Modifier = Modifier, /** System master-volume percentage (0-100); null hides the volume chip. */ volumePercent: Int? = null, /** Device mute flag; null when unreadable (no mute affordance). */ volumeMuted: Boolean? = null, /** Connected audio device battery (0-100); null hides the battery chip. */ battery: AudioDeviceBattery? = null, /** The machine's own (laptop) battery; null hides the quickview device ring. */ deviceBattery: SystemBattery? = null, /** Shared widget-pager state (also driven by the Windows AWT wheel listener). */ pager: OverlayPager, /** * Reports the touch target in window-client pixels so the window's transparent * margin stays click-through; null until measured. */ onTouchTargetChange: (java.awt.Rectangle?) -> Unit = {}, /** * Host AWT window; on macOS used to reconcile the above-menu-bar screen-origin drift. */ awtWindow: java.awt.Window? = null ) + LongParameterList:LevelRing.kt$( level: Int, icon: Painter, contentDescription: String, modifier: Modifier = Modifier, tint: Color = LevelRingGlyph, ringColor: Color = LevelRingGlyph, trackColor: Color = LevelRingTrack, strokeWidth: Dp = 2.5.dp, iconFraction: Float = 0.48f, lowTint: Color? = null, lowThreshold: Int = BatteryLowThreshold, /** * Load-metric alert tint above highThreshold; null keeps the normal * colour, and a low warning wins. */ highTint: Color? = null, highThreshold: Int = LoadHighThreshold, /** * Count badge on the ring; null or <= 1 draws nothing. */ badgeCount: Int? = null ) LongParameterList:MediaWidgets.kt$( art: ImageBitmap?, playing: Boolean, size: Dp, modifier: Modifier = Modifier, // Callers can stop the turn entirely (the settings preview does); the rate // itself is capped by [PillAnimationFps]. animate: Boolean = true, onClick: (() -> Unit)? = null ) LongParameterList:MediaWidgets.kt$( count: Int, shape: WaveformBands, playing: Boolean, animate: Boolean, color: Color, style: VisualiserStyle, modifier: Modifier = Modifier ) LongParameterList:MediaWidgets.kt$( fraction: Float, durationMs: Long, seekable: Boolean, enabled: Boolean, onSeek: (Long) -> Unit, modifier: Modifier = Modifier, accent: Color = LiveAccent ) @@ -75,7 +74,7 @@ LongParameterList:PinnedAppsPanel.kt$( favorites: List<AppItem>, scannedApps: List<AppItem>, searchQuery: String, isScanning: Boolean, onSearchQueryChange: (String) -> Unit, onAddFavorite: (AppItem) -> Unit, onRemoveFavorite: (String) -> Unit, onMove: (Int, Int) -> Unit, onResetDefaults: () -> Unit, fixedHeight: Boolean = false ) LongParameterList:PinnedAppsPanel.kt$( id: String, bitmap: ImageBitmap?, boxSize: Dp, iconSize: Dp, corner: Dp, contentDescription: String ) LongParameterList:PinnedAppsPanel.kt$( item: AppItem, isDragging: Boolean, onDragStart: () -> Unit, onDrag: (Float) -> Unit, onDragEnd: () -> Unit, onRemove: () -> Unit, onHeightChanged: (Int) -> Unit, modifier: Modifier = Modifier ) - LongParameterList:QuickView.kt$( style: NotchStyle, surface: NotchSurface, media: MediaSession?, volumePercent: Int?, /** Master mute flag; null when unreadable, which reads as "not muted". */ volumeMuted: Boolean? = null, battery: AudioDeviceBattery?, deviceBattery: SystemBattery?, notificationCount: Int?, showVolume: Boolean, showBattery: Boolean, showDeviceBattery: Boolean, /** * Equalizer bars under the title (live media only, collapsed only). The * caller also hides the outside progress border while this is on, so the * bars get the pill's full attention. */ showWaveform: Boolean = false, /** Album-art-derived or white; see [WaveformTint]. */ waveformColor: Color = WaveformWhite, /** Which bar geometry the collapsed visualiser draws; see [VisualiserStyle]. */ visualiserStyle: VisualiserStyle = VisualiserStyle.WAVEFORM, /** * Waveform-only layout: the song name is dropped and the analyser takes the * whole title frame, so the live pill reads as disc + bars + level rings. */ waveformHidesTitle: Boolean = false, /** * Calendar element (collapsed view only): [calendarEvent] is the live * event's title for the ticker — null unless one is running right now — and * [calendarEventCount] is how many events the day has, for the badged * calendar glyph. Gated by the widget toggle in settings, which is also * what decides whether a connection exists at all. */ showCalendar: Boolean = false, calendarEvent: String? = null, calendarEventCount: Int = 0, /** * Current-conditions chip (collapsed idle pill only): condition glyph + * temperature left of the date. Null until a forecast loads, which hides * the chip rather than showing a false reading. */ showWeather: Boolean = false, weather: WeatherData? = null, fahrenheit: Boolean = false, /** Shared-element morph modifiers for the live-activity disc/title/rings. */ discModifier: Modifier = Modifier, titleModifier: Modifier = Modifier, volumeRingModifier: Modifier = Modifier, batteryRingModifier: Modifier = Modifier, clock: ClockPrefs, /** * Reports the measured width of the wrapping branches (idle clock, idle * dots) so the overlay can hug the pill to its content. The fill branches * (live media, ticker) never report: measuring them would read the island * back into itself. The overlay clears staleness itself by only consuming * the value while a wrapping branch is showing. */ onWrapWidthChange: (Dp?) -> Unit = {}, ) + LongParameterList:QuickView.kt$( style: NotchStyle, surface: NotchSurface, media: MediaSession?, volumePercent: Int?, /** Master mute flag; null when unreadable, which reads as "not muted". */ volumeMuted: Boolean? = null, battery: AudioDeviceBattery?, deviceBattery: SystemBattery?, notificationCount: Int?, showVolume: Boolean, showBattery: Boolean, showDeviceBattery: Boolean, /** * Equalizer bars under the title (live media only, collapsed only). The * caller also hides the outside progress border while this is on, so the * bars get the pill's full attention. */ showWaveform: Boolean = false, /** Album-art-derived or white; see [WaveformTint]. */ waveformColor: Color = WaveformWhite, /** Which bar geometry the collapsed visualiser draws; see [VisualiserStyle]. */ visualiserStyle: VisualiserStyle = VisualiserStyle.WAVEFORM, /** * Waveform-only layout: the song name is dropped and the analyser takes the * whole title frame, so the live pill reads as disc + bars + level rings. */ waveformHidesTitle: Boolean = false, /** * Live event title for the ticker and the day's count for the badge; both * are optional. */ showCalendar: Boolean = false, calendarEvent: String? = null, calendarEventCount: Int = 0, /** * Current-conditions chip (collapsed idle pill only): condition glyph + * temperature left of the date. Null until a forecast loads, which hides * the chip rather than showing a false reading. */ showWeather: Boolean = false, weather: WeatherData? = null, fahrenheit: Boolean = false, /** Shared-element morph modifiers for the live-activity disc/title/rings. */ discModifier: Modifier = Modifier, titleModifier: Modifier = Modifier, volumeRingModifier: Modifier = Modifier, batteryRingModifier: Modifier = Modifier, clock: ClockPrefs, /** * Reports the width of wrapping branches only; measuring the fill branches * would read the island into itself. */ onWrapWidthChange: (Dp?) -> Unit = {}, ) LongParameterList:QuickViewClock.kt$( clock: ClockPrefs, tint: Color, modifier: Modifier = Modifier, battery: AudioDeviceBattery? = null, deviceBattery: SystemBattery? = null, notificationCount: Int? = null, calendarEventCount: Int = 0, weather: WeatherData? = null, fahrenheit: Boolean = false, ) LongParameterList:SettingsComponents.kt$( label: String, options: List<T>, selected: T, optionLabel: @Composable (T) -> String, onSelect: (T) -> Unit, modifier: Modifier = Modifier ) LongParameterList:SettingsComponents.kt$( title: String, icon: Painter, level: Int, checked: Boolean, onClick: (Boolean) -> Unit, modifier: Modifier = Modifier ) @@ -177,8 +176,6 @@ TooManyFunctions:MediaWidgets.kt$com.starkdroid.quicknotch.ui.MediaWidgets.kt TooManyFunctions:SettingsComponents.kt$com.starkdroid.quicknotch.ui.SettingsComponents.kt TooManyFunctions:WeatherWidget.kt$com.starkdroid.quicknotch.ui.WeatherWidget.kt - UnusedParameter:SettingsScreen.kt$onClose: () -> Unit - UnusedPrivateProperty:FavoritesRepository.kt$FavoritesRepository$wakeSignal UnusedPrivateProperty:GoogleCalendar.kt$GoogleCalendar$private const val PRODUCTION_WEB_BASE = "https://quicknotch.app" diff --git a/docs/banner.png b/docs/banner.png new file mode 100644 index 0000000..ffbba0b Binary files /dev/null and b/docs/banner.png differ diff --git a/docs/banner.svg b/docs/banner.svg new file mode 100644 index 0000000..4dac769 --- /dev/null +++ b/docs/banner.svg @@ -0,0 +1,84 @@ + + QuickNotch — one tiny notch. apps, system, weather and more. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + QuickNotch + + + One tiny notch. Apps, system, weather and more. + + + + + FREE · MIT + + + WINDOWS + macOS + + + OPEN SOURCE + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + FLICK · DOCK · DONE + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 32daf24..4c8ee1d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,8 +5,8 @@ compose-multiplatform = "1.7.3" # its MetalRedrawer.createMetalDevice runs on the AWT EDT and trips the # WindowServer "must only be used from the main thread" assertion. 0.8.25 # fixed it, and it is what puts the settings window on the GPU (~20x less -# CPU per scroll frame). Never downgrade without re-reading the pitfall in -# agents.md section 10. +# CPU per scroll frame). Never downgrade without re-reading the renderer notes +# in `main.kt` and the pin in `composeApp/build.gradle.kts`. skiko = "0.8.25" kotlin = "2.1.10" # Static analysis: detekt 1.23.8 is the last stable line and the newest one diff --git a/package.json b/package.json new file mode 100644 index 0000000..7178cab --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "name": "quicknotch", + "private": true, + "description": "Build entry for the site that ships with the desktop app. The site itself lives in web/.", + "scripts": { + "build": "cd web && npm ci --include=dev && npm run build", + "check": "cd web && npm run check", + "deploy": "wrangler deploy" + } +} diff --git a/web/README.md b/web/README.md index 492ff89..2fc48b0 100644 --- a/web/README.md +++ b/web/README.md @@ -1,8 +1,10 @@ # QuickNotch — marketing site -The landing page for QuickNotch: a bold, light, scroll-driven single page that -explains the glanceable notch and its widgets in one tour, shows the two looks, -and links to the installers. +The site for QuickNotch: a bold, light, scroll-driven landing page that explains +the glanceable notch and its widgets in one tour, shows the two looks, and hands +over an installer — plus `/download` and `/changelog`, which read the release +feed the app itself uses. Free and open source, so the copy, the schema and the +installers all say the same thing. It lives in the same repository as the Kotlin desktop app but is a completely separate workspace — the app's Gradle build and the site's Node build never @@ -43,9 +45,17 @@ npm run dev # http://localhost:5173 | `npm run check` | `svelte-check` — types plus Svelte and a11y diagnostics. | | `npm run verify` | Runtime smoke test against a running preview server (see [Verification](#verification)). | | `npm run og` | Regenerates `static/og.png` and the PNG icons from SVG. Run only when those change; the output is committed. | +| `npm run banner` | Rasterises `../docs/banner.svg` into `../docs/banner.png` for the README. | Node 20+ is recommended (developed on Node 24). +`npm audit` reports no high-severity findings. Six low-severity advisories +remain in the build toolchain (`@sveltejs/kit` 2.x, `cookie`, `deepmerge`); +clearing them needs SvelteKit 3, which moves the config into `vite.config.ts`, +drops `$lib` and requires Vite 8 — a separate migration. None is reachable in +what ships: the output is prerendered HTML with no server, no cookies and no +form actions. + --- ## Layout @@ -60,6 +70,7 @@ web/ │ │ │ ├── reveal.ts # scroll-reveal + cascade actions (IntersectionObserver → GSAP) │ │ │ └── magnetic.ts # magnetic hover for primary buttons │ │ ├── data/site.ts # ALL copy, links and not-a-token values +│ │ ├── data/releases.ts # changelog feed + installer file names │ │ ├── motion/ │ │ │ ├── gsap.ts # lazy GSAP + ScrollTrigger loader │ │ │ └── smoothScroll.ts # Lenis, wired into the GSAP ticker @@ -67,31 +78,61 @@ web/ │ │ ├── TourSection.svelte # the sticky scroll tour (state machine) │ │ ├── TourDevice.svelte # the CSS-3D laptop chassis │ │ ├── NotchPanel.svelte # one notch state, drawn to the app's geometry +│ │ ├── KofiButton.svelte # Ko-fi's own button artwork, used unchanged +│ │ ├── SupportFab.svelte # the floating support pill (every page) │ │ ├── Spotlight.svelte # scroll-driven glow (flat colour + blur) │ │ └── Grain.svelte # generated film-grain background layer │ └── routes/ │ ├── +layout.ts # `prerender = true` │ ├── +layout.svelte # fonts, styles, smooth-scroll, backdrop layers -│ └── +page.svelte # page composition (section order) -├── static/ # favicon, icons, og.png, robots.txt, sitemap.xml, manifest, _headers +│ ├── +page.svelte # page composition (section order) +│ ├── download/+page.svelte # platform cards, install notes, thank-you modal +│ ├── changelog/+page.svelte# the published release history +│ ├── connect/+page.svelte # Calendar consent (app-invoked, unlinked) +│ ├── oauth/callback/+page.svelte # hands the code back to the app +│ └── 404/+page.svelte # rendered to 404.html at the root of the build +├── static/ # favicon, icons, og.png, kofi-button.png, robots.txt, sitemap.xml, manifest, _headers +├── worker/site.js # www and http → the apex, then the assets └── scripts/ ├── generate-og.mjs # SVG → PNG for the social card and icons + ├── generate-banner.mjs # docs/banner.svg → the README banner └── verify-render.mjs # runtime smoke test over the DevTools Protocol ``` +The Worker config is one level up, at the repository root (`wrangler.toml`), next +to the `package.json` whose `build` script drives this directory — see +[Hosting on Cloudflare](#hosting-on-cloudflare-quicknotchapp). + ### Page order `Hero` (`#top`) → `TourSection` (`#tour`) → `LookSection` (`#look`) → -`PricingSection` (`#pricing`) → `FaqSection` (`#faq`) → `Footer`. +`OfferSection` (`#download`) → `FaqSection` (`#faq`) → `Footer`. The header is a notch, not a menu: a dark bar flush with the top edge — square upper corners, only the two facing the page rounded, exactly the app's base -notch — carrying the mark, the wordmark and the buy button. It rests compact at -the top of the page and opens to full width as you scroll, closing again when -you return to the top; the progress hairline runs along its lower edge. There are -no section links at all — the tour, the look and the price are found by -scrolling. The FAQ is the last section; there is no separate download section, -because the buy button in pricing is the one call to action. +notch — carrying the mark, the wordmark, the version (linking to the changelog) +and the download button. It rests compact at the top of the page and opens to +full width as you scroll, closing again when you return to the top; the progress +hairline runs along its lower edge. There are no section links at all — the +tour, the look and the download are found by scrolling. The FAQ is the last +section; the ticket in `OfferSection` is the one call to action, and it points +at `/download` rather than shipping its own second demo. + +The support ask is a floating pill in the bottom-right corner (`SupportFab`), +drawn in the same cream-and-ink as the rest of the page rather than borrowed +from Ko-fi's overlay widget: nothing is loaded from their CDN, and nothing at +all until it is clicked. It is fixed and always visible, on every page, +including `/connect` and the OAuth callback: there is no scroll threshold to +cross and no footer to step aside for. On a phone it is the cup alone. + +It is the page's only standing ask. The footer carries links and the +copyright, not a second support button; Ko-fi's own artwork still appears where +it belongs to the moment, on the download ticket and in the thank-you dialog. + +`/download` and `/changelog` are the only subpages anyone navigates to, and both +open the same way: a `← Home` back link above the eyebrow. The header mark, the +footer mark and the footer's `Home` also resolve to `/`, so leaving a page never +depends on guessing that the logo is a link. The tour is the whole product in one scroll: a CSS-3D laptop settles, its lid opens and its screen walks through the seven states — quickview, dock, system, @@ -109,6 +150,12 @@ structured data in `Seo.svelte` provably in sync — the most common cause of rich-result validation failures is structured data that has drifted from the visible text. +That includes the page titles and descriptions, the OAuth status copy and the +values the mock notch draws. If a sentence appears on screen it is in this file; +components own markup, layout and punctuation between two values. User-facing +copy avoids em dashes: a colon, a full stop or a comma does the same job and +reads like a person wrote it. + --- ## Design system @@ -228,54 +275,129 @@ Other deliberate choices: --- +## Download page and changelog + +`/download` and `/changelog` are the only two subpages, and they share one data +source: `src/lib/data/changelog.json`, the cumulative feed the +release workflow generates. `src/lib/data/releases.ts` imports it and builds the +installer URLs from the version it names. + +The feed is committed rather than fetched from GitHub for a concrete reason: +release assets are served without CORS headers, so a browser cannot read +`changelog.json` from the release itself, and going through the API would spend +a rate limit on a file the release already deploys. Importing it at build time +also means the version chip, the history and `softwareVersion` in the structured +data are in the prerendered HTML — nothing appears after hydration. The release +workflow copies the new feed to `web/src/lib/data/changelog.json` after it +publishes, which also triggers the Cloudflare build. + +Installer names are stable, so no site change is needed per release: + +| Platform | Asset | +| :--- | :--- | +| Windows | `QuickNotch-.msi` and `QuickNotch-.exe` | +| macOS, Apple silicon | `QuickNotch--arm64.dmg` | +| macOS, Intel | `QuickNotch--x64.dmg` | + +Both Windows files install the same app and both self-update: the updater +prefers the `.msi` and falls back to the `.exe`, and its script branches on the +extension. The card therefore offers them as two peer buttons, the way macOS +offers two disk images, and the button labels carry the file type. Nothing sits +under the buttons on either card: the cards end where the choice ends. + +Clicking any download opens a thank-you dialog once the file has started. It is a +native ``, so focus is trapped, Escape dismisses it and the page behind is +inert without any of that being hand-rolled; it never intercepts the link, which +is already navigating by the time the card appears. The card carries Ko-fi's own +button artwork (`static/kofi-button.png`, committed rather than hot-linked) and a +"Maybe later" button. Nothing there gates the download — the tip jar is the only +thing the site asks for, and it asks after the installer is on its way. + +--- + ## SEO - Titles, description, canonical, robots directives, Open Graph and Twitter card tags, plus three JSON-LD graphs (`SoftwareApplication`, `WebSite`, - `Organization`) rendered in the prerendered HTML. -- `static/sitemap.xml` and `static/robots.txt`. + `Organization`) rendered in the prerendered HTML. The software graph declares + `isAccessibleForFree`, a zero price and the MIT licence, and names the + repository in `sameAs`. +- `/download` and `/changelog` carry their own title, description, canonical and + Open Graph tags through `PageSeo.svelte`. +- `static/sitemap.xml` lists all three public routes; `/connect` and `/oauth/*` + stay out of it and out of `robots.txt`. - One `

` and one `

` per section, with `h3`s beneath. - Descriptive anchor text on every link, and `alt`/`aria-label` coverage. -- The visible CTAs all point at `site.purchaseUrl`; `site.downloadUrl` survives - only in the `SoftwareApplication` schema. There is no code-host, licence or - issue-tracker reference anywhere in the markup, the copy, the structured data - or the generated Open Graph image. - -### Before deploying - -1. **Set the purchase destination.** Every visible CTA reads `site.purchaseUrl` - in `src/lib/data/site.ts` (`https://quicknotch.app/buy` as a placeholder). - `site.downloadUrl` is schema-only now; point it at the installer or drop the - property. -2. **Confirm the domain.** `site.url` drives the canonical URL, the Open Graph - image URL, the sitemap and the JSON-LD. Update it alongside - `static/sitemap.xml` and `static/robots.txt`. - -### Hosting on Cloudflare (prepared, not yet live) - -The build is already Cloudflare-Pages-shaped: `adapter-static` emits plain -files to `build/`, all asset URLs are relative, and there is no server code — -no SSR, no edge functions, no environment variables. To go live: - -1. In Cloudflare Pages, create a project from this repo with build command - `npm run build` (working directory `web/`) and output directory `build`. -2. Add the custom domain `quicknotch.app` (apex) in the Pages project. Approve - the automatic DNS handling, then confirm Cloudflare owns DNS for the apex — - proxy (orange cloud) on, **Always Use HTTPS** on, **Automatic HTTPS Rewrites** - on. -3. Add a `www` → apex redirect (a single Redirect Rule: `www.quicknotch.app/*` - → `https://quicknotch.app/$1`, 301). The site's canonical, sitemap and - structured data all name the apex, so there must be exactly one. -4. In the Google OAuth client, register the production redirect URI - `https://quicknotch.app/oauth/callback` alongside the local preview URIs. - The desktop reads the loopback port out of `state`, so no app change is - needed — but consent fails as `redirect_uri_mismatch` until the URI is - registered, and new URIs can take time to propagate. -5. After the first deploy, check: `https://quicknotch.app/` loads with HSTS, - `/_headers` took effect (DevTools → response headers shows `content-security-policy`), - `/robots.txt` serves the flow disallows, `/sitemap.xml` lists only `/`, - and `/connect?standby=1` renders the standby card with a `noindex` robots - meta (view source — it must never be reachable from any link on `/`). +- Every public link (site, download, changelog, source, issues, Ko-fi) comes from + `site` in `src/lib/data/site.ts`, so the markup, the schema and the footer + cannot point at different places. + +--- + +## Hosting on Cloudflare (quicknotch.app) + +`adapter-static` emits plain files to `build/`, all asset URLs are relative, and +there is no server rendering, no database and no secret anywhere in `web/`. +Cloudflare serves that directory as the static assets of a Worker, which is what +the repository's Cloudflare build deploys. + +The Worker script (`worker/site.js`) exists for two reasons: a custom domain can +only be attached to a Worker, and the site should answer on one address. +`www.quicknotch.app` and plain `http` both redirect with a 301 to +`https://quicknotch.app`, same path, query string intact. Every other request is +handed straight to the assets binding. + +The `quicknotch` Worker builds on every push to `main`: + +| Setting | Value | +| :--- | :--- | +| Root directory | empty (the repository root) | +| Build command | `npm run build` | +| Deploy command | `npx wrangler deploy` | +| Environment variable | `NODE_VERSION=22` | + +Nothing else about the deploy lives in the dashboard. The repository root has a +`package.json` whose `build` script hands off to `web/` (install, then Vite), and +a `wrangler.toml` that names `web/build` as the asset directory and +`web/worker/site.js` as the script. Running the two commands above from a clean +clone is exactly what the Cloudflare build does, which is also the reason the +root directory must stay empty: pointing it at `web` would hide both files. + +Page counts come from Cloudflare Web Analytics: the beacon is rendered from +`+layout.svelte` with the token that lives in `site.ts`, and `_headers` allows +`static.cloudflareinsights.com` in `script-src` and `cloudflareinsights.com` in +`connect-src`, so nothing else third-party can load. It is cookieless, which is +why there is no consent banner; the desktop app itself still has no analytics at +all. `/connect` and `/oauth/*` are deliberately left uncounted: the callback URL +carries a one-time authorisation code in its query string, and that must never +reach an analytics endpoint. + +`.github/workflows/deploy-web.yml` is the manual fallback: the same build, then +`wrangler deploy`. It needs two repository secrets, `CLOUDFLARE_API_TOKEN` +(Workers Scripts: Edit) and `CLOUDFLARE_ACCOUNT_ID`; without them it +type-checks and builds, then skips the deploy with a warning. + +Then, once, in Cloudflare: + +1. Attach the custom domains `quicknotch.app` and `www.quicknotch.app` to the + Worker. The zone is in the same account, so both records and their + certificates are provisioned for you; proxy stays on. +2. In the Google OAuth client, register the production redirect URI + `https://quicknotch.app/oauth/callback` alongside the local preview URIs. The + desktop reads the loopback port out of `state`, so no app change is needed — + but consent fails as `redirect_uri_mismatch` until the URI is registered, and + new URIs can take a while to propagate. +3. After the first deploy, check: `/` loads over HSTS, `_headers` took effect + (DevTools → response headers shows `content-security-policy`), `/download` + resolves without `.html`, `/changelog` shows the published version in its + HTML, `/robots.txt` serves the flow disallows, `/sitemap.xml` lists only the + three public routes, and a mistyped address returns a real 404 with the + "this page never made it into the dock" page. + +The site itself can be hosted while the repository is private, but its download +buttons cannot work: GitHub serves no release assets to anonymous clients on a +private repository. Make the repository public before pointing anyone at +`quicknotch.app`. Security posture, all in-repo rather than in-dashboard: @@ -293,10 +415,13 @@ Security posture, all in-repo rather than in-dashboard: disallows. They are also unlinked: no nav, footer, sitemap or copy points at them. The only in-repo `/connect` reference is the OAuth error page's "Start again" link, which is itself inside the unindexed flow. -- No secrets in the repo: the OAuth `client_id` is a public identifier by - design; the `client_secret` never leaves the desktop app and appears nowhere - in `web/`. There is no token endpoint, no cookie, no analytics, no third-party - script — so there is nothing to leak and nothing to consent to. +- No secrets in the repo: the Google OAuth client id and its secret are both + injected into the desktop build at compile time and appear nowhere in this + repository; the connect page names no client, because the desktop sends its + own. There is no token endpoint and no cookie. The one third-party script is + Cloudflare's Web Analytics beacon: cookieless, no identifier that follows + anyone between sites, and the `Content-Security-Policy` allows exactly its + origin — so there is nothing to leak and nothing to consent to. --- @@ -329,11 +454,12 @@ It drives headless Chrome over the DevTools Protocol (no extra dependencies) and reports, in order: **page health** (uncaught errors and console errors, reported first so a hydration crash is obvious), whether the reveal actions fire, whether the **spotlight tracks the scroll and lands within a few pixels of the -pricing CTA**, whether the **tour advances its step and opens the lid**, +download CTA**, whether the **tour advances its step and opens the lid**, whether the hero entrance completes with each headline line at rest inside its -mask, **whether any element renders a painted gradient** (must be 0) and whether -the grain texture generated, any horizontal overflow at 1440 px and 390 px, and -finally any console errors raised during interaction. +mask, **whether any element renders a painted gradient** (must be 0), whether the +grain texture generated, any horizontal overflow at 1440 px and 390 px, and +finally any console errors raised during interaction. The spotlight probe follows +the scroll to the download ticket's CTA. Each probe is isolated, so one failing probe cannot abort the report. diff --git a/web/package-lock.json b/web/package-lock.json index ce77d61..0ad7ac4 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -11,8 +11,7 @@ "@fontsource-variable/fraunces": "^5.3.0", "@fontsource-variable/inter": "^5.1.1", "@fontsource-variable/space-grotesk": "^5.1.1", - "gsap": "^3.12.7", - "lenis": "^1.1.20" + "gsap": "^3.12.7" }, "devDependencies": { "@sveltejs/adapter-static": "^3.0.8", @@ -516,9 +515,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", - "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", "cpu": [ "arm64" ], @@ -535,13 +534,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.3" + "@img/sharp-libvips-darwin-arm64": "1.3.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", - "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", "cpu": [ "x64" ], @@ -558,13 +557,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.3" + "@img/sharp-libvips-darwin-x64": "1.3.4" } }, "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", - "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", "dev": true, "license": "Apache-2.0", "optional": true, @@ -572,7 +571,7 @@ "freebsd" ], "dependencies": { - "@img/sharp-wasm32": "0.35.4" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -582,9 +581,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", - "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", "cpu": [ "arm64" ], @@ -599,9 +598,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", - "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", "cpu": [ "x64" ], @@ -616,9 +615,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", - "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", "cpu": [ "arm" ], @@ -636,9 +635,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", - "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", "cpu": [ "arm64" ], @@ -656,9 +655,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", - "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", "cpu": [ "ppc64" ], @@ -676,9 +675,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", - "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", "cpu": [ "riscv64" ], @@ -696,9 +695,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", - "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", "cpu": [ "s390x" ], @@ -716,9 +715,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", - "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", "cpu": [ "x64" ], @@ -736,9 +735,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", - "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", "cpu": [ "arm64" ], @@ -756,9 +755,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", - "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", "cpu": [ "x64" ], @@ -776,9 +775,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", - "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", "cpu": [ "arm" ], @@ -798,13 +797,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.3" + "@img/sharp-libvips-linux-arm": "1.3.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", - "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", "cpu": [ "arm64" ], @@ -824,13 +823,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.3" + "@img/sharp-libvips-linux-arm64": "1.3.4" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", - "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", "cpu": [ "ppc64" ], @@ -850,13 +849,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.3" + "@img/sharp-libvips-linux-ppc64": "1.3.4" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", - "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", "cpu": [ "riscv64" ], @@ -876,13 +875,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.3" + "@img/sharp-libvips-linux-riscv64": "1.3.4" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", - "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", "cpu": [ "s390x" ], @@ -902,13 +901,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.3" + "@img/sharp-libvips-linux-s390x": "1.3.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", - "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", "cpu": [ "x64" ], @@ -928,13 +927,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.3" + "@img/sharp-libvips-linux-x64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", - "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", "cpu": [ "arm64" ], @@ -954,13 +953,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", - "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", "cpu": [ "x64" ], @@ -980,13 +979,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.3" + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", - "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, @@ -1001,9 +1000,9 @@ } }, "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", - "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", "cpu": [ "wasm32" ], @@ -1011,7 +1010,7 @@ "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/sharp-wasm32": "0.35.4" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -1021,9 +1020,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", - "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", "cpu": [ "arm64" ], @@ -1041,9 +1040,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", - "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", "cpu": [ "ia32" ], @@ -1061,9 +1060,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", - "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", "cpu": [ "x64" ], @@ -1919,37 +1918,6 @@ "node": ">=6" } }, - "node_modules/lenis": { - "version": "1.3.26", - "resolved": "https://registry.npmjs.org/lenis/-/lenis-1.3.26.tgz", - "integrity": "sha512-s/xTCZCxTFvHbAN1OzuhNaN5YPJH2ail0XAkctKW1b+RUAG4nUL5UHLXwNko1h8aEeT2jspBXegMgPJd8zcuag==", - "license": "MIT", - "workspaces": [ - "packages/*", - "playground", - "playground/*" - ], - "funding": { - "type": "github", - "url": "https://github.com/sponsors/darkroomengineering" - }, - "peerDependencies": { - "@nuxt/kit": ">=3.0.0", - "react": ">=17.0.0", - "vue": ">=3.0.0" - }, - "peerDependenciesMeta": { - "@nuxt/kit": { - "optional": true - }, - "react": { - "optional": true - }, - "vue": { - "optional": true - } - } - }, "node_modules/locate-character": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", @@ -2156,9 +2124,9 @@ "license": "MIT" }, "node_modules/sharp": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", - "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2173,31 +2141,31 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.4", - "@img/sharp-darwin-x64": "0.35.4", - "@img/sharp-freebsd-wasm32": "0.35.4", - "@img/sharp-libvips-darwin-arm64": "1.3.3", - "@img/sharp-libvips-darwin-x64": "1.3.3", - "@img/sharp-libvips-linux-arm": "1.3.3", - "@img/sharp-libvips-linux-arm64": "1.3.3", - "@img/sharp-libvips-linux-ppc64": "1.3.3", - "@img/sharp-libvips-linux-riscv64": "1.3.3", - "@img/sharp-libvips-linux-s390x": "1.3.3", - "@img/sharp-libvips-linux-x64": "1.3.3", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", - "@img/sharp-libvips-linuxmusl-x64": "1.3.3", - "@img/sharp-linux-arm": "0.35.4", - "@img/sharp-linux-arm64": "0.35.4", - "@img/sharp-linux-ppc64": "0.35.4", - "@img/sharp-linux-riscv64": "0.35.4", - "@img/sharp-linux-s390x": "0.35.4", - "@img/sharp-linux-x64": "0.35.4", - "@img/sharp-linuxmusl-arm64": "0.35.4", - "@img/sharp-linuxmusl-x64": "0.35.4", - "@img/sharp-webcontainers-wasm32": "0.35.4", - "@img/sharp-win32-arm64": "0.35.4", - "@img/sharp-win32-ia32": "0.35.4", - "@img/sharp-win32-x64": "0.35.4" + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" }, "peerDependenciesMeta": { "@types/node": { @@ -2221,9 +2189,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -2310,9 +2278,9 @@ } }, "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.9.1.tgz", + "integrity": "sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA==", "dev": true, "license": "0BSD", "optional": true @@ -2332,9 +2300,9 @@ } }, "node_modules/vite": { - "version": "6.4.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", - "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "version": "6.4.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.5.tgz", + "integrity": "sha512-4enwjFfi4IwtEh0w+jPzpLVOFQiPHmmzPxcHXQOyEur47o6u0jE573DmGpjA+7irXoMLYRZD5ZaFUEneS3abPg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/web/package.json b/web/package.json index e5758de..1f7c503 100644 --- a/web/package.json +++ b/web/package.json @@ -5,12 +5,13 @@ "type": "module", "scripts": { "dev": "vite dev", - "dev:oauth": "vite dev --port 5173 --strictPort", + "dev:oauth": "vite dev --port 5173 --strictPort", "build": "vite build", "preview": "vite preview", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "verify": "node scripts/verify-render.mjs", - "og": "node scripts/generate-og.mjs" + "og": "node scripts/generate-og.mjs", + "banner": "node scripts/generate-banner.mjs" }, "devDependencies": { "@sveltejs/adapter-static": "^3.0.8", @@ -26,7 +27,6 @@ "@fontsource-variable/fraunces": "^5.3.0", "@fontsource-variable/inter": "^5.1.1", "@fontsource-variable/space-grotesk": "^5.1.1", - "gsap": "^3.12.7", - "lenis": "^1.1.20" + "gsap": "^3.12.7" } } diff --git a/web/scripts/generate-banner.mjs b/web/scripts/generate-banner.mjs new file mode 100644 index 0000000..aac694a --- /dev/null +++ b/web/scripts/generate-banner.mjs @@ -0,0 +1,27 @@ +/** + * Rasterises `docs/banner.svg` into the PNG the README embeds. + * + * The SVG stays the source of truth so the banner is editable as text; GitHub + * renders the PNG identically everywhere, which SVG text cannot promise (an + * SVG loaded as an image only resolves fonts the viewer happens to own). + * + * Run with `npm run banner` from `web/`. The output is committed. + */ + +import { readFile, writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import sharp from 'sharp'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const source = join(root, 'docs', 'banner.svg'); +const target = join(root, 'docs', 'banner.png'); + +const svg = await readFile(source); +const png = await sharp(svg, { density: 192 }) + .resize({ width: 2560 }) + .png({ compressionLevel: 9 }) + .toBuffer(); + +await writeFile(target, png); +console.log(`docs/banner.png ${Math.round(png.length / 1024)} KB (2560px)`); diff --git a/web/scripts/generate-og.mjs b/web/scripts/generate-og.mjs index 6fb9b5e..a818f6b 100644 --- a/web/scripts/generate-og.mjs +++ b/web/scripts/generate-og.mjs @@ -25,9 +25,10 @@ const staticDir = join(root, 'static'); const FONT = "'Inter','Helvetica Neue',Helvetica,Arial,sans-serif"; const DISPLAY = "'Fraunces','Georgia','Times New Roman',serif"; -// Keep the card's price line in step with `pricing.label` in src/lib/data/site.ts -// (this script is plain Node and cannot import the TypeScript content module). -const PRICE_LABEL = '$9'; +// The card's footer line. QuickNotch is free and MIT licensed; this mirrors +// `offer.finePrint` in src/lib/data/site.ts (this script is plain Node and +// cannot import the TypeScript content module). +const FOOTER_LABEL = 'Free · open source · MIT'; const escape = (value) => value.replace(/&/g, '&').replace(//g, '>'); @@ -73,7 +74,7 @@ const ogSvg = ` QuickNotch — app dock, notch, widgets - ${escape(PRICE_LABEL)} · Windows & macOS + ${escape(FOOTER_LABEL)} · Windows & macOS `; async function writePng(name, buffer, width) { diff --git a/web/scripts/verify-render.mjs b/web/scripts/verify-render.mjs index 07beb0b..6c68c21 100644 --- a/web/scripts/verify-render.mjs +++ b/web/scripts/verify-render.mjs @@ -405,19 +405,19 @@ await safe('scroll to #look', `(() => { await sleep(1700); const spotMid = await safe('spotlight at #look', spotlightReport); -await safe('scroll to #pricing', `(() => { - const el = document.querySelector('#pricing .ticket__cta'); +await safe('scroll to #download', `(() => { + const el = document.querySelector('#download .ticket__cta'); if (!el) return false; const r = el.getBoundingClientRect(); window.scrollTo(0, r.top + window.scrollY - window.innerHeight / 2); return true; })()`); await sleep(2100); -const spotEnd = await safe('spotlight at #pricing', spotlightReport); +const spotEnd = await safe('spotlight at #download', spotlightReport); const downloadTarget = await safe( - 'pricing CTA rect', + 'download CTA rect', `(() => { - const el = document.querySelector('#pricing .ticket__cta'); + const el = document.querySelector('#download .ticket__cta'); if (!el) return null; const r = el.getBoundingClientRect(); return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) }; @@ -483,7 +483,7 @@ if (!spotTop || !spotMid || !spotEnd) { } else { line('at top', `${spotTop.x}, ${spotTop.y} · core ${spotTop.width}px`); line('at #look', `${spotMid.x}, ${spotMid.y} · core ${spotMid.width}px`); - line('at #pricing', `${spotEnd.x}, ${spotEnd.y} · core ${spotEnd.width}px`); + line('at #download', `${spotEnd.x}, ${spotEnd.y} · core ${spotEnd.width}px`); line( 'distance to CTA', downloadTarget diff --git a/web/src/app.css b/web/src/app.css index 818a84f..142473d 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -1,13 +1,7 @@ -/* ========================================================================== - QuickNotch — design system v2 - "Retro print-shop poster": warm cream paper, near-black ink, tomato-red and - deep-blue spot accents, chunky editorial serif. Flat poster shapes with hard - offset "sticker" shadows — still deliberately gradient-free. - ========================================================================== */ - -/* ---------- 1. Tokens ---------- */ +/* QuickNotch design system: retro print-shop poster, warm cream paper, flat shapes and hard offset shadows, no gradients. */ + :root { - /* Surfaces — paper, not glass. */ + /* Surfaces: paper, not glass. */ --paper: #f9f3e7; --paper-deep: #f1e8d6; --paper-lift: #fffdf6; @@ -17,7 +11,7 @@ --ink-2: #4c4437; --ink-3: #8b8271; - /* Accents — spot colours, one per job. */ + /* Accents: spot colours, one per job. */ --tomato: #e8481c; --tomato-deep: #b83a10; --blue: #1f4fd8; @@ -42,7 +36,7 @@ --line-strong: rgb(33 28 19 / 0.38); --line-focus: rgb(232 72 28 / 0.65); - /* Accent fills — flat tints stand in for gradient washes. */ + /* Accent fills: flat tints stand in for gradient washes. */ --accent: var(--tomato); --accent-text: var(--tomato-deep); --accent-soft: rgb(232 72 28 / 0.1); @@ -50,21 +44,20 @@ --on-accent: #fff6ea; --wash: rgb(33 28 19 / 0.02); - /* On-dark neutrals — the product mock and its notch stay dark so they read - as the deep-black hardware on top of the cream page. */ + /* On-dark neutrals: the product mock and its notch stay dark against the cream page. */ --on-dark: #f5f2ea; --on-dark-2: #b9b2a2; --on-dark-3: #7d7767; --on-dark-line: rgb(245 242 234 / 0.12); --on-dark-line-strong: rgb(245 242 234 / 0.22); - /* Type — Fraunces is the quirky editorial voice. */ + /* Type: Fraunces is the quirky editorial voice. */ --font-display: 'Fraunces Variable', 'Fraunces', Georgia, 'Times New Roman', serif; --font-body: 'Inter Variable', 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif; --font-mono: ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, Consolas, monospace; - /* Fluid type scale — big, poster-sized headlines. */ + /* Fluid type scale: big, poster-sized headlines. */ --fs-mega: clamp(2.9rem, 8.6vw, 7.25rem); --fs-display: clamp(2.35rem, 6.2vw, 5rem); --fs-h2: clamp(2rem, 5vw, 3.9rem); @@ -82,14 +75,14 @@ --maxw: 1240px; --maxw-narrow: 780px; - /* Radii — a little sharper than the café era. */ + /* Radii: a little sharper than the café era. */ --r-sm: 8px; --r-md: 14px; --r-lg: 20px; --r-xl: 26px; --r-pill: 999px; - /* Elevation — hard "print" shadows, never gradients. */ + /* Elevation: hard print shadows, never gradients. */ --shadow-1: 0 1px 0 rgb(33 28 19 / 0.1); --shadow-2: 5px 5px 0 rgb(33 28 19 / 0.92); --shadow-3: 8px 8px 0 rgb(33 28 19 / 0.85); @@ -104,7 +97,6 @@ --dur-slow: 620ms; } -/* ---------- 2. Reset & base ---------- */ *, *::before, *::after { @@ -118,7 +110,6 @@ html { -webkit-text-size-adjust: 100%; - scroll-behavior: smooth; scroll-padding-top: var(--header-h); } @@ -127,9 +118,7 @@ body { font-size: var(--fs-body); line-height: 1.62; color: var(--text-2); - /* No background here: the base colour lives on `html` (see app.html). If body - painted its own opaque background, it would sit on top of every negative - z-index layer — the grain, spotlight and wash — and bury them all. */ + /* No background here: an opaque body would bury the negative z-index grain, spotlight and wash. */ -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; text-rendering: optimizeLegibility; @@ -139,11 +128,7 @@ body { } /* - Ambient background. - - The depth comes from a single faint flat tint, the scroll-driven spotlight at - `z-index: -2`, and the film-grain layer rendered by `Grain.svelte` at - `z-index: -1`. Nothing gradient-based. + Ambient background: a flat wash at z-index -3, the scroll spotlight at -2 and the grain at -1. */ body::before { content: ''; @@ -198,7 +183,6 @@ textarea { color: var(--ink); } -/* ---------- 3. Typography ---------- */ h1, h2, h3, @@ -283,7 +267,7 @@ p { color: var(--text-3); } -/* Editorial highlighter — a flat swipe accent behind a word or two. */ +/* Editorial highlighter: a flat swipe accent behind a word or two. */ .hl { position: relative; display: inline-block; @@ -332,7 +316,6 @@ p { will-change: transform; } -/* ---------- 4. Layout ---------- */ .container { width: 100%; max-width: var(--maxw); @@ -349,8 +332,7 @@ p { .section { position: relative; - /* Tight top, generous bottom: when a nav link jumps here the heading lands - right below the sticky header, not after a wall of empty padding. */ + /* Tight top, generous bottom, so a nav jump lands the heading below the sticky header. */ padding-block: clamp(2rem, 4vw, 3rem) var(--section-y); } @@ -392,7 +374,24 @@ p { margin-inline: auto; } -/* ---------- 5. Eyebrow / chips / stickers ---------- */ +/* The way home on subpages; the header logo does this too, but a reader from the version chip should not have to know that. */ +.back { + display: inline-flex; + align-items: center; + gap: 0.4rem; + align-self: flex-start; + font-family: var(--font-mono); + font-size: var(--fs-micro); + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-3); + transition: color var(--dur-fast) var(--ease-out); +} + +.back:hover { + color: var(--accent-text); +} + .eyebrow { display: inline-flex; align-items: center; @@ -442,7 +441,14 @@ p { color: var(--accent-text); } -/* Rotating sticker badge — a little piece of tape-and-ink flair. */ +/* Versions keep their lowercase `v`, even inside an uppercase mono label. */ +.chip--version, +.version { + text-transform: none; + letter-spacing: 0.02em; +} + +/* Rotating sticker badge: tape-and-ink flair. */ .sticker { display: inline-flex; align-items: center; @@ -481,7 +487,6 @@ p { pointer-events: none; } -/* ---------- 6. Surfaces ---------- */ .glass { position: relative; border-radius: var(--r-lg); @@ -520,7 +525,6 @@ p { transform: translateY(-1px); } -/* ---------- 7. Buttons — chunky print accents ---------- */ .btn { position: relative; display: inline-flex; @@ -591,7 +595,6 @@ p { width: 100%; } -/* ---------- 8. Scroll reveal ---------- */ .js [data-reveal] { opacity: 0; will-change: opacity, transform; @@ -622,8 +625,7 @@ p { transform: none; } -/* Set by the reveal action when GSAP is unavailable, so content can never - be stuck invisible. */ +/* Set by the reveal action when GSAP is unavailable, so content can never be stuck invisible. */ .js [data-reveal].no-gsap, .js [data-stagger].no-gsap > * { transition: opacity var(--dur-slow) var(--ease-out), transform var(--dur-slow) var(--ease-out); @@ -665,7 +667,6 @@ p { } } -/* ---------- 9. Utilities ---------- */ .sr-only { position: absolute; width: 1px; @@ -705,7 +706,6 @@ p { border-top: 2px dashed var(--line-strong); } -/* ---------- 11. Scrollbar ---------- */ @media (pointer: fine) { ::-webkit-scrollbar { width: 11px; @@ -724,4 +724,4 @@ p { ::-webkit-scrollbar-thumb:hover { background: var(--ink-2); } -} \ No newline at end of file +} diff --git a/web/src/lib/actions/reveal.ts b/web/src/lib/actions/reveal.ts index 7f42a2d..e435bab 100644 --- a/web/src/lib/actions/reveal.ts +++ b/web/src/lib/actions/reveal.ts @@ -54,11 +54,8 @@ function scheduleRevealAll() { } /** - * Scroll-triggered entrance animation for a single element. - * - * The element is hidden by CSS (`.js [data-reveal]`) rather than by JS, so the - * initial state is applied before first paint — no flash — while a visitor - * without JavaScript still sees fully visible, crawlable content. + * Hides the element via CSS before first paint, so there is no flash and + * no-JS visitors still see the content. */ export function reveal(node: HTMLElement, options: RevealOptions = {}) { const { diff --git a/web/src/lib/components/CalendarNotchMock.svelte b/web/src/lib/components/CalendarNotchMock.svelte index 445de68..7ff13b2 100644 --- a/web/src/lib/components/CalendarNotchMock.svelte +++ b/web/src/lib/components/CalendarNotchMock.svelte @@ -1,13 +1,10 @@
- {connected ? 'Thursday' : 'Connecting'} - 26 + {connected ? calendarDemo.weekday : calendarDemo.connecting} + {calendarDemo.day}
- {connected ? 'Stand-up with the team · in 12 min' : 'Awaiting the code…'} + {connected ? calendarDemo.event : calendarDemo.waiting}
- {connected ? '2' : '·'} + {connected ? calendarDemo.count : calendarDemo.idle}
@@ -222,4 +216,4 @@ .cn--dim .cn-ticker__dot { background: transparent; } - \ No newline at end of file + diff --git a/web/src/lib/components/FaqSection.svelte b/web/src/lib/components/FaqSection.svelte index b5c4cfc..dcf9704 100644 --- a/web/src/lib/components/FaqSection.svelte +++ b/web/src/lib/components/FaqSection.svelte @@ -1,15 +1,10 @@ diff --git a/web/src/lib/components/KofiButton.svelte b/web/src/lib/components/KofiButton.svelte new file mode 100644 index 0000000..211f9a7 --- /dev/null +++ b/web/src/lib/components/KofiButton.svelte @@ -0,0 +1,66 @@ + + + + {support.kofiAlt} + + + diff --git a/web/src/lib/components/LookSection.svelte b/web/src/lib/components/LookSection.svelte index 8ed0d6c..8811d07 100644 --- a/web/src/lib/components/LookSection.svelte +++ b/web/src/lib/components/LookSection.svelte @@ -3,11 +3,8 @@ import { reveal, stagger } from '$lib/actions/reveal'; /** - * The look: three profiles, two finishes, one row. - * - * The old section spent three tall cards and three more rows on this — a - * screen of scrolling for two dropdowns. A swatch plus one line each says the - * same thing, and the tour above already shows the real shapes at scale. + * Three profiles, two finishes, one row; the tour above already shows the + * real shapes. */ @@ -25,7 +22,7 @@ {#each themes as theme, index (theme.id)}
  • @@ -38,7 +35,7 @@ {#each surfaces as surface (surface.id)}
  • diff --git a/web/src/lib/components/Nav.svelte b/web/src/lib/components/Nav.svelte index ecd3111..adf5ba9 100644 --- a/web/src/lib/components/Nav.svelte +++ b/web/src/lib/components/Nav.svelte @@ -1,19 +1,11 @@ - +