diff --git a/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs b/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs index 442df54d1..49bf93abf 100644 --- a/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs +++ b/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs @@ -9,6 +9,7 @@ import { fileURLToPath } from 'node:url'; import { generateUpdateManifest, prepareReleaseVersion, + resolveManifestPlatformKeys, resolveReleaseContext, resolveReleasePartition, runTauriBuild, @@ -20,10 +21,13 @@ import { } from './verify-updater-signature.mjs'; /** - * AGC macOS 分区(`-mac`)发布入口:构建 universal 包 → 双架构 smoke → 生成 universal DMG + * AGC macOS 分区(`-mac`)发布入口:构建 arm64 单架构包 → arm64 隔离 smoke → 生成 arm64 DMG * → 生成分区清单 latest.json → 用产物内烘焙的公钥验签 → 按 dry-run 决定是否上传 OSS。 * * 边界: + * - 只出 Apple Silicon(arm64)单架构:清单只登记 `darwin-aarch64`。Intel 侧要可用,前提是随包 Node + * 也能按架构各带一份(`stage-node-runtime.mjs` 对 universal 目标失败关闭);在实现之前**不得** + * 把 arm64 产物登记成 `darwin-x86_64`,否则 Intel 客户端会装到跑不起来的包。 * - Apple 签名与公证暂缺:本入口剥离 `APPLE_*` 凭据让 Tauri 跳过 Apple 签名,但**不能传 * `--no-sign`** —— 该标志同时会跳过 updater 的 minisign 签名,产物就没有 `.sig`; * 未签名 + 未公证必须显式记录而非静默通过; @@ -94,11 +98,14 @@ 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']); +// 单架构目标:清单侧 `resolveManifestPlatformKeys` 只为它登记 darwin-aarch64。 +const macTarget = 'aarch64-apple-darwin'; +const context = resolveReleaseContext([`--target=${macTarget}`]); const partition = resolveReleasePartition(context.channel, context.target); const version = await prepareReleaseVersion(context); -// 首装包名必须保持 `<产品名>_<版本>_universal.dmg`:清单侧按该后缀唯一匹配本次产物。 -const firstInstallName = `${productName}_${version}_universal.dmg`; +// 首装包名必须让清单侧的单架构分支唯一匹配:`<产品名>_<版本>_<架构>.dmg`, +// 架构段用 Tauri 的 aarch64 口径(不是 updater 平台键的 arm64 / x86_64)。 +const firstInstallName = `${productName}_${version}_aarch64.dmg`; // 幂等边界:workspace 会保留上一轮产物。先删掉本次将要写出的对象,否则 // 1) hdiutil 会因同名 DMG 已存在直接失败(首次实跑即命中); @@ -117,7 +124,7 @@ for (const stale of [ } const args = [ - '--target=universal-apple-darwin', + `--target=${macTarget}`, '--bundles', 'app', '--ci', @@ -132,16 +139,13 @@ const command = (binary, argv, 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', - ]); -} +command(process.execPath, [ + path.join(appRoot, 'scripts/check-macos-bundle.mjs'), + app, + 'arm64', +]); -// DMG 放在 bundle 根目录下:渠道清单的首装包选择会扫描该目录,命名必须匹配 `__universal.dmg`。 +// DMG 放在 bundle 根目录下:渠道清单的首装包选择会扫描该目录,命名必须匹配 `__aarch64.dmg`。 const dmgDirectory = path.join(context.bundleRoot, 'macos'); fs.mkdirSync(dmgDirectory, { recursive: true }); const dmg = path.join(dmgDirectory, firstInstallName); @@ -170,7 +174,7 @@ const release = await generateUpdateManifest(context); assert.equal( path.resolve(release.downloadArtifact), path.resolve(dmg), - '首装包必须锁定本次生成的 universal DMG', + '首装包必须锁定本次生成的 arm64 DMG', ); // 上传前门禁:用产物里烘焙的公钥复核更新包签名。验不过就停在这里,绝不写 OSS。 @@ -266,8 +270,9 @@ fs.writeFileSync( firstInstallSha256: dmgHash, manifest: 'latest.json', }, - smokes: ['arm64', 'x86_64'], - intelSmoke: process.arch === 'arm64' ? 'Rosetta' : 'native', + // 单架构发布:只跑 arm64 隔离 smoke;Intel 未支持(清单里没有 darwin-x86_64 键)。 + smokes: ['arm64'], + manifestPlatformKeys: resolveManifestPlatformKeys(context.target), }, null, 2, diff --git a/apps/ai-game-creator-shell/scripts/build-release.test.mjs b/apps/ai-game-creator-shell/scripts/build-release.test.mjs index 732302eb1..59e1a878b 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -211,6 +211,9 @@ test('packaged renderer receives the same channel as the updater manifest', () = }); let spawnOptions; runTauriBuild([], context, { + // 必须 stub:真实 staging 会用宿主平台(如 macOS 的 darwin/arm64)去对默认的 + // Windows 目标做一致性校验,在非 Windows 主机上直接失败——本用例只关心渠道注入。 + stageRuntime: () => {}, spawn: (_binary, _args, options) => { spawnOptions = options; return { status: 0 }; diff --git a/apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs b/apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs index 4caae3656..b62fe76a1 100644 --- a/apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs +++ b/apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs @@ -178,8 +178,12 @@ test('macOS release entry and smoke script derive product names from config and assert.ok(entry.includes('readProductName'), '入口必须从 Tauri 配置读产品名'); assert.ok(!entry.includes('陶泥儿'), 'macOS 发布入口不得写死产品名'); assert.ok( - entry.includes('_${version}_universal.dmg'), - '首装包名必须保留清单侧唯一匹配所需的后缀', + entry.includes("const macTarget = 'aarch64-apple-darwin'"), + 'macOS 发布入口必须固定单架构目标', + ); + assert.ok( + entry.includes('_${version}_aarch64.dmg'), + '首装包名必须保留清单侧单架构分支唯一匹配所需的后缀(Tauri 口径 aarch64)', ); const smoke = fs.readFileSync( diff --git a/apps/ai-game-creator-shell/scripts/stage-node-runtime.mjs b/apps/ai-game-creator-shell/scripts/stage-node-runtime.mjs index e712534d6..489ce0b2b 100644 --- a/apps/ai-game-creator-shell/scripts/stage-node-runtime.mjs +++ b/apps/ai-game-creator-shell/scripts/stage-node-runtime.mjs @@ -65,6 +65,17 @@ export function targetRuntime(target) { 'aarch64-apple-darwin': ['darwin', 'arm64'], 'x86_64-apple-darwin': ['darwin', 'x64'], }; + // 随包 Node 是**单架构**官方发行版:一份运行时只服务它自己的架构。 + // macOS 发布当前固定为 aarch64-apple-darwin 单架构包(Intel 未支持), + // universal 目标没有正确的运行时来源,必须失败关闭——绝不能退化成 + // 「按宿主架构暂存一份 arm64」:那样通用包自检(按 process.arch)能过, + // 但 Intel 机器上这份运行时不可执行,用户拿到的是坏包。 + if (target === 'universal-apple-darwin') + throw new Error( + 'Node 运行时不支持 universal-apple-darwin:随包 Node 只有单架构发行版,' + + '通用包需按架构各带一份(另行下载另一架构官方发行版)之后才能构建;' + + '当前 macOS 发布固定为 aarch64-apple-darwin 单架构', + ); const value = targets[target]; if (!value) throw new Error(`Node 运行时不支持发布目标:${target}`); return { platform: value[0], arch: value[1] }; diff --git a/apps/ai-game-creator-shell/scripts/stage-node-runtime.test.mjs b/apps/ai-game-creator-shell/scripts/stage-node-runtime.test.mjs index 8a55c6b3a..c2b52afb1 100644 --- a/apps/ai-game-creator-shell/scripts/stage-node-runtime.test.mjs +++ b/apps/ai-game-creator-shell/scripts/stage-node-runtime.test.mjs @@ -281,7 +281,11 @@ test('native target and macOS dynamic dependency policy reject nonportable Node' platform: 'darwin', arch: 'arm64', }); - assert.throws(() => targetRuntime('universal-apple-darwin'), /不支持/u); + // universal 必须失败关闭:只带宿主架构那一份运行时,Intel 上不可执行。 + assert.throws( + () => targetRuntime('universal-apple-darwin'), + /universal-apple-darwin/u, + ); assertPortableMacNode( '/node:\n\t/usr/lib/libSystem.B.dylib (compatibility version 1)\n', ); diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx index 68886de45..e1e5f215d 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx @@ -235,8 +235,14 @@ export function ResourceCanvasAssetGenerationPanelView({ /** * 参考选择只对有真实参考能力的入口呈现。 * - * 图集只接受单张规范引用、图标规范本身就是权威规范图产出方:这两类入口不给选择器, - * 原生侧同样拒绝额外参考(不是静默丢弃)。 + * `@` 引用输入区是**共享组件**(`ResourceReferenceInput`,聊天 / 快速编辑 / 这里同一份), + * 这一处只是「要不要挂它」的唯一分支:判据在 `resourceCanvasAssetGenerationAcceptsReferences`, + * 不在这里另写一套 kind 清单。 + * + * 拒绝用户参考的只有**图集**(`icon-spritesheet`):按合同它只接受单张权威规范引用, + * 原生侧对多余参考是**显式拒绝**(不是静默丢弃)。图标规范(`icon-spec`)虽然产出权威规范图, + * 但生成时同样可以带参考图,上限与普通生成一致——它**有** `@` 选择器。 + * 两端必须同一个词:Rust 侧是 `platform_art_asset_kind_accepts_user_reference_assets`。 */ const referenceEnabled = resourceCanvasAssetGenerationAcceptsReferences(action); @@ -494,6 +500,25 @@ export function ResourceCanvasAssetGenerationPanelView({ {resolveEditorImageSizeLabel({ aspectRatio, imageSize })} )} + {referenceEnabled ? null : ( + /* + 不给 `@` 的入口要**当场**说清为什么,否则用户只会读成「这里漏了一个按钮」 + (验收现场那条「生成图标素材没法 @」就是这么来的)。 + + 原因在通道而不在 UI:图集走平台 `icon-spritesheets` 通道,提交体只有一张 + `referenceId`(独立权威规范图)+ `iconDescriptions` + 切片参数,客户端这一侧对 + 「图集带用户参考」是**显式拒绝**(`normalize_platform_art_reference_asset_ids`: + 「透明美术图集只接受规范图引用,不接受用户参考素材」),所以放开 UI 也提交不出去。 + 判据与文案都由同一处给出,别的 reference-free 入口将来接入时不必各写一份。 + */ +

+ 该入口走平台图集通道:参考只有一张权威规范图(图标规范), + 不支持另挑参考图——把需要哪些图标写进提示词。 +

+ )} {referenceProblemNotice ? (

void; /** - * 锚点落在哪个工作面:资源栏目画布 / 运行表现层 / UI 编辑器。 + * 锚点落在哪个工作面:资源总览页 / 资源栏目画布 / 运行表现层 / UI 编辑器。 * - * 三处右上角的既有 chrome 不同(运行表现层右上角是版本入口、UI 编辑器是整页编辑器壳), - * 所以坐标由这一档在样式里分档给出;缺省按资源栏目画布算。 + * 四处的既有 chrome 不同,所以坐标由这一档在样式里分档给出;缺省按资源栏目画布算: + * - `canvas-overview`(资源总览 / 画本):顶部没有钉死的栏目标题栏,锚点贴画布顶边内缩; + * - `canvas`(资源栏目画布):顶部钉着**整宽**的栏目标题栏(含「返回资源总览」入口), + * 锚点必须让开它,否则会压在那条标题栏上(验收现场那条「生成任务和标题栏重叠」); + * - `run`:右上角被版本入口占着; + * - `editor`:UI 编辑器是整页编辑器壳。 + * + * 两页之间切换时锚点高度走样式里的 transition,不瞬移。 */ - placement?: 'canvas' | 'run' | 'editor'; + placement?: 'canvas-overview' | 'canvas' | 'run' | 'editor'; }; /** @@ -98,9 +105,57 @@ function sortResourceCanvasGenerationTaskRows( ); } +/** + * 一条图片类生成任务的「已耗时」。 + * + * 刷新粒度走对话侧同一份 `useLiveNow`(`LIVE_TIMER_TICK_MS = 100`):不足一分钟的耗时显示到 + * 0.1 秒,若按 1 秒一跳,用户看到的是一块「带小数却一格一格跳」的表,像卡住不动。 + * + * 时钟只订在**这一行**上(叶子节点):面板上通常同时挂着多条任务,把 100ms 的 tick 放在整块 + * 面板上等于每 0.1 秒重建整个列表——`useLiveNow` 的注释写明「调用方必须是叶子节点」。任务收口 + * 后(终态)不再订阅,已耗时就定格在账本给的结束时间上。 + */ +function AssetGenerationElapsedLabel({ + task, +}: { + task: ResourceCanvasAssetGenerationTask; +}) { + const now = useLiveNow(!resourceCanvasAssetGenerationTaskIsTerminal(task)); + return ( + + {`已耗时 ${resourceCanvasAssetGenerationElapsedLabel( + resourceCanvasAssetGenerationTaskElapsedMillis(task, now), + )}`} + + ); +} + +/** + * 一条派生/修改任务的「已耗时」。 + * + * 同上:粒度与对话侧同一份 `useLiveNow`,只换资源编辑账本那一份文案入口。从原生账本恢复来的 + * 收口记录没有结束时间(`elapsedMillis === null`)时整格不渲染——宁可不显示,也不拿创建时间 + * 冒充耗时。 + */ +function ResourceEditElapsedLabel({ + task, +}: { + task: ResourceCanvasResourceEditTask; +}) { + const now = useLiveNow(!resourceCanvasResourceEditTaskIsTerminal(task)); + const elapsedMillis = resourceCanvasResourceEditTaskElapsedMillis(task, now); + if (elapsedMillis === null) { + return null; + } + return ( + + {`已耗时 ${resourceCanvasResourceEditElapsedLabel(elapsedMillis)}`} + + ); +} + function assetGenerationTaskRow( task: ResourceCanvasAssetGenerationTask, - nowMillis: number, onFocusTask: (task: ResourceCanvasAssetGenerationTask) => void, ) { const focusable = task.status === 'completed' && Boolean(task.assetId); @@ -128,11 +183,7 @@ function assetGenerationTaskRow( {task.phaseDetail} - - {`已耗时 ${resourceCanvasAssetGenerationElapsedLabel( - resourceCanvasAssetGenerationTaskElapsedMillis(task, nowMillis), - )}`} - + {task.error ? (

@@ -160,14 +211,7 @@ function assetGenerationTaskRow( * 给的待办记录里没有产物 id,编一个指向不明的跳转不如不给;② 提示词单独一行,用户要能认出手上 * 这行是哪一次修改。 */ -function resourceEditTaskRow( - task: ResourceCanvasResourceEditTask, - nowMillis: number, -) { - const elapsedMillis = resourceCanvasResourceEditTaskElapsedMillis( - task, - nowMillis, - ); +function resourceEditTaskRow(task: ResourceCanvasResourceEditTask) { return (

  • {task.phaseDetail} - {elapsedMillis === null ? null : ( - - {`已耗时 ${resourceCanvasResourceEditElapsedLabel(elapsedMillis)}`} - - )} + {task.error ? (

    @@ -218,12 +258,11 @@ function resourceEditTaskRow( function resourceCanvasGenerationTaskRowNode( row: ResourceCanvasGenerationTaskRow, - nowMillis: number, onFocusTask: (task: ResourceCanvasAssetGenerationTask) => void, ) { return row.source === 'asset-generation' - ? assetGenerationTaskRow(row.task, nowMillis, onFocusTask) - : resourceEditTaskRow(row.task, nowMillis); + ? assetGenerationTaskRow(row.task, onFocusTask) + : resourceEditTaskRow(row.task); } /** @@ -251,7 +290,6 @@ export function ResourceCanvasAssetGenerationTasksPanelView({ onFocusTask, placement = 'canvas', }: ResourceCanvasAssetGenerationTasksPanelViewProps) { - const [nowMillis, setNowMillis] = useState(() => Date.now()); /** * 收起动画期:`open` 已经变 false,但侧栏还要留在 DOM 里把 `…-leave` 播完 * (`entering` 是刚打开时给根节点挂进场动画的那一档)。 @@ -285,7 +323,6 @@ export function ResourceCanvasAssetGenerationTasksPanelView({ ); const done = ordered.filter(resourceCanvasGenerationTaskRowIsTerminal); const inFlightCount = active.length; - const hasLiveTask = inFlightCount > 0; const visibleDone = done.slice( 0, RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT, @@ -314,14 +351,12 @@ export function ResourceCanvasAssetGenerationTasksPanelView({ ? { inFlightCount, ordered, active, visibleDone, done } : lastRenderedRef.current; - // 已耗时是前端计时(后端只给时间戳):只在还有未终态任务时走秒表,全部收口后停掉。 - useEffect(() => { - if (!hasLiveTask) { - return undefined; - } - const timer = setInterval(() => setNowMillis(Date.now()), 1_000); - return () => clearInterval(timer); - }, [hasLiveTask]); + /* + * 已耗时是前端计时(后端只给时间戳):秒表**不在这一层**——面板上通常同时挂着多条任务, + * 在这里订阅 100ms 的 tick 等于每 0.1 秒重建整个侧栏。计时下沉到每一行的 + * `AssetGenerationElapsedLabel` / `ResourceEditElapsedLabel`,只在**还有未终态任务**时 + * 订阅(终态行定格在账本的结束时间上)。 + */ /** * open → 进场,close → 先留一帧播 `…-leave` 再卸载。 @@ -430,11 +465,7 @@ export function ResourceCanvasAssetGenerationTasksPanelView({ ) : (

      {rendered.active.map((row) => - resourceCanvasGenerationTaskRowNode( - row, - nowMillis, - onFocusTask, - ), + resourceCanvasGenerationTaskRowNode(row, onFocusTask), )}
    )} @@ -457,11 +488,7 @@ export function ResourceCanvasAssetGenerationTasksPanelView({ <>
      {rendered.visibleDone.map((row) => - resourceCanvasGenerationTaskRowNode( - row, - nowMillis, - onFocusTask, - ), + resourceCanvasGenerationTaskRowNode(row, onFocusTask), )}
    {rendered.done.length > rendered.visibleDone.length ? ( diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPlaceholderCardView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPlaceholderCardView.tsx index 2c0305d3b..503a6489a 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPlaceholderCardView.tsx +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPlaceholderCardView.tsx @@ -2,6 +2,7 @@ import './resourceCanvasGenerationPanel.css'; import { Sparkles, X } from 'lucide-react'; import type { PointerEvent as ReactPointerEvent } from 'react'; +import { useRef } from 'react'; import { RESOURCE_CANVAS_GENERATION_PLACEHOLDER_STATUS_LABELS, @@ -44,6 +45,14 @@ export function ResourceCanvasGenerationPlaceholderCardView({ onTogglePanel, onRemove, }: ResourceCanvasGenerationPlaceholderCardViewProps) { + /** + * 这一次点击手势是从哪儿起手的。 + * + * 删除按钮自己会 `stopPropagation`,所以「按在删除按钮上、松手落回卡片」这种手势里, + * 浏览器把 `click` 派给两者的共同祖先(也就是这张卡)——删除按钮的删除没发生,卡片的 + * 「开合生成浮层」却执行了。判据不看移动距离:**从删除按钮起手的手势永远不是「点卡片」**。 + */ + const gestureOriginRef = useRef<'card' | 'remove'>('card'); return (
    { + gestureOriginRef.current = 'card'; + onPointerDown(event); + }} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerCancel} onLostPointerCapture={onPointerCancel} onClick={(event) => { event.stopPropagation(); + const origin = gestureOriginRef.current; + gestureOriginRef.current = 'card'; + if (origin === 'remove') { + return; + } onTogglePanel(); }} onKeyDown={(event) => { @@ -87,7 +104,12 @@ export function ResourceCanvasGenerationPlaceholderCardView({