Skip to content

Commit 04fac07

Browse files
committed
feat: file downloads
1 parent a4a93b4 commit 04fac07

28 files changed

+878
-58
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 },
@@ -60,6 +64,7 @@ export default class CoreTab implements IJsPathEventTarget {
6064
this.eventHeap.registerEventInterceptors({
6165
resource: createResource.bind(null, resolvedThis),
6266
dialog: createDialog.bind(null, resolvedThis),
67+
download: this.createDownload.bind(this),
6368
});
6469
}
6570

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

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)[] = [

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
@@ -54,6 +54,14 @@ export default class AwaitedEventListener {
5454
// need to give client time to register function sending events
5555
process.nextTick(() => tab.sessionState.onWebsocketMessages(resourceId, listener.listenFn));
5656
}
57+
if (listener.jsPath && listener.jsPath[0] === 'downloads') {
58+
const downloadId = listener.jsPath[1] as string;
59+
listener.listenFn = download => {
60+
if (download.id === downloadId) this.triggerListenersWithId(listenerId, download);
61+
};
62+
tab.on('download-progress', listener.listenFn);
63+
process.nextTick(() => listener.listenFn(tab.session.downloadsById.get(downloadId)));
64+
}
5765
} else if (type && tab) {
5866
if (type !== 'close') {
5967
listener.listenFn = this.triggerListenersWithType.bind(this, type);

core/lib/CommandFormatter.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,14 @@ export default class CommandFormatter {
154154
}
155155

156156
if (command.result && command.name.startsWith('go')) {
157+
const result = command.result;
157158
command.result = undefined;
159+
Object.defineProperty(command, 'parsedResult', {
160+
enumerable: false,
161+
get(): any {
162+
return result;
163+
},
164+
});
158165
}
159166

160167
// we have shell objects occasionally coming back. hide from ui

core/lib/CorePlugins.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ 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';
2929
import IHttpSocketAgent from '@secret-agent/interfaces/IHttpSocketAgent';
30+
import IPuppetContext from '@secret-agent/interfaces/IPuppetContext';
3031
import Core from '../index';
3132

3233
const DefaultBrowserEmulatorId = 'default-browser-emulator';
@@ -145,6 +146,12 @@ export default class CorePlugins implements ICorePlugins {
145146
);
146147
}
147148

149+
public async onNewPuppetContext(context: IPuppetContext): Promise<void> {
150+
await Promise.all(
151+
this.instances.filter(p => p.onNewPuppetContext).map(p => p.onNewPuppetContext(context)),
152+
);
153+
}
154+
148155
public async onNewPuppetPage(page: IPuppetPage): Promise<void> {
149156
await Promise.all(
150157
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
@@ -22,6 +22,7 @@ import IJsPathResult from '@secret-agent/interfaces/IJsPathResult';
2222
import ISessionCreateOptions from '@secret-agent/interfaces/ISessionCreateOptions';
2323
import IGeolocation from '@secret-agent/interfaces/IGeolocation';
2424
import EventSubscriber from '@secret-agent/commons/EventSubscriber';
25+
import IDownload, { IDownloadState } from '@secret-agent/interfaces/IDownload';
2526
import SessionState from './SessionState';
2627
import AwaitedEventListener from './AwaitedEventListener';
2728
import GlobalPool from './GlobalPool';
@@ -61,6 +62,7 @@ export default class Session extends TypedEventEmitter<{
6162
public userProfile?: IUserProfile;
6263

6364
public tabsById = new Map<number, Tab>();
65+
public downloadsById = new Map<string, IDownload & Partial<IDownloadState>>();
6466

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

core/lib/Tab.ts

Lines changed: 65 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,
@@ -369,6 +374,18 @@ export default class Tab extends TypedEventEmitter<ITabEventParams> {
369374
this.navigationsObserver.waitForNavigationResourceId(),
370375
timeoutMessage,
371376
);
377+
378+
const loaderId = this.puppetPage.mainFrame.activeLoaderId;
379+
if (loaderId === navigationResult.loaderId) {
380+
for (const [key, date] of Object.entries(this.puppetPage.mainFrame.lifecycleEvents)) {
381+
let status: LoadStatus;
382+
if (key === 'load') status = LoadStatus.Load;
383+
else if (key === 'DOMContentLoaded') status = LoadStatus.DomContentLoaded;
384+
else continue;
385+
this.mainFrameEnvironment.navigations.onLoadStateChanged(status, url, loaderId, date);
386+
}
387+
}
388+
372389
return this.sessionState.getResourceMeta(resource);
373390
}
374391

@@ -454,6 +471,12 @@ export default class Tab extends TypedEventEmitter<ITabEventParams> {
454471
return this.puppetPage.dismissDialog(accept, promptText);
455472
}
456473

474+
public async deleteDownload(id: string): Promise<void> {
475+
const download = this.session.downloadsById.get(id);
476+
if (!download) return;
477+
if (await existsAsync(download.path)) await Fs.promises.unlink(download.path);
478+
}
479+
457480
public async waitForNewTab(options: IWaitForOptions = {}): Promise<Tab> {
458481
// last command is the one running right now
459482
const startCommandId = Number.isInteger(options.sinceCommandId)
@@ -652,6 +675,9 @@ export default class Tab extends TypedEventEmitter<ITabEventParams> {
652675
page.on('page-callback-triggered', this.onPageCallback.bind(this));
653676
page.on('dialog-opening', this.onDialogOpening.bind(this));
654677
page.on('filechooser', this.onFileChooser.bind(this));
678+
page.on('download-started', this.onDownloadStarted.bind(this));
679+
page.on('download-progress', this.onDownloadProgress.bind(this));
680+
page.on('download-finished', this.onDownloadFinished.bind(this));
655681

656682
// resource requested should registered before navigations so we can grab nav on new tab anchor clicks
657683
page.on('resource-will-be-requested', this.onResourceWillBeRequested.bind(this), true);
@@ -938,6 +964,42 @@ export default class Tab extends TypedEventEmitter<ITabEventParams> {
938964
this.sessionState.captureError(this.id, this.mainFrameId, `events.error`, error);
939965
}
940966

967+
/////// DOWNLOADS ////////////////////////////////////////////////////////////////////////////////
968+
969+
private onDownloadStarted(event: IPuppetPageEvents['download-started']): void {
970+
const broadcast = event as IDownload;
971+
broadcast.downloadPath = `/downloads?${encode({
972+
id: event.id,
973+
sessionId: this.sessionId,
974+
})}`;
975+
this.session.downloadsById.set(event.id, broadcast);
976+
this.emit('download', broadcast);
977+
}
978+
979+
private onDownloadProgress(event: IPuppetPageEvents['download-progress']): void {
980+
const state = this.session.downloadsById.get(event.id);
981+
const broadcast = {
982+
...event,
983+
canceled: false,
984+
complete: false,
985+
};
986+
if (state) Object.assign(state, broadcast);
987+
988+
this.emit('download-progress', broadcast);
989+
}
990+
991+
private onDownloadFinished(event: IPuppetPageEvents['download-finished']): void {
992+
const state = this.session.downloadsById.get(event.id);
993+
const broadcast = {
994+
...event,
995+
complete: true,
996+
progress: 100,
997+
};
998+
if (state) Object.assign(state, broadcast);
999+
1000+
this.emit('download-progress', broadcast);
1001+
}
1002+
9411003
/////// DIALOGS //////////////////////////////////////////////////////////////////////////////////
9421004

9431005
private onDialogOpening(event: IPuppetPageEvents['dialog-opening']): void {
@@ -971,6 +1033,8 @@ interface ITabEventParams {
9711033
'resource-requested': IResourceMeta;
9721034
resource: IResourceMeta;
9731035
dialog: IPuppetDialog;
1036+
download: IDownload;
1037+
'download-progress': IDownloadState;
9741038
'websocket-message': IWebsocketResourceMessage;
9751039
'child-tab-created': Tab;
9761040
}

0 commit comments

Comments
 (0)