feat(studio): open config in configurator

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
James Montemagno
2026-08-02 16:40:20 +02:00
committed by Jan De Dobbeleer
co-authored by Copilot App
parent fa118cf2d0
commit 477acf0fbe
5 changed files with 222 additions and 0 deletions
+2
View File
@@ -10,4 +10,6 @@ Edit the config below and the prompt above updates as you type. A WebAssembly bu
Oh My Posh renders it in your browser, so nothing is sent to a server. Segments draw on
recorded sample data, not your machine.
Use **Open in Configurator** to continue editing a valid configuration in the Configurator.
<Studio />
+1
View File
@@ -7,6 +7,7 @@
"start": "cross-env NODE_ENV=development node scripts/dev.mjs",
"prebuild": "node scripts/ensure-artifacts.mjs",
"build": "docusaurus build",
"test": "node --test src/components/Studio/*.test.mjs",
"serve": "docusaurus serve",
"themes": "node export_themes.mjs",
"segment-previews": "node scripts/render-segment-previews.mjs",
@@ -0,0 +1,56 @@
export const CONFIGURATOR_ORIGIN = 'https://configurator.ohmyposh.dev';
export const CONFIGURATOR_URL = `${CONFIGURATOR_ORIGIN}/`;
const MESSAGE_VERSION = 1;
const READY_MESSAGE_TYPE = 'omp-configurator-ready';
const CONFIG_MESSAGE_TYPE = 'omp-studio-config';
const NONCE_BYTES = 32;
export function generateNonce(crypto = globalThis.crypto) {
if (!crypto || typeof crypto.getRandomValues !== 'function') {
throw new Error('Secure random values are unavailable.');
}
const bytes = new Uint8Array(NONCE_BYTES);
crypto.getRandomValues(bytes);
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
}
export function createConfiguratorHandoff(format, text, crypto) {
const nonce = generateNonce(crypto);
const url = new URL(CONFIGURATOR_URL);
url.hash = `nonce=${encodeURIComponent(nonce)}`;
return { nonce, format, text, url: url.toString() };
}
export function isConfiguratorReadyMessage(event, pending) {
if (!pending || !pending.popupWindow || event.origin !== CONFIGURATOR_ORIGIN) {
return false;
}
const { data } = event;
return (
event.source === pending.popupWindow &&
data &&
typeof data === 'object' &&
data.version === MESSAGE_VERSION &&
data.type === READY_MESSAGE_TYPE &&
data.nonce === pending.nonce
);
}
export function sendStudioConfig(pending) {
pending.popupWindow.postMessage(
{
version: MESSAGE_VERSION,
type: CONFIG_MESSAGE_TYPE,
nonce: pending.nonce,
format: pending.format,
text: pending.text,
},
CONFIGURATOR_ORIGIN,
);
}
@@ -0,0 +1,85 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
CONFIGURATOR_ORIGIN,
createConfiguratorHandoff,
isConfiguratorReadyMessage,
sendStudioConfig,
} from './configuratorHandoff.mjs';
describe('Configurator handoff', () => {
it('creates a config-free URL with a cryptographically random fragment nonce', () => {
const handoff = createConfiguratorHandoff('yaml', 'version: 4', {
getRandomValues(bytes) {
bytes.fill(0xab);
return bytes;
},
});
assert.equal(handoff.nonce, 'ab'.repeat(32));
assert.equal(handoff.url, `https://configurator.ohmyposh.dev/#nonce=${'ab'.repeat(32)}`);
assert.equal(handoff.url.includes('version'), false);
});
it('only accepts a ready message from the pending configurator window', () => {
const popupWindow = {};
const pending = { nonce: 'nonce', popupWindow };
const message = {
origin: CONFIGURATOR_ORIGIN,
source: popupWindow,
data: { version: 1, type: 'omp-configurator-ready', nonce: 'nonce' },
};
assert.equal(isConfiguratorReadyMessage(message, pending), true);
assert.equal(
isConfiguratorReadyMessage({ ...message, origin: 'https://example.com' }, pending),
false,
);
assert.equal(isConfiguratorReadyMessage({ ...message, source: {} }, pending), false);
assert.equal(
isConfiguratorReadyMessage(
{ ...message, data: { ...message.data, nonce: 'other-nonce' } },
pending,
),
false,
);
assert.equal(
isConfiguratorReadyMessage(
{ ...message, data: { ...message.data, type: 'omp-studio-config' } },
pending,
),
false,
);
assert.equal(
isConfiguratorReadyMessage({ ...message, data: { ...message.data, version: 2 } }, pending),
false,
);
});
it('sends the raw config only to the configurator origin', () => {
let sentMessage;
let targetOrigin;
const pending = {
nonce: 'nonce',
format: 'toml',
text: 'version = 4',
popupWindow: {
postMessage(message, origin) {
sentMessage = message;
targetOrigin = origin;
},
},
};
sendStudioConfig(pending);
assert.deepEqual(sentMessage, {
version: 1,
type: 'omp-studio-config',
nonce: 'nonce',
format: 'toml',
text: 'version = 4',
});
assert.equal(targetOrigin, CONFIGURATOR_ORIGIN);
});
});
+78
View File
@@ -23,6 +23,11 @@ import {
trySessionStorageRemove,
trySessionStorageSet,
} from '../ConfigEditor/studioHandoff';
import {
createConfiguratorHandoff,
isConfiguratorReadyMessage,
sendStudioConfig,
} from './configuratorHandoff.mjs';
import { exportSvgAsPng } from './exportPng';
import { CONFIG_FORMAT, CONFIG_FORMATS, STARTERS } from './config';
import styles from './styles.module.css';
@@ -78,6 +83,14 @@ function DownloadPngButton({ svg, disabled }) {
);
}
function OpenInConfiguratorButton({ onClick }) {
return (
<button type="button" className={styles.action} onClick={onClick}>
Open in Configurator
</button>
);
}
function Studio() {
const [format, setFormat] = useState(CONFIG_FORMAT);
const [configText, setConfigText] = useState(STARTERS[CONFIG_FORMAT]);
@@ -88,6 +101,7 @@ function Studio() {
// Set when a format switch was refused because the config in the editor does not parse; cleared
// by the next switch that succeeds. See handleFormatChange.
const [formatNotice, setFormatNotice] = useState(null);
const [configuratorNotice, setConfiguratorNotice] = useState(null);
// The current syntax error, if any - { message, line, column, endColumn } from
// errorPosition.js's getSyntaxError, or null. Unlike Config.js's segment editor, the studio has
// no client-side pre-parse step of its own (its render path goes straight to the wasm module,
@@ -111,6 +125,7 @@ function Studio() {
const { colorMode } = useColorMode();
const debounceRef = useRef(null);
const configuratorHandoffRef = useRef(null);
// runRender is a useCallback with no deps, so reading `format`/`colorMode` from the closure
// would pin them to whatever was selected on first render. The refs are what the render call
// actually reads.
@@ -141,6 +156,26 @@ function Studio() {
ensureLoaded();
}, [ensureLoaded]);
useEffect(() => {
const handleMessage = (event) => {
const pending = configuratorHandoffRef.current;
if (!isConfiguratorReadyMessage(event, pending)) {
return;
}
configuratorHandoffRef.current = null;
sendStudioConfig(pending);
};
window.addEventListener('message', handleMessage);
return () => {
configuratorHandoffRef.current = null;
window.removeEventListener('message', handleMessage);
};
}, []);
// Resumes the reader's own session (see studioHandoff.js's SESSION_KEY doc comment), applies a
// pending theme load if "Open in Studio" queued one (LOAD_KEY, replacing rather than resuming),
// and otherwise, if a segment doc queued one, appends its segment on top - so "Add to Studio"
@@ -333,6 +368,47 @@ function Studio() {
[runRender],
);
const handleOpenInConfigurator = useCallback(() => {
try {
parseConfig(format, configText);
} catch {
setConfiguratorNotice('Fix the configuration errors before opening it in Configurator.');
return;
}
let handoff;
try {
handoff = createConfiguratorHandoff(format, configText);
} catch {
setConfiguratorNotice(
'Could not prepare a secure handoff to Configurator. Try again in a supported browser.',
);
return;
}
let popupWindow;
try {
popupWindow = window.open(handoff.url, '_blank');
} catch {
popupWindow = null;
}
if (!popupWindow) {
setConfiguratorNotice('Could not open Configurator. Allow popups for this site and try again.');
return;
}
configuratorHandoffRef.current = {
nonce: handoff.nonce,
format,
text: configText,
popupWindow,
};
setConfiguratorNotice(null);
}, [configText, format]);
const previewHint = wasmStatus === 'ready' && !svg && !error ? 'Nothing to preview yet.' : null;
// Prefer the client-side re-parse's real message (e.g. "Expected ',' or '}' after property
// value in JSON at position 13 (line 3 column 3)") over the wasm module's generic sentinel
@@ -373,6 +449,7 @@ function Studio() {
{appendNotice && <p className={styles.notice}>{appendNotice}</p>}
{formatNotice && <p className={styles.notice}>{formatNotice}</p>}
{configuratorNotice && <p className={styles.notice}>{configuratorNotice}</p>}
<ConfigEditor
label="Config"
@@ -385,6 +462,7 @@ function Studio() {
actions={
<>
<DownloadPngButton svg={svg} disabled={!svg} />
<OpenInConfiguratorButton onClick={handleOpenInConfigurator} />
<ErrorIndicator message={displayError} />
</>
}