Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
65 changes: 61 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,40 @@ 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 and resolve to a file in dist/
const resolvedPath = path.resolve(distPath, decodeURIComponent(url.pathname));

// 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());
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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