Skip to content
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,12 @@ palettes** — Catppuccin, Gruvbox, Nord, Tokyo Night, Solarized, Dracula, the
Termspace default. Drop a YAML into `~/.config/termspace/themes/` for your
own.

Termspace checks GitHub for a newer release when it starts and once a day: one
anonymous request to the releases API, nothing else sent. A newer release shows
as a chip in the title bar that opens the release page. `updateCheck: 0` in
`~/.config/termspace/settings.yaml` (or the toggle in Settings › Updates) turns
the check off; "Check now" in the same section works either way.

## Developing

Setup, the test commands, and the bar for a PR are in
Expand Down
10 changes: 10 additions & 0 deletions docs/MANUAL-QA.md
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,16 @@ These can't be replaced by automated judgment. They're matters of impression, no
appear as typed (both English and Korean), Enter commits, and the session file has the
new title without pressing save. Escape leaves the card as it was

### Update check (no self-check covers this)

- [ ] **Update chip.** Run the previous release (`~/Applications/Termspace.AppImage`
before `install:local`, or an older AppImage from the releases page). Within a few
seconds of startup the title bar's left group ends in a chip naming the new
version. Clicking it opens that release's GitHub page in the browser. Close the
chip: it stays hidden; Settings › Updates › Check now still reports the version and
offers the page. Why manual: the only path is a live request to GitHub, which a
self-check must not depend on

### Shell integration (bash and zsh)

The OSC round trip itself is automated — the check emits the hook's sequences with
Expand Down
21 changes: 21 additions & 0 deletions docs/engineering-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -890,3 +890,24 @@ nothing — the app would be arguing with them. The directory exists from the
moment anything is written, so an empty directory is a deliberate state and is
left alone.


## Updates

**The update check ships as a notice, not an installer.** The first design replaced
the AppImage in place: download beside `$APPIMAGE`, verify against `SHA256SUMS.txt`,
rename over the running file. It was cut to a check plus a link before the first
public launch. The notice is the part that cannot be added retroactively, and an
installer that overwrites the running binary is the wrong thing to ship untested to
strangers. In-place replacement is deferred, with two decisions kept for it: the file
is replaced at the same path and name, because launchers and the AppArmor profile pin
`~/Applications/Termspace.AppImage`, and every failure leaves the installed binary
untouched.

**Prerelease visibility follows the running version, not a setting.** A stable build
sees only stable releases; a `-beta.N` build sees both. That rules out the one wrong
prompt that matters — telling a beta user to "update" to an older stable — and it is
pinned by `update-check.test.ts`.

**The self-check has no group for this.** The one path is a network request, and a
check that fails when GitHub is unreachable would be reporting the network, not the
app.
7 changes: 7 additions & 0 deletions src/main/app-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,11 @@ describe('normalizeSettings', () => {
expect(normalizeSettings({ paneLabels: 0.4 }).paneLabels).toBe(0)
expect(normalizeSettings({ paneLabels: 'off' }).paneLabels).toBe(DEFAULT_SETTINGS.paneLabels)
})

it('reads updateCheck as 0 or 1 and defaults it on', () => {
expect(normalizeSettings({}).updateCheck).toBe(1)
expect(normalizeSettings({ updateCheck: 0 }).updateCheck).toBe(0)
expect(normalizeSettings({ updateCheck: 5 }).updateCheck).toBe(1)
expect(normalizeSettings({ updateCheck: 'no' }).updateCheck).toBe(1)
})
})
3 changes: 3 additions & 0 deletions src/main/app-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export const SETTING_LIMITS = {
inheritWorkingDir: { min: 0, max: 1, step: 1 },
// Below 80 the chrome stops being readable; above 160 it crowds out the canvas.
uiScale: { min: 80, max: 160, step: 5 },
updateCheck: { min: 0, max: 1, step: 1 },
} as const

