Documentation

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 also gives the page what it can't do on its own: files from the OS's chooser, the signed-in account, payments, storage that follows the account, badges, sounds, casting, rooms and the share sheet. The page asks, the OS shows the player what is going on, and your server decides. 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>

Load that address rather than a copy. The SDK is on protocol 2, and a page whose copy reports an older protocol gets an "Update Needed" screen instead of its page.

It adds window.Laptop:

js
Laptop.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

MemberDescription
ready(callback?)Resolves with the context once the laptop answered. A page opened on its own in a browser, or one the laptop doesn't answer within 5 seconds, resolves with a standalone context.
contextThe 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), or an SDK call as "sdk:<feature>.<op>", while the page runs in a normal browser. Ignored in game. See Developing in a Browser.
notify(options)Shows a desktop notification and resolves with {id}. See Notifications.
can(capability)Whether a feature works for this page here and now. See Capabilities.
filesThe OS's file chooser. See Files.
account()The signed-in account. See Account.
badge(count)Apps only. Puts a count on the app's icon, up to 999. 0 hides it.
clipboardwriteText(text) copies up to 16384 characters. readText() reads what was last copied in this OS session and needs the clipboard permission.
soundplay(name) plays one of the OS's own sounds. See Sounds.
pay(paymentId)Shows the OS's pay sheet for a payment your server made. See Payments.
storageData kept per account on the server. See Storage.
roomsRealtime rooms with the same app's pages on other laptops. See Rooms.
castShows the app on a screen in the world. See Casting.
share(options)Shows the OS's Share sheet. See Share.
permissionsget() resolves with {account, clipboard, job, notifications}: what the player allowed this app, false for undecided. It asks nothing.
racksThe player's laptop racks. See Racks.
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. Without params, an app that is already open keeps the ones it has.
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.
resourceThe resource that owns the page, read from its cfx-nui-<resource> address. null in a normal browser.
inGameWhether the page runs in game.
embeddedWhether the page runs inside the laptop rather than on its own.
versionThe SDK's protocol version, 2, also in context.protocol.

#Errors

Every call that talks to the laptop returns a promise. A refusal rejects with an Error whose code says why, which is what the page branches on; message is readable English for logs:

CodeDescription
invalidThe call's arguments are invalid.
unsupportedNot available here, such as outside the laptop, in a Shroud message or in the Crowbar recovery toolkit.
notRegisteredThe app or site isn't registered on the server, or not with the field the feature needs.
notAllowedConfig.Sdk.allowlist doesn't let the resource use it.
noAccessThe Access Rules refuse the player.
notLicensedA paid app the account doesn't own.
deniedThe player said no to a permission, or turned it off in Privacy.
cancelledThe player closed the sheet or chooser.
busyAnother sheet is open in this window.
rateLimitedToo many calls.
tooLargeOver a size limit.
quotaOut of space, or a room is full or the player is in too many rooms.
notFoundNot found, such as a file handle or a room the page isn't in.
gone, expiredNo longer valid, such as an expired payment.
offlineThe laptop isn't online.
notReadyThe OS isn't ready yet, such as the account's files still loading.
noFundsNot enough money.
timeoutNo answer in time.
failedAnything else went wrong.

Calls the OS answers itself time out after 10 seconds, calls that go to the server, such as storage and rooms, after 15, and calls that wait for the player, such as the file chooser, the pay sheet or account(), never. Outside the laptop every call rejects with unsupported, except saveState, which only updates context.state there, and sound.play, which resolves without a sound. A mock can answer the calls of files, account, badge, clipboard, sound, pay, storage, rooms, cast, share and permissions there.

A page inside a Shroud message is someone else's content in the player's chat. It gets notify, close, setTitle, openUrl, openApp, getContext and the events, but files, account, badge, clipboard, sound, pay, storage, rooms, cast, share and permissions reject with unsupported there, and its capabilities list is empty.

#Capabilities

context.capabilities lists what the page may use on this laptop now, and Laptop.can(name) checks one. It changes with the capabilities event. A listed capability can still fail on a call, an unlisted one always does.

CapabilityWhen
files, notifyActions, clipboard, shareAlways, outside Shroud messages and the recovery toolkit.
documents, badgeApps.
account, clipboardReadRegistered with the account or clipboard permission. Not on Veil unless Config.Sdk.permissions.veil is "prompt".
payRegistered, while Config.Sdk.payments is on.
storageRegistered with storage, while Config.Sdk.storage is on.
castApps registered with cast, while Config.Sdk.cast is on.
roomsRegistered with rooms, while Config.Sdk.rooms is on.
js
if (Laptop.can("pay")) buyButton.hidden = false;

#Files

The chooser is the player's consent: a page only ever gets the files the player picked there.

