Skip to content

Commit 61cadc6

Browse files
committed
feat: add standalone EXE packaging support (via PyInstaller)
1 parent 038f5b7 commit 61cadc6

File tree

10 files changed

+781
-28
lines changed

10 files changed

+781
-28
lines changed

.gitignore

79 Bytes
Binary file not shown.

NOVIX.spec

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# -*- mode: python ; coding: utf-8 -*-
2+
3+
4+
a = Analysis(
5+
['backend\\app\\main.py'],
6+
pathex=[],
7+
binaries=[],
8+
datas=[('backend/static', 'static')],
9+
hiddenimports=['uvicorn.logging', 'uvicorn.loops', 'uvicorn.loops.auto', 'uvicorn.protocols', 'uvicorn.protocols.http', 'uvicorn.protocols.http.auto', 'uvicorn.lifespan', 'uvicorn.lifespan.on', 'engineio.async_drivers.aiohttp'],
10+
hookspath=[],
11+
hooksconfig={},
12+
runtime_hooks=[],
13+
excludes=[],
14+
noarchive=False,
15+
optimize=0,
16+
)
17+
pyz = PYZ(a.pure)
18+
19+
exe = EXE(
20+
pyz,
21+
a.scripts,
22+
[],
23+
exclude_binaries=True,
24+
name='NOVIX',
25+
debug=False,
26+
bootloader_ignore_signals=False,
27+
strip=False,
28+
upx=True,
29+
console=True,
30+
disable_windowed_traceback=False,
31+
argv_emulation=False,
32+
target_arch=None,
33+
codesign_identity=None,
34+
entitlements_file=None,
35+
)
36+
coll = COLLECT(
37+
exe,
38+
a.binaries,
39+
a.datas,
40+
strip=False,
41+
upx=True,
42+
upx_exclude=[],
43+
name='NOVIX',
44+
)

backend/app/config.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@ class Settings(BaseSettings):
4545
novix_agent_writer_provider: str = os.getenv("NOVIX_AGENT_WRITER_PROVIDER", "")
4646
novix_agent_reviewer_provider: str = os.getenv("NOVIX_AGENT_REVIEWER_PROVIDER", "")
4747
novix_agent_editor_provider: str = os.getenv("NOVIX_AGENT_EDITOR_PROVIDER", "")
48+
49+
# Storage Configuration / 存储路径配置
50+
# Note: Will be calculated in load_config logic if needed, but here we set default
51+
# If using absolute path in .env, use it. Otherwise relative.
52+
# We need a dynamic property for this in usage.
53+
data_dir: str = "../data" # Default relative path
4854

4955

