Repo created

This commit is contained in:
Fr4nz D13trich 2025-11-24 08:57:03 +01:00
parent f3a6b3a320
commit f954c78789
614 changed files with 135712 additions and 2 deletions

View file

@ -0,0 +1,190 @@
const { existsSync, readFileSync } = require('fs')
const { readFile } = require('fs/promises')
const { join } = require('path')
const { brotliCompress, constants } = require('zlib')
const { promisify } = require('util')
const { load: loadYaml } = require('js-yaml')
const brotliCompressAsync = promisify(brotliCompress)
const PLUGIN_NAME = 'ProcessLocalesPlugin'
class ProcessLocalesPlugin {
constructor(options = {}) {
this.compress = !!options.compress
this.hotReload = !!options.hotReload
if (typeof options.inputDir !== 'string') {
throw new Error('ProcessLocalesPlugin: no input directory `inputDir` specified.')
} else if (!existsSync(options.inputDir)) {
throw new Error('ProcessLocalesPlugin: the specified input directory does not exist.')
}
this.inputDir = options.inputDir
if (typeof options.outputDir !== 'string') {
throw new Error('ProcessLocalesPlugin: no output directory `outputDir` specified.')
}
this.outputDir = options.outputDir
/** @type {Map<string, any>} */
this.locales = new Map()
this.localeNames = []
/** @type {Map<string, any>} */
this.cache = new Map()
this.filePaths = []
this.previousTimestamps = new Map()
this.startTime = Date.now()
/** @type {(updatedLocales: [string, string][]) => void|null} */
this.notifyLocaleChange = null
this.loadLocales()
}
/** @param {import('webpack').Compiler} compiler */
apply(compiler) {
const { CachedSource, RawSource } = compiler.webpack.sources
const { Compilation, DefinePlugin } = compiler.webpack
new DefinePlugin({
'process.env.HOT_RELOAD_LOCALES': this.hotReload
}).apply(compiler)
compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => {
const IS_DEV_SERVER = !!compiler.watching
compilation.hooks.processAssets.tapPromise({
name: PLUGIN_NAME,
stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL
}, async (_assets) => {
// While running in the webpack dev server, this hook gets called for every incremental build.
// For incremental builds we can return the already processed versions, which saves time
// and makes webpack treat them as cached
const promises = []
/** @type {[string, string][]} */
const updatedLocales = []
if (this.hotReload && !this.notifyLocaleChange) {
console.warn('ProcessLocalesPlugin: Unable to live reload locales as `notifyLocaleChange` is not set.')
}
for (let [locale, data] of this.locales) {
// eslint-disable-next-line no-async-promise-executor
promises.push(new Promise(async (resolve) => {
if (IS_DEV_SERVER && compiler.fileTimestamps) {
const filePath = join(this.inputDir, `${locale}.yaml`)
const timestamp = compiler.fileTimestamps.get(filePath)?.safeTime
if (timestamp && timestamp > (this.previousTimestamps.get(locale) ?? this.startTime)) {
this.previousTimestamps.set(locale, timestamp)
const contents = await readFile(filePath, 'utf-8')
data = loadYaml(contents)
} else {
const { filename, source } = this.cache.get(locale)
compilation.emitAsset(filename, source, { minimized: true })
resolve()
return
}
}
this.removeEmptyValues(data)
let filename = `${this.outputDir}/${locale}.json`
let output = JSON.stringify(data)
if (this.hotReload && compiler.fileTimestamps) {
updatedLocales.push([locale, output])
}
if (this.compress) {
filename += '.br'
output = await this.compressLocale(output)
}
let source = new RawSource(output)
if (IS_DEV_SERVER) {
source = new CachedSource(source)
this.cache.set(locale, { filename, source })
// we don't need the unmodified sources anymore, as we use the cache `this.cache`
// so we can clear this to free some memory
this.locales.set(locale, null)
}
compilation.emitAsset(filename, source, { minimized: true })
resolve()
}))
}
await Promise.all(promises)
if (this.hotReload && this.notifyLocaleChange && updatedLocales.length > 0) {
this.notifyLocaleChange(updatedLocales)
}
})
})
compiler.hooks.afterCompile.tap(PLUGIN_NAME, (compilation) => {
// eslint-disable-next-line no-extra-boolean-cast
if (!!compiler.watching) {
// watch locale files for changes
compilation.fileDependencies.addAll(this.filePaths)
}
})
}
loadLocales() {
const activeLocales = JSON.parse(readFileSync(`${this.inputDir}/activeLocales.json`))
for (const locale of activeLocales) {
const filePath = join(this.inputDir, `${locale}.yaml`)
this.filePaths.push(filePath)
const contents = readFileSync(filePath, 'utf-8')
const data = loadYaml(contents)
this.locales.set(locale, data)
this.localeNames.push(data['Locale Name'] ?? locale)
}
}
async compressLocale(data) {
const buffer = Buffer.from(data, 'utf-8')
return await brotliCompressAsync(buffer, {
params: {
[constants.BROTLI_PARAM_MODE]: constants.BROTLI_MODE_TEXT,
[constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MAX_QUALITY,
[constants.BROTLI_PARAM_SIZE_HINT]: buffer.byteLength
}
})
}
/**
* vue-i18n doesn't fallback if the translation is an empty string
* so we want to get rid of them and also remove the empty objects that can get left behind
* if we've removed all the keys and values in them
* @param {object|string} data
*/
removeEmptyValues(data) {
for (const key of Object.keys(data)) {
const value = data[key]
if (typeof value === 'object') {
this.removeEmptyValues(value)
}
if (!value || (typeof value === 'object' && Object.keys(value).length === 0)) {
delete data[key]
}
}
}
}
module.exports = ProcessLocalesPlugin

0
_scripts/_empty.js Normal file
View file

31
_scripts/_localforage.js Normal file
View file

@ -0,0 +1,31 @@
import { readFile, writeFile } from '../src/renderer/helpers/android'
export function createInstance(_kwargs) {
return {
async getItem(key) {
const dataLocationFile = await readFile('data://', 'data-location.json')
if (dataLocationFile !== '') {
const locationInfo = JSON.parse(dataLocationFile)
const locationMap = Object.fromEntries(locationInfo.files.map((file) => { return [file.fileName, file.uri] }))
if (key in locationMap) {
return await readFile(locationMap[key])
}
}
const data = await readFile('data://', key)
return data
},
async setItem(key, value) {
const dataLocationFile = await readFile('data://', 'data-location.json')
if (dataLocationFile !== '') {
const locationInfo = JSON.parse(dataLocationFile)
const locationMap = Object.fromEntries(locationInfo.files.map((file) => { return [file.fileName, file.uri] }))
if (key in locationMap) {
await writeFile(locationMap[key], value)
return
}
}
await writeFile('data://', key, value)
}
}
}

View file

