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
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:
parent
0b98d8041e
commit
f1826548ea
4
.gitignore
vendored
4
.gitignore
vendored
@ -29,4 +29,6 @@ yarn-error.log*
|
||||
|
||||
stats.html
|
||||
|
||||
test-results.xml
|
||||
test-results.xml
|
||||
|
||||
dist/*
|
||||
51
electrobun.config.ts
Normal file
51
electrobun.config.ts
Normal 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
32
scripts/build-macos.mjs
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
209
scripts/finalize-desktop-artifacts.mjs
Normal file
209
scripts/finalize-desktop-artifacts.mjs
Normal 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
38
scripts/pre-build.mjs
Normal 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})`);
|
||||
17
scripts/release-artifact-utils.mjs
Normal file
17
scripts/release-artifact-utils.mjs
Normal 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}`;
|
||||
}
|
||||
13
scripts/sync-electrobun-views.mjs
Normal file
13
scripts/sync-electrobun-views.mjs
Normal 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
20
src/bun/index.js
Normal 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
253
src/desktop/appupdate.js
Normal 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
170
src/desktop/macappupdate.js
Normal 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
110
src/desktop/menu.js
Normal 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
9
src/desktop/notify.js
Normal 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
75
src/desktop/rpc.js
Normal 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
103
src/desktop/spotlight.js
Normal 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
86
src/desktop/store.js
Normal 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
437
src/desktop/winappupdate.js
Normal 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
250
src/desktop/window.js
Normal 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
103
src/electrobun-bridge.js
Normal 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;
|
||||
Loading…
x
Reference in New Issue
Block a user