diff --git a/electron-builder.json b/electron-builder.json index 9e78e66..f8bc8eb 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -117,16 +117,17 @@ }, "dmg": { "artifactName": "RokDock-${version}-mac-${arch}.dmg", + "background": "resources/dmg-background.png", "window": { "x": 130, "y": 220, - "width": 560, - "height": 420 + "width": 540, + "height": 400 }, "contents": [ - { "x": 140, "y": 140, "type": "file" }, - { "x": 420, "y": 140, "type": "link", "path": "/Applications" }, - { "x": 280, "y": 320, "type": "dir", "path": "build/RokDock Tools", "name": "RokDock Tools" } + { "x": 135, "y": 125, "type": "file" }, + { "x": 405, "y": 195, "type": "link", "path": "/Applications" }, + { "x": 135, "y": 265, "type": "dir", "path": "build/RokDock Tools", "name": "RokDock Tools" } ] }, "afterPack": "build/afterPack.cjs", diff --git a/resources/dmg-background.png b/resources/dmg-background.png new file mode 100644 index 0000000..3ad0df0 Binary files /dev/null and b/resources/dmg-background.png differ diff --git a/resources/dmg-background@2x.png b/resources/dmg-background@2x.png new file mode 100644 index 0000000..31fcdd8 Binary files /dev/null and b/resources/dmg-background@2x.png differ diff --git a/resources/dmgBackground.svg b/resources/dmgBackground.svg new file mode 100644 index 0000000..14c72a3 --- /dev/null +++ b/resources/dmgBackground.svg @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/scripts/launcherTemplates.cjs b/scripts/launcherTemplates.cjs index a2a9622..2d36ef3 100644 --- a/scripts/launcherTemplates.cjs +++ b/scripts/launcherTemplates.cjs @@ -2,6 +2,13 @@ * Pure string generators for the per-tool launcher artifacts. No I/O. The build * scripts require() this. launcherTemplates.test.ts unit-tests it. Each function * takes manifest entries shaped as { key, title, badge }. + * + * The tool is passed as the attached `--tool=` form, never `--tool `. + * When RokDock is already running, a launcher starts a second instance whose argv + * Electron forwards to the primary's second-instance handler after reordering it: + * bare positional args are moved to the end and Electron's own switches are spliced + * in, so a space-separated tool value no longer sits next to `--tool`. The attached + * `--tool=` is a single token that survives that reshuffle intact. */ /** The NSIS include written to build/tool-shortcuts.nsh and referenced by nsis.include. */ @@ -9,7 +16,7 @@ function nsisInclude(launchers) { const install = launchers .map( l => - ` CreateShortcut "$SMPROGRAMS\\RokDock\\${l.title}.lnk" "$INSTDIR\\RokDock.exe" "--tool ${l.key}" "$INSTDIR\\resources\\icons\\tools\\${l.key}.ico"` + ` CreateShortcut "$SMPROGRAMS\\RokDock\\${l.title}.lnk" "$INSTDIR\\RokDock.exe" "--tool=${l.key}" "$INSTDIR\\resources\\icons\\tools\\${l.key}.ico"` ) .join('\n') const uninstall = launchers @@ -35,7 +42,7 @@ function desktopEntry(launcher, execName) { return [ '[Desktop Entry]', `Name=RokDock ${launcher.title}`, - `Exec=${execName} --tool ${launcher.key} %U`, + `Exec=${execName} --tool=${launcher.key} %U`, `Icon=rokdock-${launcher.key}`, 'Type=Application', 'Terminal=false', @@ -75,7 +82,7 @@ function macLaunchStub(launcher) { '#!/bin/sh', "APP=$(mdfind \"kMDItemCFBundleIdentifier == 'com.rokdock.app'\" | head -n 1)", '[ -z "$APP" ] && APP="/Applications/RokDock.app"', - `exec "$APP/Contents/MacOS/RokDock" --tool ${launcher.key}`, + `exec "$APP/Contents/MacOS/RokDock" --tool=${launcher.key}`, '', ].join('\n') } diff --git a/src/main/ipc/toolWindow.ts b/src/main/ipc/toolWindow.ts index a5138d7..9e101ab 100644 --- a/src/main/ipc/toolWindow.ts +++ b/src/main/ipc/toolWindow.ts @@ -38,6 +38,19 @@ export function getScopedToolWindow(toolKey: string, scope: ToolWindowScope): Br return win && !win.isDestroyed() ? win : null } +/** + * Every live tool window registered in a given scope. The dock uses the + * 'standalone' set to know which windows are independent of its own session + * (CLI / file-association launches) and must survive the dock closing. + */ +export function getToolWindowsInScope(scope: ToolWindowScope): BrowserWindow[] { + const windows: BrowserWindow[] = [] + for (const [key, win] of scopedToolWindows) { + if (key.endsWith(`:${scope}`) && !win.isDestroyed()) windows.push(win) + } + return windows +} + /** Record a window for a tool+scope and drop the entry (identity-guarded) when it closes. */ export function setScopedToolWindow(toolKey: string, scope: ToolWindowScope, win: BrowserWindow): void { const key = scopeKey(toolKey, scope) diff --git a/src/main/main.ts b/src/main/main.ts index 59d9a35..480aae3 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -61,7 +61,7 @@ import { TelnetSessionService } from './services/telnetSession' import { checkDevDependencies } from './utils/devDependencies' import { parseLaunchRequest, toolForFile } from './launch/launchRequest' import { openToolForLaunch } from './launch/openTool' -import { getScopedToolWindow } from './ipc/toolWindow' +import { getToolWindowsInScope } from './ipc/toolWindow' let mainWindow: BrowserWindow | null = null /** Fallback if renderer never calls showWindow() (e.g. IPC failure). Cleared when the window is destroyed. Keep short now that show-window resolves the real BrowserWindow. */ @@ -192,11 +192,15 @@ function createWindow(): void { // Persist restored bounds so normal-window launch size remains stable. const bounds = maximized ? mainWindow.getNormalBounds() : mainWindow.getBounds() storeService?.setWindowBounds(bounds) - // Close auxiliary windows (screenshot preview, JSON editor, etc.) so they don't outlive the main window. - // The standalone JSON editor is an independent persistent window. Spare it so its session survives. - const standaloneJson = getScopedToolWindow('json', 'standalone') + // Close the dock's own dependents (inDock tool windows plus device-gated aux + // windows like the screenshot preview and capture popout, which are only ever + // dock-opened) so they don't outlive the main window. Windows launched + // independently via --tool or a file association register in the 'standalone' + // scope and must survive the dock closing (issue #17: --tool windows are + // independent of the main RokDock window). + const independentWindows = new Set(getToolWindowsInScope('standalone')) for (const w of BrowserWindow.getAllWindows()) { - if (w !== mainWindow && w !== standaloneJson && !w.isDestroyed()) w.close() + if (w !== mainWindow && !independentWindows.has(w) && !w.isDestroyed()) w.close() } } }) diff --git a/tests/e2e/dockCloseIndependence.spec.ts b/tests/e2e/dockCloseIndependence.spec.ts new file mode 100644 index 0000000..ffa145d --- /dev/null +++ b/tests/e2e/dockCloseIndependence.spec.ts @@ -0,0 +1,84 @@ +/** + * E2E regression (issue #17 follow-up): closing the main RokDock dock must not + * take down tool windows launched independently via `--tool`. Those windows + * register in the 'standalone' scope and are independent of the dock session. + * + * The dock still closes its own dependents (tool windows opened from within the + * dock, in the 'inDock' scope). This spec asserts both halves so the cascade is + * scoped, not disabled: a standalone JSON window survives the dock closing while + * an inDock SVG window closes with it. + */ +/* eslint-disable @typescript-eslint/no-require-imports */ +import { test, expect } from '@playwright/test' +import type { ElectronApplication } from '@playwright/test' +import { spawn } from 'child_process' +import path from 'path' +import os from 'os' +import fs from 'fs' +import { launchRokDockWithArgsAndUserData } from './helpers' + +const electronExe: string = require( + path.join(__dirname, '..', '..', 'node_modules', 'electron') +) as string +const projectRoot = path.join(__dirname, '..', '..') + +async function windowTitles(app: ElectronApplication): Promise { + try { + return await app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows().map(w => w.getTitle())) + } catch { + // The main-process evaluate context can be transiently torn down while a + // window is opening or closing. Return empty so a poll retries. + return [] + } +} + +/** Spawns a second RokDock process that forwards its argv to the primary, then exits. */ +function spawnSecondInstance(userDataDir: string, extraArgs: string[]): void { + const env = Object.fromEntries( + Object.entries(process.env).filter((e): e is [string, string] => e[1] !== undefined) + ) + delete env.ELECTRON_RUN_AS_NODE + env.ROKDOCK_E2E = '1' + const child = spawn( + electronExe, + ['out/main/main.js', `--user-data-dir=${userDataDir}`, '--no-sandbox', '--disable-gpu', ...extraArgs], + { cwd: projectRoot, env, stdio: 'ignore' } + ) + child.unref() +} + +test('closing the dock spares standalone tool windows but closes inDock ones', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rokdock-dockclose-')) + const { app, mainWin } = await launchRokDockWithArgsAndUserData([], dir) + try { + expect(await windowTitles(app)).toEqual(['RokDock']) + + // Open a dependent (inDock) tool window from within the dock. + await mainWin.evaluate(() => window.rokdock.svgExporter.openEditor('dark')) + await expect.poll(() => windowTitles(app), { timeout: 15_000 }).toContain('SVG Converter') + + // Launch an independent (standalone) tool window via a forwarded --tool argv. + spawnSecondInstance(dir, ['--tool=json']) + await expect.poll(() => windowTitles(app), { timeout: 15_000 }).toContain('JSON Editor') + + // Close the dock through the main process so the BrowserWindow 'close' event + // fires, matching the real red-button / Cmd-W path. (Playwright's page.close() + // tears down the webContents without emitting 'close', so it would bypass the + // cascade handler entirely.) + await app.evaluate(({ BrowserWindow }) => { + const dock = BrowserWindow.getAllWindows().find(w => w.getTitle() === 'RokDock') + dock?.close() + }) + + // The standalone JSON window survives; the inDock SVG window and the dock are gone. + await expect.poll(() => windowTitles(app), { timeout: 15_000 }).toEqual(['JSON Editor']) + + // The app is still running because a window remains open (window-all-closed + // never fired). A negative control: had the cascade closed the standalone + // window too, the app would have quit and this evaluate would throw. + expect(await app.evaluate(({ app: electronApp }) => electronApp.isReady())).toBe(true) + } finally { + await app.close() + } +}) diff --git a/tests/e2e/secondInstanceToolLaunch.spec.ts b/tests/e2e/secondInstanceToolLaunch.spec.ts new file mode 100644 index 0000000..06c499b --- /dev/null +++ b/tests/e2e/secondInstanceToolLaunch.spec.ts @@ -0,0 +1,81 @@ +/** + * E2E regression: a second `--tool=` launch while RokDock is already running + * must open the tool window in the running instance (single-instance argv + * forwarding), not fall back to the dock. This reproduces the Windows per-tool + * Start Menu shortcut (`RokDock.exe --tool=json`) fired while the dock is open. + * + * The attached `--tool=` form is required: Electron reorders the argv it + * forwards to the second-instance handler, so a space-separated `--tool json` + * loses its value (covered at the unit level in launchRequest.test.ts). The + * launchers emit the attached form for exactly this reason. + */ +/* eslint-disable @typescript-eslint/no-require-imports */ +import { test, expect } from '@playwright/test' +import type { ElectronApplication } from '@playwright/test' +import { spawn } from 'child_process' +import path from 'path' +import os from 'os' +import fs from 'fs' +import { launchRokDockWithArgsAndUserData } from './helpers' + +const electronExe: string = require( + path.join(__dirname, '..', '..', 'node_modules', 'electron') +) as string +const projectRoot = path.join(__dirname, '..', '..') + +async function windowTitles(app: ElectronApplication): Promise { + try { + return await app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows().map(w => w.getTitle())) + } catch { + // The main-process evaluate context can be transiently torn down while a + // window is opening. Return empty so a poll retries rather than throwing. + return [] + } +} + +/** + * Launches a second RokDock process against the given userData dir, so it loses the + * single-instance lock and forwards its argv to the primary, then exits. Mirrors + * what a per-tool launcher does when RokDock is already running. + */ +function spawnSecondInstance(userDataDir: string, extraArgs: string[]): void { + const env = Object.fromEntries( + Object.entries(process.env).filter((e): e is [string, string] => e[1] !== undefined) + ) + delete env.ELECTRON_RUN_AS_NODE + env.ROKDOCK_E2E = '1' + const child = spawn( + electronExe, + ['out/main/main.js', `--user-data-dir=${userDataDir}`, '--no-sandbox', '--disable-gpu', ...extraArgs], + { cwd: projectRoot, env, stdio: 'ignore' } + ) + child.unref() +} + +test('a second --tool= launch opens the tool window in the running instance', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rokdock-2inst-')) + const { app } = await launchRokDockWithArgsAndUserData([], dir) + try { + expect(await windowTitles(app)).toEqual(['RokDock']) + spawnSecondInstance(dir, ['--tool=json']) + await expect.poll(() => windowTitles(app), { timeout: 15_000 }).toContain('JSON Editor') + } finally { + await app.close() + } +}) + +test('a bare second launch focuses the dock and opens no tool window (negative control)', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rokdock-2inst-bare-')) + const { app } = await launchRokDockWithArgsAndUserData([], dir) + try { + expect(await windowTitles(app)).toEqual(['RokDock']) + spawnSecondInstance(dir, []) + // Wait past when a tool window would appear (the positive case opens in ~1-2s), + // then confirm the bare launch added no window and only surfaced the dock. + await new Promise((resolve) => setTimeout(resolve, 4_000)) + expect(await windowTitles(app)).toEqual(['RokDock']) + } finally { + await app.close() + } +}) diff --git a/tests/main/ipc/toolWindow.test.ts b/tests/main/ipc/toolWindow.test.ts index d71cfd5..c5b1674 100644 --- a/tests/main/ipc/toolWindow.test.ts +++ b/tests/main/ipc/toolWindow.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from 'vitest' -import { getScopedToolWindow, setScopedToolWindow, resetScopedToolWindowsForTest } from '@main/ipc/toolWindow' +import { getScopedToolWindow, getToolWindowsInScope, setScopedToolWindow, resetScopedToolWindowsForTest } from '@main/ipc/toolWindow' // Minimal fake of the BrowserWindow surface the registry touches. function fakeWin() { @@ -48,4 +48,34 @@ describe('scoped tool-window registry', () => { expect(getScopedToolWindow('json', 'standalone')).toBe(windowA) expect(getScopedToolWindow('json', 'inDock')).toBe(windowB) }) + + it('collects every live window in a scope, across tools', () => { + const jsonStandalone = fakeWin() + const svgStandalone = fakeWin() + const svgInDock = fakeWin() + setScopedToolWindow('json', 'standalone', jsonStandalone as never) + setScopedToolWindow('svg', 'standalone', svgStandalone as never) + setScopedToolWindow('svg', 'inDock', svgInDock as never) + + const standalone = getToolWindowsInScope('standalone') + expect(standalone).toHaveLength(2) + expect(standalone).toContain(jsonStandalone) + expect(standalone).toContain(svgStandalone) + expect(standalone).not.toContain(svgInDock) + + expect(getToolWindowsInScope('inDock')).toEqual([svgInDock]) + }) + + it('omits closed and destroyed windows from a scope collection', () => { + const live = fakeWin() + const closed = fakeWin() + const destroyed = fakeWin() + setScopedToolWindow('json', 'standalone', live as never) + setScopedToolWindow('svg', 'standalone', closed as never) + setScopedToolWindow('script', 'standalone', destroyed as never) + closed.emitClosed() + destroyed.destroyed = true + + expect(getToolWindowsInScope('standalone')).toEqual([live]) + }) }) diff --git a/tests/main/launch/launchRequest.test.ts b/tests/main/launch/launchRequest.test.ts index 7645485..17e5e45 100644 --- a/tests/main/launch/launchRequest.test.ts +++ b/tests/main/launch/launchRequest.test.ts @@ -20,6 +20,23 @@ describe('parseLaunchRequest', () => { .toEqual({ tool: 'svg' }) }) + // Electron reorders the argv it forwards to the second-instance handler: bare + // positionals are moved to the end and its own switches are spliced in. The + // attached --tool= form survives that intact, which is why the launchers + // use it. This is the exact shape observed from a `RokDock --tool=json` relaunch. + it('parses --tool=value from a reordered second-instance argv', () => { + const reordered = ['electron', '--user-data-dir=/d', '--no-sandbox', '--tool=json', '--allow-file-access-from-files', 'out/main/main.js'] + expect(parseLaunchRequest(reordered, cwd)).toEqual({ tool: 'json' }) + }) + + // The space-separated form does NOT survive that reordering: a switch lands + // between --tool and its value, so the value is lost. This is the failure the + // launcher --tool= form exists to avoid; documented so it does not regress. + it('returns null when a reordered argv separates --tool from its space value', () => { + const reordered = ['electron', '--user-data-dir=/d', '--tool', '--allow-file-access-from-files', 'out/main/main.js', 'json'] + expect(parseLaunchRequest(reordered, cwd)).toBeNull() + }) + it('parses a following absolute file path', () => { expect(parseLaunchRequest(['electron', 'main.js', '--tool', 'json', '/abs/foo.json'], cwd)) .toEqual({ tool: 'json', filePath: '/abs/foo.json' }) diff --git a/tests/scripts/launcherTemplates.test.ts b/tests/scripts/launcherTemplates.test.ts index efa739f..c2cd2ee 100644 --- a/tests/scripts/launcherTemplates.test.ts +++ b/tests/scripts/launcherTemplates.test.ts @@ -17,9 +17,9 @@ describe('nsisInclude', () => { it('creates one shortcut per launcher with its --tool arg and icon', () => { expect(nsh).toContain('"$SMPROGRAMS\\RokDock\\JSON Editor.lnk"') - expect(nsh).toContain('"--tool json"') + expect(nsh).toContain('"--tool=json"') expect(nsh).toContain('"$INSTDIR\\resources\\icons\\tools\\json.ico"') - expect(nsh).toContain('"--tool svg"') + expect(nsh).toContain('"--tool=svg"') }) it('deletes each shortcut on uninstall and removes the folder', () => { @@ -33,7 +33,7 @@ describe('desktopEntry', () => { const entry = templates.desktopEntry(LAUNCHERS[0], 'rokdock') expect(entry).toContain('[Desktop Entry]') expect(entry).toContain('Name=RokDock JSON Editor') - expect(entry).toContain('Exec=rokdock --tool json %U') + expect(entry).toContain('Exec=rokdock --tool=json %U') expect(entry).toContain('Icon=rokdock-json') expect(entry).toContain('Categories=Development;') expect(entry).toContain('Type=Application') @@ -58,7 +58,7 @@ describe('macLaunchStub', () => { expect(stub).toContain('#!/bin/sh') expect(stub).toContain("kMDItemCFBundleIdentifier == 'com.rokdock.app'") expect(stub).toContain('/Applications/RokDock.app') - expect(stub).toContain('--tool json') + expect(stub).toContain('--tool=json') }) }) @@ -67,7 +67,7 @@ describe('install/uninstall scripts', () => { const icons = { json: 'AAAA', svg: 'BBBB' } const script = templates.appImageInstallScript(LAUNCHERS, icons) expect(script).toContain('#!/bin/sh') - expect(script).toContain('--tool json') + expect(script).toContain('--tool=json') expect(script).toContain('rokdock-json.desktop') expect(script).toContain('AAAA') // Must use printf %b so the embedded \n escapes become real newlines in the .desktop file.