mirror of
https://github.com/yynxxxxx/Codex-X.git
synced 2026-10-18 05:59:23 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94ab211431 | ||
|
|
e8b0e5b73c |
@@ -460,6 +460,8 @@ jobs:
|
||||
--manifest latest.json \
|
||||
--assets release-assets.json \
|
||||
--version "$VERSION" \
|
||||
--repository "$GITHUB_REPOSITORY" \
|
||||
--release-tag "$RELEASE_TAG" \
|
||||
--rewrite-download-urls \
|
||||
--require-signature-assets
|
||||
gh release upload "$RELEASE_TAG" latest.json --clobber
|
||||
@@ -498,7 +500,9 @@ jobs:
|
||||
python3 scripts/validate_updater_release.py \
|
||||
--manifest published-latest.json \
|
||||
--assets release-assets.json \
|
||||
--version "$VERSION"
|
||||
--version "$VERSION" \
|
||||
--repository "$GITHUB_REPOSITORY" \
|
||||
--release-tag "$RELEASE_TAG"
|
||||
|
||||
python3 - <<'PY' > updater-urls.txt
|
||||
import json
|
||||
@@ -507,9 +511,11 @@ jobs:
|
||||
print(entry["url"])
|
||||
PY
|
||||
sort -u updater-urls.txt | while read -r url; do
|
||||
curl -fsSLI \
|
||||
curl -fsSL \
|
||||
--range 0-0 \
|
||||
--retry 5 \
|
||||
--retry-delay 3 \
|
||||
--retry-all-errors \
|
||||
"$url" >/dev/null
|
||||
"$url" \
|
||||
-o /dev/null
|
||||
done
|
||||
|
||||
@@ -2,6 +2,17 @@
|
||||
|
||||
All notable changes to Codex-X will be documented here.
|
||||
|
||||
## [v0.3.2] - 2026-07-25
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复 Windows Codex 冷启动或进入新版首页、外观设置页时,皮肤运行时因只识别旧版主壳层和侧栏而错误提示“未找到经过验证的 Codex 渲染器”的问题;首次连接现在会等待页面渐进加载,并兼容渲染文档切换。
|
||||
- “关闭皮肤”保持为随时可用的异常恢复入口,不再因运行状态刷新延迟、皮肤部分失效或其他操作卡住而无法点击;恢复期间仍会阻止重复提交和其他皮肤写操作。
|
||||
|
||||
### 安全
|
||||
|
||||
- 新版 Windows 页面检测继续要求官方进程、回环 CDP、`app://` 协议以及 Codex 壳层、首页或外观设置页的明确锚点,不会向普通网页或无标记辅助页面注入皮肤。
|
||||
|
||||
## [v0.3.1] - 2026-07-24
|
||||
|
||||
### 更新
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codex-x",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"private": true,
|
||||
"description": "Codex Switch & Instruct desktop manager",
|
||||
"type": "module",
|
||||
|
||||
Generated
+1
-1
@@ -346,7 +346,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codex-x"
|
||||
version = "0.3.1"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "codex-x"
|
||||
version = "0.3.1"
|
||||
version = "0.3.2"
|
||||
description = "Codex Switch & Instruct desktop manager"
|
||||
authors = ["yynxxxxx"]
|
||||
license = "MIT"
|
||||
|
||||
@@ -8,10 +8,18 @@ import { readImageMetadata } from "./image-metadata.mjs";
|
||||
const scriptPath = fileURLToPath(import.meta.url);
|
||||
const here = path.dirname(scriptPath);
|
||||
const root = path.resolve(here, "..");
|
||||
const SKIN_VERSION = "1.2.3";
|
||||
const SKIN_VERSION = "1.2.4";
|
||||
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]"]);
|
||||
const CDP_ID_PATTERN = /^[A-Za-z0-9._-]{1,200}$/;
|
||||
const MAX_ART_BYTES = 16 * 1024 * 1024;
|
||||
const CODEX_SURFACE_SELECTORS = Object.freeze({
|
||||
shell: "main.main-surface",
|
||||
sidebar: "aside.app-shell-left-panel",
|
||||
composer: ".composer-surface-chrome",
|
||||
home: '[role="main"]:has([data-testid="home-icon"])',
|
||||
appearance: 'input[name="appearance-theme"]',
|
||||
themePreview: '[data-testid="theme-preview"]',
|
||||
});
|
||||
let staticPayloadAssets = null;
|
||||
|
||||
function parseArgs(argv) {
|
||||
@@ -203,29 +211,36 @@ async function listAppTargets(port) {
|
||||
}
|
||||
}
|
||||
|
||||
async function probeSession(session) {
|
||||
export async function probeSession(session) {
|
||||
return session.evaluate(`(() => {
|
||||
const markers = {
|
||||
shell: Boolean(document.querySelector('main.main-surface')),
|
||||
sidebar: Boolean(document.querySelector('aside.app-shell-left-panel')),
|
||||
composer: Boolean(document.querySelector('.composer-surface-chrome')),
|
||||
main: Boolean(document.querySelector('[role="main"]')),
|
||||
shell: Boolean(document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.shell)})),
|
||||
sidebar: Boolean(document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.sidebar)})),
|
||||
composer: Boolean(document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.composer)})),
|
||||
main: Boolean(document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.home)})),
|
||||
settings: Boolean(document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.appearance)})) ||
|
||||
Boolean(document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.themePreview)})),
|
||||
};
|
||||
return {
|
||||
title: document.title,
|
||||
href: location.href,
|
||||
markers,
|
||||
codex: markers.shell && markers.sidebar,
|
||||
codex: location.protocol === 'app:' &&
|
||||
((markers.shell && markers.sidebar) || markers.main || markers.settings),
|
||||
};
|
||||
})()`);
|
||||
}
|
||||
|
||||
async function waitForCodexProbe(session, timeoutMs = 1800) {
|
||||
export async function waitForCodexProbe(session, timeoutMs = 1800) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let probe = null;
|
||||
while (Date.now() < deadline) {
|
||||
probe = await probeSession(session);
|
||||
if (probe?.codex) return probe;
|
||||
try {
|
||||
probe = await probeSession(session);
|
||||
if (probe?.codex) return probe;
|
||||
} catch {
|
||||
// Chromium can briefly replace the execution context while Codex boots.
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
return probe;
|
||||
@@ -246,7 +261,8 @@ async function connectCodexTargets(port, timeoutMs) {
|
||||
let session;
|
||||
try {
|
||||
session = await connectTarget(target, port);
|
||||
const probe = await probeSession(session);
|
||||
const remainingMs = Math.max(50, deadline - Date.now());
|
||||
const probe = await waitForCodexProbe(session, Math.min(1800, remainingMs));
|
||||
if (probe?.codex) connected.push({ target, session, probe });
|
||||
else session.close();
|
||||
} catch (error) {
|
||||
@@ -255,7 +271,7 @@ async function connectCodexTargets(port, timeoutMs) {
|
||||
}
|
||||
}
|
||||
if (connected.length) return connected;
|
||||
lastError = new Error("No page matched the expected Codex shell markers");
|
||||
lastError = new Error("No page matched the expected Codex surface markers");
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
@@ -517,7 +533,7 @@ async function verifyRemovedSession(session) {
|
||||
)()`);
|
||||
}
|
||||
|
||||
async function verifySession(session) {
|
||||
export async function verifySession(session) {
|
||||
return session.evaluate(`(() => {
|
||||
const box = (node) => {
|
||||
if (!node) return null;
|
||||
@@ -533,6 +549,8 @@ async function verifySession(session) {
|
||||
const homeSignal = homeIndicator ?? document.querySelector('[data-feature="game-source"]') ??
|
||||
document.querySelector('.group\\\\/home-suggestions');
|
||||
const homeRoute = homeSignal?.closest('[role="main"]') ?? null;
|
||||
const settingsAnchor = document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.appearance)}) ||
|
||||
document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.themePreview)});
|
||||
const home = document.querySelector('[role="main"].dream-skin-home');
|
||||
const suggestions = home?.querySelector('.group\\\\/home-suggestions') ?? null;
|
||||
const cardBoxes = suggestions ? [...suggestions.querySelectorAll('button')].map(box) : [];
|
||||
@@ -550,7 +568,10 @@ async function verifySession(session) {
|
||||
chromePresent: Boolean(chrome),
|
||||
chromePointerEvents: getComputedStyle(chrome || document.body).pointerEvents,
|
||||
homeRoute: Boolean(homeRoute),
|
||||
homeRouteBox: box(homeRoute),
|
||||
homePresent: Boolean(home),
|
||||
settingsRoute: Boolean(settingsAnchor),
|
||||
settingsAnchor: box(settingsAnchor),
|
||||
hero,
|
||||
cards: cardBoxes,
|
||||
visibleCardCount: visibleCards.length,
|
||||
@@ -564,9 +585,13 @@ async function verifySession(session) {
|
||||
y: document.documentElement.scrollHeight > document.documentElement.clientHeight,
|
||||
},
|
||||
};
|
||||
const standardSurface = Boolean(result.shell?.visible && result.sidebar?.visible);
|
||||
const routeSurface = Boolean(result.homeRouteBox?.visible || result.settingsAnchor?.visible);
|
||||
const chromePass = result.settingsRoute ||
|
||||
(result.chromePresent && result.chromePointerEvents === 'none');
|
||||
const basePass = result.installed && result.version === ${JSON.stringify(SKIN_VERSION)} &&
|
||||
result.stylePresent && result.chromePresent && result.chromePointerEvents === 'none' &&
|
||||
Boolean(result.shell?.visible) && Boolean(result.sidebar?.visible) && !result.documentOverflow.x;
|
||||
result.stylePresent && chromePass && (standardSurface || routeSurface) &&
|
||||
!result.documentOverflow.x;
|
||||
// Project selector markup varies across Codex builds — soft requirement.
|
||||
const homePass = !result.homeRoute || (
|
||||
result.homePresent && result.hero?.visible && result.hero.width >= 280 && result.hero.height >= 120
|
||||
@@ -688,9 +713,13 @@ export function earlyPayloadFor(payload, revision) {
|
||||
const install = () => {
|
||||
if (window[generationKey] !== generation) { stop(); return true; }
|
||||
if (!document.documentElement) return false;
|
||||
const shell = document.querySelector('main.main-surface');
|
||||
const sidebar = document.querySelector('aside.app-shell-left-panel');
|
||||
if (!shell || !sidebar) return false;
|
||||
if (location.protocol !== "app:") return false;
|
||||
const shell = document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.shell)});
|
||||
const sidebar = document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.sidebar)});
|
||||
const home = document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.home)});
|
||||
const settings = document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.appearance)}) ||
|
||||
document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.themePreview)});
|
||||
if (!(shell && sidebar) && !home && !settings) return false;
|
||||
stop();
|
||||
${payload};
|
||||
window[appliedKey] = generation;
|
||||
|
||||
@@ -3,24 +3,33 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import vm from "node:vm";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { earlyPayloadFor } from "../scripts/injector.mjs";
|
||||
import {
|
||||
earlyPayloadFor,
|
||||
probeSession,
|
||||
verifySession,
|
||||
waitForCodexProbe,
|
||||
} from "../scripts/injector.mjs";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const injectorPath = path.resolve(here, "../scripts/injector.mjs");
|
||||
const source = await fs.readFile(injectorPath, "utf8");
|
||||
|
||||
function createFixture() {
|
||||
function createFixture(protocol = "app:") {
|
||||
const observers = [];
|
||||
const timers = new Map();
|
||||
let nextTimer = 1;
|
||||
const markers = { shell: false, sidebar: false };
|
||||
const markers = { shell: false, sidebar: false, home: false, settings: false, themePreview: false };
|
||||
const context = {
|
||||
window: { installs: [] },
|
||||
location: { protocol },
|
||||
document: {
|
||||
documentElement: {},
|
||||
querySelector(selector) {
|
||||
if (selector === "main.main-surface") return markers.shell ? {} : null;
|
||||
if (selector === "aside.app-shell-left-panel") return markers.sidebar ? {} : null;
|
||||
if (selector === '[role="main"]:has([data-testid="home-icon"])') return markers.home ? {} : null;
|
||||
if (selector === 'input[name="appearance-theme"]') return markers.settings ? {} : null;
|
||||
if (selector === '[data-testid="theme-preview"]') return markers.themePreview ? {} : null;
|
||||
return null;
|
||||
},
|
||||
},
|
||||
@@ -50,6 +59,26 @@ guarded.markers.shell = true;
|
||||
guarded.observers[0].callback([]);
|
||||
assert.deepEqual(guarded.context.window.installs, [], "A main surface without the Codex sidebar is not sufficient.");
|
||||
|
||||
const home = createFixture();
|
||||
home.markers.home = true;
|
||||
vm.runInNewContext(earlyPayloadFor('window.installs.push("home")', "home"), home.context);
|
||||
assert.deepEqual(home.context.window.installs, ["home"], "The explicit Codex home route must support early injection.");
|
||||
|
||||
const settings = createFixture();
|
||||
settings.markers.settings = true;
|
||||
vm.runInNewContext(earlyPayloadFor('window.installs.push("settings")', "settings"), settings.context);
|
||||
assert.deepEqual(
|
||||
settings.context.window.installs,
|
||||
["settings"],
|
||||
"The Codex appearance route must support early injection.",
|
||||
);
|
||||
|
||||
const web = createFixture("https:");
|
||||
web.markers.shell = true;
|
||||
web.markers.sidebar = true;
|
||||
vm.runInNewContext(earlyPayloadFor('window.installs.push("web")', "web"), web.context);
|
||||
assert.deepEqual(web.context.window.installs, [], "Web pages must remain untouched even with spoofed shell markers.");
|
||||
|
||||
const generations = createFixture();
|
||||
vm.runInNewContext(earlyPayloadFor('window.installs.push("old")', "old"), generations.context);
|
||||
vm.runInNewContext(earlyPayloadFor('window.installs.push("new")', "new"), generations.context);
|
||||
@@ -63,9 +92,123 @@ assert.deepEqual(
|
||||
);
|
||||
assert.equal(generations.context.window.__CODEX_DREAM_SKIN_EARLY_APPLIED__, "new");
|
||||
|
||||
function createProbeSession(frames) {
|
||||
let callCount = 0;
|
||||
return {
|
||||
get callCount() { return callCount; },
|
||||
async evaluate(expression) {
|
||||
const frame = frames[Math.min(callCount, frames.length - 1)];
|
||||
callCount += 1;
|
||||
if (frame instanceof Error) throw frame;
|
||||
const marker = (key) => frame[key] ? {} : null;
|
||||
return vm.runInNewContext(expression, {
|
||||
location: { protocol: frame.protocol ?? "app:", href: `${frame.protocol ?? "app:"}//codex` },
|
||||
document: {
|
||||
title: "Codex",
|
||||
querySelector(selector) {
|
||||
if (selector === "main.main-surface") return marker("shell");
|
||||
if (selector === "aside.app-shell-left-panel") return marker("sidebar");
|
||||
if (selector === ".composer-surface-chrome") return marker("composer");
|
||||
if (selector === '[role="main"]:has([data-testid="home-icon"])') return marker("home");
|
||||
if (selector === 'input[name="appearance-theme"]') return marker("settings");
|
||||
if (selector === '[data-testid="theme-preview"]') return marker("themePreview");
|
||||
return null;
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
assert.equal((await probeSession(createProbeSession([{ shell: true, sidebar: true }]))).codex, true);
|
||||
assert.equal((await probeSession(createProbeSession([{ home: true }]))).codex, true);
|
||||
assert.equal((await probeSession(createProbeSession([{ settings: true }]))).codex, true);
|
||||
assert.equal((await probeSession(createProbeSession([{}]))).codex, false, "An unmarked app page must be rejected.");
|
||||
assert.equal(
|
||||
(await probeSession(createProbeSession([{ protocol: "https:", shell: true, sidebar: true }]))).codex,
|
||||
false,
|
||||
"A non-app page must be rejected even when it spoofs the standard shell.",
|
||||
);
|
||||
|
||||
const delayed = createProbeSession([
|
||||
new Error("Execution context was destroyed"),
|
||||
{},
|
||||
{ home: true },
|
||||
]);
|
||||
assert.equal((await waitForCodexProbe(delayed, 300))?.codex, true);
|
||||
assert.equal(delayed.callCount, 3, "Probe waiting must survive document replacement and delayed DOM markers.");
|
||||
|
||||
function createVerificationSession(route) {
|
||||
const node = (width = 640, height = 480) => ({
|
||||
getBoundingClientRect: () => ({ x: 0, y: 0, width, height }),
|
||||
querySelector: () => null,
|
||||
});
|
||||
const body = node();
|
||||
const homeHero = node(560, 240);
|
||||
const home = node();
|
||||
home.firstElementChild = { firstElementChild: { firstElementChild: homeHero } };
|
||||
const homeIndicator = { closest: () => home };
|
||||
const settingsAnchor = node(320, 48);
|
||||
const chrome = node();
|
||||
return {
|
||||
async evaluate(expression) {
|
||||
return vm.runInNewContext(expression, {
|
||||
innerWidth: 1280,
|
||||
innerHeight: 800,
|
||||
window: { __CODEX_DREAM_SKIN_STATE__: { version: "1.2.4" } },
|
||||
getComputedStyle(target) {
|
||||
return { display: "block", visibility: "visible", pointerEvents: target === chrome ? "none" : "auto" };
|
||||
},
|
||||
document: {
|
||||
body,
|
||||
documentElement: {
|
||||
classList: { contains: () => true },
|
||||
scrollWidth: 1280,
|
||||
clientWidth: 1280,
|
||||
scrollHeight: 800,
|
||||
clientHeight: 800,
|
||||
},
|
||||
getElementById(id) {
|
||||
if (id === "codex-dream-skin-style") return {};
|
||||
if (id === "codex-dream-skin-chrome" && route === "home") return chrome;
|
||||
return null;
|
||||
},
|
||||
querySelector(selector) {
|
||||
if (selector === '[data-testid="home-icon"]' && route === "home") return homeIndicator;
|
||||
if (selector === '[role="main"].dream-skin-home' && route === "home") return home;
|
||||
if (selector === 'input[name="appearance-theme"]' && route === "settings") return settingsAnchor;
|
||||
return null;
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
(await verifySession(createVerificationSession("home"))).pass,
|
||||
true,
|
||||
"A verified Codex home route must not require the legacy shell/sidebar pair.",
|
||||
);
|
||||
assert.equal(
|
||||
(await verifySession(createVerificationSession("settings"))).pass,
|
||||
true,
|
||||
"A verified appearance route must accept the installed root theme before the main shell mounts.",
|
||||
);
|
||||
assert.equal(
|
||||
(await verifySession(createVerificationSession("auxiliary"))).pass,
|
||||
false,
|
||||
"An auxiliary app page must not pass post-install verification.",
|
||||
);
|
||||
|
||||
const discoveryStart = source.indexOf("record.earlyScriptId = await registerEarly");
|
||||
const probeStart = source.indexOf("const probe = await waitForCodexProbe", discoveryStart);
|
||||
assert.ok(discoveryStart >= 0 && probeStart > discoveryStart, "Early registration must happen before full shell probing.");
|
||||
assert.match(
|
||||
source,
|
||||
/connectCodexTargets[\s\S]*const probe = await waitForCodexProbe/,
|
||||
"One-shot discovery must wait for a progressively loaded Codex renderer.",
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/finally\s*\{[\s\S]*Promise\.all\(\[\.\.\.sessions\.values\(\)\][\s\S]*removeEarly\(record\)/,
|
||||
@@ -82,4 +225,4 @@ assert.match(
|
||||
"One-shot commands must exit after flushing output so a lingering CDP close handshake cannot block Codex-X.",
|
||||
);
|
||||
|
||||
console.log("PASS: early injection is shell-guarded, generation-safe, and removed on shutdown.");
|
||||
console.log("PASS: renderer probing is route-aware, delayed-load safe, generation-safe, and guarded.");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Codex-X",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"identifier": "com.yynxxxxx.codexx",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
@@ -43,9 +43,11 @@ export function useSkinCenter({
|
||||
}: UseSkinCenterOptions) {
|
||||
const [state, setState] = React.useState<SkinCenterState | null>(null);
|
||||
const [restartRequest, setRestartRequest] = React.useState<SkinRestartRequest | null>(null);
|
||||
const [pauseBusy, setPauseBusy] = React.useState(false);
|
||||
const zipInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const imageInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const loadedRef = React.useRef(false);
|
||||
const pauseInFlightRef = React.useRef(false);
|
||||
|
||||
const load = React.useCallback(async ({
|
||||
quiet = false,
|
||||
@@ -193,7 +195,9 @@ export function useSkinCenter({
|
||||
};
|
||||
|
||||
const pause = async () => {
|
||||
setActionBusy("pauseSkin");
|
||||
if (pauseInFlightRef.current) return;
|
||||
pauseInFlightRef.current = true;
|
||||
setPauseBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await invoke<SkinActionResult>("pause_skin_theme");
|
||||
@@ -203,7 +207,8 @@ export function useSkinCenter({
|
||||
setError(String(error));
|
||||
void load({ quiet: true });
|
||||
} finally {
|
||||
setActionBusy("");
|
||||
pauseInFlightRef.current = false;
|
||||
setPauseBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -236,6 +241,7 @@ export function useSkinCenter({
|
||||
return {
|
||||
state,
|
||||
restartRequest,
|
||||
pauseBusy,
|
||||
zipInputRef,
|
||||
imageInputRef,
|
||||
refresh,
|
||||
|
||||
@@ -799,6 +799,7 @@ function App() {
|
||||
const {
|
||||
state: skinCenterState,
|
||||
restartRequest: skinRestartRequest,
|
||||
pauseBusy: skinPauseBusy,
|
||||
zipInputRef: skinZipImportRef,
|
||||
imageInputRef: skinImageInputRef,
|
||||
refresh: refreshSkinCenter,
|
||||
@@ -2238,6 +2239,7 @@ function App() {
|
||||
lang={lang}
|
||||
state={skinCenterState}
|
||||
actionBusy={actionBusy}
|
||||
pauseBusy={skinPauseBusy}
|
||||
zipInputRef={skinZipImportRef}
|
||||
imageInputRef={skinImageInputRef}
|
||||
onLoad={refreshSkinCenter}
|
||||
|
||||
@@ -46,6 +46,7 @@ export type SkinsPageProps = {
|
||||
lang: Lang;
|
||||
state: SkinCenterState | null;
|
||||
actionBusy: string;
|
||||
pauseBusy: boolean;
|
||||
zipInputRef: RefObject<HTMLInputElement>;
|
||||
imageInputRef: RefObject<HTMLInputElement>;
|
||||
onLoad: MaybeAsyncAction;
|
||||
@@ -226,6 +227,7 @@ export function SkinsPage({
|
||||
lang,
|
||||
state,
|
||||
actionBusy,
|
||||
pauseBusy,
|
||||
zipInputRef,
|
||||
imageInputRef,
|
||||
onLoad,
|
||||
@@ -240,6 +242,7 @@ export function SkinsPage({
|
||||
const themes = state?.themes ?? [];
|
||||
const current = themes.find((theme) => theme.enabled);
|
||||
const loading = actionBusy === "loadSkins";
|
||||
const controlsBusy = Boolean(actionBusy) || pauseBusy;
|
||||
const runtime = state?.runtime;
|
||||
const [categoryManagerOpen, setCategoryManagerOpen] = useState(false);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
@@ -327,7 +330,7 @@ export function SkinsPage({
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setEditorOpen(true)}
|
||||
disabled={Boolean(actionBusy)}
|
||||
disabled={controlsBusy}
|
||||
icon={<Pencil size={15} />}
|
||||
>
|
||||
{copy.edit}
|
||||
@@ -335,7 +338,7 @@ export function SkinsPage({
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => run(() => onEnableTheme(selectedTheme.id))}
|
||||
disabled={Boolean(actionBusy) || selectedThemeApplied}
|
||||
disabled={controlsBusy || selectedThemeApplied}
|
||||
icon={actionBusy === `skin:${selectedTheme.id}`
|
||||
? <Loader2 className="cx-skins-spin" size={15} />
|
||||
: selectedThemeApplied ? <Check size={15} /> : <Play size={15} />}
|
||||
@@ -347,7 +350,7 @@ export function SkinsPage({
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => run(() => onExportTheme(selectedTheme.id))}
|
||||
disabled={Boolean(actionBusy)}
|
||||
disabled={controlsBusy}
|
||||
icon={actionBusy === `skinExport:${selectedTheme.id}`
|
||||
? <Loader2 className="cx-skins-spin" size={15} />
|
||||
: <Download size={15} />}
|
||||
@@ -359,15 +362,15 @@ export function SkinsPage({
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => run(onPauseTheme)}
|
||||
disabled={Boolean(actionBusy) || !runtime?.supported || !runtime.active}
|
||||
icon={actionBusy === "pauseSkin" ? <Loader2 className="cx-skins-spin" size={15} /> : <Power size={15} />}
|
||||
disabled={pauseBusy}
|
||||
icon={pauseBusy ? <Loader2 className="cx-skins-spin" size={15} /> : <Power size={15} />}
|
||||
>
|
||||
{actionBusy === "pauseSkin" ? copy.pausing : copy.pause}
|
||||
{pauseBusy ? copy.pausing : copy.pause}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => zipInputRef.current?.click()}
|
||||
disabled={Boolean(actionBusy) || !state}
|
||||
disabled={controlsBusy || !state}
|
||||
icon={actionBusy === "importSkinZip" ? <Loader2 className="cx-skins-spin" size={15} /> : <Upload size={15} />}
|
||||
>
|
||||
{copy.importZip}
|
||||
@@ -375,7 +378,7 @@ export function SkinsPage({
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => imageInputRef.current?.click()}
|
||||
disabled={Boolean(actionBusy) || !state}
|
||||
disabled={controlsBusy || !state}
|
||||
icon={actionBusy === "createSkinFromImage"
|
||||
? <Loader2 className="cx-skins-spin" size={15} />
|
||||
: <ImagePlus size={15} />}
|
||||
@@ -401,7 +404,7 @@ export function SkinsPage({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => run(onLoad)}
|
||||
disabled={Boolean(actionBusy)}
|
||||
disabled={controlsBusy}
|
||||
/>
|
||||
</section>
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "codex-x-workspace",
|
||||
"private": true,
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"scripts": {
|
||||
"dev": "pnpm --dir apps/desktop tauri dev",
|
||||
"build": "pnpm --dir apps/desktop tauri build",
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import base64
|
||||
import json
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
VALIDATOR = ROOT / "scripts" / "validate_updater_release.py"
|
||||
REPOSITORY = "example/Codex-X"
|
||||
RELEASE_TAG = "v0.3.1"
|
||||
VERSION = "0.3.1"
|
||||
PLATFORM_ASSETS = {
|
||||
"darwin-aarch64": "Codex-X.app.tar.gz",
|
||||
"darwin-aarch64-app": "Codex-X.app.tar.gz",
|
||||
"darwin-x86_64": "Codex-X-intel.app.tar.gz",
|
||||
"darwin-x86_64-app": "Codex-X-intel.app.tar.gz",
|
||||
"windows-x86_64": "Codex-X.msi",
|
||||
"windows-x86_64-msi": "Codex-X.msi",
|
||||
"linux-x86_64": "Codex-X.AppImage",
|
||||
"linux-x86_64-deb": "Codex-X.deb",
|
||||
"linux-x86_64-rpm": "Codex-X.rpm",
|
||||
"linux-x86_64-appimage": "Codex-X.AppImage",
|
||||
}
|
||||
|
||||
|
||||
def draft_url(asset_name: str) -> str:
|
||||
return (
|
||||
"https://github.com/example/Codex-X/releases/download/"
|
||||
f"untagged-test/{asset_name}"
|
||||
)
|
||||
|
||||
|
||||
class ValidateUpdaterReleaseTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp_dir.cleanup)
|
||||
self.root = Path(self.temp_dir.name)
|
||||
self.manifest_path = self.root / "latest.json"
|
||||
self.assets_path = self.root / "assets.json"
|
||||
|
||||
signature = base64.b64encode(b"s" * 64).decode("ascii")
|
||||
manifest = {
|
||||
"version": VERSION,
|
||||
"platforms": {
|
||||
platform: {
|
||||
"signature": signature,
|
||||
"url": draft_url(asset_name),
|
||||
}
|
||||
for platform, asset_name in PLATFORM_ASSETS.items()
|
||||
},
|
||||
}
|
||||
asset_names = sorted(set(PLATFORM_ASSETS.values()))
|
||||
assets = [
|
||||
{
|
||||
"name": asset_name,
|
||||
"url": f"https://api.github.com/assets/{index}",
|
||||
"browser_download_url": draft_url(asset_name),
|
||||
}
|
||||
for index, asset_name in enumerate(asset_names, start=1)
|
||||
]
|
||||
assets.append(
|
||||
{
|
||||
"name": "latest.json",
|
||||
"url": "https://api.github.com/assets/latest",
|
||||
"browser_download_url": draft_url("latest.json"),
|
||||
}
|
||||
)
|
||||
self.manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||
self.assets_path.write_text(json.dumps(assets), encoding="utf-8")
|
||||
|
||||
def run_validator(self, *extra_args: str) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[
|
||||
"python3",
|
||||
str(VALIDATOR),
|
||||
"--manifest",
|
||||
str(self.manifest_path),
|
||||
"--assets",
|
||||
str(self.assets_path),
|
||||
"--version",
|
||||
VERSION,
|
||||
"--repository",
|
||||
REPOSITORY,
|
||||
"--release-tag",
|
||||
RELEASE_TAG,
|
||||
*extra_args,
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
def test_rewrites_draft_urls_to_stable_release_tag(self) -> None:
|
||||
result = self.run_validator("--rewrite-download-urls")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
manifest = json.loads(self.manifest_path.read_text(encoding="utf-8"))
|
||||
urls = {entry["url"] for entry in manifest["platforms"].values()}
|
||||
self.assertTrue(urls)
|
||||
self.assertTrue(
|
||||
all("/releases/download/v0.3.1/" in url for url in urls),
|
||||
urls,
|
||||
)
|
||||
self.assertTrue(all("untagged-" not in url for url in urls), urls)
|
||||
|
||||
def test_rejects_draft_urls_without_rewrite(self) -> None:
|
||||
result = self.run_validator()
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("stable release tag download URL", result.stderr)
|
||||
|
||||
def test_repairs_stale_urls_after_release_is_published(self) -> None:
|
||||
assets = json.loads(self.assets_path.read_text(encoding="utf-8"))
|
||||
for asset in assets:
|
||||
asset["browser_download_url"] = (
|
||||
f"https://github.com/{REPOSITORY}/releases/download/"
|
||||
f"{RELEASE_TAG}/{asset['name']}"
|
||||
)
|
||||
self.assets_path.write_text(json.dumps(assets), encoding="utf-8")
|
||||
|
||||
result = self.run_validator("--rewrite-download-urls")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
manifest = json.loads(self.manifest_path.read_text(encoding="utf-8"))
|
||||
self.assertTrue(
|
||||
all(
|
||||
"/releases/download/v0.3.1/" in entry["url"]
|
||||
for entry in manifest["platforms"].values()
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -9,6 +9,7 @@ import binascii
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import quote, unquote, urlparse
|
||||
|
||||
|
||||
REQUIRED_PLATFORMS = {
|
||||
@@ -48,10 +49,20 @@ def validate_signature(platform: str, value: Any) -> None:
|
||||
fail(f"{platform} signature is unexpectedly short")
|
||||
|
||||
|
||||
def canonical_asset_url(repository: str, release_tag: str, asset_name: str) -> str:
|
||||
return (
|
||||
f"https://github.com/{repository}/releases/download/"
|
||||
f"{quote(release_tag, safe='')}/{quote(asset_name, safe='')}"
|
||||
)
|
||||
|
||||
|
||||
def rewrite_download_urls(
|
||||
manifest_path: Path,
|
||||
manifest: dict[str, Any],
|
||||
assets_by_url: dict[str, dict[str, Any]],
|
||||
assets_by_name: dict[str, dict[str, Any]],
|
||||
repository: str,
|
||||
release_tag: str,
|
||||
) -> int:
|
||||
platforms = manifest.get("platforms")
|
||||
if not isinstance(platforms, dict):
|
||||
@@ -65,11 +76,14 @@ def rewrite_download_urls(
|
||||
if not isinstance(current_url, str):
|
||||
fail(f"{platform} has an invalid download URL")
|
||||
asset = assets_by_url.get(current_url)
|
||||
if asset is None:
|
||||
parsed_url = urlparse(current_url)
|
||||
asset_name = unquote(parsed_url.path.rsplit("/", 1)[-1])
|
||||
if parsed_url.scheme == "https" and parsed_url.netloc == "github.com":
|
||||
asset = assets_by_name.get(asset_name)
|
||||
if asset is None:
|
||||
fail(f"{platform} URL does not point to an asset in this release")
|
||||
download_url = asset.get("browser_download_url")
|
||||
if not isinstance(download_url, str) or not download_url.startswith("https://"):
|
||||
fail(f"{platform} asset has no public download URL")
|
||||
download_url = canonical_asset_url(repository, release_tag, asset["name"])
|
||||
if current_url != download_url:
|
||||
entry["url"] = download_url
|
||||
rewritten += 1
|
||||
@@ -86,10 +100,16 @@ def main() -> None:
|
||||
parser.add_argument("--manifest", type=Path, required=True)
|
||||
parser.add_argument("--assets", type=Path, required=True)
|
||||
parser.add_argument("--version", required=True)
|
||||
parser.add_argument("--repository", required=True)
|
||||
parser.add_argument("--release-tag", required=True)
|
||||
parser.add_argument("--rewrite-download-urls", action="store_true")
|
||||
parser.add_argument("--require-signature-assets", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
repository_parts = args.repository.split("/")
|
||||
if len(repository_parts) != 2 or not all(repository_parts):
|
||||
fail(f"invalid GitHub repository {args.repository!r}")
|
||||
|
||||
manifest = load_json(args.manifest)
|
||||
assets = load_json(args.assets)
|
||||
if not isinstance(manifest, dict):
|
||||
@@ -102,15 +122,20 @@ def main() -> None:
|
||||
)
|
||||
|
||||
asset_names: set[str] = set()
|
||||
assets_by_name: dict[str, dict[str, Any]] = {}
|
||||
assets_by_url: dict[str, dict[str, Any]] = {}
|
||||
for asset in assets:
|
||||
if not isinstance(asset, dict) or not isinstance(asset.get("name"), str):
|
||||
fail("release assets response contains an invalid entry")
|
||||
asset_names.add(asset["name"])
|
||||
assets_by_name[asset["name"]] = asset
|
||||
for field in ("url", "browser_download_url"):
|
||||
value = asset.get(field)
|
||||
if isinstance(value, str) and value:
|
||||
assets_by_url[value] = asset
|
||||
assets_by_url[
|
||||
canonical_asset_url(args.repository, args.release_tag, asset["name"])
|
||||
] = asset
|
||||
|
||||
if "latest.json" not in asset_names:
|
||||
fail("the draft release does not contain latest.json")
|
||||
@@ -120,7 +145,14 @@ def main() -> None:
|
||||
fail("latest.json has no platforms object")
|
||||
|
||||
if args.rewrite_download_urls:
|
||||
rewritten = rewrite_download_urls(args.manifest, manifest, assets_by_url)
|
||||
rewritten = rewrite_download_urls(
|
||||
args.manifest,
|
||||
manifest,
|
||||
assets_by_url,
|
||||
assets_by_name,
|
||||
args.repository,
|
||||
args.release_tag,
|
||||
)
|
||||
print(f"Rewrote {rewritten} updater asset URLs to public download URLs.")
|
||||
|
||||
for platform, entry in platforms.items():
|
||||
@@ -133,8 +165,13 @@ def main() -> None:
|
||||
asset = assets_by_url.get(url)
|
||||
if asset is None:
|
||||
fail(f"{platform} URL does not point to an asset in this release")
|
||||
if url != asset.get("browser_download_url"):
|
||||
fail(f"{platform} URL points to GitHub metadata instead of the asset download")
|
||||
expected_url = canonical_asset_url(
|
||||
args.repository,
|
||||
args.release_tag,
|
||||
asset["name"],
|
||||
)
|
||||
if url != expected_url:
|
||||
fail(f"{platform} URL does not use the stable release tag download URL")
|
||||
|
||||
for platform, suffix in REQUIRED_PLATFORMS.items():
|
||||
entry = platforms.get(platform)
|
||||
|
||||
Reference in New Issue
Block a user