Add Dock Progress Indicator and Integrate with Electron Context
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good

This commit is contained in:
Tom Butcher 2026-11-09 00:03:30 +00:00
parent ae8d4532c0
commit 672e3c59ac
9 changed files with 395 additions and 0 deletions

View File

@ -513,3 +513,104 @@ extern "C" bool setNativeWindowDragRegion(void *windowPtr, double x,
return success; return success;
} }
@interface ElectrobunDockProgressView : NSView
@property(nonatomic) double progress;
@property(nonatomic) int state;
@end
@implementation ElectrobunDockProgressView
@synthesize progress = _progress;
@synthesize state = _state;
- (NSColor *)fillColor {
switch (self.state) {
case 1:
return [NSColor systemRedColor];
case 2:
return [NSColor systemYellowColor];
default:
if (@available(macOS 10.14, *)) {
return [NSColor controlAccentColor];
}
return [NSColor systemBlueColor];
}
}
- (void)drawRect:(NSRect)dirtyRect {
(void)dirtyRect;
NSRect bounds = [self bounds];
[[NSApp applicationIconImage] drawInRect:bounds
fromRect:NSZeroRect
operation:NSCompositingOperationSourceOver
fraction:1.0];
CGFloat inset = round(bounds.size.width * 0.14);
CGFloat height = round(bounds.size.height * 0.11);
NSRect track =
NSMakeRect(NSMinX(bounds) + inset, NSMinY(bounds) + round(height * 0.9),
bounds.size.width - inset * 2.0, height);
CGFloat radius = height / 2.0;
NSBezierPath *trackPath = [NSBezierPath bezierPathWithRoundedRect:track
xRadius:radius
yRadius:radius];
[[NSColor colorWithWhite:0.0 alpha:0.55] setFill];
[trackPath fill];
double fraction = MIN(1.0, MAX(0.0, self.progress));
if (fraction > 0.0) {
NSRect fill = NSInsetRect(track, 1.5, 1.5);
CGFloat fillRadius = fill.size.height / 2.0;
fill.size.width = MAX(fill.size.height, fill.size.width * fraction);
[[self fillColor] setFill];
[[NSBezierPath bezierPathWithRoundedRect:fill
xRadius:fillRadius
yRadius:fillRadius] fill];
}
[[NSColor colorWithWhite:1.0 alpha:0.85] setStroke];
NSBezierPath *outline =
[NSBezierPath bezierPathWithRoundedRect:NSInsetRect(track, 0.5, 0.5)
xRadius:radius - 0.5
yRadius:radius - 0.5];
outline.lineWidth = 1.0;
[outline stroke];
}
@end
// fraction < 0 removes the progress bar; state: 0 normal, 1 error, 2 paused.
extern "C" bool setDockProgress(double fraction, int state) {
dispatch_async(dispatch_get_main_queue(), ^{
NSDockTile *dockTile = [NSApp dockTile];
if (dockTile == nil) {
return;
}
if (fraction < 0.0) {
if ([dockTile contentView] != nil) {
[dockTile setContentView:nil];
[dockTile display];
}
return;
}
ElectrobunDockProgressView *view = nil;
if ([[dockTile contentView]
isKindOfClass:[ElectrobunDockProgressView class]]) {
view = (ElectrobunDockProgressView *)[dockTile contentView];
} else {
NSSize size = [dockTile size];
view = [[ElectrobunDockProgressView alloc]
initWithFrame:NSMakeRect(0, 0, size.width, size.height)];
[dockTile setContentView:view];
}
view.progress = fraction;
view.state = state;
[dockTile display];
});
return true;
}

View File

