Skip to content

Commit ac3e85b

Browse files
committed
feat: enhance Codex GUI client adapter with AppX detection and fallback config path resolution
1 parent 4be9e54 commit ac3e85b

41 files changed

Lines changed: 2724 additions & 261 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/main/clients/__tests__/codex-gui.adapter.test.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,19 @@
1010
* @description Unit tests for the Codex GUI adapter.
1111
*/
1212

13-
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
13+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
1414
import { mkdirSync, writeFileSync, rmSync } from 'fs'
1515
import { join } from 'path'
1616
import { tmpdir } from 'os'
17+
18+
const { execFileSyncMock } = vi.hoisted(() => ({
19+
execFileSyncMock: vi.fn(),
20+
}))
21+
22+
vi.mock('child_process', () => ({
23+
execFileSync: execFileSyncMock,
24+
}))
25+
1726
import { codexGuiAdapter } from '../codex-gui.adapter'
1827

1928
const makeTmpDir = (): string => {
@@ -31,6 +40,8 @@ describe('codexGuiAdapter', () => {
3140

3241
beforeEach(() => {
3342
tmpDir = makeTmpDir()
43+
execFileSyncMock.mockReset()
44+
execFileSyncMock.mockReturnValue('')
3445

3546
originalAppData = process.env['APPDATA']
3647
originalLocalAppData = process.env['LOCALAPPDATA']
@@ -52,13 +63,39 @@ describe('codexGuiAdapter', () => {
5263
})
5364

5465
it('reports not installed when config and executable are absent', async () => {
66+
execFileSyncMock.mockReturnValue('')
5567
const result = await codexGuiAdapter.detect()
5668
expect(result.installed).toBe(false)
5769
expect(result.configPaths).toHaveLength(0)
5870
expect(result.serverCount).toBe(0)
5971
})
6072

73+
it('detects installed when AppX package is registered and healthy', async () => {
74+
execFileSyncMock.mockReturnValue('{"Name":"OpenAI.Codex","Status":"Ok"}')
75+
76+
const result = await codexGuiAdapter.detect()
77+
expect(result.installed).toBe(true)
78+
expect(result.configPaths).toHaveLength(0)
79+
expect(result.serverCount).toBe(0)
80+
})
81+
6182
it('detects installed when GUI executable exists but config does not', async () => {
83+
execFileSyncMock.mockReturnValue('')
84+
const exePath = join(process.env['LOCALAPPDATA'] ?? '', 'Programs', 'Codex', 'Codex.exe')
85+
mkdirSync(join(exePath, '..'), { recursive: true })
86+
writeFileSync(exePath, '')
87+
88+
const result = await codexGuiAdapter.detect()
89+
expect(result.installed).toBe(true)
90+
expect(result.configPaths).toHaveLength(0)
91+
expect(result.serverCount).toBe(0)
92+
})
93+
94+
it('falls back to executable detection when AppX query fails', async () => {
95+
execFileSyncMock.mockImplementation(() => {
96+
throw new Error('powershell timeout')
97+
})
98+
6299
const exePath = join(process.env['LOCALAPPDATA'] ?? '', 'Programs', 'Codex', 'Codex.exe')
63100
mkdirSync(join(exePath, '..'), { recursive: true })
64101
writeFileSync(exePath, '')
@@ -70,6 +107,7 @@ describe('codexGuiAdapter', () => {
70107
})
71108

72109
it('detects config and server count from APPDATA\\Codex\\config.json', async () => {
110+
execFileSyncMock.mockReturnValue('')
73111
const configDir = join(process.env['APPDATA'] ?? '', 'Codex')
74112
mkdirSync(configDir, { recursive: true })
75113
const configPath = join(configDir, 'config.json')

src/main/clients/codex-gui.adapter.ts

Lines changed: 50 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
* where the app executable exists before any MCP config file is created.
1212
*/
1313

14-
import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync, readdirSync } from 'fs'
14+
import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from 'fs'
15+
import { execFileSync } from 'child_process'
1516
import { dirname, join } from 'path'
1617
import log from 'electron-log'
1718
import type { ClientAdapter } from './types'
@@ -37,20 +38,42 @@ const resolveExistingConfigPath = (): string | undefined =>
3738
configPathCandidates().find((path) => existsSync(path))
3839

3940
/**
40-
* Detects Codex installed via Microsoft Store by checking package executable path.
41+
* Detects Codex installed via Microsoft Store by querying AppX registration.
4142
*/
42-
const hasCodexWindowsStoreExecutable = (): boolean => {
43+
const hasCodexWindowsStorePackage = (): boolean => {
4344
if (process.platform !== 'win32') return false
4445

45-
const programFiles = process.env['ProgramFiles'] ?? 'C:\\Program Files'
46-
const windowsAppsDir = join(programFiles, 'WindowsApps')
47-
if (!existsSync(windowsAppsDir)) return false
48-
4946
try {
50-
return readdirSync(windowsAppsDir)
51-
.filter((entry) => entry.startsWith('OpenAI.Codex_'))
52-
.some((entry) => existsSync(join(windowsAppsDir, entry, 'app', 'resources', 'codex.exe')))
53-
} catch {
47+
const defaultPowerShellPath = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
48+
const systemRoot = process.env['SystemRoot'] ?? 'C:\\Windows'
49+
const powerShellPath = join(
50+
systemRoot,
51+
'System32',
52+
'WindowsPowerShell',
53+
'v1.0',
54+
'powershell.exe',
55+
)
56+
const executable = existsSync(powerShellPath) ? powerShellPath : defaultPowerShellPath
57+
const command = [
58+
'$pkg = Get-AppxPackage -Name OpenAI.Codex -ErrorAction SilentlyContinue |',
59+
'Select-Object -First 1 Name, Status;',
60+
'if ($null -eq $pkg) { "" } else { $pkg | ConvertTo-Json -Compress }',
61+
].join(' ')
62+
63+
const raw = execFileSync(executable, ['-NoProfile', '-NonInteractive', '-Command', command], {
64+
encoding: 'utf-8',
65+
timeout: 2000,
66+
windowsHide: true,
67+
stdio: ['ignore', 'pipe', 'ignore'],
68+
}).trim()
69+
70+
if (raw.length === 0) return false
71+
72+
const parsed = JSON.parse(raw) as { Status?: string }
73+
return parsed.Status?.toLowerCase() === 'ok'
74+
} catch (err) {
75+
const message = err instanceof Error ? err.message : String(err)
76+
log.debug(`[codex-gui] appx detection failed: ${message}`)
5477
return false
5578
}
5679
}
@@ -59,7 +82,7 @@ const hasCodexWindowsStoreExecutable = (): boolean => {
5982
* Checks common Codex GUI installation locations.
6083
* Intentionally avoids generic PATH aliases to keep CLI and GUI detection separate.
6184
*/
62-
const isCodexGuiInstalled = (): boolean => {
85+
const hasCodexGuiExecutable = (): boolean => {
6386
if (process.platform !== 'win32') return false
6487

6588
const localAppData = process.env['LOCALAPPDATA'] ?? ''
@@ -76,7 +99,16 @@ const isCodexGuiInstalled = (): boolean => {
7699
join(programFilesX86, 'OpenAI Codex', 'Codex.exe'),
77100
]
78101

79-
return hasCodexWindowsStoreExecutable() || candidates.some((path) => existsSync(path))
102+
return candidates.some((path) => existsSync(path))
103+
}
104+
105+
type CodexGuiDetectionSource = 'config' | 'appx' | 'exe' | 'none'
106+
107+
const detectInstallSource = (hasConfig: boolean): CodexGuiDetectionSource => {
108+
if (hasConfig) return 'config'
109+
if (hasCodexWindowsStorePackage()) return 'appx'
110+
if (hasCodexGuiExecutable()) return 'exe'
111+
return 'none'
80112
}
81113

82114
export const codexGuiAdapter: ClientAdapter = {
@@ -86,7 +118,8 @@ export const codexGuiAdapter: ClientAdapter = {
86118

87119
detect(): Promise<ClientDetectionResult> {
88120
const existingConfig = resolveExistingConfigPath()
89-
const installed = existingConfig !== undefined || isCodexGuiInstalled()
121+
const source = detectInstallSource(existingConfig !== undefined)
122+
const installed = source !== 'none'
90123
let serverCount = 0
91124

92125
if (existingConfig) {
@@ -98,7 +131,9 @@ export const codexGuiAdapter: ClientAdapter = {
98131
}
99132
}
100133

101-
log.debug(`[codex-gui] detect: installed=${installed}, servers=${serverCount}`)
134+
log.debug(
135+
`[codex-gui] detect: installed=${installed}, source=${source}, servers=${serverCount}`,
136+
)
102137

103138
return Promise.resolve({
104139
installed,

src/main/db/activity-log.repo.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,4 +143,21 @@ export class ActivityLogRepo {
143143

144144
return rows.map(rowToEntry)
145145
}
146+
147+
/**
148+
* Returns the newest sync event (`sync.performed` or `sync.failed`) for a
149+
* client, or `null` if no sync event has been logged yet.
150+
*/
151+
findLatestSyncByClient(clientId: ClientId): ActivityLogEntry | null {
152+
const row = this.db
153+
.prepare(
154+
`SELECT * FROM activity_log
155+
WHERE client_id = ? AND action IN ('sync.performed', 'sync.failed')
156+
ORDER BY timestamp DESC
157+
LIMIT 1`,
158+
)
159+
.get(clientId) as ActivityLogRow | undefined
160+
161+
return row ? rowToEntry(row) : null
162+
}
146163
}

0 commit comments

Comments
 (0)