@ -0,0 +1,124 @@
import { readFile, readdir, writeFile } from 'fs/promises'
import { join } from 'path'
import { fileURLToPath } from 'url'
// sets the splashscreen & icon to one of three predefined themes (this makes it easier to tell, at a glance, which one is open)
// - release (the default production look)
// - nightly
// OR
// - development
const COLOURS = {
RELEASE: {
primary: '#f04242',
secondary: '#14a4df',
back: '#E4E4E4',
backDark: '#212121'
},
// catppucin mocha theme colours
NIGHTLY: {
primary: '#cdd6f4',
secondary: '#cdd6f4',
back: '#1e1e2e',
backDark: '#1e1e2e'
},
// inverted release colours
DEVELOPMENT: {
primary: '#E4E4E4',
secondary: '#E4E4E4',
back: '#f04242',
backDark: '#f04242'
},
// blue from the logo as the background colour
RC: {
primary: '#E4E4E4',
secondary: '#E4E4E4',
back: '#14a4df',
backDark: '#14a4df'
},
// solarised dark
FEATURE_BRANCH: {
primary: '#E4E4E4',
secondary: '#E4E4E4',
back: '#204b56',
backDark: '#204b56'
}
}
let colour = 'RELEASE'
for (const key in COLOURS) {
if (process.argv.indexOf(`--${key.toLowerCase().replaceAll('_', '-')}`) !== -1) {
colour = key
}
}
const currentTheme = COLOURS[colour]
const scriptDir = fileURLToPath(import.meta.url)
const drawablePath = join(scriptDir, '../../android/app/src/main/res/drawable/')
const foreground = join(drawablePath, 'ic_launcher_foreground.xml')
let foregroundXML = (await readFile(foreground)).toString()
foregroundXML = foregroundXML.replace(/<path android:fillColor="[^"]*?" android:strokeWidth="0\.784519" android:pathData="M 27/g, `<path android:fillColor="${currentTheme.primary}" android:strokeWidth="0.784519" android:pathData="M 27`)
foregroundXML = foregroundXML.replace(/<path android:fillColor="[^"]*?" android:strokeWidth="0\.784519" android:pathData="M 18/g, `<path android:fillColor="${currentTheme.primary}" android:strokeWidth="0.784519" android:pathData="M 18`)
foregroundXML = foregroundXML.replace(/<path android:fillColor="[^"]*?" android:strokeWidth="0\.784519" android:pathData="M 28/g, `<path android:fillColor="${currentTheme.secondary}" android:strokeWidth="0.784519" android:pathData="M 28`)
await writeFile(foreground, foregroundXML)
const background = join(drawablePath, 'ic_launcher_background.xml')
let backgroundXML = (await readFile(background)).toString()
backgroundXML = backgroundXML.replace(/android:fillColor="[^"]*?" \/>/g, `android:fillColor="${currentTheme.back}" />`)
await writeFile(background, backgroundXML)
/**
* @warning name is passed into regex unsantised; should never be given user input
* @param {string} xml
* @param {string} name
* @param {string} value
*/
function replaceItem(xml, name, value) {
return xml.replace(new RegExp(`<item name="android:${name}">[^"]*?<\/item>`), `<item name="android:${name}">${value}</item>`)
}
async function constructThemePath(isDark = false, version = 0) {
const resDirectory = join(scriptDir, '..', '..', 'android/app/src/main/res/')
const files = await readdir(resDirectory)
const versionsListed = files
.filter(file => file.startsWith(`values${isDark ? '-night-' : '-'}v`))
.map(file => parseInt(file.split('-v')[1]))
if (versionsListed.indexOf(version) !== -1) {
return join(resDirectory, `values${isDark ? '-night-' : '-'}v${version}`, 'themes.xml')
} else {
return join(resDirectory, 'values', 'themes.xml')
}
}
async function setValuesForThemeFile(values, isDark = false, version = 0) {
const themePath = await constructThemePath(isDark, version)
let themeXml = (await readFile(themePath)).toString()
for (const key in values) {
themeXml = replaceItem(themeXml, key, values[key])
}
await writeFile(themePath, themeXml)
}
await setValuesForThemeFile({
windowSplashScreenBackground: currentTheme.back,
windowSplashScreenIconBackgroundColor: currentTheme.back
}, false, 31)
await setValuesForThemeFile({
windowSplashScreenBackground: currentTheme.backDark,
windowSplashScreenIconBackgroundColor: currentTheme.backDark
}, true, 31)
await setValuesForThemeFile({
windowSplashScreenBackground: currentTheme.back,
windowSplashScreenIconBackgroundColor: currentTheme.back
}, false, 33)
await setValuesForThemeFile({
windowSplashScreenBackground: currentTheme.backDark,
windowSplashScreenIconBackgroundColor: currentTheme.backDark
}, true, 33)

View file

@ -0,0 +1,14 @@
const { readFile, writeFile } = require('fs/promises')
const { join } = require('path')
;(async () => {
const manifest = (await readFile(join(__dirname, '../android/app/src/main/AndroidManifest.xml'))).toString()
const invidiousInstances = JSON.parse((await readFile(join(__dirname, '../static/invidious-instances.json'))).toString())
const supportedLinks = manifest.match(/<!-- supported links -->[\s\S]*?<!-- \/supported links -->/gm)
const instancesXml = invidiousInstances.map(({ url, cors}) => {
return `<data android:host="${url.replace('https://', '')}" />`
}).join('\n ')
const postManifest = manifest.replace(supportedLinks[0], `<!-- supported links -->\n ${instancesXml}\n <!-- \/supported links -->`)
await writeFile(join(__dirname, '../android/app/src/main/AndroidManifest.xml'), postManifest)
})()

53
_scripts/build.js Normal file
View file

@ -0,0 +1,53 @@
const os = require('os')
const builder = require('electron-builder')
const config = require('./ebuilder.config.js')
const Platform = builder.Platform
const Arch = builder.Arch
const args = process.argv
let targets
const platform = os.platform()
if (platform === 'darwin') {
let arch = Arch.x64
if (args[2] === 'arm64') {
arch = Arch.arm64
}
targets = Platform.MAC.createTarget(['DMG', 'zip', '7z'], arch)
} else if (platform === 'win32') {
let arch = Arch.x64
if (args[2] === 'arm64') {
arch = Arch.arm64
}
targets = Platform.WINDOWS.createTarget(['nsis', 'zip', '7z', 'portable'], arch)
} else if (platform === 'linux') {
let arch = Arch.x64
if (args[2] === 'arm64') {
arch = Arch.arm64
}
if (args[2] === 'arm32') {
arch = Arch.armv7l
}
targets = Platform.LINUX.createTarget(['deb', 'zip', '7z', 'rpm', 'AppImage', 'pacman'], arch)
}
builder
.build({
targets,
config,
publish: 'never'
})
.then(m => {
console.log(m)
})
.catch(e => {
console.error(e)
})

10
_scripts/clean.mjs Normal file
View file

@ -0,0 +1,10 @@
import { rm } from 'fs/promises'
import { join } from 'path'
const BUILD_PATH = join(import.meta.dirname, '..', 'build')
const DIST_PATH = join(import.meta.dirname, '..', 'dist')
await Promise.all([
rm(BUILD_PATH, { recursive: true, force: true }),
rm(DIST_PATH, { recursive: true, force: true })
])

215
_scripts/dev-runner.js Normal file
View file

@ -0,0 +1,215 @@
process.env.NODE_ENV = 'development'
const electron = require('electron')
const webpack = require('webpack')
const WebpackDevServer = require('webpack-dev-server')
const kill = require('tree-kill')
const path = require('path')
const { spawn } = require('child_process')
const ProcessLocalesPlugin = require('./ProcessLocalesPlugin')
let electronProcess = null
let manualRestart = null
const remoteDebugging = process.argv.indexOf('--remote-debug') !== -1
const web = process.argv.indexOf('--web') !== -1
let mainConfig
let rendererConfig
let botGuardScriptConfig
let webConfig
let SHAKA_LOCALES_TO_BE_BUNDLED
if (!web) {
mainConfig = require('./webpack.main.config')
rendererConfig = require('./webpack.renderer.config')
botGuardScriptConfig = require('./webpack.botGuardScript.config')
SHAKA_LOCALES_TO_BE_BUNDLED = rendererConfig.SHAKA_LOCALES_TO_BE_BUNDLED
delete rendererConfig.SHAKA_LOCALES_TO_BE_BUNDLED
} else {
webConfig = require('./webpack.web.config')
}
if (remoteDebugging) {
// disable dvtools open in electron
process.env.RENDERER_REMOTE_DEBUGGING = true
}
// Define exit code for relaunch and set it in the environment
const relaunchExitCode = 69
process.env.FREETUBE_RELAUNCH_EXIT_CODE = relaunchExitCode
const port = 9080
async function killElectron(pid) {
return new Promise((resolve, reject) => {
if (pid) {
kill(pid, err => {
if (err) reject(err)
resolve()
})
} else {
resolve()
}
})
}
async function restartElectron() {
console.log('\nStarting electron...')
const { pid } = electronProcess || {}
await killElectron(pid)
electronProcess = spawn(electron, [
path.join(__dirname, '../dist/main.js'),
// '--enable-logging', // Enable to show logs from all electron processes
remoteDebugging ? '--inspect=9222' : '',
remoteDebugging ? '--remote-debugging-port=9223' : ''
],
// { stdio: 'inherit' } // required for logs to actually appear in the stdout
)
electronProcess.on('exit', (code, _) => {
if (code === relaunchExitCode) {
electronProcess = null
restartElectron()
return
}
if (!manualRestart) process.exit(0)
})
}
/**
* @param {import('webpack').Compiler} compiler
* @param {WebpackDevServer} devServer
*/
function setupNotifyLocaleUpdate(compiler, devServer) {
const notifyLocaleChange = (updatedLocales) => {
devServer.sendMessage(devServer.webSocketServer.clients, 'freetube-locale-update', updatedLocales)
}
compiler.options.plugins
.filter(plugin => plugin instanceof ProcessLocalesPlugin)
.forEach((/** @type {ProcessLocalesPlugin} */plugin) => {
plugin.notifyLocaleChange = notifyLocaleChange
})
}
function startBotGuardScript() {
webpack(botGuardScriptConfig, (err) => {
if (err) console.error(err)
console.log(`\nCompiled ${botGuardScriptConfig.name} script!`)
})
}
function startMain() {
const compiler = webpack(mainConfig)
const { name } = compiler
compiler.hooks.afterEmit.tap('afterEmit', async () => {
console.log(`\nCompiled ${name} script!`)
manualRestart = true
await restartElectron()
setTimeout(() => {
manualRestart = false
}, 2500)
console.log(`\nWatching file changes for ${name} script...`)
})
compiler.watch({
aggregateTimeout: 500,
},
err => {
if (err) console.error(err)
})
}
function startRenderer(callback) {
const compiler = webpack(rendererConfig)
const { name } = compiler
compiler.hooks.afterEmit.tap('afterEmit', () => {
console.log(`\nCompiled ${name} script!`)
console.log(`\nWatching file changes for ${name} script...`)
})
const server = new WebpackDevServer({
static: [
{
directory: path.resolve(__dirname, '..', 'static'),
watch: {
ignored: [
/(dashFiles|storyboards)\/*/,
'/**/.DS_Store',
'**/static/locales/*'
]
},
publicPath: '/static'
},
{
directory: path.resolve(__dirname, '..', 'node_modules', 'shaka-player', 'ui', 'locales'),
publicPath: '/static/shaka-player-locales',
watch: {
// Ignore everything that isn't one of the locales that we would bundle in production mode
ignored: `**/!(${SHAKA_LOCALES_TO_BE_BUNDLED.join('|')}).json`
}
}
],
port
}, compiler)
server.startCallback(err => {
if (err) console.error(err)
setupNotifyLocaleUpdate(compiler, server)
callback()
})
}
function startWeb () {
const compiler = webpack(webConfig)
const { name } = compiler
compiler.hooks.afterEmit.tap('afterEmit', () => {
console.log(`\nCompiled ${name} script!`)
console.log(`\nWatching file changes for ${name} script...`)
})
const server = new WebpackDevServer({
open: true,
static: {
directory: path.resolve(__dirname, '..', 'static'),
watch: {
ignored: [
/(dashFiles|storyboards)\/*/,
'/**/.DS_Store',
'**/static/locales/*'
]
}
},
port
}, compiler)
server.startCallback(err => {
if (err) console.error(err)
setupNotifyLocaleUpdate(compiler, server)
})
}
if (!web) {
startRenderer(() => {
startBotGuardScript()
startMain()
})
} else {
startWeb()
}

