feat: separate writable data dir from program dir (LAN update groundwork)

- add get_data_dir() with CMBOT_DATA_DIR override, fallback to program dir
- point config/logs/output at the data root; resources stay on program root
- harden config/template writes with parents=True for a deep data root
- add docs/10-lan-update.md (LAN auto-update design); default install root
  %LOCALAPPDATA%\CMBot for elevation-free self-update

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 16:29:47 +08:00
co-authored by Claude Opus 4.8
parent 7a1b9d8330
commit 76f2c6dd5b
4 changed files with 288 additions and 13 deletions
+1 -1
View File
@@ -56,7 +56,7 @@ def save_config(data):
config_file = get_config_path(_CONFIG_FILENAME)
try:
config_file.parent.mkdir(exist_ok=True)
config_file.parent.mkdir(parents=True, exist_ok=True)
with open(str(config_file), "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
logger.info("Config saved to %s", config_file)
+34 -11
View File
@@ -1,4 +1,5 @@
import logging
import os
import sys
from datetime import datetime
from pathlib import Path
@@ -19,19 +20,41 @@ def is_supported_image(path):
# ---------------------------------------------------------------------------
def get_app_dir():
"""Return the application root directory as a Path.
"""Return the program root directory (read-only program files) as a Path.
PyInstaller onedir: directory that contains the .exe.
PyInstaller onedir: directory that contains the .exe. In the versioned
install layout (docs/10-lan-update.md) this is versions/<x.y.z>/, which is
replaced wholesale on update.
Development: project root (three levels above this file:
src/services/file_service.py -> src/services -> src -> project root).
Resources live here; writable user data does not — use get_data_dir().
"""
if getattr(sys, "frozen", False):
return Path(sys.executable).resolve().parent
return Path(__file__).resolve().parent.parent.parent
def get_data_dir():
"""Return the writable data root (config, templates, logs, output) as a Path.
Kept separate from the program root so that replacing the program on update
never touches user data (docs/10-lan-update.md §5).
Resolution order:
1. CMBOT_DATA_DIR environment variable, when set. The launcher points this
at the install-wide data/ folder in the versioned layout.
2. Fallback to get_app_dir() — the flat layout used in development and in
the current onedir release, where data sits next to the program.
"""
env = os.environ.get("CMBOT_DATA_DIR", "").strip()
if env:
return Path(env)
return get_app_dir()
def get_resource_path(relative_path):
"""Return absolute Path for a resource file.
"""Return absolute Path for a resource file under the program root.
Development resources live under src/resources/. In a PyInstaller onedir
build, resources are copied next to the executable under resources/.
@@ -42,21 +65,21 @@ def get_resource_path(relative_path):
def get_config_path(relative_path):
"""Return absolute Path for a file under <app_dir>/config/."""
return get_app_dir() / "config" / relative_path
"""Return absolute Path for a file under <data_dir>/config/."""
return get_data_dir() / "config" / relative_path
def get_log_dir():
"""Return <app_dir>/logs/ as a Path, creating the directory if absent."""
d = get_app_dir() / "logs"
d.mkdir(exist_ok=True)
"""Return <data_dir>/logs/ as a Path, creating the directory if absent."""
d = get_data_dir() / "logs"
d.mkdir(parents=True, exist_ok=True)
return d
def get_output_dir():
"""Return <app_dir>/output/ as a Path, creating the directory if absent."""
d = get_app_dir() / "output"
d.mkdir(exist_ok=True)
"""Return <data_dir>/output/ as a Path, creating the directory if absent."""
d = get_data_dir() / "output"
d.mkdir(parents=True, exist_ok=True)
return d
+1 -1
View File
@@ -77,7 +77,7 @@ def _save_custom_templates(templates: List[Template]):
"""Write custom templates to JSON. Logs error on failure, does not raise."""
path = _templates_file()
try:
path.parent.mkdir(exist_ok=True)
path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"templates": [
{