diff --git a/src/components/Dashboard/context/AppUpdateContext.jsx b/src/components/Dashboard/context/AppUpdateContext.jsx
index 7589525..da220a8 100644
--- a/src/components/Dashboard/context/AppUpdateContext.jsx
+++ b/src/components/Dashboard/context/AppUpdateContext.jsx
@@ -130,6 +130,10 @@ export const AppUpdateProvider = ({ children }) => {
setAppSettings,
getAppEngine,
startAppUpdate,
+ checkAppUpdateResult,
+ checkDuplicateInstallations,
+ removeDuplicateInstallations,
+ onDuplicateInstallationsRemoved,
onAppUpdateProgress,
onCheckForUpdatesRequest
} = useContext(ElectronContext)
@@ -139,6 +143,11 @@ export const AppUpdateProvider = ({ children }) => {
const [updatePromptOpen, setUpdatePromptOpen] = useState(false)
const [installingUpdate, setInstallingUpdate] = useState(null)
const [updateProgress, setUpdateProgress] = useState(null)
+ const [completedUpdate, setCompletedUpdate] = useState(null)
+ const [duplicateInstall, setDuplicateInstall] = useState(null)
+ const [duplicatePromptOpen, setDuplicatePromptOpen] = useState(false)
+ const [removingDuplicate, setRemovingDuplicate] = useState(false)
+ const [duplicateRemovalResult, setDuplicateRemovalResult] = useState(null)
const runningCheckRef = useRef(null)
const updateCheckDependenciesRef = useRef({})
@@ -293,6 +302,44 @@ export const AppUpdateProvider = ({ children }) => {
}
}, [isElectron, showUpdateIfAvailable])
+ useEffect(() => {
+ if (!isElectron) return undefined
+
+ let cancelled = false
+
+ const runStartupChecks = async () => {
+ try {
+ const result = await checkAppUpdateResult?.()
+ if (!cancelled && result?.updated) {
+ setCompletedUpdate(result)
+ }
+
+ const installations = await checkDuplicateInstallations?.()
+ if (!cancelled && installations?.duplicatePath) {
+ setDuplicateInstall(installations)
+ setDuplicatePromptOpen(true)
+ }
+ } catch (error) {
+ console.warn('[AppUpdateContext] Startup update checks failed:', error)
+ }
+ }
+
+ void runStartupChecks()
+
+ return () => {
+ cancelled = true
+ }
+ }, [isElectron, checkAppUpdateResult, checkDuplicateInstallations])
+
+ useEffect(() => {
+ if (!isElectron || !onDuplicateInstallationsRemoved) return undefined
+
+ return onDuplicateInstallationsRemoved((result) => {
+ setRemovingDuplicate(false)
+ setDuplicateRemovalResult(result || { ok: false })
+ })
+ }, [isElectron, onDuplicateInstallationsRemoved])
+
useEffect(() => {
if (!isElectron || !onAppUpdateProgress) return undefined
@@ -358,6 +405,25 @@ export const AppUpdateProvider = ({ children }) => {
}
}
+ const handleRemoveDuplicate = async () => {
+ setRemovingDuplicate(true)
+ setDuplicateRemovalResult(null)
+
+ const started = await removeDuplicateInstallations?.()
+ if (!started) {
+ setRemovingDuplicate(false)
+ setDuplicateRemovalResult({
+ ok: false,
+ error: 'Failed to start removing the duplicate installation.'
+ })
+ }
+ }
+
+ const closeDuplicatePrompt = () => {
+ setDuplicatePromptOpen(false)
+ setDuplicateRemovalResult(null)
+ }
+
const updateModalOpen = Boolean(updatePromptOpen || installingUpdate)
const updateModalBusy =
Boolean(installingUpdate) && updateProgress?.phase !== 'error'
@@ -438,6 +504,100 @@ export const AppUpdateProvider = ({ children }) => {
/>
)}
+
+
+ Update Installed
+
+ }
+ open={Boolean(completedUpdate)}
+ style={{ maxWidth: 430 }}
+ centered
+ onCancel={() => setCompletedUpdate(null)}
+ footer={[
+
+ ]}
+ >
+
+ Farm Control was successfully updated to version{' '}
+ {completedUpdate?.current?.version || appVersion}
+ {completedUpdate?.previous?.version &&
+ completedUpdate.previous.version !== completedUpdate?.current?.version
+ ? ` (previously ${completedUpdate.previous.version})`
+ : ''}
+ .
+
+
+
+ OK
+
+ ]
+ : [
+ ,
+
+ ]
+ }
+ >
+ {removingDuplicate ? (
+
+
+
+ Removing the duplicate installation... You may be asked for an
+ administrator password.
+
+
+ ) : duplicateRemovalResult ? (
+
+ {duplicateRemovalResult.ok
+ ? 'The duplicate installation was removed.'
+ : duplicateRemovalResult.error ||
+ 'Failed to remove the duplicate installation.'}
+
+ ) : (
+
+
+ Farm Control is now installed in your user applications folder,
+ but an older copy is still installed at:
+
+ {duplicateInstall?.duplicatePath}
+ Do you want to remove the duplicate installation?
+
+ )}
+
)
}
diff --git a/src/components/Dashboard/context/ElectronContext.jsx b/src/components/Dashboard/context/ElectronContext.jsx
index 0e3b6eb..8f444a2 100644
--- a/src/components/Dashboard/context/ElectronContext.jsx
+++ b/src/components/Dashboard/context/ElectronContext.jsx
@@ -296,6 +296,36 @@ const ElectronProvider = ({ children }) => {
[electronAvailable, useElectrobun]
)
+ const checkAppUpdateResult = useCallback(async () => {
+ if (!electronAvailable || !useElectrobun) return null
+ return await desktopBridge.checkAppUpdateResult()
+ }, [electronAvailable, useElectrobun])
+
+ const checkDuplicateInstallations = useCallback(async () => {
+ if (!electronAvailable || !useElectrobun) return null
+ return await desktopBridge.checkDuplicateInstallations()
+ }, [electronAvailable, useElectrobun])
+
+ const removeDuplicateInstallations = useCallback(async () => {
+ if (!electronAvailable || !useElectrobun) return false
+ const result = await desktopBridge.removeDuplicateInstallations()
+ return result?.ok ?? false
+ }, [electronAvailable, useElectrobun])
+
+ const onDuplicateInstallationsRemoved = useCallback(
+ (handler) => {
+ if (
+ !electronAvailable ||
+ !useElectrobun ||
+ typeof handler !== 'function'
+ ) {
+ return () => {}
+ }
+ return desktopBridge.onMessage('duplicateInstallationsRemoved', handler)
+ },
+ [electronAvailable, useElectrobun]
+ )
+
const onAppUpdateProgress = useCallback(
(handler) => {
if (!electronAvailable || typeof handler !== 'function') {
@@ -419,6 +449,10 @@ const ElectronProvider = ({ children }) => {
getAppSettings,
setAppSettings,
startAppUpdate,
+ checkAppUpdateResult,
+ checkDuplicateInstallations,
+ removeDuplicateInstallations,
+ onDuplicateInstallationsRemoved,
onAppUpdateProgress,
onCheckForUpdatesRequest,
getToken,
diff --git a/src/desktop/appupdate.js b/src/desktop/appupdate.js
index ca3790c..24f2b08 100644
--- a/src/desktop/appupdate.js
+++ b/src/desktop/appupdate.js
@@ -212,6 +212,69 @@ const downloadArtifact = async (artifact, destinationPath, sendProgress) => {
});
};
+const getRunningEngine = (mainWindow) =>
+ mainWindow?.renderer === "cef" ? "chromium" : "native";
+
+const getRunningAppState = (mainWindow, settings) => ({
+ version: process.env.ELECTROBUN_VERSION || null,
+ branch: settings?.appUpdateRunningBranch || null,
+ engine: getRunningEngine(mainWindow),
+});
+
+// Snapshot the running version/branch/engine so the next launch can tell
+// whether an update actually completed.
+const persistCurrentAppState = async (mainWindow) => {
+ try {
+ const settings = await getAppSettings();
+ await setAppSettings({
+ ...settings,
+ current: getRunningAppState(mainWindow, settings),
+ });
+ } catch (error) {
+ console.warn("[app-update] Failed to persist current app state.", error);
+ }
+};
+
+// Ignore missing values: a field only counts as changed when it was recorded
+// both before and after the update.
+const stateValueChanged = (previous, next) =>
+ Boolean(previous) && Boolean(next) && previous !== next;
+
+let completedUpdateResult = null;
+
+export const checkForCompletedUpdate = async (mainWindow) => {
+ // Cache per process so repeated renderer calls (e.g. remounts) get the same
+ // answer instead of a false negative after `current` has been rewritten.
+ if (completedUpdateResult) return completedUpdateResult;
+
+ try {
+ const settings = await getAppSettings();
+ const previous =
+ settings?.current && typeof settings.current === "object"
+ ? settings.current
+ : null;
+ const current = getRunningAppState(mainWindow, settings);
+
+ await setAppSettings({ ...settings, current });
+
+ const updated =
+ Boolean(previous) &&
+ (stateValueChanged(previous.version, current.version) ||
+ stateValueChanged(previous.branch, current.branch) ||
+ stateValueChanged(
+ normalizeEngine(previous.engine),
+ normalizeEngine(current.engine),
+ ));
+
+ completedUpdateResult = { updated, previous, current };
+ } catch (error) {
+ console.warn("[app-update] Failed to check for a completed update.", error);
+ completedUpdateResult = { updated: false, previous: null, current: null };
+ }
+
+ return completedUpdateResult;
+};
+
const persistInstalledUpdateSettings = async (update) => {
try {
const settings = await getAppSettings();
@@ -273,6 +336,8 @@ const launchInstallerAndRestart = async (
};
const runAppUpdate = async (mainWindow, update, sendProgress) => {
+ await persistCurrentAppState(mainWindow);
+
const artifact = selectUpdateArtifact(update);
const tempDirectory = await fs.mkdtemp(
path.join(os.tmpdir(), "farmcontrol-update-"),
diff --git a/src/desktop/check-duplicate-installations.js b/src/desktop/check-duplicate-installations.js
new file mode 100644
index 0000000..31cf1cd
--- /dev/null
+++ b/src/desktop/check-duplicate-installations.js
@@ -0,0 +1,294 @@
+import { execSync, spawn } from 'node:child_process'
+import { existsSync } from 'node:fs'
+import { unlink, writeFile } from 'node:fs/promises'
+import os from 'node:os'
+import path from 'node:path'
+import process from 'node:process'
+import sudo from '@vscode/sudo-prompt'
+import { findMacAppBundle } from './updater-runner.js'
+
+const MAC_APP_NAME = 'Farm Control.app'
+const MAC_SYSTEM_APP_PATH = path.join('/Applications', MAC_APP_NAME)
+const MAC_PKG_IDENTIFIER = 'com.tombutcher.farmcontrol'
+
+const WINDOWS_APP_DIR_NAME = 'Farm Control'
+const WINDOWS_LAUNCHER_EXE = 'FarmControl.exe'
+const WINDOWS_LEGACY_LAUNCHER_EXE = 'launcher.exe'
+const WINDOWS_REGISTRY_APP_KEY = 'Software\\Tom Butcher\\Farm Control'
+const WINDOWS_REGISTRY_UNINSTALL_KEY =
+ 'Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Farm Control'
+
+const UNSUPPORTED_RESULT = {
+ supported: false,
+ runningInstall: 'unknown',
+ runningPath: null,
+ duplicatePath: null,
+}
+
+const quoteShellArg = (value) => `'${String(value).replaceAll("'", "'\\''")}'`
+
+const isPathInside = (childPath, parentPath) => {
+ if (!childPath || !parentPath) return false
+ const relative = path.relative(parentPath, childPath)
+ return (
+ relative === '' ||
+ (!relative.startsWith('..') && !path.isAbsolute(relative))
+ )
+}
+
+const samePath = (left, right) => {
+ if (!left || !right) return false
+ if (process.platform === 'win32') {
+ return path.resolve(left).toLowerCase() === path.resolve(right).toLowerCase()
+ }
+ return path.resolve(left) === path.resolve(right)
+}
+
+const checkMacInstallations = () => {
+ const runningBundle = findMacAppBundle(process.execPath)
+ const userAppPath = path.join(os.homedir(), 'Applications', MAC_APP_NAME)
+
+ let runningInstall = 'unknown'
+ if (runningBundle) {
+ if (isPathInside(runningBundle, path.join(os.homedir(), 'Applications'))) {
+ runningInstall = 'user'
+ } else if (isPathInside(runningBundle, '/Applications')) {
+ runningInstall = 'system'
+ }
+ }
+
+ const systemCopyExists =
+ existsSync(MAC_SYSTEM_APP_PATH) &&
+ !samePath(runningBundle, MAC_SYSTEM_APP_PATH)
+
+ return {
+ supported: true,
+ runningInstall,
+ runningPath: runningBundle,
+ userPath: userAppPath,
+ duplicatePath:
+ runningInstall === 'user' && systemCopyExists
+ ? MAC_SYSTEM_APP_PATH
+ : null,
+ }
+}
+
+const readWindowsInstallDir = (hive) => {
+ try {
+ const output = execSync(
+ `reg query "${hive}\\${WINDOWS_REGISTRY_APP_KEY}" /v InstallDir`,
+ { encoding: 'utf8', windowsHide: true },
+ )
+ const match = output.match(/InstallDir\s+REG_\w+\s+(.+)/i)
+ return match?.[1]?.trim() || null
+ } catch {
+ return null
+ }
+}
+
+const windowsDirLooksLikeInstall = (dir) =>
+ Boolean(dir) &&
+ (existsSync(path.join(dir, 'bin', WINDOWS_LAUNCHER_EXE)) ||
+ existsSync(path.join(dir, 'bin', WINDOWS_LEGACY_LAUNCHER_EXE)) ||
+ existsSync(path.join(dir, WINDOWS_LAUNCHER_EXE)) ||
+ existsSync(path.join(dir, 'Uninstall.exe')) ||
+ existsSync(path.join(dir, `Uninstall ${WINDOWS_APP_DIR_NAME}.exe`)))
+
+const getWindowsUserInstallDir = () =>
+ readWindowsInstallDir('HKCU') ||
+ path.join(
+ process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
+ 'Programs',
+ WINDOWS_APP_DIR_NAME,
+ )
+
+const getWindowsSystemInstallCandidates = () => {
+ const candidates = [
+ readWindowsInstallDir('HKLM'),
+ process.env.ProgramFiles &&
+ path.join(process.env.ProgramFiles, WINDOWS_APP_DIR_NAME),
+ process.env['ProgramFiles(x86)'] &&
+ path.join(process.env['ProgramFiles(x86)'], WINDOWS_APP_DIR_NAME),
+ process.env.ProgramW6432 &&
+ path.join(process.env.ProgramW6432, WINDOWS_APP_DIR_NAME),
+ ].filter(Boolean)
+
+ const unique = []
+ for (const candidate of candidates) {
+ if (!unique.some((existing) => samePath(existing, candidate))) {
+ unique.push(candidate)
+ }
+ }
+ return unique
+}
+
+const checkWindowsInstallations = () => {
+ const runningDir = path.dirname(process.execPath)
+ const userInstallDir = getWindowsUserInstallDir()
+ const systemCandidates = getWindowsSystemInstallCandidates()
+ const localAppData =
+ process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local')
+
+ let runningInstall = 'unknown'
+ if (
+ isPathInside(runningDir, userInstallDir) ||
+ isPathInside(runningDir, localAppData)
+ ) {
+ runningInstall = 'user'
+ } else if (
+ systemCandidates.some((candidate) => isPathInside(runningDir, candidate))
+ ) {
+ runningInstall = 'system'
+ }
+
+ const duplicatePath =
+ runningInstall === 'user'
+ ? systemCandidates.find(
+ (candidate) =>
+ windowsDirLooksLikeInstall(candidate) &&
+ !isPathInside(runningDir, candidate),
+ ) || null
+ : null
+
+ return {
+ supported: true,
+ runningInstall,
+ runningPath: runningDir,
+ userPath: userInstallDir,
+ duplicatePath,
+ }
+}
+
+export const checkForDuplicateInstallations = () => {
+ try {
+ if (process.platform === 'darwin') return checkMacInstallations()
+ if (process.platform === 'win32') return checkWindowsInstallations()
+ } catch (error) {
+ console.warn(
+ '[duplicate-install] Failed to check for duplicate installations.',
+ error,
+ )
+ }
+ return UNSUPPORTED_RESULT
+}
+
+const removeMacDuplicate = (duplicatePath) =>
+ new Promise((resolve, reject) => {
+ // Removing from /Applications typically needs admin rights; also forget
+ // the pkg receipt so future installer runs start clean.
+ const script = [
+ `/bin/rm -rf ${quoteShellArg(duplicatePath)}`,
+ `/usr/sbin/pkgutil --forget ${quoteShellArg(MAC_PKG_IDENTIFIER)} || true`,
+ ].join(' && ')
+
+ sudo.exec(script, { name: 'farmcontrol' }, (error, stdout, stderr) => {
+ if (error) {
+ const combined = `${stderr || ''}\n${error?.message || ''}`
+ const message =
+ /cancel/i.test(combined) || /did not grant permission/i.test(combined)
+ ? 'Removal was cancelled.'
+ : 'Failed to remove the duplicate installation.'
+ reject(new Error(message))
+ return
+ }
+ resolve()
+ })
+ })
+
+const psQuote = (value) => `'${String(value).replaceAll("'", "''")}'`
+
+// Intentionally does NOT run the old install's Uninstall.exe: it taskkills
+// FarmControl.exe, which would terminate the running app. Instead the old
+// files, HKLM registry keys and machine-level shortcuts are removed directly.
+const buildWindowsRemovalScript = (duplicatePath) =>
+ [
+ `$ErrorActionPreference = 'SilentlyContinue'`,
+ `$installDir = ${psQuote(duplicatePath)}`,
+ `if (Test-Path -LiteralPath $installDir) {`,
+ ` Remove-Item -LiteralPath $installDir -Recurse -Force`,
+ `}`,
+ `& reg.exe delete ${psQuote(`HKLM\\${WINDOWS_REGISTRY_APP_KEY}`)} /f 2>$null | Out-Null`,
+ `& reg.exe delete ${psQuote(`HKLM\\${WINDOWS_REGISTRY_UNINSTALL_KEY}`)} /f 2>$null | Out-Null`,
+ `$commonStartMenu = Join-Path $env:ProgramData 'Microsoft\\Windows\\Start Menu\\Programs\\${WINDOWS_APP_DIR_NAME}'`,
+ `if (Test-Path -LiteralPath $commonStartMenu) {`,
+ ` Remove-Item -LiteralPath $commonStartMenu -Recurse -Force`,
+ `}`,
+ `$publicDesktopShortcut = 'C:\\Users\\Public\\Desktop\\${WINDOWS_APP_DIR_NAME}.lnk'`,
+ `if (Test-Path -LiteralPath $publicDesktopShortcut) {`,
+ ` Remove-Item -LiteralPath $publicDesktopShortcut -Force`,
+ `}`,
+ `if (Test-Path -LiteralPath $installDir) { exit 1 }`,
+ `exit 0`,
+ ].join('\r\n')
+
+const removeWindowsDuplicate = async (duplicatePath) => {
+ const scriptPath = path.join(
+ os.tmpdir(),
+ `farmcontrol-remove-duplicate-${Date.now()}.ps1`,
+ )
+ await writeFile(scriptPath, buildWindowsRemovalScript(duplicatePath), 'utf8')
+
+ // Pre-quote the -File argument: PowerShell 5.1's Start-Process does not
+ // quote ArgumentList entries containing spaces.
+ const elevateCommand = [
+ `$p = Start-Process -FilePath 'powershell.exe'`,
+ `-ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','"${scriptPath.replaceAll("'", "''")}"')`,
+ `-Verb RunAs -Wait -PassThru; exit $p.ExitCode`,
+ ].join(' ')
+
+ try {
+ await new Promise((resolve, reject) => {
+ const child = spawn(
+ 'powershell.exe',
+ ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', elevateCommand],
+ { stdio: 'ignore', windowsHide: true },
+ )
+
+ child.on('error', reject)
+ child.on('exit', (code) => {
+ if (code === 0) {
+ resolve()
+ return
+ }
+ // Start-Process throws when the UAC prompt is declined, which exits
+ // the outer PowerShell with a non-zero code and no script output.
+ reject(
+ new Error(
+ code === 1
+ ? 'Failed to remove the duplicate installation.'
+ : 'Removal was cancelled.',
+ ),
+ )
+ })
+ })
+ } finally {
+ await unlink(scriptPath).catch(() => {})
+ }
+}
+
+export const removeDuplicateInstallations = async () => {
+ // Re-detect instead of trusting a path from the renderer so this can never
+ // be used to delete an arbitrary directory.
+ const check = checkForDuplicateInstallations()
+
+ if (!check.duplicatePath) {
+ return { ok: false, error: 'No duplicate installation was found.' }
+ }
+
+ try {
+ if (process.platform === 'darwin') {
+ await removeMacDuplicate(check.duplicatePath)
+ } else if (process.platform === 'win32') {
+ await removeWindowsDuplicate(check.duplicatePath)
+ } else {
+ return { ok: false, error: 'Not supported on this platform.' }
+ }
+ } catch (error) {
+ return {
+ ok: false,
+ error: error?.message || 'Failed to remove the duplicate installation.',
+ }
+ }
+
+ return { ok: true, removedPath: check.duplicatePath }
+}
diff --git a/src/desktop/rpc.js b/src/desktop/rpc.js
index bc191f8..0b6bbdf 100644
--- a/src/desktop/rpc.js
+++ b/src/desktop/rpc.js
@@ -1,5 +1,9 @@
import { BrowserView } from 'electrobun/bun'
-import { startAppUpdate } from './appupdate.js'
+import { checkForCompletedUpdate, startAppUpdate } from './appupdate.js'
+import {
+ checkForDuplicateInstallations,
+ removeDuplicateInstallations
+} from './check-duplicate-installations.js'
import { setSidebarViewMenu } from './menu.js'
import { sendToRenderer } from './notify.js'
import { resizeSpotlightWindow } from './spotlight.js'
@@ -72,6 +76,24 @@ export function createAppRpc() {
setSidebarViewMenu: async ({ sections }) => ({
ok: setSidebarViewMenu(sections)
}),
+ checkAppUpdateResult: async () =>
+ checkForCompletedUpdate(getMainWindow()),
+ checkDuplicateInstallations: async () =>
+ checkForDuplicateInstallations(),
+ removeDuplicateInstallations: async () => {
+ // Removal waits on an admin/UAC prompt which can outlive the RPC
+ // timeout; report the outcome via a push message instead.
+ void removeDuplicateInstallations()
+ .catch((error) => ({
+ ok: false,
+ error:
+ error?.message || 'Failed to remove the duplicate installation.'
+ }))
+ .then((result) => {
+ sendToRenderer('duplicateInstallationsRemoved', result)
+ })
+ return { ok: true }
+ },
getAppVersion: async () => process.env.ELECTROBUN_VERSION || 'desktop',
getAppEngine: async () => {
const mainWindow = getMainWindow()
diff --git a/src/desktop/updater-runner.js b/src/desktop/updater-runner.js
index 8578db1..ad6cc23 100644
--- a/src/desktop/updater-runner.js
+++ b/src/desktop/updater-runner.js
@@ -23,7 +23,7 @@ const resolveExistingLauncher = (...candidates) =>
const quoteBatchArg = (value) => `"${String(value).replaceAll('"', '""')}"`
-const findMacAppBundle = (startPath) => {
+export const findMacAppBundle = (startPath) => {
let current = path.resolve(startPath)
while (current !== path.dirname(current)) {
diff --git a/src/electrobun-bridge.js b/src/electrobun-bridge.js
index 16e7f50..ee5037c 100644
--- a/src/electrobun-bridge.js
+++ b/src/electrobun-bridge.js
@@ -181,6 +181,11 @@ const electronAPI = {
getAppSettings: () => invokeRequest('getAppSettings'),
setAppSettings: (settings) => invokeRequest('setAppSettings', { settings }),
startAppUpdate: (update) => invokeRequest('startAppUpdate', { update }),
+ checkAppUpdateResult: () => invokeRequest('checkAppUpdateResult'),
+ checkDuplicateInstallations: () =>
+ invokeRequest('checkDuplicateInstallations'),
+ removeDuplicateInstallations: () =>
+ invokeRequest('removeDuplicateInstallations'),
resizeSpotlightWindow: (height) =>
invokeRequest('resizeSpotlightWindow', { height }),
setSidebarViewMenu: (sections) =>