View file

@ -0,0 +1,94 @@
const { name, productName } = require('../package.json')
const config = {
appId: `io.freetubeapp.${name}`,
copyright: 'Copyleft © 2020-2025 freetubeapp@protonmail.com',
// asar: false,
// compression: 'store',
productName,
directories: {
output: './build/',
},
protocols: [
{
name: 'FreeTube',
schemes: [
'freetube'
]
}
],
files: [
'_icons/iconColor.*',
'icon.svg',
'./dist/**/*',
'!dist/web/*',
'!node_modules/**/*',
],
dmg: {
contents: [
{
path: '/Applications',
type: 'link',
x: 410,
y: 230,
},
{
type: 'file',
x: 130,
y: 230,
},
],
window: {
height: 380,
width: 540,
}
},
linux: {
category: 'Network',
icon: '_icons/icon.svg',
target: ['deb', 'zip', '7z', 'rpm', 'AppImage', 'pacman'],
},
// See the following issues for more information
// https://github.com/jordansissel/fpm/issues/1503
// https://github.com/jgraph/drawio-desktop/issues/259
rpm: {
fpm: ['--rpm-rpmbuild-define=_build_id_links none']
},
deb: {
depends: [
'libgtk-3-0',
'libnotify4',
'libnss3',
'libxss1',
'libxtst6',
'xdg-utils',
'libatspi2.0-0',
'libuuid1',
'libsecret-1-0'
]
},
mac: {
category: 'public.app-category.utilities',
icon: '_icons/iconMac.icns',
target: ['dmg', 'zip', '7z'],
type: 'distribution',
extendInfo: {
CFBundleURLTypes: [
'freetube'
],
CFBundleURLSchemes: [
'freetube'
]
}
},
win: {
icon: '_icons/icon.ico',
target: ['nsis', 'zip', '7z', 'portable'],
},
nsis: {
allowToChangeInstallationDirectory: true,
oneClick: false,
},
}
module.exports = config

View file

@ -0,0 +1,11 @@
import preferUseI18nPolyfillRule from './prefer-use-i18n-polyfill-rule.mjs'
export default {
meta: {
name: 'eslint-plugin-freetube',
version: '1.0'
},
rules: {
'prefer-use-i18n-polyfill': preferUseI18nPolyfillRule
}
}

View file

@ -0,0 +1,62 @@
import { dirname, relative, resolve } from 'path'
const polyfillPath = resolve(import.meta.dirname, '../../src/renderer/composables/use-i18n-polyfill')
function getRelativePolyfillPath(filePath) {
const relativePath = relative(dirname(filePath), polyfillPath).replaceAll('\\', '/')
if (relativePath[0] !== '.') {
return `./${relativePath}`
}
return relativePath
}
/** @type {import('eslint').Rule.RuleModule} */
export default {
meta: {
type: 'problem',
fixable: 'code'
},
create(context) {
return {
'ImportDeclaration[source.value="vue-i18n"]'(node) {
const specifierIndex = node.specifiers.findIndex(specifier => specifier.type === 'ImportSpecifier' && specifier.imported.name === 'useI18n')
if (specifierIndex !== -1) {
context.report({
node: node.specifiers.length === 1 ? node : node.specifiers[specifierIndex],
message: "Please use FreeTube's useI18n polyfill, as vue-i18n's useI18n composable does not work when the vue-i18n is in legacy mode, which is needed for components using the Options API.",
fix: context.physicalFilename === '<text>'
? undefined
: (fixer) => {
const relativePath = getRelativePolyfillPath(context.physicalFilename)
// If the import only imports `useI18n`, we can just update the source/from text
// Else we need to create a new import for `useI18n` and remove useI18n from the original one
if (node.specifiers.length === 1) {
return fixer.replaceText(node.source, `'${relativePath}'`)
} else {
const specifier = node.specifiers[specifierIndex]
let specifierText = 'useI18n'
if (specifier.imported.name !== specifier.local.name) {
specifierText += ` as ${specifier.local.name}`
}
return [
fixer.removeRange([
specifierIndex === 0 ? specifier.start : node.specifiers[specifierIndex - 1].end,
specifierIndex === node.specifiers.length - 1 ? specifier.end : node.specifiers[specifierIndex + 1].start
]),
fixer.insertTextAfter(node, `\nimport { ${specifierText} } from '${relativePath}'`)
]
}
}
})
}
}
}
}
}

16
_scripts/getInstances.js Normal file
View file

@ -0,0 +1,16 @@
const fs = require('fs/promises')
const invidiousApiUrl = 'https://api.invidious.io/instances.json'
fetch(invidiousApiUrl).then(e => e.json()).then(res => {
const data = res.filter((instance) => {
return !(instance[0].includes('.onion') ||
instance[0].includes('.i2p') ||
!instance[1].api)
}).map((instance) => {
return {
url: instance[1].uri.replace(/\/$/, ''),
cors: instance[1].cors
}
})
fs.writeFile('././static/invidious-instances.json', JSON.stringify(data, null, 2))
})

162
_scripts/getRegions.mjs Normal file
View file