@ -13,6 +13,7 @@ import { theme, Popover } from 'antd'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { ApiServerContext } from '../context/ApiServerContext' import { ApiServerContext } from '../context/ApiServerContext'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import { ElectronContext } from '../context/ElectronContext'
import { getModelByName } from '../../../database/ObjectModels' import { getModelByName } from '../../../database/ObjectModels'
import { getStateTagInfo } from '../utils/Utils' import { getStateTagInfo } from '../utils/Utils'
import SpotlightTooltip from './SpotlightTooltip' import SpotlightTooltip from './SpotlightTooltip'
@ -259,6 +260,7 @@ const LiveActivities = () => {
subscribeToObjectTypeUpdates, subscribeToObjectTypeUpdates,
subscribeToObjectUpdates subscribeToObjectUpdates
} = useContext(ApiServerContext) } = useContext(ApiServerContext)
const { isElectron, setAppProgress } = useContext(ElectronContext)
const [watched, setWatched] = useState([]) const [watched, setWatched] = useState([])
const userId = userProfile?._id const userId = userProfile?._id
@ -496,6 +498,26 @@ const LiveActivities = () => {
useEffect(() => () => restoreLiveFavicon(), []) useEffect(() => () => restoreLiveFavicon(), [])
const appProgressPercent = faviconActivity?.percent ?? null
const appProgressStatus = faviconActivity?.status ?? null
const setAppProgressRef = useRef(setAppProgress)
setAppProgressRef.current = setAppProgress
useEffect(() => {
if (!isElectron) return
void setAppProgressRef.current({
percent: appProgressPercent,
status: appProgressStatus
})
}, [isElectron, appProgressPercent, appProgressStatus])
useEffect(() => {
if (!isElectron) return undefined
return () => {
void setAppProgressRef.current({ percent: null })
}
}, [isElectron])
useEffect(() => { useEffect(() => {
const seq = ++faviconCaptureSeq.current const seq = ++faviconCaptureSeq.current
let cancelled = false let cancelled = false

View File

@ -365,6 +365,15 @@ const ElectronProvider = ({ children }) => {
[electronAvailable] [electronAvailable]
) )
const setAppProgress = useCallback(
async (progress) => {
if (!electronAvailable) return false
const result = await desktopBridge.setAppProgress(progress)
return result?.ok ?? false
},
[electronAvailable]
)
const getElectronVersion = useCallback(async () => { const getElectronVersion = useCallback(async () => {
if (!electronAvailable) return null if (!electronAvailable) return null
return await desktopBridge.getAppVersion() return await desktopBridge.getAppVersion()
@ -402,6 +411,7 @@ const ElectronProvider = ({ children }) => {
setToken, setToken,
resizeSpotlightWindow, resizeSpotlightWindow,
setSidebarViewMenu, setSidebarViewMenu,
setAppProgress,
getElectronVersion, getElectronVersion,
getAppEngine, getAppEngine,
registerTabHistoryHandler, registerTabHistoryHandler,

View File

@ -0,0 +1,69 @@
import { setMacDockProgress } from './macos-window-effects.js'
import {
setWindowsTaskbarProgress,
TASKBAR_STATE
} from './windows-taskbar-progress.js'
const MAC_DOCK_STATE = { normal: 0, error: 1, paused: 2 }
const reports = new Map()
function progressState(status) {
if (status === 'error') return 'error'
if (status === 'warning') return 'paused'
return 'normal'
}
function normalizeProgress({ percent, status } = {}) {
const value = Number(percent)
if (percent == null || !Number.isFinite(value)) return null
return {
fraction: Math.min(100, Math.max(0, value)) / 100,
state: progressState(status)
}
}
function applyDockProgress() {
let best = null
for (const report of reports.values()) {
if (report.progress && (!best || report.progress.fraction > best.fraction)) {
best = report.progress
}
}
setMacDockProgress(
best ? best.fraction : null,
best ? MAC_DOCK_STATE[best.state] : 0
)
}
export function setAppProgress(windowId, window, progress) {
const normalized = normalizeProgress(progress)
if (normalized) {
reports.set(windowId, { window, progress: normalized })
} else {
reports.delete(windowId)
}
if (process.platform === 'darwin') {
applyDockProgress()
return true
}
if (process.platform === 'win32') {
return setWindowsTaskbarProgress(
window?.ptr,
normalized?.fraction ?? null,
TASKBAR_STATE[normalized?.state ?? 'normal']
)
}
return false
}
export function clearAppProgress(windowId) {
if (!reports.delete(windowId)) return
if (process.platform === 'darwin') {
applyDockProgress()
}
}

View File

@ -45,7 +45,17 @@ function resolveDylibPath() {
return null return null
} }
let loadedLibrary
function loadMacWindowEffectsLibrary() { function loadMacWindowEffectsLibrary() {
if (loadedLibrary !== undefined) {
return loadedLibrary
}
loadedLibrary = openMacWindowEffectsLibrary()
return loadedLibrary
}
function openMacWindowEffectsLibrary() {
const dylibPath = resolveDylibPath() const dylibPath = resolveDylibPath()
if (!dylibPath) { if (!dylibPath) {
console.warn( console.warn(
@ -77,6 +87,10 @@ function loadMacWindowEffectsLibrary() {
setNativeWindowDragRegion: { setNativeWindowDragRegion: {
args: [FFIType.ptr, FFIType.f64, FFIType.f64], args: [FFIType.ptr, FFIType.f64, FFIType.f64],
returns: FFIType.bool returns: FFIType.bool
},
setDockProgress: {
args: [FFIType.f64, FFIType.i32],
returns: FFIType.bool
} }
}) })
} }
@ -104,6 +118,28 @@ function applyTrafficLightsPosition(lib, mainWindow, attempt = 0) {
return false return false
} }
// fraction is 0..1, or null to remove the bar; state: 0 normal, 1 error, 2 paused.
export function setMacDockProgress(fraction, state = 0) {
if (process.platform !== 'darwin') {
return false
}
const loaded = loadMacWindowEffectsLibrary()
if (!loaded?.lib.symbols.setDockProgress) {
return false
}
try {
return loaded.lib.symbols.setDockProgress(
fraction == null ? -1 : fraction,
state
)
} catch (error) {
console.warn('macOS dock progress: failed to update dock tile:', error)
return false
}
}
export function applyMacOSWindowEffects(mainWindow) { export function applyMacOSWindowEffects(mainWindow) {
if (process.platform !== 'darwin' || !mainWindow?.ptr) { if (process.platform !== 'darwin' || !mainWindow?.ptr) {
return return

View File

@ -1,4 +1,5 @@
import { BrowserView } from 'electrobun/bun' import { BrowserView } from 'electrobun/bun'
import { setAppProgress } from './app-progress.js'
import { import {
checkForCompletedUpdate, checkForCompletedUpdate,
getRunningAppVersion, getRunningAppVersion,
@ -20,7 +21,9 @@ import {
} from './store.js' } from './store.js'
import { import {
createAppWindow, createAppWindow,
getFocusedWindowId,
getMainWindow, getMainWindow,
getWindowById,
getWindowSession, getWindowSession,
getWindowState, getWindowState,
handleWindowControl, handleWindowControl,
@ -103,6 +106,15 @@ export function createAppRpc() {
setSidebarViewMenu: async ({ sections }) => ({ setSidebarViewMenu: async ({ sections }) => ({
ok: setSidebarViewMenu(sections) ok: setSidebarViewMenu(sections)
}), }),
setAppProgress: async ({ windowId, percent, status } = {}) => {
const targetWindowId = windowId || getFocusedWindowId()
return {
ok: setAppProgress(targetWindowId, getWindowById(targetWindowId), {
percent,
status
})
}
},
checkAppUpdateResult: async () => checkAppUpdateResult: async () =>
checkForCompletedUpdate(getMainWindow()), checkForCompletedUpdate(getMainWindow()),
checkDuplicateInstallations: async () => checkForDuplicateInstallations(), checkDuplicateInstallations: async () => checkForDuplicateInstallations(),

View File

@ -1,4 +1,5 @@
import Electrobun, { BrowserWindow, Updater, Utils } from 'electrobun/bun' import Electrobun, { BrowserWindow, Updater, Utils } from 'electrobun/bun'
import { clearAppProgress } from './app-progress.js'
import { applyApplicationMenu, setupApplicationMenuEvents } from './menu.js' import { applyApplicationMenu, setupApplicationMenuEvents } from './menu.js'
import { import {
applyMacOSWindowEffects, applyMacOSWindowEffects,
@ -447,6 +448,7 @@ function setupWindowEvents(window, windowId) {
}) })
window.on?.('close', () => { window.on?.('close', () => {
captureWindowLayout(windowId) captureWindowLayout(windowId)
clearAppProgress(windowId)
windows.delete(windowId) windows.delete(windowId)
if (focusedWindowId === windowId) { if (focusedWindowId === windowId) {
focusedWindowId = getFocusedWindowId() focusedWindowId = getFocusedWindowId()

View File

@ -0,0 +1,142 @@
import { CFunction, dlopen, FFIType, ptr, read } from 'bun:ffi'
const CLSID_TASKBAR_LIST = '56FDF344-FD6D-11D0-958A-006097C9A090'
const IID_ITASKBAR_LIST3 = 'EA1AFB91-9E28-4B86-90E9-9E9F8A5EEDAF'
const COINIT_APARTMENTTHREADED = 0x2
const CLSCTX_INPROC_SERVER = 0x1
const RPC_E_CHANGED_MODE = 0x80010106 | 0
// ITaskbarList3 vtable slots (IUnknown 0-2, ITaskbarList 3-7, ITaskbarList2 8).
const VTABLE_HR_INIT = 3
const VTABLE_SET_PROGRESS_VALUE = 9
const VTABLE_SET_PROGRESS_STATE = 10
export const TASKBAR_STATE = {
none: 0x0,
indeterminate: 0x1,
normal: 0x2,
error: 0x4,
paused: 0x8
}
const PROGRESS_TOTAL = 1000n
let taskbar
function guidBuffer(guid) {
const hex = guid.replace(/-/g, '')
const buffer = Buffer.alloc(16)
buffer.writeUInt32LE(parseInt(hex.slice(0, 8), 16), 0)
buffer.writeUInt16LE(parseInt(hex.slice(8, 12), 16), 4)
buffer.writeUInt16LE(parseInt(hex.slice(12, 16), 16), 6)
Buffer.from(hex.slice(16), 'hex').copy(buffer, 8)
return buffer
}
function vtableMethod(object, slot, args) {
const vtable = read.ptr(object, 0)
return CFunction({
ptr: read.ptr(vtable, slot * 8),
args: [FFIType.ptr, ...args],
returns: FFIType.i32
})
}
function createTaskbarList() {
const ole32 = dlopen('ole32.dll', {
CoInitializeEx: { args: [FFIType.ptr, FFIType.u32], returns: FFIType.i32 },
CoCreateInstance: {
args: [FFIType.ptr, FFIType.ptr, FFIType.u32, FFIType.ptr, FFIType.ptr],
returns: FFIType.i32
}
})
const initResult = ole32.symbols.CoInitializeEx(null, COINIT_APARTMENTTHREADED)
if (initResult < 0 && initResult !== RPC_E_CHANGED_MODE) {
throw new Error(`CoInitializeEx failed (0x${(initResult >>> 0).toString(16)})`)
}
const clsid = guidBuffer(CLSID_TASKBAR_LIST)
const iid = guidBuffer(IID_ITASKBAR_LIST3)
const out = new BigUint64Array(1)
const createResult = ole32.symbols.CoCreateInstance(
ptr(clsid),
null,
CLSCTX_INPROC_SERVER,
ptr(iid),
ptr(out)
)
if (createResult < 0 || !out[0]) {
throw new Error(
`CoCreateInstance(TaskbarList) failed (0x${(createResult >>> 0).toString(16)})`
)
}
const object = Number(out[0])
const hrInit = vtableMethod(object, VTABLE_HR_INIT, [])
const initTaskbarResult = hrInit(object)
if (initTaskbarResult < 0) {
throw new Error(
`ITaskbarList3.HrInit failed (0x${(initTaskbarResult >>> 0).toString(16)})`
)
}
return {
object,
setProgressValue: vtableMethod(object, VTABLE_SET_PROGRESS_VALUE, [
FFIType.ptr,
FFIType.u64,
FFIType.u64
]),
setProgressState: vtableMethod(object, VTABLE_SET_PROGRESS_STATE, [
FFIType.ptr,
FFIType.u32
])
}
}
function getTaskbarList() {
if (taskbar !== undefined) {
return taskbar
}
try {
taskbar = createTaskbarList()
} catch (error) {
console.warn('Windows taskbar progress unavailable:', error)
taskbar = null
}
return taskbar
}
// fraction is 0..1, or null to remove the bar.
export function setWindowsTaskbarProgress(
hwnd,
fraction,
state = TASKBAR_STATE.normal
) {
if (process.platform !== 'win32' || !hwnd) {
return false
}
const list = getTaskbarList()
if (!list) {
return false
}
try {
if (fraction == null) {
return list.setProgressState(list.object, hwnd, TASKBAR_STATE.none) >= 0
}
const completed = BigInt(
Math.round(Math.min(1, Math.max(0, fraction)) * Number(PROGRESS_TOTAL))
)
// SetProgressValue resets the state to normal, so the state goes second.
list.setProgressValue(list.object, hwnd, completed, PROGRESS_TOTAL)
return list.setProgressState(list.object, hwnd, state) >= 0
} catch (error) {
console.warn('Windows taskbar progress: failed to update:', error)
return false
}
}

View File

@ -263,6 +263,7 @@ const electronAPI = {
invokeRequest('resizeSpotlightWindow', { height }), invokeRequest('resizeSpotlightWindow', { height }),
setSidebarViewMenu: (sections) => setSidebarViewMenu: (sections) =>
invokeRequest('setSidebarViewMenu', { sections }), invokeRequest('setSidebarViewMenu', { sections }),
setAppProgress: (progress) => invokeRequest('setAppProgress', progress),
getAppVersion: () => invokeRequest('getAppVersion'), getAppVersion: () => invokeRequest('getAppVersion'),
getAppEngine: () => invokeRequest('getAppEngine'), getAppEngine: () => invokeRequest('getAppEngine'),
syncWindowTabs: (payload) => invokeRequest('syncWindowTabs', payload), syncWindowTabs: (payload) => invokeRequest('syncWindowTabs', payload),