Skip to content

Custom Apps ​

Other resources can put their own programs on the computers. An app is a web page from your resource: the desktop gives it an icon, a window, a taskbar or dock entry and a count badge, opens it in all three looks, and puts a small API into the page for dialogs, notifications, printing, storage, opening other apps and reaching your own server.

Wrenchworks, the example app, on the modern look

Needs sd-computers v1.1.0 or newer

Start from a template

sd-computers-app-templates contains six complete example resources: the same small app in each stack (plain HTML/CSS/JS, React with JavaScript, React with TypeScript, Vue 3 and Svelte 5), and Wrenchworks, a full program that uses every call on this page. Copy one, rename it, and start building. See The example app.

How it fits together ​

PartWhere it runsWhat it does
AddCustomAppYour server scriptRegisters the app: its page, icon, who gets it on which computer, and the server functions its page can call
The pageThe app's windowYour HTML. The desktop frames it from https://cfx-nui-<your resource>/... and puts the SDK into it as window.computer
computer.callServerPage to your serverCalls one of the endpoints you registered, only ever for a player signed in at a computer that has your app
computer.fetchNuiPage to your clientCalls your resource's own RegisterNUICallback handlers, for what only the game knows
SendAppMessageYour server or client to the pagePushes data to the open window, heard by computer.onMessage

The app is registered on the server, because the server already decides everything a computer session has: which apps are on the desktop, and whether a call is allowed. Your page never needs to check that the player is really at a computer: an endpoint does not run otherwise.

Registering an app ​

Syntax

