Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions apps/studio/electron.vite.config.new-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,15 @@ export default defineConfig( {
preload: {
build: {
externalizeDeps: { exclude: [ '@sentry/electron' ] },
lib: {
entry: resolve( __dirname, 'src/preload.ts' ),
rollupOptions: {
input: {
preload: resolve( __dirname, 'src/preload.ts' ),
'preview-preload': resolve( __dirname, 'src/preview-preload.ts' ),
},
output: {
entryFileNames: '[name].js',
format: 'cjs',
},
},
},
},
Expand Down
11 changes: 9 additions & 2 deletions apps/studio/electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,15 @@ export default defineConfig({
preload: {
build: {
externalizeDeps: { exclude: [ '@sentry/electron' ] },
lib: {
entry: resolve( __dirname, 'src/preload.ts' ),
rollupOptions: {
input: {
preload: resolve( __dirname, 'src/preload.ts' ),
'preview-preload': resolve( __dirname, 'src/preview-preload.ts' ),
},
output: {
entryFileNames: '[name].js',
format: 'cjs',
},
},
},
},
Expand Down
56 changes: 54 additions & 2 deletions apps/studio/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
Menu,
dialog,
MessageBoxSyncOptions,
shell,
type Event as ElectronEvent,
} from 'electron';
import path from 'path';
import { pathToFileURL } from 'url';
Expand Down Expand Up @@ -49,6 +51,11 @@ import { isStudioCliInstalled } from 'src/modules/cli/lib/ipc-handlers';
import { autoInstallMacOSCliIfNeeded } from 'src/modules/cli/lib/macos-installation-manager';
import { autoInstallWindowsCliIfNeeded } from 'src/modules/cli/lib/windows-installation-manager';
import { stopAllProcesses as stopAllStudioCodeProcesses } from 'src/modules/studio-code';
import {
isPreviewNavigationAllowed,
isPreviewWebContents,
loadPreviewWebContentsURL,
} from 'src/preview-view';
import { getRunningSiteCount, SiteServer, stopAllServers } from 'src/site-server';
import {
loadUserData,
Expand All @@ -71,6 +78,18 @@ function getRendererUrl(): string {
}
}

function openExternalWebUrl( url: string ): void {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Is this function needed don't we have similar things already?

try {
const parsedUrl = new URL( url );
if ( ! [ 'http:', 'https:' ].includes( parsedUrl.protocol ) ) {
return;
}
void shell.openExternal( parsedUrl.toString() ).catch( () => undefined );
} catch {
// Ignore malformed URLs from untrusted pages.
}
}

if ( ! process.env.IS_DEV_BUILD ) {
const { sentryRelease, isDevEnvironment } = getSentryReleaseInfo( app.getVersion() );

Expand Down Expand Up @@ -158,16 +177,49 @@ async function appBoot() {
// be able to perform privileged operations.
app.enableSandbox();

// Prevent navigation to anywhere other than known locations
// Prevent navigation to anywhere other than known locations.
// The site preview's `WebContentsView` has its own site-scoped policy,
// since it intentionally loads local WordPress pages outside the renderer
// origin.
app.on( 'web-contents-created', ( _event, contents ) => {
const blockExternalPreviewNavigation = (
event: ElectronEvent,
navigationUrl: string
): boolean => {
if ( isPreviewWebContents( contents.id ) ) {
if ( ! isPreviewNavigationAllowed( contents.id, navigationUrl ) ) {
event.preventDefault();
openExternalWebUrl( navigationUrl );
}
return true;
}
return false;
};

contents.on( 'will-navigate', ( event, navigationUrl ) => {
if ( blockExternalPreviewNavigation( event, navigationUrl ) ) {
return;
}
const { origin } = new URL( navigationUrl );
const allowedOrigins = [ new URL( getRendererUrl() ).origin ];
if ( ! allowedOrigins.includes( origin ) ) {
event.preventDefault();
}
} );
contents.setWindowOpenHandler( () => {
contents.on( 'will-redirect', ( event, navigationUrl ) => {
blockExternalPreviewNavigation( event, navigationUrl );
} );
contents.setWindowOpenHandler( ( details ) => {
if ( isPreviewWebContents( contents.id ) ) {
// Site-preview popups (target="_blank", admin-bar links, …)
// load inside the preview only when they stay on the same site.
// External URLs leave the preview bridge and open in the browser.
void loadPreviewWebContentsURL( contents.id, details.url ).then( ( loaded ) => {
if ( ! loaded ) {
openExternalWebUrl( details.url );
}
} );
}
return { action: 'deny' };
} );
} );
Expand Down
82 changes: 82 additions & 0 deletions apps/studio/src/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1867,6 +1867,88 @@ export async function isFullscreen( _event: IpcMainInvokeEvent ): Promise< boole
return window.isFullScreen();
}

function getSitePreviewBaseUrl( siteId: string ): { url: string; allowedOrigins: string[] } {
const server = SiteServer.get( siteId );
if ( ! server || ! server.details.running ) {
throw new Error( 'Cannot create preview for a site that is not running.' );
}

const details = server.details;
const baseUrl = details.customDomain
? `${ details.enableHttps ? 'https' : 'http' }://${ details.customDomain }`
: `http://localhost:${ details.port }`;

return {
url: baseUrl,
allowedOrigins: [ new URL( baseUrl ).origin ],
};
}

function resolveSitePreviewUrl( siteId: string, pathname: string = '/' ): string {
const { url: baseUrl, allowedOrigins } = getSitePreviewBaseUrl( siteId );
const resolvedUrl = new URL( pathname || '/', baseUrl );
if ( ! allowedOrigins.includes( resolvedUrl.origin ) ) {
throw new Error( 'Preview navigation blocked: path resolves outside the site origin.' );
}
return resolvedUrl.toString();
}

export async function createPreviewView(
event: IpcMainInvokeEvent,
options: {
siteId: string;
path?: string;
bounds: { x: number; y: number; width: number; height: number };
enableInspector?: boolean;
borderRadius?: number;
}
): Promise< { viewId: string } > {
const { PreviewView, registerPreviewView, disposePreviewViewsForOwner } = await import(
'src/preview-view'
);
const window = await getMainWindow();
const { allowedOrigins } = getSitePreviewBaseUrl( options.siteId );
const view = new PreviewView( window, {
...options,
url: resolveSitePreviewUrl( options.siteId, options.path ),
allowedOrigins,
ownerWebContentsId: event.sender.id,
} );
registerPreviewView( view );
event.sender.once( 'destroyed', () => disposePreviewViewsForOwner( event.sender.id ) );
return { viewId: view.id };
}

export async function setPreviewViewBounds(
event: IpcMainInvokeEvent,
viewId: string,
bounds: { x: number; y: number; width: number; height: number }
): Promise< void > {
const { getPreviewView } = await import( 'src/preview-view' );
getPreviewView( viewId, event.sender.id )?.setBounds( bounds );
}

export async function navigatePreviewView(
event: IpcMainInvokeEvent,
viewId: string,
path: string
): Promise< void > {
const { getPreviewView } = await import( 'src/preview-view' );
const view = getPreviewView( viewId, event.sender.id );
if ( ! view ) return;
await view.loadURL( resolveSitePreviewUrl( view.siteId, path ) );
}

export async function destroyPreviewView(
event: IpcMainInvokeEvent,
viewId: string
): Promise< void > {
const { disposePreviewView, getPreviewView } = await import( 'src/preview-view' );
if ( getPreviewView( viewId, event.sender.id ) ) {
disposePreviewView( viewId );
}
}

export async function getAllCustomDomains(): Promise< string[] > {
return SiteServer.getAllDetails()
.map( ( site ) => site.customDomain )
Expand Down
4 changes: 4 additions & 0 deletions apps/studio/src/ipc-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ export interface IpcEvents {
'beta-features-updated': [ void ];
'ai-agent-event': [ AgentRunEvent ];
'studio-code-event': [ { siteId: string; event: StudioCodeEvent } ];
// Inspector events forwarded from a `WebContentsView`-backed site preview.
// Renderers subscribe through `ipcListener` and dispatch to the
// `viewId`-matching consumer (today: `SitePreview`'s annotate flow).
'preview-view:event': [ { viewId: string; payload: unknown } ];
}

export async function sendIpcEventToRenderer< T extends keyof IpcEvents >(
Expand Down
5 changes: 5 additions & 0 deletions apps/studio/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,11 @@ const api: IpcApi = {
ipcRendererInvoke( 'answerAiAgentQuestion', runId, answers ),
setSessionEnvironment: ( sessionId, environment ) =>
ipcRendererInvoke( 'setSessionEnvironment', sessionId, environment ),
createPreviewView: ( options ) => ipcRendererInvoke( 'createPreviewView', options ),
setPreviewViewBounds: ( viewId, bounds ) =>
ipcRendererInvoke( 'setPreviewViewBounds', viewId, bounds ),
navigatePreviewView: ( viewId, path ) => ipcRendererInvoke( 'navigatePreviewView', viewId, path ),
destroyPreviewView: ( viewId ) => ipcRendererInvoke( 'destroyPreviewView', viewId ),
};

contextBridge.exposeInMainWorld( 'ipcApi', api );
Expand Down
Loading
Loading