// Copyright (C) 2026 half144 and PS5 React contributors // SPDX-License-Identifier: GPL-3.0-or-later // Additional attribution term: see LICENSE-ATTRIBUTION. // React Native-style modules over the host contract in native/shared/host_api.hpp. // Every call is synchronous and runs on the render thread; see docs/NATIVE-API.md. import {useEffect, useLayoutEffect, useRef, useState} from 'react'; import {gamepadSource} from './controller-events.js'; import {normalizeDownloadManifest} from './download-formats.js'; import {onFrameUnbatched} from './frame.js'; function host() { const api = globalThis.__ps5ReactNative; if (!api) { throw new Error('Native modules need a PS5 React host (globalThis.__ps5ReactNative is missing); ' + 'run the app with npm run dev, npm run preview, or a PS5 build'); } return api; } /** * @typedef {{name: string, isDirectory: boolean, isFile: boolean, size: number, modified: number}} FileStat * `modified` is milliseconds since the Unix epoch. * @typedef {{device: string, path: string, type: string}} Mount * @typedef {{id: number, name: string}} User * @typedef {{model: string | null, firmware: string | null, cpuTemperature: number | null, * socTemperature: number | null, cpuFrequency: number | null, freeMemory: number | null, * processTime: number | null, language: string | null}} Device * Temperatures in °C, frequency in Hz, memory in bytes, process time in µs; language is the * system language as a BCP 47 tag ('en-US', 'pt-BR', 'zh-Hans'). * @typedef {'up' | 'down' | 'left' | 'right' | 'cross' | 'circle' | 'triangle' | 'square' | 'l1' | 'r1' * | 'l2' | 'r2' | 'l3' | 'r3' | 'options' | 'touchpad'} Button * @typedef {{connected: boolean, leftX: number, leftY: number, rightX: number, rightY: number, * l2: number, r2: number, buttons: Button[]}} GamepadState * Sticks are -1..1 with the deadzone applied; triggers are 0..1. */ export const Platform = { /** @returns {'ps5' | 'desktop'} */ get OS() { return host().platform; }, /** * @template T * @param {{ps5?: T, desktop?: T, default?: T}} spec * @returns {T | undefined} */ select(spec) { const os = host().platform; return os in spec ? spec[os] : spec.default; }, }; export const DeviceInfo = { /** @returns {Device} */ get: () => host().device.info(), /** * Minutes east of UTC in the system time zone at `time` (default now), summer time included: * the local clock reads `time + utcOffset(time) * 60000` as UTC. * @param {number} [time] milliseconds since the epoch @returns {number} */ utcOffset: (time = Date.now()) => host().device.utcOffset(time), }; // The title is sandboxed: paths are app paths such as /app0 (read-only) and /download0. export const FileSystem = { appDir: '/app0', dataDir: '/download0', tempDir: '/temp0', /** @param {string} path @returns {FileStat[]} */ readDir: path => host().fs.readDir(path), /** @param {string} path @returns {FileStat | null} */ // device/inode remain strings to preserve native 64-bit filesystem identities. stat: path => host().fs.stat(path), /** @param {string} path @returns {boolean} */ exists: path => host().fs.stat(path) !== null, /** UTF-8, at most 8 MiB. @param {string} path @returns {string} */ readFile: path => host().fs.readFile(path), /** `sync` waits until the text is on the disk, for a file saved then renamed into place. * @param {string} path @param {string} text @param {{sync?: boolean}} [options] */ writeFile: (path, text, {sync = false} = {}) => host().fs.writeFile(path, text, false, sync), /** @param {string} path @param {string} text */ appendFile: (path, text) => host().fs.writeFile(path, text, true), /** @param {string} path @param {{recursive?: boolean}} [options] */ mkdir: (path, {recursive = false} = {}) => host().fs.mkdir(path, recursive), /** Removes a file or an empty directory. @param {string} path */ remove: path => host().fs.remove(path), /** Removes a file, or a directory with everything in it, without following symbolic links. * Synchronous: meant for small app-owned trees. @param {string} path */ removeTree: path => host().fs.removeTree(path), /** Removes as `removeTree` does, on a worker thread, 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. * @param {string} path @returns {Promise} */ removeAsync: async path => { await networkTask(host().fs.removeAsync(path), 'fs.removeAsync', path, 'removals').done; }, /** @param {string} from @param {string} to */ rename: (from, to) => host().fs.rename(from, to), /** Sets a file's or directory's permission bits, such as 0o777. */ chmod: (path, mode) => host().fs.chmod(path, mode), /** Mounted filesystems visible to the host process. Console access requires * filesystemAccess: "console" in app.json. @returns {Mount[]} */ mounts: () => host().fs.mounts(), /** Filesystem bytes; free is available to the app, clamped to 0..total. * @param {string} path @returns {{total: number, free: number}} */ diskUsage: path => host().fs.diskUsage(path), }; export const Notifications = { /** @param {string} message @param {string} [subMessage] @returns {boolean} */ show: (message, subMessage = '') => host().notify(message, subMessage), }; export const Users = { /** @returns {User | null} */ getForeground: () => host().users.foreground(), /** @returns {User[]} */ getLoggedIn: () => host().users.loggedIn(), }; function rgb(color) { if (typeof color !== 'string') return color; const match = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(color); if (!match) throw new Error(`Controller.setLightBar: expected '#rrggbb' or {r, g, b}, got '${color}'`); const [r, g, b] = match.slice(1).map(hex => parseInt(hex, 16)); return {r, g, b}; } export const Controller = { /** @param {string | {r: number, g: number, b: number}} color '#rrggbb' or 0..255 channels */ setLightBar(color) { const {r, g, b} = rgb(color); host().pad.setLightBar(r, g, b); }, resetLightBar: () => host().pad.resetLightBar(), /** @param {number} [strength] 0..1 @param {number} [ms] */ vibrate: (strength = 1, ms = 200) => host().pad.vibrate(strength, ms / 1000), /** * DualSense adaptive trigger resistance: `setTriggerEffect(trigger, {type: 'resistance', from, * strength})`. From `from` of the travel (0 top, 1 bottom) on, the trigger pushes back at `strength` * (0..1, in eight steps); `strength` 0 releases it. * @param {'l2' | 'r2' | 'both'} trigger @param {number} strength @param {number} [from] */ setTriggerResistance(trigger, strength, from = 0) { applyTriggerEffect('Controller.setTriggerResistance', trigger, {type: 'resistance', from, strength}); }, /** * A DualSense adaptive trigger effect, replacing the trigger's last one. Positions are fractions of * the travel (0 top, 1 bottom), strengths and amplitudes 0..1 in eight steps (0 is off): * - `{type: 'off'}` * - `{type: 'resistance', from = 0, strength}`: pushes back from `from` to the bottom. * - `{type: 'weapon', start, end, strength}`: resists between `start` and `end`, then gives way * with a click, like a gun trigger. The controller places them in zones: `start` 0.2..0.8, `end` * at least a zone (0.1) past it and at most 0.9; values outside are moved there. * - `{type: 'vibration', from = 0, amplitude, frequency}`: buzzes from `from` on, at `frequency` * Hz (1..255). * The PS5 turns the triggers off when the app quits; the desktop preview only logs the request. * @param {'l2' | 'r2' | 'both'} trigger @param {TriggerEffect} effect */ setTriggerEffect(trigger, effect) { applyTriggerEffect('Controller.setTriggerEffect', trigger, effect); }, /** @returns {GamepadState} */ getState: () => host().pad.state(), /** * Calls `listener` with the controller's events, once per frame, until the returned function is * called: `press` and `release` (with `heldMs`) for every button in GamepadState, L2 and R2 at the * host's press threshold and the touchpad included, then `trigger` when L2's or R2's travel changes. * With `frames`, a `frame` event ends every frame, for work that steps while something is held. A * button held when the listener starts is not a press for it. A press and release within one frame * are not seen; the host's actions (useController) are. * @param {(event: ControllerEvent) => void} listener * @param {{frames?: boolean}} [options] * @returns {() => void} */ subscribe(listener, {frames = false} = {}) { return pad.watch((state, events, elapsedMs) => { for (const event of events) listener(event); if (frames) listener({type: 'frame', elapsedMs, state}); }); }, }; /** * @typedef {import('./controller-events.js').ControllerEvent} ControllerEvent * @typedef {{type: 'off'} | {type: 'resistance', from?: number, strength: number} * | {type: 'weapon', start: number, end: number, strength: number} * | {type: 'vibration', from?: number, amplitude: number, frequency: number}} TriggerEffect */ const TRIGGER_MASKS = {l2: 1, r2: 2, both: 3}; const fraction = value => value >= 0 && value <= 1; const zone = value => Math.round(value * 9); const step8 = value => Math.max(1, Math.round(value * 8)); // libScePad modes and parameter bytes (host_api.hpp pad.setTriggerEffect). function applyTriggerEffect(call, trigger, effect) { const mask = TRIGGER_MASKS[trigger]; if (!mask) throw new Error(`${call}: trigger must be 'l2', 'r2' or 'both', got '${trigger}'`); const type = effect?.type; let command; if (type === 'off') command = [0]; else if (type === 'resistance') { const {from = 0, strength} = effect; if (!fraction(strength) || !fraction(from)) throw new RangeError(`${call}: strength and from must be 0..1, got ${strength} and ${from}`); command = strength > 0 ? [1, zone(from), step8(strength)] : [0]; } else if (type === 'weapon') { const {start, end, strength} = effect; if (!fraction(start) || !fraction(end) || !(start < end) || !fraction(strength)) throw new RangeError(`${call}: weapon start < end and strength must be 0..1, got ${start}, ${end} and ${strength}`); const from = Math.min(7, Math.max(2, zone(start))); command = strength > 0 ? [2, from, Math.min(8, Math.max(from + 1, zone(end))), step8(strength)] : [0]; } else if (type === 'vibration') { const {from = 0, amplitude, frequency} = effect; if (!fraction(amplitude) || !fraction(from) || !(frequency >= 1 && frequency <= 255)) throw new RangeError(`${call}: vibration from and amplitude must be 0..1 and frequency 1..255 Hz, ` + `got ${from}, ${amplitude} and ${frequency}`); command = amplitude > 0 ? [3, zone(from), step8(amplitude), Math.round(frequency)] : [0]; } else { throw new Error(`${call}: effect type must be 'off', 'resistance', 'weapon' or 'vibration', got '${type}'`); } host().pad.setTriggerEffect(mask, ...command); } const pad = gamepadSource(() => host().pad.state()); function sameState(a, b) { return a.connected === b.connected && a.leftX === b.leftX && a.leftY === b.leftY && a.rightX === b.rightX && a.rightY === b.rightY && a.l2 === b.l2 && a.r2 === b.r2 && a.buttons.join() === b.buttons.join(); } /** * The controller state while mounted, read once per frame (with the events' shared reader); re-renders * only when it changes. `intervalMs` reads at most that often instead of every frame. * @param {number} [intervalMs] * @returns {GamepadState} */ export function useGamepad(intervalMs = 0) { const [state, setState] = useState(Controller.getState); useEffect(() => { let waited = intervalMs; return pad.watch((next, events, elapsedMs) => { waited += elapsedMs; if (waited < intervalMs) return; waited = 0; setState(previous => (sameState(previous, next) ? previous : next)); }); }, [intervalMs]); return state; } /** * Controller.subscribe while mounted, for the latest `handler`; pass null to stop listening (a button * held when listening starts again is not a press). * @param {((event: ControllerEvent) => void) | null | undefined} handler * @param {{frames?: boolean}} [options] */ export function useControllerEvents(handler, {frames = false} = {}) { const current = useRef(handler); useLayoutEffect(() => { current.current = handler; }); const listening = Boolean(handler); useLayoutEffect(() => (listening ? Controller.subscribe(event => current.current?.(event), {frames}) : undefined), [listening, frames]); } /** * Short interface sounds. Import a 16-bit PCM WAV (`import tick from './tick.wav'`) and the build * bakes it into the app; the import is the sound's name. Mixing runs on an audio thread. */ export const Sound = { /** * Starts a sound and returns at once. A sound plays one voice at a time: while it still sounds, * playing it again is skipped (so a held D-pad keeps ticking without stacking). * @param {string} name @param {{volume?: number, pan?: number}} [options] volume 0..1, pan -1..1 * @returns {boolean} whether it started; false without an audio output */ play: (name, {volume = 1, pan = 0} = {}) => host().sound.play(name, volume, pan), /** Master volume for every sound, 0..1 (0 mutes). @param {number} volume */ setVolume: volume => host().sound.setVolume(volume), }; export const Linking = { /** Opens an http(s) URL in the system browser. @param {string} url @returns {boolean} */ openURL(url) { if (!/^https?:\/\//i.test(url)) throw new Error(`Linking.openURL: only http and https URLs are supported, got '${url}'`); return host().openURL(url); }, }; export const Power = { /** * While on, the console does not enter rest mode for inactivity (the desktop display does not * sleep): turn it on for a long download, off when it ends. The last call wins. * @param {boolean} enabled */ keepAwake: enabled => host().power.keepAwake(Boolean(enabled)), }; export const Display = { /** * The rate in Hz the display presents at. On the PS5 it is the mode the TV accepted, known after * the first frame at a rate: a TV without 120 Hz input stays at 60. * @returns {60 | 120} */ refreshRate: () => host().display.refreshRate(), /** * Switches the display to 60 or 120 Hz after this frame. The rate must be listed in app.json * "refreshRates". On the PS5 the screen goes black for a second or two while the TV resyncs; * app state is kept. The desktop preview keeps the monitor's rate and reports the requested one. * @param {60 | 120} hz */ setRefreshRate: hz => host().display.setRefreshRate(hz), }; export const AudioOutput = { /** * What the TV or AV receiver the console outputs to takes, from its EDID, read now: decide from it what * to ask a media server for, and whether a `