96 Commits

Author SHA1 Message Date
MasterAcnolo
787cc69600 fix!: major security issues. No host were filled and anyone through LAN could make undesirable things 2026-11-12 19:33:42 +01:00
MasterAcnolo
f650770df7 test: add unit tests for configuration and download controllers and handle electron app initialization and improve devMode logic 2026-10-28 21:58:46 +01:00
MasterAcnolo
f0f3018f80 docs: config autoStart missing values filling notes 2026-10-28 21:58:11 +01:00
MasterAcnolo
061c6b3b66 refactor(download): controller with:
- Event emitters
- Improved error handling.
- Can not force a download from qualities different from current set
2026-10-28 21:57:46 +01:00
MasterAcnolo
c9bb529b47 refactor(info): responsabilities is now better managed between controller and services.
Error is handled better. Normalize info
2026-10-28 21:56:11 +01:00
MasterAcnolo
8c3e1135e5 fix: linting 2026-10-28 21:52:26 +01:00
MasterAcnolo
fa5776b254 fix: linting 2026-10-28 21:52:22 +01:00
MasterAcnolo
ef68484828 fix: app management in path.helpers.js, used for unit test 2026-10-28 21:49:37 +01:00
MasterAcnolo
fdfe30e796 feat(download): update fetch requests to use JSON format and improve error handling 2026-10-28 21:48:47 +01:00
MasterAcnolo
0699656394 fix: autoUpdateYtDlp settings missing in IPC_Handlers 2026-10-28 21:47:42 +01:00
MasterAcnolo
fbe30f7ee2 feat: implement rate limits for downloads and info requests 2026-10-28 21:43:31 +01:00
MasterAcnolo
0da6df03cb feat(path): enhance path resolution for resources and user themes based on app context 2026-10-28 21:42:52 +01:00
MasterAcnolo
650696b9f5 feat(config): add default configuration path resolver and merge missing feature flags 2026-10-28 21:40:13 +01:00
MasterAcnolo
634108784e refactor(download): to cancel the download, we now retrieve the PID and execute the kill command depends on the OS. 2026-10-28 21:38:27 +01:00
MasterAcnolo
1b83272aaf feat: add auto-update option for yt-dlp in settings 2026-10-28 17:46:43 +01:00
MasterAcnolo
dc898e1235 feat: add UI toast notification for missing Firefox browser 2026-10-27 20:19:39 +01:00
MasterAcnolo
7c5ccb1527 build: optimize bundle size (only gain 1~2 MO, but it's still a weight reduction) 2026-10-26 20:43:00 +01:00
MasterAcnolo
f5259f7ade fix: server instance silent crash on close 2026-10-25 19:52:20 +01:00
MasterAcnolo
1fcf7cd988 docs: bump package.json app version to 1.6.5 2026-10-25 19:49:15 +01:00
MasterAcnolo
2e343cbd0c fix: .deb package format name. And remove "artifact" name from all configuration 2026-10-23 22:39:33 +02:00
Axel Nicolas
69de9eb9fc Merge pull request #58 from MasterAcnolo/1.6.4
1.6.4 Version
2026-10-07 20:35:34 +02:00
MasterAcnolo
cfa85649b3 docs: update changelog to reflect recent changes and improvements 2026-10-07 19:25:51 +02:00
MasterAcnolo
773ed9550d refactor: remove autoUpdate feature from configuration and UI 2026-10-07 19:24:47 +02:00
MasterAcnolo
ea3a475525 docs: add option to auto-display changelog after updates in configuration 2026-10-07 18:19:58 +02:00
MasterAcnolo
738d5ec62f fix: update README to clarify custom top bar feature and add cache directory information 2026-10-07 18:04:58 +02:00
MasterAcnolo
aff7637358 fix: remove experimental label from system tray option description 2026-10-07 17:22:44 +02:00
MasterAcnolo
7ee6d6fbe7 refactor: remove custom top bar feature from configuration and UI 2026-10-07 17:21:10 +02:00
MasterAcnolo
6aa3a39819 feat: options to not show changelog on app init 2026-10-07 10:25:29 +02:00
MasterAcnolo
f0971ebccc fix: adjust size for custom controls in topbar 2026-10-06 21:43:27 +02:00
MasterAcnolo
18c8621095 fix: update release script to reference new RELEASE-CHANGELOG.md and correct deb package name 2026-10-06 21:43:11 +02:00
MasterAcnolo
b2eb3b3b40 feat: implement changelog feature with UI modal and caching 2026-10-06 21:42:57 +02:00
MasterAcnolo
7af6898301 fix: moved CHANGELOD.md to root 2026-10-05 10:54:59 +02:00
MasterAcnolo
dc8d0a62d7 fix: enhance clean:dist and build:rpm npm scripts 2026-10-04 23:09:15 +02:00
MasterAcnolo
cf65069496 fix: extract .desktop inside a dedicated file and add npm run test:metainfo and test:desktop to check file.
also clean:dist
2026-10-02 14:52:30 +02:00
MasterAcnolo
a308352ea0 fix: update website links to new domain 2026-10-02 09:26:46 +02:00
MasterAcnolo
3712813833 fix: update desktop entry and metainfo filename 2026-10-02 09:15:19 +02:00
MasterAcnolo
9398329959 fix(release): correct AppImage filename format in release script 2026-09-29 22:14:28 +02:00
MasterAcnolo
8614b0e32e chore: remove dotenv dependency and update version to 1.6.4 2026-09-29 22:00:40 +02:00
MasterAcnolo
c212302550 chore: remove dotenv dependency and .env.example file 2026-09-29 21:59:51 +02:00
MasterAcnolo
0835e13840 refactor: reduce build bundle size by 50% 2026-09-29 21:57:54 +02:00
MasterAcnolo
1c0dd32732 feat(bugReport): add service status check and update UI if sendReport server is down 2026-09-29 21:30:20 +02:00
MasterAcnolo
1e099f6bb6 fix(sendReport): remove API key requirement for bug report service configuration 2026-09-29 17:27:16 +02:00
MasterAcnolo
1937738ab8 fix(autoUpdate): improve error handling and UI reset during update process 2026-09-29 17:20:42 +02:00
MasterAcnolo
ac1d74ff23 fix(autoUpdate): better error handling in frontEnd
#50
2026-09-27 21:19:48 +02:00
copilot-swe-agent[bot]
7f86d46ae7 fix: validate backend bug report configuration before request
Co-authored-by: MasterAcnolo <68693319+MasterAcnolo@users.noreply.github.com>
2026-09-27 18:31:59 +00:00
MasterAcnolo
187e3af559 fix: log transport names files local date 2026-09-27 20:03:25 +02:00
MasterAcnolo
e8c499fe23 fix: sendReport wrong IPC instance 2026-09-27 20:02:56 +02:00
MasterAcnolo
99b7164ff8 fix(update): don't check for update if the app is installed with SNAP or Flatpak 2026-09-27 16:23:28 +02:00
MasterAcnolo
d11c0f9839 add: .zed and OS Generated files to .gitignore, and better exclusion of IDE folder 2026-09-27 11:30:57 +02:00
MasterAcnolo
1e0b2affee update: bump app version to 1.6.4 since it's no longer a preview 2026-09-27 11:28:05 +02:00
MasterAcnolo
9975b2b583 refactor(update): add an UI indicator in frontend
disable OS native pop up menu and fix autoupdate crash on rpm and snap

#50
2026-09-25 21:08:18 +02:00
MasterAcnolo
b319a1e445 fix(ipc): send report IPC API 2026-09-16 22:16:59 +02:00
MasterAcnolo
4a64a40226 fix(front): settings menu transition 2026-09-16 20:17:42 +02:00
MasterAcnolo
2c0f5b315b refactor: toast notification style 2026-09-13 15:53:21 +02:00
MasterAcnolo
58b6637eeb feat: implement linter with config file 2026-09-13 12:06:33 +02:00
MasterAcnolo
8af5a4a383 update: bump app version to 1.6.4-preview 2026-09-13 11:56:03 +02:00
Axel Nicolas
23f04bafc3 Merge pull request #56 from MasterAcnolo/feature/send-logs
Feature/send logs
2026-09-13 11:54:43 +02:00
MasterAcnolo
35ffd7077e fix: onClick attribute on Report a Bug
(#53)
2026-09-13 11:44:37 +02:00
MasterAcnolo
408df0e2f6 feat: send logs feature that send logs to a distant service. Full integration in ui
+ expose logger method to frontEnd (#53)
2026-09-13 11:41:09 +02:00
MasterAcnolo
7dee94da6f refactor: extract CHANGELOG into a separated file. Should avoid characters issue 2026-09-11 11:01:12 +02:00
MasterAcnolo
cd43ae10fc fix: bump app version to 1.6.3 since it's no longer a preview 2026-09-10 21:36:33 +02:00
Axel Nicolas
6978277b4b Update codeql.yml 2026-09-10 15:13:02 +02:00
MasterAcnolo
b91c20d450 fix: release.sh release message ending 2026-09-10 15:09:54 +02:00
Axel Nicolas
014b1ad52a Update codeql.yml 2026-09-10 15:09:19 +02:00
Axel Nicolas
ec93d20a39 Create codeql.yml 2026-09-10 15:06:52 +02:00
MasterAcnolo
09c511fcb8 fix: docs in path.helpers 2026-09-10 15:01:50 +02:00
MasterAcnolo
f343fbd148 fix(discord-rpc): remove unusued code 2026-09-10 15:00:00 +02:00
Axel Nicolas
6d99d5f91c Update README.md 2026-09-10 13:55:09 +02:00
MasterAcnolo
0f0bb874bf feat: add logs when minimize and maximize (tray and not) 2026-09-10 12:01:07 +02:00
MasterAcnolo
b9267ec408 fix: enhance package.json with metadata used by electron-builder on Linux 2026-09-10 12:00:20 +02:00
MasterAcnolo
311bff6df5 refactor: systemTray window logic. Window reference is now dynamic and guarded against destroyed states. Minimize can now put window in the systemTray if option is active 2026-09-10 10:27:11 +02:00
MasterAcnolo
e4243a01ae fix: devMode logic 2026-09-10 10:25:03 +02:00
MasterAcnolo
52be9c6b4f update: electron from 43.3.0 to 44.3.0 2026-09-10 10:23:29 +02:00
MasterAcnolo
e541eb442a fix:
- replace unusued throw new Error by logger.error
- remove unusued variables isWindows, err
- remove unusued logFormat
- remove unusued imports logSessionEnd
2026-09-09 09:21:11 +02:00
MasterAcnolo
e2c2dde4a2 fix: README.md 2026-09-09 08:59:55 +02:00
MasterAcnolo
ed4022dfd1 fix: website link in README 2026-09-04 20:36:03 +02:00
MasterAcnolo
fcbc3f95ac fix: remove old and unusued icons 2026-09-03 20:56:04 +02:00
MasterAcnolo
f3293ef89a fix: stopServer message logic 2026-09-01 16:17:58 +02:00
MasterAcnolo
05e864b93a refactor: getBrowser function, and better firefox management on linux 2026-08-22 22:29:38 +02:00
MasterAcnolo
8ec901dc07 feat: centralize isPlaylistUrl function as a helper that could be upgraded in the future 2026-08-22 22:09:01 +02:00
MasterAcnolo
104500a82a fix: english miss spell 2026-08-21 20:20:12 +02:00
MasterAcnolo
3444f2fc0b Fix: default config in README that was not accurate 2026-08-20 19:39:21 +02:00
MasterAcnolo
86b595beff Fix: default config in README that was not accurate 2026-08-20 19:33:08 +02:00
MasterAcnolo
ed6efcf812 fix: toggle to false the createPlaylistFolder feature 2026-08-19 19:44:56 +02:00
MasterAcnolo
2c84ac7a70 fix: metadata comment displayed version 2026-08-19 17:31:36 +02:00
Axel Nicolas
3c649173a2 Update CONTRIBUTING.md 2026-08-16 10:56:54 +02:00
Axel Nicolas
b4d4ff2665 Merge pull request #47 from MasterAcnolo/refactor-back
Documentation Enhancement
2026-08-16 10:54:30 +02:00
MasterAcnolo
c2584b6df3 fix: remove SUPPORT section 2026-08-16 10:51:26 +02:00
MasterAcnolo
ec16b5209d fix: reduce size of package.json 2026-08-16 10:50:12 +02:00
MasterAcnolo
f2f1649493 add: small notes about build:all script 2026-08-16 10:50:01 +02:00
MasterAcnolo
e5076b0e0b remove: project structure from README 2026-08-16 10:37:42 +02:00
MasterAcnolo
4573917541 add: DEVELOPMENT.md file and refactor contributing and README 2026-08-16 10:36:36 +02:00
MasterAcnolo
62c109939a fix: bump version to 1.6.3-preview and add a check in release.sh, we could'nt publish a release if it's still marked as a preview 2026-08-16 10:03:27 +02:00
MasterAcnolo
b56ec1e62e refactor: some tweaks in .github md templates 2026-08-16 09:58:32 +02:00
MasterAcnolo
1a68c58ee3 refactor: extract shutdown logic in a function, rename server => serverInstance and now we stop the server smoothly 2026-08-16 09:47:25 +02:00
MasterAcnolo
39c4a69be1 add: notes in BINARIES.md about yt-dlp 2026-08-16 09:06:35 +02:00
70 changed files with 4797 additions and 982 deletions

View File

@@ -29,6 +29,9 @@ Clear description of the performance issue.
- **Storage**: [e.g. SSD/HDD] - **Storage**: [e.g. SSD/HDD]
- **Other Applications Opened**: [e.g. Discord/Opera GX] - **Other Applications Opened**: [e.g. Discord/Opera GX]
> [!INFO]
> At the start of the application, SystemInfo is printed in the logs, so you can just copy and paste it!
## Reproduction Details ## Reproduction Details
- **Download URL**: [if applicable] - **Download URL**: [if applicable]

View File

@@ -9,8 +9,8 @@ assignees: 'MasterAcnolo'
## Pre-submission Checklist ## Pre-submission Checklist
- [ ] I have checked the [FAQ](https://masteracnolo.github.io/Freedom-Loader-Site/pages/faq) - [ ] I have checked the [FAQ](https://freedomloader.acnolo.fr/#/faq)
- [ ] I have reviewed the [Wiki](https://masteracnolo.github.io/Freedom-Loader-Site/pages/wiki) - [ ] I have reviewed the [Wiki](https://freedomloader.acnolo.fr/#/wiki)
- [ ] I have searched existing issues - [ ] I have searched existing issues
## Question ## Question

View File

@@ -2,7 +2,7 @@
Brief description of what this PR does. Brief description of what this PR does.
## Related Issue ## Related Issue (if applicable)
Fixes #(issue number) Fixes #(issue number)
Closes #(issue number) Closes #(issue number)
@@ -30,7 +30,7 @@ Related to #(issue number)
- [ ] I have tested with different download scenarios (video/audio, playlists) - [ ] I have tested with different download scenarios (video/audio, playlists)
- [ ] I have checked the application logs for errors - [ ] I have checked the application logs for errors
- [ ] I have verified the UI changes (if applicable) - [ ] I have verified the UI changes (if applicable)
- [ ] I have tested on Windows 10 and/or Windows 11 - [ ] I have tested on Windows 10 and/or Windows 11 or in any Linux Distribution
## Screenshots (if applicable) ## Screenshots (if applicable)

46
.github/workflows/codeql.yml vendored Normal file
View File

@@ -0,0 +1,46 @@
name: "CodeQL PR"
on:
pull_request:
branches: [ "main" ]
schedule:
- cron: '00 00 * * 1'
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
permissions:
security-events: write
packages: read
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: javascript-typescript
build-mode: none
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
- name: Run manual build steps
if: matrix.build-mode == 'manual'
shell: bash
run: |
echo 'Manual build required'
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
with:
category: "/language:${{matrix.language}}"

13
.gitignore vendored
View File

@@ -6,12 +6,21 @@
/out /out
/srpm-out /srpm-out
# .env
*.env
# Config JSON File. Create it and fill it if you want to have dev only settings # Config JSON File. Create it and fill it if you want to have dev only settings
config/config.dev.json config/config.dev.json
# IDE # IDE
/.idea .idea/
/.vscode .vscode/
.zed/
# OS generated files
.DS_Store
.DS_Store?
Thumbs.db
# Linux executable # Linux executable
ffmpeg ffmpeg

View File

@@ -94,3 +94,4 @@ chmod +x resources/binaries/linux/deno
- These binaries are excluded from version control via `.gitignore`. - These binaries are excluded from version control via `.gitignore`.
- Always use the latest stable release of each tool. - Always use the latest stable release of each tool.
- **Yt-dlp** binaries don't need to be updated manually. At every startup of the application, the binary tries to update itself

View File

@@ -6,6 +6,7 @@ Welcome! Thanks for your interest in Freedom Loader. Your contributions—bug re
## Quick Links ## Quick Links
- [Development Guide](DEVELOPMENT.md)
- [Code of Conduct](CODE_OF_CONDUCT.md) - [Code of Conduct](CODE_OF_CONDUCT.md)
- [Issue Templates](.github/ISSUE_TEMPLATE/) - [Issue Templates](.github/ISSUE_TEMPLATE/)
- [Pull Request Templates](.github/PULL_REQUEST_TEMPLATE/) - [Pull Request Templates](.github/PULL_REQUEST_TEMPLATE/)
@@ -23,7 +24,7 @@ Found a bug or unexpected behavior? Open an [**issue**](https://github.com/Maste
- Clear description of the bug - Clear description of the bug
- Steps to reproduce - Steps to reproduce
- App version and environment (Windows version, Firefox version if relevant) - App version and environment (Windows version, Firefox version if relevant)
- Logs from: `C:\Users\[USERNAME]\AppData\Local\FreedomLoader\logs\LOGS-YYYY-MM-DD.log` - Logs from: `C:\Users\[USERNAME]\AppData\Local\FreedomLoader\logs\LOGS-YYYY-MM-DD.log` (Windows) or `~/.local/share/FreedomLoader/logs/` (Linux)
- Screenshots if applicable - Screenshots if applicable
### 2. Request a Feature ### 2. Request a Feature
@@ -43,13 +44,11 @@ Have an idea to improve Freedom Loader? Open a [**Feature Request**](https://git
- Follow the project's code style and conventions - Follow the project's code style and conventions
**Process:** **Process:**
1. Fork the repository 1. Fork the repository.
2. Create a feature branch: `git checkout -b feature/your-feature-name` 2. Check out our [**Developer Guide (DEVELOPMENT.md)**](./DEVELOPMENT.md) to set up your environment.
3. Make your changes 3. Create a feature branch following our branching conventions.
4. Test thoroughly on Windows 10/11 4. Make your changes and test thoroughly on Windows and/or Linux.
5. Commit with clear messages: `git commit -m "Add feature X"` 5. Open a Pull Request using the appropriate template.
6. Push to your fork: `git push origin feature/your-feature-name`
7. Open a Pull Request using the appropriate template
**Code Guidelines:** **Code Guidelines:**
- Use camelCase for variables and functions - Use camelCase for variables and functions
@@ -72,41 +71,9 @@ Small contributions matter—don't hesitate to submit documentation PRs.
## Development Setup ## Development Setup
### Prerequisites Want to write some code? Awesome!
- Node.js 16.x or higher
- npm or yarn
- Git
- Windows 10/11 (for testing)
### Setup Please read our [**Developer Guide (DEVELOPMENT.md)**](./DEVELOPMENT.md) for instructions on how to set up your local environment, our project architecture, and our Git branching rules.
```bash
# Clone your fork
git clone https://github.com/YOUR-USERNAME/Freedom-Loader.git
cd Freedom-Loader
# Install dependencies
npm install
# Run in development mode
npm start
# Build for production
npm run build
```
### Project Structure
```
Freedom-Loader/
├── main.js # Electron main process
├── preload.js # Electron preload script
├── config.js # Global configuration
├── server/ # Express backend
│ ├── routes/ # API routes
│ ├── controller/ # Business logic
│ └── helpers/ # Utility functions
├── public/ # Frontend (HTML, CSS, JS)
└── resources/ # Binaries (yt-dlp, ffmpeg, etc.)
```
--- ---
@@ -114,12 +81,9 @@ Freedom-Loader/
Before submitting a PR, verify: Before submitting a PR, verify:
- [ ] Download functionality works (video/audio) - [ ] Download functionality works (video/audio)
- [ ] Playlist downloads work
- [ ] Settings panel functions correctly
- [ ] Custom path selection works
- [ ] No errors in application logs - [ ] No errors in application logs
- [ ] UI changes work on all themes (if applicable) - [ ] Any features wasn't break during the development
- [ ] Tested on Windows 10 and/or Windows 11 - [ ] Tested on Windows 10/11 and/or any Linux Distribution
--- ---
@@ -148,8 +112,8 @@ See [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) for details.
## Questions? ## Questions?
- Check the [FAQ](https://masteracnolo.github.io/Freedom-Loader-Site/faq) - Check the [FAQ](https://freedomloader.acnolo.fr/faq)
- Review the [Wiki](https://masteracnolo.github.io/Freedom-Loader-Site/wiki) - Review the [Wiki](https://freedomloader.acnolo.fr/wiki)
- Open a [Question issue](https://github.com/MasterAcnolo/Freedom-Loader/issues/new/choose) - Open a [Question issue](https://github.com/MasterAcnolo/Freedom-Loader/issues/new/choose)
--- ---

388
DEVELOPMENT.md Normal file
View File

@@ -0,0 +1,388 @@
# Freedom Loader - Development Guide
Welcome to the Developer Guide! This document explains how to set up your local environment, project architecture, Git workflow, and the release process.
## Prerequisites
Before you begin, ensure you have the following installed on your machine:
- **Node.js** (v22 or higher recommended)
- **npm** (comes with Node.js)
- **Git**
- **yt-dlp binaries**: Freedom Loader requires native binaries to function. Read [BINARIES.md](./BINARIES.md) for download instructions and placement in `resources/binaries/`.
## Setup Instructions
### 1. Clone the repository
```bash
git clone https://github.com/MasterAcnolo/Freedom-Loader.git
cd Freedom-Loader
```
### 2. Install dependencies
```bash
npm install
```
### 3. Install the Binaries
Follow the instructions in `BINARIES.md` to place `yt-dlp`, `ffmpeg`, `ffprobe`, and `deno` in the correct `resources/binaries/` folder for your OS.
### 4. Run in Development Mode
```bash
npm start
```
**Note**: Development mode automatically enables detailed colored logs and the DevTron extension for debugging.
---
## Available Scripts
### Development
- `npm start` — Start the app in development mode (no warnings)
- `npm run start:warn` — Start with deprecation warnings visible
- `npm run start:debug` — Start with full debug logging (Wayland, Electron logging, stack dumps)
- `npm run dev` — Start with auto-reload on file changes (watches server/, app/, main.js, config.js)
- `npm run dev:warn` — Dev mode with warnings
- `npm run dev:debug` — Dev mode with full debug output and auto-reload
### Building
- `npm run build:linux` — Build Linux packages (AppImage, deb, snap)
- `npm run build:win` — Build Windows installer (.exe)
- `npm run build:rpm` — Build RPM and SRPM for COPR (Fedora)
- `npm run build:all` — Build both Windows and Linux (not recommended because it adds both OS binaries into a single package. The output file is 2x bigger than expected).
### Testing
- `npm test` — Run all tests (unit + integration)
- `npm run test:unit` — Run unit tests only
### Release Pipeline
- `npm run release` — Build all packages and create a draft GitHub release (no store publishing)
- `npm run release:publish` — Build all packages and publish to Snap Store + COPR
- `npm run release:dry-run` — Simulate the full release pipeline without publishing anything
### Maintenance
- `npm update` — Update npm dependencies
---
## Project Architecture
Freedom Loader is built with **Electron** (frontend) and **Node.js + Express** (backend), using a modular structure:
---
## Git & Branching Workflow
I use **Trunk-Based Development** (GitHub Flow):
### Core Principles
- **`main` is always deployable**: The `main` branch contains the latest stable code and must never be broken.
- **No long-lived branches**: I no longer maintain version branches like `v1.6` or `v1.7`.
- **Pull Requests for review**: All code changes go through PR review before merging to `main`.
### How to Contribute
#### 1. Create a feature branch
```bash
git checkout -b feat/add-new-button
# or for fixes:
git checkout -b fix/ui-bug
```
**Branch naming convention**:
- `feat/` — New features
- `fix/` — Bug fixes
- `refactor/` — Code cleanup (no behavior change)
- `docs/` — Documentation updates
- `chore/` — Dependency updates, build config, etc.
#### 2. Make commits
```bash
# Make changes
git add .
git commit -m "Brief, imperative description of the change"
```
**Commit message tips**:
- Use imperative mood: "Add theme caching" not "Added theme caching"
- Keep commits logical and focused (one feature per commit if possible)
- Link to issues if relevant: "Fix crash on download (fixes #42)"
#### 3. Push and open a PR
```bash
git push origin feat/add-new-button
```
Then open a PR on GitHub against `main`.
#### 4. Review and merge
- Address feedback in new commits (don't rebase — easier to review)
- Once approved, merge via GitHub (use "Squash and merge" for clean history if many small commits and you find it relevant)
---
## Release Process
**Note**: Only project maintainers release to production. This section documents the process for transparency and for future maintainers.
Releases are fully automated via the `release.sh` script. The process handles building, packaging, and publishing to **all distribution channels**:
- **GitHub Releases** (Windows .exe, Linux packages)
- **Fedora COPR** (automatic RPM builds for Fedora 43+)
- **Snap Store** (universal Linux)
### Before You Release
1. **Ensure `main` is green**: All tests pass, features are stable.
2. **Update `package.json` version**:
```json
"version": "1.6.1"
```
3. **Commit the version bump**: `git add package.json && git commit -m "chore: v1.6.1"`
4. **Push to `main`**: `git push origin main`
### Step 1: Local Build & Draft Release
```bash
npm run release
```
This:
1. Builds Linux packages (AppImage, deb, snap)
2. Builds RPM and SRPM for COPR
3. Builds Windows installer
4. Creates a draft GitHub release
### Step 2: Edit & Publish on GitHub
1. Go to https://github.com/MasterAcnolo/Freedom-Loader/releases
2. Edit the draft release — fill in changelog
3. Click **"Publish"** to make it live
### Step 3: Publish to Distribution Channels
```bash
npm run release:publish
```
This automatically:
- **Uploads to Snap Store**: Makes the app available via `snap install freedom-loader`
- **Submits to Fedora COPR**: Builds and publishes RPMs for Fedora 43+ (users can `dnf install freedom-loader` from the COPR repo)
Both happen in parallel — users across all platforms get the release simultaneously.
---
## Linux Development Considerations
### Cross-Distribution Compatibility
Freedom Loader targets **Debian-based** (Ubuntu, Debian) and **Fedora-based** (Fedora, RHEL, openSUSE) distributions. When developing features, keep this in mind:
#### What to test
- **Feature works on Fedora 44+** (primary Linux development environment)
- **Feature works on Ubuntu/Debian** (via deb package or AppImage)
- **Feature doesn't break Snap confinement** (Snap has restricted filesystem/IPC access)
- **Feature gracefully degrades on missing system dependencies**
#### Desktop Environment (DE) Compatibility
Test on at least **GNOME** and **KDE** (the most common DEs). Common pain points:
- **Themes**: May render differently on KDE vs GNOME — test both if possible
- **File dialogs**: Some DEs use native file pickers, others fall back to Electron's
- **Notifications**: System notification APIs vary (D-Bus, libnotify)
- **Tray icons**: May not work identically across DEs
#### How to check locally
```bash
# Test on Fedora (if available)
npm run build:linux
sudo dnf install dist/freedom-loader-*.x86_64.rpm
freedom-loader
# Test AppImage (works on any distro)
chmod +x dist/Freedom\ Loader-*.AppImage
./dist/Freedom\ Loader-*.AppImage
```
### CI/CD Gap
**Currently**: No automated testing across distros or DEs. Releases rely on manual testing before publish.
**Future improvement**: Automated tests via GitHub Actions (Ubuntu) + local testing on Fedora would catch cross-distro issues early. This is planned but not yet implemented.
For now, if you fix a Linux-specific bug or add a DE-dependent feature, **please mention it in your PR description** so reviewers can test extra carefully.
---
## Testing
### Unit Tests
Test individual functions in isolation (no Electron required).
```bash
npm run test:unit
```
Covered:
(Tests will come soon)
### End-to-End Tests (Playwright)
Test the actual Electron app launching and basic UI interactions.
```bash
npm run test
```
Covered:
(Tests will come soon)
### Running All Tests
```bash
npm test
```
Or include tests before release:
```bash
npm test && npm run release
```
---
## Common Tasks
### Add a new feature
1. Create a feature branch: `git checkout -b feat/my-feature`
2. Make changes, commit: `git add . && git commit -m "feat: my feature"`
3. Push: `git push origin feat/my-feature`
4. Open a PR on GitHub
5. Once approved, merge to `main`
6. (Later) Cut a release when ready: `npm run release`
### Fix a bug
Same as above, but use `fix/bug-name` branch and `git commit -m "fix: description"`.
### Test the app before releasing
```bash
npm run dev # Auto-reload on file changes
# Make changes, test in the UI
npm test # Run all tests
npm run release:dry-run # Simulate release (builds but doesn't publish)
```
### Update dependencies
```bash
npm update
git add package.json package-lock.json
git commit -m "chore: update dependencies"
git push origin main
```
### Rebuild only Linux (after quick fixes)
```bash
npm run build:linux
# Or just the RPM:
npm run build:rpm
```
### Publish to stores manually
If `npm run release:publish` fails partway:
```bash
# Just Snap:
snapcraft upload dist/freedom-loader_*.snap --release=stable
# Just COPR:
copr-cli build freedom-loader srpm-out/*.src.rpm
```
---
## Troubleshooting
### App won't start in dev mode
```bash
npm run start:debug
```
Check the debug output for errors. Most common:
- Missing binaries in `resources/binaries/`
- Port 8787 already in use
- A dead instance is still running — open Task Manager and kill the "Freedom Loader" process
### (Linux) Binaries in the right place but app still won't launch
You probably forgot to make them executable:
```bash
chmod +x resources/binaries/linux/*
```
The app will fail silently if binaries lack execute permissions.
### Build fails with "permission denied"
Make sure you have write access to `dist/` and `srpm-out/`:
```bash
chmod -R u+w dist srpm-out
npm run build:linux
```
### Tests fail
Check for:
- Missing test files in `tests/unit/`
- Node modules out of sync: `rm -rf node_modules && npm install`
### GitHub release publish fails
Verify `gh` CLI is installed and authenticated:
```bash
gh auth login
gh release list
```
---
## Questions?
For more info:
- **Electron docs**: https://www.electronjs.org/docs
- **electron-builder**: https://www.electron.build/
- **This repo**: https://github.com/MasterAcnolo/Freedom-Loader
Happy coding! 🎉

208
README.md
View File

@@ -1,6 +1,6 @@
<div align="center"> <div align="center">
<a href="https://masteracnolo.github.io/Freedom-Loader-Site/"><img <a href="https://freedomloader.acnolo.fr/"><img
src="./build/banner.png" src="./build/banner.png"
alt="Banner" alt="Banner"
style="width: 50%;"/></a> style="width: 50%;"/></a>
@@ -9,14 +9,12 @@
[![Release](https://img.shields.io/github/v/release/MasterAcnolo/Freedom-Loader?style=for-the-badge&color=blue)](https://github.com/MasterAcnolo/Freedom-Loader/releases) [![Release](https://img.shields.io/github/v/release/MasterAcnolo/Freedom-Loader?style=for-the-badge&color=blue)](https://github.com/MasterAcnolo/Freedom-Loader/releases)
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-red.svg?style=for-the-badge)](https://www.gnu.org/licenses/gpl-3.0) [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-red.svg?style=for-the-badge)](https://www.gnu.org/licenses/gpl-3.0)
[![Website](https://img.shields.io/badge/Website-Visit-404040?style=for-the-badge)](https://masteracnolo.github.io/Freedom-Loader-Site/) [![Website](https://img.shields.io/badge/Website-Visit-404040?style=for-the-badge)](https://freedomloader.acnolo.fr/)
[![Workshop](https://img.shields.io/badge/Worshop-Visit-0E05A1?style=for-the-badge)](https://masteracnolo.github.io/Freedom-Loader-Workshop/) [![Workshop](https://img.shields.io/badge/Worshop-Visit-0E05A1?style=for-the-badge)](https://masteracnolo.github.io/Freedom-Loader-Workshop/)
<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> <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>
</div> </div>
---
## Overview ## Overview
Freedom Loader is a desktop application built with Electron that provides a straightforward way to download video and audio content with full metadata support. The application focuses on reliability, transparency, and user privacy-no ads, no tracking, no questionable third-party services. Freedom Loader is a desktop application built with Electron that provides a straightforward way to download video and audio content with full metadata support. The application focuses on reliability, transparency, and user privacy-no ads, no tracking, no questionable third-party services.
@@ -25,7 +23,7 @@ The primary goal is to make media downloading accessible to users who want offli
### See related repo: ### See related repo:
- **[Website](https://masteracnolo.github.io/Freedom-Loader-Site/)** - Official project website and documentation - **[Website](https://github.com/MasterAcnolo/Freedom-Loader-Site/)** - Official project website and documentation
- **[Workshop](https://github.com/MasterAcnolo/Freedom-Loader-Workshop)** - Theme creation tool and theme browser - **[Workshop](https://github.com/MasterAcnolo/Freedom-Loader-Workshop)** - Theme creation tool and theme browser
## Table of Contents ## Table of Contents
@@ -35,14 +33,12 @@ The primary goal is to make media downloading accessible to users who want offli
- [Usage](#usage) - [Usage](#usage)
- [Preview](#preview) - [Preview](#preview)
- [Configuration](#configuration) - [Configuration](#configuration)
- [Project Structure](#project-structure)
- [Theme Workshop](#Theme-Workshop) - [Theme Workshop](#Theme-Workshop)
- [Technology Stack](#technology-stack) - [Technology Stack](#technology-stack)
- [Development](#development) - [Development](#development)
- [Roadmap](#roadmap) - [Roadmap](#roadmap)
- [Contributing](#contributing) - [Contributing](#contributing)
- [Submitting a community theme](#Submitting-a-community-theme) - [Submitting a community theme](#Submitting-a-community-theme)
- [Support](#support)
- [License](#license) - [License](#license)
## Features ## Features
@@ -60,7 +56,7 @@ The primary goal is to make media downloading accessible to users who want offli
### User Interface ### 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 - **Custom themes** - ZIP-based theme system with live preview
- **Theme Workshop** - web-based theme creator and browser - **Theme Workshop** - web-based theme creator and browser
- **Toast notifications** - in-app feedback system - **Toast notifications** - in-app feedback system
@@ -155,6 +151,7 @@ freedom-loader
|---------------|---------------------------------------------------------|--------------------------------------| |---------------|---------------------------------------------------------|--------------------------------------|
| **Downloads** | `C:\Users\[USERNAME]\Downloads\Freedom Loader` | `~/Downloads/Freedom Loader` | | **Downloads** | `C:\Users\[USERNAME]\Downloads\Freedom Loader` | `~/Downloads/Freedom Loader` |
| **Logs** | `C:\Users\[USERNAME]\AppData\Local\FreedomLoader\logs\` | `~/.local/share/FreedomLoader/logs/` | | **Logs** | `C:\Users\[USERNAME]\AppData\Local\FreedomLoader\logs\` | `~/.local/share/FreedomLoader/logs/` |
| **Cache** | ``
> [!NOTE] > [!NOTE]
> The download folder can be changed anytime via the "Edit" button in the UI, on both platforms. > The download folder can be changed anytime via the "Edit" button in the UI, on both platforms.
@@ -213,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. 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 ### Configuration File Location
#### Windows #### Windows
@@ -228,14 +228,13 @@ Freedom Loader can be configured either through the settings panel in the UI or
### Available Options ### Available Options
```json ```json
{ {
"autoUpdate": true,
"systemTray": true, "systemTray": true,
"discordRPC": true, "discordRPC": true,
"customTopBar": true,
"autoCheckInfo": true, "autoCheckInfo": true,
"addThumbnail": true, "addThumbnail": true,
"addMetadata": true, "addMetadata": true,
"verboseLogs": false, "verboseLogs": false,
"autoUpdateYtDlp": true,
"autoDownloadPlaylist": true, "autoDownloadPlaylist": true,
"keepPlaylistOrder": false, "keepPlaylistOrder": false,
"createPlaylistFolders": true, "createPlaylistFolders": true,
@@ -245,6 +244,7 @@ Freedom Loader can be configured either through the settings panel in the UI or
"downloadSystem": true, "downloadSystem": true,
"notifySystem": true, "notifySystem": true,
"enableHardwareAcceleration": true, "enableHardwareAcceleration": true,
"showChanglogsAfterUpdate": true,
"theme": "dark" "theme": "dark"
} }
``` ```
@@ -253,23 +253,23 @@ Freedom Loader can be configured either through the settings panel in the UI or
| Option | Type | Default | Description | | Option | Type | Default | Description |
|------------------------------|---------|----------|------------------------------------------------------------| |------------------------------|---------|----------|------------------------------------------------------------|
| `autoUpdate` | boolean | `true` | Enable automatic application updates | | `systemTray` | boolean | `false` | Allow app to minimize on close |
| `systemTray` | boolean | `true` | Allow app to minimize on close [EXPERIMENTAL] |
| `discordRPC` | boolean | `true` | Enable Discord Rich Presence integration | | `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 | | `autoCheckInfo` | boolean | `true` | Automatically fetch video information on URL paste |
| `addThumbnail` | boolean | `true` | Embed thumbnail in downloaded audio files | | `addThumbnail` | boolean | `true` | Embed thumbnail in downloaded audio files |
| `addMetadata` | boolean | `true` | Add metadata tags to downloaded files | | `addMetadata` | boolean | `true` | Add metadata tags to downloaded files |
| `verboseLogs` | boolean | `false` | Enable detailed logging for debugging | | `verboseLogs` | boolean | `false` | Enable detailed logging for debugging |
| `autoUpdateYtDlp` | boolean | `true` | Automatically update yt-dlp when the app starts |
| `autoDownloadPlaylist` | boolean | `true` | Automatically download entire playlists | | `autoDownloadPlaylist` | boolean | `true` | Automatically download entire playlists |
| `keepPlaylistOrder` | boolean | `false` | Add index before title, usefull for album | | `keepPlaylistOrder` | boolean | `false` | Add index before title, usefull for album |
| `createPlaylistFolders` | boolean | `true` | Automatically create a folder for a playlist | | `createPlaylistFolders` | boolean | `false` | Automatically create a folder for a playlist |
| `customCodec` | string | `"h264"` | Video codec for encoding (supported: h264, h265, vp9, av1) | | `customCodec` | string | `"h264"` | Video codec for encoding (supported: h264, h265, vp9, av1) |
| `logSystem` | boolean | `true` | Enable application logging | | `logSystem` | boolean | `true` | Enable application logging |
| `outputTitleCheck` | boolean | `true` | Verify output file titles | | `outputTitleCheck` | boolean | `true` | Verify output file titles |
| `downloadSystem` | boolean | `true` | Enable download system | | `downloadSystem` | boolean | `true` | Enable download system |
| `notifySystem` | boolean | `true` | Enable system notifications | | `notifySystem` | boolean | `true` | Enable system notifications |
| `enableHardwareAcceleration` | boolean | `true` | Enable Hardware Acceleration | | `enableHardwareAcceleration` | boolean | `true` | Enable Hardware Acceleration |
| `showChanglogsAfterUpdate` | boolean | `true` | Auto-display changelog popup after and update |
| `theme` | string | `dark` | Current Theme Name | | `theme` | string | `dark` | Current Theme Name |
### Supported Codecs ### Supported Codecs
@@ -285,89 +285,6 @@ Freedom Loader can be configured either through the settings panel in the UI or
> [!NOTE] > [!NOTE]
> Some configuration changes may require an application restart to take effect. > Some configuration changes may require an application restart to take effect.
## Project Structure
```
Freedom-Loader/
├── app/ # Electron main process modules
│ ├── autoUpdater.js
│ ├── dependencyCheck.js
│ ├── discordRPC.js
│ ├── ipcHandlers.js
│ ├── pathValidator.js
│ ├── splashManager.js
│ ├── themeManager.js
│ ├── tray.js
│ ├── windowManager.js
│ └── ytDlpUpdater.js
├── build/ # Build resources and assets
│ ├── app-icon.ico
│ ├── app-icon.png
│ ├── banner.png
│ └── ...
├── config/ # Configuration files
│ ├── config.default.json
│ └── config.dev.json # Configuration Used when i dev mode
├── dist/ # Compiled executables (.exe, .AppImage, .deb, .rpm, .snap)
├── public/ # Frontend assets and UI
│ ├── index.html
│ ├── splash.html # Splash Screen (Start of the application)
│ ├── assets/
│ │ ├── icon/ # Application icons
│ │ └── logo/ # Logo assets
│ ├── script/ # Frontend JavaScript modules
│ │ ├── appVersion.js
│ │ ├── clipboardPaste.js
│ │ ├── custompath.js
│ │ ├── customthemes.js
│ │ ├── downloadstatus.js
│ │ ├── fetchinfo.js
│ │ ├── progressBar.js
│ │ ├── settingsPanel.js
│ │ ├── toast.js
│ │ └── topbar.js
│ └── styles/ # CSS stylesheets
│ ├── styles.css
│ ├── variables.css
│ ├── components/ # Component-specific styles
│ └── layout/ # Layout styles
├── resources/ # Internal resources (binaries : yt-dlp, ffmpeg...)
├── server/ # Express backend server
│ ├── logger.js
│ ├── server.js
│ ├── controller/ # Request handlers
│ │ ├── download.controller.js
│ │ └── info.controller.js
│ ├── helpers/ # Utility functions
│ │ ├── buildArgs.helpers.js
│ │ ├── getBrowser.helpers.js
│ │ ├── notify.helpers.js
│ │ ├── parseInfo.helpers.js
│ │ ├── path.helpers.js
│ │ ├── rateLimit.helpers.js
│ │ └── validation.helpers.js
│ ├── routes/ # API route definitions
│ │ ├── download.route.js
│ │ └── info.route.js
│ └── services/ # Business logic layer
│ ├── download.services.js
│ └── info.services.js
├── theme/ # Theme system
│ ├── template.theme.json # Theme template
│ ├── Dark/ # Default dark theme
│ └── Light/ # Default light theme
├── BINARIES.md # External dependencies & binaries documentation
├── CODE_OF_CONDUCT.md # Community guidelines
├── config.js # Global configuration loader
├── CONTRIBUTING.md # Contribution guidelines
├── jest.config.js # Unit testing configuration
├── LICENSE # GPLv3 license
├── main.js # Electron main process entry point
├── package.json # Project metadata and dependencies
├── preload.js # Electron preload script (IPC bridge)
└── README.md # This file
```
### Architecture Overview ### Architecture Overview
Freedom Loader uses a client-server architecture within a single Electron application: Freedom Loader uses a client-server architecture within a single Electron application:
@@ -414,28 +331,7 @@ Freedom Loader includes a web-based theme creator available at [Freedom Loader W
## Development ## Development
### Prerequisites If you are interested by running a local non compiled version of Freedom Loader or just interested by contributing, see [DEVELOPMENT](./DEVELOPMENT.md)
- Node.js 16.x or higher
- npm or yarn
- Git
### Setup
```bash
# Clone the repository
git clone https://github.com/MasterAcnolo/Freedom-Loader.git
cd Freedom-Loader
# Install dependencies
npm install
# Run in development mode
npm start
# Build for production
npm run build
```
### Additional Dependencies ### Additional Dependencies
@@ -446,44 +342,23 @@ You must download the required binaries and place them in the `resources` folder
#### Required binaries #### Required binaries
- **Deno** See [BINARIES.md](./BINARIES.md) for how to setup the binaries.
- Download from: https://sourceforge.net/projects/deno.mirror/files/latest/download
- Rename to: `deno.exe`
- **FFmpeg**
- Download from: https://www.ffmpeg.org/download.html
- Required files:
- `ffmpeg.exe`
- `ffprobe.exe`
- **yt-dlp**
- Already bundled with the project
- No manual installation required
Final folder structure:
```
resources/
├── deno.exe
├── ffmpeg.exe
├── ffprobe.exe
└── yt-dlp.exe
```
> These binaries are required for the application to start correctly.
> If any of them are missing, an error message will be displayed at application startup.
### Development Guidelines ### Development Guidelines
- Follow existing code style and conventions - Follow existing code style and conventions
- Write clear commit messages - Write clear, imperative commit messages (e.g., "Add playlist indexing" not "Added")
- Test thoroughly before submitting changes - Test thoroughly on both Windows and Linux before submitting changes
- Update documentation when adding features - Update documentation (README, DEVELOPMENT.md) when adding features
- Maintain compatibility with Windows 10+ and Linux - Maintain compatibility with:
- Windows 10+
- Fedora 43+ / Debian 11+ / Ubuntu 20.04+
- GNOME and KDE desktop environments (when possible)
- Consider Linux distribution differences early (see [DEVELOPMENT.md](./DEVELOPMENT.md#linux-development-considerations))
## Roadmap ## 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 ## Contributing
@@ -496,13 +371,29 @@ Head over to the [Freedom Loader Workshop repository](https://github.com/MasterA
### Bug Reports ### 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 - Clear description of the issue
- Steps to reproduce - Steps to reproduce
- Expected vs actual behavior - Expected vs actual behavior
- Relevant logs from `AppData\Local\FreedomLoader\logs\` or `~/.local/share/FreedomLoader/logs/` - Relevant logs from `AppData\Local\FreedomLoader\logs\` or `~/.local/share/FreedomLoader/logs/`
- Screenshots if applicable - Screenshots or recordings if applicable
### Feature Requests ### Feature Requests
@@ -521,15 +412,7 @@ Open a feature request issue with:
5. Update documentation as needed 5. Update documentation as needed
6. Submit a pull request with a detailed description 6. Submit a pull request with a detailed description
Please read [CONTRIBUTING.md](CONTRIBUTING.md) and [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) before contributing. Please read [CONTRIBUTING.md](CONTRIBUTING.md), [DEVELOPMENT.md](./DEVELOPMENT.md) and [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) before contributing.
## Support
If you find this project useful and want to support its development:
[![PayPal](https://img.shields.io/badge/PayPal-00457C?style=for-the-badge&logo=paypal&logoColor=white)](https://paypal.me/axelnicolas25)
Your support helps maintain infrastructure, develop new features, and keep the project free and ad-free.
## License ## License
@@ -547,7 +430,6 @@ You are free to use, modify, and redistribute this software under the terms of t
- [@SpicyFire21](https://github.com/SpicyFire21) to be the spiciest one - [@SpicyFire21](https://github.com/SpicyFire21) to be the spiciest one
- [@AureCX](https://github.com/AureCX) for being the Official Windows Tester! - [@AureCX](https://github.com/AureCX) for being the Official Windows Tester!
- Zakaria for the website icon - Zakaria for the website icon
- Jacques Chirac to love Apples
- All users who test, report issues, and help improve the application - All users who test, report issues, and help improve the application
--- ---
@@ -556,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 **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> </div>

View File

@@ -9,7 +9,6 @@
*/ */
const { autoUpdater } = require("electron-updater"); const { autoUpdater } = require("electron-updater");
const { dialog } = require("electron");
const { logger } = require("../server/logger"); const { logger } = require("../server/logger");
/** /**
@@ -21,6 +20,26 @@ const { logger } = require("../server/logger");
autoUpdater.autoDownload = false; autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = 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. * Initializes application auto-update lifecycle.
* *
@@ -32,7 +51,6 @@ autoUpdater.autoInstallOnAppQuit = false;
* @param {BrowserWindow} mainWindow - main Electron window instance * @param {BrowserWindow} mainWindow - main Electron window instance
*/ */
function initAutoUpdater(mainWindow) { function initAutoUpdater(mainWindow) {
/** /**
* Triggered when a new version is detected. * Triggered when a new version is detected.
* Prompts user to install or defer update. * Prompts user to install or defer update.
@@ -40,23 +58,12 @@ function initAutoUpdater(mainWindow) {
autoUpdater.on("update-available", async (info) => { autoUpdater.on("update-available", async (info) => {
logger.info(`Update available: ${info.version}`); logger.info(`Update available: ${info.version}`);
const { response } = await dialog.showMessageBox(mainWindow, { mainWindow?.webContents.send("update-available", {
type: "info", version: info.version,
title: "Update Available", environment: getUpdateEnvironment(),
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,
}); });
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) => { autoUpdater.on("download-progress", (progress) => {
logger.info(`Download progress: ${Math.round(progress.percent)}%`); logger.info(`Download progress: ${Math.round(progress.percent)}%`);
mainWindow?.webContents.send("update-progress", { mainWindow?.webContents.send("update-progress", {
percent: Math.round(progress.percent), percent: progress.percent,
speed: progress.bytesPerSecond, bytesPerSecond: progress.bytesPerSecond,
transferred: progress.transferred,
total: progress.total,
}); });
}); });
@@ -78,17 +87,9 @@ function initAutoUpdater(mainWindow) {
autoUpdater.on("update-downloaded", async (info) => { autoUpdater.on("update-downloaded", async (info) => {
logger.info(`Update downloaded: ${info.version}`); logger.info(`Update downloaded: ${info.version}`);
const { response } = await dialog.showMessageBox(mainWindow, { mainWindow?.webContents.send("update-downloaded", info);
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,
});
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 * If no update is available, I put this because there is no Linux version before 1.6.0
* @type {boolean} * @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) { if (isNoUpdateAvailable) {
logger.warn("Auto update: no update metadata found (probably no previous release), ignoring", msg); logger.warn("Auto update: no update metadata found (probably no previous release), ignoring", msg);
return; return;
} }
logger.error("Auto update error:", msg); logger.error("Auto update error:", err);
mainWindow?.webContents.send("update-error", msg);
}); });
checkForUpdates(); checkForUpdates();
@@ -121,17 +124,8 @@ function initAutoUpdater(mainWindow) {
*/ */
async function checkForUpdates() { async function checkForUpdates() {
/** // If app is was installed from SNAP or Flatpak.
* Prevents electron-updater from running inside sandboxed environments. if (process.env.SNAP || process.env.FLATPAK_ID) return;
* 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 (!require("electron").app.isPackaged) return;
try { try {
@@ -150,6 +144,7 @@ async function downloadUpdate() {
await autoUpdater.downloadUpdate(); await autoUpdater.downloadUpdate();
} catch (err) { } catch (err) {
logger.error("Download failed:", err.message); logger.error("Download failed:", err.message);
throw err;
} }
} }
@@ -160,4 +155,13 @@ function installUpdate() {
autoUpdater.quitAndInstall(); autoUpdater.quitAndInstall();
} }
module.exports = { initAutoUpdater, downloadUpdate, installUpdate }; function isUpdateAvailable() {
return updateAvailable;
}
module.exports = {
initAutoUpdater,
downloadUpdate,
installUpdate,
isUpdateAvailable,
};

177
app/changelogManager.js Normal file
View 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 };

View File

@@ -95,14 +95,14 @@ function startRPC() {
smallImageText: "By MasterAcnolo", smallImageText: "By MasterAcnolo",
startTimestamp: new Date(), startTimestamp: new Date(),
details: `Open Source Download Tools - ${config.version}`, details: `Open Source Download Tools - ${config.version}`,
state: "masteracnolo.github.io/FreedomLoader", state: "freedomloader.acnolo.fr",
}; };
rpc.clearActivity() rpc.clearActivity();
rpc.setActivity(presence); rpc.setActivity(presence);
}); });
rpc.login({ clientId }).catch(err => { rpc.login({ clientId }).catch(() => {
// Since 1.6.2, it no longer print the error. This is because if discord is not opened, it will send an error. // Since 1.6.2, it no longer print the error. This is because if discord is not opened, it will send an error.
// logger.error("Unable to connect to the RPC:", err); // logger.error("Unable to connect to the RPC:", err);
logger.info("Unable to connect to the Discord RPC. Discord is maybe not launched"); logger.info("Unable to connect to the Discord RPC. Discord is maybe not launched");
@@ -123,14 +123,6 @@ async function stopRPC() {
try { try {
if (intervalId) clearInterval(intervalId); if (intervalId) clearInterval(intervalId);
/**
* Ensures RPC connection AND the underlying socket exist
* before attempting to clear activity to prevent crash.
*/
if (rpc.transport && rpc.transport.socket) {
await rpc.clearActivity();
}
await rpc.destroy(); await rpc.destroy();
} catch (err) { } catch (err) {

81
app/ipcHandlers.js Normal file → Executable file
View File

@@ -13,12 +13,15 @@
const { ipcMain, dialog, shell } = require("electron"); const { ipcMain, dialog, shell } = require("electron");
const fs = require("fs"); const fs = require("fs");
const { logger, logDir } = require("../server/logger"); const { logger, logDir } = require("../server/logger");
const { configFeatures, featuresPath } = require("../config"); const { configFeatures, featuresPath, devMode } = require("../config");
const { getThemes, reloadThemes } = require("./themeManager"); const { getThemes, reloadThemes } = require("./themeManager");
const config = require("../config"); const config = require("../config");
const { validateDownloadPath, getDefaultDownloadPath } = require("./pathValidator"); const { validateDownloadPath, getDefaultDownloadPath } = require("./pathValidator");
const { userThemesPath } = require("../server/helpers/path.helpers"); const { userThemesPath } = require("../server/helpers/path.helpers");
const { createSystemTray, destroyTray } = require("./tray"); 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. * 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". * Acts as a basic validation layer for IPC "set-feature".
*/ */
const FEATURE_WHITELIST = new Set([ const FEATURE_WHITELIST = new Set([
"autoUpdate",
"systemTray", "systemTray",
"discordRPC", "discordRPC",
"customTopBar",
"autoCheckInfo", "autoCheckInfo",
"addThumbnail", "addThumbnail",
"addMetadata", "addMetadata",
"verboseLogs", "verboseLogs",
"autoUpdateYtDlp",
"autoDownloadPlaylist", "autoDownloadPlaylist",
"keepPlaylistOrder", "keepPlaylistOrder",
"customCodec", "customCodec",
"theme", "theme",
"createPlaylistFolders", "createPlaylistFolders",
"notifySystem", "notifySystem",
"enableHardwareAcceleration" "enableHardwareAcceleration",
"showChanglogsAfterUpdate"
]); ]);
/** /**
@@ -64,12 +67,21 @@ const themeFolderPath = userThemesPath;
* @param {Function} getMainWindow - Function returning current BrowserWindow instance * @param {Function} getMainWindow - Function returning current BrowserWindow instance
*/ */
function registerIpcHandlers(getMainWindow) { function registerIpcHandlers(getMainWindow) {
/** /**
* Returns application version from config. * Returns application version from config.
*/ */
ipcMain.handle("version", () => config.version); 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. * Returns runtime feature configuration object.
*/ */
@@ -114,7 +126,17 @@ function registerIpcHandlers(getMainWindow) {
/** /**
* Window minimize request from renderer. * Window minimize request from renderer.
*/ */
ipcMain.on("window-minimize", () => getMainWindow()?.minimize()); 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");
}
});
/** /**
* Toggles maximize/unmaximize state of main window. * Toggles maximize/unmaximize state of main window.
@@ -130,7 +152,6 @@ function registerIpcHandlers(getMainWindow) {
*/ */
ipcMain.on("window-close", () => getMainWindow()?.close()); ipcMain.on("window-close", () => getMainWindow()?.close());
/** /**
* Opens Chromium DevTools in detached mode. * Opens Chromium DevTools in detached mode.
*/ */
@@ -147,14 +168,14 @@ function registerIpcHandlers(getMainWindow) {
* Opens external website in default browser. * Opens external website in default browser.
*/ */
ipcMain.on("open-website", () => ipcMain.on("open-website", () =>
shell.openExternal("https://masteracnolo.github.io/Freedom-Loader-Site/") shell.openExternal("https://freedomloader.acnolo.fr/")
); );
/** /**
* Opens official wiki page in external browser. * Opens official wiki page in external browser.
*/ */
ipcMain.on("open-wiki", () => ipcMain.on("open-wiki", () =>
shell.openExternal("https://masteracnolo.github.io/Freedom-Loader-Site/wiki") shell.openExternal("https://freedomloader.acnolo.fr/wiki")
); );
/** /**
@@ -169,7 +190,12 @@ function registerIpcHandlers(getMainWindow) {
*/ */
ipcMain.on("open-config", () => shell.openPath(configFolderPath)); 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. * Retrieves available themes from filesystem.
@@ -188,6 +214,36 @@ function registerIpcHandlers(getMainWindow) {
return await reloadThemes(); 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. * Updates a runtime feature flag and persists it to disk.
* *
@@ -223,7 +279,7 @@ function registerIpcHandlers(getMainWindow) {
if (key === "systemTray") { if (key === "systemTray") {
if (value === true) { if (value === true) {
logger.info("System Tray enabled dynamically."); logger.info("System Tray enabled dynamically.");
createSystemTray(getMainWindow(), config.devMode); createSystemTray(devMode);
} else { } else {
logger.info("System Tray disabled dynamically."); logger.info("System Tray disabled dynamically.");
destroyTray(); destroyTray();
@@ -235,12 +291,11 @@ function registerIpcHandlers(getMainWindow) {
fs.writeFileSync( fs.writeFileSync(
configFolderPath, configFolderPath,
JSON.stringify(configFeatures, null, 2), JSON.stringify(configFeatures, null, 2),
"utf-8" "utf-8",
); );
logger.info(`Feature updated: ${key} = ${value}`); logger.info(`Feature updated: ${key} = ${value}`);
return true; return true;
} catch (err) { } catch (err) {
logger.error(`set-feature failed (${key}): ${err.message}`); logger.error(`set-feature failed (${key}): ${err.message}`);
return false; return false;

View File

@@ -66,7 +66,7 @@ function validateDownloadPath(userPath) {
const real = fs.realpathSync(absolutePath); const real = fs.realpathSync(absolutePath);
if (!isSafePath(real)) { if (!isSafePath(real)) {
throw new Error("Path not allowed: system folders are blocked!"); logger.error("Path not allowed: system folders are blocked!");
} }
return real; return real;

108
app/sendReport.js Normal file
View 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
View 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};

View File

@@ -2,6 +2,7 @@ const {app, Menu, Tray, nativeImage} = require("electron");
const path = require("path"); const path = require("path");
const {logger} = require("../server/logger"); // Ajuste le chemin si besoin const {logger} = require("../server/logger"); // Ajuste le chemin si besoin
const fs = require("fs"); const fs = require("fs");
const {getMainWindow} = require("./windowManager");
/** /**
* Global reference to the Tray instance to prevent garbage collection. * Global reference to the Tray instance to prevent garbage collection.
@@ -17,11 +18,10 @@ let tray = null;
* - Builds the right-click context menu. * - Builds the right-click context menu.
* - Handles left-click behavior to toggle main window visibility. * - Handles left-click behavior to toggle main window visibility.
* *
* @param {import('electron').BrowserWindow} mainWindow - The main application window.
* @param {boolean} devMode - Indicates whether the application is running in development mode. * @param {boolean} devMode - Indicates whether the application is running in development mode.
* @returns {Tray} The created Tray instance. * @returns {Tray} The created Tray instance.
*/ */
function createSystemTray(mainWindow, devMode) { function createSystemTray(devMode) {
// Prevent creating multiple tray instances // Prevent creating multiple tray instances
if (tray) return tray; if (tray) return tray;
@@ -58,7 +58,12 @@ function createSystemTray(mainWindow, devMode) {
{ {
label: "Show Freedom Loader", label: "Show Freedom Loader",
click: () => { click: () => {
mainWindow.show(); const window = getMainWindow();
if (window && !window.isDestroyed()) {
window.show();
window.focus();
logger.info("Window Restored from SystemTray (Using Show Action)");
}
}, },
}, },
{ type: "separator" }, { type: "separator" },
@@ -82,11 +87,15 @@ function createSystemTray(mainWindow, devMode) {
* but it remains standard practice for Windows and Linux environments. * but it remains standard practice for Windows and Linux environments.
*/ */
tray.on("click", () => { tray.on("click", () => {
if (mainWindow.isVisible()) { const window = getMainWindow();
mainWindow.hide(); if (!window || window.isDestroyed()) return;
if (window.isVisible()) {
window.hide();
} else { } else {
mainWindow.show(); window.show();
mainWindow.focus(); window.focus();
logger.info("Window Restored from SystemTray (Double Click on Tray Icon)");
} }
}); });

View File

@@ -57,7 +57,7 @@ async function createMainWindow() {
height: 800, height: 800,
minWidth: 750, minWidth: 750,
minHeight: 800, 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, devTools: !app.isPackaged,
show: false, show: false,
webPreferences: { webPreferences: {
@@ -87,9 +87,11 @@ async function createMainWindow() {
* cancels the destruction and hides the window instead. * cancels the destruction and hides the window instead.
*/ */
mainWindow.on('close', (event) => { mainWindow.on('close', (event) => {
if (!app.isQuitting && config.systemTray ) {
if (!app.isQuitting && config.configFeatures.systemTray) {
event.preventDefault(); event.preventDefault();
mainWindow.hide(); mainWindow.hide();
logger.info("Window Minimized in SystemTray (Using Close Button)");
return false; return false;
} }
}); });

View File

@@ -1,5 +1,10 @@
const packageJson = require("./package.json"); 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 fs = require("fs");
const path = require("path"); const path = require("path");
@@ -7,7 +12,7 @@ const path = require("path");
* Indicates whether the application is running in development mode. * Indicates whether the application is running in development mode.
* Determined by Electron's packaging state. * Determined by Electron's packaging state.
*/ */
const devMode = process.env.NODE_ENV === "test" || !app?.isPackaged; 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. * Resolves the configuration file path depending on runtime environment.
@@ -57,6 +62,40 @@ function resolveConfigPath() {
*/ */
const featuresPath = 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. * Loads and parses the feature configuration file.
* *
@@ -64,8 +103,15 @@ const featuresPath = resolveConfigPath();
* @throws {Error} If the configuration file is missing or invalid * @throws {Error} If the configuration file is missing or invalid
*/ */
function loadFeatures() { function loadFeatures() {
const raw = fs.readFileSync(featuresPath, "utf-8"); const defaults = JSON.parse(fs.readFileSync(getDefaultConfigPath(), "utf-8"));
return JSON.parse(raw); 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() { function reloadFeatures() {
try { try {
const raw = fs.readFileSync(featuresPath, "utf-8"); return loadFeatures();
return JSON.parse(raw);
} catch (err) { } catch (err) {
// Fallback to cached snapshot if file read fails // Fallback to cached snapshot if file read fails
return configFeatures; return configFeatures;
@@ -128,4 +173,9 @@ module.exports = {
* Path to the active configuration file * Path to the active configuration file
*/ */
featuresPath, featuresPath,
/**
* Merges missing default feature flags into user configuration without overwriting existing settings.
*/
mergeMissingFeatures,
}; };

View File

@@ -1,20 +1,20 @@
{ {
"autoUpdate": true,
"systemTray": false, "systemTray": false,
"discordRPC": true, "discordRPC": true,
"customTopBar": true,
"autoCheckInfo": true, "autoCheckInfo": true,
"addThumbnail": true, "addThumbnail": true,
"addMetadata": true, "addMetadata": true,
"verboseLogs": false, "verboseLogs": false,
"autoUpdateYtDlp": true,
"autoDownloadPlaylist": true, "autoDownloadPlaylist": true,
"keepPlaylistOrder": false, "keepPlaylistOrder": false,
"createPlaylistFolders": true, "createPlaylistFolders": false,
"customCodec": "h264", "customCodec": "h264",
"logSystem": true, "logSystem": true,
"outputTitleCheck": true, "outputTitleCheck": true,
"downloadSystem": true, "downloadSystem": true,
"notifySystem": true, "notifySystem": true,
"enableHardwareAcceleration": true, "enableHardwareAcceleration": true,
"showChanglogsAfterUpdate": true,
"theme": "dark" "theme": "dark"
} }

40
eslint.config.js Normal file
View 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}
}
}
];

68
main.js
View File

@@ -13,6 +13,9 @@ process.on("unhandledRejection", (reason) => {
app.quit(); app.quit();
}); });
if (process.platform === 'linux') {
app.setDesktopName('com.masteracnolo.freedomloader.desktop');
}
/** /**
* True if this is the primary instance (lock acquired successfully) * True if this is the primary instance (lock acquired successfully)
@@ -38,7 +41,7 @@ app.setAppUserModelId("com.masteracnolo.freedomloader");
* Load the app dependencies for hardware choice * Load the app dependencies for hardware choice
*/ */
const { logger, logSessionStart, logSessionEnd, logDir } = require("./server/logger"); const { logger, logSessionStart, logSessionEnd, logDir } = require("./server/logger");
const { configFeatures } = require("./config"); const { configFeatures, devMode} = require("./config");
/** /**
* In-memory snapshot of application feature flags. * In-memory snapshot of application feature flags.
@@ -48,18 +51,18 @@ const { configFeatures } = require("./config");
*/ */
if (!configFeatures.enableHardwareAcceleration){ if (!configFeatures.enableHardwareAcceleration){
app.disableHardwareAcceleration(); app.disableHardwareAcceleration();
logger.info("Disabled Hardware Acceleration") logger.info("Disabled Hardware Acceleration");
} else { } else {
logger.info("Enable Hardware Acceleration") logger.info("Enable Hardware Acceleration");
} }
if(configFeatures.devMode){ if(devMode){
/** /**
* Start devTron extensions - @see https://github.com/electron/devtron * Start devTron extensions - @see https://github.com/electron/devtron
*/ */
const { devtron } = require('@electron/devtron'); const { devtron } = require('@electron/devtron');
devtron.install(); devtron.install();
logger.info("Loaded DevTron Extension") logger.info("Loaded DevTron Extension");
} }
/** /**
@@ -74,8 +77,10 @@ const { updateYtDlp } = require("./app/ytDlpUpdater");
const { createMainWindow, getMainWindow } = require("./app/windowManager"); const { createMainWindow, getMainWindow } = require("./app/windowManager");
const { registerIpcHandlers } = require("./app/ipcHandlers"); const { registerIpcHandlers } = require("./app/ipcHandlers");
const { createSplashWindow, closeSplashWindow, setSplashProgress } = require("./app/splashManager"); const { createSplashWindow, closeSplashWindow, setSplashProgress } = require("./app/splashManager");
const { userThemesPath, initUserThemes, isWindows, validateBinaries, defaultDownloadFolder } = require("./server/helpers/path.helpers"); const { userThemesPath, initUserThemes, validateBinaries, defaultDownloadFolder } = require("./server/helpers/path.helpers");
const {createSystemTray, destroyTray} = require("./app/tray"); 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. * Global flag indicating if the application is intentionally shutting down.
@@ -91,6 +96,28 @@ app.isQuitting = false;
*/ */
let isCleanedUp = false; let isCleanedUp = false;
/**
* Performs a graceful shutdown of all application services asynchronously.
* Once finished, it re-triggers the app.quit() process.
*/
async function cleanShutdown() {
try {
destroyTray();
await stopRPC();
stopServer();
} catch (err) {
logger.error("Error during cleanup:", err);
} finally {
logger.info("All services stopped. Have a nice day!");
logSessionEnd();
isCleanedUp = true;
app.quit();
}
}
/** /**
* If another instance want to run * If another instance want to run
*/ */
@@ -109,13 +136,21 @@ app.on("second-instance", () => {
app.whenReady().then(async () => { app.whenReady().then(async () => {
logSessionStart(logDir, defaultDownloadFolder); logSessionStart(logDir, defaultDownloadFolder);
checkReportService().catch((err) => {
logger.warn(`Bug report service startup check failed: ${err.message}`);
});
createSplashWindow(); createSplashWindow();
if (!configFeatures.devMode && !checkNativeDependencies()) return; if (!devMode && !checkNativeDependencies()) return;
const { userYtDlp } = require("./server/helpers/path.helpers"); const { userYtDlp } = require("./server/helpers/path.helpers");
if (configFeatures.autoUpdateYtDlp !== false) {
updateYtDlp(userYtDlp); updateYtDlp(userYtDlp);
} else {
logger.info("yt-dlp automatic updates skipped.");
}
try { try {
setSplashProgress(0); // Checking dependencies setSplashProgress(0); // Checking dependencies
@@ -138,12 +173,12 @@ app.whenReady().then(async () => {
getMainWindow().show(); getMainWindow().show();
if (configFeatures.systemTray) { if (configFeatures.systemTray) {
createSystemTray(getMainWindow(), configFeatures.devMode); createSystemTray(devMode);
} }
if (configFeatures.discordRPC) startRPC(); if (configFeatures.discordRPC) startRPC();
if (configFeatures.autoUpdate) initAutoUpdater(getMainWindow()); initAutoUpdater(getMainWindow());
} catch (err) { } catch (err) {
logger.error("Boot error:", err); logger.error("Boot error:", err);
@@ -166,19 +201,6 @@ app.on("before-quit", (event) => {
if (!isCleanedUp) { if (!isCleanedUp) {
event.preventDefault(); event.preventDefault();
(async () => { cleanShutdown();
try {
destroyTray();
await stopRPC();
} catch (err) {
logger.error("Error during cleanup:", err);
} finally {
logger.info("All services stopped. Have a nice day!");
logSessionEnd();
isCleanedUp = true;
app.quit();
}
})();
} }
}); });

1091
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,11 @@
{ {
"name": "freedom-loader", "name": "freedom-loader",
"desktopName": "com.masteracnolo.freedomloader",
"productName": "Freedom Loader", "productName": "Freedom Loader",
"version": "1.6.2", "version": "1.6.5",
"author": "MasterAcnolo <MasterAcnolo@users.noreply.github.com>", "author": "MasterAcnolo <MasterAcnolo@users.noreply.github.com>",
"description": "Free and open-source GUI for yt-dlp — download video and audio from hundreds of platforms", "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": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/MasterAcnolo/Freedom-Loader.git" "url": "https://github.com/MasterAcnolo/Freedom-Loader.git"
@@ -15,25 +16,27 @@
}, },
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
"clean:dist": "rm -rf dist/ && rm -rf srpm-out/",
"start": "electron --no-warnings .", "start": "electron --no-warnings .",
"start:warn": "electron --trace-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 .", "start:debug": "WAYLAND_DEBUG=1 ELECTRON_ENABLE_LOGGING=1 ELECTRON_ENABLE_STACK_DUMPING=1 electron --enable-logging --v=1 .",
"dev": "nodemon --watch server --watch app --watch main.js --watch config.js --ext js,json --exec \"electron --no-warnings .\"", "dev": "nodemon --watch server --watch app --watch main.js --watch config.js --ext js,json --exec \"electron --no-warnings .\"",
"dev:warn": "nodemon --watch server --watch app --watch main.js --watch config.js --ext js,json --exec \"electron --trace-warnings .\"", "dev:warn": "nodemon --watch server --watch app --watch main.js --watch config.js --ext js,json --exec \"electron --trace-warnings .\"",
"dev:debug": "nodemon --watch server --watch app --watch main.js --watch config.js --ext js,json --exec \"WAYLAND_DEBUG=1 ELECTRON_ENABLE_LOGGING=1 ELECTRON_ENABLE_STACK_DUMPING=1 electron --enable-logging --v=1 .\"", "dev:debug": "nodemon --watch server --watch app --watch main.js --watch config.js --ext js,json --exec \"WAYLAND_DEBUG=1 ELECTRON_ENABLE_LOGGING=1 ELECTRON_ENABLE_STACK_DUMPING=1 electron --enable-logging --v=1 .\"",
"build": "electron-builder", "build": "electron-builder",
"build:win": "electron-builder --win", "build:win": "electron-builder --win",
"build:linux": "electron-builder --linux", "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", "build:all": "electron-builder -wl",
"release": "bash scripts/release.sh", "release": "bash scripts/release.sh",
"release:publish": "bash scripts/release.sh --publish", "release:publish": "bash scripts/release.sh --publish",
"release:dry-run": "bash scripts/release.sh --dry-run", "release:dry-run": "bash scripts/release.sh --dry-run",
"test": "jest", "test": "jest",
"test:unit": "jest test/unit", "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" "update": "npm update"
}, },
"dependencies": { "dependencies": {
@@ -50,10 +53,14 @@
}, },
"devDependencies": { "devDependencies": {
"@electron/devtron": "^2.1.1", "@electron/devtron": "^2.1.1",
"@eslint/js": "^10.0.1",
"@jest/globals": "^30.4.1", "@jest/globals": "^30.4.1",
"@playwright/test": "^1.62.1", "@playwright/test": "^1.62.1",
"electron": "^43.3.0", "electron": "^44.3.0",
"electron-builder": "^26.15.3", "electron-builder": "^26.15.3",
"eslint": "^10.10.0",
"eslint-formatter-pretty": "^7.1.0",
"globals": "^17.12.0",
"jest": "^30.4.2", "jest": "^30.4.2",
"nodemon": "^3.1.14", "nodemon": "^3.1.14",
"playwright": "^1.62.1" "playwright": "^1.62.1"
@@ -70,10 +77,20 @@
} }
], ],
"files": [ "files": [
"**/*", "app/**/*",
"node_modules/**/*", "public/**/*",
"!config/config.dev.json", "server/**/*",
"!theme/**/*" "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": { "directories": {
"buildResources": "build" "buildResources": "build"
@@ -84,33 +101,21 @@
"from": "theme", "from": "theme",
"to": "theme" "to": "theme"
}, },
{
"from": "build/app-icon.ico",
"to": "app-icon.ico"
},
{
"from": "build/app-icon-64x64.png",
"to": "app-icon-64x64.png"
},
{
"from": "build/confirm-icon.png",
"to": "confirm-icon.png"
},
{
"from": "build/banner.bmp",
"to": "banner.bmp"
},
{
"from": "build/banner.png",
"to": "banner.png"
},
{
"from": "build/error.png",
"to": "error.png"
},
{ {
"from": "config/config.default.json", "from": "config/config.default.json",
"to": "config/config.default.json" "to": "config/config.default.json"
},
{
"from": "build",
"to": ".",
"filter": [
"app-icon.ico",
"app-icon-64x64.png",
"confirm-icon.png",
"banner.bmp",
"banner.png",
"error.png"
]
} }
], ],
"win": { "win": {
@@ -119,20 +124,14 @@
"artifactName": "Freedom-Loader-Setup-${version}.${ext}", "artifactName": "Freedom-Loader-Setup-${version}.${ext}",
"extraResources": [ "extraResources": [
{ {
"from": "resources/binaries/win-32/yt-dlp.exe", "from": "resources/binaries/win-32",
"to": "binaries/yt-dlp.exe" "to": "binaries",
}, "filter": [
{ "yt-dlp.exe",
"from": "resources/binaries/win-32/ffmpeg.exe", "ffmpeg.exe",
"to": "binaries/ffmpeg.exe" "ffprobe.exe",
}, "deno.exe"
{ ]
"from": "resources/binaries/win-32/ffprobe.exe",
"to": "binaries/ffprobe.exe"
},
{
"from": "resources/binaries/win-32/deno.exe",
"to": "binaries/deno.exe"
} }
] ]
}, },
@@ -154,24 +153,20 @@
"snap" "snap"
], ],
"category": "Utility", "category": "Utility",
"synopsis": "GUI for yt-dlp to download video and audio with metadata",
"maintainer": "MasterAcnolo",
"icon": "build/app-icon.png", "icon": "build/app-icon.png",
"executableName": "freedom-loader", "executableName": "freedom-loader",
"extraResources": [ "extraResources": [
{ {
"from": "resources/binaries/linux/yt-dlp", "from": "resources/binaries/linux",
"to": "binaries/yt-dlp" "to": "binaries",
}, "filter": [
{ "yt-dlp",
"from": "resources/binaries/linux/ffmpeg", "ffmpeg",
"to": "binaries/ffmpeg" "ffprobe",
}, "deno"
{ ]
"from": "resources/binaries/linux/ffprobe",
"to": "binaries/ffprobe"
},
{
"from": "resources/binaries/linux/deno",
"to": "binaries/deno"
} }
] ]
}, },
@@ -185,6 +180,9 @@
"./package/com.masteracnolo.freedomloader.metainfo.xml=/usr/share/metainfo/com.masteracnolo.freedomloader.metainfo.xml" "./package/com.masteracnolo.freedomloader.metainfo.xml=/usr/share/metainfo/com.masteracnolo.freedomloader.metainfo.xml"
] ]
}, },
"appImage": {
"artifactName": "Freedom-Loader-${version}.${ext}"
},
"snap": { "snap": {
"confinement": "strict", "confinement": "strict",
"plugs": [ "plugs": [

View 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

View File

@@ -45,7 +45,7 @@
</p> </p>
</description> </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="homepage">https://github.com/MasterAcnolo/Freedom-Loader</url>
<url type="bugtracker">https://github.com/MasterAcnolo/Freedom-Loader/issues</url> <url type="bugtracker">https://github.com/MasterAcnolo/Freedom-Loader/issues</url>

View File

@@ -8,6 +8,26 @@ const { contextBridge, ipcRenderer } = require("electron");
* All calls are explicitly whitelisted. * All calls are explicitly whitelisted.
*/ */
contextBridge.exposeInMainWorld("electronAPI", { 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 * Return process.platform to renderer
@@ -42,14 +62,23 @@ contextBridge.exposeInMainWorld("electronAPI", {
* @param {string} key - Feature name * @param {string} key - Feature name
* @param {any} value - Feature value * @param {any} value - Feature value
*/ */
setFeature: (key, value) => setFeature: (key, value) => ipcRenderer.invoke("set-feature", { key, value }),
ipcRenderer.invoke("set-feature", { key, value }),
/** /**
* Returns the current application version. * Returns the current application version.
*/ */
getVersion: () => ipcRenderer.invoke("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. * 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). * Forces a reload of theme files (useful after modification/import).
*/ */
reloadThemes: () => ipcRenderer.invoke("reload-themes"), 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. * 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. * These methods forward commands to the Electron main process via IPC.
*/ */
contextBridge.exposeInMainWorld("topbarAPI", { contextBridge.exposeInMainWorld("topbarAPI", {
/** /**
* Minimizes the application window. * Minimizes the application window.
*/ */
@@ -126,4 +228,17 @@ contextBridge.exposeInMainWorld("topbarAPI", {
* Opens configuration/settings panel. * Opens configuration/settings panel.
*/ */
openConfig: () => ipcRenderer.send("open-config"), 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"),
}); });

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -13,6 +13,8 @@
<link rel="stylesheet" href="styles/styles.css"> <link rel="stylesheet" href="styles/styles.css">
<link rel="stylesheet" href="styles/layout/topbar.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> </head>
<body> <body>
@@ -21,9 +23,11 @@
<div class="custom-controls"> <div class="custom-controls">
<button id="devtools-btn" title="Dev Tools">Tools</button> <button id="devtools-btn" title="Dev Tools">Tools</button>
<button id="logs-btn" title="Logs">Logs</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="website-btn" title="Website">Website</button>
<button id="wiki-btn" title="Wiki">Wiki</button> <button id="wiki-btn" title="Wiki">Wiki</button>
<button id="workshop-btn" title="Workshop">Theme Workshop</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> --> <!-- <button id="theme-btn" title="Theme">Theme</button> -->
</div> </div>
<div class="window-controls"> <div class="window-controls">
@@ -120,10 +124,10 @@
<summary>Advanced Settings</summary> <summary>Advanced Settings</summary>
<div class="setting-item"> <div class="setting-item">
<input type="checkbox" data-key="autoUpdate" id="autoUpdate"> <input type="checkbox" data-key="showChanglogsAfterUpdate" id="showChanglogsAfterUpdate">
<div class="setting-info"> <div class="setting-info">
<span class="setting-title">Auto Update</span> <span class="setting-title">Show Changelogs After Update</span>
<small>Enable automatic app updates.</small> <small>Display changelogs after each update.</small>
</div> </div>
</div> </div>
@@ -131,7 +135,7 @@
<input data-key="systemTray" id="systemTray" type="checkbox"> <input data-key="systemTray" id="systemTray" type="checkbox">
<div class="setting-info"> <div class="setting-info">
<span class="setting-title">System Tray</span> <span class="setting-title">System Tray</span>
<small>Minimize app on close, in the System Tray. [EXPERIMENTAL]</small> <small>Minimize app on close, in the System Tray.</small>
</div> </div>
</div> </div>
@@ -167,13 +171,13 @@
</div> </div>
</div> </div>
<div class="setting-item"> <!-- <div class="setting-item">
<input type="checkbox" data-key="customTopBar" id="customTopBar"> <input type="checkbox" data-key="customTopBar" id="customTopBar">
<div class="setting-info"> <div class="setting-info">
<span class="setting-title">Custom Top bar</span> <span class="setting-title">Custom Top bar</span>
<small>Use this if the topbar is missing or you prefer a custom one.</small> <small>Use this if the topbar is missing or you prefer a custom one.</small>
</div> </div>
</div> </div> -->
<div class="setting-item" style="margin-left: 32px; margin-top: 20px;"> <div class="setting-item" style="margin-left: 32px; margin-top: 20px;">
<div class="setting-info"> <div class="setting-info">
@@ -213,6 +217,14 @@
</div> </div>
</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> </details>
<div class="settings-footer"> <div class="settings-footer">
@@ -262,7 +274,7 @@
</div> </div>
<div class="download-path"> <div class="download-path">
Will be save on <span id="savePath"></span> Will be saved in <span id="savePath"></span>
<button id="changePath">Edit</button> <button id="changePath">Edit</button>
</div> </div>
@@ -295,14 +307,53 @@
<span id="version-badge" class="version-badge"></span> <span id="version-badge" class="version-badge"></span>
</div>
</main> </main>
<!-- Toast Container --> <!-- Toast Container -->
<div id="toast-container"></div> <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">&times;</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> <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 --> <!-- Scripts -->
<script src="script/toast.js"></script> <script src="script/toast.js"></script>
@@ -314,6 +365,9 @@
<script src="script/fetchinfo.js"></script> <script src="script/fetchinfo.js"></script>
<script src="script/progressBar.js"></script> <script src="script/progressBar.js"></script>
<script src="script/customthemes.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> <script type="module" src="script/topbar.js"></script>
</body> </body>

109
public/script/autoUpdate.js Normal file
View 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
View 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();

View File

@@ -53,17 +53,32 @@ form.addEventListener("submit", async (e) => {
const formData = new FormData(form); const formData = new FormData(form);
const params = new URLSearchParams(formData); const params = new URLSearchParams(formData);
const body = Object.fromEntries(params.entries());
try { try {
const res = await fetch("/download", { const res = await fetch("/download", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" }, headers: {"Content-Type": "application/json"},
body: params, body: JSON.stringify(body),
}); });
if (!res.ok) { if (!res.ok) {
if (!userStoppedDownload) { 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); window.showError(errorText);
resetProgressBar(); resetProgressBar();
} }
@@ -71,19 +86,27 @@ form.addEventListener("submit", async (e) => {
} }
// Download successful - check if notifications are enabled // Download successful - check if notifications are enabled
try {
const features = await window.electronAPI.getFeatures(); const features = await window.electronAPI.getFeatures();
if (features.notifySystem) { if (features.notifySystem) {
window.showSuccess("Download completed!"); window.showSuccess("Download completed!");
} }
} catch (e) {
window.showSuccess("Download completed!");
}
} catch (err) { } catch (err) {
if (!isDownloading && !userStoppedDownload) { if (!isDownloading && !userStoppedDownload) {
statusDiv.style.display = "none"; statusDiv.style.display = "none";
} }
if (!userStoppedDownload) {
window.showError("Network error while starting download");
resetProgressBar();
}
} finally { } finally {
isDownloading = false; isDownloading = false;
button.disabled = false; button.disabled = false;
stopBtn.style.display = "none"; if (stopBtn) stopBtn.style.display = "none";
} }
}); });
@@ -135,7 +158,7 @@ async function stopDownload() {
try { try {
await fetch("/download/cancel", { await fetch("/download/cancel", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" }, headers: {"Content-Type": "application/json"},
}); });
window.showWarning("Download stopped"); window.showWarning("Download stopped");

View File

@@ -32,14 +32,27 @@ async function fetchVideoInfo(url) {
try { try {
const res = await fetch(`/info?url=${encodeURIComponent(url)}`, { const res = await fetch(`/info?url=${encodeURIComponent(url)}`, {
method: "GET", 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) return { error: "Data is Missing" };
if (data.error) {
return {error: data.error.message || data.error};
}
return data; return data;
} catch (e) { } catch (e) {
@@ -269,7 +282,7 @@ async function init() {
infoDiv.classList.add("visible"); infoDiv.classList.add("visible");
}); });
}) });
} }

165
public/script/reportBug.js Normal file
View 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();
});

View File

@@ -16,8 +16,7 @@ let isOpen = false;
* Toggles visibility of the settings panel when clicking the settings button. * Toggles visibility of the settings panel when clicking the settings button.
*/ */
settingsButton.addEventListener("click", () => { settingsButton.addEventListener("click", () => {
const currentlyOpen = settingsPanel.style.display === "block"; settingsPanel.classList.toggle("open");
settingsPanel.style.display = currentlyOpen ? "none" : "block";
}); });
/** /**
@@ -25,7 +24,7 @@ settingsButton.addEventListener("click", () => {
*/ */
function closePanel() { function closePanel() {
isOpen = false; isOpen = false;
settingsPanel.style.display = "none"; settingsPanel.classList.remove("open");
} }
/** /**

View File

@@ -17,18 +17,20 @@ window.showToast = function (message, type = "info", duration = 4000) {
toast.classList.add("toast", type); toast.classList.add("toast", type);
const icons = { const icons = {
success: "✓", success: "check_circle",
error: "✕", error: "error",
warning: "⚠", warning: "warning",
info: "ℹ" info: "info"
}; };
const icon = icons[type] || icons.info; const icon = icons[type] || icons.info;
toast.style.setProperty("--toast-duration", `${duration}ms`);
toast.innerHTML = ` toast.innerHTML = `
<span class="toast-icon">${icon}</span> <span class="toast-icon material-icons-round">${icon}</span>
<span class="toast-message">${message}</span> <span class="toast-message">${message}</span>
<button class="toast-close" title="Close">×</button> <button class="toast-close" title="Close">×</button>
<div class="toast-progress"></div>
`; `;
const closeBtn = toast.querySelector(".toast-close"); const closeBtn = toast.querySelector(".toast-close");
@@ -81,3 +83,12 @@ window.showWarning = (message, duration = 4000) =>
*/ */
window.showInfo = (message, duration = 4000) => window.showInfo = (message, duration = 4000) =>
window.showToast(message, "info", duration); 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);
}
});

View File

@@ -35,25 +35,26 @@ export function initTopBar() {
} }
async function attachListeners(){ //* Disabled because i disabled native topbar for now, but keeping the code in case i want to re-enable it later
try{ // async function attachListeners(){
const featuresList = await window.electronAPI.getFeatures(); // try{
// const featuresList = await window.electronAPI.getFeatures();
if (!featuresList.customTopBar) { // if (!featuresList.customTopBar) {
const topbar = document.getElementById("topbar"); // const topbar = document.getElementById("topbar");
const container = document.getElementById("container"); // const container = document.getElementById("container");
const themeSwitcher = document.getElementById("theme-switcher"); // const themeSwitcher = document.getElementById("theme-switcher");
if (topbar) topbar.style.display = "none"; // if (topbar) topbar.style.display = "none";
if (container) container.style.marginTop = "0"; // if (container) container.style.marginTop = "0";
if (themeSwitcher) themeSwitcher.style.top = "30px"; // if (themeSwitcher) themeSwitcher.style.top = "30px";
} // }
} catch (error) { // } catch (error) {
console.error("Failed to load layout features for topbar:", error); // console.error("Failed to load layout features for topbar:", error);
} // }
} // }
initTopBar() initTopBar()
document.addEventListener("DOMContentLoaded", attachListeners) //document.addEventListener("DOMContentLoaded", attachListeners)

View 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;
}

View 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;
}
}

View File

@@ -5,73 +5,70 @@
right: 20px; right: 20px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 12px;
z-index: 9999; z-index: 9999;
pointer-events: none; pointer-events: none;
} }
/* Toast */ /* Toast */
.toast { .toast {
position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 12px;
padding: 10px 14px; padding: 14px 16px;
background-color: rgba(50, 50, 50, 0.9); background-color: rgba(30, 30, 30, 0.9);
border-radius: 6px; backdrop-filter: blur(12px);
color: #ffffff; -webkit-backdrop-filter: blur(12px);
font-size: 0.9rem; border: 1px solid rgba(255, 255, 255, 0.08);
max-width: 220px; border-radius: 10px;
min-width: 100px; color: #f3f4f6;
animation: slideIn 0.25s ease-out; 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; pointer-events: auto;
backdrop-filter: blur(8px); overflow: hidden;
}
.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);
} }
/* Toast Icon */ /* Toast Icon */
.toast-icon { .toast-icon {
flex-shrink: 0; flex-shrink: 0;
font-size: 18px; font-size: 20px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.toast-icon svg {
width: 100%;
height: 100%;
}
.toast.success .toast-icon { .toast.success .toast-icon {
color: #28a745; color: #22c55e;
} }
.toast.error .toast-icon { .toast.error .toast-icon {
color: #dc3545; color: #ef4444;
} }
.toast.warning .toast-icon { .toast.warning .toast-icon {
color: #ffc107; color: #f59e0b;
} }
.toast.info .toast-icon { .toast.info .toast-icon {
color: #17a2b8; color: #0ea5e9;
} }
/* Toast Message */ /* Toast Message */
.toast-message { .toast-message {
flex: 1; flex: 1;
font-weight: 500; font-weight: 400;
line-height: 1.4;
word-break: break-word;
} }
/* Toast Close Button */ /* Toast Close Button */
@@ -79,54 +76,83 @@
flex-shrink: 0; flex-shrink: 0;
background: none; background: none;
border: none; border: none;
color: currentColor; color: #9ca3af;
cursor: pointer; cursor: pointer;
font-size: 18px; font-size: 16px;
padding: 0; width: 24px;
height: 24px;
border-radius: 4px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
opacity: 0.5; transition: background-color 0.15s ease, color 0.15s ease;
transition: opacity 0.2s ease;
} }
.toast-close:hover { .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 */ /* Animations */
@keyframes slideIn { @keyframes slideIn {
from { from {
transform: translateX(350px); transform: translateY(-20px);
opacity: 0; opacity: 0;
} }
to { to {
transform: translateX(0); transform: translateY(0);
opacity: 1; opacity: 1;
} }
} }
@keyframes slideOut { @keyframes slideOut {
from { from {
transform: translateX(0); transform: translateY(0);
opacity: 1; opacity: 1;
} }
to { to {
transform: translateX(350px); transform: translateY(-20px);
opacity: 0; opacity: 0;
} }
} }
.toast.removing { @keyframes progress {
animation: slideOut 0.25s ease-out; from {
width: 100%;
}
to {
width: 0%;
}
}
.toast.removing {
animation: slideOut 0.25s ease-in forwards;
} }
/* Responsive */
@media (max-width: 480px) { @media (max-width: 480px) {
#toast-container { #toast-container {
bottom: 12px; top: auto;
right: 12px; bottom: 16px;
left: 12px; right: 16px;
left: 16px;
} }
.toast { .toast {

View File

@@ -45,11 +45,22 @@
border-radius: 12px; border-radius: 12px;
padding: 20px; padding: 20px;
box-shadow: 0 8px 24px rgba(0,0,0,0.4); box-shadow: 0 8px 24px rgba(0,0,0,0.4);
display: none;
overflow-y: auto; overflow-y: auto;
z-index: 9999; z-index: 9999;
animation: slideIn 0.2s ease-out; /*animation: slideIn 0.2s ease-out; */
color: var(--settings-text-color); 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 { .settings-panel::-webkit-scrollbar {

View File

@@ -14,8 +14,8 @@
.custom-controls { .custom-controls {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 4px;
padding-left: 12px; padding-left: 10px;
} }
/* RIGHT - window buttons */ /* RIGHT - window buttons */
@@ -43,11 +43,11 @@
/* Custom buttons (Logs / Wiki / etc.) */ /* Custom buttons (Logs / Wiki / etc.) */
.custom-controls button { .custom-controls button {
height: 32px; height: 28px;
padding: 0 14px; padding: 0 10px;
font-size: 15px; font-size: 14px;
border-radius: 8px; border-radius: 7px;
} }
.custom-controls button:hover { .custom-controls button:hover {

View File

@@ -12,6 +12,8 @@
@import url("components/editpathbutton.css"); @import url("components/editpathbutton.css");
@import url("components/progressBar.css"); @import url("components/progressBar.css");
@import url("components/toast.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'); @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');

View 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.

View File

@@ -54,20 +54,6 @@ exec /opt/freedom-loader/freedom-loader "$@"
EOF EOF
chmod +x "$TMP_WRAPPER/freedom-loader" 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 # Step 1 - Build .rpm from linux-unpacked via fpm
# ------------------------------------------------------------ # ------------------------------------------------------------
@@ -78,7 +64,7 @@ echo "Building RPM..."
--version "$VERSION" \ --version "$VERSION" \
--architecture x86_64 \ --architecture x86_64 \
--description "Free and open-source GUI for yt-dlp" \ --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>" \ --maintainer "MasterAcnolo <MasterAcnolo@users.noreply.github.com>" \
--license "GPL-3.0-only" \ --license "GPL-3.0-only" \
--depends gtk3 \ --depends gtk3 \
@@ -89,11 +75,10 @@ echo "Building RPM..."
--package "$RPM_OUT" \ --package "$RPM_OUT" \
"$ROOT_DIR/dist/linux-unpacked/=/opt/freedom-loader" \ "$ROOT_DIR/dist/linux-unpacked/=/opt/freedom-loader" \
"$TMP_WRAPPER/freedom-loader=/usr/bin/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/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" "$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" "$TMP_DESKTOP" rm -rf "$TMP_WRAPPER"
echo "RPM built: $RPM_OUT" echo "RPM built: $RPM_OUT"
# ------------------------------------------------------------ # ------------------------------------------------------------

View File

@@ -4,6 +4,13 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
VERSION=$(node -p "require('$ROOT_DIR/package.json').version") VERSION=$(node -p "require('$ROOT_DIR/package.json').version")
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
fi
TAG="$VERSION" TAG="$VERSION"
# Parse arguments # Parse arguments
@@ -17,7 +24,7 @@ for arg in "$@"; do
--copr) PUBLISH_COPR=true ;; --copr) PUBLISH_COPR=true ;;
--publish) PUBLISH_SNAP=true; PUBLISH_COPR=true ;; --publish) PUBLISH_SNAP=true; PUBLISH_COPR=true ;;
--dry-run) DRY_RUN=true ;; --dry-run) DRY_RUN=true ;;
*) echo "⚠️ Argument inconnu : $arg" ; exit 1 ;; *) echo "Unknown Argument : $arg" ; exit 1 ;;
esac esac
done done
@@ -57,6 +64,16 @@ echo "[0/5] Cleaning workspace..."
rm -rf "$ROOT_DIR/dist" "$ROOT_DIR/srpm-out" rm -rf "$ROOT_DIR/dist" "$ROOT_DIR/srpm-out"
echo "Workspace cleaned (dist/ and srpm-out/ removed)" 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) # Step 1 - Build Linux packages (AppImage, deb, snap)
# ------------------------------------------------------------ # ------------------------------------------------------------
@@ -98,19 +115,13 @@ PREV_TAG=$(git rev-list --tags --skip=1 --max-count=1 | xargs git describe --tag
RELEASE_NOTES="# Freedom Loader - $VERSION RELEASE_NOTES="# Freedom Loader - $VERSION
## Changelog
$CHANGELOG
## Found a bug or issue? ## Found a bug or issue?
Please report it in the [GitHub Issues](https://github.com/MasterAcnolo/Freedom-Loader/issues) section. Please report it in the [GitHub Issues](https://github.com/MasterAcnolo/Freedom-Loader/issues) section.
## Next Release (non-exhaustive roadmap)
- More format options
- Subtitle support
- Improved UI / UX
- Language selection
- Download specific parts of videos
- File renaming options
- Parallel downloads
- Skip sponsored parts automatically
**Full Changelog**: https://github.com/MasterAcnolo/Freedom-Loader/compare/${PREV_TAG}...${TAG}" **Full Changelog**: https://github.com/MasterAcnolo/Freedom-Loader/compare/${PREV_TAG}...${TAG}"
if [ "$DRY_RUN" = false ]; then if [ "$DRY_RUN" = false ]; then
@@ -122,7 +133,7 @@ if [ "$DRY_RUN" = false ]; then
"$ROOT_DIR/dist/Freedom-Loader-Setup-${VERSION}.exe.blockmap" \ "$ROOT_DIR/dist/Freedom-Loader-Setup-${VERSION}.exe.blockmap" \
"$ROOT_DIR/dist/freedom-loader-${VERSION}.x86_64.rpm" \ "$ROOT_DIR/dist/freedom-loader-${VERSION}.x86_64.rpm" \
"$ROOT_DIR/dist/freedom-loader_${VERSION}_amd64.deb" \ "$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/freedom-loader_${VERSION}_amd64.snap" \
"$ROOT_DIR/dist/latest.yml" \ "$ROOT_DIR/dist/latest.yml" \
"$ROOT_DIR/dist/latest-linux.yml" "$ROOT_DIR/dist/latest-linux.yml"

View File

@@ -1,15 +1,23 @@
const EventEmitter = require("events");
const { fetchDownload, cancelDownload } = require("../services/download.services"); const { fetchDownload, cancelDownload } = require("../services/download.services");
const { logger } = require("../logger"); const { logger } = require("../logger");
const { isValidUrl, isSafePath } = require("../helpers/validation.helpers"); const { isValidUrl, isSafePath } = require("../helpers/validation.helpers");
const { notifyDownloadFinished } = require("../helpers/notify.helpers"); const { notifyDownloadFinished } = require("../helpers/notify.helpers");
const { configFeatures } = require("../../config"); const { configFeatures } = require("../../config");
const 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 * Active Server-Sent Event subscribers receiving
* download progress updates. * download progress updates.
*
* Each listener receives a percentage value between
* 0 and 100 during the download lifecycle.
*/ */
const progressListeners = []; const progressListeners = [];
@@ -31,6 +39,13 @@ const stageListeners = [];
*/ */
const playlistListeners = []; 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. * Handles download requests and starts a yt-dlp download.
* *
@@ -47,34 +62,91 @@ const playlistListeners = [];
*/ */
async function downloadController(req, res) { async function downloadController(req, res) {
try { try {
const body = req.body || {};
const options = { const options = {
url: req.body.url, url: body.url,
audioOnly: req.body.audioOnly === "1", audioOnly: body.audioOnly === "1" || body.audioOnly === true,
quality: req.body.quality || "best", quality: ALLOWED_QUALITIES.has(body.quality) ? body.quality : "best",
outputFolder: req.body.savePath || undefined, outputFolder: body.savePath || body.outputFolder || undefined,
playlistTitle: req.body.playlistTitle || 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)) { if (options.outputFolder && !isSafePath(options.outputFolder)) {
logger.warn(`Unsafe download path rejected: ${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( const outputFolder = await fetchDownload(
options, options,
progressListeners, [progressHandler],
speedListeners, [speedHandler],
stageListeners, [stageHandler],
playlistListeners [playlistHandler]
); );
notifyDownloadFinished(outputFolder, configFeatures.notifySystem); notifyDownloadFinished(outputFolder, configFeatures.notifySystem);
res.send("Download Done !"); return res.json({success: true, message: "Download Done !", outputFolder});
} catch (err) { } catch (err) {
logger.error(`Server Error in /download: ${err?.message ?? 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) { function progressController(req, res) {
res.set({ res.set({
'Content-Type': 'text/event-stream', "Content-Type": "text/event-stream",
'Cache-Control': 'no-cache', "Cache-Control": "no-cache",
'Connection': 'keep-alive', 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); progressListeners.push(sendProgress);
const onProgress = (percent) => sendProgress(percent);
progressEmitter.on("progress", onProgress);
req.on('close', () => { req.on("close", () => {
progressListeners.splice(progressListeners.indexOf(sendProgress), 1); const index = progressListeners.indexOf(sendProgress);
if (index >= 0) progressListeners.splice(index, 1);
progressEmitter.removeListener("progress", onProgress);
try {
res.end(); res.end();
} catch (err) {
// ignore
}
}); });
} }
@@ -110,17 +198,33 @@ function progressController(req, res) {
*/ */
function speedController(req, res) { function speedController(req, res) {
res.set({ res.set({
'Content-Type': 'text/event-stream', "Content-Type": "text/event-stream",
'Cache-Control': 'no-cache', "Cache-Control": "no-cache",
'Connection': 'keep-alive', 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); speedListeners.push(sendSpeed);
const onSpeed = (speed) => sendSpeed(speed);
speedEmitter.on("speed", onSpeed);
req.on('close', () => { req.on("close", () => {
speedListeners.splice(speedListeners.indexOf(sendSpeed), 1); const index = speedListeners.indexOf(sendSpeed);
if (index >= 0) speedListeners.splice(index, 1);
speedEmitter.removeListener("speed", onSpeed);
try {
res.end(); res.end();
} catch (err) {
// ignore
}
}); });
} }
@@ -134,17 +238,33 @@ function speedController(req, res) {
*/ */
function stageController(req, res) { function stageController(req, res) {
res.set({ res.set({
'Content-Type': 'text/event-stream', "Content-Type": "text/event-stream",
'Cache-Control': 'no-cache', "Cache-Control": "no-cache",
'Connection': 'keep-alive', 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); stageListeners.push(sendStage);
const onStage = (stage) => sendStage(stage);
stageEmitter.on("stage", onStage);
req.on('close', () => { req.on("close", () => {
stageListeners.splice(stageListeners.indexOf(sendStage), 1); const index = stageListeners.indexOf(sendStage);
if (index >= 0) stageListeners.splice(index, 1);
stageEmitter.removeListener("stage", onStage);
try {
res.end(); res.end();
} catch (err) {
// ignore
}
}); });
} }
@@ -160,10 +280,12 @@ function cancelDownloadController(req, res) {
const cancelled = cancelDownload(); const cancelled = cancelDownload();
if (cancelled) { if (cancelled) {
logger.info("Download and queue cancelled by user"); 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 { } else {
logger.warn("No download to cancel"); 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) { function playlistInfoController(req, res) {
res.set({ res.set({
'Content-Type': 'text/event-stream', "Content-Type": "text/event-stream",
'Cache-Control': 'no-cache', "Cache-Control": "no-cache",
'Connection': 'keep-alive', 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); playlistListeners.push(sendPlaylistInfo);
const onPlaylist = (info) => sendPlaylistInfo(info);
playlistEmitter.on("playlist", onPlaylist);
req.on('close', () => { // Remove listener when client disconnects
playlistListeners.splice(playlistListeners.indexOf(sendPlaylistInfo), 1); req.on("close", () => {
const index = playlistListeners.indexOf(sendPlaylistInfo);
if (index >= 0) playlistListeners.splice(index, 1);
playlistEmitter.removeListener("playlist", onPlaylist);
try {
res.end(); res.end();
} catch (err) {
// ignore
}
}); });
} }
module.exports = { downloadController, progressController, speedController, stageController, cancelDownloadController, playlistInfoController}; module.exports = {
downloadController,
progressController,
speedController,
stageController,
cancelDownloadController,
playlistInfoController,
};

View File

@@ -1,128 +1,41 @@
const { fetchInfo } = require("../services/info.services"); const {fetchNormalizedInfo} = require("../services/info.services");
const { parseVideo, parsePlaylist } = require("../helpers/parseInfo.helpers");
const { logger } = require("../logger"); const { logger } = require("../logger");
const { isValidUrl } = require("../helpers/validation.helpers"); const { isValidUrl } = require("../helpers/validation.helpers");
/** /**
* Handles metadata retrieval for a video or playlist. * Handles metadata retrieval for a video or playlist.
* *
* This controller: * Returns normalized JSON responses. Accepts URL via query (?url=...)
* - Validates incoming URL (query or body) * or request body for flexibility.
* - Calls yt-dlp metadata extraction service
* - Detects playlist vs single video context
* - Applies fallback strategy if playlist extraction fails
* - Returns normalized parsed response (video or playlist)
* *
* @param {import("express").Request} req * @param {import("express").Request} req
* @param {import("express").Response} res * @param {import("express").Response} res
*/ */
async function infoController(req, 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)) { 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. RAW URL: ${url}`);
if (encodedUrl) {
logger.info(`Info request received. ENCODED: ${encodedUrl}`); logger.info(`Info request received. ENCODED: ${encodedUrl}`);
}
// Lightweight heuristic to detect playlist URLs. It works for Youtube. const result = await fetchNormalizedInfo(url);
const isPlaylistUrl = url.includes("&list") || url.includes("?list"); return res.json(result);
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) { } 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
throw err;
}
} else {
throw 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( logger.error(
`Info controller error: ${err && err.message ? err.message : err}` `Info controller error: ${err && err.message ? err.message : err}`
); );
return res.status(500).json({
return res.status(500).send("Unable to fetch info"); error: {code: "FETCH_FAILED", message: "Unable to fetch info"},
});
} }
} }

View File

@@ -1,6 +1,6 @@
const path = require("path"); const path = require("path");
const getUserBrowser = require("./getBrowser.helpers.js"); const {getUserBrowser} = require("./getBrowser.helpers.js");
const { ffmpegPath, denoPath} = require("./path.helpers.js"); const { ffmpegPath, denoPath} = require("./path.helpers.js");
const { configFeatures } = require("../../config.js"); const { configFeatures } = require("../../config.js");
const { logger } = require("../logger.js"); const { logger } = require("../logger.js");
@@ -67,9 +67,7 @@ function buildYtDlpArgs({url, audioOnly, quality, outputFolder, isPlaylist}) {
logger.info("--- CONFIGURATION ---"); logger.info("--- CONFIGURATION ---");
logger.info(`CONFIG autoUpdate: ${configFeatures.autoUpdate}`);
logger.info(`CONFIG discordRPC: ${configFeatures.discordRPC}`); logger.info(`CONFIG discordRPC: ${configFeatures.discordRPC}`);
logger.info(`CONFIG customTopBar: ${configFeatures.customTopBar}`);
logger.info(`CONFIG autoCheckInfo: ${configFeatures.autoCheckInfo}`); logger.info(`CONFIG autoCheckInfo: ${configFeatures.autoCheckInfo}`);
logger.info(`CONFIG keepPlaylistOrder: ${configFeatures.keepPlaylistOrder}`); logger.info(`CONFIG keepPlaylistOrder: ${configFeatures.keepPlaylistOrder}`);
logger.info(`CONFIG addThumbnail: ${configFeatures.addThumbnail}`); logger.info(`CONFIG addThumbnail: ${configFeatures.addThumbnail}`);
@@ -135,7 +133,7 @@ function buildYtDlpArgs({url, audioOnly, quality, outputFolder, isPlaylist}) {
if (configFeatures.addMetadata) { if (configFeatures.addMetadata) {
args.push( args.push(
"--postprocessor-args", "--postprocessor-args",
`ffmpeg:-metadata comment="Downloaded with Freedom Loader v${config.version}"` `ffmpeg:-metadata comment="Downloaded with Freedom Loader ${config.version}"`
); );
} }

View File

@@ -1,8 +1,8 @@
const fs = require("fs"); const fs = require("fs");
const os = require("os"); const os = require("os");
const path = require("path"); const notify = require("./notify.helpers");
const notify = require("./notify.helpers")
const { logger } = require("../logger"); const { logger } = require("../logger");
const {sendToast} = require("../../app/toastNotifier");
/** /**
* Detects the first supported browser installed on the system. * Detects the first supported browser installed on the system.
@@ -16,47 +16,50 @@ const { logger } = require("../logger");
* If no supported browser is found: * If no supported browser is found:
* - A warning is logged * - A warning is logged
* - A notification is displayed to the user * - 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 * - "firefox" is returned as a fallback to allow yt-dlp to
* handle the error gracefully without crashing the application * handle the error gracefully without crashing the application
* *
* @returns {string} The detected browser identifier. * @returns {string} The detected browser identifier.
*/ */
function getUserBrowser() { function getUserBrowser() {
//const userProfile = os.homedir(); const platform = process.platform;
const firefoxPath = getFirefoxProfilePath(); let isFirefoxHere = false;
/** if (platform === "win32") {
* Browsers supported by yt-dlp cookie extraction. const paths = [
* "C:\\Program Files\\Mozilla Firefox\\firefox.exe",
* Only Firefox is currently enabled and tested. "C:\\Program Files (x86)\\Mozilla Firefox\\firefox.exe",
* Additional browsers can be enabled in the future `${process.env.LOCALAPPDATA}\\Mozilla Firefox\\firefox.exe`,
* once compatibility has been verified.
*/
const browsers = [
{ name: "firefox", path: firefoxPath },
// { name: "chrome", path: path.join(userProfile, "AppData", "Local", "Google", "Chrome", "User Data", "Default") },
// { name: "brave", path: path.join(userProfile, "AppData", "Local", "BraveSoftware", "Brave-Browser", "User Data", "Default") },
// { name: "edge", path: path.join(userProfile, "AppData", "Local", "Microsoft", "Edge", "User Data", "Default") },
// { name: "opera", path: path.join(userProfile, "AppData", "Roaming", "Opera Software", "Opera Stable") },
// { name: "vivaldi", path: path.join(userProfile, "AppData", "Local", "Vivaldi", "User Data", "Default") },
// { name: "safari", path: path.join(userProfile, "AppData", "Local", "Apple Computer", "Safari") },
// { name: "whale", path: path.join(userProfile, "AppData", "Local", "Naver", "Naver Whale", "User Data", "Default") }
]; ];
// Search for the first available browser profile. isFirefoxHere = paths.some(p => fs.existsSync(p));
for (const browser of browsers) {
if (browser.path && fs.existsSync(browser.path)) {
logger.info(`Browser found: ${browser.name}`);
return browser.name;
}
} }
if (platform === "linux") {
const paths = [
"/usr/bin/firefox",
"/usr/bin/firefox-esr",
"/usr/local/bin/firefox",
"/snap/bin/firefox",
"/var/lib/flatpak/exports/bin/org.mozilla.firefox",
`${os.homedir()}/.local/share/flatpak/exports/bin/org.mozilla.firefox`,
];
isFirefoxHere = paths.some(p => fs.existsSync(p));
}
if (!isFirefoxHere) {
// If you somehow managed to live without Firefox and need help installing it. - Don't applied to my Linux chad
// No supported browser found => Notify User // No supported browser found => Notify User
logger.warn("No supported browser found on the system"); logger.warn("No supported browser found on the system");
// If you somehow managed to live without Firefox and need help installing it. - Don't applied to my Linux chad
if (process.platform === "win32") {
notify.notifyFirefoxBrowserMissing(); notify.notifyFirefoxBrowserMissing();
sendToast(
"Firefox was not found on your system. Please install Firefox to import browser cookies.",
"error",
);
} else {
logger.info("Browser found: firefox");
} }
// Fallback to Firefox and let yt-dlp handle the error gracefully. // Fallback to Firefox and let yt-dlp handle the error gracefully.
@@ -64,39 +67,4 @@ function getUserBrowser() {
return "firefox"; return "firefox";
} }
function getFirefoxProfilePath() { module.exports = {getUserBrowser};
const home = os.homedir();
if (process.platform === "win32") {
return path.join(
home,
"AppData",
"Roaming",
"Mozilla",
"Firefox",
"Profiles"
);
}
if (process.platform === "linux") {
return path.join(
"usr",
"bin",
"firefox"
);
}
if (process.platform === "darwin") {
return path.join(
home,
"Library",
"Application Support",
"Firefox",
"Profiles"
);
}
return null;
}
module.exports = getUserBrowser

View File

@@ -1,6 +1,11 @@
const path = require("path"); const path = require("path");
const fs = require("fs"); const fs = require("fs");
const { app } = require("electron"); let app;
try {
app = require("electron").app;
} catch (e) {
app = null;
}
const config = require("../../config.js"); const config = require("../../config.js");
/** /**
@@ -16,10 +21,12 @@ const ext = isWindows ? '.exe' : ''
/** Centralized resource paths */ /** Centralized resource paths */
const resourcesPath = config.devMode const resourcesPath = config.devMode
? path.join(__dirname, "../../resources") ? path.join(__dirname, "../../resources")
: process.resourcesPath; : (process.resourcesPath || path.join(__dirname, "../../resources"));
/** Default download folder (centralized) */ /** Default download folder */
const defaultDownloadFolder = path.join(app.getPath("downloads"), "Freedom Loader"); 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: * Current platform:
@@ -149,7 +156,9 @@ const binaryPaths = {
* - default themes come from app resources * - default themes come from app resources
* - user themes are persisted in userData to survive updates * - 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"); const defaultThemesSourcePath = path.join("theme");
/** /**
@@ -260,7 +269,7 @@ function validateBinaries() {
/** /**
* Helper to lazy load logger, avoid circular import * Helper to lazy load logger, avoid circular import
* @returns {winston.Logger} * @returns {import("winston").Logger}
*/ */
function getLogger() { function getLogger() {
return require("../logger.js").logger; return require("../logger.js").logger;

View File

@@ -0,0 +1,12 @@
/**
* Determines whether a URL targets a playlist.
*
* The detection is based on the presence of the YouTube
* playlist query parameter (`list`).
*
* @param {string} url - URL to inspect.
* @returns {boolean} True if the URL appears to be a playlist.
*/
export function isUrlPlaylist(url) {
return url.includes("?list=") || url.includes("&list=") || url.includes("@");
}

View File

@@ -1,6 +1,5 @@
const RateLimit = require("express-rate-limit"); const RateLimit = require("express-rate-limit");
/** /**
* Rate limiter middleware used to protect public endpoints * Rate limiter middleware used to protect public endpoints
* against abusive or excessive requests. * against abusive or excessive requests.
@@ -15,10 +14,29 @@ const RateLimit = require("express-rate-limit");
const rateLimit = RateLimit({ const rateLimit = RateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, max: 5,
message: "Too many requests, please try again later.", message: {error: {code: "RATE_LIMITED", message: "Too many requests, please try again later."}},
statusCode: 429, 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 = { module.exports = {
rateLimit rateLimit,
} downloadRateLimit,
infoRateLimit,
};

View File

@@ -17,11 +17,6 @@ try {
console.error(`Failed to create log directory: ${error.message}`); console.error(`Failed to create log directory: ${error.message}`);
} }
const logFormat = format.combine(
format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
format.printf(({ timestamp, level, message }) => `${timestamp} | ${level.toUpperCase()} | ${message}`)
);
/** /**
* Format used by the saved file * Format used by the saved file
* @type {Format} * @type {Format}

View File

@@ -1,9 +1,17 @@
const express = require("express"); const express = require("express");
const router = express.Router(); 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("/", downloadRateLimit, downloadController);
router.post("/cancel", cancelDownloadController); router.post("/cancel", downloadRateLimit, cancelDownloadController);
// SSE for download progress // SSE for download progress
router.get("/progress", progressController); router.get("/progress", progressController);

View File

@@ -1,7 +1,9 @@
const express = require("express"); const express = require("express");
const router = express.Router(); const router = express.Router();
const { infoController } = require("../controller/info.controller"); 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; module.exports = router;

View File

@@ -1,11 +1,17 @@
const express = require("express"); const express = require("express");
const path = require("path"); const path = require("path");
const { logger, logSessionEnd } = require("./logger");
const config = require("../config"); const config = require("../config");
const {logger} = require("./logger");
const { rateLimit } = require("./helpers/rateLimit.helpers"); const { rateLimit } = require("./helpers/rateLimit.helpers");
const app = express(); const app = express();
/**
* Store the state of the server
*/
let serverInstance = null;
// Middlewares // Middlewares
app.use(express.json()); app.use(express.json());
app.use(express.urlencoded({ extended: true })); app.use(express.urlencoded({ extended: true }));
@@ -30,33 +36,34 @@ app.get("/", rateLimit, (req, res) => {
*/ */
async function startServer() { async function startServer() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const server = app.listen(config.applicationPort, () => { serverInstance = app.listen(config.applicationPort, "127.0.0.1" ,() => {
logger.info(`Express server ready at http://localhost:${config.applicationPort}`); logger.info(`Express server ready at http://localhost:${config.applicationPort}`);
resolve(server); resolve(serverInstance);
}); });
server.on("error", (err) => { serverInstance.on("error", (err) => {
logger.error("Express server error:", err); logger.error("Express server error:", err);
reject(err); reject(err);
}); });
/**
* Clean exit function
* - Stop Log
* - Stop Express Server
* - Stop Electron App
*/
const gracefulExit = () => {
logSessionEnd();
server.close(() => {
logger.info("Express server closed cleanly.");
process.exit();
});
};
process.on("SIGINT", gracefulExit);
process.on("SIGTERM", gracefulExit);
}); });
} }
module.exports = { startServer }; /**
* Clean exit function called by Electron
*/
function stopServer() {
if (serverInstance) {
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");
}
}
module.exports = { startServer, stopServer };

View File

@@ -7,6 +7,7 @@ const notify = require("../helpers/notify.helpers");
const path = require("path"); const path = require("path");
const { isSafePath } = require("../helpers/validation.helpers"); const { isSafePath } = require("../helpers/validation.helpers");
const {reloadFeatures} = require("../../config"); const {reloadFeatures} = require("../../config");
const {isUrlPlaylist} = require("../helpers/playlist.helpers");
/** /**
* Reference to the currently running yt-dlp process. * Reference to the currently running yt-dlp process.
@@ -18,19 +19,6 @@ const {reloadFeatures} = require("../../config");
*/ */
let currentDownloadProcess = null; let currentDownloadProcess = null;
/**
* Determines whether a URL targets a playlist.
*
* The detection is based on the presence of the YouTube
* playlist query parameter (`list`).
*
* @param {string} url - URL to inspect.
* @returns {boolean} True if the URL appears to be a playlist.
*/
function isPlaylistUrl(url) {
return url.includes("?list=") || url.includes("&list=");
}
/** /**
* Creates a dedicated download folder for a playlist. * Creates a dedicated download folder for a playlist.
* *
@@ -77,7 +65,7 @@ function createPlaylistFolder(basePath, playlistTitle) {
} }
logger.error(`Could not find available playlist folder after 1000 attempts`); logger.error(`Could not find available playlist folder after 1000 attempts`);
throw new Error("Unable to create playlist folder"); logger.error("Unable to create playlist folder");
} catch (err) { } catch (err) {
logger.warn(`Failed to create playlist folder with title "${sanitizedTitle}": ${err.message}`); logger.warn(`Failed to create playlist folder with title "${sanitizedTitle}": ${err.message}`);
@@ -133,9 +121,11 @@ function createPlaylistFolder(basePath, playlistTitle) {
* download has completed successfully. * download has completed successfully.
*/ */
function fetchDownload(options, progressListeners, speedListeners, stageListeners, playlistInfoListeners) { 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) => { return new Promise((resolve, reject) => {
const userConfig = reloadFeatures(); const userConfig = reloadFeatures();
logger.info(`CONFIG createPlaylistFolders: ${userConfig.createPlaylistFolders}`); logger.info(`CONFIG createPlaylistFolders: ${userConfig.createPlaylistFolders}`);
@@ -160,7 +150,7 @@ function fetchDownload(options, progressListeners, speedListeners, stageListener
} }
// Détecte si c'est une playlist et crée un dossier approprié // Détecte si c'est une playlist et crée un dossier approprié
const isPlaylist = options.playlistTitle || isPlaylistUrl(options.url); const isPlaylist = options.playlistTitle || isUrlPlaylist(options.url);
if (isPlaylist && userConfig.createPlaylistFolders) { if (isPlaylist && userConfig.createPlaylistFolders) {
try { try {
@@ -183,7 +173,8 @@ function fetchDownload(options, progressListeners, speedListeners, stageListener
logger.info(`[yt-dlp args] ${args.join(" ")}`); 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; currentDownloadProcess = child;
child.on("error", err => reject(new Error(`YT-DLP Error : ${err.message}`))); child.on("error", err => reject(new Error(`YT-DLP Error : ${err.message}`)));
@@ -270,21 +261,36 @@ function fetchDownload(options, progressListeners, speedListeners, stageListener
function cancelDownload() { function cancelDownload() {
if (currentDownloadProcess) { if (currentDownloadProcess) {
logger.info("Cancelling download and killing all child processes..."); logger.info("Cancelling download and killing all child processes...");
const pid = currentDownloadProcess.pid;
// Force kill the process and all its children with SIGKILL
try { try {
// Try to kill with SIGKILL on Windows (process group) or Unix if (process.platform === "win32") {
if (process.platform === 'win32') { if (pid) {
// On Windows, kill the process tree require("child_process").execSync(`taskkill /PID ${pid} /T /F`, {
require('child_process').execSync(`taskkill /PID ${currentDownloadProcess.pid} /T /F`, { stdio: 'ignore' }); stdio: "ignore",
});
}
} else { } else {
// On Unix, send SIGKILL to process group if (pid) {
process.kill(-currentDownloadProcess.pid, 'SIGKILL'); try {
process.kill(-pid, "SIGKILL");
} catch (err) {
logger.warn(`Error killing process group: ${err.message}`);
currentDownloadProcess.kill("SIGKILL");
}
} else {
currentDownloadProcess.kill("SIGKILL");
}
} }
} catch (err) { } catch (err) {
logger.warn(`Error killing process: ${err.message}`); logger.warn(`Error killing process: ${err.message}`);
// Fallback to regular kill try {
currentDownloadProcess.kill('SIGKILL'); if (currentDownloadProcess && !currentDownloadProcess.killed) {
currentDownloadProcess.kill("SIGKILL");
}
} catch (killErr) {
logger.warn(`Fallback kill failed: ${killErr.message}`);
}
} }
currentDownloadProcess = null; currentDownloadProcess = null;

View File

@@ -1,7 +1,9 @@
const { execFile } = require("child_process"); const { execFile } = require("child_process");
const { userYtDlp, denoPath } = require("../helpers/path.helpers"); const { userYtDlp, denoPath } = require("../helpers/path.helpers");
const getUserBrowser = require("../helpers/getBrowser.helpers"); const {getUserBrowser} = require("../helpers/getBrowser.helpers");
const { logger } = require("../logger"); const { logger } = require("../logger");
const {parseVideo, parsePlaylist} = require("../helpers/parseInfo.helpers");
const {isUrlPlaylist} = require("../helpers/playlist.helpers");
/** /**
* Retrieves metadata for a video or playlist using yt-dlp. * Retrieves metadata for a video or playlist using yt-dlp.
@@ -21,33 +23,44 @@ const { logger } = require("../logger");
* *
*/ */
function fetchInfo(url) { function fetchInfo(url) {
/** /**
* Base yt-dlp arguments used to retrieve metadata * Base yt-dlp arguments used to retrieve metadata
* without downloading media files. * without downloading media files.
*/ */
const metadataArgs = [ const metadataArgs = [
"--js-runtimes", `deno:${denoPath}`, "--js-runtimes",
`deno:${denoPath}`,
"--dump-single-json", "--dump-single-json",
"--ignore-errors", "--ignore-errors",
"--yes-playlist", "--yes-playlist",
"--cookies-from-browser",`${getUserBrowser()}`, "--cookies-from-browser",
"--extractor-args","youtube:player_client=default", `${getUserBrowser()}`,
"--extractor-args",
"youtube:player_client=default",
"--ignore-no-formats-error", "--ignore-no-formats-error",
]; ];
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
execFile(
execFile(userYtDlp,[...metadataArgs , url], userYtDlp,
{ timeout: 30_000, // 30s, if more timeout [...metadataArgs, url],
maxBuffer: 10 * 1024 * 1024 }, // Maximum response size: 10 MB (Node.js default is much smaller) {
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) => { (error, stdout, stderr) => {
if (stderr) { if (stderr) {
const lower = stderr.toLowerCase(); const lower = stderr?.toLowerCase();
if (lower.includes("sign in to confirm") || lower.includes("failed to decrypt") || lower.includes("could not copy")) { if (
return reject(new Error(`Unable to extract cookies from ${getUserBrowser()}. Please log in to this browser and try again.`)); 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.`
)
);
} }
} }
@@ -66,8 +79,61 @@ function fetchInfo(url) {
logger.error(`JSON Parsing Error: ${e.message}`); logger.error(`JSON Parsing Error: ${e.message}`);
reject(new Error("JSON unreadable.")); 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};

View File

@@ -1,8 +1,8 @@
const path = require("path"); const path = require("path");
jest.mock("../../server/helpers/getBrowser.helpers.js", () => jest.mock("../../server/helpers/getBrowser.helpers.js", () => ({
jest.fn(() => "firefox") getUserBrowser: jest.fn(() => "firefox"),
); }));
jest.mock("../../server/helpers/path.helpers.js", () => ({ jest.mock("../../server/helpers/path.helpers.js", () => ({
ffmpegPath: "/mock/ffmpeg", ffmpegPath: "/mock/ffmpeg",
@@ -11,7 +11,6 @@ jest.mock("../../server/helpers/path.helpers.js", () => ({
jest.mock("../../config.js", () => ({ jest.mock("../../config.js", () => ({
configFeatures: { configFeatures: {
autoUpdate: false,
discordRPC: false, discordRPC: false,
customTopBar: false, customTopBar: false,
autoCheckInfo: false, autoCheckInfo: false,

43
test/unit/config.test.js Normal file
View 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"
);
});
});

View 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",
})
);
});
});
});

View 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();
});
});

View 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");
});
});
});

View File

@@ -1,6 +1,6 @@
const mockShow = jest.fn(); const mockShow = jest.fn();
const mockOn = jest.fn(); const mockOn = jest.fn();
const mockOpenPath = jest.fn(); const mockOpenPath = jest.fn().mockResolvedValue("");
const mockOpenExternal = jest.fn(); const mockOpenExternal = jest.fn();
jest.mock("electron", () => ({ jest.mock("electron", () => ({
@@ -71,13 +71,16 @@ describe("notify.helpers", () => {
expect(mockShow).toHaveBeenCalled(); expect(mockShow).toHaveBeenCalled();
}); });
test("opens folder when notification is clicked", () => { test("opens folder when notification is clicked", (done) => {
notifyDownloadFinished("/downloads"); notifyDownloadFinished("/downloads");
const callback = mockOn.mock.calls[0][1]; const callback = mockOn.mock.calls[0][1];
callback(); callback();
setTimeout(() => {
expect(shell.openPath).toHaveBeenCalledWith("/downloads"); expect(shell.openPath).toHaveBeenCalledWith("/downloads");
done();
}, 200);
}); });
}); });
@@ -101,7 +104,7 @@ describe("notify.helpers", () => {
callback(); callback();
expect(shell.openExternal).toHaveBeenCalledWith( 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(); callback();
expect(shell.openExternal).toHaveBeenCalledWith( expect(shell.openExternal).toHaveBeenCalledWith(
"https://youtube.com/shorts/cN9f4s1Mf88?si=519QCVd_-fzJqRf1" "https://www.firefox.com/en-US/download/"
); );
}); });
}); });

View 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([]);
});
});
});

View 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();
});
});

View 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);
});
});
});