Enhance Windows installer and application path handling

- Added a macro to fix the working directory for shortcuts in the Windows installer, ensuring correct execution context.
- Updated shortcut creation logic in `installer.nsh` to utilize the new macro for both desktop and start menu shortcuts.
- Implemented a function to patch the native wrapper path in the Electrobun source, improving compatibility with different execution contexts.
- Adjusted the `deeplink.js` and `index.js` files to set the current working directory appropriately, enhancing application behavior on Windows.
- Introduced a new module for managing window work area constraints, improving window management and user experience on Windows platforms.
This commit is contained in:
Tom Butcher 2026-08-02 23:09:39 +01:00
parent 17b5068644
commit 5c1ea30e5d
6 changed files with 179 additions and 6 deletions

View File

@ -28,15 +28,40 @@
done_uninstall:
!macroend
!macro fixShortcutWorkingDir SHORTCUT_PATH WORKING_DIR
Push $0
Push $1
Push $2
StrCpy $1 "${SHORTCUT_PATH}"
StrCpy $2 "${WORKING_DIR}"
InitPluginsDir
FileOpen $0 "$PLUGINSDIR\fix-shortcut.ps1" w
FileWrite $0 '$$s = (New-Object -COM WScript.Shell).CreateShortcut("'
FileWrite $0 $1
FileWrite $0 '")$\r$\n'
FileWrite $0 '$$s.WorkingDirectory = "'
FileWrite $0 $2
FileWrite $0 '"$\r$\n'
FileWrite $0 '$$s.Save()$\r$\n'
FileClose $0
ExecWait '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File "$PLUGINSDIR\fix-shortcut.ps1"'
Delete "$PLUGINSDIR\fix-shortcut.ps1"
Pop $2
Pop $1
Pop $0
!macroend
!macro createDesktopShortcut
SetShellVarContext all
CreateShortCut "$DESKTOP\Farm Control.lnk" "$INSTDIR\bin\launcher.exe"
CreateShortCut "$DESKTOP\Farm Control.lnk" "$INSTDIR\bin\launcher.exe" "" "$INSTDIR\bin\launcher.exe" 0 SW_SHOWNORMAL "" "Farm Control"
!insertmacro fixShortcutWorkingDir "$DESKTOP\Farm Control.lnk" "$INSTDIR\bin"
!macroend
!macro createStartMenuShortcut
SetShellVarContext all
CreateDirectory "$SMPROGRAMS\Farm Control"
CreateShortCut "$SMPROGRAMS\Farm Control\Farm Control.lnk" "$INSTDIR\bin\launcher.exe"
CreateShortCut "$SMPROGRAMS\Farm Control\Farm Control.lnk" "$INSTDIR\bin\launcher.exe" "" "$INSTDIR\bin\launcher.exe" 0 SW_SHOWNORMAL "" "Farm Control"
!insertmacro fixShortcutWorkingDir "$SMPROGRAMS\Farm Control\Farm Control.lnk" "$INSTDIR\bin"
!macroend
!macro removeDesktopShortcut

View File

@ -277,6 +277,38 @@ if (existsSync(rceditSrc)) {
cpSync(rceditSrc, rceditDest, { recursive: true, force: true })
}
function patchNativeWrapperPath(nativeTsPath) {
if (!existsSync(nativeTsPath)) {
return
}
let source = readFileSync(nativeTsPath, 'utf8')
const needle =
'const nativeWrapperPath = join(process.cwd(), `libNativeWrapper.${suffix}`);'
const replacement =
'const nativeWrapperPath = join(dirname(process.execPath), `libNativeWrapper.${suffix}`);'
if (!source.includes(needle)) {
return
}
if (!source.includes('dirname')) {
source = source.replace(
'import { join } from "path";',
'import { dirname, join } from "path";'
)
}
source = source.replace(needle, replacement)
writeFileSync(nativeTsPath, source)
}
for (const distDir of ['dist', 'dist-macos-arm64', 'dist-win-x64']) {
patchNativeWrapperPath(
path.join(electrobunDir, distDir, 'api/bun/proc/native.ts')
)
}
const markerPath = path.join(electrobunDir, '.farmcontrol-electrobun-patched')
writeFileSync(markerPath, `patched-at=${new Date().toISOString()}\n`)

View File

