♻️ Refactor download to work with new addons

This also makes things significantly easier to read
This commit is contained in:
trickypr 2022-07-11 23:03:23 +10:00
parent ac3ae4a5db
commit 27a74575b3
4 changed files with 351 additions and 367 deletions

View file

@ -1,40 +1,25 @@
// This Source Code Form is subject to the terms of the Mozilla Public // This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this // License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/. // file, You can obtain one at http://mozilla.org/MPL/2.0/.
import {
existsSync,
mkdirSync,
readFileSync,
rmdirSync,
unlinkSync,
writeFileSync,
} from 'fs'
import { join, posix, resolve, sep } from 'path'
import execa from 'execa'
import Listr from 'listr'
import { bin_name, config } from '..' import { bin_name, config } from '..'
import { BASH_PATH, ENGINE_DIR, MELON_TMP_DIR } from '../constants'
import {
commandExistsSync,
configDispatch,
delay,
ensureDir,
getConfig,
walkDirectoryTree,
windowsPathToUnix,
} from '../utils'
import { downloadFileToLocation } from '../utils/download'
import { readItem } from '../utils/store'
import { discard } from './discard'
import { init } from './init'
import { log } from '../log' import { log } from '../log'
const gFFVersion = getConfig().version.version import {
setupFirefoxSource,
shouldSetupFirefoxSource,
} from './download/firefox'
import {
addAddonsToMozBuild,
downloadAddon,
generateAddonMozBuild,
initializeAddon,
resolveAddonDownloadUrl,
unpackAddon,
} from './download/addon'
export const download = async (): Promise<void> => { export const download = async (): Promise<void> => {
const version = gFFVersion const version = config.version.version
// If gFFVersion isn't specified, provide legible error // If gFFVersion isn't specified, provide legible error
if (!version) { if (!version) {
@ -49,330 +34,28 @@ export const download = async (): Promise<void> => {
...config.addons[addon], ...config.addons[addon],
})) }))
// Listr and typescript do not mix. Just specify any and move on with the if (shouldSetupFirefoxSource()) {
// rest of our life await setupFirefoxSource(version)
// }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await new Listr<Record<string, string | any>>(
[
{
title: 'Downloading firefox source',
skip: () => {
if (
existsSync(ENGINE_DIR) &&
existsSync(resolve(ENGINE_DIR, 'toolkit', 'moz.build'))
) {
return 'Firefox has already been downloaded, unpacked and inited'
}
},
task: async (ctx, task) => {
ctx.firefoxSourceTar = await downloadFirefoxSource(version, task)
},
},
{
title: 'Unpack firefox source',
enabled: (ctx) => ctx.firefoxSourceTar,
task: async (ctx, task) => {
await unpackFirefoxSource(ctx.firefoxSourceTar, task)
},
},
{
title: 'Init firefox',
enabled: (ctx) => ctx.firefoxSourceTar && !process.env.CI_SKIP_INIT,
task: async (_ctx, task) => await init(ENGINE_DIR, task),
},
...addons
.map((addon) => includeAddon(addon.name, addon.url, addon.id))
.reduce((acc, cur) => [...acc, ...cur], []),
{
title: 'Add addons to mozbuild',
task: async () => {
// Discard the file to make sure it has no changes
await discard('browser/extensions/moz.build')
const path = join(ENGINE_DIR, 'browser', 'extensions', 'moz.build') for (const addon of addons) {
const downloadUrl = await resolveAddonDownloadUrl(addon)
const downloadedXPI = await downloadAddon(downloadUrl, addon)
// Append all the files to the bottom if (!downloadedXPI) {
writeFileSync( log.info(`Skipping ${addon.name}... Already installed`)
path, continue
`${readFileSync(path).toString()}\nDIRS += [${addons
.map((addon) => addon.name)
.sort()
.map((addon) => `"${addon}"`)
.join(',')}]`
)
},
},
{
title: 'Cleanup',
task: (ctx) => {
if (ctx.firefoxSourceTar) {
if (typeof ctx.firefoxSourceTar !== 'string') {
log.askForReport()
log.error(
`The type ctx.firefoxSourceTar was ${typeof ctx.firefoxSourceTar} when it should have been a string`
)
return
}
unlinkSync(resolve(MELON_TMP_DIR, ctx.firefoxSourceTar))
}
},
},
],
{
renderer: log.isDebug ? 'verbose' : 'default',
} }
).run()
await unpackAddon(downloadedXPI, addon)
await generateAddonMozBuild(addon)
await initializeAddon(addon)
}
await addAddonsToMozBuild(addons)
log.success( log.success(
`You should be ready to make changes to ${config.name}.\n\n\t You should import the patches next, run |${bin_name} import|.\n\t To begin building ${config.name}, run |${bin_name} build|.` `You should be ready to make changes to ${config.name}.\n\n\t You should import the patches next, run |${bin_name} import|.\n\t To begin building ${config.name}, run |${bin_name} build|.`
) )
console.log() console.log()
} }
const includeAddon = (
name: string,
downloadURL: string,
id: string
): Listr.ListrTask<Record<string, string>>[] => {
const tempFile = join(MELON_TMP_DIR, name + '.xpi')
const outPath = join(ENGINE_DIR, 'browser', 'extensions', name)
return [
{
title: `Download addon from ${downloadURL}`,
skip: () => {
if (existsSync(outPath)) {
// Now we need to do some tests. First, if there is no cache file,
// we must discard the existing folder and download the file again.
// If there is a cache file and the cache file points to the same path
// we can return and skip the download.
const extensionCache = readItem<{ url: string }>(name)
if (extensionCache.isNone()) {
// We haven't stored it in the cache, therefore we need to redonwload
// it
} else {
const cache = extensionCache.unwrap()
if (cache.url == downloadURL) {
return `${downloadURL} has already been loaded to ${name}`
}
}
}
},
task: async (ctx, task) => {
if (existsSync(tempFile)) {
unlinkSync(tempFile)
}
await downloadFileToLocation(
downloadURL,
tempFile,
(msg) => (task.output = msg)
)
ctx[name] = tempFile
// I do not know why, but this delay causes unzip to work reliably
await delay(200)
},
},
{
title: `Unpack to ${name}`,
enabled: (ctx) => typeof ctx[name] !== 'undefined',
task: async (ctx, task) => {
task.output = `Unpacking extension...`
// I do not know why, but this delay causes unzip to work reliably
await delay(200)
if (existsSync(outPath)) {
rmdirSync(outPath, { recursive: true })
}
mkdirSync(outPath, {
recursive: true,
})
await configDispatch('unzip', {
args: [
windowsPathToUnix(ctx[name]),
'-d',
windowsPathToUnix(outPath),
],
killOnError: true,
logger: (data) => (task.output = data),
shell: 'unix',
})
},
},
{
title: 'Generate mozbuild',
enabled: (ctx) => typeof ctx[name] !== 'undefined',
task: async () => {
const files = await walkDirectoryTree(outPath)
// Because the tree has the potential of being infinitely recursive, we
// cannot possibly know the the type of the tree
//
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function runTree(tree: any, parent: string): string {
if (Array.isArray(tree)) {
return tree
.sort()
.map(
(file) =>
`FINAL_TARGET_FILES.features["${id}"]${parent} += ["${file
.replace(outPath + '/', '')
.replace(outPath, '')}"]`
)
.join('\n')
}
const current = (tree['.'] as string[])
.sort()
// Don't use windows path, which brick mozbuild
.map((f) => windowsPathToUnix(f))
.map(
(f) =>
`FINAL_TARGET_FILES.features["${id}"]${parent} += ["${f
.replace(outPath + '/', '')
.replace(outPath, '')}"]`
)
.join('\n')
const children = Object.keys(tree)
.filter((folder) => folder !== '.')
.filter((folder) => typeof tree[folder] !== 'undefined')
.map((folder) => runTree(tree[folder], `${parent}["${folder}"]`))
.join('\n')
return `${current}\n${children}`
}
writeFileSync(
join(outPath, 'moz.build'),
`
DEFINES["MOZ_APP_VERSION"] = CONFIG["MOZ_APP_VERSION"]
DEFINES["MOZ_APP_MAXVERSION"] = CONFIG["MOZ_APP_MAXVERSION"]
${runTree(files, '')}`
)
},
},
{
// This step allows patches to be applied to extensions that are downloaded
// providing more flexibility to the browser developers
title: 'Initializing',
enabled: (ctx) => typeof ctx[name] !== 'undefined',
task: async (ctx, task) => {
await configDispatch('git', {
args: ['add', '-f', '.'],
cwd: outPath,
logger: (data) => (task.output = data),
})
await configDispatch('git', {
args: ['commit', '-m', name],
cwd: ENGINE_DIR,
logger: (data) => (task.output = data),
})
},
},
]
}
async function unpackFirefoxSource(
name: string,
task: Listr.ListrTaskWrapper<never>
): Promise<void> {
let cwd = process.cwd().split(sep).join(posix.sep)
if (process.platform == 'win32') {
cwd = './'
}
task.output = `Unpacking Firefox...`
if (existsSync(ENGINE_DIR)) rmdirSync(ENGINE_DIR)
mkdirSync(ENGINE_DIR)
let tarExec = 'tar'
// On MacOS, we need to use gnu tar, otherwise tar doesn't behave how we
// would expect it to behave, so this section is responsible for handling
// that
//
// If BSD tar adds --transform support in the future, we can use that
// instead
if (process.platform == 'darwin') {
// GNU Tar doesn't come preinstalled on any MacOS machines, so we need to
// check for it and ask for the user to install it if necessary
if (!commandExistsSync('gtar')) {
throw new Error(
`GNU Tar is required to extract Firefox's source on MacOS. Please install it using the command |brew install gnu-tar| and try again`
)
}
tarExec = 'gtar'
}
await execa(
tarExec,
[
'--strip-components=1',
process.platform == 'win32' ? '--force-local' : null,
'-xf',
windowsPathToUnix(resolve(MELON_TMP_DIR, name)),
'-C',
windowsPathToUnix(ENGINE_DIR),
].filter((x) => x) as string[],
{
// HACK: Use bash shell on windows to get a sane version of tar that works
shell: BASH_PATH || false,
}
)
}
// TODO: Make this function cache its output
async function downloadFirefoxSource(
version: string,
task: Listr.ListrTaskWrapper<never>
) {
const base = `https://archive.mozilla.org/pub/firefox/releases/${version}/source/`
const filename = `firefox-${version}.source.tar.xz`
const url = base + filename
const fsParent = MELON_TMP_DIR
const fsSaveLocation = resolve(fsParent, filename)
task.output = `Locating Firefox release ${version}...`
await ensureDir(fsParent)
if (existsSync(fsSaveLocation)) {
task.output = 'Using cached download'
return filename
}
if (version.includes('b'))
task.output =
'WARNING Version includes non-numeric characters. This is probably a beta.'
// Do not re-download if there is already an existing workspace present
if (existsSync(ENGINE_DIR)) {
log.error(
`Workspace already exists.\nRemove that workspace and run |${bin_name} download ${version}| again.`
)
}
task.output = `Downloading Firefox release ${version}...`
await downloadFileToLocation(
url,
resolve(MELON_TMP_DIR, filename),
(message) => (task.output = message)
)
return filename
}