5056
def load_config(config_path: str = "config.yaml") -> Dict[str, Any]:
@@ -57,7 +63,18 @@ def load_config(config_path: str = "config.yaml") -> Dict[str, Any]:
5763
Returns:
5864
Configuration dictionary / 配置字典
5965
"""
60-
config_file = Path(__file__).parent.parent / config_path
66+
import sys
67+
68+
# Determine root path: Dev or Frozen (EXE)
69+
if getattr(sys, 'frozen', False):
70+
# Running as PyInstaller Bundle
71+
# The config file should be next to the EXE
72+
root_path = Path(sys.executable).parent
73+
else:
74+
# Running as Source Code
75+
root_path = Path(__file__).parent.parent
76+
77+
config_file = root_path / config_path
6178

6279
if not config_file.exists():
6380
raise FileNotFoundError(f"Config file not found: {config_file}")
@@ -86,6 +103,19 @@ def _replace_env_vars(obj: Any) -> Any:
86103
return obj
87104

88105

106+
def __init__(self, **kwargs):
107+
super().__init__(**kwargs)
108+
# Dynamic path resolution for data_dir
109+
import sys
110+
if getattr(sys, 'frozen', False):
111+
# Frozen: data dir is next to EXE
112+
root = Path(sys.executable).parent
113+
self.data_dir = str(root / "data")
114+
else:
115+
# Dev: data dir is relative to source
116+
# Although default is "../data", we can make it absolute for clarity
117+
pass
118+
89119
# Global settings instance / 全局设置实例
90120
settings = Settings()
91121
config = load_config()

backend/app/main.py

Lines changed: 112 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -35,38 +35,125 @@
3535
)
3636

3737
# Register routers / 注册路由
38-
app.include_router(projects_router)
39-
app.include_router(cards_router)
40-
app.include_router(canon_router)
41-
app.include_router(drafts_router)
42-
app.include_router(session_router)
43-
app.include_router(config_router)
44-
app.include_router(websocket_router)
45-
app.include_router(fanfiction_router)
46-
47-
48-
@app.get("/")
49-
async def root():
50-
"""Root endpoint / 根路径"""
51-
return {
52-
"message": "NOVIX API is running",
53-
"version": "0.1.0",
54-
"status": "healthy",
55-
"docs": "/docs"
56-
}
38+
# Register routers / 注册路由
39+
# Strategy: Dual Mount
40+
# Mount at root "/" for Dev mode (where Vite proxy strips /api)
41+
# Mount at "/api" for Prod/EXE mode (where frontend calls /api directly)
42+
routers = [
43+
projects_router,
44+
cards_router,
45+
canon_router,
46+
drafts_router,
47+
session_router,
48+
config_router,
49+
websocket_router,
50+
fanfiction_router
51+
]
52+
53+
for router in routers:
54+
app.include_router(router) # Dev: http://localhost:8000/projects
55+
app.include_router(router, prefix="/api") # Prod: http://localhost:8000/api/projects
56+
57+
58+
5759

5860

5961
@app.get("/health")
6062
async def health_check():
6163
"""Health check endpoint / 健康检查"""
6264
return {"status": "ok"}
6365

66+
@app.on_event("startup")
67+
async def on_startup():
68+
"""Startup event handler / 启动事件处理"""
69+
import sys
70+
import webbrowser
71+
import asyncio
72+
73+
# Auto-open browser in a separate thread to not block startup
74+
# But webbrowser.open is usually fire-and-forget
75+
if getattr(sys, 'frozen', False):
76+
url = f"http://localhost:{settings.port}"
77+
print(f"[Main] Auto-opening browser at {url} ...")
78+
# Small delay to ensure server is ready
79+
async def open_browser():
80+
await asyncio.sleep(1.5)
81+
webbrowser.open(url)
82+
asyncio.create_task(open_browser())
83+
84+
# --- Static Files / SPA Support (Added for Packaging) ---
85+
import sys
86+
from fastapi.staticfiles import StaticFiles
87+
from fastapi.responses import FileResponse
88+
from pathlib import Path
89+
90+
# Check where static files are located
91+
if getattr(sys, 'frozen', False):
92+
# Running as PyInstaller Bundle
93+
base_path = getattr(sys, '_MEIPASS', Path(sys.executable).parent)
94+
static_dir = Path(base_path) / "static"
95+
else:
96+
# Dev: Look for backend/static if it exists (for testing build script without freezing)
97+
static_dir = Path(__file__).parent.parent / "static"
98+
99+
if static_dir.exists():
100+
print(f"[Main] Serving static files from: {static_dir}")
101+
102+
# 1. Mount assets (css, js, images)
103+
app.mount("/assets", StaticFiles(directory=str(static_dir / "assets")), name="assets")
104+
105+
# 2. Serve Index at Root
106+
@app.get("/")
107+
async def serve_root():
108+
return FileResponse(static_dir / "index.html")
109+
110+
# 3. Catch-all for SPA routes (Serve index.html)
111+
@app.get("/{full_path:path}")
112+
async def serve_spa(full_path: str):
113+
# Safety: If request asks for /api/..., and we reached here, it's a 404.
114+
# Don't return HTML, otherwise frontend crashes (SyntaxError).
115+
if full_path.startswith("api/") or full_path.startswith("api"):
116+
from fastapi import HTTPException
117+
raise HTTPException(status_code=404, detail="API Endpoint Not Found")
118+
119+
# Check if file exists in static (e.g. favicon.ico)
120+
file_path = static_dir / full_path
121+
if file_path.exists() and file_path.is_file():
122+
return FileResponse(file_path)
123+
124+
# Otherwise serve index.html for SPA routing
125+
return FileResponse(static_dir / "index.html")
126+
else:
127+
print("[Main] Static directory not found. Running in API-only mode (Dev).")
128+
64129

65130
if __name__ == "__main__":
66131
import uvicorn
67-
uvicorn.run(
68-
"app.main:app",
69-
host=settings.host,
70-
port=settings.port,
71-
reload=settings.debug
72-
)
132+
import multiprocessing
133+
134+
# Critical for Windows EXE to prevent infinite spawn loop
135+
multiprocessing.freeze_support()
136+
137+
# Determine execution mode
138+
is_frozen = getattr(sys, 'frozen', False)
139+
140+
if is_frozen:
141+
# Prod/EXE: Run directly with app instance, NO RELOAD
142+
# Reloading in frozen mode causes infinite subprocess spawning
143+
print("[Main] Running in Frozen (EXE) Mode")
144+
uvicorn.run(
145+
app, # Pass app instance directly, not string
146+
host=settings.host,
147+
port=settings.port,
148+
reload=False,
149+
log_level="info"
150+
)
151+
else:
152+
# Dev: Run with reload
153+
print("[Main] Running in Dev Mode")
154+
uvicorn.run(
155+
"app.main:app",
156+
host=settings.host,
157+
port=settings.port,
158+
reload=settings.debug
159+
)

backend/app/storage/base.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,15 @@
1414
class BaseStorage:
1515
"""Base storage class with common file operations / 带通用文件操作的存储基类"""
1616

17-
def __init__(self, data_dir: str = "../data"):
17+
def __init__(self, data_dir: Optional[str] = None):
1818
"""
1919
Initialize storage
2020
2121
Args:
2222
data_dir: Root data directory / 数据根目录
2323
"""
24-
self.data_dir = Path(data_dir)
24+
from app.config import settings
25+
self.data_dir = Path(data_dir or settings.data_dir)
2526
self.encoding = "utf-8"
2627

2728
def get_project_path(self, project_id: str) -> Path:

backend/static/assets/index-BMkaJYUb.css

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)