From 2e885c862f9321423ca797e81078887ab22f5f92 Mon Sep 17 00:00:00 2001 From: Jonathan Borgwing Date: Sat, 18 Jul 2026 14:44:12 -0400 Subject: [PATCH 1/3] feat: ship v0.2.1 adapters and menu bar updates --- .github/workflows/ci.yml | 18 + .github/workflows/finalize-release.yml | 136 ++ .github/workflows/release.yml | 145 ++- Cargo.lock | 1094 ++++++++++++++++- Cargo.toml | 2 +- INSTALL.md | 24 +- Makefile | 3 +- PRIVACY.md | 21 +- README.md | 13 +- adapters/cursor/.cursor-plugin/plugin.json | 17 + adapters/cursor/README.md | 48 +- adapters/cursor/hooks/event.mjs | 66 + adapters/cursor/hooks/hooks.json | 26 + adapters/cursor/hooks/microbridge-event.mjs | 44 + .../cursor/hooks/microbridge-event.test.mjs | 72 ++ adapters/cursor/index.mjs | 40 +- adapters/t3code/README.md | 41 +- adapters/t3code/index.mjs | 40 +- apps/microbridge-ui/package-lock.json | 368 +++++- apps/microbridge-ui/package.json | 8 +- apps/microbridge-ui/src-tauri/Cargo.lock | 4 +- apps/microbridge-ui/src-tauri/Cargo.toml | 2 +- apps/microbridge-ui/src-tauri/src/bus.rs | 59 +- apps/microbridge-ui/src-tauri/src/lib.rs | 444 ++++++- apps/microbridge-ui/src-tauri/tauri.conf.json | 8 +- apps/microbridge-ui/src/lib/bus.ts | 104 +- apps/microbridge-ui/src/lib/threads.ts | 5 +- apps/microbridge-ui/src/lib/types.ts | 57 +- apps/microbridge-ui/src/surfaces/Popover.tsx | 58 +- apps/microbridge-ui/src/surfaces/Settings.tsx | 343 ++++-- .../src/surfaces/surfaces.test.tsx | 129 ++ apps/microbridge-ui/vite.config.ts | 5 +- crates/mb-device/src/lib.rs | 80 +- crates/mb-protocol/src/lib.rs | 233 +++- crates/microbridgectl/src/main.rs | 91 +- crates/microbridged/Cargo.toml | 4 + crates/microbridged/src/config.rs | 3 +- crates/microbridged/src/lib.rs | 1 + crates/microbridged/src/main.rs | 35 +- crates/microbridged/src/socket.rs | 277 ++++- crates/microbridged/src/state.rs | 644 +++++++++- crates/microbridged/src/t3code.rs | 685 +++++++++++ docs/adapters.md | 23 +- docs/architecture.md | 23 +- docs/device-hid.md | 8 +- docs/governance.md | 4 +- docs/protocol.md | 68 +- scripts/bump-formula.sh | 4 +- scripts/smoke-formula.sh | 57 + 49 files changed, 5302 insertions(+), 382 deletions(-) create mode 100644 .github/workflows/finalize-release.yml create mode 100644 adapters/cursor/.cursor-plugin/plugin.json create mode 100644 adapters/cursor/hooks/event.mjs create mode 100644 adapters/cursor/hooks/hooks.json create mode 100755 adapters/cursor/hooks/microbridge-event.mjs create mode 100644 adapters/cursor/hooks/microbridge-event.test.mjs create mode 100644 apps/microbridge-ui/src/surfaces/surfaces.test.tsx create mode 100644 crates/microbridged/src/t3code.rs create mode 100755 scripts/smoke-formula.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cfe1e3e..fec869d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,8 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v7 + with: + persist-credentials: false - name: Install hidapi build deps (Linux) if: runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install -y libudev-dev libhidapi-dev @@ -37,13 +39,29 @@ jobs: working-directory: apps/microbridge-ui steps: - uses: actions/checkout@v7 + with: + persist-credentials: false - uses: actions/setup-node@v7 with: node-version: "22" cache: npm cache-dependency-path: apps/microbridge-ui/package-lock.json - run: npm ci + - run: npm test - run: npm run build + adapters: + name: adapters + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-node@v7 + with: + node-version: "22" + - run: node --test adapters/cursor/hooks/microbridge-event.test.mjs + - run: node -e 'for (const file of ["adapters/cursor/.cursor-plugin/plugin.json", "adapters/cursor/hooks/hooks.json"]) JSON.parse(require("fs").readFileSync(file, "utf8"))' + # Keep required-check names stable for the main branch ruleset. # Job names above are what GitHub shows as status contexts. diff --git a/.github/workflows/finalize-release.yml b/.github/workflows/finalize-release.yml new file mode 100644 index 0000000..4403aa8 --- /dev/null +++ b/.github/workflows/finalize-release.yml @@ -0,0 +1,136 @@ +name: Finalize release after Homebrew + +on: + pull_request: + types: [closed] + paths: + - Formula/microbridge.rb + +permissions: + contents: read + +jobs: + resolve: + if: >- + github.event.pull_request.merged == true && + github.event.pull_request.base.ref == 'main' && + startsWith(github.event.pull_request.head.ref, 'chore/brew-v') + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.release.outputs.tag }} + version: ${{ steps.release.outputs.version }} + steps: + - id: release + env: + HEAD_REF: ${{ github.event.pull_request.head.ref }} + run: | + set -euo pipefail + TAG="${HEAD_REF#chore/brew-}" + [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + + public-install: + name: clean public install (${{ matrix.arch }}) + needs: resolve + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: macos-15 + arch: arm64 + - os: macos-15-intel + arch: x86_64 + steps: + - name: Install from public tap and exercise app/service lifecycle + env: + VERSION: ${{ needs.resolve.outputs.version }} + EXPECTED_ARCH: ${{ matrix.arch }} + run: | + set -euo pipefail + test "$(uname -m)" = "$EXPECTED_ARCH" + brew tap DevVig/microbridge https://github.com/DevVig/microbridge + brew update + HOMEBREW_NO_INSTALL_CLEANUP=1 brew install DevVig/microbridge/microbridge + test "$(brew info --json=v2 DevVig/microbridge/microbridge | jq -r '.formulae[0].installed[0].version')" = "$VERSION" + microbridgectl help | grep -q Usage + APP="$HOME/Applications/Microbridge.app" + test -x "$(brew --prefix DevVig/microbridge/microbridge)/bin/microbridged" + brew services start DevVig/microbridge/microbridge + for _ in {1..10}; do + [[ -f "$APP/.microbridge-brew" ]] && break + sleep 1 + done + test -f "$APP/.microbridge-brew" + test "$(defaults read "$APP/Contents/Info" CFBundleShortVersionString)" = "$VERSION" + brew services list | grep -E '^microbridge[[:space:]]+(started|scheduled)' + open "$APP" + APP_PID="" + for _ in {1..10}; do + APP_PID="$(pgrep -f "$APP/Contents/MacOS/" | head -1 || true)" + [[ -n "$APP_PID" ]] && break + sleep 1 + done + test -n "$APP_PID" + kill "$APP_PID" + brew services stop DevVig/microbridge/microbridge + HOMEBREW_NO_INSTALL_CLEANUP=1 brew uninstall DevVig/microbridge/microbridge + if brew services list | grep -q '^microbridge[[:space:]]'; then + echo "microbridge service is still registered after uninstall" >&2 + exit 1 + fi + test -f "$APP/.microbridge-brew" + rm -rf "$APP" + test ! -e "$APP" + + promote: + name: promote verified release + needs: [resolve, public-install] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Mark release final and record public availability + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.resolve.outputs.tag }} + run: | + set -euo pipefail + BODY_FILE="$RUNNER_TEMP/release-body.md" + gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json body --jq .body > "$BODY_FILE" + python3 - "$BODY_FILE" <<'PY' + from pathlib import Path + import sys + + path = Path(sys.argv[1]) + body = path.read_text() + body = body.replace( + "Signed artifacts are published as a prerelease. This version is not\n" + "promoted to a final release until the Homebrew formula PR merges and\n" + "clean public-tap installations pass on Apple Silicon and Intel.", + "Signed artifacts and the Homebrew formula are publicly available. " + "Clean installs passed on Apple Silicon and Intel.", + ) + body = body.replace( + "Homebrew publication is pending; this release remains a prerelease.", + "Homebrew installation is publicly available and verified on Apple Silicon and Intel.", + ) + body = body.replace( + "## Direct download (prerelease assets available now)", + "## Direct download", + ) + body = body.replace( + "## Homebrew (available after the formula PR merges)", + "## Homebrew (publicly available)", + ) + path.write_text(body) + PY + gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --prerelease=false --notes-file "$BODY_FILE" + { + echo "## Release finalized" + echo "GitHub assets: published" + echo "Homebrew formula: merged" + echo "Clean public installs: Apple Silicon and Intel passed" + echo "Release: https://github.com/$GITHUB_REPOSITORY/releases/tag/$TAG" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4157162..7d3265d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -214,8 +214,8 @@ jobs: name: ui-${{ matrix.target }} path: ui-out/* - publish: - name: publish release + assemble-assets: + name: assemble release assets needs: [build, build-ui-macos] runs-on: ubuntu-latest steps: @@ -225,9 +225,27 @@ jobs: path: artifacts - name: Collect assets run: | + set -euo pipefail mkdir -p release-assets find artifacts -type f \( -name '*.tar.gz' -o -name '*.dmg' \) -exec cp {} release-assets/ \; ls -la release-assets + - name: Validate release versions and asset names + run: | + set -euo pipefail + TAG="${GITHUB_REF_NAME}" + VERSION="${TAG#v}" + WORKSPACE_VERSION="$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -1)" + UI_PACKAGE_VERSION="$(jq -r .version apps/microbridge-ui/package.json)" + UI_BUNDLE_VERSION="$(jq -r .version apps/microbridge-ui/src-tauri/tauri.conf.json)" + test "$VERSION" = "$WORKSPACE_VERSION" + test "$VERSION" = "$UI_PACKAGE_VERSION" + test "$VERSION" = "$UI_BUNDLE_VERSION" + for target in aarch64-apple-darwin x86_64-apple-darwin; do + test -f "release-assets/microbridge-${TAG}-${target}.tar.gz" + test -f "release-assets/microbridge-ui-${TAG}-${target}.tar.gz" + test -f "release-assets/microbridge-ui-${TAG}-${target}.dmg" + done + shasum -a 256 release-assets/* > release-assets/SHA256SUMS - name: Generate updater manifest (latest.json) run: | set -euo pipefail @@ -253,12 +271,65 @@ jobs: > release-assets/latest.json echo "Wrote latest.json:" cat release-assets/latest.json + test "$(jq -r .version release-assets/latest.json)" = "$VERSION" + - uses: actions/upload-artifact@v7 + with: + name: release-assets-${{ github.ref_name }} + path: release-assets/* + + formula-smoke-prepublish: + name: pre-publish formula smoke (${{ matrix.arch }}) + needs: assemble-assets + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: macos-15 + arch: arm64 + - os: macos-15-intel + arch: x86_64 + steps: + - uses: actions/checkout@v7 + - uses: actions/download-artifact@v8 + with: + name: release-assets-${{ github.ref_name }} + path: release-assets + - name: Rewrite formula to exact local workflow assets + env: + MICROBRIDGE_ASSET_BASE: file://${{ github.workspace }}/release-assets + run: | + ./scripts/bump-formula.sh "${{ github.ref_name }}" + test "$(sed -n 's/ version "\([^"]*\)"/\1/p' Formula/microbridge.rb)" = "${GITHUB_REF_NAME#v}" + - name: Install, launch, and remove candidate formula + run: ./scripts/smoke-formula.sh Formula/microbridge.rb "${GITHUB_REF_NAME#v}" "${{ matrix.arch }}" + + publish: + name: publish prerelease assets + needs: [assemble-assets, formula-smoke-prepublish] + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v8 + with: + name: release-assets-${{ github.ref_name }} + path: release-assets - uses: softprops/action-gh-release@v3 with: + prerelease: true generate_release_notes: true files: release-assets/* body: | - ## Install (macOS) + ## Release state + + Signed artifacts are published as a prerelease. This version is not + promoted to a final release until the Homebrew formula PR merges and + clean public-tap installations pass on Apple Silicon and Intel. + + ## Direct download (prerelease assets available now) + + Signed/notarized app assets are attached to this release. + + ## Homebrew (available after the formula PR merges) Menu bar app + daemon (not CLI-only): @@ -271,7 +342,7 @@ jobs: Upgrade later: `brew update && brew upgrade microbridge` - ### Direct download (signed + notarized DMG) + ### Signed + notarized DMG Grab `microbridge-ui-${{ github.ref_name }}-aarch64-apple-darwin.dmg` (Apple Silicon) or `…-x86_64-apple-darwin.dmg` (Intel) from the assets below, open it, and drag @@ -281,8 +352,8 @@ jobs: Full guide: [INSTALL.md](INSTALL.md). - bump-formula: - name: bump Homebrew formula + prepare-formula: + name: prepare Homebrew formula needs: publish runs-on: ubuntu-latest steps: @@ -295,7 +366,49 @@ jobs: run: | chmod +x scripts/bump-formula.sh ./scripts/bump-formula.sh "${{ github.ref_name }}" + test "$(sed -n 's/ version "\([^"]*\)"/\1/p' Formula/microbridge.rb)" = "${GITHUB_REF_NAME#v}" + - uses: actions/upload-artifact@v7 + with: + name: formula-${{ github.ref_name }} + path: Formula/microbridge.rb + + formula-smoke-public-assets: + name: public-asset formula smoke (${{ matrix.arch }}) + needs: prepare-formula + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: macos-15 + arch: arm64 + - os: macos-15-intel + arch: x86_64 + steps: + - uses: actions/checkout@v7 + - uses: actions/download-artifact@v8 + with: + name: formula-${{ github.ref_name }} + path: candidate + - name: Install, launch, and remove candidate formula + run: ./scripts/smoke-formula.sh candidate/microbridge.rb "${GITHUB_REF_NAME#v}" "${{ matrix.arch }}" + + bump-formula: + name: open Homebrew formula PR + needs: [publish, formula-smoke-public-assets] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + ref: main + - uses: actions/download-artifact@v8 + with: + name: formula-${{ github.ref_name }} + path: candidate + - name: Use smoke-tested formula + run: cp candidate/microbridge.rb Formula/microbridge.rb - name: Open PR + id: formula-pr uses: peter-evans/create-pull-request@v7 with: token: ${{ secrets.GITHUB_TOKEN }} @@ -310,3 +423,23 @@ jobs: ``` branch: chore/brew-${{ github.ref_name }} delete-branch: true + - name: Publish formula status + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + { + echo "## Homebrew publication" + echo "Pre-publication and public-asset formula smoke tests passed on Apple Silicon and Intel." + echo "Formula PR: ${{ steps.formula-pr.outputs.pull-request-url }}" + echo "The release remains a prerelease until that PR merges and clean public installs pass." + } >> "$GITHUB_STEP_SUMMARY" + BODY_FILE="$RUNNER_TEMP/release-body.md" + gh release view "$GITHUB_REF_NAME" --json body --jq .body > "$BODY_FILE" + { + echo + echo "## Homebrew formula PR" + echo "${{ steps.formula-pr.outputs.pull-request-url }}" + echo + echo "Homebrew publication is pending; this release remains a prerelease." + } >> "$BODY_FILE" + gh release edit "$GITHUB_REF_NAME" --notes-file "$BODY_FILE" diff --git a/Cargo.lock b/Cargo.lock index 063b662..f0a2120 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,18 @@ dependencies = [ "memchr", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "1.3.2" @@ -32,6 +44,12 @@ dependencies = [ "objc2", ] +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "bytes" version = "1.12.1" @@ -54,6 +72,64 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "dispatch2" version = "0.3.1" @@ -64,6 +140,17 @@ dependencies = [ "objc2", ] +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -96,6 +183,15 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -105,6 +201,66 @@ dependencies = [ "libc", ] +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core", + "wasm-bindgen", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -124,6 +280,207 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -163,12 +520,41 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "log", + "security-framework 2.11.1", + "security-framework 3.7.0", + "zeroize", +] + [[package]] name = "kqueue" version = "1.2.0" @@ -201,12 +587,24 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "log" version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchers" version = "0.2.0" @@ -218,7 +616,7 @@ dependencies = [ [[package]] name = "mb-adapters" -version = "0.1.0" +version = "0.2.1" dependencies = [ "mb-protocol", "notify", @@ -230,7 +628,7 @@ dependencies = [ [[package]] name = "mb-device" -version = "0.1.0" +version = "0.2.1" dependencies = [ "hidapi", "mb-protocol", @@ -241,7 +639,7 @@ dependencies = [ [[package]] name = "mb-protocol" -version = "0.1.0" +version = "0.2.1" dependencies = [ "serde", "serde_json", @@ -255,7 +653,7 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "microbridgectl" -version = "0.2.0" +version = "0.2.1" dependencies = [ "mb-device", "mb-protocol", @@ -265,20 +663,24 @@ dependencies = [ [[package]] name = "microbridged" -version = "0.2.0" +version = "0.2.1" dependencies = [ + "keyring", "mb-adapters", "mb-device", "mb-protocol", "objc2", "objc2-app-kit", "objc2-foundation", + "reqwest", "serde", "serde_json", + "time", "tokio", "toml", "tracing", "tracing-subscriber", + "url", ] [[package]] @@ -330,6 +732,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "objc2" version = "0.6.4" @@ -488,6 +896,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -500,6 +914,21 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "proc-macro2" version = "1.0.106" @@ -510,40 +939,269 @@ dependencies = [ ] [[package]] -name = "quote" -version = "1.0.46" +name = "quinn" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ - "proc-macro2", + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", ] [[package]] -name = "regex-automata" -version = "0.4.16" +name = "quinn-proto" +version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", ] [[package]] -name = "regex-syntax" -version = "0.8.11" +name = "quinn-udp" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" - -[[package]] -name = "same-file" -version = "1.0.6" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" dependencies = [ "winapi-util", ] +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "serde" version = "1.0.228" @@ -596,6 +1254,18 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -621,6 +1291,12 @@ dependencies = [ "libc", ] +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.2" @@ -637,6 +1313,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.119" @@ -648,6 +1336,46 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "thread_local" version = "1.1.10" @@ -657,6 +1385,61 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.53.0" @@ -684,6 +1467,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "toml" version = "0.8.23" @@ -725,6 +1518,51 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -786,12 +1624,42 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "valuable" version = "0.1.1" @@ -808,12 +1676,105 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi-util" version = "0.1.11" @@ -920,6 +1881,95 @@ dependencies = [ "memchr", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index f69064d..1f2f975 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.2.0" +version = "0.2.1" edition = "2021" license = "MIT" repository = "https://github.com/DevVig/microbridge" diff --git a/INSTALL.md b/INSTALL.md index 60bf822..78a6a80 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -54,7 +54,7 @@ Governance / why this path: [docs/governance.md](docs/governance.md). |---|---| | macOS (Homebrew) | Homebrew + **Xcode Command Line Tools** (`xcode-select --install`); Rust + Node pulled in as **build** deps (builds `.app` + daemon) | | From source | Rust stable, Node ≥ 20; macOS also needs Xcode CLT for the `.app` | -| Hardware LEDs | Codex Micro over USB (protocol ready; set `MICROBRIDGE_HID_CLAIM=1` to write) | +| Hardware LEDs/keys | Codex Micro over USB; enable **Settings → Device → Hardware control** (`MICROBRIDGE_HID_CLAIM=1` remains a developer override) | ## From source (developers) @@ -99,13 +99,23 @@ archive. **In-app updates (direct installs).** A DMG/manual install updates itself: right-click the menu bar icon → **Check for Updates…**, or turn on *Settings → Updates → check automatically at launch* (off by default). The app downloads -the signed update, verifies it, and relaunches. This is the only network call -Microbridge makes, and only when you ask — the daemon stays zero-network. +the signed update, verifies it, and relaunches. Update checks are the only +app-originated network call. The daemon also contacts a T3 Code environment +only after you explicitly enable that adapter and exchange a one-time pairing +link; Microbridge has no telemetry or cloud relay. Homebrew installs are managed by brew instead: the app detects the brew marker and points you at `brew upgrade microbridge` rather than self-replacing, so the formula version and the on-disk app never drift apart. +### Cursor integration + +Cursor support ships inside Microbridge. Open **Settings → Adapters** and click +**Enable Cursor**; Microbridge installs its bundled lifecycle integration into +Cursor's supported local-plugin directory after that explicit consent. Reload +Cursor once if it is already open. **Remove** disables the adapter and removes +only Microbridge's local integration. No Marketplace download is required. + **Note:** Homebrew installs **prebuilt** release binaries (not a from-source Tauri build). The formula checksums are refreshed by CI after each `v*` tag. @@ -119,6 +129,7 @@ Tauri build). The formula checksums are refreshed by CI after each `v*` tag. | `~/.microbridge/microbridged.sock` | Local NDJSON socket | | `~/.microbridge/config.toml` | Key source, lighting, appearance | | `~/.microbridge/daemon.log` | launchd / service logs | +| `~/.cursor/plugins/local/microbridge` | Bundled Cursor lifecycle integration (only after consent) | ## Troubleshooting @@ -130,9 +141,10 @@ brew services restart microbridge launchctl kickstart -k "gui/$(id -u)/ai.microbridge.daemon" ``` -**LEDs stay dark** — by default Microbridge only probes USB (Detected). To -write Agent Key lighting: pause ChatGPT Desktop ownership, then -`export MICROBRIDGE_HID_CLAIM=1` before starting the daemon. See +**LEDs stay dark** — by default Microbridge only probes USB (Detected). Enable +**Settings → Device → Hardware control**. If the interface is busy, pause the +other device owner and try again. Developers can still set +`MICROBRIDGE_HID_CLAIM=1` before starting the daemon. See [docs/device-hid.md](docs/device-hid.md). **Homebrew can’t fetch (private repo)** — `gh auth login`, or set diff --git a/Makefile b/Makefile index e70a46c..27c1868 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,8 @@ ci: fmt cargo fmt --all --check $(MAKE) clippy $(MAKE) test - cd apps/microbridge-ui && npm ci && npm run build + node --test adapters/cursor/hooks/microbridge-event.test.mjs + cd apps/microbridge-ui && npm ci && npm test && npm run build build: cargo build --release -p microbridged -p microbridgectl diff --git a/PRIVACY.md b/PRIVACY.md index 9b61740..2afaaa8 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,6 +1,9 @@ # Privacy -Microbridge is a **local-only** control plane. It does not phone home. +Microbridge is a local-first control plane. It has no telemetry or Microbridge +cloud account. Network access occurs only for a user-requested or user-enabled +update check or an explicitly enabled and paired T3 Code environment. There is +no unconfigured network traffic. ## What stays on your machine @@ -10,19 +13,25 @@ Microbridge is a **local-only** control plane. It does not phone home. | Config | `~/.microbridge/config.toml` | Key source, lighting, appearance | | Daemon log | `~/.microbridge/daemon.log` (or Homebrew service logs) | Debug | | Unix socket | `~/.microbridge/microbridged.sock` (mode `0600`) | Local IPC for UI + adapters | +| T3 Code credential | macOS Keychain (`ai.microbridge.t3code`) | Access the environment the user explicitly paired | ## What we do **not** do - No telemetry, analytics, or crash upload -- No update pings or cloud accounts -- No network client in the daemon (auditable in `Cargo.lock`) +- No telemetry or Microbridge cloud account +- No unconfigured network traffic; update checks and T3 access require opt-in - No uploading of session text or source code ## Adapters -First-party adapters watch **local** session stores. Community adapters must -follow the same rule (see [docs/adapters.md](docs/adapters.md)): talk only to -local runtimes; no network I/O. +First-party adapters watch local session stores. The bundled Cursor integration +sends metadata-only lifecycle events over the local socket and never sends +prompt, response, transcript, or tool argument content. The T3 Code adapter +talks only to the exact environment the user pairs, using scoped orchestration +access. + +The one-time T3 pairing token is exchanged immediately, never logged, and not +stored. Removing the adapter deletes its Keychain credential. ## Hardware diff --git a/README.md b/README.md index 660033a..48031c7 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ **An open-source control plane for the Codex Micro — one macropad, every coding agent.** -Microbridge is a tiny local daemon that bridges AI coding agents — Codex CLI, Claude Code, Cursor, T3 Code, and anything else with an adapter — to the [Work Louder Codex Micro](https://worklouder.cc/). Per-key RGB mirrors live agent state; the keys drive agent actions (approve, reject, interrupt, switch focus). No vendor desktop app required. +Microbridge is a tiny local daemon that bridges AI coding agents — Codex CLI, Claude Code, Cursor, T3 Code, and anything else with an adapter — to the [Work Louder Codex Micro](https://worklouder.cc/). Per-key RGB mirrors live agent state; keys route only the actions each adapter explicitly advertises, so unsupported controls never report false success. No vendor desktop app is required for Microbridge itself. -> **Status: early public alpha (`v0.1.x`).** Menu bar UI, local daemon, in-process Codex/Claude watchers, and signed macOS packages are shipping. **HID protocol (VID/PID, framing, `v.oai.thstatus`) is implemented from ChatGPT’s Work Louder kit**; live LED writes stay opt-in (`MICROBRIDGE_HID_CLAIM=1`) until hardware validation. See [docs/device-hid.md](docs/device-hid.md). +> **Status: early public alpha (`v0.2.x`).** Menu bar UI, local daemon, in-process Codex/Claude watchers, and signed macOS packages are shipping. Cursor lifecycle reception and paired T3 Code control are opt-in and capability-gated. **HID protocol (VID/PID, framing, `v.oai.thstatus`) is implemented from ChatGPT’s Work Louder kit**; hardware control stays off until enabled in Device settings (or `MICROBRIDGE_HID_CLAIM=1` is set for diagnostics) while physical validation is completed. See [docs/device-hid.md](docs/device-hid.md). ## Screenshots @@ -40,11 +40,16 @@ The Micro's best feature — bidirectional Agent Keys — currently works throug ## Design principles -1. **Invisible footprint.** Event-driven end to end: no polling loops, no heartbeat timers. Idle CPU is 0.0% and idle RSS targets single-digit megabytes. If Microbridge is noticeable in Activity Monitor, that is a bug — the [footprint budget](docs/architecture.md#footprint-budget) is a spec, not an aspiration. -2. **Zero-network daemon.** No telemetry, no cloud. The always-resident daemon's only I/O is a local Unix socket and the USB device — it links no HTTP client, auditable in `Cargo.lock`. The menu bar app doesn't phone home either, with one opt-in exception: an update check *you* trigger (or enable to run once at launch). No background polling, no automatic pings. +1. **Invisible footprint.** Local watchers are event-driven; device input and an explicitly paired T3 connection use bounded polling and backoff. Idle CPU and RSS remain part of the [footprint budget](docs/architecture.md#footprint-budget). +2. **Local-first and explicit network access.** There is no telemetry or cloud relay. The app checks for updates only when requested or enabled, and the daemon contacts a T3 environment only after the user enables the adapter and supplies a one-time pairing link. 3. **Rust core, any-language adapters.** The always-resident part is a single static Rust binary. First-party adapters compile into it (in-process, ~zero overhead). Community adapters are separate processes speaking [newline-delimited JSON](docs/protocol.md) — write one in whatever you like. 4. **The menu bar app is the product UI.** Configure keys, lighting, and adapters there. The daemon keeps the hardware alive underneath; `microbridgectl` is a support/debug escape hatch. +Cursor support is included in the Microbridge app and repository. Enable it +once in **Settings → Adapters**; Microbridge installs its bundled lifecycle +integration into Cursor's supported local-plugin directory. There is no +separate Marketplace download or second product to maintain. + ## Architecture ``` diff --git a/adapters/cursor/.cursor-plugin/plugin.json b/adapters/cursor/.cursor-plugin/plugin.json new file mode 100644 index 0000000..67dc68b --- /dev/null +++ b/adapters/cursor/.cursor-plugin/plugin.json @@ -0,0 +1,17 @@ +{ + "name": "microbridge", + "displayName": "Microbridge", + "version": "0.2.1", + "description": "Shows Cursor agent lifecycle state on the Codex Micro through the local Microbridge daemon.", + "author": { + "name": "DevVig" + }, + "publisher": "DevVig", + "homepage": "https://github.com/DevVig/microbridge/tree/main/adapters/cursor", + "repository": "https://github.com/DevVig/microbridge", + "license": "MIT", + "keywords": ["cursor", "hardware", "agent-status", "microbridge"], + "category": "developer-tools", + "tags": ["hardware", "lifecycle", "local-only"], + "hooks": "./hooks/hooks.json" +} diff --git a/adapters/cursor/README.md b/adapters/cursor/README.md index 766f10f..2b5a8b6 100644 --- a/adapters/cursor/README.md +++ b/adapters/cursor/README.md @@ -1,28 +1,38 @@ -# cursor adapter (community) +# Microbridge for Cursor -Out-of-process Microbridge adapter for [Cursor](https://cursor.com/). +This directory is the Cursor integration bundled with Microbridge. It reports +agent lifecycle state to the local Microbridge daemon without reading Cursor +databases, installing global hooks, using Accessibility automation, or creating +replacement sessions. -## Status +## Install and consent -**Scaffold.** Cursor does not publish a stable local session journal API. -This adapter connects and stays idle until a supported state source is -documented. PRs welcome — see the checklist in -[docs/adapters.md](../../docs/adapters.md). +1. Open **Microbridge Settings → Adapters**. +2. Click **Enable Cursor**. Microbridge installs its bundled integration into + Cursor's supported local-plugin directory after this explicit consent. +3. Reload Cursor once if it is already open. **Remove** disables the adapter + and deletes only the Microbridge-owned local plugin directory. -## Rules +The hook talks directly to `~/.microbridge/microbridged.sock` and sends only the +conversation id, lifecycle state, and workspace-derived display label. It does +not depend on `microbridgectl` being on Cursor's PATH. Prompt, response, +transcript, and tool argument content are not sent. -- Event-driven only (no polling loops) -- No scraping of Cursor's private Electron internals -- Prefer official hooks / documented session files when available +The same source is public here for review and development. A separate +Marketplace download is not required, and integration updates ship with the +Microbridge app. -## Run (once implemented) +## Capability boundary -```sh -cargo run -p microbridged # shell 1 -node adapters/cursor/index.mjs # shell 2 -``` +Lifecycle observation is implemented. Cursor does not currently expose stable +public APIs for authoritative approval acceptance, session-scoped interrupt, +opening an existing thread, or reasoning-effort changes. Microbridge therefore +reports this adapter as **Limited** and never falls back to private storage or +Accessibility scripting. -## Supported versions +Run a hook locally: -TBD — document the Cursor build you tested against before merging a real -implementation. +```sh +printf '{"conversation_id":"demo","workspace_root":"/tmp/example"}' \ + | node hooks/microbridge-event.mjs working +``` diff --git a/adapters/cursor/hooks/event.mjs b/adapters/cursor/hooks/event.mjs new file mode 100644 index 0000000..94d306b --- /dev/null +++ b/adapters/cursor/hooks/event.mjs @@ -0,0 +1,66 @@ +import { basename } from "node:path"; + +const LIFECYCLE_STATES = { + idle: "idle", + stop: "idle", + session_end: "idle", + thinking: "thinking", + before_submit_prompt: "thinking", + after_agent_thought: "thinking", + working: "working", + pre_tool_use: "working", + post_tool_use: "working", + awaiting_approval: "awaiting_approval", + done: "done", + after_agent_response: "done", + error: "error", +}; + +export function microbridgeEvent(input, lifecycle) { + const conversationId = + typeof input.conversation_id === "string" && input.conversation_id + ? input.conversation_id + : typeof input.session_id === "string" && input.session_id + ? input.session_id + : "unknown"; + const workspace = + Array.isArray(input.workspace_roots) && typeof input.workspace_roots[0] === "string" + ? input.workspace_roots[0] + : typeof input.workspace_root === "string" + ? input.workspace_root + : typeof input.cwd === "string" + ? input.cwd + : ""; + return { + conversationId, + lifecycle, + title: workspace ? `Cursor · ${basename(workspace)}` : "Cursor agent", + workspace, + }; +} + +export function lifecycleMessages(event, now = Date.now()) { + const state = LIFECYCLE_STATES[event.lifecycle] ?? "working"; + return [ + { + type: "hello", + adapter: "cursor-hook", + protocol_version: 0, + role: "ui", + adapter_version: "0.2.1", + capabilities: {}, + }, + { + type: "ingest_lifecycle", + adapter_id: "cursor", + session: { + id: `cursor:${event.conversationId}`, + app: "Cursor", + title: event.title, + state, + updated_at_ms: now, + }, + ttl_ms: event.lifecycle === "session_end" ? 1_000 : 30 * 60 * 1_000, + }, + ]; +} diff --git a/adapters/cursor/hooks/hooks.json b/adapters/cursor/hooks/hooks.json new file mode 100644 index 0000000..597b030 --- /dev/null +++ b/adapters/cursor/hooks/hooks.json @@ -0,0 +1,26 @@ +{ + "version": 1, + "hooks": { + "beforeSubmitPrompt": [ + { "command": "node \"${CURSOR_PLUGIN_ROOT}/hooks/microbridge-event.mjs\" thinking" } + ], + "afterAgentThought": [ + { "command": "node \"${CURSOR_PLUGIN_ROOT}/hooks/microbridge-event.mjs\" thinking" } + ], + "preToolUse": [ + { "command": "node \"${CURSOR_PLUGIN_ROOT}/hooks/microbridge-event.mjs\" working" } + ], + "postToolUse": [ + { "command": "node \"${CURSOR_PLUGIN_ROOT}/hooks/microbridge-event.mjs\" working" } + ], + "afterAgentResponse": [ + { "command": "node \"${CURSOR_PLUGIN_ROOT}/hooks/microbridge-event.mjs\" done" } + ], + "stop": [ + { "command": "node \"${CURSOR_PLUGIN_ROOT}/hooks/microbridge-event.mjs\" stop" } + ], + "sessionEnd": [ + { "command": "node \"${CURSOR_PLUGIN_ROOT}/hooks/microbridge-event.mjs\" session_end" } + ] + } +} diff --git a/adapters/cursor/hooks/microbridge-event.mjs b/adapters/cursor/hooks/microbridge-event.mjs new file mode 100755 index 0000000..c4b573b --- /dev/null +++ b/adapters/cursor/hooks/microbridge-event.mjs @@ -0,0 +1,44 @@ +#!/usr/bin/env node +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { lifecycleMessages, microbridgeEvent } from "./event.mjs"; + +async function readStdin() { + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + const text = Buffer.concat(chunks).toString("utf8").trim(); + if (!text) return {}; + try { + return JSON.parse(text); + } catch { + return {}; + } +} + +const input = await readStdin(); +const lifecycle = process.argv[2] ?? "working"; +const event = microbridgeEvent(input, lifecycle); +const socketPath = + process.env.MICROBRIDGE_SOCKET ?? + path.join(os.homedir(), ".microbridge", "microbridged.sock"); + +// Never send prompt, response, transcript, or tool argument content. Hook +// failures are intentionally non-blocking for the user's Cursor workflow. +await new Promise((resolve) => { + const socket = net.createConnection(socketPath); + const finish = () => { + socket.destroy(); + resolve(); + }; + socket.setTimeout(1_250, finish); + socket.on("error", finish); + socket.on("data", finish); + socket.on("connect", () => { + for (const message of lifecycleMessages(event)) { + socket.write(`${JSON.stringify(message)}\n`); + } + }); +}); + +process.stdout.write("{}\n"); diff --git a/adapters/cursor/hooks/microbridge-event.test.mjs b/adapters/cursor/hooks/microbridge-event.test.mjs new file mode 100644 index 0000000..a00a552 --- /dev/null +++ b/adapters/cursor/hooks/microbridge-event.test.mjs @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { lifecycleMessages, microbridgeEvent } from "./event.mjs"; + +test("maps only local identifiers and display metadata", () => { + const event = microbridgeEvent( + { + conversation_id: "conversation-1", + workspace_roots: ["/Users/test/Example"], + prompt: "must never be forwarded", + text: "must never be forwarded", + tool_args: { secret: true }, + }, + "working", + ); + assert.deepEqual(event, { + conversationId: "conversation-1", + lifecycle: "working", + title: "Cursor · Example", + workspace: "/Users/test/Example", + }); + assert.equal(JSON.stringify(event).includes("must never"), false); +}); + +test("missing daemon never blocks Cursor", () => { + const hook = fileURLToPath(new URL("./microbridge-event.mjs", import.meta.url)); + const result = spawnSync(process.execPath, [hook, "thinking"], { + input: JSON.stringify({ conversation_id: "conversation-2" }), + encoding: "utf8", + env: { ...process.env, MICROBRIDGE_SOCKET: "/definitely/missing/microbridged.sock" }, + }); + assert.equal(result.status, 0); + assert.equal(result.stdout, "{}\n"); +}); + +test("creates a self-contained lifecycle socket message", () => { + const [hello, ingest] = lifecycleMessages( + microbridgeEvent( + { + conversation_id: "conversation-3", + workspace_root: "/tmp/example", + prompt: "private prompt", + }, + "after_agent_response", + ), + 1234, + ); + assert.deepEqual(hello, { + type: "hello", + adapter: "cursor-hook", + protocol_version: 0, + role: "ui", + adapter_version: "0.2.1", + capabilities: {}, + }); + assert.equal(ingest.type, "ingest_lifecycle"); + assert.equal(ingest.session.id, "cursor:conversation-3"); + assert.equal(ingest.session.state, "done"); + assert.equal(ingest.session.updated_at_ms, 1234); + assert.equal(JSON.stringify(ingest).includes("private prompt"), false); +}); + +test("duplicate hook payloads normalize to the same event", () => { + const payload = { session_id: "session-1", cwd: "/tmp/project" }; + assert.deepEqual( + microbridgeEvent(payload, "done"), + microbridgeEvent(payload, "done"), + ); +}); diff --git a/adapters/cursor/index.mjs b/adapters/cursor/index.mjs index 0b1b103..8457f88 100755 --- a/adapters/cursor/index.mjs +++ b/adapters/cursor/index.mjs @@ -1,38 +1,4 @@ #!/usr/bin/env node -// Cursor community adapter scaffold — connects, says hello, then idles. -// Replace the idle section when a supported session source exists. - -import net from "node:net"; -import os from "node:os"; -import path from "node:path"; - -const socketPath = - process.env.MICROBRIDGE_SOCKET ?? - path.join(os.homedir(), ".microbridge", "microbridged.sock"); - -const socket = net.createConnection(socketPath); -const send = (message) => socket.write(`${JSON.stringify(message)}\n`); - -socket.on("connect", () => { - send({ type: "hello", adapter: "cursor", protocol_version: 0 }); - console.log("cursor adapter connected (idle — no session source yet)"); -}); - -socket.on("data", (buf) => { - for (const line of buf.toString("utf8").split("\n")) { - if (!line.trim()) continue; - try { - const msg = JSON.parse(line); - if (msg.type === "action") { - console.log("action (no-op until implemented):", msg); - } - } catch { - /* ignore */ - } - } -}); - -socket.on("error", (error) => { - console.error(`cannot reach microbridged at ${socketPath}: ${error.message}`); - process.exit(1); -}); +// Compatibility entrypoint for local hook testing. Cursor installations use +// the managed plugin in `.cursor-plugin/plugin.json` and `hooks/hooks.json`. +import "./hooks/microbridge-event.mjs"; diff --git a/adapters/t3code/README.md b/adapters/t3code/README.md index 6162566..83d7c02 100644 --- a/adapters/t3code/README.md +++ b/adapters/t3code/README.md @@ -1,27 +1,30 @@ -# t3code adapter (community) +# T3 Code adapter -Out-of-process Microbridge adapter for [T3 Code](https://github.com/pingdotgg/t3code). +The T3 Code integration runs inside `microbridged`; this directory documents +its host contract and replaces the former idle Node scaffold. -## Status +## Pairing -**Scaffold.** Wire this to T3 Code's local session / agent status surface when -one is available. Upstream contributions to T3 Code itself are currently -closed; this adapter can still ship in Microbridge independently. +1. Enable T3 Code in **Microbridge Settings → Adapters**. +2. In T3 Code, open **Settings → Connections** and create a one-time pairing link. +3. Paste the link into Microbridge. The one-time token is exchanged immediately. +4. The resulting bearer credential is stored in macOS Keychain under + `ai.microbridge.t3code` and is removed by **Remove**. -## Rules +Microbridge uses T3 Code's authenticated public endpoints: -- Event-driven only (no polling loops) -- No scraping of private Electron internals -- Prefer official hooks / documented session files +- `GET /api/orchestration/shell` for lifecycle snapshots. +- `GET /api/orchestration/threads/:threadId` for pending approval identity. +- `POST /api/orchestration/dispatch` for approval and interrupt commands. -## Run (once implemented) +It never reads T3 Code databases, bootstrap credentials, or desktop internals. -```sh -cargo run -p microbridged # shell 1 -node adapters/t3code/index.mjs # shell 2 -``` +The compatibility suite is pinned to T3 server `0.0.28` and upstream contract +commit `ebe8afb1df357423a0e036b388af3e739d640205`. Other server versions are +reported as **Incompatible** until Microbridge verifies and ships their contract. +The adapter reports **Limited** when the paired HTTP contract does not advertise +focus/open or provider option descriptors for reasoning-effort adjustment. -## Supported versions - -TBD — document the T3 Code build you tested against before merging a real -implementation. +The compatibility target is the contract present in `pingdotgg/t3code` as of +July 18, 2026. Authentication failures return to **Needs setup**; transport +failures show **Connecting** and retry with the existing paired credential. diff --git a/adapters/t3code/index.mjs b/adapters/t3code/index.mjs index 1b4de37..41f602e 100755 --- a/adapters/t3code/index.mjs +++ b/adapters/t3code/index.mjs @@ -1,37 +1,5 @@ #!/usr/bin/env node -// T3 Code community adapter scaffold — connects, says hello, then idles. - -import net from "node:net"; -import os from "node:os"; -import path from "node:path"; - -const socketPath = - process.env.MICROBRIDGE_SOCKET ?? - path.join(os.homedir(), ".microbridge", "microbridged.sock"); - -const socket = net.createConnection(socketPath); -const send = (message) => socket.write(`${JSON.stringify(message)}\n`); - -socket.on("connect", () => { - send({ type: "hello", adapter: "t3code", protocol_version: 0 }); - console.log("t3code adapter connected (idle — no session source yet)"); -}); - -socket.on("data", (buf) => { - for (const line of buf.toString("utf8").split("\n")) { - if (!line.trim()) continue; - try { - const msg = JSON.parse(line); - if (msg.type === "action") { - console.log("action (no-op until implemented):", msg); - } - } catch { - /* ignore */ - } - } -}); - -socket.on("error", (error) => { - console.error(`cannot reach microbridged at ${socketPath}: ${error.message}`); - process.exit(1); -}); +console.error( + "The T3 Code adapter is daemon-owned in Microbridge v0.2.1. Enable and pair it in Microbridge Settings → Adapters.", +); +process.exitCode = 1; diff --git a/apps/microbridge-ui/package-lock.json b/apps/microbridge-ui/package-lock.json index 8f32577..66ed1a6 100644 --- a/apps/microbridge-ui/package-lock.json +++ b/apps/microbridge-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "microbridge-ui", - "version": "0.2.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "microbridge-ui", - "version": "0.2.0", + "version": "0.2.1", "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2", @@ -24,7 +24,8 @@ "@vitejs/plugin-react": "^4.3.4", "tailwindcss": "^4.0.0", "typescript": "~5.7.2", - "vite": "^6.0.0" + "vite": "^6.0.0", + "vitest": "^4.1.10" } }, "node_modules/@babel/code-frame": { @@ -1197,6 +1198,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", @@ -1804,6 +1812,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1852,6 +1878,129 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.43", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", @@ -1920,6 +2069,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1983,6 +2142,13 @@ "node": ">=10.13.0" } }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -2035,6 +2201,26 @@ "node": ">=6" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2457,6 +2643,27 @@ "node": ">=18" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2598,6 +2805,13 @@ "semver": "bin/semver.js" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2608,6 +2822,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", @@ -2629,6 +2857,23 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2646,6 +2891,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/typescript": { "version": "5.7.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", @@ -2766,6 +3021,113 @@ } } }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/apps/microbridge-ui/package.json b/apps/microbridge-ui/package.json index 3f0cde5..227004e 100644 --- a/apps/microbridge-ui/package.json +++ b/apps/microbridge-ui/package.json @@ -1,13 +1,14 @@ { "name": "microbridge-ui", "private": true, - "version": "0.2.0", + "version": "0.2.1", "type": "module", "scripts": { "dev": "vite", "build": "tsc --noEmit && vite build", "preview": "vite preview", - "tauri": "tauri" + "tauri": "tauri", + "test": "vitest run src" }, "dependencies": { "@tauri-apps/api": "^2", @@ -26,6 +27,7 @@ "@vitejs/plugin-react": "^4.3.4", "tailwindcss": "^4.0.0", "typescript": "~5.7.2", - "vite": "^6.0.0" + "vite": "^6.0.0", + "vitest": "^4.1.10" } } diff --git a/apps/microbridge-ui/src-tauri/Cargo.lock b/apps/microbridge-ui/src-tauri/Cargo.lock index 5790884..e4f1a95 100644 --- a/apps/microbridge-ui/src-tauri/Cargo.lock +++ b/apps/microbridge-ui/src-tauri/Cargo.lock @@ -1800,7 +1800,7 @@ dependencies = [ [[package]] name = "mb-protocol" -version = "0.1.0" +version = "0.2.1" dependencies = [ "serde", ] @@ -1822,7 +1822,7 @@ dependencies = [ [[package]] name = "microbridge-ui" -version = "0.2.0" +version = "0.2.1" dependencies = [ "mb-protocol", "serde", diff --git a/apps/microbridge-ui/src-tauri/Cargo.toml b/apps/microbridge-ui/src-tauri/Cargo.toml index 2ea33ab..c3c28b5 100644 --- a/apps/microbridge-ui/src-tauri/Cargo.toml +++ b/apps/microbridge-ui/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "microbridge-ui" -version = "0.2.0" +version = "0.2.1" description = "Microbridge menu bar app (primary UI)" authors = ["Microbridge contributors"] edition = "2021" diff --git a/apps/microbridge-ui/src-tauri/src/bus.rs b/apps/microbridge-ui/src-tauri/src/bus.rs index 18a746a..2b81945 100644 --- a/apps/microbridge-ui/src-tauri/src/bus.rs +++ b/apps/microbridge-ui/src-tauri/src/bus.rs @@ -5,7 +5,8 @@ use std::sync::Arc; use std::time::Duration; use mb_protocol::{ - BusEvent, ClientMessage, ClientRole, DaemonConfig, ServerMessage, Snapshot, PROTOCOL_VERSION, + AdapterCapabilities, BusEvent, ClientMessage, ClientRole, DaemonConfig, ServerMessage, + Snapshot, PROTOCOL_VERSION, }; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::UnixStream; @@ -30,6 +31,7 @@ impl BusHandle { .await?; match read_matching(&mut reader, false, true).await? { ServerMessage::Config { config } => Ok(config), + ServerMessage::ConfigError { message } => Err(message), _ => Err("unexpected set_config reply".into()), } }; @@ -38,6 +40,28 @@ impl BusHandle { Err(_) => Err("set_config timed out waiting for microbridged".into()), } } + + pub async fn adapter_operation(&self, message: ClientMessage) -> Result { + let work = async { + let (mut write, reader) = open_ui_client().await?; + write_msg(&mut write, &message).await?; + let mut lines = reader.lines(); + while let Some(line) = lines.next_line().await.map_err(|e| e.to_string())? { + if line.trim().is_empty() { + continue; + } + if let ServerMessage::AdapterOperation { ok, message, .. } = + serde_json::from_str::(&line).map_err(|e| e.to_string())? + { + return if ok { Ok(message) } else { Err(message) }; + } + } + Err("daemon closed before acknowledging adapter operation".into()) + }; + tokio::time::timeout(Duration::from_secs(15), work) + .await + .map_err(|_| "adapter operation timed out".to_string())? + } } fn socket_path() -> PathBuf { @@ -68,6 +92,8 @@ async fn open_ui_client() -> Result< adapter: "microbridge-ui".into(), protocol_version: PROTOCOL_VERSION, role: ClientRole::Ui, + adapter_version: Some(env!("CARGO_PKG_VERSION").into()), + capabilities: AdapterCapabilities::default(), }, ) .await?; @@ -96,19 +122,16 @@ async fn read_matching( want_config: bool, ) -> Result { let mut lines = reader.lines(); - while let Some(line) = lines - .next_line() - .await - .map_err(|e| format!("read: {e}"))? - { + while let Some(line) = lines.next_line().await.map_err(|e| format!("read: {e}"))? { if line.trim().is_empty() { continue; } - let msg: ServerMessage = - serde_json::from_str(&line).map_err(|e| format!("parse: {e}"))?; + let msg: ServerMessage = serde_json::from_str(&line).map_err(|e| format!("parse: {e}"))?; match &msg { ServerMessage::Snapshot { .. } if want_snapshot => return Ok(msg), - ServerMessage::Config { .. } if want_config => return Ok(msg), + ServerMessage::Config { .. } | ServerMessage::ConfigError { .. } if want_config => { + return Ok(msg) + } _ => continue, } } @@ -146,7 +169,10 @@ pub fn apply_event(snap: &mut Snapshot, event: BusEvent) { snap.device_name = name; } BusEvent::ConfigChanged { config } => { - snap.config = config; + snap.config = *config; + } + BusEvent::AdaptersChanged { adapters } => { + snap.adapters = adapters; } } } @@ -164,23 +190,16 @@ pub fn spawn_bus_loop() -> (BusHandle, mpsc::Receiver) { (BusHandle {}, rx) } -async fn run_subscribe_once( - tx: &mpsc::Sender, -) -> Result<(), String> { +async fn run_subscribe_once(tx: &mpsc::Sender) -> Result<(), String> { let (mut write, reader) = open_ui_client().await?; write_msg(&mut write, &ClientMessage::Subscribe).await?; let mut lines = reader.lines(); - while let Some(line) = lines - .next_line() - .await - .map_err(|e| format!("read: {e}"))? - { + while let Some(line) = lines.next_line().await.map_err(|e| format!("read: {e}"))? { if line.trim().is_empty() { continue; } - let msg: ServerMessage = - serde_json::from_str(&line).map_err(|e| format!("parse: {e}"))?; + let msg: ServerMessage = serde_json::from_str(&line).map_err(|e| format!("parse: {e}"))?; if tx.send(msg).await.is_err() { break; } diff --git a/apps/microbridge-ui/src-tauri/src/lib.rs b/apps/microbridge-ui/src-tauri/src/lib.rs index 064c196..7b489b2 100644 --- a/apps/microbridge-ui/src-tauri/src/lib.rs +++ b/apps/microbridge-ui/src-tauri/src/lib.rs @@ -3,16 +3,18 @@ mod bus; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; use std::time::Duration; use bus::{apply_event, spawn_bus_loop, BusHandle, CachedSnapshot}; -use mb_protocol::{BusEvent, DaemonConfig, ServerMessage, Snapshot}; +use mb_protocol::{BusEvent, ClientMessage, DaemonConfig, ServerMessage, Snapshot}; use tauri::{ menu::{Menu, MenuItem, PredefinedMenuItem}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, - AppHandle, Emitter, Manager, PhysicalPosition, Position, Size, WebviewWindow, + AppHandle, Emitter, Manager, PhysicalPosition, PhysicalSize, Position, Size, WebviewWindow, }; use tokio::sync::Mutex; @@ -21,6 +23,238 @@ struct AppState { snapshot: CachedSnapshot, } +const CURSOR_PLUGIN_NAME: &str = "microbridge"; +const CURSOR_PLUGIN_MARKER: &str = ".microbridge-owned"; +static CURSOR_PLUGIN_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +fn validate_cursor_plugin(path: &Path) -> Result<(), String> { + let manifest_path = path.join(".cursor-plugin/plugin.json"); + let manifest = fs::read_to_string(&manifest_path) + .map_err(|e| format!("read {}: {e}", manifest_path.display()))?; + let manifest: serde_json::Value = serde_json::from_str(&manifest) + .map_err(|e| format!("parse {}: {e}", manifest_path.display()))?; + if manifest.get("name").and_then(|value| value.as_str()) != Some(CURSOR_PLUGIN_NAME) { + return Err(format!( + "{} is not the Microbridge Cursor integration", + path.display() + )); + } + for relative in ["hooks/hooks.json", "hooks/microbridge-event.mjs", "hooks/event.mjs"] { + if !path.join(relative).is_file() { + return Err(format!("Cursor integration is missing {relative}")); + } + } + Ok(()) +} + +fn copy_dir(source: &Path, destination: &Path) -> Result<(), String> { + fs::create_dir_all(destination) + .map_err(|e| format!("create {}: {e}", destination.display()))?; + for entry in fs::read_dir(source).map_err(|e| format!("read {}: {e}", source.display()))? { + let entry = entry.map_err(|e| e.to_string())?; + let file_type = entry.file_type().map_err(|e| e.to_string())?; + let target = destination.join(entry.file_name()); + if file_type.is_dir() { + copy_dir(&entry.path(), &target)?; + } else if file_type.is_file() { + fs::copy(entry.path(), &target) + .map_err(|e| format!("copy {}: {e}", target.display()))?; + } + } + Ok(()) +} + +fn cursor_plugin_source(app: &AppHandle) -> Result { + let bundled = app + .path() + .resource_dir() + .map_err(|e| e.to_string())? + .join("cursor-plugin"); + if validate_cursor_plugin(&bundled).is_ok() { + return Ok(bundled); + } + + // `tauri dev` reads the repository copy; release bundles use Resources. + let repository = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../../adapters/cursor"); + validate_cursor_plugin(&repository)?; + Ok(repository) +} + +fn cursor_plugin_destination() -> Result { + let home = std::env::var_os("HOME").ok_or_else(|| "HOME is unavailable".to_string())?; + Ok(PathBuf::from(home) + .join(".cursor/plugins/local") + .join(CURSOR_PLUGIN_NAME)) +} + +fn install_cursor_integration(app: &AppHandle) -> Result { + let source = cursor_plugin_source(app)?; + let destination = cursor_plugin_destination()?; + install_cursor_integration_at( + &source, + &destination, + &app.package_info().version.to_string(), + )?; + Ok(destination) +} + +fn install_cursor_integration_at( + source: &Path, + destination: &Path, + version: &str, +) -> Result<(), String> { + let _operation = CURSOR_PLUGIN_LOCK + .lock() + .map_err(|_| "Cursor integration installer lock is unavailable".to_string())?; + validate_cursor_plugin(source)?; + let parent = destination + .parent() + .ok_or_else(|| "Cursor plugin destination has no parent".to_string())?; + fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?; + + if destination.exists() { + if !destination.join(CURSOR_PLUGIN_MARKER).is_file() { + return Err(format!( + "Preserving unowned Cursor plugin at {}. Move it aside before enabling Microbridge.", + destination.display() + )); + } + validate_cursor_plugin(destination)?; + } + + let pid = std::process::id(); + let staging = parent.join(format!(".{CURSOR_PLUGIN_NAME}-installing-{pid}")); + let backup = parent.join(format!(".{CURSOR_PLUGIN_NAME}-backup-{pid}")); + if staging.exists() { + fs::remove_dir_all(&staging) + .map_err(|e| format!("remove stale {}: {e}", staging.display()))?; + } + if backup.exists() { + fs::remove_dir_all(&backup) + .map_err(|e| format!("remove stale {}: {e}", backup.display()))?; + } + + copy_dir(source, &staging)?; + fs::write( + staging.join(CURSOR_PLUGIN_MARKER), + format!("Microbridge {version}\n"), + ) + .map_err(|e| format!("write ownership marker: {e}"))?; + validate_cursor_plugin(&staging)?; + + if destination.exists() { + fs::rename(destination, &backup) + .map_err(|e| format!("prepare Cursor integration update: {e}"))?; + } + if let Err(error) = fs::rename(&staging, destination) { + if backup.exists() { + let _ = fs::rename(&backup, destination); + } + return Err(format!("install Cursor integration: {error}")); + } + if backup.exists() { + fs::remove_dir_all(&backup) + .map_err(|e| format!("remove old Cursor integration: {e}"))?; + } + Ok(()) +} + +fn remove_cursor_integration() -> Result { + let destination = cursor_plugin_destination()?; + remove_cursor_integration_at(&destination) +} + +fn remove_cursor_integration_at(destination: &Path) -> Result { + let _operation = CURSOR_PLUGIN_LOCK + .lock() + .map_err(|_| "Cursor integration installer lock is unavailable".to_string())?; + if !destination.exists() { + return Ok(false); + } + if !destination.join(CURSOR_PLUGIN_MARKER).is_file() { + return Err(format!( + "Preserving unowned Cursor plugin at {}. Remove it manually if that is intended.", + destination.display() + )); + } + validate_cursor_plugin(destination)?; + fs::remove_dir_all(destination) + .map_err(|e| format!("remove {}: {e}", destination.display()))?; + Ok(true) +} + +#[cfg(test)] +mod cursor_integration_tests { + use super::*; + + #[test] + fn installs_updates_and_removes_only_the_microbridge_plugin() { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "microbridge-cursor-installer-{}-{nonce}", + std::process::id() + )); + let destination = root.join("local/microbridge"); + let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../../adapters/cursor"); + + install_cursor_integration_at(&source, &destination, "0.2.1").unwrap(); + validate_cursor_plugin(&destination).unwrap(); + assert_eq!( + fs::read_to_string(destination.join(CURSOR_PLUGIN_MARKER)).unwrap(), + "Microbridge 0.2.1\n" + ); + + install_cursor_integration_at(&source, &destination, "0.2.2").unwrap(); + assert_eq!( + fs::read_to_string(destination.join(CURSOR_PLUGIN_MARKER)).unwrap(), + "Microbridge 0.2.2\n" + ); + assert!(remove_cursor_integration_at(&destination).unwrap()); + assert!(!destination.exists()); + assert!(!remove_cursor_integration_at(&destination).unwrap()); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn preserves_an_unowned_cursor_plugin() { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "microbridge-unowned-cursor-plugin-{}-{nonce}", + std::process::id() + )); + let destination = root.join("local/microbridge"); + let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../../adapters/cursor"); + copy_dir(&source, &destination).unwrap(); + + assert!(install_cursor_integration_at(&source, &destination, "0.2.1").is_err()); + assert!(!destination.join(CURSOR_PLUGIN_MARKER).exists()); + assert!(remove_cursor_integration_at(&destination).is_err()); + assert!(destination.join(".cursor-plugin/plugin.json").is_file()); + let _ = fs::remove_dir_all(root); + } +} + +fn sync_cursor_integration(app: &AppHandle, synced: &AtomicBool, enabled: bool) { + if !enabled { + synced.store(false, Ordering::Relaxed); + return; + } + if !synced.swap(true, Ordering::Relaxed) && install_cursor_integration(app).is_err() { + // Retry on the next daemon snapshot or config event. Settings reports + // explicit installation errors to the user. + synced.store(false, Ordering::Relaxed); + } +} + fn physical_tray_rect(rect: &tauri::Rect, scale: f64) -> (f64, f64, f64, f64) { let (x, y) = match rect.position { Position::Physical(p) => (p.x as f64, p.y as f64), @@ -47,7 +281,10 @@ fn position_below_tray(window: &WebviewWindow, tray_x: f64, tray_y: f64, tray_w: x = x.clamp(min_x + 8.0, max_x - 8.0); } let y = (tray_y + tray_h + 6.0).round() as i32; - let _ = window.set_position(Position::Physical(PhysicalPosition::new(x.round() as i32, y))); + let _ = window.set_position(Position::Physical(PhysicalPosition::new( + x.round() as i32, + y, + ))); } fn toggle_popover(app: &AppHandle, tray_x: f64, tray_y: f64, tray_w: f64, tray_h: f64) { @@ -116,6 +353,146 @@ async fn set_config( Ok(next) } +#[tauri::command] +async fn set_adapter_enabled( + adapter_id: String, + enabled: bool, + state: tauri::State<'_, AppState>, + app: AppHandle, +) -> Result { + let was_enabled = state + .snapshot + .lock() + .await + .as_ref() + .and_then(|snapshot| snapshot.config.adapters.get(&adapter_id)) + .map(|preference| preference.enabled) + .unwrap_or(false); + let message = state + .bus + .adapter_operation(ClientMessage::SetAdapterEnabled { + adapter_id: adapter_id.clone(), + enabled, + }) + .await?; + if adapter_id == "cursor" && enabled { + match install_cursor_integration(&app) { + Ok(path) => { + return Ok(format!( + "{message} Cursor integration installed from Microbridge at {}. Reload Cursor once if it is already open.", + path.display() + )); + } + Err(error) => { + if !was_enabled { + let _ = state + .bus + .adapter_operation(ClientMessage::SetAdapterEnabled { + adapter_id: adapter_id.clone(), + enabled: false, + }) + .await; + } + return Err(format!("Cursor was not enabled because its bundled integration could not be installed: {error}")); + } + } + } + Ok(message) +} + +#[tauri::command] +async fn pair_adapter( + adapter_id: String, + pairing_url: String, + state: tauri::State<'_, AppState>, +) -> Result { + state + .bus + .adapter_operation(ClientMessage::PairAdapter { + adapter_id, + pairing_url, + }) + .await +} + +#[tauri::command] +async fn forget_adapter( + adapter_id: String, + state: tauri::State<'_, AppState>, + app: AppHandle, +) -> Result { + let was_enabled = state + .snapshot + .lock() + .await + .as_ref() + .and_then(|snapshot| snapshot.config.adapters.get(&adapter_id)) + .map(|preference| preference.enabled) + .unwrap_or(false); + let removed = if adapter_id == "cursor" { + remove_cursor_integration()? + } else { + false + }; + let operation = state + .bus + .adapter_operation(ClientMessage::ForgetAdapter { + adapter_id: adapter_id.clone(), + }) + .await; + let message = match operation { + Ok(message) => message, + Err(error) => { + if removed { + let _ = install_cursor_integration(&app); + } + if was_enabled { + let _ = state + .bus + .adapter_operation(ClientMessage::SetAdapterEnabled { + adapter_id: adapter_id.clone(), + enabled: true, + }) + .await; + } + return Err(format!("Adapter removal failed and the prior state was restored: {error}")); + } + }; + if adapter_id == "cursor" && removed { + return Ok(format!( + "{message} The bundled Cursor integration was removed. Reload Cursor once if it is already open." + )); + } + Ok(message) +} + +/// Fit the popover to its measured card while leaving a safety margin at the +/// bottom of the active display. The frontend gives only Threads the overflow. +#[tauri::command] +fn fit_popover(content_height: f64, app: AppHandle) -> Result<(), String> { + let window = app + .get_webview_window("popover") + .ok_or_else(|| "popover window unavailable".to_string())?; + let scale = window.scale_factor().map_err(|e| e.to_string())?; + let current_size = window.outer_size().map_err(|e| e.to_string())?; + let current_position = window.outer_position().map_err(|e| e.to_string())?; + let monitor = window + .current_monitor() + .map_err(|e| e.to_string())? + .ok_or_else(|| "active monitor unavailable".to_string())?; + let monitor_bottom = i64::from(monitor.position().y) + i64::from(monitor.size().height); + let safety_margin = (24.0 * scale).round() as i64; + let available = + (monitor_bottom - i64::from(current_position.y) - safety_margin).max(180) as u32; + let desired = (content_height.max(180.0) * scale).round() as u32; + window + .set_size(Size::Physical(PhysicalSize::new( + current_size.width, + desired.min(available), + ))) + .map_err(|e| e.to_string()) +} + /// Show the settings window (and hide the popover). Shared by the `open_settings` /// command and the tray right-click menu. fn show_settings_window(app: &AppHandle) { @@ -156,7 +533,7 @@ fn quit_ui(app: AppHandle) { } /// Install channel of the running app. Homebrew drops a `.microbridge-brew` -/// marker at the bundle root (see the formula's `post_install`); its absence +/// marker at the bundle root (installed by the formula's service wrapper); its absence /// means a DMG/manual install. The in-app self-updater only replaces `direct` /// installs — brew copies are routed to `brew upgrade` so the formula version /// and the on-disk bundle never drift apart. @@ -215,6 +592,8 @@ pub fn run() { let snap_for_loop = Arc::clone(&snapshot); let hud_gen_loop = Arc::clone(&hud_generation); let handle = app.handle().clone(); + let cursor_integration_synced = Arc::new(AtomicBool::new(false)); + let cursor_sync_loop = Arc::clone(&cursor_integration_synced); tauri::async_runtime::spawn(async move { let mut last_focus: Option = None; @@ -222,6 +601,13 @@ pub fn run() { while let Some(msg) = event_rx.recv().await { match msg { ServerMessage::Snapshot { snapshot: s } => { + let cursor_enabled = s + .config + .adapters + .get("cursor") + .map(|preference| preference.enabled) + .unwrap_or(false); + sync_cursor_integration(&handle, &cursor_sync_loop, cursor_enabled); last_focus = s.focused_session_id.clone(); saw_snapshot = true; *snap_for_loop.lock().await = Some(s.clone()); @@ -229,13 +615,28 @@ pub fn run() { } ServerMessage::Event { event } => { // Ignore reconnect "offline" noise before first snapshot. - if !saw_snapshot { - if matches!( + if !saw_snapshot + && matches!( &event, - BusEvent::DeviceChanged { connected: false, .. } - ) { - continue; - } + BusEvent::DeviceChanged { + connected: false, + .. + } + ) + { + continue; + } + if let BusEvent::ConfigChanged { config } = &event { + let cursor_enabled = config + .adapters + .get("cursor") + .map(|preference| preference.enabled) + .unwrap_or(false); + sync_cursor_integration( + &handle, + &cursor_sync_loop, + cursor_enabled, + ); } let focus_changed = matches!(&event, BusEvent::FocusChanged { .. }); let mut guard = snap_for_loop.lock().await; @@ -255,6 +656,12 @@ pub fn run() { } } ServerMessage::Config { config } => { + let cursor_enabled = config + .adapters + .get("cursor") + .map(|preference| preference.enabled) + .unwrap_or(false); + sync_cursor_integration(&handle, &cursor_sync_loop, cursor_enabled); let mut guard = snap_for_loop.lock().await; if let Some(s) = guard.as_mut() { s.config = config; @@ -274,6 +681,7 @@ pub fn run() { // template image so macOS tints it for light/dark menu bars. The full // app icon is an opaque squircle and would appear as a black blob here. let tray_icon = tauri::include_image!("icons/tray.png"); + let last_left_click_ms = Arc::new(AtomicU64::new(0)); // Right-click context menu. Left-click still toggles the popover // (`show_menu_on_left_click(false)` keeps the menu on right-click only). @@ -310,7 +718,7 @@ pub fn run() { "quit" => app.exit(0), _ => {} }) - .on_tray_icon_event(|tray, event| { + .on_tray_icon_event(move |tray, event| { if let TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, @@ -318,6 +726,14 @@ pub fn run() { .. } = event { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + let previous = last_left_click_ms.swap(now, Ordering::Relaxed); + if now.saturating_sub(previous) < 180 { + return; + } let app = tray.app_handle(); let scale = app .get_webview_window("popover") @@ -353,6 +769,10 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ get_snapshot, set_config, + set_adapter_enabled, + pair_adapter, + forget_adapter, + fit_popover, open_settings, close_settings, hide_popover, diff --git a/apps/microbridge-ui/src-tauri/tauri.conf.json b/apps/microbridge-ui/src-tauri/tauri.conf.json index 09cf787..63cee7a 100644 --- a/apps/microbridge-ui/src-tauri/tauri.conf.json +++ b/apps/microbridge-ui/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Microbridge", - "version": "0.2.0", + "version": "0.2.1", "identifier": "ai.microbridge.ui", "build": { "beforeDevCommand": "npm run dev", @@ -70,6 +70,12 @@ "active": true, "targets": "all", "createUpdaterArtifacts": true, + "resources": { + "../../../adapters/cursor/.cursor-plugin/plugin.json": "cursor-plugin/.cursor-plugin/plugin.json", + "../../../adapters/cursor/hooks/hooks.json": "cursor-plugin/hooks/hooks.json", + "../../../adapters/cursor/hooks/event.mjs": "cursor-plugin/hooks/event.mjs", + "../../../adapters/cursor/hooks/microbridge-event.mjs": "cursor-plugin/hooks/microbridge-event.mjs" + }, "icon": [ "icons/32x32.png", "icons/128x128.png", diff --git a/apps/microbridge-ui/src/lib/bus.ts b/apps/microbridge-ui/src/lib/bus.ts index db7ffa3..018521c 100644 --- a/apps/microbridge-ui/src/lib/bus.ts +++ b/apps/microbridge-ui/src/lib/bus.ts @@ -40,11 +40,91 @@ const DEMO: Snapshot = { pause_leds: false, appearance: "system", lighting_preset: "codex", - state_colors: {}, + state_colors: { + idle: "#E9E9E6", + thinking: "#3D7EFF", + working: "#3D7EFF", + awaiting_approval: "#FFB000", + done: "#30C463", + error: "#FF453A", + }, + adapters: { + codex: { enabled: true }, + claude: { enabled: true }, + cursor: { enabled: false }, + t3code: { enabled: false }, + }, + hardware_control_enabled: false, brightness: 80, sleep_minutes: 3, frontmost_app: null, }, + adapters: [ + { + id: "codex", + display_name: "Codex CLI", + kind: "native", + state: "connected", + capabilities: { + lifecycle_observation: true, + approval_acceptance: false, + approval_rejection: false, + interrupt: false, + new_session: false, + focus_open: false, + reasoning_effort: false, + }, + diagnostic: "Built-in lifecycle watcher is active.", + }, + { + id: "claude", + display_name: "Claude Code", + kind: "native", + state: "connected", + capabilities: { + lifecycle_observation: true, + approval_acceptance: false, + approval_rejection: false, + interrupt: false, + new_session: false, + focus_open: false, + reasoning_effort: false, + }, + diagnostic: "Built-in lifecycle watcher is active.", + }, + { + id: "cursor", + display_name: "Cursor", + kind: "community", + state: "disabled", + capabilities: { + lifecycle_observation: false, + approval_acceptance: false, + approval_rejection: false, + interrupt: false, + new_session: false, + focus_open: false, + reasoning_effort: false, + }, + diagnostic: "Disabled until you explicitly enable this integration.", + }, + { + id: "t3code", + display_name: "T3 Code", + kind: "community", + state: "disabled", + capabilities: { + lifecycle_observation: false, + approval_acceptance: false, + approval_rejection: false, + interrupt: false, + new_session: false, + focus_open: false, + reasoning_effort: false, + }, + diagnostic: "Disabled until you explicitly enable this integration.", + }, + ], }; async function invoke(cmd: string, args?: Record): Promise { @@ -70,6 +150,28 @@ export async function setConfig(config: DaemonConfig): Promise { return next ?? config; } +export async function setAdapterEnabled(adapterId: string, enabled: boolean): Promise { + const message = await invoke("set_adapter_enabled", { adapterId, enabled }); + if (message === null) throw new Error("Adapter controls require the Microbridge app."); + return message; +} + +export async function pairAdapter(adapterId: string, pairingUrl: string): Promise { + const message = await invoke("pair_adapter", { adapterId, pairingUrl }); + if (message === null) throw new Error("Pairing requires the Microbridge app."); + return message; +} + +export async function forgetAdapter(adapterId: string): Promise { + const message = await invoke("forget_adapter", { adapterId }); + if (message === null) throw new Error("Adapter controls require the Microbridge app."); + return message; +} + +export async function fitPopover(contentHeight: number): Promise { + await invoke("fit_popover", { contentHeight }); +} + export async function openSettings(): Promise { await invoke("open_settings"); } diff --git a/apps/microbridge-ui/src/lib/threads.ts b/apps/microbridge-ui/src/lib/threads.ts index e78ee04..f867792 100644 --- a/apps/microbridge-ui/src/lib/threads.ts +++ b/apps/microbridge-ui/src/lib/threads.ts @@ -1,7 +1,5 @@ import type { SessionStatus, Snapshot } from "./types"; -const DEFAULT_LIMIT = 8; - function rank(session: SessionStatus, snapshot: Snapshot, onKeys: Set): number { let score = 0; if (session.id === snapshot.focused_session_id) score += 1000; @@ -29,7 +27,6 @@ function rank(session: SessionStatus, snapshot: Snapshot, onKeys: Set): /** Threads shown in the menu bar popover — focused / on keys / active first. */ export function visibleThreads( snapshot: Snapshot, - limit = DEFAULT_LIMIT, ): { threads: SessionStatus[]; total: number; truncated: boolean } { const onKeys = new Set( snapshot.agent_key_session_ids.filter((id): id is string => Boolean(id)), @@ -39,7 +36,7 @@ export function visibleThreads( if (diff !== 0) return diff; return b.updated_at_ms - a.updated_at_ms; }); - const threads = ranked.slice(0, limit); + const threads = ranked; return { threads, total: snapshot.sessions.length, diff --git a/apps/microbridge-ui/src/lib/types.ts b/apps/microbridge-ui/src/lib/types.ts index 7452c57..f738d84 100644 --- a/apps/microbridge-ui/src/lib/types.ts +++ b/apps/microbridge-ui/src/lib/types.ts @@ -22,6 +22,46 @@ export type KeySource = | "custom"; export type Appearance = "system" | "light" | "dark"; +export type LightingPreset = "codex" | "phosphor" | "custom"; + +export interface StateColors { + idle: string; + thinking: string; + working: string; + awaiting_approval: string; + done: string; + error: string; +} + +export type AdapterConnectionState = + | "disabled" + | "needs_setup" + | "connecting" + | "connected" + | "limited" + | "incompatible" + | "error"; + +export interface AdapterCapabilities { + lifecycle_observation: boolean; + approval_acceptance: boolean; + approval_rejection: boolean; + interrupt: boolean; + new_session: boolean; + focus_open: boolean; + reasoning_effort: boolean; +} + +export interface AdapterStatus { + id: string; + display_name: string; + kind: "native" | "community"; + state: AdapterConnectionState; + capabilities: AdapterCapabilities; + version?: string; + last_activity_ms?: number; + diagnostic: string; +} export interface DaemonConfig { key_source: KeySource; @@ -32,8 +72,10 @@ export interface DaemonConfig { approvals_interrupt: boolean; pause_leds: boolean; appearance: Appearance; - lighting_preset: string; - state_colors: Record; + lighting_preset: LightingPreset; + state_colors: StateColors; + adapters: Record; + hardware_control_enabled: boolean; brightness: number; sleep_minutes: number; frontmost_app: string | null; @@ -46,6 +88,7 @@ export interface Snapshot { device_connected: boolean; device_name: string; config: DaemonConfig; + adapters: AdapterStatus[]; } export const STATE_COLORS: Record = { @@ -57,6 +100,16 @@ export const STATE_COLORS: Record = { error: "#FF453A", }; +export const CODEX_PALETTE: StateColors = { ...STATE_COLORS }; +export const PHOSPHOR_PALETTE: StateColors = { + idle: "#4A4A52", + thinking: "#FFB454", + working: "#FF6A00", + awaiting_approval: "#FF3D00", + done: "#3DDC84", + error: "#FF4757", +}; + export const STATE_LABELS: Record = { idle: "Idle", thinking: "Thinking", diff --git a/apps/microbridge-ui/src/surfaces/Popover.tsx b/apps/microbridge-ui/src/surfaces/Popover.tsx index d0dda19..2c7facf 100644 --- a/apps/microbridge-ui/src/surfaces/Popover.tsx +++ b/apps/microbridge-ui/src/surfaces/Popover.tsx @@ -1,8 +1,10 @@ +import { useEffect, useRef } from "react"; import type { Snapshot } from "../lib/types"; import { STATE_COLORS, STATE_LABELS, elapsed } from "../lib/types"; import { DARK, LIGHT, type ThemeTokens } from "../lib/theme"; import { visibleThreads } from "../lib/threads"; import { DeviceEcho } from "../components/DeviceEcho"; +import { fitPopover } from "../lib/bus"; const MicroGlyph = ({ color }: { color: string }) => ( `${adapter.id}:${adapter.state}:${adapter.diagnostic}`) + .join("|"); + const cardRef = useRef(null); + const threadListRef = useRef(null); + + useEffect(() => { + const card = cardRef.current; + if (!card) return; + const measure = () => { + const threadList = threadListRef.current; + const current = card.getBoundingClientRect().height; + const desired = threadList + ? current - threadList.clientHeight + threadList.scrollHeight + 8 + : card.scrollHeight + 8; + void fitPopover(Math.ceil(desired)); + }; + const observer = new ResizeObserver(measure); + observer.observe(card); + if (threadListRef.current) observer.observe(threadListRef.current); + measure(); + return () => observer.disconnect(); + }, [threads.length, snapshot.device_connected, snapshot.device_name, adapterLayoutKey]); const footerButton = ( label: string, @@ -128,11 +153,12 @@ export function Popover({ return (
)} @@ -250,7 +276,7 @@ export function Popover({
@@ -276,11 +302,12 @@ export function Popover({ No live sessions

) : ( - threads.map((s) => ( -
+
+ {threads.map((s) => ( +
{elapsed(s.updated_at_ms)} -
- )) +
+ ))} +
)}
@@ -321,9 +349,9 @@ export function Popover({ className="mt-1 max-w-[240px] text-[12px] leading-relaxed" style={{ color: t.textSecondary }} > - Plug in over USB-C. Real Agent Key LEDs/input land with HID - packing (device captures). Until then use Simulator mode or start - the daemon to watch sessions. + Plug in over USB-C, then enable hardware control in Device + settings. If another app owns the HID interface, Microbridge keeps + observing threads without claiming the deck.

)} diff --git a/apps/microbridge-ui/src/surfaces/Settings.tsx b/apps/microbridge-ui/src/surfaces/Settings.tsx index 7f27db1..08c0eb5 100644 --- a/apps/microbridge-ui/src/surfaces/Settings.tsx +++ b/apps/microbridge-ui/src/surfaces/Settings.tsx @@ -1,6 +1,11 @@ import { useEffect, useState } from "react"; -import type { DaemonConfig, Snapshot } from "../lib/types"; -import { STATE_COLORS, STATE_LABELS } from "../lib/types"; +import type { AdapterCapabilities, DaemonConfig, Snapshot, StateColors } from "../lib/types"; +import { + CODEX_PALETTE, + PHOSPHOR_PALETTE, + STATE_COLORS, + STATE_LABELS, +} from "../lib/types"; import { DARK, LIGHT } from "../lib/theme"; import { appVersion, @@ -15,6 +20,26 @@ import { DeviceTwin, type ControlId, } from "../components/DeviceTwin"; +import { forgetAdapter, pairAdapter, setAdapterEnabled } from "../lib/bus"; + +const LIGHTING_STATES: { id: keyof StateColors; label: string }[] = [ + { id: "idle", label: "Idle" }, + { id: "thinking", label: "Thinking" }, + { id: "working", label: "Working" }, + { id: "awaiting_approval", label: "Needs approval" }, + { id: "done", label: "Complete" }, + { id: "error", label: "Error" }, +]; + +const CAPABILITIES: { id: keyof AdapterCapabilities; label: string }[] = [ + { id: "lifecycle_observation", label: "Live state" }, + { id: "approval_acceptance", label: "Approve" }, + { id: "approval_rejection", label: "Reject" }, + { id: "interrupt", label: "Interrupt" }, + { id: "new_session", label: "New session" }, + { id: "focus_open", label: "Open" }, + { id: "reasoning_effort", label: "Effort" }, +]; const KEY_SOURCES: { id: DaemonConfig["key_source"]; @@ -75,12 +100,31 @@ export function Settings({ const [version, setVersion] = useState(null); const [channel, setChannel] = useState(null); const [autoCheck, setAutoCheck] = useState(() => autoCheckEnabled()); + const [pairingUrl, setPairingUrl] = useState(""); + const [adapterMessage, setAdapterMessage] = useState(null); + const [adapterBusy, setAdapterBusy] = useState>(() => new Set()); useEffect(() => { void appVersion().then(setVersion); void updateChannel().then(setChannel); }, []); + const runAdapterOperation = async (adapterId: string, work: () => Promise) => { + setAdapterBusy((current) => new Set(current).add(adapterId)); + setAdapterMessage(null); + try { + setAdapterMessage(await work()); + } catch (error) { + setAdapterMessage(error instanceof Error ? error.message : String(error)); + } finally { + setAdapterBusy((current) => { + const next = new Set(current); + next.delete(adapterId); + return next; + }); + } + }; + const tabs: { id: Tab; label: string }[] = [ { id: "keys", label: "Keys" }, { id: "agent", label: "Agent Keys" }, @@ -140,7 +184,8 @@ export function Settings({ style={{ color: t.textSecondary }} > Click a control on the twin to inspect it. Agent Keys show the - live thread; command bindings land with HID. + live thread; commands route only when hardware control is enabled + and the focused adapter advertises the action.

Device

- Appearance, lighting, and sleep. Zero network — local socket + USB - only. + Appearance, lighting, and sleep. Device control stays on this Mac.

Appearance

@@ -333,33 +377,92 @@ export function Settings({

Lighting

-
- - +

+ Lighting maps agent lifecycle states to the Agent Key LEDs. Codex Defaults is the + recommended palette, Phosphor is a warmer alternate, and Custom lets you choose each + state color. Changes apply immediately and persist on this Mac. +

+
+ {[ + { id: "codex" as const, label: "Codex Defaults", palette: CODEX_PALETTE }, + { id: "phosphor" as const, label: "Phosphor", palette: PHOSPHOR_PALETTE }, + { id: "custom" as const, label: "Custom", palette: cfg.state_colors }, + ].map((preset) => ( + + ))}
+ {cfg.lighting_preset === "custom" && ( +
+ {LIGHTING_STATES.map((state) => ( + + ))} +
+ )} + + +

Hardware control

+ +