mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
fix(desktop): macOS 数据目录移出 .app 包 — 覆盖安装不再清空用户数据 + 包内旧数据自动迁移
This commit is contained in:
@@ -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 回测(为控制体积)
|
||||
- 系统通知: 设置 → 实时监控 → 系统通知
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()) == []
|
||||
Reference in New Issue
Block a user