diff --git a/bun.lock b/bun.lock index 1776a13..8a8d9e8 100644 --- a/bun.lock +++ b/bun.lock @@ -11,49 +11,49 @@ "@fontsource/lexend": "^5.2.11", "@fontsource/radio-canada-big": "^5.2.7", "@imagemagick/magick-wasm": "^0.0.37", - "@mediabunny/ac3": "^1.35.1", - "@mediabunny/flac-encoder": "^1.37.0", - "@mediabunny/mp3-encoder": "^1.35.1", - "@stripe/stripe-js": "^8.7.0", + "@mediabunny/ac3": "^1.40.0", + "@mediabunny/flac-encoder": "^1.40.0", + "@mediabunny/mp3-encoder": "^1.40.0", + "@stripe/stripe-js": "^8.11.0", "byte-data": "^19.0.1", "client-zip": "^2.5.0", "clsx": "^2.1.1", "fflate": "^0.8.2", "lucide-svelte": "^0.554.0", - "mediabunny": "^1.37.0", - "music-metadata": "^11.12.0", + "mediabunny": "^1.40.0", + "music-metadata": "^11.12.3", "overlayscrollbars": "^2.14.0", "overlayscrollbars-svelte": "^0.5.5", "p-queue": "^9.1.0", "riff-file": "^1.0.3", - "sanitize-html": "^2.17.0", + "sanitize-html": "^2.17.2", "svelte-stripe": "^1.4.0", "vert-wasm": "^0.0.2", - "vite-plugin-wasm": "^3.5.0", + "vite-plugin-wasm": "^3.6.0", }, "devDependencies": { - "@inlang/paraglide-js": "^2.11.0", + "@inlang/paraglide-js": "^2.15.0", "@poppanator/sveltekit-svg": "^5.0.1", "@sveltejs/adapter-static": "^3.0.10", - "@sveltejs/kit": "^2.52.0", + "@sveltejs/kit": "^2.55.0", "@sveltejs/vite-plugin-svelte": "^4.0.4", "@types/eslint": "^9.6.1", - "@types/sanitize-html": "^2.16.0", - "autoprefixer": "^10.4.24", + "@types/sanitize-html": "^2.16.1", + "autoprefixer": "^10.4.27", "css-select": "5.1.0", - "eslint": "^9.39.2", + "eslint": "^9.39.4", "eslint-config-prettier": "^10.1.8", "eslint-plugin-svelte": "^2.46.1", "globals": "^15.15.0", "prettier": "^3.8.1", - "prettier-plugin-svelte": "^3.4.1", + "prettier-plugin-svelte": "^3.5.1", "prettier-plugin-tailwindcss": "^0.6.14", - "sass": "^1.97.3", - "svelte": "^5.51.2", - "svelte-check": "^4.4.0", + "sass": "^1.98.0", + "svelte": "^5.54.0", + "svelte-check": "^4.4.5", "tailwindcss": "^3.4.19", "typescript": "^5.9.3", - "typescript-eslint": "^8.55.0", + "typescript-eslint": "^8.57.1", "vite": "^5.4.21", "vite-plugin-top-level-await": "^1.6.0", }, diff --git a/package.json b/package.json index d8531f9..b5a32ed 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "format": "prettier --write .", - "lint": "prettier --check . && eslint ." + "lint": "prettier --check .; p=$?; eslint .; e=$?; [ $p -eq 0 ] && [ $e -eq 0 ]" }, "devDependencies": { "@inlang/paraglide-js": "^2.15.0", diff --git a/src/lib/components/functional/popups/VertdErrorDetails.svelte b/src/lib/components/functional/popups/VertdErrorDetails.svelte index 5251358..f5bce67 100644 --- a/src/lib/components/functional/popups/VertdErrorDetails.svelte +++ b/src/lib/components/functional/popups/VertdErrorDetails.svelte @@ -13,6 +13,25 @@ type Props = DialogProps; let { additional }: Props = $props(); + let errorBlobUrl = $state(""); + + $effect(() => { + if (!additional.errorMessage) { + errorBlobUrl = ""; + return; + } + + const nextUrl = URL.createObjectURL( + new Blob([additional.errorMessage], { + type: "text/plain", + }), + ); + errorBlobUrl = nextUrl; + + return () => { + URL.revokeObjectURL(nextUrl); + }; + });
@@ -41,13 +60,7 @@ {@html sanitize(link( ["view_link"], m["convert.errors.vertd_details_error_message"](), - [ - URL.createObjectURL( - new Blob([additional.errorMessage], { - type: "text/plain", - }), - ), - ], + [errorBlobUrl || "#"], [true], ["text-blue-500 font-normal"], ))} diff --git a/src/lib/converters/ffmpeg.svelte.ts b/src/lib/converters/ffmpeg.svelte.ts index e0a9829..5722ec7 100644 --- a/src/lib/converters/ffmpeg.svelte.ts +++ b/src/lib/converters/ffmpeg.svelte.ts @@ -90,10 +90,11 @@ export class FFmpegConverter extends Converter { this.error = (msg) => error(["converters", this.name], msg); this.log(`created converter`); if (!browser) return; - try { - // this is just to cache the wasm and js for when we actually use it. we're not using this ffmpeg instance - this.ffmpeg = new FFmpeg(); - (async () => { + + // this is just to cache the wasm and js for when we actually use it. we're not using this ffmpeg instance + this.ffmpeg = new FFmpeg(); + void (async () => { + try { const baseURL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/esm"; @@ -105,15 +106,15 @@ export class FFmpegConverter extends Converter { }); this.status = "ready"; - })(); - } catch (err) { - this.error(`Error loading ffmpeg: ${err}`); - this.status = "error"; - ToastManager.add({ - type: "error", - message: m["workers.errors.ffmpeg"](), - }); - } + } catch (err) { + this.error(`Error loading ffmpeg: ${err}`); + this.status = "error"; + ToastManager.add({ + type: "error", + message: m["workers.errors.ffmpeg"](), + }); + } + })(); } public async getAvailableSettings(): Promise { @@ -251,46 +252,42 @@ export class FFmpegConverter extends Converter { ffmpeg.on("log", errorListener); - const buf = new Uint8Array(await input.file.arrayBuffer()); - await ffmpeg.writeFile("input", buf); - this.log(`wrote ${input.name} to ffmpeg virtual fs`); + try { + const buf = new Uint8Array(await input.file.arrayBuffer()); + await ffmpeg.writeFile("input", buf); + this.log(`wrote ${input.name} to ffmpeg virtual fs`); - const command = await this.buildConversionCommand( - ffmpeg, - input, - to, - conversionSettings, - isAlac, - ); - this.log(`FFmpeg command: ${command.join(" ")}`); - await ffmpeg.exec(command); - this.log("executed ffmpeg command"); + const command = await this.buildConversionCommand( + ffmpeg, + input, + to, + conversionSettings, + isAlac, + ); + this.log(`FFmpeg command: ${command.join(" ")}`); + await ffmpeg.exec(command); + this.log("executed ffmpeg command"); - if (conversionError) { + if (conversionError) throw new Error(conversionError); + + const output = (await ffmpeg.readFile( + "output" + to, + )) as unknown as Uint8Array; + + if (!output || output.length === 0) + throw new Error("empty file returned"); + + const outputFileName = + input.name.split(".").slice(0, -1).join(".") + to; + this.log(`read ${outputFileName} from ffmpeg virtual fs`); + + const outBuf = new Uint8Array(output).buffer.slice(0); + return new VertFile(new File([outBuf], outputFileName), to); + } finally { ffmpeg.off("log", errorListener); + this.activeConversions.delete(input.id); ffmpeg.terminate(); - throw new Error(conversionError); } - - const output = (await ffmpeg.readFile( - "output" + to, - )) as unknown as Uint8Array; - - if (!output || output.length === 0) { - ffmpeg.off("log", errorListener); - ffmpeg.terminate(); - throw new Error("empty file returned"); - } - - const outputFileName = - input.name.split(".").slice(0, -1).join(".") + to; - this.log(`read ${outputFileName} from ffmpeg virtual fs`); - - ffmpeg.off("log", errorListener); - ffmpeg.terminate(); - - const outBuf = new Uint8Array(output).buffer.slice(0); - return new VertFile(new File([outBuf], outputFileName), to); } public async cancel(input: VertFile): Promise { @@ -529,7 +526,7 @@ export class FFmpegConverter extends Converter { // -map for each audio track if (settings.tracks > 1) { for (let i = 0; i < settings.tracks; i++) { - tracksArgs.push("-map", `0:a:${i - 1}`); + tracksArgs.push("-map", `0:a:${i}`); } } else { tracksArgs = ["-map", "0:a:0"]; // default to first audio track if not specified diff --git a/src/lib/converters/pandoc.svelte.ts b/src/lib/converters/pandoc.svelte.ts index 8d1f869..cc99b21 100644 --- a/src/lib/converters/pandoc.svelte.ts +++ b/src/lib/converters/pandoc.svelte.ts @@ -49,69 +49,70 @@ export class PandocConverter extends Converter { this.activeConversions.set(file.id, worker); - const loadMsg: WorkerMessage = { - type: "load", - wasm: this.wasm, - id: file.id, - }; - worker.postMessage(loadMsg); - await waitForMessage(worker, "loaded"); - const convertMsg: WorkerMessage = { - type: "convert", - to, - input: { - file: file.file, - name: file.name, - from: file.from, + try { + const loadMsg: WorkerMessage = { + type: "load", + wasm: this.wasm, + id: file.id, + }; + worker.postMessage(loadMsg); + await waitForMessage(worker, "loaded"); + const convertMsg: WorkerMessage = { + type: "convert", to, - }, - id: file.id, - conversionSettings: "", // no settings for pandoc yet - }; - worker.postMessage(convertMsg); - const result = await waitForMessage(worker); - if (result.type === "error") { - worker.terminate(); - // throw new Error(result.error); - const error = result.error.toString(); - switch (result.errorKind) { - case "PandocUnknownReaderError": { - throw new Error( - `${file.from} is not a supported input format for documents.`, - ); - } - - case "PandocUnknownWriterError": { - throw new Error( - `${to} is not a supported output format for documents.`, - ); - } - - case "PandocParseError": { - if (error.includes("JSON missing pandoc-api-version")) { + input: { + file: file.file, + name: file.name, + from: file.from, + to, + }, + id: file.id, + conversionSettings: "", // no settings for pandoc yet + }; + worker.postMessage(convertMsg); + const result = await waitForMessage(worker); + if (result.type === "error") { + const error = result.error.toString(); + switch (result.errorKind) { + case "PandocUnknownReaderError": { throw new Error( - `This JSON file is not a pandoc-converted JSON file. It must be converted with pandoc / VERT to be converted again.`, + `${file.from} is not a supported input format for documents.`, ); } - } - // eslint-disable-next-line no-fallthrough - default: - if (result.errorKind) + case "PandocUnknownWriterError": { throw new Error( - `[${result.errorKind}] ${result.error}`, + `${to} is not a supported output format for documents.`, ); - else throw new Error(result.error); - } - } + } - if (!to.startsWith(".")) to = `.${to}`; - this.activeConversions.delete(file.id); - worker.terminate(); - return new VertFile( - new File([result.output], file.name), - result.isZip ? ".zip" : to, - ); + case "PandocParseError": { + if (error.includes("JSON missing pandoc-api-version")) { + throw new Error( + `This JSON file is not a pandoc-converted JSON file. It must be converted with pandoc / VERT to be converted again.`, + ); + } + } + + // eslint-disable-next-line no-fallthrough + default: + if (result.errorKind) + throw new Error( + `[${result.errorKind}] ${result.error}`, + ); + else throw new Error(result.error); + } + } + + if (!to.startsWith(".")) to = `.${to}`; + return new VertFile( + new File([result.output], file.name), + result.isZip ? ".zip" : to, + ); + } finally { + this.activeConversions.delete(file.id); + worker.terminate(); + } } public async cancel(input: VertFile): Promise { diff --git a/src/lib/converters/vertd.svelte.ts b/src/lib/converters/vertd.svelte.ts index 368a263..bddfc14 100644 --- a/src/lib/converters/vertd.svelte.ts +++ b/src/lib/converters/vertd.svelte.ts @@ -723,11 +723,42 @@ export class VertdConverter extends Converter { const apiUrl = await VertdInstance.instance.url(); return new Promise((resolve, reject) => { + let settled = false; const protocol = apiUrl.startsWith("https") ? "wss:" : "ws:"; const ws = new WebSocket( `${protocol}//${apiUrl.replace("http://", "").replace("https://", "")}/api/ws`, ); + const connectTimeout = setTimeout(() => { + if (settled) return; + settled = true; + this.activeConversions.delete(input.id); + ws.close(); + reject(new Error("vertd websocket connection timeout")); + }, 30000); + + const rejectConversion = (reason: unknown) => { + if (settled) return; + settled = true; + clearTimeout(connectTimeout); + this.activeConversions.delete(input.id); + if ( + ws.readyState === WebSocket.CONNECTING || + ws.readyState === WebSocket.OPEN + ) { + ws.close(); + } + reject(reason); + }; + + const resolveConversion = (value: VertFile) => { + if (settled) return; + settled = true; + clearTimeout(connectTimeout); + this.activeConversions.delete(input.id); + resolve(value); + }; + this.activeConversions.set(input.id, { ws, jobId: uploadRes.id, @@ -735,6 +766,7 @@ export class VertdConverter extends Converter { }); ws.onopen = () => { + clearTimeout(connectTimeout); this.log( `opened ws connection to vertd for file ${input.name}`, ); @@ -752,8 +784,32 @@ export class VertdConverter extends Converter { this.log(`sent startJob message for file ${input.name}`); }; + ws.onerror = () => { + this.error(`ws error for file ${input.name}`); + rejectConversion(new Error("vertd websocket error")); + }; + + ws.onclose = (event) => { + if (settled) return; + this.error( + `ws closed unexpectedly for file ${input.name} (code: ${event.code})`, + ); + rejectConversion(new Error("vertd websocket closed unexpectedly")); + }; + ws.onmessage = async (e) => { - const msg: VertdMessage = JSON.parse(e.data); + let msg: VertdMessage; + try { + if (typeof e.data !== "string") { + rejectConversion(new Error("invalid websocket payload type")); + return; + } + msg = JSON.parse(e.data); + } catch { + rejectConversion(new Error("invalid websocket payload")); + return; + } + this.log(`received message ${msg.type} for file ${input.name}`); switch (msg.type) { case "progressUpdate": { @@ -781,45 +837,48 @@ export class VertdConverter extends Converter { case "jobFinished": { this.log(`job finished for file ${input.name}`); ws.close(); - this.activeConversions.delete(input.id); - const url = `${apiUrl}/api/download/${msg.data.jobId}/${uploadRes.auth}`; - this.log(`downloading from ${url}`); - // const res = await fetch(url).then((res) => res.blob()); - const res = await downloadFile(url, input); - - // confirm download to clean up on server try { - await vertdFetch( - `/api/confirm/${msg.data.jobId}/${uploadRes.auth}`, - { - method: "GET", - }, - ); - this.log( - `confirmed download for file ${input.name}`, + const url = `${apiUrl}/api/download/${msg.data.jobId}/${uploadRes.auth}`; + this.log(`downloading from ${url}`); + const res = await downloadFile(url, input); + + // confirm download to clean up on server + try { + await vertdFetch( + `/api/confirm/${msg.data.jobId}/${uploadRes.auth}`, + { + method: "GET", + }, + ); + this.log( + `confirmed download for file ${input.name}`, + ); + } catch (e) { + this.error(`failed to confirm download: ${e}`); + } + + resolveConversion( + new VertFile(new File([res], input.name), to), ); } catch (e) { - this.error(`failed to confirm download: ${e}`); + if (hash) this.failure(hash); + rejectConversion(e); } - - resolve(new VertFile(new File([res], input.name), to)); break; } case "jobCancelled": { this.log("job cancelled"); ws.close(); - this.activeConversions.delete(input.id); - reject("Conversion cancelled"); + rejectConversion("Conversion cancelled"); break; } case "error": { this.error(`error: ${msg.data.message}`); - this.activeConversions.delete(input.id); if (hash) this.failure(hash); - reject({ + rejectConversion({ component: VertdErrorComponent, additional: { jobId: uploadRes.id, @@ -829,6 +888,11 @@ export class VertdConverter extends Converter { errorMessage: msg.data.message, }, }); + break; + } + + default: { + break; } } }; diff --git a/src/lib/sections/settings/index.svelte.ts b/src/lib/sections/settings/index.svelte.ts index ab9c942..4b3409b 100644 --- a/src/lib/sections/settings/index.svelte.ts +++ b/src/lib/sections/settings/index.svelte.ts @@ -1,6 +1,7 @@ import { PUB_VERTD_URL } from "$env/static/public"; import type { ConversionBitrate } from "$lib/converters/ffmpeg.svelte"; import type { ConversionSpeed } from "$lib/converters/vertd.svelte"; +import { readSettings } from "$lib/util/settings"; import { VertdInstance } from "./vertdSettings.svelte"; export { default as Appearance } from "./Appearance.svelte"; @@ -62,9 +63,9 @@ export class Settings { public load() { try { VertdInstance.instance.load(); - const ls = localStorage.getItem("settings"); - if (!ls) return; - const settings: ISettings = JSON.parse(ls); + const persisted = readSettings(); + if (!Object.keys(persisted).length) return; + const settings = persisted as ISettings; const vertdBlockedHashes = new Map( Object.entries( settings.vertdBlockedHashes || diff --git a/src/lib/store/index.svelte.ts b/src/lib/store/index.svelte.ts index abfab0b..64077a3 100644 --- a/src/lib/store/index.svelte.ts +++ b/src/lib/store/index.svelte.ts @@ -11,6 +11,7 @@ import { m } from "$lib/paraglide/messages"; import sanitizeHtml from "sanitize-html"; import { ToastManager } from "$lib/util/toast.svelte"; import { GB } from "$lib/util/consts"; +import { readSettings } from "$lib/util/settings"; class Files { public files = $state([]); @@ -52,9 +53,7 @@ class Files { public requiredConverters = $derived( Array.from( new Set( - this.files.flatMap((file) => - this.getRequiredConverters(file), - ), + this.files.flatMap((file) => this.getRequiredConverters(file)), ), ), ); @@ -93,6 +92,9 @@ class Files { ?.includes(file.from.toLowerCase()); try { + if (file.blobUrl?.startsWith("blob:")) + URL.revokeObjectURL(file.blobUrl); + if (isAudio) { // try to get the thumbnail from the audio via music-metadata const { common } = await parseBlob(file.file, { @@ -136,55 +138,65 @@ class Files { const mediaElement = isVideo ? document.createElement("video") : new Image(); - mediaElement.src = URL.createObjectURL(file); + const mediaUrl = URL.createObjectURL(file); + mediaElement.src = mediaUrl; - await new Promise((resolve, reject) => { - if (isVideo) { - const video = mediaElement as HTMLVideoElement; - // seek to 10% of video time or 2 seconds in - video.onloadeddata = () => { - const seekTime = Math.min(video.duration * 0.1, 2); - video.currentTime = seekTime; - }; - video.onseeked = resolve; - video.onerror = reject; - } else { - (mediaElement as HTMLImageElement).onload = resolve; - (mediaElement as HTMLImageElement).onerror = reject; + try { + await new Promise((resolve, reject) => { + if (isVideo) { + const video = mediaElement as HTMLVideoElement; + // seek to 10% of video time or 2 seconds in + video.onloadeddata = () => { + const seekTime = Math.min(video.duration * 0.1, 2); + video.currentTime = seekTime; + }; + video.onseeked = resolve; + video.onerror = reject; + } else { + (mediaElement as HTMLImageElement).onload = resolve; + (mediaElement as HTMLImageElement).onerror = reject; + } + }); + + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + if (!ctx) return undefined; + + const width = isVideo + ? (mediaElement as HTMLVideoElement).videoWidth + : (mediaElement as HTMLImageElement).width; + const height = isVideo + ? (mediaElement as HTMLVideoElement).videoHeight + : (mediaElement as HTMLImageElement).height; + + const scale = Math.max(maxSize / width, maxSize / height); + canvas.width = width * scale; + canvas.height = height * scale; + ctx.drawImage(mediaElement, 0, 0, canvas.width, canvas.height); + + // check if completely transparent + const imageData = ctx.getImageData( + 0, + 0, + canvas.width, + canvas.height, + ); + const isTransparent = Array.from(imageData.data).every( + (value, index) => { + return (index + 1) % 4 !== 0 || value === 0; + }, + ); + if (isTransparent) { + canvas.remove(); + return undefined; } - }); - const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("2d"); - if (!ctx) return undefined; - - const width = isVideo - ? (mediaElement as HTMLVideoElement).videoWidth - : (mediaElement as HTMLImageElement).width; - const height = isVideo - ? (mediaElement as HTMLVideoElement).videoHeight - : (mediaElement as HTMLImageElement).height; - - const scale = Math.max(maxSize / width, maxSize / height); - canvas.width = width * scale; - canvas.height = height * scale; - ctx.drawImage(mediaElement, 0, 0, canvas.width, canvas.height); - - // check if completely transparent - const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); - const isTransparent = Array.from(imageData.data).every( - (value, index) => { - return (index + 1) % 4 !== 0 || value === 0; - }, - ); - if (isTransparent) { + const url = canvas.toDataURL(); canvas.remove(); - return undefined; + return url; + } finally { + URL.revokeObjectURL(mediaUrl); } - - const url = canvas.toDataURL(); - canvas.remove(); - return url; } private async _handleZipFile(file: File): Promise { @@ -435,7 +447,7 @@ class Files { const blob = await downloadZip(dlFiles, "converted.zip").blob(); const url = URL.createObjectURL(blob); - const settings = JSON.parse(localStorage.getItem("settings") ?? "{}"); + const settings = readSettings<{ filenameFormat?: string }>(); const filenameFormat = settings.filenameFormat || "VERT_%name%"; const format = (name: string) => { @@ -603,7 +615,10 @@ export const getMaxArrayBufferSize = (): number => { // lmao uh mobile devices definitely have a much lower limit and using binary search here // was causing crashes especially on iOS, so just return 2GB to be safe :p if (get(isMobile)) { - log(["converters"], `mobile device likely detected, using 2GB fallback for max ArrayBuffer size`); + log( + ["converters"], + `mobile device likely detected, using 2GB fallback for max ArrayBuffer size`, + ); // don't save to localStorage, since it can always be a false positive or the user's browser window is simply just small return 2 * GB; } diff --git a/src/lib/types/file.svelte.ts b/src/lib/types/file.svelte.ts index 7113499..f006ad2 100644 --- a/src/lib/types/file.svelte.ts +++ b/src/lib/types/file.svelte.ts @@ -11,6 +11,7 @@ import type { SettingDefinition, } from "./conversion-settings"; import { log } from "$lib/util/logger"; +import { readSettings } from "$lib/util/settings"; const MAX_BLOB_SIZE_LIMIT = 2 * 1024 * 1024 * 1024; // 2GB @@ -121,7 +122,7 @@ export class VertFile { constructor(file: File, to: string, blobUrl?: string) { const ext = file.name.split(".").pop(); const newFile = new File( - [file.slice(0, file.size, file.type)], + [file], `${file.name.split(".").slice(0, -1).join(".")}.${ext?.toLowerCase()}`, ); this.file = newFile; @@ -479,7 +480,7 @@ export class VertFile { let to = this.result.to; if (!to.startsWith(".")) to = `.${to}`; - const settings = JSON.parse(localStorage.getItem("settings") ?? "{}"); + const settings = readSettings<{ filenameFormat?: string }>(); const filenameFormat = settings.filenameFormat || "VERT_%name%"; const format = (name: string) => { @@ -528,7 +529,7 @@ export class VertFile { setTimeout(() => { cache.delete(cacheKey); - }, 3000); + }, 30000); } else { blob = URL.createObjectURL( new Blob([await this.result.file.arrayBuffer()], { @@ -545,7 +546,9 @@ export class VertFile { a.target = "_blank"; a.style.display = "none"; a.click(); - URL.revokeObjectURL(blob); + setTimeout(() => { + URL.revokeObjectURL(blob); + }, 30000); a.remove(); } diff --git a/src/lib/util/settings.ts b/src/lib/util/settings.ts new file mode 100644 index 0000000..0e05b79 --- /dev/null +++ b/src/lib/util/settings.ts @@ -0,0 +1,23 @@ +import { browser } from "$app/environment"; +import { error } from "$lib/util/logger"; + +export function readSettings>(): Partial { + if (!browser) return {}; + + const raw = localStorage.getItem("settings"); + if (!raw) return {}; + + try { + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + localStorage.removeItem("settings"); + return {}; + } + + return parsed as Partial; + } catch (e) { + error(["settings", "error"], `failed to parse saved settings: ${e}`); + localStorage.removeItem("settings"); + return {}; + } +} \ No newline at end of file diff --git a/src/lib/util/sw.ts b/src/lib/util/sw.ts index 4a0ebee..b781635 100644 --- a/src/lib/util/sw.ts +++ b/src/lib/util/sw.ts @@ -46,15 +46,20 @@ class ServiceWorkerManager { return new Promise((resolve, reject) => { const messageChannel = new MessageChannel(); - - messageChannel.port1.onmessage = (event) => { - resolve(event.data); - }; - - setTimeout(() => { + let settled = false; + const timeoutId = setTimeout(() => { + if (settled) return; + settled = true; reject(new Error("Timeout waiting for cache info")); }, 5000); + messageChannel.port1.onmessage = (event) => { + if (settled) return; + settled = true; + clearTimeout(timeoutId); + resolve(event.data); + }; + navigator.serviceWorker?.controller?.postMessage( { type: "GET_CACHE_INFO" }, [messageChannel.port2], @@ -69,8 +74,17 @@ class ServiceWorkerManager { return new Promise((resolve, reject) => { const messageChannel = new MessageChannel(); + let settled = false; + const timeoutId = setTimeout(() => { + if (settled) return; + settled = true; + reject(new Error("Timeout waiting for cache clear")); + }, 10000); messageChannel.port1.onmessage = (event) => { + if (settled) return; + settled = true; + clearTimeout(timeoutId); if (event.data.success) { resolve(); } else { @@ -80,10 +94,6 @@ class ServiceWorkerManager { } }; - setTimeout(() => { - reject(new Error("Timeout waiting for cache clear")); - }, 10000); - navigator.serviceWorker?.controller?.postMessage( { type: "CLEAR_CACHE" }, [messageChannel.port2], diff --git a/src/routes/about/+page.svelte b/src/routes/about/+page.svelte index bdd8bbe..9500254 100644 --- a/src/routes/about/+page.svelte +++ b/src/routes/about/+page.svelte @@ -76,8 +76,15 @@ // Check if the data is already in sessionStorage const cachedContribs = sessionStorage.getItem("ghContribs"); if (cachedContribs) { - ghContribs = JSON.parse(cachedContribs); - return; + try { + const parsedContribs = JSON.parse(cachedContribs); + if (Array.isArray(parsedContribs)) { + ghContribs = parsedContribs; + return; + } + } catch { + sessionStorage.removeItem("ghContribs"); + } } // Fetch GitHub contributors @@ -104,30 +111,16 @@ !excludedNames.has(contrib.login), ); - // Fetch and cache avatar images as Base64 - const fetchAvatar = async (url: string) => { - const res = await fetch(url); - const blob = await res.blob(); - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onloadend = () => resolve(reader.result as string); - reader.onerror = reject; - reader.readAsDataURL(blob); - }); - }; - - ghContribs = await Promise.all( - filteredContribs.map( - async (contrib: { - login: string; - avatar_url: string; - html_url: string; - }) => ({ - name: contrib.login, - avatar: await fetchAvatar(contrib.avatar_url), - github: contrib.html_url, - }), - ), + ghContribs = filteredContribs.map( + (contrib: { + login: string; + avatar_url: string; + html_url: string; + }) => ({ + name: contrib.login, + avatar: contrib.avatar_url, + github: contrib.html_url, + }), ); // Cache the data in sessionStorage diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte index 812653c..5080359 100644 --- a/src/routes/settings/+page.svelte +++ b/src/routes/settings/+page.svelte @@ -2,17 +2,22 @@ import { browser } from "$app/environment"; import { error, log } from "$lib/util/logger"; import * as Settings from "$lib/sections/settings/index.svelte"; - import { PUB_PLAUSIBLE_URL } from "$env/static/public"; import { SettingsIcon } from "lucide-svelte"; import { onMount } from "svelte"; import { m } from "$lib/paraglide/messages"; import { ToastManager } from "$lib/util/toast.svelte"; import { DISABLE_ALL_EXTERNAL_REQUESTS } from "$lib/util/consts"; + import { readSettings } from "$lib/util/settings"; let settings = $state(Settings.Settings.instance.settings); let isInitial = $state(true); + const readSavedSettings = () => { + const parsed = readSettings(); + return Object.keys(parsed).length ? parsed : null; + }; + $effect(() => { if (!browser) return; if (isInitial) { @@ -20,12 +25,9 @@ return; } - const savedSettings = localStorage.getItem("settings"); - if (savedSettings) { - const parsedSettings = JSON.parse(savedSettings); - if (JSON.stringify(parsedSettings) === JSON.stringify(settings)) - return; - } + const parsedSettings = readSavedSettings(); + if (parsedSettings && JSON.stringify(parsedSettings) === JSON.stringify(settings)) + return; try { Settings.Settings.instance.settings = settings; @@ -41,9 +43,8 @@ }); onMount(() => { - const savedSettings = localStorage.getItem("settings"); - if (savedSettings) { - const parsedSettings = JSON.parse(savedSettings); + const parsedSettings = readSavedSettings(); + if (parsedSettings) { Settings.Settings.instance.settings = { ...Settings.Settings.instance.settings, ...parsedSettings, diff --git a/static/sw.js b/static/sw.js index 75982f7..0b4991f 100644 --- a/static/sw.js +++ b/static/sw.js @@ -126,8 +126,10 @@ self.addEventListener("fetch", (event) => { self.addEventListener("message", (event) => { if (!event.data) return; const type = event.data.type; + const port = event.ports?.[0]; if (type === "GET_CACHE_INFO") { + if (!port) return; event.waitUntil( caches.open(CACHE_NAME).then(async (cache) => { const keys = await cache.keys(); @@ -159,7 +161,7 @@ self.addEventListener("message", (event) => { } } - event.ports[0].postMessage({ + port.postMessage({ totalSize, fileCount: files.length, files, @@ -169,6 +171,7 @@ self.addEventListener("message", (event) => { } if (type === "CLEAR_CACHE") { + if (!port) return; event.waitUntil( caches .delete(CACHE_NAME) @@ -177,11 +180,11 @@ self.addEventListener("message", (event) => { return caches.open(CACHE_NAME); }) .then(() => { - event.ports[0].postMessage({ success: true }); + port.postMessage({ success: true }); }) .catch((err) => { console.error("[SW] failed to clear cache:", err); - event.ports[0].postMessage({ + port.postMessage({ success: false, error: err.message, });