diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml
index c014c856..e1280546 100644
--- a/.github/workflows/build-release.yml
+++ b/.github/workflows/build-release.yml
@@ -153,6 +153,29 @@ jobs:
shell: bash
run: bash packaging/build-runtime.sh
+ # ── 图标资产生成(rant 2026-08-11T18:28:09:仓库只保留 icon.svg 设计源,
+ # png/icns/ico 由 CI 构建时现生成,必须先于 GUI 构建执行)──
+ - name: Generate icon assets (icon.svg → png/icns/ico)
+ shell: bash
+ run: |
+ set -euo pipefail
+ if command -v rsvg-convert >/dev/null 2>&1; then
+ echo "rsvg-convert already available"
+ elif [ "$RUNNER_OS" = "Linux" ]; then
+ sudo apt-get update -qq && sudo apt-get install -y -qq librsvg2-bin
+ elif [ "$RUNNER_OS" = "macOS" ]; then
+ brew install librsvg
+ else
+ echo "no rsvg-convert; gen-assets.sh will fall back to Chrome/Edge headless"
+ fi
+ bash packaging/gen-assets.sh
+ # 产物必须存在(GUI 构建引用 ../packaging/assets/*.png/icns/ico)
+ test -s packaging/assets/icon.png
+ if [ "$RUNNER_OS" = "macOS" ]; then
+ test -s packaging/assets/icon.icns
+ fi
+ test -s packaging/assets/icon.ico
+
# ── GUI dist(electron-builder;linux extraResources runtime §5 R64)──
- name: Build GUI (electron-builder)
working-directory: emrg/gui
diff --git a/.gitignore b/.gitignore
index df54b9f7..98bba0a6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,3 +11,10 @@ node_modules/
.pytest_cache/
/dist/
/emrg/gui/dist/
+# icon products generated at build time from packaging/assets/icon.svg (design source)
+# (rant 2026-08-11T18:28:09: repo keeps only the SVG; png/icns/ico generated by gen-assets.sh)
+packaging/assets/icon.png
+packaging/assets/icon-512.png
+packaging/assets/icon-256.png
+packaging/assets/icon.icns
+packaging/assets/icon.ico
diff --git a/packaging/assets/icon-256.png b/packaging/assets/icon-256.png
deleted file mode 100644
index 55902400..00000000
Binary files a/packaging/assets/icon-256.png and /dev/null differ
diff --git a/packaging/assets/icon-512.png b/packaging/assets/icon-512.png
deleted file mode 100644
index bc6179cf..00000000
Binary files a/packaging/assets/icon-512.png and /dev/null differ
diff --git a/packaging/assets/icon.icns b/packaging/assets/icon.icns
deleted file mode 100644
index 553a3ce3..00000000
Binary files a/packaging/assets/icon.icns and /dev/null differ
diff --git a/packaging/assets/icon.ico b/packaging/assets/icon.ico
deleted file mode 100644
index 96af25ae..00000000
Binary files a/packaging/assets/icon.ico and /dev/null differ
diff --git a/packaging/assets/icon.png b/packaging/assets/icon.png
deleted file mode 100644
index 794c9bca..00000000
Binary files a/packaging/assets/icon.png and /dev/null differ
diff --git a/packaging/assets/icon.svg b/packaging/assets/icon.svg
new file mode 100644
index 00000000..ee62a043
--- /dev/null
+++ b/packaging/assets/icon.svg
@@ -0,0 +1,121 @@
+
+
diff --git a/packaging/gen-assets.sh b/packaging/gen-assets.sh
index 3f489548..a09d3d25 100644
--- a/packaging/gen-assets.sh
+++ b/packaging/gen-assets.sh
@@ -1,102 +1,89 @@
#!/usr/bin/env bash
-# EMRG Phase 4 — generate packaging assets (icons).
+# EMRG Phase 4 — generate packaging assets (icons) from the SVG design source.
#
-# Pure-stdlib approach (no PIL/ImageMagick dependency):
-# 1. Python stdlib draws a 1024x1024 PNG (EMRG glyph: dark bg + green "E" + circuit node)
+# Design source: packaging/assets/icon.svg ("Branch Emergence" — rant 2026-08-11T18:28:09)
+# Pipeline:
+# 1. Render icon.svg → 1024x1024 icon.png
+# renderer priority: rsvg-convert (librsvg) → Chrome/Chromium headless → sips
+# (sips 对 feGaussianBlur 支持有限 → 辉光丢失,仅作最后兜底并告警)
# 2. iconutil (macOS) → .icns
-# 3. Python stdlib → multi-size .ico (win)
+# 3. Python stdlib (area-average box filter) → multi-size .ico (win)
# 4. 512/256 PNG copies (linux)
#
# Output in packaging/assets/:
-# icon.png (1024) icon-512.png icon-256.png icon.icns icon.ico
+# icon.svg (design source, committed) icon.png (1024) icon-512.png
+# icon-256.png icon.icns icon.ico (generated, gitignored)
#
# Run from repo root: bash packaging/gen-assets.sh
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
OUT="$ROOT/packaging/assets"
+SVG="$OUT/icon.svg"
mkdir -p "$OUT"
-echo "==> generating 1024x1024 icon.png (pure stdlib)"
+if [ ! -f "$SVG" ]; then
+ echo "ERROR: $SVG not found — design source missing" >&2
+ exit 1
+fi
+
+echo "==> rendering icon.svg → 1024x1024 icon.png"
+render_svg_to_png() {
+ # 1) rsvg-convert (librsvg) — full SVG + filter support
+ if command -v rsvg-convert >/dev/null 2>&1; then
+ echo " renderer: rsvg-convert"
+ rsvg-convert -w 1024 -h 1024 "$SVG" -o "$OUT/icon.png"
+ return 0
+ fi
+ # 2) Chrome/Chromium headless --screenshot (full filter support)
+ # Windows (Git Bash): convert /c/... → file:///C:/... so Chrome resolves the URL.
+ SVG_URL="file://$SVG"
+ if command -v cygpath >/dev/null 2>&1; then
+ SVG_URL="file:///$(cygpath -m "$SVG")"
+ fi
+ for chrome in \
+ "${CHROME_BIN:-}" \
+ "google-chrome" "google-chrome-stable" "chromium" "chromium-browser" \
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
+ "/Applications/Chromium.app/Contents/MacOS/Chromium" \
+ "/c/Program Files/Google/Chrome/Application/chrome.exe" \
+ "/c/Program Files (x86)/Microsoft/Edge/Application/msedge.exe"; do
+ if [ -n "$chrome" ] && command -v "$chrome" >/dev/null 2>&1 || [ -x "$chrome" ] 2>/dev/null; then
+ echo " renderer: $chrome (headless)"
+ "$chrome" --headless --disable-gpu --hide-scrollbars \
+ --screenshot="$OUT/icon.png" --window-size=1024,1024 \
+ --default-background-color=00000000 "$SVG_URL" >/dev/null 2>&1
+ return 0
+ fi
+ done
+ # 3) sips (macOS last resort) — 辉光(feGaussianBlur)可能丢失
+ if command -v sips >/dev/null 2>&1; then
+ echo " ⚠️ renderer: sips (last resort) — glow may be lost (feGaussianBlur unsupported)" >&2
+ sips -s format png "$SVG" --out "$OUT/icon.png" >/dev/null 2>&1
+ return 0
+ fi
+ echo "ERROR: no SVG renderer found (rsvg-convert / chrome / sips)" >&2
+ return 1
+}
+render_svg_to_png || exit 1
+# sanity: non-empty 1024x1024 PNG
python3 - "$OUT/icon.png" <<'PYEOF'
-import sys, struct, zlib, math
-
-path = sys.argv[1]
-S = 1024
-# RGBA buffer (transparent bg)
-buf = bytearray([0, 0, 0, 0]) * (S * S)
-
-def put(x, y, r, g, b, a=255):
- if 0 <= x < S and 0 <= y < S:
- i = (y * S + x) * 4
- buf[i] = r; buf[i+1] = g; buf[i+2] = b; buf[i+3] = a
-
-def fill_circle(cx, cy, rad, r, g, b, a=255):
- # bounding box fill
- x0, x1 = max(0, int(cx-rad)), min(S, int(cx+rad)+1)
- y0, y1 = max(0, int(cy-rad)), min(S, int(cy+rad)+1)
- r2 = rad*rad
- for yy in range(y0, y1):
- for xx in range(x0, x1):
- dx, dy = xx-cx, yy-cy
- if dx*dx + dy*dy <= r2:
- put(xx, yy, r, g, b, a)
-
-def fill_rect(x0, y0, x1, y1, r, g, b, a=255):
- for yy in range(max(0,y0), min(S,y1)):
- for xx in range(max(0,x0), min(S,x1)):
- put(xx, yy, r, g, b, a)
-
-def fill_round_rect(x0, y0, x1, y1, rad, r, g, b, a=255):
- fill_rect(x0+rad, y0, x1-rad, y1, r, g, b, a)
- fill_rect(x0, y0+rad, x1, y1-rad, r, g, b, a)
- fill_circle(x0+rad, y0+rad, rad, r, g, b, a)
- fill_circle(x1-rad, y0+rad, rad, r, g, b, a)
- fill_circle(x0+rad, y1-rad, rad, r, g, b, a)
- fill_circle(x1-rad, y1-rad, rad, r, g, b, a)
-
-# background: rounded square (EMRG brand: dark navy #0f172a)
-fill_round_rect(64, 64, S-64, S-64, 180, 0x0f, 0x17, 0x2a)
-# subtle outer ring
-for rad in range(460, 480):
- fill_circle(S//2, S//2, rad, 0x1e, 0x29, 0x3b, 255)
-
-# circuit: ring + node (self-evolving motif)
-fill_circle(S//2, S//2, 300, 0x10, 0xb9, 0x81, 255) # green ring body
-fill_circle(S//2, S//2, 250, 0x0f, 0x17, 0x2a, 255) # punch hole → ring
-# 4 orbit nodes
-for ang in (0, 90, 180, 270):
- ox = S//2 + int(380 * math.cos(math.radians(ang)))
- oy = S//2 + int(380 * math.sin(math.radians(ang)))
- fill_circle(ox, oy, 40, 0x10, 0xb9, 0x81, 255)
-
-# "E" glyph (three bars + spine) — simple block letter
-bar = 70
-spine_x0, spine_x1 = S//2 - 200, S//2 - 130
-top_y, mid_y, bot_y = S//2 - 200, S//2 - 30, S//2 + 140
-fill_rect(spine_x0, top_y, spine_x1, bot_y+bar, 0xf8, 0xfa, 0xfc) # vertical spine
-fill_rect(spine_x0, top_y, S//2 + 200, top_y+bar, 0xf8, 0xfa, 0xfc) # top bar
-fill_rect(spine_x0, mid_y, S//2 + 140, mid_y+bar, 0xf8, 0xfa, 0xfc) # middle bar
-fill_rect(spine_x0, bot_y, S//2 + 200, bot_y+bar, 0xf8, 0xfa, 0xfc) # bottom bar
-
-def write_png(path, w, h, rgba):
- def chunk(tag, data):
- c = struct.pack(">I", len(data)) + tag + data
- c += struct.pack(">I", zlib.crc32(tag + data) & 0xffffffff)
- return c
- raw = b"".join(b"\x00" + bytes(rgba[y*w*4:(y+1)*w*4]) for y in range(h))
- png = b"\x89PNG\r\n\x1a\n"
- png += chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 6, 0, 0, 0))
- png += chunk(b"IDAT", zlib.compress(raw, 9))
- png += chunk(b"IEND", b"")
- with open(path, "wb") as f:
- f.write(png)
-
-write_png(path, S, S, buf)
-print(" wrote", path)
+import sys, struct
+data = open(sys.argv[1], "rb").read()
+assert data[:8] == b"\x89PNG\r\n\x1a\n", "not a PNG"
+pos = 8
+while pos < len(data):
+ ln = struct.unpack(">I", data[pos:pos+4])[0]
+ tag = data[pos+4:pos+8]
+ if tag == b"IHDR":
+ w, h = struct.unpack(">II", data[pos+8:pos+16])
+ assert (w, h) == (1024, 1024), f"expected 1024x1024, got {w}x{h}"
+ print(f" icon.png OK: {w}x{h}")
+ break
+ pos += 12 + ln
PYEOF
-echo "==> resizing to 512/256 (stdlib scale-down)"
+echo "==> resizing to 512/256 (stdlib area-average box filter)"
python3 - "$OUT/icon.png" "$OUT" <<'PYEOF'
import sys, zlib, struct
@@ -124,20 +111,24 @@ def read_png(path):
w, h, raw = read_png(src)
stride = w * 4
-def sample(x, y):
- i = y * stride + x * 4
- return raw[i], raw[i+1], raw[i+2], raw[i+3]
-
-def resize(nw, nh):
+def resize_area(nw, nh):
+ """Area-average (box filter) downscale — better antialiasing than nearest."""
out = bytearray([0, 0, 0, 0]) * (nw * nh)
for yy in range(nh):
- sy = min(int(yy * h / nh), h-1)
+ y0 = yy * h // nh
+ y1 = max(y0 + 1, (yy + 1) * h // nh)
for xx in range(nw):
- sx = min(int(xx * w / nw), w-1)
- i = sy * stride + sx * 4
- r, g, b, a = raw[i], raw[i+1], raw[i+2], raw[i+3]
+ x0 = xx * w // nw
+ x1 = max(x0 + 1, (xx + 1) * w // nw)
+ r = g = b = a = 0
+ n = 0
+ for sy in range(y0, y1):
+ for sx in range(x0, x1):
+ i = sy * stride + sx * 4
+ r += raw[i]; g += raw[i+1]; b += raw[i+2]; a += raw[i+3]
+ n += 1
o = (yy * nw + xx) * 4
- out[o] = r; out[o+1] = g; out[o+2] = b; out[o+3] = a
+ out[o] = r // n; out[o+1] = g // n; out[o+2] = b // n; out[o+3] = a // n
return bytes(out)
def write_png(path, nw, nh, rgba):
@@ -151,20 +142,19 @@ def write_png(path, nw, nh, rgba):
for size in (512, 256):
p = f"{outdir}/icon-{size}.png"
- write_png(p, size, size, resize(size, size))
+ write_png(p, size, size, resize_area(size, size))
print(" wrote", p)
PYEOF
-echo "==> iconutil → icon.icns (macOS)"
-ICONSET="$OUT/icon.iconset"
-mkdir -p "$ICONSET"
-# iconutil wants specific sizes
-for s in 16 32 128 256 512; do
- d=$((s*2))
- python3 - "$OUT/icon.png" "$ICONSET/icon_${s}x${s}.png" "$s" <<'PYEOF'
+echo "==> iconutil → icon.icns (macOS only; skipped on Linux/Windows)"
+if command -v iconutil >/dev/null 2>&1; then
+ ICONSET="$OUT/icon.iconset"
+ mkdir -p "$ICONSET"
+ # iconutil wants specific sizes
+ for s in 16 32 128 256 512; do
+ python3 - "$OUT/icon.png" "$ICONSET/icon_${s}x${s}.png" "$s" <<'PYEOF'
import sys, zlib, struct
src, dst, size = sys.argv[1], sys.argv[2], int(sys.argv[3])
-# reuse resize from above by re-reading
data = open(src, "rb").read(); pos, w, h, idat = 8, 0, 0, b""
while pos < len(data):
ln = struct.unpack(">I", data[pos:pos+4])[0]; tag = data[pos+4:pos+8]; chunk = data[pos+8:pos+8+ln]
@@ -175,27 +165,36 @@ raw = zlib.decompress(idat)
stride0 = w*4 + 1
raw = b"".join(raw[y*stride0+1:(y+1)*stride0] for y in range(h))
stride = w*4
-out = bytearray([0,0,0,0])*(size*size)
-for yy in range(size):
- sy = yy*h//size
- for xx in range(size):
- sx = min(xx*w//size, w-1)
- i = sy*stride + sx*4
- o = (yy*size+xx)*4
- out[o]=raw[i]; out[o+1]=raw[i+1]; out[o+2]=raw[i+2]; out[o+3]=raw[i+3]
+def resize_area(nw, nh):
+ out = bytearray([0,0,0,0])*(nw*nh)
+ for yy in range(nh):
+ y0 = yy*h//nh; y1 = max(y0+1, (yy+1)*h//nh)
+ for xx in range(nw):
+ x0 = xx*w//nw; x1 = max(x0+1, (xx+1)*w//nw)
+ r = g = b = a = n = 0
+ for sy in range(y0, y1):
+ for sx in range(x0, x1):
+ i = sy*stride + sx*4
+ r += raw[i]; g += raw[i+1]; b += raw[i+2]; a += raw[i+3]; n += 1
+ o = (yy*nw+xx)*4
+ out[o] = r//n; out[o+1] = g//n; out[o+2] = b//n; out[o+3] = a//n
+ return bytes(out)
def chunk(tag, data):
c = struct.pack(">I", len(data))+tag+data; c += struct.pack(">I", zlib.crc32(tag+data)&0xffffffff); return c
-rgba = bytes(out)
+rgba = resize_area(size, size)
raw2 = b"".join(b"\x00"+rgba[y*size*4:(y+1)*size*4] for y in range(size))
png = b"\x89PNG\r\n\x1a\n"+chunk(b"IHDR", struct.pack(">IIBBBBB", size, size, 8, 6, 0, 0, 0))+chunk(b"IDAT", zlib.compress(raw2,9))+chunk(b"IEND", b"")
open(dst, "wb").write(png)
PYEOF
- cp "$ICONSET/icon_${s}x${s}.png" "$ICONSET/icon_${s}x${s}@2x.png" 2>/dev/null || true
-done
-# icns needs exact 16/32/128/256/512 + @2x
-iconutil -c icns "$ICONSET" -o "$OUT/icon.icns"
-rm -rf "$ICONSET"
-echo " wrote $OUT/icon.icns"
+ cp "$ICONSET/icon_${s}x${s}.png" "$ICONSET/icon_${s}x${s}@2x.png" 2>/dev/null || true
+ done
+ # icns needs exact 16/32/128/256/512 + @2x
+ iconutil -c icns "$ICONSET" -o "$OUT/icon.icns"
+ rm -rf "$ICONSET"
+ echo " wrote $OUT/icon.icns"
+else
+ echo " skipping icon.icns (iconutil unavailable — macOS only)"
+fi
echo "==> icon.ico (multi-size, win)"
python3 - "$OUT/icon.png" "$OUT/icon.ico" <<'PYEOF'
@@ -212,15 +211,19 @@ stride0 = w*4 + 1
raw = b"".join(raw[y*stride0+1:(y+1)*stride0] for y in range(h))
stride = w*4
-def resize(nw, nh):
+def resize_area(nw, nh):
out = bytearray([0,0,0,0])*(nw*nh)
for yy in range(nh):
- sy = min(yy*h//nh, h-1)
+ y0 = yy*h//nh; y1 = max(y0+1, (yy+1)*h//nh)
for xx in range(nw):
- sx = min(xx*w//nw, w-1)
- i = sy*stride + sx*4
+ x0 = xx*w//nw; x1 = max(x0+1, (xx+1)*w//nw)
+ r = g = b = a = n = 0
+ for sy in range(y0, y1):
+ for sx in range(x0, x1):
+ i = sy*stride + sx*4
+ r += raw[i]; g += raw[i+1]; b += raw[i+2]; a += raw[i+3]; n += 1
o = (yy*nw+xx)*4
- out[o]=raw[i]; out[o+1]=raw[i+1]; out[o+2]=raw[i+2]; out[o+3]=raw[i+3]
+ out[o] = r//n; out[o+1] = g//n; out[o+2] = b//n; out[o+3] = a//n
return bytes(out)
def png_bytes(nw, nh, rgba):
@@ -231,7 +234,7 @@ def png_bytes(nw, nh, rgba):
# ICO: header + directory + PNG-embedded entries
sizes = [16, 24, 32, 48, 64, 128, 256]
-imgs = [(s, png_bytes(s, s, resize(s, s))) for s in sizes]
+imgs = [(s, png_bytes(s, s, resize_area(s, s))) for s in sizes]
header = struct.pack("