207 lines
7.9 KiB
GDScript
Vendored
207 lines
7.9 KiB
GDScript
Vendored
@tool
|
|
class_name McpPathTemplate
|
|
extends RefCounted
|
|
|
|
## Expands ~ / $HOME / $APPDATA / $XDG_CONFIG_HOME / $LOCALAPPDATA / $USERPROFILE
|
|
## inside path templates so per-client descriptors can declare paths declaratively
|
|
## without hand-rolling per-OS lookups.
|
|
|
|
## #691: dock worker threads (client-status refresh, configure/remove
|
|
## actions) and the #678 startup walk's discovery worker expand these
|
|
## templates off the main thread, while the spawn step mutates the
|
|
## process-global environment around `OS.create_process`
|
|
## (`GODOT_AI_OWNER_PID`, `GODOT_AI_PLUGIN_SPAWNED`, `PYTHONPATH`,
|
|
## `GODOT_AI_DISABLE_TELEMETRY`). A glibc `getenv` racing a concurrent
|
|
## `setenv` can return a freed pointer — rare but process-fatal. All env
|
|
## reads in this layer therefore go through `env_lookup`: on the MAIN
|
|
## thread it reads live and refreshes a mutex-guarded snapshot; off the
|
|
## main thread it serves from the snapshot, so no `OS.get_environment`
|
|
## runs concurrently with the spawn window's mutations. Callers pre-warm
|
|
## every var their workers can touch via `warm_env_snapshot` (plugin
|
|
## `_enter_tree` and the dock's phase-1 refresh prep, both main-thread,
|
|
## both before any worker starts).
|
|
static var _env_snapshot := {}
|
|
static var _env_snapshot_mutex := Mutex.new()
|
|
|
|
## Every var this layer and its sibling consumers (`_base.gd`
|
|
## `config_file_override_details`, `config_home_override`, `_cli_finder.gd` lookups,
|
|
## `client_configurator.gd` mode/trace reads) can touch off-main.
|
|
## Descriptor-declared config-file/config-home env names are passed as extras
|
|
## by the warm callers.
|
|
const _BASE_ENV_VARS: Array[String] = [
|
|
"HOME",
|
|
"USERPROFILE",
|
|
"XDG_CONFIG_HOME",
|
|
"APPDATA",
|
|
"LOCALAPPDATA",
|
|
"SHELL",
|
|
"ProgramFiles",
|
|
"GODOT_AI_MODE",
|
|
"GODOT_AI_STARTUP_TRACE",
|
|
## #804 (#752 adoption): _find_venv_python reads this via env_lookup on
|
|
## the dock's worker path; without pre-warming, a set override reads as
|
|
## empty there and is silently ignored — the exact misconfiguration the
|
|
## push_warning in client_configurator.gd exists to surface.
|
|
"GODOT_AI_VENV_PYTHON",
|
|
]
|
|
|
|
|
|
## Thread-safe env read (#691). Main thread: live read + snapshot refresh.
|
|
## Worker thread: snapshot only, so it can never race a main-thread
|
|
## setenv/unsetenv. A worker read of a never-warmed var returns "" — the
|
|
## same value an unset var reads as — never a live OS.get_environment,
|
|
## which would reintroduce the race for exactly the vars nobody thought
|
|
## to warm. Missing warm-up degrades resolution; it must not touch the
|
|
## process-global environment off-main.
|
|
static func env_lookup(name: String) -> String:
|
|
if OS.get_thread_caller_id() == OS.get_main_thread_id():
|
|
var live := OS.get_environment(name)
|
|
_env_snapshot_mutex.lock()
|
|
_env_snapshot[name] = live
|
|
_env_snapshot_mutex.unlock()
|
|
return live
|
|
_env_snapshot_mutex.lock()
|
|
var cached: Variant = _env_snapshot.get(name, null)
|
|
_env_snapshot_mutex.unlock()
|
|
if cached != null:
|
|
return str(cached)
|
|
return ""
|
|
|
|
|
|
## Main-thread pre-warm so subsequent worker reads never touch the real
|
|
## environment. Idempotent; safe to call before every worker dispatch.
|
|
static func warm_env_snapshot(extra_vars: PackedStringArray = PackedStringArray()) -> void:
|
|
for var_name in _BASE_ENV_VARS:
|
|
env_lookup(var_name)
|
|
for var_name in extra_vars:
|
|
if not String(var_name).is_empty():
|
|
env_lookup(String(var_name))
|
|
|
|
|
|
## Pick the right entry from a {"darwin": ..., "windows": ..., "linux": ...} map.
|
|
static func resolve(template_map: Dictionary) -> String:
|
|
var key := platform_key(template_map)
|
|
if key.is_empty():
|
|
return ""
|
|
var template: String = template_map[key]
|
|
return expand(template)
|
|
|
|
|
|
## Return the platform-specific key present in a descriptor map. `unix` is a
|
|
## shorthand for macOS and Linux. Public so descriptors can use the same
|
|
## platform selection for ordered path-candidate arrays as for one path.
|
|
static func platform_key(template_map: Dictionary) -> String:
|
|
var key := _os_key()
|
|
if template_map.has(key):
|
|
return key
|
|
if (key == "darwin" or key == "linux") and template_map.has("unix"):
|
|
return "unix"
|
|
return ""
|
|
|
|
|
|
## Expand one path template into zero or more concrete paths. A single `*` is
|
|
## allowed inside one DIRECTORY segment (for example `Packages/Claude_*`). The
|
|
## wildcard is resolved by enumerating that segment's parent; the remaining
|
|
## suffix may name a file that does not exist yet, which lets callers derive a
|
|
## deterministic create target for a fresh packaged-app install.
|
|
##
|
|
## Multiple wildcards fail closed and return no candidates. A wildcard final
|
|
## segment may identify an installation directory for `detect_paths`. Returned
|
|
## paths are sorted for deterministic tests and diagnostics; callers still
|
|
## reject ambiguous config groups rather than picking one.
|
|
static func expand_path_candidates(template: String) -> PackedStringArray:
|
|
var expanded := expand(template)
|
|
if expanded.is_empty():
|
|
return PackedStringArray()
|
|
var star := expanded.find("*")
|
|
if star < 0:
|
|
return PackedStringArray([expanded])
|
|
if expanded.find("*", star + 1) >= 0:
|
|
return PackedStringArray()
|
|
|
|
var slash_before := maxi(expanded.rfind("/", star), expanded.rfind("\\", star))
|
|
var forward_after := expanded.find("/", star)
|
|
var backward_after := expanded.find("\\", star)
|
|
var slash_after := forward_after
|
|
if slash_after < 0 or (backward_after >= 0 and backward_after < slash_after):
|
|
slash_after = backward_after
|
|
if slash_before < 0:
|
|
return PackedStringArray()
|
|
|
|
var parent := expanded.substr(0, slash_before)
|
|
var pattern := (
|
|
expanded.substr(slash_before + 1)
|
|
if slash_after < 0
|
|
else expanded.substr(slash_before + 1, slash_after - slash_before - 1)
|
|
)
|
|
var suffix := "" if slash_after < 0 else expanded.substr(slash_after + 1)
|
|
var pattern_star := pattern.find("*")
|
|
if pattern_star < 0:
|
|
return PackedStringArray()
|
|
var prefix := pattern.substr(0, pattern_star)
|
|
var ending := pattern.substr(pattern_star + 1)
|
|
var dir := DirAccess.open(parent)
|
|
if dir == null:
|
|
return PackedStringArray()
|
|
|
|
var matches := PackedStringArray()
|
|
for child in dir.get_directories():
|
|
if _wildcard_segment_matches(String(child), prefix, ending):
|
|
var matched_path := parent.path_join(String(child))
|
|
matches.append(matched_path if suffix.is_empty() else matched_path.path_join(suffix))
|
|
matches.sort()
|
|
return matches
|
|
|
|
|
|
## Substitute env vars and ~ in a single template string.
|
|
static func expand(template: String) -> String:
|
|
if template.is_empty():
|
|
return ""
|
|
var out := template
|
|
if out.begins_with("~/") or out == "~":
|
|
var home := _home()
|
|
out = home if out == "~" else home.path_join(out.substr(2))
|
|
# $HOME, $APPDATA, $LOCALAPPDATA, $USERPROFILE, $XDG_CONFIG_HOME
|
|
for var_name in ["XDG_CONFIG_HOME", "LOCALAPPDATA", "USERPROFILE", "APPDATA", "HOME"]:
|
|
var token := "$%s" % var_name
|
|
if out.find(token) >= 0:
|
|
var value := env_lookup(var_name)
|
|
if value.is_empty() and var_name == "XDG_CONFIG_HOME":
|
|
value = _home().path_join(".config")
|
|
if value.is_empty() and var_name == "APPDATA":
|
|
value = _home().path_join("AppData/Roaming")
|
|
if value.is_empty() and var_name == "LOCALAPPDATA":
|
|
value = _home().path_join("AppData/Local")
|
|
if value.is_empty() and var_name == "HOME":
|
|
value = _home()
|
|
out = out.replace(token, value)
|
|
return out
|
|
|
|
|
|
static func _os_key() -> String:
|
|
match OS.get_name():
|
|
"macOS":
|
|
return "darwin"
|
|
"Windows":
|
|
return "windows"
|
|
_:
|
|
return "linux"
|
|
|
|
|
|
static func _wildcard_segment_matches(value: String, prefix: String, ending: String) -> bool:
|
|
# Prefix/suffix tests alone allow the two fixed portions to overlap inside a
|
|
# too-short value. Glob semantics require room for both portions even when
|
|
# `*` matches an empty string.
|
|
if value.length() < prefix.length() + ending.length():
|
|
return false
|
|
if OS.get_name() == "Windows":
|
|
return value.to_lower().begins_with(prefix.to_lower()) and value.to_lower().ends_with(ending.to_lower())
|
|
return value.begins_with(prefix) and value.ends_with(ending)
|
|
|
|
|
|
static func _home() -> String:
|
|
var h := env_lookup("HOME")
|
|
if h.is_empty():
|
|
h = env_lookup("USERPROFILE")
|
|
return h
|