v3.13.0: Desktop Updater, profile system fix, Antigravity E2E, conservative compaction
- Codex Desktop Updater: check/install/rollback/service management + manual rebuild - Fix Codex CLI 0.134.0 profiles: separate <slug>.config.toml files - Fix Antigravity: prod endpoint first, model resolution, OAUTH_PROVIDER - Fix compaction: max_input_items 60->200 for 1M-token models - Antigravity E2E test suite: test-antigravity.sh - Windows GUI: UpdateDesktopWindow + profile slug fix - Updated CHANGELOG.md and README.md
This commit is contained in:
@@ -20,12 +20,77 @@ BGP_POOLS_FILE = HOME / ".codex/bgp-pools.json"
|
||||
LOG_DIR = HOME / ".cache/codex-desktop"
|
||||
LAUNCH_LOG = LOG_DIR / "launcher.log"
|
||||
PROXY_CONFIG_DIR = HOME / ".cache/codex-proxy"
|
||||
ACTIVE_ENDPOINT_FILE = HOME / ".codex/.active-endpoint.json"
|
||||
DEFAULT_CONFIG = """model = ""
|
||||
model_provider = ""
|
||||
model_catalog_json = ""
|
||||
"""
|
||||
|
||||
CHANGELOG = [
|
||||
("3.13.0", "2026-05-27", [
|
||||
"Codex Desktop Updater: auto-update from ilysenko/codex-desktop-linux",
|
||||
"Fix Antigravity: prod endpoint first, model resolution, OAUTH_PROVIDER derivation",
|
||||
"Fix Codex CLI 0.134.0 profile system: separate .config.toml files",
|
||||
"Fix compaction: max_input_items 60->200 for 1M-token Antigravity models",
|
||||
"Antigravity E2E test suite: bash test-antigravity.sh",
|
||||
]),
|
||||
("3.12.1", "2026-05-27", [
|
||||
"Fix Antigravity adapter (PR #15): simplified model resolution",
|
||||
"Removed broken schema sanitization, restored headers",
|
||||
"Re-enabled gRPC fallback by default",
|
||||
]),
|
||||
("3.12.0", "2026-05-27", [
|
||||
"gRPC auto-fallback for Antigravity (PR #13)",
|
||||
"Dynamic version fetch with probe validation",
|
||||
"Antigravity v2 handler rewrite (anti-api)",
|
||||
]),
|
||||
("3.11.10", "2026-05-26", [
|
||||
"Fix Antigravity: interleave function_call/output pairs (PR #11)",
|
||||
"Gemini sanitizer: trim non-user turns for Google API compliance",
|
||||
]),
|
||||
("3.11.9", "2026-05-26", [
|
||||
"Fix Antigravity: preserve functionCall/functionResponse (PR #10)",
|
||||
"Prevents tool responses from being dropped in multi-turn sessions",
|
||||
]),
|
||||
("3.11.8", "2026-05-26", [
|
||||
"Vision cache persisted across requests (PR #8 merge)",
|
||||
"No redundant vision API calls for same image URL",
|
||||
]),
|
||||
("3.11.7", "2026-05-26", [
|
||||
"Vision auto-detect: uses provider's vision model for image description",
|
||||
"Vision preprocessing replaces image stripping",
|
||||
"Fix AttributeError in image_url string handling",
|
||||
"Merge PR #6: vision/OCR preprocessing, PR #7: 177 unit tests",
|
||||
"Auth os error 2 fix: proper config-missing message in GUI",
|
||||
]),
|
||||
("3.11.6", "2026-05-26", [
|
||||
"Antigravity loop breakers: per-session tracking, repeated tool detection",
|
||||
"has_content fix: function_call counts as valid output",
|
||||
"Latest user instruction appended once per request for Antigravity",
|
||||
"Antigravity-only changes, no touch to other providers",
|
||||
]),
|
||||
("3.11.5", "2026-05-26", [
|
||||
"Token-aware compaction: fixes context_length_exceeded on small-context models",
|
||||
"Proactive compaction triggers on token count, not just item count",
|
||||
"Universal adaptive compaction for all providers (removed crof.ai gates)",
|
||||
"Vision model detection + image stripping for non-vision models",
|
||||
"Per-model token limit learning from error messages",
|
||||
"Smart-continue text-tool detection for text-only models",
|
||||
"Active endpoint sync: auto-removes stale references on startup",
|
||||
]),
|
||||
("3.11.0", "2026-05-26", [
|
||||
"Merge cobra PR: concurrency semaphore (max 3), auto-continue for truncated text",
|
||||
"SO_REUSEADDR on sticky port, proxy-stderr.log, stream diagnostics logging",
|
||||
"Timeout/OSError handler sends response.failed SSE instead of silent drop",
|
||||
"Restart Proxy button: only restarts proxy without killing Codex Desktop",
|
||||
"Tool call argument normalizer: fixes Arguments→arguments, strips markdown wrapping",
|
||||
"Smart-continue loop (2× retries): escalating nudges when model stops text-only mid-task",
|
||||
"XML tool call extraction: parses <name> patterns from text, injects as real calls",
|
||||
"Auto-continue + smart-continue ordered with skip guard to avoid double-firing",
|
||||
"API key hot-reload with mtime tracking + /admin/reload + /admin/verify-key endpoints",
|
||||
"GUI hot-reload: auto-refreshes proxy key on endpoint edit, verifies with upstream",
|
||||
"Synthetic tool-results disabled: was causing deepseek-v4-pro truncation on opencode.ai",
|
||||
]),
|
||||
("3.10.4", "2026-05-25", [
|
||||
"OAuth Secrets editor in GUI — update client ID/secret without editing files",
|
||||
"Secrets stored in ~/.config/codex-launcher/oauth-secrets.json (not in repo)",
|
||||
@@ -425,6 +490,9 @@ def safe_name(name):
|
||||
digest = hashlib.sha1(name.encode("utf-8")).hexdigest()[:8]
|
||||
return f"{base}-{digest}"
|
||||
|
||||
def _profile_slug(name):
|
||||
return "".join(ch if ch.isalnum() else "-" for ch in name).strip("-") or "default"
|
||||
|
||||
def label_for_backend(backend_type):
|
||||
return {
|
||||
"openai-compat": "OpenAI-compatible",
|
||||
@@ -910,6 +978,27 @@ def restore_config():
|
||||
shutil.copy2(str(CONFIG_BAK), str(tmp))
|
||||
os.replace(str(tmp), str(CONFIG))
|
||||
|
||||
def set_active_endpoint(name):
|
||||
ACTIVE_ENDPOINT_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
write_secure_text(ACTIVE_ENDPOINT_FILE, json.dumps({"active": name}, indent=2))
|
||||
|
||||
def validate_active_endpoint(logfn=None):
|
||||
if not ACTIVE_ENDPOINT_FILE.exists():
|
||||
return
|
||||
try:
|
||||
d = json.loads(ACTIVE_ENDPOINT_FILE.read_text())
|
||||
active = d.get("active", "")
|
||||
if not active:
|
||||
return
|
||||
eps = load_endpoints()
|
||||
names = {ep.get("name", "") for ep in eps}
|
||||
if active not in names:
|
||||
ACTIVE_ENDPOINT_FILE.unlink()
|
||||
if logfn:
|
||||
logfn(f"Removed stale active-endpoint '{active}' (provider no longer exists)")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def write_secure_text(path, text):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
@@ -953,23 +1042,29 @@ def write_config_for_native(endpoint, selected_model):
|
||||
mc_path = PROXY_CONFIG_DIR / f"models-{safe_name(endpoint['name'])}.json"
|
||||
mc_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
mc_path.write_text(json.dumps(model_catalog, indent=2))
|
||||
mc_str = str(mc_path).replace("\\", "/")
|
||||
|
||||
lines = [
|
||||
main_lines = [
|
||||
f'model = "{_toml_safe(selected_model)}"\n',
|
||||
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
|
||||
f'model_catalog_json = "{mc_path}"\n',
|
||||
f'model_catalog_json = "{mc_str}"\n',
|
||||
f'\n[model_providers."{endpoint["name"]}"]\n',
|
||||
f'name = "{_toml_safe(endpoint["name"])}"\n',
|
||||
f'base_url = "{_toml_safe(endpoint["base_url"])}"\n',
|
||||
f'experimental_bearer_token = "{_toml_safe(_resolve_secret(endpoint["api_key"]))}"\n',
|
||||
f'\n[profiles."{endpoint["name"]}"]\n',
|
||||
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
|
||||
]
|
||||
write_secure_text(CONFIG, "".join(main_lines))
|
||||
|
||||
profile_slug = _profile_slug(endpoint["name"])
|
||||
profile_path = CONFIG.parent / f"{profile_slug}.config.toml"
|
||||
profile_lines = [
|
||||
f'model = "{_toml_safe(selected_model)}"\n',
|
||||
f'model_catalog_json = "{mc_path}"\n',
|
||||
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
|
||||
f'model_catalog_json = "{mc_str}"\n',
|
||||
f'service_tier = "default"\n',
|
||||
f'approvals_reviewer = "user"\n',
|
||||
]
|
||||
write_secure_text(CONFIG, "".join(lines))
|
||||
write_secure_text(profile_path, "".join(profile_lines))
|
||||
|
||||
def _toml_safe(val):
|
||||
val = str(val).replace('"', '\\"')
|
||||
@@ -988,12 +1083,12 @@ def write_config_for_translated(endpoint, selected_model, proxy_port=8080):
|
||||
mc_path = PROXY_CONFIG_DIR / f"models-{safe_name(endpoint['name'])}.json"
|
||||
mc_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
mc_path.write_text(json.dumps(model_catalog, indent=2))
|
||||
mc_str = str(mc_path).replace("\\", "/")
|
||||
|
||||
lines = [
|
||||
main_lines = [
|
||||
f'model = "{_toml_safe(selected_model)}"\n',
|
||||
f'review_model = "{_toml_safe(selected_model)}"\n',
|
||||
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
|
||||
f'model_catalog_json = "{mc_path}"\n',
|
||||
f'model_catalog_json = "{mc_str}"\n',
|
||||
f'\n[model_providers."{endpoint["name"]}"]\n',
|
||||
f'name = "{_toml_safe(endpoint["name"])}"\n',
|
||||
f'base_url = "http://127.0.0.1:{proxy_port}"\n',
|
||||
@@ -1002,15 +1097,19 @@ def write_config_for_translated(endpoint, selected_model, proxy_port=8080):
|
||||
f'request_max_retries = 1\n',
|
||||
f'stream_max_retries = 0\n',
|
||||
f'stream_idle_timeout_ms = 600000\n',
|
||||
f'\n[profiles."{endpoint["name"]}"]\n',
|
||||
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
|
||||
]
|
||||
write_secure_text(CONFIG, "".join(main_lines))
|
||||
|
||||
profile_slug = _profile_slug(endpoint["name"])
|
||||
profile_path = CONFIG.parent / f"{profile_slug}.config.toml"
|
||||
profile_lines = [
|
||||
f'model = "{_toml_safe(selected_model)}"\n',
|
||||
f'review_model = "{_toml_safe(selected_model)}"\n',
|
||||
f'model_catalog_json = "{mc_path}"\n',
|
||||
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
|
||||
f'model_catalog_json = "{mc_str}"\n',
|
||||
f'service_tier = "fast"\n',
|
||||
f'approvals_reviewer = "user"\n',
|
||||
]
|
||||
write_secure_text(CONFIG, "".join(lines))
|
||||
write_secure_text(profile_path, "".join(profile_lines))
|
||||
|
||||
def _gen_model_catalog(endpoint, selected_model=None):
|
||||
default_model = selected_model or endpoint.get("default_model")
|
||||
@@ -1253,6 +1352,9 @@ def _check_codex_auth():
|
||||
if out.returncode == 0 and text:
|
||||
return ("logged_in", text)
|
||||
if text:
|
||||
_tl = text.lower()
|
||||
if "no such file" in _tl or "os error 2" in _tl or "not found" in _tl:
|
||||
return ("not_configured", "Config missing — launch once to create")
|
||||
return ("error", text)
|
||||
return ("unknown", "No output from codex login status")
|
||||
except FileNotFoundError:
|
||||
@@ -1849,6 +1951,7 @@ class LauncherWin(Gtk.Window):
|
||||
self._proc = None
|
||||
self._endpoints_data = load_endpoints()
|
||||
recover_config_if_needed()
|
||||
validate_active_endpoint()
|
||||
|
||||
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
||||
self.add(vbox)
|
||||
@@ -1856,7 +1959,7 @@ class LauncherWin(Gtk.Window):
|
||||
# header row
|
||||
hdr = Gtk.Box(spacing=8)
|
||||
vbox.pack_start(hdr, False, False, 0)
|
||||
lbl = Gtk.Label(label="<b>Codex Launcher v3.10.9</b>")
|
||||
lbl = Gtk.Label(label=f"<b>Codex Launcher v{CHANGELOG[0][0]}</b>")
|
||||
lbl.set_use_markup(True)
|
||||
hdr.pack_start(lbl, False, False, 0)
|
||||
changelog_btn = Gtk.Button(label="Changelog")
|
||||
@@ -1883,6 +1986,9 @@ class LauncherWin(Gtk.Window):
|
||||
oauth_btn = Gtk.Button(label="OAuth Secrets")
|
||||
oauth_btn.connect("clicked", lambda b: self._edit_oauth_secrets())
|
||||
hdr.pack_end(oauth_btn, False, False, 0)
|
||||
updater_btn = Gtk.Button(label="Update Desktop")
|
||||
updater_btn.connect("clicked", lambda b: self._open_updater())
|
||||
hdr.pack_end(updater_btn, False, False, 0)
|
||||
|
||||
# verification status bar
|
||||
self._cli_info = _detect_codex_cli()
|
||||
@@ -2095,6 +2201,8 @@ class LauncherWin(Gtk.Window):
|
||||
self._relogin_btn.set_sensitive("cli" not in self._missing)
|
||||
elif status == "not_installed":
|
||||
self._auth_label.set_markup("<span foreground='#888'>Auth: N/A (CLI not installed)</span>")
|
||||
elif status == "not_configured":
|
||||
self._auth_label.set_markup("<span foreground='#d29922'>⚠ Config missing — launch once to create</span>")
|
||||
else:
|
||||
self._auth_label.set_markup(f"<span foreground='#d29922'>⚠ Auth: {msg}</span>")
|
||||
self._relogin_btn.set_sensitive("cli" not in self._missing)
|
||||
@@ -2331,6 +2439,18 @@ class LauncherWin(Gtk.Window):
|
||||
_py = str(Path(__file__).resolve().parent / "flet-codex-assist.py")
|
||||
subprocess.Popen([sys.executable, _py], start_new_session=True)
|
||||
|
||||
def _open_updater(self):
|
||||
try:
|
||||
if not UPDATER_BIN and not _detect_codex_desktop():
|
||||
self.log("Codex Desktop not installed. Nothing to update.")
|
||||
return
|
||||
self._updater_window = CodexUpdaterWindow()
|
||||
self._updater_window.connect("destroy", lambda *_: setattr(self, "_updater_window", None))
|
||||
except Exception as e:
|
||||
import traceback; traceback.print_exc()
|
||||
d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, f"Error: {e}")
|
||||
d.run(); d.destroy()
|
||||
|
||||
def _backup_profile(self):
|
||||
chooser = Gtk.FileChooserDialog(
|
||||
title="Backup Codex Profile",
|
||||
@@ -2594,6 +2714,8 @@ class LauncherWin(Gtk.Window):
|
||||
begin_config_transaction(f"launch:{ep['name']}")
|
||||
write_config_for_native(ep, model)
|
||||
|
||||
set_active_endpoint(ep["name"])
|
||||
|
||||
if target == "desktop":
|
||||
if needs_proxy:
|
||||
_kill_existing_desktop(self.log)
|
||||
@@ -2651,6 +2773,7 @@ class LauncherWin(Gtk.Window):
|
||||
|
||||
begin_config_transaction(f"launch:bgp:{pool['name']}")
|
||||
write_config_for_translated(bgp_ep, model, port)
|
||||
set_active_endpoint(pool["name"])
|
||||
|
||||
if target == "desktop":
|
||||
_kill_existing_desktop(self.log)
|
||||
@@ -2771,7 +2894,7 @@ class LauncherWin(Gtk.Window):
|
||||
cmd_parts.extend(["codex", "-c", f"model={model}",
|
||||
"-s", sandbox, "-a", approval])
|
||||
else:
|
||||
cmd_parts.extend(["codex", "--profile", ep["name"], "-c", f"model={model}",
|
||||
cmd_parts.extend(["codex", "--profile", _profile_slug(ep["name"]), "-c", f"model={model}",
|
||||
"-s", sandbox, "-a", approval])
|
||||
|
||||
self.log(f"Running: {' '.join(cmd_parts)}")
|
||||
@@ -4398,10 +4521,54 @@ class EditEndpointDialog(Gtk.Dialog):
|
||||
data["default"] = name
|
||||
|
||||
save_endpoints(data)
|
||||
self._hot_reload_proxy_key(new_ep)
|
||||
self._parent_mgr._rebuild()
|
||||
self._parent_mgr._parent._on_endpoints_updated()
|
||||
self.destroy()
|
||||
|
||||
def _hot_reload_proxy_key(self, ep):
|
||||
try:
|
||||
ep_name = ep.get("name", "")
|
||||
proxy_port = None
|
||||
import glob as _glob
|
||||
for cfg_file in _glob.glob(str(PROXY_CONFIG_DIR / "proxy-*.json")):
|
||||
try:
|
||||
with open(cfg_file) as f:
|
||||
pcfg = json.load(f)
|
||||
if ep_name.lower().replace(" ", "-") in cfg_file.lower():
|
||||
proxy_port = pcfg.get("port")
|
||||
pcfg["api_key"] = ep.get("api_key", "")
|
||||
with open(cfg_file, "w") as f:
|
||||
json.dump(pcfg, f, indent=2)
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if proxy_port:
|
||||
import urllib.request as _ur
|
||||
try:
|
||||
url = f"http://127.0.0.1:{proxy_port}/admin/reload"
|
||||
resp = _ur.urlopen(url, timeout=3)
|
||||
result = json.loads(resp.read())
|
||||
reloaded = result.get("reloaded", False)
|
||||
preview = result.get("api_key_preview", "?")
|
||||
self._parent_mgr._parent.log(
|
||||
f"[hot-reload] key {'updated' if reloaded else 'unchanged'}: {preview}")
|
||||
if reloaded:
|
||||
verify_url = f"http://127.0.0.1:{proxy_port}/admin/verify-key"
|
||||
vresp = _ur.urlopen(verify_url, timeout=10)
|
||||
vresult = json.loads(vresp.read())
|
||||
valid = vresult.get("valid", False)
|
||||
if valid:
|
||||
self._parent_mgr._parent.log(
|
||||
f"[hot-reload] key verified OK ({vresult.get('models', '?')} models)")
|
||||
else:
|
||||
self._parent_mgr._parent.log(
|
||||
f"[hot-reload] WARNING: key verification failed: {vresult.get('error', 'unknown')}")
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _show_error(self, msg):
|
||||
d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, msg)
|
||||
d.run(); d.destroy()
|
||||
@@ -5722,5 +5889,510 @@ class BenchmarkWindow(Gtk.Window):
|
||||
|
||||
GLib.idle_add(_show)
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Codex Desktop Updater — auto-update from ilysenko/codex-desktop-linux
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
UPSTREAM_REPO = "ilysenko/codex-desktop-linux"
|
||||
UPDATER_BIN = shutil.which("codex-update-manager") or ""
|
||||
UPDATER_STATE_FILE = Path.home() / ".local/state/codex-update-manager/state.json"
|
||||
UPDATER_SERVICE_LOG = Path.home() / ".local/state/codex-update-manager/service.log"
|
||||
|
||||
def _get_updater_status():
|
||||
try:
|
||||
out = subprocess.run(
|
||||
[UPDATER_BIN, "status", "--json"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
if out.returncode == 0 and out.stdout.strip():
|
||||
return json.loads(out.stdout.strip())
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _get_installed_desktop_version():
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["dpkg-query", "-W", "-f", "${Version}", "codex-desktop"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if out.returncode == 0 and out.stdout.strip():
|
||||
return out.stdout.strip()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _get_upstream_info():
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
f"https://api.github.com/repos/{UPSTREAM_REPO}/commits?per_page=1",
|
||||
headers={"Accept": "application/vnd.github+json", "User-Agent": "codex-launcher"},
|
||||
)
|
||||
resp = urllib.request.urlopen(req, timeout=10)
|
||||
commits = json.loads(resp.read())
|
||||
if commits:
|
||||
c = commits[0]
|
||||
return {
|
||||
"sha": c["sha"][:12],
|
||||
"date": c["commit"]["committer"]["date"][:10],
|
||||
"message": c["commit"]["message"].split("\n")[0][:80],
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _is_updater_service_active():
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["systemctl", "--user", "is-active", "codex-update-manager.service"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
return out.stdout.strip() == "active"
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class CodexUpdaterWindow(Gtk.Window):
|
||||
def __init__(self):
|
||||
super().__init__(title="Codex Desktop Updater")
|
||||
self.set_default_size(580, 520)
|
||||
self.set_border_width(10)
|
||||
self.set_position(Gtk.WindowPosition.CENTER)
|
||||
|
||||
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
||||
self.add(vbox)
|
||||
|
||||
hdr = Gtk.Box(spacing=8)
|
||||
vbox.pack_start(hdr, False, False, 0)
|
||||
lbl = Gtk.Label()
|
||||
lbl.set_markup("<b>Codex Desktop Updater</b>\n<small>Auto-update from github.com/ilysenko/codex-desktop-linux</small>")
|
||||
lbl.set_use_markup(True)
|
||||
hdr.pack_start(lbl, False, False, 0)
|
||||
|
||||
info_frame = Gtk.Frame(label="Current Installation")
|
||||
vbox.pack_start(info_frame, False, False, 4)
|
||||
info_grid = Gtk.Grid(column_spacing=12, row_spacing=4, margin=8)
|
||||
info_frame.add(info_grid)
|
||||
|
||||
self._installed_lbl = Gtk.Label(label="Checking…", xalign=0)
|
||||
self._service_lbl = Gtk.Label(label="Checking…", xalign=0)
|
||||
self._upstream_lbl = Gtk.Label(label="Checking…", xalign=0)
|
||||
self._candidate_lbl = Gtk.Label(label="—", xalign=0)
|
||||
self._cli_lbl = Gtk.Label(label="Checking…", xalign=0)
|
||||
|
||||
labels = [
|
||||
(0, "Installed:"), (1, self._installed_lbl),
|
||||
(2, "Upstream:"), (3, self._upstream_lbl),
|
||||
(4, "Service:"), (5, self._service_lbl),
|
||||
(6, "Candidate:"), (7, self._candidate_lbl),
|
||||
(8, "CLI:"), (9, self._cli_lbl),
|
||||
]
|
||||
for idx, widget in labels:
|
||||
if isinstance(widget, str):
|
||||
widget = Gtk.Label(label=widget, xalign=0)
|
||||
info_grid.attach(widget, idx % 2, idx // 2, 1, 1)
|
||||
|
||||
btn_box = Gtk.Box(spacing=8, homogeneous=True)
|
||||
vbox.pack_start(btn_box, False, False, 4)
|
||||
|
||||
self._check_btn = Gtk.Button(label="Check for Updates")
|
||||
self._check_btn.connect("clicked", lambda b: self._check_updates())
|
||||
btn_box.pack_start(self._check_btn, True, True, 0)
|
||||
|
||||
self._install_btn = Gtk.Button(label="Install Update")
|
||||
self._install_btn.connect("clicked", lambda b: self._install_update())
|
||||
self._install_btn.set_sensitive(False)
|
||||
self._install_btn.get_style_context().add_class("suggested-action")
|
||||
btn_box.pack_start(self._install_btn, True, True, 0)
|
||||
|
||||
self._rollback_btn = Gtk.Button(label="Rollback")
|
||||
self._rollback_btn.connect("clicked", lambda b: self._rollback())
|
||||
self._rollback_btn.set_sensitive(False)
|
||||
btn_box.pack_start(self._rollback_btn, True, True, 0)
|
||||
|
||||
auto_note = Gtk.Label(xalign=0)
|
||||
auto_note.set_markup("<small>↑ Auto-updater: only detects new upstream <i>Codex.dmg</i> from OpenAI. "
|
||||
"For latest community patches, use Rebuild from Source below.</small>")
|
||||
auto_note.set_use_markup(True)
|
||||
vbox.pack_start(auto_note, False, False, 0)
|
||||
|
||||
svc_box = Gtk.Box(spacing=8, homogeneous=True)
|
||||
vbox.pack_start(svc_box, False, False, 0)
|
||||
|
||||
self._svc_start_btn = Gtk.Button(label="Start Service")
|
||||
self._svc_start_btn.connect("clicked", lambda b: self._toggle_service("start"))
|
||||
svc_box.pack_start(self._svc_start_btn, True, True, 0)
|
||||
|
||||
self._svc_stop_btn = Gtk.Button(label="Stop Service")
|
||||
self._svc_stop_btn.connect("clicked", lambda b: self._toggle_service("stop"))
|
||||
svc_box.pack_start(self._svc_stop_btn, True, True, 0)
|
||||
|
||||
self._svc_enable_btn = Gtk.Button(label="Enable Autostart")
|
||||
self._svc_enable_btn.connect("clicked", lambda b: self._toggle_service("enable"))
|
||||
svc_box.pack_start(self._svc_enable_btn, True, True, 0)
|
||||
|
||||
rebuild_box = Gtk.Box(spacing=8)
|
||||
vbox.pack_start(rebuild_box, False, False, 4)
|
||||
rebuild_info = Gtk.Label(xalign=0)
|
||||
rebuild_info.set_markup(
|
||||
"<b>Rebuild from Source (Recommended)</b>\n"
|
||||
"<small>The auto-updater only detects new upstream Codex DMGs from OpenAI's CDN.\n"
|
||||
"To get the <i>latest community fixes</i> from ilysenko/codex-desktop-linux,\n"
|
||||
"use Clone/Pull then Build & Install to rebuild a fresh .deb from source.</small>"
|
||||
)
|
||||
rebuild_info.set_use_markup(True)
|
||||
rebuild_box.pack_start(rebuild_info, True, True, 0)
|
||||
|
||||
rebuild_btn_box = Gtk.Box(spacing=8)
|
||||
vbox.pack_start(rebuild_btn_box, False, False, 0)
|
||||
|
||||
self._clone_btn = Gtk.Button(label="Clone / Pull Repo")
|
||||
self._clone_btn.connect("clicked", lambda b: self._clone_or_pull())
|
||||
rebuild_btn_box.pack_start(self._clone_btn, True, True, 0)
|
||||
|
||||
self._build_btn = Gtk.Button(label="Build & Install .deb")
|
||||
self._build_btn.connect("clicked", lambda b: self._build_and_install())
|
||||
self._build_btn.set_sensitive(False)
|
||||
self._build_btn.get_style_context().add_class("suggested-action")
|
||||
rebuild_btn_box.pack_start(self._build_btn, True, True, 0)
|
||||
|
||||
self._rebuild_dir_lbl = Gtk.Label(label="", xalign=0)
|
||||
vbox.pack_start(self._rebuild_dir_lbl, False, False, 0)
|
||||
|
||||
sw = Gtk.ScrolledWindow()
|
||||
sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
|
||||
vbox.pack_start(sw, True, True, 0)
|
||||
self._log_buf = Gtk.TextBuffer()
|
||||
tv = Gtk.TextView(buffer=self._log_buf)
|
||||
tv.set_editable(False)
|
||||
tv.set_cursor_visible(False)
|
||||
tv.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
|
||||
sw.add(tv)
|
||||
|
||||
bb = Gtk.Box(spacing=8)
|
||||
vbox.pack_start(bb, False, False, 0)
|
||||
clear_btn = Gtk.Button(label="Clear Log")
|
||||
clear_btn.connect("clicked", lambda b: self._log_buf.set_text(""))
|
||||
bb.pack_start(clear_btn, False, False, 0)
|
||||
view_log_btn = Gtk.Button(label="View Service Log")
|
||||
view_log_btn.connect("clicked", lambda b: self._view_service_log())
|
||||
bb.pack_start(view_log_btn, False, False, 0)
|
||||
close_btn = Gtk.Button(label="Close")
|
||||
close_btn.connect("clicked", lambda b: self.destroy())
|
||||
bb.pack_end(close_btn, False, False, 0)
|
||||
|
||||
self.show_all()
|
||||
self._rebuild_dir = Path.home() / ".cache/codex-launcher/codex-desktop-linux"
|
||||
self._rebuild_dir_lbl.set_markup(f"<small>Build dir: {self._rebuild_dir}</small>")
|
||||
self._rebuild_dir_lbl.set_use_markup(True)
|
||||
self._log("Updater initialized")
|
||||
threading.Thread(target=self._refresh_status, daemon=True).start()
|
||||
|
||||
def _log(self, msg):
|
||||
def _append():
|
||||
e = self._log_buf.get_end_iter()
|
||||
self._log_buf.insert(e, msg + "\n")
|
||||
GLib.idle_add(_append)
|
||||
|
||||
def _refresh_status(self):
|
||||
installed = _get_installed_desktop_version()
|
||||
upstream = _get_upstream_info()
|
||||
status = _get_updater_status()
|
||||
svc_active = _is_updater_service_active()
|
||||
|
||||
def _update():
|
||||
if installed:
|
||||
self._installed_lbl.set_markup(f"<span foreground='#2ea043'><b>{installed}</b></span>")
|
||||
self._installed_lbl.set_use_markup(True)
|
||||
else:
|
||||
self._installed_lbl.set_text("Not installed via dpkg")
|
||||
|
||||
if upstream:
|
||||
self._upstream_lbl.set_markup(
|
||||
f"<span foreground='#2ea043'>{upstream['date']}</span>"
|
||||
f" <small>({upstream['sha']}) {upstream['message']}</small>"
|
||||
)
|
||||
self._upstream_lbl.set_use_markup(True)
|
||||
else:
|
||||
self._upstream_lbl.set_text("Could not fetch")
|
||||
|
||||
if svc_active:
|
||||
self._service_lbl.set_markup("<span foreground='#2ea043'>● active</span>")
|
||||
self._service_lbl.set_use_markup(True)
|
||||
else:
|
||||
self._service_lbl.set_markup("<span foreground='#d29922'>● inactive</span>")
|
||||
self._service_lbl.set_use_markup(True)
|
||||
|
||||
if status:
|
||||
cand = status.get("candidate_version")
|
||||
if cand:
|
||||
self._candidate_lbl.set_markup(f"<span foreground='#58a6ff'><b>{cand}</b></span>")
|
||||
self._candidate_lbl.set_use_markup(True)
|
||||
self._install_btn.set_sensitive(True)
|
||||
else:
|
||||
self._candidate_lbl.set_text("No update pending")
|
||||
self._install_btn.set_sensitive(False)
|
||||
|
||||
cli_ver = status.get("cli_installed_version", "")
|
||||
cli_latest = status.get("cli_latest_version", "")
|
||||
cli_status = status.get("cli_status", "")
|
||||
if cli_ver:
|
||||
color = "#2ea043" if cli_status == "up_to_date" else "#d29922"
|
||||
self._cli_lbl.set_markup(
|
||||
f"<span foreground='{color}'>{cli_ver}"
|
||||
f"{' (up to date)' if cli_status == 'up_to_date' else f' → {cli_latest}'}"
|
||||
f"</span>"
|
||||
)
|
||||
self._cli_lbl.set_use_markup(True)
|
||||
|
||||
has_rollback = bool(status.get("last_known_good_version"))
|
||||
self._rollback_btn.set_sensitive(has_rollback)
|
||||
else:
|
||||
if not UPDATER_BIN:
|
||||
self._candidate_lbl.set_text("codex-update-manager not found")
|
||||
else:
|
||||
self._candidate_lbl.set_text("Status unavailable")
|
||||
|
||||
if self._rebuild_dir.exists():
|
||||
self._build_btn.set_sensitive(True)
|
||||
|
||||
GLib.idle_add(_update)
|
||||
self._log(f"Status: installed={installed} svc={'active' if svc_active else 'inactive'}")
|
||||
|
||||
def _check_updates(self):
|
||||
self._check_btn.set_sensitive(False)
|
||||
self._log("Checking for updates…")
|
||||
|
||||
def _run():
|
||||
try:
|
||||
out = subprocess.run(
|
||||
[UPDATER_BIN, "check-now"],
|
||||
capture_output=True, text=True, timeout=120,
|
||||
)
|
||||
self._log(f"check-now: rc={out.returncode}")
|
||||
if out.stdout:
|
||||
self._log(out.stdout.strip())
|
||||
if out.stderr:
|
||||
self._log(out.stderr.strip())
|
||||
except Exception as e:
|
||||
self._log(f"Error: {e}")
|
||||
finally:
|
||||
GLib.idle_add(lambda: self._check_btn.set_sensitive(True))
|
||||
self._refresh_status()
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
|
||||
def _install_update(self):
|
||||
self._install_btn.set_sensitive(False)
|
||||
self._log("Installing update (may prompt for sudo)…")
|
||||
|
||||
def _run():
|
||||
try:
|
||||
desktop_running = False
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["pgrep", "-f", "/opt/codex-desktop/electron"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
desktop_running = out.returncode == 0
|
||||
except Exception:
|
||||
pass
|
||||
if desktop_running:
|
||||
self._log("⚠ Codex Desktop is running. Closing it to proceed with update…")
|
||||
subprocess.run(["pkill", "-f", "/opt/codex-desktop/electron"], timeout=10)
|
||||
import time; time.sleep(3)
|
||||
self._log("Desktop closed.")
|
||||
|
||||
out = subprocess.run(
|
||||
[UPDATER_BIN, "install-ready"],
|
||||
capture_output=True, text=True, timeout=300,
|
||||
)
|
||||
self._log(f"install-ready: rc={out.returncode}")
|
||||
combined = (out.stdout or "") + (out.stderr or "")
|
||||
if out.stdout:
|
||||
self._log(out.stdout.strip())
|
||||
if out.stderr:
|
||||
self._log(out.stderr.strip())
|
||||
if out.returncode == 0 and "successfully" in combined.lower():
|
||||
self._log("Update installed successfully!")
|
||||
elif "No Codex Desktop update is ready" in combined:
|
||||
self._log("⚠ No update is ready. Run 'Check for Updates' first, or use 'Clone/Pull + Build & Install' for a manual update.")
|
||||
GLib.idle_add(lambda: self._install_btn.set_sensitive(False))
|
||||
elif "Close it to install" in combined:
|
||||
self._log("⚠ Desktop was still running. Close Desktop manually and try again.")
|
||||
GLib.idle_add(lambda: self._install_btn.set_sensitive(True))
|
||||
elif out.returncode == 0:
|
||||
self._log("⚠ install-ready returned OK but no confirmation of actual install. Output: " + combined[:200])
|
||||
GLib.idle_add(lambda: self._install_btn.set_sensitive(False))
|
||||
else:
|
||||
self._log("Update may not have completed. Check the log above.")
|
||||
GLib.idle_add(lambda: self._install_btn.set_sensitive(True))
|
||||
except Exception as e:
|
||||
self._log(f"Error: {e}")
|
||||
finally:
|
||||
self._refresh_status()
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
|
||||
def _rollback(self):
|
||||
self._rollback_btn.set_sensitive(False)
|
||||
self._log("Rolling back to previous version…")
|
||||
|
||||
def _run():
|
||||
try:
|
||||
out = subprocess.run(
|
||||
[UPDATER_BIN, "rollback"],
|
||||
capture_output=True, text=True, timeout=300,
|
||||
)
|
||||
self._log(f"rollback: rc={out.returncode}")
|
||||
if out.stdout:
|
||||
self._log(out.stdout.strip())
|
||||
if out.stderr:
|
||||
self._log(out.stderr.strip())
|
||||
except Exception as e:
|
||||
self._log(f"Error: {e}")
|
||||
finally:
|
||||
self._refresh_status()
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
|
||||
def _toggle_service(self, action):
|
||||
cmd_map = {
|
||||
"start": ["systemctl", "--user", "start", "codex-update-manager.service"],
|
||||
"stop": ["systemctl", "--user", "stop", "codex-update-manager.service"],
|
||||
"enable": ["systemctl", "--user", "enable", "--now", "codex-update-manager.service"],
|
||||
}
|
||||
cmd = cmd_map.get(action)
|
||||
if not cmd:
|
||||
return
|
||||
self._log(f"Running: {' '.join(cmd)}")
|
||||
|
||||
def _run():
|
||||
try:
|
||||
out = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
|
||||
self._log(f"{action}: rc={out.returncode}")
|
||||
if out.stderr:
|
||||
self._log(out.stderr.strip())
|
||||
except Exception as e:
|
||||
self._log(f"Error: {e}")
|
||||
finally:
|
||||
self._refresh_status()
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
|
||||
def _clone_or_pull(self):
|
||||
self._clone_btn.set_sensitive(False)
|
||||
self._log(f"Clone/pull {UPSTREAM_REPO}…")
|
||||
|
||||
def _run():
|
||||
try:
|
||||
self._rebuild_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
if self._rebuild_dir.exists():
|
||||
self._log("Pulling latest changes…")
|
||||
out = subprocess.run(
|
||||
["git", "pull", "--ff-only"],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
cwd=str(self._rebuild_dir),
|
||||
)
|
||||
else:
|
||||
self._log("Cloning repository…")
|
||||
out = subprocess.run(
|
||||
["git", "clone", "--depth=1", f"https://github.com/{UPSTREAM_REPO}.git", str(self._rebuild_dir)],
|
||||
capture_output=True, text=True, timeout=120,
|
||||
)
|
||||
self._log(f"git: rc={out.returncode}")
|
||||
if out.stdout:
|
||||
self._log(out.stdout.strip()[:200])
|
||||
if out.stderr:
|
||||
self._log(out.stderr.strip()[:200])
|
||||
if out.returncode == 0:
|
||||
self._log("Repository ready.")
|
||||
GLib.idle_add(lambda: self._build_btn.set_sensitive(True))
|
||||
except Exception as e:
|
||||
self._log(f"Error: {e}")
|
||||
finally:
|
||||
GLib.idle_add(lambda: self._clone_btn.set_sensitive(True))
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
|
||||
def _build_and_install(self):
|
||||
self._build_btn.set_sensitive(False)
|
||||
self._log("Building Codex Desktop from source (this may take several minutes)…")
|
||||
|
||||
def _run():
|
||||
try:
|
||||
self._log("Installing build dependencies…")
|
||||
out = subprocess.run(
|
||||
["bash", "-c", "bash scripts/install-deps.sh"],
|
||||
capture_output=True, text=True, timeout=300,
|
||||
cwd=str(self._rebuild_dir),
|
||||
)
|
||||
self._log(f"install-deps: rc={out.returncode}")
|
||||
if out.stderr:
|
||||
self._log(out.stderr.strip()[-300:])
|
||||
|
||||
self._log("Building app from upstream DMG…")
|
||||
out = subprocess.run(
|
||||
["make", "build-app-fresh"],
|
||||
capture_output=True, text=True, timeout=600,
|
||||
cwd=str(self._rebuild_dir),
|
||||
)
|
||||
self._log(f"build-app-fresh: rc={out.returncode}")
|
||||
if out.stderr:
|
||||
self._log(out.stderr.strip()[-300:])
|
||||
|
||||
if out.returncode != 0:
|
||||
self._log("Build failed. Check log above.")
|
||||
return
|
||||
|
||||
self._log("Building .deb package…")
|
||||
out = subprocess.run(
|
||||
["make", "deb"],
|
||||
capture_output=True, text=True, timeout=120,
|
||||
cwd=str(self._rebuild_dir),
|
||||
)
|
||||
self._log(f"deb: rc={out.returncode}")
|
||||
if out.stderr:
|
||||
self._log(out.stderr.strip()[-300:])
|
||||
|
||||
if out.returncode != 0:
|
||||
self._log("Deb build failed.")
|
||||
return
|
||||
|
||||
deb_files = list((self._rebuild_dir / "dist").glob("codex-desktop_*.deb"))
|
||||
if not deb_files:
|
||||
self._log("No .deb found in dist/")
|
||||
return
|
||||
|
||||
deb_path = deb_files[-1]
|
||||
self._log(f"Installing {deb_path.name}…")
|
||||
out = subprocess.run(
|
||||
["pkexec", "dpkg", "-i", str(deb_path)],
|
||||
capture_output=True, text=True, timeout=120,
|
||||
)
|
||||
self._log(f"dpkg -i: rc={out.returncode}")
|
||||
if out.stdout:
|
||||
self._log(out.stdout.strip()[:300])
|
||||
if out.stderr:
|
||||
self._log(out.stderr.strip()[:300])
|
||||
|
||||
if out.returncode == 0:
|
||||
self._log("Codex Desktop updated successfully!")
|
||||
else:
|
||||
self._log("Installation failed. Try: sudo dpkg -i " + str(deb_path))
|
||||
except Exception as e:
|
||||
self._log(f"Error: {e}")
|
||||
finally:
|
||||
self._refresh_status()
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
|
||||
def _view_service_log(self):
|
||||
if UPDATER_SERVICE_LOG.exists():
|
||||
subprocess.Popen(["xdg-open", str(UPDATER_SERVICE_LOG)])
|
||||
else:
|
||||
self._log(f"Service log not found at {UPDATER_SERVICE_LOG}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
Reference in New Issue
Block a user