@ -0,0 +1,162 @@
/**
* This script updates the files in static/geolocations with the available locations on YouTube.
*
* It tries to map every active FreeTube language (static/locales/activelocales.json)
* to it's equivalent on YouTube.
*
* It then uses those language mappings,
* to scrape the location selection menu on the YouTube website, in every mapped language.
*
* All languages it couldn't find on YouTube, that don't have manually added mapping,
* get logged to the console, as well as all unmapped YouTube languages.
*/
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'
import { Innertube, Misc } from 'youtubei.js'
const STATIC_DIRECTORY = `${import.meta.dirname}/../static`
const activeLanguagesPath = `${STATIC_DIRECTORY}/locales/activeLocales.json`
/** @type {string[]} */
const activeLanguages = JSON.parse(readFileSync(activeLanguagesPath, { encoding: 'utf8' }))
// en-US is en on YouTube
const initialResponse = await scrapeLanguage('en')
// Scrape language menu in en-US
/** @type {string[]} */
const youTubeLanguages = initialResponse.data.actions[0].openPopupAction.popup.multiPageMenuRenderer.sections[0].multiPageMenuSectionRenderer.items[2].compactLinkRenderer.serviceEndpoint.signalServiceEndpoint.actions[0].getMultiPageMenuAction.menu.multiPageMenuRenderer.sections[0].multiPageMenuSectionRenderer.items
.map(({ compactLinkRenderer }) => {
return compactLinkRenderer.serviceEndpoint.signalServiceEndpoint.actions[0].selectLanguageCommand.hl
})
// map FreeTube languages to their YouTube equivalents
const foundLanguageNames = ['en-US']
const unusedYouTubeLanguageNames = []
const languagesToScrape = []
for (const language of youTubeLanguages) {
if (activeLanguages.includes(language)) {
foundLanguageNames.push(language)
languagesToScrape.push({
youTube: language,
freeTube: language
})
// eslint-disable-next-line @stylistic/brace-style
}
// special cases
else if (language === 'de') {
foundLanguageNames.push('de-DE')
languagesToScrape.push({
youTube: 'de',
freeTube: 'de-DE'
})
} else if (language === 'fr') {
foundLanguageNames.push('fr-FR')
languagesToScrape.push({
youTube: 'fr',
freeTube: 'fr-FR'
})
} else if (language === 'no') {
// according to https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes
// "no" is the macro language for "nb" and "nn"
foundLanguageNames.push('nb-NO', 'nn')
languagesToScrape.push({
youTube: 'no',
freeTube: 'nb-NO'
})
languagesToScrape.push({
youTube: 'no',
freeTube: 'nn'
})
} else if (language === 'iw') {
// according to https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes
// "iw" is the old/original code for Hebrew, these days it's "he"
foundLanguageNames.push('he')
languagesToScrape.push({
youTube: 'iw',
freeTube: 'he'
})
} else if (language === 'es-419') {
foundLanguageNames.push('es-AR', 'es-MX')
languagesToScrape.push({
youTube: 'es-419',
freeTube: 'es-AR'
})
languagesToScrape.push({
youTube: 'es-419',
freeTube: 'es-MX'
})
} else if (language !== 'en') {
unusedYouTubeLanguageNames.push(language)
}
}
foundLanguageNames.push('pt-BR')
languagesToScrape.push({
youTube: 'pt',
freeTube: 'pt-BR'
})
console.log("Active FreeTube languages that aren't available on YouTube:")
console.log(activeLanguages.filter(lang => !foundLanguageNames.includes(lang)).sort())
console.log("YouTube languages that don't have an equivalent active FreeTube language:")
console.log(unusedYouTubeLanguageNames.sort())
// Scrape the location menu in various languages and write files to the file system
rmSync(`${STATIC_DIRECTORY}/geolocations`, { recursive: true })
mkdirSync(`${STATIC_DIRECTORY}/geolocations`)
processGeolocations('en-US', 'en', initialResponse)
for (const { youTube, freeTube } of languagesToScrape) {
const response = await scrapeLanguage(youTube)
processGeolocations(freeTube, youTube, response)
}
async function scrapeLanguage(youTubeLanguageCode) {
const session = await Innertube.create({
retrieve_player: false,
generate_session_locally: true,
lang: youTubeLanguageCode
})
return await session.actions.execute('/account/account_menu')
}
function processGeolocations(freeTubeLanguage, youTubeLanguage, response) {
const geolocations = response.data.actions[0].openPopupAction.popup.multiPageMenuRenderer.sections[0].multiPageMenuSectionRenderer.items[4].compactLinkRenderer.serviceEndpoint.signalServiceEndpoint.actions[0].getMultiPageMenuAction.menu.multiPageMenuRenderer.sections[0].multiPageMenuSectionRenderer.items
.map(({ compactLinkRenderer }) => {
return {
name: new Misc.Text(compactLinkRenderer.title).toString().trim(),
code: compactLinkRenderer.serviceEndpoint.signalServiceEndpoint.actions[0].selectCountryCommand.gl
}
})
const normalisedFreeTubeLanguage = freeTubeLanguage.replace('_', '-')
// give Intl.Collator 4 locales, in the hopes that it supports one of them
// deduplicate the list so it doesn't have to do duplicate work
const localeSet = new Set()
localeSet.add(normalisedFreeTubeLanguage)
localeSet.add(youTubeLanguage)
localeSet.add(normalisedFreeTubeLanguage.split('-')[0])
localeSet.add(youTubeLanguage.split('-')[0])
const locales = Array.from(localeSet)
// only sort if node supports sorting the language, otherwise hope that YouTube's sorting was correct
// node 20.3.1 doesn't support sorting `eu`
if (Intl.Collator.supportedLocalesOf(locales).length > 0) {
const collator = new Intl.Collator(locales)
geolocations.sort((a, b) => collator.compare(a.name, b.name))
}
writeFileSync(`${STATIC_DIRECTORY}/geolocations/${freeTubeLanguage}.json`, JSON.stringify(geolocations))
}

109
_scripts/getShakaLocales.js Normal file
View file

@ -0,0 +1,109 @@
const { readFileSync, readdirSync } = require('fs')
function getPreloadedLocales() {
const localesFile = readFileSync(`${__dirname}/../node_modules/shaka-player/dist/locales.js`, 'utf-8')
const localesLine = localesFile.match(/^\/\/ LOCALES: ([\w ,-]+)$/m)
if (!localesLine) {
throw new Error("Failed to parse shaka-player's preloaded locales")
}
return localesLine[1].split(',').map(locale => locale.trim())
}
function getAllLocales() {
const filenames = readdirSync(`${__dirname}/../node_modules/shaka-player/ui/locales`)
return new Set(filenames
.filter(filename => filename !== 'source.json' && filename.endsWith('.json'))
.map(filename => filename.replace('.json', '')))
}
/**
* Maps the shaka locales to FreeTube's active ones
* This allows us to know which locale files are actually needed
* and which shaka locale needs to be activated for a given FreeTube one.
* @param {Set<string>} shakaLocales
* @param {string[]} freeTubeLocales
*/
function getMappings(shakaLocales, freeTubeLocales) {
/**
* @type {[string, string][]}
* Using this structure as it gets passed to `new Map()` in the player component
* The first element is the FreeTube locale, the second one is the shaka-player one
*/
const mappings = []
for (const locale of freeTubeLocales) {
if (shakaLocales.has(locale)) {
mappings.push([
locale,
locale
])
} else if (shakaLocales.has(locale.split('-')[0])) {
mappings.push([
locale,
locale.split('-')[0]
])
}
}
// special cases
mappings.push(
// according to https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes
// "no" is the macro language for "nb" and "nn"
[
'nb-NO',
'no'
],
[
'nn',
'no'
],
// according to https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes
// "iw" is the old/original code for Hebrew, these days it's "he"
[
'he',
'iw'
],
// not sure why we have pt, pt-PT and pt-BR in the FreeTube locales
// as pt and pt-PT are the same thing, but we should handle it here anyway
[
'pt',
'pt-PT'
]
)
return mappings
}
function getShakaLocales() {
const shakaLocales = getAllLocales()
/** @type {string[]} */
const freeTubeLocales = JSON.parse(readFileSync(`${__dirname}/../static/locales/activeLocales.json`, 'utf-8'))
const mappings = getMappings(shakaLocales, freeTubeLocales)
const preloaded = getPreloadedLocales()
const shakaMappings = mappings.map(mapping => mapping[1])
// use a set to deduplicate the list
// we don't need to bundle any locale files that are already embedded in shaka-player/preloaded
/** @type {string[]} */
const toBeBundled = [...new Set(shakaMappings.filter(locale => !preloaded.includes(locale)))]
return {
SHAKA_LOCALE_MAPPINGS: mappings,
SHAKA_LOCALES_PREBUNDLED: preloaded,
SHAKA_LOCALES_TO_BE_BUNDLED: toBeBundled
}
}
module.exports = getShakaLocales()

32
_scripts/helpers.js Normal file
View file

@ -0,0 +1,32 @@
const exec = require('child_process').exec
/**
* Calls `child_process`.exec, but it outputs
* all of the stdout live and can be awaited
* @param {string} command The command to be executed
* @returns
*/
function execWithLiveOutput (command) {
return new Promise((resolve, reject) => {
const execCall = exec(command, (error, stdout, stderr) => {
if (error) {
reject(error)
}
resolve()
})
execCall.stdout.on('data', (data) => {
process.stdout.write(data)
})
execCall.stderr.on('data', (data) => {
console.error(data)
})
execCall.on('close', () => {
resolve()
})
})
}
module.exports = {
execWithLiveOutput
}

View file

