Add Dock Progress Indicator and Integrate with Electron Context
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
This commit is contained in:
parent
ae8d4532c0
commit
672e3c59ac
@ -513,3 +513,104 @@ extern "C" bool setNativeWindowDragRegion(void *windowPtr, double x,
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@ -13,6 +13,7 @@ import { theme, Popover } from 'antd'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { ApiServerContext } from '../context/ApiServerContext'
|
||||
import { AuthContext } from '../context/AuthContext'
|
||||
import { ElectronContext } from '../context/ElectronContext'
|
||||
import { getModelByName } from '../../../database/ObjectModels'
|
||||
import { getStateTagInfo } from '../utils/Utils'
|
||||
import SpotlightTooltip from './SpotlightTooltip'
|
||||
@ -259,6 +260,7 @@ const LiveActivities = () => {
|
||||
subscribeToObjectTypeUpdates,
|
||||
subscribeToObjectUpdates
|
||||
} = useContext(ApiServerContext)
|
||||
const { isElectron, setAppProgress } = useContext(ElectronContext)
|
||||
const [watched, setWatched] = useState([])
|
||||
|
||||
const userId = userProfile?._id
|
||||
@ -496,6 +498,26 @@ const LiveActivities = () => {
|
||||
|
||||
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(() => {
|
||||
const seq = ++faviconCaptureSeq.current
|
||||
let cancelled = false
|
||||
|
||||
@ -365,6 +365,15 @@ const ElectronProvider = ({ children }) => {
|
||||
[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 () => {
|
||||
if (!electronAvailable) return null
|
||||
return await desktopBridge.getAppVersion()
|
||||
@ -402,6 +411,7 @@ const ElectronProvider = ({ children }) => {
|
||||
setToken,
|
||||
resizeSpotlightWindow,
|
||||
setSidebarViewMenu,
|
||||
setAppProgress,
|
||||
getElectronVersion,
|
||||
getAppEngine,
|
||||
registerTabHistoryHandler,
|
||||
|
||||
69
src/desktop/app-progress.js
Normal file
69
src/desktop/app-progress.js
Normal 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()
|
||||
}
|
||||
}
|
||||
@ -45,7 +45,17 @@ function resolveDylibPath() {
|
||||
return null
|
||||
}
|
||||
|
||||
let loadedLibrary
|
||||
|
||||
function loadMacWindowEffectsLibrary() {
|
||||
if (loadedLibrary !== undefined) {
|
||||
return loadedLibrary
|
||||
}
|
||||
loadedLibrary = openMacWindowEffectsLibrary()
|
||||
return loadedLibrary
|
||||
}
|
||||
|
||||
function openMacWindowEffectsLibrary() {
|
||||
const dylibPath = resolveDylibPath()
|
||||
if (!dylibPath) {
|
||||
console.warn(
|
||||
@ -77,6 +87,10 @@ function loadMacWindowEffectsLibrary() {
|
||||
setNativeWindowDragRegion: {
|
||||
args: [FFIType.ptr, FFIType.f64, FFIType.f64],
|
||||
returns: FFIType.bool
|
||||
},
|
||||
setDockProgress: {
|
||||
args: [FFIType.f64, FFIType.i32],
|
||||
returns: FFIType.bool
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -104,6 +118,28 @@ function applyTrafficLightsPosition(lib, mainWindow, attempt = 0) {
|
||||
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) {
|
||||
if (process.platform !== 'darwin' || !mainWindow?.ptr) {
|
||||
return
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { BrowserView } from 'electrobun/bun'
|
||||
import { setAppProgress } from './app-progress.js'
|
||||
import {
|
||||
checkForCompletedUpdate,
|
||||
getRunningAppVersion,
|
||||
@ -20,7 +21,9 @@ import {
|
||||
} from './store.js'
|
||||
import {
|
||||
createAppWindow,
|
||||
getFocusedWindowId,
|
||||
getMainWindow,
|
||||
getWindowById,
|
||||
getWindowSession,
|
||||
getWindowState,
|
||||
handleWindowControl,
|
||||
@ -103,6 +106,15 @@ export function createAppRpc() {
|
||||
setSidebarViewMenu: async ({ sections }) => ({
|
||||
ok: setSidebarViewMenu(sections)
|
||||
}),
|
||||
setAppProgress: async ({ windowId, percent, status } = {}) => {
|
||||
const targetWindowId = windowId || getFocusedWindowId()
|
||||
return {
|
||||
ok: setAppProgress(targetWindowId, getWindowById(targetWindowId), {
|
||||
percent,
|
||||
status
|
||||
})
|
||||
}
|
||||
},
|
||||
checkAppUpdateResult: async () =>
|
||||
checkForCompletedUpdate(getMainWindow()),
|
||||
checkDuplicateInstallations: async () => checkForDuplicateInstallations(),
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import Electrobun, { BrowserWindow, Updater, Utils } from 'electrobun/bun'
|
||||
import { clearAppProgress } from './app-progress.js'
|
||||
import { applyApplicationMenu, setupApplicationMenuEvents } from './menu.js'
|
||||
import {
|
||||
applyMacOSWindowEffects,
|
||||
@ -447,6 +448,7 @@ function setupWindowEvents(window, windowId) {
|
||||
})
|
||||
window.on?.('close', () => {
|
||||
captureWindowLayout(windowId)
|
||||
clearAppProgress(windowId)
|
||||
windows.delete(windowId)
|
||||
if (focusedWindowId === windowId) {
|
||||
focusedWindowId = getFocusedWindowId()
|
||||
|
||||
142
src/desktop/windows-taskbar-progress.js
Normal file
142
src/desktop/windows-taskbar-progress.js
Normal 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
|
||||
}
|
||||
}
|
||||
@ -263,6 +263,7 @@ const electronAPI = {
|
||||
invokeRequest('resizeSpotlightWindow', { height }),
|
||||
setSidebarViewMenu: (sections) =>
|
||||
invokeRequest('setSidebarViewMenu', { sections }),
|
||||
setAppProgress: (progress) => invokeRequest('setAppProgress', progress),
|
||||
getAppVersion: () => invokeRequest('getAppVersion'),
|
||||
getAppEngine: () => invokeRequest('getAppEngine'),
|
||||
syncWindowTabs: (payload) => invokeRequest('syncWindowTabs', payload),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user