Compare commits

..
4 Commits
Author SHA1 Message Date
yyyyyynnn da3c52a7de Restore MSI style and list sessions 2026-07-04 14:32:42 +08:00
yyyyyynnn 34ec59f28b Soften startup update notification 2026-07-04 14:03:52 +08:00
yyyyyynnn 9a82612d5d Fix release asset upload 2026-07-04 13:47:14 +08:00
yyyyyynnn 10c16cba7b Fix release workflow publishing 2026-07-04 13:35:51 +08:00
10 changed files with 493 additions and 36 deletions
+67 -19
View File
@@ -9,20 +9,37 @@ on:
permissions:
contents: write
defaults:
run:
shell: bash
jobs:
build:
name: Build ${{ matrix.platform }}
name: Build ${{ matrix.name }}
runs-on: ${{ matrix.platform }}
strategy:
fail-fast: false
matrix:
include:
- platform: macos-latest
- name: macOS
platform: macos-latest
args: --bundles dmg
- platform: ubuntu-22.04
artifact: codex-x-macos
paths: |
apps/desktop/src-tauri/target/release/bundle/dmg/*.dmg
- name: Linux
platform: ubuntu-22.04
args: --bundles deb,rpm
- platform: windows-latest
artifact: codex-x-linux
paths: |
apps/desktop/src-tauri/target/release/bundle/deb/*.deb
apps/desktop/src-tauri/target/release/bundle/rpm/*.rpm
- name: Windows
platform: windows-latest
args: --bundles msi
artifact: codex-x-windows
paths: |
apps/desktop/src-tauri/target/release/bundle/msi/*.msi
steps:
- name: Checkout
@@ -61,9 +78,35 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Tauri bundles
run: pnpm --dir apps/desktop tauri build ${{ matrix.args }}
- name: Upload bundle artifacts
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}
path: ${{ matrix.paths }}
if-no-files-found: error
retention-days: 7
release:
name: Publish GitHub Release
needs: build
runs-on: ubuntu-22.04
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download bundle artifacts
uses: actions/download-artifact@v4
with:
path: release-assets
merge-multiple: true
- name: Prepare release notes
id: release_notes
shell: bash
run: |
python3 - <<'PY'
from pathlib import Path
@@ -80,21 +123,26 @@ jobs:
body = match.group(1).strip() if match else ""
if not body:
body = "Codex-X desktop release."
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as fh:
fh.write("body<<EOF\n")
with open("release-body.md", "w", encoding="utf-8") as fh:
fh.write(body + "\n")
fh.write("EOF\n")
PY
- name: Build and upload Tauri bundles
uses: tauri-apps/tauri-action@v0
- name: Publish release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
projectPath: apps/desktop
tagName: ${{ github.ref_name }}
releaseName: Codex-X ${{ github.ref_name }}
releaseBody: ${{ steps.release_notes.outputs.body }}
releaseDraft: false
prerelease: false
args: ${{ matrix.args }}
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
tag="${GITHUB_REF_NAME}"
title="Codex-X ${tag}"
if gh release view "$tag" >/dev/null 2>&1; then
gh release edit "$tag" --title "$title" --notes-file release-body.md --latest
else
gh release create "$tag" --title "$title" --notes-file release-body.md --latest
fi
mapfile -t assets < <(find release-assets -type f \( -name '*.dmg' -o -name '*.msi' -o -name '*.deb' -o -name '*.rpm' \) -print | sort)
if [ "${#assets[@]}" -eq 0 ]; then
echo "No release assets found" >&2
exit 1
fi
printf 'Uploading %s\n' "${assets[@]}"
gh release upload "$tag" "${assets[@]}" --clobber
+24
View File
@@ -2,6 +2,30 @@
All notable changes to Codex-X will be documented here.
## [v0.2.12] - 2026-07-04
- Windows MSI 安装器回退为默认简洁样式,移除上一版过重的自定义安装界面图。
- 【会话管理】页面新增会话列表,展示标题、Provider、模型、工作目录、更新时间、归档/需同步状态。
- 会话扫描会从 Codex 本地 SQLite threads 表读取最近会话,方便用户直接判断哪些历史 thread 需要同步修复。
## [v0.2.11] - 2026-07-04
- 调整首次启动自动检查更新体验:不再弹出居中的强提醒窗口。
- 自动检测到新版本时仅显示轻量 toast,并保留概览页顶部“发现新版本”提示条。
- 只有用户在【关于】页面主动点击“检查更新”时,检测到新版本才弹出“现在下载 / 稍后”窗口。
## [v0.2.10] - 2026-07-04
- 修复 Release 发布任务上传 Linux 产物时误把 `deb/`、`rpm/` 目录当作资产上传的问题。
- 发布任务现在只收集 `.dmg` / `.msi` / `.deb` / `.rpm` 文件并统一上传。
- 补齐稳定的三平台 Release 自动发布流程。
## [v0.2.9] - 2026-07-04
- 修复 GitHub Actions Release 发布流程:不再由三平台矩阵并发创建 Release,改为先上传构建产物,再由单独发布任务统一创建/更新 Release。
- 修复 `Resource not accessible by integration` 导致 Release 创建失败的问题。
- Release 仍会从 `CHANGELOG.md` 自动读取当前 tag 的更新日志,并上传 macOS / Windows / Linux 安装包。
## [v0.2.8] - 2026-07-04
- 更新页进一步产品化:去掉资源/仓库调试信息,将“有更新”改为更明显的绿色标签。
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "codex-x",
"version": "0.2.8",
"version": "0.2.12",
"private": true,
"description": "Codex Switch & Instruct desktop manager",
"type": "module",
+1 -1
View File
@@ -337,7 +337,7 @@ dependencies = [
[[package]]
name = "codex-x"
version = "0.2.8"
version = "0.2.12"
dependencies = [
"chrono",
"dirs 5.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "codex-x"
version = "0.2.8"
version = "0.2.12"
description = "Codex Switch & Instruct desktop manager"
authors = ["yynxxxxx"]
license = "MIT"
+145
View File
@@ -192,6 +192,21 @@ struct AboutInfo {
github_repo: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct SessionPreview {
id: String,
title: String,
model_provider: Option<String>,
model: Option<String>,
cwd: Option<String>,
rollout_path: Option<String>,
updated_at_ms: Option<i64>,
archived: bool,
has_user_event: bool,
needs_sync: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct SessionSyncStatus {
@@ -207,6 +222,7 @@ struct SessionSyncStatus {
needs_sync: bool,
backup_dir: Option<String>,
warnings: Vec<String>,
sessions: Vec<SessionPreview>,
}
#[derive(Debug, Clone, Serialize)]
@@ -238,6 +254,14 @@ struct SqliteScan {
warnings: Vec<String>,
}
fn sql_select_column(cols: &HashSet<String>, name: &str, fallback: &str) -> String {
if cols.contains(name) {
format!("\"{}\"", name.replace('"', "\"\""))
} else {
fallback.to_string()
}
}
fn io_err(path: &Path, source: std::io::Error) -> CodexxError {
CodexxError::Io {
path: path.display().to_string(),
@@ -1361,6 +1385,123 @@ fn scan_sqlite(codex_dir: &Path, target_provider: &str) -> Result<SqliteScan> {
Ok(scan)
}
fn list_session_previews(
codex_dir: &Path,
target_provider: &str,
limit: usize,
) -> Result<(Vec<SessionPreview>, Vec<String>)> {
let mut sessions = Vec::new();
let mut warnings = Vec::new();
let mut seen = HashSet::new();
for path in sqlite_candidate_paths(codex_dir) {
let conn = match Connection::open_with_flags(
&path,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
) {
Ok(conn) => conn,
Err(e) => {
warnings.push(format!("无法读取会话数据库: {} ({e})", path.display()));
continue;
}
};
if !sqlite_has_table(&conn, "threads")? {
continue;
}
let cols = table_column_set(&conn, "threads")?;
if !cols.contains("id") {
continue;
}
let title_col = sql_select_column(&cols, "title", "NULL");
let first_message_col = sql_select_column(&cols, "first_user_message", "NULL");
let preview_col = sql_select_column(&cols, "preview", "NULL");
let provider_col = sql_select_column(&cols, "model_provider", "NULL");
let model_col = sql_select_column(&cols, "model", "NULL");
let cwd_col = sql_select_column(&cols, "cwd", "NULL");
let rollout_col = sql_select_column(&cols, "rollout_path", "NULL");
let updated_ms_col = sql_select_column(&cols, "updated_at_ms", "NULL");
let updated_col = sql_select_column(&cols, "updated_at", "NULL");
let archived_col = sql_select_column(&cols, "archived", "0");
let has_user_event_col = sql_select_column(&cols, "has_user_event", "0");
let order_col = if cols.contains("recency_at_ms") {
"\"recency_at_ms\""
} else if cols.contains("updated_at_ms") {
"\"updated_at_ms\""
} else if cols.contains("updated_at") {
"\"updated_at\""
} else {
"\"id\""
};
let query = format!(
"SELECT \"id\", {title_col}, {first_message_col}, {preview_col}, {provider_col}, {model_col}, {cwd_col}, {rollout_col}, {updated_ms_col}, {updated_col}, {archived_col}, {has_user_event_col} FROM threads ORDER BY {order_col} DESC LIMIT {}",
limit.max(1)
);
let mut stmt = conn
.prepare(&query)
.map_err(|e| CodexxError::Database(e.to_string()))?;
let rows = stmt
.query_map([], |row| {
let id: String = row.get(0)?;
let title: Option<String> = row.get(1)?;
let first_message: Option<String> = row.get(2)?;
let preview: Option<String> = row.get(3)?;
let model_provider: Option<String> = row.get(4)?;
let model: Option<String> = row.get(5)?;
let cwd: Option<String> = row.get(6)?;
let rollout_path: Option<String> = row.get(7)?;
let updated_at_ms: Option<i64> = row.get(8)?;
let updated_at: Option<i64> = row.get(9)?;
let archived: i64 = row.get(10)?;
let has_user_event: i64 = row.get(11)?;
let clean_title = [title, first_message, preview]
.into_iter()
.flatten()
.map(|v| v.trim().to_string())
.find(|v| !v.is_empty())
.unwrap_or_else(|| format!("会话 {}", id.chars().take(8).collect::<String>()));
let normalized_provider = model_provider
.as_ref()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty());
Ok(SessionPreview {
id,
title: clean_title,
model_provider: normalized_provider.clone(),
model: model.and_then(|v| {
let v = v.trim().to_string();
(!v.is_empty()).then_some(v)
}),
cwd: cwd.and_then(|v| {
let v = v.trim().to_string();
(!v.is_empty()).then_some(v)
}),
rollout_path: rollout_path.and_then(|v| {
let v = v.trim().to_string();
(!v.is_empty()).then_some(v)
}),
updated_at_ms: updated_at_ms.or_else(|| updated_at.map(|v| v * 1000)),
archived: archived != 0,
has_user_event: has_user_event != 0,
needs_sync: normalized_provider.as_deref() != Some(target_provider),
})
})
.map_err(|e| CodexxError::Database(e.to_string()))?;
for row in rows {
let session = row.map_err(|e| CodexxError::Database(e.to_string()))?;
if seen.insert(session.id.clone()) {
sessions.push(session);
}
}
}
sessions.sort_by(|a, b| b.updated_at_ms.cmp(&a.updated_at_ms));
sessions.truncate(limit);
Ok((sessions, warnings))
}
fn provider_sync_backup_root(codex_dir: &Path) -> PathBuf {
codex_dir.join("backups_state").join("provider-sync")
}
@@ -1512,8 +1653,11 @@ fn session_sync_status_inner(
let target = current_model_provider(&codex_dir, target_provider)?;
let rollouts = scan_rollouts(&codex_dir, &target, false)?;
let sqlite = scan_sqlite(&codex_dir, &target)?;
let session_limit = sqlite.sqlite_threads.max(50).min(1000);
let (sessions, session_warnings) = list_session_previews(&codex_dir, &target, session_limit)?;
let mut warnings = rollouts.warnings;
warnings.extend(sqlite.warnings);
warnings.extend(session_warnings);
Ok(SessionSyncStatus {
codex_dir: codex_dir.display().to_string(),
target_provider: target,
@@ -1527,6 +1671,7 @@ fn session_sync_status_inner(
needs_sync: rollouts.mismatched_session_meta > 0 || sqlite.mismatched_threads > 0,
backup_dir: None,
warnings,
sessions,
})
}
+2 -8
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Codex-X",
"version": "0.2.8",
"version": "0.2.12",
"identifier": "com.yynxxxxx.codexx",
"build": {
"frontendDist": "../dist",
@@ -35,12 +35,6 @@
"icons/icon.icns",
"icons/icon.ico"
],
"targets": "all",
"windows": {
"wix": {
"bannerPath": "icons/wix/banner.bmp",
"dialogImagePath": "icons/wix/dialog.bmp"
}
}
"targets": "all"
}
}
+98 -5
View File
@@ -145,6 +145,20 @@ type SessionSyncStatus = {
needsSync: boolean;
backupDir?: string | null;
warnings: string[];
sessions: SessionPreview[];
};
type SessionPreview = {
id: string;
title: string;
modelProvider?: string | null;
model?: string | null;
cwd?: string | null;
rolloutPath?: string | null;
updatedAtMs?: number | null;
archived: boolean;
hasUserEvent: boolean;
needsSync: boolean;
};
type SessionSyncResult = {
@@ -673,6 +687,34 @@ function releaseAssetForPlatform(assets: Array<{ name?: string; browser_download
}) || assets[0];
}
function formatSessionTime(value?: number | null) {
if (!value) return "未知时间";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "未知时间";
return date.toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
function compactPath(value?: string | null, max = 58) {
if (!value) return "未记录路径";
const normalized = value.replace(/\\/g, "/");
if (normalized.length <= max) return normalized;
const parts = normalized.split("/").filter(Boolean);
if (parts.length >= 3) {
const tail = parts.slice(-3).join("/");
return `…/${tail}`;
}
return `…${normalized.slice(-max + 1)}`;
}
function shortId(value: string) {
return value.length > 8 ? value.slice(0, 8) : value;
}
function App() {
const initialLang = (localStorage.getItem(LANG_KEY) as Lang | null) || "zh";
const [lang, setLang] = React.useState<Lang>(initialLang === "en" ? "en" : "zh");
@@ -1019,6 +1061,9 @@ function App() {
const latestVersion = release.tag_name || release.name || "";
const asset = releaseAssetForPlatform(release.assets || []);
const hasUpdate = compareVersions(latestVersion, appVersion) > 0;
const message = hasUpdate
? (lang === "zh" ? "发现新版本" : "Update available")
: (lang === "zh" ? "当前已是最新版本" : "You are up to date");
setReleaseInfo({
status: "ok",
latestVersion,
@@ -1026,16 +1071,24 @@ function App() {
assetName: asset?.name,
body: release.body || "",
hasUpdate,
message: hasUpdate
? (lang === "zh" ? "发现新版本" : "Update available")
: (lang === "zh" ? "当前已是最新版本" : "You are up to date"),
message,
});
if (hasUpdate) setUpdatePromptOpen(true);
if (hasUpdate) {
if (quiet) {
setToast(lang === "zh" ? `发现新版本 ${latestVersion},可在概览页查看` : `New version ${latestVersion} is available`);
} else {
setUpdatePromptOpen(true);
}
} else if (!quiet) {
setToast(message);
}
} catch (e) {
const message = quiet ? (lang === "zh" ? "自动检查失败" : "Auto check failed") : (lang === "zh" ? "检查失败" : "Check failed");
setReleaseInfo({
status: "error",
message: quiet ? (lang === "zh" ? "自动检查失败" : "Auto check failed") : (lang === "zh" ? "检查失败" : "Check failed"),
message,
});
if (!quiet) setToast(message);
}
}, [aboutInfo?.githubRepo, aboutInfo?.appVersion, lang]);
@@ -1511,6 +1564,46 @@ function App() {
<div><span>{lang === "zh" ? "备份位置" : "Backup"}</span><code>{sessionStatus?.backupDir || `${state.codexDir}/backups_state/provider-sync`}</code></div>
</div>
<div className="session-list-card">
<div className="session-list-head">
<div>
<p className="eyebrow">{lang === "zh" ? "本地会话" : "Local threads"}</p>
<h4>{lang === "zh" ? "会话列表" : "Sessions"}</h4>
</div>
<span>{lang === "zh" ? `展示 ${sessionStatus?.sessions?.length ?? 0} / ${sessionStatus?.sqliteThreads ?? 0} 条` : `${sessionStatus?.sessions?.length ?? 0} / ${sessionStatus?.sqliteThreads ?? 0} shown`}</span>
</div>
{sessionStatus?.sessions?.length ? (
<div className="session-list">
{sessionStatus.sessions.map((item) => (
<article className={cx("session-row", item.needsSync && "needs-sync")} key={item.id}>
<div className="session-row-main">
<div className="session-thread-icon"><History size={18} /></div>
<div className="session-row-text">
<div className="session-row-title">
<strong>{item.title}</strong>
{item.archived && <span className="mini-tag">{lang === "zh" ? "已归档" : "Archived"}</span>}
{item.needsSync && <span className="mini-tag warn">{lang === "zh" ? "需同步" : "Needs sync"}</span>}
</div>
<p title={item.cwd || item.rolloutPath || undefined}>{compactPath(item.cwd || item.rolloutPath)}</p>
</div>
</div>
<div className="session-row-meta">
<span>{formatSessionTime(item.updatedAtMs)}</span>
<code>{item.modelProvider || "unknown"}</code>
<em>{item.model || "model -"}</em>
<small>#{shortId(item.id)}</small>
</div>
</article>
))}
</div>
) : (
<div className="session-empty">
<History size={22} />
<span>{lang === "zh" ? "还没有读取到会话。点击右上角“检查会话”刷新。" : "No sessions loaded. Click Check to refresh."}</span>
</div>
)}
</div>
{sessionStatus?.warnings?.length ? (
<div className="session-warning-list">
{sessionStatus.warnings.map((item, index) => <p key={index}><AlertCircle size={15} /> {item}</p>)}
+153
View File
@@ -1513,6 +1513,151 @@ html, body, #root {
font-size: 13px;
font-weight: 850;
}
.session-list-card {
padding: 16px;
border-radius: 24px;
background: rgba(4,10,24,.34);
border: 1px solid rgba(255,255,255,.08);
box-shadow: inset 0 1px 0 rgba(255,255,255,.04);
}
.session-list-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
margin-bottom: 12px;
}
.session-list-head h4 {
margin: 2px 0 0;
color: #f4f8ff;
font-size: 17px;
letter-spacing: -.02em;
}
.session-list-head span {
color: #8fa0bd;
font-size: 13px;
font-weight: 800;
padding: 7px 10px;
border-radius: 999px;
background: rgba(255,255,255,.06);
border: 1px solid rgba(255,255,255,.08);
}
.session-list {
display: grid;
gap: 10px;
max-height: 350px;
overflow: auto;
padding-right: 4px;
}
.session-list::-webkit-scrollbar {
width: 8px;
}
.session-list::-webkit-scrollbar-thumb {
border-radius: 999px;
background: rgba(124,168,255,.30);
}
.session-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 16px;
align-items: center;
padding: 13px 14px;
border-radius: 18px;
background: linear-gradient(135deg, rgba(255,255,255,.075), rgba(255,255,255,.035));
border: 1px solid rgba(255,255,255,.08);
}
.session-row.needs-sync {
border-color: rgba(255,176,32,.28);
background: linear-gradient(135deg, rgba(255,176,32,.10), rgba(255,255,255,.035));
}
.session-row-main {
min-width: 0;
display: grid;
grid-template-columns: 42px minmax(0, 1fr);
gap: 12px;
align-items: center;
}
.session-thread-icon {
width: 42px;
height: 42px;
border-radius: 14px;
display: grid;
place-items: center;
color: #2ff5b5;
background: rgba(31,242,178,.12);
border: 1px solid rgba(31,242,178,.18);
}
.session-row-text {
min-width: 0;
}
.session-row-title {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.session-row-title strong {
color: #eef5ff;
font-size: 14px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.session-row-text p {
margin: 5px 0 0;
color: #8fa0bd;
font-size: 12px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.session-row-meta {
display: grid;
grid-template-columns: 92px 92px 92px 72px;
gap: 8px;
align-items: center;
justify-items: end;
color: #8fa0bd;
font-size: 12px;
}
.session-row-meta code,
.session-row-meta em,
.session-row-meta small {
max-width: 92px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-style: normal;
}
.session-row-meta code {
color: #b9c8e6;
font-family: "JetBrains Mono", "Fira Code", "SFMono-Regular", "SF Mono", Menlo, Monaco, Consolas, monospace;
}
.mini-tag {
flex: 0 0 auto;
color: #9fb0cc;
font-size: 11px;
font-weight: 850;
padding: 3px 7px;
border-radius: 999px;
background: rgba(255,255,255,.07);
border: 1px solid rgba(255,255,255,.09);
}
.mini-tag.warn {
color: #ffd9a1;
background: rgba(255,176,32,.10);
border-color: rgba(255,176,32,.22);
}
.session-empty {
min-height: 86px;
display: grid;
place-items: center;
gap: 8px;
color: #8fa0bd;
border-radius: 18px;
border: 1px dashed rgba(255,255,255,.12);
background: rgba(255,255,255,.025);
}
.session-warning-list {
display: grid;
gap: 8px;
@@ -1541,4 +1686,12 @@ html, body, #root {
grid-column: 2;
justify-self: start;
}
.session-row {
grid-template-columns: 1fr;
}
.session-row-meta {
justify-items: start;
grid-template-columns: repeat(4, minmax(0, 1fr));
padding-left: 54px;
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "codex-x-workspace",
"private": true,
"version": "0.2.8",
"version": "0.2.12",
"scripts": {
"dev": "pnpm --dir apps/desktop tauri dev",
"build": "pnpm --dir apps/desktop tauri build",