Space Ad Mobile 9.5.1
Space Ad Mobile makes ads easy to add into your mobile game: one unified workflow for LevelPlay, AdMob, Yandex.Ads across banners, interstitials and rewarded.
Loading...
Searching...
No Matches
update_yandex_sdk.py
1"""Coordinate package imports with the active Unity Editor instance."""
2
3from __future__ import annotations
4
5import json
6import os
7import re
8import shlex
9import shutil
10import subprocess
11import sys
12import time
13import uuid
14from contextlib import contextmanager, nullcontext
15from pathlib import Path
16from typing import Iterator
17
18
19ASSETS_DIRECTORY = "Assets"
20TARGET_DIRECTORY = "YandexMobileAds"
21PRESERVED_DIRECTORY = "AdRevenue"
22PACKAGE_EXTENSION = ".unitypackage"
23YANDEX_MOBILE_ADS_PACKAGE_PREFIX = "yandex-mobileads-"
24MEDIATION_ADAPTER_PACKAGE_PREFIX = "mobileads-"
25XDG_CONFIG_HOME_ENVIRONMENT_VARIABLE = "XDG_CONFIG_HOME"
26XDG_DOWNLOAD_DIR_ENVIRONMENT_VARIABLE = "XDG_DOWNLOAD_DIR"
27XDG_USER_DIRECTORIES_FILENAME = "user-dirs.dirs"
28BRIDGE_DIRECTORY = "YandexSdkUpdater"
29REQUEST_FILENAME = "request.json"
30RESULT_FILENAME = "result.json"
31RECOVERY_FILENAME = "recovery.json"
32LOCK_FILENAME = "session.lock"
33ACTIVE_SESSION_FILENAME = "active-session.json"
34STARTUP_ATTEMPT_ENVIRONMENT_VARIABLE = "YANDEX_SDK_UPDATER_STARTUP_ATTEMPT"
35MANUAL_IMPORT_COMPLETION_FIELD = "ManualImportCompletionConfirmed"
36CORE_PACKAGE_PATTERN = re.compile(
37 rf"^{re.escape(YANDEX_MOBILE_ADS_PACKAGE_PREFIX)}"
38 r"(lite|mediation)-(\d+(?:\.\d+)+)\.unitypackage$",
39 re.IGNORECASE,
40)
41CANCEL_COMMANDS = frozenset({"q", "quit", "n", "no", "cancel"})
42POLL_INTERVAL_SECONDS = 0.35
43EDITOR_RESPONSE_TIMEOUT_SECONDS = 60
44EDITOR_BUSY_RETRY_SECONDS = 60
45EDITOR_NOT_READY_ERROR = "The editor reported an error: The editor is not ready to process the request."
46EDITOR_WAIT_WARNING_SECONDS = 30
47EDITOR_STATUS_INTERVAL_SECONDS = 10
48UNITY_BACKGROUND_RESPONSE_GRACE_SECONDS = 3.0
49UNITY_FOCUS_DELAY_SECONDS = 0.65
50UNITY_FOCUS_VERIFY_TIMEOUT_SECONDS = 1.0
51UNITY_FOCUS_VERIFY_INTERVAL_SECONDS = 0.05
52WINDOW_RESTORE_COMMAND = 9
53UNITY_FOCUS_MAX_WINDOWS = 16
54ESCAPE_KEY = "\x1b"
55
56
57class UpdateError(Exception):
58 """Represent an operation that cannot proceed safely."""
59
60
61class UserCancelled(Exception):
62 """Represent an explicit request to cancel the operation."""
63
64
66 """Represent a request to exit immediately using Escape."""
67
68
70 """Represent a definitively failed editor operation."""
71
72
74 """Represent an operation definitively cancelled by the editor."""
75
76
78 """Represent an editor import whose completion must be verified manually."""
79
80
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"):
85 return fallback
86
87 home = os.environ.get("HOME") or str(Path.home())
88
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()
92
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():
97 return path
98
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"
103
104 user_directories_file = config_home / XDG_USER_DIRECTORIES_FILENAME
105 try:
106 lines = user_directories_file.read_text(encoding="utf-8").splitlines()
107 except OSError:
108 return fallback
109
110 for line in lines:
111 match = re.match(r"^\s*XDG_DOWNLOAD_DIR\s*=\s*(.*?)\s*$", line)
112 if not match:
113 continue
114
115 try:
116 values = shlex.split(match.group(1), comments=True, posix=True)
117 except ValueError:
118 continue
119
120 if len(values) != 1:
121 continue
122
123 path = expand_path(values[0])
124 if path.is_absolute():
125 return path
126
127 return fallback
128
129
130def find_assets_directory(script_directory: Path) -> Path:
131 """Find the Unity assets root enclosing the script.
132
133 Args:
134 script_directory: Directory containing the running script.
135
136 Returns:
137 The nearest matching assets directory.
138
139 Raises:
140 UpdateError: The script is outside an assets directory.
141 """
142 for directory in (script_directory, *script_directory.parents):
143 if directory.name.casefold() == ASSETS_DIRECTORY.casefold():
144 return directory
145
146 raise UpdateError(
147 f"The script is not inside an {ASSETS_DIRECTORY} directory. "
148 "Move it into the Unity project."
149 )
150
151
152def validate_script_directory(script_directory: Path) -> None:
153 """Enforce the intended script location.
154
155 Args:
156 script_directory: Directory containing the running script.
157
158 Raises:
159 UpdateError: The script is outside the required directory.
160 """
161 if script_directory.name.casefold() != TARGET_DIRECTORY.casefold():
162 raise UpdateError(
163 f"Run this script from a {TARGET_DIRECTORY} directory within {ASSETS_DIRECTORY}."
164 )
165
166
167def version_key(version: str) -> tuple[int, ...]:
168 """Convert a dotted version into a sortable numeric tuple.
169
170 Args:
171 version: Dotted version number.
172
173 Returns:
174 Parsed version components.
175 """
176 return tuple(int(part) for part in version.split("."))
177
178
179def discover_packages(downloads_directory: Path) -> tuple[str, list[Path], list[str]]:
180 """Identify the newest compatible set of downloaded packages.
181
182 Args:
183 downloads_directory: Folder containing package archives.
184
185 Returns:
186 Selected version, ordered package paths, and ignored core versions.
187
188 Raises:
189 UpdateError: No suitable core packages are available.
190 """
191 if not downloads_directory.is_dir():
192 raise UpdateError(f"The downloads directory was not found: {downloads_directory}")
193
194 packages = sorted(
195 (
196 path
197 for path in downloads_directory.iterdir()
198 if path.is_file() and path.suffix.casefold() == PACKAGE_EXTENSION
199 ),
200 key=lambda path: path.name.casefold(),
201 )
202
203 core_packages: dict[tuple[str, str], Path] = {}
204 for package in packages:
205 match = CORE_PACKAGE_PATTERN.fullmatch(package.name)
206 if match:
207 kind, version = match.groups()
208 core_packages[(kind.casefold(), version)] = package
209
210 if not core_packages:
211 raise UpdateError(
212 "No core SDK packages were found. At least one matching "
213 "lite or mediation package is required."
214 )
215
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(
222 (
223 package
224 for package in packages
225 if package.name.casefold().startswith(
226 (YANDEX_MOBILE_ADS_PACKAGE_PREFIX, MEDIATION_ADAPTER_PACKAGE_PREFIX)
227 )
228 and package.name.casefold().endswith(mediation_suffix)
229 and package != mediation
230 ),
231 key=lambda path: path.name.casefold(),
232 )
233 ordered = [lite] if lite is not None else []
234 ordered.extend(mediation_adapters)
235 if mediation is not None:
236 ordered.append(mediation)
237
238 ignored = sorted(versions - {version}, key=version_key)
239 return version, ordered, ignored
240
241
242def metadata_path(asset_path: Path) -> Path:
243 """Get the companion metadata path for an asset.
244
245 Args:
246 asset_path: Asset directory or file path.
247
248 Returns:
249 The expected companion metadata file path.
250 """
251 return asset_path.with_name(f"{asset_path.name}.meta")
252
253
254def path_exists(path: Path) -> bool:
255 """Check whether a path is occupied, including dangling links.
256
257 Args:
258 path: Path to inspect.
259
260 Returns:
261 Whether the path is present or is a symbolic link.
262 """
263 return path.exists() or path.is_symlink()
264
265
266def check_escape() -> None:
267 """Cancel immediately when Escape is pressed in a Windows console.
268
269 Raises:
270 EscapeCancelled: Escape was pressed.
271 """
272 if sys.platform != "win32":
273 return
274
275 import msvcrt
276
277 while msvcrt.kbhit():
278 key = msvcrt.getwch()
279 if key == ESCAPE_KEY:
280 raise EscapeCancelled("The operation was cancelled by the user (Esc).")
281 if key in ("\x00", "\xe0"):
282 msvcrt.getwch()
283
284
286 """Read a pending Enter key without blocking the editor response wait.
287
288 Returns:
289 Whether Enter was pressed in the interactive console.
290
291 Raises:
292 EscapeCancelled: Escape was pressed on Windows.
293 """
294 if not sys.stdin.isatty():
295 return False
296
297 if sys.platform == "win32":
298 import msvcrt
299
300 enter_pressed = False
301 while msvcrt.kbhit():
302 key = msvcrt.getwch()
303 if key == ESCAPE_KEY:
304 print()
305 raise EscapeCancelled("The operation was cancelled by the user (Esc).")
306 if key in ("\x00", "\xe0"):
307 msvcrt.getwch()
308 elif key in ("\r", "\n"):
309 print()
310 enter_pressed = True
311 return enter_pressed
312
313 import select
314
315 readable, _, _ = select.select([sys.stdin], [], [], 0)
316 if not readable:
317 return False
318
319 entered_text = sys.stdin.readline()
320 return bool(entered_text)
321
322
323def read_console_input(message: str) -> str:
324 """Read a console response while allowing Escape without Enter.
325
326 Args:
327 message: Prompt to display.
328
329 Returns:
330 Entered text.
331
332 Raises:
333 EscapeCancelled: Escape was pressed.
334 """
335 if sys.platform != "win32":
336 return input(message)
337
338 import msvcrt
339
340 print(message, end="", flush=True)
341 characters = []
342 while True:
343 key = msvcrt.getwch()
344 if key == ESCAPE_KEY:
345 print()
346 raise EscapeCancelled("The operation was cancelled by the user (Esc).")
347 if key in ("\r", "\n"):
348 print()
349 return "".join(characters)
350 if key == "\b":
351 if characters:
352 characters.pop()
353 print("\b \b", end="", flush=True)
354 elif key in ("\x00", "\xe0"):
355 msvcrt.getwch()
356 elif key.isprintable():
357 characters.append(key)
358 print(key, end="", flush=True)
359
360
361def ask_to_continue(message: str) -> None:
362 """Wait for confirmation, allowing cancellation by command or Escape.
363
364 Args:
365 message: Prompt to display.
366
367 Raises:
368 UserCancelled: The user entered a cancellation command.
369 """
370 if read_console_input(message).strip().casefold() in CANCEL_COMMANDS:
371 raise UserCancelled("The operation was cancelled by the user.")
372
373
374def atomic_write_json(path: Path, content: dict[str, object]) -> None:
375 """Replace a small JSON document without exposing partial writes.
376
377 Args:
378 path: Destination path.
379 content: Data to serialize.
380 """
381 temporary = path.with_name(f"{path.name}.{uuid.uuid4().hex}.tmp")
382 try:
383 temporary.write_text(json.dumps(content, ensure_ascii=True), encoding="utf-8")
384 os.replace(temporary, path)
385 finally:
386 try:
387 temporary.unlink()
388 except FileNotFoundError:
389 pass
390
391
392def read_json(path: Path) -> dict[str, object] | None:
393 """Read a state document, tolerating transient file replacement.
394
395 Args:
396 path: JSON document path.
397
398 Returns:
399 Parsed document, or None when unavailable or incomplete.
400 """
401 try:
402 content = json.loads(path.read_text(encoding="utf-8"))
403 return content if isinstance(content, dict) else None
404 except (FileNotFoundError, UnicodeDecodeError, json.JSONDecodeError):
405 return None
406
407
408@contextmanager
409def exclusive_session(lock_path: Path) -> Iterator[None]:
410 """Prevent multiple processes from controlling one project simultaneously.
411
412 Args:
413 lock_path: Path for an operating-system-managed lock.
414
415 Yields:
416 Control after an exclusive lock is acquired.
417
418 Raises:
419 UpdateError: Another process already owns the lock.
420 """
421 with lock_path.open("a+b") as lock_file:
422 if sys.platform == "win32":
423 import msvcrt
424
425 lock_file.seek(0)
426 if lock_file.read(1) == b"":
427 lock_file.seek(0)
428 lock_file.write(b"0")
429 lock_file.flush()
430 lock_file.seek(0)
431 try:
432 msvcrt.locking(lock_file.fileno(), msvcrt.LK_NBLCK, 1)
433 except OSError as exc:
434 raise UpdateError("Another updater process is already running.") from exc
435 try:
436 yield
437 finally:
438 lock_file.seek(0)
439 msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
440 else:
441 import fcntl
442
443 try:
444 fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
445 except OSError as exc:
446 raise UpdateError("Another updater process is already running.") from exc
447 try:
448 yield
449 finally:
450 fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
451
452
453@contextmanager
454def focus_unity_editor(project_name: str) -> Iterator[bool]:
455 """Focus the matching Unity Editor until the operation is resolved.
456
457 Args:
458 project_name: Current Unity project directory name.
459
460 Yields:
461 Whether the editor was successfully focused.
462 """
463 if sys.platform == "win32":
464 with focus_unity_editor_windows(project_name) as focused:
465 yield focused
466 elif sys.platform == "darwin":
467 with activate_unity_windows_macos(project_name) as focused:
468 yield focused
469 elif sys.platform.startswith("linux"):
470 with activate_unity_windows_linux(project_name) as focused:
471 yield focused
472 else:
473 yield False
474
475
476@contextmanager
477def focus_unity_editor_windows(project_name: str) -> Iterator[bool]:
478 """Temporarily activate the editor window of the current project on Windows.
479
480 Args:
481 project_name: Current Unity project directory name.
482
483 Yields:
484 Whether the matching editor was activated.
485 """
486 import ctypes
487 from ctypes import wintypes
488
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)
508 )
509 kernel32.CloseHandle.argtypes = (wintypes.HANDLE,)
510
511 def focus_window(handle: int) -> bool:
512 """Activate a window while observing Windows foreground restrictions.
513
514 Args:
515 handle: Native window handle.
516
517 Returns:
518 Whether the requested window actually became the foreground window.
519 """
520 if not user32.IsWindow(handle):
521 return False
522 if user32.IsIconic(handle):
523 user32.ShowWindowAsync(handle, WINDOW_RESTORE_COMMAND)
524 if user32.GetForegroundWindow() == handle:
525 return True
526 # Keep input queues separate so an unresponsive target cannot block activation.
527 user32.SetForegroundWindow(handle)
528 return wait_for_foreground(handle)
529
530 def wait_for_foreground(handle: int) -> bool:
531 """Wait briefly for asynchronous window activation to finish.
532
533 Args:
534 handle: Expected foreground window handle.
535
536 Returns:
537 Whether the expected window became active before the deadline.
538 """
539 deadline = time.monotonic() + UNITY_FOCUS_VERIFY_TIMEOUT_SECONDS
540 while user32.GetForegroundWindow() != handle:
542 if time.monotonic() >= deadline:
543 return False
544 time.sleep(UNITY_FOCUS_VERIFY_INTERVAL_SECONDS)
545 return True
546
547 editors = []
548 seen_processes = set()
549 callback_type = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
550
551 def collect_window(handle: int, parameter: int) -> bool:
552 """Collect a visible top-level window for each Unity Editor process.
553
554 Args:
555 handle: Native window handle.
556 parameter: Unused window enumeration argument.
557
558 Returns:
559 Whether enumeration should continue.
560 """
561 if not user32.IsWindowVisible(handle) or user32.IsIconic(handle):
562 return True
563 title_length = user32.GetWindowTextLengthW(handle)
564 if not title_length:
565 return True
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:
569 return True
570 process = kernel32.OpenProcess(0x1000, False, process_id.value)
571 if not process:
572 return True
573 try:
574 executable = ctypes.create_unicode_buffer(4096)
575 length = wintypes.DWORD(len(executable))
576 if not kernel32.QueryFullProcessImageNameW(process, 0, executable, ctypes.byref(length)):
577 return True
578 if executable.value.rsplit("\\", 1)[-1].casefold() != "unity.exe":
579 return True
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
585 finally:
586 kernel32.CloseHandle(process)
587
588 callback = callback_type(collect_window)
589 user32.EnumWindows.argtypes = (callback_type, wintypes.LPARAM)
590 user32.EnumWindows(callback, 0)
591
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]
597 else:
598 selected = None
599 original = user32.GetForegroundWindow()
600 focused = selected is not None and focus_window(selected[0])
601 try:
602 yield focused
603 finally:
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)
610
611
612@contextmanager
613def activate_unity_windows_macos(project_name: str) -> Iterator[bool]:
614 """Temporarily activate this project's Unity Editor on macOS.
615
616 Args:
617 project_name: Current Unity project directory name.
618
619 Yields:
620 Whether System Events activated the matching Unity Editor process.
621 """
622 script = f"""
623 on run argv
624 set projectName to item 1 of argv
625 tell application "System Events"
626 set previousProcess to first application process whose frontmost is true
627 set previousProcessId to unix id of previousProcess
628 set editors to every application process whose name is "Unity"
629 set matchingEditors to {{}}
630 repeat with editorProcess in editors
631 try
632 set processMatches to false
633 repeat with editorWindow in every window of editorProcess
634 ignoring case
635 if (name of editorWindow as text) contains projectName then
636 set processMatches to true
637 end if
638 end ignoring
639 end repeat
640 if processMatches then set end of matchingEditors to editorProcess
641 end try
642 end repeat
643
644 if (count of matchingEditors) is 1 then
645 set selectedEditor to item 1 of matchingEditors
646 else if (count of editors) is 1 then
647 set selectedEditor to item 1 of editors
648 else
649 return (previousProcessId as string) & "|0|0"
650 end if
651
652 set activatedCount to 0
653 set activatedProcessId to 0
654 try
655 set frontmost of selectedEditor to true
656 delay {UNITY_FOCUS_DELAY_SECONDS}
657 set activatedProcessId to unix id of selectedEditor
658 set activatedCount to 1
659 end try
660 return (previousProcessId as string) & "|" & (activatedProcessId as string) & "|" & (activatedCount as string)
661 end tell
662 end run
663 """
664 previous_process_id = None
665 activated_process_id = None
666 focused = False
667 try:
668 result = subprocess.run(
669 ["osascript", "-e", script, project_name],
670 capture_output=True,
671 text=True,
672 timeout=20,
673 check=True,
674 )
675 previous_id, activated_id, activated_count = result.stdout.strip().split("|")
676 previous_process_id = int(previous_id)
677 activated_process_id = int(activated_id)
678 focused = int(activated_count) > 0
679 except (OSError, ValueError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
680 pass
681
682 try:
683 yield focused
684 finally:
685 if focused and previous_process_id and activated_process_id:
686 restore_script = f"""
687 tell application "System Events"
688 set activeProcess to first application process whose frontmost is true
689 if (unix id of activeProcess) is {activated_process_id} then
690 set previousProcess to first application process whose unix id is {previous_process_id}
691 set frontmost of previousProcess to true
692 end if
693 end tell
694 """
695 try:
696 subprocess.run(
697 ["osascript", "-e", restore_script],
698 capture_output=True,
699 text=True,
700 timeout=20,
701 check=True,
702 )
703 except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
704 pass
705
706
707def run_xdotool(*arguments: str) -> str | None:
708 """Run one X11 window operation without exposing command errors.
709
710 Args:
711 *arguments: Arguments passed to the xdotool executable.
712
713 Returns:
714 Captured output, or None if the operation could not complete.
715 """
716 try:
717 result = subprocess.run(
718 ["xdotool", *arguments], capture_output=True, text=True, timeout=4, check=True
719 )
720 return result.stdout.strip()
721 except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
722 return None
723
724
725@contextmanager
726def activate_unity_windows_linux(project_name: str) -> Iterator[bool]:
727 """Temporarily focus this project's Unity Editor on X11.
728
729 Args:
730 project_name: Current Unity project directory name.
731
732 Yields:
733 Whether the matching Unity Editor window was successfully activated.
734 """
735 if not os.environ.get("DISPLAY") or not shutil.which("xdotool"):
736 yield False
737 return
738
739 candidates = run_xdotool("search", "--onlyvisible", "--class", "Unity")
740 original = run_xdotool("getactivewindow")
741 if not candidates or not original:
742 yield False
743 return
744
745 editors = []
746 seen_processes = set()
747 for handle in candidates.splitlines():
749 process_id = run_xdotool("getwindowpid", handle)
750 if not process_id or process_id in seen_processes:
751 continue
752 try:
753 executable = (Path("/proc") / process_id / "exe").resolve(strict=True)
754 except (OSError, RuntimeError):
755 continue
756 if executable.name.casefold() != "unity":
757 continue
758 seen_processes.add(process_id)
759 title = run_xdotool("getwindowname", handle) or ""
760 editors.append((handle, process_id, title.casefold()))
761 if len(seen_processes) >= UNITY_FOCUS_MAX_WINDOWS:
762 break
763
764 matches = [editor for editor in editors if project_name.casefold() in editor[2]]
765 if len(matches) == 1:
766 selected = matches[0]
767 elif len(editors) == 1:
768 selected = editors[0]
769 else:
770 selected = None
771 if selected is None:
772 yield False
773 return
774
775 activated = run_xdotool("windowactivate", "--sync", selected[0]) is not None
776 try:
777 if activated:
778 time.sleep(UNITY_FOCUS_DELAY_SECONDS)
779 yield activated
780 finally:
781 if activated and run_xdotool("getactivewindow") == selected[0]:
782 run_xdotool("windowactivate", original)
783
784
786 """Exchange commands and results with the active Unity Editor."""
787
788 def __init__(self, directory: Path, project_name: str) -> None:
789 """Initialize communication paths.
790
791 Args:
792 directory: Shared project-local state directory.
793 project_name: Name of the Unity project to activate.
794 """
795 self.directory = directory
796 self.project_name = project_name
797 self.request_path = directory / REQUEST_FILENAME
798 self.result_path = directory / RESULT_FILENAME
799
800 def execute(self, action: str, **arguments: str) -> bool:
801 """Send an editor action and retry only a confirmed transient readiness rejection.
802
803 Args:
804 action: Operation identifier recognized by the editor.
805 **arguments: Operation-specific command fields.
806
807 Returns:
808 Whether the operation completed, including editor readiness after a Lite reload.
809
810 Raises:
811 UpdateError: The operation fails or cannot be acknowledged.
812 UserCancelled: The operation is cancelled in Unity or the console.
813 """
814 deadline = time.monotonic() + EDITOR_BUSY_RETRY_SECONDS
815 waiting_printed = False
816 while True:
817 try:
818 return self._execute_once(action, **arguments)
819 except EditorOperationError as exc:
820 if str(exc) != EDITOR_NOT_READY_ERROR or time.monotonic() >= deadline:
821 raise
822 if not waiting_printed:
823 print("Unity is refreshing assets. Waiting to retry the command...", flush=True)
824 waiting_printed = True
826 time.sleep(POLL_INTERVAL_SECONDS)
827
828 def _execute_once(self, action: str, **arguments: str) -> bool:
829 """Send a uniquely identified command and await the editor response.
830
831 Args:
832 action: Operation identifier recognized by the editor.
833 **arguments: Operation-specific command fields.
834
835 Returns:
836 Whether the operation completed, including editor readiness after a Lite reload.
837
838 Raises:
839 UpdateError: The operation fails or cannot be acknowledged.
840 UserCancelled: The operation is cancelled in Unity or the console.
841 """
842 command_id = uuid.uuid4().hex
844 "ID": command_id,
845 "Action": action,
846 "Cancelled": False,
847 **arguments,
848 })
849
850 try:
851 self._wait_for_editor(command_id, action)
852 return True
853 except EditorOperationInterrupted:
854 print("\nThe Unity Editor reloaded during the import.")
855 package = CORE_PACKAGE_PATTERN.fullmatch(Path(arguments.get("Path", "")).name)
856 if action == "import" and package is not None and package.group(1).casefold() == "lite":
857 self.execute("ping")
858 return True
859
861 "Finish or cancel the import in Unity, then press Enter ONLY "
862 "if it completed successfully; Q cancels automatic restoration: "
863 )
864 return True
865 except (UserCancelled, KeyboardInterrupt, UpdateError):
866 self._cancel_pending_request(command_id)
867 raise
868
869 def _cancel_pending_request(self, command_id: str) -> None:
870 """Mark an unclaimed request as cancelled so Unity cannot run it later.
871
872 Args:
873 command_id: Identifier of the request to cancel.
874 """
875 request = read_json(self.request_path)
876 if request is None or request.get("ID") != command_id:
877 return
878
879 result = read_json(self.result_path)
880 if result is not None and result.get("ID") == command_id and result.get("State") in {
881 "processing",
882 "completed",
883 "failed",
884 "cancelled",
885 "interrupted",
886 }:
887 return
888
889 request["Cancelled"] = True
890 atomic_write_json(self.request_path, request)
891
892 def _confirm_manual_import_completion(self, command_id: str) -> None:
893 """Signal Unity that the user dismissed the empty import dialog.
894
895 Args:
896 command_id: Identifier of the active import request.
897
898 Raises:
899 UpdateError: The active import request can no longer be confirmed.
900 """
901 request = read_json(self.request_path)
902 if request is None or request.get("ID") != command_id or request.get("Action") != "import":
903 raise UpdateError("The active import request could not be confirmed manually.")
904
905 request[MANUAL_IMPORT_COMPLETION_FIELD] = True
906 atomic_write_json(self.request_path, request)
907
908 def _wait_for_editor(self, command_id: str, action: str) -> None:
909 """Wait for Unity to acknowledge and finish the request.
910
911 Args:
912 command_id: Identifier of the pending request.
913 action: Operation requested from Unity.
914
915 Raises:
916 UpdateError: Unity fails or does not acknowledge the request.
917 UserCancelled: The user cancels while waiting.
918 """
919 ping_response_received = False
920 if action == "ping":
921 print(
922 "If Unity does not respond, select the Unity Editor window and wait for it to process "
923 "the request.\nThe updater will continue automatically once Unity responds.",
924 flush=True,
925 )
926 grace_end = time.monotonic() + UNITY_BACKGROUND_RESPONSE_GRACE_SECONDS
927 while time.monotonic() < grace_end:
929 initial_result = read_json(self.result_path)
930 if initial_result is not None and initial_result.get("ID") == command_id:
931 if initial_result.get("State") == "completed":
932 print("Unity Editor is ready.", flush=True)
933 return
934 ping_response_received = True
935 break
936 time.sleep(min(0.1, max(0.0, grace_end - time.monotonic())))
937
938 started = time.monotonic()
939 acknowledged = False
940 manual_confirmation_requested = False
941 manual_confirmation_sent = False
942 warning_printed = False
943 last_status = started
944
945 focus_needed = action == "import" or (action == "ping" and not ping_response_received)
946 focus = focus_unity_editor(self.project_name) if focus_needed else nullcontext(False)
947 with focus as focused:
948 if focus_needed and not focused:
949 if action == "ping":
950 print(
951 "The matching Unity Editor window could not be activated automatically. "
952 "Select it manually.",
953 flush=True,
954 )
955 elif action == "import":
956 print(
957 "The Unity Editor window could not be activated automatically. "
958 "Select it manually to handle the package import.",
959 flush=True,
960 )
961 if action == "import":
962 print(
963 "If Unity shows 'Nothing to import', close that dialog and press Enter here to continue.",
964 flush=True,
965 )
966 while True:
967 if action == "import":
968 enter_pressed = read_console_enter_pressed()
969 else:
971 enter_pressed = False
972
973 result = read_json(self.result_path)
974 if result is not None and result.get("ID") == command_id:
975 state = result.get("State", "")
976 detail = result.get("Message", "")
977 if state == "processing":
978 acknowledged = True
979 elif state == "completed":
980 if action == "ping":
981 print("Unity Editor is ready.", flush=True)
982 return
983 elif state == "cancelled":
984 raise EditorOperationCancelled(f"The editor cancelled the operation. {detail}".strip())
985 elif state == "failed":
986 raise EditorOperationError(f"The editor reported an error: {detail}")
987 elif state == "interrupted":
988 if action != "import":
989 raise UpdateError("The editor reloaded during an asset operation. Review the project.")
991
992 if enter_pressed and acknowledged:
993 manual_confirmation_requested = True
994
995 if manual_confirmation_requested and acknowledged and not manual_confirmation_sent:
996 self._confirm_manual_import_completion(command_id)
997 manual_confirmation_requested = False
998 manual_confirmation_sent = True
999 print("Manual import completion sent to Unity. Waiting for confirmation...", flush=True)
1000
1001 now = time.monotonic()
1002 elapsed = now - started
1003 if action == "ping" and now - last_status >= EDITOR_STATUS_INTERVAL_SECONDS:
1004 print(f"Still waiting for Unity Editor... {int(elapsed)}s / {EDITOR_RESPONSE_TIMEOUT_SECONDS}s", flush=True)
1005 last_status = now
1006 if not acknowledged and elapsed > EDITOR_RESPONSE_TIMEOUT_SECONDS:
1007 raise UpdateError(
1008 "The Unity Editor did not respond to the request. "
1009 "Check that the project is open, scripts have compiled, "
1010 "and the editor bridge has compiled successfully."
1011 )
1012 if acknowledged and elapsed > EDITOR_WAIT_WARNING_SECONDS and not warning_printed:
1013 print("\nWaiting for Unity to finish the operation. Press Ctrl+C to stop waiting.")
1014 warning_printed = True
1015 time.sleep(POLL_INTERVAL_SECONDS)
1016
1017
1018def verify_recovery(bridge: EditorBridge, local_asset: Path, root_asset: Path) -> None:
1019 """Handle an unfinished relocation from an earlier process.
1020
1021 Args:
1022 bridge: Editor communication client.
1023 local_asset: Original asset directory.
1024 root_asset: Temporary asset directory.
1025
1026 Raises:
1027 UpdateError: Recovery paths conflict or restoration fails.
1028 UserCancelled: Recovery is declined.
1029 """
1030 recovery_path = bridge.directory / RECOVERY_FILENAME
1031 journal = read_json(recovery_path)
1032 if journal is None:
1033 if path_exists(recovery_path):
1034 raise UpdateError(f"The recovery journal is unreadable: {recovery_path}")
1035 return
1036
1037 if journal.get("source") != str(local_asset) or journal.get("destination") != str(root_asset):
1038 raise UpdateError(f"The recovery journal references unexpected paths: {recovery_path}")
1039
1040 if local_asset.is_dir() and not path_exists(root_asset) and not path_exists(metadata_path(root_asset)):
1041 request = read_json(bridge.request_path)
1042 if request is not None and request.get("Action") == "move":
1043 result = read_json(bridge.result_path)
1044 if result is None or result.get("ID") != request.get("ID") or result.get("State") == "processing":
1045 raise UpdateError(
1046 "A previous asset move has not been confirmed by the editor. "
1047 "Wait for Unity to finish processing before retrying."
1048 )
1049 recovery_path.unlink()
1050 return
1051
1052 if (
1053 not root_asset.is_dir()
1054 or root_asset.is_symlink()
1055 or not metadata_path(root_asset).is_file()
1056 or metadata_path(root_asset).is_symlink()
1057 or path_exists(local_asset)
1058 or path_exists(metadata_path(local_asset))
1059 ):
1060 raise UpdateError(
1061 "A previous relocation was interrupted and the paths are not safe to restore. "
1062 f"Review both locations manually:\n {local_asset}\n {root_asset}"
1063 )
1064
1065 print(f"\nAn unfinished asset relocation was found: {root_asset}")
1067 "Ensure ALL earlier imports have finished or been cancelled in Unity. "
1068 "Press Enter to restore before updating; Q leaves it untouched: "
1069 )
1070 bridge.execute("move", Source=unity_asset_path(root_asset), Destination=unity_asset_path(local_asset))
1071 recovery_path.unlink()
1072 print("Previous asset relocation was restored.")
1073
1074
1075def unity_asset_path(path: Path) -> str:
1076 """Convert a filesystem path below the assets root into a Unity asset path.
1077
1078 Args:
1079 path: Directory inside the current project's assets folder.
1080
1081 Returns:
1082 Project-relative asset path using forward slashes.
1083 """
1084 assets = find_assets_directory(path)
1085 return f"{ASSETS_DIRECTORY}/{path.relative_to(assets).as_posix()}"
1086
1087
1088def run_update(bridge: EditorBridge, script_directory: Path, assets_directory: Path) -> None:
1089 """Run the update sequence while preserving movable project assets.
1090
1091 Args:
1092 bridge: Editor integration client.
1093 script_directory: Directory containing the updater.
1094 assets_directory: Project assets root.
1095
1096 Raises:
1097 UpdateError: A prerequisite fails or the editor reports an error.
1098 UserCancelled: The user or editor cancels the operation.
1099 """
1100 print("Searching for downloaded SDK packages...", flush=True)
1101 downloads_directory = get_downloads_directory()
1102 version, packages, ignored_versions = discover_packages(downloads_directory)
1103 local_asset = script_directory / PRESERVED_DIRECTORY
1104 root_asset = assets_directory / PRESERVED_DIRECTORY
1105
1106 print("Waiting for Unity Editor to respond...", flush=True)
1107 bridge.execute("ping")
1108 print("Checking previous update state...", flush=True)
1109 verify_recovery(bridge, local_asset, root_asset)
1110
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}")
1115
1116 should_move = local_asset.is_dir()
1117 if should_move:
1118 if path_exists(root_asset) or path_exists(metadata_path(root_asset)):
1119 raise UpdateError(
1120 "Both preserved directory locations are occupied. "
1121 "No files were modified."
1122 )
1123 if not metadata_path(local_asset).is_file() or metadata_path(local_asset).is_symlink():
1124 raise UpdateError(f"The metadata file is missing or invalid: {metadata_path(local_asset)}")
1125
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}")
1134
1135 if should_move:
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.")
1139 else:
1140 print(f"\n{PRESERVED_DIRECTORY}: absent; no relocation needed.")
1141
1142 ask_to_continue("\nPress Enter to start, or Q to cancel: ")
1143
1144 recovery_path = bridge.directory / RECOVERY_FILENAME
1145 moved = False
1146 restore_allowed = True
1147 try:
1148 if should_move:
1149 atomic_write_json(recovery_path, {
1150 "source": str(local_asset),
1151 "destination": str(root_asset),
1152 })
1153 bridge.execute("move", Source=unity_asset_path(local_asset), Destination=unity_asset_path(root_asset))
1154 moved = True
1155 print("\nPreserved directory temporarily moved by Unity Editor.")
1156
1157 for index, package in enumerate(packages, start=1):
1158 print(f"\n[{index}/{len(packages)}] Importing: {package.name}")
1159 restore_allowed = False
1160 try:
1161 bridge.execute("import", Path=str(package.resolve()))
1162 except (EditorOperationError, EditorOperationCancelled):
1163 restore_allowed = True
1164 raise
1165 else:
1166 restore_allowed = True
1167 print(f"Completed: {package.name}")
1168 finally:
1169 if path_exists(recovery_path):
1170 if moved and restore_allowed:
1171 print("\nRestoring preserved directory using Unity Editor...")
1172 try:
1173 bridge.execute(
1174 "move",
1175 Source=unity_asset_path(root_asset),
1176 Destination=unity_asset_path(local_asset),
1177 )
1178 except (UpdateError, UserCancelled, KeyboardInterrupt) as exc:
1179 print(f"WARNING: Automatic asset restoration failed: {exc}")
1180 print(f"Recovery information: {recovery_path}")
1181 raise
1182 else:
1183 recovery_path.unlink()
1184 print("Preserved directory restored successfully.")
1185 else:
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}")
1189
1190 print("\nAll selected packages have been imported successfully.")
1191
1192
1194 """Atomically claim a pending launch token, or stop after launcher cancellation."""
1195 attempt_path_value = os.environ.pop(STARTUP_ATTEMPT_ENVIRONMENT_VARIABLE, None)
1196 if not attempt_path_value:
1197 return
1198
1199 attempt_path = Path(attempt_path_value)
1200 started_path = attempt_path.with_suffix(".started")
1201 try:
1202 os.rename(attempt_path, started_path)
1203 except OSError as exc:
1204 if not attempt_path.exists():
1205 raise UpdateError("The updater startup attempt was cancelled before the controller started.") from exc
1206 raise UpdateError(f"Could not claim the updater startup attempt: {exc}") from exc
1207
1208
1209@contextmanager
1210def active_update_session(directory: Path) -> Iterator[None]:
1211 """Publish the controller process while SDK imports and restoration are active.
1212
1213 Args:
1214 directory: Shared editor bridge state directory.
1215
1216 Yields:
1217 Control while editor-side SDK patching is suspended.
1218 """
1219 marker = directory / ACTIVE_SESSION_FILENAME
1220 atomic_write_json(marker, {"ProcessId": os.getpid()})
1221 try:
1222 yield
1223 finally:
1224 try:
1225 marker.unlink()
1226 except FileNotFoundError:
1227 pass
1228
1229
1230def main() -> int:
1231 """Validate the installation and coordinate an import session.
1232
1233 Returns:
1234 Zero after a successful update or explicit cancellation.
1235
1236 Raises:
1237 UpdateError: Validation, editor communication, or recovery fails.
1238 UserCancelled: An explicit cancellation occurs.
1239 """
1241
1242 script_directory = Path(__file__).resolve().parent
1243 assets_directory = find_assets_directory(script_directory)
1244 validate_script_directory(script_directory)
1245
1246 project_directory = assets_directory.parent
1247 if not (project_directory / "ProjectSettings").is_dir():
1248 raise UpdateError(f"The Unity project settings directory is missing: {project_directory}")
1249
1250 bridge_directory = project_directory / "Library" / BRIDGE_DIRECTORY
1251 bridge_directory.mkdir(parents=True, exist_ok=True)
1252 with exclusive_session(bridge_directory / LOCK_FILENAME), active_update_session(bridge_directory):
1253 run_update(EditorBridge(bridge_directory, project_directory.name), script_directory, assets_directory)
1254 return 0
1255
1256
1257if __name__ == "__main__":
1258 print("Yandex Mobile Ads SDK Updater", flush=True)
1259 if sys.platform == "win32":
1260 print("Press Esc in the updater window to cancel or close it.\n", flush=True)
1261 else:
1262 print("Press Ctrl+C in the updater window to cancel.\n", flush=True)
1263 exit_code = 1
1264 escape_requested = False
1265 try:
1266 exit_code = main()
1267 except EscapeCancelled as exc:
1268 print(f"\n{exc}")
1269 exit_code = 0
1270 escape_requested = True
1271 except UserCancelled as exc:
1272 print(f"\n{exc}")
1273 exit_code = 0
1274 except (UpdateError, OSError, EOFError, ValueError) as exc:
1275 print(f"\nERROR: {exc}")
1276 except KeyboardInterrupt:
1277 print("\nOperation interrupted. Check the recovery information above before retrying.")
1278 finally:
1279 if not escape_requested:
1280 try:
1281 read_console_input("\nPress Enter to close...")
1282 except (EOFError, KeyboardInterrupt, EscapeCancelled):
1283 pass
1284
1285 sys.exit(exit_code)
None __init__(self, Path directory, str project_name)
bool execute(self, str action, **str arguments)
None _confirm_manual_import_completion(self, str command_id)
None _cancel_pending_request(self, str command_id)
None _wait_for_editor(self, str command_id, str action)
bool _execute_once(self, str action, **str arguments)
None ask_to_continue(str message)
Path find_assets_directory(Path script_directory)
Iterator[bool] focus_unity_editor(str project_name)
Path metadata_path(Path asset_path)
str|None run_xdotool(*str arguments)
None verify_recovery(EditorBridge bridge, Path local_asset, Path root_asset)
Iterator[None] active_update_session(Path directory)
str read_console_input(str message)
None validate_script_directory(Path script_directory)
str unity_asset_path(Path path)
None run_update(EditorBridge bridge, Path script_directory, Path assets_directory)
dict[str, object]|None read_json(Path path)
bool path_exists(Path path)
Iterator[bool] activate_unity_windows_linux(str project_name)
tuple[str, list[Path], list[str]] discover_packages(Path downloads_directory)
None atomic_write_json(Path path, dict[str, object] content)
Iterator[bool] activate_unity_windows_macos(str project_name)
tuple[int,...] version_key(str version)
Iterator[bool] focus_unity_editor_windows(str project_name)
Iterator[None] exclusive_session(Path lock_path)
None claim_controller_startup_attempt()