18 Commits

Author SHA1 Message Date
MasterAcnolo
e1ef7032bb add: default theme applied in app. If no theme is there 2026-06-18 21:55:50 +02:00
MasterAcnolo
e7333b7b6b fix: missing enableHardwareAcceleration settings in readme 2026-06-10 21:43:38 +02:00
Axel Nicolas
e2b51d32d1 Merge pull request #43 from MasterAcnolo/1.6.0
1.5.1
2026-06-02 22:47:40 +02:00
MasterAcnolo
81515c6ab8 fix: revert version to 1.5.1 in package.json and README.md 2026-06-02 22:35:29 +02:00
MasterAcnolo
c88189b44f feat: add Devtron in dev mode
remove unused settings item
add setting to disable hardware acceleration (enabled by default)
optimize main.js startup loading
2026-06-01 22:39:12 +02:00
MasterAcnolo
b745ebd29f fix: display "dev" in app version if the app is not packaged 2026-05-31 21:45:56 +02:00
MasterAcnolo
13ee4c170b fix: fix width for form 2026-05-31 21:12:35 +02:00
MasterAcnolo
0218664c5a refactor: add full documentation and fix theme loading robustness
- Add JSDoc comments across frontend and backend modules
- Improve code readability and maintainability with consistent documentation style
- Document Electron IPC handlers, UI utilities, and theme system
- Add missing function/class documentation in theme loader and app services
- Minor structural improvements and cleanup across Electron main process modules
- Minor fixes on variable names
2026-05-31 20:38:42 +02:00
MasterAcnolo
6afc5e06d6 fix: app startup. Can't start another instance now. 2026-05-31 15:48:20 +02:00
MasterAcnolo
519dcad6e6 fix: rename localMode => devMode for clarity 2026-05-29 07:42:49 +02:00
MasterAcnolo
ac94d68224 fix: rename localMode => devMode for clarity 2026-05-29 07:42:38 +02:00
MasterAcnolo
0f876fcb70 fix: update dependencies 2026-05-29 07:41:54 +02:00
MasterAcnolo
a6c11ba7c4 update: version for 1.6.0 2026-05-15 19:57:33 +02:00
MasterAcnolo
44132dd7b5 fix: update dependencies 2026-05-11 21:14:00 +02:00
MasterAcnolo
15ab6892b7 Fix: remove duplication roadmap (now full in website) 2026-05-01 12:07:39 +02:00
MasterAcnolo
725b6f0ada Fix: Configuration File Location 2026-04-15 13:55:13 +02:00
MasterAcnolo
ad0e4f858b Fix: missing options in readme 2026-04-15 13:50:39 +02:00
MasterAcnolo
68108e3a66 Update Workshop link to GitHub repository 2026-04-14 11:28:53 +02:00
41 changed files with 2359 additions and 450 deletions

View File

