").unwrap();
+ export_custom_installer(project.path(), output.path(), &Settings::default()).unwrap();
+ assert_eq!(
+ fs::read(custom.join("installer.json")).unwrap(),
+ fs::read(bundle.join("installer.json")).unwrap()
+ );
+ assert_eq!(
+ fs::read(custom.join("index.html")).unwrap(),
+ fs::read(bundle.join("index.html")).unwrap()
+ );
+ assert_eq!(
+ fs::read(custom.join("hooks/nested/setup.py")).unwrap(),
+ fs::read(bundle.join("hooks/nested/setup.py")).unwrap()
+ );
+ }
+
#[test]
fn installer_includes_designed_pages_and_per_user_installation() {
let settings = Settings {
diff --git a/Engine/crates/engine-scripting/src/workspace.rs b/Engine/crates/engine-scripting/src/workspace.rs
index d1676ee0..4445a9a2 100644
--- a/Engine/crates/engine-scripting/src/workspace.rs
+++ b/Engine/crates/engine-scripting/src/workspace.rs
@@ -284,7 +284,7 @@ description: Work on the {project_name} Rustic Game Engine project; use when ask
# Rustic workspace
-Use `workspace_info` to confirm the project root, then `read_agent_file` with path `AGENTS.md` or `CLAUDE.md` for repository routing. Use `list_agent_files`, `read_agent_file`, and `write_agent_file` to inspect or edit project agent information stored in Rustic editor app data. Use `scene_summary` rather than guessing the contents of a `.scene` file. Keep game code and content within this project unless the user explicitly asks to modify the engine source.
+Use `workspace_info` to confirm the project root, then `read_agent_file` with path `AGENTS.md` or `CLAUDE.md` for repository routing. Use `list_agent_files`, `read_agent_file`, and `write_agent_file` to inspect or edit project agent information stored in Rustic editor app data. Use `scene_summary` rather than guessing the contents of a `.scene` file. Keep game code and content within this project unless the user explicitly asks to modify the engine source. Windows exports include a Python 3.10+ custom installer API at /scripts/install (HTTP routes). Customize the project scripts/install/installer.json and index.html before exporting; read CUSTOM_INSTALLER.md for routes, token authentication, options, and limitations. Installer configuration is separate from gameplay scripts. Installer tasks run author-defined command arrays beforeCopy or afterCopy, read one JSON context line from stdin, and emit JSON log/percentage events to stdout; bundle their files under scripts/install/hooks. They require their own CLI runtimes or precompiled executables and do not use gameplay callbacks.
"
);
let skill = with_authoring_guide(&skill);
diff --git a/Engine/docs/CUSTOM_INSTALLER.md b/Engine/docs/CUSTOM_INSTALLER.md
new file mode 100644
index 00000000..eb1b5571
--- /dev/null
+++ b/Engine/docs/CUSTOM_INSTALLER.md
@@ -0,0 +1,201 @@
+# Custom installer API
+
+Windows game exports include `scripts/install/server.py`, `installer.json`, and
+`index.html` alongside the `game` payload. This is an additional installer path;
+the existing Inno Setup wizard remains available. The host requires Python 3.10+
+on the end user's computer. It is not a standalone executable or an uninstaller.
+
+## Design and launch
+
+Export a Windows game from the editor Build window. To customize future exports,
+put `installer.json` and/or `index.html` in your **project's** `scripts/install/`
+directory and export again. These two files override the bundled defaults. The project's `scripts/install/hooks/` directory is also copied recursively into the export.
+Installer files are not gameplay behaviors and need no scene attachment.
+
+A complete configuration example:
+
+```json
+{
+ "title": "My Game",
+ "payload": "../../game",
+ "location": "~/My Game",
+ "allowLocationChange": true,
+ "window": { "width": 800, "height": 600 },
+ "screens": [
+ { "kind": "welcome", "title": "Welcome", "text": "Install My Game" },
+ { "kind": "location", "title": "Choose a folder" },
+ { "kind": "options", "title": "Select components" },
+ { "kind": "install", "title": "Ready" }
+ ],
+ "options": [
+ { "id": "extras", "label": "Install bonus content", "default": false, "pathPrefix": "extras" }
+ ]
+}
+```
+
+`payload` is relative to the exported `scripts/install` directory. Options are
+boolean; an unchecked option excludes its `pathPrefix` directory or file from
+the payload. Options without a prefix are selections exposed to your UI only.
+All other payload files are installed. Supply an empty `options` array if none
+are needed. Screen titles/text, order, and content are editable. The example UI
+uses width and height for page layout; browsers control their own window size.
+Native UI clients can use these values to size their windows.
+
+From the export directory, run:
+
+```sh
+python scripts/install/server.py
+```
+
+The host prints one JSON line containing a loopback `url` and a random `token`.
+Open that URL, paste the token into the example UI, and click Connect. The port
+is chosen automatically; `--port 8765` selects a fixed port. Keep the host running
+until installation finishes. Ctrl+C closes it. Never publish or log the token.
+
+## HTTP contract
+
+All API requests require `Authorization: Bearer TOKEN`. Responses are JSON.
+The host binds only to `127.0.0.1`. Browser clients should be served from its `/`
+page; cross-origin browser access is intentionally unavailable. Replace
+`index.html` with bundled HTML/CSS/JavaScript (inline assets), or start the host
+as a child process from a C#, C++, Python, or other native UI and parse its first
+stdout line. No particular UI toolkit is required.
+
+| Method | Route | Result |
+| --- | --- | --- |
+| GET | `/scripts/install/config` | Screen design, window size, option definitions and defaults |
+| GET | `/scripts/install/status` | `status`, `percentage`, `currentTask`, `location`, `options`, `logs`, `error` |
+| GET | `/scripts/install/logs` | Most recent 1,000 file/error messages |
+| POST | `/scripts/install/configure` | Set `location` and/or boolean `options` before starting |
+| POST | `/scripts/install/start` | Start background installation once |
+| POST | `/scripts/install/cancel` | Request cancellation between files or stop the active task |
+
+For example, from C# with an `HttpClient` whose BaseAddress is the printed URL:
+
+```csharp
+client.DefaultRequestHeaders.Authorization =
+ new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
+var status = await client.GetStringAsync("scripts/install/status");
+var body = new System.Net.Http.StringContent(
+ "{\"options\":{\"extras\":true}}", System.Text.Encoding.UTF8, "application/json");
+var configured = await client.PostAsync("scripts/install/configure", body);
+configured.EnsureSuccessStatusCode();
+var started = await client.PostAsync("scripts/install/start", null);
+started.EnsureSuccessStatusCode();
+```
+
+Poll status while installing. Progress measures completed files and tasks, rather than
+bytes; `currentTask` names the file being copied or the running task ID. Status is `ready`, `installing`,
+`completed`, `cancelled`, or `failed`. Configuration locks after start. HTTP 400
+means invalid configuration/state; 401 means a missing/wrong token.
+
+## Limits and troubleshooting
+
+Installation copies files into the selected folder with the current user's
+permissions. File copying does not install runtimes, create shortcuts, register uninstallers,
+or elevate privileges. Author-defined tasks can perform additional work with
+the current user's permissions. Existing files are
+never overwritten; choose a fresh folder if installation reports a collision.
+Cancellation/failure can leave partial files or task side effects, with no rollback or resume. Logs
+and state are session-only; save API responses in your UI if persistence is needed.
+Symlink payloads and destination paths escaping the install folder are rejected.
+Use a trusted payload and a destination under your control.
+
+If the host cannot start, verify Python's version, the JSON configuration, and
+that `../../game` exists relative to `installer.json`. If Connect fails, use the
+URL and token from the current process, rather than a previous session. A failed
+copy's `error` field reports the filesystem failure; check folder permissions and
+existing files. Custom native UI frameworks must be shipped by the UI author.
+
+## Script installer logic in other languages
+
+Use `tasks` in `installer.json` for installer logic, independently of your UI.
+Tasks execute in array order within each phase: `beforeCopy`, then file copying,
+then `afterCopy` (the default). Each task has an `id`, a `command` argument array,
+an optional `timeoutSeconds` (default 300, maximum 86400), and an optional `option`
+ID that gates execution. Arguments run directly without shell expansion.
+Commands come from the packaged configuration; HTTP clients cannot submit commands.
+
+```json
+{
+ "tasks": [
+ {
+ "id": "prepare",
+ "phase": "beforeCopy",
+ "command": ["python", "hooks/prepare.py"],
+ "timeoutSeconds": 60
+ },
+ {
+ "id": "configure-extras",
+ "phase": "afterCopy",
+ "option": "extras",
+ "command": ["dotnet", "hooks/ConfigureExtras.dll"]
+ }
+ ]
+}
+```
+
+Add this property to the complete configuration above. Put task sources and
+binaries under your project's `scripts/install/hooks/` before exporting. Commands
+run with the exported `scripts/install/` as their working directory. The first
+stdin line is a JSON object containing `location`, `options`, `bundle`, `payload`,
+and `task`. The task may read this to decide what to install or configure.
+
+Python example (`hooks/prepare.py`):
+
+```python
+import json
+import sys
+from pathlib import Path
+
+context = json.loads(sys.stdin.readline())
+print(json.dumps({"log": "Preparing destination", "percentage": 10}), flush=True)
+Path(context["location"]).mkdir(parents=True, exist_ok=True)
+print(json.dumps({"log": "Preparation complete", "percentage": 100}), flush=True)
+```
+
+C# example (compile and publish as `hooks/ConfigureExtras.dll`, including its
+runtime configuration and dependencies):
+
+```csharp
+using System;
+using System.IO;
+using System.Text.Json;
+
+using var context = JsonDocument.Parse(Console.ReadLine()!);
+var location = context.RootElement.GetProperty("location").GetString()!;
+File.WriteAllText(Path.Combine(location, "extras-configured.txt"), "enabled");
+Console.WriteLine(JsonSerializer.Serialize(new { log = "Extras configured", percentage = 100 }));
+```
+
+Plain stdout/stderr text also becomes a log message. JSON stdout lines can contain
+`log` and a task-local `percentage` from 0 to 100; the host combines task progress
+with copying progress. Each file and task has equal weight. Exit code zero succeeds;
+a nonzero code or timeout fails installation and stops later tasks. Missing
+executables also fail with an error in status. Task output is read in chunks of
+up to 8 KiB and retained in the bounded session log.
+
+Language choices use ordinary executables:
+
+| Language | Task command example | Requirement |
+| --- | --- | --- |
+| Python | `["python", "hooks/setup.py"]` | Python runtime |
+| JavaScript | `["node", "hooks/setup.mjs"]` | Node.js runtime |
+| Lua | `["lua", "hooks/setup.lua"]` | Standalone Lua CLI |
+| Luau | `["luau", "hooks/setup.luau"]` | Standalone Luau CLI and any needed JSON/I/O adapter |
+| C# | `["dotnet", "hooks/Setup.dll"]` | .NET runtime, or publish a standalone executable |
+| Java | `["java", "-jar", "hooks/setup.jar"]` | Java runtime |
+| PHP | `["php", "hooks/setup.php"]` | PHP CLI |
+| C / C++ | `["hooks/setup.exe"]` | Precompiled executable and its dependencies |
+
+These are installer processes, separate from engine scripting. Bundled gameplay
+Lua, Luau, and JavaScript hosts do not supply general installer runtimes or the
+gameplay API here. Compile C/C++/C#/Java code before distributing, or explicitly
+supply a build tool command if the installer machine has its SDK. Other languages
+with a CLI can follow the same protocol. HTML/CSS continue to provide presentation;
+JavaScript or a task process supplies logic.
+
+Cancellation kills the direct active task process and prevents subsequent work;
+child processes spawned by the task are its responsibility. Tasks run as the
+current user and can modify files, so distribute trusted task code. Shutting down
+the host while a task is active does not guarantee cleanup of that process.
diff --git a/Engine/scripts/install/index.html b/Engine/scripts/install/index.html
new file mode 100644
index 00000000..4990ff23
--- /dev/null
+++ b/Engine/scripts/install/index.html
@@ -0,0 +1,16 @@
+
+Custom installer
+
+
Installer
+
+
diff --git a/Engine/scripts/install/server.py b/Engine/scripts/install/server.py
new file mode 100644
index 00000000..399dc804
--- /dev/null
+++ b/Engine/scripts/install/server.py
@@ -0,0 +1,283 @@
+"""Language-neutral custom installer host. Python 3.10+, standard library only."""
+import argparse
+import hmac
+import json
+from pathlib import Path
+import secrets
+import shutil
+import subprocess
+import time
+import threading
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+
+
+class Installer:
+ def __init__(self, bundle):
+ self.bundle = Path(bundle).resolve()
+ self.config = json.loads((self.bundle / 'installer.json').read_text(encoding='utf-8'))
+ self.payload = (self.bundle / self.config.get('payload', '../game')).resolve()
+ if not self.payload.is_dir():
+ raise ValueError('Payload directory does not exist')
+ self.lock = threading.RLock()
+ self.cancel = threading.Event()
+ self.state = dict(status='ready', percentage=0, currentTask=None,
+ location=str(Path(self.config['location']).expanduser().absolute()),
+ options={o['id']: o.get('default', False) for o in self.config.get('options', [])},
+ logs=[], error=None)
+
+ def snapshot(self):
+ with self.lock:
+ return json.loads(json.dumps(self.state))
+
+ def configure(self, values):
+ with self.lock:
+ if self.state['status'] != 'ready':
+ raise ValueError('Configuration is locked after installation starts')
+ if set(values) - {'location', 'options'}:
+ raise ValueError('Unknown configuration field')
+ location = self.state['location']
+ if 'location' in values:
+ if not self.config.get('allowLocationChange', True):
+ raise ValueError('Location changes are disabled')
+ if not isinstance(values['location'], str) or not values['location'].strip():
+ raise ValueError('Location must be a nonempty absolute path')
+ path = Path(values['location']).expanduser()
+ if not path.is_absolute():
+ raise ValueError('Location must be absolute')
+ destination = path.resolve()
+ if destination == Path(destination.anchor) or destination == self.payload or destination in self.payload.parents or self.payload in destination.parents:
+ raise ValueError('Location must not overlap the payload or be a filesystem root')
+ location = str(destination)
+ options = values.get('options', self.state['options'])
+ if not isinstance(options, dict) or set(options) - self.state['options'].keys() or any(type(v) is not bool for v in options.values()):
+ raise ValueError('Options must contain known IDs and boolean values')
+ self.state.update(location=location, options={**self.state['options'], **options})
+
+ def start(self):
+ with self.lock:
+ if self.state['status'] != 'ready':
+ raise ValueError('Installation has already started')
+ # Validate the default location as well as user changes.
+ location = self.state['location']
+ allowed = self.config.get('allowLocationChange', True)
+ self.config['allowLocationChange'] = True
+ try:
+ self.configure({'location': location})
+ finally:
+ self.config['allowLocationChange'] = allowed
+ self.state['status'] = 'installing'
+ threading.Thread(target=self.run, daemon=True).start()
+
+ def run_task(self, task, completed, total):
+ command = task.get('command')
+ if not isinstance(command, list) or not command or any(not isinstance(v, str) for v in command):
+ raise ValueError('Task command must be a nonempty argument array')
+ timeout = float(task.get('timeoutSeconds', 300))
+ if not 0 < timeout <= 86400:
+ raise ValueError('Task timeout must be between 0 and 86400 seconds')
+ with self.lock:
+ self.state['currentTask'] = task['id']
+ context = dict(location=self.state['location'], options=self.state['options'],
+ bundle=str(self.bundle), payload=str(self.payload), task=task['id'])
+ def read_output(pipe):
+ for raw in iter(lambda: pipe.readline(8192), b''):
+ line = raw.decode('utf-8', errors='replace').rstrip()
+ with self.lock:
+ try:
+ event = json.loads(line)
+ if not isinstance(event, dict):
+ raise ValueError('Expected event object')
+ if isinstance(event.get('percentage'), (int, float)):
+ fraction = max(0, min(100, event['percentage'])) / 100
+ progress = round((completed + fraction) * 100 / total, 2)
+ self.state['percentage'] = max(self.state['percentage'], progress)
+ message = str(event.get('log', line))
+ except (ValueError, TypeError):
+ message = line
+ self.state['logs'].append(message[:8192])
+ self.state['logs'] = self.state['logs'][-1000:]
+ with subprocess.Popen(command, cwd=self.bundle, stdin=subprocess.PIPE,
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT) as process:
+ reader = threading.Thread(target=read_output, args=(process.stdout,), daemon=True)
+ reader.start()
+ try:
+ try:
+ process.stdin.write((json.dumps(context) + '\n').encode())
+ process.stdin.close()
+ except BrokenPipeError:
+ pass # A task may intentionally ignore its context input.
+ deadline = time.monotonic() + timeout
+ while process.poll() is None:
+ if self.cancel.wait(.05):
+ raise InterruptedError('Installation cancelled')
+ if time.monotonic() >= deadline:
+ raise TimeoutError(f"Task timed out: {task['id']}")
+ reader.join(timeout=1)
+ if process.returncode:
+ raise ValueError(f"Task {task['id']} exited with code {process.returncode}")
+ finally:
+ if process.poll() is None:
+ process.kill()
+ process.wait()
+
+ def run(self):
+ try:
+ tasks = self.config.get('tasks', [])
+ if not isinstance(tasks, list):
+ raise ValueError('Tasks must be an array')
+ for task in tasks:
+ if not isinstance(task, dict) or not isinstance(task.get('id'), str) or task.get('phase', 'afterCopy') not in ('beforeCopy', 'afterCopy'):
+ raise ValueError('Tasks require an id and a beforeCopy/afterCopy phase')
+ if 'option' in task and task['option'] not in self.state['options']:
+ raise ValueError('Task references an unknown option')
+ tasks = [t for t in tasks if 'option' not in t or self.state['options'][t['option']]]
+ destination = Path(self.state['location'])
+ entries = sorted(self.payload.rglob('*'))
+ if any(p.is_symlink() for p in entries):
+ raise ValueError('Payload symlinks are not supported')
+ files = [p for p in entries if p.is_file()]
+ for option in self.config.get('options', []):
+ prefix = option.get('pathPrefix')
+ if prefix:
+ prefix_path = Path(prefix)
+ if prefix_path.is_absolute() or '..' in prefix_path.parts:
+ raise ValueError('Option pathPrefix must be relative to payload')
+ if not self.state['options'][option['id']]:
+ files = [p for p in files if not p.relative_to(self.payload).is_relative_to(prefix_path)]
+ total = max(1, len(files) + len(tasks))
+ completed = 0
+ destination.mkdir(parents=True, exist_ok=True)
+ for task in tasks:
+ if task.get('phase', 'afterCopy') == 'beforeCopy':
+ if self.cancel.is_set():
+ raise InterruptedError('Installation cancelled')
+ self.run_task(task, completed, total)
+ completed += 1
+ with self.lock:
+ self.state['percentage'] = round(completed * 100 / total, 2)
+ for source in files:
+ if self.cancel.is_set():
+ with self.lock:
+ self.state.update(status='cancelled', currentTask=None)
+ return
+ relative = source.relative_to(self.payload)
+ target = destination / relative
+ # Reject existing symlinks/junctions escaping the selected directory.
+ if not target.resolve().is_relative_to(destination.resolve()):
+ raise ValueError('Destination file escapes the installation directory')
+ with self.lock:
+ self.state['currentTask'] = str(relative)
+ target.parent.mkdir(parents=True, exist_ok=True)
+ if target.exists():
+ raise ValueError(f'Destination already exists: {relative}')
+ # Exclusive creation preserves existing user files.
+ with source.open('rb') as src, target.open('xb') as dst:
+ shutil.copyfileobj(src, dst)
+ with self.lock:
+ completed += 1
+ self.state['percentage'] = round(completed * 100 / total, 2)
+ self.state['logs'].append(f'Installed {relative}')
+ self.state['logs'] = self.state['logs'][-1000:]
+ for task in tasks:
+ if task.get('phase', 'afterCopy') == 'afterCopy':
+ if self.cancel.is_set():
+ raise InterruptedError('Installation cancelled')
+ self.run_task(task, completed, total)
+ completed += 1
+ with self.lock:
+ self.state['percentage'] = round(completed * 100 / total, 2)
+ with self.lock:
+ self.state.update(status='completed', percentage=100, currentTask=None)
+ except InterruptedError:
+ with self.lock:
+ self.state.update(status='cancelled', currentTask=None)
+ except Exception as error:
+ with self.lock:
+ self.state.update(status='failed', error=str(error), currentTask=None)
+ self.state['logs'].append(str(error))
+
+
+def serve(installer, port=0):
+ token = secrets.token_urlsafe(32)
+
+ class Handler(BaseHTTPRequestHandler):
+ def log_message(self, *args):
+ pass # Do not log the capability token.
+
+ def reply(self, status, value):
+ body = json.dumps(value).encode()
+ self.send_response(status)
+ self.send_header('Content-Type', 'application/json')
+ self.send_header('Cache-Control', 'no-store')
+ self.send_header('Content-Length', str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def do_GET(self):
+ if self.path == '/':
+ body = (installer.bundle / 'index.html').read_bytes()
+ self.send_response(200)
+ self.send_header('Content-Type', 'text/html; charset=utf-8')
+ self.send_header('Content-Length', str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+ return
+ self.api(False)
+
+ def do_POST(self):
+ self.api(True)
+
+ def api(self, post):
+ if not hmac.compare_digest(self.headers.get('Authorization', ''), 'Bearer ' + token):
+ self.reply(401, {'error': 'Bearer token required'})
+ return
+ route = self.path.removeprefix('/scripts/install')
+ try:
+ if not self.path.startswith('/scripts/install/'):
+ self.reply(404, {'error': 'Unknown route'})
+ return
+ if post:
+ size = int(self.headers.get('Content-Length', '0'))
+ if not 0 <= size <= 65536:
+ raise ValueError('Request exceeds 64 KiB')
+ data = json.loads(self.rfile.read(size) or b'{}')
+ if not isinstance(data, dict):
+ raise ValueError('Expected JSON object')
+ if route == '/configure':
+ installer.configure(data)
+ elif route == '/start':
+ installer.start()
+ elif route == '/cancel':
+ with installer.lock:
+ if installer.state['status'] != 'installing':
+ raise ValueError('No active installation to cancel')
+ installer.cancel.set()
+ else:
+ self.reply(404, {'error': 'Unknown route'})
+ return
+ elif route == '/config':
+ self.reply(200, installer.config)
+ return
+ elif route not in ('/status', '/logs'):
+ self.reply(404, {'error': 'Unknown route'})
+ return
+ state = installer.snapshot()
+ self.reply(200, state['logs'] if route == '/logs' else state)
+ except (ValueError, TypeError) as error:
+ self.reply(400, {'error': str(error)})
+
+ server = ThreadingHTTPServer(('127.0.0.1', port), Handler)
+ return server, token
+
+
+if __name__ == '__main__':
+ parser = argparse.ArgumentParser()
+ parser.add_argument('--bundle', type=Path, default=Path(__file__).parent)
+ parser.add_argument('--port', type=int, default=0)
+ args = parser.parse_args()
+ server, token = serve(Installer(args.bundle), args.port)
+ print(json.dumps({'url': f'http://127.0.0.1:{server.server_port}/', 'token': token}), flush=True)
+ try:
+ server.serve_forever()
+ except KeyboardInterrupt:
+ server.server_close()
diff --git a/Engine/scripts/install/test_server.py b/Engine/scripts/install/test_server.py
new file mode 100644
index 00000000..abfecbba
--- /dev/null
+++ b/Engine/scripts/install/test_server.py
@@ -0,0 +1,129 @@
+import json
+import sys
+from pathlib import Path
+import tempfile
+import threading
+import time
+import unittest
+from urllib.request import Request, urlopen
+from urllib.error import HTTPError
+from server import Installer, serve
+
+
+class InstallerTests(unittest.TestCase):
+ def setUp(self):
+ self.temp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.temp.cleanup)
+ self.root = Path(self.temp.name)
+ (self.root / 'payload/extra').mkdir(parents=True)
+ (self.root / 'payload/game.exe').write_bytes(b'game')
+ (self.root / 'payload/extra/a').write_bytes(b'optional')
+ (self.root / 'installer.json').write_text(json.dumps(dict(
+ payload='payload', location=str(self.root / 'installed'),
+ options=[dict(id='extra', default=False, pathPrefix='extra')]
+ )))
+ self.installer = Installer(self.root)
+
+ def finish(self):
+ self.installer.start()
+ for _ in range(200):
+ state = self.installer.snapshot()
+ if state['status'] != 'installing':
+ return state
+ time.sleep(.01)
+ self.fail('Installer did not finish')
+
+ def test_copy_options_and_progress(self):
+ state = self.finish()
+ self.assertEqual(state['status'], 'completed')
+ self.assertEqual(state['percentage'], 100)
+ self.assertEqual((self.root / 'installed/game.exe').read_bytes(), b'game')
+ self.assertFalse((self.root / 'installed/extra').exists())
+ self.assertTrue(state['logs'])
+ with self.assertRaises(ValueError):
+ self.installer.configure({'location': str(self.root / 'other')})
+ with self.assertRaises(ValueError):
+ self.installer.start()
+
+ def test_no_overwrite(self):
+ (self.root / 'installed').mkdir()
+ (self.root / 'installed/game.exe').write_bytes(b'user')
+ self.assertEqual(self.finish()['status'], 'failed')
+ self.assertEqual((self.root / 'installed/game.exe').read_bytes(), b'user')
+
+ def test_location_validation_and_options(self):
+ for location in ['relative', str(self.root), str(self.root / 'payload/sub')]:
+ with self.assertRaises(ValueError):
+ self.installer.configure({'location': location})
+ self.installer.configure({'location': str(self.root / 'custom'), 'options': {'extra': True}})
+ self.assertEqual(self.finish()['status'], 'completed')
+ self.assertTrue((self.root / 'custom/extra/a').exists())
+
+ def test_script_context_events_and_phase_order(self):
+ script = self.root / 'hook.py'
+ script.write_text("""import json, sys
+from pathlib import Path
+context = json.loads(sys.stdin.readline())
+assert context['options']['extra'] is False
+assert not (Path(context['location']) / 'game.exe').exists()
+(Path(context['location']) / 'hook.txt').write_text(context['task'])
+print(json.dumps({'percentage': 50, 'log': 'Script is working'}), flush=True)
+""")
+ self.installer.config['tasks'] = [dict(id='prepare', phase='beforeCopy',
+ command=[sys.executable, str(script)])]
+ state = self.finish()
+ self.assertEqual(state['status'], 'completed', state['error'])
+ self.assertIn('Script is working', state['logs'])
+ self.assertEqual((self.root / 'installed/hook.txt').read_text(), 'prepare')
+
+ def test_after_copy_and_disabled_task(self):
+ self.installer.config['tasks'] = [
+ dict(id='skipped', option='extra', command=['missing-executable']),
+ dict(id='verify-copy', command=[sys.executable, '-c',
+ "import json,sys;from pathlib import Path;c=json.loads(sys.stdin.readline());assert (Path(c['location'])/'game.exe').read_bytes()==b'game'"])
+ ]
+ state = self.finish()
+ self.assertEqual(state['status'], 'completed', state['error'])
+
+ def test_script_failure_and_timeout(self):
+ for command, timeout in [([sys.executable, '-c', 'raise SystemExit(7)'], 10),
+ ([sys.executable, '-c', 'import time;time.sleep(10)'], .1)]:
+ installer = Installer(self.root)
+ installer.config['tasks'] = [dict(id='failure', phase='beforeCopy',
+ command=command, timeoutSeconds=timeout)]
+ self.installer = installer
+ state = self.finish()
+ self.assertEqual(state['status'], 'failed')
+ self.assertIn('failure', state['error'])
+ self.assertFalse((self.root / 'installed/game.exe').exists())
+
+ def test_script_cancellation(self):
+ self.installer.config['tasks'] = [dict(id='waiting', phase='beforeCopy',
+ command=[sys.executable, '-c', 'import time;time.sleep(10)'])]
+ self.installer.start()
+ for _ in range(200):
+ if self.installer.snapshot()['currentTask'] == 'waiting':
+ break
+ time.sleep(.01)
+ self.installer.cancel.set()
+ for _ in range(200):
+ if self.installer.snapshot()['status'] == 'cancelled':
+ break
+ time.sleep(.01)
+ self.assertEqual(self.installer.snapshot()['status'], 'cancelled')
+
+ def test_authenticated_http(self):
+ server, token = serve(self.installer)
+ self.addCleanup(server.server_close)
+ self.addCleanup(server.shutdown)
+ threading.Thread(target=server.serve_forever, daemon=True).start()
+ url = f'http://127.0.0.1:{server.server_port}/scripts/install/status'
+ with self.assertRaises(HTTPError) as caught:
+ urlopen(url)
+ self.assertEqual(caught.exception.code, 401)
+ with urlopen(Request(url, headers={'Authorization': 'Bearer ' + token})) as response:
+ self.assertEqual(json.load(response)['status'], 'ready')
+
+
+if __name__ == '__main__':
+ unittest.main()