Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 485ed50b26 | |||
| 1655a2eab4 | |||
| df39d5a16f | |||
| 914791d63c | |||
| 5574f41129 | |||
| cd98e0603e | |||
| 4bc0f04d74 | |||
| 329dd749eb | |||
| 2a2e4c1cea | |||
| 12c1e5f3f8 | |||
| b178431a57 | |||
| 65b7ae0e2b | |||
| 4be95f50ae | |||
| f19a003dec | |||
| 8cffbf6112 | |||
| eed72ba39a | |||
| aa3e29f04e | |||
| 04db93faab | |||
| 55259683b8 | |||
| 5b4f9e961d | |||
| 98e42e6dae | |||
| 973e965f4f | |||
| f2a3ba7aca | |||
| 7b1bc1d3e8 | |||
| ae0ab0ddf4 | |||
| fca111239c | |||
| 573f9f447a | |||
| 9a6875b4ce | |||
| 99fb6c38c1 | |||
| 5b73082ad0 | |||
| d354e5e7c3 | |||
| 62bffebf8e | |||
| dbbbef8a0d | |||
| 88090fcc6e | |||
| a0d432b63d | |||
| d4de81c128 | |||
| c0154c2aed | |||
| c4b391cb84 | |||
| bd0f9f481a | |||
| 6fb17de9db | |||
| 57017ee046 | |||
| bdf6d0f556 | |||
| bb1df3c6da | |||
| 1e17d2c852 | |||
| b1cadd0cc8 | |||
| 261228ed3f | |||
| 8c17e40d7d | |||
| 237e440057 | |||
| fc46cabb75 | |||
| 762f037150 | |||
| 48985d3447 | |||
| ca80cb28d2 |
@@ -4,3 +4,5 @@
|
|||||||
src/components/AdventurePanel.tsx
|
src/components/AdventurePanel.tsx
|
||||||
src/data/customWorldCharacterLoadout.ts
|
src/data/customWorldCharacterLoadout.ts
|
||||||
dist_check_monster_position/**
|
dist_check_monster_position/**
|
||||||
|
# 固定上游UTF-8测试刻意包含U+FFFD;upstream-integrity.test.mjs逐字节验证来源hash。
|
||||||
|
apps/ai-game-creator-shell/src-tauri/vendor/codex-utils-path-uri/src/api_path_string_tests.rs
|
||||||
|
|||||||
@@ -47,12 +47,16 @@ temp*build*/
|
|||||||
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/codex-package.json
|
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/codex-package.json
|
||||||
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/manifest.json
|
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/manifest.json
|
||||||
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/NOTICE.md
|
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/NOTICE.md
|
||||||
|
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/darwin-arm64/
|
||||||
|
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/darwin-x64/
|
||||||
/plugins/agc-cocos-editor/native/payload/
|
/plugins/agc-cocos-editor/native/payload/
|
||||||
/plugins/agc-unity-editor/dotnet/**/bin/
|
/plugins/agc-unity-editor/dotnet/**/bin/
|
||||||
/plugins/agc-unity-editor/dotnet/**/obj/
|
/plugins/agc-unity-editor/dotnet/**/obj/
|
||||||
/plugins/agc-unity-editor/dotnet/publish/
|
/plugins/agc-unity-editor/dotnet/publish/
|
||||||
/plugins/agc-unity-editor/dotnet/native-build/
|
/plugins/agc-unity-editor/dotnet/native-build/
|
||||||
/apps/ai-game-creator-shell/logs/
|
/apps/ai-game-creator-shell/logs/
|
||||||
|
/apps/ai-game-creator-shell/src-tauri/resources/node-runtime/
|
||||||
|
/apps/ai-game-creator-shell/src-tauri/resources/node-runtime-staging-*/
|
||||||
/apps/ai-game-creator-shell/.llm-drafts/
|
/apps/ai-game-creator-shell/.llm-drafts/
|
||||||
/apps/ai-game-creator-shell/game-creator.config.local.json
|
/apps/ai-game-creator-shell/game-creator.config.local.json
|
||||||
/apps/mobile-shell/.expo/
|
/apps/mobile-shell/.expo/
|
||||||
|
|||||||
@@ -144,6 +144,8 @@ export interface AdminErrorReportListResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminErrorReportDetail extends AdminErrorReportEntry {
|
export interface AdminErrorReportDetail extends AdminErrorReportEntry {
|
||||||
|
firstFingerprint?: string;
|
||||||
|
firstSource?: string;
|
||||||
note?: string;
|
note?: string;
|
||||||
events: Array<Record<string, unknown>>;
|
events: Array<Record<string, unknown>>;
|
||||||
logNames: string[];
|
logNames: string[];
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
formatAdminTimestamp,
|
||||||
|
parseAdminTimestamp,
|
||||||
|
} from './AdminErrorReportsPage';
|
||||||
|
|
||||||
|
describe('错误报告时间格式化', () => {
|
||||||
|
it('解析 SpacetimeDB seconds.microsZ 时间', () => {
|
||||||
|
expect(parseAdminTimestamp('1778207451.731746Z')).toBe(1778207451731);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('解析微秒字符串并拒绝无效时间', () => {
|
||||||
|
expect(parseAdminTimestamp('1778207451731746')).toBe(1778207451731);
|
||||||
|
expect(formatAdminTimestamp('not-a-date')).toBe('-');
|
||||||
|
expect(formatAdminTimestamp('999999999999999999999999')).toBe('-');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,6 +17,57 @@ type Props = { token: string; onUnauthorized: (message?: string) => void };
|
|||||||
|
|
||||||
const ADMIN_ERROR_REPORT_STATUSES = ['new', 'in-progress', 'resolved'] as const;
|
const ADMIN_ERROR_REPORT_STATUSES = ['new', 'in-progress', 'resolved'] as const;
|
||||||
const ERROR_REPORT_PAGE_SIZE = 50;
|
const ERROR_REPORT_PAGE_SIZE = 50;
|
||||||
|
const ERROR_REPORT_STATUS_LABELS: Record<string, string> = {
|
||||||
|
new: '待处理',
|
||||||
|
'in-progress': '处理中',
|
||||||
|
resolved: '已解决',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseAdminTimestamp(value: string | null | undefined) {
|
||||||
|
const normalized = value?.trim() ?? '';
|
||||||
|
if (/^-?\d+\.\d{6}Z$/u.test(normalized)) {
|
||||||
|
const [secondsText, microsText] = normalized.slice(0, -1).split('.');
|
||||||
|
const seconds = Number(secondsText);
|
||||||
|
const micros = Number(microsText);
|
||||||
|
return Number.isFinite(seconds) && Number.isFinite(micros)
|
||||||
|
? seconds * 1000 + Math.floor(micros / 1000)
|
||||||
|
: Number.NaN;
|
||||||
|
}
|
||||||
|
if (/^-?\d+$/u.test(normalized)) {
|
||||||
|
const numeric = Number(normalized);
|
||||||
|
if (!Number.isFinite(numeric)) return Number.NaN;
|
||||||
|
if (Math.abs(numeric) >= 1e14) return Math.floor(numeric / 1000);
|
||||||
|
if (Math.abs(numeric) >= 1e11) return numeric;
|
||||||
|
return numeric * 1000;
|
||||||
|
}
|
||||||
|
return Date.parse(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatAdminTimestamp(value: string | null | undefined) {
|
||||||
|
const timestamp = parseAdminTimestamp(value);
|
||||||
|
if (!Number.isFinite(timestamp)) return '-';
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
if (!Number.isFinite(date.getTime())) return '-';
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
}).format(date);
|
||||||
|
} catch {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventText(event: Record<string, unknown>, key: string) {
|
||||||
|
const value = event[key];
|
||||||
|
return typeof value === 'string' || typeof value === 'number'
|
||||||
|
? String(value)
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
|
export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
|
||||||
const [reports, setReports] = useState<AdminErrorReportEntry[]>([]);
|
const [reports, setReports] = useState<AdminErrorReportEntry[]>([]);
|
||||||
@@ -29,6 +80,24 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
|
|||||||
const [pageInfo, setPageInfo] = useState({ total: 0, hasMore: false });
|
const [pageInfo, setPageInfo] = useState({ total: 0, hasMore: false });
|
||||||
const openReportRequestId = useRef(0);
|
const openReportRequestId = useRef(0);
|
||||||
const loadRequestId = useRef(0);
|
const loadRequestId = useRef(0);
|
||||||
|
const detailCloseButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selected) return;
|
||||||
|
const previousFocus = document.activeElement as HTMLElement | null;
|
||||||
|
const focusFrame = window.requestAnimationFrame(() => {
|
||||||
|
detailCloseButtonRef.current?.focus();
|
||||||
|
});
|
||||||
|
const handleEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') setSelected(null);
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleEscape);
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(focusFrame);
|
||||||
|
window.removeEventListener('keydown', handleEscape);
|
||||||
|
previousFocus?.focus?.();
|
||||||
|
};
|
||||||
|
}, [selected]);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
const requestId = ++loadRequestId.current;
|
const requestId = ++loadRequestId.current;
|
||||||
@@ -184,9 +253,15 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
|
|||||||
<td>{report.batchId}</td>
|
<td>{report.batchId}</td>
|
||||||
<td>{report.eventCount}</td>
|
<td>{report.eventCount}</td>
|
||||||
<td>{report.source ?? '-'}</td>
|
<td>{report.source ?? '-'}</td>
|
||||||
<td>{report.status}</td>
|
<td>
|
||||||
|
<span
|
||||||
|
className={`admin-error-report-status is-${report.status}`}
|
||||||
|
>
|
||||||
|
{ERROR_REPORT_STATUS_LABELS[report.status] ?? report.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
<td>{report.userId}</td>
|
<td>{report.userId}</td>
|
||||||
<td>{new Date(report.createdAt).toLocaleString()}</td>
|
<td>{formatAdminTimestamp(report.createdAt)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -224,66 +299,173 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
|
|||||||
className="admin-detail-modal"
|
className="admin-detail-modal"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-label="错误报告详情"
|
aria-label="错误报告详情"
|
||||||
|
aria-modal="true"
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
if (event.target === event.currentTarget) setSelected(null);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="admin-detail-modal__panel">
|
<div className="admin-detail-modal__panel admin-error-report-detail">
|
||||||
<header>
|
<header className="admin-error-report-detail__header">
|
||||||
<h2>{selected.batchId}</h2>
|
<div className="admin-error-report-detail__heading">
|
||||||
<button type="button" onClick={() => setSelected(null)}>
|
<span className="admin-error-report-detail__eyebrow">
|
||||||
关闭
|
错误报告详情
|
||||||
|
</span>
|
||||||
|
<h2>{selected.batchId}</h2>
|
||||||
|
<div className="admin-error-report-detail__badges">
|
||||||
|
<span
|
||||||
|
className={`admin-error-report-status is-${selected.status}`}
|
||||||
|
>
|
||||||
|
{ERROR_REPORT_STATUS_LABELS[selected.status] ??
|
||||||
|
selected.status}
|
||||||
|
</span>
|
||||||
|
<span className="admin-error-report-detail__badge">
|
||||||
|
{selected.firstSource ?? selected.source ?? '未知来源'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-error-report-detail__close"
|
||||||
|
ref={detailCloseButtonRef}
|
||||||
|
onClick={() => setSelected(null)}
|
||||||
|
aria-label="关闭错误报告详情"
|
||||||
|
>
|
||||||
|
×
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<p>
|
<div className="admin-error-report-detail__summary">
|
||||||
用户:{selected.userId} · 事件:{selected.eventCount} · 日志:
|
<div>
|
||||||
{selected.logCount}
|
<span>用户</span>
|
||||||
</p>
|
<strong>{selected.userId}</strong>
|
||||||
<pre>{JSON.stringify(selected.events.slice(0, 20), null, 2)}</pre>
|
</div>
|
||||||
{selected.userDescription ? (
|
<div>
|
||||||
<p>用户描述:{selected.userDescription}</p>
|
<span>发生时间</span>
|
||||||
) : null}
|
<strong>{formatAdminTimestamp(selected.createdAt)}</strong>
|
||||||
<label className="admin-detail-modal__note">
|
</div>
|
||||||
处理备注
|
<div>
|
||||||
<textarea
|
<span>错误事件</span>
|
||||||
value={selected.note ?? ''}
|
<strong>{selected.eventCount}</strong>
|
||||||
onChange={(event) =>
|
</div>
|
||||||
setSelected((current) =>
|
<div>
|
||||||
current
|
<span>应用日志</span>
|
||||||
? { ...current, note: event.target.value }
|
<strong>{selected.logCount}</strong>
|
||||||
: current,
|
</div>
|
||||||
)
|
|
||||||
}
|
|
||||||
maxLength={2000}
|
|
||||||
rows={4}
|
|
||||||
placeholder="记录处理结论或后续跟进事项"
|
|
||||||
disabled={busy}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<div className="admin-detail-modal__actions">
|
|
||||||
<select
|
|
||||||
value={selected.status}
|
|
||||||
onChange={(event) => void saveStatus(event.target.value)}
|
|
||||||
disabled={busy}
|
|
||||||
>
|
|
||||||
{ADMIN_ERROR_REPORT_STATUSES.map((value) => (
|
|
||||||
<option key={value} value={value}>
|
|
||||||
{value}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void saveStatus(selected.status)}
|
|
||||||
disabled={busy}
|
|
||||||
>
|
|
||||||
保存备注
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void download(selected.batchId)}
|
|
||||||
disabled={downloading}
|
|
||||||
>
|
|
||||||
{downloading ? '下载中…' : '下载诊断包'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="admin-error-report-detail__body">
|
||||||
|
<section className="admin-error-report-detail__section">
|
||||||
|
<div className="admin-error-report-detail__section-heading">
|
||||||
|
<div>
|
||||||
|
<h3>错误事件</h3>
|
||||||
|
<span>展示最近 20 条,完整内容请下载诊断包。</span>
|
||||||
|
</div>
|
||||||
|
<span className="admin-error-report-detail__count">
|
||||||
|
{selected.events.length} 条
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="admin-error-report-event-list">
|
||||||
|
{selected.events.length === 0 ? (
|
||||||
|
<p className="admin-error-report-detail__empty">
|
||||||
|
归档中没有可展示的错误事件。
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{selected.events.slice(0, 20).map((event, index) => {
|
||||||
|
const message = eventText(event, 'message') || '未知错误';
|
||||||
|
const stack = eventText(event, 'stack');
|
||||||
|
const occurredAt = eventText(event, 'occurredAt');
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className="admin-error-report-event"
|
||||||
|
key={`${index}-${message}`}
|
||||||
|
>
|
||||||
|
<div className="admin-error-report-event__topline">
|
||||||
|
<span className="admin-error-report-event__index">
|
||||||
|
#{index + 1}
|
||||||
|
</span>
|
||||||
|
<span>{eventText(event, 'source') || 'client'}</span>
|
||||||
|
<span>{eventText(event, 'count') || '1'} 次</span>
|
||||||
|
<time>{formatAdminTimestamp(occurredAt)}</time>
|
||||||
|
</div>
|
||||||
|
<p className="admin-error-report-event__message">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
{eventText(event, 'fingerprint') ? (
|
||||||
|
<code>{eventText(event, 'fingerprint')}</code>
|
||||||
|
) : null}
|
||||||
|
{stack ? (
|
||||||
|
<details>
|
||||||
|
<summary>查看调用栈</summary>
|
||||||
|
<pre>{stack}</pre>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{selected.userDescription ? (
|
||||||
|
<section className="admin-error-report-detail__section">
|
||||||
|
<div className="admin-error-report-detail__section-heading">
|
||||||
|
<div>
|
||||||
|
<h3>用户描述</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="admin-error-report-detail__description">
|
||||||
|
{selected.userDescription}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
<section className="admin-error-report-detail__section admin-error-report-detail__section--note">
|
||||||
|
<label className="admin-detail-modal__note">
|
||||||
|
<span>处理备注</span>
|
||||||
|
<textarea
|
||||||
|
value={selected.note ?? ''}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSelected((current) =>
|
||||||
|
current
|
||||||
|
? { ...current, note: event.target.value }
|
||||||
|
: current,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
maxLength={2000}
|
||||||
|
rows={4}
|
||||||
|
placeholder="记录处理结论或后续跟进事项"
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<footer className="admin-error-report-detail__footer">
|
||||||
|
<label className="admin-error-report-detail__status-field">
|
||||||
|
状态
|
||||||
|
<select
|
||||||
|
value={selected.status}
|
||||||
|
onChange={(event) => void saveStatus(event.target.value)}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{ADMIN_ERROR_REPORT_STATUSES.map((value) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{ERROR_REPORT_STATUS_LABELS[value]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="admin-error-report-detail__footer-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void saveStatus(selected.status)}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{busy ? '保存中…' : '保存备注'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void download(selected.batchId)}
|
||||||
|
disabled={downloading}
|
||||||
|
>
|
||||||
|
{downloading ? '下载中…' : '下载诊断包'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -3213,6 +3213,377 @@ button:disabled {
|
|||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-error-report-detail {
|
||||||
|
width: min(980px, 100%);
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #eadfd6;
|
||||||
|
background: #fffdfb;
|
||||||
|
box-shadow: 0 24px 80px rgb(57 31 18 / 22%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error-report-detail__header {
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 24px 28px 20px;
|
||||||
|
border-bottom: 1px solid #eee2d8;
|
||||||
|
background: linear-gradient(135deg, #fffaf5, #fffdfb 65%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error-report-detail__heading {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error-report-detail__eyebrow {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 7px;
|
||||||
|
color: #a4775d;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 750;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error-report-detail__heading h2 {
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: #3d2a20;
|
||||||
|
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||||
|
font-size: 19px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error-report-detail__badges,
|
||||||
|
.admin-error-report-detail__footer-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error-report-detail__badges {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error-report-detail__badge,
|
||||||
|
.admin-error-report-detail__count {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 26px;
|
||||||
|
padding: 0 9px;
|
||||||
|
border: 1px solid #eadfd6;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #785b49;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error-report-detail__close {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border: 1px solid #e5d5c8;
|
||||||
|
border-radius: 9px;
|
||||||
|
color: #765848;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error-report-detail__close:hover,
|
||||||
|
.admin-error-report-detail__close:focus-visible {
|
||||||
|
border-color: #b6623f;
|
||||||
|
color: #9b4f31;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error-report-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 26px;
|
||||||
|
padding: 0 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error-report-status.is-new {
|
||||||
|
color: #a14e20;
|
||||||
|
background: #fff0e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error-report-status.is-in-progress {
|
||||||
|
color: #72551b;
|
||||||
|
background: #fff7d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error-report-status.is-resolved {
|
||||||
|
color: #28704d;
|
||||||
|
background: #e9f7ee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error-report-detail__summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.7fr) repeat(3, minmax(100px, 1fr));
|
||||||
|
gap: 1px;
|
||||||
|
border-bottom: 1px solid #eee2d8;
|
||||||
|
background: #eee2d8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error-report-detail__summary > div {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
background: #fffdfb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error-report-detail__summary span,
|
||||||
|
.admin-error-report-detail__section-heading span,
|
||||||
|
.admin-error-report-detail__status-field {
|
||||||
|
color: #997d6a;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error-report-detail__summary strong {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: #4d3326;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error-report-detail__body {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
max-height: min(62vh, 640px);
|
||||||
|
overflow: auto;
|
||||||
|
padding: 20px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error-report-detail__section {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error-report-detail__section-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error-report-detail__section-heading h3 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
color: #4d3326;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error-report-detail__section-heading span {
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error-report-detail__count {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error-report-event-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error-report-detail__empty {
|
||||||
|
margin: 0;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px dashed #e4d2c5;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #997d6a;
|
||||||
|
background: #fffaf6;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error-report-event {
|
||||||
|
display: grid;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid #eee2d8;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error-report-event__topline {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: #927663;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error-report-event__topline time {
|
||||||
|
margin-left: auto;
|
||||||
|
color: #aa9282;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error-report-event__index {
|
||||||
|
color: #b6623f;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error-report-event__message {
|
||||||
|
margin: 0;
|
||||||
|
color: #4a3024;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.55;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error-report-event code {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: #957a69;
|
||||||
|
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error-report-event details {
|
||||||
|
border-top: 1px solid #f0e7df;
|
||||||
|
padding-top: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error-report-event summary {
|
||||||
|
color: #a15d3e;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error-report-event pre {
|
||||||
|
max-height: 180px;
|
||||||
|
margin: 9px 0 0;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #f0e7df;
|
||||||
|
background: #fffaf6;
|
||||||
|
color: #72594a;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error-report-detail__description {
|
||||||
|
margin: 0;
|
||||||
|
padding: 13px 15px;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #634b3c;
|
||||||
|
background: #fff8f2;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error-report-detail__section--note {
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error-report-detail__section--note .admin-detail-modal__note {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
color: #624938;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error-report-detail__section--note textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
resize: vertical;
|
||||||
|
border: 1px solid #e4d2c5;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 11px 12px;
|
||||||
|
color: #4d3326;
|
||||||
|
background: #fff;
|
||||||
|
font: inherit;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error-report-detail__section--note textarea:focus {
|
||||||
|
border-color: #b6623f;
|
||||||
|
box-shadow: 0 0 0 3px rgb(182 98 63 / 14%);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error-report-detail__footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px 28px 20px;
|
||||||
|
border-top: 1px solid #eee2d8;
|
||||||
|
background: #fffaf6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error-report-detail__status-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error-report-detail__status-field select {
|
||||||
|
min-width: 132px;
|
||||||
|
min-height: 38px;
|
||||||
|
border: 1px solid #e4d2c5;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0 10px;
|
||||||
|
color: #4d3326;
|
||||||
|
background: #fff;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error-report-detail__footer-actions button {
|
||||||
|
min-height: 38px;
|
||||||
|
border: 1px solid #d9c3b4;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0 13px;
|
||||||
|
color: #6d4b3a;
|
||||||
|
background: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error-report-detail__footer-actions button:last-child {
|
||||||
|
border-color: #a96442;
|
||||||
|
color: #fff;
|
||||||
|
background: #a96442;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error-report-detail__footer-actions button:hover,
|
||||||
|
.admin-error-report-detail__footer-actions button:focus-visible {
|
||||||
|
border-color: #a96442;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.admin-error-report-detail__header,
|
||||||
|
.admin-error-report-detail__body,
|
||||||
|
.admin-error-report-detail__footer {
|
||||||
|
padding-right: 18px;
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error-report-detail__summary {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error-report-detail__footer {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error-report-detail__footer-actions button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
.admin-agc-models {
|
.admin-agc-models {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,15 +66,15 @@
|
|||||||
"zustand": "^5.0.14"
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@openai/codex": "0.147.0",
|
"@openai/codex": "0.155.1",
|
||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
"@tauri-apps/cli": "^2.11.2",
|
"@tauri-apps/cli": "^2.11.2",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/three": "^0.184.1",
|
|
||||||
"@types/react-window": "^1.8.8",
|
"@types/react-window": "^1.8.8",
|
||||||
|
"@types/three": "^0.184.1",
|
||||||
"tailwindcss": "^4.1.14",
|
"tailwindcss": "^4.1.14",
|
||||||
"typescript": "~5.8.2",
|
"typescript": "~5.8.2",
|
||||||
"vitest": "^0.34.6"
|
"vitest": "^0.34.6"
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { execFileSync, spawnSync } from 'node:child_process';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import {
|
||||||
|
generateUpdateManifest,
|
||||||
|
prepareReleaseVersion,
|
||||||
|
resolveReleaseContext,
|
||||||
|
resolveReleasePartition,
|
||||||
|
runTauriBuild,
|
||||||
|
} from './build-release.mjs';
|
||||||
|
import { readReleaseDryRun, uploadReleaseArtifacts } from './release-oss.mjs';
|
||||||
|
import {
|
||||||
|
readUpdaterPubkey,
|
||||||
|
verifyUpdaterSignature,
|
||||||
|
} from './verify-updater-signature.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AGC macOS 分区(`<channel>-mac`)发布入口:构建 universal 包 → 双架构 smoke → 生成 universal DMG
|
||||||
|
* → 生成分区清单 latest.json → 用产物内烘焙的公钥验签 → 按 dry-run 决定是否上传 OSS。
|
||||||
|
*
|
||||||
|
* 边界:
|
||||||
|
* - Apple 签名与公证暂缺:本入口剥离 `APPLE_*` 凭据让 Tauri 跳过 Apple 签名,但**不能传
|
||||||
|
* `--no-sign`** —— 该标志同时会跳过 updater 的 minisign 签名,产物就没有 `.sig`;
|
||||||
|
* 未签名 + 未公证必须显式记录而非静默通过;
|
||||||
|
* - 更新包签名(TAURI_SIGNING_PRIVATE_KEY,minisign)是硬需求:缺了客户端一律拒绝安装,
|
||||||
|
* 因此构建前要求凭据存在,构建后用内置公钥复核 `.sig` 才允许继续上传;
|
||||||
|
* - 未通过验签绝不写 OSS:上传顺序为更新包、签名、首装包,全部成功后才覆盖渠道清单指针。
|
||||||
|
*/
|
||||||
|
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||||
|
const repoRoot = path.resolve(appRoot, '../..');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 产品名只从 Tauri 配置读取:它同时决定 `*.app` 目录名、updater 归档名与 DMG 卷名。
|
||||||
|
* 写死会在改名后让入口静默找错对象(清理、打包、归档三处一起失效)。
|
||||||
|
*/
|
||||||
|
function readProductName() {
|
||||||
|
const read = (file) =>
|
||||||
|
JSON.parse(fs.readFileSync(path.join(appRoot, 'src-tauri', file), 'utf8'));
|
||||||
|
const base = read('tauri.conf.json');
|
||||||
|
const macosPath = path.join(appRoot, 'src-tauri', 'tauri.macos.conf.json');
|
||||||
|
const productName = fs.existsSync(macosPath)
|
||||||
|
? (read('tauri.macos.conf.json').productName ?? base.productName)
|
||||||
|
: base.productName;
|
||||||
|
assert.ok(
|
||||||
|
typeof productName === 'string' && productName.trim().length > 0,
|
||||||
|
'Tauri 配置缺少 productName',
|
||||||
|
);
|
||||||
|
return productName;
|
||||||
|
}
|
||||||
|
|
||||||
|
const productName = readProductName();
|
||||||
|
const appBundleName = `${productName}.app`;
|
||||||
|
const updaterArtifactName = `${productName}.app.tar.gz`;
|
||||||
|
assert.equal(process.platform, 'darwin', '只能在 macOS Agent 执行');
|
||||||
|
assert.equal(
|
||||||
|
process.env.JENKINS_URL?.length > 0,
|
||||||
|
true,
|
||||||
|
'此入口仅用于 Jenkins 独立工作区',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
fs.realpathSync(process.env.WORKSPACE || '.'),
|
||||||
|
fs.realpathSync(repoRoot),
|
||||||
|
'必须在 Jenkins workspace 根目录执行',
|
||||||
|
);
|
||||||
|
const space = fs.statfsSync(repoRoot);
|
||||||
|
assert.ok(
|
||||||
|
space.bavail * space.bsize >= 8 * 1024 ** 3,
|
||||||
|
'构建前至少需要 8 GiB 可用空间;禁止自动清理开发缓存',
|
||||||
|
);
|
||||||
|
|
||||||
|
// 仅剥离 Apple 签名/公证变量:本节点没有证书,误用只会让构建失败;
|
||||||
|
// 更新包签名与 OSS 凭据必须保留,它们是本入口发布能力的组成部分。
|
||||||
|
for (const key of Object.keys(process.env)) {
|
||||||
|
if (/^APPLE_/u.test(key)) delete process.env[key];
|
||||||
|
}
|
||||||
|
assert.ok(
|
||||||
|
process.env.TAURI_SIGNING_PRIVATE_KEY?.length > 0 ||
|
||||||
|
process.env.TAURI_SIGNING_PRIVATE_KEY_PATH?.length > 0,
|
||||||
|
'缺少更新包签名私钥(TAURI_SIGNING_PRIVATE_KEY / _PATH):无签名的更新包会被客户端拒绝,禁止继续',
|
||||||
|
);
|
||||||
|
|
||||||
|
const bucket = process.env.AGC_OSS_BUCKET?.trim() || 'agc-dev';
|
||||||
|
const endpoint =
|
||||||
|
process.env.AGC_OSS_ENDPOINT?.trim() || 'oss-rg-china-mainland.aliyuncs.com';
|
||||||
|
if (!/^[a-z0-9][a-z0-9.-]{1,62}$/u.test(bucket) || /[\r\n\0]/u.test(endpoint)) {
|
||||||
|
throw new Error('OSS bucket 或 endpoint 配置无效');
|
||||||
|
}
|
||||||
|
process.env.AGC_UPDATE_OSS_BASE_URL ||= `https://${bucket}.${endpoint}/agc`;
|
||||||
|
const dryRun = readReleaseDryRun();
|
||||||
|
|
||||||
|
process.env.CARGO_TARGET_DIR = path.join(appRoot, 'src-tauri/target');
|
||||||
|
const context = resolveReleaseContext(['--target=universal-apple-darwin']);
|
||||||
|
const partition = resolveReleasePartition(context.channel, context.target);
|
||||||
|
const version = await prepareReleaseVersion(context);
|
||||||
|
// 首装包名必须保持 `<产品名>_<版本>_universal.dmg`:清单侧按该后缀唯一匹配本次产物。
|
||||||
|
const firstInstallName = `${productName}_${version}_universal.dmg`;
|
||||||
|
|
||||||
|
// 幂等边界:workspace 会保留上一轮产物。先删掉本次将要写出的对象,否则
|
||||||
|
// 1) hdiutil 会因同名 DMG 已存在直接失败(首次实跑即命中);
|
||||||
|
// 2) 上一轮遗留的 `.sig` 会让验签门禁把「本轮其实没签」判成通过。
|
||||||
|
// 只删本次要写出的确切路径,不动其它版本产物与编译缓存。
|
||||||
|
const macosBundle = path.join(context.bundleRoot, 'macos');
|
||||||
|
for (const stale of [
|
||||||
|
path.join(macosBundle, updaterArtifactName),
|
||||||
|
path.join(macosBundle, `${updaterArtifactName}.sig`),
|
||||||
|
path.join(macosBundle, `${firstInstallName}`),
|
||||||
|
path.join(macosBundle, `${firstInstallName}.sha256`),
|
||||||
|
path.join(context.bundleRoot, 'latest.json'),
|
||||||
|
path.join(context.bundleRoot, 'release-notes.txt'),
|
||||||
|
]) {
|
||||||
|
fs.rmSync(stale, { force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
'--target=universal-apple-darwin',
|
||||||
|
'--bundles',
|
||||||
|
'app',
|
||||||
|
'--ci',
|
||||||
|
// 刻意不传 `--no-sign`:它会连带跳过 updater 签名,而客户端强制校验更新包签名。
|
||||||
|
// Apple 侧改为剥离 APPLE_* 凭据,未配置身份时 Tauri 不签名也不失败。
|
||||||
|
// 基础配置已开启;这里显式声明,避免被其它配置来源关掉后静默失去更新能力。
|
||||||
|
'--config',
|
||||||
|
'{"bundle":{"createUpdaterArtifacts":true}}',
|
||||||
|
];
|
||||||
|
const command = (binary, argv, options = {}) =>
|
||||||
|
execFileSync(binary, argv, { cwd: repoRoot, stdio: 'inherit', ...options });
|
||||||
|
runTauriBuild(args, context);
|
||||||
|
|
||||||
|
const app = path.join(context.bundleRoot, 'macos', appBundleName);
|
||||||
|
for (const architecture of ['arm64', 'x86_64']) {
|
||||||
|
command(process.execPath, [
|
||||||
|
path.join(appRoot, 'scripts/check-macos-bundle.mjs'),
|
||||||
|
app,
|
||||||
|
architecture,
|
||||||
|
'--universal',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DMG 放在 bundle 根目录下:渠道清单的首装包选择会扫描该目录,命名必须匹配 `_<version>_universal.dmg`。
|
||||||
|
const dmgDirectory = path.join(context.bundleRoot, 'macos');
|
||||||
|
fs.mkdirSync(dmgDirectory, { recursive: true });
|
||||||
|
const dmg = path.join(dmgDirectory, firstInstallName);
|
||||||
|
const stage = fs.mkdtempSync(path.join(os.tmpdir(), 'agc-ci-dmg-'));
|
||||||
|
try {
|
||||||
|
command('ditto', [app, path.join(stage, appBundleName)]);
|
||||||
|
fs.symlinkSync('/Applications', path.join(stage, 'Applications'));
|
||||||
|
command('hdiutil', [
|
||||||
|
'create',
|
||||||
|
// 前面已删除同名对象;这里再要求显式覆盖,避免残留文件让构建以「文件已存在」失败。
|
||||||
|
'-ov',
|
||||||
|
'-volname',
|
||||||
|
productName,
|
||||||
|
'-srcfolder',
|
||||||
|
stage,
|
||||||
|
'-format',
|
||||||
|
'UDZO',
|
||||||
|
dmg,
|
||||||
|
]);
|
||||||
|
command('hdiutil', ['verify', dmg]);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(stage, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const release = await generateUpdateManifest(context);
|
||||||
|
assert.equal(
|
||||||
|
path.resolve(release.downloadArtifact),
|
||||||
|
path.resolve(dmg),
|
||||||
|
'首装包必须锁定本次生成的 universal DMG',
|
||||||
|
);
|
||||||
|
|
||||||
|
// 上传前门禁:用产物里烘焙的公钥复核更新包签名。验不过就停在这里,绝不写 OSS。
|
||||||
|
const signature = verifyUpdaterSignature({
|
||||||
|
artifactPath: release.artifact,
|
||||||
|
signaturePath: `${release.artifact}.sig`,
|
||||||
|
pubkey: readUpdaterPubkey(),
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`[agc-macos] 更新包签名校验通过:alg=${signature.algorithm},keyId=${signature.keyId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const artifacts = path.join(repoRoot, 'artifacts');
|
||||||
|
// 只清理本 Job 的归档输出,不能把上次 DMG 当成本次成功产物。
|
||||||
|
fs.rmSync(artifacts, { recursive: true, force: true });
|
||||||
|
fs.mkdirSync(artifacts, { recursive: true });
|
||||||
|
const sha256 = (file) => {
|
||||||
|
const hash = createHash('sha256');
|
||||||
|
hash.update(fs.readFileSync(file));
|
||||||
|
return hash.digest('hex');
|
||||||
|
};
|
||||||
|
const dmgHash = sha256(dmg);
|
||||||
|
fs.writeFileSync(`${dmg}.sha256`, `${dmgHash} ${path.basename(dmg)}\n`);
|
||||||
|
|
||||||
|
const uploadPlan = uploadReleaseArtifacts(release, {
|
||||||
|
bucket,
|
||||||
|
endpoint,
|
||||||
|
binary: process.env.OSSUTIL_BIN?.trim() || 'ossutil',
|
||||||
|
accessKeyId: process.env.AGC_OSS_ACCESS_KEY_ID?.trim(),
|
||||||
|
accessKeySecret: process.env.AGC_OSS_ACCESS_KEY_SECRET,
|
||||||
|
dryRun,
|
||||||
|
});
|
||||||
|
|
||||||
|
const archived = [
|
||||||
|
dmg,
|
||||||
|
`${dmg}.sha256`,
|
||||||
|
release.manifestPath,
|
||||||
|
release.notesPath,
|
||||||
|
`${release.artifact}.sig`,
|
||||||
|
];
|
||||||
|
for (const file of archived) {
|
||||||
|
fs.copyFileSync(file, path.join(artifacts, path.basename(file)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const commit = execFileSync('git', ['rev-parse', 'HEAD'], {
|
||||||
|
cwd: repoRoot,
|
||||||
|
encoding: 'utf8',
|
||||||
|
}).trim();
|
||||||
|
// Apple 签名状态必须实测:剥离 APPLE_* 后 Tauri 通常跳过签名,但节点若装了 Developer ID
|
||||||
|
// 证书仍可能签上,硬编码 appleSigned=false 会把「其实签了」写成假事实。
|
||||||
|
const signatureProbe = spawnSync('codesign', ['-dv', '--verbose=2', app], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
});
|
||||||
|
const signatureText = `${signatureProbe.stdout ?? ''}${signatureProbe.stderr ?? ''}`;
|
||||||
|
const appleSigned = /Authority=Developer ID Application/u.test(signatureText);
|
||||||
|
const appleSignatureKind = appleSigned
|
||||||
|
? 'developer-id'
|
||||||
|
: /Signature=adhoc/u.test(signatureText)
|
||||||
|
? 'adhoc'
|
||||||
|
: 'unsigned';
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(artifacts, 'build-manifest.json'),
|
||||||
|
`${JSON.stringify(
|
||||||
|
{
|
||||||
|
version,
|
||||||
|
commit,
|
||||||
|
target: context.target,
|
||||||
|
channel: context.channel,
|
||||||
|
// Apple 签名与公证暂缺:显式记录为未验证项,不静默通过。
|
||||||
|
appleSigned,
|
||||||
|
appleSignatureKind,
|
||||||
|
notarized: false,
|
||||||
|
dryRun,
|
||||||
|
uploaded: !dryRun,
|
||||||
|
updaterSignature: {
|
||||||
|
algorithm: signature.algorithm,
|
||||||
|
keyId: signature.keyId,
|
||||||
|
verified: true,
|
||||||
|
},
|
||||||
|
oss: {
|
||||||
|
bucket,
|
||||||
|
endpoint,
|
||||||
|
partition,
|
||||||
|
latest: `oss://${bucket}/agc/${partition}/latest.json`,
|
||||||
|
objects: uploadPlan.map(({ destination }) => destination),
|
||||||
|
},
|
||||||
|
artifacts: {
|
||||||
|
updater: path.basename(release.artifact),
|
||||||
|
updaterSha256: sha256(release.artifact),
|
||||||
|
updaterBytes: fs.statSync(release.artifact).size,
|
||||||
|
updaterSignature: path.basename(`${release.artifact}.sig`),
|
||||||
|
firstInstall: path.basename(dmg),
|
||||||
|
firstInstallSha256: dmgHash,
|
||||||
|
manifest: 'latest.json',
|
||||||
|
},
|
||||||
|
smokes: ['arm64', 'x86_64'],
|
||||||
|
intelSmoke: process.arch === 'arm64' ? 'Rosetta' : 'native',
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)}\n`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
dryRun
|
||||||
|
? `[agc-macos] dry-run 完成:${partition} 分区产物与清单已生成,未写入 OSS`
|
||||||
|
: `[agc-macos] ${partition} 分区更新包、签名、首装包与清单已上传 OSS`,
|
||||||
|
);
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
defaultEditorFeatures,
|
defaultEditorFeatures,
|
||||||
withDefaultCargoFeatures,
|
withDefaultCargoFeatures,
|
||||||
} from './cargo-features.mjs';
|
} from './cargo-features.mjs';
|
||||||
|
import { stageNodeRuntime } from './stage-node-runtime.mjs';
|
||||||
|
|
||||||
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||||
// 提交摘要里的 pathspec 与 `git log` 都以仓库根为基准,不能在应用目录里执行。
|
// 提交摘要里的 pathspec 与 `git log` 都以仓库根为基准,不能在应用目录里执行。
|
||||||
@@ -46,16 +47,12 @@ function explicitBuildTarget(args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function validateReleaseTarget(target) {
|
function validateReleaseTarget(target) {
|
||||||
if (target === 'universal-apple-darwin') {
|
|
||||||
throw new Error(
|
|
||||||
'内置 Codex 资源仅支持 macOS 单架构构建,请使用 aarch64-apple-darwin 或 x86_64-apple-darwin',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
![
|
![
|
||||||
'x86_64-pc-windows-msvc',
|
'x86_64-pc-windows-msvc',
|
||||||
'aarch64-apple-darwin',
|
'aarch64-apple-darwin',
|
||||||
'x86_64-apple-darwin',
|
'x86_64-apple-darwin',
|
||||||
|
'universal-apple-darwin',
|
||||||
].includes(target)
|
].includes(target)
|
||||||
) {
|
) {
|
||||||
throw new Error(`不支持的发布目标:${target}`);
|
throw new Error(`不支持的发布目标:${target}`);
|
||||||
@@ -200,10 +197,12 @@ export function updateManifestUrl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 单架构产物只登记实际目标,不能把同一原生资源映射为另一架构。
|
* universal 主程序与双目录原生资源共用一个更新包;单架构只登记实际目标。
|
||||||
*/
|
*/
|
||||||
export function resolveManifestPlatformKeys(target = defaultTarget()) {
|
export function resolveManifestPlatformKeys(target = defaultTarget()) {
|
||||||
validateReleaseTarget(target);
|
validateReleaseTarget(target);
|
||||||
|
if (target === 'universal-apple-darwin')
|
||||||
|
return ['darwin-aarch64', 'darwin-x86_64'];
|
||||||
if (target === 'aarch64-apple-darwin') return ['darwin-aarch64'];
|
if (target === 'aarch64-apple-darwin') return ['darwin-aarch64'];
|
||||||
if (target === 'x86_64-apple-darwin') return ['darwin-x86_64'];
|
if (target === 'x86_64-apple-darwin') return ['darwin-x86_64'];
|
||||||
if (target.includes('windows')) {
|
if (target.includes('windows')) {
|
||||||
@@ -442,22 +441,25 @@ export function createChannelConfig(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeChannelConfigFile(channel, target) {
|
function writeChannelConfigFile(channel, target, includeNodeRuntime = false) {
|
||||||
const configPath = path.join(
|
const configPath = path.join(
|
||||||
os.tmpdir(),
|
os.tmpdir(),
|
||||||
`agc-tauri-channel-${channel}-${target}.json`,
|
`agc-tauri-channel-${channel}-${target}.json`,
|
||||||
);
|
);
|
||||||
fs.writeFileSync(
|
const config = createChannelConfig(channel, target);
|
||||||
configPath,
|
// 普通 cargo test/dev 不要求发行资源;只有完成 staging 的发行构建加入映射。
|
||||||
`${JSON.stringify(createChannelConfig(channel, target), null, 2)}\n`,
|
if (includeNodeRuntime)
|
||||||
);
|
config.bundle = {
|
||||||
|
resources: { 'resources/node-runtime': 'game-runtime/node' },
|
||||||
|
};
|
||||||
|
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
||||||
return configPath;
|
return configPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runTauriBuild(
|
export function runTauriBuild(
|
||||||
args = [],
|
args = [],
|
||||||
context = resolveReleaseContext(args),
|
context = resolveReleaseContext(args),
|
||||||
{ spawn = spawnSync } = {},
|
{ spawn = spawnSync, stageRuntime = stageNodeRuntime } = {},
|
||||||
) {
|
) {
|
||||||
if (
|
if (
|
||||||
explicitBuildTarget(args) &&
|
explicitBuildTarget(args) &&
|
||||||
@@ -467,7 +469,12 @@ export function runTauriBuild(
|
|||||||
}
|
}
|
||||||
const tauriArguments = buildTauriBuildArguments(args, context.target);
|
const tauriArguments = buildTauriBuildArguments(args, context.target);
|
||||||
const { channel, target } = context;
|
const { channel, target } = context;
|
||||||
const configPath = writeChannelConfigFile(channel, target);
|
if (!args.includes('--no-bundle')) stageRuntime(target);
|
||||||
|
const configPath = writeChannelConfigFile(
|
||||||
|
channel,
|
||||||
|
target,
|
||||||
|
!args.includes('--no-bundle'),
|
||||||
|
);
|
||||||
console.log(
|
console.log(
|
||||||
`[ai-game-creator-shell] 渠道 ${channel} 端点配置:${configPath}`,
|
`[ai-game-creator-shell] 渠道 ${channel} 端点配置:${configPath}`,
|
||||||
);
|
);
|
||||||
@@ -483,7 +490,17 @@ export function runTauriBuild(
|
|||||||
const result = spawn(
|
const result = spawn(
|
||||||
npmCommand,
|
npmCommand,
|
||||||
['--prefix', '../..', 'exec', 'tauri', '--', ...tauriArguments],
|
['--prefix', '../..', 'exec', 'tauri', '--', ...tauriArguments],
|
||||||
{ cwd: appRoot, stdio: 'inherit', shell: process.platform === 'win32' },
|
{
|
||||||
|
cwd: appRoot,
|
||||||
|
stdio: 'inherit',
|
||||||
|
shell: process.platform === 'win32',
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
// Vite embeds the platform API origin in the packaged renderer. The
|
||||||
|
// release channel and updater channel therefore cannot drift apart.
|
||||||
|
VITE_AGC_PLATFORM_CHANNEL: channel,
|
||||||
|
},
|
||||||
|
},
|
||||||
);
|
);
|
||||||
if (result.error) throw result.error;
|
if (result.error) throw result.error;
|
||||||
if (result.status !== 0) process.exit(result.status ?? 1);
|
if (result.status !== 0) process.exit(result.status ?? 1);
|
||||||
@@ -543,6 +560,18 @@ export function selectFirstInstallArtifact(
|
|||||||
if (!selected?.endsWith('.exe')) {
|
if (!selected?.endsWith('.exe')) {
|
||||||
throw new Error('Windows 首装包必须复用本次 NSIS .exe 更新包');
|
throw new Error('Windows 首装包必须复用本次 NSIS .exe 更新包');
|
||||||
}
|
}
|
||||||
|
} else if (target === 'universal-apple-darwin') {
|
||||||
|
// universal 主程序只产出一个 DMG,aarch64 与 x86_64 首装共用它(命名见 build-macos-ci.mjs)。
|
||||||
|
const suffix = `_${version}_universal.dmg`;
|
||||||
|
const candidates = files.filter((file) =>
|
||||||
|
path.basename(file).endsWith(suffix),
|
||||||
|
);
|
||||||
|
if (candidates.length !== 1) {
|
||||||
|
throw new Error(
|
||||||
|
`首装 DMG 必须唯一匹配本次版本 ${version} 的 universal 产物,找到 ${candidates.length} 个`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
selected = candidates[0];
|
||||||
} else {
|
} else {
|
||||||
// Tauri DMG 文件名使用 aarch64 / x64,而 updater 的 Intel 平台键是 x86_64。
|
// Tauri DMG 文件名使用 aarch64 / x64,而 updater 的 Intel 平台键是 x86_64。
|
||||||
const architecture = target.startsWith('aarch64') ? 'aarch64' : 'x64';
|
const architecture = target.startsWith('aarch64') ? 'aarch64' : 'x64';
|
||||||
|
|||||||
@@ -45,19 +45,22 @@ const packageVersion = JSON.parse(
|
|||||||
).version;
|
).version;
|
||||||
|
|
||||||
function createDmgFixture(root, target, version = packageVersion) {
|
function createDmgFixture(root, target, version = packageVersion) {
|
||||||
const architecture = target.startsWith('aarch64') ? 'aarch64' : 'x64';
|
const architecture = target.startsWith('aarch64')
|
||||||
|
? 'aarch64'
|
||||||
|
: target === universalTarget
|
||||||
|
? 'universal'
|
||||||
|
: 'x64';
|
||||||
const dmg = path.join(root, `陶泥儿_${version}_${architecture}.dmg`);
|
const dmg = path.join(root, `陶泥儿_${version}_${architecture}.dmg`);
|
||||||
writeFileSync(dmg, 'first installation disk image');
|
writeFileSync(dmg, 'first installation disk image');
|
||||||
return dmg;
|
return dmg;
|
||||||
}
|
}
|
||||||
|
|
||||||
test('native sidecar builds reject universal targets and accept each macOS architecture', () => {
|
test('native sidecar builds accept universal and each macOS architecture', () => {
|
||||||
assert.throws(() => buildTauriBuildArguments([], universalTarget), /单架构/);
|
for (const target of [
|
||||||
assert.throws(
|
universalTarget,
|
||||||
() => buildTauriBuildArguments(['--target=universal-apple-darwin']),
|
'aarch64-apple-darwin',
|
||||||
/单架构/,
|
'x86_64-apple-darwin',
|
||||||
);
|
]) {
|
||||||
for (const target of ['aarch64-apple-darwin', 'x86_64-apple-darwin']) {
|
|
||||||
assert.deepEqual(buildTauriBuildArguments([], target), [
|
assert.deepEqual(buildTauriBuildArguments([], target), [
|
||||||
'build',
|
'build',
|
||||||
'--target',
|
'--target',
|
||||||
@@ -201,8 +204,26 @@ test('channel manifest URL and build-time endpoint follow the channel', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('macOS manifests only advertise the architecture actually built', () => {
|
test('packaged renderer receives the same channel as the updater manifest', () => {
|
||||||
assert.throws(() => resolveManifestPlatformKeys(universalTarget), /单架构/);
|
const context = resolveReleaseContext([], {
|
||||||
|
AGC_BUILD_TARGET: windowsTarget,
|
||||||
|
AGC_UPDATE_CHANNEL: 'release',
|
||||||
|
});
|
||||||
|
let spawnOptions;
|
||||||
|
runTauriBuild([], context, {
|
||||||
|
spawn: (_binary, _args, options) => {
|
||||||
|
spawnOptions = options;
|
||||||
|
return { status: 0 };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(spawnOptions?.env?.VITE_AGC_PLATFORM_CHANNEL, 'release');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('macOS manifests advertise exactly the architectures actually built', () => {
|
||||||
|
assert.deepEqual(resolveManifestPlatformKeys(universalTarget), [
|
||||||
|
'darwin-aarch64',
|
||||||
|
'darwin-x86_64',
|
||||||
|
]);
|
||||||
assert.deepEqual(resolveManifestPlatformKeys('aarch64-apple-darwin'), [
|
assert.deepEqual(resolveManifestPlatformKeys('aarch64-apple-darwin'), [
|
||||||
'darwin-aarch64',
|
'darwin-aarch64',
|
||||||
]);
|
]);
|
||||||
@@ -246,7 +267,6 @@ test('release context resolves explicit targets before environment/default and f
|
|||||||
['--target='],
|
['--target='],
|
||||||
['--target', '--no-bundle'],
|
['--target', '--no-bundle'],
|
||||||
['--target', windowsTarget, '--target=aarch64-apple-darwin'],
|
['--target', windowsTarget, '--target=aarch64-apple-darwin'],
|
||||||
['--target', universalTarget],
|
|
||||||
['--target', 'unknown'],
|
['--target', 'unknown'],
|
||||||
])
|
])
|
||||||
assert.throws(() => resolveReleaseContext(args, {}));
|
assert.throws(() => resolveReleaseContext(args, {}));
|
||||||
@@ -279,6 +299,7 @@ test('explicit macOS target drives version lookup, Tauri endpoint, artifact and
|
|||||||
build: (args, context) => {
|
build: (args, context) => {
|
||||||
seenContexts.push(context);
|
seenContexts.push(context);
|
||||||
runTauriBuild(args, context, {
|
runTauriBuild(args, context, {
|
||||||
|
stageRuntime: () => {},
|
||||||
spawn: (_binary, command) => {
|
spawn: (_binary, command) => {
|
||||||
const configIndex = command.lastIndexOf('--config');
|
const configIndex = command.lastIndexOf('--config');
|
||||||
const config = JSON.parse(
|
const config = JSON.parse(
|
||||||
@@ -461,8 +482,8 @@ test('invalid target or platform used as channel fails before any release side e
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
() => buildRelease(['--target', universalTarget], sideEffects),
|
() => buildRelease(['--target', 'unknown'], sideEffects),
|
||||||
/单架构/,
|
/不支持的发布目标/,
|
||||||
);
|
);
|
||||||
await withEnv({ AGC_UPDATE_CHANNEL: 'dev-win' }, () =>
|
await withEnv({ AGC_UPDATE_CHANNEL: 'dev-win' }, () =>
|
||||||
assert.rejects(
|
assert.rejects(
|
||||||
@@ -473,6 +494,43 @@ test('invalid target or platform used as channel fails before any release side e
|
|||||||
assert.equal(touched, false);
|
assert.equal(touched, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('universal uses the Mac channel and the same signed artifact for both architectures', () => {
|
||||||
|
const context = resolveReleaseContext(['--target', universalTarget], {
|
||||||
|
AGC_BUILD_TARGET: windowsTarget,
|
||||||
|
});
|
||||||
|
// 渠道本身不含系统:分区由渠道 + 目标推导,二者不能混为一谈。
|
||||||
|
assert.equal(context.channel, 'dev');
|
||||||
|
assert.equal(
|
||||||
|
resolveReleasePartition(context.channel, context.target),
|
||||||
|
'dev-mac',
|
||||||
|
);
|
||||||
|
assert.ok(context.bundleRoot.includes(universalTarget));
|
||||||
|
withSignedArtifact('陶泥儿.app.tar.gz', (artifact) => {
|
||||||
|
const manifest = createUpdateManifest(artifact, {
|
||||||
|
...context,
|
||||||
|
downloadArtifact: createDmgFixture(
|
||||||
|
path.dirname(artifact),
|
||||||
|
universalTarget,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
assert.deepEqual(Object.keys(manifest.platforms), [
|
||||||
|
'darwin-aarch64',
|
||||||
|
'darwin-x86_64',
|
||||||
|
]);
|
||||||
|
assert.deepEqual(
|
||||||
|
manifest.platforms['darwin-aarch64'],
|
||||||
|
manifest.platforms['darwin-x86_64'],
|
||||||
|
);
|
||||||
|
assert.match(manifest.platforms['darwin-aarch64'].url, /\/dev-mac\//);
|
||||||
|
// 两个平台键共用同一个 universal 首装包,不能要求出两份架构 DMG。
|
||||||
|
assert.deepEqual(
|
||||||
|
manifest.downloads['darwin-aarch64'].url,
|
||||||
|
manifest.downloads['darwin-x86_64'].url,
|
||||||
|
);
|
||||||
|
assert.match(manifest.downloads['darwin-aarch64'].url, /_universal\.dmg$/u);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('Windows remains the default and explicit Windows overrides macOS environment', () => {
|
test('Windows remains the default and explicit Windows overrides macOS environment', () => {
|
||||||
const files = ['/tmp/mac.app.tar.gz', '/tmp/windows.exe', '/tmp/mac.dmg'];
|
const files = ['/tmp/mac.app.tar.gz', '/tmp/windows.exe', '/tmp/mac.dmg'];
|
||||||
for (const context of [
|
for (const context of [
|
||||||
@@ -490,6 +548,7 @@ test('Windows remains the default and explicit Windows overrides macOS environme
|
|||||||
['--target', windowsTarget, '--config', 'user-config.json'],
|
['--target', windowsTarget, '--config', 'user-config.json'],
|
||||||
context,
|
context,
|
||||||
{
|
{
|
||||||
|
stageRuntime: () => {},
|
||||||
spawn: (_binary, command) => {
|
spawn: (_binary, command) => {
|
||||||
assert.ok(
|
assert.ok(
|
||||||
command.includes(
|
command.includes(
|
||||||
@@ -528,6 +587,52 @@ test('no-bundle smoke skips version writes and manifest generation', async () =>
|
|||||||
assert.deepEqual(steps, ['dev']);
|
assert.deepEqual(steps, ['dev']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('release stages Node before Tauri and injects its resource mapping only for bundles', () => {
|
||||||
|
const context = resolveReleaseContext(['--target', windowsTarget]);
|
||||||
|
const events = [];
|
||||||
|
runTauriBuild(['--target', windowsTarget], context, {
|
||||||
|
stageRuntime(target) {
|
||||||
|
assert.equal(target, windowsTarget);
|
||||||
|
events.push('stage');
|
||||||
|
},
|
||||||
|
spawn(_binary, args) {
|
||||||
|
events.push('build');
|
||||||
|
const config = JSON.parse(
|
||||||
|
readFileSync(args[args.lastIndexOf('--config') + 1], 'utf8'),
|
||||||
|
);
|
||||||
|
assert.deepEqual(config.bundle.resources, {
|
||||||
|
'resources/node-runtime': 'game-runtime/node',
|
||||||
|
});
|
||||||
|
return { status: 0 };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.deepEqual(events, ['stage', 'build']);
|
||||||
|
runTauriBuild(['--no-bundle', '--target', windowsTarget], context, {
|
||||||
|
stageRuntime() {
|
||||||
|
assert.fail('no-bundle must not stage resources');
|
||||||
|
},
|
||||||
|
spawn(_binary, args) {
|
||||||
|
const config = JSON.parse(
|
||||||
|
readFileSync(args[args.lastIndexOf('--config') + 1], 'utf8'),
|
||||||
|
);
|
||||||
|
assert.equal(config.bundle, undefined);
|
||||||
|
return { status: 0 };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
runTauriBuild(['--target', windowsTarget], context, {
|
||||||
|
stageRuntime() {
|
||||||
|
throw new Error('missing runtime');
|
||||||
|
},
|
||||||
|
spawn() {
|
||||||
|
assert.fail('invalid runtime must prevent build');
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/missing runtime/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('channel manifest carries version, platform keys and signature', () => {
|
test('channel manifest carries version, platform keys and signature', () => {
|
||||||
withSignedArtifact('陶泥儿_0.1.48_x64-setup.exe', (artifact) => {
|
withSignedArtifact('陶泥儿_0.1.48_x64-setup.exe', (artifact) => {
|
||||||
withEnv({ AGC_UPDATE_RELEASE_NOTES: '修复与改进' }, () => {
|
withEnv({ AGC_UPDATE_RELEASE_NOTES: '修复与改进' }, () => {
|
||||||
@@ -631,6 +736,7 @@ for (const channel of ['release', 'beta-2']) {
|
|||||||
'2.3.4',
|
'2.3.4',
|
||||||
);
|
);
|
||||||
runTauriBuild([`--target=${target}`], context, {
|
runTauriBuild([`--target=${target}`], context, {
|
||||||
|
stageRuntime: () => {},
|
||||||
spawn: (_binary, command) => {
|
spawn: (_binary, command) => {
|
||||||
const config = JSON.parse(
|
const config = JSON.parse(
|
||||||
readFileSync(
|
readFileSync(
|
||||||
|
|||||||
@@ -4,9 +4,25 @@ import { EventEmitter } from 'node:events';
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
import ts from 'typescript';
|
import ts from 'typescript';
|
||||||
|
|
||||||
|
// 固定解析源码保留上游测试中的替换字符;必须同时核对原始字节与许可。
|
||||||
|
execFileSync(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
'--test',
|
||||||
|
fileURLToPath(
|
||||||
|
new URL(
|
||||||
|
'../src-tauri/vendor/codex-patch-parser/upstream-integrity.test.mjs',
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
{ stdio: 'inherit' },
|
||||||
|
);
|
||||||
|
|
||||||
import {
|
import {
|
||||||
appIdentifier,
|
appIdentifier,
|
||||||
defaultRealSwarmTestTask,
|
defaultRealSwarmTestTask,
|
||||||
@@ -1313,6 +1329,18 @@ if (tauriConfig.identifier !== 'world.genarrative.ai-game-creator') {
|
|||||||
|
|
||||||
const expectedBundledDesignAgentResources = {
|
const expectedBundledDesignAgentResources = {
|
||||||
'design-agent': 'design-agent',
|
'design-agent': 'design-agent',
|
||||||
|
...Object.fromEntries(
|
||||||
|
[
|
||||||
|
'codex-patch-parser',
|
||||||
|
'codex-utils-path-uri',
|
||||||
|
'codex-utils-absolute-path',
|
||||||
|
].flatMap((name) =>
|
||||||
|
['LICENSE', 'NOTICE'].map((file) => [
|
||||||
|
`vendor/${name}/${file}`,
|
||||||
|
`licenses/${name}/${file}`,
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
const expectedBundledWindowsResources = {
|
const expectedBundledWindowsResources = {
|
||||||
'resources/codex/win-x64/bin/codex.exe': 'coding-agent/win-x64/bin/codex.exe',
|
'resources/codex/win-x64/bin/codex.exe': 'coding-agent/win-x64/bin/codex.exe',
|
||||||
@@ -1336,7 +1364,7 @@ assert.deepEqual(
|
|||||||
'AI game creator shell base Tauri config must bundle the design-agent resource pack',
|
'AI game creator shell base Tauri config must bundle the design-agent resource pack',
|
||||||
);
|
);
|
||||||
for (const key of Object.keys(tauriConfig.bundle?.resources ?? {})) {
|
for (const key of Object.keys(tauriConfig.bundle?.resources ?? {})) {
|
||||||
if (String(key).includes('codex')) {
|
if (String(key).startsWith('resources/codex/')) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'AI game creator shell base Tauri config must not require Windows-only Codex resources',
|
'AI game creator shell base Tauri config must not require Windows-only Codex resources',
|
||||||
);
|
);
|
||||||
@@ -1365,18 +1393,20 @@ if (windowsTauriConfig.bundle?.useLocalToolsDir !== true) {
|
|||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
macosTauriConfig.bundle?.resources,
|
macosTauriConfig.bundle?.resources,
|
||||||
Object.fromEntries([
|
Object.fromEntries([
|
||||||
...[
|
...['darwin-arm64', 'darwin-x64'].flatMap((arch) =>
|
||||||
'bin/codex',
|
[
|
||||||
'bin/codex-code-mode-host',
|
'bin/codex',
|
||||||
'codex-path/rg',
|
'bin/codex-code-mode-host',
|
||||||
'codex-resources/zsh/bin/zsh',
|
'codex-path/rg',
|
||||||
'codex-package.json',
|
'codex-resources/zsh/bin/zsh',
|
||||||
'NOTICE.md',
|
'codex-package.json',
|
||||||
'manifest.json',
|
'NOTICE.md',
|
||||||
].map((file) => [
|
'manifest.json',
|
||||||
`resources/codex/mac-native/${file}`,
|
].map((file) => [
|
||||||
`coding-agent/mac-native/${file}`,
|
`resources/codex/mac-native/${arch}/${file}`,
|
||||||
]),
|
`coding-agent/mac-native/${arch}/${file}`,
|
||||||
|
]),
|
||||||
|
),
|
||||||
['resources/plugins', 'plugins'],
|
['resources/plugins', 'plugins'],
|
||||||
]),
|
]),
|
||||||
'macOS must bundle the complete native Codex layout and plugin workspace',
|
'macOS must bundle the complete native Codex layout and plugin workspace',
|
||||||
|
|||||||
@@ -8,6 +8,13 @@ import path from 'node:path';
|
|||||||
// 只操作临时复制品;不启动 GUI、不读取开发机凭据、不访问 Provider。
|
// 只操作临时复制品;不启动 GUI、不读取开发机凭据、不访问 Provider。
|
||||||
assert.equal(process.platform, 'darwin', '此验证必须在 macOS 执行');
|
assert.equal(process.platform, 'darwin', '此验证必须在 macOS 执行');
|
||||||
const source = path.resolve(process.argv[2] || '');
|
const source = path.resolve(process.argv[2] || '');
|
||||||
|
const architecture =
|
||||||
|
process.argv[3] || (process.arch === 'arm64' ? 'arm64' : 'x86_64');
|
||||||
|
assert.ok(
|
||||||
|
['arm64', 'x86_64'].includes(architecture),
|
||||||
|
'架构只接受 arm64 / x86_64',
|
||||||
|
);
|
||||||
|
const requireUniversal = process.argv.includes('--universal');
|
||||||
assert.ok(
|
assert.ok(
|
||||||
source.endsWith('.app') && fs.statSync(source).isDirectory(),
|
source.endsWith('.app') && fs.statSync(source).isDirectory(),
|
||||||
'请传入 .app 绝对路径',
|
'请传入 .app 绝对路径',
|
||||||
@@ -15,7 +22,28 @@ assert.ok(
|
|||||||
const root = fs.realpathSync(
|
const root = fs.realpathSync(
|
||||||
fs.mkdtempSync(path.join(os.tmpdir(), 'agc-macos-bundle-')),
|
fs.mkdtempSync(path.join(os.tmpdir(), 'agc-macos-bundle-')),
|
||||||
);
|
);
|
||||||
const app = path.join(root, '陶泥儿 隔离测试.app');
|
// 产品名从传入的 .app 推导,不在校验脚本里写死;改名后校验对象仍指向同一个包。
|
||||||
|
const appBundleName = path.basename(source);
|
||||||
|
const app = path.join(root, `隔离-${appBundleName}`);
|
||||||
|
// 侧车清单版本必须等于锁定的 @openai/codex 版本,避免两处固定版本漂移。
|
||||||
|
const appPackage = JSON.parse(
|
||||||
|
fs.readFileSync(
|
||||||
|
path.join(
|
||||||
|
path.dirname(new URL(import.meta.url).pathname),
|
||||||
|
'../package.json',
|
||||||
|
),
|
||||||
|
'utf8',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const pinnedCodexVersion =
|
||||||
|
appPackage.dependencies?.['@openai/codex'] ??
|
||||||
|
appPackage.devDependencies?.['@openai/codex'] ??
|
||||||
|
appPackage.optionalDependencies?.['@openai/codex'];
|
||||||
|
assert.match(
|
||||||
|
pinnedCodexVersion,
|
||||||
|
/^\d+\.\d+\.\d+$/u,
|
||||||
|
'package.json 必须锁定精确的 @openai/codex 版本',
|
||||||
|
);
|
||||||
const home = path.join(root, 'home');
|
const home = path.join(root, 'home');
|
||||||
const config = path.join(root, 'config');
|
const config = path.join(root, 'config');
|
||||||
const tmp = path.join(root, 'tmp');
|
const tmp = path.join(root, 'tmp');
|
||||||
@@ -31,17 +59,58 @@ const env = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function run(command, args) {
|
function run(command, args) {
|
||||||
const result = spawnSync(command, args, {
|
// 只强制被测应用切片;本机 Xcode 检查工具可能仅提供宿主架构。
|
||||||
cwd: root,
|
const useSlice = command.startsWith(`${app}${path.sep}`);
|
||||||
env,
|
const result = spawnSync(
|
||||||
encoding: 'utf8',
|
useSlice ? '/usr/bin/arch' : command,
|
||||||
timeout: 30_000,
|
useSlice ? [`-${architecture}`, command, ...args] : args,
|
||||||
maxBuffer: 1024 * 1024,
|
{
|
||||||
});
|
cwd: root,
|
||||||
|
env,
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: 120_000,
|
||||||
|
maxBuffer: 1024 * 1024,
|
||||||
|
},
|
||||||
|
);
|
||||||
assert.ifError(result.error);
|
assert.ifError(result.error);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APFS 上优先用 `ditto --clone`:整包按区块克隆,秒级完成且几乎不占额外空间。
|
||||||
|
* 跨卷或非 APFS 时回退到真实复制;两种路径都必须产出可独立改动的副本,
|
||||||
|
* 因为「缺组件拒绝」用例会在副本里改名文件。
|
||||||
|
*/
|
||||||
|
function copyBundle(from, to) {
|
||||||
|
const cloned = spawnSync('/usr/bin/ditto', ['--clone', from, to], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
cloned.status === 0 &&
|
||||||
|
fs.existsSync(path.join(to, 'Contents/Info.plist'))
|
||||||
|
) {
|
||||||
|
return 'clone';
|
||||||
|
}
|
||||||
|
fs.cpSync(from, to, { recursive: true });
|
||||||
|
return 'copy';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 可执行名以包内 Info.plist 为准:它是稳定契约,但没必要在校验脚本里重复硬编码。 */
|
||||||
|
function readBundleExecutable(appPath) {
|
||||||
|
const plist = path.join(appPath, 'Contents/Info.plist');
|
||||||
|
const result = spawnSync(
|
||||||
|
'/usr/libexec/PlistBuddy',
|
||||||
|
['-c', 'Print :CFBundleExecutable', plist],
|
||||||
|
{ encoding: 'utf8' },
|
||||||
|
);
|
||||||
|
const name = (result.stdout ?? '').trim();
|
||||||
|
assert.ok(
|
||||||
|
name.length > 0,
|
||||||
|
`无法从 Info.plist 读取 CFBundleExecutable:${plist}`,
|
||||||
|
);
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
async function hashFile(file) {
|
async function hashFile(file) {
|
||||||
const hash = createHash('sha256');
|
const hash = createHash('sha256');
|
||||||
for await (const chunk of fs.createReadStream(file)) hash.update(chunk);
|
for await (const chunk of fs.createReadStream(file)) hash.update(chunk);
|
||||||
@@ -60,7 +129,7 @@ async function handshake(executable) {
|
|||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
const timer = setTimeout(
|
const timer = setTimeout(
|
||||||
() => reject(new Error('app-server 初始化超时')),
|
() => reject(new Error('app-server 初始化超时')),
|
||||||
15_000,
|
120_000,
|
||||||
);
|
);
|
||||||
const finish = (error) => {
|
const finish = (error) => {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
@@ -127,23 +196,39 @@ async function handshake(executable) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
fs.cpSync(source, app, { recursive: true });
|
const copiedWith = copyBundle(source, app);
|
||||||
const resources = path.join(app, 'Contents/Resources');
|
const resources = path.join(app, 'Contents/Resources');
|
||||||
const bundle = path.join(resources, 'coding-agent/mac-native');
|
const platform = architecture === 'arm64' ? 'darwin-arm64' : 'darwin-x64';
|
||||||
|
const bundle = path.join(resources, 'coding-agent/mac-native', platform);
|
||||||
const executable = path.join(bundle, 'bin/codex');
|
const executable = path.join(bundle, 'bin/codex');
|
||||||
const main = path.join(
|
const main = path.join(app, 'Contents/MacOS', readBundleExecutable(app));
|
||||||
app,
|
const mainArchitectures = run('/usr/bin/lipo', ['-archs', main]);
|
||||||
'Contents/MacOS/genarrative-ai-game-creator-shell',
|
assert.equal(mainArchitectures.status, 0);
|
||||||
);
|
assert.ok(mainArchitectures.stdout.split(/\s+/).includes(architecture));
|
||||||
|
if (requireUniversal) {
|
||||||
|
assert.deepEqual(mainArchitectures.stdout.trim().split(/\s+/).sort(), [
|
||||||
|
'arm64',
|
||||||
|
'x86_64',
|
||||||
|
]);
|
||||||
|
for (const platform of ['darwin-arm64', 'darwin-x64']) {
|
||||||
|
assert.ok(
|
||||||
|
fs.existsSync(
|
||||||
|
path.join(
|
||||||
|
resources,
|
||||||
|
'coding-agent/mac-native',
|
||||||
|
platform,
|
||||||
|
'manifest.json',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
const manifest = JSON.parse(
|
const manifest = JSON.parse(
|
||||||
fs.readFileSync(path.join(bundle, 'manifest.json'), 'utf8'),
|
fs.readFileSync(path.join(bundle, 'manifest.json'), 'utf8'),
|
||||||
);
|
);
|
||||||
assert.equal(manifest.schemaVersion, 'genarrative-codex-sidecar.v2');
|
assert.equal(manifest.schemaVersion, 'genarrative-codex-sidecar.v2');
|
||||||
assert.equal(
|
assert.equal(manifest.platform, platform);
|
||||||
manifest.platform,
|
assert.equal(manifest.version, `codex-cli ${pinnedCodexVersion}`);
|
||||||
process.arch === 'arm64' ? 'darwin-arm64' : 'darwin-x64',
|
|
||||||
);
|
|
||||||
assert.equal(manifest.version, 'codex-cli 0.147.0');
|
|
||||||
const components = [
|
const components = [
|
||||||
'bin/codex',
|
'bin/codex',
|
||||||
'bin/codex-code-mode-host',
|
'bin/codex-code-mode-host',
|
||||||
@@ -159,14 +244,41 @@ try {
|
|||||||
fs.accessSync(file, fs.constants.X_OK);
|
fs.accessSync(file, fs.constants.X_OK);
|
||||||
const arch = run('/usr/bin/lipo', ['-archs', file]);
|
const arch = run('/usr/bin/lipo', ['-archs', file]);
|
||||||
assert.equal(arch.status, 0, component);
|
assert.equal(arch.status, 0, component);
|
||||||
assert.equal(
|
assert.equal(arch.stdout.trim(), architecture, component);
|
||||||
arch.stdout.trim(),
|
|
||||||
process.arch === 'arm64' ? 'arm64' : 'x86_64',
|
|
||||||
component,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assert.ok(fs.existsSync(path.join(bundle, 'NOTICE.md')));
|
assert.ok(fs.existsSync(path.join(bundle, 'NOTICE.md')));
|
||||||
|
const nodeRoot = path.join(resources, 'game-runtime/node');
|
||||||
|
const nodeManifest = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(nodeRoot, 'manifest.json'), 'utf8'),
|
||||||
|
);
|
||||||
|
assert.equal(nodeManifest.schemaVersion, 'agc-node-runtime.v1');
|
||||||
|
assert.equal(nodeManifest.platform, 'darwin');
|
||||||
|
assert.equal(nodeManifest.arch, process.arch);
|
||||||
|
const runtimeFiles = fs
|
||||||
|
.readdirSync(nodeRoot, { recursive: true })
|
||||||
|
.filter(
|
||||||
|
(file) =>
|
||||||
|
fs.statSync(path.join(nodeRoot, file)).isFile() &&
|
||||||
|
file !== 'manifest.json',
|
||||||
|
);
|
||||||
|
assert.deepEqual(runtimeFiles.sort(), Object.keys(nodeManifest.files).sort());
|
||||||
|
for (const [file, digest] of Object.entries(nodeManifest.files)) {
|
||||||
|
assert.equal(await hashFile(path.join(nodeRoot, file)), digest, file);
|
||||||
|
}
|
||||||
|
assert.ok(nodeManifest.files['NODE-LICENSE']);
|
||||||
|
assert.ok(nodeManifest.files['node_modules/npm/LICENSE']);
|
||||||
|
assert.equal(
|
||||||
|
run(path.join(nodeRoot, 'node'), ['--version']).stdout.trim(),
|
||||||
|
nodeManifest.nodeVersion,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
run(path.join(nodeRoot, 'node'), [
|
||||||
|
path.join(nodeRoot, 'node_modules/npm/bin/npm-cli.js'),
|
||||||
|
'--version',
|
||||||
|
]).stdout.trim(),
|
||||||
|
nodeManifest.npmVersion,
|
||||||
|
);
|
||||||
const plugin = path.join(resources, 'plugins/agc-cocos-editor');
|
const plugin = path.join(resources, 'plugins/agc-cocos-editor');
|
||||||
for (const file of [
|
for (const file of [
|
||||||
'plugin.json',
|
'plugin.json',
|
||||||
@@ -177,10 +289,13 @@ try {
|
|||||||
}
|
}
|
||||||
const packageFiles = fs.readdirSync(resources, { recursive: true });
|
const packageFiles = fs.readdirSync(resources, { recursive: true });
|
||||||
assert.ok(
|
assert.ok(
|
||||||
!packageFiles.some((file) =>
|
!packageFiles.some(
|
||||||
/(^|\/)(\.env[^/]*|auth\.json|node_modules|target|\.git)(\/|$)|\.(exe|dll)$/.test(
|
(file) =>
|
||||||
file,
|
/(^|\/)(\.env[^/]*|auth\.json|target|\.git)(\/|$)|\.(exe|dll)$/.test(
|
||||||
),
|
file,
|
||||||
|
) ||
|
||||||
|
(/(^|\/)node_modules(\/|$)/.test(file) &&
|
||||||
|
!file.startsWith('game-runtime/node/node_modules/npm')),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
assert.equal(run(executable, ['--version']).stdout.trim(), manifest.version);
|
assert.equal(run(executable, ['--version']).stdout.trim(), manifest.version);
|
||||||
@@ -212,7 +327,7 @@ try {
|
|||||||
assert.notEqual(broken.status, 0);
|
assert.notEqual(broken.status, 0);
|
||||||
assert.match(`${broken.stdout}\n${broken.stderr}`, /Codex CLI 未安装/);
|
assert.match(`${broken.stdout}\n${broken.stderr}`, /Codex CLI 未安装/);
|
||||||
console.log(
|
console.log(
|
||||||
'PASS: 隔离安装包资源、架构、摘要、权限、正式 Codex 查找、app-server 握手及缺组件拒绝',
|
`PASS (${architecture}, 副本=${copiedWith}): 隔离安装包资源、架构、摘要、权限、正式 Codex 查找、app-server 握手及缺组件拒绝`,
|
||||||
);
|
);
|
||||||
console.log(
|
console.log(
|
||||||
'未验证:GUI、真实登录/Provider 对话、Cocos macOS 原生桥接;插件 Node 仍为外部前提',
|
'未验证:GUI、真实登录/Provider 对话、Cocos macOS 原生桥接;插件 Node 仍为外部前提',
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,134 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||||
|
const repoRoot = path.resolve(appRoot, '../..');
|
||||||
|
const platforms = {
|
||||||
|
arm64: 'aarch64-apple-darwin',
|
||||||
|
x64: 'x86_64-apple-darwin',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function lockedMacPackage(lock, arch, version) {
|
||||||
|
assert.ok(Object.hasOwn(platforms, arch), '未知 macOS 架构');
|
||||||
|
const alias = `@openai/codex-darwin-${arch}`;
|
||||||
|
const entry = lock.packages?.[`node_modules/${alias}`];
|
||||||
|
assert.equal(
|
||||||
|
entry?.version,
|
||||||
|
`${version}-darwin-${arch}`,
|
||||||
|
'原生依赖必须与应用锁定版本一致',
|
||||||
|
);
|
||||||
|
assert.deepEqual(entry.os, ['darwin']);
|
||||||
|
assert.deepEqual(entry.cpu, [arch]);
|
||||||
|
const url = new URL(entry.resolved);
|
||||||
|
assert.equal(url.protocol, 'https:');
|
||||||
|
assert.equal(
|
||||||
|
url.hostname,
|
||||||
|
'registry.npmjs.org',
|
||||||
|
'只下载锁定的官方 npm 原生包',
|
||||||
|
);
|
||||||
|
assert.equal(url.username + url.password + url.search + url.hash, '');
|
||||||
|
assert.match(entry.integrity, /^sha512-[A-Za-z0-9+/]+={0,2}$/);
|
||||||
|
return { alias, target: platforms[arch], ...entry };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyPackageIntegrity(bytes, expected) {
|
||||||
|
const actual = `sha512-${createHash('sha512').update(bytes).digest('base64')}`;
|
||||||
|
assert.equal(actual, expected, 'Codex 下载包 lockfile integrity 不匹配');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateArchiveListing(listing) {
|
||||||
|
const files = listing.trim().split(/\r?\n/u);
|
||||||
|
assert.ok(files.length > 0);
|
||||||
|
for (const file of files) {
|
||||||
|
assert.ok(file.startsWith('package/'), '原生包必须只有 package 根目录');
|
||||||
|
assert.ok(
|
||||||
|
!file.split('/').includes('..') && !file.includes('\\'),
|
||||||
|
'压缩包路径不安全',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function prepareMacosCodex() {
|
||||||
|
assert.equal(process.platform, 'darwin', '该入口仅用于 macOS 构建机');
|
||||||
|
const lock = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(repoRoot, 'package-lock.json'), 'utf8'),
|
||||||
|
);
|
||||||
|
const app = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(appRoot, 'package.json'), 'utf8'),
|
||||||
|
);
|
||||||
|
const version = app.devDependencies['@openai/codex'];
|
||||||
|
assert.match(version, /^\d+\.\d+\.\d+$/u, 'Codex 必须锁定精确版本');
|
||||||
|
const cache = path.join(appRoot, 'src-tauri/target/.macos-native-cache');
|
||||||
|
fs.mkdirSync(cache, { recursive: true });
|
||||||
|
for (const arch of Object.keys(platforms)) {
|
||||||
|
const entry = lockedMacPackage(lock, arch, version);
|
||||||
|
const archive = path.join(cache, `codex-${entry.version}.tgz`);
|
||||||
|
if (!fs.existsSync(archive)) {
|
||||||
|
const response = await fetch(entry.resolved, {
|
||||||
|
signal: AbortSignal.timeout(300_000),
|
||||||
|
});
|
||||||
|
assert.ok(response.ok, `原生包下载失败 HTTP ${response.status}`);
|
||||||
|
const bytes = Buffer.from(await response.arrayBuffer());
|
||||||
|
verifyPackageIntegrity(bytes, entry.integrity);
|
||||||
|
const partial = `${archive}.${process.pid}.tmp`;
|
||||||
|
fs.writeFileSync(partial, bytes);
|
||||||
|
fs.renameSync(partial, archive);
|
||||||
|
}
|
||||||
|
verifyPackageIntegrity(fs.readFileSync(archive), entry.integrity);
|
||||||
|
validateArchiveListing(
|
||||||
|
execFileSync('tar', ['-tzf', archive], { encoding: 'utf8' }),
|
||||||
|
);
|
||||||
|
// 拒绝链接、设备及其它特殊条目,不能让 tar 在包目录之外写入。
|
||||||
|
const entries = execFileSync('tar', ['-tvzf', archive], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
});
|
||||||
|
assert.ok(
|
||||||
|
entries
|
||||||
|
.trim()
|
||||||
|
.split(/\r?\n/u)
|
||||||
|
.every((line) => /^[-d]/u.test(line)),
|
||||||
|
'原生包禁止链接或特殊文件',
|
||||||
|
);
|
||||||
|
const parent = path.join(repoRoot, 'node_modules/@openai');
|
||||||
|
fs.mkdirSync(parent, { recursive: true });
|
||||||
|
const stage = fs.mkdtempSync(path.join(parent, '.mac-native-'));
|
||||||
|
try {
|
||||||
|
execFileSync(
|
||||||
|
'tar',
|
||||||
|
['-xzf', archive, '-C', stage, '--strip-components=1'],
|
||||||
|
{ stdio: 'pipe' },
|
||||||
|
);
|
||||||
|
const metadata = JSON.parse(
|
||||||
|
fs.readFileSync(
|
||||||
|
path.join(stage, 'vendor', entry.target, 'codex-package.json'),
|
||||||
|
'utf8',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.equal(metadata.version, version);
|
||||||
|
assert.equal(metadata.target, entry.target);
|
||||||
|
assert.equal(metadata.entrypoint, 'bin/codex');
|
||||||
|
const destination = path.join(repoRoot, 'node_modules', entry.alias);
|
||||||
|
assert.ok(
|
||||||
|
!fs.existsSync(destination) ||
|
||||||
|
!fs.lstatSync(destination).isSymbolicLink(),
|
||||||
|
'拒绝覆盖链接依赖',
|
||||||
|
);
|
||||||
|
fs.rmSync(destination, { recursive: true, force: true });
|
||||||
|
fs.renameSync(stage, destination);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(stage, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
console.log(`[macOS Codex] ${entry.version}: lockfile integrity 已验证`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
process.argv[1] &&
|
||||||
|
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
||||||
|
) {
|
||||||
|
await prepareMacosCodex();
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
lockedMacPackage,
|
||||||
|
validateArchiveListing,
|
||||||
|
verifyPackageIntegrity,
|
||||||
|
} from './prepare-macos-codex.mjs';
|
||||||
|
|
||||||
|
const lock = JSON.parse(
|
||||||
|
fs.readFileSync(new URL('../../../package-lock.json', import.meta.url)),
|
||||||
|
);
|
||||||
|
const version = JSON.parse(
|
||||||
|
fs.readFileSync(new URL('../package.json', import.meta.url)),
|
||||||
|
).devDependencies['@openai/codex'];
|
||||||
|
|
||||||
|
test('both macOS dependencies resolve from the lockfile without floating versions', () => {
|
||||||
|
assert.equal(
|
||||||
|
lockedMacPackage(lock, 'arm64', version).target,
|
||||||
|
'aarch64-apple-darwin',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
lockedMacPackage(lock, 'x64', version).target,
|
||||||
|
'x86_64-apple-darwin',
|
||||||
|
);
|
||||||
|
assert.throws(() => lockedMacPackage(lock, 'other', version));
|
||||||
|
assert.throws(() => lockedMacPackage(lock, 'x64', '0.0.0'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('native package integrity rejects tampering', () => {
|
||||||
|
const bytes = Buffer.from('pinned package');
|
||||||
|
const integrity = `sha512-${createHash('sha512').update(bytes).digest('base64')}`;
|
||||||
|
verifyPackageIntegrity(bytes, integrity);
|
||||||
|
assert.throws(() =>
|
||||||
|
verifyPackageIntegrity(Buffer.from('modified'), integrity),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('archive traversal and non-package entries fail closed', () => {
|
||||||
|
validateArchiveListing(
|
||||||
|
'package/package.json\npackage/vendor/target/bin/codex\n',
|
||||||
|
);
|
||||||
|
for (const listing of [
|
||||||
|
'',
|
||||||
|
'/tmp/payload',
|
||||||
|
'package/../private',
|
||||||
|
'other/file',
|
||||||
|
'package/..\\file',
|
||||||
|
]) {
|
||||||
|
assert.throws(() => validateArchiveListing(listing));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CI pipeline is manual, publishes the macOS partition and never reuses a developer workspace', () => {
|
||||||
|
const pipeline = fs.readFileSync(
|
||||||
|
new URL(
|
||||||
|
'../../../jenkins/Jenkinsfile.ai-game-creator-shell-macos-build',
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
for (const required of [
|
||||||
|
'genarrative-agc-macos',
|
||||||
|
'disableConcurrentBuilds()',
|
||||||
|
'$AGC_AGENT_ROOT',
|
||||||
|
'StrictHostKeyChecking=yes',
|
||||||
|
'git merge-base --is-ancestor',
|
||||||
|
'allowEmptyArchive: false',
|
||||||
|
"string(name: 'AGC_UPDATE_CHANNEL', defaultValue: 'dev'",
|
||||||
|
'AGC_UPDATE_CHANNEL=${params.AGC_UPDATE_CHANNEL}',
|
||||||
|
"string(credentialsId: 'AgcUpdaterSigningKey'",
|
||||||
|
"string(credentialsId: 'AgcUpdaterSigningKeyPassword'",
|
||||||
|
"string(credentialsId: 'AliyunAccessKeyId'",
|
||||||
|
"string(credentialsId: 'AliyunaccessKeySecret'",
|
||||||
|
'AGC_RELEASE_VERSION',
|
||||||
|
'OSSUTIL_BIN',
|
||||||
|
// 并行度必须可调:节点是共用机器,写死容易把整机压满或反过来浪费一半核心。
|
||||||
|
"string(name: 'CARGO_BUILD_JOBS', defaultValue: '8'",
|
||||||
|
'CARGO_BUILD_JOBS=${params.CARGO_BUILD_JOBS}',
|
||||||
|
// Agent 工作区按约定匹配,不写死节点名:节点改名(-local → -01)后守卫仍成立。
|
||||||
|
'"$HOME"/Library/Jenkins/agents/*/workspace/*',
|
||||||
|
// 上一次发布的 commit 落在 master 上,取到它更新摘要才不会退化成「最近提交」。
|
||||||
|
'refs/heads/master:refs/remotes/origin/master',
|
||||||
|
]) {
|
||||||
|
assert.ok(pipeline.includes(required), required);
|
||||||
|
}
|
||||||
|
assert.ok(
|
||||||
|
!pipeline.includes('genarrative-agc-macos-local'),
|
||||||
|
'Jenkinsfile 不得写死具体节点名',
|
||||||
|
);
|
||||||
|
// 这条管线是正式发布入口(与 Windows 对称):默认真发布,演练需显式勾选。
|
||||||
|
assert.match(
|
||||||
|
pipeline,
|
||||||
|
/booleanParam\(name: 'AGC_RELEASE_DRY_RUN', defaultValue: false/u,
|
||||||
|
'Channel 发布默认必须是真发布,演练只能显式勾选',
|
||||||
|
);
|
||||||
|
// 节点是办公机:离线期间排队的旧构建必须自行让位,且跳过要覆盖后续全部阶段。
|
||||||
|
assert.match(
|
||||||
|
pipeline,
|
||||||
|
/booleanParam\(name: 'SKIP_IF_SUPERSEDED', defaultValue: false/u,
|
||||||
|
);
|
||||||
|
// 仓库文件不得出现节点用户名/个人 Home 路径:换机或改名后必须仍然可用。
|
||||||
|
assert.ok(
|
||||||
|
!pipeline.includes('/Users/'),
|
||||||
|
'Jenkinsfile 不得写死个人 Home 路径,工具链位置应按 $HOME 展开',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
pipeline.includes('export PATH="$HOME/'),
|
||||||
|
'PATH 必须在 shell 步骤里按 $HOME 展开',
|
||||||
|
);
|
||||||
|
// 超时必须高于实测最慢(78 分钟冷构建 + 共用机器),否则会被中断在链接阶段。
|
||||||
|
assert.ok(
|
||||||
|
pipeline.includes('timeout(time: 150'),
|
||||||
|
'构建超时上限必须留出冷构建余量',
|
||||||
|
);
|
||||||
|
for (const diagnostic of ['macOS 发布失败', '被中断']) {
|
||||||
|
assert.ok(pipeline.includes(diagnostic), diagnostic);
|
||||||
|
}
|
||||||
|
assert.ok(
|
||||||
|
pipeline.includes('.jenkins-superseded-by'),
|
||||||
|
'必须记录被推进的标记供后续阶段判定',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(pipeline.match(/env\.AGC_BUILD_SUPERSEDED != 'true'/gu) ?? []).length,
|
||||||
|
3,
|
||||||
|
'Toolchain / Package / Archive 三个阶段都必须按跳过标记收口',
|
||||||
|
);
|
||||||
|
for (const forbidden of [
|
||||||
|
'triggers {',
|
||||||
|
'cron(',
|
||||||
|
'pollSCM(',
|
||||||
|
'git clean -fdx',
|
||||||
|
// release:upload 会重新触发一次完整构建,既翻倍耗时也绕过本 Job 的验签门禁。
|
||||||
|
'release:upload',
|
||||||
|
]) {
|
||||||
|
assert.ok(!pipeline.includes(forbidden), forbidden);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('macOS release entry verifies the updater signature before uploading', () => {
|
||||||
|
const entry = fs.readFileSync(
|
||||||
|
new URL('./build-macos-ci.mjs', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const verifyIndex = entry.indexOf('verifyUpdaterSignature({');
|
||||||
|
const uploadIndex = entry.indexOf('uploadReleaseArtifacts(release');
|
||||||
|
assert.ok(verifyIndex > 0, '必须调用更新包验签');
|
||||||
|
assert.ok(uploadIndex > 0, '必须调用 OSS 上传');
|
||||||
|
assert.ok(verifyIndex < uploadIndex, '必须先验签再上传,验不过不得写 OSS');
|
||||||
|
// 无签名私钥时禁止构建:未签名的更新包会被客户端一律拒绝。
|
||||||
|
assert.ok(entry.includes('TAURI_SIGNING_PRIVATE_KEY'));
|
||||||
|
// `--no-sign` 会连带跳过 updater 的 minisign 签名,产物将没有 .sig,入口不得传它。
|
||||||
|
assert.ok(
|
||||||
|
!entry.includes("'--no-sign'"),
|
||||||
|
'--no-sign 会同时跳过 updater 签名,产物缺少 .sig',
|
||||||
|
);
|
||||||
|
// workspace 会跨构建保留产物:必须先删本次要写的对象,否则会因同名 DMG 失败,
|
||||||
|
// 或让上一轮遗留的 .sig 让验签门禁误通过。
|
||||||
|
for (const required of [
|
||||||
|
// 清理对象用派生的产品名算出来,而不是写死某个名字。
|
||||||
|
'${updaterArtifactName}.sig',
|
||||||
|
'${firstInstallName}.sha256',
|
||||||
|
'fs.rmSync(stale, { force: true })',
|
||||||
|
"'-ov'",
|
||||||
|
]) {
|
||||||
|
assert.ok(entry.includes(required), required);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('macOS release entry and smoke script derive product names from config and the bundle', () => {
|
||||||
|
const entry = fs.readFileSync(
|
||||||
|
new URL('./build-macos-ci.mjs', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
// 产品名决定 *.app、updater 归档与 DMG 卷名:写死会在改名后静默找错对象。
|
||||||
|
assert.ok(entry.includes('readProductName'), '入口必须从 Tauri 配置读产品名');
|
||||||
|
assert.ok(!entry.includes('陶泥儿'), 'macOS 发布入口不得写死产品名');
|
||||||
|
assert.ok(
|
||||||
|
entry.includes('_${version}_universal.dmg'),
|
||||||
|
'首装包名必须保留清单侧唯一匹配所需的后缀',
|
||||||
|
);
|
||||||
|
|
||||||
|
const smoke = fs.readFileSync(
|
||||||
|
new URL('./check-macos-bundle.mjs', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
assert.ok(!smoke.includes('陶泥儿'), '校验脚本不得写死产品名');
|
||||||
|
for (const required of [
|
||||||
|
'path.basename(source)',
|
||||||
|
'Print :CFBundleExecutable',
|
||||||
|
"'--clone'",
|
||||||
|
]) {
|
||||||
|
assert.ok(smoke.includes(required), required);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# 只读取 Windows Installer 已登记的同版本 Node.js 缓存;不执行安装、不访问网络。
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
|
||||||
|
$expectedVersion = $env:AGC_STAGING_NODE_VERSION
|
||||||
|
if ($expectedVersion -notmatch '^\d+\.\d+\.\d+$') { throw 'Invalid Node version' }
|
||||||
|
|
||||||
|
# WinVerifyTrust 强制仅使用本地证书缓存,禁止吊销/证书 URL 网络检索。
|
||||||
|
Add-Type -TypeDefinition @'
|
||||||
|
using System;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
public static class AgcOfflineSignature {
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
struct FileInfo { public uint Size; public IntPtr Path; public IntPtr File; public IntPtr Subject; }
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
struct TrustData {
|
||||||
|
public uint Size; public IntPtr Policy; public IntPtr Sip; public uint Ui;
|
||||||
|
public uint Revocation; public uint Choice; public IntPtr File;
|
||||||
|
public uint StateAction; public IntPtr State; public IntPtr Url;
|
||||||
|
public uint Flags; public uint Context;
|
||||||
|
}
|
||||||
|
[DllImport("wintrust.dll", ExactSpelling=true, PreserveSig=true)]
|
||||||
|
static extern int WinVerifyTrust(IntPtr window, ref Guid action, ref TrustData data);
|
||||||
|
public static bool Verify(string path) {
|
||||||
|
IntPtr name = Marshal.StringToCoTaskMemUni(path);
|
||||||
|
IntPtr file = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(FileInfo)));
|
||||||
|
try {
|
||||||
|
var info = new FileInfo { Size=(uint)Marshal.SizeOf(typeof(FileInfo)), Path=name };
|
||||||
|
Marshal.StructureToPtr(info, file, false);
|
||||||
|
var data = new TrustData { Size=(uint)Marshal.SizeOf(typeof(TrustData)), Ui=2, Choice=1, File=file, Flags=0x1000|0x10 };
|
||||||
|
var action = new Guid("00AAC56B-CD44-11d0-8CC2-00C04FC295EE");
|
||||||
|
return WinVerifyTrust(new IntPtr(-1), ref action, ref data) == 0;
|
||||||
|
} finally { Marshal.FreeHGlobal(file); Marshal.FreeCoTaskMem(name); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
|
||||||
|
function Read-Property($database, [string]$name) {
|
||||||
|
$view = $database.OpenView("SELECT ``Value`` FROM ``Property`` WHERE ``Property`` = '$name'")
|
||||||
|
try {
|
||||||
|
[void]$view.Execute()
|
||||||
|
$record = $view.Fetch()
|
||||||
|
if ($null -ne $record) { return $record.StringData(1) }
|
||||||
|
return ''
|
||||||
|
} finally { [void]$view.Close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
$installer = New-Object -ComObject WindowsInstaller.Installer
|
||||||
|
$cacheRoot = [System.IO.Path]::GetFullPath((Join-Path ([Environment]::GetFolderPath('Windows')) 'Installer'))
|
||||||
|
$registrations = @(
|
||||||
|
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*',
|
||||||
|
'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
|
||||||
|
'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*'
|
||||||
|
)
|
||||||
|
$products = Get-ItemProperty $registrations -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object { $_.DisplayName -eq 'Node.js' -and $_.DisplayVersion -eq $expectedVersion -and $_.PSChildName -match '^\{[0-9A-Fa-f-]{36}\}$' } |
|
||||||
|
Select-Object -ExpandProperty PSChildName -Unique
|
||||||
|
foreach ($product in $products) {
|
||||||
|
try {
|
||||||
|
if ($installer.ProductInfo($product, 'ProductName') -ne 'Node.js') { continue }
|
||||||
|
if ($installer.ProductInfo($product, 'VersionString') -ne $expectedVersion) { continue }
|
||||||
|
$package = [System.IO.Path]::GetFullPath($installer.ProductInfo($product, 'LocalPackage'))
|
||||||
|
if (-not [string]::Equals([System.IO.Path]::GetDirectoryName($package), $cacheRoot, [StringComparison]::OrdinalIgnoreCase)) { continue }
|
||||||
|
if ([System.IO.Path]::GetExtension($package) -ne '.msi') { continue }
|
||||||
|
$entry = Get-Item -LiteralPath $package -Force
|
||||||
|
$cache = Get-Item -LiteralPath $cacheRoot -Force
|
||||||
|
if (($entry.Attributes -band [IO.FileAttributes]::ReparsePoint) -or ($cache.Attributes -band [IO.FileAttributes]::ReparsePoint)) { continue }
|
||||||
|
if (-not [AgcOfflineSignature]::Verify($package)) { continue }
|
||||||
|
$certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new([System.Security.Cryptography.X509Certificates.X509Certificate]::CreateFromSignedFile($package))
|
||||||
|
if ($certificate.Subject -notmatch '(^|,\s*)O=OpenJS Foundation(,|$)') { continue }
|
||||||
|
$database = $installer.OpenDatabase($package, 0)
|
||||||
|
if ((Read-Property $database 'ProductName') -ne 'Node.js') { continue }
|
||||||
|
if ((Read-Property $database 'ProductVersion') -ne $expectedVersion) { continue }
|
||||||
|
if ((Read-Property $database 'ProductCode') -ne $product) { continue }
|
||||||
|
$manufacturer = Read-Property $database 'Manufacturer'
|
||||||
|
if ($manufacturer -notin @('Node.js Foundation', 'OpenJS Foundation')) { continue }
|
||||||
|
$view = $database.OpenView('SELECT `Text` FROM `Control` WHERE `Dialog_` = ''LicenseAgreementDlg'' AND `Control` = ''LicenseText''')
|
||||||
|
try {
|
||||||
|
[void]$view.Execute()
|
||||||
|
$record = $view.Fetch()
|
||||||
|
if ($null -eq $record) { continue }
|
||||||
|
$content = $record.StringData(1)
|
||||||
|
} finally { [void]$view.Close() }
|
||||||
|
if (-not $content.StartsWith('{\rtf') -or $content.Length -gt 1048576) { continue }
|
||||||
|
if (-not $content.Contains('Node.js') -or -not $content.Contains('Permission is hereby granted')) { continue }
|
||||||
|
[pscustomobject]@{
|
||||||
|
productName = 'Node.js'; version = $expectedVersion; manufacturer = $manufacturer
|
||||||
|
signatureVerified = $true; signer = 'OpenJS Foundation'; format = 'rtf'; content = $content
|
||||||
|
} | ConvertTo-Json -Compress
|
||||||
|
exit 0
|
||||||
|
} catch {
|
||||||
|
# 单个损坏/无权限缓存不能绕过验证;继续查找其它已登记候选。
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw 'No matching trusted installed Node.js license'
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
advanceRunner,
|
||||||
|
createRunner,
|
||||||
|
createRunnerRandom,
|
||||||
|
measuredRunnerFairWindowMs,
|
||||||
|
restartRunner,
|
||||||
|
RUNNER_SEED,
|
||||||
|
runnerProjection,
|
||||||
|
runnerSeedFromSearch,
|
||||||
|
setRunnerInput,
|
||||||
|
startRunner,
|
||||||
|
stepRunner,
|
||||||
|
} from '../src-tauri/resources/agc-skills/agc-browser-playtest/references/runner-physics.mjs';
|
||||||
|
|
||||||
|
function jump(
|
||||||
|
settings,
|
||||||
|
holdTicks,
|
||||||
|
functions = {
|
||||||
|
createRunner,
|
||||||
|
startRunner,
|
||||||
|
setRunnerInput,
|
||||||
|
stepRunner,
|
||||||
|
runnerProjection,
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const state = functions.createRunner(RUNNER_SEED, settings);
|
||||||
|
functions.startRunner(state);
|
||||||
|
functions.setRunnerInput(state, 'jump', true);
|
||||||
|
const samples = [];
|
||||||
|
for (let tick = 0; tick < 150; tick += 1) {
|
||||||
|
if (tick === holdTicks) functions.setRunnerInput(state, 'jump', false);
|
||||||
|
functions.stepRunner(state);
|
||||||
|
samples.push(functions.runnerProjection(state));
|
||||||
|
if (state.onGround && state.jumpCount > 0) break;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
samples,
|
||||||
|
height: Math.max(
|
||||||
|
...samples.map((sample) => sample.groundY - sample.playerY),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('fixed seed, fixed steps and independent decoration produce the same course', () => {
|
||||||
|
assert.equal(runnerSeedFromSearch('?agcPlaytestSeed=20260920'), RUNNER_SEED);
|
||||||
|
assert.throws(() =>
|
||||||
|
runnerSeedFromSearch('?agcPlaytestSeed=1&agcPlaytestSeed=2'),
|
||||||
|
);
|
||||||
|
assert.throws(() => runnerSeedFromSearch('?agcPlaytestSeed=4294967296'));
|
||||||
|
const first = createRunner();
|
||||||
|
const decoration = createRunnerRandom(71);
|
||||||
|
for (let index = 0; index < 100; index += 1) decoration();
|
||||||
|
const second = createRunner();
|
||||||
|
assert.deepEqual(first.course, second.course);
|
||||||
|
assert.notEqual(
|
||||||
|
first.courseFingerprint,
|
||||||
|
createRunner(20260921).courseFingerprint,
|
||||||
|
);
|
||||||
|
startRunner(first);
|
||||||
|
startRunner(second);
|
||||||
|
for (let index = 0; index < 60; index += 1) advanceRunner(first, 1 / 60);
|
||||||
|
for (let index = 0; index < 120; index += 1) advanceRunner(second, 1 / 120);
|
||||||
|
assert.deepEqual(runnerProjection(first), runnerProjection(second));
|
||||||
|
const course = structuredClone(first.course);
|
||||||
|
restartRunner(first);
|
||||||
|
assert.equal(first.phase, 'ready');
|
||||||
|
assert.equal(first.simulationTick, 0);
|
||||||
|
assert.equal(first.jumpCount, 0);
|
||||||
|
assert.deepEqual(first.course, course);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('short jump cuts once, long jump rises higher, release restores slide collision size', () => {
|
||||||
|
const short = jump({}, 4);
|
||||||
|
const long = jump({}, 18);
|
||||||
|
assert.equal(short.state.jumpCount, 1);
|
||||||
|
assert.equal(short.state.jumpCutCount, 1);
|
||||||
|
assert.ok(long.height > short.height + 10);
|
||||||
|
assert.ok(short.state.onGround && long.state.onGround);
|
||||||
|
const state = short.state;
|
||||||
|
setRunnerInput(state, 'slide', true);
|
||||||
|
stepRunner(state);
|
||||||
|
assert.ok(state.sliding && state.playerHeight < state.config.playerHeight);
|
||||||
|
setRunnerInput(state, 'slide', false);
|
||||||
|
stepRunner(state);
|
||||||
|
assert.ok(!state.sliding && !state.slideHeld);
|
||||||
|
assert.equal(state.playerHeight, state.config.playerHeight);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('baseline has at least 180ms clearance while the original narrow-window parameters fail', () => {
|
||||||
|
const baseline = jump({}, 4);
|
||||||
|
assert.ok(
|
||||||
|
measuredRunnerFairWindowMs(
|
||||||
|
baseline.samples,
|
||||||
|
49,
|
||||||
|
baseline.state.course[0],
|
||||||
|
) >= 180,
|
||||||
|
);
|
||||||
|
const original = jump(
|
||||||
|
{
|
||||||
|
gravity: 2300,
|
||||||
|
jumpVelocity: 800,
|
||||||
|
releaseVelocity: 720,
|
||||||
|
playerWidth: 48.9,
|
||||||
|
},
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
const window = measuredRunnerFairWindowMs(
|
||||||
|
original.samples,
|
||||||
|
48.9,
|
||||||
|
original.state.course[0],
|
||||||
|
);
|
||||||
|
assert.ok(window < 180, `original jump window ${window}ms must fail`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('regression mutations expose repeated jump-cut and ignored slide release', async () => {
|
||||||
|
const source = fs.readFileSync(
|
||||||
|
new URL(
|
||||||
|
'../src-tauri/resources/agc-skills/agc-browser-playtest/references/runner-physics.mjs',
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const repeatedCut = source.replace('&& !state.jumpCut)', ')');
|
||||||
|
assert.notEqual(repeatedCut, source);
|
||||||
|
const broken = await import(
|
||||||
|
`data:text/javascript;base64,${Buffer.from(repeatedCut).toString('base64')}`
|
||||||
|
);
|
||||||
|
assert.ok(jump({}, 4, broken).state.jumpCutCount > 1);
|
||||||
|
const noRelease = source.replace(
|
||||||
|
'state.slideHeld = Boolean(held);',
|
||||||
|
'if (held) state.slideHeld = true;',
|
||||||
|
);
|
||||||
|
const stuck = await import(
|
||||||
|
`data:text/javascript;base64,${Buffer.from(noRelease).toString('base64')}`
|
||||||
|
);
|
||||||
|
const state = stuck.createRunner();
|
||||||
|
stuck.startRunner(state);
|
||||||
|
stuck.setRunnerInput(state, 'slide', true);
|
||||||
|
stuck.stepRunner(state);
|
||||||
|
stuck.setRunnerInput(state, 'slide', false);
|
||||||
|
stuck.stepRunner(state);
|
||||||
|
assert.equal(state.sliding, true);
|
||||||
|
});
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||||
|
export const nodeRuntimeSchema = 'agc-node-runtime.v1';
|
||||||
|
|
||||||
|
export function readInstalledNodeLicense(
|
||||||
|
version,
|
||||||
|
{
|
||||||
|
execute = execFileSync,
|
||||||
|
powershellPath = path.join(
|
||||||
|
process.env.SystemRoot || 'C:/Windows',
|
||||||
|
'System32/WindowsPowerShell/v1.0/powershell.exe',
|
||||||
|
),
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const result = JSON.parse(
|
||||||
|
execute(
|
||||||
|
powershellPath,
|
||||||
|
[
|
||||||
|
'-NoProfile',
|
||||||
|
'-NonInteractive',
|
||||||
|
'-Command',
|
||||||
|
'& ([scriptblock]::Create([IO.File]::ReadAllText($env:AGC_LICENSE_READER_SCRIPT, [Text.Encoding]::UTF8)))',
|
||||||
|
],
|
||||||
|
{
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: 20_000,
|
||||||
|
maxBuffer: 2 * 1024 * 1024,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
AGC_STAGING_NODE_VERSION: version,
|
||||||
|
AGC_LICENSE_READER_SCRIPT: fileURLToPath(
|
||||||
|
new URL('./read-installed-node-license.ps1', import.meta.url),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
result.productName !== 'Node.js' ||
|
||||||
|
result.version !== version ||
|
||||||
|
!['Node.js Foundation', 'OpenJS Foundation'].includes(
|
||||||
|
result.manufacturer,
|
||||||
|
) ||
|
||||||
|
result.signatureVerified !== true ||
|
||||||
|
result.signer !== 'OpenJS Foundation' ||
|
||||||
|
result.format !== 'rtf' ||
|
||||||
|
typeof result.content !== 'string' ||
|
||||||
|
!result.content.startsWith('{\\rtf') ||
|
||||||
|
!result.content.includes('Node.js') ||
|
||||||
|
!result.content.includes('Permission is hereby granted')
|
||||||
|
) {
|
||||||
|
throw new Error('已安装 Node 许可的版本、产品或签名身份不匹配');
|
||||||
|
}
|
||||||
|
return result.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function targetRuntime(target) {
|
||||||
|
const targets = {
|
||||||
|
'x86_64-pc-windows-msvc': ['win32', 'x64'],
|
||||||
|
'aarch64-apple-darwin': ['darwin', 'arm64'],
|
||||||
|
'x86_64-apple-darwin': ['darwin', 'x64'],
|
||||||
|
};
|
||||||
|
const value = targets[target];
|
||||||
|
if (!value) throw new Error(`Node 运行时不支持发布目标:${target}`);
|
||||||
|
return { platform: value[0], arch: value[1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function inside(root, file) {
|
||||||
|
const relative = path.relative(root, file);
|
||||||
|
return (
|
||||||
|
relative !== '..' &&
|
||||||
|
!relative.startsWith(`..${path.sep}`) &&
|
||||||
|
!path.isAbsolute(relative)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function packageFiles(root, directory = root) {
|
||||||
|
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||||
|
const file = path.join(directory, entry.name);
|
||||||
|
if (entry.isSymbolicLink()) throw new Error('运行时资源不能包含符号链接');
|
||||||
|
if (entry.isDirectory()) return packageFiles(root, file);
|
||||||
|
if (!entry.isFile()) throw new Error('运行时资源包含非普通文件');
|
||||||
|
return [file];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function replacementIdentity(destination) {
|
||||||
|
let stat;
|
||||||
|
try {
|
||||||
|
stat = fs.lstatSync(destination);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') return null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (stat.isSymbolicLink() || !stat.isDirectory())
|
||||||
|
throw new Error('拒绝覆盖链接或非目录运行时目标');
|
||||||
|
if (fs.readdirSync(destination).length === 0) return stat;
|
||||||
|
let manifest;
|
||||||
|
try {
|
||||||
|
const file = path.join(destination, 'manifest.json');
|
||||||
|
const metadata = fs.lstatSync(file);
|
||||||
|
if (
|
||||||
|
!metadata.isFile() ||
|
||||||
|
metadata.isSymbolicLink() ||
|
||||||
|
metadata.size > 4 * 1024 * 1024
|
||||||
|
)
|
||||||
|
throw new Error('manifest invalid');
|
||||||
|
manifest = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||||
|
} catch {
|
||||||
|
throw new Error('拒绝覆盖非本工具生成的运行时目录');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!manifest ||
|
||||||
|
typeof manifest !== 'object' ||
|
||||||
|
!manifest.files ||
|
||||||
|
typeof manifest.files !== 'object' ||
|
||||||
|
Array.isArray(manifest.files)
|
||||||
|
)
|
||||||
|
throw new Error('拒绝覆盖没有合法运行时清单的目录');
|
||||||
|
const entries = Object.entries(manifest.files || {});
|
||||||
|
const validFiles =
|
||||||
|
entries.length > 0 &&
|
||||||
|
entries.length <= 20_000 &&
|
||||||
|
entries.every(
|
||||||
|
([file, digest]) =>
|
||||||
|
typeof digest === 'string' &&
|
||||||
|
/^[0-9a-f]{64}$/u.test(digest) &&
|
||||||
|
!file.includes('\\') &&
|
||||||
|
!file.includes(':') &&
|
||||||
|
file.split('/').every((part) => part && part !== '.' && part !== '..'),
|
||||||
|
);
|
||||||
|
const nodeFile = manifest.platform === 'win32' ? 'node.exe' : 'node';
|
||||||
|
if (
|
||||||
|
manifest.schemaVersion !== nodeRuntimeSchema ||
|
||||||
|
!['win32', 'darwin'].includes(manifest.platform) ||
|
||||||
|
!['x64', 'arm64'].includes(manifest.arch) ||
|
||||||
|
!/^v\d+\.\d+\.\d+$/u.test(manifest.nodeVersion) ||
|
||||||
|
!/^\d+\.\d+\.\d+$/u.test(manifest.npmVersion) ||
|
||||||
|
!validFiles ||
|
||||||
|
!manifest.files[nodeFile] ||
|
||||||
|
!manifest.files['node_modules/npm/bin/npm-cli.js'] ||
|
||||||
|
!manifest.files['node_modules/npm/LICENSE'] ||
|
||||||
|
!(manifest.files['NODE-LICENSE'] || manifest.files['NODE-LICENSE.rtf'])
|
||||||
|
) {
|
||||||
|
throw new Error('拒绝覆盖没有合法运行时清单的目录');
|
||||||
|
}
|
||||||
|
// 容许重建损坏/缺文件的资源,但不删除后来混入的其它文件或链接。
|
||||||
|
for (const file of packageFiles(destination)) {
|
||||||
|
const relative = path.relative(destination, file).split(path.sep).join('/');
|
||||||
|
if (
|
||||||
|
relative !== 'manifest.json' &&
|
||||||
|
!Object.hasOwn(manifest.files, relative)
|
||||||
|
)
|
||||||
|
throw new Error('拒绝覆盖包含未登记文件的运行时目录');
|
||||||
|
}
|
||||||
|
return stat;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameDirectoryIdentity(before, after) {
|
||||||
|
return before === null
|
||||||
|
? after === null
|
||||||
|
: after !== null &&
|
||||||
|
before.dev === after.dev &&
|
||||||
|
before.ino === after.ino &&
|
||||||
|
before.birthtimeMs === after.birthtimeMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupStaging(staging, parent, prefix, identity) {
|
||||||
|
if (
|
||||||
|
path.dirname(staging) !== parent ||
|
||||||
|
!path.basename(staging).startsWith(prefix) ||
|
||||||
|
fs.realpathSync(parent) !== parent
|
||||||
|
)
|
||||||
|
throw new Error('拒绝清理非本次创建的 staging 路径');
|
||||||
|
let stat;
|
||||||
|
try {
|
||||||
|
stat = fs.lstatSync(staging);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') return;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
stat.isSymbolicLink() ||
|
||||||
|
!stat.isDirectory() ||
|
||||||
|
!sameDirectoryIdentity(identity, stat)
|
||||||
|
)
|
||||||
|
throw new Error('staging 目录身份发生变化,拒绝递归清理');
|
||||||
|
fs.rmSync(staging, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertPortableMacNode(output) {
|
||||||
|
const dependencies = output
|
||||||
|
.split(/\r?\n/u)
|
||||||
|
.slice(1)
|
||||||
|
.map((line) => line.trim().split(' (')[0])
|
||||||
|
.filter(Boolean);
|
||||||
|
if (
|
||||||
|
dependencies.some(
|
||||||
|
(dependency) =>
|
||||||
|
!dependency.startsWith('/usr/lib/') &&
|
||||||
|
!dependency.startsWith('/System/Library/'),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'Node 链接了非系统动态库,不能作为便携运行时发布;请使用官方独立 Node 发行版',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stageNodeRuntime(
|
||||||
|
target,
|
||||||
|
{
|
||||||
|
nodePath = process.execPath,
|
||||||
|
npmCli = process.env.npm_execpath,
|
||||||
|
licensePath = process.env.AGC_NODE_LICENSE_PATH,
|
||||||
|
destination = path.join(appRoot, 'src-tauri', 'resources', 'node-runtime'),
|
||||||
|
execute = execFileSync,
|
||||||
|
installedLicense = readInstalledNodeLicense,
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const native = targetRuntime(target);
|
||||||
|
const node = fs.realpathSync(nodePath);
|
||||||
|
const query = (args) =>
|
||||||
|
execute(node, args, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: 10_000,
|
||||||
|
maxBuffer: 1024 * 1024,
|
||||||
|
}).trim();
|
||||||
|
const info = JSON.parse(
|
||||||
|
query([
|
||||||
|
'-p',
|
||||||
|
'JSON.stringify({platform:process.platform,arch:process.arch,version:process.version})',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
if (!/^v\d+\.\d+\.\d+$/u.test(info.version))
|
||||||
|
throw new Error('发行 Node 必须使用稳定的三段版本');
|
||||||
|
if (info.platform !== native.platform || info.arch !== native.arch) {
|
||||||
|
throw new Error(
|
||||||
|
`Node 运行时平台/架构与发布目标不一致:${info.platform}/${info.arch} → ${target}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (native.platform === 'darwin') {
|
||||||
|
assertPortableMacNode(
|
||||||
|
execute('/usr/bin/otool', ['-L', node], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: 10_000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const nodeDirectory = path.dirname(node);
|
||||||
|
const npmCandidates = [
|
||||||
|
npmCli,
|
||||||
|
path.join(nodeDirectory, 'node_modules/npm/bin/npm-cli.js'),
|
||||||
|
path.resolve(nodeDirectory, '../lib/node_modules/npm/bin/npm-cli.js'),
|
||||||
|
];
|
||||||
|
const cli = npmCandidates.find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate &&
|
||||||
|
fs.existsSync(candidate) &&
|
||||||
|
path.basename(fs.realpathSync(candidate)) === 'npm-cli.js',
|
||||||
|
);
|
||||||
|
if (!cli) throw new Error('缺少与构建 Node 配套的 npm-cli.js');
|
||||||
|
const npmRoot = path.resolve(path.dirname(fs.realpathSync(cli)), '..');
|
||||||
|
const npmPackage = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(npmRoot, 'package.json'), 'utf8'),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
npmPackage.name !== 'npm' ||
|
||||||
|
!/^\d+\.\d+\.\d+$/u.test(npmPackage.version) ||
|
||||||
|
query([cli, '--version']) !== npmPackage.version
|
||||||
|
)
|
||||||
|
throw new Error('npm 包身份或实际版本不匹配');
|
||||||
|
const licenses = [
|
||||||
|
licensePath,
|
||||||
|
path.join(nodeDirectory, 'LICENSE'),
|
||||||
|
path.join(nodeDirectory, 'LICENSE.txt'),
|
||||||
|
path.resolve(nodeDirectory, '../LICENSE'),
|
||||||
|
path.resolve(nodeDirectory, '../share/doc/node/LICENSE'),
|
||||||
|
];
|
||||||
|
const nodeLicense = licenses.find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate && fs.existsSync(candidate) && fs.statSync(candidate).isFile(),
|
||||||
|
);
|
||||||
|
let license;
|
||||||
|
let licenseName = 'NODE-LICENSE';
|
||||||
|
if (nodeLicense) license = fs.readFileSync(nodeLicense, 'utf8');
|
||||||
|
else if (native.platform === 'win32') {
|
||||||
|
try {
|
||||||
|
license = installedLicense(info.version.slice(1));
|
||||||
|
licenseName = 'NODE-LICENSE.rtf';
|
||||||
|
} catch {
|
||||||
|
throw new Error(
|
||||||
|
'缺少同版本受信任 Node 完整许可;请通过 AGC_NODE_LICENSE_PATH 指定本地发行版 LICENSE 文件',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else
|
||||||
|
throw new Error(
|
||||||
|
'缺少 Node 完整许可;请通过 AGC_NODE_LICENSE_PATH 指定本地发行版 LICENSE 文件',
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!license.includes('Node.js') ||
|
||||||
|
!license.includes('Permission is hereby granted')
|
||||||
|
)
|
||||||
|
throw new Error('Node LICENSE 不包含发行许可');
|
||||||
|
if (!fs.statSync(path.join(npmRoot, 'LICENSE')).isFile())
|
||||||
|
throw new Error('npm 缺少 LICENSE');
|
||||||
|
// 临时同级目录完成后才替换资源;不污染 Node 安装或项目工作区。
|
||||||
|
const requestedDestination = path.resolve(destination);
|
||||||
|
if (requestedDestination === path.dirname(requestedDestination))
|
||||||
|
throw new Error('运行时输出目录不能是文件系统根目录');
|
||||||
|
fs.mkdirSync(path.dirname(requestedDestination), { recursive: true });
|
||||||
|
const parent = fs.realpathSync(path.dirname(requestedDestination));
|
||||||
|
const resolvedDestination = path.join(
|
||||||
|
parent,
|
||||||
|
path.basename(requestedDestination),
|
||||||
|
);
|
||||||
|
const destinationIdentity = replacementIdentity(resolvedDestination);
|
||||||
|
const stagingPrefix = `${path.basename(resolvedDestination)}-staging-`;
|
||||||
|
const staging = fs.mkdtempSync(path.join(parent, stagingPrefix));
|
||||||
|
const stagingIdentity = fs.lstatSync(staging);
|
||||||
|
try {
|
||||||
|
const executable = native.platform === 'win32' ? 'node.exe' : 'node';
|
||||||
|
fs.copyFileSync(node, path.join(staging, executable));
|
||||||
|
fs.chmodSync(path.join(staging, executable), 0o755);
|
||||||
|
fs.writeFileSync(path.join(staging, licenseName), license);
|
||||||
|
fs.cpSync(npmRoot, path.join(staging, 'node_modules/npm'), {
|
||||||
|
recursive: true,
|
||||||
|
dereference: true,
|
||||||
|
filter(source) {
|
||||||
|
if (!inside(npmRoot, fs.realpathSync(source)))
|
||||||
|
throw new Error('npm 资源链接越出包目录');
|
||||||
|
// 安装目录里的个人 npm 配置或凭据不属于发行包。
|
||||||
|
if (
|
||||||
|
/^(?:\.npmrc|npmrc|\.env.*|auth\.json|\.git)$/u.test(
|
||||||
|
path.basename(source),
|
||||||
|
) ||
|
||||||
|
/\.(?:pem|key)$/u.test(source)
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const name of ['npm', 'npx']) {
|
||||||
|
if (native.platform === 'win32') {
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(staging, `${name}.cmd`),
|
||||||
|
`@ECHO OFF\r\n"%~dp0node.exe" "%~dp0node_modules\\npm\\bin\\${name}-cli.js" %*\r\n`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(staging, name),
|
||||||
|
`#!/bin/sh\nbasedir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)\nexec "$basedir/node" "$basedir/node_modules/npm/bin/${name}-cli.js" "$@"\n`,
|
||||||
|
{ mode: 0o755 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const files = Object.fromEntries(
|
||||||
|
packageFiles(staging)
|
||||||
|
.sort()
|
||||||
|
.map((file) => [
|
||||||
|
path.relative(staging, file).split(path.sep).join('/'),
|
||||||
|
createHash('sha256').update(fs.readFileSync(file)).digest('hex'),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const manifest = {
|
||||||
|
schemaVersion: nodeRuntimeSchema,
|
||||||
|
...native,
|
||||||
|
nodeVersion: info.version,
|
||||||
|
npmVersion: npmPackage.version,
|
||||||
|
files,
|
||||||
|
};
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(staging, 'manifest.json'),
|
||||||
|
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
fs.realpathSync(parent) !== parent ||
|
||||||
|
!sameDirectoryIdentity(
|
||||||
|
destinationIdentity,
|
||||||
|
replacementIdentity(resolvedDestination),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new Error('运行时目标身份发生变化,拒绝覆盖');
|
||||||
|
if (destinationIdentity !== null)
|
||||||
|
fs.rmSync(resolvedDestination, { recursive: true });
|
||||||
|
fs.renameSync(staging, resolvedDestination);
|
||||||
|
return manifest;
|
||||||
|
} finally {
|
||||||
|
cleanupStaging(staging, parent, stagingPrefix, stagingIdentity);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
assertPortableMacNode,
|
||||||
|
readInstalledNodeLicense,
|
||||||
|
stageNodeRuntime,
|
||||||
|
targetRuntime,
|
||||||
|
} from './stage-node-runtime.mjs';
|
||||||
|
|
||||||
|
function fixture(run) {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agc-node-staging-test-'));
|
||||||
|
try {
|
||||||
|
const npm = path.join(root, 'source/node_modules/npm');
|
||||||
|
fs.mkdirSync(path.join(npm, 'bin'), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(root, 'source/node.exe'),
|
||||||
|
'native executable fixture',
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(root, 'source/LICENSE'),
|
||||||
|
'Node.js\nPermission is hereby granted',
|
||||||
|
);
|
||||||
|
fs.writeFileSync(path.join(npm, 'LICENSE'), 'npm distribution license');
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(npm, 'package.json'),
|
||||||
|
JSON.stringify({ name: 'npm', version: '11.0.0' }),
|
||||||
|
);
|
||||||
|
for (const name of ['npm', 'npx'])
|
||||||
|
fs.writeFileSync(path.join(npm, `bin/${name}-cli.js`), '// fixture');
|
||||||
|
const options = {
|
||||||
|
nodePath: path.join(root, 'source/node.exe'),
|
||||||
|
npmCli: path.join(npm, 'bin/npm-cli.js'),
|
||||||
|
destination: path.join(root, 'bundle'),
|
||||||
|
installedLicense() {
|
||||||
|
throw new Error('no installed fixture license');
|
||||||
|
},
|
||||||
|
execute(_file, args) {
|
||||||
|
return args[0] === '-p'
|
||||||
|
? JSON.stringify({
|
||||||
|
platform: 'win32',
|
||||||
|
arch: 'x64',
|
||||||
|
version: 'v24.0.0',
|
||||||
|
})
|
||||||
|
: '11.0.0';
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return run(root, options);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('stages matching Node/npm and licenses with a complete integrity manifest', () =>
|
||||||
|
fixture((_root, options) => {
|
||||||
|
const manifest = stageNodeRuntime('x86_64-pc-windows-msvc', options);
|
||||||
|
assert.equal(manifest.nodeVersion, 'v24.0.0');
|
||||||
|
assert.equal(manifest.npmVersion, '11.0.0');
|
||||||
|
assert.ok(manifest.files['NODE-LICENSE']);
|
||||||
|
assert.ok(manifest.files['node_modules/npm/LICENSE']);
|
||||||
|
for (const [file, digest] of Object.entries(manifest.files)) {
|
||||||
|
assert.equal(
|
||||||
|
createHash('sha256')
|
||||||
|
.update(fs.readFileSync(path.join(options.destination, file)))
|
||||||
|
.digest('hex'),
|
||||||
|
digest,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.match(
|
||||||
|
fs.readFileSync(path.join(options.destination, 'npm.cmd'), 'utf8'),
|
||||||
|
/%~dp0node\.exe/u,
|
||||||
|
);
|
||||||
|
}));
|
||||||
|
|
||||||
|
test('local npm configuration and credential files never enter the runtime bundle', () =>
|
||||||
|
fixture((root, options) => {
|
||||||
|
const npm = path.join(root, 'source/node_modules/npm');
|
||||||
|
for (const name of [
|
||||||
|
'.npmrc',
|
||||||
|
'npmrc',
|
||||||
|
'.env.local',
|
||||||
|
'auth.json',
|
||||||
|
'private.key',
|
||||||
|
])
|
||||||
|
fs.writeFileSync(path.join(npm, name), 'private-fixture');
|
||||||
|
const manifest = stageNodeRuntime('x86_64-pc-windows-msvc', options);
|
||||||
|
for (const name of [
|
||||||
|
'.npmrc',
|
||||||
|
'npmrc',
|
||||||
|
'.env.local',
|
||||||
|
'auth.json',
|
||||||
|
'private.key',
|
||||||
|
]) {
|
||||||
|
assert.equal(manifest.files[`node_modules/npm/${name}`], undefined);
|
||||||
|
assert.equal(
|
||||||
|
fs.existsSync(path.join(options.destination, 'node_modules/npm', name)),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
test('rejects mismatched architecture before modifying the existing runtime', () =>
|
||||||
|
fixture((_root, options) => {
|
||||||
|
fs.mkdirSync(options.destination);
|
||||||
|
fs.writeFileSync(path.join(options.destination, 'keep'), 'old');
|
||||||
|
assert.throws(
|
||||||
|
() => stageNodeRuntime('aarch64-apple-darwin', options),
|
||||||
|
/平台\/架构/u,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(path.join(options.destination, 'keep'), 'utf8'),
|
||||||
|
'old',
|
||||||
|
);
|
||||||
|
}));
|
||||||
|
|
||||||
|
test('refuses to recursively replace an unrelated existing directory', () =>
|
||||||
|
fixture((root, options) => {
|
||||||
|
fs.mkdirSync(options.destination);
|
||||||
|
fs.writeFileSync(path.join(options.destination, 'keep.txt'), 'user data');
|
||||||
|
assert.throws(
|
||||||
|
() => stageNodeRuntime('x86_64-pc-windows-msvc', options),
|
||||||
|
/拒绝覆盖非本工具/u,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(path.join(options.destination, 'keep.txt'), 'utf8'),
|
||||||
|
'user data',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
fs.readdirSync(root).some((name) => name.startsWith('bundle-staging-')),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(options.destination, 'manifest.json'),
|
||||||
|
JSON.stringify({ schemaVersion: 'another-tool.v1', files: {} }),
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => stageNodeRuntime('x86_64-pc-windows-msvc', options),
|
||||||
|
/拒绝覆盖/u,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(path.join(options.destination, 'keep.txt'), 'utf8'),
|
||||||
|
'user data',
|
||||||
|
);
|
||||||
|
}));
|
||||||
|
|
||||||
|
test('refuses a linked destination without touching the linked directory', () =>
|
||||||
|
fixture((root, options) => {
|
||||||
|
const linked = path.join(root, 'other-project');
|
||||||
|
fs.mkdirSync(linked);
|
||||||
|
fs.writeFileSync(path.join(linked, 'keep.txt'), 'user data');
|
||||||
|
fs.symlinkSync(
|
||||||
|
linked,
|
||||||
|
options.destination,
|
||||||
|
process.platform === 'win32' ? 'junction' : 'dir',
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => stageNodeRuntime('x86_64-pc-windows-msvc', options),
|
||||||
|
/拒绝覆盖链接/u,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(path.join(linked, 'keep.txt'), 'utf8'),
|
||||||
|
'user data',
|
||||||
|
);
|
||||||
|
assert.equal(fs.lstatSync(options.destination).isSymbolicLink(), true);
|
||||||
|
assert.equal(
|
||||||
|
fs.readdirSync(root).some((name) => name.startsWith('bundle-staging-')),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}));
|
||||||
|
|
||||||
|
test('replaces only an empty directory or this tool runtime without unrelated files', () =>
|
||||||
|
fixture((root, options) => {
|
||||||
|
fs.mkdirSync(options.destination);
|
||||||
|
stageNodeRuntime('x86_64-pc-windows-msvc', options);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(options.destination, 'node.exe'),
|
||||||
|
'damaged previous build',
|
||||||
|
);
|
||||||
|
stageNodeRuntime('x86_64-pc-windows-msvc', options);
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(path.join(options.destination, 'node.exe'), 'utf8'),
|
||||||
|
'native executable fixture',
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(options.destination, 'unrelated.txt'),
|
||||||
|
'keep this',
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => stageNodeRuntime('x86_64-pc-windows-msvc', options),
|
||||||
|
/未登记文件/u,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(path.join(options.destination, 'unrelated.txt'), 'utf8'),
|
||||||
|
'keep this',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
fs.readdirSync(root).some((name) => name.startsWith('bundle-staging-')),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}));
|
||||||
|
|
||||||
|
test('missing npm and missing Node license fail closed without a partial bundle', () =>
|
||||||
|
fixture((root, options) => {
|
||||||
|
fs.rmSync(path.join(root, 'source/LICENSE'));
|
||||||
|
assert.throws(
|
||||||
|
() => stageNodeRuntime('x86_64-pc-windows-msvc', options),
|
||||||
|
/缺少同版本受信任 Node 完整许可/u,
|
||||||
|
);
|
||||||
|
assert.equal(fs.existsSync(options.destination), false);
|
||||||
|
fs.rmSync(path.join(root, 'source/node_modules/npm/bin/npm-cli.js'));
|
||||||
|
assert.throws(
|
||||||
|
() => stageNodeRuntime('x86_64-pc-windows-msvc', options),
|
||||||
|
/npm-cli/u,
|
||||||
|
);
|
||||||
|
}));
|
||||||
|
|
||||||
|
test('installed MSI license requires matching version product manufacturer and verified signer', () => {
|
||||||
|
const receipt = {
|
||||||
|
productName: 'Node.js',
|
||||||
|
version: '24.0.0',
|
||||||
|
manufacturer: 'Node.js Foundation',
|
||||||
|
signatureVerified: true,
|
||||||
|
signer: 'OpenJS Foundation',
|
||||||
|
format: 'rtf',
|
||||||
|
content: '{\\rtf1 Node.js Permission is hereby granted}',
|
||||||
|
};
|
||||||
|
const read = (value) =>
|
||||||
|
readInstalledNodeLicense('24.0.0', {
|
||||||
|
execute: () => JSON.stringify(value),
|
||||||
|
});
|
||||||
|
assert.equal(read(receipt), receipt.content);
|
||||||
|
for (const changed of [
|
||||||
|
{ version: '23.0.0' },
|
||||||
|
{ productName: 'other' },
|
||||||
|
{ manufacturer: 'unknown' },
|
||||||
|
{ signatureVerified: false },
|
||||||
|
{ signer: 'other' },
|
||||||
|
]) {
|
||||||
|
assert.throws(() => read({ ...receipt, ...changed }), /不匹配/u);
|
||||||
|
}
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
readInstalledNodeLicense('24.0.0', {
|
||||||
|
execute() {
|
||||||
|
throw new Error('no registered MSI');
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/no registered MSI/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('matching installed license is preserved as original RTF and included in hashes', () =>
|
||||||
|
fixture((root, options) => {
|
||||||
|
fs.rmSync(path.join(root, 'source/LICENSE'));
|
||||||
|
const content = '{\\rtf1 Node.js Permission is hereby granted}';
|
||||||
|
options.installedLicense = (version) => {
|
||||||
|
assert.equal(version, '24.0.0');
|
||||||
|
return content;
|
||||||
|
};
|
||||||
|
const manifest = stageNodeRuntime('x86_64-pc-windows-msvc', options);
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(
|
||||||
|
path.join(options.destination, 'NODE-LICENSE.rtf'),
|
||||||
|
'utf8',
|
||||||
|
),
|
||||||
|
content,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
manifest.files['NODE-LICENSE.rtf'],
|
||||||
|
createHash('sha256').update(content).digest('hex'),
|
||||||
|
);
|
||||||
|
assert.equal(manifest.files['NODE-LICENSE'], undefined);
|
||||||
|
}));
|
||||||
|
|
||||||
|
test('native target and macOS dynamic dependency policy reject nonportable Node', () => {
|
||||||
|
assert.deepEqual(targetRuntime('aarch64-apple-darwin'), {
|
||||||
|
platform: 'darwin',
|
||||||
|
arch: 'arm64',
|
||||||
|
});
|
||||||
|
assert.throws(() => targetRuntime('universal-apple-darwin'), /不支持/u);
|
||||||
|
assertPortableMacNode(
|
||||||
|
'/node:\n\t/usr/lib/libSystem.B.dylib (compatibility version 1)\n',
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
assertPortableMacNode(
|
||||||
|
'/node:\n\t/opt/homebrew/opt/icu/lib/libicu.dylib (compatibility version 1)\n',
|
||||||
|
),
|
||||||
|
/非系统动态库/u,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import {
|
||||||
|
createHash,
|
||||||
|
createPublicKey,
|
||||||
|
verify as cryptoVerify,
|
||||||
|
} from 'node:crypto';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新包签名门禁:用产物里烘焙的 updater 公钥校验 `.sig`,
|
||||||
|
* 防止「发布出去的更新包没人装得上」——客户端校验失败会直接拒绝安装,
|
||||||
|
* 而且公钥发布后不可更换,所以必须在构建期、上传前就失败关闭。
|
||||||
|
*
|
||||||
|
* 格式说明(与 Tauri 2 的实际产出对齐,均为实测):
|
||||||
|
* - `tauri.conf.json` 的 `plugins.updater.pubkey` 是「minisign 公钥文本」的 base64;
|
||||||
|
* - 产物旁的 `<artifact>.sig` 是「minisign 签名文本」的 base64;
|
||||||
|
* - 公钥 blob 42 字节(alg `Ed` + 8 字节 keyId + 32 字节 Ed25519 公钥);
|
||||||
|
* - 签名 blob 74 字节(alg `Ed` 或 `ED` + 8 字节 keyId + 64 字节签名);
|
||||||
|
* - Tauri 产出的是 `ED`:先对文件做 BLAKE2b-512,再对摘要做 Ed25519 签名。
|
||||||
|
*/
|
||||||
|
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||||
|
const defaultTauriConfigPath = path.join(appRoot, 'src-tauri/tauri.conf.json');
|
||||||
|
const defaultMacosConfigPath = path.join(
|
||||||
|
appRoot,
|
||||||
|
'src-tauri/tauri.macos.conf.json',
|
||||||
|
);
|
||||||
|
|
||||||
|
const PUBLIC_KEY_ALGORITHM = 'Ed';
|
||||||
|
const RAW_ALGORITHM = 'Ed';
|
||||||
|
const PREHASHED_ALGORITHM = 'ED';
|
||||||
|
|
||||||
|
function unwrapMinisignText(value, label) {
|
||||||
|
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||||
|
throw new Error(`${label} 为空`);
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (trimmed.startsWith('untrusted comment:')) return trimmed;
|
||||||
|
const decoded = Buffer.from(trimmed, 'base64').toString('utf8');
|
||||||
|
if (!decoded.startsWith('untrusted comment:')) {
|
||||||
|
throw new Error(`${label} 不是 minisign 内容(缺少 untrusted comment 头)`);
|
||||||
|
}
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentLines(text) {
|
||||||
|
return text
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析 updater 公钥(`tauri.conf.json` 里的 base64 值或 minisign 文本)。 */
|
||||||
|
export function decodeUpdaterPublicKey(value, label = 'updater 公钥') {
|
||||||
|
const lines = contentLines(unwrapMinisignText(value, label));
|
||||||
|
if (lines.length < 2) throw new Error(`${label} 缺少密钥内容行`);
|
||||||
|
const blob = Buffer.from(lines[1], 'base64');
|
||||||
|
if (blob.length !== 42) {
|
||||||
|
throw new Error(
|
||||||
|
`${label} 长度异常:期望 42 字节,实际 ${blob.length} 字节`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const algorithm = blob.subarray(0, 2).toString('latin1');
|
||||||
|
if (algorithm !== PUBLIC_KEY_ALGORITHM) {
|
||||||
|
throw new Error(`${label} 算法不受支持:${algorithm}`);
|
||||||
|
}
|
||||||
|
return { algorithm, keyId: blob.subarray(2, 10), key: blob.subarray(10) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析 `.sig`(base64 值或 minisign 文本)。 */
|
||||||
|
export function decodeUpdaterSignature(value, label = '更新包签名') {
|
||||||
|
const lines = contentLines(unwrapMinisignText(value, label));
|
||||||
|
if (lines.length < 2) throw new Error(`${label} 缺少签名内容行`);
|
||||||
|
const blob = Buffer.from(lines[1], 'base64');
|
||||||
|
if (blob.length !== 74) {
|
||||||
|
throw new Error(
|
||||||
|
`${label} 长度异常:期望 74 字节,实际 ${blob.length} 字节`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const algorithm = blob.subarray(0, 2).toString('latin1');
|
||||||
|
if (algorithm !== RAW_ALGORITHM && algorithm !== PREHASHED_ALGORITHM) {
|
||||||
|
throw new Error(`${label} 算法不受支持:${algorithm}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
algorithm,
|
||||||
|
keyId: blob.subarray(2, 10),
|
||||||
|
signature: blob.subarray(10),
|
||||||
|
trustedComment: lines[2] ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicKeyObject(rawKey) {
|
||||||
|
return createPublicKey({
|
||||||
|
key: { kty: 'OKP', crv: 'Ed25519', x: rawKey.toString('base64url') },
|
||||||
|
format: 'jwk',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验更新包签名;任何不一致都抛错(调用方据此失败关闭)。
|
||||||
|
*/
|
||||||
|
export function verifyUpdaterSignature({
|
||||||
|
artifactPath,
|
||||||
|
signaturePath,
|
||||||
|
pubkey,
|
||||||
|
}) {
|
||||||
|
const publicKey = decodeUpdaterPublicKey(pubkey);
|
||||||
|
const signature = decodeUpdaterSignature(
|
||||||
|
fs.readFileSync(signaturePath, 'utf8'),
|
||||||
|
);
|
||||||
|
if (!publicKey.keyId.equals(signature.keyId)) {
|
||||||
|
throw new Error(
|
||||||
|
`更新包签名与内置公钥的 keyId 不一致:公钥 ${publicKey.keyId.toString('hex')},签名 ${signature.keyId.toString('hex')};` +
|
||||||
|
'签名私钥与产物内烘焙的公钥不是同一对,发布后客户端会拒绝安装',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const payload = fs.readFileSync(artifactPath);
|
||||||
|
const message =
|
||||||
|
signature.algorithm === PREHASHED_ALGORITHM
|
||||||
|
? createHash('blake2b512').update(payload).digest()
|
||||||
|
: payload;
|
||||||
|
if (
|
||||||
|
!cryptoVerify(
|
||||||
|
null,
|
||||||
|
message,
|
||||||
|
publicKeyObject(publicKey.key),
|
||||||
|
signature.signature,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`更新包签名校验失败:${path.basename(artifactPath)};该产物无法被客户端接受`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
algorithm: signature.algorithm,
|
||||||
|
keyId: publicKey.keyId.toString('hex'),
|
||||||
|
trustedComment: signature.trustedComment,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取该平台生效的 updater 公钥:macOS 配置可覆盖基础配置,与构建期行为一致。
|
||||||
|
*/
|
||||||
|
export function readUpdaterPubkey({
|
||||||
|
configPath = defaultTauriConfigPath,
|
||||||
|
platformConfigPath = defaultMacosConfigPath,
|
||||||
|
} = {}) {
|
||||||
|
const readPubkey = (file) => {
|
||||||
|
if (!fs.existsSync(file)) return null;
|
||||||
|
const config = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||||
|
return config?.plugins?.updater?.pubkey ?? null;
|
||||||
|
};
|
||||||
|
const pubkey = readPubkey(platformConfigPath) ?? readPubkey(configPath);
|
||||||
|
if (!pubkey) throw new Error('未在 Tauri 配置中找到 plugins.updater.pubkey');
|
||||||
|
return pubkey;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
process.argv[1] &&
|
||||||
|
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
||||||
|
) {
|
||||||
|
const [artifactPath, signaturePath = `${artifactPath}.sig`] =
|
||||||
|
process.argv.slice(2);
|
||||||
|
if (!artifactPath) {
|
||||||
|
throw new Error(
|
||||||
|
'用法:node verify-updater-signature.mjs <更新包> [<签名文件>]',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const result = verifyUpdaterSignature({
|
||||||
|
artifactPath,
|
||||||
|
signaturePath,
|
||||||
|
pubkey: readUpdaterPubkey(),
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`[agc-macos] 更新包签名校验通过:${path.basename(artifactPath)}(alg=${result.algorithm},keyId=${result.keyId})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
createHash,
|
||||||
|
generateKeyPairSync,
|
||||||
|
randomBytes,
|
||||||
|
sign as cryptoSign,
|
||||||
|
} from 'node:crypto';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
decodeUpdaterPublicKey,
|
||||||
|
decodeUpdaterSignature,
|
||||||
|
readUpdaterPubkey,
|
||||||
|
verifyUpdaterSignature,
|
||||||
|
} from './verify-updater-signature.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用进程内生成的 Ed25519 密钥自造 minisign 结构,
|
||||||
|
* 覆盖 Tauri 实际使用的 `ED`(BLAKE2b-512 预哈希)与 `Ed`(原文)两种模式。
|
||||||
|
*/
|
||||||
|
function createKeyMaterial() {
|
||||||
|
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
||||||
|
const rawKey = Buffer.from(
|
||||||
|
publicKey.export({ format: 'jwk' }).x,
|
||||||
|
'base64url',
|
||||||
|
);
|
||||||
|
const keyId = randomBytes(8);
|
||||||
|
const pubkey = Buffer.from(
|
||||||
|
`untrusted comment: minisign public key: ${keyId.reverse().toString('hex').toUpperCase()}\n` +
|
||||||
|
`${Buffer.concat([Buffer.from('Ed'), keyId, rawKey]).toString('base64')}\n`,
|
||||||
|
).toString('base64');
|
||||||
|
return { privateKey, keyId, rawKey, pubkey };
|
||||||
|
}
|
||||||
|
|
||||||
|
function signFixture({ privateKey, keyId }, payload, algorithm) {
|
||||||
|
const message =
|
||||||
|
algorithm === 'ED'
|
||||||
|
? createHash('blake2b512').update(payload).digest()
|
||||||
|
: payload;
|
||||||
|
const signature = cryptoSign(null, message, privateKey);
|
||||||
|
const blob = Buffer.concat([Buffer.from(algorithm), keyId, signature]);
|
||||||
|
const globalSignature = cryptoSign(null, blob, privateKey);
|
||||||
|
return Buffer.from(
|
||||||
|
'untrusted comment: signature from tauri secret key\n' +
|
||||||
|
`${blob.toString('base64')}\n` +
|
||||||
|
'trusted comment: timestamp:0\tfile:fixture\n' +
|
||||||
|
`${globalSignature.toString('base64')}\n`,
|
||||||
|
).toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
function withFixture(run) {
|
||||||
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'agc-sig-test-'));
|
||||||
|
try {
|
||||||
|
const artifactPath = path.join(directory, 'app.app.tar.gz');
|
||||||
|
fs.writeFileSync(artifactPath, 'update payload');
|
||||||
|
return run({ directory, artifactPath });
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('接受 Tauri 实际使用的 ED(BLAKE2b-512 预哈希)签名', () => {
|
||||||
|
withFixture(({ directory, artifactPath }) => {
|
||||||
|
const material = createKeyMaterial();
|
||||||
|
const signaturePath = path.join(directory, 'app.app.tar.gz.sig');
|
||||||
|
fs.writeFileSync(
|
||||||
|
signaturePath,
|
||||||
|
signFixture(material, fs.readFileSync(artifactPath), 'ED'),
|
||||||
|
);
|
||||||
|
const result = verifyUpdaterSignature({
|
||||||
|
artifactPath,
|
||||||
|
signaturePath,
|
||||||
|
pubkey: material.pubkey,
|
||||||
|
});
|
||||||
|
assert.equal(result.algorithm, 'ED');
|
||||||
|
assert.equal(result.keyId, material.keyId.toString('hex'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('接受原文 Ed 签名,两种算法互不通用', () => {
|
||||||
|
withFixture(({ directory, artifactPath }) => {
|
||||||
|
const material = createKeyMaterial();
|
||||||
|
const payload = fs.readFileSync(artifactPath);
|
||||||
|
const signaturePath = path.join(directory, 'app.app.tar.gz.sig');
|
||||||
|
fs.writeFileSync(signaturePath, signFixture(material, payload, 'Ed'));
|
||||||
|
assert.equal(
|
||||||
|
verifyUpdaterSignature({
|
||||||
|
artifactPath,
|
||||||
|
signaturePath,
|
||||||
|
pubkey: material.pubkey,
|
||||||
|
}).algorithm,
|
||||||
|
'Ed',
|
||||||
|
);
|
||||||
|
// 原文模式下签名的是别的载荷时必须失败:证明确实在校验内容而非只看结构。
|
||||||
|
fs.writeFileSync(
|
||||||
|
signaturePath,
|
||||||
|
signFixture(material, Buffer.from('别的载荷'), 'Ed'),
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
verifyUpdaterSignature({
|
||||||
|
artifactPath,
|
||||||
|
signaturePath,
|
||||||
|
pubkey: material.pubkey,
|
||||||
|
}),
|
||||||
|
/签名校验失败/u,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('产物被篡改时失败关闭', () => {
|
||||||
|
withFixture(({ directory, artifactPath }) => {
|
||||||
|
const material = createKeyMaterial();
|
||||||
|
const signaturePath = path.join(directory, 'app.app.tar.gz.sig');
|
||||||
|
fs.writeFileSync(
|
||||||
|
signaturePath,
|
||||||
|
signFixture(material, fs.readFileSync(artifactPath), 'ED'),
|
||||||
|
);
|
||||||
|
fs.writeFileSync(artifactPath, 'tampered payload');
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
verifyUpdaterSignature({
|
||||||
|
artifactPath,
|
||||||
|
signaturePath,
|
||||||
|
pubkey: material.pubkey,
|
||||||
|
}),
|
||||||
|
/签名校验失败/u,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('签名私钥与内置公钥不是同一对时给出明确错误', () => {
|
||||||
|
withFixture(({ directory, artifactPath }) => {
|
||||||
|
const signing = createKeyMaterial();
|
||||||
|
const baked = createKeyMaterial();
|
||||||
|
const signaturePath = path.join(directory, 'app.app.tar.gz.sig');
|
||||||
|
fs.writeFileSync(
|
||||||
|
signaturePath,
|
||||||
|
signFixture(signing, fs.readFileSync(artifactPath), 'ED'),
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
verifyUpdaterSignature({
|
||||||
|
artifactPath,
|
||||||
|
signaturePath,
|
||||||
|
pubkey: baked.pubkey,
|
||||||
|
}),
|
||||||
|
/keyId 不一致/u,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('公钥或签名格式非法时拒绝解析', () => {
|
||||||
|
assert.throws(() => decodeUpdaterPublicKey(''), /为空/u);
|
||||||
|
assert.throws(
|
||||||
|
() => decodeUpdaterPublicKey('bm90IGEgbWluaXNpZ24ga2V5'),
|
||||||
|
/不是 minisign 内容/u,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
decodeUpdaterPublicKey(
|
||||||
|
Buffer.from('untrusted comment: x\nAAAA\n').toString('base64'),
|
||||||
|
),
|
||||||
|
/长度异常/u,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
decodeUpdaterSignature(
|
||||||
|
Buffer.from('untrusted comment: x\nAAAA\n').toString('base64'),
|
||||||
|
),
|
||||||
|
/长度异常/u,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('仓库里配置的 updater 公钥可被解析(两平台共用)', () => {
|
||||||
|
const decoded = decodeUpdaterPublicKey(readUpdaterPubkey());
|
||||||
|
assert.equal(decoded.algorithm, 'Ed');
|
||||||
|
assert.equal(decoded.key.length, 32);
|
||||||
|
});
|
||||||
+97
-3
@@ -747,6 +747,47 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "codex-patch-parser"
|
||||||
|
version = "0.155.1"
|
||||||
|
dependencies = [
|
||||||
|
"codex-utils-absolute-path",
|
||||||
|
"codex-utils-path-uri",
|
||||||
|
"pretty_assertions",
|
||||||
|
"tempfile",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "codex-utils-absolute-path"
|
||||||
|
version = "0.155.1"
|
||||||
|
dependencies = [
|
||||||
|
"dirs",
|
||||||
|
"dunce",
|
||||||
|
"pretty_assertions",
|
||||||
|
"schemars 0.8.22",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
|
"ts-rs 11.1.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "codex-utils-path-uri"
|
||||||
|
version = "0.155.1"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"codex-utils-absolute-path",
|
||||||
|
"pretty_assertions",
|
||||||
|
"schemars 0.8.22",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"ts-rs 11.1.0",
|
||||||
|
"url",
|
||||||
|
"urlencoding",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "combine"
|
name = "combine"
|
||||||
version = "4.6.7"
|
version = "4.6.7"
|
||||||
@@ -1065,6 +1106,12 @@ dependencies = [
|
|||||||
"syn 2.0.118",
|
"syn 2.0.118",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "diff"
|
||||||
|
version = "0.1.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.10.7"
|
version = "0.10.7"
|
||||||
@@ -1751,7 +1798,9 @@ dependencies = [
|
|||||||
"axum",
|
"axum",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"chromiumoxide",
|
"chromiumoxide",
|
||||||
|
"chrono",
|
||||||
"cocos-editor-bridge",
|
"cocos-editor-bridge",
|
||||||
|
"codex-patch-parser",
|
||||||
"editor-adapter-api",
|
"editor-adapter-api",
|
||||||
"futures",
|
"futures",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
@@ -1790,7 +1839,7 @@ dependencies = [
|
|||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"toml 0.8.2",
|
"toml 0.8.2",
|
||||||
"ts-rs",
|
"ts-rs 12.0.1",
|
||||||
"ttf-parser",
|
"ttf-parser",
|
||||||
"typed_floats",
|
"typed_floats",
|
||||||
"unicode-normalization",
|
"unicode-normalization",
|
||||||
@@ -4052,6 +4101,16 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pretty_assertions"
|
||||||
|
version = "1.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d"
|
||||||
|
dependencies = [
|
||||||
|
"diff",
|
||||||
|
"yansi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro-crate"
|
name = "proc-macro-crate"
|
||||||
version = "1.3.1"
|
version = "1.3.1"
|
||||||
@@ -5031,7 +5090,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
"ts-rs",
|
"ts-rs 12.0.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6239,6 +6298,17 @@ version = "0.2.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ts-rs"
|
||||||
|
version = "11.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4994acea2522cd2b3b85c1d9529a55991e3ad5e25cdcd3de9d505972c4379424"
|
||||||
|
dependencies = [
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"ts-rs-macros 11.1.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ts-rs"
|
name = "ts-rs"
|
||||||
version = "12.0.1"
|
version = "12.0.1"
|
||||||
@@ -6246,7 +6316,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8"
|
checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"ts-rs-macros",
|
"ts-rs-macros 12.0.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ts-rs-macros"
|
||||||
|
version = "11.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ee6ff59666c9cbaec3533964505d39154dc4e0a56151fdea30a09ed0301f62e2"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.118",
|
||||||
|
"termcolor",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6458,6 +6540,12 @@ dependencies = [
|
|||||||
"serde_derive",
|
"serde_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urlencoding"
|
||||||
|
version = "2.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "urlpattern"
|
name = "urlpattern"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -7498,6 +7586,12 @@ dependencies = [
|
|||||||
"rustix",
|
"rustix",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "yansi"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ version = "0.1.67"
|
|||||||
edition = "2021"
|
edition = "2021"
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
|
[workspace]
|
||||||
|
members = [".", "vendor/codex-patch-parser", "vendor/codex-utils-path-uri", "vendor/codex-utils-absolute-path"]
|
||||||
|
resolver = "2"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
# 模板库假数据注入(仅本地页面压测/演示用):只有显式开启该 feature 才会编译并在读取清单后
|
# 模板库假数据注入(仅本地页面压测/演示用):只有显式开启该 feature 才会编译并在读取清单后
|
||||||
@@ -23,6 +27,8 @@ shared-contracts = { path = "../../../server-rs/crates/shared-contracts", defaul
|
|||||||
tauri-build = { version = "2.6.2", features = [] }
|
tauri-build = { version = "2.6.2", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
codex-patch-parser = { path = "vendor/codex-patch-parser" }
|
||||||
|
chrono = { version = "0.4", default-features = false, features = ["std"] }
|
||||||
ts-rs = "12.0.1"
|
ts-rs = "12.0.1"
|
||||||
typed_floats = { version = "1.0.7", features = ["serde"] }
|
typed_floats = { version = "1.0.7", features = ["serde"] }
|
||||||
nalgebra = { version = "0.35.0", features = ["serde-serialize"] }
|
nalgebra = { version = "0.35.0", features = ["serde-serialize"] }
|
||||||
|
|||||||
@@ -33,7 +33,23 @@ fn sha256_file(path: &std::path::Path) -> Result<String, std::io::Error> {
|
|||||||
fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) {
|
fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) {
|
||||||
let target = env::var("TARGET").expect("Cargo TARGET");
|
let target = env::var("TARGET").expect("Cargo TARGET");
|
||||||
println!("cargo:rustc-env=AGC_BUILD_TARGET={target}");
|
println!("cargo:rustc-env=AGC_BUILD_TARGET={target}");
|
||||||
let Some(layout) = codex_bundle::for_target(&target) else {
|
if target.contains("apple-darwin") {
|
||||||
|
// Tauri 的 universal 两次 Cargo 编译共用 resource staging,
|
||||||
|
// 每次都生成完整双架构目录,最终 bundle 不取决于最后编译的切片。
|
||||||
|
let staging = manifest_dir.join("resources/codex/mac-native");
|
||||||
|
if staging.exists() {
|
||||||
|
fs::remove_dir_all(&staging).expect("清理 macOS Codex staging 失败");
|
||||||
|
}
|
||||||
|
for target in ["aarch64-apple-darwin", "x86_64-apple-darwin"] {
|
||||||
|
stage_codex_target(manifest_dir, target);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stage_codex_target(manifest_dir, &target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stage_codex_target(manifest_dir: &std::path::Path, target: &str) {
|
||||||
|
let Some(layout) = codex_bundle::for_target(target) else {
|
||||||
assert!(
|
assert!(
|
||||||
!target.contains("windows") && !target.contains("apple-darwin"),
|
!target.contains("windows") && !target.contains("apple-darwin"),
|
||||||
"不支持的 Codex 随包目标:{target}"
|
"不支持的 Codex 随包目标:{target}"
|
||||||
@@ -83,7 +99,7 @@ fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) {
|
|||||||
&fs::read(source.join("codex-package.json")).expect("读取 Codex 原生包元数据失败"),
|
&fs::read(source.join("codex-package.json")).expect("读取 Codex 原生包元数据失败"),
|
||||||
)
|
)
|
||||||
.expect("Codex 原生包元数据无效");
|
.expect("Codex 原生包元数据无效");
|
||||||
codex_bundle::validate_package_metadata(&metadata, &target, layout)
|
codex_bundle::validate_package_metadata(&metadata, target, layout)
|
||||||
.unwrap_or_else(|error| panic!("{error}"));
|
.unwrap_or_else(|error| panic!("{error}"));
|
||||||
let target_dir = manifest_dir.join("resources/codex").join(layout.directory);
|
let target_dir = manifest_dir.join("resources/codex").join(layout.directory);
|
||||||
let notice = target_dir.join("NOTICE.md");
|
let notice = target_dir.join("NOTICE.md");
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! 构建与运行共用的平台布局;只允许分发锁定原生包里的明确组件。
|
//! 构建与运行共用的平台布局;只允许分发锁定原生包里的明确组件。
|
||||||
|
|
||||||
pub const VERSION: &str = "0.147.0";
|
pub const VERSION: &str = "0.155.1";
|
||||||
pub const CLI_VERSION: &str = "codex-cli 0.147.0";
|
pub const CLI_VERSION: &str = "codex-cli 0.155.1";
|
||||||
pub const SCHEMA: &str = "genarrative-codex-sidecar.v2";
|
pub const SCHEMA: &str = "genarrative-codex-sidecar.v2";
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug)]
|
#[derive(Clone, Copy, Debug)]
|
||||||
@@ -49,7 +49,11 @@ pub fn for_target(target: &str) -> Option<Layout> {
|
|||||||
} else {
|
} else {
|
||||||
"codex-darwin-x64"
|
"codex-darwin-x64"
|
||||||
},
|
},
|
||||||
directory: "mac-native",
|
directory: if target.starts_with("aarch64") {
|
||||||
|
"mac-native/darwin-arm64"
|
||||||
|
} else {
|
||||||
|
"mac-native/darwin-x64"
|
||||||
|
},
|
||||||
executable: "bin/codex",
|
executable: "bin/codex",
|
||||||
files: MAC_FILES,
|
files: MAC_FILES,
|
||||||
}),
|
}),
|
||||||
@@ -90,6 +94,9 @@ mod tests {
|
|||||||
let intel = for_target("x86_64-apple-darwin").unwrap();
|
let intel = for_target("x86_64-apple-darwin").unwrap();
|
||||||
assert_eq!(intel.platform, "darwin-x64");
|
assert_eq!(intel.platform, "darwin-x64");
|
||||||
assert_eq!(intel.npm_package, "codex-darwin-x64");
|
assert_eq!(intel.npm_package, "codex-darwin-x64");
|
||||||
|
assert_eq!(mac.directory, "mac-native/darwin-arm64");
|
||||||
|
assert_eq!(intel.directory, "mac-native/darwin-x64");
|
||||||
|
assert_ne!(mac.directory, intel.directory);
|
||||||
let windows = for_target("x86_64-pc-windows-msvc").unwrap();
|
let windows = for_target("x86_64-pc-windows-msvc").unwrap();
|
||||||
assert_eq!(windows.directory, "win-x64");
|
assert_eq!(windows.directory, "win-x64");
|
||||||
assert_eq!(windows.files.len(), 6);
|
assert_eq!(windows.files.len(), 6);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user