mirror of
https://github.com/citron-neo/citron-neo-winupdater.git
synced 2026-07-05 15:22:03 -07:00
Source nightly from Clangtron and discover PR builds via PR comments
Signed-off-by: Mythrax <mythrax@mythrax-rs.org>
This commit is contained in:
@@ -5,18 +5,26 @@ Official external updater utility for **Citron Neo** (Windows only), built with
|
||||
It can pull builds from any of three official channels:
|
||||
|
||||
- **Stable** — <https://github.com/citron-neo/emulator/releases>
|
||||
- **Nightly CI (MSVC)** — <https://github.com/citron-neo/CI/releases>
|
||||
- **PR Builds** — <https://github.com/citron-neo/PR/tags> (release assets are pulled from `citron-neo/PR/releases`)
|
||||
- **Nightly CI (Clangtron)** — <https://github.com/citron-neo/CI/releases>
|
||||
- **PR Builds** — open pull requests on <https://github.com/citron-neo/emulator/pulls>; per-PR Windows artifacts are pulled from the `**Build Artifacts for PR #N**` comment (powered by [nightly.link](https://nightly.link/))
|
||||
|
||||
The Clangtron (MinGW-w64) toolchain is no longer produced upstream, so the
|
||||
nightly CI channel only ships MSVC artifacts.
|
||||
Upstream no longer ships the MSVC or MinGW-w64 Windows toolchains. The only
|
||||
Windows artifact produced is the Clangtron build (Clang LTO cross-compiled
|
||||
from Linux), e.g. `Citron-windows-nightly-<sha>-x64-clangtron.zip`.
|
||||
|
||||
For the **PR Builds** channel, the updater scans the most recently updated
|
||||
open PRs on `citron-neo/emulator`. For each PR it reads the build-artifact
|
||||
comment posted to the PR, extracts the Windows Clangtron download URL (only
|
||||
URLs hosted on `nightly.link` are accepted), and presents the PRs in a
|
||||
dropdown together with their build status (`ready`, `building`, or
|
||||
`no Windows build`).
|
||||
|
||||
## Features
|
||||
|
||||
- Automatic update check on startup
|
||||
- Manual **Check for Updates** and **Update Now**
|
||||
- Current version vs latest version display
|
||||
- Switchable release channel: Stable / Nightly CI / PR Builds (Nightly CI is the default)
|
||||
- Switchable release channel: Stable / Nightly CI (Clangtron) / PR Builds (Nightly CI is the default)
|
||||
- Modern dark-mode UI (CustomTkinter)
|
||||
- Download + extraction progress bar
|
||||
- Detailed log panel
|
||||
@@ -64,14 +72,20 @@ nightly CI channel only ships MSVC artifacts.
|
||||
1. Launch updater.
|
||||
2. On first run, choose the install/update folder in setup popup.
|
||||
3. Optional: import data from an older portable install by selecting the folder containing `user`.
|
||||
4. Choose a release channel (`Stable`, `Nightly CI - MSVC`, or `PR Builds`).
|
||||
4. Choose a release channel (`Stable`, `Nightly CI - Clangtron`, or `PR Builds`).
|
||||
5. Click **Check for Updates**.
|
||||
6. Click **Update Now** if an update is available.
|
||||
7. Click **Launch Citron Neo** after success.
|
||||
6. For **PR Builds**, pick an open PR from the dropdown that became visible.
|
||||
The summary line tells you whether its Windows build is `ready`, still
|
||||
`building`, or has no usable Windows artifact. Use **Open PR on GitHub**
|
||||
to open the PR thread in your browser.
|
||||
7. Click **Update Now** if an update is available.
|
||||
8. Click **Launch Citron Neo** after success.
|
||||
|
||||
Switching the channel triggers a fresh check against the new repository.
|
||||
Builds applied from different channels are tracked independently — moving from
|
||||
nightly to stable (or vice versa) will be detected as an update.
|
||||
nightly to stable (or to a specific PR build) will be detected as an update.
|
||||
Installed PR builds are recorded as `pr-<number>-<short-sha>` so re-checking a
|
||||
PR after CI rebuilds the same commit will not trigger a redundant download.
|
||||
|
||||
The updater stores config in:
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import queue
|
||||
import threading
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
from tkinter import filedialog, messagebox
|
||||
from typing import Callable, Optional
|
||||
@@ -12,7 +13,11 @@ from updater import (
|
||||
CHANNEL_NIGHTLY,
|
||||
CHANNEL_PR,
|
||||
CHANNEL_STABLE,
|
||||
PR_BUILD_STATUS_BUILDING,
|
||||
PR_BUILD_STATUS_MISSING,
|
||||
PR_BUILD_STATUS_READY,
|
||||
CheckResult,
|
||||
PullRequestBuild,
|
||||
ReleaseInfo,
|
||||
UpdaterError,
|
||||
UpdaterService,
|
||||
@@ -20,13 +25,20 @@ from updater import (
|
||||
|
||||
CHANNEL_LABELS = {
|
||||
CHANNEL_STABLE: "Stable (citron-neo/emulator)",
|
||||
CHANNEL_NIGHTLY: "Nightly CI - MSVC (citron-neo/CI)",
|
||||
CHANNEL_PR: "PR Builds (citron-neo/PR)",
|
||||
CHANNEL_NIGHTLY: "Nightly CI - Clangtron (citron-neo/CI)",
|
||||
CHANNEL_PR: "PR Builds (citron-neo/emulator)",
|
||||
}
|
||||
# Order matters for the dropdown: stable, nightly, PR.
|
||||
CHANNEL_ORDER = (CHANNEL_STABLE, CHANNEL_NIGHTLY, CHANNEL_PR)
|
||||
LABEL_TO_CHANNEL = {label: key for key, label in CHANNEL_LABELS.items()}
|
||||
|
||||
PR_PLACEHOLDER_LABEL = "(no PRs loaded)"
|
||||
PR_STATUS_TEXT = {
|
||||
PR_BUILD_STATUS_READY: "Ready - Clangtron Windows build available",
|
||||
PR_BUILD_STATUS_BUILDING: "Building - check back once CI finishes",
|
||||
PR_BUILD_STATUS_MISSING: "No usable Windows build for this PR",
|
||||
}
|
||||
|
||||
|
||||
class UpdaterApp:
|
||||
def __init__(self) -> None:
|
||||
@@ -38,6 +50,9 @@ class UpdaterApp:
|
||||
self.busy = False
|
||||
self._startup_check_done = False
|
||||
self.ui_queue: queue.Queue[Callable[[], None]] = queue.Queue()
|
||||
self.pr_builds: list[PullRequestBuild] = []
|
||||
self.pr_label_lookup: dict[str, PullRequestBuild] = {}
|
||||
self.selected_pr: Optional[PullRequestBuild] = None
|
||||
|
||||
self.root = ctk.CTk()
|
||||
self.root.title("Citron Neo Updater")
|
||||
@@ -54,7 +69,7 @@ class UpdaterApp:
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
self.root.grid_columnconfigure(0, weight=1)
|
||||
self.root.grid_rowconfigure(3, weight=1)
|
||||
self.root.grid_rowconfigure(4, weight=1)
|
||||
|
||||
title = ctk.CTkLabel(
|
||||
self.root,
|
||||
@@ -94,8 +109,51 @@ class UpdaterApp:
|
||||
)
|
||||
self.channel_menu.grid(row=0, column=2, padx=12, pady=10, sticky="e")
|
||||
|
||||
self.pr_frame = ctk.CTkFrame(self.root)
|
||||
self.pr_frame.grid(row=2, column=0, padx=20, pady=8, sticky="ew")
|
||||
self.pr_frame.grid_columnconfigure(1, weight=1)
|
||||
|
||||
ctk.CTkLabel(
|
||||
self.pr_frame,
|
||||
text="PR Build:",
|
||||
font=ctk.CTkFont(size=14, weight="bold"),
|
||||
).grid(row=0, column=0, padx=12, pady=(10, 4), sticky="w")
|
||||
|
||||
self.pr_var = ctk.StringVar(value=PR_PLACEHOLDER_LABEL)
|
||||
self.pr_menu = ctk.CTkOptionMenu(
|
||||
self.pr_frame,
|
||||
variable=self.pr_var,
|
||||
values=[PR_PLACEHOLDER_LABEL],
|
||||
command=self._on_pr_selected,
|
||||
)
|
||||
self.pr_menu.grid(row=0, column=1, padx=8, pady=(10, 4), sticky="ew")
|
||||
self.pr_menu.configure(state="disabled")
|
||||
|
||||
self.pr_open_btn = ctk.CTkButton(
|
||||
self.pr_frame,
|
||||
text="Open PR on GitHub",
|
||||
width=160,
|
||||
command=self._open_selected_pr,
|
||||
)
|
||||
self.pr_open_btn.grid(row=0, column=2, padx=(8, 12), pady=(10, 4), sticky="e")
|
||||
self.pr_open_btn.configure(state="disabled")
|
||||
|
||||
self.pr_status_var = ctk.StringVar(value="Switch to PR Builds and check for updates to populate this list.")
|
||||
ctk.CTkLabel(
|
||||
self.pr_frame,
|
||||
textvariable=self.pr_status_var,
|
||||
text_color="#bdbdbd",
|
||||
font=ctk.CTkFont(size=13),
|
||||
anchor="w",
|
||||
justify="left",
|
||||
wraplength=820,
|
||||
).grid(row=1, column=0, columnspan=3, padx=12, pady=(0, 10), sticky="ew")
|
||||
|
||||
# Hidden by default; toggled when the PR channel is selected.
|
||||
self.pr_frame.grid_remove()
|
||||
|
||||
controls_frame = ctk.CTkFrame(self.root)
|
||||
controls_frame.grid(row=2, column=0, padx=20, pady=8, sticky="ew")
|
||||
controls_frame.grid(row=3, column=0, padx=20, pady=8, sticky="ew")
|
||||
controls_frame.grid_columnconfigure((0, 1, 2, 3, 4), weight=1)
|
||||
|
||||
self.check_btn = ctk.CTkButton(
|
||||
@@ -135,7 +193,7 @@ class UpdaterApp:
|
||||
self.import_btn.grid(row=0, column=4, padx=8, pady=12, sticky="ew")
|
||||
|
||||
progress_frame = ctk.CTkFrame(self.root)
|
||||
progress_frame.grid(row=3, column=0, padx=20, pady=(8, 6), sticky="nsew")
|
||||
progress_frame.grid(row=4, column=0, padx=20, pady=(8, 6), sticky="nsew")
|
||||
progress_frame.grid_columnconfigure(0, weight=1)
|
||||
progress_frame.grid_rowconfigure(2, weight=1)
|
||||
|
||||
@@ -177,6 +235,9 @@ class UpdaterApp:
|
||||
self.install_path_var.set(str(install_path))
|
||||
preferred = self.service.get_preferred_channel()
|
||||
self.channel_var.set(CHANNEL_LABELS.get(preferred, CHANNEL_LABELS[CHANNEL_NIGHTLY]))
|
||||
self._set_pr_panel_visible(preferred == CHANNEL_PR)
|
||||
if preferred == CHANNEL_PR:
|
||||
self.pr_status_var.set("Loading open PRs from citron-neo/emulator...")
|
||||
self.log("Updater started.")
|
||||
|
||||
def _maybe_show_first_run_setup(self) -> None:
|
||||
@@ -295,6 +356,9 @@ class UpdaterApp:
|
||||
self.import_btn.configure(state=button_state)
|
||||
self.channel_menu.configure(state=button_state)
|
||||
self.update_btn.configure(state=button_state if self.current_release else "disabled")
|
||||
pr_menu_state = button_state if self.pr_builds and not busy else "disabled"
|
||||
self.pr_menu.configure(state=pr_menu_state)
|
||||
self.pr_open_btn.configure(state=button_state if self.selected_pr else "disabled")
|
||||
|
||||
def _progress_cb(self, value: float, status: str) -> None:
|
||||
self.ui_queue.put(lambda: self.progress_bar.set(max(0.0, min(1.0, value))))
|
||||
@@ -308,10 +372,13 @@ class UpdaterApp:
|
||||
def check_updates(self) -> None:
|
||||
def task() -> None:
|
||||
self.ui_queue.put(lambda: self.status_var.set("Status: Checking for updates..."))
|
||||
channel = self.service.get_preferred_channel().upper()
|
||||
self.ui_queue.put(
|
||||
lambda c=channel: self.log(f"Checking GitHub release (channel: {c})...")
|
||||
)
|
||||
channel = self.service.get_preferred_channel()
|
||||
channel_upper = channel.upper()
|
||||
if channel == CHANNEL_PR:
|
||||
msg = "Scanning open PRs on citron-neo/emulator for Windows Clangtron builds..."
|
||||
else:
|
||||
msg = f"Checking GitHub release (channel: {channel_upper})..."
|
||||
self.ui_queue.put(lambda m=msg: self.log(m))
|
||||
self.ui_queue.put(lambda: self.progress_bar.set(0))
|
||||
try:
|
||||
result = self.service.check_for_updates()
|
||||
@@ -324,10 +391,17 @@ class UpdaterApp:
|
||||
self._run_background(task)
|
||||
|
||||
def _apply_check_result(self, result: CheckResult) -> None:
|
||||
self.current_release = result.release
|
||||
self.current_version_var.set(f"Current: {result.current_version}")
|
||||
self.latest_version_var.set(f"Latest: {result.latest_version}")
|
||||
|
||||
channel = self.service.get_preferred_channel()
|
||||
|
||||
if channel == CHANNEL_PR:
|
||||
self.current_release = None
|
||||
self._populate_pr_dropdown(result.pull_requests)
|
||||
return
|
||||
|
||||
self.current_release = result.release
|
||||
if result.update_available and result.release:
|
||||
self.status_var.set("Status: Update available")
|
||||
self.update_btn.configure(state="normal")
|
||||
@@ -340,6 +414,122 @@ class UpdaterApp:
|
||||
self.update_btn.configure(state="disabled")
|
||||
self.log("No update needed.")
|
||||
|
||||
def _populate_pr_dropdown(self, prs: list[PullRequestBuild]) -> None:
|
||||
self.pr_builds = list(prs or [])
|
||||
self.selected_pr = None
|
||||
self.current_release = None
|
||||
self.pr_label_lookup = {}
|
||||
labels: list[str] = []
|
||||
|
||||
ready_count = 0
|
||||
building_count = 0
|
||||
for pr in self.pr_builds:
|
||||
label = pr.display_label
|
||||
# Disambiguate duplicates that share the same display string.
|
||||
base = label
|
||||
n = 2
|
||||
while label in self.pr_label_lookup:
|
||||
label = f"{base} #{n}"
|
||||
n += 1
|
||||
self.pr_label_lookup[label] = pr
|
||||
labels.append(label)
|
||||
if pr.status == PR_BUILD_STATUS_READY:
|
||||
ready_count += 1
|
||||
elif pr.status == PR_BUILD_STATUS_BUILDING:
|
||||
building_count += 1
|
||||
|
||||
if not labels:
|
||||
labels = [PR_PLACEHOLDER_LABEL]
|
||||
self.pr_var.set(PR_PLACEHOLDER_LABEL)
|
||||
self.pr_menu.configure(values=labels, state="disabled")
|
||||
self.pr_status_var.set(
|
||||
"No open PRs were returned by GitHub. Check your network or try again later."
|
||||
)
|
||||
self.pr_open_btn.configure(state="disabled")
|
||||
self.update_btn.configure(state="disabled")
|
||||
self.status_var.set("Status: No PR builds available")
|
||||
self.log("No open PRs returned for the PR channel.")
|
||||
return
|
||||
|
||||
self.pr_menu.configure(values=labels, state="normal")
|
||||
|
||||
first_ready_label = next(
|
||||
(lbl for lbl, pr in self.pr_label_lookup.items() if pr.status == PR_BUILD_STATUS_READY),
|
||||
None,
|
||||
)
|
||||
default_label = first_ready_label or labels[0]
|
||||
self.pr_var.set(default_label)
|
||||
self._on_pr_selected(default_label)
|
||||
|
||||
summary = (
|
||||
f"Loaded {len(self.pr_builds)} open PR(s) - "
|
||||
f"{ready_count} ready, {building_count} building, "
|
||||
f"{len(self.pr_builds) - ready_count - building_count} without Windows builds."
|
||||
)
|
||||
self.log(summary)
|
||||
self.status_var.set(
|
||||
f"Status: {ready_count} PR build(s) ready"
|
||||
if ready_count
|
||||
else "Status: No PR Windows builds ready yet"
|
||||
)
|
||||
|
||||
def _on_pr_selected(self, selected_label: str) -> None:
|
||||
pr = self.pr_label_lookup.get(selected_label)
|
||||
self.selected_pr = pr
|
||||
if not pr:
|
||||
self.current_release = None
|
||||
self.update_btn.configure(state="disabled")
|
||||
self.pr_open_btn.configure(state="disabled")
|
||||
self.pr_status_var.set("Select a PR to see its build status.")
|
||||
return
|
||||
|
||||
self.pr_open_btn.configure(state="disabled" if self.busy else "normal")
|
||||
commit_part = f"@ {pr.short_sha}" if pr.short_sha else ""
|
||||
author_part = f"by {pr.author}" if pr.author else ""
|
||||
meta = " ".join(part for part in (commit_part, author_part) if part)
|
||||
status_text = PR_STATUS_TEXT.get(pr.status, pr.status)
|
||||
self.pr_status_var.set(f"PR #{pr.number} {meta} - {status_text}")
|
||||
|
||||
if pr.status != PR_BUILD_STATUS_READY:
|
||||
self.current_release = None
|
||||
self.update_btn.configure(state="disabled")
|
||||
return
|
||||
|
||||
try:
|
||||
release = self.service.pr_build_to_release_info(pr)
|
||||
except UpdaterError as exc:
|
||||
self.current_release = None
|
||||
self.update_btn.configure(state="disabled")
|
||||
self.pr_status_var.set(f"PR #{pr.number}: {exc}")
|
||||
return
|
||||
|
||||
self.current_release = release
|
||||
self.latest_version_var.set(f"Latest: PR #{pr.number} ({pr.short_sha or 'unknown'})")
|
||||
self.update_btn.configure(state="disabled" if self.busy else "normal")
|
||||
|
||||
def _open_selected_pr(self) -> None:
|
||||
if not self.selected_pr:
|
||||
return
|
||||
url = self.selected_pr.pr_url
|
||||
if not url:
|
||||
return
|
||||
try:
|
||||
webbrowser.open(url, new=2)
|
||||
except Exception as exc:
|
||||
self._handle_error("Could not open PR in browser", exc)
|
||||
|
||||
def _set_pr_panel_visible(self, visible: bool) -> None:
|
||||
if visible:
|
||||
self.pr_frame.grid()
|
||||
else:
|
||||
self.pr_frame.grid_remove()
|
||||
self.pr_builds = []
|
||||
self.pr_label_lookup = {}
|
||||
self.selected_pr = None
|
||||
self.pr_var.set(PR_PLACEHOLDER_LABEL)
|
||||
self.pr_menu.configure(values=[PR_PLACEHOLDER_LABEL], state="disabled")
|
||||
self.pr_open_btn.configure(state="disabled")
|
||||
|
||||
def _on_channel_changed(self, selected_label: str) -> None:
|
||||
channel = LABEL_TO_CHANNEL.get(selected_label, CHANNEL_NIGHTLY)
|
||||
try:
|
||||
@@ -347,6 +537,11 @@ class UpdaterApp:
|
||||
except Exception as exc:
|
||||
self._handle_error("Channel setting failed", exc)
|
||||
return
|
||||
self._set_pr_panel_visible(channel == CHANNEL_PR)
|
||||
if channel == CHANNEL_PR:
|
||||
self.pr_status_var.set("Loading open PRs from citron-neo/emulator...")
|
||||
else:
|
||||
self.pr_status_var.set("Switch to PR Builds and check for updates to populate this list.")
|
||||
self.log(f"Release channel set to {channel.upper()}.")
|
||||
self.status_var.set(f"Status: Release channel: {channel.upper()}")
|
||||
# Refresh release lookup to switch source repository immediately.
|
||||
|
||||
+295
-45
@@ -2,12 +2,14 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
from urllib.parse import urlparse
|
||||
from zipfile import BadZipFile, ZipFile
|
||||
|
||||
import requests
|
||||
@@ -18,8 +20,12 @@ VERSION_MARKER_NAME = ".citron_updater_version.json"
|
||||
KNOWN_PROCESS_NAMES = ("citron-neo.exe", "citron.exe", "yuzu.exe")
|
||||
|
||||
# Release channels.
|
||||
# Clangtron / MinGW is no longer produced upstream — only MSVC builds remain
|
||||
# on the CI channel, so toolchain selection has been removed.
|
||||
# Upstream no longer ships MSVC or MinGW Windows builds — the only Windows
|
||||
# toolchain produced is Clangtron (Clang LTO cross-compiled from Linux),
|
||||
# served from the citron-neo/CI nightly-windows release. PR builds are now
|
||||
# discovered by scanning open PRs on citron-neo/emulator and reading the
|
||||
# "Build Artifacts for PR #N" comment that links direct downloads via
|
||||
# nightly.link.
|
||||
CHANNEL_STABLE = "stable"
|
||||
CHANNEL_NIGHTLY = "nightly"
|
||||
CHANNEL_PR = "pr"
|
||||
@@ -28,16 +34,46 @@ DEFAULT_CHANNEL = CHANNEL_NIGHTLY
|
||||
CHANNEL_RELEASE_API = {
|
||||
CHANNEL_STABLE: "https://api.github.com/repos/citron-neo/emulator/releases",
|
||||
CHANNEL_NIGHTLY: "https://api.github.com/repos/citron-neo/CI/releases",
|
||||
CHANNEL_PR: "https://api.github.com/repos/citron-neo/PR/releases",
|
||||
}
|
||||
|
||||
# Fallback for PR builds: upstream surfaces them at /tags rather than /releases.
|
||||
# We still need release assets to install binaries, but if the releases endpoint
|
||||
# is empty we can hit /tags to verify the channel exists before erroring out.
|
||||
CHANNEL_TAGS_API = {
|
||||
CHANNEL_PR: "https://api.github.com/repos/citron-neo/PR/tags",
|
||||
# Channels the user may select. PR is sourced from open PRs on
|
||||
# citron-neo/emulator instead of a releases endpoint, so it isn't part of
|
||||
# CHANNEL_RELEASE_API but is still a valid channel.
|
||||
SUPPORTED_CHANNELS = frozenset({CHANNEL_STABLE, CHANNEL_NIGHTLY, CHANNEL_PR})
|
||||
|
||||
EMULATOR_PRS_API = "https://api.github.com/repos/citron-neo/emulator/pulls"
|
||||
EMULATOR_PR_COMMENTS_API = (
|
||||
"https://api.github.com/repos/citron-neo/emulator/issues/{number}/comments"
|
||||
)
|
||||
EMULATOR_PR_HTML_URL = "https://github.com/citron-neo/emulator/pull/{number}"
|
||||
|
||||
GITHUB_API_HEADERS = {
|
||||
"Accept": "application/vnd.github+json",
|
||||
"User-Agent": "CitronNeoUpdater",
|
||||
}
|
||||
|
||||
# Cap how many PRs we look up so we don't hammer the GitHub API for every open
|
||||
# PR (and run into anonymous rate limits).
|
||||
PR_FETCH_LIMIT = 25
|
||||
|
||||
# Restrict PR artifact URLs to nightly.link to avoid being tricked into
|
||||
# downloading from arbitrary hosts via crafted PR comments.
|
||||
ALLOWED_PR_ARTIFACT_HOST = "nightly.link"
|
||||
|
||||
PR_ARTIFACT_HEADER_RE = re.compile(r"\*\*Build Artifacts for PR\b", re.IGNORECASE)
|
||||
PR_WINDOWS_ROW_RE = re.compile(
|
||||
r"\|\s*\*\*Windows\*\*\s*\|"
|
||||
r"(?P<commit>[^|]*)\|"
|
||||
r"(?P<artifacts>[^|]*)\|"
|
||||
r"(?P<logs>[^|]*)\|",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
PR_MD_LINK_RE = re.compile(r"\[(?P<label>[^\]]+)\]\((?P<url>[^)]+)\)")
|
||||
|
||||
PR_BUILD_STATUS_READY = "ready"
|
||||
PR_BUILD_STATUS_BUILDING = "building"
|
||||
PR_BUILD_STATUS_MISSING = "missing"
|
||||
|
||||
CONFIG_DIR = Path(os.getenv("APPDATA", str(Path.home()))) / "CitronNeoUpdater"
|
||||
CONFIG_FILE = CONFIG_DIR / "config.json"
|
||||
DEFAULT_INSTALL_PATH = Path(os.getenv("APPDATA", str(Path.home()))) / "citron"
|
||||
@@ -68,12 +104,40 @@ class ReleaseInfo:
|
||||
channel: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class PullRequestBuild:
|
||||
number: int
|
||||
title: str
|
||||
author: str
|
||||
head_sha: str
|
||||
short_sha: str
|
||||
pr_url: str
|
||||
updated_at: str
|
||||
status: str # one of PR_BUILD_STATUS_*
|
||||
artifact_label: Optional[str] = None
|
||||
artifact_url: Optional[str] = None
|
||||
run_url: Optional[str] = None
|
||||
|
||||
@property
|
||||
def display_label(self) -> str:
|
||||
title = " ".join(self.title.split()) or "Untitled PR"
|
||||
if len(title) > 70:
|
||||
title = title[:67] + "..."
|
||||
suffix = {
|
||||
PR_BUILD_STATUS_READY: "ready",
|
||||
PR_BUILD_STATUS_BUILDING: "building",
|
||||
PR_BUILD_STATUS_MISSING: "no Windows build",
|
||||
}.get(self.status, self.status)
|
||||
return f"#{self.number} ({suffix}) - {title}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CheckResult:
|
||||
current_version: str
|
||||
latest_version: str
|
||||
update_available: bool
|
||||
release: Optional[ReleaseInfo]
|
||||
pull_requests: list[PullRequestBuild] = field(default_factory=list)
|
||||
|
||||
|
||||
def _default_config() -> dict:
|
||||
@@ -87,11 +151,12 @@ def _default_config() -> dict:
|
||||
|
||||
def _normalize_channel(value: object) -> str:
|
||||
text = str(value or "").lower().strip()
|
||||
if text in CHANNEL_RELEASE_API:
|
||||
if text in SUPPORTED_CHANNELS:
|
||||
return text
|
||||
# Migrate legacy toolchain values from older configs — both toolchains map
|
||||
# to the nightly CI channel, which is now MSVC-only.
|
||||
if text in {"msvc", "mingw"}:
|
||||
# Migrate legacy toolchain values from older configs. The MSVC and MinGW
|
||||
# toolchains were retired upstream; both now resolve to the nightly CI
|
||||
# channel (Clangtron-only).
|
||||
if text in {"msvc", "mingw", "clang", "clangtron"}:
|
||||
return CHANNEL_NIGHTLY
|
||||
return DEFAULT_CHANNEL
|
||||
|
||||
@@ -149,7 +214,7 @@ class UpdaterService:
|
||||
|
||||
def set_preferred_channel(self, channel: str) -> None:
|
||||
normalized = str(channel).lower().strip()
|
||||
if normalized not in CHANNEL_RELEASE_API:
|
||||
if normalized not in SUPPORTED_CHANNELS:
|
||||
raise UpdaterError(f"Unsupported release channel: {channel}")
|
||||
cfg = self.config_store.load()
|
||||
cfg["preferred_channel"] = normalized
|
||||
@@ -201,12 +266,33 @@ class UpdaterService:
|
||||
def check_for_updates(self, install_path: Optional[Path] = None) -> CheckResult:
|
||||
install_path = install_path or self.get_install_path()
|
||||
current = self.get_current_version(install_path)
|
||||
release = self._fetch_latest_windows_release()
|
||||
channel = self.get_preferred_channel()
|
||||
|
||||
if channel == CHANNEL_PR:
|
||||
prs = self.fetch_open_pull_requests()
|
||||
ready_count = sum(1 for pr in prs if pr.status == PR_BUILD_STATUS_READY)
|
||||
if not prs:
|
||||
latest = "No open PRs found"
|
||||
elif ready_count:
|
||||
latest = f"{ready_count} PR build(s) ready"
|
||||
else:
|
||||
latest = "No PR Windows builds ready yet"
|
||||
return CheckResult(
|
||||
current_version=current,
|
||||
latest_version=latest,
|
||||
update_available=False,
|
||||
release=None,
|
||||
pull_requests=prs,
|
||||
)
|
||||
|
||||
release = self._fetch_latest_windows_release(channel=channel)
|
||||
latest = (
|
||||
f"{release.tag_name or release.name or 'Unknown'} "
|
||||
f"({release.asset_name}, {release.channel.upper()})"
|
||||
)
|
||||
update_available = self._is_update_available(install_path=install_path, latest_release=release)
|
||||
update_available = self._is_update_available(
|
||||
install_path=install_path, latest_release=release
|
||||
)
|
||||
return CheckResult(
|
||||
current_version=current,
|
||||
latest_version=latest,
|
||||
@@ -214,11 +300,18 @@ class UpdaterService:
|
||||
release=release,
|
||||
)
|
||||
|
||||
def _fetch_latest_windows_release(self) -> ReleaseInfo:
|
||||
channel = self.get_preferred_channel()
|
||||
api_url = CHANNEL_RELEASE_API[channel]
|
||||
def _fetch_latest_windows_release(self, channel: str) -> ReleaseInfo:
|
||||
api_url = CHANNEL_RELEASE_API.get(channel)
|
||||
if not api_url:
|
||||
raise UpdaterError(
|
||||
f"Channel {channel!r} does not expose GitHub releases."
|
||||
)
|
||||
try:
|
||||
resp = requests.get(api_url, timeout=DEFAULT_TIMEOUT)
|
||||
resp = requests.get(
|
||||
api_url,
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
headers=GITHUB_API_HEADERS,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
releases = resp.json()
|
||||
except requests.RequestException as exc:
|
||||
@@ -229,10 +322,7 @@ class UpdaterService:
|
||||
raise NetworkError("GitHub API returned invalid JSON.") from exc
|
||||
|
||||
if not isinstance(releases, list) or not releases:
|
||||
tag_hint = self._tag_hint_for_channel(channel)
|
||||
raise NetworkError(
|
||||
f"No releases found on the {channel} channel.{tag_hint}"
|
||||
)
|
||||
raise NetworkError(f"No releases found on the {channel} channel.")
|
||||
|
||||
for rel in releases:
|
||||
if rel.get("draft"):
|
||||
@@ -257,27 +347,179 @@ class UpdaterService:
|
||||
)
|
||||
|
||||
raise NetworkError(
|
||||
f"No suitable Windows zip artifact was found on the {channel} channel."
|
||||
f"No suitable Windows Clangtron zip artifact was found on the "
|
||||
f"{channel} channel."
|
||||
)
|
||||
|
||||
def _tag_hint_for_channel(self, channel: str) -> str:
|
||||
tags_url = CHANNEL_TAGS_API.get(channel)
|
||||
if not tags_url:
|
||||
return ""
|
||||
def fetch_open_pull_requests(self) -> list[PullRequestBuild]:
|
||||
try:
|
||||
resp = requests.get(tags_url, timeout=DEFAULT_TIMEOUT)
|
||||
resp = requests.get(
|
||||
EMULATOR_PRS_API,
|
||||
params={
|
||||
"state": "open",
|
||||
"per_page": PR_FETCH_LIMIT,
|
||||
"sort": "updated",
|
||||
"direction": "desc",
|
||||
},
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
headers=GITHUB_API_HEADERS,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
tags = resp.json()
|
||||
except (requests.RequestException, ValueError):
|
||||
return ""
|
||||
if isinstance(tags, list) and tags:
|
||||
sample = str(tags[0].get("name", "")).strip()
|
||||
if sample:
|
||||
return (
|
||||
f" Tags exist (e.g. {sample}) but no release assets are "
|
||||
f"published — install requires built binaries."
|
||||
payload = resp.json()
|
||||
except requests.RequestException as exc:
|
||||
raise NetworkError(f"Unable to fetch open pull requests: {exc}") from exc
|
||||
except ValueError as exc:
|
||||
raise NetworkError("GitHub API returned invalid JSON for PR list.") from exc
|
||||
|
||||
if not isinstance(payload, list):
|
||||
raise NetworkError("Unexpected PR list payload from GitHub.")
|
||||
|
||||
results: list[PullRequestBuild] = []
|
||||
for pr in payload:
|
||||
if not isinstance(pr, dict):
|
||||
continue
|
||||
number = int(pr.get("number", 0) or 0)
|
||||
if not number:
|
||||
continue
|
||||
head = pr.get("head") or {}
|
||||
head_sha = str(head.get("sha", "") or "")
|
||||
user = pr.get("user") or {}
|
||||
artifact = self._fetch_pr_windows_artifact(number)
|
||||
results.append(
|
||||
PullRequestBuild(
|
||||
number=number,
|
||||
title=str(pr.get("title", "") or "Untitled PR"),
|
||||
author=str(user.get("login", "") or "unknown"),
|
||||
head_sha=head_sha,
|
||||
short_sha=head_sha[:7],
|
||||
pr_url=str(
|
||||
pr.get("html_url", "")
|
||||
or EMULATOR_PR_HTML_URL.format(number=number)
|
||||
),
|
||||
updated_at=str(pr.get("updated_at", "") or ""),
|
||||
status=artifact.get("status", PR_BUILD_STATUS_MISSING),
|
||||
artifact_label=artifact.get("label"),
|
||||
artifact_url=artifact.get("url"),
|
||||
run_url=artifact.get("run"),
|
||||
)
|
||||
return ""
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
def _fetch_pr_windows_artifact(self, pr_number: int) -> dict:
|
||||
try:
|
||||
resp = requests.get(
|
||||
EMULATOR_PR_COMMENTS_API.format(number=pr_number),
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
headers=GITHUB_API_HEADERS,
|
||||
params={"per_page": 100},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
comments = resp.json()
|
||||
except (requests.RequestException, ValueError):
|
||||
return {"status": PR_BUILD_STATUS_MISSING}
|
||||
|
||||
if not isinstance(comments, list):
|
||||
return {"status": PR_BUILD_STATUS_MISSING}
|
||||
|
||||
# Walk newest -> oldest so the freshest build comment wins, but fall
|
||||
# back to "building" if older comments only show in-progress state.
|
||||
building_seen = False
|
||||
for comment in reversed(comments):
|
||||
if not isinstance(comment, dict):
|
||||
continue
|
||||
body = str(comment.get("body", "") or "")
|
||||
if not PR_ARTIFACT_HEADER_RE.search(body):
|
||||
continue
|
||||
parsed = self._parse_pr_artifact_comment(body)
|
||||
if not parsed:
|
||||
continue
|
||||
if parsed["status"] == PR_BUILD_STATUS_READY:
|
||||
return parsed
|
||||
if parsed["status"] == PR_BUILD_STATUS_BUILDING:
|
||||
building_seen = True
|
||||
if building_seen:
|
||||
return {"status": PR_BUILD_STATUS_BUILDING}
|
||||
return {"status": PR_BUILD_STATUS_MISSING}
|
||||
|
||||
def _parse_pr_artifact_comment(self, body: str) -> Optional[dict]:
|
||||
match = PR_WINDOWS_ROW_RE.search(body)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
artifacts_cell = match.group("artifacts").strip()
|
||||
logs_cell = match.group("logs").strip()
|
||||
|
||||
if not artifacts_cell or "building" in artifacts_cell.lower():
|
||||
return {"status": PR_BUILD_STATUS_BUILDING}
|
||||
|
||||
links = PR_MD_LINK_RE.findall(artifacts_cell)
|
||||
chosen_label: Optional[str] = None
|
||||
chosen_url: Optional[str] = None
|
||||
|
||||
# Prefer Clangtron — the only Windows toolchain produced upstream.
|
||||
for label, url in links:
|
||||
if not self._is_acceptable_pr_artifact_url(url):
|
||||
continue
|
||||
blob = (label + " " + url).lower()
|
||||
if "clangtron" in blob:
|
||||
chosen_label, chosen_url = label, url
|
||||
break
|
||||
|
||||
# Fall back to any other Windows artifact that isn't a discontinued
|
||||
# MSVC/MinGW toolchain build.
|
||||
if not chosen_url:
|
||||
for label, url in links:
|
||||
if not self._is_acceptable_pr_artifact_url(url):
|
||||
continue
|
||||
blob = (label + " " + url).lower()
|
||||
if "msvc" in blob or "mingw" in blob:
|
||||
continue
|
||||
chosen_label, chosen_url = label, url
|
||||
break
|
||||
|
||||
if not chosen_url:
|
||||
# Build is up but only MSVC/MinGW artifacts are present, which we
|
||||
# treat as no usable Windows build.
|
||||
return {"status": PR_BUILD_STATUS_MISSING}
|
||||
|
||||
run_match = PR_MD_LINK_RE.search(logs_cell)
|
||||
run_url = run_match.group("url") if run_match else None
|
||||
|
||||
return {
|
||||
"status": PR_BUILD_STATUS_READY,
|
||||
"label": chosen_label or "Windows",
|
||||
"url": chosen_url,
|
||||
"run": run_url,
|
||||
}
|
||||
|
||||
def _is_acceptable_pr_artifact_url(self, url: str) -> bool:
|
||||
if not url.lower().endswith(".zip"):
|
||||
return False
|
||||
try:
|
||||
host = urlparse(url).hostname or ""
|
||||
except ValueError:
|
||||
return False
|
||||
return host.lower() == ALLOWED_PR_ARTIFACT_HOST
|
||||
|
||||
def pr_build_to_release_info(self, pr: PullRequestBuild) -> ReleaseInfo:
|
||||
if pr.status != PR_BUILD_STATUS_READY or not pr.artifact_url:
|
||||
raise UpdaterError(
|
||||
f"PR #{pr.number} does not have a ready Windows Clangtron build yet."
|
||||
)
|
||||
label_slug = re.sub(r"[^A-Za-z0-9._-]+", "-", pr.artifact_label or "Windows").strip("-") or "Windows"
|
||||
asset_name = f"Citron-PR-{pr.number}-{pr.short_sha or 'unknown'}-{label_slug}.zip"
|
||||
return ReleaseInfo(
|
||||
name=f"PR #{pr.number}: {pr.title}",
|
||||
tag_name=f"pr-{pr.number}-{pr.short_sha}" if pr.short_sha else f"pr-{pr.number}",
|
||||
published_at=pr.updated_at,
|
||||
release_id=pr.number,
|
||||
asset_name=asset_name,
|
||||
asset_url=pr.artifact_url,
|
||||
asset_size=0,
|
||||
asset_updated_at=pr.updated_at,
|
||||
channel=CHANNEL_PR,
|
||||
)
|
||||
|
||||
def _pick_windows_asset(self, assets: list[dict]) -> Optional[dict]:
|
||||
scored: list[tuple[int, dict]] = []
|
||||
@@ -286,14 +528,25 @@ class UpdaterService:
|
||||
if not name.endswith(".zip"):
|
||||
continue
|
||||
|
||||
# Hard reject discontinued toolchains. Upstream stopped producing
|
||||
# MSVC and MinGW Windows builds; only Clangtron (Clang LTO) is
|
||||
# shipped, so we never select these even if older releases still
|
||||
# have the artifacts attached.
|
||||
if "msvc" in name or "mingw" in name:
|
||||
continue
|
||||
|
||||
score = 0
|
||||
if "windows" in name or "win64" in name or "win-" in name:
|
||||
score += 6
|
||||
elif "win" in name:
|
||||
score += 3
|
||||
|
||||
if "msvc" in name:
|
||||
score += 8
|
||||
# Clangtron is the only Windows toolchain produced upstream now.
|
||||
if "clangtron" in name:
|
||||
score += 12
|
||||
elif "clang" in name:
|
||||
score += 6
|
||||
|
||||
if "x86_64" in name or "x64" in name or "amd64" in name:
|
||||
score += 3
|
||||
if "citron" in name:
|
||||
@@ -303,9 +556,6 @@ class UpdaterService:
|
||||
if "nightly" in name:
|
||||
score += 2
|
||||
|
||||
# Removed/undesired variants.
|
||||
if "mingw" in name or "clang" in name or "clangtron" in name:
|
||||
score -= 50
|
||||
if "debug" in name or "symbols" in name or "pdb" in name:
|
||||
score -= 4
|
||||
if "source" in name or name.endswith(("-src.zip", "_src.zip")):
|
||||
|
||||
Reference in New Issue
Block a user