From d47d80514f78f1f92c3bcdcdde6094c1eab28a50 Mon Sep 17 00:00:00 2001 From: blakebyrnes Date: Wed, 25 Aug 2021 22:13:17 -0400 Subject: [PATCH] fix(chromealive): fix bar positioning and focus --- apps/boss/assets/entitlements.mac.plist | 17 ++++++ apps/boss/lib/Menubar.ts | 16 +++--- apps/boss/package.json | 4 +- .../hero-plugins/WindowBoundsCorePlugin.ts | 18 +++++- apps/chromealive-core/index.ts | 55 +++++++++++++++---- .../lib/AliveBarPositioner.ts | 29 +++++++--- apps/chromealive-core/lib/SessionObserver.ts | 8 ++- apps/chromealive-ui/src/pages/app/index.vue | 21 +++++-- apps/chromealive/lib/ChromeAlive.ts | 27 ++++++--- commons/lib/utils.ts | 10 +++- databox | 2 +- hero | 2 +- 12 files changed, 162 insertions(+), 47 deletions(-) create mode 100644 apps/boss/assets/entitlements.mac.plist diff --git a/apps/boss/assets/entitlements.mac.plist b/apps/boss/assets/entitlements.mac.plist new file mode 100644 index 000000000..e644a263d --- /dev/null +++ b/apps/boss/assets/entitlements.mac.plist @@ -0,0 +1,17 @@ + + + + + com.apple.security.network.client + + com.apple.security.network.server + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + + com.apple.security.cs.disable-library-validation + + diff --git a/apps/boss/lib/Menubar.ts b/apps/boss/lib/Menubar.ts index 05f91b49f..b8d79e51a 100644 --- a/apps/boss/lib/Menubar.ts +++ b/apps/boss/lib/Menubar.ts @@ -56,17 +56,16 @@ export class Menubar extends EventEmitter { } private hideWindow(): void { - if (!this.#browserWindow || !this.#isVisible) { - return; - } - (this.#nsEventMonitor as any)?.stop(); - - this.#browserWindow.hide(); - this.#isVisible = false; if (this.#blurTimeout) { clearTimeout(this.#blurTimeout); this.#blurTimeout = null; } + (this.#nsEventMonitor as any)?.stop(); + + if (this.#browserWindow?.isVisible()) { + this.#browserWindow.hide(); + } + this.#isVisible = false; } private async showWindow(trayPos?: Electron.Rectangle): Promise { @@ -134,9 +133,10 @@ export class Menubar extends EventEmitter { console.warn('Quitting Ulixee Menubar'); e.preventDefault(); await this.stopServer(); + this.hideWindow(); app.exit(); }); - ChromeAliveCore.register(); + ChromeAliveCore.register(true); // for now auto-start this.startServer().catch(console.error); ShutdownHandler.exitOnSignal = false; diff --git a/apps/boss/package.json b/apps/boss/package.json index bac8e1a44..6fa6e860f 100644 --- a/apps/boss/package.json +++ b/apps/boss/package.json @@ -47,7 +47,9 @@ "extendInfo": { "LSUIElement": 1 }, - "target": "dmg" + "target": "dmg", + "entitlements": "assets/entitlements.mac.plist", + "entitlementsInherit": "assets/entitlements.mac.plist" }, "dmg": { "sign": false diff --git a/apps/chromealive-core/hero-plugins/WindowBoundsCorePlugin.ts b/apps/chromealive-core/hero-plugins/WindowBoundsCorePlugin.ts index d10aa7d92..7cd4d9c84 100644 --- a/apps/chromealive-core/hero-plugins/WindowBoundsCorePlugin.ts +++ b/apps/chromealive-core/hero-plugins/WindowBoundsCorePlugin.ts @@ -1,13 +1,29 @@ import { IBounds } from '@ulixee/apps-chromealive-interfaces/apis/IAppBoundsChangedApi'; import CorePlugin from '@ulixee/hero-plugin-utils/lib/CorePlugin'; import { IPuppetPage } from '@ulixee/hero-interfaces/IPuppetPage'; -import { ISessionSummary } from '@ulixee/hero-interfaces/ICorePlugin'; +import { IBrowserEmulatorConfig, ISessionSummary } from '@ulixee/hero-interfaces/ICorePlugin'; import { waitForChromeExtension } from '../lib/activateChromeExtension'; import AliveBarPositioner from '../lib/AliveBarPositioner'; export default class WindowBoundsCorePlugin extends CorePlugin { public static id = '@ulixee/window-bounds-core-plugin'; + configure(options: IBrowserEmulatorConfig): Promise | void { + if ((options.viewport as any)?.isDefault) { + const maxChromeBounds = AliveBarPositioner.getMaxChromeBounds(); + Object.assign(options.viewport, { + width: 0, + height: 0, + deviceScaleFactor: 0, + positionX: maxChromeBounds?.left, + positionY: maxChromeBounds?.top, + screenWidth: maxChromeBounds?.width, + screenHeight: maxChromeBounds?.height, + mobile: undefined, + }); + } + } + onNewPuppetPage(page: IPuppetPage, sessionSummary: ISessionSummary): Promise { if (!sessionSummary.options.showBrowser) return; return Promise.all([ diff --git a/apps/chromealive-core/index.ts b/apps/chromealive-core/index.ts index 643cfc2d3..ee1b3740d 100644 --- a/apps/chromealive-core/index.ts +++ b/apps/chromealive-core/index.ts @@ -5,6 +5,8 @@ import { ChildProcess } from 'child_process'; import launchChromeAlive from '@ulixee/apps-chromealive/index'; import type Puppet from '@ulixee/hero-puppet'; import IDevtoolsSession from '@ulixee/hero-interfaces/IDevtoolsSession'; +import { bindFunctions } from '@ulixee/commons/lib/utils'; +import * as util from 'util'; import FocusedWindowCorePlugin from './hero-plugins/FocusedWindowCorePlugin'; import WindowBoundsCorePlugin from './hero-plugins/WindowBoundsCorePlugin'; import SessionObserver from './lib/SessionObserver'; @@ -12,6 +14,8 @@ import ConnectionToClient from './lib/ConnectionToClient'; import activateChromeExtension from './lib/activateChromeExtension'; import AliveBarPositioner from './lib/AliveBarPositioner'; +util.inspect.defaultOptions.depth = 10; + const debug = Debug('ulixee:chromealive'); export default class ChromeAliveCore { @@ -24,6 +28,9 @@ export default class ChromeAliveCore { public static setCoreServerAddress(address: Promise) { this.coreServerAddress = address; + this.launchApp().catch(err => { + console.error('Cannot launch ChromeAlive app', err); + }); } public static getConnection(): ConnectionToClient { @@ -37,6 +44,7 @@ export default class ChromeAliveCore { HeroGlobalPool.events.off('browser-launched', this.launchApp); HeroGlobalPool.events.off('all-browsers-closed', this.closeApp); HeroGlobalPool.events.off('session-created', this.onHeroSessionCreated); + HeroGlobalPool.events.off('browser-has-no-open-windows', this.onBrowserHasNoWindows); FocusedWindowCorePlugin.onVisibilityChange = null; AliveBarPositioner.getSessionDevtools = null; this.getConnection().close(); @@ -52,17 +60,17 @@ export default class ChromeAliveCore { this.shouldAutoShowBrowser = true; } - this.launchApp = this.launchApp.bind(this); - this.closeApp = this.closeApp.bind(this); - this.onHeroSessionCreated = this.onHeroSessionCreated.bind(this); + bindFunctions(this); + const connection = this.getConnection(); - connection.on('connected', this.onWsConnected.bind(this)); - HeroGlobalPool.events.on('browser-launched', this.launchApp); + connection.on('connected', this.onWsConnected); + HeroGlobalPool.events.on('browser-launched', this.onNewBrowser); HeroGlobalPool.events.on('all-browsers-closed', this.closeApp); HeroGlobalPool.events.on('session-created', this.onHeroSessionCreated); + HeroGlobalPool.events.on('browser-has-no-open-windows', this.onBrowserHasNoWindows); - FocusedWindowCorePlugin.onVisibilityChange = this.changeActiveSessions.bind(this); - AliveBarPositioner.getSessionDevtools = this.getSessionDevtools.bind(this); + FocusedWindowCorePlugin.onVisibilityChange = this.changeActiveSessions; + AliveBarPositioner.getSessionDevtools = this.getSessionDevtools; HeroCore.use(FocusedWindowCorePlugin); HeroCore.use(WindowBoundsCorePlugin); @@ -73,6 +81,7 @@ export default class ChromeAliveCore { if (this.shouldAutoShowBrowser) { heroSession.options.showBrowser = true; heroSession.options.showBrowserInteractions = true; + heroSession.options.viewport ??= { width: 0, height: 0 }; } // if not auto-registered, check if browser is showing @@ -88,7 +97,10 @@ export default class ChromeAliveCore { this.sessionObserversById.set(heroSession.id, sessionObserver); sessionObserver.on('session:updated', this.sendActiveSession.bind(this, heroSession.id)); sessionObserver.on('output:updated', this.sendOutput.bind(this, heroSession.id)); - this.activeHeroSessionId ??= heroSession.id; + if (!this.activeHeroSessionId) { + this.sendEvent('App.show'); + this.activeHeroSessionId = heroSession.id; + } this.sendActiveSession(heroSession.id); } @@ -131,17 +143,39 @@ export default class ChromeAliveCore { return page.devtoolsSession; } - private static async launchApp(event: { puppet: Puppet }): Promise { - const args: string[] = []; + private static async launchApp(): Promise { + if (this.app && !this.app.killed) return; + + const args: string[] = ['--hide']; if (this.coreServerAddress) { args.push(`--coreServerAddress=${await this.coreServerAddress}`); } this.app = launchChromeAlive(...args); + this.app.once('exit', () => (this.app = null)); + this.app.once('close', () => (this.app = null)); debug('Launched Electron App', { file: this.app?.spawnfile, args: this.app?.spawnargs, }); + } + + private static onBrowserHasNoWindows(event: { puppet: Puppet }) { + const browserId = event.puppet.browserId; + setTimeout( + (p: Puppet) => { + const sessionsUsingEngine = HeroSession.sessionsWithBrowserId(browserId); + const hasWindows = sessionsUsingEngine.some(x => x.tabsById.size > 0); + if (!hasWindows) { + return event.puppet.close(); + } + }, + 2e3, + event.puppet, + ).unref(); + } + + private static async onNewBrowser(event: { puppet: Puppet }): Promise { await activateChromeExtension(event.puppet); } @@ -165,6 +199,7 @@ export default class ChromeAliveCore { eventType: T, data: IChromeAliveEvents[T] = null, ) { + console.log('SendEvent', { eventType, data }); this.getConnection().sendEvent({ eventType, data }); } } diff --git a/apps/chromealive-core/lib/AliveBarPositioner.ts b/apps/chromealive-core/lib/AliveBarPositioner.ts index 666eb1382..2eaf42cfa 100644 --- a/apps/chromealive-core/lib/AliveBarPositioner.ts +++ b/apps/chromealive-core/lib/AliveBarPositioner.ts @@ -7,13 +7,27 @@ const debug = Debug('ulixee:chromealive'); export default class AliveBarPositioner { public static getSessionDevtools: (sessionId: string) => IDevtoolsSession; - private static lastToolbarBounds: IBounds; private static workarea: IBounds; + private static lastToolbarBounds: IBounds; + private static isFirstAdjustment = true; private static lastWindowBoundsBySessionId: { [sessionId: string]: IBounds & { windowId: number }; } = {}; + public static getMaxChromeBounds(): IBounds | null { + if (!this.workarea || !this.lastToolbarBounds) return null; + + const { top, height } = this.lastToolbarBounds; + const toolbarBottom = top + height + 1; + return { + top: toolbarBottom, + left: this.lastToolbarBounds.left, + width: this.lastToolbarBounds.width, + height: this.workarea.height - this.lastToolbarBounds.height, + }; + } + public static onChromeWindowBoundsChanged( sessionId: string, windowId: number, @@ -39,19 +53,20 @@ export default class AliveBarPositioner { if (!this.lastToolbarBounds) return; const chromeBounds = this.lastWindowBoundsBySessionId[sessionId]; - const { top, height } = this.lastToolbarBounds; - const toolbarBottom = top + height + 2; + let newBounds = this.getMaxChromeBounds(); - if (chromeBounds.top < toolbarBottom) { + if (chromeBounds.top < newBounds.top) { const devtools = this.getSessionDevtools(sessionId); if (!devtools) return; + if (!this.isFirstAdjustment) { + newBounds = { top: newBounds.top } as any; + } + this.isFirstAdjustment = false; devtools .send('Browser.setWindowBounds', { windowId: chromeBounds.windowId, - bounds: { - top: toolbarBottom, - }, + bounds: newBounds, }) .catch(() => null); } diff --git a/apps/chromealive-core/lib/SessionObserver.ts b/apps/chromealive-core/lib/SessionObserver.ts index a8232c748..d4f967152 100644 --- a/apps/chromealive-core/lib/SessionObserver.ts +++ b/apps/chromealive-core/lib/SessionObserver.ts @@ -52,7 +52,11 @@ export default class SessionObserver extends TypedEventEmitter<{ const runCommands = this.heroSession.sessionState.commands.filter( x => x.run === this.heroSession.resumeCounter, ); - const thisRunUrls = new Set(runCommands.map(x => x.url)); + const thisRunUrls = new Set(); + for (const command of runCommands) { + thisRunUrls.add(command.url); + if (command.result?.url) thisRunUrls.add(command.result.url); + } const runStart = runCommands[0]?.runStartDate; const loadedUrls = this.loadedUrls.filter( @@ -170,7 +174,7 @@ export default class SessionObserver extends TypedEventEmitter<{ // update url in case it changed sessionUrl.url = status.url; - if (status.newStatus === 'ContentPaint') { + if (status.newStatus === 'ContentPaint' || !sessionUrl.screenshotBase64) { try { const screenshot = await tab.puppetPage.screenshot('jpeg', undefined, 50); sessionUrl.screenshotBase64 = screenshot.toString('base64'); diff --git a/apps/chromealive-ui/src/pages/app/index.vue b/apps/chromealive-ui/src/pages/app/index.vue index b57fa316d..9dd79a00b 100644 --- a/apps/chromealive-ui/src/pages/app/index.vue +++ b/apps/chromealive-ui/src/pages/app/index.vue @@ -44,7 +44,7 @@ class="output app-button" ref="outputButton" @click.prevent="toggleOutput()" - :class="{ selected: isShowingOutput }" + :class="{ selected: !!outputWindow }" > Output ({{ outputSize }}) @@ -126,8 +126,6 @@ import flattenJson, { FlatJson } from '@/utils/flattenJson'; }) export default class ChromeAliveApp extends Vue { private client = Client; - private isPlaying = false; - private isShowingOutput = false; private isShowingInput = false; private scriptTimeAgo = ''; private timeAgoTimeout: number; @@ -162,6 +160,10 @@ export default class ChromeAliveApp extends Vue { private screenshotsByNavigationId = new Map(); + private get isPlaying() { + return this.session?.state === 'play'; + } + canPlay(): boolean { if (!this.session.heroSessionId) return false; return this.session.state === 'paused'; @@ -228,8 +230,7 @@ export default class ChromeAliveApp extends Vue { } toggleOutput() { - this.isShowingOutput = !this.isShowingOutput; - if (!this.isShowingOutput) { + if (this.outputWindow) { this.outputWindow.close(); this.outputWindow = null; } else { @@ -237,6 +238,13 @@ export default class ChromeAliveApp extends Vue { const { bottom } = (this.$refs.toolbar as HTMLElement).getBoundingClientRect(); const features = `top=${bottom},left=${left},width=300,height=500,frame=true,nodeIntegration=no`; this.outputWindow = window.open('/output.html', '_blank', features); + this.outputWindow.addEventListener('blur', () => { + this.outputWindow?.close() + this.outputWindow = null; + }); + this.outputWindow.addEventListener('close', () => { + this.outputWindow = null; + }) } } @@ -335,6 +343,9 @@ export default class ChromeAliveApp extends Vue { return; } + this.lastAppBounds = appBounds; + this.lastToolbarBounds = toolbarBounds + await this.client.connect(); await this.client.send('App.boundsChanged', { workarea: (window as any).workarea, diff --git a/apps/chromealive/lib/ChromeAlive.ts b/apps/chromealive/lib/ChromeAlive.ts index 17d912ff2..473a7a646 100644 --- a/apps/chromealive/lib/ChromeAlive.ts +++ b/apps/chromealive/lib/ChromeAlive.ts @@ -15,6 +15,7 @@ export class ChromeAlive extends EventEmitter { #vueServer: Http.Server; #vueAddress: Promise; #resetAlwaysTopTimeout: NodeJS.Timeout; + #hideOnLaunch = false; constructor(readonly coreServerAddress?: string) { super(); @@ -23,6 +24,8 @@ export class ChromeAlive extends EventEmitter { .find(x => x.startsWith('--coreServerAddress=')) ?.replace('--coreServerAddress=', ''); + this.#hideOnLaunch = process.argv.some(x => x === '--hide'); + // hide the dock icon if it shows if (process.platform === 'darwin') { app.setActivationPolicy('accessory'); @@ -87,10 +90,7 @@ export class ChromeAlive extends EventEmitter { if (this.#browserWindow.isAlwaysOnTop()) { clearTimeout(this.#resetAlwaysTopTimeout); - this.#resetAlwaysTopTimeout = setTimeout( - () => this.#browserWindow.setAlwaysOnTop(false), - 1e3, - ); + this.#resetAlwaysTopTimeout = setTimeout(() => this.#browserWindow.setAlwaysOnTop(false), 50); } this.#isVisible = true; } @@ -102,7 +102,11 @@ export class ChromeAlive extends EventEmitter { private async appReady(): Promise { try { - await this.showWindow(); + await this.createWindow(); + if (!this.#hideOnLaunch) { + await this.showWindow(); + } + ShutdownHandler.register(() => this.appExit()); this.emit('ready'); @@ -116,12 +120,15 @@ export class ChromeAlive extends EventEmitter { const workarea = mainScreen.workArea; this.#browserWindow = new BrowserWindow({ + show: false, frame: false, roundedCorners: false, fullscreenable: false, transparent: true, movable: false, closable: true, + acceptFirstMouse: true, + paintWhenInitiallyHidden: true, hasShadow: false, skipTaskbar: true, autoHideMenuBar: true, @@ -151,10 +158,12 @@ export class ChromeAlive extends EventEmitter { }; }); - app.on('browser-window-blur', (event, window) => { - if (window.getParentWindow()?.id === this.#browserWindow.id) { - window.close(); - } + this.#browserWindow.webContents.on('did-create-window', childWindow => { + childWindow.on('blur', e => { + childWindow.hide(); + childWindow.close(); + e.preventDefault(); + }); }); this.#browserWindow.on('close', () => app.exit()); diff --git a/commons/lib/utils.ts b/commons/lib/utils.ts index 8d0f0e842..66e8b0536 100644 --- a/commons/lib/utils.ts +++ b/commons/lib/utils.ts @@ -17,7 +17,7 @@ export function getCallSite(priorToFilename?: string, endFilename?: string): Cal Error.prepareStackTrace = (_, stack) => stack; - let stack = (err.stack as unknown) as CallSite[]; + let stack = err.stack as unknown as CallSite[]; Error.prepareStackTrace = undefined; let startIndex = 1; @@ -53,7 +53,13 @@ export function bindFunctions(self: any): void { continue; } const descriptor = Reflect.getOwnPropertyDescriptor(object, key); - if (descriptor && typeof descriptor.value === 'function') { + if ( + descriptor && + typeof descriptor.value === 'function' && + !descriptor.get && + !descriptor.set && + descriptor.writable + ) { self[key] = self[key].bind(self); } } diff --git a/databox b/databox index a3e62af34..b9c15ffef 160000 --- a/databox +++ b/databox @@ -1 +1 @@ -Subproject commit a3e62af344da1edb63b8b52eeb9c3f1fc08b1b3f +Subproject commit b9c15ffefc068d9fee2d9cea44382eb97cc14161 diff --git a/hero b/hero index 34ad683c5..05901d25e 160000 --- a/hero +++ b/hero @@ -1 +1 @@ -Subproject commit 34ad683c526cbf93b5dc15a61f0e51038021491b +Subproject commit 05901d25e4d38d24e312ab01bd0d597afdbec586