Skip to content
Open
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
106 changes: 106 additions & 0 deletions e2e/startup.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import type { Locator } from '@playwright/test'
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { expect, test, type PlexoApp } from './fixtures'

// Issue #258: Start Plexo minimized in system tray and launch at startup.
// Settings under General: "Launch on startup" and "Start minimized".

async function openLimitsDialog(plexo: PlexoApp): Promise<Locator> {
await plexo.page.getByRole('button', { name: /^\d+ networks?/ }).click()
await plexo.page.getByRole('button', { name: 'Speed & data limits', exact: true }).click()
return plexo.page.getByRole('dialog', { name: 'Speed & data limits' })
}

test.describe('startup and minimized settings @smoke', () => {
test('start minimized and launch on startup settings persist across app relaunches', async ({
plexo,
dirs
}) => {
const initial = await plexo.page.evaluate(() => window.plexo.initialState)
expect(initial.startMinimized ?? false).toBe(false)

await plexo.api.updateSettings({ openAtLogin: true, startMinimized: true })

const settingsFile = join(dirs.userData, 'app-settings.json')
const saved = JSON.parse(await readFile(settingsFile, 'utf8'))
expect(saved.openAtLogin).toBe(true)
expect(saved.startMinimized).toBe(true)

await plexo.relaunch()

const relaunched = await plexo.page.evaluate(() => window.plexo.initialState)
expect(relaunched.openAtLogin).toBe(true)
expect(relaunched.startMinimized).toBe(true)

await plexo.api.updateSettings({ openAtLogin: false, startMinimized: false })
await plexo.relaunch()

const restored = await plexo.page.evaluate(() => window.plexo.initialState)
expect(restored.openAtLogin).toBe(false)
expect(restored.startMinimized).toBe(false)
})

test('toggling startup and start minimized in settings UI saves and persists', async ({
plexo,
dirs
}) => {
const dialog = await openLimitsDialog(plexo)
const launchSwitch = dialog.getByRole('switch', { name: 'Launch on startup', exact: true })
const minimizedSwitch = dialog.getByRole('switch', { name: 'Start minimized', exact: true })

await expect(launchSwitch).not.toBeChecked()
await expect(minimizedSwitch).not.toBeChecked()

await launchSwitch.check()
await minimizedSwitch.check()

await expect(launchSwitch).toBeChecked()
await expect(minimizedSwitch).toBeChecked()

await dialog.getByRole('button', { name: 'Save', exact: true }).click()
await expect(dialog).toBeHidden()

const settingsFile = join(dirs.userData, 'app-settings.json')
const saved = JSON.parse(await readFile(settingsFile, 'utf8'))
expect(saved.openAtLogin).toBe(true)
expect(saved.startMinimized).toBe(true)

// Reopen dialog to verify UI reflects saved settings
const reopened = await openLimitsDialog(plexo)
await expect(
reopened.getByRole('switch', { name: 'Launch on startup', exact: true })
).toBeChecked()
await expect(
reopened.getByRole('switch', { name: 'Start minimized', exact: true })
).toBeChecked()

// Test "Reset to defaults" resets both toggles
await reopened.getByRole('button', { name: 'Reset to defaults', exact: true }).click()
const confirmDialog = plexo.page.getByRole('alertdialog', {
name: 'Reset all downloads to defaults?'
})
await confirmDialog.getByRole('button', { name: 'Reset to defaults', exact: true }).click()

await expect(
reopened.getByRole('switch', { name: 'Launch on startup', exact: true })
).not.toBeChecked()
await expect(
reopened.getByRole('switch', { name: 'Start minimized', exact: true })
).not.toBeChecked()

await reopened.getByRole('button', { name: 'Save', exact: true }).click()
await expect(reopened).toBeHidden()

const resetSaved = JSON.parse(await readFile(settingsFile, 'utf8'))
expect(resetSaved.openAtLogin).toBe(false)
expect(resetSaved.startMinimized).toBe(false)
})

test('launching with --minimized CLI argument starts cleanly', async ({ plexo }) => {
await plexo.quit()
await plexo.launch({}, ['--minimized'])
expect(plexo.alive).toBe(true)
expect(plexo.page).toBeDefined()
})
})
93 changes: 93 additions & 0 deletions src/main/autostart.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { app } from 'electron'
import { existsSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { mkdir, rm, writeFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { testKnobs } from './testKnobs'

export interface AutostartOptions {
openAtLogin: boolean
startMinimized?: boolean
}

function autostartDesktopPath(): string {
if (testKnobs.userDataDir) {
return join(testKnobs.userDataDir, 'autostart', 'plexo.desktop')
}
return join(homedir(), '.config', 'autostart', 'plexo.desktop')
}

/**
* Configures the application to launch at system startup on Windows, macOS, and Linux.
* - Windows: Uses Electron's built-in setLoginItemSettings (Windows Registry: HKCU\...\Run).
* - macOS: Uses Electron's built-in setLoginItemSettings (macOS Login Items / ServiceManagement).
* - Linux: Follows the XDG Autostart specification by managing ~/.config/autostart/plexo.desktop.
*/
export async function applyAutostart(options: AutostartOptions): Promise<void> {
const { openAtLogin, startMinimized } = options

if (process.platform === 'win32') {
try {
app.setLoginItemSettings({
openAtLogin,
args: startMinimized ? ['--minimized'] : []
})
} catch (error) {
console.error('[plexo] failed to set Windows login item settings', error)
}
return
}

if (process.platform === 'darwin') {
try {
app.setLoginItemSettings({
openAtLogin
})
} catch (error) {
console.error('[plexo] failed to set macOS login item settings', error)
}
return
}

if (process.platform === 'linux') {
const desktopPath = autostartDesktopPath()
try {
if (openAtLogin) {
await mkdir(dirname(desktopPath), { recursive: true })
const executable = process.env.APPIMAGE || process.execPath
const execArgs = startMinimized ? ' --minimized' : ''
const content =
[
'[Desktop Entry]',
'Type=Application',
'Version=1.0',
'Name=Plexo',
'Comment=Plexo Download Manager',
`Exec=${executable}${execArgs}`,
'StartupNotify=false',
'Terminal=false'
].join('\n') + '\n'
await writeFile(desktopPath, content, 'utf8')
} else {
await rm(desktopPath, { force: true }).catch(() => {})
}
} catch (error) {
console.error('[plexo] failed to configure Linux autostart desktop entry', error)
}
}
}

/** Queries whether the application is currently registered to launch at startup. */
export function isAutostartRegistered(): boolean {
if (process.platform === 'win32' || process.platform === 'darwin') {
try {
return app.getLoginItemSettings().openAtLogin
} catch {
return false
}
}
if (process.platform === 'linux') {
return existsSync(autostartDesktopPath())
}
return false
}
25 changes: 22 additions & 3 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { acceptedLink, bringForward, linkFromArgs, offerLink } from './openLinks
import { loadSettings, loadThemeSource, migrateLegacyNetworkPreferences } from './settings'
import { startUpdater } from './updater'
import { testKnobs } from './testKnobs'
import type { AppSettings } from '../shared/types'
import type { DownloadManager } from './download/downloadManager'

// In dev mode the app runs as the raw `electron` binary, which otherwise shows "Electron" in
Expand Down Expand Up @@ -104,7 +105,19 @@ function titleBarOverlay(): Electron.TitleBarOverlayOptions {
}
}

function createWindow(): void {
function shouldStartMinimized(settings: AppSettings): boolean {
const argv = process.argv
if (
argv.includes('--minimized') ||
argv.includes('--start-minimized') ||
argv.includes('--hidden')
) {
return true
}
return Boolean(settings.startMinimized)
}

function createWindow(startMinimized = false): void {
mainWindow = new BrowserWindow({
width: 760,
height: 640,
Expand Down Expand Up @@ -134,6 +147,10 @@ function createWindow(): void {
})

mainWindow.on('ready-to-show', () => {
if (startMinimized) {
ensureTray()
return
}
if (!testKnobs.hideWindow) mainWindow?.show()
})

Expand Down Expand Up @@ -237,9 +254,11 @@ app.whenReady().then(async () => {
if (process.platform !== 'darwin') mainWindow?.setTitleBarOverlay(titleBarOverlay())
})

createWindow()
const settings = await loadSettings()
const startMinimized = shouldStartMinimized(settings)
createWindow(startMinimized)
if (testKnobs.hideWindow) app.dock?.hide()
startUpdater(() => mainWindow, (await loadSettings()).autoUpdate !== false)
startUpdater(() => mainWindow, settings.autoUpdate !== false)

app.on('activate', function () {
if (mainWindow && !mainWindow.isDestroyed()) {
Expand Down
14 changes: 12 additions & 2 deletions src/main/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { deviceBindingSupported } from '../network/deviceBinding'
import { pendingLink, pendingLinks, releaseLink } from '../openLinks'
import { NetworkMonitor } from '../network/interfaces'
import { loadSettings, saveSettings } from '../settings'
import { applyAutostart, isAutostartRegistered } from '../autostart'
import { showUpdateMenu, updateState } from '../updater'

async function openNetworkSettings(): Promise<void> {
Expand Down Expand Up @@ -126,7 +127,14 @@ export function registerIpcHandlers(getWindow: () => BrowserWindow | null): Down
}
await saveSettings(patch)
// Read back rather than taken from the patch: what was saved is what passed the checks.
await manager.applySettings(await loadSettings())
const applied = await loadSettings()
if (patch?.openAtLogin !== undefined || patch?.startMinimized !== undefined) {
await applyAutostart({
openAtLogin: Boolean(applied.openAtLogin),
startMinimized: Boolean(applied.startMinimized)
})
}
await manager.applySettings(applied)
})

// Answered via sendSync from the preload, which blocks the page until returnValue is set — so a
Expand Down Expand Up @@ -157,7 +165,9 @@ export function registerIpcHandlers(getWindow: () => BrowserWindow | null): Down
slowMode: settings.slowMode ?? false,
slowModeSpeed: settings.slowModeSpeed ?? DEFAULT_SLOW_MODE_SPEED,
speedUnit: settings.speedUnit ?? 'bytes',
destinationDir: destinationExists ? destinationDir : undefined
destinationDir: destinationExists ? destinationDir : undefined,
openAtLogin: settings.openAtLogin ?? isAutostartRegistered(),
startMinimized: settings.startMinimized ?? false
} satisfies InitialState
} catch (error) {
console.error('[plexo] failed to read initial state', error)
Expand Down
2 changes: 2 additions & 0 deletions src/main/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ function sanitizeSettings(parsed: unknown): AppSettings {
settings.speedUnit = parsed.speedUnit
}
if (parsed.browserExtensionUsed === true) settings.browserExtensionUsed = true
if (typeof parsed.openAtLogin === 'boolean') settings.openAtLogin = parsed.openAtLogin
if (typeof parsed.startMinimized === 'boolean') settings.startMinimized = parsed.startMinimized
return settings
}

Expand Down
Loading
Loading