mirror of
https://github.com/JanDeDobbeleer/oh-my-posh.git
synced 2026-08-24 10:14:12 -05:00
feat(studio): open config in configurator
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
committed by
Jan De Dobbeleer
co-authored by
Copilot App
parent
fa118cf2d0
commit
477acf0fbe
@@ -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 />
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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} />
|
||||
</>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user