diff --git a/.changeset/desktop-dock-tile.md b/.changeset/desktop-dock-tile.md new file mode 100644 index 00000000..9f1977aa --- /dev/null +++ b/.changeset/desktop-dock-tile.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-desktop": patch +--- + +Stop a second, unnamed Pythinker icon appearing in the macOS Dock while the app runs. diff --git a/.changeset/remove-managed-kimi-endpoints.md b/.changeset/remove-managed-kimi-endpoints.md index ccda57f9..232e5b63 100644 --- a/.changeset/remove-managed-kimi-endpoints.md +++ b/.changeset/remove-managed-kimi-endpoints.md @@ -1,5 +1,5 @@ --- -"@pymodel/pythinker-code": minor +"@pymodel/pythinker-code": major --- Remove the hosted self-update checks, default plugin marketplace catalog, official plugin badges, tips banner, and sign-up links; Kimi now serves only as a model provider through OAuth or an API key. Set PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL to keep using a plugin catalog. diff --git a/.changeset/subagent-execution-inspector.md b/.changeset/subagent-execution-inspector.md index 6aef96eb..a25fcd78 100644 --- a/.changeset/subagent-execution-inspector.md +++ b/.changeset/subagent-execution-inspector.md @@ -2,4 +2,4 @@ "@pymodel/pythinker-code": patch --- -Allow sub agent activity cards to open their live execution transcript. +Allow subagent activity cards to open their live execution transcript. diff --git a/apps/desktop/src/host-supervisor.ts b/apps/desktop/src/host-supervisor.ts index 66e3aa4a..58d1f9cf 100644 --- a/apps/desktop/src/host-supervisor.ts +++ b/apps/desktop/src/host-supervisor.ts @@ -1,6 +1,7 @@ /** Supervise the loopback Web Host used by the first desktop application. */ import { spawn, spawnSync, type ChildProcessByStdio } from 'node:child_process' +import { basename, join } from 'node:path' import type { Readable } from 'node:stream' const READINESS_PREFIX = 'Pythinker server: ' @@ -312,6 +313,31 @@ export function createHostSupervisor(options: HostSupervisorOptions): HostSuperv return { start, shutdown } } +/** + * Resolve the executable that runs the Host on a packaged macOS app. + * + * The app must not re-exec its own main binary. LaunchServices registers that + * child as a second `Foreground` application under the same bundle id, so it + * takes a Dock tile of its own — drawn with the generic Unix-executable icon, + * since a bare executable has no icon to show. `ELECTRON_RUN_AS_NODE` stops the + * child from becoming a browser process but does not stop that registration. + * The bundled Electron helper declares `LSUIElement`, so it runs the very same + * Node runtime with no Dock tile and no second app. + * @param options - Platform, the app's own executable, its `Frameworks` directory, and an existence probe. + * @returns The helper executable when the bundle ships one, otherwise `execPath` unchanged. + */ +export function resolveHostExecutable(options: { + readonly platform: string + readonly execPath: string + readonly frameworksPath: string + readonly exists: (path: string) => boolean +}): string { + if (options.platform !== 'darwin') return options.execPath + const name = basename(options.execPath) + const helper = join(options.frameworksPath, `${name} Helper.app`, 'Contents', 'MacOS', `${name} Helper`) + return options.exists(helper) ? helper : options.execPath +} + /** Options for the real Pythinker server child. */ export interface SpawnPythinkerServerOptions { /** Node-compatible executable selected by the desktop app. */ diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 581f4ed1..8c5bb96a 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -30,6 +30,7 @@ import { isPortInUseError, parseRunningServerConflict, resolveDesktopPort, + resolveHostExecutable, spawnPythinkerServer, type HostSupervisor, } from './host-supervisor' @@ -112,7 +113,12 @@ function hostPaths(): { nodeExecutable: string; cliEntry: string; cwd: string; e } } return { - nodeExecutable: process.execPath, + nodeExecutable: resolveHostExecutable({ + platform: process.platform, + execPath: process.execPath, + frameworksPath: join(process.resourcesPath, '..', 'Frameworks'), + exists: existsSync, + }), cliEntry: join(process.resourcesPath, 'host/node_modules/@pymodel/pythinker-code/dist/main.mjs'), cwd: app.getPath('home'), electronRunAsNode: true, diff --git a/apps/desktop/tests/host-supervisor.spec.ts b/apps/desktop/tests/host-supervisor.spec.ts index 96f26f32..0729b936 100644 --- a/apps/desktop/tests/host-supervisor.spec.ts +++ b/apps/desktop/tests/host-supervisor.spec.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { createHostSupervisor, createReadinessParser, + resolveHostExecutable, type HostChild, } from '../src/host-supervisor' import * as hostSupervisor from '../src/host-supervisor' @@ -509,3 +510,51 @@ describe('desktop Host process', () => { expect(spawnSync).not.toHaveBeenCalled() }) }) + +describe('resolveHostExecutable', () => { + const APP = '/Applications/Pythinker.app/Contents/MacOS/Pythinker' + const FRAMEWORKS = '/Applications/Pythinker.app/Contents/Frameworks' + const HELPER = `${FRAMEWORKS}/Pythinker Helper.app/Contents/MacOS/Pythinker Helper` + + it('runs the Host from the LSUIElement helper so it takes no Dock tile of its own', () => { + // Re-execing the app's own binary registers a second Foreground app under + // the same bundle id, which shows up as a stray generic-executable icon in + // the Dock next to the real app. + const seen: string[] = [] + const resolved = resolveHostExecutable({ + platform: 'darwin', + execPath: APP, + frameworksPath: FRAMEWORKS, + exists: path => { + seen.push(path) + return path === HELPER + }, + }) + expect(resolved).toBe(HELPER) + expect(seen).toEqual([HELPER]) + }) + + it('keeps the app executable when the bundle ships no matching helper', () => { + expect( + resolveHostExecutable({ + platform: 'darwin', + execPath: APP, + frameworksPath: FRAMEWORKS, + exists: () => false, + }), + ).toBe(APP) + }) + + it('leaves non-macOS platforms alone', () => { + for (const platform of ['win32', 'linux']) { + expect( + resolveHostExecutable({ + platform, + execPath: 'C:\\Program Files\\Pythinker\\Pythinker.exe', + frameworksPath: 'C:\\Program Files\\Pythinker\\Frameworks', + exists: () => true, + }), + ).toBe('C:\\Program Files\\Pythinker\\Pythinker.exe') + } + }) +})