2026-04-14 17:41:39 +02:00

18 KiB

External Apps System - codem-phone

This system allows external FiveM resources to add custom apps inside the phone.

Quick Start

1. Client-side: Register Your App

-- client/main.lua
CreateThread(function()
    -- Wait for codem-phone to start
    while GetResourceState('codem-phone') ~= 'started' do
        Wait(100)
    end
    Wait(1000) -- Small delay

    -- Read HTML content
    local htmlContent = LoadResourceFile(GetCurrentResourceName(), 'ui/index.html')

    -- Register the app
    exports['codem-phone']:AddCustomApp({
        identifier = 'myapp',              -- Unique ID (required)
        name = 'My Custom App',            -- Display name (required)
        icon = 'nui://myresource/icon.png', -- Icon URL (optional)
        ui = htmlContent,                  -- HTML content (required)
        description = 'An awesome app',    -- Description (optional)
        defaultApp = false,                -- Is default app? (optional)
        notification = true,               -- Show notifications? (optional)
        onOpen = function()                -- Called when app opens (optional)
            print('App opened!')
        end,
        onClose = function()               -- Called when app closes (optional)
            print('App closed!')
        end
    })
end)

2. HTML/JS: Communicate with Phone

⚠️ IMPORTANT CSS RULES:

  • DO NOT use 100vh! Use 100% instead
  • DO NOT use px or rem units! Use % and em ONLY
  • Add html, body { width: 100%; height: 100%; overflow: hidden; }
  • Your app will be displayed inside the phone screen, not fullscreen

WARNING: Using px, rem, or vh units will cause your app to display incorrectly on the phone screen. Always use % for dimensions and em for font sizes, padding, margins, etc.

<!-- ui/index.html -->
<!DOCTYPE html>
<html>
<head>
    <title>My App</title>
    <style>
        /* IMPORTANT: Always include these rules */
        /* USE ONLY % and em - NO px, rem, or vh! */
        html, body {
            width: 100%;
            height: 100%;
            margin: 0;
            padding: 0;
            overflow: hidden;
        }

        body {
            padding: 1em;  /* Use em, NOT px! */
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
            font-size: 1em;  /* Base font size */
            background: #000;
            color: #fff;
            display: flex;
            flex-direction: column;
        }
    </style>
</head>
<body>
    <h1>My App</h1>
    <p>Phone Number: <span id="phone">-</span></p>
    <button onclick="closeApp()">Close</button>

    <script>
        let playerData = null;
        let pendingCallbacks = {};

        // Listen for messages from phone
        window.addEventListener('message', function(event) {
            const data = event.data;
            if (!data || !data.type) return;

            switch(data.type) {
                case 'mphone:init':
                    // Called when phone opens this app
                    playerData = data.player;
                    document.getElementById('phone').textContent = data.player.phoneNumber;
                    break;

                case 'mphone:callback:response':
                    // Callback response received
                    const cb = pendingCallbacks[data.callbackId];
                    if (cb) {
                        cb(data.result);
                        delete pendingCallbacks[data.callbackId];
                    }
                    break;

                case 'mphone:playerData':
                    // Player data response
                    playerData = data.player;
                    break;
            }
        });

        // Close the app
        function closeApp() {
            window.parent.postMessage({ type: 'mphone:close' }, '*');
        }

        // Show notification
        function showNotification(header, message) {
            window.parent.postMessage({
                type: 'mphone:notification',
                header: header,
                message: message
            }, '*');
        }

        // Set waypoint on map
        function setWaypoint(x, y) {
            window.parent.postMessage({
                type: 'mphone:setWaypoint',
                x: x,
                y: y
            }, '*');
        }

        // ═══════════════════════════════════════════════════════════
        // CALLBACK SYSTEM - Send requests to Client or Server
        // ═══════════════════════════════════════════════════════════

        /**
         * Send callback to Lua
         * @param {string} action - Event action name
         * @param {object} payload - Data to send
         * @param {boolean} toServer - true = server event, false = client event
         * @returns {Promise} - Response promise
         */
        function sendCallback(action, payload, toServer = false) {
            return new Promise((resolve) => {
                const callbackId = 'cb_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
                pendingCallbacks[callbackId] = resolve;

                window.parent.postMessage({
                    type: 'mphone:callback',
                    action: action,
                    payload: payload || {},
                    callbackId: callbackId,
                    server: toServer  // IMPORTANT: true = server, false = client
                }, '*');

                // 10 second timeout
                setTimeout(() => {
                    if (pendingCallbacks[callbackId]) {
                        resolve({ success: false, error: 'Timeout' });
                        delete pendingCallbacks[callbackId];
                    }
                }, 10000);
            });
        }

        // ═══════════════════════════════════════════════════════════
        // USAGE EXAMPLES
        // ═══════════════════════════════════════════════════════════

        // CLIENT event example (server: false or not specified)
        async function getClientData() {
            const result = await sendCallback('getLocalData', { key: 'value' }, false);
            // This event goes to handler in client/main.lua
            console.log('Client response:', result);
        }

        // SERVER event example (server: true)
        async function getServerData() {
            const result = await sendCallback('getPlayerStats', { playerId: 1 }, true);
            // This event goes to handler in server/main.lua
            console.log('Server response:', result);
        }

        // Database operation example (always use server: true)
        async function saveToDatabase() {
            const result = await sendCallback('saveData', {
                key: 'score',
                value: 100
            }, true);  // server: true is REQUIRED for database operations!

            if (result.success) {
                showNotification('Success', 'Data saved!');
            }
        }
    </script>
