diff --git a/.github/workflows/flatpak.yml b/.github/workflows/flatpak.yml index 4a20d973c7236..c3c871bec75de 100644 --- a/.github/workflows/flatpak.yml +++ b/.github/workflows/flatpak.yml @@ -77,22 +77,22 @@ jobs: date +"%Y-%m-%d" >> $GITHUB_ENV echo 'EOF' >> $GITHUB_ENV - name: Update x64 File Location in yml File - uses: mikefarah/yq@v4.28.1 + uses: mikefarah/yq@v4.28.2 with: # The Command which should be run cmd: yq w -i io.freetubeapp.FreeTube.yml modules[0].sources[0].url 'https://github.com/FreeTubeApp/FreeTube/releases/download/v${{ steps.sub.outputs.result }}-beta/freetube-${{ steps.sub.outputs.result }}-linux-portable-x64.zip' - name: Update x64 Hash in yml File - uses: mikefarah/yq@v4.28.1 + uses: mikefarah/yq@v4.28.2 with: # The Command which should be run cmd: yq w -i io.freetubeapp.FreeTube.yml modules[0].sources[0].sha256 ${{ env.HASH_X64 }} - name: Update ARM File Location in yml File - uses: mikefarah/yq@v4.28.1 + uses: mikefarah/yq@v4.28.2 with: # The Command which should be run cmd: yq w -i io.freetubeapp.FreeTube.yml modules[0].sources[1].url 'https://github.com/FreeTubeApp/FreeTube/releases/download/v${{ steps.sub.outputs.result }}-beta/freetube-${{ steps.sub.outputs.result }}-linux-portable-arm64.zip' - name: Update ARM Hash in yml File - uses: mikefarah/yq@v4.28.1 + uses: mikefarah/yq@v4.28.2 with: # The Command which should be run cmd: yq w -i io.freetubeapp.FreeTube.yml modules[0].sources[1].sha256 ${{ env.HASH_ARM64 }} diff --git a/.github/workflows/report.yml b/.github/workflows/report.yml index 34cb3099dcb40..fddf6630d47fd 100644 --- a/.github/workflows/report.yml +++ b/.github/workflows/report.yml @@ -13,7 +13,7 @@ jobs: # For bug reports - name: New bug issue - uses: alex-page/github-project-automation-plus@v0.8.1 + uses: alex-page/github-project-automation-plus@v0.8.2 if: contains(github.event.issue.labels.*.name, 'bug') && github.event.action == 'opened' with: project: Bug Reports @@ -22,7 +22,7 @@ jobs: action: update - name: Bug issue closed - uses: alex-page/github-project-automation-plus@v0.8.1 + uses: alex-page/github-project-automation-plus@v0.8.2 if: github.event.action == 'closed' || github.event.action == 'deleted' with: action: delete @@ -31,7 +31,7 @@ jobs: repo-token: ${{ secrets.PUSH_TOKEN }} - name: Bug issue reopened - uses: alex-page/github-project-automation-plus@v0.8.1 + uses: alex-page/github-project-automation-plus@v0.8.2 if: contains(github.event.issue.labels.*.name, 'bug') && github.event.action == 'reopened' with: project: Bug Reports @@ -41,7 +41,7 @@ jobs: # For feature requests - name: New feature issue - uses: alex-page/github-project-automation-plus@v0.8.1 + uses: alex-page/github-project-automation-plus@v0.8.2 if: contains(github.event.issue.labels.*.name, 'enhancement') && github.event.action == 'opened' with: project: Feature Requests @@ -50,7 +50,7 @@ jobs: action: update - name: Feature request issue closed - uses: alex-page/github-project-automation-plus@v0.8.1 + uses: alex-page/github-project-automation-plus@v0.8.2 if: github.event.action == 'closed' || github.event.action == 'deleted' with: action: delete @@ -59,7 +59,7 @@ jobs: repo-token: ${{ secrets.PUSH_TOKEN }} - name: Feature request issue reopened - uses: alex-page/github-project-automation-plus@v0.8.1 + uses: alex-page/github-project-automation-plus@v0.8.2 if: contains(github.event.issue.labels.*.name, 'enhancement') && github.event.action == 'reopened' with: project: Feature Requests diff --git a/_scripts/dev-runner.js b/_scripts/dev-runner.js index bedcb7f2355f0..24857459b3019 100644 --- a/_scripts/dev-runner.js +++ b/_scripts/dev-runner.js @@ -53,10 +53,12 @@ async function restartElectron() { electronProcess = spawn(electron, [ path.join(__dirname, '../dist/main.js'), - // '--enable-logging', Enable to show logs from all electron processes + // '--enable-logging', // Enable to show logs from all electron processes remoteDebugging ? '--inspect=9222' : '', - remoteDebugging ? '--remote-debugging-port=9223' : '', - ]) + remoteDebugging ? '--remote-debugging-port=9223' : '' + ], + // { stdio: 'inherit' } // required for logs to actually appear in the stdout + ) electronProcess.on('exit', (code, _) => { if (code === relaunchExitCode) { diff --git a/package.json b/package.json index 124b9f6b31675..7250e7f8b3f45 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "https-proxy-agent": "^5.0.0", "lodash.debounce": "^4.0.8", "lodash.isequal": "^4.5.0", - "marked": "^4.0.17", + "marked": "^4.1.1", "nedb-promises": "^6.2.1", "opml-to-json": "^1.0.1", "process": "^0.11.10", @@ -68,9 +68,10 @@ "video.js": "7.18.1", "videojs-contrib-quality-levels": "^2.1.0", "videojs-http-source-selector": "^1.1.6", + "videojs-mobile-ui": "^0.8.0", "videojs-overlay": "^2.1.4", "videojs-vtt-thumbnails-freetube": "0.0.15", - "vue": "^2.7.10", + "vue": "^2.7.13", "vue-i18n": "^8.27.2", "vue-observe-visibility": "^1.0.0", "vue-router": "^3.6.5", @@ -90,7 +91,7 @@ "babel-loader": "^8.2.5", "copy-webpack-plugin": "^11.0.0", "css-loader": "^6.7.1", - "css-minimizer-webpack-plugin": "^4.1.0", + "css-minimizer-webpack-plugin": "^4.2.2", "electron": "^21.1.1", "electron-builder": "^23.6.0", "eslint": "^7.32.0", @@ -108,7 +109,7 @@ "lefthook": "^1.1.2", "mini-css-extract-plugin": "^2.6.1", "npm-run-all": "^4.1.5", - "prettier": "^2.3.2", + "prettier": "^2.7.1", "rimraf": "^3.0.2", "sass": "^1.54.9", "sass-loader": "^13.0.2", diff --git a/src/main/ImageCache.js b/src/main/ImageCache.js new file mode 100644 index 0000000000000..4a635b7d92e9f --- /dev/null +++ b/src/main/ImageCache.js @@ -0,0 +1,73 @@ +// cleanup expired images once every 5 mins +const CLEANUP_INTERVAL = 300_000 + +// images expire after 2 hours if no expiry information is found in the http headers +const FALLBACK_MAX_AGE = 7200 + +export class ImageCache { + constructor() { + this._cache = new Map() + + setInterval(this._cleanup.bind(this), CLEANUP_INTERVAL) + } + + add(url, mimeType, data, expiry) { + this._cache.set(url, { mimeType, data, expiry }) + } + + has(url) { + return this._cache.has(url) + } + + get(url) { + const entry = this._cache.get(url) + + if (!entry) { + // this should never happen as the `has` method should be used to check for the existence first + throw new Error(`No image cache entry for ${url}`) + } + + return { + data: entry.data, + mimeType: entry.mimeType + } + } + + _cleanup() { + // seconds since 1970-01-01 00:00:00 + const now = Math.trunc(Date.now() / 1000) + + for (const [key, entry] of this._cache.entries()) { + if (entry.expiry <= now) { + this._cache.delete(key) + } + } + } +} + +/** + * Extracts the cache expiry timestamp of image from HTTP headers + * @param {Record} headers + * @returns a timestamp in seconds + */ +export function extractExpiryTimestamp(headers) { + const maxAgeRegex = /max-age=([0-9]+)/ + + const cacheControl = headers['cache-control'] + if (cacheControl && maxAgeRegex.test(cacheControl)) { + let maxAge = parseInt(cacheControl.match(maxAgeRegex)[1]) + + if (headers.age) { + maxAge -= parseInt(headers.age) + } + + // we don't need millisecond precision, so we can store it as seconds to use less memory + return Math.trunc(Date.now() / 1000) + maxAge + } else if (headers.expires) { + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Expires + + return Math.trunc(Date.parse(headers.expires) / 1000) + } else { + return Math.trunc(Date.now() / 1000) + FALLBACK_MAX_AGE + } +} diff --git a/src/main/index.js b/src/main/index.js index ef8ab6c0d4517..d1bc8f0428195 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -1,12 +1,14 @@ import { app, BrowserWindow, dialog, Menu, ipcMain, - powerSaveBlocker, screen, session, shell, nativeTheme + powerSaveBlocker, screen, session, shell, nativeTheme, net, protocol } from 'electron' import path from 'path' import cp from 'child_process' import { IpcChannels, DBActions, SyncEvents } from '../constants' import baseHandlers from '../datastores/handlers/base' +import { extractExpiryTimestamp, ImageCache } from './ImageCache' +import { existsSync } from 'fs' if (process.argv.includes('--version')) { app.exit() @@ -49,6 +51,17 @@ function runApp() { app.commandLine.appendSwitch('enable-file-cookies') app.commandLine.appendSwitch('ignore-gpu-blacklist') + // command line switches need to be added before the app ready event first + // that means we can't use the normal settings system as that is asynchonous, + // doing it synchronously ensures that we add it before the event fires + const replaceHttpCache = existsSync(`${app.getPath('userData')}/experiment-replace-http-cache`) + if (replaceHttpCache) { + // the http cache causes excessive disk usage during video playback + // we've got a custom image cache to make up for disabling the http cache + // experimental as it increases RAM use in favour of reduced disk use + app.commandLine.appendSwitch('disable-http-cache') + } + // See: https://stackoverflow.com/questions/45570589/electron-protocol-handler-not-working-on-windows // remove so we can register each time as we run the app. app.removeAsDefaultProtocolClient('freetube') @@ -149,6 +162,113 @@ function runApp() { }) }) + if (replaceHttpCache) { + // in-memory image cache + + const imageCache = new ImageCache() + + protocol.registerBufferProtocol('imagecache', (request, callback) => { + // Remove `imagecache://` prefix + const url = decodeURIComponent(request.url.substring(13)) + if (imageCache.has(url)) { + const cached = imageCache.get(url) + + // eslint-disable-next-line node/no-callback-literal + callback({ + mimeType: cached.mimeType, + data: cached.data + }) + return + } + + const newRequest = net.request({ + method: request.method, + url + }) + + // Electron doesn't allow certain headers to be set: + // https://www.electronjs.org/docs/latest/api/client-request#requestsetheadername-value + // also blacklist Origin and Referrer as we don't want to let YouTube know about them + const blacklistedHeaders = ['content-length', 'host', 'trailer', 'te', 'upgrade', 'cookie2', 'keep-alive', 'transfer-encoding', 'origin', 'referrer'] + + for (const header of Object.keys(request.headers)) { + if (!blacklistedHeaders.includes(header.toLowerCase())) { + newRequest.setHeader(header, request.headers[header]) + } + } + + newRequest.on('response', (response) => { + const chunks = [] + response.on('data', (chunk) => { + chunks.push(chunk) + }) + + response.on('end', () => { + const data = Buffer.concat(chunks) + + const expiryTimestamp = extractExpiryTimestamp(response.headers) + const mimeType = response.headers['content-type'] + + imageCache.add(url, mimeType, data, expiryTimestamp) + + // eslint-disable-next-line node/no-callback-literal + callback({ + mimeType, + data: data + }) + }) + + response.on('error', (error) => { + console.error('image cache error', error) + + // error objects don't get serialised properly + // https://stackoverflow.com/a/53624454 + + const errorJson = JSON.stringify(error, (key, value) => { + if (value instanceof Error) { + return { + // Pull all enumerable properties, supporting properties on custom Errors + ...value, + // Explicitly pull Error's non-enumerable properties + name: value.name, + message: value.message, + stack: value.stack + } + } + + return value + }) + + // eslint-disable-next-line node/no-callback-literal + callback({ + statusCode: response.statusCode ?? 400, + mimeType: 'application/json', + data: Buffer.from(errorJson) + }) + }) + }) + + newRequest.end() + }) + + const imageRequestFilter = { urls: ['https://*/*', 'http://*/*'] } + session.defaultSession.webRequest.onBeforeRequest(imageRequestFilter, (details, callback) => { + // the requests made by the imagecache:// handler to fetch the image, + // are allowed through, as their resourceType is 'other' + if (details.resourceType === 'image') { + // eslint-disable-next-line node/no-callback-literal + callback({ + redirectURL: `imagecache://${encodeURIComponent(details.url)}` + }) + } else { + // eslint-disable-next-line node/no-callback-literal + callback({}) + } + }) + + // --- end of `if experimentsDisableDiskCache` --- + } + await createWindow() if (isDev) { diff --git a/src/renderer/components/data-settings/data-settings.js b/src/renderer/components/data-settings/data-settings.js index df5294cab077e..20d75ee6d16de 100644 --- a/src/renderer/components/data-settings/data-settings.js +++ b/src/renderer/components/data-settings/data-settings.js @@ -10,7 +10,16 @@ import { MAIN_PROFILE_ID } from '../../../constants' import { opmlToJSON } from 'opml-to-json' import ytch from 'yt-channel-info' -import { calculateColorLuminance, copyToClipboard, getRandomColor, showToast } from '../../helpers/utils' +import { + calculateColorLuminance, + copyToClipboard, + getRandomColor, + readFileFromDialog, + showOpenDialog, + showSaveDialog, + showToast, + writeFileFromDialog +} from '../../helpers/utils' export default Vue.extend({ name: 'DataSettings', @@ -97,13 +106,13 @@ export default Vue.extend({ ] } - const response = await this.showOpenDialog(options) + const response = await showOpenDialog(options) if (response.canceled || response.filePaths?.length === 0) { return } let textDecode try { - textDecode = await this.readFileFromDialog({ response }) + textDecode = await readFileFromDialog(response) } catch (err) { const message = this.$t('Settings.Data Settings.Unable to read file') showToast(`${message}: ${err}`) @@ -503,13 +512,13 @@ export default Vue.extend({ ] } - const response = await this.showSaveDialog(options) + const response = await showSaveDialog(options) if (response.canceled || response.filePath === '') { // User canceled the save dialog return } try { - await this.writeFileFromDialog({ response, content: subscriptionsDb }) + await writeFileFromDialog(response, subscriptionsDb) } catch (writeErr) { const message = this.$t('Settings.Data Settings.Unable to read file') showToast(`${message}: ${writeErr}`) @@ -568,14 +577,14 @@ export default Vue.extend({ return object }) - const response = await this.showSaveDialog(options) + const response = await showSaveDialog(options) if (response.canceled || response.filePath === '') { // User canceled the save dialog return } try { - await this.writeFileFromDialog({ response, content: JSON.stringify(subscriptionsObject) }) + await writeFileFromDialog(response, JSON.stringify(subscriptionsObject)) } catch (writeErr) { const message = this.$t('Settings.Data Settings.Unable to write file') showToast(`${message}: ${writeErr}`) @@ -613,14 +622,14 @@ export default Vue.extend({ } }) - const response = await this.showSaveDialog(options) + const response = await showSaveDialog(options) if (response.canceled || response.filePath === '') { // User canceled the save dialog return } try { - await this.writeFileFromDialog({ response, content: opmlData }) + await writeFileFromDialog(response, opmlData) } catch (writeErr) { const message = this.$t('Settings.Data Settings.Unable to write file') showToast(`${message}: ${writeErr}`) @@ -652,14 +661,14 @@ export default Vue.extend({ exportText += `${channel.id},${channelUrl},${channelName}\n` }) exportText += '\n' - const response = await this.showSaveDialog(options) + const response = await showSaveDialog(options) if (response.canceled || response.filePath === '') { // User canceled the save dialog return } try { - await this.writeFileFromDialog({ response, content: exportText }) + await writeFileFromDialog(response, exportText) } catch (writeErr) { const message = this.$t('Settings.Data Settings.Unable to write file') showToast(`${message}: ${writeErr}`) @@ -699,13 +708,13 @@ export default Vue.extend({ newPipeObject.subscriptions.push(subscription) }) - const response = await this.showSaveDialog(options) + const response = await showSaveDialog(options) if (response.canceled || response.filePath === '') { // User canceled the save dialog return } try { - await this.writeFileFromDialog({ response, content: JSON.stringify(newPipeObject) }) + await writeFileFromDialog(response, JSON.stringify(newPipeObject)) } catch (writeErr) { const message = this.$t('Settings.Data Settings.Unable to write file') showToast(`${message}: ${writeErr}`) @@ -725,13 +734,13 @@ export default Vue.extend({ ] } - const response = await this.showOpenDialog(options) + const response = await showOpenDialog(options) if (response.canceled || response.filePaths?.length === 0) { return } let textDecode try { - textDecode = await this.readFileFromDialog({ response }) + textDecode = await readFileFromDialog(response) } catch (err) { const message = this.$t('Settings.Data Settings.Unable to read file') showToast(`${message}: ${err}`) @@ -799,14 +808,14 @@ export default Vue.extend({ ] } - const response = await this.showSaveDialog(options) + const response = await showSaveDialog(options) if (response.canceled || response.filePath === '') { // User canceled the save dialog return } try { - await this.writeFileFromDialog({ response, content: historyDb }) + await writeFileFromDialog(response, historyDb) } catch (writeErr) { const message = this.$t('Settings.Data Settings.Unable to write file') showToast(`${message}: ${writeErr}`) @@ -825,13 +834,13 @@ export default Vue.extend({ ] } - const response = await this.showOpenDialog(options) + const response = await showOpenDialog(options) if (response.canceled || response.filePaths?.length === 0) { return } let data try { - data = await this.readFileFromDialog({ response }) + data = await readFileFromDialog(response) } catch (err) { const message = this.$t('Settings.Data Settings.Unable to read file') showToast(`${message}: ${err}`) @@ -942,13 +951,13 @@ export default Vue.extend({ ] } - const response = await this.showSaveDialog(options) + const response = await showSaveDialog(options) if (response.canceled || response.filePath === '') { // User canceled the save dialog return } try { - await this.writeFileFromDialog({ response, content: JSON.stringify(this.allPlaylists) }) + await writeFileFromDialog(response, JSON.stringify(this.allPlaylists)) } catch (writeErr) { const message = this.$t('Settings.Data Settings.Unable to write file') showToast(`${message}: ${writeErr}`) @@ -1100,10 +1109,6 @@ export default Vue.extend({ 'updateShowProgressBar', 'updateHistory', 'compactHistory', - 'showOpenDialog', - 'readFileFromDialog', - 'showSaveDialog', - 'writeFileFromDialog', 'getUserDataPath', 'addPlaylist', 'addVideo' diff --git a/src/renderer/components/experimental-settings/experimental-settings.css b/src/renderer/components/experimental-settings/experimental-settings.css new file mode 100644 index 0000000000000..58e50c3ab6762 --- /dev/null +++ b/src/renderer/components/experimental-settings/experimental-settings.css @@ -0,0 +1,6 @@ +.experimental-warning { + text-align: center; + font-weight: bold; + padding-left: 4%; + padding-right: 4% +} diff --git a/src/renderer/components/experimental-settings/experimental-settings.js b/src/renderer/components/experimental-settings/experimental-settings.js new file mode 100644 index 0000000000000..83cbf7080dc70 --- /dev/null +++ b/src/renderer/components/experimental-settings/experimental-settings.js @@ -0,0 +1,73 @@ +import { closeSync, existsSync, openSync, rmSync } from 'fs' +import Vue from 'vue' +import { mapActions } from 'vuex' +import FtSettingsSection from '../ft-settings-section/ft-settings-section.vue' +import FtFlexBox from '../ft-flex-box/ft-flex-box.vue' +import FtToggleSwitch from '../ft-toggle-switch/ft-toggle-switch.vue' +import FtPrompt from '../ft-prompt/ft-prompt.vue' + +export default Vue.extend({ + name: 'ExperimentalSettings', + components: { + 'ft-settings-section': FtSettingsSection, + 'ft-flex-box': FtFlexBox, + 'ft-toggle-switch': FtToggleSwitch, + 'ft-prompt': FtPrompt + }, + data: function () { + return { + replaceHttpCacheLoading: true, + replaceHttpCache: false, + replaceHttpCachePath: '', + showRestartPrompt: false + } + }, + mounted: function () { + this.getUserDataPath().then((userData) => { + this.replaceHttpCachePath = `${userData}/experiment-replace-http-cache` + + this.replaceHttpCache = existsSync(this.replaceHttpCachePath) + this.replaceHttpCacheLoading = false + }) + }, + methods: { + updateReplaceHttpCache: function () { + this.replaceHttpCache = !this.replaceHttpCache + + if (this.replaceHttpCache) { + // create an empty file + closeSync(openSync(this.replaceHttpCachePath, 'w')) + } else { + rmSync(this.replaceHttpCachePath) + } + }, + + handleRestartPrompt: function (value) { + this.replaceHttpCache = value + this.showRestartPrompt = true + }, + + handleReplaceHttpCache: function (value) { + this.showRestartPrompt = false + + if (value === null || value === 'no') { + this.replaceHttpCache = !this.replaceHttpCache + return + } + + if (this.replaceHttpCache) { + // create an empty file + closeSync(openSync(this.replaceHttpCachePath, 'w')) + } else { + rmSync(this.replaceHttpCachePath) + } + + const { ipcRenderer } = require('electron') + ipcRenderer.send('relaunchRequest') + }, + + ...mapActions([ + 'getUserDataPath' + ]) + } +}) diff --git a/src/renderer/components/experimental-settings/experimental-settings.vue b/src/renderer/components/experimental-settings/experimental-settings.vue new file mode 100644 index 0000000000000..0e70671b8d4c3 --- /dev/null +++ b/src/renderer/components/experimental-settings/experimental-settings.vue @@ -0,0 +1,30 @@ + + +