Aether runs each extension backend script in a separate Goja JavaScript runtime. This keeps extension code away from browser and Node.js APIs, but it is not an operating-system security boundary. See Security for what the sandbox does and does not protect.
Warning
The backend runtime is not a browser or Node.js. These APIs are unavailable there:
windowdocumentfetch(unless explicitly provided viaAether.http.get)localStoragerequire()process,fs,child_process(and all other Node.js modules)
Use the injected Aether object to call the launcher APIs your manifest permissions allow.
When Aether runs your extension's main.js, it adds an Aether object to the global scope. The APIs available on that object depend on the permissions in manifest.json.
Allows the extension to register a frontend UI tab.
Aether.ui.registerSidebarPage(options)- options (Object):
id(String): A page identifier unique within the extension.label(String): The page name shown in navigation.url(String): The path to your UI HTML file, relative to the extension root (for example,"ui/index.html").
- Pages appear under Active Extensions by default. Set
"pinToSidebar": truein the extension manifest to give its pages permanent sidebar links. Duplicate labels are qualified with the extension name.
- options (Object):
This permission is kept for compatibility, but Aether.ui.openDialog() is currently a stub. It does not open a launcher dialog.
instances:list allows the extension to query instances. The separate mods:list, mods:install, mods:delete, and mods:toggle permissions control access to files in their mods directories. Sensitive mod operations require confirmation in the launcher UI.
Aether.instances.list()- Returns an array of objects representing all installed instances:
[{ id, name, version, loader }].
- Returns an array of objects representing all installed instances:
Aether.instances.installMod(instanceId, jarName, downloadURL)- instanceId (String): The ID of the instance to modify.
- jarName (String): The filename to save the mod as (e.g.
fabric-api.jar). - downloadURL (String): The URL to download the mod from (must be allowed in
hosts).
Aether.instances.listMods(instanceId)- instanceId (String): The ID of the instance.
- Returns an array of strings representing the filenames in the
modsfolder.
Aether.instances.deleteMod(instanceId, jarName)- Deletes the specified mod file from the instance.
Aether.instances.toggleMod(instanceId, jarName, enable)- enable (Boolean): True to enable, false to disable.
- Disabling a mod renames it to
.jar.disabled. Enabling it renames it back to.jar.
Aether.instances.listWorlds(instanceId)(requiressaves:list)- Returns singleplayer worlds from the instance's
saves/folder:[{ id, name, lastPlayed, gameMode }], most recently played first.namecomes fromlevel.dat; worlds with an unreadablelevel.datstill appear under their folder name.
- Returns singleplayer worlds from the instance's
Aether.instances.launchToServer(instanceId, host, port)(requiresinstances:launch)- Launches the game and auto-connects via the vanilla
--server/--portflags (all versions). Hosts must not contain whitespace or start with-; ports are clamped to 1–65535.
- Launches the game and auto-connects via the vanilla
Aether.instances.launchToWorld(instanceId, world)(requiresinstances:launch)- Launches the game and auto-loads a singleplayer world via Mojang Quick Play (
--quickPlaySingleplayer). Requires Minecraft 1.20+; older instances return an error.worldis the saves folder name, confined tosaves/(traversal rejected) and must contain alevel.dat.
- Launches the game and auto-loads a singleplayer world via Mojang Quick Play (
Allows the extension to register a custom mod loader that Aether can use to launch instances.
Aether.launcher.registerModLoader(config)- config (Object):
id(String): A unique identifier for the loader.name(String): The display name of the loader.description(String): A brief description of the loader.onLaunch(Function): A callback executed when an instance with this loader is launched.
- config (Object):
Allows the extension to write base64 encoded skins to the local filesystem.
Aether.skins.export(base64Data, filename)- base64Data (String): The skin image encoded as a base64 string.
- filename (String): The name to save the skin as (e.g.,
skin.png). Returns the saved file path.
Used by the Skin Selector extension. Tokens never cross the bridge. Go calls
https://api.minecraftservices.com/minecraft/profile with the active
Microsoft account. Offline accounts yield ERR_OFFLINE_ACCOUNT; signed-out
yields ERR_NO_ACCOUNT. Upload / equip / hide require launcher confirmation.
Aether.account.getActive()(requiresaccount:read)- Returns
{ signedIn, id, username, type }. No tokens.
- Returns
Aether.skins.listMine()(requiresskin:manage)- Returns
[{ id, state, url, variant }]from the Mojang profile.
- Returns
Aether.skins.upload(base64Data, variant?)(requiresskin:manage)- base64Data PNG (plain base64 or data URL, max 5 MiB). variant
classic|slim.
- base64Data PNG (plain base64 or data URL, max 5 MiB). variant
Aether.skins.applyUrl(httpsUrl, variant?)(requiresskin:manage)- Downloads an
https://gallery skin in Go, then uploads it.
- Downloads an
Aether.capes.listMine()(requirescape:manage)- Returns
[{ id, state, url, alias }].
- Returns
Aether.capes.equip(capeId)/Aether.capes.hide()(requirescape:manage)
The backend runtime has no network access by default. To make a request, declare the network:http permission and call Aether.http.get(url). Requests must use HTTPS, target a hostname allowed by hosts, and return no more than 10 MiB.
The backend cannot call browser fetch(). The launcher checks requested hosts, but does not currently log or rate-limit these requests.
An extension's frontend iframe can use the browser's normal fetch() behavior. Those requests do not pass through the backend API or its host allow-list.
The backend cannot download files by default.
Aether.fs.download(url, dest)(requiresfs:downloadpermission)- Downloads a file from the given URL (must be allowed in
hosts) into Aether's sharedlibrariesdirectory. This capability also requires HTTPS; mod installation additionally requires a.jarfile of at most 100 MiB.
- Downloads a file from the given URL (must be allowed in
Your frontend UI runs in an <iframe> served by a local HTTP server. Because it's isolated, it cannot call the Aether Go API directly.
To communicate between your UI and the backend Goja sandbox, use the built-in IPC bridge.
You can register a listener using Aether.ui.onMessage. Any data returned by this function is automatically sent back to the frontend UI as a response. You can also push messages down to the frontend UI without a prompt using Aether.ui.postMessage.
// Listen for messages from the frontend
Aether.ui.onMessage((payload) => {
if (payload.action === 'download_mod') {
const path = Aether.instances.installMod(payload.instanceId, payload.jarName, payload.url);
return { status: 'success', path: path }; // Sent back to frontend
}
});
// Push a message to the frontend unconditionally
Aether.ui.postMessage({ type: 'download_progress', percent: 50 });Because the frontend runs in an isolated <iframe>, you use standard Web APIs (window.postMessage) to talk to the bridge, and listen for responses via window.addEventListener('message').
// Send a message to your backend script. Every request carries an
// incrementing requestId; responses echo it back for correlation.
let requestId = 0;
const pending = {};
window.parent.postMessage({
type: 'download_mod',
requestId: ++requestId,
instanceId: 'fabric-1.20',
jarName: 'my-mod.jar',
url: 'https://example.com/mod.jar',
// targetOrigin is "*" on purpose: window.location is the iframe's own
// origin while window.parent is the Wails webview, so a computed
// origin never matches, so messages are silently dropped.
// requestId correlation is the actual boundary.
}, '*');
// Listen for responses or pushed messages from the backend script.
// Backend payloads are forwarded as-is (no marker). Match only on
// requestId, or every reply is dropped.
window.addEventListener('message', (event) => {
const msg = event.data;
if (!msg || msg.requestId == null || !pending[msg.requestId]) return;
if (event.data.status === 'success') {
console.log("Mod downloaded to: ", event.data.path);
}
});graph TD
A[Launcher Starts] --> B[Read manifest.json]
B --> C[Validate manifest]
C --> D[Create Goja runtime]
D --> E[Inject Aether API]
E --> F[Execute main.js]
F --> G[Register UI]
G --> H[Ready]
Future API versions will introduce explicit lifecycle callbacks so your extension can run setup or cleanup logic predictively:
onLoad()onEnable()onDisable()onUnload()onUpdate()
Extensions can subscribe to core launcher events (requires discord:presence or instances:list):
Aether.events.on('instance:state', (evt) => { ... })evtis{ id: string, state: "Running" | "Stopped" | "Crashed" }. Aether fires it when a game process starts or stops.- Example for Rich Presence:
Aether.events.on('instance:state', (e) => { if (e.state === 'Running') { const inst = Aether.instances.list().find(i => i.id === e.id); Aether.discord.setActivity({ details: inst.name, state: `${inst.version} \u2022 ${inst.loader}`, largeImageKey: "aether-logo", smallImageKey: inst.loader, startTimestamp: Date.now() }); } else { Aether.discord.clearActivity(); } });
Aether.events.off('instance:state')removes all handlers for the event.
Requires discord:presence permission. Works only if Discord desktop is running.
Aether.discord.setActivity(opts)opts:{ details, state, largeImageKey, largeText, smallImageKey, smallText, startTimestamp }startTimestampis milliseconds since epoch (Date.now()).
Aether.discord.clearActivity()clears the activity and returns Discord to Idle.
Legacy planned names instance:launch/instance:stop remain supported as aliases for instance:state with Running/Stopped.
Extensions can read the multiplayer server list and manage extension-owned
server directories. Server hosting processes (start/stop) are a planned
v2 addition and are not available yet.
Requires servers:list and/or servers:manage.
Aether.servers.list(instanceId)- Returns the instance's
servers.datentries:[{ name, ip, hidden, hasIcon }].
- Returns the instance's
Aether.servers.ping(hostport)- Pings a server (
"mc.example.com"or"mc.example.com:25566", default port 25565) using the status protocol. - Returns
{ online, host, port, motd, playersOnline, playersMax, version, protocol, latencyMs }. - Unreachable servers yield
{ online: false }, not an error. Only malformed input throws. - Requires
servers:listbecause the user supplies the target host, so thenetwork:httphost allow-list cannot be used.
- Pings a server (
Aether.servers.listWithStatus(instanceId, timeoutMs?)- Returns
servers.datentries with live status attached:[{ name, ip, hidden, online, host, port, motd, playersOnline, playersMax, version, latencyMs }]. - Pings run concurrently in Go (max 6) with a per-server budget (
timeoutMs, default 3000, clamped 500–10000), so one dead server can't stall the list. - Results are cached by
servers.datcontent (mtime and size) for 45 seconds. Revisited lists return quickly, and in-game edits invalidate the cache immediately. - Prefer this to looping over
list()andping(). The sandbox is single-threaded, so sequential pings can take several seconds for each unreachable server.
- Returns
Structs sent across the bridge use their lowercase JSON field names, such as row.name. The sandbox field mapper guarantees this behavior, covered by TestSandboxStructKeysUseJSONTags.
Aether.servers.create(id, name?)- Creates
servers/<id>/with a starterserver.properties. Returns{ id, name }.
- Creates
Aether.servers.listServers()- Returns
[{ id, name }]for every managed server directory.
- Returns
Aether.servers.delete(instanceId)- Deletes
servers/<id>/after the standard user confirmation.
- Deletes
Aether.servers.readFile(instanceId, relpath)- Returns a text file inside
servers/<id>/as UTF-8 (5 MiB cap).
- Returns a text file inside
Aether.servers.writeFile(instanceId, relpath, base64Data)- Writes base64 content inside
servers/<id>/(5 MiB cap, atomic write).
- Writes base64 content inside
Paths stay inside servers/<id>/ (traversal rejected), and single files are
capped at 5 MiB.
Supervised game-server processes (Paper/Purpur/vanilla jars). The launcher
owns the child process: extensions can start, stop, query, and send console
input, but never receive handles, PIDs for reuse, or shell access. Output
streams into server:log events; transitions emit server:state.
Aether.servers.start(id, opts?)opts:{ mcVersion?, memoryMB?, jarName?, extraArgs?[] }. Memory defaults to 2048, clamped to 512–16384.jarNamedefaults to auto-detect (paper-*.jar,purpur-*.jar, thenserver.jar).mcVersiondefaults to detection from the jar filename.- Fails with an EULA error when
eula.txtis not accepted. Show a confirmation dialog, then callacceptEulaand retry. Never accept silently. - Fails when the server is already running, when 2 servers already run, or when the configured port is in use. Requires a compatible Java, which the launcher resolves (managed → system → download).
Aether.servers.stop(id): sendsstopfor graceful shutdown, then kills the process after 10 seconds if needed.Aether.servers.status(id):{ id, running, pid?, startedAt?, port?, mcVersion? }.Aether.servers.send(id, command): writes a console line to stdin (4 KB cap).Aether.servers.acceptEula(id): writeseula=true. Call only after explicit user confirmation.Aether.servers.eulaStatus(id):truewheneula.txtaccepts the EULA.Aether.servers.recentLogs(id, n?): returns buffered log lines, oldest first.
Extensions may declare an api version in their manifest. The current launcher does not negotiate API versions or enforce minApi and maxApi ranges; those fields are planned compatibility metadata.
Current permissions recognized by the runtime:
ui:sidebarui:dialogs(stub only)instances:listmods:listmods:installmods:deletemods:togglenetwork:httpfs:downloadlauncher:modloaderskin:exportaccount:readskin:managecape:managediscord:presenceservers:listservers:manageservers:processsaves:listinstances:launch
The legacy instances:patch permission is still recognized for migration and grants the current instance/mod capabilities. New extensions should use the granular permissions above.
Confirmation requests and decisions are recorded in Aether's extension security log.
Future granular permissions:
instances:read,instances:writesettings:read,settings:writelauncher:launch,launcher:stopdownloads:start,downloads:cancelnotifications:showclipboard:read,clipboard:writeextensions:list