Build Desktop App (Cross-Platform) #43
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Build Desktop App (Cross-Platform) | |
| on: | |
| schedule: | |
| - cron: '0 0 * * *' # Daily at 8 AM Beijing time (00:00 UTC) | |
| workflow_dispatch: | |
| inputs: | |
| version: | |
| description: 'Release version (e.g. 1.0.0-beta.1)' | |
| required: false | |
| default: '' | |
| electron_repo: | |
| description: 'Electron frontend repo (owner/repo)' | |
| required: false | |
| default: 'Project-N-E-K-O/N.E.K.O.-PC' | |
| electron_ref: | |
| description: 'Electron repo branch/tag/commit' | |
| required: false | |
| default: 'main' | |
| skip_signing: | |
| description: 'Skip code signing' | |
| required: false | |
| default: 'true' | |
| type: choice | |
| options: | |
| - 'true' | |
| - 'false' | |
| env: | |
| PYTHON_VERSION: '3.11' | |
| NODE_VERSION: '20' | |
| # Anonymous embedding profile id (compatibility contract — bumped only when | |
| # the upstream weights/tokenizer become incompatible with previously-cached | |
| # vectors). The repo + revision together pin the actual artifacts. | |
| EMBEDDING_MODEL_REPO: jinaai/jina-embeddings-v5-text-nano-retrieval | |
| EMBEDDING_MODEL_PROFILE_ID: local-text-retrieval-v1 | |
| EMBEDDING_MODEL_REVISION: ac5d898c8d382b17167c33e5c8af644a3519b47d | |
| jobs: | |
| # ────────────────────────────────────────────── | |
| # Step 0: Calculate version | |
| # ────────────────────────────────────────────── | |
| version: | |
| if: github.repository == 'Project-N-E-K-O/N.E.K.O' | |
| runs-on: ubuntu-latest | |
| outputs: | |
| version: ${{ steps.calc.outputs.VERSION }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Calculate version | |
| id: calc | |
| run: | | |
| if [[ "$GITHUB_REF" == refs/tags/v* ]]; then | |
| VERSION="${GITHUB_REF#refs/tags/v}" | |
| elif [[ -n "${{ github.event.inputs.version }}" ]]; then | |
| VERSION="${{ github.event.inputs.version }}" | |
| else | |
| COMMIT_SHORT=$(git rev-parse --short HEAD) | |
| DATE=$(date +%Y%m%d) | |
| VERSION="${DATE}-${COMMIT_SHORT}" | |
| fi | |
| echo "VERSION=$VERSION" >> $GITHUB_OUTPUT | |
| echo "Resolved version: $VERSION" | |
| # ────────────────────────────────────────────── | |
| # Step 1: Build Python backend with Nuitka | |
| # ────────────────────────────────────────────── | |
| build-python: | |
| if: github.repository == 'Project-N-E-K-O/N.E.K.O' | |
| needs: version | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - os: windows-latest | |
| platform: win | |
| artifact_name: python-backend-win | |
| output_binary: projectneko_server.exe | |
| - os: macos-15 | |
| platform: mac-x64 | |
| python_arch: x64 | |
| artifact_name: python-backend-mac-x64 | |
| output_binary: projectneko_server | |
| - os: macos-latest | |
| platform: mac-arm64 | |
| artifact_name: python-backend-mac-arm64 | |
| output_binary: projectneko_server | |
| - os: ubuntu-latest | |
| platform: linux | |
| artifact_name: python-backend-linux | |
| output_binary: projectneko_server | |
| runs-on: ${{ matrix.os }} | |
| timeout-minutes: 300 | |
| steps: | |
| - name: Checkout backend repo | |
| uses: actions/checkout@v4 | |
| - name: Set up Python ${{ env.PYTHON_VERSION }} | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: ${{ env.PYTHON_VERSION }} | |
| architecture: ${{ matrix.python_arch || '' }} | |
| - name: Install uv | |
| uses: astral-sh/setup-uv@v4 | |
| - name: Create venv and install dependencies | |
| shell: bash | |
| run: | | |
| uv venv .venv | |
| # Embedding runtime deps (onnxruntime, tokenizers; CPU SIMD detect | |
| # reads numpy's __cpu_features__, no py-cpuinfo) are in | |
| # [project.dependencies] now — plain `uv sync` covers both the | |
| # build-time prepare step and the bundled runtime. | |
| # `--group galgame` adds rapidocr-onnxruntime + opencv-python-headless | |
| # for the bundled OCR backend; pyproject's [tool.uv].override-dependencies | |
| # blocks opencv-python (full) so only the headless cv2 ships. | |
| uv sync --group galgame | |
| uv pip install --python .venv nuitka ordered-set zstandard | |
| # --- Generate runtime config files (not tracked in git) --- | |
| - name: Generate default config files | |
| shell: bash | |
| run: | | |
| python -c " | |
| import json, os | |
| configs = { | |
| 'config/characters.json': {'主人': {'档案名': '', '性别': '', '昵称': ''}, '猫娘': {}, '当前猫娘': ''}, | |
| 'config/characters.en.json': {'主人': {'档案名': '', '性别': '', '昵称': ''}, '猫娘': {}, '当前猫娘': ''}, | |
| 'config/characters.ja.json': {'主人': {'档案名': '', '性别': '', '昵称': ''}, '猫娘': {}, '当前猫娘': ''}, | |
| 'config/characters.ko.json': {'主人': {'档案名': '', '性别': '', '昵称': ''}, '猫娘': {}, '当前猫娘': ''}, | |
| 'config/characters.ru.json': {'主人': {'档案名': '', '性别': '', '昵称': ''}, '猫娘': {}, '当前猫娘': ''}, | |
| 'config/characters.zh-CN.json': {'主人': {'档案名': '', '性别': '', '昵称': ''}, '猫娘': {}, '当前猫娘': ''}, | |
| 'config/characters.zh-TW.json': {'主人': {'档案名': '', '性别': '', '昵称': ''}, '猫娘': {}, '当前猫娘': ''}, | |
| 'config/core_config.json': {}, | |
| 'config/user_preferences.json': [], | |
| } | |
| for path, data in configs.items(): | |
| if not os.path.exists(path): | |
| with open(path, 'w', encoding='utf-8') as f: | |
| json.dump(data, f, ensure_ascii=False, indent=2) | |
| print(f'Created {path}') | |
| " | |
| # Copy browser_use prompt .md templates from installed package | |
| mkdir -p data/browser_use_prompts | |
| VENV_PYTHON=".venv/bin/python" | |
| if [ -f ".venv/Scripts/python.exe" ]; then | |
| VENV_PYTHON=".venv/Scripts/python.exe" | |
| fi | |
| $VENV_PYTHON -c " | |
| import shutil | |
| from pathlib import Path | |
| import browser_use.agent.system_prompts as sp | |
| src = Path(sp.__file__).parent | |
| dst = Path('data/browser_use_prompts') | |
| dst.mkdir(parents=True, exist_ok=True) | |
| for md in src.glob('*.md'): | |
| shutil.copy2(md, dst / md.name) | |
| print(f'Copied {md.name}') | |
| " | |
| echo "=== browser_use_prompts ===" | |
| ls -la data/browser_use_prompts/ | |
| echo "Default config files and directories ready." | |
| # --- Platform-specific system dependencies --- | |
| - name: Install Linux system dependencies | |
| if: runner.os == 'Linux' | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y --no-install-recommends \ | |
| build-essential \ | |
| pkg-config \ | |
| portaudio19-dev \ | |
| patchelf \ | |
| ccache | |
| - name: Install macOS build tools | |
| if: runner.os == 'macOS' | |
| run: | | |
| brew install ccache portaudio || true | |
| # --- Pre-install Playwright Chromium for bundle --- | |
| - name: Install Playwright Chromium (Unix) | |
| if: runner.os != 'Windows' | |
| shell: bash | |
| run: | | |
| mkdir -p playwright_browsers | |
| PLAYWRIGHT_BROWSERS_PATH="$(pwd)/playwright_browsers" \ | |
| .venv/bin/python -m playwright install chromium || \ | |
| echo "[WARNING] Playwright Chromium install failed; will download on first run." | |
| - name: Install Playwright Chromium (Windows) | |
| if: runner.os == 'Windows' | |
| shell: pwsh | |
| run: | | |
| if (-not (Test-Path "playwright_browsers")) { New-Item -ItemType Directory -Path "playwright_browsers" } | |
| $env:PLAYWRIGHT_BROWSERS_PATH = "${{ github.workspace }}\playwright_browsers" | |
| .venv\Scripts\python.exe -m playwright install chromium | |
| if ($LASTEXITCODE -ne 0) { | |
| Write-Warning "Playwright Chromium install failed; will download on first run." | |
| } | |
| # --- Prepare local embedding model assets (cross-platform) --- | |
| # Mirrors the upstream Hugging Face repo at a pinned revision into the | |
| # anonymous profile folder so the bundled app can run vector memory | |
| # offline. The runtime (memory/embeddings.py) auto-falls back to bundled | |
| # assets when the user's app-data profile is incomplete. | |
| - name: Prepare embedding model assets | |
| shell: bash | |
| run: | | |
| VENV_PYTHON=".venv/bin/python" | |
| if [ -f ".venv/Scripts/python.exe" ]; then | |
| VENV_PYTHON=".venv/Scripts/python.exe" | |
| fi | |
| $VENV_PYTHON scripts/prepare_embedding_model.py \ | |
| --repo "$EMBEDDING_MODEL_REPO" \ | |
| --revision "$EMBEDDING_MODEL_REVISION" \ | |
| --profile-id "$EMBEDDING_MODEL_PROFILE_ID" \ | |
| --output-root data/embedding_models \ | |
| --variant both | |
| # --- Warm tiktoken o200k_base cache for offline use (PR #929) --- | |
| # tiktoken downloads encoding blobs (~1.5 MB each) on first use into | |
| # TIKTOKEN_CACHE_DIR; without warming, the bundled app would either | |
| # need network at startup or fall back to the heuristic counter. | |
| # launcher.py points TIKTOKEN_CACHE_DIR at data/tiktoken_cache when it | |
| # exists in the bundle. | |
| - name: Warm tiktoken cache (o200k_base) | |
| shell: bash | |
| run: | | |
| mkdir -p data/tiktoken_cache | |
| VENV_PYTHON=".venv/bin/python" | |
| if [ -f ".venv/Scripts/python.exe" ]; then | |
| VENV_PYTHON=".venv/Scripts/python.exe" | |
| fi | |
| TIKTOKEN_CACHE_DIR="$(pwd)/data/tiktoken_cache" \ | |
| $VENV_PYTHON -c "import tiktoken; tiktoken.get_encoding('o200k_base')" | |
| echo "=== tiktoken_cache ===" | |
| ls -la data/tiktoken_cache/ | |
| - name: Set up Node.js for frontend builds | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| - name: Build all frontend projects | |
| shell: bash | |
| run: | | |
| if [[ "$RUNNER_OS" == "Linux" ]]; then | |
| ./build_frontend.sh | |
| bash scripts/verify_frontend_build.sh | |
| exit 0 | |
| fi | |
| cd frontend/plugin-manager | |
| npm ci | |
| npm run build-only | |
| cd ../react-neko-chat | |
| npm ci | |
| npm run build | |
| # --- Build with Nuitka --- | |
| - name: Build with Nuitka (Unix) | |
| if: runner.os != 'Windows' | |
| shell: bash | |
| run: | | |
| NUITKA_OPTS="--standalone" | |
| NUITKA_OPTS="$NUITKA_OPTS --output-dir=dist" | |
| NUITKA_OPTS="$NUITKA_OPTS --output-filename=projectneko_server" | |
| # Config data files | |
| NUITKA_OPTS="$NUITKA_OPTS --include-data-files=config/__init__.py=config/__init__.py" | |
| NUITKA_OPTS="$NUITKA_OPTS --include-data-files=config/api_providers.json=config/api_providers.json" | |
| NUITKA_OPTS="$NUITKA_OPTS --include-data-files=config/characters.json=config/characters.json" | |
| NUITKA_OPTS="$NUITKA_OPTS --include-data-files=config/characters.en.json=config/characters.en.json" | |
| NUITKA_OPTS="$NUITKA_OPTS --include-data-files=config/characters.ja.json=config/characters.ja.json" | |
| NUITKA_OPTS="$NUITKA_OPTS --include-data-files=config/characters.ko.json=config/characters.ko.json" | |
| NUITKA_OPTS="$NUITKA_OPTS --include-data-files=config/characters.ru.json=config/characters.ru.json" | |
| NUITKA_OPTS="$NUITKA_OPTS --include-data-files=config/characters.zh-CN.json=config/characters.zh-CN.json" | |
| NUITKA_OPTS="$NUITKA_OPTS --include-data-files=config/characters.zh-TW.json=config/characters.zh-TW.json" | |
| NUITKA_OPTS="$NUITKA_OPTS --include-data-files=config/core_config.json=config/core_config.json" | |
| NUITKA_OPTS="$NUITKA_OPTS --include-data-files=config/user_preferences.json=config/user_preferences.json" | |
| # Data directories | |
| NUITKA_OPTS="$NUITKA_OPTS --include-data-dir=assets=assets" | |
| NUITKA_OPTS="$NUITKA_OPTS --include-data-dir=templates=templates" | |
| NUITKA_OPTS="$NUITKA_OPTS --include-data-dir=data/browser_use_prompts=data/browser_use_prompts" | |
| # Plugin manager UI (vite dist; gitignored) | |
| if [ -d "frontend/plugin-manager/dist" ]; then | |
| NUITKA_OPTS="$NUITKA_OPTS --include-data-dir=frontend/plugin-manager/dist=frontend/plugin-manager/dist" | |
| else | |
| echo "[WARNING] frontend/plugin-manager/dist missing; /ui will be empty in bundle." | |
| fi | |
| if [ -d "plugin/plugins" ]; then | |
| NUITKA_OPTS="$NUITKA_OPTS --include-data-dir=plugin/plugins=plugin/plugins" | |
| fi | |
| # macOS: skip bundling playwright_browsers (Chromium .app path has spaces | |
| # that break Nuitka's xattr call; browser will download on first run) | |
| if [[ "$RUNNER_OS" != "macOS" ]]; then | |
| NUITKA_OPTS="$NUITKA_OPTS --include-data-dir=playwright_browsers=playwright_browsers" | |
| fi | |
| if [ -d "data/tiktoken_cache" ]; then | |
| NUITKA_OPTS="$NUITKA_OPTS --include-data-dir=data/tiktoken_cache=data/tiktoken_cache" | |
| fi | |
| if [ -d "data/embedding_models" ]; then | |
| NUITKA_OPTS="$NUITKA_OPTS --include-data-dir=data/embedding_models=data/embedding_models" | |
| fi | |
| NUITKA_OPTS="$NUITKA_OPTS --include-data-dir=static=static" | |
| # Packages | |
| NUITKA_OPTS="$NUITKA_OPTS --include-package=uvicorn" | |
| NUITKA_OPTS="$NUITKA_OPTS --include-package=fastapi" | |
| NUITKA_OPTS="$NUITKA_OPTS --include-package=starlette" | |
| NUITKA_OPTS="$NUITKA_OPTS --include-package=jinja2" | |
| NUITKA_OPTS="$NUITKA_OPTS --include-package=websockets" | |
| NUITKA_OPTS="$NUITKA_OPTS --include-package=app" | |
| NUITKA_OPTS="$NUITKA_OPTS --include-package=config" | |
| NUITKA_OPTS="$NUITKA_OPTS --include-package=plugin" | |
| # Built-in plugins are imported at runtime via their dotted entry path | |
| # (plugin.plugins.<name>:Class in each plugin.toml), so they must be | |
| # compiled into the binary. Shipping them only via --include-data-dir | |
| # drops the .py (Nuitka treats it as code) and every built-in plugin | |
| # fails to import. Kept in sync with build_nuitka.bat. | |
| NUITKA_OPTS="$NUITKA_OPTS --include-package=plugin.plugins" | |
| NUITKA_OPTS="$NUITKA_OPTS --include-package=brain" | |
| NUITKA_OPTS="$NUITKA_OPTS --include-package=main_logic" | |
| NUITKA_OPTS="$NUITKA_OPTS --include-package=main_routers" | |
| NUITKA_OPTS="$NUITKA_OPTS --include-package=memory" | |
| NUITKA_OPTS="$NUITKA_OPTS --include-package=utils" | |
| NUITKA_OPTS="$NUITKA_OPTS --include-package=steamworks" | |
| NUITKA_OPTS="$NUITKA_OPTS --include-package=browser_use" | |
| NUITKA_OPTS="$NUITKA_OPTS --include-package=browser_use_sdk" | |
| NUITKA_OPTS="$NUITKA_OPTS --include-package=playwright" | |
| NUITKA_OPTS="$NUITKA_OPTS --include-package=tiktoken" | |
| NUITKA_OPTS="$NUITKA_OPTS --include-package=tiktoken_ext" | |
| NUITKA_OPTS="$NUITKA_OPTS --include-package=onnxruntime" | |
| NUITKA_OPTS="$NUITKA_OPTS --include-package=tokenizers" | |
| # bilibili_dm/bilibili_danmaku plugins import bilibili_api; compile the | |
| # package itself (CI previously only carried its package-data). | |
| NUITKA_OPTS="$NUITKA_OPTS --include-package=bilibili_api" | |
| # galgame_plugin native OCR/vision cohort. Installed on every platform by | |
| # `uv sync --group galgame` (rapidocr-onnxruntime + opencv-python-headless, | |
| # which pulls shapely/pyclipper). Nuitka's auto-follow misses their data | |
| # files, so include package + data explicitly. Synced with build_nuitka.bat. | |
| NUITKA_OPTS="$NUITKA_OPTS --include-package=cv2" | |
| NUITKA_OPTS="$NUITKA_OPTS --include-package-data=cv2" | |
| NUITKA_OPTS="$NUITKA_OPTS --include-package=rapidocr_onnxruntime" | |
| NUITKA_OPTS="$NUITKA_OPTS --include-package-data=rapidocr_onnxruntime" | |
| NUITKA_OPTS="$NUITKA_OPTS --include-package=shapely" | |
| NUITKA_OPTS="$NUITKA_OPTS --include-package-data=shapely" | |
| NUITKA_OPTS="$NUITKA_OPTS --include-package=pyclipper" | |
| NUITKA_OPTS="$NUITKA_OPTS --include-package=mss" | |
| # dxcam is Windows-only (win32 marker); it's handled in the Windows cmd | |
| # build step and is never installed here, so it's intentionally omitted. | |
| # Package data | |
| NUITKA_OPTS="$NUITKA_OPTS --include-package-data=audiolab" | |
| NUITKA_OPTS="$NUITKA_OPTS --include-package-data=pyrnnoise" | |
| NUITKA_OPTS="$NUITKA_OPTS --include-package-data=browser_use" | |
| # NOTE: do NOT use --include-package-data=playwright here, | |
| # Nuitka's built-in playwright plugin handles driver/node automatically | |
| # and adding package-data would conflict with the exe at the same path. | |
| NUITKA_OPTS="$NUITKA_OPTS --include-package-data=jinja2" | |
| NUITKA_OPTS="$NUITKA_OPTS --include-package-data=certifi" | |
| NUITKA_OPTS="$NUITKA_OPTS --include-package-data=bilibili_api" | |
| # Exclusions | |
| # galgame_plugin/training/ is offline model-training code (torch / | |
| # torchvision / albumentations) — not declared deps, not installed, and | |
| # runtime inference uses the exported .onnx via onnxruntime instead. | |
| # Carve it out so --include-package=plugin.plugins doesn't recurse into it. | |
| NUITKA_OPTS="$NUITKA_OPTS --nofollow-import-to=plugin.plugins.galgame_plugin.training" | |
| NUITKA_OPTS="$NUITKA_OPTS --nofollow-import-to=audiolab" | |
| NUITKA_OPTS="$NUITKA_OPTS --nofollow-import-to=pyrnnoise" | |
| NUITKA_OPTS="$NUITKA_OPTS --nofollow-import-to=matplotlib" | |
| NUITKA_OPTS="$NUITKA_OPTS --nofollow-import-to=pytest" | |
| # macOS: exclude pyobjc frameworks (Foundation requires --mode=app) | |
| if [[ "$RUNNER_OS" == "macOS" ]]; then | |
| NUITKA_OPTS="$NUITKA_OPTS --nofollow-import-to=Foundation" | |
| NUITKA_OPTS="$NUITKA_OPTS --nofollow-import-to=AppKit" | |
| NUITKA_OPTS="$NUITKA_OPTS --nofollow-import-to=objc" | |
| NUITKA_OPTS="$NUITKA_OPTS --nofollow-import-to=PyObjCTools" | |
| NUITKA_OPTS="$NUITKA_OPTS --nofollow-import-to=CoreFoundation" | |
| NUITKA_OPTS="$NUITKA_OPTS --nofollow-import-to=Quartz" | |
| fi | |
| # Plugin & CI flags | |
| NUITKA_OPTS="$NUITKA_OPTS --enable-plugin=dill-compat" | |
| NUITKA_OPTS="$NUITKA_OPTS --assume-yes-for-downloads" | |
| # Steam files (include if present in repo). | |
| # PR #1264 moved native libs into steamworks/; runtime still loads them | |
| # from the exe sibling dir (see steamworks/__init__.py), so dest stays at root. | |
| # Gate by RUNNER_OS: all platforms' libs are checked into the repo, but | |
| # feeding a Linux ELF .so to a macOS Nuitka build trips an arch-mismatch FATAL. | |
| # The --include-package=steamworks above also makes Nuitka pick up every | |
| # native lib in steamworks/ as package data, so strip the wrong-platform | |
| # copies from the source tree before invoking nuitka. | |
| NUITKA_OPTS="$NUITKA_OPTS --include-data-files=steam_appid.txt=steam_appid.txt" | |
| if [[ "$RUNNER_OS" == "macOS" ]]; then | |
| rm -f steamworks/SteamworksPy.so steamworks/libsteam_api.so \ | |
| steamworks/SteamworksPy64.dll steamworks/steam_api64.dll steamworks/steam_api64.lib | |
| if [ -f "steamworks/libsteam_api.dylib" ]; then | |
| NUITKA_OPTS="$NUITKA_OPTS --include-data-files=steamworks/libsteam_api.dylib=libsteam_api.dylib" | |
| fi | |
| if [ -f "steamworks/SteamworksPy.dylib" ]; then | |
| NUITKA_OPTS="$NUITKA_OPTS --include-data-files=steamworks/SteamworksPy.dylib=SteamworksPy.dylib" | |
| fi | |
| elif [[ "$RUNNER_OS" == "Linux" ]]; then | |
| rm -f steamworks/SteamworksPy.dylib steamworks/libsteam_api.dylib \ | |
| steamworks/SteamworksPy64.dll steamworks/steam_api64.dll steamworks/steam_api64.lib | |
| if [ -f "steamworks/libsteam_api.so" ]; then | |
| NUITKA_OPTS="$NUITKA_OPTS --include-data-files=steamworks/libsteam_api.so=libsteam_api.so" | |
| fi | |
| if [ -f "steamworks/SteamworksPy.so" ]; then | |
| NUITKA_OPTS="$NUITKA_OPTS --include-data-files=steamworks/SteamworksPy.so=SteamworksPy.so" | |
| fi | |
| fi | |
| echo "=== Nuitka options ===" | |
| echo "$NUITKA_OPTS" | tr ' ' '\n' | |
| echo "======================" | |
| .venv/bin/python -m nuitka $NUITKA_OPTS launcher.py | |
| - name: Build with Nuitka (Windows) | |
| if: runner.os == 'Windows' | |
| shell: cmd | |
| run: | | |
| set NUITKA_OPTS=--standalone --output-dir="dist" --output-filename="projectneko_server.exe" | |
| set NUITKA_OPTS=%NUITKA_OPTS% --windows-icon-from-ico=assets/icon.ico | |
| set NUITKA_OPTS=%NUITKA_OPTS% --company-name="Project N.E.K.O." | |
| set NUITKA_OPTS=%NUITKA_OPTS% --product-name="N.E.K.O. AI Assistant" | |
| set NUITKA_OPTS=%NUITKA_OPTS% --file-version=1.0.0.0 | |
| set NUITKA_OPTS=%NUITKA_OPTS% --product-version=1.0.0.0 | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-data-files=config/__init__.py=config/__init__.py | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-data-files=config/api_providers.json=config/api_providers.json | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-data-files=config/characters.json=config/characters.json | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-data-files=config/characters.en.json=config/characters.en.json | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-data-files=config/characters.ja.json=config/characters.ja.json | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-data-files=config/characters.ko.json=config/characters.ko.json | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-data-files=config/characters.ru.json=config/characters.ru.json | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-data-files=config/characters.zh-CN.json=config/characters.zh-CN.json | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-data-files=config/characters.zh-TW.json=config/characters.zh-TW.json | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-data-files=config/core_config.json=config/core_config.json | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-data-files=config/user_preferences.json=config/user_preferences.json | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-data-dir=assets=assets | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-data-dir=templates=templates | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-data-dir=data/browser_use_prompts=data/browser_use_prompts | |
| if not exist "frontend\plugin-manager\dist" echo [WARNING] frontend\plugin-manager\dist missing; /ui will be empty in bundle. | |
| if exist "frontend\plugin-manager\dist" set NUITKA_OPTS=%NUITKA_OPTS% --include-data-dir=frontend/plugin-manager/dist=frontend/plugin-manager/dist | |
| if exist "plugin\plugins" set NUITKA_OPTS=%NUITKA_OPTS% --include-data-dir=plugin/plugins=plugin/plugins | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-data-dir=playwright_browsers=playwright_browsers | |
| if exist "data\tiktoken_cache" set NUITKA_OPTS=%NUITKA_OPTS% --include-data-dir=data/tiktoken_cache=data/tiktoken_cache | |
| if exist "data\embedding_models" set NUITKA_OPTS=%NUITKA_OPTS% --include-data-dir=data/embedding_models=data/embedding_models | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-data-dir=static=static | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-package=uvicorn | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-package=fastapi | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-package=starlette | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-package=jinja2 | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-package=websockets | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-package=app | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-package=config | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-package=plugin | |
| rem Built-in plugins are imported at runtime via their dotted entry path | |
| rem (plugin.plugins.<name>:Class in plugin.toml), so they must be compiled | |
| rem into the binary; --include-data-dir alone drops the .py. Synced with | |
| rem build_nuitka.bat. training/ is offline-only (torch) so carve it out. | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-package=plugin.plugins | |
| set NUITKA_OPTS=%NUITKA_OPTS% --nofollow-import-to=plugin.plugins.galgame_plugin.training | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-package=brain | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-package=main_logic | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-package=main_routers | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-package=memory | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-package=utils | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-package=steamworks | |
| rem PR #1264 moved native libs into steamworks/; runtime still loads them | |
| rem from the exe sibling dir (see steamworks/__init__.py), so dest stays at root. | |
| rem Guard with `if exist` to mirror the Unix branch — keeps the build | |
| rem soft on incidental missing libs instead of fataling in Nuitka. | |
| rem --include-package=steamworks pulls in every native lib in steamworks/ | |
| rem as package data; strip the non-Windows copies so Nuitka can't trip | |
| rem an arch check on them (mirrors the Unix branch's rm step). | |
| del /q steamworks\SteamworksPy.so steamworks\libsteam_api.so steamworks\SteamworksPy.dylib steamworks\libsteam_api.dylib 2>nul | |
| if exist "steamworks\steam_api64.dll" set NUITKA_OPTS=%NUITKA_OPTS% --include-data-files=steamworks/steam_api64.dll=steam_api64.dll | |
| if exist "steamworks\steam_api64.lib" set NUITKA_OPTS=%NUITKA_OPTS% --include-data-files=steamworks/steam_api64.lib=steam_api64.lib | |
| if exist "steamworks\SteamworksPy64.dll" set NUITKA_OPTS=%NUITKA_OPTS% --include-data-files=steamworks/SteamworksPy64.dll=SteamworksPy64.dll | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-data-files=steam_appid.txt=steam_appid.txt | |
| set NUITKA_OPTS=%NUITKA_OPTS% --nofollow-import-to=audiolab | |
| set NUITKA_OPTS=%NUITKA_OPTS% --nofollow-import-to=pyrnnoise | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-package-data=audiolab | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-package-data=pyrnnoise | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-package=browser_use | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-package-data=browser_use | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-package=browser_use_sdk | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-package=playwright | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-package-data=playwright | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-package=tiktoken | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-package=tiktoken_ext | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-package=onnxruntime | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-package=tokenizers | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-package-data=jinja2 | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-package-data=certifi | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-package=bilibili_api | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-package-data=bilibili_api | |
| rem galgame_plugin native OCR/vision cohort (uv sync --group galgame: | |
| rem rapidocr-onnxruntime + opencv-python-headless, pulling shapely/pyclipper; | |
| rem mss/dxcam from main deps). Nuitka auto-follow misses their data files, | |
| rem so include package + data explicitly. Synced with build_nuitka.bat. | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-package=rapidocr_onnxruntime | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-package-data=rapidocr_onnxruntime | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-package=cv2 | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-package-data=cv2 | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-package=pyclipper | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-package=shapely | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-package-data=shapely | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-package=mss | |
| set NUITKA_OPTS=%NUITKA_OPTS% --include-package=dxcam | |
| set NUITKA_OPTS=%NUITKA_OPTS% --enable-plugin=dill-compat | |
| set NUITKA_OPTS=%NUITKA_OPTS% --nofollow-import-to=matplotlib | |
| set NUITKA_OPTS=%NUITKA_OPTS% --nofollow-import-to=pytest | |
| set NUITKA_OPTS=%NUITKA_OPTS% --windows-console-mode=force | |
| set NUITKA_OPTS=%NUITKA_OPTS% --assume-yes-for-downloads | |
| .venv\Scripts\python.exe -m nuitka %NUITKA_OPTS% launcher.py | |
| # --- Rename Nuitka output directory --- | |
| - name: Rename build output | |
| shell: bash | |
| run: | | |
| if [ -d "dist/launcher.dist" ]; then | |
| mv dist/launcher.dist dist/Xiao8 | |
| fi | |
| # macOS: playwright_browsers was excluded from Nuitka (xattr bug with | |
| # spaces in .app paths), copy it manually into the output directory | |
| if [[ "$RUNNER_OS" == "macOS" && -d "playwright_browsers" ]]; then | |
| cp -R playwright_browsers dist/Xiao8/playwright_browsers | |
| echo "Copied playwright_browsers into dist/Xiao8/" | |
| fi | |
| echo "=== Build output ===" | |
| ls -la dist/Xiao8/ || echo "dist/Xiao8 not found!" | |
| # --- Verify bundled embedding + tiktoken assets --- | |
| # memory/embeddings.py treats every onnx_data sidecar plus both fp32 | |
| # and int8 variants as required at session-load time; tiktoken | |
| # o200k_base must also be present so utils.tokenize doesn't fall | |
| # back to the heuristic counter. Use -s (non-empty) so a zero-byte | |
| # file from an interrupted download fails the build, matching | |
| # prepare_embedding_model.py's own non-empty contract. | |
| - name: Verify bundled offline assets | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| if [ ! -d "dist/Xiao8" ]; then | |
| echo "::error::dist/Xiao8 not found, cannot verify" | |
| exit 1 | |
| fi | |
| embedding_required=( | |
| "tokenizer.json" | |
| "onnx/model.onnx" | |
| "onnx/model.onnx_data" | |
| "onnx/model_quantized.onnx" | |
| "onnx/model_quantized.onnx_data" | |
| ) | |
| missing=0 | |
| for rel in "${embedding_required[@]}"; do | |
| if [ ! -s "dist/Xiao8/data/embedding_models/$EMBEDDING_MODEL_PROFILE_ID/$rel" ]; then | |
| echo "::error::missing or empty bundled embedding asset: $rel" | |
| missing=1 | |
| fi | |
| done | |
| if ! ls dist/Xiao8/data/tiktoken_cache/ 2>/dev/null | grep -q .; then | |
| echo "::error::data/tiktoken_cache empty in bundle (o200k_base would re-download at runtime)" | |
| missing=1 | |
| fi | |
| [ "$missing" -eq 0 ] | |
| # --- Generic dist invariant check --- | |
| # Catches the broader class of "Nuitka silently dropped a required | |
| # asset" bugs: missing config/, static/, plugin/plugins, frontend | |
| # build output, or built-in plugin.toml files. See | |
| # scripts/check_nuitka_dist.py for the full asset list and rationale. | |
| - name: Verify Nuitka dist invariants | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| if [[ "$RUNNER_OS" == "Windows" ]]; then | |
| .venv/Scripts/python.exe scripts/check_nuitka_dist.py dist/Xiao8 | |
| else | |
| .venv/bin/python scripts/check_nuitka_dist.py dist/Xiao8 | |
| fi | |
| - name: Upload Python backend artifact | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: ${{ matrix.artifact_name }} | |
| path: dist/Xiao8/ | |
| retention-days: 7 | |
| compression-level: 6 | |
| # ────────────────────────────────────────────── | |
| # Step 2: Build Electron desktop app | |
| # ────────────────────────────────────────────── | |
| build-electron: | |
| if: github.repository == 'Project-N-E-K-O/N.E.K.O' | |
| needs: [version, build-python] | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - os: windows-latest | |
| platform: win | |
| python_artifact: python-backend-win | |
| electron_args: '--win' | |
| artifact_name: desktop-win-x64 | |
| - os: macos-15 | |
| platform: mac-x64 | |
| python_artifact: python-backend-mac-x64 | |
| electron_args: '--mac --x64' | |
| artifact_name: desktop-mac-x64 | |
| - os: macos-latest | |
| platform: mac-arm64 | |
| python_artifact: python-backend-mac-arm64 | |
| electron_args: '--mac --arm64' | |
| artifact_name: desktop-mac-arm64 | |
| - os: ubuntu-latest | |
| platform: linux | |
| python_artifact: python-backend-linux | |
| electron_args: '--linux' | |
| artifact_name: desktop-linux-x64 | |
| runs-on: ${{ matrix.os }} | |
| timeout-minutes: 180 | |
| steps: | |
| - name: Checkout Electron frontend | |
| uses: actions/checkout@v4 | |
| with: | |
| repository: ${{ github.event.inputs.electron_repo || 'Project-N-E-K-O/N.E.K.O.-PC' }} | |
| ref: ${{ github.event.inputs.electron_ref || 'main' }} | |
| token: ${{ secrets.ELECTRON_REPO_TOKEN || secrets.GITHUB_TOKEN }} | |
| path: electron-app | |
| - name: Set up Node.js ${{ env.NODE_VERSION }} | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| cache: 'npm' | |
| cache-dependency-path: electron-app/package-lock.json | |
| - name: Download Python backend artifact | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: ${{ matrix.python_artifact }} | |
| path: electron-app/bin | |
| - name: Make server binary executable (Unix) | |
| if: runner.os != 'Windows' | |
| run: chmod +x electron-app/bin/projectneko_server | |
| # --- Cache Electron binary to avoid download failures --- | |
| - name: Cache Electron binary | |
| uses: actions/cache@v4 | |
| with: | |
| path: | | |
| ~/AppData/Local/electron/Cache | |
| ~/Library/Caches/electron | |
| ~/.cache/electron | |
| key: electron-${{ matrix.os }}-${{ matrix.platform }}-${{ hashFiles('electron-app/package-lock.json') }} | |
| restore-keys: | | |
| electron-${{ matrix.os }}-${{ matrix.platform }}- | |
| - name: Install npm dependencies | |
| working-directory: electron-app | |
| run: npm ci || npm install | |
| - name: Update version in package.json | |
| working-directory: electron-app | |
| run: | | |
| node -e " | |
| const fs = require('fs'); | |
| const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); | |
| pkg.version = '${{ needs.version.outputs.version }}'.replace(/^[^0-9]*/, ''); | |
| if (!/^\d+\.\d+\.\d+/.test(pkg.version)) { | |
| pkg.version = '0.0.0-' + pkg.version; | |
| } | |
| fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); | |
| console.log('Updated version to:', pkg.version); | |
| " | |
| - name: Install Linux build dependencies | |
| if: runner.os == 'Linux' | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y --no-install-recommends \ | |
| dpkg \ | |
| fakeroot | |
| # Explicitly disable macOS code signing via package.json patch | |
| # N.E.K.O.-PC has hardenedRuntime, afterSign (notarize.js), and entitlements | |
| # which all require a valid signing identity; disable them all for CI builds | |
| - name: Disable macOS code signing | |
| if: runner.os == 'macOS' && (github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && github.event.inputs.skip_signing == 'true')) | |
| working-directory: electron-app | |
| run: | | |
| node -e " | |
| const fs = require('fs'); | |
| const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); | |
| pkg.build = pkg.build || {}; | |
| pkg.build.mac = pkg.build.mac || {}; | |
| pkg.build.mac.identity = null; | |
| pkg.build.mac.hardenedRuntime = false; | |
| delete pkg.build.mac.entitlements; | |
| delete pkg.build.mac.entitlementsInherit; | |
| delete pkg.build.afterSign; | |
| fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); | |
| console.log('macOS code signing fully disabled'); | |
| " | |
| # Linux .deb requires a maintainer email; N.E.K.O.-PC package.json | |
| # only has author name without email, so patch it here | |
| - name: Set Linux deb maintainer | |
| if: runner.os == 'Linux' | |
| working-directory: electron-app | |
| run: | | |
| node -e " | |
| const fs = require('fs'); | |
| const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); | |
| pkg.build = pkg.build || {}; | |
| pkg.build.linux = pkg.build.linux || {}; | |
| pkg.build.linux.maintainer = 'Project N.E.K.O. <projectneko@yahoo.com>'; | |
| fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); | |
| console.log('Set Linux deb maintainer'); | |
| " | |
| # --- Build Electron app --- | |
| - name: Build Electron app | |
| working-directory: electron-app | |
| run: npx electron-builder ${{ matrix.electron_args }} --publish never | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| CSC_IDENTITY_AUTO_DISCOVERY: ${{ (github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && github.event.inputs.skip_signing == 'true')) && 'false' || 'true' }} | |
| WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }} | |
| WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }} | |
| CSC_LINK: ${{ secrets.MAC_CSC_LINK }} | |
| CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }} | |
| APPLE_ID: ${{ secrets.APPLE_ID }} | |
| APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} | |
| APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} | |
| - name: List build output | |
| working-directory: electron-app/dist | |
| shell: bash | |
| run: | | |
| echo "=== Build artifacts ===" | |
| ls -lahR . || true | |
| - name: Upload desktop artifact | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: ${{ matrix.artifact_name }} | |
| path: | | |
| electron-app/dist/*.exe | |
| electron-app/dist/*.dmg | |
| electron-app/dist/*.zip | |
| electron-app/dist/*.AppImage | |
| electron-app/dist/*.deb | |
| electron-app/dist/*.tar.gz | |
| electron-app/dist/*.blockmap | |
| electron-app/dist/latest*.yml | |
| if-no-files-found: warn | |
| retention-days: 14 | |
| # ────────────────────────────────────────────── | |
| # Step 3: Publish Nightly Release | |
| # ────────────────────────────────────────────── | |
| nightly: | |
| if: github.repository == 'Project-N-E-K-O/N.E.K.O' && always() && !cancelled() && needs.build-python.result == 'success' && needs.build-electron.result != 'failure' | |
| needs: [version, build-python, build-electron] | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Download all artifacts | |
| uses: actions/download-artifact@v4 | |
| with: | |
| path: artifacts | |
| - name: Organize release files | |
| run: | | |
| mkdir -p release | |
| VERSION="${{ needs.version.outputs.version }}" | |
| # Zip Python backend artifacts per platform | |
| for dir in artifacts/python-backend-*; do | |
| [ -d "$dir" ] || continue | |
| name=$(basename "$dir") | |
| echo "Zipping $name ..." | |
| (cd "$dir" && zip -qr "../../release/${name}-${VERSION}.zip" .) | |
| done | |
| # Collect Electron desktop artifacts | |
| find artifacts/desktop-* -type f \( \ | |
| -name "*.exe" -o -name "*.dmg" -o -name "*.zip" \ | |
| -o -name "*.AppImage" -o -name "*.deb" -o -name "*.tar.gz" \ | |
| -o -name "latest*.yml" \ | |
| \) -exec cp {} release/ \; 2>/dev/null || true | |
| echo "=== Nightly release files ===" | |
| ls -lah release/ | |
| - name: Delete old nightly release | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| gh release delete nightly --yes --cleanup-tag 2>/dev/null || true | |
| - name: Create nightly release | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| VERSION="${{ needs.version.outputs.version }}" | |
| DATE=$(date +%Y-%m-%d) | |
| gh release create nightly release/* \ | |
| --title "Nightly Build ${DATE}" \ | |
| --target "${{ github.sha }}" \ | |
| --prerelease \ | |
| --notes "$(cat <<NOTES | |
| ## Nightly Build ${DATE} (${VERSION}) | |
| > ⚠️ This is an **unsigned** nightly build for testing purposes. | |
| > | |
| > 🍎 **macOS users:** If you see *"N.E.K.O is damaged and can't be opened"*, run this in Terminal: | |
| > \`\`\`bash | |
| > xattr -cr /path-to/N.E.K.O.app | |
| > \`\`\` | |
| ### Desktop App (Electron + Python backend) | |
| | Platform | File | | |
| |----------|------| | |
| | Windows x64 | \`N.E.K.O.exe\` (Portable) | | |
| | macOS Intel | \`N.E.K.O-*.dmg\` / \`.zip\` | | |
| | macOS Apple Silicon | \`N.E.K.O-*-arm64.dmg\` / \`.zip\` | | |
| | Linux x64 | \`N.E.K.O-*.AppImage\` / \`.deb\` / \`.tar.gz\` | | |
| ### Python Backend Only (standalone, no Electron) | |
| | Platform | File | | |
| |----------|------| | |
| | Windows x64 | \`python-backend-win-*.zip\` | | |
| | macOS Intel | \`python-backend-mac-x64-*.zip\` | | |
| | macOS Apple Silicon | \`python-backend-mac-arm64-*.zip\` | | |
| | Linux x64 | \`python-backend-linux-*.zip\` | | |
| --- | |
| Built from [\`${VERSION}\`](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/commit/${GITHUB_SHA}) | |
| NOTES | |
| )" |