Documentation

Apps & Sites

Add your own apps and websites to the laptop from another resource.

Other resources add apps and sites to the laptop from a server script. An app opens in its own window on the desktop and is listed in the Applications menu. A site opens in the browser at the address you give it. Both are web pages in your own resource, shown in an iframe, so build them with anything you like. scrollIntoView() and focus() scroll only your page, never the window or browser around it.

The registration holds everything the OS shows and everything the server allows the page: its storage, payments, rooms, casting, who may use it, its price and the permissions it may ask for. The server sends the page part to every client and checks the rest on every call the page makes, so nothing a page claims on the client decides what it gets.

The shipped example sites are complete starting points: a business site, a police application site with an app for officers, and two games with leaderboards.

App Templates

The zyke_laptop app templates repository holds ready-to-copy resources: an app template in plain HTML, CSS and JavaScript, a website template, and a Vite, React and TypeScript template. It also has full examples, such as a bank and a shop that take payments on the pay sheet, a notes app with its own file type, a data stick dead drop, and realtime tic-tac-toe through rooms. Copy the one closest to what you want, rename it and build from there.

General Information

RegisterApp, UnregisterApp, RegisterSite and UnregisterSite are server exports. AddBookmark, SendAppMessage, SendSiteMessage and SetAppBadge are client exports, since they act on the player's own OS. A resource that wants to reach one player's page from the server triggers its own client event and calls them there.

Registrations belong to the resource that made them and are removed when it stops. Registering the same id again from the same resource replaces the earlier registration.

Config.Sdk in shared/unlocked/sdk.lua turns each of the page's features on or off, and its allowlist limits them to the resources you name.

Types / Classes

SdkAppRegistration, SdkSiteRegistration, SdkAccessRules, SdkRoomsRegistration and SdkCastRegistration are in types/sdk.lua, and LaptopAppRegistration, LaptopSiteRegistration, LaptopAppDocument, LaptopWindowOptions and LaptopBookmark in types/laptop.lua inside zyke_laptop.

#Registering

The laptop forgets every registration when it restarts and fires the server event zyke_laptop:Ready once it is back. Register on that event and when your own resource starts:

Example:

lua
local function registerLaptopContent()
    exports["zyke_laptop"]:RegisterApp({
        id = "my_app",
        label = "My App",
        icon = "briefcase",
        category = "utilities",
        description = "Shown in the Applications menu and the app store",
        ui = "web/app.html",
        window = {width = 720, height = 480, minWidth = 480, minHeight = 320, resizable = true},
        storage = true,
    })

    exports["zyke_laptop"]:RegisterSite({
        id = "my_market",
        url = "mymarket7xq2v5k3.onion",
        title = "My Market",
        description = "Shown in the search results and the directory",
        ui = "web/site.html",
        category = "shopping",
    })
end

AddEventHandler("zyke_laptop:Ready", registerLaptopContent)

if (GetResourceState("zyke_laptop") == "started") then registerLaptopContent() end

Add the pages to your own fxmanifest.lua so the laptop can load them:

lua
files {
    "web/**/*",
}

A ui that is a path, such as web/app.html, is loaded from your resource at https://cfx-nui-<your_resource>/web/app.html. An https:// address is used as it is. Any other scheme is refused, since the page couldn't load the SDK.

A field with a bad value is left out with a warning in the server console, and so is a field the laptop doesn't know. A bad access or price refuses the whole registration instead, so a typo never opens a job app to everyone or makes a paid app free. laptop_sdk_check prints the warnings again; see Developer Tools.

Older versions registered apps and sites with client exports and fired zyke_laptop:Ready on the client. Both are gone: register on the server. laptop_sdk_check finds resources still using the old names.

#Server Sided Exports

#Register App

Adds an app for every player. Returns true, or false and an error with the reason in the server console:

  • "invalid": id, label or ui is missing or invalid, id belongs to a built-in app, os lists no known OS, or access or price is invalid.
  • "taken": another started resource registered the id.

Example:

