# Native modules `@ps5-react/core` exposes platform services as React Native-style modules. Apps import them like any other export; they never touch the SDK, libkernel, or SDL directly. ```jsx import {Platform, DeviceInfo, FileSystem, Controller, useGamepad} from '@ps5-react/core'; const {model, firmware} = DeviceInfo.get(); FileSystem.writeFile(`${FileSystem.dataDir}/save.json`, JSON.stringify(state)); Controller.setLightBar('#38bdf8'); ``` [`apps/system-explorer`](../apps/system-explorer) exercises every module. ## Contract The modules in `runtime/js/native.js` wrap `globalThis.__ps5ReactNative`, which each host installs before the bundle runs. Its exact shape (ABI v15) is documented in [`native/shared/host_api.hpp`](../native/shared/host_api.hpp). `native/shared/host_api.cpp` implements the filesystem with POSIX and the object itself; `native/ps5/` and `native/desktop/` implement the platform functions. - **Synchronous bridge.** Each native call returns directly. Networking calls only submit/cancel/poll bounded tasks; `Http.request` and `task.done` expose promises in the JavaScript wrapper while native workers perform I/O. - **Render thread.** Calls run on the same thread as React and the rasterizer. Keep them cheap: avoid listing large directories or reading files every frame. Call them from event handlers, effects, or state initializers rather than repeatedly during render. - **Errors.** A failed call throws an `Error` whose message names the call, the path, and the system error, for example `fs.readDir /download0/missing: No such file or directory`. Wrap calls in `try`/`catch` and show the message. - **Missing host.** If `__ps5ReactNative` does not exist (for example when a module is evaluated outside a PS5 React host), every call throws an error explaining that a PS5 React host is required. - **QuickJS runtime.** The runtime has no `Date` objects (`Date.now()` works), typed arrays, or `Proxy`; values cross the boundary as strings, numbers, booleans, plain objects, arrays, and `null`. ## Platform | Member | Returns | Notes | | --- | --- | --- | | `Platform.OS` | `'ps5' \| 'desktop'` | The running host | | `Platform.select(spec)` | `spec[OS]`, else `spec.default` | Keys: `ps5`, `desktop`, `default` | ```js const label = Platform.select({ps5: 'Console', desktop: 'Preview', default: 'Unknown'}); ``` ## DeviceInfo `DeviceInfo.get()` returns: | Field | Unit | Notes | | --- | --- | --- | | `model` | string | Console model, or `Desktop preview ()` | | `firmware` | string | System software version, or `macOS ` | | `cpuTemperature` | °C | `null` on desktop | | `socTemperature` | °C | `null` on desktop | | `cpuFrequency` | Hz | `null` when unknown (always on Apple silicon) | | `freeMemory` | bytes | Memory available to the app | | `processTime` | µs | CPU time used by this process | | `language` | string | System language as a BCP 47 tag (`en-US`, `pt-BR`, `zh-Hans`) | Any value the host cannot read is `null`. `DeviceInfo.utcOffset(time = Date.now())` (ABI v14) returns the system time zone's offset from UTC at that instant in minutes, summer time included: `120` for Central European Summer Time, `-300` for US Eastern Standard Time. With no `Date` objects in the runtime, local time is arithmetic on `time + utcOffset(time) * 60000`. Pass the instant you are converting, not the current time, when it may fall on the other side of a summer-time change. The PS5 reads the zone set in the console's settings; the desktop preview the Mac's. ## FileSystem By default, the title runs sandboxed. App paths are logical roots; explicit console paths become accessible only after opt-in filesystem access succeeds. | Constant | Path | Access | | --- | --- | --- | | `FileSystem.appDir` | `/app0` | The installed application folder; read-only | | `FileSystem.dataDir` | `/download0` | Writable, persists between launches | | `FileSystem.tempDir` | `/temp0` | Writable scratch space | On the console, listing `/app0` may return an empty array even though files inside it can be read by path. Paths containing `..` components are rejected. | Function | Returns | Notes | | --- | --- | --- | | `readDir(path)` | `FileStat[]` | Entries of a directory, without `.` and `..` | | `stat(path)` | `FileStat \| null` | `null` when the path does not exist | | `exists(path)` | `boolean` | `stat(path) !== null` | | `readFile(path)` | `string` | UTF-8; throws above 8 MiB | | `writeFile(path, text, {sync})` | — | Creates or truncates; `sync: true` flushes it to the disk before returning, for a file saved then renamed into place | | `appendFile(path, text)` | — | Creates or appends | | `mkdir(path, {recursive})` | — | `recursive` defaults to `false` | | `remove(path)` | — | A file or an empty directory | | `removeTree(path)` | — | A file, or a directory and everything in it; symbolic links are unlinked, never followed. Synchronous: for small app-owned trees | | `removeAsync(path)` | `Promise` | What `removeTree` removes, on a worker thread, one path at a time in the order asked. Resolves once the path is gone, or if it never existed; rejects with an `Error` naming the path and the system error. For large files and trees, whose removal holds the screen for seconds | | `rename(from, to)` | — | Both are app paths | | `chmod(path, mode)` | — | Permission bits, such as `0o777`; a file or directory the console's own services must read (a title's files) needs them open to every user | | `mounts()` | `{device, path, type}[]` | Up to 64 mounted filesystems visible to this process, as host paths; see [Hardware status](#hardware-status) | | `diskUsage(path)` | `{total, free}` | Bytes for the filesystem holding `path`; `free` is available to the app, clamped to `0..total` | `FileStat` is `{name, isDirectory, isFile, size, modified, device, inode}`, where `size` is in bytes and `modified` is milliseconds since the Unix epoch. `device` and `inode` are decimal strings that preserve native 64-bit identities. Use both to detect replacement of a directory or file, including another drive mounted at the same path; these identities are filesystem-local, not universal drive serial numbers. Files are text only; there is no binary read or write. For console-wide filesystem access, add this to the app manifest: ```json { "filesystemAccess": "console" } ``` Then query storage from an event handler or effect: ```js const entries = FileSystem.readDir('/data'); const mounts = FileSystem.mounts(); const {total, free} = FileSystem.diskUsage('/data'); ``` Keep `filesystemAccess` set to `"sandbox"` (the default) for apps that only need their own files. The opt-in helper requires resident Lapy or a local ELF loader; see [access requirements](#hardware-status). On failure, filesystem calls retain their normal permission errors rather than returning fake values. ## Http and Downloads `Http.request(url, options)` provides bounded text/JSON HTTP responses, effective URLs and allowlisted lowercase response headers. Set `followRedirects: false` to inspect redirect metadata; cookies and authentication headers stay private. HTTP requests accept a custom `Range` header for bounded probes; downloads own their ranges and reject it. Custom headers disable automatic redirects. Response headers include `etag` and `last-modified` for stable file identity checks. `Downloads.enqueue({url, destination, ...options})` returns a cancellable task with progress subscriptions and a completion promise. Binary downloads stay native, use parallel validated ranges when eligible, and resume durable ranges. `Downloads.enqueueManifest({manifest, destination, ...options})` supports split byte manifests with per-piece SHA-1 and whole-file SHA-256 verification. `DownloadFormats` validates manifests and routes image, package, archive and binary filenames to their appropriate destinations. Native download options include opt-in `rejectHtml`, exact `expectedBytes`, `storageRoot` identity checks, `recoverCompleted` for receipt-backed SHA-256 verification of published files, and normalized `pieces: [{url, offset, size, sha1}]`. These options share ABI v2 on both hosts; there are no additional global native functions. `Downloads.setRateLimit(bytesPerSecond)` (ABI v17) caps what all file downloads receive together, 0 for no limit. It applies at once, running downloads included; a capped download opens about one connection per MB/s. A download enqueued with `unlimited: true`, such as an app's own update, is exempt. PS5 builds require `networking: true` and `filesystemAccess: "console"`; desktop networking is available by default. See [NETWORKING.md](NETWORKING.md) for options, resource limits, errors, lifecycle and the hardware-validation gap. ## LocalNetwork (ABI v9) `LocalNetwork.broadcast({port, message, timeoutMs, maxReplies, signal})` sends one UDP datagram to `255.255.255.255:port` and resolves with the replies gathered for `timeoutMs` (100–10000, default 1500) or until `maxReplies` (1–64, default 16) arrive: `[{address, port, text}]`, where `address` is the sender's IPv4 address. It is for service discovery, such as Jellyfin's: ```js import {LocalNetwork} from '@ps5-react/core'; const replies = await LocalNetwork.broadcast({port: 7359, message: 'who is JellyfinServer?'}); ``` The query runs on a native worker of its own; the bridge (`lan.broadcast`, `lan.cancel`, `lan.poll`) only submits, cancels and polls. Messages are at most 1 KiB and each reply is cut at 4 KiB; four queries run at a time, and a fifth throws. A `signal` cancels the query and rejects with an `AbortError`. Anyone on the network can reply with anything: check a reply against its sender before using an address it names. It needs `networking: true` (and on PS5 `filesystemAccess: "console"`), like HTTP. Only IPv4 limited broadcast is sent; multicast (SSDP, mDNS) and unicast UDP are not available. ## Images `` loads `http://` and `https://` URLs natively: fetched and decoded by two workers off the render thread, at the size the element is drawn, into a bounded LRU cache. `Image.prefetch(uri, {width, height, resizeMode})` warms that cache. `Image.getColor(uri)` resolves to the art's most prominent vivid colour as `'#rrggbb'`, or `null` for grey or dark art, computed while decoding a small copy. The bridge (`image.load`, `image.release`, `image.poll`, ABI v3; results carry that `color`; `image.warm` and `image.defer(ms)`, which ScrollViews call while they move to hold network requests back) is internal to the `Image` component. See [IMAGES.md](IMAGES.md) for sizing, formats, memory budgets and errors. ## Notifications `Notifications.show(message, subMessage?)` shows a system notification and returns `true` when the host accepted it. On PS5 it uses libkernel's `sceKernelSendNotificationRequest`: a plain toast whose subMessage follows the message on a second line. ## Users | Function | Returns | | --- | --- | | `Users.getForeground()` | `{id, name} \| null` — the user who launched the app | | `Users.getLoggedIn()` | `{id, name}[]` | ## Controller | Function | Notes | | --- | --- | | `setLightBar(color)` | `'#rrggbb'` or `{r, g, b}` with 0–255 channels | | `resetLightBar()` | Restores the system color | | `vibrate(strength = 1, ms = 200)` | `strength` 0–1 | | `setTriggerEffect(trigger, effect)` | ABI v12. A DualSense adaptive trigger effect on `'l2'`, `'r2'` or `'both'`, replacing the last one; effects below. The PS5 turns the triggers off when the app quits; the desktop preview logs the request without sending it | | `setTriggerResistance(trigger, strength, from = 0)` | `setTriggerEffect(trigger, {type: 'resistance', from, strength})` | | `getState()` | `GamepadState`, the latest polled state | | `subscribe(listener, {frames = false})` | Calls `listener(event)` once per frame with the controller's events, below; returns the function that stops it | Trigger effects take positions as fractions of the travel (0 top, 1 bottom) and strengths 0–1 in the controller's eight steps, where 0 turns the effect off. An invalid effect throws a `RangeError` naming the field: | Effect | Feel | | --- | --- | | `{type: 'off'}` | A free trigger | | `{type: 'resistance', from = 0, strength}` | Pushes back from `from` to the bottom (libScePad mode 1, feedback) | | `{type: 'weapon', start, end, strength}` | Resists between `start` and `end`, then gives way with a click, like a gun trigger (mode 2). The controller places them in tenths of the travel: `start` lands in 0.2–0.8 and `end` at least a tenth past it, at most 0.9 | | `{type: 'vibration', from = 0, amplitude, frequency}` | Buzzes from `from` on, at `frequency` Hz, 1–255 (mode 3) | `GamepadState` is: ```ts { connected: boolean; leftX: number; leftY: number; // -1..1, deadzone applied rightX: number; rightY: number; // -1..1, deadzone applied l2: number; r2: number; // 0..1 buttons: string[]; // held: up down left right cross circle triangle // square l1 r1 l2 r2 l3 r3 options touchpad } ``` The host updates the state once per frame, before JavaScript timers and controller actions run. ### Controller events `useControllerEvents(handler, {frames = false})` (or `Controller.subscribe`) diffs the state once per frame, in the frame callback that steps motion, and calls the handler for each change: what an action cannot tell, such as a release, how far a trigger is in, or the touchpad and L3/R3, which send no action. All listeners share one read of the state, made only while any listens. | Event | When | | --- | --- | | `{type: 'press', button, state}` | A `GamepadState` button went down; L2 and R2 at the host's press threshold (half travel) | | `{type: 'release', button, heldMs, state}` | It came up, after `heldMs` of frame time | | `{type: 'trigger', trigger, value, previous, state}` | `'l2'` or `'r2'` travel changed, 0–1 | | `{type: 'frame', elapsedMs, state}` | With `frames`, after the others every frame, for work that steps while something is held | A frame delivers releases, then presses, then trigger changes, so a release ends before the next press starts. A button already held when a listener starts is not a press for it (it may be the press that mounted the listener); its release is. Pass `null` as the handler to stop listening. The events are a diff of per-frame states: a press and release within one frame are not seen, while the host's actions (`useController`) still arrive. ```jsx function SkipIntro({onSkip}) { useControllerEvents(event => { if (event.type === 'press' && event.button === 'touchpad') onSkip(); }); return Press the touchpad to skip; } ``` `useGamepad(intervalMs = 0)` reads the state on the same per-frame reader while the component is mounted (at most once per `intervalMs` when given) and re-renders only when it changes: ```jsx function Sticks() { const pad = useGamepad(); return {pad.leftX} {pad.leftY} {pad.buttons.join(' ')}; } ``` Menus use focus navigation ([NAVIGATION.md](NAVIGATION.md)). `useController` receives the raw `up`, `down`, `left`, `right`, `confirm`, `back`, `l1`, `r1`, `l2`, `r2`, `triangle`, and `square` actions (plus `previous`/`next` for compatibility); `useControllerEvents` is for releases, holds and analog travel, and `useGamepad` for drawing raw state. Options arrives as the `options` action, and the PS button leaves the app ([NAVIGATION.md](NAVIGATION.md#host-input-input-protocol-v5)). ## Sound Short interface sounds: focus ticks, confirm, back, errors, notifications. Import a WAV like an image; the build bakes it into the app and the import is the sound's name: ```jsx import {Sound, useNavigationEvents} from '@ps5-react/core'; import tick from './assets/sounds/focus.wav'; useNavigationEvents(event => { if (event.type === 'move') Sound.play(tick); }); ``` | Function | Notes | | --- | --- | | `Sound.play(name, {volume = 1, pan = 0})` | Returns `true` when the sound started; `volume` 0–1, `pan` -1 (left) to 1 (right) | | `Sound.setVolume(volume)` | Master volume 0–1 for every sound (0 mutes) | - **Format.** 16-bit PCM WAV, mono or stereo, 8–96 kHz, at most 10 seconds; other files fail the build with the file name. Keep sounds short: they stay decoded in memory (8 bytes per frame). An app that imports no WAV opens no audio output. - **One voice per sound.** While a sound still plays, playing it again is skipped and returns `false`, so a held D-pad keeps ticking without stacking. Different sounds overlap. - **Cheap and non-blocking.** `play` posts a command to a lock-free queue and returns; mixing runs on an audio thread (the ps5-homebrew-ui mixer: 32 voices, 48 kHz stereo, a soft limiter). It returns `false` when there is no audio output. An unknown name throws `sound.play : no imported .wav has this name`. - **Feedback for navigation.** `useNavigationEvents` ([NAVIGATION.md](NAVIGATION.md#navigation-events)) reports each move, blocked direction, press, back and button action, so one hook at the root gives every screen its sounds. `apps/starter/sounds.js` wires the framework's sound set (`focus`, `confirm`, `back`, `error`, `page`, `notify`) this way, and `npm run create` copies it. The set is derived from Google's Material Design sound resources (CC BY 4.0): keep `licenses/material-sounds-NOTICE.txt` with an app that ships it. `node tools/ui_sounds.mjs ` rebuilds it from the pack's WAV files. ## Linking `Linking.openURL(url)` opens an `http://` or `https://` URL in the system browser and returns `true` when the host launched it. Other schemes throw. ## Power `Power.keepAwake(enabled)` keeps the console out of rest mode for inactivity while `true`, for work that runs with nobody touching the controller, such as a download of several hours. Turn it on when the work starts and off when it ends; the last call wins, so an app with several jobs keeps one count of its own. The PS5 host calls `sceSystemServicePowerTick` every 30 seconds while it is on, which restarts the inactivity timer as input does (the method FTP and download homebrew use). The desktop preview disables or enables the SDL screen saver and prints `[power] keep awake on|off`. **Not yet validated on hardware**: no test has shown a console staying awake through its rest-mode timeout. ## Video (ABI v10) `