@ -0,0 +1,48 @@
/**
* Injects the paths that the renderer process is allowed to read into the main.js file,
* by replacing __FREETUBE_ALLOWED_PATHS__ with an array of strings with the paths.
*
* This allows the main process to validate the paths which the renderer process accesses,
* to ensure that it cannot access other files on the disk, without the users permission (e.g. file picker).
*/
import { closeSync, ftruncateSync, openSync, readFileSync, readdirSync, writeSync } from 'fs'
import { join, relative, resolve } from 'path'
const distDirectory = resolve(import.meta.dirname, '..', 'dist')
const webDirectory = join(distDirectory, 'web')
const paths = readdirSync(distDirectory, {
recursive: true,
withFileTypes: true
})
.filter(dirent => {
// only include files not directories
return dirent.isFile() &&
// disallow the renderer process/browser windows to read the main.js file
dirent.name !== 'main.js' &&
dirent.name !== 'main.js.LICENSE.txt' &&
// disallow the renderer process/browser windows to read the botGuardScript.js file
dirent.name !== 'botGuardScript.js' &&
// filter out any web build files, in case the dist directory contains a web build
!dirent.parentPath.startsWith(webDirectory)
})
.map(dirent => {
const joined = join(dirent.parentPath, dirent.name)
return '/' + relative(distDirectory, joined).replaceAll('\\', '/')
})
let fileHandle
try {
fileHandle = openSync(join(distDirectory, 'main.js'), 'r+')
let contents = readFileSync(fileHandle, 'utf-8')
contents = contents.replace('__FREETUBE_ALLOWED_PATHS__', JSON.stringify(paths))
ftruncateSync(fileHandle)
writeSync(fileHandle, contents, 0, 'utf-8')
} finally {
if (typeof fileHandle !== 'undefined') {
closeSync(fileHandle)
}
}

View file

@ -0,0 +1,22 @@
/**
* electron-context-menu only needs mime-db for its save as feature.
* As we only activate save image and save as image features, we can remove all other mimetypes,
* as they will never get used.
* Which results in quite a significant reduction in file size.
* @param {string} source
*/
module.exports = function (source) {
const original = JSON.parse(source)
// Only the extensions field is needed, see: https://github.com/kevva/ext-list/blob/v2.2.2/index.js
return JSON.stringify({
'image/apng': { extensions: original['image/apng'].extensions },
'image/avif': { extensions: original['image/avif'].extensions },
'image/gif': { extensions: original['image/gif'].extensions },
'image/jpeg': { extensions: original['image/jpeg'].extensions },
'image/png': { extensions: original['image/png'].extensions },
'image/svg+xml': { extensions: original['image/svg+xml'].extensions },
'image/webp': { extensions: original['image/webp'].extensions }
})
}

134
_scripts/patchShaka.mjs Normal file
View file

@ -0,0 +1,134 @@
// This script fixes shaka not exporting its type definitions and referencing remote google fonts in its CSS
// by adding an export line to the type definitions and downloading the fonts and updating the CSS to point to the local files
// this script only makes changes if they are needed, so running it multiple times doesn't cause any problems
import { appendFileSync, closeSync, ftruncateSync, openSync, readFileSync, writeFileSync, writeSync } from 'fs'
import { resolve } from 'path'
const SHAKA_DIST_DIR = resolve(import.meta.dirname, '../node_modules/shaka-player/dist')
function fixTypes() {
let fixedTypes = false
let fileHandleNormal
try {
fileHandleNormal = openSync(`${SHAKA_DIST_DIR}/shaka-player.ui.d.ts`, 'a+')
const contents = readFileSync(fileHandleNormal, 'utf-8')
// This script is run after every `yarn install`, even if shaka-player wasn't updated
// So we want to check first, if we actually need to make any changes
// or if the ones from the previous run are still intact
if (!contents.includes('export default shaka')) {
appendFileSync(fileHandleNormal, 'export default shaka;\n')
fixedTypes = true
}
} finally {
if (typeof fileHandleNormal !== 'undefined') {
closeSync(fileHandleNormal)
}
}
let fileHandleDebug
try {
fileHandleDebug = openSync(`${SHAKA_DIST_DIR}/shaka-player.ui.debug.d.ts`, 'a+')
const contents = readFileSync(fileHandleDebug, 'utf-8')
// This script is run after every `yarn install`, even if shaka-player wasn't updated
// So we want to check first, if we actually need to make any changes
// or if the ones from the previous run are still intact
if (!contents.includes('export default shaka')) {
appendFileSync(fileHandleDebug, 'export default shaka;\n')
fixedTypes = true
}
} finally {
if (typeof fileHandleDebug !== 'undefined') {
closeSync(fileHandleDebug)
}
}
if (fixedTypes) {
console.log('Fixed shaka-player types')
}
}
async function removeRobotoFont() {
let cssFileHandle
try {
cssFileHandle = openSync(`${SHAKA_DIST_DIR}/controls.css`, 'r+')
let cssContents = readFileSync(cssFileHandle, 'utf-8')
const beforeReplacement = cssContents.length
cssContents = cssContents.replace(/@font-face{font-family:Roboto;[^}]+}/, '')
if (cssContents.length !== beforeReplacement) {
ftruncateSync(cssFileHandle)
writeSync(cssFileHandle, cssContents, 0, 'utf-8')
console.log('Removed shaka-player Roboto font, so it uses ours')
}
} finally {
if (typeof cssFileHandle !== 'undefined') {
closeSync(cssFileHandle)
}
}
}
async function replaceAndDownloadMaterialIconsFont() {
let cssFileHandle
try {
cssFileHandle = openSync(`${SHAKA_DIST_DIR}/controls.css`, 'r+')
let cssContents = readFileSync(cssFileHandle, 'utf-8')
const fontFaceRegex = /@font-face{font-family:'Material Icons Round'[^}]+format\('opentype'\)}/
if (fontFaceRegex.test(cssContents)) {
const cssResponse = await fetch('https://fonts.googleapis.com/icon?family=Material+Icons+Round', {
headers: {
// Without the user-agent it returns the otf file instead of the woff2 one
'user-agent': 'Firefox/125.0'
}
})
const text = await cssResponse.text()
let newFontCSS = text.match(/(@font-face\s*{[^}]+})/)[1].replaceAll('\n', '')
const urlMatch = newFontCSS.match(/https:\/\/fonts\.gstatic\.com\/s\/materialiconsround\/(?<version>[^/]+)\/[^.]+\.(?<extension>\w+)/)
const url = urlMatch[0]
const { version, extension } = urlMatch.groups
const fontResponse = await fetch(url)
const fontContent = new Uint8Array(await fontResponse.arrayBuffer())
const filename = `shaka-materialiconsround-${version}.${extension}`
writeFileSync(`${SHAKA_DIST_DIR}/${filename}`, fontContent)
newFontCSS = newFontCSS.replace(url, `./${filename}`)
cssContents = cssContents.replace(fontFaceRegex, newFontCSS)
ftruncateSync(cssFileHandle)
writeSync(cssFileHandle, cssContents, 0, 'utf-8')
console.log('Changed shaka-player Material Icons Rounded font to use the smaller woff2 format instead of otf')
console.log('Downloaded shaka-player Material Icons Rounded font')
}
} catch (e) {
console.error(e)
} finally {
if (typeof cssFileHandle !== 'undefined') {
closeSync(cssFileHandle)
}
}
}
fixTypes()
await removeRobotoFont()
await replaceAndDownloadMaterialIconsFont()

105
_scripts/releaseAndroid.js Normal file
View file

