Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
38 changes: 23 additions & 15 deletions docs/developer/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,21 +144,29 @@ npm run dev
npm run dev -- --instance dev
```

The loop watches maintained application sources and brand assets, coalesces
rapid edits, and runs the same complete build and staged-layout verifier. A
successful build asks only the addressed process to quit through Electron's
normal shutdown lifecycle, waits for its durability barrier to finish, then
launches the same checkout, state root, name, and icon. It prints `ready` only
after the replacement publishes its healthy local service. A failed build or
startup prints a concise diagnostic, leaves the watcher active, and tries again
after the next relevant edit. Replacement windows appear without activating
Markover, so rebuilds do not take focus from the application currently in use;
click Markover when the replacement is ready to resume interactive QA.
Generated output, dependency directories, Git
metadata, and instance state do not trigger rebuilds. Keep only one loop per
instance and use `npm start` for deterministic one-shot work. End the loop with
Ctrl-C; it asks the addressed instance to quit through the same managed
shutdown path and waits for that process before returning.
The loop performs one complete build and addressed-bundle preparation when it
starts. If the selected instance is already running under this live loop, the
new watcher attaches to it; an older non-live instance is replaced once so it
can load the development renderer safely.

After that startup, CSS, HTML, renderer, preload, and renderer-only dependency
edits build into a separate worktree-local renderer directory. The directory is
published only after every asset succeeds, then the existing Electron process
reloads the existing `BrowserWindow`. The native window is never closed or
recreated, so its size, position, visibility, and focus remain unchanged. A
failed renderer build leaves the displayed renderer and last published assets
untouched, and the next valid edit retries normally.

An edit used by Electron's main process or local backend prints the message
`restart required` and leaves the running window untouched. Stop and restart the loop
when that change should enter the application; the loop never turns a runtime
edit into an automatic app restart. Watcher implementation updates hand the
running app to the replacement watcher without quitting it. Generated output,
dependency directories, Git metadata, and instance state do not trigger
rebuilds. Keep only one loop per instance and use `npm start` for deterministic
one-shot work. End the loop with Ctrl-C; it asks the addressed instance to quit
through the managed durability path and waits for that process before
returning.

## Development review links

Expand Down
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const rendererGlobals = {
module.exports = defineConfig([
{
ignores: [
'.markover/**',
'build/**',
'dist/**',
'evals/**/results/**',
Expand Down
186 changes: 186 additions & 0 deletions scripts/development-renderer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import fs from 'node:fs/promises'
import path from 'node:path'

import {
build as esbuild,
type BuildOptions,
type Metafile
} from 'esbuild'

import { brandAssetNames } from './app-layout'

const defaultProjectDirectory = path.resolve(__dirname, '../..')

const staticInputs = [
'src/index.html',
'src/styles.css',
...brandAssetNames.map((name) => `design/brand/${name}`)
] as const

export type DevelopmentRendererBuild = (
options: BuildOptions
) => Promise<{ metafile: Metafile }>

export interface DevelopmentRendererOptions {
build?: DevelopmentRendererBuild
projectDirectory?: string
publishedDirectory: string
}

export interface DevelopmentRendererResult {
inputPaths: string[]
publishedDirectory: string
}

function normalizeProjectPath(
projectDirectory: string,
inputPath: string
): string {
const relative = path.isAbsolute(inputPath)
? path.relative(projectDirectory, inputPath)
: inputPath
return relative.split(path.sep).join('/').replace(/^\.\//, '')
}

async function copyFile(source: string, destination: string): Promise<void> {
await fs.mkdir(path.dirname(destination), { recursive: true })
await fs.copyFile(source, destination)
}

function errorCode(error: unknown): unknown {
if (error !== null && typeof error === 'object' && 'code' in error) {
return error.code
}
return null
}

async function movePublishedAside(
publishedDirectory: string,
parent: string,
name: string
): Promise<string | null> {
const previousDirectory = await fs.mkdtemp(
path.join(parent, `.${name}.previous-`)
)
await fs.rmdir(previousDirectory)
try {
await fs.rename(publishedDirectory, previousDirectory)
return previousDirectory
} catch (error) {
if (errorCode(error) === 'ENOENT') return null
throw error
}
}

async function replacePublishedDirectory(
stagedDirectory: string,
publishedDirectory: string
): Promise<void> {
const parent = path.dirname(publishedDirectory)
const name = path.basename(publishedDirectory)
await fs.mkdir(parent, { recursive: true })

const previousDirectory = await movePublishedAside(
publishedDirectory,
parent,
name
)
try {
await fs.rename(stagedDirectory, publishedDirectory)
} catch (error) {
if (previousDirectory !== null) {
await fs.rename(previousDirectory, publishedDirectory)
}
throw error
}
if (previousDirectory !== null) {
await fs.rm(previousDirectory, { recursive: true, force: true })
}
}

export async function buildDevelopmentRenderer({
build = esbuild as DevelopmentRendererBuild,
projectDirectory = defaultProjectDirectory,
publishedDirectory
}: DevelopmentRendererOptions): Promise<DevelopmentRendererResult> {
const resolvedProjectDirectory = path.resolve(projectDirectory)
const resolvedPublishedDirectory = path.resolve(publishedDirectory)
const parent = path.dirname(resolvedPublishedDirectory)
const name = path.basename(resolvedPublishedDirectory)
await fs.mkdir(parent, { recursive: true })
const stagedDirectory = await fs.mkdtemp(path.join(parent, `.${name}.building-`))
const sourceDirectory = path.join(stagedDirectory, 'src')
const metafiles: Metafile[] = []

try {
for (const input of staticInputs) {
await copyFile(
path.join(resolvedProjectDirectory, input),
path.join(stagedDirectory, input)
)
}

const preload = await build({
absWorkingDir: resolvedProjectDirectory,
bundle: true,
entryPoints: ['src/preload.ts'],
external: ['electron'],
format: 'cjs',
logLevel: 'warning',
metafile: true,
outfile: path.join(sourceDirectory, 'preload.js'),
platform: 'node',
sourcemap: 'external',
sourcesContent: true,
target: 'node22'
})
metafiles.push(preload.metafile)

const startup = await build({
absWorkingDir: resolvedProjectDirectory,
bundle: true,
entryPoints: ['src/startup.ts'],
format: 'iife',
logLevel: 'warning',
metafile: true,
outfile: path.join(sourceDirectory, 'startup.js'),
platform: 'browser',
sourcemap: 'external',
sourcesContent: true,
target: 'chrome150'
})
metafiles.push(startup.metafile)

const renderer = await build({
absWorkingDir: resolvedProjectDirectory,
bundle: true,
entryPoints: ['src/renderer.ts'],
format: 'esm',
logLevel: 'warning',
metafile: true,
outfile: path.join(sourceDirectory, 'renderer.js'),
platform: 'browser',
sourcemap: 'external',
sourcesContent: true,
splitting: false,
target: 'chrome150'
})
metafiles.push(renderer.metafile)

const inputPaths = new Set<string>(staticInputs)
for (const metafile of metafiles) {
for (const inputPath of Object.keys(metafile.inputs)) {
inputPaths.add(normalizeProjectPath(resolvedProjectDirectory, inputPath))
}
}

await replacePublishedDirectory(stagedDirectory, resolvedPublishedDirectory)
return {
inputPaths: [...inputPaths].sort(),
publishedDirectory: resolvedPublishedDirectory
}
} catch (error) {
await fs.rm(stagedDirectory, { recursive: true, force: true })
throw error
}
}
10 changes: 8 additions & 2 deletions scripts/development-watch-bootstrap.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ let bootstrapReloadRequested = false
let revision = 0
let transition = null
let watcherInputs = new Set()
const watcherHandoffChanges = new Set()

function normalizedBundleInput(filePath) {
const absolutePath = path.isAbsolute(filePath)
Expand All @@ -147,7 +148,8 @@ function scheduleWatcherStart() {
}, debounceMilliseconds)
}

function requestWatcherStart() {
function requestWatcherStart(filePath) {
watcherHandoffChanges.add(filePath)
revision += 1
scheduleWatcherStart()
}
Expand All @@ -173,7 +175,7 @@ const bootstrapWatcher = watch(
return
}
if (!started || starting || isWatcherInput(filePath)) {
requestWatcherStart()
requestWatcherStart(filePath)
} else {
developmentLoop.notify(filePath)
}
Expand Down Expand Up @@ -303,6 +305,10 @@ async function startWatcher() {
)
started = true
developmentLoop.start()
for (const filePath of watcherHandoffChanges) {
developmentLoop.notify(filePath)
}
watcherHandoffChanges.clear()
} catch (error) {
fail(error)
process.stderr.write(
Expand Down
Loading