Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions __mocks__/expo-auth-session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Mock for expo-auth-session
export const ResponseType = {
Code: 'code',
Token: 'token',
IdToken: 'id_token',
};

export const makeRedirectUri = jest.fn().mockReturnValue('test://auth/callback');

export const useAutoDiscovery = jest.fn().mockReturnValue({
authorizationEndpoint: 'https://example.com/oauth/authorize',
tokenEndpoint: 'https://example.com/oauth/token',
});

export const useAuthRequest = jest.fn().mockReturnValue([
null, // request
null, // response
jest.fn().mockResolvedValue({ type: 'cancel' }), // promptAsync
]);

export const exchangeCodeAsync = jest.fn().mockResolvedValue({
idToken: 'mock-id-token',
accessToken: 'mock-access-token',
});

export const AuthSessionRedirectUriOptions = {};
20 changes: 20 additions & 0 deletions __mocks__/expo-crypto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Mock for expo-crypto
export const digestStringAsync = jest.fn().mockResolvedValue('mock-hash');

export const CryptoDigestAlgorithm = {
SHA256: 'SHA-256',
SHA512: 'SHA-512',
SHA1: 'SHA-1',
MD2: 'MD2',
MD4: 'MD4',
MD5: 'MD5',
};

export const CryptoEncoding = {
HEX: 'hex',
BASE64: 'base64',
};

export const getRandomBytesAsync = jest.fn().mockResolvedValue(new Uint8Array(32));

export const randomUUID = jest.fn().mockReturnValue('mock-uuid-1234');
8 changes: 8 additions & 0 deletions __mocks__/expo-web-browser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Mock for expo-web-browser
export const maybeCompleteAuthSession = jest.fn().mockReturnValue({ type: 'success' });

export const openBrowserAsync = jest.fn().mockResolvedValue({ type: 'cancel' });

export const openAuthSessionAsync = jest.fn().mockResolvedValue({ type: 'cancel' });

export const dismissBrowser = jest.fn();
27 changes: 25 additions & 2 deletions __mocks__/react-native-mmkv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,36 @@

import { useState } from 'react';

const fallbackStores: Map<string, Record<string, string>> = new Map();

function getFallbackStorage(id: string): Storage {
let existing = fallbackStores.get(id);
if (existing === undefined) {
existing = {};
fallbackStores.set(id, existing);
}
const _fallbackData: Record<string, string> = existing;
const _fallbackStorage: Storage = {
getItem(key: string) { return _fallbackData[key] ?? null; },
setItem(key: string, value: string) { _fallbackData[key] = value; },
removeItem(key: string) { delete _fallbackData[key]; },
key(index: number) { return Object.keys(_fallbackData)[index] ?? null; },
get length() { return Object.keys(_fallbackData).length; },
clear() { Object.keys(_fallbackData).forEach((k) => { delete _fallbackData[k]; }); },
} as unknown as Storage;
return _fallbackStorage;
}

