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,RegisterSiteandUnregisterSiteare server exports.AddBookmark,SendAppMessage,SendSiteMessageandSetAppBadgeare 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
idagain from the same resource replaces the earlier registration.
Config.Sdkinshared/unlocked/sdk.luaturns each of the page's features on or off, and itsallowlistlimits them to the resources you name.
Types / Classes
SdkAppRegistration,SdkSiteRegistration,SdkAccessRules,SdkRoomsRegistrationandSdkCastRegistrationare intypes/sdk.lua, andLaptopAppRegistration,LaptopSiteRegistration,LaptopAppDocument,LaptopWindowOptionsandLaptopBookmarkintypes/laptop.luainsidezyke_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:
lualocal 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:
luafiles {
"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, orfalseand an error with the reason in the server console:
"invalid":id,labeloruiis missing or invalid,idbelongs to a built-in app,oslists no known OS, oraccessorpriceis invalid."taken": another started resource registered theid.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"}, })
| Field | Default | Description |
|---|---|---|
id | Required | Unique 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. |
label | Required | The app's name. |
ui | Required | The 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. |
description | None | Shown in the Applications menu and the app store. |
window | OS 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. |
favorite | false | Lists the app under Favorites in the Applications menu. |
requiresConnection | false | Shows a "connect first" screen instead of the app while the Onion connection is down. The app store shows that the app needs it. |
singleInstance | true | Opening the app again brings its open window forward and passes it the new params. false opens a new window each time. |
desktop | true | Puts a shortcut on the desktop of each account that gets the app. See Desktop Shortcuts. |
preinstalled | false | Installs the app for each account the first time it sees it, instead of offering it in the app store. See The App Store. |
os | Every OS | The OSes the app shows on. See Apps for Some Operating Systems. |
documents | None | File types the app owns. See Document Types. |
storage | None | true, or {maxBytes}, for Laptop.storage. See Storage. |
payments | Every allowed payee | {payees = {"society:fleeca"}} limits who the app's payments may pay. See Payments. |
rooms | None | true, or a table, for Laptop.rooms. See Rooms. |
cast | None | The app's viewer page for screens. See Casting. |
access | Everyone | Who sees the app at all. See Access Rules. |
price | Free | Sells the app in the store. See Paid Apps. |
permissions | None | What 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, orfalseand an error with the reason in the server console:
"invalid":id,url,titleoruiis missing or invalid,accessis invalid, or it has aprice."taken": another started resource registered theid, 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, })
| Field | Default | Description |
|---|---|---|
id | Required | Unique site id: letters, digits, _ and -, up to 64. |
url | Required | The 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. |
title | Required | The site's name in tabs, search results and the directory. |
ui | Required | The page shown in the browser: a path inside your resource or an https:// address. |
description | None | Shown in search results and the directory. |
keywords | None | Words search matches without showing them. |
icon | Globe or onion | A Lucide icon name or an image URL, shown on the tab and the new tab page. |
listed | true | false keeps the site out of search and the directory. Players only reach it by its address. |
category | None | The section it is listed under. See Site Categories. |
network | From the address | "clearnet" or "onion". See Networks. |
monitored | false | Records visits as traces while browsing.record is "monitored". See Listing and Monitoring. |
storage, payments, rooms, access, permissions | None | The 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
trueonce the laptop has the bookmark or keeps it for the next boot, even if the player already had it, andfalseonly whenidorurlis 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
messageevent. Only an open page gets it. Returnsfalsewhile 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
messageevent. Only an open page gets it. Returnsfalsewhile 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.badgein the page. The count is rounded down and kept between 0 and 999, and0hides it. Returnsfalsefor 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:
luaexports["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.
#Paid Apps
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:
| Permission | Description |
|---|---|
account | Laptop.account(). The first call asks the player (Allow / Don't allow), and the answer is kept per account. |
clipboard | Laptop.clipboard.readText(), asked the same way. Writing to the clipboard needs no permission. |
job | Adds the job to Laptop.account() while Config.Sdk.account.job is on. |
notifications | Notifications 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:
luaexports["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,
},
})
| Field | Default | Description |
|---|---|---|
maxMembers | Config.Sdk.rooms.maxMembers | Members per room, from 2 up to the config's. |
messageBytes | Config.Sdk.rooms.maxMessageBytes | Bytes one message may take as JSON, from 64 up to the config's. |
rate | Config.Sdk.rooms.rate | {count, rate}: a burst of count messages per player and room, then rate a second. |
join | None | function(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.
luaexports["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:
luadocuments = {{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 = trueon 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.preinstalledin the Config lists app ids that new accounts start with. It takes the ids of registered apps as well asconnection,chat,messenger,paintandslides.appStore.enabled = falseremoves 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.
| Category | For |
|---|---|
government | The city, the police, courts and other public offices. |
services | Services a player books or calls: towing, mechanics, parking, taxis, deliveries. |
business | Companies presenting themselves: opening hours, menus, locations, jobs. |
shopping | Stores that sell online. |
finance | Banks, loans, insurance and investing. |
news | News, radio and other media. |
community | Forums, social sites, clubs and events. |
games | Games and entertainment. |
search | Search engines and directories. |
other | Everything 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:
| Where | Lists |
|---|---|
| Bling, QuackQuackGo, LS Pages | Listed clearnet sites. |
| New tab page of Ledge and Emberfox | Listed clearnet sites, with LS Pages for the rest. |
| Hush | Every listed site. |
| Onion Directory | Listed 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
idonce 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
idthe 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:
luaRegisterNUICallback("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:
luaZ.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.