Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,58 @@ 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
- 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
run: |
ls -la dist
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*/*.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
61 changes: 60 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,67 @@ 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
- 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
if [ "${{ matrix.os }}" = mac ]; then
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
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
Expand Down
18 changes: 15 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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+<key> does what
Ctrl+Shift+<key> does.

| Keys | |
| --- | --- |
| Ctrl+Shift+T / W | new tab / close tab; Ctrl+Tab, Ctrl+PgUp/PgDn switch |
Expand All @@ -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

Expand All @@ -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
Expand Down
62 changes: 50 additions & 12 deletions desktop/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -96,16 +99,41 @@ 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 };
for (const k of Object.keys(env)) if (k.startsWith("ELECTRON_") || k.startsWith("CHROME_")) delete env[k];
// 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",
Expand All @@ -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. */
Expand All @@ -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;
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -352,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);
Expand Down
4 changes: 2 additions & 2 deletions desktop/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

38 changes: 33 additions & 5 deletions desktop/package.json
Original file line number Diff line number Diff line change
@@ -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. <hello@profullstack.com>",
Expand All @@ -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",
Expand Down Expand Up @@ -48,12 +50,38 @@
"category": "System;TerminalEmulator",
"icon": "build/icon.png",
"artifactName": "hqterm-desktop-linux-${arch}.${ext}",
"executableArgs": ["--no-sandbox"]
"executableArgs": [
"--no-sandbox"
]
},
"mac": {
"target": "zip",
"executableName": "hqterm",
"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"
}
}
}
26 changes: 26 additions & 0 deletions desktop/scripts/ci-smoke.sh
Original file line number Diff line number Diff line change
@@ -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"
Loading
Loading