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
9 changes: 7 additions & 2 deletions .eslintignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.eslintignorenode_modules
node_modules
__tests__/
.vscode/
android/
Expand All @@ -7,4 +7,9 @@ ios/
.expo
.expo-shared
docs/
cli/
cli/
electron/
fastlane/
patches/
public/
scripts/
19 changes: 6 additions & 13 deletions .eslintrc.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
const path = require('path');

module.exports = {
extends: ['expo', 'plugin:tailwindcss/recommended', 'prettier'],
plugins: ['prettier', 'unicorn', '@typescript-eslint', 'unused-imports', 'tailwindcss', 'simple-import-sort', 'eslint-plugin-react-compiler'],
parserOptions: {
project: './tsconfig.json',
},
extends: ['expo', 'prettier'],
plugins: ['prettier', 'unicorn', '@typescript-eslint', 'unused-imports', 'simple-import-sort', 'eslint-plugin-react-compiler'],
// parserOptions: {
// project: './tsconfig.json',
// },
rules: {
'prettier/prettier': 'warn',
'max-params': ['error', 10], // Limit the number of parameters in a function to use object instead
Expand All @@ -24,17 +24,10 @@ module.exports = {
},
], // Ensure `import type` is used when it's necessary
'import/prefer-default-export': 'off', // Named export is easier to refactor automatically
'import/no-cycle': ['error', { maxDepth: '∞' }],
'tailwindcss/classnames-order': [
'warn',
{
officialSorting: true,
},
], // Follow the same ordering as the official plugin `prettier-plugin-tailwindcss`
'import/no-cycle': 'off', // Disabled due to performance issues
'simple-import-sort/imports': 'error', // Import configuration for `eslint-plugin-simple-import-sort`
'simple-import-sort/exports': 'error', // Export configuration for `eslint-plugin-simple-import-sort`
'@typescript-eslint/no-unused-vars': 'off',
'tailwindcss/no-custom-classname': 'off',
'unused-imports/no-unused-imports': 'off',
'unused-imports/no-unused-vars': [
'off',
Expand Down
9 changes: 6 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ RUN yarn install --frozen-lockfile
# Copy source files
COPY . .

# Build the web application without environment variables
# Environment variables will be injected at runtime via docker-entrypoint.sh
RUN yarn web:build
# Build the web application with production defaults
# Runtime environment variables will be injected at startup via docker-entrypoint.sh
# APP_ENV=production ensures the build uses production defaults and no .env suffix on IDs
RUN APP_ENV=production yarn web:build

### STAGE 2: Run ###
FROM nginx:1.25-alpine
Expand All @@ -42,6 +43,8 @@ EXPOSE 80
ENV APP_ENV=production \
UNIT_NAME="Resgrid Unit" \
UNIT_SCHEME="ResgridUnit" \
UNIT_BUNDLE_ID="com.resgrid.unit" \
UNIT_PACKAGE="com.resgrid.unit" \
UNIT_VERSION="0.0.1" \
UNIT_BASE_API_URL="https://api.resgrid.com" \
UNIT_API_VERSION="v4" \
Expand Down
43 changes: 43 additions & 0 deletions __mocks__/react-native-svg.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import React from 'react';

const createMockComponent = (name: string) => {
const Component = React.forwardRef((props: any, ref: any) =>
React.createElement(name, { ...props, ref })
);
Component.displayName = name;
return Component;
};

export const Svg = createMockComponent('Svg');
export const Circle = createMockComponent('Circle');
export const Ellipse = createMockComponent('Ellipse');
export const G = createMockComponent('G');
export const Text = createMockComponent('SvgText');
export const TSpan = createMockComponent('TSpan');
export const TextPath = createMockComponent('TextPath');
export const Path = createMockComponent('Path');
export const Polygon = createMockComponent('Polygon');
export const Polyline = createMockComponent('Polyline');
export const Line = createMockComponent('Line');
export const Rect = createMockComponent('Rect');
export const Use = createMockComponent('Use');
export const Image = createMockComponent('SvgImage');
export const Symbol = createMockComponent('SvgSymbol');
export const Defs = createMockComponent('Defs');
export const LinearGradient = createMockComponent('LinearGradient');
export const RadialGradient = createMockComponent('RadialGradient');
export const Stop = createMockComponent('Stop');
export const ClipPath = createMockComponent('ClipPath');
export const Pattern = createMockComponent('Pattern');
export const Mask = createMockComponent('Mask');
export const ForeignObject = createMockComponent('ForeignObject');
export const Marker = createMockComponent('Marker');
export const SvgFromUri = createMockComponent('SvgFromUri');
export const SvgFromXml = createMockComponent('SvgFromXml');
export const SvgXml = createMockComponent('SvgXml');
export const SvgUri = createMockComponent('SvgUri');
export const SvgCss = createMockComponent('SvgCss');
export const SvgCssUri = createMockComponent('SvgCssUri');
export const parse = jest.fn();

export default Svg;
2 changes: 2 additions & 0 deletions app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,11 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
'android.permission.POST_NOTIFICATIONS',
'android.permission.FOREGROUND_SERVICE',
'android.permission.FOREGROUND_SERVICE_MICROPHONE',
'android.permission.FOREGROUND_SERVICE_PHONE_CALL',
'android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE',
'android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK',
'android.permission.READ_PHONE_STATE',
'android.permission.READ_PHONE_NUMBERS',
'android.permission.MANAGE_OWN_CALLS',
],
},
Expand Down
1 change: 1 addition & 0 deletions babel.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ module.exports = function (api) {
extensions: ['.ios.ts', '.android.ts', '.ts', '.ios.tsx', '.android.tsx', '.tsx', '.jsx', '.js', '.json'],
},
],
'babel-plugin-transform-import-meta',
'react-native-reanimated/plugin',
],
};
Expand Down
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ services:
- APP_ENV=production
- UNIT_NAME=Resgrid Unit
- UNIT_SCHEME=ResgridUnit
- UNIT_BUNDLE_ID=com.resgrid.unit
- UNIT_PACKAGE=com.resgrid.unit
- UNIT_VERSION=0.0.1
- UNIT_BASE_API_URL=https://api.resgrid.com
- UNIT_API_VERSION=v4
Expand Down
4 changes: 4 additions & 0 deletions docker/docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,18 @@ js_escape() {
}

