From dea67fc153a8edd1776c24605de33028917464ff Mon Sep 17 00:00:00 2001 From: yilsem Date: Fri, 9 Oct 2026 15:59:39 +0300 Subject: [PATCH] =?UTF-8?q?feat(settings):=20Codex=20Ba=C4=9Flama,=20on=20?= =?UTF-8?q?by=20default?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Codex Bağlama runs the bundled bridge for an OpenAI Codex install on the same computer: the IDE's models and combos appear in Codex and switching models keeps the conversation. The IDE points Codex's openai_base_url at the bridge only after the bridge answers, keeps it running across logins, and removes every trace when the setting is turned off. A Codex that already uses another base URL is left untouched. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HRDGW1LiKcDUZYUdKBoWYW --- .gitea/workflows/ci.yml | 8 + .../electron/codex-link-config.test.ts | 287 ++++++++++ apps/desktop/electron/codex-link-config.ts | 429 ++++++++++++++ apps/desktop/electron/codex-link-types.ts | 39 ++ apps/desktop/electron/codex-link.test.ts | 403 ++++++++++++++ apps/desktop/electron/codex-link.ts | 525 ++++++++++++++++++ apps/desktop/electron/main.ts | 70 +++ apps/desktop/electron/preload.ts | 10 + apps/desktop/package.json | 8 + .../app/settings/codex-link-settings.test.tsx | 193 +++++++ .../src/app/settings/codex-link-settings.tsx | 233 ++++++++ apps/desktop/src/app/settings/index.tsx | 22 +- apps/desktop/src/app/settings/types.ts | 1 + apps/desktop/src/global.d.ts | 8 + apps/desktop/src/i18n/codex-link.ts | 80 +++ apps/desktop/vitest.mobile.config.ts | 3 +- 16 files changed, 2317 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/electron/codex-link-config.test.ts create mode 100644 apps/desktop/electron/codex-link-config.ts create mode 100644 apps/desktop/electron/codex-link-types.ts create mode 100644 apps/desktop/electron/codex-link.test.ts create mode 100644 apps/desktop/electron/codex-link.ts create mode 100644 apps/desktop/src/app/settings/codex-link-settings.test.tsx create mode 100644 apps/desktop/src/app/settings/codex-link-settings.tsx create mode 100644 apps/desktop/src/i18n/codex-link.ts diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index a871c91..e66707d 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -43,6 +43,14 @@ jobs: New-Item -ItemType Junction -Path node_modules -Target (Join-Path $deps 'node_modules') | Out-Null node node_modules/vitest/vitest.mjs run --root apps/desktop --config vitest.mobile.config.ts exit $LASTEXITCODE + - name: Codex koprusu (Python, bagimliliksiz) testleri + shell: powershell + run: | + $ErrorActionPreference = 'Stop' + $py = (Get-Command python -ErrorAction SilentlyContinue).Source + if ($py) { & $py -m unittest discover -s apps/desktop/assets/codex-bridge } + else { & py -3 -m unittest discover -s apps/desktop/assets/codex-bridge } + exit $LASTEXITCODE approval-gate-windows: runs-on: windows-latest diff --git a/apps/desktop/electron/codex-link-config.test.ts b/apps/desktop/electron/codex-link-config.test.ts new file mode 100644 index 0000000..f520d2c --- /dev/null +++ b/apps/desktop/electron/codex-link-config.test.ts @@ -0,0 +1,287 @@ +import { describe, expect, it } from 'vitest' + +import { + addCodexLinkLine, + CODEX_LINK_LINE, + CODEX_LINK_RUN_COMMAND_MAX, + codexAutostartPlan, + codexBridgeArgs, + codexBridgeCommandLine, + codexBridgeConfig, + codexBridgeLauncherSource, + inspectCodexConfig, + parseCodexLinkSettings, + removeCodexLinkLine, + serializeCodexLinkSettings +} from './codex-link-config' + +const FOREIGN = 'openai_base_url = "http://127.0.0.1:8799/backend-api/codex"' + +// Add then remove must hand the user's file back exactly as it was. +function expectRoundTrip(original: string) { + const added = addCodexLinkLine(original) + + expect(added.changed).toBe(true) + expect(added.owner).toBe('ours') + expect(inspectCodexConfig(added.text)).toBe('ours') + + const removed = removeCodexLinkLine(added.text) + + expect(removed.changed).toBe(true) + expect(removed.text).toBe(original) + + return added.text +} + +describe('the contract line', () => { + it('is the exact text Codex and the bridge agree on', () => { + expect(CODEX_LINK_LINE).toBe( + 'openai_base_url = "http://127.0.0.1:8795/backend-api/codex" # AITURK IDE Codex Baglama' + ) + }) +}) + +describe('addCodexLinkLine', () => { + it('writes the line into an empty file', () => { + expect(expectRoundTrip('')).toBe(`${CODEX_LINK_LINE}\n`) + }) + + it('puts the line before the first table when the file only has tables', () => { + const original = '[projects."C:\\\\work"]\ntrust_level = "trusted"\n\n[mcp_servers.x]\ncommand = "node"\n' + const text = expectRoundTrip(original) + + expect(text.indexOf(CODEX_LINK_LINE)).toBeLessThan(text.indexOf('[projects.')) + expect(text.endsWith(original)).toBe(true) + }) + + it('keeps existing top-level keys and tables intact', () => { + const original = '# my config\nmodel = "gpt-6"\napproval_policy = "never"\n\n[features]\nweb = true\n' + const text = expectRoundTrip(original) + + expect(text.indexOf(CODEX_LINK_LINE)).toBeLessThan(text.indexOf('[features]')) + expect(text).toContain('model = "gpt-6"\napproval_policy = "never"\n\n[features]\nweb = true\n') + }) + + it('preserves a CRLF file and writes its own line with CRLF', () => { + const original = 'model = "gpt-6"\r\n\r\n[features]\r\nweb = true\r\n' + const text = expectRoundTrip(original) + + expect(text).toBe(`${CODEX_LINK_LINE}\r\n${original}`) + expect(text.replace(/\r\n/g, '')).not.toMatch(/[\r\n]/) + }) + + it('keeps a byte-order mark at the very start', () => { + const text = expectRoundTrip('\uFEFFmodel = "gpt-6"\n') + + expect(text.startsWith(`\uFEFF${CODEX_LINK_LINE}\n`)).toBe(true) + }) + + it('keeps a file without a trailing newline exactly as it was', () => { + expectRoundTrip('model = "gpt-6"\n[features]\nweb = true') + }) + + it('is idempotent once our line is there', () => { + const once = addCodexLinkLine('model = "gpt-6"\n[features]\n').text + const twice = addCodexLinkLine(once) + + expect(twice.changed).toBe(false) + expect(twice.owner).toBe('ours') + expect(twice.text).toBe(once) + }) + + it('refreshes a stale copy of our own line in place', () => { + const stale = 'model = "gpt-6"\nopenai_base_url = "http://127.0.0.1:1/x" # AITURK IDE Codex Baglama\n[features]\n' + const edit = addCodexLinkLine(stale) + + expect(edit.changed).toBe(true) + expect(edit.text).toBe(`model = "gpt-6"\n${CODEX_LINK_LINE}\n[features]\n`) + }) + + it('still owns its line after Codex rewrote the config and dropped the comment', () => { + const rewritten = 'openai_base_url = "http://127.0.0.1:8795/backend-api/codex"\nmodel = "x"\n' + + expect(inspectCodexConfig(rewritten)).toBe('ours') + expect(removeCodexLinkLine(rewritten).text).toBe('model = "x"\n') + // Another local port is somebody else's bridge. + expect(inspectCodexConfig('openai_base_url = "http://127.0.0.1:8799/backend-api/codex"\n')).toBe('foreign') + }) + + it('does not touch a foreign openai_base_url and reports it', () => { + for (const original of [ + `${FOREIGN}\n[features]\n`, + `model = "gpt-6"\r\n ${FOREIGN} # my own bridge\r\n`, + '"openai_base_url" = "https://example.test/v1"\n', + "'openai_base_url'='https://example.test/v1'\n" + ]) { + const edit = addCodexLinkLine(original) + + expect(edit).toEqual({ changed: false, owner: 'foreign', text: original }) + expect(inspectCodexConfig(original)).toBe('foreign') + } + }) + + it('does not treat the same key under a table as top-level', () => { + const original = 'model = "gpt-6"\n\n[model_providers.mine]\nopenai_base_url = "https://example.test/v1"\n' + + expect(inspectCodexConfig(original)).toBe('absent') + + const text = expectRoundTrip(original) + + expect(text.indexOf(CODEX_LINK_LINE)).toBeLessThan(text.indexOf('[model_providers.mine]')) + expect(text).toContain('[model_providers.mine]\nopenai_base_url = "https://example.test/v1"\n') + }) + + it('ignores the key inside a comment', () => { + const original = '# openai_base_url = "https://example.test/v1"\n #openai_base_url = "x"\nmodel = "gpt-6"\n' + + expect(inspectCodexConfig(original)).toBe('absent') + expectRoundTrip(original) + }) + + it('ignores look-alikes inside multi-line values', () => { + // Neither the "key" inside the string nor the "[table]" inside it is real, + // so the genuine foreign key after it must still be found. + const inString = 'notes = """\nopenai_base_url = "nope"\n[not.a.table]\n"""\nmodel = "gpt-6"\n' + + expect(inspectCodexConfig(inString)).toBe('absent') + expect(inspectCodexConfig(`${inString}${FOREIGN}\n`)).toBe('foreign') + + const inArray = 'notify = [\n "a",\n ["b"]\n]\n' + + expect(inspectCodexConfig(`${inArray}${FOREIGN}\n`)).toBe('foreign') + expect(inspectCodexConfig(`literal = '''\n[x]\n'''\n${FOREIGN}\n`)).toBe('foreign') + }) + + it('is not fooled by a bracket or hash inside a quoted value', () => { + const original = `title = "a [b] # c"\n${FOREIGN}\n` + + expect(inspectCodexConfig(original)).toBe('foreign') + }) +}) + +describe('removeCodexLinkLine', () => { + it('removes only our line, wherever the user moved it', () => { + const text = `model = "gpt-6"\r\n${CODEX_LINK_LINE}\r\napproval_policy = "never"\r\n[features]\r\n` + const edit = removeCodexLinkLine(text) + + expect(edit).toEqual({ + changed: true, + owner: 'absent', + text: 'model = "gpt-6"\r\napproval_policy = "never"\r\n[features]\r\n' + }) + }) + + it('leaves a file without our line untouched', () => { + for (const original of ['', 'model = "gpt-6"\n', `${FOREIGN}\n[features]\n`]) { + expect(removeCodexLinkLine(original).changed).toBe(false) + expect(removeCodexLinkLine(original).text).toBe(original) + } + }) + + it('never removes a foreign line', () => { + expect(removeCodexLinkLine(`${FOREIGN}\n`)).toEqual({ changed: false, owner: 'foreign', text: `${FOREIGN}\n` }) + }) + + it('leaves our marker alone when it sits under a table', () => { + const original = `[model_providers.mine]\n${CODEX_LINK_LINE}\n` + + expect(removeCodexLinkLine(original)).toEqual({ changed: false, owner: 'absent', text: original }) + }) +}) + +describe('preference file', () => { + it('is on by default when the file is missing or malformed', () => { + for (const raw of [null, undefined, '', '{', 'null', '[]', '"off"', '{"kapsam":"ide"}', '{"enabled":"false"}']) { + expect(parseCodexLinkSettings(raw).enabled).toBe(true) + } + }) + + it('is off only when explicitly disabled', () => { + expect(parseCodexLinkSettings('{"enabled": false, "kapsam": "ide"}').enabled).toBe(false) + expect(parseCodexLinkSettings('{"enabled": true}').enabled).toBe(true) + }) + + it('writes the contract shape and keeps unknown fields', () => { + expect(JSON.parse(serializeCodexLinkSettings({}, false))).toEqual({ enabled: false, kapsam: 'ide' }) + + const { stored } = parseCodexLinkSettings('{"enabled": false, "kapsam": "hepsi", "gelecek": {"a": 1}}') + + expect(JSON.parse(serializeCodexLinkSettings(stored, true))).toEqual({ + enabled: true, + gelecek: { a: 1 }, + kapsam: 'hepsi' + }) + }) +}) + +describe('bridge configuration and command', () => { + const launch = { + bridgeHome: 'C:\\Users\\a b\\AppData\\Local\\TurkServis\\AITURK-IDE\\codex-bridge', + bridgeScript: 'C:\\Program Files\\AITURK IDE\\resources\\codex-bridge\\bridge.py', + hermesHome: 'C:\\Users\\a b\\AppData\\Local\\TurkServis\\AITURK-IDE\\agent' + } + + const python = `${launch.hermesHome}\\hermes-agent\\venv\\Scripts\\pythonw.exe` + + it('writes the agreed bridge.json', () => { + expect(codexBridgeConfig()).toEqual({ + port: 8795, + chatgpt_backend: 'https://chatgpt.com', + routes: {}, + nine_router_catalog: { enabled: false }, + pano_kanca_user: false, + ozet_kopru: { enabled: true, kapsam: 'hepsi', openai_modeli: 'gpt-6-luna', dongu_siniri: 3 } + }) + }) + + it('passes the same arguments to the spawn and the login item', () => { + expect(codexBridgeArgs(launch)).toEqual([ + launch.bridgeScript, + '--home', + launch.bridgeHome, + '--hermes-home', + launch.hermesHome + ]) + expect(codexBridgeCommandLine(python, launch)).toBe( + `"${python}" "${launch.bridgeScript}" --home "${launch.bridgeHome}" --hermes-home "${launch.hermesHome}"` + ) + }) + + it('drops a trailing separator that would escape the closing quote', () => { + expect( + codexBridgeCommandLine('C:\\py\\pythonw.exe', { + bridgeHome: 'C:\\d\\', + bridgeScript: 'C:\\b.py', + hermesHome: 'C:\\h\\' + }) + ).toBe('"C:\\py\\pythonw.exe" "C:\\b.py" --home "C:\\d" --hermes-home "C:\\h"') + }) + + it('uses the direct command for the login item when it fits a Run value', () => { + const short = { bridgeHome: 'C:\\d', bridgeScript: 'C:\\b\\bridge.py', hermesHome: 'C:\\h' } + + expect(codexAutostartPlan('C:\\py\\pythonw.exe', short, 'C:\\d\\baslat.pyw')).toEqual({ + command: '"C:\\py\\pythonw.exe" "C:\\b\\bridge.py" --home "C:\\d" --hermes-home "C:\\h"', + launcher: null + }) + }) + + it('falls back to a launcher when the direct command is too long for a Run value', () => { + expect(codexBridgeCommandLine(python, launch).length).toBeGreaterThan(CODEX_LINK_RUN_COMMAND_MAX) + + const launcherPath = `${launch.bridgeHome}\\baslat.pyw` + const plan = codexAutostartPlan(python, launch, launcherPath) + + expect(plan?.command).toBe(`"${python}" "${launcherPath}"`) + expect(plan?.command.length).toBeLessThanOrEqual(CODEX_LINK_RUN_COMMAND_MAX) + expect(plan?.launcher).toEqual({ path: launcherPath, source: codexBridgeLauncherSource(launch) }) + // The launcher carries the very same argv as the direct command. + expect(plan?.launcher?.source).toContain(`ARGV = ${JSON.stringify(codexBridgeArgs(launch))}`) + }) + + it('registers nothing when not even the launcher command fits', () => { + const deep = `C:\\${'x'.repeat(300)}` + + expect(codexAutostartPlan(`${deep}\\pythonw.exe`, launch, `${deep}\\baslat.pyw`)).toBeNull() + }) +}) diff --git a/apps/desktop/electron/codex-link-config.ts b/apps/desktop/electron/codex-link-config.ts new file mode 100644 index 0000000..ec6efcf --- /dev/null +++ b/apps/desktop/electron/codex-link-config.ts @@ -0,0 +1,429 @@ +/** + * Codex Link ("Codex Bağlama") — the pure half. + * + * AITURK IDE ships a small local Python bridge that lets the OpenAI Codex + * installed on this machine use the IDE's models and combos. Turning the + * feature on means editing a file the IDE does not own (Codex's `config.toml`) + * and registering a login item, so every text transformation lives here as a + * dependency-free, byte-preserving function that can be proven without + * Electron, a filesystem, or a registry. `codex-link.ts` owns the decisions and + * the side effects. + * + * Invariants: + * - Only the line carrying OUR trailing comment is ever written or removed. + * - An `openai_base_url` that is not ours means someone else's bridge is in + * charge: report `foreign` and change nothing. + * - Everything else in the file — including its line endings and BOM — comes + * back byte for byte. + */ + +export const CODEX_LINK_PORT = 8795 +export const CODEX_LINK_ORIGIN = `http://127.0.0.1:${CODEX_LINK_PORT}` +export const CODEX_LINK_STATUS_URL = `${CODEX_LINK_ORIGIN}/aiturk/codex-baglama/status` +export const CODEX_LINK_STOP_URL = `${CODEX_LINK_ORIGIN}/aiturk/codex-baglama/stop` + +/** The trailing comment that marks the line as ours. */ +export const CODEX_LINK_MARKER = '# AITURK IDE Codex Baglama' +// Must equal SURUM in assets/codex-bridge/bridge.py; a running bridge that reports +// another version is restarted so an updated IDE never keeps an old bridge alive. +export const CODEX_BRIDGE_VERSION = '1' +export const CODEX_LINK_LINE = `openai_base_url = "${CODEX_LINK_ORIGIN}/backend-api/codex" ${CODEX_LINK_MARKER}` + +export const CODEX_LINK_BACKUP_SUFFIX = '.aiturk-yedek' +export const CODEX_LINK_RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run' +export const CODEX_LINK_RUN_VALUE = 'AITURK-IDE-Codex-Baglama' +export const CODEX_LINK_LAUNCHER_NAME = 'baslat.pyw' + +// Windows documents Run-key command lines as "no longer than 260 characters". +// A longer value is not reliably started at sign-in, so the direct command is +// only used when it fits (see autostartPlan). +export const CODEX_LINK_RUN_COMMAND_MAX = 260 + +/** Who owns the top-level `openai_base_url` in a Codex config. */ +export type CodexConfigOwner = 'absent' | 'foreign' | 'ours' + +interface Line { + eol: string + text: string +} + +const BOM = '\uFEFF' +const KEY_PATTERN = /^[ \t]*(?:openai_base_url|"openai_base_url"|'openai_base_url')[ \t]*=/ + +// Split into lines WITHOUT losing anything: joining `text + eol` of every entry +// reproduces the input exactly, whatever mix of \n, \r\n and \r it used. +function splitLines(source: string): Line[] { + const lines: Line[] = [] + let start = 0 + + while (start < source.length) { + let end = start + + while (end < source.length && source[end] !== '\n' && source[end] !== '\r') { + end += 1 + } + + let eol = '' + + if (end < source.length) { + eol = source[end] === '\r' && source[end + 1] === '\n' ? '\r\n' : source[end] + } + + lines.push({ eol, text: source.slice(start, end) }) + start = end + eol.length + } + + return lines +} + +function joinLines(lines: Line[]): string { + return lines.map(line => line.text + line.eol).join('') +} + +function splitBom(source: string): { body: string; bom: string } { + return source.startsWith(BOM) ? { body: source.slice(1), bom: BOM } : { body: source, bom: '' } +} + +type MultilineQuote = "'''" | '"""' | null + +interface ValueState { + depth: number + multiline: MultilineQuote +} + +// Index just past the `=` of a `key = value` line, or the line length when the +// line holds no value (blank, comment, malformed). Quoted keys may contain `=`. +function valueStart(text: string): number { + let pos = 0 + + while (pos < text.length) { + const char = text[pos] + + if (char === '#') { + return text.length + } + + if (char === '=') { + return pos + 1 + } + + if (char === '"' || char === "'") { + pos += 1 + + while (pos < text.length && text[pos] !== char) { + pos += char === '"' && text[pos] === '\\' ? 2 : 1 + } + } + + pos += 1 + } + + return text.length +} + +// Walk a value just far enough to know whether the NEXT line is still inside it +// (multi-line string or array). That is the only TOML the top-level scan needs: +// without it, text inside a multi-line value could pass for a key or a table +// header. +function scanValue(text: string, from: number, state: ValueState): ValueState { + let { depth, multiline } = state + let pos = from + + while (pos < text.length) { + if (multiline) { + if (multiline === '"""' && text[pos] === '\\') { + pos += 2 + + continue + } + + if (text.startsWith(multiline, pos)) { + pos += 3 + + // Up to two quotes may sit directly before the closing delimiter. + while (text[pos] === multiline[0]) { + pos += 1 + } + + multiline = null + + continue + } + + pos += 1 + + continue + } + + const char = text[pos] + + if (char === '#') { + break + } + + if (text.startsWith('"""', pos) || text.startsWith("'''", pos)) { + multiline = text.slice(pos, pos + 3) as MultilineQuote + pos += 3 + + continue + } + + if (char === '"' || char === "'") { + pos += 1 + + while (pos < text.length && text[pos] !== char) { + pos += char === '"' && text[pos] === '\\' ? 2 : 1 + } + + pos += 1 + + continue + } + + if (char === '[' || char === '{') { + depth += 1 + } else if (char === ']' || char === '}') { + depth = Math.max(0, depth - 1) + } + + pos += 1 + } + + return { depth, multiline } +} + +// Indices of the top-level `openai_base_url` lines: those before the first +// `[table]` header, outside comments and multi-line values. +function topLevelEntries(lines: Line[]): { index: number; ours: boolean }[] { + const entries: { index: number; ours: boolean }[] = [] + let state: ValueState = { depth: 0, multiline: null } + + for (let index = 0; index < lines.length; index += 1) { + const { text } = lines[index] + let from = 0 + + if (!state.multiline && state.depth === 0) { + if (text.trimStart().startsWith('[')) { + break + } + + if (KEY_PATTERN.test(text)) { + // Ours by marker or by value: Codex may rewrite its config and drop the comment. + entries.push({ + index, + ours: text.includes(CODEX_LINK_MARKER) || text.includes(`"${CODEX_LINK_ORIGIN}/backend-api/codex"`) + }) + } + + from = valueStart(text) + } + + state = scanValue(text, from, state) + } + + return entries +} + +function ownerOf(entries: { ours: boolean }[]): CodexConfigOwner { + if (entries.some(entry => !entry.ours)) { + return 'foreign' + } + + return entries.length > 0 ? 'ours' : 'absent' +} + +/** Who owns the top-level `openai_base_url` of this config text. */ +export function inspectCodexConfig(source: string): CodexConfigOwner { + return ownerOf(topLevelEntries(splitLines(splitBom(source).body))) +} + +export interface CodexConfigEdit { + changed: boolean + owner: CodexConfigOwner + text: string +} + +/** + * Point Codex at the bridge. Idempotent; a foreign `openai_base_url` leaves the + * text untouched and reports `foreign`. + * + * The line goes on the very first line: top-level keys must precede the first + * table header, and the top of the file is the one position that is always + * top-level without having to understand the values below it. + */ +export function addCodexLinkLine(source: string): CodexConfigEdit { + const { body, bom } = splitBom(source) + const lines = splitLines(body) + const entries = topLevelEntries(lines) + const owner = ownerOf(entries) + + if (owner === 'foreign') { + return { changed: false, owner, text: source } + } + + if (owner === 'ours') { + const [first, ...duplicates] = entries + + if (duplicates.length === 0 && lines[first.index].text === CODEX_LINK_LINE) { + return { changed: false, owner, text: source } + } + + // A stale copy of our line (older address) or an accidental duplicate: + // normalize to exactly one current line, in place. + const drop = new Set(duplicates.map(entry => entry.index)) + lines[first.index] = { ...lines[first.index], text: CODEX_LINK_LINE } + + return { changed: true, owner, text: bom + joinLines(lines.filter((_line, index) => !drop.has(index))) } + } + + const eol = body.includes('\r\n') ? '\r\n' : '\n' + + return { changed: true, owner: 'ours', text: `${bom}${CODEX_LINK_LINE}${eol}${body}` } +} + +/** Remove our line (and only ours). A foreign key is left exactly as it was. */ +export function removeCodexLinkLine(source: string): CodexConfigEdit { + const { body, bom } = splitBom(source) + const lines = splitLines(body) + const entries = topLevelEntries(lines) + const drop = new Set(entries.filter(entry => entry.ours).map(entry => entry.index)) + const remaining = ownerOf(entries.filter(entry => !entry.ours)) + + if (drop.size === 0) { + return { changed: false, owner: remaining, text: source } + } + + return { changed: true, owner: remaining, text: bom + joinLines(lines.filter((_line, index) => !drop.has(index))) } +} + +// ── Preference file ───────────────────────────────────────────────────────── + +export interface CodexLinkSettings { + enabled: boolean + /** Every field of the stored object, so a rewrite keeps what it did not set. */ + stored: Record +} + +/** Missing, unreadable or malformed means ON: the feature is on by default. */ +export function parseCodexLinkSettings(raw: null | string | undefined): CodexLinkSettings { + if (typeof raw !== 'string') { + return { enabled: true, stored: {} } + } + + try { + const parsed = JSON.parse(raw) + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { enabled: true, stored: {} } + } + + return { enabled: parsed.enabled !== false, stored: parsed as Record } + } catch { + return { enabled: true, stored: {} } + } +} + +export function serializeCodexLinkSettings(stored: Record, enabled: boolean): string { + const kapsam = typeof stored.kapsam === 'string' && stored.kapsam ? stored.kapsam : 'ide' + + return `${JSON.stringify({ ...stored, enabled, kapsam }, null, 2)}\n` +} + +// ── Bridge configuration ──────────────────────────────────────────────────── + +/** `bridge.json`, rewritten on every apply. */ +export function codexBridgeConfig() { + return { + port: CODEX_LINK_PORT, + chatgpt_backend: 'https://chatgpt.com', + routes: {}, + nine_router_catalog: { enabled: false }, + pano_kanca_user: false, + ozet_kopru: { enabled: true, kapsam: 'hepsi', openai_modeli: 'gpt-6-luna', dongu_siniri: 3 } + } +} + +export function serializeCodexBridgeConfig(): string { + return `${JSON.stringify(codexBridgeConfig(), null, 2)}\n` +} + +// ── Bridge command line ───────────────────────────────────────────────────── + +export interface CodexBridgeLaunch { + bridgeHome: string + bridgeScript: string + hermesHome: string +} + +/** Arguments after the interpreter — identical for the spawn and the login item. */ +export function codexBridgeArgs({ bridgeHome, bridgeScript, hermesHome }: CodexBridgeLaunch): string[] { + return [bridgeScript, '--home', bridgeHome, '--hermes-home', hermesHome] +} + +// A trailing backslash would escape the closing quote (`"C:\dir\"`). +function quoted(value: string): string { + return `"${value.replace(/[\\/]+$/, '')}"` +} + +/** `"" "" --home "" --hermes-home ""` */ +export function codexBridgeCommandLine(python: string, launch: CodexBridgeLaunch): string { + const [script, homeFlag, home, hermesFlag, hermes] = codexBridgeArgs(launch) + + return `${quoted(python)} ${quoted(script)} ${homeFlag} ${quoted(home)} ${hermesFlag} ${quoted(hermes)}` +} + +/** + * A tiny windowless launcher for the login item, used when the direct command + * would not fit a Run value. It runs the bridge exactly as the direct command + * would: same script, same arguments, the script's folder first on sys.path so + * its helper modules import. + */ +export function codexBridgeLauncherSource(launch: CodexBridgeLaunch): string { + const argv = JSON.stringify(codexBridgeArgs(launch)) + + return [ + '# Written by AITURK IDE (Settings > Codex Baglama). Regenerated on every sync; do not edit.', + 'import os', + 'import runpy', + 'import sys', + '', + `ARGV = ${argv}`, + '', + 'sys.argv = list(ARGV)', + 'sys.path.insert(0, os.path.dirname(ARGV[0]))', + "runpy.run_path(ARGV[0], run_name='__main__')", + '' + ].join('\n') +} + +export interface CodexAutostartPlan { + command: string + /** Launcher file to write first; null when the direct command is used. */ + launcher: { path: string; source: string } | null +} + +/** + * The login-item command. Prefer the direct bridge command; fall back to the + * launcher when that exceeds the Run-value limit. Returns null when not even + * the launcher form fits (nothing is registered rather than something that + * silently never starts). + */ +export function codexAutostartPlan( + python: string, + launch: CodexBridgeLaunch, + launcherPath: string +): CodexAutostartPlan | null { + const direct = codexBridgeCommandLine(python, launch) + + if (direct.length <= CODEX_LINK_RUN_COMMAND_MAX) { + return { command: direct, launcher: null } + } + + const viaLauncher = `${quoted(python)} ${quoted(launcherPath)}` + + if (viaLauncher.length > CODEX_LINK_RUN_COMMAND_MAX) { + return null + } + + return { command: viaLauncher, launcher: { path: launcherPath, source: codexBridgeLauncherSource(launch) } } +} diff --git a/apps/desktop/electron/codex-link-types.ts b/apps/desktop/electron/codex-link-types.ts new file mode 100644 index 0000000..d925eeb --- /dev/null +++ b/apps/desktop/electron/codex-link-types.ts @@ -0,0 +1,39 @@ +/** + * Codex Link ("Codex Bağlama") — the shape that crosses the IPC bridge. + * + * Types only, so the renderer can import them without pulling main-process + * code into its program. See codex-link.ts for what the fields mean in action. + */ + +export type CodexLinkIdeState = 'anahtar-yok' | 'kapali' | 'tamam' + +/** Stable codes; the renderer owns the wording. Detail goes to the desktop log. */ +export type CodexLinkError = + | 'autostart-failed' + | 'bridge-config-failed' + | 'bridge-missing' + | 'bridge-start-failed' + | 'config-write-failed' + | 'settings-write-failed' + +export interface CodexLinkStatus { + enabled: boolean + /** Codex's `config.toml` exists on this machine. */ + codexFound: boolean + /** The IDE's own Python environment exists (first-run setup finished). */ + venvReady: boolean + /** Codex already uses another bridge; nothing of its setup is touched. */ + foreign: boolean + bridgeRunning: boolean + /** From the bridge's status endpoint; null while it is not answering. */ + ideDurum: CodexLinkIdeState | null + ideModel: null | number + ozet: boolean | null + lastError: CodexLinkError | null +} + +export interface CodexLinkPatch { + enabled?: boolean + /** "Sync now": re-run the apply without changing the preference. */ + sync?: boolean +} diff --git a/apps/desktop/electron/codex-link.test.ts b/apps/desktop/electron/codex-link.test.ts new file mode 100644 index 0000000..35f6ddf --- /dev/null +++ b/apps/desktop/electron/codex-link.test.ts @@ -0,0 +1,403 @@ +import { describe, expect, it } from 'vitest' + +import { CODEX_BRIDGE_START_BUDGET_MS, type CodexLinkDeps, type CodexLinkPaths, createCodexLink } from './codex-link' +import { + CODEX_LINK_LINE, + CODEX_LINK_STATUS_URL, + CODEX_LINK_STOP_URL, + codexBridgeArgs, + codexBridgeConfig +} from './codex-link-config' + +const PATHS: CodexLinkPaths = { + settingsFile: 'D:\\ide\\codex-baglama.json', + bridgeHome: 'D:\\ide\\codex-bridge', + hermesHome: 'D:\\ide\\agent', + bridgeScript: 'D:\\app\\codex-bridge\\bridge.py', + pythonw: 'D:\\ide\\agent\\venv\\Scripts\\pythonw.exe', + python: 'D:\\ide\\agent\\venv\\Scripts\\python.exe', + codexConfig: 'D:\\home\\.codex\\config.toml' +} + +const BRIDGE_JSON = `${PATHS.bridgeHome}\\bridge.json` +const BACKUP = `${PATHS.codexConfig}.aiturk-yedek` +const STATUS_OK = { ok: true, surum: '1', ide_durum: 'tamam', ide_model: 7, ozet: true } +const USER_CONFIG = 'model = "gpt-6"\r\n\r\n[features]\r\nweb = true\r\n' + +interface World { + /** What the machine looks like; mutated by the fake effects. */ + files: Map + /** Is something answering on the bridge port? */ + listening: boolean + /** Version the listening bridge reports before any spawn; a spawn brings the current one. */ + staleVersion: null | string + /** Does a spawn bring the bridge up? `false` = it never answers. */ + spawnWorks: boolean + /** Spawned bridge only starts answering after this many status probes. */ + probesUntilUp: number + autostart: null | string + regFails: boolean + /** Every observable effect, in order. */ + events: string[] + clock: number +} + +function createWorld(patch: Partial & { files?: Map } = {}) { + const world: World = { + autostart: null, + clock: 0, + events: [], + files: new Map([ + [PATHS.codexConfig, USER_CONFIG], + [PATHS.bridgeScript, '# bridge'], + [PATHS.pythonw, ''], + [PATHS.python, ''] + ]), + listening: false, + staleVersion: null, + probesUntilUp: 0, + regFails: false, + spawnWorks: true, + ...patch + } + + let spawned = false + let probesSinceSpawn = 0 + + const deps: CodexLinkDeps = { + paths: PATHS, + exists: file => world.files.has(file), + readText: file => world.files.get(file) ?? null, + writeTextAtomic: (file, text) => { + world.events.push(`write ${file}`) + world.files.set(file, text) + }, + backupOnce: (source, target) => { + if (!world.files.has(target)) { + world.events.push(`backup ${target}`) + world.files.set(target, world.files.get(source) ?? '') + } + }, + spawnBridge: (command, args, env) => { + world.events.push(`spawn ${command} ${JSON.stringify(args)} ${JSON.stringify(env)}`) + spawned = true + probesSinceSpawn = 0 + world.staleVersion = null + }, + request: async (method, url) => { + if (method === 'GET' && url === CODEX_LINK_STATUS_URL) { + if (spawned && world.spawnWorks && !world.listening) { + probesSinceSpawn += 1 + + if (probesSinceSpawn > world.probesUntilUp) { + world.listening = true + } + } + + if (world.listening) { + world.events.push('status ok') + + return world.staleVersion ? { ...STATUS_OK, surum: world.staleVersion } : STATUS_OK + } + + return null + } + + if (method === 'POST' && url === CODEX_LINK_STOP_URL) { + world.events.push('stop') + + if (!world.listening) { + return null + } + + world.listening = false + + return { ok: true } + } + + throw new Error(`unexpected request ${method} ${url}`) + }, + setAutostart: async command => { + world.events.push('autostart set') + + if (world.regFails) { + throw new Error('reg add exited 1') + } + + world.autostart = command + }, + clearAutostart: async () => { + world.events.push('autostart clear') + world.autostart = null + }, + sleep: async ms => { + world.clock += ms + }, + now: () => world.clock, + log: () => undefined + } + + return { link: createCodexLink(deps), world } +} + +const writes = (world: World) => world.events.filter(event => event.startsWith('write ')) +const spawns = (world: World) => world.events.filter(event => event.startsWith('spawn ')) +// Effects that change the machine (a status probe does not). +const effects = (world: World) => world.events.filter(event => event !== 'status ok') + +describe('applyCodexLink', () => { + it('touches nothing when Codex is not installed', async () => { + const { link, world } = createWorld() + world.files.delete(PATHS.codexConfig) + + const status = await link.apply() + + expect(status).toMatchObject({ enabled: true, codexFound: false, bridgeRunning: false, lastError: null }) + expect(effects(world)).toEqual([]) + expect(world.autostart).toBeNull() + }) + + it('touches nothing when Codex already uses another bridge', async () => { + const foreign = 'openai_base_url = "http://127.0.0.1:8799/backend-api/codex"\n[features]\n' + const { link, world } = createWorld({ listening: true }) + world.files.set(PATHS.codexConfig, foreign) + + const status = await link.apply({ sync: true }) + + expect(status).toMatchObject({ enabled: true, codexFound: true, foreign: true, bridgeRunning: true }) + expect(effects(world)).toEqual([]) + expect(world.files.get(PATHS.codexConfig)).toBe(foreign) + }) + + it('waits for the IDE setup: no venv, no changes', async () => { + const { link, world } = createWorld() + world.files.delete(PATHS.pythonw) + world.files.delete(PATHS.python) + + const status = await link.apply() + + expect(status).toMatchObject({ enabled: true, codexFound: true, venvReady: false, foreign: false }) + expect(effects(world)).toEqual([]) + }) + + it('brings everything up in the safe order', async () => { + const { link, world } = createWorld({ probesUntilUp: 2 }) + + const status = await link.apply() + + expect(status).toEqual({ + enabled: true, + codexFound: true, + venvReady: true, + foreign: false, + bridgeRunning: true, + ideDurum: 'tamam', + ideModel: 7, + ozet: true, + lastError: null + }) + + const env = { AITURK_CODEX_BRIDGE_HOME: PATHS.bridgeHome, HERMES_HOME: PATHS.hermesHome } + + const args = codexBridgeArgs({ + bridgeHome: PATHS.bridgeHome, + bridgeScript: PATHS.bridgeScript, + hermesHome: PATHS.hermesHome + }) + + // bridge.json → start → verified answer → config.toml (backed up) → login item + expect(world.events).toEqual([ + `write ${BRIDGE_JSON}`, + `spawn ${PATHS.pythonw} ${JSON.stringify(args)} ${JSON.stringify(env)}`, + 'status ok', + `backup ${BACKUP}`, + `write ${PATHS.codexConfig}`, + 'autostart set' + ]) + + expect(JSON.parse(world.files.get(BRIDGE_JSON) ?? '')).toEqual(codexBridgeConfig()) + expect(world.files.get(PATHS.codexConfig)).toBe(`${CODEX_LINK_LINE}\r\n${USER_CONFIG}`) + expect(world.files.get(BACKUP)).toBe(USER_CONFIG) + expect(world.autostart).toBe( + `"${PATHS.pythonw}" "${PATHS.bridgeScript}" --home "${PATHS.bridgeHome}" --hermes-home "${PATHS.hermesHome}"` + ) + }) + + it('never points Codex at a bridge that did not come up', async () => { + const { link, world } = createWorld({ spawnWorks: false }) + + const status = await link.apply() + + expect(status).toMatchObject({ enabled: true, bridgeRunning: false, lastError: 'bridge-start-failed' }) + expect(spawns(world)).toHaveLength(1) + expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG) + expect(world.files.has(BACKUP)).toBe(false) + expect(world.autostart).toBeNull() + expect(writes(world)).toEqual([`write ${BRIDGE_JSON}`]) + // The start poll is bounded by its one budget. + expect(world.clock).toBe(CODEX_BRIDGE_START_BUDGET_MS) + }) + + it('takes its own line back out when the bridge can no longer be started', async () => { + const { link, world } = createWorld({ spawnWorks: false }) + world.files.set(PATHS.codexConfig, `${CODEX_LINK_LINE}\r\n${USER_CONFIG}`) + + const status = await link.apply() + + expect(status.lastError).toBe('bridge-start-failed') + expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG) + }) + + it('does not start a second bridge when one is already listening', async () => { + const { link, world } = createWorld({ listening: true }) + + await link.apply() + await link.apply({ sync: true }) + + expect(spawns(world)).toEqual([]) + expect(world.files.get(PATHS.codexConfig)).toBe(`${CODEX_LINK_LINE}\r\n${USER_CONFIG}`) + // The config is written once; the login item is refreshed on every apply. + expect(writes(world).filter(event => event === `write ${PATHS.codexConfig}`)).toHaveLength(1) + expect(world.events.filter(event => event === 'autostart set')).toHaveLength(2) + }) + + it('replaces a bridge left running by an older IDE build', async () => { + const { link, world } = createWorld({ listening: true, staleVersion: '0' }) + + const status = await link.apply() + + expect(world.events.indexOf('stop')).toBeGreaterThanOrEqual(0) + expect(spawns(world)).toHaveLength(1) + expect(world.events.indexOf('stop')).toBeLessThan(world.events.findIndex(event => event.startsWith('spawn'))) + expect(status.bridgeRunning).toBe(true) + expect(world.files.get(PATHS.codexConfig)).toBe(`${CODEX_LINK_LINE}\r\n${USER_CONFIG}`) + }) + + it('never overwrites the first backup', async () => { + const { link, world } = createWorld({ listening: true }) + world.files.set(BACKUP, 'the original original') + + await link.apply() + + expect(world.files.get(BACKUP)).toBe('the original original') + }) + + it('falls back to python.exe when pythonw.exe is missing', async () => { + const { link, world } = createWorld() + world.files.delete(PATHS.pythonw) + + await link.apply() + + expect(spawns(world)[0]).toContain(`spawn ${PATHS.python} `) + expect(world.autostart?.startsWith(`"${PATHS.python}" `)).toBe(true) + }) + + it('reports a missing bridge script instead of starting anything', async () => { + const { link, world } = createWorld() + world.files.delete(PATHS.bridgeScript) + + const status = await link.apply() + + expect(status.lastError).toBe('bridge-missing') + expect(effects(world)).toEqual([]) + }) + + it('surfaces a registry failure but keeps the working link', async () => { + const { link, world } = createWorld({ listening: true, regFails: true }) + + const status = await link.apply() + + expect(status).toMatchObject({ bridgeRunning: true, lastError: 'autostart-failed' }) + expect(world.files.get(PATHS.codexConfig)).toBe(`${CODEX_LINK_LINE}\r\n${USER_CONFIG}`) + }) + + it('undoes everything when turned off', async () => { + const { link, world } = createWorld({ listening: true }) + + await link.apply() + world.events.length = 0 + + const status = await link.apply({ enabled: false }) + + expect(status).toMatchObject({ enabled: false, codexFound: true, bridgeRunning: false, lastError: null }) + expect(world.events).toEqual([ + `write ${PATHS.settingsFile}`, + `write ${PATHS.codexConfig}`, + 'autostart clear', + 'stop' + ]) + expect(JSON.parse(world.files.get(PATHS.settingsFile) ?? '')).toEqual({ enabled: false, kapsam: 'ide' }) + // Byte for byte what the user had before the feature touched it. + expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG) + expect(world.autostart).toBeNull() + expect(world.listening).toBe(false) + }) + + it('stays off across applies and comes back when turned on', async () => { + const { link, world } = createWorld() + world.files.set(PATHS.settingsFile, '{"enabled": false, "kapsam": "ide", "not": "ours"}') + + expect(await link.apply()).toMatchObject({ enabled: false, bridgeRunning: false }) + expect(spawns(world)).toEqual([]) + expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG) + + expect(await link.apply({ enabled: true })).toMatchObject({ enabled: true, bridgeRunning: true }) + expect(JSON.parse(world.files.get(PATHS.settingsFile) ?? '')).toEqual({ + enabled: true, + kapsam: 'ide', + not: 'ours' + }) + expect(world.files.get(PATHS.codexConfig)).toBe(`${CODEX_LINK_LINE}\r\n${USER_CONFIG}`) + }) + + it('does not stop a bridge that serves a foreign config when turned off', async () => { + const foreign = 'openai_base_url = "http://127.0.0.1:8799/backend-api/codex"\n' + const { link, world } = createWorld({ listening: true }) + world.files.set(PATHS.codexConfig, foreign) + + const status = await link.apply({ enabled: false }) + + expect(status).toMatchObject({ enabled: false, foreign: true, bridgeRunning: true }) + expect(world.events).not.toContain('stop') + expect(world.files.get(PATHS.codexConfig)).toBe(foreign) + expect(world.listening).toBe(true) + }) + + it('runs concurrent applies one after another', async () => { + const { link, world } = createWorld({ probesUntilUp: 3 }) + + const [first, second] = await Promise.all([link.apply(), link.apply({ enabled: false })]) + + expect(first.enabled).toBe(true) + expect(second).toMatchObject({ enabled: false, bridgeRunning: false }) + expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG) + expect(world.autostart).toBeNull() + }) +}) + +describe('getStatus', () => { + it('reads without changing anything', async () => { + const { link, world } = createWorld({ listening: true }) + + const status = await link.getStatus() + + expect(status).toMatchObject({ + enabled: true, + codexFound: true, + venvReady: true, + foreign: false, + bridgeRunning: true, + ideDurum: 'tamam', + ideModel: 7, + ozet: true + }) + expect(effects(world)).toEqual([]) + }) + + it('reports a bridge that is not answering', async () => { + const { link, world } = createWorld() + + expect(await link.getStatus()).toMatchObject({ bridgeRunning: false, ideDurum: null, ideModel: null, ozet: null }) + expect(effects(world)).toEqual([]) + }) +}) diff --git a/apps/desktop/electron/codex-link.ts b/apps/desktop/electron/codex-link.ts new file mode 100644 index 0000000..632017d --- /dev/null +++ b/apps/desktop/electron/codex-link.ts @@ -0,0 +1,525 @@ +/** + * Codex Link ("Codex Bağlama") — decisions and side effects. + * + * When the feature is on and OpenAI Codex is installed on this machine, the IDE + * runs a small local Python bridge, points Codex's `config.toml` at it and + * registers the bridge as a login item, so Codex keeps working with the IDE's + * models while the IDE itself is closed. Turning it off undoes all three. + * + * Ordering is the safety property: Codex cannot work at all while its config + * points at a dead address, so the config line is written only AFTER the bridge + * has answered, and it is taken back out if the bridge cannot be brought up. + * + * Electron-free on purpose. `createCodexLink` takes every effect as a + * dependency (files, spawn, HTTP, registry) so the ordering above is provable + * in a unit test; `createNodeCodexLinkDeps` is the real wiring main.ts uses. + * The text transformations live in `codex-link-config.ts`. + */ + +import { execFile, spawn } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' + +import { + addCodexLinkLine, + CODEX_BRIDGE_VERSION, + CODEX_LINK_BACKUP_SUFFIX, + CODEX_LINK_LAUNCHER_NAME, + CODEX_LINK_RUN_KEY, + CODEX_LINK_RUN_VALUE, + CODEX_LINK_STATUS_URL, + CODEX_LINK_STOP_URL, + codexAutostartPlan, + codexBridgeArgs, + type CodexBridgeLaunch, + inspectCodexConfig, + parseCodexLinkSettings, + removeCodexLinkLine, + serializeCodexBridgeConfig, + serializeCodexLinkSettings +} from './codex-link-config' +import type { CodexLinkError, CodexLinkIdeState, CodexLinkPatch, CodexLinkStatus } from './codex-link-types' +import { hiddenWindowsChildOptions } from './windows-child-options' + +export type { CodexLinkError, CodexLinkIdeState, CodexLinkPatch, CodexLinkStatus } from './codex-link-types' + +export interface CodexLinkPaths { + /** `/codex-baglama.json` */ + settingsFile: string + /** `/codex-bridge` — the bridge's data folder. */ + bridgeHome: string + hermesHome: string + /** `/codex-bridge/bridge.py` */ + bridgeScript: string + /** Windowless interpreter, preferred. */ + pythonw: string + /** Console interpreter, used hidden when pythonw is absent. */ + python: string + /** `/config.toml` */ + codexConfig: string +} + +export interface CodexLinkDeps { + paths: CodexLinkPaths + exists: (file: string) => boolean + /** null when the file is missing; throws when it cannot be read losslessly. */ + readText: (file: string) => null | string + /** tmp + rename; creates the parent folder. */ + writeTextAtomic: (file: string, text: string) => void + /** Copy `source` to `target` unless `target` already exists. */ + backupOnce: (source: string, target: string) => void + /** Start the bridge detached and hidden; it must outlive the IDE. */ + spawnBridge: (command: string, args: string[], env: Record) => void + /** Parsed JSON body, or null when nothing answered within `budgetMs`. */ + request: (method: 'GET' | 'POST', url: string, budgetMs: number) => Promise + setAutostart: (command: string) => Promise + clearAutostart: () => Promise + sleep: (ms: number) => Promise + now: () => number + log: (message: string) => void +} + +// The single wait this feature owns: how long a freshly spawned bridge gets to +// answer its status endpoint before the apply gives up. +export const CODEX_BRIDGE_START_BUDGET_MS = 10_000 +const START_POLL_INTERVAL_MS = 500 +// A probe of an already-running (or absent) local bridge. Loopback answers or +// refuses at once; the bound only keeps Settings from hanging on a wedged port. +const PROBE_BUDGET_MS = 2_000 + +interface BridgeProbe { + ideDurum: CodexLinkIdeState | null + ideModel: null | number + ozet: boolean | null + running: boolean + surum: null | string +} + +const NOT_RUNNING: BridgeProbe = { ideDurum: null, ideModel: null, ozet: null, running: false, surum: null } + +function readProbe(body: unknown): BridgeProbe { + if (!body || typeof body !== 'object' || (body as { ok?: unknown }).ok !== true) { + return NOT_RUNNING + } + + const reply = body as { ide_durum?: unknown; ide_model?: unknown; ozet?: unknown; surum?: unknown } + const state = reply.ide_durum + + return { + ideDurum: state === 'tamam' || state === 'kapali' || state === 'anahtar-yok' ? state : null, + ideModel: typeof reply.ide_model === 'number' && Number.isFinite(reply.ide_model) ? reply.ide_model : null, + ozet: typeof reply.ozet === 'boolean' ? reply.ozet : null, + running: true, + surum: typeof reply.surum === 'string' ? reply.surum : null + } +} + +function describe(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +export interface CodexLink { + /** Side-effect free: reads the preference, the files and the status endpoint. */ + getStatus: () => Promise + /** Persist `patch.enabled` (if given), make the machine match, report. */ + apply: (patch?: CodexLinkPatch) => Promise +} + +export function createCodexLink(deps: CodexLinkDeps): CodexLink { + const { paths } = deps + let lastError: CodexLinkError | null = null + let queue: Promise = Promise.resolve() + + const launch: CodexBridgeLaunch = { + bridgeHome: paths.bridgeHome, + bridgeScript: paths.bridgeScript, + hermesHome: paths.hermesHome + } + + const fail = (code: CodexLinkError, error?: unknown) => { + lastError = code + deps.log(`[codex-link] ${code}${error === undefined ? '' : `: ${describe(error)}`}`) + } + + const readSettings = () => { + try { + return parseCodexLinkSettings(deps.readText(paths.settingsFile)) + } catch { + return parseCodexLinkSettings(null) + } + } + + // The interpreter to run the bridge with, or null while the IDE's own + // first-run setup has not produced the venv yet. + const interpreter = () => + deps.exists(paths.pythonw) ? paths.pythonw : deps.exists(paths.python) ? paths.python : null + + // The owner of Codex's base URL. An unreadable config counts as foreign for + // the purpose of "do not touch": we only edit what we could read back intact. + const readCodexConfig = (): { found: boolean; owner: 'absent' | 'foreign' | 'ours'; text: null | string } => { + if (!deps.exists(paths.codexConfig)) { + return { found: false, owner: 'absent', text: null } + } + + try { + const text = deps.readText(paths.codexConfig) + + return text === null + ? { found: false, owner: 'absent', text: null } + : { found: true, owner: inspectCodexConfig(text), text } + } catch (error) { + deps.log(`[codex-link] config.toml could not be read: ${describe(error)}`) + + return { found: true, owner: 'foreign', text: null } + } + } + + const probe = async (budgetMs = PROBE_BUDGET_MS): Promise => { + try { + return readProbe(await deps.request('GET', CODEX_LINK_STATUS_URL, budgetMs)) + } catch { + return NOT_RUNNING + } + } + + const statusFrom = ( + enabled: boolean, + config: { found: boolean; owner: string }, + bridge: BridgeProbe + ): CodexLinkStatus => ({ + enabled, + codexFound: config.found, + venvReady: interpreter() !== null, + foreign: config.owner === 'foreign', + bridgeRunning: bridge.running, + ideDurum: bridge.ideDurum, + ideModel: bridge.ideModel, + ozet: bridge.ozet, + lastError + }) + + const removeOurLine = (text: null | string): boolean => { + if (text === null) { + return true + } + + const edit = removeCodexLinkLine(text) + + if (!edit.changed) { + return true + } + + try { + deps.writeTextAtomic(paths.codexConfig, edit.text) + + return true + } catch (error) { + fail('config-write-failed', error) + + return false + } + } + + // Off: take back everything we put there. Each step is independent — a + // failure in one must not leave the others in place. + const tearDown = async (config: ReturnType): Promise => { + removeOurLine(config.text) + + try { + await deps.clearAutostart() + } catch (error) { + fail('autostart-failed', error) + } + + // A foreign config means whatever listens on the port serves someone + // else's setup; stopping it would break a bridge we do not own. + if (config.owner === 'foreign') { + return probe() + } + + try { + // No answer means nothing of ours is listening; leave it alone. + return (await deps.request('POST', CODEX_LINK_STOP_URL, PROBE_BUDGET_MS)) === null ? probe() : NOT_RUNNING + } catch { + return probe() + } + } + + const startBridge = async (python: string): Promise => { + try { + deps.spawnBridge(python, codexBridgeArgs(launch), { + AITURK_CODEX_BRIDGE_HOME: paths.bridgeHome, + HERMES_HOME: paths.hermesHome + }) + } catch (error) { + deps.log(`[codex-link] bridge spawn failed: ${describe(error)}`) + + return NOT_RUNNING + } + + const deadline = deps.now() + CODEX_BRIDGE_START_BUDGET_MS + + for (;;) { + const remaining = deadline - deps.now() + + if (remaining <= 0) { + return NOT_RUNNING + } + + const result = await probe(Math.min(PROBE_BUDGET_MS, remaining)) + + if (result.running) { + return result + } + + await deps.sleep(Math.min(START_POLL_INTERVAL_MS, Math.max(0, deadline - deps.now()))) + } + } + + // On: bridge.json → bridge up (verified) → config.toml → login item. + const setUp = async (python: string, config: ReturnType): Promise => { + if (!deps.exists(paths.bridgeScript)) { + fail('bridge-missing', paths.bridgeScript) + + return probe() + } + + try { + deps.writeTextAtomic(path.join(paths.bridgeHome, 'bridge.json'), serializeCodexBridgeConfig()) + } catch (error) { + fail('bridge-config-failed', error) + + return probe() + } + + let bridge = await probe() + + if (bridge.running && bridge.surum !== CODEX_BRIDGE_VERSION) { + // A bridge from an older IDE build is still listening: replace it. + try { + await deps.request('POST', CODEX_LINK_STOP_URL, PROBE_BUDGET_MS) + } catch { + // It stops answering once it exits; the start below verifies the new one. + } + + await deps.sleep(START_POLL_INTERVAL_MS * 2) + bridge = NOT_RUNNING + } + + if (!bridge.running) { + bridge = await startBridge(python) + } + + if (!bridge.running) { + fail('bridge-start-failed') + // Fail open: a line left over from an earlier run would now point Codex + // at a dead address. Without it Codex simply talks to OpenAI directly. + removeOurLine(config.text) + + return bridge + } + + const edit = addCodexLinkLine(config.text ?? '') + + if (edit.changed) { + try { + deps.backupOnce(paths.codexConfig, `${paths.codexConfig}${CODEX_LINK_BACKUP_SUFFIX}`) + deps.writeTextAtomic(paths.codexConfig, edit.text) + } catch (error) { + fail('config-write-failed', error) + + return bridge + } + } + + // Rewritten on every apply: an IDE update can move the bridge script. + try { + const plan = codexAutostartPlan(python, launch, path.join(paths.bridgeHome, CODEX_LINK_LAUNCHER_NAME)) + + if (!plan) { + throw new Error('the bridge command does not fit a Run value') + } + + if (plan.launcher) { + deps.writeTextAtomic(plan.launcher.path, plan.launcher.source) + } + + await deps.setAutostart(plan.command) + } catch (error) { + fail('autostart-failed', error) + } + + return bridge + } + + const run = async (patch?: CodexLinkPatch): Promise => { + lastError = null + + if (typeof patch?.enabled === 'boolean') { + try { + deps.writeTextAtomic(paths.settingsFile, serializeCodexLinkSettings(readSettings().stored, patch.enabled)) + } catch (error) { + // The preference did not stick, so do not act on it either: what the + // machine does must match what the file says after a restart. + fail('settings-write-failed', error) + } + } + + const { enabled } = readSettings() + const config = readCodexConfig() + + if (!enabled) { + return statusFrom(enabled, config, await tearDown(config)) + } + + const python = interpreter() + + if (!config.found || !python || config.owner === 'foreign') { + return statusFrom(enabled, config, await probe()) + } + + return statusFrom(enabled, config, await setUp(python, config)) + } + + return { + getStatus: async () => statusFrom(readSettings().enabled, readCodexConfig(), await probe()), + // One apply at a time: two interleaved runs could write the config line + // from one while the other is tearing the bridge down. + apply: patch => { + const next = queue.then(() => run(patch)) + queue = next.catch(() => undefined) + + return next + } + } +} + +// ── Real dependencies ─────────────────────────────────────────────────────── + +function writeTextAtomic(file: string, text: string): void { + fs.mkdirSync(path.dirname(file), { recursive: true }) + const tmp = `${file}.${process.pid}.tmp` + + try { + fs.writeFileSync(tmp, text, 'utf8') + fs.renameSync(tmp, file) + } catch (error) { + fs.rmSync(tmp, { force: true }) + + throw error + } +} + +// Decode as UTF-8 and insist the bytes survive the round trip. A config in +// another encoding would be corrupted by a rewrite, so refuse to return it. +function readTextLossless(file: string): null | string { + let bytes: Buffer + + try { + bytes = fs.readFileSync(file) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null + } + + throw error + } + + const text = bytes.toString('utf8') + + if (!Buffer.from(text, 'utf8').equals(bytes)) { + throw new Error('file is not valid UTF-8') + } + + return text +} + +function runReg(args: string[]): Promise<{ code: number; stderr: string }> { + const reg = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'reg.exe') + + return new Promise(resolve => { + execFile(reg, args, hiddenWindowsChildOptions({ encoding: 'utf8' }), (error, _stdout, stderr) => { + const exit = error ? (typeof (error as { code?: unknown }).code === 'number' ? (error as any).code : 1) : 0 + resolve({ code: exit, stderr: String(stderr || (error ? error.message : '')).trim() }) + }) + }) +} + +async function requestJson(method: 'GET' | 'POST', url: string, budgetMs: number): Promise { + try { + const response = await fetch(url, { method, signal: AbortSignal.timeout(Math.max(1, budgetMs)) }) + + return response.ok ? await response.json() : null + } catch { + return null + } +} + +export function createNodeCodexLinkDeps(paths: CodexLinkPaths, log: (message: string) => void): CodexLinkDeps { + return { + paths, + exists: file => fs.existsSync(file), + readText: readTextLossless, + writeTextAtomic, + backupOnce: (source, target) => { + if (!fs.existsSync(target)) { + fs.copyFileSync(source, target, fs.constants.COPYFILE_EXCL) + } + }, + spawnBridge: (command, args, env) => { + fs.mkdirSync(paths.bridgeHome, { recursive: true }) + + // Detached with no stdio and unref'd: the bridge is deliberately NOT a + // child the IDE cleans up on quit — Codex depends on it staying alive. + const child = spawn( + command, + args, + hiddenWindowsChildOptions({ + cwd: paths.bridgeHome, + detached: true, + env: { ...process.env, ...env }, + stdio: 'ignore' + }) as any + ) + + child.on('error', error => log(`[codex-link] bridge process error: ${error.message}`)) + child.unref() + }, + request: requestJson, + setAutostart: async command => { + const result = await runReg([ + 'add', + CODEX_LINK_RUN_KEY, + '/v', + CODEX_LINK_RUN_VALUE, + '/t', + 'REG_SZ', + '/d', + command, + '/f' + ]) + + if (result.code !== 0) { + throw new Error(`reg add exited ${result.code}: ${result.stderr}`) + } + }, + clearAutostart: async () => { + // `reg delete` fails on a value that is not there; absent is already the + // state we want, so look first. + const present = await runReg(['query', CODEX_LINK_RUN_KEY, '/v', CODEX_LINK_RUN_VALUE]) + + if (present.code !== 0) { + return + } + + const result = await runReg(['delete', CODEX_LINK_RUN_KEY, '/v', CODEX_LINK_RUN_VALUE, '/f']) + + if (result.code !== 0) { + throw new Error(`reg delete exited ${result.code}: ${result.stderr}`) + } + }, + sleep: ms => new Promise(resolve => setTimeout(resolve, ms)), + now: () => Date.now(), + log + } +} diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index a9817fe..d4ce82c 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -36,6 +36,7 @@ import { WebSocket as RelayWebSocket } from 'ws' import { classifyActiveRuntime, needsPackagedRuntimeUpgrade } from './active-runtime-state' import { createAiturkCliRunner, createAiturkLogin } from './aiturk-login' +import { createCodexLink, createNodeCodexLinkDeps } from './codex-link' import { createMobileBackendResolver } from './mobile-backend' import { createMobileLink, @@ -13091,6 +13092,9 @@ async function startHermes() { // accumulated count of the resolved episode. bootstrapRepairAttempt = 0 + // The local backend is up, so the venv the Codex bridge runs on exists. + syncCodexLinkOnBoot() + return { baseUrl, mode: 'local', @@ -17182,6 +17186,72 @@ ipcMain.handle('hermes:quick-entry:settings:set', async (_event, patch) => { return applyQuickEntrySettings(next) }) +// Codex Link ("Codex Bağlama"): lets the OpenAI Codex on this machine use the +// IDE's models through a small local bridge. Main owns the preference file, the +// bridge process, Codex's config line and the login item; Settings reads the +// resulting state and asks for changes over IPC. Windows only (the login item is +// a Run-key value). All decisions live in electron/codex-link.ts. +const AITURK_IDE_DATA_ROOT = path.dirname(HERMES_HOME) + +const codexLink = createCodexLink( + createNodeCodexLinkDeps( + { + settingsFile: path.join(AITURK_IDE_DATA_ROOT, 'codex-baglama.json'), + bridgeHome: path.join(AITURK_IDE_DATA_ROOT, 'codex-bridge'), + hermesHome: HERMES_HOME, + bridgeScript: IS_PACKAGED + ? path.join(process.resourcesPath, 'codex-bridge', 'bridge.py') + : path.join(APP_ROOT, 'assets', 'codex-bridge', 'bridge.py'), + pythonw: path.join(VENV_ROOT, 'Scripts', 'pythonw.exe'), + python: getVenvPython(VENV_ROOT), + codexConfig: path.join(process.env.CODEX_HOME || path.join(app.getPath('home'), '.codex'), 'config.toml') + }, + rememberLog + ) +) + +let codexLinkBootSynced = false + +// Once per launch, after the local backend is ready. Never allowed to affect +// startup: it is not awaited and every failure ends in the log. +function syncCodexLinkOnBoot() { + if (!IS_WINDOWS || codexLinkBootSynced) { + return + } + + codexLinkBootSynced = true + void codexLink.apply().catch(error => rememberLog(`[codex-link] boot sync failed: ${error?.message ?? error}`)) +} + +// Only the primary window's top-level frame may read or change the link. +function assertCodexLinkSender(event) { + const contents = mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null + + if ( + !IS_WINDOWS || + !contents || + event.sender !== contents || + (event.senderFrame && event.senderFrame !== contents.mainFrame) + ) { + throw new Error('codex-link:unavailable') + } +} + +ipcMain.handle('aiturk:codex-link:get', async event => { + assertCodexLinkSender(event) + + return codexLink.getStatus() +}) + +ipcMain.handle('aiturk:codex-link:set', async (event, patch) => { + assertCodexLinkSender(event) + + return codexLink.apply({ + enabled: typeof patch?.enabled === 'boolean' ? patch.enabled : undefined, + sync: patch?.sync === true + }) +}) + // Quick window → main → PRIMARY renderer. We never submit here: the renderer // owns the one prompt-submit path, and forwarding keeps it that way. The // payload is `{ target, text }` — target routing (current chat / a picked diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index a01fec0..f54b8f9 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -290,6 +290,16 @@ contextBridge.exposeInMainWorld('hermesDesktop', { // Opens the "Telefon bağlantısı" window (electron/mobile-host.mjs). open: () => ipcRenderer.invoke('aiturk:mobile:open') }, + // "Codex Bağlama" (electron/codex-link.ts). Windows only: absent elsewhere, + // so Settings does not offer a page that cannot do anything. + ...(process.platform === 'win32' + ? { + codexLink: { + get: () => ipcRenderer.invoke('aiturk:codex-link:get'), + set: patch => ipcRenderer.invoke('aiturk:codex-link:set', patch) + } + } + : {}), mcpOauth: { // One-shot loopback listener for MCP OAuth against remote backends: bind // on this machine, hand redirectUri to mcp.servers.oauth.start, then wait diff --git a/apps/desktop/package.json b/apps/desktop/package.json index f6a6cb9..159e1d0 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -299,6 +299,14 @@ "*.dll", "manifest.json" ] + }, + { + "from": "assets/codex-bridge", + "to": "codex-bridge", + "filter": [ + "*.py", + "!test_*.py" + ] } ] }, diff --git a/apps/desktop/src/app/settings/codex-link-settings.test.tsx b/apps/desktop/src/app/settings/codex-link-settings.test.tsx new file mode 100644 index 0000000..cd18de2 --- /dev/null +++ b/apps/desktop/src/app/settings/codex-link-settings.test.tsx @@ -0,0 +1,193 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { I18nProvider } from '@/i18n/context' + +import type { CodexLinkStatus } from '../../../electron/codex-link-types' + +import { CodexLinkSettings } from './codex-link-settings' + +const notifyError = vi.hoisted(() => vi.fn()) +vi.mock('@/store/notifications', () => ({ notifyError })) + +const RUNNING: CodexLinkStatus = { + enabled: true, + codexFound: true, + venvReady: true, + foreign: false, + bridgeRunning: true, + ideDurum: 'tamam', + ideModel: 12, + ozet: true, + lastError: null +} + +const get = vi.fn<() => Promise>() +const set = vi.fn<(patch: { enabled?: boolean; sync?: boolean }) => Promise>() + +function renderPage(locale: 'en' | 'tr' = 'tr') { + return render( + + + + ) +} + +function statusTexts() { + return screen.getAllByRole('listitem').map(item => item.textContent) +} + +beforeEach(() => { + ;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { codexLink: { get, set } } +}) + +afterEach(() => { + cleanup() + vi.resetAllMocks() + delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop +}) + +describe('Codex Link settings', () => { + it('explains the feature and shows a healthy link in Turkish', async () => { + get.mockResolvedValue(RUNNING) + renderPage() + + expect(await screen.findByText('Köprü çalışıyor.')).toBeTruthy() + expect(statusTexts()).toEqual(['Bu bilgisayarda Codex bulundu.', 'Köprü çalışıyor.', "Codex'e 12 model eklendi."]) + expect( + screen.getByText( + "AITURK IDE'deki modelleri ve comboları bu bilgisayardaki Codex'e ekler; modeller arasında geçerken sohbet bağlamını korur." + ) + ).toBeTruthy() + expect(screen.getByText("Codex'te yeni modelleri görmek için Codex'i yeniden açın.")).toBeTruthy() + expect(screen.getByRole('switch', { name: 'Codex Bağlama' }).getAttribute('aria-checked')).toBe('true') + expect(set).not.toHaveBeenCalled() + }) + + it('renders in English for other locales', async () => { + get.mockResolvedValue(RUNNING) + renderPage('en') + + expect(await screen.findByText('The bridge is running.')).toBeTruthy() + expect(statusTexts()).toEqual([ + 'Codex was found on this computer.', + 'The bridge is running.', + '12 models added to Codex.' + ]) + expect(screen.getByRole('button', { name: 'Sync now' })).toBeTruthy() + }) + + it('says only that Codex is missing when it is not installed', async () => { + get.mockResolvedValue({ ...RUNNING, codexFound: false, bridgeRunning: false, ideDurum: null, ideModel: null }) + renderPage() + + expect(await screen.findByText('Bu bilgisayarda Codex bulunamadı.')).toBeTruthy() + expect(statusTexts()).toEqual(['Bu bilgisayarda Codex bulunamadı.']) + }) + + it('says another bridge is in charge and does not claim ours is running', async () => { + get.mockResolvedValue({ ...RUNNING, foreign: true }) + renderPage() + + expect(await screen.findByText(/başka bir köprü kullanıyor/)).toBeTruthy() + expect(statusTexts()).toEqual([ + 'Bu bilgisayarda Codex bulundu.', + 'Bu bilgisayarda Codex başka bir köprü kullanıyor; ayarına dokunulmadı.' + ]) + }) + + it('says it is waiting for the IDE setup', async () => { + get.mockResolvedValue({ ...RUNNING, venvReady: false, bridgeRunning: false, ideDurum: null, ideModel: null }) + renderPage() + + expect(await screen.findByText('IDE kurulumu tamamlanınca etkinleşecek.')).toBeTruthy() + expect(screen.queryByText('Köprü çalışmıyor.')).toBeNull() + }) + + it('asks for a sign-in when the bridge has no IDE session', async () => { + get.mockResolvedValue({ ...RUNNING, ideDurum: 'anahtar-yok', ideModel: 0 }) + renderPage() + + expect(await screen.findByText('IDE oturumu bulunamadı, giriş yapın.')).toBeTruthy() + expect(screen.queryByText(/model eklendi/)).toBeNull() + }) + + it('shows a stopped bridge together with the last error', async () => { + get.mockResolvedValue({ + ...RUNNING, + bridgeRunning: false, + ideDurum: null, + ideModel: null, + lastError: 'bridge-start-failed' + }) + renderPage() + + expect(await screen.findByText('Köprü çalışmıyor.')).toBeTruthy() + expect(screen.getByText('Köprü başlatılamadı; Codex ayarına dokunulmadı.')).toBeTruthy() + }) + + it('turns the link off through the bridge and adopts the reply', async () => { + get.mockResolvedValue(RUNNING) + set.mockResolvedValue({ ...RUNNING, enabled: false, bridgeRunning: false, ideDurum: null, ideModel: null }) + renderPage() + + await screen.findByText('Köprü çalışıyor.') + fireEvent.click(screen.getByRole('switch', { name: 'Codex Bağlama' })) + + expect(set).toHaveBeenCalledWith({ enabled: false }) + expect(await screen.findByText('Codex Bağlama kapalı. Codex kendi ayarlarıyla çalışıyor.')).toBeTruthy() + expect(screen.getByRole('switch', { name: 'Codex Bağlama' }).getAttribute('aria-checked')).toBe('false') + }) + + it('rolls the switch back and reports when the change fails', async () => { + get.mockResolvedValue(RUNNING) + set.mockRejectedValue(new Error('codex-link:unavailable')) + renderPage() + + await screen.findByText('Köprü çalışıyor.') + fireEvent.click(screen.getByRole('switch', { name: 'Codex Bağlama' })) + + await waitFor(() => expect(notifyError).toHaveBeenCalledWith(expect.any(Error), 'Codex Bağlama değiştirilemedi.')) + expect(screen.getByRole('switch', { name: 'Codex Bağlama' }).getAttribute('aria-checked')).toBe('true') + expect(screen.getByText('Köprü çalışıyor.')).toBeTruthy() + }) + + it('syncs on demand and keeps the button disabled while it runs', async () => { + get.mockResolvedValue({ ...RUNNING, bridgeRunning: false, ideDurum: null, ideModel: null }) + + let finish: (status: CodexLinkStatus) => void = () => undefined + + set.mockReturnValue( + new Promise(resolve => { + finish = resolve + }) + ) + renderPage() + + await screen.findByText('Köprü çalışmıyor.') + fireEvent.click(screen.getByRole('button', { name: 'Şimdi eşitle' })) + + expect(set).toHaveBeenCalledTimes(1) + expect(set).toHaveBeenCalledWith({ sync: true }) + + const pending = screen.getByRole('button', { name: 'Eşitleniyor…' }) as HTMLButtonElement + + expect(pending.disabled).toBe(true) + fireEvent.click(pending) + expect(set).toHaveBeenCalledTimes(1) + + finish(RUNNING) + + expect(await screen.findByText('Köprü çalışıyor.')).toBeTruthy() + expect((screen.getByRole('button', { name: 'Şimdi eşitle' }) as HTMLButtonElement).disabled).toBe(false) + }) + + it('offers nothing to click where the desktop bridge is absent', () => { + delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop + renderPage() + + expect(screen.getByText('Codex Bağlama yalnızca Windows masaüstü uygulamasında kullanılabilir.')).toBeTruthy() + expect(screen.queryByRole('switch')).toBeNull() + expect(get).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/app/settings/codex-link-settings.tsx b/apps/desktop/src/app/settings/codex-link-settings.tsx new file mode 100644 index 0000000..19b0e8a --- /dev/null +++ b/apps/desktop/src/app/settings/codex-link-settings.tsx @@ -0,0 +1,233 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Switch } from '@/components/ui/switch' +import { type CodexLinkCopy, useCodexLinkCopy } from '@/i18n/codex-link' +import { triggerHaptic } from '@/lib/haptics' +import { AlertTriangle, Check, CircleIcon, Link, RefreshCw } from '@/lib/icons' +import { cn } from '@/lib/utils' +import { notifyError } from '@/store/notifications' + +import type { CodexLinkPatch, CodexLinkStatus } from '../../../electron/codex-link-types' + +import { EmptyState, ListRow, ListRowSkeleton, SettingsContent, SettingsSection } from './primitives' + +type Tone = 'muted' | 'ok' | 'warn' + +interface StatusLine { + id: string + text: string + tone: Tone +} + +/** + * What the user needs to know, top-down, stopping at the first fact that makes + * the rest moot (no Codex → nothing else to say; another bridge → ours is not + * involved; setup pending → the bridge cannot be running yet). + */ +export function codexLinkStatusLines(status: CodexLinkStatus, copy: CodexLinkCopy): StatusLine[] { + const lines: StatusLine[] = [] + const error = status.lastError ? [{ id: 'error', text: copy.errors[status.lastError], tone: 'warn' as const }] : [] + + if (!status.codexFound) { + return [{ id: 'codex', text: copy.codexMissing, tone: 'muted' }, ...error] + } + + lines.push({ id: 'codex', text: copy.codexFound, tone: 'ok' }) + + if (status.foreign) { + return [...lines, { id: 'foreign', text: copy.foreign, tone: 'warn' }, ...error] + } + + if (!status.enabled) { + return [...lines, { id: 'off', text: copy.off, tone: 'muted' }, ...error] + } + + if (!status.venvReady) { + return [...lines, { id: 'venv', text: copy.venvPending, tone: 'muted' }, ...error] + } + + if (!status.bridgeRunning) { + return [...lines, { id: 'bridge', text: copy.bridgeStopped, tone: 'warn' }, ...error] + } + + lines.push({ id: 'bridge', text: copy.bridgeRunning, tone: 'ok' }) + + if (status.ideDurum === 'anahtar-yok') { + lines.push({ id: 'ide', text: copy.signIn, tone: 'warn' }) + } else if (status.ideDurum === 'tamam' && status.ideModel !== null) { + lines.push({ id: 'ide', text: copy.models(status.ideModel), tone: 'ok' }) + } + + return [...lines, ...error] +} + +const TONE_ICON = { muted: CircleIcon, ok: Check, warn: AlertTriangle } as const + +const TONE_CLASS: Record = { + muted: 'text-(--ui-text-tertiary)', + ok: 'text-(--ui-text-secondary)', + warn: 'text-amber-500/90' +} + +// Phone-sized hit area around the compact switch: the visible control keeps the +// design-system size, the label that wraps it is the 44px target. +const TOUCH_TARGET = 'flex min-h-11 min-w-11 cursor-pointer items-center @2xl:justify-end' + +/** + * Settings → Codex Link. Main owns everything real (the preference file, the + * bridge, Codex's config, the login item); this page reads the state it reports + * and asks for changes, adopting whatever comes back as the truth. + */ +export function CodexLinkSettings() { + const copy = useCodexLinkCopy() + const bridge = typeof window === 'undefined' ? undefined : window.hermesDesktop?.codexLink + const [status, setStatus] = useState(null) + const [loadFailed, setLoadFailed] = useState(false) + const [busy, setBusy] = useState<'sync' | 'toggle' | null>(null) + // A slow initial read must not overwrite the answer to a newer change. + const generation = useRef(0) + + useEffect(() => { + if (!bridge) { + return + } + + const ticket = generation.current + + bridge.get().then( + next => { + if (generation.current === ticket) { + setStatus(next) + } + }, + () => { + if (generation.current === ticket) { + setLoadFailed(true) + } + } + ) + }, [bridge]) + + const change = useCallback( + async (patch: CodexLinkPatch, kind: 'sync' | 'toggle') => { + if (!bridge || busy) { + return + } + + const before = status + generation.current += 1 + setBusy(kind) + setLoadFailed(false) + + // The switch moves at once; main's reply (or the rollback) has the last word. + if (typeof patch.enabled === 'boolean' && before) { + setStatus({ ...before, enabled: patch.enabled }) + } + + try { + setStatus(await bridge.set(patch)) + } catch (error) { + setStatus(before) + notifyError(error, copy.saveFailed) + } finally { + setBusy(null) + } + }, + [bridge, busy, copy.saveFailed, status] + ) + + if (!bridge) { + return ( + + + + ) + } + + const lines = status ? codexLinkStatusLines(status, copy) : [] + + return ( + + + + { + triggerHaptic('selection') + void change({ enabled }, 'toggle') + }} + /> + + } + description={copy.description} + title={copy.title} + /> + + {!status && !loadFailed && } + + {loadFailed && !status && ( +

