feat: Add customization URL parameter (#5992)
* Add customization URL parameter
* fix: Preserve should be customizeable
* Update customizations docs
* fix: Overlay items on patient name
* Add customization test
* Fix resolve to absolute path
* fix: Warn on no data in load
* Remove unused customization stuff
* fix: PR comments
* Update stored parameters to only use an array for mulitples
* Remove requires ohif.* special call out
* Remove strict mode
* PR comments
* Document segmentation examples
* Add three examples as requested
* PR comments
* lock
* Remove old customizatoin export
* fix: Ordering issues on customization loads
* fix: Use correct default for dev builds app config
* Fixes for conflicts
* chore: restore pnpm-lock.yaml to match master
The lockfile diff was incidental peer-descriptor churn and carried no
functional dependency change. It tripped the CircleCI security-audit gate
(which only runs when pnpm-lock.yaml is in the PR diff), surfacing a
pre-existing critical `decompress` transitive vuln that also exists on
master. Restoring master's lockfile removes the audit trigger.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(ci): restore json5 lockfile entry; ignore unfixable decompress GHSA
The previous commit restored pnpm-lock.yaml from master, which dropped the
json5@2.2.3 entry that platform/core legitimately depends on (JSONC parsing
for the customization feature). That broke `--frozen-lockfile` install
(ERR_PNPM_OUTDATED_LOCKFILE). This restores the correct lockfile.
Because the lockfile must change (json5), the CircleCI security-audit gate
runs and previously failed on a critical `decompress` <=4.2.1 zip-slip
advisory. This is a pre-existing transitive vuln (present on master too) with
no published patch — decompress's latest release is 4.2.1, so no version
bump/override can resolve it. It reaches the tree only via @itk-wasm/dam, a
build/data-asset extraction tool under @cornerstonejs/labelmap-interpolation.
Add GHSA-mp2f-45pm-3cg9 to the existing pnpm-workspace.yaml auditConfig
ignoreGhsas accepted-risk list, matching how the repo already exempts other
build-tooling advisories. `pnpm audit --audit-level high` now passes locally
(1 critical ignored, 0 high).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(e2e): fix visitStudy URL encoding that broke mpr2 study load
The visitStudy rewrite (added for the ?customization= option) built the URL
with new URLSearchParams({ StudyInstanceUIDs: studyInstanceUID }), which
percent-encodes the value. mpr2.spec.ts embeds an extra param in the UID
string ('<uid>&hangingprotocolid=mpr'), so the & and = were encoded and the
whole thing collapsed into one invalid StudyInstanceUIDs value -> the study
could not be found ('studies are not available'), the viewer never rendered,
and the side-panel-header-right click timed out.
Restore master's raw concatenation for StudyInstanceUIDs (so embedded params
survive as separate query params) while still appending the customization
option separately. Only mpr2 embeds & in the UID, matching the single failure.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* PR comments
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3d9a17bc0c
commit
3dd5c70cb2
@ -14,7 +14,7 @@ import './CustomizableViewportOverlay.css';
|
||||
import { useViewportRendering } from '../../hooks';
|
||||
|
||||
const EPSILON = 1e-4;
|
||||
const { formatPN } = utils;
|
||||
const { formatPN, formatValue } = utils;
|
||||
|
||||
type ViewportData = StackViewportData | VolumeViewportData;
|
||||
|
||||
@ -184,7 +184,12 @@ function CustomizableViewportOverlay({
|
||||
} else {
|
||||
const renderItem = customizationService.transform(item);
|
||||
|
||||
if (typeof renderItem.contentF === 'function') {
|
||||
if (
|
||||
renderItem &&
|
||||
typeof renderItem === 'object' &&
|
||||
'contentF' in renderItem &&
|
||||
typeof renderItem.contentF === 'function'
|
||||
) {
|
||||
return renderItem.contentF(overlayItemProps);
|
||||
}
|
||||
}
|
||||
@ -357,7 +362,8 @@ function OverlayItem(props) {
|
||||
const { instance, customization = {} } = props;
|
||||
const { color, attribute, title, label, background } = customization;
|
||||
const value = customization.contentF?.(props, customization) ?? instance?.[attribute];
|
||||
if (value === undefined || value === null) {
|
||||
const displayValue = formatValue(value);
|
||||
if (displayValue === null || displayValue === '') {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
@ -367,7 +373,7 @@ function OverlayItem(props) {
|
||||
title={title}
|
||||
>
|
||||
{label ? <span className="mr-1 shrink-0">{label}</span> : null}
|
||||
<span className="ml-0 shrink-0">{value}</span>
|
||||
<span className="ml-0 shrink-0">{displayValue}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import type { Button } from '@ohif/core/types';
|
||||
|
||||
import { EVENTS } from '@cornerstonejs/core';
|
||||
import { ViewportGridService } from '@ohif/core';
|
||||
import { ViewportGridService, ToolbarService } from '@ohif/core';
|
||||
import i18n from 'i18next';
|
||||
|
||||
const { TOOLBAR_SECTIONS } = ToolbarService;
|
||||
|
||||
const callbacks = (toolName: string) => [
|
||||
{
|
||||
commandName: 'setViewportForToolConfiguration',
|
||||
@ -708,4 +710,91 @@ const toolbarButtons: Button[] = [
|
||||
// },
|
||||
];
|
||||
|
||||
export default toolbarButtons;
|
||||
/**
|
||||
* Default toolbar layout: which buttons appear in each toolbar section.
|
||||
* Registered as a customization so modes (and `?customization=` modules) can
|
||||
* append/replace entries without rebuilding.
|
||||
*/
|
||||
export const toolbarSections = {
|
||||
[TOOLBAR_SECTIONS.primary]: [
|
||||
'MeasurementTools',
|
||||
'Zoom',
|
||||
'Pan',
|
||||
'TrackballRotate',
|
||||
'WindowLevel',
|
||||
'Capture',
|
||||
'Layout',
|
||||
'Crosshairs',
|
||||
'MoreTools',
|
||||
],
|
||||
|
||||
[TOOLBAR_SECTIONS.viewportActionMenu.topLeft]: ['orientationMenu', 'dataOverlayMenu'],
|
||||
|
||||
[TOOLBAR_SECTIONS.viewportActionMenu.bottomMiddle]: ['AdvancedRenderingControls'],
|
||||
|
||||
AdvancedRenderingControls: [
|
||||
'windowLevelMenuEmbedded',
|
||||
'voiManualControlMenu',
|
||||
'Colorbar',
|
||||
'opacityMenu',
|
||||
'thresholdMenu',
|
||||
],
|
||||
|
||||
[TOOLBAR_SECTIONS.viewportActionMenu.topRight]: [
|
||||
'modalityLoadBadge',
|
||||
'trackingStatus',
|
||||
'navigationComponent',
|
||||
],
|
||||
|
||||
[TOOLBAR_SECTIONS.viewportActionMenu.bottomLeft]: ['windowLevelMenu'],
|
||||
|
||||
MeasurementTools: [
|
||||
'Length',
|
||||
'Bidirectional',
|
||||
'ArrowAnnotate',
|
||||
'EllipticalROI',
|
||||
'RectangleROI',
|
||||
'CircleROI',
|
||||
'PlanarFreehandROI',
|
||||
'SplineROI',
|
||||
'LivewireContour',
|
||||
],
|
||||
|
||||
MoreTools: [
|
||||
'Reset',
|
||||
'rotate-right',
|
||||
'flipHorizontal',
|
||||
'ImageSliceSync',
|
||||
'ReferenceLines',
|
||||
'ImageOverlayViewer',
|
||||
'StackScroll',
|
||||
'invert',
|
||||
'Probe',
|
||||
'Cine',
|
||||
'Angle',
|
||||
'CobbAngle',
|
||||
'Magnify',
|
||||
'CalibrationLine',
|
||||
'TagBrowser',
|
||||
'AdvancedMagnify',
|
||||
'UltrasoundDirectionalTool',
|
||||
'WindowLevelRegion',
|
||||
'SegmentLabelTool',
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Customizations registered (at default scope) by the cornerstone extension:
|
||||
* - `cornerstone.toolbarButtons` – the default toolbar button definitions
|
||||
* - `cornerstone.toolbarSections` – the default toolbar layout (section -> button ids)
|
||||
*
|
||||
* Modes read these by name in `onModeEnter`; URL `?customization=` modules can
|
||||
* extend them with immutability-helper commands (e.g. `$push` a new button).
|
||||
*/
|
||||
const toolbarButtonsCustomization = {
|
||||
'cornerstone.toolbarButtons': toolbarButtons,
|
||||
'cornerstone.toolbarSections': toolbarSections,
|
||||
};
|
||||
|
||||
export { toolbarButtons };
|
||||
export default toolbarButtonsCustomization;
|
||||
@ -8,6 +8,7 @@ import volumeRenderingCustomization from './customizations/volumeRenderingCustom
|
||||
import colorbarCustomization from './customizations/colorbarCustomization';
|
||||
import modalityColorMapCustomization from './customizations/modalityColorMapCustomization';
|
||||
import windowLevelPresetsCustomization from './customizations/windowLevelPresetsCustomization';
|
||||
import toolbarButtonsCustomization from './customizations/toolbarButtonsCustomization';
|
||||
import miscCustomization from './customizations/miscCustomization';
|
||||
import captureViewportModalCustomization from './customizations/captureViewportModalCustomization';
|
||||
import viewportDownloadWarningCustomization from './customizations/viewportDownloadWarningCustomization';
|
||||
@ -32,6 +33,7 @@ function getCustomizationModule({ commandsManager, servicesManager, extensionMan
|
||||
...colorbarCustomization,
|
||||
...modalityColorMapCustomization,
|
||||
...windowLevelPresetsCustomization,
|
||||
...toolbarButtonsCustomization,
|
||||
...miscCustomization,
|
||||
...captureViewportModalCustomization,
|
||||
...viewportDownloadWarningCustomization,
|
||||
|
||||
@ -3,6 +3,7 @@ import {
|
||||
WindowLevelTool,
|
||||
SegmentBidirectionalTool,
|
||||
StackScrollTool,
|
||||
PlanarRotateTool,
|
||||
VolumeRotateTool,
|
||||
ZoomTool,
|
||||
MIPJumpToClickTool,
|
||||
@ -74,6 +75,7 @@ export default function initCornerstoneTools(configuration = {}) {
|
||||
addTool(SegmentBidirectionalTool);
|
||||
addTool(WindowLevelTool);
|
||||
addTool(StackScrollTool);
|
||||
addTool(PlanarRotateTool);
|
||||
addTool(VolumeRotateTool);
|
||||
addTool(ZoomTool);
|
||||
addTool(ProbeTool);
|
||||
@ -138,6 +140,7 @@ const toolNames = {
|
||||
WindowLevel: WindowLevelTool.toolName,
|
||||
StackScroll: StackScrollTool.toolName,
|
||||
Zoom: ZoomTool.toolName,
|
||||
PlanarRotate: PlanarRotateTool.toolName,
|
||||
VolumeRotate: VolumeRotateTool.toolName,
|
||||
MipJumpToClick: MIPJumpToClickTool.toolName,
|
||||
Length: LengthTool.toolName,
|
||||
|
||||
@ -52,6 +52,7 @@ function processResults(qidoStudies) {
|
||||
accession: getString(qidoStudy['00080050']) || '', // short string, probably a number?
|
||||
mrn: getString(qidoStudy['00100020']) || '', // medicalRecordNumber
|
||||
patientName: utils.formatPN(getName(qidoStudy['00100010'])) || '',
|
||||
patientBirthDate: getString(qidoStudy['00100030']) || '', // YYYYMMDD
|
||||
instances: Number(getString(qidoStudy['00201208'])) || 0, // number
|
||||
description: getString(qidoStudy['00081030']) || '',
|
||||
modalities: getString(getModalities(qidoStudy['00080060'], qidoStudy['00080061'])) || '',
|
||||
@ -153,6 +154,7 @@ function mapParams(params, options = {}) {
|
||||
'00081030', // Study Description
|
||||
'00080060', // Modality
|
||||
'00080090', // Referring Physician's Name
|
||||
'00100030', // Patient's Birth Date
|
||||
// Add more fields here if you want them in the result
|
||||
].join(',');
|
||||
|
||||
|
||||
@ -28,7 +28,7 @@ function ViewerHeader({ appConfig }: withAppTypes<{ appConfig: AppTypes.Config }
|
||||
if (dataSourceIdx !== -1 && existingDataSource) {
|
||||
searchQuery.append('datasources', pathname.substring(dataSourceIdx + 1));
|
||||
}
|
||||
preserveQueryParameters(searchQuery);
|
||||
preserveQueryParameters(searchQuery, customizationService);
|
||||
|
||||
navigate({
|
||||
pathname: '/',
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { utils } from '@ohif/core';
|
||||
|
||||
export default {
|
||||
'ohif.overlayItem': function (props) {
|
||||
@ -13,7 +14,8 @@ export default {
|
||||
: this.contentF && typeof this.contentF === 'function'
|
||||
? this.contentF(props)
|
||||
: null;
|
||||
if (!value) {
|
||||
const displayValue = utils.formatValue(value);
|
||||
if (!displayValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -24,7 +26,7 @@ export default {
|
||||
title={this.title || ''}
|
||||
>
|
||||
{this.label && <span className="mr-1 shrink-0">{this.label}</span>}
|
||||
<span className="font-light">{value}</span>
|
||||
<span className="font-light">{displayValue}</span>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
|
||||
@ -1,11 +1,9 @@
|
||||
import update from 'immutability-helper';
|
||||
import { ToolbarService, utils } from '@ohif/core';
|
||||
import { utils } from '@ohif/core';
|
||||
|
||||
import initToolGroups from './initToolGroups';
|
||||
import toolbarButtons from './toolbarButtons';
|
||||
import { id } from './id';
|
||||
|
||||
const { TOOLBAR_SECTIONS } = ToolbarService;
|
||||
const { structuredCloneWithFunctions } = utils;
|
||||
|
||||
/**
|
||||
@ -145,9 +143,22 @@ export function onModeEnter({
|
||||
// Init Default and SR ToolGroups
|
||||
initToolGroups(extensionManager, toolGroupService, commandsManager);
|
||||
|
||||
toolbarService.register(this.toolbarButtons);
|
||||
// Toolbar buttons and layout may be supplied either as a customization name
|
||||
// (a string, resolved through the customization service so `?customization=`
|
||||
// modules can extend the cornerstone-registered defaults) or as a literal
|
||||
// value (the button array / sections object) for modes that define them inline.
|
||||
const resolveToolbarCustomization = (value: unknown) =>
|
||||
typeof value === 'string' ? customizationService.getCustomization(value) : value;
|
||||
|
||||
for (const [key, section] of Object.entries(this.toolbarSections)) {
|
||||
const toolbarButtons = resolveToolbarCustomization(this.toolbarButtons) as any;
|
||||
const toolbarSections = (resolveToolbarCustomization(this.toolbarSections) ?? {}) as Record<
|
||||
string,
|
||||
string[]
|
||||
>;
|
||||
|
||||
toolbarService.register(toolbarButtons);
|
||||
|
||||
for (const [key, section] of Object.entries(toolbarSections)) {
|
||||
toolbarService.updateSection(key, section);
|
||||
}
|
||||
|
||||
@ -212,74 +223,6 @@ export function onModeExit({ servicesManager }: withAppTypes) {
|
||||
cornerstoneViewportService.destroy();
|
||||
}
|
||||
|
||||
export const toolbarSections = {
|
||||
[TOOLBAR_SECTIONS.primary]: [
|
||||
'MeasurementTools',
|
||||
'Zoom',
|
||||
'Pan',
|
||||
'TrackballRotate',
|
||||
'WindowLevel',
|
||||
'Capture',
|
||||
'Layout',
|
||||
'Crosshairs',
|
||||
'MoreTools',
|
||||
],
|
||||
|
||||
[TOOLBAR_SECTIONS.viewportActionMenu.topLeft]: ['orientationMenu', 'dataOverlayMenu'],
|
||||
|
||||
[TOOLBAR_SECTIONS.viewportActionMenu.bottomMiddle]: ['AdvancedRenderingControls'],
|
||||
|
||||
AdvancedRenderingControls: [
|
||||
'windowLevelMenuEmbedded',
|
||||
'voiManualControlMenu',
|
||||
'Colorbar',
|
||||
'opacityMenu',
|
||||
'thresholdMenu',
|
||||
],
|
||||
|
||||
[TOOLBAR_SECTIONS.viewportActionMenu.topRight]: [
|
||||
'modalityLoadBadge',
|
||||
'trackingStatus',
|
||||
'navigationComponent',
|
||||
],
|
||||
|
||||
[TOOLBAR_SECTIONS.viewportActionMenu.bottomLeft]: ['windowLevelMenu'],
|
||||
|
||||
MeasurementTools: [
|
||||
'Length',
|
||||
'Bidirectional',
|
||||
'ArrowAnnotate',
|
||||
'EllipticalROI',
|
||||
'RectangleROI',
|
||||
'CircleROI',
|
||||
'PlanarFreehandROI',
|
||||
'SplineROI',
|
||||
'LivewireContour',
|
||||
],
|
||||
|
||||
MoreTools: [
|
||||
'Reset',
|
||||
'rotate-right',
|
||||
'flipHorizontal',
|
||||
'ImageSliceSync',
|
||||
'ReferenceLines',
|
||||
'ImageOverlayViewer',
|
||||
'StackScroll',
|
||||
'invert',
|
||||
'Probe',
|
||||
'Cine',
|
||||
'Angle',
|
||||
'CobbAngle',
|
||||
'Magnify',
|
||||
'CalibrationLine',
|
||||
'TagBrowser',
|
||||
'AdvancedMagnify',
|
||||
'UltrasoundDirectionalTool',
|
||||
'WindowLevelRegion',
|
||||
'SegmentLabelTool',
|
||||
],
|
||||
};
|
||||
|
||||
export const basicLayout = {
|
||||
id: ohif.layout,
|
||||
props: {
|
||||
@ -342,7 +285,10 @@ export const modeInstance = {
|
||||
hide: false,
|
||||
displayName: 'Non-Longitudinal Basic',
|
||||
_activatePanelTriggersSubscriptions: [],
|
||||
toolbarSections,
|
||||
// Toolbar buttons and layout are referenced by customization name; the
|
||||
// cornerstone extension registers the defaults and `?customization=` modules
|
||||
// can extend them. onModeEnter resolves these names via the customization service.
|
||||
toolbarSections: 'cornerstone.toolbarSections',
|
||||
|
||||
/**
|
||||
* Lifecycle hooks
|
||||
@ -364,7 +310,7 @@ export const modeInstance = {
|
||||
// general handler needs to come last. For this case, the dicomvideo must
|
||||
// come first to remove video transfer syntax before ohif uses images
|
||||
sopClassHandlers,
|
||||
toolbarButtons,
|
||||
toolbarButtons: 'cornerstone.toolbarButtons',
|
||||
enableSegmentationEdit: false,
|
||||
nonModeModalities: NON_IMAGE_MODALITIES,
|
||||
};
|
||||
@ -389,4 +335,4 @@ export const mode = {
|
||||
};
|
||||
|
||||
export default mode;
|
||||
export { initToolGroups, toolbarButtons };
|
||||
export { initToolGroups };
|
||||
|
||||
@ -69,6 +69,7 @@ function initDefaultToolGroup(extensionManager, toolGroupService, commandsManage
|
||||
{ toolName: toolNames.Angle },
|
||||
{ toolName: toolNames.CobbAngle },
|
||||
{ toolName: toolNames.Magnify },
|
||||
{ toolName: toolNames.PlanarRotate },
|
||||
{ toolName: toolNames.CalibrationLine },
|
||||
{
|
||||
toolName: toolNames.PlanarFreehandContourSegmentation,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import i18n from 'i18next';
|
||||
import { id } from './id';
|
||||
import { initToolGroups, toolbarButtons, cornerstone,
|
||||
import { initToolGroups, cornerstone,
|
||||
ohif,
|
||||
dicomsr,
|
||||
dicomvideo,
|
||||
@ -73,4 +73,4 @@ const mode = {
|
||||
};
|
||||
|
||||
export default mode;
|
||||
export { initToolGroups, toolbarButtons };
|
||||
export { initToolGroups };
|
||||
|
||||
@ -15,7 +15,6 @@ const WATCH_IGNORED = /node_modules[\\/](?!@cornerstonejs(?:[\\/]|$))/;
|
||||
// ~~ Env Vars
|
||||
const HTML_TEMPLATE = process.env.HTML_TEMPLATE || 'index.html';
|
||||
const PUBLIC_URL = process.env.PUBLIC_URL || '/';
|
||||
const APP_CONFIG = process.env.APP_CONFIG || 'config/default.js';
|
||||
|
||||
// proxy settings
|
||||
const PROXY_TARGET = process.env.PROXY_TARGET;
|
||||
@ -103,6 +102,12 @@ const setHeaders = (res, path) => {
|
||||
module.exports = (env, argv) => {
|
||||
const baseConfig = webpackBase(env, argv, { SRC_DIR, DIST_DIR });
|
||||
const isProdBuild = process.env.NODE_ENV === 'production';
|
||||
// Honor an explicit APP_CONFIG; otherwise the dev server gets the
|
||||
// full-featured `config/dev.js` and a production build the locked-down
|
||||
// `config/default.js`. This lets `APP_CONFIG=config/foo.js pnpm run dev`
|
||||
// override the default instead of being clobbered by the script.
|
||||
const APP_CONFIG =
|
||||
process.env.APP_CONFIG || (isProdBuild ? 'config/default.js' : 'config/dev.js');
|
||||
const hasProxy = PROXY_TARGET && PROXY_DOMAIN;
|
||||
|
||||
const mergedConfig = merge(baseConfig, {
|
||||
|
||||
@ -11,11 +11,44 @@ window.config = {
|
||||
strictZSpacingForVolumeViewport: true,
|
||||
// filterQueryParam: false,
|
||||
|
||||
// Add some customizations to the default e2e datasource
|
||||
customizationService: [
|
||||
'@ohif/extension-default.customizationModule.datasources',
|
||||
'@ohif/extension-default.customizationModule.helloPage',
|
||||
],
|
||||
// Allowlist for the `?customization=` URL parameter and for
|
||||
// `customizationService.requires` below. The `default` prefix (no slashes)
|
||||
// resolves a value to `<publicUrl>/customizations/<value>.jsonc`. Files are
|
||||
// fetched as DATA (JSON with comments) and never executed.
|
||||
customizationUrlPrefixes: {
|
||||
default: './customizations/',
|
||||
},
|
||||
|
||||
// Phase-tagged startup customizations. Each block is applied at a fixed point
|
||||
// in the lifecycle so ordering is deterministic regardless of when extensions
|
||||
// and modes load:
|
||||
// - requires: URL customization data files to resolve up front.
|
||||
// - bootstrap: applied (Global) BEFORE extensions register.
|
||||
// - global: applied (Global) AFTER extensions register.
|
||||
// - mode: applied (Mode) on each mode enter — `*` (general) first,
|
||||
// then a block keyed by the mode id / routeName.
|
||||
customizationService: {
|
||||
// Pulls in platform/app/public/customizations/patientBirthDate.jsonc, which
|
||||
// adds a "Birth Date" column to the WorkList study list (global phase).
|
||||
requires: ['patientBirthDate'],
|
||||
|
||||
// The previous (legacy-array) customizations, now in the explicit `global`
|
||||
// phase. A `global` block accepts the same input as setCustomizations: an
|
||||
// array mixing string references and inline object maps.
|
||||
global: [
|
||||
'@ohif/extension-default.customizationModule.datasources',
|
||||
'@ohif/extension-default.customizationModule.helloPage',
|
||||
],
|
||||
|
||||
// Example of mode-scoped customizations (cleared/reapplied per mode):
|
||||
// the `*` block applies to every mode first; a mode-named block (matched
|
||||
// against the mode id or routeName, e.g. 'viewer') applies after it.
|
||||
//
|
||||
// mode: {
|
||||
// '*': { 'someCustomizationId': { $set: 'applies to all modes' } },
|
||||
// viewer: { 'someCustomizationId': { $set: 'overrides for the viewer mode' } },
|
||||
// },
|
||||
},
|
||||
|
||||
defaultDataSourceName: 'e2e',
|
||||
investigationalUseDialog: {
|
||||
|
||||
@ -1,5 +1,21 @@
|
||||
/** @type {AppTypes.Config} */
|
||||
|
||||
// Secure, minimal default configuration.
|
||||
//
|
||||
// This is what a plain production build with no APP_CONFIG produces, so it is
|
||||
// deliberately locked down:
|
||||
// - The local file data source (`dicomlocal`) and the runtime `?url=` sources
|
||||
// (`dicomjson`, `dicomwebproxy`) are NOT enabled — they widen the attack
|
||||
// surface of a default deployment.
|
||||
// - `?customization=` URL loading is OFF: no `customizationUrlPrefixes` are
|
||||
// configured, so any `?customization=` value is rejected (and aborts boot
|
||||
// rather than silently loading).
|
||||
// - `dangerouslyUseDynamicConfig` (the `configUrl` query parameter) is off.
|
||||
//
|
||||
// It does not need to "just work" untouched — point the data source below at
|
||||
// your own DICOMweb server. For a fully-featured setup with every data source
|
||||
// and customization loading enabled, see config/dev.js (local development) and
|
||||
// config/netlify.js (the public demo deploy).
|
||||
window.config = {
|
||||
name: 'config/default.js',
|
||||
routerBasename: null,
|
||||
@ -7,6 +23,21 @@ window.config = {
|
||||
extensions: [],
|
||||
modes: [],
|
||||
customizationService: {},
|
||||
|
||||
// --- URL-driven customizations (?customization=) ----------------------------
|
||||
// OFF by default. To allow loading customization data files from the URL, set
|
||||
// `customizationUrlPrefixes` to a map of allowed prefixes. The `default` prefix
|
||||
// (no slashes) is used for values with no leading slash; every other prefix
|
||||
// must start AND end with a slash and is matched against the leading
|
||||
// `/segment/` of the value. Files are fetched and parsed as JSONC data — they
|
||||
// are never executed. Example (left disabled here on purpose):
|
||||
//
|
||||
// customizationUrlPrefixes: {
|
||||
// default: './customizations/', // ?customization=ctPresets
|
||||
// '/remote/': 'https://cdn.example.com/ohif-custom/', // ?customization=/remote/siteA
|
||||
// },
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
showStudyList: true,
|
||||
// some windows systems have issues with more than 3 web workers
|
||||
maxNumberOfWebWorkers: 3,
|
||||
@ -26,81 +57,13 @@ window.config = {
|
||||
prefetch: 25,
|
||||
},
|
||||
showErrorDetails: 'always', // 'always', 'dev', 'production'
|
||||
// filterQueryParam: false,
|
||||
// Defines multi-monitor layouts
|
||||
multimonitor: [
|
||||
{
|
||||
id: 'split',
|
||||
test: ({ multimonitor }) => multimonitor === 'split',
|
||||
screens: [
|
||||
{
|
||||
id: 'ohif0',
|
||||
screen: null,
|
||||
location: {
|
||||
screen: 0,
|
||||
width: 0.5,
|
||||
height: 1,
|
||||
left: 0,
|
||||
top: 0,
|
||||
},
|
||||
options: 'location=no,menubar=no,scrollbars=no,status=no,titlebar=no',
|
||||
},
|
||||
{
|
||||
id: 'ohif1',
|
||||
screen: null,
|
||||
location: {
|
||||
width: 0.5,
|
||||
height: 1,
|
||||
left: 0.5,
|
||||
top: 0,
|
||||
},
|
||||
options: 'location=no,menubar=no,scrollbars=no,status=no,titlebar=no',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: '2',
|
||||
test: ({ multimonitor }) => multimonitor === '2',
|
||||
screens: [
|
||||
{
|
||||
id: 'ohif0',
|
||||
screen: 0,
|
||||
location: {
|
||||
width: 1,
|
||||
height: 1,
|
||||
left: 0,
|
||||
top: 0,
|
||||
},
|
||||
options: 'fullscreen=yes,location=no,menubar=no,scrollbars=no,status=no,titlebar=no',
|
||||
},
|
||||
{
|
||||
id: 'ohif1',
|
||||
screen: 1,
|
||||
location: {
|
||||
width: 1,
|
||||
height: 1,
|
||||
left: 0,
|
||||
top: 0,
|
||||
},
|
||||
options: 'fullscreen=yes,location=no,menubar=no,scrollbars=no,status=no,titlebar=no',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
// `dangerouslyUseDynamicConfig` (load configuration from a `configUrl` query
|
||||
// parameter) is intentionally left OFF in the secure default build. See
|
||||
// config/dev.js for the documented shape.
|
||||
defaultDataSourceName: 'ohif',
|
||||
/* Dynamic config allows user to pass "configUrl" query string this allows to load config without recompiling application. The regex will ensure valid configuration source */
|
||||
// dangerouslyUseDynamicConfig: {
|
||||
// enabled: true,
|
||||
// // regex will ensure valid configuration source and default is /.*/ which matches any character. To use this, setup your own regex to choose a specific source of configuration only.
|
||||
// // Example 1, to allow numbers and letters in an absolute or sub-path only.
|
||||
// // regex: /(0-9A-Za-z.]+)(\/[0-9A-Za-z.]+)*/
|
||||
// // Example 2, to restricts to either hosptial.com or othersite.com.
|
||||
// // regex: /(https:\/\/hospital.com(\/[0-9A-Za-z.]+)*)|(https:\/\/othersite.com(\/[0-9A-Za-z.]+)*)/
|
||||
// regex: /.*/,
|
||||
// },
|
||||
dataSources: [
|
||||
{
|
||||
// Read-only public demo server. Replace with your own DICOMweb server.
|
||||
namespace: '@ohif/extension-default.dataSourcesModule.dicomweb',
|
||||
sourceName: 'ohif',
|
||||
configuration: {
|
||||
@ -118,9 +81,6 @@ window.config = {
|
||||
supportsWildcard: true,
|
||||
staticWado: true,
|
||||
singlepart: 'bulkdata,video',
|
||||
// whether the data source should use retrieveBulkData to grab metadata,
|
||||
// and in case of relative path, what would it be relative to, options
|
||||
// are in the series level or study level (some servers like series some study)
|
||||
bulkDataURI: {
|
||||
enabled: true,
|
||||
relativeResolution: 'studies',
|
||||
@ -130,157 +90,13 @@ window.config = {
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
namespace: '@ohif/extension-default.dataSourcesModule.dicomweb',
|
||||
sourceName: 'ohif2',
|
||||
configuration: {
|
||||
friendlyName: 'AWS S3 Static wado secondary server',
|
||||
name: 'aws',
|
||||
wadoUriRoot: 'https://dd14fa38qiwhyfd.cloudfront.net/dicomweb',
|
||||
qidoRoot: 'https://dd14fa38qiwhyfd.cloudfront.net/dicomweb',
|
||||
wadoRoot: 'https://dd14fa38qiwhyfd.cloudfront.net/dicomweb',
|
||||
qidoSupportsIncludeField: false,
|
||||
supportsReject: false,
|
||||
imageRendering: 'wadors',
|
||||
thumbnailRendering: 'wadors',
|
||||
enableStudyLazyLoad: true,
|
||||
supportsFuzzyMatching: false,
|
||||
supportsWildcard: true,
|
||||
staticWado: true,
|
||||
singlepart: 'bulkdata,video',
|
||||
// whether the data source should use retrieveBulkData to grab metadata,
|
||||
// and in case of relative path, what would it be relative to, options
|
||||
// are in the series level or study level (some servers like series some study)
|
||||
bulkDataURI: {
|
||||
enabled: true,
|
||||
relativeResolution: 'studies',
|
||||
},
|
||||
omitQuotationForMultipartRequest: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
namespace: '@ohif/extension-default.dataSourcesModule.dicomweb',
|
||||
sourceName: 'ohif3',
|
||||
configuration: {
|
||||
friendlyName: 'AWS S3 Static wado secondary server',
|
||||
name: 'aws',
|
||||
wadoUriRoot: 'https://d3t6nz73ql33tx.cloudfront.net/dicomweb',
|
||||
qidoRoot: 'https://d3t6nz73ql33tx.cloudfront.net/dicomweb',
|
||||
wadoRoot: 'https://d3t6nz73ql33tx.cloudfront.net/dicomweb',
|
||||
qidoSupportsIncludeField: false,
|
||||
supportsReject: false,
|
||||
imageRendering: 'wadors',
|
||||
thumbnailRendering: 'wadors',
|
||||
enableStudyLazyLoad: true,
|
||||
supportsFuzzyMatching: false,
|
||||
supportsWildcard: true,
|
||||
staticWado: true,
|
||||
singlepart: 'bulkdata,video',
|
||||
// whether the data source should use retrieveBulkData to grab metadata,
|
||||
// and in case of relative path, what would it be relative to, options
|
||||
// are in the series level or study level (some servers like series some study)
|
||||
bulkDataURI: {
|
||||
enabled: true,
|
||||
relativeResolution: 'studies',
|
||||
},
|
||||
omitQuotationForMultipartRequest: true,
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
namespace: '@ohif/extension-default.dataSourcesModule.dicomweb',
|
||||
sourceName: 'local5000',
|
||||
configuration: {
|
||||
friendlyName: 'Static WADO Local Data',
|
||||
name: 'DCM4CHEE',
|
||||
qidoRoot: 'http://localhost:5000/dicomweb',
|
||||
wadoRoot: 'http://localhost:5000/dicomweb',
|
||||
qidoSupportsIncludeField: false,
|
||||
supportsReject: true,
|
||||
supportsStow: true,
|
||||
imageRendering: 'wadors',
|
||||
thumbnailRendering: 'wadors',
|
||||
enableStudyLazyLoad: true,
|
||||
supportsFuzzyMatching: false,
|
||||
supportsWildcard: true,
|
||||
staticWado: true,
|
||||
singlepart: 'video',
|
||||
bulkDataURI: {
|
||||
enabled: true,
|
||||
relativeResolution: 'studies',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
namespace: '@ohif/extension-default.dataSourcesModule.dicomweb',
|
||||
sourceName: 'orthanc',
|
||||
configuration: {
|
||||
friendlyName: 'local Orthanc DICOMWeb Server',
|
||||
name: 'DCM4CHEE',
|
||||
wadoUriRoot: 'http://localhost/pacs/dicom-web',
|
||||
qidoRoot: 'http://localhost/pacs/dicom-web',
|
||||
wadoRoot: 'http://localhost/pacs/dicom-web',
|
||||
qidoSupportsIncludeField: true,
|
||||
supportsReject: true,
|
||||
dicomUploadEnabled: true,
|
||||
imageRendering: 'wadors',
|
||||
thumbnailRendering: 'wadors',
|
||||
enableStudyLazyLoad: true,
|
||||
supportsFuzzyMatching: true,
|
||||
supportsWildcard: true,
|
||||
omitQuotationForMultipartRequest: true,
|
||||
bulkDataURI: {
|
||||
enabled: true,
|
||||
// This is an example config that can be used to fix the retrieve URL
|
||||
// where it has the wrong prefix (eg a canned prefix). It is better to
|
||||
// just use the correct prefix out of the box, but that is sometimes hard
|
||||
// when URLs go through several systems.
|
||||
// Example URLS are:
|
||||
// "BulkDataURI" : "http://localhost/dicom-web/studies/1.2.276.0.7230010.3.1.2.2344313775.14992.1458058363.6979/series/1.2.276.0.7230010.3.1.3.1901948703.36080.1484835349.617/instances/1.2.276.0.7230010.3.1.4.1901948703.36080.1484835349.618/bulk/00420011",
|
||||
// when running on http://localhost:3003 with no server running on localhost. This can be corrected to:
|
||||
// /orthanc/dicom-web/studies/1.2.276.0.7230010.3.1.2.2344313775.14992.1458058363.6979/series/1.2.276.0.7230010.3.1.3.1901948703.36080.1484835349.617/instances/1.2.276.0.7230010.3.1.4.1901948703.36080.1484835349.618/bulk/00420011
|
||||
// which is a valid relative URL, and will result in using the http://localhost:3003/orthanc/.... path
|
||||
// startsWith: 'http://localhost/',
|
||||
// prefixWith: '/orthanc/',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
namespace: '@ohif/extension-default.dataSourcesModule.dicomwebproxy',
|
||||
sourceName: 'dicomwebproxy',
|
||||
configuration: {
|
||||
friendlyName: 'dicomweb delegating proxy',
|
||||
name: 'dicomwebproxy',
|
||||
// Security controls for runtime ?url=... datasource loading:
|
||||
// In authenticated environments, runtime ?url origins must be allowlisted:
|
||||
// dangerouslyAllowedOriginsForAuthenticatedEnvironments: [
|
||||
// 'https://config.example.com',
|
||||
// 'http://localhost:5000',
|
||||
// ],
|
||||
},
|
||||
},
|
||||
{
|
||||
namespace: '@ohif/extension-default.dataSourcesModule.dicomjson',
|
||||
sourceName: 'dicomjson',
|
||||
configuration: {
|
||||
friendlyName: 'dicom json',
|
||||
name: 'json',
|
||||
// Security controls for runtime ?url=... datasource loading:
|
||||
// In authenticated environments, runtime ?url origins must be allowlisted:
|
||||
// dangerouslyAllowedOriginsForAuthenticatedEnvironments: [
|
||||
// 'https://config.example.com',
|
||||
// 'http://localhost:5000',
|
||||
// ],
|
||||
},
|
||||
},
|
||||
{
|
||||
namespace: '@ohif/extension-default.dataSourcesModule.dicomlocal',
|
||||
sourceName: 'dicomlocal',
|
||||
configuration: {
|
||||
friendlyName: 'dicom local',
|
||||
},
|
||||
},
|
||||
// The following data sources are intentionally NOT enabled in the secure
|
||||
// default because they broaden the attack surface of a default deployment.
|
||||
// Enable them only in a deployment you control (see config/dev.js):
|
||||
// - dicomlocal: loads DICOM files from the user's machine.
|
||||
// - dicomjson: loads metadata from an arbitrary `?url=` (gate with
|
||||
// `dangerouslyAllowedOriginsForAuthenticatedEnvironments`).
|
||||
// - dicomwebproxy: delegating proxy driven by `?url=`.
|
||||
],
|
||||
httpErrorHandler: error => {
|
||||
// This is 429 when rejected from the public idc sandbox too often.
|
||||
@ -289,29 +105,4 @@ window.config = {
|
||||
// Could use services manager here to bring up a dialog/modal if needed.
|
||||
console.warn('test, navigate to https://ohif.org/');
|
||||
},
|
||||
// segmentation: {
|
||||
// segmentLabel: {
|
||||
// enabledByDefault: true,
|
||||
// labelColor: [255, 255, 0, 1], // must be an array
|
||||
// hoverTimeout: 1,
|
||||
// background: 'rgba(100, 100, 100, 0.5)', // can be any valid css color
|
||||
// },
|
||||
// },
|
||||
// whiteLabeling: {
|
||||
// createLogoComponentFn: function (React) {
|
||||
// return React.createElement(
|
||||
// 'a',
|
||||
// {
|
||||
// target: '_self',
|
||||
// rel: 'noopener noreferrer',
|
||||
// className: 'text-purple-600 line-through',
|
||||
// href: '_X___IDC__LOGO__LINK___Y_',
|
||||
// },
|
||||
// React.createElement('img', {
|
||||
// src: './Logo.svg',
|
||||
// className: 'w-14 h-14',
|
||||
// })
|
||||
// );
|
||||
// },
|
||||
// },
|
||||
};
|
||||
|
||||
272
platform/app/public/config/dev.js
Normal file
272
platform/app/public/config/dev.js
Normal file
@ -0,0 +1,272 @@
|
||||
/** @type {AppTypes.Config} */
|
||||
|
||||
// Local development configuration.
|
||||
//
|
||||
// This is the default config for the dev server (`pnpm run dev`, `dev:fast`,
|
||||
// `start`). Like config/netlify.js it is full-featured — every data source is
|
||||
// enabled and the `?customization=` URL feature is ON via
|
||||
// `customizationUrlPrefixes` — so the whole app can be exercised locally. The
|
||||
// locked-down config/default.js is what a plain production build emits instead.
|
||||
window.config = {
|
||||
name: 'config/dev.js',
|
||||
routerBasename: null,
|
||||
// whiteLabeling: {},
|
||||
extensions: [],
|
||||
modes: [],
|
||||
customizationService: {},
|
||||
|
||||
// URL-driven customizations (?customization=). The `default` prefix (no
|
||||
// slashes) is used for values without a leading slash; every other prefix
|
||||
// must start AND end with a slash and matches the leading `/segment/` of the
|
||||
// value. Files are fetched and parsed as JSONC data — never executed.
|
||||
// e.g. ?customization=ctPresets -> ./customizations/ctPresets.jsonc
|
||||
customizationUrlPrefixes: {
|
||||
default: './customizations/',
|
||||
},
|
||||
|
||||
showStudyList: true,
|
||||
// some windows systems have issues with more than 3 web workers
|
||||
maxNumberOfWebWorkers: 3,
|
||||
// below flag is for performance reasons, but it might not work for all servers
|
||||
showWarningMessageForCrossOrigin: true,
|
||||
showCPUFallbackMessage: true,
|
||||
showLoadingIndicator: true,
|
||||
experimentalStudyBrowserSort: false,
|
||||
strictZSpacingForVolumeViewport: true,
|
||||
groupEnabledModesFirst: true,
|
||||
allowMultiSelectExport: false,
|
||||
maxNumRequests: {
|
||||
interaction: 100,
|
||||
thumbnail: 5,
|
||||
// Prefetch number is dependent on the http protocol. For http 2 or
|
||||
// above, the number of requests can be go a lot higher.
|
||||
prefetch: 25,
|
||||
},
|
||||
showErrorDetails: 'always', // 'always', 'dev', 'production'
|
||||
// filterQueryParam: false,
|
||||
// Defines multi-monitor layouts
|
||||
multimonitor: [
|
||||
{
|
||||
id: 'split',
|
||||
test: ({ multimonitor }) => multimonitor === 'split',
|
||||
screens: [
|
||||
{
|
||||
id: 'ohif0',
|
||||
screen: null,
|
||||
location: {
|
||||
screen: 0,
|
||||
width: 0.5,
|
||||
height: 1,
|
||||
left: 0,
|
||||
top: 0,
|
||||
},
|
||||
options: 'location=no,menubar=no,scrollbars=no,status=no,titlebar=no',
|
||||
},
|
||||
{
|
||||
id: 'ohif1',
|
||||
screen: null,
|
||||
location: {
|
||||
width: 0.5,
|
||||
height: 1,
|
||||
left: 0.5,
|
||||
top: 0,
|
||||
},
|
||||
options: 'location=no,menubar=no,scrollbars=no,status=no,titlebar=no',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: '2',
|
||||
test: ({ multimonitor }) => multimonitor === '2',
|
||||
screens: [
|
||||
{
|
||||
id: 'ohif0',
|
||||
screen: 0,
|
||||
location: {
|
||||
width: 1,
|
||||
height: 1,
|
||||
left: 0,
|
||||
top: 0,
|
||||
},
|
||||
options: 'fullscreen=yes,location=no,menubar=no,scrollbars=no,status=no,titlebar=no',
|
||||
},
|
||||
{
|
||||
id: 'ohif1',
|
||||
screen: 1,
|
||||
location: {
|
||||
width: 1,
|
||||
height: 1,
|
||||
left: 0,
|
||||
top: 0,
|
||||
},
|
||||
options: 'fullscreen=yes,location=no,menubar=no,scrollbars=no,status=no,titlebar=no',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
defaultDataSourceName: 'ohif',
|
||||
/* Dynamic config allows user to pass "configUrl" query string this allows to load config without recompiling application. The regex will ensure valid configuration source */
|
||||
// dangerouslyUseDynamicConfig: {
|
||||
// enabled: true,
|
||||
// regex: /.*/,
|
||||
// },
|
||||
dataSources: [
|
||||
{
|
||||
namespace: '@ohif/extension-default.dataSourcesModule.dicomweb',
|
||||
sourceName: 'ohif',
|
||||
configuration: {
|
||||
friendlyName: 'AWS S3 Static wado server',
|
||||
name: 'aws',
|
||||
wadoUriRoot: 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb',
|
||||
qidoRoot: 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb',
|
||||
wadoRoot: 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb',
|
||||
qidoSupportsIncludeField: false,
|
||||
imageRendering: 'wadors',
|
||||
thumbnailRendering: 'thumbnail',
|
||||
thumbnailRequestStrategy: 'fetch',
|
||||
enableStudyLazyLoad: true,
|
||||
supportsFuzzyMatching: true,
|
||||
supportsWildcard: true,
|
||||
staticWado: true,
|
||||
singlepart: 'bulkdata,video',
|
||||
bulkDataURI: {
|
||||
enabled: true,
|
||||
relativeResolution: 'studies',
|
||||
transform: url => url.replace('/pixeldata.mp4', '/rendered'),
|
||||
},
|
||||
omitQuotationForMultipartRequest: true,
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
namespace: '@ohif/extension-default.dataSourcesModule.dicomweb',
|
||||
sourceName: 'ohif2',
|
||||
configuration: {
|
||||
friendlyName: 'AWS S3 Static wado secondary server',
|
||||
name: 'aws',
|
||||
wadoUriRoot: 'https://dd14fa38qiwhyfd.cloudfront.net/dicomweb',
|
||||
qidoRoot: 'https://dd14fa38qiwhyfd.cloudfront.net/dicomweb',
|
||||
wadoRoot: 'https://dd14fa38qiwhyfd.cloudfront.net/dicomweb',
|
||||
qidoSupportsIncludeField: false,
|
||||
supportsReject: false,
|
||||
imageRendering: 'wadors',
|
||||
thumbnailRendering: 'wadors',
|
||||
enableStudyLazyLoad: true,
|
||||
supportsFuzzyMatching: false,
|
||||
supportsWildcard: true,
|
||||
staticWado: true,
|
||||
singlepart: 'bulkdata,video',
|
||||
bulkDataURI: {
|
||||
enabled: true,
|
||||
relativeResolution: 'studies',
|
||||
},
|
||||
omitQuotationForMultipartRequest: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
namespace: '@ohif/extension-default.dataSourcesModule.dicomweb',
|
||||
sourceName: 'ohif3',
|
||||
configuration: {
|
||||
friendlyName: 'AWS S3 Static wado secondary server',
|
||||
name: 'aws',
|
||||
wadoUriRoot: 'https://d3t6nz73ql33tx.cloudfront.net/dicomweb',
|
||||
qidoRoot: 'https://d3t6nz73ql33tx.cloudfront.net/dicomweb',
|
||||
wadoRoot: 'https://d3t6nz73ql33tx.cloudfront.net/dicomweb',
|
||||
qidoSupportsIncludeField: false,
|
||||
supportsReject: false,
|
||||
imageRendering: 'wadors',
|
||||
thumbnailRendering: 'wadors',
|
||||
enableStudyLazyLoad: true,
|
||||
supportsFuzzyMatching: false,
|
||||
supportsWildcard: true,
|
||||
staticWado: true,
|
||||
singlepart: 'bulkdata,video',
|
||||
bulkDataURI: {
|
||||
enabled: true,
|
||||
relativeResolution: 'studies',
|
||||
},
|
||||
omitQuotationForMultipartRequest: true,
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
namespace: '@ohif/extension-default.dataSourcesModule.dicomweb',
|
||||
sourceName: 'local5000',
|
||||
configuration: {
|
||||
friendlyName: 'Static WADO Local Data',
|
||||
name: 'DCM4CHEE',
|
||||
qidoRoot: 'http://localhost:5000/dicomweb',
|
||||
wadoRoot: 'http://localhost:5000/dicomweb',
|
||||
qidoSupportsIncludeField: false,
|
||||
supportsReject: true,
|
||||
supportsStow: true,
|
||||
imageRendering: 'wadors',
|
||||
thumbnailRendering: 'wadors',
|
||||
enableStudyLazyLoad: true,
|
||||
supportsFuzzyMatching: false,
|
||||
supportsWildcard: true,
|
||||
staticWado: true,
|
||||
singlepart: 'video',
|
||||
bulkDataURI: {
|
||||
enabled: true,
|
||||
relativeResolution: 'studies',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
namespace: '@ohif/extension-default.dataSourcesModule.dicomweb',
|
||||
sourceName: 'orthanc',
|
||||
configuration: {
|
||||
friendlyName: 'local Orthanc DICOMWeb Server',
|
||||
name: 'DCM4CHEE',
|
||||
wadoUriRoot: 'http://localhost/pacs/dicom-web',
|
||||
qidoRoot: 'http://localhost/pacs/dicom-web',
|
||||
wadoRoot: 'http://localhost/pacs/dicom-web',
|
||||
qidoSupportsIncludeField: true,
|
||||
supportsReject: true,
|
||||
dicomUploadEnabled: true,
|
||||
imageRendering: 'wadors',
|
||||
thumbnailRendering: 'wadors',
|
||||
enableStudyLazyLoad: true,
|
||||
supportsFuzzyMatching: true,
|
||||
supportsWildcard: true,
|
||||
omitQuotationForMultipartRequest: true,
|
||||
bulkDataURI: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
namespace: '@ohif/extension-default.dataSourcesModule.dicomwebproxy',
|
||||
sourceName: 'dicomwebproxy',
|
||||
configuration: {
|
||||
friendlyName: 'dicomweb delegating proxy',
|
||||
name: 'dicomwebproxy',
|
||||
},
|
||||
},
|
||||
{
|
||||
namespace: '@ohif/extension-default.dataSourcesModule.dicomjson',
|
||||
sourceName: 'dicomjson',
|
||||
configuration: {
|
||||
friendlyName: 'dicom json',
|
||||
name: 'json',
|
||||
},
|
||||
},
|
||||
{
|
||||
namespace: '@ohif/extension-default.dataSourcesModule.dicomlocal',
|
||||
sourceName: 'dicomlocal',
|
||||
configuration: {
|
||||
friendlyName: 'dicom local',
|
||||
},
|
||||
},
|
||||
],
|
||||
httpErrorHandler: error => {
|
||||
// This is 429 when rejected from the public idc sandbox too often.
|
||||
console.warn(error.status);
|
||||
|
||||
// Could use services manager here to bring up a dialog/modal if needed.
|
||||
console.warn('test, navigate to https://ohif.org/');
|
||||
},
|
||||
};
|
||||
@ -20,6 +20,11 @@ window.config = {
|
||||
routerBasename: null,
|
||||
extensions: [],
|
||||
modes: ['@ohif/mode-test'],
|
||||
// Allow the `default` prefix so e2e specs can exercise `?customization=` (e.g.
|
||||
// Customization.spec.ts loads `?customization=veterinaryOverlay`).
|
||||
customizationUrlPrefixes: {
|
||||
default: './customizations/',
|
||||
},
|
||||
showStudyList: true,
|
||||
// below flag is for performance reasons, but it might not work for all servers
|
||||
maxNumberOfWebWorkers: 3,
|
||||
|
||||
@ -1,13 +1,30 @@
|
||||
/** @type {AppTypes.Config} */
|
||||
|
||||
// Public demo / Netlify deploy configuration.
|
||||
//
|
||||
// This is the full-featured config used for the Netlify deploy (build:viewer:ci).
|
||||
// Unlike the locked-down config/default.js, it enables every data source and
|
||||
// turns the `?customization=` URL feature ON via `customizationUrlPrefixes`.
|
||||
window.config = {
|
||||
name: 'config/netlify.js',
|
||||
routerBasename: null,
|
||||
customizationService: [
|
||||
'@ohif/extension-default.customizationModule.theme',
|
||||
],
|
||||
// whiteLabeling: {},
|
||||
extensions: [],
|
||||
modes: [],
|
||||
customizationService: ['@ohif/extension-default.customizationModule.theme'],
|
||||
|
||||
// URL-driven customizations (?customization=). The `default` prefix (no
|
||||
// slashes) is used for values without a leading slash; every other prefix
|
||||
// must start AND end with a slash and matches the leading `/segment/` of the
|
||||
// value. Files are fetched and parsed as JSONC data — never executed.
|
||||
// e.g. ?customization=ctPresets -> ./customizations/ctPresets.jsonc
|
||||
customizationUrlPrefixes: {
|
||||
default: './customizations/',
|
||||
},
|
||||
|
||||
showStudyList: true,
|
||||
// some windows systems have issues with more than 3 web workers
|
||||
maxNumberOfWebWorkers: 3,
|
||||
// below flag is for performance reasons, but it might not work for all servers
|
||||
showWarningMessageForCrossOrigin: true,
|
||||
showCPUFallbackMessage: true,
|
||||
@ -15,8 +32,83 @@ window.config = {
|
||||
experimentalStudyBrowserSort: false,
|
||||
strictZSpacingForVolumeViewport: true,
|
||||
groupEnabledModesFirst: true,
|
||||
allowMultiSelectExport: false,
|
||||
maxNumRequests: {
|
||||
interaction: 100,
|
||||
thumbnail: 5,
|
||||
// Prefetch number is dependent on the http protocol. For http 2 or
|
||||
// above, the number of requests can be go a lot higher.
|
||||
prefetch: 25,
|
||||
},
|
||||
showErrorDetails: 'always', // 'always', 'dev', 'production'
|
||||
// filterQueryParam: false,
|
||||
// Defines multi-monitor layouts
|
||||
multimonitor: [
|
||||
{
|
||||
id: 'split',
|
||||
test: ({ multimonitor }) => multimonitor === 'split',
|
||||
screens: [
|
||||
{
|
||||
id: 'ohif0',
|
||||
screen: null,
|
||||
location: {
|
||||
screen: 0,
|
||||
width: 0.5,
|
||||
height: 1,
|
||||
left: 0,
|
||||
top: 0,
|
||||
},
|
||||
options: 'location=no,menubar=no,scrollbars=no,status=no,titlebar=no',
|
||||
},
|
||||
{
|
||||
id: 'ohif1',
|
||||
screen: null,
|
||||
location: {
|
||||
width: 0.5,
|
||||
height: 1,
|
||||
left: 0.5,
|
||||
top: 0,
|
||||
},
|
||||
options: 'location=no,menubar=no,scrollbars=no,status=no,titlebar=no',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: '2',
|
||||
test: ({ multimonitor }) => multimonitor === '2',
|
||||
screens: [
|
||||
{
|
||||
id: 'ohif0',
|
||||
screen: 0,
|
||||
location: {
|
||||
width: 1,
|
||||
height: 1,
|
||||
left: 0,
|
||||
top: 0,
|
||||
},
|
||||
options: 'fullscreen=yes,location=no,menubar=no,scrollbars=no,status=no,titlebar=no',
|
||||
},
|
||||
{
|
||||
id: 'ohif1',
|
||||
screen: 1,
|
||||
location: {
|
||||
width: 1,
|
||||
height: 1,
|
||||
left: 0,
|
||||
top: 0,
|
||||
},
|
||||
options: 'fullscreen=yes,location=no,menubar=no,scrollbars=no,status=no,titlebar=no',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
defaultDataSourceName: 'ohif',
|
||||
/* Dynamic config allows user to pass "configUrl" query string this allows to load config without recompiling application. The regex will ensure valid configuration source */
|
||||
// dangerouslyUseDynamicConfig: {
|
||||
// enabled: true,
|
||||
// regex: /.*/,
|
||||
// },
|
||||
dataSources: [
|
||||
{
|
||||
namespace: '@ohif/extension-default.dataSourcesModule.dicomweb',
|
||||
@ -32,13 +124,10 @@ window.config = {
|
||||
thumbnailRendering: 'thumbnail',
|
||||
thumbnailRequestStrategy: 'fetch',
|
||||
enableStudyLazyLoad: true,
|
||||
supportsFuzzyMatching: false,
|
||||
supportsFuzzyMatching: true,
|
||||
supportsWildcard: true,
|
||||
staticWado: true,
|
||||
singlepart: 'bulkdata,video',
|
||||
// whether the data source should use retrieveBulkData to grab metadata,
|
||||
// and in case of relative path, what would it be relative to, options
|
||||
// are in the series level or study level (some servers like series some study)
|
||||
bulkDataURI: {
|
||||
enabled: true,
|
||||
relativeResolution: 'studies',
|
||||
@ -66,9 +155,6 @@ window.config = {
|
||||
supportsWildcard: true,
|
||||
staticWado: true,
|
||||
singlepart: 'bulkdata,video',
|
||||
// whether the data source should use retrieveBulkData to grab metadata,
|
||||
// and in case of relative path, what would it be relative to, options
|
||||
// are in the series level or study level (some servers like series some study)
|
||||
bulkDataURI: {
|
||||
enabled: true,
|
||||
relativeResolution: 'studies',
|
||||
@ -76,7 +162,6 @@ window.config = {
|
||||
omitQuotationForMultipartRequest: true,
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
namespace: '@ohif/extension-default.dataSourcesModule.dicomweb',
|
||||
sourceName: 'ohif3',
|
||||
@ -95,9 +180,6 @@ window.config = {
|
||||
supportsWildcard: true,
|
||||
staticWado: true,
|
||||
singlepart: 'bulkdata,video',
|
||||
// whether the data source should use retrieveBulkData to grab metadata,
|
||||
// and in case of relative path, what would it be relative to, options
|
||||
// are in the series level or study level (some servers like series some study)
|
||||
bulkDataURI: {
|
||||
enabled: true,
|
||||
relativeResolution: 'studies',
|
||||
@ -130,6 +212,29 @@ window.config = {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
namespace: '@ohif/extension-default.dataSourcesModule.dicomweb',
|
||||
sourceName: 'orthanc',
|
||||
configuration: {
|
||||
friendlyName: 'local Orthanc DICOMWeb Server',
|
||||
name: 'DCM4CHEE',
|
||||
wadoUriRoot: 'http://localhost/pacs/dicom-web',
|
||||
qidoRoot: 'http://localhost/pacs/dicom-web',
|
||||
wadoRoot: 'http://localhost/pacs/dicom-web',
|
||||
qidoSupportsIncludeField: true,
|
||||
supportsReject: true,
|
||||
dicomUploadEnabled: true,
|
||||
imageRendering: 'wadors',
|
||||
thumbnailRendering: 'wadors',
|
||||
enableStudyLazyLoad: true,
|
||||
supportsFuzzyMatching: true,
|
||||
supportsWildcard: true,
|
||||
omitQuotationForMultipartRequest: true,
|
||||
bulkDataURI: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
namespace: '@ohif/extension-default.dataSourcesModule.dicomwebproxy',
|
||||
@ -163,20 +268,3 @@ window.config = {
|
||||
console.warn('test, navigate to https://ohif.org/');
|
||||
},
|
||||
};
|
||||
|
||||
function waitForElement(selector, maxAttempts = 20, interval = 25) {
|
||||
return new Promise(resolve => {
|
||||
let attempts = 0;
|
||||
|
||||
const checkForElement = setInterval(() => {
|
||||
const element = document.querySelector(selector);
|
||||
|
||||
if (element || attempts >= maxAttempts) {
|
||||
clearInterval(checkForElement);
|
||||
resolve();
|
||||
}
|
||||
|
||||
attempts++;
|
||||
}, interval);
|
||||
});
|
||||
}
|
||||
|
||||
21
platform/app/public/customizations/ctPresets.jsonc
Normal file
21
platform/app/public/customizations/ctPresets.jsonc
Normal file
@ -0,0 +1,21 @@
|
||||
// Example URL-loaded customization: ctPresets
|
||||
//
|
||||
// Replaces the CT window/level presets offered in the window-level menu with a
|
||||
// site-specific set (key: `cornerstone.windowLevelPresets`). Other modalities
|
||||
// keep their defaults because only the `CT` entry is overridden.
|
||||
//
|
||||
// Load it with `?customization=ctPresets`.
|
||||
{
|
||||
"global": {
|
||||
"cornerstone.windowLevelPresets": {
|
||||
"$merge": {
|
||||
"CT": [
|
||||
{ "id": "ct-soft-tissue", "description": "Soft tissue", "window": "400", "level": "40" },
|
||||
{ "id": "ct-lung", "description": "Lung", "window": "1500", "level": "-600" },
|
||||
{ "id": "ct-angio", "description": "Angio", "window": "600", "level": "300" },
|
||||
{ "id": "ct-bone", "description": "Bone", "window": "2500", "level": "480" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
23
platform/app/public/customizations/measurementLabels.jsonc
Normal file
23
platform/app/public/customizations/measurementLabels.jsonc
Normal file
@ -0,0 +1,23 @@
|
||||
// Example URL-loaded customization: measurementLabels
|
||||
//
|
||||
// Makes the viewer prompt for a label from a fixed list whenever a measurement
|
||||
// is created (key: `measurementLabels`). `labelOnMeasure` triggers the prompt on
|
||||
// creation and `exclusive` restricts entries to the provided items.
|
||||
//
|
||||
// Load it with `?customization=measurementLabels`.
|
||||
{
|
||||
"global": {
|
||||
"measurementLabels": {
|
||||
"$set": {
|
||||
"labelOnMeasure": true,
|
||||
"exclusive": true,
|
||||
"items": [
|
||||
{ "value": "Head", "label": "Head" },
|
||||
{ "value": "Shoulder", "label": "Shoulder" },
|
||||
{ "value": "Knee", "label": "Knee" },
|
||||
{ "value": "Toe", "label": "Toe" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
34
platform/app/public/customizations/patientBirthDate.jsonc
Normal file
34
platform/app/public/customizations/patientBirthDate.jsonc
Normal file
@ -0,0 +1,34 @@
|
||||
// Example URL-loaded customization: patientBirthDate
|
||||
//
|
||||
// Adds a "Birth Date" column to the WorkList study list, right after the
|
||||
// Patient column. Because a URL customization file is DATA (never executed), it
|
||||
// cannot carry a render function — it inserts a plain, serializable column spec
|
||||
// (`id` + `meta`) and WorkList expands it into a display-only text column that
|
||||
// reads `row.patientBirthDate` (surfaced by the DICOMweb data source from DICOM
|
||||
// tag 00100030).
|
||||
//
|
||||
// This applies in the `global` phase, so it is in place before the WorkList
|
||||
// renders. Load it with `?customization=patientBirthDate`, or pull it in from
|
||||
// `appConfig.customizationService.requires`.
|
||||
{
|
||||
"global": {
|
||||
"workList.columns": {
|
||||
// Insert at index 1 (immediately after the Patient column). Using a
|
||||
// fixed index keeps the trailing `actions` column last.
|
||||
"$splice": [
|
||||
[
|
||||
1,
|
||||
0,
|
||||
{
|
||||
"id": "patientBirthDate",
|
||||
"meta": {
|
||||
"label": "Birth Date",
|
||||
"minWidth": 130,
|
||||
"priority": 40
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
42
platform/app/public/customizations/smoothRotate.jsonc
Normal file
42
platform/app/public/customizations/smoothRotate.jsonc
Normal file
@ -0,0 +1,42 @@
|
||||
// Example URL-loaded customization: smoothRotate
|
||||
//
|
||||
// Adds a "Smooth Rotate" tool button to the More Tools menu of the basic and
|
||||
// longitudinal viewers. It activates the cornerstone `PlanarRotate` tool, which
|
||||
// rotates the image freely as you drag (unlike the fixed 90° "Rotate Right").
|
||||
//
|
||||
// It extends the default toolbar customizations registered by the cornerstone
|
||||
// extension (`cornerstone.toolbarButtons` / `cornerstone.toolbarSections`) using
|
||||
// immutability-helper `$push` commands, so the built-in buttons are preserved.
|
||||
//
|
||||
// Load it with `?customization=smoothRotate`.
|
||||
{
|
||||
"global": {
|
||||
"cornerstone.toolbarButtons": {
|
||||
"$push": [
|
||||
{
|
||||
"id": "SmoothRotate",
|
||||
"uiType": "ohif.toolButton",
|
||||
"props": {
|
||||
"type": "tool",
|
||||
// Re-use the existing rotate icon.
|
||||
"icon": "tool-rotate-right",
|
||||
"label": "Smooth Rotate",
|
||||
"tooltip": "Smooth Rotate (drag to rotate the image freely)",
|
||||
"commands": {
|
||||
"commandName": "setToolActiveToolbar",
|
||||
"commandOptions": {
|
||||
"toolName": "PlanarRotate"
|
||||
}
|
||||
},
|
||||
"evaluate": "evaluate.cornerstoneTool"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"cornerstone.toolbarSections": {
|
||||
"MoreTools": {
|
||||
"$push": ["SmoothRotate"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
5
platform/app/public/customizations/veterinary.jsonc
Normal file
5
platform/app/public/customizations/veterinary.jsonc
Normal file
@ -0,0 +1,5 @@
|
||||
// Example chaining module: ensures `veterinaryOverlay` is loaded and applied
|
||||
// first when using `?customization=veterinary` alone.
|
||||
{
|
||||
"requires": ["veterinaryOverlay"]
|
||||
}
|
||||
56
platform/app/public/customizations/veterinaryOverlay.jsonc
Normal file
56
platform/app/public/customizations/veterinaryOverlay.jsonc
Normal file
@ -0,0 +1,56 @@
|
||||
// Example URL-loaded customization: veterinaryOverlay
|
||||
//
|
||||
// Demonstrates a runtime-loaded customization that overrides the default
|
||||
// viewport overlay with a veterinary-style demographics layout. Loaded via
|
||||
// `?customization=veterinaryOverlay` (see CustomizationService URL handling).
|
||||
//
|
||||
// Uses the same `global`-at-top-level shape as the cornerstone overlay samples
|
||||
// and `inheritsFrom: 'ohif.overlayItem'` on each row, matching
|
||||
// extensions/cornerstone/.../viewportOverlayCustomization.tsx.
|
||||
{
|
||||
"global": {
|
||||
"viewportOverlay.topLeft": {
|
||||
"$set": [
|
||||
{
|
||||
"id": "PatientName",
|
||||
"inheritsFrom": "ohif.overlayItem",
|
||||
"attribute": "PatientName",
|
||||
"label": "Patient",
|
||||
"title": "Patient name"
|
||||
},
|
||||
{
|
||||
"id": "PatientID",
|
||||
"inheritsFrom": "ohif.overlayItem",
|
||||
"attribute": "PatientID",
|
||||
"label": "ID",
|
||||
"title": "Patient ID"
|
||||
},
|
||||
{
|
||||
"id": "StudyDate",
|
||||
"inheritsFrom": "ohif.overlayItem",
|
||||
"attribute": "StudyDate",
|
||||
"label": "Date",
|
||||
"title": "Study date"
|
||||
}
|
||||
]
|
||||
},
|
||||
"viewportOverlay.topRight": {
|
||||
"$set": [
|
||||
{
|
||||
"id": "PatientSpecies",
|
||||
"inheritsFrom": "ohif.overlayItem",
|
||||
"attribute": "PatientSpecies",
|
||||
"label": "Species",
|
||||
"title": "Patient species"
|
||||
},
|
||||
{
|
||||
"id": "PatientBreed",
|
||||
"inheritsFrom": "ohif.overlayItem",
|
||||
"attribute": "PatientBreed",
|
||||
"label": "Breed",
|
||||
"title": "Patient breed"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -110,7 +110,6 @@ function App({
|
||||
cineService,
|
||||
userAuthenticationService,
|
||||
uiNotificationService,
|
||||
customizationService,
|
||||
} = servicesManager.services;
|
||||
|
||||
const providers = [
|
||||
@ -142,8 +141,8 @@ function App({
|
||||
|
||||
let authRoutes = null;
|
||||
|
||||
// Should there be a generic call to init on the extension manager?
|
||||
customizationService.init(extensionManager);
|
||||
// customizationService.init(extensionManager) runs in appInit after extensions register;
|
||||
// do not call init again here — repeated init would duplicate-merge unless guarded (see CustomizationService.init).
|
||||
|
||||
// Use config to create routes
|
||||
const appRoutes = createRoutes({
|
||||
|
||||
@ -28,7 +28,7 @@ import loadModules, { loadModule as peerImport } from './pluginImports';
|
||||
import { publicUrl } from './utils/publicUrl';
|
||||
|
||||
/**
|
||||
* @param {object|func} appConfigOrFunc - application configuration, or a function that returns application configuration
|
||||
* @param {object|function} appConfigOrFunc - application configuration, or a function that returns application configuration
|
||||
* @param {object[]} defaultExtensions - array of extension objects
|
||||
*/
|
||||
async function appInit(appConfigOrFunc, defaultExtensions, defaultModes) {
|
||||
@ -91,8 +91,25 @@ async function appInit(appConfigOrFunc, defaultExtensions, defaultModes) {
|
||||
* Example2: [[ext1, config], ext2, [ext3, config]]
|
||||
*/
|
||||
const loadedExtensions = await loadModules([...defaultExtensions, ...appConfig.extensions]);
|
||||
|
||||
const { customizationService } = servicesManager.services;
|
||||
// Resolve every customization module up front — from
|
||||
// `appConfig.customizationService.requires` and the `?customization=` URL
|
||||
// parameter — long before any mode loads, then apply the `bootstrap` phase
|
||||
// BEFORE extensions register so it is in place while they initialize. Modules
|
||||
// are only loaded when `appConfig.customizationUrlPrefixes` allows their
|
||||
// prefix; the feature is off by default, and a value with an unconfigured
|
||||
// prefix throws here (aborting startup) rather than being silently ignored.
|
||||
await customizationService.loadAndApplyBootstrapCustomizations(extensionManager);
|
||||
|
||||
await extensionManager.registerExtensions(loadedExtensions, appConfig.dataSources);
|
||||
|
||||
// Merge extension default/global modules, then layer on the `global` phase of
|
||||
// the structured config + the URL modules resolved above (so `$apply`-style
|
||||
// overrides see the extension-provided defaults they build on).
|
||||
customizationService.init(extensionManager);
|
||||
customizationService.applyGlobalCustomizations();
|
||||
|
||||
// TODO: We no longer use `utils.addServer`
|
||||
// TODO: We no longer init webWorkers at app level
|
||||
// TODO: We no longer init the user Manager
|
||||
|
||||
@ -175,7 +175,9 @@ function parseFiltersFromURL(params: URLSearchParams): ColumnFiltersState {
|
||||
* Build URL query string from study list state preserving key query parameters.
|
||||
*/
|
||||
function buildQueryFromState(state: StudyListState): string {
|
||||
const query: Record<string, string> = {};
|
||||
// preserveQueryStrings may write repeated values as arrays, so the value type
|
||||
// must allow string[] in addition to string.
|
||||
const query: Record<string, string | string[]> = {};
|
||||
|
||||
// Sorting
|
||||
if (state.sorting.length > 0) {
|
||||
|
||||
@ -221,6 +221,12 @@ export default function ModeRoute({
|
||||
appConfig,
|
||||
});
|
||||
|
||||
// `extensionManager.onModeEnter` resets the customization mode scope via
|
||||
// `customizationService.onModeEnter`; now layer on the `mode` phase blocks
|
||||
// for this mode — the general (`*`) block first, then any block keyed by
|
||||
// this mode's id / routeName so a single mode can override the general one.
|
||||
customizationService.applyModeCustomizations([mode.id, mode.routeName]);
|
||||
|
||||
// use the URL hangingProtocolId if it exists, otherwise use the one
|
||||
// defined in the mode configuration
|
||||
const hangingProtocolIdToUse = hangingProtocolService.getProtocolById(
|
||||
|
||||
@ -51,7 +51,16 @@ export default function WorkList({
|
||||
// `workList.columns` is registered as a value (StudyList.defaultColumns) and
|
||||
// merged via customization commands, so we read the result directly.
|
||||
const customized = customizationService.getCustomization('workList.columns');
|
||||
return Array.isArray(customized) ? customized : StudyList.defaultColumns;
|
||||
const resolved = Array.isArray(customized) ? customized : StudyList.defaultColumns;
|
||||
// Expand data-only column specs. A `?customization=` JSONC file (or any
|
||||
// serializable source) cannot carry render functions, so an entry that has
|
||||
// an `id` but no `accessorFn`/`cell` is turned into a display-only text
|
||||
// column that reads `row[id]` — matching `StudyList.textColumn`.
|
||||
return resolved.map((col: any) =>
|
||||
col && typeof col === 'object' && col.id && !col.accessorFn && !col.cell
|
||||
? StudyList.textColumn(col.id, col.meta?.label ?? col.id, col.meta)
|
||||
: col
|
||||
);
|
||||
}, [customizationService]);
|
||||
|
||||
const logoComponent = appConfig?.whiteLabeling?.createLogoComponentFn?.(React) ?? (
|
||||
|
||||
@ -14,7 +14,7 @@ workbox.core.clientsClaim();
|
||||
|
||||
// Cache static assets that aren't precached
|
||||
workbox.routing.registerRoute(
|
||||
/\.(?:js|css|json5)$/,
|
||||
/\.(?:js|css|json5|jsonc)$/,
|
||||
new workbox.strategies.StaleWhileRevalidate({
|
||||
cacheName: 'static-resources',
|
||||
})
|
||||
|
||||
102
platform/app/src/utils/preserveQueryParameters.test.ts
Normal file
102
platform/app/src/utils/preserveQueryParameters.test.ts
Normal file
@ -0,0 +1,102 @@
|
||||
// query-string is the stringifier used by the worklist navigation callers
|
||||
// (useStudyListStateSync, LegacyWorkList); test against it, not the `qs` library.
|
||||
import qs from 'query-string';
|
||||
|
||||
import { preserveQueryParameters, preserveQueryStrings } from './preserveQueryParameters';
|
||||
|
||||
describe('preserveQueryParameters', () => {
|
||||
it('preserves base keys as query arrays', () => {
|
||||
const current = new URLSearchParams();
|
||||
current.append('configUrl', 'foo.js');
|
||||
const out = new URLSearchParams();
|
||||
preserveQueryParameters(out, undefined, current);
|
||||
expect(out.getAll('configUrl')).toEqual(['foo.js']);
|
||||
});
|
||||
|
||||
it('preserves all repeated values for the customization key', () => {
|
||||
const current = new URLSearchParams();
|
||||
current.append('customization', 'a');
|
||||
current.append('customization', 'b');
|
||||
const out = new URLSearchParams();
|
||||
preserveQueryParameters(out, undefined, current);
|
||||
expect(out.getAll('customization')).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('does not preserve unrelated keys', () => {
|
||||
const current = new URLSearchParams();
|
||||
current.append('foo', 'bar');
|
||||
const out = new URLSearchParams();
|
||||
preserveQueryParameters(out, undefined, current);
|
||||
expect(out.get('foo')).toBeNull();
|
||||
});
|
||||
|
||||
it('uses customization service values for multi-key preservation', () => {
|
||||
const customizationService = {
|
||||
getValue: jest.fn().mockReturnValue(['customization', 'customizationAlt']),
|
||||
};
|
||||
const current = new URLSearchParams();
|
||||
current.append('customizationAlt', 'c');
|
||||
const out = new URLSearchParams();
|
||||
preserveQueryParameters(out, customizationService, current);
|
||||
expect(out.getAll('customizationAlt')).toEqual(['c']);
|
||||
expect(customizationService.getValue).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('preserveQueryStrings', () => {
|
||||
it('keeps single values as strings and repeated values as arrays', () => {
|
||||
const current = new URLSearchParams();
|
||||
current.append('configUrl', 'foo.js');
|
||||
current.append('customization', 'a');
|
||||
current.append('customization', 'b');
|
||||
|
||||
const out: Record<string, string | string[]> = {};
|
||||
preserveQueryStrings(out, undefined, current);
|
||||
expect(out.configUrl).toBe('foo.js');
|
||||
expect(out.customization).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('keeps a single customization value as a string', () => {
|
||||
const current = new URLSearchParams();
|
||||
current.append('customization', 'only');
|
||||
const out: Record<string, string | string[]> = {};
|
||||
preserveQueryStrings(out, undefined, current);
|
||||
expect(out.customization).toBe('only');
|
||||
});
|
||||
|
||||
it('uses customization service values for query string preservation', () => {
|
||||
const customizationService = {
|
||||
getValue: jest.fn().mockReturnValue(['customization', 'customizationAlt']),
|
||||
};
|
||||
const current = new URLSearchParams();
|
||||
current.append('customizationAlt', 'c');
|
||||
const out: Record<string, string | string[]> = {};
|
||||
preserveQueryStrings(out, customizationService, current);
|
||||
expect(out.customizationAlt).toBe('c');
|
||||
expect(customizationService.getValue).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('serializes single preserved values as plain query keys with default options', () => {
|
||||
const current = new URLSearchParams();
|
||||
current.append('configUrl', 'foo.js');
|
||||
const out: Record<string, string | string[]> = {};
|
||||
preserveQueryStrings(out, undefined, current);
|
||||
// Default options only — no arrayFormat. Single values are plain strings, so
|
||||
// they round-trip unchanged regardless of arrayFormat.
|
||||
const search = qs.stringify(out, { skipNull: true, skipEmptyString: true });
|
||||
expect(search).toBe('configUrl=foo.js');
|
||||
expect(search).not.toMatch(/configUrl\[/);
|
||||
});
|
||||
|
||||
it('serializes repeated preserved values as duplicated keys with default options', () => {
|
||||
const current = new URLSearchParams();
|
||||
current.append('customization', 'a');
|
||||
current.append('customization', 'b');
|
||||
const out: Record<string, string | string[]> = {};
|
||||
preserveQueryStrings(out, undefined, current);
|
||||
// query-string's default arrayFormat already produces duplicated keys, so callers
|
||||
// need no arrayFormat option for repeated preserved keys to round-trip.
|
||||
const search = qs.stringify(out, { skipNull: true, skipEmptyString: true });
|
||||
expect(search).toBe('customization=a&customization=b');
|
||||
});
|
||||
});
|
||||
@ -1,26 +1,63 @@
|
||||
function preserve(query, current, key) {
|
||||
const value = current.get(key);
|
||||
if (value) {
|
||||
query.append(key, value);
|
||||
}
|
||||
}
|
||||
import type CustomizationService from '@ohif/core/src/services/CustomizationService';
|
||||
|
||||
export const preserveKeys = ['configUrl', 'multimonitor', 'screenNumber', 'hangingProtocolId'];
|
||||
/**
|
||||
* Keys preserved when navigating between worklist and viewer modes.
|
||||
* Each key may repeat in the URL; a single occurrence is preserved as a string and
|
||||
* multiple occurrences as an array (see preserveQueryStrings).
|
||||
*/
|
||||
export const PRESERVE_CUSTOMIZATION_KEYS_KEY = 'ohif.preserveCustomizationKeys';
|
||||
export const preserveKeys = [
|
||||
'configUrl',
|
||||
'multimonitor',
|
||||
'screenNumber',
|
||||
'hangingProtocolId',
|
||||
'customization',
|
||||
];
|
||||
|
||||
export function preserveQueryParameters(
|
||||
query,
|
||||
current = new URLSearchParams(window.location.search)
|
||||
) {
|
||||
for (const key of preserveKeys) {
|
||||
preserve(query, current, key);
|
||||
}
|
||||
}
|
||||
|
||||
export function preserveQueryStrings(query, current = new URLSearchParams(window.location.search)) {
|
||||
for (const key of preserveKeys) {
|
||||
const value = current.get(key);
|
||||
function preserveKey(query: URLSearchParams, current: URLSearchParams, key: string) {
|
||||
const values = current.getAll(key);
|
||||
for (const value of values) {
|
||||
if (value) {
|
||||
query[key] = value;
|
||||
query.append(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getPreserveKeys(customizationService?: CustomizationService): string[] {
|
||||
const customKeys = customizationService?.getValue?.(PRESERVE_CUSTOMIZATION_KEYS_KEY, []) || [];
|
||||
if (!customKeys?.length) {
|
||||
return preserveKeys;
|
||||
}
|
||||
|
||||
return [...preserveKeys, ...customKeys];
|
||||
}
|
||||
|
||||
export function preserveQueryParameters(
|
||||
query: URLSearchParams,
|
||||
customizationService?: CustomizationService,
|
||||
current: URLSearchParams = new URLSearchParams(window.location.search)
|
||||
): void {
|
||||
for (const key of getPreserveKeys(customizationService)) {
|
||||
preserveKey(query, current, key);
|
||||
}
|
||||
}
|
||||
|
||||
export function preserveQueryStrings(
|
||||
query: Record<string, string | string[]>,
|
||||
customizationService?: CustomizationService,
|
||||
current: URLSearchParams = new URLSearchParams(window.location.search)
|
||||
): void {
|
||||
for (const key of getPreserveKeys(customizationService)) {
|
||||
const values = current.getAll(key).filter(Boolean);
|
||||
if (!values.length) {
|
||||
continue;
|
||||
}
|
||||
// A single value is stored as a plain string and repeated values as an array.
|
||||
// The worklist stringifier (query-string) serializes both correctly with its
|
||||
// default arrayFormat: a string becomes `key=value` and an array becomes
|
||||
// duplicated `key=a&key=b` keys — no arrayFormat option required by callers.
|
||||
// Keeping single values as strings also keeps them safe under stricter
|
||||
// serializers (e.g. the `qs` library, whose default would index arrays).
|
||||
query[key] = values.length === 1 ? values[0] : values;
|
||||
}
|
||||
}
|
||||
|
||||
@ -49,6 +49,7 @@
|
||||
"gl-matrix": "3.4.3",
|
||||
"immutability-helper": "3.1.1",
|
||||
"isomorphic-base64": "1.0.2",
|
||||
"json5": "2.2.3",
|
||||
"lodash.clonedeep": "4.5.0",
|
||||
"lodash.isequal": "4.5.0",
|
||||
"moment": "2.30.1",
|
||||
|
||||
@ -0,0 +1,50 @@
|
||||
import CustomizationService from './CustomizationService';
|
||||
|
||||
const commandsManager = {};
|
||||
|
||||
describe('CustomizationService.init (extension default/global deduplication)', () => {
|
||||
it('does not re-merge the same extension customization modules on repeated init', () => {
|
||||
const getModuleEntry = jest.fn((id: string) => {
|
||||
if (id === 'ext1.customizationModule.default') {
|
||||
return { value: { fromDefault: { $set: 1 } } };
|
||||
}
|
||||
if (id === 'ext1.customizationModule.global') {
|
||||
return { value: { fromGlobal: { $set: 'g' } } };
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const extensionManager = {
|
||||
registeredExtensionIds: ['ext1'],
|
||||
getRegisteredExtensionIds: () => ['ext1'],
|
||||
getModuleEntry,
|
||||
};
|
||||
|
||||
const service = new CustomizationService({ commandsManager, configuration: {} });
|
||||
service.init(extensionManager as any);
|
||||
service.init(extensionManager as any);
|
||||
|
||||
expect(getModuleEntry).toHaveBeenCalledTimes(2);
|
||||
expect(service.getCustomization('fromDefault')).toBe(1);
|
||||
expect(service.getCustomization('fromGlobal')).toBe('g');
|
||||
});
|
||||
|
||||
it('merges a module the first time it appears on a later init', () => {
|
||||
const moduleEntries: Record<string, { value: Record<string, unknown> }> = {};
|
||||
|
||||
const extensionManager = {
|
||||
registeredExtensionIds: ['ext2'],
|
||||
getRegisteredExtensionIds: () => ['ext2'],
|
||||
getModuleEntry: (id: string) => moduleEntries[id],
|
||||
};
|
||||
|
||||
const service = new CustomizationService({ commandsManager, configuration: {} });
|
||||
service.init(extensionManager as any);
|
||||
expect(service.getCustomization('late')).toBeUndefined();
|
||||
|
||||
moduleEntries['ext2.customizationModule.default'] = { value: { late: { $set: true } } };
|
||||
service.init(extensionManager as any);
|
||||
|
||||
expect(service.getCustomization('late')).toBe(true);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,130 @@
|
||||
import CustomizationService, { GENERAL_MODE_KEY } from './CustomizationService';
|
||||
|
||||
const commandsManager = {};
|
||||
|
||||
const policy = {
|
||||
prefixes: { default: './customizations/' },
|
||||
};
|
||||
|
||||
/** Minimal ExtensionManager stub exposing the appConfig the URL policy reads. */
|
||||
function makeExtensionManager(appConfig: Record<string, unknown> = {}) {
|
||||
return {
|
||||
appConfig: { customizationUrlPrefixes: policy.prefixes, ...appConfig },
|
||||
registeredExtensionIds: [],
|
||||
getRegisteredExtensionIds: () => [],
|
||||
getModuleEntry: () => undefined,
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe('CustomizationService phase-tagged loading', () => {
|
||||
it('applies bootstrap before extensions and global after, both Global scope', async () => {
|
||||
const service = new CustomizationService({
|
||||
commandsManager,
|
||||
configuration: {
|
||||
bootstrap: { early: { $set: 'pre' } },
|
||||
global: { late: { $set: 'post' } },
|
||||
},
|
||||
});
|
||||
|
||||
const extensionManager = makeExtensionManager();
|
||||
await service.loadAndApplyBootstrapCustomizations(extensionManager);
|
||||
|
||||
// bootstrap is applied immediately; global is not yet.
|
||||
expect(service.getCustomization('early')).toBe('pre');
|
||||
expect(service.getCustomization('late')).toBeUndefined();
|
||||
|
||||
service.init(extensionManager);
|
||||
service.applyGlobalCustomizations();
|
||||
|
||||
expect(service.getCustomization('late')).toBe('post');
|
||||
expect(service.getCustomizations(service.Scope.Global).get('early')).toBeDefined();
|
||||
});
|
||||
|
||||
it('does not treat a phase-tagged config as legacy Global references', () => {
|
||||
const service = new CustomizationService({
|
||||
commandsManager,
|
||||
configuration: { global: { a: { $set: 1 } } },
|
||||
});
|
||||
service.init(makeExtensionManager());
|
||||
// `global` must NOT be added as a customization id during init; it is a phase.
|
||||
expect(service.getCustomization('global')).toBeUndefined();
|
||||
expect(service.getCustomization('a')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('applies the general mode block first, then the mode-specific block', () => {
|
||||
const service = new CustomizationService({
|
||||
commandsManager,
|
||||
configuration: {
|
||||
mode: {
|
||||
[GENERAL_MODE_KEY]: { greeting: { $set: 'general' }, shared: { $set: 'general' } },
|
||||
viewer: { greeting: { $set: 'viewer' } },
|
||||
},
|
||||
},
|
||||
});
|
||||
service.init(makeExtensionManager());
|
||||
|
||||
service.onModeEnter();
|
||||
service.applyModeCustomizations(['viewer']);
|
||||
|
||||
// mode-specific overrides general; non-overridden general value remains.
|
||||
expect(service.getCustomization('greeting')).toBe('viewer');
|
||||
expect(service.getCustomization('shared')).toBe('general');
|
||||
});
|
||||
|
||||
it('matches a mode block by id OR routeName', () => {
|
||||
const service = new CustomizationService({
|
||||
commandsManager,
|
||||
configuration: {
|
||||
mode: { viewer: { fromRouteName: { $set: true } } },
|
||||
},
|
||||
});
|
||||
service.init(makeExtensionManager());
|
||||
|
||||
service.onModeEnter();
|
||||
service.applyModeCustomizations(['@ohif/mode-longitudinal', 'viewer']);
|
||||
expect(service.getCustomization('fromRouteName')).toBe(true);
|
||||
});
|
||||
|
||||
it('clears mode-scope customizations on re-enter so a different mode does not leak', () => {
|
||||
const service = new CustomizationService({
|
||||
commandsManager,
|
||||
configuration: {
|
||||
mode: {
|
||||
viewer: { onlyViewer: { $set: true } },
|
||||
segmentation: { onlySeg: { $set: true } },
|
||||
},
|
||||
},
|
||||
});
|
||||
service.init(makeExtensionManager());
|
||||
|
||||
service.onModeEnter();
|
||||
service.applyModeCustomizations(['viewer']);
|
||||
expect(service.getCustomization('onlyViewer')).toBe(true);
|
||||
|
||||
service.onModeEnter();
|
||||
service.applyModeCustomizations(['segmentation']);
|
||||
expect(service.getCustomization('onlyViewer')).toBeUndefined();
|
||||
expect(service.getCustomization('onlySeg')).toBe(true);
|
||||
});
|
||||
|
||||
it('applies phase blocks from URL-loaded modules and merges $apply over extension defaults', async () => {
|
||||
const service = new CustomizationService({ commandsManager, configuration: {} });
|
||||
const extensionManager = makeExtensionManager();
|
||||
|
||||
// Seed an extension-style default so $apply has something to build on.
|
||||
service.init(extensionManager);
|
||||
service.setCustomizations({ 'list.columns': ['a', 'b'] }, service.Scope.Default);
|
||||
|
||||
const importFn = jest.fn(async () => ({
|
||||
global: { 'list.columns': { $push: ['c'] } },
|
||||
mode: { '*': { banner: { $set: 'all' } }, viewer: { banner: { $set: 'viewer' } } },
|
||||
}));
|
||||
|
||||
await service.loadCustomizationModules(['A'], { policy, importFn });
|
||||
service.applyGlobalCustomizations();
|
||||
expect(service.getCustomization('list.columns')).toEqual(['a', 'b', 'c']);
|
||||
|
||||
service.applyModeCustomizations(['viewer']);
|
||||
expect(service.getCustomization('banner')).toBe('viewer');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,148 @@
|
||||
import CustomizationService from './CustomizationService';
|
||||
|
||||
const commandsManager = {};
|
||||
|
||||
const policy = {
|
||||
prefixes: { default: './customizations/' },
|
||||
};
|
||||
|
||||
describe('CustomizationService.requires (URL customization modules)', () => {
|
||||
let service: CustomizationService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new CustomizationService({ commandsManager, configuration: {} });
|
||||
});
|
||||
|
||||
it('loads a single module', async () => {
|
||||
const importFn = jest.fn(async (url: string) => ({
|
||||
global: { entry: { value: url } },
|
||||
}));
|
||||
const loaded = await service.requires(['A'], { policy, importFn });
|
||||
expect(loaded).toHaveLength(1);
|
||||
expect(loaded[0].request.name).toBe('A');
|
||||
expect(loaded[0].module.global).toBeDefined();
|
||||
expect(importFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns immediately when the same module is already loaded', async () => {
|
||||
const importFn = jest.fn(async (url: string) => ({
|
||||
global: { entry: { value: url } },
|
||||
}));
|
||||
await service.requires(['A'], { policy, importFn });
|
||||
expect(importFn).toHaveBeenCalledTimes(1);
|
||||
const loadedAgain = await service.requires(['A'], { policy, importFn });
|
||||
expect(loadedAgain).toHaveLength(0);
|
||||
expect(importFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('loads dependencies first via module requires', async () => {
|
||||
const importFn = jest.fn(async (url: string) => {
|
||||
if (url.endsWith('/A.jsonc')) {
|
||||
return {
|
||||
global: { 'pkg.A': { value: 'A' } },
|
||||
requires: ['B'],
|
||||
};
|
||||
}
|
||||
if (url.endsWith('/B.jsonc')) {
|
||||
return {
|
||||
global: { 'pkg.B': { value: 'B' } },
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const loaded = await service.requires(['A'], { policy, importFn });
|
||||
|
||||
expect(loaded.map(l => l.request.name)).toEqual(['B', 'A']);
|
||||
});
|
||||
|
||||
it('handles cycles per the spec: A requires B, B requires A => B then A', async () => {
|
||||
const importFn = jest.fn(async (url: string) => {
|
||||
if (url.endsWith('/A.jsonc')) {
|
||||
return {
|
||||
global: { 'pkg.A': { value: 'A' } },
|
||||
requires: ['B'],
|
||||
};
|
||||
}
|
||||
if (url.endsWith('/B.jsonc')) {
|
||||
return {
|
||||
global: { 'pkg.B': { value: 'B' } },
|
||||
requires: ['A'],
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const loaded = await service.requires(['A'], { policy, importFn });
|
||||
|
||||
expect(loaded.map(l => l.request.name)).toEqual(['B', 'A']);
|
||||
expect(importFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not treat customization field refs as URL dependencies', async () => {
|
||||
const importFn = jest.fn(async () => ({
|
||||
global: {
|
||||
'viewportOverlay.topLeft.X': { customization: 'ohif.overlayItem' },
|
||||
},
|
||||
}));
|
||||
const loaded = await service.requires(['A'], { policy, importFn });
|
||||
expect(loaded).toHaveLength(1);
|
||||
expect(importFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('parses comma-separated names like the URL query integration', async () => {
|
||||
const importFn = jest.fn(async () => ({
|
||||
global: { 'pkg.X': {} },
|
||||
}));
|
||||
const loaded = await service.requires(['A', 'B', 'C'], { policy, importFn });
|
||||
expect(loaded.map(l => l.request.name)).toEqual(['A', 'B', 'C']);
|
||||
expect(importFn).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('throws and loads nothing when any entry is rejected', async () => {
|
||||
const importFn = jest.fn(async () => ({
|
||||
global: {},
|
||||
}));
|
||||
await expect(
|
||||
service.requires(['A', '/missing/foo', '../escape'], { policy, importFn })
|
||||
).rejects.toThrow(/refusing to load customization/);
|
||||
expect(importFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('warns and skips when import fails', async () => {
|
||||
const importFn = jest.fn(async () => {
|
||||
throw new Error('404');
|
||||
});
|
||||
const warn = jest.fn();
|
||||
const loaded = await service.requires(['A'], {
|
||||
policy,
|
||||
importFn,
|
||||
logger: { warn, error: jest.fn() },
|
||||
});
|
||||
expect(loaded).toHaveLength(0);
|
||||
expect(warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('warns and skips when the module has no customization payload', async () => {
|
||||
const importFn = jest.fn(async () => ({}));
|
||||
const warn = jest.fn();
|
||||
const loaded = await service.requires(['A'], {
|
||||
policy,
|
||||
importFn,
|
||||
logger: { warn, error: jest.fn() },
|
||||
});
|
||||
expect(loaded).toHaveLength(0);
|
||||
expect(warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applyCustomizationUrlSearchParams delegates to requires', async () => {
|
||||
const importFn = jest.fn(async () => ({
|
||||
global: { 'pkg.X': {} },
|
||||
}));
|
||||
const params = new URLSearchParams();
|
||||
params.append('customization', 'A,B');
|
||||
params.append('customization', 'C');
|
||||
await service.applyCustomizationUrlSearchParams(params, { policy, importFn });
|
||||
expect(importFn).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
@ -1,8 +1,33 @@
|
||||
import update, { extend } from 'immutability-helper';
|
||||
import JSON5 from 'json5';
|
||||
import { PubSubService } from '../_shared/pubSubServiceInterface';
|
||||
import type { Customization } from './types';
|
||||
import type { CommandsManager } from '../../classes';
|
||||
import type { ExtensionManager } from '../../extensions';
|
||||
import type ExtensionManager from '../../extensions/ExtensionManager';
|
||||
import { getCustomizationUrlPolicy } from './customizationUrl';
|
||||
import { getUrlCustomizationModulePayload } from './getUrlCustomizationModulePayload';
|
||||
import { resolveCustomizationUrl } from './resolve';
|
||||
import {
|
||||
parseCustomizationParams,
|
||||
validateCustomizationRequests,
|
||||
} from './validate';
|
||||
import type { ValidatedCustomization } from './validate';
|
||||
import type { CustomizationUrlPolicy } from './customizationUrlDefaults';
|
||||
import type {
|
||||
CustomizationModule,
|
||||
CustomizationPhaseInput,
|
||||
LoadedCustomization,
|
||||
LoadOptions,
|
||||
PhasedCustomizationConfig,
|
||||
} from './customizationUrlTypes';
|
||||
|
||||
/**
|
||||
* Reserved key in a `mode` phase block for the "general" customizations that
|
||||
* apply to every mode. The general block is applied FIRST on each mode enter;
|
||||
* a block keyed by the entered mode's id / routeName is applied after it so a
|
||||
* single mode can override the general values.
|
||||
*/
|
||||
export const GENERAL_MODE_KEY = '*';
|
||||
|
||||
const EVENTS = {
|
||||
MODE_CUSTOMIZATION_MODIFIED: 'event::CustomizationService:modeModified',
|
||||
@ -28,7 +53,8 @@ export enum CustomizationScope {
|
||||
|
||||
/**
|
||||
* Default customizations that serve as fallbacks when no global or mode-specific
|
||||
* customizations are defined. These can only be defined once.
|
||||
* customizations are defined. These are not cleared when the service re-inits
|
||||
* for a mode change; only Mode scope is reset.
|
||||
*/
|
||||
Default = 'default',
|
||||
}
|
||||
@ -89,10 +115,9 @@ export default class CustomizationService extends PubSubService {
|
||||
private modeCustomizations = new Map<string, Customization>();
|
||||
|
||||
/**
|
||||
* A collection of default customizations used as fallbacks. These serve as
|
||||
* the base configuration and are registered at setup. Default customizations
|
||||
* provide baseline values that can be overridden by mode or global customizations.
|
||||
* Use these for cases where default values are necessary for predictable behavior.
|
||||
* A collection of default customizations used as fallbacks. Entries are merged
|
||||
* over time (including from `init()` re-reading extension default modules) and
|
||||
* are not cleared on mode change. Mode and Global scopes override these values.
|
||||
*/
|
||||
private defaultCustomizations = new Map<string, Customization>();
|
||||
|
||||
@ -103,41 +128,334 @@ export default class CustomizationService extends PubSubService {
|
||||
private transformedCustomizations = new Map<string, Customization>();
|
||||
private configuration: AppTypes.Config;
|
||||
|
||||
/**
|
||||
* URL customization modules already imported and applied (key = normalized `/prefix/name`).
|
||||
* Entries are kept for the lifetime of the page: repeated loads skip imports, and the app
|
||||
* normally applies `?customization=` only at bootstrap (see {@link applyWindowUrlCustomizations}).
|
||||
*/
|
||||
private _urlCustomizationLoaded = new Map<string, LoadedCustomization>();
|
||||
|
||||
private _urlCustomizationPending = new Map<string, Promise<LoadedCustomization | null>>();
|
||||
|
||||
/**
|
||||
* Every URL customization module resolved this page session, in load order
|
||||
* (dependencies before dependents). The lifecycle phase appliers
|
||||
* ({@link applyBootstrapCustomizations}, {@link applyGlobalCustomizations},
|
||||
* {@link applyModeCustomizations}) iterate this list so a module's phase
|
||||
* blocks are applied at the right time regardless of when it was fetched.
|
||||
*/
|
||||
private _resolvedUrlModules: LoadedCustomization[] = [];
|
||||
|
||||
/**
|
||||
* Normalized form of `appConfig.customizationService`. Either a phase-tagged
|
||||
* config ({@link PhasedCustomizationConfig}) or, for the legacy array/object
|
||||
* form, the references to add (Global) during {@link init}.
|
||||
*/
|
||||
private _customizationConfig: {
|
||||
phased?: PhasedCustomizationConfig;
|
||||
legacyReferences?: unknown;
|
||||
} | null = null;
|
||||
|
||||
/** Id/aliases of the mode currently entered, used to re-apply mode phase blocks. */
|
||||
private _currentModeIds: string[] = [];
|
||||
|
||||
/**
|
||||
* Extension module entry ids (e.g. `${extensionId}.customizationModule.default`) whose
|
||||
* default/global payloads have already been merged via {@link init}. Matches the URL
|
||||
* loader pattern: repeated {@link init} skips work for the same slot so immutability-style
|
||||
* merges are not applied twice. A slot is recorded only after a module was present and applied;
|
||||
* if a module appears only on a later {@link init} (e.g. a newly registered extension), it is merged then.
|
||||
*/
|
||||
private _extensionCustomizationModuleApplied = new Set<string>();
|
||||
|
||||
constructor({ configuration, commandsManager }) {
|
||||
super(EVENTS);
|
||||
this.configuration = configuration;
|
||||
this.commandsManager = commandsManager;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Public API
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Clears mode customizations and merges each extension's `customizationModule.default` /
|
||||
* `customizationModule.global` into the service. Safe to call multiple times (e.g. from
|
||||
* {@link onModeEnter}): each extension module slot is merged at most once per page session,
|
||||
* matching the deduplication pattern used for URL-loaded modules in {@link requires}.
|
||||
* Slots with no module yet are left unmarked so a later call can merge when the module appears.
|
||||
*/
|
||||
public init(extensionManager: ExtensionManager): void {
|
||||
this.extensionManager = extensionManager;
|
||||
// Clear defaults as those are defined by the customization modules
|
||||
this.defaultCustomizations.clear();
|
||||
// Clear modes because those are defined in onModeEnter functions.
|
||||
// Mode customizations are defined per mode in onModeEnter; reset them here.
|
||||
// Default customizations are not cleared — they are merged again from
|
||||
// extension modules below so definitions stay available across mode changes.
|
||||
this.modeCustomizations.clear();
|
||||
|
||||
this.extensionManager.getRegisteredExtensionIds().forEach(extensionId => {
|
||||
const keyDefault = `${extensionId}.customizationModule.default`;
|
||||
const defaultCustomizations = this._findExtensionValue(keyDefault);
|
||||
if (defaultCustomizations) {
|
||||
const { value } = defaultCustomizations;
|
||||
this._addReference(value, CustomizationScope.Default);
|
||||
if (!this._extensionCustomizationModuleApplied.has(keyDefault)) {
|
||||
const defaultCustomizations = this._findExtensionValue(keyDefault);
|
||||
if (defaultCustomizations) {
|
||||
const { value } = defaultCustomizations;
|
||||
this._addReference(value, CustomizationScope.Default);
|
||||
this._extensionCustomizationModuleApplied.add(keyDefault);
|
||||
}
|
||||
}
|
||||
const keyGlobal = `${extensionId}.customizationModule.global`;
|
||||
const globalCustomizations = this._findExtensionValue(keyGlobal);
|
||||
if (globalCustomizations) {
|
||||
const { value } = globalCustomizations;
|
||||
this._addReference(value, CustomizationScope.Global);
|
||||
if (!this._extensionCustomizationModuleApplied.has(keyGlobal)) {
|
||||
const globalCustomizations = this._findExtensionValue(keyGlobal);
|
||||
if (globalCustomizations) {
|
||||
const { value } = globalCustomizations;
|
||||
this._addReference(value, CustomizationScope.Global);
|
||||
this._extensionCustomizationModuleApplied.add(keyGlobal);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Only add references for the configuration once.
|
||||
if (!this.configuration?._hasBeenAdded) {
|
||||
this.addReferences(this.configuration);
|
||||
// Only add references for the configuration once. The phase-tagged config
|
||||
// form (bootstrap/global/mode) is applied by the lifecycle appliers
|
||||
// instead, so only the legacy array/object form is added here as Global.
|
||||
const config = this._getCustomizationConfig();
|
||||
if (config.legacyReferences !== undefined && !this.configuration?._hasBeenAdded) {
|
||||
this.addReferences(config.legacyReferences);
|
||||
Object.defineProperty(this.configuration, '_hasBeenAdded', { value: true, writable: false });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoized, normalized view of `appConfig.customizationService`. Detects the
|
||||
* phase-tagged form (any of `requires` / `bootstrap` / `global` / `mode`)
|
||||
* and otherwise treats the value as legacy Global references.
|
||||
*/
|
||||
private _getCustomizationConfig(): { phased?: PhasedCustomizationConfig; legacyReferences?: unknown } {
|
||||
if (!this._customizationConfig) {
|
||||
this._customizationConfig = normalizeCustomizationConfig(this.configuration);
|
||||
}
|
||||
return this._customizationConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads and applies `?customization=` modules from `window.location.search`.
|
||||
*
|
||||
* **Throws on disallowed values.** A `?customization=` value whose prefix is
|
||||
* not in `appConfig.customizationUrlPrefixes` (the default — the feature is off
|
||||
* until prefixes are configured) rejects this promise, which by design aborts
|
||||
* app bootstrap rather than silently ignoring the request.
|
||||
*
|
||||
* **Intended SPA behavior:** The shell typically calls this once during startup. It does not
|
||||
* run again on client-side route changes. The query key `customization` may still appear in
|
||||
* URLs (for example preserved by worklist navigation) without implying that modules are
|
||||
* re-evaluated on every navigation. Modules resolved here are also deduplicated by normalized
|
||||
* URL for the lifetime of the page in {@link requires}. To pick up a different `?customization=`
|
||||
* set, use a full page load or call {@link applyCustomizationUrlSearchParams} /
|
||||
* {@link requires} from your own integration code when appropriate.
|
||||
*
|
||||
* **What is loaded:** Each `?customization=` entry resolves to a JSONC file (JSON with
|
||||
* comments / trailing commas) under a configured prefix directory. The file is fetched and
|
||||
* parsed as **data** — it is never executed. Executable code (plugins, modes, extensions)
|
||||
* loads only through `pluginConfig.json`, never from the customization URL path. A module's
|
||||
* `global` payload is applied as global customizations and its `requires` are loaded first.
|
||||
*/
|
||||
public async applyWindowUrlCustomizations(overrides?: Partial<LoadOptions>): Promise<void> {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
await this.applyCustomizationUrlSearchParams(
|
||||
new URLSearchParams(window.location.search),
|
||||
overrides
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses `?customization=` values from the search string and delegates to
|
||||
* {@link requires}.
|
||||
*/
|
||||
public async applyCustomizationUrlSearchParams(
|
||||
params: URLSearchParams,
|
||||
overrides?: Partial<LoadOptions>
|
||||
): Promise<void> {
|
||||
const raws = parseCustomizationParams(params);
|
||||
if (!raws.length) {
|
||||
return;
|
||||
}
|
||||
await this.requires(raws, overrides);
|
||||
}
|
||||
|
||||
/**
|
||||
* Depth-first dynamic import of URL customization modules
|
||||
* `requires` edges and `customization` field
|
||||
* references are loaded before dependents. Already-loaded modules (same normalized key) are
|
||||
* skipped for the rest of the page session; they are not unloaded when the address bar changes.
|
||||
*
|
||||
* Invalid query entries, resolve failures, failed imports, and modules without a
|
||||
* customization payload are warned and skipped.
|
||||
*/
|
||||
public requires(
|
||||
names: string | string[],
|
||||
overrides?: Partial<LoadOptions>
|
||||
): Promise<LoadedCustomization[]> {
|
||||
return this.loadCustomizationModules(names, overrides).then(newlyLoaded => {
|
||||
// Back-compat: a direct `requires()` / `applyWindowUrlCustomizations()`
|
||||
// call applies the `global` slice immediately. The `bootstrap` and
|
||||
// `mode` phases are driven by the boot orchestration
|
||||
// ({@link loadAndApplyBootstrapCustomizations}) and {@link onModeEnter}.
|
||||
this._applyLoadedUrlCustomizationModules(newlyLoaded);
|
||||
return newlyLoaded;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves (fetches + parses) the given URL customization modules and their
|
||||
* `requires` dependencies depth-first, WITHOUT applying any phase block.
|
||||
* Newly resolved modules are appended to {@link _resolvedUrlModules} in load
|
||||
* order so the lifecycle appliers can apply each phase at the right time.
|
||||
*
|
||||
* Rejects (aborting the load) when any entry is disallowed by the policy.
|
||||
*/
|
||||
public loadCustomizationModules(
|
||||
names: string | string[],
|
||||
overrides?: Partial<LoadOptions>
|
||||
): Promise<LoadedCustomization[]> {
|
||||
const policy = overrides?.policy ?? getCustomizationUrlPolicy(this);
|
||||
const list = (Array.isArray(names) ? names : [names])
|
||||
.map(s => String(s).trim())
|
||||
.filter(Boolean);
|
||||
if (!list.length) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
const { valid, rejected } = validateCustomizationRequests(list, policy);
|
||||
const logger = overrides?.logger || console;
|
||||
// A `?customization=` value that is not allowed by the configured prefixes is
|
||||
// a hard error: stop the load and let it propagate. The feature is off until
|
||||
// `appConfig.customizationUrlPrefixes` allows a prefix, so on a default build
|
||||
// any `?customization=` value throws here rather than being silently ignored.
|
||||
if (rejected.length) {
|
||||
const details = rejected.map(r => `"${r.raw}" (${r.reason})`).join('; ');
|
||||
return Promise.reject(
|
||||
new Error(
|
||||
`[customizationUrl] refusing to load customization(s): ${details}. ` +
|
||||
`Allowed prefixes are configured in appConfig.customizationUrlPrefixes.`
|
||||
)
|
||||
);
|
||||
}
|
||||
if (!valid.length) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
const importFn = overrides?.importFn ?? this._urlDefaultImport.bind(this);
|
||||
const requestedSet = new Set<string>();
|
||||
const newlyLoaded: LoadedCustomization[] = [];
|
||||
|
||||
return valid
|
||||
.reduce(
|
||||
(prev, request) =>
|
||||
prev.then(() =>
|
||||
this._urlCustomizationLoadOne(
|
||||
request,
|
||||
policy,
|
||||
importFn,
|
||||
logger,
|
||||
requestedSet,
|
||||
newlyLoaded
|
||||
)
|
||||
),
|
||||
Promise.resolve() as Promise<void>
|
||||
)
|
||||
.then(() => {
|
||||
this._resolvedUrlModules.push(...newlyLoaded);
|
||||
return newlyLoaded;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot-time entry point that runs BEFORE extensions are registered. It:
|
||||
* 1. records the ExtensionManager (so the URL policy / appConfig is readable),
|
||||
* 2. resolves every customization module requested via the
|
||||
* `appConfig.customizationService.requires` list and the `?customization=`
|
||||
* URL parameter (their data is fetched once, up front — long before any
|
||||
* mode loads), and
|
||||
* 3. applies the `bootstrap` phase blocks.
|
||||
*
|
||||
* Rejects (aborting bootstrap) if a `?customization=` value is disallowed.
|
||||
*/
|
||||
public async loadAndApplyBootstrapCustomizations(
|
||||
extensionManager: ExtensionManager,
|
||||
overrides?: Partial<LoadOptions>
|
||||
): Promise<void> {
|
||||
this.extensionManager = extensionManager;
|
||||
const config = this._getCustomizationConfig();
|
||||
|
||||
const names: string[] = [];
|
||||
const requires = config.phased?.requires;
|
||||
if (typeof requires === 'string' && requires) {
|
||||
names.push(requires);
|
||||
} else if (Array.isArray(requires)) {
|
||||
names.push(...requires.filter(name => typeof name === 'string' && name));
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
names.push(...parseCustomizationParams(new URLSearchParams(window.location.search)));
|
||||
}
|
||||
|
||||
if (names.length) {
|
||||
await this.loadCustomizationModules(names, overrides);
|
||||
}
|
||||
|
||||
this.applyBootstrapCustomizations();
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the `bootstrap` phase (Global scope) of the structured app config
|
||||
* and every resolved URL module. App-config blocks apply first so URL modules
|
||||
* layer on top.
|
||||
*/
|
||||
public applyBootstrapCustomizations(): void {
|
||||
this._applyPhase('bootstrap');
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the `global` phase (Global scope) of the structured app config and
|
||||
* every resolved URL module. Call after {@link init} so extension default /
|
||||
* global customizations are already in place for `$apply`-style merges.
|
||||
*/
|
||||
public applyGlobalCustomizations(): void {
|
||||
this._applyPhase('global');
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the `mode` phase (Mode scope) for the entered mode. The general
|
||||
* block (`*`) is applied FIRST, then any block keyed by one of `modeIds`
|
||||
* (the mode's id / routeName), so a single mode can override the general
|
||||
* values. Call this AFTER `customizationService.onModeEnter()` has reset the
|
||||
* mode scope (i.e. after `extensionManager.onModeEnter()`).
|
||||
*/
|
||||
public applyModeCustomizations(modeIds: string | string[]): void {
|
||||
const ids = (Array.isArray(modeIds) ? modeIds : [modeIds]).filter(Boolean) as string[];
|
||||
this._currentModeIds = ids;
|
||||
|
||||
const blocks = this._collectPhaseBlocks('mode') as Array<{
|
||||
mode?: Record<string, CustomizationPhaseInput>;
|
||||
}>;
|
||||
|
||||
// General first, for every source.
|
||||
for (const block of blocks) {
|
||||
const general = block.mode?.[GENERAL_MODE_KEY];
|
||||
if (general) {
|
||||
this.setCustomizations(general, CustomizationScope.Mode);
|
||||
}
|
||||
}
|
||||
// Then mode-specific, for every source.
|
||||
for (const block of blocks) {
|
||||
for (const id of ids) {
|
||||
const specific = block.mode?.[id];
|
||||
if (specific) {
|
||||
this.setCustomizations(specific, CustomizationScope.Mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public onModeEnter(): void {
|
||||
this.clearTransformedCustomizations();
|
||||
|
||||
@ -148,17 +466,6 @@ export default class CustomizationService extends PubSubService {
|
||||
this.clearTransformedCustomizations();
|
||||
}
|
||||
|
||||
private clearTransformedCustomizations(): void {
|
||||
super.reset();
|
||||
|
||||
const modeCustomizationKeys = Array.from(this.modeCustomizations.keys());
|
||||
for (const key of modeCustomizationKeys) {
|
||||
this.transformedCustomizations.delete(key);
|
||||
}
|
||||
|
||||
this.modeCustomizations.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified getter for customizations.
|
||||
*
|
||||
@ -184,6 +491,14 @@ export default class CustomizationService extends PubSubService {
|
||||
return newTransformed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a customization value, or the provided fallback when unset.
|
||||
*/
|
||||
public getValue<T = Customization>(customizationId: string, fallbackValue?: T): T | undefined {
|
||||
const value = this.getCustomization(customizationId);
|
||||
return (value === undefined ? fallbackValue : (value as T)) as T | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes an object with multiple properties, each property containing
|
||||
* immutability-helper commands, and applies them one by one.
|
||||
@ -229,34 +544,6 @@ export default class CustomizationService extends PubSubService {
|
||||
this._setCustomization(customizationId, customization, scope);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method to set a single customization
|
||||
*/
|
||||
private _setCustomization(
|
||||
customizationId: string,
|
||||
customization: Customization,
|
||||
scope: CustomizationScope = CustomizationScope.Mode
|
||||
): void {
|
||||
// if (typeof customization === 'string') {
|
||||
// const extensionValue = this._findExtensionValue(customization);
|
||||
// customization = extensionValue.value;
|
||||
// }
|
||||
|
||||
switch (scope) {
|
||||
case CustomizationScope.Global:
|
||||
this.setGlobalCustomization(customizationId, customization);
|
||||
break;
|
||||
case CustomizationScope.Mode:
|
||||
this.setModeCustomization(customizationId, customization);
|
||||
break;
|
||||
case CustomizationScope.Default:
|
||||
this.setDefaultCustomization(customizationId, customization);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Invalid customization scope: ${scope}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all customizations for a given scope.
|
||||
*
|
||||
@ -303,68 +590,6 @@ export default class CustomizationService extends PubSubService {
|
||||
return result.$transform?.(this) || result;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Sets a mode-specific customization.
|
||||
*
|
||||
* This method allows you to define or update a customization that applies only to the current mode.
|
||||
* Mode customizations are temporary and isolated, reset whenever a mode changes.
|
||||
*
|
||||
* @param customizationId - The unique identifier for the customization.
|
||||
* @param customization - The customization object containing the desired settings.
|
||||
*/
|
||||
private setModeCustomization(customizationId: string, customization: Customization): void {
|
||||
const defaultCustomization = this.defaultCustomizations.get(customizationId);
|
||||
const modeCustomization = this.modeCustomizations.get(customizationId);
|
||||
const globCustomization = this.globalCustomizations.get(customizationId);
|
||||
|
||||
const sourceCustomization =
|
||||
modeCustomization || this._cloneIfNeeded(globCustomization) || defaultCustomization;
|
||||
|
||||
const result = this._update(sourceCustomization, customization);
|
||||
this.modeCustomizations.set(customizationId, result);
|
||||
|
||||
this.transformedCustomizations.clear();
|
||||
this._broadcastEvent(this.EVENTS.MODE_CUSTOMIZATION_MODIFIED, {
|
||||
buttons: this.modeCustomizations,
|
||||
button: this.modeCustomizations.get(customizationId),
|
||||
});
|
||||
}
|
||||
|
||||
private setGlobalCustomization(id: string, value: Customization): void {
|
||||
const defaultCustomization = this.defaultCustomizations.get(id);
|
||||
const globCustomization = this.globalCustomizations.get(id);
|
||||
|
||||
const sourceCustomization = this._cloneIfNeeded(globCustomization) || defaultCustomization;
|
||||
this.globalCustomizations.set(id, this._update(sourceCustomization, value));
|
||||
|
||||
this.transformedCustomizations.clear();
|
||||
this._broadcastEvent(this.EVENTS.GLOBAL_CUSTOMIZATION_MODIFIED, {
|
||||
buttons: this.defaultCustomizations,
|
||||
button: this.defaultCustomizations.get(id),
|
||||
});
|
||||
}
|
||||
|
||||
private setDefaultCustomization(id: string, value: Customization): void {
|
||||
if (this.defaultCustomizations.has(id)) {
|
||||
console.warn(`Trying to update existing default for customization ${id}`);
|
||||
}
|
||||
this.transformedCustomizations.clear();
|
||||
|
||||
const sourceCustomization = this.defaultCustomizations.get(id);
|
||||
this.defaultCustomizations.set(id, this._update(sourceCustomization, value));
|
||||
|
||||
this._broadcastEvent(this.EVENTS.DEFAULT_CUSTOMIZATION_MODIFIED, {
|
||||
buttons: this.defaultCustomizations,
|
||||
button: this.defaultCustomizations.get(id),
|
||||
});
|
||||
}
|
||||
|
||||
private _findExtensionValue(value: string) {
|
||||
const entry = this.extensionManager.getModuleEntry(value);
|
||||
return entry as { value: Customization };
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a custom command to be used in customization updates.
|
||||
* @param commandName - The name of the command (without the $ prefix)
|
||||
@ -381,40 +606,6 @@ export default class CustomizationService extends PubSubService {
|
||||
extend(commandName, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses immutability-helper to apply the user's commands (e.g. $set, $push, $apply, etc.)
|
||||
* Takes into account the 'mergeType' if it's explicitly 'Replace'; otherwise does a normal update.
|
||||
*/
|
||||
private _update(oldValue: Customization | undefined, newValue: Customization): Customization {
|
||||
if (!oldValue) {
|
||||
oldValue = undefined;
|
||||
}
|
||||
|
||||
// Use immutability-helper to apply the commands
|
||||
// if $ is not part of the value in the json string, then we just return the newValue
|
||||
if (!hasDollarKey(newValue)) {
|
||||
return newValue;
|
||||
}
|
||||
|
||||
const result = update(oldValue, newValue);
|
||||
return result;
|
||||
}
|
||||
|
||||
private _cloneIfNeeded(value: any) {
|
||||
// If it's null/undefined or not an object, return as is
|
||||
if (!value || typeof value !== 'object') {
|
||||
return value;
|
||||
}
|
||||
|
||||
// If it's an array, create a shallow copy
|
||||
if (Array.isArray(value)) {
|
||||
return [...value];
|
||||
}
|
||||
|
||||
// Otherwise create a shallow copy of the object
|
||||
return { ...value };
|
||||
}
|
||||
|
||||
_addReference(value?: any, type = CustomizationScope.Global): void {
|
||||
if (!value) {
|
||||
return;
|
||||
@ -451,6 +642,362 @@ export default class CustomizationService extends PubSubService {
|
||||
this._addReference(references, type);
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Private methods
|
||||
// ===========================================================================
|
||||
|
||||
private clearTransformedCustomizations(): void {
|
||||
super.reset();
|
||||
|
||||
const modeCustomizationKeys = Array.from(this.modeCustomizations.keys());
|
||||
for (const key of modeCustomizationKeys) {
|
||||
this.transformedCustomizations.delete(key);
|
||||
}
|
||||
|
||||
this.modeCustomizations.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Default loader for a `?customization=` module. Customization files are
|
||||
* **data**, not code: the file is fetched and parsed as JSONC (JSON with
|
||||
* comments / trailing commas, via JSON5 which is a superset). It is never
|
||||
* executed. Executable modules — plugins, modes and extensions — load only
|
||||
* through `pluginConfig.json`, never from the customization URL path.
|
||||
*/
|
||||
private async _urlDefaultImport(url: string): Promise<any> {
|
||||
if (typeof fetch !== 'function') {
|
||||
throw new Error(`No fetch implementation available to load customization ${url}`);
|
||||
}
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch customization ${url}: ${response.status} ${response.statusText}`
|
||||
);
|
||||
}
|
||||
const text = await response.text();
|
||||
return JSON5.parse(text);
|
||||
}
|
||||
|
||||
private _normalizeImportedCustomizationModule(imported: any): CustomizationModule {
|
||||
return imported && typeof imported === 'object' && 'customizations' in imported
|
||||
? imported
|
||||
: imported && typeof imported.default === 'object'
|
||||
? imported.default
|
||||
: imported;
|
||||
}
|
||||
|
||||
private _collectUrlDependencyRefs(module: CustomizationModule): string[] {
|
||||
const refs = new Set<string>();
|
||||
const payload = getUrlCustomizationModulePayload(module);
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return Array.from(refs);
|
||||
}
|
||||
const moduleRequires = (payload as any).requires;
|
||||
if (typeof moduleRequires === 'string' && moduleRequires) {
|
||||
refs.add(moduleRequires);
|
||||
} else if (Array.isArray(moduleRequires)) {
|
||||
for (const id of moduleRequires) {
|
||||
if (typeof id === 'string' && id) {
|
||||
refs.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(refs);
|
||||
}
|
||||
|
||||
private _urlDependencyToRequest(
|
||||
name: string,
|
||||
policy: CustomizationUrlPolicy
|
||||
): ValidatedCustomization | null {
|
||||
// `requires` entries are module names to load; each is validated/resolved like any
|
||||
// other URL customization request. Entries that don't resolve to a valid module
|
||||
// (e.g. a bare customization-key reference) are rejected by validation and
|
||||
// skipped — there is no namespace carve-out.
|
||||
const result = validateCustomizationRequests([name], policy);
|
||||
if (result.valid.length) {
|
||||
return result.valid[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private _urlCustomizationLoadOne(
|
||||
request: ValidatedCustomization,
|
||||
policy: CustomizationUrlPolicy,
|
||||
importFn: (url: string) => Promise<any>,
|
||||
logger: { warn: (...args: any[]) => void; error: (...args: any[]) => void },
|
||||
requestedSet: Set<string>,
|
||||
newlyLoaded: LoadedCustomization[]
|
||||
): Promise<LoadedCustomization | null> {
|
||||
const key = request.normalized;
|
||||
if (this._urlCustomizationLoaded.has(key)) {
|
||||
return Promise.resolve(this._urlCustomizationLoaded.get(key) || null);
|
||||
}
|
||||
if (this._urlCustomizationPending.has(key)) {
|
||||
return this._urlCustomizationPending.get(key)!;
|
||||
}
|
||||
|
||||
requestedSet.add(key);
|
||||
|
||||
const promise = this._urlCustomizationLoadOneBody(
|
||||
request,
|
||||
policy,
|
||||
importFn,
|
||||
logger,
|
||||
requestedSet,
|
||||
newlyLoaded
|
||||
);
|
||||
|
||||
this._urlCustomizationPending.set(key, promise);
|
||||
// Use then(success, failure) for cleanup — `finally` left rejections unhandled
|
||||
// with the current Promise polyfill in the Jest/Node test stack.
|
||||
promise.then(
|
||||
() => this._urlCustomizationPending.delete(key),
|
||||
() => this._urlCustomizationPending.delete(key)
|
||||
);
|
||||
return promise;
|
||||
}
|
||||
|
||||
private _urlCustomizationLoadOneBody(
|
||||
request: ValidatedCustomization,
|
||||
policy: CustomizationUrlPolicy,
|
||||
importFn: (url: string) => Promise<any>,
|
||||
logger: { warn: (...args: any[]) => void; error: (...args: any[]) => void },
|
||||
requestedSet: Set<string>,
|
||||
newlyLoaded: LoadedCustomization[]
|
||||
): Promise<LoadedCustomization | null> {
|
||||
const key = request.normalized;
|
||||
const importFailedSentinel = Symbol('importFailed');
|
||||
|
||||
let url: string;
|
||||
try {
|
||||
url = resolveCustomizationUrl(request, policy);
|
||||
} catch (err) {
|
||||
const msg = `[customizationUrl] failed to resolve "${request.raw}": ${(err as Error).message}`;
|
||||
logger.warn(msg);
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
return importFn(url)
|
||||
.catch(err => {
|
||||
logger.warn(
|
||||
`[customizationUrl] failed to import customization "${request.raw}" (${url})`,
|
||||
err
|
||||
);
|
||||
return importFailedSentinel;
|
||||
})
|
||||
.then(importedOrSentinel => {
|
||||
if (importedOrSentinel === importFailedSentinel) {
|
||||
return null;
|
||||
}
|
||||
const imported = importedOrSentinel;
|
||||
const module = this._normalizeImportedCustomizationModule(imported);
|
||||
if (!module || typeof module !== 'object') {
|
||||
const msg = `[customizationUrl] missing customization module "${request.raw}" (${url}): module is not an object`;
|
||||
logger.warn(msg);
|
||||
return null;
|
||||
}
|
||||
if (!getUrlCustomizationModulePayload(module)) {
|
||||
const msg = `[customizationUrl] missing customization module "${request.raw}" (${url}): no customizations payload`;
|
||||
logger.warn(msg);
|
||||
return null;
|
||||
}
|
||||
|
||||
const depRefs = this._collectUrlDependencyRefs(module);
|
||||
let depsChain: Promise<unknown> = Promise.resolve();
|
||||
for (const depRef of depRefs) {
|
||||
depsChain = depsChain.then(() => {
|
||||
const depRequest = this._urlDependencyToRequest(depRef, policy);
|
||||
if (!depRequest || requestedSet.has(depRequest.normalized)) {
|
||||
return undefined;
|
||||
}
|
||||
return this._urlCustomizationLoadOne(
|
||||
depRequest,
|
||||
policy,
|
||||
importFn,
|
||||
logger,
|
||||
requestedSet,
|
||||
newlyLoaded
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return depsChain.then(() => {
|
||||
const loaded: LoadedCustomization = { request, module, url };
|
||||
this._urlCustomizationLoaded.set(key, loaded);
|
||||
newlyLoaded.push(loaded);
|
||||
return loaded;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private _applyLoadedUrlCustomizationModules(loaded: LoadedCustomization[]): void {
|
||||
if (!loaded?.length) {
|
||||
return;
|
||||
}
|
||||
for (const entry of loaded) {
|
||||
const payload = getUrlCustomizationModulePayload(entry.module);
|
||||
if (payload?.global) {
|
||||
this.setCustomizations(payload.global, CustomizationScope.Global);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects the phase blocks for `phase` from every source, in apply order:
|
||||
* the structured app config first, then each resolved URL module in load
|
||||
* order. Used by {@link applyModeCustomizations}; the simpler `bootstrap` /
|
||||
* `global` phases go through {@link _applyPhase}.
|
||||
*/
|
||||
private _collectPhaseBlocks(phase: keyof PhasedCustomizationConfig): PhasedCustomizationConfig[] {
|
||||
const blocks: PhasedCustomizationConfig[] = [];
|
||||
const phased = this._getCustomizationConfig().phased;
|
||||
if (phased && phased[phase] !== undefined) {
|
||||
blocks.push(phased);
|
||||
}
|
||||
for (const entry of this._resolvedUrlModules) {
|
||||
const payload = getUrlCustomizationModulePayload(entry.module);
|
||||
if (payload && payload[phase] !== undefined) {
|
||||
blocks.push(payload);
|
||||
}
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/** Applies a Global-scoped phase (`bootstrap` / `global`) from all sources. */
|
||||
private _applyPhase(phase: 'bootstrap' | 'global'): void {
|
||||
for (const block of this._collectPhaseBlocks(phase)) {
|
||||
const value = block[phase] as CustomizationPhaseInput | undefined;
|
||||
if (value) {
|
||||
this.setCustomizations(value, CustomizationScope.Global);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method to set a single customization
|
||||
*/
|
||||
private _setCustomization(
|
||||
customizationId: string,
|
||||
customization: Customization,
|
||||
scope: CustomizationScope = CustomizationScope.Mode
|
||||
): void {
|
||||
// if (typeof customization === 'string') {
|
||||
// const extensionValue = this._findExtensionValue(customization);
|
||||
// customization = extensionValue.value;
|
||||
// }
|
||||
|
||||
switch (scope) {
|
||||
case CustomizationScope.Global:
|
||||
this.setGlobalCustomization(customizationId, customization);
|
||||
break;
|
||||
case CustomizationScope.Mode:
|
||||
this.setModeCustomization(customizationId, customization);
|
||||
break;
|
||||
case CustomizationScope.Default:
|
||||
this.setDefaultCustomization(customizationId, customization);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Invalid customization scope: ${scope}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Sets a mode-specific customization.
|
||||
*
|
||||
* This method allows you to define or update a customization that applies only to the current mode.
|
||||
* Mode customizations are temporary and isolated, reset whenever a mode changes.
|
||||
*
|
||||
* @param customizationId - The unique identifier for the customization.
|
||||
* @param customization - The customization object containing the desired settings.
|
||||
*/
|
||||
private setModeCustomization(customizationId: string, customization: Customization): void {
|
||||
const defaultCustomization = this.defaultCustomizations.get(customizationId);
|
||||
const modeCustomization = this.modeCustomizations.get(customizationId);
|
||||
const globCustomization = this.globalCustomizations.get(customizationId);
|
||||
|
||||
const sourceCustomization =
|
||||
modeCustomization || this._cloneIfNeeded(globCustomization) || defaultCustomization;
|
||||
|
||||
const result = this._update(sourceCustomization, customization);
|
||||
this.modeCustomizations.set(customizationId, result);
|
||||
|
||||
this.transformedCustomizations.clear();
|
||||
this._broadcastEvent(this.EVENTS.MODE_CUSTOMIZATION_MODIFIED, {
|
||||
buttons: this.modeCustomizations,
|
||||
button: this.modeCustomizations.get(customizationId),
|
||||
});
|
||||
}
|
||||
|
||||
private setGlobalCustomization(id: string, value: Customization): void {
|
||||
const defaultCustomization = this.defaultCustomizations.get(id);
|
||||
const globCustomization = this.globalCustomizations.get(id);
|
||||
|
||||
const sourceCustomization = this._cloneIfNeeded(globCustomization) || defaultCustomization;
|
||||
this.globalCustomizations.set(id, this._update(sourceCustomization, value));
|
||||
|
||||
this.transformedCustomizations.clear();
|
||||
this._broadcastEvent(this.EVENTS.GLOBAL_CUSTOMIZATION_MODIFIED, {
|
||||
buttons: this.globalCustomizations,
|
||||
button: this.globalCustomizations.get(id),
|
||||
});
|
||||
}
|
||||
|
||||
private setDefaultCustomization(id: string, value: Customization): void {
|
||||
// There are two inits now, without a clear between them, so we can't warn about existing defaults
|
||||
// if (this.defaultCustomizations.has(id)) {
|
||||
// console.warn(`Trying to update existing default for customization ${id}`);
|
||||
// }
|
||||
this.transformedCustomizations.clear();
|
||||
|
||||
const sourceCustomization = this.defaultCustomizations.get(id);
|
||||
this.defaultCustomizations.set(id, this._update(sourceCustomization, value));
|
||||
|
||||
this._broadcastEvent(this.EVENTS.DEFAULT_CUSTOMIZATION_MODIFIED, {
|
||||
buttons: this.defaultCustomizations,
|
||||
button: this.defaultCustomizations.get(id),
|
||||
});
|
||||
}
|
||||
|
||||
private _findExtensionValue(value: string) {
|
||||
const entry = this.extensionManager.getModuleEntry(value);
|
||||
return entry as { value: Customization };
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses immutability-helper to apply the user's commands (e.g. $set, $push, $apply, etc.)
|
||||
* Takes into account the 'mergeType' if it's explicitly 'Replace'; otherwise does a normal update.
|
||||
*/
|
||||
private _update(oldValue: Customization | undefined, newValue: Customization): Customization {
|
||||
if (!oldValue) {
|
||||
oldValue = undefined;
|
||||
}
|
||||
|
||||
// Use immutability-helper to apply the commands
|
||||
// if $ is not part of the value in the json string, then we just return the newValue
|
||||
if (!hasDollarKey(newValue)) {
|
||||
return newValue;
|
||||
}
|
||||
|
||||
const result = update(oldValue, newValue);
|
||||
return result;
|
||||
}
|
||||
|
||||
private _cloneIfNeeded(value: any) {
|
||||
// If it's null/undefined or not an object, return as is
|
||||
if (!value || typeof value !== 'object') {
|
||||
return value;
|
||||
}
|
||||
|
||||
// If it's an array, create a shallow copy
|
||||
if (Array.isArray(value)) {
|
||||
return [...value];
|
||||
}
|
||||
|
||||
// Otherwise create a shallow copy of the object
|
||||
return { ...value };
|
||||
}
|
||||
}
|
||||
|
||||
/** Add custom $filter command */
|
||||
@ -523,6 +1070,48 @@ extend('$filter', (query, original) => {
|
||||
return deepFilter(original, query);
|
||||
});
|
||||
|
||||
const PHASE_CONFIG_KEYS: Array<keyof PhasedCustomizationConfig> = [
|
||||
'requires',
|
||||
'bootstrap',
|
||||
'global',
|
||||
'mode',
|
||||
];
|
||||
|
||||
/**
|
||||
* Normalizes `appConfig.customizationService` into either:
|
||||
* - `{ phased }` — the structured, phase-tagged config, detected by
|
||||
* the presence of any of `requires` / `bootstrap`
|
||||
* / `global` / `mode`; or
|
||||
* - `{ legacyReferences }` — the legacy array / object-map form, which is
|
||||
* added (Global scope) during `init()` exactly as
|
||||
* before.
|
||||
*/
|
||||
export function normalizeCustomizationConfig(configuration: unknown): {
|
||||
phased?: PhasedCustomizationConfig;
|
||||
legacyReferences?: unknown;
|
||||
} {
|
||||
if (!configuration) {
|
||||
return {};
|
||||
}
|
||||
if (Array.isArray(configuration)) {
|
||||
return { legacyReferences: configuration };
|
||||
}
|
||||
if (typeof configuration === 'object') {
|
||||
const isPhased = PHASE_CONFIG_KEYS.some(key => key in (configuration as object));
|
||||
if (isPhased) {
|
||||
const phased: PhasedCustomizationConfig = {};
|
||||
for (const key of PHASE_CONFIG_KEYS) {
|
||||
if (key in (configuration as object)) {
|
||||
(phased as Record<string, unknown>)[key] = (configuration as Record<string, unknown>)[key];
|
||||
}
|
||||
}
|
||||
return { phased };
|
||||
}
|
||||
return { legacyReferences: configuration };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function hasDollarKey(value) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
|
||||
@ -0,0 +1,49 @@
|
||||
import {
|
||||
CUSTOMIZATION_URL_PREFIXES_KEY,
|
||||
customizationUrlDefaults,
|
||||
DEFAULT_PREFIX,
|
||||
} from './customizationUrlDefaults';
|
||||
import type { CustomizationUrlPolicy } from './customizationUrlDefaults';
|
||||
import { getUrlCustomizationModulePayload } from './getUrlCustomizationModulePayload';
|
||||
import {
|
||||
parseCustomizationParams,
|
||||
validateCustomizationRequests,
|
||||
normalizeCustomizationValue,
|
||||
} from './validate';
|
||||
import type { ValidatedCustomization, ValidationResult } from './validate';
|
||||
import { resolveCustomizationUrl } from './resolve';
|
||||
|
||||
/**
|
||||
* Builds the `?customization=` policy from the **app config** property
|
||||
* `customizationUrlPrefixes` (read off `extensionManager.appConfig`). This is
|
||||
* intentionally not a customization: customizations can be loaded from the URL,
|
||||
* so letting one define prefixes would let it widen its own allowlist. When the
|
||||
* property is absent the policy has no prefixes and the feature is off.
|
||||
*/
|
||||
export function getCustomizationUrlPolicy(customizationService: any): CustomizationUrlPolicy {
|
||||
const prefixes =
|
||||
customizationService?.extensionManager?.appConfig?.[CUSTOMIZATION_URL_PREFIXES_KEY];
|
||||
if (prefixes && typeof prefixes === 'object') {
|
||||
return { prefixes };
|
||||
}
|
||||
return customizationUrlDefaults;
|
||||
}
|
||||
|
||||
export {
|
||||
CUSTOMIZATION_URL_PREFIXES_KEY,
|
||||
customizationUrlDefaults,
|
||||
DEFAULT_PREFIX,
|
||||
getUrlCustomizationModulePayload,
|
||||
parseCustomizationParams,
|
||||
validateCustomizationRequests,
|
||||
normalizeCustomizationValue,
|
||||
resolveCustomizationUrl,
|
||||
};
|
||||
|
||||
export type {
|
||||
CustomizationUrlPolicy,
|
||||
ValidatedCustomization,
|
||||
ValidationResult,
|
||||
};
|
||||
|
||||
export type { CustomizationModule, LoadedCustomization, LoadOptions } from './customizationUrlTypes';
|
||||
@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Policy for the `?customization=` URL query parameter.
|
||||
*
|
||||
* The effective policy comes from the **app config** property
|
||||
* `customizationUrlPrefixes` (NOT a customization — a customization must never
|
||||
* be able to widen its own allowlist). When that property is absent the policy
|
||||
* has no prefixes, so the feature is **off by default**: any `?customization=`
|
||||
* value is rejected because its prefix is not configured.
|
||||
*
|
||||
* Shape:
|
||||
* - prefixes: map of prefix -> base URL used to resolve a customization value
|
||||
* to a fetched `.jsonc` data file. The `default` prefix (no slashes) is used
|
||||
* for values with no leading slash; every other prefix must start and end
|
||||
* with a slash (e.g. `/remote/`) and is matched against the leading
|
||||
* `/segment/` of the value.
|
||||
*
|
||||
* Example app config:
|
||||
* window.config = {
|
||||
* customizationUrlPrefixes: {
|
||||
* default: './customizations/',
|
||||
* '/remote/': 'https://cdn.example.com/ohif-customizations/',
|
||||
* },
|
||||
* };
|
||||
*/
|
||||
export interface CustomizationUrlPolicy {
|
||||
prefixes: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* App config property name holding the prefix allowlist. Read directly off
|
||||
* `appConfig` — deliberately not a customization key.
|
||||
*/
|
||||
export const CUSTOMIZATION_URL_PREFIXES_KEY = 'customizationUrlPrefixes';
|
||||
|
||||
export const DEFAULT_PREFIX = 'default';
|
||||
|
||||
/** Off by default: no prefixes are allowed until the app config configures them. */
|
||||
export const customizationUrlDefaults: CustomizationUrlPolicy = {
|
||||
prefixes: {},
|
||||
};
|
||||
|
||||
export default customizationUrlDefaults;
|
||||
@ -0,0 +1,56 @@
|
||||
import type { CustomizationUrlPolicy } from './customizationUrlDefaults';
|
||||
import type { ValidatedCustomization } from './validate';
|
||||
|
||||
/**
|
||||
* The value accepted by any single customization phase block. It is whatever
|
||||
* {@link CustomizationService.setCustomizations} accepts:
|
||||
* - an object map of `customizationId -> customization` (with optional
|
||||
* immutability-helper commands like `$set` / `$apply` / `$splice`), or
|
||||
* - an array that mixes string references (extension module ids resolved via
|
||||
* the ExtensionManager) and inline object maps.
|
||||
*/
|
||||
export type CustomizationPhaseInput = string[] | Record<string, any>;
|
||||
|
||||
/**
|
||||
* Mode-phase customizations, keyed by mode. The reserved `*` key (see
|
||||
* `GENERAL_MODE_KEY`) is the "general" block applied to every mode FIRST; any
|
||||
* other key is matched against the entered mode's `id` / `routeName` and applied
|
||||
* AFTER the general block, so a single mode can override the general values.
|
||||
*/
|
||||
export type ModePhaseCustomizations = Record<string, CustomizationPhaseInput>;
|
||||
|
||||
/**
|
||||
* Phase-tagged customization payload. The same shape is used by:
|
||||
* - URL-loaded customization modules (`?customization=` JSONC files), and
|
||||
* - the `appConfig.customizationService` structured config.
|
||||
*
|
||||
* Each block is applied at a distinct point in the app lifecycle so ordering is
|
||||
* deterministic regardless of when extensions / modes load:
|
||||
* - `requires` — other URL customization modules to resolve first.
|
||||
* - `bootstrap` — applied (Global scope) BEFORE extensions register.
|
||||
* - `global` — applied (Global scope) AFTER extensions register / init.
|
||||
* - `mode` — applied (Mode scope) on every mode enter; general first,
|
||||
* then the entered mode's specific block.
|
||||
*/
|
||||
export interface PhasedCustomizationConfig {
|
||||
requires?: string | string[];
|
||||
bootstrap?: CustomizationPhaseInput;
|
||||
global?: CustomizationPhaseInput;
|
||||
mode?: ModePhaseCustomizations;
|
||||
}
|
||||
|
||||
export interface CustomizationModule extends PhasedCustomizationConfig {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface LoadedCustomization {
|
||||
request: ValidatedCustomization;
|
||||
module: CustomizationModule;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface LoadOptions {
|
||||
policy?: CustomizationUrlPolicy;
|
||||
importFn?: (url: string) => Promise<any>;
|
||||
logger?: { warn: (...args: any[]) => void; error: (...args: any[]) => void };
|
||||
}
|
||||
@ -0,0 +1,41 @@
|
||||
import type { CustomizationModule, PhasedCustomizationConfig } from './customizationUrlTypes';
|
||||
|
||||
/**
|
||||
* Extracts the phase-tagged payload from a loaded customization module.
|
||||
*
|
||||
* A module is considered to carry a payload when it declares any of the
|
||||
* lifecycle phase blocks (`bootstrap` / `global` / `mode`) or a `requires`
|
||||
* edge. Returns `null` when none are present so callers can warn/skip a module
|
||||
* that does nothing.
|
||||
*/
|
||||
export function getUrlCustomizationModulePayload(
|
||||
module: CustomizationModule | null | undefined
|
||||
): PhasedCustomizationConfig | null {
|
||||
if (!module || typeof module !== 'object') {
|
||||
return null;
|
||||
}
|
||||
const hasBootstrap = isPhaseInput(module.bootstrap);
|
||||
const hasGlobal = isPhaseInput(module.global);
|
||||
const hasMode = module.mode && typeof module.mode === 'object' && !Array.isArray(module.mode);
|
||||
const hasRequires =
|
||||
typeof module.requires === 'string' ||
|
||||
(Array.isArray(module.requires) && module.requires.length > 0);
|
||||
|
||||
if (!hasBootstrap && !hasGlobal && !hasMode && !hasRequires) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...(hasBootstrap ? { bootstrap: module.bootstrap } : {}),
|
||||
...(hasGlobal ? { global: module.global } : {}),
|
||||
...(hasMode ? { mode: module.mode } : {}),
|
||||
...(hasRequires ? { requires: module.requires } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** A phase block is either an object map or an array of references. */
|
||||
function isPhaseInput(value: unknown): boolean {
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0;
|
||||
}
|
||||
return Boolean(value) && typeof value === 'object';
|
||||
}
|
||||
@ -1,3 +1,11 @@
|
||||
import CustomizationService from './CustomizationService';
|
||||
|
||||
export { GENERAL_MODE_KEY, normalizeCustomizationConfig } from './CustomizationService';
|
||||
export type {
|
||||
CustomizationModule,
|
||||
CustomizationPhaseInput,
|
||||
ModePhaseCustomizations,
|
||||
PhasedCustomizationConfig,
|
||||
} from './customizationUrlTypes';
|
||||
|
||||
export default CustomizationService;
|
||||
|
||||
@ -0,0 +1,87 @@
|
||||
import { resolveCustomizationUrl } from './resolve';
|
||||
import type { ValidatedCustomization } from './validate';
|
||||
|
||||
const policy = {
|
||||
prefixes: {
|
||||
default: './customizations/',
|
||||
remote: 'https://customizations.example.com/ohifCustomizations',
|
||||
relative: '/customAssets/',
|
||||
},
|
||||
};
|
||||
|
||||
function req(prefix: string, name: string): ValidatedCustomization {
|
||||
return {
|
||||
raw: name,
|
||||
normalized: `/${prefix}/${name}`,
|
||||
prefix,
|
||||
name,
|
||||
};
|
||||
}
|
||||
|
||||
describe('CustomizationService URL resolve', () => {
|
||||
let originalLocation: PropertyDescriptor | undefined;
|
||||
|
||||
beforeAll(() => {
|
||||
originalLocation = Object.getOwnPropertyDescriptor(window, 'location');
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: {
|
||||
origin: 'https://viewer.example.com',
|
||||
search: '',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (originalLocation) {
|
||||
Object.defineProperty(window, 'location', originalLocation);
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves /default/<name> against same-origin/base', () => {
|
||||
const url = resolveCustomizationUrl(req('default', 'veterinaryOverlay'), policy);
|
||||
expect(url.startsWith('https://viewer.example.com')).toBe(true);
|
||||
expect(url.endsWith('/customizations/veterinaryOverlay.jsonc')).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves an absolute trusted prefix to its remote URL', () => {
|
||||
const url = resolveCustomizationUrl(req('remote', 'veterinaryOverlay'), policy);
|
||||
expect(url).toBe(
|
||||
'https://customizations.example.com/ohifCustomizations/veterinaryOverlay.jsonc'
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves a relative absolute-path prefix against same origin', () => {
|
||||
const url = resolveCustomizationUrl(req('relative', 'foo'), policy);
|
||||
expect(url).toBe('https://viewer.example.com/customAssets/foo.jsonc');
|
||||
});
|
||||
|
||||
it('throws on unknown prefix', () => {
|
||||
expect(() => resolveCustomizationUrl(req('missing', 'x'), policy)).toThrow();
|
||||
});
|
||||
|
||||
it('throws when the name contains traversal', () => {
|
||||
expect(() =>
|
||||
resolveCustomizationUrl(req('default', '../escape'), policy)
|
||||
).toThrow(/traversal/);
|
||||
});
|
||||
|
||||
it('accepts names that already include .jsonc suffix', () => {
|
||||
const url = resolveCustomizationUrl(req('default', 'veterinary.jsonc'), policy);
|
||||
expect(url.endsWith('/customizations/veterinary.jsonc')).toBe(true);
|
||||
});
|
||||
|
||||
it('throws when an encoded traversal would escape the base after URL normalization', () => {
|
||||
// `%2e%2e` survives the literal `..` check but the URL parser collapses it to
|
||||
// `..`, which would otherwise escape the prefix directory.
|
||||
expect(() => resolveCustomizationUrl(req('default', '%2e%2e/secret'), policy)).toThrow(
|
||||
/escapes its configured prefix/
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when an encoded traversal escapes an absolute (remote) prefix', () => {
|
||||
expect(() =>
|
||||
resolveCustomizationUrl(req('remote', '%2e%2e/%2e%2e/evil'), policy)
|
||||
).toThrow(/escapes its configured prefix/);
|
||||
});
|
||||
});
|
||||
84
platform/core/src/services/CustomizationService/resolve.ts
Normal file
84
platform/core/src/services/CustomizationService/resolve.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import type { CustomizationUrlPolicy } from './customizationUrlDefaults';
|
||||
import type { ValidatedCustomization } from './validate';
|
||||
|
||||
const ABSOLUTE_URL_REGEX = /^([a-z][a-z0-9+.-]*:|\/\/)/i;
|
||||
|
||||
// Used only to parse same-origin/relative paths into a comparable URL. Any
|
||||
// host works since we compare origin + pathname against the base built the
|
||||
// same way; it is never part of the returned value.
|
||||
const PARSE_BASE = 'https://ohif.invalid';
|
||||
|
||||
function getViewerPublicUrl(): string {
|
||||
if (typeof window === 'undefined') {
|
||||
return '/';
|
||||
}
|
||||
return (window as any).PUBLIC_URL || '/';
|
||||
}
|
||||
|
||||
/**
|
||||
* Defense-in-depth containment check on the *final* resolved string. We parse
|
||||
* both the resolved URL and its configured base directory with the same URL
|
||||
* parser that `import()` uses — which collapses `.`/`..`/`%2e%2e` path segments
|
||||
* — and require the result to stay within the base directory. Validation should
|
||||
* already have rejected traversal, but parsing the final string here guarantees
|
||||
* a module can never load outside its allowlisted prefix even if an earlier
|
||||
* check is bypassed.
|
||||
*/
|
||||
function assertWithinBase(
|
||||
finalUrl: string,
|
||||
baseDirUrl: string,
|
||||
request: ValidatedCustomization
|
||||
): void {
|
||||
let resolved: URL;
|
||||
let baseDir: URL;
|
||||
try {
|
||||
resolved = new URL(finalUrl, PARSE_BASE);
|
||||
baseDir = new URL(baseDirUrl, PARSE_BASE);
|
||||
} catch {
|
||||
throw new Error(`Customization URL could not be parsed for "${request.raw}"`);
|
||||
}
|
||||
const baseDirPath = baseDir.pathname.endsWith('/') ? baseDir.pathname : `${baseDir.pathname}/`;
|
||||
const within = resolved.origin === baseDir.origin && resolved.pathname.startsWith(baseDirPath);
|
||||
if (!within) {
|
||||
throw new Error(`Customization "${request.raw}" escapes its configured prefix directory`);
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveCustomizationUrl(
|
||||
request: ValidatedCustomization,
|
||||
policy: CustomizationUrlPolicy
|
||||
): string {
|
||||
const prefixes = policy.prefixes || {};
|
||||
const base = prefixes[request.prefix];
|
||||
if (!base) {
|
||||
throw new Error(`Unknown customization prefix: ${request.prefix}`);
|
||||
}
|
||||
if (request.name.includes('..')) {
|
||||
throw new Error(`Customization name contains traversal: ${request.name}`);
|
||||
}
|
||||
|
||||
const fileName = request.name.endsWith('.jsonc') ? request.name : `${request.name}.jsonc`;
|
||||
const baseWithSlash = base.endsWith('/') ? base : `${base}/`;
|
||||
const joined = `${baseWithSlash}${fileName}`;
|
||||
|
||||
if (ABSOLUTE_URL_REGEX.test(base)) {
|
||||
assertWithinBase(joined, baseWithSlash, request);
|
||||
return joined;
|
||||
}
|
||||
|
||||
const origin =
|
||||
typeof window !== 'undefined' && window.location?.origin ? window.location.origin : '';
|
||||
const publicUrl = getViewerPublicUrl();
|
||||
const root = publicUrl?.startsWith('/') ? publicUrl : `/${publicUrl || ''}`;
|
||||
const strip = (value: string): string =>
|
||||
value.startsWith('./') ? value.slice(2) : value.startsWith('/') ? value.slice(1) : value;
|
||||
const relative = strip(joined);
|
||||
const relativeBase = strip(baseWithSlash);
|
||||
const rootWithSlash = root.endsWith('/') ? root : `${root}/`;
|
||||
const path = `${rootWithSlash}${relative}`;
|
||||
const baseDirPath = `${rootWithSlash}${relativeBase}`;
|
||||
|
||||
const finalUrl = origin ? `${origin}${path}` : path;
|
||||
assertWithinBase(finalUrl, origin ? `${origin}${baseDirPath}` : baseDirPath, request);
|
||||
return finalUrl;
|
||||
}
|
||||
160
platform/core/src/services/CustomizationService/validate.test.ts
Normal file
160
platform/core/src/services/CustomizationService/validate.test.ts
Normal file
@ -0,0 +1,160 @@
|
||||
import {
|
||||
parseCustomizationParams,
|
||||
normalizeCustomizationValue,
|
||||
validateCustomizationRequests,
|
||||
} from './validate';
|
||||
import { customizationUrlDefaults } from './customizationUrlDefaults';
|
||||
|
||||
describe('CustomizationService URL validate', () => {
|
||||
describe('parseCustomizationParams', () => {
|
||||
it('returns repeated and comma-delimited values flattened', () => {
|
||||
const params = new URLSearchParams();
|
||||
params.append('customization', 'a,b');
|
||||
params.append('customization', 'c');
|
||||
expect(parseCustomizationParams(params)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('matches the parameter key case-insensitively', () => {
|
||||
const params = new URLSearchParams();
|
||||
params.append('Customization', 'foo');
|
||||
params.append('CUSTOMIZATION', 'bar');
|
||||
expect(parseCustomizationParams(params)).toEqual(['foo', 'bar']);
|
||||
});
|
||||
|
||||
it('skips empty pieces and trims whitespace', () => {
|
||||
const params = new URLSearchParams();
|
||||
params.append('customization', ' a , , b ');
|
||||
expect(parseCustomizationParams(params)).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeCustomizationValue', () => {
|
||||
it('prepends /default/ for path-relative values', () => {
|
||||
expect(normalizeCustomizationValue('veterinaryOverlay')).toBe(
|
||||
'/default/veterinaryOverlay'
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves explicit /prefix/name forms', () => {
|
||||
expect(normalizeCustomizationValue('/remote/foo')).toBe('/remote/foo');
|
||||
});
|
||||
|
||||
it('returns null when there is no name part', () => {
|
||||
expect(normalizeCustomizationValue('/onlyPrefix')).toBeNull();
|
||||
expect(normalizeCustomizationValue('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateCustomizationRequests', () => {
|
||||
const policy = {
|
||||
...customizationUrlDefaults,
|
||||
prefixes: {
|
||||
default: './customizations/',
|
||||
'/remote/': 'https://customizations.example.com/ohifCustomizations',
|
||||
},
|
||||
};
|
||||
|
||||
it('accepts default-prefixed names', () => {
|
||||
const result = validateCustomizationRequests(['veterinary'], policy);
|
||||
expect(result.rejected).toEqual([]);
|
||||
expect(result.valid).toHaveLength(1);
|
||||
expect(result.valid[0].normalized).toBe('/default/veterinary');
|
||||
expect(result.valid[0].prefix).toBe('default');
|
||||
expect(result.valid[0].name).toBe('veterinary');
|
||||
});
|
||||
|
||||
it('accepts arbitrary logical names under a configured prefix', () => {
|
||||
const result = validateCustomizationRequests(['siteTheme2026'], policy);
|
||||
expect(result.rejected).toEqual([]);
|
||||
expect(result.valid).toHaveLength(1);
|
||||
expect(result.valid[0].name).toBe('siteTheme2026');
|
||||
});
|
||||
|
||||
it('accepts remote-prefixed names', () => {
|
||||
const result = validateCustomizationRequests(['/remote/veterinaryOverlay'], policy);
|
||||
expect(result.rejected).toEqual([]);
|
||||
expect(result.valid[0].prefix).toBe('/remote/');
|
||||
expect(result.valid[0].name).toBe('veterinaryOverlay');
|
||||
});
|
||||
|
||||
it('keeps multi-segment names under a slash prefix', () => {
|
||||
const result = validateCustomizationRequests(['/remote/siteA/theme'], policy);
|
||||
expect(result.rejected).toEqual([]);
|
||||
expect(result.valid[0].prefix).toBe('/remote/');
|
||||
expect(result.valid[0].name).toBe('siteA/theme');
|
||||
});
|
||||
|
||||
it('rejects values with .. traversal', () => {
|
||||
const result = validateCustomizationRequests(['../etc/passwd'], policy);
|
||||
expect(result.valid).toEqual([]);
|
||||
expect(result.rejected[0].reason).toMatch(/traversal/);
|
||||
});
|
||||
|
||||
it('rejects full URLs', () => {
|
||||
const result = validateCustomizationRequests(
|
||||
['http://evil.example.com/x', 'https://evil/x', '//evil/x'],
|
||||
policy
|
||||
);
|
||||
expect(result.valid).toEqual([]);
|
||||
expect(result.rejected).toHaveLength(3);
|
||||
for (const r of result.rejected) {
|
||||
expect(r.reason).toMatch(/full URLs/);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects unknown prefixes', () => {
|
||||
const result = validateCustomizationRequests(['/missing/x'], policy);
|
||||
expect(result.valid).toEqual([]);
|
||||
expect(result.rejected[0].reason).toMatch(/unknown prefix/);
|
||||
});
|
||||
|
||||
it('rejects an explicit /default/ prefix (the default prefix has no slashes)', () => {
|
||||
// The `default` prefix is only reachable by values without a leading slash.
|
||||
// A leading-slash value matches `/default/`, which is not a configured key.
|
||||
const result = validateCustomizationRequests(['/default/foo'], policy);
|
||||
expect(result.valid).toEqual([]);
|
||||
expect(result.rejected[0].reason).toMatch(/unknown prefix/);
|
||||
});
|
||||
|
||||
it('rejects unsafe name segments', () => {
|
||||
const result = validateCustomizationRequests(['foo/./bar'], policy);
|
||||
expect(result.valid).toEqual([]);
|
||||
expect(result.rejected[0].reason).toMatch(/unsafe/);
|
||||
});
|
||||
|
||||
it('rejects percent-encoded traversal that decodes to ".."', () => {
|
||||
// URLSearchParams decodes one layer, so a double-encoded `..` arrives here
|
||||
// as `%2e%2e`; the WHATWG URL parser would normalize that to `..`.
|
||||
const result = validateCustomizationRequests(
|
||||
['foo/%2e%2e/%2e%2e/app-config', '/default/%2E%2E/secret'],
|
||||
policy
|
||||
);
|
||||
expect(result.valid).toEqual([]);
|
||||
expect(result.rejected).toHaveLength(2);
|
||||
for (const r of result.rejected) {
|
||||
expect(r.reason).toMatch(/traversal/);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects percent-encoded slashes that would inject extra path segments', () => {
|
||||
const result = validateCustomizationRequests(['/default/foo%2f..%2fbar'], policy);
|
||||
expect(result.valid).toEqual([]);
|
||||
expect(result.rejected[0].reason).toMatch(/traversal/);
|
||||
});
|
||||
|
||||
it('rejects encoded full URLs', () => {
|
||||
const result = validateCustomizationRequests(['https%3A%2F%2Fevil.example.com/x'], policy);
|
||||
expect(result.valid).toEqual([]);
|
||||
expect(result.rejected[0].reason).toMatch(/full URLs/);
|
||||
});
|
||||
|
||||
it('rejects malformed percent-encoding', () => {
|
||||
const result = validateCustomizationRequests(['/default/foo%zz', '/default/bar%'], policy);
|
||||
expect(result.valid).toEqual([]);
|
||||
expect(result.rejected).toHaveLength(2);
|
||||
for (const r of result.rejected) {
|
||||
expect(r.reason).toMatch(/percent-encoding/);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
177
platform/core/src/services/CustomizationService/validate.ts
Normal file
177
platform/core/src/services/CustomizationService/validate.ts
Normal file
@ -0,0 +1,177 @@
|
||||
import type { CustomizationUrlPolicy } from './customizationUrlDefaults';
|
||||
import { DEFAULT_PREFIX } from './customizationUrlDefaults';
|
||||
|
||||
export interface ValidatedCustomization {
|
||||
raw: string;
|
||||
normalized: string;
|
||||
prefix: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: ValidatedCustomization[];
|
||||
rejected: { raw: string; reason: string }[];
|
||||
}
|
||||
|
||||
const FULL_URL_REGEX = /^([a-z][a-z0-9+.-]*:|\/\/)/i;
|
||||
|
||||
/**
|
||||
* Fully percent-decode a customization value so validation runs against the
|
||||
* string the URL parser (used by `import()`) ultimately resolves — not the
|
||||
* escaped form. This is what blocks encoded traversal such as `%2e%2e`
|
||||
* (decodes to `..`) or `%2f` (decodes to `/`): the WHATWG URL parser treats
|
||||
* `%2e%2e` as a `..` path segment and would otherwise let a value escape the
|
||||
* configured prefix directory.
|
||||
*
|
||||
* Returns the stable decoded string, or `null` when the value is malformed
|
||||
* (invalid escape), still contains a literal `%` after decoding, or nests
|
||||
* encoding too deeply to settle. Legitimate customization names are plain
|
||||
* identifiers/paths and never need percent-encoding, so rejecting these is safe.
|
||||
*/
|
||||
export function fullyDecodeValue(value: string): string | null {
|
||||
let current = value;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (!current.includes('%')) {
|
||||
return current;
|
||||
}
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(current);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (decoded === current) {
|
||||
// Stable but still contains a literal `%`; treat as unsafe.
|
||||
return null;
|
||||
}
|
||||
current = decoded;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseCustomizationParams(
|
||||
params: URLSearchParams,
|
||||
paramKey = 'customization'
|
||||
): string[] {
|
||||
const out: string[] = [];
|
||||
const keys = Array.from(new Set(params.keys()));
|
||||
for (const key of keys) {
|
||||
if (key.toLowerCase() !== paramKey.toLowerCase()) {
|
||||
continue;
|
||||
}
|
||||
for (const raw of params.getAll(key)) {
|
||||
if (!raw) continue;
|
||||
for (const piece of raw.split(',')) {
|
||||
const trimmed = piece.trim();
|
||||
if (trimmed) {
|
||||
out.push(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a customization value into its prefix and name.
|
||||
*
|
||||
* - A value with **no leading slash** uses the special `default` prefix and the
|
||||
* whole value is the name (e.g. `abc/def` -> prefix `default`, name `abc/def`).
|
||||
* - A value with a **leading slash** is matched against its first `/segment/`,
|
||||
* which becomes the prefix (slashes included), and the remainder is the name
|
||||
* (e.g. `/remote/def` -> prefix `/remote/`, name `def`). This is why every
|
||||
* prefix other than `default` must start and end with a slash.
|
||||
*
|
||||
* Returns `null` when there is no usable name part.
|
||||
*/
|
||||
export function splitPrefixAndName(
|
||||
value: string
|
||||
): { prefix: string; name: string } | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
if (!value.startsWith('/')) {
|
||||
return { prefix: DEFAULT_PREFIX, name: value };
|
||||
}
|
||||
const rest = value.slice(1);
|
||||
const slashIdx = rest.indexOf('/');
|
||||
if (slashIdx <= 0) {
|
||||
return null;
|
||||
}
|
||||
const segment = rest.slice(0, slashIdx);
|
||||
const name = rest.slice(slashIdx + 1);
|
||||
if (!segment || !name) {
|
||||
return null;
|
||||
}
|
||||
return { prefix: `/${segment}/`, name };
|
||||
}
|
||||
|
||||
function buildNormalized(prefix: string, name: string): string {
|
||||
return prefix === DEFAULT_PREFIX ? `/${DEFAULT_PREFIX}/${name}` : `${prefix}${name}`;
|
||||
}
|
||||
|
||||
export function normalizeCustomizationValue(value: string): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const split = splitPrefixAndName(value.trim());
|
||||
if (!split) {
|
||||
return null;
|
||||
}
|
||||
return buildNormalized(split.prefix, split.name);
|
||||
}
|
||||
|
||||
function hasUnsafeNameSegments(name: string): boolean {
|
||||
if (!name.trim()) {
|
||||
return true;
|
||||
}
|
||||
return name.split('/').some(seg => !seg || seg === '.' || seg === '..' || seg.includes('..'));
|
||||
}
|
||||
|
||||
export function validateCustomizationRequests(
|
||||
raws: string[],
|
||||
policy: CustomizationUrlPolicy
|
||||
): ValidationResult {
|
||||
const result: ValidationResult = { valid: [], rejected: [] };
|
||||
const prefixes = policy.prefixes || {};
|
||||
|
||||
for (const raw of raws) {
|
||||
// Validate against the fully decoded value so percent-encoded traversal
|
||||
// (e.g. `%2e%2e`, `%2f`) cannot slip past the checks below and escape the
|
||||
// configured prefix once the URL parser normalizes the import path.
|
||||
const decoded = fullyDecodeValue(raw);
|
||||
if (decoded === null) {
|
||||
result.rejected.push({ raw, reason: 'contains malformed or unsupported percent-encoding' });
|
||||
continue;
|
||||
}
|
||||
if (decoded.includes('..')) {
|
||||
result.rejected.push({ raw, reason: 'contains ".." traversal segment' });
|
||||
continue;
|
||||
}
|
||||
if (FULL_URL_REGEX.test(decoded)) {
|
||||
result.rejected.push({ raw, reason: 'full URLs are not permitted' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const split = splitPrefixAndName(decoded.trim());
|
||||
if (!split) {
|
||||
result.rejected.push({ raw, reason: 'could not be split into a prefix and name' });
|
||||
continue;
|
||||
}
|
||||
const { prefix, name } = split;
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(prefixes, prefix)) {
|
||||
result.rejected.push({ raw, reason: `unknown prefix "${prefix}"` });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hasUnsafeNameSegments(name)) {
|
||||
result.rejected.push({ raw, reason: 'invalid or unsafe customization name' });
|
||||
continue;
|
||||
}
|
||||
|
||||
result.valid.push({ raw, normalized: buildNormalized(prefix, name), prefix, name });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@ -191,7 +191,7 @@ export class MultiMonitorService {
|
||||
* Try moving the screen to the correct location - this will only work with
|
||||
* screens opened with openWindow containing no more than 1 tab.
|
||||
*/
|
||||
public async onModeEnter() {
|
||||
public onModeEnter() {
|
||||
this.setBasePath();
|
||||
|
||||
if (
|
||||
|
||||
@ -21,6 +21,8 @@ import { MultiMonitorService } from './MultiMonitorService';
|
||||
|
||||
import type Services from '../types/Services';
|
||||
|
||||
export * from './CustomizationService/customizationUrl';
|
||||
|
||||
export {
|
||||
Services,
|
||||
MeasurementService,
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/no-namespace */
|
||||
import HangingProtocolServiceType from '../services/HangingProtocolService';
|
||||
import CustomizationServiceType from '../services/CustomizationService';
|
||||
import type { PhasedCustomizationConfig } from '../services/CustomizationService';
|
||||
import MeasurementServiceType from '../services/MeasurementService';
|
||||
import ViewportGridServiceType from '../services/ViewportGridService';
|
||||
import ToolbarServiceType from '../services/ToolBarService';
|
||||
@ -85,7 +86,29 @@ declare global {
|
||||
export interface Config {
|
||||
studyBrowserMode?: 'all' | 'primary';
|
||||
routerBasename?: string;
|
||||
customizationService?: CustomizationServiceType;
|
||||
/**
|
||||
* Startup customizations. Two forms are accepted:
|
||||
*
|
||||
* - **Legacy**: an array of references / object map applied to the Global
|
||||
* scope during `init()` (e.g.
|
||||
* `['@ohif/extension-default.customizationModule.datasources']`).
|
||||
* - **Phase-tagged** ({@link PhasedCustomizationConfig}): an object with
|
||||
* any of `requires` / `bootstrap` / `global` / `mode`. `requires`
|
||||
* pulls in URL-style customization data files; `bootstrap` /
|
||||
* `global` apply (Global scope) before / after extensions register; and
|
||||
* `mode` applies (Mode scope) per mode on entry — the `*` block to all
|
||||
* modes first, then a block keyed by the mode id / routeName.
|
||||
*/
|
||||
customizationService?: PhasedCustomizationConfig | string[] | Record<string, unknown>;
|
||||
/**
|
||||
* Allowlist of prefixes for the `?customization=` URL parameter, mapping a
|
||||
* prefix to a base URL/path. The `default` prefix (no slashes) handles
|
||||
* values with no leading slash; every other prefix must start and end with
|
||||
* a slash (e.g. `/remote/`). Intentionally an app-config property and not a
|
||||
* customization so a URL-loaded customization cannot widen its own
|
||||
* allowlist. Absent (the default) means `?customization=` is disabled.
|
||||
*/
|
||||
customizationUrlPrefixes?: Record<string, string>;
|
||||
extensions?: string[];
|
||||
modes?: string[];
|
||||
experimentalStudyBrowserSort?: boolean;
|
||||
|
||||
22
platform/core/src/utils/formatValue.js
Normal file
22
platform/core/src/utils/formatValue.js
Normal file
@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Formats values for safe text display.
|
||||
*/
|
||||
export default function formatValue(value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && typeof value.Alphabetic === 'string') {
|
||||
return value.Alphabetic;
|
||||
}
|
||||
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
40
platform/core/src/utils/formatValue.test.js
Normal file
40
platform/core/src/utils/formatValue.test.js
Normal file
@ -0,0 +1,40 @@
|
||||
import formatValue from './formatValue';
|
||||
|
||||
describe('formatValue', () => {
|
||||
it('returns null for null or undefined', () => {
|
||||
expect(formatValue(null)).toBeNull();
|
||||
expect(formatValue(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns strings unchanged, including empty strings', () => {
|
||||
expect(formatValue('horse')).toBe('horse');
|
||||
expect(formatValue('')).toBe('');
|
||||
});
|
||||
|
||||
it('extracts the Alphabetic component of a PersonName object', () => {
|
||||
expect(formatValue({ Alphabetic: 'Doe^John' })).toBe('Doe^John');
|
||||
});
|
||||
|
||||
it('coerces numbers to strings, including zero', () => {
|
||||
expect(formatValue(0)).toBe('0');
|
||||
expect(formatValue(42)).toBe('42');
|
||||
});
|
||||
|
||||
it('coerces booleans to strings', () => {
|
||||
expect(formatValue(true)).toBe('true');
|
||||
expect(formatValue(false)).toBe('false');
|
||||
});
|
||||
|
||||
it('returns null for plain objects so they never render as [object Object]', () => {
|
||||
expect(formatValue({})).toBeNull();
|
||||
expect(formatValue({ foo: 'bar' })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for objects whose Alphabetic is not a string', () => {
|
||||
expect(formatValue({ Alphabetic: 123 })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for arrays', () => {
|
||||
expect(formatValue(['a', 'b'])).toBeNull();
|
||||
});
|
||||
});
|
||||
@ -15,6 +15,7 @@ import isDicomUid from './isDicomUid';
|
||||
import formatDate from './formatDate';
|
||||
import formatTime from './formatTime';
|
||||
import formatPN from './formatPN';
|
||||
import formatValue from './formatValue';
|
||||
import generateAcceptHeader from './generateAcceptHeader';
|
||||
import resolveObjectPath from './resolveObjectPath';
|
||||
import hierarchicalListUtils from './hierarchicalListUtils';
|
||||
@ -73,6 +74,7 @@ const utils = {
|
||||
formatDate,
|
||||
formatTime,
|
||||
formatPN,
|
||||
formatValue,
|
||||
b64toBlob,
|
||||
urlUtil,
|
||||
imageIdToURI,
|
||||
@ -117,6 +119,7 @@ export {
|
||||
absoluteUrl,
|
||||
sortBy,
|
||||
formatDate,
|
||||
formatValue,
|
||||
writeScript,
|
||||
b64toBlob,
|
||||
urlUtil,
|
||||
|
||||
@ -0,0 +1,203 @@
|
||||
---
|
||||
sidebar_position: 7
|
||||
sidebar_label: Customization URL & config lockdown
|
||||
title: '?customization= URL parameter and the secure default config'
|
||||
---
|
||||
|
||||
# `?customization=` URL parameter and the secure default config
|
||||
|
||||
3.13 adds the ability to load customizations at runtime from the
|
||||
`?customization=` URL parameter, and as part of that it **locks down the default
|
||||
configuration**. Both changes can affect existing deployments.
|
||||
|
||||
## `?customization=` is off by default
|
||||
|
||||
A `?customization=` value loads a **JSONC data file** (JSON with comments /
|
||||
trailing commas) and applies its `global` payload as global customizations. The
|
||||
file is fetched and parsed as **data — it is never executed**. Executable code
|
||||
(plugins, modes, extensions) still loads only through `pluginConfig.json`.
|
||||
|
||||
The feature is **off until you configure an allowlist of prefixes** in the app
|
||||
config. This is an **app-config property, not a customization** — a customization
|
||||
could itself be loaded from the URL, so it must not be able to widen its own
|
||||
allowlist.
|
||||
|
||||
```js
|
||||
window.config = {
|
||||
customizationUrlPrefixes: {
|
||||
// The `default` prefix (no slashes) handles values with no leading slash.
|
||||
default: './customizations/', // ?customization=ctPresets -> ./customizations/ctPresets.jsonc
|
||||
// Every other prefix MUST start and end with a slash and is matched against
|
||||
// the leading `/segment/` of the value.
|
||||
'/remote/': 'https://cdn.example.com/ohif-customizations/', // ?customization=/remote/siteA
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Resolution rules:
|
||||
|
||||
- `?customization=ctPresets` → `default` prefix → `./customizations/ctPresets.jsonc`
|
||||
- `?customization=/remote/siteA` → `/remote/` prefix → `https://cdn.example.com/ohif-customizations/siteA.jsonc`
|
||||
- **A value whose prefix is not configured throws and aborts startup** rather than
|
||||
being silently ignored. With no `customizationUrlPrefixes` configured, *any*
|
||||
`?customization=` value throws. (Note: because the `default` prefix has no
|
||||
slashes, an explicit `/default/x` is treated as the `/default/` prefix and is
|
||||
rejected unless you configure that key — use the bare `x` form instead.)
|
||||
|
||||
If you previously enabled this feature via the `ohif.customizationUrl`
|
||||
**customization** (an early iteration of this PR), move the `prefixes` map to the
|
||||
top-level `customizationUrlPrefixes` **app-config** property and drop the
|
||||
`prefixes` wrapper key:
|
||||
|
||||
```js
|
||||
// Before (customization — no longer read)
|
||||
customizationService: [{ 'ohif.customizationUrl': { $set: { prefixes: { default: './customizations/' } } } }],
|
||||
|
||||
// After (app config property)
|
||||
customizationUrlPrefixes: { default: './customizations/' },
|
||||
```
|
||||
|
||||
## Phase-tagged customizations (lifecycle ordering)
|
||||
|
||||
A customization module — whether loaded from a `?customization=` data file or
|
||||
declared inline in `appConfig.customizationService` — can tag its payload with
|
||||
the lifecycle phase it should be applied in. This makes ordering deterministic
|
||||
regardless of when extensions and modes load, and lets a single source target
|
||||
the bootstrap, global, and mode-entry time frames at once.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
// Other customization data files to resolve FIRST (depth-first). Each entry is
|
||||
// itself a `?customization=` value — a customization module name resolved
|
||||
// through the same `customizationUrlPrefixes` rules — NOT a customization id.
|
||||
// So `"requiredCustomizationToLoad"` fetches
|
||||
// ./customizations/requiredCustomizationToLoad.jsonc (via the `default` prefix)
|
||||
// and applies its phase blocks before this file's.
|
||||
"requires": ["requiredCustomizationToLoad"],
|
||||
|
||||
// Applied (Global scope) BEFORE extensions register — in place while they init.
|
||||
"bootstrap": { "someId": { "$set": "value" } },
|
||||
|
||||
// Applied (Global scope) AFTER extensions register / init — so `$apply`-style
|
||||
// merges can build on extension-provided defaults.
|
||||
"global": { "workList.columns": { "$splice": [[1, 0, { "id": "patientBirthDate", "meta": { "label": "Birth Date" } }]] } },
|
||||
|
||||
// Applied (Mode scope) on EVERY mode enter. The reserved `*` (general) block
|
||||
// is applied first; a block keyed by the entered mode's id / routeName is
|
||||
// applied after it, so a single mode can override the general values.
|
||||
"mode": {
|
||||
"*": { "someId": { "$set": "all modes" } },
|
||||
"viewer": { "someId": { "$set": "viewer only" } }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The same shape is accepted by the app config. The legacy array / object form is
|
||||
still supported and is applied to the Global scope during `init()` exactly as
|
||||
before:
|
||||
|
||||
```js
|
||||
window.config = {
|
||||
// `customizationUrlPrefixes` is a top-level app-config (window.config) property,
|
||||
// deliberately NOT part of `customizationService`. The `customizationService`
|
||||
// block below — and any customization loaded from a `?customization=` URL — can
|
||||
// never read or widen this allowlist, because a URL-loaded customization must
|
||||
// not be able to grant itself new load locations. It can only be changed here,
|
||||
// in the global config, which is not itself updatable by any customization.
|
||||
customizationUrlPrefixes: { default: './customizations/' },
|
||||
customizationService: {
|
||||
requires: ['patientBirthDate'], // resolves ./customizations/patientBirthDate.jsonc
|
||||
global: [ // mixes string references and inline maps
|
||||
'@ohif/extension-default.customizationModule.datasources',
|
||||
{ 'workList.variant': 'default' },
|
||||
],
|
||||
mode: {
|
||||
'*': { 'someId': { $set: 'all modes' } },
|
||||
viewer: { 'someId': { $set: 'viewer only' } },
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
How the phases map onto the boot sequence:
|
||||
|
||||
| Phase | When | Scope |
|
||||
| --- | --- | --- |
|
||||
| `requires` | resolved up front (before extensions register) | — (loads other modules) |
|
||||
| `bootstrap` | before `extensionManager.registerExtensions` | Global |
|
||||
| `global` | after extensions register + `customizationService.init` | Global |
|
||||
| `mode` | on each mode enter, after the mode scope is reset | Mode |
|
||||
|
||||
All `?customization=` data files and `requires` are **fetched once, up front**
|
||||
(well before any mode loads); only the *application* of each phase block is
|
||||
deferred to its lifecycle point.
|
||||
|
||||
> Note: a `?customization=` data file is JSON and cannot carry render functions.
|
||||
> The WorkList expands a serializable column spec (an entry with an `id` and
|
||||
> `meta` but no `accessorFn`/`cell`) into a display-only text column that reads
|
||||
> `row[id]`, which is how the `patientBirthDate` example above renders.
|
||||
|
||||
## `config/default.js` is now a secure, minimal baseline
|
||||
|
||||
`config/default.js` — what a plain production build with no `APP_CONFIG` emits —
|
||||
is now deliberately locked down. The most impactful part of this is the **data
|
||||
source list**, which previously shipped seven sources and now ships **one**.
|
||||
|
||||
### Data sources removed from `config/default.js`
|
||||
|
||||
| `sourceName` | Type | Why it was removed |
|
||||
| --- | --- | --- |
|
||||
| `ohif2` | DICOMweb (AWS CloudFront `dd14fa38…`) | Extra read-only demo server; not needed in a baseline. |
|
||||
| `ohif3` | DICOMweb (AWS CloudFront `d3t6nz73…`) | Extra read-only demo server; not needed in a baseline. |
|
||||
| `local5000` | DICOMweb (`http://localhost:5000`) | Points at a local dev PACS that does not exist in a real deployment. |
|
||||
| `orthanc` | DICOMweb (`http://localhost/pacs/dicom-web`) | Points at a local dev PACS that does not exist in a real deployment. |
|
||||
| `dicomjson` | Runtime `?url=` JSON metadata | Loads metadata from an arbitrary `?url=`; widens the attack surface of a default build. Gate with `dangerouslyAllowedOriginsForAuthenticatedEnvironments` if you re-enable it. |
|
||||
| `dicomwebproxy` | Runtime `?url=` delegating proxy | Driven by `?url=`; same attack-surface concern as `dicomjson`. |
|
||||
| `dicomlocal` | Local file (drag-and-drop) | Loads DICOM files from the user's machine; not appropriate for a locked-down baseline. |
|
||||
|
||||
### What remains
|
||||
|
||||
- A single **read-only demo DICOMweb source** (`sourceName: 'ohif'`,
|
||||
`defaultDataSourceName: 'ohif'`) — replace its `wadoRoot` / `qidoRoot` /
|
||||
`wadoUriRoot` with your own DICOMweb server.
|
||||
|
||||
### Other lockdowns in `config/default.js`
|
||||
|
||||
- The `multimonitor` layouts were removed.
|
||||
- `?customization=` is off (no `customizationUrlPrefixes`).
|
||||
- `dangerouslyUseDynamicConfig` (the `configUrl` query parameter) is off.
|
||||
|
||||
### If you relied on the old `default.js`
|
||||
|
||||
If your deployment relied on `default.js` shipping the local/`?url=` data sources
|
||||
or the full demo source list, you have two options:
|
||||
|
||||
- **Point the dev/demo configs at it**: copy the `dataSources` entries you need
|
||||
from `config/dev.js` or `config/netlify.js` into your own config, or
|
||||
- **Build with a different config**: set `APP_CONFIG` to your own config file at
|
||||
build time (`APP_CONFIG=config/my-site.js pnpm run build`).
|
||||
|
||||
## New `config/dev.js`; dev server no longer uses `default.js`
|
||||
|
||||
- **`config/dev.js`** (new) is the full-featured local-development config: every
|
||||
data source enabled and `?customization=` turned on. The dev-server scripts
|
||||
(`pnpm run dev`, `dev:fast`, `start`) now default to `config/dev.js`.
|
||||
- **`config/netlify.js`** is the public demo / Netlify deploy config: the full
|
||||
data source set plus `customizationUrlPrefixes: { default: './customizations/' }`.
|
||||
- **`config/default.js`** remains the fallback for a real production build
|
||||
(`pnpm run build` with no `APP_CONFIG`).
|
||||
|
||||
### `APP_CONFIG` is honored, not clobbered
|
||||
|
||||
The default config is selected by the build (`webpack.pwa.js` / `rsbuild.config.ts`),
|
||||
**not** hard-coded into the npm scripts, so an explicit `APP_CONFIG` always wins:
|
||||
|
||||
- `pnpm run dev` (no `APP_CONFIG`) → `config/dev.js`
|
||||
- `APP_CONFIG=config/local_orthanc.js pnpm run dev` → **`config/local_orthanc.js`**
|
||||
- `pnpm run build` (no `APP_CONFIG`) → `config/default.js`
|
||||
- `APP_CONFIG=config/my-site.js pnpm run build` → **`config/my-site.js`**
|
||||
|
||||
The rule is: **the dev server defaults to `config/dev.js` and a production build
|
||||
to `config/default.js`, but any explicit `APP_CONFIG` overrides the default.** So
|
||||
if you maintain your own dev tooling that expected `pnpm run dev` to use
|
||||
`config/default.js`, just set `APP_CONFIG=config/default.js` explicitly.
|
||||
@ -236,6 +236,20 @@ When a customization is retrieved:
|
||||
As you have guessed the `.setCustomizations` accept a second argument which is the scope. By default it is set to `mode`.
|
||||
|
||||
|
||||
## Areas of Customization
|
||||
|
||||
Use this introduction page for the core model (scope, priority, and syntax), then refer to focused pages for specific areas:
|
||||
|
||||
- [Specific Customizations](./specificCustomizations.md): Built-in keys such as `ohif.preserveCustomizationKeys`, plus the `customizationUrlPrefixes` app-config allowlist that drives `?customization=` (off by default) and `requires` behavior, and how URL-loaded files interact with bootstrap and SPA navigation (intended one-time load per page).
|
||||
- [Custom Routes](./customRoutes.md): Route-level customization through `routes.customRoutes`.
|
||||
- [Context Menu](./contextMenu.md): Context menu structures and interaction customization.
|
||||
- [Study Browser](./StudyBrowser.md): Study browser-specific configuration values.
|
||||
- [Viewport Overlay](./viewportOverlay.md): Overlay item configuration and layout.
|
||||
- [Viewport Scrollbar](./ViewportScrollbar.md): Scrollbar behavior and display customizations.
|
||||
- [Measurements](./Measurements.md): Measurement-related customization surface.
|
||||
- [Segmentation](./Segmentation.md): Segmentation-specific customization options.
|
||||
- [Advanced Customization](./advanced.md): `inheritsFrom`, `$transform`, and compositional patterns.
|
||||
|
||||
## Customization Syntax
|
||||
|
||||
|
||||
|
||||
@ -0,0 +1,234 @@
|
||||
---
|
||||
title: Specific Customizations
|
||||
summary: Documentation for specific built-in customization keys, including URL parameter preservation and URL-driven customization module loading.
|
||||
sidebar_position: 9
|
||||
---
|
||||
|
||||
# Specific Customizations
|
||||
|
||||
This page documents concrete customization keys that have app-level behavior.
|
||||
|
||||
## `ohif.preserveCustomizationKeys`
|
||||
|
||||
- **Purpose**: Controls which query-string keys should be preserved while navigating between worklist and viewer routes.
|
||||
- **Default behavior**: The app always preserves:
|
||||
- `configUrl`
|
||||
- `multimonitor`
|
||||
- `screenNumber`
|
||||
- `hangingProtocolId`
|
||||
- `customization`
|
||||
- **How this customization is applied**: The value from `ohif.preserveCustomizationKeys` is appended to the default list above (it does not replace the defaults).
|
||||
|
||||
Example:
|
||||
|
||||
```js
|
||||
window.config = {
|
||||
customizationService: [
|
||||
{
|
||||
'ohif.preserveCustomizationKeys': {
|
||||
$set: ['customizationAlt', 'experimentFlag'],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
With this example, navigation preserves the default keys plus `customizationAlt` and `experimentFlag`.
|
||||
|
||||
## `customizationUrlPrefixes` (app config)
|
||||
|
||||
- **Purpose**: Allowlist of prefixes that `?customization=` values may resolve against. The `?customization=` feature is **off until this is configured**.
|
||||
- **It is an app-config property, not a customization.** Because a customization can itself be loaded from the URL, letting one define prefixes would let it widen its own allowlist — so the allowlist lives on `window.config` directly and is never read from `customizationService`.
|
||||
- **Prefix model**:
|
||||
- The special `default` prefix (no slashes) is used for values **without** a leading slash; the whole value is the file name (e.g. `ctAbdomen`, or `siteA/theme`).
|
||||
- Every other prefix **must start and end with a slash** (e.g. `/remote/`) and is matched against the leading `/segment/` of the value.
|
||||
- **Off by default**: with no `customizationUrlPrefixes` configured, any `?customization=` value is rejected (see below).
|
||||
|
||||
Example:
|
||||
|
||||
```js
|
||||
window.config = {
|
||||
customizationUrlPrefixes: {
|
||||
default: './customizations/',
|
||||
'/remote/': 'https://cdn.example.com/ohif-customizations/',
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Using `?customization=`
|
||||
|
||||
You can pass one or more customization entries in the URL:
|
||||
|
||||
- `?customization=ctAbdomen` → `default` prefix → `./customizations/ctAbdomen.jsonc`
|
||||
- `?customization=/remote/siteA` → `/remote/` prefix → `https://cdn.example.com/ohif-customizations/siteA.jsonc`
|
||||
- `?customization=basePack&customization=siteOverrides`
|
||||
|
||||
Each entry is split into a prefix and a name, resolved through `customizationUrlPrefixes`, fetched, parsed as JSONC, and then applied.
|
||||
|
||||
#### Security considerations (`?customization=`)
|
||||
|
||||
A URL-loaded customization is a **JSONC data file** (JSON with comments / trailing commas). It is fetched and parsed as data — it is **never executed** as code. Executable code (plugins, modes, extensions) loads only through `pluginConfig.json`, never from the customization URL path. This makes `?customization=` far lower risk than loading a JavaScript bundle, but the values still change application behavior, so treat the source directories as trusted configuration.
|
||||
|
||||
- **Off until configured, and a hard failure when misused:** With no `customizationUrlPrefixes` set, every `?customization=` value is rejected. A value whose prefix is not on the allowlist **throws and aborts app startup** rather than being silently ignored — so a stray or hostile `?customization=` link on an unconfigured deployment fails loudly instead of partially applying.
|
||||
- **Allowlisted resolution only:** The loader rejects values that look like full URLs (with a scheme), rejects path traversal (`..`), rejects unknown prefixes, and rejects unsafe name segments. The final fetch URL is always built from your configured `customizationUrlPrefixes` plus a `.jsonc` file under that base—users cannot pass an arbitrary absolute URL as the customization token alone.
|
||||
- **Your prefixes define the trust boundary:** If a `prefix` maps to a host or path you do not control, or to a directory where untrusted parties can publish files, `?customization=` becomes a way to inject configuration into the app. Prefer HTTPS bases, narrow directories, and static hosting of reviewed files.
|
||||
- **`requires` chains:** Dependencies declared in a loaded file are resolved with the **same** policy and validation. A trusted root file can still pull in further files from the same prefix allowlist—review entire chains you ship.
|
||||
- **Links and social engineering:** Anyone can share a URL that includes `?customization=...`. Recipients’ browsers will attempt to load the corresponding modules if they pass validation. Combine with normal defenses (user education, authenticated portals, enterprise policies) as you would for any deep link that changes application behavior.
|
||||
|
||||
#### What `requires` means
|
||||
|
||||
A customization file can declare dependencies via `requires` so dependent files load first.
|
||||
|
||||
Example file shape:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
// load these first, then apply this file's `global` payload
|
||||
"requires": ["basePack", "/remote/sharedTools"],
|
||||
"global": {
|
||||
"someCustomizationKey": {
|
||||
"$set": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When this file is loaded via `?customization=...`, the loader:
|
||||
|
||||
1. Resolves and loads each `requires` dependency first.
|
||||
2. Applies dependency customizations first.
|
||||
3. Applies the requested module after dependencies.
|
||||
|
||||
This allows packaging layered customizations (base -> shared -> site-specific) without repeating setup in every module.
|
||||
|
||||
### Example modules
|
||||
|
||||
A URL-loaded file applies its `global` payload as **global customizations** — the same layer as
|
||||
`window.config`'s `customizationService` entries, but loaded at runtime from `?customization=`. Any
|
||||
customization key that is read through `customizationService.getCustomization(...)` can therefore be
|
||||
set this way. The examples below are complete files; drop one under `platform/app/public/customizations/`
|
||||
(the `default` prefix) and load it with `?customization=<fileName>`. Because the files are JSONC, you
|
||||
can keep `//` comments and trailing commas in them.
|
||||
|
||||
The shipped [`veterinaryOverlay.jsonc`](https://github.com/OHIF/Viewers/blob/master/platform/app/public/customizations/veterinaryOverlay.jsonc)
|
||||
demonstrates a fourth scenario — replacing the viewport overlay layout via `viewportOverlay.topLeft` /
|
||||
`viewportOverlay.topRight`.
|
||||
|
||||
#### 1. Site-specific window/level presets
|
||||
|
||||
Override the CT presets offered in the window-level menu (key: `cornerstone.windowLevelPresets`).
|
||||
`$merge` replaces only the `CT` entry, so presets for other modalities (PT, etc.) are kept.
|
||||
|
||||
```jsonc
|
||||
// platform/app/public/customizations/ctPresets.jsonc -> ?customization=ctPresets
|
||||
{
|
||||
"global": {
|
||||
"cornerstone.windowLevelPresets": {
|
||||
"$merge": {
|
||||
"CT": [
|
||||
{ "id": "ct-soft-tissue", "description": "Soft tissue", "window": "400", "level": "40" },
|
||||
{ "id": "ct-lung", "description": "Lung", "window": "1500", "level": "-600" },
|
||||
{ "id": "ct-angio", "description": "Angio", "window": "600", "level": "300" },
|
||||
{ "id": "ct-bone", "description": "Bone", "window": "2500", "level": "480" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Predefined measurement labels
|
||||
|
||||
Make the viewer prompt for a label from a fixed list whenever a measurement is created
|
||||
(key: `measurementLabels`).
|
||||
|
||||
```jsonc
|
||||
// platform/app/public/customizations/measurementLabels.jsonc -> ?customization=measurementLabels
|
||||
{
|
||||
"global": {
|
||||
"measurementLabels": {
|
||||
"$set": {
|
||||
"labelOnMeasure": true,
|
||||
"exclusive": true,
|
||||
"items": [
|
||||
{ "value": "Head", "label": "Head" },
|
||||
{ "value": "Shoulder", "label": "Shoulder" },
|
||||
{ "value": "Knee", "label": "Knee" },
|
||||
{ "value": "Toe", "label": "Toe" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Add a toolbar button
|
||||
|
||||
The basic and longitudinal viewers register their toolbar as customizations
|
||||
(`cornerstone.toolbarButtons` — the button definitions, and `cornerstone.toolbarSections` — the
|
||||
layout that maps each section to a list of button ids). A module can therefore add a button by
|
||||
`$push`-ing a definition onto `cornerstone.toolbarButtons` and the button's id onto a section.
|
||||
|
||||
The shipped [`smoothRotate.jsonc`](https://github.com/OHIF/Viewers/blob/master/platform/app/public/customizations/smoothRotate.jsonc)
|
||||
adds a **Smooth Rotate** button to the *More Tools* menu that activates the cornerstone `PlanarRotate`
|
||||
tool (drag to rotate the image freely, unlike the fixed 90° *Rotate Right*):
|
||||
|
||||
```jsonc
|
||||
// platform/app/public/customizations/smoothRotate.jsonc -> ?customization=smoothRotate
|
||||
{
|
||||
"global": {
|
||||
"cornerstone.toolbarButtons": {
|
||||
"$push": [
|
||||
{
|
||||
"id": "SmoothRotate",
|
||||
"uiType": "ohif.toolButton",
|
||||
"props": {
|
||||
"type": "tool",
|
||||
"icon": "tool-rotate-right",
|
||||
"label": "Smooth Rotate",
|
||||
"tooltip": "Smooth Rotate (drag to rotate the image freely)",
|
||||
"commands": {
|
||||
"commandName": "setToolActiveToolbar",
|
||||
"commandOptions": { "toolName": "PlanarRotate" }
|
||||
},
|
||||
"evaluate": "evaluate.cornerstoneTool"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"cornerstone.toolbarSections": {
|
||||
"MoreTools": { "$push": ["SmoothRotate"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> Because the cornerstone extension registers the default toolbar at the *default* scope and a URL
|
||||
> module applies at the *global* scope, the `$push` **extends** the built-in buttons rather than
|
||||
> replacing them. The same pattern works for any tool already in the active tool group.
|
||||
|
||||
Each payload value uses [immutability-helper](https://github.com/kolodny/immutability-helper)
|
||||
commands (`$set`, `$push`, `$merge`, ...) exactly like `window.config` customizations, so a module can
|
||||
also append to a list or merge into an existing object rather than replacing it wholesale.
|
||||
|
||||
### URL modules, bootstrap, and client-side navigation (intended behavior)
|
||||
|
||||
Modules referenced from `?customization=` are loaded when the app applies URL customizations from
|
||||
`window.location.search`, which happens **once at bootstrap** in the default shell (for example
|
||||
from app initialization). That is intentional:
|
||||
|
||||
- **No automatic refresh on SPA navigation:** Client-side routing may change the visible URL, and
|
||||
keys such as `customization` are often **kept in the query string** on purpose (see
|
||||
`ohif.preserveCustomizationKeys` above) so bookmarks and deep links stay consistent. That
|
||||
preservation does **not** mean the viewer re-fetches URL customization files on every route
|
||||
change.
|
||||
- **Previously loaded files stay applied:** The service remembers each normalized key for
|
||||
the lifetime of the page. A later call to the same loader path skips files that were already
|
||||
fetched, and global payloads from those files are not rolled back when only the query string
|
||||
changes.
|
||||
|
||||
If you need a different `?customization=` pack to take effect without a full reload, your
|
||||
integration must trigger loading explicitly (for example by calling
|
||||
`customizationService.applyCustomizationUrlSearchParams` or `customizationService.requires` with
|
||||
the new list). New module keys not seen before can still be loaded that way; unloading or
|
||||
replacing an already-loaded pack is not supported out of the box.
|
||||
37
pnpm-lock.yaml
generated
37
pnpm-lock.yaml
generated
@ -91,7 +91,7 @@ importers:
|
||||
version: 7.29.7(@babel/core@7.29.7)
|
||||
'@babel/preset-env':
|
||||
specifier: 7.29.7
|
||||
version: 7.29.7(@babel/core@7.29.7)
|
||||
version: 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)
|
||||
'@babel/preset-react':
|
||||
specifier: 7.29.7
|
||||
version: 7.29.7(@babel/core@7.29.7)
|
||||
@ -901,7 +901,7 @@ importers:
|
||||
version: 7.29.7(@babel/core@7.29.7)
|
||||
'@babel/preset-env':
|
||||
specifier: 7.29.7
|
||||
version: 7.29.7(@babel/core@7.29.7)
|
||||
version: 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)
|
||||
'@babel/preset-react':
|
||||
specifier: 7.29.7
|
||||
version: 7.29.7(@babel/core@7.29.7)
|
||||
@ -1187,7 +1187,7 @@ importers:
|
||||
version: 7.29.7(@babel/core@7.29.7)
|
||||
'@babel/preset-env':
|
||||
specifier: 7.29.7
|
||||
version: 7.29.7(@babel/core@7.29.7)
|
||||
version: 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)
|
||||
'@babel/preset-react':
|
||||
specifier: 7.29.7
|
||||
version: 7.29.7(@babel/core@7.29.7)
|
||||
@ -1297,7 +1297,7 @@ importers:
|
||||
version: 7.29.7(@babel/core@7.29.7)
|
||||
'@babel/preset-env':
|
||||
specifier: 7.29.7
|
||||
version: 7.29.7(@babel/core@7.29.7)
|
||||
version: 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)
|
||||
'@babel/preset-react':
|
||||
specifier: 7.29.7
|
||||
version: 7.29.7(@babel/core@7.29.7)
|
||||
@ -1581,6 +1581,9 @@ importers:
|
||||
isomorphic-base64:
|
||||
specifier: 1.0.2
|
||||
version: 1.0.2
|
||||
json5:
|
||||
specifier: 2.2.3
|
||||
version: 2.2.3
|
||||
lodash.clonedeep:
|
||||
specifier: 4.5.0
|
||||
version: 4.5.0
|
||||
@ -14038,7 +14041,7 @@ snapshots:
|
||||
'@babel/helpers': 7.29.2
|
||||
'@babel/parser': 7.29.3
|
||||
'@babel/template': 7.28.6
|
||||
'@babel/traverse': 7.29.0
|
||||
'@babel/traverse': 7.29.0(supports-color@8.1.1)
|
||||
'@babel/types': 7.29.0
|
||||
convert-source-map: 2.0.0
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
@ -14058,7 +14061,7 @@ snapshots:
|
||||
'@babel/helpers': 7.29.2
|
||||
'@babel/parser': 7.29.3
|
||||
'@babel/template': 7.28.6
|
||||
'@babel/traverse': 7.29.0
|
||||
'@babel/traverse': 7.29.0(supports-color@8.1.1)
|
||||
'@babel/types': 7.29.0
|
||||
convert-source-map: 2.0.0
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
@ -15241,7 +15244,7 @@ snapshots:
|
||||
'@babel/helper-module-transforms': 7.28.6(@babel/core@7.28.0(supports-color@8.1.1))
|
||||
'@babel/helper-plugin-utils': 7.28.6
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
'@babel/traverse': 7.29.0
|
||||
'@babel/traverse': 7.29.0(supports-color@8.1.1)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@ -15251,17 +15254,17 @@ snapshots:
|
||||
'@babel/helper-module-transforms': 7.28.6(@babel/core@7.28.0)
|
||||
'@babel/helper-plugin-utils': 7.28.6
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
'@babel/traverse': 7.29.0
|
||||
'@babel/traverse': 7.29.0(supports-color@8.1.1)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/plugin-transform-modules-systemjs@7.29.4(@babel/core@7.29.7(supports-color@8.1.1))':
|
||||
'@babel/plugin-transform-modules-systemjs@7.29.4(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7(supports-color@8.1.1)
|
||||
'@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1))
|
||||
'@babel/helper-plugin-utils': 7.28.6
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
'@babel/traverse': 7.29.0
|
||||
'@babel/traverse': 7.29.0(supports-color@8.1.1)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@ -16026,7 +16029,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/preset-env@7.29.7(@babel/core@7.29.7)':
|
||||
'@babel/preset-env@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)':
|
||||
dependencies:
|
||||
'@babel/compat-data': 7.29.7
|
||||
'@babel/core': 7.29.7(supports-color@8.1.1)
|
||||
@ -16068,7 +16071,7 @@ snapshots:
|
||||
'@babel/plugin-transform-member-expression-literals': 7.29.7(@babel/core@7.29.7)
|
||||
'@babel/plugin-transform-modules-amd': 7.29.7(@babel/core@7.29.7)
|
||||
'@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7)
|
||||
'@babel/plugin-transform-modules-systemjs': 7.29.4(@babel/core@7.29.7(supports-color@8.1.1))
|
||||
'@babel/plugin-transform-modules-systemjs': 7.29.4(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)
|
||||
'@babel/plugin-transform-modules-umd': 7.29.7(@babel/core@7.29.7)
|
||||
'@babel/plugin-transform-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7)
|
||||
'@babel/plugin-transform-new-target': 7.29.7(@babel/core@7.29.7)
|
||||
@ -16218,7 +16221,7 @@ snapshots:
|
||||
'@babel/parser': 7.29.7
|
||||
'@babel/types': 7.29.7
|
||||
|
||||
'@babel/traverse@7.29.0':
|
||||
'@babel/traverse@7.29.0(supports-color@8.1.1)':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.0
|
||||
'@babel/generator': 7.29.7
|
||||
@ -16880,7 +16883,7 @@ snapshots:
|
||||
'@babel/generator': 7.29.7
|
||||
'@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.29.7)
|
||||
'@babel/plugin-transform-runtime': 7.29.7(@babel/core@7.29.7)
|
||||
'@babel/preset-env': 7.29.7(@babel/core@7.29.7)
|
||||
'@babel/preset-env': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)
|
||||
'@babel/preset-react': 7.29.7(@babel/core@7.29.7)
|
||||
'@babel/preset-typescript': 7.29.7(@babel/core@7.29.7)
|
||||
'@babel/runtime': 7.29.7
|
||||
@ -17411,7 +17414,7 @@ snapshots:
|
||||
'@docusaurus/plugin-pwa@3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.13(@swc/helpers@0.5.21))(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@swc/helpers@0.5.21))(@docusaurus/plugin-content-docs@3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.13(@swc/helpers@0.5.21))(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@swc/helpers@0.5.21))(@mdx-js/react@3.0.1(@types/react@18.3.23)(react@18.3.1))(@rspack/core@1.7.11(@swc/helpers@0.5.21))(@swc/core@1.15.13(@swc/helpers@0.5.21))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4))(@mdx-js/react@3.0.1(@types/react@18.3.23)(react@18.3.1))(@rspack/core@1.7.11(@swc/helpers@0.5.21))(@swc/core@1.15.13(@swc/helpers@0.5.21))(@types/babel__core@7.20.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7(supports-color@8.1.1)
|
||||
'@babel/preset-env': 7.29.7(@babel/core@7.29.7)
|
||||
'@babel/preset-env': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)
|
||||
'@docusaurus/bundler': 3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.13(@swc/helpers@0.5.21))(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@swc/helpers@0.5.21))(@rspack/core@1.7.11(@swc/helpers@0.5.21))(@swc/core@1.15.13(@swc/helpers@0.5.21))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)
|
||||
'@docusaurus/core': 3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.13(@swc/helpers@0.5.21))(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@swc/helpers@0.5.21))(@mdx-js/react@3.0.1(@types/react@18.3.23)(react@18.3.1))(@rspack/core@1.7.11(@swc/helpers@0.5.21))(@swc/core@1.15.13(@swc/helpers@0.5.21))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)
|
||||
'@docusaurus/logger': 3.10.1
|
||||
@ -18430,7 +18433,7 @@ snapshots:
|
||||
|
||||
'@kitware/vtk.js@35.5.3(@babel/preset-env@7.29.7(@babel/core@7.29.7))(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0)':
|
||||
dependencies:
|
||||
'@babel/preset-env': 7.29.7(@babel/core@7.29.7)
|
||||
'@babel/preset-env': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)
|
||||
'@types/webxr': 0.5.24
|
||||
autoprefixer: 10.4.27(postcss@8.5.14)
|
||||
commander: 8.3.0
|
||||
@ -29546,7 +29549,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@apideck/better-ajv-errors': 0.3.6(ajv@8.20.0)
|
||||
'@babel/core': 7.29.7(supports-color@8.1.1)
|
||||
'@babel/preset-env': 7.29.7(@babel/core@7.29.7)
|
||||
'@babel/preset-env': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)
|
||||
'@babel/runtime': 7.29.7
|
||||
'@rollup/plugin-babel': 5.3.1(@babel/core@7.29.7)(@types/babel__core@7.20.5)(rollup@2.80.0)
|
||||
'@rollup/plugin-node-resolve': 15.3.1(rollup@2.80.0)
|
||||
|
||||
@ -13,7 +13,9 @@ const DIST_DIR = path.resolve(__dirname, './platform/app/dist');
|
||||
const PUBLIC_DIR = path.resolve(__dirname, './platform/app/public');
|
||||
|
||||
// Environment variables (similar to webpack.pwa.js)
|
||||
const APP_CONFIG = process.env.APP_CONFIG || 'config/default.js';
|
||||
// rsbuild is used only by the dev server (`dev:fast`), so default to the
|
||||
// full-featured `config/dev.js` while still honoring an explicit APP_CONFIG.
|
||||
const APP_CONFIG = process.env.APP_CONFIG || 'config/dev.js';
|
||||
const PUBLIC_URL = process.env.PUBLIC_URL || '/';
|
||||
|
||||
// Add these constants
|
||||
|
||||
22
tests/Customization.spec.ts
Normal file
22
tests/Customization.spec.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { expect, test, visitStudyOptions } from './utils';
|
||||
|
||||
test('should apply customization from URL query parameter', async ({ page }) => {
|
||||
const studyInstanceUID = '2.25.96975534054447904995905761963464388233';
|
||||
|
||||
await visitStudyOptions(page, studyInstanceUID, {
|
||||
customization: 'veterinaryOverlay',
|
||||
});
|
||||
|
||||
const patientNameOverlayItem = page
|
||||
.locator('[data-cy="viewport-overlay-top-left"] [title="Patient name"]')
|
||||
.first();
|
||||
|
||||
await expect(patientNameOverlayItem).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
const patientNameOverlayText = (await patientNameOverlayItem.textContent())?.trim() ?? '';
|
||||
const patientNameValue = patientNameOverlayText.replace(/^Patient\s*/, '');
|
||||
|
||||
expect(patientNameValue.length).toBeGreaterThan(0);
|
||||
expect(patientNameValue).not.toContain('[object Object]');
|
||||
expect(patientNameValue).toMatch(/horse/i);
|
||||
});
|
||||
@ -1,5 +1,8 @@
|
||||
import { visitStudy } from './visitStudy';
|
||||
import { addOHIFConfiguration, addOHIFGlobalCustomizations } from './OHIFConfiguration';
|
||||
import { visitStudy, visitStudyOptions } from './visitStudy';
|
||||
import {
|
||||
addOHIFConfiguration,
|
||||
addOHIFGlobalCustomizations,
|
||||
} from './OHIFConfiguration';
|
||||
import { checkForScreenshot } from './checkForScreenshot';
|
||||
import { screenShotPaths } from './screenShotPaths';
|
||||
import {
|
||||
@ -36,6 +39,7 @@ import {
|
||||
|
||||
export {
|
||||
visitStudy,
|
||||
visitStudyOptions,
|
||||
addOHIFConfiguration,
|
||||
addOHIFGlobalCustomizations,
|
||||
checkForScreenshot,
|
||||
|
||||
@ -1,4 +1,11 @@
|
||||
import { Page } from 'playwright-test-coverage';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
type VisitStudyOptions = {
|
||||
mode?: string;
|
||||
delay?: number;
|
||||
datasources?: string;
|
||||
customization?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Visit the study
|
||||
@ -8,18 +15,44 @@ import { Page } from 'playwright-test-coverage';
|
||||
* @param delay - The delay to wait after visiting the study
|
||||
* @param datasources - the data source to load the study from
|
||||
*/
|
||||
export async function visitStudy(
|
||||
export async function visitStudyOptions(
|
||||
page: Page,
|
||||
studyInstanceUID: string,
|
||||
mode: string,
|
||||
delay: number = 0,
|
||||
datasources = 'ohif'
|
||||
options: VisitStudyOptions = {}
|
||||
) {
|
||||
const mode = options.mode || 'viewer';
|
||||
const resolvedDelay = options.delay ?? 0;
|
||||
const resolvedDatasources = options.datasources || 'ohif';
|
||||
const { customization } = options;
|
||||
|
||||
// await page.goto(`/?resultsPerPage=100&datasources=${datasources}`);
|
||||
// await page.getByTestId(studyInstanceUID).click();
|
||||
// await page.getByRole('button', { name: mode }).click();
|
||||
await page.goto(`/${mode}/${datasources}?StudyInstanceUIDs=${studyInstanceUID}`);
|
||||
// studyInstanceUID may itself carry extra query params appended with `&`
|
||||
// (e.g. `<uid>&hangingprotocolid=mpr`), so concatenate it raw rather than
|
||||
// running it through URLSearchParams, which would percent-encode the `&`/`=`
|
||||
// and collapse everything into a single invalid StudyInstanceUIDs value.
|
||||
let url = `/${mode}/${resolvedDatasources}?StudyInstanceUIDs=${studyInstanceUID}`;
|
||||
if (customization) {
|
||||
url += `&customization=${encodeURIComponent(customization)}`;
|
||||
}
|
||||
|
||||
await page.goto(url);
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(delay);
|
||||
await page.waitForTimeout(resolvedDelay);
|
||||
}
|
||||
|
||||
export async function visitStudy(
|
||||
page: Page,
|
||||
studyInstanceUID: string,
|
||||
mode = 'viewer',
|
||||
delay: number = 0,
|
||||
datasources = 'ohif'
|
||||
) {
|
||||
return visitStudyOptions(page, studyInstanceUID, {
|
||||
mode,
|
||||
delay,
|
||||
datasources,
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user