Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion jetbrains
Submodule jetbrains updated 22 files
+8 −5 .github/workflows/ci.yml
+7 −1 README.en.md
+6 −1 README.md
+13 −12 build.gradle.kts
+150 −0 src/main/kotlin/com/alicejump/okscripttoolkit/core/AccountStoreService.kt
+17 −10 src/main/kotlin/com/alicejump/okscripttoolkit/core/ProjectDirResolution.kt
+1 −0 src/main/kotlin/com/alicejump/okscripttoolkit/core/PythonScriptRunner.kt
+2 −2 src/main/kotlin/com/alicejump/okscripttoolkit/core/RunDir.kt
+9 −7 src/main/kotlin/com/alicejump/okscripttoolkit/core/ScreenshotCapture.kt
+364 −0 src/main/kotlin/com/alicejump/okscripttoolkit/tasklauncher/AccountEditorDialog.kt
+177 −0 src/main/kotlin/com/alicejump/okscripttoolkit/tasklauncher/GlobalConfigEditor.kt
+4 −4 src/main/kotlin/com/alicejump/okscripttoolkit/tasklauncher/GlobalSnapshotRules.kt
+59 −2 src/main/kotlin/com/alicejump/okscripttoolkit/tasklauncher/TaskLauncherService.kt
+99 −33 src/main/kotlin/com/alicejump/okscripttoolkit/tasklauncher/TaskLauncherToolWindowFactory.kt
+23 −8 src/main/kotlin/com/alicejump/okscripttoolkit/ui/TemplateAssetToolWindowFactory.kt
+30 −0 src/main/resources/messages/OkScriptToolkitBundle.properties
+30 −0 src/main/resources/messages/OkScriptToolkitBundle_es.properties
+30 −0 src/main/resources/messages/OkScriptToolkitBundle_ja.properties
+30 −0 src/main/resources/messages/OkScriptToolkitBundle_ko.properties
+30 −0 src/main/resources/messages/OkScriptToolkitBundle_zh_CN.properties
+30 −0 src/main/resources/messages/OkScriptToolkitBundle_zh_TW.properties
+7 −19 src/test/kotlin/com/alicejump/okscripttoolkit/core/ProjectDirResolutionTest.kt
76 changes: 74 additions & 2 deletions python/account_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@

存储位置与执行器一致:传 --run-dir 时 get_relative_path("configs") 改道沙箱
(与 run_executor 的 install_config_path_patch 同手法,store 在导入期固定路径,
patch 必须先于 store import)。不传 --run-dir 则读写项目 configs(仅诊断用途)。
patch 必须先于 store import)。沙箱文件缺失时,先从项目声明的 config_folder
复制一次账号文件;后续编辑均以沙箱为准。不传 --run-dir 则读写项目文件(仅诊断用途)。