@@ -7,7 +7,7 @@
### **A clean, open-source multimedia downloader for Windows** ### **A clean, open-source multimedia downloader for Windows**
[![Release](https://img.shields.io/badge/Release-1.5.0-blue?style=for-the-badge)](https://github.com/MasterAcnolo/Freedom-Loader/releases) [![Release](https://img.shields.io/badge/Release-1.5.1-blue?style=for-the-badge)](https://github.com/MasterAcnolo/Freedom-Loader/releases)
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-red.svg?style=for-the-badge)](https://www.gnu.org/licenses/gpl-3.0) [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-red.svg?style=for-the-badge)](https://www.gnu.org/licenses/gpl-3.0)
[![Website](https://img.shields.io/badge/Website-Visit-404040?style=for-the-badge)](https://masteracnolo.github.io/Freedom-Loader-Site/) [![Website](https://img.shields.io/badge/Website-Visit-404040?style=for-the-badge)](https://masteracnolo.github.io/Freedom-Loader-Site/)
[![Workshop](https://img.shields.io/badge/Worshop-Visit-0E05A1?style=for-the-badge)](https://masteracnolo.github.io/Freedom-Loader-Workshop/) [![Workshop](https://img.shields.io/badge/Worshop-Visit-0E05A1?style=for-the-badge)](https://masteracnolo.github.io/Freedom-Loader-Workshop/)
@@ -26,7 +26,7 @@ The primary goal is to make media downloading accessible to users who want offli
### See related repo: ### See related repo:
- **[Website](https://masteracnolo.github.io/Freedom-Loader-Site/)** - Official project website and documentation - **[Website](https://masteracnolo.github.io/Freedom-Loader-Site/)** - Official project website and documentation
- **[Workshop](https://masteracnolo.github.io/Freedom-Loader-Workshop/)** - Theme creation tool and theme browser - **[Workshop](https://github.com/MasterAcnolo/Freedom-Loader-Workshop)** - Theme creation tool and theme browser
## Table of Contents ## Table of Contents
@@ -165,7 +165,7 @@ Freedom Loader can be configured either through the settings panel in the UI or
### Configuration File Location ### Configuration File Location
``` ```
C:\Users\[USERNAME]\AppData\Roaming\FreedomLoader\config.json %APPDATA%\Freedom Loader\config.json
``` ```
### Available Options ### Available Options
@@ -179,11 +179,14 @@ C:\Users\[USERNAME]\AppData\Roaming\FreedomLoader\config.json
"addMetadata": true, "addMetadata": true,
"verboseLogs": false, "verboseLogs": false,
"autoDownloadPlaylist": true, "autoDownloadPlaylist": true,
"createPlaylistFolders": true,
"customCodec": "h264", "customCodec": "h264",
"logSystem": true, "logSystem": true,
"outputTitleCheck": true, "outputTitleCheck": true,
"downloadSystem": true, "downloadSystem": true,
"notifySystem": true "notifySystem": true,
"enableHardwareAcceleration": true,
"theme": "dark"
} }
``` ```
@@ -199,11 +202,14 @@ C:\Users\[USERNAME]\AppData\Roaming\FreedomLoader\config.json
| `addMetadata` | boolean | `true` | Add metadata tags to downloaded files | | `addMetadata` | boolean | `true` | Add metadata tags to downloaded files |
| `verboseLogs` | boolean | `false` | Enable detailed logging for debugging | | `verboseLogs` | boolean | `false` | Enable detailed logging for debugging |
| `autoDownloadPlaylist` | boolean | `true` | Automatically download entire playlists | | `autoDownloadPlaylist` | boolean | `true` | Automatically download entire playlists |
| `createPlaylistFolders` | boolean | `true` | Automatically create a folder for a playlist |
| `customCodec` | string | `"h264"` | Video codec for encoding (supported: h264, h265, vp9, av1) | | `customCodec` | string | `"h264"` | Video codec for encoding (supported: h264, h265, vp9, av1) |
| `logSystem` | boolean | `true` | Enable application logging | | `logSystem` | boolean | `true` | Enable application logging |
| `outputTitleCheck` | boolean | `true` | Verify output file titles | | `outputTitleCheck` | boolean | `true` | Verify output file titles |
| `downloadSystem` | boolean | `true` | Enable download system | | `downloadSystem` | boolean | `true` | Enable download system |
| `notifySystem` | boolean | `true` | Enable system notifications | | `notifySystem` | boolean | `true` | Enable system notifications |
| `enableHardwareAcceleration` | boolean | `true` | Enable Hardware Acceleration |
| `theme` | string | `dark` | Current Theme Name |
### Supported Codecs ### Supported Codecs
@@ -423,39 +429,6 @@ ressources/
See the most recent Roadmap [Here](https://masteracnolo.github.io/Freedom-Loader-Site/Roadmap). See the most recent Roadmap [Here](https://masteracnolo.github.io/Freedom-Loader-Site/Roadmap).
### Completed
- [x] ~~Website and documentation~~
- [x] ~~Automatic YT-DLP updates~~
- [x] ~~Automatic application updates~~
- [x] ~~Enhanced download status display~~
- [x] ~~Video chapter extraction~~
- [x] ~~Custom output path selection~~
- [x] ~~Custom codec selection~~
- [x] ~~Settings Panel with toggle switch for features.~~
- [x] ~~Custom theme system (ZIP format) ~~
- [x] ~~Theme Workshop (web tool)~~
- [x] ~~Splash screen~~
- [x] ~~Download stop button~~
- [x] ~~Config saved in AppData (preserved on update)~~
### In Progress
- [ ] Additional format support (WebM, OGG, etc.)
- [ ] Chrome/Edge cookie support
- [ ] Improved multi-site support
### Planned
- [ ] Subtitle download and embedding
- [ ] Multiple language support
- [ ] Partial video downloads
- [ ] Custom file naming patterns
- [ ] Parallel downloads
- [ ] Automatic sponsor detection (SponsorBlock integration)
- [ ] Download history
- [ ] Linux version
## Contributing ## Contributing
Contributions are welcome and appreciated. This project benefits from community involvement. Contributions are welcome and appreciated. This project benefits from community involvement.

View File

@@ -1,11 +1,42 @@
/**
* Electron auto-update system using electron-updater.
*
* Handles:
* - update detection
* - user confirmation dialogs
* - download progress streaming to renderer
* - application restart and installation
*/
const { autoUpdater } = require("electron-updater"); const { autoUpdater } = require("electron-updater");
const { dialog } = require("electron"); const { dialog } = require("electron");
const { logger } = require("../server/logger"); const { logger } = require("../server/logger");
/**
* Configures autoUpdater behavior:
* - disables automatic download
* - disables automatic install on quit
* (manual user-controlled update flow)
*/
autoUpdater.autoDownload = false; autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = false; autoUpdater.autoInstallOnAppQuit = false;
/**
* Initializes application auto-update lifecycle.
*
* Registers update event listeners and binds them to:
* - UI dialogs (Electron dialog)
* - renderer communication (webContents)
* - download/install flow control
*
* @param {BrowserWindow} mainWindow - main Electron window instance
*/
function initAutoUpdater(mainWindow) { function initAutoUpdater(mainWindow) {
/**
* Triggered when a new version is detected.
* Prompts user to install or defer update.
*/
autoUpdater.on("update-available", async (info) => { autoUpdater.on("update-available", async (info) => {
logger.info(`Update available: ${info.version}`); logger.info(`Update available: ${info.version}`);
@@ -28,6 +59,10 @@ function initAutoUpdater(mainWindow) {
} }
}); });
/**
* Streams update download progress to renderer process.
* Used to update UI progress bar and status indicators.
*/
autoUpdater.on("download-progress", (progress) => { autoUpdater.on("download-progress", (progress) => {
logger.info(`Download progress: ${Math.round(progress.percent)}%`); logger.info(`Download progress: ${Math.round(progress.percent)}%`);
mainWindow?.webContents.send("update-progress", { mainWindow?.webContents.send("update-progress", {
@@ -36,6 +71,10 @@ function initAutoUpdater(mainWindow) {
}); });
}); });
/**
* Triggered when update has been fully downloaded.
* Prompts user to restart application and install update.
*/
autoUpdater.on("update-downloaded", async (info) => { autoUpdater.on("update-downloaded", async (info) => {
logger.info(`Update downloaded: ${info.version}`); logger.info(`Update downloaded: ${info.version}`);
@@ -52,6 +91,10 @@ function initAutoUpdater(mainWindow) {
if (response === 0) autoUpdater.quitAndInstall(); if (response === 0) autoUpdater.quitAndInstall();
}); });
/**
* Handles update system errors.
* Logs failure and displays an error dialog to user.
*/
autoUpdater.on("error", (err) => { autoUpdater.on("error", (err) => {
logger.error("Auto update error:", err.message); logger.error("Auto update error:", err.message);
dialog.showErrorBox("Update Error", err.message); dialog.showErrorBox("Update Error", err.message);
@@ -60,6 +103,10 @@ function initAutoUpdater(mainWindow) {
checkForUpdates(); checkForUpdates();
} }
/**
* Manually triggers update check on startup.
* Separated from init for reusability and testability.
*/
async function checkForUpdates() { async function checkForUpdates() {
try { try {
await autoUpdater.checkForUpdates(); await autoUpdater.checkForUpdates();
@@ -68,6 +115,10 @@ async function checkForUpdates() {
} }
} }
/**
* Manually triggers update download.
* Used when user accepts update prompt.
*/
async function downloadUpdate() { async function downloadUpdate() {
try { try {
await autoUpdater.downloadUpdate(); await autoUpdater.downloadUpdate();
@@ -76,6 +127,9 @@ async function downloadUpdate() {
} }
} }
/**
* Immediately quits application and installs downloaded update.
*/
function installUpdate() { function installUpdate() {
autoUpdater.quitAndInstall(); autoUpdater.quitAndInstall();
} }

View File

@@ -2,9 +2,33 @@ const fs = require("fs");
const { app, dialog } = require("electron"); const { app, dialog } = require("electron");
const { logger } = require("../server/logger"); const { logger } = require("../server/logger");
/**
* Verifies that all required native binaries exist before the app starts.
*
* This includes:
* - yt-dlp (download engine)
* - ffmpeg (media processing)
* - ffprobe (media inspection)
* - deno (JS runtime used by yt-dlp hooks)
*
* If any dependency is missing:
* - logs the error
* - shows a blocking Electron error dialog
* - exits the application
*
* @returns {boolean} true if all dependencies are present, false otherwise
*/
function checkNativeDependencies() { function checkNativeDependencies() {
/**
* Native dependency paths are resolved at runtime from the server helpers.
* This ensures correct resolution in both dev and packaged builds.
*/
const { binaryPaths } = require("../server/helpers/path.helpers"); const { binaryPaths } = require("../server/helpers/path.helpers");
/**
* List of required external executables used by the application runtime.
* Each entry defines a binary name and its resolved absolute path.
*/
const deps = [ const deps = [
{ name: "yt-dlp.exe", path: binaryPaths.ytDlp }, { name: "yt-dlp.exe", path: binaryPaths.ytDlp },
{ name: "ffmpeg.exe", path: binaryPaths.ffmpeg }, { name: "ffmpeg.exe", path: binaryPaths.ffmpeg },
@@ -15,9 +39,16 @@ function checkNativeDependencies() {
const missing = deps.filter(d => !fs.existsSync(d.path)); const missing = deps.filter(d => !fs.existsSync(d.path));
if (missing.length === 0) return true; if (missing.length === 0) return true;
/**
* Human-readable list of missing binaries for logging and user dialog display.
*/
const list = missing.map(d => d.name).join(", "); const list = missing.map(d => d.name).join(", ");
logger.error(`Missing dependencies: ${list}`); logger.error(`Missing dependencies: ${list}`);
/**
* Defers error dialog until Electron app is fully initialized.
* Required to safely display modal dialogs before quit.
*/
app.whenReady().then(() => { app.whenReady().then(() => {
dialog.showErrorBox( dialog.showErrorBox(
"Missing dependencies", "Missing dependencies",

View File

@@ -2,12 +2,40 @@ const config = require('../config');
const RPC = require("discord-rpc"); const RPC = require("discord-rpc");
const { logger } = require("../server/logger"); const { logger } = require("../server/logger");
/**
* Discord Application Client ID used to authenticate the RPC connection.
* Comes from the application configuration.
*/
const clientId = `${config.DiscordRPCID}`; const clientId = `${config.DiscordRPCID}`;
/**
* Discord RPC client instance using IPC transport.
* Maintains a persistent connection with Discord desktop client.
*/
const rpc = new RPC.Client({ transport: "ipc" }); const rpc = new RPC.Client({ transport: "ipc" });
/**
* Optional interval reference used for future RPC refresh logic.
* Currently reserved for potential periodic activity updates.
*/
let intervalId; let intervalId;
/**
* Initializes Discord Rich Presence (RPC) connection.
*
* Responsibilities:
* - Connect to Discord via IPC transport
* - Set application presence (title, state, timestamps, assets)
* - Handle connection errors gracefully via logger
*
* Triggered once during application startup.
*/
function startRPC() { function startRPC() {
/**
* Rich Presence payload describing current application state
* shown in Discord user profile.
*/
rpc.on("ready", () => { rpc.on("ready", () => {
const presence = { const presence = {
largeImageKey: "icon", largeImageKey: "icon",
@@ -27,10 +55,22 @@ function startRPC() {
}); });
} }
/**
* Gracefully stops Discord Rich Presence connection.
*
* Responsibilities:
* - Clear any running intervals
* - Remove current activity from Discord
* - Destroy RPC transport connection
* - Handle cleanup errors safely
*/
async function stopRPC(){ async function stopRPC(){
try { try {
if (intervalId) clearInterval(intervalId); if (intervalId) clearInterval(intervalId);
/**
* Ensures RPC connection exists before attempting cleanup.
*/
if (rpc && rpc.transport) { if (rpc && rpc.transport) {
await rpc.clearActivity() await rpc.clearActivity()
await rpc.destroy(); await rpc.destroy();

View File

@@ -1,3 +1,15 @@
/**
* Electron IPC main process registry.
*
* Centralizes all IPC channels exposed to renderer process:
* - Application info (version, features)
* - Window controls (topbar actions)
* - File system interactions (theme, config, download paths)
* - UI state synchronization (progress bar)
*
* Acts as the bridge between renderer and privileged Node/Electron APIs.
*/
const { ipcMain, dialog, shell } = require("electron"); const { ipcMain, dialog, shell } = require("electron");
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
@@ -8,6 +20,12 @@ const config = require("../config");
const { validateDownloadPath, getDefaultDownloadPath } = require("./pathValidator"); const { validateDownloadPath, getDefaultDownloadPath } = require("./pathValidator");
const { userThemesPath } = require("../server/helpers/path.helpers"); const { userThemesPath } = require("../server/helpers/path.helpers");
/**
* Security whitelist for feature flags that can be modified at runtime.
*
* Prevents unauthorized or unexpected configuration keys from being persisted.
* Acts as a basic validation layer for IPC "set-feature".
*/
const FEATURE_WHITELIST = new Set([ const FEATURE_WHITELIST = new Set([
"autoUpdate", "autoUpdate",
"discordRPC", "discordRPC",
@@ -20,20 +38,49 @@ const FEATURE_WHITELIST = new Set([
"customCodec", "customCodec",
"theme", "theme",
"createPlaylistFolders", "createPlaylistFolders",
"notifySystem" "notifySystem",
"enableHardwareAcceleration"
]); ]);
/**
* Absolute path to the configuration file storing feature flags.
*/
const configFolderPath = featuresPath; const configFolderPath = featuresPath;
/**
* Directory containing user-installed themes.
*/
const themeFolderPath = userThemesPath; const themeFolderPath = userThemesPath;
/**
* Registers all IPC handlers and event listeners for Electron main process.
*
* This function wires renderer → main communication channels:
* - synchronous queries (handle)
* - fire-and-forget events (on)
*
* @param {Function} getMainWindow - Function returning current BrowserWindow instance
*/
function registerIpcHandlers(getMainWindow) { function registerIpcHandlers(getMainWindow) {
// Infos générales /**
* Returns application version from config.
*/
ipcMain.handle("version", () => config.version); ipcMain.handle("version", () => config.version);
/**
* Returns runtime feature configuration object.
*/
ipcMain.handle("features", () => configFeatures); ipcMain.handle("features", () => configFeatures);
// Sélection et validation de dossier /**
* Opens native folder selection dialog and validates selected path.
*
* Ensures:
* - user cancellation is handled safely
* - selected path is validated against security rules
* - unsafe directories are rejected
*/
ipcMain.handle("select-download-folder", async () => { ipcMain.handle("select-download-folder", async () => {
const result = await dialog.showOpenDialog({ properties: ["openDirectory"] }); const result = await dialog.showOpenDialog({ properties: ["openDirectory"] });
if (result.canceled) { if (result.canceled) {
@@ -53,48 +100,108 @@ function registerIpcHandlers(getMainWindow) {
ipcMain.handle("validate-download-path", (_, userPath) => validateDownloadPath(userPath)); ipcMain.handle("validate-download-path", (_, userPath) => validateDownloadPath(userPath));
ipcMain.handle("get-default-download-path", () => getDefaultDownloadPath()); ipcMain.handle("get-default-download-path", () => getDefaultDownloadPath());
// Progression dans la taskbar /**
* Updates Windows/macOS taskbar progress indicator.
*
* @param {number} percent - Progress value (0100)
*/
ipcMain.on("set-progress", (_, percent) => { ipcMain.on("set-progress", (_, percent) => {
getMainWindow()?.setProgressBar(percent / 100); getMainWindow()?.setProgressBar(percent / 100);
}); });
// TOPBAR ACTION /**
* Window minimize request from renderer.
*/
ipcMain.on("window-minimize", () => getMainWindow()?.minimize()); ipcMain.on("window-minimize", () => getMainWindow()?.minimize());
/**
* Toggles maximize/unmaximize state of main window.
*/
ipcMain.on("window-maximize", () => { ipcMain.on("window-maximize", () => {
const win = getMainWindow(); const win = getMainWindow();
if (!win) return; if (!win) return;
win.isMaximized() ? win.unmaximize() : win.maximize(); win.isMaximized() ? win.unmaximize() : win.maximize();
}); });
/**
* Closes the main application window.
*/
ipcMain.on("window-close", () => getMainWindow()?.close()); ipcMain.on("window-close", () => getMainWindow()?.close());
/**
* Opens Chromium DevTools in detached mode.
*/
ipcMain.on("open-devtools", () => ipcMain.on("open-devtools", () =>
getMainWindow()?.webContents.openDevTools({ mode: "detach" }) getMainWindow()?.webContents.openDevTools({ mode: "detach" })
); );
/**
* Opens application logs directory in system file explorer.
*/
ipcMain.on("open-logs", () => logDir && shell.openPath(logDir)); ipcMain.on("open-logs", () => logDir && shell.openPath(logDir));
/**
* Opens external website in default browser.
*/
ipcMain.on("open-website", () => ipcMain.on("open-website", () =>
shell.openExternal("https://masteracnolo.github.io/Freedom-Loader-Site/") shell.openExternal("https://masteracnolo.github.io/Freedom-Loader-Site/")
); );
/**
* Opens official wiki page in external browser.
*/
ipcMain.on("open-wiki", () => ipcMain.on("open-wiki", () =>
shell.openExternal("https://masteracnolo.github.io/Freedom-Loader-Site/wiki") shell.openExternal("https://masteracnolo.github.io/Freedom-Loader-Site/wiki")
); );
/**
* Opens workshop page in external browser.
*/
ipcMain.on("open-workshop", () => ipcMain.on("open-workshop", () =>
shell.openExternal("https://masteracnolo.github.io/Freedom-Loader-Workshop") shell.openExternal("https://masteracnolo.github.io/Freedom-Loader-Workshop")
); );
/**
* Opens configuration folder in system file explorer.
*/
ipcMain.on("open-config", () => shell.openPath(configFolderPath)); ipcMain.on("open-config", () => shell.openPath(configFolderPath));
// THEME /**
* Retrieves available themes from filesystem.
*/
ipcMain.handle("get-themes", () => getThemes()); ipcMain.handle("get-themes", () => getThemes());
/**
* Opens theme directory in file explorer.
*/
ipcMain.on("open-theme", () => shell.openPath(themeFolderPath)); ipcMain.on("open-theme", () => shell.openPath(themeFolderPath));
/**
* Reloads themes from disk dynamically.
*/
ipcMain.handle("reload-themes", async () => { ipcMain.handle("reload-themes", async () => {
return await reloadThemes(); return await reloadThemes();
}); });
// Modification des features /**
* Updates a runtime feature flag and persists it to disk.
*
* Flow:
* - Validates key against whitelist
* - Prevents unnecessary writes if value is unchanged
* - Updates in-memory config object
* - Persists to configuration file
*
* Security note:
* Only whitelisted keys can be modified via IPC to prevent config injection.
*
* @param {Electron.IpcMainEvent} event
* @param {{key: string, value: any}} payload
* @returns {boolean} success state
*/
ipcMain.handle("set-feature", (event, { key, value }) => { ipcMain.handle("set-feature", (event, { key, value }) => {
try { try {
if (!FEATURE_WHITELIST.has(key)) { if (!FEATURE_WHITELIST.has(key)) {

View File

@@ -2,28 +2,77 @@ const fs = require("fs");
const path = require("path"); const path = require("path");
const { logger } = require("../server/logger"); const { logger } = require("../server/logger");
/**
* Cached resolved default download path.
* Initialized lazily on first access to avoid unnecessary imports.
*/
let _defaultPath = null; let _defaultPath = null;
/**
* Returns the application's default download directory.
*
* This value is lazily loaded and cached after first call.
* The path originates from server configuration helpers.
*
* @returns {string} Absolute default download path
*/
function getDefaultDownloadPath() { function getDefaultDownloadPath() {
if (!_defaultPath) { if (!_defaultPath) {
/**
* Lazy-loaded dependency providing the configured default download folder.
* Required only when function is executed to reduce startup cost.
*/
const { defaultDownloadFolder } = require("../server/helpers/path.helpers"); const { defaultDownloadFolder } = require("../server/helpers/path.helpers");
_defaultPath = defaultDownloadFolder; _defaultPath = defaultDownloadFolder;
} }
return _defaultPath; return _defaultPath;
} }
/**
* Validates and sanitizes a user-provided download path.
*
* Steps performed:
* - Resolves absolute real filesystem path
* - Normalizes symbolic links via realpathSync
* - Checks path safety rules (blocks system-critical directories)
* - Falls back to default path if input is empty
*
* @param {string} userPath - User-provided directory path
* @returns {string} Sanitized absolute path
* @throws Error If path is invalid, unsafe, or inaccessible
*/
function validateDownloadPath(userPath) { function validateDownloadPath(userPath) {
/**
* Lazy-loaded helper used to enforce safe directory constraints.
* Prevents writes to protected system folders.
*/
const { isSafePath } = require("../server/helpers/validation.helpers"); const { isSafePath } = require("../server/helpers/validation.helpers");
if (!userPath) return getDefaultDownloadPath(); if (!userPath) return getDefaultDownloadPath();
try { try {
/**
* Resolves filesystem symlinks to ensure canonical absolute path.
* Required to prevent path traversal or alias bypass.
*/
const resolved = fs.realpathSync(path.resolve(userPath)); const resolved = fs.realpathSync(path.resolve(userPath));
if (!isSafePath(resolved)) { if (!isSafePath(resolved)) {
throw new Error("Path not allowed: system folders are blocked!"); throw new Error("Path not allowed: system folders are blocked!");
} }
return resolved; return resolved;
} catch (err) { }
catch (err) {
/**
* Handles invalid filesystem paths, permissions issues, or resolution failures.
* Logs diagnostic information before propagating a sanitized error.
*/
logger.error(`Invalid download path: ${userPath}${err.message}`); logger.error(`Invalid download path: ${userPath}${err.message}`);
throw new Error(`Invalid or inaccessible path: ${err.message}`); throw new Error(`Invalid or inaccessible path: ${err.message}`);
} }

View File

@@ -1,11 +1,28 @@
const { BrowserWindow, app } = require("electron"); const { BrowserWindow, app } = require("electron");
const path = require("path"); const path = require("path");
/**
* Reference to the splash screen BrowserWindow instance.
* Used during application startup before main window is ready.
*/
let splashWindow = null; let splashWindow = null;
/**
* Creates and displays the splash screen window.
*
* Responsibilities:
* - Initializes a frameless, always-on-top splash window
* - Loads splash HTML UI
* - Dynamically injects banner image depending on dev/production mode
* - Acts as startup visual feedback before main window loads
*/
function createSplashWindow() { function createSplashWindow() {
const splashOptions = { /**
* Configuration for splash screen BrowserWindow.
* Optimized for minimal UI and fast startup display.
*/
const splashOptions = {
width: 400, width: 400,
height: 300, height: 300,
frame: false, frame: false,
@@ -19,13 +36,29 @@ const splashOptions = {
}, },
}; };
/**
* Instantiates the splash screen window.
*/
splashWindow = new BrowserWindow(splashOptions); splashWindow = new BrowserWindow(splashOptions);
/**
* Absolute path to splash HTML file used as initial UI.
* Loaded locally from application bundle or project folder.
*/
const splashPath = path.join(__dirname, "../public/splash.html"); const splashPath = path.join(__dirname, "../public/splash.html");
splashWindow.loadFile(splashPath); splashWindow.loadFile(splashPath);
// Inject banner path for both dev and packaged app /**
* Executes once splash HTML is fully loaded.
* Used here to inject dynamic assets (banner image path).
*/
splashWindow.webContents.on('did-finish-load', () => { splashWindow.webContents.on('did-finish-load', () => {
/**
* Resolves splash banner image path depending on runtime mode:
* - Production: bundled resources directory
* - Development: local build directory
*/
let bannerPath; let bannerPath;
// Check if app is packaged // Check if app is packaged
@@ -36,12 +69,21 @@ const splashOptions = {
bannerPath = path.join(__dirname, '../build/banner.png'); bannerPath = path.join(__dirname, '../build/banner.png');
} }
/**
* Injects runtime image source into splash DOM.
* Uses file:// protocol to load local image safely in Electron context.
*/
splashWindow.webContents.executeJavaScript(` splashWindow.webContents.executeJavaScript(`
document.querySelector('img[alt="Freedom Loader"]').src = 'file:///${bannerPath.replace(/\\/g, '/')}'; document.querySelector('img[alt="Freedom Loader"]').src = 'file:///${bannerPath.replace(/\\/g, '/')}';
`); `);
}); });
} }
/**
* Closes and cleans up the splash screen window.
*
* Should be called once main window is ready to display.
*/
function closeSplashWindow() { function closeSplashWindow() {
if (splashWindow) { if (splashWindow) {
splashWindow.close(); splashWindow.close();
@@ -49,6 +91,13 @@ function closeSplashWindow() {
} }
} }
/**
* Updates splash screen progress indicator from main process.
*
* Sends a step value to renderer via injected JS function.
*
* @param {number} step - Current initialization step or progress value
*/
function setSplashProgress(step) { function setSplashProgress(step) {
if (splashWindow) { if (splashWindow) {
splashWindow.webContents.executeJavaScript(`window.setProgress(${step})`); splashWindow.webContents.executeJavaScript(`window.setProgress(${step})`);

View File

@@ -1,14 +1,48 @@
/**
* Theme loading and parsing system.
*
* Supports:
* - Folder-based themes
* - ZIP-based themes
* - Theme validation against required schema
* - Image embedding as base64 data URIs
* - Cached theme registry with sorted priority order
*
* Acts as the core theme ingestion pipeline for the application UI.
*/
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const { logger } = require("../server/logger"); const { logger } = require("../server/logger");
const JSZip = require("jszip"); const JSZip = require("jszip");
/**
* Maximum allowed size (in bytes) for theme images.
* Prevents memory abuse from large embedded assets.
*/
const MAX_IMAGE_SIZE = 10 * 1024 * 1024; const MAX_IMAGE_SIZE = 10 * 1024 * 1024;
/**
* List of supported image file extensions for theme assets.
*/
const ALLOWED_IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif"]; const ALLOWED_IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif"];
/**
* Allowed base filenames for theme background/cover images.
* Restricts theme asset resolution to known conventions.
*/
const ALLOWED_IMAGE_NAMES = ["cover", "background"]; const ALLOWED_IMAGE_NAMES = ["cover", "background"];
/**
* Priority order used to sort themes in UI.
* Themes not listed are placed at the end.
*/
const THEME_ORDER = ["dark", "light"]; const THEME_ORDER = ["dark", "light"];
/**
* Required JSON schema paths for a valid theme definition.
* Each entry represents a nested property path that must exist.
*/
const REQUIRED_KEYS = [ const REQUIRED_KEYS = [
["meta", "name"], ["meta", "name"],
["meta", "author"], ["meta", "author"],
@@ -20,10 +54,25 @@ const REQUIRED_KEYS = [
["style", "progressBar", "fill"], ["style", "progressBar", "fill"],
]; ];
/**
* Safely retrieves a deeply nested value from an object using a key path array.
*
* @param {object} obj - Target object
* @param {string[]} keys - Property path segments
* @returns {*} value or undefined if path is invalid
*/
function getNestedValue(obj, keys) { function getNestedValue(obj, keys) {
return keys.reduce((acc, key) => acc?.[key], obj); return keys.reduce((acc, key) => acc?.[key], obj);
} }
/**
* Validates theme JSON structure against required schema.
*
* Ensures all mandatory configuration keys exist before theme is accepted.
*
* @param {object} json - Parsed theme JSON
* @returns {{valid: boolean, reason?: string}}
*/
function validateThemeJson(json) { function validateThemeJson(json) {
for (const keyPath of REQUIRED_KEYS) { for (const keyPath of REQUIRED_KEYS) {
if (getNestedValue(json, keyPath) === undefined) { if (getNestedValue(json, keyPath) === undefined) {
@@ -33,6 +82,14 @@ function validateThemeJson(json) {
return { valid: true }; return { valid: true };
} }
/**
* Normalizes raw theme JSON into application-ready theme object.
*
* @param {string} themeId
* @param {object} themeJson
* @param {string|null} imageData - Base64 encoded theme image
* @returns {object} normalized theme object
*/
function buildThemeObject(themeId, themeJson, imageData) { function buildThemeObject(themeId, themeJson, imageData) {
return { return {
id: themeId.toLowerCase(), id: themeId.toLowerCase(),
@@ -45,6 +102,17 @@ function buildThemeObject(themeId, themeJson, imageData) {
}; };
} }
/**
* Extracts and encodes an image from theme archive into base64 data URI.
*
* Applies safety checks:
* - Rejects images exceeding MAX_IMAGE_SIZE
* - Converts file extension into valid MIME type
*
* @param {Buffer} buffer
* @param {string} filename
* @returns {string|null} data URI or null if invalid
*/
function extractImage(buffer, filename) { function extractImage(buffer, filename) {
if (buffer.length > MAX_IMAGE_SIZE) { if (buffer.length > MAX_IMAGE_SIZE) {
logger.warn(`Image too large, ignoring`); logger.warn(`Image too large, ignoring`);
@@ -55,6 +123,20 @@ function extractImage(buffer, filename) {
return `data:image/${mime};base64,${buffer.toString("base64")}`; return `data:image/${mime};base64,${buffer.toString("base64")}`;
} }
/**
* Loads a theme from a ZIP archive.
*
* Steps:
* - Extract ZIP content
* - Locate .theme.json file
* - Validate JSON structure
* - Extract optional image asset
* - Build normalized theme object
*
* @param {string} zipPath
* @param {string} themeId
* @returns {Promise<object|null>}
*/
async function loadThemeFromZip(zipPath, themeId) { async function loadThemeFromZip(zipPath, themeId) {
try { try {
const zipBuffer = fs.readFileSync(zipPath); const zipBuffer = fs.readFileSync(zipPath);
@@ -91,6 +173,15 @@ async function loadThemeFromZip(zipPath, themeId) {
} }
} }
/**
* Loads a theme from a directory structure.
*
* Equivalent to ZIP loader but works on filesystem directly.
*
* @param {string} folderPath
* @param {string} themeId
* @returns {Promise<object|null>}
*/
async function loadThemeFromFolder(folderPath, themeId) { async function loadThemeFromFolder(folderPath, themeId) {
try { try {
const jsonFile = fs.readdirSync(folderPath).find(f => f.endsWith(".theme.json")); const jsonFile = fs.readdirSync(folderPath).find(f => f.endsWith(".theme.json"));
@@ -124,13 +215,38 @@ async function loadThemeFromFolder(folderPath, themeId) {
} }
} }
/**
* In-memory cache of loaded themes.
* Avoids re-parsing filesystem on every request.
*/
let cachedThemes = null; let cachedThemes = null;
/**
* Active theme directory path used as source of truth.
*/
let themeFolderPath = null; let themeFolderPath = null;
/**
* Sets the active theme directory path.
*
* @param {string} folderPath
*/
function setThemeFolderPath(folderPath) { function setThemeFolderPath(folderPath) {
themeFolderPath = folderPath; themeFolderPath = folderPath;
} }
/**
* Loads all themes from the configured theme directory.
*
* Supports:
* - ZIP themes (.zip)
* - Folder themes (directory-based)
*
* Iterates through filesystem entries and delegates parsing
* to the appropriate loader (ZIP or folder).
*
* @returns {Promise<object[]>} List of valid theme objects
*/
async function loadThemesFromFolder() { async function loadThemesFromFolder() {
const themes = []; const themes = [];
@@ -161,11 +277,31 @@ async function loadThemesFromFolder() {
return themes; return themes;
} }
/**
* Returns sorting priority for a theme ID.
*
* Lower index = higher priority.
* Themes not in THEME_ORDER are placed at the end.
*
* @param {string} id
* @returns {number}
*/
function getThemeOrder(id) { function getThemeOrder(id) {
const index = THEME_ORDER.indexOf(id); const index = THEME_ORDER.indexOf(id);
return index === -1 ? Infinity : index; return index === -1 ? Infinity : index;
} }
/**
* Initializes the theme system.
*
* Steps:
* - Sets theme folder path
* - Loads all themes from disk
* - Sorts themes by priority order
* - Caches result in memory
*
* @param {string} folderPath
*/
async function initThemes(folderPath) { async function initThemes(folderPath) {
setThemeFolderPath(folderPath); setThemeFolderPath(folderPath);
const themes = await loadThemesFromFolder(); const themes = await loadThemesFromFolder();
@@ -174,10 +310,24 @@ async function initThemes(folderPath) {
logger.info(`Themes after sort: ${cachedThemes.map(t => t.id).join(", ")}`); logger.info(`Themes after sort: ${cachedThemes.map(t => t.id).join(", ")}`);
} }
/**
* Returns cached themes.
*
* If themes are not loaded yet, returns empty array.
*
* @returns {object[]}
*/
function getThemes() { function getThemes() {
return cachedThemes ?? []; return cachedThemes ?? [];
} }
/**
* Reloads themes from disk and refreshes cache.
*
* Used when themes are modified at runtime without restart.
*
* @returns {Promise<object[]>}
*/
async function reloadThemes() { async function reloadThemes() {
const themes = await loadThemesFromFolder(); const themes = await loadThemesFromFolder();
cachedThemes = themes.sort((a, b) => getThemeOrder(a.id) - getThemeOrder(b.id)); cachedThemes = themes.sort((a, b) => getThemeOrder(a.id) - getThemeOrder(b.id));

View File

@@ -1,21 +1,55 @@
/**
* Electron main window module dependencies.
* Handles BrowserWindow lifecycle and application UI shell.
*/
const { BrowserWindow, app } = require("electron"); const { BrowserWindow, app } = require("electron");
const path = require("path"); const path = require("path");
const { logger } = require("../server/logger"); const { logger } = require("../server/logger");
const { configFeatures } = require("../config"); const { configFeatures } = require("../config");
const config = require("../config"); const config = require("../config");
/**
* Singleton reference to the main Electron BrowserWindow instance.
* Ensures only one application window exists at runtime.
*/
let mainWindow = null; let mainWindow = null;
/**
* Creates and initializes the main Electron application window.
*
* Responsibilities:
* - Ensures singleton window instance (prevents duplicates)
* - Configures window options (size, icon, preload, security settings)
* - Loads frontend application from local dev server
* - Handles lifecycle events (close, errors)
*
* @returns {Promise<BrowserWindow>} The created or existing window instance
*/
async function createMainWindow() { async function createMainWindow() {
/**
* Prevents multiple instances of the main window.
* Returns existing instance if already created.
*/
if (mainWindow) { if (mainWindow) {
logger.warn("Window already exists, no new creation!"); logger.warn("Window already exists, no new creation!");
return mainWindow; return mainWindow;
} }
const iconPath = config.localMode /**
* Resolves application icon path depending on runtime mode:
* - Development: local build folder
* - Production: packaged Electron resources
*/
const iconPath = config.devMode
? path.join(__dirname, "../build/app-icon.ico") ? path.join(__dirname, "../build/app-icon.ico")
: path.join(process.resourcesPath, "build/app-icon.ico"); : path.join(process.resourcesPath, "build/app-icon.ico");
/**
* Electron BrowserWindow configuration object.
* Defines UI behavior, security settings, and preload bridge.
*/
const windowOptions = { const windowOptions = {
title: `Freedom Loader ${config.version}`, title: `Freedom Loader ${config.version}`,
icon: iconPath, icon: iconPath,
@@ -35,6 +69,10 @@ async function createMainWindow() {
mainWindow = new BrowserWindow(windowOptions); mainWindow = new BrowserWindow(windowOptions);
/**
* Loads the frontend application served by the local dev server.
* In production, this could be replaced with a file:// build.
*/
try { try {
await mainWindow.loadURL(`http://localhost:${config.applicationPort}`); await mainWindow.loadURL(`http://localhost:${config.applicationPort}`);
logger.info("Window loaded"); logger.info("Window loaded");
@@ -43,6 +81,10 @@ async function createMainWindow() {
throw err; throw err;
} }
/**
* Cleans up window reference when the main window is closed.
* Prevents memory leaks and allows recreation if needed.
*/
mainWindow.on("closed", () => { mainWindow.on("closed", () => {
logger.info("Main window closed"); logger.info("Main window closed");
mainWindow = null; mainWindow = null;
@@ -51,6 +93,14 @@ async function createMainWindow() {
return mainWindow; return mainWindow;
} }
/**
* Returns the current main window instance.
*
* Useful for IPC handlers and background services
* needing access to the renderer process.
*
* @returns {BrowserWindow | null}
*/
function getMainWindow() { function getMainWindow() {
return mainWindow; return mainWindow;
} }

View File

@@ -1,10 +1,33 @@
const { execFile } = require("child_process"); const { execFile } = require("child_process");
const { logger } = require("../server/logger"); const { logger } = require("../server/logger");
/**
* Attempts to update the local yt-dlp binary using its built-in update command.
*
* This function:
* - Executes yt-dlp with the `-U` flag
* - Logs update output or warnings if update fails
* - Never throws: update failure is non-blocking by design
*
* @param {string} ytDlpPath - Absolute path to the yt-dlp executable
*/
function updateYtDlp(ytDlpPath) { function updateYtDlp(ytDlpPath) {
logger.info("yt-dlp update check starting..."); logger.info("yt-dlp update check starting...");
/**
* Executes yt-dlp CLI process in a non-interactive mode.
* Used here specifically for self-update via `-U` flag.
*/
execFile(ytDlpPath, ["-U"], (err, stdout) => { execFile(ytDlpPath, ["-U"], (err, stdout) => {
/**
* Handles yt-dlp update result:
* - Logs warning if update fails (network, binary issues, permissions)
* - Logs stdout output when update succeeds
*/
if (err) logger.warn("yt-dlp update failed (continuing):", err.message); if (err) logger.warn("yt-dlp update failed (continuing):", err.message);
else logger.info(`yt-dlp update: ${stdout.trim()}`); else logger.info(`yt-dlp update: ${stdout.trim()}`);
}); });
} }

View File

@@ -3,41 +3,107 @@ const { app } = require("electron");
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const localMode = !app.isPackaged; /**
* Indicates whether the application is running in development mode.
* Determined by Electron's packaging state.
*/
const devMode = !app.isPackaged;
/**
* Resolves the configuration file path depending on runtime environment.
*
* In development:
* - Uses local config/config.dev.json
* - Creates it from default if missing
*
* In production:
* - Uses app userData/config.json
* - Creates it from bundled default config if missing
*
* @returns {string} Absolute path to the active configuration file
*/
function resolveConfigPath() { function resolveConfigPath() {
if (localMode) { if (devMode) {
const devConfigPath = path.join(__dirname, "config", "config.dev.json"); const devConfigPath = path.join(__dirname, "config", "config.dev.json");
if (!fs.existsSync(devConfigPath)) { if (!fs.existsSync(devConfigPath)) {
const defaultConfigPath = path.join(__dirname, "config", "config.default.json"); const defaultConfigPath = path.join(
__dirname,
"config",
"config.default.json"
);
fs.copyFileSync(defaultConfigPath, devConfigPath); fs.copyFileSync(defaultConfigPath, devConfigPath);
} }
return devConfigPath; return devConfigPath;
} }
const userConfigPath = path.join(app.getPath("userData"), "config.json"); const userConfigPath = path.join(app.getPath("userData"), "config.json");
if (!fs.existsSync(userConfigPath)) { if (!fs.existsSync(userConfigPath)) {
const defaultConfigPath = path.join(process.resourcesPath, "config", "config.default.json"); const defaultConfigPath = path.join(
process.resourcesPath,
"config",
"config.default.json"
);
fs.copyFileSync(defaultConfigPath, userConfigPath); fs.copyFileSync(defaultConfigPath, userConfigPath);
} }
return userConfigPath; return userConfigPath;
} }
/**
* Path to the active configuration file used at runtime.
*/
const featuresPath = resolveConfigPath(); const featuresPath = resolveConfigPath();
/**
* Loads and parses the feature configuration file.
*
* @returns {Object} Parsed feature flags configuration
* @throws {Error} If the configuration file is missing or invalid
*/
function loadFeatures() { function loadFeatures() {
const raw = fs.readFileSync(featuresPath, "utf-8"); const raw = fs.readFileSync(featuresPath, "utf-8");
return JSON.parse(raw); return JSON.parse(raw);
} }
/**
* In-memory snapshot of application feature flags.
*
* Note: Changes to the config file are not automatically reflected.
* A restart or reload is required.
*/
const configFeatures = loadFeatures(); const configFeatures = loadFeatures();
module.exports = { module.exports = {
version: packageJson.version, /**
* Application version from package.json
*/
version: devMode ? `dev-${packageJson.version}` : `v${packageJson.version}` ,
/**
* Backend Express / internal server port
*/
applicationPort: "8787", applicationPort: "8787",
localMode,
/**
* Indicates whether the app is running in development mode
*/
devMode,
/**
* Discord RPC application identifier
*/
DiscordRPCID: "1410934537051181146", DiscordRPCID: "1410934537051181146",
/**
* Runtime feature flags loaded from configuration file
*/
configFeatures, configFeatures,
featuresPath
} /**
* Path to the active configuration file
*/
featuresPath,
};

View File

@@ -13,5 +13,6 @@
"outputTitleCheck": true, "outputTitleCheck": true,
"downloadSystem": true, "downloadSystem": true,
"notifySystem": true, "notifySystem": true,
"enableHardwareAcceleration": true,
"theme": "dark" "theme": "dark"
} }

98
main.js
View File

@@ -1,3 +1,8 @@
/**
* Handle crash application and avoid ghost instance
*/
const { app } = require("electron");
process.on("uncaughtException", (err) => { process.on("uncaughtException", (err) => {
console.error("Uncaught exception:", err); console.error("Uncaught exception:", err);
app.quit(); app.quit();
@@ -8,44 +13,97 @@ process.on("unhandledRejection", (reason) => {
app.quit(); app.quit();
}); });
const { app } = require("electron");
const { createSplashWindow, closeSplashWindow, setSplashProgress } = require("./app/splashManager");
const path = require("path");
/**
* True if this is the primary instance (lock acquired successfully)
*/
const isPrimaryInstance = app.requestSingleInstanceLock();
/**
* If we are on a second instance
*/
if (!isPrimaryInstance) {
app.quit();
process.exit(0);
}
/**
* Set App Name and AppID
*/
app.setName("Freedom Loader");
app.setAppUserModelId("com.masteracnolo.freedomloader");
/**
* Load the app dependencies for hardware choice
*/
const { logger, logSessionStart, logSessionEnd } = require("./server/logger"); const { logger, logSessionStart, logSessionEnd } = require("./server/logger");
const { initAutoUpdater } = require("./app/autoUpdater");
const { startRPC, stopRPC } = require("./app/discordRPC");
const { configFeatures } = require("./config"); const { configFeatures } = require("./config");
/**
* On start, enable or disable hardWareAcceleration
*/
if (!configFeatures.enableHardwareAcceleration){
app.disableHardwareAcceleration();
logger.info("Disabled Hardware Acceleration")
} else {
logger.info("Enable Hardware Acceleration")
}
/**
* In-memory snapshot of application feature flags.
*
* Note: Changes to the config file are not automatically reflected.
* A restart could be required.
*/
const config = require("./config"); const config = require("./config");
if(config.devMode){
/**
* Start devTron extensions - @see https://github.com/electron/devtron
*/
const { devtron } = require('@electron/devtron');
devtron.install();
logger.info("Loaded DevTron Extension")
}
/**
* Load the app dependencies
*/
const { initThemes } = require("./app/themeManager"); const { initThemes } = require("./app/themeManager");
const { initAutoUpdater } = require("./app/autoUpdater");
const { startRPC, stopRPC } = require("./app/discordRPC");
const { checkNativeDependencies } = require("./app/dependencyCheck"); const { checkNativeDependencies } = require("./app/dependencyCheck");
const { updateYtDlp } = require("./app/ytDlpUpdater"); const { updateYtDlp } = require("./app/ytDlpUpdater");
const { createMainWindow, getMainWindow } = require("./app/windowManager"); const { createMainWindow, getMainWindow } = require("./app/windowManager");
const { registerIpcHandlers } = require("./app/ipcHandlers"); const { registerIpcHandlers } = require("./app/ipcHandlers");
const { createSplashWindow, closeSplashWindow, setSplashProgress } = require("./app/splashManager");
const { userThemesPath, initUserThemes } = require("./server/helpers/path.helpers"); const { userThemesPath, initUserThemes } = require("./server/helpers/path.helpers");
app.setName("Freedom Loader");
app.setAppUserModelId("com.masteracnolo.freedomloader");
app.disableHardwareAcceleration();
if (!config.localMode) { /**
const gotLock = app.requestSingleInstanceLock(); * If another instance want to run
if (gotLock) { */
app.on("second-instance", () => { app.on("second-instance", () => {
logger.info("New instance detected, closing older..."); logger.info("Second instance detected");
getMainWindow()?.destroy();
}); const mainWindow = require("./app/windowManager").getMainWindow();
}
} if (!mainWindow) return;
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
});
app.whenReady().then(async () => { app.whenReady().then(async () => {
logSessionStart(); logSessionStart();
createSplashWindow(); createSplashWindow();
if (!config.localMode && !checkNativeDependencies()) return; if (!config.devMode && !checkNativeDependencies()) return;
const { userYtDlp } = require("./server/helpers/path.helpers"); const { userYtDlp } = require("./server/helpers/path.helpers");
updateYtDlp(userYtDlp); updateYtDlp(userYtDlp);
@@ -64,8 +122,6 @@ app.whenReady().then(async () => {
setSplashProgress(3); // Almost ready setSplashProgress(3); // Almost ready
await createMainWindow(); await createMainWindow();
await new Promise(resolve => setTimeout(resolve, 2000));
closeSplashWindow(); closeSplashWindow();
getMainWindow().show(); getMainWindow().show();
if (configFeatures.discordRPC) startRPC(); if (configFeatures.discordRPC) startRPC();

393
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "freedom-loader", "name": "freedom-loader",
"version": "1.5.0", "version": "1.5.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "freedom-loader", "name": "freedom-loader",
"version": "1.5.0", "version": "1.5.1",
"dependencies": { "dependencies": {
"chalk": "^4.1.2", "chalk": "^4.1.2",
"debug": "^4.4.1", "debug": "^4.4.1",
@@ -20,10 +20,21 @@
"winston-daily-rotate-file": "^5.0.0" "winston-daily-rotate-file": "^5.0.0"
}, },
"devDependencies": { "devDependencies": {
"@electron/devtron": "^2.1.1",
"electron": "^41.2.0", "electron": "^41.2.0",
"electron-builder": "^26.8.1" "electron-builder": "^26.8.1"
} }
}, },
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@colors/colors": { "node_modules/@colors/colors": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz",
@@ -111,6 +122,27 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/@electron/devtron": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@electron/devtron/-/devtron-2.1.1.tgz",
"integrity": "sha512-hiNj9faRQJYVJ69TQyJ2+BWlMAc97xwuaol1XhRb2PM4FYn7ze569kOxycQodZlEq9Sim+9yu0TxFu7//Oagxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@fontsource-variable/roboto": "^5.2.5",
"@fontsource/space-mono": "^5.2.5",
"@microlink/react-json-view": "^1.26.2",
"ag-grid-community": "^34.1.2",
"ag-grid-react": "^34.1.2",
"denque": "^2.1.0",
"lucide-react": "^0.513.0",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"engines": {
"node": ">=22.12.0"
}
},
"node_modules/@electron/fuses": { "node_modules/@electron/fuses": {
"version": "1.8.0", "version": "1.8.0",
"resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-1.8.0.tgz", "resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-1.8.0.tgz",
@@ -511,6 +543,26 @@
"node": ">= 10.0.0" "node": ">= 10.0.0"
} }
}, },
"node_modules/@fontsource-variable/roboto": {
"version": "5.2.10",
"resolved": "https://registry.npmjs.org/@fontsource-variable/roboto/-/roboto-5.2.10.tgz",
"integrity": "sha512-LJ0iLg6aHbLzN515gyHzmdTqJzd9NlI95cCg1DW0F5G7KkFqRLBBKBbqEJx4nSu4aby3IKmw3ZH6Fe928IfaSQ==",
"dev": true,
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/space-mono": {
"version": "5.2.9",
"resolved": "https://registry.npmjs.org/@fontsource/space-mono/-/space-mono-5.2.9.tgz",
"integrity": "sha512-b61faFOHEISQ/pD25G+cfGY9o/WW6lRv6hBQQfpWvEJ4y1V+S4gmth95EVyBE2VL3qDYHeVQ8nBzrplzdXTDDg==",
"dev": true,
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@isaacs/cliui": { "node_modules/@isaacs/cliui": {
"version": "8.0.2", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -705,6 +757,25 @@
"node": ">= 10.0.0" "node": ">= 10.0.0"
} }
}, },
"node_modules/@microlink/react-json-view": {
"version": "1.31.20",
"resolved": "https://registry.npmjs.org/@microlink/react-json-view/-/react-json-view-1.31.20.tgz",
"integrity": "sha512-gNLkGvjFDeAqVGvK3H7lfoDqetn/9lW2ugiYiJhchc7jQU1ZaKsZnt97ANluXWFfd/wifoA9TrVOTsUXwXCJwA==",
"dev": true,
"license": "MIT",
"dependencies": {
"react-base16-styling": "~0.10.0",
"react-lifecycles-compat": "~3.0.4",
"react-textarea-autosize": "~8.5.9"
},
"engines": {
"node": ">=17"
},
"peerDependencies": {
"react": ">= 15",
"react-dom": ">= 15"
}
},
"node_modules/@npmcli/agent": { "node_modules/@npmcli/agent": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-3.0.0.tgz", "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-3.0.0.tgz",
@@ -852,6 +923,13 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/lodash": {
"version": "4.17.24",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz",
"integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/ms": { "node_modules/@types/ms": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
@@ -917,9 +995,9 @@
} }
}, },
"node_modules/@xmldom/xmldom": { "node_modules/@xmldom/xmldom": {
"version": "0.8.12", "version": "0.8.13",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
"integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==", "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -956,6 +1034,38 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/ag-charts-types": {
"version": "12.3.1",
"resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-12.3.1.tgz",
"integrity": "sha512-5216xYoawnvMXDFI6kTpPku+mH0Csiwu/FE7lsAm8Z22HEN6ciSG/V7g+IrpLWncELqksgENebCTP75PZ3CsHA==",
"dev": true,
"license": "MIT"
},
"node_modules/ag-grid-community": {
"version": "34.3.1",
"resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-34.3.1.tgz",
"integrity": "sha512-PwlrPudsFOzGumphi2y9ihWeaUlIwKhOra/MXu2LjeV2U8DgLLcYS8CartE5Hszhn1poJHawwI9HWrxlKliwdw==",
"dev": true,
"license": "MIT",
"dependencies": {
"ag-charts-types": "12.3.1"
}
},
"node_modules/ag-grid-react": {
"version": "34.3.1",
"resolved": "https://registry.npmjs.org/ag-grid-react/-/ag-grid-react-34.3.1.tgz",
"integrity": "sha512-1UTlBT+xJkjNZAuf7RxK61mgxKGTPB+6XR99oIHq7cYC89kJmLbWqhHt/1XqRWF5cAgSKk8u+HtOQaN8tAZStw==",
"dev": true,
"license": "MIT",
"dependencies": {
"ag-grid-community": "34.3.1",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/agent-base": { "node_modules/agent-base": {
"version": "7.1.4", "version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@@ -1355,9 +1465,9 @@
"optional": true "optional": true
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "5.0.5", "version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1986,6 +2096,13 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -2103,6 +2220,16 @@
"node": ">=0.4.0" "node": ">=0.4.0"
} }
}, },
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=0.10"
}
},
"node_modules/depd": { "node_modules/depd": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -2794,12 +2921,12 @@
} }
}, },
"node_modules/express-rate-limit": { "node_modules/express-rate-limit": {
"version": "8.3.2", "version": "8.5.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz",
"integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", "integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ip-address": "10.1.0" "ip-address": "^10.2.0"
}, },
"engines": { "engines": {
"node": ">= 16" "node": ">= 16"
@@ -3549,9 +3676,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/ip-address": { "node_modules/ip-address": {
"version": "10.1.0", "version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 12" "node": ">= 12"
@@ -3566,6 +3693,13 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/is-arrayish": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
"dev": true,
"license": "MIT"
},
"node_modules/is-fullwidth-code-point": { "node_modules/is-fullwidth-code-point": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
@@ -3690,6 +3824,13 @@
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
}, },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
@@ -3827,6 +3968,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash-es": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash.escaperegexp": { "node_modules/lodash.escaperegexp": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz",
@@ -3874,6 +4022,19 @@
"node": ">= 12.0.0" "node": ">= 12.0.0"
} }
}, },
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/lowercase-keys": { "node_modules/lowercase-keys": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
@@ -3897,6 +4058,16 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/lucide-react": {
"version": "0.513.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.513.0.tgz",
"integrity": "sha512-CJZKq2g8Y8yN4Aq002GahSXbG2JpFv9kXwyiOAMvUBv7pxeOFHUWKB0mO7MiY4ZVFCV4aNjv2BJFq/z3DgKPQg==",
"dev": true,
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/make-fetch-happen": { "node_modules/make-fetch-happen": {
"version": "14.0.3", "version": "14.0.3",
"resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-14.0.3.tgz", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-14.0.3.tgz",
@@ -4361,6 +4532,16 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-hash": { "node_modules/object-hash": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
@@ -4705,6 +4886,18 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/proper-lockfile": { "node_modules/proper-lockfile": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
@@ -4752,9 +4945,9 @@
} }
}, },
"node_modules/qs": { "node_modules/qs": {
"version": "6.15.0", "version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"side-channel": "^1.1.0" "side-channel": "^1.1.0"
@@ -4819,6 +5012,99 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/react": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-base16-styling": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/react-base16-styling/-/react-base16-styling-0.10.0.tgz",
"integrity": "sha512-H1k2eFB6M45OaiRru3PBXkuCcn2qNmx+gzLb4a9IPMR7tMH8oBRXU5jGbPDYG1Hz+82d88ED0vjR8BmqU3pQdg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/lodash": "^4.17.0",
"color": "^4.2.3",
"csstype": "^3.1.3",
"lodash-es": "^4.17.21"
}
},
"node_modules/react-base16-styling/node_modules/color": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1",
"color-string": "^1.9.0"
},
"engines": {
"node": ">=12.5.0"
}
},
"node_modules/react-base16-styling/node_modules/color-string": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "^1.0.0",
"simple-swizzle": "^0.2.2"
}
},
"node_modules/react-dom": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.7"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT"
},
"node_modules/react-lifecycles-compat": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==",
"dev": true,
"license": "MIT"
},
"node_modules/react-textarea-autosize": {
"version": "8.5.9",
"resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz",
"integrity": "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.20.13",
"use-composed-ref": "^1.3.0",
"use-latest": "^1.2.1"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/read-binary-file-arch": { "node_modules/read-binary-file-arch": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz",
@@ -5033,6 +5319,13 @@
"node": ">=11.0.0" "node": ">=11.0.0"
} }
}, },
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"dev": true,
"license": "MIT"
},
"node_modules/semver": { "node_modules/semver": {
"version": "6.3.1", "version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@@ -5227,6 +5520,16 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/simple-swizzle": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
"integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-arrayish": "^0.3.1"
}
},
"node_modules/simple-update-notifier": { "node_modules/simple-update-notifier": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
@@ -5613,9 +5916,9 @@
} }
}, },
"node_modules/tmp": { "node_modules/tmp": {
"version": "0.2.5", "version": "0.2.7",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
"integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -5756,6 +6059,54 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/use-composed-ref": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.4.0.tgz",
"integrity": "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-isomorphic-layout-effect": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz",
"integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-latest": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/use-latest/-/use-latest-1.3.0.tgz",
"integrity": "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"use-isomorphic-layout-effect": "^1.1.1"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/utf8-byte-length": { "node_modules/utf8-byte-length": {
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz",

View File

@@ -1,7 +1,7 @@
{ {
"name": "freedom-loader", "name": "freedom-loader",
"productName": "Freedom Loader", "productName": "Freedom Loader",
"version": "1.5.0", "version": "1.5.1",
"author": "MasterAcnolo", "author": "MasterAcnolo",
"description": "Freedom Loader", "description": "Freedom Loader",
"main": "main.js", "main": "main.js",
@@ -24,7 +24,9 @@
}, },
"devDependencies": { "devDependencies": {
"electron": "^41.2.0", "electron": "^41.2.0",
"electron-builder": "^26.8.1" "electron-builder": "^26.8.1",
"@electron/devtron": "^2.1.1"
}, },
"build": { "build": {
"appId": "com.masteracnolo.freedomloader", "appId": "com.masteracnolo.freedomloader",

View File

@@ -1,27 +1,124 @@
const { contextBridge, ipcRenderer } = require("electron"); const { contextBridge, ipcRenderer } = require("electron");
/**
* Exposes the main application API to the renderer process.
*
* This API acts as a secure bridge between the renderer and the
* Electron main process using IPC (Inter-Process Communication).
* All calls are explicitly whitelisted.
*/
contextBridge.exposeInMainWorld("electronAPI", { contextBridge.exposeInMainWorld("electronAPI", {
/**
* Returns the default system download directory.
*/
getDefaultDownloadPath: () => ipcRenderer.invoke("get-default-download-path"), getDefaultDownloadPath: () => ipcRenderer.invoke("get-default-download-path"),
/**
* Opens a native dialog allowing the user to select a download folder.
*/
selectDownloadFolder: () => ipcRenderer.invoke("select-download-folder"), selectDownloadFolder: () => ipcRenderer.invoke("select-download-folder"),
/**
* Sends download progress value to the main process (used for taskbar / UI sync).
*
* @param {number} percent - Download progress percentage (0100)
*/
setProgress: (percent) => ipcRenderer.send("set-progress", percent), setProgress: (percent) => ipcRenderer.send("set-progress", percent),
/**
* Retrieves application feature flags (runtime configuration).
*/
getFeatures: () => ipcRenderer.invoke("features"), getFeatures: () => ipcRenderer.invoke("features"),
setFeature: (key, value) => ipcRenderer.invoke("set-feature", { key, value }),
/**
* Updates a feature flag dynamically at runtime.
*
* @param {string} key - Feature name
* @param {any} value - Feature value
*/
setFeature: (key, value) =>
ipcRenderer.invoke("set-feature", { key, value }),
/**
* Returns the current application version.
*/
getVersion: () => ipcRenderer.invoke("version"), getVersion: () => ipcRenderer.invoke("version"),
getValidatedDownloadPath: (path) => ipcRenderer.invoke("validate-download-path", path),
/**
* Validates a download path before using it for file operations.
*
* @param {string} path - Path to validate
*/
getValidatedDownloadPath: (path) =>
ipcRenderer.invoke("validate-download-path", path),
/**
* Retrieves available themes from the filesystem or config layer.
*/
getThemes: () => ipcRenderer.invoke("get-themes"), getThemes: () => ipcRenderer.invoke("get-themes"),
reloadThemes: () => ipcRenderer.invoke("reload-themes")
/**
* Forces a reload of theme files (useful after modification/import).
*/
reloadThemes: () => ipcRenderer.invoke("reload-themes"),
}); });
// Contrôles de fenêtre et outils custom pour la topbar
/**
* Exposes window control and developer utilities for the custom topbar UI.
*
* These methods forward commands to the Electron main process via IPC.
*/
contextBridge.exposeInMainWorld("topbarAPI", { contextBridge.exposeInMainWorld("topbarAPI", {
/**
* Minimizes the application window.
*/
minimize: () => ipcRenderer.send("window-minimize"), minimize: () => ipcRenderer.send("window-minimize"),
/**
* Toggles maximize/restore state of the application window.
*/
maximize: () => ipcRenderer.send("window-maximize"), maximize: () => ipcRenderer.send("window-maximize"),
/**
* Closes the application window.
*/
close: () => ipcRenderer.send("window-close"), close: () => ipcRenderer.send("window-close"),
/**
* Opens Chromium DevTools for debugging.
*/
openDevTools: () => ipcRenderer.send("open-devtools"), openDevTools: () => ipcRenderer.send("open-devtools"),
/**
* Opens application logs directory or log viewer.
*/
openLogs: () => ipcRenderer.send("open-logs"), openLogs: () => ipcRenderer.send("open-logs"),
/**
* Opens the official website or project homepage.
*/
openWebsite: () => ipcRenderer.send("open-website"), openWebsite: () => ipcRenderer.send("open-website"),
/**
* Opens the theme directory or theme editor interface.
*/
openTheme: () => ipcRenderer.send("open-theme"), openTheme: () => ipcRenderer.send("open-theme"),
/**
* Opens project wiki/documentation.
*/
openWiki: () => ipcRenderer.send("open-wiki"), openWiki: () => ipcRenderer.send("open-wiki"),
/**
* Opens the workshop or external content library.
*/
openWorkshop: () => ipcRenderer.send("open-workshop"), openWorkshop: () => ipcRenderer.send("open-workshop"),
openConfig: () => ipcRenderer.send("open-config")
/**
* Opens configuration/settings panel.
*/
openConfig: () => ipcRenderer.send("open-config"),
}); });

View File

@@ -175,28 +175,20 @@
</div> </div>
</div> </div>
<div class="setting-item">
<input type="checkbox" data-key="enableHardwareAcceleration" id="enableHardwareAcceleration">
<div class="setting-info">
<span class="setting-title">Enable Hardware Acceleration</span>
<small>Uses GPU acceleration for rendering when available. <br> (Be effective on next launch)</small>
</div>
</div>
</details> </details>
<!-- Developer Settings --> <!-- Developer Settings -->
<details class="settings-section"> <details class="settings-section">
<summary>Developer Settings</summary> <summary>Developer Settings</summary>
<!-- <div class="setting-item">
<input type="checkbox" data-key="downloadSystem" id="downloadSystem">
<div class="setting-info">
<span class="setting-title">Download System</span>
<small>Enable/disable the internal download system.</small>
</div>
</div> -->
<!-- <div class="setting-item">
<input type="checkbox" data-key="logSystem" id="logSystem">
<div class="setting-info">
<span class="setting-title">Log System</span>
<small>Enable logging system.</small>
</div>
</div> -->
<div class="setting-item"> <div class="setting-item">
<input type="checkbox" data-key="verboseLogs" id="verboseLogs"> <input type="checkbox" data-key="verboseLogs" id="verboseLogs">
<div class="setting-info"> <div class="setting-info">

View File

@@ -1,8 +1,21 @@
async function versionLabel(){ /**
* Retrieves the application version from the Electron main process
* and displays it in the UI version badge (bottom-right corner).
*
* This is mainly used for debugging and build identification.
*/
async function versionLabel() {
const appVersion = await window.electronAPI.getVersion(); const appVersion = await window.electronAPI.getVersion();
// Write in front the app version for debugging (bottom right) /**
document.getElementById("version-badge").textContent = `v${appVersion}`; * UI element displaying the current application version.
}; * Updated at runtime after IPC call resolves.
*/
const versionBadge = document.getElementById("version-badge");
if (!versionBadge) return;
versionBadge.textContent = `${appVersion}`;
}
versionLabel(); versionLabel();

View File

@@ -1,35 +1,42 @@
// Clipboard paste functionality for URL input /**
document.addEventListener('DOMContentLoaded', () => { * Initializes clipboard paste functionality for the URL input field.
const pasteBtn = document.getElementById('pasteBtn'); *
const urlInput = document.getElementById('UrlInput'); * When the user clicks the paste button, the system attempts to read
* the clipboard content and inject it into the URL input field.
* It also triggers an input event to notify any bound listeners.
*/
document.addEventListener("DOMContentLoaded", () => {
const pasteBtn = document.getElementById("pasteBtn");
const urlInput = document.getElementById("UrlInput");
if (pasteBtn && urlInput) { if (!pasteBtn || !urlInput) return;
pasteBtn.addEventListener('click', async () => {
/**
* Handles clipboard paste action triggered by the paste button.
* Reads clipboard text and inserts it into the URL input field.
*/
pasteBtn.addEventListener("click", async () => {
try { try {
// Read text from clipboard
const text = await navigator.clipboard.readText(); const text = await navigator.clipboard.readText();
// Paste into the URL input
urlInput.value = text; urlInput.value = text;
// Trigger input event to ensure any listeners are notified // Notify frameworks / listeners bound to input changes
urlInput.dispatchEvent(new Event('input', { bubbles: true })); urlInput.dispatchEvent(new Event("input", { bubbles: true }));
// Optional: Focus the input
urlInput.focus(); urlInput.focus();
// Visual feedback // Temporary UI feedback on paste action
pasteBtn.style.backgroundColor = 'var(--form-button-bg-hover-color)'; pasteBtn.style.backgroundColor = "var(--form-button-bg-hover-color)";
setTimeout(() => { setTimeout(() => {
pasteBtn.style.backgroundColor = ''; pasteBtn.style.backgroundColor = "";
}, 200); }, 200);
} catch (err) { } catch (err) {
console.error('Failed to read clipboard:', err); console.error("Clipboard access failed:", err);
// Fallback: prompt user if clipboard API fails alert(
alert('Unable to access clipboard. Please paste manually using Ctrl+V.'); "Unable to access clipboard. Please paste manually using Ctrl+V."
);
} }
}); });
}
}); });

View File

@@ -1,9 +1,24 @@
/**
* Initializes the download path system once the UI is ready.
*
* Responsibilities:
* - Restore last known user-selected download path (localStorage)
* - Validate it against backend rules
* - Synchronize with backend authoritative path
* - Setup folder selection UI interaction
*/
window.addEventListener("DOMContentLoaded", async () => { window.addEventListener("DOMContentLoaded", async () => {
const savePathElem = document.getElementById("savePath"); const savePathElem = document.getElementById("savePath");
const form = document.getElementById("downloadForm"); const form = document.getElementById("downloadForm");
// input caché = DERNIER chemin validé par le back /**
* Hidden form field storing the validated download path
* used during download submission.
*
* Created dynamically if not already present in DOM.
*/
let hidden = document.getElementById("savePathInput"); let hidden = document.getElementById("savePathInput");
if (!hidden) { if (!hidden) {
hidden = document.createElement("input"); hidden = document.createElement("input");
hidden.type = "hidden"; hidden.type = "hidden";
@@ -12,6 +27,17 @@ window.addEventListener("DOMContentLoaded", async () => {
form.appendChild(hidden); form.appendChild(hidden);
} }
/**
* Applies a validated download path received from backend.
*
* Updates:
* - UI display (visible path)
* - tooltip (full path)
* - hidden form input (used for submission)
* - localStorage cache (UX persistence only, not authoritative)
*
* @param {string} path - validated absolute download path
*/
async function applyPathFromBack(path) { async function applyPathFromBack(path) {
savePathElem.textContent = path; savePathElem.textContent = path;
savePathElem.title = path; savePathElem.title = path;
@@ -19,6 +45,14 @@ window.addEventListener("DOMContentLoaded", async () => {
localStorage.setItem("customDownloadPath", path); // UX only localStorage.setItem("customDownloadPath", path); // UX only
} }
/**
* Loads the initial download path on application startup.
*
* Priority order:
* 1. cached localStorage value (UX only, not trusted)
* 2. backend validated version of cached path
* 3. backend default fallback path if validation fails
*/
async function loadInitialPath() { async function loadInitialPath() {
// On affiche ce que le user a vu la dernière fois (UX) // On affiche ce que le user a vu la dernière fois (UX)
const cached = localStorage.getItem("customDownloadPath"); const cached = localStorage.getItem("customDownloadPath");
@@ -42,13 +76,16 @@ window.addEventListener("DOMContentLoaded", async () => {
await loadInitialPath(); await loadInitialPath();
document /**
.getElementById("changePath") * Opens system folder selector and updates download path.
.addEventListener("click", async () => { *
* The selected folder is validated by Electron backend
* before being applied to the UI and persisted.
*/
document.getElementById("changePath").addEventListener("click", async () => {
try { try {
// selectDownloadFolder already returns a validated path // selectDownloadFolder already returns a validated path
const validatedPath = const validatedPath = await window.electronAPI.selectDownloadFolder();
await window.electronAPI.selectDownloadFolder();
if (!validatedPath) return; // cancelled if (!validatedPath) return; // cancelled

View File

@@ -1,6 +1,22 @@
const themeSelect = document.getElementById("themeSelect"); const themeSelect = document.getElementById("themeSelect");
/**
* Currently selected themes loaded from backend.
* Used as cache for UI theme switching.
*/
let loadedThemes = []; let loadedThemes = [];
/**
* Applies a theme object to the application UI by mapping
* theme configuration values to CSS variables and DOM properties.
*
* This function mutates:
* - document.body (background styling)
* - :root CSS variables (design system tokens)
* - subtitle DOM element (theme metadata display)
*
* @param {Object} theme - Theme definition object
* @param {Object} theme.style - Style configuration
*/
function applyTheme(theme) { function applyTheme(theme) {
const root = document.documentElement; const root = document.documentElement;
const style = theme.style; const style = theme.style;
@@ -15,72 +31,75 @@ function applyTheme(theme) {
document.body.style.backgroundImage = ""; document.body.style.backgroundImage = "";
} }
// Colors // CSS variables (design tokens)
root.style.setProperty("--background-color", style.colors?.background || ""); root.style.setProperty("--background-color", style.colors?.background || "#121212");
root.style.setProperty("--default-text-color", style.colors?.text?.default || ""); root.style.setProperty("--default-text-color", style.colors?.text?.default || "#e0e0e0");
root.style.setProperty("--subtitle-color", style.colors?.text?.subtitle || ""); root.style.setProperty("--subtitle-color", style.colors?.text?.subtitle || "#2196f3");
root.style.setProperty("--audio-only-label-color", style.colors?.text?.audioOnly || ""); root.style.setProperty("--audio-only-label-color", style.colors?.text?.audioOnly || "#f5f5f5");
// Form root.style.setProperty("--form-bg-color", style.form?.background || "#1e1e1e");
root.style.setProperty("--form-bg-color", style.form?.background || ""); root.style.setProperty("--form-input-bg-color", style.form?.input?.background || "#2a2a2a");
root.style.setProperty("--form-input-bg-color", style.form?.input?.background || ""); root.style.setProperty("--form-input-border-color", style.form?.input?.border || "#444444");
root.style.setProperty("--form-input-border-color", style.form?.input?.border || ""); root.style.setProperty("--form-input-border-focus-color", style.form?.input?.borderFocus || "#2196f3");
root.style.setProperty("--form-input-border-focus-color", style.form?.input?.borderFocus || ""); root.style.setProperty("--form-input-text-color", style.form?.input?.text || "#f5f5f5");
root.style.setProperty("--form-input-text-color", style.form?.input?.text || ""); root.style.setProperty("--form-input-placeholder-color", style.form?.input?.placeholder || "#888888");
root.style.setProperty("--form-input-placeholder-color", style.form?.input?.placeholder || ""); root.style.setProperty("--form-button-bg-color", style.form?.button?.background || "#2196f3");
root.style.setProperty("--form-button-bg-color", style.form?.button?.background || ""); root.style.setProperty("--form-button-text-color", style.form?.button?.text || "#ffffff");
root.style.setProperty("--form-button-text-color", style.form?.button?.text || ""); root.style.setProperty("--form-button-bg-hover-color", style.form?.button?.hover || "#1976d2");
root.style.setProperty("--form-button-bg-hover-color", style.form?.button?.hover || ""); root.style.setProperty("--paste-button-icon-color", style.form?.pasteButtonIcon || "#ffffff");
root.style.setProperty("--paste-button-icon-color", style.form?.pasteButtonIcon || "");
// Checkbox root.style.setProperty("--checkbox-unchecked-bg-color", style.checkbox?.background?.unchecked || "#555555");
root.style.setProperty("--checkbox-unchecked-bg-color", style.checkbox?.background?.unchecked || ""); root.style.setProperty("--checkbox-checked-bg-color", style.checkbox?.background?.checked || "#2196f3");
root.style.setProperty("--checkbox-checked-bg-color", style.checkbox?.background?.checked || ""); root.style.setProperty("--checkbox-checkmark-border-color", style.checkbox?.checkmarkBorder || "#ffffff");
root.style.setProperty("--checkbox-checkmark-border-color", style.checkbox?.checkmarkBorder || ""); root.style.setProperty("--checkbox-pulse-color", style.checkbox?.pulse || "rgba(33, 150, 243, 0.5)");
root.style.setProperty("--checkbox-pulse-color", style.checkbox?.pulse || "");
// Download root.style.setProperty("--download-status-color", style.download?.status || "#2196f3");
root.style.setProperty("--download-status-color", style.download?.status || "");
// Progress bar root.style.setProperty("--progress-bar-bg-color", style.progressBar?.background || "#444444");
root.style.setProperty("--progress-bar-bg-color", style.progressBar?.background || ""); root.style.setProperty("--progress-bar-fill-color", style.progressBar?.fill || "#2196f3");
root.style.setProperty("--progress-bar-fill-color", style.progressBar?.fill || "");
// Video info root.style.setProperty("--video-info-bg-color", style.videoInfo?.background || "#1a1a1a");
root.style.setProperty("--video-info-bg-color", style.videoInfo?.background || ""); root.style.setProperty("--video-info-text-color", style.videoInfo?.text || "#e0e0e0");
root.style.setProperty("--video-info-text-color", style.videoInfo?.text || ""); root.style.setProperty("--video-info-heading-color", style.videoInfo?.heading || "#2196f3");
root.style.setProperty("--video-info-heading-color", style.videoInfo?.heading || ""); root.style.setProperty("--video-info-list-color", style.videoInfo?.list || "#cccccc");
root.style.setProperty("--video-info-list-color", style.videoInfo?.list || ""); root.style.setProperty("--video-info-list-strong-color", style.videoInfo?.strong || "#ffffff");
root.style.setProperty("--video-info-list-strong-color", style.videoInfo?.strong || ""); root.style.setProperty("--video-info-link-color", style.videoInfo?.link || "#2196f3");
root.style.setProperty("--video-info-link-color", style.videoInfo?.link || ""); root.style.setProperty("--video-info-link-hover-color", style.videoInfo?.linkHover || "#1976d2");
root.style.setProperty("--video-info-link-hover-color", style.videoInfo?.linkHover || "");
// Playlist root.style.setProperty("--playlist-background-color", style.playlist?.background || "#303030");
root.style.setProperty("--playlist-background-color", style.playlist?.background || "");
// Settings root.style.setProperty("--settings-button-bg-color", style.settings?.button?.background || "#1e1e1e");
root.style.setProperty("--settings-button-bg-color", style.settings?.button?.background || ""); root.style.setProperty("--settings-button-text-color", style.settings?.button?.text || "#e0e0e0");
root.style.setProperty("--settings-button-text-color", style.settings?.button?.text || ""); root.style.setProperty("--open-theme-button-bg-color", style.settings?.openThemeButton?.background || "#2196f3");
root.style.setProperty("--open-theme-button-bg-color", style.settings?.openThemeButton?.background || ""); root.style.setProperty("--open-theme-button-text-color", style.settings?.openThemeButton?.text || "#ffffff");
root.style.setProperty("--open-theme-button-text-color", style.settings?.openThemeButton?.text || ""); root.style.setProperty("--open-json-button-bg-color", style.settings?.openJsonButton?.background || style.settings?.button?.background || "#1e1e1e");
root.style.setProperty("--open-json-button-bg-color", style.settings?.openJsonButton?.background || style.settings?.button?.background || ""); root.style.setProperty("--open-json-button-text-color", style.settings?.openJsonButton?.text || style.settings?.button?.text || "#e0e0e0");
root.style.setProperty("--open-json-button-text-color", style.settings?.openJsonButton?.text || style.settings?.button?.text || ""); root.style.setProperty("--settings-modal-bg-color", style.settings?.background?.modal || "#1e1e1e");
root.style.setProperty("--settings-modal-bg-color", style.settings?.background?.modal || ""); root.style.setProperty("--settings-section-bg-color", style.settings?.background?.section || "#2a2a2a");
root.style.setProperty("--settings-section-bg-color", style.settings?.background?.section || ""); root.style.setProperty("--settings-text-color", style.settings?.text || "#e0e0e0");
root.style.setProperty("--settings-text-color", style.settings?.text || ""); root.style.setProperty("--settings-subtitle-color", style.settings?.subtitle || "#aaaaaa");
root.style.setProperty("--settings-subtitle-color", style.settings?.subtitle || "");
// Subtitle
const subtitleEl = document.getElementById("subtitle"); const subtitleEl = document.getElementById("subtitle");
if (subtitleEl) subtitleEl.textContent = theme.subtitle || theme.name; if (subtitleEl) subtitleEl.textContent = theme.subtitle || theme.name;
} }
/**
* Persists selected theme ID into Electron backend features storage.
*
* @param {string} themeId
*/
function saveTheme(themeId) { function saveTheme(themeId) {
window.electronAPI.setFeature("theme", themeId); window.electronAPI.setFeature("theme", themeId);
} }
/**
* Populates theme dropdown selector with available themes.
*
* @param {Array} themes
*/
function populateThemeSelect(themes) { function populateThemeSelect(themes) {
themeSelect.innerHTML = ""; themeSelect.innerHTML = "";
for (const theme of themes) { for (const theme of themes) {
const option = document.createElement("option"); const option = document.createElement("option");
option.value = theme.id; option.value = theme.id;
@@ -89,35 +108,143 @@ function populateThemeSelect(themes) {
} }
} }
/**
* Initializes theme system:
* - loads themes from backend
* - restores saved theme
* - applies selected theme
*/
async function initThemes() { async function initThemes() {
loadedThemes = await window.electronAPI.getThemes(); loadedThemes = await window.electronAPI.getThemes();
if (!loadedThemes.length) return; if (!loadedThemes.length) {
const defaultTheme = {
id: "dark",
name: "Dark",
subtitle: "Darkness is my ally",
style: {
background: {
size: "cover",
position: "center",
attachment: "fixed"
},
colors: {
background: "#121212",
text: {
default: "#e0e0e0",
subtitle: "#2196f3",
audioOnly: "#f5f5f5"
}
},
form: {
background: "#1e1e1e",
input: {
background: "#2a2a2a",
border: "#444444",
borderFocus: "#2196f3",
text: "#f5f5f5",
placeholder: "#888888"
},
button: {
background: "#2196f3",
text: "#ffffff",
hover: "#1976d2"
},
pasteButtonIcon: "#ffffff"
},
checkbox: {
background: {
unchecked: "#555555",
checked: "#2196f3"
},
checkmarkBorder: "#ffffff",
pulse: "rgba(33, 150, 243, 0.5)"
},
download: {
status: "#2196f3"
},
progressBar: {
background: "#444444",
fill: "#2196f3"
},
videoInfo: {
background: "#1a1a1a",
text: "#e0e0e0",
heading: "#2196f3",
list: "#cccccc",
strong: "#ffffff",
link: "#2196f3",
linkHover: "#1976d2"
},
playlist: {
background: "#303030"
},
settings: {
button: {
background: "#1e1e1e",
text: "#e0e0e0"
},
openThemeButton: {
background: "#2196f3",
text: "#ffffff"
},
openJsonButton: {
background: "#2196f3",
text: "#ffffff"
},
background: {
modal: "#1e1e1e",
section: "#2a2a2a"
},
text: "#e0e0e0",
subtitle: "#aaaaaa"
}
}
};
loadedThemes = [defaultTheme];
}
populateThemeSelect(loadedThemes); populateThemeSelect(loadedThemes);
const features = await window.electronAPI.getFeatures(); const features = await window.electronAPI.getFeatures();
const savedId = features.theme; const savedId = features.theme;
const theme = loadedThemes.find(t => t.id === savedId) || loadedThemes[0]; const theme = loadedThemes.find(t => t.id === savedId) || loadedThemes[0];
themeSelect.value = theme.id; themeSelect.value = theme.id;
applyTheme(theme); applyTheme(theme);
} }
/**
* Handles theme selection change from UI dropdown.
*/
themeSelect.addEventListener("change", (e) => { themeSelect.addEventListener("change", (e) => {
const theme = loadedThemes.find(t => t.id === e.target.value); const theme = loadedThemes.find(t => t.id === e.target.value);
if (theme) { if (!theme) return;
applyTheme(theme); applyTheme(theme);
saveTheme(theme.id); saveTheme(theme.id);
}
}); });
/**
* Reloads themes from backend and reapplies current selection.
*/
async function refreshThemes() { async function refreshThemes() {
loadedThemes = await window.electronAPI.reloadThemes(); loadedThemes = await window.electronAPI.reloadThemes();
populateThemeSelect(loadedThemes); populateThemeSelect(loadedThemes);
const features = await window.electronAPI.getFeatures(); const features = await window.electronAPI.getFeatures();
const savedId = features.theme; const savedId = features.theme;
const theme = loadedThemes.find(t => t.id === savedId) || loadedThemes[0]; const theme = loadedThemes.find(t => t.id === savedId) || loadedThemes[0];
themeSelect.value = theme.id; themeSelect.value = theme.id;

View File

@@ -2,9 +2,35 @@ const form = document.getElementById("downloadForm");
const statusDiv = document.getElementById("downloadStatus"); const statusDiv = document.getElementById("downloadStatus");
const button = form.querySelector("button[type=\"submit\"]"); const button = form.querySelector("button[type=\"submit\"]");
const stopBtn = document.getElementById("stopBtn"); const stopBtn = document.getElementById("stopBtn");
/**
* Indicates whether a download process is currently active.
* Used to prevent duplicate submissions and to gate UI actions
* such as disabling the form or showing progress.
*/
let isDownloading = false; let isDownloading = false;
/**
* Indicates whether the current download was explicitly
* stopped by the user via the cancel action.
*
* Used to differentiate between:
* - user-initiated cancellation
* - failures or natural completion
*/
let userStoppedDownload = false; let userStoppedDownload = false;
/**
* Handles download form submission.
*
* - Validates user input (URL)
* - Disables UI during download
* - Sends request to backend `/download`
* - Manages success / error states
* - Restores UI state after completion
*/
form.addEventListener("submit", async (e) => { form.addEventListener("submit", async (e) => {
e.preventDefault(); e.preventDefault();
@@ -61,6 +87,13 @@ form.addEventListener("submit", async (e) => {
} }
}); });
/**
* Displays initial download progress UI immediately
* when a download starts.
*
* Initializes progress bar, speed indicator, stage text
* and shows the stop button.
*/
function showInitialProgress() { function showInitialProgress() {
const progressWrapper = document.getElementById("downloadProgressWrapper"); const progressWrapper = document.getElementById("downloadProgressWrapper");
const progressBarText = document.getElementById("downloadProgressText"); const progressBarText = document.getElementById("downloadProgressText");
@@ -84,6 +117,14 @@ function showInitialProgress() {
if (stopBtn) stopBtn.style.display = "inline-flex"; if (stopBtn) stopBtn.style.display = "inline-flex";
} }
/**
* Cancels the current download process.
*
* Sends a request to backend `/download/cancel`,
* updates UI state and resets progress indicators.
*
* Prevents duplicate calls if no download is active.
*/
async function stopDownload() { async function stopDownload() {
if (!isDownloading) return; if (!isDownloading) return;
@@ -109,6 +150,12 @@ async function stopDownload() {
} }
} }
/**
* Resets all download progress UI elements to their
* default hidden state.
*
* Used after completion or cancellation of a download.
*/
function resetProgressBar() { function resetProgressBar() {
const progressWrapper = document.getElementById("downloadProgressWrapper"); const progressWrapper = document.getElementById("downloadProgressWrapper");
if (progressWrapper) progressWrapper.style.display = "none"; if (progressWrapper) progressWrapper.style.display = "none";

View File

@@ -1,12 +1,33 @@
/**
* Converts a raw upload date string (format YYYYMMDD)
* into a human-readable format (DD/MM/YYYY).
*
* Returns "Inconnue" if the input is invalid or malformed.
*/
function formatDate(dateStr) { function formatDate(dateStr) {
if (!dateStr || dateStr.length !== 8) return "Inconnue"; if (!dateStr || dateStr.length !== 8) return "Inconnue";
return `${dateStr.slice(6,8)}/${dateStr.slice(4,6)}/${dateStr.slice(0,4)}`; return `${dateStr.slice(6,8)}/${dateStr.slice(4,6)}/${dateStr.slice(0,4)}`;
} }
/**
* Converts a file size in bytes into megabytes (MB).
*
* Returns a formatted string with 2 decimals (e.g. "12.34 Mo").
* If input is falsy, returns "Inconnue".
*/
function formatSize(bytes) { function formatSize(bytes) {
return bytes ? (bytes / (1024*1024)).toFixed(2) + " Mo" : "Inconnue"; return bytes ? (bytes / (1024*1024)).toFixed(2) + " Mo" : "Inconnue";
} }
/**
* Fetches video or playlist metadata from the backend `/info` endpoint.
*
* Sends a POST request with URL-encoded body.
* Handles network errors, HTTP errors and JSON parsing safely.
*
* @param {string} url - Video or playlist URL
* @returns {Promise<Object>} Parsed metadata or error object
*/
async function fetchVideoInfo(url) { async function fetchVideoInfo(url) {
try { try {
const res = await fetch("/info", { const res = await fetch("/info", {
@@ -26,9 +47,19 @@ async function fetchVideoInfo(url) {
} }
} }
/**
* Initializes the video info system UI.
*
* - Listens to URL input changes
* - Automatically fetches video/playlist metadata if enabled
* - Renders either playlist UI or single video UI
* - Handles loading states and caching (lastFetchedUrl)
*
* Also manages dynamic playlist title injection into the form.
*/
async function init() { async function init() {
// Main input listener: triggers auto-fetch when URL changes
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
const urlInput = document.getElementById("UrlInput"); const urlInput = document.getElementById("UrlInput");
const infoDiv = document.getElementById("videoInfo"); const infoDiv = document.getElementById("videoInfo");

View File

@@ -1,32 +1,53 @@
/**
* DOM elements used for download progress UI rendering.
* These elements are updated in real-time via SSE streams.
*/
const progressWrapper = document.getElementById("downloadProgressWrapper"); const progressWrapper = document.getElementById("downloadProgressWrapper");
const progressBar = document.getElementById("downloadProgress"); const progressBar = document.getElementById("downloadProgress");
const progressBarText = document.getElementById("downloadProgressText") const progressBarText = document.getElementById("downloadProgressText");
const speedElement = document.getElementById("downloadSpeedText"); const speedElement = document.getElementById("downloadSpeedText");
const stageElement = document.getElementById("downloadStage"); const stageElement = document.getElementById("downloadStage");
const playlistInfoElement = document.getElementById("playlistInfoText"); const playlistInfoElement = document.getElementById("playlistInfoText");
/**
* Server-Sent Events streams for real-time download updates.
* Each stream is responsible for a specific aspect of the download state.
*/
const speedEvt = new EventSource("/download/speed"); const speedEvt = new EventSource("/download/speed");
const stageEvt = new EventSource("/download/stage"); const stageEvt = new EventSource("/download/stage");
const playlistInfoEvt = new EventSource("/download/playlist-info"); const playlistInfoEvt = new EventSource("/download/playlist-info");
/**
* Resets the progress UI to its initial state.
* Hides all dynamic elements and clears displayed values.
*/
function startProgress() { function startProgress() {
progressBar.style.width = "0%"; progressBar.style.width = "0%";
progressBarText.innerHTML = "0%"; progressBarText.innerHTML = "0%";
speedElement.innerHTML = "0 Mib/s"; speedElement.innerHTML = "0 Mib/s";
} }
/**
* Makes the progress UI visible and enables related controls
* such as the stop button.
*/
function showProgress() { function showProgress() {
progressWrapper.style.display = "block"; progressWrapper.style.display = "block";
progressBarText.style.display = "block"; progressBarText.style.display = "block";
speedElement.style.display = "block"; speedElement.style.display = "block";
// Show stop button
const stopBtn = document.getElementById("stopBtn"); const stopBtn = document.getElementById("stopBtn");
if (stopBtn) stopBtn.style.display = "inline-flex"; if (stopBtn) stopBtn.style.display = "inline-flex";
} }
/**
* Updates the progress bar UI with the given percentage.
* Automatically triggers UI visibility if not already shown.
*
* @param {number} percent - Download progress percentage (0100)
*/
function updateProgress(percent) { function updateProgress(percent) {
// Show progress div and stop button only when percent > 0
if (percent > 0 && progressWrapper.style.display !== "block") { if (percent > 0 && progressWrapper.style.display !== "block") {
showProgress(); showProgress();
} }
@@ -35,6 +56,10 @@ function updateProgress(percent) {
progressBarText.innerHTML = `${percent}%`; progressBarText.innerHTML = `${percent}%`;
} }
/**
* Fully resets the progress UI and hides all download indicators.
* Called after completion or cancellation.
*/
function resetProgress() { function resetProgress() {
progressBar.style.width = "0%"; progressBar.style.width = "0%";
progressBarText.textContent = ""; progressBarText.textContent = "";
@@ -50,13 +75,19 @@ function resetProgress() {
playlistInfoElement.textContent = ""; playlistInfoElement.textContent = "";
playlistInfoElement.style.display = "none"; playlistInfoElement.style.display = "none";
// Hide stop button
const stopBtn = document.getElementById("stopBtn"); const stopBtn = document.getElementById("stopBtn");
if (stopBtn) stopBtn.style.display = "none"; if (stopBtn) stopBtn.style.display = "none";
} }
// SSE Connexion /**
* Main SSE stream handling download progress updates.
* Supports:
* - reset signal
* - done signal
* - percentage updates
*/
const evtSource = new EventSource("/download/progress"); const evtSource = new EventSource("/download/progress");
evtSource.onmessage = e => { evtSource.onmessage = e => {
if (e.data === "reset") { if (e.data === "reset") {
startProgress(); startProgress();
@@ -65,11 +96,10 @@ evtSource.onmessage = e => {
} }
if (e.data === "done") { if (e.data === "done") {
// Keep progress bar visible for better UX, let toast handle the feedback
setTimeout(() => { setTimeout(() => {
resetProgress(); resetProgress();
window.electronAPI.setProgress(-1); window.electronAPI.setProgress(-1);
}, 2000); // Wait 2s so user sees 100% progress }, 2000);
return; return;
} }
@@ -77,22 +107,30 @@ evtSource.onmessage = e => {
if (!isNaN(percent)) { if (!isNaN(percent)) {
updateProgress(percent); updateProgress(percent);
window.electronAPI.setProgress(percent); // Update Task Bar window.electronAPI.setProgress(percent);
// Don't hide at 100%, wait for "done" signal instead
} }
}; };
/**
* SSE stream: download speed updates (e.g. "5.2 MiB/s").
*/
speedEvt.onmessage = e => { speedEvt.onmessage = e => {
speedElement.style.display = "block"; speedElement.style.display = "block";
speedElement.textContent = e.data; // ex: "5.2MiB/s" speedElement.textContent = e.data;
}; };
/**
* SSE stream: download stage updates (e.g. downloading, merging, extracting).
*/
stageEvt.onmessage = e => { stageEvt.onmessage = e => {
stageElement.style.display = "block"; stageElement.style.display = "block";
stageElement.textContent = e.data; // ex: "📥 Downloading..." stageElement.textContent = e.data;
}; };
/**
* SSE stream: playlist progress updates (e.g. "Item 1 of 15").
*/
playlistInfoEvt.onmessage = e => { playlistInfoEvt.onmessage = e => {
playlistInfoElement.style.display = "block"; playlistInfoElement.style.display = "block";
playlistInfoElement.textContent = e.data; // ex: "Item 1 of 15" playlistInfoElement.textContent = e.data;
}; };

View File

@@ -1,26 +1,48 @@
/**
* Settings panel DOM elements used to control application feature flags
* and configuration UI interactions.
*/
const settingsPanel = document.getElementById("settings-panel"); const settingsPanel = document.getElementById("settings-panel");
const settingsModal = document.querySelector(".settings-modal"); const settingsModal = document.querySelector(".settings-modal");
const settingsButton = document.getElementById("settings-button"); const settingsButton = document.getElementById("settings-button");
/**
* Tracks current settings panel state.
* NOTE: actual visibility is derived from DOM style in current implementation.
*/
let isOpen = false; let isOpen = false;
/**
* Toggles visibility of the settings panel when clicking the settings button.
*/
settingsButton.addEventListener("click", () => { settingsButton.addEventListener("click", () => {
const isOpen = settingsPanel.style.display === "block"; const currentlyOpen = settingsPanel.style.display === "block";
settingsPanel.style.display = isOpen ? "none" : "block"; settingsPanel.style.display = currentlyOpen ? "none" : "block";
}); });
/**
* Closes the settings panel and resets internal state.
*/
function closePanel() { function closePanel() {
isOpen = false; isOpen = false;
settingsPanel.style.display = "none"; settingsPanel.style.display = "none";
} }
/**
* Loads feature flags from Electron backend and syncs them
* with corresponding UI controls (checkboxes, selects).
*
* Also registers change listeners to persist updates in real time.
*/
async function loadSettings() { async function loadSettings() {
const features = await window.electronAPI.getFeatures(); const features = await window.electronAPI.getFeatures();
document.querySelectorAll(".settings-section input, .settings-section select").forEach(el => { document
.querySelectorAll(".settings-section input, .settings-section select")
.forEach((el) => {
const key = el.dataset.key; const key = el.dataset.key;
if (!key) return;
if (features[key] !== undefined) { if (features[key] !== undefined) {
if (el.type === "checkbox") el.checked = features[key]; if (el.type === "checkbox") el.checked = features[key];
else if (el.tagName === "SELECT") el.value = features[key]; else if (el.tagName === "SELECT") el.value = features[key];
@@ -28,39 +50,56 @@ async function loadSettings() {
el.addEventListener("change", () => { el.addEventListener("change", () => {
if (key === "theme") return; if (key === "theme") return;
let value = el.type === "checkbox" ? el.checked : el.value;
const value = el.type === "checkbox" ? el.checked : el.value;
window.electronAPI.setFeature(key, value); window.electronAPI.setFeature(key, value);
}); });
}); });
} }
// Open The JSON /**
document.getElementById("open-json-btn").addEventListener("click", () => { * Opens configuration JSON file via Electron topbar API.
*/
document.getElementById("open-json-btn")
?.addEventListener("click", () => {
window.topbarAPI.openConfig(); window.topbarAPI.openConfig();
}); });
// Open Theme Folder /**
document.getElementById("open-theme").addEventListener("click", () => { * Opens theme directory via Electron topbar API.
*/
document.getElementById("open-theme")
?.addEventListener("click", () => {
window.topbarAPI.openTheme(); window.topbarAPI.openTheme();
}); });
// Refresh Themes /**
document.getElementById("refresh-themes-btn").addEventListener("click", function() { * Refreshes theme list and updates UI selector with a short animation.
*/
document.getElementById("refresh-themes-btn")
?.addEventListener("click", function () {
this.classList.add("spinning"); this.classList.add("spinning");
window.refreshThemes(); window.refreshThemes();
setTimeout(() => { setTimeout(() => {
this.classList.remove("spinning"); this.classList.remove("spinning");
}, 600); }, 600);
}); });
/**
* Clicking the overlay outside the modal closes the settings panel.
*/
settingsPanel?.addEventListener("click", closePanel);
/* clic sur l'overlay = fermer */ /**
settingsPanel.addEventListener("click", closePanel); * Prevents modal clicks from closing the settings panel
* by stopping event propagation to the overlay.
/* clic dans la modal = stop propagation */ */
settingsModal.addEventListener("click", (e) => { settingsModal?.addEventListener("click", (e) => {
e.stopPropagation(); e.stopPropagation();
}); });
/**
* Initializes settings UI by loading backend feature flags.
*/
loadSettings(); loadSettings();

View File

@@ -1,17 +1,26 @@
window.showToast = function(message, type = 'info', duration = 4000) { /**
const container = document.getElementById('toast-container'); * Displays a toast notification in the global UI container.
if (!container) return; *
* Toasts are automatically appended to `#toast-container`,
* support multiple visual types, and can auto-dismiss after a delay.
*
* @param {string} message - Message displayed inside the toast
* @param {"success"|"error"|"warning"|"info"} [type="info"] - Visual style of the toast
* @param {number} [duration=4000] - Auto-dismiss delay in milliseconds (0 disables auto removal)
* @returns {HTMLElement|null} The created toast element or null if container is missing
*/
window.showToast = function (message, type = "info", duration = 4000) {
const container = document.getElementById("toast-container");
if (!container) return null;
// Create toast element const toast = document.createElement("div");
const toast = document.createElement('div'); toast.classList.add("toast", type);
toast.classList.add('toast', type);
// Icons for different types
const icons = { const icons = {
success: '✓', success: "✓",
error: '✕', error: "✕",
warning: '⚠', warning: "⚠",
info: '' info: ""
}; };
const icon = icons[type] || icons.info; const icon = icons[type] || icons.info;
@@ -22,14 +31,11 @@ window.showToast = function(message, type = 'info', duration = 4000) {
<button class="toast-close" title="Close">×</button> <button class="toast-close" title="Close">×</button>
`; `;
// Add close functionality const closeBtn = toast.querySelector(".toast-close");
const closeBtn = toast.querySelector('.toast-close'); closeBtn.addEventListener("click", () => removeToast(toast));
closeBtn.addEventListener('click', () => removeToast(toast));
// Add to container
container.appendChild(toast); container.appendChild(toast);
// Auto remove after duration
if (duration > 0) { if (duration > 0) {
setTimeout(() => removeToast(toast), duration); setTimeout(() => removeToast(toast), duration);
} }
@@ -37,18 +43,41 @@ window.showToast = function(message, type = 'info', duration = 4000) {
return toast; return toast;
}; };
/**
* Removes a toast element with a small exit animation.
*
* @param {HTMLElement} toast
*/
function removeToast(toast) { function removeToast(toast) {
if (!toast.parentElement) return; if (!toast?.parentElement) return;
toast.classList.add("removing");
toast.classList.add('removing');
setTimeout(() => { setTimeout(() => {
if (toast.parentElement) {
toast.remove(); toast.remove();
}
}, 300); }, 300);
} }
window.showSuccess = (message, duration = 4000) => window.showToast(message, 'success', duration); /**
window.showError = (message, duration = 5000) => window.showToast(message, 'error', duration); * Shortcut: success toast
window.showWarning = (message, duration = 4000) => window.showToast(message, 'warning', duration); */
window.showInfo = (message, duration = 4000) => window.showToast(message, 'info', duration); window.showSuccess = (message, duration = 4000) =>
window.showToast(message, "success", duration);
/**
* Shortcut: error toast
*/
window.showError = (message, duration = 5000) =>
window.showToast(message, "error", duration);
/**
* Shortcut: warning toast
*/
window.showWarning = (message, duration = 4000) =>
window.showToast(message, "warning", duration);
/**
* Shortcut: info toast
*/
window.showInfo = (message, duration = 4000) =>
window.showToast(message, "info", duration);

View File

@@ -1,16 +1,22 @@
// Ajout des listeners pour la topbar /**
* Initializes topbar button event listeners and binds them
* to the Electron preload API exposed in `window.topbarAPI`.
*
* This function is safe to call once DOM is ready.
* It silently aborts if the API or elements are missing.
*/
function setupTopbarListeners() { function setupTopbarListeners() {
const { topbarAPI } = window; const { topbarAPI } = window;
if (!topbarAPI) return; if (!topbarAPI) return;
const minBtn = document.getElementById('minimize-btn'); const minBtn = document.getElementById("minimize-btn");
const maxBtn = document.getElementById('maximize-btn'); const maxBtn = document.getElementById("maximize-btn");
const closeBtn = document.getElementById('close-btn'); const closeBtn = document.getElementById("close-btn");
const devtoolsBtn = document.getElementById('devtools-btn'); const devtoolsBtn = document.getElementById("devtools-btn");
const logsBtn = document.getElementById('logs-btn'); const logsBtn = document.getElementById("logs-btn");
const websiteBtn = document.getElementById('website-btn'); const websiteBtn = document.getElementById("website-btn");
const wikiBtn = document.getElementById('wiki-btn'); const wikiBtn = document.getElementById("wiki-btn");
const workshopBtn = document.getElementById('workshop-btn'); const workshopBtn = document.getElementById("workshop-btn");
if (minBtn) minBtn.onclick = () => topbarAPI.minimize(); if (minBtn) minBtn.onclick = () => topbarAPI.minimize();
if (maxBtn) maxBtn.onclick = () => topbarAPI.maximize(); if (maxBtn) maxBtn.onclick = () => topbarAPI.maximize();
@@ -22,12 +28,30 @@ function setupTopbarListeners() {
if (workshopBtn) workshopBtn.onclick = () => topbarAPI.openWorkshop(); if (workshopBtn) workshopBtn.onclick = () => topbarAPI.openWorkshop();
} }
document.addEventListener('DOMContentLoaded', setupTopbarListeners); /**
* Registers topbar event listeners after DOM is fully loaded.
*/
document.addEventListener("DOMContentLoaded", setupTopbarListeners);
const features = await window.electronAPI.getFeatures(); /**
* Applies UI layout adjustments depending on feature flags
* provided by the Electron backend.
*
* Specifically handles:
* - hiding custom topbar
* - adjusting layout spacing
* - repositioning theme switcher
*/
(async function applyFeatureLayout() {
const features = await window.electronAPI.getFeatures();
if(!features.customTopBar){ if (!features.customTopBar) {
document.getElementById("topbar").style.display = "none"; const topbar = document.getElementById("topbar");
document.getElementById("container").style.marginTop = "0"; const container = document.getElementById("container");
document.getElementById("theme-switcher").style.top = "30px"; const themeSwitcher = document.getElementById("theme-switcher");
}
if (topbar) topbar.style.display = "none";
if (container) container.style.marginTop = "0";
if (themeSwitcher) themeSwitcher.style.top = "30px";
}
})();

View File

@@ -40,8 +40,7 @@ form button:hover {
form#downloadForm { form#downloadForm {
display: flex; display: flex;
gap: 0.5em; gap: 0.5em;
max-width: 600px; width: 350px;
width: 100%;
justify-content: center; justify-content: center;
} }

View File

@@ -4,11 +4,47 @@ const { isValidUrl, isSafePath } = require("../helpers/validation.helpers");
const { notifyDownloadFinished } = require("../helpers/notify.helpers"); const { notifyDownloadFinished } = require("../helpers/notify.helpers");
const { configFeatures } = require("../../config"); const { configFeatures } = require("../../config");
const listeners = []; /**
const speedListeners = []; * Active Server-Sent Event subscribers receiving
const stageListeners = []; * download progress updates.
const playlistInfoListeners = []; *
* Each listener receives a percentage value between
* 0 and 100 during the download lifecycle.
*/
const progressListeners = [];
/**
* Active Server-Sent Event subscribers receiving
* download speed updates.
*/
const speedListeners = [];
/**
* Active Server-Sent Event subscribers receiving
* download stage updates.
*/
const stageListeners = [];
/**
* Active Server-Sent Event subscribers receiving
* playlist progress information.
*/
const playlistListeners = [];
/**
* Handles download requests and starts a yt-dlp download.
*
* Validates user input, prepares download options and
* delegates the download process to the download service.
*
* Once the download completes successfully, a desktop
* notification may be displayed depending on the
* application configuration.
*
* @param {import("express").Request} req
* @param {import("express").Response} res
* @returns {Promise<void>}
*/
async function downloadController(req, res) { async function downloadController(req, res) {
try { try {
const options = { const options = {
@@ -25,17 +61,30 @@ async function downloadController(req, res) {
return res.status(400).send("Save Path Not Allowed."); return res.status(400).send("Save Path Not Allowed.");
} }
// Get output folder when the download is finished const outputFolder = await fetchDownload(
const outputFolder = await fetchDownload(options, listeners, speedListeners, stageListeners, playlistInfoListeners); options,
progressListeners,
speedListeners,
stageListeners,
playlistListeners
);
notifyDownloadFinished(outputFolder, configFeatures.notifySystem); notifyDownloadFinished(outputFolder, configFeatures.notifySystem);
res.send("Download Done !"); res.send("Download Done !");
} catch (err) { } catch (err) {
logger.error(`Server Error in /download : ${err.message}`); logger.error(`Server Error in /download: ${err?.message ?? err}`);
res.status(500).send(`Server Error`); res.status(500).send("Server Error");
} }
} }
/**
* Opens a Server-Sent Events (SSE) connection used to
* stream download progress updates to the client.
*
* @param {import("express").Request} req
* @param {import("express").Response} res
*/
function progressController(req, res) { function progressController(req, res) {
res.set({ res.set({
'Content-Type': 'text/event-stream', 'Content-Type': 'text/event-stream',
@@ -44,14 +93,21 @@ function progressController(req, res) {
}); });
const sendProgress = percent => res.write(`data: ${percent}\n\n`); const sendProgress = percent => res.write(`data: ${percent}\n\n`);
listeners.push(sendProgress); progressListeners.push(sendProgress);
req.on('close', () => { req.on('close', () => {
listeners.splice(listeners.indexOf(sendProgress), 1); progressListeners.splice(progressListeners.indexOf(sendProgress), 1);
res.end(); res.end();
}); });
} }
/**
* Opens a Server-Sent Events (SSE) connection used to
* stream download speed updates to the client.
*
* @param {import("express").Request} req
* @param {import("express").Response} res
*/
function speedController(req, res) { function speedController(req, res) {
res.set({ res.set({
'Content-Type': 'text/event-stream', 'Content-Type': 'text/event-stream',
@@ -68,6 +124,14 @@ function speedController(req, res) {
}); });
} }
/**
* Opens a Server-Sent Events (SSE) connection used to
* stream download stage updates such as downloading,
* merging, extracting audio or embedding metadata.
*
* @param {import("express").Request} req
* @param {import("express").Response} res
*/
function stageController(req, res) { function stageController(req, res) {
res.set({ res.set({
'Content-Type': 'text/event-stream', 'Content-Type': 'text/event-stream',
@@ -84,6 +148,14 @@ function stageController(req, res) {
}); });
} }
/**
* Cancels the currently running download process.
*
* Returns an error response if no download is active.
*
* @param {import("express").Request} req
* @param {import("express").Response} res
*/
function cancelDownloadController(req, res) { function cancelDownloadController(req, res) {
const cancelled = cancelDownload(); const cancelled = cancelDownload();
if (cancelled) { if (cancelled) {
@@ -95,6 +167,18 @@ function cancelDownloadController(req, res) {
} }
} }
/**
* Opens a Server-Sent Events (SSE) connection used to
* stream playlist progress information to the client.
*
* Example:
* - Item 1 of 20
* - Item 12 of 20
* - Item 20 of 20
*
* @param {import("express").Request} req
* @param {import("express").Response} res
*/
function playlistInfoController(req, res) { function playlistInfoController(req, res) {
res.set({ res.set({
'Content-Type': 'text/event-stream', 'Content-Type': 'text/event-stream',
@@ -103,10 +187,10 @@ function playlistInfoController(req, res) {
}); });
const sendPlaylistInfo = info => res.write(`data: ${info}\n\n`); const sendPlaylistInfo = info => res.write(`data: ${info}\n\n`);
playlistInfoListeners.push(sendPlaylistInfo); playlistListeners.push(sendPlaylistInfo);
req.on('close', () => { req.on('close', () => {
playlistInfoListeners.splice(playlistInfoListeners.indexOf(sendPlaylistInfo), 1); playlistListeners.splice(playlistListeners.indexOf(sendPlaylistInfo), 1);
res.end(); res.end();
}); });
} }

View File

@@ -3,70 +3,114 @@ const { parseVideo, parsePlaylist } = require("../helpers/parseInfo.helpers");
const { logger } = require("../logger"); const { logger } = require("../logger");
const { isValidUrl } = require("../helpers/validation.helpers"); const { isValidUrl } = require("../helpers/validation.helpers");
async function infoController(req, res){ /**
* Handles metadata retrieval for a video or playlist.
*
* This controller:
* - Validates incoming URL (query or body)
* - Calls yt-dlp metadata extraction service
* - Detects playlist vs single video context
* - Applies fallback strategy if playlist extraction fails
* - Returns normalized parsed response (video or playlist)
*
* @param {import("express").Request} req
* @param {import("express").Response} res
*/
async function infoController(req, res) {
const url = req.body.url || req.query.url; // POST et GET const url = req.body.url || req.query.url; // Supports POST and GET requests
/**
// If no url, non-string url or invalid url * Basic validation:
* - URL must exist
if (!url || typeof url !== "string" || !isValidUrl(url)) return res.status(400).send("Invalid URL Or Missing"); * - Must be a string
* - Must pass custom URL validation
logger.info(`/Info Request receive by the Info Controller. URL: ${url}`); */
if (!url || typeof url !== "string" || !isValidUrl(url)) {
if (url.includes("&list") || url.includes("?list")) { return res.status(400).send("Invalid URL Or Missing");
logger.info("Estimated Data Type: Playlist")
} else{
logger.info("Estimated Data Type: Video")
} }
logger.info(`Info request received. URL: ${url}`);
// Lightweight heuristic to detect playlist URLs
const isPlaylistUrl = url.includes("&list") || url.includes("?list");
logger.info(
isPlaylistUrl
? "Estimated Data Type: Playlist"
: "Estimated Data Type: Video"
);
try { try {
let data; let data;
let fetchError = null;
/**
* Primary metadata fetch attempt
*/
try { try {
data = await fetchInfo(url); data = await fetchInfo(url);
} catch (err) { } catch (err) {
fetchError = err;
// If it's a playlist URL and fetch failed, try fetching just the single video /**
if ((url.includes("&list") || url.includes("?list"))) { * Fallback strategy:
logger.info(`Playlist fetch failed: ${err.message}. Retrying with single video...`); * If playlist extraction fails, retry as single video
*/
if (isPlaylistUrl) {
// Remove the &list parameter logger.info(
`Playlist fetch failed: ${err.message}. Retrying as single video...`
);
// Remove playlist query parameter (best-effort cleanup)
const singleVideoUrl = url.split("&list")[0].split("?list")[0]; const singleVideoUrl = url.split("&list")[0].split("?list")[0];
try { try {
data = await fetchInfo(singleVideoUrl); data = await fetchInfo(singleVideoUrl);
logger.info(`Single video fetch succeeded after playlist failure`); logger.info("Single video fetch succeeded after playlist failure");
} catch (retryErr) { } catch (retryErr) {
logger.error(`Single video fetch also failed: ${retryErr.message}`); logger.error(
throw err; // Throw the original error `Single video fetch also failed: ${retryErr.message}`
);
// Preserve original error context
throw err;
} }
} else { } else {
throw err; throw err;
} }
} }
/**
* Normalize response based on yt-dlp output type
*/
if (data._type === "playlist") { if (data._type === "playlist") {
const playlist = parsePlaylist(data); const playlist = parsePlaylist(data);
logger.info(`Playlist : ${playlist.title} (${playlist.count} vidéos)`);
logger.info(
`Playlist fetched: ${playlist.title} (${playlist.count} videos)`
);
return res.json(playlist); return res.json(playlist);
} else{ } else {
const video = parseVideo(data); const video = parseVideo(data);
logger.info(`Unique Video: ${video.title}`);
logger.info(`Video fetched: ${video.title}`);
return res.json(video); return res.json(video);
} }
} catch (err) { } catch (err) {
logger.error(`Info controller error: ${err && err.message ? err.message : err}`);
return res.status(500).send(`Unable to fetch info`);
}
logger.error(
`Info controller error: ${err && err.message ? err.message : err}`
);
return res.status(500).send("Unable to fetch info");
}
} }
module.exports = {infoController}; module.exports = { infoController };

View File

@@ -5,6 +5,26 @@ const { ffmpegPath, denoPath} = require("./path.helpers.js");
const { configFeatures } = require("../../config.js"); const { configFeatures } = require("../../config.js");
const { logger } = require("../logger.js"); const { logger } = require("../logger.js");
/**
* Validates whether the provided codec is supported by the application.
*
* If the codec is supported, it is returned unchanged.
* Otherwise, a warning is logged and the default codec ("h264")
* is returned as a fallback.
*
* Supported codecs:
* - av01
* - vp9.2
* - vp9
* - h265
* - h264
* - vp8
* - h263
* - theora
*
* @param {string} codec - Codec identifier to validate.
* @returns {string} A valid codec identifier.
*/
function validateCodec(codec){ function validateCodec(codec){
const validCodec = [ const validCodec = [
@@ -20,6 +40,28 @@ function validateCodec(codec){
} }
} }
/**
* Builds the complete yt-dlp argument list based on the
* application configuration and download options.
*
* The generated arguments include:
* - Browser cookie extraction
* - FFmpeg integration
* - Deno runtime configuration
* - Metadata and thumbnail embedding
* - Playlist handling
* - Video/audio format selection
* - Quality filtering
* - Output file destination
*
* @param {Object} options - Download configuration.
* @param {string} options.url - Video or playlist URL.
* @param {boolean} options.audioOnly - Whether to extract audio only.
* @param {string} options.quality - Requested quality preset.
* @param {string} options.outputFolder - Download destination folder.
*
* @returns {string[]} Array of arguments ready to be passed to yt-dlp.
*/
function buildYtDlpArgs({ url, audioOnly, quality, outputFolder }) { function buildYtDlpArgs({ url, audioOnly, quality, outputFolder }) {
logger.info("--- CONFIGURATION ---"); logger.info("--- CONFIGURATION ---");

View File

@@ -4,10 +4,33 @@ const path = require("path");
const notify = require("./notify.helpers") const notify = require("./notify.helpers")
const { logger } = require("../logger"); const { logger } = require("../logger");
/**
* Detects the first supported browser installed on the system.
*
* The detected browser is used by yt-dlp to import authentication
* cookies when required by certain platforms.
*
* Currently, Firefox is the only browser officially supported
* by the application.
*
* If no supported browser is found:
* - A warning is logged
* - A notification is displayed to the user
* - "firefox" is returned as a fallback to allow yt-dlp to
* handle the error gracefully without crashing the application
*
* @returns {string} The detected browser identifier.
*/
function getUserBrowser() { function getUserBrowser() {
const userProfile = os.homedir(); const userProfile = os.homedir();
// List of browsers supported by yt-dlp (Currently I can only guarantee Firefox, Sorry) /**
* Browsers supported by yt-dlp cookie extraction.
*
* Only Firefox is currently enabled and tested.
* Additional browsers can be enabled in the future
* once compatibility has been verified.
*/
const browsers = [ const browsers = [
{ name: "firefox", path: path.join(userProfile, "AppData", "Roaming", "Mozilla", "Firefox", "Profiles") }, { name: "firefox", path: path.join(userProfile, "AppData", "Roaming", "Mozilla", "Firefox", "Profiles") },
// { name: "chrome", path: path.join(userProfile, "AppData", "Local", "Google", "Chrome", "User Data", "Default") }, // { name: "chrome", path: path.join(userProfile, "AppData", "Local", "Google", "Chrome", "User Data", "Default") },
@@ -19,7 +42,7 @@ function getUserBrowser() {
// { name: "whale", path: path.join(userProfile, "AppData", "Local", "Naver", "Naver Whale", "User Data", "Default") } // { name: "whale", path: path.join(userProfile, "AppData", "Local", "Naver", "Naver Whale", "User Data", "Default") }
]; ];
// Search for an available browser // Search for the first available browser profile.
for (const browser of browsers) { for (const browser of browsers) {
if (fs.existsSync(browser.path)) { if (fs.existsSync(browser.path)) {
logger.info(`Browser found: ${browser.name}`); logger.info(`Browser found: ${browser.name}`);
@@ -27,12 +50,12 @@ function getUserBrowser() {
} }
} }
// No browser found - notify user // No supported browser found => Notify User
logger.warn("No supported browser found on the system"); logger.warn("No supported browser found on the system");
notify.notifyFirefoxBrowserMissing(); notify.notifyFirefoxBrowserMissing();
// Fallback: return "Firefox" to let YT-DLP manage error // Fallback to Firefox and let yt-dlp handle the error gracefully.
// this avoid app crash // This prevents the application from crashing
return "firefox"; return "firefox";
} }

View File

@@ -1,8 +1,19 @@
const { Notification, shell } = require("electron"); const { Notification, shell } = require("electron");
const { iconPaths } = require("./path.helpers"); const { iconPaths } = require("./path.helpers");
const { logger } = require("../logger");
/**
* Displays a system notification when a download completes successfully.
*
* If enabled, clicking the notification opens the download folder
* using the system file explorer.
*
* @param {string} folder - Path to the completed download folder.
* @param {boolean} notifyEnabled - Whether notifications are enabled in config.
*/
function notifyDownloadFinished(folder, notifyEnabled = true) { function notifyDownloadFinished(folder, notifyEnabled = true) {
if (!notifyEnabled) return; if (!notifyEnabled) return;
if (!folder) return;
const notif = new Notification({ const notif = new Notification({
title: "Freedom Loader", title: "Freedom Loader",
@@ -14,6 +25,15 @@ function notifyDownloadFinished(folder, notifyEnabled = true) {
notif.show(); notif.show();
} }
/**
* Displays a notification when browser cookies cannot be extracted.
*
* This usually indicates:
* - User is not logged in to the browser
* - Browser profile is inaccessible
*
* Clicking the notification opens a tutorial link.
*/
function notifyCookiesBrowserError(){ function notifyCookiesBrowserError(){
const notif = new Notification({ const notif = new Notification({
title: "Cookies Error", title: "Cookies Error",
@@ -21,23 +41,41 @@ function notifyCookiesBrowserError(){
icon: iconPaths.error, icon: iconPaths.error,
}); });
notif.on("click", () => shell.openExternal("https://youtube.com/shorts/cN9f4s1Mf88?si=519QCVd_-fzJqRf1")); notif.on("click", () =>
shell.openExternal(
"https://youtube.com/shorts/cN9f4s1Mf88?si=519QCVd_-fzJqRf1"
)
);
notif.show(); notif.show();
} }
function notifyFirefoxBrowserMissing(){ /**
* Displays a notification when Firefox is not detected
* on the user system.
*
* This is required for cookie extraction via yt-dlp.
*
* Clicking the notification opens an installation guide.
*/
function notifyFirefoxBrowserMissing() {
const notif = new Notification({ const notif = new Notification({
title: "Firefox Missing", title: "Firefox Missing",
body: "Firefox was not found on your system. Click here to follow the installation guide", body: "Firefox was not found on your system. Click here to follow the installation guide",
icon: iconPaths.error, icon: iconPaths.error,
}); });
notif.on("click", () => shell.openExternal("https://youtube.com/shorts/cN9f4s1Mf88?si=519QCVd_-fzJqRf1")) notif.on("click", () =>
shell.openExternal(
"https://youtube.com/shorts/cN9f4s1Mf88?si=519QCVd_-fzJqRf1"
)
);
notif.show(); notif.show();
} }
module.exports = { module.exports = {
notifyDownloadFinished, notifyDownloadFinished,
notifyCookiesBrowserError, notifyCookiesBrowserError,
notifyFirefoxBrowserMissing notifyFirefoxBrowserMissing,
}; };

View File

@@ -1,13 +1,24 @@
/**
* Normalizes a video payload by attaching the resource type.
*
* @param {Object} data - Raw video metadata.
* @returns {Object} Normalized video object.
*/
function parseVideo(data) { function parseVideo(data) {
return { return {
type: "video", type: "video",
...data ...data
}; };
} }
/**
* Transforms a raw playlist payload into a normalized structure
* containing playlist metadata and a simplified list of videos.
*
* @param {Object} data - Raw playlist metadata.
* @returns {Object} Normalized playlist object.
*/
function parsePlaylist(data) { function parsePlaylist(data) {
return { return {
type: "playlist", type: "playlist",
title: data.title || data.id, title: data.title || data.id,

View File

@@ -7,21 +7,31 @@ const config = require("../../config.js");
const { logger } = require("../logger.js"); const { logger } = require("../logger.js");
// Centralized resource paths // Centralized resource paths
const resourcesPath = config.localMode const resourcesPath = config.devMode
? path.join(__dirname, "../../ressources") ? path.join(__dirname, "../../ressources")
: process.resourcesPath; : process.resourcesPath;
// Default download folder (centralized) // Default download folder (centralized)
const defaultDownloadFolder = path.join(os.homedir(), "Downloads", "Freedom Loader"); const defaultDownloadFolder = path.join(os.homedir(), "Downloads", "Freedom Loader");
// Binary paths /**
* Runtime-resolved binary paths.
* These values differ between development and production builds.
*/
let userYtDlp; let userYtDlp;
let ffmpegPath; let ffmpegPath;
let denoPath; let denoPath;
/** Source binary bundled inside application resources (used for first-time copy) */
const sourceYtDlp = path.join(resourcesPath, "binaries","yt-dlp.exe"); const sourceYtDlp = path.join(resourcesPath, "binaries","yt-dlp.exe");
if (config.localMode) { /**
* Resolve binary locations depending on environment:
* - dev: local resources folder
* - prod: extracted userData + packaged resources
*/
if (config.devMode) {
userYtDlp = path.join(__dirname, "../../ressources/yt-dlp.exe"); userYtDlp = path.join(__dirname, "../../ressources/yt-dlp.exe");
ffmpegPath = path.join(__dirname, "../../ressources/"); // <- has ffmpeg.exe and ffprobe.exe ffmpegPath = path.join(__dirname, "../../ressources/"); // <- has ffmpeg.exe and ffprobe.exe
denoPath = path.join(__dirname, "../../ressources/deno.exe"); denoPath = path.join(__dirname, "../../ressources/deno.exe");
@@ -35,14 +45,20 @@ if (config.localMode) {
} }
} }
// Notification icon paths /**
* Centralized notification and UI icon paths.
* Used by Electron notifications and UI components.
*/
const iconPaths = { const iconPaths = {
confirm: path.join(resourcesPath, "confirm-icon.png"), confirm: path.join(resourcesPath, "confirm-icon.png"),
error: path.join(resourcesPath, "error.png"), error: path.join(resourcesPath, "error.png"),
app: path.join(resourcesPath, "app-icon.ico") app: path.join(resourcesPath, "app-icon.ico")
}; };
// Binary paths for verification /**
* Static reference paths to bundled binaries.
* Used mainly for verification and diagnostics.
*/
const binaryPaths = { const binaryPaths = {
ytDlp: path.join(resourcesPath, "binaries", "yt-dlp.exe"), ytDlp: path.join(resourcesPath, "binaries", "yt-dlp.exe"),
ffmpeg: path.join(resourcesPath, "binaries", "ffmpeg.exe"), ffmpeg: path.join(resourcesPath, "binaries", "ffmpeg.exe"),
@@ -50,10 +66,21 @@ const binaryPaths = {
deno: path.join(resourcesPath, "binaries", "deno.exe") deno: path.join(resourcesPath, "binaries", "deno.exe")
}; };
// Theme paths - themes stored in AppData to survive updates /**
* Theme storage system:
* - default themes come from app resources
* - user themes are persisted in userData to survive updates
*/
const userThemesPath = path.join(app.getPath("userData"), "themes"); const userThemesPath = path.join(app.getPath("userData"), "themes");
const defaultThemesSourcePath = path.join(resourcesPath, "theme"); const defaultThemesSourcePath = path.join(resourcesPath, "theme");
/**
* Initializes user theme directory and ensures default themes exist.
*
* - Creates user themes folder if missing
* - Copies bundled themes from resources if not present
* - Applies permissive permissions for runtime modification
*/
function initUserThemes() { function initUserThemes() {
try { try {
if (!fs.existsSync(userThemesPath)) { if (!fs.existsSync(userThemesPath)) {
@@ -94,6 +121,13 @@ function initUserThemes() {
} }
} }
/**
* Recursively copies a directory and its contents.
*
* @param {string} src - Source directory
* @param {string} dest - Destination directory
* @throws Error If copy operation fails
*/
function copyDirectory(src, dest) { function copyDirectory(src, dest) {
try { try {
if (!fs.existsSync(dest)) { if (!fs.existsSync(dest)) {
@@ -116,8 +150,9 @@ function copyDirectory(src, dest) {
} }
} }
if (!userYtDlp){ logger.error("Missing YT-DLP")} // Runtime validation of critical binaries
if (!ffmpegPath){ logger.error("Missing FFMPEG")} if (!userYtDlp){ logger.error("Missing yt-dlp binary")}
if (!denoPath){ logger.error("Missing DENO")} if (!ffmpegPath){ logger.error("Missing ffmpeg binary")}
if (!denoPath){ logger.error("Missing deno binary")}
module.exports = { userYtDlp, ffmpegPath, denoPath, iconPaths, binaryPaths, resourcesPath, defaultDownloadFolder, userThemesPath, initUserThemes }; module.exports = { userYtDlp, ffmpegPath, denoPath, iconPaths, binaryPaths, resourcesPath, defaultDownloadFolder, userThemesPath, initUserThemes };

View File

@@ -1,14 +1,24 @@
const RateLimit = require("express-rate-limit"); const RateLimit = require("express-rate-limit");
/* Rate Limite */ /**
const rateLimite = RateLimit({ * Rate limiter middleware used to protect public endpoints
* against abusive or excessive requests.
*
* Restrictions:
* - 5 requests per IP address
* - 15-minute sliding window
*
* When the limit is exceeded, a HTTP 429 (Too Many Requests)
* response is returned.
*/
const rateLimit = RateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // limit each IP to 100 requests per windowMs on the root path max: 5,
message: "Too many requests, please try again later.", message: "Too many requests, please try again later.",
statusCode: 429, // HTTP status code for "Too Many Requests" statusCode: 429,
}); });
module.exports = { module.exports = {
rateLimite rateLimit
} }

View File

@@ -1,5 +1,14 @@
const path = require("path"); const path = require("path");
/**
* Validates whether the provided string is a well-formed URL.
*
* The validation relies on the native URL constructor and
* returns `true` only if the value can be successfully parsed.
*
* @param {string} url - URL to validate.
* @returns {boolean} True if the URL is valid, otherwise false.
*/
function isValidUrl(url) { function isValidUrl(url) {
try { try {
new URL(url); new URL(url);
@@ -8,13 +17,26 @@ function isValidUrl(url) {
} catch { } catch {
return false; return false;
} }
} }
/**
* Validates whether a folder path is considered safe for
* file operations.
*
* Security checks:
* - Rejects empty or very short paths.
* - Resolves the absolute path before validation.
* - Prevents access to sensitive Windows system directories.
*
* @param {string} folder - Folder path to validate.
* @returns {boolean} True if the path is considered safe, otherwise false.
*/
function isSafePath(folder) { function isSafePath(folder) {
if (!folder || folder.length < 3) return false; if (!folder || folder.length < 3) return false;
const unsafe = ["System32", "\\Windows"]; const unsafe = ["System32", "\\Windows"];
const resolved = path.resolve(folder); const resolved = path.resolve(folder);
return !unsafe.some(u => resolved.includes(u)); return !unsafe.some(u => resolved.includes(u));
} }

View File

@@ -20,7 +20,15 @@ const logFormat = format.combine(
format.printf(({ timestamp, level, message }) => `${timestamp} | ${level.toUpperCase()} | ${message}`) format.printf(({ timestamp, level, message }) => `${timestamp} | ${level.toUpperCase()} | ${message}`)
); );
// Logger configuration /**
* Logger Instance with differents types
* - info
* - error
* - warn
*
* You need to specify the text that need to be displayed
* @param string
*/
const logger = createLogger({ const logger = createLogger({
level: "info", level: "info",
format: logFormat, format: logFormat,
@@ -40,11 +48,17 @@ const logger = createLogger({
], ],
}); });
/**
* Start Log Session
*/
function logSessionStart() { function logSessionStart() {
logger.info(`--- Starting session: ${new Date().toISOString()} ---`); logger.info(`--- Starting session: ${new Date().toISOString()} ---`);
logger.info(`Application Version: ${config.version}`) logger.info(`Application Version: ${config.version}`)
} }
/**
* Stop Log Session
*/
function logSessionEnd() { function logSessionEnd() {
logger.info(`--- Ending session: ${new Date().toISOString()} ---`); logger.info(`--- Ending session: ${new Date().toISOString()} ---`);
} }

View File

@@ -2,7 +2,7 @@ const express = require("express");
const path = require("path"); const path = require("path");
const { logger, logSessionEnd } = require("./logger"); const { logger, logSessionEnd } = require("./logger");
const config = require("../config"); const config = require("../config");
const { rateLimite } = require("./helpers/rateLimit.helpers"); const { rateLimit } = require("./helpers/rateLimit.helpers");
const app = express(); const app = express();
@@ -14,10 +14,18 @@ app.use(express.static(path.join(__dirname, "../public")));
// Routes // Routes
app.use("/download", require("./routes/download.route")); app.use("/download", require("./routes/download.route"));
app.use("/info", require("./routes/info.route")); app.use("/info", require("./routes/info.route"));
app.get("/", rateLimite, (req, res) => { app.get("/", rateLimit, (req, res) => {
res.sendFile(path.join(__dirname, "../public/index.html")); res.sendFile(path.join(__dirname, "../public/index.html"));
}); });
/**
* Initializes and starts the Express HTTP server.
*
* - Binds the server to `config.applicationPort`
* - Resolves with the HTTP server instance on successful startup
* - Rejects on server binding or runtime errors
* - Registers SIGINT/SIGTERM handlers for graceful shutdown
*/
async function startServer() { async function startServer() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const server = app.listen(config.applicationPort, () => { const server = app.listen(config.applicationPort, () => {
@@ -30,6 +38,12 @@ async function startServer() {
reject(err); reject(err);
}); });
/**
* Clean exit function
* - Stop Log
* - Stop Express Server
* - Stop Electron App
*/
const gracefulExit = () => { const gracefulExit = () => {
logSessionEnd(); logSessionEnd();
server.close(() => { server.close(() => {

View File

@@ -8,13 +8,45 @@ const path = require("path");
const { isSafePath } = require("../helpers/validation.helpers"); const { isSafePath } = require("../helpers/validation.helpers");
const { configFeatures } = require("../../config.js"); const { configFeatures } = require("../../config.js");
/**
* Reference to the currently running yt-dlp process.
*
* Used to support download cancellation and lifecycle
* management.
*
* @type {import("child_process").ChildProcess|null}
*/
let currentDownloadProcess = null; let currentDownloadProcess = null;
// Détecte si une URL est une playlist /**
* Determines whether a URL targets a playlist.
*
* The detection is based on the presence of the YouTube
* playlist query parameter (`list`).
*
* @param {string} url - URL to inspect.
* @returns {boolean} True if the URL appears to be a playlist.
*/
function isPlaylistUrl(url) { function isPlaylistUrl(url) {
return url.includes("?list=") || url.includes("&list="); return url.includes("?list=") || url.includes("&list=");
} }
/**
* Creates a dedicated download folder for a playlist.
*
* The playlist title is sanitized to ensure it can be safely
* used as a filesystem path. If the target folder already
* exists, an incremented suffix is appended until a unique
* folder name is found.
*
* If folder creation fails, a fallback folder named
* "Untitled Playlist X" is generated.
*
* @param {string} basePath - Parent download directory.
* @param {string} playlistTitle - Playlist title returned by yt-dlp.
* @returns {string} Absolute path of the created playlist folder.
* @throws {Error} If no valid folder can be created.
*/
function createPlaylistFolder(basePath, playlistTitle) { function createPlaylistFolder(basePath, playlistTitle) {
const sanitizedTitle = playlistTitle const sanitizedTitle = playlistTitle
.replace(/[<>:"|?*]/g, '') .replace(/[<>:"|?*]/g, '')
@@ -74,6 +106,32 @@ function createPlaylistFolder(basePath, playlistTitle) {
} }
} }
/**
* Starts a yt-dlp download process and monitors its progress.
*
* Responsibilities:
* - Validates and prepares the output directory
* - Creates playlist folders when enabled
* - Builds yt-dlp command arguments
* - Spawns the yt-dlp process
* - Parses progress, speed and stage information
* - Notifies listeners about download events
* - Handles download completion and failures
*
* @param {Object} options - Download options.
* @param {string} options.url - Video or playlist URL.
* @param {string} [options.outputFolder] - Destination folder.
* @param {string} [options.playlistTitle] - Playlist title.
*
* @param {Function[]} listeners - Progress listeners.
* @param {Function[]} speedListeners - Download speed listeners.
* @param {Function[]} stageListeners - Processing stage listeners.
* @param {Function[]} playlistInfoListeners - Playlist progress listeners.
*
* @returns {Promise<string>}
* Resolves with the final output folder path once the
* download has completed successfully.
*/
function fetchDownload(options, listeners, speedListeners, stageListeners, playlistInfoListeners) { function fetchDownload(options, listeners, speedListeners, stageListeners, playlistInfoListeners) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -191,6 +249,16 @@ function fetchDownload(options, listeners, speedListeners, stageListeners, playl
}); });
} }
/**
* Cancels the currently running yt-dlp process.
*
* Attempts to terminate the entire process tree to ensure
* that yt-dlp and any spawned FFmpeg processes are stopped.
*
* @returns {boolean}
* True if a download process was cancelled,
* otherwise false.
*/
function cancelDownload() { function cancelDownload() {
if (currentDownloadProcess) { if (currentDownloadProcess) {
logger.info("Cancelling download and killing all child processes..."); logger.info("Cancelling download and killing all child processes...");

View File

@@ -3,8 +3,30 @@ const { userYtDlp, denoPath } = require("../helpers/path.helpers");
const getUserBrowser = require("../helpers/getBrowser.helpers"); const getUserBrowser = require("../helpers/getBrowser.helpers");
const { logger } = require("../logger"); const { logger } = require("../logger");
/**
* Retrieves metadata for a video or playlist using yt-dlp.
*
* The command executes yt-dlp in JSON mode and returns the
* parsed response object without downloading any media.
*
* Features:
* - Supports videos and playlists
* - Imports browser cookies when available
* - Uses Deno for JavaScript extraction
* - Applies a 30-second timeout
* - Limits response size to 10 MB
*
* @param {string} url - Video or playlist URL to inspect.
* @returns {Promise<Object>} Resolves with the metadata returned by yt-dlp.
*
*/
function fetchInfo(url) { function fetchInfo(url) {
const args = [
/**
* Base yt-dlp arguments used to retrieve metadata
* without downloading media files.
*/
const metadataArgs = [
"--js-runtimes", `deno:${denoPath}`, "--js-runtimes", `deno:${denoPath}`,
"--dump-single-json", "--dump-single-json",
"--ignore-errors", "--ignore-errors",
@@ -16,9 +38,9 @@ function fetchInfo(url) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
execFile(userYtDlp,[...args, url], execFile(userYtDlp,[...metadataArgs , url],
{ timeout: 30_000, // 30s, if more timeout { timeout: 30_000, // 30s, if more timeout
maxBuffer: 10 * 1024 * 1024 }, // 10MO max response (Default: 200ko) maxBuffer: 10 * 1024 * 1024 }, // Maximum response size: 10 MB (Node.js default is much smaller)
(error, stdout, stderr) => { (error, stdout, stderr) => {