From 2bd57d013975fe023377d2374c823a2f440a1889 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 12 Oct 2026 09:44:33 +0000 Subject: [PATCH 1/5] desktop: macOS and Windows builds (0.4.0) - main: Windows shells (pwsh/powershell/cmd) and hqsh.exe off the PATH, PATH delimiter and the Windows Path key, Homebrew bins + UTF-8 locale for Finder launches, home dir when started from a launcher, macOS app menu. - renderer: Cmd+ on macOS does Ctrl+Shift+; Windows install hint. - electron-builder: macOS dmg+zip (ad-hoc signed), Windows NSIS + zip. - CI and release: build and smoke-test on macos arm64/x64 and windows x64. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 44 ++++++++++++++++++++++++++++ .github/workflows/release.yml | 52 ++++++++++++++++++++++++++++++++- desktop/main.js | 54 +++++++++++++++++++++++++++++------ desktop/package-lock.json | 4 +-- desktop/package.json | 37 ++++++++++++++++++++---- desktop/scripts/ci-smoke.sh | 26 +++++++++++++++++ desktop/src/app.js | 13 +++++++-- package.json | 2 +- 8 files changed, 211 insertions(+), 21 deletions(-) create mode 100755 desktop/scripts/ci-smoke.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d83c6d7..5f658ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,3 +79,47 @@ jobs: name: hqterm-desktop-linux-x86_64 path: desktop/dist/*.AppImage retention-days: 7 + + # macOS (both CPUs) and Windows: build the installers, then launch the + # unpacked app twice for real (window, PTYs, split, layout restore). + desktop-mac-win: + strategy: + fail-fast: false + matrix: + include: + - { runner: macos-latest, os: mac, arch: arm64 } + - { runner: macos-15-intel, os: mac, arch: x64 } + - { runner: windows-latest, os: win, arch: x64 } + runs-on: ${{ matrix.runner }} + defaults: + run: + shell: bash + working-directory: desktop + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: npm ci + - run: npm test + - run: npm run dist:${{ matrix.os }} -- --${{ matrix.arch }} + - name: List and check the build + run: | + ls -la dist + if [ "${{ matrix.os }}" = mac ]; then codesign --verify --deep --strict --verbose=2 dist/mac*/hqterm.app; fi + - name: Smoke test + run: | + if [ "${{ matrix.os }}" = mac ]; then exe="$(ls dist/mac*/hqterm.app/Contents/MacOS/* | head -n1)"; else exe="$(ls dist/win*-unpacked/*.exe | head -n1)"; fi + echo "launching $exe" + scripts/ci-smoke.sh "$exe" + - uses: actions/upload-artifact@v4 + if: always() + with: + name: hqterm-desktop-smoke-${{ matrix.os }}-${{ matrix.arch }} + path: | + desktop/smoke-*.png + desktop/smoke-*.log + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2535e98..3e62799 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -55,8 +55,58 @@ jobs: name: desktop-${{ matrix.arch }} path: desktop/dist/hqterm-desktop-linux-${{ matrix.arch }}.AppImage + # macOS (Apple Silicon + Intel, ad-hoc signed .dmg/.zip) and Windows + # (per-user NSIS installer + portable .zip). Built and launched natively. + desktop-mac-win: + strategy: + fail-fast: false + matrix: + include: + - { runner: macos-latest, os: mac, arch: arm64 } + - { runner: macos-15-intel, os: mac, arch: x64 } + - { runner: windows-latest, os: win, arch: x64 } + runs-on: ${{ matrix.runner }} + defaults: + run: + shell: bash + working-directory: desktop + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Tag matches desktop/package.json + run: | + want="v$(node -p 'require("./package.json").version')" + [ "$want" = "$GITHUB_REF_NAME" ] || { echo "tag $GITHUB_REF_NAME but desktop/package.json says $want"; exit 1; } + - run: npm ci + - run: npm test + - run: npm run dist:${{ matrix.os }} -- --${{ matrix.arch }} + - name: Check and smoke test + run: | + ls -la dist + if [ "${{ matrix.os }}" = mac ]; then + codesign --verify --deep --strict --verbose=2 dist/mac*/hqterm.app + exe="$(ls dist/mac*/hqterm.app/Contents/MacOS/* | head -n1)" + else + exe="$(ls dist/win*-unpacked/*.exe | head -n1)" + fi + scripts/ci-smoke.sh "$exe" + - uses: actions/upload-artifact@v4 + with: + name: desktop-${{ matrix.os }}-${{ matrix.arch }} + path: | + desktop/dist/hqterm-desktop-macos-*.dmg + desktop/dist/hqterm-desktop-macos-*.zip + desktop/dist/hqterm-desktop-windows-*-setup.exe + desktop/dist/hqterm-desktop-windows-*.zip + if-no-files-found: error + release: - needs: desktop + needs: [desktop, desktop-mac-win] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/desktop/main.js b/desktop/main.js index 30c4fb6..15a1e79 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -86,7 +86,10 @@ function listHosts() { } } +const WIN = process.platform === "win32"; + function loginShell() { + if (WIN) return which("pwsh.exe") || which("powershell.exe") || process.env.COMSPEC || "cmd.exe"; let sh = process.env.SHELL; if (!sh) { try { @@ -96,6 +99,24 @@ function loginShell() { return sh && fs.existsSync(sh) ? sh : "/bin/sh"; } +/** The PATH key of an env object: Windows spells it "Path". */ +const pathKey = (env) => Object.keys(env).find((k) => k.toUpperCase() === "PATH") || "PATH"; + +/** `name` on the panes' PATH (with PATHEXT on Windows), or undefined. */ +function which(name, env = paneEnv()) { + const exts = WIN && !path.extname(name) ? (env.PATHEXT || ".EXE;.CMD;.BAT").split(";") : [""]; + for (const dir of (env[pathKey(env)] || "").split(path.delimiter)) { + if (!dir) continue; + for (const ext of exts) { + const f = path.join(dir, name + ext); + try { + if (fs.statSync(f).isFile()) return f; + } catch {} + } + } + return undefined; +} + /** The environment every pane starts with. */ function paneEnv() { const env = { ...process.env }; @@ -103,9 +124,16 @@ function paneEnv() { // AppImage runtime variables would leak into every child. for (const k of ["APPDIR", "APPIMAGE", "ARGV0", "OWD", "NO_AT_BRIDGE"]) delete env[k]; if (env.LD_LIBRARY_PATH && process.env.APPDIR && env.LD_LIBRARY_PATH.includes(process.env.APPDIR)) delete env.LD_LIBRARY_PATH; - const localBin = path.join(os.homedir(), ".local", "bin"); - const parts = (env.PATH || "/usr/local/bin:/usr/bin:/bin").split(":"); - if (!parts.includes(localBin)) env.PATH = [localBin, ...parts].join(":"); + // ~/.local/bin is where both installers put hqsh and hqterm. A macOS app + // started from Finder gets only /usr/bin:/bin:/usr/sbin:/sbin, so add + // Homebrew's and /usr/local's bin too. + const key = pathKey(env); + const parts = (env[key] || (WIN ? "" : "/usr/local/bin:/usr/bin:/bin")).split(path.delimiter).filter(Boolean); + const extra = [path.join(os.homedir(), ".local", "bin")]; + if (process.platform === "darwin") extra.push("/opt/homebrew/bin", "/usr/local/bin"); + env[key] = [...extra.filter((d) => !parts.includes(d)), ...parts].join(path.delimiter); + // Finder sets no locale either, and without one shells mangle emoji. + if (process.platform === "darwin" && !env.LANG && !env.LC_ALL && !env.LC_CTYPE) env.LANG = "en_US.UTF-8"; Object.assign(env, { TERM: "xterm-256color", COLORTERM: "truecolor", @@ -123,7 +151,8 @@ function startDir() { const owd = process.env.OWD; if (owd && owd !== "/" && fs.existsSync(owd)) return owd; const cwd = process.cwd(); - return cwd && cwd !== "/" ? cwd : os.homedir(); + // Started from a launcher, the cwd is / (Linux, macOS) or the install dir (Windows). + return cwd && cwd !== "/" && cwd !== path.dirname(process.execPath) ? cwd : os.homedir(); } /** [file, args] for a pane spec; host and session are checked before they reach a shell. */ @@ -133,10 +162,13 @@ function command(spec) { // hqsh forwards only TERM. sshd accepts LC_* by default (AcceptEnv LANG LC_*), // a new session's shell inherits it, and hqtui reads LC_TERMINAL=iTerm2 as // "draw iTerm2 inline images": so remote hqtui/qc draw HD images here too. + // Windows has no /bin/sh: run hqsh.exe straight off the PATH. + if (WIN) return [which("hqsh.exe"), [spec.host, "--session", session, "--", "-o", "SetEnv=LC_TERMINAL=iTerm2"]]; return ["/bin/sh", ["-lc", 'exec hqsh "$0" --session "$1" -- -o SetEnv=LC_TERMINAL=iTerm2', spec.host, session]]; } - if (spec && spec.kind === "hqterm") return ["/bin/sh", ["-lc", "exec hqterm"]]; - return [loginShell(), ["-l"]]; + if (spec && spec.kind === "hqterm") return WIN ? [which("hqterm.exe"), []] : ["/bin/sh", ["-lc", "exec hqterm"]]; + const sh = loginShell(); + return [sh, WIN ? (/pwsh|powershell/i.test(sh) ? ["-NoLogo"] : []) : ["-l"]]; } let pty; @@ -209,7 +241,10 @@ ipcMain.handle("layout:save", (_e, data) => { ipcMain.handle("pty:spawn", (e, { spec, cols, rows }) => { pty = pty || require("node-pty"); - const [file, args] = command(spec); + let [file, args] = command(spec); + // Not installed (Windows; elsewhere the shell exits 127 by itself): exit 127 + // the same way, so the pane says how to install it. + if (!file) [file, args] = [process.env.COMSPEC || "cmd.exe", ["/d", "/c", "exit 127"]]; const id = nextPty++; const p = pty.spawn(file, args, { name: "xterm-256color", @@ -297,8 +332,9 @@ if (!app.requestSingleInstanceLock()) { } }); app.whenReady().then(() => { - // No menu: Ctrl+W, Ctrl+R and friends belong to the shell. - Menu.setApplicationMenu(null); + // No menu: Ctrl+W, Ctrl+R and friends belong to the shell. macOS keeps + // the app menu (Cmd+Q, Cmd+H); Cmd+T/W/C/V/D are handled in the renderer. + Menu.setApplicationMenu(process.platform === "darwin" ? Menu.buildFromTemplate([{ role: "appMenu" }]) : null); createWindow(); // What the GPU is doing, for "why is it slow": ~/.config/hqterm/desktop.log. try { diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 8ee97d4..9283fc8 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "hqterm-desktop", - "version": "0.3.2", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hqterm-desktop", - "version": "0.3.2", + "version": "0.4.0", "license": "MIT", "dependencies": { "@xterm/addon-fit": "^0.11.0", diff --git a/desktop/package.json b/desktop/package.json index 8dd1b9a..0b6592a 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "hqterm-desktop", "productName": "hqterm", - "version": "0.3.2", + "version": "0.4.0", "description": "hqterm desktop: a terminal window that shows hqtui/qc images and HD emoji (xterm.js + node-pty).", "license": "MIT", "author": "Profullstack, Inc. ", @@ -13,7 +13,9 @@ "prepare-assets": "node scripts/prepare.mjs", "start": "npm run prepare-assets && electron .", "test": "node --test test/layout-tests.js", - "dist": "npm run prepare-assets && electron-builder --linux AppImage --publish never" + "dist": "npm run prepare-assets && electron-builder --linux AppImage --publish never", + "dist:mac": "npm run prepare-assets && electron-builder --mac dmg zip --publish never", + "dist:win": "npm run prepare-assets && electron-builder --win nsis zip --publish never" }, "dependencies": { "@xterm/addon-fit": "^0.11.0", @@ -48,12 +50,37 @@ "category": "System;TerminalEmulator", "icon": "build/icon.png", "artifactName": "hqterm-desktop-linux-${arch}.${ext}", - "executableArgs": ["--no-sandbox"] + "executableArgs": [ + "--no-sandbox" + ] }, "mac": { - "target": "zip", + "target": [ + "dmg", + "zip" + ], + "category": "public.app-category.developer-tools", "icon": "build/icon.png", - "identity": null + "artifactName": "hqterm-desktop-macos-${arch}.${ext}", + "identity": "-", + "hardenedRuntime": false + }, + "dmg": { + "artifactName": "hqterm-desktop-macos-${arch}.${ext}" + }, + "win": { + "target": [ + "nsis", + "zip" + ], + "icon": "build/icon.png", + "artifactName": "hqterm-desktop-windows-${arch}.${ext}" + }, + "nsis": { + "oneClick": true, + "perMachine": false, + "artifactName": "hqterm-desktop-windows-${arch}-setup.${ext}", + "shortcutName": "hqterm" } } } diff --git a/desktop/scripts/ci-smoke.sh b/desktop/scripts/ci-smoke.sh new file mode 100755 index 0000000..2b79506 --- /dev/null +++ b/desktop/scripts/ci-smoke.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# CI smoke test of a built desktop app on macOS or Windows (Linux runs the +# AppImage under Xvfb in the workflow itself). Launches it twice against a +# fresh config: one shell split once (2 PTYs), then the saved layout restored +# and split again (3 PTYs). Screenshots land in smoke-1.png and smoke-2.png. +# +# desktop/scripts/ci-smoke.sh path/to/app-executable +set -uo pipefail +exe="$1" +# Paths the app (not bash) reads: on Windows they must be C:/... not /c/... +here="$(pwd -W 2>/dev/null || pwd)" +cfg="$here/smoke-cfg" +rm -rf "$cfg" + +smoke() { + HQTERM_DESKTOP_SMOKE="${HQTERM_DESKTOP_SMOKE:-12000}" HQTERM_DESKTOP_SMOKE_SHOT="$here/$1" XDG_CONFIG_HOME="$cfg" \ + "$exe" 2>&1 | tee "$1.log" + echo "exit ${PIPESTATUS[0]}" +} + +smoke smoke-1.png +grep -q "hqterm-desktop smoke: ok (ptys=2)" smoke-1.png.log || { echo "first launch failed"; exit 1; } +cat "$cfg/hqterm/desktop-layout.json" +smoke smoke-2.png +grep -q "hqterm-desktop smoke: ok (ptys=3)" smoke-2.png.log || { echo "second launch failed"; exit 1; } +echo "smoke: ok" diff --git a/desktop/src/app.js b/desktop/src/app.js index eeae7fe..48c1253 100644 --- a/desktop/src/app.js +++ b/desktop/src/app.js @@ -5,10 +5,11 @@ const L = window.HqLayout; const hq = window.hq; const $ = (sel) => document.querySelector(sel); - const INSTALL = "curl -fsSL https://hqterm.sh/install | sh"; const MAX_WEBGL = 12; const init = await hq.init(); + const MAC = init.platform === "darwin"; + const INSTALL = init.platform === "win32" ? "irm https://hqterm.sh/install.ps1 | iex" : "curl -fsSL https://hqterm.sh/install | sh"; const config = init.config; let fontSize = config.fontSize; const theme = Object.assign( @@ -583,10 +584,16 @@ (e) => { if (e.key === "Escape" && !menu.hidden) return closeMenu(); let handled = true; - if (e.ctrlKey && e.key === "Tab") activate(active + (e.shiftKey ? -1 : 1)); + // macOS: Cmd+ does what Ctrl+Shift+ does (Cmd+Shift+D splits down). + const cmd = MAC && e.metaKey && !e.ctrlKey && !e.altKey; + if (cmd && e.shiftKey && e.code === "KeyD") splitFocused("column"); + else if (cmd && !e.shiftKey && (e.key === "=" || e.key === "+")) zoom(1); + else if (cmd && !e.shiftKey && e.key === "-") zoom(-1); + else if (cmd && !e.shiftKey && e.key === "0") zoom(0); + else if (e.ctrlKey && e.key === "Tab") activate(active + (e.shiftKey ? -1 : 1)); else if (e.ctrlKey && !e.shiftKey && !e.altKey && e.key === "PageDown") activate(active + 1); else if (e.ctrlKey && !e.shiftKey && !e.altKey && e.key === "PageUp") activate(active - 1); - else if (e.ctrlKey && e.shiftKey && !e.altKey) { + else if ((e.ctrlKey && e.shiftKey && !e.altKey) || cmd) { switch (e.code) { case "KeyT": newTab({ kind: "shell" }); diff --git a/package.json b/package.json index 6961ff2..a0f7369 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hqterm", - "version": "0.3.2", + "version": "0.4.0", "description": "Terminal session manager: persistent hqsh sessions with images and HD emoji, built on hqtui.", "license": "MIT", "private": true, From 2beee439e0dd6760d6106d71b8a4c397d7ba3c88 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 12 Oct 2026 09:46:10 +0000 Subject: [PATCH 2/5] install: desktop app on macOS (~/Applications) and Windows (install.ps1); docs Co-Authored-By: Claude Opus 5.5 --- README.md | 18 ++++++++++++--- install.ps1 | 40 +++++++++++++++++++++++++++++--- install.sh | 55 ++++++++++++++++++++++++++++++++++---------- site/docs.html | 19 +++++++++++---- site/index.html | 4 +++- test/desktop.test.ts | 8 ++++--- 6 files changed, 117 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 0d4d0b6..d13daba 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,15 @@ is missing (current Ubuntu/Kubuntu ship only fuse3) it unpacks the app once into does this too on Linux when `$DISPLAY` or `$WAYLAND_DISPLAY` is set; `--no-desktop` skips it. +macOS and Windows builds are on every release too: `hqterm-desktop-macos-{arm64,x64}.{dmg,zip}` +(ad-hoc signed; the installer puts it in `~/Applications/hqterm.app` on a Mac +not reached over ssh) and `hqterm-desktop-windows-x64-setup.exe` / `.zip` +(per-user, no admin; `install.ps1` runs it silently unless +`$env:HQTERM_NO_DESKTOP` is set). Neither is notarized/code-signed yet, so the +first launch of a browser download needs right-click > Open (macOS) or +More info > Run anyway (SmartScreen). On macOS, Cmd+ does what +Ctrl+Shift+ does. + | Keys | | | --- | --- | | Ctrl+Shift+T / W | new tab / close tab; Ctrl+Tab, Ctrl+PgUp/PgDn switch | @@ -76,7 +85,9 @@ Settings: `~/.config/hqterm/desktop.json`, e.g. (the default font size is 15). Develop: `cd desktop && npm install && npm start` (needs bun and a C++ -toolchain for node-pty); `npm run dist` builds the AppImage. +toolchain for node-pty); `npm run dist` builds the AppImage, `npm run dist:mac` +the macOS .dmg/.zip and `npm run dist:win` the Windows installer/.zip (each on +its own OS). ## Develop @@ -88,8 +99,9 @@ bun run build # single binary in dist/ ``` Releases: push a `v*` tag matching package.json; CI builds -`hqterm-{linux,darwin}-{amd64,arm64}`, `hqterm-desktop-linux-{x86_64,aarch64}.AppImage` -and `SHA256SUMS` (bump `desktop/package.json` too). +`hqterm-{linux,darwin}-{amd64,arm64}`, `hqterm-desktop-linux-{x86_64,aarch64}.AppImage`, +`hqterm-desktop-macos-{arm64,x64}.{dmg,zip}`, `hqterm-desktop-windows-x64-setup.exe`, +`hqterm-desktop-windows-x64.zip` and `SHA256SUMS` (bump `desktop/package.json` too). `site/index.html` is the hqterm.sh landing page and `site/docs.html` is https://hqterm.sh/docs; `install.sh` is served at https://hqterm.sh/install and diff --git a/install.ps1 b/install.ps1 index e3c5765..5ea3784 100644 --- a/install.ps1 +++ b/install.ps1 @@ -5,8 +5,10 @@ # Installs hqsh (the client and the host side of hqterm sessions) from the # latest GitHub release of profullstack/hqsh into ~\.local\bin (or # $env:HQTERM_BIN), checks it against SHA256SUMS, and puts that directory on -# your user PATH so ssh sessions find it. No admin rights. Safe to run again. -# Pin a version with $env:HQSH_VERSION = "v0.3.0". +# your user PATH so ssh sessions find it. Then the hqterm desktop app, from the +# latest profullstack/hqterm release, installed per user (Start menu entry); +# skipped over ssh or with $env:HQTERM_NO_DESKTOP = "1". No admin rights. Safe +# to run again. Pin versions with $env:HQSH_VERSION / $env:HQTERM_VERSION. $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' @@ -86,4 +88,36 @@ if (-not (Get-Service -Name sshd -ErrorAction SilentlyContinue)) { Say ' Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0' Say " Start-Service sshd; Set-Service sshd -StartupType Automatic" } -Say 'done. Connect from here with: hqsh (or hqterm on Linux/macOS)' +# The desktop app: a per-user NSIS installer, run silently. Windows on ARM +# runs the x64 build under emulation. +$desktop = -not $env:HQTERM_NO_DESKTOP -and -not $env:SSH_CONNECTION +if ($desktop) { + $hqBase = if ($env:HQTERM_VERSION) { + "https://github.com/profullstack/hqterm/releases/download/$($env:HQTERM_VERSION)" + } else { + 'https://github.com/profullstack/hqterm/releases/latest/download' + } + $asset = 'hqterm-desktop-windows-x64-setup.exe' + $tmp = Join-Path ([IO.Path]::GetTempPath()) ("hqterm-" + [Guid]::NewGuid()) + New-Item -ItemType Directory -Path $tmp | Out-Null + try { + Say "downloading $asset from github.com/profullstack/hqterm" + $file = Join-Path $tmp $asset + Invoke-WebRequest -UseBasicParsing "$hqBase/$asset" -OutFile $file + Invoke-WebRequest -UseBasicParsing "$hqBase/SHA256SUMS" -OutFile (Join-Path $tmp 'SHA256SUMS') + $line = Get-Content (Join-Path $tmp 'SHA256SUMS') | Where-Object { $_ -match "\s\*?$([regex]::Escape($asset))$" } | Select-Object -First 1 + if (-not $line) { throw "$asset is not listed in SHA256SUMS" } + $want = ($line -split '\s+')[0].ToLower() + $got = (Get-FileHash -Algorithm SHA256 $file).Hash.ToLower() + if ($want -ne $got) { throw "checksum mismatch for $asset (expected $want, got $got)" } + Say "verified $asset (sha256 $got)" + $p = Start-Process -FilePath $file -ArgumentList '/S' -Wait -PassThru + if ($p.ExitCode -ne 0) { throw "the installer exited with $($p.ExitCode)" } + Say "installed the hqterm desktop app (Start menu: hqterm)" + } catch { + Say "warning: the desktop app was not installed ($($_.Exception.Message)); hqsh is" + } finally { + Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue + } +} +Say 'done. Open hqterm from the Start menu, or connect from here with: hqsh ' diff --git a/install.sh b/install.sh index 95711d2..992cae4 100755 --- a/install.sh +++ b/install.sh @@ -1,15 +1,15 @@ #!/bin/sh # hqterm installer: https://hqterm.sh # -# curl -fsSL https://hqterm.sh/install | sh # hqterm + hqsh (+ desktop app on a Linux desktop) +# curl -fsSL https://hqterm.sh/install | sh # hqterm + hqsh (+ desktop app on a Linux desktop or a Mac) # curl -fsSL https://hqterm.sh/install | sh -s -- --desktop # also the desktop app, display or not # curl -fsSL https://hqterm.sh/install | sh -s -- --server # hqsh only (remote hosts) # # Installs prebuilt binaries from the latest GitHub releases of # profullstack/hqterm and profullstack/hqsh into ~/.local/bin (or $HQTERM_BIN). -# The desktop app (Linux x86_64/arm64 AppImage) goes to -# ~/.local/opt/hqterm-desktop with an app-launcher entry. No sudo, safe to run -# again. Pin versions with HQTERM_VERSION / HQSH_VERSION (e.g. v0.2.0). +# The desktop app goes to ~/.local/opt/hqterm-desktop with an app-launcher +# entry on Linux (x86_64/arm64 AppImage), and to ~/Applications/hqterm.app on +# macOS (Apple silicon/Intel). No sudo, safe to run again. Pin versions with HQTERM_VERSION / HQSH_VERSION (e.g. v0.2.0). set -eu BIN="${HQTERM_BIN:-$HOME/.local/bin}" @@ -24,9 +24,9 @@ for arg in "$@"; do -h|--help) echo "usage: install.sh [--server] [--desktop|--no-desktop] [--bin=DIR]" echo " --server install only hqsh (for hosts you connect to)" - echo " --desktop also install the hqterm desktop app (Linux AppImage)" + echo " --desktop also install the hqterm desktop app (Linux AppImage, macOS app)" echo " --no-desktop never install the desktop app" - echo " (default: install it on Linux when a display is present)" + echo " (default: on Linux with a display, and on a Mac not over ssh)" echo " --bin=DIR install into DIR (default ~/.local/bin, or \$HQTERM_BIN)" exit 0 ;; *) echo "hqterm install: unknown option $arg" >&2; exit 2 ;; @@ -42,7 +42,11 @@ fi DESKTOP_AUTO=0 if [ "$DESKTOP" = auto ]; then DESKTOP_AUTO=1 - if [ "$(uname -s)" = Linux ] && [ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ]; then DESKTOP=1; else DESKTOP=0; fi + DESKTOP=0 + case "$(uname -s)" in + Linux) [ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ] && DESKTOP=1 ;; + Darwin) [ -z "${SSH_CONNECTION:-}${SSH_TTY:-}" ] && DESKTOP=1 ;; + esac fi if [ -n "${HQTERM_INSTALL_PARSE_ONLY:-}" ]; then echo "bin=$BIN server=$SERVER desktop=$DESKTOP auto=$DESKTOP_AUTO" @@ -148,11 +152,38 @@ have_libfuse2() { return 1 } -# install_desktop: the AppImage, an icon and an app-launcher entry (Linux only). +# install_desktop_mac: hqterm.app into ~/Applications (no admin rights). +install_desktop_mac() { + case "$ARCH" in + amd64) march=x64 ;; + *) march=arm64 ;; + esac + asset="hqterm-desktop-macos-$march.zip" + base="$(release_base hqterm "${HQTERM_VERSION:-}")" + say "downloading $asset" + fetch "$base/$asset" "$TMP/$asset" || return 1 + verify "$base" "$asset" hqterm + # A copy dragged into /Applications from the .dmg is updated where it is. + apps="$HOME/Applications" + if [ -d /Applications/hqterm.app ] && [ -w /Applications ] && [ -w /Applications/hqterm.app ]; then apps=/Applications; fi + mkdir -p "$apps" "$TMP/app" + ditto -x -k "$TMP/$asset" "$TMP/app" || return 1 + [ -d "$TMP/app/hqterm.app" ] || return 1 + # Downloaded with curl, so no quarantine flag; clear it anyway, since the + # app is ad-hoc signed and Gatekeeper would refuse a quarantined copy. + xattr -dr com.apple.quarantine "$TMP/app/hqterm.app" 2>/dev/null || true + rm -rf "$apps/hqterm.app" + mv "$TMP/app/hqterm.app" "$apps/hqterm.app" + say "installed $apps/hqterm.app (open it from Launchpad, Spotlight or: open -a hqterm)" + return 0 +} + +# install_desktop: the AppImage, an icon and an app-launcher entry (Linux), +# or the .app (macOS). install_desktop() { - if [ "$OS" != linux ]; then - say "the desktop app is Linux-only for now; skipping it" - return 1 + if [ "$OS" = darwin ]; then + install_desktop_mac + return fi case "$ARCH" in amd64) aarch=x86_64 ;; @@ -234,7 +265,7 @@ DESKTOP_OK=0 if [ "$DESKTOP" = 1 ]; then if install_desktop; then DESKTOP_OK=1 - elif [ "$DESKTOP_AUTO" = 1 ] || [ "$OS" != linux ]; then + elif [ "$DESKTOP_AUTO" = 1 ]; then say "warning: the desktop app was not installed; hqterm and hqsh are" else die "could not download the desktop app for $OS-$ARCH (no release asset yet?)" diff --git a/site/docs.html b/site/docs.html index 43f322c..1731d26 100644 --- a/site/docs.html +++ b/site/docs.html @@ -67,16 +67,16 @@

