Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ jobs:

- name: Run Web supervisor and world shell tests
shell: pwsh
run: node --test apps/desktop/darwin-target.test.mjs apps/web/tests/home.test.mjs apps/web/tests/supervise.test.mjs games/mosslight/tests/developer_panel.test.mjs scripts/world-export-state.test.mjs
run: node --test apps/desktop/darwin-target.test.mjs apps/desktop/darwin-icon.test.mjs apps/web/tests/home.test.mjs apps/web/tests/supervise.test.mjs games/mosslight/tests/developer_panel.test.mjs scripts/world-export-state.test.mjs

world:
name: Godot world checks
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ corepack yarn build:desktop:darwin
corepack yarn verify:desktop:darwin
```

The Swift menu-bar launcher source is in `apps/desktop/macos/Launcher.swift` and is compiled with the system `swiftc` from Command Line Tools, targeting macOS 13.5 or later to match the official Node 24 binary supported by the packaging workflow. Packaging shares `apps/desktop/pack-app.mjs` with Windows and copies the current architecture's Node runtime and native dependencies. Intel and Apple Silicon packages must be installed, built, and verified separately in matching native environments. Artifacts are written to `dist/desktop-darwin-*/`, including `qcode-darwin-x64.zip` or `qcode-darwin-arm64.zip` and `SHA256SUMS.txt`. Verification covers binary architecture, readiness smoke tests, process cleanup on exit, single-instance behavior, native module loading, and data migration.
The Swift menu-bar launcher source is in `apps/desktop/macos/Launcher.swift` and is compiled with the system `swiftc` from Command Line Tools, targeting macOS 13.5 or later to match the official Node 24 binary supported by the packaging workflow. The app icon is generated during packaging by `apps/desktop/darwin-icon.mjs`, which uses the system `sips` and `iconutil` to build an `AppIcon.icns` (16–512px including Retina scales) from `assets/brand/android-chrome-512x512.png`; a missing source image or a failed generation aborts the build instead of shipping an unbranded bundle. Packaging shares `apps/desktop/pack-app.mjs` with Windows and copies the current architecture's Node runtime and native dependencies. Intel and Apple Silicon packages must be installed, built, and verified separately in matching native environments. Artifacts are written to `dist/desktop-darwin-*/`, including `qcode-darwin-x64.zip` or `qcode-darwin-arm64.zip` and `SHA256SUMS.txt`. Verification covers binary architecture, readiness smoke tests, process cleanup on exit, single-instance behavior, native module loading, and data migration.

An online edition would require a separate account authentication and permission-isolation design; local temporary credentials are not an online login solution.

Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ corepack yarn build:desktop:darwin
corepack yarn verify:desktop:darwin
```

Swift 菜单栏启动器源码在 `apps/desktop/macos/Launcher.swift`,用系统 `swiftc` 编译(Command Line Tools 即可),最低系统版本固定为 macOS 13.5,与允许用于打包的 Node 24 官方二进制兼容边界一致。打包逻辑与 Windows 共用 `apps/desktop/pack-app.mjs`,复制当前架构的 Node 与原生依赖;Intel 和 Apple Silicon 必须分别在对应架构环境安装依赖、构建和验证。产物位于 `dist/desktop-darwin-*/`,含对应的 `qcode-darwin-x64.zip` 或 `qcode-darwin-arm64.zip` 与 `SHA256SUMS.txt`。验证覆盖二进制架构、冒烟就绪、退出清进程、单实例、原生模块加载与数据迁移。
Swift 菜单栏启动器源码在 `apps/desktop/macos/Launcher.swift`,用系统 `swiftc` 编译(Command Line Tools 即可),最低系统版本固定为 macOS 13.5,与允许用于打包的 Node 24 官方二进制兼容边界一致。应用图标由 `apps/desktop/darwin-icon.mjs` 在打包时用系统 `sips` 与 `iconutil` 从 `assets/brand/android-chrome-512x512.png` 现场生成 16–512px 含 Retina 档的 `AppIcon.icns` 并写入 bundle,源图缺失或生成失败会中止构建,不产出无品牌发布物。打包逻辑与 Windows 共用 `apps/desktop/pack-app.mjs`,复制当前架构的 Node 与原生依赖;Intel 和 Apple Silicon 必须分别在对应架构环境安装依赖、构建和验证。产物位于 `dist/desktop-darwin-*/`,含对应的 `qcode-darwin-x64.zip` 或 `qcode-darwin-arm64.zip` 与 `SHA256SUMS.txt`。验证覆盖二进制架构、冒烟就绪、退出清进程、单实例、原生模块加载与数据迁移。

