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
36 changes: 35 additions & 1 deletion apps/cli/src/commands/self.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,38 @@ export function readManifest(env: NodeJS.ProcessEnv = process.env): InstallManif
}
}

/**
* Which surface flags to hand the installer when re-running it to upgrade.
*
* The installer with no flag auto-detects a desktop session, so a CLI-only
* install is deliberately left to that detection: a machine that has grown a
* desktop since it was first installed gets the desktop app on its next
* update, which is what "update" is expected to mean. Pinning it to
* `--cli-only` — as this used to — meant an install made from a tty or over
* SSH could never gain the desktop app, no matter how many times it updated,
* and said nothing about why.
*
* A desktop install is pinned the other way, to `--desktop`. Updating one from
* a tty (a cron job, an SSH session) has no DISPLAY to detect, so auto-detect
* would quietly downgrade it to CLI-only and strip the app out from under a
* running desktop.
*
* An explicit flag on the update command always wins, so either direction
* stays reachable by hand.
*/
export function installerFlags(manifest: InstallManifest, parsed?: ParsedArgv): string[] {
if (parsed && hasFlag(parsed, '--cli-only')) return ['--cli-only']
if (parsed && hasFlag(parsed, '--desktop')) return ['--desktop']
return manifest.desktop ? ['--desktop'] : []
}

/** What `Surfaces:` prints, so an update never silently skips the desktop app. */
function describeSurfaces(flags: string[]): string {
if (flags.includes('--cli-only')) return 'CLI only (--cli-only)'
if (flags.includes('--desktop')) return 'CLI and desktop app'
return 'CLI, plus the desktop app if this machine has a desktop session'
}

