mirror of
https://github.com/MasterAcnolo/Freedom-Loader.git
synced 2026-11-13 23:19:21 +01:00
Compare commits
428 Commits
dev
...
4f03c5876f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f03c5876f | ||
|
|
a5f05f32af | ||
|
|
0d1b1e4919 | ||
|
|
0e77a94ec7 | ||
|
|
7f481d20b2 | ||
|
|
1ff32ca021 | ||
|
|
90d8a9f65d | ||
|
|
d5965c2ce0 | ||
|
|
15f59a6093 | ||
|
|
8b31436e25 | ||
|
|
7d5203001b | ||
|
|
60709c4fad | ||
|
|
2fb6509edc | ||
|
|
eebce83976 | ||
|
|
a56a070bc8 | ||
|
|
e1ef7032bb | ||
|
|
e7333b7b6b | ||
|
|
e2b51d32d1 | ||
|
|
81515c6ab8 | ||
|
|
c88189b44f | ||
|
|
b745ebd29f | ||
|
|
13ee4c170b | ||
|
|
0218664c5a | ||
|
|
6afc5e06d6 | ||
|
|
519dcad6e6 | ||
|
|
ac94d68224 | ||
|
|
0f876fcb70 | ||
|
|
a6c11ba7c4 | ||
|
|
44132dd7b5 | ||
|
|
15ab6892b7 | ||
|
|
725b6f0ada | ||
|
|
ad0e4f858b | ||
|
|
68108e3a66 | ||
|
|
eff2affd42 | ||
|
|
87cf05643f | ||
|
|
ce14b8abd7 | ||
|
|
44ed1bbf0e | ||
|
|
e0d017d9fd | ||
|
|
c3ea1ac0bd | ||
|
|
238d4ea717 | ||
|
|
d879a5eb1d | ||
|
|
40bea35286 | ||
|
|
83ac76381a | ||
|
|
6ff0c71be7 | ||
|
|
fd0b339bcc | ||
|
|
bc5faf38b6 | ||
|
|
e744e55475 | ||
|
|
a581c9309d | ||
|
|
61040769ee | ||
|
|
06f6a30797 | ||
|
|
a2653eef57 | ||
|
|
89573d11f7 | ||
|
|
f0c28a7f5e | ||
|
|
f3c37c2498 | ||
|
|
8fe4e7c38a | ||
|
|
912f4042e5 | ||
|
|
5dc61b4a15 | ||
|
|
5fb49b0c9d | ||
|
|
75fdf91fcd | ||
|
|
011e0a703a | ||
|
|
5aebc78d44 | ||
|
|
60137a15dc | ||
|
|
d05b9e2511 | ||
|
|
ebb48f4f8b | ||
|
|
8be47b9821 | ||
|
|
ca8b107f61 | ||
|
|
f25d07cb83 | ||
|
|
49e1e1b507 | ||
|
|
23d11d8933 | ||
|
|
9f88fe19c3 | ||
|
|
9b4b0306c7 | ||
|
|
96401a6e1f | ||
|
|
0c7103c5c9 | ||
|
|
798084e3bf | ||
|
|
5c4e6669fe | ||
|
|
00c542925c | ||
|
|
24460cf0b5 | ||
|
|
a3c1b1d753 | ||
|
|
5ade3fe144 | ||
|
|
577069b471 | ||
|
|
a4da47647a | ||
|
|
c5b29a0647 | ||
|
|
90e1a5fde7 | ||
|
|
dcfea2073c | ||
|
|
0be1ef1710 | ||
|
|
da393b19d9 | ||
|
|
1c27cc19cf | ||
|
|
ff7366c46c | ||
|
|
459d4c6df4 | ||
|
|
d2ca3e99d2 | ||
|
|
8ad3ea55cc | ||
|
|
71dbd104f8 | ||
|
|
57a5defb08 | ||
|
|
25c78ff8b7 | ||
|
|
792a59d8d7 | ||
|
|
b13c98dda2 | ||
|
|
1f103c20b9 | ||
|
|
70d62959ca | ||
|
|
83ca963502 | ||
|
|
9fb9295093 | ||
|
|
74fcdbb738 | ||
|
|
32c9a66a35 | ||
|
|
45e0840fea | ||
|
|
19b3f8b947 | ||
|
|
cc5bde5b48 | ||
|
|
9a48ac05e3 | ||
|
|
5750444655 | ||
|
|
fdb22f2342 | ||
|
|
760550e521 | ||
|
|
334e83ae1f | ||
|
|
5d776594fc | ||
|
|
adb36e3fa2 | ||
|
|
87945fba80 | ||
|
|
2234f9ed81 | ||
|
|
7cd3b4be06 | ||
|
|
a42b020b96 | ||
|
|
c1e70dae80 | ||
|
|
6877c51c0e | ||
|
|
5a41090175 | ||
|
|
5983f21759 | ||
|
|
ee9e02410f | ||
|
|
909480083e | ||
|
|
ca0492ed04 | ||
|
|
6c73dd75be | ||
|
|
556dccec08 | ||
|
|
f720d7c889 | ||
|
|
bb23f37fb5 | ||
|
|
220e81d8b2 | ||
|
|
44d8772827 | ||
|
|
335fb9652b | ||
|
|
243b98cbf9 | ||
|
|
23da75a17b | ||
|
|
756d2c048e | ||
|
|
659c1d4cf9 | ||
|
|
443fd31170 | ||
|
|
6d8095ceb2 | ||
|
|
c18cb6c8aa | ||
|
|
a3c5025434 | ||
|
|
3d7986cb87 | ||
|
|
3f8701567f | ||
|
|
ccb16c49d5 | ||
|
|
86d9ac3592 | ||
|
|
43d87c2b47 | ||
|
|
19aa8ed8fa | ||
|
|
3ddfaa999e | ||
|
|
3d7f3b7481 | ||
|
|
cfe2e17313 | ||
|
|
dedf10c6b3 | ||
|
|
ac0b4a0750 | ||
|
|
29ca2ea496 | ||
|
|
2a3905bdfb | ||
|
|
d3755ab57d | ||
|
|
9824e107fe | ||
|
|
e437722937 | ||
|
|
fd8c4ae33c | ||
|
|
967144bd55 | ||
|
|
cd8eb05020 | ||
|
|
590060e504 | ||
|
|
26c7d4d2ee | ||
|
|
caa987fa42 | ||
|
|
ccb090e4b2 | ||
|
|
8d070ed10d | ||
|
|
343dbb26a2 | ||
|
|
fef4f6de36 | ||
|
|
1a61972750 | ||
|
|
45ae5d7f98 | ||
|
|
fecb123d0e | ||
|
|
5c62ed081a | ||
|
|
8f0f31041d | ||
|
|
0e48ab3b4c | ||
|
|
d4c33bcf2d | ||
|
|
2f105f5145 | ||
|
|
b7a193b062 | ||
|
|
65364d3095 | ||
|
|
c356e2e30d | ||
|
|
e45810b54c | ||
|
|
55db30febc | ||
|
|
163faec221 | ||
|
|
5398fed928 | ||
|
|
f959171db5 | ||
|
|
01b6bcd02c | ||
|
|
0ff4715b35 | ||
|
|
745a73a5b0 | ||
|
|
a69114edce | ||
|
|
489a4766fa | ||
|
|
adcee62386 | ||
|
|
b867d819b7 | ||
|
|
31d8b20a76 | ||
|
|
42ae0e7204 | ||
|
|
43869420c0 | ||
|
|
6c9d296fbd | ||
|
|
008428f10a | ||
|
|
ae7d338474 | ||
|
|
12ff25cc4b | ||
|
|
f31767e7e7 | ||
|
|
7c4f2841e1 | ||
|
|
a27b3555f6 | ||
|
|
c451ddd042 | ||
|
|
da6311055f | ||
|
|
945efb2059 | ||
|
|
ab2881f8e9 | ||
|
|
f23811fff4 | ||
|
|
19ab77501b | ||
|
|
37eeed583a | ||
|
|
caf08efb90 | ||
|
|
7ff998e4df | ||
|
|
0c1e8fe899 | ||
|
|
4158e8c6e3 | ||
|
|
653319e408 | ||
|
|
0a4f14fba7 | ||
|
|
fb4571fc51 | ||
|
|
c11fd40681 | ||
|
|
cbfde8b761 | ||
|
|
c774821cc4 | ||
|
|
2ed15a32bc | ||
|
|
c252853603 | ||
|
|
cfdddec073 | ||
|
|
97a63ab791 | ||
|
|
a0e00341e1 | ||
|
|
26d42491ce | ||
|
|
d678ba2f12 | ||
|
|
55b400eb05 | ||
|
|
0a6313b1f3 | ||
|
|
b4312376bb | ||
|
|
5ffb143a1a | ||
|
|
ee24473077 | ||
|
|
2b98a63f92 | ||
|
|
2e594d87e8 | ||
|
|
b343c2869d | ||
|
|
094b93f4fa | ||
|
|
cdc8a623c0 | ||
|
|
2b43c00a3a | ||
|
|
14928798d4 | ||
|
|
e5fb44fc6b | ||
|
|
9f9e4cd1d8 | ||
|
|
45fe7336fe | ||
|
|
b909e7d917 | ||
|
|
ce62748ade | ||
|
|
d4ecc36f6b | ||
|
|
fee364610e | ||
|
|
492af8525b | ||
|
|
fb58b5ae06 | ||
|
|
577034b0bd | ||
|
|
6fc452edb4 | ||
|
|
086ca49599 | ||
|
|
be1de35da8 | ||
|
|
3c1f57667f | ||
|
|
5997cd20b4 | ||
|
|
6220c9400e | ||
|
|
48a16b927f | ||
|
|
87545d77b4 | ||
|
|
4e0fe29c20 | ||
|
|
f8e3bc843d | ||
|
|
b3f1edaabd | ||
|
|
ef784dd19c | ||
|
|
69bbcee885 | ||
|
|
60b4a99506 | ||
|
|
c1030400f5 | ||
|
|
3f508da501 | ||
|
|
0437cb61a2 | ||
|
|
1372582c54 | ||
|
|
070c3d507f | ||
|
|
3e9c545952 | ||
|
|
e3845e1849 | ||
|
|
91703d6a91 | ||
|
|
cff3101b03 | ||
|
|
c9a48ec64c | ||
|
|
e6419a9759 | ||
|
|
9d1d2fc7a8 | ||
|
|
cdca4a82ab | ||
|
|
c2e4e12512 | ||
|
|
96c1c6717d | ||
|
|
abf776175c | ||
|
|
d6de184bfd | ||
|
|
12249c4e8a | ||
|
|
f1fbc3f0c7 | ||
|
|
bb8cf54bf9 | ||
|
|
dccf8bd786 | ||
|
|
793bb79d76 | ||
|
|
f5cdf27ce6 | ||
|
|
627c56cd25 | ||
|
|
783871d131 | ||
|
|
138c05f995 | ||
|
|
5009a479af | ||
|
|
2a95a10113 | ||
|
|
9d6065c80a | ||
|
|
cd8b2bf007 | ||
|
|
1faf74fbc4 | ||
|
|
09163afbcd | ||
|
|
405d141055 | ||
|
|
599e09fa9f | ||
|
|
4954de5f1a | ||
|
|
ac854ae559 | ||
|
|
9944f05139 | ||
|
|
f6558b656a | ||
|
|
bfbf57ba38 | ||
|
|
d0af35377c | ||
|
|
59c263a246 | ||
|
|
42700bd7d8 | ||
|
|
5a9b364b4b | ||
|
|
3619550c74 | ||
|
|
31cc4012ac | ||
|
|
2e329a8584 | ||
|
|
d92dd59f86 | ||
|
|
5bb7ca377d | ||
|
|
9ee9a3574c | ||
|
|
dc50a44682 | ||
|
|
4be5c14999 | ||
|
|
b92ffebdb4 | ||
|
|
7b6e48b09e | ||
|
|
368851b4bb | ||
|
|
fa2da5712d | ||
|
|
7df97d70bb | ||
|
|
5c5f595829 | ||
|
|
5b659e3d8a | ||
|
|
1a526777cc | ||
|
|
b0d99d4c29 | ||
|
|
c7554475a9 | ||
|
|
cab177e8a7 | ||
|
|
f7244a69ac | ||
|
|
c6ed79ed1d | ||
|
|
00e5358bbb | ||
|
|
598c35eb54 | ||
|
|
00c74ea6c6 | ||
|
|
0c415c9106 | ||
|
|
b0495d033d | ||
|
|
d72cc6e0ca | ||
|
|
b59cb9f57f | ||
|
|
af743be5b0 | ||
|
|
024d039701 | ||
|
|
e9a91656bc | ||
|
|
3505606b44 | ||
|
|
815abae0a0 | ||
|
|
004271241e | ||
|
|
8295967ff2 | ||
|
|
053dca365c | ||
|
|
62110b1a5b | ||
|
|
aae9b2866c | ||
|
|
0d8bddbc20 | ||
|
|
2a7d61bab7 | ||
|
|
de71035fa0 | ||
|
|
5368fb9311 | ||
|
|
3d583a9b6f | ||
|
|
94de0a42f1 | ||
|
|
5ba2901273 | ||
|
|
f5888b8382 | ||
|
|
6ece182629 | ||
|
|
777257b71d | ||
|
|
df0156d603 | ||
|
|
68cdad06ab | ||
|
|
ee7c6520b9 | ||
|
|
4ed1609e41 | ||
|
|
b64e61d45d | ||
|
|
b4c2c865d8 | ||
|
|
e32ffd8fa0 | ||
|
|
8be6a932e5 | ||
|
|
6ed59fd272 | ||
|
|
4df1cb207f | ||
|
|
d03e3bc7e5 | ||
|
|
df02acb97f | ||
|
|
6ef04700ab | ||
|
|
766694f330 | ||
|
|
93380b2f36 | ||
|
|
8c969d694f | ||
|
|
f2f3d42e8c | ||
|
|
8d8973acfc | ||
|
|
281bff2379 | ||
|
|
e30f54050f | ||
|
|
0ae808c1a0 | ||
|
|
2598573844 | ||
|
|
27782376a3 | ||
|
|
876f7e1db0 | ||
|
|
db654c5f82 | ||
|
|
751f3d5c3e | ||
|
|
2a8d307bc8 | ||
|
|
ec6963cff3 | ||
|
|
196a07977a | ||
|
|
f20366f0c7 | ||
|
|
f54dc18e85 | ||
|
|
251049c7fe | ||
|
|
5d347967b1 | ||
|
|
d5c506c4e0 | ||
|
|
6718f4133e | ||
|
|
9ff6678730 | ||
|
|
3957450fdb | ||
|
|
25e50a6e7a | ||
|
|
22f935f678 | ||
|
|
58b843630b | ||
|
|
dec3f3272a | ||
|
|
f3252c2548 | ||
|
|
ac28afb17a | ||
|
|
b1a42f44fa | ||
|
|
78eea2fa0f | ||
|
|
4290e6db52 | ||
|
|
0ffc1af218 | ||
|
|
10cefdba19 | ||
|
|
c888946588 | ||
|
|
9026c63979 | ||
|
|
cfc2c5bbaa | ||
|
|
a41da5c4c2 | ||
|
|
b4242e4295 | ||
|
|
1f0650a71c | ||
|
|
87fe8af6f5 | ||
|
|
592d3a5ae4 | ||
|
|
dc847a5a8c | ||
|
|
250e6c3f23 | ||
|
|
1ffe9dcea0 | ||
|
|
b40a4fc033 | ||
|
|
0900e1b485 | ||
|
|
432633af52 | ||
|
|
152ee39535 | ||
|
|
9d824f5c33 | ||
|
|
accb55e538 | ||
|
|
bfb3e0cbdf | ||
|
|
b9460dbceb | ||
|
|
1216e5f10d | ||
|
|
8b4b3c84e0 | ||
|
|
f7abca90cb | ||
|
|
83b31cb645 | ||
|
|
bb967834f0 | ||
|
|
e9f4b80b7b | ||
|
|
abd5295395 | ||
|
|
d4d36c39ca | ||
|
|
95701bf56e | ||
|
|
7b93413050 | ||
|
|
c2c69935b1 | ||
|
|
07485f39ba | ||
|
|
0c99e48950 |
3
.github/ISSUE_TEMPLATE/performance-issue.md
vendored
3
.github/ISSUE_TEMPLATE/performance-issue.md
vendored
@@ -29,9 +29,6 @@ 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]
|
||||||
|
|||||||
4
.github/ISSUE_TEMPLATE/question.md
vendored
4
.github/ISSUE_TEMPLATE/question.md
vendored
@@ -9,8 +9,8 @@ assignees: 'MasterAcnolo'
|
|||||||
|
|
||||||
## Pre-submission Checklist
|
## Pre-submission Checklist
|
||||||
|
|
||||||
- [ ] I have checked the [FAQ](https://freedomloader.acnolo.fr/#/faq)
|
- [ ] I have checked the [FAQ](https://masteracnolo.github.io/Freedom-Loader-Site/pages/faq)
|
||||||
- [ ] I have reviewed the [Wiki](https://freedomloader.acnolo.fr/#/wiki)
|
- [ ] I have reviewed the [Wiki](https://masteracnolo.github.io/Freedom-Loader-Site/pages/wiki)
|
||||||
- [ ] I have searched existing issues
|
- [ ] I have searched existing issues
|
||||||
|
|
||||||
## Question
|
## Question
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Brief description of what this PR does.
|
Brief description of what this PR does.
|
||||||
|
|
||||||
## Related Issue (if applicable)
|
## Related Issue
|
||||||
|
|
||||||
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 or in any Linux Distribution
|
- [ ] I have tested on Windows 10 and/or Windows 11
|
||||||
|
|
||||||
## Screenshots (if applicable)
|
## Screenshots (if applicable)
|
||||||
|
|
||||||
|
|||||||
46
.github/workflows/codeql.yml
vendored
46
.github/workflows/codeql.yml
vendored
@@ -1,46 +0,0 @@
|
|||||||
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}}"
|
|
||||||
18
.gitignore
vendored
18
.gitignore
vendored
@@ -4,32 +4,20 @@
|
|||||||
# Build Output Folder
|
# Build Output Folder
|
||||||
/dist
|
/dist
|
||||||
/out
|
/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
|
||||||
ffprobe
|
ffprobe
|
||||||
deno
|
deno
|
||||||
yt-dlp
|
|
||||||
|
|
||||||
# Windows Executable
|
# Windows Executable
|
||||||
ffmpeg.exe
|
ffmpeg.exe
|
||||||
ffprobe.exe
|
ffprobe.exe
|
||||||
deno.exe
|
deno.exe
|
||||||
yt-dlp.exe
|
|
||||||
@@ -93,5 +93,4 @@ chmod +x resources/binaries/linux/deno
|
|||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- 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
|
|
||||||
@@ -6,7 +6,6 @@ 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/)
|
||||||
@@ -24,7 +23,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` (Windows) or `~/.local/share/FreedomLoader/logs/` (Linux)
|
- Logs from: `C:\Users\[USERNAME]\AppData\Local\FreedomLoader\logs\LOGS-YYYY-MM-DD.log`
|
||||||
- Screenshots if applicable
|
- Screenshots if applicable
|
||||||
|
|
||||||
### 2. Request a Feature
|
### 2. Request a Feature
|
||||||
@@ -44,11 +43,13 @@ 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. Check out our [**Developer Guide (DEVELOPMENT.md)**](./DEVELOPMENT.md) to set up your environment.
|
2. Create a feature branch: `git checkout -b feature/your-feature-name`
|
||||||
3. Create a feature branch following our branching conventions.
|
3. Make your changes
|
||||||
4. Make your changes and test thoroughly on Windows and/or Linux.
|
4. Test thoroughly on Windows 10/11
|
||||||
5. Open a Pull Request using the appropriate template.
|
5. Commit with clear messages: `git commit -m "Add feature X"`
|
||||||
|
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
|
||||||
@@ -71,9 +72,41 @@ Small contributions matter—don't hesitate to submit documentation PRs.
|
|||||||
|
|
||||||
## Development Setup
|
## Development Setup
|
||||||
|
|
||||||
Want to write some code? Awesome!
|
### Prerequisites
|
||||||
|
- Node.js 16.x or higher
|
||||||
|
- npm or yarn
|
||||||
|
- Git
|
||||||
|
- Windows 10/11 (for testing)
|
||||||
|
|
||||||
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.
|
### Setup
|
||||||
|
```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.)
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -81,9 +114,12 @@ Please read our [**Developer Guide (DEVELOPMENT.md)**](./DEVELOPMENT.md) for ins
|
|||||||
|
|
||||||
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
|
||||||
- [ ] Any features wasn't break during the development
|
- [ ] UI changes work on all themes (if applicable)
|
||||||
- [ ] Tested on Windows 10/11 and/or any Linux Distribution
|
- [ ] Tested on Windows 10 and/or Windows 11
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -112,8 +148,8 @@ See [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) for details.
|
|||||||
|
|
||||||
## Questions?
|
## Questions?
|
||||||
|
|
||||||
- Check the [FAQ](https://freedomloader.acnolo.fr/faq)
|
- Check the [FAQ](https://masteracnolo.github.io/Freedom-Loader-Site/faq)
|
||||||
- Review the [Wiki](https://freedomloader.acnolo.fr/wiki)
|
- Review the [Wiki](https://masteracnolo.github.io/Freedom-Loader-Site/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)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -125,4 +161,4 @@ Check out his work: [IG @designmark_studio](https://www.instagram.com/designmark
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Thank you for contributing to Freedom Loader!**
|
**Thank you for contributing to Freedom Loader!**
|
||||||
388
DEVELOPMENT.md
388
DEVELOPMENT.md
@@ -1,388 +0,0 @@
|
|||||||
# 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! 🎉
|
|
||||||
259
README.md
259
README.md
@@ -1,20 +1,22 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<a href="https://freedomloader.acnolo.fr/"><img
|
<a href="https://masteracnolo.github.io/Freedom-Loader-Site/"><img
|
||||||
src="./build/banner.png"
|
src="./build/banner.png"
|
||||||
alt="Banner"
|
alt="Banner"
|
||||||
style="width: 50%;"/></a>
|
style="width: 50%;"/></a>
|
||||||
|
|
||||||
### **A clean, open-source multimedia downloader for Windows and Linux**
|
### **A clean, open-source multimedia downloader for Windows and Linux**
|
||||||
|
|
||||||
[](https://github.com/MasterAcnolo/Freedom-Loader/releases)
|
[](https://github.com/MasterAcnolo/Freedom-Loader/releases)
|
||||||
[](https://www.gnu.org/licenses/gpl-3.0)
|
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||||
[](https://freedomloader.acnolo.fr/)
|
[](https://masteracnolo.github.io/Freedom-Loader-Site/)
|
||||||
[](https://masteracnolo.github.io/Freedom-Loader-Workshop/)
|
[](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.
|
||||||
@@ -23,7 +25,7 @@ The primary goal is to make media downloading accessible to users who want offli
|
|||||||
|
|
||||||
### See related repo:
|
### See related repo:
|
||||||
|
|
||||||
- **[Website](https://github.com/MasterAcnolo/Freedom-Loader-Site/)** - Official project website and documentation
|
- **[Website](https://masteracnolo.github.io/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
|
||||||
@@ -33,12 +35,14 @@ 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
|
||||||
@@ -56,10 +60,10 @@ 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
|
- **Custom window controls** - Frameless window with custom top bar (optional)
|
||||||
- **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
|
||||||
- **Real-time progress tracking** - Live download progress bar with network speed indicator
|
- **Real-time progress tracking** - Live download progress bar with network speed indicator
|
||||||
- **Server-Sent Events (SSE)** - Non-blocking progress updates via event streaming
|
- **Server-Sent Events (SSE)** - Non-blocking progress updates via event streaming
|
||||||
- **Settings panel** - In-app configuration interface with live updates
|
- **Settings panel** - In-app configuration interface with live updates
|
||||||
@@ -76,17 +80,16 @@ The primary goal is to make media downloading accessible to users who want offli
|
|||||||
- **Rate limiting** - Express-based request throttling to prevent server overload
|
- **Rate limiting** - Express-based request throttling to prevent server overload
|
||||||
- **Graceful shutdown** - Proper cleanup of servers, logs, and RPC connections
|
- **Graceful shutdown** - Proper cleanup of servers, logs, and RPC connections
|
||||||
- **System notifications** - Windows notifications on download completion (clickable to open folder)
|
- **System notifications** - Windows notifications on download completion (clickable to open folder)
|
||||||
- **Splash screen** - animated loading screen on startup
|
- **Splash screen** — animated loading screen on startup
|
||||||
- **Stop download** - ability to cancel in-progress downloads
|
- **Stop download** — ability to cancel in-progress downloads
|
||||||
- **Playlist folders** - dedicated folder creation per playlist
|
- **Playlist folders** — dedicated folder creation per playlist
|
||||||
- **Keep Playlist Order** - Add an Index before the title, could be useful for an album for example.
|
|
||||||
- **System Tray** - Could minimize the application in the system Tray, allow it to run in the background.
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
[](https://copr.fedorainfracloud.org/coprs/masteracnolo/freedom-loader/)
|
[](https://copr.fedorainfracloud.org/coprs/masteracnolo/freedom-loader/)
|
||||||
[](https://github.com/MasterAcnolo/Freedom-Loader/releases)
|
[](https://github.com/MasterAcnolo/Freedom-Loader/releases)
|
||||||
[](https://snapcraft.io/freedom-loader)
|
|
||||||
|
[//]: # ([](https://snapcraft.io/freedom-loader))
|
||||||
[](https://github.com/MasterAcnolo/Freedom-Loader/releases)
|
[](https://github.com/MasterAcnolo/Freedom-Loader/releases)
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
@@ -103,7 +106,7 @@ The primary goal is to make media downloading accessible to users who want offli
|
|||||||
|
|
||||||
### Linux
|
### Linux
|
||||||
|
|
||||||
#### Fedora (via Copr - recommended)
|
#### Fedora (via Copr — recommended)
|
||||||
|
|
||||||
Installing via Copr lets `dnf` handle future updates automatically:
|
Installing via Copr lets `dnf` handle future updates automatically:
|
||||||
|
|
||||||
@@ -119,11 +122,14 @@ sudo dnf install freedom-loader
|
|||||||
sudo apt install ./freedom-loader_1.x.x_amd64.deb
|
sudo apt install ./freedom-loader_1.x.x_amd64.deb
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Snap (any distro with snapd)
|
[//]: # (#### Snap (any distro with snapd))
|
||||||
|
|
||||||
```bash
|
[//]: # ()
|
||||||
sudo snap install freedom-loader
|
[//]: # (```bash)
|
||||||
```
|
|
||||||
|
[//]: # (sudo snap install freedom-loader)
|
||||||
|
|
||||||
|
[//]: # (```)
|
||||||
|
|
||||||
#### RPM (Fedora/RHEL/openSUSE, without Copr)
|
#### RPM (Fedora/RHEL/openSUSE, without Copr)
|
||||||
|
|
||||||
@@ -151,7 +157,6 @@ 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.
|
||||||
@@ -177,8 +182,7 @@ The application supports various configuration options:
|
|||||||
- Custom download directory selection
|
- Custom download directory selection
|
||||||
- Discord Rich Presence activation
|
- Discord Rich Presence activation
|
||||||
- Theme customization
|
- Theme customization
|
||||||
- Log level
|
- Log level configuration
|
||||||
- And somes to see in the list [Configuration Options](#configuration-options) available
|
|
||||||
|
|
||||||
### Troubleshooting
|
### Troubleshooting
|
||||||
|
|
||||||
@@ -210,9 +214,6 @@ 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
|
||||||
@@ -222,21 +223,20 @@ defaults from `config/config.default.json`. Existing values and unknown custom k
|
|||||||
|
|
||||||
#### Linux
|
#### Linux
|
||||||
```
|
```
|
||||||
~/.config/Freedom\ Loader/config.json
|
~/.config/Freedom Loader/config.json
|
||||||
```
|
```
|
||||||
|
|
||||||
### Available Options
|
### Available Options
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"systemTray": true,
|
"autoUpdate": 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,
|
|
||||||
"createPlaylistFolders": true,
|
"createPlaylistFolders": true,
|
||||||
"customCodec": "h264",
|
"customCodec": "h264",
|
||||||
"logSystem": true,
|
"logSystem": true,
|
||||||
@@ -244,7 +244,6 @@ defaults from `config/config.default.json`. Existing values and unknown custom k
|
|||||||
"downloadSystem": true,
|
"downloadSystem": true,
|
||||||
"notifySystem": true,
|
"notifySystem": true,
|
||||||
"enableHardwareAcceleration": true,
|
"enableHardwareAcceleration": true,
|
||||||
"showChanglogsAfterUpdate": true,
|
|
||||||
"theme": "dark"
|
"theme": "dark"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -253,23 +252,21 @@ defaults from `config/config.default.json`. Existing values and unknown custom k
|
|||||||
|
|
||||||
| Option | Type | Default | Description |
|
| Option | Type | Default | Description |
|
||||||
|------------------------------|---------|----------|------------------------------------------------------------|
|
|------------------------------|---------|----------|------------------------------------------------------------|
|
||||||
| `systemTray` | boolean | `false` | Allow app to minimize on close |
|
| `autoUpdate` | boolean | `true` | Enable automatic application updates |
|
||||||
| `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 |
|
| `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
|
||||||
@@ -283,7 +280,95 @@ defaults from `config/config.default.json`. Existing values and unknown custom k
|
|||||||
- `theora` - Theora (legacy open codec)
|
- `theora` - Theora (legacy open codec)
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> Some configuration changes may require an application restart to take effect.
|
> 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
|
||||||
|
│ ├── windowManager.js
|
||||||
|
│ └── ytDlpUpdater.js
|
||||||
|
├── build/ # Build resources and assets
|
||||||
|
├── config/ # Configuration files
|
||||||
|
│ ├── config.default.json
|
||||||
|
│ └── config.dev.json
|
||||||
|
├── public/ # Frontend assets and UI
|
||||||
|
│ ├── index.html
|
||||||
|
│ ├── splash.html
|
||||||
|
│ ├── 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
|
||||||
|
│ │ ├── checkbox.css
|
||||||
|
│ │ ├── editpathbutton.css
|
||||||
|
│ │ ├── loader.css
|
||||||
|
│ │ ├── progressBar.css
|
||||||
|
│ │ └── toast.css
|
||||||
|
│ └── layout/ # Layout styles
|
||||||
|
│ ├── container.css
|
||||||
|
│ ├── form.css
|
||||||
|
│ ├── header.css
|
||||||
|
│ ├── settingsPanel.css
|
||||||
|
│ ├── topbar.css
|
||||||
|
│ └── videoinfo.css
|
||||||
|
├── resources/ # Internal resources (binaries)
|
||||||
|
├── 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
|
||||||
|
│ │ └── dark.theme.json
|
||||||
|
│ └── Light/ # Default light theme
|
||||||
|
│ └── light.theme.json
|
||||||
|
├── main.js # Electron main process entry point
|
||||||
|
├── preload.js # Electron preload script (IPC bridge)
|
||||||
|
├── config.js # Global configuration loader
|
||||||
|
├── package.json # Project metadata and dependencies
|
||||||
|
├── CODE_OF_CONDUCT.md # Community guidelines
|
||||||
|
├── CONTRIBUTING.md # Contribution guidelines
|
||||||
|
├── LICENSE # GPLv3 license
|
||||||
|
└── README.md # This file
|
||||||
|
```
|
||||||
|
|
||||||
### Architecture Overview
|
### Architecture Overview
|
||||||
|
|
||||||
@@ -308,7 +393,7 @@ Freedom Loader includes a web-based theme creator available at [Freedom Loader W
|
|||||||
### Installing a custom theme
|
### Installing a custom theme
|
||||||
1. Download a `.zip` theme file
|
1. Download a `.zip` theme file
|
||||||
2. Drop it in the `theme/` folder of your Freedom Loader installation
|
2. Drop it in the `theme/` folder of your Freedom Loader installation
|
||||||
3. Restart the app - your theme appears in the settings panel
|
3. Restart the app — your theme appears in the settings panel
|
||||||
|
|
||||||
## Technology Stack
|
## Technology Stack
|
||||||
|
|
||||||
@@ -331,7 +416,28 @@ Freedom Loader includes a web-based theme creator available at [Freedom Loader W
|
|||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
If you are interested by running a local non compiled version of Freedom Loader or just interested by contributing, see [DEVELOPMENT](./DEVELOPMENT.md)
|
### Prerequisites
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
@@ -342,23 +448,44 @@ You must download the required binaries and place them in the `resources` folder
|
|||||||
|
|
||||||
#### Required binaries
|
#### Required binaries
|
||||||
|
|
||||||
See [BINARIES.md](./BINARIES.md) for how to setup the binaries.
|
- **Deno**
|
||||||
|
- 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, imperative commit messages (e.g., "Add playlist indexing" not "Added")
|
- Write clear commit messages
|
||||||
- Test thoroughly on both Windows and Linux before submitting changes
|
- Test thoroughly before submitting changes
|
||||||
- Update documentation (README, DEVELOPMENT.md) when adding features
|
- Update documentation when adding features
|
||||||
- Maintain compatibility with:
|
- Maintain compatibility with Windows 10+ and Linux
|
||||||
- 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://freedomloader.acnolo.fr/Roadmap).
|
See the most recent Roadmap [Here](https://masteracnolo.github.io/Freedom-Loader-Site/Roadmap).
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
@@ -371,29 +498,13 @@ Head over to the [Freedom Loader Workshop repository](https://github.com/MasterA
|
|||||||
|
|
||||||
### Bug Reports
|
### Bug Reports
|
||||||
|
|
||||||
You can report bugs either using the built-in application system or via the GitHub Issues system.
|
Use the GitHub Issues system and include:
|
||||||
|
|
||||||
#### 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 or recordings if applicable
|
- Screenshots if applicable
|
||||||
|
|
||||||
### Feature Requests
|
### Feature Requests
|
||||||
|
|
||||||
@@ -412,7 +523,15 @@ 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), [DEVELOPMENT.md](./DEVELOPMENT.md) and [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) before contributing.
|
Please read [CONTRIBUTING.md](CONTRIBUTING.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:
|
||||||
|
|
||||||
|
[](https://paypal.me/axelnicolas25)
|
||||||
|
|
||||||
|
Your support helps maintain infrastructure, develop new features, and keep the project free and ad-free.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
@@ -424,12 +543,12 @@ You are free to use, modify, and redistribute this software under the terms of t
|
|||||||
|
|
||||||
- [yt-dlp](https://github.com/yt-dlp/yt-dlp) for the robust download engine
|
- [yt-dlp](https://github.com/yt-dlp/yt-dlp) for the robust download engine
|
||||||
- [FFmpeg](https://ffmpeg.org/) for media processing capabilities
|
- [FFmpeg](https://ffmpeg.org/) for media processing capabilities
|
||||||
- [Deno](https://deno.com/) for helping me to bypass challenges
|
- [Deno](https://deno.com/) for helping me bypassing challenges
|
||||||
- [Electron](https://www.electronjs.org/) for the desktop application framework
|
- [Electron](https://www.electronjs.org/) for the desktop application framework
|
||||||
- The open-source community for continuous support and contributions
|
- The open-source community for continuous support and contributions
|
||||||
- [@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!
|
|
||||||
- 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
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -438,6 +557,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://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)
|
[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)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -45,9 +45,9 @@ When using Freedom Loader, I recommend:
|
|||||||
|
|
||||||
- **Keep Updated**: Always use the latest version of Freedom Loader — older versions receive no security patches
|
- **Keep Updated**: Always use the latest version of Freedom Loader — older versions receive no security patches
|
||||||
- **Official Sources**: Download only from official sources:
|
- **Official Sources**: Download only from official sources:
|
||||||
- [GitHub Releases](https://github.com/MasterAcnolo/Freedom-Loader/releases)
|
- [GitHub Releases](https://github.com/MasterAcnolo/Freedom-Loader/releases)
|
||||||
- [Fedora COPR](https://copr.fedorainfracloud.org/coprs/masteracnolo/freedom-loader/)
|
- [Fedora COPR](https://copr.fedorainfracloud.org/coprs/masteracnolo/freedom-loader/)
|
||||||
- [Snap Store](https://snapcraft.io/freedom-loader)
|
<!-- - [Snap Store](https://snapcraft.io/freedom-loader) -->
|
||||||
- **Verify Downloads**: Check that installers are from one of the above sources
|
- **Verify Downloads**: Check that installers are from one of the above sources
|
||||||
- **Antivirus**: Keep your antivirus software up to date
|
- **Antivirus**: Keep your antivirus software up to date
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const { autoUpdater } = require("electron-updater");
|
const { autoUpdater } = require("electron-updater");
|
||||||
|
const { dialog } = require("electron");
|
||||||
const { logger } = require("../server/logger");
|
const { logger } = require("../server/logger");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,26 +21,6 @@ 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.
|
||||||
*
|
*
|
||||||
@@ -51,6 +32,7 @@ function getUpdateEnvironment() {
|
|||||||
* @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.
|
||||||
@@ -58,12 +40,23 @@ 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}`);
|
||||||
|
|
||||||
mainWindow?.webContents.send("update-available", {
|
const { response } = await dialog.showMessageBox(mainWindow, {
|
||||||
version: info.version,
|
type: "info",
|
||||||
environment: getUpdateEnvironment(),
|
title: "Update Available",
|
||||||
|
message: `Version ${info.version} is available.`,
|
||||||
|
detail: "Would you like to download and install it now?",
|
||||||
|
buttons: ["Install Update", "Maybe Later"],
|
||||||
|
defaultId: 0,
|
||||||
|
cancelId: 1,
|
||||||
});
|
});
|
||||||
updateAvailable = true;
|
|
||||||
|
|
||||||
|
if (response === 0) {
|
||||||
|
await autoUpdater.downloadUpdate();
|
||||||
|
} else {
|
||||||
|
mainWindow?.webContents.executeJavaScript(
|
||||||
|
`window.showUpdateBadge && window.showUpdateBadge("${info.version}")`
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -73,10 +66,8 @@ 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: progress.percent,
|
percent: Math.round(progress.percent),
|
||||||
bytesPerSecond: progress.bytesPerSecond,
|
speed: progress.bytesPerSecond,
|
||||||
transferred: progress.transferred,
|
|
||||||
total: progress.total,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -87,9 +78,17 @@ 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}`);
|
||||||
|
|
||||||
mainWindow?.webContents.send("update-downloaded", info);
|
const { response } = await dialog.showMessageBox(mainWindow, {
|
||||||
|
type: "info",
|
||||||
|
title: "Update Ready",
|
||||||
|
message: `Version ${info.version} has been downloaded.`,
|
||||||
|
detail: "The application will restart to apply the update.",
|
||||||
|
buttons: ["Install Now", "Later"],
|
||||||
|
defaultId: 0,
|
||||||
|
cancelId: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response === 0) autoUpdater.quitAndInstall();
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -103,16 +102,14 @@ 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 =
|
const isNoUpdateAvailable = /404/.test(msg) || /Cannot find latest.*\.yml/i.test(msg);
|
||||||
/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:", err);
|
logger.error("Auto update error:", msg);
|
||||||
mainWindow?.webContents.send("update-error", msg);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
checkForUpdates();
|
checkForUpdates();
|
||||||
@@ -124,9 +121,12 @@ function initAutoUpdater(mainWindow) {
|
|||||||
*/
|
*/
|
||||||
async function checkForUpdates() {
|
async function checkForUpdates() {
|
||||||
|
|
||||||
// If app is was installed from SNAP or Flatpak.
|
if (process.env.SNAP) {
|
||||||
if (process.env.SNAP || process.env.FLATPAK_ID) return;
|
logger.info("Running as Snap, update managed by snapd, skipping electron-updater");
|
||||||
if (!require("electron").app.isPackaged) return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!require("electron").app.isPackaged) return ;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await autoUpdater.checkForUpdates();
|
await autoUpdater.checkForUpdates();
|
||||||
@@ -144,7 +144,6 @@ 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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,13 +154,4 @@ function installUpdate() {
|
|||||||
autoUpdater.quitAndInstall();
|
autoUpdater.quitAndInstall();
|
||||||
}
|
}
|
||||||
|
|
||||||
function isUpdateAvailable() {
|
module.exports = { initAutoUpdater, downloadUpdate, installUpdate };
|
||||||
return updateAvailable;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
initAutoUpdater,
|
|
||||||
downloadUpdate,
|
|
||||||
installUpdate,
|
|
||||||
isUpdateAvailable,
|
|
||||||
};
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
const RPC = require("discord-rpc");
|
const RPC = require("discord-rpc");
|
||||||
const { logger } = require("../server/logger");
|
const { logger } = require("../server/logger");
|
||||||
const fs = require("fs");
|
|
||||||
const path = require("path");
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Discord Application Client ID used to authenticate the RPC connection.
|
* Discord Application Client ID used to authenticate the RPC connection.
|
||||||
@@ -22,51 +20,6 @@ const rpc = new RPC.Client({ transport: "ipc" });
|
|||||||
*/
|
*/
|
||||||
let intervalId;
|
let intervalId;
|
||||||
|
|
||||||
/**
|
|
||||||
* Automagically fixes Discord RPC on Linux for Sandboxed versions (Flatpak & Snap).
|
|
||||||
* It creates a symlink from the sandbox IPC socket to the standard system location.
|
|
||||||
*
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
function fixLinuxDiscordRPC() {
|
|
||||||
// Only execute on Linux
|
|
||||||
if (process.platform !== "linux") return;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the user's runtime directory (e.g., /run/user/1000).
|
|
||||||
* @type {string}
|
|
||||||
*/
|
|
||||||
const xdgDir = process.env.XDG_RUNTIME_DIR || `/run/user/${process.getuid()}`;
|
|
||||||
const standardSocket = path.join(xdgDir, "discord-ipc-0");
|
|
||||||
|
|
||||||
// If the standard socket already exists (Native Discord), do nothing.
|
|
||||||
if (fs.existsSync(standardSocket)) return;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Paths for known sandboxed Discord IPC sockets.
|
|
||||||
*/
|
|
||||||
const flatpakSocket = path.join(xdgDir, "app/com.discordapp.Discord/discord-ipc-0");
|
|
||||||
const snapSocket = path.join(xdgDir, "snap.discord/discord-ipc-0");
|
|
||||||
|
|
||||||
let targetSocket = null;
|
|
||||||
|
|
||||||
if (fs.existsSync(flatpakSocket)) {
|
|
||||||
targetSocket = flatpakSocket;
|
|
||||||
} else if (fs.existsSync(snapSocket)) {
|
|
||||||
targetSocket = snapSocket;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If a sandboxed socket is found, create the symlink stealthily.
|
|
||||||
if (targetSocket) {
|
|
||||||
try {
|
|
||||||
fs.symlinkSync(targetSocket, standardSocket);
|
|
||||||
logger.info(`Discord RPC bridge created automatically targeting: ${targetSocket}`);
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn(`Failed to create Discord RPC bridge: ${err.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes Discord Rich Presence (RPC) connection.
|
* Initializes Discord Rich Presence (RPC) connection.
|
||||||
*
|
*
|
||||||
@@ -79,11 +32,6 @@ function fixLinuxDiscordRPC() {
|
|||||||
*/
|
*/
|
||||||
function startRPC() {
|
function startRPC() {
|
||||||
|
|
||||||
/**
|
|
||||||
* Patch that create Simlinks for Linux environment;
|
|
||||||
*/
|
|
||||||
fixLinuxDiscordRPC();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rich Presence payload describing current application state
|
* Rich Presence payload describing current application state
|
||||||
* shown in Discord user profile.
|
* shown in Discord user profile.
|
||||||
@@ -95,17 +43,15 @@ 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: "freedomloader.acnolo.fr",
|
state: "masteracnolo.github.io/FreedomLoader",
|
||||||
};
|
};
|
||||||
rpc.clearActivity();
|
rpc.clearActivity()
|
||||||
rpc.setActivity(presence);
|
rpc.setActivity(presence);
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
rpc.login({ clientId }).catch(() => {
|
rpc.login({ clientId }).catch(err => {
|
||||||
// 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");
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,23 +60,28 @@ function startRPC() {
|
|||||||
*
|
*
|
||||||
* Responsibilities:
|
* Responsibilities:
|
||||||
* - Clear any running intervals
|
* - Clear any running intervals
|
||||||
* - Remove current activity from Discord if connected
|
* - Remove current activity from Discord
|
||||||
* - Destroy RPC transport connection
|
* - Destroy RPC transport connection
|
||||||
* - Handle cleanup errors safely
|
* - Handle cleanup errors safely
|
||||||
*/
|
*/
|
||||||
async function stopRPC() {
|
async function stopRPC(){
|
||||||
if (rpc) {
|
|
||||||
try {
|
try {
|
||||||
if (intervalId) clearInterval(intervalId);
|
if (intervalId) clearInterval(intervalId);
|
||||||
|
|
||||||
await rpc.destroy();
|
/**
|
||||||
|
* Ensures RPC connection exists before attempting cleanup.
|
||||||
|
*/
|
||||||
|
if (rpc && rpc.transport) {
|
||||||
|
await rpc.clearActivity()
|
||||||
|
await rpc.destroy();
|
||||||
|
} else{
|
||||||
|
logger.error("Not Able to close RPC")
|
||||||
|
}
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// 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("Error while closing the RPC:", err);
|
||||||
// logger.error("Error while closing the RPC:", err);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
module.exports = { startRPC, stopRPC};
|
module.exports = { startRPC, stopRPC};
|
||||||
|
|||||||
145
app/ipcHandlers.js
Executable file → Normal file
145
app/ipcHandlers.js
Executable file → Normal file
@@ -13,15 +13,11 @@
|
|||||||
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, devMode } = require("../config");
|
const { configFeatures, featuresPath } = 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 {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.
|
||||||
@@ -30,21 +26,19 @@ const { getChangelog, markChangelogSeen } = require("./changelogManager");
|
|||||||
* 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([
|
||||||
"systemTray",
|
"autoUpdate",
|
||||||
"discordRPC",
|
"discordRPC",
|
||||||
|
"customTopBar",
|
||||||
"autoCheckInfo",
|
"autoCheckInfo",
|
||||||
"addThumbnail",
|
"addThumbnail",
|
||||||
"addMetadata",
|
"addMetadata",
|
||||||
"verboseLogs",
|
"verboseLogs",
|
||||||
"autoUpdateYtDlp",
|
|
||||||
"autoDownloadPlaylist",
|
"autoDownloadPlaylist",
|
||||||
"keepPlaylistOrder",
|
|
||||||
"customCodec",
|
"customCodec",
|
||||||
"theme",
|
"theme",
|
||||||
"createPlaylistFolders",
|
"createPlaylistFolders",
|
||||||
"notifySystem",
|
"notifySystem",
|
||||||
"enableHardwareAcceleration",
|
"enableHardwareAcceleration"
|
||||||
"showChanglogsAfterUpdate"
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -67,21 +61,12 @@ 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.
|
||||||
*/
|
*/
|
||||||
@@ -126,17 +111,7 @@ function registerIpcHandlers(getMainWindow) {
|
|||||||
/**
|
/**
|
||||||
* Window minimize request from renderer.
|
* Window minimize request from renderer.
|
||||||
*/
|
*/
|
||||||
ipcMain.on("window-minimize", () => {
|
ipcMain.on("window-minimize", () => getMainWindow()?.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.
|
||||||
@@ -152,6 +127,7 @@ 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.
|
||||||
*/
|
*/
|
||||||
@@ -162,20 +138,20 @@ function registerIpcHandlers(getMainWindow) {
|
|||||||
/**
|
/**
|
||||||
* Opens application logs directory in system file explorer.
|
* Opens application logs directory in system file explorer.
|
||||||
*/
|
*/
|
||||||
ipcMain.on("open-logs", () => logDir && shell.openPath(logDir));
|
ipcMain.on("open-logs", () => logDir && shell.openPath(logDir));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens external website in default browser.
|
* Opens external website in default browser.
|
||||||
*/
|
*/
|
||||||
ipcMain.on("open-website", () =>
|
ipcMain.on("open-website", () =>
|
||||||
shell.openExternal("https://freedomloader.acnolo.fr/")
|
shell.openExternal("https://masteracnolo.github.io/Freedom-Loader-Site/")
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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://freedomloader.acnolo.fr/wiki")
|
shell.openExternal("https://masteracnolo.github.io/Freedom-Loader-Site/wiki")
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -190,13 +166,8 @@ 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.
|
||||||
*/
|
*/
|
||||||
@@ -214,36 +185,6 @@ 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.
|
||||||
*
|
*
|
||||||
@@ -261,46 +202,32 @@ function registerIpcHandlers(getMainWindow) {
|
|||||||
* @returns {boolean} success state
|
* @returns {boolean} success state
|
||||||
*/
|
*/
|
||||||
ipcMain.handle("set-feature", (event, { key, value }) => {
|
ipcMain.handle("set-feature", (event, { key, value }) => {
|
||||||
try {
|
try {
|
||||||
if (!FEATURE_WHITELIST.has(key)) {
|
if (!FEATURE_WHITELIST.has(key)) {
|
||||||
logger.warn(`Rejected feature (not whitelisted): ${key}`);
|
logger.warn(`Rejected feature (not whitelisted): ${key}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (configFeatures[key] === value) {
|
if (configFeatures[key] === value) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
configFeatures[key] = value;
|
||||||
* Dynamically intercepts and applies System Tray state changes at runtime.
|
|
||||||
* Instantiates or destroys the tray icon immediately to prevent window
|
fs.writeFileSync(
|
||||||
* lifecycle desynchronization (e.g., minimizing a window to a non-existent tray).
|
configFolderPath,
|
||||||
*/
|
JSON.stringify(configFeatures, null, 2),
|
||||||
if (key === "systemTray") {
|
"utf-8"
|
||||||
if (value === true) {
|
);
|
||||||
logger.info("System Tray enabled dynamically.");
|
|
||||||
createSystemTray(devMode);
|
logger.info(`Feature updated: ${key} = ${value}`);
|
||||||
} else {
|
return true;
|
||||||
logger.info("System Tray disabled dynamically.");
|
|
||||||
destroyTray();
|
} catch (err) {
|
||||||
|
logger.error(`set-feature failed (${key}): ${err.message}`);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
configFeatures[key] = value;
|
|
||||||
|
|
||||||
fs.writeFileSync(
|
|
||||||
configFolderPath,
|
|
||||||
JSON.stringify(configFeatures, null, 2),
|
|
||||||
"utf-8",
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.info(`Feature updated: ${key} = ${value}`);
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`set-feature failed (${key}): ${err.message}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { registerIpcHandlers };
|
module.exports = { registerIpcHandlers };
|
||||||
@@ -56,30 +56,35 @@ function validateDownloadPath(userPath) {
|
|||||||
if (!userPath) return getDefaultDownloadPath();
|
if (!userPath) return getDefaultDownloadPath();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const absolutePath = path.resolve(userPath);
|
|
||||||
|
|
||||||
if (!fs.existsSync(absolutePath)) {
|
/**
|
||||||
fs.mkdirSync(absolutePath, { recursive: true });
|
* Resolves filesystem symlinks to ensure canonical absolute path.
|
||||||
logger.info(`Download folder created: ${absolutePath}`);
|
* Required to prevent path traversal or alias bypass.
|
||||||
}
|
*/
|
||||||
|
const resolved = fs.realpathSync(path.resolve(userPath));
|
||||||
|
|
||||||
const real = fs.realpathSync(absolutePath);
|
if (!fs.existsSync(resolved)) {
|
||||||
|
fs.mkdirSync(resolved, { recursive: true });
|
||||||
if (!isSafePath(real)) {
|
logger.info(`Download folder created: ${resolved}`);
|
||||||
logger.error("Path not allowed: system folders are blocked!");
|
|
||||||
}
|
|
||||||
|
|
||||||
return real;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
catch (err) {
|
const real = fs.realpathSync(resolved);
|
||||||
/**
|
|
||||||
* Handles invalid filesystem paths, permissions issues, or resolution failures.
|
if (!isSafePath(resolved)) {
|
||||||
* Logs diagnostic information before propagating a sanitized error.
|
throw new Error("Path not allowed: system folders are blocked!");
|
||||||
*/
|
|
||||||
logger.error(`Invalid download path: ${userPath} — ${err.message}`);
|
|
||||||
throw new Error(`Invalid or inaccessible path: ${err.message}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return real;
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles invalid filesystem paths, permissions issues, or resolution failures.
|
||||||
|
* Logs diagnostic information before propagating a sanitized error.
|
||||||
|
*/
|
||||||
|
logger.error(`Invalid download path: ${userPath} — ${err.message}`);
|
||||||
|
throw new Error(`Invalid or inaccessible path: ${err.message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { validateDownloadPath, getDefaultDownloadPath };
|
module.exports = { validateDownloadPath, getDefaultDownloadPath };
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
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};
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
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};
|
|
||||||
117
app/tray.js
117
app/tray.js
@@ -1,117 +0,0 @@
|
|||||||
const {app, Menu, Tray, nativeImage} = require("electron");
|
|
||||||
const path = require("path");
|
|
||||||
const {logger} = require("../server/logger"); // Ajuste le chemin si besoin
|
|
||||||
const fs = require("fs");
|
|
||||||
const {getMainWindow} = require("./windowManager");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Global reference to the Tray instance to prevent garbage collection.
|
|
||||||
* @type {Tray | null}
|
|
||||||
*/
|
|
||||||
let tray = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates and configures the cross-platform System Tray.
|
|
||||||
*
|
|
||||||
* Responsibilities:
|
|
||||||
* - Loads the appropriate icon format based on the operating system.
|
|
||||||
* - Builds the right-click context menu.
|
|
||||||
* - Handles left-click behavior to toggle main window visibility.
|
|
||||||
*
|
|
||||||
* @param {boolean} devMode - Indicates whether the application is running in development mode.
|
|
||||||
* @returns {Tray} The created Tray instance.
|
|
||||||
*/
|
|
||||||
function createSystemTray(devMode) {
|
|
||||||
// Prevent creating multiple tray instances
|
|
||||||
if (tray) return tray;
|
|
||||||
|
|
||||||
const isWin = process.platform === "win32";
|
|
||||||
const iconName = isWin ? "app-icon.ico" : "app-icon-64x64.png";
|
|
||||||
|
|
||||||
// Attempt to load the icon from the local build directory first
|
|
||||||
let iconPath = path.join(__dirname, "..", "build", iconName);
|
|
||||||
|
|
||||||
// Fallback to the extracted resources path if running in production
|
|
||||||
if (!devMode && !fs.existsSync(iconPath)) {
|
|
||||||
iconPath = path.join(process.resourcesPath, iconName);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fs.existsSync(iconPath)) {
|
|
||||||
logger.error(`System Tray icon not found at resolved path: ${iconPath}`);
|
|
||||||
} else {
|
|
||||||
logger.info("System Tray icon resolved successfully.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const icon = nativeImage.createFromPath(iconPath);
|
|
||||||
|
|
||||||
if (icon.isEmpty()) {
|
|
||||||
logger.error("System Tray icon file was found but could not be decoded by Electron (image is empty).");
|
|
||||||
}
|
|
||||||
|
|
||||||
tray = new Tray(icon);
|
|
||||||
tray.setToolTip("Freedom Loader");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* System Tray Context Menu (Right-click action).
|
|
||||||
*/
|
|
||||||
const contextMenu = Menu.buildFromTemplate([
|
|
||||||
{
|
|
||||||
label: "Show Freedom Loader",
|
|
||||||
click: () => {
|
|
||||||
const window = getMainWindow();
|
|
||||||
if (window && !window.isDestroyed()) {
|
|
||||||
window.show();
|
|
||||||
window.focus();
|
|
||||||
logger.info("Window Restored from SystemTray (Using Show Action)");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ type: "separator" },
|
|
||||||
{
|
|
||||||
label: "Quit",
|
|
||||||
click: () => {
|
|
||||||
/**
|
|
||||||
* Flags the application for a deliberate exit,
|
|
||||||
* bypassing the default "minimize to tray" behavior.
|
|
||||||
*/
|
|
||||||
app.isQuitting = true;
|
|
||||||
app.quit();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
tray.setContextMenu(contextMenu);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Left-click behavior: Toggles main window visibility.
|
|
||||||
* but it remains standard practice for Windows and Linux environments.
|
|
||||||
*/
|
|
||||||
tray.on("click", () => {
|
|
||||||
const window = getMainWindow();
|
|
||||||
if (!window || window.isDestroyed()) return;
|
|
||||||
|
|
||||||
if (window.isVisible()) {
|
|
||||||
window.hide();
|
|
||||||
} else {
|
|
||||||
window.show();
|
|
||||||
window.focus();
|
|
||||||
logger.info("Window Restored from SystemTray (Double Click on Tray Icon)");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return tray;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Safely destroys the tray icon to prevent OS-level crashes (especially on Linux/Wayland).
|
|
||||||
* Should be called right before the application quits.
|
|
||||||
*/
|
|
||||||
function destroyTray() {
|
|
||||||
if (tray && !tray.isDestroyed()) {
|
|
||||||
tray.destroy();
|
|
||||||
tray = null;
|
|
||||||
logger.info("System Tray destroyed cleanly.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {createSystemTray, destroyTray};
|
|
||||||
@@ -57,7 +57,7 @@ async function createMainWindow() {
|
|||||||
height: 800,
|
height: 800,
|
||||||
minWidth: 750,
|
minWidth: 750,
|
||||||
minHeight: 800,
|
minHeight: 800,
|
||||||
frame: false, // Force custom topbar for consistent UI across platforms, and less maintenance headaches with native titlebar actions
|
frame: !configFeatures.customTopBar,
|
||||||
devTools: !app.isPackaged,
|
devTools: !app.isPackaged,
|
||||||
show: false,
|
show: false,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
@@ -81,21 +81,6 @@ async function createMainWindow() {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Intercepts the window close event.
|
|
||||||
* If the app is not explicitly quitting (via Tray menu),
|
|
||||||
* cancels the destruction and hides the window instead.
|
|
||||||
*/
|
|
||||||
mainWindow.on('close', (event) => {
|
|
||||||
|
|
||||||
if (!app.isQuitting && config.configFeatures.systemTray) {
|
|
||||||
event.preventDefault();
|
|
||||||
mainWindow.hide();
|
|
||||||
logger.info("Window Minimized in SystemTray (Using Close Button)");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cleans up window reference when the main window is closed.
|
* Cleans up window reference when the main window is closed.
|
||||||
* Prevents memory leaks and allows recreation if needed.
|
* Prevents memory leaks and allows recreation if needed.
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.0 KiB |
82
config.js
82
config.js
@@ -1,10 +1,5 @@
|
|||||||
const packageJson = require("./package.json");
|
const packageJson = require("./package.json");
|
||||||
let app;
|
const { app } = require("electron");
|
||||||
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");
|
||||||
|
|
||||||
@@ -12,7 +7,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 = app && !app.isPackaged ? true : app ? !app.isPackaged || process.env.NODE_ENV === "test" : process.env.NODE_ENV !== "production";
|
const devMode = !app.isPackaged;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves the configuration file path depending on runtime environment.
|
* Resolves the configuration file path depending on runtime environment.
|
||||||
@@ -62,40 +57,6 @@ 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.
|
||||||
*
|
*
|
||||||
@@ -103,31 +64,8 @@ function mergeMissingFeatures(defaults, current) {
|
|||||||
* @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 defaults = JSON.parse(fs.readFileSync(getDefaultConfigPath(), "utf-8"));
|
const raw = fs.readFileSync(featuresPath, "utf-8");
|
||||||
const current = JSON.parse(fs.readFileSync(featuresPath, "utf-8"));
|
return JSON.parse(raw);
|
||||||
const {features, changed} = mergeMissingFeatures(defaults, current);
|
|
||||||
|
|
||||||
if (changed) {
|
|
||||||
fs.writeFileSync(featuresPath, JSON.stringify(features, null, 2), "utf-8");
|
|
||||||
}
|
|
||||||
|
|
||||||
return features;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reloads feature flags from disk.
|
|
||||||
* Use this instead of the cached configFeatures snapshot
|
|
||||||
* when you need the latest user settings at call time.
|
|
||||||
*
|
|
||||||
* @returns {Object} Fresh feature flags from config file
|
|
||||||
*/
|
|
||||||
function reloadFeatures() {
|
|
||||||
try {
|
|
||||||
return loadFeatures();
|
|
||||||
} catch (err) {
|
|
||||||
// Fallback to cached snapshot if file read fails
|
|
||||||
return configFeatures;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -136,7 +74,7 @@ function reloadFeatures() {
|
|||||||
* Note: Changes to the config file are not automatically reflected.
|
* Note: Changes to the config file are not automatically reflected.
|
||||||
* A restart or reload is required.
|
* A restart or reload is required.
|
||||||
*/
|
*/
|
||||||
let configFeatures = loadFeatures();
|
const configFeatures = loadFeatures();
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
/**
|
/**
|
||||||
@@ -164,18 +102,8 @@ module.exports = {
|
|||||||
*/
|
*/
|
||||||
configFeatures,
|
configFeatures,
|
||||||
|
|
||||||
/**
|
|
||||||
* Function to reload the config
|
|
||||||
*/
|
|
||||||
reloadFeatures,
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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,
|
|
||||||
};
|
};
|
||||||
@@ -1,20 +1,18 @@
|
|||||||
{
|
{
|
||||||
"systemTray": false,
|
"autoUpdate": 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,
|
"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"
|
||||||
}
|
}
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
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}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
];
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
testEnvironment: "node",
|
|
||||||
testMatch: ["**/test/unit/**/*.test.js"],
|
|
||||||
collectCoverageFrom: [
|
|
||||||
"server/**/*.js",
|
|
||||||
"app/**/*.js",
|
|
||||||
"!app/autoUpdater.js",
|
|
||||||
],
|
|
||||||
|
|
||||||
modulePathIgnorePatterns: [
|
|
||||||
"<rootDir>/dist",
|
|
||||||
"<rootDir>/release",
|
|
||||||
"<rootDir>/build",
|
|
||||||
],
|
|
||||||
|
|
||||||
watchPathIgnorePatterns: [
|
|
||||||
"<rootDir>/dist",
|
|
||||||
"<rootDir>/release",
|
|
||||||
"<rootDir>/build",
|
|
||||||
],
|
|
||||||
};
|
|
||||||
105
main.js
105
main.js
@@ -13,9 +13,6 @@ 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)
|
||||||
@@ -41,7 +38,17 @@ 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, devMode} = require("./config");
|
const { configFeatures } = require("./config");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On start, enable or disable hardWareAcceleration
|
||||||
|
*/
|
||||||
|
if (!configFeatures.enableHardwareAcceleration){
|
||||||
|
app.disableHardwareAcceleration();
|
||||||
|
logger.info("Disabled Hardware Acceleration")
|
||||||
|
} else {
|
||||||
|
logger.info("Enable Hardware Acceleration")
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* In-memory snapshot of application feature flags.
|
* In-memory snapshot of application feature flags.
|
||||||
@@ -49,20 +56,15 @@ const { configFeatures, devMode} = require("./config");
|
|||||||
* Note: Changes to the config file are not automatically reflected.
|
* Note: Changes to the config file are not automatically reflected.
|
||||||
* A restart could be required.
|
* A restart could be required.
|
||||||
*/
|
*/
|
||||||
if (!configFeatures.enableHardwareAcceleration){
|
const config = require("./config");
|
||||||
app.disableHardwareAcceleration();
|
|
||||||
logger.info("Disabled Hardware Acceleration");
|
|
||||||
} else {
|
|
||||||
logger.info("Enable Hardware Acceleration");
|
|
||||||
}
|
|
||||||
|
|
||||||
if(devMode){
|
if(config.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")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -77,46 +79,8 @@ 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, validateBinaries, defaultDownloadFolder } = require("./server/helpers/path.helpers");
|
const { userThemesPath, initUserThemes, isWindows, validateBinaries, defaultDownloadFolder } = require("./server/helpers/path.helpers");
|
||||||
const {createSystemTray, destroyTray} = require("./app/tray");
|
|
||||||
const { stopServer } = require("./server/server");
|
|
||||||
const {checkReportService} = require("./app/sendReport");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Global flag indicating if the application is intentionally shutting down.
|
|
||||||
* Used across the app to bypass the "minimize to tray on close" behavior.
|
|
||||||
* @type {boolean}
|
|
||||||
*/
|
|
||||||
app.isQuitting = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* State flag to track the asynchronous cleanup process during shutdown.
|
|
||||||
* Prevents the application from exiting before services (RPC, Tray, Logs) are properly closed.
|
|
||||||
* @type {boolean}
|
|
||||||
*/
|
|
||||||
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
|
||||||
@@ -136,21 +100,13 @@ 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 (!devMode && !checkNativeDependencies()) return;
|
if (!config.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
|
||||||
@@ -171,14 +127,9 @@ app.whenReady().then(async () => {
|
|||||||
|
|
||||||
closeSplashWindow();
|
closeSplashWindow();
|
||||||
getMainWindow().show();
|
getMainWindow().show();
|
||||||
|
|
||||||
if (configFeatures.systemTray) {
|
|
||||||
createSystemTray(devMode);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (configFeatures.discordRPC) startRPC();
|
if (configFeatures.discordRPC) startRPC();
|
||||||
|
|
||||||
initAutoUpdater(getMainWindow());
|
if (configFeatures.autoUpdate) initAutoUpdater(getMainWindow());
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error("Boot error:", err);
|
logger.error("Boot error:", err);
|
||||||
@@ -187,20 +138,12 @@ app.whenReady().then(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.on("window-all-closed", () => {
|
app.on("window-all-closed", () => {
|
||||||
if (app.isQuitting || !configFeatures.systemTray) {
|
logger.info("Shutting down...");
|
||||||
logger.info("Shutting down...");
|
app.quit();
|
||||||
app.quit();
|
|
||||||
} else if (process.platform !== "darwin") {
|
|
||||||
logger.info("Main window closed, app running in background (Tray).");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on("before-quit", (event) => {
|
app.on("before-quit", async () => {
|
||||||
app.isQuitting = true;
|
await stopRPC();
|
||||||
|
logger.info("All services stopped. Have a nice day!");
|
||||||
if (!isCleanedUp) {
|
logSessionEnd();
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
cleanShutdown();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
8167
package-lock.json
generated
8167
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
154
package.json
154
package.json
@@ -1,42 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "freedom-loader",
|
"name": "freedom-loader",
|
||||||
"desktopName": "com.masteracnolo.freedomloader",
|
|
||||||
"productName": "Freedom Loader",
|
"productName": "Freedom Loader",
|
||||||
"version": "1.6.5",
|
"version": "1.6.0",
|
||||||
"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": "Freedom Loader",
|
||||||
"homepage": "https://freedomloader.acnolo.fr/",
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/MasterAcnolo/Freedom-Loader.git"
|
|
||||||
},
|
|
||||||
"license": "GPL-3.0-only",
|
|
||||||
"bugs": {
|
|
||||||
"url": "https://github.com/MasterAcnolo/Freedom-Loader/issues"
|
|
||||||
},
|
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean:dist": "rm -rf dist/ && rm -rf srpm-out/",
|
"start": "electron --trace-warnings .",
|
||||||
"start": "electron --no-warnings .",
|
"postinstall": "chmod +x resources/binaries/linux/* 2>/dev/null || true",
|
||||||
"start:warn": "electron --trace-warnings .",
|
|
||||||
"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: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 .\"",
|
|
||||||
"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": "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:publish": "bash scripts/release.sh --publish",
|
|
||||||
"release:dry-run": "bash scripts/release.sh --dry-run",
|
|
||||||
"test": "jest",
|
|
||||||
"test:unit": "jest test/unit",
|
|
||||||
"test:metainfo": "appstreamcli validate package/com.masteracnolo.freedomloader.metainfo.xml",
|
|
||||||
"test:desktop": "desktop-file-validate package/com.masteracnolo.freedomloader.desktop",
|
|
||||||
"lint": "eslint . --format pretty",
|
|
||||||
"lint:fix": "eslint . --fix --format pretty",
|
|
||||||
"update": "npm update"
|
"update": "npm update"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -53,17 +28,8 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@electron/devtron": "^2.1.1",
|
"@electron/devtron": "^2.1.1",
|
||||||
"@eslint/js": "^10.0.1",
|
"electron": "^41.2.0",
|
||||||
"@jest/globals": "^30.4.1",
|
"electron-builder": "^25.1.8"
|
||||||
"@playwright/test": "^1.62.1",
|
|
||||||
"electron": "^44.3.0",
|
|
||||||
"electron-builder": "^26.15.3",
|
|
||||||
"eslint": "^10.10.0",
|
|
||||||
"eslint-formatter-pretty": "^7.1.0",
|
|
||||||
"globals": "^17.12.0",
|
|
||||||
"jest": "^30.4.2",
|
|
||||||
"nodemon": "^3.1.14",
|
|
||||||
"playwright": "^1.62.1"
|
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "com.masteracnolo.freedomloader",
|
"appId": "com.masteracnolo.freedomloader",
|
||||||
@@ -77,20 +43,10 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"files": [
|
"files": [
|
||||||
"app/**/*",
|
"**/*",
|
||||||
"public/**/*",
|
"node_modules/**/*",
|
||||||
"server/**/*",
|
"!config/config.dev.json",
|
||||||
"build/**/*",
|
"!theme/**/*"
|
||||||
"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"
|
||||||
@@ -102,36 +58,46 @@
|
|||||||
"to": "theme"
|
"to": "theme"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "config/config.default.json",
|
"from": "build/confirm-icon.png",
|
||||||
"to": "config/config.default.json"
|
"to": "confirm-icon.png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "build",
|
"from": "build/banner.bmp",
|
||||||
"to": ".",
|
"to": "banner.bmp"
|
||||||
"filter": [
|
},
|
||||||
"app-icon.ico",
|
{
|
||||||
"app-icon-64x64.png",
|
"from": "build/banner.png",
|
||||||
"confirm-icon.png",
|
"to": "banner.png"
|
||||||
"banner.bmp",
|
},
|
||||||
"banner.png",
|
{
|
||||||
"error.png"
|
"from": "build/error.png",
|
||||||
]
|
"to": "error.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "config/config.default.json",
|
||||||
|
"to": "config/config.default.json"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"win": {
|
"win": {
|
||||||
"target": "nsis",
|
"target": "nsis",
|
||||||
"icon": "build/app-icon.ico",
|
"icon": "build/app-icon.ico",
|
||||||
"artifactName": "Freedom-Loader-Setup-${version}.${ext}",
|
"artifactName": "${productName}-Setup-${version}.${ext}",
|
||||||
"extraResources": [
|
"extraResources": [
|
||||||
{
|
{
|
||||||
"from": "resources/binaries/win-32",
|
"from": "resources/binaries/win-32/yt-dlp.exe",
|
||||||
"to": "binaries",
|
"to": "binaries/yt-dlp.exe"
|
||||||
"filter": [
|
},
|
||||||
"yt-dlp.exe",
|
{
|
||||||
"ffmpeg.exe",
|
"from": "resources/binaries/win-32/ffmpeg.exe",
|
||||||
"ffprobe.exe",
|
"to": "binaries/ffmpeg.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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -150,23 +116,27 @@
|
|||||||
"target": [
|
"target": [
|
||||||
"AppImage",
|
"AppImage",
|
||||||
"deb",
|
"deb",
|
||||||
|
"rpm",
|
||||||
"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",
|
|
||||||
"extraResources": [
|
"extraResources": [
|
||||||
{
|
{
|
||||||
"from": "resources/binaries/linux",
|
"from": "resources/binaries/linux/yt-dlp",
|
||||||
"to": "binaries",
|
"to": "binaries/yt-dlp"
|
||||||
"filter": [
|
},
|
||||||
"yt-dlp",
|
{
|
||||||
"ffmpeg",
|
"from": "resources/binaries/linux/ffmpeg",
|
||||||
"ffprobe",
|
"to": "binaries/ffmpeg"
|
||||||
"deno"
|
},
|
||||||
]
|
{
|
||||||
|
"from": "resources/binaries/linux/ffprobe",
|
||||||
|
"to": "binaries/ffprobe"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "resources/binaries/linux/deno",
|
||||||
|
"to": "binaries/deno"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -180,14 +150,18 @@
|
|||||||
"./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": [
|
||||||
"default",
|
"default",
|
||||||
"removable-media"
|
"home",
|
||||||
|
"removable-media",
|
||||||
|
"network",
|
||||||
|
"audio-playback",
|
||||||
|
"desktop",
|
||||||
|
"desktop-legacy",
|
||||||
|
"x11",
|
||||||
|
"wayland"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
[Desktop Entry]
|
|
||||||
Name=Freedom Loader
|
|
||||||
Exec=/opt/freedom-loader/freedom-loader %U
|
|
||||||
Icon=freedom-loader
|
|
||||||
Type=Application
|
|
||||||
Categories=AudioVideo;
|
|
||||||
Comment=Free and open-source GUI for yt-dlp
|
|
||||||
StartupWMClass=com.masteracnolo.freedomloader
|
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</description>
|
</description>
|
||||||
|
|
||||||
<launchable type="desktop-id">com.masteracnolo.freedomloader.desktop</launchable>
|
<launchable type="desktop-id">freedom-loader.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>
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
#!/bin/sh -xe
|
|
||||||
git clone --depth 1 https://github.com/MasterAcnolo/Freedom-Loader.git src
|
|
||||||
cd src
|
|
||||||
npm ci
|
|
||||||
npx electron-builder --linux dir
|
|
||||||
|
|
||||||
VERSION=$(node -p "require('./package.json').version")
|
|
||||||
tar czf "$resultdir/freedom-loader-$VERSION.tar.gz" -C dist linux-unpacked
|
|
||||||
sed "s/__VERSION__/$VERSION/" package/freedom-loader.spec > "$resultdir/freedom-loader.spec"
|
|
||||||
125
preload.js
125
preload.js
@@ -8,26 +8,6 @@ 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
|
||||||
@@ -62,23 +42,14 @@ 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) => ipcRenderer.invoke("set-feature", { key, value }),
|
setFeature: (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.
|
||||||
*
|
*
|
||||||
@@ -96,89 +67,16 @@ 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.
|
||||||
*
|
*
|
||||||
* 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.
|
||||||
*/
|
*/
|
||||||
@@ -228,17 +126,4 @@ 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"),
|
|
||||||
});
|
});
|
||||||
BIN
public/assets/logo/Freedom Loader White.png
Normal file
BIN
public/assets/logo/Freedom Loader White.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
BIN
public/assets/logo/Freedom Loader.png
Normal file
BIN
public/assets/logo/Freedom Loader.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
BIN
public/assets/logo/FreedomLoader2.png
Normal file
BIN
public/assets/logo/FreedomLoader2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
@@ -12,9 +12,7 @@
|
|||||||
|
|
||||||
<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>
|
||||||
|
|
||||||
@@ -23,11 +21,9 @@
|
|||||||
<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">
|
||||||
@@ -60,9 +56,9 @@
|
|||||||
<div class="setting-item" id="theme" style="margin-left: 32px;">
|
<div class="setting-item" id="theme" style="margin-left: 32px;">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<div style="display: flex; align-items: center; gap: 10px;">
|
<div style="display: flex; align-items: center; gap: 10px;">
|
||||||
<span class="setting-title">Theme</span>
|
<span class="setting-title">Thème</span>
|
||||||
|
|
||||||
<select id="themeSelect" data-key="theme" aria-label="Choose the theme">
|
<select id="themeSelect" data-key="theme" aria-label="Choisir le thème">
|
||||||
|
|
||||||
</select>
|
</select>
|
||||||
<button id="refresh-themes-btn" title="Refresh themes" class="refresh-themes-btn">
|
<button id="refresh-themes-btn" title="Refresh themes" class="refresh-themes-btn">
|
||||||
@@ -110,10 +106,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<input data-key="keepPlaylistOrder" id="keepPlaylistOrder" type="checkbox">
|
<input type="checkbox" data-key="autoCheckInfo" id="autoCheckInfo">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<span class="setting-title">Keep Playlist Order</span>
|
<span class="setting-title">Auto Fetch Info</span>
|
||||||
<small>Add index before output file, to keep the order.</small>
|
<small>Automatically fetch information for new downloads.</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -124,18 +120,10 @@
|
|||||||
<summary>Advanced Settings</summary>
|
<summary>Advanced Settings</summary>
|
||||||
|
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<input type="checkbox" data-key="showChanglogsAfterUpdate" id="showChanglogsAfterUpdate">
|
<input type="checkbox" data-key="autoUpdate" id="autoUpdate">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<span class="setting-title">Show Changelogs After Update</span>
|
<span class="setting-title">Auto Update</span>
|
||||||
<small>Display changelogs after each update.</small>
|
<small>Enable automatic app updates.</small>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="setting-item">
|
|
||||||
<input data-key="systemTray" id="systemTray" type="checkbox">
|
|
||||||
<div class="setting-info">
|
|
||||||
<span class="setting-title">System Tray</span>
|
|
||||||
<small>Minimize app on close, in the System Tray.</small>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -147,14 +135,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div> -->
|
</div> -->
|
||||||
|
|
||||||
<div class="setting-item">
|
|
||||||
<input data-key="autoCheckInfo" id="autoCheckInfo" type="checkbox">
|
|
||||||
<div class="setting-info">
|
|
||||||
<span class="setting-title">Auto Fetch Info</span>
|
|
||||||
<small>Automatically fetch information for new downloads.</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<input type="checkbox" data-key="addMetadata" id="addMetadata">
|
<input type="checkbox" data-key="addMetadata" id="addMetadata">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
@@ -171,13 +151,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">
|
||||||
@@ -217,14 +197,6 @@
|
|||||||
</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">
|
||||||
@@ -274,7 +246,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="download-path">
|
<div class="download-path">
|
||||||
Will be saved in <span id="savePath"></span>
|
Will be save on <span id="savePath"></span>
|
||||||
<button id="changePath">Edit</button>
|
<button id="changePath">Edit</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -307,56 +279,18 @@
|
|||||||
|
|
||||||
<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">×</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>
|
||||||
|
<script src="script/customConfiguration.js"></script>
|
||||||
<script src="script/clipboardPaste.js"></script>
|
<script src="script/clipboardPaste.js"></script>
|
||||||
<script src="script/custompath.js"></script>
|
<script src="script/custompath.js"></script>
|
||||||
<script src="script/settingsPanel.js"></script>
|
<script src="script/settingsPanel.js"></script>
|
||||||
@@ -365,9 +299,6 @@
|
|||||||
<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>
|
||||||
|
|||||||
@@ -1,54 +1,21 @@
|
|||||||
/**
|
/**
|
||||||
* Retrieves the application version and build state from the backend.
|
* Retrieves the application version from the Electron main process
|
||||||
* Displays a formatted string indicating packaging state and preview status.
|
* and displays it in the UI version badge (bottom-right corner).
|
||||||
* Hides the "Packaged" state for production builds to keep the UI clean.
|
|
||||||
*
|
*
|
||||||
* Example outputs: "Not Packaged Preview v1.6.2" or "Preview v1.6.2"
|
* This is mainly used for debugging and build identification.
|
||||||
*
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
*/
|
||||||
async function displayVersionLabel() {
|
async function versionLabel() {
|
||||||
/**
|
|
||||||
* Raw version string from the backend (e.g., "dev-1.6.2-preview" or "v1.6.2")
|
|
||||||
* @type {string}
|
|
||||||
*/
|
|
||||||
const appVersion = await window.electronAPI.getVersion();
|
const appVersion = await window.electronAPI.getVersion();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UI element displaying the current application version.
|
* UI element displaying the current application version.
|
||||||
* @type {HTMLElement | null}
|
* Updated at runtime after IPC call resolves.
|
||||||
*/
|
*/
|
||||||
const versionBadge = document.getElementById("version-badge");
|
const versionBadge = document.getElementById("version-badge");
|
||||||
|
|
||||||
if (!versionBadge) return;
|
if (!versionBadge) return;
|
||||||
|
|
||||||
/**
|
versionBadge.textContent = `${appVersion}`;
|
||||||
* Extracts the numeric semantic version (e.g., "1.6.2").
|
|
||||||
* @type {RegExpMatchArray | null}
|
|
||||||
*/
|
|
||||||
const versionMatch = appVersion.match(/(\d+\.\d+\.\d+)/);
|
|
||||||
const baseVersion = versionMatch ? versionMatch[0] : "Unknown";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determines the packaging state. The backend prepends "dev-" if unpackaged.
|
|
||||||
* @type {boolean}
|
|
||||||
*/
|
|
||||||
const isPackaged = !appVersion.includes("dev-");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determines if the current build is a preview version.
|
|
||||||
* @type {boolean}
|
|
||||||
*/
|
|
||||||
const isPreview = appVersion.includes("preview");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Construct the final display string.
|
|
||||||
* If packaged, we don't mention it. If not, we explicitly say "Not Packaged ".
|
|
||||||
*/
|
|
||||||
const packageStateStr = isPackaged ? "" : "Not Packaged ";
|
|
||||||
const previewStr = isPreview ? "Preview " : "";
|
|
||||||
|
|
||||||
versionBadge.textContent = `${packageStateStr}${previewStr}v${baseVersion}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
displayVersionLabel();
|
versionLabel();
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
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();
|
|
||||||
@@ -1,282 +0,0 @@
|
|||||||
/** 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();
|
|
||||||
27
public/script/customConfiguration.js
Normal file
27
public/script/customConfiguration.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
async function initPlatformRestrictions() {
|
||||||
|
const platform = await window.electronAPI.getProcessPlatform();
|
||||||
|
|
||||||
|
const themeSelect = document.getElementById("themeSelect");
|
||||||
|
const refreshBtn = document.getElementById("refresh-themes-btn");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is the OS windows
|
||||||
|
* @type {boolean}
|
||||||
|
*/
|
||||||
|
const isWindows = platform === "win32";
|
||||||
|
|
||||||
|
console.log(isWindows);
|
||||||
|
|
||||||
|
// If it's windows, the button is not disabled.
|
||||||
|
themeSelect.disabled = !isWindows;
|
||||||
|
refreshBtn.disabled = !isWindows;
|
||||||
|
|
||||||
|
themeSelect.classList.toggle("disabled", !isWindows);
|
||||||
|
refreshBtn.classList.toggle("disabled", !isWindows);
|
||||||
|
|
||||||
|
if (!isWindows) {
|
||||||
|
themeSelect.title = "Themes are only available on Windows";
|
||||||
|
refreshBtn.title = "Themes are only available on Windows";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -121,7 +121,7 @@ async function initThemes() {
|
|||||||
const defaultTheme = {
|
const defaultTheme = {
|
||||||
id: "dark",
|
id: "dark",
|
||||||
name: "Dark",
|
name: "Dark",
|
||||||
subtitle: "Because Why Not ?",
|
subtitle: "Darkness is my ally",
|
||||||
style: {
|
style: {
|
||||||
background: {
|
background: {
|
||||||
size: "cover",
|
size: "cover",
|
||||||
|
|||||||
@@ -53,32 +53,17 @@ 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/json"},
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
body: JSON.stringify(body),
|
body: params,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
if (!userStoppedDownload) {
|
if (!userStoppedDownload) {
|
||||||
let errorText = "Server Error";
|
const errorText = await res.text();
|
||||||
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();
|
||||||
}
|
}
|
||||||
@@ -86,12 +71,8 @@ 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!");
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
window.showSuccess("Download completed!");
|
window.showSuccess("Download completed!");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,14 +80,10 @@ form.addEventListener("submit", async (e) => {
|
|||||||
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;
|
||||||
if (stopBtn) stopBtn.style.display = "none";
|
stopBtn.style.display = "none";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -158,9 +135,9 @@ async function stopDownload() {
|
|||||||
try {
|
try {
|
||||||
await fetch("/download/cancel", {
|
await fetch("/download/cancel", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {"Content-Type": "application/json"},
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
});
|
});
|
||||||
|
|
||||||
window.showWarning("Download stopped");
|
window.showWarning("Download stopped");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error stopping download:", err);
|
console.error("Error stopping download:", err);
|
||||||
|
|||||||
@@ -30,29 +30,16 @@ function formatSize(bytes) {
|
|||||||
*/
|
*/
|
||||||
async function fetchVideoInfo(url) {
|
async function fetchVideoInfo(url) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/info?url=${encodeURIComponent(url)}`, {
|
const res = await fetch("/info", {
|
||||||
method: "GET",
|
method: "POST",
|
||||||
headers: {Accept: "application/json"},
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({ url }),
|
||||||
});
|
});
|
||||||
|
|
||||||
let data;
|
if (!res.ok) return { error: `An Error occured when fetching info` };
|
||||||
try {
|
|
||||||
data = await res.json();
|
|
||||||
} catch (e) {
|
|
||||||
return {error: "Network or JSON Issue"};
|
|
||||||
}
|
|
||||||
|
|
||||||
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"};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
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) {
|
||||||
@@ -282,7 +269,7 @@ async function init() {
|
|||||||
infoDiv.classList.add("visible");
|
infoDiv.classList.add("visible");
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
})
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,165 +0,0 @@
|
|||||||
class BugReportModal {
|
|
||||||
constructor() {
|
|
||||||
this.modal = null;
|
|
||||||
this.form = null;
|
|
||||||
this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {
|
|
||||||
this.modal = document.createElement("div");
|
|
||||||
this.modal.className = "bug-modal-overlay";
|
|
||||||
this.modal.style.cssText = "display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.7); justify-content:center; align-items:center; z-index:99999; font-family:sans-serif;";
|
|
||||||
|
|
||||||
this.modal.innerHTML = `
|
|
||||||
<div class="bug-modal-content" style="background:#14141491 !important; color:#ffffff !important; padding:24px !important; border-radius:10px !important; width:420px !important; box-shadow: 0 10px 25px rgba(0,0,0,0.8) !important; display:flex !important; flex-direction:column !important; gap:16px !important; border: 1px solid #333 !important; box-sizing: border-box !important;">
|
|
||||||
<h3 style="margin:0 !important; font-size:1.2rem !important; font-weight:600 !important; color:#ffffff !important;">Report a Bug</h3>
|
|
||||||
<form id="bugReportForm" style="display:flex !important; flex-direction:column !important; gap:12px !important; width:unset !important; margin:0 !important; background:transparent !important; box-shadow: unset !important;">
|
|
||||||
<div style="display:flex !important; flex-direction:column !important; gap:4px !important;">
|
|
||||||
<label style="font-size:0.85rem !important; color:#aaaaaa !important;">Title <span style="color: red; font-weight: bold" >*</span> </label>
|
|
||||||
<input type="text" id="bugTitle" required style="background:#2a2a2a !important; border:1px solid #444 !important; color:#ffffff !important; border-radius:6px !important; padding:8px 10px !important; outline:none !important; width:100% !important; box-sizing: border-box !important;" />
|
|
||||||
</div>
|
|
||||||
<div style="display:flex !important; flex-direction:column !important; gap:4px !important;">
|
|
||||||
<label style="font-size:0.85rem !important; color:#aaaaaa !important;">Description <span style="color: red; font-weight: bold" >*</span></label>
|
|
||||||
<textarea id="bugDescription" placeholder="Provide some details about the bug" required rows="4" style="background:#2a2a2a !important; border:1px solid #444 !important; color:#ffffff !important; border-radius:6px !important; padding:8px 10px !important; outline:none !important; resize:vertical !important; width:100% !important; box-sizing: border-box !important;"></textarea>
|
|
||||||
</div>
|
|
||||||
<div style="display:flex !important; flex-direction:column !important; gap:4px !important;">
|
|
||||||
<label style="font-size:0.85rem !important; color:#aaaaaa !important;">Include Logs From Today</label>
|
|
||||||
<select id="bugIncludeLogs" style="background:#2a2a2a !important; border:1px solid #444 !important; color:#ffffff !important; border-radius:6px !important; padding:8px 10px !important; outline:none !important; width:100% !important; box-sizing: border-box !important;">
|
|
||||||
<option value="yes" style="background:#2a2a2a !important; color:#ffffff !important;">Yes</option>
|
|
||||||
<option value="no" style="background:#2a2a2a !important; color:#ffffff !important;">No</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div id="bugReportServiceStatus" role="status" aria-live="polite" style="display:none !important; align-items:center !important; gap:8px !important; color:#aaaaaa !important; font-size:0.8rem !important;">
|
|
||||||
<span id="bugReportServiceStatusDot" style="width:8px !important; height:8px !important; border-radius:50% !important; background:#f59e0b !important; flex-shrink:0 !important;"></span>
|
|
||||||
<span id="bugReportServiceStatusText">Checking bug report service...</span>
|
|
||||||
</div>
|
|
||||||
<div style="display:flex !important; justify-content:flex-end !important; gap:8px !important; margin-top:8px !important;">
|
|
||||||
<button type="button" id="bugCancelBtn" style="background:#333333 !important; border:none !important; color:#cccccc !important; padding:8px 14px !important; border-radius:6px !important; cursor:pointer !important;">Cancel</button>
|
|
||||||
<button type="submit" id="bugSendBtn" disabled style="background:#4f46e5 !important; border:none !important; color:#ffffff !important; padding:8px 16px !important; border-radius:6px !important; cursor:not-allowed !important; opacity:0.5 !important; font-weight:500 !important;">Send</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.body.appendChild(this.modal);
|
|
||||||
this.form = this.modal.querySelector("#bugReportForm");
|
|
||||||
|
|
||||||
this.bindEvents();
|
|
||||||
}
|
|
||||||
|
|
||||||
bindEvents() {
|
|
||||||
const titleInput = this.modal.querySelector("#bugTitle");
|
|
||||||
const descInput = this.modal.querySelector("#bugDescription");
|
|
||||||
const sendButton = this.modal.querySelector("#bugSendBtn");
|
|
||||||
|
|
||||||
titleInput.addEventListener("input", () => {
|
|
||||||
localStorage.setItem("draft_bug_title", titleInput.value);
|
|
||||||
});
|
|
||||||
|
|
||||||
descInput.addEventListener("input", () => {
|
|
||||||
localStorage.setItem("draft_bug_desc", descInput.value);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.modal.querySelector("#bugCancelBtn").addEventListener("click", () => {
|
|
||||||
this.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.modal.addEventListener("click", (e) => {
|
|
||||||
if (e.target === this.modal) {
|
|
||||||
this.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.form.addEventListener("submit", async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (sendButton.disabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
title: titleInput.value,
|
|
||||||
description: descInput.value,
|
|
||||||
includeLogs: this.modal.querySelector("#bugIncludeLogs").value
|
|
||||||
};
|
|
||||||
|
|
||||||
window.electronAPI.logInfo("Submitting:", data);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const success = await window.topbarAPI.sendReport(data);
|
|
||||||
|
|
||||||
if (success) {
|
|
||||||
localStorage.removeItem("draft_bug_title");
|
|
||||||
localStorage.removeItem("draft_bug_desc");
|
|
||||||
this.form.reset();
|
|
||||||
this.close();
|
|
||||||
window.showSuccess("Bug report sent successfully!");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
window.showError("Failed to send bug report.");
|
|
||||||
window.electronAPI.logError("Failed to send bug report:", err.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
loadState() {
|
|
||||||
const savedTitle = localStorage.getItem("draft_bug_title");
|
|
||||||
const savedDesc = localStorage.getItem("draft_bug_desc");
|
|
||||||
|
|
||||||
if (savedTitle) {
|
|
||||||
this.modal.querySelector("#bugTitle").value = savedTitle;
|
|
||||||
}
|
|
||||||
if (savedDesc) {
|
|
||||||
this.modal.querySelector("#bugDescription").value = savedDesc;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
open() {
|
|
||||||
this.loadState();
|
|
||||||
this.modal.style.display = "flex";
|
|
||||||
this.updateServiceStatus();
|
|
||||||
}
|
|
||||||
|
|
||||||
close() {
|
|
||||||
this.modal.style.display = "none";
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateServiceStatus() {
|
|
||||||
const statusText = this.modal.querySelector("#bugReportServiceStatusText");
|
|
||||||
const statusDot = this.modal.querySelector("#bugReportServiceStatusDot");
|
|
||||||
const sendButton = this.modal.querySelector("#bugSendBtn");
|
|
||||||
|
|
||||||
sendButton.disabled = true;
|
|
||||||
sendButton.style.setProperty("cursor", "not-allowed", "important");
|
|
||||||
sendButton.style.setProperty("opacity", "0.5", "important");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const serviceStatus = await window.topbarAPI.getReportServiceStatus();
|
|
||||||
const serviceAvailable = serviceStatus.status === "online";
|
|
||||||
const statusVisible = !serviceAvailable;
|
|
||||||
statusText.textContent = serviceStatus.message;
|
|
||||||
statusDot.style.background = "#ef4444";
|
|
||||||
statusText.style.color = "#fca5a5";
|
|
||||||
this.modal.querySelector("#bugReportServiceStatus").style.setProperty(
|
|
||||||
"display",
|
|
||||||
statusVisible ? "flex" : "none",
|
|
||||||
"important"
|
|
||||||
);
|
|
||||||
sendButton.disabled = !serviceAvailable;
|
|
||||||
sendButton.style.setProperty("cursor", serviceAvailable ? "pointer" : "not-allowed", "important");
|
|
||||||
sendButton.style.setProperty("opacity", serviceAvailable ? "1" : "0.5", "important");
|
|
||||||
} catch (err) {
|
|
||||||
statusText.textContent = "Unable to check bug report service.";
|
|
||||||
statusDot.style.background = "#ef4444";
|
|
||||||
statusText.style.color = "#fca5a5";
|
|
||||||
this.modal.querySelector("#bugReportServiceStatus").style.setProperty("display", "flex", "important");
|
|
||||||
sendButton.disabled = true;
|
|
||||||
window.electronAPI.logError("Failed to retrieve bug report service status:", err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.bugModal = new BugReportModal();
|
|
||||||
|
|
||||||
document.getElementById("report-bug-btn").addEventListener("click", () => {
|
|
||||||
window.bugModal.open();
|
|
||||||
});
|
|
||||||
@@ -16,7 +16,8 @@ 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", () => {
|
||||||
settingsPanel.classList.toggle("open");
|
const currentlyOpen = settingsPanel.style.display === "block";
|
||||||
|
settingsPanel.style.display = currentlyOpen ? "none" : "block";
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,7 +25,7 @@ settingsButton.addEventListener("click", () => {
|
|||||||
*/
|
*/
|
||||||
function closePanel() {
|
function closePanel() {
|
||||||
isOpen = false;
|
isOpen = false;
|
||||||
settingsPanel.classList.remove("open");
|
settingsPanel.style.display = "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -17,20 +17,18 @@ window.showToast = function (message, type = "info", duration = 4000) {
|
|||||||
toast.classList.add("toast", type);
|
toast.classList.add("toast", type);
|
||||||
|
|
||||||
const icons = {
|
const icons = {
|
||||||
success: "check_circle",
|
success: "✓",
|
||||||
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 material-icons-round">${icon}</span>
|
<span class="toast-icon">${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");
|
||||||
@@ -82,13 +80,4 @@ window.showWarning = (message, duration = 4000) =>
|
|||||||
* Shortcut: info toast
|
* Shortcut: info toast
|
||||||
*/
|
*/
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -5,56 +5,53 @@
|
|||||||
* This function is safe to call once DOM is ready.
|
* This function is safe to call once DOM is ready.
|
||||||
* It silently aborts if the API or elements are missing.
|
* It silently aborts if the API or elements are missing.
|
||||||
*/
|
*/
|
||||||
export function initTopBar() {
|
function setupTopbarListeners() {
|
||||||
const { topbarAPI } = window;
|
const { topbarAPI } = window;
|
||||||
if (!topbarAPI) return;
|
if (!topbarAPI) return;
|
||||||
|
|
||||||
const minBtn = document.getElementById("minimize-btn");
|
const minBtn = document.getElementById("minimize-btn");
|
||||||
if (minBtn) minBtn.onclick = () => topbarAPI.minimize();
|
|
||||||
|
|
||||||
const maxBtn = document.getElementById("maximize-btn");
|
const maxBtn = document.getElementById("maximize-btn");
|
||||||
if (maxBtn) maxBtn.onclick = () => topbarAPI.maximize();
|
|
||||||
|
|
||||||
const closeBtn = document.getElementById("close-btn");
|
const closeBtn = document.getElementById("close-btn");
|
||||||
if (closeBtn) closeBtn.onclick = () => topbarAPI.close();
|
|
||||||
|
|
||||||
const devtoolsBtn = document.getElementById("devtools-btn");
|
const devtoolsBtn = document.getElementById("devtools-btn");
|
||||||
if (devtoolsBtn) devtoolsBtn.onclick = () => topbarAPI.openDevTools();
|
|
||||||
|
|
||||||
const logsBtn = document.getElementById("logs-btn");
|
const logsBtn = document.getElementById("logs-btn");
|
||||||
if (logsBtn) logsBtn.onclick = () => topbarAPI.openLogs();
|
|
||||||
|
|
||||||
const websiteBtn = document.getElementById("website-btn");
|
const websiteBtn = document.getElementById("website-btn");
|
||||||
if (websiteBtn) websiteBtn.onclick = () => topbarAPI.openWebsite();
|
|
||||||
|
|
||||||
const wikiBtn = document.getElementById("wiki-btn");
|
const wikiBtn = document.getElementById("wiki-btn");
|
||||||
if (wikiBtn) wikiBtn.onclick = () => topbarAPI.openWiki();
|
|
||||||
|
|
||||||
const workshopBtn = document.getElementById("workshop-btn");
|
const workshopBtn = document.getElementById("workshop-btn");
|
||||||
if (workshopBtn) workshopBtn.onclick = () => topbarAPI.openWorkshop();
|
|
||||||
|
|
||||||
|
if (minBtn) minBtn.onclick = () => topbarAPI.minimize();
|
||||||
|
if (maxBtn) maxBtn.onclick = () => topbarAPI.maximize();
|
||||||
|
if (closeBtn) closeBtn.onclick = () => topbarAPI.close();
|
||||||
|
if (devtoolsBtn) devtoolsBtn.onclick = () => topbarAPI.openDevTools();
|
||||||
|
if (logsBtn) logsBtn.onclick = () => topbarAPI.openLogs();
|
||||||
|
if (websiteBtn) websiteBtn.onclick = () => topbarAPI.openWebsite();
|
||||||
|
if (wikiBtn) wikiBtn.onclick = () => topbarAPI.openWiki();
|
||||||
|
if (workshopBtn) workshopBtn.onclick = () => topbarAPI.openWorkshop();
|
||||||
}
|
}
|
||||||
|
|
||||||
//* Disabled because i disabled native topbar for now, but keeping the code in case i want to re-enable it later
|
/**
|
||||||
// async function attachListeners(){
|
* Registers topbar event listeners after DOM is fully loaded.
|
||||||
// try{
|
*/
|
||||||
// const featuresList = await window.electronAPI.getFeatures();
|
document.addEventListener("DOMContentLoaded", setupTopbarListeners);
|
||||||
|
|
||||||
// if (!featuresList.customTopBar) {
|
|
||||||
// const topbar = document.getElementById("topbar");
|
|
||||||
// const container = document.getElementById("container");
|
|
||||||
// const themeSwitcher = document.getElementById("theme-switcher");
|
|
||||||
|
|
||||||
// if (topbar) topbar.style.display = "none";
|
|
||||||
// if (container) container.style.marginTop = "0";
|
|
||||||
// if (themeSwitcher) themeSwitcher.style.top = "30px";
|
|
||||||
// }
|
|
||||||
|
|
||||||
// } catch (error) {
|
|
||||||
// console.error("Failed to load layout features for topbar:", error);
|
|
||||||
// }
|
|
||||||
|
|
||||||
// }
|
/**
|
||||||
|
* Applies UI layout adjustments depending on feature flags
|
||||||
|
* provided by the Electron backend.
|
||||||
|
*
|
||||||
|
* Specifically handles:
|
||||||
|
* - hiding custom topbar
|
||||||
|
* - adjusting layout spacing
|
||||||
|
* - repositioning theme switcher
|
||||||
|
*/
|
||||||
|
(async function applyFeatureLayout() {
|
||||||
|
const features = await window.electronAPI.getFeatures();
|
||||||
|
|
||||||
initTopBar()
|
if (!features.customTopBar) {
|
||||||
//document.addEventListener("DOMContentLoaded", attachListeners)
|
const topbar = document.getElementById("topbar");
|
||||||
|
const container = document.getElementById("container");
|
||||||
|
const themeSwitcher = document.getElementById("theme-switcher");
|
||||||
|
|
||||||
|
if (topbar) topbar.style.display = "none";
|
||||||
|
if (container) container.style.marginTop = "0";
|
||||||
|
if (themeSwitcher) themeSwitcher.style.top = "30px";
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
: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;
|
|
||||||
}
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
.changelog-overlay {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 10000;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 24px;
|
|
||||||
background: rgba(5, 10, 16, 0.72);
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.changelog-overlay[hidden] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.changelog-modal {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
width: min(680px, 100%);
|
|
||||||
max-height: min(720px, 88vh);
|
|
||||||
overflow: hidden;
|
|
||||||
color: var(--settings-text-color, #f8fafc);
|
|
||||||
background: var(--settings-modal-bg-color, #18212b);
|
|
||||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
|
||||||
border-radius: 14px;
|
|
||||||
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.45);
|
|
||||||
}
|
|
||||||
|
|
||||||
.changelog-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 20px;
|
|
||||||
padding: 24px 28px 18px;
|
|
||||||
border-bottom: 1px solid rgba(148, 163, 184, 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
.changelog-eyebrow {
|
|
||||||
color: var(--fill-accent, #0ea5e9);
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.changelog-header h2 {
|
|
||||||
margin: 5px 0 0;
|
|
||||||
font-size: 26px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.changelog-close {
|
|
||||||
width: 34px;
|
|
||||||
height: 34px;
|
|
||||||
padding: 0;
|
|
||||||
color: inherit;
|
|
||||||
font-size: 24px;
|
|
||||||
line-height: 1;
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
|
||||||
border-radius: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.changelog-close:hover {
|
|
||||||
background: rgba(148, 163, 184, 0.14);
|
|
||||||
}
|
|
||||||
|
|
||||||
.changelog-list {
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 20px 28px 28px;
|
|
||||||
scrollbar-color: var(--fill-accent, #0ea5e9) transparent;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
}
|
|
||||||
|
|
||||||
.changelog-list::-webkit-scrollbar,
|
|
||||||
.changelog-body pre::-webkit-scrollbar {
|
|
||||||
width: 7px;
|
|
||||||
height: 7px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.changelog-list::-webkit-scrollbar-track,
|
|
||||||
.changelog-body pre::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.changelog-list::-webkit-scrollbar-thumb,
|
|
||||||
.changelog-body pre::-webkit-scrollbar-thumb {
|
|
||||||
background: color-mix(in srgb, var(--fill-accent, #0ea5e9) 70%, transparent);
|
|
||||||
border: 2px solid transparent;
|
|
||||||
border-radius: 999px;
|
|
||||||
background-clip: padding-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.changelog-list::-webkit-scrollbar-thumb:hover,
|
|
||||||
.changelog-body pre::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: var(--fill-accent, #0ea5e9);
|
|
||||||
background-clip: padding-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.changelog-entry {
|
|
||||||
padding: 18px 0;
|
|
||||||
border-bottom: 1px solid rgba(148, 163, 184, 0.16);
|
|
||||||
}
|
|
||||||
|
|
||||||
.changelog-entry:first-child {
|
|
||||||
padding-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.changelog-entry:last-child {
|
|
||||||
border-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.changelog-entry-heading {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.changelog-entry-heading h3 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 17px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.changelog-entry-heading span {
|
|
||||||
flex-shrink: 0;
|
|
||||||
color: var(--settings-subtitle-color, #94a3b8);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.changelog-body {
|
|
||||||
margin: 12px 0 0;
|
|
||||||
color: var(--settings-subtitle-color, #cbd5e1);
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.55;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
.changelog-body p {
|
|
||||||
margin: 0 0 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.changelog-body p:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.changelog-body h1,
|
|
||||||
.changelog-body h2,
|
|
||||||
.changelog-body h3,
|
|
||||||
.changelog-body h4,
|
|
||||||
.changelog-body h5,
|
|
||||||
.changelog-body h6 {
|
|
||||||
margin: 14px 0 8px;
|
|
||||||
color: var(--settings-text-color, #f8fafc);
|
|
||||||
}
|
|
||||||
|
|
||||||
.changelog-body h1 { font-size: 20px; }
|
|
||||||
.changelog-body h2 { font-size: 18px; }
|
|
||||||
.changelog-body h3 { font-size: 16px; }
|
|
||||||
.changelog-body h4,
|
|
||||||
.changelog-body h5,
|
|
||||||
.changelog-body h6 { font-size: 14px; }
|
|
||||||
|
|
||||||
.changelog-body ul,
|
|
||||||
.changelog-body ol {
|
|
||||||
margin: 8px 0 12px;
|
|
||||||
padding-left: 22px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.changelog-body li + li {
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.changelog-body code {
|
|
||||||
padding: 2px 5px;
|
|
||||||
color: var(--settings-text-color, #f8fafc);
|
|
||||||
background: rgba(148, 163, 184, 0.16);
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.changelog-body pre {
|
|
||||||
padding: 10px 12px;
|
|
||||||
overflow-x: auto;
|
|
||||||
background: rgba(15, 23, 42, 0.45);
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.changelog-body pre code {
|
|
||||||
padding: 0;
|
|
||||||
background: transparent;
|
|
||||||
white-space: pre;
|
|
||||||
}
|
|
||||||
|
|
||||||
.changelog-body blockquote {
|
|
||||||
margin: 10px 0;
|
|
||||||
padding-left: 12px;
|
|
||||||
color: var(--settings-subtitle-color, #cbd5e1);
|
|
||||||
border-left: 3px solid var(--fill-accent, #0ea5e9);
|
|
||||||
}
|
|
||||||
|
|
||||||
.changelog-body a {
|
|
||||||
color: var(--fill-accent, #0ea5e9);
|
|
||||||
}
|
|
||||||
|
|
||||||
.changelog-loading,
|
|
||||||
.changelog-empty {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--settings-subtitle-color, #cbd5e1);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 780px) {
|
|
||||||
.changelog-overlay {
|
|
||||||
padding: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.changelog-header,
|
|
||||||
.changelog-list {
|
|
||||||
padding-left: 18px;
|
|
||||||
padding-right: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.changelog-entry-heading {
|
|
||||||
align-items: flex-start;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 5px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,70 +5,73 @@
|
|||||||
right: 20px;
|
right: 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 8px;
|
||||||
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: 12px;
|
gap: 10px;
|
||||||
padding: 14px 16px;
|
padding: 10px 14px;
|
||||||
background-color: rgba(30, 30, 30, 0.9);
|
background-color: rgba(50, 50, 50, 0.9);
|
||||||
backdrop-filter: blur(12px);
|
border-radius: 6px;
|
||||||
-webkit-backdrop-filter: blur(12px);
|
color: #ffffff;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
font-size: 0.9rem;
|
||||||
border-radius: 10px;
|
max-width: 220px;
|
||||||
color: #f3f4f6;
|
min-width: 100px;
|
||||||
font-family: inherit;
|
animation: slideIn 0.25s ease-out;
|
||||||
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;
|
||||||
overflow: hidden;
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.success {
|
||||||
|
background-color: rgba(40, 167, 69, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.error {
|
||||||
|
background-color: rgba(220, 53, 69, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.warning {
|
||||||
|
background-color: rgba(255, 193, 7, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.info {
|
||||||
|
background-color: rgba(23, 162, 184, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Toast Icon */
|
/* Toast Icon */
|
||||||
.toast-icon {
|
.toast-icon {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
font-size: 20px;
|
font-size: 18px;
|
||||||
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: #22c55e;
|
color: #28a745;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast.error .toast-icon {
|
.toast.error .toast-icon {
|
||||||
color: #ef4444;
|
color: #dc3545;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast.warning .toast-icon {
|
.toast.warning .toast-icon {
|
||||||
color: #f59e0b;
|
color: #ffc107;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast.info .toast-icon {
|
.toast.info .toast-icon {
|
||||||
color: #0ea5e9;
|
color: #17a2b8;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Toast Message */
|
/* Toast Message */
|
||||||
.toast-message {
|
.toast-message {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-weight: 400;
|
font-weight: 500;
|
||||||
line-height: 1.4;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Toast Close Button */
|
/* Toast Close Button */
|
||||||
@@ -76,87 +79,58 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: #9ca3af;
|
color: currentColor;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 16px;
|
font-size: 18px;
|
||||||
width: 24px;
|
padding: 0;
|
||||||
height: 24px;
|
|
||||||
border-radius: 4px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
transition: background-color 0.15s ease, color 0.15s ease;
|
opacity: 0.5;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast-close:hover {
|
.toast-close:hover {
|
||||||
background-color: rgba(255, 255, 255, 0.1);
|
opacity: 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: translateY(-20px);
|
transform: translateX(350px);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
transform: translateY(0);
|
transform: translateX(0);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slideOut {
|
@keyframes slideOut {
|
||||||
from {
|
from {
|
||||||
transform: translateY(0);
|
transform: translateX(0);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
transform: translateY(-20px);
|
transform: translateX(350px);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes progress {
|
|
||||||
from {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
width: 0%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast.removing {
|
.toast.removing {
|
||||||
animation: slideOut 0.25s ease-in forwards;
|
animation: slideOut 0.25s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
#toast-container {
|
#toast-container {
|
||||||
top: auto;
|
bottom: 12px;
|
||||||
bottom: 16px;
|
right: 12px;
|
||||||
right: 16px;
|
left: 12px;
|
||||||
left: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast {
|
.toast {
|
||||||
max-width: none;
|
max-width: none;
|
||||||
min-width: auto;
|
min-width: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,22 +45,11 @@
|
|||||||
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 {
|
||||||
|
|||||||
@@ -14,8 +14,8 @@
|
|||||||
.custom-controls {
|
.custom-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 6px;
|
||||||
padding-left: 10px;
|
padding-left: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 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: 28px;
|
height: 32px;
|
||||||
padding: 0 10px;
|
padding: 0 14px;
|
||||||
|
|
||||||
font-size: 14px;
|
font-size: 15px;
|
||||||
border-radius: 7px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-controls button:hover {
|
.custom-controls button:hover {
|
||||||
|
|||||||
@@ -12,8 +12,6 @@
|
|||||||
@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');
|
||||||
|
|
||||||
|
|||||||
BIN
resources/binaries/linux/yt-dlp
Executable file
BIN
resources/binaries/linux/yt-dlp
Executable file
Binary file not shown.
BIN
resources/binaries/win-32/yt-dlp.exe
Normal file
BIN
resources/binaries/win-32/yt-dlp.exe
Normal file
Binary file not shown.
@@ -1,24 +0,0 @@
|
|||||||
### 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.
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
||||||
VERSION=$(node -p "require('$ROOT_DIR/package.json').version")
|
|
||||||
RPM_OUT="$ROOT_DIR/dist/freedom-loader-${VERSION}.x86_64.rpm"
|
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# Resolve fpm from electron-builder cache
|
|
||||||
# Excludes Ruby gem wrappers, targets standalone binaries only
|
|
||||||
# Prefers fpm@2.x over legacy fpm-1.9.x
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
FPM=$(find ~/.cache/electron-builder -name "fpm" -type f 2>/dev/null \
|
|
||||||
| grep -v "/gems/bin/fpm" \
|
|
||||||
| grep -v "/gems/gems/" \
|
|
||||||
| grep -v "/lib/app/bin/fpm" \
|
|
||||||
| grep "fpm@" \
|
|
||||||
| head -1)
|
|
||||||
|
|
||||||
# Fallback to legacy fpm-1.9.x if fpm@2.x not found
|
|
||||||
if [ -z "$FPM" ]; then
|
|
||||||
FPM=$(find ~/.cache/electron-builder -name "fpm" -type f 2>/dev/null \
|
|
||||||
| grep -v "/gems/" \
|
|
||||||
| grep -v "/lib/app/bin/fpm" \
|
|
||||||
| head -1)
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "$FPM" ]; then
|
|
||||||
echo "Error: fpm not found in electron-builder cache. Run 'npm run build:linux' once first to download it."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Using fpm: $FPM"
|
|
||||||
|
|
||||||
# Check that linux-unpacked exists
|
|
||||||
if [ ! -d "$ROOT_DIR/dist/linux-unpacked" ]; then
|
|
||||||
echo "Error: dist/linux-unpacked not found. Run 'npm run build:linux' first."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# Prepare staging files
|
|
||||||
# fpm requires exact file-to-destination mappings to avoid
|
|
||||||
# scanning system directories (which causes permission errors)
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
|
|
||||||
# Shell wrapper for /usr/bin/freedom-loader
|
|
||||||
TMP_WRAPPER="$ROOT_DIR/.rpm-pkg-tmp-wrapper"
|
|
||||||
mkdir -p "$TMP_WRAPPER"
|
|
||||||
cat > "$TMP_WRAPPER/freedom-loader" << 'EOF'
|
|
||||||
#!/bin/sh
|
|
||||||
exec /opt/freedom-loader/freedom-loader "$@"
|
|
||||||
EOF
|
|
||||||
chmod +x "$TMP_WRAPPER/freedom-loader"
|
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# Step 1 - Build .rpm from linux-unpacked via fpm
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
echo "Building RPM..."
|
|
||||||
"$FPM" -s dir -t rpm --force \
|
|
||||||
--rpm-use-file-permissions \
|
|
||||||
--name freedom-loader \
|
|
||||||
--version "$VERSION" \
|
|
||||||
--architecture x86_64 \
|
|
||||||
--description "Free and open-source GUI for yt-dlp" \
|
|
||||||
--url "https://freedomloader.acnolo.fr/" \
|
|
||||||
--maintainer "MasterAcnolo <MasterAcnolo@users.noreply.github.com>" \
|
|
||||||
--license "GPL-3.0-only" \
|
|
||||||
--depends gtk3 \
|
|
||||||
--depends nss \
|
|
||||||
--depends libXScrnSaver \
|
|
||||||
--depends at-spi2-core \
|
|
||||||
--rpm-attr "4755,root,root:/opt/freedom-loader/chrome-sandbox" \
|
|
||||||
--package "$RPM_OUT" \
|
|
||||||
"$ROOT_DIR/dist/linux-unpacked/=/opt/freedom-loader" \
|
|
||||||
"$TMP_WRAPPER/freedom-loader=/usr/bin/freedom-loader" \
|
|
||||||
"$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.desktop=/usr/share/applications/com.masteracnolo.freedomloader.desktop"
|
|
||||||
rm -rf "$TMP_WRAPPER"
|
|
||||||
echo "RPM built: $RPM_OUT"
|
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# Step 2 - Build SRPM for COPR
|
|
||||||
# Wraps the .rpm above into a source RPM for COPR submission
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
echo "Building SRPM for COPR..."
|
|
||||||
mkdir -p "$ROOT_DIR/srpm-out"
|
|
||||||
|
|
||||||
TMP_DIR="$ROOT_DIR/.copr-build-tmp"
|
|
||||||
mkdir -p "$TMP_DIR"
|
|
||||||
TMP_SPEC="$TMP_DIR/freedom-loader.spec"
|
|
||||||
sed "s/__VERSION__/$VERSION/g" "$ROOT_DIR/package/freedom-loader.spec" > "$TMP_SPEC"
|
|
||||||
|
|
||||||
rpmbuild -bs "$TMP_SPEC" \
|
|
||||||
--define "_sourcedir $ROOT_DIR/dist" \
|
|
||||||
--define "_srcrpmdir $ROOT_DIR/srpm-out"
|
|
||||||
|
|
||||||
rm -rf "$TMP_DIR"
|
|
||||||
echo "SRPM built: $ROOT_DIR/srpm-out/freedom-loader-$VERSION-1*.src.rpm"
|
|
||||||
20
scripts/make-copr-srpm.sh
Executable file
20
scripts/make-copr-srpm.sh
Executable file
@@ -0,0 +1,20 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
VERSION=$(node -p "require('$ROOT_DIR/package.json').version")
|
||||||
|
mkdir -p "$ROOT_DIR/srpm-out"
|
||||||
|
|
||||||
|
TMP_DIR="$ROOT_DIR/.copr-build-tmp"
|
||||||
|
mkdir -p "$TMP_DIR"
|
||||||
|
TMP_SPEC="$TMP_DIR/freedom-loader.spec"
|
||||||
|
sed "s/__VERSION__/$VERSION/g" "$ROOT_DIR/package/freedom-loader.spec" > "$TMP_SPEC"
|
||||||
|
|
||||||
|
rpmbuild -bs "$TMP_SPEC" \
|
||||||
|
--define "_sourcedir $ROOT_DIR/dist" \
|
||||||
|
--define "_srcrpmdir $ROOT_DIR/srpm-out"
|
||||||
|
|
||||||
|
rm -rf "$TMP_DIR"
|
||||||
|
echo "SRPM généré : $ROOT_DIR/srpm-out/freedom-loader-$VERSION-1*.src.rpm"
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
||||||
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"
|
|
||||||
|
|
||||||
# Parse arguments
|
|
||||||
PUBLISH_SNAP=false
|
|
||||||
PUBLISH_COPR=false
|
|
||||||
DRY_RUN=false
|
|
||||||
|
|
||||||
for arg in "$@"; do
|
|
||||||
case $arg in
|
|
||||||
--snap) PUBLISH_SNAP=true ;;
|
|
||||||
--copr) PUBLISH_COPR=true ;;
|
|
||||||
--publish) PUBLISH_SNAP=true; PUBLISH_COPR=true ;;
|
|
||||||
--dry-run) DRY_RUN=true ;;
|
|
||||||
*) echo "Unknown Argument : $arg" ; exit 1 ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "============================================"
|
|
||||||
echo " Freedom Loader - Release Pipeline"
|
|
||||||
echo " Version : $VERSION"
|
|
||||||
echo " Tag : $TAG"
|
|
||||||
echo " Snap : $PUBLISH_SNAP"
|
|
||||||
echo " COPR : $PUBLISH_COPR"
|
|
||||||
echo " Dry run : $DRY_RUN"
|
|
||||||
echo "============================================"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# Step 0.1 - Check Dependencies
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
echo "[0/5] Checking dependencies..."
|
|
||||||
|
|
||||||
command -v gh >/dev/null 2>&1 || { echo "'gh' CLI missing, need to be installed (https://cli.github.com/)."; exit 1; }
|
|
||||||
|
|
||||||
if [ "$PUBLISH_SNAP" = true ] && [ "$DRY_RUN" = false ]; then
|
|
||||||
command -v snapcraft >/dev/null 2>&1 || { echo "'snapcraft' missing, need to be installed."; exit 1; }
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$PUBLISH_COPR" = true ] && [ "$DRY_RUN" = false ]; then
|
|
||||||
command -v copr-cli >/dev/null 2>&1 || { echo "'copr-cli' missing, need to be installed."; exit 1; }
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "All dependencies found"
|
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# Step 0.2 - Clean Workspace
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
echo "[0/5] Cleaning workspace..."
|
|
||||||
# On supprime les dossiers générés lors des builds précédents
|
|
||||||
rm -rf "$ROOT_DIR/dist" "$ROOT_DIR/srpm-out"
|
|
||||||
echo "Workspace cleaned (dist/ and srpm-out/ removed)"
|
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# Step 0.3 - Retrieve Changelog
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
if [ ! -f "$ROOT_DIR/scripts/RELEASE-CHANGELOG.md" ]; then
|
|
||||||
echo "Warning: RELEASE-CHANGELOG.md not found, release notes will be empty."
|
|
||||||
CHANGELOG=""
|
|
||||||
else
|
|
||||||
CHANGELOG="$(cat "$ROOT_DIR/scripts/RELEASE-CHANGELOG.md")"
|
|
||||||
echo "Changelog retrieved successfully"
|
|
||||||
fi
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# Step 1 - Build Linux packages (AppImage, deb, snap)
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
echo "[1/5] Building Linux packages..."
|
|
||||||
cd "$ROOT_DIR"
|
|
||||||
npm run build:linux
|
|
||||||
echo "Linux packages built"
|
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# Step 2 - Build RPM binary + SRPM for COPR
|
|
||||||
# Uses the create-copr-package.sh script which handles:
|
|
||||||
# - fpm binary resolution from electron-builder cache
|
|
||||||
# - .rpm generation from linux-unpacked
|
|
||||||
# - .src.rpm generation for COPR submission
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
echo "[2/5] Building RPM and SRPM..."
|
|
||||||
bash "$ROOT_DIR/scripts/create-copr-package.sh"
|
|
||||||
echo "RPM and SRPM built"
|
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# Step 3 - Build Windows installer
|
|
||||||
# Note: requires Windows binaries to be present locally under
|
|
||||||
# resources/binaries/win-32/ (not committed to the repo)
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
echo "[3/5] Building Windows installer..."
|
|
||||||
npm run build:win
|
|
||||||
echo " Windows installer built"
|
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# Step 4 - Create a draft release on GitHub
|
|
||||||
# Release notes are pre-filled with the standard template.
|
|
||||||
# Edit and publish manually from the GitHub web interface.
|
|
||||||
# Requires gh CLI to be installed and authenticated.
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
echo "[4/5] Creating GitHub draft release $TAG..."
|
|
||||||
|
|
||||||
# Utilise une méthode un peu plus robuste pour trouver l'ancien tag (ignore le HEAD actuel s'il est déjà tagué)
|
|
||||||
PREV_TAG=$(git rev-list --tags --skip=1 --max-count=1 | xargs git describe --tags 2>/dev/null || echo "previous")
|
|
||||||
|
|
||||||
RELEASE_NOTES="# Freedom Loader - $VERSION
|
|
||||||
|
|
||||||
## Changelog
|
|
||||||
|
|
||||||
$CHANGELOG
|
|
||||||
|
|
||||||
## Found a bug or issue?
|
|
||||||
Please report it in the [GitHub Issues](https://github.com/MasterAcnolo/Freedom-Loader/issues) section.
|
|
||||||
|
|
||||||
**Full Changelog**: https://github.com/MasterAcnolo/Freedom-Loader/compare/${PREV_TAG}...${TAG}"
|
|
||||||
|
|
||||||
if [ "$DRY_RUN" = false ]; then
|
|
||||||
gh release create "$TAG" \
|
|
||||||
--title "$TAG" \
|
|
||||||
--notes "$RELEASE_NOTES" \
|
|
||||||
--draft \
|
|
||||||
"$ROOT_DIR/dist/Freedom-Loader-Setup-${VERSION}.exe" \
|
|
||||||
"$ROOT_DIR/dist/Freedom-Loader-Setup-${VERSION}.exe.blockmap" \
|
|
||||||
"$ROOT_DIR/dist/freedom-loader-${VERSION}.x86_64.rpm" \
|
|
||||||
"$ROOT_DIR/dist/freedom-loader_${VERSION}_amd64.deb" \
|
|
||||||
"$ROOT_DIR/dist/Freedom-Loader-${VERSION}.AppImage" \
|
|
||||||
"$ROOT_DIR/dist/freedom-loader_${VERSION}_amd64.snap" \
|
|
||||||
"$ROOT_DIR/dist/latest.yml" \
|
|
||||||
"$ROOT_DIR/dist/latest-linux.yml"
|
|
||||||
echo "Draft release created: https://github.com/MasterAcnolo/Freedom-Loader/releases"
|
|
||||||
else
|
|
||||||
echo " [DRY RUN] gh release create $TAG (skipped)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# Step 5 - Publish to package stores
|
|
||||||
# Snap and COPR are opt-in via --snap, --copr, or --publish.
|
|
||||||
# Without these flags, this step is skipped entirely.
|
|
||||||
# Run after the GitHub release is published (not the draft),
|
|
||||||
# so store users always get the same version as GitHub users.
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
echo "[5/5] Publishing to package stores..."
|
|
||||||
|
|
||||||
if [ "$PUBLISH_SNAP" = true ]; then
|
|
||||||
echo " Publishing to Snap Store..."
|
|
||||||
if [ "$DRY_RUN" = false ]; then
|
|
||||||
export PATH=$PATH:/var/lib/snapd/snap/bin
|
|
||||||
snapcraft upload \
|
|
||||||
"$ROOT_DIR/dist/freedom-loader_${VERSION}_amd64.snap" \
|
|
||||||
--release=stable
|
|
||||||
echo " Snap published"
|
|
||||||
else
|
|
||||||
echo " [DRY RUN] snapcraft upload (skipped)"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo " Snap: skipped (use --snap or --publish to enable)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$PUBLISH_COPR" = true ]; then
|
|
||||||
echo " Publishing to COPR..."
|
|
||||||
if [ "$DRY_RUN" = false ]; then
|
|
||||||
copr-cli build freedom-loader \
|
|
||||||
"$ROOT_DIR/srpm-out/freedom-loader-${VERSION}-1"*.src.rpm
|
|
||||||
echo "COPR build triggered"
|
|
||||||
else
|
|
||||||
echo " [DRY RUN] copr-cli build (skipped)"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo " COPR: skipped (use --copr or --publish to enable)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# Done - Summary
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
echo ""
|
|
||||||
echo "============================================"
|
|
||||||
echo " Release pipeline complete."
|
|
||||||
echo ""
|
|
||||||
echo " Next steps:"
|
|
||||||
echo " 1. Fill in the release notes on GitHub"
|
|
||||||
echo " 2. Publish the draft release"
|
|
||||||
if [ "$PUBLISH_SNAP" = false ] || [ "$PUBLISH_COPR" = false ]; then
|
|
||||||
echo ""
|
|
||||||
echo " Store publishing (not done yet):"
|
|
||||||
[ "$PUBLISH_SNAP" = false ] && echo " - Snap : bash scripts/release.sh --snap"
|
|
||||||
[ "$PUBLISH_COPR" = false ] && echo " - COPR : bash scripts/release.sh --copr"
|
|
||||||
fi
|
|
||||||
echo "============================================"
|
|
||||||
@@ -1,23 +1,15 @@
|
|||||||
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 = [];
|
||||||
|
|
||||||
@@ -39,13 +31,6 @@ 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.
|
||||||
*
|
*
|
||||||
@@ -62,91 +47,34 @@ const ALLOWED_QUALITIES = new Set(["best", "medium", "worst", "1080", "720", "48
|
|||||||
*/
|
*/
|
||||||
async function downloadController(req, res) {
|
async function downloadController(req, res) {
|
||||||
try {
|
try {
|
||||||
const body = req.body || {};
|
|
||||||
const options = {
|
const options = {
|
||||||
url: body.url,
|
url: req.body.url,
|
||||||
audioOnly: body.audioOnly === "1" || body.audioOnly === true,
|
audioOnly: req.body.audioOnly === "1",
|
||||||
quality: ALLOWED_QUALITIES.has(body.quality) ? body.quality : "best",
|
quality: req.body.quality || "best",
|
||||||
outputFolder: body.savePath || body.outputFolder || undefined,
|
outputFolder: req.body.savePath || undefined,
|
||||||
playlistTitle: body.playlistTitle || undefined,
|
playlistTitle: req.body.playlistTitle || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!options.url || typeof options.url !== "string" || !isValidUrl(options.url)) {
|
if (!options.url || !isValidUrl(options.url)) return res.status(400).send("Invalid 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).json({
|
return res.status(400).send("Save Path Not Allowed.");
|
||||||
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,
|
||||||
[progressHandler],
|
progressListeners,
|
||||||
[speedHandler],
|
speedListeners,
|
||||||
[stageHandler],
|
stageListeners,
|
||||||
[playlistHandler]
|
playlistListeners
|
||||||
);
|
);
|
||||||
|
|
||||||
notifyDownloadFinished(outputFolder, configFeatures.notifySystem);
|
notifyDownloadFinished(outputFolder, configFeatures.notifySystem);
|
||||||
return res.json({success: true, message: "Download Done !", outputFolder});
|
res.send("Download Done !");
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(`Server Error in /download: ${err?.message ?? err}`);
|
logger.error(`Server Error in /download: ${err?.message ?? err}`);
|
||||||
if (err && err.message === "Download already in progress") {
|
res.status(500).send("Server Error");
|
||||||
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"},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,33 +87,17 @@ 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) => {
|
const sendProgress = percent => res.write(`data: ${percent}\n\n`);
|
||||||
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', () => {
|
||||||
const index = progressListeners.indexOf(sendProgress);
|
progressListeners.splice(progressListeners.indexOf(sendProgress), 1);
|
||||||
if (index >= 0) progressListeners.splice(index, 1);
|
res.end();
|
||||||
progressEmitter.removeListener("progress", onProgress);
|
|
||||||
try {
|
|
||||||
res.end();
|
|
||||||
} catch (err) {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,33 +110,17 @@ 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) => {
|
const sendSpeed = speed => res.write(`data: ${speed}\n\n`);
|
||||||
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', () => {
|
||||||
const index = speedListeners.indexOf(sendSpeed);
|
speedListeners.splice(speedListeners.indexOf(sendSpeed), 1);
|
||||||
if (index >= 0) speedListeners.splice(index, 1);
|
res.end();
|
||||||
speedEmitter.removeListener("speed", onSpeed);
|
|
||||||
try {
|
|
||||||
res.end();
|
|
||||||
} catch (err) {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,33 +134,17 @@ 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) => {
|
const sendStage = stage => res.write(`data: ${stage}\n\n`);
|
||||||
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', () => {
|
||||||
const index = stageListeners.indexOf(sendStage);
|
stageListeners.splice(stageListeners.indexOf(sendStage), 1);
|
||||||
if (index >= 0) stageListeners.splice(index, 1);
|
res.end();
|
||||||
stageEmitter.removeListener("stage", onStage);
|
|
||||||
try {
|
|
||||||
res.end();
|
|
||||||
} catch (err) {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,12 +160,10 @@ 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");
|
||||||
return res.json({success: true, message: "Download stopped! Queue cleared."});
|
res.send("Download stopped! Queue cleared.");
|
||||||
} else {
|
} else {
|
||||||
logger.warn("No download to cancel");
|
logger.warn("No download to cancel");
|
||||||
return res.status(400).json({
|
res.status(400).send("No download in progress !");
|
||||||
error: {code: "NO_DOWNLOAD_IN_PROGRESS", message: "No download in progress !"},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,42 +181,18 @@ 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) => {
|
const sendPlaylistInfo = info => res.write(`data: ${info}\n\n`);
|
||||||
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);
|
|
||||||
|
|
||||||
// Remove listener when client disconnects
|
req.on('close', () => {
|
||||||
req.on("close", () => {
|
playlistListeners.splice(playlistListeners.indexOf(sendPlaylistInfo), 1);
|
||||||
const index = playlistListeners.indexOf(sendPlaylistInfo);
|
res.end();
|
||||||
if (index >= 0) playlistListeners.splice(index, 1);
|
|
||||||
playlistEmitter.removeListener("playlist", onPlaylist);
|
|
||||||
try {
|
|
||||||
res.end();
|
|
||||||
} catch (err) {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = { downloadController, progressController, speedController, stageController, cancelDownloadController, playlistInfoController};
|
||||||
downloadController,
|
|
||||||
progressController,
|
|
||||||
speedController,
|
|
||||||
stageController,
|
|
||||||
cancelDownloadController,
|
|
||||||
playlistInfoController,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,42 +1,116 @@
|
|||||||
const {fetchNormalizedInfo} = require("../services/info.services");
|
const { fetchInfo } = 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.
|
||||||
*
|
*
|
||||||
* Returns normalized JSON responses. Accepts URL via query (?url=...)
|
* This controller:
|
||||||
* or request body for flexibility.
|
* - Validates incoming URL (query or body)
|
||||||
|
* - Calls yt-dlp metadata extraction service
|
||||||
|
* - Detects playlist vs single video context
|
||||||
|
* - Applies fallback strategy if playlist extraction fails
|
||||||
|
* - Returns normalized parsed response (video or playlist)
|
||||||
*
|
*
|
||||||
* @param {import("express").Request} req
|
* @param {import("express").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;
|
|
||||||
|
|
||||||
|
const url = req.body.url || req.query.url; // Supports POST and GET requests
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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).json({
|
return res.status(400).send("Invalid URL Or Missing");
|
||||||
error: {code: "INVALID_URL", message: "Invalid or missing URL"},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Info request received. RAW URL: ${url}`);
|
logger.info(`Info request received. URL: ${url}`);
|
||||||
if (encodedUrl) {
|
|
||||||
logger.info(`Info request received. ENCODED: ${encodedUrl}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await fetchNormalizedInfo(url);
|
// Lightweight heuristic to detect playlist URLs
|
||||||
return res.json(result);
|
const isPlaylistUrl = url.includes("&list") || url.includes("?list");
|
||||||
} catch (err) {
|
|
||||||
logger.error(
|
logger.info(
|
||||||
`Info controller error: ${err && err.message ? err.message : err}`
|
isPlaylistUrl
|
||||||
|
? "Estimated Data Type: Playlist"
|
||||||
|
: "Estimated Data Type: Video"
|
||||||
);
|
);
|
||||||
return res.status(500).json({
|
|
||||||
error: {code: "FETCH_FAILED", message: "Unable to fetch info"},
|
try {
|
||||||
});
|
let data;
|
||||||
}
|
|
||||||
|
/**
|
||||||
|
* Primary metadata fetch attempt
|
||||||
|
*/
|
||||||
|
try {
|
||||||
|
data = await fetchInfo(url);
|
||||||
|
} catch (err) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback strategy:
|
||||||
|
* If playlist extraction fails, retry as single video
|
||||||
|
*/
|
||||||
|
if (isPlaylistUrl) {
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Playlist fetch failed: ${err.message}. Retrying as single video...`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remove playlist query parameter (best-effort cleanup)
|
||||||
|
const singleVideoUrl = url.split("&list")[0].split("?list")[0];
|
||||||
|
|
||||||
|
try {
|
||||||
|
data = await fetchInfo(singleVideoUrl);
|
||||||
|
logger.info("Single video fetch succeeded after playlist failure");
|
||||||
|
} catch (retryErr) {
|
||||||
|
logger.error(
|
||||||
|
`Single video fetch also failed: ${retryErr.message}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Preserve original error context
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize response based on yt-dlp output type
|
||||||
|
*/
|
||||||
|
if (data._type === "playlist") {
|
||||||
|
|
||||||
|
const playlist = parsePlaylist(data);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Playlist fetched: ${playlist.title} (${playlist.count} videos)`
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.json(playlist);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
const video = parseVideo(data);
|
||||||
|
|
||||||
|
logger.info(`Video fetched: ${video.title}`);
|
||||||
|
|
||||||
|
return res.json(video);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
|
||||||
|
logger.error(
|
||||||
|
`Info controller error: ${err && err.message ? err.message : err}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.status(500).send("Unable to fetch info");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { infoController };
|
module.exports = { infoController };
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
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");
|
||||||
const config = require("../../config")
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates whether the provided codec is supported by the application.
|
* Validates whether the provided codec is supported by the application.
|
||||||
@@ -63,13 +62,14 @@ function validateCodec(codec){
|
|||||||
*
|
*
|
||||||
* @returns {string[]} Array of arguments ready to be passed to yt-dlp.
|
* @returns {string[]} Array of arguments ready to be passed to yt-dlp.
|
||||||
*/
|
*/
|
||||||
function buildYtDlpArgs({url, audioOnly, quality, outputFolder, isPlaylist}) {
|
function buildYtDlpArgs({ url, audioOnly, quality, outputFolder }) {
|
||||||
|
|
||||||
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 addThumbnail: ${configFeatures.addThumbnail}`);
|
logger.info(`CONFIG addThumbnail: ${configFeatures.addThumbnail}`);
|
||||||
logger.info(`CONFIG addMetadata: ${configFeatures.addMetadata}`);
|
logger.info(`CONFIG addMetadata: ${configFeatures.addMetadata}`);
|
||||||
logger.info(`CONFIG verboseLogs: ${configFeatures.verboseLogs}`);
|
logger.info(`CONFIG verboseLogs: ${configFeatures.verboseLogs}`);
|
||||||
@@ -117,27 +117,7 @@ function buildYtDlpArgs({url, audioOnly, quality, outputFolder, isPlaylist}) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
args.push("-f", qualityMap[quality] || "best");
|
args.push("-f", qualityMap[quality] || "best");
|
||||||
|
args.push("-o", path.join(outputFolder, "%(title)s.%(ext)s"));
|
||||||
/**
|
|
||||||
* Default title output template
|
|
||||||
* @type {string}
|
|
||||||
*/
|
|
||||||
let outputTemplate = "%(title)s.%(ext)s";
|
|
||||||
|
|
||||||
// If it's a playlist, and we want to keep the playlist order. Add an index before the title.
|
|
||||||
if (isPlaylist && configFeatures.keepPlaylistOrder && configFeatures.autoDownloadPlaylist) {
|
|
||||||
outputTemplate = "%(playlist_index)02d - %(title)s.%(ext)s";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add a custom signature in each file downloaded with the app. It's a small things but i found it cool :)
|
|
||||||
if (configFeatures.addMetadata) {
|
|
||||||
args.push(
|
|
||||||
"--postprocessor-args",
|
|
||||||
`ffmpeg:-metadata comment="Downloaded with Freedom Loader ${config.version}"`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
args.push("-o", path.join(outputFolder, outputTemplate));
|
|
||||||
args.push(url);
|
args.push(url);
|
||||||
|
|
||||||
return args.filter(Boolean);
|
return args.filter(Boolean);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const os = require("os");
|
const os = require("os");
|
||||||
const notify = require("./notify.helpers");
|
const path = require("path");
|
||||||
|
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,50 +16,47 @@ const {sendToast} = require("../../app/toastNotifier");
|
|||||||
* 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 platform = process.platform;
|
//const userProfile = os.homedir();
|
||||||
let isFirefoxHere = false;
|
const firefoxPath = getFirefoxProfilePath();
|
||||||
|
|
||||||
if (platform === "win32") {
|
/**
|
||||||
const paths = [
|
* Browsers supported by yt-dlp cookie extraction.
|
||||||
"C:\\Program Files\\Mozilla Firefox\\firefox.exe",
|
*
|
||||||
"C:\\Program Files (x86)\\Mozilla Firefox\\firefox.exe",
|
* Only Firefox is currently enabled and tested.
|
||||||
`${process.env.LOCALAPPDATA}\\Mozilla Firefox\\firefox.exe`,
|
* Additional browsers can be enabled in the future
|
||||||
];
|
* 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") }
|
||||||
|
];
|
||||||
|
|
||||||
isFirefoxHere = paths.some(p => fs.existsSync(p));
|
// Search for the first available browser profile.
|
||||||
|
for (const browser of browsers) {
|
||||||
|
if (browser.path && fs.existsSync(browser.path)) {
|
||||||
|
logger.info(`Browser found: ${browser.name}`);
|
||||||
|
return browser.name;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (platform === "linux") {
|
// No supported browser found => Notify User
|
||||||
const paths = [
|
logger.warn("No supported browser found on the system");
|
||||||
"/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
|
||||||
// If you somehow managed to live without Firefox and need help installing it. - Don't applied to my Linux chad
|
if (process.platform === "win32") {
|
||||||
// No supported browser found => Notify User
|
|
||||||
logger.warn("No supported browser found on the system");
|
|
||||||
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.
|
||||||
@@ -67,4 +64,39 @@ function getUserBrowser() {
|
|||||||
return "firefox";
|
return "firefox";
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {getUserBrowser};
|
function getFirefoxProfilePath() {
|
||||||
|
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
|
||||||
@@ -2,12 +2,6 @@ const { Notification, shell } = require("electron");
|
|||||||
const { iconPaths } = require("./path.helpers");
|
const { iconPaths } = require("./path.helpers");
|
||||||
const { logger } = require("../logger");
|
const { logger } = require("../logger");
|
||||||
|
|
||||||
/**
|
|
||||||
* Set that is holding all notifications. Used to avoid Race Condition and notification crash
|
|
||||||
* @type {Set<any>}
|
|
||||||
*/
|
|
||||||
const activeNotifications = new Set();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Displays a system notification when a download completes successfully.
|
* Displays a system notification when a download completes successfully.
|
||||||
*
|
*
|
||||||
@@ -20,34 +14,14 @@ const activeNotifications = new Set();
|
|||||||
function notifyDownloadFinished(folder, notifyEnabled = true) {
|
function notifyDownloadFinished(folder, notifyEnabled = true) {
|
||||||
if (!notifyEnabled) return;
|
if (!notifyEnabled) return;
|
||||||
if (!folder) return;
|
if (!folder) return;
|
||||||
|
|
||||||
const notif = new Notification({
|
const notif = new Notification({
|
||||||
title: "Freedom Loader",
|
title: "Freedom Loader",
|
||||||
body: "Your download is complete, click here to open it.",
|
body: "Your download is complete, click here to open it.",
|
||||||
icon: iconPaths.confirm,
|
icon: iconPaths.confirm,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Protect notification to garbage collection and race condition, by adding a 150ms timeout before trying to open the folder
|
notif.on("click", () => shell.openPath(folder));
|
||||||
activeNotifications.add(notif);
|
|
||||||
|
|
||||||
notif.on("click", () => {
|
|
||||||
activeNotifications.delete(notif);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
shell.openPath(folder).then((errorMessage) => {
|
|
||||||
if (errorMessage) {
|
|
||||||
logger.error(`Impossible d'ouvrir le dossier : ${errorMessage}`);
|
|
||||||
}
|
|
||||||
}).catch(err => {
|
|
||||||
logger.error(`Erreur inattendue de shell.openPath : ${err}`);
|
|
||||||
});
|
|
||||||
}, 150);
|
|
||||||
});
|
|
||||||
|
|
||||||
notif.on("close", () => {
|
|
||||||
activeNotifications.delete(notif);
|
|
||||||
});
|
|
||||||
|
|
||||||
notif.show();
|
notif.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,16 +41,11 @@ function notifyCookiesBrowserError(){
|
|||||||
icon: iconPaths.error,
|
icon: iconPaths.error,
|
||||||
});
|
});
|
||||||
|
|
||||||
activeNotifications.add(notif);
|
notif.on("click", () =>
|
||||||
|
shell.openExternal(
|
||||||
notif.on("click", () => {
|
"https://youtube.com/shorts/cN9f4s1Mf88?si=519QCVd_-fzJqRf1"
|
||||||
shell.openExternal("https://www.firefox.com/en-US/download/");
|
)
|
||||||
activeNotifications.delete(notif);
|
);
|
||||||
});
|
|
||||||
|
|
||||||
notif.on("close", () => {
|
|
||||||
activeNotifications.delete(notif);
|
|
||||||
});
|
|
||||||
|
|
||||||
notif.show();
|
notif.show();
|
||||||
}
|
}
|
||||||
@@ -96,16 +65,11 @@ function notifyFirefoxBrowserMissing() {
|
|||||||
icon: iconPaths.error,
|
icon: iconPaths.error,
|
||||||
});
|
});
|
||||||
|
|
||||||
activeNotifications.add(notif);
|
notif.on("click", () =>
|
||||||
|
shell.openExternal(
|
||||||
notif.on("click", () => {
|
"https://youtube.com/shorts/cN9f4s1Mf88?si=519QCVd_-fzJqRf1"
|
||||||
shell.openExternal("https://www.firefox.com/en-US/download/");
|
)
|
||||||
activeNotifications.delete(notif);
|
);
|
||||||
});
|
|
||||||
|
|
||||||
notif.on("close", () => {
|
|
||||||
activeNotifications.delete(notif);
|
|
||||||
});
|
|
||||||
|
|
||||||
notif.show();
|
notif.show();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
const path = require("path");
|
const path = require("path");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
let app;
|
const { app } = require("electron");
|
||||||
try {
|
|
||||||
app = require("electron").app;
|
|
||||||
} catch (e) {
|
|
||||||
app = null;
|
|
||||||
}
|
|
||||||
const config = require("../../config.js");
|
const config = require("../../config.js");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,12 +16,10 @@ 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 || path.join(__dirname, "../../resources"));
|
: process.resourcesPath;
|
||||||
|
|
||||||
/** Default download folder */
|
/** Default download folder (centralized) */
|
||||||
const defaultDownloadFolder = app && typeof app.getPath === "function"
|
const defaultDownloadFolder = path.join(app.getPath("downloads"), "Freedom Loader");
|
||||||
? path.join(app.getPath("downloads"), "Freedom Loader")
|
|
||||||
: path.join(process.env.HOME || process.cwd(), "Downloads", "Freedom Loader");
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Current platform:
|
* Current platform:
|
||||||
@@ -156,10 +149,8 @@ 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 = app && typeof app.getPath === "function"
|
const userThemesPath = path.join(app.getPath("userData"), "themes");
|
||||||
? path.join(app.getPath("userData"), "themes")
|
const defaultThemesSourcePath = path.join(resourcesPath, "theme");
|
||||||
: path.join(process.env.HOME || process.cwd(), ".config", "Freedom Loader", "themes");
|
|
||||||
const defaultThemesSourcePath = path.join("theme");
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes user theme directory and ensures default themes exist.
|
* Initializes user theme directory and ensures default themes exist.
|
||||||
@@ -269,7 +260,7 @@ function validateBinaries() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper to lazy load logger, avoid circular import
|
* Helper to lazy load logger, avoid circular import
|
||||||
* @returns {import("winston").Logger}
|
* @returns {winston.Logger}
|
||||||
*/
|
*/
|
||||||
function getLogger() {
|
function getLogger() {
|
||||||
return require("../logger.js").logger;
|
return require("../logger.js").logger;
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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("@");
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
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.
|
||||||
@@ -14,29 +15,10 @@ 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: {error: {code: "RATE_LIMITED", message: "Too many requests, please try again later."}},
|
message: "Too many requests, please try again later.",
|
||||||
standardHeaders: true,
|
statusCode: 429,
|
||||||
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,
|
|
||||||
};
|
|
||||||
@@ -17,29 +17,9 @@ try {
|
|||||||
console.error(`Failed to create log directory: ${error.message}`);
|
console.error(`Failed to create log directory: ${error.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const logFormat = format.combine(
|
||||||
* Format used by the saved file
|
format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
|
||||||
* @type {Format}
|
format.printf(({ timestamp, level, message }) => `${timestamp} | ${level.toUpperCase()} | ${message}`)
|
||||||
*/
|
|
||||||
const fileFormat = format.combine(
|
|
||||||
format.timestamp({format: "YYYY-MM-DD HH:mm:ss"}),
|
|
||||||
format.errors({stack: true}), // Capture la stack trace complète des erreurs
|
|
||||||
format.printf(({timestamp, level, message, stack}) => {
|
|
||||||
return `${timestamp} | ${level.toUpperCase()} | ${stack || message}`;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Formated used by the IDE/Console
|
|
||||||
* @type {Format}
|
|
||||||
*/
|
|
||||||
const consoleFormat = format.combine(
|
|
||||||
format.colorize({all: true}),
|
|
||||||
format.timestamp({format: "HH:mm:ss"}),
|
|
||||||
format.errors({stack: true}),
|
|
||||||
format.printf(({timestamp, level, message, stack}) => {
|
|
||||||
return `[${timestamp}] ${level}: ${stack || message}`;
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,6 +33,7 @@ const consoleFormat = format.combine(
|
|||||||
*/
|
*/
|
||||||
const logger = createLogger({
|
const logger = createLogger({
|
||||||
level: "info",
|
level: "info",
|
||||||
|
format: logFormat,
|
||||||
transports: [
|
transports: [
|
||||||
new DailyRotateFile({
|
new DailyRotateFile({
|
||||||
dirname: logDir,
|
dirname: logDir,
|
||||||
@@ -60,11 +41,11 @@ const logger = createLogger({
|
|||||||
datePattern: "YYYY-MM-DD",
|
datePattern: "YYYY-MM-DD",
|
||||||
zippedArchive: false,
|
zippedArchive: false,
|
||||||
maxFiles: "7d",
|
maxFiles: "7d",
|
||||||
format: fileFormat,
|
format: logFormat,
|
||||||
options: { flags: "a" },
|
options: { flags: "a" },
|
||||||
}),
|
}),
|
||||||
new transports.Console({
|
new transports.Console({
|
||||||
format: consoleFormat,
|
format: logFormat,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -73,17 +54,16 @@ const logger = createLogger({
|
|||||||
* Start Log Session
|
* Start Log Session
|
||||||
*/
|
*/
|
||||||
function logSessionStart(logDir, downloadPath) {
|
function logSessionStart(logDir, downloadPath) {
|
||||||
logger.info(`--- Starting session: ${new Date().toLocaleString('sv-SE')} ---`);
|
logger.info(`--- Starting session: ${new Date().toISOString()} ---`);
|
||||||
logger.info("============================================================")
|
|
||||||
logger.info(`Application Version: ${config.version}`)
|
logger.info(`Application Version: ${config.version}`)
|
||||||
if (typeof logSystemInfo === 'function') logSystemInfo(logger, logDir, downloadPath);
|
logSystemInfo(logger, logDir, downloadPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stop Log Session
|
* Stop Log Session
|
||||||
*/
|
*/
|
||||||
function logSessionEnd() {
|
function logSessionEnd() {
|
||||||
logger.info(`--- Ending session: ${new Date().toLocaleString('sv-SE')} ---`);
|
logger.info(`--- Ending session: ${new Date().toISOString()} ---`);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
@@ -1,17 +1,9 @@
|
|||||||
const express = require("express");
|
const express = require("express");
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const {
|
const { downloadController, progressController, speedController, stageController, cancelDownloadController, playlistInfoController } = require("../controller/download.controller");
|
||||||
downloadController,
|
|
||||||
progressController,
|
|
||||||
speedController,
|
|
||||||
stageController,
|
|
||||||
cancelDownloadController,
|
|
||||||
playlistInfoController,
|
|
||||||
} = require("../controller/download.controller");
|
|
||||||
const {downloadRateLimit} = require("../helpers/rateLimit.helpers");
|
|
||||||
|
|
||||||
router.post("/", downloadRateLimit, downloadController);
|
router.post("/", downloadController);
|
||||||
router.post("/cancel", downloadRateLimit, cancelDownloadController);
|
router.post("/cancel", cancelDownloadController);
|
||||||
|
|
||||||
// SSE for download progress
|
// SSE for download progress
|
||||||
router.get("/progress", progressController);
|
router.get("/progress", progressController);
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
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("/", infoRateLimit, infoController);
|
router.post("/", infoController);
|
||||||
router.post("/", infoRateLimit, infoController);
|
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
@@ -1,17 +1,11 @@
|
|||||||
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 }));
|
||||||
@@ -19,16 +13,14 @@ app.use(express.static(path.join(__dirname, "../public")));
|
|||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
app.use("/download", require("./routes/download.route"));
|
app.use("/download", require("./routes/download.route"));
|
||||||
app.use("/info", require("./routes/info.route"));
|
app.use("/info", require("./routes/info.route"));
|
||||||
|
|
||||||
// Interface
|
|
||||||
app.get("/", rateLimit, (req, res) => {
|
app.get("/", rateLimit, (req, res) => {
|
||||||
res.sendFile(path.join(__dirname, "../public/index.html"));
|
res.sendFile(path.join(__dirname, "../public/index.html"));
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes and starts the Express HTTP server.
|
* Initializes and starts the Express HTTP server.
|
||||||
*
|
*
|
||||||
* - Binds the server to `config.applicationPort`
|
* - Binds the server to `config.applicationPort`
|
||||||
* - Resolves with the HTTP server instance on successful startup
|
* - Resolves with the HTTP server instance on successful startup
|
||||||
* - Rejects on server binding or runtime errors
|
* - Rejects on server binding or runtime errors
|
||||||
@@ -36,34 +28,33 @@ app.get("/", rateLimit, (req, res) => {
|
|||||||
*/
|
*/
|
||||||
async function startServer() {
|
async function startServer() {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
serverInstance = app.listen(config.applicationPort, "127.0.0.1" ,() => {
|
const server = app.listen(config.applicationPort, () => {
|
||||||
logger.info(`Express server ready at http://localhost:${config.applicationPort}`);
|
logger.info(`Express server ready at http://localhost:${config.applicationPort}`);
|
||||||
resolve(serverInstance);
|
resolve(server);
|
||||||
});
|
});
|
||||||
|
|
||||||
serverInstance.on("error", (err) => {
|
server.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 };
|
|
||||||
@@ -6,8 +6,7 @@ const { buildYtDlpArgs } = require("../helpers/buildArgs.helpers");
|
|||||||
const notify = require("../helpers/notify.helpers");
|
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 { configFeatures } = require("../../config.js");
|
||||||
const {isUrlPlaylist} = require("../helpers/playlist.helpers");
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reference to the currently running yt-dlp process.
|
* Reference to the currently running yt-dlp process.
|
||||||
@@ -19,6 +18,19 @@ const {isUrlPlaylist} = require("../helpers/playlist.helpers");
|
|||||||
*/
|
*/
|
||||||
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.
|
||||||
*
|
*
|
||||||
@@ -37,23 +49,23 @@ let currentDownloadProcess = null;
|
|||||||
*/
|
*/
|
||||||
function createPlaylistFolder(basePath, playlistTitle) {
|
function createPlaylistFolder(basePath, playlistTitle) {
|
||||||
const sanitizedTitle = playlistTitle
|
const sanitizedTitle = playlistTitle
|
||||||
.replace(/[<>:"|?*]/g, '')
|
.replace(/[<>:"|?*]/g, '')
|
||||||
.replace(/\s+/g, ' ')
|
.replace(/\s+/g, ' ')
|
||||||
.trim()
|
.trim()
|
||||||
.substring(0, 200);
|
.substring(0, 200);
|
||||||
|
|
||||||
const playlistPath = path.join(basePath, sanitizedTitle);
|
const playlistPath = path.join(basePath, sanitizedTitle);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!fs.existsSync(playlistPath)) {
|
if (!fs.existsSync(playlistPath)) {
|
||||||
fs.mkdirSync(playlistPath, { recursive: true });
|
fs.mkdirSync(playlistPath, { recursive: true });
|
||||||
logger.info(`Playlist folder created: ${playlistPath}`);
|
logger.info(`Playlist folder created: ${playlistPath}`);
|
||||||
return playlistPath;
|
return playlistPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
let counter = 1;
|
let counter = 1;
|
||||||
let newPath;
|
let newPath;
|
||||||
|
|
||||||
while (counter <= 1000) {
|
while (counter <= 1000) {
|
||||||
newPath = path.join(basePath, `${sanitizedTitle} ${counter}`);
|
newPath = path.join(basePath, `${sanitizedTitle} ${counter}`);
|
||||||
if (!fs.existsSync(newPath)) {
|
if (!fs.existsSync(newPath)) {
|
||||||
@@ -63,17 +75,17 @@ function createPlaylistFolder(basePath, playlistTitle) {
|
|||||||
}
|
}
|
||||||
counter++;
|
counter++;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.error(`Could not find available playlist folder after 1000 attempts`);
|
logger.error(`Could not find available playlist folder after 1000 attempts`);
|
||||||
logger.error("Unable to create playlist folder");
|
throw new 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}`);
|
||||||
|
|
||||||
// Fallback: create folder "Untitled Playlist X"
|
// Fallback: create folder "Untitled Playlist X"
|
||||||
let counter = 1;
|
let counter = 1;
|
||||||
let fallbackPath;
|
let fallbackPath;
|
||||||
|
|
||||||
while (counter <= 1000) {
|
while (counter <= 1000) {
|
||||||
fallbackPath = path.join(basePath, `Untitled Playlist ${counter}`);
|
fallbackPath = path.join(basePath, `Untitled Playlist ${counter}`);
|
||||||
if (!fs.existsSync(fallbackPath)) {
|
if (!fs.existsSync(fallbackPath)) {
|
||||||
@@ -88,7 +100,7 @@ function createPlaylistFolder(basePath, playlistTitle) {
|
|||||||
}
|
}
|
||||||
counter++;
|
counter++;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.error(`Could not create playlist folder after 1000 attempts`);
|
logger.error(`Could not create playlist folder after 1000 attempts`);
|
||||||
throw new Error("Unable to create playlist folder");
|
throw new Error("Unable to create playlist folder");
|
||||||
}
|
}
|
||||||
@@ -111,7 +123,7 @@ function createPlaylistFolder(basePath, playlistTitle) {
|
|||||||
* @param {string} [options.outputFolder] - Destination folder.
|
* @param {string} [options.outputFolder] - Destination folder.
|
||||||
* @param {string} [options.playlistTitle] - Playlist title.
|
* @param {string} [options.playlistTitle] - Playlist title.
|
||||||
*
|
*
|
||||||
* @param {Function[]} progressListeners - Progress listeners.
|
* @param {Function[]} listeners - Progress listeners.
|
||||||
* @param {Function[]} speedListeners - Download speed listeners.
|
* @param {Function[]} speedListeners - Download speed listeners.
|
||||||
* @param {Function[]} stageListeners - Processing stage listeners.
|
* @param {Function[]} stageListeners - Processing stage listeners.
|
||||||
* @param {Function[]} playlistInfoListeners - Playlist progress listeners.
|
* @param {Function[]} playlistInfoListeners - Playlist progress listeners.
|
||||||
@@ -120,26 +132,21 @@ function createPlaylistFolder(basePath, playlistTitle) {
|
|||||||
* Resolves with the final output folder path once the
|
* Resolves with the final output folder path once the
|
||||||
* download has completed successfully.
|
* download has completed successfully.
|
||||||
*/
|
*/
|
||||||
function fetchDownload(options, progressListeners, speedListeners, stageListeners, playlistInfoListeners) {
|
function fetchDownload(options, listeners, 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();
|
logger.info(`CONFIG createPlaylistFolders: ${configFeatures.createPlaylistFolders}`);
|
||||||
|
|
||||||
logger.info(`CONFIG createPlaylistFolders: ${userConfig.createPlaylistFolders}`);
|
|
||||||
|
|
||||||
let outputFolder = options.outputFolder || defaultDownloadFolder;
|
let outputFolder = options.outputFolder || defaultDownloadFolder;
|
||||||
|
|
||||||
// Normalize path and validate it's safe (within Users folder)
|
// Normalize path and validate it's safe (within Users folder)
|
||||||
let safeOutputFolder = path.resolve(outputFolder);
|
let safeOutputFolder = path.resolve(outputFolder);
|
||||||
|
|
||||||
if (!isSafePath(safeOutputFolder)) {
|
if (!isSafePath(safeOutputFolder)) {
|
||||||
logger.warn(`Path not allowed, using default instead: ${safeOutputFolder}`);
|
logger.warn(`Path not allowed, using default instead: ${safeOutputFolder}`);
|
||||||
safeOutputFolder = path.resolve(defaultDownloadFolder);
|
safeOutputFolder = path.resolve(defaultDownloadFolder);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create download folder if it doesn't exist
|
// Create download folder if it doesn't exist
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(safeOutputFolder, { recursive: true });
|
fs.mkdirSync(safeOutputFolder, { recursive: true });
|
||||||
@@ -150,9 +157,9 @@ 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 || isUrlPlaylist(options.url);
|
const isPlaylist = options.playlistTitle || isPlaylistUrl(options.url);
|
||||||
|
|
||||||
if (isPlaylist && userConfig.createPlaylistFolders) {
|
if (isPlaylist && configFeatures.createPlaylistFolders) {
|
||||||
try {
|
try {
|
||||||
const playlistName = options.playlistTitle || "Untitled Playlist";
|
const playlistName = options.playlistTitle || "Untitled Playlist";
|
||||||
safeOutputFolder = createPlaylistFolder(safeOutputFolder, playlistName);
|
safeOutputFolder = createPlaylistFolder(safeOutputFolder, playlistName);
|
||||||
@@ -161,45 +168,39 @@ function fetchDownload(options, progressListeners, speedListeners, stageListener
|
|||||||
logger.error(`Failed to create playlist folder: ${err.message}`);
|
logger.error(`Failed to create playlist folder: ${err.message}`);
|
||||||
return reject(err);
|
return reject(err);
|
||||||
}
|
}
|
||||||
} else if (isPlaylist && !userConfig.createPlaylistFolders) {
|
} else if (isPlaylist && !configFeatures.createPlaylistFolders) {
|
||||||
logger.info(`Playlist detected but createPlaylistFolders is disabled, using base folder`);
|
logger.info(`Playlist detected but createPlaylistFolders is disabled, using base folder`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const args = buildYtDlpArgs({
|
const args = buildYtDlpArgs({ ...options, outputFolder: safeOutputFolder });
|
||||||
...options,
|
|
||||||
outputFolder: safeOutputFolder,
|
|
||||||
isPlaylist
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info(`[yt-dlp args] ${args.join(" ")}`);
|
logger.info(`[yt-dlp args] ${args.join(" ")}`);
|
||||||
|
|
||||||
// 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);
|
||||||
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}`)));
|
||||||
|
|
||||||
child.on("close", code => {
|
child.on("close", code => {
|
||||||
currentDownloadProcess = null;
|
currentDownloadProcess = null;
|
||||||
progressListeners.forEach(fn => fn("done"));
|
listeners.forEach(fn => fn("done"));
|
||||||
if (code === 0) resolve(safeOutputFolder);
|
if (code === 0) resolve(safeOutputFolder);
|
||||||
else if (code === null) reject(new Error(`Download cancelled by user`));
|
else if (code === null) reject(new Error(`Download cancelled by user`));
|
||||||
else reject(new Error(`YT-DLP failed with code : ${code}`));
|
else reject(new Error(`YT-DLP failed with code : ${code}`));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
child.stdout.on("data", data => {
|
child.stdout.on("data", data => {
|
||||||
data.toString().split("\n").forEach(line => {
|
data.toString().split("\n").forEach(line => {
|
||||||
if (!line.trim()) return;
|
if (!line.trim()) return;
|
||||||
logger.info(`[yt-dlp] ${line}`);
|
logger.info(`[yt-dlp] ${line}`);
|
||||||
|
|
||||||
// Progress Bar
|
// Progress Bar
|
||||||
if (line.startsWith("[download] Destination:")) {
|
if (line.startsWith("[download] Destination:")) {
|
||||||
progressListeners.forEach(fn => fn("reset"));
|
listeners.forEach(fn => fn("reset"));
|
||||||
stageListeners.forEach(fn => fn("Downloading..."));
|
stageListeners.forEach(fn => fn("Downloading..."));
|
||||||
}
|
}
|
||||||
const match = line.match(/\[download\]\s+(\d+\.\d+)%/);
|
const match = line.match(/\[download\]\s+(\d+\.\d+)%/);
|
||||||
if (match) progressListeners.forEach(fn => fn(parseFloat(match[1])));
|
if (match) listeners.forEach(fn => fn(parseFloat(match[1])));
|
||||||
|
|
||||||
if (line.includes("MiB/s") || line.includes("KiB/s")) {
|
if (line.includes("MiB/s") || line.includes("KiB/s")) {
|
||||||
const match = line.match(/\d+(\.\d+)?[KM]?iB\/s/);
|
const match = line.match(/\d+(\.\d+)?[KM]?iB\/s/);
|
||||||
@@ -244,7 +245,7 @@ function fetchDownload(options, progressListeners, speedListeners, stageListener
|
|||||||
data.toString().split("\n").forEach(line => line.trim() && logger.error(`[yt-dlp stderr] ${line}`));
|
data.toString().split("\n").forEach(line => line.trim() && logger.error(`[yt-dlp stderr] ${line}`));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,38 +262,23 @@ 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 {
|
||||||
if (process.platform === "win32") {
|
// Try to kill with SIGKILL on Windows (process group) or Unix
|
||||||
if (pid) {
|
if (process.platform === 'win32') {
|
||||||
require("child_process").execSync(`taskkill /PID ${pid} /T /F`, {
|
// On Windows, kill the process tree
|
||||||
stdio: "ignore",
|
require('child_process').execSync(`taskkill /PID ${currentDownloadProcess.pid} /T /F`, { stdio: 'ignore' });
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
if (pid) {
|
// On Unix, send SIGKILL to process group
|
||||||
try {
|
process.kill(-currentDownloadProcess.pid, 'SIGKILL');
|
||||||
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}`);
|
||||||
try {
|
// Fallback to regular kill
|
||||||
if (currentDownloadProcess && !currentDownloadProcess.killed) {
|
currentDownloadProcess.kill('SIGKILL');
|
||||||
currentDownloadProcess.kill("SIGKILL");
|
|
||||||
}
|
|
||||||
} catch (killErr) {
|
|
||||||
logger.warn(`Fallback kill failed: ${killErr.message}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
currentDownloadProcess = null;
|
currentDownloadProcess = null;
|
||||||
logger.info("Download cancelled successfully");
|
logger.info("Download cancelled successfully");
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
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.
|
||||||
@@ -23,117 +21,53 @@ const {isUrlPlaylist} = require("../helpers/playlist.helpers");
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
function fetchInfo(url) {
|
function fetchInfo(url) {
|
||||||
/**
|
|
||||||
* Base yt-dlp arguments used to retrieve metadata
|
|
||||||
* without downloading media files.
|
|
||||||
*/
|
|
||||||
const metadataArgs = [
|
|
||||||
"--js-runtimes",
|
|
||||||
`deno:${denoPath}`,
|
|
||||||
"--dump-single-json",
|
|
||||||
"--ignore-errors",
|
|
||||||
"--yes-playlist",
|
|
||||||
"--cookies-from-browser",
|
|
||||||
`${getUserBrowser()}`,
|
|
||||||
"--extractor-args",
|
|
||||||
"youtube:player_client=default",
|
|
||||||
"--ignore-no-formats-error",
|
|
||||||
];
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
/**
|
||||||
execFile(
|
* Base yt-dlp arguments used to retrieve metadata
|
||||||
userYtDlp,
|
* without downloading media files.
|
||||||
[...metadataArgs, url],
|
*/
|
||||||
{
|
const metadataArgs = [
|
||||||
timeout: 30_000, // 30s, if more timeout
|
"--js-runtimes", `deno:${denoPath}`,
|
||||||
maxBuffer: 10 * 1024 * 1024, // Maximum response size: 10 MB (Node.js default is much smaller)
|
"--dump-single-json",
|
||||||
},
|
"--ignore-errors",
|
||||||
(error, stdout, stderr) => {
|
"--yes-playlist",
|
||||||
if (stderr) {
|
"--cookies-from-browser",`${getUserBrowser()}`,
|
||||||
const lower = stderr?.toLowerCase();
|
"--extractor-args","youtube:player_client=default",
|
||||||
if (
|
"--ignore-no-formats-error",
|
||||||
lower.includes("sign in to confirm") ||
|
];
|
||||||
lower.includes("failed to decrypt") ||
|
|
||||||
lower.includes("could not copy")
|
return new Promise((resolve, reject) => {
|
||||||
) {
|
|
||||||
return reject(
|
execFile(userYtDlp,[...metadataArgs , url],
|
||||||
new Error(
|
{ timeout: 30_000, // 30s, if more timeout
|
||||||
`Unable to extract cookies from ${getUserBrowser()}. Please log in to this browser and try again.`
|
maxBuffer: 10 * 1024 * 1024 }, // Maximum response size: 10 MB (Node.js default is much smaller)
|
||||||
)
|
|
||||||
);
|
(error, stdout, stderr) => {
|
||||||
|
|
||||||
|
if (stderr) {
|
||||||
|
const lower = stderr.toLowerCase();
|
||||||
|
if (lower.includes("sign in to confirm") || lower.includes("failed to decrypt") || lower.includes("could not copy")) {
|
||||||
|
return reject(new Error(`Unable to extract cookies from ${getUserBrowser()}. Please log in to this browser and try again.`));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
logger.error(`YT-DLP Error: ${error.message}`);
|
logger.error(`YT-DLP Error: ${error.message}`);
|
||||||
if (stderr) logger.debug(`stderr: ${stderr}`);
|
if (stderr) logger.debug(`stderr: ${stderr}`);
|
||||||
return reject(new Error("Unable to fetch Info."));
|
return reject(new Error("Unable to fetch Info."));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!stdout) return reject(new Error("No Data Received."));
|
if (!stdout) return reject(new Error("No Data Received."));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(stdout);
|
const data = JSON.parse(stdout);
|
||||||
resolve(data);
|
resolve(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
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};
|
|
||||||
|
|||||||
@@ -1,181 +0,0 @@
|
|||||||
const path = require("path");
|
|
||||||
|
|
||||||
jest.mock("../../server/helpers/getBrowser.helpers.js", () => ({
|
|
||||||
getUserBrowser: jest.fn(() => "firefox"),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock("../../server/helpers/path.helpers.js", () => ({
|
|
||||||
ffmpegPath: "/mock/ffmpeg",
|
|
||||||
denoPath: "/mock/deno",
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock("../../config.js", () => ({
|
|
||||||
configFeatures: {
|
|
||||||
discordRPC: false,
|
|
||||||
customTopBar: false,
|
|
||||||
autoCheckInfo: false,
|
|
||||||
addThumbnail: true,
|
|
||||||
addMetadata: true,
|
|
||||||
verboseLogs: false,
|
|
||||||
autoDownloadPlaylist: false,
|
|
||||||
customCodec: "h264",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock("../../server/logger.js", () => ({
|
|
||||||
logger: {
|
|
||||||
info: jest.fn(),
|
|
||||||
error: jest.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { buildYtDlpArgs } = require("../../server/helpers/buildArgs.helpers");
|
|
||||||
const { configFeatures } = require("../../config");
|
|
||||||
const { logger } = require("../../server/logger");
|
|
||||||
|
|
||||||
describe("buildYtDlpArgs", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("builds default video arguments", () => {
|
|
||||||
const args = buildYtDlpArgs({
|
|
||||||
url: "https://youtube.com/watch?v=123",
|
|
||||||
audioOnly: false,
|
|
||||||
quality: "best",
|
|
||||||
outputFolder: "/downloads",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(args).toContain("--cookies-from-browser");
|
|
||||||
expect(args).toContain("firefox");
|
|
||||||
|
|
||||||
expect(args).toContain("--ffmpeg-location");
|
|
||||||
expect(args).toContain("/mock/ffmpeg");
|
|
||||||
|
|
||||||
expect(args).toContain("--js-runtimes");
|
|
||||||
expect(args).toContain("deno:/mock/deno");
|
|
||||||
|
|
||||||
expect(args).toContain("--merge-output");
|
|
||||||
expect(args).toContain("mp4");
|
|
||||||
|
|
||||||
expect(args).toContain("-f");
|
|
||||||
expect(args).toContain("bestvideo+bestaudio/best/mp4");
|
|
||||||
|
|
||||||
expect(args).toContain(
|
|
||||||
path.join("/downloads", "%(title)s.%(ext)s")
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(args.at(-1)).toBe("https://youtube.com/watch?v=123");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("builds audio-only arguments", () => {
|
|
||||||
const args = buildYtDlpArgs({
|
|
||||||
url: "https://youtube.com/watch?v=123",
|
|
||||||
audioOnly: true,
|
|
||||||
quality: "best",
|
|
||||||
outputFolder: "/downloads",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(args).toContain("--extract-audio");
|
|
||||||
expect(args).toContain("--audio-format");
|
|
||||||
expect(args).toContain("mp3");
|
|
||||||
expect(args).toContain("bestaudio");
|
|
||||||
|
|
||||||
expect(args).not.toContain("--merge-output");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("uses requested quality", () => {
|
|
||||||
const args = buildYtDlpArgs({
|
|
||||||
url: "https://youtube.com/watch?v=123",
|
|
||||||
audioOnly: false,
|
|
||||||
quality: "720",
|
|
||||||
outputFolder: "/downloads",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(args).toContain(
|
|
||||||
"bestvideo[height<=720]+bestaudio/best[height<=720]/mp4"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("falls back to best for unknown quality", () => {
|
|
||||||
const args = buildYtDlpArgs({
|
|
||||||
url: "https://youtube.com/watch?v=123",
|
|
||||||
audioOnly: false,
|
|
||||||
quality: "unknown",
|
|
||||||
outputFolder: "/downloads",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(args).toContain("best");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("uses --no-playlist by default", () => {
|
|
||||||
const args = buildYtDlpArgs({
|
|
||||||
url: "https://youtube.com/watch?v=123",
|
|
||||||
audioOnly: false,
|
|
||||||
quality: "best",
|
|
||||||
outputFolder: "/downloads",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(args).toContain("--no-playlist");
|
|
||||||
expect(args).not.toContain("--yes-playlist");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("uses --yes-playlist when enabled", () => {
|
|
||||||
configFeatures.autoDownloadPlaylist = true;
|
|
||||||
|
|
||||||
const args = buildYtDlpArgs({
|
|
||||||
url: "https://youtube.com/watch?v=123",
|
|
||||||
audioOnly: false,
|
|
||||||
quality: "best",
|
|
||||||
outputFolder: "/downloads",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(args).toContain("--yes-playlist");
|
|
||||||
expect(args).not.toContain("--no-playlist");
|
|
||||||
|
|
||||||
configFeatures.autoDownloadPlaylist = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
test("embeds thumbnail and metadata when enabled", () => {
|
|
||||||
const args = buildYtDlpArgs({
|
|
||||||
url: "https://youtube.com/watch?v=123",
|
|
||||||
audioOnly: false,
|
|
||||||
quality: "best",
|
|
||||||
outputFolder: "/downloads",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(args).toContain("--embed-thumbnail");
|
|
||||||
expect(args).toContain("--add-metadata");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("logs codec validation", () => {
|
|
||||||
buildYtDlpArgs({
|
|
||||||
url: "https://youtube.com/watch?v=123",
|
|
||||||
audioOnly: false,
|
|
||||||
quality: "best",
|
|
||||||
outputFolder: "/downloads",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(logger.info).toHaveBeenCalledWith("Codec Valid: h264");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("falls back to h264 when codec is invalid", () => {
|
|
||||||
configFeatures.customCodec = "invalid";
|
|
||||||
|
|
||||||
const args = buildYtDlpArgs({
|
|
||||||
url: "https://youtube.com/watch?v=123",
|
|
||||||
audioOnly: false,
|
|
||||||
quality: "best",
|
|
||||||
outputFolder: "/downloads",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(logger.error).toHaveBeenCalledWith(
|
|
||||||
"Codec not valid: invalid. Using default codec"
|
|
||||||
);
|
|
||||||
|
|
||||||
const sortIndex = args.indexOf("-S");
|
|
||||||
expect(args[sortIndex + 1]).toContain("vcodec:h264");
|
|
||||||
|
|
||||||
configFeatures.customCodec = "h264";
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
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"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
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",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
jest.mock("child_process");
|
|
||||||
jest.mock("../../server/helpers/path.helpers", () => ({
|
|
||||||
userYtDlp: "/mock/yt-dlp",
|
|
||||||
denoPath: "/mock/deno",
|
|
||||||
}));
|
|
||||||
jest.mock("../../server/helpers/getBrowser.helpers", () => ({
|
|
||||||
getUserBrowser: jest.fn(() => "firefox"),
|
|
||||||
}));
|
|
||||||
jest.mock("../../server/logger", () => ({
|
|
||||||
logger: {
|
|
||||||
info: jest.fn(),
|
|
||||||
error: jest.fn(),
|
|
||||||
debug: jest.fn(),
|
|
||||||
warn: jest.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
jest.mock("../../server/helpers/parseInfo.helpers", () => ({
|
|
||||||
parseVideo: jest.fn((data) => ({type: "video", ...data})),
|
|
||||||
parsePlaylist: jest.fn((data) => ({
|
|
||||||
type: "playlist",
|
|
||||||
title: data.title,
|
|
||||||
count: data.entries ? data.entries.length : 0,
|
|
||||||
videos: [],
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
jest.mock("../../server/helpers/playlist.helpers", () => ({
|
|
||||||
isUrlPlaylist: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const {execFile} = require("child_process");
|
|
||||||
const {fetchInfo, fetchNormalizedInfo} = require("../../server/services/info.services");
|
|
||||||
const {parseVideo, parsePlaylist} = require("../../server/helpers/parseInfo.helpers");
|
|
||||||
const {isUrlPlaylist} = require("../../server/helpers/playlist.helpers");
|
|
||||||
const {logger} = require("../../server/logger");
|
|
||||||
|
|
||||||
describe("info.services", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("fetchInfo", () => {
|
|
||||||
test("resolves parsed json on success", async () => {
|
|
||||||
execFile.mockImplementation((cmd, args, opts, cb) => {
|
|
||||||
cb(null, JSON.stringify({_type: "video", title: "Test"}), "");
|
|
||||||
});
|
|
||||||
const result = await fetchInfo("https://youtube.com/watch?v=123");
|
|
||||||
expect(result).toEqual({_type: "video", title: "Test"});
|
|
||||||
expect(execFile).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects when cookies error in stderr", async () => {
|
|
||||||
execFile.mockImplementation((cmd, args, opts, cb) => {
|
|
||||||
cb(null, "", "sign in to confirm you're not a bot");
|
|
||||||
});
|
|
||||||
await expect(fetchInfo("https://youtube.com/watch?v=123")).rejects.toThrow(
|
|
||||||
/Unable to extract cookies/
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects on exec error", async () => {
|
|
||||||
execFile.mockImplementation((cmd, args, opts, cb) => {
|
|
||||||
cb(new Error("spawn failed"), "", "");
|
|
||||||
});
|
|
||||||
await expect(fetchInfo("https://youtube.com/watch?v=123")).rejects.toThrow(
|
|
||||||
"Unable to fetch Info."
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects when no stdout", async () => {
|
|
||||||
execFile.mockImplementation((cmd, args, opts, cb) => {
|
|
||||||
cb(null, "", "");
|
|
||||||
});
|
|
||||||
await expect(fetchInfo("https://youtube.com/watch?v=123")).rejects.toThrow(
|
|
||||||
"No Data Received."
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects on invalid json", async () => {
|
|
||||||
execFile.mockImplementation((cmd, args, opts, cb) => {
|
|
||||||
cb(null, "not json", "");
|
|
||||||
});
|
|
||||||
await expect(fetchInfo("https://youtube.com/watch?v=123")).rejects.toThrow(
|
|
||||||
"JSON unreadable."
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("fetchNormalizedInfo", () => {
|
|
||||||
test("normalizes video", async () => {
|
|
||||||
isUrlPlaylist.mockReturnValue(false);
|
|
||||||
execFile.mockImplementation((cmd, args, opts, cb) => {
|
|
||||||
cb(null, JSON.stringify({_type: "video", title: "My Video"}), "");
|
|
||||||
});
|
|
||||||
const result = await fetchNormalizedInfo("https://youtube.com/watch?v=123");
|
|
||||||
expect(result.type).toBe("video");
|
|
||||||
expect(parseVideo).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("normalizes playlist", async () => {
|
|
||||||
isUrlPlaylist.mockReturnValue(true);
|
|
||||||
execFile.mockImplementation((cmd, args, opts, cb) => {
|
|
||||||
cb(
|
|
||||||
null,
|
|
||||||
JSON.stringify({
|
|
||||||
_type: "playlist",
|
|
||||||
title: "Playlist",
|
|
||||||
entries: [{id: "1"}, {id: "2"}],
|
|
||||||
}),
|
|
||||||
""
|
|
||||||
);
|
|
||||||
});
|
|
||||||
const result = await fetchNormalizedInfo("https://youtube.com/playlist?list=ABC");
|
|
||||||
expect(result.type).toBe("playlist");
|
|
||||||
expect(parsePlaylist).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("converts playlist with 1 entry to video", async () => {
|
|
||||||
isUrlPlaylist.mockReturnValue(true);
|
|
||||||
execFile.mockImplementation((cmd, args, opts, cb) => {
|
|
||||||
cb(
|
|
||||||
null,
|
|
||||||
JSON.stringify({
|
|
||||||
_type: "playlist",
|
|
||||||
title: "Playlist",
|
|
||||||
entries: [{id: "1", title: "Single"}],
|
|
||||||
}),
|
|
||||||
""
|
|
||||||
);
|
|
||||||
});
|
|
||||||
const result = await fetchNormalizedInfo("https://youtube.com/playlist?list=ABC");
|
|
||||||
expect(result.type).toBe("video");
|
|
||||||
expect(parseVideo).toHaveBeenCalled();
|
|
||||||
expect(logger.info).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining("Playlist with 1 item converted to video")
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("falls back to single video when playlist fetch fails", async () => {
|
|
||||||
isUrlPlaylist.mockReturnValue(true);
|
|
||||||
let callCount = 0;
|
|
||||||
execFile.mockImplementation((cmd, args, opts, cb) => {
|
|
||||||
callCount++;
|
|
||||||
if (callCount === 1) {
|
|
||||||
cb(new Error("fail"), "", "");
|
|
||||||
} else {
|
|
||||||
cb(null, JSON.stringify({_type: "video", title: "Fallback"}), "");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const result = await fetchNormalizedInfo("https://youtube.com/watch?v=123&list=ABC");
|
|
||||||
expect(result.type).toBe("video");
|
|
||||||
expect(callCount).toBe(2);
|
|
||||||
expect(logger.info).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining("Retrying as single video")
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("throws when url is invalid type", async () => {
|
|
||||||
await expect(fetchNormalizedInfo(null)).rejects.toThrow("Invalid URL");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
const mockShow = jest.fn();
|
|
||||||
const mockOn = jest.fn();
|
|
||||||
const mockOpenPath = jest.fn().mockResolvedValue("");
|
|
||||||
const mockOpenExternal = jest.fn();
|
|
||||||
|
|
||||||
jest.mock("electron", () => ({
|
|
||||||
Notification: jest.fn().mockImplementation(() => ({
|
|
||||||
show: mockShow,
|
|
||||||
on: mockOn,
|
|
||||||
})),
|
|
||||||
shell: {
|
|
||||||
openPath: mockOpenPath,
|
|
||||||
openExternal: mockOpenExternal,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock("../../server/helpers/path.helpers", () => ({
|
|
||||||
iconPaths: {
|
|
||||||
confirm: "/icons/confirm.png",
|
|
||||||
error: "/icons/error.png",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock("../../server/logger", () => ({
|
|
||||||
logger: {
|
|
||||||
info: jest.fn(),
|
|
||||||
error: jest.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { Notification, shell } = require("electron");
|
|
||||||
|
|
||||||
const {
|
|
||||||
notifyDownloadFinished,
|
|
||||||
notifyCookiesBrowserError,
|
|
||||||
notifyFirefoxBrowserMissing,
|
|
||||||
} = require("../../server/helpers/notify.helpers");
|
|
||||||
|
|
||||||
describe("notify.helpers", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("notifyDownloadFinished", () => {
|
|
||||||
test("does nothing when notifications are disabled", () => {
|
|
||||||
notifyDownloadFinished("/downloads", false);
|
|
||||||
|
|
||||||
expect(Notification).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does nothing when folder is missing", () => {
|
|
||||||
notifyDownloadFinished(undefined, true);
|
|
||||||
|
|
||||||
expect(Notification).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("creates and shows notification", () => {
|
|
||||||
notifyDownloadFinished("/downloads");
|
|
||||||
|
|
||||||
expect(Notification).toHaveBeenCalledWith({
|
|
||||||
title: "Freedom Loader",
|
|
||||||
body: "Your download is complete, click here to open it.",
|
|
||||||
icon: "/icons/confirm.png",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockOn).toHaveBeenCalledWith(
|
|
||||||
"click",
|
|
||||||
expect.any(Function)
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(mockShow).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("opens folder when notification is clicked", (done) => {
|
|
||||||
notifyDownloadFinished("/downloads");
|
|
||||||
|
|
||||||
const callback = mockOn.mock.calls[0][1];
|
|
||||||
callback();
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
expect(shell.openPath).toHaveBeenCalledWith("/downloads");
|
|
||||||
done();
|
|
||||||
}, 200);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("notifyCookiesBrowserError", () => {
|
|
||||||
test("creates notification", () => {
|
|
||||||
notifyCookiesBrowserError();
|
|
||||||
|
|
||||||
expect(Notification).toHaveBeenCalledWith({
|
|
||||||
title: "Cookies Error",
|
|
||||||
body: "Unable to retrieve cookies. Please log in to your browser and click here to view the tutorial.",
|
|
||||||
icon: "/icons/error.png",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockShow).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("opens tutorial when clicked", () => {
|
|
||||||
notifyCookiesBrowserError();
|
|
||||||
|
|
||||||
const callback = mockOn.mock.calls[0][1];
|
|
||||||
callback();
|
|
||||||
|
|
||||||
expect(shell.openExternal).toHaveBeenCalledWith(
|
|
||||||
"https://www.firefox.com/en-US/download/"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("notifyFirefoxBrowserMissing", () => {
|
|
||||||
test("creates notification", () => {
|
|
||||||
notifyFirefoxBrowserMissing();
|
|
||||||
|
|
||||||
expect(Notification).toHaveBeenCalledWith({
|
|
||||||
title: "Firefox Missing",
|
|
||||||
body: "Firefox was not found on your system. Click here to follow the installation guide",
|
|
||||||
icon: "/icons/error.png",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockShow).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("opens installation guide when clicked", () => {
|
|
||||||
notifyFirefoxBrowserMissing();
|
|
||||||
|
|
||||||
const callback = mockOn.mock.calls[0][1];
|
|
||||||
callback();
|
|
||||||
|
|
||||||
expect(shell.openExternal).toHaveBeenCalledWith(
|
|
||||||
"https://www.firefox.com/en-US/download/"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
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([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
BIN
theme/dark.zip
BIN
theme/dark.zip
Binary file not shown.
Reference in New Issue
Block a user