Skip to content

Commit e430854

Browse files
committed
feat: file downloads
1 parent 252876c commit e430854

File tree

15 files changed

+208
-14
lines changed

15 files changed

+208
-14
lines changed

client/lib/CoreTab.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import IWaitForOptions from '@secret-agent/interfaces/IWaitForOptions';
88
import IScreenshotOptions from '@secret-agent/interfaces/IScreenshotOptions';
99
import IFrameMeta from '@secret-agent/interfaces/IFrameMeta';
1010
import IFileChooserPrompt from '@secret-agent/interfaces/IFileChooserPrompt';
11+
import IDownload, { IDownloadState } from '@secret-agent/interfaces/IDownload';
1112
import CoreCommandQueue from './CoreCommandQueue';
1213
import CoreEventHeap from './CoreEventHeap';
1314
import IWaitForResourceFilter from '../interfaces/IWaitForResourceFilter';
@@ -17,6 +18,7 @@ import ConnectionToCore from '../connections/ConnectionToCore';
1718
import CoreFrameEnvironment from './CoreFrameEnvironment';
1819
import { createDialog } from './Dialog';
1920
import CoreSession from './CoreSession';
21+
import Download, { createDownload } from './Download';
2022

2123
export default class CoreTab implements IJsPathEventTarget {
2224
public tabId: number;
@@ -32,6 +34,7 @@ export default class CoreTab implements IJsPathEventTarget {
3234
private readonly connection: ConnectionToCore;
3335
private readonly mainFrameId: number;
3436
private readonly coreSession: CoreSession;
37+
private readonly downloadsById = new Map<string, Download>();
3538

3639
constructor(
3740
meta: ISessionMeta & { sessionName: string },
@@ -57,6 +60,7 @@ export default class CoreTab implements IJsPathEventTarget {
5760
this.eventHeap.registerEventInterceptors({
5861
resource: createResource.bind(null, resolvedThis),
5962
dialog: createDialog.bind(null, resolvedThis),
63+
'download-started': this.createDownload.bind(resolvedThis),
6064
});
6165
}
6266

@@ -176,4 +180,16 @@ export default class CoreTab implements IJsPathEventTarget {
176180
const session = this.connection.getSession(this.sessionId);
177181
session?.removeTab(this);
178182
}
183+
184+
private createDownload(download: IDownload): Download {
185+
const newDownload = createDownload(Promise.resolve(this), download);
186+
this.downloadsById.set(download.id, newDownload);
187+
return newDownload;
188+
}
189+
190+
private onDownloadProgress(data: IDownloadState): void {
191+
const download = this.downloadsById.get(data.id);
192+
if (!download) return;
193+
Object.assign(download, data);
194+
}
179195
}

client/lib/Download.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import StateMachine from 'awaited-dom/base/StateMachine';
2+
import Resolvable from '@secret-agent/commons/Resolvable';
3+
import IDownload from '@secret-agent/interfaces/IDownload';
4+
import CoreTab from './CoreTab';
5+
6+
const { getState, setState } = StateMachine<Download, IState>();
7+
8+
interface IState {
9+
coreTab: Promise<CoreTab>;
10+
downloadPromise: Resolvable<void>;
11+
complete: boolean;
12+
}
13+
14+
export default class Download {
15+
id: string;
16+
url: string;
17+
path: string;
18+
suggestedFilename: string;
19+
20+
progress: number;
21+
totalBytes: number;
22+
canceled: boolean;
23+
24+
get complete(): boolean {
25+
return getState(this).complete;
26+
}
27+
28+
set complete(value) {
29+
setState(this, { complete: value });
30+
if (value) getState(this).downloadPromise.resolve();
31+
}
32+
33+
waitForFinished(): Promise<void> {
34+
return getState(this).downloadPromise.promise;
35+
}
36+
37+
async saveAs(): Promise<Buffer> {
38+
// todo: add streaming ability
39+
}
40+
}
41+
42+
export function createDownload(coreTab: Promise<CoreTab>, data: IDownload): Download {
43+
const download = new Download();
44+
Object.assign(download, data);
45+
setState(download, {
46+
coreTab,
47+
downloadPromise: new Resolvable<void>(),
48+
});
49+
return download;
50+
}

client/lib/Tab.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import CoreFrameEnvironment from './CoreFrameEnvironment';
3535
import IAwaitedOptions from '../interfaces/IAwaitedOptions';
3636
import Dialog from './Dialog';
3737
import FileChooser from './FileChooser';
38+
import Download from './Download';
3839

3940
const awaitedPathState = StateMachine<
4041
any,
@@ -53,6 +54,7 @@ export interface IState {
5354
interface IEventType {
5455
resource: Resource | WebsocketResource;
5556
dialog: Dialog;
57+
download: Download;
5658
}
5759

5860
const propertyKeys: (keyof Tab)[] = [

core/lib/CorePlugins.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { PluginTypes } from '@secret-agent/interfaces/IPluginTypes';
2626
import requirePlugins from '@secret-agent/plugin-utils/lib/utils/requirePlugins';
2727
import IHttp2ConnectSettings from '@secret-agent/interfaces/IHttp2ConnectSettings';
2828
import IDeviceProfile from '@secret-agent/interfaces/IDeviceProfile';
29+
import IPuppetContext from '@secret-agent/interfaces/IPuppetContext';
2930
import Core from '../index';
3031

3132
const DefaultBrowserEmulatorId = 'default-browser-emulator';
@@ -136,6 +137,12 @@ export default class CorePlugins implements ICorePlugins {
136137
this.instances.filter(p => p.onTlsConfiguration).forEach(p => p.onTlsConfiguration(settings));
137138
}
138139

140+
public async onNewPuppetContext(context: IPuppetContext): Promise<void> {
141+
await Promise.all(
142+
this.instances.filter(p => p.onNewPuppetContext).map(p => p.onNewPuppetContext(context)),
143+
);
144+
}
145+
139146
public async onNewPuppetPage(page: IPuppetPage): Promise<void> {
140147
await Promise.all(
141148
this.instances.filter(p => p.onNewPuppetPage).map(p => p.onNewPuppetPage(page)),

core/lib/Tab.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import IFrameMeta from '@secret-agent/interfaces/IFrameMeta';
2323
import { LoadStatus } from '@secret-agent/interfaces/INavigation';
2424
import IPuppetDialog from '@secret-agent/interfaces/IPuppetDialog';
2525
import IFileChooserPrompt from '@secret-agent/interfaces/IFileChooserPrompt';
26+
import IDownload, { IDownloadState } from '@secret-agent/interfaces/IDownload';
2627
import FrameNavigations from './FrameNavigations';
2728
import CommandRecorder from './CommandRecorder';
2829
import FrameEnvironment from './FrameEnvironment';
@@ -622,6 +623,9 @@ export default class Tab extends TypedEventEmitter<ITabEventParams> {
622623
page.on('page-callback-triggered', this.onPageCallback.bind(this));
623624
page.on('dialog-opening', this.onDialogOpening.bind(this));
624625
page.on('filechooser', this.onFileChooser.bind(this));
626+
page.on('download-started', this.onDownloadStarted.bind(this));
627+
page.on('download-progress', this.onDownloadProgress.bind(this));
628+
page.on('download-finished', this.onDownloadFinished.bind(this));
625629

626630
// resource requested should registered before navigations so we can grab nav on new tab anchor clicks
627631
page.on('resource-will-be-requested', this.onResourceWillBeRequested.bind(this), true);
@@ -848,6 +852,28 @@ export default class Tab extends TypedEventEmitter<ITabEventParams> {
848852
this.sessionState.captureError(this.id, this.mainFrameId, `events.error`, error);
849853
}
850854

855+
/////// DOWNLOADS ////////////////////////////////////////////////////////////////////////////////
856+
857+
private onDownloadStarted(event: IPuppetPageEvents['download-started']): void {
858+
this.emit('download-started', event);
859+
}
860+
861+
private onDownloadProgress(event: IPuppetPageEvents['download-progress']): void {
862+
this.emit('download-progress', {
863+
...event,
864+
canceled: false,
865+
complete: false,
866+
});
867+
}
868+
869+
private onDownloadFinished(event: IPuppetPageEvents['download-finished']): void {
870+
this.emit('download-progress', {
871+
...event,
872+
complete: true,
873+
progress: 100,
874+
});
875+
}
876+
851877
/////// DIALOGS //////////////////////////////////////////////////////////////////////////////////
852878

853879
private onDialogOpening(event: IPuppetPageEvents['dialog-opening']): void {
@@ -881,6 +907,8 @@ interface ITabEventParams {
881907
'resource-requested': IResourceMeta;
882908
resource: IResourceMeta;
883909
dialog: IPuppetDialog;
910+
'download-started': IDownload;
911+
'download-progress': IDownloadState;
884912
'websocket-message': IWebsocketResourceMessage;
885913
'child-tab-created': Tab;
886914
}

interfaces/ICorePlugin.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import IViewport from './IViewport';
1616
import IGeolocation from './IGeolocation';
1717
import IDeviceProfile from './IDeviceProfile';
1818
import IHttp2ConnectSettings from './IHttp2ConnectSettings';
19+
import IPuppetContext from './IPuppetContext';
1920

2021
export default interface ICorePlugin
2122
extends ICorePluginMethods,
@@ -116,6 +117,7 @@ export interface IBrowserEmulatorMethods {
116117
beforeHttpRequest?(request: IHttpResourceLoadDetails): Promise<any> | void;
117118
beforeHttpResponse?(resource: IHttpResourceLoadDetails): Promise<any> | void;
118119

120+
onNewPuppetContext?(context: IPuppetContext): Promise<any>;
119121
onNewPuppetPage?(page: IPuppetPage): Promise<any>;
120122
onNewPuppetWorker?(worker: IPuppetWorker): Promise<any>;
121123

interfaces/IDownload.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export default interface IDownload {
2+
id: string;
3+
path: string;
4+
suggestedFilename: string;
5+
url: string;
6+
}
7+
8+
export interface IDownloadState {
9+
id: string;
10+
totalBytes: number;
11+
complete: boolean;
12+
progress: number;
13+
canceled: boolean;
14+
}

interfaces/IPuppetContext.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ export default interface IPuppetContext extends ITypedEventEmitter<IPuppetContex
2222
cookies: (Omit<ICookie, 'expires'> & { expires?: string | Date | number })[],
2323
origins?: string[],
2424
): Promise<void>;
25+
26+
enableDownloads(downloadsPath: string): Promise<void>;
2527
}
2628

2729
export interface IPuppetPageOptions {

interfaces/IPuppetPage.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,4 +65,12 @@ export interface IPuppetPageEvents extends IPuppetFrameManagerEvents, IPuppetNet
6565
filechooser: { frameId: string; selectMultiple: boolean; objectId: string };
6666
'page-error': { frameId: string; error: Error };
6767
'page-callback-triggered': { name: string; frameId: string; payload: any };
68+
'download-started': {
69+
id: string;
70+
path: string;
71+
suggestedFilename: string;
72+
url: string;
73+
};
74+
'download-progress': { id: string; totalBytes: number; progress: number };
75+
'download-finished': { id: string; totalBytes: number; canceled: boolean };
6876
}

plugin-utils/lib/BrowserEngine.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export default class BrowserEngine implements IBrowserEngine {
1010
public fullVersion: string;
1111
public executablePath: string;
1212
public executablePathEnvVar: string;
13+
public userDataDir?: string;
1314

1415
public readonly launchArguments: string[] = [];
1516

plugins/default-browser-emulator/index.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ import IUserAgentOption from '@secret-agent/interfaces/IUserAgentOption';
1515
import BrowserEngine from '@secret-agent/plugin-utils/lib/BrowserEngine';
1616
import IGeolocation from '@secret-agent/interfaces/IGeolocation';
1717
import IHttp2ConnectSettings from '@secret-agent/interfaces/IHttp2ConnectSettings';
18+
import IPuppetContext from '@secret-agent/interfaces/IPuppetContext';
19+
import * as Path from 'path';
20+
import * as os from 'os';
1821
import Viewports from './lib/Viewports';
1922
import setWorkerDomOverrides from './lib/setWorkerDomOverrides';
2023
import setPageDomOverrides from './lib/setPageDomOverrides';
@@ -43,6 +46,8 @@ const dataLoader = new DataLoader(__dirname);
4346
export const latestBrowserEngineId = 'chrome-88-0';
4447
export const latestChromeBrowserVersion = { major: '88', minor: '0' };
4548

49+
let sessionDirCounter = 0;
50+
4651
@BrowserEmulatorClassDecorator
4752
export default class DefaultBrowserEmulator extends BrowserEmulator {
4853
public static id = dataLoader.pkg.name.replace('@secret-agent/', '');
@@ -51,6 +56,7 @@ export default class DefaultBrowserEmulator extends BrowserEmulator {
5156
public locale: string;
5257
public viewport: IViewport;
5358
public geolocation: IGeolocation;
59+
public userDataDir: string;
5460

5561
protected readonly data: IBrowserData;
5662
private readonly domOverridesBuilder: DomOverridesBuilder;
@@ -109,6 +115,10 @@ export default class DefaultBrowserEmulator extends BrowserEmulator {
109115
configureHttp2Session(this, this.data, request, settings);
110116
}
111117

118+
public async onNewPuppetContext(context: IPuppetContext): Promise<any> {
119+
await context.enableDownloads(this.userDataDir);
120+
}
121+
112122
public onNewPuppetPage(page: IPuppetPage): Promise<any> {
113123
// Don't await here! we want to queue all these up to run before the debugger resumes
114124
const devtools = page.devtoolsSession;
@@ -154,6 +164,13 @@ export default class DefaultBrowserEmulator extends BrowserEmulator {
154164
disableDevtools?: boolean;
155165
},
156166
): void {
167+
const dataDir = Path.join(
168+
os.tmpdir(),
169+
browserEngine.fullVersion.replace('.', '-'),
170+
`${String(Date.now()).substr(0, 10)}-${(sessionDirCounter += 1)}`,
171+
);
172+
browserEngine.userDataDir = dataDir;
173+
157174
configureBrowserLaunchArgs(browserEngine, options);
158175
}
159176
}

plugins/default-browser-emulator/lib/helpers/configureBrowserLaunchArgs.ts

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
1-
import * as Path from 'path';
2-
import * as os from 'os';
31
import BrowserEngine from '@secret-agent/plugin-utils/lib/BrowserEngine';
42
import { defaultScreen } from '../Viewports';
53

6-
let sessionDirCounter = 0;
7-
84
export function configureBrowserLaunchArgs(
95
engine: BrowserEngine,
106
options: {
@@ -60,12 +56,7 @@ export function configureBrowserLaunchArgs(
6056
);
6157

6258
if (options.showBrowser) {
63-
const dataDir = Path.join(
64-
os.tmpdir(),
65-
engine.fullVersion.replace('.', '-'),
66-
`${String(Date.now()).substr(0, 10)}-${(sessionDirCounter += 1)}`,
67-
);
68-
engine.launchArguments.push(`--user-data-dir=${dataDir}`); // required to allow multiple browsers to be headed
59+
engine.launchArguments.push(`--user-data-dir=${engine.userDataDir}`); // required to allow multiple browsers to be headed
6960

7061
if (!options.disableDevtools) engine.launchArguments.push('--auto-open-devtools-for-tabs');
7162
} else {

puppet-chrome/lib/Browser.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,9 @@ export class Browser extends TypedEventEmitter<IBrowserEvents> implements IPuppe
5656
...proxySettings,
5757
});
5858

59-
return new BrowserContext(this, plugins, browserContextId, logger, proxy);
59+
const context = new BrowserContext(this, plugins, browserContextId, logger, proxy);
60+
if (plugins.onNewPuppetContext) await plugins.onNewPuppetContext(context);
61+
return context;
6062
}
6163

6264
public async getFeatures(): Promise<{

puppet-chrome/lib/BrowserContext.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,21 +28,20 @@ import { Page } from './Page';
2828
import { Browser } from './Browser';
2929
import { DevtoolsSession } from './DevtoolsSession';
3030
import Frame from './Frame';
31-
3231
import CookieParam = Protocol.Network.CookieParam;
3332
import TargetInfo = Protocol.Target.TargetInfo;
3433

3534
export class BrowserContext
3635
extends TypedEventEmitter<IPuppetContextEvents>
37-
implements IPuppetContext
38-
{
36+
implements IPuppetContext {
3937
public logger: IBoundLog;
4038

4139
public workersById = new Map<string, IPuppetWorker>();
4240
public pagesById = new Map<string, Page>();
4341
public plugins: ICorePlugins;
4442
public proxy: IProxyConnectionOptions;
4543
public readonly id: string;
44+
public downloadsPath?: string;
4645

4746
private attachedTargetIds = new Set<string>();
4847
private pageOptionsByTargetId = new Map<string, IPuppetPageOptions>();
@@ -119,6 +118,15 @@ export class BrowserContext
119118
}
120119
}
121120

121+
async enableDownloads(downloadsPath: string): Promise<any> {
122+
this.downloadsPath = downloadsPath;
123+
await this.sendWithBrowserDevtoolsSession('Browser.setDownloadBehavior', {
124+
behavior: 'allowAndName',
125+
browserContextId: this.id,
126+
downloadPath: downloadsPath,
127+
});
128+
}
129+
122130
initializePage(page: Page): Promise<any> {
123131
if (this.pageOptionsByTargetId.get(page.targetId)?.runPageScripts === false) return;
124132

0 commit comments

Comments
 (0)