export function settingsFile(env: NodeJS.ProcessEnv): string {
Expand Down Expand Up @@ -110,6 +111,7 @@ export function normalizeSettings(raw: unknown): AppSettings {
paneLabels: Math.round(clampNumber(input['paneLabels'], 'paneLabels')),
idleDim: clampNumber(input['idleDim'], 'idleDim'),
notifications: Math.round(clampNumber(input['notifications'], 'notifications')),
updateCheck: Math.round(clampNumber(input['updateCheck'], 'updateCheck')),
inheritWorkingDir: Math.round(clampNumber(input['inheritWorkingDir'], 'inheritWorkingDir')),
fontFamily: cleanFontFamily(input['fontFamily']),
theme: cleanId(input['theme']),
Expand Down Expand Up @@ -147,6 +149,7 @@ const HEADER = `# Termspace settings
# focusBorderColor the colour custom uses, as #rrggbb
# locale interface language: en, ko, or empty to follow the system
# textRendering terminal glyph antialiasing: grayscale, or subpixel for the browser's LCD text (Linux only)
# updateCheck 1 asks GitHub for a newer release at startup and once a day. Nothing else is sent
`

/**
Expand Down
21 changes: 20 additions & 1 deletion src/main/ipc-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Routes renderer requests to pty-host and session-config, and batches pty output back.
*/
import { writeFile } from 'node:fs/promises'
import { clipboard, dialog, ipcMain, Notification, shell, type BrowserWindow } from 'electron'
import { app, clipboard, dialog, ipcMain, Notification, shell, type BrowserWindow } from 'electron'
import type {
LayoutSnapshot,
PaneAttention,
Expand All @@ -18,6 +18,7 @@ import { RC_LINE, RC_LINE_ZSH } from './shell-integration'
import { activateWindow } from './window-manager'
import { loadSettings, saveSettings, settingsFile } from './app-settings'
import { loadKeybindings, saveKeybindings } from './keybindings-file'
import { createUpdater } from './updater'
import { listMonoFonts } from './font-list'
import { ensureThemesDir, listUserThemes } from './theme-config'
import { OutputBatcher } from './output-batcher'
Expand Down Expand Up @@ -72,6 +73,7 @@ const INVOKE_CHANNELS = [
'pty:foreground-commands',
'pty:titles',
'pty:cwd',
'update:check',
]
const ON_CHANNELS = [
'pty:write',
Expand All @@ -88,6 +90,7 @@ const ON_CHANNELS = [
'settings:reveal',
'themes:reveal',
'app:visible-pane',
'update:open-release',
]

export function registerIpcHandlers(
Expand Down Expand Up @@ -410,6 +413,21 @@ export function registerIpcHandlers(
})()
})

/*
* Release check. The URL never crosses to the renderer: it gets a state and
* asks main to open the page.
*/
const updater = createUpdater({
currentVersion: app.getVersion(),
fetch: (input, init) => fetch(input, init),
automatic: async () => (await loadSettings(env)).updateCheck === 1,
onState: (state) => send('update:state', state),
})
ipcMain.handle('update:check', () => updater.checkNow())
ipcMain.on('update:open-release', () => void shell.openExternal(updater.releaseUrl()))
// Not under the self-check: four instances asking GitHub at once is noise.
if (env['VITE_SELFCHECK'] !== '1') updater.start()

/*
* Screenshots for the self-check. DOM queries can't see a layout that is
* present and correctly classed but visually wrong.
Expand Down Expand Up @@ -445,6 +463,7 @@ export function registerIpcHandlers(
win.off('maximize', notifyMaximize)
win.off('unmaximize', notifyMaximize)
batcher.dispose()
updater.stop()
for (const channel of INVOKE_CHANNELS) ipcMain.removeHandler(channel)
for (const channel of ON_CHANNELS) ipcMain.removeAllListeners(channel)
}
Expand Down
97 changes: 97 additions & 0 deletions src/main/update-check.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest'
import { compareSemver, parseSemver, pickUpdate } from './update-check'

const release = (tag: string, extra: Record<string, unknown> = {}) => ({
tag_name: tag,
html_url: `https://github.com/ba2slk/termspace/releases/tag/${tag}`,
draft: false,
prerelease: tag.includes('-'),
...extra,
})

describe('parseSemver', () => {
it('reads a plain version, with or without the v', () => {
expect(parseSemver('1.2.3')).toEqual({ major: 1, minor: 2, patch: 3, prerelease: [] })
expect(parseSemver('v1.2.3')).toEqual({ major: 1, minor: 2, patch: 3, prerelease: [] })
})

it('splits the prerelease part, numbers as numbers', () => {
expect(parseSemver('1.1.0-beta.4')).toEqual({ major: 1, minor: 1, patch: 0, prerelease: ['beta', 4] })
})

it('returns null for anything else', () => {
expect(parseSemver('1.2')).toBeNull()
expect(parseSemver('nightly')).toBeNull()
expect(parseSemver('')).toBeNull()
})
})

describe('compareSemver', () => {
const v = (s: string) => parseSemver(s)!
it('orders by major, minor, patch', () => {
expect(compareSemver(v('1.0.0'), v('1.0.1'))).toBeLessThan(0)
expect(compareSemver(v('1.10.0'), v('1.9.9'))).toBeGreaterThan(0)
expect(compareSemver(v('2.0.0'), v('2.0.0'))).toBe(0)
})
it('puts a prerelease before its release, and orders prereleases', () => {
expect(compareSemver(v('1.1.0-beta.4'), v('1.1.0'))).toBeLessThan(0)
expect(compareSemver(v('1.1.0'), v('1.1.1'))).toBeLessThan(0)
expect(compareSemver(v('1.1.0-beta.4'), v('1.1.0-beta.10'))).toBeLessThan(0)
expect(compareSemver(v('1.1.0-alpha.1'), v('1.1.0-beta.1'))).toBeLessThan(0)
})
})

describe('pickUpdate', () => {
it('offers the newest stable release above a stable build', () => {
const picked = pickUpdate([release('v1.0.0'), release('v1.2.0'), release('v1.1.0')], '1.0.0')
expect(picked).toEqual({ version: '1.2.0', url: 'https://github.com/ba2slk/termspace/releases/tag/v1.2.0' })
})

it('reports nothing when the running version is the newest', () => {
expect(pickUpdate([release('v1.0.0'), release('v1.1.0')], '1.1.0')).toBeNull()
})

it('reports nothing for a local build newer than anything published', () => {
expect(pickUpdate([release('v1.0.0'), release('v1.1.0')], '1.2.0')).toBeNull()
})

it('hides prereleases from a stable build', () => {
expect(pickUpdate([release('v1.0.0'), release('v1.1.0-beta.4')], '1.0.0')).toBeNull()
})

it('shows prereleases to a prerelease build', () => {
expect(pickUpdate([release('v1.1.0-beta.3'), release('v1.1.0-beta.4')], '1.1.0-beta.3')?.version).toBe('1.1.0-beta.4')
})

it('never offers a beta build an older stable', () => {
expect(pickUpdate([release('v1.0.0'), release('v1.1.0-beta.4')], '1.1.0-beta.4')).toBeNull()
})

it('offers a beta build the stable that supersedes it', () => {
expect(pickUpdate([release('v1.1.0-beta.4'), release('v1.1.0')], '1.1.0-beta.4')?.version).toBe('1.1.0')
})

it('treats a release GitHub flags as prerelease that way even with a plain tag', () => {
expect(pickUpdate([release('v1.5.0', { prerelease: true })], '1.0.0')).toBeNull()
})

it('skips drafts, unparseable tags and malformed entries', () => {
const payload = [
release('v9.0.0', { draft: true }),
release('nightly'),
{ html_url: 'x' },
'garbage',
release('v1.0.1'),
]
expect(pickUpdate(payload, '1.0.0')?.version).toBe('1.0.1')
})

it('returns null for a payload that is not a list, or a version that does not parse', () => {
expect(pickUpdate({ message: 'rate limited' }, '1.0.0')).toBeNull()
expect(pickUpdate([release('v1.0.1')], 'dev')).toBeNull()
})

it('requires the html_url to be an https github.com page', () => {
expect(pickUpdate([release('v1.0.1', { html_url: 'file:///etc/passwd' })], '1.0.0')).toBeNull()
})
})
113 changes: 113 additions & 0 deletions src/main/update-check.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* Which published release, if any, to offer the running build. Pure.
*
* The GitHub payload is untrusted input: every field is checked before it is
* read, and anything that does not fit is skipped rather than thrown on.
*/

export interface SemVer {
readonly major: number
readonly minor: number
readonly patch: number
/** Dot-separated identifiers after the hyphen; numeric ones as numbers. */
readonly prerelease: readonly (string | number)[]
}

const SEMVER = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/

export function parseSemver(input: string): SemVer | null {
const m = SEMVER.exec(input)
if (m === null) return null
const prerelease = (m[4] ?? '')
.split('.')
.filter((part) => part !== '')
.map((part) => (/^\d+$/.test(part) ? Number(part) : part))
return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]), prerelease }
}