用法:
python account_store.py <project_dir> get --run-dir <run_dir>
Expand All @@ -18,10 +19,13 @@
输出(最后一行 JSON):{"ok": true, ...} / {"ok": false, "error": "..."}
"""
import argparse
import ast
import importlib
import json
import os
import shutil
import sys
import tempfile

sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
Expand All @@ -32,6 +36,73 @@
"src.tasks.account_scope_store",
)

ACCOUNT_FILE = "account_scoped_overrides.json"


def detect_config_folder(project_dir: str) -> str:
"""导入项目之前读取常量 config_folder;与 probe_task_schemas 的规则一致。"""
for candidate in (
os.path.join(project_dir, "src", "config.py"),
os.path.join(project_dir, "config.py"),
):
try:
with open(candidate, encoding="utf-8") as stream:
tree = ast.parse(stream.read(), filename=candidate)
except (OSError, SyntaxError):
continue
for node in ast.walk(tree):
if not isinstance(node, ast.Dict):
continue
for key, value in zip(node.keys, node.values):
if (
isinstance(key, ast.Constant)
and key.value == "config_folder"
and isinstance(value, ast.Constant)
and isinstance(value.value, str)
):
return value.value
return "configs"


def initialize_sandbox_account_file(project_dir: str, run_dir: str) -> None:
"""首次编辑时导入项目账号文件,不覆盖已有沙箱文件。"""
source_folder = detect_config_folder(project_dir)
source_configs = (
source_folder if os.path.isabs(source_folder)
else os.path.join(project_dir, source_folder)
)
source = os.path.join(source_configs, ACCOUNT_FILE)
target_dir = os.path.join(os.path.abspath(run_dir), "configs")
target = os.path.join(target_dir, ACCOUNT_FILE)
if not os.path.isfile(source) or os.path.lexists(target):
return
os.makedirs(target_dir, exist_ok=True)
# 先在目标目录写完整临时文件,再用硬链接的「目标必须不存在」语义发布。
# 避免和正在运行的执行器/另一宿主同时初始化时相互覆盖或暴露半写入文件。
fd, temporary = tempfile.mkstemp(prefix=".account-store-", suffix=".tmp", dir=target_dir)
os.close(fd)
try:
shutil.copyfile(source, temporary)
try:
os.link(temporary, target)
except FileExistsError:
pass
except OSError:
# 某些文件系统不支持硬链接;仍用排他创建防止覆盖已有编辑。
created = False
try:
with open(temporary, "rb") as input_file, open(target, "xb") as output_file:
created = True
shutil.copyfileobj(input_file, output_file)
except FileExistsError:
pass
except Exception:
if created:
os.unlink(target)
raise
finally:
os.unlink(temporary)


def apply_sandbox_redirect(run_dir: str) -> None:
"""把 get_relative_path("configs", ...) 改道沙箱(必须在 store import 前调用)。
Expand Down Expand Up @@ -83,9 +154,10 @@ def main() -> None:
kwargs[extra[i].lstrip("-")] = extra[i + 1]
try:
# patch 先于 store import:store 在模块导入期就用 get_relative_path 固定路径
project_dir = args.project_dir
project_dir = os.path.abspath(args.project_dir)
sys.path.insert(0, project_dir)
if kwargs.get("run-dir"):
initialize_sandbox_account_file(project_dir, kwargs["run-dir"])
apply_sandbox_redirect(kwargs["run-dir"])
store = load_store_module(project_dir)
if args.command == "get":
Expand Down
55 changes: 30 additions & 25 deletions python/probe_task_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -573,17 +573,17 @@ def resolve_run_dir(project_dir):
def collect_multi_account(project_dir, tasks, broken, global_groups):
"""探测多账户存储,返回只读概要与「打开数据位置」的路径。

存储位置与执行器一致:沙箱(`<run_dir>/<config_folder>/`)优先——执行器
与插件的账号编辑都落沙箱;项目侧文件仅作首次探测回退(执行器启动
copytree 会把它带进沙箱)。storePath 一律报沙箱路径。
存储位置与执行器一致:沙箱 `<run_dir>/configs/` 优先——执行器
与插件的账号编辑都落沙箱;项目侧源文件按声明的 config_folder 查找,
首次启动或编辑时复制到沙箱。storePath 一律报实际沙箱路径。

⚠️ 沙箱根目录**按宿主解析**(见 `resolve_run_dir`):曾写死
`.vscode/ok-script-toolkit`,JetBrains 宿主会拿到一个根本不存在、也永远不会被
读写的路径(它的沙箱是 `.idea/ok-script-toolkit`)。
"""
config_folder = detect_config_folder(project_dir)
sandbox_path = os.path.join(
resolve_run_dir(project_dir), config_folder, "account_scoped_overrides.json"
resolve_run_dir(project_dir), "configs", "account_scoped_overrides.json"
)
project_path = os.path.join(project_dir, config_folder, "account_scoped_overrides.json")
# store 模块可 import 性:区分「项目不支持账号编辑」与「读取失败(环境问题)」
Expand All @@ -600,9 +600,10 @@ def collect_multi_account(project_dir, tasks, broken, global_groups):
"available": has_data_file,
"storePath": sandbox_path,
"hasStoreModule": has_store_module,
"accountCount": 0,
"overrideAccounts": 0,
"overriddenTasks": [],
}
if not has_data_file:
return info
store_data = {}
enabled_tasks = {}
rules_module = None
Expand Down Expand Up @@ -657,30 +658,34 @@ def task_enabled_keys(task):
except Exception as e: # noqa: BLE001
broken.append({"task": f"<multi-account:{task_key}>", "error": f"{type(e).__name__}: {e}"})
info["enabledTasks"] = enabled_tasks
data_path = sandbox_path if os.path.isfile(sandbox_path) else project_path
try:
with open(data_path, encoding="utf-8") as fp:
data = json.load(fp)
store_data = data
if isinstance(data, dict):
registry = data.get("account_registry")
accounts = data.get("accounts")
info["accountCount"] = len(registry) if isinstance(registry, dict) else 0
info["overrideAccounts"] = len(accounts) if isinstance(accounts, dict) else 0
if isinstance(accounts, dict):
tasks = set()
for value in accounts.values():
if isinstance(value, dict):
tasks.update(value.keys())
info["overriddenTasks"] = sorted(str(item) for item in tasks)
except (OSError, ValueError):
info["readable"] = False
if has_data_file:
data_path = sandbox_path if os.path.isfile(sandbox_path) else project_path
try:
with open(data_path, encoding="utf-8") as fp:
data = json.load(fp)
if isinstance(data, dict):
store_data = data
registry = data.get("account_registry")
accounts = data.get("accounts")
info["accountCount"] = len(registry) if isinstance(registry, dict) else 0
info["overrideAccounts"] = len(accounts) if isinstance(accounts, dict) else 0
if isinstance(accounts, dict):
stored_tasks = set()
for value in accounts.values():
if isinstance(value, dict):
stored_tasks.update(value.keys())
info["overriddenTasks"] = sorted(str(item) for item in stored_tasks)
else:
info["readable"] = False
except (OSError, ValueError):
info["readable"] = False