lua
local ok, err = exports['sd-computers']:AddCustomApp(app)
FieldTypeRequiredDescription
idstringyesUnique id: 1-48 characters of a-z, 0-9, _ and -. The ids of the desktop's own apps (mail, files, ...) are reserved. identifier works too
labelstringyesThe name under the icon and on the window. name works too
uistringnoThe page: 'my-res/web/index.html' (served as https://cfx-nui-my-res/web/index.html), or a full http(s):// address such as a Vite dev server. Without one the window says the app has no window of its own
iconstringnoThe icon, in the same forms as ui. Square, any size, SVG or PNG. Without one the desktop draws a tile with the label's first letter
colorstringno'#rrggbb': the tile behind a missing icon
descriptionstringnoWhat it does (200 characters)
developerstringnoWho made it
versionstringnoShown to the page as computer.app.version
size{ w, h }noThe window's size, 320x200 to 1600x1000. Default 900x600
resizablebooleannofalse keeps the window at its size. Default true
desktopbooleannoAn icon on the desktop. Default true
pinnedbooleannoOn the taskbar (modern) and dock (mac). Default false
computersstring | string[]noOnly these computers (ids from configs/shared/computers.lua, or placed_<n>). Absent = every computer
stylesstring | string[]noOnly these looks: 'modern', 'mac', 'retro'. Absent = all three
groupsstring | string[] | tablenoOnly these jobs or gangs: 'mechanic', { 'mechanic', 'tuner' }, or { mechanic = 2 } with a minimum grade. Absent = everyone
itemsstring | string[]noOnly while the player carries each of these items
checkfunction(src, ctx)noYour own gate: return true to give the app. Any other answer, or an error, leaves it off
endpointstablenoThe functions the page can call: { ['orders:list'] = function(src, data, ctx) ... end }. See Endpoints
printfunction(src, data, ctx)noBuilds the page computer.print sends to a printer. See Printing
onOpenfunction(src, ctx)noThe player opened the app's window (again after signing back in, for a window left open)
onClosefunction(src, ctx)noThe window closed: closed by the player, the computer locked or closed, or the app left their desktop
ReturnTypeDescription
okbooleanWhether it was registered
errstring?Why not

The gates are checked as the player opens a computer, again whenever an app is registered or taken away, and on RefreshApps. An app that turns up or goes away while a player is at a computer appears on, or disappears from, their desktop there and then; its window closes if it is gone.

ctx, handed to every function above, says who and where:

lua
ctx = {
    source = 12, citizenid = 'ABC12345', name = 'Jessica Hale', app = 'wrenchworks',
    computer = { id = 'mrpd_frontdesk', label = 'MRPD Front Desk', hostname = 'LSPD-MRPD-FD01',
                 ip = '10.42.17.88', instance = 'mrpd_frontdesk', style = 'modern', coords = { x, y, z } },
}

ctx.computer.instance is the machine, which is what AddLog takes: one prop of a model computer is '<id>@x,y,z'.

When to register ​

Apps live in sd-computers' memory, so register whenever it is ready: at its start, at every restart, and once at yours in case it was already running.

lua
local function register()
    local ok, err = exports['sd-computers']:AddCustomApp({
        id = 'my-app',
        label = 'My App',
        ui = GetCurrentResourceName() .. '/web/index.html',
        icon = GetCurrentResourceName() .. '/web/icon.svg',
        endpoints = endpoints,
    })
    if not ok then print('could not register: ' .. err) end
end

-- sd-computers started (or restarted) and its exports are ready.
AddEventHandler('sd-computers:ready', register)

-- Already running when this resource started.
CreateThread(function()
    if GetResourceState('sd-computers') == 'started' then pcall(register) end
end)

Registering the same id again from the same resource replaces the app, so calling register() twice is safe. Another resource cannot take it over. Apps go away by themselves when their resource stops, or with RemoveCustomApp.

Do not list sd-computers as a dependency

dependency 'sd-computers' in your manifest makes FiveM stop your resource whenever sd-computers stops, and a restart of sd-computers does not start it again. Leave it out and register on sd-computers:ready, as above. onResourceStart for sd-computers is too early: it fires while sd-computers is still setting up its database, before its exports exist.

Every field at once ​

lua
exports['sd-computers']:AddCustomApp({
    id          = 'wrenchworks',
    label       = 'Wrenchworks',
    ui          = 'wrenchworks/web/index.html',
    icon        = 'wrenchworks/web/icon.svg',
    color       = '#f2711c',
    description = 'Work orders for your garage.',
    developer   = 'Wrenchworks Software',
    version     = '1.0.0',
    size        = { w = 1120, h = 720 },
    resizable   = true,
    desktop     = true,
    pinned      = true,

    computers   = { 'public_pc', 'business_laptop' },
    styles      = { 'modern', 'mac' },
    groups      = { mechanic = 0 },
    items       = 'tablet',
    check       = function(src, ctx) return true end,

    endpoints   = {
        ['orders:list'] = function(src, data, ctx) return { orders = orders } end,
    },
    print       = function(src, data, ctx) return { title = 'Work order', body = '...' } end,
    onOpen      = function(src, ctx) end,
    onClose     = function(src, ctx) end,
})

The gates decide what is on the desktop, nothing more

groups, items and check decide whether a player gets the app's icon. Your endpoints only run for a player who has the app open, so they inherit that, and they are behind the computer's own checks (signed in, still at the desk). But anything your resource does outside of them - its own events, its own callbacks - is reachable without any computer, so check those yourself. And an endpoint should still check what matters to it: whether this player may change this order.

The page ​

List the page's files in your manifest, and do not set a ui_page for it: FiveM lays a resource's ui_page over the game at all times, and a computer does not need it to frame the page.

lua
files {
    'web/index.html',
    'web/app.css',
    'web/app.js',
    'web/icon.svg',
}

The desktop puts the SDK into your page after the page has loaded, as window.computer. Start once it is there:

js
function whenComputer() {
  return new Promise(resolve => {
    if (window.computer && window.computer.isReady) return resolve(window.computer);
    window.addEventListener('computer:ready', e => resolve(e.detail), { once: true });
  });
}

whenComputer().then(computer => {
  // computer.app, computer.machine, computer.user, computer.settings ...
});

A page served from somewhere the desktop cannot reach into (a dev server on localhost, a website) loads the SDK itself and works the same way:

html
<script src="https://cfx-nui-sd-computers/web/build/sdk/sdcomputers.js"></script>
<script>computer.ready().then(start);</script>

Types for the whole API ship next to it, web/build/sdk/sdcomputers.d.ts: copy it beside your page's source for completion and type checking.

computer.inComputer is false when the page is opened in a plain browser tab, which is where you render mock data.

What the page is told ​

PropertyDescription
computer.app{ id, label, resource, version, developer }
computer.machine{ id, label, hostname, ip, style, os, build }: the computer it is open on
computer.user{ name, initials, job }: the character at the desk
computer.settings{ theme, style, accent, clock24, colors, font }: see Matching the desktop
computer.launchWhat the app was opened with: OpenApp's props, or another app's computer.openApp(id, props)
computer.versionBumped when this API changes
computer.supports(name)Whether a call works here: computer.supports('pickPhoto') is false on a computer without Photos, print while printing is off

Talking to your resource ​

Endpoints ​

The page calls the functions you registered under endpoints with computer.callServer:

lua
endpoints = {
    ['orders:create'] = function(src, data, ctx)
        if type(data) ~= 'table' or not data.plate then
            return false, 'A work order needs the plate.'
        end
        local order = createOrder(data, ctx.name)
        return order
    end,
}
js
try {
  const order = await computer.callServer('orders:create', { plate: '46EEK572', work: 'Respray' });
} catch (err) {
  computer.dialog.alert({ title: 'Could not file it', message: err.message }); // 'A work order needs the plate.'
}

An endpoint returns its answer, which the page gets as JSON. To refuse, return false and a message: the page's promise rejects with that message.

false, 'message' - not nil, 'message'

What a function in another resource returns crosses into sd-computers as a list, and a list cannot start with nil: return nil, 'message' arrives as nothing at all, and the page's call succeeds with null. Return false in front of the message.

An endpoint that throws an error is logged in your resource's console, and the page is told only that something went wrong. Each player's app can make 15 calls a second (CallsPerSecond in configs/shared/apps.lua); past that a call is refused before it reaches you.

Your client script ​

computer.fetchNui(event, data) posts to your resource's own NUI callbacks, the same as any NUI page. Use it for what only the player's game knows:

lua
RegisterNUICallback('carInBay', function(_, cb)
    cb(nearestVehicle() or false)
end)
js
const car = await computer.fetchNui('carInBay');

fetchNui resolves undefined on any failure; computer.fetchNuiStrict rejects instead. The SDK also sets a global fetchNui when the page has none of its own.

Pushing to the page ​

From your server, to one player or to everyone with the app open:

lua
exports['sd-computers']:SendAppMessage(source, 'wrenchworks', 'orders', { orders = orders })
exports['sd-computers']:SendAppMessage(-1, 'wrenchworks', 'orders', { orders = orders })

From your client, in sd-phone's shape:

lua
exports['sd-computers']:SendCustomAppMessage('wrenchworks', { action = 'bay', data = car })

In the page:

js
const stop = computer.onMessage('orders', data => render(data.orders));
computer.onMessage('*', (action, data) => console.log(action, data)); // every action

Messages sent while the page is still starting are held and handed over once it is ready. Pull the first data from the page (an endpoint) rather than pushing it from onOpen: the page may not be listening yet.

The desktop's own pieces ​

Notifications and badges ​

js
await computer.notify({ title: 'Order #12 is done', body: 'Lamar finished the Sultan RS', data: { order: 12 } });
computer.onNotificationClick(data => openOrder(data.order));

await computer.setBadge(3); // 0 clears it

A notification carries your app's icon and name. Clicking it brings the app up (opening it if it was closed) and hands data back to the page. The badge sits on your icon on the taskbar, dock and desktop (retro, a 1991 desktop, has none). It is kept per player on the server, so it is still there at the next computer they sit at. Your server can do both: NotifyApp and SetAppBadge.

Dialogs and menus ​

The desktop's own dialogs, drawn over your page in the look the computer has:

js
await computer.dialog.alert({ title: 'Saved', message: 'The order is on the board.' });

const yes = await computer.dialog.confirm({
  title: 'Delete work order #12?', message: 'It comes off the board for everyone.',
  confirmLabel: 'Delete', cancelLabel: 'Keep it', danger: true,
});

const quote = await computer.dialog.prompt({
  title: 'Quote', message: 'What will it cost?', value: '1200', placeholder: '0', maxLength: 10,
}); // null when cancelled; password: true masks it

row.addEventListener('contextmenu', e => computer.menu(e, [
  { label: 'Start work', onClick: () => start(order) },
  { separator: true },
  { label: 'Delete', danger: true, onClick: () => remove(order) },
]));

computer.menu resolves the picked item's id (its index when it has none) or null, and runs its onClick. One dialog at a time: asking for a second while one is up rejects.

Pictures and colours ​

js
const photo = await computer.pickPhoto(); // { url, id } or null
const colour = await computer.pickColor('#f2711c'); // '#rrggbb' or null

pickPhoto is the Photos app's own picker: the character's pictures, or a new one added by its link (only from the hosts configs/shared/photos.lua allows).

Printing ​

computer.print puts up the desktop's print dialog: a printer in reach of the computer, and how many copies. The page comes out of that printer like any other, waits in its tray, and is taken as the printout item.

Give your app a print function and the page is built on your server, from your own data, so a printed order is what your order book says, whatever the page asked:

lua
print = function(src, data, ctx)
    local order = orders[data.id]
    if not order then return false, 'That order is gone.' end
    return {
        title = ('Work order #%d'):format(order.id),
        letterhead = 'WRENCHWORKS',
        subhead = ctx.computer.label,
        fields = { { label = 'Vehicle', value = order.model }, { label = 'Quote', value = '$' .. order.quote } },
        body = order.work,
        image = order.photo,
        footer = 'Thank you for your custom.',
    }
end,
js
const sent = await computer.print({ title: 'Work order #12', data: { id: 12 } }); // true once it went to a printer

The page can have title, letterhead, subhead, fields ({ label, value }), body, table ({ head, rows }), image (https) and footer; see PrintAt for the limits. An app without a print function can still print what the player typed, as a plain page: computer.print({ title, text }).

Opening other apps ​

js
await computer.openApp('mail', { to: 'franklin@lsmail.com', subject: 'Your car is ready' });
await computer.openApp('browser', { address: 'weazel.news' });
await computer.openApp('messages', { number: '5551234' });
await computer.openApp('wrenchworks', { order: 12 }); // another custom app, by its id

Rejects when the app is not on this computer.

Storage ​

Small values your app keeps, as JSON, either for the character (they follow them to every computer) or for the computer (everyone who uses that machine sees the same ones):

js
await computer.storage.set('prefs', { filter: 'open' });
const prefs = await computer.storage.get('prefs', { filter: 'all' }); // the fallback when there is none

await computer.storage.set('bay-note', 'Lift 2 is out', { scope: 'computer' });
const keys = await computer.storage.keys();
await computer.storage.remove('prefs'); // or set(key, null)
await computer.storage.clear({ scope: 'computer' });

It is kept on the server, in the computers_app_data table. Each app has 64 names and 64 KB per character, and the same per computer (Storage in configs/shared/apps.lua). Names are case-sensitive. set never throws: it resolves false when a write is refused, for size or for too many writes in a row (20 every 5 seconds).

What is stored here is written by the page, and the page runs on the player's own game: treat it like anything else a client sends. Preferences, drafts and notes belong here; a balance, a rank or anything your server's rules depend on belongs in your own resource's storage, changed through your endpoints.

The Event Viewer, and sounds ​

js
await computer.log('Bay note changed', 'information'); // 'information' | 'warning' | 'error'
await computer.sound('notify'); // 'notify' | 'error' | 'click', in the look's own voice

computer.log writes a line in this computer's Event Viewer under your app's name (20 a minute per player). Your server writes there with AddLog and ctx.computer.instance.

The window ​

js
computer.window.setTitle('Wrenchworks - 3 open');
computer.window.minimize();
computer.window.maximize();
computer.window.restore();
computer.window.focus();
computer.window.close();

const stop = computer.window.on(state => {
  // { focused, minimized, maximized, width, height, title }
  document.body.classList.toggle('dimmed', !state.focused);
});

computer.window.preventClose('The order you are writing is not filed yet.'); // false to stop asking

With preventClose set, closing the window - or standing up from the computer - asks the player first, with your message.

The page also gets computer.onLaunch(cb) when the app is brought up again with new launch props, and computer.onSettings(cb) when the desktop's look changes.

Matching the desktop ​

Your page keeps its own look: it is your program. The desktop tells it what it looks like, for an app that wants to fit in or follow light and dark:

  • computer.settings: { theme: 'dark' | 'light', style: 'modern' | 'mac' | 'retro', accent, clock24, colors, font }, and computer.onSettings(cb) when it changes.
  • On the page's <html>: data-theme and data-style, and CSS variables --sdc-background, --sdc-surface, --sdc-card, --sdc-text, --sdc-text-secondary, --sdc-text-tertiary, --sdc-border, --sdc-border-strong, --sdc-accent, --sdc-accent-fill, --sdc-accent-text, --sdc-on-accent, --sdc-success, --sdc-caution, --sdc-critical and --sdc-font.
css
html[data-style='retro'] .card { border-radius: 0; }
html[data-theme='light'] .app { --bg: #f4f2ee; }

Wrenchworks on the mac and retro looks

Keys ​

Escape your page does not use (no preventDefault) is the desktop's: on the desktop it stands the player up, and while they are typing in one of your fields the first press only warns them, as the desktop's own fields do. Alt+F4 closes the window. To keep Escape for yourself - to close your own panel - call e.preventDefault() in your handler, and only when it did something.

The screen other players see ​

A computer in use shows a live copy of its screen on the prop to the players standing nearby. Your page is never part of that copy: they see your app's icon and name where its window is.

Exports ​

Server ​

ExportDescription
AddCustomApp(app)Register an app, or replace your own. Returns ok, err
RemoveCustomApp(id)Take your app away
GetCustomApps()Every registered app
SendAppMessage(target, id, action, data)Push to the page
NotifyApp(target, id, n)A notification from the app
SetAppBadge(target, id, count)The count on the app's icon
OpenApp(source, id, props)Open the app's window for a player
CloseApp(source, id)Close it
GetAppUsers(id)Who has its window open
HasApp(source, id)Whether a player is at a computer with it
GetComputerSession(source)Where a player is sitting
RefreshApps(source?)Look at the gates again

Every export also answers to its camelCase name (addCustomApp, sendAppMessage, ...), the way sd-phone spells its own. The ones that change an app (messages, notifications, badges, opening and closing it) only work for the resource that registered it.

target is a player's server id, or -1: everyone with the app's window open (SendAppMessage), everyone at a computer that has the app (NotifyApp), or every player online (SetAppBadge).

RemoveCustomApp ​

lua
local ok, err = exports['sd-computers']:RemoveCustomApp('wrenchworks')

GetCustomApps ​

lua
local apps = exports['sd-computers']:GetCustomApps()
-- { { id, label, resource, description, developer, version, ui, icon }, ... }

SendAppMessage ​

lua
exports['sd-computers']:SendAppMessage(target, 'wrenchworks', 'orders', { orders = orders })

Heard by computer.onMessage('orders', cb).

NotifyApp ​

lua
exports['sd-computers']:NotifyApp(source, 'wrenchworks', {
    title = 'New work order #15', body = 'Comet SR - Lester C.', data = { order = 15 },
})

Shown only to a player signed in at a computer that has the app. data reaches computer.onNotificationClick when it is clicked.

SetAppBadge ​

lua
exports['sd-computers']:SetAppBadge(-1, 'wrenchworks', openOrders)

Kept per player until they leave the server, so it is there whenever they sit down.

OpenApp ​

lua
local ok, err = exports['sd-computers']:OpenApp(source, 'wrenchworks', { order = 12 })

Opens (or brings forward) the app's window for one player signed in at a computer that has it (not -1). props arrive as computer.launch, and as computer.onLaunch when the window was open already.

CloseApp ​

lua
exports['sd-computers']:CloseApp(source, 'wrenchworks')

GetAppUsers ​

lua
local players = exports['sd-computers']:GetAppUsers('wrenchworks') -- { 3, 12 }

HasApp ​

lua
if exports['sd-computers']:HasApp(source, 'wrenchworks') then ... end

GetComputerSession ​

lua
local ctx = exports['sd-computers']:GetComputerSession(source)
-- nil, or the ctx above with signedIn = true | false

RefreshApps ​

lua
exports['sd-computers']:RefreshApps(source) -- nil = everyone at a computer

Looks at every app's groups, items and check again for a player sitting at a computer (or, with no argument, for everyone at one), and adds or removes icons to match. Call it when something your check reads has changed, or after giving or taking an item an app needs. An app a player loses closes its window, with its onClose.

Client ​

ExportDescription
SendCustomAppMessage(id, { action, data })Push to your app's page from this client (sd-phone's shape). SendAppMessage(id, action, data) is the same
IsAppOpen(id)Whether your app's window is open on this player's desktop
GetCurrentComputer(){ id, instance, coords } of the computer the player is using, or nil

And two local client events, for a client script that only runs while its window is open:

lua
AddEventHandler('sd-computers:appOpened', function(id, computer)
    if id ~= 'wrenchworks' then return end
    -- start watching something, and SendCustomAppMessage what changes
end)

AddEventHandler('sd-computers:appClosed', function(id)
    if id == 'wrenchworks' then watching = false end
end)

Server events ​

EventWhen
sd-computers:readysd-computers has started (or restarted) and its exports are ready: register your apps

Configuration ​

configs/shared/apps.lua is the server owner's say over every custom app:

lua
return {
    Enabled = true,          -- false = no custom app is ever installed
    Blocked = {},            -- app ids refused even when a resource registers them
    MaxApps = 64,            -- most custom apps registered at once
    Storage = {
        MaxKeys = 64,        -- names one app keeps per character, and per computer
        MaxBytes = 65536,    -- bytes of names and values together
    },
    CallsPerSecond = 15,     -- computer.callServer calls per player per app
    LogsPerMinute = 20,      -- computer.log lines per player
}

The example app ​

Wrenchworks (wrenchworks in the app templates) is a garage's work-order book. Mechanics file an order for the car in the bay, start it, mark it done, print it for the customer and email them, and everyone with the app open sees each change as it happens.

What it doesWhat it uses
The order book, kept on its server (a resource KVP)endpoints, computer.callServer, false, 'message' refusals
Every open window updates, and every icon counts the open ordersSendAppMessage(-1, ...), SetAppBadge(-1, ...)
A notification for the other mechanics when an order is filedNotifyApp, computer.onNotificationClick
"In the bay": the car next to the playerfetchNui, SendCustomAppMessage, sd-computers:appOpened / appClosed, IsAppOpen
Right-click an order; delete, set a quotecomputer.menu, dialog.confirm, dialog.prompt, dialog.alert, computer.sound
A photo and a label colour on an ordercomputer.pickPhoto, computer.pickColor
Print an order; email the customerprint, computer.print, computer.openApp('mail', ...)
A finished order goes into the Event ViewerAddLog with ctx.computer.instance
The bay note on each computer, and the player's filtercomputer.storage with both scopes, computer.log
A half-written order asks before the window closescomputer.window.preventClose
The title says how many are open; the header dims in the backgroundcomputer.window.setTitle, computer.window.on
Its own look, following light and dark, square on retrocomputer.settings, onSettings, data-style
/workorder 12 opens it on that orderOpenApp, computer.launch, computer.onLaunch

Copy the folder into your resources and ensure wrenchworks after sd-computers. The five smaller templates show the same setup in each stack, with ui/dist already built; in them, Config.DevServer = 'http://localhost:5173' points the app at npm run dev for live reload, and their index.html loads the SDK itself, as a page on another address needs (see The page). In sd-computers' own browser dev server (npm run dev in web/, with the templates checked out beside sd-computers), #ext:wrenchworks opens the example with mock data.

Coming from sd-phone ​

sd-phonesd-computers
addCustomApp on the clientAddCustomApp on the server
identifier, nameid, label (the sd-phone names work too)
job, requiresgroups, items, check (and computers, styles)
defaultApp, App StoreEvery app is installed; the gates decide who sees it
componentsLoadedcomputer:ready, computer.ready()
fetchNui, useNuiEventcomputer.fetchNui, computer.onMessage
sendCustomAppMessage(id, { action, data })The same, SendCustomAppMessage on the client; SendAppMessage(target, id, action, data) on the server
SetPopUp, SetContextMenucomputer.dialog.*, computer.menu
SendNotificationcomputer.notify, NotifyApp
GetStorage / SetStorage (device-local)computer.storage (on the server, per character or per computer)
onOpen / onClose on the clientonOpen / onClose on the server; sd-computers:appOpened / appClosed on the client
-callServer endpoints, computer.print, computer.openApp, computer.window, badges, computer.log