/**
* Re-runs the installer that put this copy here. It is idempotent, so
* updating is installing again, and the manifest remembers where it came
Expand All @@ -73,8 +105,10 @@ export async function runUpdate(parsed: ParsedArgv, output: Output): Promise<num
}

const installer = manifest.installer
const flags = installerFlags(manifest, parsed)
output.line(`Current version: ${VERSION}`)
output.line(`Updating from ${installer}`)
output.line(`Surfaces: ${describeSurfaces(flags)}`)

if (hasFlag(parsed, '--dry-run')) {
output.line('Dry run: would re-run the installer, which upgrades in place.')
Expand All @@ -91,7 +125,7 @@ export async function runUpdate(parsed: ParsedArgv, output: Output): Promise<num
return failure(output, `Could not download the installer from ${installer}.`, EXIT.unavailable)
}

const run = spawnSync('sh', ['-s', '--', ...(manifest.desktop ? [] : ['--cli-only'])], {
const run = spawnSync('sh', ['-s', '--', ...flags], {
input: script.stdout,
stdio: ['pipe', 'inherit', 'inherit'],
})
Expand Down
2 changes: 2 additions & 0 deletions apps/cli/src/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ COMMANDS

doctor check rsync, ssh and the install
update upgrade to the latest release [alias: upgrade]
updates the CLI, and the desktop app when this
machine has a desktop [--cli-only | --desktop]
uninstall remove DiskPush, keeping your connections and profiles
[alias: remove]

Expand Down
48 changes: 48 additions & 0 deletions apps/cli/src/installer-flags.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import { installerFlags, type InstallManifest } from './commands/self.js'
import { parseArgv } from './parse-argv.js'

function manifest(desktop: boolean): InstallManifest {
return {
version: '0.2.0',
method: desktop ? 'linux-app' : 'cli-tarball',
installer: 'https://diskpush.com/install.sh',
installedAt: '2026-08-30T00:00:00Z',
paths: [],
desktop,
}
}

describe('installerFlags', () => {
it('leaves a CLI-only install to the installer to auto-detect', () => {
// The bug this exists to prevent: pinning --cli-only meant an install made
// from a tty could never gain the desktop app, however often it updated.
expect(installerFlags(manifest(false))).toEqual([])
})

it('pins a desktop install to --desktop', () => {
// Updating from a cron job or an SSH session has no DISPLAY to detect, so
// auto-detect would strip the app out from under a working desktop.
expect(installerFlags(manifest(true))).toEqual(['--desktop'])
})

it('honours an explicit --cli-only on a desktop install', () => {
const parsed = parseArgv(['update', '--cli-only'])
expect(installerFlags(manifest(true), parsed)).toEqual(['--cli-only'])
})

it('honours an explicit --desktop on a CLI-only install', () => {
const parsed = parseArgv(['update', '--desktop'])
expect(installerFlags(manifest(false), parsed)).toEqual(['--desktop'])
})

it('prefers --cli-only when both are somehow passed', () => {
const parsed = parseArgv(['update', '--cli-only', '--desktop'])
expect(installerFlags(manifest(false), parsed)).toEqual(['--cli-only'])
})

it('adds no flag for a plain update of a CLI-only install', () => {
const parsed = parseArgv(['update'])
expect(installerFlags(manifest(false), parsed)).toEqual([])
})
})
4 changes: 2 additions & 2 deletions apps/cli/src/self-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { VERSION } from './help.js'
import type { Output } from './output.js'
import { readManifest, stateDirectory } from './commands/self.js'
import { installerFlags, readManifest, stateDirectory } from './commands/self.js'

/**
* Startup update check.
Expand Down Expand Up @@ -105,7 +105,7 @@ export async function autoUpdate(command: string | null, output: Output, force =
return 'failed'
}

const run = spawnSync('sh', ['-s', '--', ...(manifest.desktop ? [] : ['--cli-only'])], {
const run = spawnSync('sh', ['-s', '--', ...installerFlags(manifest)], {
input: script.stdout,
stdio: ['pipe', 'ignore', 'inherit'],
})
Expand Down
57 changes: 57 additions & 0 deletions apps/desktop/electron/main/bundle-path.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest'
import { join } from 'node:path'
import { contentTypeFor, resolveBundlePath } from './bundle-path.js'

const ROOT = '/opt/diskpush/out'

describe('resolveBundlePath', () => {
it('serves index.html for the origin root', () => {
expect(resolveBundlePath(ROOT, '/')).toBe(join(ROOT, 'index.html'))
})

it('resolves the root-absolute asset paths Next emits', () => {
// The whole reason the bundle scheme exists: under file:// these resolved
// against the filesystem root and every one of them 404'd.
expect(resolveBundlePath(ROOT, '/_next/static/css/app.css')).toBe(join(ROOT, '_next/static/css/app.css'))
expect(resolveBundlePath(ROOT, '/_next/static/chunks/main-app.js')).toBe(join(ROOT, '_next/static/chunks/main-app.js'))
})

it('decodes percent-escapes in a filename', () => {
expect(resolveBundlePath(ROOT, '/_next/a%20b.css')).toBe(join(ROOT, '_next/a b.css'))
})

it('refuses to escape the bundle', () => {
for (const attempt of ['/../../../etc/passwd', '/_next/../../etc/passwd', '/%2e%2e/%2e%2e/etc/passwd']) {
expect(resolveBundlePath(ROOT, attempt)).toBeNull()
}
})

it('refuses a malformed escape and an embedded NUL', () => {
expect(resolveBundlePath(ROOT, '/%')).toBeNull()
expect(resolveBundlePath(ROOT, '/index.html%00.png')).toBeNull()
})

it('does not treat a sibling directory as inside the bundle', () => {
// String-prefix checks without a separator let /opt/diskpush/outside through.
expect(resolveBundlePath('/opt/diskpush/out', '/../outside/secret')).toBeNull()
})
})

describe('contentTypeFor', () => {
it('types the assets a Next export is made of', () => {
// Served as octet-stream, a stylesheet is a stylesheet the renderer ignores.
expect(contentTypeFor('/out/_next/static/css/app.css')).toBe('text/css; charset=utf-8')
expect(contentTypeFor('/out/_next/static/chunks/main.js')).toBe('text/javascript; charset=utf-8')
expect(contentTypeFor('/out/index.html')).toBe('text/html; charset=utf-8')
expect(contentTypeFor('/out/_next/static/media/geist.woff2')).toBe('font/woff2')
})

it('ignores case in the extension', () => {
expect(contentTypeFor('/out/LOGO.PNG')).toBe('image/png')
})

it('falls back for anything unrecognised', () => {
expect(contentTypeFor('/out/data.bin')).toBe('application/octet-stream')
expect(contentTypeFor('/out/LICENSE')).toBe('application/octet-stream')
})
})
59 changes: 59 additions & 0 deletions apps/desktop/electron/main/bundle-path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { extname, join, normalize, sep } from 'node:path'

/**
* Maps a bundle-scheme pathname to a file inside the exported renderer, or
* null when it escapes.
*
* Kept apart from index.ts so the tests can import it: index.ts registers a
* privileged scheme at module scope, so importing it outside Electron throws.
*/
export function resolveBundlePath(root: string, pathname: string): string | null {
let decoded: string
try {
decoded = decodeURIComponent(pathname)
} catch {
// A malformed escape is not a path we should guess at.
return null
}
// A NUL byte truncates the path in some syscalls; refuse rather than normalise.
if (decoded.includes('\0')) return null

const target = normalize(join(root, decoded === '/' ? 'index.html' : decoded))
// A crafted ../ must not turn the bundle scheme into a reader for the disk.
if (target !== root && !target.startsWith(root + sep)) return null
return target
}