# 全局配置组的按账号覆盖(ok-end-field 滑索/键位 Proxy 模式:组覆盖存在
# accounts[acc_id][组名])——收录条件:组名在已知 Proxy 列表 或 存储里出现过
# 该组名的覆盖数据(项目 GUI 是覆盖创建入口,probe 不凭空猜测没出现过的组)
stored_names = set()
for account_tasks in (store_data or {}).get("accounts", {}).values():
stored_accounts = store_data.get("accounts")
for account_tasks in (stored_accounts if isinstance(stored_accounts, dict) else {}).values():
if isinstance(account_tasks, dict):
stored_names |= set(account_tasks.keys())
for group in global_groups or []:
Expand Down
63 changes: 57 additions & 6 deletions python/run_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,11 @@
配置沙箱:调试插件绝不允许改动目标项目的 `configs/`。宿主经
`OK_TOOLKIT_RUN_DIR` 传入沙箱根目录(如 `<workspace>/.vscode/ok-script-toolkit`),
`config['config_folder']` 与 `config['screenshots_folder']` 一并改道,ok 框架的读写
全部落在沙箱内。沙箱初始化时把项目 configs/ 整目录拷进来 —— 任务读到的是
**项目当前配置 + 插件参数覆盖**;执行器的写入只落沙箱,项目侧文件保持原样。
全部落在沙箱内。沙箱初始化时把项目配置目录拷进来 —— 任务读到的是
**项目当前配置 + 插件参数覆盖**;账号覆盖文件在首次拷入后保留沙箱编辑。
执行器的写入只落沙箱,项目侧文件保持原样。

`devices.json` 是唯一例外:工具箱的 connect_game.py 把连接结果写在
`devices.json` 是连接信息的特例:工具箱的 connect_game.py 把连接结果写在
`<项目>/configs/devices.json`(`folder=` 硬指定,不受 config_folder 影响),
而执行器需要读到同一个窗口。故启动时把它**拷进沙箱**做桥接;此后执行器对它的
写入只落沙箱,项目侧文件保持原样。
Expand All @@ -80,6 +81,7 @@
import os
import shutil
import sys
import tempfile
import threading
import time

Expand Down Expand Up @@ -129,6 +131,36 @@ def task_key(task) -> str:

# ── 配置沙箱 ──────────────────────────────────────────────────────────

