diff --git a/packaging/windows/installer.nsh b/packaging/windows/installer.nsh index 8fb4e0f..fd8e767 100644 --- a/packaging/windows/installer.nsh +++ b/packaging/windows/installer.nsh @@ -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 diff --git a/scripts/patch-electrobun-src.mjs b/scripts/patch-electrobun-src.mjs index 3c75776..999c2b6 100644 --- a/scripts/patch-electrobun-src.mjs +++ b/scripts/patch-electrobun-src.mjs @@ -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`) diff --git a/src/bun/deeplink.js b/src/bun/deeplink.js index 9c54fbc..bfca4e3 100644 --- a/src/bun/deeplink.js +++ b/src/bun/deeplink.js @@ -36,6 +36,7 @@ if (!existsSync(launcherPath)) { } const child = spawn(launcherPath, [], { + cwd: dirname(launcherPath), detached: true, stdio: 'ignore', windowsHide: true diff --git a/src/bun/index.js b/src/bun/index.js index 0dc587f..3d95789 100644 --- a/src/bun/index.js +++ b/src/bun/index.js @@ -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 }) diff --git a/src/desktop/window.js b/src/desktop/window.js index 54be50e..bf75a10 100644 --- a/src/desktop/window.js +++ b/src/desktop/window.js @@ -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?.()) { diff --git a/src/desktop/windows-work-area.js b/src/desktop/windows-work-area.js new file mode 100644 index 0000000..7645e81 --- /dev/null +++ b/src/desktop/windows-work-area.js @@ -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 +}