Skip to content

Commit de1e7f3

Browse files
committed
Logo to 15rem, sandbox-aware desktop launcher, and update-on-start
**Logo** is 15rem (240px) wide, 80px tall at its 3:1 ratio. Measured in a browser: exact at 1440, and at 390 it shrinks to 232px rather than overflowing, with the download button still beside it. **Sandbox.** Reported from a real machine: the AppImage aborts with FATAL:setuid_sandbox_host.cc(163) The SUID sandbox helper binary was found, but is not configured correctly. Chromium wants one of two sandboxes. The namespace sandbox needs unprivileged user namespaces, which recent kernels restrict; the fallback SUID helper needs chrome-sandbox owned by root with mode 4755, which an install without root cannot set. The .deb was already covered by its postinst — but so was nothing else, and the installer's no-root path had exactly the same hole. The installer now writes a launcher that decides at run time instead of the installer guessing: SUID helper if configured, else the namespace sandbox if the kernel allows it, else --no-sandbox with a message saying why and pointing at the .deb. The sandbox is only dropped where neither option actually exists. **Update on start**, for both surfaces, shelling out to the same installer rather than embedding electron-updater — the installer already understands every layout DiskPush can be installed in, and electron-updater only handles AppImage on Linux. The CLI checks before running the command and re-execs afterwards, so the command runs on the version just installed rather than the one already loaded into memory. It is rate-limited to once every six hours, because a CLI that makes a network request per invocation hangs whenever GitHub is slow, and it is skipped entirely for --json output, for the self-management commands, and under DISKPUSH_NO_AUTO_UPDATE. The desktop applies the update on launch but defers the restart while a transfer is running. Restarting a file-transfer app mid-transfer to pick up a version bump would abandon the transfer. Version comparison is numeric, with a test: as strings, "0.10.0" < "0.9.0". 209 tests pass.
1 parent 0749395 commit de1e7f3

7 files changed

Lines changed: 323 additions & 2 deletions

File tree

‎apps/cli/src/bin.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { EXIT } from './exit-codes.js'
1212
import { HELP, VERSION } from './help.js'
1313
import { Output } from './output.js'
1414
import { ArgvError, hasFlag, looksLikeEndpoint, parseArgv } from './parse-argv.js'
15+
import { autoUpdate, reexec } from './self-update.js'
1516
import { existsSync } from 'node:fs'
1617

