False until the first transaction fetch has come back.
Same signal createCrudStore exposes, for the same reason: an empty list is not the
same statement as "this account has no transactions", and Bank used to make the
second one while still waiting for the first.
Read-only as far as apps are concerned: this reflects what the server said, and
nothing an app does can change it. Gating a screen on it hides a button, never a
capability — the privileged action itself is checked again server-side.
Whether the player holds an admin ace, as decided by the server.
One store rather than each screen asking for itself. Settings already fetched this
privately to gate Developer Tools, and the home screen needs the same answer to
decide whether the Administration app exists — two copies of one fact drift, and the
whole point of asking the server is that there is a single authority.
It decides what the UI shows and nothing more. Every privileged action is checked
again server-side, because a NUI request is not proof of intent (AGENTS.md §2.9).
A plain browser has no ace list and no server to ask, so it stands in as allowed.
refreshAdmin: ()=>Promise<void>
Ask once per session. Safe to call from anywhere that needs the answer.
Implementation of the useAppLevels facet — see the useAppLevels hook doc for the
usage contract (the two-things-have-to-happen and appId-stops-Back rules).
Fetch an add-on's bundle text, never its component — the sandboxed iframe
transport runs it, this registry only ever hands over bytes. Idempotent, same as
loadComponent.
The manifest for an app the shell can render — installed, or a bundled add-on.
The shell holds app ids; anything shown to a player needs the manifest's name.
Without this the error boundary rendered the id rather than the manifest's
name, which are not the same string.
The addOns fallback exists because installed and renderable are not the same
set for a core: false app. isKnownApp already counts a bundled add-on as known
whether or not it has been installed, so openApp('notes') from a ?app= deep
link legitimately makes it the current app — but Shell.svelte renders through
{#if manifest && ...}, so an installed-only lookup left that path on a permanent
spinner. Opening an uninstalled add-on straight from a deep link is by design (it
is how the dev harness reaches one), so the manifest has to resolve for it. It is
still a fact about the build, not the install: addOns is written once at startup
from the manifest glob and nothing that is not in this bundle can appear in it.
The only way a remote app is ever installed. Builds the manifest from entry —
never from anything the fetched bundle itself claims to be — after the bundle's
bytes are hash-verified against entry.sha256. See installVerified below.
isInstalled: (appId:string)=>boolean
Whether the app has actually been installed — the question getManifest used to be
asked in place of, and stopped being able to answer.
getManifest resolves a bundled add-on that has never been installed (see its own
comment), which is right for rendering one opened by a deep link and wrong for any
gate that means "the player has this app". nuiMessages' appEvent is exactly such a
gate: it reads the manifest's permissions to decide whether a pushed toast is
allowed, and with the fallback in place a never-installed Blabber would have started
raising toasts. Installed-ness is its own fact, so it gets its own question.
isKnownApp: (appId:string)=>boolean
Whether the app exists, regardless of whether its chunk has arrived.
Register a manifest with its bundle as source text, never executed here.
source given explicitly — a catalog install (installVerified, already
hash-verified) or a dev-registered add-on — is stashed as-is. Omitted, manifest.id
must be one of this build's own bundled add-ons (addOnIds); its text is left for
getAddOnSource to fetch lazily, on first open, rather than eagerly here.
Register a manifest with its component already loaded — the in-process path.
Stays for core apps, which always ran in-process. For a core: false manifest
this is dev-only: error_boundary.spec.ts registers runtime crash fixtures
through it, and nothing shipped is meant to reach it — a real add-on registers
through registerAddOn below, as source text, and runs sandboxed. Blocked outside
import.meta.env.DEV so a production build can't be handed a live component for
an app that never went through the sandboxed transport.
Implementation of the appStorageBytes facet — see the appStorageBytes hook doc for
the usage contract. Keys are counted alongside values here: both occupy the quota.
The camera hardware — whether a capture is in flight, and whether the shot just taken
is being previewed.
The gallery half moved to useMedia. One hook covering both meant an app that only
wanted to show pictures had to go through something called "camera", and the Photos
app did exactly that.
Split out of useSystemHardware, which had grown to mean "anything the shell owns".
A 12-versus-24-hour preference is not hardware — it is a locale setting that happens
to live next to the clock, and an app asking for the time should not have to reach
through battery and signal to find it.
The time already rendered in the player's chosen form.
Exposed so nothing re-implements the 12/24 branch. The status bar and the Display
preview both show a clock, and two formatters would be one preference with two
answers.
is24Hour: Writable<boolean>
Whether to render it in 24-hour form. Writable: Settings toggles it.
Implementation of the useDeepLink facet — see the useDeepLink hook doc for the
usage contract (the residency rules on when handle runs and what its return means).
A rune file rather than a plain module: the handler reads props and stores, and only
an effect re-runs when those change.
Settings was importing ../../store/devtools directly, which §2.7 prohibits outright
— reaching into a shell store is the exact thing that stops an app working as a
standalone add-on. It is the only store an app module was still reaching for.
The flag is session-scoped and not persisted; see store/devtools.ts for why.
Its own hook rather than a corner of useSystemHardware, for the reason useClock
was split out: that hook means battery, signal and the volume buttons, and how large
the frame is rendered is none of those. It is the window's business, and the only app
with a reason to touch it is Settings.
Applies a new grid size and reflows anything the shrink pushed out of bounds. The
setter alone would leave those items structurally valid but unreachable — a shrink
is the one time homeGridItems needs touching from outside homeGrid.ts itself, so
this bundles the write and the reflow into one call rather than asking every caller
to remember the second step.
OS Service Hook for the shared, cross-game leaderboard table.
The only door into gphone_highscores — no app, core or add-on, reaches the table any
other way. app is the game's own id ('snek' today); a second game reuses this hook rather
than adding a table.
The action already using this key in the same context, if any. Two actions may
share a key when their contexts are disjoint — Enter is both Answer Call and the
camera shutter, and only one is ever eligible.
Everything configurable from gPhone's own Shortcuts screen, grouped by owner.
Core first (ownerId: 'core'), then one group per installed app that declares its
own keybinds, sorted alphabetically by ownerLabel. An app with no declared
keybinds contributes no group at all, rather than an empty one.
Claim an action for as long as this component is mounted.
Pass appId for anything an app claims. Apps are resident, so the claim outlives
the app being on screen, and without an owner the dispatcher hands the action to
whichever app registered last — see the registry note in shell/state/keybinds.ts.
Only actions carrying their own when: 'app:…' context are safe without it, and
naming the app costs nothing either way.
GPHONE-27: the one implicit, no-permission door a sandboxed add-on's baseline plumbing
reaches the shell through — onAppForeground/onAppUnmount (this file's own twin, over
currentApp), useDeepLink (consumeDeepLink), the onback prop every app gets
(goHome), and useAppLevels's physical Back binding (onBack).
Replaces IframeHostServer.ts's old isImplicitNavPlumbing — a hand-maintained
facet/member allow-list keyed on literal strings, which is exactly the shape of bug
GPHONE-31 was: a legitimate implicit caller (consumeDeepLink) simply wasn't on the
list. A dedicated facet with its own null permission needs no such list — every
member here is implicit by construction, and the general navigation/keybinds
facets this used to borrow members from now require their real declared permissions
unconditionally, with no exemption at all.
appId is this facet's one factory argument, so IframeHostServer.ts pins it via
APP_SCOPED_FACETS exactly like storage/deepLink — a raw message naming lifecycle
directly can state its own facet id (factoryArgs[0]) all it wants; the shell replaces
it before the factory ever sees it.
Share the caller's current in-game position, and set a GPS waypoint from a location a
message already carries. An action pair rather than a data subscription — closer in
shape to useCamera than to useContacts — so there is no store here to read.
The media gallery — the client face of the media service.
Split out of useCamera, which owned both the hardware and the library. They are
used by different apps for different reasons: Media reads the gallery and never
touches the shutter, and an app attaching an image to a message wants neither.
OS Service Hook for phone navigation (opening apps, returning home, closing phone shell).
consumeDeepLink used to live here, reachable only through an isImplicitNavPlumbing
exemption since useDeepLink is implicit and no real caller ever held navigation
permission for it. Moved to the dedicated lifecycle facet (GPHONE-27) — this facet's
members now all genuinely require the navigation permission, no exemption at all.
Implementation of the onAppForeground facet — see the onAppForeground hook doc for
the usage contract (the stale-balance rationale for why this exists).
Parameters
appId: string
handler: ()=>void
Returns ()=>void
onAppUnmount
onAppUnmount:(handler:()=>void)=>void
Type Declaration
(handler:()=>void):void
Executes a cleanup callback when the application component is unmounted or closed.
Split from useReports — the moderation queue — because the two are different
capabilities: every social app lets a player file a report, and only Admin reads them.
ReportDialog is the usual caller; an app that files reports from its own UI uses this.
Implementation of the useService facet — see the useService hook doc for the usage
contract (what this does and does not change, whose service an id may name).
defaultValue behaves as it does everywhere else in the SDK: a failed round trip
resolves to it rather than throwing, so a missing server half degrades to an empty
list instead of a crashed app. Omit it when a failure should surface — a write
wrapped in useAppAction wants the error so it can toast it.
id: string
sound
sound:()=>{play:(effect:SoundEffect)=>void}
Type Declaration
():{play:(effect:SoundEffect)=>void}
The phone's sound effects.
SoundService has been complete and unit-tested since the shell existed — volume- and
mute-aware, used by AppIcon, ToggleSwitch and SegmentedControl. It was simply
never exported, so the only route to it was ../../shell/state/audio, which
boundary.test.ts rejects. An app had no sanctioned way to make a noise.
That is the SegmentedControl failure mode again: written, working, left out of the
barrel. The volume controls live in useSystemHardware — this is playback.
Returns {play:(effect:SoundEffect)=>void}
play: (effect:SoundEffect)=>void
Play one of the phone's built-in effects. Silent while muted.
The wall-side route for clearAppStorage (GPHONE-16 step 4): that facet is a bare
function, not a factory, so a remoteCall naming it has no member to call. This
member is what the iframe twin's clearAppStorage(appId) actually reaches.
Serves the iframe persisted twin's markUnsynced (GPHONE-16 step 4): an add-on
cannot import settingsSync directly, so this is the one member of the facet that
reaches it on the add-on's behalf.
Bluetooth state management and anti-doxxing privacy controls.
Defaults to ON (true) and persists state across app launches and phone restarts.
When OFF, the device is invisible to proximity scans and blocks unsolicited contact
sharing or snooping attempts.
cellServiceEnabled: Writable<boolean>
Cellular network service state. Defaults to ON (true) and persists across reloads.
When OFF, cell service is disabled and clamped signal level is forced to 0 bars.
charge: Writable<number>
isBluetoothDiscoverable: Readable<boolean>
Derived store indicating whether the device is visible/discoverable for Bluetooth proximity features.
setSignal: (level:number)=>void
setVolume: (val:number)=>void
setVolumeStep: (percent:number)=>void
signalLevel: Writable<number>
soundMuted: Writable<boolean>
soundVolume: Writable<number>
toggleBluetooth: ()=>void
toggleCellService: ()=>void
toggleMute: ()=>void
volumeStep: Writable<number>
How far one physical volume-button press moves the volume, in whole percent.
What a given seed would look like, without applying it.
The swatch a preset button renders is the wallpaper that preset produces, generated the
same way — so the button cannot advertise one thing and set another, which is exactly
what a hand-written gradient beside a hand-written seed allowed.
Use a photo, optionally re-seeding the theme from its dominant color.
The seed is separate because a photo does not have to dictate the accent — and
seedFromImage returns null when it cannot read one, in which case the picture
changes and the colors stay put.
setWallpaperSeed: (seed:string)=>void
Pick a color: the wallpaper and every role in the phone follow it together.
wallpaperBackground: Readable<string>
The CSS background for whatever is currently set.
One value, one format, whichever kind of wallpaper it is — so PhoneFrame writes it to
one property and never chooses between a class and a style.
wallpaperNeedsContrast: Readable<boolean>
Whether text drawn over the wallpaper needs the .text-on-wallpaper treatment.
Every other setting follows the player's citizenid to any machine. This one cannot
affordably: a custom wallpaper is a base64 data URL of unbounded size, and syncing it
would push megabytes across the NUI bridge and into MySQL every time the color changed.
The part players actually notice does still follow them — the seed and light/dark mode
live in themeStore, which syncs, and they are what generate the whole scheme. What
stays behind is a photo you set from this machine's own gallery.
One key per facet function. The runtime object living behind this shape is the
Proxyexported asfacetsfrom../../current.ts.