@ -0,0 +1,105 @@
// script to trigger release
// #region Imports
const { version } = require('../package.json')
const { promisify } = require('util')
const { fetch } = require('undici')
const { get } = require('prompt')
const { exec } = require('child_process')
const { randomUUID } = require('crypto')
const { writeFile, rm, readFile } = require('fs/promises')
// #endregion
;(async (get, exec) => {
const givenArguments = { dryRun: false }
for (let arg of process.argv) {
if (arg.startsWith('--version-number=')) {
givenArguments.versionNumber = arg.split('--version-number=')[1]
}
if (arg.startsWith('--number-of-commits=')) {
givenArguments.numberOfCommits = parseInt(arg.split('--number-of-commits=')[1])
}
if (arg === '--dry-run') {
givenArguments.dryRun = true
}
if (arg.startsWith('--f=')) {
givenArguments.message = arg.split('--f=')[1]
}
}
if (givenArguments.dryRun) {
console.log('starting dry run')
}
const releases = await fetch('https://api.github.com/repos/MarmadileManteater/FreeTubeCordova/releases')
/** @type {Array<{tag_name: string}>} */
const releasesJSON = await releases.json()
// #region Version Number
const latestTag = releasesJSON[0].tag_name
const [ _latestMajor, _latestMinor, _latestPatch, latestRun ] = latestTag.split('.')
const [ currentMajor, currentMinor, currentPatch ] = version.split('.')
const latestRunNumber = parseInt(latestRun)
const currentRunNumber = latestRunNumber + 1
const defaultVersionNumber = `${currentMajor}.${currentMinor}.${currentPatch}.${currentRunNumber}`
// get version number from either the props or a prompt
let { versionNumber } = ('versionNumber' in givenArguments) ? givenArguments : await get({
properties: {
versionNumber: {
pattern: /[0-9]*?.[0-9]*?.[0-9]*?.[0-9]*/,
message: 'Version number to release with',
default: defaultVersionNumber
}
}
})
const buildNumber = versionNumber.split('.').at(-1)
if (versionNumber === 'default') {
versionNumber = defaultVersionNumber
}
// #endregion
if (!('message' in givenArguments)) {
// #region Latest Git History
const gitDiff = (await exec('git log origin/development...origin/release')).stdout
const numberOfCommits = ('numberOfCommits' in givenArguments) ? givenArguments.numberOfCommits : 3
let accumulator = 0
let mostRecentCommits = ''
for (let line of gitDiff.split('\n')) {
if (line.trim() === '') {
continue
}
if (line.startsWith('commit ')) {
mostRecentCommits += `\n`
accumulator++
}
if (accumulator < numberOfCommits + 1) {
mostRecentCommits += `${line}\n`
}
}
mostRecentCommits = mostRecentCommits.trim()
// #endregion
givenArguments.message = mostRecentCommits
} else {
// message is in given arguments (a file was passed that needs to be read)
givenArguments.message = await readFile(givenArguments.message)
}
const commitMessage = `**Release ${versionNumber}**
${givenArguments.message}
...
**Full Changelog**: https://github.com/MarmadileManteater/FreeTubeCordova/compare/${latestTag}...${versionNumber}`
// #region Commit changes to release branch
if (!givenArguments.dryRun) {
const fileId = randomUUID()
await writeFile(`commit_message_${fileId}.txt`, commitMessage)
await exec('git pull')
await exec('git checkout release')
await exec('git merge origin/development --no-commit --no-ff')
await exec(`git commit -F commit_message_${fileId}.txt`)
await rm(`commit_message_${fileId}.txt`)
} else {
// is a dry run
console.log(commitMessage)
console.log('------')
console.log('nothing committed: dry run')
}
// #endregion
})(promisify(get), promisify(exec))

View file

@ -0,0 +1,13 @@
const { hash } = require('crypto')
const { join } = require('path')
const { readFileSync } = require('fs')
const path = join(__dirname, '../src/renderer/sigFrameScript.js')
const rawScript = readFileSync(path, 'utf8')
const script = rawScript.split(/\r?\n/).map(line => line.trim()).filter(line => !line.startsWith('//')).join('')
module.exports.sigFrameTemplateParameters = {
sigFrameSrc: `data:text/html,${encodeURIComponent(`<!doctype html><script>${script}</script>`)}`,
sigFrameCspHash: `sha512-${hash('sha512', script, 'base64')}`
}

15
_scripts/sigViewConfig.js Normal file
View file

@ -0,0 +1,15 @@
const { hash } = require('crypto')
const { join } = require('path')
const { readFileSync } = require('fs')
const path = join(__dirname, '../src/renderer/sigViewScript.js')
const rawScript = readFileSync(path, 'utf8')
const script = process.env.NODE_ENV === 'development'
? rawScript
: require('terser').minify_sync({ [path]: rawScript }).code
module.exports.sigViewTemplateParameters = {
sigViewRaw: `<!doctype html><script>${script}</script>`,
sigViewCspHash: `sha512-${hash('sha512', script, 'base64')}`
}

View file