/** SemVer 2.0 precedence: a prerelease sorts before its release. */
export function compareSemver(a: SemVer, b: SemVer): number {
if (a.major !== b.major) return a.major - b.major
if (a.minor !== b.minor) return a.minor - b.minor
if (a.patch !== b.patch) return a.patch - b.patch
if (a.prerelease.length === 0 && b.prerelease.length === 0) return 0
if (a.prerelease.length === 0) return 1
if (b.prerelease.length === 0) return -1
const n = Math.min(a.prerelease.length, b.prerelease.length)
for (let i = 0; i < n; i++) {
const x = a.prerelease[i] as string | number
const y = b.prerelease[i] as string | number
if (x === y) continue
// Numbers sort before strings; numbers numerically, strings lexically.
if (typeof x === 'number' && typeof y === 'number') return x - y
if (typeof x === 'number') return -1
if (typeof y === 'number') return 1
return x < y ? -1 : 1
}
return a.prerelease.length - b.prerelease.length
}

export interface ReleaseCandidate {
/** Without the leading v. */
readonly version: string
readonly url: string
}

interface ParsedRelease extends ReleaseCandidate {
readonly semver: SemVer
readonly prerelease: boolean
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}

/** Only a page on github.com may leave the app from here. */
function isReleasePage(url: unknown): url is string {
if (typeof url !== 'string') return false
try {
const parsed = new URL(url)
return parsed.protocol === 'https:' && parsed.hostname === 'github.com'
} catch {
return false
}
}

