Skip to content

Build Desktop App (Cross-Platform) #50

Build Desktop App (Cross-Platform)

Build Desktop App (Cross-Platform) #50

Workflow file for this run

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"
# onnxruntime.transformers/ is a benchmark + model-conversion toolbox
# nothing at runtime imports (rapidocr uses only the core InferenceSession).
# --include-package=onnxruntime would recurse and compile the whole subtree,
# including a 130k+ line gpt2 benchmark C unit that crashes the C backend.
# Synced with build_nuitka.bat.
NUITKA_OPTS="$NUITKA_OPTS --nofollow-import-to=onnxruntime.transformers"
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
rem onnxruntime.transformers/ is a benchmark + model-conversion toolbox
rem nothing at runtime imports (rapidocr uses only the core InferenceSession).
rem --include-package=onnxruntime would recurse and compile the whole subtree,
rem including a 130k+ line gpt2 benchmark C unit that crashes the C backend.
rem Synced with build_nuitka.bat.
set NUITKA_OPTS=%NUITKA_OPTS% --nofollow-import-to=onnxruntime.transformers
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
)"