diff --git a/native/macos/window-effects.mm b/native/macos/window-effects.mm index 04ddaacb..a9283b1b 100644 --- a/native/macos/window-effects.mm +++ b/native/macos/window-effects.mm @@ -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; +} diff --git a/src/components/Dashboard/common/LiveActivities.jsx b/src/components/Dashboard/common/LiveActivities.jsx index ee49df54..a6c77a5a 100644 --- a/src/components/Dashboard/common/LiveActivities.jsx +++ b/src/components/Dashboard/common/LiveActivities.jsx @@ -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 diff --git a/src/components/Dashboard/context/ElectronContext.jsx b/src/components/Dashboard/context/ElectronContext.jsx index 5ab2e26a..e10561f6 100644 --- a/src/components/Dashboard/context/ElectronContext.jsx +++ b/src/components/Dashboard/context/ElectronContext.jsx @@ -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, diff --git a/src/desktop/app-progress.js b/src/desktop/app-progress.js new file mode 100644 index 00000000..3529737b --- /dev/null +++ b/src/desktop/app-progress.js @@ -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() + } +} diff --git a/src/desktop/macos-window-effects.js b/src/desktop/macos-window-effects.js index aafa3ccd..883f4b59 100644 --- a/src/desktop/macos-window-effects.js +++ b/src/desktop/macos-window-effects.js @@ -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 diff --git a/src/desktop/rpc.js b/src/desktop/rpc.js index 43cc91bc..be431c36 100644 --- a/src/desktop/rpc.js +++ b/src/desktop/rpc.js @@ -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(), diff --git a/src/desktop/window.js b/src/desktop/window.js index 058b988a..5ad7e1c3 100644 --- a/src/desktop/window.js +++ b/src/desktop/window.js @@ -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() diff --git a/src/desktop/windows-taskbar-progress.js b/src/desktop/windows-taskbar-progress.js new file mode 100644 index 00000000..0b590bdd --- /dev/null +++ b/src/desktop/windows-taskbar-progress.js @@ -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 + } +} diff --git a/src/electrobun-bridge.js b/src/electrobun-bridge.js index e77029ec..7f36e64c 100644 --- a/src/electrobun-bridge.js +++ b/src/electrobun-bridge.js @@ -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),