hqterm docs

Install

- + - - + +
WhereCommandInstalls
Your Linux or macOS machinecurl -fsSL https://hqterm.sh/install | shhqterm and hqsh in ~/.local/bin, plus the desktop app on a Linux desktop
Your Linux or macOS machinecurl -fsSL https://hqterm.sh/install | shhqterm and hqsh in ~/.local/bin, plus the desktop app on a Linux desktop or a Mac
A Linux or macOS servercurl -fsSL https://hqterm.sh/install | sh -s -- --serverhqsh only. It also runs hqsh server setup, which turns on systemd lingering.
Windows (PC or server)irm https://hqterm.sh/install.ps1 | iexhqsh.exe in ~\.local\bin, added to your user PATH
Desktop app onlycurl -fsSL https://hqterm.sh/install | sh -s -- --desktopThe Linux AppImage in ~/.local/opt/hqterm-desktop, plus a launcher entry
Windows (PC or server)irm https://hqterm.sh/install.ps1 | iexhqsh.exe in ~\.local\bin, added to your user PATH, plus the desktop app (not over ssh; $env:HQTERM_NO_DESKTOP = "1" skips it)
Desktop app onlycurl -fsSL https://hqterm.sh/install | sh -s -- --desktopLinux: the AppImage in ~/.local/opt/hqterm-desktop, plus a launcher entry. macOS: ~/Applications/hqterm.app