lua
---@param app SdkAppRegistration
---@return boolean registered
---@return string? err
local registered, err = exports["zyke_laptop"]:RegisterApp({
    id = "my_app",
    label = "My App",
    icon = "briefcase", -- Lucide icon name (https://lucide.dev/icons) or an image URL
    category = "utilities",
    description = "Shown in the Applications menu and the app store",
    ui = "web/app.html", -- A path inside your resource, or an https:// address
    window = {width = 720, height = 480, minWidth = 480, minHeight = 320, resizable = true},
    favorite = false,
    requiresConnection = false,
    singleInstance = true,
    desktop = true,
    preinstalled = false,
    os = {"facade", "lumen"}, -- Leave it out to show the app on every OS
    documents = {{extension = "ledger", label = "Ledger", icon = "book", color = "#2f7d4f"}},
    storage = {maxBytes = 65536},
    access = {jobs = {mechanic = 0}},
    permissions = {"account"},
})
FieldDefaultDescription
idRequiredUnique app id: letters, digits, _ and -, up to 64. connection, browser, settings, terminal, hacking, editor, files, calculator, chat, messenger, mail, store, crypto, mdt, racks, devicemanager, viewer, paint, slides and recovery belong to built-in apps and are refused.
labelRequiredThe app's name.
uiRequiredThe page shown in the window: a path inside your resource or an https:// address.
icon"box"A Lucide icon name or an image URL.
category"other"accessories, internet, office, system, utilities or other. The section of the Applications menu and the app store it is listed under. Any other value is other.
descriptionNoneShown in the Applications menu and the app store.
windowOS default{width?, height?, minWidth?, minHeight?, resizable?, maximized?}, in pixels. maximized = true opens the window maximised. Registered apps always keep the OS's title bar, so chrome = "none" is dropped with a warning.
favoritefalseLists the app under Favorites in the Applications menu.
requiresConnectionfalseShows a "connect first" screen instead of the app while the Onion connection is down. The app store shows that the app needs it.
singleInstancetrueOpening the app again brings its open window forward and passes it the new params. false opens a new window each time.
desktoptruePuts a shortcut on the desktop of each account that gets the app. See Desktop Shortcuts.
preinstalledfalseInstalls the app for each account the first time it sees it, instead of offering it in the app store. See The App Store.
osEvery OSThe OSes the app shows on. See Apps for Some Operating Systems.
documentsNoneFile types the app owns. See Document Types.
storageNonetrue, or {maxBytes}, for Laptop.storage. See Storage.
paymentsEvery allowed payee{payees = {"society:fleeca"}} limits who the app's payments may pay. See Payments.
roomsNonetrue, or a table, for Laptop.rooms. See Rooms.
castNoneThe app's viewer page for screens. See Casting.
accessEveryoneWho sees the app at all. See Access Rules.
priceFreeSells the app in the store. See Paid Apps.
permissionsNoneWhat the page may ask the player for. See Permissions.

#Unregister App

Removes one of your resource's apps before it stops.

Example:

lua
---@param appId string
exports["zyke_laptop"]:UnregisterApp("my_app")

#Register Site

Adds a site to the browser for every player. Returns true, or false and an error with the reason in the server console:

  • "invalid": id, url, title or ui is missing or invalid, access is invalid, or it has a price.
  • "taken": another started resource registered the id, or another site has the address.

Example:

lua
---@param site SdkSiteRegistration
---@return boolean registered
---@return string? err
local registered, err = exports["zyke_laptop"]:RegisterSite({
    id = "my_market",
    url = "mymarket7xq2v5k3.onion", -- What the player types into the address bar
    title = "My Market",
    description = "Shown in the search results and the directory",
    keywords = {"market", "goods"},
    icon = "store",
    ui = "web/site.html",
    listed = true,
    category = "shopping",
    network = "onion",
    monitored = false,
})
FieldDefaultDescription
idRequiredUnique site id: letters, digits, _ and -, up to 64.
urlRequiredThe address the player types, such as lscustoms.com or mymarket7xq2v5k3.onion. It is compared in lower case, without a scheme or trailing slash. No player can register it as a domain.
titleRequiredThe site's name in tabs, search results and the directory.
uiRequiredThe page shown in the browser: a path inside your resource or an https:// address.
descriptionNoneShown in search results and the directory.
keywordsNoneWords search matches without showing them.
iconGlobe or onionA Lucide icon name or an image URL, shown on the tab and the new tab page.
listedtruefalse keeps the site out of search and the directory. Players only reach it by its address.
categoryNoneThe section it is listed under. See Site Categories.
networkFrom the address"clearnet" or "onion". See Networks.
monitoredfalseRecords visits as traces while browsing.record is "monitored". See Listing and Monitoring.
storage, payments, rooms, access, permissionsNoneThe same as for an app. A site can't cast, and a price refuses the registration.

#Unregister Site

Removes one of your resource's sites before it stops.

Example:

lua
---@param siteId string
exports["zyke_laptop"]:UnregisterSite("my_market")

#Client Sided Exports

#Add Bookmark

Adds a bookmark to the player's browser. Returns true once the laptop has the bookmark or keeps it for the next boot, even if the player already had it, and false only when id or url is missing. See Bookmarks.

Example:

lua
---@param bookmark LaptopBookmark
---@return boolean kept
local kept = exports["zyke_laptop"]:AddBookmark({
    id = "my_market", -- Unique within your resource, each id is added once
    url = "mymarket7xq2v5k3.onion",
    title = "My Market",
    force = false, -- true adds it back even if the player deleted it
})

#Send App Message

Sends data to your app's page, where the SDK raises it as the message event. Only an open page gets it. Returns false while no OS runs.

Example:

lua
---@param appId string
---@param data any
---@return boolean delivered
exports["zyke_laptop"]:SendAppMessage("my_app", {orders = orders})

#Send Site Message

Sends data to your site's page, where the SDK raises it as the message event. Only an open page gets it. Returns false while no OS runs.

Example:

lua
---@param siteId string
---@param data any
---@return boolean delivered
exports["zyke_laptop"]:SendSiteMessage("my_market", {listings = listings})

#Set App Badge

Puts a count on the icon of one of your resource's apps, the same as Laptop.badge in the page. The count is rounded down and kept between 0 and 999, and 0 hides it. Returns false for another resource's app, an unknown app, or while no OS runs.

Example:

lua
---@param appId string
---@param count integer @ 0 hides it
---@return boolean set
exports["zyke_laptop"]:SetAppBadge("my_app", #unreadOrders)

#Access Rules

access decides who sees an app or site at all:

lua
exports["zyke_laptop"]:RegisterApp({
    id = "mdt_tools",
    label = "MDT Tools",
    ui = "web/index.html",
    access = {
        jobs = {police = 2, sheriff = 0},   -- Job to lowest grade, or a list of names for any grade
        onDuty = true,                      -- With jobs: only while on duty
        items = {"police_badge"},           -- Carries any of them
        ace = {"zyke_laptop.app.mdt_tools"},
        any = false,                        -- true: one passing rule is enough, false: every listed rule must pass
    },
})

access needs at least one of jobs, items or ace. A player the rules refuse doesn't see the app in the store, the menus, the desktop or search, and a site shows the browser's "can't reach" page. The server checks the rules on every call, so the app appears and goes as the job changes, and losing access closes its open windows with a notification. A job change shows at once; items and ACE are checked again within a few seconds on the next call. CanUseLaptopApp in server/unlocked/hooks.lua gets the last word.

For your own callbacks behind Laptop.post, CanUseApp answers the same rules plus the licence.

price = {amount = 2500, currency = "either", payee = "society:fleeca"} puts the price on the store's Get button. currency is "bank" (the default), "crypto" or "either", and payee is "server" (the default), "society:<name>" or "player:<identifier>", as Config.Sdk.payments.payees allows. The amount is a whole number from 1 to Config.Sdk.payments.maxAmount.

Buying runs the same pay sheet as Laptop.pay, and the app downloads once it is paid. Until then the app counts as not installed and its server calls answer notLicensed. A licence belongs to the account (Nimbus or local) by default, so it follows a Nimbus account to every laptop. Config.Sdk.store.licenceOwner = "character" gives it to the character instead. Guest accounts and Veil can't buy under "account", and on Veil a paid app only shows with a character licence. A refunded store purchase takes the licence back.

With Config.Sdk.store.paidApps or Config.Sdk.payments.enabled off, a price is left out with a warning and the app is free. See Access and Licences for checking, granting and revoking licences from your server.

#Permissions

permissions declares what the page may ask the player for:

PermissionDescription
accountLaptop.account(). The first call asks the player (Allow / Don't allow), and the answer is kept per account.
clipboardLaptop.clipboard.readText(), asked the same way. Writing to the clipboard needs no permission.
jobAdds the job to Laptop.account() while Config.Sdk.account.job is on.
notificationsNotifications need no declaration and stay on until the player turns them off.

Settings → Privacy lists every app with a switch per permission and Clear app data for its storage. On Veil the account and clipboard are refused without asking unless Config.Sdk.permissions.veil is "prompt". Config.Sdk.allowlist = {my_bank = {"account"}} limits what a resource may get; a permission outside its list answers notAllowed.

#Rooms

rooms lets the pages of the same app or site on different laptops talk in realtime through Laptop.rooms.join:

lua
exports["zyke_laptop"]:RegisterApp({
    id = "fleeca_bank",
    label = "Fleeca",
    ui = "web/index.html",
    rooms = {
        maxMembers = 8,
        messageBytes = 2048,
        rate = {count = 20, rate = 10},
        -- Optional: refuse a join, or give the member data the others see, such as a name
        join = function(plyId, room, data)
            return true, {name = GetPlayerName(plyId)}
        end,
    },
})
FieldDefaultDescription
maxMembersConfig.Sdk.rooms.maxMembersMembers per room, from 2 up to the config's.
messageBytesConfig.Sdk.rooms.maxMessageBytesBytes one message may take as JSON, from 64 up to the config's.
rateConfig.Sdk.rooms.rate{count, rate}: a burst of count messages per player and room, then rate a second.
joinNonefunction(plyId, room, data), returns true and the member's data, or false. Return false, "noAccess" to refuse with noAccess; any other refusal is denied. An error refuses with failed.

A player joins up to Config.Sdk.rooms.maxRoomsPerPlayer rooms, and with Config.Sdk.rooms.requireNetwork on, only while the laptop is online. They leave when the window closes, the laptop powers off or goes offline, or they drop. Your server takes part with the room exports.

#Casting

An app can show its own viewer page on the TVs, monitors and projectors the laptop casts to. The page never sends pixels: it sends content, your server checks it, and the screen's viewer page draws it.

lua
exports["zyke_laptop"]:RegisterApp({
    id = "fleeca_bank",
    label = "Fleeca",
    ui = "web/index.html",
    cast = {
        ui = "web/cast.html",                     -- A page of this resource, which loads laptop-cast.js
        snapshot = function(content) return {balance = tonumber(content.balance) or 0} end,
        delta = function(delta) return delta end, -- Optional, left out drops deltas
        signal = function(signal) return nil end, -- Optional, nil drops the message
    },
})

ui has to be a page of your own resource. snapshot is required and checks what the window sends with Laptop.cast.snapshot, and delta and signal check the other two kinds of message. Whatever they return is what every viewer sees, so return plain values, and use IsCastImageAllowed and ToCastText for images and text. An error in one of them drops the message.

Only apps can cast, and only while Config.Sdk.cast.enabled is on. The screens and limits are the laptop's own; see Casting. For the page side, see Casting in the JavaScript SDK.

#Document Types

An app can own file types, so Files shows them with the app's emblem and label and opens them in the app:

lua
documents = {{extension = "ledger", label = "Ledger", icon = "book", color = "#2f7d4f"}},

extension is up to 8 lower case letters and digits, without the dot, and label up to 40 characters. icon is a Lucide name and color a #rrggbb colour. An app holds up to 8. Extensions the OS opens itself, such as txt, png, key, draw or deck, and ones another resource's app claimed first are left out with a warning. The app finds the file it was opened with in Laptop.context.files, or in the filesOpened event when its window was already open.

#The App Store

Facade, Lumen and Solace have an app store on the desktop. The system apps come with the OS. Registered apps are offered in the store and show nowhere else until the account downloads them, the same as Shroud, Natter, the Onion network app, Paint and Slides. The store lists each app with its label, icon, description and price, sorted into sections by category. Downloading needs the laptop to be online.

OpenApp, Laptop.openApp and a notification for an app the account has not downloaded open the store on that app's page instead.

Three settings skip the store:

  • preinstalled = true on the registration installs the app for each account the first time it sees it. An account that removes it in the store keeps it removed.
  • appStore.preinstalled in the Config lists app ids that new accounts start with. It takes the ids of registered apps as well as connection, chat, messenger, paint and slides.
  • appStore.enabled = false removes the store and installs every app from the start.

Veil and the Crowbar recovery toolkit run live, so Veil has every registered app without the store. Crowbar shows no registered apps at all; sites still open in its browser. See Operating Systems for the store from the player's side.

#Desktop Shortcuts

The first time an account on Facade, Lumen or Solace gets an app, from the store or preinstalled, the app puts a shortcut on that account's desktop, the way an installer does. Set desktop = false to leave it off.

The shortcut is a file in the account's Desktop folder: My App.lnk on Facade, My App.desktop on Lumen and My App alias on Solace. The player can open, rename, move and delete it like any other file, and it stays on the drive with the account. The laptop keeps the ids of apps that already had a shortcut, so a shortcut the player deleted doesn't come back, and an app whose resource restarts keeps its one shortcut. Removing the app in the store removes its shortcuts; downloading it again adds one.

Veil keeps no home between boots, so it shows no shortcuts.

#Apps for Some Operating Systems

An app with an os list only shows on those OSes: "facade", "lumen", "solace" or "veil". On any other OS it is left out of the menus and the store, and OpenApp, Laptop.openApp and a notification for it don't open it.

Unknown ids are dropped with a warning in the server console. A list left without a known id refuses the registration, so a typo can't show the app on every OS. To change an app's OSes, register it again.

#Site Categories

A site's category is the section it is listed under: in LS Pages for a clearnet site, also shown next to it in Bling and QuackQuackGo, and in the Onion Directory for an onion site. Every search engine, Hush included, matches it in searches. Leave it out or use "other" when nothing fits. Any other value is listed as "other" with a warning in the server console.

CategoryFor
governmentThe city, the police, courts and other public offices.
servicesServices a player books or calls: towing, mechanics, parking, taxis, deliveries.
businessCompanies presenting themselves: opening hours, menus, locations, jobs.
shoppingStores that sell online.
financeBanks, loans, insurance and investing.
newsNews, radio and other media.
communityForums, social sites, clubs and events.
gamesGames and entertainment.
searchSearch engines and directories.
otherEverything else.

#Networks

A site's network decides whether it needs the Onion connection. Without one it is taken from the address: .onion addresses are onion sites and everything else is clearnet. Facade, Lumen and Solace open clearnet sites without the Onion connection. On Veil every site needs it.

Every site needs the laptop to be online through a hotspot or a phone. See Wi-Fi & Browsing.

#Listing and Monitoring

Listed sites show up in the browser's search engines and directories:

WhereLists
Bling, QuackQuackGo, LS PagesListed clearnet sites.
New tab page of Ledge and EmberfoxListed clearnet sites, with LS Pages for the rest.
HushEvery listed site.
Onion DirectoryListed onion sites.

listed = false keeps a site out of all of them. See Bookmarks for handing its address to players.

monitored = true records visits to the site as visit traces while browsing.record is "monitored". The visiting client reports the flag with the visit, so for a site that matters, mark it with the SetSiteMonitored export as well.

#Bookmarks

A site registered with listed = false stays out of search and the directory, so players only find it by its address. To hand the address out in the world, for example when a contact trusts the player, add it to their bookmarks with AddBookmark.

Ledge, Emberfox, Savanna and Veil Browser share the player's bookmarks, so the bookmark shows in whichever browser the player opens, also after they boot another OS. Bookmarks are kept on the player's PC, like the browsers' other data.

  • The laptop adds each id once and saves that with the bookmarks, so one the player deleted stays deleted.
  • The laptop doesn't have to be out. A bookmark given while no OS runs waits on the player's PC, through relogs, and the browser takes it the next time an OS boots.
  • Call it again when the player loads in. A call for an id the browser already added does nothing, and one that never reached the browser is added then.
  • When the player asks for the address again in the world, pass force = true. It adds the bookmark back even if they deleted it, but never twice if they still have it.
  • At most 50 bookmarks wait at once. The oldest are dropped first.

The bookmark is only a shortcut. Anyone who types the address still reaches the page, so decide on your server what the page shows them.

#Talking to the Page

Your page talks to its own resource the way any NUI page does: a fetch to https://<your_resource>/<callback> reaches your RegisterNUICallback. The SDK's Laptop.post does this for you. To push data to the page from Lua, use SendAppMessage or SendSiteMessage.

The OS runs while a laptop is at hand and for a few minutes after the player put it away or walked off. While no OS runs, messages are dropped and both exports return false.

Example:

lua
RegisterNUICallback("buyItem", function(data, cb)
    cb(Z.callback.await("my_resource:BuyItem", data.item))
end)

RegisterNetEvent("my_resource:OrdersChanged", function(orders)
    exports["zyke_laptop"]:SendAppMessage("my_app", {orders = orders})
end)

Include the JavaScript SDK in your pages to receive messages, follow the OS's theme, type into fields on the laptop's screen, and use storage, payments, rooms and the rest of the SDK.

#Server Callbacks

The page runs on the player's client, so anything it sends can be faked. Server callbacks behind a laptop page should check that the player may use the app with the CanUseApp server export and that they are at a laptop with IsAtLaptop, and check jobs and money again, rather than trusting the page:

Example:

lua
Z.callback.register("my_resource:GetOrders", function(plyId)
    if (GetResourceState("zyke_laptop") ~= "started" or not exports["zyke_laptop"]:IsAtLaptop(plyId)) then return nil end
    if (not exports["zyke_laptop"]:CanUseApp(plyId, "my_app")) then return nil end

    return getOrders(plyId)
end)

IsAtLaptop is true while the player holds a laptop or stands at a placed one. For money, use Payments, where your server sets the amount and the laptop takes it.