1718
async function main(argv: readonly string[]): Promise<number> {
@@ -58,6 +59,12 @@ async function main(argv: readonly string[]): Promise<number> {
5859
return EXIT.ok
5960
}
6061

62+
// Check for a newer release before running the command, so it runs on the
63+
// version just installed. Rate-limited, silent when offline, and skipped
64+
// entirely for --json, for the self-management commands, and when
65+
// DISKPUSH_NO_AUTO_UPDATE is set.
66+
if (await autoUpdate(command, output) === 'updated') reexec()
67+
6168
const store = await DiskPushStore.open()
6269
try {
6370
if (command in TRANSFER_ALIASES) return await runTransfer(command, parsed, store, output)

‎apps/cli/src/self-update.test.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { isNewer } from './self-update.js'
3+
4+
describe('isNewer', () => {
5+
it('compares numerically, not lexically', () => {
6+
// The bug this exists to prevent: "0.10.0" < "0.9.0" as strings.
7+
expect(isNewer('0.10.0', '0.9.0')).toBe(true)
8+
expect(isNewer('0.9.0', '0.10.0')).toBe(false)
9+
})
10+
11+
it('is false for the same version', () => {
12+
expect(isNewer('1.2.3', '1.2.3')).toBe(false)
13+
})
14+
15+
it('handles a shorter version string', () => {
16+
expect(isNewer('1.3', '1.2.9')).toBe(true)
17+
expect(isNewer('1.2', '1.2.1')).toBe(false)
18+
})
19+
20+
it('does not treat an older release as newer', () => {
21+
expect(isNewer('0.1.0', '0.2.0')).toBe(false)
22+
})
23+
})

‎apps/cli/src/self-update.ts‎

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { spawnSync } from 'node:child_process'
2+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
3+
import { dirname, join } from 'node:path'
4+
import { VERSION } from './help.js'
5+
import type { Output } from './output.js'
6+
import { readManifest, stateDirectory } from './commands/self.js'
7+
8+
/**
9+
* Startup update check.
10+
*
11+
* Rate-limited rather than run on every invocation: a CLI that makes a network
12+
* request before each command is a CLI that hangs whenever GitHub is slow, and
13+
* `diskpush` is the kind of thing people put in a loop.
14+
*/
15+
const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000
16+
const RELEASES_API = 'https://api.github.com/repos/profullstack/diskpush/releases/latest'
17+
18+
function stampPath(): string {
19+
return join(stateDirectory(), 'last-update-check')
20+
}
21+
22+
function readStamp(): number {
23+
try {
24+
return Number(readFileSync(stampPath(), 'utf8').trim()) || 0
25+
} catch {
26+
return 0
27+
}
28+
}
29+
30+
function writeStamp(): void {
31+
try {
32+
mkdirSync(dirname(stampPath()), { recursive: true })
33+
writeFileSync(stampPath(), String(Date.now()))
34+
} catch {
35+
// A cache we cannot write is a check that runs more often, not an error.
36+
}
37+
}
38+
39+
/** Compares dotted versions numerically, so 0.10.0 is newer than 0.9.0. */
40+
export function isNewer(candidate: string, current: string): boolean {
41+
const a = candidate.split('.').map(Number)
42+
const b = current.split('.').map(Number)
43+
for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
44+
const left = a[i] ?? 0
45+
const right = b[i] ?? 0
46+
if (left !== right) return left > right
47+
}
48+
return false
49+
}
50+
51+
async function latestVersion(timeoutMs = 4000): Promise<string | null> {
52+
try {
53+
const response = await fetch(RELEASES_API, {
54+
headers: { accept: 'application/vnd.github+json', 'user-agent': `diskpush/${VERSION}` },
55+
signal: AbortSignal.timeout(timeoutMs),
56+
})
57+
if (!response.ok) return null
58+
const release = (await response.json()) as { tag_name?: string; draft?: boolean }
59+
if (!release.tag_name || release.draft) return null
60+
return release.tag_name.replace(/^v/, '')
61+
} catch {
62+
// Offline, rate-limited, slow: none of these should stop the command the
63+
// user actually typed.
64+
return null
65+
}
66+
}
67+
68+
export type AutoUpdateOutcome = 'skipped' | 'current' | 'updated' | 'failed'
69+
70+
/**
71+
* Checks for a newer release and installs it before the requested command
72+
* runs, so the command executes on the version it just installed.
73+
*
74+
* Deliberately does nothing when: the copy was not installed by the installer,
75+
* output is machine-readable, the command is one where a surprise upgrade
76+
* would be wrong, or DISKPUSH_NO_AUTO_UPDATE is set.
77+
*/
78+
export async function autoUpdate(command: string | null, output: Output, force = false): Promise<AutoUpdateOutcome> {
79+
if (process.env.DISKPUSH_NO_AUTO_UPDATE) return 'skipped'
80+
// --json output is parsed by scripts; a progress line in it is a bug.
81+
if (output.isJson) return 'skipped'
82+
// Updating inside these would be circular, surprising, or both.
83+
if (command && ['update', 'upgrade', 'uninstall', 'remove', 'version', 'help', 'doctor'].includes(command)) {
84+
return 'skipped'
85+
}
86+
87+
const manifest = readManifest()
88+
if (!manifest || manifest.method === 'source') return 'skipped'
89+
90+
if (!force && Date.now() - readStamp() < CHECK_INTERVAL_MS) return 'skipped'
91+
writeStamp()
92+
93+
const latest = await latestVersion()
94+
if (!latest) return 'skipped'
95+
if (!isNewer(latest, VERSION)) return 'current'
96+
97+
output.warn(`DiskPush ${latest} is available (you have ${VERSION}). Updating...`)
98+
99+
const script = spawnSync('sh', ['-c', `curl -fsSL ${manifest.installer} || wget -qO- ${manifest.installer}`], {
100+
encoding: 'utf8',
101+
maxBuffer: 16 * 1024 * 1024,
102+
})
103+
if (script.status !== 0 || !script.stdout) {
104+
output.warn('Update check failed to download the installer; continuing on the current version.')
105+
return 'failed'
106+
}
107+
108+
const run = spawnSync('sh', ['-s', '--', ...(manifest.desktop ? [] : ['--cli-only'])], {
109+
input: script.stdout,
110+
stdio: ['pipe', 'ignore', 'inherit'],
111+
})
112+
if (run.status !== 0) {
113+
output.warn('Update failed; continuing on the current version.')
114+
return 'failed'
115+
}
116+
117+
output.warn(`Updated to ${latest}.`)
118+
return 'updated'
119+
}
120+
121+
/**
122+
* Re-runs the original command on the freshly installed version.
123+
*
124+
* Without this the process would carry on executing the code it loaded at
125+
* startup, which is the old version — so the update would appear to have had
126+
* no effect until the next invocation.
127+
*/
128+
export function reexec(): never {
129+
const manifest = readManifest()
130+
const binary = manifest ? join(dirname(stateDirectory()), '..', 'bin', 'diskpush') : null
131+
const target = binary && existsSync(binary) ? binary : process.argv[1]
132+
133+
const result = spawnSync(target!, process.argv.slice(2), {
134+
stdio: 'inherit',
135+
env: { ...process.env, DISKPUSH_NO_AUTO_UPDATE: '1' },
136+
})
137+
process.exit(result.status ?? 0)
138+
}

‎apps/desktop/electron/main/index.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { join } from 'node:path'
22
import { app, BrowserWindow, shell } from 'electron'
33
import { registerIpc } from './ipc'
4+
import { checkForUpdates } from './services/updater'
45
import { closeAllSessions } from './services/sessions'
5-
import { cancelAll } from './services/transfers'
6+
import { cancelAll, hasActiveTransfer } from './services/transfers'
67

78
const isDev = !app.isPackaged && process.env.DISKPUSH_DEV === '1'
89
const DEV_URL = 'http://localhost:3210'
@@ -75,6 +76,9 @@ app.whenReady().then(() => {
7576
registerIpc()
7677
createWindow()
7778

79+
// Not awaited: a slow or unreachable GitHub must not delay the window.
80+
void checkForUpdates(hasActiveTransfer)
81+
7882
app.on('activate', () => {
7983
if (BrowserWindow.getAllWindows().length === 0) createWindow()
8084
})

‎apps/desktop/electron/main/services/transfers.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,11 @@ export function cancelTransfer(jobId: string): boolean {
237237
return true
238238
}
239239

240+
/** True while any transfer is in flight; the updater defers a restart on it. */
241+
export function hasActiveTransfer(): boolean {
242+
return running.size > 0
243+
}
244+
240245
export function cancelAll(): void {
241246
for (const job of running.values()) job.cancel()
242247
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { spawn } from 'node:child_process'
2+
import { existsSync, readFileSync } from 'node:fs'
3+
import { homedir } from 'node:os'
4+
import { join } from 'node:path'
5+
import { app, BrowserWindow, dialog } from 'electron'
6+
7+
/**
8+
* Startup update check.
9+
*
10+
* It shells out to the same installer the CLI uses rather than embedding
11+
* electron-updater: the installer already knows every way DiskPush can be
12+
* installed, and electron-updater only handles AppImage on Linux, which is not
13+
* the layout the installer produces.
14+
*/
15+
16+
const RELEASES_API = 'https://api.github.com/repos/profullstack/diskpush/releases/latest'
17+
18+
type Manifest = { version: string; method: string; installer: string; desktop: boolean }
19+
20+
function manifestPath(): string {
21+
const dataHome = process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share')
22+
return join(process.env.DISKPUSH_HOME ? join(process.env.DISKPUSH_HOME, 'install') : join(dataHome, 'diskpush'), 'manifest.json')
23+
}
24+
25+
function readManifest(): Manifest | null {
26+
const path = manifestPath()
27+
if (!existsSync(path)) return null
28+
try {
29+
return JSON.parse(readFileSync(path, 'utf8')) as Manifest
30+
} catch {
31+
return null
32+
}
33+
}
34+
35+
/** Numeric comparison, so 0.10.0 is newer than 0.9.0. */
36+
export function isNewer(candidate: string, current: string): boolean {
37+
const a = candidate.split('.').map(Number)
38+
const b = current.split('.').map(Number)
39+
for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
40+
const left = a[i] ?? 0
41+
const right = b[i] ?? 0
42+
if (left !== right) return left > right
43+
}
44+
return false
45+
}
46+
47+
async function latestVersion(): Promise<string | null> {
48+
try {
49+
const response = await fetch(RELEASES_API, {
50+
headers: { accept: 'application/vnd.github+json', 'user-agent': `diskpush/${app.getVersion()}` },
51+
signal: AbortSignal.timeout(6000),
52+
})
53+
if (!response.ok) return null
54+
const release = (await response.json()) as { tag_name?: string; draft?: boolean }
55+
if (!release.tag_name || release.draft) return null
56+
return release.tag_name.replace(/^v/, '')
57+
} catch {
58+
return null
59+
}
60+
}
61+
62+
function runInstaller(installer: string): Promise<boolean> {
63+
return new Promise((resolve) => {
64+
const child = spawn('sh', ['-c', `curl -fsSL ${installer} | sh`], { stdio: 'ignore', detached: false })
65+
child.on('error', () => resolve(false))
66+
child.on('close', (code) => resolve(code === 0))
67+
})
68+
}
69+
70+
/**
71+
* @param hasActiveTransfer tells the updater whether restarting right now
72+
* would interrupt work. It applies the update either way, but a file
73+
* transfer is exactly the thing not to kill for a version bump.
74+
*/
75+
export async function checkForUpdates(hasActiveTransfer: () => boolean): Promise<void> {
76+
if (process.env.DISKPUSH_NO_AUTO_UPDATE) return
77+
78+
const manifest = readManifest()
79+
// Installed some other way (a .deb, a distro package, a source checkout):
80+
// whatever installed it is what should update it.
81+
if (!manifest || manifest.method === 'source') return
82+
83+
const latest = await latestVersion()
84+
if (!latest || !isNewer(latest, app.getVersion())) return
85+
86+
const installed = await runInstaller(manifest.installer)
87+
if (!installed) return
88+
89+
// The new version is on disk; only the running process is stale. Restarting
90+
// mid-transfer would abandon it, so that waits.
91+
if (hasActiveTransfer()) return
92+
93+
const window = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0]
94+
const options = {
95+
type: 'info' as const,
96+
buttons: ['Later', 'Restart now'],
97+
defaultId: 1,
98+
cancelId: 0,
99+
title: 'Update installed',
100+
message: `DiskPush ${latest} has been installed.`,
101+
detail: 'Restart to use it. Your connections, profiles and queued jobs are unaffected.',
102+
}
103+
104+
const result = window ? await dialog.showMessageBox(window, options) : await dialog.showMessageBox(options)
105+
if (result.response === 1) {
106+
app.relaunch()
107+
app.exit(0)
108+
}
109+
}

‎scripts/install.sh‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,41 @@ if [ "$WANT_DESKTOP" = yes ]; then
116116
tar -xzf "$WORK/app.tar.gz" -C "$SHARE/app" --strip-components=1
117117
METHOD=linux-app
118118

119+
# Chromium wants one of two sandboxes. The namespace sandbox needs
120+
# unprivileged user namespaces; the fallback SUID helper needs
121+
# chrome-sandbox owned by root with mode 4755, which an install without
122+
# root cannot set. Where a kernel denies the first and we cannot provide
123+
# the second, Electron aborts on launch:
124+
#
125+
# FATAL:setuid_sandbox_host.cc(163) The SUID sandbox helper binary was
126+
# found, but is not configured correctly.
127+
#
128+
# So the launcher decides at run time rather than the installer guessing,
129+
# and only drops the sandbox when neither option is actually available.
130+
cat > "$SHARE/app/launch.sh" <<'LAUNCH'
131+
#!/bin/sh
132+
set -eu
133+
here="$(cd "$(dirname "$0")" && pwd)"
134+
app="$here/diskpush-desktop"
135+
136+
# Is the SUID helper correctly configured? (A .deb install sets this up.)
137+
if [ -u "$here/chrome-sandbox" ]; then
138+
exec "$app" "$@"
139+
fi
140+
141+
# Can Chromium use the namespace sandbox instead?
142+
if unshare --user true >/dev/null 2>&1; then
143+
exec "$app" "$@"
144+
fi
145+
146+
echo "diskpush: this kernel denies unprivileged user namespaces, and the SUID" >&2
147+
echo " sandbox helper needs root to configure, which this install does" >&2
148+
echo " not have. Starting without Chromium's sandbox." >&2
149+
echo " For a sandboxed install, use the .deb: https://diskpush.com/download" >&2
150+
exec "$app" --no-sandbox "$@"
151+
LAUNCH
152+
chmod 0755 "$SHARE/app/launch.sh"
153+
119154
# A launcher, an icon, and a menu entry. This is what a .deb would give
120155
# you; done by hand because doing it by hand needs no root.
121156
mkdir -p "$PREFIX/share/applications" "$PREFIX/share/icons/hicolor/512x512/apps"
@@ -128,7 +163,7 @@ Type=Application
128163
Name=DiskPush
129164
GenericName=File Transfer
130165
Comment=Push files fast. Sync only what changed.
131-
Exec=$SHARE/app/diskpush-desktop %U
166+
Exec=$SHARE/app/launch.sh %U
132167
Icon=diskpush
133168
Terminal=false
134169
Categories=Utility;FileTransfer;Network;

0 commit comments

Comments
 (0)