Nothing needs sudo or admin rights, and the installers are safe to run again. Every download is checked against the release's SHA256SUMS. hqsh must be installed on both ends: the plain install covers your machine, and --server covers each host.

- +
install.sh option
--serverInstall hqsh only, for hosts you connect to
--desktop / --no-desktopAlways or never install the desktop app. By default it is installed on Linux when a display is present.
--desktop / --no-desktopAlways or never install the desktop app. By default it is installed on Linux when a display is present, and on a Mac unless you are logged in over ssh.
--bin=DIRInstall into DIR instead of ~/.local/bin (or $HQTERM_BIN)
HQTERM_VERSION, HQSH_VERSIONPin a release, for example HQSH_VERSION=v0.3.0
@@ -170,7 +170,15 @@

A whole fleet

for h in web1 web2 db1; do ssh "$h" 'curl -fsSL https://hqterm.sh/install | sh -s -- --server'; done

The desktop app

-

hqterm's own terminal window (Electron and xterm.js with WebGL): tabs, split panes that come back where you left them, and hqtui/qc images and HD emoji drawn in full. Linux x86_64 and arm64.

+

hqterm's own terminal window (Electron and xterm.js with WebGL): tabs, split panes that come back where you left them, and hqtui/qc images and HD emoji drawn in full. Linux, macOS and Windows.