class MockMMKV {
private storage: Storage;
private prefix: string;

constructor(config?: { id?: string; encryptionKey?: string }) {
this.storage = typeof window !== 'undefined' ? window.localStorage : ({} as Storage);
this.prefix = config?.id || 'mmkv';
const id = config?.id || 'mmkv';
this.storage = typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
? window.localStorage
: getFallbackStorage(id);
this.prefix = id;
}

private getKey(key: string): string {
Expand Down
10 changes: 10 additions & 0 deletions app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
ITSAppUsesNonExemptEncryption: false,
UIViewControllerBasedStatusBarAppearance: false,
NSBluetoothAlwaysUsageDescription: 'Allow Resgrid Dispatch to connect to bluetooth devices for PTT.',
LSApplicationQueriesSchemes: [Env.SCHEME, 'https', 'http'],
},
entitlements: {
...((Env.APP_ENV === 'production' || Env.APP_ENV === 'internal') && {
Expand All @@ -72,6 +73,14 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
},
softwareKeyboardLayoutMode: 'pan',
package: Env.PACKAGE,
intentFilters: [
{
action: 'VIEW',
autoVerify: true,
data: [{ scheme: Env.SCHEME }],
category: ['BROWSABLE', 'DEFAULT'],
},
],
...(fs.existsSync(path.join(__dirname, 'google-services.json')) && {
googleServicesFile: 'google-services.json',
}),
Expand Down Expand Up @@ -268,6 +277,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
'@livekit/react-native-expo-plugin',
'@config-plugins/react-native-webrtc',
'@config-plugins/react-native-callkeep',
'expo-web-browser',
'./customGradle.plugin.js',
'./customManifest.plugin.js',
['app-icon-badge', appIconBadgeConfig],
Expand Down
2 changes: 2 additions & 0 deletions assets/js/form-render.min.js

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions assets/js/jquery-3.6.0.min.js

Large diffs are not rendered by default.

86 changes: 82 additions & 4 deletions electron/main.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const { app, BrowserWindow, protocol, net } = require('electron');
const { app, BrowserWindow, protocol, net, Notification, ipcMain, session } = require('electron');
const path = require('path');
const url = require('url');

Expand Down Expand Up @@ -31,9 +31,9 @@ function createWindow() {
icon: path.join(__dirname, '../assets/icon.png'),
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
nodeIntegration: isDev,
contextIsolation: !isDev,
webSecurity: !isDev,
nodeIntegration: false,
contextIsolation: true,
webSecurity: true,
},
});

Expand All @@ -55,6 +55,43 @@ function createWindow() {
}

app.whenReady().then(() => {
// ── Content Security Policy ───────────────────────────────────────
// Set a proper CSP to silence the Electron security warning about
// "unsafe-eval" / missing CSP. In development we allow the local
// dev-server origin; in production only the custom app:// scheme.
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
let csp;
if (isDev) {
// Dev mode: Metro/webpack needs 'unsafe-eval' for source maps
// and hot-reload, blob: for dynamic chunks, ws: for HMR.
csp =
"default-src 'self' http://localhost:8081;" +
" script-src 'self' http://localhost:8081 'unsafe-inline' 'unsafe-eval' blob:;" +
" style-src 'self' http://localhost:8081 'unsafe-inline';" +
" img-src 'self' http://localhost:8081 data: https: blob:;" +
" font-src 'self' http://localhost:8081 data:;" +
" connect-src 'self' http://localhost:8081 https: wss: ws:;" +
" media-src 'self' http://localhost:8081 data: blob:;" +
" worker-src 'self' blob:;";
} else {
csp =
"default-src 'self' app:;" +
" script-src 'self' app: 'unsafe-inline';" +
" style-src 'self' app: 'unsafe-inline';" +
" img-src 'self' app: data: https:;" +
" font-src 'self' app: data:;" +
" connect-src 'self' app: https: wss:;" +
" media-src 'self' app: data:;" +
" worker-src 'self' blob:;";
}

callback({
responseHeaders: {
...details.responseHeaders,
'Content-Security-Policy': [csp],
},
});
});
// Register the custom app:// protocol handler for production builds.
// This serves all files from the dist/ directory so that absolute asset
// paths in the bundled HTML/JS/CSS resolve correctly.
Expand All @@ -77,6 +114,47 @@ app.whenReady().then(() => {

createWindow();

// ── Notification IPC handlers ──────────────────────────────────────
// Allow the renderer to request native Electron Notification objects
// which map to macOS Notification Center, Windows Toast & Linux
// libnotify/notify-send automatically.

ipcMain.handle('notifications:isSupported', () => {
return Notification.isSupported();
});

ipcMain.handle('notifications:show', (_event, payload) => {
if (!Notification.isSupported()) {
console.warn('Native notifications are not supported on this platform');
return false;
}

try {
const notification = new Notification({
title: payload.title || 'Resgrid Dispatch',
body: payload.body || '',
icon: path.join(__dirname, '../assets/icon.png'),
silent: false,
});

notification.on('click', () => {
// Focus / restore the main window when the notification is clicked
const windows = BrowserWindow.getAllWindows();
if (windows.length > 0) {
const win = windows[0];
if (win.isMinimized()) win.restore();
win.focus();
}
});

notification.show();
return true;
} catch (err) {
console.error('Failed to show native notification:', err);
return false;
}
});

app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
Expand Down
49 changes: 49 additions & 0 deletions electron/preload.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,55 @@
// preload.js
// All of the Node.js APIs are available in the preload process.
// It has the same sandbox as a Chrome extension.
const { contextBridge, ipcRenderer } = require('electron');

// ── Expose Electron platform flag ──────────────────────────────────────
// The renderer can use `window.__ELECTRON__` to detect if it is running
// inside Electron (as opposed to a regular browser).
contextBridge.exposeInMainWorld('__ELECTRON__', true);

// ── Expose notification bridge ─────────────────────────────────────────
// Provides a safe bridge for the renderer to trigger native OS
// notifications via the main process (macOS, Windows, Linux).
const notificationCallbacks = [];

contextBridge.exposeInMainWorld('electronNotifications', {
/**
* Show a native OS notification via the Electron main process.
* @param {{ title: string, body: string, eventCode?: string, data?: object }} payload
* @returns {Promise<boolean>} true if the notification was shown
*/
show: (payload) => ipcRenderer.invoke('notifications:show', payload),

/**
* Check whether native notifications are supported on this platform.
* @returns {Promise<boolean>}
*/
isSupported: () => ipcRenderer.invoke('notifications:isSupported'),

/**
* Register a callback that fires when the main process sends a
* notification payload to the renderer (e.g. from a backend push
* channel handled in the main process).
* @param {(payload: object) => void} callback
*/
onNotification: (callback) => {
notificationCallbacks.push(callback);
},
});

// Forward notifications pushed from main → renderer
ipcRenderer.on('notification:push', (_event, payload) => {
for (const cb of notificationCallbacks) {
try {
cb(payload);
} catch (err) {
console.error('Error in notification callback:', err);
}
}
});

// ── Version information (original preload logic) ───────────────────────
window.addEventListener('DOMContentLoaded', () => {
const replaceText = (selector, text) => {
const element = document.getElementById(selector)
Expand Down
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
"install-maestro": "curl -Ls 'https://get.maestro.mobile.dev' | bash",
"e2e-test": "maestro test .maestro/ -e APP_ID=com.obytes.development",
"web:build": "cross-env APP_ENV=production expo export --platform web",
"electron:dev": "concurrently \"cross-env BROWSER=none yarn web\" \"wait-on http://localhost:8081 && electron .\"",
"electron:dev": "concurrently \"cross-env NODE_OPTIONS=--max-old-space-size=16384 BROWSER=none yarn web\" \"wait-on http://localhost:8081 && electron electron/main.js\"",
"electron:pack": "yarn web:build && electron-builder -c.extraMetadata.main=electron/main.js",
"docker:build": "docker build -t resgrid-dispatch-web .",
"docker:run": "docker run -p 3000:80 --env-file .env.docker resgrid-dispatch-web",
Expand Down Expand Up @@ -151,9 +151,11 @@
"expo-application": "~6.1.5",
"expo-asset": "~11.1.7",
"expo-audio": "~0.4.9",
"expo-auth-session": "~6.2.1",
"expo-av": "~15.1.7",
"expo-build-properties": "~0.14.8",
"expo-constants": "~17.1.7",
"expo-crypto": "~14.1.5",
"expo-dev-client": "~5.2.4",
"expo-device": "~7.1.4",
"expo-document-picker": "~13.1.6",
Expand All @@ -175,6 +177,7 @@
"expo-status-bar": "~2.2.3",
"expo-system-ui": "~5.0.11",
"expo-task-manager": "~13.1.6",
"expo-web-browser": "~14.2.0",
"geojson": "~0.5.0",
"he": "^1.2.0",
"i18next": "~23.14.0",
Expand Down Expand Up @@ -298,4 +301,4 @@
"resolutions": {
"form-data": "4.0.4"
}
}
}
34 changes: 34 additions & 0 deletions scripts/generate-vendor-sources.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#!/usr/bin/env node
/**
* Generates self-hosted TypeScript wrapper files for jQuery and form-render.
* Run: node scripts/generate-vendor-sources.js
*
* The output files embed the minified JS as a string literal so the
* call-form-renderer can inline them into the srcdoc iframe without relying
* on any external CDN.
*/

const fs = require('fs');
const path = require('path');

const root = path.resolve(__dirname, '..');
const outDir = path.join(root, 'src', 'lib', 'form-render');

fs.mkdirSync(outDir, { recursive: true });

function generate(srcRelative, outFile, exportName) {
const src = fs.readFileSync(path.join(root, srcRelative), 'utf8');
const content =
'// Auto-generated – do not edit manually.\n' +
'// Regenerate via: node scripts/generate-vendor-sources.js\n' +
'const ' + exportName + ': string = ' + JSON.stringify(src) + ';\n' +
'export default ' + exportName + ';\n';
fs.writeFileSync(path.join(outDir, outFile), content, 'utf8');
const size = fs.statSync(path.join(outDir, outFile)).size;
console.log('Wrote', outFile, '(' + size + ' bytes)');
}

generate('assets/js/jquery-3.6.0.min.js', 'jquery-source.ts', 'jquerySource');
generate('assets/js/form-render.min.js', 'form-render-source.ts', 'formRenderSource');

console.log('Done.');
Loading
Loading