From fe38837b9e7e9ed70a68eaeb478ebf2a163c164c Mon Sep 17 00:00:00 2001 From: arabcoders Date: Mon, 25 Aug 2025 22:21:32 +0300 Subject: [PATCH 1/2] Add dialog composable --- ui/app/components/Dialog.vue | 151 ++++++++++++++++++++++++++++++++ ui/app/composables/useDialog.ts | 127 +++++++++++++++++++++++++++ 2 files changed, 278 insertions(+) create mode 100644 ui/app/components/Dialog.vue create mode 100644 ui/app/composables/useDialog.ts diff --git a/ui/app/components/Dialog.vue b/ui/app/components/Dialog.vue new file mode 100644 index 00000000..2c4809ae --- /dev/null +++ b/ui/app/components/Dialog.vue @@ -0,0 +1,151 @@ + + + + + diff --git a/ui/app/composables/useDialog.ts b/ui/app/composables/useDialog.ts new file mode 100644 index 00000000..8d9dd5ae --- /dev/null +++ b/ui/app/composables/useDialog.ts @@ -0,0 +1,127 @@ +import {reactive, readonly} from 'vue' + +export type DialogResult = { status: boolean; value: T } + +type BaseOptions = { + /** + * Title of the dialog + */ + title?: string + /** + * Message to display in the dialog + */ + message?: string + /** + * Text for the confirm button + */ + confirmText?: string +} + +export type PromptOptions = BaseOptions & { + /** + * Text for the input field + */ + initial?: string + /** + * Placeholder text for the input field + */ + placeholder?: string + /** + * Text for the cancel button + */ + cancelText?: string + /** + * Function to validate the input value + * @returns true if valid, or an error message string if invalid + */ + validate?: (v: string) => true | string +} + +export type ConfirmOptions = BaseOptions & { + /** + * Text for the confirm button + */ + cancelText?: string +} + +export type AlertOptions = BaseOptions & {} + +type QueueItem = { + type: 'prompt' | 'confirm' | 'alert' + opts: PromptOptions | ConfirmOptions | AlertOptions + resolve: (r: DialogResult) => void +} + +type DialogState = { + current: QueueItem | null + queue: QueueItem[] + errorMsg: string | null + input: string +} + +export const useDialog = () => { + const raw = useState('dialog:state', () => reactive({ + current: null, + queue: [], + errorMsg: null, + input: '', + } as DialogState)) + + const state = raw.value + + const _dequeue = () => { + if (!state.current && state.queue.length) { + state.current = state.queue.shift()! + state.errorMsg = null + state.input = state.current.type === 'prompt' ? (state.current.opts as PromptOptions).initial ?? '' : '' + } + } + + const promptDialog = (opts: PromptOptions) => new Promise>((resolve) => { + state.queue.push({type: 'prompt', opts, resolve}) + _dequeue() + }) + + const confirmDialog = (opts: ConfirmOptions) => new Promise>((resolve) => { + state.queue.push({type: 'confirm', opts, resolve}) + _dequeue() + }) + + const alertDialog = (opts: AlertOptions) => new Promise>((resolve) => { + state.queue.push({type: 'alert', opts, resolve}) + _dequeue() + }) + + const confirm = (value?: string) => { + if (!state.current) return + if (state.current.type === 'prompt') { + const val = value ?? state.input + const v = (state.current.opts as PromptOptions).validate?.(val) + if (v && v !== true) { + state.errorMsg = v + return + } + state.current.resolve({status: true, value: val}) + } else { + state.current.resolve({status: true, value: null}) + } + state.current = null + _dequeue() + } + + const cancel = () => { + if (!state.current) return + state.current.resolve({status: false, value: null}) + state.current = null + _dequeue() + } + + return { + promptDialog, + confirmDialog, + alertDialog, + confirm, + cancel, + state: readonly(state) as Readonly, + } +} From 70e06f01229435a98efb776398b181eab8319abe Mon Sep 17 00:00:00 2001 From: arabcoders Date: Mon, 25 Aug 2025 22:22:04 +0300 Subject: [PATCH 2/2] remove webview in favor of using built-in browser. --- .github/workflows/native-build.yml | 18 +- .vscode/settings.json | 7 +- app.spec | 23 +-- app/library/config.py | 6 +- app/local.py | 133 +++++++++++++ app/main.py | 2 + app/native.py | 187 ------------------ app/routes/api/system.py | 59 ++++++ pyproject.toml | 7 +- ui/app/components/InputAutocomplete.vue | 5 +- ui/app/components/TextareaAutocomplete.vue | 3 +- ui/app/components/shutdown.vue | 91 +++++++++ ui/app/layouts/default.vue | 61 +++++- uv.lock | 217 +-------------------- 14 files changed, 366 insertions(+), 453 deletions(-) create mode 100644 app/local.py delete mode 100644 app/native.py create mode 100644 app/routes/api/system.py create mode 100644 ui/app/components/shutdown.vue diff --git a/.github/workflows/native-build.yml b/.github/workflows/native-build.yml index b2d9db76..3564a3ed 100644 --- a/.github/workflows/native-build.yml +++ b/.github/workflows/native-build.yml @@ -1,4 +1,4 @@ -name: Build WebView wrappers +name: Build Native wrappers on: workflow_dispatch: @@ -52,10 +52,6 @@ jobs: restore-keys: | choco-${{ runner.os }}-${{ matrix.arch }}- - - name: Install Qt (Windows) - if: matrix.os == 'windows-latest' - run: choco install -y qt6-base-dev - - name: Cache Python venv id: cache-python uses: actions/cache@v4 @@ -77,16 +73,6 @@ jobs: uv venv --system-site-packages --relocatable uv sync --link-mode=copy --active - - name: Install PyInstaller + Qt backend (Windows) - if: matrix.os == 'windows-latest' - run: | - uv pip install pyinstaller pywebview QtPy PySide6 - - - name: Install PyInstaller + Qt backend (Other OSs) - if: matrix.os != 'windows-latest' - run: | - uv pip install pyinstaller pywebview[qt] - - name: Install pnpm uses: pnpm/action-setup@v4 with: @@ -107,7 +93,7 @@ jobs: - name: Build native binary run: | - uv run pyinstaller ./app.spec + uv tool run pyinstaller ./app.spec - name: Upload artifact uses: actions/upload-artifact@v4 diff --git a/.vscode/settings.json b/.vscode/settings.json index 09c5c3df..03ae043d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -24,6 +24,7 @@ "bilibili", "brainicism", "brotlicffi", + "choco", "consoletitle", "continuedl", "cookiesfrombrowser", @@ -41,6 +42,7 @@ "engineio", "Errno", "euuo", + "excepthook", "faststart", "finaldir", "flac", @@ -91,10 +93,6 @@ "pycryptodome", "pyinstaller", "pypi", - "pyside", - "pywebview", - "qstr", - "qtpy", "quicktime", "rejecttitle", "remux", @@ -103,6 +101,7 @@ "SIGUSR", "smhd", "socketio", + "softprops", "sstr", "SUPPRESSHELP", "tebibytes", diff --git a/app.spec b/app.spec index 73a1a074..3615ae53 100644 --- a/app.spec +++ b/app.spec @@ -1,25 +1,10 @@ import os import platform -import sys import tomllib block_cipher = None machine = platform.machine().lower() -binaries = [] -if sys.platform.startswith("linux"): - if machine in ("x86_64", "amd64"): - libdir = "/usr/lib/x86_64-linux-gnu" - elif machine in ("aarch64", "arm64"): - libdir = "/usr/lib/aarch64-linux-gnu" - else: - libdir = "/usr/lib" - binaries = [] -elif sys.platform == "darwin": - binaries = [] -elif sys.platform.startswith("win"): - # Windows DLLs - binaries = [] with open("./uv.lock", "rb") as f: lock = tomllib.load(f) @@ -37,9 +22,9 @@ hidden = [ hidden = [f.replace("-", "_") for f in hidden] a = Analysis( # noqa: F821 # type: ignore - ["app/native.py"], + ["app/local.py"], pathex=[os.getcwd()], - binaries=binaries, + binaries=[], datas=[ ("ui/exported", "ui/exported"), ("app/", "app/"), @@ -47,7 +32,7 @@ a = Analysis( # noqa: F821 # type: ignore hiddenimports=hidden, hookspath=[], runtime_hooks=[], - excludes=[], + excludes=["libstdc++.so.6"], cipher=block_cipher, ) @@ -63,7 +48,7 @@ exe = EXE( # type: ignore # noqa: F821 debug=False, strip=False, upx=True, - console=False, # Turn on to True if you want a console window for debugging. + console=True, icon="ui/public/favicon.ico", onefile=True, ) diff --git a/app/library/config.py b/app/library/config.py index adf26c72..a94d2e1c 100644 --- a/app/library/config.py +++ b/app/library/config.py @@ -185,7 +185,7 @@ class Config: """Enable yt-dlp debugging.""" is_native: bool = False - "Is the application running in webview." + "Is the application running in natively." prevent_live_premiere: bool = False """Prevent downloading of the initial premiere live broadcast.""" @@ -288,7 +288,9 @@ class Config: @staticmethod def get_instance(is_native: bool = False) -> "Config": """Static access method.""" - return Config(is_native) if not Config.__instance else Config.__instance + cls: Config = Config(is_native) if not Config.__instance else Config.__instance + cls.is_native = is_native or cls.is_native + return cls @staticmethod def get_manager() -> SyncManager: diff --git a/app/local.py b/app/local.py new file mode 100644 index 00000000..2347bf74 --- /dev/null +++ b/app/local.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +# flake8: noqa: SIM115 S310 T201 + +import argparse +import os +import socket +import sys +import time +import urllib.request +import webbrowser +from pathlib import Path + +from dotenv import load_dotenv + +os.environ["PYTHONUTF8"] = "1" + +sys.path.insert(0, os.path.join(getattr(sys, "_MEIPASS", os.path.abspath(os.path.dirname(__file__))), "app")) + +APP_NAME = "YTPTube" +APP_ROOT = str((Path(__file__).parent / "..").resolve()) +if APP_ROOT not in sys.path: + sys.path.insert(0, APP_ROOT) + +import platformdirs # type: ignore + + +def set_env(): + dct = {} + + if not os.getenv("YTP_CONFIG_PATH"): + dct["YTP_CONFIG_PATH"] = platformdirs.user_config_dir(APP_NAME.lower(), "arabcoders", ensure_exists=True) + + if not os.getenv("YTP_TEMP_PATH"): + dct["YTP_TEMP_PATH"] = platformdirs.user_cache_dir(APP_NAME.lower(), "arabcoders", ensure_exists=True) + + if not os.getenv("YTP_DOWNLOAD_PATH"): + dct["YTP_DOWNLOAD_PATH"] = platformdirs.user_downloads_dir() + + if os.getenv("YTP_ACCESS_LOG", None) is None: + dct["YTP_ACCESS_LOG"] = "false" + + if not os.getenv("YTP_HOST"): + dct["YTP_HOST"] = "127.0.0.1" + + if dct: + os.environ.update(dct) + + +def open_browser_when_ready(url: str, timeout: float = 5.0) -> None: + import threading + + def wait_then_open(): + deadline = time.time() + timeout + while time.time() < deadline: + try: + with urllib.request.urlopen(url, timeout=1) as response: + if 200 == response.status: + os.environ.pop("LD_LIBRARY_PATH", None) + webbrowser.open_new(url) + return + except Exception: + time.sleep(0.5) + + print(f"Failed to open browser automatically within {timeout} seconds. Please open {url} manually.") + + threading.Thread(target=wait_then_open, daemon=True).start() + + +def app_start(host: str, port: int) -> None: + import asyncio + + from app.main import Main + + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + Main(is_native=True).start(host, port) + + +def update_env_file(env_file: Path, port: int) -> None: + lines = [] + if env_file.exists(): + with env_file.open("r", encoding="utf-8") as f: + lines = f.readlines() + + port_line = f"YTP_PORT={port}\n" + for i, line in enumerate(lines): + if line.startswith("YTP_PORT="): + lines[i] = port_line + break + else: + lines.append(port_line) + + with env_file.open("w", encoding="utf-8") as f: + f.writelines(lines) + + +def main(): + parser = argparse.ArgumentParser(description="Start YTPTube server") + parser.add_argument("--no-browser", action="store_true", help="Do not open browser on start") + args = parser.parse_args() + + set_env() + + env_file: Path = Path(os.getenv("YTP_CONFIG_PATH")) / ".env" + + port = None + if env_file.exists(): + load_dotenv(env_file) + port = os.getenv("YTP_PORT") + + host = os.getenv("YTP_HOST", "127.0.0.1") + + if not port or 8081 == int(port): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind((host, 0)) + port = s.getsockname()[1] + update_env_file(env_file, port) + + url = f"http://{host}:{port}" + + env_no_browser: bool = os.getenv("YTP_NO_BROWSER", "false").lower() in ("1", "true", "yes") + if not args.no_browser and not env_no_browser: + open_browser_when_ready(url) + + app_start(host, int(port)) + + +if __name__ == "__main__": + main() diff --git a/app/main.py b/app/main.py index c960d0e1..15c6ae3b 100644 --- a/app/main.py +++ b/app/main.py @@ -141,6 +141,8 @@ class Main: def started(_): LOG.info("=" * 40) LOG.info(f"YTPTube {self._config.app_version} - started on http://{host}:{port}{self._config.base_path}") + if self._config.is_native: + LOG.info("Running in native mode.") LOG.info("=" * 40) loop = asyncio.get_event_loop() diff --git a/app/native.py b/app/native.py deleted file mode 100644 index b486522a..00000000 --- a/app/native.py +++ /dev/null @@ -1,187 +0,0 @@ -#!/usr/bin/env python3 -import asyncio -import json -import logging -import os -import queue -import socket -import sys -import threading -import traceback -from pathlib import Path - -import platformdirs - -sys.path.insert(0, os.path.join(getattr(sys, "_MEIPASS", os.path.abspath(os.path.dirname(__file__))), "app")) - -APP_NAME = "YTPTube" -APP_ROOT = str((Path(__file__).parent / "..").resolve()) -if APP_ROOT not in sys.path: - sys.path.insert(0, APP_ROOT) - -try: - import webview # type: ignore - - if "linux" in sys.platform: - os.environ.setdefault("LC_ALL", "C.UTF-8") - os.environ.setdefault("LANG", "C.UTF-8") - import webview.platforms.qt # type: ignore - - # monkey patch the download handler for pywebview to support qt6. - def on_download_requested(self, download): - from qtpy import QtCore # type: ignore - from qtpy.QtWidgets import QFileDialog # type: ignore - - old_path = download.url().path() - suffix = QtCore.QFileInfo(old_path).suffix() - filename, _ = QFileDialog.getSaveFileName( - self, self.localization["global.saveFile"], old_path, "*." + suffix - ) - if filename: - if hasattr(download, "setPath"): - download.setPath(filename) - else: - download.setDownloadDirectory(os.path.dirname(filename)) - download.setDownloadFileName(os.path.basename(filename)) - download.accept() - else: - download.cancel() - - webview.platforms.qt.BrowserView.on_download_requested = on_download_requested - -except ImportError as e: - pkgs = "pywebview[edgechromium]" if os.name == "nt" else "pywebview[qt]" - msg: str = f"Please run 'uv pip install {pkgs}' to run YTPTube in native mode." - raise ImportError(msg) from e - - -def error_window(exc: Exception | str) -> None: - trace: str = "\n".join(traceback.format_exception(exc)) if isinstance(exc, Exception) else exc - webview.create_window( - f"{APP_NAME} - Error", - html=f"