View file

@ -0,0 +1,218 @@
import {
existsSync,
mkdirSync,
readFileSync,
rmdirSync,
unlinkSync,
writeFileSync,
} from 'fs'
import { join } from 'path'
import { isMatch } from 'picomatch'
import { ENGINE_DIR, MELON_TMP_DIR } from '../../constants'
import { log } from '../../log'
import {
AddonInfo,
configDispatch,
delay,
walkDirectoryTree,
windowsPathToUnix,
} from '../../utils'
import { downloadFileToLocation } from '../../utils/download'
import { readItem } from '../../utils/store'
import { discard } from '../discard'
export async function resolveAddonDownloadUrl(
addon: AddonInfo
): Promise<string> {
switch (addon.platform) {
case 'url':
return addon.url
case 'amo':
return (
await (
await fetch(
`https://addons.mozilla.org/api/v4/addons/addon/${addon.amoId}/versions/`
)
).json()
).results[0].files[0].url
case 'github':
return (
(
((
await (
await fetch(
`https://api.github.com/repos/${addon.repo}/releases/tags/${addon.version}`
)
).json()
).assets as {
url: string
browser_download_url: string
name: string
}[]) || []
).find((asset) => isMatch(asset.name, addon.fileGlob))
?.browser_download_url || 'failed'
)
}
}
export async function downloadAddon(
url: string,
addon: AddonInfo & { name: string }
): Promise<string | false> {
const tempFile = join(MELON_TMP_DIR, addon.name + '.xpi')
const outPath = join(ENGINE_DIR, 'browser', 'extensions', addon.name)
log.info(`Download addon from ${url}`)
if (existsSync(outPath)) {
// Now we need to do some tests. First, if there is no cache file,
// we must discard the existing folder and download the file again.
// If there is a cache file and the cache file points to the same path
// we can return and skip the download.
const extensionCache = readItem<{ url: string }>(addon.name)
if (extensionCache.isNone()) {
// We haven't stored it in the cache, therefore we need to redonwload
// it
} else {
const cache = extensionCache.unwrap()
if (cache.url == url) {
return false
}
}
}
if (existsSync(tempFile)) {
unlinkSync(tempFile)
}
await downloadFileToLocation(url, tempFile)
// I do not know why, but this delay causes unzip to work reliably
await delay(200)
return tempFile
}
export async function unpackAddon(
path: string,
addon: AddonInfo & { name: string }
) {
const outPath = join(ENGINE_DIR, 'browser', 'extensions', addon.name)
log.info(`Unpacking extension...`)
// I do not know why, but this delay causes unzip to work reliably
await delay(200)
if (existsSync(outPath)) {
rmdirSync(outPath, { recursive: true })
}
mkdirSync(outPath, {
recursive: true,
})
await configDispatch('unzip', {
args: [windowsPathToUnix(path), '-d', windowsPathToUnix(outPath)],
killOnError: true,
shell: 'unix',
})
}
export async function generateAddonMozBuild(
addon: AddonInfo & { name: string }
) {
const outPath = join(ENGINE_DIR, 'browser', 'extensions', addon.name)
log.info(`Generating addon mozbuild...`)
const files = await walkDirectoryTree(outPath)
// Because the tree has the potential of being infinitely recursive, we
// cannot possibly know the the type of the tree
//
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function runTree(tree: any, parent: string): string {
if (Array.isArray(tree)) {
return tree
.sort()
.map(
(file) =>
`FINAL_TARGET_FILES.features["${addon.id}"]${parent} += ["${file
.replace(outPath + '/', '')
.replace(outPath, '')}"]`
)
.join('\n')
}
const current = (tree['.'] as string[])
.sort()
// Don't use windows path, which brick mozbuild
.map((f) => windowsPathToUnix(f))
.map(
(f) =>
`FINAL_TARGET_FILES.features["${addon.id}"]${parent} += ["${f
.replace(outPath + '/', '')
.replace(outPath, '')}"]`
)
.join('\n')
const children = Object.keys(tree)
.filter((folder) => folder !== '.')
.filter((folder) => typeof tree[folder] !== 'undefined')
.map((folder) => runTree(tree[folder], `${parent}["${folder}"]`))
.join('\n')
return `${current}\n${children}`
}
writeFileSync(
join(outPath, 'moz.build'),
`
DEFINES["MOZ_APP_VERSION"] = CONFIG["MOZ_APP_VERSION"]
DEFINES["MOZ_APP_MAXVERSION"] = CONFIG["MOZ_APP_MAXVERSION"]
${runTree(files, '')}`
)
}
export async function initializeAddon(addon: AddonInfo & { name: string }) {
const outPath = join(ENGINE_DIR, 'browser', 'extensions', addon.name)
log.info(`Initializing addon...`)
await configDispatch('git', {
args: ['add', '-f', '.'],
cwd: outPath,
})
await configDispatch('git', {
args: ['commit', '-m', addon.name],
cwd: ENGINE_DIR,
})
}
export async function addAddonsToMozBuild(
addons: (AddonInfo & { name: string })[]
) {
log.info('Adding addons to mozbuild...')
// Discard the file to make sure it has no changes
await discard('browser/extensions/moz.build')
const path = join(ENGINE_DIR, 'browser', 'extensions', 'moz.build')
// Append all the files to the bottom
writeFileSync(
path,
`${readFileSync(path).toString()}\nDIRS += [${addons
.map((addon) => addon.name)
.sort()
.map((addon) => `"${addon}"`)
.join(',')}]`
)
}

