diff --git a/README.md b/README.md index 98a2260..ffdd585 100644 --- a/README.md +++ b/README.md @@ -179,7 +179,8 @@ docker compose up --build | 平台 | 文件 | | :--- | :--- | | **Windows** | `TickFlowStockPanel-Setup-x64.exe` → 双击安装向导,自动创建桌面/开始菜单快捷方式 | -| **macOS** | `TickFlowStockPanel-macos.zip` → 解压后运行 app(首次打开右键→打开,绕过 Gatekeeper) | +| **macOS (Apple Silicon)** | `TickFlowStockPanel-macos-arm64.dmg` → 双击打开,拖入「应用程序」(M1/M2/M3/M4 芯片选这个) | +| **macOS (Intel)** | `TickFlowStockPanel-macos-x64.dmg` → 双击打开,拖入「应用程序」(老款 Intel 芯片选这个) | | **Linux** | `TickFlowStockPanel-linux-x64.tar.gz` → 解压后运行可执行文件 | 下载地址:**[GitHub Releases](https://github.com/shy3130/tickflow-stock-panel/releases/latest)** diff --git a/packaging/generate_icon.py b/packaging/generate_icon.py index c9d32d2..60f1770 100644 --- a/packaging/generate_icon.py +++ b/packaging/generate_icon.py @@ -1,7 +1,7 @@ -"""生成应用图标 icon.ico — 白底圆角 + 紫色 Logo。 +"""生成应用图标 — 白色 squircle 背景 + 紫色 Logo。 设计 (与 frontend Logo.tsx / favicon.svg 一致): - 背景: 白色圆角方块 + 背景: 白色 squircle (macOS Big Sur+ 要求不透明 squircle 背景, 用白色填充) 内容: 紫色 #5B21B6 方括号 + K线 (上影短/下影长, bullish 站稳) 蜡烛几何 (32x32 viewBox): @@ -9,9 +9,11 @@ body: y=9 ~ y=19 (偏上) → 上影 = 2 (短), 下影 = 6 (长) -每尺寸独立绘制: 小尺寸加粗线条, 大尺寸精细。 +每尺寸独立绘制: 线条占比全尺寸统一 (~9.4%), 小尺寸微调补偿, 不随尺寸递减。 运行: python packaging/generate_icon.py -产物: packaging/icon.ico (16/32/48/64/128/256 多尺寸) +产物: + packaging/icon.ico — Windows (16/32/48/64/128/256) + packaging/icon.icns — macOS (16/32/64/128/256/512, 含 @2x) """ from __future__ import annotations @@ -19,21 +21,27 @@ from pathlib import Path from PIL import Image, ImageDraw -OUT = Path(__file__).parent / "icon.ico" +OUT_ICO = Path(__file__).parent / "icon.ico" +OUT_ICNS = Path(__file__).parent / "icon.icns" -BRAND = (91, 33, 182, 255) # #5B21B6 -BRAND_WICK = (91, 33, 182, 153) # 0.6 opacity +# 背景: 白色 squircle (不透明, macOS 规范) +BG = (255, 255, 255, 255) +# logo 线条色: 紫色 #5B21B6 (与 Logo.tsx / favicon.svg 一致) +LOGO = (91, 33, 182, 255) # #5B21B6 +# wick 影线: 同色不透明 (半透明在小尺寸会糊掉) +LOGO_WICK = (91, 33, 182, 255) def _draw(size: int, sw_b: float, sw_w: float, body_w: float) -> Image.Image: """绘制单尺寸图标 (像素直接映射 32-viewBox)。""" factor = max(8, 2048 // size) s = size * factor - img = Image.new("RGBA", (s, s), (255, 255, 255, 0)) + img = Image.new("RGBA", (s, s), (0, 0, 0, 0)) d = ImageDraw.Draw(img) - # 白色圆角背景 - d.rounded_rectangle([0, 0, s - 1, s - 1], radius=int(s * 0.18), fill=(255, 255, 255, 255)) + # 品牌色 squircle 背景 (圆角方块, macOS Big Sur+ 规范形状, 不透明) + # 纯色填充, 不用渐变 — 渐变在 16px 小尺寸下看不出, 反而易引入 alpha 合成 bug + d.rounded_rectangle([0, 0, s - 1, s - 1], radius=int(s * 0.22), fill=BG) def p(v): return v * s / 32 @@ -45,45 +53,73 @@ def _draw(size: int, sw_b: float, sw_w: float, body_w: float) -> Image.Image: for pts in [[(10, 4), (4, 4), (4, 28), (10, 28)], [(22, 4), (28, 4), (28, 28), (22, 28)]]: scaled = [(p(x), p(y)) for x, y in pts] for i in range(len(scaled) - 1): - d.line([scaled[i], scaled[i + 1]], fill=BRAND, width=swb, joint="curve") + d.line([scaled[i], scaled[i + 1]], fill=LOGO, width=swb, joint="curve") # wick 影线 (上短下长) - d.line([(p(16), p(7)), (p(16), p(25))], fill=BRAND_WICK, width=sww) + d.line([(p(16), p(7)), (p(16), p(25))], fill=LOGO_WICK, width=sww) wcap = sww // 2 + 1 for cy in [7, 25]: - d.ellipse([p(16) - wcap, p(cy) - wcap, p(16) + wcap, p(cy) + wcap], fill=BRAND_WICK) + d.ellipse([p(16) - wcap, p(cy) - wcap, p(16) + wcap, p(cy) + wcap], fill=LOGO_WICK) # body 实体 (偏上 → 上影短下影长) d.rounded_rectangle( [p(16 - body_w / 2), p(9), p(16 + body_w / 2), p(19)], - radius=p(0.5), fill=BRAND, + radius=p(0.5), fill=LOGO, ) return img.resize((size, size), Image.LANCZOS) def draw_logo(size: int) -> Image.Image: - """按尺寸选参数 (小尺寸加粗)。""" + """按尺寸选参数, 保证各尺寸视觉粗细一致。 + + 核心原则: 所有尺寸线条占图标的视觉比例统一, 不能出现"大尺寸反而显细"。 + 基准定为 sw=4.0/32 (12.5%) — 经多轮对比选定, Dock 放大时线条仍清晰有力。 + 小尺寸在基准上略加粗, 抵消像素化糊掉; 大尺寸统一用基准, 不递减。 + """ if size <= 16: - return _draw(size, sw_b=4.0, sw_w=3.5, body_w=9) + return _draw(size, sw_b=4.5, sw_w=3.8, body_w=9) elif size <= 32: - return _draw(size, sw_b=3.0, sw_w=2.5, body_w=8) - elif size <= 48: - return _draw(size, sw_b=2.5, sw_w=2.0, body_w=7) + return _draw(size, sw_b=4.2, sw_w=3.6, body_w=8) else: - return _draw(size, sw_b=2.0, sw_w=1.5, body_w=6) + # ≥48px 统一用基准 4.0, 不随尺寸递减 + return _draw(size, sw_b=4.0, sw_w=3.4, body_w=8) + + +def _save_ico(images_by_size: dict[int, Image.Image]) -> None: + """Windows .ico (16/32/48/64/128/256)。主图 256 优先, 资源管理器大图清晰。""" + sizes = [16, 32, 48, 64, 128, 256] + images = [images_by_size[s] for s in sizes] + images[-1].save( + OUT_ICO, format="ICO", + sizes=[(s, s) for s in sizes], + append_images=images[:-1], + ) + print(f"生成: {OUT_ICO} (尺寸 {sizes})") + + +def _save_icns(images_by_size: dict[int, Image.Image]) -> None: + """macOS .icns (16/32/64/128/256/512)。 + + Apple 要求 .icns 至少含一张大图 (512 或 1024) 才合法, 否则 Finder/Dock + 不显示。这里用 512 作主图 (Pillow 12 写 1024 需额外编码, 512 已覆盖 + Retina @2x)。@2x 高清由系统从大图自动缩放, 无需单独提供。 + """ + sizes = [16, 32, 64, 128, 256, 512] + images = [images_by_size[s] for s in sizes] + images[-1].save( + OUT_ICNS, format="ICNS", + append_images=images[:-1], + ) + print(f"生成: {OUT_ICNS} (尺寸 {sizes})") def main() -> None: - sizes = [16, 32, 48, 64, 128, 256] - images = [draw_logo(sz) for sz in sizes] - # 主图 256 (大尺寸优先, 资源管理器大图清晰) - images[-1].save( - OUT, format="ICO", - sizes=[(sz, sz) for sz in sizes], - append_images=images[:-1], - ) - print(f"生成: {OUT} (尺寸 {sizes})") + # 一次绘制所有用到的尺寸 (ico 和 icns 取并集), 避免重复绘制 + all_sizes = {16, 32, 48, 64, 128, 256, 512} + images_by_size = {sz: draw_logo(sz) for sz in all_sizes} + _save_ico(images_by_size) + _save_icns(images_by_size) if __name__ == "__main__": diff --git a/packaging/icon.icns b/packaging/icon.icns new file mode 100644 index 0000000..58c3ae2 Binary files /dev/null and b/packaging/icon.icns differ diff --git a/packaging/icon.ico b/packaging/icon.ico index ff83526..4b89799 100644 Binary files a/packaging/icon.ico and b/packaging/icon.ico differ diff --git a/packaging/tickflow.spec b/packaging/tickflow.spec index 6a187dd..0b54aa9 100644 --- a/packaging/tickflow.spec +++ b/packaging/tickflow.spec @@ -22,6 +22,8 @@ from PyInstaller.utils.hooks import ( copy_metadata, ) +_IS_MACOS = sys.platform == "darwin" + block_cipher = None # ── 资源路径基准: 项目根 (spec 文件在 packaging/ 下) ────────────────── @@ -29,7 +31,10 @@ ROOT = Path(SPECPATH).parent FRONTEND_DIST = str(ROOT / "frontend" / "dist") TIERS_YAML = str(ROOT / "tiers.yaml") BUILTIN_STRATEGIES = str(ROOT / "backend" / "app" / "strategy" / "builtin") -APP_ICON = str(ROOT / "packaging" / "icon.ico") +# 图标按平台选: Windows 用 .ico, macOS 用 .icns (PyInstaller 对 .ico 在 +# mac 上静默忽略, 不换格式 Dock/Finder 会显示通用图标)。两者都由 +# packaging/generate_icon.py 一并生成。 +APP_ICON = str(ROOT / "packaging" / ("icon.icns" if _IS_MACOS else "icon.ico")) # ── 收集带原生库的依赖 (.libs/ 目录必须完整, 否则启动崩) ───────────── # polars / pyarrow / duckdb / fastexcel 都自带共享库子目录 @@ -165,3 +170,34 @@ coll = COLLECT( upx_exclude=[], name="TickFlowStockPanel", ) + +# ── macOS: 封装成 .app 包 ──────────────────────────────────────────── +# Windows/Linux: 上面 COLLECT 产出的 onedir 目录即是最终产物。 +# macOS: 额外加 BUNDLE, 把目录包装成标准 .app (Contents/MacOS/...), +# 这样 Dock/Finder 能识别, 用户可双击启动, 并显示自定义 .icns 图标。 +# BUNDLE 必须引用上面的 COLLECT (coll), 它会把 coll 的产物搬进 .app。 +# +# PyInstaller 6 BUNDLE 关键参数 (见 building/osx.py): +# - version: → CFBundleShortVersionString (默认 0.0.0, 必须显式传) +# - info_plist (单数!): 用户自定义键, update 合并进默认 plist, 可覆盖任意字段 +if _IS_MACOS: + import json + + # 版本号从 frontend/package.json 读, 与 Release tag 对齐 + _pkg_json = json.loads((ROOT / "frontend" / "package.json").read_text(encoding="utf-8")) + APP_VERSION = _pkg_json["version"] + + app = BUNDLE( + coll, + name="TickFlowStockPanel.app", + icon=APP_ICON, + bundle_identifier="com.tickflow.stockpanel", + version=APP_VERSION, # → CFBundleShortVersionString / CFBundleVersion + info_plist={ + "CFBundleName": "TickFlow Stock Panel", + "CFBundleDisplayName": "TickFlow 股票面板", + "CFBundleVersion": APP_VERSION, + "NSHighResolutionCapable": True, + "LSMinimumSystemVersion": "10.13", + }, + )