Add initial Electrobun configuration and build scripts for macOS
Some checks reported errors
farmcontrol/farmcontrol-ui/pipeline/head Something is wrong with the build of this commit

- Created electrobun.config.ts to define application settings and build parameters.
- Added build-macos.mjs script to handle macOS builds, including architecture targeting.
- Introduced finalize-desktop-artifacts.mjs for managing post-build processes and artifact publishing.
- Implemented pre-build.mjs for validating required files before the build process.
- Added utility scripts for managing release artifacts and syncing views.
- Established desktop-specific functionalities in the src/desktop directory, including app update handling and menu management.
- Updated .gitignore to include dist directory and test results for cleaner repository management.
- Introduced bun.lock for dependency management with Bun.
This commit is contained in:
Tom Butcher 2026-08-01 20:18:02 +01:00
parent 0b98d8041e
commit f1826548ea
19 changed files with 4756 additions and 1 deletions

2
.gitignore vendored
View File

@ -30,3 +30,5 @@ yarn-error.log*
stats.html
test-results.xml
dist/*

2777
bun.lock Normal file

File diff suppressed because it is too large Load Diff

51
electrobun.config.ts Normal file
View File

@ -0,0 +1,51 @@
import type { ElectrobunConfig } from "electrobun";
import { readFileSync } from "node:fs";
const packageJson = JSON.parse(readFileSync("./package.json", "utf8"));
export default {
app: {
name: "Farm Control",
identifier: "com.tombutcher.farmcontrol",
version: packageJson.version,
description: "3D Printer ERP and Control Software.",
urlSchemes: ["farmcontrol"],
},
runtime: {
exitOnLastWindowClosed: true,
},
build: {
buildFolder: "build",
artifactFolder: "app_dist",
bun: {
entrypoint: "src/bun/index.js",
},
copy: {
"dist/mainview": "views/mainview",
},
watch: ["scripts"],
watchIgnore: ["dist/**", "build/**", "app_dist/**"],
mac: {
bundleCEF: false,
icon: "assets/logos/farmcontrolicon.png",
},
linux: {
bundleCEF: false,
icon: "assets/logos/farmcontrolicon.png",
},
win: {
bundleCEF: false,
icon: "assets/logos/farmcontrolicon.png",
},
},
scripts: {
preBuild: "scripts/pre-build.mjs",
postPackage: "scripts/finalize-desktop-artifacts.mjs",
},
release: {
baseUrl:
process.env.ELECTROBUN_RELEASE_BASE_URL ||
process.env.RELEASE_BASE_URL ||
"",
},
} satisfies ElectrobunConfig;

32
scripts/build-macos.mjs Normal file
View File

@ -0,0 +1,32 @@
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import path from "node:path";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const targetArchs = ["arm64", "x64"];
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: rootDir,
stdio: "inherit",
env: process.env,
...options,
});
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
run("bun", ["run", "build"]);
for (const targetArch of targetArchs) {
console.log(`\n=== Building macOS ${targetArch} ===\n`);
run("bun", ["electrobun", "build", "--env=stable"], {
env: {
...process.env,
ELECTROBUN_ARCH: targetArch,
},
});
}

View File

@ -0,0 +1,209 @@
import {
cpSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
statSync,
} from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import {
getReleaseArch,
getReleaseArtifactName,
getReleaseVersion,
} from "./release-artifact-utils.mjs";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const packageJson = JSON.parse(
readFileSync(path.join(rootDir, "package.json"), "utf8"),
);
const buildEnv = process.env.ELECTROBUN_BUILD_ENV || "stable";
const targetOs =
process.env.ELECTROBUN_OS ||
(process.platform === "darwin"
? "macos"
: process.platform === "win32"
? "win"
: process.platform === "linux"
? "linux"
: null);
const buildArch = getReleaseArch(
process.env.ELECTROBUN_ARCH || process.arch,
);
const version =
process.env.ELECTROBUN_APP_VERSION || getReleaseVersion(packageJson);
const artifactDir =
process.env.ELECTROBUN_ARTIFACT_DIR || path.join(rootDir, "app_dist");
const identifier =
process.env.ELECTROBUN_APP_IDENTIFIER || "com.tombutcher.farmcontrol";
const artifactPrefix = `farmcontrol-${version}-`;
function getBuildRoot() {
const electrobunBuildDir = process.env.ELECTROBUN_BUILD_DIR;
if (!electrobunBuildDir) {
return path.join(rootDir, "build");
}
const baseName = path.basename(electrobunBuildDir);
if (baseName.startsWith("stable-")) {
return path.dirname(electrobunBuildDir);
}
return electrobunBuildDir;
}
function walkFiles(dir) {
const files = [];
if (!existsSync(dir)) {
return files;
}
for (const entry of readdirSync(dir)) {
const fullPath = path.join(dir, entry);
const stats = statSync(fullPath);
if (stats.isDirectory()) {
files.push(...walkFiles(fullPath));
} else {
files.push(fullPath);
}
}
return files;
}
function findByExtension(root, extension) {
return walkFiles(root).find((filePath) =>
filePath.toLowerCase().endsWith(extension.toLowerCase()),
);
}
function findMacAppBundle(arch) {
const platformDir = path.join(getBuildRoot(), `stable-macos-${arch}`);
if (!existsSync(platformDir)) {
return null;
}
for (const child of readdirSync(platformDir)) {
if (child.endsWith(".app")) {
return path.join(platformDir, child);
}
}
return null;
}
function findMacDmgSource(arch) {
const prefixedArtifact = walkFiles(artifactDir).find(
(filePath) =>
filePath.includes(`stable-macos-${arch}`) &&
filePath.toLowerCase().endsWith(".dmg"),
);
if (prefixedArtifact) {
return prefixedArtifact;
}
const buildDmg = findByExtension(
path.join(getBuildRoot(), `stable-macos-${arch}`),
".dmg",
);
if (buildDmg) {
return buildDmg;
}
return findByExtension(artifactDir, ".dmg");
}
function publishArtifact(sourcePath, arch, ext) {
if (!sourcePath || !existsSync(sourcePath)) {
throw new Error(
`Missing source artifact for ${arch}.${ext}: ${sourcePath ?? "not found"}`,
);
}
mkdirSync(artifactDir, { recursive: true });
const destination = path.join(
artifactDir,
getReleaseArtifactName(version, arch, ext),
);
cpSync(sourcePath, destination);
console.log(`Published ${destination}`);
return destination;
}
function buildMacPkg(appBundlePath, arch) {
const pkgPath = path.join(
artifactDir,
getReleaseArtifactName(version, arch, "pkg"),
);
const result = spawnSync(
"pkgbuild",
[
"--component",
appBundlePath,
"--install-location",
"/Applications",
"--identifier",
identifier,
"--version",
version,
pkgPath,
],
{ stdio: "inherit" },
);
if (result.status !== 0) {
throw new Error(`pkgbuild failed with exit code ${result.status ?? 1}`);
}
console.log(`Published ${pkgPath}`);
return pkgPath;
}
if (buildEnv === "dev") {
console.log("finalize-desktop-artifacts: skipping dev build");
process.exit(0);
}
if (targetOs !== "macos") {
console.log(
"finalize-desktop-artifacts: no desktop packaging configured for",
targetOs ?? "unknown target",
);
process.exit(0);
}
const dmgSource = findMacDmgSource(buildArch);
const appBundle = findMacAppBundle(buildArch);
if (!dmgSource || !appBundle) {
console.log(
`finalize-desktop-artifacts: no macOS ${buildArch} release artifacts found, skipping`,
);
process.exit(0);
}
const published = [
publishArtifact(dmgSource, buildArch, "dmg"),
buildMacPkg(appBundle, buildArch),
];
for (const filePath of published) {
const basename = path.basename(filePath);
for (const entry of readdirSync(artifactDir)) {
if (entry === basename || entry.startsWith(artifactPrefix)) {
continue;
}
if (
entry.includes("stable-macos-") ||
entry.endsWith("-update.json") ||
entry.endsWith(".tar.gz")
) {
rmSync(path.join(artifactDir, entry), { recursive: true, force: true });
}
}
}

38
scripts/pre-build.mjs Normal file
View File

@ -0,0 +1,38 @@
import { existsSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const buildEnv = process.env.ELECTROBUN_BUILD_ENV || "dev";
const requiredFiles = [
"src/bun/index.js",
"electrobun.config.ts",
"package.json",
];
for (const relativePath of requiredFiles) {
const filePath = path.join(rootDir, relativePath);
if (!existsSync(filePath)) {
console.error(`pre-build: required file not found: ${relativePath}`);
process.exit(1);
}
}
if (buildEnv === "stable" && process.env.ELECTROBUN_OS === "macos") {
const codesignVars = [
"ELECTROBUN_DEVELOPER_ID",
"ELECTROBUN_APPLEID",
"ELECTROBUN_APPLEIDPASS",
"ELECTROBUN_TEAMID",
];
const missing = codesignVars.filter((name) => !process.env[name]);
if (missing.length > 0) {
console.warn(
`pre-build: stable macOS build without code signing credentials (${missing.join(", ")}); Electrobun will skip signing.`,
);
}
}
console.log(`pre-build: validation passed (${buildEnv})`);

View File

@ -0,0 +1,17 @@
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 getReleaseArtifactName(version, arch, ext) {
return `farmcontrol-${version}-${arch}.${ext}`;
}

View File

@ -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}`);

20
src/bun/index.js Normal file
View File

@ -0,0 +1,20 @@
import { createAppRpc } from "../desktop/rpc.js";
import { registerGlobalShortcuts, unregisterGlobalShortcuts } from "../desktop/spotlight.js";
import {
createMainWindow,
handleDeepLinkFromArgv,
setupDevAuthServer,
setupNavigationGestures,
} from "../desktop/window.js";
const rpc = createAppRpc();
const mainWindow = await createMainWindow(rpc);
setupNavigationGestures(mainWindow);
registerGlobalShortcuts(rpc);
setupDevAuthServer();
handleDeepLinkFromArgv();
process.on("exit", () => {
unregisterGlobalShortcuts();
});

253
src/desktop/appupdate.js Normal file
View File

@ -0,0 +1,253 @@
import { createWriteStream, promises as fs } from "node:fs";
import http from "node:http";
import https from "node:https";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import { Utils } from "electrobun/bun";
import { launchMacInstaller } from "./macappupdate.js";
import { launchWindowsInstaller } from "./winappupdate.js";
const SUPPORTED_TARGETS = {
darwin: {
extension: ".pkg",
osMatchers: ["darwin", "mac", "macos", "osx"],
},
win32: {
extension: ".msi",
osMatchers: ["win32", "win", "windows"],
},
};
let runningUpdate = null;
const getArtifactName = (artifact) =>
String(artifact?.fileName || artifact?.relativePath || artifact?.url || "");
const normalizeArch = (arch) => {
if (arch === "x64" || arch === "amd64") return "x64";
if (arch === "arm64" || arch === "aarch64") return "arm64";
return arch;
};
const artifactMatchesPlatform = (artifact, target, platform, arch) => {
const name = getArtifactName(artifact).toLowerCase();
const normalizedArch = normalizeArch(arch);
const artifactArch = normalizeArch(String(artifact?.arch || "").toLowerCase());
const artifactPlatform = String(
artifact?.platform || artifact?.os || artifact?.target || "",
).toLowerCase();
if (!name.endsWith(target.extension)) return false;
if (!artifact?.url) return false;
const matchesArch =
artifactArch === normalizedArch ||
name.includes(`-${normalizedArch}`) ||
name.includes(`_${normalizedArch}`) ||
name.includes(`.${normalizedArch}.`) ||
name.includes(normalizedArch);
const matchesOs =
!artifactPlatform ||
target.osMatchers.includes(artifactPlatform) ||
target.osMatchers.some((matcher) => name.includes(matcher)) ||
(platform === "darwin" && name.includes("mac")) ||
(platform === "win32" && name.includes("win"));
return matchesArch && matchesOs;
};
const selectUpdateArtifact = (
update,
platform = process.platform,
arch = process.arch,
) => {
const target = SUPPORTED_TARGETS[platform];
if (!target) {
throw new Error(`App updates are not supported on ${platform}.`);
}
const artifacts = Array.isArray(update?.artifacts) ? update.artifacts : [];
const matchingArtifact = artifacts.find((artifact) =>
artifactMatchesPlatform(artifact, target, platform, arch),
);
const fallbackArtifact = artifacts.find((artifact) => {
const name = getArtifactName(artifact).toLowerCase();
return artifact?.url && name.endsWith(target.extension);
});
if (!matchingArtifact && !fallbackArtifact) {
throw new Error(
`No ${target.extension} update artifact found for ${platform}/${arch}.`,
);
}
return matchingArtifact || fallbackArtifact;
};
const getInstallErrorMessage = (error, output = "") => {
const combined = `${output}\n${error?.message || ""}`.trim();
if (
/cancel/i.test(combined) ||
/did not grant permission/i.test(combined) ||
/user canceled/i.test(combined)
) {
return "Update installation was cancelled.";
}
if (/incorrect/i.test(combined)) {
return "The administrator password was incorrect.";
}
return combined || "Failed to install update.";
};
const getDownloadUrl = (url, redirectCount = 0) =>
new Promise((resolve, reject) => {
if (redirectCount > 5) {
reject(new Error("Too many redirects while downloading update."));
return;
}
const parsedUrl = new URL(url);
const client = parsedUrl.protocol === "https:" ? https : http;
const request = client.get(parsedUrl, (response) => {
const location = response.headers.location;
if (response.statusCode >= 300 && response.statusCode < 400 && location) {
response.resume();
resolve(
getDownloadUrl(
new URL(location, parsedUrl).toString(),
redirectCount + 1,
),
);
return;
}
resolve({ response, url: parsedUrl.toString() });
});
request.on("error", reject);
});
const downloadArtifact = async (artifact, destinationPath, sendProgress) => {
const { response } = await getDownloadUrl(artifact.url);
if (response.statusCode < 200 || response.statusCode >= 300) {
response.resume();
throw new Error(`Update download failed with HTTP ${response.statusCode}.`);
}
const totalBytes =
Number.parseInt(response.headers["content-length"], 10) || 0;
let downloadedBytes = 0;
await new Promise((resolve, reject) => {
const output = createWriteStream(destinationPath);
response.on("data", (chunk) => {
downloadedBytes += chunk.length;
const percent = totalBytes
? Math.round((downloadedBytes / totalBytes) * 100)
: null;
sendProgress({
phase: "downloading",
percent,
downloadedBytes,
totalBytes,
message: totalBytes
? `Downloading update (${percent}%)`
: "Downloading update",
});
});
response.on("error", reject);
output.on("error", reject);
output.on("finish", resolve);
response.pipe(output);
});
};
const restartApp = () => {
Utils.quit();
};
const launchInstallerAndQuit = async (
mainWindow,
installerPath,
sendProgress,
) => {
const installerHelpers = { sendProgress, getInstallErrorMessage };
if (process.platform === "darwin") {
await launchMacInstaller(mainWindow, installerPath, sendProgress, installerHelpers);
restartApp();
return;
}
if (process.platform === "win32") {
await launchWindowsInstaller(
mainWindow,
installerPath,
sendProgress,
installerHelpers,
);
restartApp();
return;
}
throw new Error(`App updates are not supported on ${process.platform}.`);
};
const runAppUpdate = async (mainWindow, update, sendProgress) => {
const artifact = selectUpdateArtifact(update);
const tempDirectory = await fs.mkdtemp(
path.join(os.tmpdir(), "farmcontrol-update-"),
);
const artifactName = path.basename(getArtifactName(artifact));
const installerPath = path.join(tempDirectory, artifactName);
sendProgress({
phase: "preparing",
percent: 0,
artifact,
message: "Preparing update download",
});
await downloadArtifact(artifact, installerPath, sendProgress);
sendProgress({
phase: "downloaded",
percent: 100,
downloadedBytes: null,
totalBytes: null,
artifact,
message: "Update downloaded",
});
await launchInstallerAndQuit(mainWindow, installerPath, sendProgress);
};
export function startAppUpdate(mainWindow, update, sendProgress) {
if (runningUpdate) return runningUpdate;
runningUpdate = runAppUpdate(mainWindow, update, sendProgress)
.then(() => ({ ok: true }))
.catch((error) => {
sendProgress({
phase: "error",
percent: null,
message: error?.message || "Failed to update app.",
});
throw error;
})
.finally(() => {
runningUpdate = null;
});
return runningUpdate;
}

170
src/desktop/macappupdate.js Normal file
View File

@ -0,0 +1,170 @@
import { promises as fs } from 'fs'
import { createRequire } from 'module'
import path from 'path'
const require = createRequire(import.meta.url)
const sudo = require('@vscode/sudo-prompt')
const quoteShellArg = (value) => `'${String(value).replaceAll("'", "'\\''")}'`
const parseMacInstallerProgress = (output) => {
const lines = String(output || '').split('\n')
let percent = null
let message = 'Installing update...'
for (const line of lines) {
if (line.startsWith('installer:PHASE:')) {
message = line.slice('installer:PHASE:'.length).trim() || message
} else if (line.startsWith('installer:STATUS:')) {
const status = line.slice('installer:STATUS:'.length).trim()
if (status) message = status
} else if (line.startsWith('installer:%')) {
const value = Number.parseFloat(line.slice('installer:%'.length))
if (Number.isFinite(value)) {
percent = Math.min(100, Math.round(value <= 1 ? value * 100 : value))
}
} else if (
line.startsWith('installer: ') &&
!line.startsWith('installer:PHASE:') &&
!line.startsWith('installer:STATUS:') &&
!line.startsWith('installer:%')
) {
const text = line.slice('installer: '.length).trim()
if (text) message = text
}
}
return { percent, message }
}
const isMacInstallSuccessful = (output) =>
/installer: The (install|upgrade) was successful\./i.test(output)
const isMacInstallFailed = (output) =>
/installer: The install failed/i.test(output)
const buildMacInstallScript = (installerPath, logPath) =>
`sleep 2 && /usr/sbin/installer -pkg ${quoteShellArg(
installerPath
)} -target / -verboseR 2>&1 | /usr/bin/tee ${quoteShellArg(logPath)}`
const startMacInstallerProgressWatch = (logPath, sendProgress) => {
let installerOutput = ''
let offset = 0
let lastPercent = null
let lastMessage = null
const poll = async () => {
try {
const stat = await fs.stat(logPath)
if (stat.size <= offset) return
const handle = await fs.open(logPath, 'r')
try {
const buffer = Buffer.alloc(stat.size - offset)
await handle.read(buffer, 0, buffer.length, offset)
offset = stat.size
installerOutput += buffer.toString('utf8')
const { percent, message } = parseMacInstallerProgress(installerOutput)
const resolvedMessage = message || 'Installing update...'
if (percent !== lastPercent || resolvedMessage !== lastMessage) {
lastPercent = percent
lastMessage = resolvedMessage
sendProgress( {
phase: 'installing',
percent,
message: resolvedMessage
})
}
} finally {
await handle.close()
}
} catch (error) {
if (error?.code !== 'ENOENT') {
console.error('[app-update] installer log poll error:', error)
}
}
}
const intervalId = setInterval(() => {
poll().catch((error) => {
console.error('[app-update] installer log poll error:', error)
})
}, 300)
return async () => {
clearInterval(intervalId)
await poll()
return installerOutput
}
}
export const launchMacInstaller = (
mainWindow,
installerPath,
webContents,
{ sendProgress, getInstallErrorMessage }
) => {
const logPath = path.join(path.dirname(installerPath), 'install.log')
const installScript = buildMacInstallScript(installerPath, logPath)
const promptName = 'farmcontrol'
sendProgress( {
phase: 'installing',
percent: 0,
message: 'Enter your Mac password when prompted.'
})
if (mainWindow && !mainWindow.isDestroyed?.()) {
mainWindow.focus?.();
mainWindow.show?.();
}
const stopProgressWatch = startMacInstallerProgressWatch(logPath, sendProgress)
return new Promise((resolve, reject) => {
sudo.exec(installScript, { name: promptName }, async (error, stdout, stderr) => {
const watchedOutput = await stopProgressWatch()
const output = `${stdout || ''}${stderr || ''}` || watchedOutput
await fs.unlink(logPath).catch(() => {})
if (stderr) console.error('[app-update] installer stderr:', stderr)
if (error) {
console.error('[app-update] installer error:', error)
const message = getInstallErrorMessage(error, output)
sendProgress( {
phase: 'error',
percent: null,
message
})
reject(new Error(message))
return
}
if (isMacInstallFailed(output) || !isMacInstallSuccessful(output)) {
const message = getInstallErrorMessage(null, output)
sendProgress( {
phase: 'error',
percent: null,
message
})
reject(new Error(message))
return
}
const { percent, message } = parseMacInstallerProgress(output)
sendProgress( {
phase: 'installing',
percent: percent ?? 100,
message: message || 'Installation complete. Restarting Farm Control...'
})
resolve()
})
})
}

110
src/desktop/menu.js Normal file
View File

@ -0,0 +1,110 @@
import { ApplicationMenu } from "electrobun/bun";
const SIDEBAR_MENU_ACTION_PREFIX = "sidebar-nav:";
let sidebarViewMenuSections = [];
let navigateHandler = null;
function toMenuItems(items = []) {
return items
.map((item) => {
if (item?.type === "divider") {
return { type: "separator" };
}
const menuItem = {
label: item.label,
};
if (
item?.children &&
Array.isArray(item.children) &&
item.children.length
) {
menuItem.submenu = toMenuItems(item.children);
} else if (item?.path) {
menuItem.action = `${SIDEBAR_MENU_ACTION_PREFIX}${item.path}`;
} else {
menuItem.enabled = false;
}
return menuItem;
})
.filter(Boolean);
}
function buildApplicationMenuTemplate() {
const env = (process.env.NODE_ENV || "development").trim();
const viewSubmenu = sidebarViewMenuSections.map((section) => ({
label: section.label,
submenu: toMenuItems(section.items || []),
}));
if (viewSubmenu.length === 0) {
viewSubmenu.push({ label: "No sidebar items available", enabled: false });
}
if (env === "development") {
viewSubmenu.push(
{ type: "separator" },
{
label: "Toggle Developer Tools",
accelerator: "i",
action: "toggle-devtools",
},
);
}
const template = [
{ role: "fileMenu" },
{ role: "editMenu" },
{ label: "View", submenu: viewSubmenu },
{ role: "windowMenu" },
];
if (process.platform === "darwin") {
template.unshift({ role: "appMenu" });
}
return template;
}
export function applyApplicationMenu() {
ApplicationMenu.setApplicationMenu(buildApplicationMenuTemplate());
}
export function setSidebarViewMenu(sections) {
if (!Array.isArray(sections)) {
return false;
}
sidebarViewMenuSections = sections;
applyApplicationMenu();
return true;
}
export function setupApplicationMenuEvents({
onNavigate,
onToggleDevTools,
}) {
navigateHandler = onNavigate;
ApplicationMenu.on("application-menu-clicked", (event) => {
const action = event?.data?.action;
if (!action) return;
if (action === "toggle-devtools") {
onToggleDevTools?.();
return;
}
if (action.startsWith(SIDEBAR_MENU_ACTION_PREFIX)) {
const path = action.slice(SIDEBAR_MENU_ACTION_PREFIX.length);
navigateHandler?.(path || "/");
}
});
}
export function getSidebarMenuActionPrefix() {
return SIDEBAR_MENU_ACTION_PREFIX;
}

9
src/desktop/notify.js Normal file
View File

@ -0,0 +1,9 @@
let sendMessage = () => false;
export function setMessageSender(sender) {
sendMessage = sender;
}
export function sendToRenderer(channel, data) {
return sendMessage(channel, data);
}

75
src/desktop/rpc.js Normal file
View File

@ -0,0 +1,75 @@
import { BrowserView } from "electrobun/bun";
import { startAppUpdate } from "./appupdate.js";
import { setSidebarViewMenu } from "./menu.js";
import { sendToRenderer } from "./notify.js";
import { resizeSpotlightWindow } from "./spotlight.js";
import {
clearAuthSession,
getAppSettings,
getAuthSession,
setAppSettings,
setAuthSession,
} from "./store.js";
import {
getMainWindow,
getWindowState,
handleWindowControl,
openExternalUrl,
openInternalUrl,
} from "./window.js";
export function createAppRpc() {
return BrowserView.defineRPC({
maxRequestTime: 30000,
handlers: {
requests: {
getOsInfo: async () => ({
platform: process.platform,
}),
getWindowState: async () => getWindowState(),
windowControl: async ({ action }) => {
handleWindowControl(action);
return { ok: true };
},
openExternalUrl: async ({ url }) => {
openExternalUrl(url);
return { ok: true };
},
openInternalUrl: async ({ url }) => ({
ok: openInternalUrl(url),
}),
getAuthSession: async () => getAuthSession(),
setAuthSession: async ({ session }) => ({
ok: await setAuthSession(session),
}),
clearAuthSession: async () => ({
ok: await clearAuthSession(),
}),
getAppSettings: async () => getAppSettings(),
setAppSettings: async ({ settings }) => ({
ok: await setAppSettings(settings),
}),
startAppUpdate: async ({ update }) => {
const mainWindow = getMainWindow();
const sendProgress = (payload) => {
sendToRenderer("appUpdateProgress", {
timestamp: new Date().toISOString(),
...payload,
});
};
await startAppUpdate(mainWindow, update, sendProgress);
return { ok: true };
},
resizeSpotlightWindow: async ({ height }) => ({
ok: resizeSpotlightWindow(height),
}),
setSidebarViewMenu: async ({ sections }) => ({
ok: setSidebarViewMenu(sections),
}),
getAppVersion: async () => process.env.ELECTROBUN_VERSION || "desktop",
},
messages: {},
},
});
}

103
src/desktop/spotlight.js Normal file
View File

@ -0,0 +1,103 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { BrowserWindow, GlobalShortcut } from "electrobun/bun";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const DEV_SERVER_PORT = 5780;
const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`;
const SPOTLIGHT_ROUTE_PATH = "/dashboard/electron/spotlightcontent";
let spotlightWindow = null;
function getSpotlightRouteUrl() {
if (process.env.NODE_ENV === "development") {
return `${DEV_SERVER_URL}${SPOTLIGHT_ROUTE_PATH}`;
}
return `views://mainview/index.html#${SPOTLIGHT_ROUTE_PATH}`;
}
export function openSpotlightContentWindow(rpc) {
if (spotlightWindow && !spotlightWindow.isDestroyed?.()) {
spotlightWindow.show?.();
spotlightWindow.focus?.();
return spotlightWindow;
}
spotlightWindow = new BrowserWindow({
title: "Farm Control Spotlight",
url: getSpotlightRouteUrl(),
rpc,
transparent: true,
frame: {
width: 700,
height: 40,
x: 100,
y: 100,
},
});
spotlightWindow.on?.("close", (event) => {
event?.preventDefault?.();
if (spotlightWindow && !spotlightWindow.isDestroyed?.()) {
spotlightWindow.hide?.();
}
});
spotlightWindow.on?.("blur", () => {
if (spotlightWindow && !spotlightWindow.isDestroyed?.()) {
spotlightWindow.hide?.();
}
});
return spotlightWindow;
}
export function getSpotlightWindow() {
return spotlightWindow;
}
export function registerGlobalShortcuts(rpc) {
try {
const registered = GlobalShortcut.register("Alt+Shift+Q", () => {
openSpotlightContentWindow(rpc);
});
if (!registered) {
console.warn("[globalShortcut] Failed to register Alt+Shift+Q");
}
} catch (error) {
console.warn(
"[globalShortcut] Error registering Alt+Shift+Q",
error?.message || error,
);
}
}
export function unregisterGlobalShortcuts() {
try {
GlobalShortcut.unregisterAll();
} catch (error) {
console.warn(
"[globalShortcut] Error unregistering shortcuts",
error?.message || error,
);
}
}
export function resizeSpotlightWindow(height) {
if (!spotlightWindow || spotlightWindow.isDestroyed?.()) return false;
try {
const frame = spotlightWindow.getFrame?.() || spotlightWindow.getBounds?.();
const width = frame?.width || 700;
spotlightWindow.setSize?.(width, height);
spotlightWindow.center?.();
return true;
} catch (error) {
console.warn("[spotlight] Failed to resize window.", error?.message || error);
return false;
}
}

86
src/desktop/store.js Normal file
View File

@ -0,0 +1,86 @@
import { existsSync } from "node:fs";
import path from "node:path";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { Utils } from "electrobun/bun";
const AUTH_SERVICE = "com.tombutcher.farmcontrol";
const AUTH_SECRET_NAME = "authSession";
const SETTINGS_FILE = "settings.json";
function getSettingsPath() {
return path.join(Utils.paths.userData, SETTINGS_FILE);
}
async function ensureUserDataDir() {
await mkdir(Utils.paths.userData, { recursive: true });
}
export async function getAuthSession() {
try {
const value = await Bun.secrets.get({
service: AUTH_SERVICE,
name: AUTH_SECRET_NAME,
});
if (!value) return null;
return JSON.parse(value);
} catch (error) {
console.warn("[auth-session] Failed to read auth session.", error);
return null;
}
}
export async function setAuthSession(session) {
if (!session || typeof session !== "object") return false;
try {
await Bun.secrets.set({
service: AUTH_SERVICE,
name: AUTH_SECRET_NAME,
value: JSON.stringify(session),
});
return true;
} catch (error) {
console.warn("[auth-session] Failed to write auth session.", error);
return false;
}
}
export async function clearAuthSession() {
try {
await Bun.secrets.delete({
service: AUTH_SERVICE,
name: AUTH_SECRET_NAME,
});
return true;
} catch (error) {
console.warn("[auth-session] Failed to clear auth session.", error);
return false;
}
}
export async function getAppSettings() {
try {
await ensureUserDataDir();
const settingsPath = getSettingsPath();
if (!existsSync(settingsPath)) return {};
const raw = await readFile(settingsPath, "utf8");
const settings = JSON.parse(raw);
return settings && typeof settings === "object" ? settings : {};
} catch (error) {
console.warn("[app-settings] Failed to read settings.", error);
return {};
}
}
export async function setAppSettings(settings) {
if (!settings || typeof settings !== "object") return false;
try {
await ensureUserDataDir();
await writeFile(getSettingsPath(), JSON.stringify(settings, null, 2));
return true;
} catch (error) {
console.warn("[app-settings] Failed to write settings.", error);
return false;
}
}

437
src/desktop/winappupdate.js Normal file
View File

@ -0,0 +1,437 @@
import { spawn } from 'child_process'
import { promises as fs } from 'fs'
import os from 'os'
import path from 'path'
import process from 'process'
const MSI_OLE_HEADER = Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1])
const DEBUG_PREFIX = '[app-update][win-progress]'
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const debugLog = () => {}
const decodeMsiLogBuffer = (buffer) => {
if (!buffer?.length) return ''
if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) {
debugLog('decoded MSI log as UTF-16 LE (BOM)')
return buffer.subarray(2).toString('utf16le')
}
const sample = buffer.subarray(0, Math.min(buffer.length, 64))
const looksUtf16 =
sample.length >= 4 &&
sample.filter((byte) => byte === 0).length > sample.length / 4
if (looksUtf16) {
debugLog('decoded MSI log as UTF-16 LE (heuristic)')
return buffer.toString('utf16le')
}
debugLog('decoded MSI log as UTF-8')
return buffer.toString('utf8')
}
const formatMsiActionName = (actionName) => {
const humanized = String(actionName)
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/_/g, ' ')
.toLowerCase()
.trim()
if (!humanized) return 'Installing update...'
return `${humanized.charAt(0).toUpperCase()}${humanized.slice(1)}...`
}
const parseWindowsInstallerProgress = (output) => {
const lines = String(output || '').split(/\r?\n/)
let percent = null
let message = 'Installing update...'
let totalTicks = 0
let currentTicks = 0
let actionStarts = 0
let actionEnds = 0
const matchedLines = []
for (const line of lines) {
const actionStart = line.match(/^Action start \d{2}:\d{2}:\d{2}: (.+?)\./)
if (actionStart) {
actionStarts += 1
message = formatMsiActionName(actionStart[1])
matchedLines.push(`action-start:${actionStart[1]}`)
}
const doingAction = line.match(/Doing action:\s*(.+)$/)
if (doingAction && !actionStart) {
message = formatMsiActionName(doingAction[1])
matchedLines.push(`doing-action:${doingAction[1]}`)
}
if (/^Action ended \d{2}:\d{2}:\d{2}: .+?\. Return value \d+\./.test(line)) {
actionEnds += 1
matchedLines.push('action-ended')
}
const progressReset = line.match(/^\s*0\s+(\d+)\s+0(?:\s+\d+)?\s*$/)
if (progressReset) {
totalTicks = Number.parseInt(progressReset[1], 10) || 0
currentTicks = 0
matchedLines.push(`progress-reset:${totalTicks}`)
}
const progressIncrement = line.match(/^\s*2\s+(\d+)\s*$/)
if (progressIncrement) {
currentTicks += Number.parseInt(progressIncrement[1], 10) || 0
matchedLines.push(`progress-increment:${progressIncrement[1]}`)
}
const progressAddition = line.match(/^\s*3\s+(\d+)\s*$/)
if (progressAddition) {
totalTicks += Number.parseInt(progressAddition[1], 10) || 0
matchedLines.push(`progress-addition:${progressAddition[1]}`)
}
if (/Installation success or error status:\s*0\b/.test(line)) {
percent = 100
message = 'Installation complete. Restarting Farm Control...'
matchedLines.push('install-success')
}
}
if (percent !== 100) {
if (totalTicks > 0) {
percent = Math.min(99, Math.round((currentTicks / totalTicks) * 100))
} else if (actionStarts > 0) {
percent = Math.min(
95,
Math.max(5, Math.round((actionEnds / actionStarts) * 90))
)
}
}
return {
percent,
message,
stats: {
lineCount: lines.length,
actionStarts,
actionEnds,
totalTicks,
currentTicks,
matchedLines: matchedLines.slice(-8)
}
}
}
const isWindowsInstallSuccessful = (output) =>
/Installation success or error status:\s*0\b/.test(output) ||
/MainEngineThread is returning 0\b/.test(output)
const isWindowsInstallFailed = (output) =>
/Installation success or error status:\s*[1-9]\d*\b/.test(output) ||
/MainEngineThread is returning [1-9]\d*\b/.test(output)
const isValidMsiPackage = async (filePath) => {
const handle = await fs.open(filePath, 'r')
try {
const header = Buffer.alloc(MSI_OLE_HEADER.length)
await handle.read(header, 0, header.length, 0)
return header.equals(MSI_OLE_HEADER)
} finally {
await handle.close()
}
}
const prepareInstallerPath = async (installerPath) => {
const fileName = path.basename(installerPath)
const updateDir = path.join(
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
'FarmControl',
'Updates'
)
await fs.mkdir(updateDir, { recursive: true })
const stablePath = path.join(updateDir, fileName)
await fs.copyFile(installerPath, stablePath)
// Resolve to a canonical long path. Short 8.3 paths (e.g. ADMINI~1) break msiexec.
const resolvedPath = await fs.realpath(stablePath)
const stats = await fs.stat(resolvedPath)
if (!stats.isFile() || stats.size === 0) {
throw new Error('Update installer file is missing or empty.')
}
if (!(await isValidMsiPackage(resolvedPath))) {
throw new Error(
'Downloaded update is not a valid Windows Installer package. The file may be corrupted or incomplete.'
)
}
return resolvedPath
}
const startWindowsInstallerProgressWatch = (logPath, sendProgress) => {
let installerOutput = ''
let lastLogSize = 0
let lastPercent = null
let lastMessage = null
let pollCount = 0
const poll = async () => {
pollCount += 1
try {
const stat = await fs.stat(logPath)
if (stat.size === 0) {
debugLog(`poll #${pollCount}: log exists but is empty`, { logPath })
return
}
if (stat.size === lastLogSize) {
debugLog(`poll #${pollCount}: no new log data`, {
logPath,
size: stat.size
})
return
}
const buffer = Buffer.alloc(stat.size)
const handle = await fs.open(logPath, 'r')
try {
await handle.read(buffer, 0, stat.size, 0)
} finally {
await handle.close()
}
lastLogSize = stat.size
installerOutput = decodeMsiLogBuffer(buffer)
const { percent, message, stats } =
parseWindowsInstallerProgress(installerOutput)
const resolvedPercent = percent ?? lastPercent ?? 0
const resolvedMessage = message || 'Installing update...'
debugLog(`poll #${pollCount}: parsed installer log`, {
logPath,
size: stat.size,
textLength: installerOutput.length,
preview: installerOutput.slice(0, 240).replace(/\s+/g, ' '),
parsed: stats,
resolvedPercent,
resolvedMessage
})
if (
resolvedPercent !== lastPercent ||
resolvedMessage !== lastMessage
) {
debugLog(`poll #${pollCount}: sending progress update`, {
percent: resolvedPercent,
message: resolvedMessage
})
lastPercent = resolvedPercent
lastMessage = resolvedMessage
sendProgress( {
phase: 'installing',
percent: resolvedPercent,
message: resolvedMessage
})
} else {
debugLog(`poll #${pollCount}: progress unchanged, skipping UI update`, {
percent: resolvedPercent,
message: resolvedMessage
})
}
} catch (error) {
if (error?.code === 'ENOENT') {
debugLog(`poll #${pollCount}: log file not created yet`, { logPath })
return
}
console.error(`${DEBUG_PREFIX} installer log poll error:`, error)
}
}
const intervalId = setInterval(() => {
poll().catch((error) => {
console.error(`${DEBUG_PREFIX} installer log poll error:`, error)
})
}, 300)
return async () => {
clearInterval(intervalId)
await poll()
debugLog('stopped progress watch', {
logPath,
finalSize: lastLogSize,
textLength: installerOutput.length,
pollCount
})
return installerOutput
}
}
export const launchWindowsInstaller = async (
mainWindow,
installerPath,
webContents,
{ sendProgress, getInstallErrorMessage }
) => {
const resolvedPath = await prepareInstallerPath(installerPath)
const logPath = path.join(path.dirname(resolvedPath), 'install.log')
debugLog('prepared installer', {
installerPath,
resolvedPath,
logPath
})
sendProgress( {
phase: 'installing',
percent: 0,
message: 'Installing update...'
})
await fs.unlink(logPath).catch(() => {})
// Allow file handles from the download/copy to settle before msiexec opens the MSI.
await sleep(2000)
const stopProgressWatch = startWindowsInstallerProgressWatch(
logPath,
sendProgress
)
return new Promise((resolve, reject) => {
let processOutput = ''
const startedAt = Date.now()
const installerArgs = [
'/i',
resolvedPath,
'/qn',
'/norestart',
'/L*v!',
logPath
]
debugLog('spawning msiexec', {
args: installerArgs,
elapsedMs: Date.now() - startedAt
})
const installerProcess = spawn('msiexec.exe', installerArgs, {
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
})
installerProcess.stdout?.on('data', (data) => {
const text = data.toString('utf16le')
processOutput += text
debugLog('msiexec stdout chunk', {
length: text.length,
preview: text.slice(0, 200)
})
})
installerProcess.stderr?.on('data', (data) => {
const text = data.toString('utf16le')
processOutput += text
debugLog('msiexec stderr chunk', {
length: text.length,
preview: text.slice(0, 200)
})
})
installerProcess.on('spawn', () => {
debugLog('msiexec spawned', {
pid: installerProcess.pid,
elapsedMs: Date.now() - startedAt
})
})
installerProcess.on('error', async (error) => {
console.error(`${DEBUG_PREFIX} installer spawn error:`, error)
const watchedOutput = await stopProgressWatch()
debugLog('installer spawn failed', {
watchedOutputLength: watchedOutput.length,
processOutputLength: processOutput.length
})
const message = error?.message || 'Failed to start update installer.'
sendProgress( {
phase: 'error',
percent: null,
message
})
reject(error)
})
installerProcess.on('exit', async (code, signal) => {
const watchedOutput = await stopProgressWatch()
const output = watchedOutput || processOutput
const finalParse = parseWindowsInstallerProgress(output)
debugLog('msiexec exited', {
code,
signal,
elapsedMs: Date.now() - startedAt,
watchedOutputLength: watchedOutput.length,
processOutputLength: processOutput.length,
parsed: finalParse.stats,
outputPreview: output.slice(0, 500).replace(/\s+/g, ' ')
})
debugLog('keeping install log', { logPath })
if (code !== 0) {
const message = getInstallErrorMessage(null, output)
sendProgress( {
phase: 'error',
percent: null,
message
})
reject(new Error(message))
return
}
const succeeded =
isWindowsInstallSuccessful(output) ||
(code === 0 && !isWindowsInstallFailed(output))
debugLog('install success evaluation', {
succeeded,
isSuccessful: isWindowsInstallSuccessful(output),
isFailed: isWindowsInstallFailed(output),
exitCode: code
})
if (!succeeded) {
const message = getInstallErrorMessage(null, output)
sendProgress( {
phase: 'error',
percent: null,
message
})
reject(new Error(message))
return
}
const { percent, message } = finalParse
sendProgress( {
phase: 'installing',
percent: percent ?? 100,
message: message || 'Installation complete. Restarting Farm Control...'
})
debugLog('installer completed successfully')
resolve()
})
})
}

250
src/desktop/window.js Normal file
View File

@ -0,0 +1,250 @@
import Electrobun, { BrowserWindow, Updater, Utils } from "electrobun/bun";
import { applyApplicationMenu, setupApplicationMenuEvents } from "./menu.js";
import { sendToRenderer, setMessageSender } from "./notify.js";
const DEV_SERVER_PORT = 5780;
const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`;
const PROTOCOL_PREFIX = "farmcontrol://";
let mainWindow = null;
let webviewDomReady = false;
const pendingNavigations = [];
export function getMainWindow() {
return mainWindow;
}
export async function getMainViewUrl() {
const channel = await Updater.localInfo.channel();
if (channel === "dev" || process.env.NODE_ENV === "development") {
try {
await fetch(DEV_SERVER_URL, { method: "HEAD" });
console.log(`Using Vite dev server at ${DEV_SERVER_URL}`);
return DEV_SERVER_URL;
} catch {
console.warn(
"Vite dev server not running. Start it with `bun run dev:renderer`.",
);
}
}
return "views://mainview/index.html";
}
function deliverNavigation(redirectPath) {
sendToRenderer("navigate", redirectPath);
mainWindow?.show?.();
mainWindow?.activate?.();
}
function flushPendingNavigations() {
if (!mainWindow || !webviewDomReady) {
return;
}
while (pendingNavigations.length > 0) {
const redirectPath = pendingNavigations.shift();
setTimeout(() => deliverNavigation(redirectPath), 100);
}
}
function sendNavigateToRenderer(redirectPath) {
if (!redirectPath || typeof redirectPath !== "string") {
return;
}
if (!mainWindow || !webviewDomReady) {
pendingNavigations.push(redirectPath);
return;
}
setTimeout(() => deliverNavigation(redirectPath), 100);
}
export function handleDeepLink(url) {
if (!url?.startsWith(`${PROTOCOL_PREFIX}app`)) return;
const redirectPath = url.replace(`${PROTOCOL_PREFIX}app`, "") || "/";
sendNavigateToRenderer(redirectPath);
}
function findProtocolUrl(args) {
return args.find(
(arg) => typeof arg === "string" && arg.startsWith(PROTOCOL_PREFIX),
);
}
export function handleDeepLinkFromArgv() {
if (process.platform === "darwin") return;
const url = findProtocolUrl(process.argv);
if (url) handleDeepLink(url);
}
function setupWindowEvents(window) {
window.on?.("maximize", () => {
sendToRenderer("windowState", { isMaximized: true });
});
window.on?.("unmaximize", () => {
sendToRenderer("windowState", { isMaximized: false });
});
window.on?.("enter-full-screen", () => {
sendToRenderer("windowState", { isFullScreen: true });
});
window.on?.("leave-full-screen", () => {
sendToRenderer("windowState", { isFullScreen: false });
});
}
export function setupMainWindowMessaging(window = mainWindow) {
if (!window) {
return;
}
setMessageSender((channel, data) => {
try {
const send = window.webview?.rpc?.send;
if (!send) {
console.warn(
`No RPC sender available for channel: ${channel}. Is the window ready?`,
);
return false;
}
send[channel](data);
return true;
} catch (error) {
console.warn(`Failed to send RPC message on channel: ${channel}`, error);
return false;
}
});
}
export async function createMainWindow(rpc) {
const url = await getMainViewUrl();
mainWindow = new BrowserWindow({
title: "Farm Control",
url,
rpc,
titleBarStyle: "hiddenInset",
trafficLightOffset: { x: 14, y: 12 },
frame: {
width: 1200,
height: 800,
x: 100,
y: 100,
},
});
setupMainWindowMessaging(mainWindow);
applyApplicationMenu();
setupApplicationMenuEvents({
onNavigate: sendNavigateToRenderer,
onToggleDevTools: () => {
mainWindow?.webview?.toggleDevTools?.();
},
});
setupWindowEvents(mainWindow);
Electrobun.events.on("open-url", (event) => {
const url = event?.data?.url;
if (url) {
handleDeepLink(url);
}
});
return new Promise((resolve) => {
mainWindow.webview.on("dom-ready", () => {
webviewDomReady = true;
flushPendingNavigations();
resolve(mainWindow);
});
});
}
export async function setupDevAuthServer() {
const env = (process.env.NODE_ENV || "development").trim();
if (env !== "development") return;
const express = (await import("express")).default;
const app = express();
const port = 3500;
app.use((req, res) => {
const redirectPath = req.originalUrl;
res.send(
`Open Farmcontrol to continue... (Redirect path: ${redirectPath})`,
);
sendNavigateToRenderer(redirectPath);
});
app.listen(port, () => {});
}
export function openInternalUrl(url) {
sendNavigateToRenderer(url);
return true;
}
export function getWindowState() {
if (!mainWindow) {
return { isFullScreen: false, isMaximized: false };
}
return {
isFullScreen: mainWindow.isFullScreen?.() ?? false,
isMaximized: mainWindow.isMaximized?.() ?? false,
};
}
export function handleWindowControl(action) {
if (!mainWindow) return;
switch (action) {
case "minimize":
mainWindow.minimize?.();
break;
case "maximize":
if (mainWindow.isMaximized?.()) {
mainWindow.unmaximize?.();
} else {
mainWindow.maximize?.();
}
break;
case "close":
mainWindow.close?.();
break;
default:
break;
}
}
export function sendNavigationGesture(direction) {
sendToRenderer("navigationGesture", direction);
}
export function setupNavigationGestures(window) {
if (!window) return;
if (process.platform === "darwin") {
window.on?.("swipe", (_event, direction) => {
if (direction === "left") {
sendNavigationGesture("back");
} else if (direction === "right") {
sendNavigationGesture("forward");
}
});
}
window.on?.("app-command", (_event, command) => {
if (command === "browser-backward") {
sendNavigationGesture("back");
} else if (command === "browser-forward") {
sendNavigationGesture("forward");
}
});
}
export function openExternalUrl(url) {
Utils.openExternal(url);
}

103
src/electrobun-bridge.js Normal file
View File

@ -0,0 +1,103 @@
import Electrobun, { Electroview } from "electrobun/view";
const isDesktop = Boolean(
typeof window !== "undefined" &&
window.__electrobunWebviewId &&
window.__electrobunRpcSocketPort,
);
const listeners = new Map();
const pendingMessages = new Map();
let rpc = null;
function dispatchMessage(channel, data) {
const channelListeners = listeners.get(channel);
if (!channelListeners?.size) {
if (!pendingMessages.has(channel)) {
pendingMessages.set(channel, []);
}
pendingMessages.get(channel).push(data);
return;
}
for (const callback of channelListeners) {
callback(data);
}
}
if (isDesktop) {
rpc = Electroview.defineRPC({
maxRequestTime: 30000,
handlers: {
requests: {},
messages: {
"*": (channel, data) => {
dispatchMessage(channel, data);
},
},
},
});
new Electrobun.Electroview({ rpc });
}
function onMessage(channel, callback) {
if (!listeners.has(channel)) {
listeners.set(channel, new Set());
}
listeners.get(channel).add(callback);
const queued = pendingMessages.get(channel);
if (queued?.length) {
pendingMessages.delete(channel);
for (const payload of queued) {
callback(payload);
}
}
return () => {
listeners.get(channel)?.delete(callback);
};
}
function removeAllListeners(channel) {
listeners.delete(channel);
pendingMessages.delete(channel);
}
async function invokeRequest(method, params = {}) {
if (!rpc?.request?.[method]) {
console.warn(`Unhandled RPC request: ${method}`);
return null;
}
return await rpc.request[method](params);
}
const electronAPI = {
isDesktop,
onMessage,
removeAllListeners,
getOsInfo: () => invokeRequest("getOsInfo"),
getWindowState: () => invokeRequest("getWindowState"),
windowControl: (action) => invokeRequest("windowControl", { action }),
openExternalUrl: (url) => invokeRequest("openExternalUrl", { url }),
openInternalUrl: (url) => invokeRequest("openInternalUrl", { url }),
getAuthSession: () => invokeRequest("getAuthSession"),
setAuthSession: (session) => invokeRequest("setAuthSession", { session }),
clearAuthSession: () => invokeRequest("clearAuthSession"),
getAppSettings: () => invokeRequest("getAppSettings"),
setAppSettings: (settings) => invokeRequest("setAppSettings", { settings }),
startAppUpdate: (update) => invokeRequest("startAppUpdate", { update }),
resizeSpotlightWindow: (height) =>
invokeRequest("resizeSpotlightWindow", { height }),
setSidebarViewMenu: (sections) =>
invokeRequest("setSidebarViewMenu", { sections }),
getAppVersion: () => invokeRequest("getAppVersion"),
};
if (isDesktop) {
window.electronAPI = electronAPI;
}
export default electronAPI;