Skip to content

Commit 45d1c0a

Browse files
authored
fix(desktop): run the macOS Host from the helper so it takes no Dock tile (#149)
## Related Issue No issue — reported directly: a second, unnamed icon appears in the macOS Dock whenever the desktop app is running. ## Problem The packaged app starts its Host by re-executing its own binary (`Pythinker.app/Contents/MacOS/Pythinker`). macOS registers that child with LaunchServices as a second **foreground** application under the same bundle id, so it takes a Dock tile of its own. A bare executable has no icon, so the tile renders with the generic Unix-executable artwork next to the real app icon. `ELECTRON_RUN_AS_NODE=1` is already set and is not the missing piece: it stops the child from becoming a browser process, but it does not affect LaunchServices registration. Observed on the installed 0.1.5 build: ``` 90) "pythinker-code" bundleID="com.pythinker.desktop" executable path="/Applications/Pythinker.app/Contents/MacOS/Pythinker" pid = 83782 type="Foreground" parentASN="Pythinker" (inferred) ``` ## What changed `resolveHostExecutable` picks the bundled Electron helper (`Contents/Frameworks/<name> Helper.app`) as the Host runtime on macOS. The helper declares `LSUIElement`, so it registers as a UI element rather than a foreground app and takes no Dock tile, while running the identical Node runtime. It falls back to `process.execPath` when the bundle ships no matching helper, and non-macOS platforms are returned unchanged. Verified against the installed bundle: ``` same script via the app binary → type="Foreground" (Dock tile) same script via the helper → type="UIElement" (no Dock tile) helper as a Node runtime → node v24.18.1, electron 43.4.0 ``` ## Checklist - [x] I have read the [CONTRIBUTING](https://github.com/PyModel/pythinker-code/blob/main/CONTRIBUTING.md) document. - [ ] I have linked a related issue (external PRs: the issue must have a maintainer's `/approve`). - [x] I have added tests that prove my feature works. — 3 cases in `apps/desktop/tests/host-supervisor.spec.ts`; reverting the fix turns the first one red. - [x] Ran `gen-changesets` skill, or this PR needs no changeset. - [x] Ran `gen-docs` skill, or this PR needs no doc update. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **Bug Fixes** - Fixed a macOS Dock issue that could display a second unnamed Pythinker icon while the app was running. - Improved packaged desktop startup behavior across macOS, Windows, and Linux. - **Changes** - Removed managed Kimi update checks, marketplace defaults, official plugin badges, tips, and sign-up links. - Kimi remains available as a model provider through OAuth or an API key; marketplace access requires a configured marketplace URL. - Updated activity card wording to “subagent.” <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 9e372f0 commit 45d1c0a

4 files changed

Lines changed: 87 additions & 1 deletion

File tree

.changeset/desktop-dock-tile.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-desktop": patch
3+
---
4+
5+
Stop a second, unnamed Pythinker icon appearing in the macOS Dock while the app runs.

apps/desktop/src/host-supervisor.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/** Supervise the loopback Web Host used by the first desktop application. */
22

33
import { spawn, spawnSync, type ChildProcessByStdio } from 'node:child_process'
4+
import { basename, join } from 'node:path'
45
import type { Readable } from 'node:stream'
56

67
const READINESS_PREFIX = 'Pythinker server: '
@@ -312,6 +313,31 @@ export function createHostSupervisor(options: HostSupervisorOptions): HostSuperv
312313
return { start, shutdown }
313314
}
314315

