diff --git a/Documentation/GettingStarted/LocalDevelopment/index.md b/Documentation/GettingStarted/LocalDevelopment/index.md index ad3c101..cf19a29 100644 --- a/Documentation/GettingStarted/LocalDevelopment/index.md +++ b/Documentation/GettingStarted/LocalDevelopment/index.md @@ -39,14 +39,15 @@ This generates `Source/dist/`, including `manifest.json`. The **Lens - Cratis Developer Tools** extension should now appear in your extension list. -## 4. Set up Arc connection +## 4. Open Lens on an Arc application page -1. Open the extension options page from the extension details. -2. Set **Arc Base URL** to your local Arc host, for example `http://localhost:5000`. -3. Keep the default **Tenant Header Name** unless your app expects a different header. -4. Select **Save Settings**. +1. Navigate to your Arc application in Chrome. +2. Open the Lens extension popup once on that page so Lens can detect Arc context. +3. Open the extension options page from the extension details. +4. Keep the default **Tenant Header Name** unless your app expects a different header. +5. Select **Save Settings**. -Lens can now fetch command and query introspection metadata from your Arc application. +Lens now uses the detected Arc context and current page location for command and query introspection. ## 5. Use watch mode while developing @@ -57,4 +58,3 @@ npm run dev ``` Vite rebuilds the extension when you change source files. Reload the extension in `chrome://extensions` after each rebuild. - diff --git a/Source/manifest.json b/Source/manifest.json index 7947467..d459ed3 100644 --- a/Source/manifest.json +++ b/Source/manifest.json @@ -5,6 +5,7 @@ "description": "Browser extension bringing Cratis insights and developer productivity tools to your fingertips.", "permissions": [ "storage", + "scripting", "declarativeNetRequest", "declarativeNetRequestWithHostAccess" ], diff --git a/Source/src/options/Options.tsx b/Source/src/options/Options.tsx index cfa0c6a..76b8bf7 100644 --- a/Source/src/options/Options.tsx +++ b/Source/src/options/Options.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react'; import { ExtensionSettings } from '../shared/types'; import { getSettings, saveSettings } from '../shared/storage'; +import { ArcContextSnapshot, getArcContextSnapshot } from '../shared/arc-context'; import { UserList } from './components/UserList'; import { TenantList } from './components/TenantList'; import { ArcSettings } from './components/ArcSettings'; @@ -12,11 +13,17 @@ type Tab = 'users' | 'tenants' | 'arc' | 'commands' | 'queries'; export function Options() { const [settings, setSettings] = useState(null); + const [arcContext, setArcContext] = useState(null); + const [arcContextLoading, setArcContextLoading] = useState(true); const [activeTab, setActiveTab] = useState('users'); const [saved, setSaved] = useState(false); + const hasArcContext = arcContext?.isArcApplication === true; useEffect(() => { getSettings().then(setSettings); + getArcContextSnapshot() + .then(setArcContext) + .finally(() => setArcContextLoading(false)); }, []); const handleChange = async (updated: ExtensionSettings) => { @@ -30,13 +37,18 @@ export function Options() { return
Loading settings…
; } + const arcBaseUrl = arcContext?.baseUrl ?? ''; + const tabs: { id: Tab; label: string }[] = [ { id: 'users', label: 'Users' }, { id: 'tenants', label: 'Tenants' }, { id: 'arc', label: 'Arc Settings' }, - { id: 'commands', label: 'Commands' }, - { id: 'queries', label: 'Queries' }, ]; + if (hasArcContext) { + tabs.push({ id: 'commands', label: 'Commands' }); + tabs.push({ id: 'queries', label: 'Queries' }); + } + const resolvedActiveTab = tabs.some(tab => tab.id === activeTab) ? activeTab : 'users'; return (
@@ -49,7 +61,7 @@ export function Options() { {tabs.map(t => (
diff --git a/Source/src/options/components/ArcSettings.tsx b/Source/src/options/components/ArcSettings.tsx index 292fb78..c1c95c6 100644 --- a/Source/src/options/components/ArcSettings.tsx +++ b/Source/src/options/components/ArcSettings.tsx @@ -1,12 +1,14 @@ import { useState } from 'react'; import { ExtensionSettings } from '../../shared/types'; +import { ArcContextSnapshot } from '../../shared/arc-context'; interface Props { settings: ExtensionSettings; onChange: (settings: ExtensionSettings) => void; + arcContext: ArcContextSnapshot | null; } -export function ArcSettings({ settings, onChange }: Props) { +export function ArcSettings({ settings, onChange, arcContext }: Props) { const [arcBaseUrl, setArcBaseUrl] = useState(settings.arcBaseUrl); const [tenantHeaderName, setTenantHeaderName] = useState(settings.tenantHeaderName); @@ -22,9 +24,23 @@ export function ArcSettings({ settings, onChange }: Props) {
Connection
+ {arcContext?.isArcApplication && arcContext.baseUrl ? ( + <> +

+ Commands and Queries use Arc context detected from the current page. +

+
+ {arcContext.baseUrl} +
+ + ) : ( +

+ Arc context is unavailable for the current page. Commands and Queries are hidden until Lens is opened on an Arc application page. +

+ )} -
- +
+

- The base URL of your Arc application. Used to fetch introspection data and to scope header injection. + Optional scope used for request header injection rules.

diff --git a/Source/src/options/components/CommandsPanel.tsx b/Source/src/options/components/CommandsPanel.tsx index 2891ea0..da4b8a6 100644 --- a/Source/src/options/components/CommandsPanel.tsx +++ b/Source/src/options/components/CommandsPanel.tsx @@ -1,5 +1,6 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useEffect } from 'react'; import { CommandIntrospectionMetadata } from '../../shared/types'; +import { ARC_CONTEXT_UNAVAILABLE_MESSAGE } from './arc-panel-constants'; interface Props { arcBaseUrl: string; @@ -19,17 +20,22 @@ interface CommandState { } export function CommandsPanel({ arcBaseUrl }: Props) { - const [baseUrl, setBaseUrl] = useState(arcBaseUrl); const [commands, setCommands] = useState(null); const [fetchError, setFetchError] = useState(null); const [fetching, setFetching] = useState(false); const [states, setStates] = useState>({}); const fetchCommands = useCallback(async () => { + if (!arcBaseUrl) { + setCommands(null); + setFetchError(ARC_CONTEXT_UNAVAILABLE_MESSAGE); + return; + } + setFetching(true); setFetchError(null); try { - const url = `${baseUrl.replace(/\/$/, '')}/.cratis/commands`; + const url = `${arcBaseUrl.replace(/\/$/, '')}/.cratis/commands`; const res = await fetch(url); if (!res.ok) { setFetchError(`HTTP ${res.status}: ${res.statusText}`); @@ -47,7 +53,11 @@ export function CommandsPanel({ arcBaseUrl }: Props) { } finally { setFetching(false); } - }, [baseUrl]); + }, [arcBaseUrl]); + + useEffect(() => { + void fetchCommands(); + }, [fetchCommands]); const toggleExpanded = (type: string) => { setStates(prev => ({ @@ -63,7 +73,7 @@ export function CommandsPanel({ arcBaseUrl }: Props) { const invoke = async (cmd: CommandIntrospectionMetadata) => { setStates(prev => ({ ...prev, [cmd.type]: { ...prev[cmd.type], loading: true, result: null } })); try { - const url = `${baseUrl.replace(/\/$/, '')}${cmd.route}`; + const url = `${arcBaseUrl.replace(/\/$/, '')}${cmd.route}`; const state = states[cmd.type]; let bodyData: BodyInit | null = null; try { @@ -104,14 +114,11 @@ export function CommandsPanel({ arcBaseUrl }: Props) {
- setBaseUrl(e.target.value)} - placeholder="http://localhost:5000" - /> -
{fetchError && ( @@ -123,13 +130,13 @@ export function CommandsPanel({ arcBaseUrl }: Props) { {commands === null && !fetchError && (
-

Enter your Arc base URL and click "Fetch Commands" to discover available commands.

+

Loading commands from Arc introspection…

)} {commands !== null && commands.length === 0 && (
-

No commands discovered from {baseUrl}/.cratis/commands.

+

No commands discovered from {arcBaseUrl}/.cratis/commands.

)} diff --git a/Source/src/options/components/QueriesPanel.tsx b/Source/src/options/components/QueriesPanel.tsx index d937a10..760e5f3 100644 --- a/Source/src/options/components/QueriesPanel.tsx +++ b/Source/src/options/components/QueriesPanel.tsx @@ -1,5 +1,6 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useEffect } from 'react'; import { QueryIntrospectionMetadata } from '../../shared/types'; +import { ARC_CONTEXT_UNAVAILABLE_MESSAGE } from './arc-panel-constants'; interface Props { arcBaseUrl: string; @@ -32,17 +33,22 @@ function buildUrl(baseUrl: string, route: string, params: Record } export function QueriesPanel({ arcBaseUrl }: Props) { - const [baseUrl, setBaseUrl] = useState(arcBaseUrl); const [queries, setQueries] = useState(null); const [fetchError, setFetchError] = useState(null); const [fetching, setFetching] = useState(false); const [states, setStates] = useState>({}); const fetchQueries = useCallback(async () => { + if (!arcBaseUrl) { + setQueries(null); + setFetchError(ARC_CONTEXT_UNAVAILABLE_MESSAGE); + return; + } + setFetching(true); setFetchError(null); try { - const url = `${baseUrl.replace(/\/$/, '')}/.cratis/queries`; + const url = `${arcBaseUrl.replace(/\/$/, '')}/.cratis/queries`; const res = await fetch(url); if (!res.ok) { setFetchError(`HTTP ${res.status}: ${res.statusText}`); @@ -63,7 +69,11 @@ export function QueriesPanel({ arcBaseUrl }: Props) { } finally { setFetching(false); } - }, [baseUrl]); + }, [arcBaseUrl]); + + useEffect(() => { + void fetchQueries(); + }, [fetchQueries]); const toggleExpanded = (type: string) => { setStates(prev => ({ @@ -83,7 +93,7 @@ export function QueriesPanel({ arcBaseUrl }: Props) { setStates(prev => ({ ...prev, [query.type]: { ...prev[query.type], loading: true, result: null } })); try { const state = states[query.type]; - const url = buildUrl(baseUrl, query.route, state.params); + const url = buildUrl(arcBaseUrl, query.route, state.params); const res = await fetch(url, { method: 'GET' }); let body = ''; const contentType = res.headers.get('content-type') ?? ''; @@ -112,14 +122,11 @@ export function QueriesPanel({ arcBaseUrl }: Props) {
- setBaseUrl(e.target.value)} - placeholder="http://localhost:5000" - /> -
{fetchError && ( @@ -131,13 +138,13 @@ export function QueriesPanel({ arcBaseUrl }: Props) { {queries === null && !fetchError && (
-

Enter your Arc base URL and click "Fetch Queries" to discover available queries.

+

Loading queries from Arc introspection…

)} {queries !== null && queries.length === 0 && (
-

No queries discovered from {baseUrl}/.cratis/queries.

+

No queries discovered from {arcBaseUrl}/.cratis/queries.

)} @@ -146,7 +153,7 @@ export function QueriesPanel({ arcBaseUrl }: Props) { {queries.map(query => { const state = states[query.type] ?? { expanded: false, params: {}, result: null, loading: false }; const paramNames = extractPathParams(query.route); - const finalUrl = buildUrl(baseUrl, query.route, state.params); + const finalUrl = buildUrl(arcBaseUrl, query.route, state.params); return (
diff --git a/Source/src/options/components/arc-panel-constants.ts b/Source/src/options/components/arc-panel-constants.ts new file mode 100644 index 0000000..5e94583 --- /dev/null +++ b/Source/src/options/components/arc-panel-constants.ts @@ -0,0 +1 @@ +export const ARC_CONTEXT_UNAVAILABLE_MESSAGE = 'Arc base URL unavailable. Open Lens on an Arc application page.'; diff --git a/Source/src/options/options.css b/Source/src/options/options.css index 049e33e..39c1969 100644 --- a/Source/src/options/options.css +++ b/Source/src/options/options.css @@ -119,6 +119,16 @@ body { min-height: 400px; } +.warning-banner { + background: rgba(249, 226, 175, 0.08); + border: 1px solid var(--accent-yellow); + border-radius: var(--radius-sm); + color: var(--accent-yellow); + font-size: 13px; + padding: 10px 12px; + margin-bottom: 16px; +} + /* Cards */ .card { background: var(--bg-card); @@ -382,6 +392,8 @@ select { /* Arc panel */ .arc-url-row { display: flex; + align-items: center; + justify-content: space-between; gap: 8px; margin-bottom: 16px; } diff --git a/Source/src/popup/Popup.tsx b/Source/src/popup/Popup.tsx index 6d85a13..2670c01 100644 --- a/Source/src/popup/Popup.tsx +++ b/Source/src/popup/Popup.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; import { ExtensionSettings, UserProfile, Tenant } from '../shared/types'; import { getSettings, saveSettings, getActiveUser, getActiveTenant } from '../shared/storage'; +import { captureArcContextForActiveTab, saveArcContextSnapshot } from '../shared/arc-context'; import './popup.css'; export function Popup() { @@ -10,6 +11,12 @@ export function Popup() { getSettings().then(setSettings); }, []); + useEffect(() => { + captureArcContextForActiveTab() + .then(saveArcContextSnapshot) + .catch(() => undefined); + }, []); + const selectUser = useCallback(async (userId: string) => { if (!settings) return; const updated = { ...settings, activeUserId: userId }; diff --git a/Source/src/shared/arc-context.ts b/Source/src/shared/arc-context.ts new file mode 100644 index 0000000..f28e70b --- /dev/null +++ b/Source/src/shared/arc-context.ts @@ -0,0 +1,208 @@ +export interface ArcContextSnapshot { + isArcApplication: boolean; + baseUrl: string | null; + pageOrigin: string | null; + configuration: Record | null; + capturedAt: number; +} + +const ARC_CONTEXT_SNAPSHOT_KEY = 'arcContextSnapshot'; + +interface ArcContextDetectionResult { + isArcApplication: boolean; + baseUrl: string | null; + pageOrigin: string; + configuration: Record | null; +} + +const MAX_SANITIZATION_DEPTH = 6; +const ARC_CONTEXT_MARKER_PROPERTY = 'reconnectQueries'; + +function isObject(value: unknown): value is object { + return typeof value === 'object' && value !== null; +} + +function getOrigin(url: string | undefined): string | null { + if (!url) return null; + try { + return new URL(url).origin; + } catch { + return null; + } +} + +function toSnapshot(result: ArcContextDetectionResult): ArcContextSnapshot { + return { + isArcApplication: result.isArcApplication, + baseUrl: result.baseUrl, + pageOrigin: result.pageOrigin, + configuration: result.configuration, + capturedAt: Date.now(), + }; +} + +function createNonArcSnapshot(pageOrigin: string | null): ArcContextSnapshot { + return { + isArcApplication: false, + baseUrl: null, + pageOrigin, + configuration: null, + capturedAt: Date.now(), + }; +} + +function detectArcContextFromPage(): ArcContextDetectionResult { + function sanitize(value: unknown, depth = 0): unknown { + if (depth > MAX_SANITIZATION_DEPTH) return null; + if (value === null || value === undefined) return value; + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value; + if (Array.isArray(value)) return value.map(item => sanitize(item, depth + 1)); + if (typeof value === 'object') { + const source = value as Record; + const output: Record = {}; + for (const [key, child] of Object.entries(source)) { + if (typeof child === 'function') continue; + output[key] = sanitize(child, depth + 1); + } + return output; + } + return null; + } + + function toAbsoluteBaseUrl(value: unknown, pageOrigin: string): string | null { + if (typeof value !== 'string' || !value.trim()) return null; + try { + return new URL(value, pageOrigin).origin; + } catch { + return null; + } + } + + function getConfiguredBaseUrl(configuration: Record | null, pageOrigin: string): string | null { + if (!configuration) return null; + // Arc apps may expose base URL under different configuration names. + // We check the most explicit/common names first. + const candidates = [ + configuration.baseUrl, + configuration.apiBaseUrl, + configuration.apiSurface, + configuration.apiSurfaceBaseUrl, + configuration.baseUri, + configuration.baseAddress, + ]; + + for (const candidate of candidates) { + const resolved = toAbsoluteBaseUrl(candidate, pageOrigin); + if (resolved) { + return resolved; + } + } + + return null; + } + + const pageOrigin = window.location.origin; + const root = document.getElementById('root'); + if (!root) { + return { + isArcApplication: false, + baseUrl: null, + pageOrigin, + configuration: null, + }; + } + + const fiberKey = Object.keys(root).find(key => key.startsWith('__reactFiber')); + if (!fiberKey) { + return { + isArcApplication: false, + baseUrl: null, + pageOrigin, + configuration: null, + }; + } + + const visited = new WeakSet(); + const fiberNode = (root as unknown as Record)[fiberKey]; + const queue: object[] = isObject(fiberNode) ? [fiberNode] : []; + + while (queue.length > 0) { + const node = queue.pop() as Record | undefined; + if (!node) continue; + if (visited.has(node)) continue; + visited.add(node); + + const memoizedProps = node.memoizedProps as Record | undefined; + const contextValue = memoizedProps?.value; + + if ( + contextValue && + typeof contextValue === 'object' && + ARC_CONTEXT_MARKER_PROPERTY in (contextValue as Record) + ) { + const context = contextValue as Record; + const configuration = sanitize(context.configuration) as Record | null; + const baseUrl = getConfiguredBaseUrl(configuration, pageOrigin) ?? pageOrigin; + + return { + isArcApplication: true, + baseUrl, + pageOrigin, + configuration, + }; + } + + const child = node.child; + const sibling = node.sibling; + const parent = node.return; + if (isObject(child) && !visited.has(child)) queue.push(child); + if (isObject(sibling) && !visited.has(sibling)) queue.push(sibling); + if (isObject(parent) && !visited.has(parent)) queue.push(parent); + } + + return { + isArcApplication: false, + baseUrl: null, + pageOrigin, + configuration: null, + }; +} + +export async function captureArcContextForActiveTab(): Promise { + const [activeTab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true }); + const pageOrigin = getOrigin(activeTab?.url); + if (!activeTab?.id || !activeTab.url) { + return createNonArcSnapshot(pageOrigin); + } + + if (activeTab.url.startsWith('chrome://') || activeTab.url.startsWith('chrome-extension://')) { + return createNonArcSnapshot(pageOrigin); + } + + try { + const [executionResult] = await chrome.scripting.executeScript({ + target: { tabId: activeTab.id }, + // Arc context is exposed on page-owned React fiber nodes, so this needs page-world access. + world: 'MAIN', + func: detectArcContextFromPage, + }); + + const result = executionResult?.result as ArcContextDetectionResult | undefined; + if (!result) { + return createNonArcSnapshot(pageOrigin); + } + + return toSnapshot(result); + } catch { + return createNonArcSnapshot(pageOrigin); + } +} + +export async function saveArcContextSnapshot(snapshot: ArcContextSnapshot): Promise { + await chrome.storage.session.set({ [ARC_CONTEXT_SNAPSHOT_KEY]: snapshot }); +} + +export async function getArcContextSnapshot(): Promise { + const data = await chrome.storage.session.get(ARC_CONTEXT_SNAPSHOT_KEY); + return (data[ARC_CONTEXT_SNAPSHOT_KEY] as ArcContextSnapshot | undefined) ?? null; +}