chore: 迁移到 pnpm 并更新打包与测试文档
- 用 pnpm 替换 npm,锁定 pnpm@10.33.0,删除 package-lock.json 相关说明 - package.json 将渲染层依赖移入 devDependencies,减小 app.asar 体积 - 新增 electron:smoke 冒烟脚本与 icon 生成脚本 - win 平台设置 AppUserModelID,修复任务栏图标归组 - 清理 tsBuildInfoFile 输出路径至 node_modules/.tmp - 更新测试断言以匹配画幅遮罩、取景器缩放与模型库外部资产行为 - README 与打包指南补充 pnpm、镜像与常见问题说明
This commit is contained in:
136
scripts/generate_icon.py
Normal file
136
scripts/generate_icon.py
Normal file
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
"""生成应用图标 public/icon.png(512x512)。
|
||||
|
||||
仓库原先引用了 public/icon.png 但文件缺失,导致 electron-builder 回落到默认
|
||||
Electron 图标。本脚本零第三方依赖(仅标准库 zlib/struct),可重复生成:
|
||||
|
||||
python scripts/generate_icon.py
|
||||
|
||||
图形语义:视口取景框(四角括号)+ 播放三角,对应"预演/播放分镜"。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
SIZE = 512
|
||||
SS = 3 # 超采样倍率,用于边缘抗锯齿
|
||||
W = SIZE * SS
|
||||
|
||||
# 取自 src/styles/index.css 的设计令牌
|
||||
BG_TOP = (22, 24, 28)
|
||||
BG_BOTTOM = (9, 9, 9)
|
||||
BRACKET = (242, 244, 246)
|
||||
ACCENT = (3, 150, 255)
|
||||
|
||||
|
||||
def rounded_rect_coverage(px: float, py: float, x0: float, y0: float, x1: float, y1: float, radius: float) -> bool:
|
||||
if not (x0 <= px <= x1 and y0 <= py <= y1):
|
||||
return False
|
||||
cx = min(max(px, x0 + radius), x1 - radius)
|
||||
cy = min(max(py, y0 + radius), y1 - radius)
|
||||
dx = px - cx
|
||||
dy = py - cy
|
||||
return dx * dx + dy * dy <= radius * radius
|
||||
|
||||
|
||||
def bracket_coverage(px: float, py: float, inset: float, arm: float, thickness: float) -> bool:
|
||||
"""四角取景框括号:每角由一条横臂和一条竖臂组成。"""
|
||||
lo = inset
|
||||
hi = SIZE - inset
|
||||
horizontal_arm = (lo <= px <= lo + arm) or (hi - arm <= px <= hi)
|
||||
vertical_arm = (lo <= py <= lo + arm) or (hi - arm <= py <= hi)
|
||||
horizontal_bar = (lo <= py <= lo + thickness) or (hi - thickness <= py <= hi)
|
||||
vertical_bar = (lo <= px <= lo + thickness) or (hi - thickness <= px <= hi)
|
||||
return (horizontal_arm and horizontal_bar) or (vertical_arm and vertical_bar)
|
||||
|
||||
|
||||
def triangle_coverage(px: float, py: float) -> bool:
|
||||
ax, ay = 196.0, 168.0
|
||||
bx, by = 196.0, 344.0
|
||||
cx, cy = 356.0, 256.0
|
||||
d1 = (px - bx) * (ay - by) - (ax - bx) * (py - by)
|
||||
d2 = (px - cx) * (by - cy) - (bx - cx) * (py - cy)
|
||||
d3 = (px - ax) * (cy - ay) - (cx - ax) * (py - ay)
|
||||
has_neg = d1 < 0 or d2 < 0 or d3 < 0
|
||||
has_pos = d1 > 0 or d2 > 0 or d3 > 0
|
||||
return not (has_neg and has_pos)
|
||||
|
||||
|
||||
def build_pixels() -> bytearray:
|
||||
samples = SS * SS
|
||||
pixels = bytearray(SIZE * SIZE * 4)
|
||||
for oy in range(SIZE):
|
||||
for ox in range(SIZE):
|
||||
bg_hits = 0
|
||||
bracket_hits = 0
|
||||
triangle_hits = 0
|
||||
for sy in range(SS):
|
||||
for sx in range(SS):
|
||||
px = ox + (sx + 0.5) / SS
|
||||
py = oy + (sy + 0.5) / SS
|
||||
if rounded_rect_coverage(px, py, 0.0, 0.0, float(SIZE), float(SIZE), 118.0):
|
||||
bg_hits += 1
|
||||
if bracket_coverage(px, py, inset=92.0, arm=104.0, thickness=26.0):
|
||||
bracket_hits += 1
|
||||
if triangle_coverage(px, py):
|
||||
triangle_hits += 1
|
||||
|
||||
alpha = bg_hits / samples
|
||||
if alpha <= 0:
|
||||
continue
|
||||
|
||||
ratio = oy / (SIZE - 1)
|
||||
base = tuple(
|
||||
BG_TOP[channel] + (BG_BOTTOM[channel] - BG_TOP[channel]) * ratio for channel in range(3)
|
||||
)
|
||||
|
||||
accent_cov = (bracket_hits + triangle_hits) / samples
|
||||
if accent_cov > 0:
|
||||
layer = BRACKET if bracket_hits >= triangle_hits else ACCENT
|
||||
layer_alpha = min(1.0, accent_cov / max(alpha, 1e-6))
|
||||
base = tuple(
|
||||
base[channel] + (layer[channel] - base[channel]) * layer_alpha for channel in range(3)
|
||||
)
|
||||
|
||||
offset = (oy * SIZE + ox) * 4
|
||||
pixels[offset] = int(round(base[0]))
|
||||
pixels[offset + 1] = int(round(base[1]))
|
||||
pixels[offset + 2] = int(round(base[2]))
|
||||
pixels[offset + 3] = int(round(alpha * 255))
|
||||
return pixels
|
||||
|
||||
|
||||
def write_png(path: Path, pixels: bytearray) -> None:
|
||||
raw = bytearray()
|
||||
stride = SIZE * 4
|
||||
for y in range(SIZE):
|
||||
raw.append(0) # filter type 0
|
||||
raw.extend(pixels[y * stride : (y + 1) * stride])
|
||||
|
||||
def chunk(tag: bytes, payload: bytes) -> bytes:
|
||||
return (
|
||||
struct.pack(">I", len(payload))
|
||||
+ tag
|
||||
+ payload
|
||||
+ struct.pack(">I", zlib.crc32(tag + payload) & 0xFFFFFFFF)
|
||||
)
|
||||
|
||||
png = b"\x89PNG\r\n\x1a\n"
|
||||
png += chunk(b"IHDR", struct.pack(">IIBBBBB", SIZE, SIZE, 8, 6, 0, 0, 0))
|
||||
png += chunk(b"IDAT", zlib.compress(bytes(raw), 9))
|
||||
png += chunk(b"IEND", b"")
|
||||
path.write_bytes(png)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
target = Path(__file__).resolve().parent.parent / "public" / "icon.png"
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
write_png(target, build_pixels())
|
||||
print(f"wrote {target} ({target.stat().st_size} bytes, {SIZE}x{SIZE})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
105
scripts/test-electron.cjs
Normal file
105
scripts/test-electron.cjs
Normal file
@@ -0,0 +1,105 @@
|
||||
// scripts/test-electron.cjs
|
||||
// 冒烟测试:确认构建产物 dist/index.html 能被 Electron 正常加载,且渲染层没有报错。
|
||||
//
|
||||
// 用法:pnpm run electron:smoke
|
||||
// 注意:package.json 中有 "type": "module",因此本文件必须使用 .cjs 后缀,
|
||||
// 否则 require 会因被当作 ESM 而报错。
|
||||
//
|
||||
// 这个脚本也用来验证「dist 是自包含的」:渲染层需要的所有依赖都已被 Vite
|
||||
// 打包进 dist/assets,因此打包时不需要把 node_modules 塞进 app.asar。
|
||||
|
||||
// 有的终端 / CI 会注入 ELECTRON_RUN_AS_NODE=1,那会让 Electron 退化成纯 Node,
|
||||
// require('electron') 只返回二进制路径,后面会以 "Cannot read properties of
|
||||
// undefined (reading 'whenReady')" 收场。提前拦下并给出可执行的修复命令。
|
||||
if (process.env.ELECTRON_RUN_AS_NODE) {
|
||||
console.error("FAIL 检测到环境变量 ELECTRON_RUN_AS_NODE 已设置。");
|
||||
console.error(" Electron 会因此退化成纯 Node,require('electron') 只返回二进制路径。");
|
||||
console.error(" 清掉它再运行:");
|
||||
console.error(" bash : unset ELECTRON_RUN_AS_NODE");
|
||||
console.error(" cmd : set ELECTRON_RUN_AS_NODE=");
|
||||
console.error(" PowerShell: Remove-Item Env:ELECTRON_RUN_AS_NODE");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { app, BrowserWindow } = require("electron");
|
||||
const path = require("path");
|
||||
|
||||
console.log("Electron:", process.versions.electron);
|
||||
console.log("Chrome:", process.versions.chrome);
|
||||
console.log("Node:", process.versions.node);
|
||||
|
||||
const distEntry = path.join(__dirname, "..", "dist", "index.html");
|
||||
|
||||
const rendererErrors = [];
|
||||
const IGNORABLE = [
|
||||
// 模型库是仓库外的外部资产,缺失时浏览器只会报资源 404,不影响应用本身
|
||||
/模型库/,
|
||||
/net::ERR_FILE_NOT_FOUND/,
|
||||
];
|
||||
|
||||
// 加载 + 渲染 + 首帧动画都跑完,再判定结果
|
||||
const SETTLE_MS = 4000;
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 800,
|
||||
height: 600,
|
||||
show: false,
|
||||
webPreferences: {
|
||||
// 与 electron/main.cjs 保持一致,避免冒烟环境比生产环境宽松
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
},
|
||||
});
|
||||
|
||||
mainWindow.webContents.on("console-message", (_event, level, message) => {
|
||||
// level: 0=verbose 1=info 2=warning 3=error
|
||||
if (level >= 3 && !IGNORABLE.some((re) => re.test(message))) {
|
||||
rendererErrors.push(message);
|
||||
}
|
||||
});
|
||||
|
||||
mainWindow.webContents.on("did-fail-load", (_event, code, description, url) => {
|
||||
rendererErrors.push(`did-fail-load ${code} ${description} ${url}`);
|
||||
});
|
||||
|
||||
mainWindow.webContents.on("render-process-gone", (_event, details) => {
|
||||
rendererErrors.push(`render-process-gone ${details.reason}`);
|
||||
});
|
||||
|
||||
try {
|
||||
await mainWindow.loadFile(distEntry);
|
||||
console.log("OK 页面加载成功:", distEntry);
|
||||
} catch (error) {
|
||||
console.error("FAIL 页面加载失败:", error.message);
|
||||
console.error(" 请先执行 pnpm run build 生成 dist/");
|
||||
app.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// 给 React 首帧和 R3F 初始化留出时间,暴露运行时错误
|
||||
await new Promise((resolve) => setTimeout(resolve, SETTLE_MS));
|
||||
|
||||
const rootChildCount = await mainWindow.webContents
|
||||
.executeJavaScript("document.getElementById('root')?.childElementCount ?? -1")
|
||||
.catch(() => -1);
|
||||
|
||||
if (rootChildCount > 0) {
|
||||
console.log(`OK React 已挂载(#root 子节点 ${rootChildCount} 个)`);
|
||||
} else {
|
||||
rendererErrors.push(`#root 未渲染任何子节点(childElementCount=${rootChildCount})`);
|
||||
}
|
||||
|
||||
if (rendererErrors.length === 0) {
|
||||
console.log("PASS 冒烟测试通过:页面加载、React 挂载、无渲染层错误");
|
||||
app.exit(0);
|
||||
} else {
|
||||
console.error(`FAIL 渲染层出现 ${rendererErrors.length} 个错误:`);
|
||||
rendererErrors.slice(0, 10).forEach((message) => console.error(" -", message));
|
||||
app.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
app.quit();
|
||||
});
|
||||
Reference in New Issue
Block a user