diff --git a/README.md b/README.md index 5624f30c..b6ad77d6 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,33 @@ # Codex++ +

+ Codex++ 图标 +

+ Codex++ 是一个面向 Codex App 的外部增强启动器。它不修改 Codex App 原始安装文件,而是通过外部 launcher 启动 Codex,并使用 Chromium DevTools Protocol 向渲染进程注入增强脚本。 +## 目录 + +- [功能](#功能) +- [痛点](#痛点) +- [解决效果](#解决效果) +- [讨论交流](#讨论交流) +- [友情链接](#友情链接) +- [工作方式](#工作方式) +- [环境要求](#环境要求) +- [Windows 使用](#windows-使用) + - [图形菜单安装/卸载](#图形菜单安装卸载) + - [命令行安装](#命令行安装) + - [命令行卸载](#命令行卸载) +- [macOS 使用](#macos-使用) + - [安装](#安装) + - [卸载](#卸载) +- [直接启动](#直接启动) +- [数据与备份](#数据与备份) +- [常见问题](#常见问题) + +## 功能 + 当前功能: - 在会话列表悬停显示"删除"按钮 @@ -39,7 +65,7 @@ Codex++ 启动后会解锁插件入口,并在会话列表悬停时显示删除 欢迎扫码加入 Codex++ 交流群,反馈问题、交流使用体验或提出新功能建议: -Codex++ 交流群二维码 +[画像:f5885733b2d29c3cf56f70b625343189] ## 友情链接 @@ -193,6 +219,57 @@ Codex++ 默认读取 Codex 本地数据库: ~/.codex-session-delete/launcher.log ``` +## Windows 自动接管(可选) + +默认情况下 Codex++ 只在你**从 `Codex++` 快捷方式启动时**生效。如果你从开始菜单、任务栏或系统原生入口直接启动 Codex,那一次不会有注入,`Codex++` 菜单和插件解锁都不会出现。 + +Windows 可以注册一个常驻 watcher 解决这个问题。它会每 3 秒探测一次本机 CDP 端口,发现 Codex 在跑但 CDP 没起来,就把这一批 Codex 进程杀掉、通过 launcher 重拉一次带注入的版本。这样不管你从哪里打开 Codex,都会被自动接管。 + +注意代价: + +- 每次 Codex 通过原生路径启动,都会先打开一瞬间,再被 kill,再被 launcher 带 CDP 重开。视觉上是 1 ~ 2 秒的"打开→关闭→重开"闪烁。 +- watcher 以 `pythonw.exe` 常驻运行,登录时自动启动(通过 `HKCU\...\Run` 和 Startup 文件夹双路径注册,后者防止某些注册表清理工具干扰)。 + +### 安装 + +```bash +python -m codex_session_delete watch-install +``` + +安装完成后 watcher 会立即启动,无需重启。 + +### 卸载 + +```bash +python -m codex_session_delete watch-remove +``` + +`remove` / 系统卸载 Codex++ 时会自动连带执行 `watch-remove`,不需要手动处理。 + +### 临时开关 + +保留自启项、但让 watcher 不再自动接管: + +```bash +python -m codex_session_delete watch-disable +python -m codex_session_delete watch-enable +``` + +### 日志 + +```text +%USERPROFILE%\.codex-session-delete\watcher.log +``` + +示例: + +```text +[...] watcher started (interval=3.0s) +[...] Codex running without CDP (pids=[...]); attempting takeover +[...] takeover: killing 4 codex pid(s): [...] +[...] takeover: CDP is up on 9229 (launcher pid=...) +``` + ## 常见问题 ### 双击 Codex++ 没反应 @@ -248,6 +325,7 @@ codex_session_delete/ storage_adapter.py 本地 SQLite 删除/撤销 windows_installer.py Windows 快捷方式与卸载项 macos_installer.py macOS app bundle 安装 + watcher.py Windows 常驻 watcher(可选,原生启动接管) inject/renderer-inject.js tests/ 自动化测试 diff --git a/codex_session_delete/__init__.py b/codex_session_delete/__init__.py index 3dc1f76b..92192eed 100644 --- a/codex_session_delete/__init__.py +++ b/codex_session_delete/__init__.py @@ -1 +1 @@ -__version__ = "0.1.0" +__version__ = "1.0.4" diff --git a/codex_session_delete/app_paths.py b/codex_session_delete/app_paths.py index b413b139..802b3d19 100644 --- a/codex_session_delete/app_paths.py +++ b/codex_session_delete/app_paths.py @@ -3,6 +3,7 @@ import os import re import sys +import subprocess from pathlib import Path @@ -16,13 +17,33 @@ def _version_tuple(path: Path) -> tuple[int, ...]: return tuple(int(part) for part in match.group(1).split(".") if part.isdigit()) -def find_latest_codex_app_dir(windows_apps_dir: Path | None = None) -> Path | None: - root = windows_apps_dir or Path("C:/Program Files/WindowsApps") - candidates = [path / "app" for path in root.glob("OpenAI.Codex_*_x64__*") if (path / "app").is_dir()] - if not candidates: - return None - return max(candidates, key=lambda app_dir: _version_tuple(app_dir.parent)) +def find_latest_codex_app_dir(root: Path | None = None) -> Path | None: + if root is not None: + matches = [path for path in root.iterdir() if path.is_dir() and _version_tuple(path)] + if not matches: + return None + latest = max(matches, key=_version_tuple) + app = latest / "app" + return app if app.is_dir() else latest + cmd = 'Get-AppxPackage -Name "OpenAI.Codex" | Select-Object -ExpandProperty InstallLocation' + try: + r = subprocess.run( + ["powershell", "-NoProfile", "-Command", cmd], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=8, + check=False, + ) + if r.returncode != 0 or not (p := r.stdout.strip()): + return None + root = Path(p) + app = root / "app" + return app if app.is_dir() else root + except (OSError, subprocess.SubprocessError): + return None def user_data_candidates() -> list[Path]: candidates: list[Path] = [] diff --git a/codex_session_delete/assets/codex-plus-plus.ico b/codex_session_delete/assets/codex-plus-plus.ico new file mode 100644 index 00000000..52f40f61 Binary files /dev/null and b/codex_session_delete/assets/codex-plus-plus.ico differ diff --git a/codex_session_delete/assets/codex-plus-plus.png b/codex_session_delete/assets/codex-plus-plus.png new file mode 100644 index 00000000..600fe358 Binary files /dev/null and b/codex_session_delete/assets/codex-plus-plus.png differ diff --git a/codex_session_delete/backup_store.py b/codex_session_delete/backup_store.py index c235700a..3a6fe996 100644 --- a/codex_session_delete/backup_store.py +++ b/codex_session_delete/backup_store.py @@ -14,6 +14,7 @@ def __init__(self, backup_dir: Path): def write_backup(self, session_id: str, source_db: str, tables: dict[str, list[dict[str, Any]]]) -> str: token = f"{int(time.time())}-{uuid.uuid4().hex}" + self.backup_dir.mkdir(parents=True, exist_ok=True) path = self.path_for(token) payload = { "token": token, diff --git a/codex_session_delete/cdp.py b/codex_session_delete/cdp.py index 00a8eefc..bcc39dec 100644 --- a/codex_session_delete/cdp.py +++ b/codex_session_delete/cdp.py @@ -10,6 +10,7 @@ BridgeHandler = Callable[[str, dict[str, object]], dict[str, object]] +BRIDGE_BINDING_NAME = "codexSessionDeleteV2" def list_targets(port: int) -> list[dict[str, object]]: @@ -36,7 +37,7 @@ def evaluate_script(websocket_url: str, script: str) -> dict[str, object]: payload = { "id": 1, "method": "Runtime.evaluate", - "params": {"expression": script, "awaitPromise": False}, + "params": {"expression": script, "awaitPromise": False, "allowUnsafeEvalBlockedByCSP": True}, } ws.send(json.dumps(payload)) while True: @@ -73,13 +74,11 @@ def build_bridge_script(binding_name: str) -> str: }}); }})(); """ - - def install_bridge(websocket_url: str, binding_name: str, handler: BridgeHandler) -> websocket.WebSocket: ws = websocket.create_connection(websocket_url, timeout=5) ws.send(json.dumps({"id": 1, "method": "Runtime.addBinding", "params": {"name": binding_name}})) _wait_for_id(ws, 1) - ws.send(json.dumps({"id": 2, "method": "Runtime.evaluate", "params": {"expression": build_bridge_script(binding_name), "awaitPromise": False}})) + ws.send(json.dumps({"id": 2, "method": "Runtime.evaluate", "params": {"expression": build_bridge_script(binding_name), "awaitPromise": False, "allowUnsafeEvalBlockedByCSP": True}})) _wait_for_id(ws, 2) thread = threading.Thread(target=_bridge_loop, args=(ws, handler), daemon=True) thread.start() @@ -90,13 +89,12 @@ def inject_file(port: int, script_path: Path, helper_port: int, handler: BridgeH targets = list_targets(port) target = pick_page_target(targets) websocket_url = str(target["webSocketDebuggerUrl"]) - bridge_socket = install_bridge(websocket_url, "codexSessionDelete", handler) if handler else None + bridge_socket = install_bridge(websocket_url, BRIDGE_BINDING_NAME, handler) if handler else None script = script_path.read_text(encoding="utf-8") prefix = f"window.__CODEX_SESSION_DELETE_HELPER__ = 'http://127.0.0.1:{helper_port}';\n" result = evaluate_script(websocket_url, prefix + script) return bridge_socket or result - def _bridge_loop(ws: websocket.WebSocket, handler: BridgeHandler) -> None: while True: try: @@ -121,12 +119,12 @@ def _bridge_loop(ws: websocket.WebSocket, handler: BridgeHandler) -> None: def _resolve_bridge(ws: websocket.WebSocket, request_id: str, result: dict[str, object]) -> None: expression = f"window.__codexSessionDeleteResolve({json.dumps(request_id)}, {json.dumps(result)})" - ws.send(json.dumps({"id": _next_id(), "method": "Runtime.evaluate", "params": {"expression": expression, "awaitPromise": False}})) + ws.send(json.dumps({"id": _next_id(), "method": "Runtime.evaluate", "params": {"expression": expression, "awaitPromise": False, "allowUnsafeEvalBlockedByCSP": True}})) def _reject_bridge(ws: websocket.WebSocket, request_id: str, message: str) -> None: expression = f"window.__codexSessionDeleteReject({json.dumps(request_id)}, {json.dumps(message)})" - ws.send(json.dumps({"id": _next_id(), "method": "Runtime.evaluate", "params": {"expression": expression, "awaitPromise": False}})) + ws.send(json.dumps({"id": _next_id(), "method": "Runtime.evaluate", "params": {"expression": expression, "awaitPromise": False, "allowUnsafeEvalBlockedByCSP": True}})) def _wait_for_id(ws: websocket.WebSocket, message_id: int) -> dict[str, object]: diff --git a/codex_session_delete/cli.py b/codex_session_delete/cli.py index b9bfe459..21df1b0e 100644 --- a/codex_session_delete/cli.py +++ b/codex_session_delete/cli.py @@ -1,13 +1,16 @@ from __future__ import annotations import argparse +import os +import subprocess +import sys import traceback -import time from pathlib import Path from codex_session_delete.helper_server import HelperServer from codex_session_delete.installers import InstallOptions, install_codex_plus_plus, uninstall_codex_plus_plus -from codex_session_delete.launcher import launch_and_inject +from codex_session_delete.launcher import launch_and_inject, shutdown_helper +from codex_session_delete import watcher def add_launch_arguments(parser: argparse.ArgumentParser) -> None: @@ -40,6 +43,17 @@ def build_parser() -> argparse.ArgumentParser: remove_parser.add_argument("--install-root", type=Path, default=None) remove_parser.add_argument("--remove-data", action="store_true") + watch_parser = subparsers.add_parser("watch", help="Run the Codex watcher loop (auto-reinject when Codex is launched normally)") + watch_parser.add_argument("--debug-port", type=int, default=9229) + + watch_install_parser = subparsers.add_parser("watch-install", help="Register the watcher to run at Windows logon") + watch_install_parser.add_argument("--debug-port", type=int, default=9229) + + subparsers.add_parser("watch-remove", help="Unregister the watcher logon task") + + subparsers.add_parser("watch-enable", help="Re-enable the watcher loop after it was disabled") + subparsers.add_parser("watch-disable", help="Disable the watcher loop without removing the logon task") + add_launch_arguments(parser) return parser @@ -56,26 +70,169 @@ def log_launch_failure(exc: BaseException) -> None: path.write_text("".join(traceback.format_exception(type(exc), exc, exc.__traceback__)), encoding="utf-8") -def wait_for_shutdown(server: HelperServer) -> None: +def wait_for_windows_process_id(process_id: int) -> None: + if sys.platform != "win32": + return + import ctypes + + synchronize = 0x00100000 + infinite = 0xFFFFFFFF + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.OpenProcess.argtypes = [ctypes.c_ulong, ctypes.c_int, ctypes.c_ulong] + kernel32.OpenProcess.restype = ctypes.c_void_p + kernel32.WaitForSingleObject.argtypes = [ctypes.c_void_p, ctypes.c_ulong] + kernel32.WaitForSingleObject.restype = ctypes.c_ulong + kernel32.CloseHandle.argtypes = [ctypes.c_void_p] + kernel32.CloseHandle.restype = ctypes.c_int + + handle = kernel32.OpenProcess(synchronize, False, process_id) + if not handle: + return try: - while True: - time.sleep(3600) + kernel32.WaitForSingleObject(handle, infinite) + finally: + kernel32.CloseHandle(handle) + + +def wait_for_shutdown(server: HelperServer, codex_proc) -> None: + try: + if isinstance(codex_proc, int): + wait_for_windows_process_id(codex_proc) + elif codex_proc is None and sys.platform == "darwin": + import subprocess as _sp + import time as _time + while True: + result = _sp.run(["pgrep", "-f", "^/Applications/Codex\\.app/Contents/MacOS/Codex"], capture_output=True) + if result.returncode != 0: + break + _time.sleep(2) + elif codex_proc is not None: + codex_proc.wait() except KeyboardInterrupt: - server.shutdown() + pass + finally: + shutdown_helper(server) + + +def stop_existing_windows_launchers() -> None: + if sys.platform != "win32": + return + current_pid = os.getpid() + script = ( + "Get-CimInstance Win32_Process | " + "Where-Object { $_.ProcessId -ne $env:CODEX_PLUS_PLUS_PID -and " + "$_.CommandLine -match 'pythonw?(.exe)?\"?\\s+-m\\s+codex_session_delete\\s+launch(\\s|$)' } | " + "ForEach-Object { Stop-Process -Id $_.ProcessId -Force }" + ) + env = {**os.environ, "CODEX_PLUS_PLUS_PID": str(current_pid)} + subprocess.run(["powershell", "-NoProfile", "-Command", script], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=env) def run_launch(args: argparse.Namespace) -> int: + stop_existing_windows_launchers() try: - server = launch_and_inject(args.app_dir, args.db, args.backup_dir, args.debug_port, args.helper_port) + server, codex_proc = launch_and_inject(args.app_dir, args.db, args.backup_dir, args.debug_port, args.helper_port) except Exception as exc: log_launch_failure(exc) raise print(f"Codex session delete helper running on http://127.0.0.1:{server.port}") print("Keep this terminal open while using the delete buttons. Press Ctrl+C to stop.") - wait_for_shutdown(server) + wait_for_shutdown(server, codex_proc) return 0 +WATCHER_RUN_NAME = "CodexPlusPlusWatcher" +WATCHER_RUN_KEY = "HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run" +WATCHER_STARTUP_SHORTCUT_NAME = "CodexPlusPlusWatcher.lnk" + + +def _watcher_command(debug_port: int) -> tuple[str, str, str]: + python = sys.executable + pythonw = Path(python).with_name("pythonw.exe") + exe = str(pythonw if pythonw.exists() else python) + arguments = f"-m codex_session_delete watch --debug-port {debug_port}" + full = f'"{exe}" {arguments}' + return exe, arguments, full + + +def _ps_quote(value: str) -> str: + return "'" + value.replace("'", "''") + "'" + + +def install_watcher_logon_task(debug_port: int) -> None: + if sys.platform != "win32": + raise RuntimeError("watch-install is only supported on Windows") + exe, arguments, full_command = _watcher_command(debug_port) + project_root = str(Path(__file__).resolve().parent.parent) + script = f""" +$ErrorActionPreference = 'Stop' +$Exe = {_ps_quote(exe)} +$Args = {_ps_quote(arguments)} +$RunFullCommand = {_ps_quote(full_command)} +$ProjectRoot = {_ps_quote(project_root)} +$ShortcutName = {_ps_quote(WATCHER_STARTUP_SHORTCUT_NAME)} +# 1) HKCU Run value +New-Item -Path '{WATCHER_RUN_KEY}' -Force | Out-Null +Set-ItemProperty -Path '{WATCHER_RUN_KEY}' -Name '{WATCHER_RUN_NAME}' -Value $RunFullCommand +# 2) Startup folder .lnk (survives registry cleanups) +$Startup = [Environment]::GetFolderPath('Startup') +New-Item -ItemType Directory -Force -Path $Startup | Out-Null +$Shell = New-Object -ComObject WScript.Shell +$ShortcutPath = Join-Path $Startup $ShortcutName +$Shortcut = $Shell.CreateShortcut($ShortcutPath) +$Shortcut.TargetPath = $Exe +$Shortcut.Arguments = $Args +$Shortcut.WorkingDirectory = $ProjectRoot +$Shortcut.WindowStyle = 7 +$Shortcut.Description = 'Codex++ watcher (auto-inject Codex on start)' +$Shortcut.Save() +# 3) Echo what was written for verification +$runValue = (Get-ItemProperty -Path '{WATCHER_RUN_KEY}' -Name '{WATCHER_RUN_NAME}').'{WATCHER_RUN_NAME}' +Write-Output ("watch-install: HKCU Run = " + $runValue) +Write-Output ("watch-install: Startup shortcut = " + $ShortcutPath) +""".strip() + result = subprocess.run( + ["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script], + check=True, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + if result.stdout: + print(result.stdout.strip()) + # Start the watcher right now as well. + subprocess.Popen( + [exe, "-m", "codex_session_delete", "watch", "--debug-port", str(debug_port)], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + close_fds=True, + creationflags=( + subprocess.CREATE_NEW_PROCESS_GROUP + | getattr(subprocess, "DETACHED_PROCESS", 0x00000008) + | getattr(subprocess, "CREATE_NO_WINDOW", 0) + ), + ) + print("watch-install: watcher process spawned") + + +def uninstall_watcher_logon_task() -> None: + if sys.platform != "win32": + return + script = f""" +Remove-ItemProperty -Path '{WATCHER_RUN_KEY}' -Name '{WATCHER_RUN_NAME}' -ErrorAction SilentlyContinue | Out-Null +$Startup = [Environment]::GetFolderPath('Startup') +$ShortcutPath = Join-Path $Startup {_ps_quote(WATCHER_STARTUP_SHORTCUT_NAME)} +if (Test-Path $ShortcutPath) {{ Remove-Item $ShortcutPath -Force -ErrorAction SilentlyContinue }} +Get-CimInstance Win32_Process -Filter "Name='pythonw.exe' OR Name='python.exe'" | Where-Object {{ $_.CommandLine -match 'codex_session_delete\\s+watch' }} | ForEach-Object {{ Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }} +""".strip() + subprocess.run( + ["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script], + check=False, + ) + + def main(argv: list[str] | None = None) -> int: args = build_parser().parse_args(argv) if args.command in {"install", "setup"}: @@ -83,6 +240,21 @@ def main(argv: list[str] | None = None) -> int: return 0 if args.command in {"uninstall", "remove"}: uninstall_codex_plus_plus(InstallOptions(install_root=args.install_root, remove_data=args.remove_data)) + uninstall_watcher_logon_task() + return 0 + if args.command == "watch": + return watcher.watch_loop(debug_port=args.debug_port) + if args.command == "watch-install": + install_watcher_logon_task(args.debug_port) + return 0 + if args.command == "watch-remove": + uninstall_watcher_logon_task() + return 0 + if args.command == "watch-enable": + watcher.enable_watcher() + return 0 + if args.command == "watch-disable": + watcher.disable_watcher() return 0 return run_launch(args) diff --git a/codex_session_delete/helper_server.py b/codex_session_delete/helper_server.py index 04da4975..0b5c753a 100644 --- a/codex_session_delete/helper_server.py +++ b/codex_session_delete/helper_server.py @@ -10,6 +10,7 @@ class DeleteService(Protocol): def delete(self, session: SessionRef) -> DeleteResult: ... def undo(self, token: str) -> DeleteResult: ... + def find_archived_thread_by_title(self, title: str) -> SessionRef | None: ... class HelperServer(ThreadingHTTPServer): @@ -45,6 +46,10 @@ def do_POST(self) -> None: token = str(payload.get("undo_token", "")) self._send_json(self.server.service.undo(token).to_dict()) return + if self.path == "/archived-thread": + session = self.server.service.find_archived_thread_by_title(str(payload.get("title", ""))) + self._send_json({"session_id": session.session_id, "title": session.title} if session else {"session_id": "", "title": ""}) + return self._send_json({"error": "not found"}, status=404) except Exception as exc: result = DeleteResult(DeleteStatus.FAILED, str(payload.get("session_id", "")) if "payload" in locals() else "", str(exc)) diff --git a/codex_session_delete/inject/renderer-inject.js b/codex_session_delete/inject/renderer-inject.js index 8738ff72..15d20448 100644 --- a/codex_session_delete/inject/renderer-inject.js +++ b/codex_session_delete/inject/renderer-inject.js @@ -2,13 +2,20 @@ const helperBase = window.__CODEX_SESSION_DELETE_HELPER__ || "http://127.0.0.1:57321"; const buttonClass = "codex-delete-button"; const styleId = "codex-delete-style"; + const codexDeleteStyleVersion = "4"; const codexPlusMenuId = "codex-plus-menu"; + const codexDeleteVersion = "5"; + const codexArchiveDeleteAllVersion = "2"; + const codexPlusVersion = "1.0.4"; const codexPlusSettingsKey = "codexPlusSettings"; function installStyle() { - if (document.getElementById(styleId)) return; + const existingStyle = document.getElementById(styleId); + if (existingStyle?.dataset.codexDeleteStyleVersion === codexDeleteStyleVersion) return; + existingStyle?.remove(); const style = document.createElement("style"); style.id = styleId; + style.dataset.codexDeleteStyleVersion = codexDeleteStyleVersion; style.textContent = ` .${buttonClass} { position: absolute; @@ -27,33 +34,108 @@ cursor: pointer; } [data-codex-delete-row="true"]:hover .${buttonClass} { opacity: 1; } + [data-codex-delete-row="true"].codex-archive-confirm-visible .${buttonClass} { right: 66px; } + .codex-archive-delete-all { + border: 1px solid #ef4444; + border-radius: 7px; + background: #fee2e2; + color: #991b1b; + font: 12px system-ui, sans-serif; + line-height: 16px; + padding: 3px 8px; + cursor: pointer; + } + .codex-archive-action-bar { + position: fixed; + right: 28px; + top: 86px; + z-index: 2147482999; + box-shadow: 0 8px 24px rgba(0,0,0,.18); + } .codex-delete-toast { position: fixed; right: 18px; bottom: 18px; - z-index: 2147483647; + z-index: 2147483000; padding: 10px 12px; border-radius: 8px; background: #111827; color: white; font: 13px system-ui, sans-serif; box-shadow: 0 8px 30px rgba(0,0,0,.25); + pointer-events: none; + } + .codex-delete-toast button { margin-left: 10px; pointer-events: auto; } + .codex-delete-confirm-overlay { + position: fixed; + inset: 0; + z-index: 2147483200; + display: flex; + align-items: center; + justify-content: center; + background: rgba(15,23,42,.28); + } + .codex-delete-confirm-content { + width: min(420px, calc(100vw - 48px)); + border: 1px solid rgba(15,23,42,.12); + border-radius: 12px; + background: #ffffff; + color: #111827; + font: 14px system-ui, sans-serif; + box-shadow: 0 24px 80px rgba(15,23,42,.22); + padding: 18px; + } + .codex-delete-confirm-title { font-size: 16px; font-weight: 650; } + .codex-delete-confirm-message { margin-top: 8px; color: #4b5563; line-height: 1.45; } + .codex-delete-confirm-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 18px; + } + .codex-delete-confirm-actions button { + border: 1px solid #d1d5db; + border-radius: 7px; + padding: 6px 12px; + background: #ffffff; + color: #111827; + font: 13px system-ui, sans-serif; + } + .codex-delete-confirm-actions [data-codex-delete-confirm="true"] { + border-color: #ef4444; + background: #dc2626; } - .codex-delete-toast button { margin-left: 10px; } #${codexPlusMenuId}.codex-plus-menu-floating { position: fixed; top: 0; - left: 240px; + right: 140px; + left: auto; z-index: 2147483645; height: 30px; color: #d1d5db; font: 13px system-ui, sans-serif; + text-align: right; + pointer-events: auto; + -webkit-app-region: no-drag; } #${codexPlusMenuId} { display: inline-flex; align-items: center; height: 100%; flex: 0 0 auto; + pointer-events: auto; + -webkit-app-region: no-drag; + } + .codex-plus-trigger { + border: 0; + background: transparent; + color: inherit; + font: inherit; + height: 100%; + padding: 0 8px; + cursor: pointer; + pointer-events: auto; + -webkit-app-region: no-drag; } .codex-plus-modal-overlay { position: fixed; @@ -123,7 +205,7 @@ } function defaultCodexPlusSettings() { - return { pluginEntryUnlock: true, forcePluginInstall: true, sessionDelete: true }; + return { pluginEntryUnlock: true, forcePluginInstall: true, sessionDelete: true, nativeMenuPlacement: true }; } function codexPlusSettings() { @@ -156,7 +238,7 @@ overlay.innerHTML = `
-
Codex++
+
Codex++ ${codexPlusVersion}
@@ -172,9 +254,17 @@
会话删除
在会话列表悬停显示删除按钮,并支持撤销。
+
+
原生菜单栏位置
把 Codex++ 菜单插入顶部原生菜单栏;默认关闭以避免页面重渲染冲突。
+ +
关于 Codex++
Codex++ 是通过外部 launcher 注入的增强菜单,不修改 Codex App 原始安装文件。
GitHub: https://github.com/BigPizzaV3/CodexPlusPlus
+
+
提出问题
打开 GitHub Issues 反馈问题或建议。
+ +
`; @@ -183,6 +273,12 @@ overlay.remove(); return; } + const issueButton = event.target.closest("[data-codex-plus-issue]"); + if (issueButton) { + const issueUrl = "https://github.com/BigPizzaV3/CodexPlusPlus/issues"; + window.open(issueUrl, "_blank"); + return; + } const toggle = event.target.closest("[data-codex-plus-setting]"); if (!toggle) return; const key = toggle.getAttribute("data-codex-plus-setting"); @@ -193,6 +289,7 @@ } function findNativeMenuInsertionPoint() { + if (!codexPlusSettings().nativeMenuPlacement) return null; const header = document.querySelector(".app-header-tint"); const menuBar = header?.querySelector(".flex.items-center.gap-0\\.5") || header?.querySelector('[class*="flex items-center gap-0.5"]'); if (!menuBar) return null; @@ -205,7 +302,7 @@ if (node !== keep) node.remove(); }); Array.from(document.querySelectorAll("button")).forEach((button) => { - if ((button.textContent || "").trim() === "Codex++" && !button.closest(`#${codexPlusMenuId}`)) { + if ((button.textContent || "").trim() === `Codex++ ${codexPlusVersion}` && !button.closest(`#${codexPlusMenuId}`)) { button.remove(); } }); @@ -241,7 +338,7 @@ menu.dataset.codexPlusMenuVersion = "5"; const trigger = document.createElement("button"); trigger.type = "button"; - trigger.textContent = "Codex++"; + trigger.textContent = `Codex++ ${codexPlusVersion}`; const nativeButtonClass = insertionPoint?.nativeButtonClass || "codex-plus-trigger"; configureCodexPlusTrigger(menu, trigger, nativeButtonClass); menu.appendChild(trigger); @@ -282,7 +379,7 @@ function enablePluginEntry() { if (!codexPlusSettings().pluginEntryUnlock) return; const buttons = Array.from(document.querySelectorAll("button")); - const pluginButton = buttons.find((element) => (element.textContent || "").trim() === "插件"); + const pluginButton = buttons.find((element) => /^(插件|Plugins)$/i.test((element.textContent || "").trim())); if (!pluginButton) return; spoofChatGPTAuthMethod(pluginButton); pluginButton.disabled = false; @@ -323,17 +420,54 @@ }); } - function sessionRows() { + let cachedSessionRows = []; + let cachedSessionRowsAt = 0; + + function sessionRows(forceRefresh = false) { + const now = Date.now(); + if (!forceRefresh && now - cachedSessionRowsAt < 150) { + cachedSessionRows = cachedSessionRows.filter((row) => row.isConnected); + if (cachedSessionRows.length> 0) return cachedSessionRows; + } + const codexThreads = Array.from(document.querySelectorAll('[data-app-action-sidebar-thread-id]')); - if (codexThreads.length> 0) return codexThreads; + if (codexThreads.length> 0) { + cachedSessionRows = codexThreads; + cachedSessionRowsAt = now; + return cachedSessionRows; + } - const candidates = Array.from(document.querySelectorAll("a, button, [role='button'], [data-testid], li, div")); - return candidates.filter((element) => { + const candidates = Array.from(document.querySelectorAll("a[href*='session'], a[href*='conversation'], a[href*='thread'], button[data-testid*='session'], button[data-testid*='conversation'], button[data-testid*='thread'], [role='button'][data-testid*='session'], [role='button'][data-testid*='conversation'], [role='button'][data-testid*='thread'], li[data-testid*='session'], li[data-testid*='conversation'], li[data-testid*='thread']")); + cachedSessionRows = candidates.filter((element) => { const text = (element.textContent || "").trim(); const href = element.getAttribute("href") || ""; - const hasSessionHint = /session|conversation|thread/i.test(href + " " + element.outerHTML.slice(0, 400)); + const testId = element.getAttribute("data-testid") || ""; + const hasSessionHint = /session|conversation|thread/i.test(href + " " + testId); return text.length> 0 && text.length < 200 && hasSessionHint; }); + cachedSessionRowsAt = now; + return cachedSessionRows; + } + + function archivedPageRows() { + const rows = Array.from(document.querySelectorAll("button")).filter((button) => (button.textContent || "").trim() === "取消归档").map((button) => button.closest(".flex.w-full.items-center.justify-between") || button.parentElement).filter(Boolean); + rows.forEach((row) => { + row.dataset.codexArchivePageRow = "true"; + row.setAttribute("data-codex-archive-page-row", "true"); + }); + return rows; + } + + function archivedSessionRows() { + return sessionRows().filter((row) => row.querySelector('button[aria-label="取消归档对话"]') || row.outerHTML.includes("取消归档") || row.outerHTML.includes("unarchive")); + } + + function archivedRows() { + return [...archivedSessionRows(), ...archivedPageRows()]; + } + + function archivedPageVisible() { + return archivedRows().length> 0 || window.location.href.includes("archive") || (document.body.textContent || "").includes("已归档对话"); } function sessionRefFromRow(row) { @@ -373,58 +507,397 @@ setTimeout(() => toast.remove(), 10000); } + function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); + } + + function confirmDelete(title) { + document.querySelectorAll(".codex-delete-confirm-overlay").forEach((node) => node.remove()); + return new Promise((resolve) => { + const overlay = document.createElement("div"); + overlay.className = "codex-delete-confirm-overlay"; + overlay.innerHTML = ` +
+
删除会话
+
删除"${escapeHtml(title)}"?
+
+ + +
+
+ `; + const finish = (value, event) => { + event?.preventDefault(); + event?.stopPropagation(); + event?.target?.blur?.(); + overlay.remove(); + resolve(value); + }; + overlay.addEventListener("click", (event) => { + if (event.target === overlay || event.target.closest("[data-codex-delete-cancel]")) { + finish(false, event); + return; + } + if (event.target.closest("[data-codex-delete-confirm]")) { + finish(true, event); + } + }, true); + overlay.addEventListener("keydown", (event) => { + if (event.key === "Escape") finish(false, event); + }, true); + document.body.appendChild(overlay); + overlay.querySelector("[data-codex-delete-cancel]")?.focus(); + }); + } + + function rowHref(row) { + return row.getAttribute("href") || row.querySelector("a")?.getAttribute("href") || ""; + } + + function isCurrentSessionRow(row, ref) { + if (row.getAttribute("aria-current") === "page" || row.getAttribute("aria-current") === "true") return true; + const href = rowHref(row); + if (href) { + try { + const url = new URL(href, window.location.href); + if (url.href === window.location.href || url.pathname === window.location.pathname) return true; + } catch { + if (window.location.href.includes(href)) return true; + } + } + return !!ref.session_id && window.location.href.includes(ref.session_id); + } + + function releaseDeleteFocus(row, button) { + button.blur(); + if (row.contains(document.activeElement)) { + document.activeElement.blur(); + } + } + + function removeDeletedRow(row, button, ref) { + releaseDeleteFocus(row, button); + const shouldReload = isCurrentSessionRow(row, ref); + row.remove(); + if (shouldReload) { + window.location.reload(); + } + } + + function updateDeleteButtonOffsets() { + sessionRows().forEach((row) => { + const hasArchiveConfirm = Array.from(row.querySelectorAll("button")).some((button) => { + const rect = button.getBoundingClientRect(); + const label = button.getAttribute("aria-label") || ""; + const text = (button.textContent || "").trim(); + if (button.classList.contains(buttonClass) || label === "归档对话" || label === "置顶对话") return false; + return text === "确认" || (text.length> 0 && rect.width> 0 && rect.width <= 36 && rect.x> row.getBoundingClientRect().right - 50); + }); + row.classList.toggle("codex-archive-confirm-visible", hasArchiveConfirm); + }); + } + + function openDeleteConfirmForRow(row, button, ref, event) { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation?.(); + releaseDeleteFocus(row, button); + confirmDelete(ref.title).then(async (confirmed) => { + if (!confirmed) return; + releaseDeleteFocus(row, button); + const result = await postJson("/delete", ref); + if (result.status === "server_deleted" || result.status === "local_deleted") { + removeDeletedRow(row, button, ref); + showToast(result.message || "Deleted", result.undo_token); + } else { + showToast(result.message || "Delete failed", null); + } + }); + } + + function installDeleteButtonEventDelegation() { + document.removeEventListener("pointerup", window.__codexSessionDeleteDocumentDeleteHandler, true); + document.removeEventListener("click", window.__codexSessionDeleteDocumentDeleteHandler, true); + const handler = (event) => { + const button = event.target?.closest?.(`.${buttonClass}`); + const row = button?.closest?.("[data-app-action-sidebar-thread-id]"); + if (!button || !row) return; + const ref = sessionRefFromRow(row); + if (!ref.session_id) return; + openDeleteConfirmForRow(row, button, ref, event); + }; + window.__codexSessionDeleteDocumentDeleteHandler = handler; + document.addEventListener("pointerup", handler, true); + document.addEventListener("click", handler, true); + } + function attachButton(row) { if (!codexPlusSettings().sessionDelete) return; - if (row.dataset.codexDeleteRow === "true") return; + const existingDeleteButtons = Array.from(row.querySelectorAll(`.${buttonClass}`)); + if (existingDeleteButtons.length === 1 && existingDeleteButtons[0].dataset.codexDeleteVersion === codexDeleteVersion) return; + existingDeleteButtons.forEach((button) => button.remove()); + row.dataset.codexDeleteRow = "false"; const ref = sessionRefFromRow(row); if (!ref.session_id) return; row.dataset.codexDeleteRow = "true"; const button = document.createElement("button"); button.type = "button"; button.className = buttonClass; + button.dataset.codexDeleteVersion = codexDeleteVersion; button.textContent = "删除"; + const stopDeleteButtonEvent = (event) => { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation?.(); + releaseDeleteFocus(row, button); + }; + ["pointerdown", "mousedown", "mouseup", "touchstart"].forEach((eventName) => { + button.addEventListener(eventName, stopDeleteButtonEvent, true); + }); + const openDeleteConfirm = (event) => openDeleteConfirmForRow(row, button, ref, event); + button.addEventListener("pointerup", openDeleteConfirm, true); + button.addEventListener("click", openDeleteConfirm, true); + row.appendChild(button); + const refreshDeleteButton = (originalButton) => { + if (!originalButton.isConnected) return; + const replacement = originalButton.cloneNode(true); + ["pointerdown", "mousedown", "mouseup", "touchstart"].forEach((eventName) => { + replacement.addEventListener(eventName, stopDeleteButtonEvent, true); + }); + replacement.addEventListener("pointerup", openDeleteConfirm, true); + replacement.addEventListener("click", openDeleteConfirm, true); + originalButton.replaceWith(replacement); + }; + setTimeout(() => refreshDeleteButton(button), 0); + } + + function tryAttachButton(row) { + try { + attachButton(row); + } catch (error) { + window.__codexSessionDeleteAttachButtonFailures = window.__codexSessionDeleteAttachButtonFailures || []; + window.__codexSessionDeleteAttachButtonFailures.push(String(error?.stack || error)); + } + } + + function reactArchivedThreadFromNode(node) { + const reactKey = Object.keys(node).find((key) => key.startsWith("__reactFiber$") || key.startsWith("__reactInternalInstance$")); + let fiber = reactKey ? node[reactKey] : null; + for (let depth = 0; fiber && depth < 20; depth += 1, fiber = fiber.return) { + const props = fiber.memoizedProps || fiber.pendingProps || {}; + if (props.archivedThread?.id) return props.archivedThread; + const childThread = props.children?.props?.archivedThread; + if (childThread?.id) return childThread; + } + return null; + } + + function archivedThreadFromRow(row) { + for (const node of [row, ...row.querySelectorAll("*")]) { + const thread = reactArchivedThreadFromNode(node); + if (thread?.id || thread?.sessionId) return thread; + } + return null; + } + + function archivedRefFromRow(row) { + const archivedThread = archivedThreadFromRow(row); + if (archivedThread?.id || archivedThread?.sessionId) { + return { session_id: archivedThread.id || archivedThread.sessionId, title: archivedThread.title || row.querySelector(".truncate.text-base")?.textContent?.trim() || "Untitled session" }; + } + const sidebarRef = sessionRefFromRow(row); + if (sidebarRef.session_id) return sidebarRef; + const titleNode = row.querySelector(".truncate.text-base, [data-thread-title], a, div"); + const title = ((titleNode || row).textContent || "Untitled session") + .replace("取消归档", "") + .replace("删除", "") + .replace(/\d{4}年\d{1,2}月\d{1,2}日.*$/, "") + .replace(/\s+·\s+.*$/, "") + .trim() + .slice(0, 160); + return { session_id: "", title }; + } + + async function resolveArchivedThread(row) { + const ref = archivedRefFromRow(row); + if (ref.session_id) return ref; + const resolved = await postJson("/archived-thread", { title: ref.title }); + return resolved?.session_id ? resolved : ref; + } + + function stopArchivedButtonEvent(event) { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation?.(); + } + + function archiveTitleContainer() { + return Array.from(document.querySelectorAll("h1, h2, h3, div, span")) + .find((element) => (element.textContent || "").trim() === "已归档对话" && element.getBoundingClientRect().x> 350); + } + + async function deleteArchivedSessions(rows) { + let deleted = 0; + for (const row of rows) { + const ref = await resolveArchivedThread(row); + if (!ref.session_id) continue; + const result = await postJson("/delete", ref); + if (result.status === "server_deleted" || result.status === "local_deleted") { + row.remove(); + deleted += 1; + } + } + showToast(`已删除 ${deleted} 个归档会话`, null); + } + + function attachArchivedPageDeleteButton(row) { + if (!codexPlusSettings().sessionDelete) return; + if (row.dataset.codexArchiveDeleteRow === "true") return; + row.dataset.codexArchiveDeleteRow = "true"; + const unarchiveButton = Array.from(row.querySelectorAll("button")).find((button) => (button.textContent || "").trim() === "取消归档"); + if (!unarchiveButton) return; + const button = document.createElement("button"); + button.type = "button"; + button.className = "codex-archive-delete-all"; + button.textContent = "删除"; + ["pointerdown", "mousedown", "mouseup", "touchstart"].forEach((eventName) => { + button.addEventListener(eventName, stopArchivedButtonEvent, true); + }); button.addEventListener("click", async (event) => { event.preventDefault(); event.stopPropagation(); - if (!confirm(`删除会话"${ref.title}"?`)) return; + event.stopImmediatePropagation?.(); + const ref = await resolveArchivedThread(row); + if (!ref.session_id) { + showToast("Delete failed: archived session id not found", null); + return; + } + if (!(await confirmDelete(ref.title))) return; const result = await postJson("/delete", ref); if (result.status === "server_deleted" || result.status === "local_deleted") { - row.style.display = "none"; + row.remove(); showToast(result.message || "Deleted", result.undo_token); } else { showToast(result.message || "Delete failed", null); } + }, true); + unarchiveButton.insertAdjacentElement("afterend", button); + } + + function installArchivedDeleteAllButton() { + const existingButton = document.querySelector("[data-codex-archive-delete-all]"); + if (!codexPlusSettings().sessionDelete || !archivedPageVisible()) { + existingButton?.remove(); + return; + } + const rows = archivedRows(); + if (rows.length === 0) { + existingButton?.remove(); + return; + } + if (existingButton?.dataset.codexArchiveDeleteAllVersion === codexArchiveDeleteAllVersion) return; + existingButton?.remove(); + const button = document.createElement("button"); + button.type = "button"; + button.className = "codex-archive-delete-all codex-archive-action-bar"; + Object.assign(button.style, { + position: "static", + marginLeft: "12px", + verticalAlign: "middle", + zIndex: "2147482999", + cursor: "pointer", + pointerEvents: "auto", + maxWidth: "fit-content", + alignSelf: "flex-start", }); - row.appendChild(button); + button.dataset.codexArchiveDeleteAll = "true"; + button.dataset.codexArchiveDeleteAllVersion = codexArchiveDeleteAllVersion; + button.textContent = "删除全部归档"; + ["pointerdown", "mousedown", "mouseup", "touchstart"].forEach((eventName) => { + button.addEventListener(eventName, stopArchivedButtonEvent, true); + }); + const openArchivedDeleteAllConfirm = async (event) => { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation?.(); + const currentRows = archivedRows(); + if (currentRows.length === 0) return; + if (!(await confirmDelete(`全部 ${currentRows.length} 个归档会话`))) return; + await deleteArchivedSessions(currentRows); + }; + button.addEventListener("pointerup", openArchivedDeleteAllConfirm, true); + button.addEventListener("click", openArchivedDeleteAllConfirm, true); + const title = archiveTitleContainer(); + if (title) { + title.insertAdjacentElement("afterend", button); + } else { + document.body.appendChild(button); + } } function scanLightweight() { installStyle(); installCodexPlusMenu(); + installDeleteButtonEventDelegation(); } function scanDeferred() { enablePluginEntry(); unblockPluginInstallButtons(); - sessionRows().forEach(attachButton); + sessionRows().forEach(tryAttachButton); + updateDeleteButtonOffsets(); + archivedPageRows().forEach(attachArchivedPageDeleteButton); + installArchivedDeleteAllButton(); + } + + function runScanStep(step) { + try { + step(); + } catch (error) { + window.__codexSessionDeleteScanFailures = window.__codexSessionDeleteScanFailures || []; + window.__codexSessionDeleteScanFailures.push(String(error?.stack || error)); + } } function scan() { - scanLightweight(); - requestAnimationFrame(scanDeferred); + runScanStep(scanLightweight); + requestAnimationFrame(() => runScanStep(scanDeferred)); + } + + function shouldScheduleScan(mutations) { + if (!mutations) return true; + return mutations.some((mutation) => { + const target = mutation.target; + if (target?.closest?.(".codex-delete-toast, .codex-delete-confirm-overlay, .codex-plus-modal-overlay, #codex-plus-menu")) return false; + return Array.from(mutation.addedNodes).some((node) => { + if (node.nodeType !== 1) return false; + if (node.closest?.(".codex-delete-toast, .codex-delete-confirm-overlay, .codex-plus-modal-overlay, #codex-plus-menu")) return false; + return true; + }) || Array.from(mutation.removedNodes).some((node) => node.nodeType === 1); + }); } - function scheduleScan() { + function runScheduledScan() { + window.__codexSessionDeleteScanPending = false; + clearTimeout(window.__codexSessionDeleteScanTimer); + window.__codexSessionDeleteScanTimer = null; + scan(); + } + + function scheduleScan(mutations) { + if (!shouldScheduleScan(mutations)) return; if (window.__codexSessionDeleteScanPending) return; window.__codexSessionDeleteScanPending = true; - requestAnimationFrame(() => { - window.__codexSessionDeleteScanPending = false; - scan(); - }); + window.__codexSessionDeleteScanTimer = setTimeout(runScheduledScan, 200); } - scheduleScan(); + scan(); window.__codexSessionDeleteObserver?.disconnect(); window.__codexSessionDeleteObserver = new MutationObserver(scheduleScan); - window.__codexSessionDeleteObserver.observe(document.documentElement, { childList: true, subtree: true }); + window.__codexSessionDeleteObserver.observe(document.body || document.documentElement, { childList: true, subtree: true }); })(); diff --git a/codex_session_delete/launcher.py b/codex_session_delete/launcher.py index f5d400e2..f16b8316 100644 --- a/codex_session_delete/launcher.py +++ b/codex_session_delete/launcher.py @@ -1,8 +1,12 @@ from __future__ import annotations +import ctypes +import socket import subprocess +import sys import threading import time +import uuid from pathlib import Path from typing import Any @@ -33,24 +37,161 @@ def undo(self, token: str) -> DeleteResult: return DeleteResult(DeleteStatus.FAILED, "", "No local backup adapter configured", undo_token=token) return self.local_adapter.undo(token) + def find_archived_thread_by_title(self, title: str) -> SessionRef | None: + if self.local_adapter is None: + return None + return self.local_adapter.find_archived_thread_by_title(title) + class InjectedHelperServer(HelperServer): bridge_socket: Any = None -def build_codex_command(app_dir: Path, debug_port: int) -> list[str]: - if app_dir.suffix == ".app": - exe = app_dir / "Contents" / "MacOS" / "Codex" - else: - candidates = [app_dir / "Codex.exe", app_dir / "codex.exe"] - exe = next((path for path in candidates if path.exists()), candidates[-1]) +def _can_bind_loopback_port(port: int) -> bool: + if port == 0: + return True + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + if sys.platform == "win32" and hasattr(socket, "SO_EXCLUSIVEADDRUSE"): + probe.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1) + probe.bind(("127.0.0.1", port)) + return True + except OSError: + return False + + +def _find_available_loopback_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + if sys.platform == "win32" and hasattr(socket, "SO_EXCLUSIVEADDRUSE"): + probe.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1) + probe.bind(("127.0.0.1", 0)) + return int(probe.getsockname()[1]) + + +def select_windows_loopback_port(requested_port: int) -> int: + if sys.platform != "win32" or _can_bind_loopback_port(requested_port): + return requested_port + return _find_available_loopback_port() + + +def build_codex_arguments(debug_port: int) -> list[str]: return [ - str(exe), f"--remote-debugging-port={debug_port}", f"--remote-allow-origins=http://127.0.0.1:{debug_port}", ] +def build_codex_executable(app_dir: Path) -> Path: + if app_dir.suffix == ".app": + return app_dir / "Contents" / "MacOS" / "Codex" + candidates = [app_dir / "Codex.exe", app_dir / "codex.exe"] + return next((path for path in candidates if path.exists()), candidates[-1]) + + +def build_codex_command(app_dir: Path, debug_port: int) -> list[str]: + return [str(build_codex_executable(app_dir)), *build_codex_arguments(debug_port)] + + +def packaged_app_user_model_id(app_dir: Path) -> str | None: + package_dir = app_dir.parent if app_dir.name.lower() == "app" else app_dir + if not package_dir.name.startswith("OpenAI.Codex_") or "__" not in package_dir.name: + return None + identity_name = package_dir.name.split("_", 1)[0] + publisher_id = package_dir.name.rsplit("__", 1)[1] + if not publisher_id: + return None + return f"{identity_name}_{publisher_id}!App" + + +class _GUID(ctypes.Structure): + _fields_ = [ + ("Data1", ctypes.c_uint32), + ("Data2", ctypes.c_uint16), + ("Data3", ctypes.c_uint16), + ("Data4", ctypes.c_ubyte * 8), + ] + + def __init__(self, value: str): + parsed = uuid.UUID(value) + data4 = bytes([parsed.clock_seq_hi_variant, parsed.clock_seq_low]) + parsed.node.to_bytes(6, "big") + super().__init__(parsed.time_low, parsed.time_mid, parsed.time_hi_version, (ctypes.c_ubyte * 8)(*data4)) + + +def _raise_for_hresult(hr: int, operation: str) -> None: + if hr < 0: + raise OSError(f"{operation} failed with HRESULT 0x{hr & 0xFFFFFFFF:08X}") + + +def activate_packaged_app(app_user_model_id: str, arguments: str) -> int: + if sys.platform != "win32": + raise RuntimeError("Packaged app activation is only supported on Windows") + + ole32 = ctypes.OleDLL("ole32") + ole32.CoInitializeEx.argtypes = [ctypes.c_void_p, ctypes.c_ulong] + ole32.CoInitializeEx.restype = ctypes.c_long + ole32.CoUninitialize.argtypes = [] + ole32.CoUninitialize.restype = None + ole32.CoCreateInstance.argtypes = [ + ctypes.POINTER(_GUID), + ctypes.c_void_p, + ctypes.c_ulong, + ctypes.POINTER(_GUID), + ctypes.POINTER(ctypes.c_void_p), + ] + ole32.CoCreateInstance.restype = ctypes.c_long + + coinit_hr = ole32.CoInitializeEx(None, 2) + should_uninitialize = coinit_hr>= 0 + if coinit_hr < 0 and coinit_hr != -2147417850: # RPC_E_CHANGED_MODE + _raise_for_hresult(coinit_hr, "CoInitializeEx") + + activation_manager = ctypes.c_void_p() + try: + clsid = _GUID("45BA127D-10A8-46EA-8AB7-56EA9078943C") + iid = _GUID("2e941141-7f97-4756-ba1d-9decde894a3d") + _raise_for_hresult( + ole32.CoCreateInstance(ctypes.byref(clsid), None, 1, ctypes.byref(iid), ctypes.byref(activation_manager)), + "CoCreateInstance(ApplicationActivationManager)", + ) + + activate_application_type = ctypes.WINFUNCTYPE( + ctypes.c_long, + ctypes.c_void_p, + ctypes.c_wchar_p, + ctypes.c_wchar_p, + ctypes.c_ulong, + ctypes.POINTER(ctypes.c_ulong), + ) + + vtable = ctypes.cast(activation_manager, ctypes.POINTER(ctypes.POINTER(ctypes.c_void_p))).contents + activate_application = activate_application_type(vtable[3]) + + process_id = ctypes.c_ulong() + _raise_for_hresult( + activate_application(activation_manager, app_user_model_id, arguments, 0, ctypes.byref(process_id)), + "ActivateApplication", + ) + return int(process_id.value) + finally: + if activation_manager.value: + release = ctypes.WINFUNCTYPE(ctypes.c_ulong, ctypes.c_void_p)( + ctypes.cast(activation_manager, ctypes.POINTER(ctypes.POINTER(ctypes.c_void_p))).contents[2] + ) + release(activation_manager) + if should_uninitialize: + ole32.CoUninitialize() + + +def launch_codex_app(app_dir: Path, debug_port: int) -> Any: + app_user_model_id = packaged_app_user_model_id(app_dir) if sys.platform == "win32" else None + if app_user_model_id: + return activate_packaged_app(app_user_model_id, subprocess.list2cmdline(build_codex_arguments(debug_port))) + if app_dir.suffix == ".app": + subprocess.run(["open", "-a", str(app_dir), "--args", *build_codex_arguments(debug_port)], check=True) + return None + return subprocess.Popen(build_codex_command(app_dir, debug_port)) + + def start_helper(service, host: str = "127.0.0.1", port: int = 57321) -> HelperServer: server = InjectedHelperServer(host, port, service) thread = threading.Thread(target=server.serve_forever, daemon=True) @@ -58,6 +199,11 @@ def start_helper(service, host: str = "127.0.0.1", port: int = 57321) -> HelperS return server +def shutdown_helper(server: HelperServer) -> None: + server.shutdown() + server.server_close() + + def inject_with_retry(debug_port: int, script_path: Path, helper_port: int, service: ApiFirstDeleteService, attempts: int = 20, delay: float = 0.5) -> Any: last_error: Exception | None = None for _ in range(attempts): @@ -71,16 +217,44 @@ def inject_with_retry(debug_port: int, script_path: Path, helper_port: int, serv raise RuntimeError("Codex injection failed") -def launch_and_inject(app_dir: Path | None, db_path: Path | None, backup_dir: Path, debug_port: int, helper_port: int) -> HelperServer: +def launch_and_inject(app_dir: Path | None, db_path: Path | None, backup_dir: Path, debug_port: int, helper_port: int) -> tuple[HelperServer, Any]: resolved_app_dir = resolve_codex_app_dir(app_dir) if resolved_app_dir is None: raise RuntimeError("Codex App directory not found") + debug_port = select_windows_loopback_port(debug_port) + helper_port = select_windows_loopback_port(helper_port) service = ApiFirstDeleteService(UnavailableApiAdapter(), db_path, backup_dir) server = start_helper(service, port=helper_port) - subprocess.Popen(build_codex_command(resolved_app_dir, debug_port)) - script_path = Path(__file__).parent / "inject" / "renderer-inject.js" - server.bridge_socket = inject_with_retry(debug_port, script_path, server.port, service) - return server + codex_proc = None + try: + codex_proc = launch_codex_app(resolved_app_dir, debug_port) + script_path = Path(__file__).parent / "inject" / "renderer-inject.js" + server.bridge_socket = inject_with_retry(debug_port, script_path, server.port, service) + return server, codex_proc + except Exception: + shutdown_helper(server) + # Kill any Codex process we just activated so the next attempt starts from a clean state + # instead of staring at a half-rendered white window. + if sys.platform == "win32": + try: + subprocess.run( + [ + "powershell.exe", + "-NoProfile", + "-NonInteractive", + "-Command", + "Get-CimInstance Win32_Process -Filter \"Name='Codex.exe' OR Name='codex.exe'\" | " + "ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }", + ], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=6, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + except (OSError, subprocess.SubprocessError): + pass + raise def handle_bridge_request(service: ApiFirstDeleteService, path: str, payload: dict[str, object]) -> dict[str, object]: @@ -89,4 +263,7 @@ def handle_bridge_request(service: ApiFirstDeleteService, path: str, payload: di return service.delete(session).to_dict() if path == "/undo": return service.undo(str(payload.get("undo_token", ""))).to_dict() + if path == "/archived-thread": + session = service.find_archived_thread_by_title(str(payload.get("title", ""))) + return {"session_id": session.session_id, "title": session.title} if session else {"session_id": "", "title": ""} return {"status": DeleteStatus.FAILED.value, "session_id": str(payload.get("session_id", "")), "message": "Unknown bridge path"} diff --git a/codex_session_delete/macos_installer.py b/codex_session_delete/macos_installer.py index 517f9c50..0f8cfc6f 100644 --- a/codex_session_delete/macos_installer.py +++ b/codex_session_delete/macos_installer.py @@ -7,6 +7,10 @@ from pathlib import Path from typing import TYPE_CHECKING +from codex_session_delete.app_paths import find_macos_codex_app + +ICON_ASSET = Path(__file__).resolve().parent / "assets" / "codex-plus-plus.png" + if TYPE_CHECKING: from codex_session_delete.installers import InstallOptions @@ -38,10 +42,12 @@ def install_macos_app(options: "InstallOptions") -> None: "CFBundleName": "Codex++", "CFBundleDisplayName": "Codex++", "CFBundleIdentifier": "com.bigpizzav3.codexplusplus", - "CFBundleVersion": "0.1.0", - "CFBundleShortVersionString": "0.1.0", + "CFBundleVersion": "1.0.4", + "CFBundleShortVersionString": "1.0.4", "CFBundlePackageType": "APPL", "CFBundleExecutable": EXECUTABLE_NAME, + "CFBundleIconFile": "codex-plus-plus.png", + "LSUIElement": True, "LSMinimumSystemVersion": "12.0", } (contents / "Info.plist").write_bytes(plistlib.dumps(plist)) @@ -50,8 +56,22 @@ def install_macos_app(options: "InstallOptions") -> None: executable.write_text(f"#!/bin/sh\nexec {_launcher_command(options)}\n", encoding="utf-8") executable.chmod(executable.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + _copy_codex_icon(resources) + def uninstall_macos_app(options: "InstallOptions") -> None: app = _app_root(options) if app.exists(): shutil.rmtree(app) + + +def _copy_codex_icon(resources: Path) -> None: + if ICON_ASSET.is_file(): + shutil.copy2(ICON_ASSET, resources / "codex-plus-plus.png") + return + codex_app = find_macos_codex_app() + if codex_app is None: + return + icon_src = codex_app / "Contents" / "Resources" / "electron.icns" + if icon_src.is_file(): + shutil.copy2(icon_src, resources / "electron.icns") diff --git a/codex_session_delete/storage_adapter.py b/codex_session_delete/storage_adapter.py index 05b65c94..42bc55fc 100644 --- a/codex_session_delete/storage_adapter.py +++ b/codex_session_delete/storage_adapter.py @@ -47,6 +47,23 @@ def undo(self, token: str) -> DeleteResult: path.write_bytes(base64.b64decode(file_backup["content_b64"])) return DeleteResult(DeleteStatus.UNDONE, session_id, "Local session restored from backup", undo_token=token) + def find_archived_thread_by_title(self, title: str) -> SessionRef | None: + if not self.db_path.exists(): + return None + with sqlite3.connect(self.db_path) as db: + db.row_factory = sqlite3.Row + if self._schema_kind(db) != "codex_threads" or not self._has_columns(db, "threads", {"archived"}): + return None + row = db.execute( + """ + SELECT id, title FROM threads + WHERE archived = 1 AND (title = ? OR title LIKE ? OR ? LIKE '%' || title || '%') + ORDER BY archived_at DESC LIMIT 1 + """, + (title, f"%{title}%", title), + ).fetchone() + return SessionRef(session_id=str(row["id"]), title=str(row["title"] or title)) if row else None + def _delete_generic_session(self, db: sqlite3.Connection, session: SessionRef) -> DeleteResult: session_rows = self._select_dicts(db, "SELECT * FROM sessions WHERE id = ?", (session.session_id,)) if not session_rows: diff --git a/codex_session_delete/watcher.py b/codex_session_delete/watcher.py new file mode 100644 index 00000000..54b148bb --- /dev/null +++ b/codex_session_delete/watcher.py @@ -0,0 +1,243 @@ +from __future__ import annotations + +import os +import socket +import subprocess +import sys +import time +import traceback +from datetime import datetime +from pathlib import Path + + +WATCHER_INTERVAL_SECONDS = 3.0 +CDP_PROBE_TIMEOUT_SECONDS = 0.5 +CDP_WAIT_TIMEOUT_SECONDS = 25.0 +KILL_WAIT_TIMEOUT_SECONDS = 8.0 +CODEX_PROCESS_NAMES = {"codex.exe"} + + +def data_root() -> Path: + return Path.home() / ".codex-session-delete" + + +def watcher_log_path() -> Path: + return data_root() / "watcher.log" + + +def watcher_disabled_flag() -> Path: + return data_root() / "watcher.disabled" + + +def log(line: str) -> None: + path = watcher_log_path() + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as handle: + handle.write(f"[{datetime.now().isoformat(timespec='seconds')}] {line}\n") + + +def cdp_listening(port: int) -> bool: + try: + with socket.create_connection(("127.0.0.1", port), timeout=CDP_PROBE_TIMEOUT_SECONDS): + return True + except OSError: + return False + + +def _run_powershell(script: str, timeout: float = 8.0) -> str: + try: + result = subprocess.run( + ["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=timeout, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + return result.stdout or "" + except (OSError, subprocess.SubprocessError) as exc: + log(f"powershell failed: {exc}") + return "" + + +def find_codex_processes() -> list[int]: + script = ( + "Get-CimInstance Win32_Process -Filter \"Name='Codex.exe' OR Name='codex.exe'\" " + "| Select-Object -ExpandProperty ProcessId" + ) + output = _run_powershell(script) + return [int(line) for line in output.splitlines() if line.strip().isdigit()] + + +def kill_processes(pids: list[int]) -> None: + if not pids: + return + script = "; ".join( + f"Stop-Process -Id {pid} -Force -ErrorAction SilentlyContinue" for pid in pids + ) + _run_powershell(script, timeout=6.0) + + +def wait_until_no_codex(timeout: float = KILL_WAIT_TIMEOUT_SECONDS) -> bool: + """Poll until no Codex process is left, or until timeout. Returns True if clean, False if still alive.""" + deadline = time.time() + timeout + while time.time() < deadline: + remaining = find_codex_processes() + if not remaining: + return True + # Be aggressive: re-issue kill for anything still alive. + kill_processes(remaining) + time.sleep(0.5) + return not find_codex_processes() + + +def wait_for_cdp(port: int, timeout: float = CDP_WAIT_TIMEOUT_SECONDS) -> bool: + deadline = time.time() + timeout + while time.time() < deadline: + if cdp_listening(port): + return True + time.sleep(0.5) + return False + + +def spawn_launcher() -> subprocess.Popen | None: + python = sys.executable + pythonw = Path(python).with_name("pythonw.exe") + exe = str(pythonw if pythonw.exists() else python) + args = [exe, "-m", "codex_session_delete", "launch"] + creationflags = 0 + if sys.platform == "win32": + creationflags = ( + subprocess.CREATE_NEW_PROCESS_GROUP + | getattr(subprocess, "DETACHED_PROCESS", 0x00000008) + | getattr(subprocess, "CREATE_NO_WINDOW", 0) + ) + try: + return subprocess.Popen( + args, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + close_fds=True, + creationflags=creationflags, + ) + except Exception as exc: + log(f"failed to spawn launcher: {exc}") + return None + + +def stop_launcher_processes() -> None: + script = ( + "Get-CimInstance Win32_Process -Filter \"Name='pythonw.exe' OR Name='python.exe'\" | " + "Where-Object { $_.CommandLine -match 'codex_session_delete\\s+launch' } | " + "ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }" + ) + _run_powershell(script, timeout=6.0) + + +def takeover(debug_port: int) -> bool: + """Perform one atomic takeover attempt: kill codex cleanly, spawn launcher, wait for CDP. + + Returns True on success (CDP up), False otherwise. On failure, caller should back off briefly. + """ + # Step 1: Kill existing launcher processes (stale / failed) so we start from a known state. + stop_launcher_processes() + + # Step 2: Kill all Codex.exe and wait for them to disappear. + pids = find_codex_processes() + log(f"takeover: killing {len(pids)} codex pid(s): {pids}") + kill_processes(pids) + if not wait_until_no_codex(): + log("takeover: codex processes did not exit in time, aborting this attempt") + return False + + # Step 3: Give AppX activation machinery a moment to reset the "app is running" state. + time.sleep(1.5) + + # Step 4: Spawn a fresh launcher that will activate the packaged app with CDP args. + proc = spawn_launcher() + if proc is None: + return False + + # Step 5: Wait for CDP to come up. Launcher does injection in the background. + if wait_for_cdp(debug_port): + log(f"takeover: CDP is up on {debug_port} (launcher pid={proc.pid})") + return True + + # Step 6: CDP did not come up. Clean up the launcher we spawned and any codex it started, + # so the next pass can retry cleanly instead of staring at a broken window. + log("takeover: CDP did not come up in time; cleaning up failed attempt") + stop_launcher_processes() + stragglers = find_codex_processes() + if stragglers: + kill_processes(stragglers) + wait_until_no_codex(timeout=4.0) + return False + + +def watch_loop(debug_port: int = 9229) -> int: + if sys.platform != "win32": + log("watcher only supported on Windows") + return 1 + + log(f"watcher started (interval={WATCHER_INTERVAL_SECONDS}s)") + last_state = None + backoff_until = 0.0 + + while True: + try: + if watcher_disabled_flag().exists(): + if last_state != "disabled": + log("disabled flag present; idling") + last_state = "disabled" + time.sleep(WATCHER_INTERVAL_SECONDS) + continue + + if cdp_listening(debug_port): + if last_state != "cdp_ok": + log("CDP is up") + last_state = "cdp_ok" + time.sleep(WATCHER_INTERVAL_SECONDS) + continue + + codex_pids = find_codex_processes() + if not codex_pids: + if last_state != "idle": + log("no Codex running; idling") + last_state = "idle" + time.sleep(WATCHER_INTERVAL_SECONDS) + continue + + now = time.time() + if now < backoff_until: + if last_state != "backoff": + log(f"in backoff after failed takeover; {backoff_until - now:.1f}s remaining") + last_state = "backoff" + time.sleep(WATCHER_INTERVAL_SECONDS) + continue + + log(f"Codex running without CDP (pids={codex_pids}); attempting takeover") + last_state = "takeover" + success = takeover(debug_port) + if success: + last_state = "cdp_ok" + else: + backoff_until = time.time() + 10.0 + last_state = "failed" + except Exception as exc: + log("watch loop error: " + "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))) + + time.sleep(WATCHER_INTERVAL_SECONDS) + + +def enable_watcher() -> None: + flag = watcher_disabled_flag() + if flag.exists(): + flag.unlink() + + +def disable_watcher() -> None: + flag = watcher_disabled_flag() + flag.parent.mkdir(parents=True, exist_ok=True) + flag.touch() diff --git a/codex_session_delete/windows_installer.py b/codex_session_delete/windows_installer.py index 5bf7720c..bf618a47 100644 --- a/codex_session_delete/windows_installer.py +++ b/codex_session_delete/windows_installer.py @@ -26,6 +26,10 @@ def _project_root_expr() -> str: return _ps_quote(str(Path(__file__).resolve().parent.parent)) +def _icon_path_expr() -> str: + return _ps_quote(str(Path(__file__).resolve().parent / "assets" / "codex-plus-plus.ico")) + + def _split_launcher_command(command: str) -> tuple[str, str]: prefix = "python " if command.startswith(prefix): @@ -36,26 +40,26 @@ def _split_launcher_command(command: str) -> tuple[str, str]: def build_install_shortcut_script(options: "InstallOptions") -> str: root = _install_root_expr(options) project_root = _project_root_expr() + icon_path = _icon_path_expr() target, arguments = _split_launcher_command(_launcher_command(options)) target_expr = "$Pythonw" if target == "python" else _ps_quote(target) arguments_expr = _ps_quote(arguments) return f""" $InstallRoot = {root} $ProjectRoot = {project_root} +$CodexPlusIcon = {icon_path} New-Item -ItemType Directory -Force -Path $InstallRoot | Out-Null $ShortcutPath = Join-Path $InstallRoot 'Codex++.lnk' $Python = (Get-Command python).Source $PythonwCandidate = Join-Path (Split-Path $Python -Parent) 'pythonw.exe' $Pythonw = if (Test-Path $PythonwCandidate) {{ $PythonwCandidate }} else {{ $Python }} -$CodexPackage = Get-ChildItem 'C:\\Program Files\\WindowsApps' -Directory -Filter 'OpenAI.Codex_*_x64__*' -ErrorAction SilentlyContinue | Sort-Object Name -Descending | Select-Object -First 1 -$CodexIcon = if ($CodexPackage) {{ Join-Path $CodexPackage.FullName 'app\\Codex.exe' }} else {{ $Python }} $Shell = New-Object -ComObject WScript.Shell $Shortcut = $Shell.CreateShortcut($ShortcutPath) $Shortcut.TargetPath = {target_expr} $Shortcut.Arguments = {arguments_expr} $Shortcut.WorkingDirectory = $ProjectRoot $Shortcut.Description = 'Launch Codex with Codex++ injection' -$Shortcut.IconLocation = "$CodexIcon,0" +$Shortcut.IconLocation = $CodexPlusIcon $Shortcut.Save() $LegacyUninstallKey = 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Codex++' if (Test-Path $LegacyUninstallKey) {{ Remove-Item $LegacyUninstallKey -Force }} @@ -63,9 +67,9 @@ def build_install_shortcut_script(options: "InstallOptions") -> str: $UninstallCommand = 'cmd.exe /c cd /d "' + $ProjectRoot + '" && "' + $Python + '" -m codex_session_delete uninstall --install-root "' + $InstallRoot + '"' New-Item -Path $UninstallKey -Force | Out-Null Set-ItemProperty -Path $UninstallKey -Name DisplayName -Value 'Codex++' -Set-ItemProperty -Path $UninstallKey -Name DisplayVersion -Value '0.1.0' +Set-ItemProperty -Path $UninstallKey -Name DisplayVersion -Value '1.0.4' Set-ItemProperty -Path $UninstallKey -Name Publisher -Value 'BigPizzaV3' -Set-ItemProperty -Path $UninstallKey -Name DisplayIcon -Value $CodexIcon +Set-ItemProperty -Path $UninstallKey -Name DisplayIcon -Value $CodexPlusIcon Set-ItemProperty -Path $UninstallKey -Name InstallLocation -Value $ProjectRoot Set-ItemProperty -Path $UninstallKey -Name UninstallString -Value $UninstallCommand Set-ItemProperty -Path $UninstallKey -Name QuietUninstallString -Value $UninstallCommand diff --git a/docs/images/codex-plus-plus.ico b/docs/images/codex-plus-plus.ico new file mode 100644 index 00000000..52f40f61 Binary files /dev/null and b/docs/images/codex-plus-plus.ico differ diff --git a/docs/images/codex-plus-plus.png b/docs/images/codex-plus-plus.png new file mode 100644 index 00000000..600fe358 Binary files /dev/null and b/docs/images/codex-plus-plus.png differ diff --git a/pyproject.toml b/pyproject.toml index 2dabc12f..dc4388c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codex-session-delete" -version = "0.1.0" +version = "1.0.4" description = "External Codex App session delete injector" requires-python = ">=3.11" dependencies = [ @@ -18,6 +18,9 @@ test = ["pytest>=8.0"] [project.scripts] codex-session-delete = "codex_session_delete.cli:main" +[tool.setuptools.package-data] +codex_session_delete = ["assets/*", "inject/*.js"] + [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["."] diff --git a/tests/test_backup_store.py b/tests/test_backup_store.py index 82a83cfb..bc6dbb10 100644 --- a/tests/test_backup_store.py +++ b/tests/test_backup_store.py @@ -25,5 +25,13 @@ def test_backup_store_rejects_unknown_token(tmp_path): store.read_backup("missing") except FileNotFoundError as exc: assert "missing" in str(exc) - else: - raise AssertionError("missing backup token was accepted") + + +def test_write_backup_recreates_missing_backup_directory(tmp_path): + backup_dir = tmp_path / "backups" + store = BackupStore(backup_dir) + backup_dir.rmdir() + + token = store.write_backup("s1", "db.sqlite", {"sessions": []}) + + assert store.path_for(token).exists() diff --git a/tests/test_cdp.py b/tests/test_cdp.py index 730831e4..959e5b67 100644 --- a/tests/test_cdp.py +++ b/tests/test_cdp.py @@ -1,7 +1,7 @@ import json import websocket -from codex_session_delete.cdp import _bridge_loop, build_bridge_script, pick_page_target +from codex_session_delete.cdp import BRIDGE_BINDING_NAME, _bridge_loop, build_bridge_script, pick_page_target class TimeoutThenMessageSocket: @@ -50,6 +50,10 @@ def test_build_bridge_script_installs_binding_callbacks(): assert "window.__codexSessionDeleteReject" in script +def test_bridge_binding_name_is_versioned_for_reinjection(): + assert BRIDGE_BINDING_NAME == "codexSessionDeleteV2" + + def test_bridge_loop_continues_after_idle_timeout(): ws = TimeoutThenMessageSocket() diff --git a/tests/test_helper_server.py b/tests/test_helper_server.py index 4bf1ec34..171c4cd5 100644 --- a/tests/test_helper_server.py +++ b/tests/test_helper_server.py @@ -10,6 +10,7 @@ class FakeDeleteService: def __init__(self): self.deleted = [] self.undone = [] + self.archived_title_queries = [] def delete(self, session: SessionRef): self.deleted.append(session) @@ -19,6 +20,10 @@ def undo(self, token: str): self.undone.append(token) return DeleteResult(DeleteStatus.UNDONE, "s1", "Restored", undo_token=token) + def find_archived_thread_by_title(self, title: str): + self.archived_title_queries.append(title) + return SessionRef(session_id="archived-t1", title=title) + def post_json(url, payload): data = json.dumps(payload).encode("utf-8") @@ -47,6 +52,22 @@ def test_helper_server_delete_and_undo(): assert service.undone == ["u1"] +def test_helper_server_resolves_archived_thread_by_title(): + service = FakeDeleteService() + server = HelperServer("127.0.0.1", 0, service) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + base = f"http://127.0.0.1:{server.port}" + resolved = post_json(base + "/archived-thread", {"title": "Codex Thread"}) + finally: + server.shutdown() + thread.join(timeout=3) + + assert resolved == {"session_id": "archived-t1", "title": "Codex Thread"} + assert service.archived_title_queries == ["Codex Thread"] + + def test_helper_server_allows_private_network_preflight(): service = FakeDeleteService() server = HelperServer("127.0.0.1", 0, service) diff --git a/tests/test_launcher_cli.py b/tests/test_launcher_cli.py index ec45c945..98766891 100644 --- a/tests/test_launcher_cli.py +++ b/tests/test_launcher_cli.py @@ -3,49 +3,120 @@ import pytest from codex_session_delete import cli, launcher -from codex_session_delete.launcher import build_codex_command +from codex_session_delete.launcher import build_codex_command, launch_codex_app, packaged_app_user_model_id class FakeServer: port = 57321 + def __init__(self): + self.shutdown_called = False + self.server_close_called = False -def test_build_codex_command_adds_remote_debugging_port(): - app_dir = Path("C:/Program Files/WindowsApps/OpenAI.Codex_1.0.0.0_x64__abc/app") + def shutdown(self): + self.shutdown_called = True - command = build_codex_command(app_dir, 9229) + def server_close(self): + self.server_close_called = True - assert str(app_dir / "Codex.exe") in command[0] or str(app_dir / "codex.exe") in command[0] - assert "--remote-debugging-port=9229" in command +class FakeProcess: + def __init__(self): + self.waited = False + def wait(self): + self.waited = True -def test_build_codex_command_allows_devtools_websocket_origin(): - app_dir = Path("C:/Program Files/WindowsApps/OpenAI.Codex_1.0.0.0_x64__abc/app") - command = build_codex_command(app_dir, 9229) +def test_launch_codex_windows_adds_remote_debugging_port(monkeypatch): + app_dir = Path("C:/Codex/app") + popen_calls = [] + monkeypatch.setattr(launcher.subprocess, "Popen", lambda args, **kw: popen_calls.append(args)) - assert "--remote-allow-origins=http://127.0.0.1:9229" in command + launch_codex_app(app_dir, 9229) + assert popen_calls + assert str(app_dir / "Codex.exe") in popen_calls[0][0] or str(app_dir / "codex.exe") in popen_calls[0][0] + assert "--remote-debugging-port=9229" in popen_calls[0] -def test_build_codex_command_supports_macos_app_bundle(tmp_path): +def test_launch_codex_windows_allows_devtools_websocket_origin(monkeypatch): + app_dir = Path("C:/Codex/app") + popen_calls = [] + monkeypatch.setattr(launcher.subprocess, "Popen", lambda args, **kw: popen_calls.append(args)) + + launch_codex_app(app_dir, 9229) + + assert "--remote-allow-origins=http://127.0.0.1:9229" in popen_calls[0] + + +def test_launch_codex_macos_uses_open_command(monkeypatch, tmp_path): app = tmp_path / "Codex.app" - executable = app / "Contents" / "MacOS" / "Codex" - executable.parent.mkdir(parents=True) - executable.write_text("#!/bin/sh\n", encoding="utf-8") + (app / "Contents" / "MacOS").mkdir(parents=True) + run_calls = [] + monkeypatch.setattr(launcher.subprocess, "run", lambda args, **kw: run_calls.append(args)) + + proc = launch_codex_app(app, 9229) + + assert proc is None + assert len(run_calls) == 1 + assert run_calls[0][0] == "open" + assert "-a" in run_calls[0] + assert str(app) in run_calls[0] + + +def test_packaged_app_user_model_id_from_windowsapps_path(): + app_dir = Path("C:/Program Files/WindowsApps/OpenAI.Codex_26.506.2212.0_x64__2p2nqsd0c76g0/app") + + assert packaged_app_user_model_id(app_dir) == "OpenAI.Codex_2p2nqsd0c76g0!App" + + +def test_packaged_app_user_model_id_ignores_non_packaged_path(): + app_dir = Path("C:/Codex/app") + + assert packaged_app_user_model_id(app_dir) is None + + +def test_launch_uses_packaged_activation_for_windowsapps(monkeypatch): + app_dir = Path("C:/Program Files/WindowsApps/OpenAI.Codex_26.506.2212.0_x64__2p2nqsd0c76g0/app") + activated = [] + launched = [] + monkeypatch.setattr(launcher.sys, "platform", "win32") + monkeypatch.setattr( + launcher, + "activate_packaged_app", + lambda aumid, arguments: activated.append((aumid, arguments)) or 1234, + ) + monkeypatch.setattr(launcher.subprocess, "Popen", lambda command: launched.append(command)) + + assert launcher.launch_codex_app(app_dir, 9229) == 1234 + + assert activated == [( + "OpenAI.Codex_2p2nqsd0c76g0!App", + "--remote-debugging-port=9229 --remote-allow-origins=http://127.0.0.1:9229", + )] + assert launched == [] + + +def test_windows_port_selector_uses_ephemeral_port_when_default_is_busy(monkeypatch): + monkeypatch.setattr(launcher.sys, "platform", "win32") + monkeypatch.setattr(launcher, "_can_bind_loopback_port", lambda port: port != 9229) + monkeypatch.setattr(launcher, "_find_available_loopback_port", lambda: 43001) + + assert launcher.select_windows_loopback_port(9229) == 43001 + - command = build_codex_command(app, 9229) +def test_non_windows_port_selector_keeps_requested_port(monkeypatch): + monkeypatch.setattr(launcher.sys, "platform", "darwin") + monkeypatch.setattr(launcher, "_can_bind_loopback_port", lambda port: False) - assert command[0] == str(executable) - assert "--remote-debugging-port=9229" in command - assert "--remote-allow-origins=http://127.0.0.1:9229" in command + assert launcher.select_windows_loopback_port(9229) == 9229 def test_cli_keeps_helper_server_alive_after_injection(monkeypatch): waited = [] - monkeypatch.setattr(cli, "launch_and_inject", lambda *args: FakeServer()) - monkeypatch.setattr(cli, "wait_for_shutdown", lambda server: waited.append(server.port)) + monkeypatch.setattr(cli, "launch_and_inject", lambda *args: (FakeServer(), None)) + monkeypatch.setattr(cli, "wait_for_shutdown", lambda server, proc: waited.append(server.port)) exit_code = cli.main([]) @@ -56,8 +127,8 @@ def test_cli_keeps_helper_server_alive_after_injection(monkeypatch): def test_cli_launch_subcommand_keeps_helper_server_alive_after_injection(monkeypatch): waited = [] calls = [] - monkeypatch.setattr(cli, "launch_and_inject", lambda *args: calls.append(args) or FakeServer()) - monkeypatch.setattr(cli, "wait_for_shutdown", lambda server: waited.append(server.port)) + monkeypatch.setattr(cli, "launch_and_inject", lambda *args: calls.append(args) or (FakeServer(), None)) + monkeypatch.setattr(cli, "wait_for_shutdown", lambda server, proc: waited.append(server.port)) exit_code = cli.main(["launch"]) @@ -78,8 +149,6 @@ def test_cli_install_dispatches_to_platform_installer(monkeypatch, tmp_path): assert calls[0].launcher_command == "python -m codex_session_delete" - - def test_cli_uninstall_dispatches_to_platform_installer(monkeypatch, tmp_path): calls = [] monkeypatch.setattr(cli, "uninstall_codex_plus_plus", lambda options: calls.append(options)) @@ -96,7 +165,7 @@ def test_launch_retries_injection_until_codex_page_is_ready(monkeypatch, tmp_pat attempts = [] monkeypatch.setattr(launcher, "resolve_codex_app_dir", lambda app_dir=None: tmp_path) monkeypatch.setattr(launcher, "start_helper", lambda *args, **kwargs: FakeServer()) - monkeypatch.setattr(launcher.subprocess, "Popen", lambda *args, **kwargs: None) + monkeypatch.setattr(launcher, "launch_codex_app", lambda *args: None) def inject_after_retry(*args): attempts.append(args) @@ -107,12 +176,38 @@ def inject_after_retry(*args): monkeypatch.setattr(launcher, "inject_file", inject_after_retry) monkeypatch.setattr(launcher.time, "sleep", lambda seconds: None) - server = launcher.launch_and_inject(None, None, tmp_path / "backups", 9229, 57321) + server, proc = launcher.launch_and_inject(None, None, tmp_path / "backups", 9229, 57321) assert server.port == 57321 assert len(attempts) == 2 +def test_launch_and_inject_returns_windows_packaged_process_id(monkeypatch, tmp_path): + monkeypatch.setattr(launcher, "resolve_codex_app_dir", lambda app_dir=None: tmp_path) + monkeypatch.setattr(launcher, "start_helper", lambda *args, **kwargs: FakeServer()) + monkeypatch.setattr(launcher, "launch_codex_app", lambda *args: 1234) + monkeypatch.setattr(launcher, "inject_with_retry", lambda *args, **kwargs: {"result": {}}) + + server, proc = launcher.launch_and_inject(None, None, tmp_path / "backups", 9229, 57321) + + assert server.port == 57321 + assert proc == 1234 + + +def test_launch_and_inject_closes_helper_when_injection_fails(monkeypatch, tmp_path): + server = FakeServer() + monkeypatch.setattr(launcher, "resolve_codex_app_dir", lambda app_dir=None: tmp_path) + monkeypatch.setattr(launcher, "start_helper", lambda *args, **kwargs: server) + monkeypatch.setattr(launcher, "launch_codex_app", lambda *args: 1234) + monkeypatch.setattr(launcher, "inject_with_retry", lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("inject failed"))) + + with pytest.raises(RuntimeError, match="inject failed"): + launcher.launch_and_inject(None, None, tmp_path / "backups", 9229, 57321) + + assert server.shutdown_called is True + assert server.server_close_called is True + + def test_launch_uses_resolved_app_dir(monkeypatch, tmp_path): launched = [] mac_app = tmp_path / "Applications" / "OpenAI Codex.app" @@ -121,12 +216,53 @@ def test_launch_uses_resolved_app_dir(monkeypatch, tmp_path): executable.write_text("#!/bin/sh\n", encoding="utf-8") monkeypatch.setattr(launcher, "resolve_codex_app_dir", lambda app_dir=None: mac_app) monkeypatch.setattr(launcher, "start_helper", lambda *args, **kwargs: FakeServer()) - monkeypatch.setattr(launcher.subprocess, "Popen", lambda command: launched.append(command)) + monkeypatch.setattr(launcher.subprocess, "run", lambda args, **kw: launched.append(args)) monkeypatch.setattr(launcher, "inject_with_retry", lambda *args, **kwargs: {"result": {}}) launcher.launch_and_inject(None, None, tmp_path / "backups", 9229, 57321) - assert launched[0][0] == str(executable) + assert str(executable) not in launched[0] + assert "open" in launched[0] + + +def test_cli_stops_existing_windows_launchers_before_launch(monkeypatch): + commands = [] + monkeypatch.setattr(cli.sys, "platform", "win32") + monkeypatch.setattr(cli.os, "getpid", lambda: 9876) + monkeypatch.setattr(cli.subprocess, "run", lambda command, **kwargs: commands.append((command, kwargs))) + + cli.stop_existing_windows_launchers() + + assert len(commands) == 1 + command, kwargs = commands[0] + assert command[:3] == ["powershell", "-NoProfile", "-Command"] + assert "codex_session_delete" in command[3] + assert "pythonw?" in command[3] + assert "Stop-Process" in command[3] + assert kwargs["env"]["CODEX_PLUS_PLUS_PID"] == "9876" + assert kwargs["check"] is False + + +def test_cli_skips_launcher_cleanup_on_non_windows(monkeypatch): + commands = [] + monkeypatch.setattr(cli.sys, "platform", "linux") + monkeypatch.setattr(cli.subprocess, "run", lambda command, **kwargs: commands.append((command, kwargs))) + + cli.stop_existing_windows_launchers() + + assert commands == [] + + +def test_cli_launch_runs_launcher_cleanup_before_injection(monkeypatch): + events = [] + monkeypatch.setattr(cli, "stop_existing_windows_launchers", lambda: events.append("cleanup")) + monkeypatch.setattr(cli, "launch_and_inject", lambda *args: events.append("launch") or (FakeServer(), None)) + monkeypatch.setattr(cli, "wait_for_shutdown", lambda server, proc: events.append("wait")) + + exit_code = cli.main(["launch"]) + + assert exit_code == 0 + assert events == ["cleanup", "launch", "wait"] def test_cli_setup_alias_installs_with_default_launcher(monkeypatch): @@ -162,3 +298,27 @@ def test_cli_logs_launch_failure_for_hidden_pythonw(monkeypatch, tmp_path): cli.main(["launch"]) assert "inject failed" in log_path.read_text(encoding="utf-8") + + +def test_wait_for_shutdown_waits_for_windows_process_id(monkeypatch): + server = FakeServer() + waited = [] + monkeypatch.setattr(cli.sys, "platform", "win32") + monkeypatch.setattr(cli, "wait_for_windows_process_id", lambda process_id: waited.append(process_id)) + + cli.wait_for_shutdown(server, 1234) + + assert waited == [1234] + assert server.shutdown_called is True + assert server.server_close_called is True + + +def test_wait_for_shutdown_waits_for_popen_like_process(): + server = FakeServer() + proc = FakeProcess() + + cli.wait_for_shutdown(server, proc) + + assert proc.waited is True + assert server.shutdown_called is True + assert server.server_close_called is True diff --git a/tests/test_macos_installer.py b/tests/test_macos_installer.py index c1480979..dd243bb0 100644 --- a/tests/test_macos_installer.py +++ b/tests/test_macos_installer.py @@ -23,6 +23,8 @@ def test_install_macos_app_creates_app_bundle(tmp_path): assert plist["CFBundleName"] == "Codex++" assert plist["CFBundleExecutable"] == "CodexPlusPlus" assert plist["CFBundleIdentifier"] == "com.bigpizzav3.codexplusplus" + assert plist["CFBundleIconFile"] == "codex-plus-plus.png" + assert (app / "Contents" / "Resources" / "codex-plus-plus.png").exists() script = executable.read_text(encoding="utf-8") assert "python -m codex_session_delete launch" in script diff --git a/tests/test_readme.py b/tests/test_readme.py index 34528f7b..0941de63 100644 --- a/tests/test_readme.py +++ b/tests/test_readme.py @@ -9,7 +9,16 @@ def test_readme_limits_discussion_group_qr_size(): assert '![Codex++ 交流群二维码](docs/images/discussion-group-qr.jpg)' not in text -def test_readme_includes_linux_do_friend_link(): +def test_readme_includes_codex_plus_icon_and_toc(): + text = Path("README.md").read_text(encoding="utf-8") + + assert ' runScanStep(scanDeferred))" in text + assert "if (window.__codexSessionDeleteScanPending) return" in text + assert "setTimeout(runScheduledScan, 200)" in text + assert "setTimeout(() => runScanStep(scanDeferred), 50)" not in text + assert "codexSessionDeleteAttachButtonFailures" in text + assert "tryAttachButton" in text + assert "sessionRows().forEach(tryAttachButton)" in text + assert "sessionRows().forEach(attachButton)" not in text assert "new MutationObserver(scheduleScan)" in text assert "new MutationObserver(scan)" not in text - assert "scheduleScan();" in text - assert " scan();\n window.__codexSessionDeleteObserver" not in text + assert "scan();" in text + assert " scan();\n window.__codexSessionDeleteObserver" in text + + +def test_renderer_script_clears_focus_and_removes_deleted_rows(): + text = Path("codex_session_delete/inject/renderer-inject.js").read_text(encoding="utf-8") + assert "removeDeletedRow(row, button, ref)" in text + assert "function releaseDeleteFocus" in text + assert "releaseDeleteFocus(row, button)" in text + assert "button.blur()" in text + assert "document.activeElement.blur()" in text + assert "row.remove()" in text + assert "row.style.display = \"none\"" not in text + + +def test_renderer_script_uses_in_page_confirm_and_stops_early_pointer_events(): + text = Path("codex_session_delete/inject/renderer-inject.js").read_text(encoding="utf-8") + assert "confirm(" not in text + assert "codex-delete-confirm-overlay" in text + assert "escapeHtml(title)" in text + assert "stopImmediatePropagation" in text + assert "\"pointerdown\", \"mousedown\", \"mouseup\", \"touchstart\"" in text + + +def test_renderer_script_reloads_after_deleting_current_session(): + text = Path("codex_session_delete/inject/renderer-inject.js").read_text(encoding="utf-8") + assert "isCurrentSessionRow" in text + assert "window.location.href.includes(ref.session_id)" in text + assert "window.location.reload()" in text + + +def test_renderer_script_toast_does_not_capture_page_interactions(): + text = Path("codex_session_delete/inject/renderer-inject.js").read_text(encoding="utf-8") + assert "z-index: 2147483000" in text + assert "pointer-events: none" in text + assert "pointer-events: auto" in text +def test_renderer_script_sidebar_delete_opens_on_pointerup_when_click_is_unreliable(): + text = Path("codex_session_delete/inject/renderer-inject.js").read_text(encoding="utf-8") + assert "openDeleteConfirm" in text + assert "codexDeleteVersion = \"5\"" in text + assert "existingDeleteButtons.length === 1" in text + assert "existingDeleteButtons[0].dataset.codexDeleteVersion === codexDeleteVersion" in text + assert "existingDeleteButtons.forEach((button) => button.remove())" in text + assert "row.dataset.codexDeleteRow = \"false\"" in text + assert "installDeleteButtonEventDelegation" in text + assert "codexSessionDeleteDocumentDeleteHandler" in text + assert "document.addEventListener(\"pointerup\", handler, true)" in text + assert "document.addEventListener(\"click\", handler, true)" in text + assert "button.addEventListener(\"pointerup\", openDeleteConfirm, true)" in text + + + text = Path("codex_session_delete/inject/renderer-inject.js").read_text(encoding="utf-8") + assert "updateDeleteButtonOffsets" in text + assert "codexDeleteStyleVersion = \"4\"" in text + assert "right: 66px" in text + assert "确认" in text + assert "归档对话" in text + assert "button.getAttribute(\"aria-label\")" in text + assert "label === \"归档对话\"" in text + + + text = Path("codex_session_delete/inject/renderer-inject.js").read_text(encoding="utf-8") + assert "archivedSessionRows" in text + assert "archivedPageRows" in text + assert "installArchivedDeleteAllButton" in text + assert "删除全部归档" in text + assert "deleteArchivedSessions" in text + assert "attachArchivedPageDeleteButton" in text + assert "resolveArchivedThread" in text + assert "stopArchivedButtonEvent" in text + assert "[\"pointerdown\", \"mousedown\", \"mouseup\", \"touchstart\"].forEach((eventName) => {\n button.addEventListener(eventName, stopArchivedButtonEvent, true);" in text + assert "pointerup" in text + assert "button.addEventListener(\"pointerup\", openArchivedDeleteAllConfirm, true)" in text + assert "archivedRefFromRow(row)" in text + assert "reactArchivedThreadFromNode" in text + assert "archivedThreadFromRow" in text + assert "props.archivedThread?.id" in text + assert "archivedThread.id || archivedThread.sessionId" in text + assert "replace(/\\d{4}年\\d{1,2}月\\d{1,2}日.*$/, \"\")" in text + assert "const titleMatches = sessionRows().map(sessionRefFromRow)" not in text + assert "document.querySelectorAll(\"[data-codex-archive-delete-all]\").forEach((node) => node.remove())" not in text + assert "const existingButton = document.querySelector(\"[data-codex-archive-delete-all]\")" in text + assert "if (existingButton?.dataset.codexArchiveDeleteAllVersion === codexArchiveDeleteAllVersion) return" in text + assert "existingButton?.remove()" in text + assert "button.dataset.codexArchiveDeleteAllVersion = codexArchiveDeleteAllVersion" in text + assert "data-codex-archive-delete-all" in text + assert "codex-archive-action-bar" in text + assert "codexDeleteStyleVersion" in text + assert "style.dataset.codexDeleteStyleVersion" in text + assert "position: fixed" in text + assert "archiveTitleContainer" in text + assert "element.getBoundingClientRect().x> 350" in text + assert "已归档对话" in text + assert "insertAdjacentElement(\"afterend\", button)" in text + assert "maxWidth: \"fit-content\"" in text + assert "alignSelf: \"flex-start\"" in text + assert "Object.assign(button.style" in text + assert "cursor: \"pointer\"" in text + assert "position: \"static\"" in text + assert "data-codex-archive-page-row" in text + assert "data-app-action-sidebar-thread-id" in text + assert "取消归档" in text + assert "已归档对话" in text + + +def test_renderer_script_does_not_include_fast_mode_patch(): + text = Path("codex_session_delete/inject/renderer-inject.js").read_text(encoding="utf-8") + assert "codexFastModeUnlockVersion" not in text + assert "enableFastModeFeatureFlags" not in text + assert "patchFastModeGates" not in text + assert "patchGeneralSettingsSpeedGate" not in text + assert "patchCodexPostForFastMode" not in text + assert "recordFastModeDiagnostic" not in text + assert "additionalSpeedTiers" not in text + assert "bodyJsonString" not in text + assert "forceChatGPTAuthForFastMode" not in text + assert "codex-fast-mode-row" not in text + assert "setAuthMethod(\"chatgpt\")" in text + assert "patchFastModeGateOnObject" not in text -def test_renderer_script_adds_codex_plus_menu_with_feature_toggles(): text = Path("codex_session_delete/inject/renderer-inject.js").read_text(encoding="utf-8") assert "installCodexPlusMenu" in text assert "Codex++" in text + assert "codexPlusVersion = \"1.0.4\"" in text + assert "Codex++ ${codexPlusVersion}" in text + assert "提出问题" in text + assert "https://github.com/BigPizzaV3/CodexPlusPlus/issues" in text + assert "window.open(issueUrl, \"_blank\")" in text assert "插件选项解锁" in text assert "特殊插件强制安装" in text assert "会话删除" in text + assert "原生菜单栏位置" in text + assert "nativeMenuPlacement: true" in text assert "关于 Codex++" in text assert "https://github.com/BigPizzaV3/CodexPlusPlus" in text assert "codexPlusSettings" in text @@ -87,13 +220,19 @@ def test_renderer_script_adds_codex_plus_menu_with_feature_toggles(): assert "backdrop-blur-xl" not in text assert "codex-plus-menu-floating" in text assert "findNativeMenuInsertionPoint" in text + assert "if (!codexPlusSettings().nativeMenuPlacement) return null" in text + assert "right: 140px" in text + assert "left: auto" in text + assert "pointer-events: auto" in text + assert "-webkit-app-region: no-drag" in text + assert ".codex-plus-trigger" in text assert "app-header-tint" in text assert "flex items-center gap-0.5" in text assert "codex-plus-menu-floating" in text assert "nativeButtonClass" in text assert "removeDuplicateCodexPlusMenus" in text assert "data-codex-plus-menu" in text - assert "textContent || \"\").trim() === \"Codex++\"" in text + assert "textContent || \"\").trim() === `Codex++ ${codexPlusVersion}`" in text assert "codexPlusMenuVersion = \"5\"" in text assert "codexPlusTriggerInstalled = \"5\"" in text assert ".codex-plus-trigger:hover" not in text diff --git a/tests/test_storage_adapter.py b/tests/test_storage_adapter.py index 7a81cef9..eaec34c0 100644 --- a/tests/test_storage_adapter.py +++ b/tests/test_storage_adapter.py @@ -116,6 +116,44 @@ def test_delete_codex_thread_schema_accepts_local_prefixed_thread_id(tmp_path): assert db.execute("SELECT COUNT(*) FROM threads WHERE id = 't1'").fetchone()[0] == 0 +def test_find_archived_codex_thread_by_title(tmp_path): + db_path = tmp_path / "state_5.sqlite" + rollout_path = tmp_path / "archived.jsonl" + create_codex_thread_db(db_path, rollout_path) + with sqlite3.connect(db_path) as db: + db.execute("UPDATE threads SET archived = 1, archived_at = 123 WHERE id = 't1'") + adapter = SQLiteStorageAdapter(db_path, BackupStore(tmp_path / "backups")) + + session = adapter.find_archived_thread_by_title("Codex Thread") + + assert session == SessionRef(session_id="t1", title="Codex Thread") + + +def test_find_archived_codex_thread_by_title_matches_rendered_archive_card_text(tmp_path): + db_path = tmp_path / "state_5.sqlite" + rollout_path = tmp_path / "archived.jsonl" + create_codex_thread_db(db_path, rollout_path) + with sqlite3.connect(db_path) as db: + db.execute("UPDATE threads SET archived = 1, archived_at = 123 WHERE id = 't1'") + adapter = SQLiteStorageAdapter(db_path, BackupStore(tmp_path / "backups")) + + session = adapter.find_archived_thread_by_title("Codex Thread 2026年5月9日,1:19 · RustGUI") + + assert session == SessionRef(session_id="t1", title="Codex Thread") + + +def test_find_archived_codex_thread_by_title_ignores_active_threads(tmp_path): + db_path = tmp_path / "state_5.sqlite" + rollout_path = tmp_path / "active.jsonl" + create_codex_thread_db(db_path, rollout_path) + adapter = SQLiteStorageAdapter(db_path, BackupStore(tmp_path / "backups")) + + session = adapter.find_archived_thread_by_title("Codex Thread") + + assert session is None + + +def test_delete_unsupported_schema_fails(tmp_path): db_path = tmp_path / "unknown.sqlite" with sqlite3.connect(db_path) as db: db.execute("CREATE TABLE unrelated (id TEXT PRIMARY KEY)") diff --git a/tests/test_watcher.py b/tests/test_watcher.py new file mode 100644 index 00000000..5b017836 --- /dev/null +++ b/tests/test_watcher.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import socket +import sys +import types +from pathlib import Path + +import pytest + +from codex_session_delete import watcher + + +def test_cdp_listening_returns_true_when_bound(): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server: + server.bind(("127.0.0.1", 0)) + server.listen(1) + port = server.getsockname()[1] + assert watcher.cdp_listening(port) is True + + +def test_cdp_listening_returns_false_when_closed(): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + probe.bind(("127.0.0.1", 0)) + port = probe.getsockname()[1] + # After the probe socket closes, nothing should be listening on that port + # (the port may get reused but the probe finishes with connection refused in normal conditions) + assert watcher.cdp_listening(port) is False + + +def test_enable_watcher_removes_flag(tmp_path, monkeypatch): + monkeypatch.setattr(watcher, "data_root", lambda: tmp_path) + flag = tmp_path / "watcher.disabled" + flag.parent.mkdir(parents=True, exist_ok=True) + flag.touch() + assert flag.exists() + watcher.enable_watcher() + assert not flag.exists() + + +def test_disable_watcher_creates_flag(tmp_path, monkeypatch): + monkeypatch.setattr(watcher, "data_root", lambda: tmp_path) + flag = tmp_path / "watcher.disabled" + assert not flag.exists() + watcher.disable_watcher() + assert flag.exists() + + +def test_enable_watcher_is_idempotent(tmp_path, monkeypatch): + monkeypatch.setattr(watcher, "data_root", lambda: tmp_path) + # Should not raise when flag does not exist + watcher.enable_watcher() + assert not (tmp_path / "watcher.disabled").exists() + + +def test_watch_loop_exits_on_non_windows(monkeypatch, tmp_path): + monkeypatch.setattr(watcher, "data_root", lambda: tmp_path) + monkeypatch.setattr(watcher.sys, "platform", "linux") + assert watcher.watch_loop() == 1 + + +def test_wait_until_no_codex_success(monkeypatch): + calls = {"n": 0} + + def find() -> list[int]: + calls["n"] += 1 + # First poll: one process, subsequent polls: empty + return [1234] if calls["n"] == 1 else [] + + monkeypatch.setattr(watcher, "find_codex_processes", find) + killed: list[list[int]] = [] + monkeypatch.setattr(watcher, "kill_processes", lambda pids: killed.append(list(pids))) + assert watcher.wait_until_no_codex(timeout=2.0) is True + + +def test_wait_until_no_codex_times_out(monkeypatch): + monkeypatch.setattr(watcher, "find_codex_processes", lambda: [1]) + monkeypatch.setattr(watcher, "kill_processes", lambda pids: None) + assert watcher.wait_until_no_codex(timeout=0.5) is False + + +def test_wait_for_cdp_returns_true_when_listening(monkeypatch): + seq = iter([False, False, True]) + monkeypatch.setattr(watcher, "cdp_listening", lambda port: next(seq)) + assert watcher.wait_for_cdp(port=9229, timeout=2.0) is True + + +def test_wait_for_cdp_returns_false_on_timeout(monkeypatch): + monkeypatch.setattr(watcher, "cdp_listening", lambda port: False) + assert watcher.wait_for_cdp(port=9229, timeout=0.3) is False diff --git a/tests/test_windows_installer.py b/tests/test_windows_installer.py index 76260d3b..2b6836df 100644 --- a/tests/test_windows_installer.py +++ b/tests/test_windows_installer.py @@ -10,6 +10,7 @@ def test_build_install_shortcut_script_contains_codex_plus_shortcuts(tmp_path): script = build_install_shortcut_script(options) assert "Codex++.lnk" in script + assert "codex-plus-plus.ico" in script assert "-m codex_session_delete launch" in script assert "CreateShortcut" in script assert "TargetPath = $Pythonw" in script @@ -19,9 +20,9 @@ def test_build_install_shortcut_script_contains_codex_plus_shortcuts(tmp_path): assert "-EncodedCommand" not in script assert "powershell.exe" not in script assert "WorkingDirectory = $ProjectRoot" in script - assert "OpenAI.Codex_*_x64__*" in script - assert "Codex.exe" in script - assert "IconLocation = \"$CodexIcon,0\"" in script + assert "codex-plus-plus.ico" in script + assert "Codex.exe" not in script + assert "IconLocation = $CodexPlusIcon" in script assert "$Python,0" not in script assert str(Path.cwd()) in script assert "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\CodexPlusPlus" in script

AltStyle によって変換されたページ (->オリジナル) /