CallDescription
files.open({types?, startIn?})Shows the OS's open chooser and resolves with a list holding the file the player picked, with its content for text or a src address for an image. Rejects with cancelled when nothing was picked.
files.save({suggestedName, content, types?, startIn?})Shows the save chooser, writes the text there and resolves with a handle.
files.read(handle)Reads a file this window opened or saved again.
files.write(handle, content)Overwrites it without asking again. Rejects with notAllowed for a read-only file.
files.release(handle)Lets go of the handle.

types is a list of {label?, extensions}, such as [{label: "Ledger", extensions: ["ledger"]}]. startIn is "documents", "persistent" or "stick". A handle is {id, name, path, kind, size, modified, location, readOnly}; calls take the handle or its id. It only works in the window that got it, until the window closes, the account changes or the stick it is on is pulled out. Files are text, 64 KiB by default (Config.Sdk.files.maxFileBytes).

An app that registered Document Types gets the file it was opened with from Files in context.files, or in the filesOpened event when its window was already open.

#Account

Laptop.account() resolves with the account the laptop is signed in to:

FieldDescription
kind"nimbus", "local", "guest" or "live" (Veil and other systems booted from a stick).
nameThe account's name.
addressThe Nimbus address, null for other accounts.
appUserIdAn id that is stable per account and app, and random per guest and live session. It is not proof of anything: your server reads the same value with GetLaptopAccount.
osThe OS the page runs on.
job{name, label, grade, gradeLabel, onDuty}, only with the job permission while Config.Sdk.account.job is on, and never on Veil or as guest.

It needs the account permission. The first call asks the player, and the answer is kept per account. The account event fires when the OS signs in, out or switches account: file handles from before are gone, so read account() again.

#Notifications

Laptop.notify(options) shows a desktop notification and resolves with {id}, the id given or one the OS made. A string is the same as {title}.

OptionDescription
titleUp to 120 characters.
textUp to 500 characters.
iconA Lucide name or an image URL. A relative path resolves against the page.
duration1000 to 30000 ms.
idLetters, digits, _ and -, up to 32. A notification of this app with the same id is replaced.
actionsUp to two buttons, {id, label}, with labels up to 24 characters.
dataJSON of up to 1 KiB, handed back with the action.
soundOne of the app sounds or "notification" instead of the chime, or null for a silent banner.

Clicking the notification opens the app, or the site's address. A click on it or one of its buttons comes back to the page as the notificationAction event, {notificationId, action, data}, with action null for the notification itself. When the app isn't open, it opens with the event in context.launch.notificationAction.

Notifications chime as their banner shows, with the player's volume, UI-sounds switch and Do Not Disturb, and only while the laptop's screen is open. One that arrives with the laptop closed goes to the list silently. A player who turned the app's notifications off in Settings → Privacy makes notify reject with denied.

#Sounds

Laptop.sound.play(name) plays one of the OS's own sounds for the player at the laptop: click, send, receive, success or error, in the voice of the OS the page runs on, with the same volume, UI-sounds switch and Do Not Disturb. A page gets one sound per 60 ms, and faster calls reject with rateLimited. Pages bring no audio files through the SDK.

For a sound others near the player hear, such as a card reader, your server calls PlayAppSound.

#Payments

Laptop.pay(paymentId) shows the OS's pay sheet for a payment your server made with CreatePayment. The page never passes an amount. It resolves once the money moved with {paymentId, status: "settled", method, amount, coin?}, and rejects with cancelled when the player closes the sheet.

js
const { paymentId } = await Laptop.post("startCheckout", { plan: "premium" });

try {
    await Laptop.pay(paymentId);
    showThanks();
} catch (error) {
    if (error.code !== "cancelled") showError(error.code);
}

See Payments for the server side.

#Storage

Laptop.storage keeps the app's or site's data per account on the server, the same on every laptop the account signs in to. It needs storage in the registration. See Storage for the limits and the server exports that read and write the same data.

CallDescription
get(key)Resolves with the value, or null.
getAll()Resolves with every key and value.
set(key, value)Writes a JSON value. null removes the key. Rejects with tooLarge over 8 KiB and quota over the app's space.
remove(key)Removes the key.
keys()Resolves with the keys.
usage()Resolves with {bytes, maxBytes}.

Keys are up to 64 letters, digits, _, -, . and :. When another laptop of the account, or your server, changes a key, the page gets the storage event with {key, value}, value null for a removed key.

#Rooms

Laptop.rooms.join(name, data?) joins a room of this app or site and resolves with it. It needs rooms in the registration, and the room's limits and join check come from there. See Rooms. The name is up to 48 letters, digits, _ and -.

js
const room = await Laptop.rooms.join("lobby", { ready: true });

