From e5e5eb1518458051011442ef942f688561e7368b Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Tue, 28 Jul 2026 16:09:32 +0100 Subject: [PATCH 1/4] fix: re-hide macos dock icon after parentless dialogs --- src/main/lifecycle/first-run.test.ts | 24 +++++++++++++++++++++++- src/main/lifecycle/first-run.ts | 4 ++++ src/main/updater.test.ts | 24 +++++++++++++++++++++++- src/main/updater.ts | 9 ++++++++- 4 files changed, 58 insertions(+), 3 deletions(-) diff --git a/src/main/lifecycle/first-run.test.ts b/src/main/lifecycle/first-run.test.ts index c95d09c79..cef342395 100644 --- a/src/main/lifecycle/first-run.test.ts +++ b/src/main/lifecycle/first-run.test.ts @@ -19,6 +19,7 @@ vi.mock('node:fs', () => ({ const moveToApplicationsFolderMock = vi.fn(); const isInApplicationsFolderMock = vi.fn(() => false); const getPathMock = vi.fn(() => '/User/Data'); +const dockHideMock = vi.fn(); const showMessageBoxMock = vi.fn(async () => ({ response: 0, @@ -30,7 +31,15 @@ vi.mock('electron', () => ({ getPath: () => getPathMock(), isInApplicationsFolder: () => isInApplicationsFolderMock(), moveToApplicationsFolder: () => moveToApplicationsFolderMock(), - } satisfies Pick, + dock: { + hide: () => dockHideMock(), + }, + } satisfies Pick< + Electron.App, + 'getPath' | 'isInApplicationsFolder' | 'moveToApplicationsFolder' + > & { + dock: { hide: () => void }; + }, dialog: { showMessageBox: () => showMessageBoxMock(), } satisfies Pick, @@ -125,6 +134,19 @@ describe('main/lifecycle/first-run', () => { expect(moveToApplicationsFolderMock).not.toHaveBeenCalled(); }); + it('re-hides the dock icon after the prompt closes (#3069)', async () => { + existsSyncMock.mockReturnValueOnce(false); + existsSyncMock.mockReturnValueOnce(false); + showMessageBoxMock.mockResolvedValueOnce({ + response: 1, + checkboxChecked: false, + }); + + await onFirstRunMaybe(); + + expect(dockHideMock).toHaveBeenCalled(); + }); + it('does not prompt on non-macOS', async () => { mac = false; existsSyncMock.mockReturnValueOnce(false); diff --git a/src/main/lifecycle/first-run.ts b/src/main/lifecycle/first-run.ts index 585a86f0f..570fd32a0 100644 --- a/src/main/lifecycle/first-run.ts +++ b/src/main/lifecycle/first-run.ts @@ -38,6 +38,10 @@ async function promptMoveToApplicationsFolder() { message: 'Move to Applications Folder?', }); + // Parentless dialogs pull a dock-hidden (accessory) app into the Dock and + // macOS does not restore the accessory policy on close; re-hide (#3069). + app.dock?.hide(); + if (response === 0) { app.moveToApplicationsFolder(); } diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts index 18e496dac..a583d8c47 100644 --- a/src/main/updater.test.ts +++ b/src/main/updater.test.ts @@ -1,4 +1,4 @@ -import { dialog } from 'electron'; +import { app, dialog } from 'electron'; import type { Menubar } from 'electron-menubar'; import { APPLICATION } from '../shared/constants'; @@ -45,6 +45,11 @@ vi.mock('electron', () => { } } return { + app: { + dock: { + hide: vi.fn(), + }, + }, dialog: { showMessageBox: vi.fn(), } satisfies Pick, @@ -139,6 +144,23 @@ describe('main/updater.ts', () => { expect(autoUpdater.quitAndInstall).toHaveBeenCalled(); }); + + it('re-hides the dock icon when user dismisses the dialog (#3069)', async () => { + vi.mocked(dialog.showMessageBox).mockResolvedValue({ + response: 1, // "Later" button index + checkboxChecked: false, + }); + + await updater.start(); + + emit('update-downloaded', { releaseName: 'v9.9.9' }); + + // Allow then() of showMessageBox promise to resolve + await Promise.resolve(); + + expect(autoUpdater.quitAndInstall).not.toHaveBeenCalled(); + expect(app.dock?.hide).toHaveBeenCalled(); + }); }); describe('update event handlers & scheduling', () => { diff --git a/src/main/updater.ts b/src/main/updater.ts index 214014d84..a0c27a180 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -1,4 +1,4 @@ -import { dialog, type MessageBoxOptions } from 'electron'; +import { app, dialog, type MessageBoxOptions } from 'electron'; import type { Menubar } from 'electron-menubar'; import { autoUpdater } from 'electron-updater'; @@ -200,7 +200,14 @@ export default class AppUpdater { dialog.showMessageBox(dialogOpts).then((returnValue) => { if (returnValue.response === 0) { autoUpdater.quitAndInstall(); + return; } + + // Presenting a parentless dialog forces a dock-hidden (accessory) macOS + // app to activate into the Dock, and the accessory policy is not + // restored when the dialog closes. Re-assert it so the dock icon + // disappears again (#3069). + app.dock?.hide(); }); } } From 445100af946011408c4f35808c16a08170d3b951 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Tue, 28 Jul 2026 16:30:18 +0100 Subject: [PATCH 2/4] fix: re-assert hidden dock after startup hide race --- src/main/lifecycle/startup.test.ts | 41 +++++++++++++++++++++++++++++- src/main/lifecycle/startup.ts | 14 ++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/main/lifecycle/startup.test.ts b/src/main/lifecycle/startup.test.ts index f81f9a66f..b9ade4825 100644 --- a/src/main/lifecycle/startup.test.ts +++ b/src/main/lifecycle/startup.test.ts @@ -7,13 +7,21 @@ import { handleProtocolURL, initializeAppLifecycle } from './startup'; const requestSingleInstanceLockMock = vi.fn(() => true); const appOnMock = vi.fn(); const appQuitMock = vi.fn(); +const dockIsVisibleMock = vi.fn(() => false); +const dockHideMock = vi.fn(); vi.mock('electron', () => ({ app: { requestSingleInstanceLock: () => requestSingleInstanceLockMock(), on: (...a: unknown[]) => appOnMock(...a), quit: () => appQuitMock(), - } satisfies Pick, + dock: { + isVisible: () => dockIsVisibleMock(), + hide: () => dockHideMock(), + }, + } satisfies Pick & { + dock: { isVisible: () => boolean; hide: () => void }; + }, })); const sendRendererEventMock = vi.fn(); @@ -74,6 +82,37 @@ describe('main/lifecycle/startup.ts', () => { expect(mb.setContextMenu).toHaveBeenCalledWith(contextMenu); }); + + it('re-hides the dock icon when the startup hide was dropped (#3069)', () => { + vi.useFakeTimers(); + dockIsVisibleMock.mockReturnValue(true); + const mb = createMb(); + + initializeAppLifecycle(mb as unknown as Menubar, {} as Electron.Menu, 'gitify'); + const readyHandler = (mb.on as unknown as ReturnType).mock.calls[0]?.[1]; + readyHandler?.(); + + expect(dockHideMock).not.toHaveBeenCalled(); + vi.advanceTimersByTime(2_000); + expect(dockHideMock).toHaveBeenCalled(); + + vi.useRealTimers(); + }); + + it('leaves the dock alone when it is already hidden', () => { + vi.useFakeTimers(); + dockIsVisibleMock.mockReturnValue(false); + const mb = createMb(); + + initializeAppLifecycle(mb as unknown as Menubar, {} as Electron.Menu, 'gitify'); + const readyHandler = (mb.on as unknown as ReturnType).mock.calls[0]?.[1]; + readyHandler?.(); + + vi.advanceTimersByTime(2_000); + expect(dockHideMock).not.toHaveBeenCalled(); + + vi.useRealTimers(); + }); }); describe('handleProtocolURL', () => { diff --git a/src/main/lifecycle/startup.ts b/src/main/lifecycle/startup.ts index e80a38f71..15aabe90e 100644 --- a/src/main/lifecycle/startup.ts +++ b/src/main/lifecycle/startup.ts @@ -7,6 +7,9 @@ import { logInfo, logWarn } from '../../shared/logger'; import { sendRendererEvent } from '../events'; +/** Delay before re-asserting the hidden dock after startup (see #3069). */ +const DOCK_REHIDE_DELAY_MS = 2_000; + /** * Set up core application lifecycle events including tray ready setup, * protocol URL handling, and single-instance enforcement. @@ -29,6 +32,17 @@ export function initializeAppLifecycle( mb.app.setAppUserModelId(APPLICATION.ID); mb.tray.setToolTip(APPLICATION.NAME); mb.setContextMenu(contextMenu); + + // macOS can silently drop `app.dock.hide()` when it races an app + // activation transition, and `electron-menubar` calls it very early in + // startup. If that hide was swallowed, the dock icon sticks around for + // the whole session (#3069). Re-check once startup has settled; guarded + // on visibility because `dock.hide()` also deactivates the app. + setTimeout(() => { + if (app.dock?.isVisible()) { + app.dock.hide(); + } + }, DOCK_REHIDE_DELAY_MS); }); preventSecondInstance(mb, protocol); From ae09d9c50bcc37f460a13cf884de3c49b7648e8d Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 29 Jul 2026 13:09:10 +0200 Subject: [PATCH 3/4] chore(deps): update electron-menubar to v10.1.4 --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- pnpm-workspace.yaml | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index f14246498..315ed6e5e 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,7 @@ }, "dependencies": { "electron-log": "5.4.4", - "electron-menubar": "10.1.3", + "electron-menubar": "10.1.4", "electron-updater": "6.8.9", "react": "19.2.8", "react-dom": "19.2.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e9c64bc15..9b91ae5a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ importers: specifier: 5.4.4 version: 5.4.4 electron-menubar: - specifier: 10.1.3 - version: 10.1.3(electron@43.2.0(supports-color@10.2.2)) + specifier: 10.1.4 + version: 10.1.4(electron@43.2.0(supports-color@10.2.2)) electron-updater: specifier: 6.8.9 version: 6.8.9(supports-color@10.2.2) @@ -2559,8 +2559,8 @@ packages: resolution: {integrity: sha512-istWgaXjBfURBSS8LWVW9C3jsc6+ac+tY1lXrQEOTp0lVj+a4OlO1Tmqb36GgnEUDv92DGC9VI1HNXwJinWpgA==} engines: {node: '>= 14'} - electron-menubar@10.1.3: - resolution: {integrity: sha512-5XRQCmYCiObJDvjROSGP3+MX/7AEA3qp5mVUtpo/CfOGCkhNA6q1dvhKnPC8B2+aKCJDjBQBMhgVW8pMMbedCQ==} + electron-menubar@10.1.4: + resolution: {integrity: sha512-Ly3jNCr1IKEric9iP9w2G5oCZ7lxJCCrH6EXVKrY2JkzQUcAEUduMgJYciE1sewwhcmlzZHGq/1mOlfdRlHr6w==} peerDependencies: electron: 43.2.0 @@ -6849,7 +6849,7 @@ snapshots: electron-log@5.4.4: {} - electron-menubar@10.1.3(electron@43.2.0(supports-color@10.2.2)): + electron-menubar@10.1.4(electron@43.2.0(supports-color@10.2.2)): dependencies: electron: 43.2.0(supports-color@10.2.2) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3710df06d..6f0d979be 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -10,9 +10,9 @@ allowBuilds: catalog: electron: '43.2.0' # Adopt this freshly published, first-party release ahead of pnpm's default -# minimumReleaseAge gate; it carries the Windows tray click fix (#3064). +# minimumReleaseAge gate; it carries the macOS dock icon fix (#3069). minimumReleaseAgeExclude: - - electron-menubar@10.1.3 + - electron-menubar@10.1.4 overrides: react-is: '19.2.8' # Force patched versions of transitive dev-tooling deps pulled in by From c6ac955f7cccd13a3ebacb9ecf4a672fdc3ffecf Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 29 Jul 2026 13:09:12 +0200 Subject: [PATCH 4/4] refactor: drop startup dock re-assert now owned by electron-menubar --- src/main/lifecycle/startup.test.ts | 41 +----------------------------- src/main/lifecycle/startup.ts | 14 ---------- 2 files changed, 1 insertion(+), 54 deletions(-) diff --git a/src/main/lifecycle/startup.test.ts b/src/main/lifecycle/startup.test.ts index b9ade4825..f81f9a66f 100644 --- a/src/main/lifecycle/startup.test.ts +++ b/src/main/lifecycle/startup.test.ts @@ -7,21 +7,13 @@ import { handleProtocolURL, initializeAppLifecycle } from './startup'; const requestSingleInstanceLockMock = vi.fn(() => true); const appOnMock = vi.fn(); const appQuitMock = vi.fn(); -const dockIsVisibleMock = vi.fn(() => false); -const dockHideMock = vi.fn(); vi.mock('electron', () => ({ app: { requestSingleInstanceLock: () => requestSingleInstanceLockMock(), on: (...a: unknown[]) => appOnMock(...a), quit: () => appQuitMock(), - dock: { - isVisible: () => dockIsVisibleMock(), - hide: () => dockHideMock(), - }, - } satisfies Pick & { - dock: { isVisible: () => boolean; hide: () => void }; - }, + } satisfies Pick, })); const sendRendererEventMock = vi.fn(); @@ -82,37 +74,6 @@ describe('main/lifecycle/startup.ts', () => { expect(mb.setContextMenu).toHaveBeenCalledWith(contextMenu); }); - - it('re-hides the dock icon when the startup hide was dropped (#3069)', () => { - vi.useFakeTimers(); - dockIsVisibleMock.mockReturnValue(true); - const mb = createMb(); - - initializeAppLifecycle(mb as unknown as Menubar, {} as Electron.Menu, 'gitify'); - const readyHandler = (mb.on as unknown as ReturnType).mock.calls[0]?.[1]; - readyHandler?.(); - - expect(dockHideMock).not.toHaveBeenCalled(); - vi.advanceTimersByTime(2_000); - expect(dockHideMock).toHaveBeenCalled(); - - vi.useRealTimers(); - }); - - it('leaves the dock alone when it is already hidden', () => { - vi.useFakeTimers(); - dockIsVisibleMock.mockReturnValue(false); - const mb = createMb(); - - initializeAppLifecycle(mb as unknown as Menubar, {} as Electron.Menu, 'gitify'); - const readyHandler = (mb.on as unknown as ReturnType).mock.calls[0]?.[1]; - readyHandler?.(); - - vi.advanceTimersByTime(2_000); - expect(dockHideMock).not.toHaveBeenCalled(); - - vi.useRealTimers(); - }); }); describe('handleProtocolURL', () => { diff --git a/src/main/lifecycle/startup.ts b/src/main/lifecycle/startup.ts index 15aabe90e..e80a38f71 100644 --- a/src/main/lifecycle/startup.ts +++ b/src/main/lifecycle/startup.ts @@ -7,9 +7,6 @@ import { logInfo, logWarn } from '../../shared/logger'; import { sendRendererEvent } from '../events'; -/** Delay before re-asserting the hidden dock after startup (see #3069). */ -const DOCK_REHIDE_DELAY_MS = 2_000; - /** * Set up core application lifecycle events including tray ready setup, * protocol URL handling, and single-instance enforcement. @@ -32,17 +29,6 @@ export function initializeAppLifecycle( mb.app.setAppUserModelId(APPLICATION.ID); mb.tray.setToolTip(APPLICATION.NAME); mb.setContextMenu(contextMenu); - - // macOS can silently drop `app.dock.hide()` when it races an app - // activation transition, and `electron-menubar` calls it very early in - // startup. If that hide was swallowed, the dock icon sticks around for - // the whole session (#3069). Re-check once startup has settled; guarded - // on visibility because `dock.hide()` also deactivates the app. - setTimeout(() => { - if (app.dock?.isVisible()) { - app.dock.hide(); - } - }, DOCK_REHIDE_DELAY_MS); }); preventSecondInstance(mb, protocol);