diff --git a/.gitignore b/.gitignore index 7f20662..82eae49 100644 --- a/.gitignore +++ b/.gitignore @@ -28,4 +28,7 @@ samples/ .vscode/ *.log celerybeat-schedule -node_modules \ No newline at end of file +node_modules +src-tauri/target/ +src-tauri/binaries/ +src-tauri/vc_redist.x64.exe \ No newline at end of file diff --git a/TEST_DEBUG_PLAN.md b/TEST_DEBUG_PLAN.md new file mode 100644 index 0000000..5e1f099 --- /dev/null +++ b/TEST_DEBUG_PLAN.md @@ -0,0 +1,95 @@ +# TEST & DEBUG PLAN — SonicForge DAW Standalone (Windows .exe / .msi) + +Áp dụng cho bản đóng gói Tauri v2 + PyInstaller (xem `DESKTOP_INSTALL_PLAN.md`, +`build_windows.ps1`, `engine.spec`, `src-tauri/`). + +--- + +## 0. Cổng kiểm soát TRƯỚC khi build (pre-build gates — chạy trên máy dev) + +```powershell +# 1. Backend tests (repo đã có 86 tests pytest) +python -m pytest tests/ -x -q + +# 2. Frontend syntax + bundle +node --check app/static/js/app.precompiled.js +node build.mjs # rebuild app.precompiled.js từ app.jsx + +# 3. Python syntax của entry + spec +python -m py_compile desktop_engine.py engine.spec app/config.py app/tasks/worker.py + +# 4. Chạy thử engine độc lập (chưa cần Tauri) +python desktop_engine.py # mở browser: http://127.0.0.1:8000/docs +``` + +Gate: tất cả PASS mới chạy `build_windows.ps1`. + +--- + +## 1. TEST PLAN — theo 6 kịch bản của tài liệu gốc + +| # | Kịch bản | Cách kiểm thử | Kết quả mong đợi | +|---|---|---|---| +| 1 | **Lifecycle & Process Spawn** | Mở app → Task Manager tìm `daw_engine.exe` → đóng cửa sổ → kiểm tra lại | Engine xuất hiện lúc launch; BIẾN MẤT hoàn toàn khi đóng (không orphan). Engine còn có watchdog: nếu cha chết đột ngột, tự thoát ≤1s | +| 2 | **Port 8000 & Fallback** | Mở app → browser truy cập `http://localhost:8000/docs`; hoặc chiếm sẵn port 8000 (vd `net stop` dịch vụ khác / chạy server khác) rồi mở app | Swagger UI hiển thị. Khi 8000 bị chiếm → engine tự chuyển 8001…8010, loader page trong app tự redirect tới port đúng | +| 3 | **VST3 Local Scan (Windows)** | Plugin Manager → set path `C:\Program Files\Common Files\VST3` → **[Scan]** | Phát hiện đúng VST3 đã cài (Vital, Surge XT, FabFilter…). Scan state lưu ở `%APPDATA%\SonicForgeDAW\storage\sf_scan_state.json` | +| 4 | **Isolation & Crash Protection** | Load 1 VST3 không ổn định / giả lập crash | UI hiện thông báo lỗi, cửa sổ DAW vẫn phản hồi (tiến trình engine riêng — shell không chết theo) | +| 5 | **Storage Persistence (`%APPDATA%`)** | Mở Explorer tới `%APPDATA%\SonicForgeDAW\storage\` | Có `sonicforge.db`, `sf_scan_state.json`, `uploads/`, `processed/`, `soundfonts/` — **không** nằm trong thư mục cài đặt (onefile giải nén tạm sẽ bị xóa khi thoát) | +| 6 | **Audio Export / Bouncing** | Click **Export WAV** ở chế độ Standalone | File `.wav` render đúng (VST3 + SoundFont qua Pedalboard/FluidSynth) | + +### 1.1 Kiểm thử cài đặt (installer) +- **NSIS**: cài trên máy sạch (không có Python, không có VC++ redist) → app chạy được, `hooks.nsh` tự cài VC redist. Gỡ cài bằng Control Panel → không còn process, không còn shortcut. +- **MSI**: cài qua `msiexec /i SonicForgeDAW_1.0.0_x64_en-US.msi` (hoặc double-click) → tương đương; kiểm tra repair/uninstall. +- **Nâng cấp**: cài bản mới đè bản cũ → `%APPDATA%\SonicForgeDAW\storage` GIỮ NGUYÊN (dữ liệu người dùng không mất). +- **SmartScreen/AV**: lần đầu chạy có thể bị chặn ("More info → Run anyway"); bản production phải ký số (signtool + certificate DigiCert/Sectigo). + +### 1.2 Kiểm thử hồi quy (regression — sau mỗi thay đổi code) +- `pytest tests/` — 86 test hiện có (conftest fixture dùng test client). +- Smoke UI thủ công: play/pause, piano roll, FX rack, save/load project, soundfont scan. + +--- + +## 2. DEBUG WORKFLOWS + +### 2.1 Engine (Python sidecar) không start / crash âm thầm +1. Mở log: `%APPDATA%\SonicForgeDAW\logs\engine.log` (desktop_engine.py ghi log ra file vì `console=False`). +2. Chạy thủ công engine có console: sửa `console=True` trong `engine.spec` → rebuild → chạy `dist\daw_engine.exe` từ cmd → xem traceback trực tiếp. +3. Test độc lập không cần Tauri: `python desktop_engine.py` (dev) — đúng code path, có stdout. + +### 2.2 UI (WebView2) +- **F12 / Ctrl+Shift+I** trong cửa sổ app: mở DevTools (đã bật `"devtools": true` trong `tauri.conf.json`) — Console, Network, Web Audio context. +- `devUrl: "http://localhost:8000"` — bản dev có thể chạy `tauri dev` với engine chạy tay. + +### 2.3 Sidecar không spawn / không thấy engine +- Kiểm tra `src-tauri\binaries\daw_engine-x86_64-pc-windows-msvc.exe` có tồn tại (build script copy tự động). +- Chạy `npx tauri build` lần đầu: nếu thiếu sidecar → lib.rs `expect("sidecar daw_engine not found")` fail rõ ràng. +- Kiểm tra port bị chiếm: `netstat -ano | findstr :8000`. + +### 2.4 Windows build lỗi (máy dev) +- Rust toolchain: `rustup default stable-msvc` (bắt buộc MSVC toolchain cho Tauri Windows). +- WebView2 runtime: Win10/11 có sẵn; máy cũ cài [WebView2 Evergreen](https://developer.microsoft.com/microsoft-edge/webview2/). +- Nếu `tauri build` lỗi NSIS/MSI riêng: build riêng từng target `npx tauri build --bundles nsis` / `--bundles msi`. + +--- + +## 3. Known Gaps (đã xử lý / chấp nhận) + +| Vấn đề | Trạng thái | +|---|---| +| Celery cần Redis broker | **Đã xử lý**: `SF_DESKTOP=1` → `task_always_eager=True` (chạy đồng bộ trong tiến trình, `memory://` broker + `cache+memory://` backend). Chế độ docker/cloud không đổi | +| Storage khi frozen (onefile giải nén tạm) | **Đã xử lý**: `config.py` freeze-aware → writable data về `%APPDATA%\SonicForgeDAW\storage`, assets read-only trong `_MEIPASS` | +| Port 8000 bị chiếm | **Đã xử lý**: probe 8000–8010 + loader page redirect tự động | +| `engine.spec` console=False → mù log | **Đã xử lý**: log ra file `%APPDATA%\SonicForgeDAW\logs\engine.log` | +| Soundfont scanner path `/opt/daw_engine/soundfonts` (Linux-only) | **Chấp nhận**: cần xác minh scanner bỏ qua path không tồn tại trên Windows (test scenario #5) | +| `child.kill()` = TerminateProcess (không graceful 100%) | **Giảm thiểu**: watchdog parent-PID trong `desktop_engine.py` → engine tự shutdown sạch khi cha chết | + +--- + +## 4. Checklist phát hành (release) + +- [ ] Pre-build gates PASS (mục 0) +- [ ] `build_windows.ps1` chạy sạch, đủ 6 bước +- [ ] 6 kịch bản test (mục 1) PASS trên máy sạch +- [ ] Installer NSIS + MSI cài/gỡ/nâng cấp đúng +- [ ] Ký số (production) — SmartScreen hết cảnh báo +- [ ] Commit artifacts: `engine.spec`, `desktop_engine.py`, `src-tauri/`, `build_windows.ps1`, `tools/gen_icons.py` diff --git a/app/config.py b/app/config.py index 5fe73e1..15d9193 100644 --- a/app/config.py +++ b/app/config.py @@ -1,15 +1,31 @@ import os +import sys + + +def _app_dir(): + if getattr(sys, "frozen", False): + # PyInstaller onefile: assets read-only nằm trong thư mục giải nén tạm + return os.path.join(sys._MEIPASS, "app") + return os.path.dirname(os.path.abspath(__file__)) + + +def _storage_dir(): + if getattr(sys, "frozen", False): + # Dữ liệu ghi được (DB, uploads, processed) phải ngoài thư mục tạm + root = os.environ.get("APPDATA") or os.path.expanduser("~") + return os.path.join(root, "SonicForgeDAW", "storage") + return os.path.join(_app_dir(), "storage") + class Settings: REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379/0") CELERY_BROKER_URL: str = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0") CELERY_RESULT_BACKEND: str = os.getenv("CELERY_RESULT_BACKEND", "redis://localhost:6379/0") - # __file__ is app/config.py, so dirname(__file__) = app/, dirname(app/) = project root - APP_DIR: str = os.path.dirname(os.path.abspath(__file__)) + APP_DIR: str = _app_dir() BASE_DIR: str = os.path.dirname(APP_DIR) TEMPLATES_DIR: str = os.path.join(APP_DIR, "templates") - STORAGE_DIR: str = os.path.join(APP_DIR, "storage") + STORAGE_DIR: str = _storage_dir() UPLOADS_DIR: str = os.path.join(STORAGE_DIR, "uploads") PROCESSED_DIR: str = os.path.join(STORAGE_DIR, "processed") diff --git a/app/tasks/worker.py b/app/tasks/worker.py index b9e6d7e..67118fd 100644 --- a/app/tasks/worker.py +++ b/app/tasks/worker.py @@ -27,6 +27,16 @@ celery_app.conf.update( enable_utc=True, ) +# Che do desktop (SF_DESKTOP=1, do desktop_engine.py set): chay task dong bo +# trong tien trinh (eager) — ban Standalone Windows KHONG kem Redis broker. +if os.getenv("SF_DESKTOP") == "1": + celery_app.conf.update( + task_always_eager=True, + task_eager_propagates=True, + broker_url="memory://", + result_backend="cache+memory://", + ) + # ── Lịch trình tự động dọn dẹp file hết hạn (Week 5) ── celery_app.conf.beat_schedule = { "cleanup-expired-files-every-hour": { diff --git a/build_windows.ps1 b/build_windows.ps1 new file mode 100644 index 0000000..dfff013 --- /dev/null +++ b/build_windows.ps1 @@ -0,0 +1,33 @@ +# build_windows.ps1 — ONE-COMMAND build: daw_engine.exe (PyInstaller) + Tauri v2 (NSIS + MSI) +# Chay tren Windows: powershell -ExecutionPolicy Bypass -File build_windows.ps1 +$ErrorActionPreference = "Stop" +Set-Location $PSScriptRoot + +Write-Host "== [1/6] Python dependencies ==" +python -m pip install --upgrade pip +python -m pip install -r requirements.txt pyinstaller pywin32 + +Write-Host "== [2/6] Frontend bundle (app.jsx -> app.precompiled.js) ==" +npm install +node build.mjs # @babel/standalone; thay cho 'npm run build' (Babel 8 ESM-only CLI conflict) + +Write-Host "== [3/6] Build daw_engine.exe (PyInstaller) ==" +pyinstaller engine.spec --clean --noconfirm + +Write-Host "== [4/6] Sidecar binary -> src-tauri/binaries (tauri triple naming) ==" +New-Item -ItemType Directory -Force src-tauri\binaries | Out-Null +Copy-Item dist\daw_engine.exe src-tauri\binaries\daw_engine-x86_64-pc-windows-msvc.exe -Force + +Write-Host "== [5/6] VC++ Redistributable cho hooks.nsh ==" +if (-not (Test-Path src-tauri\vc_redist.x64.exe)) { + Invoke-WebRequest -Uri "https://aka.ms/vs/17/release/vc_redist.x64.exe" -OutFile src-tauri\vc_redist.x64.exe +} + +Write-Host "== [6/6] Tauri build (NSIS .exe + MSI) ==" +npm install -D @tauri-apps/cli +npx tauri build + +Write-Host "" +Write-Host "== DONE ==" +Write-Host " NSIS: src-tauri\target\release\bundle\nsis\SonicForgeDAW_1.0.0_x64-setup.exe" +Write-Host " MSI : src-tauri\target\release\bundle\msi\SonicForgeDAW_1.0.0_x64_en-US.msi" diff --git a/desktop_engine.py b/desktop_engine.py new file mode 100644 index 0000000..d0f63bc --- /dev/null +++ b/desktop_engine.py @@ -0,0 +1,87 @@ +"""SonicForge DAW — Desktop engine launcher (PyInstaller entry point). + +Chay FastAPI backend duoi dang tien trinh nen (sidecar) cho ban cai Standalone +Windows (Tauri v2 shell). Tu chon port 8000-8010 (fallback neu bi chiem), tu +thoat khi tien trinh cha (Tauri) ket thuc — Graceful Shutdown. +""" +import logging +import os +import socket +import sys +import threading +import time + +PORT_RANGE = (8000, 8010) +APP_DATA_DIR_NAME = "SonicForgeDAW" + + +def _pick_port(): + for port in range(PORT_RANGE[0], PORT_RANGE[1] + 1): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + s.bind(("127.0.0.1", port)) + return port + except OSError: + continue + return PORT_RANGE[0] + + +def _parent_alive(pid): + try: + os.kill(pid, 0) + return True + except OSError: + return False + + +def main(): + os.environ.setdefault("SF_DESKTOP", "1") + port = _pick_port() + os.environ["SF_PORT"] = str(port) + + # Log ra file — khi dong goi console=False, stdout khong nhin thay duoc. + try: + log_dir = os.path.join( + os.environ.get("APPDATA") or os.path.expanduser("~"), + APP_DATA_DIR_NAME, "logs", + ) + os.makedirs(log_dir, exist_ok=True) + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + handlers=[ + logging.FileHandler(os.path.join(log_dir, "engine.log"), encoding="utf-8"), + logging.StreamHandler(), + ], + ) + except Exception: + logging.basicConfig(level=logging.INFO) + + import uvicorn + import app.main # noqa: F401 — import tuong minh de PyInstaller bundle du package app + + config = uvicorn.Config(app.main.app, host="127.0.0.1", port=port, + log_level="info", access_log=False) + server = uvicorn.Server(config) + + parent_pid = os.environ.get("SF_PARENT_PID") + if parent_pid: + def _watchdog(): + pid = int(parent_pid) + while True: + time.sleep(1.0) + if not _parent_alive(pid): + logging.getLogger("desktop_engine").info( + "Parent process exited — shutting down engine") + server.should_exit = True + return + threading.Thread(target=_watchdog, daemon=True).start() + + logging.getLogger("desktop_engine").info( + "SonicForge engine listening on 127.0.0.1:%d", port) + server.run() + + +if __name__ == "__main__": + main() diff --git a/engine.spec b/engine.spec new file mode 100644 index 0000000..1528ca6 --- /dev/null +++ b/engine.spec @@ -0,0 +1,67 @@ +# engine.spec — PyInstaller config cho daw_engine.exe (sidecar Python) +# Chay: pyinstaller engine.spec --clean --noconfirm (tren Windows) +# -*- mode: python ; coding: utf-8 -*- + +from PyInstaller.utils.hooks import collect_dynamic_libs + +# Native DLL cho pedalboard va soundfile +binaries = collect_dynamic_libs('pedalboard') +binaries += collect_dynamic_libs('soundfile') + +# Assets doc (read-only) + thu muc storage (khoi tao rong; khi frozen, +# config.py chuyen storage sang %APPDATA%\SonicForgeDAW\storage) +datas = [ + ('app/templates', 'app/templates'), # index.html, favicon.svg + ('app/static', 'app/static'), # js/css/processors + ('app/storage', 'app/storage'), + ('md', 'md'), # /ai-prompt-generator +] + +a = Analysis( + ['desktop_engine.py'], + pathex=['.'], + binaries=binaries, + datas=datas, + hiddenimports=[ + 'uvicorn.logging', + 'uvicorn.loops', + 'uvicorn.loops.auto', + 'uvicorn.protocols', + 'uvicorn.protocols.http', + 'uvicorn.protocols.http.auto', + 'uvicorn.protocols.websockets', + 'uvicorn.protocols.websockets.auto', + 'pedalboard', + 'soundfile', + 'sf2utils', + 'mido.backends.rtmidi', + ], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=['tkinter'], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=None, + noarchive=False, +) + +pyz = PYZ(a.pure, a.zipped_data, cipher=None) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + [], + name='daw_engine', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, # True khi debug (xem log truc tiep), False cho production + icon='src-tauri/icons/icon.ico', +) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml new file mode 100644 index 0000000..9de4308 --- /dev/null +++ b/src-tauri/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "sonicforge-daw" +version = "1.0.0" +description = "SonicForge DAW desktop shell (Tauri v2 + Python sidecar)" +edition = "2021" + +[lib] +name = "sonicforge_daw_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +tauri = { version = "2", features = [] } +tauri-plugin-shell = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[profile.release] +strip = true +lto = true +codegen-units = 1 diff --git a/src-tauri/build.rs b/src-tauri/build.rs new file mode 100644 index 0000000..d860e1e --- /dev/null +++ b/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json new file mode 100644 index 0000000..230b422 --- /dev/null +++ b/src-tauri/capabilities/default.json @@ -0,0 +1,7 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Default capability for the main window", + "windows": ["main"], + "permissions": ["core:default"] +} diff --git a/src-tauri/hooks.nsh b/src-tauri/hooks.nsh new file mode 100644 index 0000000..ccec66d --- /dev/null +++ b/src-tauri/hooks.nsh @@ -0,0 +1,7 @@ +; NSIS installer hooks — cai Microsoft Visual C++ Redistributable neu thieu +; (MSVCP140.dll missing). Dat vc_redist.x64.exe CANH tauri.conf.json +; (build_windows.ps1 tu tai ve neu chua co). +!macro customInstall + File "/oname=$PLUGINSDIR\vc_redist.x64.exe" "vc_redist.x64.exe" + ExecWait '"$PLUGINSDIR\vc_redist.x64.exe" /install /quiet /norestart' +!macroend diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png new file mode 100644 index 0000000..9847717 Binary files /dev/null and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000..3272f57 Binary files /dev/null and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png new file mode 100644 index 0000000..6617788 Binary files /dev/null and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico new file mode 100644 index 0000000..84fc88d Binary files /dev/null and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png new file mode 100644 index 0000000..9ab417d Binary files /dev/null and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs new file mode 100644 index 0000000..91b5b8b --- /dev/null +++ b/src-tauri/src/lib.rs @@ -0,0 +1,41 @@ +// SonicForge DAW — desktop shell: spawn/terminate daw_engine.exe sidecar. +use tauri_plugin_shell::process::CommandChild; +use tauri_plugin_shell::ShellExt; +use std::sync::Mutex; + +struct EngineProcess(Mutex>); + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .plugin(tauri_plugin_shell::init()) + .setup(|app| { + // 1. Spawn sidecar daw_engine.exe (PyInstaller bundle) + let sidecar_command = app + .shell() + .sidecar("daw_engine") + .expect("sidecar daw_engine not found — run build_windows.ps1 first"); + let (_rx, child) = sidecar_command + .env("SF_PARENT_PID", std::process::id().to_string()) + .spawn() + .expect("Failed to spawn daw_engine sidecar"); + + app.manage(EngineProcess(Mutex::new(Some(child)))); + println!("Python Background Engine started (localhost:8000, auto-fallback 8000-8010)"); + Ok(()) + }) + .on_window_event(|window, event| { + // 2. Terminate sidecar khi DAW window dong — tranh orphan process + if let tauri::WindowEvent::Destroyed = event { + let state = window.state::(); + if let Ok(mut lock) = state.0.lock() { + if let Some(child) = lock.take() { + let _ = child.kill(); + println!("daw_engine sidecar terminated."); + } + } + } + }) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs new file mode 100644 index 0000000..b407a77 --- /dev/null +++ b/src-tauri/src/main.rs @@ -0,0 +1,5 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + sonicforge_daw_lib::run() +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json new file mode 100644 index 0000000..1d1c266 --- /dev/null +++ b/src-tauri/tauri.conf.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "SonicForgeDAW", + "version": "1.0.0", + "identifier": "com.sonicforge.daw", + "build": { + "frontendDist": "ui", + "devUrl": "http://localhost:8000" + }, + "app": { + "windows": [ + { + "title": "Sonic Forge DAW - Professional Desktop Studio", + "width": 1440, + "height": 900, + "resizable": true, + "fullscreen": false, + "devtools": true + } + ], + "security": { + "csp": null + } + }, + "bundle": { + "active": true, + "targets": ["msi", "nsis"], + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.ico" + ], + "externalBin": [ + "binaries/daw_engine" + ], + "nsis": { + "installerHooks": "hooks.nsh" + } + } +} diff --git a/src-tauri/ui/index.html b/src-tauri/ui/index.html new file mode 100644 index 0000000..f7b1338 --- /dev/null +++ b/src-tauri/ui/index.html @@ -0,0 +1,35 @@ + + + + + SonicForge DAW + + + +
Đang khởi động SonicForge Engine…
+ + + diff --git a/tools/gen_icons.py b/tools/gen_icons.py new file mode 100644 index 0000000..666c9e3 --- /dev/null +++ b/tools/gen_icons.py @@ -0,0 +1,59 @@ +"""Sinh bo icon cho Tauri v2 + PyInstaller (chay 1 lan, co the tai chay). + +Usage: python tools/gen_icons.py +Output: src-tauri/icons/{32x32,128x128,128x128@2x}.png, icon.png, icon.ico +""" +import os +from PIL import Image, ImageDraw, ImageFont + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +ICONS = os.path.join(ROOT, "src-tauri", "icons") +BG = (18, 20, 26, 255) # #12141a — trùng màu nền loader +FG = (77, 182, 255, 255) # xanh dương sáng +RING = (46, 52, 66, 255) + + +def _font(size): + for p in ("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", + "C:/Windows/Fonts/segoeuib.ttf"): + if os.path.exists(p): + return ImageFont.truetype(p, size) + return ImageFont.load_default() + + +def _draw(size): + img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + d = ImageDraw.Draw(img) + # nền bo góc + viền + r = size // 8 + d.rounded_rectangle([0, 0, size - 1, size - 1], radius=r, fill=BG) + d.rounded_rectangle([size // 32, size // 32, size - 1 - size // 32, size - 1 - size // 32], + radius=max(1, r - size // 32), outline=RING, width=max(2, size // 64)) + # chữ "SF" + font = _font(int(size * 0.42)) + text = "SF" + bbox = d.textbbox((0, 0), text, font=font) + w, h = bbox[2] - bbox[0], bbox[3] - bbox[1] + d.text(((size - w) / 2 - bbox[0], (size - h) / 2 - bbox[1]), text, + font=font, fill=FG) + return img + + +def main(): + os.makedirs(ICONS, exist_ok=True) + sizes = {"32x32.png": 32, "128x128.png": 128, "128x128@2x.png": 256, "icon.png": 512} + imgs = [] + for name, size in sizes.items(): + img = _draw(size) + img.save(os.path.join(ICONS, name)) + imgs.append((img, size)) + print("generated", name) + # icon.ico: multi-size + base = _draw(256) + base.save(os.path.join(ICONS, "icon.ico"), + sizes=[(16, 16), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)]) + print("generated icon.ico") + + +if __name__ == "__main__": + main()