Compare commits
8 Commits
v2.0.0-rc.
...
v2.0.0-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
| 7490f269b0 | |||
| c7f919fe18 | |||
| 7c85d91b85 | |||
| c11a1eed35 | |||
| 7544f56ba9 | |||
| 1a01d29f19 | |||
| 315c2e911b | |||
| 28ff6ccc68 |
16
.gitignore
vendored
@@ -1,5 +1,11 @@
|
||||
node_modules
|
||||
electron
|
||||
dist
|
||||
.env
|
||||
build
|
||||
desktop/node_modules
|
||||
desktop/electron
|
||||
desktop/dist
|
||||
desktop/.env
|
||||
desktop/build
|
||||
|
||||
docker/node_modules
|
||||
docker/electron
|
||||
docker/dist
|
||||
docker/.env
|
||||
docker/build
|
||||
|
||||
44
README.md
@@ -1,31 +1,55 @@
|
||||
# 🎀 WaifuBoard
|
||||
|
||||
**Lightweight all-in-one app for boorus, manga and light novels — no sources included, total freedom via extensions.**
|
||||
**Lightweight all-in-one app for boorus, anime, manga and light novels — no sources included, total freedom via extensions.**
|
||||
|
||||
<img src="public/banner.png" alt="WaifuBoard Hero" width="100%"/>
|
||||
<img src="assets/hero.png" alt="WaifuBoard Hero" width="100%"/>
|
||||
<div align="center">
|
||||
|
||||
[](https://waifuboard.app)
|
||||
[](https://git.waifuboard.app/ItsSkaiya/WaifuBoard/releases/latest)
|
||||
[](https://git.waifuboard.app/ItsSkaiya/WaifuBoard-Extensions)
|
||||
|
||||
**[Website](https://waifuboard.app)** • **[Documentation](https://waifuboard.app/docs)** • **[Download Latest](https://git.waifuboard.app/ItsSkaiya/WaifuBoard/releases/latest)** • **[Discord (soon)](#)**
|
||||
**[Website](https://waifuboard.app)** • **[Documentation](https://waifuboard.app/docs)** • **[Download Latest](https://git.waifuboard.app/ItsSkaiya/WaifuBoard/releases/latest)** • **[Discord](https://discord.gg/DAVNf7erRJ)**
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
## 🚀 What is WaifuBoard?
|
||||
|
||||
**A lightweight and privacy-friendly desktop app designed around modular extensions.** WaifuBoard delivers a clean, organized reading experience for boorus, manga, and light novels, powered entirely by community-made sources.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- Lightweight
|
||||
- Discord Rich Presence
|
||||
- Super clean & fast UI
|
||||
- Built-in **Extension Marketplace**
|
||||
- Local lists & anilist integration
|
||||
- Multi user support
|
||||
- Server version (Coming soon!)
|
||||
- Fully open-source & community-driven
|
||||
- Future mobile ports planned
|
||||
|
||||
> ⚠️ **Note**
|
||||
>
|
||||
> Release candidates may contain breaking changes and unresolved bugs.
|
||||
> Expect frequent updates and major changes between versions.
|
||||
|
||||
## 🖼️ Gallery
|
||||
|
||||
### 👥 Multi-User Profiles
|
||||
<img src="assets/users.gif" alt="Multi-user profiles" width="100%"/>
|
||||
|
||||
### 📖 Reader
|
||||
|
||||
#### Manga
|
||||
<img src="assets/manga.png" alt="Manga reader" width="100%"/>
|
||||
|
||||
#### Manhwa
|
||||
<img src="assets/manhwa.png" alt="Manhwa reader" width="100%"/>
|
||||
|
||||
#### Light Novel
|
||||
<img src="assets/novel.gif" alt="Light novel reader" width="100%"/>
|
||||
|
||||
### 📅 Schedule & Tracking
|
||||
<img src="assets/schedule.png" alt="Anime schedule" width="100%"/>
|
||||
|
||||
### 📚 Lists (Local & AniList)
|
||||
<img src="assets/list.gif" alt="Lists and tracking" width="100%"/>
|
||||
|
||||
|
||||
## 🖥️ Download & Platform Support
|
||||
|
||||
|
||||
BIN
assets/hero.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
assets/list.gif
Normal file
|
After Width: | Height: | Size: 6.2 MiB |
BIN
assets/manga.png
Normal file
|
After Width: | Height: | Size: 756 KiB |
BIN
assets/manhwa.png
Normal file
|
After Width: | Height: | Size: 577 KiB |
BIN
assets/novel.gif
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
assets/schedule.png
Normal file
|
After Width: | Height: | Size: 227 KiB |
BIN
assets/users.gif
Normal file
|
After Width: | Height: | Size: 5.2 MiB |
5
desktop/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
electron
|
||||
dist
|
||||
.env
|
||||
build
|
||||
121
desktop/main.js
Normal file
@@ -0,0 +1,121 @@
|
||||
const { app, BrowserWindow, ipcMain } = require('electron');
|
||||
const { fork } = require('child_process');
|
||||
const path = require('path');
|
||||
const log = require('electron-log');
|
||||
|
||||
log.transports.file.level = 'info';
|
||||
log.format = '[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] {text}';
|
||||
|
||||
let win;
|
||||
let backend;
|
||||
const net = require('net');
|
||||
|
||||
function waitForServer(port, host = '127.0.0.1', timeout = 10000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const start = Date.now();
|
||||
|
||||
const check = () => {
|
||||
const socket = new net.Socket();
|
||||
|
||||
socket
|
||||
.once('connect', () => {
|
||||
socket.destroy();
|
||||
resolve();
|
||||
})
|
||||
.once('error', () => {
|
||||
socket.destroy();
|
||||
if (Date.now() - start > timeout) {
|
||||
reject(new Error('Backend timeout'));
|
||||
} else {
|
||||
setTimeout(check, 200);
|
||||
}
|
||||
})
|
||||
.connect(port, host);
|
||||
};
|
||||
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
function startBackend() {
|
||||
backend = fork(path.join(__dirname, 'server.js'), [], {
|
||||
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
|
||||
env: {
|
||||
...process.env,
|
||||
IS_PACKAGED: app.isPackaged ? 'true' : 'false'
|
||||
}
|
||||
});
|
||||
|
||||
log.info('Starting backend process...');
|
||||
|
||||
backend.stdout.on('data', (data) => {
|
||||
log.info(`[Backend]: ${data.toString().trim()}`);
|
||||
});
|
||||
|
||||
backend.stderr.on('data', (data) => {
|
||||
log.error(`[Backend ERROR]: ${data.toString().trim()}`);
|
||||
});
|
||||
|
||||
backend.on('exit', (code) => {
|
||||
log.warn(`Backend process exited with code: ${code}`);
|
||||
});
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
win = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
frame: false,
|
||||
titleBarStyle: "hidden",
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "preload.js"),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true
|
||||
}
|
||||
});
|
||||
|
||||
win.setMenu(null);
|
||||
|
||||
win.loadURL('http://localhost:54322');
|
||||
|
||||
win.on('closed', () => {
|
||||
win = null;
|
||||
});
|
||||
}
|
||||
|
||||
ipcMain.on("win:minimize", () => win.minimize());
|
||||
ipcMain.on("win:maximize", () => {
|
||||
if (win.isMaximized()) {
|
||||
win.unmaximize();
|
||||
} else {
|
||||
win.maximize();
|
||||
}
|
||||
});
|
||||
ipcMain.on("win:close", () => win.close());
|
||||
|
||||
process.on('uncaughtException', (err) => {
|
||||
log.error('Critical unhandled error in Main:', err);
|
||||
});
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
log.info('--- Application Started ---');
|
||||
console.log("Logs location:", log.transports.file.getFile().path);
|
||||
startBackend();
|
||||
await waitForServer(54322);
|
||||
createWindow();
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||
});
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
log.info('Closing all windows...');
|
||||
if (backend) {
|
||||
backend.kill();
|
||||
log.info('Backend process terminated.');
|
||||
}
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
42
package-lock.json → desktop/package-lock.json
generated
@@ -11,10 +11,11 @@
|
||||
"dependencies": {
|
||||
"@fastify/static": "^8.3.0",
|
||||
"@ryuziii/discord-rpc": "^1.0.1-rc.1",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"bindings": "^1.5.0",
|
||||
"cheerio": "^1.1.2",
|
||||
"dotenv": "^17.2.3",
|
||||
"electron-log": "^5.4.3",
|
||||
"fastify": "^5.6.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"node-addon-api": "^8.5.0",
|
||||
@@ -1972,18 +1973,13 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bcrypt": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
|
||||
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-addon-api": "^8.3.0",
|
||||
"node-gyp-build": "^4.8.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
"node_modules/bcryptjs": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
||||
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"bin": {
|
||||
"bcrypt": "bin/bcrypt"
|
||||
}
|
||||
},
|
||||
"node_modules/bindings": {
|
||||
@@ -3402,6 +3398,15 @@
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-log": {
|
||||
"version": "5.4.3",
|
||||
"resolved": "https://registry.npmjs.org/electron-log/-/electron-log-5.4.3.tgz",
|
||||
"integrity": "sha512-sOUsM3LjZdugatazSQ/XTyNcw8dfvH1SYhXWiJyfYodAAKOZdHs0txPiLDXFzOZbhXgAgshQkshH2ccq0feyLQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-publish": {
|
||||
"version": "26.0.11",
|
||||
"resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.0.11.tgz",
|
||||
@@ -5365,17 +5370,6 @@
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp-build": {
|
||||
"version": "4.8.4",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
||||
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"node-gyp-build": "bin.js",
|
||||
"node-gyp-build-optional": "optional.js",
|
||||
"node-gyp-build-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp/node_modules/chownr": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
|
||||
@@ -4,10 +4,8 @@
|
||||
"description": "",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "tsc && node server.js",
|
||||
"electron": "tsc && electron .",
|
||||
"dist": "npm run build && electron-builder"
|
||||
"start": "tsc && electron .",
|
||||
"dist": "tsc && electron-builder"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
@@ -16,10 +14,11 @@
|
||||
"dependencies": {
|
||||
"@fastify/static": "^8.3.0",
|
||||
"@ryuziii/discord-rpc": "^1.0.1-rc.1",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"bindings": "^1.5.0",
|
||||
"cheerio": "^1.1.2",
|
||||
"dotenv": "^17.2.3",
|
||||
"electron-log": "^5.4.3",
|
||||
"fastify": "^5.6.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"node-addon-api": "^8.5.0",
|
||||
@@ -50,7 +49,11 @@
|
||||
"public/assets/*"
|
||||
],
|
||||
"extraResources": [
|
||||
"./.env"
|
||||
{
|
||||
"from": "C:\\Users\\synta\\AppData\\Local\\ms-playwright\\chromium_headless_shell-1200",
|
||||
"to": "playwright/chromium"
|
||||
},
|
||||
".env"
|
||||
],
|
||||
"win": {
|
||||
"target": "portable",
|
||||
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 123 KiB |
@@ -9,13 +9,14 @@ const { initDatabase } = require("./electron/shared/database");
|
||||
const { loadExtensions } = require("./electron/shared/extensions");
|
||||
const { init } = require("./electron/api/rpc/rpc.controller");
|
||||
const dotenv = require("dotenv");
|
||||
const envPath = process.resourcesPath
|
||||
|
||||
const isPackaged = process.env.IS_PACKAGED === "true";
|
||||
|
||||
const envPath = isPackaged
|
||||
? path.join(process.resourcesPath, ".env")
|
||||
: path.join(__dirname, ".env");
|
||||
|
||||
// Attempt to load it and log the result to be sure
|
||||
dotenv.config({ path: envPath });
|
||||
|
||||
dotenv.config({ path: envPath, override: false });
|
||||
const viewsRoutes = require("./electron/views/views.routes");
|
||||
const animeRoutes = require("./electron/api/anime/anime.routes");
|
||||
const booksRoutes = require("./electron/api/books/books.routes");
|
||||
186
desktop/src/api/user/user.service.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import {queryAll, queryOne, run} from '../../shared/database';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
const USER_DB_NAME = 'userdata';
|
||||
const SALT_ROUNDS = 10;
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
profile_picture_url: string | null;
|
||||
has_password: boolean;
|
||||
}
|
||||
|
||||
export async function userExists(id: number): Promise<boolean> {
|
||||
const sql = 'SELECT 1 FROM User WHERE id = ?';
|
||||
const row = await queryOne(sql, [id], USER_DB_NAME);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
export async function createUser(username: string, profilePictureUrl?: string, password?: string): Promise<{ lastID: number }> {
|
||||
let passwordHash = null;
|
||||
|
||||
if (password && password.trim()) {
|
||||
passwordHash = await bcrypt.hash(password.trim(), SALT_ROUNDS);
|
||||
}
|
||||
|
||||
const sql = `
|
||||
INSERT INTO User (username, profile_picture_url, password_hash)
|
||||
VALUES (?, ?, ?)
|
||||
`;
|
||||
const params = [username, profilePictureUrl || null, passwordHash];
|
||||
|
||||
const result = await run(sql, params, USER_DB_NAME);
|
||||
|
||||
return { lastID: result.lastID };
|
||||
}
|
||||
|
||||
export async function updateUser(userId: number, updates: any): Promise<any> {
|
||||
const fields: string[] = [];
|
||||
const values: (string | number | null)[] = [];
|
||||
|
||||
if (updates.username !== undefined) {
|
||||
fields.push('username = ?');
|
||||
values.push(updates.username);
|
||||
}
|
||||
|
||||
if (updates.profilePictureUrl !== undefined) {
|
||||
fields.push('profile_picture_url = ?');
|
||||
values.push(updates.profilePictureUrl);
|
||||
}
|
||||
|
||||
if (updates.password !== undefined) {
|
||||
if (updates.password === null || updates.password === '') {
|
||||
// Eliminar contraseña
|
||||
fields.push('password_hash = ?');
|
||||
values.push(null);
|
||||
} else {
|
||||
// Actualizar contraseña
|
||||
const hash = await bcrypt.hash(updates.password.trim(), SALT_ROUNDS);
|
||||
fields.push('password_hash = ?');
|
||||
values.push(hash);
|
||||
}
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
return { changes: 0, lastID: userId };
|
||||
}
|
||||
|
||||
const setClause = fields.join(', ');
|
||||
const sql = `UPDATE User SET ${setClause} WHERE id = ?`;
|
||||
values.push(userId);
|
||||
|
||||
return await run(sql, values, USER_DB_NAME);
|
||||
}
|
||||
|
||||
export async function deleteUser(userId: number): Promise<any> {
|
||||
await run(
|
||||
`DELETE FROM ListEntry WHERE user_id = ?`,
|
||||
[userId],
|
||||
USER_DB_NAME
|
||||
);
|
||||
|
||||
await run(
|
||||
`DELETE FROM UserIntegration WHERE user_id = ?`,
|
||||
[userId],
|
||||
USER_DB_NAME
|
||||
);
|
||||
|
||||
await run(
|
||||
`DELETE FROM favorites WHERE user_id = ?`,
|
||||
[userId],
|
||||
'favorites'
|
||||
);
|
||||
|
||||
const result = await run(
|
||||
`DELETE FROM User WHERE id = ?`,
|
||||
[userId],
|
||||
USER_DB_NAME
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getAllUsers(): Promise<User[]> {
|
||||
const sql = `
|
||||
SELECT
|
||||
id,
|
||||
username,
|
||||
profile_picture_url,
|
||||
CASE WHEN password_hash IS NOT NULL THEN 1 ELSE 0 END as has_password
|
||||
FROM User
|
||||
ORDER BY id
|
||||
`;
|
||||
|
||||
const users = await queryAll(sql, [], USER_DB_NAME);
|
||||
|
||||
return users.map((user: any) => ({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
profile_picture_url: user.profile_picture_url || null,
|
||||
has_password: !!user.has_password
|
||||
})) as User[];
|
||||
}
|
||||
|
||||
export async function getUserById(id: number): Promise<User | null> {
|
||||
const sql = `
|
||||
SELECT
|
||||
id,
|
||||
username,
|
||||
profile_picture_url,
|
||||
CASE WHEN password_hash IS NOT NULL THEN 1 ELSE 0 END as has_password
|
||||
FROM User
|
||||
WHERE id = ?
|
||||
`;
|
||||
|
||||
const user = await queryOne(sql, [id], USER_DB_NAME);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
profile_picture_url: user.profile_picture_url || null,
|
||||
has_password: !!user.has_password
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyPassword(userId: number, password: string): Promise<boolean> {
|
||||
const sql = 'SELECT password_hash FROM User WHERE id = ?';
|
||||
const user = await queryOne(sql, [userId], USER_DB_NAME);
|
||||
|
||||
if (!user || !user.password_hash) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return await bcrypt.compare(password, user.password_hash);
|
||||
}
|
||||
|
||||
export async function getAniListIntegration(userId: number) {
|
||||
const sql = `
|
||||
SELECT anilist_user_id, expires_at
|
||||
FROM UserIntegration
|
||||
WHERE user_id = ? AND platform = ?
|
||||
`;
|
||||
|
||||
const row = await queryOne(sql, [userId, "AniList"], USER_DB_NAME);
|
||||
|
||||
if (!row) {
|
||||
return { connected: false };
|
||||
}
|
||||
|
||||
return {
|
||||
connected: true,
|
||||
anilistUserId: row.anilist_user_id,
|
||||
expiresAt: row.expires_at
|
||||
};
|
||||
}
|
||||
|
||||
export async function removeAniListIntegration(userId: number) {
|
||||
const sql = `
|
||||
DELETE FROM UserIntegration
|
||||
WHERE user_id = ? AND platform = ?
|
||||
`;
|
||||
|
||||
return run(sql, [userId, "AniList"], USER_DB_NAME);
|
||||
}
|
||||
@@ -97,7 +97,7 @@ function applyStyles() {
|
||||
document.documentElement.style.setProperty('--ln-max-width', config.ln.maxWidth + 'px');
|
||||
document.documentElement.style.setProperty('--ln-font-family', config.ln.fontFamily);
|
||||
document.documentElement.style.setProperty('--ln-text-color', config.ln.textColor);
|
||||
document.documentElement.style.setProperty('--bg-base', config.ln.bg);
|
||||
document.documentElement.style.setProperty('--color-bg-base', config.ln.bg);
|
||||
document.documentElement.style.setProperty('--ln-text-align', config.ln.textAlign);
|
||||
}
|
||||
|
||||
102
desktop/src/scripts/updateNotifier.js
Normal file
@@ -0,0 +1,102 @@
|
||||
const Gitea_OWNER = "ItsSkaiya";
|
||||
const Gitea_REPO = "WaifuBoard";
|
||||
const CURRENT_VERSION = "v2.0.0-rc.1";
|
||||
const UPDATE_CHECK_INTERVAL = 5 * 60 * 1000;
|
||||
|
||||
let currentVersionDisplay;
|
||||
let latestVersionDisplay;
|
||||
let updateToast;
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
currentVersionDisplay = document.getElementById("currentVersionDisplay");
|
||||
latestVersionDisplay = document.getElementById("latestVersionDisplay");
|
||||
updateToast = document.getElementById("updateToast");
|
||||
|
||||
if (currentVersionDisplay) {
|
||||
currentVersionDisplay.textContent = CURRENT_VERSION;
|
||||
}
|
||||
|
||||
checkForUpdates();
|
||||
|
||||
setInterval(checkForUpdates, UPDATE_CHECK_INTERVAL);
|
||||
});
|
||||
|
||||
function showToast(latestVersion) {
|
||||
if (latestVersionDisplay && updateToast) {
|
||||
latestVersionDisplay.textContent = latestVersion;
|
||||
updateToast.classList.add("update-available");
|
||||
updateToast.classList.remove("hidden");
|
||||
} else {
|
||||
console.error(
|
||||
"Error: Cannot display toast because one or more DOM elements were not found.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function hideToast() {
|
||||
if (updateToast) {
|
||||
updateToast.classList.add("hidden");
|
||||
updateToast.classList.remove("update-available");
|
||||
}
|
||||
}
|
||||
|
||||
function isVersionOutdated(versionA, versionB) {
|
||||
const vA = versionA.replace(/^v/, "").split(".").map(Number);
|
||||
const vB = versionB.replace(/^v/, "").split(".").map(Number);
|
||||
|
||||
for (let i = 0; i < Math.max(vA.length, vB.length); i++) {
|
||||
const numA = vA[i] || 0;
|
||||
const numB = vB[i] || 0;
|
||||
|
||||
if (numA < numB) return true;
|
||||
if (numA > numB) return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function checkForUpdates() {
|
||||
console.log(`Checking for updates for ${Gitea_OWNER}/${Gitea_REPO}...`);
|
||||
|
||||
const apiUrl = `https://git.waifuboard.app/api/v1/repos/${Gitea_OWNER}/${Gitea_REPO}/releases/latest`;
|
||||
|
||||
try {
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
console.info("No releases found for this repository.");
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
`Gitea API error: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const latestVersion = data.tag_name;
|
||||
|
||||
if (!latestVersion) {
|
||||
console.warn("Release found but no tag_name present");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Latest Gitea Release: ${latestVersion}`);
|
||||
|
||||
if (isVersionOutdated(CURRENT_VERSION, latestVersion)) {
|
||||
console.warn("Update available!");
|
||||
showToast(latestVersion);
|
||||
} else {
|
||||
console.info("Package is up to date.");
|
||||
hideToast();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch Gitea release:", error);
|
||||
}
|
||||
}
|
||||
147
desktop/src/shared/headless.js
Normal file
@@ -0,0 +1,147 @@
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const { chromium } = require("playwright-core");
|
||||
|
||||
let browser;
|
||||
let context;
|
||||
const BLOCK_LIST = [
|
||||
"google-analytics", "doubleclick", "facebook", "twitter",
|
||||
"adsystem", "analytics", "tracker", "pixel", "quantserve", "newrelic"
|
||||
];
|
||||
|
||||
function isPackaged() {
|
||||
return process.env.IS_PACKAGED === "true";
|
||||
}
|
||||
|
||||
function getChromiumPath() {
|
||||
if (isPackaged()) {
|
||||
return path.join(
|
||||
process.resourcesPath,
|
||||
"playwright",
|
||||
"chromium",
|
||||
"chrome-headless-shell-win64",
|
||||
"chrome-headless-shell.exe"
|
||||
);
|
||||
}
|
||||
|
||||
return chromium.executablePath();
|
||||
}
|
||||
|
||||
async function initHeadless() {
|
||||
if (browser) return;
|
||||
|
||||
const exePath = getChromiumPath();
|
||||
|
||||
if (!fs.existsSync(exePath)) {
|
||||
throw new Error("Chromium not found: " + exePath);
|
||||
}
|
||||
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath: exePath,
|
||||
args: [
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-gpu",
|
||||
"--disable-extensions",
|
||||
"--disable-background-networking",
|
||||
"--disable-sync",
|
||||
"--disable-translate",
|
||||
"--mute-audio",
|
||||
"--no-first-run",
|
||||
"--no-zygote",
|
||||
]
|
||||
});
|
||||
context = await browser.newContext({
|
||||
userAgent:
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/122.0.0.0 Safari/537.36"
|
||||
});
|
||||
}
|
||||
|
||||
async function turboScroll(page) {
|
||||
await page.evaluate(() => {
|
||||
return new Promise((resolve) => {
|
||||
let last = 0;
|
||||
let same = 0;
|
||||
const timer = setInterval(() => {
|
||||
const h = document.body.scrollHeight;
|
||||
window.scrollTo(0, h);
|
||||
if (h === last) {
|
||||
same++;
|
||||
if (same >= 5) {
|
||||
clearInterval(timer);
|
||||
resolve();
|
||||
}
|
||||
} else {
|
||||
same = 0;
|
||||
last = h;
|
||||
}
|
||||
}, 20);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function scrape(url, handler, options = {}) {
|
||||
const {
|
||||
waitUntil = "domcontentloaded",
|
||||
waitSelector = null,
|
||||
timeout = 10000,
|
||||
scrollToBottom = false,
|
||||
renderWaitTime = 0,
|
||||
loadImages = true
|
||||
} = options;
|
||||
if (!browser) await initHeadless();
|
||||
const page = await context.newPage();
|
||||
let collectedRequests = [];
|
||||
await page.route("**/*", (route) => {
|
||||
const req = route.request();
|
||||
const rUrl = req.url().toLowerCase();
|
||||
const type = req.resourceType();
|
||||
|
||||
collectedRequests.push({
|
||||
url: req.url(),
|
||||
method: req.method(),
|
||||
resourceType: type
|
||||
});
|
||||
|
||||
if (type === "font" || type === "media" || type === "manifest")
|
||||
return route.abort();
|
||||
|
||||
if (BLOCK_LIST.some(k => rUrl.includes(k)))
|
||||
return route.abort();
|
||||
|
||||
if (!loadImages && (
|
||||
type === "image" || rUrl.match(/\.(jpg|jpeg|png|gif|webp|svg)$/)
|
||||
)) return route.abort();
|
||||
route.continue();
|
||||
});
|
||||
await page.goto(url, { waitUntil, timeout });
|
||||
if (waitSelector) {
|
||||
try {
|
||||
await page.waitForSelector(waitSelector, { timeout });
|
||||
} catch {}
|
||||
}
|
||||
if (scrollToBottom) {
|
||||
await turboScroll(page);
|
||||
}
|
||||
if (renderWaitTime > 0) {
|
||||
await new Promise(r => setTimeout(r, renderWaitTime));
|
||||
}
|
||||
const result = await handler(page);
|
||||
await page.close();
|
||||
|
||||
return { result, requests: collectedRequests };
|
||||
}
|
||||
|
||||
async function closeScraper() {
|
||||
if (context) await context.close();
|
||||
if (browser) await browser.close();
|
||||
context = null;
|
||||
browser = null;
|
||||
}
|
||||
module.exports = {
|
||||
initHeadless,
|
||||
scrape,
|
||||
closeScraper
|
||||
};
|
||||