+ {copy.loadFailed} +

+ )} + + {status && ( + + {lines.map(line => { + const Icon = TONE_ICON[line.tone] + + return ( +
  • + + {line.text} +
  • + ) + })} + + } + title={copy.statusTitle} + wide + /> + )} + + { + triggerHaptic('open') + void change({ sync: true }, 'sync') + }} + type="button" + variant="secondary" + > + + {busy === 'sync' ? copy.syncing : copy.sync} + + } + description={copy.syncDesc} + title={copy.syncTitle} + /> + +

    + {copy.restartNote} +

    +
    +
    + ) +} diff --git a/apps/desktop/src/app/settings/index.tsx b/apps/desktop/src/app/settings/index.tsx index ea36d7f..a32b771 100644 --- a/apps/desktop/src/app/settings/index.tsx +++ b/apps/desktop/src/app/settings/index.tsx @@ -9,6 +9,7 @@ import { Tip } from '@/components/ui/tooltip' import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes' import { useI18n } from '@/i18n' import { useAiturkCopy } from '@/i18n/aiturk' +import { useCodexLinkCopy } from '@/i18n/codex-link' import { triggerHaptic } from '@/lib/haptics' import { Archive, @@ -20,6 +21,7 @@ import { Info, Keyboard, KeyRound, + Link, Package, RefreshCw, Search, @@ -46,6 +48,7 @@ import { SKILLS_ROUTE } from '../routes' import { AboutSettings } from './about-settings' import { AppearanceSettings } from './appearance-settings' import { BillingSettings } from './billing' +import { CodexLinkSettings } from './codex-link-settings' import { ConfigSettings } from './config-settings' import { SECTIONS } from './constants' import { GatewaySettings } from './gateway-settings' @@ -68,6 +71,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [ 'keys', 'notifications', 'billing', + 'codex-link', 'plugins', 'sessions', 'about' @@ -76,6 +80,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) { const { t } = useI18n() const aiturkCopy = useAiturkCopy() + const codexLinkCopy = useCodexLinkCopy() const navigate = useNavigate() const { hash, pathname, search } = useLocation() @@ -267,6 +272,19 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set } ] : []), + // "Codex Bağlama": present only where the desktop bridge offers it + // (Windows). A stale ?tab=codex-link elsewhere renders its own notice. + ...(window.hermesDesktop?.codexLink + ? [ + { + active: activeView === 'codex-link', + icon: Link, + id: 'codex-link', + label: codexLinkCopy.nav, + onSelect: () => setActiveView('codex-link') + } + ] + : []), { active: activeView === 'keybinds', icon: Keyboard, @@ -320,7 +338,7 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set onSelect: () => setActiveView('about') } ], - [activeView, keysView, providerView, t, aiturkCopy, setActiveView, openProviderView, openKeysView] + [activeView, keysView, providerView, t, aiturkCopy, codexLinkCopy, setActiveView, openProviderView, openKeysView] ) // Type-to-search: printable keystrokes on the Settings surface (outside any @@ -439,6 +457,8 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set ) : activeView === 'plugins' ? ( + ) : activeView === 'codex-link' ? ( + ) : ( ) diff --git a/apps/desktop/src/app/settings/types.ts b/apps/desktop/src/app/settings/types.ts index 5ecdf27..52b6e59 100644 --- a/apps/desktop/src/app/settings/types.ts +++ b/apps/desktop/src/app/settings/types.ts @@ -7,6 +7,7 @@ import type { EnvVarInfo } from '@/types/hermes' export type SettingsView = | 'about' | 'billing' + | 'codex-link' | 'connections' | 'gateway' | 'keybinds' diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index db4ac71..b2e4061 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -1,6 +1,7 @@ import type { GatewayWsUrlResult } from '@hermes/shared' import type { TranslucencyState } from '@hermes/shared/translucency' +import type { CodexLinkPatch, CodexLinkStatus } from '../electron/codex-link-types' import type { PoolLimits } from '../electron/pool-limits' import type { WakeIndicatorState } from './lib/wake-indicator' @@ -335,6 +336,13 @@ declare global { aiturkMobile?: { open: () => Promise<{ ok: true }> } + /** "Codex Bağlama" (electron/codex-link.ts): lets the Codex on this machine + * use the IDE's models. `get` only reads; `set` applies and returns the + * state that actually resulted. Windows only. */ + codexLink?: { + get: () => Promise + set: (patch: CodexLinkPatch) => Promise + } /** One-shot loopback callback listener for MCP OAuth against remote * backends (electron/mcp-oauth-callback-ipc.ts): bind on THIS machine, * pass redirectUri as client_redirect_uri to mcp.servers.oauth.start, diff --git a/apps/desktop/src/i18n/codex-link.ts b/apps/desktop/src/i18n/codex-link.ts new file mode 100644 index 0000000..5345958 --- /dev/null +++ b/apps/desktop/src/i18n/codex-link.ts @@ -0,0 +1,80 @@ +import type { CodexLinkError } from '../../electron/codex-link-types' + +import { useI18n } from './context' + +// Copy for Settings → Codex Link ("Codex Bağlama"). Kept beside the other +// AITURK-distribution strings (see ./aiturk): Turkish for `tr`, English for +// every other locale. +const en = { + nav: 'Codex Link', + title: 'Codex Link', + description: + 'Adds the models and combos from AITURK IDE to the Codex on this computer and keeps the conversation context when you switch between models.', + unavailable: 'Codex Link is only available in the Windows desktop app.', + loadFailed: 'Could not read the Codex Link status.', + saveFailed: 'Could not change Codex Link.', + statusTitle: 'Status', + off: 'Codex Link is off. Codex runs with its own settings.', + codexFound: 'Codex was found on this computer.', + codexMissing: 'Codex was not found on this computer.', + foreign: 'Codex on this computer uses another bridge; its settings were not touched.', + venvPending: 'It will turn on once the IDE setup is complete.', + bridgeRunning: 'The bridge is running.', + bridgeStopped: 'The bridge is not running.', + models: (count: number) => (count === 1 ? '1 model added to Codex.' : `${count} models added to Codex.`), + signIn: 'No IDE session was found; sign in.', + errors: { + 'autostart-failed': 'Could not set the bridge to start with Windows.', + 'bridge-config-failed': 'Could not write the bridge settings.', + 'bridge-missing': 'The bridge files are missing from this installation.', + 'bridge-start-failed': 'The bridge could not be started; the Codex settings were left unchanged.', + 'config-write-failed': 'Could not write the Codex settings file.', + 'settings-write-failed': 'Could not save this setting.' + } satisfies Record, + syncTitle: 'Sync now', + syncDesc: 'Checks the bridge and the Codex settings again.', + sync: 'Sync now', + syncing: 'Syncing…', + restartNote: 'Reopen Codex to see new models there.' +} + +const tr: typeof en = { + nav: 'Codex Bağlama', + title: 'Codex Bağlama', + description: + "AITURK IDE'deki modelleri ve comboları bu bilgisayardaki Codex'e ekler; modeller arasında geçerken sohbet bağlamını korur.", + unavailable: 'Codex Bağlama yalnızca Windows masaüstü uygulamasında kullanılabilir.', + loadFailed: 'Codex Bağlama durumu okunamadı.', + saveFailed: 'Codex Bağlama değiştirilemedi.', + statusTitle: 'Durum', + off: 'Codex Bağlama kapalı. Codex kendi ayarlarıyla çalışıyor.', + codexFound: 'Bu bilgisayarda Codex bulundu.', + codexMissing: 'Bu bilgisayarda Codex bulunamadı.', + foreign: 'Bu bilgisayarda Codex başka bir köprü kullanıyor; ayarına dokunulmadı.', + venvPending: 'IDE kurulumu tamamlanınca etkinleşecek.', + bridgeRunning: 'Köprü çalışıyor.', + bridgeStopped: 'Köprü çalışmıyor.', + models: (count: number) => `Codex'e ${count} model eklendi.`, + signIn: 'IDE oturumu bulunamadı, giriş yapın.', + errors: { + 'autostart-failed': 'Köprü Windows açılışında başlayacak şekilde ayarlanamadı.', + 'bridge-config-failed': 'Köprü ayarları yazılamadı.', + 'bridge-missing': 'Köprü dosyaları bu kurulumda bulunamadı.', + 'bridge-start-failed': 'Köprü başlatılamadı; Codex ayarına dokunulmadı.', + 'config-write-failed': 'Codex ayar dosyası yazılamadı.', + 'settings-write-failed': 'Bu ayar kaydedilemedi.' + }, + syncTitle: 'Şimdi eşitle', + syncDesc: 'Köprüyü ve Codex ayarını yeniden denetler.', + sync: 'Şimdi eşitle', + syncing: 'Eşitleniyor…', + restartNote: "Codex'te yeni modelleri görmek için Codex'i yeniden açın." +} + +export type CodexLinkCopy = typeof en + +export function useCodexLinkCopy(): CodexLinkCopy { + const { locale } = useI18n() + + return locale === 'tr' ? tr : en +} diff --git a/apps/desktop/vitest.mobile.config.ts b/apps/desktop/vitest.mobile.config.ts index 2654493..aca1ad6 100644 --- a/apps/desktop/vitest.mobile.config.ts +++ b/apps/desktop/vitest.mobile.config.ts @@ -6,7 +6,8 @@ export default defineConfig({ include: [ 'electron/mobile-*.test.ts', 'electron/first-run-setup*.test.ts', - 'electron/primary-backend-startup.test.ts' + 'electron/primary-backend-startup.test.ts', + 'electron/codex-link*.test.ts' ] } })