View file

@ -0,0 +1,101 @@
import execa from 'execa'
import { existsSync, mkdirSync, rmdirSync } from 'fs'
import { resolve } from 'path'
import { bin_name } from '../..'
import { BASH_PATH, ENGINE_DIR, MELON_TMP_DIR } from '../../constants'
import { log } from '../../log'
import { commandExistsSync } from '../../utils/commandExists'
import { downloadFileToLocation } from '../../utils/download'
import { ensureDir, windowsPathToUnix } from '../../utils/fs'
import { init } from '../init'
export function shouldSetupFirefoxSource() {
return !(
existsSync(ENGINE_DIR) &&
existsSync(resolve(ENGINE_DIR, 'toolkit', 'moz.build'))
)
}
export async function setupFirefoxSource(version: string) {
const firefoxSourceTar = await downloadFirefoxSource(version)
await unpackFirefoxSource(firefoxSourceTar)
if (!process.env.CI_SKIP_INIT) {
log.info('Init firefox')
await init(ENGINE_DIR)
}
}
async function unpackFirefoxSource(name: string): Promise<void> {
log.info(`Unpacking Firefox...`)
if (existsSync(ENGINE_DIR)) rmdirSync(ENGINE_DIR)
mkdirSync(ENGINE_DIR)
let tarExec = 'tar'
// On MacOS, we need to use gnu tar, otherwise tar doesn't behave how we
// would expect it to behave, so this section is responsible for handling
// that
//
// If BSD tar adds --transform support in the future, we can use that
// instead
if (process.platform == 'darwin') {
// GNU Tar doesn't come preinstalled on any MacOS machines, so we need to
// check for it and ask for the user to install it if necessary
if (!commandExistsSync('gtar')) {
throw new Error(
`GNU Tar is required to extract Firefox's source on MacOS. Please install it using the command |brew install gnu-tar| and try again`
)
}
tarExec = 'gtar'
}
await execa(
tarExec,
[
'--strip-components=1',
process.platform == 'win32' ? '--force-local' : null,
'-xf',
windowsPathToUnix(resolve(MELON_TMP_DIR, name)),
'-C',
windowsPathToUnix(ENGINE_DIR),
].filter((x) => x) as string[],
{
// HACK: Use bash shell on windows to get a sane version of tar that works
shell: BASH_PATH || false,
}
)
}
async function downloadFirefoxSource(version: string) {
const base = `https://archive.mozilla.org/pub/firefox/releases/${version}/source/`
const filename = `firefox-${version}.source.tar.xz`
const url = base + filename
const fsParent = MELON_TMP_DIR
const fsSaveLocation = resolve(fsParent, filename)
log.info(`Locating Firefox release ${version}...`)
await ensureDir(fsParent)
if (existsSync(fsSaveLocation)) {
log.info('Using cached download')
return filename
}
// Do not re-download if there is already an existing workspace present
if (existsSync(ENGINE_DIR))
log.error(
`Workspace already exists.\nRemove that workspace and run |${bin_name} download ${version}| again.`
)
log.info(`Downloading Firefox release ${version}...`)
await downloadFileToLocation(url, resolve(MELON_TMP_DIR, filename))
return filename
}