/**
* Content types for what a Next export actually contains.
*
* The bundle is read with fs rather than `net.fetch('file://…')` because the
* packaged app is an asar archive: fs is asar-aware, so it reads straight out
* of it, and nothing here has to know whether the app is packaged. That means
* the response carries no type of its own, and a stylesheet served as
* octet-stream is a stylesheet the renderer ignores.
*/
const CONTENT_TYPES: Record<string, string> = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.otf': 'font/otf',
'.map': 'application/json; charset=utf-8',
}

export function contentTypeFor(path: string): string {
return CONTENT_TYPES[extname(path).toLowerCase()] ?? 'application/octet-stream'
}
46 changes: 42 additions & 4 deletions apps/desktop/electron/main/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { join } from 'node:path'
import { readFile } from 'node:fs/promises'
import { join, normalize } from 'node:path'
import { fileURLToPath } from 'node:url'
import { app, BrowserWindow, shell } from 'electron'
import { app, BrowserWindow, protocol, shell } from 'electron'
import { contentTypeFor, resolveBundlePath } from './bundle-path.js'
import { registerIpc } from './ipc.js'
import { checkForUpdates } from './services/updater.js'
import { closeAllSessions } from './services/sessions.js'
Expand All @@ -19,6 +21,41 @@ const here = join(fileURLToPath(import.meta.url), '..')
const isDev = !app.isPackaged && process.env.DISKPUSH_DEV === '1'
const DEV_URL = 'http://localhost:3210'

/**
* The exported renderer is served over a real scheme rather than loaded from
* file://.
*
* Next emits root-absolute asset URLs (`/_next/static/...`) and refuses to emit
* relative ones — `next/font` rejects an assetPrefix without a leading slash.
* Under file:// those resolve against the filesystem root, so every stylesheet
* and chunk 404s: the window shows unstyled prerendered HTML that never
* hydrates. A standard scheme gives the bundle an origin, so the same absolute
* paths resolve inside it, and CSP `'self'` means the bundle instead of the
* whole disk.
*/
const APP_SCHEME = 'diskpush-app'
const APP_ORIGIN = `${APP_SCHEME}://bundle`

// Must run before the app is ready, hence module scope rather than whenReady.
protocol.registerSchemesAsPrivileged([
{ scheme: APP_SCHEME, privileges: { standard: true, secure: true, supportFetchAPI: true } },
])

/** Serves the exported renderer, and nothing outside it. */
function serveBundle(): void {
const root = normalize(join(here, '..', '..', 'out'))
protocol.handle(APP_SCHEME, async (request) => {
const target = resolveBundlePath(root, new URL(request.url).pathname)
if (!target) return new Response('Forbidden', { status: 403 })
try {
const body = await readFile(target)
return new Response(body, { headers: { 'content-type': contentTypeFor(target) } })
} catch {
return new Response('Not found', { status: 404 })
}
})
}

function createWindow(): BrowserWindow {
const window = new BrowserWindow({
width: 1360,
Expand All @@ -43,7 +80,7 @@ function createWindow(): BrowserWindow {
// Navigation is pinned to the app itself. A renderer compromise should not
// be able to point the window at somewhere else.
window.webContents.on('will-navigate', (event, url) => {
const allowed = isDev ? url.startsWith(DEV_URL) : url.startsWith('file://')
const allowed = isDev ? url.startsWith(DEV_URL) : url.startsWith(APP_ORIGIN)
if (!allowed) event.preventDefault()
})

Expand Down Expand Up @@ -78,13 +115,14 @@ function createWindow(): BrowserWindow {
})

if (isDev) void window.loadURL(DEV_URL)
else void window.loadFile(join(here, '..', '..', 'out', 'index.html'))
else void window.loadURL(`${APP_ORIGIN}/index.html`)

return window
}

app.whenReady().then(() => {
registerIpc()
if (!isDev) serveBundle()
createWindow()

// Not awaited: a slow or unreachable GitHub must not delay the window.
Expand Down
Loading
Loading