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
11 changes: 6 additions & 5 deletions electron-builder.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Binary file added resources/dmg-background.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added resources/dmg-background@2x.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
48 changes: 48 additions & 0 deletions resources/dmgBackground.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 10 additions & 3 deletions scripts/launcherTemplates.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,21 @@
* 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=<key>` form, never `--tool <key>`.
* 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=<key>` is a single token that survives that reshuffle intact.
*/

/** The NSIS include written to build/tool-shortcuts.nsh and referenced by nsis.include. */
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
Expand All @@ -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',
Expand Down Expand Up @@ -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')
}
Expand Down
13 changes: 13 additions & 0 deletions src/main/ipc/toolWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 9 additions & 5 deletions src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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()
}
}
})
Expand Down
84 changes: 84 additions & 0 deletions tests/e2e/dockCloseIndependence.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string[]> {
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()
}
})
81 changes: 81 additions & 0 deletions tests/e2e/secondInstanceToolLaunch.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* E2E regression: a second `--tool=<key>` 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=<key>` 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<string[]> {
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=<key> 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<void>((resolve) => setTimeout(resolve, 4_000))
expect(await windowTitles(app)).toEqual(['RokDock'])
} finally {
await app.close()
}
})
32 changes: 31 additions & 1 deletion tests/main/ipc/toolWindow.test.ts
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down Expand Up @@ -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])
})
})
17 changes: 17 additions & 0 deletions tests/main/launch/launchRequest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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=<key> 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=<key> 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' })
Expand Down
Loading