From 02fcd928b22151a34bfa01e192dd1a6ef856ed8b Mon Sep 17 00:00:00 2001 From: AleksanderBodurri Date: Thu, 25 Jan 2024 19:31:57 -0500 Subject: [PATCH] refactor(devtools): implement iframe support for Angular DevTools' browser code (#53934) Modifies the messaging layer of devtools to allow for switching communication between frames on a page. When served as a browser extension. Design: - When a page renders, DevTools installs a content script onto it through it's manifest file. The all_frames option is used here to install this script onto every frame in a page. - When Angular is detected, the content script will install a backend script into it's frame. - Each content script / backend script pairing is kept track of in the background script. This pairing represents an angular devtools context in a particular frame. - Angular DevTools is able to ask the background script to list each frame that has been registered on a page. - Angular Devtools is able to ask the background script to "enable" the connection on a particular frame. This enables the messaging between the content script <-> background script <-> devtools page Limitations: - The `inspectedWindow.eval` API is only able to target frames by frameURL. This means some features that integrate with Chrome DevTools like inspect element and open source will not be available when inspecting frames that do not have a unique url on the page. PR Close #53934 --- .../projects/protocol/src/lib/messages.ts | 22 ++ .../shell-browser/src/app/BUILD.bazel | 43 ++- .../shell-browser/src/app/app.component.ts | 22 +- .../shell-browser/src/app/app.module.ts | 18 +- .../projects/shell-browser/src/app/backend.ts | 6 +- .../shell-browser/src/app/background.ts | 242 +++--------- .../src/app/chrome-application-environment.ts | 2 + .../src/app/chrome-application-operations.ts | 41 +- .../shell-browser/src/app/content-script.ts | 137 ++++--- .../app/detect-angular-for-extension-icon.ts | 42 ++- .../projects/shell-browser/src/app/inject.ts | 51 --- .../shell-browser/src/app/tab_manager.ts | 245 ++++++++++++ .../shell-browser/src/app/tab_manager_spec.ts | 355 ++++++++++++++++++ .../src/manifest/manifest.chrome.json | 16 +- .../src/manifest/manifest.firefox.json | 19 +- 15 files changed, 903 insertions(+), 358 deletions(-) delete mode 100644 devtools/projects/shell-browser/src/app/inject.ts create mode 100644 devtools/projects/shell-browser/src/app/tab_manager.ts create mode 100644 devtools/projects/shell-browser/src/app/tab_manager_spec.ts diff --git a/devtools/projects/protocol/src/lib/messages.ts b/devtools/projects/protocol/src/lib/messages.ts index 20f3c64a002..5ae4c73a47e 100644 --- a/devtools/projects/protocol/src/lib/messages.ts +++ b/devtools/projects/protocol/src/lib/messages.ts @@ -213,6 +213,19 @@ export interface Route { isAux: boolean; } +export interface AngularDetection { + // This is necessary because the runtime + // message listener handles messages globally + // including from other extensions. We don't + // want to set icon and/or popup based on + // a message coming from an unrelated extension. + isAngularDevTools: true; + isIvy: boolean; + isAngular: boolean; + isDebugMode: boolean; + isSupportedAngularVersion: boolean; +} + export type Topic = keyof Events; export interface InjectorGraphViewQuery { @@ -273,4 +286,13 @@ export interface Events { ) => void; logProvider: (injector: SerializedInjector, providers: SerializedProviderRecord) => void; + + contentScriptConnected: (frameId: number, name: string, url: string) => void; + contentScriptDisconnected: (frameId: number, name: string, url: string) => void; + enableFrameConnection: (frameId: number, tabId: number) => void; + frameConnected: (frameId: number) => void; + detectAngular: (detectionResult: AngularDetection) => void; + backendReady: () => void; + + log: (logEvent: {message: string; level: 'log' | 'warn' | 'debug' | 'error'}) => void; } diff --git a/devtools/projects/shell-browser/src/app/BUILD.bazel b/devtools/projects/shell-browser/src/app/BUILD.bazel index 83e207de816..06fe4ab97f3 100644 --- a/devtools/projects/shell-browser/src/app/BUILD.bazel +++ b/devtools/projects/shell-browser/src/app/BUILD.bazel @@ -1,7 +1,8 @@ load("@io_bazel_rules_sass//:defs.bzl", "sass_binary") load("//devtools/tools:ng_module.bzl", "ng_module") -load("//devtools/tools:typescript.bzl", "ts_library") +load("//devtools/tools:typescript.bzl", "ts_library", "ts_test_library") load("//tools:defaults.bzl", "esbuild") +load("//devtools/tools:defaults.bzl", "karma_web_test_suite") package(default_visibility = ["//visibility:public"]) @@ -15,7 +16,6 @@ ng_module( srcs = [ "app.component.ts", "app.module.ts", - "inject.ts", ], angular_assets = [ "app.component.html", @@ -35,6 +35,8 @@ ng_module( "//packages/core", "//packages/platform-browser", "//packages/platform-browser/animations", + "@npm//@angular/material", + "@npm//rxjs", ], ) @@ -78,8 +80,10 @@ ts_library( "chrome-application-operations.ts", ], deps = [ + ":chrome_application_environment", "//devtools/projects/ng-devtools", "//devtools/projects/protocol", + "//packages/core", "@npm//@types", ], ) @@ -126,7 +130,38 @@ ts_library( srcs = [ "background.ts", ], - deps = ["//devtools/projects/shell-browser/src/app:detect_angular_for_extension_icon"], + deps = [ + ":tab_manager", + "//devtools/projects/protocol", + "//devtools/projects/shell-browser/src/app:detect_angular_for_extension_icon", + ], +) + +ts_library( + name = "tab_manager", + srcs = [ + "tab_manager.ts", + ], + deps = [ + "//devtools/projects/protocol", + ], +) + +karma_web_test_suite( + name = "tab_manager_test", + deps = [ + ":tab_manager_test_lib", + ], +) + +ts_test_library( + name = "tab_manager_test_lib", + srcs = [ + "tab_manager_spec.ts", + ], + deps = [ + ":tab_manager", + ], ) ts_library( @@ -162,6 +197,8 @@ ts_library( "detect-angular-for-extension-icon.ts", ], deps = [ + ":same_page_message_bus", + "//devtools/projects/protocol", "//devtools/projects/shared-utils", "@npm//@types", ], diff --git a/devtools/projects/shell-browser/src/app/app.component.ts b/devtools/projects/shell-browser/src/app/app.component.ts index 0e6d69fe230..0b0be191620 100644 --- a/devtools/projects/shell-browser/src/app/app.component.ts +++ b/devtools/projects/shell-browser/src/app/app.component.ts @@ -6,39 +6,21 @@ * found in the LICENSE file at https://angular.io/license */ -import {ChangeDetectorRef, Component, NgZone, OnInit} from '@angular/core'; -import {Events, MessageBus, PriorityAwareMessageBus} from 'protocol'; - -import {injectScripts} from './inject'; -import {ZoneAwareChromeMessageBus} from './zone-aware-chrome-message-bus'; -import {DevToolsComponent} from 'ng-devtools'; +import {ChangeDetectorRef, Component, inject, OnInit} from '@angular/core'; @Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.scss'], - providers: [ - { - provide: MessageBus, - useFactory(ngZone: NgZone): MessageBus { - const port = chrome.runtime.connect({ - name: '' + chrome.devtools.inspectedWindow.tabId, - }); - return new PriorityAwareMessageBus(new ZoneAwareChromeMessageBus(port, ngZone)); - }, - deps: [NgZone], - }, - ], }) export class AppComponent implements OnInit { - constructor(private _cd: ChangeDetectorRef) {} + private _cd = inject(ChangeDetectorRef); ngOnInit(): void { chrome.devtools.network.onNavigated.addListener(() => { window.location.reload(); }); - injectScripts(['app/backend_bundle.js']); this._cd.detectChanges(); } } diff --git a/devtools/projects/shell-browser/src/app/app.module.ts b/devtools/projects/shell-browser/src/app/app.module.ts index 612efc6889e..bc10e6838b9 100644 --- a/devtools/projects/shell-browser/src/app/app.module.ts +++ b/devtools/projects/shell-browser/src/app/app.module.ts @@ -6,17 +6,20 @@ * found in the LICENSE file at https://angular.io/license */ -import {NgModule} from '@angular/core'; +import {NgModule, NgZone} from '@angular/core'; +import {MatSelect} from '@angular/material/select'; import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; import {ApplicationEnvironment, ApplicationOperations, DevToolsComponent} from 'ng-devtools'; import {AppComponent} from './app.component'; import {ChromeApplicationEnvironment} from './chrome-application-environment'; import {ChromeApplicationOperations} from './chrome-application-operations'; +import {ZoneAwareChromeMessageBus} from './zone-aware-chrome-message-bus'; +import {Events, MessageBus, PriorityAwareMessageBus} from 'protocol'; @NgModule({ declarations: [AppComponent], - imports: [BrowserAnimationsModule, DevToolsComponent], + imports: [BrowserAnimationsModule, DevToolsComponent, MatSelect], bootstrap: [AppComponent], providers: [ { @@ -27,6 +30,17 @@ import {ChromeApplicationOperations} from './chrome-application-operations'; provide: ApplicationEnvironment, useClass: ChromeApplicationEnvironment, }, + { + provide: MessageBus, + useFactory(ngZone: NgZone): MessageBus { + const port = chrome.runtime.connect({ + name: '' + chrome.devtools.inspectedWindow.tabId, + }); + + return new PriorityAwareMessageBus(new ZoneAwareChromeMessageBus(port, ngZone)); + }, + deps: [NgZone], + }, ], }) export class AppModule {} diff --git a/devtools/projects/shell-browser/src/app/backend.ts b/devtools/projects/shell-browser/src/app/backend.ts index 8dadbe5e996..585008ce190 100644 --- a/devtools/projects/shell-browser/src/app/backend.ts +++ b/devtools/projects/shell-browser/src/app/backend.ts @@ -14,8 +14,8 @@ import {initializeExtendedWindowOperations} from './chrome-window-extensions'; import {SamePageMessageBus} from './same-page-message-bus'; const messageBus = new SamePageMessageBus( - 'angular-devtools-backend', - 'angular-devtools-content-script', + `angular-devtools-backend-${location.href}`, + `angular-devtools-content-script-${location.href}`, ); let initialized = false; @@ -47,4 +47,6 @@ messageBus.on('handshake', () => { }, false, ); + + messageBus.emit('backendReady'); }); diff --git a/devtools/projects/shell-browser/src/app/background.ts b/devtools/projects/shell-browser/src/app/background.ts index 80b94d32513..fda02d5a8dd 100644 --- a/devtools/projects/shell-browser/src/app/background.ts +++ b/devtools/projects/shell-browser/src/app/background.ts @@ -8,164 +8,10 @@ /// -import {AngularDetection} from './detect-angular-for-extension-icon'; +import {AngularDetection} from 'protocol'; +import {TabManager, Tabs} from './tab_manager'; -const isManifestV3 = chrome.runtime.getManifest().manifest_version === 3; - -const browserAction = (() => { - // Electron does not expose browserAction object, - // Use empty calls as fallback if they are not defined. - const noopAction = {setIcon: () => {}, setPopup: () => {}}; - - if (isManifestV3) { - return chrome.action || noopAction; - } - - return chrome.browserAction || noopAction; -})(); - -// By default use the black and white icon. -// Replace it only when we detect an Angular app. -browserAction.setIcon( - { - path: { - 16: chrome.runtime.getURL(`assets/icon-bw16.png`), - 48: chrome.runtime.getURL(`assets/icon-bw48.png`), - 128: chrome.runtime.getURL(`assets/icon-bw128.png`), - }, - }, - () => {}, -); - -const ports: { - [tab: string]: - | { - 'content-script': chrome.runtime.Port | null; - devtools: chrome.runtime.Port | null; - } - | undefined; -} = {}; - -chrome.runtime.onConnect.addListener((port) => { - let tab: string | null = null; - let name: 'devtools' | 'content-script' | null = null; - // tslint:disable-next-line:no-console - console.log('Connection event in the background script'); - - if (isNumeric(port.name)) { - tab = port.name; - - // tslint:disable-next-line:no-console - console.log('Angular devtools connected, injecting the content script', port.name, ports[tab]); - - name = 'devtools'; - installContentScript(parseInt(port.name, 10)); - } else { - if (!port.sender || !port.sender.tab) { - // tslint:disable-next-line:no-console - console.error('Unable to access the port sender and sender tab'); - - return; - } - if (port.sender.tab.id === undefined) { - // tslint:disable-next-line:no-console - console.error('Sender tab id is undefined'); - - return; - } - - // tslint:disable-next-line:no-console - console.log('Content script connected', port.sender.tab.id); - tab = port.sender.tab.id.toString(); - name = 'content-script'; - } - - let portsTab = ports[tab]; - if (!portsTab) { - // tslint:disable-next-line:no-console - console.log('Creating a tab port'); - - portsTab = ports[tab] = { - devtools: null, - 'content-script': null, - }; - } - - portsTab[name] = port; - - if (portsTab.devtools && portsTab['content-script']) { - doublePipe(portsTab.devtools, portsTab['content-script'], tab); - } -}); - -const isNumeric = (str: string): boolean => { - return +str + '' === str; -}; - -const installContentScript = (tabId: number) => { - // tslint:disable-next-line:no-console - console.log('Installing the content-script'); - - // We first inject the content-script and after that - // invoke the global that it exposes. - - if (isManifestV3) { - chrome.scripting.executeScript( - {files: ['app/content_script_bundle.js'], target: {tabId}}, - () => { - chrome.scripting.executeScript({func: () => (globalThis as any).main(), target: {tabId}}); - }, - ); - - return; - } - - // manifest V2 APIs - chrome.tabs.executeScript(tabId, {file: 'app/content_script_bundle.js'}, (result) => { - chrome.tabs.executeScript(tabId, { - code: 'globalThis.main()', - }); - }); -}; - -const doublePipe = ( - devtoolsPort: chrome.runtime.Port | null, - contentScriptPort: chrome.runtime.Port, - tab: string, -) => { - if (devtoolsPort === null) { - console.warn('DevTools port is equal to null'); - return; - } - - // tslint:disable-next-line:no-console - console.log('Creating two-way communication channel', Date.now(), ports); - - const onDevToolsMessage = (message: chrome.runtime.Port) => { - contentScriptPort.postMessage(message); - }; - devtoolsPort.onMessage.addListener(onDevToolsMessage); - - const onContentScriptMessage = (message: chrome.runtime.Port) => { - devtoolsPort.postMessage(message); - }; - contentScriptPort.onMessage.addListener(onContentScriptMessage); - - const shutdown = (source: string) => { - // tslint:disable-next-line:no-console - console.log('Disconnecting', source); - - devtoolsPort.onMessage.removeListener(onDevToolsMessage); - contentScriptPort.onMessage.removeListener(onContentScriptMessage); - devtoolsPort.disconnect(); - contentScriptPort.disconnect(); - ports[tab] = undefined; - }; - devtoolsPort.onDisconnect.addListener(shutdown.bind(null, 'devtools')); - contentScriptPort.onDisconnect.addListener(shutdown.bind(null, 'content-script')); -}; - -const getPopUpName = (ng: AngularDetection) => { +function getPopUpName(ng: AngularDetection): string { if (!ng.isAngular) { return 'not-angular.html'; } @@ -176,29 +22,63 @@ const getPopUpName = (ng: AngularDetection) => { return 'production.html'; } return 'supported.html'; -}; +} -chrome.runtime.onMessage.addListener((req: AngularDetection, sender) => { - if (!req.isAngularDevTools) { - return; - } - if (sender && sender.tab) { - browserAction.setPopup({ - tabId: sender.tab.id, - popup: `popups/${getPopUpName(req)}`, - }); - } - if (sender && sender.tab && req.isAngular) { - browserAction.setIcon( - { - tabId: sender.tab.id, - path: { - 16: chrome.runtime.getURL(`assets/icon16.png`), - 48: chrome.runtime.getURL(`assets/icon48.png`), - 128: chrome.runtime.getURL(`assets/icon128.png`), - }, +if (chrome !== undefined && chrome.runtime !== undefined) { + const isManifestV3 = chrome.runtime.getManifest().manifest_version === 3; + + const browserAction = (() => { + // Electron does not expose browserAction object, + // Use empty calls as fallback if they are not defined. + const noopAction = {setIcon: () => {}, setPopup: () => {}}; + + if (isManifestV3) { + return chrome.action || noopAction; + } + + return chrome.browserAction || noopAction; + })(); + + // By default use the black and white icon. + // Replace it only when we detect an Angular app. + browserAction.setIcon( + { + path: { + 16: chrome.runtime.getURL(`assets/icon-bw16.png`), + 48: chrome.runtime.getURL(`assets/icon-bw48.png`), + 128: chrome.runtime.getURL(`assets/icon-bw128.png`), }, - () => {}, - ); - } -}); + }, + () => {}, + ); + + chrome.runtime.onMessage.addListener((req: AngularDetection, sender) => { + if (!req.isAngularDevTools) { + return; + } + + if (sender && sender.tab) { + browserAction.setPopup({ + tabId: sender.tab.id, + popup: `popups/${getPopUpName(req)}`, + }); + } + + if (sender && sender.tab && req.isAngular) { + browserAction.setIcon( + { + tabId: sender.tab.id, + path: { + 16: chrome.runtime.getURL(`assets/icon16.png`), + 48: chrome.runtime.getURL(`assets/icon48.png`), + 128: chrome.runtime.getURL(`assets/icon128.png`), + }, + }, + () => {}, + ); + } + }); + + const tabs = {}; + TabManager.initialize(tabs); +} diff --git a/devtools/projects/shell-browser/src/app/chrome-application-environment.ts b/devtools/projects/shell-browser/src/app/chrome-application-environment.ts index dd4708d24e0..f839f15a1b3 100644 --- a/devtools/projects/shell-browser/src/app/chrome-application-environment.ts +++ b/devtools/projects/shell-browser/src/app/chrome-application-environment.ts @@ -11,6 +11,8 @@ import {ApplicationEnvironment, Environment} from 'ng-devtools'; import {environment} from '../environments/environment'; export class ChromeApplicationEnvironment extends ApplicationEnvironment { + frameSelectorEnabled = true; + override get environment(): Environment { return environment; } diff --git a/devtools/projects/shell-browser/src/app/chrome-application-operations.ts b/devtools/projects/shell-browser/src/app/chrome-application-operations.ts index 729e461f32f..bffe57b2a19 100644 --- a/devtools/projects/shell-browser/src/app/chrome-application-operations.ts +++ b/devtools/projects/shell-browser/src/app/chrome-application-operations.ts @@ -11,32 +11,29 @@ import {ApplicationOperations} from 'ng-devtools'; import {DirectivePosition, ElementPosition} from 'protocol'; +function runInInspectedWindow(script: string, frameURL?: URL): void { + chrome.devtools.inspectedWindow.eval(script, {frameURL: frameURL?.toString?.()}); +} + export class ChromeApplicationOperations extends ApplicationOperations { - override viewSource(position: ElementPosition, directiveIndex: number): void { - if (chrome.devtools) { - chrome.devtools.inspectedWindow.eval( - `inspect(inspectedApplication.findConstructorByPosition('${position}', ${directiveIndex}))`, - ); - } + override viewSource(position: ElementPosition, directiveIndex?: number, target?: URL): void { + const viewSource = `inspect(inspectedApplication.findConstructorByPosition('${position}', ${directiveIndex}))`; + runInInspectedWindow(viewSource, target); } - override selectDomElement(position: ElementPosition): void { - if (chrome.devtools) { - chrome.devtools.inspectedWindow.eval( - `inspect(inspectedApplication.findDomElementByPosition('${position}'))`, - ); - } + override selectDomElement(position: ElementPosition, target?: URL): void { + const selectDomElement = `inspect(inspectedApplication.findDomElementByPosition('${position}'))`; + runInInspectedWindow(selectDomElement, target); } - override inspect(directivePosition: DirectivePosition, objectPath: string[]): void { - if (chrome.devtools) { - const args = { - directivePosition, - objectPath, - }; - chrome.devtools.inspectedWindow.eval( - `inspect(inspectedApplication.findPropertyByPosition('${JSON.stringify(args)}'))`, - ); - } + override inspect(directivePosition: DirectivePosition, objectPath: string[], target?: URL): void { + const args = { + directivePosition, + objectPath, + }; + const inspect = `inspect(inspectedApplication.findPropertyByPosition('${JSON.stringify( + args, + )}'))`; + runInInspectedWindow(inspect, target); } } diff --git a/devtools/projects/shell-browser/src/app/content-script.ts b/devtools/projects/shell-browser/src/app/content-script.ts index 8c2c4cd60f0..a9b6ada8d8c 100644 --- a/devtools/projects/shell-browser/src/app/content-script.ts +++ b/devtools/projects/shell-browser/src/app/content-script.ts @@ -9,59 +9,92 @@ import {ChromeMessageBus} from './chrome-message-bus'; import {SamePageMessageBus} from './same-page-message-bus'; -export const main = () => { - let backgroundDisconnected = false; - let backendInitialized = false; +let backgroundDisconnected = false; +let backendInstalled = false; +let backendInitialized = false; - // console.log('Content script executing', (window as any)); +const port = chrome.runtime.connect({ + name: `${document.title || location.href}`, +}); - const port = chrome.runtime.connect({ - name: 'content-script', - }); - - const handleDisconnect = (): void => { - // console.log('Background disconnected', new Date()); - localMessageBus.emit('shutdown'); - localMessageBus.destroy(); - chromeMessageBus.destroy(); - backgroundDisconnected = true; - }; - - port.onDisconnect.addListener(handleDisconnect); - - const localMessageBus = new SamePageMessageBus( - 'angular-devtools-content-script', - 'angular-devtools-backend', - ); - const chromeMessageBus = new ChromeMessageBus(port); - - const handshakeWithBackend = (): void => { - localMessageBus.emit('handshake'); - }; - - chromeMessageBus.onAny((topic, args) => { - localMessageBus.emit(topic, args); - }); - - localMessageBus.onAny((topic, args) => { - backendInitialized = true; - chromeMessageBus.emit(topic, args); - }); - - if (!backendInitialized) { - // tslint:disable-next-line:no-console - console.log('Attempting initialization', new Date()); - - const retry = () => { - if (backendInitialized || backgroundDisconnected) { - return; - } - handshakeWithBackend(); - setTimeout(retry, 500); - }; - retry(); - } +const handleDisconnect = (): void => { + // console.log('Background disconnected', new Date()); + localMessageBus.emit('shutdown'); + localMessageBus.destroy(); + chromeMessageBus.destroy(); + backgroundDisconnected = true; }; -// expose to use as callback for chrome.tabs.executeScript in background.ts -(globalThis as any).main = main; +port.onDisconnect.addListener(handleDisconnect); + +const detectAngularMessageBus = new SamePageMessageBus( + `angular-devtools-content-script-${location.href}`, + `angular-devtools-detect-angular-${location.href}`, +); + +detectAngularMessageBus.on('detectAngular', (detectionResult) => { + // only install backend once + if (backendInstalled) { + return; + } + + if (detectionResult.isAngularDevTools !== true) { + return; + } + + if (detectionResult.isAngular !== true) { + return; + } + + if (detectionResult.isDebugMode !== true) { + return; + } + + if (detectionResult.isSupportedAngularVersion !== true) { + return; + } + + // Defensive check against non html page. Realistically this should never happen. + if (document.contentType !== 'text/html') { + return; + } + + const script = document.createElement('script'); + script.src = chrome.runtime.getURL('app/backend_bundle.js'); + document.documentElement.appendChild(script); + document.documentElement.removeChild(script); + backendInstalled = true; +}); + +const localMessageBus = new SamePageMessageBus( + `angular-devtools-content-script-${location.href}`, + `angular-devtools-backend-${location.href}`, +); +const chromeMessageBus = new ChromeMessageBus(port); + +const handshakeWithBackend = (): void => { + localMessageBus.emit('handshake'); +}; + +chromeMessageBus.onAny((topic, args) => { + localMessageBus.emit(topic, args); +}); + +localMessageBus.onAny((topic, args) => { + backendInitialized = true; + chromeMessageBus.emit(topic, args); +}); + +if (!backendInitialized) { + // tslint:disable-next-line:no-console + console.log('Attempting initialization', new Date()); + + const retry = () => { + if (backendInitialized || backgroundDisconnected) { + return; + } + handshakeWithBackend(); + setTimeout(retry, 500); + }; + retry(); +} diff --git a/devtools/projects/shell-browser/src/app/detect-angular-for-extension-icon.ts b/devtools/projects/shell-browser/src/app/detect-angular-for-extension-icon.ts index 960ea40bc94..ac91dcd2e83 100644 --- a/devtools/projects/shell-browser/src/app/detect-angular-for-extension-icon.ts +++ b/devtools/projects/shell-browser/src/app/detect-angular-for-extension-icon.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ +import {AngularDetection} from 'protocol'; import { appIsAngular, appIsAngularInDevMode, @@ -13,18 +14,12 @@ import { appIsSupportedAngularVersion, } from 'shared-utils'; -export interface AngularDetection { - // This is necessary because the runtime - // message listener handles messages globally - // including from other extensions. We don't - // want to set icon and/or popup based on - // a message coming from an unrelated extension. - isAngularDevTools: true; - isIvy: boolean; - isAngular: boolean; - isDebugMode: boolean; - isSupportedAngularVersion: boolean; -} +import {SamePageMessageBus} from './same-page-message-bus'; + +const detectAngularMessageBus = new SamePageMessageBus( + `angular-devtools-detect-angular-${location.href}`, + `angular-devtools-content-script-${location.href}`, +); function detectAngular(win: Window): void { const isAngular = appIsAngular(); @@ -32,20 +27,29 @@ function detectAngular(win: Window): void { const isDebugMode = appIsAngularInDevMode(); const isIvy = appIsAngularIvy(); - win.postMessage( + const detection: AngularDetection = { + isIvy, + isAngular, + isDebugMode, + isSupportedAngularVersion, + isAngularDevTools: true, + }; + + // For the background script to toggle the icon. + win.postMessage(detection, '*'); + + // For the content script to inject the backend. + detectAngularMessageBus.emit('detectAngular', [ { isIvy, isAngular, isDebugMode, isSupportedAngularVersion, isAngularDevTools: true, - } as AngularDetection, - '*', - ); + }, + ]); - if (!isAngular) { - setTimeout(() => detectAngular(win), 1000); - } + setTimeout(() => detectAngular(win), 1000); } detectAngular(window); diff --git a/devtools/projects/shell-browser/src/app/inject.ts b/devtools/projects/shell-browser/src/app/inject.ts deleted file mode 100644 index 7d9428f8b4a..00000000000 --- a/devtools/projects/shell-browser/src/app/inject.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -const loadScripts = (urls: string[]) => { - return urls - .map((url, idx) => { - return ` - const script${idx} = document.constructor.prototype.createElement.call(document, 'script'); - script${idx}.src = getScriptName("${url}"); - document.documentElement.appendChild(script${idx}); - script${idx}.parentNode.removeChild(script${idx}); - `; - }) - .join('\n'); -}; - -let loaded = false; -export const injectScripts = (urls: string[], cb?: () => void) => { - if (loaded) { - // Not throwing a hard error here, because we don't want to stop the - // execution when folks are not using Trusted Types or when they have - // allowed redeclaration of a security policy. - console.error('Trying to reinject scripts'); - } - loaded = true; - urls = urls.map((s) => chrome.runtime.getURL(s)); - const script = ` - (function () { - let policy = null; - if (window.trustedTypes && window.trustedTypes.createPolicy) { - policy = window.trustedTypes.createPolicy('angular#devtools', { - createScriptURL: url => ${JSON.stringify(urls)}.indexOf(url) >= 0 ? url : null - }); - } - const getScriptName = name => policy ? policy.createScriptURL(name) : name; - ${loadScripts(urls)} - })(); - `; - chrome.devtools.inspectedWindow.eval(script, (_, err) => { - if (err) { - // tslint:disable-next-line:no-console - console.log(err); - } - cb?.(); - }); -}; diff --git a/devtools/projects/shell-browser/src/app/tab_manager.ts b/devtools/projects/shell-browser/src/app/tab_manager.ts new file mode 100644 index 00000000000..e2fdcc24271 --- /dev/null +++ b/devtools/projects/shell-browser/src/app/tab_manager.ts @@ -0,0 +1,245 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +/// + +import {Events, Topic} from 'protocol'; + +export interface ContentScriptConnection { + port: chrome.runtime.Port | null; + enabled: boolean; + frameId: 'devtools' | number; + backendReady?: boolean; +} + +export interface DevToolsConnection { + devtools: chrome.runtime.Port | null; + contentScripts: {[name: string]: ContentScriptConnection}; +} + +function isNumeric(str: string): boolean { + return +str + '' === str; +} + +export interface Tabs { + [tabId: string]: DevToolsConnection | undefined; +} + +export class TabManager { + constructor( + private tabs: Tabs, + private runtime: typeof chrome.runtime, + ) {} + + static initialize(tabs: Tabs, runtime: typeof chrome.runtime = chrome.runtime): TabManager { + const manager = new TabManager(tabs, runtime); + manager.initialize(); + return manager; + } + + private initialize(): void { + this.runtime.onConnect.addListener((port) => { + if (isNumeric(port.name)) { + this.registerDevToolsForTab(port); + return; + } + + if ( + !port.sender || + !port.sender.tab || + port.sender.tab.id === undefined || + port.sender.frameId === undefined + ) { + console.warn('Received a connection from an unknown sender', port); + return; + } + + this.registerContentScriptForTab(port); + }); + } + + private ensureTabExists(tabId: number): void { + this.tabs[tabId] ??= { + devtools: null, + contentScripts: {}, + }; + } + + private registerDevToolsForTab(port: chrome.runtime.Port): void { + // For the devtools page, our port name is the tab id. + const tabId = parseInt(port.name, 10); + + this.ensureTabExists(tabId); + const tab = this.tabs[tabId]!; + + tab.devtools = port; + tab.devtools.onDisconnect.addListener(() => { + tab.devtools = null; + + for (const connection of Object.values(tab.contentScripts)) { + connection.enabled = false; + } + }); + + // DevTools may register after the content script has already registered. If that's the case, + // we need to set up the double pipe between the devtools and each content script, and send + // the contentScriptConnected message to the devtools page to inform it of all frames on the page. + for (const [frameId, connection] of Object.entries(tab.contentScripts)) { + if (connection.port === null || connection.backendReady !== true) { + continue; + } + + tab.devtools!.postMessage({ + topic: 'contentScriptConnected', + args: [parseInt(frameId, 10), connection.port.name, connection.port.sender!.url], + }); + this.doublePipe(tab.devtools, connection); + } + } + + private registerContentScriptForTab(port: chrome.runtime.Port): void { + // A content script connection will have a sender and a tab id. + const sender = port.sender!; + const frameId = sender.frameId!; + const tabId = sender.tab!.id!; + + this.ensureTabExists(tabId); + const tab = this.tabs[tabId]!; + + if (tab.contentScripts[frameId] === undefined) { + tab.contentScripts[frameId] = { + port: null, + enabled: false, + frameId: -1, + }; + } + + const contentScript = tab.contentScripts[frameId]!; + contentScript.port = port; + contentScript.frameId = frameId; + contentScript.enabled = contentScript.enabled ?? false; + + // When the content script disconnects, clean up the connection state we're storing in the + // background page. + contentScript.port.onDisconnect.addListener(() => { + delete tab.contentScripts[frameId]; + + if (Object.keys(tab.contentScripts).length === 0) { + delete this.tabs[tabId]; + } + }); + + // Listen for the backendReady message from the content script. This message is sent when the + // content script has loaded the backend bundle and is ready to receive messages from the + // backend. + contentScript.port!.onMessage.addListener((message) => { + if (message.topic === 'backendReady') { + contentScript.backendReady = true; + } + }); + + // If the devtools connection is already established, set up the double pipe between the + // devtools and the content script. + if (tab.devtools) { + this.doublePipe(tab.devtools, tab.contentScripts[frameId]); + } + } + + private doublePipe( + devtoolsPort: chrome.runtime.Port | null, + contentScriptConnection: ContentScriptConnection, + ): void { + if (devtoolsPort === null) { + throw new Error('DevTools port is equal to null'); + } + + const contentScriptPort = contentScriptConnection.port; + + if (contentScriptPort === null) { + throw new Error('Content script port is equal to null'); + } + + // tslint:disable-next-line:no-console + console.log('Creating two-way communication channel', Date.now(), this.tabs); + + const onDevToolsMessage = (message: {topic: Topic; args: Parameters}) => { + if (message.topic === 'enableFrameConnection') { + if (message.args.length !== 2) { + throw new Error('Expected two arguments for enableFrameConnection'); + } + + const [frameId, tabId] = message.args as [frameId: number, tabId: number]; + + if (frameId === contentScriptConnection.frameId) { + const tab = this.tabs[tabId]; + + if (tab === undefined) { + throw new Error(`Expected tab to be registered with tabId ${tabId}`); + } + + for (const frameId of Object.keys(tab.contentScripts)) { + tab.contentScripts[frameId].enabled = false; + } + + contentScriptConnection.enabled = true; + devtoolsPort.postMessage({ + topic: 'frameConnected', + args: [contentScriptConnection.frameId], + }); + } + } + + // Do not allow any message to be sent if a content script is not enabled. This is the + // mechanism that lets us select which content script connection Angular Devtools is connected + // to. + if (!contentScriptConnection.enabled) { + return; + } + + contentScriptPort.postMessage(message); + }; + devtoolsPort.onMessage.addListener(onDevToolsMessage); + + const onContentScriptMessage = (message: {topic: Topic; args: Parameters}) => { + if (message.topic === 'backendReady') { + devtoolsPort.postMessage({ + topic: 'contentScriptConnected', + args: [ + contentScriptConnection.frameId, + contentScriptConnection.port!.name, + contentScriptConnection.port!.sender!.url, + ], + }); + return; + } + + // Do not allow any message to be sent if a content script is not enabled. This is the + // mechanism that lets us select which content script connection Angular Devtools is connected + // to. + if (!contentScriptConnection.enabled) { + return; + } + + devtoolsPort.postMessage(message); + }; + contentScriptPort.onMessage.addListener(onContentScriptMessage); + + const shutdownContentScript = () => { + devtoolsPort.onMessage.removeListener(onDevToolsMessage); + devtoolsPort.postMessage({ + topic: 'contentScriptDisconnected', + args: [contentScriptConnection.frameId, contentScriptConnection.port!.name], + }); + + contentScriptPort.onMessage.removeListener(onContentScriptMessage); + contentScriptPort.disconnect(); + }; + + contentScriptPort.onDisconnect.addListener(() => shutdownContentScript()); + } +} diff --git a/devtools/projects/shell-browser/src/app/tab_manager_spec.ts b/devtools/projects/shell-browser/src/app/tab_manager_spec.ts new file mode 100644 index 00000000000..aa20a74441e --- /dev/null +++ b/devtools/projects/shell-browser/src/app/tab_manager_spec.ts @@ -0,0 +1,355 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {DevToolsConnection, TabManager, Tabs} from './tab_manager'; + +interface MockSender { + url: string; + tab: { + id: number; + }; + frameId: number; +} + +const TEST_MESSAGE_ONE = {topic: 'test', args: ['test1']}; +const TEST_MESSAGE_TWO = {topic: 'test', args: ['test2']}; + +class MockPort { + onMessageListeners: Function[] = []; + onDisconnectListeners: Function[] = []; + messagesPosted: any[] = []; + name: string; + sender?: MockSender; + + constructor( + public properties: { + name: string; + sender?: MockSender; + }, + ) { + this.name = properties.name; + this.sender = properties.sender; + } + + postMessage(message: any) { + this.messagesPosted.push(message); + } + + onMessage = { + addListener: (listener: Function) => { + this.onMessageListeners.push(listener); + }, + }; + + onDisconnect = { + addListener: (listener: Function) => { + this.onDisconnectListeners.push(listener); + }, + }; +} + +function assertArrayHasObj(array: T[], obj: T) { + expect(array).toContain(jasmine.objectContaining(obj as object)); +} + +function assertArrayDoesNotHaveObj(array: T[], obj: T) { + expect(array).not.toContain(jasmine.objectContaining(obj)); +} + +function mockSpyFunction(obj: any, property: string, returnValue: any) { + (obj[property] as any).and.returnValue(() => returnValue); +} + +function mockSpyProperty(obj: any, property: string, value: any) { + (Object.getOwnPropertyDescriptor(obj, property)!.get as any).and.returnValue(value); +} + +describe('Tab Manager - ', () => { + let tabs: Tabs; + const tabId = 12345; + // let devtoolsPort: MockPort; + let chromeRuntime: jasmine.SpyObj; + let tabManager: TabManager; + let tab: DevToolsConnection; + let chromeRuntimeOnConnectListeners: ((port: MockPort) => void)[] = []; + + function connectToChromeRuntime(port: MockPort) { + chromeRuntimeOnConnectListeners.forEach((listener) => listener(port)); + } + + function emitMessageToPort(port: MockPort, message: any) { + port.onMessageListeners.forEach((listener) => listener(message)); + } + + function emitDisconnectToPort(port: MockPort) { + port.onDisconnectListeners.forEach((listener) => listener()); + } + + function createDevToolsPort() { + const port = new MockPort({ + name: tabId.toString(), + }); + connectToChromeRuntime(port); + return port; + } + + beforeEach(() => { + chromeRuntimeOnConnectListeners = []; + chromeRuntime = jasmine.createSpyObj( + 'chrome.runtime', + ['getManifest', 'getURL'], + ['onConnect', 'onDisconnect'], + ); + mockSpyFunction(chromeRuntime, 'getManifest', {manifest_version: 3}); + mockSpyFunction(chromeRuntime, 'getURL', (path: string) => path); + mockSpyProperty(chromeRuntime, 'onConnect', { + addListener: (listener: (port: MockPort) => void) => { + chromeRuntimeOnConnectListeners.push(listener); + }, + }); + }); + + describe('Single Frame', () => { + const testURL = 'http://example.com'; + const contentScriptFrameId = 0; + // let contentPort: MockPort; + + function createContentScriptPort() { + const port = new MockPort({ + name: 'Content Script', + sender: { + url: testURL, + tab: { + id: tabId, + }, + frameId: contentScriptFrameId, + }, + }); + connectToChromeRuntime(port); + return port; + } + + beforeEach(() => { + tabs = {}; + tabManager = TabManager.initialize(tabs, chromeRuntime); + }); + + it('should setup tab object in the tab manager', () => { + const contentScriptPort = createContentScriptPort(); + const devtoolsPort = createDevToolsPort(); + tab = tabs[tabId]!; + + expect(tab).toBeDefined(); + expect(tab!.devtools).toBe(devtoolsPort as unknown as chrome.runtime.Port); + expect(tab!.contentScripts[contentScriptFrameId].port).toBe( + contentScriptPort as unknown as chrome.runtime.Port, + ); + }); + + it('should set frame connection as enabled when an enableFrameConnection message is recieved', () => { + createContentScriptPort(); + const devtoolsPort = createDevToolsPort(); + tab = tabs[tabId]!; + + // Test backendReady and contentScriptConnected messages. + expect(tab?.contentScripts[contentScriptFrameId]?.enabled).toBe(false); + emitMessageToPort(devtoolsPort, { + topic: 'enableFrameConnection', + args: [contentScriptFrameId, tabId], + }); + + expect(tab?.contentScripts[contentScriptFrameId]?.enabled).toBe(true); + assertArrayHasObj(devtoolsPort.messagesPosted, { + topic: 'frameConnected', + args: [contentScriptFrameId], + }); + }); + + it('should pipe messages from the content script and devtools script to each other when the content script frame is enabled', () => { + const contentScriptPort = createContentScriptPort(); + const devtoolsPort = createDevToolsPort(); + + emitMessageToPort(devtoolsPort, { + topic: 'enableFrameConnection', + args: [contentScriptFrameId, tabId], + }); + + // Verify that the double pipe is set up between the content script and the devtools page. + emitMessageToPort(contentScriptPort, TEST_MESSAGE_ONE); + assertArrayHasObj(devtoolsPort.messagesPosted, TEST_MESSAGE_ONE); + assertArrayDoesNotHaveObj(contentScriptPort.messagesPosted, TEST_MESSAGE_ONE); + + emitMessageToPort(devtoolsPort, TEST_MESSAGE_TWO); + assertArrayHasObj(contentScriptPort.messagesPosted, TEST_MESSAGE_TWO); + assertArrayDoesNotHaveObj(devtoolsPort.messagesPosted, TEST_MESSAGE_TWO); + }); + + it('should not pipe messages from the content script and devtools script to each other when the content script frame is disabled', () => { + const contentScriptPort = createContentScriptPort(); + const devtoolsPort = createDevToolsPort(); + + expect(tab?.contentScripts[contentScriptFrameId]?.enabled).toBe(false); + emitMessageToPort(contentScriptPort, TEST_MESSAGE_ONE); + expect(contentScriptPort.messagesPosted.length).toBe(0); + + emitMessageToPort(devtoolsPort, TEST_MESSAGE_TWO); + expect(devtoolsPort.messagesPosted.length).toBe(0); + }); + + it('should set backendReady when the contentPort recieves the backendReady message', () => { + const contentScriptPort = createContentScriptPort(); + const devtoolsPort = createDevToolsPort(); + + emitMessageToPort(devtoolsPort, { + topic: 'enableFrameConnection', + args: [contentScriptFrameId, tabId], + }); + emitMessageToPort(contentScriptPort, {topic: 'backendReady'}); + expect(tab?.contentScripts[contentScriptFrameId]?.backendReady).toBe(true); + assertArrayHasObj(devtoolsPort.messagesPosted, { + topic: 'contentScriptConnected', + args: [contentScriptFrameId, contentScriptPort.name, contentScriptPort.sender!.url], + }); + }); + + it('should set tab.devtools to null when the devtoolsPort disconnects', () => { + const contentScriptPort = createContentScriptPort(); + const devtoolsPort = createDevToolsPort(); + + emitMessageToPort(devtoolsPort, { + topic: 'enableFrameConnection', + args: [contentScriptFrameId, tabId], + }); + expect(tab?.contentScripts[contentScriptFrameId]?.enabled).toBe(true); + + emitDisconnectToPort(devtoolsPort); + expect(tab.devtools).toBeNull(); + expect(tab?.contentScripts[contentScriptFrameId]?.enabled).toBe(false); + }); + }); + + describe('Multiple Frames', () => { + const topLevelFrameId = 0; + const childFrameId = 1; + + function createTopLevelContentScriptPort() { + const port = new MockPort({ + name: 'Top level content script', + sender: { + url: 'TEST_URL', + tab: { + id: tabId, + }, + frameId: topLevelFrameId, + }, + }); + connectToChromeRuntime(port); + return port; + } + function createChildContentScriptPort() { + const port = new MockPort({ + name: 'Child content script', + sender: { + url: 'TEST_URL_2', + tab: { + id: tabId, + }, + frameId: childFrameId, + }, + }); + connectToChromeRuntime(port); + return port; + } + + beforeEach(() => { + tabs = {}; + tabManager = TabManager.initialize(tabs, chromeRuntime); + }); + + it('should setup tab object in the tab manager', () => { + const devtoolsPort = createDevToolsPort(); + const topLevelContentScriptPort = createTopLevelContentScriptPort(); + const childContentScriptPort = createChildContentScriptPort(); + + tab = tabs[tabId]!; + + expect(tab).toBeDefined(); + expect(tab!.devtools).toBe(devtoolsPort as unknown as chrome.runtime.Port); + expect(tab!.contentScripts[topLevelFrameId].port).toBe( + topLevelContentScriptPort as unknown as chrome.runtime.Port, + ); + expect(tab!.contentScripts[childFrameId].port).toBe( + childContentScriptPort as unknown as chrome.runtime.Port, + ); + }); + + it('should setup message and disconnect listeners on devtools and content script ports', () => { + const devtoolsPort = createDevToolsPort(); + const topLevelContentScriptPort = createTopLevelContentScriptPort(); + const childContentScriptPort = createChildContentScriptPort(); + + // 1 listener to clean up tab object if this was the last content script connection. + // 1 listener to cleanup the douple pipe between the content script and the devtools page. + expect(topLevelContentScriptPort.onDisconnectListeners.length).toBe(2); + expect(childContentScriptPort.onDisconnectListeners.length).toBe(2); + + // 1 listener to clean up devtools connection + expect(devtoolsPort.onDisconnectListeners.length).toBe(1); + + // 1 listener set when the content script is registered to check for backendReady. + // 1 listener set when the double pipe is set up between the content script and the devtools page. + expect(topLevelContentScriptPort.onMessageListeners.length).toBe(2); + expect(childContentScriptPort.onMessageListeners.length).toBe(2); + }); + + it('should set the correct frame connection as enabled when an enableFrameConnection message is recieved', () => { + const devtoolsPort = createDevToolsPort(); + createTopLevelContentScriptPort(); + createChildContentScriptPort(); + + expect(tab?.contentScripts[topLevelFrameId]?.enabled).toBe(false); + expect(tab?.contentScripts[childFrameId]?.enabled).toBe(false); + emitMessageToPort(devtoolsPort, { + topic: 'enableFrameConnection', + args: [topLevelFrameId, tabId], + }); + expect(tab?.contentScripts[topLevelFrameId]?.enabled).toBe(true); + expect(tab?.contentScripts[childFrameId]?.enabled).toBe(false); + assertArrayHasObj(devtoolsPort.messagesPosted, { + topic: 'frameConnected', + args: [topLevelFrameId], + }); + assertArrayDoesNotHaveObj(devtoolsPort.messagesPosted, { + topic: 'frameConnected', + args: [childFrameId], + }); + }); + + it('should pipe messages from the correct content script and devtools script when that content script frame is enabled', () => { + const devtoolsPort = createDevToolsPort(); + const topLevelContentScriptPort = createTopLevelContentScriptPort(); + const childContentScriptPort = createChildContentScriptPort(); + + emitMessageToPort(devtoolsPort, { + topic: 'enableFrameConnection', + args: [topLevelFrameId, tabId], + }); + emitMessageToPort(devtoolsPort, TEST_MESSAGE_ONE); + assertArrayHasObj(topLevelContentScriptPort.messagesPosted, TEST_MESSAGE_ONE); + assertArrayDoesNotHaveObj(childContentScriptPort.messagesPosted, TEST_MESSAGE_ONE); + + emitMessageToPort(devtoolsPort, { + topic: 'enableFrameConnection', + args: [childFrameId, tabId], + }); + emitMessageToPort(devtoolsPort, TEST_MESSAGE_TWO); + assertArrayHasObj(childContentScriptPort.messagesPosted, TEST_MESSAGE_TWO); + assertArrayDoesNotHaveObj(topLevelContentScriptPort.messagesPosted, TEST_MESSAGE_TWO); + }); + }); +}); diff --git a/devtools/projects/shell-browser/src/manifest/manifest.chrome.json b/devtools/projects/shell-browser/src/manifest/manifest.chrome.json index f1b6e36cb52..2614b86f9f9 100644 --- a/devtools/projects/shell-browser/src/manifest/manifest.chrome.json +++ b/devtools/projects/shell-browser/src/manifest/manifest.chrome.json @@ -27,8 +27,7 @@ { "resources": [ "app/backend_bundle.js", - "app/detect_angular_for_extension_icon_bundle.js", - "devtools.html" + "app/detect_angular_for_extension_icon_bundle.js" ], "matches": [""], "extension_ids": [] @@ -52,7 +51,18 @@ "js": [ "app/ng_validate_bundle.js" ], - "run_at": "document_idle" + "run_at": "document_idle", + "all_frames": true + }, + { + "matches": [ + "" + ], + "js": [ + "app/content_script_bundle.js" + ], + "run_at": "document_idle", + "all_frames": true } ] } \ No newline at end of file diff --git a/devtools/projects/shell-browser/src/manifest/manifest.firefox.json b/devtools/projects/shell-browser/src/manifest/manifest.firefox.json index d0620740c8e..2e2ac7ce89c 100644 --- a/devtools/projects/shell-browser/src/manifest/manifest.firefox.json +++ b/devtools/projects/shell-browser/src/manifest/manifest.firefox.json @@ -29,13 +29,26 @@ "https://*/*", "file:///*" ], - "content_scripts": [{ + "content_scripts": [ + { "matches": [ "" ], "js": [ "app/ng_validate_bundle.js" ], - "run_at": "document_idle" - }] + "run_at": "document_idle", + "all_frames": true + }, + { + "matches": [ + "" + ], + "js": [ + "app/content_script_bundle.js" + ], + "run_at": "document_idle", + "all_frames": true + } + ] } \ No newline at end of file