From 65f8b16972a83670f11449945951fe52e2065a34 Mon Sep 17 00:00:00 2001 From: Jamie Wells Date: Mon, 9 Nov 2026 04:26:05 +0000 Subject: [PATCH 1/4] Add customizable installer HTTP API and Windows export bundle --- Engine/apps/editor/src/project_build.rs | 40 ++++ .../crates/engine-scripting/src/workspace.rs | 2 +- Engine/docs/CUSTOM_INSTALLER.md | 107 ++++++++++ Engine/scripts/install/index.html | 16 ++ Engine/scripts/install/server.py | 198 ++++++++++++++++++ Engine/scripts/install/test_server.py | 75 +++++++ 6 files changed, 437 insertions(+), 1 deletion(-) create mode 100644 Engine/docs/CUSTOM_INSTALLER.md create mode 100644 Engine/scripts/install/index.html create mode 100644 Engine/scripts/install/server.py create mode 100644 Engine/scripts/install/test_server.py diff --git a/Engine/apps/editor/src/project_build.rs b/Engine/apps/editor/src/project_build.rs index a9aead84..98f30083 100644 --- a/Engine/apps/editor/src/project_build.rs +++ b/Engine/apps/editor/src/project_build.rs @@ -598,6 +598,7 @@ fn build(root: &Path, output: &Path, settings: &Settings, runtime: &Path) -> Res } } } + export_custom_installer(root, output, settings)?; if settings.installer { let compiler = ensure_installer_compiler(&settings.compiler)?; if !available(&compiler.to_string_lossy(), "/?") { @@ -805,6 +806,45 @@ fn collect_artifacts( Ok(()) } +// Export a language-neutral companion host alongside the Windows game payload. +fn export_custom_installer(root: &Path, output: &Path, settings: &Settings) -> Result<(), String> { + let directory = output.join("scripts/install"); + fs::create_dir_all(&directory).map_err(|e| e.to_string())?; + fs::write( + directory.join("server.py"), + include_str!("../../../scripts/install/server.py"), + ) + .map_err(|e| e.to_string())?; + fs::write( + directory.join("index.html"), + include_str!("../../../scripts/install/index.html"), + ) + .map_err(|e| e.to_string())?; + let config = serde_json::json!({ + "title": settings.name.trim(), "payload": "../../game", + "location": format!("~/{}", settings.name.trim()), "allowLocationChange": true, + "window": {"width": 768, "height": 480}, "options": [], + "screens": [ + {"kind": "welcome", "title": "Welcome", "text": settings.welcome}, + {"kind": "location", "title": "Installation location"}, + {"kind": "options", "title": "Options"}, + {"kind": "install", "title": "Ready to install"} + ] + }); + fs::write( + directory.join("installer.json"), + serde_json::to_vec_pretty(&config).map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string())?; + for name in ["installer.json", "index.html"] { + let custom = root.join("scripts/install").join(name); + if custom.is_file() { + fs::copy(custom, directory.join(name)).map_err(|e| e.to_string())?; + } + } + Ok(()) +} + fn installer_script(settings: &Settings) -> String { let name = settings.name.trim(); let style = if settings.modern { "modern" } else { "classic" }; diff --git a/Engine/crates/engine-scripting/src/workspace.rs b/Engine/crates/engine-scripting/src/workspace.rs index d1676ee0..7513ff81 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. " ); 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..f2520f43 --- /dev/null +++ b/Engine/docs/CUSTOM_INSTALLER.md @@ -0,0 +1,107 @@ +# 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. Only these two files override the bundled defaults. +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 | + +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, rather than +bytes; `currentTask` names the file being copied. 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. It does not execute payload commands, install runtimes, create +shortcuts, register uninstallers, or elevate privileges. Existing files are +never overwritten; choose a fresh folder if installation reports a collision. +Cancellation/failure can leave partial files, 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. diff --git a/Engine/scripts/install/index.html b/Engine/scripts/install/index.html new file mode 100644 index 00000000..9bb70076 --- /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..51b5f2df
--- /dev/null
+++ b/Engine/scripts/install/server.py
@@ -0,0 +1,198 @@
+"""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 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(self):
+        try:
+            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)]
+            destination.mkdir(parents=True, exist_ok=True)
+            for index, source in enumerate(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:
+                    self.state['percentage'] = round((index + 1) * 100 / max(1, len(files)), 2)
+                    self.state['logs'].append(f'Installed {relative}')
+                    self.state['logs'] = self.state['logs'][-1000:]
+            with self.lock:
+                self.state.update(status='completed', percentage=100, 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..1a972d27
--- /dev/null
+++ b/Engine/scripts/install/test_server.py
@@ -0,0 +1,75 @@
+import json
+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_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()

From c08ad221d43c18bce8a18f3f527f7273673be2a2 Mon Sep 17 00:00:00 2001
From: Jamie Wells 
Date: Mon, 9 Nov 2026 04:27:53 +0000
Subject: [PATCH 2/4] Send installer actions as POST requests

---
 Engine/scripts/install/index.html | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/Engine/scripts/install/index.html b/Engine/scripts/install/index.html
index 9bb70076..4990ff23 100644
--- a/Engine/scripts/install/index.html
+++ b/Engine/scripts/install/index.html
@@ -11,6 +11,6 @@ 

Installer