Devices/Apps

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):

server.lua
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

server.lua
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, type select (options a list or fn(source)), text (maxLength up to 64), number (min, max) or toggle; default optional. Admins fill them per device in the panel; they reach you in ctx.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 } } }). preinstalled puts it on devices from the start: true for 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.
  • can lines are text or { icon, text } with the icon check, wifi, offline, eye, cover, bell, key, lock, shield, devices, pin or disk. The publisher shown in the Store is the name of your resource.

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.

CoverScreenshot
Suggested size1920 x 800 (12:5)twice your canvas, for example 2560 x 1800
Width1200 to 3840 px640 to 3840 px
Height500 to 1600 px400 to 2400 px
Shape2:1 to 3:11:1 to 2.5:1 (wider than tall)
FilePNG, JPG or WebP, at most 2 MBPNG, JPG or WebP, at most 2 MB
Wherea file of your resourcea 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) returns true.

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.

The icon decides nothing on its own

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:

server.lua
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:

client.lua
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.

server.lua
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 with mode=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 every post that is not marked { readOnly = true }, publish, notify and close. Your client gets appWatch and may add what only the game knows with sendWatchMessage.
  • 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.
Published state is public

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 dblclick events; the right click menu of the browser is blocked.
  • Your client must not call SetNuiFocus while 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.
  • publish carries 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 SetNuiFocus while 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.

Next steps