</body>
</html>

3. Listen for Callback Events

Client-side Event (server: false)

-- client/main.lua
-- Event format: codem-phone:customApp:{identifier}:{action}
-- Note: Client events do NOT have source parameter!

AddEventHandler('codem-phone:customApp:myapp:getLocalData', function(payload, cb)
    -- payload: data from app
    -- cb: response function

    local localData = {
        playerPed = PlayerPedId(),
        coords = GetEntityCoords(PlayerPedId())
    }

    cb({ success = true, data = localData })
end)

-- Example: Get vehicle list
AddEventHandler('codem-phone:customApp:myapp:getVehicles', function(payload, cb)
    local vehicles = GetGamePool('CVehicle')
    cb({ success = true, count = #vehicles })
end)

Server-side Event (server: true)

-- server/main.lua
-- Event format: codem-phone:customApp:{identifier}:{action}
-- Note: Server events HAVE source parameter!

AddEventHandler('codem-phone:customApp:myapp:getPlayerStats', function(source, payload, cb)
    -- source: player server ID
    -- payload: data from app
    -- cb: response function

    local playerName = GetPlayerName(source)

    cb({
        success = true,
        name = playerName,
        id = source
    })
end)

-- Example: Save to database
AddEventHandler('codem-phone:customApp:myapp:saveData', function(source, payload, cb)
    MySQL.insert('INSERT INTO app_data (player_id, data) VALUES (?, ?)',
        { source, json.encode(payload) },
        function(insertId)
            cb({ success = true, id = insertId })
        end
    )
end)

-- Example: Read from database
AddEventHandler('codem-phone:customApp:myapp:loadData', function(source, payload, cb)
    MySQL.query('SELECT * FROM app_data WHERE player_id = ?', { source },
        function(results)
            cb({ success = true, data = results })
        end
    )
end)

4. Send Message to App

-- client/main.lua
-- Send message to app (when app is open)
exports['codem-phone']:SendCustomAppMessage('myapp', {
    type = 'customEvent',
    data = { key = 'value' }
})

server Parameter Summary

server Value Event Runs Where source Parameter Use Case
false (default) Client (client/main.lua) NO Local data, ped info, coordinates
true Server (server/main.lua) YES Database, player validation, money operations

API Reference

Client Exports

-- Add app
exports['codem-phone']:AddCustomApp({
    identifier = string,      -- Unique ID (required)
    name = string,            -- Display name (required)
    ui = string,              -- HTML content (required)
    icon = string,            -- Icon URL (optional, default: 'apps/default.png')
    description = string,     -- Description (optional)
    defaultApp = boolean,     -- Is default app? (optional, default: false)
    notification = boolean,   -- Notification support (optional, default: true)
    onOpen = function,        -- Called when opened (optional)
    onClose = function        -- Called when closed (optional)
})

-- Remove app
exports['codem-phone']:RemoveCustomApp(identifier)

-- Send message to app
exports['codem-phone']:SendCustomAppMessage(identifier, message)

-- Get app info
local app = exports['codem-phone']:GetCustomApp(identifier)

-- Check if phone is open
local isOpen = exports['codem-phone']:IsPhoneOpen()

JavaScript Message Types (Sent to Parent)

Type Description Parameters
mphone:close Close the app -
mphone:notification Show notification header, message
mphone:callback Send callback action, payload, callbackId, server
mphone:navigate Navigate to another app path
mphone:setWaypoint Set waypoint on map x, y
mphone:getPlayer Request player data -

JavaScript Message Types (Sent to App)

Type Description Content
mphone:init When app opens identifier, player, theme, language
mphone:callback:response Callback response callbackId, result
mphone:playerData Player data player
Custom types Messages from Lua Variable

Example Resource Structure

my-phone-app/
├── fxmanifest.lua
├── client/
│   └── main.lua
├── server/
│   └── main.lua (optional - for server callbacks)
└── ui/
    └── index.html

fxmanifest.lua

fx_version 'cerulean'
game 'gta5'

author 'Your Name'
description 'Custom phone app'
version '1.0.0'

client_scripts {
    'client/main.lua'
}

-- Add if using server callbacks
server_scripts {
    'server/main.lua'
}

files {
    'ui/**/*'
}

dependency 'codem-phone'

Full Example - Counter App

client/main.lua

local appRegistered = false

CreateThread(function()
    while GetResourceState('codem-phone') ~= 'started' do
        Wait(100)
    end
    Wait(1000)

    local htmlContent = LoadResourceFile(GetCurrentResourceName(), 'ui/index.html')

    local success, err = exports['codem-phone']:AddCustomApp({
        identifier = 'counter',
        name = 'Counter',
        icon = 'nui://' .. GetCurrentResourceName() .. '/ui/icon.png',
        ui = htmlContent,
        onOpen = function()
            print('[Counter] App opened')
        end,
        onClose = function()
            print('[Counter] App closed')
        end
    })

    if success then
        appRegistered = true
    end
end)

server/main.lua

local PlayerCounters = {}

-- Get counter
AddEventHandler('codem-phone:customApp:counter:getCounter', function(source, payload, cb)
    cb({ success = true, count = PlayerCounters[source] or 0 })
end)

-- Increment counter
AddEventHandler('codem-phone:customApp:counter:increment', function(source, payload, cb)
    PlayerCounters[source] = (PlayerCounters[source] or 0) + 1
    cb({ success = true, count = PlayerCounters[source] })
end)

-- Decrement counter
AddEventHandler('codem-phone:customApp:counter:decrement', function(source, payload, cb)
    PlayerCounters[source] = math.max(0, (PlayerCounters[source] or 0) - 1)
    cb({ success = true, count = PlayerCounters[source] })
end)

ui/index.html

<!DOCTYPE html>
<html>
<head>
    <style>
        /* USE ONLY % and em - NO px, rem, or vh! */
        html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; }
        body {
            display: flex; flex-direction: column; align-items: center;
            justify-content: center; background: #1a1a2e; color: white;
            font-family: sans-serif; font-size: 1em;
        }
        .count { font-size: 4.5em; margin: 1.25em 0; }
        button { padding: 0.9em 1.9em; margin: 0.3em; font-size: 1.25em; cursor: pointer; }
    </style>
</head>
<body>
    <h1>Counter</h1>
    <div class="count" id="count">0</div>
    <div>
        <button onclick="decrement()">-</button>
        <button onclick="increment()">+</button>
    </div>
    <button onclick="closeApp()">Close</button>

    <script>
        let pendingCallbacks = {};

        window.addEventListener('message', (e) => {
            if (e.data.type === 'mphone:init') loadCounter();
            if (e.data.type === 'mphone:callback:response') {
                const cb = pendingCallbacks[e.data.callbackId];
                if (cb) { cb(e.data.result); delete pendingCallbacks[e.data.callbackId]; }
            }
        });

        function sendCallback(action, payload) {
            return new Promise(resolve => {
                const id = 'cb_' + Date.now();
                pendingCallbacks[id] = resolve;
                window.parent.postMessage({
                    type: 'mphone:callback',
                    action: action,
                    payload: payload || {},
                    callbackId: id,
                    server: true  // Send to server!
                }, '*');
                setTimeout(() => { resolve({ success: false }); delete pendingCallbacks[id]; }, 10000);
            });
        }

        async function loadCounter() {
            const r = await sendCallback('getCounter');
            if (r.success) document.getElementById('count').textContent = r.count;
        }

        async function increment() {
            const r = await sendCallback('increment');
            if (r.success) document.getElementById('count').textContent = r.count;
        }

        async function decrement() {
            const r = await sendCallback('decrement');
            if (r.success) document.getElementById('count').textContent = r.count;
        }

        function closeApp() {
            window.parent.postMessage({ type: 'mphone:close' }, '*');
        }
    </script>
</body>
</html>

Tips

  1. Icon:
    • Use 128x128 PNG for best results
    • Use nui:// URL format: nui://your-resource-name/ui/icon.png
    • Make sure the icon file is included in your files section of fxmanifest.lua
    • Example: icon = 'nui://my-phone-app/ui/icon.png'
  2. HTML: Pass HTML string directly to ui parameter, don't use nui:// URL
  3. ⚠️ CSS UNITS:
    • ONLY use % and em - DO NOT use px, rem, or vh
    • Use 100% instead of 100vh for height
    • Use em for font-size, padding, margin, border-radius, etc.
    • Example: font-size: 1em; NOT font-size: 16px;
    • Example: padding: 1em; NOT padding: 16px;
  4. Security: Validate all data in server callbacks
  5. Theme: Use theme value from mphone:init message for dark/light theme support
  6. Database: ALWAYS use server: true for database operations

When Resource Stops

Your app is automatically removed when your resource stops. This is handled by the onResourceStop event handler.