diff --git a/.github/workflows/desktop2_cd.yaml b/.github/workflows/desktop2_cd.yaml new file mode 100644 index 0000000000..96d2777213 --- /dev/null +++ b/.github/workflows/desktop2_cd.yaml @@ -0,0 +1,294 @@ +# Electron CD for `@hypr/desktop2`. +# +# Contrasts with `desktop_cd.yaml` (Tauri): +# - no CrabNebula draft/upload/publish — distribution is a GitHub Release +# directly (ncipollo/release-action), same pattern as the Tauri workflow's +# final `release` job. Auto-update feed (`latest-mac.yml`) is intentionally +# not published yet; `electron-updater` wiring is a separate slice. +# - reuses `./.github/actions/apple_cert` to import the Developer ID cert +# (the composite is framework-agnostic). +# - `./.github/actions/macos_notarize_dmg` is NOT invoked — electron-builder +# notarizes + staples the outer `.dmg` via `mac.notarize: true` in +# `electron-builder.config.ts`. +# - no Rust sidecar and no Tauri bundler. Rust work is limited to +# `@hypr/napi-sdk` (NAPI) plus the embedded `char` CLI binary that is +# packaged into the Electron app bundle. +on: + workflow_dispatch: + inputs: + channel: + description: "Release channel" + required: true + type: choice + options: + - staging + - nightly + - stable + publish: + description: "Create GitHub release and tag (ignored for staging)" + type: boolean + default: true + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.channel }} + cancel-in-progress: true + +env: + RELEASE_CHANNEL: ${{ inputs.channel }} + NODE_OPTIONS: "--max-old-space-size=4096" + +jobs: + compute-version: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@v4 + with: + lfs: true + fetch-depth: 0 + fetch-tags: true + - run: | + git remote set-url origin "https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git" + git fetch --tags --force + - uses: ./.github/actions/doxxer_install + - id: version + run: | + if [[ "${{ inputs.channel }}" == "staging" ]]; then + VERSION=$(doxxer --config doxxer.desktop2.toml next dev) + elif [[ "${{ inputs.channel }}" == "nightly" ]]; then + LATEST_TAG=$(git tag -l 'desktop2_v*' --sort=-creatordate | head -n1) + if [[ "$LATEST_TAG" == *"-nightly"* ]]; then + VERSION=$(doxxer --config doxxer.desktop2.toml next prerelease) + else + VERSION=$(doxxer --config doxxer.desktop2.toml next pre-patch) + fi + elif [[ "${{ inputs.channel }}" == "stable" ]]; then + VERSION=$(doxxer --config doxxer.desktop2.toml next patch) + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Computed version: $VERSION" + + build-macos: + needs: compute-version + permissions: + contents: write + runs-on: depot-macos-15 + strategy: + fail-fast: true + # Staging builds only the host (arm64) arch to keep the iteration loop + # short. Stable/nightly ship both intel + silicon, matching desktop. + matrix: + include: ${{ inputs.channel == 'staging' && fromJSON('[{"target":"aarch64-apple-darwin","arch":"arm64","artifact_name":"silicon"}]') || fromJSON('[{"target":"aarch64-apple-darwin","arch":"arm64","artifact_name":"silicon"},{"target":"x86_64-apple-darwin","arch":"x64","artifact_name":"intel"}]') }} + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 + with: + lfs: true + fetch-depth: 0 + fetch-tags: true + submodules: recursive + + - uses: ./.github/actions/macos_tcc + + - name: Stamp version into apps/desktop2/package.json + run: ./scripts/version.sh "./apps/desktop2/package.json" "${{ needs.compute-version.outputs.version }}" + + - uses: ./.github/actions/rust_install + with: + platform: macos + + - run: echo "SDKROOT=$(xcrun --sdk macosx --show-sdk-path)" >> $GITHUB_ENV + + - uses: ./.github/actions/pnpm_install + + # Builds `apps/cli` → `apps/desktop2/binaries/char-cli-`. + # `electron-builder.config.ts#mac.extraFiles` picks the matching triple + # by `process.arch` / `ELECTRON_BUILDER_ARCH` and drops it under + # `.app/Contents/MacOS/char-cli` where the Developer ID identity + # co-signs it during the main packaging pass. Runtime resolver is + # `electron/src/paths.ts::embeddedCliPath()`. + - name: Build embedded `char` CLI for ${{ matrix.target }} + env: + TAURI_ENV_TARGET_TRIPLE: ${{ matrix.target }} + run: pnpm -F @hypr/desktop2 build:embedded-cli + + - name: Build @hypr/desktop2 (UI + electron + NAPI for ${{ matrix.target }}) + env: + # `napi build` respects `CARGO_BUILD_TARGET` (verified against + # @napi-rs/cli v3.6.2); this pins the NAPI `.node` to `matrix.target` + # so electron-builder doesn't pack the host-arch binary by accident. + # Turbo cascades via desktop2's `^build` dependency to @hypr/napi-sdk. + CARGO_BUILD_TARGET: ${{ matrix.target }} + # Channel drives appId / productName / userData via `channel.ts`; + # threaded into the build so any build-time codegen can read it too. + HYPR_CHANNEL: ${{ inputs.channel }} + run: pnpm turbo run build -F @hypr/desktop2 + + - uses: ./.github/actions/apple_cert + id: apple-cert + with: + apple-certificate: ${{ secrets.APPLE_CERTIFICATE }} + apple-certificate-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + keychain-password: ${{ secrets.KEYCHAIN_PASSWORD }} + + - name: electron-builder — package + sign + notarize (${{ matrix.arch }}) + working-directory: apps/desktop2 + env: + # Channel selects appId / productName / executableName / icons / + # DMG background / artifactName in `electron-builder.config.ts`, + # and is stamped into the packaged `package.json` via + # `extraMetadata.hyprChannel` for `electron/src/channel.ts`. + HYPR_CHANNEL: ${{ inputs.channel }} + # Code signing: rely on keychain identity already imported by + # `apple_cert`. `CSC_NAME` pins the identity so electron-builder + # doesn't guess when multiple certs are present. + CSC_NAME: ${{ steps.apple-cert.outputs.cert-id }} + CSC_IDENTITY_AUTO_DISCOVERY: "true" + # Notarization via notarytool (`@electron/notarize`). `mac.notarize` + # in `electron-builder.config.ts` is gated on `CI=true && APPLE_ID`. + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + # Don't let electron-builder itself publish — we upload via the + # `release` job below, so local failures don't orphan GH releases. + GH_TOKEN: "" + # Pinned so `embeddedCliTriple()` in `electron-builder.config.ts` + # resolves to the exact binary built in the prior step, even though + # electron-builder is invoked once per arch. + ELECTRON_BUILDER_ARCH: ${{ matrix.arch }} + run: | + pnpm exec electron-builder \ + --mac \ + --${{ matrix.arch }} \ + --config electron-builder.config.ts \ + --publish never + + - id: find-dmg + run: | + DMG_FILE=$(find apps/desktop2/release -maxdepth 1 -name "*.dmg" -type f | head -1) + if [[ -z "$DMG_FILE" ]]; then + echo "::error::No DMG found in apps/desktop2/release" + ls -la apps/desktop2/release || true + exit 1 + fi + echo "path=$DMG_FILE" >> $GITHUB_OUTPUT + echo "name=$(basename "$DMG_FILE")" >> $GITHUB_OUTPUT + + - name: Verify signature + notarization + run: | + codesign --verify --deep --strict --verbose=2 "${{ steps.find-dmg.outputs.path }}" + spctl -a -t open -vvv --context context:primary-signature "${{ steps.find-dmg.outputs.path }}" + + # Upload name is channel-agnostic; the dmg inside keeps electron-builder's + # channel-prefixed `artifactName` (e.g. `char-nightly--.dmg`). + - uses: actions/upload-artifact@v4 + with: + name: dmg-${{ inputs.channel }}-${{ matrix.artifact_name }} + path: ${{ steps.find-dmg.outputs.path }} + retention-days: 7 + + create-tag: + if: ${{ inputs.channel != 'staging' && !cancelled() && needs.build-macos.result == 'success' }} + needs: [compute-version, build-macos] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + - uses: mathieudutour/github-tag-action@v6.2 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + custom_tag: desktop2_v${{ needs.compute-version.outputs.version }} + tag_prefix: "" + + release: + if: ${{ inputs.channel != 'staging' && inputs.publish && !cancelled() && needs.build-macos.result == 'success' && needs.create-tag.result == 'success' }} + needs: [compute-version, build-macos, create-tag] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + - run: git fetch --tags --force + + # DMG filenames are channel-prefixed by `electron-builder.config.ts`'s + # per-channel `artifactName` (e.g. `char-nightly--arm64.dmg`). + # We flatten both arches into `./dist/` and upload to the release as-is. + - uses: actions/download-artifact@v4 + with: + pattern: dmg-${{ inputs.channel }}-* + merge-multiple: true + path: dist + + - id: collect + run: | + set -euo pipefail + FILES=() + for f in dist/*.dmg; do + FILES+=("$f") + done + if [[ ${#FILES[@]} -eq 0 ]]; then + echo "::error::No DMGs found to release" + ls -la dist || true + exit 1 + fi + printf 'files<> "$GITHUB_OUTPUT" + + - id: checksums + uses: ./.github/actions/generate_checksums + with: + files: ${{ steps.collect.outputs.files }} + + - id: release-body + env: + CHANNEL: ${{ inputs.channel }} + VERSION: ${{ needs.compute-version.outputs.version }} + run: | + if [[ "$CHANNEL" != "nightly" ]]; then + echo "value=https://char.com/changelog/$VERSION" >> "$GITHUB_OUTPUT" + exit 0 + fi + + CURRENT_TAG="desktop2_v$VERSION" + PREV_TAG="" + LAST_TAG="" + LAST_NIGHTLY_TAG="" + + while IFS= read -r tag; do + if [[ "$tag" == "$CURRENT_TAG" ]]; then + PREV_TAG="$LAST_NIGHTLY_TAG" + if [[ -z "$PREV_TAG" ]]; then + PREV_TAG="$LAST_TAG" + fi + break + fi + + if [[ "$tag" == *"-nightly."* ]]; then + LAST_NIGHTLY_TAG="$tag" + fi + + LAST_TAG="$tag" + done < <(git tag -l 'desktop2_v*' --sort=v:refname) + + if [[ -n "$PREV_TAG" ]]; then + echo "value=https://github.com/${{ github.repository }}/compare/$PREV_TAG...$CURRENT_TAG" >> "$GITHUB_OUTPUT" + else + echo "value=https://github.com/${{ github.repository }}/commits/${{ github.sha }}" >> "$GITHUB_OUTPUT" + fi + + - uses: ncipollo/release-action@v1 + with: + tag: desktop2_v${{ needs.compute-version.outputs.version }} + name: desktop2_v${{ needs.compute-version.outputs.version }} + body: ${{ steps.release-body.outputs.value }} + prerelease: ${{ inputs.channel == 'nightly' }} + makeLatest: ${{ inputs.channel == 'nightly' && 'false' || 'true' }} + # Artifact names are channel-prefixed by electron-builder itself + # (e.g. `char-nightly--.dmg`), so a glob is enough. + artifacts: "dist/*.dmg,dist/*.sha256" diff --git a/Cargo.lock b/Cargo.lock index fb698a2033..16b3a8466b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4114,6 +4114,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "convert_case" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cookie" version = "0.18.1" @@ -4591,6 +4600,22 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "ctor" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95d0d11eb38e7642efca359c3cf6eb7b2e528182d09110165de70192b0352775" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7ab264ea985f1bd27887d7b21ea2bb046728e05d11909ca138d700c494730db" + [[package]] name = "ctutils" version = "0.4.2" @@ -4879,6 +4904,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "db-app2" +version = "0.1.0" +dependencies = [ + "db-core", + "db-migrate", + "serde", + "specta", + "sqlx", + "tokio", +] + [[package]] name = "db-change" version = "0.1.0" @@ -5660,6 +5697,21 @@ dependencies = [ "dtoa", ] +[[package]] +name = "dtor" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17f72721db8027a4e96dd6fb50d2a1d32259c9d3da1b63dee612ccd981e14293" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c98b077c7463d01d22dde8a24378ddf1ca7263dc687cffbed38819ea6c21131" + [[package]] name = "dunce" version = "1.0.5" @@ -9625,10 +9677,38 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef68590049bab63a464eee1a1158ac04c6f6613a546d8d90f78636b8b94f171" +[[package]] +name = "hypr-api" +version = "0.1.0" +dependencies = [ + "db-app2", + "db-core", + "db-execute", + "db-migrate", + "db-reactive", + "serde_json", + "sqlx", + "thiserror 2.0.18", + "tokio", + "tracing", +] + [[package]] name = "hypr-http-utils" version = "0.1.0" +[[package]] +name = "hypr-napi" +version = "0.1.0" +dependencies = [ + "hypr-api", + "napi", + "napi-build", + "napi-derive", + "serde_json", + "tokio", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -10833,6 +10913,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + [[package]] name = "libm" version = "0.2.16" @@ -11883,6 +11973,7 @@ name = "mobile-bridge" version = "0.1.0" dependencies = [ "db-app", + "db-app2", "db-core", "db-execute", "db-migrate", @@ -12037,6 +12128,66 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11ec1bc47d34ae756616f387c11fd0595f86f2cc7e6473bde9e3ded30cb902a1" +[[package]] +name = "napi" +version = "3.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa73b028610e2b26e9e40bd2c8ff8a98e6d7ed5d67d89ebf4bfd2f992616b024" +dependencies = [ + "bitflags 2.11.1", + "ctor 0.10.0", + "futures", + "napi-build", + "napi-sys", + "nohash-hasher", + "rustc-hash 2.1.2", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "napi-build" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d376940fd5b723c6893cd1ee3f33abbfd86acb1cd1ec079f3ab04a2a3bc4d3b1" + +[[package]] +name = "napi-derive" +version = "3.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7430702d3cc05cf55f0a2c9e41d991c3b7a53f91e6146a8f282b1bfc7f3fd133" +dependencies = [ + "convert_case 0.11.0", + "ctor 0.10.0", + "napi-derive-backend", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "napi-derive-backend" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ca5a083f2c9b49a0c7d33ec75c083498849c6fcc46f5497317faa39ea77f5d5" +dependencies = [ + "convert_case 0.11.0", + "proc-macro2", + "quote", + "semver", + "syn 2.0.117", +] + +[[package]] +name = "napi-sys" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eb602b84d7c1edae45e50bbf1374696548f36ae179dfa667f577e384bb90c2b" +dependencies = [ + "libloading 0.9.0", +] + [[package]] name = "native-tls" version = "0.2.18" @@ -12149,6 +12300,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + [[package]] name = "nom" version = "7.1.3" @@ -20200,7 +20357,7 @@ dependencies = [ "anyhow", "brotli", "cargo_metadata 0.19.2", - "ctor", + "ctor 0.2.9", "dunce", "glob", "html5ever 0.29.1", diff --git a/Cargo.toml b/Cargo.toml index 8f32839ab0..449ce2fb0f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ hypr-agent-core = { path = "crates/agent-core", package = "agent-core" } hypr-am = { path = "crates/am", package = "am" } hypr-amp = { path = "crates/amp", package = "amp" } hypr-analytics = { path = "crates/analytics", package = "analytics" } +hypr-api = { path = "crates/hypr-api", package = "hypr-api" } hypr-api-agent = { path = "crates/api-agent", package = "api-agent" } hypr-api-auth = { path = "crates/api-auth", package = "api-auth" } hypr-api-bot = { path = "crates/api-bot", package = "api-bot" } @@ -86,6 +87,7 @@ hypr-codex = { path = "crates/codex", package = "codex" } hypr-cursor = { path = "crates/cursor", package = "cursor" } hypr-data = { path = "crates/data", package = "data" } hypr-db-app = { path = "crates/db-app", package = "db-app" } +hypr-db-app2 = { path = "crates/db-app2", package = "db-app2" } hypr-db-change = { path = "crates/db-change", package = "db-change" } hypr-db-cli = { path = "crates/db-cli", package = "db-cli" } hypr-db-core = { path = "crates/db-core", package = "db-core" } diff --git a/apps/desktop2/.gitignore b/apps/desktop2/.gitignore new file mode 100644 index 0000000000..d3a20fe080 --- /dev/null +++ b/apps/desktop2/.gitignore @@ -0,0 +1,16 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +node_modules +dist +release +binaries +*.local + +# Editor files +.DS_Store diff --git a/apps/desktop2/AGENTS.md b/apps/desktop2/AGENTS.md new file mode 100644 index 0000000000..e404e52aa1 --- /dev/null +++ b/apps/desktop2/AGENTS.md @@ -0,0 +1,139 @@ +# `@hypr/desktop2` + +Electron port of the note-taking desktop app. Context on the port and +non-goals is in `README.md`. + +## Commands + +- Typecheck: `pnpm -F @hypr/desktop2 check` +- Dev: `pnpm turbo run dev -F @hypr/desktop2` +- Build: `pnpm turbo run build -F @hypr/desktop2` +- Bundle: `pnpm turbo run bundle -F @hypr/desktop2` +- Repo format after edits: `pnpm exec dprint fmt` + +Run through `turbo` for dev/build/bundle so `@hypr/napi-sdk` (NAPI) rebuilds +upstream. Direct `pnpm -F @hypr/desktop2 ", + "chrome://settings", + "hyprnote://deep-link", + ])("rejects %s", (url) => { + expect(isAllowedExternalUrl(url)).toBe(false); + }); + + it("rejects malformed URLs", () => { + expect(isAllowedExternalUrl("not a url")).toBe(false); + expect(isAllowedExternalUrl("")).toBe(false); + expect(isAllowedExternalUrl("//example.com/no-scheme")).toBe(false); + }); +}); diff --git a/apps/desktop2/src/main/url-allowlist.ts b/apps/desktop2/src/main/url-allowlist.ts new file mode 100644 index 0000000000..78178d3847 --- /dev/null +++ b/apps/desktop2/src/main/url-allowlist.ts @@ -0,0 +1,24 @@ +// Renderer-reachable URL schemes for `shell.openExternal`. Anything else +// is rejected before it hits the OS — if the renderer is ever compromised +// (XSS, malicious rendered content) we don't want arbitrary protocol +// handlers to be a privilege-escalation vector (`file://`, `javascript:`, +// custom registered handlers like `vscode://`, `slack://`, etc). +// +// Additions to this list require a matching risk review: +// - `http:` / `https:` launch the default browser. +// - `mailto:` launches the default mail client with a pre-filled draft. +// Anything more invasive (deep links, custom schemes) needs a dedicated +// IPC handler, not this generic surface. +export const OPEN_EXTERNAL_ALLOWED_SCHEMES: ReadonlySet = new Set([ + "http:", + "https:", + "mailto:", +]); + +export function isAllowedExternalUrl(raw: string): boolean { + try { + return OPEN_EXTERNAL_ALLOWED_SCHEMES.has(new URL(raw).protocol); + } catch { + return false; + } +} diff --git a/apps/desktop2/src/main/window.ts b/apps/desktop2/src/main/window.ts new file mode 100644 index 0000000000..e495cc1996 --- /dev/null +++ b/apps/desktop2/src/main/window.ts @@ -0,0 +1,175 @@ +import { BrowserWindow, Menu, MenuItem, app, shell } from "electron"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { CHANNEL } from "./channel.js"; +import { isAllowedExternalUrl } from "./url-allowlist.js"; + +const currentDir = path.dirname(fileURLToPath(import.meta.url)); +const DEV_URL = process.env.ELECTRON_RENDERER_URL; + +type MainWindowOptions = { + preloadPath?: string; +}; + +// Mirrors `plugins/windows/src/window/v1.rs::AppWindow::Main`. +// On macOS we let traffic-light controls show but hide the title and let +// content extend under the title bar. On Windows/Linux we drop decorations +// entirely (`frame: false`), matching `decorations(false)` in the Tauri side. +export function createMainWindow(opts: MainWindowOptions = {}): BrowserWindow { + const preload = opts.preloadPath ?? path.join(currentDir, "preload.cjs"); + const isDev = !app.isPackaged; + + const window = new BrowserWindow({ + width: 910, + height: 600, + minWidth: 620, + minHeight: 500, + show: false, + // Paint the native frame with the stone-50 canvas color the UI uses, so + // resizes and the pre-mount flash don't show a white seam under the + // rounded content panels. + backgroundColor: "#fafaf9", + ...platformChrome(isDev), + webPreferences: { + preload, + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + }); + + window.once("ready-to-show", () => window.show()); + + // Mirrors `.disable_drag_drop_handler()` on the Tauri builder: prevent the + // webview from navigating to a file when the user drops one into the window. + window.webContents.on("will-navigate", (event, url) => { + if (DEV_URL && url.startsWith(DEV_URL)) return; + if (!DEV_URL && url.startsWith("file://")) return; + event.preventDefault(); + }); + + // External links open in the user's browser rather than a new Electron window. + // Gated through the same allowlist as the `hypr:openExternal` IPC so a + // compromised renderer can't reach `file://`, `javascript:`, or custom + // protocol handlers via `window.open` / `target="_blank"` either. + window.webContents.setWindowOpenHandler(({ url }) => { + if (isAllowedExternalUrl(url)) { + void shell.openExternal(url); + } + return { action: "deny" }; + }); + + if (isDebugBuild(isDev)) { + attachDebugContextMenu(window); + } + + void loadContent(window, isDev); + + return window; +} + +function platformChrome( + isDev: boolean, +): Electron.BrowserWindowConstructorOptions { + switch (process.platform) { + case "darwin": + // Light theme is applied globally via `nativeTheme.themeSource`, matching + // Tauri's `theme(Some(tauri::Theme::Light))`. + return { + titleBarStyle: "hiddenInset", + trafficLightPosition: { x: 12, y: macosTrafficLightY(isDev) }, + }; + case "win32": + case "linux": + default: + return { frame: false }; + } +} + +// Vertically center the traffic lights inside the 40px (`h-10`) top bar that +// `src/renderer/shell/shell.view.tsx` renders, matching the `apps/desktop/src/main2/shell.tsx` +// reference. The y value does NOT translate 1:1 from Tauri's +// `traffic_light_position(12, 18)` even though both apps use the same top-bar +// CSS: Tauri's `TitleBarStyle::Overlay` places web content flush with the +// window top (y=0), while Electron's `hiddenInset` anchors the traffic-light +// widget higher than where the h-10 flex row's size-7 button lands, so we +// need a larger y to meet the button's visual center. +// +// If the traffic lights still look high/low compared to the home icon after +// a macOS update, nudge these two numbers — they are the entire contract. +function macosTrafficLightY(isDev: boolean): number { + if (process.platform !== "darwin") { + return 10; + } + + // Electron exposes the macOS product version (e.g. "26.0.0") via + // `process.getSystemVersion`, which maps cleanly to Tauri's `tauri_plugin_os`. + const version = process.getSystemVersion?.() ?? ""; + const major = Number.parseInt(version.split(".")[0] ?? "0", 10); + if (!Number.isFinite(major)) { + return 10; + } + + // macOS 26 (Tahoe) dev builds ship a taller traffic-light row in WebKit — + // same reason the Tauri plugin bumps 18 → 24 in its equivalent branch. + return major >= 26 && isDev ? 16 : 10; +} + +// Electron ships no default context menu. We enable a minimal one on dev +// (`pnpm dev`), staging, and nightly builds so developers and internal testers +// get copy/paste + Inspect Element, while stable production builds remain +// locked down like the Tauri app. +function isDebugBuild(isDev: boolean): boolean { + return isDev || CHANNEL !== "stable"; +} + +function attachDebugContextMenu(window: BrowserWindow): void { + window.webContents.on("context-menu", (_event, params) => { + const menu = new Menu(); + const { editFlags } = params; + + if (editFlags.canCut) menu.append(new MenuItem({ role: "cut" })); + if (editFlags.canCopy) menu.append(new MenuItem({ role: "copy" })); + if (editFlags.canPaste) menu.append(new MenuItem({ role: "paste" })); + if (editFlags.canSelectAll) { + menu.append(new MenuItem({ role: "selectAll" })); + } + + if (menu.items.length > 0) { + menu.append(new MenuItem({ type: "separator" })); + } + + menu.append( + new MenuItem({ + label: "Inspect Element", + click: () => window.webContents.inspectElement(params.x, params.y), + }), + ); + menu.append( + new MenuItem({ + label: "Toggle DevTools", + accelerator: + process.platform === "darwin" ? "Alt+Command+I" : "Ctrl+Shift+I", + click: () => window.webContents.toggleDevTools(), + }), + ); + + menu.popup({ window }); + }); +} + +async function loadContent( + window: BrowserWindow, + isDev: boolean, +): Promise { + if (isDev && DEV_URL) { + // Don't auto-open DevTools — it's jarring as a detached window on every + // launch, and electron-vite's dev server still honors the default + // Electron shortcut (⌥⌘I / Ctrl+Shift+I) when the developer wants it. + await window.loadURL(DEV_URL); + return; + } + + await window.loadFile(path.join(currentDir, "..", "ui", "index.html")); +} diff --git a/apps/desktop2/src/preload/preload.cts b/apps/desktop2/src/preload/preload.cts new file mode 100644 index 0000000000..c5f7878fce --- /dev/null +++ b/apps/desktop2/src/preload/preload.cts @@ -0,0 +1,107 @@ +import { contextBridge, ipcRenderer } from "electron"; + +import type { + DrizzleProxyClient, + LiveQueryClient, + ProxyQueryMethod, + ProxyQueryResult, + QueryEvent, + Row, + Unsubscribe, +} from "@hypr/db-runtime"; + +import type { HyprElectronApi } from "../shared/api"; +import { hyprIpcChannels } from "../shared/channels"; +import type { DbSubscribeResult } from "../shared/subscribe"; +import type { UpdaterEvent } from "../shared/updater"; +import { updaterIpcChannels } from "../shared/updater"; + +const dbClient: DrizzleProxyClient & LiveQueryClient = { + execute: (sql: string, params: unknown[] = []) => + ipcRenderer.invoke(hyprIpcChannels.dbExecute, sql, params) as Promise, + + executeProxy: ( + sql: string, + params: unknown[], + method: ProxyQueryMethod, + ): Promise => + ipcRenderer.invoke( + hyprIpcChannels.dbExecuteProxy, + sql, + params, + method, + ) as Promise, + + subscribe: async ( + sql: string, + params: unknown[], + options: { + onData: (rows: T[]) => void; + onError?: (error: string) => void; + }, + ): Promise => { + const { channel, reactive } = (await ipcRenderer.invoke( + hyprIpcChannels.dbSubscribe, + sql, + params, + )) as DbSubscribeResult; + + if (!reactive) { + console.warn( + `[desktop2] live query subscription is non-reactive for SQL "${sql}"`, + ); + } + + const listener = ( + _event: Electron.IpcRendererEvent, + delta: QueryEvent, + ) => { + if (delta.event === "result") { + options.onData(delta.data); + return; + } + + options.onError?.(delta.data); + }; + ipcRenderer.on(channel, listener); + + return async () => { + ipcRenderer.removeListener(channel, listener); + await ipcRenderer.invoke(hyprIpcChannels.dbUnsubscribe, channel); + }; + }, +}; + +const api: HyprElectronApi = { + db: dbClient, + openExternal: (url) => ipcRenderer.invoke(hyprIpcChannels.openExternal, url), + embeddedCli: { + check: () => ipcRenderer.invoke(hyprIpcChannels.embeddedCliCheck), + install: () => ipcRenderer.invoke(hyprIpcChannels.embeddedCliInstall), + uninstall: () => ipcRenderer.invoke(hyprIpcChannels.embeddedCliUninstall), + }, + updater: { + check: () => ipcRenderer.invoke(updaterIpcChannels.check), + install: () => ipcRenderer.invoke(updaterIpcChannels.install), + subscribe: (callback) => { + const listener = ( + _event: Electron.IpcRendererEvent, + event: UpdaterEvent, + ) => { + callback(event); + }; + ipcRenderer.on(updaterIpcChannels.event, listener); + return () => { + ipcRenderer.removeListener(updaterIpcChannels.event, listener); + }; + }, + }, +}; + +contextBridge.exposeInMainWorld("hypr", api); + +// Renderer-side platform awareness. The UI uses this to apply a drag region +// and traffic-light padding on macOS without guessing from the user agent. +contextBridge.exposeInMainWorld("hyprPlatform", { + os: process.platform, +}); diff --git a/apps/desktop2/src/renderer/app/app-providers.view.tsx b/apps/desktop2/src/renderer/app/app-providers.view.tsx new file mode 100644 index 0000000000..3b66cf3928 --- /dev/null +++ b/apps/desktop2/src/renderer/app/app-providers.view.tsx @@ -0,0 +1,28 @@ +import type { ReactNode } from "react"; + +/** + * Single provider stack for the renderer, wrapped around the shell + any + * route-level content. + * + * Analogue of `apps/desktop/src/main2/layout.tsx#Main2Layout`. Today it is a + * passthrough because the desktop2 slice deliberately does not port the Tauri + * app's context graph (no TinyBase, no search engine, no AI task store, no + * notification channel). Providers that the Tauri shell composes through + * `Main2Layout` land here when their underlying services port over, in this + * order: + * + * NotificationProvider // port of ~/contexts/notifications + * SearchEngineProvider // once search indexing is wired + * SearchUIProvider // overlay / shortcut state + * ShellProvider // split-pane + chat + sidebar state + * ToolRegistryProvider // AI tool registry, once ported + * AITaskProvider // AI task queue, once ported + * + * The boundary rules of `apps/desktop2/AGENTS.md` still apply: nothing here + * may import from `src/main/**` or `src/preload/**`. Providers must talk to + * the Electron main process exclusively through `~/bridge` (which wraps + * `window.hypr`, typed by `src/shared/api.ts`). + */ +export function AppProvidersView({ children }: { children: ReactNode }) { + return <>{children}; +} diff --git a/apps/desktop2/src/renderer/app/app-root.container.tsx b/apps/desktop2/src/renderer/app/app-root.container.tsx new file mode 100644 index 0000000000..73b8a04026 --- /dev/null +++ b/apps/desktop2/src/renderer/app/app-root.container.tsx @@ -0,0 +1,33 @@ +import { AppProvidersView } from "~/app/app-providers.view"; +import { useAppLifecycle } from "~/app/use-app-lifecycle"; +import { HomeContainer } from "~/home"; +import { ShellContainer } from "~/shell"; +import { selectCurrentTab, TabContentView, useTabsStore } from "~/tabs"; + +/** + * Composition root mounted by the router's root route. + * + * Mirrors the Tauri `/app/main2/_layout` route (see + * `apps/desktop/src/routes/app/main2/_layout.tsx` + + * `apps/desktop/src/main2/layout.tsx`): run app-wide lifecycle, install the + * provider stack, then render the shell with tab-aware body content. + * + * Keep this container thin. Provider wiring belongs in `AppProvidersView`, + * effect wiring in `useAppLifecycle`. Tab → body resolution stays here because + * it is the only place that knows the shell shape. + */ +export function AppRootContainer() { + useAppLifecycle(); + + const currentTab = useTabsStore(selectCurrentTab); + + return ( + + } /> + } + /> + + ); +} diff --git a/apps/desktop2/src/renderer/app/index.ts b/apps/desktop2/src/renderer/app/index.ts new file mode 100644 index 0000000000..a2dee788de --- /dev/null +++ b/apps/desktop2/src/renderer/app/index.ts @@ -0,0 +1,3 @@ +export { AppProvidersView } from "~/app/app-providers.view"; +export { AppRootContainer } from "~/app/app-root.container"; +export { useAppLifecycle } from "~/app/use-app-lifecycle"; diff --git a/apps/desktop2/src/renderer/app/use-app-lifecycle.ts b/apps/desktop2/src/renderer/app/use-app-lifecycle.ts new file mode 100644 index 0000000000..717a00019c --- /dev/null +++ b/apps/desktop2/src/renderer/app/use-app-lifecycle.ts @@ -0,0 +1,22 @@ +/** + * Single place where app-wide startup / teardown effects run. + * + * Analogue of `apps/desktop/src/main2/lifecycle.tsx#useMain2Lifecycle`. It is + * intentionally a no-op today: the point is that there is exactly one stable + * seam in the React tree where future ports plug their startup work in, rather + * than each feature sprinkling `useEffect`s into the renderer root. + * + * Candidate additions (ported from the Tauri app): + * - Electron-side tab restoration / pin hydration (`useDesktopTabLifecycle`). + * - STT listener bootstrap once the listener stack lands. + * - Hoisted updater subscription so `use-update` survives `UpdateBanner` + * remounts (today the snapshot is banner-local). + * - Global shortcut / keyboard manager install. + * + * Keep this hook side-effect-only. Return values belong in dedicated hooks or + * stores; this one is for wiring, not for data. + */ +export function useAppLifecycle(): void { + // Stable extension point. Add effects here, not in `AppRootContainer` or + // `main.tsx`. +} diff --git a/apps/desktop2/src/renderer/bridge.ts b/apps/desktop2/src/renderer/bridge.ts new file mode 100644 index 0000000000..037f117cd4 --- /dev/null +++ b/apps/desktop2/src/renderer/bridge.ts @@ -0,0 +1,3 @@ +export const hypr = window.hypr; +export const platform = window.hyprPlatform; +export const isMac = platform.os === "darwin"; diff --git a/apps/desktop2/src/renderer/db.ts b/apps/desktop2/src/renderer/db.ts new file mode 100644 index 0000000000..1828e60c71 --- /dev/null +++ b/apps/desktop2/src/renderer/db.ts @@ -0,0 +1,12 @@ +import { createDb } from "@hypr/db"; +import { electronLiveQueryClient } from "@hypr/db-electron"; +import { createUseDrizzleLiveQuery, createUseLiveQuery } from "@hypr/db-react"; + +// Renderer-side data-access singleton. Mirrors `apps/desktop/src/db/index.ts` +// on the Tauri side — same drizzle handle, same hooks, different transport. +// Hook usage: `useDrizzleLiveQuery(db.select().from(sessions)...)`. +export const db = createDb(electronLiveQueryClient); +export const useLiveQuery = createUseLiveQuery(electronLiveQueryClient); +export const useDrizzleLiveQuery = createUseDrizzleLiveQuery( + electronLiveQueryClient, +); diff --git a/apps/desktop2/src/renderer/electron.d.ts b/apps/desktop2/src/renderer/electron.d.ts new file mode 100644 index 0000000000..3ec89454b2 --- /dev/null +++ b/apps/desktop2/src/renderer/electron.d.ts @@ -0,0 +1,15 @@ +/// +import type { HyprElectronApi } from "../shared/api"; + +export type HyprPlatform = { + os: NodeJS.Platform; +}; + +declare global { + interface Window { + hypr: HyprElectronApi; + hyprPlatform: HyprPlatform; + } +} + +export {}; diff --git a/apps/desktop2/src/renderer/home/constants.ts b/apps/desktop2/src/renderer/home/constants.ts new file mode 100644 index 0000000000..403668ce96 --- /dev/null +++ b/apps/desktop2/src/renderer/home/constants.ts @@ -0,0 +1 @@ +export const dailyNoteSectionClassName = "flex min-h-[400px] flex-col"; diff --git a/apps/desktop2/src/renderer/home/daily-note-editor/daily-note-editor.container.test.tsx b/apps/desktop2/src/renderer/home/daily-note-editor/daily-note-editor.container.test.tsx new file mode 100644 index 0000000000..8775c29dc8 --- /dev/null +++ b/apps/desktop2/src/renderer/home/daily-note-editor/daily-note-editor.container.test.tsx @@ -0,0 +1,80 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { queryClient } from "~/query-client"; + +// Stub the tiptap-backed editor with a plain textarea so the container's +// subscribe/debounce/mutation wiring can be driven from DOM events. The +// real editor is exercised by its own package tests. +vi.mock("@hypr/editor/daily", () => ({ + DailyNoteEditor: ({ + value, + onChange, + placeholder, + }: { + value: string; + onChange: (next: string) => void; + placeholder?: string; + }) => ( +