An error occurred

{trace}
", - width=600, - height=400, - resizable=True, - ) - webview.start( - gui="edgechromium" if os.name == "nt" else "qt", - debug=False, - storage_path=str(Path(os.getenv("YTP_TEMP_PATH", os.getcwd())) / "webview"), - private_mode=False, - ) - sys.exit(1) - - -def set_env(): - defaults = { - "YTP_CONFIG_PATH": lambda: platformdirs.user_config_dir(APP_NAME.lower(), "arabcoders", ensure_exists=True), - "YTP_TEMP_PATH": lambda: platformdirs.user_cache_dir(APP_NAME.lower(), "arabcoders", ensure_exists=True), - "YTP_DOWNLOAD_PATH": lambda: platformdirs.user_downloads_dir(), - "YTP_ACCESS_LOG": "false", - "YTP_BROWSER_ENABLED": "true", - "YTP_BROWSER_CONTROL_ENABLED": "true", - } - - for key, value in defaults.items(): - if os.getenv(key) is not None: - continue - - os.environ[key] = value() if callable(value) else value - - -def run_backend(host: str, port: int, ready_event: threading.Event, error_queue: queue.Queue): - try: - try: - loop = asyncio.get_running_loop() - except RuntimeError: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - from app.main import Main - - Main(is_native=True).start(host, port, cb=lambda: ready_event.set()) - except Exception as e: - logging.exception(e) - error_queue.put(e) - ready_event.set() - - -def get_usable_port(host: str = "127.0.0.1") -> int: - """ - Get a usable port on the specified host. - If no port is available, it will raise an OSError. - """ - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind((host, 0)) - port = s.getsockname()[1] - s.close() - - return port - - -def main(): - host = "127.0.0.1" - set_env() - - cfg_path: Path = Path(os.getenv("YTP_CONFIG_PATH")) / "webview.json" - win_conf: dict[str, int] = {} - - if cfg_path.exists(): - data = json.loads(cfg_path.read_text()) - for key in ("width", "height", "x", "y"): - if key in data: - win_conf[key] = data[key] - - port = get_usable_port(host) - - ready = threading.Event() - errors: queue.Queue = queue.Queue() - threading.Thread(target=run_backend, args=(host, port, ready, errors), daemon=False).start() - - ready.wait(timeout=5) - - if not errors.empty(): - error_window(errors.get()) - return - - from app.library.version import APP_VERSION - - gui = "edgechromium" if os.name == "nt" else "qt" - create_kwargs = {**win_conf, "resizable": True} - window = webview.create_window(f"{APP_NAME} - {APP_VERSION}", f"http://{host}:{port}", **create_kwargs) - - def save_geometry(): - cfg = { - "port": port, - "width": window.width, - "height": window.height, - "x": window.x, - "y": window.y, - } - cfg_path.write_text(json.dumps(cfg)) - - window.events.resized += lambda *_: save_geometry() - window.events.moved += lambda *_: save_geometry() - window.events.closing += lambda *_: os._exit(0) - - webview.settings["ALLOW_DOWNLOADS"] = True - webview.settings["OPEN_DEVTOOLS_IN_DEBUG"] = False - webview.start( - gui=gui, - debug=True, - storage_path=str(Path(os.getenv("YTP_TEMP_PATH", os.getcwd())) / "webview"), - private_mode=False, - ) - - -if __name__ == "__main__": - try: - from multiprocessing import freeze_support - - freeze_support() - main() - except Exception as e: - logging.exception(e) - error_window(e) - os._exit(1) diff --git a/app/routes/api/system.py b/app/routes/api/system.py new file mode 100644 index 00000000..5564ea40 --- /dev/null +++ b/app/routes/api/system.py @@ -0,0 +1,59 @@ +import asyncio +import logging + +from aiohttp import web +from aiohttp.web import Request, Response +from aiohttp.web_runner import GracefulExit + +from app.library.config import Config +from app.library.encoder import Encoder +from app.library.Events import EventBus, Events +from app.library.router import route + +LOG: logging.Logger = logging.getLogger(__name__) + + +@route("POST", "api/system/shutdown", "system.shutdown") +async def shutdown_system(request: Request, config: Config, encoder: Encoder, notify: EventBus) -> Response: + """ + Get the presets. + + Args: + request (Request): The request object. + config (Config): The config instance. + encoder (Encoder): The encoder instance. + notify (EventBus): The event bus instance. + + Returns: + Response: The response object. + + """ + if config.is_native is not True: + return web.json_response( + {"error": "Shutdown is only available in the native mode."}, + status=web.HTTPBadRequest.status_code, + ) + + app = request.app + + async def do_shutdown(): + await notify.emit( + Events.SHUTDOWN, + data={"app": app}, + title="Application Shutdown", + message="Shutdown initiated by user request.", + ) + await asyncio.sleep(0.5) + await app.shutdown() + await app.cleanup() + raise GracefulExit + + # Schedule shutdown after response + asyncio.create_task(do_shutdown()) + return web.json_response( + data={ + "message": "The application shutting down.", + }, + status=web.HTTPOk.status_code, + dumps=encoder.encode, + ) diff --git a/pyproject.toml b/pyproject.toml index bbfd5975..cfe61218 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,12 +78,7 @@ indent-width = 4 target-version = "py311" [project.optional-dependencies] -webview = [ - #windows only - "qtpy; sys_platform == 'win32'", - "pyside6; sys_platform == 'win32'", - # all - "pywebview", +installer = [ "pyinstaller", ] diff --git a/ui/app/components/InputAutocomplete.vue b/ui/app/components/InputAutocomplete.vue index 2588cb50..3e1d4ce1 100644 --- a/ui/app/components/InputAutocomplete.vue +++ b/ui/app/components/InputAutocomplete.vue @@ -3,7 +3,7 @@
+ :disabled="disabled" :id="id" />