├── .github └── FUNDING.yml ├── assets ├── attributions.txt └── electron.ico ├── .vscode └── settings.json ├── forge.env.d.ts ├── .prettierrc ├── src ├── main │ ├── types │ │ ├── Bootstrap.ts │ │ ├── App.ts │ │ ├── FileConfig.ts │ │ ├── MainInvocableMethods.ts │ │ └── UIConfig.ts │ ├── views │ │ ├── editor │ │ │ ├── renderer.ts │ │ │ ├── editor-view-preload.ts │ │ │ ├── index.html │ │ │ └── EditorView.ts │ │ └── modal │ │ │ ├── renderer.ts │ │ │ ├── modal-view-preload.ts │ │ │ ├── index.html │ │ │ └── ModalView.ts │ ├── assets │ │ └── help │ │ │ ├── logger.console │ │ │ ├── log.level.md │ │ │ └── date.time.format.md │ │ │ ├── logger.file.1 │ │ │ ├── log.level.md │ │ │ ├── date.time.format.md │ │ │ ├── log.filename.md │ │ │ └── log.dir.md │ │ │ ├── request │ │ │ ├── proxy.reject.unauthorized.tls.md │ │ │ └── proxy.url.md │ │ │ ├── include │ │ │ ├── all.media.variants.md │ │ │ ├── audio.by.filename.md │ │ │ ├── images.by.filename.md │ │ │ └── attachments.by.filename.md │ │ │ ├── downloader │ │ │ ├── max.video.resolution.md │ │ │ ├── stop.on.md │ │ │ ├── cookie.md │ │ │ ├── target.md │ │ │ ├── path.to.deno.md │ │ │ ├── use.status.cache.md │ │ │ └── path.to.ffmpeg.md │ │ │ ├── embed.downloader.vimeo │ │ │ ├── helper.ytdlp.path.md │ │ │ ├── type.md │ │ │ └── exec.md │ │ │ ├── patreon.dl.gui │ │ │ └── connect.youtube.md │ │ │ ├── output │ │ │ ├── campaign.dir.name.format.md │ │ │ ├── content.dir.name.format.md │ │ │ └── media.filename.format.md │ │ │ └── embed.downloader.youtube │ │ │ └── exec.md │ ├── ui │ │ ├── helpers │ │ │ └── Toast.ts │ │ ├── editor │ │ │ ├── components │ │ │ │ ├── Common.tsx │ │ │ │ ├── CheckboxRow.tsx │ │ │ │ ├── HelpIcon.tsx │ │ │ │ ├── SelectRow.tsx │ │ │ │ └── TextInputRow.tsx │ │ │ ├── AlertsBox.tsx │ │ │ ├── EditorPanel.tsx │ │ │ ├── OtherBox.tsx │ │ │ ├── DownloadBox.tsx │ │ │ ├── WebBrowserToolbar.tsx │ │ │ ├── EditorToolbar.tsx │ │ │ ├── OutputBox.tsx │ │ │ ├── NetworkBox.tsx │ │ │ └── LoggingBox.tsx │ │ ├── modals │ │ │ ├── ModalWrapper.tsx │ │ │ ├── AboutModal.tsx │ │ │ ├── HelpModal.tsx │ │ │ ├── PreviewModal.tsx │ │ │ ├── ConfirmSaveModal.tsx │ │ │ └── WebBrowserSettingsModal.tsx │ │ ├── styles │ │ │ └── main.css │ │ ├── contexts │ │ │ ├── ConfigProvider.tsx │ │ │ └── EditorContextProvider.tsx │ │ └── App.tsx │ ├── main-window-preload.ts │ ├── util │ │ ├── WindowState.ts │ │ ├── Help.ts │ │ ├── ObjectHelper.ts │ │ ├── Config.ts │ │ ├── RecentDocuments.ts │ │ └── YouTubeConfigurator.ts │ ├── mixins │ │ ├── WebBrowserEvents.ts │ │ ├── EditorEvents.ts │ │ └── AppMenu.ts │ ├── config │ │ └── WebBrowserSettings.ts │ ├── DownloaderConsoleLogger.ts │ └── Constants.ts ├── server-console │ ├── ui │ │ ├── styles │ │ │ └── main.css │ │ ├── ServerConsoleApp.tsx │ │ ├── components │ │ │ ├── HelpIcon.tsx │ │ │ ├── ServerErrorModal.tsx │ │ │ ├── SelectRow.tsx │ │ │ ├── TextInputRow.tsx │ │ │ └── ServerFormModal.tsx │ │ └── ServerConsoleToolbar.tsx │ ├── renderer.ts │ ├── Constants.ts │ ├── index.html │ ├── server-console-preload.ts │ ├── util │ │ └── Server.ts │ ├── types │ │ ├── Server.ts │ │ ├── ServerConsoleEvents.ts │ │ └── ServerConsoleInvocableMethods.ts │ ├── config │ │ └── Servers.ts │ └── ServerConsoleWindow.ts ├── resources │ ├── packaging │ │ ├── deb │ │ │ ├── postrm │ │ │ └── postinst │ │ ├── rpm │ │ │ ├── postuninstall.sh │ │ │ └── postinstall.sh │ │ └── patreon-dl-gui-server-console.desktop │ └── patreon-dl-vimeo.js ├── common │ ├── Constants.ts │ ├── ui │ │ ├── index.ts │ │ ├── Loader.tsx │ │ ├── styles │ │ │ └── components.css │ │ └── components │ │ │ ├── CustomScrollbars.tsx │ │ │ └── ToolbarButton.tsx │ ├── util │ │ ├── Misc.ts │ │ ├── FS.ts │ │ └── WindowState.ts │ ├── types │ │ └── Utility.ts │ ├── RendererAPI.ts │ └── ProcessBase.ts └── index.ts ├── vite.preload.config.ts ├── tsconfig.json ├── vite.renderer.config.ts ├── eslint.config.mjs ├── vite.main.config.ts ├── .gitignore ├── misc └── rpm-spec.ejs └── package.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: patrickkfkan 2 | -------------------------------------------------------------------------------- /assets/attributions.txt: -------------------------------------------------------------------------------- 1 | electron.ico - icons-icons.com -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } -------------------------------------------------------------------------------- /forge.env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "none", 3 | "experimentalTernaries": true 4 | } -------------------------------------------------------------------------------- /assets/electron.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patrickkfkan/patreon-dl-gui/HEAD/assets/electron.ico -------------------------------------------------------------------------------- /src/main/types/Bootstrap.ts: -------------------------------------------------------------------------------- 1 | export interface BootstrapData { 2 | browserExecutablePath: string; 3 | } 4 | -------------------------------------------------------------------------------- /src/main/views/editor/renderer.ts: -------------------------------------------------------------------------------- 1 | import { loadMainUI } from "../../../common/ui/Loader"; 2 | 3 | loadMainUI(); 4 | -------------------------------------------------------------------------------- /src/server-console/ui/styles/main.css: -------------------------------------------------------------------------------- 1 | .server-list-table .rdg-cell[aria-selected="true"] { 2 | outline: none; 3 | } 4 | -------------------------------------------------------------------------------- /src/main/views/modal/renderer.ts: -------------------------------------------------------------------------------- 1 | import { loadModalWrapper } from "../../../common/ui/Loader"; 2 | 3 | loadModalWrapper(); 4 | -------------------------------------------------------------------------------- /src/server-console/renderer.ts: -------------------------------------------------------------------------------- 1 | import { loadServerConsoleUI } from "../common/ui/Loader"; 2 | 3 | loadServerConsoleUI(); 4 | -------------------------------------------------------------------------------- /src/resources/packaging/deb/postrm: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | rm -f /usr/share/applications/patreon-dl-gui-server-console.desktop 3 | update-desktop-database /usr/share/applications || true -------------------------------------------------------------------------------- /src/resources/packaging/rpm/postuninstall.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | rm -f /usr/share/applications/patreon-dl-gui-server-console.desktop 3 | update-desktop-database /usr/share/applications || true 4 | -------------------------------------------------------------------------------- /src/resources/packaging/rpm/postinstall.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | install -Dm644 /usr/lib/patreon-dl-gui/resources/patreon-dl-gui-server-console.desktop /usr/share/applications/patreon-dl-gui-server-console.desktop 3 | update-desktop-database /usr/share/applications || true -------------------------------------------------------------------------------- /src/common/Constants.ts: -------------------------------------------------------------------------------- 1 | import { app } from "electron"; 2 | import envPaths from "env-paths"; 3 | 4 | export const APP_DATA_PATH = envPaths(app.getName(), { 5 | suffix: "" 6 | }).data; 7 | 8 | export const APP_URL = "https://github.com/patrickkfkan/patreon-dl-gui"; 9 | -------------------------------------------------------------------------------- /src/resources/packaging/deb/postinst: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | install -Dm644 /usr/lib/patreon-dl-gui/resources/patreon-dl-gui-server-console.desktop /usr/share/applications/patreon-dl-gui-server-console.desktop 4 | update-desktop-database /usr/share/applications || true -------------------------------------------------------------------------------- /src/resources/packaging/patreon-dl-gui-server-console.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=patreon-dl-gui (Server Console) 3 | Comment=Browse content downloaded by patreon-dl 4 | Exec=/usr/bin/patreon-dl-gui --server-console 5 | Icon=patreon-dl-gui 6 | Type=Application 7 | Categories=Utility; -------------------------------------------------------------------------------- /src/main/assets/help/logger.console/log.level.md: -------------------------------------------------------------------------------- 1 | ## Log level 2 | 3 | --- 4 | 5 | Sets the logging level. 6 | 7 | Log levels are defined in the following order: 8 | 9 | - debug 10 | - info 11 | - warn 12 | - error 13 | 14 | Messages up to the specified level will be included in the logs. 15 | -------------------------------------------------------------------------------- /src/main/assets/help/logger.file.1/log.level.md: -------------------------------------------------------------------------------- 1 | ## Log level 2 | 3 | --- 4 | 5 | Sets the logging level. 6 | 7 | Log levels are defined in the following order: 8 | 9 | - debug 10 | - info 11 | - warn 12 | - error 13 | 14 | Messages up to the specified level will be included in the logs. 15 | -------------------------------------------------------------------------------- /src/main/assets/help/request/proxy.reject.unauthorized.tls.md: -------------------------------------------------------------------------------- 1 | ## Reject unauthorized TLS (proxy) 2 | 3 | --- 4 | 5 | When connecting to a proxy server through SSL/TLS, this option indicates whether invalid certificates should be rejected. You should uncheck this option if your proxy server uses self-signed certs. 6 | -------------------------------------------------------------------------------- /src/main/assets/help/include/all.media.variants.md: -------------------------------------------------------------------------------- 1 | ## All media variants 2 | 3 | --- 4 | 5 | Some media items may be provided in different sizes and formats. For example, images may have "large" and "small" variations. Enable this option to download all of them. If disabled, only the best quality variant will be downloaded. 6 | -------------------------------------------------------------------------------- /src/main/assets/help/downloader/max.video.resolution.md: -------------------------------------------------------------------------------- 1 | ## Max video resolution 2 | 3 | The maximum video resolution to download (height in pixels). 4 | 5 | Applies to Patreon-hosted videos and embedded YouTube videos when using the built-in YouTube downloader. 6 | 7 | Set to "None" to download the highest resolution available. 8 | -------------------------------------------------------------------------------- /src/main/ui/helpers/Toast.ts: -------------------------------------------------------------------------------- 1 | import type { TypeOptions } from "react-toastify"; 2 | import { toast } from "react-toastify"; 3 | 4 | export function showToast(type: TypeOptions, message: string) { 5 | toast(message, { 6 | type, 7 | autoClose: 3000, 8 | position: "bottom-center", 9 | theme: "dark" 10 | }); 11 | } 12 | -------------------------------------------------------------------------------- /src/common/ui/index.ts: -------------------------------------------------------------------------------- 1 | export type AccessibilityProps = { 2 | ariaLabel?: string | null; 3 | }; 4 | 5 | export type HelpProps = 6 | | { 7 | helpTooltip?: undefined | null; 8 | helpHasMoreInfo?: undefined | null; 9 | } 10 | | { 11 | helpTooltip: string; 12 | helpHasMoreInfo?: boolean | null; 13 | }; 14 | -------------------------------------------------------------------------------- /src/main/assets/help/embed.downloader.vimeo/helper.ytdlp.path.md: -------------------------------------------------------------------------------- 1 | ## Path to yt-dlp 2 | 3 | --- 4 | 5 | The path to [yt-dlp](https://github.com/yt-dlp/yt-dlp) executable when dowloading embedded Vimeo videos through `patreon-dl`'s helper script. 6 | 7 | If not specified, `yt-dlp` will be called directly when needed, so it should be in the PATH. 8 | -------------------------------------------------------------------------------- /src/main/assets/help/logger.file.1/date.time.format.md: -------------------------------------------------------------------------------- 1 | ## Datetime format 2 | 3 | --- 4 | 5 | The string pattern used to format the date and time of log messages. 6 | 7 | Date-time formatting is provided by https://github.com/felixge/node-dateformat. Refer to the README of that project for pattern rules. 8 | 9 | --- 10 | 11 | Default: `mmm dd HH:MM:ss` 12 | -------------------------------------------------------------------------------- /src/main/assets/help/logger.console/date.time.format.md: -------------------------------------------------------------------------------- 1 | ## Datetime format 2 | 3 | --- 4 | 5 | The string pattern used to format the date and time of log messages. 6 | 7 | Date-time formatting is provided by https://github.com/felixge/node-dateformat. Refer to the README of that project for pattern rules. 8 | 9 | --- 10 | 11 | Default: `mmm dd HH:MM:ss` 12 | -------------------------------------------------------------------------------- /src/common/util/Misc.ts: -------------------------------------------------------------------------------- 1 | export function getErrorString(error: unknown): string { 2 | if (error instanceof Error) { 3 | if (error.cause) { 4 | return `${error.message}: ${getErrorString(error.cause)}`; 5 | } 6 | return error.message; 7 | } 8 | else if (typeof error === 'object') { 9 | return JSON.stringify(error); 10 | } 11 | return String(error); 12 | } -------------------------------------------------------------------------------- /src/main/types/App.ts: -------------------------------------------------------------------------------- 1 | import type { UIConfig } from "./UIConfig"; 2 | 3 | export interface Editor { 4 | id: number; 5 | name: string; 6 | filePath: string | null; 7 | config: UIConfig; 8 | modified: boolean; 9 | loadAlerts?: AlertMessage[]; 10 | promptOnSave: boolean; 11 | } 12 | 13 | export interface AlertMessage { 14 | type: "warn" | "error"; 15 | text: string; 16 | } 17 | -------------------------------------------------------------------------------- /src/main/assets/help/include/audio.by.filename.md: -------------------------------------------------------------------------------- 1 | ## Audio by filename 2 | 3 | --- 4 | 5 | If specified, only audio items with filenames matching the specified pattern will be downloaded. Leave blank to download all audio files. 6 | 7 | By default, filename patterns are case-sensitive. To ignore case, start the pattern with `!`. 8 | 9 | This setting takes a glob pattern. For example, `*.wav` will download WAV files only. 10 | -------------------------------------------------------------------------------- /src/main/assets/help/include/images.by.filename.md: -------------------------------------------------------------------------------- 1 | ## Images by filename 2 | 3 | --- 4 | 5 | If specified, only images with filenames matching the specified pattern will be downloaded. Leave blank to download all images. 6 | 7 | By default, filename patterns are case-sensitive. To ignore case, start the pattern with `!`. 8 | 9 | This setting takes a glob pattern. For example, `*.{gif,jpg,jpeg}` will download JPEGs and GIFs only. 10 | -------------------------------------------------------------------------------- /src/main/assets/help/include/attachments.by.filename.md: -------------------------------------------------------------------------------- 1 | ## Attachments by filename 2 | 3 | --- 4 | 5 | If specified, only attachments with filenames matching the specified pattern will be downloaded. Leave blank to download all attachments. 6 | 7 | By default, filename patterns are case-sensitive. To ignore case, start the pattern with `!`. 8 | 9 | This setting takes a glob pattern. For example, `*.zip` will download ZIP files only. 10 | -------------------------------------------------------------------------------- /src/main/main-window-preload.ts: -------------------------------------------------------------------------------- 1 | import { contextBridge } from "electron"; 2 | import RendererAPI from "../common/RendererAPI"; 3 | 4 | const mainAPI = new RendererAPI<"main">(); 5 | 6 | contextBridge.exposeInMainWorld("mainAPI", { 7 | on: mainAPI.on, 8 | emitMainEvent: mainAPI.emitMainEvent, 9 | invoke: mainAPI.invoke 10 | }); 11 | 12 | declare global { 13 | interface Window { 14 | mainAPI: RendererAPI<"main">; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/server-console/Constants.ts: -------------------------------------------------------------------------------- 1 | import type { DeepRequired } from "patreon-dl"; 2 | import type { ServerConsoleWindowProps } from "./ServerConsoleWindow"; 3 | 4 | export const DEFAULT_SERVER_CONSOLE_WINDOW_PROPS: ServerConsoleWindowProps & 5 | DeepRequired> = { 6 | size: { width: 800, height: 480 }, 7 | minSize: { width: 800, height: 480 }, 8 | state: "normal", 9 | devTools: false 10 | }; 11 | -------------------------------------------------------------------------------- /src/main/views/editor/editor-view-preload.ts: -------------------------------------------------------------------------------- 1 | import { contextBridge } from "electron"; 2 | import RendererAPI from "../../../common/RendererAPI"; 3 | 4 | const mainAPI = new RendererAPI<"main">(); 5 | 6 | contextBridge.exposeInMainWorld("mainAPI", { 7 | on: mainAPI.on, 8 | emitMainEvent: mainAPI.emitMainEvent, 9 | invoke: mainAPI.invoke 10 | }); 11 | 12 | declare global { 13 | interface Window { 14 | mainAPI: RendererAPI<"main">; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/main/views/modal/modal-view-preload.ts: -------------------------------------------------------------------------------- 1 | import { contextBridge } from "electron"; 2 | import RendererAPI from "../../../common/RendererAPI"; 3 | 4 | const mainAPI = new RendererAPI<"main">(); 5 | 6 | contextBridge.exposeInMainWorld("mainAPI", { 7 | on: mainAPI.on, 8 | emitMainEvent: mainAPI.emitMainEvent, 9 | invoke: mainAPI.invoke 10 | }); 11 | 12 | declare global { 13 | interface Window { 14 | mainAPI: RendererAPI<"main">; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/main/assets/help/downloader/stop.on.md: -------------------------------------------------------------------------------- 1 | ## Stop condition 2 | 3 | --- 4 | 5 | When to stop the downloader. 6 | 7 | If you choose "Previously downloaded item encountered", you must also enable "Use status cache" for this setting to be effective. 8 | 9 | If you choose "Publish date of item out of specified range", you must also set the "Published" criteria under the "Include" -> "Posts" tab (or "Products", as the case may be) for this setting to be effective. 10 | -------------------------------------------------------------------------------- /src/main/assets/help/downloader/cookie.md: -------------------------------------------------------------------------------- 1 | ## Cookie 2 | 3 | --- 4 | 5 | The cookie to use in download requests. 6 | 7 | Cookies are necessary to download locked content which you have access to through subscription / purchase. 8 | 9 | ##### Usage 10 | 11 | If not already signed into Patreon, do so in the embedded web browser. Wait for a brief moment for the cookie to be obtained. 12 | 13 | You can also enter the cookie value manually by selecting "Enter value" from the dropdown menu. 14 | -------------------------------------------------------------------------------- /src/main/views/editor/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/server-console/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /vite.preload.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | 3 | export default defineConfig((_env) => { 4 | return { 5 | build: { 6 | rollupOptions: { 7 | output: { 8 | format: "es", 9 | // Must output preload scripts as .mjs, otherwise electron will require() them 10 | entryFileNames: "[name].mjs", 11 | chunkFileNames: "[name].mjs", 12 | assetFileNames: "[name].[ext]" 13 | } 14 | } 15 | } 16 | }; 17 | }); 18 | -------------------------------------------------------------------------------- /src/main/assets/help/downloader/target.md: -------------------------------------------------------------------------------- 1 | ## Download target 2 | 3 | --- 4 | 5 | Download targets are captured through the embedded web browser. 6 | 7 | ##### Usage 8 | 9 | In the embedded web browser, visit a Patreon page showing the contents you want to download. Supported contents include: 10 | 11 | - Posts by a creator 12 | - A single post 13 | - Posts in a collection 14 | - A shop product 15 | 16 | Wait for a brief moment after the page loads, and you should see the target description appear in the textbox. 17 | -------------------------------------------------------------------------------- /src/main/views/modal/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/server-console/server-console-preload.ts: -------------------------------------------------------------------------------- 1 | import { contextBridge } from "electron"; 2 | import RendererAPI from "../common/RendererAPI"; 3 | 4 | const serverConsoleAPI = new RendererAPI<"serverConsole">(); 5 | 6 | contextBridge.exposeInMainWorld("serverConsoleAPI", { 7 | on: serverConsoleAPI.on, 8 | emitMainEvent: serverConsoleAPI.emitMainEvent, 9 | invoke: serverConsoleAPI.invoke 10 | }); 11 | 12 | declare global { 13 | interface Window { 14 | serverConsoleAPI: RendererAPI<"serverConsole">; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "jsx": "react-jsx", 5 | "allowJs": true, 6 | "module": "esnext", 7 | "esModuleInterop": true, 8 | "noImplicitAny": true, 9 | "sourceMap": true, 10 | "baseUrl": ".", 11 | "outDir": "dist", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "strict": true, 15 | "paths": { 16 | "*": ["node_modules/*"] 17 | } 18 | }, 19 | "include": ["src/**/*"], 20 | "exclude": ["node_modules", "out"] 21 | } 22 | -------------------------------------------------------------------------------- /src/main/assets/help/downloader/path.to.deno.md: -------------------------------------------------------------------------------- 1 | ## Path to Deno 2 | 3 | The path to [Deno](https://deno.com) executable. 4 | 5 | Deno is used by the built-in YouTube downloader to run third-party code in a sandboxed environment. It is optional but without it, the code runs without isolation, increasing the risk of security vulnerabilities such as unauthorized access, data corruption, or malicious behavior. For this reason, installing Deno is strongly recommended. 6 | 7 | Set this option if you have Deno installed and the executable is not in the system's PATH. 8 | -------------------------------------------------------------------------------- /src/main/types/FileConfig.ts: -------------------------------------------------------------------------------- 1 | import type { FILE_CONFIG_SECTION_PROPS } from "../Constants"; 2 | 3 | export type FileConfigSection = keyof typeof FILE_CONFIG_SECTION_PROPS; 4 | export type FileConfigProp = 5 | (typeof FILE_CONFIG_SECTION_PROPS)[S][number]; 6 | export type FileConfigContents = { 7 | [S in FileConfigSection]: { 8 | [P in FileConfigProp]: string; 9 | }; 10 | }; 11 | 12 | export interface FileConfig { 13 | editorId: number; 14 | name: string; 15 | filePath: T extends "hasPath" ? string : string | null; 16 | contents: string; 17 | } 18 | -------------------------------------------------------------------------------- /src/main/assets/help/downloader/use.status.cache.md: -------------------------------------------------------------------------------- 1 | ## Use status cache 2 | 3 | --- 4 | 5 | If enabled, `patreon-dl` creates a file that keeps track of items that have been downloaded. In subsequent downloads, the status cache is used to quickly determine whether an item previously downloaded has changed and can thus be skipped. 6 | 7 |
8 | warning 9 | If you modify or delete any downloaded file, the status cache will be unaware of it! 10 |
11 | -------------------------------------------------------------------------------- /src/main/util/WindowState.ts: -------------------------------------------------------------------------------- 1 | import type { MainWindowState as MainWindowState } from "../MainWindow"; 2 | import type { WindowState } from "../../common/util/WindowState"; 3 | import { 4 | loadLastWindowState, 5 | saveWindowState 6 | } from "../../common/util/WindowState"; 7 | 8 | export function loadLastMainWindowState() { 9 | return loadLastWindowState("main", isMainWindowState); 10 | } 11 | 12 | export function saveMainWindowState(data: MainWindowState) { 13 | saveWindowState("main", data); 14 | } 15 | 16 | function isMainWindowState(data: WindowState): data is MainWindowState { 17 | return Reflect.has(data, "editorPanelWidth"); 18 | } 19 | -------------------------------------------------------------------------------- /src/common/ui/Loader.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from "react-dom/client"; 2 | import App from "../../main/ui/App"; 3 | import ModalWrapper from "../../main/ui/modals/ModalWrapper"; 4 | import ServerConsoleApp from "../../server-console/ui/ServerConsoleApp"; 5 | 6 | export function loadMainUI() { 7 | const root = createRoot(document.body); 8 | root.render(); 9 | } 10 | 11 | export function loadModalWrapper() { 12 | const root = createRoot(document.body); 13 | root.render(); 14 | } 15 | 16 | export function loadServerConsoleUI() { 17 | const root = createRoot(document.body); 18 | root.render(); 19 | } 20 | -------------------------------------------------------------------------------- /src/common/ui/styles/components.css: -------------------------------------------------------------------------------- 1 | .scrollbars .track-vertical { 2 | top: 0; 3 | bottom: 2px; 4 | right: 0; 5 | background: var(--bs-secondary); 6 | opacity: 0.2; 7 | border-radius: 3px; 8 | } 9 | 10 | .scrollbars .thumb-vertical { 11 | border-radius: 3px; 12 | background: var(--bs-light); 13 | opacity: 0.2; 14 | } 15 | 16 | .scrollbars .track-horizontal { 17 | left: 0; 18 | bottom: 2px; 19 | right: 0; 20 | background: var(--bs-secondary); 21 | opacity: 0.2; 22 | border-radius: 3px; 23 | } 24 | 25 | .scrollbars .thumb-horizontal { 26 | border-radius: 3px; 27 | background: var(--bs-light); 28 | opacity: 0.2; 29 | } 30 | -------------------------------------------------------------------------------- /src/server-console/util/Server.ts: -------------------------------------------------------------------------------- 1 | import type { ServerList } from "../types/Server"; 2 | 3 | export function getStartableServerListEntryIds(list: ServerList) { 4 | return list.entries 5 | .filter( 6 | (entry) => 7 | entry.status === "stopped" || 8 | (entry.status === "error" && entry.action === "start") 9 | ) 10 | .map((entry) => entry.id); 11 | } 12 | 13 | export function getStoppableServerListEntryIds(list: ServerList) { 14 | return list.entries 15 | .filter( 16 | (entry) => 17 | entry.status === "running" || 18 | (entry.status === "error" && entry.action === "stop") 19 | ) 20 | .map((entry) => entry.id); 21 | } 22 | -------------------------------------------------------------------------------- /src/main/assets/help/patreon.dl.gui/connect.youtube.md: -------------------------------------------------------------------------------- 1 | ## Connect to YouTube account 2 | 3 | --- 4 | 5 | This option is only available when you choose to use the built-in downloader for downloading embedded YouTube videos. 6 | 7 | `patreon-dl` supports downloading embedded YouTube videos or from embedded YouTube video links. In addition, if you have a YouTube Premium subscription, you can connect `patreon-dl` to your account and download videos at qualities available only to Premium accounts (e.g. "1080p Premium"). 8 | 9 | To connect to your YouTube account, open the YouTube Configurator through clicking the cogs icon button or choosing from the Run menu. This option has no effect if you are not connected. 10 | -------------------------------------------------------------------------------- /src/main/assets/help/downloader/path.to.ffmpeg.md: -------------------------------------------------------------------------------- 1 | ## Path to FFmpeg 2 | 3 | --- 4 | 5 | The path to [FFmpeg](https://ffmpeg.org) executable. 6 | 7 | FFmpeg is required when downloading: 8 | 9 | - videos that are provided only in streaming format; and 10 | - embedded YouTube videos using the built-in downloader. 11 | 12 |
13 | info 14 | Not all video downloads require FFmpeg, but you should have it installed on your system anyway. 15 |
16 | 17 | If not specified, `ffmpeg` will be called directly when needed, so it should be in the PATH. 18 | -------------------------------------------------------------------------------- /src/main/assets/help/embed.downloader.vimeo/type.md: -------------------------------------------------------------------------------- 1 | ## Embedded Vimeo downloader 2 | 3 | --- 4 | 5 | Choose the method to download embedded Vimeo videos: 6 | 7 | ##### Use helper script 8 | 9 | `patreon-dl` provides a helper script to download embedded Vimeo content. If you choose this option, you must also install [yt-dlp](https://github.com/yt-dlp/yt-dlp). The helper script processes the embed HTML content in the Patreon post and instructs `yt-dlp` to do the actual downloading (using information obtained from the embed HTML). 10 | 11 | ##### Run external command 12 | 13 | Provide the full command to download embedded Vimeo content. Set placeholders in the command for `patreon-dl` to inject runtime values (see help details for the "External command" field). 14 | -------------------------------------------------------------------------------- /src/main/ui/editor/components/Common.tsx: -------------------------------------------------------------------------------- 1 | import type { UIConfig, UIConfigSection } from "../../../types/UIConfig"; 2 | import type { HelpProps } from "../../../../common/ui"; 3 | import HelpIcon from "./HelpIcon"; 4 | 5 | export type CreateHelpIconArgs = HelpProps & { 6 | config: [S, keyof UIConfig[S]]; 7 | className?: string; 8 | }; 9 | 10 | export function createHelpIcon( 11 | args: CreateHelpIconArgs 12 | ) { 13 | const { helpTooltip, helpHasMoreInfo, config, className } = args; 14 | if (!helpTooltip) { 15 | return null; 16 | } 17 | return ( 18 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/main/util/Help.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, readFileSync } from "fs"; 2 | import type { UIConfig, UIConfigSection } from "../types/UIConfig"; 3 | import path from "path"; 4 | import { dirname } from "path"; 5 | import { fileURLToPath } from "url"; 6 | 7 | const __dirname = dirname(fileURLToPath(import.meta.url)); 8 | 9 | export function getHelpContents( 10 | section: S, 11 | prop: keyof UIConfig[S] 12 | ) { 13 | const filePath = path.resolve( 14 | __dirname, 15 | `assets/main/help/${section}/${String(prop)}.md` 16 | ); 17 | if (!existsSync(filePath)) { 18 | throw Error( 19 | `Could not read help file for [${section}] ${String(prop)}: ${filePath} does not exist` 20 | ); 21 | } 22 | return readFileSync(filePath).toString(); 23 | } 24 | -------------------------------------------------------------------------------- /src/server-console/types/Server.ts: -------------------------------------------------------------------------------- 1 | import type { WebServer } from "patreon-dl"; 2 | 3 | export interface Server { 4 | name: string; 5 | dataDir: string; 6 | port: "auto" | "manual"; 7 | portNumber: number; 8 | } 9 | 10 | export type ServerListEntry = { 11 | id: number; 12 | server: Server; 13 | } & ( 14 | | { 15 | status: "starting"; 16 | } 17 | | { 18 | status: "running"; 19 | url: string; 20 | instance: WebServer; 21 | } 22 | | { 23 | status: "stopping"; 24 | instance: WebServer; 25 | } 26 | | { 27 | status: "stopped"; 28 | } 29 | | { 30 | status: "error"; 31 | action: "start" | "stop"; 32 | message: string; 33 | } 34 | ); 35 | 36 | export interface ServerList { 37 | entries: ServerListEntry[]; 38 | } 39 | -------------------------------------------------------------------------------- /src/server-console/types/ServerConsoleEvents.ts: -------------------------------------------------------------------------------- 1 | import type { Server, ServerList } from "./Server"; 2 | 3 | export type ServerConsoleRendererEvent = 4 | | "serverListUpdate" 5 | | "showAddServerForm" 6 | | "showEditServerForm" 7 | | "closeServerForm"; 8 | 9 | export type ServerConsoleMainEvent = "uiReady"; 10 | 11 | export type ServerConsoleRendererEventListener< 12 | E extends ServerConsoleRendererEvent 13 | > = 14 | E extends "serverListUpdate" ? (list: ServerList) => void 15 | : E extends "showAddServerForm" ? () => void 16 | : E extends "showEditServerForm" ? (server: Server) => void 17 | : E extends "closeServerForm" ? () => void 18 | : never; 19 | 20 | export type ServerConsoleMainEventListener = 21 | E extends "uiReady" ? () => void : never; 22 | -------------------------------------------------------------------------------- /src/main/ui/modals/ModalWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { ToastContainer } from "react-toastify"; 2 | import AboutModal from "./AboutModal"; 3 | import ConfirmSaveModal from "./ConfirmSaveModal"; 4 | import DownloaderModal from "./DownloaderModal"; 5 | import HelpModal from "./HelpModal"; 6 | import PreviewModal from "./PreviewModal"; 7 | import YouTubeConfiguratorModal from "./YouTubeConfiguratorModal"; 8 | import WebBRowserSettingsModal from "./WebBrowserSettingsModal"; 9 | 10 | function ModalWrapper() { 11 | return ( 12 | <> 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | ); 23 | } 24 | 25 | export default ModalWrapper; 26 | -------------------------------------------------------------------------------- /src/server-console/ui/ServerConsoleApp.tsx: -------------------------------------------------------------------------------- 1 | import "bootswatch/dist/darkly/bootstrap.min.css"; 2 | import "material-icons/iconfont/material-icons.css"; 3 | import "material-symbols"; 4 | import "./styles/main.css"; 5 | import "../../common/ui/styles/components.css"; 6 | import { useEffect } from "react"; 7 | import { Stack } from "react-bootstrap"; 8 | import ServerListTable from "./components/ServerListTable"; 9 | import ServerConsoleToolbar from "./ServerConsoleToolbar"; 10 | import ServerFormModal from "./components/ServerFormModal"; 11 | 12 | function ServerConsoleApp() { 13 | useEffect(() => { 14 | window.serverConsoleAPI.emitMainEvent("uiReady"); 15 | }, []); 16 | 17 | return ( 18 | <> 19 | 20 | 21 | 22 | 23 | 24 | 25 | ); 26 | } 27 | 28 | export default ServerConsoleApp; 29 | -------------------------------------------------------------------------------- /src/common/ui/components/CustomScrollbars.tsx: -------------------------------------------------------------------------------- 1 | import Scrollbars from "react-custom-scrollbars-4"; 2 | 3 | function CustomScrollbars({ children }: { children: React.ReactNode }) { 4 | return ( 5 | ( 10 |
11 | )} 12 | renderTrackVertical={(props) => ( 13 |
14 | )} 15 | renderThumbHorizontal={(props) => ( 16 |
17 | )} 18 | renderThumbVertical={(props) => ( 19 |
20 | )} 21 | renderView={(props) =>
} 22 | > 23 | {children} 24 | 25 | ); 26 | } 27 | 28 | export default CustomScrollbars; 29 | -------------------------------------------------------------------------------- /src/server-console/ui/components/HelpIcon.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { Tooltip } from "react-tooltip"; 3 | import "react-tooltip/dist/react-tooltip.css"; 4 | import _ from "lodash"; 5 | 6 | interface HelpIconProps { 7 | tooltip: string; 8 | className?: string; 9 | } 10 | 11 | function HelpIcon(props: HelpIconProps) { 12 | const [id] = useState(_.uniqueId("tooltip-trigger-")); 13 | const { tooltip, className } = props; 14 | 15 | return ( 16 | <> 17 | 23 | help 24 | 25 | 26 |
{tooltip}
27 |
28 | 29 | ); 30 | } 31 | 32 | export default HelpIcon; 33 | -------------------------------------------------------------------------------- /vite.renderer.config.ts: -------------------------------------------------------------------------------- 1 | import { ConfigEnv, defineConfig } from "vite"; 2 | import path from "path"; 3 | import react from "@vitejs/plugin-react"; 4 | import { fileURLToPath } from "url"; 5 | 6 | const SRC_ROOTS = { 7 | "editor_view": "src/main/views/editor", 8 | "modal_view": "src/main/views/modal", 9 | "server_console": "src/server-console" 10 | }; 11 | 12 | const __filename = fileURLToPath(import.meta.url); 13 | const __dirname = path.dirname(__filename); 14 | 15 | export default defineConfig((env) => { 16 | const { name: rendererName} = (env as ConfigEnv<"renderer">).forgeConfigSelf; 17 | const srcRoot = SRC_ROOTS[rendererName]; 18 | if (!srcRoot) { 19 | throw Error(`No src root defined for renderer "${rendererName}"`); 20 | } 21 | return { 22 | root: path.resolve(__dirname, srcRoot), 23 | plugins: [react()], 24 | build: { 25 | outDir: path.resolve(__dirname, `.vite/renderer/${rendererName}`) 26 | } 27 | }; 28 | }); 29 | -------------------------------------------------------------------------------- /src/main/assets/help/output/campaign.dir.name.format.md: -------------------------------------------------------------------------------- 1 | ## Campaign directory name format 2 | 3 | --- 4 | 5 | Name format of campaign directories. A format is a string pattern consisting of fields enclosed in curly braces. 6 | 7 | When you download content, a directory is created for the campaign that hosts the content. Content directories, which stores the downloaded content, are then placed under the campaign directory. If campaign info could not be obtained from content, then content directory will be created directly under `out.dir`. 8 | 9 | A format must contain at least one of the following fields: 10 | 11 | - `creator.vanity` 12 | - `creator.name` 13 | - `creator.id` 14 | - `campaign.name` 15 | - `campaign.id` 16 | 17 | Characters enclosed in square brackets followed by a question mark denote conditional separators. If the value of a field could not be obtained or is empty, the conditional separator immediately adjacent to it will be omitted from the name. 18 | 19 | --- 20 | 21 | Default: `{creator.vanity}[ - ]?{campaign.name}` 22 | 23 | Fallback: `campaign-{campaign.id}` 24 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import eslint from "@eslint/js"; 3 | import reactPlugin from "eslint-plugin-react"; 4 | import tseslint from "typescript-eslint"; 5 | 6 | export default tseslint.config( 7 | { 8 | ignores: ["src/resources/patreon-dl-vimeo.js"] 9 | }, 10 | eslint.configs.recommended, 11 | tseslint.configs.recommended, 12 | reactPlugin.configs.flat.recommended, 13 | reactPlugin.configs.flat["jsx-runtime"], 14 | { 15 | files: ["src/**/*.ts", "src/**/*.tsx"], 16 | languageOptions: { 17 | globals: { 18 | ...globals.browser, 19 | ...globals.node 20 | } 21 | }, 22 | rules: { 23 | "@typescript-eslint/no-unused-vars": [ 24 | "warn", 25 | { 26 | vars: "all", 27 | varsIgnorePattern: "^_", 28 | args: "after-used", 29 | argsIgnorePattern: "^_", 30 | caughtErrorsIgnorePattern: "^_" 31 | } 32 | ], 33 | "@typescript-eslint/consistent-type-imports": "error", 34 | "react/prop-types": "off" 35 | }, 36 | settings: { 37 | react: { 38 | version: "detect" 39 | } 40 | } 41 | } 42 | ); 43 | -------------------------------------------------------------------------------- /src/common/util/FS.ts: -------------------------------------------------------------------------------- 1 | import type { BaseWindow } from "electron"; 2 | import type electron from "electron"; 3 | import { dialog } from "electron"; 4 | import fs from "fs"; 5 | import { APP_DATA_PATH } from "../Constants"; 6 | import { getErrorString } from "./Misc"; 7 | 8 | export type FSChooserResult = 9 | | { 10 | canceled: true; 11 | } 12 | | { 13 | canceled: false; 14 | filePath: string; 15 | }; 16 | 17 | export async function openFSChooser( 18 | win: BaseWindow, 19 | options: electron.OpenDialogOptions 20 | ): Promise { 21 | const result = await dialog.showOpenDialog(win, options); 22 | if (result.canceled) { 23 | return { 24 | canceled: true 25 | }; 26 | } else { 27 | return { 28 | canceled: false, 29 | filePath: result.filePaths[0] 30 | }; 31 | } 32 | } 33 | 34 | export function ensureAppDataPath() { 35 | if (!fs.existsSync(APP_DATA_PATH)) { 36 | try { 37 | fs.mkdirSync(APP_DATA_PATH, { 38 | recursive: true 39 | }); 40 | } catch (error: unknown) { 41 | console.error( 42 | `Failed to create app data path "${APP_DATA_PATH}":`, 43 | getErrorString(error) 44 | ); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/main/util/ObjectHelper.ts: -------------------------------------------------------------------------------- 1 | import { DateTime } from "patreon-dl"; 2 | 3 | const NO_CLEAN = [DateTime]; 4 | 5 | export default class ObjectHelper { 6 | static clean( 7 | obj: object, 8 | opts?: { 9 | deep?: boolean; 10 | cleanNulls?: boolean; 11 | cleanEmptyObjects?: boolean; 12 | } 13 | ) { 14 | const deep = opts?.deep || false; 15 | const cleanNulls = opts?.cleanNulls || false; 16 | const cleanEmptyObjects = opts?.cleanEmptyObjects || false; 17 | 18 | if (!obj || typeof obj !== "object") { 19 | return obj; 20 | } 21 | const result: Record = {}; 22 | for (const [k, v] of Object.entries(obj)) { 23 | const skip = v === undefined || (v === null && cleanNulls); 24 | if (!skip) { 25 | if ( 26 | v !== null && 27 | typeof v === "object" && 28 | !NO_CLEAN.find((nc) => v instanceof nc) 29 | ) { 30 | const c = deep ? this.clean(v, opts) : v; 31 | if (Object.entries(c).length > 0 || !cleanEmptyObjects) { 32 | result[k] = c; 33 | } 34 | } else { 35 | result[k] = v; 36 | } 37 | } 38 | } 39 | return Array.isArray(obj) ? Object.values(result) : result; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main/mixins/WebBrowserEvents.ts: -------------------------------------------------------------------------------- 1 | import { PATREON_URL } from "../Constants"; 2 | import type { MainProcessConstructor } from "../MainProcess"; 3 | 4 | export function WebBrowserEventSupportMixin< 5 | TBase extends MainProcessConstructor 6 | >(Base: TBase) { 7 | return class WebBrowserEventSupportedProcess extends Base { 8 | protected registerMainEventListeners() { 9 | const callbacks = super.registerMainEventListeners(); 10 | return [ 11 | ...callbacks, 12 | 13 | this.handle("setWebBrowserURL", (url) => { 14 | this.win.webBrowserView?.gotoURL(url); 15 | }), 16 | 17 | this.handle("setWebBrowserURLToHome", () => { 18 | this.win.webBrowserView?.gotoURL(PATREON_URL); 19 | }), 20 | 21 | this.handle("webBrowserBack", () => { 22 | this.win.webBrowserView?.goBack(); 23 | }), 24 | 25 | this.handle("webBrowserForward", () => { 26 | this.win.webBrowserView?.goForward(); 27 | }), 28 | 29 | this.handle("webBrowserReload", () => { 30 | this.win.webBrowserView?.reload(); 31 | }), 32 | 33 | this.on("viewBoundsChange", (bounds) => { 34 | this.win.updateViewBounds(bounds); 35 | }) 36 | ]; 37 | } 38 | }; 39 | } 40 | -------------------------------------------------------------------------------- /src/main/assets/help/request/proxy.url.md: -------------------------------------------------------------------------------- 1 | ## Proxy URL 2 | 3 | --- 4 | 5 | The URL of the proxy server. Supports HTTP, HTTPS, SOCKS4 and SOCKS5 protocols. 6 | 7 | The URL follows this scheme: `protocol://[username:[password]]@hostname:port`. For example: 8 | 9 | - `http://proxy.xyz:8080` 10 | - `socks5://user:password@socksproxy.xyz:1080` 11 | 12 |
13 | info 14 | Web browser sessions are persistent. This means data such as login status is preserved. When you set a proxy URL, the session will be 15 | persisted based on the URL's hostname. In other words, if you set a new proxy URL (never been used in patreon-dl-gui), the embedded web browser will start a new session. 16 |
17 | 18 |
19 | warning 20 | FFmpeg, which is required to download videos in streaming format, supports HTTP proxy only. For other types of proxy, video streams will be handled through direct connection (i.e. without proxy). 21 |
22 | -------------------------------------------------------------------------------- /src/main/views/editor/EditorView.ts: -------------------------------------------------------------------------------- 1 | import { WebContentsView } from "electron"; 2 | import { fileURLToPath } from "url"; 3 | import path from "path"; 4 | 5 | declare const EDITOR_VIEW_VITE_DEV_SERVER_URL: string; 6 | declare const EDITOR_VIEW_VITE_NAME: string; 7 | 8 | const __filename = fileURLToPath(import.meta.url); 9 | const __dirname = path.dirname(__filename); 10 | 11 | export default class EditorView extends WebContentsView { 12 | constructor() { 13 | super({ 14 | webPreferences: { 15 | sandbox: false, 16 | preload: path.join(__dirname, "editor-view-preload.mjs") 17 | } 18 | }); 19 | } 20 | 21 | openDevTools() { 22 | this.webContents.openDevTools(); 23 | } 24 | 25 | closeDevTools() { 26 | this.webContents.closeDevTools(); 27 | } 28 | 29 | load() { 30 | if (EDITOR_VIEW_VITE_DEV_SERVER_URL) { 31 | // Development: load from Vite dev server 32 | return this.webContents.loadURL(EDITOR_VIEW_VITE_DEV_SERVER_URL); 33 | } else { 34 | // Production: load the built HTML file 35 | return this.webContents.loadFile( 36 | path.resolve( 37 | __dirname, 38 | `../renderer/${EDITOR_VIEW_VITE_NAME}/index.html` 39 | ) 40 | ); 41 | } 42 | } 43 | 44 | destroy() { 45 | this.removeAllListeners(); 46 | this.webContents.close(); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/main/views/modal/ModalView.ts: -------------------------------------------------------------------------------- 1 | import { WebContentsView } from "electron"; 2 | import { fileURLToPath } from "url"; 3 | import path from "path"; 4 | 5 | declare const MODAL_VIEW_VITE_DEV_SERVER_URL: string; 6 | declare const MODAL_VIEW_VITE_NAME: string; 7 | 8 | const __filename = fileURLToPath(import.meta.url); 9 | const __dirname = path.dirname(__filename); 10 | 11 | export default class ModalView extends WebContentsView { 12 | constructor() { 13 | super({ 14 | webPreferences: { 15 | sandbox: false, 16 | preload: path.join(__dirname, "modal-view-preload.mjs") 17 | } 18 | }); 19 | this.setBackgroundColor("#00000000"); 20 | } 21 | 22 | openDevTools() { 23 | this.webContents.openDevTools(); 24 | } 25 | 26 | closeDevTools() { 27 | this.webContents.closeDevTools(); 28 | } 29 | 30 | load() { 31 | if (MODAL_VIEW_VITE_DEV_SERVER_URL) { 32 | // Development: load from Vite dev server 33 | return this.webContents.loadURL(MODAL_VIEW_VITE_DEV_SERVER_URL); 34 | } else { 35 | // Production: load the built HTML file 36 | return this.webContents.loadFile( 37 | path.resolve( 38 | __dirname, 39 | `../renderer/${MODAL_VIEW_VITE_NAME}/index.html` 40 | ) 41 | ); 42 | } 43 | } 44 | 45 | destroy() { 46 | this.removeAllListeners(); 47 | this.webContents.close(); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/server-console/ui/components/ServerErrorModal.tsx: -------------------------------------------------------------------------------- 1 | import { Modal } from "react-bootstrap"; 2 | import type { ServerListEntry } from "../../types/Server"; 3 | 4 | export interface ServerErrorModalProps { 5 | serverListEntry: ServerListEntry & { status: "error" }; 6 | show: boolean; 7 | onClose: () => void; 8 | } 9 | 10 | function ServerErrorModal({ 11 | serverListEntry: entry, 12 | show, 13 | onClose 14 | }: ServerErrorModalProps) { 15 | let headerTitle, messageTitle; 16 | switch (entry.action) { 17 | case "start": 18 | headerTitle = "Failed to start server"; 19 | messageTitle = "The start process returned the following error:"; 20 | break; 21 | case "stop": 22 | headerTitle = "Failed to top server"; 23 | messageTitle = "The stop process returned the following error:"; 24 | break; 25 | } 26 | return ( 27 | 28 | 29 | {headerTitle} 30 | 31 | 32 |
{messageTitle}
33 |
34 |           {entry.message}
35 |         
36 |
37 |
38 | ); 39 | } 40 | 41 | export default ServerErrorModal; 42 | -------------------------------------------------------------------------------- /src/server-console/ui/components/SelectRow.tsx: -------------------------------------------------------------------------------- 1 | import { Col, Form, Row } from "react-bootstrap"; 2 | import type { AccessibilityProps, HelpProps } from "../../../common/ui"; 3 | import HelpIcon from "./HelpIcon"; 4 | 5 | type SelectRowProps = { 6 | label: string; 7 | value: string; 8 | options: { label: string; value: string }[]; 9 | onChange: (e: React.ChangeEvent) => void; 10 | } & Pick & 11 | AccessibilityProps; 12 | 13 | function SelectRow(props: SelectRowProps) { 14 | const { label, value, options, onChange, ariaLabel, helpTooltip } = props; 15 | 16 | return ( 17 | 18 | 19 | {label}: 20 | 21 | 22 |
23 | 29 | {options.map(({ value, label }) => ( 30 | 33 | ))} 34 | 35 | {helpTooltip ? 36 | 37 | : null} 38 |
39 | 40 |
41 | ); 42 | } 43 | 44 | export default SelectRow; 45 | -------------------------------------------------------------------------------- /src/main/config/WebBrowserSettings.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import fs from "fs-extra"; 3 | import { APP_DATA_PATH } from "../../common/Constants"; 4 | import { DEFAULT_WEB_BROWSER_SETTINGS } from "../Constants"; 5 | import { getErrorString } from "../../common/util/Misc"; 6 | 7 | const WEB_BROWSER_SETTINGS_PATH = path.join( 8 | APP_DATA_PATH, 9 | "/WebBrowserSettings.json" 10 | ); 11 | 12 | export interface WebBrowserSettings { 13 | userAgent: string; 14 | // Clear session data (cookies, cache...) on closing the editor 15 | clearSessionDataOnExit: boolean; 16 | } 17 | 18 | export function getWebBrowseSettings(): WebBrowserSettings { 19 | if (!fs.existsSync(WEB_BROWSER_SETTINGS_PATH)) { 20 | return DEFAULT_WEB_BROWSER_SETTINGS; 21 | } 22 | try { 23 | const loaded = fs.readJSONSync(WEB_BROWSER_SETTINGS_PATH); 24 | return { 25 | ...DEFAULT_WEB_BROWSER_SETTINGS, 26 | ...loaded 27 | }; 28 | } catch (error: unknown) { 29 | console.error( 30 | `Failed to read web browser settings from "${WEB_BROWSER_SETTINGS_PATH}"`, 31 | getErrorString(error) 32 | ); 33 | return DEFAULT_WEB_BROWSER_SETTINGS; 34 | } 35 | } 36 | 37 | export function saveWebBrowserSettings(settings: WebBrowserSettings) { 38 | try { 39 | return fs.writeJSONSync(WEB_BROWSER_SETTINGS_PATH, settings); 40 | } catch (error: unknown) { 41 | const err = getErrorString(error); 42 | const message = `Failed to save web browser settings to "${WEB_BROWSER_SETTINGS_PATH}": ${err}`; 43 | console.error(message); 44 | throw error; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/main/assets/help/output/content.dir.name.format.md: -------------------------------------------------------------------------------- 1 | ## Content directory name format 2 | 3 | --- 4 | 5 | Name format of content directories. A format is a string pattern consisting 6 | of fields enclosed in curly braces. 7 | 8 | Content can be a post or product. A directory is created for each piece of 9 | content. Downloaded items for the content are placed under this directory. 10 | 11 | ##### Required fields 12 | 13 | A format must contain at least one of the following unique identifier fields: 14 | 15 | | Field | Description | 16 | | -------------- | -------------------------------- | 17 | | `content.id` | ID of content. | 18 | | `content.slug` | Last segment of the content URL. | 19 | 20 | ##### Optional fields 21 | 22 | In addition, a format may contain the following fields: 23 | 24 | | Field | Description | 25 | | --------------------- | -------------------------------------- | 26 | | `content.name` | Post title or product name. | 27 | | `content.type` | Type of content ("product" or "post"). | 28 | | `content.publishDate` | Publish date (ISO UTC format). | 29 | 30 | ##### Conditional separators 31 | 32 | Characters enclosed in square brackets followed by a question mark denote conditional separators. If the value of a field could not be obtained or is empty, the conditional separator immediately adjacent to it will be omitted from the name. 33 | 34 | --- 35 | 36 | Default: `{content.id}[ - ]?{content.name}` 37 | 38 | Fallback: `{content.type}-{content.id}` 39 | -------------------------------------------------------------------------------- /src/common/types/Utility.ts: -------------------------------------------------------------------------------- 1 | // https://stackoverflow.com/questions/69676439/create-constant-array-type-from-an-object-type/69676731#69676731 2 | export type UnionToTuple< 3 | U extends string | number | symbol, 4 | R extends (string | number | symbol)[] = [] 5 | > = 6 | { 7 | [S in U]: Exclude extends never ? [...R, S] 8 | : UnionToTuple, [...R, S]>; 9 | }[U] extends infer S extends string[] ? 10 | S 11 | : never; 12 | 13 | /** 14 | * Converts a tuple of strings to a tuple of objects. 15 | * Each object in the tuple has type V & { VK: string; } 16 | * E.g.: 17 | * const objectTuple: TupleToObjectTuple<['a', 'b'], { label: string; }> = 18 | * [ 19 | * {value: 'a', label: 'some string' }, 20 | * {value: 'b', label: 'some other string' } 21 | * ] 22 | */ 23 | export type TupleToObjectTuple< 24 | T extends (string | number | symbol)[], 25 | V extends object = object, 26 | VK extends string = "value" 27 | > = { 28 | [K in keyof T]: T[K] extends string | number | symbol ? 29 | V & { [key in VK]: T[K] } 30 | : never; 31 | }; 32 | 33 | export type UnionToObjectTuple< 34 | U extends string | number | symbol, 35 | V extends object = object, 36 | VK extends string = "value" 37 | > = TupleToObjectTuple, V, VK>; 38 | 39 | export type ObjectKeysByValueType = { 40 | [K in keyof S]: S[K] extends T ? K : never; 41 | }[keyof S]; 42 | 43 | // https://stackoverflow.com/questions/42999983/typescript-removing-readonly-modifier 44 | export type DeepWriteable = { 45 | -readonly [P in keyof T]: DeepWriteable; 46 | }; 47 | -------------------------------------------------------------------------------- /src/main/util/Config.ts: -------------------------------------------------------------------------------- 1 | import { FileLogger } from "patreon-dl"; 2 | import { MAX_VIDEO_RESOLUTIONS } from "../Constants"; 3 | import { type MaxVideoResolution } from "../types/UIConfig"; 4 | import { getErrorString } from "../../common/util/Misc"; 5 | 6 | export type ValidateProxyURLResult = 7 | | { 8 | isValid: true; 9 | } 10 | | { 11 | isValid: false; 12 | error: string; 13 | }; 14 | 15 | export function getDefaultFileLoggerOptions() { 16 | const config = { ...FileLogger.getDefaultConfig() }; 17 | config.enabled = false; 18 | 19 | return config; 20 | } 21 | 22 | export function validateProxyURL(url: string): ValidateProxyURLResult { 23 | try { 24 | const urlObj = new URL(url); 25 | if (!["http:", "https:", "socks4:", "socks5:"].includes(urlObj.protocol)) { 26 | throw Error( 27 | `Proxy protocol must be one of "http", "https", "socks4", "socks5"` 28 | ); 29 | } 30 | return { 31 | isValid: true 32 | }; 33 | } catch (error: unknown) { 34 | return { 35 | isValid: false, 36 | error: getErrorString(error) 37 | }; 38 | } 39 | } 40 | 41 | export function normalizeMaxVideoResolution( 42 | value: string | number | null 43 | ): MaxVideoResolution { 44 | if (!value || (typeof value === "number" && value < 0)) { 45 | return "none"; 46 | } 47 | let s = typeof value === "string" ? value.trim() : String(value); 48 | if (!s.endsWith("p")) { 49 | s = `${s}p`; 50 | } 51 | if (MAX_VIDEO_RESOLUTIONS.includes(s as MaxVideoResolution)) { 52 | return s as MaxVideoResolution; 53 | } 54 | throw Error(`Unrecognized value "${value}"`); 55 | } 56 | -------------------------------------------------------------------------------- /vite.main.config.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { builtinModules } from "module"; 4 | import { ConfigEnv, defineConfig, normalizePath } from "vite"; 5 | import { viteStaticCopy } from "vite-plugin-static-copy"; 6 | 7 | const __filename = fileURLToPath(import.meta.url); 8 | const __dirname = path.dirname(__filename); 9 | 10 | const mainAssetsSrcPath = path.resolve(__dirname, "src/main/assets"); 11 | 12 | export default defineConfig((env) => { 13 | const forgeEnv = env as ConfigEnv<"build">; 14 | return { 15 | plugins: [ 16 | viteStaticCopy({ 17 | targets: [ 18 | { 19 | src: normalizePath(path.resolve(mainAssetsSrcPath, "help/**/*.md")), 20 | dest: "assets/main", 21 | // Don't use "structured: true" as it creates directories for the *full* path of src. 22 | rename: (_filename, _ext, fullPath) => 23 | path.relative(mainAssetsSrcPath, fullPath) 24 | } 25 | ] 26 | }) 27 | ], 28 | build: { 29 | lib: { 30 | entry: forgeEnv.forgeConfigSelf.entry, 31 | formats: ["es"], 32 | fileName: () => "[name].js" 33 | }, 34 | target: "esnext", 35 | rollupOptions: { 36 | // Vite generates CJS code for these modules so they need to be externalized. 37 | external: [ 38 | "bufferutil", 39 | "utf-8-validate", 40 | "undici", // ^6.21.3 41 | "patreon-dl", // ^3.2.1 42 | 43 | ...builtinModules, 44 | ...builtinModules.map(m => `node:${m}`) 45 | ] 46 | } 47 | } 48 | }; 49 | }); 50 | -------------------------------------------------------------------------------- /src/common/RendererAPI.ts: -------------------------------------------------------------------------------- 1 | import type { IpcRendererEvent } from "electron"; 2 | import { ipcRenderer } from "electron"; 3 | import type { 4 | ProcessInvocableMethod, 5 | ProcessInvocableMethodHandler, 6 | ProcessMainEvent, 7 | ProcessMainEventListener, 8 | ProcessRendererEvent, 9 | ProcessRendererEventListener, 10 | ProcessType 11 | } from "./ProcessBase"; 12 | 13 | export default class RendererAPI { 14 | on>( 15 | eventName: E, 16 | listener: ProcessRendererEventListener, 17 | options?: { once?: boolean } 18 | ) { 19 | const internalListener = ( 20 | _event: IpcRendererEvent, 21 | ...args: Parameters> 22 | ) => { 23 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 24 | listener(...(args as [any])); 25 | }; 26 | const once = options?.once ?? false; 27 | if (once) { 28 | ipcRenderer.once(eventName, internalListener); 29 | } else { 30 | ipcRenderer.on(eventName, internalListener); 31 | } 32 | return () => { 33 | ipcRenderer.off(eventName, internalListener); 34 | }; 35 | } 36 | 37 | emitMainEvent>( 38 | eventName: E, 39 | ...args: Parameters> 40 | ) { 41 | ipcRenderer.send(eventName, ...args); 42 | } 43 | 44 | invoke>( 45 | methodName: M, 46 | ...args: Parameters> 47 | ): Promise>> { 48 | return ipcRenderer.invoke(methodName, ...args); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/server-console/types/ServerConsoleInvocableMethods.ts: -------------------------------------------------------------------------------- 1 | import type { OpenDialogOptions } from "electron"; 2 | import type { Server } from "./Server"; 3 | import type { FSChooserResult } from "../../common/util/FS"; 4 | 5 | export type ServerConsoleInvocableMethod = 6 | | "selectServerListEntry" 7 | | "openFSChooser" 8 | | "addServer" 9 | | "editServer" 10 | | "saveServerFormData" 11 | | "cancelServerForm" 12 | | "deleteServer" 13 | | "startServer" 14 | | "stopServer" 15 | | "startAllServers" 16 | | "stopAllServers" 17 | | "openExternalBrowser"; 18 | 19 | export type SaveServerFormResult = 20 | | { 21 | success: true; 22 | } 23 | | { 24 | success: false; 25 | errors: Partial>; 26 | }; 27 | 28 | export type ServerConsoleInvocableMethodHandler< 29 | M extends ServerConsoleInvocableMethod 30 | > = 31 | M extends "openFSChooser" ? 32 | (dialogOptions: OpenDialogOptions) => Promise 33 | : M extends "showServerForm" ? (mode: "add" | "edit") => void 34 | : M extends "addServer" ? () => void 35 | : M extends "editServer" ? (serverListEntryId: number) => void 36 | : M extends "saveServerFormData" ? (server: Server) => SaveServerFormResult 37 | : M extends "cancelServerForm" ? () => void 38 | : M extends "deleteServer" ? (serverListEntryId: number) => void 39 | : M extends "startServer" ? (serverListEntryId: number) => void 40 | : M extends "stopServer" ? (serverListEntryId: number) => void 41 | : M extends "startAllServers" ? () => void 42 | : M extends "stopAllServers" ? () => void 43 | : M extends "openExternalBrowser" ? (url: string) => void 44 | : never; 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | .DS_Store 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # TypeScript cache 43 | *.tsbuildinfo 44 | 45 | # Optional npm cache directory 46 | .npm 47 | 48 | # Optional eslint cache 49 | .eslintcache 50 | 51 | # Optional REPL history 52 | .node_repl_history 53 | 54 | # Output of 'npm pack' 55 | *.tgz 56 | !proxy-chain-2.5.7-custom.tgz 57 | 58 | # Yarn Integrity file 59 | .yarn-integrity 60 | 61 | # dotenv environment variables file 62 | .env 63 | .env.test 64 | 65 | # parcel-bundler cache (https://parceljs.org/) 66 | .cache 67 | 68 | # next.js build output 69 | .next 70 | 71 | # nuxt.js build output 72 | .nuxt 73 | 74 | # vuepress build output 75 | .vuepress/dist 76 | 77 | # Serverless directories 78 | .serverless/ 79 | 80 | # FuseBox cache 81 | .fusebox/ 82 | 83 | # DynamoDB Local files 84 | .dynamodb/ 85 | 86 | # Webpack 87 | .webpack/ 88 | 89 | # Vite 90 | .vite/ 91 | 92 | # Electron-Forge 93 | out/ 94 | 95 | resources_out/ 96 | 97 | test/ -------------------------------------------------------------------------------- /src/main/ui/styles/main.css: -------------------------------------------------------------------------------- 1 | .split-pane-divider:hover { 2 | cursor: ew-resize; 3 | } 4 | 5 | .nav-link.active { 6 | --bs-nav-underline-link-active-color: var(--bs-nav-link-color) !important; 7 | } 8 | 9 | .insertable { 10 | font-size: 0.85rem; 11 | } 12 | 13 | .form-floating > input + label:after { 14 | background: none !important; 15 | } 16 | 17 | .dropdown.btn-group { 18 | transition: 19 | color 0.15s ease-in-out, 20 | background-color 0.15s ease-in-out, 21 | border-color 0.15s ease-in-out, 22 | box-shadow 0.15s ease-in-out; 23 | } 24 | 25 | .browser-obtainable-input.browser-input-mode .input-group { 26 | border-radius: var(--bs-border-radius); 27 | border: 1px solid var(--bs-secondary); 28 | border-left: 0; 29 | } 30 | 31 | .browser-obtainable-input.browser-input-mode .input-group-text { 32 | border-top: 0; 33 | border-bottom: 0; 34 | } 35 | 36 | #web-browser-toolbar .btn.nav { 37 | background: none !important; 38 | border: none !important; 39 | } 40 | 41 | #web-browser-toolbar .btn.nav:hover { 42 | color: var(--bs-info); 43 | } 44 | 45 | .help-modal-body table { 46 | border-collapse: collapse; 47 | margin-bottom: 2rem; 48 | } 49 | 50 | .help-modal-body table th { 51 | background: var(--bs-modal-bg); 52 | } 53 | 54 | .help-modal-body table td, 55 | .help-modal-body table th { 56 | padding: 0.5rem; 57 | border: 1px solid var(--bs-light); 58 | } 59 | 60 | .help-modal-body h2 { 61 | font-size: 1.5rem; 62 | } 63 | 64 | .help-modal-body h5 { 65 | margin-bottom: 1rem; 66 | padding-top: 1rem; 67 | font-size: large; 68 | font-weight: bold; 69 | } 70 | 71 | .help-modal-body code { 72 | color: var(--bs-info); 73 | } 74 | 75 | .help-modal-body ul { 76 | padding-left: 1.5rem; 77 | } 78 | -------------------------------------------------------------------------------- /src/main/assets/help/logger.file.1/log.filename.md: -------------------------------------------------------------------------------- 1 | ## Log filename 2 | 3 | --- 4 | 5 | Filename of the log file. Can be a fixed name or string pattern. 6 | 7 | ##### String pattern 8 | 9 | A pattern consists of fields enclosed in curly braces. The fields will be replaced with actual values at runtime. Available fields: 10 | 11 | | Field | Description | 12 | | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 13 | | `target.url.path` | The pathname of the target's URL, sanitized as necessary. | 14 | | `datetime.` | The date-time of logger creation, where `` is the string pattern used to format the date-time value. For pattern rules, refer to [https://github.com/felixge/node-dateformat](https://github.com/felixge/node-dateformat). | 15 | | `log.level` | The log level specified for the file logger. | 16 | 17 | --- 18 | 19 | Default: `{datetime.yyyymmdd}-{log.level}.log` 20 | -------------------------------------------------------------------------------- /src/main/assets/help/logger.file.1/log.dir.md: -------------------------------------------------------------------------------- 1 | ## Log file directory 2 | 3 | --- 4 | 5 | The destination directory of the log file. Can be a fixed path or string pattern. 6 | 7 | ##### String pattern 8 | 9 | A pattern consists of fields enclosed in curly braces. The fields will be replaced with actual values at runtime. Available fields: 10 | 11 | | Field | Description | 12 | | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 13 | | `out.dir` | The directory given for "Destination" under "Download". | 14 | | `target.url.path` | The pathname of the target's URL, sanitized as necessary. | 15 | | `datetime.` | The date-time of logger creation, where `` is the string pattern used to format the date-time value. For pattern rules, refer to [https://github.com/felixge/node-dateformat](https://github.com/felixge/node-dateformat). | 16 | 17 | --- 18 | 19 | Default: `{out.dir}/logs/{target.url.path}` 20 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import MainProcess from "./main/MainProcess"; 2 | import { app, session } from "electron"; 3 | import parseArgs from "yargs-parser"; 4 | import ServerConsoleProcess from "./server-console/ServerConsoleProcess"; 5 | 6 | /** 7 | * Electron injects the app name and version into the default user agent string. E.g.: 8 | * "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) 9 | * patreon-dl-gui/2.2.0 Chrome/132.0.6834.159 Electron/34.0.2 Safari/537.36" 10 | * This function returns the default user agent string for the application 11 | * without the app name and version. 12 | * @returns The default user agent string for the application. 13 | */ 14 | function getDefaultUserAgent() { 15 | // Just strip the app name and version from the default user agent string. 16 | // It's important to leave everything else intact to avoid Cloudflare (if triggered) 17 | // going into a loop. 18 | return session.defaultSession 19 | .getUserAgent() 20 | .replace(` ${app.name}/${app.getVersion()}`, ""); 21 | } 22 | 23 | const processArgs = parseArgs(process.argv); 24 | if (Reflect.has(processArgs, "ignore-certificate-errors")) { 25 | app.commandLine.appendSwitch("ignore-certificate-errors"); 26 | } 27 | 28 | app.on("ready", async () => { 29 | const serverConsole = Reflect.has(processArgs, "server-console"); 30 | if (serverConsole) { 31 | const serverConsoleProcess = new ServerConsoleProcess(); 32 | await serverConsoleProcess.start(); 33 | } else { 34 | const defaultUserAgent = getDefaultUserAgent(); 35 | session.defaultSession.setUserAgent(defaultUserAgent); 36 | const main = new MainProcess({ defaultUserAgent }); 37 | await main.start(); 38 | } 39 | }); 40 | 41 | app.on("window-all-closed", () => { 42 | app.quit(); 43 | }); 44 | -------------------------------------------------------------------------------- /src/main/ui/modals/AboutModal.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from "react"; 2 | import { Modal } from "react-bootstrap"; 3 | import type { AboutInfo } from "../../types/MainEvents"; 4 | 5 | function AboutModal() { 6 | const [info, setInfo] = useState(null); 7 | const [show, setShow] = useState(false); 8 | 9 | useEffect(() => { 10 | const removeListenerCallbacks = [ 11 | window.mainAPI.on("aboutInfo", (info) => { 12 | setInfo(info); 13 | setShow(true); 14 | }) 15 | ]; 16 | 17 | return () => { 18 | removeListenerCallbacks.forEach((cb) => cb()); 19 | }; 20 | }, []); 21 | 22 | const hide = useCallback(() => { 23 | setShow(false); 24 | }, []); 25 | 26 | const end = useCallback(() => { 27 | window.mainAPI.emitMainEvent("aboutModalClose"); 28 | }, []); 29 | 30 | if (!info) { 31 | return null; 32 | } 33 | 34 | const { appName, appVersion, appURL } = info; 35 | 36 | return ( 37 | <> 38 | 39 | 40 | 41 |
{appName}
42 |
v{appVersion}
43 | 54 |
55 |
56 | 57 | ); 58 | } 59 | 60 | export default AboutModal; 61 | -------------------------------------------------------------------------------- /src/main/ui/editor/components/CheckboxRow.tsx: -------------------------------------------------------------------------------- 1 | import type { 2 | UIConfig, 3 | UIConfigSectionPropTuple, 4 | UIConfigSectionWithPropsOf 5 | } from "../../../types/UIConfig"; 6 | import { useConfig } from "../../contexts/ConfigProvider"; 7 | import { Col, Form, Row } from "react-bootstrap"; 8 | import type { AccessibilityProps, HelpProps } from "../../../../common/ui"; 9 | import { createHelpIcon } from "./Common"; 10 | import type { JSX } from "react"; 11 | 12 | type CheckboxRowProps> = { 13 | config: UIConfigSectionPropTuple; 14 | label: string; 15 | onChange?: (value: boolean) => void; 16 | appendElements?: JSX.Element[]; 17 | } & HelpProps & 18 | AccessibilityProps; 19 | 20 | function CheckboxRow>( 21 | props: CheckboxRowProps 22 | ) { 23 | const { config, setConfigValue } = useConfig(); 24 | const { 25 | config: pConfig, 26 | label, 27 | ariaLabel, 28 | onChange, 29 | appendElements = [] 30 | } = props; 31 | const [section, prop] = pConfig; 32 | const value = config[section][prop] as boolean; 33 | 34 | return ( 35 | 36 | {label}: 37 | 38 | { 41 | setConfigValue(section, prop, !value as UIConfig[S][typeof prop]); 42 | if (onChange) { 43 | onChange(value); 44 | } 45 | }} 46 | aria-label={ariaLabel || label} 47 | /> 48 | {...appendElements} 49 | {createHelpIcon({ ...props, className: "ms-3" })} 50 | 51 | 52 | ); 53 | } 54 | 55 | export default CheckboxRow; 56 | -------------------------------------------------------------------------------- /src/common/ui/components/ToolbarButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button, ButtonGroup, Dropdown } from "react-bootstrap"; 2 | 3 | interface ToolbarButtonProps { 4 | icon: string; 5 | label?: string; 6 | className?: string; 7 | iconClassName?: string; 8 | onClick: () => void; 9 | tooltip?: string; 10 | split?: { 11 | label: string; 12 | onClick: () => void; 13 | }[]; 14 | disabled?: boolean; 15 | } 16 | 17 | function ToolbarButton(props: ToolbarButtonProps) { 18 | const { 19 | icon, 20 | label, 21 | className, 22 | iconClassName, 23 | onClick, 24 | tooltip, 25 | split, 26 | disabled 27 | } = props; 28 | const baseClasses = "d-flex align-items-center"; 29 | const button = ( 30 | 48 | ); 49 | if (!split || split.length === 0) { 50 | return button; 51 | } 52 | const dropdownItems = split.map(({ label, onClick }) => ( 53 | 54 | {label} 55 | 56 | )); 57 | 58 | return ( 59 | 60 | {button} 61 | 67 | {dropdownItems} 68 | 69 | ); 70 | } 71 | 72 | export default ToolbarButton; 73 | -------------------------------------------------------------------------------- /src/main/ui/editor/AlertsBox.tsx: -------------------------------------------------------------------------------- 1 | import { useEditor } from "../contexts/EditorContextProvider"; 2 | import { Accordion } from "react-bootstrap"; 3 | import { useCallback, useState } from "react"; 4 | 5 | function AlertsBox() { 6 | const { activeEditor, setEditorProp } = useEditor(); 7 | const [visible, setVisibility] = useState(true); 8 | 9 | if (!activeEditor) { 10 | return null; 11 | } 12 | 13 | const dismiss = useCallback(() => { 14 | if (!activeEditor) { 15 | return; 16 | } 17 | setEditorProp(activeEditor, { loadAlerts: undefined }); 18 | setVisibility(false); 19 | }, [activeEditor]); 20 | 21 | const { loadAlerts } = activeEditor; 22 | 23 | if (!loadAlerts || !visible || loadAlerts.length === 0) { 24 | return null; 25 | } 26 | 27 | const contents = loadAlerts.map((alert, i) => ( 28 |
32 | 35 | {alert.type === "error" ? "error" : "warning"} 36 | 37 | {alert.text} 38 |
39 | )); 40 | 41 | return ( 42 | 43 | 44 | 45 | 46 | warning 47 | {" "} 48 | There were issues loading this config. 49 | 54 | 55 | {contents} 56 | 57 | 58 | ); 59 | } 60 | 61 | export default AlertsBox; 62 | -------------------------------------------------------------------------------- /src/main/assets/help/embed.downloader.vimeo/exec.md: -------------------------------------------------------------------------------- 1 | ## Vimeo download command 2 | 3 | --- 4 | 5 | The command to download embedded Vimeo videos. 6 | 7 | Fields enclosed in curly braces will be replaced with actual values at runtime. Available fields: 8 | 9 | | Field | Description | 10 | | -------------------- | --------------------------------------------------------------- | 11 | | `post.id` | ID of the post containing the embedded video. | 12 | | `embed.provider` | Name of the provider, i.e. "Vimeo". | 13 | | `embed.provider.url` | Link to the provider's site. | 14 | | `embed.url` | Link to the video page supplied by the provider. | 15 | | `embed.subject` | Subject of the video. | 16 | | `embed.html` | The HTML code that embeds the video player on the Patreon page. | 17 | | `dest.dir` | The directory where the video should be saved. | 18 | 19 | ##### Note about external downloaders 20 | 21 | External downloaders are not subject to "Max retries" (under Other -> Network requests tab) and "File exists action" (under Output tab) settings. This is because `patreon-dl` has no control over the downloading process nor knowledge about the outcome of it (including where and under what name the file was saved). 22 | 23 | Also note that external downloaders are not executed when "Dry run" is enabled. This is because `patreon-dl` does not create directories in dry-run and external downloaders might throw an error as they try to write in non-existent directories. 24 | 25 | Although care is taken to ensure command arguments are properly escaped, you should be aware of the risks involved in running external programs with arguments having arbitrary values (as you will see, certain embed properties can be passed as arguments). You should always quote strings. 26 | -------------------------------------------------------------------------------- /src/server-console/ui/ServerConsoleToolbar.tsx: -------------------------------------------------------------------------------- 1 | import { Navbar } from "react-bootstrap"; 2 | import { useCallback, useEffect, useState } from "react"; 3 | import ToolbarButton from "../../common/ui/components/ToolbarButton"; 4 | import type { ServerList } from "../types/Server"; 5 | import { 6 | getStartableServerListEntryIds, 7 | getStoppableServerListEntryIds 8 | } from "../util/Server"; 9 | 10 | function ServerConsoleToolbar() { 11 | const [serverList, setServerList] = useState(null); 12 | 13 | useEffect(() => { 14 | const removeListenerCallbacks = [ 15 | window.serverConsoleAPI.on("serverListUpdate", (list) => { 16 | setServerList(list); 17 | }) 18 | ]; 19 | 20 | return () => { 21 | removeListenerCallbacks.forEach((cb) => cb()); 22 | }; 23 | }, []); 24 | 25 | const addServer = useCallback(async () => { 26 | await window.serverConsoleAPI.invoke("addServer"); 27 | }, []); 28 | 29 | const startAllServers = useCallback(async () => { 30 | await window.serverConsoleAPI.invoke("startAllServers"); 31 | }, []); 32 | 33 | const stopAllServers = useCallback(async () => { 34 | await window.serverConsoleAPI.invoke("stopAllServers"); 35 | }, []); 36 | 37 | if (!serverList) { 38 | return null; 39 | } 40 | 41 | const startAllButton = 42 | getStartableServerListEntryIds(serverList).length > 0 ? 43 | 48 | : null; 49 | const stopAllButton = 50 | getStoppableServerListEntryIds(serverList).length > 0 ? 51 | 52 | : null; 53 | 54 | return ( 55 | 56 | 57 | {startAllButton} 58 | {stopAllButton} 59 | 60 | ); 61 | } 62 | 63 | export default ServerConsoleToolbar; 64 | -------------------------------------------------------------------------------- /src/server-console/config/Servers.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs-extra"; 2 | import path from "path"; 3 | import { APP_DATA_PATH } from "../../common/Constants"; 4 | import type { Server } from "../types/Server"; 5 | import { getErrorString } from "../../common/util/Misc"; 6 | 7 | const SERVERS_FILE_PATH = path.join(APP_DATA_PATH, "/Servers.json"); 8 | 9 | export function getServers(): Server[] { 10 | try { 11 | if (!fs.existsSync(SERVERS_FILE_PATH)) { 12 | return []; 13 | } else { 14 | const data = fs.readJSONSync(SERVERS_FILE_PATH); 15 | if (data && Array.isArray(data)) { 16 | return data.reduce((result, entry, index) => { 17 | if (isServer(entry)) { 18 | result.push(entry); 19 | } else { 20 | console.warn( 21 | `Entry #${index} in servers file contains invalid data` 22 | ); 23 | } 24 | return result; 25 | }, []); 26 | } else { 27 | console.warn( 28 | "Servers file contains data that has unexpected data structure." 29 | ); 30 | return []; 31 | } 32 | } 33 | } catch (error: unknown) { 34 | console.error( 35 | "Failed to read servers file:", 36 | getErrorString(error) 37 | ); 38 | return []; 39 | } 40 | } 41 | 42 | export function saveServers(servers: Server[]) { 43 | try { 44 | fs.writeJSONSync(SERVERS_FILE_PATH, servers); 45 | } catch (error: unknown) { 46 | console.error( 47 | "Failed to write to servers file:", 48 | getErrorString(error) 49 | ); 50 | } 51 | } 52 | 53 | function isServer(data: unknown): data is Server { 54 | if (!data || typeof data !== "object") { 55 | return false; 56 | } 57 | return ( 58 | Reflect.has(data, "name") && 59 | Reflect.has(data, "dataDir") && 60 | Reflect.has(data, "port") && 61 | ["auto", "manual"].includes(Reflect.get(data, "port")) && 62 | Reflect.has(data, "portNumber") && 63 | typeof Reflect.get(data, "portNumber") === "number" 64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /src/main/ui/editor/components/HelpIcon.tsx: -------------------------------------------------------------------------------- 1 | import { useEditor } from "../../../ui/contexts/EditorContextProvider"; 2 | import { useCommands } from "../../../ui/contexts/CommandsProvider"; 3 | import type { UIConfig, UIConfigSection } from "../../../types/UIConfig"; 4 | import { useCallback, useState } from "react"; 5 | import { Tooltip } from "react-tooltip"; 6 | import "react-tooltip/dist/react-tooltip.css"; 7 | import _ from "lodash"; 8 | 9 | interface HelpIconProps< 10 | S extends UIConfigSection, 11 | P extends keyof UIConfig[S] 12 | > { 13 | config?: [S, P] | null; 14 | tooltip: string; 15 | className?: string; 16 | } 17 | 18 | function HelpIcon( 19 | props: HelpIconProps 20 | ) { 21 | const [id] = useState(_.uniqueId("tooltip-trigger-")); 22 | const { showHelpIcons } = useEditor(); 23 | const { requestHelp } = useCommands(); 24 | const { config, tooltip, className } = props; 25 | 26 | const getMoreInfo = useCallback(() => { 27 | if (!config) { 28 | return; 29 | } 30 | requestHelp(...config); 31 | }, [config]); 32 | 33 | if (!showHelpIcons) { 34 | return null; 35 | } 36 | 37 | const moreInfoLink = 38 | config ? 39 | 45 | More info. 46 | 47 | : null; 48 | 49 | return ( 50 | <> 51 | 57 | help 58 | 59 | 65 |
66 | {tooltip} 67 | {moreInfoLink} 68 |
69 |
70 | 71 | ); 72 | } 73 | 74 | export default HelpIcon; 75 | -------------------------------------------------------------------------------- /misc/rpm-spec.ejs: -------------------------------------------------------------------------------- 1 | %define __spec_install_post %{nil} 2 | %define _binary_payload w<%= compressionLevel %>.xzdio 3 | 4 | %if "%{_host_cpu}" != "%{_target_cpu}" 5 | %global __strip /bin/true 6 | %endif 7 | 8 | Name: <%= name %> 9 | Version: <%= version %> 10 | Release: <%= revision %>%{?dist} 11 | <% if (description) { print(`Summary: ${description}\n`) } 12 | %> 13 | <% if (license) { print(`License: ${license}\n`) } 14 | if (homepage) { print(`URL: ${homepage}\n`) } 15 | if (license || homepage) { print('\n') } 16 | 17 | %>Requires: <%= requires.join(', ') %> 18 | AutoReqProv: no 19 | 20 | <% if (productDescription) { 21 | %>%description 22 | <% print(productDescription) 23 | print('\n\n\n') } 24 | 25 | 26 | %>%install 27 | mkdir -p %{buildroot}/usr/ 28 | cp <%= process.platform === 'darwin' ? '-R' : '-r' %> usr/* %{buildroot}/usr/ 29 | 30 | 31 | %files 32 | /usr/bin/<%= name %> 33 | /usr/lib/<%= name %>/ 34 | /usr/share/applications/<%= name %>.desktop 35 | /usr/share/doc/<%= name %>/ 36 | <% if (_.isObject(icon)) { 37 | _.forEach(icon, function (path, resolution) { 38 | %>/usr/share/icons/hicolor/<%= resolution %>/apps/<%= name %><%= resolution === 'symbolic' ? '-symbolic' : '' %>.<%= ['scalable', 'symbolic'].includes(resolution) ? 'svg' : 'png' %> 39 | <% }) } else { 40 | %>/usr/share/pixmaps/<%= name %>.png 41 | <% } %> 42 | 43 | <% if (pre) { 44 | %>%pre 45 | <% print(pre) 46 | if (preun || post || postun) print('\n\n\n') } 47 | 48 | 49 | %><% if (preun) { 50 | %>%preun 51 | <% print(preun) 52 | if (post || postun) print('\n\n\n') } 53 | 54 | 55 | %><% if (post) { 56 | %>%post 57 | <% print(post) 58 | if (postun) print('\n\n\n') } 59 | 60 | 61 | %><% if (postun) { 62 | %>%postun 63 | <% print(postun) } %> 64 | 65 | <% print('\n\n\n') %> 66 | 67 | %posttrans 68 | if [ "$1" = "2" ]; then 69 | if [ ! -f /usr/share/applications/patreon-dl-gui-server-console.desktop ]; then 70 | install -Dm644 /usr/lib/patreon-dl-gui/resources/patreon-dl-gui-server-console.desktop /usr/share/applications/patreon-dl-gui-server-console.desktop 71 | update-desktop-database /usr/share/applications || true 72 | fi 73 | fi 74 | -------------------------------------------------------------------------------- /src/main/ui/editor/components/SelectRow.tsx: -------------------------------------------------------------------------------- 1 | import type { 2 | UIConfig, 3 | UIConfigSectionWithPropsOf 4 | } from "../../../types/UIConfig"; 5 | import type { 6 | TupleToObjectTuple, 7 | UnionToTuple 8 | } from "../../../../common/types/Utility"; 9 | import { useConfig } from "../../contexts/ConfigProvider"; 10 | import { Col, Form, Row } from "react-bootstrap"; 11 | import type { AccessibilityProps, HelpProps } from "../../../../common/ui"; 12 | import { createHelpIcon } from "./Common"; 13 | 14 | type SelectRowProps< 15 | S extends UIConfigSectionWithPropsOf, 16 | P extends keyof UIConfig[S] 17 | > = { 18 | config: [S, P]; 19 | label: string; 20 | options: TupleToObjectTuple< 21 | UnionToTuple, 22 | { label: string } 23 | >; 24 | } & HelpProps & 25 | AccessibilityProps; 26 | 27 | function SelectRow< 28 | S extends UIConfigSectionWithPropsOf, 29 | P extends keyof UIConfig[S] 30 | >(props: SelectRowProps) { 31 | const { config, setConfigValue } = useConfig(); 32 | const { config: pConfig, label, options, ariaLabel } = props; 33 | const [section, prop] = pConfig; 34 | const value = config[section][prop] as string; 35 | 36 | return ( 37 | 38 | {label}: 39 | 40 |
41 | 45 | setConfigValue( 46 | section, 47 | prop, 48 | e.currentTarget.value as UIConfig[S][P] 49 | ) 50 | } 51 | aria-label={ariaLabel || label} 52 | > 53 | {options.map(({ value, label }) => ( 54 | 60 | ))} 61 | 62 | {createHelpIcon(props)} 63 |
64 | 65 |
66 | ); 67 | } 68 | 69 | export default SelectRow; 70 | -------------------------------------------------------------------------------- /src/main/ui/modals/HelpModal.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from "react"; 2 | import { Modal } from "react-bootstrap"; 3 | import Markdown from "react-markdown"; 4 | import remarkGfm from "remark-gfm"; 5 | import rehypeRaw from "rehype-raw"; 6 | 7 | function HelpModal() { 8 | const [contents, setContents] = useState(null); 9 | const [show, setShow] = useState(false); 10 | 11 | useEffect(() => { 12 | const removeListenerCallbacks = [ 13 | window.mainAPI.on("requestHelpResult", (result) => { 14 | setContents(result.contents); 15 | setShow(true); 16 | }) 17 | ]; 18 | 19 | return () => { 20 | removeListenerCallbacks.forEach((cb) => cb()); 21 | }; 22 | }, []); 23 | 24 | const openExternalBrowser = useCallback(async (url?: string) => { 25 | if (url) { 26 | await window.mainAPI.invoke("openExternalBrowser", url); 27 | } 28 | }, []); 29 | 30 | const renderLink = useCallback( 31 | ( 32 | props: React.DetailedHTMLProps< 33 | React.AnchorHTMLAttributes, 34 | HTMLAnchorElement 35 | > 36 | ) => { 37 | return ( 38 | { 43 | e.preventDefault(); 44 | await openExternalBrowser(props.href); 45 | }} 46 | > 47 | {props.children} 48 | 49 | ); 50 | }, 51 | [openExternalBrowser] 52 | ); 53 | 54 | const hide = useCallback(() => { 55 | setShow(false); 56 | }, []); 57 | 58 | const end = useCallback(() => { 59 | window.mainAPI.emitMainEvent("helpModalClose"); 60 | }, []); 61 | 62 | if (!contents) { 63 | return null; 64 | } 65 | 66 | return ( 67 | <> 68 | 76 | 77 | 82 | {contents} 83 | 84 | 85 | 86 | 87 | ); 88 | } 89 | 90 | export default HelpModal; 91 | -------------------------------------------------------------------------------- /src/main/assets/help/embed.downloader.youtube/exec.md: -------------------------------------------------------------------------------- 1 | ## YouTube download command 2 | 3 | --- 4 | 5 | The command to download embedded YouTube videos. 6 | 7 | Fields enclosed in curly braces will be replaced with actual values at runtime. Available fields: 8 | 9 | | Field | Description | 10 | | -------------------- | --------------------------------------------------------------- | 11 | | `post.id` | ID of the post containing the embedded video. | 12 | | `embed.provider` | Name of the provider, i.e. "YouTube". | 13 | | `embed.provider.url` | Link to the provider's site. | 14 | | `embed.url` | Link to the video page supplied by the provider. | 15 | | `embed.subject` | Subject of the video. | 16 | | `embed.html` | The HTML code that embeds the video player on the Patreon page. | 17 | | `dest.dir` | The directory where the video should be saved. | 18 | 19 | For example, if you intend to use [yt-dlp](https://github.com/yt-dlp) for YouTube downloads, you may specify the following command: 20 | 21 | ``` 22 | yt-dlp -o "{dest.dir}/%(title)s.%(ext)s" "{embed.url}" 23 | ``` 24 | 25 | This will cause `yt-dlp` to download the video at `embed.url` and save it in `dest.dir`. The filename will be determined by the format `%(title)s.%(ext)s`. 26 | 27 | (See: https://github.com/yt-dlp/yt-dlp?tab=readme-ov-fileoutput-template). 28 | 29 | ##### Note about external downloaders 30 | 31 | External downloaders are not subject to "Max retries" (under Other -> Network requests tab) and "File exists action" (under Output tab) settings. This is because `patreon-dl` has no control over the downloading process nor knowledge about the outcome of it (including where and under what name the file was saved). 32 | 33 | Also note that external downloaders are not executed when "Dry run" is enabled. This is because `patreon-dl` does not create directories in dry-run and external downloaders might throw an error as they try to write in non-existent directories. 34 | 35 | Although care is taken to ensure command arguments are properly escaped, you should be aware of the risks involved in running external programs with arguments having arbitrary values (as you will see, certain embed properties can be passed as arguments). You should always quote strings. 36 | -------------------------------------------------------------------------------- /src/main/assets/help/output/media.filename.format.md: -------------------------------------------------------------------------------- 1 | ## Media filename format 2 | 3 | --- 4 | 5 | Filename format of a downloaded item. A format is a string pattern consisting of fields enclosed in curly braces. 6 | 7 | ##### Required fields 8 | 9 | A format must contain at least one of the following fields: 10 | 11 | | Field | Description | 12 | | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 13 | | `media.id` | ID of the item downloaded (assigned by Patreon). | 14 | | `media.filename` | Can be one of the following, in order of availability:
  • original filename included in the item's API data; or
  • filename derived from the header of the response to the HTTP download request.
| 15 | 16 | ##### Optional fields 17 | 18 | In addition, a format may contain the following fields: 19 | 20 | | Field | Description | 21 | | --------------- | ------------------------------------------------------------------------------------------- | 22 | | `media.type` | Type of item (e.g. "image" or "video"). | 23 | | `media.variant` | Where applicable, the variant of the item (e.g. "original", "thumbnailSmall"...for images). | 24 | 25 | If you enabled "All media variants", `media.variant` will be appended to the filename regardless of whether you have included it in the format. 26 | 27 | Sometimes `media.filename` could not be obtained, in which case it will be replaced with `media.id`, unless it is already present in the format. 28 | 29 | ##### Conditional separators 30 | 31 | Characters enclosed in square brackets followed by a question mark denote conditional separators. If the value of a field could not be obtained or is empty, the conditional separator immediately adjacent to it will be omitted from the name. 32 | 33 | --- 34 | 35 | Default: `{media.filename}` 36 | 37 | Fallback: `{media.type}-{media.id}` 38 | -------------------------------------------------------------------------------- /src/main/mixins/EditorEvents.ts: -------------------------------------------------------------------------------- 1 | import { dialog } from "electron"; 2 | import type { MainProcessConstructor } from "../MainProcess"; 3 | 4 | export function EditorEventSupportMixin( 5 | Base: TBase 6 | ) { 7 | return class EditorEventSupportedProcess extends Base { 8 | protected registerMainEventListeners() { 9 | const callbacks = super.registerMainEventListeners(); 10 | return [ 11 | ...callbacks, 12 | 13 | this.handle("newEditor", async () => { 14 | await this.createEditor(null, (editor) => { 15 | this.emitRendererEvent( 16 | this.win.editorView, 17 | "editorCreated", 18 | editor 19 | ); 20 | }); 21 | }), 22 | 23 | this.handle("closeEditor", async (editor) => { 24 | if (editor.modified) { 25 | const dialogOpts = { 26 | title: "Confirm", 27 | message: `"${editor.name}" has been modified. Discard changes?`, 28 | buttons: ["Cancel", "Discard"], 29 | cancelId: 0, 30 | defaultId: 1 31 | }; 32 | const result = await dialog.showMessageBox(this.win, dialogOpts); 33 | if (result.response === dialogOpts.cancelId) { 34 | return { 35 | canceled: true 36 | }; 37 | } 38 | } 39 | await this.win.removeWebBrowserViewForEditor(editor); 40 | return { 41 | canceled: false, 42 | editor 43 | }; 44 | }), 45 | 46 | this.on("activeEditorChange", (info) => { 47 | const shouldRefreshMenu = 48 | (this.activeEditor === null || info.editor === null) && 49 | this.activeEditor !== info.editor; 50 | this.activeEditor = info.editor; 51 | if (shouldRefreshMenu) { 52 | this.setAppMenu({ 53 | enabled: { 54 | save: !!this.activeEditor, 55 | saveAs: !!this.activeEditor, 56 | preview: !!this.activeEditor, 57 | startDownload: !!this.activeEditor 58 | } 59 | }); 60 | } 61 | if (info.editor) { 62 | this.win.setActiveWebBrowserViewByEditor(info.editor); 63 | } 64 | }), 65 | 66 | this.on("modifiedEditorsChange", (info) => { 67 | this.modifiedEditors = info.editors; 68 | }), 69 | 70 | this.handle("applyProxy", (editor) => { 71 | return this.win.applyProxy(editor); 72 | }) 73 | ]; 74 | } 75 | }; 76 | } 77 | -------------------------------------------------------------------------------- /src/main/ui/editor/EditorPanel.tsx: -------------------------------------------------------------------------------- 1 | import DownloadBox from "./DownloadBox"; 2 | import IncludeBox from "./IncludeBox"; 3 | import OutputBox from "./OutputBox"; 4 | import EmbedsBox from "./EmbedsBox"; 5 | import LoggingBox from "./LoggingBox"; 6 | import OtherBox from "./OtherBox"; 7 | import AlertsBox from "./AlertsBox"; 8 | import { useEffect } from "react"; 9 | import { Tab, Tabs } from "react-bootstrap"; 10 | import { useCommands } from "../contexts/CommandsProvider"; 11 | import NetworkBox from "./NetworkBox"; 12 | 13 | function EditorPanel() { 14 | const { closeActiveEditor } = useCommands(); 15 | 16 | useEffect(() => { 17 | const closeEditorKeyListener = (event: KeyboardEvent) => { 18 | if (event.ctrlKey && event.key.toLowerCase() === "w") { 19 | event.preventDefault(); 20 | closeActiveEditor(); 21 | } 22 | }; 23 | window.addEventListener("keyup", closeEditorKeyListener); 24 | 25 | return () => { 26 | window.removeEventListener("keyup", closeEditorKeyListener); 27 | }; 28 | }, [closeActiveEditor]); 29 | 30 | return ( 31 | <> 32 | 33 | 34 | 35 | 41 | 42 | 43 | 49 | 50 | 51 | 57 | 58 | 59 | 65 | 66 | 67 | 73 | 74 | 75 | 81 | 82 | 83 | 84 | 85 | ); 86 | } 87 | 88 | export default EditorPanel; 89 | -------------------------------------------------------------------------------- /src/common/util/WindowState.ts: -------------------------------------------------------------------------------- 1 | import { APP_DATA_PATH } from "../Constants"; 2 | import path from "path"; 3 | import fs from "fs-extra"; 4 | import { getErrorString } from "./Misc"; 5 | 6 | export interface WindowState { 7 | size: { width: number; height: number }; 8 | position: { x: number; y: number }; 9 | state: "normal" | "maximized" | "minimized"; 10 | } 11 | 12 | export interface ConstrainedWindowState extends WindowState { 13 | minSize?: { width: number; height: number }; 14 | } 15 | 16 | const WINDOW_STATE_FILE_PATH = path.join(APP_DATA_PATH, "/WindowState.json"); 17 | 18 | class CachedWindowStates { 19 | static #cachedWindowStates: Record | null = null; 20 | 21 | static get() { 22 | if (this.#cachedWindowStates) { 23 | return this.#cachedWindowStates; 24 | } 25 | let cached = {}; 26 | if (fs.existsSync(WINDOW_STATE_FILE_PATH)) { 27 | try { 28 | cached = fs.readJSONSync(WINDOW_STATE_FILE_PATH) || {}; 29 | } catch (error: unknown) { 30 | console.error( 31 | `Failed to load last window states from "${WINDOW_STATE_FILE_PATH}":`, 32 | getErrorString(error) 33 | ); 34 | } 35 | } 36 | this.#cachedWindowStates = cached; 37 | return this.#cachedWindowStates; 38 | } 39 | } 40 | 41 | export function loadLastWindowState(windowName: string): WindowState | null; 42 | export function loadLastWindowState( 43 | windowName: string, 44 | validateFn: (data: WindowState) => data is T 45 | ): T | null; 46 | export function loadLastWindowState( 47 | windowName: string, 48 | validateFn?: (data: WindowState) => data is T 49 | ) { 50 | const _validateFn = validateFn ?? isWindowState; 51 | const data = CachedWindowStates.get()[windowName]; 52 | if (!data) { 53 | return null; 54 | } 55 | if (!isWindowState(data) || !_validateFn(data)) { 56 | console.warn( 57 | `Last ${windowName} window state data has unexpected structure - ignoring it.` 58 | ); 59 | return null; 60 | } 61 | return data; 62 | } 63 | 64 | export function saveWindowState( 65 | windowName: string, 66 | data: T 67 | ) { 68 | const current = CachedWindowStates.get(); 69 | current[windowName] = data; 70 | try { 71 | fs.writeJSONSync(WINDOW_STATE_FILE_PATH, current); 72 | } catch (error: unknown) { 73 | console.error( 74 | `Failed to write ${windowName} window state to file:`, 75 | getErrorString(error) 76 | ); 77 | } 78 | } 79 | 80 | function isWindowState(data: unknown): data is WindowState { 81 | if (!data || !(typeof data === "object")) { 82 | return false; 83 | } 84 | return ( 85 | Reflect.has(data, "size") && 86 | Reflect.has(data, "position") && 87 | Reflect.has(data, "state") 88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /src/main/ui/modals/PreviewModal.tsx: -------------------------------------------------------------------------------- 1 | import type { FileConfig } from "../../types/FileConfig"; 2 | import { useCallback, useEffect, useState } from "react"; 3 | import { Button, Modal } from "react-bootstrap"; 4 | import { showToast } from "../helpers/Toast"; 5 | 6 | function PreviewModal() { 7 | const [fileConfig, setFileConfig] = useState(null); 8 | const [show, setShow] = useState(false); 9 | 10 | useEffect(() => { 11 | const removeListenerCallbacks = [ 12 | window.mainAPI.on("previewInfo", (info) => { 13 | setFileConfig(info); 14 | setShow(true); 15 | }) 16 | ]; 17 | 18 | return () => { 19 | removeListenerCallbacks.forEach((cb) => cb()); 20 | }; 21 | }, []); 22 | 23 | const copyToClipboard = useCallback(() => { 24 | if (!fileConfig) { 25 | return; 26 | } 27 | navigator.clipboard.writeText(fileConfig.contents); 28 | showToast("success", "Config copied to clipboard"); 29 | }, [fileConfig]); 30 | 31 | const hide = useCallback(() => { 32 | setShow(false); 33 | }, []); 34 | 35 | const end = useCallback(() => { 36 | window.mainAPI.emitMainEvent("previewModalClose"); 37 | }, []); 38 | 39 | if (!fileConfig) { 40 | return null; 41 | } 42 | 43 | const { name, filePath, contents } = fileConfig; 44 | 45 | return ( 46 | <> 47 | 56 | 57 | 58 |
59 | {name} 60 | {filePath ? 61 | {filePath} 62 | : null} 63 |
64 |
65 |
66 | 80 |
81 |
82 | 86 | {contents} 87 | 88 |
89 | 90 | ); 91 | } 92 | 93 | export default PreviewModal; 94 | -------------------------------------------------------------------------------- /src/main/ui/modals/ConfirmSaveModal.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from "react"; 2 | import { Button, Modal } from "react-bootstrap"; 3 | import type { FileConfig } from "../../types/FileConfig"; 4 | 5 | function ConfirmSaveModal() { 6 | const [fileConfig, setFileConfig] = useState | null>( 7 | null 8 | ); 9 | const [show, setShow] = useState(false); 10 | 11 | useEffect(() => { 12 | const removeListenerCallbacks = [ 13 | window.mainAPI.on("promptOverwriteOnSave", (config) => { 14 | setFileConfig(config); 15 | setShow(true); 16 | }) 17 | ]; 18 | 19 | return () => { 20 | removeListenerCallbacks.forEach((cb) => cb()); 21 | }; 22 | }, []); 23 | 24 | const confirm = useCallback(() => { 25 | if (!fileConfig) { 26 | return; 27 | } 28 | setShow(false); 29 | window.mainAPI.emitMainEvent("confirmSave", { 30 | confirmed: true, 31 | config: fileConfig 32 | }); 33 | }, [fileConfig]); 34 | 35 | const cancel = useCallback(() => { 36 | window.mainAPI.emitMainEvent("confirmSave", { confirmed: false }); 37 | setShow(false); 38 | }, []); 39 | 40 | const end = useCallback(() => { 41 | window.mainAPI.emitMainEvent("confirmSaveModalClose"); 42 | }, []); 43 | 44 | if (!fileConfig) { 45 | return null; 46 | } 47 | 48 | const { name, filePath, contents } = fileConfig; 49 | 50 | return ( 51 | <> 52 | 63 | 64 | 65 |
66 | {name} 67 | {filePath} 68 | 69 | Overwrite existing file with the following contents? 70 | 71 |
72 |
73 |
74 | 78 | {contents} 79 | 80 | 81 | 84 | 91 | 92 |
93 | 94 | ); 95 | } 96 | 97 | export default ConfirmSaveModal; 98 | -------------------------------------------------------------------------------- /src/main/util/RecentDocuments.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs-extra"; 2 | import path from "path"; 3 | import { APP_DATA_PATH } from "../../common/Constants"; 4 | import { getErrorString } from "../../common/util/Misc"; 5 | 6 | const MAX_ENTRIES = 10; 7 | 8 | export type RecentDocument = { 9 | name: string; 10 | filePath: string; 11 | }; 12 | 13 | export default class RecentDocuments { 14 | static #dataFilePath: string; 15 | static #data: RecentDocument[]; 16 | static #status: "ready" | null = null; 17 | 18 | static #load() { 19 | if (this.#status !== null) { 20 | return; 21 | } 22 | const filePath = path.join(APP_DATA_PATH, "/RecentDocuments.json"); 23 | let data: RecentDocument[] = []; 24 | try { 25 | if (!fs.existsSync(filePath)) { 26 | fs.writeJsonSync(filePath, data); 27 | } else { 28 | const _data = fs.readJSONSync(filePath); 29 | if ( 30 | _data && 31 | Array.isArray(_data) && 32 | _data.every((value) => this.#isRecentDocument(value)) 33 | ) { 34 | data = _data; 35 | } else { 36 | console.warn( 37 | "Recent documents have unexpected data structure - resetting it." 38 | ); 39 | fs.writeJsonSync(filePath, data); 40 | } 41 | } 42 | } catch (error: unknown) { 43 | console.error( 44 | "Failed to load recent documents:", 45 | getErrorString(error) 46 | ); 47 | } finally { 48 | this.#dataFilePath = filePath; 49 | this.#data = data; 50 | this.#status = "ready"; 51 | } 52 | } 53 | 54 | static #save() { 55 | try { 56 | fs.writeJSONSync(this.#dataFilePath, this.#data); 57 | } catch (error: unknown) { 58 | console.error( 59 | "Failed to write recent documents to file:", 60 | getErrorString(error) 61 | ); 62 | } 63 | } 64 | 65 | static add(entry: RecentDocument) { 66 | this.#load(); 67 | const i = this.#data.findIndex((value) => this.#isEqual(value, entry)); 68 | if (i >= 0) { 69 | this.#data.splice(i, 1); 70 | } 71 | this.#data.unshift(entry); 72 | if (this.#data.length > MAX_ENTRIES) { 73 | this.#data.splice(MAX_ENTRIES); 74 | } 75 | this.#save(); 76 | } 77 | 78 | static clear() { 79 | this.#load(); 80 | this.#data.splice(0); 81 | this.#save(); 82 | } 83 | 84 | static list(): readonly RecentDocument[] { 85 | this.#load(); 86 | return this.#data; 87 | } 88 | 89 | static #isRecentDocument(value: unknown): value is RecentDocument { 90 | return ( 91 | !!value && 92 | typeof value === "object" && 93 | Reflect.has(value, "name") && 94 | Reflect.has(value, "filePath") 95 | ); 96 | } 97 | 98 | static #isEqual(o1: RecentDocument, o2: RecentDocument) { 99 | return o1.filePath === o2.filePath; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/main/ui/editor/OtherBox.tsx: -------------------------------------------------------------------------------- 1 | import type { MaxVideoResolution, UIConfig } from "../../types/UIConfig"; 2 | import { useConfig } from "../contexts/ConfigProvider"; 3 | import TextInputRow from "./components/TextInputRow"; 4 | import { Container } from "react-bootstrap"; 5 | import { useMemo } from "react"; 6 | import _ from "lodash"; 7 | import CheckboxRow from "./components/CheckboxRow"; 8 | import SelectRow from "./components/SelectRow"; 9 | import type { UnionToObjectTuple } from "../../../common/types/Utility"; 10 | 11 | interface OtherBoxState { 12 | pathToFFmpeg: string; 13 | pathToDeno: string; 14 | maxVideoResolution: MaxVideoResolution; 15 | noPrompt: boolean; 16 | dryRun: boolean; 17 | } 18 | 19 | let oldState: OtherBoxState | null = null; 20 | 21 | const MAX_VIDEO_RESOLUTION_OPTIONS: UnionToObjectTuple< 22 | MaxVideoResolution, 23 | { label: string } 24 | > = [ 25 | { value: "none", label: "None - download best quality" }, 26 | { value: "360p", label: "360p" }, 27 | { value: "480p", label: "480p" }, 28 | { value: "720p", label: "720p" }, 29 | { value: "1080p", label: "1080p" }, 30 | { value: "1440p", label: "1440p" }, 31 | { value: "2160p", label: "2160p" } 32 | ]; 33 | 34 | function getOtherBoxState(config: UIConfig): OtherBoxState { 35 | const state: OtherBoxState = { 36 | pathToFFmpeg: config["downloader"]["path.to.ffmpeg"], 37 | pathToDeno: config["downloader"]["path.to.deno"], 38 | maxVideoResolution: config["downloader"]["max.video.resolution"], 39 | noPrompt: config.downloader["no.prompt"], 40 | dryRun: config.downloader["dry.run"] 41 | }; 42 | 43 | if (oldState && _.isEqual(oldState, state)) { 44 | return oldState; 45 | } 46 | oldState = _.cloneDeep(state); 47 | return state; 48 | } 49 | 50 | function OtherBox() { 51 | const { config } = useConfig(); 52 | const state = getOtherBoxState(config); 53 | 54 | return useMemo(() => { 55 | return ( 56 | 57 | 64 | 71 | 78 | 83 | 88 | 89 | ); 90 | }, [state]); 91 | } 92 | 93 | export default OtherBox; 94 | -------------------------------------------------------------------------------- /src/main/util/YouTubeConfigurator.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import fs from "fs-extra"; 3 | import { YouTubeCredentialsCapturer } from "patreon-dl"; 4 | import { EventEmitter } from "events"; 5 | import { APP_DATA_PATH } from "../../common/Constants"; 6 | import { getErrorString } from "../../common/util/Misc"; 7 | 8 | export const YT_CREDS_PATH = path.join( 9 | APP_DATA_PATH, 10 | "/YouTubeCredentials.json" 11 | ); 12 | 13 | export interface YouTubeConnectionStatus { 14 | isConnected: boolean; 15 | } 16 | 17 | export interface YouTubeConnectVerificationInfo { 18 | verificationURL: string; 19 | code: string; 20 | } 21 | 22 | export type YouTubeConnectResult = 23 | | { 24 | status: "success"; 25 | credentialsPath: string; 26 | } 27 | | { 28 | status: "error"; 29 | error: string; 30 | }; 31 | 32 | export default class YouTubeConfigurator extends EventEmitter { 33 | #capturer: YouTubeCredentialsCapturer; 34 | 35 | constructor() { 36 | super(); 37 | this.#capturer = new YouTubeCredentialsCapturer(); 38 | } 39 | 40 | startConnect() { 41 | this.#capturer.on("pending", (data) => { 42 | this.emit("verificationInfo", { 43 | verificationURL: data.verificationURL, 44 | code: data.code 45 | }); 46 | }); 47 | this.#capturer.on("capture", (credentials) => { 48 | try { 49 | fs.writeJSONSync(YT_CREDS_PATH, credentials); 50 | this.emit("end", { 51 | status: "success", 52 | credentialsPath: YT_CREDS_PATH 53 | }); 54 | } catch (error: unknown) { 55 | console.error( 56 | `Error saving credentials to "${YT_CREDS_PATH}": `, 57 | getErrorString(error) 58 | ); 59 | this.emit("end", { 60 | status: "error", 61 | error: getErrorString(error) 62 | }); 63 | } finally { 64 | this.endConnect(); 65 | } 66 | }); 67 | this.#capturer.begin(); 68 | } 69 | 70 | endConnect() { 71 | this.#capturer.removeAllListeners(); 72 | } 73 | 74 | resetConnectionStatus() { 75 | try { 76 | if (fs.existsSync(YT_CREDS_PATH)) { 77 | fs.unlinkSync(YT_CREDS_PATH); 78 | } 79 | } catch (error: unknown) { 80 | console.error( 81 | `Error deleting YouTube credentials file "${YT_CREDS_PATH}`, 82 | getErrorString(error) 83 | ); 84 | } 85 | } 86 | 87 | static getConnectionStatus(): YouTubeConnectionStatus { 88 | return { isConnected: fs.existsSync(YT_CREDS_PATH) }; 89 | } 90 | 91 | emit(eventName: "end", result: YouTubeConnectResult): boolean; 92 | emit( 93 | eventName: "verificationInfo", 94 | info: YouTubeConnectVerificationInfo 95 | ): boolean; 96 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 97 | emit(eventName: string | symbol, ...args: any[]): boolean { 98 | return super.emit(eventName, ...args); 99 | } 100 | 101 | on(eventName: "end", listener: (result: YouTubeConnectResult) => void): this; 102 | on( 103 | eventName: "verificationInfo", 104 | listener: (info: YouTubeConnectVerificationInfo) => void 105 | ): this; 106 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 107 | on(eventName: string | symbol, listener: (...args: any[]) => void): this { 108 | return super.on(eventName, listener); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/main/types/MainInvocableMethods.ts: -------------------------------------------------------------------------------- 1 | import type { OpenDialogOptions } from "electron"; 2 | import type { Editor } from "./App"; 3 | import type { UIConfig, UIConfigSection } from "./UIConfig"; 4 | import type { SaveFileConfigResult } from "./MainEvents"; 5 | import type { WebBrowserSettings } from "../config/WebBrowserSettings"; 6 | import type { FSChooserResult } from "../../common/util/FS"; 7 | 8 | export type MainProcessInvocableMethod = 9 | | "getEditorPanelWidth" 10 | | "newEditor" 11 | | "closeEditor" 12 | | "openFile" 13 | | "save" 14 | | "saveAs" 15 | | "preview" 16 | | "openFSChooser" 17 | | "applyProxy" 18 | | "requestHelp" 19 | | "requestAboutInfo" 20 | | "openExternalBrowser" 21 | | "setWebBrowserURL" 22 | | "setWebBrowserURLToHome" 23 | | "webBrowserBack" 24 | | "webBrowserForward" 25 | | "webBrowserReload" 26 | | "startDownload" 27 | | "abortDownload" 28 | | "configureYouTube" 29 | | "startYouTubeConnect" 30 | | "cancelYouTubeConnect" 31 | | "disconnectYouTube" 32 | | "requestWebBrowserSettings" 33 | | "saveWebBrowserSettings" 34 | | "clearSessionData"; 35 | 36 | export type MainProcessInvocableMethodHandler< 37 | M extends MainProcessInvocableMethod 38 | > = 39 | M extends "getEditorPanelWidth" ? () => number 40 | : M extends "newEditor" ? () => void 41 | : M extends "closeEditor" ? (editor: Editor) => Promise 42 | : M extends "openFile" ? 43 | (currentEditors: Editor[], filePath?: string) => Promise 44 | : M extends "save" ? (editor: Editor) => Promise 45 | : M extends "saveAs" ? (editor: Editor) => Promise 46 | : M extends "preview" ? (editor: Editor) => void 47 | : M extends "openFSChooser" ? 48 | (dialogOptions: OpenDialogOptions) => Promise 49 | : M extends "applyProxy" ? (editor: Editor) => void 50 | : M extends "requestHelp" ? 51 | (section: S, prop: keyof UIConfig[S]) => void 52 | : M extends "requestAboutInfo" ? () => void 53 | : M extends "openExternalBrowser" ? (url: string) => void 54 | : M extends "setWebBrowserURL" ? (url: string) => void 55 | : M extends "setWebBrowserURLToHome" ? () => void 56 | : M extends "webBrowserBack" ? () => void 57 | : M extends "webBrowserForward" ? () => void 58 | : M extends "webBrowserReload" ? () => void 59 | : M extends "startDownload" ? (editor: Editor) => void 60 | : M extends "abortDownload" ? () => void 61 | : M extends "configureYouTube" ? () => void 62 | : M extends "startYouTubeConnect" ? () => void 63 | : M extends "cancelYouTubeConnect" ? () => void 64 | : M extends "disconnectYouTube" ? () => void 65 | : M extends "requestWebBrowserSettings" ? () => void 66 | : M extends "saveWebBrowserSettings" ? 67 | (settings: WebBrowserSettings) => Promise 68 | : M extends "clearSessionData" ? () => void 69 | : never; 70 | 71 | export type CloseEditorResult = 72 | | { 73 | canceled: true; 74 | } 75 | | { 76 | canceled: false; 77 | editor: Editor; 78 | }; 79 | 80 | export type OpenFileResult = 81 | | { 82 | canceled: true; 83 | hasError?: undefined; 84 | editor?: undefined; 85 | isNewEditor?: undefined; 86 | } 87 | | { 88 | canceled?: undefined; 89 | hasError: true; 90 | editor?: undefined; 91 | isNewEditor?: undefined; 92 | } 93 | | { 94 | canceled?: undefined; 95 | hasError?: undefined; 96 | editor: Editor; 97 | isNewEditor: boolean; 98 | }; 99 | -------------------------------------------------------------------------------- /src/main/ui/editor/DownloadBox.tsx: -------------------------------------------------------------------------------- 1 | import type { UIConfig } from "../../types/UIConfig"; 2 | import CheckboxRow from "./components/CheckboxRow"; 3 | import TextInputRow from "./components/TextInputRow"; 4 | import BrowserObtainableInputRow from "./components/BrowserObtainableInputRow"; 5 | import { useConfig } from "../contexts/ConfigProvider"; 6 | import { Container, Card } from "react-bootstrap"; 7 | import { useMemo } from "react"; 8 | import _ from "lodash"; 9 | import type { StopOnCondition } from "patreon-dl"; 10 | import SelectRow from "./components/SelectRow"; 11 | 12 | interface DownloadBoxState { 13 | target: UIConfig["downloader"]["target"]; 14 | outDir: string; 15 | cookie: UIConfig["downloader"]["cookie"]; 16 | useStatusCache: boolean; 17 | stopOn: StopOnCondition; 18 | } 19 | 20 | let oldState: DownloadBoxState | null = null; 21 | 22 | function getDownloadBoxState(config: UIConfig): DownloadBoxState { 23 | const state = { 24 | target: config.downloader.target, 25 | outDir: config.output["out.dir"], 26 | cookie: config.downloader.cookie, 27 | useStatusCache: config.downloader["use.status.cache"], 28 | stopOn: config.downloader["stop.on"] 29 | }; 30 | if (oldState && _.isEqual(oldState, state)) { 31 | return oldState; 32 | } 33 | oldState = _.cloneDeep(state); 34 | return state; 35 | } 36 | 37 | function DownloadBox() { 38 | const { config } = useConfig(); 39 | const state = getDownloadBoxState(config); 40 | 41 | return useMemo(() => { 42 | return ( 43 | 44 | Download 45 | 46 | 47 | 54 | 60 | 66 | 72 | 89 | 90 | 91 | 92 | ); 93 | }, [state]); 94 | } 95 | 96 | export default DownloadBox; 97 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "patreon-dl-gui", 3 | "productName": "patreon-dl-gui", 4 | "version": "2.6.0", 5 | "description": "GUI for patreon-dl", 6 | "type": "module", 7 | "main": ".vite/build/index.js", 8 | "scripts": { 9 | "prepare": "tsc --noEmit && rimraf ./resources_out && npx pkg --targets node20 --out-path ./resources_out/bin ./src/resources/patreon-dl-vimeo.js", 10 | "start": "npm run prepare && electron-forge start", 11 | "package": "cross-env-shell FORGE_PHASE=package \"npm run prepare && electron-forge package\"", 12 | "make": "cross-env-shell FORGE_PHASE=make \"npm run prepare && electron-forge make\"", 13 | "publish": "npm run prepare && electron-forge publish", 14 | "lint": "npx eslint ./src", 15 | "lint:fix": "npx eslint ./src --fix", 16 | "prettify": "prettier --write ./src" 17 | }, 18 | "author": "Patrick Kan", 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/patrickkfkan/patreon-dl-gui.git" 22 | }, 23 | "license": "MIT", 24 | "devDependencies": { 25 | "@electron-forge/cli": "^7.8.3", 26 | "@electron-forge/maker-deb": "^7.8.1", 27 | "@electron-forge/maker-rpm": "^7.8.1", 28 | "@electron-forge/maker-squirrel": "^7.8.1", 29 | "@electron-forge/maker-wix": "^7.8.1", 30 | "@electron-forge/maker-zip": "^7.8.1", 31 | "@electron-forge/plugin-auto-unpack-natives": "^7.8.1", 32 | "@electron-forge/plugin-fuses": "^7.8.1", 33 | "@electron-forge/plugin-vite": "^7.8.1", 34 | "@electron/fuses": "^1.8.0", 35 | "@eslint/js": "^9.20.0", 36 | "@types/dateformat": "^5.0.3", 37 | "@types/lodash": "^4.17.15", 38 | "@types/react": "^19.0.8", 39 | "@types/react-dom": "^19.0.3", 40 | "@types/yargs-parser": "^21.0.3", 41 | "@vitejs/plugin-react": "^4.6.0", 42 | "@yao-pkg/pkg": "^6.3.2", 43 | "cross-env": "^10.0.0", 44 | "css-loader": "^6.11.0", 45 | "electron": "^38.0.0", 46 | "electron-rebuild": "^3.2.9", 47 | "eslint": "^9.20.1", 48 | "eslint-plugin-react": "^7.37.4", 49 | "globals": "^15.15.0", 50 | "node-abi": "^4.14.0", 51 | "node-loader": "^2.1.0", 52 | "prettier": "^3.5.1", 53 | "rimraf": "^6.0.1", 54 | "style-loader": "^3.3.4", 55 | "ts-loader": "^9.5.2", 56 | "ts-node": "^10.9.2", 57 | "typescript": "^5.7.3", 58 | "typescript-eslint": "^8.24.1", 59 | "vite": "^6.3.5", 60 | "vite-plugin-static-copy": "^3.1.0" 61 | }, 62 | "dependencies": { 63 | "bootswatch": "^5.3.3", 64 | "cheerio": "^1.0.0", 65 | "classnames": "^2.5.1", 66 | "configparser": "^0.3.10", 67 | "dateformat": "^5.0.3", 68 | "env-paths": "^3.0.0", 69 | "html-entities": "^2.6.0", 70 | "lodash": "^4.17.21", 71 | "material-icons": "^1.13.13", 72 | "material-symbols": "^0.28.1", 73 | "normalize-url": "^8.0.1", 74 | "patreon-dl": "^3.5.0", 75 | "portfinder": "^1.0.35", 76 | "proxy-chain": "^2.5.8", 77 | "react": "^19.0.0", 78 | "react-bootstrap": "^2.10.9", 79 | "react-custom-scrollbars-4": "^4.5.1", 80 | "react-data-grid": "^7.0.0-beta.56", 81 | "react-dom": "^19.0.0", 82 | "react-markdown": "^10.0.0", 83 | "react-toastify": "^11.0.3", 84 | "react-tooltip": "^5.28.0", 85 | "rehype-raw": "^7.0.0", 86 | "remark-gfm": "^4.0.1", 87 | "shescape": "^2.1.2", 88 | "yargs-parser": "^21.1.1" 89 | }, 90 | "overrides": { 91 | "@electron-forge/maker-rpm": { 92 | "electron-installer-redhat": "git+https://github.com/electron-userland/electron-installer-redhat.git#ab6694f" 93 | } 94 | }, 95 | "keywords": [ 96 | "patreon", 97 | "download", 98 | "downloader" 99 | ] 100 | } 101 | -------------------------------------------------------------------------------- /src/main/DownloaderConsoleLogger.ts: -------------------------------------------------------------------------------- 1 | import type { ConsoleLoggerOptions, LogEntry, LogLevel } from "patreon-dl"; 2 | import { ConsoleLogger, FetcherError } from "patreon-dl"; 3 | import { encode } from "html-entities"; 4 | import dateFormat from "dateformat"; 5 | import EventEmitter from "events"; 6 | 7 | export interface DownloaderLogMessage { 8 | text: string; 9 | level: LogLevel; 10 | } 11 | 12 | export default class DownloaderConsoleLogger extends ConsoleLogger { 13 | #eventEmitter: EventEmitter; 14 | 15 | constructor(options?: ConsoleLoggerOptions) { 16 | super(); 17 | this.setOptions(options); 18 | this.#eventEmitter = new EventEmitter(); 19 | } 20 | 21 | protected errorToStrings(m: Error, forceNoStack = false): string[] { 22 | const result: string[] = []; 23 | const msg = encode(m.cause ? `${m.message}:` : m.message); 24 | if (m.name !== "Error") { 25 | result.push(encode(`(${m.name}) ${msg}`)); 26 | } else { 27 | result.push(encode(msg)); 28 | } 29 | if (m.cause instanceof Error) { 30 | result.push(...this.errorToStrings(m.cause, true)); 31 | } else if (m.cause) { 32 | result.push(encode(m.cause as string)); 33 | } 34 | if (m instanceof FetcherError) { 35 | result.push(encode(`(${m.method}: ${m.url})`)); 36 | } 37 | if (m.stack && this.config.include.errorStack && !forceNoStack) { 38 | result.push(encode(m.stack)); 39 | } 40 | return result; 41 | } 42 | 43 | protected toStrings(entry: LogEntry): string[] { 44 | const { level, originator, message } = entry; 45 | const strings = message.reduce((result, m) => { 46 | if (m instanceof Error) { 47 | result.push(...this.errorToStrings(m)); 48 | } else if (typeof m === "object") { 49 | result.push(encode(JSON.stringify(m, null, 2))); 50 | } else { 51 | result.push(encode(m)); 52 | } 53 | 54 | return result; 55 | }, []); 56 | 57 | if (originator && this.config.include.originator) { 58 | strings.unshift(this.colorize(encode(`${originator}:`), "originator")); 59 | } 60 | 61 | if (this.config.include.level) { 62 | strings.unshift(this.colorize(encode(`${level}:`), level)); 63 | } 64 | 65 | if (this.config.include.dateTime) { 66 | const dateTimeStr = encode( 67 | `${dateFormat(new Date(), this.config.dateTimeFormat)}:` 68 | ); 69 | strings.unshift(dateTimeStr); 70 | } 71 | 72 | return strings; 73 | } 74 | 75 | protected colorize(value: string, colorKey: string) { 76 | if (this.config.color) { 77 | switch (colorKey) { 78 | case "error": 79 | return `${value}`; 80 | case "warn": 81 | return `${value}`; 82 | case "info": 83 | return `${value}`; 84 | case "debug": 85 | return `${value}`; 86 | case "originator": 87 | return `${value}`; 88 | } 89 | } 90 | return `${value}`; 91 | } 92 | 93 | protected toOutput(level: LogLevel, msg: string[]) { 94 | this.emit("message", { 95 | text: msg.join(" "), 96 | level 97 | }); 98 | } 99 | 100 | on(event: "message", listener: (message: DownloaderLogMessage) => void) { 101 | this.#eventEmitter.on(event, listener); 102 | } 103 | 104 | emit(event: "message", message: DownloaderLogMessage) { 105 | this.#eventEmitter.emit(event, message); 106 | } 107 | 108 | removeAllListeners() { 109 | this.#eventEmitter.removeAllListeners(); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/main/ui/editor/WebBrowserToolbar.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Form, InputGroup, Navbar, Stack } from "react-bootstrap"; 2 | import { useCommands } from "../contexts/CommandsProvider"; 3 | import type { KeyboardEvent } from "react"; 4 | import { useEffect, useRef, useState } from "react"; 5 | import ToolbarButton from "../../../common/ui/components/ToolbarButton"; 6 | import type { WebBrowserPageNavigatedInfo } from "../../types/MainEvents"; 7 | 8 | function WebBrowserToolbar() { 9 | const urlInputRef = useRef(null); 10 | const [pageInfo, setPageInfo] = useState( 11 | null 12 | ); 13 | const [editedURL, setEditedURL] = useState(""); 14 | const { gotoURL, gotoHome, goBack, goForward, reload, editSettings } = 15 | useCommands().webBrowser; 16 | 17 | useEffect(() => { 18 | const removeListenerCallbacks = [ 19 | window.mainAPI.on("browserPageNavigated", (info) => { 20 | setPageInfo(info); 21 | setEditedURL(info.url); 22 | }) 23 | ]; 24 | 25 | return () => { 26 | removeListenerCallbacks.forEach((cb) => cb()); 27 | }; 28 | }, []); 29 | 30 | const handleURLInputKeydown = (e: KeyboardEvent) => { 31 | if (e.key === "Enter") { 32 | if (editedURL.trim() === "") { 33 | return; 34 | } 35 | gotoURL(editedURL); 36 | if (urlInputRef.current) { 37 | urlInputRef.current.blur(); 38 | } 39 | } else if (e.key === "Escape") { 40 | setEditedURL(pageInfo?.url || ""); 41 | } 42 | }; 43 | 44 | return ( 45 | 46 | 47 | 55 | 63 | 70 | 77 | 78 | setEditedURL(e.currentTarget.value)} 85 | /> 86 | gotoURL(editedURL)} 90 | > 91 | 95 | arrow_right_alt 96 | 97 | 98 | 99 | 106 | 107 | 108 | ); 109 | } 110 | 111 | export default WebBrowserToolbar; 112 | -------------------------------------------------------------------------------- /src/server-console/ui/components/TextInputRow.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef } from "react"; 2 | import { Button, Col, Form, InputGroup, Row } from "react-bootstrap"; 3 | import classNames from "classnames"; 4 | import type { AccessibilityProps, HelpProps } from "../../../common/ui"; 5 | import HelpIcon from "./HelpIcon"; 6 | 7 | type InputValueType = "text" | "number" | "dir" | "file"; 8 | 9 | type TextInputRowProps = { 10 | type?: T; 11 | value: T extends "number" ? number : string; 12 | label: string; 13 | onChange: (e: React.ChangeEvent) => void; 14 | error?: string; 15 | } & Pick & 16 | AccessibilityProps; 17 | 18 | const nativeSetter = Object.getOwnPropertyDescriptor( 19 | window.HTMLInputElement.prototype, 20 | "value" 21 | )?.set; 22 | 23 | function TextInputRow(props: TextInputRowProps) { 24 | const { 25 | type = "text", 26 | value, 27 | label, 28 | ariaLabel, 29 | onChange, 30 | error, 31 | helpTooltip 32 | } = props; 33 | const textboxRef = useRef(null); 34 | 35 | const textbox = ( 36 | 47 | ); 48 | 49 | const openFSChooser = useCallback(async (type: "dir" | "file") => { 50 | const result = await window.serverConsoleAPI.invoke("openFSChooser", { 51 | properties: type === "dir" ? ["openDirectory"] : ["openFile"], 52 | title: type === "dir" ? "Choose directory" : "Choose file" 53 | }); 54 | if (result.canceled) { 55 | return; 56 | } 57 | if (textboxRef.current) { 58 | nativeSetter?.call(textboxRef.current, result.filePath); 59 | textboxRef.current.dispatchEvent(new Event("change", { bubbles: true })); 60 | } 61 | }, []); 62 | 63 | let textboxContainer; 64 | switch (type) { 65 | case "text": 66 | case "number": 67 | textboxContainer = textbox; 68 | break; 69 | case "dir": 70 | case "file": 71 | textboxContainer = ( 72 | 73 | {textbox} 74 | 87 | 88 | ); 89 | break; 90 | } 91 | 92 | const classes = classNames("m-0", "py-1", "align-items-center"); 93 | 94 | return ( 95 |
96 | 97 | 98 | {label}: 99 | 100 | 101 |
102 | {textboxContainer} 103 | {helpTooltip ? 104 | 105 | : null} 106 |
107 | 108 |
109 | {error ? 110 | 111 | 112 | 113 | {error} 114 | 115 | 116 | : null} 117 |
118 | ); 119 | } 120 | 121 | export default TextInputRow; 122 | -------------------------------------------------------------------------------- /src/main/ui/editor/EditorToolbar.tsx: -------------------------------------------------------------------------------- 1 | import { useEditor } from "../contexts/EditorContextProvider"; 2 | import type { Editor } from "../../types/App"; 3 | import { Button, Container, Nav, Navbar, NavDropdown } from "react-bootstrap"; 4 | import { useCommands } from "../contexts/CommandsProvider"; 5 | import { useEffect, useMemo, useState } from "react"; 6 | import type { RecentDocument } from "../../util/RecentDocuments"; 7 | import ToolbarButton from "../../../common/ui/components/ToolbarButton"; 8 | 9 | const getEditorName = (editor: Editor) => { 10 | return `${editor.name}${editor.modified ? "*" : ""}`; 11 | }; 12 | 13 | function EditorToolbar() { 14 | const { editors, activeEditor, setActiveEditor } = useEditor(); 15 | const [recentDocuments, setRecentDocuments] = useState< 16 | readonly RecentDocument[] 17 | >([]); 18 | const { 19 | createEditor, 20 | openFile, 21 | save, 22 | saveAs, 23 | preview, 24 | startDownload, 25 | closeActiveEditor 26 | } = useCommands(); 27 | 28 | useEffect(() => { 29 | const removeListenerCallbacks = [ 30 | window.mainAPI.on("recentDocumentsInfo", (info) => { 31 | setRecentDocuments(info.entries); 32 | }) 33 | ]; 34 | 35 | return () => { 36 | removeListenerCallbacks.forEach((cb) => cb()); 37 | }; 38 | }, []); 39 | 40 | const recentDocumentSplitItems = useMemo( 41 | () => 42 | recentDocuments.map((doc) => ({ 43 | label: doc.name, 44 | onClick: () => openFile(doc.filePath) 45 | })), 46 | [recentDocuments, openFile] 47 | ); 48 | 49 | return ( 50 | 51 | 52 | {activeEditor ? 53 | 58 | {editors.map((editor) => ( 59 | setActiveEditor(editor)} 63 | className={`${editor.id === activeEditor.id ? "fw-bold text-info" : ""}`} 64 | > 65 | {getEditorName(editor)} 66 | 67 | ))} 68 | 69 | : null} 70 | 71 | openFile()} 74 | tooltip="Open file" 75 | split={recentDocumentSplitItems} 76 | /> 77 | 88 | 94 | 95 | {activeEditor ? 96 | 97 | 98 | 106 | 107 | 108 | : null} 109 | 110 | ); 111 | } 112 | 113 | export default EditorToolbar; 114 | -------------------------------------------------------------------------------- /src/main/ui/modals/WebBrowserSettingsModal.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from "react"; 2 | import { Button, Form, Modal, Stack } from "react-bootstrap"; 3 | import type { WebBrowserSettings } from "../../config/WebBrowserSettings"; 4 | 5 | function WebBRowserSettingsModal() { 6 | const [settings, setSettings] = useState(null); 7 | const [show, setShow] = useState(false); 8 | 9 | useEffect(() => { 10 | const removeListenerCallbacks = [ 11 | window.mainAPI.on("webBrowserSettings", (settings) => { 12 | setSettings(settings); 13 | setShow(true); 14 | }) 15 | ]; 16 | 17 | return () => { 18 | removeListenerCallbacks.forEach((cb) => cb()); 19 | }; 20 | }, []); 21 | 22 | const hide = useCallback(() => { 23 | setShow(false); 24 | }, []); 25 | 26 | const end = useCallback(() => { 27 | window.mainAPI.emitMainEvent("webBrowserSettingsModalClose"); 28 | }, []); 29 | 30 | const handleUserAgentChange = useCallback( 31 | (e: React.ChangeEvent) => { 32 | if (!settings) { 33 | return; 34 | } 35 | settings.userAgent = e.target.value; 36 | setSettings({ ...settings }); 37 | }, 38 | [settings] 39 | ); 40 | 41 | const toggleClearSessionData = useCallback(async () => { 42 | if (!settings) { 43 | return; 44 | } 45 | settings.clearSessionDataOnExit = !settings.clearSessionDataOnExit; 46 | setSettings({ ...settings }); 47 | }, [settings]); 48 | 49 | const clearSessionData = useCallback(async () => { 50 | await window.mainAPI.invoke("clearSessionData"); 51 | }, []); 52 | 53 | const handleApply = useCallback(async () => { 54 | if (!settings) { 55 | return; 56 | } 57 | await window.mainAPI.invoke("saveWebBrowserSettings", settings); 58 | hide(); 59 | }, [settings, hide]); 60 | 61 | if (!settings) { 62 | return null; 63 | } 64 | 65 | return ( 66 | <> 67 | 68 | 69 | 70 |
71 |
72 | User Agent 73 | 74 | {" "} 75 | (leave blank unless you know what you're doing) 76 | 77 | : 78 |
79 | 85 |
86 | 87 | 93 | 96 | 97 |
98 | 99 | 100 |
101 | 104 | 107 |
108 |
109 |
110 |
111 | 112 | ); 113 | } 114 | 115 | export default WebBRowserSettingsModal; 116 | -------------------------------------------------------------------------------- /src/main/Constants.ts: -------------------------------------------------------------------------------- 1 | import { app } from "electron"; 2 | import path from "path"; 3 | import type { MainWindowProps } from "./MainWindow"; 4 | import type { DeepRequired } from "patreon-dl"; 5 | import { dirname } from "path"; 6 | import { fileURLToPath } from "url"; 7 | import type { WebBrowserSettings } from "./config/WebBrowserSettings"; 8 | import type { MaxVideoResolution } from "./types/UIConfig"; 9 | 10 | const __dirname = dirname(fileURLToPath(import.meta.url)); 11 | 12 | export const PATREON_URL = "https://www.patreon.com"; 13 | 14 | export const DEFAULT_MAIN_WINDOW_PROPS: MainWindowProps & 15 | DeepRequired> = { 16 | size: { width: 1366, height: 768 }, 17 | state: "normal", 18 | devTools: false, 19 | editorPanelWidth: 540, 20 | webBrowserViewInitialURL: PATREON_URL, 21 | webBrowserViewUserAgent: "" 22 | }; 23 | 24 | export const FILE_CONFIG_SECTION_PROPS = { 25 | downloader: [ 26 | "target.url", 27 | "cookie", 28 | "use.status.cache", 29 | "stop.on", 30 | "no.prompt", 31 | "dry.run", 32 | "path.to.ffmpeg", 33 | "path.to.deno", 34 | "max.video.resolution" 35 | ], 36 | output: [ 37 | "out.dir", 38 | "campaign.dir.name.format", 39 | "content.dir.name.format", 40 | "media.filename.format", 41 | "content.file.exists.action", 42 | "info.file.exists.action", 43 | "info.api.file.exists.action" 44 | ], 45 | include: [ 46 | "locked.content", 47 | "posts.in.tier", 48 | "posts.with.media.type", 49 | "posts.published.after", 50 | "posts.published.before", 51 | "products.published.after", 52 | "products.published.before", 53 | "campaign.info", 54 | "content.info", 55 | "content.media", 56 | "preview.media", 57 | "all.media.variants", 58 | "media.thumbnails", 59 | "images.by.filename", 60 | "audio.by.filename", 61 | "attachments.by.filename", 62 | "comments" 63 | ], 64 | request: [ 65 | "max.retries", 66 | "max.concurrent", 67 | "min.time", 68 | "proxy.url", 69 | "proxy.reject.unauthorized.tls", 70 | "user.agent" 71 | ], 72 | "embed.downloader.youtube": ["exec"], 73 | "embed.downloader.vimeo": ["exec"], 74 | "logger.console": [ 75 | "enabled", 76 | "log.level", 77 | "include.date.time", 78 | "include.level", 79 | "include.originator", 80 | "include.error.stack", 81 | "date.time.format", 82 | "color" 83 | ], 84 | "logger.file.1": [ 85 | "enabled", 86 | "log.dir", 87 | "log.filename", 88 | "file.exists.action", 89 | "log.level", 90 | "include.date.time", 91 | "include.level", 92 | "include.originator", 93 | "include.error.stack", 94 | "date.time.format", 95 | "color" 96 | ], 97 | "patreon.dl.gui": [ 98 | "connect.youtube", 99 | "vimeo.downloader.type", 100 | "vimeo.helper.ytdlp.path", 101 | "vimeo.helper.password", 102 | "vimeo.helper.ytdlp.args" 103 | ] 104 | } as const; 105 | 106 | const isDevMode = !app.isPackaged; 107 | const vimeoHelperScriptFile = `patreon-dl-vimeo${process.platform === "win32" ? ".exe" : ""}`; 108 | export const VIMEO_HELPER_SCRIPT_PATH = 109 | isDevMode ? 110 | path.resolve(__dirname, `../../resources_out/bin/${vimeoHelperScriptFile}`) 111 | : path.resolve(process.resourcesPath, `bin/${vimeoHelperScriptFile}`); 112 | 113 | export const VIMEO_HELPER_SCRIPT_EXEC_ARGS = [ 114 | "-o", 115 | `"{dest.dir}${path.sep}%(title)s.%(ext)s"`, 116 | "--embed-html", 117 | '"{embed.html}"', 118 | "--embed-url", 119 | '"{embed.url}"' 120 | ]; 121 | 122 | export const DEFAULT_WEB_BROWSER_SETTINGS: WebBrowserSettings = { 123 | userAgent: "", 124 | clearSessionDataOnExit: false 125 | }; 126 | 127 | export const MAX_VIDEO_RESOLUTIONS: MaxVideoResolution[] = [ 128 | "none", 129 | "360p", 130 | "480p", 131 | "720p", 132 | "1080p", 133 | "1440p", 134 | "2160p" 135 | ]; 136 | -------------------------------------------------------------------------------- /src/common/ProcessBase.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | BrowserWindow, 3 | IpcMainEvent, 4 | IpcMainInvokeEvent, 5 | WebContentsView 6 | } from "electron"; 7 | import { ipcMain } from "electron"; 8 | import type { 9 | MainProcessMainEvent, 10 | MainProcessMainEventListener, 11 | MainProcessRendererEvent, 12 | MainProcessRendererEventListener 13 | } from "../main/types/MainEvents"; 14 | import type { 15 | MainProcessInvocableMethod, 16 | MainProcessInvocableMethodHandler 17 | } from "../main/types/MainInvocableMethods"; 18 | import type { 19 | ServerConsoleMainEvent, 20 | ServerConsoleMainEventListener, 21 | ServerConsoleRendererEvent, 22 | ServerConsoleRendererEventListener 23 | } from "../server-console/types/ServerConsoleEvents"; 24 | import type { 25 | ServerConsoleInvocableMethod, 26 | ServerConsoleInvocableMethodHandler 27 | } from "../server-console/types/ServerConsoleInvocableMethods"; 28 | 29 | export type ProcessType = "main" | "serverConsole"; 30 | 31 | export type ProcessRendererEvent = 32 | T extends "main" ? MainProcessRendererEvent 33 | : T extends "serverConsole" ? ServerConsoleRendererEvent 34 | : never; 35 | 36 | export type ProcessMainEvent = 37 | T extends "main" ? MainProcessMainEvent 38 | : T extends "serverConsole" ? ServerConsoleMainEvent 39 | : never; 40 | 41 | export type ProcessRendererEventListener< 42 | T extends ProcessType, 43 | E extends ProcessRendererEvent 44 | > = 45 | E extends MainProcessRendererEvent ? MainProcessRendererEventListener 46 | : E extends ServerConsoleRendererEvent ? ServerConsoleRendererEventListener 47 | : never; 48 | 49 | export type ProcessMainEventListener< 50 | T extends ProcessType, 51 | E extends ProcessMainEvent 52 | > = 53 | E extends MainProcessMainEvent ? MainProcessMainEventListener 54 | : E extends ServerConsoleMainEvent ? ServerConsoleMainEventListener 55 | : never; 56 | 57 | export type ProcessInvocableMethod = 58 | T extends "main" ? MainProcessInvocableMethod 59 | : T extends "serverConsole" ? ServerConsoleInvocableMethod 60 | : never; 61 | 62 | export type ProcessInvocableMethodHandler< 63 | T extends ProcessType, 64 | M extends ProcessInvocableMethod 65 | > = 66 | M extends MainProcessInvocableMethod ? MainProcessInvocableMethodHandler 67 | : M extends ServerConsoleInvocableMethod ? 68 | ServerConsoleInvocableMethodHandler 69 | : never; 70 | 71 | export default abstract class ProcessBase { 72 | protected emitRendererEvent>( 73 | win: BrowserWindow | WebContentsView, 74 | eventName: E, 75 | ...args: Parameters> 76 | ) { 77 | win.webContents.send(eventName, ...args); 78 | } 79 | 80 | on>( 81 | eventName: E, 82 | listener: ProcessMainEventListener, 83 | options?: { once?: boolean } 84 | ): () => void { 85 | const internalListener = ( 86 | _event: IpcMainEvent, 87 | ...args: Parameters> 88 | ) => { 89 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 90 | (listener as any)(...args); 91 | }; 92 | const once = options?.once ?? false; 93 | if (once) { 94 | ipcMain.once(eventName, internalListener); 95 | } else { 96 | ipcMain.on(eventName, internalListener); 97 | } 98 | return () => { 99 | ipcMain.off(eventName, internalListener); 100 | }; 101 | } 102 | 103 | handle>( 104 | methodName: M, 105 | handler: ProcessInvocableMethodHandler 106 | ) { 107 | const internalHandler = ( 108 | _event: IpcMainInvokeEvent, 109 | ...args: Parameters> 110 | ) => { 111 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 112 | return (handler as any)(...args); 113 | }; 114 | ipcMain.handle(methodName, internalHandler); 115 | return () => { 116 | ipcMain.removeHandler(methodName); 117 | }; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/server-console/ServerConsoleWindow.ts: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow } from "electron"; 2 | import path from "path"; 3 | import { DEFAULT_SERVER_CONSOLE_WINDOW_PROPS } from "./Constants"; 4 | import type { 5 | ConstrainedWindowState, 6 | WindowState 7 | } from "../common/util/WindowState"; 8 | import { fileURLToPath } from "url"; 9 | 10 | declare const SERVER_CONSOLE_VITE_DEV_SERVER_URL: string; 11 | declare const SERVER_CONSOLE_VITE_NAME: string; 12 | 13 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 14 | 15 | export type ServerConsoleWindowState = WindowState; 16 | 17 | export interface ServerConsoleWindowProps 18 | extends Partial { 19 | devTools?: boolean; 20 | } 21 | 22 | export default class ServerConsoleWindow extends BrowserWindow { 23 | #emitStateChangeEventDelayTimer: NodeJS.Timeout | null; 24 | 25 | constructor(props?: ServerConsoleWindowProps) { 26 | const devTools = 27 | props?.devTools ?? DEFAULT_SERVER_CONSOLE_WINDOW_PROPS.devTools; 28 | super({ 29 | webPreferences: { 30 | sandbox: false, 31 | preload: path.join(__dirname, "server-console-preload.mjs"), 32 | devTools 33 | }, 34 | title: `Server Console - ${app.getName()}` 35 | }); 36 | 37 | const windowSize = props?.size ?? DEFAULT_SERVER_CONSOLE_WINDOW_PROPS.size; 38 | const windowPosition = 39 | props?.position ?? DEFAULT_SERVER_CONSOLE_WINDOW_PROPS.position; 40 | const windowState = 41 | props?.state ?? DEFAULT_SERVER_CONSOLE_WINDOW_PROPS.state; 42 | 43 | this.setMinimumSize( 44 | DEFAULT_SERVER_CONSOLE_WINDOW_PROPS.minSize.width, 45 | DEFAULT_SERVER_CONSOLE_WINDOW_PROPS.minSize.height 46 | ); 47 | 48 | this.setSize(windowSize.width, windowSize.height); 49 | if (windowState === "maximized") { 50 | this.maximize(); 51 | } else if (windowPosition) { 52 | this.setPosition(windowPosition.x, windowPosition.y); 53 | } else { 54 | this.center(); 55 | } 56 | 57 | if (devTools) { 58 | this.webContents.openDevTools(); 59 | } 60 | 61 | this.#emitStateChangeEventDelayTimer = null; 62 | 63 | this.on("resize", () => { 64 | this.#emitStateChangeEvent(); 65 | }); 66 | 67 | this.on("move", () => { 68 | this.#emitStateChangeEvent(); 69 | }); 70 | 71 | this.on("minimize", () => { 72 | this.#emitStateChangeEvent(); 73 | }); 74 | } 75 | 76 | #emitStateChangeEvent() { 77 | if (this.#emitStateChangeEventDelayTimer) { 78 | clearTimeout(this.#emitStateChangeEventDelayTimer); 79 | } 80 | this.#emitStateChangeEventDelayTimer = setTimeout(() => { 81 | this.emitServerConsoleWindowEvent("stateChange", this.getStateInfo()); 82 | }, 500); 83 | } 84 | 85 | async destroy() { 86 | this.removeAllListeners(); 87 | this.close(); 88 | } 89 | 90 | async launch() { 91 | if (SERVER_CONSOLE_VITE_DEV_SERVER_URL) { 92 | // Development: load from Vite dev server 93 | await this.webContents.loadURL(SERVER_CONSOLE_VITE_DEV_SERVER_URL); 94 | } else { 95 | // Production: load the built HTML file 96 | await this.webContents.loadFile( 97 | path.resolve( 98 | __dirname, 99 | `../renderer/${SERVER_CONSOLE_VITE_NAME}/index.html` 100 | ) 101 | ); 102 | } 103 | this.show(); 104 | } 105 | 106 | getStateInfo(): ServerConsoleWindowState { 107 | const [width, height] = this.getSize(); 108 | const [x, y] = this.getPosition(); 109 | return { 110 | size: { width, height }, 111 | position: { x, y }, 112 | state: 113 | this.isMaximized() ? "maximized" 114 | : this.isMinimized() ? "minimized" 115 | : "normal" 116 | }; 117 | } 118 | 119 | emitServerConsoleWindowEvent( 120 | event: "stateChange", 121 | info: ServerConsoleWindowState 122 | ): boolean; 123 | emitServerConsoleWindowEvent(event: string, ...args: unknown[]) { 124 | return this.emit(event, ...args); 125 | } 126 | 127 | onServerConsoleWindowEvent( 128 | event: "stateChange", 129 | listener: (info: ServerConsoleWindowState) => void 130 | ): this; 131 | 132 | onServerConsoleWindowEvent( 133 | event: string, 134 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 135 | listener: (...args: any[]) => void 136 | ) { 137 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 138 | return this.on(event as any, listener); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/main/ui/contexts/ConfigProvider.tsx: -------------------------------------------------------------------------------- 1 | import { useEditor } from "./EditorContextProvider"; 2 | import type { 3 | BrowserObtainableInput, 4 | UIConfig, 5 | UIConfigSection 6 | } from "../../types/UIConfig"; 7 | import type { UnionToTuple } from "../../../common/types/Utility"; 8 | import type React from "react"; 9 | import { 10 | createContext, 11 | useCallback, 12 | useContext, 13 | useEffect, 14 | useState 15 | } from "react"; 16 | import _ from "lodash"; 17 | 18 | type SetConfigValueFn = < 19 | S extends UIConfigSection, 20 | P extends keyof UIConfig[S] 21 | >( 22 | section: S, 23 | prop: P, 24 | value: UIConfig[S][P], 25 | _triggerRefresh?: boolean 26 | ) => void; 27 | 28 | export interface ConfigContextValue { 29 | config: UIConfig; 30 | setConfigValue: SetConfigValueFn; 31 | } 32 | const ConfigContext = createContext( 33 | {} as ConfigContextValue 34 | ); 35 | 36 | const isBrowserObtainableInput = ( 37 | value: unknown 38 | ): value is BrowserObtainableInput => { 39 | const props: UnionToTuple = [ 40 | "inputMode", 41 | "browserValue", 42 | "manualValue" 43 | ]; 44 | return ( 45 | !!value && 46 | typeof value === "object" && 47 | props.every((prop) => Reflect.has(value, prop)) 48 | ); 49 | }; 50 | 51 | const ConfigProvider = ({ children }: { children: React.ReactNode }) => { 52 | const { actionPending, activeEditor, markEditorModified } = useEditor(); 53 | const [, setRefreshToken] = useState(new Date().getMilliseconds()); 54 | 55 | const config = activeEditor?.config || null; 56 | 57 | const triggerRefresh = useCallback(() => { 58 | setRefreshToken(new Date().getMilliseconds()); 59 | }, [activeEditor]); 60 | 61 | const setConfigValue = useCallback( 62 | (section, prop, value, _triggerRefresh = true) => { 63 | if (!config || !activeEditor || _.isEqual(config[section][prop], value)) { 64 | return; 65 | } 66 | 67 | let markModified = !activeEditor.modified && section !== "support.data"; 68 | if ( 69 | markModified && 70 | isBrowserObtainableInput(config[section][prop]) && 71 | isBrowserObtainableInput(value) 72 | ) { 73 | if ( 74 | config[section][prop].inputMode === "browser" && 75 | value.inputMode === "browser" 76 | ) { 77 | // Do not mark modified if only the description has changed - this can happen when config has just been loaded 78 | // and only the browser value is set. 79 | markModified = 80 | config[section][prop].browserValue?.value !== 81 | value.browserValue?.value; 82 | } else if ( 83 | config[section][prop].inputMode === "manual" && 84 | value.inputMode === "manual" 85 | ) { 86 | markModified = 87 | config[section][prop].manualValue !== value.manualValue; 88 | } 89 | } 90 | 91 | config[section][prop] = value; 92 | 93 | if (markModified) { 94 | markEditorModified(activeEditor); 95 | } 96 | if (_triggerRefresh) { 97 | triggerRefresh(); 98 | } 99 | }, 100 | [config, triggerRefresh, markEditorModified, activeEditor] 101 | ); 102 | 103 | useEffect(() => { 104 | const removeListenerCallbacks = 105 | config ? 106 | [ 107 | window.mainAPI.on("browserPageInfo", (info) => { 108 | if (actionPending) { 109 | return; 110 | } 111 | setConfigValue("support.data", "browserObtainedValues", { 112 | ...config["support.data"].browserObtainedValues, 113 | target: { 114 | value: info.url || "", 115 | description: info.pageDescription 116 | }, 117 | cookie: { 118 | value: info.cookie || "", 119 | description: info.cookieDescription 120 | }, 121 | tiers: info.tiers 122 | }); 123 | setConfigValue('support.data', 'bootstrapData', info.bootstrapData); 124 | }) 125 | ] 126 | : []; 127 | 128 | return () => { 129 | removeListenerCallbacks.forEach((cb) => cb()); 130 | }; 131 | }, [config, setConfigValue, triggerRefresh, actionPending]); 132 | 133 | useEffect(() => {}, [actionPending]); 134 | 135 | if (!config) { 136 | return null; 137 | } 138 | 139 | return ( 140 | 141 | {children} 142 | 143 | ); 144 | }; 145 | 146 | const useConfig = () => useContext(ConfigContext); 147 | 148 | export { useConfig, ConfigProvider }; 149 | -------------------------------------------------------------------------------- /src/main/mixins/AppMenu.ts: -------------------------------------------------------------------------------- 1 | import { Menu } from "electron"; 2 | import type { MainProcessConstructor } from "../MainProcess"; 3 | import RecentDocuments from "../util/RecentDocuments"; 4 | 5 | export interface AppMenuOptions { 6 | enabled?: { 7 | save?: boolean; 8 | saveAs?: boolean; 9 | preview?: boolean; 10 | startDownload?: boolean; 11 | }; 12 | } 13 | 14 | export function AppMenuSupportMixin( 15 | Base: TBase 16 | ) { 17 | return class AppMenuSupportedProcess extends Base { 18 | #currentAppMenuOptions?: AppMenuOptions; 19 | 20 | setAppMenu(options?: AppMenuOptions) { 21 | options = options || this.#currentAppMenuOptions; 22 | const recentDocuments = RecentDocuments.list(); 23 | const recentDocumentMenuItems = 24 | recentDocuments.map((doc) => ({ 25 | label: doc.name, 26 | click: () => this.execUICommand("openFile", doc.filePath) 27 | })); 28 | if (recentDocuments.length > 0) { 29 | recentDocumentMenuItems.push( 30 | { 31 | type: "separator" 32 | }, 33 | { 34 | label: "Clear Recent", 35 | click: () => { 36 | RecentDocuments.clear(); 37 | this.setAppMenu(); 38 | this.emitRendererEvent( 39 | this.win.editorView, 40 | "recentDocumentsInfo", 41 | { 42 | entries: RecentDocuments.list() 43 | } 44 | ); 45 | } 46 | } 47 | ); 48 | } 49 | Menu.setApplicationMenu( 50 | Menu.buildFromTemplate([ 51 | { 52 | label: "&File", 53 | submenu: [ 54 | { 55 | label: "&New", 56 | accelerator: "CommandOrControl+N", 57 | click: () => this.execUICommand("createEditor") 58 | }, 59 | { 60 | label: "&Open", 61 | accelerator: "CommandOrControl+O", 62 | click: () => this.execUICommand("openFile") 63 | }, 64 | { 65 | label: "Open Recent", 66 | visible: recentDocumentMenuItems.length > 0, 67 | submenu: recentDocumentMenuItems 68 | }, 69 | { 70 | label: "&Save", 71 | accelerator: "CommandOrControl+S", 72 | enabled: options?.enabled?.save ?? true, 73 | click: () => this.execUICommand("save") 74 | }, 75 | { 76 | label: "Save &As...", 77 | enabled: options?.enabled?.saveAs ?? true, 78 | click: () => this.execUICommand("saveAs") 79 | }, 80 | { 81 | type: "separator" 82 | }, 83 | { 84 | label: "Pre&view", 85 | accelerator: "CommandOrControl+Shift+P", 86 | enabled: options?.enabled?.preview ?? true, 87 | click: () => this.execUICommand("preview") 88 | }, 89 | { 90 | type: "separator" 91 | }, 92 | { 93 | label: "E&xit", 94 | click: () => this.end() 95 | } 96 | ] 97 | }, 98 | { 99 | label: "&Run", 100 | submenu: [ 101 | { 102 | label: "Start &Download", 103 | accelerator: "F5", 104 | enabled: options?.enabled?.startDownload ?? true, 105 | click: () => this.execUICommand("startDownload") 106 | }, 107 | { 108 | label: "YouTube Configurator", 109 | click: () => this.execUICommand("configureYouTube") 110 | } 111 | ] 112 | }, 113 | { 114 | label: "&Help", 115 | submenu: [ 116 | { 117 | type: "checkbox", 118 | label: "Show Help &Icons", 119 | checked: false, 120 | click: (e) => { 121 | this.execUICommand("showHelpIcons", e.checked); 122 | } 123 | }, 124 | { 125 | type: "separator" 126 | }, 127 | { 128 | label: "About", 129 | click: () => { 130 | this.execUICommand("showAbout"); 131 | } 132 | } 133 | ] 134 | } 135 | ]) 136 | ); 137 | this.#currentAppMenuOptions = options; 138 | } 139 | }; 140 | } 141 | -------------------------------------------------------------------------------- /src/resources/patreon-dl-vimeo.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Taken from patreon-dl: 5 | * https://github.com/patrickkfkan/patreon-dl/blob/master/bin/patreon-dl-vimeo.js 6 | * 7 | * Converted to CommonJS so it can be packaged as single executable application 8 | * in `npm run prepare`. 9 | */ 10 | const parseArgs = require("yargs-parser"); 11 | const spawn = require("@patrickkfkan/cross-spawn"); 12 | const path = require("path"); 13 | 14 | function tryGetPlayerURL(html) { 15 | if (!html) { 16 | return null; 17 | } 18 | 19 | const regex = /https:\/\/player\.vimeo\.com\/video\/\d+/g; 20 | const match = regex.exec(html); 21 | if (match && match[0]) { 22 | console.log("Found Vimeo player URL from embed HTML:", match[0]); 23 | return match[0]; 24 | } 25 | 26 | const regex2 = /src="(\/\/cdn.embedly.com\/widgets.+?)"/g; 27 | const match2 = regex2.exec(html); 28 | if (match2 && match2[1]) { 29 | const embedlyURL = match2[1]; 30 | console.log("Found Embedly URL from embed HTML:", embedlyURL); 31 | let embedlySrc; 32 | try { 33 | const urlObj = new URL(`https:${embedlyURL}`); 34 | embedlySrc = urlObj.searchParams.get("src"); 35 | } catch (error) { 36 | console.error("Error parsing Embedly URL:", error); 37 | } 38 | try { 39 | const embedlySrcObj = new URL(embedlySrc); 40 | if (embedlySrcObj.hostname === "player.vimeo.com") { 41 | console.log(`Got Vimeo player URL from Embedly src: ${embedlySrc}`); 42 | } else { 43 | console.warn( 44 | `Embedly src "${embedlySrc}" does not correspond to Vimeo player URL` 45 | ); 46 | } 47 | return embedlySrc; 48 | } catch (error) { 49 | console.error(`Error parsing Embedly src "${embedlySrc}":`, error); 50 | } 51 | } 52 | 53 | return null; 54 | } 55 | 56 | function getCommandString(cmd, args) { 57 | const quotedArgs = args.map((arg) => (arg.includes(" ") ? `"${arg}"` : arg)); 58 | return [cmd, ...quotedArgs].join(" "); 59 | } 60 | 61 | async function download(url, o, videoPassword, ytdlpPath, ytdlpArgs) { 62 | let proc; 63 | const ytdlp = ytdlpPath || "yt-dlp"; 64 | const parsedYtdlpArgs = parseArgs(ytdlpArgs); 65 | try { 66 | return await new Promise((resolve, reject) => { 67 | let settled = false; 68 | const args = []; 69 | if (!parsedYtdlpArgs["o"] && !parsedYtdlpArgs["output"]) { 70 | args.push("-o", o); 71 | } 72 | if (!parsedYtdlpArgs["referrer"]) { 73 | args.push("--referer", "https://patreon.com/"); 74 | } 75 | args.push(...ytdlpArgs); 76 | const printArgs = [...args]; 77 | if (videoPassword && !parsedYtdlpArgs["video-password"]) { 78 | args.push("--video-password", videoPassword); 79 | printArgs.push("--video-password", "******"); 80 | } 81 | args.push(url); 82 | printArgs.push(url); 83 | 84 | console.log(`Command: ${getCommandString(ytdlp, printArgs)}`); 85 | proc = spawn(ytdlp, args); 86 | 87 | proc.stdout?.on("data", (data) => { 88 | console.log(data.toString()); 89 | }); 90 | 91 | proc.stderr?.on("data", (data_1) => { 92 | console.error(data_1.toString()); 93 | }); 94 | 95 | proc.on("error", (err) => { 96 | if (settled) { 97 | return; 98 | } 99 | settled = true; 100 | reject(err); 101 | }); 102 | 103 | proc.on("exit", (code) => { 104 | if (settled) { 105 | return; 106 | } 107 | settled = true; 108 | resolve(code); 109 | }); 110 | }); 111 | } finally { 112 | if (proc) { 113 | proc.removeAllListeners(); 114 | proc.stdout?.removeAllListeners(); 115 | proc.stderr?.removeAllListeners(); 116 | } 117 | } 118 | } 119 | 120 | const args = parseArgs(process.argv.slice(2)); 121 | const { 122 | o: _o, 123 | "embed-html": _embedHTML, 124 | "embed-url": _embedURL, 125 | "video-password": videoPassword, 126 | "yt-dlp": _ytdlpPath 127 | } = args; 128 | const o = _o?.trim() ? path.resolve(_o.trim()) : null; 129 | const embedHTML = _embedHTML?.trim(); 130 | const embedURL = _embedURL?.trim(); 131 | const ytdlpPath = _ytdlpPath?.trim() ? path.resolve(_ytdlpPath.trim()) : null; 132 | const ytdlpArgs = args["_"]; 133 | 134 | if (!o) { 135 | console.error("No output file specified"); 136 | process.exit(1); 137 | } 138 | 139 | if (!embedHTML && !embedURL) { 140 | console.error("No embed HTML or URL provided"); 141 | process.exit(1); 142 | } 143 | 144 | const url = tryGetPlayerURL(embedHTML) || embedURL; 145 | 146 | if (!url) { 147 | console.error(`Failed to obtain video URL`); 148 | process.exit(1); 149 | } 150 | 151 | async function doDownload(_url) { 152 | let code = await download(_url, o, videoPassword, ytdlpPath, ytdlpArgs); 153 | if (code !== 0 && _url !== embedURL && embedURL) { 154 | console.log(`Download failed - retrying with embed URL "${embedURL}"`); 155 | return await doDownload(embedURL); 156 | } 157 | return code; 158 | } 159 | 160 | console.log(`Going to download video from "${url}"`); 161 | 162 | doDownload(url).then((code) => { 163 | process.exit(code); 164 | }); 165 | -------------------------------------------------------------------------------- /src/main/ui/contexts/EditorContextProvider.tsx: -------------------------------------------------------------------------------- 1 | import type { Editor } from "../../types/App"; 2 | import type { YouTubeConnectionStatus } from "../../util/YouTubeConfigurator"; 3 | import type React from "react"; 4 | import { 5 | createContext, 6 | useCallback, 7 | useContext, 8 | useEffect, 9 | useState 10 | } from "react"; 11 | 12 | interface EditorContextValue { 13 | editors: Editor[]; 14 | activeEditor: Editor | null; 15 | addEditor: (editor: Editor) => void; 16 | setActiveEditor: (editor: Editor) => void; 17 | markEditorModified: (editor: Editor) => void; 18 | actionPending: boolean; 19 | setActionPending: (value: boolean) => void; 20 | setEditorProp: ( 21 | editor: Editor, 22 | value: Partial< 23 | Pick< 24 | Editor, 25 | "name" | "filePath" | "modified" | "loadAlerts" | "promptOnSave" 26 | > 27 | > 28 | ) => void; 29 | closeEditor: (editor: Editor) => void; 30 | showHelpIcons: boolean; 31 | setShowHelpIcons: (value: boolean) => void; 32 | youtubeConnectionStatus: YouTubeConnectionStatus | null; 33 | } 34 | const EditorContext = createContext({} as EditorContextValue); 35 | 36 | const EditorContextProvider = ({ children }: { children: React.ReactNode }) => { 37 | const [editors, setEditors] = useState([]); 38 | const [activeEditor, setActiveEditor] = useState(null); 39 | const [youtubeConnectionStatus, setYouTubeConnectionStatus] = 40 | useState(null); 41 | const [actionPending, setActionPending] = useState(false); 42 | const [showHelpIcons, setShowHelpIcons] = useState(false); 43 | const [, setRefreshToken] = useState(new Date().getMilliseconds()); 44 | 45 | const sendModifiedEditorsChangeEvent = useCallback( 46 | (override?: Editor[]) => { 47 | window.mainAPI.emitMainEvent("modifiedEditorsChange", { 48 | editors: (override || editors).filter((editor) => editor.modified) 49 | }); 50 | }, 51 | [editors] 52 | ); 53 | 54 | const closeEditor = useCallback( 55 | (editor: Editor) => { 56 | const editorIndex = 57 | editors.findIndex((_editor) => _editor.id === editor.id) || 0; 58 | const editorsAfterRemove = editors.filter( 59 | (_editor) => _editor.id !== editor.id 60 | ); 61 | const nextActiveEditor = 62 | editorsAfterRemove[editorIndex - 1] || editorsAfterRemove[0] || null; 63 | setEditors(editorsAfterRemove); 64 | setActiveEditor(nextActiveEditor); 65 | if (editor.modified) { 66 | // `editors` not yet updated - need to override that 67 | sendModifiedEditorsChangeEvent(editorsAfterRemove); 68 | } 69 | }, 70 | [editors, sendModifiedEditorsChangeEvent] 71 | ); 72 | 73 | const addEditor = useCallback( 74 | (editor: Editor) => { 75 | setEditors([...editors, editor]); 76 | setActiveEditor(editor); 77 | }, 78 | [editors, setActiveEditor] 79 | ); 80 | 81 | useEffect(() => { 82 | const removeListenerCallbacks = [ 83 | window.mainAPI.on("editorCreated", (editor) => { 84 | addEditor(editor); 85 | }), 86 | window.mainAPI.on("youtubeConnectionStatus", (status) => { 87 | setYouTubeConnectionStatus(status); 88 | }) 89 | ]; 90 | 91 | return () => { 92 | removeListenerCallbacks.forEach((cb) => cb()); 93 | }; 94 | }, [addEditor, editors]); 95 | 96 | useEffect(() => { 97 | window.mainAPI.emitMainEvent("activeEditorChange", { 98 | editor: activeEditor 99 | }); 100 | }, [activeEditor]); 101 | 102 | const triggerRefresh = useCallback(() => { 103 | setRefreshToken(new Date().getMilliseconds()); 104 | }, []); 105 | 106 | const setEditorProp = useCallback( 107 | (editor, values) => { 108 | const modifiedStateChanged = 109 | values.modified !== undefined && values.modified !== editor.modified; 110 | for (const [prop, value] of Object.entries(values)) { 111 | (editor as unknown as Record)[prop] = value; 112 | } 113 | if (modifiedStateChanged) { 114 | sendModifiedEditorsChangeEvent(); 115 | } 116 | triggerRefresh(); 117 | }, 118 | [sendModifiedEditorsChangeEvent] 119 | ); 120 | 121 | const markEditorModified = useCallback( 122 | (editor: Editor) => { 123 | setEditorProp(editor, { modified: true }); 124 | }, 125 | [setEditorProp] 126 | ); 127 | 128 | if (!activeEditor) { 129 | document.title = "patreon-dl-gui"; 130 | } else { 131 | document.title = `${activeEditor.name}${activeEditor.modified ? "*" : ""} - patreon-dl-gui`; 132 | } 133 | 134 | return ( 135 | 151 | {children} 152 | 153 | ); 154 | }; 155 | 156 | const useEditor = () => useContext(EditorContext); 157 | 158 | export { useEditor, EditorContextProvider }; 159 | -------------------------------------------------------------------------------- /src/main/types/UIConfig.ts: -------------------------------------------------------------------------------- 1 | import type { FileExistsAction, LogLevel, PostDownloaderBootstrapData, ProductDownloaderBootstrapData, StopOnCondition } from "patreon-dl"; 2 | import type { ObjectKeysByValueType } from "../../common/types/Utility"; 3 | 4 | export interface Tier { 5 | id: string; 6 | title: string; 7 | } 8 | 9 | export interface PageInfo { 10 | url: string | null; 11 | title: string | null; 12 | pageDescription: string; 13 | tiers: Tier[] | null; 14 | cookie: string | null; 15 | cookieDescription: string; 16 | bootstrapData: PostDownloaderBootstrapData | ProductDownloaderBootstrapData | null; 17 | } 18 | 19 | export interface BrowserObtainableInput { 20 | inputMode: "manual" | "browser"; 21 | manualValue: string; 22 | browserValue: BrowserObtainedValue | null; 23 | } 24 | 25 | export interface BrowserObtainedValue { 26 | value: string; 27 | description: string; 28 | } 29 | 30 | export type CustomSelectionValue< 31 | T extends boolean | string, 32 | V extends string 33 | > = { 34 | type: T | "custom"; 35 | custom: V[]; 36 | }; 37 | 38 | export type MaxVideoResolution = 39 | | "none" 40 | | "360p" 41 | | "480p" 42 | | "720p" 43 | | "1080p" 44 | | "1440p" 45 | | "2160p"; 46 | 47 | export interface UIConfig { 48 | downloader: { 49 | target: BrowserObtainableInput; 50 | cookie: BrowserObtainableInput; 51 | "path.to.ffmpeg": string; 52 | "path.to.deno": string; 53 | "max.video.resolution": MaxVideoResolution; 54 | "use.status.cache": boolean; 55 | "stop.on": Exclude; 56 | "no.prompt": boolean; 57 | "dry.run": boolean; 58 | }; 59 | output: { 60 | "out.dir": string; 61 | "campaign.dir.name.format": string; 62 | "content.dir.name.format": string; 63 | "media.filename.format": string; 64 | "content.file.exists.action": FileExistsAction; 65 | "info.file.exists.action": FileExistsAction; 66 | "info.api.file.exists.action": FileExistsAction; 67 | }; 68 | include: { 69 | "locked.content": boolean; 70 | "campaign.info": boolean; 71 | "content.info": boolean; 72 | "content.media": CustomSelectionValue< 73 | boolean, 74 | "image" | "video" | "audio" | "attachment" | "file" 75 | >; 76 | "preview.media": CustomSelectionValue; 77 | "all.media.variants": boolean; 78 | "media.thumbnails": boolean; 79 | "images.by.filename": string; 80 | "audio.by.filename": string; 81 | "attachments.by.filename": string; 82 | "posts.in.tier": CustomSelectionValue<"any", string>; 83 | "posts.with.media.type": CustomSelectionValue< 84 | "any" | "none", 85 | "image" | "video" | "audio" | "attachment" | "podcast" 86 | >; 87 | "posts.published": { 88 | type: "anytime" | "after" | "before" | "between"; 89 | after: string; 90 | before: string; 91 | }; 92 | "products.published": { 93 | type: "anytime" | "after" | "before" | "between"; 94 | after: string; 95 | before: string; 96 | }; 97 | comments: boolean; 98 | }; 99 | request: { 100 | "max.retries": number; 101 | "max.concurrent": number; 102 | "min.time": number; 103 | "proxy.url": string; 104 | "proxy.reject.unauthorized.tls": boolean; 105 | }; 106 | "embed.downloader.youtube": { 107 | type: "default" | "custom"; 108 | exec: string; 109 | }; 110 | "embed.downloader.vimeo": { 111 | type: "helper" | "custom"; 112 | exec: string; 113 | // Helper params 114 | "helper.ytdlp.path": string; 115 | "helper.password": string; 116 | "helper.ytdlp.args": string; 117 | }; 118 | "logger.console": { 119 | enabled: boolean; 120 | "log.level": LogLevel; 121 | "include.date.time": boolean; 122 | "include.level": boolean; 123 | "include.originator": boolean; 124 | "include.error.stack": boolean; 125 | "date.time.format": string; 126 | color: boolean; 127 | }; 128 | "logger.file.1": { 129 | enabled: boolean; 130 | "log.level": LogLevel; 131 | "log.dir": string; 132 | "log.filename": string; 133 | "file.exists.action": "append" | "overwrite"; 134 | "include.date.time": boolean; 135 | "include.level": boolean; 136 | "include.originator": boolean; 137 | "include.error.stack": boolean; 138 | "date.time.format": string; 139 | color: boolean; 140 | }; 141 | "patreon.dl.gui": { 142 | "connect.youtube": boolean; 143 | }; 144 | "support.data": { 145 | browserObtainedValues: { 146 | target: BrowserObtainedValue | null; 147 | cookie: BrowserObtainedValue | null; 148 | tiers: Tier[] | null; 149 | }; 150 | appliedProxySettings: { 151 | url: string; 152 | rejectUnauthorizedTLS: boolean; 153 | }; 154 | bootstrapData: PostDownloaderBootstrapData 155 | | ProductDownloaderBootstrapData 156 | | null; 157 | }; 158 | } 159 | 160 | export type UIConfigSection = keyof UIConfig; 161 | 162 | export type UIConfigValuesByType< 163 | S extends UIConfigSection, 164 | T 165 | > = ObjectKeysByValueType; 166 | 167 | export type UIConfigByValueType = { 168 | [K in keyof UIConfig as UIConfigValuesByType extends never ? never 169 | : K]: UIConfigValuesByType; 170 | }; 171 | 172 | export type UIConfigProp< 173 | S extends UIConfigSectionWithPropsOf, 174 | T 175 | > = UIConfigByValueType[S]; 176 | export type UIConfigSectionWithPropsOf = keyof UIConfigByValueType; 177 | export type UIConfigSectionPropTuple = [ 178 | S, 179 | UIConfigValuesByType 180 | ]; 181 | -------------------------------------------------------------------------------- /src/main/ui/editor/OutputBox.tsx: -------------------------------------------------------------------------------- 1 | import type { UIConfig } from "../../types/UIConfig"; 2 | import type { UnionToObjectTuple } from "../../../common/types/Utility"; 3 | import { useConfig } from "../contexts/ConfigProvider"; 4 | import SelectRow from "./components/SelectRow"; 5 | import TextInputRow from "./components/TextInputRow"; 6 | import { Container, Tab, Tabs } from "react-bootstrap"; 7 | import { useMemo } from "react"; 8 | import type { FileExistsAction } from "patreon-dl"; 9 | import _ from "lodash"; 10 | 11 | interface OutputBoxState { 12 | campaignDirNameFormat: string; 13 | contentDirNameFormat: string; 14 | mediaFilenameFormat: string; 15 | contentFileExistsAction: FileExistsAction; 16 | infoFileExistsAction: FileExistsAction; 17 | infoAPIFileExistsAction: FileExistsAction; 18 | } 19 | 20 | let oldState: OutputBoxState | null = null; 21 | 22 | function getOutputBoxState(config: UIConfig): OutputBoxState { 23 | const state: OutputBoxState = { 24 | campaignDirNameFormat: config.output["campaign.dir.name.format"], 25 | contentDirNameFormat: config.output["content.dir.name.format"], 26 | mediaFilenameFormat: config.output["media.filename.format"], 27 | contentFileExistsAction: config.output["content.file.exists.action"], 28 | infoFileExistsAction: config.output["info.file.exists.action"], 29 | infoAPIFileExistsAction: config.output["info.api.file.exists.action"] 30 | }; 31 | 32 | if (oldState && _.isEqual(oldState, state)) { 33 | return oldState; 34 | } 35 | oldState = _.cloneDeep(state); 36 | return state; 37 | } 38 | 39 | const FILE_EXISTS_ACTION_OPTIONS: UnionToObjectTuple< 40 | FileExistsAction, 41 | { label: string } 42 | > = [ 43 | { value: "skip", label: "Skip" }, 44 | { value: "overwrite", label: "Overwrite" }, 45 | { value: "saveAsCopy", label: "Save as copy" }, 46 | { value: "saveAsCopyIfNewer", label: "Save as copy if newer" } 47 | ]; 48 | 49 | function OutputBox() { 50 | const { config } = useConfig(); 51 | const state = getOutputBoxState(config); 52 | 53 | return useMemo(() => { 54 | return ( 55 | 60 | 61 | 62 | 76 | 90 | 103 | 104 | 105 | 106 | 111 | 112 | 119 | 126 | 133 | 134 | 135 | 136 | ); 137 | }, [state]); 138 | } 139 | 140 | export default OutputBox; 141 | -------------------------------------------------------------------------------- /src/main/ui/App.tsx: -------------------------------------------------------------------------------- 1 | import EditorPanel from "./editor/EditorPanel"; 2 | import { EditorContextProvider } from "./contexts/EditorContextProvider"; 3 | import EditorToolbar from "./editor/EditorToolbar"; 4 | import { ConfigProvider } from "./contexts/ConfigProvider"; 5 | import "bootswatch/dist/darkly/bootstrap.min.css"; 6 | import "material-icons/iconfont/material-icons.css"; 7 | import "material-symbols"; 8 | import "./styles/main.css"; 9 | import "../../common/ui/styles/components.css"; 10 | import { useCallback, useEffect, useRef, useState } from "react"; 11 | import { CommandsProvider } from "./contexts/CommandsProvider"; 12 | import WebBrowserToolbar from "./editor/WebBrowserToolbar"; 13 | import CustomScrollbars from "../../common/ui/components/CustomScrollbars"; 14 | import { ToastContainer } from "react-toastify"; 15 | 16 | function App() { 17 | const [uiReady, setUIReady] = useState(false); 18 | const [editorPanelWidth, setEditorPanelWidth] = useState(null); 19 | const editorPanelRef = useRef(null); 20 | const webBrowserViewRef = useRef(null); 21 | const dividerRef = useRef(null); 22 | 23 | useEffect(() => { 24 | (async () => { 25 | const width = await window.mainAPI.invoke("getEditorPanelWidth"); 26 | setEditorPanelWidth(width); 27 | setUIReady(true); 28 | })(); 29 | }, []); 30 | 31 | const sendViewBounds = useCallback(() => { 32 | const __getBounds = (el: HTMLElement) => { 33 | const { 34 | offsetLeft: x, 35 | offsetTop: y, 36 | clientWidth: width, 37 | clientHeight: height 38 | } = el; 39 | return { 40 | x, 41 | y, 42 | width, 43 | height 44 | }; 45 | }; 46 | if (webBrowserViewRef.current && editorPanelRef.current) { 47 | window.mainAPI.emitMainEvent("viewBoundsChange", { 48 | editorView: __getBounds(editorPanelRef.current), 49 | webBrowserView: __getBounds(webBrowserViewRef.current) 50 | }); 51 | } 52 | }, []); 53 | 54 | useEffect(() => { 55 | let resetDrag: (() => void) | null = null; 56 | if (dividerRef.current) { 57 | dividerRef.current.addEventListener("mousedown", (e) => { 58 | if (editorPanelRef.current) { 59 | const dragStartPosition = { 60 | x: e.pageX, 61 | y: e.pageY, 62 | editorPanelWidth: editorPanelRef.current.clientWidth 63 | }; 64 | const mouseMoveListener = (e: MouseEvent) => { 65 | const deltaX = e.pageX - dragStartPosition.x; 66 | const newEditorViewWidth = 67 | dragStartPosition.editorPanelWidth + deltaX; 68 | setEditorPanelWidth(newEditorViewWidth); 69 | }; 70 | const _resetDrag = (resetDrag = () => { 71 | document.removeEventListener("mousemove", mouseMoveListener); 72 | document.removeEventListener("mouseup", mouseUpListener); 73 | document.body.style.userSelect = "inherit"; 74 | }); 75 | const mouseUpListener = () => { 76 | _resetDrag(); 77 | resetDrag = null; 78 | }; 79 | document.addEventListener("mouseup", mouseUpListener); 80 | document.addEventListener("mousemove", mouseMoveListener); 81 | document.body.style.userSelect = "none"; 82 | } 83 | }); 84 | } 85 | return () => { 86 | if (resetDrag) { 87 | resetDrag(); 88 | } 89 | }; 90 | }, [uiReady]); 91 | 92 | useEffect(() => { 93 | if (webBrowserViewRef.current) { 94 | const resizeObserver = new ResizeObserver((entries) => { 95 | const entry = entries[0]; 96 | if (entry) { 97 | sendViewBounds(); 98 | } 99 | }); 100 | resizeObserver.observe(webBrowserViewRef.current); 101 | 102 | return () => { 103 | resizeObserver.disconnect(); 104 | }; 105 | } 106 | }, [uiReady, sendViewBounds]); 107 | 108 | useEffect(() => { 109 | if (uiReady) { 110 | sendViewBounds(); 111 | window.mainAPI.emitMainEvent("uiReady"); 112 | } 113 | }, [uiReady]); 114 | 115 | if (!uiReady) { 116 | return null; 117 | } 118 | 119 | return ( 120 | 121 | 122 |
123 |
128 | 129 | 130 |
131 | 132 | 133 | 134 |
135 |
136 |
137 |
142 |
146 |
147 |
148 |
149 |
150 |
151 | 152 |
153 |
154 | 160 |
161 |
162 |
163 | ); 164 | } 165 | 166 | export default App; 167 | -------------------------------------------------------------------------------- /src/main/ui/editor/NetworkBox.tsx: -------------------------------------------------------------------------------- 1 | import type { UIConfig } from "../../types/UIConfig"; 2 | import { useConfig } from "../contexts/ConfigProvider"; 3 | import TextInputRow from "./components/TextInputRow"; 4 | import { Collapse, Container, Tab, Tabs } from "react-bootstrap"; 5 | import { useCallback, useEffect, useMemo, useState } from "react"; 6 | import _ from "lodash"; 7 | import CheckboxRow from "./components/CheckboxRow"; 8 | import { useEditor } from "../contexts/EditorContextProvider"; 9 | import { showToast } from "../helpers/Toast"; 10 | 11 | interface NetworkBoxState { 12 | request: UIConfig["request"]; 13 | } 14 | 15 | let oldState: NetworkBoxState | null = null; 16 | 17 | function getNetworkBoxState(config: UIConfig): NetworkBoxState { 18 | const state: NetworkBoxState = { 19 | request: config["request"] 20 | }; 21 | 22 | if (oldState && _.isEqual(oldState, state)) { 23 | return oldState; 24 | } 25 | oldState = _.cloneDeep(state); 26 | return state; 27 | } 28 | 29 | function NetworkBox() { 30 | const { config, setConfigValue } = useConfig(); 31 | const { activeEditor } = useEditor(); 32 | const state = getNetworkBoxState(config); 33 | const [showProxyNotice, setShowProxyNotice] = useState(false); 34 | 35 | useEffect(() => { 36 | const removeListenerCallbacks = [ 37 | window.mainAPI.on("applyProxyResult", (result) => { 38 | if (result.status === "success") { 39 | setConfigValue("support.data", "appliedProxySettings", { 40 | url: config.request["proxy.url"], 41 | rejectUnauthorizedTLS: 42 | config.request["proxy.reject.unauthorized.tls"] 43 | }); 44 | setShowProxyNotice(false); 45 | showToast("success", "Proxy settings applied"); 46 | } else { 47 | showToast("error", `Failed to apply proxy settings: ${result.error}`); 48 | } 49 | }) 50 | ]; 51 | 52 | return () => { 53 | removeListenerCallbacks.forEach((cb) => cb()); 54 | }; 55 | }, [config]); 56 | 57 | const applyProxySettings = useCallback(async () => { 58 | if (!activeEditor) { 59 | return; 60 | } 61 | try { 62 | await window.mainAPI.invoke("applyProxy", activeEditor); 63 | } catch (error: unknown) { 64 | console.error(error); 65 | } 66 | }, [activeEditor]); 67 | 68 | const refreshProxyNoticeVisibility = useCallback(() => { 69 | const applied = config["support.data"].appliedProxySettings; 70 | const current = config["request"]; 71 | if (applied.url.trim() === "" && current["proxy.url"].trim() === "") { 72 | setShowProxyNotice(false); 73 | return; 74 | } 75 | setShowProxyNotice( 76 | applied.url.trim() !== current["proxy.url"].trim() || 77 | applied.rejectUnauthorizedTLS !== 78 | current["proxy.reject.unauthorized.tls"] 79 | ); 80 | }, [ 81 | config["support.data"].appliedProxySettings.url, 82 | config.request["proxy.url"], 83 | config["support.data"].appliedProxySettings.rejectUnauthorizedTLS, 84 | config.request["proxy.reject.unauthorized.tls"] 85 | ]); 86 | 87 | useEffect(() => { 88 | refreshProxyNoticeVisibility(); 89 | }, [refreshProxyNoticeVisibility]); 90 | 91 | return useMemo(() => { 92 | return ( 93 | 98 | 99 | 100 | 107 | 114 | 121 | 122 | 123 | 124 | 125 | 126 | 127 |
128 | Proxy settings changed. 129 | 130 | 131 | Apply 132 | 133 | 134 |
135 |
136 |
137 | 138 | 145 | 152 | 153 |
154 |
155 | ); 156 | }, [ 157 | state, 158 | showProxyNotice, 159 | applyProxySettings, 160 | refreshProxyNoticeVisibility 161 | ]); 162 | } 163 | 164 | export default NetworkBox; 165 | -------------------------------------------------------------------------------- /src/main/ui/editor/components/TextInputRow.tsx: -------------------------------------------------------------------------------- 1 | import type { 2 | UIConfig, 3 | UIConfigSectionPropTuple, 4 | UIConfigSectionWithPropsOf 5 | } from "../../../types/UIConfig"; 6 | import { useConfig } from "../../contexts/ConfigProvider"; 7 | import type React from "react"; 8 | import { useCallback, useRef } from "react"; 9 | import { Button, Col, Form, InputGroup, Row } from "react-bootstrap"; 10 | import classNames from "classnames"; 11 | import type { AccessibilityProps, HelpProps } from "../../../../common/ui"; 12 | import { createHelpIcon } from "./Common"; 13 | 14 | type InputValueType = "text" | "number" | "dir" | "file"; 15 | type ConfigValueType = 16 | T extends "number" ? number : string; 17 | 18 | type TextInputRowProps< 19 | S extends UIConfigSectionWithPropsOf>, 20 | T extends InputValueType 21 | > = { 22 | as?: "inputGroup" | "row"; 23 | type?: T; 24 | config: UIConfigSectionPropTuple>; 25 | label: string; 26 | insertables?: { value: string; label: string }[]; 27 | onChange?: (e: React.ChangeEvent) => void; 28 | } & HelpProps & 29 | AccessibilityProps; 30 | 31 | function TextInputRow< 32 | S extends UIConfigSectionWithPropsOf>, 33 | T extends InputValueType 34 | >(props: TextInputRowProps) { 35 | const { config, setConfigValue } = useConfig(); 36 | const { 37 | as = "row", 38 | type = "text", 39 | config: target, 40 | label, 41 | insertables, 42 | ariaLabel, 43 | onChange 44 | } = props; 45 | const [section, prop] = target; 46 | const value = config[section][prop] as string | number; 47 | const textboxRef = useRef(null); 48 | 49 | const _setConfigValue = useCallback( 50 | (inputValue: string) => { 51 | const configValue = type === "number" ? Number(inputValue) : inputValue; 52 | setConfigValue( 53 | section, 54 | prop, 55 | configValue as UIConfig[typeof section][typeof prop] 56 | ); 57 | }, 58 | [setConfigValue] 59 | ); 60 | 61 | const handleValueChange = useCallback( 62 | (e: React.ChangeEvent) => { 63 | _setConfigValue(e.currentTarget.value); 64 | if (onChange) { 65 | onChange(e); 66 | } 67 | }, 68 | [_setConfigValue, onChange] 69 | ); 70 | 71 | const textbox = ( 72 | 80 | ); 81 | 82 | const openFSChooser = useCallback( 83 | async (type: "dir" | "file") => { 84 | const result = await window.mainAPI.invoke("openFSChooser", { 85 | properties: type === "dir" ? ["openDirectory"] : ["openFile"], 86 | title: type === "dir" ? "Choose directory" : "Choose file" 87 | }); 88 | if (result.canceled) { 89 | return; 90 | } 91 | _setConfigValue(result.filePath); 92 | }, 93 | [_setConfigValue] 94 | ); 95 | 96 | let textboxContainer; 97 | switch (type) { 98 | case "text": 99 | case "number": 100 | textboxContainer = textbox; 101 | break; 102 | case "dir": 103 | case "file": 104 | textboxContainer = ( 105 | 106 | {textbox} 107 | 119 | 120 | ); 121 | break; 122 | } 123 | 124 | const insertField = useCallback((value: string) => { 125 | const textbox = textboxRef.current; 126 | if (!textbox) { 127 | return; 128 | } 129 | const currentStart = textbox.selectionStart || 0; 130 | textbox.setRangeText(value); 131 | textbox.selectionEnd = textbox.selectionStart = currentStart + value.length; 132 | textbox.focus(); 133 | const event = new Event("change", { bubbles: true }); 134 | textbox.dispatchEvent(event); 135 | }, []); 136 | 137 | const insertableLinks = insertables?.map(({ value, label }) => ( 138 | insertField(value)} 143 | aria-label={`Insert ${label}`} 144 | > 145 | {label} 146 | 147 | )); 148 | 149 | let insertablesContainer = <>; 150 | if (insertableLinks && insertableLinks.length > 0) { 151 | insertablesContainer = ( 152 |
153 |
Insert:
154 |
{insertableLinks}
155 |
156 | ); 157 | } 158 | 159 | const classes = classNames( 160 | "py-1", 161 | insertableLinks && insertableLinks.length > 0 ? null : "align-items-center" 162 | ); 163 | 164 | if (as === "inputGroup") { 165 | return ( 166 | <> 167 |
168 | 169 | {label} 170 | {textboxContainer} 171 | {createHelpIcon({ ...props, className: "ms-2 pt-1" })} 172 | 173 |
174 | {insertablesContainer} 175 | 176 | ); 177 | } 178 | 179 | return ( 180 | <> 181 | 182 | {label}: 183 | 184 |
185 | {textboxContainer} 186 | {createHelpIcon(props)} 187 |
188 | {insertablesContainer} 189 | 190 |
191 | 192 | ); 193 | } 194 | 195 | export default TextInputRow; 196 | -------------------------------------------------------------------------------- /src/server-console/ui/components/ServerFormModal.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useReducer, useState } from "react"; 2 | import { Button, Modal, Stack } from "react-bootstrap"; 3 | import _ from "lodash"; 4 | import type { Server } from "../../types/Server"; 5 | import TextInputRow from "./TextInputRow"; 6 | import SelectRow from "./SelectRow"; 7 | import type { SaveServerFormResult } from "../../types/ServerConsoleInvocableMethods"; 8 | 9 | const NEW_SERVER: Server = { 10 | name: "", 11 | dataDir: "", 12 | port: "auto", 13 | portNumber: 3000 14 | }; 15 | 16 | const PORT_OPTIONS = [ 17 | { label: "Auto", value: "auto" }, 18 | { label: "Specify a port number", value: "manual" } 19 | ]; 20 | 21 | type ServerPropertyValues = { 22 | [K in keyof Server]?: Server[K]; 23 | }; 24 | 25 | const serverReducer = ( 26 | current: Server, 27 | propertyValues: ServerPropertyValues 28 | ) => { 29 | const updated = { 30 | ...current, 31 | ...propertyValues 32 | }; 33 | return _.isEqual(current, updated) ? current : updated; 34 | }; 35 | 36 | function ServerFormModal() { 37 | const [mode, setMode] = useState<"add" | "edit" | null>(null); 38 | const [server, setServerPropertyValues] = useReducer(serverReducer, { 39 | ...NEW_SERVER 40 | }); 41 | const [show, setShow] = useState(false); 42 | const [errors, setErrors] = useState< 43 | (SaveServerFormResult & { success: false })["errors"] | null 44 | >(null); 45 | const [helpIconsVisible, setHelpIconsVisible] = useState(false); 46 | 47 | useEffect(() => { 48 | const removeListenerCallbacks = [ 49 | window.serverConsoleAPI.on("showAddServerForm", () => { 50 | setMode("add"); 51 | setServerPropertyValues({ ...NEW_SERVER }); 52 | setErrors(null); 53 | setShow(true); 54 | }), 55 | window.serverConsoleAPI.on("showEditServerForm", (server) => { 56 | setMode("edit"); 57 | setServerPropertyValues({ ...server }); 58 | setErrors(null); 59 | setShow(true); 60 | }), 61 | window.serverConsoleAPI.on("closeServerForm", () => { 62 | setShow(false); 63 | }) 64 | ]; 65 | 66 | return () => { 67 | removeListenerCallbacks.forEach((cb) => cb()); 68 | }; 69 | }, []); 70 | 71 | const handleSave = useCallback(async () => { 72 | const result = await window.serverConsoleAPI.invoke( 73 | "saveServerFormData", 74 | server 75 | ); 76 | if (!result.success) { 77 | setErrors(result.errors); 78 | } 79 | }, [server]); 80 | 81 | const handleCancel = useCallback(async () => { 82 | await window.serverConsoleAPI.invoke("cancelServerForm"); 83 | }, []); 84 | 85 | const toggleHelpIcons = useCallback(() => { 86 | setHelpIconsVisible(!helpIconsVisible); 87 | }, [helpIconsVisible]); 88 | 89 | if (!mode) { 90 | return; 91 | } 92 | 93 | const headerTitle = mode === "add" ? "Add server" : "Edit server"; 94 | 95 | return ( 96 | 105 | 106 | {headerTitle} 107 | 108 | 109 | 110 | { 115 | setServerPropertyValues({ name: e.target.value }); 116 | }} 117 | error={errors?.name} 118 | helpTooltip={ 119 | helpIconsVisible ? "Provide a name for the server." : undefined 120 | } 121 | /> 122 | { 127 | setServerPropertyValues({ dataDir: e.target.value }); 128 | }} 129 | error={errors?.dataDir} 130 | helpTooltip={ 131 | helpIconsVisible ? 132 | "Set this to the destination directory defined in your downloader's configuration." 133 | : undefined 134 | } 135 | /> 136 | { 140 | setServerPropertyValues({ 141 | port: e.target.value as typeof server.port 142 | }); 143 | }} 144 | options={PORT_OPTIONS} 145 | helpTooltip={ 146 | helpIconsVisible ? 147 | 'The port the server will listen on. Set to "Auto" to have one assigned automatically.' 148 | : undefined 149 | } 150 | /> 151 | {server.port === "manual" ? 152 | { 157 | setServerPropertyValues({ portNumber: Number(e.target.value) }); 158 | }} 159 | error={errors?.portNumber} 160 | helpTooltip={ 161 | helpIconsVisible ? 162 | "Enter a valid port number between 1024 and 65535 (inclusive)." 163 | : undefined 164 | } 165 | /> 166 | : null} 167 | 168 | 169 | 170 | 171 | 176 |
177 | 180 | 183 |
184 |
185 |
186 |
187 | ); 188 | } 189 | 190 | export default ServerFormModal; 191 | -------------------------------------------------------------------------------- /src/main/ui/editor/LoggingBox.tsx: -------------------------------------------------------------------------------- 1 | import type { UIConfig } from "../../types/UIConfig"; 2 | import type { UnionToObjectTuple } from "../../../common/types/Utility"; 3 | import { useConfig } from "../contexts/ConfigProvider"; 4 | import SelectRow from "./components/SelectRow"; 5 | import CheckboxRow from "./components/CheckboxRow"; 6 | import TextInputRow from "./components/TextInputRow"; 7 | import { Container, Tab, Tabs } from "react-bootstrap"; 8 | import { useCallback, useMemo } from "react"; 9 | import type { LogLevel } from "patreon-dl"; 10 | import _ from "lodash"; 11 | 12 | interface LoggingBoxState { 13 | consoleLogger: UIConfig["logger.console"]; 14 | fileLogger: UIConfig["logger.file.1"]; 15 | } 16 | 17 | let oldState: LoggingBoxState | null = null; 18 | 19 | function getLoggingBoxState(config: UIConfig): LoggingBoxState { 20 | const state: LoggingBoxState = { 21 | consoleLogger: config["logger.console"], 22 | fileLogger: config["logger.file.1"] 23 | }; 24 | 25 | if (oldState && _.isEqual(oldState, state)) { 26 | return oldState; 27 | } 28 | oldState = _.cloneDeep(state); 29 | return state; 30 | } 31 | 32 | const LOG_LEVEL_OPTIONS: UnionToObjectTuple = [ 33 | { value: "debug", label: "Debug" }, 34 | { value: "info", label: "Info" }, 35 | { value: "warn", label: "Warn" }, 36 | { value: "error", label: "Error" } 37 | ]; 38 | 39 | function LoggingBox() { 40 | const { config } = useConfig(); 41 | const state = getLoggingBoxState(config); 42 | 43 | const getCommonElements = useCallback( 44 | (forType: "logger.console" | "logger.file.1") => { 45 | return ( 46 | <> 47 | 52 | 57 | 62 | 67 | 73 | 78 | 79 | ); 80 | }, 81 | [] 82 | ); 83 | 84 | return useMemo(() => { 85 | return ( 86 | 91 | 96 | 97 | 103 | 110 | {getCommonElements("logger.console")} 111 | 112 | 113 | 114 | 119 | 120 | 126 | 133 | 145 | 156 | 165 | {getCommonElements("logger.file.1")} 166 | 167 | 168 | 169 | ); 170 | }, [state]); 171 | } 172 | 173 | export default LoggingBox; 174 | --------------------------------------------------------------------------------