def copy_account_file_once(source: str, target: str) -> None:
"""以完整文件发布项目账号初值;已有沙箱编辑绝不覆盖。"""
if not os.path.isfile(source) or os.path.lexists(target):
return
fd, temporary = tempfile.mkstemp(
prefix=".account-store-", suffix=".tmp", dir=os.path.dirname(target)
)
os.close(fd)
try:
shutil.copyfile(source, temporary)
try:
os.link(temporary, target)
except FileExistsError:
pass
except OSError:
# 不支持硬链接的文件系统退回排他创建,仍不会覆盖沙箱编辑。
created = False
try:
with open(temporary, "rb") as input_file, open(target, "xb") as output_file:
created = True
shutil.copyfileobj(input_file, output_file)
except FileExistsError:
pass
except Exception:
if created:
os.unlink(target)
raise
finally:
os.unlink(temporary)

def apply_config_sandbox(config: dict) -> str:
"""把 ok 框架的配置读写改道到沙箱目录,避免污染目标项目的 configs/。

Expand Down Expand Up @@ -162,8 +194,9 @@ def apply_config_sandbox(config: dict) -> str:
# connect_game.py 写的项目侧文件对执行器可见)。不拷的话任务读到的全是默认值,
# 与插件 UI(probe 会拷项目配置读「当前值」)和项目自身 GUI 完全脱钩 ——
# 实测被报告为「执行器配置跟插件的配置不相关联」。
# copytree(dirs_exist_ok=True) 对同名文件整体覆盖:沙箱视图每次启动都从项目
# 配置重建,上一次运行留在沙箱里的状态不参与。
# copytree(dirs_exist_ok=True) 对普通同名文件整体覆盖:沙箱视图每次启动都从
# 项目配置重建。账号覆盖由插件编辑、执行器读取;已有沙箱文件必须保留,
# 否则每次启动都会把用户编辑覆盖成项目里的旧文件。
# 注意必须**先**读项目原值再覆盖 config["config_folder"] —— 覆盖之后 config
# 里已经是沙箱路径,再读就拷了个寂寞(首次实现就栽在这里,被测试 4 抓住)。
source_folder = str(config.get("config_folder") or "configs")
Expand All @@ -179,7 +212,25 @@ def apply_config_sandbox(config: dict) -> str:

if os.path.isdir(source_configs):
try:
shutil.copytree(source_configs, config_folder, dirs_exist_ok=True)
account_file = "account_scoped_overrides.json"
sandbox_account_file = os.path.join(config_folder, account_file)

def defer_account_file(source_dir, names):
if (
os.path.normcase(os.path.abspath(source_dir))
== os.path.normcase(os.path.abspath(source_configs))
and account_file in names
):
return {account_file}
return set()

shutil.copytree(
source_configs, config_folder, dirs_exist_ok=True,
ignore=defer_account_file,
)
copy_account_file_once(
os.path.join(source_configs, account_file), sandbox_account_file,
)
except OSError as e: # noqa: BLE001 — 拷贝失败退回「默认值」语义,绝不阻断启动
_note(f"项目 configs 拷入沙箱失败(任务将读到默认值):{e}")

Expand Down
6 changes: 3 additions & 3 deletions python/tests/test_probe_multi_account_path.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ def capture_chdir(path):
check(".vscode" not in info["storePath"],
"JetBrains 宿主的 storePath 不应再出现 .vscode")

print("\n[6] 自定义 config_folder 同时用于项目回退与沙箱 storePath")
print("\n[6] 自定义 config_folder 用于项目回退,沙箱仍使用 configs")
with tempfile.TemporaryDirectory() as proj:
set_run_dir(os.path.join(proj, ".idea", "ok-script-toolkit"))
os.makedirs(os.path.join(proj, "src"), exist_ok=True)
Expand All @@ -148,8 +148,8 @@ def capture_chdir(path):
info = probe.collect_multi_account(proj, [], [], [])
check(info["available"] is True, "自定义目录中的项目侧文件应使 available=True")
check(info["storePath"] == os.path.join(
proj, ".idea", "ok-script-toolkit", "custom-configs", "account_scoped_overrides.json"),
f"storePath 应使用自定义配置目录,实际 {info['storePath']!r}")
proj, ".idea", "ok-script-toolkit", "configs", "account_scoped_overrides.json"),
f"storePath 应使用沙箱 configs 目录,实际 {info['storePath']!r}")
check(info.get("accountCount") == 1,
f"应从项目侧文件读出账号数 1,实际 {info.get('accountCount')!r}")

Expand Down
Loading