diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cac73790..1cc5067c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -103,11 +103,25 @@ jobs: ELECTROBUN_DEVELOPER_ID: ${{ secrets.ELECTROBUN_DEVELOPER_ID }} run: bun run desktop:release + - name: Build Homebrew app archive + run: | + set -euo pipefail + brew install zstd + tmp_dir="$(mktemp -d)" + zstd -d -o "$tmp_dir/Gloomberb.app.tar" artifacts/stable-macos-arm64-Gloomberb.app.tar.zst + tar -xf "$tmp_dir/Gloomberb.app.tar" -C "$tmp_dir" + test -x "$tmp_dir/Gloomberb.app/Contents/Resources/gloomberb" + test -f "$tmp_dir/Gloomberb.app/Contents/Resources/gloomberb-tui/tui-entry.js" + test -f "$tmp_dir/Gloomberb.app/Contents/Resources/gloomberb-tui/node_modules/@opentui/core-darwin-arm64/index.ts" + "$tmp_dir/Gloomberb.app/Contents/Resources/gloomberb" help + ditto -c -k --keepParent "$tmp_dir/Gloomberb.app" artifacts/stable-macos-arm64-Gloomberb.app.zip + - name: Verify desktop artifacts run: | set -euo pipefail test -f artifacts/stable-macos-arm64-Gloomberb.dmg test -f artifacts/stable-macos-arm64-Gloomberb.app.tar.zst + test -f artifacts/stable-macos-arm64-Gloomberb.app.zip test -f artifacts/stable-macos-arm64-update.json codesign --verify --verbose=4 artifacts/stable-macos-arm64-Gloomberb.dmg xcrun stapler validate -v artifacts/stable-macos-arm64-Gloomberb.dmg @@ -135,6 +149,43 @@ jobs: with: files: dist/* + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Update Homebrew tap + env: + GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + run: | + set -euo pipefail + if [ -z "${GH_TOKEN:-}" ]; then + echo "HOMEBREW_TAP_TOKEN is not configured; skipping Homebrew tap update." + exit 0 + fi + + VERSION="${GITHUB_REF_NAME#v}" + ZIP_PATH="dist/stable-macos-arm64-Gloomberb.app.zip" + SHA256="$(sha256sum "$ZIP_PATH" | awk '{print $1}')" + TAP_DIR="$RUNNER_TEMP/homebrew-tap" + + git config --global user.name "gloomberb-release" + git config --global user.email "actions@users.noreply.github.com" + gh repo clone vincelwt/homebrew-tap "$TAP_DIR" + git -C "$TAP_DIR" remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/vincelwt/homebrew-tap.git" + bun run scripts/write-homebrew-cask.ts \ + --version "$VERSION" \ + --sha256 "$SHA256" \ + --output "$TAP_DIR/Casks/gloomberb.rb" + + cd "$TAP_DIR" + if git diff --quiet; then + echo "Homebrew tap already up to date." + exit 0 + fi + git add Casks/gloomberb.rb + git commit -m "Update Gloomberb to $VERSION" + git push + - uses: actions/setup-node@v4 with: node-version: 20 diff --git a/.github/workflows/verify-desktop-homebrew.yml b/.github/workflows/verify-desktop-homebrew.yml new file mode 100644 index 00000000..f612fb68 --- /dev/null +++ b/.github/workflows/verify-desktop-homebrew.yml @@ -0,0 +1,122 @@ +name: Verify Desktop Homebrew Package + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + verify: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - run: bun install + + - name: Configure Apple signing + env: + APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_KEYCHAIN_PASSWORD: ${{ secrets.APPLE_KEYCHAIN_PASSWORD }} + ELECTROBUN_APPLEAPIKEY: ${{ secrets.ELECTROBUN_APPLEAPIKEY }} + ELECTROBUN_APPLEAPIKEY_BASE64: ${{ secrets.ELECTROBUN_APPLEAPIKEY_BASE64 }} + ELECTROBUN_APPLEAPIISSUER: ${{ secrets.ELECTROBUN_APPLEAPIISSUER }} + ELECTROBUN_DEVELOPER_ID: ${{ secrets.ELECTROBUN_DEVELOPER_ID }} + run: | + set -euo pipefail + + : "${APPLE_CERTIFICATE_BASE64:?missing APPLE_CERTIFICATE_BASE64 secret}" + : "${APPLE_CERTIFICATE_PASSWORD:?missing APPLE_CERTIFICATE_PASSWORD secret}" + : "${APPLE_KEYCHAIN_PASSWORD:?missing APPLE_KEYCHAIN_PASSWORD secret}" + : "${ELECTROBUN_APPLEAPIKEY:?missing ELECTROBUN_APPLEAPIKEY secret}" + : "${ELECTROBUN_APPLEAPIKEY_BASE64:?missing ELECTROBUN_APPLEAPIKEY_BASE64 secret}" + : "${ELECTROBUN_APPLEAPIISSUER:?missing ELECTROBUN_APPLEAPIISSUER secret}" + : "${ELECTROBUN_DEVELOPER_ID:?missing ELECTROBUN_DEVELOPER_ID secret}" + + decode_base64() { + if base64 --help 2>&1 | grep -q -- "--decode"; then + base64 --decode + else + base64 -D + fi + } + + CERTIFICATE_PATH="$RUNNER_TEMP/apple-signing-certificate.p12" + KEYCHAIN_PATH="$RUNNER_TEMP/app-signing.keychain-db" + NOTARY_KEY_PATH="$RUNNER_TEMP/AuthKey_${ELECTROBUN_APPLEAPIKEY}.p8" + + printf "%s" "$APPLE_CERTIFICATE_BASE64" | decode_base64 > "$CERTIFICATE_PATH" + printf "%s" "$ELECTROBUN_APPLEAPIKEY_BASE64" | decode_base64 > "$NOTARY_KEY_PATH" + + security create-keychain -p "$APPLE_KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$APPLE_KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security import "$CERTIFICATE_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$APPLE_KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | tr -d '"') + security find-identity -v -p codesigning "$KEYCHAIN_PATH" + + echo "ELECTROBUN_APPLEAPIKEYPATH=$NOTARY_KEY_PATH" >> "$GITHUB_ENV" + + - name: Build signed desktop artifacts + env: + ELECTROBUN_APPLEAPIISSUER: ${{ secrets.ELECTROBUN_APPLEAPIISSUER }} + ELECTROBUN_APPLEAPIKEY: ${{ secrets.ELECTROBUN_APPLEAPIKEY }} + ELECTROBUN_DEVELOPER_ID: ${{ secrets.ELECTROBUN_DEVELOPER_ID }} + run: bun run desktop:release + + - name: Build and verify Homebrew archive + run: | + set -euo pipefail + brew install zstd + tmp_dir="$(mktemp -d)" + zstd -d -o "$tmp_dir/Gloomberb.app.tar" artifacts/stable-macos-arm64-Gloomberb.app.tar.zst + tar -xf "$tmp_dir/Gloomberb.app.tar" -C "$tmp_dir" + + test -x "$tmp_dir/Gloomberb.app/Contents/Resources/gloomberb" + test -f "$tmp_dir/Gloomberb.app/Contents/Resources/gloomberb-tui/tui-entry.js" + test -f "$tmp_dir/Gloomberb.app/Contents/Resources/gloomberb-tui/node_modules/@opentui/core-darwin-arm64/index.ts" + "$tmp_dir/Gloomberb.app/Contents/Resources/gloomberb" help + + codesign --verify --verbose=4 "$tmp_dir/Gloomberb.app" + ditto -c -k --keepParent "$tmp_dir/Gloomberb.app" artifacts/stable-macos-arm64-Gloomberb.app.zip + + - name: Verify desktop artifacts + run: | + set -euo pipefail + test -f artifacts/stable-macos-arm64-Gloomberb.dmg + test -f artifacts/stable-macos-arm64-Gloomberb.app.tar.zst + test -f artifacts/stable-macos-arm64-Gloomberb.app.zip + test -f artifacts/stable-macos-arm64-update.json + codesign --verify --verbose=4 artifacts/stable-macos-arm64-Gloomberb.dmg + xcrun stapler validate -v artifacts/stable-macos-arm64-Gloomberb.dmg + + - name: Verify cask generation and tap access + env: + GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + run: | + set -euo pipefail + : "${GH_TOKEN:?missing HOMEBREW_TAP_TOKEN secret}" + + sha256="$(shasum -a 256 artifacts/stable-macos-arm64-Gloomberb.app.zip | awk '{print $1}')" + tap_dir="$RUNNER_TEMP/homebrew-tap" + gh repo clone vincelwt/homebrew-tap "$tap_dir" + git -C "$tap_dir" remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/vincelwt/homebrew-tap.git" + bun run scripts/write-homebrew-cask.ts \ + --version "0.0.0" \ + --sha256 "$sha256" \ + --output "$tap_dir/Casks/gloomberb.rb" + + ruby -c "$tap_dir/Casks/gloomberb.rb" + grep -q 'stable-macos-arm64-Gloomberb.app.zip' "$tap_dir/Casks/gloomberb.rb" + grep -q 'binary "#{appdir}/Gloomberb.app/Contents/Resources/gloomberb", target: "gloomberb"' "$tap_dir/Casks/gloomberb.rb" + + cd "$tap_dir" + git add Casks/gloomberb.rb + git commit -m "Verify cask update" --allow-empty + git push --dry-run origin HEAD:homebrew-verify-${GITHUB_RUN_ID} diff --git a/README.md b/README.md index 55fa5d96..d12688e6 100644 --- a/README.md +++ b/README.md @@ -60,15 +60,23 @@ Open the command bar with `Ctrl+P` or `` ` ``, then type a shortcut or command n ## Install -Desktop: +macOS desktop app + terminal command: + +```bash +brew install --cask vincelwt/tap/gloomberb +# or +curl -fsSL gloomberb.com/install | bash +``` + +Both install `Gloomberb.app` and a `gloomberb` terminal command that runs the TUI through the app bundle, so the Bun runtime is stored once. + +Desktop-only download: - [Download Gloomberb for Mac](https://gloomberb.com/download/desktop) -Terminal UI: +Terminal-only install: ```bash -curl -fsSL gloomberb.com/install | bash -# or bun install -g gloomberb ``` @@ -78,6 +86,8 @@ Then run: gloomberb ``` +On macOS, app updates replace the app bundle in place and keep the terminal command pointing at the updated bundle. Homebrew users can also update through `brew upgrade --cask gloomberb`. + For the best terminal experience, use a [Kitty](https://sw.kovidgoyal.net/kitty/)-compatible terminal such as Ghostty, Kitty, or WezTerm. ## CLI diff --git a/electrobun.config.ts b/electrobun.config.ts index daf30c78..a556ad7e 100644 --- a/electrobun.config.ts +++ b/electrobun.config.ts @@ -42,7 +42,7 @@ const config: ElectrobunConfig = { }, scripts: { preBuild: "scripts/build-electrobun-view.ts", - postBuild: "", + postBuild: "scripts/install-electrobun-tui-shim.ts", postWrap: "", postPackage: "", }, diff --git a/scripts/install-electrobun-tui-shim.ts b/scripts/install-electrobun-tui-shim.ts new file mode 100644 index 00000000..4463d3c7 --- /dev/null +++ b/scripts/install-electrobun-tui-shim.ts @@ -0,0 +1,94 @@ +import { chmodSync, cpSync, existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "fs"; +import { join } from "path"; + +const SHIM = `#!/bin/sh +set -eu + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +CONTENTS_DIR="$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)" +cd "$CONTENTS_DIR/MacOS" +exec "./bun" "$CONTENTS_DIR/Resources/gloomberb-tui/tui-entry.js" "$@" +`; + +function appBundlesIn(dir: string): string[] { + if (!existsSync(dir)) return []; + return readdirSync(dir) + .filter((entry) => entry.endsWith(".app")) + .map((entry) => join(dir, entry)); +} + +function nativeFilesIn(dir: string): string[] { + if (!existsSync(dir)) return []; + + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name); + if (entry.isDirectory()) return nativeFilesIn(path); + if (entry.isFile() && (entry.name.endsWith(".dylib") || entry.name.endsWith(".node"))) return [path]; + return []; + }); +} + +function signNativeFiles(dir: string): void { + const developerId = process.env.ELECTROBUN_DEVELOPER_ID; + if (process.platform !== "darwin" || !developerId) return; + + for (const file of nativeFilesIn(dir)) { + const mode = statSync(file).mode; + chmodSync(file, mode | 0o755); + + const signed = Bun.spawnSync({ + cmd: ["codesign", "--force", "--timestamp", "--options", "runtime", "--sign", developerId, file], + stdout: "inherit", + stderr: "inherit", + }); + + if (signed.exitCode !== 0) { + process.exit(signed.exitCode ?? 1); + } + } +} + +const bundlePaths = [ + process.env.ELECTROBUN_WRAPPER_BUNDLE_PATH, + ...appBundlesIn(process.env.ELECTROBUN_BUILD_DIR ?? ""), +].filter((value): value is string => Boolean(value)); + +const uniqueBundlePaths = [...new Set(bundlePaths)]; +const nativeCorePackageName = `core-${process.platform}-${process.arch}`; +const nativeCorePackagePath = join(process.cwd(), "node_modules", "@opentui", nativeCorePackageName); + +for (const bundlePath of uniqueBundlePaths) { + const resourcesPath = join(bundlePath, "Contents", "Resources"); + if (!existsSync(resourcesPath)) continue; + + const tuiBundleDir = join(resourcesPath, "gloomberb-tui"); + rmSync(tuiBundleDir, { recursive: true, force: true }); + mkdirSync(tuiBundleDir, { recursive: true }); + + const build = Bun.spawnSync({ + cmd: [ + process.execPath, + "build", + join(process.cwd(), "src", "renderers", "electrobun", "bun", "tui-entry.ts"), + "--target=bun", + `--outdir=${tuiBundleDir}`, + ], + stdout: "inherit", + stderr: "inherit", + }); + + if (build.exitCode !== 0) { + process.exit(1); + } + + const nativeCoreDestPath = join(tuiBundleDir, "node_modules", "@opentui", nativeCorePackageName); + rmSync(nativeCoreDestPath, { recursive: true, force: true }); + mkdirSync(join(tuiBundleDir, "node_modules", "@opentui"), { recursive: true }); + cpSync(nativeCorePackagePath, nativeCoreDestPath, { recursive: true, dereference: true }); + signNativeFiles(nativeCoreDestPath); + + const shimPath = join(resourcesPath, "gloomberb"); + writeFileSync(shimPath, SHIM); + chmodSync(shimPath, 0o755); + console.log(`Installed TUI shim: ${shimPath}`); +} diff --git a/scripts/install.sh b/scripts/install.sh index 8e10055a..41a587da 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -3,6 +3,7 @@ set -e REPO="vincelwt/gloomberb" INSTALL_DIR="${GLOOMBERB_INSTALL_DIR:-$HOME/.local/bin}" +APP_DIR="${GLOOMBERB_APP_DIR:-/Applications}" # Detect platform OS="$(uname -s)" @@ -31,40 +32,128 @@ if [ "$os" = "darwin" ] && [ "$arch" = "x64" ]; then arch="arm64" fi -ASSET="gloomberb-${os}-${arch}.gz" +download_file() { + url="$1" + dest="$2" + if command -v curl >/dev/null 2>&1; then + curl -fSL --progress-bar "$url" -o "$dest" + elif command -v wget >/dev/null 2>&1; then + wget -q --show-progress "$url" -O "$dest" + else + echo "Error: curl or wget required" + exit 1 + fi +} -# Get latest release download URL -echo "Fetching latest release..." -DOWNLOAD_URL="https://github.com/${REPO}/releases/latest/download/${ASSET}" +install_file() { + src="$1" + dest="$2" + dir="$(dirname "$dest")" + mkdir -p "$dir" + if [ -w "$dir" ]; then + mv "$src" "$dest" + else + echo "Installing to ${dest} (requires sudo)..." + sudo mv "$src" "$dest" + fi +} -# Download -TMP="$(mktemp)" -echo "Downloading ${ASSET}..." -if command -v curl >/dev/null 2>&1; then - curl -fSL --progress-bar "$DOWNLOAD_URL" -o "$TMP" -elif command -v wget >/dev/null 2>&1; then - wget -q --show-progress "$DOWNLOAD_URL" -O "$TMP" -else - echo "Error: curl or wget required" - exit 1 -fi +install_symlink() { + target="$1" + dest="$2" + dir="$(dirname "$dest")" + mkdir -p "$dir" + if [ -w "$dir" ]; then + ln -sfn "$target" "$dest" + else + echo "Linking ${dest} (requires sudo)..." + sudo ln -sfn "$target" "$dest" + fi +} + +install_macos_app() { + ASSET="stable-macos-arm64-Gloomberb.app.zip" + DOWNLOAD_URL="https://github.com/${REPO}/releases/latest/download/${ASSET}" + TMP_DIR="$(mktemp -d)" + ZIP_PATH="${TMP_DIR}/${ASSET}" + APP_PATH="${TMP_DIR}/Gloomberb.app" + DEST_APP="${APP_DIR}/Gloomberb.app" + DEST_CLI="${INSTALL_DIR}/gloomberb" + + echo "Fetching latest macOS release..." + echo "Downloading ${ASSET}..." + if ! download_file "$DOWNLOAD_URL" "$ZIP_PATH"; then + rm -rf "$TMP_DIR" + echo "Combined macOS app install is not available for the latest release yet." + echo "Falling back to the standalone terminal command." + install_standalone_cli + return + fi + + echo "Extracting app..." + if command -v ditto >/dev/null 2>&1; then + ditto -x -k "$ZIP_PATH" "$TMP_DIR" + else + unzip -q "$ZIP_PATH" -d "$TMP_DIR" + fi + + if [ ! -d "$APP_PATH" ]; then + echo "Error: ${ASSET} did not contain Gloomberb.app" + exit 1 + fi + + echo "Installing Gloomberb.app to ${APP_DIR}..." + mkdir -p "$APP_DIR" 2>/dev/null || true + if [ -w "$APP_DIR" ]; then + rm -rf "$DEST_APP" + mv "$APP_PATH" "$DEST_APP" + else + echo "Installing app to ${APP_DIR} (requires sudo)..." + sudo rm -rf "$DEST_APP" + sudo mv "$APP_PATH" "$DEST_APP" + fi -# Decompress -echo "Extracting..." -mv "$TMP" "$TMP.gz" -gunzip "$TMP.gz" -chmod +x "$TMP" -mkdir -p "$INSTALL_DIR" + APP_CLI="${DEST_APP}/Contents/Resources/gloomberb" + if [ ! -x "$APP_CLI" ]; then + echo "Error: installed app is missing the gloomberb terminal shim" + exit 1 + fi + + install_symlink "$APP_CLI" "$DEST_CLI" + rm -rf "$TMP_DIR" + + echo "Installed Gloomberb.app to ${DEST_APP}" + echo "Installed terminal command to ${DEST_CLI}" +} + +install_standalone_cli() { + ASSET="gloomberb-${os}-${arch}.gz" + + # Get latest release download URL + echo "Fetching latest release..." + DOWNLOAD_URL="https://github.com/${REPO}/releases/latest/download/${ASSET}" -if [ -w "$INSTALL_DIR" ]; then - mv "$TMP" "$INSTALL_DIR/gloomberb" + # Download + TMP="$(mktemp)" + echo "Downloading ${ASSET}..." + download_file "$DOWNLOAD_URL" "$TMP" + + # Decompress + echo "Extracting..." + mv "$TMP" "$TMP.gz" + gunzip "$TMP.gz" + chmod +x "$TMP" + install_file "$TMP" "$INSTALL_DIR/gloomberb" + + echo "Installed gloomberb to ${INSTALL_DIR}/gloomberb" +} + +if [ "$os" = "darwin" ]; then + install_macos_app else - echo "Installing to ${INSTALL_DIR} (requires sudo)..." - sudo mv "$TMP" "$INSTALL_DIR/gloomberb" + install_standalone_cli fi -echo "Installed gloomberb to ${INSTALL_DIR}/gloomberb" - case ":$PATH:" in *":$INSTALL_DIR:"*) ;; *) echo "Warning: $INSTALL_DIR is not in your PATH. Add it with:" diff --git a/scripts/write-homebrew-cask.ts b/scripts/write-homebrew-cask.ts new file mode 100644 index 00000000..83e3af3b --- /dev/null +++ b/scripts/write-homebrew-cask.ts @@ -0,0 +1,81 @@ +import { dirname } from "path"; +import { mkdirSync, writeFileSync } from "fs"; + +interface Options { + version: string; + sha256: string; + output: string; +} + +function readOptions(): Options { + const args = process.argv.slice(2); + const options: Partial = {}; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + const value = args[i + 1]; + if (!value) { + throw new Error(`Missing value for ${arg}`); + } + switch (arg) { + case "--version": + options.version = value; + i++; + break; + case "--sha256": + options.sha256 = value; + i++; + break; + case "--output": + options.output = value; + i++; + break; + default: + throw new Error(`Unknown argument: ${arg}`); + } + } + + if (!options.version || !/^\d+\.\d+\.\d+$/.test(options.version)) { + throw new Error("--version must be in X.Y.Z format"); + } + if (!options.sha256 || !/^[a-f0-9]{64}$/.test(options.sha256)) { + throw new Error("--sha256 must be a lowercase SHA-256 digest"); + } + if (!options.output) { + throw new Error("--output is required"); + } + + return options as Options; +} + +function renderCask({ version, sha256 }: Pick): string { + return `cask "gloomberb" do + version "${version}" + sha256 "${sha256}" + + url "https://github.com/vincelwt/gloomberb/releases/download/v#{version}/stable-macos-arm64-Gloomberb.app.zip", + verified: "github.com/vincelwt/gloomberb/" + name "Gloomberb" + desc "Open-source finance terminal" + homepage "https://gloomberb.com" + + livecheck do + url :url + strategy :github_latest + end + + auto_updates true + + app "Gloomberb.app" + binary "#{appdir}/Gloomberb.app/Contents/Resources/gloomberb", target: "gloomberb" + + uninstall quit: "com.vincelwt.gloomberb" + + zap trash: "~/.gloomberb" +end +`; +} + +const options = readOptions(); +mkdirSync(dirname(options.output), { recursive: true }); +writeFileSync(options.output, renderCask(options)); diff --git a/src/renderers/electrobun/bun/desktop-update.ts b/src/renderers/electrobun/bun/desktop-update.ts new file mode 100644 index 00000000..3467a583 --- /dev/null +++ b/src/renderers/electrobun/bun/desktop-update.ts @@ -0,0 +1,162 @@ +import Electrobun from "electrobun/bun"; +import type { UpdateStatusEntry } from "electrobun/bun"; +import type { + ReleaseInfo, + UpdateCheckResult, + UpdateProgress, +} from "../../../updater"; + +let desktopUpdateInProgress = false; + +function desktopReleasePlatformPrefix(channel: string): string { + const os = process.platform === "darwin" ? "macos" : process.platform === "win32" ? "win" : "linux"; + const arch = process.arch === "arm64" ? "arm64" : "x64"; + return `${channel}-${os}-${arch}`; +} + +async function desktopReleaseInfo(updateInfo: { + version?: string; + hash?: string; +}, currentVersion: string): Promise { + const [channel, baseUrl] = await Promise.all([ + Electrobun.Updater.localInfo.channel(), + Electrobun.Updater.localInfo.baseUrl(), + ]); + const version = updateInfo.version || currentVersion; + return { + version, + tagName: `v${version}`, + downloadUrl: `${baseUrl.replace(/\/+$/, "")}/${desktopReleasePlatformPrefix(channel)}-update.json`, + publishedAt: "", + updateAction: { kind: "desktop" }, + }; +} + +function mapDesktopUpdateStatus(entry: UpdateStatusEntry): UpdateProgress | null { + const progress = entry.details?.progress; + switch (entry.status) { + case "downloading": + case "download-starting": + case "checking-local-tar": + case "local-tar-found": + case "local-tar-missing": + case "fetching-patch": + case "patch-found": + case "patch-not-found": + case "downloading-patch": + case "downloading-full-bundle": + case "download-progress": + return { + phase: "downloading", + percent: typeof progress === "number" ? progress : undefined, + }; + case "applying-patch": + case "patch-applied": + case "extracting-version": + case "patch-chain-complete": + case "decompressing": + case "download-complete": + case "applying": + case "extracting": + case "replacing-app": + case "launching-new-version": + return { phase: "replacing" }; + case "complete": + return { phase: "done", message: "Update installed, restarting..." }; + case "error": + return { + phase: "error", + error: entry.details?.errorMessage || entry.message, + }; + default: + return null; + } +} + +export async function checkElectrobunDesktopUpdate(currentVersion: string): Promise { + try { + const [channel, baseUrl] = await Promise.all([ + Electrobun.Updater.localInfo.channel(), + Electrobun.Updater.localInfo.baseUrl(), + ]); + if (channel === "dev" || !baseUrl) { + return { kind: "disabled" }; + } + + const info = await Electrobun.Updater.checkForUpdate(); + if (info.error) { + return { kind: "error", error: info.error }; + } + if (!info.updateAvailable) { + return { kind: "current" }; + } + + return { + kind: "available", + release: await desktopReleaseInfo(info, currentVersion), + }; + } catch (error) { + return { + kind: "error", + error: error instanceof Error ? error.message : "Desktop update check failed", + }; + } +} + +export async function runElectrobunDesktopUpdate( + currentVersion: string, + onProgress: (progress: UpdateProgress) => void, +): Promise { + if (desktopUpdateInProgress) { + onProgress({ + phase: "error", + error: "A desktop update is already in progress.", + }); + return; + } + + desktopUpdateInProgress = true; + Electrobun.Updater.clearStatusHistory(); + Electrobun.Updater.onStatusChange((entry) => { + const progress = mapDesktopUpdateStatus(entry); + if (progress) onProgress(progress); + }); + + try { + onProgress({ phase: "downloading", percent: 0 }); + const result = await checkElectrobunDesktopUpdate(currentVersion); + if (result.kind === "error") { + onProgress({ phase: "error", error: result.error }); + return; + } + if (result.kind !== "available") { + onProgress({ + phase: "done", + message: result.kind === "disabled" ? "Desktop updates are unavailable in this build" : "Already on the latest version", + }); + return; + } + + await Electrobun.Updater.downloadUpdate(); + const updateInfo = Electrobun.Updater.updateInfo(); + if (updateInfo?.error) { + onProgress({ phase: "error", error: updateInfo.error }); + return; + } + if (!updateInfo?.updateReady) { + onProgress({ phase: "error", error: "Desktop update did not finish downloading." }); + return; + } + + onProgress({ phase: "replacing" }); + await Electrobun.Updater.applyUpdate(); + } catch (error) { + onProgress({ + phase: "error", + error: error instanceof Error ? error.message : "Desktop update failed", + }); + } finally { + desktopUpdateInProgress = false; + Electrobun.Updater.onStatusChange(null); + } +} diff --git a/src/renderers/electrobun/bun/index.ts b/src/renderers/electrobun/bun/index.ts index fe9c2524..05b8dc4c 100644 --- a/src/renderers/electrobun/bun/index.ts +++ b/src/renderers/electrobun/bun/index.ts @@ -1,7 +1,7 @@ import { existsSync, mkdirSync } from "fs"; import { dirname, join } from "path"; import { Buffer } from "node:buffer"; -import Electrobun, { ApplicationMenu, BrowserView, BrowserWindow, Utils, ContextMenu, type UpdateStatusEntry } from "electrobun/bun"; +import Electrobun, { ApplicationMenu, BrowserView, BrowserWindow, Utils, ContextMenu } from "electrobun/bun"; import { APP_SESSION_ID, APP_SESSION_SCHEMA_VERSION, @@ -14,7 +14,7 @@ import { findPaneInstance, type AppConfig } from "../../../types/config"; import type { AppSessionSnapshot } from "../../../core/state/session-persistence"; import { syncConfigActiveLayoutState, type PaneRuntimeState } from "../../../core/state/app-state"; import type { DesktopDockPreviewState, DesktopSharedStateSnapshot, DesktopThemePreviewState } from "../../../types/desktop-window"; -import type { ReleaseInfo, UpdateCheckResult, UpdateProgress } from "../../../updater"; +import type { ReleaseInfo, UpdateProgress } from "../../../updater"; import { buildSoundCommand } from "../../../notifications/app-notifier"; import { isPaneDetached } from "../../../plugins/pane-manager"; import { ELECTROBUN_CONTEXT_MENU_ACTION, type DesktopRestartMessage, type ElectrobunDesktopRpcSchema } from "../shared/protocol"; @@ -27,6 +27,10 @@ import { applicationMenuCommand } from "./application-menu-click"; import { registerElectrobunCoreCapabilities } from "./core-capabilities"; import { setNativeIbkrGatewayModuleLoader } from "../../../plugins/ibkr/gateway-service"; import { apiClient, type PersistedAuthUser } from "../../../utils/api-client"; +import { + checkElectrobunDesktopUpdate, + runElectrobunDesktopUpdate, +} from "./desktop-update"; import { DEFAULT_WINDOW_FRAME, DETACHED_WINDOW_MIN_SIZE, @@ -64,7 +68,6 @@ let mainWindow: BrowserWindow | null = null; let desktopWorkspace: DesktopWorkspace | null = null; let currentDockPreview: DesktopDockPreviewState = { paneId: null, edge: null }; let currentThemePreview: DesktopThemePreviewState = { theme: null }; -let desktopUpdateInProgress = false; let desktopRestartInProgress = false; const detachedWindows = new Map(); @@ -272,71 +275,6 @@ function playNotificationSound(sound: string | undefined): void { } } -function desktopReleasePlatformPrefix(channel: string): string { - const os = process.platform === "darwin" ? "macos" : process.platform === "win32" ? "win" : "linux"; - const arch = process.arch === "arm64" ? "arm64" : "x64"; - return `${channel}-${os}-${arch}`; -} - -async function desktopReleaseInfo(updateInfo: { - version?: string; - hash?: string; -}, currentVersion: string): Promise { - const [channel, baseUrl] = await Promise.all([ - Electrobun.Updater.localInfo.channel(), - Electrobun.Updater.localInfo.baseUrl(), - ]); - const version = updateInfo.version || currentVersion; - return { - version, - tagName: `v${version}`, - downloadUrl: `${baseUrl.replace(/\/+$/, "")}/${desktopReleasePlatformPrefix(channel)}-update.json`, - publishedAt: "", - updateAction: { kind: "desktop" }, - }; -} - -function mapDesktopUpdateStatus(entry: UpdateStatusEntry): UpdateProgress | null { - const progress = entry.details?.progress; - switch (entry.status) { - case "downloading": - case "download-starting": - case "checking-local-tar": - case "local-tar-found": - case "local-tar-missing": - case "fetching-patch": - case "patch-found": - case "patch-not-found": - case "downloading-patch": - case "downloading-full-bundle": - case "download-progress": - return { - phase: "downloading", - percent: typeof progress === "number" ? progress : undefined, - }; - case "applying-patch": - case "patch-applied": - case "extracting-version": - case "patch-chain-complete": - case "decompressing": - case "download-complete": - case "applying": - case "extracting": - case "replacing-app": - case "launching-new-version": - return { phase: "replacing" }; - case "complete": - return { phase: "done", message: "Update installed, restarting..." }; - case "error": - return { - phase: "error", - error: entry.details?.errorMessage || entry.message, - }; - default: - return null; - } -} - function sendUpdateProgress(rpc: DesktopRpc, progress: UpdateProgress): void { try { rpc.send["update.progress"]({ @@ -347,89 +285,8 @@ function sendUpdateProgress(rpc: DesktopRpc, progress: UpdateProgress): void { } } -async function checkDesktopUpdate(currentVersion: string): Promise { - try { - const [channel, baseUrl] = await Promise.all([ - Electrobun.Updater.localInfo.channel(), - Electrobun.Updater.localInfo.baseUrl(), - ]); - if (channel === "dev" || !baseUrl) { - return { kind: "disabled" }; - } - - const info = await Electrobun.Updater.checkForUpdate(); - if (info.error) { - return { kind: "error", error: info.error }; - } - if (!info.updateAvailable) { - return { kind: "current" }; - } - - return { - kind: "available", - release: await desktopReleaseInfo(info, currentVersion), - }; - } catch (error) { - return { - kind: "error", - error: error instanceof Error ? error.message : "Desktop update check failed", - }; - } -} - async function runDesktopUpdate(rpc: DesktopRpc, currentVersion: string): Promise { - if (desktopUpdateInProgress) { - sendUpdateProgress(rpc, { - phase: "error", - error: "A desktop update is already in progress.", - }); - return; - } - - desktopUpdateInProgress = true; - Electrobun.Updater.clearStatusHistory(); - Electrobun.Updater.onStatusChange((entry) => { - const progress = mapDesktopUpdateStatus(entry); - if (progress) sendUpdateProgress(rpc, progress); - }); - - try { - sendUpdateProgress(rpc, { phase: "downloading", percent: 0 }); - const result = await checkDesktopUpdate(currentVersion); - if (result.kind === "error") { - sendUpdateProgress(rpc, { phase: "error", error: result.error }); - return; - } - if (result.kind !== "available") { - sendUpdateProgress(rpc, { - phase: "done", - message: result.kind === "disabled" ? "Desktop updates are unavailable in this build" : "Already on the latest version", - }); - return; - } - - await Electrobun.Updater.downloadUpdate(); - const updateInfo = Electrobun.Updater.updateInfo(); - if (updateInfo?.error) { - sendUpdateProgress(rpc, { phase: "error", error: updateInfo.error }); - return; - } - if (!updateInfo?.updateReady) { - sendUpdateProgress(rpc, { phase: "error", error: "Desktop update did not finish downloading." }); - return; - } - - sendUpdateProgress(rpc, { phase: "replacing" }); - await Electrobun.Updater.applyUpdate(); - } catch (error) { - sendUpdateProgress(rpc, { - phase: "error", - error: error instanceof Error ? error.message : "Desktop update failed", - }); - } finally { - desktopUpdateInProgress = false; - Electrobun.Updater.onStatusChange(null); - } + await runElectrobunDesktopUpdate(currentVersion, (progress) => sendUpdateProgress(rpc, progress)); } interface PluginStateBackendSetEntry { diff --git a/src/renderers/electrobun/bun/tui-entry.ts b/src/renderers/electrobun/bun/tui-entry.ts new file mode 100644 index 00000000..3890e54e --- /dev/null +++ b/src/renderers/electrobun/bun/tui-entry.ts @@ -0,0 +1,6 @@ +import { startOpenTuiApp } from "../../opentui/start"; + +startOpenTuiApp().catch((err) => { + console.error("Fatal error:", err); + process.exitCode = 1; +}); diff --git a/src/updater.test.ts b/src/updater.test.ts index d28ef147..275ff843 100644 --- a/src/updater.test.ts +++ b/src/updater.test.ts @@ -62,6 +62,20 @@ describe("detectUpdateAction", () => { ["/opt/homebrew/bin/bun", "src/index.tsx"], )).toBeNull(); }); + + test("does not treat a macOS app bundle launcher as a standalone CLI binary", () => { + expect(detectUpdateAction( + "/Applications/Gloomberb.app/Contents/MacOS/launcher", + ["/Applications/Gloomberb.app/Contents/MacOS/launcher"], + )).toBeNull(); + }); + + test("does not suggest Bun-managed updates for the bundled macOS app runtime", () => { + expect(detectUpdateAction( + "/Applications/Gloomberb.app/Contents/MacOS/bun", + ["/Applications/Gloomberb.app/Contents/MacOS/bun", "/Applications/Gloomberb.app/Contents/Resources/gloomberb-tui/tui-entry.js"], + )).toBeNull(); + }); }); describe("resolveSelfUpdateTargetPath", () => { @@ -85,6 +99,13 @@ describe("resolveSelfUpdateTargetPath", () => { ["/Applications/gloomberb"], )).toBe("/Applications/gloomberb"); }); + + it("rejects launchers inside macOS app bundles", () => { + expect(resolveSelfUpdateTargetPath( + "/Applications/Gloomberb.app/Contents/MacOS/launcher", + ["/Applications/Gloomberb.app/Contents/MacOS/launcher"], + )).toBeNull(); + }); }); describe("canSelfUpdate", () => { diff --git a/src/updater.ts b/src/updater.ts index 05030e7f..0c3f4132 100644 --- a/src/updater.ts +++ b/src/updater.ts @@ -100,12 +100,18 @@ function isSourceEntrypoint(entrypoint: string): boolean { return sourceEntrypointPattern.test(entrypoint) || tsEntrypointPattern.test(entrypoint); } +function isMacAppBundleExecutable(execPath: string): boolean { + return normalizePath(execPath).includes(".app/contents/macos/"); +} + export function resolveSelfUpdateTargetPath( execPath = getRuntimeProcess()?.execPath ?? "", argv = getRuntimeProcess()?.argv ?? [], ): string | null { const resolvedExecPath = tryRealpath(execPath); const normalizedExecPath = normalizePath(resolvedExecPath); + if (isMacAppBundleExecutable(resolvedExecPath)) return null; + const execBase = basename(normalizedExecPath); const runtimeExecutables = new Set(["bun", "bunx", "node", "nodejs", "npm", "npx", "pnpm", "yarn"]); if (runtimeExecutables.has(execBase)) return null; @@ -121,6 +127,8 @@ export function detectUpdateAction( execPath = getRuntimeProcess()?.execPath ?? "", argv = getRuntimeProcess()?.argv ?? [], ): UpdateAction | null { + if (isMacAppBundleExecutable(execPath)) return null; + if (resolveSelfUpdateTargetPath(execPath, argv)) { return { kind: "self" }; }