mirror of
https://github.com/MasterAcnolo/Freedom-Loader.git
synced 2026-11-13 15:09:20 +01:00
Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
787cc69600 | ||
|
|
f650770df7 | ||
|
|
f0f3018f80 | ||
|
|
061c6b3b66 | ||
|
|
c9bb529b47 | ||
|
|
8c3e1135e5 | ||
|
|
fa5776b254 | ||
|
|
ef68484828 | ||
|
|
fdfe30e796 | ||
|
|
0699656394 | ||
|
|
fbe30f7ee2 | ||
|
|
0da6df03cb | ||
|
|
650696b9f5 | ||
|
|
634108784e | ||
|
|
1b83272aaf | ||
|
|
dc898e1235 | ||
|
|
7c5ccb1527 | ||
|
|
f5259f7ade | ||
|
|
1fcf7cd988 | ||
|
|
2e343cbd0c | ||
|
|
69de9eb9fc | ||
|
|
cfa85649b3 | ||
|
|
773ed9550d | ||
|
|
ea3a475525 | ||
|
|
738d5ec62f | ||
|
|
aff7637358 | ||
|
|
7ee6d6fbe7 | ||
|
|
6aa3a39819 | ||
|
|
f0971ebccc | ||
|
|
18c8621095 | ||
|
|
b2eb3b3b40 | ||
|
|
7af6898301 | ||
|
|
dc8d0a62d7 | ||
|
|
cf65069496 | ||
|
|
a308352ea0 | ||
|
|
3712813833 | ||
|
|
9398329959 | ||
|
|
8614b0e32e | ||
|
|
c212302550 | ||
|
|
0835e13840 | ||
|
|
1c0dd32732 | ||
|
|
1e099f6bb6 | ||
|
|
1937738ab8 | ||
|
|
ac1d74ff23 | ||
|
|
7f86d46ae7 | ||
|
|
187e3af559 | ||
|
|
e8c499fe23 | ||
|
|
99b7164ff8 | ||
|
|
d11c0f9839 | ||
|
|
1e0b2affee | ||
|
|
9975b2b583 | ||
|
|
b319a1e445 | ||
|
|
4a64a40226 | ||
|
|
2c0f5b315b | ||
|
|
58b6637eeb | ||
|
|
8af5a4a383 | ||
|
|
23f04bafc3 | ||
|
|
35ffd7077e | ||
|
|
408df0e2f6 | ||
|
|
7dee94da6f |
4
.github/ISSUE_TEMPLATE/question.md
vendored
4
.github/ISSUE_TEMPLATE/question.md
vendored
@@ -9,8 +9,8 @@ assignees: 'MasterAcnolo'
|
||||
|
||||
## Pre-submission Checklist
|
||||
|
||||
- [ ] I have checked the [FAQ](https://masteracnolo.github.io/Freedom-Loader-Site/#/faq)
|
||||
- [ ] I have reviewed the [Wiki](https://masteracnolo.github.io/Freedom-Loader-Site/#/wiki)
|
||||
- [ ] I have checked the [FAQ](https://freedomloader.acnolo.fr/#/faq)
|
||||
- [ ] I have reviewed the [Wiki](https://freedomloader.acnolo.fr/#/wiki)
|
||||
- [ ] I have searched existing issues
|
||||
|
||||
## Question
|
||||
|
||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -6,12 +6,21 @@
|
||||
/out
|
||||
/srpm-out
|
||||
|
||||
# .env
|
||||
*.env
|
||||
|
||||
# Config JSON File. Create it and fill it if you want to have dev only settings
|
||||
config/config.dev.json
|
||||
|
||||
# IDE
|
||||
/.idea
|
||||
/.vscode
|
||||
.idea/
|
||||
.vscode/
|
||||
.zed/
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
Thumbs.db
|
||||
|
||||
# Linux executable
|
||||
ffmpeg
|
||||
|
||||
@@ -112,8 +112,8 @@ See [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) for details.
|
||||
|
||||
## Questions?
|
||||
|
||||
- Check the [FAQ](https://masteracnolo.github.io/Freedom-Loader-Site/faq)
|
||||
- Review the [Wiki](https://masteracnolo.github.io/Freedom-Loader-Site/wiki)
|
||||
- Check the [FAQ](https://freedomloader.acnolo.fr/faq)
|
||||
- Review the [Wiki](https://freedomloader.acnolo.fr/wiki)
|
||||
- Open a [Question issue](https://github.com/MasterAcnolo/Freedom-Loader/issues/new/choose)
|
||||
|
||||
---
|
||||
|
||||
44
README.md
44
README.md
@@ -1,6 +1,6 @@
|
||||
<div align="center">
|
||||
|
||||
<a href="https://masteracnolo.github.io/Freedom-Loader-Site/"><img
|
||||
<a href="https://freedomloader.acnolo.fr/"><img
|
||||
src="./build/banner.png"
|
||||
alt="Banner"
|
||||
style="width: 50%;"/></a>
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
[](https://github.com/MasterAcnolo/Freedom-Loader/releases)
|
||||
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||
[](https://masteracnolo.github.io/Freedom-Loader-Site/)
|
||||
[](https://freedomloader.acnolo.fr/)
|
||||
[](https://masteracnolo.github.io/Freedom-Loader-Workshop/)
|
||||
<a href="https://www.firefox.com/fr/?utm_campaign=SET_DEFAULT_BROWSER"><img src="https://img.shields.io/badge/Require Firefox-E66000?style=for-the-badge&logo=Firefox-Browser&logoColor=white"></a>
|
||||
|
||||
@@ -56,7 +56,7 @@ The primary goal is to make media downloading accessible to users who want offli
|
||||
|
||||
### User Interface
|
||||
|
||||
- **Custom window controls** - Frameless window with custom top bar (optional)
|
||||
- **Custom window controls** - Frameless window with custom top bar
|
||||
- **Custom themes** - ZIP-based theme system with live preview
|
||||
- **Theme Workshop** - web-based theme creator and browser
|
||||
- **Toast notifications** - in-app feedback system
|
||||
@@ -151,6 +151,7 @@ freedom-loader
|
||||
|---------------|---------------------------------------------------------|--------------------------------------|
|
||||
| **Downloads** | `C:\Users\[USERNAME]\Downloads\Freedom Loader` | `~/Downloads/Freedom Loader` |
|
||||
| **Logs** | `C:\Users\[USERNAME]\AppData\Local\FreedomLoader\logs\` | `~/.local/share/FreedomLoader/logs/` |
|
||||
| **Cache** | ``
|
||||
|
||||
> [!NOTE]
|
||||
> The download folder can be changed anytime via the "Edit" button in the UI, on both platforms.
|
||||
@@ -209,6 +210,9 @@ If you encounter issues:
|
||||
|
||||
Freedom Loader can be configured either through the settings panel in the UI or by directly editing the `config.json` file located in the application data directory.
|
||||
|
||||
When the application starts, any settings missing from an existing `config.json` are automatically added using the
|
||||
defaults from `config/config.default.json`. Existing values and unknown custom keys are preserved.
|
||||
|
||||
### Configuration File Location
|
||||
|
||||
#### Windows
|
||||
@@ -224,14 +228,13 @@ Freedom Loader can be configured either through the settings panel in the UI or
|
||||
### Available Options
|
||||
```json
|
||||
{
|
||||
"autoUpdate": true,
|
||||
"systemTray": true,
|
||||
"discordRPC": true,
|
||||
"customTopBar": true,
|
||||
"autoCheckInfo": true,
|
||||
"addThumbnail": true,
|
||||
"addMetadata": true,
|
||||
"verboseLogs": false,
|
||||
"autoUpdateYtDlp": true,
|
||||
"autoDownloadPlaylist": true,
|
||||
"keepPlaylistOrder": false,
|
||||
"createPlaylistFolders": true,
|
||||
@@ -241,6 +244,7 @@ Freedom Loader can be configured either through the settings panel in the UI or
|
||||
"downloadSystem": true,
|
||||
"notifySystem": true,
|
||||
"enableHardwareAcceleration": true,
|
||||
"showChanglogsAfterUpdate": true,
|
||||
"theme": "dark"
|
||||
}
|
||||
```
|
||||
@@ -249,14 +253,13 @@ Freedom Loader can be configured either through the settings panel in the UI or
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|------------------------------|---------|----------|------------------------------------------------------------|
|
||||
| `autoUpdate` | boolean | `true` | Enable automatic application updates |
|
||||
| `systemTray` | boolean | `false` | Allow app to minimize on close [EXPERIMENTAL] |
|
||||
| `systemTray` | boolean | `false` | Allow app to minimize on close |
|
||||
| `discordRPC` | boolean | `true` | Enable Discord Rich Presence integration |
|
||||
| `customTopBar` | boolean | `true` | Use custom application top bar |
|
||||
| `autoCheckInfo` | boolean | `true` | Automatically fetch video information on URL paste |
|
||||
| `addThumbnail` | boolean | `true` | Embed thumbnail in downloaded audio files |
|
||||
| `addMetadata` | boolean | `true` | Add metadata tags to downloaded files |
|
||||
| `verboseLogs` | boolean | `false` | Enable detailed logging for debugging |
|
||||
| `autoUpdateYtDlp` | boolean | `true` | Automatically update yt-dlp when the app starts |
|
||||
| `autoDownloadPlaylist` | boolean | `true` | Automatically download entire playlists |
|
||||
| `keepPlaylistOrder` | boolean | `false` | Add index before title, usefull for album |
|
||||
| `createPlaylistFolders` | boolean | `false` | Automatically create a folder for a playlist |
|
||||
@@ -266,6 +269,7 @@ Freedom Loader can be configured either through the settings panel in the UI or
|
||||
| `downloadSystem` | boolean | `true` | Enable download system |
|
||||
| `notifySystem` | boolean | `true` | Enable system notifications |
|
||||
| `enableHardwareAcceleration` | boolean | `true` | Enable Hardware Acceleration |
|
||||
| `showChanglogsAfterUpdate` | boolean | `true` | Auto-display changelog popup after and update |
|
||||
| `theme` | string | `dark` | Current Theme Name |
|
||||
|
||||
### Supported Codecs
|
||||
@@ -354,7 +358,7 @@ See [BINARIES.md](./BINARIES.md) for how to setup the binaries.
|
||||
|
||||
## Roadmap
|
||||
|
||||
See the most recent Roadmap [Here](https://masteracnolo.github.io/Freedom-Loader-Site/Roadmap).
|
||||
See the most recent Roadmap [Here](https://freedomloader.acnolo.fr/Roadmap).
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -367,13 +371,29 @@ Head over to the [Freedom Loader Workshop repository](https://github.com/MasterA
|
||||
|
||||
### Bug Reports
|
||||
|
||||
Use the GitHub Issues system and include:
|
||||
You can report bugs either using the built-in application system or via the GitHub Issues system.
|
||||
|
||||
#### Using the Built-in Report Tool
|
||||
|
||||
1. Open Freedom Loader.
|
||||
2. Trigger the bug report interface.
|
||||
3. Fill in the title and description of the issue.
|
||||
4. Choose whether to include today's logs automatically.
|
||||
5. Submit the report.
|
||||
|
||||
> [!NOTE]
|
||||
> If you need to attach extra context like screenshots or screen recordings, it is recommended to use the GitHub Issues
|
||||
system instead.
|
||||
|
||||
#### Using GitHub Issues
|
||||
|
||||
Open a new issue on the GitHub repository and include:
|
||||
|
||||
- Clear description of the issue
|
||||
- Steps to reproduce
|
||||
- Expected vs actual behavior
|
||||
- Relevant logs from `AppData\Local\FreedomLoader\logs\` or `~/.local/share/FreedomLoader/logs/`
|
||||
- Screenshots if applicable
|
||||
- Screenshots or recordings if applicable
|
||||
|
||||
### Feature Requests
|
||||
|
||||
@@ -418,6 +438,6 @@ You are free to use, modify, and redistribute this software under the terms of t
|
||||
|
||||
**Freedom Loader** - Put freedom in your downloads
|
||||
|
||||
[Website](https://masteracnolo.github.io/Freedom-Loader-Site/) • [Download](https://github.com/MasterAcnolo/Freedom-Loader/releases) • [Documentation](https://masteracnolo.github.io/Freedom-Loader-Site/wiki) • [Report Bug](https://github.com/MasterAcnolo/Freedom-Loader/issues)
|
||||
[Website](https://freedomloader.acnolo.fr/) • [Download](https://github.com/MasterAcnolo/Freedom-Loader/releases) • [Documentation](https://freedomloader.acnolo.fr/wiki) • [Report Bug](https://github.com/MasterAcnolo/Freedom-Loader/issues)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
*/
|
||||
|
||||
const { autoUpdater } = require("electron-updater");
|
||||
const { dialog } = require("electron");
|
||||
const { logger } = require("../server/logger");
|
||||
|
||||
/**
|
||||
@@ -21,6 +20,26 @@ const { logger } = require("../server/logger");
|
||||
autoUpdater.autoDownload = false;
|
||||
autoUpdater.autoInstallOnAppQuit = false;
|
||||
|
||||
let updateAvailable = false;
|
||||
|
||||
/**
|
||||
* Determines how the update should be handled based on the platform
|
||||
* @returns {"auto" | "snap" | "flatpak" | "linux-native"}
|
||||
*/
|
||||
function getUpdateEnvironment() {
|
||||
if (
|
||||
process.env.APPIMAGE ||
|
||||
process.platform === "win32" ||
|
||||
process.platform === "darwin"
|
||||
) {
|
||||
return "auto";
|
||||
}
|
||||
if (process.env.SNAP) return "snap";
|
||||
if (process.env.FLATPAK_ID) return "flatpak";
|
||||
|
||||
return "linux-native";
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes application auto-update lifecycle.
|
||||
*
|
||||
@@ -32,7 +51,6 @@ autoUpdater.autoInstallOnAppQuit = false;
|
||||
* @param {BrowserWindow} mainWindow - main Electron window instance
|
||||
*/
|
||||
function initAutoUpdater(mainWindow) {
|
||||
|
||||
/**
|
||||
* Triggered when a new version is detected.
|
||||
* Prompts user to install or defer update.
|
||||
@@ -40,23 +58,12 @@ function initAutoUpdater(mainWindow) {
|
||||
autoUpdater.on("update-available", async (info) => {
|
||||
logger.info(`Update available: ${info.version}`);
|
||||
|
||||
const { response } = await dialog.showMessageBox(mainWindow, {
|
||||
type: "info",
|
||||
title: "Update Available",
|
||||
message: `Version ${info.version} is available.`,
|
||||
detail: "Would you like to download and install it now?",
|
||||
buttons: ["Install Update", "Maybe Later"],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
mainWindow?.webContents.send("update-available", {
|
||||
version: info.version,
|
||||
environment: getUpdateEnvironment(),
|
||||
});
|
||||
updateAvailable = true;
|
||||
|
||||
if (response === 0) {
|
||||
await autoUpdater.downloadUpdate();
|
||||
} else {
|
||||
mainWindow?.webContents.executeJavaScript(
|
||||
`window.showUpdateBadge && window.showUpdateBadge("${info.version}")`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -66,8 +73,10 @@ function initAutoUpdater(mainWindow) {
|
||||
autoUpdater.on("download-progress", (progress) => {
|
||||
logger.info(`Download progress: ${Math.round(progress.percent)}%`);
|
||||
mainWindow?.webContents.send("update-progress", {
|
||||
percent: Math.round(progress.percent),
|
||||
speed: progress.bytesPerSecond,
|
||||
percent: progress.percent,
|
||||
bytesPerSecond: progress.bytesPerSecond,
|
||||
transferred: progress.transferred,
|
||||
total: progress.total,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,17 +87,9 @@ function initAutoUpdater(mainWindow) {
|
||||
autoUpdater.on("update-downloaded", async (info) => {
|
||||
logger.info(`Update downloaded: ${info.version}`);
|
||||
|
||||
const { response } = await dialog.showMessageBox(mainWindow, {
|
||||
type: "info",
|
||||
title: "Update Ready",
|
||||
message: `Version ${info.version} has been downloaded.`,
|
||||
detail: "The application will restart to apply the update.",
|
||||
buttons: ["Install Now", "Later"],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
});
|
||||
mainWindow?.webContents.send("update-downloaded", info);
|
||||
|
||||
|
||||
if (response === 0) autoUpdater.quitAndInstall();
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -102,14 +103,16 @@ function initAutoUpdater(mainWindow) {
|
||||
* If no update is available, I put this because there is no Linux version before 1.6.0
|
||||
* @type {boolean}
|
||||
*/
|
||||
const isNoUpdateAvailable = /404/.test(msg) || /Cannot find latest.*\.yml/i.test(msg);
|
||||
const isNoUpdateAvailable =
|
||||
/404/.test(msg) || /Cannot find latest.*\.yml/i.test(msg);
|
||||
|
||||
if (isNoUpdateAvailable) {
|
||||
logger.warn("Auto update: no update metadata found (probably no previous release), ignoring", msg);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.error("Auto update error:", msg);
|
||||
logger.error("Auto update error:", err);
|
||||
mainWindow?.webContents.send("update-error", msg);
|
||||
});
|
||||
|
||||
checkForUpdates();
|
||||
@@ -121,18 +124,9 @@ function initAutoUpdater(mainWindow) {
|
||||
*/
|
||||
async function checkForUpdates() {
|
||||
|
||||
/**
|
||||
* Prevents electron-updater from running inside sandboxed environments.
|
||||
* Snap and Flatpak lock the file system in read-only mode, causing the updater to crash.
|
||||
* In these environments, updates are safely handled by snapd or the Flatpak runtime.
|
||||
* Standalone Linux packages (.deb, .rpm, AppImage) will bypass this and update normally.
|
||||
*/
|
||||
if (process.env.SNAP || process.env.FLATPAK_ID) {
|
||||
logger.info("Running as Snap or Flatpak. Updates are managed by the OS store. Skipping electron-updater.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!require("electron").app.isPackaged) return ;
|
||||
// If app is was installed from SNAP or Flatpak.
|
||||
if (process.env.SNAP || process.env.FLATPAK_ID) return;
|
||||
if (!require("electron").app.isPackaged) return;
|
||||
|
||||
try {
|
||||
await autoUpdater.checkForUpdates();
|
||||
@@ -150,6 +144,7 @@ async function downloadUpdate() {
|
||||
await autoUpdater.downloadUpdate();
|
||||
} catch (err) {
|
||||
logger.error("Download failed:", err.message);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,4 +155,13 @@ function installUpdate() {
|
||||
autoUpdater.quitAndInstall();
|
||||
}
|
||||
|
||||
module.exports = { initAutoUpdater, downloadUpdate, installUpdate };
|
||||
function isUpdateAvailable() {
|
||||
return updateAvailable;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
initAutoUpdater,
|
||||
downloadUpdate,
|
||||
installUpdate,
|
||||
isUpdateAvailable,
|
||||
};
|
||||
|
||||
177
app/changelogManager.js
Normal file
177
app/changelogManager.js
Normal file
@@ -0,0 +1,177 @@
|
||||
const { app } = require("electron");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const https = require("https");
|
||||
const config = require("../config");
|
||||
const { logger } = require("../server/logger");
|
||||
|
||||
/** Maximum age of the cached release list before it must be refreshed. */
|
||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Filename used to persist changelog data in the Electron user data folder. */
|
||||
const CACHE_FILE_NAME = "changelog-cache.json";
|
||||
|
||||
/** GitHub API endpoint containing the application's published releases. */
|
||||
const RELEASES_URL = "https://api.github.com/repos/MasterAcnolo/Freedom-Loader/releases";
|
||||
|
||||
/** Returns the path of the local changelog cache file. */
|
||||
function getCachePath() {
|
||||
return path.join(app.getPath("userData"), CACHE_FILE_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the numeric major, minor, and patch parts from a version string.
|
||||
*
|
||||
* @param {string} version - Version string or release tag
|
||||
* @returns {number[]|null} Parsed version parts, or null when invalid
|
||||
*/
|
||||
function normalizeVersion(version) {
|
||||
const match = String(version || "").match(/(\d+)\.(\d+)\.(\d+)/);
|
||||
return match ? match.slice(1).map(Number) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares two semantic version strings.
|
||||
*
|
||||
* @param {string} left - First version to compare
|
||||
* @param {string} right - Second version to compare
|
||||
* @returns {number} Negative, zero, or positive comparison result
|
||||
*/
|
||||
function compareVersions(left, right) {
|
||||
const leftParts = normalizeVersion(left) || [0, 0, 0];
|
||||
const rightParts = normalizeVersion(right) || [0, 0, 0];
|
||||
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
if (leftParts[index] !== rightParts[index]) {
|
||||
return leftParts[index] - rightParts[index];
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Reads and parses the cached changelog, if one is available. */
|
||||
function readCache() {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(getCachePath(), "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Persists the fetched releases together with their cache timestamp. */
|
||||
function writeCache(releases) {
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
getCachePath(),
|
||||
JSON.stringify({ cachedAt: Date.now(), releases }, null, 2),
|
||||
"utf8",
|
||||
);
|
||||
} catch (error) {
|
||||
logger.warn(`Unable to cache changelog: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches published releases from GitHub and maps them to the UI data shape.
|
||||
*
|
||||
* @returns {Promise<object[]>} Published releases
|
||||
*/
|
||||
function fetchReleases() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = https.get(
|
||||
RELEASES_URL,
|
||||
{
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
"User-Agent": "Freedom-Loader",
|
||||
},
|
||||
},
|
||||
(response) => {
|
||||
let body = "";
|
||||
response.setEncoding("utf8");
|
||||
response.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
response.on("end", () => {
|
||||
// Do not attempt to parse an error response as a release list.
|
||||
if (response.statusCode !== 200) {
|
||||
reject(new Error(`GitHub returned HTTP ${response.statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Draft releases are not visible to users and should not appear in the changelog.
|
||||
const releases = JSON.parse(body)
|
||||
.filter((release) => !release.draft)
|
||||
.map((release) => ({
|
||||
version: release.tag_name,
|
||||
name: release.name || release.tag_name,
|
||||
date: release.published_at || release.created_at,
|
||||
body: release.body || "No changelog available.",
|
||||
url: release.html_url,
|
||||
}));
|
||||
resolve(releases);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
request.setTimeout(10000, () => {
|
||||
request.destroy(new Error("Changelog request timed out"));
|
||||
});
|
||||
request.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns cached or freshly fetched releases and identifies unread versions.
|
||||
*
|
||||
* @returns {Promise<object>} Changelog data for the renderer
|
||||
*/
|
||||
async function getChangelog() {
|
||||
const cache = readCache();
|
||||
let releases = cache?.releases || [];
|
||||
const cacheIsFresh = cache && cache.releases?.length > 0 && Date.now() - cache.cachedAt < CACHE_TTL_MS;
|
||||
|
||||
// Refresh stale or missing data, while keeping the last cache as a fallback.
|
||||
if (!cacheIsFresh) {
|
||||
try {
|
||||
releases = await fetchReleases();
|
||||
writeCache(releases);
|
||||
} catch (error) {
|
||||
logger.warn(`Unable to retrieve changelog: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const lastSeenVersion = cache?.lastSeenVersion || null;
|
||||
|
||||
// Without a previous version, all available releases are considered unread.
|
||||
const unreadReleases = lastSeenVersion
|
||||
? releases.filter((release) => compareVersions(release.version, lastSeenVersion) > 0)
|
||||
: releases;
|
||||
|
||||
return {
|
||||
// An empty release list means that the fetch failed or returned no releases.
|
||||
error: releases.length === 0,
|
||||
currentVersion: config.version,
|
||||
lastSeenVersion,
|
||||
hasUnread: unreadReleases.length > 0,
|
||||
releases,
|
||||
unreadReleases,
|
||||
};
|
||||
}
|
||||
|
||||
/** Marks the current application version as seen in the local cache. */
|
||||
function markChangelogSeen() {
|
||||
const cache = readCache() || { releases: [] };
|
||||
cache.lastSeenVersion = config.version;
|
||||
cache.cachedAt = cache.cachedAt || 0 ;
|
||||
fs.mkdirSync(path.dirname(getCachePath()), { recursive: true });
|
||||
fs.writeFileSync(getCachePath(), JSON.stringify(cache, null, 2), "utf8");
|
||||
return true;
|
||||
}
|
||||
|
||||
module.exports = { getChangelog, markChangelogSeen };
|
||||
@@ -95,9 +95,9 @@ function startRPC() {
|
||||
smallImageText: "By MasterAcnolo",
|
||||
startTimestamp: new Date(),
|
||||
details: `Open Source Download Tools - ${config.version}`,
|
||||
state: "masteracnolo.github.io/FreedomLoader",
|
||||
state: "freedomloader.acnolo.fr",
|
||||
};
|
||||
rpc.clearActivity()
|
||||
rpc.clearActivity();
|
||||
rpc.setActivity(presence);
|
||||
|
||||
});
|
||||
|
||||
162
app/ipcHandlers.js
Normal file → Executable file
162
app/ipcHandlers.js
Normal file → Executable file
@@ -13,12 +13,15 @@
|
||||
const { ipcMain, dialog, shell } = require("electron");
|
||||
const fs = require("fs");
|
||||
const { logger, logDir } = require("../server/logger");
|
||||
const {configFeatures, featuresPath, devMode} = require("../config");
|
||||
const { configFeatures, featuresPath, devMode } = require("../config");
|
||||
const { getThemes, reloadThemes } = require("./themeManager");
|
||||
const config = require("../config");
|
||||
const { validateDownloadPath, getDefaultDownloadPath } = require("./pathValidator");
|
||||
const { userThemesPath } = require("../server/helpers/path.helpers");
|
||||
const { createSystemTray, destroyTray } = require("./tray");
|
||||
const {sendReport, checkReportService, getReportServiceStatus} = require("./sendReport");
|
||||
const { isUpdateAvailable, downloadUpdate, installUpdate } = require("./autoUpdater");
|
||||
const { getChangelog, markChangelogSeen } = require("./changelogManager");
|
||||
|
||||
/**
|
||||
* Security whitelist for feature flags that can be modified at runtime.
|
||||
@@ -27,21 +30,21 @@ const { createSystemTray, destroyTray } = require("./tray");
|
||||
* Acts as a basic validation layer for IPC "set-feature".
|
||||
*/
|
||||
const FEATURE_WHITELIST = new Set([
|
||||
"autoUpdate",
|
||||
"systemTray",
|
||||
"discordRPC",
|
||||
"customTopBar",
|
||||
"autoCheckInfo",
|
||||
"addThumbnail",
|
||||
"addMetadata",
|
||||
"verboseLogs",
|
||||
"autoUpdateYtDlp",
|
||||
"autoDownloadPlaylist",
|
||||
"keepPlaylistOrder",
|
||||
"customCodec",
|
||||
"theme",
|
||||
"createPlaylistFolders",
|
||||
"notifySystem",
|
||||
"enableHardwareAcceleration"
|
||||
"enableHardwareAcceleration",
|
||||
"showChanglogsAfterUpdate"
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -64,12 +67,21 @@ const themeFolderPath = userThemesPath;
|
||||
* @param {Function} getMainWindow - Function returning current BrowserWindow instance
|
||||
*/
|
||||
function registerIpcHandlers(getMainWindow) {
|
||||
|
||||
/**
|
||||
* Returns application version from config.
|
||||
*/
|
||||
ipcMain.handle("version", () => config.version);
|
||||
|
||||
/**
|
||||
* Retrieves the changelog from cache or GitHub API.
|
||||
*/
|
||||
ipcMain.handle("get-changelog", () => getChangelog());
|
||||
|
||||
/**
|
||||
* Marks all changelog entries as seen and updates cache.
|
||||
*/
|
||||
ipcMain.handle("mark-changelog-seen", () => markChangelogSeen());
|
||||
|
||||
/**
|
||||
* Returns runtime feature configuration object.
|
||||
*/
|
||||
@@ -115,17 +127,16 @@ function registerIpcHandlers(getMainWindow) {
|
||||
* Window minimize request from renderer.
|
||||
*/
|
||||
ipcMain.on("window-minimize", () => {
|
||||
// Minimize to tray
|
||||
if (configFeatures.systemTray) {
|
||||
getMainWindow()?.hide()
|
||||
logger.info("Window Minimized in SystemTray (Using Minimize Button)");
|
||||
} else {
|
||||
// Native minimize
|
||||
getMainWindow()?.minimize()
|
||||
logger.info("Window minimized normally");
|
||||
}
|
||||
}
|
||||
);
|
||||
// Minimize to tray
|
||||
if (configFeatures.systemTray) {
|
||||
getMainWindow()?.hide();
|
||||
logger.info("Window Minimized in SystemTray (Using Minimize Button)");
|
||||
} else {
|
||||
// Native minimize
|
||||
getMainWindow()?.minimize();
|
||||
logger.info("Window minimized normally");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Toggles maximize/unmaximize state of main window.
|
||||
@@ -141,7 +152,6 @@ function registerIpcHandlers(getMainWindow) {
|
||||
*/
|
||||
ipcMain.on("window-close", () => getMainWindow()?.close());
|
||||
|
||||
|
||||
/**
|
||||
* Opens Chromium DevTools in detached mode.
|
||||
*/
|
||||
@@ -152,20 +162,20 @@ function registerIpcHandlers(getMainWindow) {
|
||||
/**
|
||||
* 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", () =>
|
||||
shell.openExternal("https://masteracnolo.github.io/Freedom-Loader-Site/")
|
||||
shell.openExternal("https://freedomloader.acnolo.fr/")
|
||||
);
|
||||
|
||||
/**
|
||||
* Opens official wiki page in external browser.
|
||||
*/
|
||||
ipcMain.on("open-wiki", () =>
|
||||
shell.openExternal("https://masteracnolo.github.io/Freedom-Loader-Site/wiki")
|
||||
shell.openExternal("https://freedomloader.acnolo.fr/wiki")
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -180,7 +190,12 @@ function registerIpcHandlers(getMainWindow) {
|
||||
*/
|
||||
ipcMain.on("open-config", () => shell.openPath(configFolderPath));
|
||||
|
||||
|
||||
/**
|
||||
* Front end logger
|
||||
*/
|
||||
ipcMain.on("log-error", (_, message) => logger.error(`[Frontend] ${message}`));
|
||||
ipcMain.on("log-info", (_, message) => logger.info(`[Frontend] ${message}`));
|
||||
ipcMain.on("log-warn", (_, message) => logger.warn(`[Frontend] ${message}`));
|
||||
|
||||
/**
|
||||
* Retrieves available themes from filesystem.
|
||||
@@ -199,6 +214,36 @@ function registerIpcHandlers(getMainWindow) {
|
||||
return await reloadThemes();
|
||||
});
|
||||
|
||||
/**
|
||||
* Send bug report
|
||||
*/
|
||||
ipcMain.handle("send-report", async (_, params) => {
|
||||
return await sendReport(params);
|
||||
});
|
||||
|
||||
ipcMain.handle("get-report-service-status", async () => {
|
||||
await checkReportService();
|
||||
return getReportServiceStatus();
|
||||
});
|
||||
|
||||
ipcMain.handle("is-update-available", () => {
|
||||
return isUpdateAvailable();
|
||||
});
|
||||
|
||||
ipcMain.handle("download-update", async () => {
|
||||
return await downloadUpdate();
|
||||
});
|
||||
|
||||
ipcMain.handle("install-update", async () => {
|
||||
return installUpdate();
|
||||
});
|
||||
|
||||
ipcMain.on("open-release-page", () => {
|
||||
shell.openExternal(
|
||||
"https://github.com/MasterAcnolo/Freedom-Loader/releases/latest",
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Updates a runtime feature flag and persists it to disk.
|
||||
*
|
||||
@@ -216,47 +261,46 @@ function registerIpcHandlers(getMainWindow) {
|
||||
* @returns {boolean} success state
|
||||
*/
|
||||
ipcMain.handle("set-feature", (event, { key, value }) => {
|
||||
try {
|
||||
if (!FEATURE_WHITELIST.has(key)) {
|
||||
logger.warn(`Rejected feature (not whitelisted): ${key}`);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
if (!FEATURE_WHITELIST.has(key)) {
|
||||
logger.warn(`Rejected feature (not whitelisted): ${key}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (configFeatures[key] === value) {
|
||||
return true;
|
||||
}
|
||||
if (configFeatures[key] === value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically intercepts and applies System Tray state changes at runtime.
|
||||
* Instantiates or destroys the tray icon immediately to prevent window
|
||||
* lifecycle desynchronization (e.g., minimizing a window to a non-existent tray).
|
||||
*/
|
||||
if (key === "systemTray") {
|
||||
if (value === true) {
|
||||
logger.info("System Tray enabled dynamically.");
|
||||
createSystemTray(devMode);
|
||||
} else {
|
||||
logger.info("System Tray disabled dynamically.");
|
||||
destroyTray();
|
||||
}
|
||||
}
|
||||
|
||||
configFeatures[key] = value;
|
||||
|
||||
fs.writeFileSync(
|
||||
configFolderPath,
|
||||
JSON.stringify(configFeatures, null, 2),
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
logger.info(`Feature updated: ${key} = ${value}`);
|
||||
return true;
|
||||
|
||||
} catch (err) {
|
||||
logger.error(`set-feature failed (${key}): ${err.message}`);
|
||||
return false;
|
||||
/**
|
||||
* Dynamically intercepts and applies System Tray state changes at runtime.
|
||||
* Instantiates or destroys the tray icon immediately to prevent window
|
||||
* lifecycle desynchronization (e.g., minimizing a window to a non-existent tray).
|
||||
*/
|
||||
if (key === "systemTray") {
|
||||
if (value === true) {
|
||||
logger.info("System Tray enabled dynamically.");
|
||||
createSystemTray(devMode);
|
||||
} else {
|
||||
logger.info("System Tray disabled dynamically.");
|
||||
destroyTray();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
configFeatures[key] = value;
|
||||
|
||||
fs.writeFileSync(
|
||||
configFolderPath,
|
||||
JSON.stringify(configFeatures, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
logger.info(`Feature updated: ${key} = ${value}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.error(`set-feature failed (${key}): ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerIpcHandlers };
|
||||
108
app/sendReport.js
Normal file
108
app/sendReport.js
Normal file
@@ -0,0 +1,108 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const {logger, logDir} = require("../server/logger");
|
||||
|
||||
const REPORT_URL = "https://freedom-loader-report.acnolo.fr";
|
||||
const REPORT_SERVICE_TIMEOUT_MS = 5000;
|
||||
let reportServiceStatus = {
|
||||
status: "checking",
|
||||
message: "Checking bug report service..."
|
||||
};
|
||||
let reportServiceCheck = null;
|
||||
|
||||
async function checkReportService() {
|
||||
if (reportServiceCheck) {
|
||||
return reportServiceCheck;
|
||||
}
|
||||
|
||||
reportServiceCheck = (async () => {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), REPORT_SERVICE_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(REPORT_URL, {
|
||||
method: "GET",
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
if (response.status >= 500) {
|
||||
throw new Error(`Server responded with status ${response.status}`);
|
||||
}
|
||||
|
||||
reportServiceStatus = {
|
||||
status: "online",
|
||||
message: "Bug report service is available."
|
||||
};
|
||||
logger.info(`Bug report service is reachable (status ${response.status}).`);
|
||||
} catch (err) {
|
||||
reportServiceStatus = {
|
||||
status: "offline",
|
||||
message: "Bug report service is unavailable. Reports may fail to send."
|
||||
};
|
||||
logger.warn(`Bug report service is unreachable: ${err.message}`);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
return reportServiceStatus;
|
||||
})();
|
||||
|
||||
return reportServiceCheck;
|
||||
}
|
||||
|
||||
function getReportServiceStatus() {
|
||||
return reportServiceStatus;
|
||||
}
|
||||
|
||||
async function sendReport(params) {
|
||||
try {
|
||||
const {title, description, includeLogs} = params;
|
||||
|
||||
if (!title || !description) {
|
||||
throw new Error("Title and description are required.");
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("title", title);
|
||||
formData.append("context", description);
|
||||
|
||||
if (includeLogs === "yes" && logDir) {
|
||||
// YYYY-MM-DD
|
||||
const now = new Date();
|
||||
const today = [
|
||||
now.getFullYear(),
|
||||
String(now.getMonth() + 1).padStart(2, "0"),
|
||||
String(now.getDate()).padStart(2, "0")
|
||||
].join("-");
|
||||
|
||||
const logFilePath = path.join(logDir, `LOGS-${today}.log`);
|
||||
|
||||
if (fs.existsSync(logFilePath)) {
|
||||
const logFileContent = fs.readFileSync(logFilePath);
|
||||
const blob = new Blob([logFileContent], {type: "text/plain"});
|
||||
formData.append("file", blob, `LOGS-${today}.log`);
|
||||
logger.info("Logs attached to bug report.");
|
||||
} else {
|
||||
logger.warn(`Log file not found for today: ${logFilePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(REPORT_URL, {
|
||||
method: "POST",
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error(`Server returned an error: ${response.status}`);
|
||||
throw new Error(`Server responded with status ${response.status}`);
|
||||
}
|
||||
|
||||
logger.info("Bug report successfully sent.");
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.error(`Failed to send bug report: ${err.message}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {sendReport, checkReportService, getReportServiceStatus};
|
||||
22
app/toastNotifier.js
Normal file
22
app/toastNotifier.js
Normal file
@@ -0,0 +1,22 @@
|
||||
const {getMainWindow} = require("./windowManager");
|
||||
|
||||
/**
|
||||
* Sends a toast notification to the renderer UI when the main window is ready.
|
||||
*
|
||||
* @param {string} message
|
||||
* @param {"success"|"error"|"warning"|"info"} [type="info"]
|
||||
* @param {number} [duration=4000]
|
||||
* @returns {boolean} Whether the toast was sent.
|
||||
*/
|
||||
function sendToast(message, type = "info", duration = 4000) {
|
||||
const mainWindow = getMainWindow();
|
||||
|
||||
if (!mainWindow || mainWindow.isDestroyed() || mainWindow.webContents.isDestroyed()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
mainWindow.webContents.send("show-toast", {message, type, duration});
|
||||
return true;
|
||||
}
|
||||
|
||||
module.exports = {sendToast};
|
||||
@@ -95,7 +95,7 @@ function createSystemTray(devMode) {
|
||||
} else {
|
||||
window.show();
|
||||
window.focus();
|
||||
logger.info("Window Restored from SystemTray (Double Click on Tray Icon)")
|
||||
logger.info("Window Restored from SystemTray (Double Click on Tray Icon)");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ async function createMainWindow() {
|
||||
height: 800,
|
||||
minWidth: 750,
|
||||
minHeight: 800,
|
||||
frame: !configFeatures.customTopBar,
|
||||
frame: false, // Force custom topbar for consistent UI across platforms, and less maintenance headaches with native titlebar actions
|
||||
devTools: !app.isPackaged,
|
||||
show: false,
|
||||
webPreferences: {
|
||||
|
||||
62
config.js
62
config.js
@@ -1,5 +1,10 @@
|
||||
const packageJson = require("./package.json");
|
||||
const { app } = require("electron");
|
||||
let app;
|
||||
try {
|
||||
app = require("electron").app;
|
||||
} catch (e) {
|
||||
app = null;
|
||||
}
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
@@ -7,7 +12,7 @@ const path = require("path");
|
||||
* Indicates whether the application is running in development mode.
|
||||
* Determined by Electron's packaging state.
|
||||
*/
|
||||
const devMode = !app.isPackaged || process.env.NODE_ENV === "test";
|
||||
const devMode = app && !app.isPackaged ? true : app ? !app.isPackaged || process.env.NODE_ENV === "test" : process.env.NODE_ENV !== "production";
|
||||
|
||||
/**
|
||||
* Resolves the configuration file path depending on runtime environment.
|
||||
@@ -57,6 +62,40 @@ function resolveConfigPath() {
|
||||
*/
|
||||
const featuresPath = resolveConfigPath();
|
||||
|
||||
/**
|
||||
* Resolves the bundled default configuration path.
|
||||
*
|
||||
* @returns {string} Absolute path to the default configuration file
|
||||
*/
|
||||
function getDefaultConfigPath() {
|
||||
return devMode
|
||||
? path.join(__dirname, "config", "config.default.json")
|
||||
: path.join(process.resourcesPath, "config", "config.default.json");
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds missing default feature flags without overwriting user settings.
|
||||
*
|
||||
* Unknown user-defined keys are intentionally preserved.
|
||||
*
|
||||
* @param {Object} defaults - Default feature flags
|
||||
* @param {Object} current - User feature flags
|
||||
* @returns {{features: Object, changed: boolean}} Merged features and migration state
|
||||
*/
|
||||
function mergeMissingFeatures(defaults, current) {
|
||||
if (!defaults || typeof defaults !== "object" || Array.isArray(defaults)) {
|
||||
throw new Error("Default configuration must be a JSON object");
|
||||
}
|
||||
if (!current || typeof current !== "object" || Array.isArray(current)) {
|
||||
throw new Error("User configuration must be a JSON object");
|
||||
}
|
||||
|
||||
const features = {...defaults, ...current};
|
||||
const changed = Object.keys(defaults).some((key) => !Object.prototype.hasOwnProperty.call(current, key));
|
||||
|
||||
return {features, changed};
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads and parses the feature configuration file.
|
||||
*
|
||||
@@ -64,8 +103,15 @@ const featuresPath = resolveConfigPath();
|
||||
* @throws {Error} If the configuration file is missing or invalid
|
||||
*/
|
||||
function loadFeatures() {
|
||||
const raw = fs.readFileSync(featuresPath, "utf-8");
|
||||
return JSON.parse(raw);
|
||||
const defaults = JSON.parse(fs.readFileSync(getDefaultConfigPath(), "utf-8"));
|
||||
const current = JSON.parse(fs.readFileSync(featuresPath, "utf-8"));
|
||||
const {features, changed} = mergeMissingFeatures(defaults, current);
|
||||
|
||||
if (changed) {
|
||||
fs.writeFileSync(featuresPath, JSON.stringify(features, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
return features;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,8 +123,7 @@ function loadFeatures() {
|
||||
*/
|
||||
function reloadFeatures() {
|
||||
try {
|
||||
const raw = fs.readFileSync(featuresPath, "utf-8");
|
||||
return JSON.parse(raw);
|
||||
return loadFeatures();
|
||||
} catch (err) {
|
||||
// Fallback to cached snapshot if file read fails
|
||||
return configFeatures;
|
||||
@@ -128,4 +173,9 @@ module.exports = {
|
||||
* Path to the active configuration file
|
||||
*/
|
||||
featuresPath,
|
||||
|
||||
/**
|
||||
* Merges missing default feature flags into user configuration without overwriting existing settings.
|
||||
*/
|
||||
mergeMissingFeatures,
|
||||
};
|
||||
@@ -1,12 +1,11 @@
|
||||
{
|
||||
"autoUpdate": true,
|
||||
"systemTray": false,
|
||||
"discordRPC": true,
|
||||
"customTopBar": true,
|
||||
"autoCheckInfo": true,
|
||||
"addThumbnail": true,
|
||||
"addMetadata": true,
|
||||
"verboseLogs": false,
|
||||
"autoUpdateYtDlp": true,
|
||||
"autoDownloadPlaylist": true,
|
||||
"keepPlaylistOrder": false,
|
||||
"createPlaylistFolders": false,
|
||||
@@ -16,5 +15,6 @@
|
||||
"downloadSystem": true,
|
||||
"notifySystem": true,
|
||||
"enableHardwareAcceleration": true,
|
||||
"showChanglogsAfterUpdate": true,
|
||||
"theme": "dark"
|
||||
}
|
||||
40
eslint.config.js
Normal file
40
eslint.config.js
Normal file
@@ -0,0 +1,40 @@
|
||||
const js = require("@eslint/js");
|
||||
const globals = require("globals");
|
||||
|
||||
module.exports = [
|
||||
js.configs.recommended,
|
||||
|
||||
{ignores: ["node_modules/**", "srpm-out/**", "dist/**", "test/**"]},
|
||||
|
||||
{
|
||||
languageOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "commonjs",
|
||||
},
|
||||
rules: {
|
||||
"no-unused-vars": ["error", {argsIgnorePattern: "^_"}],
|
||||
"no-console": "off",
|
||||
"semi": ["error", "always"]
|
||||
},
|
||||
|
||||
|
||||
},
|
||||
{
|
||||
files: ["app/**", "server/**", "main.js"],
|
||||
languageOptions: {
|
||||
globals: {...globals.node}
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ["public/**"],
|
||||
languageOptions: {
|
||||
globals: {...globals.browser}
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ["preload.js"],
|
||||
languageOptions: {
|
||||
globals: {...globals.node, ...globals.browser}
|
||||
}
|
||||
}
|
||||
];
|
||||
20
main.js
20
main.js
@@ -13,6 +13,9 @@ process.on("unhandledRejection", (reason) => {
|
||||
app.quit();
|
||||
});
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
app.setDesktopName('com.masteracnolo.freedomloader.desktop');
|
||||
}
|
||||
|
||||
/**
|
||||
* True if this is the primary instance (lock acquired successfully)
|
||||
@@ -48,9 +51,9 @@ const { configFeatures, devMode} = require("./config");
|
||||
*/
|
||||
if (!configFeatures.enableHardwareAcceleration){
|
||||
app.disableHardwareAcceleration();
|
||||
logger.info("Disabled Hardware Acceleration")
|
||||
logger.info("Disabled Hardware Acceleration");
|
||||
} else {
|
||||
logger.info("Enable Hardware Acceleration")
|
||||
logger.info("Enable Hardware Acceleration");
|
||||
}
|
||||
|
||||
if(devMode){
|
||||
@@ -59,7 +62,7 @@ if(devMode){
|
||||
*/
|
||||
const { devtron } = require('@electron/devtron');
|
||||
devtron.install();
|
||||
logger.info("Loaded DevTron Extension")
|
||||
logger.info("Loaded DevTron Extension");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,6 +80,7 @@ const { createSplashWindow, closeSplashWindow, setSplashProgress } = require("./
|
||||
const { userThemesPath, initUserThemes, validateBinaries, defaultDownloadFolder } = require("./server/helpers/path.helpers");
|
||||
const {createSystemTray, destroyTray} = require("./app/tray");
|
||||
const { stopServer } = require("./server/server");
|
||||
const {checkReportService} = require("./app/sendReport");
|
||||
|
||||
/**
|
||||
* Global flag indicating if the application is intentionally shutting down.
|
||||
@@ -132,13 +136,21 @@ app.on("second-instance", () => {
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
logSessionStart(logDir, defaultDownloadFolder);
|
||||
checkReportService().catch((err) => {
|
||||
logger.warn(`Bug report service startup check failed: ${err.message}`);
|
||||
});
|
||||
|
||||
createSplashWindow();
|
||||
|
||||
if (!devMode && !checkNativeDependencies()) return;
|
||||
|
||||
const { userYtDlp } = require("./server/helpers/path.helpers");
|
||||
|
||||
if (configFeatures.autoUpdateYtDlp !== false) {
|
||||
updateYtDlp(userYtDlp);
|
||||
} else {
|
||||
logger.info("yt-dlp automatic updates skipped.");
|
||||
}
|
||||
|
||||
try {
|
||||
setSplashProgress(0); // Checking dependencies
|
||||
@@ -166,7 +178,7 @@ app.whenReady().then(async () => {
|
||||
|
||||
if (configFeatures.discordRPC) startRPC();
|
||||
|
||||
if (configFeatures.autoUpdate) initAutoUpdater(getMainWindow());
|
||||
initAutoUpdater(getMainWindow());
|
||||
|
||||
} catch (err) {
|
||||
logger.error("Boot error:", err);
|
||||
|
||||
1083
package-lock.json
generated
1083
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
36
package.json
36
package.json
@@ -2,10 +2,10 @@
|
||||
"name": "freedom-loader",
|
||||
"desktopName": "com.masteracnolo.freedomloader",
|
||||
"productName": "Freedom Loader",
|
||||
"version": "1.6.3",
|
||||
"version": "1.6.5",
|
||||
"author": "MasterAcnolo <MasterAcnolo@users.noreply.github.com>",
|
||||
"description": "Free and open-source GUI for yt-dlp — download video and audio from hundreds of platforms",
|
||||
"homepage": "https://masteracnolo.github.io/Freedom-Loader-Site/",
|
||||
"homepage": "https://freedomloader.acnolo.fr/",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/MasterAcnolo/Freedom-Loader.git"
|
||||
@@ -16,6 +16,7 @@
|
||||
},
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"clean:dist": "rm -rf dist/ && rm -rf srpm-out/",
|
||||
"start": "electron --no-warnings .",
|
||||
"start:warn": "electron --trace-warnings .",
|
||||
"start:debug": "WAYLAND_DEBUG=1 ELECTRON_ENABLE_LOGGING=1 ELECTRON_ENABLE_STACK_DUMPING=1 electron --enable-logging --v=1 .",
|
||||
@@ -25,13 +26,17 @@
|
||||
"build": "electron-builder",
|
||||
"build:win": "electron-builder --win",
|
||||
"build:linux": "electron-builder --linux",
|
||||
"build:rpm": "bash scripts/create-copr-package.sh",
|
||||
"build:rpm": "npm run clean:dist && npm run build:linux && bash scripts/create-copr-package.sh",
|
||||
"build:all": "electron-builder -wl",
|
||||
"release": "bash scripts/release.sh",
|
||||
"release:publish": "bash scripts/release.sh --publish",
|
||||
"release:dry-run": "bash scripts/release.sh --dry-run",
|
||||
"test": "jest",
|
||||
"test:unit": "jest test/unit",
|
||||
"test:metainfo": "appstreamcli validate package/com.masteracnolo.freedomloader.metainfo.xml",
|
||||
"test:desktop": "desktop-file-validate package/com.masteracnolo.freedomloader.desktop",
|
||||
"lint": "eslint . --format pretty",
|
||||
"lint:fix": "eslint . --fix --format pretty",
|
||||
"update": "npm update"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -48,10 +53,14 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/devtron": "^2.1.1",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@jest/globals": "^30.4.1",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"electron": "^44.3.0",
|
||||
"electron-builder": "^26.15.3",
|
||||
"eslint": "^10.10.0",
|
||||
"eslint-formatter-pretty": "^7.1.0",
|
||||
"globals": "^17.12.0",
|
||||
"jest": "^30.4.2",
|
||||
"nodemon": "^3.1.14",
|
||||
"playwright": "^1.62.1"
|
||||
@@ -68,10 +77,20 @@
|
||||
}
|
||||
],
|
||||
"files": [
|
||||
"**/*",
|
||||
"node_modules/**/*",
|
||||
"!config/config.dev.json",
|
||||
"!theme/**/*"
|
||||
"app/**/*",
|
||||
"public/**/*",
|
||||
"server/**/*",
|
||||
"build/**/*",
|
||||
"config/config.default.json",
|
||||
"config.js",
|
||||
"main.js",
|
||||
"preload.js",
|
||||
"package.json",
|
||||
"!node_modules/**/README",
|
||||
"!node_modules/**/*.md",
|
||||
"!node_modules/**/*.map",
|
||||
"!**/test/**",
|
||||
"!**/__tests__/**"
|
||||
],
|
||||
"directories": {
|
||||
"buildResources": "build"
|
||||
@@ -161,6 +180,9 @@
|
||||
"./package/com.masteracnolo.freedomloader.metainfo.xml=/usr/share/metainfo/com.masteracnolo.freedomloader.metainfo.xml"
|
||||
]
|
||||
},
|
||||
"appImage": {
|
||||
"artifactName": "Freedom-Loader-${version}.${ext}"
|
||||
},
|
||||
"snap": {
|
||||
"confinement": "strict",
|
||||
"plugs": [
|
||||
|
||||
8
package/com.masteracnolo.freedomloader.desktop
Normal file
8
package/com.masteracnolo.freedomloader.desktop
Normal file
@@ -0,0 +1,8 @@
|
||||
[Desktop Entry]
|
||||
Name=Freedom Loader
|
||||
Exec=/opt/freedom-loader/freedom-loader %U
|
||||
Icon=freedom-loader
|
||||
Type=Application
|
||||
Categories=AudioVideo;
|
||||
Comment=Free and open-source GUI for yt-dlp
|
||||
StartupWMClass=com.masteracnolo.freedomloader
|
||||
@@ -45,7 +45,7 @@
|
||||
</p>
|
||||
</description>
|
||||
|
||||
<launchable type="desktop-id">freedom-loader.desktop</launchable>
|
||||
<launchable type="desktop-id">com.masteracnolo.freedomloader.desktop</launchable>
|
||||
|
||||
<url type="homepage">https://github.com/MasterAcnolo/Freedom-Loader</url>
|
||||
<url type="bugtracker">https://github.com/MasterAcnolo/Freedom-Loader/issues</url>
|
||||
|
||||
125
preload.js
125
preload.js
@@ -8,6 +8,26 @@ const { contextBridge, ipcRenderer } = require("electron");
|
||||
* All calls are explicitly whitelisted.
|
||||
*/
|
||||
contextBridge.exposeInMainWorld("electronAPI", {
|
||||
/**
|
||||
* Sends error log message.
|
||||
*
|
||||
* @param {...any} args - Values to log
|
||||
*/
|
||||
logError: (...args) => ipcRenderer.send("log-error", args.map(arg => typeof arg === "object" ? JSON.stringify(arg) : arg).join(" ")),
|
||||
|
||||
/**
|
||||
* Sends info log message.
|
||||
*
|
||||
* @param {...any} args - Values to log
|
||||
*/
|
||||
logInfo: (...args) => ipcRenderer.send("log-info", args.map(arg => typeof arg === "object" ? JSON.stringify(arg) : arg).join(" ")),
|
||||
|
||||
/**
|
||||
* Sends warning log message.
|
||||
*
|
||||
* @param {...any} args - Values to log
|
||||
*/
|
||||
logWarn: (...args) => ipcRenderer.send("log-warn", args.map(arg => typeof arg === "object" ? JSON.stringify(arg) : arg).join(" ")),
|
||||
|
||||
/**
|
||||
* Return process.platform to renderer
|
||||
@@ -42,14 +62,23 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
* @param {string} key - Feature name
|
||||
* @param {any} value - Feature value
|
||||
*/
|
||||
setFeature: (key, value) =>
|
||||
ipcRenderer.invoke("set-feature", { key, value }),
|
||||
setFeature: (key, value) => ipcRenderer.invoke("set-feature", { key, value }),
|
||||
|
||||
/**
|
||||
* Returns the current application version.
|
||||
*/
|
||||
*/
|
||||
getVersion: () => ipcRenderer.invoke("version"),
|
||||
|
||||
/**
|
||||
* Returns the changelog data
|
||||
*/
|
||||
getChangelog: () => ipcRenderer.invoke("get-changelog"),
|
||||
|
||||
/**
|
||||
* Marks the current changelog version as seen by the user.
|
||||
*/
|
||||
markChangelogSeen: () => ipcRenderer.invoke("mark-changelog-seen"),
|
||||
|
||||
/**
|
||||
* Validates a download path before using it for file operations.
|
||||
*
|
||||
@@ -67,8 +96,82 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
* Forces a reload of theme files (useful after modification/import).
|
||||
*/
|
||||
reloadThemes: () => ipcRenderer.invoke("reload-themes"),
|
||||
});
|
||||
|
||||
/*
|
||||
* ==========================================
|
||||
* Auto Updater IPC
|
||||
* ==========================================
|
||||
*/
|
||||
|
||||
/**
|
||||
* Checks if an update has already been detected by the main process.
|
||||
*
|
||||
* @returns {Promise<boolean>} Resolves to true if an update is available.
|
||||
*/
|
||||
isUpdateAvailable: () => ipcRenderer.invoke("is-update-available"),
|
||||
|
||||
/**
|
||||
* Subscribes to the update available event pushed by the main process.
|
||||
*
|
||||
* @param {Function} callback - Function executed with update info when an update is found.
|
||||
*/
|
||||
onUpdateAvailable: (callback) =>
|
||||
ipcRenderer.on("update-available", (_, info) => callback(info)),
|
||||
|
||||
/**
|
||||
* Instructs the main process to start downloading the available update.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
downloadUpdate: () => ipcRenderer.invoke("download-update"),
|
||||
|
||||
/**
|
||||
* Instructs the main process to quit the application and install the downloaded update.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
installUpdate: () => ipcRenderer.invoke("install-update"),
|
||||
|
||||
/**
|
||||
* Subscribes to the update error event.
|
||||
*
|
||||
* @param {Function} callback - Function executed with error information when an update error occurs.
|
||||
* @returns {Electron.IpcRenderer}
|
||||
*/
|
||||
onUpdateError: (callback) => ipcRenderer.on("update-error", (_, err) => callback(err)),
|
||||
|
||||
/**
|
||||
* Subscribes to the download progress event.
|
||||
*
|
||||
* @param {Function} callback - Function executed with progress metrics (percent, bytesPerSecond, transferred, total).
|
||||
* @returns {Electron.IpcRenderer}
|
||||
*/
|
||||
onDownloadProgress: (callback) =>
|
||||
ipcRenderer.on("update-progress", (_, progress) => callback(progress)),
|
||||
|
||||
/**
|
||||
* Subscribes to the update downloaded event.
|
||||
* Triggered when the update file is fully downloaded and ready to install.
|
||||
*
|
||||
* @param {Function} callback - Function executed with update info.
|
||||
*/
|
||||
onUpdateDownloaded: (callback) =>
|
||||
ipcRenderer.on("update-downloaded", (_, info) => callback(info)),
|
||||
|
||||
openReleasePage: () => ipcRenderer.send("open-release-page"),
|
||||
|
||||
/**
|
||||
* Subscribes to toast notifications emitted by the main process.
|
||||
*
|
||||
* @param {Function} callback - Function executed with toast data.
|
||||
* @returns {Function} Removes the listener.
|
||||
*/
|
||||
onToast: (callback) => {
|
||||
const listener = (_, toast) => callback(toast);
|
||||
ipcRenderer.on("show-toast", listener);
|
||||
return () => ipcRenderer.removeListener("show-toast", listener);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Exposes window control and developer utilities for the custom topbar UI.
|
||||
@@ -76,7 +179,6 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
* These methods forward commands to the Electron main process via IPC.
|
||||
*/
|
||||
contextBridge.exposeInMainWorld("topbarAPI", {
|
||||
|
||||
/**
|
||||
* Minimizes the application window.
|
||||
*/
|
||||
@@ -126,4 +228,17 @@ contextBridge.exposeInMainWorld("topbarAPI", {
|
||||
* Opens configuration/settings panel.
|
||||
*/
|
||||
openConfig: () => ipcRenderer.send("open-config"),
|
||||
|
||||
/**
|
||||
* Send Bug Report
|
||||
*
|
||||
* @param params
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
sendReport: (params) => ipcRenderer.invoke("send-report", params),
|
||||
|
||||
/**
|
||||
* Returns the startup health status of the bug report service.
|
||||
*/
|
||||
getReportServiceStatus: () => ipcRenderer.invoke("get-report-service-status"),
|
||||
});
|
||||
@@ -13,6 +13,8 @@
|
||||
<link rel="stylesheet" href="styles/styles.css">
|
||||
<link rel="stylesheet" href="styles/layout/topbar.css">
|
||||
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons+Round" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -21,9 +23,11 @@
|
||||
<div class="custom-controls">
|
||||
<button id="devtools-btn" title="Dev Tools">Tools</button>
|
||||
<button id="logs-btn" title="Logs">Logs</button>
|
||||
<button id="report-bug-btn" title="Report a bug">Report a Bug</button> <!-- TODO: Find a better place -->
|
||||
<button id="website-btn" title="Website">Website</button>
|
||||
<button id="wiki-btn" title="Wiki">Wiki</button>
|
||||
<button id="workshop-btn" title="Workshop">Theme Workshop</button>
|
||||
<button id="changelog-btn" title="Changelog">Changelog</button>
|
||||
<!-- <button id="theme-btn" title="Theme">Theme</button> -->
|
||||
</div>
|
||||
<div class="window-controls">
|
||||
@@ -120,38 +124,38 @@
|
||||
<summary>Advanced Settings</summary>
|
||||
|
||||
<div class="setting-item">
|
||||
<input type="checkbox" data-key="autoUpdate" id="autoUpdate">
|
||||
<input type="checkbox" data-key="showChanglogsAfterUpdate" id="showChanglogsAfterUpdate">
|
||||
<div class="setting-info">
|
||||
<span class="setting-title">Auto Update</span>
|
||||
<small>Enable automatic app updates.</small>
|
||||
<span class="setting-title">Show Changelogs After Update</span>
|
||||
<small>Display changelogs after each update.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<input data-key="systemTray" id="systemTray" type="checkbox">
|
||||
<div class="setting-info">
|
||||
<span class="setting-title">System Tray</span>
|
||||
<small>Minimize app on close, in the System Tray. [EXPERIMENTAL]</small>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<input data-key="systemTray" id="systemTray" type="checkbox">
|
||||
<div class="setting-info">
|
||||
<span class="setting-title">System Tray</span>
|
||||
<small>Minimize app on close, in the System Tray.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- <div class="setting-item">
|
||||
<input type="checkbox" data-key="outputTitleCheck" id="outputTitleCheck">
|
||||
<div class="setting-info">
|
||||
<span class="setting-title">Output title (special chars)</span>
|
||||
<small>Check for unusual characters in output titles.</small>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<div class="setting-item">
|
||||
<input data-key="autoCheckInfo" id="autoCheckInfo" type="checkbox">
|
||||
<div class="setting-info">
|
||||
<span class="setting-title">Auto Fetch Info</span>
|
||||
<small>Automatically fetch information for new downloads.</small>
|
||||
</div>
|
||||
<!-- <div class="setting-item">
|
||||
<input type="checkbox" data-key="outputTitleCheck" id="outputTitleCheck">
|
||||
<div class="setting-info">
|
||||
<span class="setting-title">Output title (special chars)</span>
|
||||
<small>Check for unusual characters in output titles.</small>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item">
|
||||
<input data-key="autoCheckInfo" id="autoCheckInfo" type="checkbox">
|
||||
<div class="setting-info">
|
||||
<span class="setting-title">Auto Fetch Info</span>
|
||||
<small>Automatically fetch information for new downloads.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<input type="checkbox" data-key="addMetadata" id="addMetadata">
|
||||
<div class="setting-info">
|
||||
<span class="setting-title">Add Metadata</span>
|
||||
@@ -167,13 +171,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<!-- <div class="setting-item">
|
||||
<input type="checkbox" data-key="customTopBar" id="customTopBar">
|
||||
<div class="setting-info">
|
||||
<span class="setting-title">Custom Top bar</span>
|
||||
<small>Use this if the topbar is missing or you prefer a custom one.</small>
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<div class="setting-item" style="margin-left: 32px; margin-top: 20px;">
|
||||
<div class="setting-info">
|
||||
@@ -213,6 +217,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<input data-key="autoUpdateYtDlp" id="autoUpdateYtDlp" type="checkbox">
|
||||
<div class="setting-info">
|
||||
<span class="setting-title">Automatically Update yt-dlp</span>
|
||||
<small>Check for and install yt-dlp updates when the application starts.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</details>
|
||||
|
||||
<div class="settings-footer">
|
||||
@@ -295,14 +307,53 @@
|
||||
|
||||
<span id="version-badge" class="version-badge"></span>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Toast Container -->
|
||||
<div id="toast-container"></div>
|
||||
|
||||
<div class="changelog-overlay" id="changelog-overlay" hidden>
|
||||
<section class="changelog-modal" role="dialog" aria-modal="true" aria-labelledby="changelog-title">
|
||||
<div class="changelog-header">
|
||||
<div>
|
||||
<span class="changelog-eyebrow">Freedom Loader</span>
|
||||
<h2 id="changelog-title">What's new</h2>
|
||||
</div>
|
||||
<button class="changelog-close" id="changelog-close" type="button" title="Close changelog" aria-label="Close changelog">×</button>
|
||||
</div>
|
||||
<div class="changelog-list" id="changelog-list">
|
||||
<p class="changelog-loading">Loading changelog...</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<h2 id="reference"> Because Why Not ? </h2>
|
||||
|
||||
<div id="updateAvailable" style="display:none">
|
||||
<div class="update-banner">
|
||||
<div class="banner-row">
|
||||
<div class="banner-left">
|
||||
<div class="icon-wrap">
|
||||
<span class="material-symbols-outlined" id="update-icon">download</span>
|
||||
</div>
|
||||
<div class="text-block">
|
||||
<span class="update-title" id="update-title">Update available</span>
|
||||
<span class="update-sub" id="update-sub">A new version is ready</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-update" id="btn-update">Download</button>
|
||||
</div>
|
||||
<div class="progress-wrap" id="progress-wrap" style="display:none">
|
||||
<div class="progress-track">
|
||||
<div class="progress-fill" id="progress-fill"></div>
|
||||
</div>
|
||||
<div class="progress-meta">
|
||||
<span id="progress-pct">0%</span>
|
||||
<span id="progress-eta"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="script/toast.js"></script>
|
||||
@@ -314,6 +365,9 @@
|
||||
<script src="script/fetchinfo.js"></script>
|
||||
<script src="script/progressBar.js"></script>
|
||||
<script src="script/customthemes.js"></script>
|
||||
<script src="script/reportBug.js"></script>
|
||||
<script src="script/autoUpdate.js"></script>
|
||||
<script type="module" src="script/changelog.js"></script>
|
||||
<script type="module" src="script/topbar.js"></script>
|
||||
|
||||
</body>
|
||||
|
||||
109
public/script/autoUpdate.js
Normal file
109
public/script/autoUpdate.js
Normal file
@@ -0,0 +1,109 @@
|
||||
async function initUpdateUI() {
|
||||
const container = document.getElementById("updateAvailable");
|
||||
|
||||
const hasUpdate = await window.electronAPI.isUpdateAvailable();
|
||||
if (hasUpdate) {
|
||||
setupAndShowUpdateUI();
|
||||
}
|
||||
|
||||
window.electronAPI.onUpdateAvailable((info) => {
|
||||
setupAndShowUpdateUI(info);
|
||||
});
|
||||
|
||||
function setupAndShowUpdateUI(info) {
|
||||
container.style.display = "block";
|
||||
|
||||
const btn = document.getElementById("btn-update");
|
||||
const icon = document.getElementById("update-icon");
|
||||
const title = document.getElementById("update-title");
|
||||
const sub = document.getElementById("update-sub");
|
||||
const progressWrap = document.getElementById("progress-wrap");
|
||||
const progressFill = document.getElementById("progress-fill");
|
||||
const progressPct = document.getElementById("progress-pct");
|
||||
const progressEta = document.getElementById("progress-eta");
|
||||
|
||||
if (info && info.version) {
|
||||
sub.textContent = `A new version (${info.version}) is ready`;
|
||||
}
|
||||
|
||||
if (info && info.environment && info.environment !== "auto") {
|
||||
icon.textContent = "open_in_new";
|
||||
|
||||
if (info.environment === "snap") {
|
||||
sub.textContent = `Version ${info.version} is out. Use the Snap Store or run "snap refresh".`;
|
||||
btn.textContent = "View Release";
|
||||
} else {
|
||||
sub.textContent = `Version ${info.version} is out. Update via your package manager or download it manually.`;
|
||||
btn.textContent = "Download manually";
|
||||
}
|
||||
|
||||
btn.onclick = () => {
|
||||
window.electronAPI.openReleasePage();
|
||||
container.style.display = "none";
|
||||
};
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const initialButtonText = btn.textContent;
|
||||
const initialIconText = icon.textContent;
|
||||
const initialTitleText = title.textContent;
|
||||
const initialSubText = sub.textContent;
|
||||
|
||||
const resetUI = () => {
|
||||
btn.disabled = false;
|
||||
btn.textContent = initialButtonText;
|
||||
icon.textContent = initialIconText;
|
||||
title.textContent = initialTitleText;
|
||||
sub.textContent = initialSubText;
|
||||
progressWrap.style.display = "none";
|
||||
progressFill.style.width = "0%";
|
||||
progressPct.textContent = "0%";
|
||||
progressEta.textContent = "";
|
||||
};
|
||||
|
||||
window.electronAPI.onUpdateError((err) => {
|
||||
console.error("Update failed asynchronously:", err);
|
||||
resetUI();
|
||||
});
|
||||
|
||||
btn.onclick = async () => {
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Downloading...";
|
||||
icon.textContent = "download";
|
||||
title.textContent = "Downloading update";
|
||||
progressWrap.style.display = "flex";
|
||||
|
||||
try {
|
||||
await window.electronAPI.downloadUpdate();
|
||||
} catch {
|
||||
resetUI();
|
||||
}
|
||||
};
|
||||
|
||||
window.electronAPI.onDownloadProgress((progress) => {
|
||||
const pct = Math.round(progress.percent);
|
||||
progressFill.style.width = pct + "%";
|
||||
progressPct.textContent = pct + "%";
|
||||
|
||||
const remaining = (progress.total - progress.transferred) / progress.bytesPerSecond;
|
||||
progressEta.textContent =
|
||||
remaining > 0 ? `~${Math.ceil(remaining)}s left` : "almost done";
|
||||
});
|
||||
|
||||
window.electronAPI.onUpdateDownloaded(() => {
|
||||
progressFill.style.width = "100%";
|
||||
progressPct.textContent = "100%";
|
||||
progressEta.textContent = "Done";
|
||||
icon.textContent = "check";
|
||||
title.textContent = "Ready to install";
|
||||
sub.textContent = "Restart to apply the update";
|
||||
btn.textContent = "Restart";
|
||||
btn.disabled = false;
|
||||
btn.onclick = () => window.electronAPI.installUpdate();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
initUpdateUI();
|
||||
282
public/script/changelog.js
Executable file
282
public/script/changelog.js
Executable file
@@ -0,0 +1,282 @@
|
||||
/** DOM elements used by the changelog modal. */
|
||||
const UI = {
|
||||
overlay: document.getElementById("changelog-overlay"),
|
||||
list: document.getElementById("changelog-list"),
|
||||
closeBtn: document.getElementById("changelog-close"),
|
||||
openBtn: document.getElementById("changelog-btn")
|
||||
};
|
||||
|
||||
/** Changelog payload loaded from the Electron main process. */
|
||||
let changelogData = null;
|
||||
|
||||
/**
|
||||
* Formats an ISO release date for the current locale.
|
||||
*
|
||||
* @param {string} dateString - Release date returned by GitHub
|
||||
* @returns {string} Localized date or an empty string
|
||||
*/
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return "";
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(new Date(dateString));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses inline Markdown and appends safe DOM nodes to a parent element.
|
||||
*
|
||||
* @param {HTMLElement} parent - Element receiving the parsed content
|
||||
* @param {string} value - Inline Markdown text
|
||||
*/
|
||||
function parseInline(parent, value) {
|
||||
const tokenPattern = /(\*\*|__)(.+?)\1|(?<!\*)\*([^*]+)\*(?!\*)|(?<!_)_([^_]+)_(?!_)|`([^`]+)`|~~([^~]+)~~|\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g;
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = tokenPattern.exec(value)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parent.appendChild(document.createTextNode(value.slice(lastIndex, match.index)));
|
||||
}
|
||||
|
||||
const element = match[7]
|
||||
? document.createElement("a")
|
||||
: document.createElement(match[1] ? "strong" : match[3] || match[4] ? "em" : match[5] ? "code" : "del");
|
||||
|
||||
element.textContent = match[7] || match[2] || match[3] || match[4] || match[5] || match[6];
|
||||
|
||||
if (match[7]) {
|
||||
element.href = match[8];
|
||||
element.target = "_blank";
|
||||
element.rel = "noopener noreferrer";
|
||||
}
|
||||
|
||||
parent.appendChild(element);
|
||||
lastIndex = tokenPattern.lastIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < value.length) {
|
||||
parent.appendChild(document.createTextNode(value.slice(lastIndex)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses block-level Markdown such as headings, lists, quotes and code blocks.
|
||||
*
|
||||
* @param {HTMLElement} parent - Element receiving the parsed content
|
||||
* @param {string} markdown - Release body in Markdown format
|
||||
*/
|
||||
function parseBlock(parent, markdown) {
|
||||
const lines = markdown.replace(/\r\n?/g, "\n").split("\n");
|
||||
let paragraph = [];
|
||||
let list = null;
|
||||
let codeBlock = null;
|
||||
|
||||
const flushParagraph = () => {
|
||||
if (!paragraph.length) return;
|
||||
const element = document.createElement("p");
|
||||
paragraph.forEach((line, index) => {
|
||||
if (index) element.appendChild(document.createElement("br"));
|
||||
parseInline(element, line);
|
||||
});
|
||||
parent.appendChild(element);
|
||||
paragraph = [];
|
||||
};
|
||||
|
||||
const closeList = () => {
|
||||
if (list) {
|
||||
parent.appendChild(list);
|
||||
list = null;
|
||||
}
|
||||
};
|
||||
|
||||
lines.forEach((line) => {
|
||||
if (line.trim().startsWith("```")) {
|
||||
flushParagraph();
|
||||
closeList();
|
||||
if (codeBlock) {
|
||||
const code = document.createElement("code");
|
||||
code.textContent = codeBlock.join("\n");
|
||||
const pre = document.createElement("pre");
|
||||
pre.appendChild(code);
|
||||
parent.appendChild(pre);
|
||||
codeBlock = null;
|
||||
} else {
|
||||
codeBlock = [];
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (codeBlock) {
|
||||
codeBlock.push(line);
|
||||
return;
|
||||
}
|
||||
|
||||
const heading = line.match(/^(#{1,6})\s+(.+)$/);
|
||||
const unorderedItem = line.match(/^\s*[-*+]\s+(.+)$/);
|
||||
const orderedItem = line.match(/^\s*\d+[.]\s+(.+)$/);
|
||||
const quote = line.match(/^\s*>\s?(.*)$/);
|
||||
|
||||
if (!line.trim()) {
|
||||
flushParagraph();
|
||||
closeList();
|
||||
} else if (heading) {
|
||||
flushParagraph();
|
||||
closeList();
|
||||
const element = document.createElement(`h${heading[1].length}`);
|
||||
parseInline(element, heading[2]);
|
||||
parent.appendChild(element);
|
||||
} else if (unorderedItem || orderedItem) {
|
||||
flushParagraph();
|
||||
const isOrdered = Boolean(orderedItem);
|
||||
if (!list || list.tagName.toLowerCase() !== (isOrdered ? "ol" : "ul")) {
|
||||
closeList();
|
||||
list = document.createElement(isOrdered ? "ol" : "ul");
|
||||
}
|
||||
const item = document.createElement("li");
|
||||
parseInline(item, (unorderedItem || orderedItem)[1]);
|
||||
list.appendChild(item);
|
||||
} else if (quote) {
|
||||
flushParagraph();
|
||||
closeList();
|
||||
const element = document.createElement("blockquote");
|
||||
parseInline(element, quote[1]);
|
||||
parent.appendChild(element);
|
||||
} else if (/^\s*([-*_])\s*\1\s*\1\s*$/.test(line)) {
|
||||
flushParagraph();
|
||||
closeList();
|
||||
parent.appendChild(document.createElement("hr"));
|
||||
} else {
|
||||
paragraph.push(line);
|
||||
}
|
||||
});
|
||||
|
||||
flushParagraph();
|
||||
closeList();
|
||||
if (codeBlock) {
|
||||
const code = document.createElement("code");
|
||||
code.textContent = codeBlock.join("\n");
|
||||
const pre = document.createElement("pre");
|
||||
pre.appendChild(code);
|
||||
parent.appendChild(pre);
|
||||
}
|
||||
}
|
||||
|
||||
/** Renders the empty state when no releases are available. */
|
||||
function renderEmpty() {
|
||||
const emptyMessage = document.createElement("p");
|
||||
emptyMessage.className = "changelog-empty";
|
||||
emptyMessage.textContent = "No changelog available.";
|
||||
UI.list.appendChild(emptyMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders one release entry in the changelog list.
|
||||
*
|
||||
* @param {object} release - Release data from the backend
|
||||
*/
|
||||
function renderEntry(release) {
|
||||
const article = document.createElement("article");
|
||||
article.className = "changelog-entry";
|
||||
|
||||
const heading = document.createElement("div");
|
||||
heading.className = "changelog-entry-heading";
|
||||
|
||||
const title = document.createElement("h3");
|
||||
title.textContent = release.name;
|
||||
|
||||
const metadata = document.createElement("span");
|
||||
metadata.textContent = [release.version, formatDate(release.date)]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
|
||||
heading.append(title, metadata);
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "changelog-body";
|
||||
parseBlock(body, release.body);
|
||||
|
||||
article.append(heading, body);
|
||||
UI.list.appendChild(article);
|
||||
}
|
||||
|
||||
/** Renders all releases, replacing the current modal contents. */
|
||||
function renderReleases(releases) {
|
||||
UI.list.replaceChildren();
|
||||
if (!releases.length) {
|
||||
renderEmpty();
|
||||
return;
|
||||
}
|
||||
releases.forEach((release) => renderEntry(release));
|
||||
}
|
||||
|
||||
/** Opens the changelog modal and moves focus to its close button. */
|
||||
function openChangelog(releases) {
|
||||
renderReleases(releases);
|
||||
UI.overlay.hidden = false;
|
||||
UI.closeBtn?.focus();
|
||||
}
|
||||
|
||||
/** Closes the modal and records the current version as seen. */
|
||||
async function closeChangelog() {
|
||||
UI.overlay.hidden = true;
|
||||
await window.electronAPI.markChangelogSeen();
|
||||
}
|
||||
|
||||
/** Loads changelog data and opens the modal when unread releases exist. */
|
||||
async function loadChangelog() {
|
||||
|
||||
const features = await window.electronAPI.getFeatures();
|
||||
|
||||
try {
|
||||
changelogData = await window.electronAPI.getChangelog();
|
||||
|
||||
if (!changelogData.error && changelogData.hasUnread && features.showChanglogsAfterUpdate === true) {
|
||||
openChangelog(changelogData.unreadReleases);
|
||||
window.electronAPI.logInfo("Showing Changelog");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Unable to load changelog:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Handles a click on the button that opens the changelog. */
|
||||
async function handleOpenClick() {
|
||||
if (!changelogData || changelogData.error) {
|
||||
changelogData = await window.electronAPI.getChangelog();
|
||||
}
|
||||
|
||||
if (changelogData.error) {
|
||||
window.showError("Unable to load changelog. Please check your network connection.");
|
||||
return;
|
||||
}
|
||||
|
||||
openChangelog(changelogData.releases);
|
||||
}
|
||||
|
||||
/** Closes the modal when the user clicks on the overlay background. */
|
||||
function handleOverlayClick(event) {
|
||||
if (event.target === UI.overlay) closeChangelog();
|
||||
}
|
||||
|
||||
/** Closes the modal when Escape is pressed. */
|
||||
function handleKeydown(event) {
|
||||
if (event.key === "Escape" && !UI.overlay.hidden) closeChangelog();
|
||||
}
|
||||
|
||||
/** Registers all changelog modal event listeners. */
|
||||
function bindEvents() {
|
||||
UI.openBtn?.addEventListener("click", handleOpenClick);
|
||||
UI.closeBtn?.addEventListener("click", closeChangelog);
|
||||
UI.overlay?.addEventListener("click", handleOverlayClick);
|
||||
document.addEventListener("keydown", handleKeydown);
|
||||
}
|
||||
|
||||
/** Initializes the changelog data flow and modal interactions. */
|
||||
function init() {
|
||||
bindEvents();
|
||||
loadChangelog();
|
||||
}
|
||||
|
||||
init();
|
||||
@@ -53,17 +53,32 @@ form.addEventListener("submit", async (e) => {
|
||||
|
||||
const formData = new FormData(form);
|
||||
const params = new URLSearchParams(formData);
|
||||
const body = Object.fromEntries(params.entries());
|
||||
|
||||
try {
|
||||
const res = await fetch("/download", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: params,
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
if (!userStoppedDownload) {
|
||||
const errorText = await res.text();
|
||||
let errorText = "Server Error";
|
||||
try {
|
||||
const contentType = res.headers.get("content-type") || "";
|
||||
if (contentType.includes("application/json")) {
|
||||
const json = await res.json();
|
||||
errorText =
|
||||
(json && json.error && json.error.message) ||
|
||||
(json && json.message) ||
|
||||
errorText;
|
||||
} else {
|
||||
errorText = await res.text();
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
window.showError(errorText);
|
||||
resetProgressBar();
|
||||
}
|
||||
@@ -71,8 +86,12 @@ form.addEventListener("submit", async (e) => {
|
||||
}
|
||||
|
||||
// Download successful - check if notifications are enabled
|
||||
const features = await window.electronAPI.getFeatures();
|
||||
if (features.notifySystem) {
|
||||
try {
|
||||
const features = await window.electronAPI.getFeatures();
|
||||
if (features.notifySystem) {
|
||||
window.showSuccess("Download completed!");
|
||||
}
|
||||
} catch (e) {
|
||||
window.showSuccess("Download completed!");
|
||||
}
|
||||
|
||||
@@ -80,10 +99,14 @@ form.addEventListener("submit", async (e) => {
|
||||
if (!isDownloading && !userStoppedDownload) {
|
||||
statusDiv.style.display = "none";
|
||||
}
|
||||
if (!userStoppedDownload) {
|
||||
window.showError("Network error while starting download");
|
||||
resetProgressBar();
|
||||
}
|
||||
} finally {
|
||||
isDownloading = false;
|
||||
button.disabled = false;
|
||||
stopBtn.style.display = "none";
|
||||
if (stopBtn) stopBtn.style.display = "none";
|
||||
}
|
||||
});
|
||||
|
||||
@@ -135,7 +158,7 @@ async function stopDownload() {
|
||||
try {
|
||||
await fetch("/download/cancel", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
headers: {"Content-Type": "application/json"},
|
||||
});
|
||||
|
||||
window.showWarning("Download stopped");
|
||||
|
||||
@@ -32,14 +32,27 @@ async function fetchVideoInfo(url) {
|
||||
try {
|
||||
const res = await fetch(`/info?url=${encodeURIComponent(url)}`, {
|
||||
method: "GET",
|
||||
headers: {"Accept": "application/json"}
|
||||
headers: {Accept: "application/json"},
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
let data;
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch (e) {
|
||||
return {error: "Network or JSON Issue"};
|
||||
}
|
||||
|
||||
if (!res.ok) return {error: `An Error occured when fetching info`};
|
||||
if (!res.ok) {
|
||||
if (data && data.error) {
|
||||
return {error: data.error.message || "An Error occured when fetching info"};
|
||||
}
|
||||
return {error: "An Error occured when fetching info"};
|
||||
}
|
||||
|
||||
if (!data) return { error: "Data is Missing" };
|
||||
if (data.error) {
|
||||
return {error: data.error.message || data.error};
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
@@ -269,7 +282,7 @@ async function init() {
|
||||
infoDiv.classList.add("visible");
|
||||
});
|
||||
|
||||
})
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
165
public/script/reportBug.js
Normal file
165
public/script/reportBug.js
Normal file
@@ -0,0 +1,165 @@
|
||||
class BugReportModal {
|
||||
constructor() {
|
||||
this.modal = null;
|
||||
this.form = null;
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.modal = document.createElement("div");
|
||||
this.modal.className = "bug-modal-overlay";
|
||||
this.modal.style.cssText = "display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.7); justify-content:center; align-items:center; z-index:99999; font-family:sans-serif;";
|
||||
|
||||
this.modal.innerHTML = `
|
||||
<div class="bug-modal-content" style="background:#14141491 !important; color:#ffffff !important; padding:24px !important; border-radius:10px !important; width:420px !important; box-shadow: 0 10px 25px rgba(0,0,0,0.8) !important; display:flex !important; flex-direction:column !important; gap:16px !important; border: 1px solid #333 !important; box-sizing: border-box !important;">
|
||||
<h3 style="margin:0 !important; font-size:1.2rem !important; font-weight:600 !important; color:#ffffff !important;">Report a Bug</h3>
|
||||
<form id="bugReportForm" style="display:flex !important; flex-direction:column !important; gap:12px !important; width:unset !important; margin:0 !important; background:transparent !important; box-shadow: unset !important;">
|
||||
<div style="display:flex !important; flex-direction:column !important; gap:4px !important;">
|
||||
<label style="font-size:0.85rem !important; color:#aaaaaa !important;">Title <span style="color: red; font-weight: bold" >*</span> </label>
|
||||
<input type="text" id="bugTitle" required style="background:#2a2a2a !important; border:1px solid #444 !important; color:#ffffff !important; border-radius:6px !important; padding:8px 10px !important; outline:none !important; width:100% !important; box-sizing: border-box !important;" />
|
||||
</div>
|
||||
<div style="display:flex !important; flex-direction:column !important; gap:4px !important;">
|
||||
<label style="font-size:0.85rem !important; color:#aaaaaa !important;">Description <span style="color: red; font-weight: bold" >*</span></label>
|
||||
<textarea id="bugDescription" placeholder="Provide some details about the bug" required rows="4" style="background:#2a2a2a !important; border:1px solid #444 !important; color:#ffffff !important; border-radius:6px !important; padding:8px 10px !important; outline:none !important; resize:vertical !important; width:100% !important; box-sizing: border-box !important;"></textarea>
|
||||
</div>
|
||||
<div style="display:flex !important; flex-direction:column !important; gap:4px !important;">
|
||||
<label style="font-size:0.85rem !important; color:#aaaaaa !important;">Include Logs From Today</label>
|
||||
<select id="bugIncludeLogs" style="background:#2a2a2a !important; border:1px solid #444 !important; color:#ffffff !important; border-radius:6px !important; padding:8px 10px !important; outline:none !important; width:100% !important; box-sizing: border-box !important;">
|
||||
<option value="yes" style="background:#2a2a2a !important; color:#ffffff !important;">Yes</option>
|
||||
<option value="no" style="background:#2a2a2a !important; color:#ffffff !important;">No</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="bugReportServiceStatus" role="status" aria-live="polite" style="display:none !important; align-items:center !important; gap:8px !important; color:#aaaaaa !important; font-size:0.8rem !important;">
|
||||
<span id="bugReportServiceStatusDot" style="width:8px !important; height:8px !important; border-radius:50% !important; background:#f59e0b !important; flex-shrink:0 !important;"></span>
|
||||
<span id="bugReportServiceStatusText">Checking bug report service...</span>
|
||||
</div>
|
||||
<div style="display:flex !important; justify-content:flex-end !important; gap:8px !important; margin-top:8px !important;">
|
||||
<button type="button" id="bugCancelBtn" style="background:#333333 !important; border:none !important; color:#cccccc !important; padding:8px 14px !important; border-radius:6px !important; cursor:pointer !important;">Cancel</button>
|
||||
<button type="submit" id="bugSendBtn" disabled style="background:#4f46e5 !important; border:none !important; color:#ffffff !important; padding:8px 16px !important; border-radius:6px !important; cursor:not-allowed !important; opacity:0.5 !important; font-weight:500 !important;">Send</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(this.modal);
|
||||
this.form = this.modal.querySelector("#bugReportForm");
|
||||
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
const titleInput = this.modal.querySelector("#bugTitle");
|
||||
const descInput = this.modal.querySelector("#bugDescription");
|
||||
const sendButton = this.modal.querySelector("#bugSendBtn");
|
||||
|
||||
titleInput.addEventListener("input", () => {
|
||||
localStorage.setItem("draft_bug_title", titleInput.value);
|
||||
});
|
||||
|
||||
descInput.addEventListener("input", () => {
|
||||
localStorage.setItem("draft_bug_desc", descInput.value);
|
||||
});
|
||||
|
||||
this.modal.querySelector("#bugCancelBtn").addEventListener("click", () => {
|
||||
this.close();
|
||||
});
|
||||
|
||||
this.modal.addEventListener("click", (e) => {
|
||||
if (e.target === this.modal) {
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
|
||||
this.form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
if (sendButton.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
title: titleInput.value,
|
||||
description: descInput.value,
|
||||
includeLogs: this.modal.querySelector("#bugIncludeLogs").value
|
||||
};
|
||||
|
||||
window.electronAPI.logInfo("Submitting:", data);
|
||||
|
||||
try {
|
||||
const success = await window.topbarAPI.sendReport(data);
|
||||
|
||||
if (success) {
|
||||
localStorage.removeItem("draft_bug_title");
|
||||
localStorage.removeItem("draft_bug_desc");
|
||||
this.form.reset();
|
||||
this.close();
|
||||
window.showSuccess("Bug report sent successfully!");
|
||||
}
|
||||
} catch (err) {
|
||||
window.showError("Failed to send bug report.");
|
||||
window.electronAPI.logError("Failed to send bug report:", err.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadState() {
|
||||
const savedTitle = localStorage.getItem("draft_bug_title");
|
||||
const savedDesc = localStorage.getItem("draft_bug_desc");
|
||||
|
||||
if (savedTitle) {
|
||||
this.modal.querySelector("#bugTitle").value = savedTitle;
|
||||
}
|
||||
if (savedDesc) {
|
||||
this.modal.querySelector("#bugDescription").value = savedDesc;
|
||||
}
|
||||
}
|
||||
|
||||
open() {
|
||||
this.loadState();
|
||||
this.modal.style.display = "flex";
|
||||
this.updateServiceStatus();
|
||||
}
|
||||
|
||||
close() {
|
||||
this.modal.style.display = "none";
|
||||
}
|
||||
|
||||
async updateServiceStatus() {
|
||||
const statusText = this.modal.querySelector("#bugReportServiceStatusText");
|
||||
const statusDot = this.modal.querySelector("#bugReportServiceStatusDot");
|
||||
const sendButton = this.modal.querySelector("#bugSendBtn");
|
||||
|
||||
sendButton.disabled = true;
|
||||
sendButton.style.setProperty("cursor", "not-allowed", "important");
|
||||
sendButton.style.setProperty("opacity", "0.5", "important");
|
||||
|
||||
try {
|
||||
const serviceStatus = await window.topbarAPI.getReportServiceStatus();
|
||||
const serviceAvailable = serviceStatus.status === "online";
|
||||
const statusVisible = !serviceAvailable;
|
||||
statusText.textContent = serviceStatus.message;
|
||||
statusDot.style.background = "#ef4444";
|
||||
statusText.style.color = "#fca5a5";
|
||||
this.modal.querySelector("#bugReportServiceStatus").style.setProperty(
|
||||
"display",
|
||||
statusVisible ? "flex" : "none",
|
||||
"important"
|
||||
);
|
||||
sendButton.disabled = !serviceAvailable;
|
||||
sendButton.style.setProperty("cursor", serviceAvailable ? "pointer" : "not-allowed", "important");
|
||||
sendButton.style.setProperty("opacity", serviceAvailable ? "1" : "0.5", "important");
|
||||
} catch (err) {
|
||||
statusText.textContent = "Unable to check bug report service.";
|
||||
statusDot.style.background = "#ef4444";
|
||||
statusText.style.color = "#fca5a5";
|
||||
this.modal.querySelector("#bugReportServiceStatus").style.setProperty("display", "flex", "important");
|
||||
sendButton.disabled = true;
|
||||
window.electronAPI.logError("Failed to retrieve bug report service status:", err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.bugModal = new BugReportModal();
|
||||
|
||||
document.getElementById("report-bug-btn").addEventListener("click", () => {
|
||||
window.bugModal.open();
|
||||
});
|
||||
@@ -16,8 +16,7 @@ let isOpen = false;
|
||||
* Toggles visibility of the settings panel when clicking the settings button.
|
||||
*/
|
||||
settingsButton.addEventListener("click", () => {
|
||||
const currentlyOpen = settingsPanel.style.display === "block";
|
||||
settingsPanel.style.display = currentlyOpen ? "none" : "block";
|
||||
settingsPanel.classList.toggle("open");
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -25,7 +24,7 @@ settingsButton.addEventListener("click", () => {
|
||||
*/
|
||||
function closePanel() {
|
||||
isOpen = false;
|
||||
settingsPanel.style.display = "none";
|
||||
settingsPanel.classList.remove("open");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,18 +17,20 @@ window.showToast = function (message, type = "info", duration = 4000) {
|
||||
toast.classList.add("toast", type);
|
||||
|
||||
const icons = {
|
||||
success: "✓",
|
||||
error: "✕",
|
||||
warning: "⚠",
|
||||
info: "ℹ"
|
||||
success: "check_circle",
|
||||
error: "error",
|
||||
warning: "warning",
|
||||
info: "info"
|
||||
};
|
||||
|
||||
const icon = icons[type] || icons.info;
|
||||
|
||||
toast.style.setProperty("--toast-duration", `${duration}ms`);
|
||||
toast.innerHTML = `
|
||||
<span class="toast-icon">${icon}</span>
|
||||
<span class="toast-icon material-icons-round">${icon}</span>
|
||||
<span class="toast-message">${message}</span>
|
||||
<button class="toast-close" title="Close">×</button>
|
||||
<div class="toast-progress"></div>
|
||||
`;
|
||||
|
||||
const closeBtn = toast.querySelector(".toast-close");
|
||||
@@ -81,3 +83,12 @@ window.showWarning = (message, duration = 4000) =>
|
||||
*/
|
||||
window.showInfo = (message, duration = 4000) =>
|
||||
window.showToast(message, "info", duration);
|
||||
|
||||
/**
|
||||
* Listens for toast notifications from the main process and displays them in the UI.
|
||||
*/
|
||||
window.electronAPI?.onToast(({message, type = "info", duration = 4000} = {}) => {
|
||||
if (typeof message === "string") {
|
||||
window.showToast(message, type, duration);
|
||||
}
|
||||
});
|
||||
@@ -35,25 +35,26 @@ export function initTopBar() {
|
||||
|
||||
}
|
||||
|
||||
async function attachListeners(){
|
||||
try{
|
||||
const featuresList = await window.electronAPI.getFeatures();
|
||||
//* Disabled because i disabled native topbar for now, but keeping the code in case i want to re-enable it later
|
||||
// async function attachListeners(){
|
||||
// try{
|
||||
// const featuresList = await window.electronAPI.getFeatures();
|
||||
|
||||
if (!featuresList.customTopBar) {
|
||||
const topbar = document.getElementById("topbar");
|
||||
const container = document.getElementById("container");
|
||||
const themeSwitcher = document.getElementById("theme-switcher");
|
||||
// if (!featuresList.customTopBar) {
|
||||
// const topbar = document.getElementById("topbar");
|
||||
// const container = document.getElementById("container");
|
||||
// const themeSwitcher = document.getElementById("theme-switcher");
|
||||
|
||||
if (topbar) topbar.style.display = "none";
|
||||
if (container) container.style.marginTop = "0";
|
||||
if (themeSwitcher) themeSwitcher.style.top = "30px";
|
||||
}
|
||||
// if (topbar) topbar.style.display = "none";
|
||||
// if (container) container.style.marginTop = "0";
|
||||
// if (themeSwitcher) themeSwitcher.style.top = "30px";
|
||||
// }
|
||||
|
||||
} catch (error) {
|
||||
console.error("Failed to load layout features for topbar:", error);
|
||||
}
|
||||
// } catch (error) {
|
||||
// console.error("Failed to load layout features for topbar:", error);
|
||||
// }
|
||||
|
||||
}
|
||||
// }
|
||||
|
||||
initTopBar()
|
||||
document.addEventListener("DOMContentLoaded", attachListeners)
|
||||
//document.addEventListener("DOMContentLoaded", attachListeners)
|
||||
128
public/styles/components/autoUpdate.css
Normal file
128
public/styles/components/autoUpdate.css
Normal file
@@ -0,0 +1,128 @@
|
||||
:root {
|
||||
--border-accent: #0ea5e966;
|
||||
--bg-accent: #0ea5e915;
|
||||
--fill-accent: #0ea5e9;
|
||||
--text-accent: #0369a1;
|
||||
--text-secondary: #64748b;
|
||||
}
|
||||
|
||||
#updateAvailable {
|
||||
display: none;
|
||||
width: fit-content;
|
||||
max-width: 420px;
|
||||
position: absolute;
|
||||
right: 18px;
|
||||
bottom: 5%;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.update-banner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
border: 0.5px solid var(--border-accent);
|
||||
background: var(--bg-accent);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.banner-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.banner-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.icon-wrap {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
background: var(--fill-accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.icon-wrap i {
|
||||
font-size: 16px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.text-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.update-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-accent);
|
||||
}
|
||||
|
||||
.update-sub {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.btn-update {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 6px 14px;
|
||||
border-radius: 8px;
|
||||
background: var(--fill-accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.btn-update:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-update:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.progress-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
height: 4px;
|
||||
border-radius: 99px;
|
||||
background: var(--border-accent);
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 99px;
|
||||
background: var(--fill-accent);
|
||||
width: 0%;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-meta {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
229
public/styles/components/changelog.css
Normal file
229
public/styles/components/changelog.css
Normal file
@@ -0,0 +1,229 @@
|
||||
.changelog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: rgba(5, 10, 16, 0.72);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.changelog-overlay[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.changelog-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(680px, 100%);
|
||||
max-height: min(720px, 88vh);
|
||||
overflow: hidden;
|
||||
color: var(--settings-text-color, #f8fafc);
|
||||
background: var(--settings-modal-bg-color, #18212b);
|
||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.changelog-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 24px 28px 18px;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.18);
|
||||
}
|
||||
|
||||
.changelog-eyebrow {
|
||||
color: var(--fill-accent, #0ea5e9);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.changelog-header h2 {
|
||||
margin: 5px 0 0;
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.changelog-close {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.changelog-close:hover {
|
||||
background: rgba(148, 163, 184, 0.14);
|
||||
}
|
||||
|
||||
.changelog-list {
|
||||
overflow-y: auto;
|
||||
padding: 20px 28px 28px;
|
||||
scrollbar-color: var(--fill-accent, #0ea5e9) transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.changelog-list::-webkit-scrollbar,
|
||||
.changelog-body pre::-webkit-scrollbar {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
}
|
||||
|
||||
.changelog-list::-webkit-scrollbar-track,
|
||||
.changelog-body pre::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.changelog-list::-webkit-scrollbar-thumb,
|
||||
.changelog-body pre::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--fill-accent, #0ea5e9) 70%, transparent);
|
||||
border: 2px solid transparent;
|
||||
border-radius: 999px;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.changelog-list::-webkit-scrollbar-thumb:hover,
|
||||
.changelog-body pre::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--fill-accent, #0ea5e9);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.changelog-entry {
|
||||
padding: 18px 0;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.16);
|
||||
}
|
||||
|
||||
.changelog-entry:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.changelog-entry:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.changelog-entry-heading {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.changelog-entry-heading h3 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.changelog-entry-heading span {
|
||||
flex-shrink: 0;
|
||||
color: var(--settings-subtitle-color, #94a3b8);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.changelog-body {
|
||||
margin: 12px 0 0;
|
||||
color: var(--settings-subtitle-color, #cbd5e1);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.changelog-body p {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.changelog-body p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.changelog-body h1,
|
||||
.changelog-body h2,
|
||||
.changelog-body h3,
|
||||
.changelog-body h4,
|
||||
.changelog-body h5,
|
||||
.changelog-body h6 {
|
||||
margin: 14px 0 8px;
|
||||
color: var(--settings-text-color, #f8fafc);
|
||||
}
|
||||
|
||||
.changelog-body h1 { font-size: 20px; }
|
||||
.changelog-body h2 { font-size: 18px; }
|
||||
.changelog-body h3 { font-size: 16px; }
|
||||
.changelog-body h4,
|
||||
.changelog-body h5,
|
||||
.changelog-body h6 { font-size: 14px; }
|
||||
|
||||
.changelog-body ul,
|
||||
.changelog-body ol {
|
||||
margin: 8px 0 12px;
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
.changelog-body li + li {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.changelog-body code {
|
||||
padding: 2px 5px;
|
||||
color: var(--settings-text-color, #f8fafc);
|
||||
background: rgba(148, 163, 184, 0.16);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.changelog-body pre {
|
||||
padding: 10px 12px;
|
||||
overflow-x: auto;
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.changelog-body pre code {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.changelog-body blockquote {
|
||||
margin: 10px 0;
|
||||
padding-left: 12px;
|
||||
color: var(--settings-subtitle-color, #cbd5e1);
|
||||
border-left: 3px solid var(--fill-accent, #0ea5e9);
|
||||
}
|
||||
|
||||
.changelog-body a {
|
||||
color: var(--fill-accent, #0ea5e9);
|
||||
}
|
||||
|
||||
.changelog-loading,
|
||||
.changelog-empty {
|
||||
margin: 0;
|
||||
color: var(--settings-subtitle-color, #cbd5e1);
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.changelog-overlay {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.changelog-header,
|
||||
.changelog-list {
|
||||
padding-left: 18px;
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
.changelog-entry-heading {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
}
|
||||
@@ -5,73 +5,70 @@
|
||||
right: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: 12px;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Toast */
|
||||
.toast {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
background-color: rgba(50, 50, 50, 0.9);
|
||||
border-radius: 6px;
|
||||
color: #ffffff;
|
||||
font-size: 0.9rem;
|
||||
max-width: 220px;
|
||||
min-width: 100px;
|
||||
animation: slideIn 0.25s ease-out;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
background-color: rgba(30, 30, 30, 0.9);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 10px;
|
||||
color: #f3f4f6;
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
max-width: 320px;
|
||||
min-width: 220px;
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3), 0 8px 10px -6px rgba(0, 0, 0, 0.2);
|
||||
animation: slideIn 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
pointer-events: auto;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.toast.success {
|
||||
background-color: rgba(40, 167, 69, 0.15);
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
background-color: rgba(220, 53, 69, 0.15);
|
||||
}
|
||||
|
||||
.toast.warning {
|
||||
background-color: rgba(255, 193, 7, 0.15);
|
||||
}
|
||||
|
||||
.toast.info {
|
||||
background-color: rgba(23, 162, 184, 0.15);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Toast Icon */
|
||||
.toast-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 18px;
|
||||
font-size: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toast-icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.toast.success .toast-icon {
|
||||
color: #28a745;
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.toast.error .toast-icon {
|
||||
color: #dc3545;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.toast.warning .toast-icon {
|
||||
color: #ffc107;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.toast.info .toast-icon {
|
||||
color: #17a2b8;
|
||||
color: #0ea5e9;
|
||||
}
|
||||
|
||||
/* Toast Message */
|
||||
.toast-message {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Toast Close Button */
|
||||
@@ -79,54 +76,83 @@
|
||||
flex-shrink: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: currentColor;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
padding: 0;
|
||||
font-size: 16px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.2s ease;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.toast-close:hover {
|
||||
opacity: 1;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.toast-progress {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 3px;
|
||||
width: 100%;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
animation: progress var(--toast-duration, 4000ms) linear forwards;
|
||||
}
|
||||
|
||||
.toast.success .toast-progress {
|
||||
background-color: #22c55e;
|
||||
}
|
||||
|
||||
.toast.error .toast-progress {
|
||||
background-color: #ef4444;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(350px);
|
||||
transform: translateY(-20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideOut {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateX(350px);
|
||||
transform: translateY(-20px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.toast.removing {
|
||||
animation: slideOut 0.25s ease-out;
|
||||
@keyframes progress {
|
||||
from {
|
||||
width: 100%;
|
||||
}
|
||||
to {
|
||||
width: 0%;
|
||||
}
|
||||
}
|
||||
|
||||
.toast.removing {
|
||||
animation: slideOut 0.25s ease-in forwards;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 480px) {
|
||||
#toast-container {
|
||||
bottom: 12px;
|
||||
right: 12px;
|
||||
left: 12px;
|
||||
top: auto;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
left: 16px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
|
||||
@@ -45,11 +45,22 @@
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
||||
display: none;
|
||||
overflow-y: auto;
|
||||
z-index: 9999;
|
||||
animation: slideIn 0.2s ease-out;
|
||||
/*animation: slideIn 0.2s ease-out; */
|
||||
color: var(--settings-text-color);
|
||||
|
||||
display: block;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateX(20px);
|
||||
transition: opacity 0.2s ease-out, transform 0.2s ease-out;
|
||||
}
|
||||
|
||||
.settings-panel.open {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.settings-panel::-webkit-scrollbar {
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
.custom-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding-left: 12px;
|
||||
gap: 4px;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
/* RIGHT - window buttons */
|
||||
@@ -43,11 +43,11 @@
|
||||
|
||||
/* Custom buttons (Logs / Wiki / etc.) */
|
||||
.custom-controls button {
|
||||
height: 32px;
|
||||
padding: 0 14px;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
|
||||
font-size: 15px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.custom-controls button:hover {
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
@import url("components/editpathbutton.css");
|
||||
@import url("components/progressBar.css");
|
||||
@import url("components/toast.css");
|
||||
@import url("components/autoUpdate.css");
|
||||
@import url("components/changelog.css");
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap');
|
||||
|
||||
|
||||
24
scripts/RELEASE-CHANGELOG.md
Normal file
24
scripts/RELEASE-CHANGELOG.md
Normal file
@@ -0,0 +1,24 @@
|
||||
### Features
|
||||
- **In-App Feedback**: Added a built-in feedback form allowing users to send feedback, bug reports, and daily logs directly to the development team.
|
||||
- **Community Contributions**: GitHub issues are now better maintained on the repository. If you want to contribute to the project, feel free to drop by and check out the `good first issue` tags!
|
||||
- **Changelog**: The changelog is now automatically displayed after an update (can be disabled if preferred).
|
||||
|
||||
### Improvements
|
||||
- **Bundle Size**: Optimized assets and dependencies, reducing the overall application bundle size by approximately 30% for specific versions.
|
||||
- **Updater UI**: Reworked the update notification for better clarity. Windows and AppImage users can download updates seamlessly in-app, while unsupported Linux package users are gracefully redirected to the manual download page. Flatpak and Snap users are not prompted, as updates are handled natively by their respective package managers.
|
||||
- **Toast Notifications**: Refactored the style and overall UX of toast alerts.
|
||||
- **Code Quality**: Implemented a linter to enforce coding standards.
|
||||
|
||||
### Fixes
|
||||
- **Auto-Updater**: Fixed a major issue causing `404 Not Found` errors when attempting to download updates for `.AppImage` releases.
|
||||
- **Linux Updates**: Fixed update crashes on `.rpm` and `.snap` packages. In-app update flows are now disabled for these formats to let the OS handle the process natively.
|
||||
- **Windows Logs**: Fixed a file naming issue with the log transport system on Windows.
|
||||
- **Desktop Entry Name**: Fixed the desktop entry name for Linux users, ensuring it displays correctly in application menus.
|
||||
- **Custom Top Bar**: Removed the `customTopBar` settings.
|
||||
- **Auto Update**: Removed the `autoAppUpdate` settings.
|
||||
- **UI (System Tray)**: Removed the ***[EXPERIMENTAL]*** label from the config panel.
|
||||
|
||||
### Chores
|
||||
- **Dependencies**: Removed `dotenv` from the project.
|
||||
- **Website**: A brand new website URL is now available: [freedomloader.acnolo.fr](https://freedomloader.acnolo.fr)
|
||||
- **Dev**: Added a new npm script to check Linux file quality.
|
||||
@@ -54,20 +54,6 @@ exec /opt/freedom-loader/freedom-loader "$@"
|
||||
EOF
|
||||
chmod +x "$TMP_WRAPPER/freedom-loader"
|
||||
|
||||
# Desktop entry file
|
||||
TMP_DESKTOP="$ROOT_DIR/.rpm-pkg-tmp-desktop"
|
||||
mkdir -p "$TMP_DESKTOP"
|
||||
cat > "$TMP_DESKTOP/freedom-loader.desktop" << 'EOF'
|
||||
[Desktop Entry]
|
||||
Name=Freedom Loader
|
||||
Exec=/opt/freedom-loader/freedom-loader %U
|
||||
Icon=freedom-loader
|
||||
Type=Application
|
||||
Categories=AudioVideo;Utility;Network;
|
||||
Comment=Free and open-source GUI for yt-dlp
|
||||
StartupWMClass=Freedom Loader
|
||||
EOF
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Step 1 - Build .rpm from linux-unpacked via fpm
|
||||
# ------------------------------------------------------------
|
||||
@@ -78,7 +64,7 @@ echo "Building RPM..."
|
||||
--version "$VERSION" \
|
||||
--architecture x86_64 \
|
||||
--description "Free and open-source GUI for yt-dlp" \
|
||||
--url "https://masteracnolo.github.io/Freedom-Loader-Site/" \
|
||||
--url "https://freedomloader.acnolo.fr/" \
|
||||
--maintainer "MasterAcnolo <MasterAcnolo@users.noreply.github.com>" \
|
||||
--license "GPL-3.0-only" \
|
||||
--depends gtk3 \
|
||||
@@ -89,11 +75,10 @@ echo "Building RPM..."
|
||||
--package "$RPM_OUT" \
|
||||
"$ROOT_DIR/dist/linux-unpacked/=/opt/freedom-loader" \
|
||||
"$TMP_WRAPPER/freedom-loader=/usr/bin/freedom-loader" \
|
||||
"$TMP_DESKTOP/freedom-loader.desktop=/usr/share/applications/freedom-loader.desktop" \
|
||||
"$ROOT_DIR/build/app-icon.png=/usr/share/icons/hicolor/512x512/apps/freedom-loader.png" \
|
||||
"$ROOT_DIR/package/com.masteracnolo.freedomloader.metainfo.xml=/usr/share/metainfo/com.masteracnolo.freedomloader.metainfo.xml"
|
||||
|
||||
rm -rf "$TMP_WRAPPER" "$TMP_DESKTOP"
|
||||
"$ROOT_DIR/package/com.masteracnolo.freedomloader.metainfo.xml=/usr/share/metainfo/com.masteracnolo.freedomloader.metainfo.xml" \
|
||||
"$ROOT_DIR/package/com.masteracnolo.freedomloader.desktop=/usr/share/applications/com.masteracnolo.freedomloader.desktop"
|
||||
rm -rf "$TMP_WRAPPER"
|
||||
echo "RPM built: $RPM_OUT"
|
||||
|
||||
# ------------------------------------------------------------
|
||||
|
||||
@@ -5,7 +5,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
VERSION=$(node -p "require('$ROOT_DIR/package.json').version")
|
||||
|
||||
f [[ "$VERSION" == *"-preview"* ]]; then
|
||||
if [[ "$VERSION" == *"-preview"* ]]; then
|
||||
echo "Error: The current version ($VERSION) is a 'preview"
|
||||
echo "Unable to launch release pipeline. Please update the package.json with a stable version.."
|
||||
exit 1
|
||||
@@ -64,6 +64,16 @@ echo "[0/5] Cleaning workspace..."
|
||||
rm -rf "$ROOT_DIR/dist" "$ROOT_DIR/srpm-out"
|
||||
echo "Workspace cleaned (dist/ and srpm-out/ removed)"
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Step 0.3 - Retrieve Changelog
|
||||
# ------------------------------------------------------------
|
||||
if [ ! -f "$ROOT_DIR/scripts/RELEASE-CHANGELOG.md" ]; then
|
||||
echo "Warning: RELEASE-CHANGELOG.md not found, release notes will be empty."
|
||||
CHANGELOG=""
|
||||
else
|
||||
CHANGELOG="$(cat "$ROOT_DIR/scripts/RELEASE-CHANGELOG.md")"
|
||||
echo "Changelog retrieved successfully"
|
||||
fi
|
||||
# ------------------------------------------------------------
|
||||
# Step 1 - Build Linux packages (AppImage, deb, snap)
|
||||
# ------------------------------------------------------------
|
||||
@@ -105,6 +115,10 @@ PREV_TAG=$(git rev-list --tags --skip=1 --max-count=1 | xargs git describe --tag
|
||||
|
||||
RELEASE_NOTES="# Freedom Loader - $VERSION
|
||||
|
||||
## Changelog
|
||||
|
||||
$CHANGELOG
|
||||
|
||||
## Found a bug or issue?
|
||||
Please report it in the [GitHub Issues](https://github.com/MasterAcnolo/Freedom-Loader/issues) section.
|
||||
|
||||
@@ -119,7 +133,7 @@ if [ "$DRY_RUN" = false ]; then
|
||||
"$ROOT_DIR/dist/Freedom-Loader-Setup-${VERSION}.exe.blockmap" \
|
||||
"$ROOT_DIR/dist/freedom-loader-${VERSION}.x86_64.rpm" \
|
||||
"$ROOT_DIR/dist/freedom-loader_${VERSION}_amd64.deb" \
|
||||
"$ROOT_DIR/dist/Freedom Loader-${VERSION}.AppImage" \
|
||||
"$ROOT_DIR/dist/Freedom-Loader-${VERSION}.AppImage" \
|
||||
"$ROOT_DIR/dist/freedom-loader_${VERSION}_amd64.snap" \
|
||||
"$ROOT_DIR/dist/latest.yml" \
|
||||
"$ROOT_DIR/dist/latest-linux.yml"
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
const EventEmitter = require("events");
|
||||
const { fetchDownload, cancelDownload } = require("../services/download.services");
|
||||
const { logger } = require("../logger");
|
||||
const { isValidUrl, isSafePath } = require("../helpers/validation.helpers");
|
||||
const { notifyDownloadFinished } = require("../helpers/notify.helpers");
|
||||
const { configFeatures } = require("../../config");
|
||||
|
||||
const progressEmitter = new EventEmitter();
|
||||
const speedEmitter = new EventEmitter();
|
||||
const stageEmitter = new EventEmitter();
|
||||
const playlistEmitter = new EventEmitter();
|
||||
|
||||
progressEmitter.setMaxListeners(0);
|
||||
speedEmitter.setMaxListeners(0);
|
||||
stageEmitter.setMaxListeners(0);
|
||||
playlistEmitter.setMaxListeners(0);
|
||||
|
||||
/**
|
||||
* Active Server-Sent Event subscribers receiving
|
||||
* download progress updates.
|
||||
*
|
||||
* Each listener receives a percentage value between
|
||||
* 0 and 100 during the download lifecycle.
|
||||
*/
|
||||
const progressListeners = [];
|
||||
|
||||
@@ -31,6 +39,13 @@ const stageListeners = [];
|
||||
*/
|
||||
const playlistListeners = [];
|
||||
|
||||
/**
|
||||
* Set of allowed quality values for downloads.
|
||||
* Used to validate user input and prevent invalid
|
||||
* quality options from being passed to the download service.
|
||||
*/
|
||||
const ALLOWED_QUALITIES = new Set(["best", "medium", "worst", "1080", "720", "480"]);
|
||||
|
||||
/**
|
||||
* Handles download requests and starts a yt-dlp download.
|
||||
*
|
||||
@@ -47,34 +62,91 @@ const playlistListeners = [];
|
||||
*/
|
||||
async function downloadController(req, res) {
|
||||
try {
|
||||
const body = req.body || {};
|
||||
const options = {
|
||||
url: req.body.url,
|
||||
audioOnly: req.body.audioOnly === "1",
|
||||
quality: req.body.quality || "best",
|
||||
outputFolder: req.body.savePath || undefined,
|
||||
playlistTitle: req.body.playlistTitle || undefined,
|
||||
url: body.url,
|
||||
audioOnly: body.audioOnly === "1" || body.audioOnly === true,
|
||||
quality: ALLOWED_QUALITIES.has(body.quality) ? body.quality : "best",
|
||||
outputFolder: body.savePath || body.outputFolder || undefined,
|
||||
playlistTitle: body.playlistTitle || undefined,
|
||||
};
|
||||
|
||||
if (!options.url || !isValidUrl(options.url)) return res.status(400).send("Invalid URL !");
|
||||
if (!options.url || typeof options.url !== "string" || !isValidUrl(options.url)) {
|
||||
return res.status(400).json({
|
||||
error: {code: "INVALID_URL", message: "Invalid URL"},
|
||||
});
|
||||
}
|
||||
if (options.outputFolder && !isSafePath(options.outputFolder)) {
|
||||
logger.warn(`Unsafe download path rejected: ${options.outputFolder}`);
|
||||
return res.status(400).send("Save Path Not Allowed.");
|
||||
return res.status(400).json({
|
||||
error: {code: "INVALID_PATH", message: "Save path not allowed"},
|
||||
});
|
||||
}
|
||||
|
||||
const progressHandler = (percent) => {
|
||||
progressEmitter.emit("progress", percent);
|
||||
for (let i = progressListeners.length - 1; i >= 0; i -= 1) {
|
||||
try {
|
||||
progressListeners[i](percent);
|
||||
} catch (err) {
|
||||
logger.debug(`progress listener error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const speedHandler = (speed) => {
|
||||
speedEmitter.emit("speed", speed);
|
||||
for (let i = speedListeners.length - 1; i >= 0; i -= 1) {
|
||||
try {
|
||||
speedListeners[i](speed);
|
||||
} catch (err) {
|
||||
logger.debug(`speed listener error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const stageHandler = (stage) => {
|
||||
stageEmitter.emit("stage", stage);
|
||||
for (let i = stageListeners.length - 1; i >= 0; i -= 1) {
|
||||
try {
|
||||
stageListeners[i](stage);
|
||||
} catch (err) {
|
||||
logger.debug(`stage listener error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const playlistHandler = (info) => {
|
||||
playlistEmitter.emit("playlist", info);
|
||||
for (let i = playlistListeners.length - 1; i >= 0; i -= 1) {
|
||||
try {
|
||||
playlistListeners[i](info);
|
||||
} catch (err) {
|
||||
logger.debug(`playlist listener error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const outputFolder = await fetchDownload(
|
||||
options,
|
||||
progressListeners,
|
||||
speedListeners,
|
||||
stageListeners,
|
||||
playlistListeners
|
||||
[progressHandler],
|
||||
[speedHandler],
|
||||
[stageHandler],
|
||||
[playlistHandler]
|
||||
);
|
||||
|
||||
notifyDownloadFinished(outputFolder, configFeatures.notifySystem);
|
||||
res.send("Download Done !");
|
||||
|
||||
return res.json({success: true, message: "Download Done !", outputFolder});
|
||||
} catch (err) {
|
||||
logger.error(`Server Error in /download: ${err?.message ?? err}`);
|
||||
res.status(500).send("Server Error");
|
||||
if (err && err.message === "Download already in progress") {
|
||||
return res.status(409).json({
|
||||
error: {code: "DOWNLOAD_IN_PROGRESS", message: "A download is already in progress"},
|
||||
});
|
||||
}
|
||||
return res.status(500).json({
|
||||
error: {code: "DOWNLOAD_FAILED", message: "Server Error"},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,17 +159,33 @@ async function downloadController(req, res) {
|
||||
*/
|
||||
function progressController(req, res) {
|
||||
res.set({
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
});
|
||||
res.flushHeaders?.();
|
||||
|
||||
const sendProgress = percent => res.write(`data: ${percent}\n\n`);
|
||||
const sendProgress = (percent) => {
|
||||
try {
|
||||
res.write(`data: ${percent}\n\n`);
|
||||
} catch (err) {
|
||||
// ignore broken pipe
|
||||
}
|
||||
};
|
||||
progressListeners.push(sendProgress);
|
||||
const onProgress = (percent) => sendProgress(percent);
|
||||
progressEmitter.on("progress", onProgress);
|
||||
|
||||
req.on('close', () => {
|
||||
progressListeners.splice(progressListeners.indexOf(sendProgress), 1);
|
||||
res.end();
|
||||
req.on("close", () => {
|
||||
const index = progressListeners.indexOf(sendProgress);
|
||||
if (index >= 0) progressListeners.splice(index, 1);
|
||||
progressEmitter.removeListener("progress", onProgress);
|
||||
try {
|
||||
res.end();
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -110,17 +198,33 @@ function progressController(req, res) {
|
||||
*/
|
||||
function speedController(req, res) {
|
||||
res.set({
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
});
|
||||
res.flushHeaders?.();
|
||||
|
||||
const sendSpeed = speed => res.write(`data: ${speed}\n\n`);
|
||||
const sendSpeed = (speed) => {
|
||||
try {
|
||||
res.write(`data: ${speed}\n\n`);
|
||||
} catch (err) {
|
||||
// ignore broken pipe
|
||||
}
|
||||
};
|
||||
speedListeners.push(sendSpeed);
|
||||
const onSpeed = (speed) => sendSpeed(speed);
|
||||
speedEmitter.on("speed", onSpeed);
|
||||
|
||||
req.on('close', () => {
|
||||
speedListeners.splice(speedListeners.indexOf(sendSpeed), 1);
|
||||
res.end();
|
||||
req.on("close", () => {
|
||||
const index = speedListeners.indexOf(sendSpeed);
|
||||
if (index >= 0) speedListeners.splice(index, 1);
|
||||
speedEmitter.removeListener("speed", onSpeed);
|
||||
try {
|
||||
res.end();
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -134,17 +238,33 @@ function speedController(req, res) {
|
||||
*/
|
||||
function stageController(req, res) {
|
||||
res.set({
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
});
|
||||
res.flushHeaders?.();
|
||||
|
||||
const sendStage = stage => res.write(`data: ${stage}\n\n`);
|
||||
const sendStage = (stage) => {
|
||||
try {
|
||||
res.write(`data: ${stage}\n\n`);
|
||||
} catch (err) {
|
||||
// ignore broken pipe
|
||||
}
|
||||
};
|
||||
stageListeners.push(sendStage);
|
||||
const onStage = (stage) => sendStage(stage);
|
||||
stageEmitter.on("stage", onStage);
|
||||
|
||||
req.on('close', () => {
|
||||
stageListeners.splice(stageListeners.indexOf(sendStage), 1);
|
||||
res.end();
|
||||
req.on("close", () => {
|
||||
const index = stageListeners.indexOf(sendStage);
|
||||
if (index >= 0) stageListeners.splice(index, 1);
|
||||
stageEmitter.removeListener("stage", onStage);
|
||||
try {
|
||||
res.end();
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -160,10 +280,12 @@ function cancelDownloadController(req, res) {
|
||||
const cancelled = cancelDownload();
|
||||
if (cancelled) {
|
||||
logger.info("Download and queue cancelled by user");
|
||||
res.send("Download stopped! Queue cleared.");
|
||||
return res.json({success: true, message: "Download stopped! Queue cleared."});
|
||||
} else {
|
||||
logger.warn("No download to cancel");
|
||||
res.status(400).send("No download in progress !");
|
||||
return res.status(400).json({
|
||||
error: {code: "NO_DOWNLOAD_IN_PROGRESS", message: "No download in progress !"},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,18 +303,42 @@ function cancelDownloadController(req, res) {
|
||||
*/
|
||||
function playlistInfoController(req, res) {
|
||||
res.set({
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
});
|
||||
res.flushHeaders?.();
|
||||
|
||||
const sendPlaylistInfo = info => res.write(`data: ${info}\n\n`);
|
||||
const sendPlaylistInfo = (info) => {
|
||||
try {
|
||||
res.write(`data: ${info}\n\n`);
|
||||
} catch (err) {
|
||||
// ignore broken pipe
|
||||
}
|
||||
};
|
||||
playlistListeners.push(sendPlaylistInfo);
|
||||
const onPlaylist = (info) => sendPlaylistInfo(info);
|
||||
playlistEmitter.on("playlist", onPlaylist);
|
||||
|
||||
req.on('close', () => {
|
||||
playlistListeners.splice(playlistListeners.indexOf(sendPlaylistInfo), 1);
|
||||
res.end();
|
||||
// Remove listener when client disconnects
|
||||
req.on("close", () => {
|
||||
const index = playlistListeners.indexOf(sendPlaylistInfo);
|
||||
if (index >= 0) playlistListeners.splice(index, 1);
|
||||
playlistEmitter.removeListener("playlist", onPlaylist);
|
||||
try {
|
||||
res.end();
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { downloadController, progressController, speedController, stageController, cancelDownloadController, playlistInfoController};
|
||||
module.exports = {
|
||||
downloadController,
|
||||
progressController,
|
||||
speedController,
|
||||
stageController,
|
||||
cancelDownloadController,
|
||||
playlistInfoController,
|
||||
};
|
||||
|
||||
@@ -1,130 +1,42 @@
|
||||
const { fetchInfo } = require("../services/info.services");
|
||||
const { parseVideo, parsePlaylist } = require("../helpers/parseInfo.helpers");
|
||||
const {fetchNormalizedInfo} = require("../services/info.services");
|
||||
const { logger } = require("../logger");
|
||||
const { isValidUrl } = require("../helpers/validation.helpers");
|
||||
const {isUrlPlaylist} = require("../helpers/playlist.helpers");
|
||||
|
||||
/**
|
||||
* 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)
|
||||
* Returns normalized JSON responses. Accepts URL via query (?url=...)
|
||||
* or request body for flexibility.
|
||||
*
|
||||
* @param {import("express").Request} req
|
||||
* @param {import("express").Response} res
|
||||
*/
|
||||
async function infoController(req, res) {
|
||||
try {
|
||||
const url = req.query.url || (req.body && req.body.url);
|
||||
const encodedUrl = typeof url === "string" ? encodeURIComponent(url) : undefined;
|
||||
|
||||
// Previously, i support both POST and GET element, but it's kinda weird. So now it only support GET.
|
||||
const url = req.query.url;
|
||||
const encodedUrl = encodeURIComponent(url)
|
||||
|
||||
/**
|
||||
* Basic validation:
|
||||
* - URL must exist
|
||||
* - Must be a string
|
||||
* - Must pass custom URL validation
|
||||
*/
|
||||
if (!url || typeof url !== "string" || !isValidUrl(url)) {
|
||||
return res.status(400).send("Invalid URL Or Missing");
|
||||
return res.status(400).json({
|
||||
error: {code: "INVALID_URL", message: "Invalid or missing URL"},
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`Info request received. RAW URL: ${url}`);
|
||||
logger.info(`Info request received. ENCODED: ${encodedUrl}`);
|
||||
|
||||
// Lightweight heuristic to detect playlist URLs. It works for Youtube.
|
||||
const isPlaylistUrl = isUrlPlaylist(url);
|
||||
|
||||
logger.info(
|
||||
isPlaylistUrl
|
||||
? "Estimated Data Type: Playlist"
|
||||
: "Estimated Data Type: Video"
|
||||
);
|
||||
|
||||
try {
|
||||
let data;
|
||||
|
||||
/**
|
||||
* Primary metadata fetch attempt
|
||||
*/
|
||||
try {
|
||||
data = await fetchInfo(url);
|
||||
} catch (err) {
|
||||
|
||||
/**
|
||||
* Fallback strategy:
|
||||
* If playlist extraction fails, retry as single video
|
||||
*/
|
||||
if (isPlaylistUrl) {
|
||||
|
||||
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];
|
||||
|
||||
try {
|
||||
data = await fetchInfo(singleVideoUrl);
|
||||
logger.info("Single video fetch succeeded after playlist failure");
|
||||
} catch (retryErr) {
|
||||
logger.error(
|
||||
`Single video fetch also failed: ${retryErr.message}`
|
||||
);
|
||||
|
||||
// Preserve original error context
|
||||
logger.error(err);
|
||||
}
|
||||
|
||||
} else {
|
||||
logger.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize response based on yt-dlp output type
|
||||
*/
|
||||
if (data._type === "playlist") {
|
||||
|
||||
// If the playlist got only one video, show it as a video.
|
||||
if (data.entries && data.entries.length === 1) {
|
||||
|
||||
const singleVideo = parseVideo(data.entries[0]);
|
||||
|
||||
logger.info(`Playlist with 1 item converted to video: ${singleVideo.title}`);
|
||||
|
||||
return res.json(singleVideo);
|
||||
}
|
||||
|
||||
const playlist = parsePlaylist(data);
|
||||
|
||||
logger.info(
|
||||
`Playlist fetched: ${playlist.title} (${playlist.count} videos)`
|
||||
);
|
||||
|
||||
return res.json(playlist);
|
||||
|
||||
} else {
|
||||
|
||||
const video = parseVideo(data);
|
||||
|
||||
logger.info(`Video fetched: ${video.title}`);
|
||||
|
||||
return res.json(video);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
|
||||
logger.error(
|
||||
`Info controller error: ${err && err.message ? err.message : err}`
|
||||
);
|
||||
|
||||
return res.status(500).send("Unable to fetch info");
|
||||
if (encodedUrl) {
|
||||
logger.info(`Info request received. ENCODED: ${encodedUrl}`);
|
||||
}
|
||||
|
||||
const result = await fetchNormalizedInfo(url);
|
||||
return res.json(result);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`Info controller error: ${err && err.message ? err.message : err}`
|
||||
);
|
||||
return res.status(500).json({
|
||||
error: {code: "FETCH_FAILED", message: "Unable to fetch info"},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { infoController };
|
||||
@@ -67,9 +67,7 @@ function buildYtDlpArgs({url, audioOnly, quality, outputFolder, isPlaylist}) {
|
||||
|
||||
logger.info("--- CONFIGURATION ---");
|
||||
|
||||
logger.info(`CONFIG autoUpdate: ${configFeatures.autoUpdate}`);
|
||||
logger.info(`CONFIG discordRPC: ${configFeatures.discordRPC}`);
|
||||
logger.info(`CONFIG customTopBar: ${configFeatures.customTopBar}`);
|
||||
logger.info(`CONFIG autoCheckInfo: ${configFeatures.autoCheckInfo}`);
|
||||
logger.info(`CONFIG keepPlaylistOrder: ${configFeatures.keepPlaylistOrder}`);
|
||||
logger.info(`CONFIG addThumbnail: ${configFeatures.addThumbnail}`);
|
||||
|
||||
@@ -2,6 +2,7 @@ const fs = require("fs");
|
||||
const os = require("os");
|
||||
const notify = require("./notify.helpers");
|
||||
const { logger } = require("../logger");
|
||||
const {sendToast} = require("../../app/toastNotifier");
|
||||
|
||||
/**
|
||||
* Detects the first supported browser installed on the system.
|
||||
@@ -15,6 +16,7 @@ const { logger } = require("../logger");
|
||||
* If no supported browser is found:
|
||||
* - A warning is logged
|
||||
* - A notification is displayed to the user
|
||||
* - An in-app toast is displayed to the user
|
||||
* - "firefox" is returned as a fallback to allow yt-dlp to
|
||||
* handle the error gracefully without crashing the application
|
||||
*
|
||||
@@ -51,6 +53,11 @@ function getUserBrowser() {
|
||||
// No supported browser found => Notify User
|
||||
logger.warn("No supported browser found on the system");
|
||||
notify.notifyFirefoxBrowserMissing();
|
||||
|
||||
sendToast(
|
||||
"Firefox was not found on your system. Please install Firefox to import browser cookies.",
|
||||
"error",
|
||||
);
|
||||
} else {
|
||||
logger.info("Browser found: firefox");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const { app } = require("electron");
|
||||
let app;
|
||||
try {
|
||||
app = require("electron").app;
|
||||
} catch (e) {
|
||||
app = null;
|
||||
}
|
||||
const config = require("../../config.js");
|
||||
|
||||
/**
|
||||
@@ -16,10 +21,12 @@ const ext = isWindows ? '.exe' : ''
|
||||
/** Centralized resource paths */
|
||||
const resourcesPath = config.devMode
|
||||
? path.join(__dirname, "../../resources")
|
||||
: process.resourcesPath;
|
||||
: (process.resourcesPath || path.join(__dirname, "../../resources"));
|
||||
|
||||
/** Default download folder (centralized) */
|
||||
const defaultDownloadFolder = path.join(app.getPath("downloads"), "Freedom Loader");
|
||||
/** Default download folder */
|
||||
const defaultDownloadFolder = app && typeof app.getPath === "function"
|
||||
? path.join(app.getPath("downloads"), "Freedom Loader")
|
||||
: path.join(process.env.HOME || process.cwd(), "Downloads", "Freedom Loader");
|
||||
|
||||
/**
|
||||
* Current platform:
|
||||
@@ -149,7 +156,9 @@ const binaryPaths = {
|
||||
* - 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 = app && typeof app.getPath === "function"
|
||||
? path.join(app.getPath("userData"), "themes")
|
||||
: path.join(process.env.HOME || process.cwd(), ".config", "Freedom Loader", "themes");
|
||||
const defaultThemesSourcePath = path.join("theme");
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
const RateLimit = require("express-rate-limit");
|
||||
|
||||
|
||||
/**
|
||||
* Rate limiter middleware used to protect public endpoints
|
||||
* against abusive or excessive requests.
|
||||
@@ -15,10 +14,29 @@ const RateLimit = require("express-rate-limit");
|
||||
const rateLimit = RateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 5,
|
||||
message: "Too many requests, please try again later.",
|
||||
statusCode: 429,
|
||||
message: {error: {code: "RATE_LIMITED", message: "Too many requests, please try again later."}},
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
|
||||
const downloadRateLimit = RateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 100,
|
||||
message: {error: {code: "RATE_LIMITED", message: "Too many download requests, please try again later."}},
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
|
||||
const infoRateLimit = RateLimit({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
max: 60,
|
||||
message: {error: {code: "RATE_LIMITED", message: "Too many info requests, please try again later."}},
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
rateLimit
|
||||
}
|
||||
rateLimit,
|
||||
downloadRateLimit,
|
||||
infoRateLimit,
|
||||
};
|
||||
@@ -1,9 +1,17 @@
|
||||
const express = require("express");
|
||||
const router = express.Router();
|
||||
const { downloadController, progressController, speedController, stageController, cancelDownloadController, playlistInfoController } = require("../controller/download.controller");
|
||||
const {
|
||||
downloadController,
|
||||
progressController,
|
||||
speedController,
|
||||
stageController,
|
||||
cancelDownloadController,
|
||||
playlistInfoController,
|
||||
} = require("../controller/download.controller");
|
||||
const {downloadRateLimit} = require("../helpers/rateLimit.helpers");
|
||||
|
||||
router.post("/", downloadController);
|
||||
router.post("/cancel", cancelDownloadController);
|
||||
router.post("/", downloadRateLimit, downloadController);
|
||||
router.post("/cancel", downloadRateLimit, cancelDownloadController);
|
||||
|
||||
// SSE for download progress
|
||||
router.get("/progress", progressController);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
const express = require("express");
|
||||
const router = express.Router();
|
||||
const { infoController } = require("../controller/info.controller");
|
||||
const {infoRateLimit} = require("../helpers/rateLimit.helpers");
|
||||
|
||||
router.get("/", infoController);
|
||||
router.get("/", infoRateLimit, infoController);
|
||||
router.post("/", infoRateLimit, infoController);
|
||||
|
||||
module.exports = router;
|
||||
@@ -36,7 +36,7 @@ app.get("/", rateLimit, (req, res) => {
|
||||
*/
|
||||
async function startServer() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const serverInstance = app.listen(config.applicationPort, () => {
|
||||
serverInstance = app.listen(config.applicationPort, "127.0.0.1" ,() => {
|
||||
logger.info(`Express server ready at http://localhost:${config.applicationPort}`);
|
||||
resolve(serverInstance);
|
||||
});
|
||||
@@ -53,11 +53,13 @@ async function startServer() {
|
||||
*/
|
||||
function stopServer() {
|
||||
if (serverInstance) {
|
||||
try{
|
||||
serverInstance.close();
|
||||
logger.info("Express server closed cleanly");
|
||||
} catch (err){
|
||||
logger.error("Express server was not closed cleanly:", err)
|
||||
try {
|
||||
serverInstance.close(() => {
|
||||
logger.info("Express server closed cleanly");
|
||||
});
|
||||
serverInstance = null;
|
||||
} catch (err) {
|
||||
logger.error("Express server was not closed cleanly:", err);
|
||||
}
|
||||
} else {
|
||||
logger.info("Express server was already closed");
|
||||
|
||||
@@ -121,9 +121,11 @@ function createPlaylistFolder(basePath, playlistTitle) {
|
||||
* download has completed successfully.
|
||||
*/
|
||||
function fetchDownload(options, progressListeners, speedListeners, stageListeners, playlistInfoListeners) {
|
||||
if (currentDownloadProcess && !currentDownloadProcess.killed) {
|
||||
return Promise.reject(new Error("Download already in progress"));
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
const userConfig = reloadFeatures();
|
||||
|
||||
logger.info(`CONFIG createPlaylistFolders: ${userConfig.createPlaylistFolders}`);
|
||||
@@ -171,7 +173,8 @@ function fetchDownload(options, progressListeners, speedListeners, stageListener
|
||||
|
||||
logger.info(`[yt-dlp args] ${args.join(" ")}`);
|
||||
|
||||
const child = execFile(userYtDlp, args);
|
||||
// If we are in a non-Windows environment, we can use detached mode to allow killing the entire process group.
|
||||
const child = execFile(userYtDlp, args, {detached: process.platform !== "win32"});
|
||||
currentDownloadProcess = child;
|
||||
|
||||
child.on("error", err => reject(new Error(`YT-DLP Error : ${err.message}`)));
|
||||
@@ -258,21 +261,36 @@ function fetchDownload(options, progressListeners, speedListeners, stageListener
|
||||
function cancelDownload() {
|
||||
if (currentDownloadProcess) {
|
||||
logger.info("Cancelling download and killing all child processes...");
|
||||
const pid = currentDownloadProcess.pid;
|
||||
|
||||
// Force kill the process and all its children with SIGKILL
|
||||
try {
|
||||
// Try to kill with SIGKILL on Windows (process group) or Unix
|
||||
if (process.platform === 'win32') {
|
||||
// On Windows, kill the process tree
|
||||
require('child_process').execSync(`taskkill /PID ${currentDownloadProcess.pid} /T /F`, { stdio: 'ignore' });
|
||||
if (process.platform === "win32") {
|
||||
if (pid) {
|
||||
require("child_process").execSync(`taskkill /PID ${pid} /T /F`, {
|
||||
stdio: "ignore",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// On Unix, send SIGKILL to process group
|
||||
process.kill(-currentDownloadProcess.pid, 'SIGKILL');
|
||||
if (pid) {
|
||||
try {
|
||||
process.kill(-pid, "SIGKILL");
|
||||
} catch (err) {
|
||||
logger.warn(`Error killing process group: ${err.message}`);
|
||||
currentDownloadProcess.kill("SIGKILL");
|
||||
}
|
||||
} else {
|
||||
currentDownloadProcess.kill("SIGKILL");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`Error killing process: ${err.message}`);
|
||||
// Fallback to regular kill
|
||||
currentDownloadProcess.kill('SIGKILL');
|
||||
try {
|
||||
if (currentDownloadProcess && !currentDownloadProcess.killed) {
|
||||
currentDownloadProcess.kill("SIGKILL");
|
||||
}
|
||||
} catch (killErr) {
|
||||
logger.warn(`Fallback kill failed: ${killErr.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
currentDownloadProcess = null;
|
||||
|
||||
@@ -2,6 +2,8 @@ const { execFile } = require("child_process");
|
||||
const { userYtDlp, denoPath } = require("../helpers/path.helpers");
|
||||
const {getUserBrowser} = require("../helpers/getBrowser.helpers");
|
||||
const { logger } = require("../logger");
|
||||
const {parseVideo, parsePlaylist} = require("../helpers/parseInfo.helpers");
|
||||
const {isUrlPlaylist} = require("../helpers/playlist.helpers");
|
||||
|
||||
/**
|
||||
* Retrieves metadata for a video or playlist using yt-dlp.
|
||||
@@ -21,54 +23,117 @@ const { logger } = require("../logger");
|
||||
*
|
||||
*/
|
||||
function fetchInfo(url) {
|
||||
/**
|
||||
* Base yt-dlp arguments used to retrieve metadata
|
||||
* without downloading media files.
|
||||
*/
|
||||
const metadataArgs = [
|
||||
"--js-runtimes",
|
||||
`deno:${denoPath}`,
|
||||
"--dump-single-json",
|
||||
"--ignore-errors",
|
||||
"--yes-playlist",
|
||||
"--cookies-from-browser",
|
||||
`${getUserBrowser()}`,
|
||||
"--extractor-args",
|
||||
"youtube:player_client=default",
|
||||
"--ignore-no-formats-error",
|
||||
];
|
||||
|
||||
/**
|
||||
* Base yt-dlp arguments used to retrieve metadata
|
||||
* without downloading media files.
|
||||
*/
|
||||
const metadataArgs = [
|
||||
"--js-runtimes", `deno:${denoPath}`,
|
||||
"--dump-single-json",
|
||||
"--ignore-errors",
|
||||
"--yes-playlist",
|
||||
"--cookies-from-browser",`${getUserBrowser()}`,
|
||||
"--extractor-args","youtube:player_client=default",
|
||||
"--ignore-no-formats-error",
|
||||
];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
execFile(userYtDlp,[...metadataArgs , url],
|
||||
{ timeout: 30_000, // 30s, if more timeout
|
||||
maxBuffer: 10 * 1024 * 1024 }, // Maximum response size: 10 MB (Node.js default is much smaller)
|
||||
|
||||
(error, stdout, stderr) => {
|
||||
|
||||
if (stderr) {
|
||||
const lower = stderr.toLowerCase();
|
||||
if (lower.includes("sign in to confirm") || lower.includes("failed to decrypt") || lower.includes("could not copy")) {
|
||||
return reject(new Error(`Unable to extract cookies from ${getUserBrowser()}. Please log in to this browser and try again.`));
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
userYtDlp,
|
||||
[...metadataArgs, url],
|
||||
{
|
||||
timeout: 30_000, // 30s, if more timeout
|
||||
maxBuffer: 10 * 1024 * 1024, // Maximum response size: 10 MB (Node.js default is much smaller)
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
if (stderr) {
|
||||
const lower = stderr?.toLowerCase();
|
||||
if (
|
||||
lower.includes("sign in to confirm") ||
|
||||
lower.includes("failed to decrypt") ||
|
||||
lower.includes("could not copy")
|
||||
) {
|
||||
return reject(
|
||||
new Error(
|
||||
`Unable to extract cookies from ${getUserBrowser()}. Please log in to this browser and try again.`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
logger.error(`YT-DLP Error: ${error.message}`);
|
||||
if (stderr) logger.debug(`stderr: ${stderr}`);
|
||||
return reject(new Error("Unable to fetch Info."));
|
||||
}
|
||||
if (error) {
|
||||
logger.error(`YT-DLP Error: ${error.message}`);
|
||||
if (stderr) logger.debug(`stderr: ${stderr}`);
|
||||
return reject(new Error("Unable to fetch Info."));
|
||||
}
|
||||
|
||||
if (!stdout) return reject(new Error("No Data Received."));
|
||||
if (!stdout) return reject(new Error("No Data Received."));
|
||||
|
||||
try {
|
||||
const data = JSON.parse(stdout);
|
||||
|
||||
resolve(data);
|
||||
} catch (e) {
|
||||
logger.error(`JSON Parsing Error: ${e.message}`);
|
||||
reject(new Error("JSON unreadable."));
|
||||
}
|
||||
});
|
||||
});
|
||||
try {
|
||||
const data = JSON.parse(stdout);
|
||||
resolve(data);
|
||||
} catch (e) {
|
||||
logger.error(`JSON Parsing Error: ${e.message}`);
|
||||
reject(new Error("JSON unreadable."));
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { fetchInfo };
|
||||
/**
|
||||
* Fetches and normalizes metadata for a video or playlist.
|
||||
* Handles playlist fallback to single video when playlist extraction fails
|
||||
* or when playlist contains only one entry.
|
||||
*
|
||||
* @param {string} url - Video or playlist URL
|
||||
* @returns {Promise<Object>} Normalized metadata object
|
||||
*/
|
||||
async function fetchNormalizedInfo(url) {
|
||||
if (!url || typeof url !== "string") {
|
||||
throw new Error("Invalid URL");
|
||||
}
|
||||
|
||||
const isPlaylistUrl = isUrlPlaylist(url);
|
||||
let data;
|
||||
|
||||
try {
|
||||
data = await fetchInfo(url);
|
||||
} catch (err) {
|
||||
if (isPlaylistUrl) {
|
||||
logger.info(
|
||||
`Playlist fetch failed: ${err.message}. Retrying as single video...`
|
||||
);
|
||||
try {
|
||||
const singleVideoUrl = url.split("&list")[0].split("?list")[0];
|
||||
data = await fetchInfo(singleVideoUrl);
|
||||
logger.info("Single video fetch succeeded after playlist failure");
|
||||
} catch (retryErr) {
|
||||
logger.error(`Single video fetch also failed: ${retryErr.message}`);
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
if (data && data._type === "playlist") {
|
||||
if (data.entries && data.entries.length === 1) {
|
||||
const singleVideo = parseVideo(data.entries[0]);
|
||||
logger.info(`Playlist with 1 item converted to video: ${singleVideo.title}`);
|
||||
return singleVideo;
|
||||
}
|
||||
const playlist = parsePlaylist(data);
|
||||
logger.info(`Playlist fetched: ${playlist.title} (${playlist.count} videos)`);
|
||||
return playlist;
|
||||
}
|
||||
|
||||
const video = parseVideo(data);
|
||||
logger.info(`Video fetched: ${video.title}`);
|
||||
return video;
|
||||
}
|
||||
|
||||
module.exports = {fetchInfo, fetchNormalizedInfo};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
const path = require("path");
|
||||
|
||||
jest.mock("../../server/helpers/getBrowser.helpers.js", () =>
|
||||
jest.fn(() => "firefox")
|
||||
);
|
||||
jest.mock("../../server/helpers/getBrowser.helpers.js", () => ({
|
||||
getUserBrowser: jest.fn(() => "firefox"),
|
||||
}));
|
||||
|
||||
jest.mock("../../server/helpers/path.helpers.js", () => ({
|
||||
ffmpegPath: "/mock/ffmpeg",
|
||||
@@ -11,7 +11,6 @@ jest.mock("../../server/helpers/path.helpers.js", () => ({
|
||||
|
||||
jest.mock("../../config.js", () => ({
|
||||
configFeatures: {
|
||||
autoUpdate: false,
|
||||
discordRPC: false,
|
||||
customTopBar: false,
|
||||
autoCheckInfo: false,
|
||||
|
||||
43
test/unit/config.test.js
Normal file
43
test/unit/config.test.js
Normal file
@@ -0,0 +1,43 @@
|
||||
const {mergeMissingFeatures} = require("../../config");
|
||||
|
||||
describe("configuration migration", () => {
|
||||
test("adds missing defaults and preserves user values", () => {
|
||||
const result = mergeMissingFeatures(
|
||||
{enabled: true, retries: 3},
|
||||
{enabled: false}
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
features: {enabled: false, retries: 3},
|
||||
changed: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves unknown user-defined keys", () => {
|
||||
const result = mergeMissingFeatures(
|
||||
{enabled: true},
|
||||
{enabled: false, customSetting: "keep me"}
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
features: {enabled: false, customSetting: "keep me"},
|
||||
changed: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("reports no migration when all default keys exist", () => {
|
||||
const result = mergeMissingFeatures(
|
||||
{enabled: true, retries: 3},
|
||||
{enabled: false, retries: 0}
|
||||
);
|
||||
|
||||
expect(result.changed).toBe(false);
|
||||
expect(result.features.retries).toBe(0);
|
||||
});
|
||||
|
||||
test("rejects non-object configurations", () => {
|
||||
expect(() => mergeMissingFeatures({}, [])).toThrow(
|
||||
"User configuration must be a JSON object"
|
||||
);
|
||||
});
|
||||
});
|
||||
213
test/unit/download.controller.test.js
Normal file
213
test/unit/download.controller.test.js
Normal file
@@ -0,0 +1,213 @@
|
||||
jest.mock("../../server/services/download.services", () => ({
|
||||
fetchDownload: jest.fn(),
|
||||
cancelDownload: jest.fn(),
|
||||
}));
|
||||
jest.mock("../../server/logger", () => ({
|
||||
logger: {
|
||||
info: jest.fn(),
|
||||
error: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
},
|
||||
}));
|
||||
jest.mock("../../server/helpers/validation.helpers", () => ({
|
||||
isValidUrl: jest.fn(),
|
||||
isSafePath: jest.fn(),
|
||||
}));
|
||||
jest.mock("../../server/helpers/notify.helpers", () => ({
|
||||
notifyDownloadFinished: jest.fn(),
|
||||
}));
|
||||
jest.mock("../../config", () => ({
|
||||
configFeatures: {
|
||||
notifySystem: true,
|
||||
},
|
||||
}));
|
||||
|
||||
const {fetchDownload, cancelDownload} = require("../../server/services/download.services");
|
||||
const {logger} = require("../../server/logger");
|
||||
const {isValidUrl, isSafePath} = require("../../server/helpers/validation.helpers");
|
||||
const {notifyDownloadFinished} = require("../../server/helpers/notify.helpers");
|
||||
const {
|
||||
downloadController,
|
||||
cancelDownloadController,
|
||||
progressController,
|
||||
speedController,
|
||||
stageController,
|
||||
playlistInfoController,
|
||||
} = require("../../server/controller/download.controller");
|
||||
|
||||
describe("download.controller", () => {
|
||||
let req, res;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
req = {body: {}, on: jest.fn()};
|
||||
res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn().mockReturnThis(),
|
||||
send: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
write: jest.fn().mockReturnThis(),
|
||||
flushHeaders: jest.fn(),
|
||||
end: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe("downloadController", () => {
|
||||
test("returns 400 for invalid url", async () => {
|
||||
isValidUrl.mockReturnValue(false);
|
||||
req.body.url = "bad-url";
|
||||
await downloadController(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: {code: "INVALID_URL", message: "Invalid URL"},
|
||||
});
|
||||
});
|
||||
|
||||
test("returns 400 for unsafe path", async () => {
|
||||
isValidUrl.mockReturnValue(true);
|
||||
isSafePath.mockReturnValue(false);
|
||||
req.body.url = "https://youtube.com/watch?v=123";
|
||||
req.body.savePath = "C:\\Windows\\System32";
|
||||
await downloadController(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: {code: "INVALID_PATH", message: "Save path not allowed"},
|
||||
});
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("starts download and returns success", async () => {
|
||||
isValidUrl.mockReturnValue(true);
|
||||
isSafePath.mockReturnValue(true);
|
||||
req.body = {
|
||||
url: "https://youtube.com/watch?v=123",
|
||||
audioOnly: "1",
|
||||
quality: "720",
|
||||
savePath: "/home/user/Downloads",
|
||||
playlistTitle: "My Playlist",
|
||||
};
|
||||
fetchDownload.mockResolvedValue("/home/user/Downloads");
|
||||
await downloadController(req, res);
|
||||
expect(fetchDownload).toHaveBeenCalled();
|
||||
const callArgs = fetchDownload.mock.calls[0][0];
|
||||
expect(callArgs.url).toBe("https://youtube.com/watch?v=123");
|
||||
expect(callArgs.audioOnly).toBe(true);
|
||||
expect(callArgs.quality).toBe("720");
|
||||
expect(callArgs.outputFolder).toBe("/home/user/Downloads");
|
||||
expect(callArgs.playlistTitle).toBe("My Playlist");
|
||||
expect(notifyDownloadFinished).toHaveBeenCalledWith(
|
||||
"/home/user/Downloads",
|
||||
true
|
||||
);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
message: "Download Done !",
|
||||
outputFolder: "/home/user/Downloads",
|
||||
});
|
||||
});
|
||||
|
||||
test("defaults quality to best for invalid quality", async () => {
|
||||
isValidUrl.mockReturnValue(true);
|
||||
fetchDownload.mockResolvedValue("/downloads");
|
||||
req.body = {url: "https://youtube.com/watch?v=123", quality: "invalid"};
|
||||
await downloadController(req, res);
|
||||
const callArgs = fetchDownload.mock.calls[0][0];
|
||||
expect(callArgs.quality).toBe("best");
|
||||
});
|
||||
|
||||
test("returns 409 when download already in progress", async () => {
|
||||
isValidUrl.mockReturnValue(true);
|
||||
fetchDownload.mockRejectedValue(new Error("Download already in progress"));
|
||||
req.body.url = "https://youtube.com/watch?v=123";
|
||||
await downloadController(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(409);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: {code: "DOWNLOAD_IN_PROGRESS", message: "A download is already in progress"},
|
||||
});
|
||||
});
|
||||
|
||||
test("returns 500 on generic error", async () => {
|
||||
isValidUrl.mockReturnValue(true);
|
||||
fetchDownload.mockRejectedValue(new Error("yt-dlp failed"));
|
||||
req.body.url = "https://youtube.com/watch?v=123";
|
||||
await downloadController(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: {code: "DOWNLOAD_FAILED", message: "Server Error"},
|
||||
});
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancelDownloadController", () => {
|
||||
test("returns success when download cancelled", () => {
|
||||
cancelDownload.mockReturnValue(true);
|
||||
cancelDownloadController(req, res);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
message: "Download stopped! Queue cleared.",
|
||||
});
|
||||
expect(logger.info).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns 400 when no download in progress", () => {
|
||||
cancelDownload.mockReturnValue(false);
|
||||
cancelDownloadController(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: {code: "NO_DOWNLOAD_IN_PROGRESS", message: "No download in progress !"},
|
||||
});
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SSE controllers", () => {
|
||||
test("progressController sets headers and writes data", () => {
|
||||
req.on = jest.fn((event, cb) => {
|
||||
if (event === "close") {
|
||||
// don't call immediately
|
||||
}
|
||||
});
|
||||
progressController(req, res);
|
||||
expect(res.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
})
|
||||
);
|
||||
expect(res.flushHeaders).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("speedController sets headers", () => {
|
||||
req.on = jest.fn();
|
||||
speedController(req, res);
|
||||
expect(res.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
"Content-Type": "text/event-stream",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("stageController sets headers", () => {
|
||||
req.on = jest.fn();
|
||||
stageController(req, res);
|
||||
expect(res.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
"Content-Type": "text/event-stream",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("playlistInfoController sets headers", () => {
|
||||
req.on = jest.fn();
|
||||
playlistInfoController(req, res);
|
||||
expect(res.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
"Content-Type": "text/event-stream",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
83
test/unit/info.controller.test.js
Normal file
83
test/unit/info.controller.test.js
Normal file
@@ -0,0 +1,83 @@
|
||||
jest.mock("../../server/services/info.services", () => ({
|
||||
fetchNormalizedInfo: jest.fn(),
|
||||
}));
|
||||
jest.mock("../../server/logger", () => ({
|
||||
logger: {
|
||||
info: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
}));
|
||||
jest.mock("../../server/helpers/validation.helpers", () => ({
|
||||
isValidUrl: jest.fn(),
|
||||
}));
|
||||
|
||||
const {fetchNormalizedInfo} = require("../../server/services/info.services");
|
||||
const {logger} = require("../../server/logger");
|
||||
const {isValidUrl} = require("../../server/helpers/validation.helpers");
|
||||
const {infoController} = require("../../server/controller/info.controller");
|
||||
|
||||
describe("info.controller", () => {
|
||||
let req, res;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
req = {query: {}, body: {}};
|
||||
res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn().mockReturnThis(),
|
||||
send: jest.fn().mockReturnThis(),
|
||||
};
|
||||
});
|
||||
|
||||
test("returns 400 for missing url", async () => {
|
||||
isValidUrl.mockReturnValue(false);
|
||||
req.query.url = undefined;
|
||||
await infoController(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: {code: "INVALID_URL", message: "Invalid or missing URL"},
|
||||
});
|
||||
});
|
||||
|
||||
test("returns 400 for invalid url", async () => {
|
||||
isValidUrl.mockReturnValue(false);
|
||||
req.query.url = "not-a-url";
|
||||
await infoController(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: {code: "INVALID_URL", message: "Invalid or missing URL"},
|
||||
});
|
||||
});
|
||||
|
||||
test("accepts url from body", async () => {
|
||||
isValidUrl.mockReturnValue(true);
|
||||
req.body.url = "https://youtube.com/watch?v=123";
|
||||
fetchNormalizedInfo.mockResolvedValue({type: "video", title: "Test"});
|
||||
await infoController(req, res);
|
||||
expect(fetchNormalizedInfo).toHaveBeenCalledWith("https://youtube.com/watch?v=123");
|
||||
expect(res.json).toHaveBeenCalledWith({type: "video", title: "Test"});
|
||||
});
|
||||
|
||||
test("returns normalized result for valid url", async () => {
|
||||
isValidUrl.mockReturnValue(true);
|
||||
req.query.url = "https://youtube.com/watch?v=123";
|
||||
const mockResult = {type: "video", title: "My Video", duration: 120};
|
||||
fetchNormalizedInfo.mockResolvedValue(mockResult);
|
||||
await infoController(req, res);
|
||||
expect(fetchNormalizedInfo).toHaveBeenCalledWith("https://youtube.com/watch?v=123");
|
||||
expect(res.json).toHaveBeenCalledWith(mockResult);
|
||||
expect(logger.info).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns 500 on service error", async () => {
|
||||
isValidUrl.mockReturnValue(true);
|
||||
req.query.url = "https://youtube.com/watch?v=123";
|
||||
fetchNormalizedInfo.mockRejectedValue(new Error("fetch failed"));
|
||||
await infoController(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: {code: "FETCH_FAILED", message: "Unable to fetch info"},
|
||||
});
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
161
test/unit/info.services.test.js
Normal file
161
test/unit/info.services.test.js
Normal file
@@ -0,0 +1,161 @@
|
||||
jest.mock("child_process");
|
||||
jest.mock("../../server/helpers/path.helpers", () => ({
|
||||
userYtDlp: "/mock/yt-dlp",
|
||||
denoPath: "/mock/deno",
|
||||
}));
|
||||
jest.mock("../../server/helpers/getBrowser.helpers", () => ({
|
||||
getUserBrowser: jest.fn(() => "firefox"),
|
||||
}));
|
||||
jest.mock("../../server/logger", () => ({
|
||||
logger: {
|
||||
info: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
},
|
||||
}));
|
||||
jest.mock("../../server/helpers/parseInfo.helpers", () => ({
|
||||
parseVideo: jest.fn((data) => ({type: "video", ...data})),
|
||||
parsePlaylist: jest.fn((data) => ({
|
||||
type: "playlist",
|
||||
title: data.title,
|
||||
count: data.entries ? data.entries.length : 0,
|
||||
videos: [],
|
||||
})),
|
||||
}));
|
||||
jest.mock("../../server/helpers/playlist.helpers", () => ({
|
||||
isUrlPlaylist: jest.fn(),
|
||||
}));
|
||||
|
||||
const {execFile} = require("child_process");
|
||||
const {fetchInfo, fetchNormalizedInfo} = require("../../server/services/info.services");
|
||||
const {parseVideo, parsePlaylist} = require("../../server/helpers/parseInfo.helpers");
|
||||
const {isUrlPlaylist} = require("../../server/helpers/playlist.helpers");
|
||||
const {logger} = require("../../server/logger");
|
||||
|
||||
describe("info.services", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("fetchInfo", () => {
|
||||
test("resolves parsed json on success", async () => {
|
||||
execFile.mockImplementation((cmd, args, opts, cb) => {
|
||||
cb(null, JSON.stringify({_type: "video", title: "Test"}), "");
|
||||
});
|
||||
const result = await fetchInfo("https://youtube.com/watch?v=123");
|
||||
expect(result).toEqual({_type: "video", title: "Test"});
|
||||
expect(execFile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects when cookies error in stderr", async () => {
|
||||
execFile.mockImplementation((cmd, args, opts, cb) => {
|
||||
cb(null, "", "sign in to confirm you're not a bot");
|
||||
});
|
||||
await expect(fetchInfo("https://youtube.com/watch?v=123")).rejects.toThrow(
|
||||
/Unable to extract cookies/
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects on exec error", async () => {
|
||||
execFile.mockImplementation((cmd, args, opts, cb) => {
|
||||
cb(new Error("spawn failed"), "", "");
|
||||
});
|
||||
await expect(fetchInfo("https://youtube.com/watch?v=123")).rejects.toThrow(
|
||||
"Unable to fetch Info."
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects when no stdout", async () => {
|
||||
execFile.mockImplementation((cmd, args, opts, cb) => {
|
||||
cb(null, "", "");
|
||||
});
|
||||
await expect(fetchInfo("https://youtube.com/watch?v=123")).rejects.toThrow(
|
||||
"No Data Received."
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects on invalid json", async () => {
|
||||
execFile.mockImplementation((cmd, args, opts, cb) => {
|
||||
cb(null, "not json", "");
|
||||
});
|
||||
await expect(fetchInfo("https://youtube.com/watch?v=123")).rejects.toThrow(
|
||||
"JSON unreadable."
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchNormalizedInfo", () => {
|
||||
test("normalizes video", async () => {
|
||||
isUrlPlaylist.mockReturnValue(false);
|
||||
execFile.mockImplementation((cmd, args, opts, cb) => {
|
||||
cb(null, JSON.stringify({_type: "video", title: "My Video"}), "");
|
||||
});
|
||||
const result = await fetchNormalizedInfo("https://youtube.com/watch?v=123");
|
||||
expect(result.type).toBe("video");
|
||||
expect(parseVideo).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("normalizes playlist", async () => {
|
||||
isUrlPlaylist.mockReturnValue(true);
|
||||
execFile.mockImplementation((cmd, args, opts, cb) => {
|
||||
cb(
|
||||
null,
|
||||
JSON.stringify({
|
||||
_type: "playlist",
|
||||
title: "Playlist",
|
||||
entries: [{id: "1"}, {id: "2"}],
|
||||
}),
|
||||
""
|
||||
);
|
||||
});
|
||||
const result = await fetchNormalizedInfo("https://youtube.com/playlist?list=ABC");
|
||||
expect(result.type).toBe("playlist");
|
||||
expect(parsePlaylist).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("converts playlist with 1 entry to video", async () => {
|
||||
isUrlPlaylist.mockReturnValue(true);
|
||||
execFile.mockImplementation((cmd, args, opts, cb) => {
|
||||
cb(
|
||||
null,
|
||||
JSON.stringify({
|
||||
_type: "playlist",
|
||||
title: "Playlist",
|
||||
entries: [{id: "1", title: "Single"}],
|
||||
}),
|
||||
""
|
||||
);
|
||||
});
|
||||
const result = await fetchNormalizedInfo("https://youtube.com/playlist?list=ABC");
|
||||
expect(result.type).toBe("video");
|
||||
expect(parseVideo).toHaveBeenCalled();
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Playlist with 1 item converted to video")
|
||||
);
|
||||
});
|
||||
|
||||
test("falls back to single video when playlist fetch fails", async () => {
|
||||
isUrlPlaylist.mockReturnValue(true);
|
||||
let callCount = 0;
|
||||
execFile.mockImplementation((cmd, args, opts, cb) => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
cb(new Error("fail"), "", "");
|
||||
} else {
|
||||
cb(null, JSON.stringify({_type: "video", title: "Fallback"}), "");
|
||||
}
|
||||
});
|
||||
const result = await fetchNormalizedInfo("https://youtube.com/watch?v=123&list=ABC");
|
||||
expect(result.type).toBe("video");
|
||||
expect(callCount).toBe(2);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Retrying as single video")
|
||||
);
|
||||
});
|
||||
|
||||
test("throws when url is invalid type", async () => {
|
||||
await expect(fetchNormalizedInfo(null)).rejects.toThrow("Invalid URL");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
const mockShow = jest.fn();
|
||||
const mockOn = jest.fn();
|
||||
const mockOpenPath = jest.fn();
|
||||
const mockOpenPath = jest.fn().mockResolvedValue("");
|
||||
const mockOpenExternal = jest.fn();
|
||||
|
||||
jest.mock("electron", () => ({
|
||||
@@ -71,13 +71,16 @@ describe("notify.helpers", () => {
|
||||
expect(mockShow).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("opens folder when notification is clicked", () => {
|
||||
test("opens folder when notification is clicked", (done) => {
|
||||
notifyDownloadFinished("/downloads");
|
||||
|
||||
const callback = mockOn.mock.calls[0][1];
|
||||
callback();
|
||||
|
||||
expect(shell.openPath).toHaveBeenCalledWith("/downloads");
|
||||
setTimeout(() => {
|
||||
expect(shell.openPath).toHaveBeenCalledWith("/downloads");
|
||||
done();
|
||||
}, 200);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,7 +104,7 @@ describe("notify.helpers", () => {
|
||||
callback();
|
||||
|
||||
expect(shell.openExternal).toHaveBeenCalledWith(
|
||||
"https://youtube.com/shorts/cN9f4s1Mf88?si=519QCVd_-fzJqRf1"
|
||||
"https://www.firefox.com/en-US/download/"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -126,7 +129,7 @@ describe("notify.helpers", () => {
|
||||
callback();
|
||||
|
||||
expect(shell.openExternal).toHaveBeenCalledWith(
|
||||
"https://youtube.com/shorts/cN9f4s1Mf88?si=519QCVd_-fzJqRf1"
|
||||
"https://www.firefox.com/en-US/download/"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
66
test/unit/parseInfo.helpers.test.js
Normal file
66
test/unit/parseInfo.helpers.test.js
Normal file
@@ -0,0 +1,66 @@
|
||||
const {parseVideo, parsePlaylist} = require("../../server/helpers/parseInfo.helpers");
|
||||
|
||||
describe("parseInfo.helpers", () => {
|
||||
describe("parseVideo", () => {
|
||||
test("adds type video and spreads data", () => {
|
||||
const data = {title: "Test Video", duration: 120, id: "abc123"};
|
||||
const result = parseVideo(data);
|
||||
expect(result.type).toBe("video");
|
||||
expect(result.title).toBe("Test Video");
|
||||
expect(result.duration).toBe(120);
|
||||
expect(result.id).toBe("abc123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePlaylist", () => {
|
||||
test("normalizes playlist with entries", () => {
|
||||
const data = {
|
||||
title: "My Playlist",
|
||||
uploader: "Test Channel",
|
||||
entries: [
|
||||
{
|
||||
id: "vid1",
|
||||
title: "Video 1",
|
||||
webpage_url: "https://example.com/watch?v=vid1",
|
||||
duration: 60,
|
||||
thumbnail: "thumb1.jpg",
|
||||
uploader: "Uploader1",
|
||||
},
|
||||
{
|
||||
id: "vid2",
|
||||
title: "Video 2",
|
||||
webpage_url: "https://example.com/watch?v=vid2",
|
||||
duration: 90,
|
||||
thumbnail: "thumb2.jpg",
|
||||
uploader: "Uploader2",
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = parsePlaylist(data);
|
||||
expect(result.type).toBe("playlist");
|
||||
expect(result.title).toBe("My Playlist");
|
||||
expect(result.channel).toBe("Test Channel");
|
||||
expect(result.count).toBe(2);
|
||||
expect(result.videos).toHaveLength(2);
|
||||
expect(result.videos[0]).toEqual({
|
||||
id: "vid1",
|
||||
title: "Video 1",
|
||||
url: "https://example.com/watch?v=vid1",
|
||||
duration: 60,
|
||||
thumbnail: "thumb1.jpg",
|
||||
uploader: "Uploader1",
|
||||
});
|
||||
});
|
||||
|
||||
test("falls back to id when title missing", () => {
|
||||
const data = {
|
||||
id: "PL123",
|
||||
entries: [],
|
||||
};
|
||||
const result = parsePlaylist(data);
|
||||
expect(result.title).toBe("PL123");
|
||||
expect(result.count).toBe(0);
|
||||
expect(result.videos).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
38
test/unit/toastNotifier.test.js
Normal file
38
test/unit/toastNotifier.test.js
Normal file
@@ -0,0 +1,38 @@
|
||||
const mockSend = jest.fn();
|
||||
const mockGetMainWindow = jest.fn();
|
||||
|
||||
jest.mock("../../app/windowManager", () => ({
|
||||
getMainWindow: mockGetMainWindow,
|
||||
}));
|
||||
|
||||
const {sendToast} = require("../../app/toastNotifier");
|
||||
|
||||
describe("toastNotifier", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test("sends a toast to the active renderer", () => {
|
||||
mockGetMainWindow.mockReturnValue({
|
||||
isDestroyed: () => false,
|
||||
webContents: {
|
||||
isDestroyed: () => false,
|
||||
send: mockSend,
|
||||
},
|
||||
});
|
||||
|
||||
expect(sendToast("Firefox is missing", "error", 5000)).toBe(true);
|
||||
expect(mockSend).toHaveBeenCalledWith("show-toast", {
|
||||
message: "Firefox is missing",
|
||||
type: "error",
|
||||
duration: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
test("does not throw when the renderer is unavailable", () => {
|
||||
mockGetMainWindow.mockReturnValue(null);
|
||||
|
||||
expect(sendToast("Firefox is missing", "error")).toBe(false);
|
||||
expect(mockSend).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
40
test/unit/validation.helpers.test.js
Normal file
40
test/unit/validation.helpers.test.js
Normal file
@@ -0,0 +1,40 @@
|
||||
const {isValidUrl, isSafePath} = require("../../server/helpers/validation.helpers");
|
||||
|
||||
describe("validation.helpers", () => {
|
||||
describe("isValidUrl", () => {
|
||||
test("returns true for valid http url", () => {
|
||||
expect(isValidUrl("http://example.com")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for valid https url", () => {
|
||||
expect(isValidUrl("https://youtube.com/watch?v=123")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for invalid url", () => {
|
||||
expect(isValidUrl("not-a-url")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for empty string", () => {
|
||||
expect(isValidUrl("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSafePath", () => {
|
||||
test("returns false for empty or short path", () => {
|
||||
expect(isSafePath("")).toBe(false);
|
||||
expect(isSafePath("ab")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for Windows System32 path", () => {
|
||||
expect(isSafePath("C:\\Windows\\System32\\test")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for Windows path containing Windows", () => {
|
||||
expect(isSafePath("C:\\Windows\\SomeFolder")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns true for safe user path", () => {
|
||||
expect(isSafePath("/home/user/Downloads")).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user