在线版本需要另行设计账号认证和权限隔离;本地临时凭证不作为在线登录方案。

Expand Down
11 changes: 8 additions & 3 deletions apps/desktop/build-darwin.mjs
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
// 构建当前 Mac 架构的便携包:内置 Node、Web 插件、Godot 世界与菜单栏启动器。
import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { spawnSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import path from 'node:path'
import { embedNodeRuntime, materializeAppTree, requireBuiltArtifacts, root } from './pack-app.mjs'
import { resolveDarwinTarget } from './darwin-target.mjs'
import { buildDarwinAppIcon, missingIconSourceError, DARWIN_ICON_SOURCE, DARWIN_ICNS_BASENAME } from './darwin-icon.mjs'

if (process.platform !== 'darwin') throw new Error('需要在 macOS 上构建 darwin 便携包')
const target = resolveDarwinTarget(process.arch)
if (!existsSync(path.join(root, DARWIN_ICON_SOURCE))) {
throw missingIconSourceError(DARWIN_ICON_SOURCE)
}

requireBuiltArtifacts()

Expand Down Expand Up @@ -50,6 +54,7 @@ const infoPlist = `<?xml version="1.0" encoding="UTF-8"?>
<key>CFBundleShortVersionString</key><string>0.0.0-preview</string>
<key>CFBundleExecutable</key><string>qcode</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleIconFile</key><string>${DARWIN_ICNS_BASENAME}</string>
<key>LSMinimumSystemVersion</key><string>${target.minimumSystemVersion}</string>
<key>LSUIElement</key><true/>
<key>NSHighResolutionCapable</key><true/>
Expand All @@ -58,8 +63,8 @@ const infoPlist = `<?xml version="1.0" encoding="UTF-8"?>
`
writeFileSync(path.join(bundle, 'Contents', 'Info.plist'), infoPlist)

const iconSource = path.join(root, 'assets/brand/favicon.ico')
if (existsSync(iconSource)) cpSync(iconSource, path.join(resourcesDir, 'favicon.ico'))
// 图标缺失或生成失败必须中止构建,禁止静默产出无品牌发布物。
buildDarwinAppIcon({ sourcePath: path.join(root, DARWIN_ICON_SOURCE), resourcesDir })

const binary = path.join(macosDir, 'qcode')
const compile = spawnSync(
Expand Down
68 changes: 68 additions & 0 deletions apps/desktop/darwin-icon.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// 从品牌源图可复现地生成 macOS 原生应用图标(ICNS),供 QCode.app bundle 使用。
import { execFileSync } from 'node:child_process'
import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'

export const DARWIN_ICON_SOURCE = 'assets/brand/android-chrome-512x512.png'
export const DARWIN_ICNS_BASENAME = 'AppIcon'

// iconset 命名遵循 Apple 规范;源图 512px 是仓库现有最高分辨率品牌图,
// 不做超采样补 1024 档——Finder 与 Dock 最大只消费到 512。
export const ICONSET_ENTRIES = [
{ outputName: 'icon_16x16.png', pixelWidth: 16 },
{ outputName: 'icon_16x16@2x.png', pixelWidth: 32 },
{ outputName: 'icon_32x32.png', pixelWidth: 32 },
{ outputName: 'icon_32x32@2x.png', pixelWidth: 64 },
{ outputName: 'icon_128x128.png', pixelWidth: 128 },
{ outputName: 'icon_128x128@2x.png', pixelWidth: 256 },
{ outputName: 'icon_256x256.png', pixelWidth: 256 },
{ outputName: 'icon_256x256@2x.png', pixelWidth: 512 },
{ outputName: 'icon_512x512.png', pixelWidth: 512 },
]

export function missingIconSourceError(sourcePath) {
return new Error(
`缺少 macOS 应用图标源图:${sourcePath}。构建拒绝静默产出无品牌便携包,请恢复 assets/brand/ 下的品牌资源后重试。`,
)
}

// exec 注入以便跨平台测试;真实 macOS 构建走 sips + iconutil,生成方式即代码。
export function buildDarwinAppIcon({
sourcePath,
resourcesDir,
exec = execFileSync,
makeWorkdir = () => mkdtempSync(path.join(tmpdir(), 'qcode-iconset.')),
} = {}) {
if (!existsSync(sourcePath)) {
throw missingIconSourceError(sourcePath)
}
const workdir = makeWorkdir()
const iconset = path.join(workdir, `${DARWIN_ICNS_BASENAME}.iconset`)
try {
mkdirSync(iconset)
for (const { outputName, pixelWidth } of ICONSET_ENTRIES) {
const target = path.join(iconset, outputName)
try {
exec('sips', ['-z', String(pixelWidth), String(pixelWidth), sourcePath, '--out', target])
} catch (error) {
throw new Error(
`生成应用图标尺寸 ${outputName} 失败:sips 未能缩放品牌源图(${error?.message ?? error})。` +
'请确认在装有 Xcode Command Line Tools 的 macOS 上构建。',
)
}
}
const icnsPath = path.join(resourcesDir, `${DARWIN_ICNS_BASENAME}.icns`)
try {
exec('iconutil', ['-c', 'icns', iconset, '-o', icnsPath])
} catch (error) {
throw new Error(
`打包 ICNS 失败:iconutil 未能在 ${icnsPath} 产出应用图标(${error?.message ?? error})。` +
'构建中止,不产出缺少品牌图标的便携包。',
)
}
return icnsPath
} finally {
rmSync(workdir, { recursive: true, force: true })
}
}
77 changes: 77 additions & 0 deletions apps/desktop/darwin-icon.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import assert from 'node:assert/strict'
import { existsSync, mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import test from 'node:test'
import { ICONSET_ENTRIES, buildDarwinAppIcon, DARWIN_ICON_SOURCE } from './darwin-icon.mjs'

const scratch = () => mkdtempSync(path.join(tmpdir(), 'qcode-icon-test.'))

test('iconset covers Finder and Retina scales derived from the 512px brand source', () => {
assert.deepEqual(
ICONSET_ENTRIES.map(({ outputName, pixelWidth }) => `${pixelWidth}:${outputName}`),
[
'16:icon_16x16.png',
'32:icon_16x16@2x.png',
'32:icon_32x32.png',
'64:icon_32x32@2x.png',
'128:icon_128x128.png',
'256:icon_128x128@2x.png',
'256:icon_256x256.png',
'512:icon_256x256@2x.png',
'512:icon_512x512.png',
],
)
assert.ok(DARWIN_ICON_SOURCE.endsWith('.png'))
})

test('missing brand source fails the build instead of shipping an unbranded bundle', () => {
assert.throws(
() => buildDarwinAppIcon({ sourcePath: '/nonexistent/brand.png', resourcesDir: scratch(), exec: () => {} }),
/缺少 macOS 应用图标源图.*拒绝静默产出无品牌便携包/s,
)
})

test('sips failure names the scale that broke', () => {
const failing = (command) => {
if (command === 'sips') throw new Error('sips boom')
}
assert.throws(
() => buildDarwinAppIcon({ sourcePath: import.meta.filename, resourcesDir: scratch(), exec: failing }),
/生成应用图标尺寸 icon_16x16\.png 失败/,
)
})

test('iconutil failure aborts before the bundle is accepted', () => {
const exec = (command) => {
if (command === 'iconutil') throw new Error('iconutil boom')
}
assert.throws(
() => buildDarwinAppIcon({ sourcePath: import.meta.filename, resourcesDir: scratch(), exec }),
/打包 ICNS 失败.*不产出缺少品牌图标的便携包/s,
)
})

test('success path runs every scale, writes AppIcon.icns, and cleans the workdir', () => {
const source = path.join(scratch(), 'brand.png')
writeFileSync(source, 'png')
const resourcesDir = scratch()
const workdir = scratch()
const calls = []
const exec = (command, args) => {
calls.push([command, args[0]])
if (command === 'sips') writeFileSync(args[args.length - 1], 'scaled')
if (command === 'iconutil') writeFileSync(args[args.length - 1], 'icns')
}
const icnsPath = buildDarwinAppIcon({
sourcePath: source,
resourcesDir,
exec,
makeWorkdir: () => workdir,
})
assert.equal(icnsPath, path.join(resourcesDir, 'AppIcon.icns'))
assert.ok(existsSync(icnsPath))
assert.equal(calls.filter(([command]) => command === 'sips').length, ICONSET_ENTRIES.length)
assert.deepEqual(calls.at(-1), ['iconutil', '-c'])
assert.ok(!existsSync(path.join(workdir, 'AppIcon.iconset')))
})
2 changes: 2 additions & 0 deletions docs/construction-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

## 产品目标

macOS 便携启动器应用图标(2026-09-20,已实现待验收):`apps/desktop/darwin-icon.mjs` 在打包时用系统 `sips` 与 `iconutil` 从 `assets/brand/android-chrome-512x512.png`(512px、透明)生成 16–512px 含 Retina 的 `AppIcon.icns` 写入 bundle Resources,`Info.plist` 声明 `CFBundleIconFile`;替换原先静默复制 `favicon.ico` 的路径,源图缺失时在产生任何构建产物前立即失败。5 项注入式图标测试通过并加入 CI;Apple M5 Pro / macOS 26.5.1 / arm64 实机重新导出世界并完成完整打包,产出 743,818 字节 ICNS,`iconutil` 反解确认 9 档完整,ZIP 解压往返后清单与图标一致,缺失源图构建即时拒绝。Finder 与应用信息目视截图见 PR 记录;Intel 机器未覆盖(图标流水线与架构无关,未在真机重复执行)。详见 [桌面启动器说明](../apps/desktop/README.md)。

QCode 重命名(2026-09-18,已实现待验收):产品与公开仓库已改名为 QCode;Mosslight / 苔光之屿继续作为世界名称。workspace、Web 插件目录与包名、TypeScript / Godot 自有符号、DOM 命名空间、Web/Godot Bridge、桌面应用、产品文档和发布链接已改用 QCode;新路由和消息使用 `/qcode/*`、`qcode-host`、`qcode-world`,迁移期兼容旧路由、请求头、消息来源和 Bridge 名称。浏览器存储、开发目录、Host 状态文件及桌面用户数据均采用无覆盖迁移,教程存储 domain 保持旧稳定身份以读取既有进度,世界导出状态改用 `.qcode-world-export.json`;`check:qcode-naming` 已接入 CI,开发目录迁移测试也已加入 CI。immutable 安装、类型检查、Web 构建、61 项插件测试、23 项本轮启动/迁移/文档及辅助测试、Godot 4.7.2 的 7 个 CI 场景、世界导出及哈希校验通过;隔离临时数据目录在 3081 端口启动后,认证入口、首页、QCode 图标与世界 HTML/WASM/PCK 的 HTTP 冒烟通过。Windows QCode 安装包与便携包完成安装提取、认证页面、世界资源、退出清理、单实例、原生模块、旧数据迁移与双目录冲突验收。介绍页通过 1440px 桌面截图检查,真实 390px 浏览器视口无横向溢出,动态标题正确。GitHub API 确认公开仓库为 `Qiuner/QCode`、remote 和 Pages 地址已更新且主分支保护保持不变,新 Pages 路径返回 200。macOS 脚本已同步但未在原生环境执行;真实浏览器 WebGL 与模型请求未在本轮验收,远端 CI 与线上页面的新内容仍待验收。本地 checkout 路径暂不改名,以免使活跃任务工作区失效。详见 [QCode 重命名施工方案](qcode-renaming-plan.md)。

世界导出新鲜度校验(2026-09-18,已实现待验收):Godot Web 导出记录场景、脚本、运行时资源与 Web 壳输入的总体 SHA-256;`check:world-export`、开发启动及桌面打包在哈希不一致、状态缺失或关键产物缺失时停止并要求重新执行 `build:world`。4 项校验器测试、脚本语法、真实世界重新导出、独立校验命令与桌面打包前置检查通过;未重新生成安装包。
Expand Down
Loading