JavaScript SDK
The laptop.js SDK for pages your resource shows as apps, sites and Shroud messages.
laptop.js connects a page from your resource to the laptop. It receives what your Lua sends, opens apps and addresses, shows notifications, keeps state while the laptop sleeps, matches the OS's look, and makes typing work on the laptop's own screen. It is plain JavaScript with no build step and no dependencies.
Include it in every page you register with RegisterApp or RegisterSite, and in pages you show in a Shroud message:
html<script src="https://cfx-nui-zyke_laptop/nui/sdk/laptop.js"></script>
It adds window.Laptop:
jsLaptop.ready().then((context) => {
console.log(context.kind, context.id, context.params);
});
Laptop.on("message", (data) => renderOrders(data.orders));
document.querySelector("#buy").addEventListener("click", async () => {
const result = await Laptop.post("buyItem", { item: "lockpick" });
Laptop.notify({ title: "Order placed", text: result.message, icon: "package" });
});
#Members
| Member | Description |
|---|---|
ready(callback?) | Resolves with the context once the laptop answered. A page opened on its own in a browser resolves with a standalone context. |
context | The current context, null until ready resolved. Kept up to date by the events below. |
post(endpoint, data?) | POSTs to your resource's RegisterNUICallback(endpoint) and parses the JSON reply. A reply that isn't JSON comes back as text, an empty one as null. |
mock(endpoint, handler) | Answers post(endpoint) while the page runs in a normal browser. Ignored in game. See Developing in a Browser. |
notify(options) | Shows a desktop notification: {title, text?, icon?, duration?}, or just a title. icon is a Lucide name or an image URL, a relative path resolves against the page. duration is 1000 to 30000 ms. Clicking it opens the app, or the site's address. |
close() | Closes the app's window, or the site's browser tab. |
setTitle(title) | Sets the app's window title, or the site's tab title. |
openUrl(url, {newTab?}) | Opens an address in the OS's browser: Ledge, Emberfox, Savanna or Veil Browser. A site navigates its own tab unless newTab is set. |
pushUrl(url, {replace?}) | Sites only. Moves the tab to another of the site's addresses without loading the page again, like history.pushState, so back and forward step through the site's pages and the address bar shows where the player is. url resolves against the current address, such as "/units/12", and has to stay on the site. replace swaps the current address instead, like history.replaceState. |
openApp(appId, params?) | Opens another app and resolves with {windowId}. Rejects when it doesn't exist or doesn't run on this OS. An app the account hasn't downloaded opens the app store on its page. |
saveState(state) | Keeps a JSON value of up to 16 KiB for the app's window, or for the address the site's tab shows, like history.state. When the laptop wakes after it was closed, put away, the player relogged or the server restarted, the page gets it back in context.state. null forgets it. Rejects where nothing is kept, such as a page in a Shroud message. |
getContext() | Asks the laptop for the context again, updates context and resolves with it. |
on(event, handler) | Listens to an event and returns a function that stops listening. |
off(event, handler) | Stops listening. |
resource | The resource that owns the page, read from its cfx-nui-<resource> address. null in a normal browser. |
inGame | Whether the page runs in game. |
embedded | Whether the page runs inside the laptop rather than on its own. |
version | The SDK's protocol version, 1, also in context.protocol. |
The calls that talk to the laptop (notify, close, setTitle, openUrl, pushUrl, openApp, getContext, saveState) return promises. They reject with an error when the laptop refuses the call or doesn't answer within 10 seconds. Outside the laptop they reject too, except saveState, which only updates context.state there.
#Context
| Field | Description |
|---|---|
kind | "app" or "site". A page in a Shroud message runs as the app "chat". null on a page opened on its own. |
id | The app id or site id. |
windowId | The window the page runs in. |
resource | The resource that registered the app or site. |
params | The params the app was opened with, from OpenApp or Laptop.openApp. |
os | "facade", "lumen", "solace" or "veil". |
toolkit | true in the Crowbar recovery toolkit's live session, whose os is "lumen". |
theme | "light" or "dark". |
accent | blue, teal, green, yellow, orange, red, pink, purple or slate. |
palette | The OS's resolved colours. See Theming. |
shape | {radius, radiusControl, controlHeight}, as CSS lengths. |
connection | {status, connected, exitIp} of the Onion connection. status is disconnected, connecting, connected or failed. |
settings | {language, clock24h, securityLevel, uiSounds, volume, doNotDisturb}. securityLevel is standard, safer or safest. A page with its own sounds follows volume and uiSounds. |
visible | Whether the laptop's screen is open. |
devices | What is plugged into the laptop's ports. See Devices. |
display | "dui" in game, where the OS runs on the laptop's own screen. Unset on a page opened on its own. Older laptops could also send "nui", so the type keeps it. |
state | What the page last kept with saveState. |
url, host, path, search | Sites only: the address the tab shows. |
chat | Pages in a Shroud message only: {messageId}, the message the page is in. |
protocol | The SDK's protocol version. |
standalone | true on a page opened on its own, outside the laptop. |
#Events
| Event | Data |
|---|---|
ready | The context, once the laptop answered. |
message | What SendAppMessage or SendSiteMessage sent. |
params | New params, when the app is opened again while its window is open. |
theme | {theme, accent, palette, os, shape} after the player changed the style or accent colour. |
connection | {status, connected, exitIp} when the Onion connection changes. |
visibility | {visible} when the player opens or closes the laptop's screen. |
devices | The new devices list when something is plugged into or pulled out of the laptop the OS runs on. |
settings | The new settings when the player changes one of them. |
location | Sites only: {url, host, path, search, state} when the tab moved to another of the site's addresses through pushUrl, back or forward. state is what the page saved for that address. A page that routes by its address renders the page for path here, and for context.path when it loads. |
context | The whole context, after any of the above except message changed it. |
#Devices
context.devices holds one entry per filled port of the laptop the OS runs on: {port, kind, side, type, item, label}, such as {port: "usba", kind: "usba", side: "right", type: "data_stick", item: "usb_drive", label: "USB Drive"}. kind is usbc, usba, hdmi or audio, and side is "rear" on a desktop computer. type is the device type, built in or registered by a resource. The list is empty on a screen opened without a laptop prop.
A page that needs a device, such as a scanner that only works with an antenna your resource registered as a device type, checks it and listens to devices:
jsconst hasAntenna = (devices) => devices.some((device) => device.type === "wifi_antenna");
Laptop.ready().then((context) => setScanner(hasAntenna(context.devices)));
Laptop.on("devices", (devices) => setScanner(hasAntenna(devices)));
The list comes from the client, so check the device again on the server with the GetSessionDevices export before anything that pays out. See Ports & Devices for the devices themselves.
#Theming
The SDK styles the page to match the OS. It sets data-laptop-theme (light or dark), data-laptop-accent and data-laptop-os on <html>, sets its color-scheme, and exposes the palette and shapes as CSS variables. They update when the player changes the style or accent.
| Variable | Description |
|---|---|
--laptop-font | The OS's font. |
--laptop-font-mono | The monospace font, Adwaita Mono. |
--laptop-window-bg, --laptop-window-fg | Window background and text. |
--laptop-view-bg, --laptop-view-fg | Content areas, such as lists and text views. |
--laptop-headerbar-bg | Header bars. |
--laptop-sidebar-bg | Sidebars. |
--laptop-card-bg | Cards. |
--laptop-popover-bg | Popovers and menus. |
--laptop-borders | Borders and separators. |
--laptop-dim-fg | Dimmed text. |
--laptop-hover-bg | Hovered rows. |
--laptop-button-bg | Buttons. |
--laptop-entry-bg | Text entries. |
--laptop-accent | Accent-coloured text. |
--laptop-accent-bg, --laptop-accent-fg | Accent-coloured buttons and their text. |
--laptop-success, --laptop-warning, --laptop-error | Status colours. |
--laptop-destructive-bg | Destructive buttons. |
--laptop-radius | Cards and panels. |
--laptop-radius-control | Buttons and entries. |
--laptop-control-height | Buttons and entries. |
The font and shapes follow the OS:
| Variable | Facade | Lumen | Solace | Veil |
|---|---|---|---|---|
--laptop-font | Selawik | Ubuntu Sans | Inter | Adwaita Sans |
--laptop-radius | 4px | 12px | 10px | 12px, 14px in the modern style |
--laptop-radius-control | 4px | 6px | 6px | 6px |
--laptop-control-height | 32px | 34px | 28px | 34px |
cssbody { font-family: var(--laptop-font); background: var(--laptop-window-bg); color: var(--laptop-window-fg); } .card { border-radius: var(--laptop-radius); background: var(--laptop-card-bg); } button { height: var(--laptop-control-height); border-radius: var(--laptop-radius-control); } [data-laptop-os="facade"] button { font-weight: 400; }
The fonts load from the laptop's own files, and the browser only downloads the ones the page uses.
#Keyboard
Keys pressed in your page never reach the laptop on their own, so the SDK passes some on:
- Escape closes the laptop's topmost dialog or popover, or the screen.
- The browser's keyboard shortcuts, such as Ctrl+T, Ctrl+W, Ctrl+L, Ctrl+Tab and Alt+Left, go to the browser your site is open in. The SDK calls
preventDefault()on the ones it passes on. In an app's window they do nothing.
To keep one of these keys for your page, call preventDefault() on it in your own keydown listener, on an element, on document or on window. The SDK checks this after all of them have run.
#Wheel Scrolling
The SDK eases the mouse wheel the way the laptop's own pages do: a notch glides over about 200 ms instead of jumping, and quick notches run into one motion. Everything that scrolls through CSS overflow gets it, the page itself included.
- To keep the wheel for a handler of your own, such as a map that zooms, call
preventDefault()on thewheelevent. - An element with
scroll-snap-typeorscroll-behavior: smoothkeeps the browser's own scrolling. - Ctrl with the wheel, and the wheel over a focused number field, are left to the browser.
- A page opened on its own outside the laptop keeps its browser's scrolling.
#On the Laptop's Own Screen
The OS runs on the laptop's own screen and the camera zooms onto it. Your page runs in a browser that has no keyboard of its own: the laptop catches what the player types and hands it to the page. The mouse works, but that browser can't show a <select>'s list, has no double clicks and never starts a drag. context.display is "dui" there.
A page with the SDK gets almost everything it would get in a normal browser, with no changes:
- Typing into inputs, text areas and
contenteditableelements, with the caret keys, Backspace, Delete, Ctrl+Backspace, Enter, Tab and undo. Yourkeydown,keyup,beforeinputandinputlisteners and React'sonChangesee it all. - Ctrl+C, Ctrl+X and Ctrl+A. Ctrl+V pastes the player's real clipboard and raises a
pasteevent you can handle. - Japanese, Chinese and other input methods: the text being composed shows in the field and is replaced by the final text once, with
compositionstart,compositionupdateandcompositionendevents. - Ctrl, Shift and Alt on mouse events, and
dblclick. - A
<select>opens a list in the OS's colours under it. The arrow keys, Home, End, Enter and Escape work on it, and choosing an option raisesinputandchange. A<select multiple>or one withsizeis a plain list box and needs no popup. - Dragging a range slider's thumb or a scrollbar.
- The arrow keys on sliders, Space on check boxes, and Space or Enter on buttons and links.
- The player sees the cursor your CSS asks for, such as the text cursor over a field or the hand over a link.
What a page has to keep in mind:
- The keys it gets are events your script can see, not ones the browser types for:
isTrustedisfalseon them. Don't ignore untrusted key events. - HTML5 drag and drop (
draggable,dragstart,drop) doesn't start. Build drags frompointerdown,pointermoveandpointerup, which work. - Date, time and colour inputs can't open their pickers. Use your own.
- Reading the clipboard with
navigator.clipboardis blocked. Handle thepasteevent instead.
A page without the SDK only gets the mouse: clicks, scrolling and hovering work, but nothing can be typed into it, and the cursor stays an arrow over it. Escape and the browser's shortcuts, such as Ctrl+W, still reach the laptop. Include laptop.js in any page that has a text field.
#TypeScript and React
The laptop serves two more files next to laptop.js, at https://cfx-nui-zyke_laptop/nui/sdk/. They are in the resource at nui/sdk/:
laptop.d.tsdeclares thewindow.Laptopglobal and its types in theZykeLaptopnamespace: the context, the events and their data, the notify options, the theme, palette, shape, devices and OS.laptop-react.tsholds React hooks.
| Hook | Description |
|---|---|
useLaptopContext() | Returns the context, null before the laptop answered, and re-renders when it changes. |
useLaptopEvent(event, handler) | Listens to an event, always calling the newest handler. |
useLaptopMessage<T>(handler) | Receives what SendAppMessage or SendSiteMessage sent. |
useLaptopDevices() | Returns the plugged devices and re-renders when they change. |
useLaptopSettings() | Returns the OS settings, null before the laptop answered, and re-renders when they change. |
useLaptopState<T>(key, initial) | Works like useState, but keeps the value through saveState, so the page opens on the same tab after the laptop slept. Every key of the page together takes 16 KiB at most. |
laptopPost<T>(endpoint, data?) | A typed Laptop.post. |
Copy both files into your page's source, for example src/laptop/, so your build doesn't depend on where zyke_laptop is installed. laptop.d.ts is a global declaration, so it only has to be inside your tsconfig's include, which src already covers:
json{
"include": ["src"]
}
A file outside include can be added by path, such as "include": ["src", "../../zyke_laptop/nui/sdk/laptop.d.ts"], or referenced with /// <reference path="./laptop.d.ts" />.
Load laptop.js with a <script> tag before your bundle in index.html, as above, so window.Laptop is there when your code runs:
tsximport { useState } from "react";
import { laptopPost, useLaptopContext, useLaptopMessage } from "./laptop/laptop-react";
const Orders = () => {
const context = useLaptopContext();
const [orders, setOrders] = useState<Order[]>([]);
useLaptopMessage<{ orders: Order[] }>((data) => setOrders(data.orders));
if (!context) return null;
return <OrderList orders={orders} compact={context.os === "facade"} onBuy={(id) => laptopPost<BuyResult>("buyItem", { id })} />;
};
#Developing in a Browser
A page can be worked on in a normal browser, outside the game. There Laptop.ready() resolves with a standalone context in Veil's dark style, and Laptop.post answers from the mocks you register instead of your resource. A post without a mock rejects. Calls that need the laptop, such as notify and openApp, reject, and saveState only updates context.state.
jsLaptop.mock("getOrders", () => ({ orders: [{ id: 1, item: "lockpick", amount: 2 }] }))
.mock("buyItem", (data) => ({ message: `Bought ${data.item}` }));
Mocks are ignored in game, so they can stay in the page. The https://cfx-nui-zyke_laptop/... script address only resolves in game; while working in a browser, point the script tag at nui/sdk/laptop.js inside the laptop's folder. See Working on a Page in a Browser for how the examples do it.