+ + + + + + +
PlatformDownload
macOS, Apple siliconhqterm-desktop-macos-arm64.dmg (or .zip)
macOS, Intelhqterm-desktop-macos-x64.dmg (or .zip)
Windows 10/11 (x64; ARM64 runs it emulated)hqterm-desktop-windows-x64-setup.exe (per user, no admin), or the portable .zip
Linux x86_64 / arm64x86_64 AppImage / aarch64 AppImage
+

The installers above (install and install.ps1) fetch the right one for you. The Mac and Windows builds are not signed by Apple or Microsoft yet. On a Mac, a .dmg downloaded in a browser opens after a right-click on hqterm, then Open (or xattr -dr com.apple.quarantine /Applications/hqterm.app); the curl installer needs none of that. On Windows, SmartScreen may ask once: More info, then Run anyway. Panes run your login shell; on Windows that is PowerShell 7 (pwsh) when installed, else Windows PowerShell, and remote panes need hqsh.exe on your PATH (install.ps1 puts it there).

@@ -180,6 +188,7 @@

The desktop app

Key
Ctrl+Shift+T / WNew tab / close tab. Ctrl+Tab and Ctrl+PgUp/PgDn switch tabs.
Ctrl+Shift+C / VCopy / paste
Ctrl+= / Ctrl+- / Ctrl+0Zoom in / out / reset
+