316+
/**
317+
* Resolve the executable that runs the Host on a packaged macOS app.
318+
*
319+
* The app must not re-exec its own main binary. LaunchServices registers that
320+
* child as a second `Foreground` application under the same bundle id, so it
321+
* takes a Dock tile of its own — drawn with the generic Unix-executable icon,
322+
* since a bare executable has no icon to show. `ELECTRON_RUN_AS_NODE` stops the
323+
* child from becoming a browser process but does not stop that registration.
324+
* The bundled Electron helper declares `LSUIElement`, so it runs the very same
325+
* Node runtime with no Dock tile and no second app.
326+
* @param options - Platform, the app's own executable, its `Frameworks` directory, and an existence probe.
327+
* @returns The helper executable when the bundle ships one, otherwise `execPath` unchanged.
328+
*/
329+
export function resolveHostExecutable(options: {
330+
readonly platform: string
331+
readonly execPath: string
332+
readonly frameworksPath: string
333+
readonly exists: (path: string) => boolean
334+
}): string {
335+
if (options.platform !== 'darwin') return options.execPath
336+
const name = basename(options.execPath)
337+
const helper = join(options.frameworksPath, `${name} Helper.app`, 'Contents', 'MacOS', `${name} Helper`)
338+
return options.exists(helper) ? helper : options.execPath
339+
}
340+
315341
/** Options for the real Pythinker server child. */
316342
export interface SpawnPythinkerServerOptions {
317343
/** Node-compatible executable selected by the desktop app. */

apps/desktop/src/main.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
isPortInUseError,
3131
parseRunningServerConflict,
3232
resolveDesktopPort,
33+
resolveHostExecutable,
3334
spawnPythinkerServer,
3435
type HostSupervisor,
3536
} from './host-supervisor'
@@ -112,7 +113,12 @@ function hostPaths(): { nodeExecutable: string; cliEntry: string; cwd: string; e
112113
}
113114
}
114115
return {
115-
nodeExecutable: process.execPath,
116+
nodeExecutable: resolveHostExecutable({
117+
platform: process.platform,
118+
execPath: process.execPath,
119+
frameworksPath: join(process.resourcesPath, '..', 'Frameworks'),
120+
exists: existsSync,
121+
}),
116122
cliEntry: join(process.resourcesPath, 'host/node_modules/@pymodel/pythinker-code/dist/main.mjs'),
117123
cwd: app.getPath('home'),
118124
electronRunAsNode: true,

apps/desktop/tests/host-supervisor.spec.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
33
import {
44
createHostSupervisor,
55
createReadinessParser,
6+
resolveHostExecutable,
67
type HostChild,
78
} from '../src/host-supervisor'
89
import * as hostSupervisor from '../src/host-supervisor'
@@ -509,3 +510,51 @@ describe('desktop Host process', () => {
509510
expect(spawnSync).not.toHaveBeenCalled()
510511
})
511512
})
513+
514+
describe('resolveHostExecutable', () => {
515+
const APP = '/Applications/Pythinker.app/Contents/MacOS/Pythinker'
516+
const FRAMEWORKS = '/Applications/Pythinker.app/Contents/Frameworks'
517+
const HELPER = `${FRAMEWORKS}/Pythinker Helper.app/Contents/MacOS/Pythinker Helper`
518+
519+
it('runs the Host from the LSUIElement helper so it takes no Dock tile of its own', () => {
520+
// Re-execing the app's own binary registers a second Foreground app under
521+
// the same bundle id, which shows up as a stray generic-executable icon in
522+
// the Dock next to the real app.
523+
const seen: string[] = []
524+
const resolved = resolveHostExecutable({
525+
platform: 'darwin',
526+
execPath: APP,
527+
frameworksPath: FRAMEWORKS,
528+
exists: path => {
529+
seen.push(path)
530+
return path === HELPER
531+
},
532+
})
533+
expect(resolved).toBe(HELPER)
534+
expect(seen).toEqual([HELPER])
535+
})
536+
537+
it('keeps the app executable when the bundle ships no matching helper', () => {
538+
expect(
539+
resolveHostExecutable({
540+
platform: 'darwin',
541+
execPath: APP,
542+
frameworksPath: FRAMEWORKS,
543+
exists: () => false,
544+
}),
545+
).toBe(APP)
546+
})
547+
548+
it('leaves non-macOS platforms alone', () => {
549+
for (const platform of ['win32', 'linux']) {
550+
expect(
551+
resolveHostExecutable({
552+
platform,
553+
execPath: 'C:\\Program Files\\Pythinker\\Pythinker.exe',
554+
frameworksPath: 'C:\\Program Files\\Pythinker\\Frameworks',
555+
exists: () => true,
556+
}),
557+
).toBe('C:\\Program Files\\Pythinker\\Pythinker.exe')
558+
}
559+
})
560+
})

0 commit comments

Comments
 (0)