View file

@ -3,24 +3,12 @@
// file, You can obtain one at http://mozilla.org/MPL/2.0/. // file, You can obtain one at http://mozilla.org/MPL/2.0/.
import { Command } from 'commander' import { Command } from 'commander'
import { existsSync, readFileSync } from 'fs' import { existsSync, readFileSync } from 'fs'
import Listr from 'listr'
import { resolve } from 'path' import { resolve } from 'path'
import { bin_name } from '..' import { bin_name } from '..'
import { log } from '../log' import { log } from '../log'
import { config, configDispatch } from '../utils' import { config, configDispatch } from '../utils'
export const init = async ( export const init = async (directory: Command | string): Promise<void> => {
directory: Command | string,
task?: Listr.ListrTaskWrapper<unknown>
): Promise<void> => {
function logInfo(data: string) {
if (task) {
task.output = data
} else {
log.info(data)
}
}
const cwd = process.cwd() const cwd = process.cwd()
const dir = resolve(cwd as string, directory.toString()) const dir = resolve(cwd as string, directory.toString())
@ -49,49 +37,43 @@ export const init = async (
version = version.trim().replace(/\\n/g, '') version = version.trim().replace(/\\n/g, '')
// TODO: Use bash on windows, this may significantly improve performance. Still needs testing though // TODO: Use bash on windows, this may significantly improve performance. Still needs testing though
logInfo('Initializing git, this may take some time') log.info('Initializing git, this may take some time')
await configDispatch('git', { await configDispatch('git', {
args: ['init'], args: ['init'],
cwd: dir, cwd: dir,
logger: logInfo,
shell: 'unix', shell: 'unix',
}) })
await configDispatch('git', { await configDispatch('git', {
args: ['init'], args: ['init'],
cwd: dir, cwd: dir,
logger: logInfo,
shell: 'unix', shell: 'unix',
}) })
await configDispatch('git', { await configDispatch('git', {
args: ['checkout', '--orphan', version], args: ['checkout', '--orphan', version],
cwd: dir, cwd: dir,
logger: logInfo,
shell: 'unix', shell: 'unix',
}) })
await configDispatch('git', { await configDispatch('git', {
args: ['add', '-f', '.'], args: ['add', '-f', '.'],
cwd: dir, cwd: dir,
logger: logInfo,
shell: 'unix', shell: 'unix',
}) })
logInfo('Committing...') log.info('Committing...')
await configDispatch('git', { await configDispatch('git', {
args: ['commit', '-aqm', `"Firefox ${version}"`], args: ['commit', '-aqm', `"Firefox ${version}"`],
cwd: dir, cwd: dir,
logger: logInfo,
shell: 'unix', shell: 'unix',
}) })
await configDispatch('git', { await configDispatch('git', {
args: ['checkout', '-b', config.name.toLowerCase().replace(/\s/g, '_')], args: ['checkout', '-b', config.name.toLowerCase().replace(/\s/g, '_')],
cwd: dir, cwd: dir,
logger: logInfo,
shell: 'unix', shell: 'unix',
}) })
} }