Apps
Put your own app into the Evolent OS, on laptops, computers, tablets and terminals.
Devices pages
Any script can put its own app on laptops, computers and the tablet (in a window of the Evolent OS), or full screen on a terminal. API 1, protocol 1, SDK @evolent/os-app 1.0.0.
Start from the example app evolent_os_hello: it uses everything on this page, with its page in Svelte.
Words
- App: one page of your resource, registered with
registerApp. It is bound to the resource that registered it first. - Host:
evolent_devices. It owns the device, the use, the camera, the pointer, the windows, the lock screen and what people nearby see. - SDK:
sdk/, the small TypeScript library your page bundles. See SDK. - Session: one open app on one device or tablet, with its own token. It ends when the window closes, the player leaves the device, or a rule stops holding.
Handshake
Register on your own start when the host runs, and again on evolent_devices:ready (the host was started or restarted after you):
AddEventHandler('evolent_devices:ready', register)
CreateThread(function()
if GetResourceState('evolent_devices') == 'started' then register() end
end)Never put evolent_devices in your dependencies: ensure evolent_devices would then stop your resource and not start it again. When your resource stops, the host drops your app and closes its windows everywhere.
The definition
exports.evolent_devices:registerApp({
api = 1,
id = 'hello', -- ^[a-z][a-z0-9_]{1,31}$
label = 'Hello', -- at most 32, no middle dots or long dashes
version = '1.0.0', -- shown in the panel and added to the page URL (?v=)
icon = { path = 'M4 4h16v16H4z', color = '#C3FF00' }, -- one filled 24 x 24 SVG path, at most 4 KB
page = 'web/build/index.html', -- a .html in your resource, listed in files {}
kinds = { 'pc', 'laptop', 'tablet' }, -- or { 'terminal' } alone
canvas = { width = 960, height = 600 }, -- the size your page is designed for
window = { pc = { width = 1282, height = 946 } }, -- optional window size per kind
watch = 'cover', -- 'cover' (default) or 'state'
network = true, -- default true: the app needs signal
settings = { { key = 'shop', type = 'select', label = 'Shop', options = function(source) return { { value = 'a', label = 'A' } } end } },
defaults = { kinds = { pc = true, laptop = true, tablet = false }, playerDevices = false, preinstalled = false, access = nil },
description = 'Says hello.', -- optional, at most 120, shown in the Store
size = 40, -- optional, megabytes shown in the Store
category = 'tools', -- optional: 'dealership', 'work', 'tools' or 'fun'
about = 'Says hello to the player.', -- optional, at most 400, the app page in the Store
can = { { icon = 'wifi', text = 'Needs a signal' }, 'Works on every device' },
pictures = { 'web/build/store/hello_1.webp' },
cover = 'web/build/store/hello_cover.webp',
accent = '#22C55E',
canOpen = function(source, ctx) return true end,
onOpen = function(source, ctx) return { greeting = 'Hi' } end, -- the page's context, at most 16 KB
onClose = function(source, ctx, reason) end,
watchData = function(ctx) return { title = 'Hello' } end, -- the watch copy's context, at most 8 KB
notices = function(source, ctx) return { { title = 'Welcome' } } end,
look = function(ctx) return { accent = '#9F4040', neon = '#3B82F6', light = '#CDDCF5' } end,
})- Hooks run with
pcall; an error counts as no and prints one console line per app and hook. settings: up to 8 fields,typeselect(options a list orfn(source)),text(maxLengthup to 64),number(min,max) ortoggle;defaultoptional. Admins fill them per device in the panel; they reach you inctx.settings.defaults: where the app is on before an admin touches it: per kind, on player devices, and an optional access rule ({ mode = 'rules', rules = { { type = 'job', name = 'police', grade = 0 } } }).preinstalledputs it on devices from the start:truefor every kind, or per kind as{ tablet = true }.- Lay your page out with percentages or flex, not a fixed pixel box: when the app is full screen or the window has another shape, the frame is wider or taller than your canvas at the same scale.
- A terminal app's canvas must be 8:3 (within 1.5 %), for example 1600 x 600.
canlines are text or{ icon, text }with the iconcheck,wifi,offline,eye,cover,bell,key,lock,shield,devices,pinordisk. The publisher shown in the Store is the name of your resource.
registerApp answers true, or false, reason and prints the reason once: invalid_definition, invalid_api, unsupported_api (a newer api than this host), invalid_id, invalid_label, invalid_version, invalid_icon, invalid_page, page_missing, invalid_kinds, invalid_canvas, canvas_aspect, invalid_window, invalid_watch, invalid_network, invalid_settings_fields, invalid_defaults, invalid_description, invalid_app_size, invalid_category, invalid_about, invalid_pictures, invalid_cover, invalid_accent, invalid_can, id_taken (another resource has it, or it is store, settings, files or photos).
Your app in the Store
Colour. Your card, the big suggestion, the app page header and its gallery are washed in the colour of your app; the Store around them keeps the brand colour of the server. accent = '#RRGGBB' sets it. Without accent the Store takes the colour of your icon, and when the icon is white, grey or black it falls back to the brand colour.
Screenshots (pictures, up to 5) are pictures of your page alone, without a window frame or a background, taken at twice the canvas size so they stay sharp. The Store puts each one in a window on your colour.
Cover (cover, one) is a finished banner you design yourself. The Store shows it as it is: it fills the top of your card, sits next to your name in the big suggestion and opens the gallery on the app page.
| Cover | Screenshot | |
|---|---|---|
| Suggested size | 1920 x 800 (12:5) | twice your canvas, for example 2560 x 1800 |
| Width | 1200 to 3840 px | 640 to 3840 px |
| Height | 500 to 1600 px | 400 to 2400 px |
| Shape | 2:1 to 3:1 | 1:1 to 2.5:1 (wider than tall) |
| File | PNG, JPG or WebP, at most 2 MB | PNG, JPG or WebP, at most 2 MB |
| Where | a file of your resource | a file of your resource, or an https link |
Cards and the suggestion crop a cover a little, so keep words and logos inside the middle 1520 x 560 of a 1920 x 800 cover.
Files must be in your resource and listed in files {}. The server reads each file when you register: the type in its first bytes must match the name, and the size and shape must follow the table. A file that does not is left out with one console line that says why. The app itself still registers.
Approval and who sees the app
The first registration of a new id waits as Pending in the panel (/evdevices, Apps) and shows on no device until an admin presses Allow. Resources in Config.trustedResources are allowed on first sight. Blocked apps never show; the Enabled switch turns an app off everywhere at once.
A player sees your icon on a device when all of these hold:
- The app is registered, its resource runs, it is allowed and enabled, and it supports the kind.
- The device's entry for it is on (or your kind default when the device has none).
- On a player's own device, it is allowed on player devices.
- The player passes the entry's access rule (or the app's).
- Your
canOpen(source, ctx)returnstrue.
Opening checks all of it again. While the window is open the host re-checks the rules every second and canOpen every 5 seconds, and closes the window with the reason when one fails.
Device access only decides whether the icon shows. Every server callback of yours that should only work through a device calls appUser(source, appId) first.
Your own rule type
Add a rule type to the panel's "who can use" editor, such as the staff of a dealership:
exports.evolent_devices:registerAccess({
id = 'dealership_staff',
label = 'Dealership staff',
options = function(source) return { { value = 'pdm', label = 'PDM' } } end,
levels = { { value = 'staff', label = 'Staff' }, { value = 'boss', label = 'Boss' } },
check = function(source, cid, value, level) return true end,
})Call accessChanged(id, value) when a member list changes, so open windows are checked again.
The Store
Laptops, computers and tablets have a Store app. It lists every app the rules above allow on that device, and the icon shows on the desktop, in the launcher and on the tablet home only once the app is installed there. Installs are per device (by serial on a tablet), kept on the server, and need signal on a laptop or tablet.
An app starts installed when your defaults.preinstalled says so, when an admin ticks it on the app page, or when it is switched on for that one device: in the panel, in startApps of an item, or in the apps list of spawnDevice. Removing it in the Store wins over all of these on that device. A device set to one app, openApp and openTablet open your app without an install.
Opening, windows, closing
Open
A double click on the icon (or openApp) asks the server: the rules above, at most 4 windows, signal for a network app. The server makes the session token, calls onOpen, fires appOpened, and answers the page URL with host, app, kind, mode and v in the query.
Handshake
The OS makes an iframe of at least your canvas size. The SDK says hello, the host answers os:open with the context, then flushes what your client sent with sendAppMessage before (up to 32 messages). The SDK says ready. No hello within 8 seconds shows "App did not start" with Retry.
Minimise
Minimise keeps the iframe (your page keeps its state) and sends os:visibility false; lock and no signal do too.
Close
os:close to the page, onClose and appClosed on the server, appClosed on the client, then the iframe is removed. Leaving the device closes every app of it.
Reasons you may see in appClosed and refusals: app_closed, device_closed, device_reopened, app_access, app_off, app_stopped, app_blocked, app_pending, app_switched_off, app_not_player_devices, app_wrong_kind, too_many_windows, no_signal, single_app, slow_down, and what you pass to closeApp.
Data
Pushes (game to page): your client calls exports.evolent_devices:sendAppMessage(appId, message) (or the server export). The table reaches the page as it is: os.on('greet', fn) for { action = 'greet', data = ... }, os.onMessage(fn) for any other shape.
Pulls (page to game): await os.post('roll', data). The SDK posts to your own resource's NUI callback with the session token as __os. Check it:
RegisterNUICallback('roll', function(data, cb)
if not exports.evolent_devices:isAppPost('hello', data) then return cb({ ok = false }) end
cb(lib.callback.await('hello:roll', false))
end)Relay: when that fetch cannot reach your resource (a page inside a screen may not be allowed to), the SDK switches to the host for the rest of the session, with no change to your page. Your client then gets evolent_devices:appPost(appId, name, data, replyId) and answers with exports.evolent_devices:answerAppPost(replyId, result); no answer within 10 seconds gives the page { ok = false, reason = 'no_response' }. Handle both with one function, like the example app.
The gate is always on the server: appUser(source, appId) before you hand out anything.
lib.callback.register('hello:roll', function(source)
if not exports.evolent_devices:appUser(source, 'hello') then return { ok = false } end
return { ok = true, value = math.random(6) }
end)Signal
Laptops and tablets are wireless; computers and terminals are wired (full bars). With network = true (the default) your app does not open without signal (no_signal), its window shows "No signal" when the bars drop to 0, and appUser answers nil until the signal comes back. Use network = false for an app that works offline, such as a calculator.
People nearby
People within 12 m of a laptop or computer in use see its screen.
watch = 'cover'(the default): they see your window with your icon and an "In use" skeleton. None of your code runs on their clients.watch = 'state': your page loads on their screen withmode=watch(at most 2 such copies per screen, the rest are covers). It draws only from what the user's copy published:os.publish({ ... })in use mode,os.onState(fn)in watch mode.watchData(ctx)is the watch copy's context. In watch mode the SDK refuses everypostthat is not marked{ readOnly = true },publish,notifyandclose. Your client getsappWatchand may add what only the game knows withsendWatchMessage.- An admin can force covers for your app, for a device, or for the whole server.
- While the OS is locked, bystanders see only the lock screen.
Everyone within 12 m gets it: at most 16 flat keys and 2 KB, 2 changes a second. Never names, money, ids, plates or typed text. os.privatePointer(true) hides the user's pointer from them while a code is typed.
Input inside your page
- Laptop and computer pages are drawn in the world, so they get no real keyboard. The game sends typed keys and the SDK puts them into the field the player clicked last (inputs and text areas, the caret, selection, Backspace, Delete, arrows, Home, End, Enter, Tab). Native
<select>popups do not draw there, so the SDK shows a plain list in their place. You write nothing for this. - A key your page does not use (Escape outside a field) goes back to the OS: it closes the window. Holding Escape and the Leave button always work, so no app can trap a player.
- The tablet is the main NUI frame: real keyboard and focus (
os.input == 'native'). - Double clicks arrive as real
dblclickevents; the right click menu of the browser is blocked. - Your client must not call
SetNuiFocuswhile your app is hosted: the host owns focus.
Terminal apps
kinds = { 'terminal' }: your page is the whole screen of a terminal, loaded straight into the screen (no OS, no iframe), canvas 8:3. Admins pick your app on a terminal in the panel. The SDK talks to the host directly, the rest is the same: context, post, publish, close (leaves the terminal), leave. With your resource stopped, the terminal shows "Out of service".
The look hook gives a terminal its own colours while nobody uses it. It gets ctx without user and answers { accent, neon?, light? } in '#RRGGBB', or nil for the devices brand. accent tints the idle picture, the out of service page and the app icon on the cover; neon the glowing edges of the terminal; light the soft light in front of it. The host asks when the terminal is published and keeps the answer, so keep the hook fast. Call lookChanged() after your colours change.
Before you ask an admin to press Allow
- Every server callback of the app checks
appUser. publishcarries nothing private.- The page loads nothing from outside your resource: it loads on the clients of everyone nearby when
watch = 'state', and an outside address would show their IP addresses to that server. That includes fonts: bundle the ones you use (the OS uses Poppins and JetBrains Mono). - The app does not call
SetNuiFocuswhile hosted. - No middle dots or long dashes in labels and notices.
- A notification carries nothing a bystander may not know: the count on a screen in the world is public.