On macOS, Cmd works in place of Ctrl+Shift (Cmd+T, Cmd+W, Cmd+C, Cmd+V, Cmd+D; Cmd+Shift+D splits down), Cmd+= / Cmd+- / Cmd+0 zoom, and Cmd+Q quits. Option is Meta.

The + button opens a shell, the session manager, or any host (as hqsh host --session main). The layout (tabs, splits, each remote pane's host and session) is saved to ~/.config/hqterm/desktop-layout.json. The next launch reattaches every remote pane; local panes come back as fresh shells.

Settings live in ~/.config/hqterm/desktop.json:

{ "fontFamily": "JetBrains Mono", "fontSize": 15, "theme": { "background": "#000" }, "restore": true, "gpu": true }
diff --git a/site/index.html b/site/index.html index 1b317c1..0129022 100644 --- a/site/index.html +++ b/site/index.html @@ -75,7 +75,9 @@

hqterm

diff --git a/test/desktop.test.ts b/test/desktop.test.ts index 5954f42..73d4088 100644 --- a/test/desktop.test.ts +++ b/test/desktop.test.ts @@ -101,9 +101,11 @@ describe("install.sh flags", () => { return { code: p.exitCode, out: p.stdout.toString().trim(), err: p.stderr.toString() }; }; const linux = process.platform === "linux"; + const mac = process.platform === "darwin"; - test("defaults: desktop only on a Linux display", () => { - expect(run([]).out).toBe("bin=/h/.local/bin server=0 desktop=0 auto=1"); + test("defaults: desktop on a Linux display or a Mac not over ssh", () => { + expect(run([]).out).toBe(`bin=/h/.local/bin server=0 desktop=${mac ? 1 : 0} auto=1`); + expect(run([], { SSH_CONNECTION: "1.2.3.4 5 6.7.8.9 22" }).out).toBe("bin=/h/.local/bin server=0 desktop=0 auto=1"); expect(run([], { DISPLAY: ":0" }).out).toBe(`bin=/h/.local/bin server=0 desktop=${linux ? 1 : 0} auto=1`); expect(run([], { WAYLAND_DISPLAY: "wayland-0" }).out).toBe(`bin=/h/.local/bin server=0 desktop=${linux ? 1 : 0} auto=1`); }); @@ -112,7 +114,7 @@ describe("install.sh flags", () => { expect(run(["--desktop"]).out).toBe("bin=/h/.local/bin server=0 desktop=1 auto=0"); expect(run(["--no-desktop"], { DISPLAY: ":0" }).out).toBe("bin=/h/.local/bin server=0 desktop=0 auto=0"); expect(run(["--server"], { DISPLAY: ":0" }).out).toBe("bin=/h/.local/bin server=1 desktop=0 auto=0"); - expect(run(["--bin=/opt/b"]).out).toBe("bin=/opt/b server=0 desktop=0 auto=1"); + expect(run(["--bin=/opt/b"]).out).toBe(`bin=/opt/b server=0 desktop=${mac ? 1 : 0} auto=1`); }); test("bad combinations fail", () => { From 55cf2f0f23eadbdb63936503015bb8beacc8ac51 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 12 Oct 2026 09:50:14 +0000 Subject: [PATCH 3/5] ci: find the .app by glob, ad-hoc sign PR builds; install.sh takes any .app name Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 6 ++++-- .github/workflows/release.yml | 4 ++-- install.sh | 5 +++++ 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f658ea..def8b1a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -106,13 +106,15 @@ jobs: - run: npm ci - run: npm test - run: npm run dist:${{ matrix.os }} -- --${{ matrix.arch }} + env: + CSC_FOR_PULL_REQUEST: "true" - name: List and check the build run: | ls -la dist - if [ "${{ matrix.os }}" = mac ]; then codesign --verify --deep --strict --verbose=2 dist/mac*/hqterm.app; fi + if [ "${{ matrix.os }}" = mac ]; then ls dist/mac*; codesign --verify --deep --strict --verbose=2 dist/mac*/*.app; fi - name: Smoke test run: | - if [ "${{ matrix.os }}" = mac ]; then exe="$(ls dist/mac*/hqterm.app/Contents/MacOS/* | head -n1)"; else exe="$(ls dist/win*-unpacked/*.exe | head -n1)"; fi + if [ "${{ matrix.os }}" = mac ]; then exe="$(ls dist/mac*/*.app/Contents/MacOS/* | head -n1)"; else exe="$(ls dist/win*-unpacked/*.exe | head -n1)"; fi echo "launching $exe" scripts/ci-smoke.sh "$exe" - uses: actions/upload-artifact@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3e62799..7232ed8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -89,8 +89,8 @@ jobs: run: | ls -la dist if [ "${{ matrix.os }}" = mac ]; then - codesign --verify --deep --strict --verbose=2 dist/mac*/hqterm.app - exe="$(ls dist/mac*/hqterm.app/Contents/MacOS/* | head -n1)" + ls dist/mac*; codesign --verify --deep --strict --verbose=2 dist/mac*/*.app + exe="$(ls dist/mac*/*.app/Contents/MacOS/* | head -n1)" else exe="$(ls dist/win*-unpacked/*.exe | head -n1)" fi diff --git a/install.sh b/install.sh index 992cae4..e2843ab 100755 --- a/install.sh +++ b/install.sh @@ -168,6 +168,11 @@ install_desktop_mac() { if [ -d /Applications/hqterm.app ] && [ -w /Applications ] && [ -w /Applications/hqterm.app ]; then apps=/Applications; fi mkdir -p "$apps" "$TMP/app" ditto -x -k "$TMP/$asset" "$TMP/app" || return 1 + # One .app in the zip, whatever electron-builder named it; installed as hqterm.app. + for a in "$TMP/app"/*.app; do + [ -d "$a" ] && [ "$a" != "$TMP/app/hqterm.app" ] && mv "$a" "$TMP/app/hqterm.app" + break + done [ -d "$TMP/app/hqterm.app" ] || return 1 # Downloaded with curl, so no quarantine flag; clear it anyway, since the # app is ad-hoc signed and Gatekeeper would refuse a quarantined copy. From 624d46ef01f25d349f82f04a3ef99bb7f9d157d4 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 12 Oct 2026 09:53:04 +0000 Subject: [PATCH 4/5] desktop: Option+drag selects on macOS (smoke uses it); bundle is hqterm.app Co-Authored-By: Claude Opus 5.5 --- desktop/main.js | 8 +++++--- desktop/package.json | 1 + desktop/src/app.js | 2 ++ 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/desktop/main.js b/desktop/main.js index 15a1e79..9a1d8a7 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -388,9 +388,11 @@ if (!app.requestSingleInstanceLock()) { } if (!win || !r) return; const send = (type, x, modifiers) => win.webContents.sendInputEvent({ type, x, y: r.y, button: "left", clickCount: 1, modifiers }); - send("mouseDown", r.x, ["shift"]); - for (let x = r.x; x <= r.x + 80; x += 10) send("mouseMove", x, ["shift", "leftButtonDown"]); - send("mouseUp", r.x + 80, ["shift"]); + // The modifier that forces a selection: Option on macOS, Shift elsewhere. + const force = process.platform === "darwin" ? "alt" : "shift"; + send("mouseDown", r.x, [force]); + for (let x = r.x; x <= r.x + 80; x += 10) send("mouseMove", x, [force, "leftButtonDown"]); + send("mouseUp", r.x + 80, [force]); await sleep(100); for (let x = r.x + 80; x <= r.x + 140; x += 10) win.webContents.sendInputEvent({ type: "mouseMove", x, y: r.y + 20 }); await sleep(300); diff --git a/desktop/package.json b/desktop/package.json index 0b6592a..4b0eca2 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -55,6 +55,7 @@ ] }, "mac": { + "executableName": "hqterm", "target": [ "dmg", "zip" diff --git a/desktop/src/app.js b/desktop/src/app.js index 48c1253..8d256d5 100644 --- a/desktop/src/app.js +++ b/desktop/src/app.js @@ -98,6 +98,8 @@ scrollback: 10000, cursorBlink: true, macOptionIsMeta: true, + // On macOS xterm.js forces a selection with Option+drag, not Shift+drag. + macOptionClickForcesSelection: true, allowTransparency: false, }); const rt = { pane, spec: pane.spec, el, term, fit: new FitAddon.FitAddon(), title: "", opened: false }; From 38e72f83b1a7e442ef7f064c69b90f5d790f7cfc Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 12 Oct 2026 09:56:30 +0000 Subject: [PATCH 5/5] ci: retry the desktop build when hdiutil cannot eject the dmg Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 11 ++++++++++- .github/workflows/release.yml | 11 ++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index def8b1a..f56018e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -105,7 +105,16 @@ jobs: node-version: 22 - run: npm ci - run: npm test - - run: npm run dist:${{ matrix.os }} -- --${{ matrix.arch }} + - name: Build + run: | + # hdiutil sometimes cannot eject the dmg ("Resource busy"), mostly + # from Spotlight indexing it: turn that off and retry. + if [ "${{ matrix.os }}" = mac ]; then sudo mdutil -a -i off >/dev/null 2>&1 || true; fi + for i in 1 2 3; do + npm run dist:${{ matrix.os }} -- --${{ matrix.arch }} && exit 0 + echo "build attempt $i failed"; sleep 10 + done + exit 1 env: CSC_FOR_PULL_REQUEST: "true" - name: List and check the build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7232ed8..037de08 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -84,7 +84,16 @@ jobs: [ "$want" = "$GITHUB_REF_NAME" ] || { echo "tag $GITHUB_REF_NAME but desktop/package.json says $want"; exit 1; } - run: npm ci - run: npm test - - run: npm run dist:${{ matrix.os }} -- --${{ matrix.arch }} + - name: Build + run: | + # hdiutil sometimes cannot eject the dmg ("Resource busy"), mostly + # from Spotlight indexing it: turn that off and retry. + if [ "${{ matrix.os }}" = mac ]; then sudo mdutil -a -i off >/dev/null 2>&1 || true; fi + for i in 1 2 3; do + npm run dist:${{ matrix.os }} -- --${{ matrix.arch }} && exit 0 + echo "build attempt $i failed"; sleep 10 + done + exit 1 - name: Check and smoke test run: | ls -la dist