@ -36,6 +36,7 @@ if (!existsSync(launcherPath)) {
}
const child = spawn(launcherPath, [], {
cwd: dirname(launcherPath),
detached: true,
stdio: 'ignore',
windowsHide: true

View File

@ -1,9 +1,19 @@
import { chdirSync } from 'node:fs'
import { dirname } from 'node:path'
import {
captureLaunchUrl,
closeSingleInstanceServer,
ensureSingleInstanceLock
} from '../desktop/single-instance.js'
if (process.platform === 'win32') {
try {
chdirSync(dirname(process.execPath))
} catch {
// Best effort; some launchers may not allow changing directory.
}
}
const launchUrl = captureLaunchUrl()
const gotSingleInstanceLock = await ensureSingleInstanceLock({ launchUrl })

View File

@ -5,6 +5,10 @@ import {
MAC_TRAFFIC_LIGHT_OFFSET
} from './macos-window-effects.js'
import { sendToRenderer, setMessageSender } from './notify.js'
import {
clampWindowToWorkArea,
isWindowWorkAreaMaximized
} from './windows-work-area.js'
const isMacOS = process.platform === 'darwin'
const isWindows = process.platform === 'win32'
@ -167,12 +171,20 @@ function syncWindowsWebviewLayout(window) {
window.setSize(width, height)
}
function handleWindowsWindowChange(window) {
if (clampWindowToWorkArea(window)) {
syncWindowsWebviewLayout(window)
}
broadcastWindowState()
}
function applyWindowsStartupWindowState(window) {
if (!window) return
setTimeout(() => {
window.maximize?.()
syncWindowsWebviewLayout(window)
handleWindowsWindowChange(window)
setTimeout(() => syncWindowsWebviewLayout(window), 0)
setTimeout(() => syncWindowsWebviewLayout(window), 100)
setTimeout(broadcastWindowState, 100)
@ -181,9 +193,13 @@ function applyWindowsStartupWindowState(window) {
function setupWindowEvents(window) {
// Electrobun emits resize/focus, not Electron's maximize/fullscreen events.
window.on?.('resize', broadcastWindowState)
const onWindowChange = isWindows
? () => handleWindowsWindowChange(window)
: broadcastWindowState
window.on?.('resize', onWindowChange)
window.on?.('focus', broadcastWindowState)
window.on?.('move', broadcastWindowState)
window.on?.('move', onWindowChange)
}
export function setupMainWindowMessaging(window = mainWindow) {
@ -314,7 +330,9 @@ export function getWindowState() {
return {
isFullScreen: mainWindow.isFullScreen?.() ?? false,
isMaximized: mainWindow.isMaximized?.() ?? false
isMaximized: isWindows
? isWindowWorkAreaMaximized(mainWindow)
: (mainWindow.isMaximized?.() ?? false)
}
}
@ -331,6 +349,10 @@ export function handleWindowControl(action) {
} else {
mainWindow.maximize?.()
}
if (isWindows) {
handleWindowsWindowChange(mainWindow)
}
break
case 'fullscreen':
if (mainWindow.isFullScreen?.()) {

View File

@ -0,0 +1,83 @@
import { Screen } from 'electrobun/bun'
const FRAME_TOLERANCE_PX = 4
function framesMatch(a, b, tolerance = FRAME_TOLERANCE_PX) {
return (
Math.abs(a.x - b.x) <= tolerance &&
Math.abs(a.y - b.y) <= tolerance &&
Math.abs(a.width - b.width) <= tolerance &&
Math.abs(a.height - b.height) <= tolerance
)
}
function getFrameCenter(frame) {
return {
x: frame.x + frame.width / 2,
y: frame.y + frame.height / 2
}
}
function getDisplayForFrame(frame) {
const { x, y } = getFrameCenter(frame)
const displays = Screen.getAllDisplays()
const match = displays.find(({ bounds }) => {
return (
x >= bounds.x &&
x < bounds.x + bounds.width &&
y >= bounds.y &&
y < bounds.y + bounds.height
)
})
return match ?? Screen.getPrimaryDisplay()
}
function frameCoversMonitor(frame, bounds) {
return (
frame.x <= bounds.x + FRAME_TOLERANCE_PX &&
frame.y <= bounds.y + FRAME_TOLERANCE_PX &&
frame.x + frame.width >= bounds.x + bounds.width - FRAME_TOLERANCE_PX &&
frame.y + frame.height >= bounds.y + bounds.height - FRAME_TOLERANCE_PX
)
}
export function isWindowWorkAreaMaximized(window) {
if (!window?.getFrame) {
return false
}
const frame = window.getFrame()
const { workArea } = getDisplayForFrame(frame)
return framesMatch(frame, workArea)
}
export function clampWindowToWorkArea(window) {
if (!window?.getFrame || !window?.setFrame) {
return false
}
if (window.isFullScreen?.()) {
return false
}
const frame = window.getFrame()
if (!frame.width || !frame.height) {
return false
}
const display = getDisplayForFrame(frame)
const { bounds, workArea } = display
if (framesMatch(frame, workArea)) {
return false
}
if (!frameCoversMonitor(frame, bounds)) {
return false
}
window.setFrame(workArea.x, workArea.y, workArea.width, workArea.height)
return true
}