room.on("message", ({ from, data }) => addMessage(from, data));
room.on("join", (member) => addMember(member));
room.on("leave", (member) => removeMember(member.id));
room.on("close", ({ reason }) => showClosed(reason));

await room.send({ text: "Hello" });
MemberDescription
id, nameThe room.
selfThis page's member id.
members{id, data} of every member, kept current. data is what your join check gave them.
send(data, to?)Sends to every other member, or the one member id. Rejects with tooLarge over the room's size and rateLimited over its rate.
leave()Leaves the room.
on(event, handler)message with {from, data}, join and leave with the member, and close with {reason}: "left", "kicked", "closed", "offline" or "laptopOff". Returns a function that stops listening.

Member ids are random per room and never a server id. A message from your server comes from "server". Joining rejects with quota when the room or the player's rooms are full, offline without a network, and denied or noAccess when your join check refuses.

#Casting

An app registered with cast can show its own viewer page on the screens the laptop casts to:

CallDescription
cast.start(snapshot)Shows the OS's Cast menu with this app as the source, and the player picks the screen. Resolves with {screen, via}. The window's title bar gets a Cast button as well.
cast.snapshot(content)Replaces what the screen shows.
cast.delta(delta)Changes it, in order.
cast.signal(signal)Passes something short, such as a pointer.
cast.screens()Lists the screens in reach: {id, label, via, distance, busy}.
cast.stop()Ends the cast.

A message over the cast's size limit rejects with tooLarge, and one sent while the app isn't casting with notFound. The cast event says when the cast started or stopped, or that the screen wants a fresh snapshot (wantSnapshot). Your server's snapshot, delta and signal functions check every message before a viewer sees it.

The viewer page loads https://cfx-nui-zyke_laptop/nui/sdk/laptop-cast.js and listens with LaptopCast.on("snapshot" | "delta" | "signal" | "setup", handler). setup and LaptopCast.context give {locale, width, height}, the screen's picture in pixels. Messages that came before the page listened go to the first listener. Every player who looks at the screen runs the viewer page, so keep it still between messages: no animation loops, timers or polling.

#Share

Laptop.share({title?, text?, url?, file?}) shows the OS's Share sheet. The player picks Mail, Natter, Shroud, Files or the clipboard, and the app they pick only gets a prefilled draft; nothing is sent until they send it. file is a handle from Laptop.files, and a share with a file offers only Mail, which attaches it. It needs text, url or file. It resolves with {target}, "mail", "messenger", "chat", "files" or "clipboard", or rejects with cancelled.

#Racks

Laptop.racks reads and runs the player's laptop racks, as the Hive app shows them. Changes made elsewhere are not pushed: read them again with get, or send them from your resource with SendAppMessage.

CallDescription
list(){racks, unit, profiles}: the racks the player owns or is a member of.
get(rackId)One rack with its laptops, workloads, shares, members and history.
workloads()Every workload resources registered, with the resource that owns each.
setShare(rackId, workloadId, share)Sets the percent of the rack's compute, 0 to 100, for a workload your page's resource registered.
setPower(rackId, on)Turns the rack on or off.
setProfile(rackId, profile)"eco", "balanced", "max" or a profile the server added.

A refusal rejects with the server's code, such as noAccess, notYourWorkload, overAllocated, unknownWorkload or rateLimited. See Workloads for registering a workload.

#Context

FieldDescription
kind"app" or "site". A page in a Shroud message runs as the app "chat". null on a page opened on its own.
idThe app id or site id.
windowIdThe window the page runs in.
resourceThe resource that registered the app or site.
paramsThe params the app was opened with, from OpenApp or Laptop.openApp.
os"facade", "lumen", "solace" or "veil".
toolkittrue in the Crowbar recovery toolkit's live session, whose os is "lumen".
theme"light" or "dark".
accentblue, teal, green, yellow, orange, red, pink, purple or slate.
paletteThe 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.
visibleWhether the laptop's screen is open.
devicesWhat 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.
stateWhat the page last kept with saveState.
capabilitiesWhat the page may use here and now. See Capabilities.
filesApps only: files the app was opened with, such as a document double-clicked in Files.
launch{notificationAction} when the OS opened the app for a click on one of its notifications.
url, host, path, searchSites only: the address the tab shows.
chatPages in a Shroud message only: {messageId}, the message the page is in.
protocolThe SDK's protocol version.
standalonetrue on a page opened on its own, outside the laptop.

#Events

