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.