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
17 changes: 16 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.11.0] — 2026-05-07

### Features

- Auto-update: DPlex now checks for new releases on launch and every
6 hours. On Windows and Linux AppImage, updates download silently
in the background and a banner offers a one-click "Restart and
install". On macOS and Linux `.deb` builds (where in-place
replacement isn't safe yet) the banner instead links to the
release page; you can also "Skip this version" to stop being
prompted for that release.
- New "About" tab in Settings shows the current version, last update
check time, and a manual "Check for updates" button.

## [0.10.0] — 2026-05-07

### Features
Expand Down Expand Up @@ -328,7 +342,8 @@ AI-assisted development.
- Eight built-in themes (dark and light variants).
- Keyboard shortcuts for tabs, splits, sidebar, and settings.

[Unreleased]: https://github.com/Ron537/DPlex/compare/v0.10.0...HEAD
[Unreleased]: https://github.com/Ron537/DPlex/compare/v0.11.0...HEAD
[0.11.0]: https://github.com/Ron537/DPlex/compare/v0.10.0...v0.11.0
[0.10.0]: https://github.com/Ron537/DPlex/compare/v0.9.2...v0.10.0
[0.9.2]: https://github.com/Ron537/DPlex/compare/v0.9.1...v0.9.2
[0.9.1]: https://github.com/Ron537/DPlex/compare/v0.9.0...v0.9.1
Expand Down
14 changes: 12 additions & 2 deletions package-lock.json

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

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "dplex",
"version": "0.10.0",
"version": "0.11.0",
"description": "A terminal multiplexer built for AI-assisted development. Manage multiple AI CLI sessions (Copilot, Claude, and more) alongside regular terminals in one window.",
"main": "./out/main/index.js",
"author": {
Expand Down Expand Up @@ -63,6 +63,7 @@
"@xterm/xterm": "^6.0.0",
"allotment": "^1.20.5",
"diff": "^9.0.0",
"electron-log": "^5.4.3",
"electron-updater": "^6.8.3",
"ignore": "^5.3.2",
"lucide-react": "^1.7.0",
Expand Down
18 changes: 17 additions & 1 deletion src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,13 @@ import {
setActiveCompositeId
} from './services/notifications'
import * as attentionService from './services/attentionService'
import { initAutoUpdater } from './services/autoUpdater'
import {
initAutoUpdater,
getUpdateState,
checkForUpdates,
installUpdate,
openUpdateDownload
} from './services/autoUpdater'
import { makeCompositeId } from '../preload/attentionTypes'
import {
getBranch,
Expand Down Expand Up @@ -494,6 +500,16 @@ function registerIpcHandlers(): void {
return getBranch(dirPath)
})

// App version + auto-update
ipcMain.handle('app:getVersion', () => app.getVersion())
ipcMain.handle('app:getUpdateState', () => getUpdateState())
ipcMain.handle('app:checkForUpdates', () => checkForUpdates())
ipcMain.handle('app:installUpdate', () => installUpdate())
ipcMain.handle('app:openUpdateDownload', () => {
openUpdateDownload()
return getUpdateState()
})

// Git service — reactive branch watching
ipcMain.handle('git:getBranch', async (_event, dirPath: string) => {
return getBranch(dirPath)
Expand Down
205 changes: 171 additions & 34 deletions src/main/services/autoUpdater.ts
Original file line number Diff line number Diff line change
@@ -1,60 +1,197 @@
import { app, BrowserWindow } from 'electron'
import { app, BrowserWindow, shell } from 'electron'
import { autoUpdater } from 'electron-updater'
import log from 'electron-log/main'
import type { InstallMode, UpdateState } from '../../preload/updateTypes'
import {
initialState,
reduce,
shouldSkipPeriodicCheck,
type UpdateAction
} from './updateState'

const RELEASE_URL_BASE = 'https://github.com/Ron537/DPlex/releases'
const PERIODIC_CHECK_MS = 6 * 60 * 60 * 1000

/**
* Wires automatic-update checks for packaged builds.
* Coordinates auto-update for packaged builds.
*
* electron-updater reads its feed location from `publish:` in
* `electron-builder.yml` at build time. For DPlex that's GitHub Releases
* — a signed release tagged `v*` appears in the users' app within a few
* hours of publishing.
* Per-platform behaviour:
*
* Skipped entirely in development and unpackaged builds: there's no
* meaningful update feed and the updater logs noisy errors otherwise.
* - **Windows + Linux AppImage** (`autoInstall`): silent background
* download, banner offered when ready, "Restart and install" applies
* the update via Squirrel.
* - **macOS** (`manualDownload`): notification only. We can't apply an
* update in place because Squirrel.Mac validates that the running
* bundle's code-signature certificate matches the replacement and
* ad-hoc signed builds use ephemeral identifiers, so the swap is
* rejected. The banner offers a "Download" button that opens the
* GitHub releases page; the user replaces the .app manually. Once we
* have Developer ID signing + notarization, this branch goes away.
* - **Linux non-AppImage** (`manualDownload`): same as macOS. We don't
* want to drive `dpkg`/`apt` from the app — the user knows their
* package manager and can grab the new `.deb` from the release page.
* - **Unpackaged** (`unsupported`): no-op everywhere.
*
* This is a minimal hook: it logs progress, surfaces failures quietly,
* and installs downloaded updates on next quit. It does not yet display
* an in-app banner or let the user defer an update — those are UI
* additions that can come later.
* The renderer never sees the underlying `electron-updater` instance —
* only `UpdateState` snapshots over IPC and a small command surface
* (`check`, `install`, `openDownload`).
*/
export function initAutoUpdater(getMainWindow: () => BrowserWindow | null): void {
if (!app.isPackaged) return

autoUpdater.autoDownload = true
autoUpdater.autoInstallOnAppQuit = true
let getMainWindow: (() => BrowserWindow | null) | null = null
let state: UpdateState = initialState('unsupported')
let periodicTimer: NodeJS.Timeout | null = null
let started = false

export function initAutoUpdater(getMainWindowFn: () => BrowserWindow | null): void {
if (started) return
started = true
getMainWindow = getMainWindowFn
state = initialState(resolveInstallMode())

if (!app.isPackaged || state.installMode === 'unsupported') {
return
}

// Pipe electron-updater's verbose logging into a rotating file so
// failures the user reports can be diagnosed after the fact.
log.transports.file.level = 'info'
autoUpdater.logger = log

autoUpdater.autoDownload = state.installMode === 'autoInstall'
autoUpdater.autoInstallOnAppQuit = state.installMode === 'autoInstall'
// Allow prerelease channels only if the current version is itself a
// prerelease (semver tag contains a hyphen) — keeps stable users on
// stable builds while letting early testers on `v0.2.0-beta.1` track
// stable builds while letting early testers on `v0.x.0-beta.1` track
// the beta track.
autoUpdater.allowPrerelease = /-/.test(app.getVersion())

autoUpdater.on('error', (err) => {
// Never throw into the main event loop — a missing feed or offline
// user must not crash the app. Log for debugging only.
console.warn('[auto-update] error:', err?.message ?? err)
log.warn('[auto-update] error:', err)
dispatch({
type: 'error',
message: shortenError(err)
})
})

autoUpdater.on('update-available', (info) => {
console.log('[auto-update] update available:', info.version)
autoUpdater.on('checking-for-update', () => {
dispatch({ type: 'check-started' })
})

autoUpdater.on('update-not-available', () => {
console.log('[auto-update] already up to date')
dispatch({ type: 'check-finished-no-update' })
})

autoUpdater.on('update-available', (info) => {
dispatch({
type: 'available',
version: info.version,
releaseUrl: `${RELEASE_URL_BASE}/tag/v${info.version}`
})
})

autoUpdater.on('download-progress', (info) => {
dispatch({
type: 'download-progress',
percent: info.percent,
version: state.version
})
})

autoUpdater.on('update-downloaded', (info) => {
console.log('[auto-update] update downloaded:', info.version)
// Let the renderer surface a toast/banner when we add one. The update
// installs automatically on next quit, so no forced restart.
const win = getMainWindow()
if (win && !win.isDestroyed()) {
win.webContents.send('app:updateDownloaded', { version: info.version })
}
dispatch({ type: 'downloaded', version: info.version })
})

// Fire-and-forget check. electron-updater debounces internally and
// handles retries, so we don't schedule our own polling.
autoUpdater.checkForUpdates().catch((err) => {
console.warn('[auto-update] initial check failed:', err?.message ?? err)
// Initial check + recurring poll. `electron-updater` debounces
// overlapping requests internally; we still skip when state is in a
// terminal-pending status to avoid noisy regressions.
void runCheck('startup')
periodicTimer = setInterval(() => {
if (shouldSkipPeriodicCheck(state.status)) return
void runCheck('periodic')
}, PERIODIC_CHECK_MS)
}

export function shutdownAutoUpdater(): void {
if (periodicTimer) {
clearInterval(periodicTimer)
periodicTimer = null
}
started = false
}

export function getUpdateState(): UpdateState {
return state
}

export async function checkForUpdates(): Promise<UpdateState> {
if (!state.canCheck) return state
await runCheck('manual')
return state
}

export function installUpdate(): UpdateState {
if (!state.canInstall) return state
dispatch({ type: 'install-started' })
// `quitAndInstall` synchronously fires `before-quit` and then exits.
// Wrap in setImmediate so the renderer round-trips the state update
// (showing the disabled "Restarting…" button) before the process
// tears down.
setImmediate(() => {
try {
autoUpdater.quitAndInstall(false, true)
} catch (err) {
log.error('[auto-update] quitAndInstall failed:', err)
dispatch({ type: 'error', message: shortenError(err) })
}
})
return state
}

export function openUpdateDownload(): void {
if (!state.canOpenDownload) return
const url = state.releaseUrl ?? `${RELEASE_URL_BASE}/latest`
void shell.openExternal(url)
}

function dispatch(action: UpdateAction): void {
const next = reduce(state, action, { installMode: state.installMode })
if (next === state) return
state = next
const win = getMainWindow?.()
if (win && !win.isDestroyed()) {
win.webContents.send('app:updateStateChanged', state)
}
}

async function runCheck(reason: 'startup' | 'periodic' | 'manual'): Promise<void> {
try {
log.info(`[auto-update] check (${reason})`)
await autoUpdater.checkForUpdates()
} catch (err) {
log.warn(`[auto-update] check (${reason}) failed:`, err)
dispatch({ type: 'error', message: shortenError(err) })
}
}

function resolveInstallMode(): InstallMode {
if (!app.isPackaged) return 'unsupported'
if (process.platform === 'darwin') return 'manualDownload'
if (process.platform === 'win32') return 'autoInstall'
if (process.platform === 'linux') {
// electron-builder sets APPIMAGE to the .AppImage path when running
// from one. Anything else (.deb, .rpm, source build) gets the
// manual path so we don't try to invoke a privileged package
// manager from inside the app.
return process.env.APPIMAGE ? 'autoInstall' : 'manualDownload'
}
return 'unsupported'
}

function shortenError(err: unknown): string {
if (err instanceof Error) {
// First line is usually the most useful summary (`HttpError:
// 404 not found`, `net::ERR_INTERNET_DISCONNECTED`, etc.).
return err.message.split('\n')[0].slice(0, 160)
}
return String(err).slice(0, 160)
}
Loading
Loading