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
2 changes: 2 additions & 0 deletions .github/workflows/react-native-cicd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,8 @@ jobs:
| grep -v "<!-- This is an auto-generated comment: release notes by coderabbit.ai -->" \
| grep -v "<!-- end of auto-generated comment: release notes by coderabbit.ai -->" \
|| true)"
# Replace occurrences of "PR" with "Release"
NOTES="$(printf '%s\n' "$NOTES" | sed -E 's/\bPR\b/Release/g')"
else
NOTES="$(git log -n 5 --pretty=format:'- %s')"
fi
Expand Down
11 changes: 9 additions & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,19 @@
"statusBar.background": "#8ab9ab",
"statusBar.foreground": "#15202b",
"statusBarItem.hoverBackground": "#6aa694",
"statusBarItem.remoteBackground": "#8ab9ab",
"statusBarItem.remoteBackground": "#8c5fa0",
"statusBarItem.remoteForeground": "#15202b",
"titleBar.activeBackground": "#8ab9ab",
"titleBar.activeForeground": "#15202b",
"titleBar.inactiveBackground": "#8ab9ab99",
"titleBar.inactiveForeground": "#15202b99"
"titleBar.inactiveForeground": "#15202b99",
"activityBarTop.activeBackground": "#aaccc2",
"activityBarTop.background": "#aaccc2",
"activityBarTop.foreground": "#15202b",
"activityBarTop.inactiveForeground": "#15202b99",
"commandCenter.foreground": "#15202b",
"statusBar.debuggingBackground": "#8ab9ab",
"statusBar.debuggingForeground": "#15202b"
},
"peacock.color": "#8ab9ab",
"cSpell.words": [
Expand Down
16 changes: 16 additions & 0 deletions __mocks__/@notifee/react-native.web.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
const AndroidImportance = {
DEFAULT: 3,
HIGH: 4,
LOW: 2,
MIN: 1,
NONE: 0,
};

const notifee = {
registerForegroundService: () => {},
createChannel: async () => 'mock-channel-id',
displayNotification: async () => 'mock-notification-id',
stopForegroundService: async () => undefined,
};

module.exports = { default: notifee, AndroidImportance };
85 changes: 84 additions & 1 deletion __mocks__/react-native-gesture-handler.ts
Original file line number Diff line number Diff line change
@@ -1 +1,84 @@
module.exports = require('react-native-gesture-handler/src/mocks.js');
// Manual Jest mock for react-native-gesture-handler.
// RNGH 2.28 no longer ships src/mocks.js (the library mocks moved to
// src/mocks/mocks), so re-export them and add the named exports the app
// imports (ScrollView, GestureHandlerRootView, Gesture, GestureDetector).
/* eslint-disable @typescript-eslint/no-explicit-any */
const RN = require('react-native');
const libraryMocks = require('react-native-gesture-handler/src/mocks/mocks');
const { State } = require('react-native-gesture-handler/src/State');
const { Directions } = require('react-native-gesture-handler/src/Directions');

// Chainable no-op gesture builder (Gesture.Pan().onStart(...).onEnd(...) etc.)
const createChainableGesture = (): any => {
const gesture: any = {};
const chainableMethods = [
'onBegin',
'onStart',
'onUpdate',
'onChange',
'onEnd',
'onFinalize',
'onTouchesDown',
'onTouchesMove',
'onTouchesUp',
'onTouchesCancelled',
'enabled',
'shouldCancelWhenOutside',
'hitSlop',
'activeOffsetX',
'activeOffsetY',
'failOffsetX',
'failOffsetY',
'minDistance',
'minPointers',
'maxPointers',
'minDuration',
'maxDuration',
'maxDelay',
'numberOfTaps',
'maxDistance',
'runOnJS',
'simultaneousWithExternalGesture',
'requireExternalGestureToFail',
'blocksExternalGesture',
'withRef',
'withTestId',
];
chainableMethods.forEach((method) => {
gesture[method] = () => gesture;
});
return gesture;
};

const Gesture = {
Pan: createChainableGesture,
Tap: createChainableGesture,
Pinch: createChainableGesture,
Rotation: createChainableGesture,
Fling: createChainableGesture,
LongPress: createChainableGesture,
Native: createChainableGesture,
Manual: createChainableGesture,
Hover: createChainableGesture,
ForceTouch: createChainableGesture,
Simultaneous: (...gestures: any[]) => gestures[0],
Exclusive: (...gestures: any[]) => gestures[0],
Race: (...gestures: any[]) => gestures[0],
};

module.exports = {
...libraryMocks.default,
RawButton: libraryMocks.RawButton,
BaseButton: libraryMocks.BaseButton,
RectButton: libraryMocks.RectButton,
BorderlessButton: libraryMocks.BorderlessButton,
// Named exports used by the app that the library mocks omit
GestureHandlerRootView: RN.View,
ScrollView: RN.ScrollView,
RefreshControl: RN.RefreshControl,
Gesture,
GestureDetector: ({ children }: { children?: any }) => children ?? null,
gestureHandlerRootHOC: (component: any) => component,
State,
Directions,
};
10 changes: 3 additions & 7 deletions babel.config.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
module.exports = function (api) {
api.cache(true);

// Check if we're in test environment
const isTest = process.env.NODE_ENV === 'test';

return {
presets: isTest
? ['babel-preset-expo'] // No nativewind in test environment
: [['babel-preset-expo', { jsxImportSource: 'nativewind' }], 'nativewind/babel'],
// NativeWind v5 no longer needs a babel preset or jsxImportSource —
// styling is wired through the metro transform (react-native-css).
presets: ['babel-preset-expo'],
plugins: [
[
'module-resolver',
Expand All @@ -19,7 +16,6 @@ module.exports = function (api) {
'@unitools/image': '@unitools/image-expo',
'@unitools/router': '@unitools/router-expo',
'@unitools/link': '@unitools/link-expo',
'@tailwind.config': './tailwind.config.js',
'@assets': './assets',
},
extensions: ['.ios.ts', '.android.ts', '.ts', '.ios.tsx', '.android.tsx', '.tsx', '.jsx', '.js', '.json'],
Expand Down
137 changes: 125 additions & 12 deletions electron/main.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const { app, BrowserWindow, protocol, net, Notification, ipcMain, session } = require('electron');
const { app, BrowserWindow, protocol, net, Notification, ipcMain, session, shell, Menu } = require('electron');
const path = require('path');
const url = require('url');

Expand All @@ -23,8 +23,52 @@ protocol.registerSchemesAsPrivileged([
},
]);

let mainWindow = null;
let pendingDeepLink = null;

// Deep-link scheme matching the app's linking scheme (env.js SCHEME).
// URL schemes are case-insensitive; argv/open-url matching is done lowercased.
const deepLinkScheme = 'ResgridDispatch';
const deepLinkPrefix = deepLinkScheme.toLowerCase() + '://';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules low

String concatenation operator used to assemble the prefix string. Refactor to use a template literal (e.g., ${deepLinkScheme.toLowerCase()}://) for improved readability and fewer errors.

Kody rule violation: Use Template Literals Instead of String Concatenation

Prompt for LLM

File electron/main.js:

Line 32:

String concatenation operator used to assemble the prefix string. Refactor to use a template literal (e.g., `${deepLinkScheme.toLowerCase()}://`) for improved readability and fewer errors.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.


app.setAsDefaultProtocolClient(deepLinkScheme);

function forwardDeepLink(deepLinkUrl) {
if (mainWindow && !mainWindow.webContents.isLoading()) {
mainWindow.webContents.send('deep-link', deepLinkUrl);
} else {
pendingDeepLink = deepLinkUrl;
}
}

// Single-instance lock: required for the second-instance handler below to
// fire on Windows/Linux instead of spawning a duplicate process.
const gotSingleInstanceLock = app.requestSingleInstanceLock();
if (!gotSingleInstanceLock) {
app.quit();
} else {
app.on('second-instance', (_event, argv) => {
const deepLinkUrl = argv.find((arg) => arg.toLowerCase().startsWith(deepLinkPrefix));
if (!mainWindow) {
createWindow();
} else {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
if (deepLinkUrl) {
forwardDeepLink(deepLinkUrl);
}
});
}

// macOS deep links arrive via open-url instead of second-instance argv
app.on('open-url', (event, openUrl) => {
event.preventDefault();
forwardDeepLink(openUrl);
});

function createWindow() {
const mainWindow = new BrowserWindow({
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
title: 'Resgrid Dispatch',
Expand All @@ -42,6 +86,51 @@ function createWindow() {
event.preventDefault();
});

mainWindow.on('closed', () => {
mainWindow = null;
});

// Popups (incl. the OIDC/SSO flow) never create a new BrowserWindow.
// Only http(s) targets are handed to the OS browser, everything else
// is denied outright.
mainWindow.webContents.setWindowOpenHandler(({ url: targetUrl }) => {
try {
const parsed = new URL(targetUrl);
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
shell.openExternal(targetUrl);
}
} catch (err) {
console.error('Blocked invalid window.open URL:', targetUrl, err);
}
return { action: 'deny' };
});

// Block in-window navigation away from the app origin. Dev allows the
// local Metro server only; production allows the app:// scheme only.
mainWindow.webContents.on('will-navigate', (event, targetUrl) => {
let allowed = false;
try {
const parsed = new URL(targetUrl);
allowed = isDev
? (parsed.protocol === 'http:' || parsed.protocol === 'https:') &&
(parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1')
: parsed.protocol === 'app:';
} catch (err) {
console.error('Blocked invalid navigation URL:', targetUrl, err);
}
if (!allowed) {
event.preventDefault();
}
});

// Flush any deep link queued before the renderer finished loading
mainWindow.webContents.on('did-finish-load', () => {
if (pendingDeepLink) {
mainWindow.webContents.send('deep-link', pendingDeepLink);
pendingDeepLink = null;
}
});

// In development, load the local Expo web server
// In production, load via custom app:// protocol that serves from dist/
if (isDev) {
Expand All @@ -64,25 +153,30 @@ app.whenReady().then(() => {
if (isDev) {
// Dev mode: Metro/webpack needs 'unsafe-eval' for source maps
// and hot-reload, blob: for dynamic chunks, ws: for HMR.
// Mapbox GL loads its stylesheet from api.mapbox.com and uses
// blob: workers. connect-src allows plain http/ws for
// self-hosted Resgrid servers without TLS.
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';" +
" style-src 'self' http://localhost:8081 'unsafe-inline' https://api.mapbox.com;" +
" 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:;" +
" connect-src 'self' http://localhost:8081 https: http: wss: ws:;" +
" media-src 'self' http://localhost:8081 data: blob:;" +
" worker-src 'self' blob:;";
" worker-src 'self' blob:;" +
" child-src 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:;" +
" style-src 'self' app: 'unsafe-inline' https://api.mapbox.com;" +
" img-src 'self' app: data: https: blob:;" +
" font-src 'self' app: data:;" +
" connect-src 'self' app: https: wss:;" +
" media-src 'self' app: data:;" +
" worker-src 'self' blob:;";
" connect-src 'self' app: https: http: wss: ws:;" +
" media-src 'self' app: data: blob:;" +
" worker-src 'self' blob:;" +
" child-src blob:;";
}

callback({
Expand All @@ -107,11 +201,30 @@ app.whenReady().then(() => {
filePath = 'index.html';
}

const fullPath = path.join(distPath, filePath);
return net.fetch(url.pathToFileURL(fullPath).toString());
// Reject backslashes outright (Windows separator bypass) and
// verify the normalized/resolved path stays inside dist/ so
// ../ segments cannot escape to arbitrary local files.
if (filePath.includes('\\')) {
return new Response('Forbidden', { status: 403 });
}

const resolvedPath = path.resolve(distPath, path.normalize(filePath));
if (resolvedPath !== distPath && !resolvedPath.startsWith(distPath + path.sep)) {
return new Response('Forbidden', { status: 403 });
}

return net.fetch(url.pathToFileURL(resolvedPath).toString());
});
}

// Minimal application menu so copy/paste roles (and their keyboard
// shortcuts, especially on macOS) work inside the window.
const menuTemplate = [
...(process.platform === 'darwin' ? [{ role: 'appMenu' }] : []),
{ role: 'editMenu' },
];
Menu.setApplicationMenu(Menu.buildFromTemplate(menuTemplate));

createWindow();

// ── Notification IPC handlers ──────────────────────────────────────
Expand Down
23 changes: 23 additions & 0 deletions electron/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,29 @@ ipcRenderer.on('notification:push', (_event, payload) => {
}
});

// ── Expose deep-link bridge ────────────────────────────────────────────
// Lets the renderer subscribe to deep-link URLs (resgriddispatch://...)
// forwarded by the main process from second-instance / open-url events.
contextBridge.exposeInMainWorld('electronDeepLinks', {
/**
* Register a callback that fires when the main process forwards a
* deep-link URL to the renderer.
* @param {(url: string) => void} callback
* @returns {() => void} unsubscribe function that removes the listener
*/
onDeepLink: (callback) => {
const handler = (_event, deepLinkUrl) => {
try {
callback(deepLinkUrl);
} catch (err) {
console.error('Error in deep-link callback:', err);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Unstructured error logging detected in the deep-link catch block. Replace console.error with a structured logger.error call, including operation names and relevant identifiers for searchability and correlation.

Kody rule violation: Include error context in structured logs

Prompt for LLM

File electron/preload.js:

Line 66:

Unstructured error logging detected in the `deep-link` catch block. Replace `console.error` with a structured `logger.error` call, including operation names and relevant identifiers for searchability and correlation.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Unstructured logging in the catch block bypasses centralized logging and violates Rule [3] by omitting required structured fields. Replace console.error with a structured logger call, such as logger.error('deep-link callback failed', { op: 'onDeepLink', err }), to include operation names and relevant identifiers.

Kody rule violation: Include error context in structured logs

Prompt for LLM

File electron/preload.js:

Line 67:

Unstructured logging in the catch block bypasses centralized logging and violates Rule [3] by omitting required structured fields. Replace `console.error` with a structured logger call, such as `logger.error('deep-link callback failed', { op: 'onDeepLink', err })`, to include operation names and relevant identifiers.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

}
};
ipcRenderer.on('deep-link', handler);
return () => ipcRenderer.removeListener('deep-link', handler);
},
});

// ── Version information (original preload logic) ───────────────────────
window.addEventListener('DOMContentLoaded', () => {
const replaceText = (selector, text) => {
Expand Down
Loading
Loading