Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 84 additions & 1 deletion src/extension-runners/firefox-android.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@
* in a Firefox for Android instance.
*/

import fs from 'fs/promises';
import path from 'path';
import readline from 'readline';

import JSZip from 'jszip';

import { withTempDir } from '../util/temp-dir.js';
import DefaultADBUtils from '../util/adb.js';
import {
Expand All @@ -24,7 +27,6 @@ const ignoredParams = {
keepProfileChanges: '--keep-profile-changes',
browserConsole: '--browser-console',
preInstall: '--pre-install',
startUrl: '--start-url',
args: '--args',
};

Expand Down Expand Up @@ -116,6 +118,8 @@ export class FirefoxAndroidExtensionRunner {
// Connect to RDP socket on the local tcp server, install all the pushed extension
// and keep track of the built and installed extension by extension sourceDir.
await this.rdpInstallExtensions();

await this.launchStartUrlsIfNeeded();
}

// Method exported from the IExtensionRunner interface.
Expand Down Expand Up @@ -569,4 +573,83 @@ export class FirefoxAndroidExtensionRunner {
this.reloadableExtensions.set(extension.sourceDir, addonId);
}
}

/**
* Creates a helper extension to open --start-url as needed.
* Must be called after rdpInstallExtensions().
*
* The opened tabs could be in the background, if Fenix's homescreen is shown
* when the app launches. This is a limitation of Firefox for Android.
*/
async launchStartUrlsIfNeeded() {
const {
adbUtils,
selectedAdbDevice,
selectedArtifactsDir,
params: { startUrl },
} = this;
if (!startUrl?.length) {
return;
}
const urls = Array.isArray(startUrl) ? startUrl : [startUrl];

log.debug(`Trying to open URLs: ${urls.join(' ')}`);

async function backgroundScript(urlsToOpen) {
// eslint-disable-next-line no-undef
const browser = globalThis.browser;
for (const url of urlsToOpen) {
try {
await browser.tabs.create({ url });
} catch (e) {
// Ignore invalid URLs.
Promise.reject(e);
}
}
await browser.management.uninstallSelf();
}
// The extension ID and file name here are chosen to be unique. In theory
// if users choose the same extension ID, this would overwrite their
// extension. They should simply not choose these identifiers!
const xpiFileName = 'web-ext-internal-helper-to-open-start-urls.xpi';
const adbExtensionPath = `${selectedArtifactsDir}/${xpiFileName}`;
const manifestJson = JSON.stringify({
name: 'web-ext helper to open URLs',
description: 'web-ext helper to open URLs in new tabs and self-destruct',
version: '1',
manifest_version: 3,
background: { scripts: ['background.js'] },
permissions: ['notifications'],
browser_specific_settings: {
gecko: { id: '@web-ext-internal-helper-to-open-start-urls' },
gecko_android: {},
},
});
const backgroundJs = `(${backgroundScript})(${JSON.stringify(urls)})`;
await withTempDir(async (tmpDir) => {
const zip = new JSZip();
zip.file('manifest.json', manifestJson);
zip.file('background.js', backgroundJs);
const rawZipBytes = await zip.generateAsync({
compression: 'DEFLATE',
type: 'nodebuffer',
});
const extensionPath = path.join(tmpDir.path(), xpiFileName);
await fs.writeFile(extensionPath, rawZipBytes);

await adbUtils.pushFile(
selectedAdbDevice,
extensionPath,
adbExtensionPath,
);
});
try {
// this.remoteFirefox is initialized by rdpInstallExtensions.
await this.remoteFirefox.installTemporaryAddon(adbExtensionPath);
log.debug('Successfully installed helper extension to open URLs');
} catch (e) {
// Unexpected, but not a fatal error from web-ext's perspective.
log.error(`Failed to open URLs via internal helper extension: ${e}`);
}
}
}
37 changes: 35 additions & 2 deletions tests/unit/helpers.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import path from 'path';
import EventEmitter from 'events';
import stream from 'stream';
import streamConsumers from 'stream/consumers';
import { promisify } from 'util';
import { fileURLToPath, pathToFileURL } from 'url';