EventData
readyThe context, once the laptop answered.
messageWhat SendAppMessage or SendSiteMessage sent.
paramsNew 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.
devicesThe new devices list when something is plugged into or pulled out of the laptop the OS runs on.
settingsThe new settings when the player changes one of them.
locationSites 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.
capabilitiesThe new capabilities list.
accountNo data. The OS signed in, out or switched account.
notificationAction{notificationId, action, data} when the player clicked one of the page's notifications or its buttons.
filesOpenedA list of files the app was opened with while its window was open.
storage{key, value} when another laptop of the account or your server changed the app's storage.
cast{state, screen?}, state being started, stopped or wantSnapshot.
contextThe 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:

js
const 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.

VariableDescription
--laptop-fontThe OS's font.
--laptop-font-monoThe monospace font, Adwaita Mono.
--laptop-window-bg, --laptop-window-fgWindow background and text.
--laptop-view-bg, --laptop-view-fgContent areas, such as lists and text views.
--laptop-headerbar-bgHeader bars.
--laptop-sidebar-bgSidebars.
--laptop-card-bgCards.
--laptop-popover-bgPopovers and menus.
--laptop-bordersBorders and separators.
--laptop-dim-fgDimmed text.
--laptop-hover-bgHovered rows.
--laptop-button-bgButtons.
--laptop-entry-bgText entries.
--laptop-accentAccent-coloured text.
--laptop-accent-bg, --laptop-accent-fgAccent-coloured buttons and their text.
--laptop-success, --laptop-warning, --laptop-errorStatus colours.
--laptop-destructive-bgDestructive buttons.
--laptop-radiusCards and panels.
--laptop-radius-controlButtons and entries.
--laptop-control-heightButtons and entries.

The font and shapes follow the OS:

VariableFacadeLumenSolaceVeil
--laptop-fontSelawikUbuntu SansInterAdwaita Sans
--laptop-radius4px12px10px12px, 14px in the modern style
--laptop-radius-control4px6px6px6px
--laptop-control-height32px34px28px34px
css
body { 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 the wheel event.
  • An element with scroll-snap-type or scroll-behavior: smooth keeps 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 contenteditable elements, with the caret keys, Backspace, Delete, Ctrl+Backspace, Enter, Tab and undo. Your keydown, keyup, beforeinput and input listeners and React's onChange see it all.
  • Ctrl+C, Ctrl+X and Ctrl+A. Ctrl+V pastes the player's real clipboard and raises a paste event 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, compositionupdate and compositionend events.
  • 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 raises input and change. A <select multiple> or one with size is 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: isTrusted is false on them. Don't ignore untrusted key events.
  • HTML5 drag and drop (draggable, dragstart, drop) doesn't start. Build drags from pointerdown, pointermove and pointerup, which work.
  • Date, time and colour inputs can't open their pickers. Use your own.
  • Reading the clipboard with navigator.clipboard is blocked. Handle the paste event 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 more files next to laptop.js, at https://cfx-nui-zyke_laptop/nui/sdk/. They are in the resource at nui/sdk/:

  • laptop.d.ts declares the window.Laptop global and its types in the ZykeLaptop namespace: the context, the events and their data, the notify options, the theme, palette, shape, devices, OS, error codes, files, account, storage, rooms, casting and racks. It also declares window.LaptopCast in the ZykeLaptopCast namespace.
  • laptop-react.ts holds React hooks.
  • laptop-cast.js is the script for an app's cast viewer page.

For a ready Vite, React and TypeScript setup using these, start from zyke_laptop_react_template in the app templates repository.

HookDescription
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.
useLaptopCapabilities()Returns the page's capabilities and re-renders when they change.
useLaptopAccount()Returns {account, error}: the account, read once and again when the OS switches account, or the code of a refusal such as denied.
useLaptopStorage<T>(key, initial)Works like useState for one key of Laptop.storage: initial until the server answers, then the stored value, kept current when another laptop changes it. Setting it shows the value at once and rolls back and rejects when the write is refused.
useLaptopRoom(name, data?)Joins the room while mounted and leaves on unmount or another name. Returns {room, members, error}: room is null until joined and after a close, members stays current, and error is the code of a refused join.
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:

tsx
import { 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, with an empty capabilities list, 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 with unsupported, and saveState only updates context.state.

A mock named "sdk:<feature>.<op>" answers an SDK call instead, such as "sdk:pay.show" for Laptop.pay, "sdk:storage.get" for Laptop.storage.get or "sdk:account.get" for Laptop.account(). The handler gets the call's arguments.

js
Laptop.mock("getOrders", () => ({ orders: [{ id: 1, item: "lockpick", amount: 2 }] }))
    .mock("buyItem", (data) => ({ message: `Bought ${data.item}` }))
    .mock("sdk:pay.show", ({ paymentId }) => ({ paymentId, status: "settled", method: "bank", amount: 2500 }))
    .mock("sdk:storage.get", ({ key }) => (key === "plan" ? { tier: "premium" } : null));

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.