82 """Resolve the configured downloads directory on Linux, with a home-folder fallback."""
83 fallback = Path.home() /
"Downloads"
84 if not sys.platform.startswith(
"linux"):
87 home = os.environ.get(
"HOME")
or str(Path.home())
89 def expand_path(value: str) -> Path:
90 value = re.sub(
r"\$\{HOME\}|\$HOME(?=/|$)",
lambda _: home, value)
91 return Path(os.path.expandvars(value)).expanduser()
93 configured_directory = os.environ.get(XDG_DOWNLOAD_DIR_ENVIRONMENT_VARIABLE)
94 if configured_directory:
95 path = expand_path(configured_directory)
96 if path.is_absolute():
99 configured_home = os.environ.get(XDG_CONFIG_HOME_ENVIRONMENT_VARIABLE)
100 config_home = expand_path(configured_home)
if configured_home
else Path(home) /
".config"
101 if not config_home.is_absolute():
102 config_home = Path(home) /
".config"
104 user_directories_file = config_home / XDG_USER_DIRECTORIES_FILENAME
106 lines = user_directories_file.read_text(encoding=
"utf-8").splitlines()
111 match = re.match(
r"^\s*XDG_DOWNLOAD_DIR\s*=\s*(.*?)\s*$", line)
116 values = shlex.split(match.group(1), comments=
True, posix=
True)
123 path = expand_path(values[0])
124 if path.is_absolute():
180 """Identify the newest compatible set of downloaded packages.
183 downloads_directory: Folder containing package archives.
186 Selected version, ordered package paths, and ignored core versions.
189 UpdateError: No suitable core packages are available.
191 if not downloads_directory.is_dir():
192 raise UpdateError(f
"The downloads directory was not found: {downloads_directory}")
197 for path
in downloads_directory.iterdir()
198 if path.is_file()
and path.suffix.casefold() == PACKAGE_EXTENSION
200 key=
lambda path: path.name.casefold(),
203 core_packages: dict[tuple[str, str], Path] = {}
204 for package
in packages:
205 match = CORE_PACKAGE_PATTERN.fullmatch(package.name)
207 kind, version = match.groups()
208 core_packages[(kind.casefold(), version)] = package
210 if not core_packages:
212 "No core SDK packages were found. At least one matching "
213 "lite or mediation package is required."
216 versions = {version
for _, version
in core_packages}
217 version = max(versions, key=version_key)
218 lite = core_packages.get((
"lite", version))
219 mediation = core_packages.get((
"mediation", version))
220 mediation_suffix = f
"-mediation-{version}{PACKAGE_EXTENSION}".casefold()
221 mediation_adapters = sorted(
224 for package
in packages
225 if package.name.casefold().startswith(
226 (YANDEX_MOBILE_ADS_PACKAGE_PREFIX, MEDIATION_ADAPTER_PACKAGE_PREFIX)
228 and package.name.casefold().endswith(mediation_suffix)
229 and package != mediation
231 key=
lambda path: path.name.casefold(),
233 ordered = [lite]
if lite
is not None else []
234 ordered.extend(mediation_adapters)
235 if mediation
is not None:
236 ordered.append(mediation)
238 ignored = sorted(versions - {version}, key=version_key)
239 return version, ordered, ignored
478 """Temporarily activate the editor window of the current project on Windows.
481 project_name: Current Unity project directory name.
484 Whether the matching editor was activated.
487 from ctypes
import wintypes
489 user32 = ctypes.WinDLL(
"user32", use_last_error=
True)
490 kernel32 = ctypes.WinDLL(
"kernel32", use_last_error=
True)
491 user32.GetForegroundWindow.restype = wintypes.HWND
492 user32.GetWindowThreadProcessId.argtypes = (wintypes.HWND, ctypes.POINTER(wintypes.DWORD))
493 user32.GetWindowThreadProcessId.restype = wintypes.DWORD
494 user32.IsWindowVisible.argtypes = (wintypes.HWND,)
495 user32.IsIconic.argtypes = (wintypes.HWND,)
496 user32.GetWindowTextLengthW.argtypes = (wintypes.HWND,)
497 user32.GetWindowTextW.argtypes = (wintypes.HWND, wintypes.LPWSTR, ctypes.c_int)
498 user32.SetForegroundWindow.argtypes = (wintypes.HWND,)
499 user32.SetForegroundWindow.restype = wintypes.BOOL
500 user32.IsWindow.argtypes = (wintypes.HWND,)
501 user32.IsWindow.restype = wintypes.BOOL
502 user32.ShowWindowAsync.argtypes = (wintypes.HWND, ctypes.c_int)
503 user32.ShowWindowAsync.restype = wintypes.BOOL
504 kernel32.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
505 kernel32.OpenProcess.restype = wintypes.HANDLE
506 kernel32.QueryFullProcessImageNameW.argtypes = (
507 wintypes.HANDLE, wintypes.DWORD, wintypes.LPWSTR, ctypes.POINTER(wintypes.DWORD)
509 kernel32.CloseHandle.argtypes = (wintypes.HANDLE,)
511 def focus_window(handle: int) -> bool:
512 """Activate a window while observing Windows foreground restrictions.
515 handle: Native window handle.
518 Whether the requested window actually became the foreground window.
520 if not user32.IsWindow(handle):
522 if user32.IsIconic(handle):
523 user32.ShowWindowAsync(handle, WINDOW_RESTORE_COMMAND)
524 if user32.GetForegroundWindow() == handle:
527 user32.SetForegroundWindow(handle)
528 return wait_for_foreground(handle)
530 def wait_for_foreground(handle: int) -> bool:
531 """Wait briefly for asynchronous window activation to finish.
534 handle: Expected foreground window handle.
537 Whether the expected window became active before the deadline.
539 deadline = time.monotonic() + UNITY_FOCUS_VERIFY_TIMEOUT_SECONDS
540 while user32.GetForegroundWindow() != handle:
542 if time.monotonic() >= deadline:
544 time.sleep(UNITY_FOCUS_VERIFY_INTERVAL_SECONDS)
548 seen_processes = set()
549 callback_type = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
551 def collect_window(handle: int, parameter: int) -> bool:
552 """Collect a visible top-level window for each Unity Editor process.
555 handle: Native window handle.
556 parameter: Unused window enumeration argument.
559 Whether enumeration should continue.
561 if not user32.IsWindowVisible(handle)
or user32.IsIconic(handle):
563 title_length = user32.GetWindowTextLengthW(handle)
566 process_id = wintypes.DWORD()
567 user32.GetWindowThreadProcessId(handle, ctypes.byref(process_id))
568 if not process_id.value
or process_id.value
in seen_processes:
570 process = kernel32.OpenProcess(0x1000,
False, process_id.value)
574 executable = ctypes.create_unicode_buffer(4096)
575 length = wintypes.DWORD(len(executable))
576 if not kernel32.QueryFullProcessImageNameW(process, 0, executable, ctypes.byref(length)):
578 if executable.value.rsplit(
"\\", 1)[-1].casefold() !=
"unity.exe":
580 title = ctypes.create_unicode_buffer(title_length + 1)
581 user32.GetWindowTextW(handle, title, len(title))
582 seen_processes.add(process_id.value)
583 editors.append((handle, process_id.value, title.value.casefold()))
584 return len(editors) < UNITY_FOCUS_MAX_WINDOWS
586 kernel32.CloseHandle(process)
588 callback = callback_type(collect_window)
589 user32.EnumWindows.argtypes = (callback_type, wintypes.LPARAM)
590 user32.EnumWindows(callback, 0)
592 matches = [editor
for editor
in editors
if project_name.casefold()
in editor[2]]
593 if len(matches) == 1:
594 selected = matches[0]
595 elif len(editors) == 1:
596 selected = editors[0]
599 original = user32.GetForegroundWindow()
600 focused = selected
is not None and focus_window(selected[0])
604 if focused
and original
and original != selected[0]:
605 foreground = user32.GetForegroundWindow()
606 active_process_id = wintypes.DWORD()
607 user32.GetWindowThreadProcessId(foreground, ctypes.byref(active_process_id))
608 if active_process_id.value == selected[1]:
609 focus_window(original)
1088def run_update(bridge: EditorBridge, script_directory: Path, assets_directory: Path) ->
None:
1089 """Run the update sequence while preserving movable project assets.
1092 bridge: Editor integration client.
1093 script_directory: Directory containing the updater.
1094 assets_directory: Project assets root.
1097 UpdateError: A prerequisite fails or the editor reports an error.
1098 UserCancelled: The user or editor cancels the operation.
1100 print(
"Searching for downloaded SDK packages...", flush=
True)
1103 local_asset = script_directory / PRESERVED_DIRECTORY
1104 root_asset = assets_directory / PRESERVED_DIRECTORY
1106 print(
"Waiting for Unity Editor to respond...", flush=
True)
1107 bridge.execute(
"ping")
1108 print(
"Checking previous update state...", flush=
True)
1111 if path_exists(local_asset)
and (
not local_asset.is_dir()
or local_asset.is_symlink()):
1112 raise UpdateError(f
"The preserved asset path is not a regular directory: {local_asset}")
1113 if path_exists(root_asset)
and (
not root_asset.is_dir()
or root_asset.is_symlink()):
1114 raise UpdateError(f
"The root asset path is not a regular directory: {root_asset}")
1116 should_move = local_asset.is_dir()
1120 "Both preserved directory locations are occupied. "
1121 "No files were modified."
1124 raise UpdateError(f
"The metadata file is missing or invalid: {metadata_path(local_asset)}")
1126 print(f
"\nProject: {assets_directory.parent}")
1127 print(f
"Downloads directory: {downloads_directory}")
1128 print(f
"Selected version: {version}")
1129 if ignored_versions:
1130 print(f
"Other core versions skipped: {', '.join(ignored_versions)}")
1131 print(
"\nImport order:")
1132 for index, package
in enumerate(packages, start=1):
1133 print(f
" {index}. {package.name}")
1136 print(f
"\n{PRESERVED_DIRECTORY}: temporarily move to {ASSETS_DIRECTORY} using the editor.")
1137 elif root_asset.is_dir():
1138 print(f
"\n{PRESERVED_DIRECTORY}: already in {ASSETS_DIRECTORY}; no relocation needed.")
1140 print(f
"\n{PRESERVED_DIRECTORY}: absent; no relocation needed.")
1144 recovery_path = bridge.directory / RECOVERY_FILENAME
1146 restore_allowed =
True
1150 "source": str(local_asset),
1151 "destination": str(root_asset),
1155 print(
"\nPreserved directory temporarily moved by Unity Editor.")
1157 for index, package
in enumerate(packages, start=1):
1158 print(f
"\n[{index}/{len(packages)}] Importing: {package.name}")
1159 restore_allowed =
False
1161 bridge.execute(
"import", Path=str(package.resolve()))
1162 except (EditorOperationError, EditorOperationCancelled):
1163 restore_allowed =
True
1166 restore_allowed =
True
1167 print(f
"Completed: {package.name}")
1170 if moved
and restore_allowed:
1171 print(
"\nRestoring preserved directory using Unity Editor...")
1178 except (UpdateError, UserCancelled, KeyboardInterrupt)
as exc:
1179 print(f
"WARNING: Automatic asset restoration failed: {exc}")
1180 print(f
"Recovery information: {recovery_path}")
1183 recovery_path.unlink()
1184 print(
"Preserved directory restored successfully.")
1186 print(
"\nWARNING: The asset location could not be confirmed automatically.")
1187 print(f
"Check both locations: {local_asset} and {root_asset}")
1188 print(f
"Recovery information: {recovery_path}")
1190 print(
"\nAll selected packages have been imported successfully.")