Expand All @@ -26,14 +27,15 @@ export class ZipFile {
constructor() {
this._zip = null;
this._close = null;
this._entries = null;
}

/*
* Open a zip file and return a promise that resolves to a yauzl
* zipfile object.
*/
open(...args) {
return promisify(yauzl.open)(...args).then((zip) => {
open(zippath) {
return promisify(yauzl.open)(zippath, { autoClose: false }).then((zip) => {
this._zip = zip;
this._close = new Promise((resolve) => {
zip.once('close', resolve);
Expand Down Expand Up @@ -62,8 +64,13 @@ export class ZipFile {
'Cannot operate on a falsey zip file. Call open() first.',
);
}
if (this._entries) {
throw new Error('readEach can be called only once');
}
this._entries = new Map();

this._zip.on('entry', (entry) => {
this._entries.set(entry.fileName, entry);
onRead(entry);
});

Expand Down Expand Up @@ -94,6 +101,32 @@ export class ZipFile {
});
});
}

async getEntryByFileName(fileName) {
if (!this._entries) {
await this.readEach(() => {});
}
return this._entries.get(fileName);
}

/**
* Resolve a promise with the content of the entry as a string.
*/
async getAsText(fileName) {
const entry = await this.getEntryByFileName(fileName);
if (!entry) {
throw new Error(`Entry not found in zip file: ${fileName}`);
}
return new Promise((resolve, reject) => {
this._zip.openReadStream(entry, (err, readStream) => {
if (err) {
reject(err);
} else {
resolve(streamConsumers.text(readStream));
}
});
});
}
}

/*
Expand Down
86 changes: 82 additions & 4 deletions tests/unit/test-extension-runners/test.firefox-android.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
createFakeStdin,
getFakeFirefox,
getFakeRemoteFirefox,
ZipFile,
} from '../helpers.js';

// Fake result for client.installTemporaryAddon().then(installResult => ...)
Expand All @@ -35,6 +36,9 @@ const fakeRDPUnixSocketFile =
const fakeRDPUnixAbstractSocketFile =
'@org.mozilla.firefox/firefox-debugger-socket';

// The actual XPI, xpiFileName in src/extension-runners/firefox-android.js
const helperXpiName = 'web-ext-internal-helper-to-open-start-urls.xpi';

// Reduce the waiting time during tests.
FirefoxAndroidExtensionRunner.unixSocketDiscoveryRetryInterval = 0;

Expand Down Expand Up @@ -833,6 +837,84 @@ describe('util/extension-runners/firefox-android', () => {
sinon.assert.calledOnce(anotherCallback);
});

it('opens a single URL when specified via --start-url', async () => {
const { params, fakeADBUtils } = prepareSelectedDeviceAndAPKParams();
params.startUrl = 'https://example.com/';

let pushedBackgroundJs;
fakeADBUtils.pushFile = sinon.spy(async (_, localZipPath) => {
if (localZipPath.includes(helperXpiName)) {
const zipFile = new ZipFile();
await zipFile.open(localZipPath);
pushedBackgroundJs = await zipFile.getAsText('background.js');
await zipFile.close();
}
});

const runnerInstance = new FirefoxAndroidExtensionRunner(params);
await runnerInstance.run();

const { installTemporaryAddon } = runnerInstance.remoteFirefox;

sinon.assert.calledTwice(installTemporaryAddon);

sinon.assert.calledWithMatch(
installTemporaryAddon,
`${runnerInstance.selectedArtifactsDir}/${builtFileName}.xpi`,
);

sinon.assert.calledWithMatch(
installTemporaryAddon,
`${runnerInstance.selectedArtifactsDir}/${helperXpiName}`,
);
sinon.assert.calledWithMatch(
fakeADBUtils.pushFile,
'emulator-1',
sinon.match(/\bweb-ext-internal-helper-to-open-start-urls\.xpi$/),
`${runnerInstance.selectedArtifactsDir}/${helperXpiName}`,
);
assert.include(pushedBackgroundJs, '["https://example.com/"]');
});

it('opens a multiple URL when specified via --start-url', async () => {
const { params, fakeADBUtils } = prepareSelectedDeviceAndAPKParams();
params.startUrl = ['about:blank', 'http://localhost'];

let pushedBackgroundJs;
fakeADBUtils.pushFile = sinon.spy(async (_, localZipPath) => {
if (localZipPath.includes(helperXpiName)) {
const zipFile = new ZipFile();
await zipFile.open(localZipPath);
pushedBackgroundJs = await zipFile.getAsText('background.js');
await zipFile.close();
}
});

const runnerInstance = new FirefoxAndroidExtensionRunner(params);
await runnerInstance.run();

sinon.assert.calledWithMatch(
fakeADBUtils.pushFile,
'emulator-1',
sinon.match(/\bweb-ext-internal-helper-to-open-start-urls\.xpi$/),
`${runnerInstance.selectedArtifactsDir}/${helperXpiName}`,
);
assert.include(pushedBackgroundJs, '["about:blank","http://localhost"]');
});

it('does not install helper extension without --start-url', async () => {
const { params } = prepareSelectedDeviceAndAPKParams();

const runnerInstance = new FirefoxAndroidExtensionRunner(params);
await runnerInstance.run();

const { installTemporaryAddon } = runnerInstance.remoteFirefox;
sinon.assert.calledOnce(installTemporaryAddon);
const seenPath = installTemporaryAddon.firstCall.args[0];
assert.include(seenPath, '.xpi');
assert.notInclude(seenPath, helperXpiName);
});

it('logs warnings on the unsupported CLI options', async () => {
const params = prepareSelectedDeviceAndAPKParams();

Expand All @@ -856,10 +938,6 @@ describe('util/extension-runners/firefox-android', () => {
params: { preInstall: true },
expectedMessage: /Android target does not support --pre-install/,
},
{
params: { startUrl: 'http://fake-start-url.org' },
expectedMessage: /Android target does not support --start-url/,
},
{
params: { args: ['-headless=false'] },
expectedMessage: /Android target does not support --args/,
Expand Down