Skip to content

Commit

Permalink
Disable http disk cache and implempent in-memory image cache (#2498)
Browse files Browse the repository at this point in the history
* Disable http disk cache and implempent in-memory image cache

* Add comment about removing URL scheme prefix

Co-authored-by: PikachuEXE <[email protected]>

* Add early return to clean up the code

* Rewrite cache expiry logic with fallbacks

* Move this change behind a CLI argument --experiments-disable-disk-cache

* Replace CLI flag with a GUI setting

* Improve warning message styling

* ! Fix incompatibility with latest settings code

* Use CSS instead of sass for the experimental settings

* Return the error as JSON instead of throwing it

* Inline restart prompt label and option names and values

* Mention crash risk and recommend backups in the warning

Co-authored-by: PikachuEXE <[email protected]>
  • Loading branch information
absidue and PikachuEXE authored Oct 25, 2022
1 parent 9ed5127 commit 697bed2
Show file tree
Hide file tree
Showing 11 changed files with 324 additions and 6 deletions.
8 changes: 5 additions & 3 deletions _scripts/dev-runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
73 changes: 73 additions & 0 deletions src/main/ImageCache.js
Original file line number Diff line number Diff line change
@@ -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<string, string>} 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
}
}
122 changes: 121 additions & 1 deletion src/main/index.js
Original file line number Diff line number Diff line change
@@ -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()
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.experimental-warning {
text-align: center;
font-weight: bold;
padding-left: 4%;
padding-right: 4%
}
Original file line number Diff line number Diff line change
@@ -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'
])
}
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<template>
<ft-settings-section
:title="$t('Settings.Experimental Settings.Experimental Settings')"
>
<p class="experimental-warning">
{{ $t('Settings.Experimental Settings.Warning') }}
</p>
<ft-flex-box>
<ft-toggle-switch
tooltip-position="top"
:label="$t('Settings.Experimental Settings.Replace HTTP Cache')"
:compact="true"
:default-value="replaceHttpCache"
:disabled="replaceHttpCacheLoading"
:tooltip="$t('Tooltips.Experimental Settings.Replace HTTP Cache')"
@change="handleRestartPrompt"
/>
</ft-flex-box>
<ft-prompt
v-if="showRestartPrompt"
:label="$t('Settings[\'The app needs to restart for changes to take effect. Restart and apply change?\']')"
:option-names="[$t('Yes'), $t('No')]"
:option-values="['yes', 'no']"
@click="handleReplaceHttpCache"
/>
</ft-settings-section>
</template>

<script src="./experimental-settings.js" />
<style scoped src="./experimental-settings.css" />
4 changes: 4 additions & 0 deletions src/renderer/components/ft-toggle-switch/ft-toggle-switch.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ export default Vue.extend({
tooltip: {
type: String,
default: ''
},
tooltipPosition: {
type: String,
default: 'bottom-left'
}
},
data: function () {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
<ft-tooltip
v-if="tooltip !== ''"
class="selectTooltip"
position="bottom-left"
:position="tooltipPosition"
:tooltip="tooltip"
/>
</label>
Expand Down
4 changes: 3 additions & 1 deletion src/renderer/views/Settings/Settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import DistractionSettings from '../../components/distraction-settings/distracti
import ProxySettings from '../../components/proxy-settings/proxy-settings.vue'
import SponsorBlockSettings from '../../components/sponsor-block-settings/sponsor-block-settings.vue'
import ParentControlSettings from '../../components/parental-control-settings/parental-control-settings.vue'
import ExperimentalSettings from '../../components/experimental-settings/experimental-settings.vue'

export default Vue.extend({
name: 'Settings',
Expand All @@ -30,7 +31,8 @@ export default Vue.extend({
'proxy-settings': ProxySettings,
'sponsor-block-settings': SponsorBlockSettings,
'download-settings': DownloadSettings,
'parental-control-settings': ParentControlSettings
'parental-control-settings': ParentControlSettings,
'experimental-settings': ExperimentalSettings
},
computed: {
usingElectron: function () {
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/views/Settings/Settings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
<parental-control-settings />
<hr>
<sponsor-block-settings />
<hr v-if="usingElectron">
<experimental-settings v-if="usingElectron" />
</div>
</template>

Expand Down
Loading

0 comments on commit 697bed2

Please sign in to comment.