fix(desktop): macOS 数据目录移出 .app 包 — 覆盖安装不再清空用户数据 + 包内旧数据自动迁移

This commit is contained in:
shy3130
2026-10-13 17:23:13 +08:00
parent 68368ac8c2
commit 25fd5a0fad
5 changed files with 209 additions and 41 deletions
+1 -1
View File
@@ -238,7 +238,7 @@ jobs:
- **Windows**: 下载后双击运行, 按安装向导操作(无需管理员权限), 自动创建桌面/开始菜单快捷方式
- **macOS**: 双击 dmg 打开, 将 TSP 拖入 Applications 文件夹; 首次启动需右键→打开(绕过 Gatekeeper)
- **Linux**: 解压后运行可执行文件
- 数据存储在安装目录下的 `data/` 子文件夹, 卸载重装不丢数据
- 数据存储: Windows 在安装目录的 `data/` 子文件夹 (卸载重装不丢); macOS 在 `~/Library/Application Support/TSP` (替换 App 升级不丢)
- 内置 Polars 兼容内核, 新老 CPU (无 AVX2) 均可运行
- 含纯 Polars 回测引擎; 不含 vectorbt 回测(为控制体积)
- 系统通知: 设置 → 实时监控 → 系统通知
+2
View File
@@ -491,6 +491,8 @@ cp .env.example .env # 按需填 TICKFLOW_API_KEY(留空 = None 模式)
<summary><b>🍎 macOS 安装注意事项(点开查看)</b></summary>
- **仅支持 Apple Silicon(M 系列芯片)**,暂无 Intel 版本。
- **数据位置**:用户数据(API Key、AI 配置、自选、行情数据)存放在 `~/Library/Application Support/TSP`,**升级替换 TSP.app 不会丢失**。
- **≤ v0.3.6 老用户升级须知**:旧版把数据存在 App 包内部(`TSP.app/Contents/MacOS/data`),直接用新 App 替换会把旧数据一起移入废纸篓。升级前请先备份该目录;若已替换,可从废纸篓把旧 `TSP.app` 拖出并取出 `Contents/MacOS/data`,恢复进新 App 同位置后启动一次,程序会自动把数据迁移到上述安全位置。
- 打开 dmg 后,请先把 **TSP.app 拖入「应用程序(Applications)」再运行**;不要直接在 dmg 挂载窗口里双击运行 —— 挂载卷是只读的,会导致启动即退出。
- 首次打开会被 macOS Gatekeeper 拦截(安装包暂未做开发者签名与公证):前往 **系统设置 → 隐私与安全性**,下滑到「已阻止使用 "TSP"」→ 点 **仍要打开**,再按提示确认一次。
- 若提示 **「"TSP" 已损坏,无法打开」**,或完成上一步后仍一闪退出:打开「终端」执行下面的命令,然后再启动(`xattr` 为 macOS 自带命令,作用是移除下载文件上的隔离标记,不会改动系统设置):
+16 -5
View File
@@ -22,11 +22,18 @@ def _user_data_root() -> Path:
定位策略 (按优先级):
1. 环境变量 DATA_DIR (pydantic-settings 自动注入到 settings.data_dir, 不在此处理)
2. 打包桌面版: exe 同级的 data/ 子目录 (<安装目录>/data/)
—— 与程序同处一个总目录 (用户选择的安装目录), 视觉直观, 便于备份/迁移。
2. 打包桌面版:
- macOS: ~/Library/Application Support/TSP —— 必须在 .app 包外。
升级用新 TSP.app 替换旧包时 Finder 会把整个旧包移入废纸篓,
数据放包内 (≤0.3.6 的 exe 同级 data) 等于每次覆盖安装都被清空;
App Support 是 mac 用户数据惯例位置, 替换 .app 永不触碰,
也规避向 /Applications 包内写入的权限问题。包内旧数据由
KlineRepository._migrate_legacy_data_dir() 首次启动自动搬出。
- Windows/Linux: exe 同级的 data/ 子目录 (<安装目录>/data/)
—— 与程序同处一个总目录 (用户选择的安装目录), 视觉直观, 便于备份/迁移。
3. 非 frozen (开发模式): 项目根 data/
为什么不用 platformdirs 默认 (%LOCALAPPDATA%) 作为主路径:
为什么 Windows 不用 platformdirs 默认 (%LOCALAPPDATA%) 作为主路径:
- 落在 C 盘系统目录, 用户不易察觉, 占系统盘空间
- 用户期望「数据跟随程序」(便于备份/迁移)
为什么放 {app}/data (exe 旁的 data/) 而非 {app} 外的兄弟目录:
@@ -34,10 +41,14 @@ def _user_data_root() -> Path:
- 数据安全: Inno Setup 覆盖安装(升级)时只往 {app} 写新程序文件, 不会清空
目录里不在安装清单上的运行时文件 (data/ 即此类), 故覆盖安装不丢数据。
(注意: 卸载时需在 .iss 中豁免 data/, 见 packaging/tsp.iss 的 [UninstallDelete]。)
旧版本数据迁移: 见 DataStore._migrate_legacy_data_dir(), 老用户首次启动自动搬迁。
旧版本数据迁移: 见 KlineRepository._migrate_legacy_data_dir(), 老用户首次启动自动搬迁。
"""
# 打包桌面版: exe 同级的 data/ 子目录 (与程序同一总目录, 覆盖安装不丢数据)
# 打包桌面版
if _IS_FROZEN:
# macOS: 数据放 .app 包外 (升级替换 App 不丢数据), 位置为 mac 惯例路径
if sys.platform == "darwin":
return Path.home() / "Library" / "Application Support" / "TSP"
# Windows/Linux: exe 同级的 data/ 子目录 (与程序同一总目录, 覆盖安装不丢数据)
exe_dir = Path(sys.executable).resolve().parent
return exe_dir / "data"
+47 -35
View File
@@ -146,11 +146,15 @@ class DataStore:
self._register_views()
def _migrate_legacy_data_dir(self) -> None:
"""把旧桌面版数据目录 (<安装目录>/../TickFlowStockPanel_Data/) 迁移到新位置 (<安装目录>/data/)。
"""把旧版数据目录迁移到当前位置 (打包桌面版, 首次启动自动搬迁)。
背景: 旧版 data_dir = exe_dir.parent / "TickFlowStockPanel_Data" (兄弟目录),
新版改为 exe_dir / "data" (子目录)。老用户首次升级时旧数据在兄弟目录,
若不迁移会导致历史行情/策略/回测/监控全部"丢失"(实际还在旧位置)。
legacy 候选 (逐个尝试, 命中即搬):
1. data_dir.parent / "TickFlowStockPanel_Data" —— Windows 旧版兄弟目录
(旧版 data_dir = exe_dir.parent / "TickFlowStockPanel_Data", 新版改子目录)。
2. macOS ≤0.3.6 的 .app 包内数据 (exe 同级 data/) —— 数据在包内时
覆盖安装替换 App 会连数据一起进废纸篓; 修复后 data 移到
~/Library/Application Support/TSP, 用户把包内数据找回/恢复进新包
(或从 dmg 直接运行过) 的场景由此接住并搬出。
策略 (仅打包桌面版触发, 开发/Docker 不受影响):
1. 旧目录存在且新 data/ 还基本为空 → 整目录搬迁 (shutil.move, 跨盘符安全)。
@@ -165,41 +169,49 @@ class DataStore:
import shutil
try:
legacy_dir = self.data_dir.parent / "TickFlowStockPanel_Data"
if not legacy_dir.exists():
return # 新装用户, 无旧数据
candidates: list[Path] = [
self.data_dir.parent / "TickFlowStockPanel_Data",
]
if sys.platform == "darwin":
candidates.append(Path(sys.executable).resolve().parent / "data")
# 新 data/ 目录里已有实质性内容 → 用户已在新路径跑过, 不覆盖
# (用 .parquet 作为"有真实数据"的判据, 避免空子目录误判)
has_new_data = any(self.data_dir.rglob("*.parquet")) or any(
self.data_dir.rglob("*.jsonl")
)
if has_new_data:
logger.info(
"legacy data dir %s exists but new %s already has data, skip migration",
legacy_dir, self.data_dir,
for legacy_dir in candidates:
if not legacy_dir.exists():
continue
if legacy_dir.resolve() == self.data_dir.resolve():
continue # 防御: 同一路径 (理论上不会出现) 不做任何事
# 新 data/ 目录里已有实质性内容 → 用户已在新路径跑过, 不覆盖
# (用 .parquet 作为"有真实数据"的判据, 避免空子目录误判)
has_new_data = any(self.data_dir.rglob("*.parquet")) or any(
self.data_dir.rglob("*.jsonl")
)
return
if has_new_data:
logger.info(
"legacy data dir %s exists but new %s already has data, skip migration",
legacy_dir, self.data_dir,
)
continue
logger.info("migrating legacy data %s -> %s", legacy_dir, self.data_dir)
# 逐项 move 而非整目录 move: data/ 可能已被 __init__ 创建了空子目录,
# 直接 shutil.move(legacy, data) 会因目标已存在失败。
for item in legacy_dir.iterdir():
dest = self.data_dir / item.name
if dest.exists():
# 同名子目录 (如 kline_daily): 合并内容
if dest.is_dir():
shutil.move(str(item), str(dest / item.name))
logger.info("migrating legacy data %s -> %s", legacy_dir, self.data_dir)
# 逐项 move 而非整目录 move: data/ 可能已被 __init__ 创建了空子目录,
# 直接 shutil.move(legacy, data) 会因目标已存在失败。
for item in legacy_dir.iterdir():
dest = self.data_dir / item.name
if dest.exists():
# 同名子目录 (如 kline_daily): 合并内容
if dest.is_dir():
shutil.move(str(item), str(dest / item.name))
else:
item.unlink() # 同名文件, 以新路径为准, 删旧
else:
item.unlink() # 同名文件, 以新路径为准, 删旧
else:
shutil.move(str(item), str(dest))
# 搬完后清理空的旧目录
try:
shutil.rmtree(legacy_dir)
except OSError:
logger.warning("legacy dir %s not empty, kept", legacy_dir)
logger.info("legacy data migration done")
shutil.move(str(item), str(dest))
# 搬完后清理空的旧目录
try:
shutil.rmtree(legacy_dir)
except OSError:
logger.warning("legacy dir %s not empty, kept", legacy_dir)
logger.info("legacy data migration done")
except Exception as e: # noqa: BLE001
logger.warning("legacy data migration failed (startup continues): %s", e)
+143
View File
@@ -0,0 +1,143 @@
"""macOS 数据目录移出 .app 包 — 路径解析与包内旧数据迁移。
背景: ≤0.3.6 的 frozen data 目录 = exe 同级, mac 上落在 TSP.app 包内,
覆盖安装替换 App 时旧包(含数据)整体进废纸篓, 用户数据全部"清空"。
修复: darwin frozen 默认 → ~/Library/Application Support/TSP (包外),
启动时自动把包内/兄弟目录的旧数据搬过来。
"""
from __future__ import annotations
import sys
from pathlib import Path
from app import config as config_mod
from app.config import _user_data_root
from app.tickflow.repository import DataStore
# ── 路径解析 ─────────────────────────────────────────────────────────
def test_darwin_frozen_uses_app_support_outside_bundle(tmp_path, monkeypatch):
home = tmp_path / "fakehome"
monkeypatch.setattr(config_mod, "_IS_FROZEN", True)
monkeypatch.setattr(sys, "platform", "darwin")
monkeypatch.setattr(Path, "home", lambda: home)
assert _user_data_root() == home / "Library" / "Application Support" / "TSP"
def test_windows_frozen_keeps_exe_sibling_data(tmp_path, monkeypatch):
exe = tmp_path / "TSP" / "TSP.exe"
monkeypatch.setattr(config_mod, "_IS_FROZEN", True)
monkeypatch.setattr(sys, "platform", "win32")
monkeypatch.setattr(sys, "executable", str(exe))
assert _user_data_root() == exe.parent / "data"
def test_dev_mode_uses_project_root(monkeypatch):
monkeypatch.setattr(config_mod, "_IS_FROZEN", False)
assert _user_data_root() == config_mod._PROJECT_ROOT / "data"
# ── 迁移 (直接构造最小实例调方法, 不触发完整 __init__) ────────────────
def _repo_with_data_dir(new_dir: Path) -> DataStore:
repo = DataStore.__new__(DataStore)
repo.data_dir = new_dir
return repo
def _make_real_data(root: Path) -> None:
(root / "kline_daily_enriched" / "date=2026-10-10").mkdir(parents=True)
(root / "kline_daily_enriched" / "date=2026-10-10" / "part.parquet").write_bytes(b"x")
(root / "user_data").mkdir(parents=True)
(root / "user_data" / "secrets.json").write_text("{}", encoding="utf-8")
def test_darwin_bundle_data_migrates_to_new_location(tmp_path, monkeypatch):
"""包内旧数据(用户从废纸篓恢复/从 dmg 运行过)首次启动自动搬出。"""
new_dir = tmp_path / "newdata"
new_dir.mkdir()
bundle_data = tmp_path / "TSP.app" / "Contents" / "MacOS" / "data"
_make_real_data(bundle_data)
monkeypatch.setattr(sys, "frozen", True, raising=False)
monkeypatch.setattr(sys, "platform", "darwin")
monkeypatch.setattr(sys, "executable", str(bundle_data.parent / "TSP"))
_repo_with_data_dir(new_dir)._migrate_legacy_data_dir()
assert (new_dir / "user_data" / "secrets.json").exists()
assert (new_dir / "kline_daily_enriched" / "date=2026-10-10" / "part.parquet").exists()
assert not bundle_data.exists() # 旧包内目录已清空
def test_migration_skips_when_new_location_has_data(tmp_path, monkeypatch):
"""新位置已有真实数据 → 绝不覆盖 (用户已在两套路径都跑过)。"""
new_dir = tmp_path / "newdata"
_make_real_data(new_dir)
(new_dir / "user_data" / "secrets.json").write_text('{"mine": 1}', encoding="utf-8")
bundle_data = tmp_path / "TSP.app" / "Contents" / "MacOS" / "data"
_make_real_data(bundle_data)
monkeypatch.setattr(sys, "frozen", True, raising=False)
monkeypatch.setattr(sys, "platform", "darwin")
monkeypatch.setattr(sys, "executable", str(bundle_data.parent / "TSP"))
_repo_with_data_dir(new_dir)._migrate_legacy_data_dir()
assert (new_dir / "user_data" / "secrets.json").read_text(encoding="utf-8") == '{"mine": 1}'
assert bundle_data.exists() # 旧数据原样保留
def test_migration_noop_without_legacy(tmp_path, monkeypatch):
new_dir = tmp_path / "newdata"
new_dir.mkdir()
monkeypatch.setattr(sys, "frozen", True, raising=False)
monkeypatch.setattr(sys, "platform", "darwin")
monkeypatch.setattr(sys, "executable", str(tmp_path / "TSP.app" / "Contents" / "MacOS" / "TSP"))
_repo_with_data_dir(new_dir)._migrate_legacy_data_dir()
assert list(new_dir.iterdir()) == []
def test_windows_sibling_legacy_still_migrates(tmp_path, monkeypatch):
"""既有行为回归: Windows 旧版兄弟目录 TickFlowStockPanel_Data 照常搬迁。"""
new_dir = tmp_path / "TSP" / "data"
new_dir.mkdir(parents=True)
legacy = tmp_path / "TSP" / "TickFlowStockPanel_Data"
_make_real_data(legacy)
monkeypatch.setattr(sys, "frozen", True, raising=False)
monkeypatch.setattr(sys, "platform", "win32")
monkeypatch.setattr(sys, "executable", str(tmp_path / "TSP" / "TSP.exe"))
_repo_with_data_dir(new_dir)._migrate_legacy_data_dir()
assert (new_dir / "user_data" / "secrets.json").exists()
assert not legacy.exists()
def test_migration_same_path_guard(tmp_path, monkeypatch):
"""防御: legacy 候选与当前 data_dir 同一路径时不动 (Windows 逻辑回归中的边界)。"""
data_dir = tmp_path / "TSP" / "data"
_make_real_data(data_dir)
monkeypatch.setattr(sys, "frozen", True, raising=False)
monkeypatch.setattr(sys, "platform", "win32")
monkeypatch.setattr(sys, "executable", str(data_dir / "TSP.exe"))
_repo_with_data_dir(data_dir)._migrate_legacy_data_dir()
# 数据原样, 无自搬自
assert (data_dir / "user_data" / "secrets.json").exists()
def test_dev_mode_never_migrates(tmp_path, monkeypatch):
"""非 frozen (开发/Docker) 直接返回, 即使存在旧目录也不动。"""
new_dir = tmp_path / "newdata"
new_dir.mkdir()
legacy = new_dir.parent / "TickFlowStockPanel_Data"
_make_real_data(legacy)
monkeypatch.delattr(sys, "frozen", raising=False)
_repo_with_data_dir(new_dir)._migrate_legacy_data_dir()
assert legacy.exists()
assert list(new_dir.iterdir()) == []