# Create the env-config.js file with environment variables
# Includes ALL fields expected by the client env schema in env.js
cat > "${HTML_DIR}/env-config.js" << EOF
// Runtime environment configuration - generated by docker-entrypoint.sh
// This file is generated at container startup and injects environment variables
window.__ENV__ = {
APP_ENV: "$(js_escape "${APP_ENV:-production}")",
NAME: "$(js_escape "${UNIT_NAME:-Resgrid Unit}")",
SCHEME: "$(js_escape "${UNIT_SCHEME:-ResgridUnit}")",
BUNDLE_ID: "$(js_escape "${UNIT_BUNDLE_ID:-com.resgrid.unit}")",
PACKAGE: "$(js_escape "${UNIT_PACKAGE:-com.resgrid.unit}")",
VERSION: "$(js_escape "${UNIT_VERSION:-0.0.1}")",
ANDROID_VERSION_CODE: 1,
BASE_API_URL: "$(js_escape "${UNIT_BASE_API_URL:-https://api.resgrid.com}")",
API_VERSION: "$(js_escape "${UNIT_API_VERSION:-v4}")",
RESGRID_API_URL: "$(js_escape "${UNIT_RESGRID_API_URL:-/api/v4}")",
Expand Down
66 changes: 62 additions & 4 deletions electron/main.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,24 @@
/* eslint-disable no-undef */
const { app, BrowserWindow, ipcMain, Notification, nativeTheme, Menu } = require('electron');
const { app, BrowserWindow, ipcMain, Notification, nativeTheme, Menu, protocol, net } = require('electron');
const path = require('path');
const fs = require('fs');
const { pathToFileURL } = require('url');

// Register custom protocol scheme before app is ready
// This allows serving the Expo web export with absolute paths (/_expo/static/...)
// via a custom protocol instead of file://, which breaks absolute path resolution.
protocol.registerSchemesAsPrivileged([
{
scheme: 'app',
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
corsEnabled: true,
stream: true,
},
},
]);

// Handle creating/removing shortcuts on Windows when installing/uninstalling.
if (require('electron-squirrel-startup')) {
Expand Down Expand Up @@ -33,9 +51,14 @@ function createWindow() {
});

// Load the app
const startUrl = isDev ? 'http://localhost:8081' : `file://${path.join(__dirname, '../dist/index.html')}`;

mainWindow.loadURL(startUrl);
if (isDev) {
// In development, load from the Expo dev server
mainWindow.loadURL('http://localhost:8081');
} else {
// In production, load via the custom app:// protocol
// which correctly resolves absolute paths (/_expo/static/...) from the dist directory
mainWindow.loadURL('app://bundle/index.html');
}

// Show window when ready
mainWindow.once('ready-to-show', () => {
Expand Down Expand Up @@ -154,6 +177,41 @@ ipcMain.handle('get-platform', () => {

// Handle app ready
app.whenReady().then(() => {
// Register custom protocol handler for serving the Expo web export
// This resolves absolute paths like /_expo/static/js/... from the dist directory
const distPath = path.join(__dirname, '..', 'dist');
const resolvedDist = path.resolve(distPath);

protocol.handle('app', (request) => {
const url = new URL(request.url);
// Decode the pathname, join with base path, then canonicalize to prevent directory traversal
const joinedPath = path.join(distPath, decodeURIComponent(url.pathname));
const resolvedPath = path.resolve(joinedPath);

// Security check: ensure resolved path is within distPath to prevent directory traversal
let filePath;
if (!resolvedPath.startsWith(resolvedDist + path.sep) && resolvedPath !== resolvedDist) {
// Path escapes distPath - fall back to index.html
filePath = path.join(resolvedDist, 'index.html');
} else {
filePath = resolvedPath;

// If the path points to a directory or file doesn't exist, fall back to index.html
// This supports SPA client-side routing
try {
const stat = fs.statSync(filePath);
if (stat.isDirectory()) {
filePath = path.join(resolvedDist, 'index.html');
}
} catch {
// File not found - serve index.html for client-side routing
filePath = path.join(resolvedDist, 'index.html');
}
}

return net.fetch(pathToFileURL(filePath).toString());
});

createMenu();
createWindow();

Expand Down
22 changes: 22 additions & 0 deletions global.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,25 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

/* Web-only pulse animation for user location marker on map */
@keyframes pulse-ring {
0%, 100% {
transform: scale(1);
opacity: 0.3;
}
50% {
transform: scale(1.2);
opacity: 0.15;
}
}

/* Web-only skeleton loading animation */
@keyframes skeleton-pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.75;
}
}
28 changes: 28 additions & 0 deletions jest-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,34 @@ global.window = {};
// @ts-ignore
global.window = global;

// Mock react-native Platform globally
// Must include `default` export because react-native/index.js and processColor.js
// access Platform via require('./Libraries/Utilities/Platform').default
jest.mock('react-native/Libraries/Utilities/Platform', () => {
const platform = {
OS: 'ios' as const,
select: jest.fn((obj: any) => obj.ios ?? obj.native ?? obj.default),
Version: 14,
constants: {
osVersion: '14.0',
interfaceIdiom: 'phone',
isTesting: true,
reactNativeVersion: { major: 0, minor: 76, patch: 0 },
systemName: 'iOS',
},
isTesting: true,
isPad: false,
isTV: false,
isVision: false,
isMacCatalyst: false,
};
return {
__esModule: true,
default: platform,
...platform,
};
});

// Mock expo-audio globally
jest.mock('expo-audio', () => ({
createAudioPlayer: jest.fn(() => ({
Expand Down
46 changes: 46 additions & 0 deletions metro.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,52 @@ config.resolver.resolveRequest = (context, moduleName, platform) => {
type: 'sourceFile',
};
}

// Countly SDK needs its own shim with proper default export.
// The CountlyConfig subpath must resolve to a dedicated shim whose
// default export is the CountlyConfig class (not the Countly object).
if (moduleName === 'countly-sdk-react-native-bridge/CountlyConfig') {
return {
filePath: path.resolve(__dirname, 'src/lib/countly-config-shim.web.ts'),
type: 'sourceFile',
};
}
if (moduleName === 'countly-sdk-react-native-bridge' || moduleName.startsWith('countly-sdk-react-native-bridge/')) {
return {
filePath: path.resolve(__dirname, 'src/lib/countly-shim.web.ts'),
type: 'sourceFile',
};
}

// Force zustand and related packages to use CJS build instead of ESM
// The ESM build uses import.meta.env which Metro doesn't support
const zustandModules = {
zustand: path.resolve(__dirname, 'node_modules/zustand/index.js'),
'zustand/shallow': path.resolve(__dirname, 'node_modules/zustand/shallow.js'),
'zustand/middleware': path.resolve(__dirname, 'node_modules/zustand/middleware.js'),
'zustand/traditional': path.resolve(__dirname, 'node_modules/zustand/traditional.js'),
'zustand/vanilla': path.resolve(__dirname, 'node_modules/zustand/vanilla.js'),
'zustand/context': path.resolve(__dirname, 'node_modules/zustand/context.js'),
};

if (zustandModules[moduleName]) {
return {
filePath: zustandModules[moduleName],
type: 'sourceFile',
};
}

// Block build-time/dev packages that use import.meta from being bundled
// These are dev tools that should never be included in a client bundle
const buildTimePackages = ['tinyglobby', 'fdir', 'node-gyp', 'electron-builder', 'electron-rebuild', '@electron/rebuild', 'app-builder-lib', 'dmg-builder'];

if (buildTimePackages.some((pkg) => moduleName === pkg || moduleName.startsWith(`${pkg}/`))) {
// Return an empty module shim
return {
filePath: path.resolve(__dirname, 'src/lib/empty-module.web.js'),
type: 'sourceFile',
};
}
}

// Use the original resolver for everything else
Expand Down
6 changes: 4 additions & 2 deletions nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,17 @@ http {
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self';" always;
# Note: CSP connect-src/img-src must allow the configured API URL, Mapbox, Sentry, etc.
# The docker-entrypoint.sh can generate a tighter policy if needed.
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data: https:; connect-src 'self' https: wss:; worker-src 'self' blob:;" always;

# Static assets with cache
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self';" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data: https:; connect-src 'self' https: wss:; worker-src 'self' blob:;" always;
try_files $uri =404;
}

Expand Down
Loading