bun run dev:renderer.",
+ );
+
+ console.log(`pre-build: validation passed (${buildEnv})`);
+ process.exit(0);
+}
+
+const buildRenderer = spawnSync(
+ "bun",
+ [path.join(rootDir, "scripts/build-renderer.mjs")],
+ {
+ cwd: rootDir,
+ stdio: "inherit",
+ env: {
+ ...process.env,
+ NODE_ENV: "production",
+ },
+ },
+);
+
+if (buildRenderer.status !== 0) {
+ process.exit(buildRenderer.status ?? 1);
+}
+
+const rendererIndex = path.join(rootDir, "dist/mainview/index.html");
+if (!existsSync(rendererIndex)) {
+ console.error(`pre-build: renderer output not found: dist/mainview/index.html`);
+ process.exit(1);
+}
+
+console.log(`pre-build: validation and renderer build passed (${buildEnv})`);
diff --git a/scripts/prepare-app-icons.mjs b/scripts/prepare-app-icons.mjs
new file mode 100644
index 0000000..6db3159
--- /dev/null
+++ b/scripts/prepare-app-icons.mjs
@@ -0,0 +1,104 @@
+import { existsSync, mkdirSync, rmSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { Jimp } from "jimp";
+import pngToIco from "png-to-ico";
+
+const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+
+const MAC_ICONSET_ENTRIES = [
+ { name: "icon_16x16.png", size: 16 },
+ { name: "icon_16x16@2x.png", size: 32 },
+ { name: "icon_32x32.png", size: 32 },
+ { name: "icon_32x32@2x.png", size: 64 },
+ { name: "icon_128x128.png", size: 128 },
+ { name: "icon_128x128@2x.png", size: 256 },
+ { name: "icon_256x256.png", size: 256 },
+ { name: "icon_256x256@2x.png", size: 512 },
+ { name: "icon_512x512.png", size: 512 },
+ { name: "icon_512x512@2x.png", size: 1024 },
+];
+
+const WIN_ICO_SIZES = [16, 32, 48, 256];
+const LINUX_ICON_SIZE = 256;
+
+function resolveSourcePath(arg) {
+ if (!arg) {
+ return path.join(rootDir, "assets/farmcontrolhosticon.png");
+ }
+
+ const candidate = path.isAbsolute(arg) ? arg : path.resolve(rootDir, arg);
+ return candidate;
+}
+
+async function resizePng(sourceImage, size) {
+ return sourceImage
+ .clone()
+ .resize({ w: size, h: size })
+ .getBuffer("image/png");
+}
+
+async function writePng(filePath, sourceImage, size) {
+ const png = await resizePng(sourceImage, size);
+ await Bun.write(filePath, png);
+ return png;
+}
+
+function parseArgs(argv) {
+ const sourceArg = argv.find((arg) => !arg.startsWith("-"));
+ const outDirArgIndex = argv.indexOf("--out-dir");
+ const outDir =
+ outDirArgIndex >= 0 ? argv[outDirArgIndex + 1] : path.join(rootDir, "assets");
+
+ return {
+ sourcePath: resolveSourcePath(sourceArg),
+ outDir: path.isAbsolute(outDir) ? outDir : path.resolve(rootDir, outDir),
+ };
+}
+
+async function main() {
+ const { sourcePath, outDir } = parseArgs(process.argv.slice(2));
+
+ if (!existsSync(sourcePath)) {
+ console.error(`prepare-app-icons: source image not found: ${sourcePath}`);
+ process.exit(1);
+ }
+
+ const sourceImage = await Jimp.read(sourcePath);
+ if (sourceImage.width < 256 || sourceImage.height < 256) {
+ console.warn(
+ `prepare-app-icons: source image is ${sourceImage.width}x${sourceImage.height}; Electrobun recommends at least 256x256`,
+ );
+ }
+
+ mkdirSync(outDir, { recursive: true });
+
+ const iconsetDir = path.join(outDir, "icon.iconset");
+ rmSync(iconsetDir, { recursive: true, force: true });
+ mkdirSync(iconsetDir, { recursive: true });
+
+ for (const entry of MAC_ICONSET_ENTRIES) {
+ const iconPath = path.join(iconsetDir, entry.name);
+ await writePng(iconPath, sourceImage, entry.size);
+ }
+
+ const icoPngs = [];
+ for (const size of WIN_ICO_SIZES) {
+ icoPngs.push(await resizePng(sourceImage, size));
+ }
+
+ const icoPath = path.join(outDir, "icon.ico");
+ await Bun.write(icoPath, await pngToIco(icoPngs));
+
+ const linuxIconPath = path.join(outDir, "icon.png");
+ await writePng(linuxIconPath, sourceImage, LINUX_ICON_SIZE);
+
+ console.log(`prepare-app-icons: wrote ${iconsetDir}`);
+ console.log(`prepare-app-icons: wrote ${icoPath}`);
+ console.log(`prepare-app-icons: wrote ${linuxIconPath}`);
+}
+
+main().catch((error) => {
+ console.error(`prepare-app-icons: ${error.message}`);
+ process.exit(1);
+});
diff --git a/scripts/prepare-installer-icons.mjs b/scripts/prepare-installer-icons.mjs
new file mode 100644
index 0000000..503481d
--- /dev/null
+++ b/scripts/prepare-installer-icons.mjs
@@ -0,0 +1,110 @@
+import { existsSync, mkdirSync, rmSync } from 'node:fs'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { Jimp } from 'jimp'
+import pngToIco from 'png-to-ico'
+
+const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
+const INSTALLER_SOURCE = path.join(
+ rootDir,
+ 'assets/logos/farmcontrolinstaller.png'
+)
+const UNINSTALLER_SOURCE = path.join(
+ rootDir,
+ 'assets/logos/farmcontroluninstaller.png'
+)
+
+const MAC_ICONSET_ENTRIES = [
+ { name: 'icon_16x16.png', size: 16 },
+ { name: 'icon_16x16@2x.png', size: 32 },
+ { name: 'icon_32x32.png', size: 32 },
+ { name: 'icon_32x32@2x.png', size: 64 },
+ { name: 'icon_128x128.png', size: 128 },
+ { name: 'icon_128x128@2x.png', size: 256 },
+ { name: 'icon_256x256.png', size: 256 },
+ { name: 'icon_256x256@2x.png', size: 512 },
+ { name: 'icon_512x512.png', size: 512 },
+ { name: 'icon_512x512@2x.png', size: 1024 }
+]
+
+const WIN_ICO_SIZES = [16, 32, 48, 256]
+
+async function resizePng(sourceImage, size) {
+ return sourceImage
+ .clone()
+ .resize({ w: size, h: size })
+ .getBuffer('image/png')
+}
+
+async function writePng(filePath, sourceImage, size) {
+ const png = await resizePng(sourceImage, size)
+ await Bun.write(filePath, png)
+ return png
+}
+
+async function writeWindowsIco(sourceImage, icoPath) {
+ const icoPngs = []
+ for (const size of WIN_ICO_SIZES) {
+ icoPngs.push(await resizePng(sourceImage, size))
+ }
+
+ await Bun.write(icoPath, await pngToIco(icoPngs))
+ console.log(`prepare-installer-icons: wrote ${icoPath}`)
+}
+
+async function main() {
+ if (!existsSync(INSTALLER_SOURCE)) {
+ console.error(
+ `prepare-installer-icons: source image not found: ${INSTALLER_SOURCE}`
+ )
+ process.exit(1)
+ }
+
+ if (!existsSync(UNINSTALLER_SOURCE)) {
+ console.error(
+ `prepare-installer-icons: source image not found: ${UNINSTALLER_SOURCE}`
+ )
+ process.exit(1)
+ }
+
+ const installerImage = await Jimp.read(INSTALLER_SOURCE)
+ if (installerImage.width < 256 || installerImage.height < 256) {
+ console.warn(
+ `prepare-installer-icons: installer source is ${installerImage.width}x${installerImage.height}; recommend at least 256x256`
+ )
+ }
+
+ const uninstallerImage = await Jimp.read(UNINSTALLER_SOURCE)
+ if (uninstallerImage.width < 256 || uninstallerImage.height < 256) {
+ console.warn(
+ `prepare-installer-icons: uninstaller source is ${uninstallerImage.width}x${uninstallerImage.height}; recommend at least 256x256`
+ )
+ }
+
+ const assetsDir = path.join(rootDir, 'assets')
+ mkdirSync(assetsDir, { recursive: true })
+
+ const iconsetDir = path.join(assetsDir, 'installer.iconset')
+ rmSync(iconsetDir, { recursive: true, force: true })
+ mkdirSync(iconsetDir, { recursive: true })
+
+ for (const entry of MAC_ICONSET_ENTRIES) {
+ await writePng(
+ path.join(iconsetDir, entry.name),
+ installerImage,
+ entry.size
+ )
+ }
+
+ console.log(`prepare-installer-icons: wrote ${iconsetDir}`)
+ await writeWindowsIco(installerImage, path.join(assetsDir, 'installer.ico'))
+ await writeWindowsIco(
+ uninstallerImage,
+ path.join(assetsDir, 'uninstaller.ico')
+ )
+}
+
+main().catch((error) => {
+ console.error(`prepare-installer-icons: ${error.message}`)
+ process.exit(1)
+})
diff --git a/scripts/release-artifact-utils.mjs b/scripts/release-artifact-utils.mjs
new file mode 100644
index 0000000..83912ed
--- /dev/null
+++ b/scripts/release-artifact-utils.mjs
@@ -0,0 +1,28 @@
+export function getReleaseVersion(packageJson) {
+ return packageJson.version;
+}
+
+export function getReleaseArch(platformArch = process.arch) {
+ if (platformArch === "arm64" || platformArch === "aarch64") {
+ return "arm64";
+ }
+ if (platformArch === "x64" || platformArch === "amd64") {
+ return "x64";
+ }
+ return platformArch;
+}
+
+export function isBundleCefEnabled(
+ value = process.env.ELECTROBUN_BUNDLE_CEF,
+) {
+ return ["1", "true", "yes"].includes(String(value || "").toLowerCase());
+}
+
+export function getReleaseArtifactName(version, arch, ext, options = {}) {
+ const cef =
+ typeof options === "boolean"
+ ? options
+ : Boolean(options.cef ?? isBundleCefEnabled());
+ const cefSuffix = cef ? "-cef" : "";
+ return `farmcontrol-${version}-${arch}${cefSuffix}.${ext}`;
+}
diff --git a/scripts/run-electrobun-build.mjs b/scripts/run-electrobun-build.mjs
new file mode 100644
index 0000000..53737d0
--- /dev/null
+++ b/scripts/run-electrobun-build.mjs
@@ -0,0 +1,101 @@
+import { spawnSync } from "node:child_process";
+import path from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { getReleaseArch, isBundleCefEnabled } from "./release-artifact-utils.mjs";
+
+const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+const electrobunCli = path.join(
+ rootDir,
+ "node_modules/electrobun/src/cli/index.ts",
+);
+
+const buildEnv = process.env.ELECTROBUN_BUILD_ENV || "stable";
+const targetArch = getReleaseArch(
+ process.env.ELECTROBUN_TARGET_ARCH ||
+ process.env.ELECTROBUN_FORCE_ARCH ||
+ process.arch,
+);
+const bundleCef = isBundleCefEnabled();
+
+const patchResult = spawnSync("bun", [path.join(rootDir, "scripts/patch-electrobun-src.mjs")], {
+ cwd: rootDir,
+ stdio: "inherit",
+ env: process.env,
+});
+
+if (patchResult.status !== 0) {
+ process.exit(patchResult.status ?? 1);
+}
+
+const macosEffectsResult = spawnSync(
+ "bun",
+ [path.join(rootDir, "scripts/build-macos-effects.mjs")],
+ { cwd: rootDir, stdio: "inherit", env: process.env },
+);
+
+if (macosEffectsResult.status !== 0) {
+ process.exit(macosEffectsResult.status ?? 1);
+}
+
+if (process.platform === "darwin") {
+ const { validateMacosEffectsDylib, outFile } = await import(
+ pathToFileURL(
+ path.join(rootDir, "scripts/build-macos-effects.mjs"),
+ ).href
+ );
+
+ if (!validateMacosEffectsDylib({ dylibPath: outFile })) {
+ console.error(
+ "run-electrobun-build: macOS window effects dylib is missing or invalid.",
+ );
+ process.exit(1);
+ }
+}
+
+const ensureCoreEnv = {
+ ...process.env,
+ ELECTROBUN_TARGET_ARCH: targetArch,
+ ELECTROBUN_FORCE_ARCH: targetArch,
+};
+
+const ensureCoreResult = spawnSync(
+ "bun",
+ [path.join(rootDir, "scripts/ensure-electrobun-core.mjs")],
+ {
+ cwd: rootDir,
+ stdio: "inherit",
+ env: ensureCoreEnv,
+ },
+);
+
+if (ensureCoreResult.status !== 0) {
+ process.exit(ensureCoreResult.status ?? 1);
+}
+
+const env = {
+ ...process.env,
+ ELECTROBUN_BUILD_ENV: buildEnv,
+ ELECTROBUN_OS: "macos",
+ ELECTROBUN_ARCH: targetArch,
+ ELECTROBUN_FORCE_ARCH: targetArch,
+ ELECTROBUN_TARGET_ARCH: targetArch,
+ NODE_ENV: process.env.NODE_ENV || "production",
+};
+
+console.log(
+ `run-electrobun-build: env=${buildEnv} os=macos arch=${targetArch} cef=${bundleCef} (host ${getReleaseArch(process.arch)})`,
+);
+
+const result = spawnSync(
+ "bun",
+ [electrobunCli, "build", `--env=${buildEnv}`],
+ {
+ cwd: rootDir,
+ stdio: "inherit",
+ env,
+ },
+);
+
+if (result.status !== 0) {
+ process.exit(result.status ?? 1);
+}
diff --git a/scripts/sync-electrobun-views.mjs b/scripts/sync-electrobun-views.mjs
new file mode 100644
index 0000000..48e77e0
--- /dev/null
+++ b/scripts/sync-electrobun-views.mjs
@@ -0,0 +1,13 @@
+import { cpSync, mkdirSync, rmSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+const buildDir = path.join(rootDir, "build");
+const viewDir = path.join(rootDir, "dist", "mainview");
+
+rmSync(viewDir, { recursive: true, force: true });
+mkdirSync(viewDir, { recursive: true });
+cpSync(buildDir, viewDir, { recursive: true });
+
+console.log(`Synced renderer build to ${viewDir}`);
diff --git a/scripts/write-build-info.mjs b/scripts/write-build-info.mjs
new file mode 100644
index 0000000..e013764
--- /dev/null
+++ b/scripts/write-build-info.mjs
@@ -0,0 +1,15 @@
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+const buildNumber =
+ process.env.BUILD_NUMBER || process.env.VITE_BUILD_NUMBER || "dev";
+
+const buildInfoPath = path.join(rootDir, "src/buildInfo.json");
+
+await Bun.write(
+ buildInfoPath,
+ `${JSON.stringify({ buildNumber }, null, 2)}\n`,
+);
+
+console.log(`write-build-info: ${buildInfoPath} (buildNumber=${buildNumber})`);
diff --git a/src/buildInfo.json b/src/buildInfo.json
new file mode 100644
index 0000000..9287740
--- /dev/null
+++ b/src/buildInfo.json
@@ -0,0 +1,3 @@
+{
+ "buildNumber": "dev"
+}
diff --git a/src/bun/deeplink.js b/src/bun/deeplink.js
new file mode 100644
index 0000000..4a6dad3
--- /dev/null
+++ b/src/bun/deeplink.js
@@ -0,0 +1,50 @@
+import { spawn } from 'node:child_process'
+import { existsSync } from 'node:fs'
+import { dirname, join } from 'node:path'
+import {
+ buildDeeplinkPayload,
+ findProtocolUrl,
+ forwardDeeplinkToRunningInstance,
+ writeDeeplinkSignal
+} from '../desktop/deeplink-ipc.js'
+
+const url = findProtocolUrl(process.argv)
+
+console.log('[deeplink] argv:', process.argv)
+console.log('[deeplink] url:', url ?? null)
+
+if (!url) {
+ process.exit(1)
+}
+
+const payload = buildDeeplinkPayload(url, process.argv)
+const forwarded = await forwardDeeplinkToRunningInstance(payload)
+
+if (forwarded) {
+ console.log('[deeplink] forwarded to running Farm Control instance')
+ process.exit(0)
+}
+
+console.log('[deeplink] no running instance found, launching Farm Control')
+
+writeDeeplinkSignal(payload)
+
+const binDir = dirname(process.execPath)
+const launcherPath = ['FarmControl.exe', 'launcher.exe']
+ .map((name) => join(binDir, name))
+ .find((candidate) => existsSync(candidate))
+
+if (!launcherPath) {
+ console.error('[deeplink] FarmControl.exe not found in', binDir)
+ process.exit(1)
+}
+
+const child = spawn(launcherPath, [], {
+ cwd: dirname(launcherPath),
+ detached: true,
+ stdio: 'ignore',
+ windowsHide: true
+})
+
+child.unref()
+process.exit(0)
diff --git a/src/bun/index.js b/src/bun/index.js
new file mode 100644
index 0000000..847d149
--- /dev/null
+++ b/src/bun/index.js
@@ -0,0 +1,43 @@
+import { ensureWindowsWorkingDirectory } from '../desktop/windows-app-paths.js'
+import {
+ captureLaunchUrl,
+ closeSingleInstanceServer,
+ ensureSingleInstanceLock
+} from '../desktop/single-instance.js'
+
+ensureWindowsWorkingDirectory()
+
+const launchUrl = captureLaunchUrl()
+const gotSingleInstanceLock = await ensureSingleInstanceLock({ launchUrl })
+
+if (!gotSingleInstanceLock) {
+ process.exit(0)
+}
+
+const { createAppRpc } = await import('../desktop/rpc.js')
+const {
+ registerGlobalShortcuts,
+ unregisterGlobalShortcuts
+} = await import('../desktop/spotlight.js')
+const {
+ createMainWindow,
+ handleDeepLinkFromArgv,
+ setupDevAuthServer,
+ setupNavigationGestures,
+ setupWindowsDeepLinkHandling
+} = await import('../desktop/window.js')
+
+setupWindowsDeepLinkHandling()
+
+const rpc = createAppRpc()
+const mainWindow = await createMainWindow(rpc)
+
+setupNavigationGestures(mainWindow)
+registerGlobalShortcuts(rpc)
+setupDevAuthServer()
+handleDeepLinkFromArgv(launchUrl)
+
+process.on('exit', () => {
+ unregisterGlobalShortcuts()
+ closeSingleInstanceServer()
+})
diff --git a/src/components/Dashboard/Layout.jsx b/src/components/Dashboard/Layout.jsx
index 56160aa..596a564 100644
--- a/src/components/Dashboard/Layout.jsx
+++ b/src/components/Dashboard/Layout.jsx
@@ -30,7 +30,7 @@ const DashboardLayout = ({ children }) => {