Skip to content

Commit d40049c

Browse files
committed
feat: file downloads
fix: download-progress not wired up feat: add ability to download actual file
1 parent b211ba3 commit d40049c

30 files changed

+803
-32
lines changed

client/lib/CoreTab.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ 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 from '@secret-agent/interfaces/IDownload';
12+
import { URL } from 'url';
1113
import CoreCommandQueue from './CoreCommandQueue';
1214
import CoreEventHeap from './CoreEventHeap';
1315
import IWaitForResourceFilter from '../interfaces/IWaitForResourceFilter';
@@ -17,6 +19,7 @@ import ConnectionToCore from '../connections/ConnectionToCore';
1719
import CoreFrameEnvironment from './CoreFrameEnvironment';
1820
import { createDialog } from './Dialog';
1921
import CoreSession from './CoreSession';
22+
import Download, { createDownload } from './Download';
2023

2124
export default class CoreTab implements IJsPathEventTarget {
2225
public tabId: number;
@@ -32,6 +35,7 @@ export default class CoreTab implements IJsPathEventTarget {
3235
private readonly connection: ConnectionToCore;
3336
private readonly mainFrameId: number;
3437
private readonly coreSession: CoreSession;
38+
private readonly downloadsById = new Map<string, Download>();
3539

3640
constructor(
3741
meta: ISessionMeta & { sessionName: string },
@@ -57,6 +61,7 @@ export default class CoreTab implements IJsPathEventTarget {
5761
this.eventHeap.registerEventInterceptors({
5862
resource: createResource.bind(null, resolvedThis),
5963
dialog: createDialog.bind(null, resolvedThis),
64+
download: this.createDownload.bind(this),
6065
});
6166
}
6267

@@ -176,4 +181,20 @@ export default class CoreTab implements IJsPathEventTarget {
176181
const session = this.connection.getSession(this.sessionId);
177182
session?.removeTab(this);
178183
}
184+
185+
public async deleteDownload(id: string): Promise<void> {
186+
await this.commandQueue.run('Tab.deleteDownload', id);
187+
}
188+
189+
private createDownload(download: IDownload): Download {
190+
const newDownload = createDownload(this, download);
191+
this.downloadsById.set(download.id, newDownload);
192+
newDownload.downloadUrl = this.connection.hostOrError.then(host => {
193+
if (host instanceof Error) return null;
194+
const hostUrl = new URL(download.downloadPath, host);
195+
hostUrl.protocol = 'http:';
196+
return hostUrl.href;
197+
});
198+
return newDownload;
199+
}
179200
}

client/lib/Download.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import StateMachine from 'awaited-dom/base/StateMachine';
2+
import Resolvable from '@secret-agent/commons/Resolvable';
3+
import IDownload, { IDownloadState } from '@secret-agent/interfaces/IDownload';
4+
import { httpGet } from '@secret-agent/commons/downloadFile';
5+
import CoreTab from './CoreTab';
6+
7+
const { getState, setState } = StateMachine<Download, IState>();
8+
9+
interface IState {
10+
coreTab: CoreTab;
11+
downloadPromise: Resolvable<void>;
12+
complete: boolean;
13+
}
14+
15+
export default class Download {
16+
id: string;
17+
url: string;
18+
path: string;
19+
suggestedFilename: string;
20+
21+
progress = 0;
22+
totalBytes = 0;
23+
canceled = false;
24+
25+
#downloadUrl: Promise<string>;
26+
27+
get complete(): boolean {
28+
return getState(this).complete;
29+
}
30+
31+
set complete(value) {
32+
setState(this, { complete: value });
33+
if (value) {
34+
getState(this).downloadPromise.resolve();
35+
}
36+
}
37+
38+
waitForFinished(): Promise<void> {
39+
return getState(this).downloadPromise.promise;
40+
}
41+
42+
set downloadUrl(value: Promise<string>) {
43+
this.#downloadUrl = value;
44+
}
45+
46+
async delete(): Promise<void> {
47+
const coreTab = await getState(this).coreTab;
48+
await coreTab.deleteDownload(this.id);
49+
}
50+
51+
async data(): Promise<Buffer> {
52+
await this.waitForFinished();
53+
const url = await this.#downloadUrl;
54+
const downloaderPromise = new Resolvable<Buffer>();
55+
const request = httpGet(url, async response => {
56+
if (response.statusCode !== 200) {
57+
const error = new Error(
58+
`Download failed: server returned code ${response.statusCode}. URL: ${url}`,
59+
);
60+
// consume response data to free up memory
61+
response.resume();
62+
downloaderPromise.reject(error);
63+
return;
64+
}
65+
const buffer: Buffer[] = [];
66+
for await (const chunk of response) {
67+
buffer.push(chunk);
68+
}
69+
downloaderPromise.resolve(Buffer.concat(buffer));
70+
});
71+
request.once('error', downloaderPromise.reject);
72+
return downloaderPromise.promise;
73+
}
74+
}
75+
76+
export function createDownload(coreTab: CoreTab, data: IDownload): Download {
77+
const download = new Download();
78+
Object.assign(download, data);
79+
setState(download, {
80+
coreTab,
81+
downloadPromise: new Resolvable<void>(),
82+
});
83+
84+
function onDownloadProgress(progress: IDownloadState): void {
85+
if (progress.complete) {
86+
coreTab
87+
.removeEventListener(['downloads', progress.id], 'download-progress', onDownloadProgress)
88+
.catch(() => null);
89+
}
90+
Object.assign(download, progress);
91+
}
92+
93+
coreTab
94+
.addEventListener(['downloads', download.id], 'download-progress', onDownloadProgress)
95+
.catch(() => null);
96+
97+
return download;
98+
}

client/lib/Tab.ts

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

3839
const awaitedPathState = StateMachine<
3940
any,
@@ -52,6 +53,7 @@ export interface IState {
5253
interface IEventType {
5354
resource: Resource | WebsocketResource;
5455
dialog: Dialog;
56+
download: Download;
5557
}
5658

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

commons/downloadFile.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ export function httpGet(
6060
return request;
6161
}
6262

63-
function getRequestOptionsWithProxy(url: string): RequestOptions {
63+
export function getRequestOptionsWithProxy(url: string): RequestOptions {
6464
const urlParsed = parse(url);
6565

6666
const options: https.RequestOptions = {

copyfiles.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,13 @@ const copyArgs = [
1616
'.*ignore',
1717
];
1818
if (isBuild) {
19-
copyArgs.push('testing/*/**', 'core/test/html/**', 'puppet/test/*/**', 'yarn.lock');
19+
copyArgs.push(
20+
'testing/*/**',
21+
'core/test/html/**',
22+
'full-client/test/html/**',
23+
'puppet/test/*/**',
24+
'yarn.lock',
25+
);
2026
}
2127

2228
for (const workspace of workspaces) {

core/lib/AwaitedEventListener.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,14 @@ export default class AwaitedEventListener {
5050
// need to give client time to register function sending events
5151
process.nextTick(() => tab.sessionState.onWebsocketMessages(resourceId, listener.listenFn));
5252
}
53+
if (listener.jsPath && listener.jsPath[0] === 'downloads') {
54+
const downloadId = listener.jsPath[1] as string;
55+
listener.listenFn = download => {
56+
if (download.id === downloadId) this.triggerListenersWithId(listenerId, download);
57+
};
58+
tab.on('download-progress', listener.listenFn);
59+
process.nextTick(() => listener.listenFn(tab.session.downloadsById.get(downloadId)));
60+
}
5361
} else if (type && tab) {
5462
if (type !== 'close') {
5563
listener.listenFn = this.triggerListenersWithType.bind(this, type);

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/Session.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import IViewport from '@secret-agent/interfaces/IViewport';
2121
import IJsPathResult from '@secret-agent/interfaces/IJsPathResult';
2222
import ISessionCreateOptions from '@secret-agent/interfaces/ISessionCreateOptions';
2323
import IGeolocation from '@secret-agent/interfaces/IGeolocation';
24+
import IDownload, { IDownloadState } from '@secret-agent/interfaces/IDownload';
2425
import SessionState from './SessionState';
2526
import AwaitedEventListener from './AwaitedEventListener';
2627
import GlobalPool from './GlobalPool';
@@ -60,6 +61,7 @@ export default class Session extends TypedEventEmitter<{
6061
public userProfile?: IUserProfile;
6162

6263
public tabsById = new Map<number, Tab>();
64+
public downloadsById = new Map<string, IDownload & Partial<IDownloadState>>();
6365

6466
public get isClosing() {
6567
return this._isClosing;

core/lib/Tab.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ 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';
27+
import { encode } from 'querystring';
28+
import * as Fs from 'fs';
29+
import { existsAsync } from '@secret-agent/commons/fileUtils';
2630
import FrameNavigations from './FrameNavigations';
2731
import CommandRecorder from './CommandRecorder';
2832
import FrameEnvironment from './FrameEnvironment';
@@ -108,7 +112,7 @@ export default class Tab extends TypedEventEmitter<ITabEventParams> {
108112
windowOpenParams?: { url: string; windowName: string; loaderId: string },
109113
) {
110114
super();
111-
this.setEventsToLog(['child-tab-created', 'close']);
115+
this.setEventsToLog(['child-tab-created', 'close', 'dialog', 'download', 'download-progress']);
112116
this.id = session.nextTabId();
113117
this.logger = log.createChild(module, {
114118
tabId: this.id,
@@ -139,6 +143,7 @@ export default class Tab extends TypedEventEmitter<ITabEventParams> {
139143
this.commandRecorder = new CommandRecorder(this, this.session, this.id, this.mainFrameId, [
140144
this.focus,
141145
this.dismissDialog,
146+
this.deleteDownload,
142147
this.getFrameEnvironments,
143148
this.goto,
144149
this.goBack,
@@ -437,6 +442,12 @@ export default class Tab extends TypedEventEmitter<ITabEventParams> {
437442
return this.puppetPage.dismissDialog(accept, promptText);
438443
}
439444

445+
public async deleteDownload(id: string): Promise<void> {
446+
const download = this.session.downloadsById.get(id);
447+
if (!download) return;
448+
if (await existsAsync(download.path)) await Fs.promises.unlink(download.path);
449+
}
450+
440451
public async waitForNewTab(options: IWaitForOptions = {}): Promise<Tab> {
441452
// last command is the one running right now
442453
const startCommandId = Number.isInteger(options.sinceCommandId)
@@ -634,6 +645,9 @@ export default class Tab extends TypedEventEmitter<ITabEventParams> {
634645
page.on('page-callback-triggered', this.onPageCallback.bind(this));
635646
page.on('dialog-opening', this.onDialogOpening.bind(this));
636647
page.on('filechooser', this.onFileChooser.bind(this));
648+
page.on('download-started', this.onDownloadStarted.bind(this));
649+
page.on('download-progress', this.onDownloadProgress.bind(this));
650+
page.on('download-finished', this.onDownloadFinished.bind(this));
637651

638652
// resource requested should registered before navigations so we can grab nav on new tab anchor clicks
639653
page.on('resource-will-be-requested', this.onResourceWillBeRequested.bind(this), true);
@@ -910,6 +924,42 @@ export default class Tab extends TypedEventEmitter<ITabEventParams> {
910924
this.sessionState.captureError(this.id, this.mainFrameId, `events.error`, error);
911925
}
912926

927+
/////// DOWNLOADS ////////////////////////////////////////////////////////////////////////////////
928+
929+
private onDownloadStarted(event: IPuppetPageEvents['download-started']): void {
930+
const broadcast = event as IDownload;
931+
broadcast.downloadPath = `/downloads?${encode({
932+
id: event.id,
933+
sessionId: this.sessionId,
934+
})}`;
935+
this.session.downloadsById.set(event.id, broadcast);
936+
this.emit('download', broadcast);
937+
}
938+
939+
private onDownloadProgress(event: IPuppetPageEvents['download-progress']): void {
940+
const state = this.session.downloadsById.get(event.id);
941+
const broadcast = {
942+
...event,
943+
canceled: false,
944+
complete: false,
945+
};
946+
if (state) Object.assign(state, broadcast);
947+
948+
this.emit('download-progress', broadcast);
949+
}
950+
951+
private onDownloadFinished(event: IPuppetPageEvents['download-finished']): void {
952+
const state = this.session.downloadsById.get(event.id);
953+
const broadcast = {
954+
...event,
955+
complete: true,
956+
progress: 100,
957+
};
958+
if (state) Object.assign(state, broadcast);
959+
960+
this.emit('download-progress', broadcast);
961+
}
962+
913963
/////// DIALOGS //////////////////////////////////////////////////////////////////////////////////
914964

915965
private onDialogOpening(event: IPuppetPageEvents['dialog-opening']): void {
@@ -943,6 +993,8 @@ interface ITabEventParams {
943993
'resource-requested': IResourceMeta;
944994
resource: IResourceMeta;
945995
dialog: IPuppetDialog;
996+
download: IDownload;
997+
'download-progress': IDownloadState;
946998
'websocket-message': IWebsocketResourceMessage;
947999
'child-tab-created': Tab;
9481000
}

core/server/ConnectionToClient.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,12 +294,14 @@ export default class ConnectionToClient extends TypedEventEmitter<{
294294
}
295295

296296
const session = Session.get(meta.sessionId);
297-
session.sessionState.nextCommandMeta = commandMeta;
298297
if (!session) {
298+
if (method === 'close' || method === 'removeEventListener') return;
299+
299300
return new SessionClosedOrMissingError(
300301
`The requested command (${command}) references a session that is closed or invalid.`,
301302
);
302303
}
304+
session.sessionState.nextCommandMeta = commandMeta;
303305

304306
return await this[method](meta, ...args);
305307
}

0 commit comments

Comments
 (0)