@ -0,0 +1,236 @@
const path = require('path')
const fs = require('fs')
const webpack = require('webpack')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const VueLoaderPlugin = require('vue-loader/lib/plugin')
const CopyWebpackPlugin = require('copy-webpack-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const JsonMinimizerPlugin = require('json-minimizer-webpack-plugin')
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin')
const ProcessLocalesPlugin = require('./ProcessLocalesPlugin')
const {
SHAKA_LOCALE_MAPPINGS,
SHAKA_LOCALES_PREBUNDLED,
SHAKA_LOCALES_TO_BE_BUNDLED
} = require('./getShakaLocales')
const { sigViewTemplateParameters } = require('./sigViewConfig')
const isDevMode = process.env.NODE_ENV === 'development'
const { version: swiperVersion } = JSON.parse(fs.readFileSync(path.join(__dirname, '../node_modules/swiper/package.json')))
const config = {
name: 'web',
mode: process.env.NODE_ENV,
devtool: isDevMode ? 'eval-cheap-module-source-map' : false,
entry: {
web: path.join(__dirname, '../src/renderer/main.js'),
},
output: {
path: path.join(__dirname, '../android/app/src/main/assets'),
filename: '[name].js',
},
externals: {
android: 'Android'
},
module: {
rules: [
{
test: /\.js$/,
use: 'babel-loader',
exclude: /node_modules/,
},
{
test: /\.vue$/,
loader: 'vue-loader',
options: {
compilerOptions: {
whitespace: 'condense',
}
}
},
{
test: /\.scss$/,
use: [
{
loader: MiniCssExtractPlugin.loader,
},
{
loader: 'css-loader',
options: {
esModule: false
}
},
{
loader: 'sass-loader',
options: {
implementation: require('sass')
}
},
],
},
{
test: /\.css$/,
use: [
{
loader: MiniCssExtractPlugin.loader
},
{
loader: 'css-loader',
options: {
esModule: false
}
}
],
},
{
test: /\.html$/,
use: 'vue-html-loader',
},
{
test: /\.(png|jpe?g|gif|tif?f|bmp|webp|svg)(\?.*)?$/,
type: 'asset/resource',
generator: {
filename: 'imgs/[name][ext]'
}
},
{
test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
type: 'asset/resource',
generator: {
filename: 'fonts/[name][ext]'
}
},
],
},
// webpack defaults to only optimising the production builds, so having this here is fine
optimization: {
minimizer: [
'...', // extend webpack's list instead of overwriting it
new JsonMinimizerPlugin({
exclude: /\/locales\/.*\.json/
}),
new CssMinimizerPlugin()
]
},
node: {
__dirname: true,
__filename: isDevMode,
},
plugins: [
new webpack.DefinePlugin({
'process.env.IS_ELECTRON': false,
'process.env.IS_ELECTRON_MAIN': false,
'process.env.IS_ANDROID': true,
'process.env.IS_RELEASE': !isDevMode,
'process.env.SUPPORTS_LOCAL_API': true,
'process.env.SWIPER_VERSION': `'${swiperVersion}'`,
// video.js' vhs-utils supports both atob() in web browsers and Buffer in node
// As the FreeTube web build only runs in web browsers, we can override their check for atob() here: https://github.com/videojs/vhs-utils/blob/main/src/decode-b64-to-uint8-array.js#L3
// overriding that check means we don't need to include a Buffer polyfill
// https://caniuse.com/atob-btoa
// NOTE FOR THE FUTURE: this override won't work with vite as their define does a find and replace in the code for production builds,
// but uses globals in development builds to save build time, so this would replace the actual atob() function with true if used with vite
// this works in webpack as webpack does a find and replace in the source code for both development and production builds
// https://vitejs.dev/config/shared-options.html#define
// https://webpack.js.org/plugins/define-plugin/
'window.atob': true
}),
new webpack.ProvidePlugin({
process: 'process/browser.js'
}),
new HtmlWebpackPlugin({
excludeChunks: ['processTaskWorker'],
filename: 'index.html',
template: path.resolve(__dirname, '../src/index.ejs'),
nodeModules: false,
}),
new HtmlWebpackPlugin({
filename: "decipher.html",
inject: false,
templateContent: sigViewTemplateParameters.sigViewRaw,
nodeModules: false
}),
new VueLoaderPlugin(),
new MiniCssExtractPlugin({
filename: isDevMode ? '[name].css' : '[name].[contenthash].css',
chunkFilename: isDevMode ? '[id].css' : '[id].[contenthash].css',
}),
new CopyWebpackPlugin({
patterns: [
{
from: path.join(__dirname, '../node_modules/swiper/modules/{a11y,navigation,pagination}-element.css').replaceAll('\\', '/'),
to: `swiper-${swiperVersion}.css`,
context: path.join(__dirname, '../node_modules/swiper/modules'),
transformAll: (assets) => {
return Buffer.concat(assets.map(asset => asset.data))
}
}
]
})
],
resolve: {
alias: {
vue$: 'vue/dist/vue.runtime.esm.js',
'portal-vue$': 'portal-vue/dist/portal-vue.esm.js',
DB_HANDLERS_ELECTRON_RENDERER_OR_WEB$: path.resolve(__dirname, '../src/datastores/handlers/web.js'),
// change to "shaka-player.ui.debug.js" to get debug logs (update jsconfig to get updated types)
'shaka-player$': 'shaka-player/dist/shaka-player.ui.js',
'localforage': path.resolve(__dirname, '_localforage.js')
},
fallback: {
'fs/promises': path.resolve(__dirname, '_empty.js'),
path: require.resolve('path-browserify'),
},
extensions: ['.js', '.vue']
},
target: 'web',
}
const processLocalesPlugin = new ProcessLocalesPlugin({
compress: false,
inputDir: path.join(__dirname, '../static/locales'),
outputDir: 'static/locales',
})
const processAndroidLocales = new ProcessLocalesPlugin({
compress: false,
inputDir: path.join(__dirname, '../static/locales-android'),
outputDir: 'static/locales-android',
})
config.plugins.push(
processLocalesPlugin,
processAndroidLocales,
new webpack.DefinePlugin({
'process.env.LOCALE_NAMES': JSON.stringify(processLocalesPlugin.localeNames),
'process.env.GEOLOCATION_NAMES': JSON.stringify(fs.readdirSync(path.join(__dirname, '..', 'static', 'geolocations')).map(filename => filename.replace('.json', ''))),
'process.env.SHAKA_LOCALE_MAPPINGS': JSON.stringify(SHAKA_LOCALE_MAPPINGS),
'process.env.SHAKA_LOCALES_PREBUNDLED': JSON.stringify(SHAKA_LOCALES_PREBUNDLED)
}),
new CopyWebpackPlugin({
patterns: [
{
from: path.join(__dirname, '../static/pwabuilder-sw.js'),
to: path.join(__dirname, '../android/app/src/main/assets/pwabuilder-sw.js'),
},
{
from: path.join(__dirname, '../static'),
to: path.join(__dirname, '../android/app/src/main/assets/static'),
globOptions: {
dot: true,
ignore: ['**/.*', '**/locales/**', '**/locales-android/**', '**/pwabuilder-sw.js', '**/dashFiles/**', '**/storyboards/**'],
},
},
{
from: path.join(__dirname, '../node_modules/shaka-player/ui/locales', `{${SHAKA_LOCALES_TO_BE_BUNDLED.join(',')}}.json`).replaceAll('\\', '/'),
to: path.join(__dirname, '../android/app/src/main/assets/static/shaka-player-locales'),
context: path.join(__dirname, '../node_modules/shaka-player/ui/locales')
}
]
})
)
module.exports = config

View file

@ -0,0 +1,6 @@
const config = require('./webpack.botGuardScript.config.js')
const { join } = require('path')
config.output.path = join(__dirname, '../android/app/src/main/assets/')
module.exports = config

View file

@ -0,0 +1,23 @@
const path = require('path')
/** @type {import('webpack').Configuration} */
module.exports = {
name: 'botGuardScript',
// Always use production mode, as we use the output as a function body and the debug output doesn't work for that
mode: 'production',
devtool: false,
target: 'web',
entry: {
botGuardScript: path.join(__dirname, '../src/botGuardScript.js'),
},
output: {
filename: '[name].js',
path: path.join(__dirname, '../dist'),
library: {
type: 'modern-module'
}
},
experiments: {
outputModule: true
}
}

View file

@ -0,0 +1,72 @@
const path = require('path')
const webpack = require('webpack')
const CopyWebpackPlugin = require('copy-webpack-plugin')
const JsonMinimizerPlugin = require('json-minimizer-webpack-plugin')
const isDevMode = process.env.NODE_ENV === 'development'
const config = {
name: 'main',
mode: process.env.NODE_ENV,
devtool: isDevMode ? 'eval-cheap-module-source-map' : false,
entry: {
main: path.join(__dirname, '../src/main/index.js'),
},
module: {
rules: [
{
test: /\.js$/,
use: 'babel-loader',
exclude: /node_modules/,
},
{
resource: path.resolve(__dirname, '../node_modules/mime-db/db.json'),
use: path.join(__dirname, 'mime-db-shrinking-loader.js')
}
],
},
// webpack defaults to only optimising the production builds, so having this here is fine
optimization: {
minimizer: [
'...', // extend webpack's list instead of overwriting it
new JsonMinimizerPlugin({
exclude: /\/locales\/.*\.json/
})
]
},
node: {
__dirname: isDevMode,
__filename: isDevMode
},
plugins: [
new webpack.DefinePlugin({
'process.env.IS_ELECTRON_MAIN': true
})
],
output: {
filename: '[name].js',
libraryTarget: 'commonjs2',
path: path.join(__dirname, '../dist'),
publicPath: ''
},
target: 'electron-main',
}
if (!isDevMode) {
config.plugins.push(
new CopyWebpackPlugin({
patterns: [
{
from: path.join(__dirname, '../static'),
to: path.join(__dirname, '../dist/static'),
globOptions: {
dot: true,
ignore: ['**/.*', '**/locales/**', '**/pwabuilder-sw.js', '**/manifest.json', '**/dashFiles/**', '**/storyboards/**'],
},
},
]
})
)
}
module.exports = config

View file

@ -0,0 +1,197 @@
const path = require('path')
const { readFileSync, readdirSync } = require('fs')
const webpack = require('webpack')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const VueLoaderPlugin = require('vue-loader/lib/plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin')
const ProcessLocalesPlugin = require('./ProcessLocalesPlugin')
const CopyWebpackPlugin = require('copy-webpack-plugin')
const {
SHAKA_LOCALE_MAPPINGS,
SHAKA_LOCALES_PREBUNDLED,
SHAKA_LOCALES_TO_BE_BUNDLED
} = require('./getShakaLocales')
const { sigFrameTemplateParameters } = require('./sigFrameConfig')
const isDevMode = process.env.NODE_ENV === 'development'
const { version: swiperVersion } = JSON.parse(readFileSync(path.join(__dirname, '../node_modules/swiper/package.json')))
const processLocalesPlugin = new ProcessLocalesPlugin({
compress: !isDevMode,
hotReload: isDevMode,
inputDir: path.join(__dirname, '../static/locales'),
outputDir: 'static/locales',
})
const config = {
name: 'renderer',
mode: process.env.NODE_ENV,
devtool: isDevMode ? 'eval-cheap-module-source-map' : false,
entry: {
renderer: path.join(__dirname, '../src/renderer/main.js'),
},
infrastructureLogging: {
// Only warnings and errors
// level: 'none' disable logging
// Please read https://webpack.js.org/configuration/other-options/#infrastructurelogginglevel
level: isDevMode ? 'info' : 'none'
},
output: {
libraryTarget: 'commonjs2',
path: path.join(__dirname, '../dist'),
filename: '[name].js',
},
module: {
rules: [
{
test: /\.js$/,
use: 'babel-loader',
exclude: /node_modules/,
},
{
test: /\.vue$/,
loader: 'vue-loader',
options: {
compilerOptions: {
whitespace: 'condense',
}
}
},
{
test: /\.scss$/,
use: [
{
loader: MiniCssExtractPlugin.loader,
},
{
loader: 'css-loader',
options: {
esModule: false
}
},
{
loader: 'sass-loader',
options: {
implementation: require('sass')
}
},
],
},
{
test: /\.css$/,
use: [
{
loader: MiniCssExtractPlugin.loader
},
{
loader: 'css-loader',
options: {
esModule: false
}
}
],
},
{
test: /\.(png|jpe?g|gif|tif?f|bmp|webp|svg)(\?.*)?$/,
type: 'asset/resource',
generator: {
filename: 'imgs/[name][ext]'
}
},
{
test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
type: 'asset/resource',
generator: {
filename: 'fonts/[name][ext]'
}
},
],
},
// webpack defaults to only optimising the production builds, so having this here is fine
optimization: {
minimizer: [
'...', // extend webpack's list instead of overwriting it
new CssMinimizerPlugin()
]
},
node: {
__dirname: false,
__filename: false
},
plugins: [
processLocalesPlugin,
new webpack.DefinePlugin({
'process.env.IS_ELECTRON': true,
'process.env.IS_ELECTRON_MAIN': false,
'process.env.SUPPORTS_LOCAL_API': true,
'process.env.LOCALE_NAMES': JSON.stringify(processLocalesPlugin.localeNames),
'process.env.GEOLOCATION_NAMES': JSON.stringify(readdirSync(path.join(__dirname, '..', 'static', 'geolocations')).map(filename => filename.replace('.json', ''))),
'process.env.SWIPER_VERSION': `'${swiperVersion}'`,
'process.env.SHAKA_LOCALE_MAPPINGS': JSON.stringify(SHAKA_LOCALE_MAPPINGS),
'process.env.SHAKA_LOCALES_PREBUNDLED': JSON.stringify(SHAKA_LOCALES_PREBUNDLED)
}),
new HtmlWebpackPlugin({
filename: 'index.html',
template: path.resolve(__dirname, '../src/index.ejs'),
templateParameters: sigFrameTemplateParameters
}),
new VueLoaderPlugin(),
new MiniCssExtractPlugin({
filename: isDevMode ? '[name].css' : '[name].[contenthash].css',
chunkFilename: isDevMode ? '[id].css' : '[id].[contenthash].css',
}),
new CopyWebpackPlugin({
patterns: [
{
from: path.join(__dirname, '../node_modules/swiper/modules/{a11y,navigation,pagination}-element.css').replaceAll('\\', '/'),
to: `swiper-${swiperVersion}.css`,
context: path.join(__dirname, '../node_modules/swiper/modules'),
transformAll: (assets) => {
return Buffer.concat(assets.map(asset => asset.data))
}
},
// Don't need to copy them in dev mode,
// as we configure WebpackDevServer to serve them
...(isDevMode
? []
: [
{
from: path.join(__dirname, '../node_modules/shaka-player/ui/locales', `{${SHAKA_LOCALES_TO_BE_BUNDLED.join(',')}}.json`).replaceAll('\\', '/'),
to: path.join(__dirname, '../dist/static/shaka-player-locales'),
context: path.join(__dirname, '../node_modules/shaka-player/ui/locales'),
transform: {
transformer: (input) => {
return JSON.stringify(JSON.parse(input.toString('utf-8')))
}
}
}
])
]
})
],
resolve: {
alias: {
vue$: 'vue/dist/vue.runtime.esm.js',
'portal-vue$': 'portal-vue/dist/portal-vue.esm.js',
DB_HANDLERS_ELECTRON_RENDERER_OR_WEB$: path.resolve(__dirname, '../src/datastores/handlers/electron.js'),
'youtubei.js$': 'youtubei.js/web',
// change to "shaka-player.ui.debug.js" to get debug logs (update jsconfig to get updated types)
'shaka-player$': 'shaka-player/dist/shaka-player.ui.js',
},
extensions: ['.js', '.vue']
},
target: 'electron-renderer',
}
if (isDevMode) {
// hack to pass it through to the dev-runner.js script
// gets removed there before the config object is passed to webpack
config.SHAKA_LOCALES_TO_BE_BUNDLED = SHAKA_LOCALES_TO_BE_BUNDLED
}
module.exports = config

View file

@ -0,0 +1,220 @@
const path = require('path')
const fs = require('fs')
const webpack = require('webpack')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const VueLoaderPlugin = require('vue-loader/lib/plugin')
const CopyWebpackPlugin = require('copy-webpack-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const JsonMinimizerPlugin = require('json-minimizer-webpack-plugin')
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin')
const ProcessLocalesPlugin = require('./ProcessLocalesPlugin')
const {
SHAKA_LOCALE_MAPPINGS,
SHAKA_LOCALES_PREBUNDLED,
SHAKA_LOCALES_TO_BE_BUNDLED
} = require('./getShakaLocales')
const isDevMode = process.env.NODE_ENV === 'development'
const { version: swiperVersion } = JSON.parse(fs.readFileSync(path.join(__dirname, '../node_modules/swiper/package.json')))
const config = {
name: 'web',
mode: process.env.NODE_ENV,
devtool: isDevMode ? 'eval-cheap-module-source-map' : false,
entry: {
web: path.join(__dirname, '../src/renderer/main.js'),
},
output: {
path: path.join(__dirname, '../dist/web'),
filename: '[name].js',
},
externals: {
android: '{}',
'youtubei.js': '{}'
},
module: {
rules: [
{
test: /\.js$/,
use: 'babel-loader',
exclude: /node_modules/,
},
{
test: /\.vue$/,
loader: 'vue-loader',
options: {
compilerOptions: {
whitespace: 'condense',
}
}
},
{
test: /\.scss$/,
use: [
{
loader: MiniCssExtractPlugin.loader,
},
{
loader: 'css-loader',
options: {
esModule: false
}
},
{
loader: 'sass-loader',
options: {
implementation: require('sass')
}
},
],
},
{
test: /\.css$/,
use: [
{
loader: MiniCssExtractPlugin.loader
},
{
loader: 'css-loader',
options: {
esModule: false
}
}
],
},
{
test: /\.html$/,
use: 'vue-html-loader',
},
{
test: /\.(png|jpe?g|gif|tif?f|bmp|webp|svg)(\?.*)?$/,
type: 'asset/resource',
generator: {
filename: 'imgs/[name][ext]'
}
},
{
test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
type: 'asset/resource',
generator: {
filename: 'fonts/[name][ext]'
}
},
],
},
// webpack defaults to only optimising the production builds, so having this here is fine
optimization: {
minimizer: [
'...', // extend webpack's list instead of overwriting it
new JsonMinimizerPlugin({
exclude: /\/locales\/.*\.json/
}),
new CssMinimizerPlugin()
]
},
node: {
__dirname: false,
__filename: false
},
plugins: [
new webpack.DefinePlugin({
'process.env.IS_ELECTRON': false,
'process.env.IS_ELECTRON_MAIN': false,
'process.env.SUPPORTS_LOCAL_API': false,
'process.env.SWIPER_VERSION': `'${swiperVersion}'`,
'process.env.IS_ANDROID': false,
'process.env.IS_RELEASE': !isDevMode
}),
new webpack.ProvidePlugin({
process: 'process/browser'
}),
new HtmlWebpackPlugin({
excludeChunks: ['processTaskWorker'],
filename: 'index.html',
template: path.resolve(__dirname, '../src/index.ejs')
}),
new VueLoaderPlugin(),
new MiniCssExtractPlugin({
filename: isDevMode ? '[name].css' : '[name].[contenthash].css',
chunkFilename: isDevMode ? '[id].css' : '[id].[contenthash].css',
}),
new CopyWebpackPlugin({
patterns: [
{
from: path.join(__dirname, '../node_modules/swiper/modules/{a11y,navigation,pagination}-element.css').replaceAll('\\', '/'),
to: `swiper-${swiperVersion}.css`,
context: path.join(__dirname, '../node_modules/swiper/modules'),
transformAll: (assets) => {
return Buffer.concat(assets.map(asset => asset.data))
}
}
]
})
],
resolve: {
alias: {
vue$: 'vue/dist/vue.runtime.esm.js',
'portal-vue$': 'portal-vue/dist/portal-vue.esm.js',
DB_HANDLERS_ELECTRON_RENDERER_OR_WEB$: path.resolve(__dirname, '../src/datastores/handlers/web.js'),
// change to "shaka-player.ui.debug.js" to get debug logs (update jsconfig to get updated types)
'shaka-player$': 'shaka-player/dist/shaka-player.ui.js',
},
fallback: {
'fs/promises': path.resolve(__dirname, '_empty.js'),
path: require.resolve('path-browserify'),
},
extensions: ['.js', '.vue']
},
target: 'web',
}
const processLocalesPlugin = new ProcessLocalesPlugin({
compress: false,
hotReload: isDevMode,
inputDir: path.join(__dirname, '../static/locales'),
outputDir: 'static/locales',
})
config.plugins.push(
processLocalesPlugin,
new webpack.DefinePlugin({
'process.env.LOCALE_NAMES': JSON.stringify(processLocalesPlugin.localeNames),
'process.env.GEOLOCATION_NAMES': JSON.stringify(fs.readdirSync(path.join(__dirname, '..', 'static', 'geolocations')).map(filename => filename.replace('.json', ''))),
'process.env.SHAKA_LOCALE_MAPPINGS': JSON.stringify(SHAKA_LOCALE_MAPPINGS),
'process.env.SHAKA_LOCALES_PREBUNDLED': JSON.stringify(SHAKA_LOCALES_PREBUNDLED)
}),
new CopyWebpackPlugin({
patterns: [
{
from: path.join(__dirname, '../_icons/192x192.png'),
to: path.join(__dirname, '../dist/web/static/_icons/192x192.png'),
},
{
from: path.join(__dirname, '../_icons/512x512.png'),
to: path.join(__dirname, '../dist/web/static/_icons/512x512.png'),
},
{
from: path.join(__dirname, '../static/pwabuilder-sw.js'),
to: path.join(__dirname, '../dist/web/pwabuilder-sw.js'),
},
{
from: path.join(__dirname, '../static'),
to: path.join(__dirname, '../dist/web/static'),
globOptions: {
dot: true,
ignore: ['**/.*', '**/locales/**', '**/pwabuilder-sw.js', '**/dashFiles/**', '**/storyboards/**'],
},
},
{
from: path.join(__dirname, '../node_modules/shaka-player/ui/locales', `{${SHAKA_LOCALES_TO_BE_BUNDLED.join(',')}}.json`).replaceAll('\\', '/'),
to: path.join(__dirname, '../dist/web/static/shaka-player-locales'),
context: path.join(__dirname, '../node_modules/shaka-player/ui/locales')
}
]
})
)
module.exports = config