diff --git a/bridge/clients/desktop_app.py b/bridge/clients/desktop_app.py index 0b051932..b157a52d 100644 --- a/bridge/clients/desktop_app.py +++ b/bridge/clients/desktop_app.py @@ -28,7 +28,7 @@ def match(executable): class DesktopApp: def __init__(self, executable, home): - self.executable = Path(executable).expanduser().resolve() + self.executable = Path(self.resolve_executable(executable)).expanduser().resolve() if self.executable.suffix == '.app': try: self.executable = native_discovery('darwin').bundle_executable(self.executable) @@ -36,6 +36,13 @@ def __init__(self, executable, home): raise ValueError('请选择有效的 Codex / ChatGPT 应用程序包') from None self.home = Path(home).resolve() + @staticmethod + def resolve_executable(executable): + if executable and sys.platform == 'win32': + from bridge.platforms.windows.discovery_ext import updated_codex_executable + return updated_codex_executable(executable) + return executable + @staticmethod def discover(runtime): if not runtime: diff --git a/bridge/clients/manager.py b/bridge/clients/manager.py index ff15befa..b8814a0b 100644 --- a/bridge/clients/manager.py +++ b/bridge/clients/manager.py @@ -429,7 +429,7 @@ def _descriptor(self, provider): def _codex_executable(self, accounts): from bridge.clients.desktop_app import DesktopApp # Windows may cache the CLI outside its Store-installed desktop bundle. - return (accounts.index.get('desktopExecutable') or DesktopApp.discover(accounts.runtime) + return DesktopApp.resolve_executable(accounts.index.get('desktopExecutable') or DesktopApp.discover(accounts.runtime) or self.config.get('discovered', {}).get('codex', {}).get('executable', '')) def _verified(self, provider): diff --git a/bridge/features/accounts/accounts.py b/bridge/features/accounts/accounts.py index 5afe10dc..3426b718 100644 --- a/bridge/features/accounts/accounts.py +++ b/bridge/features/accounts/accounts.py @@ -219,7 +219,7 @@ def public(self): def desktop_status(self): result = self.public() with self.lock: - result.update(desktopExecutable=self.index.get('desktopExecutable') or DesktopApp.discover(self.runtime), + result.update(desktopExecutable=DesktopApp.resolve_executable(self.index.get('desktopExecutable') or DesktopApp.discover(self.runtime)), enrollment=self.enrollment, discovery=self.discovery, codexHome=str(self.home)) return result diff --git a/bridge/platforms/windows/discovery_ext.py b/bridge/platforms/windows/discovery_ext.py index b2d36bee..f4f298eb 100644 --- a/bridge/platforms/windows/discovery_ext.py +++ b/bridge/platforms/windows/discovery_ext.py @@ -61,6 +61,32 @@ def windows_installations(): return {} +def updated_codex_executable(executable): + """Follow a removed Store version only within its original package family.""" + executable = Path(executable) + if executable.is_file(): + return str(executable) + package = executable.parent.parent + match = re.fullmatch(r'OpenAI\.Codex_[\d.]+_(?:x64|x86|arm64|neutral)__([A-Za-z0-9]+)', + package.name, re.IGNORECASE) + if (not match or package.parent.name.casefold() != 'windowsapps' + or executable.parent.name.casefold() != 'app' + or executable.name.casefold() not in ('chatgpt.exe', 'codex.exe')): + return str(executable) + family = 'OpenAI.Codex_' + match.group(1) + matches = set() + inventory = windows_installations() + for row in _rows(inventory, 'packages'): + folder = _path(row.get('InstallLocation')) + if (str(row.get('Name', '')).casefold() != 'openai.codex' or not folder + or str(row.get('PackageFamilyName', '')).casefold() != family.casefold()): + continue + for candidate, _ in _package_entries(folder, ('ChatGPT.exe', 'Codex.exe')): + if candidate.is_file(): + matches.add(str(candidate.resolve())) + return next(iter(matches)) if len(matches) == 1 else str(executable) + + def _rows(inventory, key): value = inventory.get(key, []) if isinstance(value, dict): diff --git a/tests/test_client_lifecycle.py b/tests/test_client_lifecycle.py index 4ea7a8da..6da154bf 100644 --- a/tests/test_client_lifecycle.py +++ b/tests/test_client_lifecycle.py @@ -97,6 +97,19 @@ def test_explicit_codex_gui_keeps_precedence_even_when_missing(self): self.assertFalse(descriptor['installed']) discover.assert_not_called() + def test_status_resolves_updated_codex_without_rescan_or_launch(self): + selected = str(self.root/'removed-version.exe') + self.accounts.index['desktopExecutable'] = selected + replacement = self.manager.config['discovered']['codex']['executable'] + with patch('bridge.clients.desktop_app.DesktopApp.resolve_executable', return_value=replacement): + rows = self.manager.clients(refresh=True)['clients'] + self.assertEqual(self.manager._descriptor('codex')['executable'], replacement) + codex = next(row for row in rows if row['id'] == 'codex') + self.assertTrue(codex['running']) + self.assertTrue(codex['connected']) + self.assertEqual(self.accounts.index['desktopExecutable'], selected) + self.launch.assert_not_called() + def test_missing_scanned_codex_gui_does_not_report_configured(self): self.accounts.index['desktopExecutable'] = '' self.manager.config['discovered']['codex']['executable'] = str(self.root/'missing.exe') diff --git a/tests/test_windows_discovery.py b/tests/test_windows_discovery.py index 36599122..79abdeef 100644 --- a/tests/test_windows_discovery.py +++ b/tests/test_windows_discovery.py @@ -34,6 +34,33 @@ def scan(self, inventory, preferences=None): return discover_clients(preferences, platform='win32', home=self.home, env=self.env, windows_inventory=inventory) + def test_removed_store_versions_follow_same_registered_package_repeatedly(self): + from bridge.platforms.windows.discovery_ext import updated_codex_executable + selected = self.root/'WindowsApps/OpenAI.Codex_1.0_x64__publisher/app/ChatGPT.exe' + for version in ('2.0', '3.0'): + gui = self.executable(f'WindowsApps/OpenAI.Codex_{version}_x64__publisher/app/ChatGPT.exe') + (gui.parent.parent/'AppxManifest.xml').write_text( + '' + '' + '') + inventory = {'packages': [{'Name': 'OpenAI.Codex', + 'InstallLocation': str(gui.parent.parent), 'PackageFamilyName': 'OpenAI.Codex_publisher'}]} + with patch('bridge.platforms.windows.discovery_ext.windows_installations', return_value=inventory): + self.assertEqual(updated_codex_executable(selected), str(gui)) + gui.unlink() + + def test_store_resolution_preserves_custom_paths_and_rejects_other_families(self): + from bridge.platforms.windows.discovery_ext import updated_codex_executable + custom = self.root/'selected.exe' + with patch('bridge.platforms.windows.discovery_ext.windows_installations') as inventory: + self.assertEqual(updated_codex_executable(custom), str(custom)) + inventory.assert_not_called() + selected = self.root/'WindowsApps/OpenAI.Codex_1.0_x64__publisher/app/ChatGPT.exe' + with patch('bridge.platforms.windows.discovery_ext.windows_installations', return_value={ + 'packages': [{'Name': 'OpenAI.Codex', 'InstallLocation': str(self.root), + 'PackageFamilyName': 'OpenAI.Codex_other'}]}): + self.assertEqual(updated_codex_executable(selected), str(selected)) + def test_store_registration_finds_apps_without_listing_windowsapps(self): codex = self.executable('Protected/OpenAI.Codex_1/app/ChatGPT.exe') claude = self.executable('Protected/Claude_2/app/Claude.exe')