Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ jobs:
dotnet new console --output "$RUNNER_TEMP/dotnet-warmup" --no-restore
dotnet build "$RUNNER_TEMP/dotnet-warmup" --nologo
fi
- name: Custom installer API integration tests
if: ${{ !cancelled() }}
run: python -m unittest discover -s scripts/install -v
- name: Unit, integration, and documentation tests
if: ${{ !cancelled() }}
run: |
Expand Down
4 changes: 2 additions & 2 deletions Engine/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

76 changes: 76 additions & 0 deletions Engine/apps/editor/src/project_build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(), "/?") {
Expand Down Expand Up @@ -805,6 +806,49 @@ 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())?;
let hooks = root.join("scripts/install/hooks");
if hooks.is_dir() {
copy_tree(&hooks, &directory.join("hooks"), &[])?;
}
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" };
Expand Down Expand Up @@ -901,6 +945,38 @@ mod tests {
);
}
}
#[test]
fn custom_installer_export_preserves_design_and_hook_files() {
let project = tempfile::tempdir().unwrap();
let output = tempfile::tempdir().unwrap();
export_custom_installer(project.path(), output.path(), &Settings::default()).unwrap();
let bundle = output.path().join("scripts/install");
let config: serde_json::Value =
serde_json::from_slice(&fs::read(bundle.join("installer.json")).unwrap()).unwrap();
assert_eq!(config["payload"], "../../game");
assert!(bundle.join("server.py").is_file());
assert!(bundle.join("index.html").is_file());

let custom = project.path().join("scripts/install");
fs::create_dir_all(custom.join("hooks/nested")).unwrap();
fs::write(custom.join("hooks/nested/setup.py"), "print('setup')").unwrap();
fs::write(custom.join("installer.json"), r#"{"title":"Custom"}"#).unwrap();
fs::write(custom.join("index.html"), "<h1>Custom</h1>").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 {
Expand Down
2 changes: 1 addition & 1 deletion Engine/crates/engine-scripting/src/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
201 changes: 201 additions & 0 deletions Engine/docs/CUSTOM_INSTALLER.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 16 additions & 0 deletions Engine/scripts/install/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en"><meta charset="utf-8"><title>Custom installer</title>
<style>body{font:16px system-ui;max-width:48rem;margin:2rem auto;padding:1rem}label{display:block;margin:1rem 0}input[type=text]{width:95%}progress{width:100%}pre{white-space:pre-wrap;max-height:16rem;overflow:auto}button{margin:.5rem;padding:.5rem}</style>
<h1 id="title">Installer</h1><label>Session token <input id="token" type="password"></label><button id="connect">Connect</button>
<main id="screen"></main><p id="error" role="alert"></p><progress id="progress" max="100" value="0"></progress><p id="status" aria-live="polite"></p><pre id="logs"></pre>
<script>
const el=id=>document.getElementById(id);let config,page=0,timer;
async function api(route,data){const response=await fetch('/scripts/install/'+route,{method:data?'POST':'GET',headers:{Authorization:'Bearer '+el('token').value,'Content-Type':'application/json'},body:data?JSON.stringify(data):undefined});const result=await response.json();if(!response.ok)throw Error(result.error);return result;}
function button(text,action){const b=document.createElement('button');b.textContent=text;b.onclick=()=>Promise.resolve(action()).catch(showError);el('screen').append(b);}
function showError(e){el('error').textContent=e.message;}
function render(){el('screen').replaceChildren();const screen=config.screens[page];const title=document.createElement('h2');title.textContent=screen.title;el('screen').append(title);const text=document.createElement('p');text.textContent=screen.text||'';el('screen').append(text);
if(screen.kind==='options')for(const option of config.options||[]){const label=document.createElement('label'),input=document.createElement('input');input.type='checkbox';input.checked=option.default||false;input.onchange=async()=>{try{await api('configure',{options:{[option.id]:input.checked}});option.default=input.checked;}catch(e){input.checked=option.default||false;showError(e);}};label.append(input,document.createTextNode(option.label));el('screen').append(label);}
if(screen.kind==='location'){const input=document.createElement('input');input.type='text';input.value=config.location;input.disabled=config.allowLocationChange===false;input.onchange=async()=>{try{await api('configure',{location:input.value});config.location=input.value;}catch(e){input.value=config.location;showError(e);}};el('screen').append(input);}
if(page>0)button('Back',()=>{page--;render();});if(page<config.screens.length-1)button('Next',()=>{page++;render();});else button('Install',()=>api('start',{}));button('Cancel installation',()=>api('cancel',{}));}
el('connect').onclick=async()=>{try{config=await api('config');el('title').textContent=config.title;document.body.style.maxWidth=Math.max(320,Math.min(config.window?.width||768,1920))+'px';document.body.style.minHeight=Math.max(200,Math.min(config.window?.height||480,1080))+'px';render();clearInterval(timer);timer=setInterval(async()=>{try{const s=await api('status');el('progress').value=s.percentage;el('status').textContent=s.status+' — '+s.percentage+'% '+(s.currentTask||'');el('logs').textContent=s.logs.join('\n');if(s.error)showError(Error(s.error));}catch(e){showError(e);clearInterval(timer);}},500);}catch(e){showError(e);}};
</script></html>
Loading
Loading