function parseRelease(entry: unknown): ParsedRelease | null {
if (!isRecord(entry)) return null
if (entry['draft'] === true) return null
if (typeof entry['tag_name'] !== 'string') return null
const semver = parseSemver(entry['tag_name'])
if (semver === null) return null
if (!isReleasePage(entry['html_url'])) return null
return {
version: entry['tag_name'].replace(/^v/, ''),
url: entry['html_url'],
semver,
prerelease: entry['prerelease'] === true || semver.prerelease.length > 0,
}
}

/**
* The newest release the running build should hear about, or null.
*
* A stable build only hears about stable releases; a prerelease build hears
* about both. A build newer than everything published is a local one and gets
* nothing.
*/
export function pickUpdate(payload: unknown, currentVersion: string): ReleaseCandidate | null {
if (!Array.isArray(payload)) return null
const current = parseSemver(currentVersion)
if (current === null) return null
const acceptPrerelease = current.prerelease.length > 0

let best: ParsedRelease | null = null
for (const entry of payload) {
const release = parseRelease(entry)
if (release === null) continue
if (release.prerelease && !acceptPrerelease) continue
if (compareSemver(release.semver, current) <= 0) continue
if (best === null || compareSemver(release.semver, best.semver) > 0) best = release
}
return best === null ? null : { version: best.version, url: best.url }
}
Loading
Loading