Overview & Architecture
Aero Client is a Tauri (Rust + WebView2 / WKWebView) wrapper around kirka.io. Before any page JavaScript runs, the client injects a single initialization script into the game window. That script:
- Creates the global
window.__AERO__object (settings, helpers, UI toolkit, script-settings registry). - Sets up the resource swapper, SPA navigation detection,
window.openrouting, keyboard shortcuts, FPS cap, and raw mouse input. - Defines the
Menuclass (the in-game Mod Menu) and the game preload logic.
Userscripts are not part of this init script. They are loaded separately at runtime: the
game preload calls a backend command to read the scripts, then evals the ones that are
enabled. Enabling or disabling a script does not require rebuilding the client.
Two kinds of scripts
- Imported scripts —
.jsfiles the user drops into a folder on their computer. - Bundled "Base" scripts — scripts shipped inside the client (compiled in). They always appear and cannot be deleted by the user.
Both kinds behave identically at runtime and use the same APIs.
Folder Layout
All user data lives under the OS Documents directory:
Documents/AeroClient/
├─ settings.json # client settings (source of window.__AERO__.settings)
├─ scripts/ # imported userscripts (.js) — one file = one script
├─ CSS/ # imported stylesheets (.css), managed by the CSS tab
└─ swapper/assets/ # resource-swapper replacement files
The scripts and CSS folders are created automatically on launch. The Mod Menu
has buttons to open these folders directly.
Installing a Script
- Open the Mod Menu and click Open Scripts Folder (or browse to
Documents/AeroClient/scripts/). - Drop your
.jsfile in. One file is one script. - Back in the Mod Menu, the script appears as a card — click its toggle to Enable it. It runs immediately, no refresh needed.
File Format & Metadata Header
A script is a plain .js file. The loader reads an optional metadata header from the
first 15 lines using simple line-prefix matching:
// @name <text>— display label on the Mod Menu card. If omitted, the filename without.jsis used.// @category <text>— grouping label. If omitted, defaults toImported. Only the valueHUDis special-cased by the menu filters; any other category is stored but not specially filtered.// @author <text>— script author. If omitted, it will say that the author is unknown.
Other common userscript headers (// @version,
// @description, // @match, // @run-at) are ignored
by the loader — they're conventional/cosmetic only. The client decides when and where scripts run, not
the @match/@run-at directives. A // ==UserScript== …
// ==/UserScript== block is conventional but not required.
.js extension and exact
spacing/casing (e.g. Custom Skin Link.js) — is the stable identifier used everywhere: in the
enabled/favorite/seen lists, and as the key for registering a settings panel.
Lifecycle, Timing & Execution Context
- When: scripts run after
DOMContentLoadedin the game window. The init script (andwindow.__AERO__) is already fully available. - How: each enabled script's source runs via indirect
evalin the global scope. Wrap your script in an IIFE.'use strict'is fine. - Which: only scripts whose filename is in the
enabled_scriptssetting are executed. Disabled scripts never run. - Live enable: toggling a script ON runs it immediately (no refresh) by dispatching an event carrying the script's source.
- Live disable caveat: toggling OFF only removes it from the enabled list — code that already ran stays active until refresh (F5). Scripts needing clean teardown should no-op when disabled or instruct the user to refresh.
- Host allowlist: the preload only initializes on
kirka.io,snipers.io,ask101math.com,fpsiogame.com,cloudconverts.com. - Error isolation: an exception while running a script is caught and logged to the console (prefixed
[Aero]); it won't crash the client.
DOMContentLoaded listeners. Because scripts run at/after that event, listeners
attached to it never fire. kirka.io is a Vue SPA, so most in-game elements appear later — use a
MutationObserver or polling to wait for the game DOM.
Your First Script
Wrap everything in an IIFE and wait for the SPA DOM with a MutationObserver instead of
DOMContentLoaded:
// @name My First Script
// @category HUD
(function () {
'use strict';
// Scripts run after DOMContentLoaded — game DOM may not exist yet.
const obs = new MutationObserver(() => {
const hud = document.querySelector('#app .game-hud');
if (!hud) return;
obs.disconnect();
// build your feature here
const tag = document.createElement('div');
tag.textContent = 'Hello from Aero';
hud.appendChild(tag);
});
obs.observe(document.body, { childList: true, subtree: true });
})();
Base vs Imported Scripts & Precedence
The backend returns a combined list: bundled built-ins first, then imported folder scripts.
- Precedence: if a folder file has the same filename as a bundled built-in, the folder file is skipped — the shipped version always wins. Users can't shadow a base feature by dropping a same-named file.
- Built-ins are flagged
builtin: true, show a small BASE badge, and appear under the Base filter. Imported scripts appear under Imported.
Currently shipped base scripts
Custom Skin Link.js— recolor the default skin or supply a custom image URL/base64.Kirka0ads.js— per-weapon ADS zoom power (0ads) with images.gun_scale_modifier.js— resize the view-model (gun) with keybinds.CustomServerPreset.js— save/apply/build custom room-creation presets.
Per-script entry the menu receives
name— the filename (stable key).label— display name (from@nameor filename).content— the script source.category— from@categoryorImported.builtin—truefor shipped base scripts,falsefor folder scripts.
The window.__AERO__ Global
Properties and methods available to every script:
settings— live object of the client's current settings. Read for client state (e.g.base_url,menu_keybind, feature toggles); kept in sync as the user changes settings.swapper— the resource-swapper URL→local map.version— the client version string.logo— the Aero logo as a base64 data URI (the menu is injected into a remote page, so local asset paths can't be used).menuHTML,menuCSS— raw markup/styles for the Mod Menu (used internally).invoke(command, args)— thin wrapper over the Tauri invoke bridge; calls a Rust backend command and returns a Promise. See Backend Commands.scriptSettings— internal registry mapping a script filename to its settings-panel builder.registerScriptSettings(name, builder)— register a settings panel for a script.ui— the UI toolkit for building native-looking settings panels.
Adding a Settings Panel
A script can add a settings panel that opens from the gear icon on its Mod Menu card.
Call window.__AERO__.registerScriptSettings(name, builder):
name— must exactly equal the script's filename, including.jsand any spaces/casing (e.g.gun_scale_modifier.js). This is how the menu matches a panel to a card.builder— a function called asbuilder(panel, ui)each time the user opens the gear.
Always guard the call so the script also works outside the client:
(function () {
const KEY = 'myscript_glow';
function build(panel, ui) {
ui.note(panel, 'Configure My First Script.');
ui.toggle(
panel,
'Enable glow',
localStorage.getItem(KEY) === '1',
(on) => localStorage.setItem(KEY, on ? '1' : '0'),
'Adds a glow to the HUD.'
);
}
if (window.__AERO__ && window.__AERO__.registerScriptSettings) {
window.__AERO__.registerScriptSettings('My First Script.js', build);
}
})();
Behavior of the gear/settings view
- The settings view is in-place inside the Mod Menu (a body element with a back arrow), not a popup.
panelis that body element; it is cleared before each open, andbuilderruns fresh every time. Don't cache DOM nodes across opens.uiis the same object aswindow.__AERO__.ui.- If the script is currently disabled, the menu shows "Enable this script to configure it." instead of calling your builder.
- If a script has no registered panel, the menu shows a fallback "how to edit this script" guide with an "Open Scripts Folder" button.
- If your builder throws, the menu shows "Failed to load this script's settings." and logs the error.
Scripts persist their own state in localStorage — the UI toolkit does not auto-persist; your
onChange callbacks decide what to store.
The ui Toolkit
All builders append to a parent element and return the created element (or a small object). Styling piggybacks on the client's menu CSS so panels look native.
note(parent, text)— appends a small muted sub-label. Returns the span.row(parent, label, control, sub)— a standard setting row: left-side label (with optionalsubdescription) and a right-side control. Returns the row. This is the building block the others use; use it directly for custom controls. (Insert your own element before the row's.setting-infoto add e.g. an icon/thumbnail on the left.)toggle(parent, label, checked, onChange, sub)— a square checkbox row.onChange(newChecked). Returns the input.text(parent, label, value, onChange, opts)— a text input row.onChange(value).opts:type(defaulttext),placeholder,sub,image: true(renders next to a live image preview for URL/base64 fields). Returns the input.imagePreview(url)— returns{ wrap, set(url) }:wrapis the preview element to insert;set('')clears it; invalid/empty states show "No image"/"Invalid image".slider(parent, label, value, onChange, opts)— a range slider row.onChange(value)fires on input.opts:min,max,step,sub. Returns the input.select(parent, label, options, value, onChange, sub)— a dropdown.optionsis an array of strings, or[value, label]pairs. Returns the select.button(parent, label, onClick)— a full-width client-style button. Returns the button (override inline width/padding to make it inline-sized).keybind(parent, label, value, onChange, sub)— a "press a key" capture button. Capturesevent.key(e.g.a,Space,ArrowUp) — different from the client's own hotkeys, which storeevent.code. Returns the button.
Most helpers return the underlying control element so you can further tweak styles/attributes after creation.
Persisting Data & Events
Persisting script state
- Scripts manage their own persistence with
localStorage— the UI toolkit saves nothing automatically. - Namespace your keys with a script-specific prefix to avoid collisions (e.g.
csl_…,gsc_…,kirka_ads_…,kirka_room_presets). localStoragepersists across sessions and refreshes.
Client settings
- Read client settings via
window.__AERO__.settings. - Change a client setting with
invoke('update_setting', { key, value })(for client settings — most scripts just uselocalStoragefor their own state).
Events
Dispatched on document:
aero-settings-changed— a client setting changed;detailis{ setting, value }.aero-run-script— a script was enabled live;detailis{ name, content }.aero-css-folder-changed— the active folder CSS changed;detail.contentis the new CSS (or empty).
Dispatched on window:
aero-url-change— SPA navigation;detailis{ url }. React to entering lobby/game/servers/profile/market/friends.
window.addEventListener('aero-url-change', (e) => {
if (e.detail.url.includes('/profile')) {
// re-apply your feature for this view
}
});
Scripts may also define and dispatch their own CustomEvents to coordinate between a settings panel and an in-page panel.
Backend Commands (invoke)
Call via window.__AERO__.invoke(command, args) → Promise.
- Settings:
get_settings,update_setting({ key, value }),reset_settings. - Scripts/CSS:
get_scripts,get_scripts_path,open_scripts_folder,get_css_files,open_css_folder,import_css({ name, content }). - Swapper:
open_swapper_folder. - Window/app:
toggle_devtools,toggle_fullscreen,navigate({ url }),close_app,restart_app,launch_game,check_for_updates. - Clipboard:
get_clipboard_text,set_clipboard_text({ text }). - System:
open_external({ url }),set_autostart({ enable }),update_rpc_state({ url }).
const settings = await window.__AERO__.invoke('get_settings');
window.__AERO__.invoke('update_setting', {
key: 'menu_keybind',
value: 'KeyP'
});
Most scripts only ever need update_setting/get_settings (and rarely open_scripts_folder). Everything else is used by the client's own menu.
CSS Classes & Variables
When building a settings panel (or any element injected into the menu), these classes are available (all scoped under the menu root). Use them so your UI matches the client:
- Layout/rows:
setting,setting.column,setting-info,setting-header,setting-sub. - Controls:
checkbox(square toggle),text-input,range(slider),change-keybind,aero-button(andaero-button.large). - Image fields:
config-image-row,image-preview,image-preview-empty. - Cards:
card,card-top,card-name,card-icon,card-bottom,card-settings,card-toggle,card-fav.
Color custom properties are stored as RGB triplets and used via rgba(var(--name), alpha):
--cyan— accent/active color.--light— primary text.--lgray— muted text.--dgray— input/control background.--panel— panel background.--border— border color.--en-bg,--en-text— "enabled" background/text (good for active toggle states).--close-bg— red/destructive tint.--refresh-text,--white— extra accents.
text-input is capped at ~45% width — set flex: 1; max-width: none
to fill a row. aero-button is width: 100% — set width: auto + padding for
inline size. For a compact multi-select, style small toggle "chips" using --dgray/--lgray
for off and --en-bg/--en-text for on.
Gotchas & Best Practices
- Filename = identity. The name passed to
registerScriptSettingsmust exactly match the filename (with.js, spaces, and case). - No
DOMContentLoaded. Scripts run after it. Set up immediately and use observers/intervals for late-loading SPA DOM. - Wrap in an IIFE. Scripts share global scope; avoid leaking globals and name collisions.
- The settings builder re-runs and the panel is wiped each open. Rebuild your UI every call; don't hold references between opens.
- Don't throw in the builder — it's caught, but your panel won't render.
- Disabling doesn't tear down. If your script patches globals or adds observers, guard behavior behind a live
localStorage/setting check, or tell users to refresh after disabling. - Persist with
localStorage, namespaced per script. - Keybind capture uses
event.key, notevent.code. - Built-ins win over same-named folder files.
- Categories: only
HUDis special in filters; pick it if your script is a HUD feature.
Bundling a Base Script (maintainers)
For maintainers shipping a script with the client rather than as a user import:
- Place the
.jsfile in the client's in-tree scripts source folder (src/scripts/). - Register it in the backend's built-in scripts table (the list compiled into the binary), keyed by its exact filename.
- It will then always appear under the Base filter, take precedence over any same-named folder file, and behave exactly like an imported script otherwise.