diff --git a/.github/workflows/launcher.yml b/.github/workflows/launcher.yml index cff2f4ab..d369ca88 100644 --- a/.github/workflows/launcher.yml +++ b/.github/workflows/launcher.yml @@ -25,7 +25,9 @@ jobs: fail-fast: false matrix: include: - - os: ubuntu-latest + # Build against the oldest supported glibc, not the moving latest + # runner (whose binaries cannot launch on older Ubuntu releases). + - os: ubuntu-22.04 os_label: linux - os: windows-latest os_label: windows @@ -49,6 +51,7 @@ jobs: uses: Swatinem/rust-cache@v2 with: workspaces: launcher + prefix-key: native-${{ matrix.os }} # Same insurance as the Release workflow: Slint has no C build deps, # but a few crates probe for these headers. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 13929b5c..0b7310be 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -142,7 +142,8 @@ jobs: fail-fast: false matrix: include: - - os: ubuntu-latest + # Keep the installer compatible with glibc 2.35 (Ubuntu 22.04+). + - os: ubuntu-22.04 os_label: linux - os: windows-latest os_label: windows @@ -170,6 +171,7 @@ jobs: uses: Swatinem/rust-cache@v2 with: workspaces: launcher + prefix-key: native-${{ matrix.os }} # Slint's Linux backend loads these at runtime through dlopen, and its # build has no C dependencies — this is insurance for the few crates diff --git a/.github/workflows/startup.yml b/.github/workflows/startup.yml new file mode 100644 index 00000000..6e6d1968 --- /dev/null +++ b/.github/workflows/startup.yml @@ -0,0 +1,47 @@ +name: Startup regressions + +on: + push: + paths: + - 'craftbot.py' + - 'run.py' + - 'installer/**' + - 'app/console.py' + - 'app/python_runtime.py' + - 'app/provision/runtimes.py' + - 'app/provision/verify.py' + - 'tests/test_native_runtime.py' + - 'tests/test_python_downloads.py' + - 'tests/test_autostart.py' + - 'tests/test_craftbot_service.py' + - '.github/workflows/startup.yml' + pull_request: + paths: + - 'craftbot.py' + - 'run.py' + - 'installer/**' + - 'app/console.py' + - 'app/python_runtime.py' + - 'app/provision/runtimes.py' + - 'app/provision/verify.py' + - 'tests/test_native_runtime.py' + - 'tests/test_python_downloads.py' + - 'tests/test_autostart.py' + - 'tests/test_craftbot_service.py' + - '.github/workflows/startup.yml' + +jobs: + startup: + strategy: + fail-fast: false + matrix: + os: [windows-latest, macos-latest, ubuntu-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + - run: python -m pip install pytest==9.0.2 + - name: Check login startup and browser-only shortcuts + run: python -m pytest tests/test_autostart.py tests/test_craftbot_service.py tests/test_python_downloads.py tests/test_native_runtime.py -q diff --git a/.gitignore b/.gitignore index aefbca0d..90bfd604 100644 --- a/.gitignore +++ b/.gitignore @@ -130,3 +130,6 @@ agent_file_system/*.local-backup /research/ /research_notes/ /workspace/ + +# Per-install automatic startup preference (managed installs keep it in user data). +/startup.json diff --git a/README.md b/README.md index 9372ae2e..8c2b887c 100644 --- a/README.md +++ b/README.md @@ -77,11 +77,16 @@ python craftbot.py stop # Stop CraftBot python craftbot.py restart # Restart CraftBot python craftbot.py status # Check if it's running and if auto-start is enabled python craftbot.py logs # See recent log output +python craftbot.py autostart disable # Don't launch automatically at future logins +python craftbot.py autostart enable # Restore automatic startup at login +python craftbot.py autostart status # Check automatic startup registration python craftbot.py uninstall # Stop, remove auto-start, and uninstall packages ``` > [!TIP] -> After `install` or `start`, a **CraftBot desktop shortcut** is created automatically. If you close the browser, just double-click the shortcut to reopen it. +> After `install` or `start`, a **CraftBot desktop shortcut** is created automatically. It only opens the browser; it never starts or restarts CraftBot. Automatic startup runs CraftBot in the background when you log in after a restart, without opening a browser. If you disabled startup, run `python craftbot.py start` before using the shortcut. + +Both the installer and `python craftbot.py install` enable automatic startup by default. Use `python craftbot.py install --no-autostart` to opt out during installation. Reinstall and Repair refresh the startup configuration and preserve an explicit opt-out and custom ports. Disabling automatic startup does not stop the current session; use `python craftbot.py stop` for that. --- diff --git a/app/console.py b/app/console.py index 25c86dec..923b8cac 100644 --- a/app/console.py +++ b/app/console.py @@ -4,9 +4,26 @@ import sys -def configure_console() -> None: +def configure_console(*, log_file: str = None) -> None: if sys.platform != "win32": return + # pythonw launched by Task Scheduler / HKCU Run has no standard handles. + # Give it real files before any startup code flushes output or passes the + # streams to a subprocess. Installation already supplies these handles, + # which used to hide this crash until the first login after installation. + if sys.stdout is None or sys.stderr is None: + try: + output = open( + log_file or os.devnull, "a", encoding="utf-8", errors="replace" + ) + except OSError: + output = open(os.devnull, "w", encoding="utf-8") + if sys.stdout is None: + sys.stdout = output + if sys.stderr is None: + sys.stderr = output + if sys.stdin is None: + sys.stdin = open(os.devnull, "r", encoding="utf-8") for stream in (sys.stdout, sys.stderr): reconfigure = getattr(stream, "reconfigure", None) if reconfigure is not None: diff --git a/app/provision/runtimes.py b/app/provision/runtimes.py index 458e79e7..a8b2e72c 100644 --- a/app/provision/runtimes.py +++ b/app/provision/runtimes.py @@ -23,16 +23,21 @@ # pip. python.org's embeddable package is Windows-only and ships without pip, # so it cannot serve the same role. # -# The release tag and patch version are RESOLVED FROM THE API, never -# hardcoded. Hardcoding them was tried and produced a silent 404: the guessed -# tag, patch version and platform triple were all wrong, so the download -# failed and the install aborted at the first stage. The asset name embeds -# all three (cpython-3.10.21+20260825-x86_64-pc-windows-msvc-install_only), -# so any one being stale breaks it. Same approach as node_runtime, which -# reads nodejs.org/dist/index.json rather than pinning a version. +# Resolve actual asset URLs from release metadata instead of constructing +# filenames. New releases stopped including Python 3.10 after its EOL, so +# retain the last published 3.10 release while these dependency locks need it. PBS_API = ( "https://api.github.com/repos/astral-sh/python-build-standalone/releases/latest" ) +PBS_310_API = "https://api.github.com/repos/astral-sh/python-build-standalone/releases/tags/20261009" +PBS_310_ARCHIVE = "https://github.com/astral-sh/python-build-standalone/releases/download/20261009/cpython-3.10.22%2B20261009" +PBS_310_TRIPLES = { + "x86_64-pc-windows-msvc", + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu", + "x86_64-apple-darwin", + "aarch64-apple-darwin", +} # The version the locks are generated for. NOT a minimum: a lock is valid # for exactly one (platform, python) pair, so accepting "3.10 or newer" would @@ -175,8 +180,8 @@ def _pbs_triple() -> Optional[str]: def _pbs_download_url(log: LogFn) -> Optional[str]: """Resolve the download URL for a TARGET_PYTHON build, or None. - Asks the API for the newest release and picks the asset matching this - machine's triple. `install_only` is the variant that extracts to a + Tries the newest release, then the last release containing Python 3.10, + and picks the asset matching this machine's triple. `install_only` extracts to a ready-to-run tree; the full builds carry object files and headers nobody here needs. """ @@ -195,21 +200,30 @@ def _pbs_download_url(log: LogFn) -> Optional[str]: except ImportError: ctx = ssl.create_default_context() - try: - req = urllib.request.Request(PBS_API, headers={"User-Agent": "CraftBot"}) - data = json.loads(urllib.request.urlopen(req, timeout=60, context=ctx).read()) - except Exception as e: - log(f" could not reach the Python download index: {str(e)[:160]}") - return None - prefix = f"cpython-{TARGET_PYTHON[0]}.{TARGET_PYTHON[1]}." suffix = f"-{triple}-install_only.tar.gz" - for asset in data.get("assets", []): - name = asset.get("name") or "" - if name.startswith(prefix) and name.endswith(suffix): - return asset.get("browser_download_url") - - log(f" no {prefix}*{suffix} in release {data.get('tag_name')}") + indexes = [PBS_API] + ([PBS_310_API] if TARGET_PYTHON == (3, 10) else []) + for index in indexes: + try: + req = urllib.request.Request(index, headers={"User-Agent": "CraftBot"}) + with urllib.request.urlopen(req, timeout=60, context=ctx) as response: + data = json.load(response) + except Exception as e: + log(f" could not reach the Python download index: {str(e)[:160]}") + # Shared/public IPs can exhaust GitHub's unauthenticated API quota. + # Release assets have a separate download endpoint. Keep the known + # final 3.10 build available even when metadata cannot be queried. + if TARGET_PYTHON == (3, 10) and triple in PBS_310_TRIPLES: + log(" using the archived Python 3.10.22 release download") + return f"{PBS_310_ARCHIVE}-{triple}-install_only.tar.gz" + return None + for asset in data.get("assets", []): + name = asset.get("name") or "" + if name.startswith(prefix) and name.endswith(suffix): + url = asset.get("browser_download_url") + if url: + return url + log(f" no {prefix}*{suffix} in release {data.get('tag_name')}") return None diff --git a/app/provision/verify.py b/app/provision/verify.py index 8e5de901..fa7f7723 100644 --- a/app/provision/verify.py +++ b/app/provision/verify.py @@ -63,7 +63,7 @@ def vcredist_installed() -> bool: try: with winreg.OpenKey( winreg.HKEY_LOCAL_MACHINE, - r"SOFTWARE\Microsoft\VisualStudio .0\VC\Runtimesd", + r"SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64", ) as key: installed, _ = winreg.QueryValueEx(key, "Installed") return bool(installed) diff --git a/app/python_runtime.py b/app/python_runtime.py index c71123ed..cf478ab2 100644 --- a/app/python_runtime.py +++ b/app/python_runtime.py @@ -173,10 +173,22 @@ def reexec_if_needed() -> None: return env = {**os.environ, _REEXEC_MARK: "1"} argv = [target, *sys.argv] - sys.stdout.flush() - sys.stderr.flush() + for stream in (sys.stdout, sys.stderr): + if stream is not None: + stream.flush() if sys.platform == "win32": # execv on Windows spawns a detached child and returns the console # immediately — run as a subprocess and forward the exit code. - sys.exit(subprocess.run(argv, env=env).returncode) + # Python-level replacements for absent pythonw handles (notably the + # startup log) are not inherited unless explicitly passed here. + sys.exit( + subprocess.run( + argv, + env=env, + stdin=sys.stdin, + stdout=sys.stdout, + stderr=sys.stderr, + creationflags=subprocess.CREATE_NO_WINDOW, + ).returncode + ) os.execve(target, argv, env) diff --git a/craftbot.py b/craftbot.py index e05a8dd5..25962300 100644 --- a/craftbot.py +++ b/craftbot.py @@ -12,6 +12,9 @@ python craftbot.py status Show if CraftBot is running python craftbot.py logs [-n N] Show last N log lines (default: 50) python craftbot.py install [options] Register for auto-start on boot/login + python craftbot.py autostart enable Enable automatic startup at login + python craftbot.py autostart disable Disable automatic startup (keep installed) + python craftbot.py autostart status Show automatic startup status python craftbot.py uninstall Remove auto-start registration python craftbot.py repair [options] Re-copy current EXE over the installed copy and restart (frozen EXE only) @@ -28,6 +31,7 @@ --agent-app-port PORT First Agent App port, apps get PORT to PORT+99 (default: 3100) --conda Use conda environment --no-conda Don't use conda + --no-autostart Install without automatic startup at login Examples: python craftbot.py start # Start in background (browser mode) @@ -39,31 +43,21 @@ """ import sys +import os # In windowed (console=False) PyInstaller builds, sys.stdout and sys.stderr # are None. Lots of libraries (including run.py at import time) call # sys.stdout.isatty() unconditionally and crash with AttributeError. Install -# a dummy file-like before ANY other import that might transitively hit +# an output handle before ANY other import that might transitively hit # stdout. Must be the very first thing this module does. if sys.stdout is None or sys.stderr is None: - import io - - class _NullIO(io.TextIOBase): - def isatty(self) -> bool: - return False - - def write(self, s: str) -> int: - return len(s) - - def flush(self) -> None: - pass - + # Real handles can also be passed through an interpreter handoff. + output = open(os.devnull, "w", encoding="utf-8") if sys.stdout is None: - sys.stdout = _NullIO() + sys.stdout = output if sys.stderr is None: - sys.stderr = _NullIO() + sys.stderr = output -import os import shlex import shutil import signal @@ -76,6 +70,7 @@ def flush(self) -> None: from installer import helpers as _helpers from installer import metadata as _metadata from installer import payload as _payload +from installer import startup as _startup # Store platform once so static analysers don't short-circuit platform branches _PLATFORM: str = sys.platform @@ -390,6 +385,21 @@ def _remove_pid() -> None: pass +def _claim_service_process() -> bool: + """Publish a login service's PID without replacing an already running app.""" + if _owned_pid(): + print("CraftBot is already running; skipping automatic startup.") + return False + _write_pid(os.getpid()) + return True + + +def _release_service_process(pid: int) -> None: + # A manual restart may already have replaced our PID record. + if _owned_pid() == pid: + _remove_pid() + + def _parse_port_arg(args: List[str], flag: str, default: int) -> int: prefix = f"{flag}=" for i, arg in enumerate(args): @@ -749,7 +759,7 @@ def cmd_start(extra_args: List[str]) -> bool: # Create a desktop shortcut so the user can reopen the browser anytime if "--cli" not in run_args: if _PLATFORM == "win32": - _create_desktop_shortcut_windows() + _create_desktop_shortcut_windows(extra_args) else: _create_desktop_shortcut_unix(extra_args) @@ -808,6 +818,10 @@ def cmd_stop(quiet: bool = False, args: Optional[List[str]] = None) -> bool: if _stop_orphan_listeners(args): stopped = True + # A forced termination (and a reboot) cannot run the agent's cleanup. + # Do not leave the previous process's readiness behind after stop. + _clear_agent_ready() + if not stopped and not quiet: if had_record: print(f" {DIM}▸ CraftBot not running — cleaned up stale PID file{RESET}") @@ -993,29 +1007,34 @@ def _ensure_ico() -> Optional[str]: return None -def _create_desktop_shortcut_windows() -> None: +def _ps_literal(value: str) -> str: + return "'" + value.replace("'", "''") + "'" + + +def _create_desktop_shortcut_windows(extra_args: Optional[List[str]] = None) -> None: """Create a .lnk shortcut on the Windows Desktop with the CraftBot icon.""" desktop = _find_desktop() if not desktop: return shortcut_path = os.path.join(desktop, SHORTCUT_NAME) - if os.path.exists(shortcut_path): - return # already exists, don't recreate + # Refresh existing links too: upgrades and custom ports must take effect. ico_path = _ensure_ico() try: # Write the PS script to a temp file with UTF-8-BOM so PowerShell # handles non-ASCII paths (e.g. Japanese Desktop folder) correctly. import tempfile + browser_command = '/c start "" ' + _frontend_url(extra_args or []) ps_lines = [ "$ws = New-Object -ComObject WScript.Shell", - f'$s = $ws.CreateShortcut("{shortcut_path}")', + f"$s = $ws.CreateShortcut({_ps_literal(shortcut_path)})", '$s.TargetPath = "cmd.exe"', - f'$s.Arguments = "/c start {BROWSER_URL}"', + f"$s.Arguments = {_ps_literal(browser_command)}", + '$s.WorkingDirectory = ""', "$s.WindowStyle = 7", # minimized (hides the cmd flash) ] if ico_path: - ps_lines.append(f'$s.IconLocation = "{ico_path},0"') + ps_lines.append(f"$s.IconLocation = {_ps_literal(ico_path + ',0')}") ps_lines += [ '$s.Description = "Open CraftBot in your browser"', "$s.Save()", @@ -1037,6 +1056,8 @@ def _create_desktop_shortcut_windows() -> None: ], capture_output=True, timeout=15, + check=True, + **_quiet_windows_flags(), ) finally: try: @@ -1063,16 +1084,6 @@ def _installed_launch() -> Optional[List[str]]: return _metadata.launch_command(INSTALL_METADATA_FILE) -def _shortcut_start_command(extra_args: List[str]) -> Optional[str]: - restart_args = _port_args(extra_args) - if IS_FROZEN: - launch = _installed_launch() - if not launch: - return None - return shlex.join(launch + restart_args) - return shlex.join([_python_exe(), "craftbot.py", "start"] + restart_args) - - def _create_desktop_shortcut_unix(extra_args: Optional[List[str]] = None) -> None: """Create a desktop shortcut on Linux or macOS.""" if extra_args is None: @@ -1085,24 +1096,7 @@ def _create_desktop_shortcut_unix(extra_args: Optional[List[str]] = None) -> Non if _PLATFORM == "darwin": # macOS does not support XDG .desktop files — create a double-clickable .command script shortcut_path = os.path.join(desktop, "CraftBot.command") - backend_url = _backend_url(extra_args) - start_cmd = _shortcut_start_command(extra_args) - if not start_cmd: - print(" (Could not create desktop shortcut: no installed agent found)") - return - content = ( - "#!/bin/sh\n" - f"cd {shlex.quote(BASE_DIR)} || exit 1\n" - f"backend_status=$(curl -sS -o /dev/null -w '%{{http_code}}' " - f"{shlex.quote(backend_url)} 2>/dev/null || true)\n" - f"if curl -fsS {shlex.quote(browser_url)} >/dev/null 2>&1 " - '&& [ "$backend_status" -ge 100 ] 2>/dev/null ' - '&& [ "$backend_status" -lt 500 ] 2>/dev/null; then\n' - f" open {shlex.quote(browser_url)}\n" - "else\n" - f" exec {start_cmd}\n" - "fi\n" - ) + content = f"#!/bin/sh\nexec open {shlex.quote(browser_url)}\n" with open(shortcut_path, "w") as f: f.write(content) os.chmod(shortcut_path, 0o755) @@ -1135,12 +1129,51 @@ def _create_desktop_shortcut_unix(extra_args: Optional[List[str]] = None) -> Non # ─── Auto-start: Windows Task Scheduler ─────────────────────────────────────── +def _quiet_windows_flags() -> dict: + return {"creationflags": 0x08000000} if _PLATFORM == "win32" else {} + + +def _autostart_launch(run_args: List[str]) -> Optional[List[str]]: + launch = _installed_launch() if IS_FROZEN else [_python_exe(), RUN_SCRIPT] + if not launch: + print("No installed CraftBot found — run install first.") + return None + args = _build_run_args(run_args) + if os.path.basename(launch[-1]) == "run.py": + if "--service" not in args: + args.append("--service") + # Login managers do not inherit CRAFTBOT_HOME / XDG_DATA_HOME from + # the installation shell. Keep the same data and preferences after + # login, including when the user relocated them. + args.append(f"--state-root={paths.STATE_ROOT.resolve()}") + return launch + args + + +def _remove_windows_registry() -> bool: + try: + import winreg + + with winreg.OpenKey( + winreg.HKEY_CURRENT_USER, + r"Software\Microsoft\Windows\CurrentVersion\Run", + 0, + winreg.KEY_SET_VALUE, + ) as key: + winreg.DeleteValue(key, TASK_NAME) + return True + except FileNotFoundError: + return True + except OSError as exc: + print(f"Could not remove the old login registry entry: {exc}") + return False + + def _install_windows_registry(action: str) -> bool: """Fallback: register auto-start via HKCU Run registry key (no admin needed).""" try: import winreg - key = winreg.OpenKey( + key = winreg.CreateKeyEx( winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows\CurrentVersion\Run", 0, @@ -1153,63 +1186,90 @@ def _install_windows_registry(action: str) -> bool: return False -def _install_windows(run_args: List[str]) -> None: - if IS_FROZEN: - # Register the installed CraftBot for auto-start: [python, run.py]. - launch = _installed_launch() - if not launch: - print( - f" {RED}✗{RESET} {WHITE}No installed CraftBot found — run install first.{RESET}" - ) - return - # 8.3 short paths avoid quoting trouble in Task Scheduler's XML for - # paths containing spaces, which %LOCALAPPDATA%\Programs often does. - quoted = " ".join(f'"{_to_short_path(part)}"' for part in launch) - action = f"{quoted} {' '.join(run_args)}".strip() - else: - python = _python_exe() - # Use 8.3 short paths to avoid Unicode/long-path failures in schtasks /tr - python_s = _to_short_path(python) - script_s = _to_short_path(RUN_SCRIPT) - action = f'"{python_s}" "{script_s}" {" ".join(run_args)}' +def _install_windows(run_args: List[str]) -> bool: + import csv + import tempfile - # Try Task Scheduler first; silently fall back to Registry on failure + command = _autostart_launch(run_args) + if not command: + return False + directory = ( + os.path.dirname(command[1]) + if len(command) > 1 and command[1].endswith(".py") + else BASE_DIR + ) + # XML separates the executable from its arguments, preserving Unicode and + # spaces without relying on 8.3 names. Configure background-app settings + # explicitly; schtasks /sc ONLOGON otherwise inherits battery/72h limits. registered = False + xml_file = None try: + identity = subprocess.run( + ["whoami", "/user", "/fo", "csv", "/nh"], + capture_output=True, + text=True, + check=True, + timeout=10, + **_quiet_windows_flags(), + ) + user = next(csv.reader([identity.stdout.strip()]))[1] + if not user.startswith("S-1-"): + raise ValueError("Could not determine the current Windows user SID") + with tempfile.NamedTemporaryFile( + mode="w", suffix=".xml", delete=False, encoding="utf-16" + ) as f: + xml_file = f.name + f.write(_startup.windows_task_xml(command, directory, user)) result = subprocess.run( - [ - "schtasks", - "/create", - "/tn", - TASK_NAME, - "/tr", - action, - "/sc", - "ONLOGON", - "/f", - ], + ["schtasks", "/create", "/tn", TASK_NAME, "/xml", xml_file, "/f"], capture_output=True, text=True, timeout=30, + **_quiet_windows_flags(), ) registered = result.returncode == 0 - except Exception: - pass - - if not registered: - registered = _install_windows_registry(action) + except (OSError, subprocess.SubprocessError, ValueError, IndexError) as exc: + print(f"Task Scheduler registration unavailable: {exc}") + finally: + if xml_file: + os.remove(xml_file) + if registered: + # An earlier non-admin installation may have used HKCU Run. Keep only + # one launch trigger, rather than starting two agents at the next login. + registered = _remove_windows_registry() + else: + # Remove an obsolete task before falling back, so its old action cannot + # race the updated registry action. A missing task is fine. + try: + query = subprocess.run( + ["schtasks", "/query", "/tn", TASK_NAME], + capture_output=True, + timeout=10, + **_quiet_windows_flags(), + ) + if query.returncode == 0: + subprocess.run( + ["schtasks", "/delete", "/tn", TASK_NAME, "/f"], + capture_output=True, + check=True, + timeout=15, + **_quiet_windows_flags(), + ) + registered = _install_windows_registry(subprocess.list2cmdline(command)) + except (OSError, subprocess.SubprocessError) as exc: + print(f"Could not replace the old startup task: {exc}") if registered: print("Auto-start registered. CraftBot will start automatically on login.") - print(f"Open CraftBot: {BROWSER_URL}") - _create_desktop_shortcut_windows() + _create_desktop_shortcut_windows(run_args) else: print( - "Could not register auto-start. Use 'python craftbot.py start' to start manually." + "Could not register auto-start. Run 'python craftbot.py autostart enable' to retry." ) + return registered -def _uninstall_windows() -> None: +def _uninstall_windows(*, stop: bool = True) -> bool: removed_any = False # Remove from Task Scheduler @@ -1249,25 +1309,26 @@ def _uninstall_windows() -> None: if not removed_any: print("No auto-start registration found (already uninstalled?).") + return not _is_installed() # ─── Auto-start: Linux systemd (user service) ───────────────────────────────── -def _install_linux(run_args: List[str]) -> None: +def _install_linux(run_args: List[str]) -> bool: service_dir = os.path.expanduser("~/.config/systemd/user") os.makedirs(service_dir, exist_ok=True) service_file = os.path.join(service_dir, f"{SYSTEMD_SERVICE}.service") - if IS_FROZEN: - launch = _installed_launch() - if not launch: - print("Error: no installed CraftBot found — run install first.") - return - exec_start = f"{' '.join(launch)} {' '.join(run_args)}".strip() - else: - python = _installed_python() - exec_start = f"{python} {RUN_SCRIPT} {' '.join(run_args)}" + command = _autostart_launch(run_args) + if not command: + return False + exec_start = " ".join(_startup.systemd_quote(arg, command=True) for arg in command) + directory = ( + os.path.dirname(command[1]) + if len(command) > 1 and command[1].endswith(".py") + else BASE_DIR + ) content = f"""[Unit] Description=CraftBot AI Agent @@ -1276,7 +1337,7 @@ def _install_linux(run_args: List[str]) -> None: [Service] Type=simple ExecStart={exec_start} -WorkingDirectory={BASE_DIR} +WorkingDirectory={_startup.systemd_path(directory)} Restart=on-failure RestartSec=5 Environment=PYTHONUNBUFFERED=1 @@ -1296,9 +1357,10 @@ def _install_linux(run_args: List[str]) -> None: print("CraftBot will start automatically when you log in.") print(f"\nOpen CraftBot: {BROWSER_URL}") print(f" Tip: Bookmark {BROWSER_URL} so you never have to remember it!") - _create_desktop_shortcut_unix() + _create_desktop_shortcut_unix(run_args) print(f"\nTo start it now: systemctl --user start {SYSTEMD_SERVICE}") print(f"To view logs: journalctl --user -u {SYSTEMD_SERVICE} -f") + return True except subprocess.CalledProcessError as e: print(f"Error enabling systemd service: {e}") print(f"Service file written to: {service_file}") @@ -1307,9 +1369,10 @@ def _install_linux(run_args: List[str]) -> None: ) except FileNotFoundError: print("systemctl not found. Is systemd running on this system?") + return False -def _uninstall_linux() -> None: +def _uninstall_linux(*, stop: bool = True) -> bool: service_file = os.path.expanduser( f"~/.config/systemd/user/{SYSTEMD_SERVICE}.service" ) @@ -1319,13 +1382,15 @@ def _uninstall_linux() -> None: capture_output=True, timeout=10, ) - subprocess.run( - ["systemctl", "--user", "stop", SYSTEMD_SERVICE], - capture_output=True, - timeout=10, - ) + if stop: + subprocess.run( + ["systemctl", "--user", "stop", SYSTEMD_SERVICE], + capture_output=True, + timeout=10, + ) except Exception: pass + if os.path.isfile(service_file): os.remove(service_file) print("Auto-start removed (service file deleted).") @@ -1338,101 +1403,72 @@ def _uninstall_linux() -> None: except Exception: pass + return not os.path.isfile(service_file) + # ─── Auto-start: macOS launchd ──────────────────────────────────────────────── -def _install_macos(run_args: List[str]) -> None: +def _install_macos(run_args: List[str]) -> bool: + import plistlib + agents_dir = os.path.expanduser("~/Library/LaunchAgents") os.makedirs(agents_dir, exist_ok=True) plist_file = os.path.join(agents_dir, f"{LAUNCHD_LABEL}.plist") - if IS_FROZEN: - launch = _installed_launch() - if not launch: - print("Error: no installed CraftBot found — run install first.") - return - program_args = launch + run_args - else: - python = _installed_python() - program_args = [python, RUN_SCRIPT] + run_args - program_args_xml = "\n".join(f" {a}" for a in program_args) - - content = f""" - - - - Label - {LAUNCHD_LABEL} - ProgramArguments - -{program_args_xml} - - WorkingDirectory - {BASE_DIR} - RunAtLoad - - KeepAlive - - StandardOutPath - {LOG_FILE} - StandardErrorPath - {LOG_FILE} - EnvironmentVariables - - PYTHONUNBUFFERED - 1 - - - -""" - with open(plist_file, "w") as f: - f.write(content) - - # `launchctl load` honours RunAtLoad, so loading the agent starts CraftBot - # then and there. When one is already running — the normal case, since - # install starts it first — that second process fights the first for ports - # 7925/7926 and one of them loses. Writing the plist is enough on its own: - # launchd bootstraps ~/Library/LaunchAgents at login, so auto-start works - # from the next login whether or not it was loaded now. - if _owned_pid(): - print(f"Auto-start registered as launchd agent '{LAUNCHD_LABEL}'.") - print("CraftBot is already running — auto-start begins at your next login.") - _print_launchd_hints(plist_file) - return + program_args = _autostart_launch(run_args) + if not program_args: + return False + directory = ( + os.path.dirname(program_args[1]) + if len(program_args) > 1 and program_args[1].endswith(".py") + else BASE_DIR + ) + with open(plist_file, "wb") as f: + plistlib.dump( + { + "Label": LAUNCHD_LABEL, + "ProgramArguments": program_args, + "WorkingDirectory": directory, + "RunAtLoad": True, + "KeepAlive": False, + "StandardOutPath": LOG_FILE, + "StandardErrorPath": LOG_FILE, + "EnvironmentVariables": {"PYTHONUNBUFFERED": "1"}, + }, + f, + ) - try: - subprocess.run(["launchctl", "load", plist_file], check=True, timeout=10) - print(f"Auto-start registered as launchd agent '{LAUNCHD_LABEL}'.") - print("CraftBot will start automatically when you log in.") - _print_launchd_hints(plist_file) - except subprocess.CalledProcessError as e: - print(f"Error loading launchd agent: {e}") - print(f"Plist written to: {plist_file}") - print(f"Try manually: launchctl load {plist_file}") + # launchd reads ~/Library/LaunchAgents at login. Do not load RunAtLoad + # now: changing the login preference must not start a second agent. + print(f"Auto-start registered as launchd agent '{LAUNCHD_LABEL}'.") + print("CraftBot will start automatically at your next login.") + _print_launchd_hints(plist_file, run_args) + return True -def _print_launchd_hints(plist_file: str) -> None: +def _print_launchd_hints(plist_file: str, run_args: List[str]) -> None: print(f"\nOpen CraftBot: {BROWSER_URL}") print(f" Tip: Bookmark {BROWSER_URL} so you never have to remember it!") - _create_desktop_shortcut_unix() + _create_desktop_shortcut_unix(run_args) print(f"\nPlist file: {plist_file}") -def _uninstall_macos() -> None: +def _uninstall_macos(*, stop: bool = True) -> bool: plist_file = os.path.expanduser(f"~/Library/LaunchAgents/{LAUNCHD_LABEL}.plist") if os.path.isfile(plist_file): try: - subprocess.run( - ["launchctl", "unload", plist_file], capture_output=True, timeout=10 - ) + if stop: + subprocess.run( + ["launchctl", "unload", plist_file], capture_output=True, timeout=10 + ) except Exception: pass os.remove(plist_file) print("Auto-start removed.") else: print("No launchd agent found.") + return not os.path.isfile(plist_file) # ─── Install / Uninstall dispatch ───────────────────────────────────────────── @@ -1445,12 +1481,18 @@ def _is_installed() -> bool: # Check Task Scheduler try: result = subprocess.run( - ["schtasks", "/query", "/tn", TASK_NAME], + ["schtasks", "/query", "/tn", TASK_NAME, "/xml"], capture_output=True, + text=True, timeout=10, ) if result.returncode == 0: - return True + import xml.etree.ElementTree as ET + + root = ET.fromstring(result.stdout) + enabled = root.find("{*}Settings/{*}Enabled") + if enabled is None or enabled.text != "false": + return True except Exception: pass # Check Registry fallback @@ -1475,7 +1517,73 @@ def _is_installed() -> bool: service_file = os.path.expanduser( f"~/.config/systemd/user/{SYSTEMD_SERVICE}.service" ) - return os.path.isfile(service_file) + if not os.path.isfile(service_file): + return False + try: + return ( + subprocess.run( + ["systemctl", "--user", "is-enabled", "--quiet", SYSTEMD_SERVICE], + capture_output=True, + timeout=10, + ).returncode + == 0 + ) + except (OSError, subprocess.SubprocessError): + return False + + +def _startup_options(extra_args: List[str]) -> tuple: + settings = _startup.read(paths.STATE_ROOT) + enabled = settings["enabled"] and "--no-autostart" not in extra_args + return _startup.merge_args(settings["args"], extra_args), enabled + + +def _configure_autostart(enabled: bool, run_args: List[str]) -> bool: + """Refresh OS registration on every install/repair, honoring an opt-out.""" + try: + if enabled: + ok = _helpers.dispatch_per_platform( + win=_install_windows, mac=_install_macos, linux=_install_linux + )(_build_run_args(run_args)) + else: + ok = _helpers.dispatch_per_platform( + win=_uninstall_windows, mac=_uninstall_macos, linux=_uninstall_linux + )(stop=False) + if not ok: + print( + "Automatic startup could not be updated; installation is not complete." + ) + return False + _startup.write(paths.STATE_ROOT, enabled, run_args) + return True + except (OSError, subprocess.SubprocessError) as exc: + print(f"Could not update automatic startup: {exc}") + return False + + +def cmd_autostart(args: List[str]) -> bool: + if not args or args[0] not in ("enable", "disable", "status"): + print("Usage: python craftbot.py autostart enable|disable|status [options]") + return False + action = args[0] + if action == "status": + print( + "Automatic startup at login: " + + ("enabled" if _is_installed() else "disabled") + ) + return True + try: + settings = _startup.read(paths.STATE_ROOT) + except (ValueError, OSError): + # An explicit command can repair corrupt preference data. + settings = {"args": []} + run_args = _startup.merge_args(settings["args"], args[1:]) + if not _configure_autostart(action == "enable", run_args): + return False + print( + f"Automatic startup {'enabled' if action == 'enable' else 'disabled'} for future logins." + ) + return True def _remove_legacy_agent_bundle(target_dir: str) -> None: @@ -1526,6 +1634,7 @@ def _full_install_frozen( if not IS_FROZEN: raise RuntimeError("_full_install_frozen called outside frozen mode") + extra_args, auto_start = _startup_options(extra_args) target_dir = os.path.normpath(target_dir) from app import paths, provision @@ -1643,15 +1752,13 @@ def _full_install_frozen( except OSError as e: print(f" (could not copy icon: {e})") - # 5. Register auto-start. - run_args = _build_run_args(extra_args, service_mode=True) - _helpers.dispatch_per_platform( - win=_install_windows, mac=_install_macos, linux=_install_linux - )(run_args) - - # 6. Start. + # Start before registering RunAtLoad on macOS, as in source installs. if not cmd_start(extra_args): raise RuntimeError("CraftBot installed but failed to start.") + if not _configure_autostart(auto_start, extra_args): + raise RuntimeError( + "CraftBot started but automatic startup could not be configured." + ) def cmd_install(extra_args: List[str]) -> bool: @@ -1667,6 +1774,7 @@ def cmd_install(extra_args: List[str]) -> bool: _full_install_frozen(target_dir, extra_args) return True + extra_args, auto_start = _startup_options(extra_args) _warn_path_issues() # ── Step 1: Install dependencies via install.py ──────────────────────── install_script = os.path.join(BASE_DIR, "install.py") @@ -1757,21 +1865,10 @@ def cmd_install(extra_args: List[str]) -> bool: print() # ── Step 3: Register auto-start ──────────────────────────────────────── - if _is_installed(): - print( - f"\n {DIM}▸ STEP 3/3 ░░ AUTO-START ALREADY REGISTERED — SKIPPING{RESET}" - ) - if _PLATFORM == "win32": - _create_desktop_shortcut_windows() - elif _PLATFORM != "darwin": - _create_desktop_shortcut_unix() - else: - _retro_step(3, 3, "Registering auto-start") - run_args = _build_run_args(extra_args, service_mode=True) - _helpers.dispatch_per_platform( - win=_install_windows, mac=_install_macos, linux=_install_linux - )(run_args) - print() + _retro_step(3, 3, "Updating automatic startup") + if not _configure_autostart(auto_start, extra_args): + return False + print() print(f"\n {GREEN}▸{RESET} {WHITE}CRAFTBOT IS RUNNING IN THE BACKGROUND{RESET}") print(f" {DIM}░░{RESET} {ORANGE}{_frontend_url(extra_args)}{RESET}") @@ -2094,6 +2191,10 @@ def main() -> None: if not cmd_install(rest): sys.exit(1) + elif command == "autostart": + if not cmd_autostart(rest): + sys.exit(1) + elif command == "uninstall": cmd_uninstall() diff --git a/installer/startup.py b/installer/startup.py new file mode 100644 index 00000000..04f84f57 --- /dev/null +++ b/installer/startup.py @@ -0,0 +1,135 @@ +"""Persistent login preferences and service definitions (stdlib only).""" + +import json +import os +import subprocess +import tempfile +import xml.etree.ElementTree as ET +from pathlib import Path + +PORT_FLAGS = ("--frontend-port", "--backend-port", "--agent-app-port") +MODE_FLAGS = ("--cli", "--conda", "--no-conda", "--dev-ui") + + +def read(state_root) -> dict: + try: + data = json.loads( + (Path(state_root) / "startup.json").read_text(encoding="utf-8") + ) + except FileNotFoundError: + return {"enabled": True, "args": []} + if ( + not isinstance(data, dict) + or not isinstance(data.get("enabled"), bool) + or not isinstance(data.get("args"), list) + or not all(isinstance(arg, str) for arg in data["args"]) + ): + raise ValueError( + "Invalid startup.json; use 'craftbot.py autostart enable' or 'disable' to reset it" + ) + return data + + +def saved_args(args) -> list: + """Only runtime choices persist; e.g. --no-open-browser is per invocation.""" + result = [] + it = iter(args) + for arg in it: + if arg in PORT_FLAGS: + value = next(it, None) + if value is not None: + result.extend([arg, value]) + elif arg.split("=", 1)[0] in PORT_FLAGS or arg in MODE_FLAGS: + result.append(arg) + return result + + +def merge_args(saved, extra) -> list: + """Reuse installed ports during Repair; explicit arguments override them.""" + replaced = {arg.split("=", 1)[0] for arg in extra} + if replaced.intersection(("--conda", "--no-conda")): + replaced.update(("--conda", "--no-conda")) + result = [] + it = iter(saved_args(saved)) + for arg in it: + value = next(it, None) if arg in PORT_FLAGS else None + if arg.split("=", 1)[0] not in replaced: + result.append(arg) + if value is not None: + result.append(value) + return result + [arg for arg in extra if arg != "--no-autostart"] + + +def write(state_root, enabled: bool, args) -> None: + root = Path(state_root) + root.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=root, + prefix=".startup-", + suffix=".json", + delete=False, + ) as f: + temporary = f.name + json.dump({"enabled": enabled, "args": saved_args(args)}, f, indent=2) + try: + os.replace(temporary, root / "startup.json") + finally: + Path(temporary).unlink(missing_ok=True) + + +def windows_task_xml(command, directory: str, user: str) -> str: + """No battery restrictions, time limit, elevation, or cross-user login trigger.""" + task = ET.Element( + "Task", + version="1.2", + xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task", + ) + + def child(parent, name, text=None, **attrs): + element = ET.SubElement(parent, name, **attrs) + element.text = text + return element + + trigger = child(child(task, "Triggers"), "LogonTrigger") + child(trigger, "Enabled", "true") + child(trigger, "UserId", user) + principal = child(child(task, "Principals"), "Principal", id="Author") + child(principal, "UserId", user) + child(principal, "LogonType", "InteractiveToken") + child(principal, "RunLevel", "LeastPrivilege") + settings = child(task, "Settings") + for name, value in ( + ("MultipleInstancesPolicy", "IgnoreNew"), + ("DisallowStartIfOnBatteries", "false"), + ("StopIfGoingOnBatteries", "false"), + ("StartWhenAvailable", "true"), + ("AllowStartOnDemand", "true"), + ("Enabled", "true"), + ("ExecutionTimeLimit", "PT0S"), + ): + child(settings, name, value) + action = child(child(task, "Actions", Context="Author"), "Exec") + child(action, "Command", command[0]) + child(action, "Arguments", subprocess.list2cmdline(command[1:])) + child(action, "WorkingDirectory", directory) + return ET.tostring(task, encoding="unicode") + + +def systemd_quote(value: str, *, command=False) -> str: + # Unit-file quoting is not shell quoting. Escape specifiers, and disable + # environment expansion in ExecStart arguments by doubling dollars. + value = value.replace("%", "%%") + if command: + value = value.replace("$", "$$") + return json.dumps(value, ensure_ascii=False) + + +def systemd_path(value: str) -> str: + # WorkingDirectory is one literal path, not a word list like ExecStart. + # systemd does not strip quotes here: a quoted path fails the absolute + # path check. Spaces are literal; only unit specifiers need escaping. + if "\n" in value or "\r" in value: + raise ValueError("systemd working directories cannot contain newlines") + return value.replace("%", "%%") diff --git a/launcher/Cargo.lock b/launcher/Cargo.lock index 7874c37f..7c550bdb 100644 --- a/launcher/Cargo.lock +++ b/launcher/Cargo.lock @@ -932,6 +932,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -945,9 +955,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "core-graphics-types", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -958,7 +968,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "libc", ] @@ -985,6 +995,7 @@ dependencies = [ "flate2", "fs4", "libc", + "native-tls", "open", "rfd", "serde", @@ -1516,6 +1527,15 @@ dependencies = [ "yeslogic-fontconfig-sys", ] +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1523,7 +1543,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -1537,6 +1557,12 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -2947,6 +2973,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe 0.2.2", + "openssl-sys", + "schannel", + "security-framework 3.7.0", + "security-framework-sys", + "tempfile", +] + [[package]] name = "natord" version = "1.0.9" @@ -3513,12 +3556,55 @@ dependencies = [ "libc", ] +[[package]] +name = "openssl" +version = "0.10.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4de8111e94a56adca7ebfe6c365b6d42f99deb69b74b6becacd4204e2f193aa2" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "openssl-probe" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +[[package]] +name = "openssl-probe" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1" + +[[package]] +name = "openssl-sys" +version = "0.9.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19c413d6ec9ebaf5e1c829d355cb3e9f783778d700f135be0921d52fce3c60ae" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -4318,11 +4404,11 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" dependencies = [ - "openssl-probe", + "openssl-probe 0.1.6", "rustls-pemfile", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 2.11.1", ] [[package]] @@ -4434,7 +4520,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ "bitflags 2.13.1", - "core-foundation", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -5363,6 +5462,7 @@ checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" dependencies = [ "base64", "log", + "native-tls", "once_cell", "rustls", "rustls-native-certs", @@ -5448,6 +5548,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -6043,7 +6149,7 @@ dependencies = [ "calloop 0.13.0", "cfg_aliases", "concurrent-queue", - "core-foundation", + "core-foundation 0.9.4", "core-graphics", "cursor-icon", "dpi", diff --git a/launcher/Cargo.toml b/launcher/Cargo.toml index 31f4b6fb..0c0f9db1 100644 --- a/launcher/Cargo.toml +++ b/launcher/Cargo.toml @@ -15,10 +15,8 @@ path = "src/main.rs" [dependencies] # UI. Slint draws its own widgets, so nothing needs to be present on the -# user's machine. Both renderers are enabled: femtovg (GPU) is the default -# and the software renderer is the fallback for machines with no usable -# OpenGL — Windows Sandbox, most VMs, some remote desktops. That fallback is -# the difference between "opens a window" and "does nothing when clicked". +# user's machine. Software rendering is the default so a basic display +# driver is sufficient. femtovg remains available as an explicit override. slint = { version = "1.17", default-features = false, features = [ "std", "compat-1-2", @@ -29,9 +27,9 @@ slint = { version = "1.17", default-features = false, features = [ ] } # HTTP, blocking. All downloads run on a worker thread, so a synchronous -# client is the simplest correct choice. rustls: no OpenSSL to find or ship. -# native-certs: trust the OS certificate store rather than a bundled list, so -# a corporate TLS-inspecting proxy whose CA is installed on the machine works. +# client is the simplest correct choice. Unix uses rustls with the OS trust +# store; Windows selects Schannel below, including its automatic root updates. +# Both honor installed corporate CAs and require no bundled OpenSSL library. ureq = { version = "2.12", default-features = false, features = ["tls", "native-certs", "json"] } serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -48,6 +46,8 @@ dirs = "6" fs4 = "0.13" [target.'cfg(windows)'.dependencies] +native-tls = "0.2" +ureq = { version = "2.12", default-features = false, features = ["native-tls"] } windows-sys = { version = "0.59", features = [ "Win32_Foundation", "Win32_System_Threading", diff --git a/launcher/README.md b/launcher/README.md index 4dd6209a..c05a87ea 100644 --- a/launcher/README.md +++ b/launcher/README.md @@ -50,6 +50,14 @@ State is read the same way `craftbot.py` writes it in source mode: `craftbot.pid` beside `craftbot.py`, and `.agent-ready` under the user data directory for "running and actually serving" versus "still starting". +Install and Repair refresh login startup through `craftbot.py install`, using +the current interpreter, source directory and saved ports. Automatic startup +defaults to enabled; `craftbot.py autostart disable` opts out without uninstalling +or stopping the current session, and `autostart enable` restores it. The choice +is kept in `startup.json` under the user data directory and survives Repair. +Desktop shortcuts only open the browser. Login startup writes its own PID and +logs, so the launcher can also recognize an agent started after a reboot. + ### Files the launcher owns | | Windows | macOS | Linux | @@ -71,7 +79,13 @@ cargo test Rust 1.85+ (`rustup` installs it). No system libraries are needed on Windows or macOS; on Linux the runtime uses the system's X11/Wayland and OpenGL via -`dlopen`, with a software renderer as fallback. +`dlopen`; the setup window uses software rendering by default. + +Linux release binaries are built on Ubuntu 22.04 to keep their glibc +requirements compatible with that release and newer distributions. Avoid +building release artifacts on `ubuntu-latest`, which can silently raise the +minimum glibc version. Minimal X11 desktops also need `libxkbcommon-x11-0` +(in addition to their display server and a browser). The CraftBot version is baked in at build time: `CRAFTBOT_VERSION=1.4.0 cargo build --release`, or a `VERSION` file at the repo root, or "latest" @@ -105,12 +119,12 @@ This is how CI can exercise the real pipeline on a runner without a display, and how a support case can be reproduced from a terminal. It is not a user CLI — `craftbot.py` is that. -### Rendering fallback +### Rendering -Slint tries the GPU renderer first and falls back to its software renderer -where OpenGL is unavailable (Windows Sandbox, most VMs, some remote -desktops). To force one: `SLINT_BACKEND=winit-software` or -`SLINT_BACKEND=winit-femtovg`. +The setup window uses Slint's software renderer by default, including on +Windows with the basic display driver, virtual machines, and remote desktops. +An OpenGL driver is not required. `SLINT_BACKEND=winit-femtovg` explicitly +selects GPU rendering; `SLINT_BACKEND=winit-software` selects software rendering. ## How it is wired into the repo @@ -125,7 +139,7 @@ desktops). To force one: `SLINT_BACKEND=winit-software` or shortcut so `uninstall` on a managed install does not pip-uninstall from a sidecar the launcher is about to delete. - `scripts/package_source.py` excludes `launcher/` from `CraftBot-src.zip` - and requires `installer/{helpers,metadata,payload}.py`, which + and requires `installer/{helpers,metadata,payload,startup}.py`, which `craftbot.py` imports at module scope. - The Tk installer (`installer/ui/`, `installer/wizard.py`, `installer/api.py`, `packaging/`) and the PyInstaller hooks (`hooks/`, diff --git a/launcher/packaging/release.yml b/launcher/packaging/release.yml index 758d6fc0..4a56032f 100644 --- a/launcher/packaging/release.yml +++ b/launcher/packaging/release.yml @@ -142,7 +142,8 @@ jobs: fail-fast: false matrix: include: - - os: ubuntu-latest + # Keep the installer compatible with glibc 2.35 (Ubuntu 22.04+). + - os: ubuntu-22.04 os_label: linux - os: windows-latest os_label: windows @@ -170,6 +171,7 @@ jobs: uses: Swatinem/rust-cache@v2 with: workspaces: launcher + prefix-key: native-${{ matrix.os }} # Slint's Linux backend loads these at runtime through dlopen, and its # build has no C dependencies — this is insurance for the few crates diff --git a/launcher/src/download.rs b/launcher/src/download.rs index a997661a..57e3225e 100644 --- a/launcher/src/download.rs +++ b/launcher/src/download.rs @@ -10,20 +10,29 @@ const USER_AGENT: &str = concat!("CraftBotInstaller/", env!("CARGO_PKG_VERSION") pub type Progress<'a> = &'a mut dyn FnMut(u64, Option); -fn agent() -> ureq::Agent { - ureq::AgentBuilder::new() +fn agent() -> Result { + let builder = ureq::AgentBuilder::new() .user_agent(USER_AGENT) .timeout_connect(Duration::from_secs(30)) // Reads are bounded per call, not per download: a 100 MB file on a // slow link legitimately takes minutes. .timeout_read(Duration::from_secs(120)) - .redirects(10) - .build() + .redirects(10); + // A fresh Windows installation populates trusted roots on demand. + // Reading its current store into rustls misses that Windows chain-building + // step and rejects legitimate GitHub downloads with UnknownIssuer. + // Schannel retains the OS certificate and hostname validation policies. + #[cfg(windows)] + let builder = builder.tls_connector(std::sync::Arc::new( + native_tls::TlsConnector::new() + .map_err(|e| format!("cannot initialize Windows TLS: {e}"))?, + )); + Ok(builder.build()) } /// Fetch a small JSON document (a GitHub release listing). pub fn get_json(url: &str) -> Result { - let resp = agent() + let resp = agent()? .get(url) .set("Accept", "application/vnd.github+json") .call() @@ -36,7 +45,7 @@ pub fn get_json(url: &str) -> Result { /// download is never mistaken for a finished one. `progress` is called with /// (bytes so far, total if the server said). pub fn to_file(url: &str, dest: &Path, progress: Progress) -> Result<(), String> { - let resp = agent().get(url).call().map_err(|e| describe(url, e))?; + let resp = agent()?.get(url).call().map_err(|e| describe(url, e))?; let total = resp .header("Content-Length") .and_then(|v| v.trim().parse::().ok()); diff --git a/launcher/src/main.rs b/launcher/src/main.rs index 23d2ec0c..cb8b9128 100644 --- a/launcher/src/main.rs +++ b/launcher/src/main.rs @@ -81,6 +81,18 @@ fn main() { std::process::exit(headless(&args[1..])); } + // A basic Windows display driver can create a window but fail later + // when femtovg requests OpenGL symbols. Slint's automatic fallback does + // not catch that event-loop failure. This small setup UI needs no GPU. + let mut backend = slint::BackendSelector::new(); + if std::env::var_os("SLINT_BACKEND").is_none() { + backend = backend.renderer_name("software".into()); + } + if let Err(e) = backend.select() { + logger::log(&format!("could not initialize the window backend: {e}")); + std::process::exit(1); + } + let ui = match InstallerWindow::new() { Ok(ui) => ui, Err(e) => { @@ -179,6 +191,7 @@ fn main() { logger::log("entering event loop"); if let Err(e) = ui.run() { logger::log(&format!("event loop ended with error: {e}")); + std::process::exit(1); } logger::log("window closed"); } diff --git a/launcher/src/python.rs b/launcher/src/python.rs index 33511602..1fe308ee 100644 --- a/launcher/src/python.rs +++ b/launcher/src/python.rs @@ -8,9 +8,9 @@ //! module puts it. When install.py later runs its own `python` stage it //! finds this one already there and accepts it; nothing is downloaded twice. //! -//! The release tag, patch version and platform triple are resolved from the -//! GitHub API rather than hardcoded, for the reason recorded in runtimes.py: -//! a hardcoded asset name went stale and produced a silent 404. +//! Asset URLs are resolved from GitHub release metadata, including the final +//! Python 3.10 release when the newest release no longer carries that version. +//! This mirrors app/provision/runtimes.py and its dependency-lock constraint. use crate::download; use crate::logger; @@ -23,6 +23,26 @@ pub const TARGET: (u32, u32) = (3, 10); const PBS_API: &str = "https://api.github.com/repos/astral-sh/python-build-standalone/releases/latest"; +const PBS_310_API: &str = + "https://api.github.com/repos/astral-sh/python-build-standalone/releases/tags/20261009"; +const PBS_310_ARCHIVE: &str = "https://github.com/astral-sh/python-build-standalone/releases/download/20261009/cpython-3.10.22%2B20261009"; + +fn archived_url(triple: &str) -> Option { + if TARGET == (3, 10) + && matches!( + triple, + "x86_64-pc-windows-msvc" + | "x86_64-unknown-linux-gnu" + | "aarch64-unknown-linux-gnu" + | "x86_64-apple-darwin" + | "aarch64-apple-darwin" + ) + { + Some(format!("{PBS_310_ARCHIVE}-{triple}-install_only.tar.gz")) + } else { + None + } +} /// The interpreter to run CraftBot with, downloading it if needed. pub fn ensure( @@ -130,29 +150,117 @@ fn triple() -> Option { } fn resolve_url(triple: &str) -> Result { - let data = download::get_json(PBS_API) - .map_err(|e| format!("could not reach the Python download index: {e}"))?; + resolve_url_with(triple, download::get_json) +} + +fn resolve_url_with( + triple: &str, + mut fetch: impl FnMut(&str) -> Result, +) -> Result { let prefix = format!("cpython-{}.{}.", TARGET.0, TARGET.1); let suffix = format!("-{triple}-install_only.tar.gz"); - let assets = data - .get("assets") - .and_then(|a| a.as_array()) - .cloned() - .unwrap_or_default(); - for asset in &assets { - let name = asset.get("name").and_then(|n| n.as_str()).unwrap_or(""); - if name.starts_with(&prefix) && name.ends_with(&suffix) { - if let Some(url) = asset.get("browser_download_url").and_then(|u| u.as_str()) { - return Ok(url.to_string()); + let mut indexes = vec![PBS_API]; + if TARGET == (3, 10) { + indexes.push(PBS_310_API); + } + for index in indexes { + let data = match fetch(index) { + Ok(data) => data, + Err(error) => { + // GitHub API quotas are shared by public egress IP. Assets + // use a separate endpoint, so metadata throttling must not + // strand a fresh install whose compatible build is known. + if let Some(url) = archived_url(triple) { + logger::log(&format!( + "Python index unavailable ({error}); using archived 3.10.22 download" + )); + return Ok(url); + } + return Err(format!( + "could not reach the Python download index: {error}" + )); + } + }; + if let Some(assets) = data.get("assets").and_then(|a| a.as_array()) { + for asset in assets { + let name = asset.get("name").and_then(|n| n.as_str()).unwrap_or(""); + if name.starts_with(&prefix) && name.ends_with(&suffix) { + if let Some(url) = asset.get("browser_download_url").and_then(|u| u.as_str()) { + if !url.is_empty() { + return Ok(url.to_string()); + } + } + } } } } - let tag = data.get("tag_name").and_then(|t| t.as_str()).unwrap_or("?"); Err(format!( - "no {prefix}*{suffix} in python-build-standalone release {tag}" + "no {prefix}*{suffix} in the checked python-build-standalone releases" )) } +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn api_rate_limit_does_not_block_known_python_downloads() { + for triple in [ + "x86_64-pc-windows-msvc", + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu", + "x86_64-apple-darwin", + "aarch64-apple-darwin", + ] { + let url = + resolve_url_with(triple, |_| Err("HTTP 403 rate limit exceeded".into())).unwrap(); + assert_eq!( + url, + format!("{PBS_310_ARCHIVE}-{triple}-install_only.tar.gz") + ); + } + assert!(resolve_url_with("unsupported-target", |_| Err("HTTP 403".into())).is_err()); + } + + #[test] + fn resolves_retired_python_from_last_supported_release() { + for triple in [ + "x86_64-pc-windows-msvc", + "x86_64-unknown-linux-gnu", + "x86_64-apple-darwin", + "aarch64-apple-darwin", + ] { + let mut requests = Vec::new(); + let url = resolve_url_with(triple, |index| { + requests.push(index.to_string()); + Ok(if index == PBS_API { + json!({"assets": []}) + } else { + json!({"assets": [{ + "name": format!("cpython-3.10.22+20261009-{triple}-install_only.tar.gz"), + "browser_download_url": "https://example.test/compatible-python.tar.gz" + }]}) + }) + }) + .unwrap(); + assert_eq!(requests, [PBS_API, PBS_310_API]); + assert_eq!(url, "https://example.test/compatible-python.tar.gz"); + } + } + + #[test] + fn does_not_substitute_a_different_python_version() { + let result = resolve_url_with("x86_64-unknown-linux-gnu", |_| { + Ok(json!({"assets": [{ + "name": "cpython-3.11.0+20261110-x86_64-unknown-linux-gnu-install_only.tar.gz", + "browser_download_url": "https://example.test/wrong-python.tar.gz" + }]})) + }); + assert!(result.is_err()); + } +} + /// Extract a .tar.gz, preserving permissions — the executable bit on /// bin/python3 is the whole point on macOS and Linux. fn extract_tar_gz(archive: &std::path::Path, dest: &std::path::Path) -> Result<(), String> { diff --git a/run.py b/run.py index 3ffdc2ca..5991b287 100644 --- a/run.py +++ b/run.py @@ -33,18 +33,28 @@ import atexit from typing import Tuple, Optional, Dict, Any, List -from app.runtime_preflight import ( +# Internal service argument: apply before importing any app module, because +# app.paths resolves STATE_ROOT at import time. Keep it out of the agent's +# arguments; interpreter handoffs inherit the resulting environment. +for _argument in sys.argv[1:]: + if _argument.startswith("--state-root="): + os.environ["CRAFTBOT_HOME"] = _argument.split("=", 1)[1] + sys.argv.remove(_argument) + +from app.runtime_preflight import ( # noqa: E402 ensure_runtime_dependencies, mark_runtime_dependencies_checked, ) -from app import python_runtime +from app import python_runtime # noqa: E402 # Single resolved Node runtime — see app/node_runtime.py. install.py # downloads the sidecar when nothing suitable exists; run.py never installs. -from app import node_runtime -from app.console import configure_console +from app import node_runtime # noqa: E402 +from app.console import configure_console # noqa: E402 -configure_console() +configure_console( + log_file=os.path.join(os.path.dirname(os.path.abspath(__file__)), "craftbot.log") +) multiprocessing.freeze_support() @@ -79,6 +89,8 @@ def _bootstrap_state(): CRAFTBOT_HOME is seeded in a dev checkout too. """ if _paths.CODE_ROOT.resolve() == _paths.STATE_ROOT.resolve(): + # Login tasks do not inherit the installer's working directory. + os.chdir(_paths.STATE_ROOT) return import shutil as _shutil @@ -1484,6 +1496,17 @@ def _patched_init(self, *args, **kwargs): # interpreter (the one the dependencies live in) before doing anything. python_runtime.reexec_if_needed() + # Login services run this script directly, rather than through cmd_start. + # Record their identity too, so status/stop/the installer can find them. + # Consume the internal flag here; it must not reach the agent's CLI. + if "--service" in sys.argv: + sys.argv.remove("--service") + import craftbot + + if not craftbot._claim_service_process(): + sys.exit(0) + atexit.register(craftbot._release_service_process, os.getpid()) + args_list = sys.argv[1:] args = set(args_list) diff --git a/scripts/package_source.py b/scripts/package_source.py index b5560921..703a011e 100644 --- a/scripts/package_source.py +++ b/scripts/package_source.py @@ -147,6 +147,7 @@ def build(output: Path) -> int: "installer/helpers.py", "installer/metadata.py", "installer/payload.py", + "installer/startup.py", ] with zipfile.ZipFile(output) as zf: names = set(zf.namelist()) diff --git a/scripts/test_install_e2e.py b/scripts/test_install_e2e.py index 3e85165f..dae8829d 100644 --- a/scripts/test_install_e2e.py +++ b/scripts/test_install_e2e.py @@ -129,6 +129,7 @@ def main() -> int: # are exec'd from source so their absence is silent until called. required = [ "run.py", + "installer/startup.py", "app/provision/__init__.py", "app/data/action/read_pdf.py", "craftos_integrations/providers/whatsapp_web/bridge.js", diff --git a/tests/test_autostart.py b/tests/test_autostart.py new file mode 100644 index 00000000..2fdfea41 --- /dev/null +++ b/tests/test_autostart.py @@ -0,0 +1,432 @@ +"""Login startup must work without the installer's console or environment.""" + +import json +import os +import plistlib +import shutil +import subprocess +import sys +import textwrap +import xml.etree.ElementTree as ET +from pathlib import Path + +import pytest + +import craftbot +from installer import startup + + +@pytest.fixture(autouse=True) +def configured_context_window(): + """Override the suite's model fixture: startup must need only the stdlib.""" + + +@pytest.fixture(autouse=True) +def isolated_startup(tmp_path, monkeypatch): + monkeypatch.setattr(craftbot.paths, "STATE_ROOT", tmp_path) + monkeypatch.setattr(craftbot, "BASE_DIR", str(tmp_path)) + monkeypatch.setattr(craftbot, "RUN_SCRIPT", str(tmp_path / "run.py")) + monkeypatch.setattr(craftbot, "PID_FILE", str(tmp_path / "craftbot.pid")) + monkeypatch.setattr(craftbot, "LOG_FILE", str(tmp_path / "craftbot.log")) + monkeypatch.setattr(craftbot, "_find_desktop", lambda: None) + monkeypatch.setattr(craftbot, "_close_console_window", lambda: None) + monkeypatch.setattr(craftbot, "_warn_path_issues", lambda: None) + monkeypatch.setattr(craftbot.time, "sleep", lambda seconds: None) + + +@pytest.mark.parametrize("enabled", [True, False]) +def test_install_refreshes_registration_and_preserves_opt_out( + tmp_path, monkeypatch, enabled +): + startup.write(tmp_path, enabled, ["--frontend-port", "8123", "--backend-port=8124"]) + events = [] + monkeypatch.setattr( + craftbot, "cmd_start", lambda args: events.append(("start", args)) or True + ) + # An existing registration is no reason to skip refreshing it. + monkeypatch.setattr(craftbot, "_is_installed", lambda: True) + monkeypatch.setattr( + craftbot, + "_configure_autostart", + lambda on, args: events.append((on, args)) or True, + ) + assert craftbot.cmd_install(["--no-open-browser"]) + assert events == [ + ( + "start", + ["--frontend-port", "8123", "--backend-port=8124", "--no-open-browser"], + ), + ( + enabled, + ["--frontend-port", "8123", "--backend-port=8124", "--no-open-browser"], + ), + ] + + +def test_fresh_install_defaults_to_automatic_startup(monkeypatch): + choices = [] + monkeypatch.setattr(craftbot, "cmd_start", lambda args: True) + monkeypatch.setattr( + craftbot, "_configure_autostart", lambda on, args: choices.append(on) or True + ) + assert craftbot.cmd_install([]) + assert choices == [True] + + +def test_install_opt_out_and_registration_failure_are_not_silent(monkeypatch): + choices = [] + monkeypatch.setattr(craftbot, "cmd_start", lambda args: True) + monkeypatch.setattr( + craftbot, + "_configure_autostart", + lambda on, args: choices.append((on, args)) or False, + ) + assert not craftbot.cmd_install(["--no-autostart"]) + assert choices == [(False, [])] + + +def test_enable_disable_persist_without_stopping_current_session(tmp_path, monkeypatch): + events = [] + + def dispatch(**handlers): + if "_install_windows" in getattr(handlers["win"], "__name__", ""): + return lambda args: events.append(("enable", args)) or True + return lambda **kwargs: events.append(("disable", kwargs)) or True + + monkeypatch.setattr(craftbot._helpers, "dispatch_per_platform", dispatch) + assert craftbot.cmd_autostart(["disable", "--frontend-port", "8123"]) + assert startup.read(tmp_path) == { + "enabled": False, + "args": ["--frontend-port", "8123"], + } + assert events[0] == ("disable", {"stop": False}) + assert craftbot.cmd_autostart(["enable"]) + assert events[1] == ("enable", ["--frontend-port", "8123", "--no-open-browser"]) + assert startup.read(tmp_path)["enabled"] is True + + +def test_repair_port_override_does_not_keep_old_port(): + assert startup.merge_args( + ["--frontend-port", "8123", "--backend-port=8124", "--conda"], + ["--frontend-port=9123", "--no-conda", "--no-autostart"], + ) == ["--backend-port=8124", "--frontend-port=9123", "--no-conda"] + + +def test_startup_failure_does_not_persist_success(tmp_path, monkeypatch): + monkeypatch.setattr( + craftbot._helpers, "dispatch_per_platform", lambda **kw: lambda args: False + ) + assert not craftbot.cmd_autostart(["enable"]) + assert not (tmp_path / "startup.json").exists() + + +def test_windows_registration_replaces_task_and_removes_registry_duplicate( + tmp_path, monkeypatch +): + monkeypatch.setattr(craftbot, "_PLATFORM", "win32") + python = str(tmp_path / "Python & tools" / "pythonw.exe") + monkeypatch.setattr(craftbot, "_python_exe", lambda: python) + definitions = [] + + def run(cmd, **kwargs): + if cmd[0] == "whoami": + return subprocess.CompletedProcess( + cmd, 0, '"domain\\user","S-1-5-21-123"\n' + ) + assert cmd[:2] == ["schtasks", "/create"] + definitions.append( + ET.fromstring(Path(cmd[cmd.index("/xml") + 1]).read_text(encoding="utf-16")) + ) + return subprocess.CompletedProcess(cmd, 0) + + cleaned = [] + monkeypatch.setattr(craftbot.subprocess, "run", run) + monkeypatch.setattr( + craftbot, "_remove_windows_registry", lambda: cleaned.append(True) or True + ) + assert craftbot._install_windows(["--frontend-port", "8123"]) + root = definitions[0] + + def value(path): + return root.findtext("/".join("{*}" + part for part in path.split("/"))) + + assert value("Actions/Exec/Command") == python + assert value("Actions/Exec/WorkingDirectory") == str(tmp_path) + arguments = value("Actions/Exec/Arguments") + assert "--service" in arguments and "--no-open-browser" in arguments + assert "--frontend-port 8123" in arguments + assert value("Triggers/LogonTrigger/UserId") == "S-1-5-21-123" + assert value("Settings/DisallowStartIfOnBatteries") == "false" + assert value("Settings/StopIfGoingOnBatteries") == "false" + assert value("Settings/ExecutionTimeLimit") == "PT0S" + assert value("Settings/StartWhenAvailable") == "true" + assert cleaned == [True] + + +def test_windows_registry_fallback_replaces_old_task_first(monkeypatch): + events = [] + + def run(cmd, **kwargs): + events.append(cmd[:2]) + if cmd[0] == "whoami": + return subprocess.CompletedProcess(cmd, 0, '"user","S-1-5-21-123"') + return subprocess.CompletedProcess(cmd, 1 if "/create" in cmd else 0) + + monkeypatch.setattr(craftbot.subprocess, "run", run) + monkeypatch.setattr( + craftbot, + "_install_windows_registry", + lambda action: events.append(action) or True, + ) + assert craftbot._install_windows([]) + assert events[-2] == ["schtasks", "/delete"] + assert "--service" in events[-1] + assert "--no-open-browser" in events[-1] + + +@pytest.mark.parametrize("platform", ["linux", "darwin"]) +def test_unix_registration_quotes_paths_and_keeps_ports( + tmp_path, monkeypatch, platform +): + home = tmp_path / "Home & space" + home.mkdir() + monkeypatch.setattr(craftbot, "_PLATFORM", platform) + monkeypatch.setattr( + craftbot.os.path, "expanduser", lambda path: str(home / path.removeprefix("~/")) + ) + monkeypatch.setattr(craftbot, "_python_exe", lambda: "/Python & tools/python3") + monkeypatch.setattr( + craftbot.subprocess, + "run", + lambda cmd, **kw: subprocess.CompletedProcess(cmd, 0), + ) + if platform == "darwin": + assert craftbot._install_macos(["--frontend-port", "8123"]) + data = plistlib.loads( + (home / "Library/LaunchAgents/com.craftbot.agent.plist").read_bytes() + ) + assert data["ProgramArguments"] == [ + "/Python & tools/python3", + craftbot.RUN_SCRIPT, + "--frontend-port", + "8123", + "--no-open-browser", + "--service", + f"--state-root={tmp_path.resolve()}", + ] + assert data["RunAtLoad"] is True + else: + assert craftbot._install_linux(["--frontend-port", "8123"]) + content = (home / ".config/systemd/user/craftbot.service").read_text() + assert 'ExecStart="/Python & tools/python3"' in content + assert '"--service"' in content and '"8123"' in content + assert craftbot._uninstall_linux(stop=False) + assert not (home / ".config/systemd/user/craftbot.service").exists() + + +def test_service_pid_does_not_replace_running_app(tmp_path, monkeypatch): + monkeypatch.setattr(craftbot, "_owned_pid", lambda: 123) + assert not craftbot._claim_service_process() + assert not Path(craftbot.PID_FILE).exists() + + +def test_service_pid_is_published_and_cleared(): + assert craftbot._claim_service_process() + assert craftbot._owned_pid() == os.getpid() + craftbot._release_service_process(os.getpid()) + assert not Path(craftbot.PID_FILE).exists() + + +@pytest.mark.skipif( + sys.platform != "linux" or not shutil.which("systemd-analyze"), + reason="requires systemd's real unit parser", +) +def test_generated_linux_unit_is_accepted_by_systemd(tmp_path, monkeypatch): + home = tmp_path / "Home with spaces" + home.mkdir() + monkeypatch.setattr( + craftbot.os.path, "expanduser", lambda path: str(home / path.removeprefix("~/")) + ) + monkeypatch.setattr(craftbot, "_python_exe", lambda: sys.executable) + real_run = subprocess.run + monkeypatch.setattr( + craftbot.subprocess, + "run", + lambda cmd, **kw: subprocess.CompletedProcess(cmd, 0), + ) + assert craftbot._install_linux([]) + unit = home / ".config/systemd/user/craftbot.service" + result = real_run( + ["systemd-analyze", "verify", str(unit)], + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0, result.stderr + + +@pytest.mark.skipif( + sys.platform != "win32", reason="requires actual pythonw.exe handles" +) +def test_cold_pythonw_startup_logs_and_survives_interpreter_handoff(tmp_path): + pythonw = Path(sys.executable).with_name("pythonw.exe") + if not pythonw.exists(): + pytest.skip("pythonw.exe is not installed") + repo = Path(__file__).resolve().parents[1] + (tmp_path / "config.json").write_text( + json.dumps({"python_executable": sys.executable}) + ) + probe = tmp_path / "startup_probe.py" + probe.write_text( + textwrap.dedent("""\ + import json, os, pathlib, sys + sys.path.insert(0, sys.argv[1]) + work = pathlib.Path(sys.argv[2]) + from app import python_runtime + from app.console import configure_console + if not os.environ.get("CRAFTBOT_PY_REEXEC"): + (work / "initial.json").write_text(json.dumps({"stdout_missing": sys.stdout is None})) + configure_console(log_file=str(work / "startup.log")) + python_runtime.CONFIG_FILE = str(work / "config.json") + python_runtime.reexec_if_needed() + print("HANDOFF COMPLETE", flush=True) + (work / "result.json").write_text(json.dumps({"python": sys.executable})) + """), + encoding="utf-8", + ) + env = { + k: v + for k, v in os.environ.items() + if not k.startswith(("CRAFTBOT_", "CONDA_")) + and k not in ("PYTHONPATH", "VIRTUAL_ENV") + } + proc = subprocess.run( + [str(pythonw), str(probe), str(repo), str(tmp_path)], + cwd=os.environ["WINDIR"], + env=env, + close_fds=True, + creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NO_WINDOW, + timeout=30, + ) + assert proc.returncode == 0 + assert json.loads((tmp_path / "initial.json").read_text())["stdout_missing"] is True + assert ( + json.loads((tmp_path / "result.json").read_text())["python"] == sys.executable + ) + assert "HANDOFF COMPLETE" in (tmp_path / "startup.log").read_text() + + +@pytest.mark.skipif(sys.platform != "win32", reason="requires Windows shortcuts") +def test_real_windows_shortcut_refreshes_url_without_launching_agent( + tmp_path, monkeypatch +): + desktop = tmp_path / "Desktop O'Hare $literal" + desktop.mkdir() + monkeypatch.setattr(craftbot, "_find_desktop", lambda: str(desktop)) + monkeypatch.setattr(craftbot, "_ensure_ico", lambda: None) + craftbot._create_desktop_shortcut_windows([]) + craftbot._create_desktop_shortcut_windows(["--frontend-port", "8123"]) + link = desktop / "CraftBot.lnk" + inspect = tmp_path / "inspect.ps1" + inspect.write_text( + "$ws = New-Object -ComObject WScript.Shell\n" + f"$s = $ws.CreateShortcut({craftbot._ps_literal(str(link))})\n" + "$s | Select-Object TargetPath,Arguments | ConvertTo-Json\n", + encoding="utf-8-sig", + ) + result = subprocess.run( + [ + "powershell", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + str(inspect), + ], + capture_output=True, + text=True, + check=True, + ) + shortcut = json.loads(result.stdout) + assert Path(shortcut["TargetPath"]).name.lower() == "cmd.exe" + assert shortcut["Arguments"] == '/c start "" http://localhost:8123' + + +@pytest.mark.skipif(sys.platform != "win32", reason="requires pythonw.exe") +@pytest.mark.parametrize("relocated_state", [False, True]) +def test_real_run_service_entry_from_unrelated_working_directory( + tmp_path, monkeypatch, relocated_state +): + """Run the actual entry point, stopping at preflight before real servers start.""" + repo = Path(__file__).resolve().parents[1] + tree = tmp_path / "installed CraftBot" + for relative in ( + "run.py", + "craftbot.py", + "app/__init__.py", + "app/paths.py", + "app/console.py", + "app/python_runtime.py", + "app/runtime_preflight.py", + "app/node_runtime.py", + "installer/__init__.py", + "installer/helpers.py", + "installer/metadata.py", + "installer/payload.py", + "installer/startup.py", + ): + dest = tree / relative + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(repo / relative, dest) + # A disposable source installation: no user data or real app dependencies. + (tree / "install.py").touch() + (tree / "requirements.txt").touch() + pythonw = Path(sys.executable).with_name("pythonw.exe") + (tree / "config.json").write_text(json.dumps({"python_executable": str(pythonw)})) + (tree / "craftbot.pid").write_text('{"pid": 2147483647, "started": 1}') + state = tmp_path / "relocated user data" if relocated_state else tree + monkeypatch.setattr(craftbot.paths, "STATE_ROOT", state) + monkeypatch.setattr(craftbot, "RUN_SCRIPT", str(tree / "run.py")) + monkeypatch.setattr(craftbot, "_python_exe", lambda: str(pythonw)) + command = craftbot._autostart_launch([]) + runner = tmp_path / "entry_probe.py" + runner.write_text( + textwrap.dedent("""\ + import json, os, pathlib, runpy, sys + tree = pathlib.Path(sys.argv[1]) + state = pathlib.Path(sys.argv[2]) + command = sys.argv[3:] + sys.path.insert(0, str(tree)) + from app import runtime_preflight + def reached_preflight(**kwargs): + import craftbot + assert craftbot._owned_pid() == os.getpid() + assert "--service" not in sys.argv + assert pathlib.Path.cwd() == state + print("SERVICE ENTRY OK", flush=True) + (tree / "preflight-reached").touch() + raise SystemExit(0) + runtime_preflight.ensure_runtime_dependencies = reached_preflight + sys.argv = command + runpy.run_path(str(tree / "run.py"), run_name="__main__") + """), + encoding="utf-8", + ) + env = { + k: v + for k, v in os.environ.items() + if not k.startswith(("CRAFTBOT_", "CONDA_")) + and k not in ("PYTHONPATH", "VIRTUAL_ENV") + } + child = subprocess.run( + [str(pythonw), str(runner), str(tree), str(state), *command[1:]], + cwd=os.environ["WINDIR"], + env=env, + close_fds=True, + creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NO_WINDOW, + timeout=30, + ) + assert child.returncode == 0, (tree / "craftbot.log").read_text(encoding="utf-8") + assert (tree / "preflight-reached").exists() + assert not (tree / "craftbot.pid").exists() + assert "SERVICE ENTRY OK" in (tree / "craftbot.log").read_text(encoding="utf-8") diff --git a/tests/test_craftbot_service.py b/tests/test_craftbot_service.py index 8a39e267..0426d2c4 100644 --- a/tests/test_craftbot_service.py +++ b/tests/test_craftbot_service.py @@ -1,5 +1,4 @@ # -*- coding: utf-8 -*- -import shlex import subprocess import sys @@ -8,6 +7,20 @@ import craftbot +@pytest.fixture(autouse=True) +def configured_context_window(): + """Service management runs before the agent's model dependencies exist.""" + + +@pytest.fixture(autouse=True) +def isolated_service_state(tmp_path, monkeypatch): + monkeypatch.setattr(craftbot, "PID_FILE", str(tmp_path / "craftbot.pid")) + monkeypatch.setattr(craftbot, "LOG_FILE", str(tmp_path / "craftbot.log")) + monkeypatch.setattr(craftbot, "_orphan_listeners", lambda args=None: []) + monkeypatch.setattr(craftbot.paths, "STATE_ROOT", tmp_path) + monkeypatch.setattr(craftbot.paths, "AGENT_READY_FILE", tmp_path / ".agent-ready") + + class _ExitedProcess: pid = 12345 @@ -15,6 +28,15 @@ def wait(self, timeout=None): return 1 +@pytest.mark.parametrize("pid", [None, 12345]) +def test_stop_clears_readiness_even_after_a_previous_crash(monkeypatch, pid): + craftbot.paths.AGENT_READY_FILE.write_text("stale process") + monkeypatch.setattr(craftbot, "_owned_pid", lambda: pid) + monkeypatch.setattr(craftbot, "_kill_owned_pid", lambda value: None) + craftbot.cmd_stop(quiet=True) + assert not craftbot.paths.AGENT_READY_FILE.exists() + + class _RunningProcess: pid = 23456 @@ -67,106 +89,33 @@ def fake_popen(*args, **kwargs): assert events == [] -def test_macos_source_shortcut_uses_custom_backend_port(tmp_path, monkeypatch, capsys): - desktop = tmp_path / "Desktop" - desktop.mkdir() - base_dir = tmp_path / "Craft Bot" - base_dir.mkdir() - python_exe = "/Applications/Python 3.10/bin/python3.10" - - monkeypatch.setattr(craftbot, "_PLATFORM", "darwin") - monkeypatch.setattr(craftbot, "IS_FROZEN", False) - monkeypatch.setattr(craftbot, "BASE_DIR", str(base_dir)) - monkeypatch.setattr(craftbot, "_find_desktop", lambda: str(desktop)) - monkeypatch.setattr(craftbot, "_python_exe", lambda: python_exe) - - craftbot._create_desktop_shortcut_unix(["--backend-port", "8123"]) - - shortcut = desktop / "CraftBot.command" - content = shortcut.read_text() - assert f"cd {shlex.quote(str(base_dir))}" in content - assert "curl -fsS http://localhost:7925" in content - assert ( - "backend_status=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8123" - in content - ) - assert '[ "$backend_status" -ge 100 ]' in content - assert '[ "$backend_status" -lt 500 ]' in content - assert "curl -fsS http://localhost:8123" not in content - assert "http://localhost:7926" not in content - assert "open http://localhost:7925" in content - assert ( - f"exec {shlex.quote(python_exe)} craftbot.py start --backend-port 8123" - in content - ) - - output = capsys.readouterr().out - assert "Desktop shortcut created" in output - - -def test_macos_source_shortcut_accepts_equals_backend_port(tmp_path, monkeypatch): - desktop = tmp_path / "Desktop" - desktop.mkdir() - base_dir = tmp_path / "CraftBot" - base_dir.mkdir() - - monkeypatch.setattr(craftbot, "_PLATFORM", "darwin") - monkeypatch.setattr(craftbot, "IS_FROZEN", False) - monkeypatch.setattr(craftbot, "BASE_DIR", str(base_dir)) - monkeypatch.setattr(craftbot, "_find_desktop", lambda: str(desktop)) - monkeypatch.setattr(craftbot, "_python_exe", lambda: "/usr/local/bin/python3.10") - - craftbot._create_desktop_shortcut_unix(["--backend-port=8123"]) - - content = (desktop / "CraftBot.command").read_text() - assert ( - "backend_status=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8123" - in content - ) - assert "craftbot.py start --backend-port=8123" in content - - -def test_shortcut_keeps_agent_app_port(tmp_path, monkeypatch): +@pytest.mark.parametrize( + "platform,filename", [("darwin", "CraftBot.command"), ("linux", "CraftBot.desktop")] +) +@pytest.mark.parametrize("frozen", [False, True]) +def test_unix_shortcuts_only_open_the_configured_url( + tmp_path, monkeypatch, platform, filename, frozen +): desktop = tmp_path / "Desktop" desktop.mkdir() - base_dir = tmp_path / "CraftBot" - base_dir.mkdir() - - monkeypatch.setattr(craftbot, "_PLATFORM", "darwin") - monkeypatch.setattr(craftbot, "IS_FROZEN", False) - monkeypatch.setattr(craftbot, "BASE_DIR", str(base_dir)) + monkeypatch.setattr(craftbot, "_PLATFORM", platform) + monkeypatch.setattr(craftbot, "IS_FROZEN", frozen) monkeypatch.setattr(craftbot, "_find_desktop", lambda: str(desktop)) - monkeypatch.setattr(craftbot, "_python_exe", lambda: "/usr/local/bin/python3.10") - + # Replace an older shortcut which may have started the app itself. + (desktop / filename).write_text("old shortcut: craftbot.py start") craftbot._create_desktop_shortcut_unix( - ["--backend-port", "17926", "--agent-app-port", "13100", "--no-conda"] + ["--frontend-port=8123", "--backend-port", "8124"] ) - - content = (desktop / "CraftBot.command").read_text() - assert "craftbot.py start --backend-port 17926 --agent-app-port 13100" in content - assert "--no-conda" not in content - - -def test_macos_frozen_shortcut_starts_installed_agent_executable(tmp_path, monkeypatch): - desktop = tmp_path / "Desktop" - desktop.mkdir() - base_dir = tmp_path / "CraftBot" - base_dir.mkdir() - installed_agent = "/Applications/CraftBot/CraftBot Agent" - - monkeypatch.setattr(craftbot, "_PLATFORM", "darwin") - monkeypatch.setattr(craftbot, "IS_FROZEN", True) - monkeypatch.setattr(craftbot, "BASE_DIR", str(base_dir)) - monkeypatch.setattr(craftbot, "_find_desktop", lambda: str(desktop)) - monkeypatch.setattr(craftbot, "_python_exe", lambda: "/bad/python") - monkeypatch.setattr(craftbot, "_installed_launch", lambda: [installed_agent]) - - craftbot._create_desktop_shortcut_unix(["--frontend-port", "9000"]) - - content = (desktop / "CraftBot.command").read_text() - assert f"exec {shlex.quote(installed_agent)} --frontend-port 9000" in content - assert "craftbot.py start" not in content - assert "/bad/python" not in content + content = (desktop / filename).read_text() + assert "http://localhost:8123" in content + assert "7925" not in content + assert "craftbot.py" not in content + assert "run.py" not in content + assert "curl" not in content + if platform == "darwin": + assert content == "#!/bin/sh\nexec open http://localhost:8123\n" + else: + assert "Exec=" in content def test_start_ignores_stale_ready_marker_when_child_exits( diff --git a/tests/test_native_runtime.py b/tests/test_native_runtime.py new file mode 100644 index 00000000..e2c8b057 --- /dev/null +++ b/tests/test_native_runtime.py @@ -0,0 +1,34 @@ +"""Recognize the Windows runtime installed by Microsoft's redistributable.""" + +from contextlib import nullcontext +import sys +from types import SimpleNamespace + +import pytest + +from app.provision import verify + + +@pytest.fixture(autouse=True) +def configured_context_window(): + """The OS prerequisite probe needs no model configuration.""" + + +@pytest.mark.parametrize("installed", [0, 1]) +def test_vcredist_reads_microsoft_runtime_registration(monkeypatch, installed): + registration = r"SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64" + key = object() + + def open_key(hive, path): + if hive != "HKLM" or path != registration: + raise FileNotFoundError(path) + return nullcontext(key) + + monkeypatch.setitem(sys.modules, "winreg", SimpleNamespace( + HKEY_LOCAL_MACHINE="HKLM", + OpenKey=open_key, + QueryValueEx=lambda handle, name: (installed, 4), + )) + monkeypatch.setattr(verify.sys, "platform", "win32") + monkeypatch.setattr(verify.os.path, "isfile", lambda path: False) + assert verify.vcredist_installed() is bool(installed) diff --git a/tests/test_python_downloads.py b/tests/test_python_downloads.py new file mode 100644 index 00000000..4c11689c --- /dev/null +++ b/tests/test_python_downloads.py @@ -0,0 +1,94 @@ +"""A newer upstream release must not strand installs pinned to Python 3.10.""" + +import io +import json +import urllib.request +import urllib.error + +import pytest + +from app.provision import runtimes + + +@pytest.fixture(autouse=True) +def configured_context_window(): + """Download selection needs no model configuration.""" + + +@pytest.mark.parametrize("triple", sorted(runtimes.PBS_310_TRIPLES)) +def test_api_rate_limit_uses_known_release_asset(monkeypatch, triple): + monkeypatch.setattr(runtimes, "_pbs_triple", lambda: triple) + + def rate_limited(request, **kwargs): + raise urllib.error.HTTPError( + request.full_url, 403, "rate limit exceeded", {}, None + ) + + monkeypatch.setattr(urllib.request, "urlopen", rate_limited) + assert runtimes._pbs_download_url(lambda message: None) == ( + f"{runtimes.PBS_310_ARCHIVE}-{triple}-install_only.tar.gz" + ) + + +def test_unavailable_index_never_falls_back_to_wrong_python_version(monkeypatch): + monkeypatch.setattr(runtimes, "TARGET_PYTHON", (3, 11)) + monkeypatch.setattr(runtimes, "_pbs_triple", lambda: "x86_64-unknown-linux-gnu") + monkeypatch.setattr( + urllib.request, + "urlopen", + lambda *a, **kw: (_ for _ in ()).throw(OSError("offline")), + ) + assert runtimes._pbs_download_url(lambda message: None) is None + + +@pytest.mark.parametrize( + "triple", + [ + "x86_64-pc-windows-msvc", + "x86_64-unknown-linux-gnu", + "x86_64-apple-darwin", + "aarch64-apple-darwin", + ], +) +def test_retired_python_uses_last_supported_release(monkeypatch, triple): + monkeypatch.setattr(runtimes, "_pbs_triple", lambda: triple) + requests = [] + + def fetch(request, **kwargs): + requests.append(request.full_url) + assets = ( + [] + if request.full_url == runtimes.PBS_API + else [ + { + "name": f"cpython-3.10.22+20261009-{triple}-install_only.tar.gz", + "browser_download_url": "https://example.test/compatible-python.tar.gz", + } + ] + ) + return io.BytesIO(json.dumps({"assets": assets}).encode()) + + monkeypatch.setattr(urllib.request, "urlopen", fetch) + assert ( + runtimes._pbs_download_url(lambda message: None) + == "https://example.test/compatible-python.tar.gz" + ) + assert requests == [runtimes.PBS_API, runtimes.PBS_310_API] + + +def test_missing_matching_python_does_not_select_a_different_version(monkeypatch): + data = { + "assets": [ + { + "name": "cpython-3.11.0+20261110-x86_64-unknown-linux-gnu-install_only.tar.gz", + "browser_download_url": "https://example.test/wrong-python.tar.gz", + } + ] + } + monkeypatch.setattr(runtimes, "_pbs_triple", lambda: "x86_64-unknown-linux-gnu") + monkeypatch.setattr( + urllib.request, + "urlopen", + lambda *a, **kw: io.BytesIO(json.dumps(data).encode()), + ) + assert runtimes._pbs_download_url(lambda message: None) is None