From 3deca483a165db5dd506b1b5d6947b7616d7d34b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Gon=C3=A7alves?= Date: Wed, 24 Jun 2026 20:45:30 -0300 Subject: [PATCH 01/47] Harden remote play packaging and secrets --- README.md | 82 +- default.nix | 45 + flake.nix | 19 + pkgbuild/PKGBUILD | 54 +- pyproject.toml | 68 + src/big_remote_play/__init__.py | 3 + src/big_remote_play/__main__.py | 8 + .../main.py => src/big_remote_play/app.py | 29 +- .../big_remote_play}/guest/__init__.py | 0 .../guest/moonlight_client.py | 16 +- .../big_remote_play}/host/__init__.py | 0 src/big_remote_play/host/sunshine_manager.py | 579 ++++++++ src/big_remote_play/paths.py | 64 + .../big_remote_play}/ui/__init__.py | 0 .../big_remote_play}/ui/guest_view.py | 133 +- .../big_remote_play}/ui/host_view.py | 1026 +++++++++++-- .../big_remote_play}/ui/installer_window.py | 6 +- .../big_remote_play}/ui/main_window.py | 247 +++- .../ui/moonlight_preferences.py | 28 +- .../ui/performance_monitor.py | 279 ++-- .../big_remote_play}/ui/preferences.py | 82 +- .../ui/private_network_view.py | 1179 ++++++++------- .../ui/sunshine_preferences.py | 797 ++++++++++ .../big_remote_play}/utils/__init__.py | 0 .../big_remote_play}/utils/audio.py | 84 +- .../big_remote_play}/utils/config.py | 24 +- .../big_remote_play}/utils/game_detector.py | 11 +- src/big_remote_play/utils/i18n.py | 19 + .../big_remote_play}/utils/icons.py | 47 +- .../big_remote_play}/utils/logger.py | 3 +- .../utils/moonlight_config.py | 7 +- .../big_remote_play}/utils/network.py | 44 +- src/big_remote_play/utils/script_protocol.py | 36 + src/big_remote_play/utils/secret_store.py | 146 ++ src/big_remote_play/utils/secure_io.py | 40 + src/big_remote_play/utils/system_check.py | 161 ++ src/big_remote_play/utils/uri.py | 27 + src/big_remote_play/utils/widgets.py | 298 ++++ tests/conftest.py | 11 + tests/test_audio.py | 21 + tests/test_config.py | 44 + tests/test_drop_guest_validation.py | 36 + tests/test_network.py | 29 + tests/test_paths.py | 30 + tests/test_script_protocol.py | 53 + tests/test_secret_store.py | 23 + tests/test_secure_io.py | 32 + tests/test_security_regressions.py | 130 ++ tests/test_sunshine_api.py | 345 +++++ usr/bin/big-remote-play | 51 +- .../big-remote-play/host/sunshine_manager.py | 449 ------ .../scripts/big-remoteplay-install.sh | 170 --- .../scripts/configure_firewall.sh | 79 +- .../scripts/create-network_headscale.sh | 903 ++++++------ .../scripts/create-network_tailscale.sh | 1301 +++++++++-------- .../scripts/create-network_zerotier.sh | 356 ++--- .../big-remote-play/scripts/drop_guest.sh | 9 +- .../scripts/fix_sunshine_libs.sh | 66 - .../scripts/headscale_master-new.sh | 565 ------- usr/share/big-remote-play/ui/style.css | 186 +++ .../ui/sunshine_preferences.py | 569 ------- usr/share/big-remote-play/utils/i18n.py | 26 - .../big-remote-play/utils/system_check.py | 207 --- 63 files changed, 6884 insertions(+), 4498 deletions(-) create mode 100644 default.nix create mode 100644 flake.nix create mode 100644 pyproject.toml create mode 100644 src/big_remote_play/__init__.py create mode 100644 src/big_remote_play/__main__.py rename usr/share/big-remote-play/main.py => src/big_remote_play/app.py (89%) rename {usr/share/big-remote-play => src/big_remote_play}/guest/__init__.py (100%) rename {usr/share/big-remote-play => src/big_remote_play}/guest/moonlight_client.py (96%) rename {usr/share/big-remote-play => src/big_remote_play}/host/__init__.py (100%) create mode 100644 src/big_remote_play/host/sunshine_manager.py create mode 100644 src/big_remote_play/paths.py rename {usr/share/big-remote-play => src/big_remote_play}/ui/__init__.py (100%) rename {usr/share/big-remote-play => src/big_remote_play}/ui/guest_view.py (92%) rename {usr/share/big-remote-play => src/big_remote_play}/ui/host_view.py (66%) rename {usr/share/big-remote-play => src/big_remote_play}/ui/installer_window.py (97%) rename {usr/share/big-remote-play => src/big_remote_play}/ui/main_window.py (82%) rename {usr/share/big-remote-play => src/big_remote_play}/ui/moonlight_preferences.py (94%) rename {usr/share/big-remote-play => src/big_remote_play}/ui/performance_monitor.py (80%) rename {usr/share/big-remote-play => src/big_remote_play}/ui/preferences.py (78%) rename {usr/share/big-remote-play => src/big_remote_play}/ui/private_network_view.py (67%) create mode 100644 src/big_remote_play/ui/sunshine_preferences.py rename {usr/share/big-remote-play => src/big_remote_play}/utils/__init__.py (100%) rename {usr/share/big-remote-play => src/big_remote_play}/utils/audio.py (82%) rename {usr/share/big-remote-play => src/big_remote_play}/utils/config.py (70%) rename {usr/share/big-remote-play => src/big_remote_play}/utils/game_detector.py (96%) create mode 100644 src/big_remote_play/utils/i18n.py rename {usr/share/big-remote-play => src/big_remote_play}/utils/icons.py (52%) rename {usr/share/big-remote-play => src/big_remote_play}/utils/logger.py (98%) rename {usr/share/big-remote-play => src/big_remote_play}/utils/moonlight_config.py (90%) rename {usr/share/big-remote-play => src/big_remote_play}/utils/network.py (92%) create mode 100644 src/big_remote_play/utils/script_protocol.py create mode 100644 src/big_remote_play/utils/secret_store.py create mode 100644 src/big_remote_play/utils/secure_io.py create mode 100644 src/big_remote_play/utils/system_check.py create mode 100644 src/big_remote_play/utils/uri.py create mode 100644 src/big_remote_play/utils/widgets.py create mode 100644 tests/conftest.py create mode 100644 tests/test_audio.py create mode 100644 tests/test_config.py create mode 100644 tests/test_drop_guest_validation.py create mode 100644 tests/test_network.py create mode 100644 tests/test_paths.py create mode 100644 tests/test_script_protocol.py create mode 100644 tests/test_secret_store.py create mode 100644 tests/test_secure_io.py create mode 100644 tests/test_security_regressions.py create mode 100644 tests/test_sunshine_api.py delete mode 100644 usr/share/big-remote-play/host/sunshine_manager.py delete mode 100755 usr/share/big-remote-play/scripts/big-remoteplay-install.sh delete mode 100755 usr/share/big-remote-play/scripts/fix_sunshine_libs.sh delete mode 100755 usr/share/big-remote-play/scripts/headscale_master-new.sh delete mode 100644 usr/share/big-remote-play/ui/sunshine_preferences.py delete mode 100644 usr/share/big-remote-play/utils/i18n.py delete mode 100644 usr/share/big-remote-play/utils/system_check.py diff --git a/README.md b/README.md index b4b578e..6818feb 100644 --- a/README.md +++ b/README.md @@ -202,51 +202,59 @@ Big Remote Play acts as a **unified interface** that orchestrates multiple open- ``` big-remote-play/ +├── 📁 src/ +│ └── 📁 big_remote_play/ # Python package (built as a wheel) +│ ├── app.py # Application entry point (Adw.Application) +│ ├── __main__.py # `python -m big_remote_play` +│ ├── paths.py # Data/locale path resolution +│ ├── 📁 ui/ # User Interface (GTK4/libadwaita) +│ │ ├── main_window.py # Main window with sidebar nav +│ │ ├── host_view.py # Host server configuration +│ │ ├── guest_view.py # Guest client connection +│ │ ├── private_network_view.py # VPN/Private network setup +│ │ ├── performance_monitor.py # Real-time performance dashboard +│ │ ├── sunshine_preferences.py # Sunshine advanced settings +│ │ ├── moonlight_preferences.py # Moonlight advanced settings +│ │ ├── preferences.py # General app preferences +│ │ └── installer_window.py # Dependency installer +│ ├── 📁 host/ # Host module +│ │ └── sunshine_manager.py # Sunshine server management +│ ├── 📁 guest/ # Guest module +│ │ └── moonlight_client.py # Moonlight client wrapper +│ └── 📁 utils/ # Utility modules +│ ├── audio.py # PulseAudio management +│ ├── config.py # Configuration management (atomic) +│ ├── game_detector.py # Game detection (Steam/Lutris/Heroic) +│ ├── i18n.py # Internationalization +│ ├── icons.py # Icon utilities +│ ├── logger.py # Logging system +│ ├── network.py # Network discovery & tools +│ ├── script_protocol.py # Network-script marker parser +│ ├── secure_io.py # Owner-only secret writes +│ └── system_check.py # System dependency checker +├── 📁 tests/ # pytest suite ├── 📁 usr/ │ ├── 📁 bin/ -│ │ └── big-remote-play # Shell launcher script +│ │ └── big-remote-play # Resilient launcher (survives Python upgrades) │ └── 📁 share/ │ ├── 📁 applications/ -│ │ └── big-remote-play.desktop # Desktop entry -│ ├── 📁 big-remote-play/ -│ │ ├── main.py # Application entry point -│ │ ├── 📁 ui/ # User Interface -│ │ │ ├── main_window.py # Main window with sidebar nav -│ │ │ ├── host_view.py # Host server configuration -│ │ │ ├── guest_view.py # Guest client connection -│ │ │ ├── private_network_view.py # VPN/Private network setup -│ │ │ ├── performance_monitor.py # Real-time performance dashboard -│ │ │ ├── sunshine_preferences.py # Sunshine advanced settings -│ │ │ ├── moonlight_preferences.py # Moonlight advanced settings -│ │ │ ├── preferences.py # General app preferences -│ │ │ ├── installer_window.py # Dependency installer +│ │ └── big-remote-play.desktop # Desktop entry +│ ├── 📁 big-remote-play/ # Data assets (shipped under /usr/share, not code) +│ │ ├── 📁 ui/ │ │ │ └── style.css # Custom GTK4 styles -│ │ ├── 📁 host/ # Host module -│ │ │ └── sunshine_manager.py # Sunshine server management -│ │ ├── 📁 guest/ # Guest module -│ │ │ └── moonlight_client.py # Moonlight client wrapper -│ │ ├── 📁 utils/ # Utility modules -│ │ │ ├── audio.py # PulseAudio management -│ │ │ ├── config.py # Configuration management -│ │ │ ├── game_detector.py # Game detection (Steam/Lutris/Heroic) -│ │ │ ├── i18n.py # Internationalization -│ │ │ ├── icons.py # Icon utilities -│ │ │ ├── logger.py # Logging system -│ │ │ ├── network.py # Network discovery & tools -│ │ │ └── system_check.py # System dependency checker -│ │ ├── 📁 scripts/ # Shell scripts -│ │ │ ├── big-remoteplay-configure.sh -│ │ │ ├── big-remoteplay-firewall.sh -│ │ │ ├── big-remoteplay-install.sh -│ │ │ ├── big-remoteplay-service.sh +│ │ ├── 📁 scripts/ # Shell scripts (gettext-localized) │ │ │ ├── configure_firewall.sh │ │ │ ├── create-network_headscale.sh -│ │ │ ├── fix_sunshine_libs.sh -│ │ │ └── headscale_master.sh -│ │ └── 📁 icons/ # SVG/PNG icons -│ ├── 📁 icons/ # System icon theme -│ └── 📁 locale/ # Compiled translations +│ │ │ ├── create-network_tailscale.sh +│ │ │ ├── create-network_zerotier.sh +│ │ │ └── drop_guest.sh +│ │ ├── 📁 icons/ # App SVG icons +│ │ └── 📁 img/ # App images +│ └── 📁 locale/ # Compiled translations (.mo) ├── 📁 locale/ # Translation source files (.po/.pot) +├── pyproject.toml # Wheel build + ruff/pytest/pyright config +├── flake.nix # Nix build +├── default.nix # Nix package definition ├── 📁 pkgbuild/ # Arch Linux packaging │ ├── PKGBUILD │ └── pkgbuild.install diff --git a/default.nix b/default.nix new file mode 100644 index 0000000..aa6c219 --- /dev/null +++ b/default.nix @@ -0,0 +1,45 @@ +{ lib +, python3Packages +, gtk4 +, libadwaita +, libsecret +, pkg-config +, wrapGAppsHook4 +, gobject-introspection +, gettext +, curl +, iproute2 +}: +python3Packages.buildPythonApplication { + pname = "big-remote-play"; + version = "2.0.0"; + src = ./.; + pyproject = true; + + build-system = with python3Packages; [ uv-build ]; + dependencies = with python3Packages; [ pygobject3 pycairo ]; + + nativeBuildInputs = [ pkg-config wrapGAppsHook4 gobject-introspection gettext ]; + buildInputs = [ gtk4 libadwaita libsecret ]; + + # Runtime tools resolved from PATH at use time (sunshine, moonlight, docker, + # tailscale, zerotier) are not Nix build inputs; the app degrades gracefully + # when they are absent. + propagatedBuildInputs = [ curl iproute2 ]; + + dontWrapGApps = false; + + postInstall = '' + cp -a $src/usr/share/big-remote-play $out/share/big-remote-play + install -Dm644 $src/usr/share/applications/big-remote-play.desktop \ + $out/share/applications/big-remote-play.desktop + cp -a $src/usr/share/locale $out/share/locale + ''; + + meta = with lib; { + description = "Integrated remote cooperative gaming system"; + homepage = "https://github.com/biglinux/big-remote-play"; + license = licenses.gpl3Plus; + platforms = platforms.linux; + }; +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..25b0077 --- /dev/null +++ b/flake.nix @@ -0,0 +1,19 @@ +{ + description = "Big Remote Play — integrated remote cooperative gaming"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let pkgs = nixpkgs.legacyPackages.${system}; + in { + packages.default = pkgs.callPackage ./. { }; + apps.default = { + type = "app"; + program = "${self.packages.${system}.default}/bin/big-remote-play"; + }; + }); +} diff --git a/pkgbuild/PKGBUILD b/pkgbuild/PKGBUILD index 9d83aad..a8dd1db 100644 --- a/pkgbuild/PKGBUILD +++ b/pkgbuild/PKGBUILD @@ -1,42 +1,48 @@ # Maintainer: Rafael Ruscher pkgname=big-remote-play pkgdesc="Integrated remote cooperative gaming system" -depends=('python' 'gtk4' 'libadwaita' 'python-gobject' 'python-cairo' 'avahi' 'curl' 'iproute2' 'sunshine-bin' 'moonlight-qt' 'icu' 'docker' 'docker-compose' 'jq' 'miniupnpc') -# makedepends=('') +depends=('python' 'gtk4' 'libadwaita' 'vte4' 'python-gobject' 'python-cairo' 'libsecret' 'avahi' 'curl' 'iproute2' 'sunshine-bin' 'moonlight-qt' 'docker' 'docker-compose' 'jq' 'miniupnpc') +makedepends=('python-build' 'python-installer' 'python-uv-build' 'python-wheel' 'gettext') replaces=('big-remote-play-together') conflicts=('big-remote-play-together') pkgver=$(date +%y.%m.%d) pkgrel=$(date +%H%M) arch=('any') -license=('GPL3') +license=('GPL-3.0-or-later') url="https://github.com/biglinux/$pkgname" provides=("$pkgname") -source=("git+${url}.git") -md5sums=('SKIP') -if [ -e "${pkgname}.install" ];then +source=() +md5sums=() +if [ -e "${pkgname}.install" ]; then install=${pkgname}.install -elif [ -e "pkgbuild.install" ];then +elif [ -e "pkgbuild.install" ]; then install=pkgbuild.install fi -package() { - # Verify default folder - if [ -d "${srcdir}/${pkgname}/${pkgname}" ]; then - InternalDir="${srcdir}/${pkgname}/${pkgname}" - else - InternalDir="${srcdir}/${pkgname}" - fi +# Repository working tree (PKGBUILD lives in pkgbuild/, so the root is one up). +_repo="${startdir}/.." + +prepare() { + # Stage only what the build needs, avoiding .git and build artifacts. + rm -rf "${srcdir}/tree" + install -dm755 "${srcdir}/tree" + cp -a "${_repo}/src" "${_repo}/usr" "${_repo}/pyproject.toml" "${srcdir}/tree/" +} - # Copy files - if [ -d "${InternalDir}/usr" ]; then - cp -r "${InternalDir}/usr" "${pkgdir}/" - fi +build() { + cd "${srcdir}/tree" || return 1 + python -m build --wheel --no-isolation --skip-dependency-check +} + +package() { + cd "${srcdir}/tree" || return 1 - if [ -d "${InternalDir}/etc" ]; then - cp -r "${InternalDir}/etc" "${pkgdir}/" - fi + # 1. Python code -> /usr/lib/pythonX.Y/site-packages/big_remote_play + python -m installer --destdir="${pkgdir}" dist/*.whl - if [ -d "${InternalDir}/opt" ]; then - cp -r "${InternalDir}/opt" "${pkgdir}/" - fi + # 2. Data, desktop entry, icons, locale catalogs, and the resilient launcher. + # Copied after the wheel so usr/bin/big-remote-play overwrites the wheel's + # version-pinned console script. + cp -a usr/. "${pkgdir}/usr/" + chmod 755 "${pkgdir}/usr/bin/${pkgname}" } diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..cb1c4fc --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,68 @@ +[project] +name = "big-remote-play" +version = "2.0.0" +description = "Integrated remote cooperative gaming system" +requires-python = ">=3.11" +license = "GPL-3.0-or-later" +authors = [{ name = "BigLinux Team" }] +# GI bindings declared so Nix buildPythonApplication resolves pygobject3/pycairo +# from nixpkgs. On Arch they are satisfied by the python-gobject pacman package +# and are NEVER pip-installed at runtime (zero PyPI runtime deps). +dependencies = ["PyGObject"] + +[project.urls] +Homepage = "https://github.com/biglinux/big-remote-play" + +[project.gui-scripts] +big-remote-play = "big_remote_play.app:main" + +[build-system] +requires = ["uv_build>=0.11.16,<0.12"] +build-backend = "uv_build" + +[tool.uv.build-backend] +module-name = "big_remote_play" +module-root = "src" + +[tool.pytest.ini_options] +pythonpath = ["src"] +testpaths = ["tests"] + +[tool.mypy] +mypy_path = "src" + +[tool.pyright] +include = ["src"] +typeCheckingMode = "basic" +# GObject-introspection bindings have no source stubs and return nullable, weakly +# typed values; without full PyGObject stubs these rules fire pervasively across the +# existing untyped GUI code. Downgraded to warnings so the gate reflects real +# correctness — full type annotation is tracked as incremental debt. The genuine +# correctness rules (possibly-unbound, call issues) stay as errors. +reportMissingModuleSource = "warning" +reportOptionalMemberAccess = "warning" +reportOptionalSubscript = "warning" +reportOptionalIterable = "warning" +reportOptionalCall = "warning" +reportArgumentType = "warning" +reportAttributeAccessIssue = "warning" +# The remaining call-issue sites pass GI nullable returns (FileDialog.get_path, +# optional hover index, config paths) that are guarded at runtime; same typing-gap +# class as above. +reportCallIssue = "warning" + +[tool.ruff] +# Pragmatic line length: the existing GUI code uses long single-line widget setup. +line-length = 200 +src = ["src", "tests"] + +[tool.ruff.lint] +# Floor ruleset: real correctness bugs (pyflakes F) and syntax errors (E9). +# Import ordering (I) is intentionally excluded: auto-sorting the GTK modules can +# move `from gi.repository import ...` above the required `gi.require_version()` +# calls and break startup. Style debt (E701 multi-statement lines, complexity) is +# advisory, not gated, to avoid a high-risk mass reformat of untested GUI code. +select = ["F", "E9"] + +[tool.ruff.lint.mccabe] +max-complexity = 10 diff --git a/src/big_remote_play/__init__.py b/src/big_remote_play/__init__.py new file mode 100644 index 0000000..2f78971 --- /dev/null +++ b/src/big_remote_play/__init__.py @@ -0,0 +1,3 @@ +"""Big Remote Play — integrated remote cooperative gaming.""" + +__version__ = "2.0.0" diff --git a/src/big_remote_play/__main__.py b/src/big_remote_play/__main__.py new file mode 100644 index 0000000..a269bd4 --- /dev/null +++ b/src/big_remote_play/__main__.py @@ -0,0 +1,8 @@ +"""Entry point for `python -m big_remote_play`.""" + +import sys + +from big_remote_play.app import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/usr/share/big-remote-play/main.py b/src/big_remote_play/app.py similarity index 89% rename from usr/share/big-remote-play/main.py rename to src/big_remote_play/app.py index bcbfb88..937376d 100644 --- a/usr/share/big-remote-play/main.py +++ b/src/big_remote_play/app.py @@ -1,18 +1,16 @@ -import sys, os, gi, locale, gettext +import sys, os, gi gi.require_version('Gtk', '4.0') gi.require_version('Adw', '1') -from gi.repository import Gtk, Adw, Gio, GLib, Gdk -from pathlib import Path -from ui.main_window import MainWindow -from utils.config import Config -from utils.logger import Logger +from gi.repository import Gtk, Adw, Gio, Gdk, GLib +from big_remote_play.ui.main_window import MainWindow +from big_remote_play.utils.config import Config +from big_remote_play.utils.logger import Logger +from big_remote_play import paths -from utils.i18n import _ +from big_remote_play.utils.i18n import _ -BASE_DIR = os.path.dirname(os.path.abspath(__file__)) -sys.path.insert(0, BASE_DIR) -ICONS_DIR = os.path.join(BASE_DIR, "icons") -IMG_DIR = os.path.join(BASE_DIR, "img") +ICONS_DIR = str(paths.ICONS_DIR) +IMG_DIR = str(paths.IMG_DIR) class BigRemotePlayApp(Adw.Application): def __init__(self): @@ -52,7 +50,7 @@ def setup_icon(self): self.logger.info(_("Icons and images paths added")) def load_custom_css(self): - cp = Gtk.CssProvider(); cp_path = Path(__file__).parent / 'ui' / 'style.css' + cp = Gtk.CssProvider(); cp_path = paths.STYLE_CSS if cp_path.exists(): cp.load_from_path(str(cp_path)); Gtk.StyleContext.add_provider_for_display(self.window.get_display() if self.window else Gdk.Display.get_default(), cp, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) def show_about(self, *args): @@ -82,7 +80,7 @@ def show_about(self, *args): about.present() def show_preferences(self, *args, tab=None): - from ui.preferences import PreferencesWindow + from big_remote_play.ui.preferences import PreferencesWindow pref_win = PreferencesWindow(transient_for=self.window, config=self.config, initial_tab=tab) # Reload GuestView settings when preferences close @@ -101,12 +99,15 @@ def on_close(*_): def do_shutdown(self): try: Adw.Application.do_shutdown(self) - except: pass + except Exception: pass os._exit(0) def main(): import signal signal.signal(signal.SIGINT, signal.SIG_DFL) + # Without this the AT-SPI/process name is "__main__.py" (from `python -m`). + GLib.set_prgname('big-remote-play') + GLib.set_application_name('Big Remote Play') return BigRemotePlayApp().run(sys.argv) if __name__ == '__main__': diff --git a/usr/share/big-remote-play/guest/__init__.py b/src/big_remote_play/guest/__init__.py similarity index 100% rename from usr/share/big-remote-play/guest/__init__.py rename to src/big_remote_play/guest/__init__.py diff --git a/usr/share/big-remote-play/guest/moonlight_client.py b/src/big_remote_play/guest/moonlight_client.py similarity index 96% rename from usr/share/big-remote-play/guest/moonlight_client.py rename to src/big_remote_play/guest/moonlight_client.py index 4fa64e6..60cc654 100644 --- a/usr/share/big-remote-play/guest/moonlight_client.py +++ b/src/big_remote_play/guest/moonlight_client.py @@ -17,7 +17,7 @@ def _prepare_ip(self, ip): if ':' in clean_ip and '%' not in clean_ip and clean_ip.startswith('fe80'): try: # Get interface with default route - route = subprocess.check_output(['ip', '-6', 'route', 'show', 'default'], text=True).split() + route = subprocess.check_output(['ip', '-6', 'route', 'show', 'default'], text=True, timeout=5).split() if 'dev' in route: dev_idx = route.index('dev') + 1 if dev_idx < len(route): @@ -27,13 +27,13 @@ def _prepare_ip(self, ip): # Dumb fallback: get first UP interface that is not lo try: import json - out = subprocess.check_output(['ip', '-j', 'addr'], text=True) + out = subprocess.check_output(['ip', '-j', 'addr'], text=True, timeout=5) for i in json.loads(out): if i['ifname'] != 'lo' and 'UP' in i['flags']: clean_ip = f"{clean_ip}%{i['ifname']}" break - except: pass - except: pass + except Exception: pass + except Exception: pass return clean_ip @@ -91,7 +91,7 @@ def disconnect(self): try: if self.process: self.process.terminate(); self.process.wait(timeout=5) self.process = None; self.connected_host = None; return True - except: + except Exception: if self.process: self.process.kill(); self.process = None; self.connected_host = None return False @@ -152,12 +152,12 @@ def list_apps(self, host_ip): if ':' in target_ip: # 2. Flush neighbor cache for the specific target interface to avoid stale routes try: subprocess.run(['ip', '-6', 'neigh', 'flush', 'all'], capture_output=True, timeout=1) - except: pass + except Exception: pass # 3. Ping all-nodes multicast briefly to populate neighbor cache try: subprocess.run(['ping', '-6', '-c', '1', '-W', '1', 'ff02::1%lo'], capture_output=True, timeout=1) - except: pass - except: pass + except Exception: pass + except Exception: pass try: target_ip = self._prepare_ip(host_ip) diff --git a/usr/share/big-remote-play/host/__init__.py b/src/big_remote_play/host/__init__.py similarity index 100% rename from usr/share/big-remote-play/host/__init__.py rename to src/big_remote_play/host/__init__.py diff --git a/src/big_remote_play/host/sunshine_manager.py b/src/big_remote_play/host/sunshine_manager.py new file mode 100644 index 0000000..7e41459 --- /dev/null +++ b/src/big_remote_play/host/sunshine_manager.py @@ -0,0 +1,579 @@ +import logging +import subprocess, signal, os, shutil +import base64 +import hashlib +import http.client +import json +import ssl +import urllib.parse +from pathlib import Path +from big_remote_play.utils.i18n import _ +from big_remote_play.utils.secure_io import secure_write_text + +_log = logging.getLogger("big-remoteplay") + +# Sunshine config web server. 127.0.0.1 avoids IPv6 (::1) quirks when Sunshine +# binds 0.0.0.0. TLS uses a self-signed cert verified via trust-on-first-use +# fingerprint pinning (see _api_request), not a CA chain. +API_HOST = "127.0.0.1" +API_PORT = 47990 + + +def _cert_fingerprint(cert_der: bytes) -> str: + """SHA-256 hex of a DER-encoded certificate.""" + return hashlib.sha256(cert_der).hexdigest() + + +class SunshineHost: + def __init__(self, cdir: Path = None): + self.config_dir = cdir or (Path.home() / ".config" / "big-remoteplay" / "sunshine") + self.config_dir.mkdir(parents=True, exist_ok=True) + # TOFU store for Sunshine's self-signed API certificate fingerprint. + self.cert_fp_file = self.config_dir / "sunshine_cert.sha256" + self.process = None + self.pid = None + + def start(self, **kwargs): + if self.is_running(): + return True, "Already running" + + sc = shutil.which("sunshine") + if not sc: + return False, "Sunshine executable not found" + try: + config_file = self.config_dir / "sunshine.conf" + # Prepare environment + env = os.environ.copy() + if "DISPLAY" not in env: + env["DISPLAY"] = ":0" + + if "XAUTHORITY" not in env: + home = os.path.expanduser("~") + xauth = os.path.join(home, ".Xauthority") + if os.path.exists(xauth): + env["XAUTHORITY"] = xauth + + if "XDG_RUNTIME_DIR" not in env: + uid = os.getuid() + runtime_dir = f"/run/user/{uid}" + if os.path.exists(runtime_dir): + env["XDG_RUNTIME_DIR"] = runtime_dir + + # Pass WAYLAND_DISPLAY if exists + if "WAYLAND_DISPLAY" in os.environ: + env["WAYLAND_DISPLAY"] = os.environ["WAYLAND_DISPLAY"] + + cmd = [sc, str(config_file)] + + # Start process redirecting logs to file + log_path = self.config_dir / "sunshine.log" + self.log_file = open(log_path, "a") + + self.process = subprocess.Popen( + cmd, + text=True, + stdout=self.log_file, + stderr=subprocess.STDOUT, + env=env, + cwd=str(self.config_dir), # Force CWD for local configs + start_new_session=True, # Create new group ID + ) + + self.pid = self.process.pid + + # Check if process died immediately (e.g. library error) + try: + # Wait a bit to see if startup fails + exit_code = self.process.wait(timeout=2.0) + + # If reached here, process ended (failed) + self.log_file.flush() + # Try to read the error from the log + error_detail = "" + try: + with open(log_path, "r") as f: + lines = f.readlines() + if lines: + # Look for shared library errors in the last 10 lines + for line in lines[-10:]: + if "error while loading shared libraries" in line or "symbol lookup error" in line: + error_detail = line.strip() + break + except Exception: + pass + + self.log_file.write(_("Sunshine failed to start (Exit code {}).\n").format(exit_code)) + if error_detail: + _log.error(_("Sunshine failed to start: {}").format(error_detail)) + else: + _log.error(_("Sunshine failed to start (Exit code {}). Check logs.").format(exit_code)) + + self.process = None + self.pid = None + return False, error_detail if error_detail else f"Exit code {exit_code}" + + except subprocess.TimeoutExpired: + # Process continues running after timeout, success! + pass + + # Save PID + pid_file = self.config_dir / "sunshine.pid" + with open(pid_file, "w") as f: + f.write(str(self.pid)) + + _log.info(_("Sunshine started (PID: {})").format(self.pid)) + return True, None + + except Exception as e: + _log.error(_("Error starting Sunshine: {}").format(e)) + return False, str(e) # Return tuple (success, error_message) + + def stop(self) -> bool: + """Stops Sunshine server""" + if not self.is_running(): + _log.info(_("Sunshine is not running")) + return False + + try: + if self.process: + try: + pgid = os.getpgid(self.process.pid) + os.killpg(pgid, signal.SIGTERM) + try: + self.process.wait(timeout=2) + except subprocess.TimeoutExpired: + os.killpg(pgid, signal.SIGKILL) + except Exception: + try: + self.process.terminate() + except Exception: + pass + else: + pid_file = self.config_dir / "sunshine.pid" + if pid_file.exists(): + try: + with open(pid_file, "r") as f: + pid = int(f.read().strip()) + os.kill(pid, signal.SIGTERM) + except Exception: + pass + + # Fallback for orphan processes + subprocess.run(["pkill", "sunshine"], stderr=subprocess.DEVNULL, timeout=10) + + # Close log + if hasattr(self, "log_file"): + try: + self.log_file.close() + except Exception: + pass + del self.log_file + + pid_file = self.config_dir / "sunshine.pid" + if pid_file.exists(): + pid_file.unlink() + + self.process = None + self.pid = None + return True + except Exception: + subprocess.run(["pkill", "-9", "sunshine"], stderr=subprocess.DEVNULL, timeout=10) + return False + + def restart(self) -> bool: + """Restarts the server""" + self.stop() + return self.start() + + def is_running(self) -> bool: + """Checks if Sunshine is running""" + # Check process directly + if self.process and self.process.poll() is None: + return True + + # Check PID file + pid_file = self.config_dir / "sunshine.pid" + if pid_file.exists(): + try: + with open(pid_file, "r") as f: + pid = int(f.read().strip()) + + # Check if process exists + os.kill(pid, 0) + return True + + except (OSError, ValueError): + # Process does not exist, clear PID file + pid_file.unlink() + return False + + # Check via pgrep + try: + result = subprocess.run(["pgrep", "-x", "sunshine"], capture_output=True, timeout=5) + return result.returncode == 0 + except Exception: + return False + + def get_status(self) -> dict: + """Gets server status""" + return { + "running": self.is_running(), + "pid": self.pid, + "config_dir": str(self.config_dir), + } + + def update_apps(self, apps_list: list) -> bool: + """ + Updates application list (apps.json) + + Args: + apps_list: List of dictionaries describing apps + Ex: [{'name': 'Steam', 'cmd': 'steam', ...}] + """ + try: + import json + + apps_file = self.config_dir / "apps.json" + + # Sunshine apps.json format + data = {"env": {"PATH": "$(PATH):$(HOME)/.local/bin"}, "apps": apps_list} + + with open(apps_file, "w") as f: + json.dump(data, f, indent=4) + + return True + except Exception as e: + _log.error(f"Error saving apps.json: {e}") + return False + + def configure(self, settings: dict) -> bool: + """ + Configures Sunshine + + Args: + settings: Dictionary with settings + """ + try: + config_file = self.config_dir / "sunshine.conf" + + # Load existing config + current_config = {} + if config_file.exists(): + try: + with open(config_file, "r") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + if "=" in line: + parts = line.split("=", 1) + if len(parts) == 2: + current_config[parts[0].strip()] = parts[1].strip() + except Exception as e: + _log.error(f"Error reading existing config: {e}") + + # Update with new settings + for k, v in settings.items(): + if v is None: + # Remove key if value is None + if k in current_config: + del current_config[k] + else: + current_config[k] = str(v) + + # Ensure pointing to apps.json + if "apps_file" not in current_config: + current_config["apps_file"] = "apps.json" + + # Save merged config + with open(config_file, "w") as f: + for key, value in current_config.items(): + f.write(f"{key} = {value}\n") + + return True + + except Exception as e: + _log.error(f"Error configuring Sunshine: {e}") + return False + + def _trust_fingerprint(self, fingerprint: str) -> bool: + """Trust-on-first-use check for Sunshine's self-signed API cert. + + First contact pins the fingerprint (0600 file); later connections must + match exactly. A mismatch means the cert changed (Sunshine reinstall) or + a local process is impersonating the API: refuse rather than trust blindly. + """ + try: + if self.cert_fp_file.exists(): + return self.cert_fp_file.read_text().strip() == fingerprint + secure_write_text(str(self.cert_fp_file), fingerprint) + return True + except Exception as exc: + _log.error(_("Certificate trust error: {}").format(exc)) + return False + + def _api_request(self, method: str, path: str, payload: dict = None, auth: tuple[str, str] | None = None, timeout: float = 5.0) -> tuple[int, bytes]: + """Calls the Sunshine config API over TLS with TOFU cert pinning. + + Returns (status_code, body_bytes). status 0 means the connection failed + or the certificate fingerprint did not match the pinned value. + """ + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.check_hostname = False + # Self-signed cert: skip CA validation; we verify by pinned fingerprint. + ctx.verify_mode = ssl.CERT_NONE + try: + # Some Sunshine builds negotiate legacy TLS renegotiation. + ctx.options |= 0x4 # ssl.OP_LEGACY_SERVER_CONNECT + except Exception: + pass + + conn = http.client.HTTPSConnection(API_HOST, API_PORT, context=ctx, timeout=timeout) + try: + conn.connect() + cert_der = conn.sock.getpeercert(binary_form=True) + if not cert_der or not self._trust_fingerprint(_cert_fingerprint(cert_der)): + return 0, b"" + + headers = {"Content-Type": "application/json"} + if auth: + username, password = auth + token = base64.b64encode(f"{username}:{password}".encode()).decode() + headers["Authorization"] = f"Basic {token}" + + body = json.dumps(payload).encode("utf-8") if payload is not None else None + conn.request(method, path, body=body, headers=headers) + response = conn.getresponse() + return response.status, response.read() + except Exception as exc: + _log.error(_("Sunshine API request failed: {}").format(exc)) + return 0, b"" + finally: + conn.close() + + def send_pin(self, pin: str, name: str | None = None, auth: tuple[str, str] | None = None) -> tuple[bool, str]: + """Sends a pairing PIN to Sunshine (POST /api/pin).""" + payload = {"pin": pin} + if name: + payload["name"] = name + + status, data = self._api_request("POST", "/api/pin", payload, auth) + if status == 0: + return False, _("Connection Error: Sunshine unreachable or certificate mismatch") + if status == 200: + try: + accepted = json.loads(data).get("status", True) + except Exception: + accepted = True + return (True, _("PIN sent successfully")) if accepted else (False, _("Sunshine rejected the PIN")) + if status == 401: + return False, _("Authentication Failed. Configure a user in Sunshine.") + # 307 here means no admin user exists yet; the caller offers to create one. + return False, _("API Error: {}").format(status) + + def set_credentials(self, new_username: str, new_password: str, current: tuple[str, str] | None = None) -> tuple[bool, str]: + """Sets or changes the Sunshine admin credentials (POST /api/password). + + First-run create: leave current empty. Password change: pass the existing + (username, password) as current; Sunshine requires it to authorize and + authenticate the change. + """ + current_user, current_password = current if current else ("", "") + payload = { + "currentUsername": current_user, + "currentPassword": current_password, + "newUsername": new_username, + "newPassword": new_password, + "confirmNewPassword": new_password, + } + # When current credentials exist, authenticate the request with them. + auth = current if current else None + status, data = self._api_request("POST", "/api/password", payload, auth) + if status == 0: + return False, _("Connection Error: Sunshine unreachable or certificate mismatch") + if status == 401: + return False, _("Authentication Failed. Check the current password.") + if status == 200: + try: + if not json.loads(data).get("status", True): + return False, _("Sunshine rejected the credentials") + except Exception: + pass + return True, _("Credentials updated successfully") + return False, _("API Error: {}").format(status) + + def create_user(self, username: str, password: str) -> tuple[bool, str]: + """Creates the Sunshine admin user (first-run, no existing credentials).""" + return self.set_credentials(username, password) + + def reset_credentials(self, new_username: str, new_password: str) -> tuple[bool, str]: + """Resets admin credentials WITHOUT the current password (`sunshine --creds`). + + For a lost/forgotten password: this writes Sunshine's credentials file + directly via the CLI, bypassing the API auth. Restart Sunshine afterwards + for a running instance to pick up the new credentials. + """ + if not new_username or not new_password: + return False, _("Username and password cannot be empty") + sc = shutil.which("sunshine") + if not sc: + return False, _("Sunshine executable not found") + env = os.environ.copy() + try: + # argv array, no shell; credentials are not logged. + res = subprocess.run([sc, "--creds", new_username, new_password], capture_output=True, text=True, env=env, timeout=15) + if res.returncode == 0: + return True, _("Credentials reset successfully") + detail = (res.stderr or res.stdout or "").strip() + return False, _("Reset failed: {}").format(detail[:200]) if detail else _("Reset failed") + except Exception as exc: + return False, _("Reset error: {}").format(exc) + + def list_clients(self, auth: tuple[str, str] | None = None) -> list: + """Lists paired devices (GET /api/clients/list). + + Each entry exposes name, uuid and enabled. Note: these are paired + clients, not live stream sessions (Sunshine exposes no session API). + """ + status, data = self._api_request("GET", "/api/clients/list", auth=auth) + if status != 200 or not data: + return [] + try: + obj = json.loads(data) + except Exception: + return [] + return obj.get("named_certs", []) if isinstance(obj, dict) else [] + + def unpair_client(self, uuid: str, auth: tuple[str, str] | None = None) -> bool: + """Removes a single paired device (POST /api/clients/unpair).""" + if not uuid: + return False + status, _data = self._api_request("POST", "/api/clients/unpair", {"uuid": uuid}, auth) + return status == 200 + + def unpair_all_clients(self, auth: tuple[str, str] | None = None) -> bool: + """Removes all paired devices (POST /api/clients/unpair-all).""" + status, _data = self._api_request("POST", "/api/clients/unpair-all", {}, auth) + return status == 200 + + def set_client_enabled(self, uuid: str, enabled: bool, auth: tuple[str, str] | None = None) -> bool: + """Enables or disables a paired device (POST /api/clients/update).""" + if not uuid: + return False + status, _data = self._api_request("POST", "/api/clients/update", {"uuid": uuid, "enabled": enabled}, auth) + return status == 200 + + def get_logs(self, auth: tuple[str, str] | None = None) -> str: + """Fetches the Sunshine log (GET /api/logs, text/plain).""" + status, data = self._api_request("GET", "/api/logs", auth=auth) + if status != 200 or not data: + return "" + return data.decode("utf-8", errors="replace") + + def close_app(self, auth: tuple[str, str] | None = None) -> bool: + """Closes the currently streaming app (POST /api/apps/close). + + Gentle stop: ends the app for the guest without root, unlike the + socket-level kill in drop_guest.sh which evicts a network address. + """ + status, _data = self._api_request("POST", "/api/apps/close", {}, auth) + return status == 200 + + def get_apps(self, auth: tuple[str, str] | None = None) -> list: + """Lists configured Sunshine apps (GET /api/apps).""" + status, data = self._api_request("GET", "/api/apps", auth=auth) + if status != 200 or not data: + return [] + try: + obj = json.loads(data) + except Exception: + return [] + return obj.get("apps", []) if isinstance(obj, dict) else [] + + def add_app(self, entry: dict, auth: tuple[str, str] | None = None) -> bool: + """Adds or replaces a Sunshine app (POST /api/apps). + + entry must follow the Sunshine app schema (name, cmd, image-path, + detached, prep-cmd, ...). index defaults to -1 (append); a real index + replaces that slot. Apps are re-sorted by name server-side. + """ + payload = dict(entry) + payload.setdefault("index", -1) + status, _data = self._api_request("POST", "/api/apps", payload, auth) + return status == 200 + + def delete_app(self, index: int, auth: tuple[str, str] | None = None) -> bool: + """Removes a Sunshine app by index (DELETE /api/apps/{index}).""" + if index is None or index < 0: + return False + status, _data = self._api_request("DELETE", f"/api/apps/{index}", auth=auth) + return status == 200 + + def upload_cover(self, key: str, url: str | None = None, data_b64: str | None = None, auth: tuple[str, str] | None = None) -> str: + """Uploads box art for an app (POST /api/covers/upload). + + Provide either a url (images.igdb.com only) or base64 image data. + Returns the saved cover path, or "" on failure. + """ + if not key or (not url and not data_b64): + return "" + payload = {"key": key} + if url: + payload["url"] = url + if data_b64: + payload["data"] = data_b64 + status, data = self._api_request("POST", "/api/covers/upload", payload, auth) + if status != 200 or not data: + return "" + try: + return json.loads(data).get("path", "") + except Exception: + return "" + + def get_config(self, auth: tuple[str, str] | None = None) -> dict: + """Fetches the canonical Sunshine config incl. defaults (GET /api/config). + + The response also carries status/platform/version metadata keys. + """ + status, data = self._api_request("GET", "/api/config", auth=auth) + if status != 200 or not data: + return {} + try: + obj = json.loads(data) + except Exception: + return {} + return obj if isinstance(obj, dict) else {} + + def save_config(self, settings: dict, auth: tuple[str, str] | None = None) -> bool: + """Updates Sunshine config while it runs (POST /api/config). + + Server skips null/empty values. Use only when Sunshine is up; the + pre-start path stays in configure() (file write). + """ + status, _data = self._api_request("POST", "/api/config", dict(settings), auth) + return status == 200 + + def restart_via_api(self, auth: tuple[str, str] | None = None) -> bool: + """Restarts Sunshine through the API (POST /api/restart). + + The process restarts and the connection commonly drops mid-request, so + a transport failure (status 0) is treated as a likely success here. + """ + status, _data = self._api_request("POST", "/api/restart", {}, auth) + return status in (200, 0) + + def browse(self, path: str, type_filter: str = "any", auth: tuple[str, str] | None = None) -> dict: + """Browses the host filesystem (GET /api/browse). + + type_filter: directory | executable | file | any. Returns + {path, parent, entries:[{name,type,path}]}, or {} on failure. + """ + query = urllib.parse.urlencode({"path": path or "", "type": type_filter}) + status, data = self._api_request("GET", f"/api/browse?{query}", auth=auth) + if status != 200 or not data: + return {} + try: + obj = json.loads(data) + except Exception: + return {} + return obj if isinstance(obj, dict) else {} diff --git a/src/big_remote_play/paths.py b/src/big_remote_play/paths.py new file mode 100644 index 0000000..e44243b --- /dev/null +++ b/src/big_remote_play/paths.py @@ -0,0 +1,64 @@ +"""Resource path resolution. + +Python code ships inside the wheel (site-packages); all data assets (icons, img, +scripts, ui/style.css) and gettext catalogs stay under /usr/share. This module is +the single source of truth for locating them, working in three layouts: + +1. Installed: code in site-packages, data in /usr/share/big-remote-play, catalogs + in /usr/share/locale. +2. Dev tree: run from the repo with PYTHONPATH=src; data in usr/share/... relative + to the repo root. +3. Override: BIG_REMOTE_PLAY_DATADIR / BIG_REMOTE_PLAY_LOCALEDIR for tests/relocation. +""" + +import os +from pathlib import Path + +_DATADIR_ENV = "BIG_REMOTE_PLAY_DATADIR" +_LOCALEDIR_ENV = "BIG_REMOTE_PLAY_LOCALEDIR" +_INSTALLED_DATADIR = Path("/usr/share/big-remote-play") +_INSTALLED_LOCALEDIR = Path("/usr/share/locale") + + +def _resolve_data_dir() -> Path: + override = os.environ.get(_DATADIR_ENV) + if override and Path(override).is_dir(): + return Path(override) + # Dev tree: /usr/share/big-remote-play next to /src. + for parent in Path(__file__).resolve().parents: + candidate = parent / "usr" / "share" / "big-remote-play" + if (candidate / "icons").is_dir(): + return candidate + return _INSTALLED_DATADIR + + +def _resolve_locale_dir() -> Path: + override = os.environ.get(_LOCALEDIR_ENV) + if override and Path(override).is_dir(): + return Path(override) + for parent in Path(__file__).resolve().parents: + candidate = parent / "usr" / "share" / "locale" + if candidate.is_dir(): + return candidate + return _INSTALLED_LOCALEDIR + + +DATA_DIR = _resolve_data_dir() +ICONS_DIR = DATA_DIR / "icons" +IMG_DIR = DATA_DIR / "img" +SCRIPTS_DIR = DATA_DIR / "scripts" +STYLE_CSS = DATA_DIR / "ui" / "style.css" +LOCALE_DIR = _resolve_locale_dir() + + +def script_path(name: str) -> str: + """Absolute path to a bundled script. + + Prefers the fixed installed location (/usr/share/big-remote-play/scripts) since + scripts are executed via pkexec and polkit expects stable system paths; falls + back to the resolved data dir when running from the dev tree. + """ + installed = _INSTALLED_DATADIR / "scripts" / name + if installed.exists(): + return str(installed) + return str(SCRIPTS_DIR / name) diff --git a/usr/share/big-remote-play/ui/__init__.py b/src/big_remote_play/ui/__init__.py similarity index 100% rename from usr/share/big-remote-play/ui/__init__.py rename to src/big_remote_play/ui/__init__.py diff --git a/usr/share/big-remote-play/ui/guest_view.py b/src/big_remote_play/ui/guest_view.py similarity index 92% rename from usr/share/big-remote-play/ui/guest_view.py rename to src/big_remote_play/ui/guest_view.py index 61ea531..34a769b 100644 --- a/usr/share/big-remote-play/ui/guest_view.py +++ b/src/big_remote_play/ui/guest_view.py @@ -5,13 +5,13 @@ gi.require_version('Adw', '1') from gi.repository import Gtk, Adw, GLib, Gdk -import subprocess, threading, os, time -from pathlib import Path -from utils.config import Config -from guest.moonlight_client import MoonlightClient -from utils.i18n import _ -from utils.icons import create_icon_widget -from utils.moonlight_config import MoonlightConfigManager +import threading, time +from big_remote_play.utils.config import Config +from big_remote_play.guest.moonlight_client import MoonlightClient +from big_remote_play.utils.i18n import _ +from big_remote_play.utils.icons import create_icon_widget +from big_remote_play.utils.widgets import create_helper_card +from big_remote_play.utils.moonlight_config import MoonlightConfigManager class GuestView(Gtk.Box): def __init__(self): @@ -21,7 +21,7 @@ def __init__(self): self.is_connected = False self.pin_dialog = None - from utils.logger import Logger + from big_remote_play.utils.logger import Logger self.config = Config() self.logger = None if self.config.get('verbose_logging', False): @@ -46,51 +46,45 @@ def run_detect(): threading.Thread(target=run_detect, daemon=True).start() def setup_ui(self): - clamp = Adw.Clamp(); clamp.set_maximum_size(800) + clamp = Adw.Clamp(); clamp.set_maximum_size(1040); clamp.set_valign(Gtk.Align.CENTER) for m in ['top', 'bottom', 'start', 'end']: getattr(clamp, f'set_margin_{m}')(24) - content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=24) - + content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=18) from .performance_monitor import PerformanceMonitor self.perf_monitor = PerformanceMonitor(); self.perf_monitor.set_visible(False) - - self.header = Adw.PreferencesGroup() - self.header.set_title(_('Connect to Server')) - self.header.set_description(_('Find and connect to the host using the options below.')) - # Custom Header Suffix with Help Button - suffix_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) - suffix_box.set_valign(Gtk.Align.CENTER) - - help_btn = Gtk.Button(icon_name="help-about-symbolic") - help_btn.add_css_class("flat") - help_btn.set_tooltip_text(_("Shortcuts & Instructions")) - help_btn.connect("clicked", lambda b: self.show_shortcuts_dialog()) - suffix_box.append(help_btn) - - suffix_box.append(create_icon_widget('network-workgroup-symbolic', size=24)) - self.header.set_header_suffix(suffix_box) - - content.append(self.header) content.append(self.perf_monitor) - + self.method_stack = Adw.ViewStack() - + page_dis = self.method_stack.add_titled(self.create_discover_page(), 'discover', _('Discover')) page_dis.set_icon_name('system-search-symbolic') - + page_man = self.method_stack.add_titled(self.create_manual_page(), 'manual', _('Manual')) page_man.set_icon_name('network-wired-symbolic') - + page_pin = self.method_stack.add_titled(self.create_pin_page(), 'pin', _('PIN Code')) page_pin.set_icon_name('dialog-password-symbolic') - - switcher = Adw.ViewSwitcher() + + # Segmented inline switcher (mockup 01): icon + label pill, not the wide bar. + switcher = Adw.InlineViewSwitcher() switcher.set_stack(self.method_stack) - switcher.set_policy(Adw.ViewSwitcherPolicy.WIDE) + switcher.set_display_mode(Adw.InlineViewSwitcherDisplayMode.BOTH) switcher.set_halign(Gtk.Align.CENTER) - + + # Title is carried by the window header; keep only the contextual help + # action here, right-aligned, with the segmented switcher centered. + help_btn = Gtk.Button(icon_name="help-about-symbolic") + help_btn.add_css_class("flat") + help_btn.set_tooltip_text(_("Shortcuts & Instructions")) + help_btn.update_property([Gtk.AccessibleProperty.LABEL], [_("Shortcuts & Instructions")]) + help_btn.connect("clicked", lambda b: self.show_shortcuts_dialog()) + + switcher_bar = Gtk.CenterBox() + switcher_bar.set_center_widget(switcher) + switcher_bar.set_end_widget(help_btn) + self.switcher_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) - self.switcher_box.append(switcher) + self.switcher_box.append(switcher_bar) self.switcher_box.append(self.method_stack) settings_group = Adw.PreferencesGroup(); settings_group.set_title(_('Client Settings')); settings_group.set_margin_top(12) @@ -143,6 +137,15 @@ def setup_ui(self): self.audio_row.set_active(True); settings_group.add(self.audio_row) self.hw_decode_row = Adw.SwitchRow(); self.hw_decode_row.set_title(_('Hardware Decoding')); self.hw_decode_row.set_subtitle(_('Use GPU for decoding')) self.hw_decode_row.set_active(True); settings_group.add(self.hw_decode_row) + + advanced_row = Adw.ActionRow(title=_('Advanced client settings'), + subtitle=_('Input, controller, codec and more')) + advanced_row.set_activatable(True) + advanced_row.add_prefix(create_icon_widget('preferences-system-symbolic', size=20)) + advanced_row.add_suffix(create_icon_widget('go-next-symbolic', size=16)) + advanced_row.connect('activated', self.open_advanced_client_settings) + settings_group.add(advanced_row) + content.append(self.switcher_box); content.append(settings_group) self.load_guest_settings(); self.connect_settings_signals(); clamp.set_child(content) scroll = Gtk.ScrolledWindow(); scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC); scroll.set_vexpand(True); scroll.set_child(clamp); self.append(scroll) @@ -159,7 +162,6 @@ def monitor_connection(self): # Update state if it was disconnected self.is_connected = True host_name = self.moonlight.connected_host if self.moonlight.connected_host else "Host" - self.header.set_description(_("Host connected: {}").format(host_name)) self.perf_monitor.set_connection_status(host_name, _("Active Session"), True) self.perf_monitor.set_visible(True) @@ -169,7 +171,6 @@ def monitor_connection(self): if self.is_connected: # Detected disconnection self.is_connected = False - self.header.set_description(_('Find and connect to the host using the options below.')) self.perf_monitor.set_connection_status("None", _("Disconnected"), False) self.perf_monitor.stop_monitoring() self.perf_monitor.set_visible(False) @@ -186,10 +187,7 @@ def update_ui_state(self): # switcher_box must remain visible so the "Stop" button is accessible if hasattr(self, 'switcher_box'): self.switcher_box.set_visible(True) if hasattr(self, 'apply_settings_btn'): self.apply_settings_btn.set_visible(c) - if hasattr(self, 'header'): - self.header.set_visible(True) - self.header.set_description(_('Host connected. Click Stop to disconnect.') if c else _('Find and connect to the host using the options below.')) - + # Update connection buttons state self._update_all_buttons_state() @@ -306,10 +304,31 @@ def create_discover_page(self): host_scroll.set_child(self.hosts_list) action.append(buttons_box); box.append(header); box.append(host_scroll); box.append(action) - return box + box.set_hexpand(True) + + # Side helper card: what to try if the host doesn't show up (mockup 01). + helper = create_helper_card( + _("If you can't find the host"), + 'dialog-question-symbolic', + [ + ('network-wireless-symbolic', _('Check your local network'), + _('Make sure the host and this device are on the same Wi-Fi or wired network.')), + ('network-wired-symbolic', _('Use the manual connection'), + _('Enter the host IP address or hostname and port to connect directly.')), + ('dialog-password-symbolic', _('Use the PIN code'), + _('Ask the host for the PIN code and connect quickly and securely.')), + ], + ) + helper.set_valign(Gtk.Align.START) + helper.set_size_request(280, -1) + + columns = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=18) + columns.append(box) + columns.append(helper) + return columns def discover_hosts(self): - from utils.network import NetworkDiscovery + from big_remote_play.utils.network import NetworkDiscovery self.first_radio_in_list = self.selected_host_card_data = None self._update_all_buttons_state() while row := self.hosts_list.get_row_at_index(0): self.hosts_list.remove(row) @@ -621,7 +640,7 @@ def on_pin_callback(pin): if is_local: # Attempt automation try: - from host.sunshine_manager import SunshineHost + from big_remote_play.host.sunshine_manager import SunshineHost from pathlib import Path sun = SunshineHost(Path.home() / '.config' / 'big-remoteplay' / 'sunshine') if sun.is_running(): @@ -709,7 +728,7 @@ def get_auto_resolution(self): if monitors.get_n_items() > 0: monitor = monitors.get_item(0) if monitor: r = monitor.get_geometry(); return f"{r.width}x{r.height}" - except: pass + except Exception: pass return "1920x1080" def connect_manual(self, ip, port, ipv6=False): @@ -726,7 +745,7 @@ def connect_pin(self, _widget): self.show_loading(True) # 1. Resolve PIN to IP via Utils - from utils.network import resolve_pin_to_ip + from big_remote_play.utils.network import resolve_pin_to_ip def run_resolve(): res = resolve_pin_to_ip(pin) @@ -905,7 +924,7 @@ def load_guest_settings(self): s = self.config.get('guest', {}) self.scale_row.set_active(s.get('scale_native', False)) self.audio_row.set_active(s.get('audio', True)) - except: pass + except Exception: pass def on_reset_clicked(self, _widget): dialog = Adw.MessageDialog(heading=_("Reset defaults?"), body=_("All client settings will be restored.")) dialog.set_transient_for(self.get_root()) @@ -931,6 +950,20 @@ def show_toast(self, m): if hasattr(w, 'show_toast'): w.show_toast(m) else: print(f"Toast: {m}") + def open_advanced_client_settings(self, _widget=None): + """Full Moonlight client tuning, opened from the Connect task itself.""" + from big_remote_play.ui.moonlight_preferences import MoonlightPreferencesPage + win = Adw.PreferencesWindow() + root = self.get_root() + if root: + win.set_transient_for(root) + win.set_modal(True) + win.set_title(_('Advanced client settings')) + win.add(MoonlightPreferencesPage()) + # Refresh the inline quick-settings after the advanced sheet closes. + win.connect('close-request', lambda w: (self.load_guest_settings(), False)[1]) + win.present() + def show_shortcuts_dialog(self): dialog = Adw.Window(transient_for=self.get_root()) dialog.set_modal(True) diff --git a/usr/share/big-remote-play/ui/host_view.py b/src/big_remote_play/ui/host_view.py similarity index 66% rename from usr/share/big-remote-play/ui/host_view.py rename to src/big_remote_play/ui/host_view.py index 4396e10..05a8ed9 100644 --- a/usr/share/big-remote-play/ui/host_view.py +++ b/src/big_remote_play/ui/host_view.py @@ -5,14 +5,17 @@ from gi.repository import Gtk, Gdk, Adw, GLib import subprocess, random, string, json, socket, os, time from pathlib import Path -from utils.game_detector import GameDetector - -from utils.config import Config -import subprocess, os, tempfile, threading -from gi.repository import GLib -from utils.i18n import _ -from utils.icons import create_icon_widget -from ui.sunshine_preferences import SunshineConfigManager +from big_remote_play.utils.game_detector import GameDetector + +from big_remote_play.utils.config import Config +import threading +from big_remote_play.utils.i18n import _ +from big_remote_play.utils.icons import create_icon_widget, set_icon +from big_remote_play.utils.widgets import MetricTile +from big_remote_play import paths +from big_remote_play.utils.secure_io import secure_write_text +from big_remote_play.utils.uri import open_uri, open_path +from big_remote_play.ui.sunshine_preferences import SunshineConfigManager class HostView(Gtk.Box): def __init__(self): self.loading_settings = True @@ -23,7 +26,7 @@ def __init__(self): self.pin_code = None self.private_audio_apps = set() - from host.sunshine_manager import SunshineHost + from big_remote_play.host.sunshine_manager import SunshineHost self.sunshine = SunshineHost(Path.home() / '.config' / 'big-remoteplay' / 'sunshine') if self.sunshine.is_running(): @@ -79,13 +82,13 @@ def detect_monitors(self): # Xrandr (Reinforcement for X11) try: cmd = "xrandr --listmonitors | tail -n +2 | awk '{print $NF}'" - res = subprocess.check_output(cmd, shell=True, text=True) + res = subprocess.check_output(cmd, shell=True, text=True, timeout=5) for n in res.splitlines(): n = n.strip() if n and n not in names: monitors.append((f"Display ({n})", n)) names.append(n) - except: pass + except Exception: pass # DRM (Reinforcement for KMS/DRM) try: @@ -96,56 +99,52 @@ def detect_monitors(self): if name not in names: monitors.append((f"DRM Display ({name})", name)) names.append(name) - except: pass + except Exception: pass return monitors def detect_gpus(self): gpus = [] try: - lspci = subprocess.check_output(['lspci'], text=True).lower() + lspci = subprocess.check_output(['lspci'], text=True, timeout=5).lower() if 'nvidia' in lspci: try: - subprocess.check_call(['nvidia-smi'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + subprocess.check_call(['nvidia-smi'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5) gpus.append({'label': 'NVENC (NVIDIA)', 'encoder': 'nvenc', 'adapter': 'auto'}) - except: pass + except Exception: pass if 'intel' in lspci: gpus.append({'label': 'VAAPI (Intel Quicksync)', 'encoder': 'vaapi', 'adapter': '/dev/dri/renderD128'}) - except: pass + except Exception: pass try: from pathlib import Path if Path('/dev/dri').exists(): for node in sorted(list(Path('/dev/dri').glob('renderD*'))): if not any(str(node) == g['adapter'] for g in gpus): gpus.append({'label': f"VAAPI (Adapter {node.name})", 'encoder': 'vaapi', 'adapter': str(node)}) - except: pass + except Exception: pass gpus.extend([{'label':'Vulkan (Exp)', 'encoder':'vulkan', 'adapter':'auto'}, {'label':'Software', 'encoder':'software', 'adapter':'auto'}]) return gpus def setup_ui(self): clamp = Adw.Clamp() - clamp.set_maximum_size(800) + clamp.set_maximum_size(1040) + clamp.set_valign(Gtk.Align.CENTER) for margin in ['top', 'bottom', 'start', 'end']: getattr(clamp, f'set_margin_{margin}')(24) - - content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=24) - + + content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=18) + self.loading_bar = Gtk.ProgressBar() self.loading_bar.add_css_class('osd') self.loading_bar.set_visible(False) content.append(self.loading_bar) - + from .performance_monitor import PerformanceMonitor self.perf_monitor = PerformanceMonitor(sunshine=self.sunshine) self.perf_monitor.set_visible(True) self.perf_monitor.set_connection_status("Localhost", _("Sunshine Offline"), False) - - self.header = Adw.PreferencesGroup() - self.header.set_header_suffix(create_icon_widget('network-server-symbolic', size=24)) - self.header.set_title(_('Host Server')) - self.header.set_description(_('Configure and share your game for friends to connect')) - - + + game_group = Adw.PreferencesGroup() game_group.set_title(_('Game Configuration')) @@ -185,6 +184,14 @@ def setup_ui(self): self.custom_cmd_entry = Adw.EntryRow() self.custom_cmd_entry.set_title(_('Command')) + browse_btn = Gtk.Button() + browse_btn.set_child(create_icon_widget("folder-open-symbolic", size=16)) + browse_btn.add_css_class("flat") + browse_btn.set_valign(Gtk.Align.CENTER) + browse_btn.set_tooltip_text(_("Browse host for executable")) + browse_btn.update_property([Gtk.AccessibleProperty.LABEL], [_("Browse host filesystem")]) + browse_btn.connect("clicked", lambda b: self.open_host_browse_dialog()) + self.custom_cmd_entry.add_suffix(browse_btn) self.custom_app_expander.add_row(self.custom_cmd_entry) game_group.add(self.custom_app_expander) @@ -365,52 +372,150 @@ def setup_ui(self): game_group.add(self.advanced_expander) - button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) - button_box.set_halign(Gtk.Align.CENTER) - button_box.set_margin_top(12) - button_box.set_margin_bottom(24) - + # Normal-sized header actions (live in the window header bar on the Server + # page; the title is dropped there in favour of these). + def _icon_label(icon_name, label_widget): + box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + box.set_halign(Gtk.Align.CENTER) + box.append(create_icon_widget(icon_name, size=14)) + box.append(label_widget) + return box + self.start_button = Gtk.Button() - self.start_button.add_css_class('pill') self.start_button.add_css_class('suggested-action') - self.start_button.set_size_request(200, 50) - - btn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) - btn_box.set_halign(Gtk.Align.CENTER) + sb = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + sb.set_halign(Gtk.Align.CENTER) self.start_btn_spinner = Gtk.Spinner() self.start_btn_spinner.set_visible(False) + self.start_btn_icon = create_icon_widget('media-playback-start-symbolic', size=14) self.start_btn_label = Gtk.Label(label=_('Start Server')) - btn_box.append(self.start_btn_spinner) - btn_box.append(self.start_btn_label) - self.start_button.set_child(btn_box) - + sb.append(self.start_btn_spinner) + sb.append(self.start_btn_icon) + sb.append(self.start_btn_label) + self.start_button.set_child(sb) self.start_button.connect('clicked', self.toggle_hosting) - - self.configure_button = Gtk.Button(label=_('Configure Sunshine')) - self.configure_button.add_css_class('pill') - self.configure_button.set_size_request(200, 50) + + self.configure_button = Gtk.Button() + self.configure_button.set_child(_icon_label('emblem-system-symbolic', Gtk.Label(label=_('Configure')))) + self.configure_button.set_tooltip_text(_('Configure Sunshine')) self.configure_button.connect('clicked', self.open_sunshine_config) - - button_box.append(self.start_button) - button_box.append(self.configure_button) - - self.pin_button = Gtk.Button(label=_('Enter PIN')) - self.pin_button.add_css_class('pill') - self.pin_button.add_css_class('success-action') - self.pin_button.set_size_request(180, -1) + + self.pin_button = Gtk.Button() + self.pin_button.set_child(_icon_label('network-wireless-symbolic', Gtk.Label(label=_('Generate PIN')))) + self.pin_button.set_tooltip_text(_('Generate a PIN for a guest')) self.pin_button.set_visible(False) self.pin_button.connect('clicked', self.open_pin_dialog) - button_box.append(self.pin_button) - + + # Standalone box reparented into the header bar by the main window. + self.header_action_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + self.header_action_box.add_css_class('header-actions') + for b in (self.start_button, self.configure_button, self.pin_button): + self.header_action_box.append(b) + self.create_summary_box() # View Switcher and Stack self.view_stack = Adw.ViewStack() self.view_stack.set_vexpand(True) - # 1. Information Page - info_page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) - info_page.append(self.summary_box) + # 1. Information Page — two columns (mockup 05): info + helper | management. + info_left = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + info_left.set_hexpand(True) + info_left.append(self.summary_box) + + manage_group = Adw.PreferencesGroup() + manage_group.set_title(_("Management")) + manage_group.add_css_class('compact-rows') + + devices_row = Adw.ActionRow(title=_("Paired Devices"), + subtitle=_("View, disable or remove paired clients")) + devices_row.set_activatable(True) + devices_row.add_prefix(create_icon_widget("network-workgroup-symbolic", size=20)) + devices_row.add_suffix(create_icon_widget("go-next-symbolic", size=16)) + devices_row.connect("activated", self.open_paired_devices_dialog) + manage_group.add(devices_row) + + logs_row = Adw.ActionRow(title=_("Sunshine Logs"), + subtitle=_("View the Sunshine server log")) + logs_row.set_activatable(True) + logs_row.add_prefix(create_icon_widget("text-x-generic-symbolic", size=20)) + logs_row.add_suffix(create_icon_widget("go-next-symbolic", size=16)) + logs_row.connect("activated", self.open_logs_dialog) + manage_group.add(logs_row) + + library_row = Adw.ActionRow(title=_("Game Library"), + subtitle=_("Manage games shown to guests in Moonlight")) + library_row.set_activatable(True) + library_row.add_prefix(create_icon_widget("applications-games-symbolic", size=20)) + library_row.add_suffix(create_icon_widget("go-next-symbolic", size=16)) + library_row.connect("activated", self.open_game_library_dialog) + manage_group.add(library_row) + + password_row = Adw.ActionRow(title=_("Server Password"), + subtitle=_("Change or reset the Sunshine login (if you forgot it)")) + password_row.set_activatable(True) + password_row.add_prefix(create_icon_widget("dialog-password-symbolic", size=20)) + password_row.add_suffix(create_icon_widget("go-next-symbolic", size=16)) + password_row.connect("activated", self.open_password_dialog) + manage_group.add(password_row) + + advanced_row = Adw.ActionRow(title=_("Advanced server settings"), + subtitle=_("Server tuning, codecs, network and library fix")) + advanced_row.set_activatable(True) + advanced_row.add_prefix(create_icon_widget("preferences-system-symbolic", size=20)) + advanced_row.add_suffix(create_icon_widget("go-next-symbolic", size=16)) + advanced_row.connect("activated", self.open_advanced_settings) + manage_group.add(advanced_row) + + # Getting-started tips, two side by side (no heading). + def _tip(icon_name, title, desc): + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + row.set_hexpand(True) + row.add_css_class('helper-row') + ic = create_icon_widget(icon_name, size=18) + ic.add_css_class('accent') + ic.set_valign(Gtk.Align.START) + ic.set_margin_top(2) + row.append(ic) + text = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + text.set_hexpand(True) + t = Gtk.Label(label=title) + t.add_css_class('caption-heading') + t.set_halign(Gtk.Align.START) + t.set_wrap(True) + text.append(t) + d = Gtk.Label(label=desc) + d.add_css_class('caption') + d.add_css_class('dim-label') + d.set_halign(Gtk.Align.START) + d.set_wrap(True) + d.set_max_width_chars(34) + text.append(d) + row.append(text) + return row + + tips = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=24) + tips.add_css_class('helper-card') + tips.set_homogeneous(True) + tips.append(_tip('dialog-password-symbolic', _('Share your PIN or address'), + _('Share your PIN code or the server address so friends can connect.'))) + tips.append(_tip('security-high-symbolic', _('Keep it secure'), + _('Keep your server and network secure. Use a VPN when sharing games.'))) + + info_left.set_valign(Gtk.Align.START) + info_right = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + info_right.set_hexpand(True) + info_right.set_valign(Gtk.Align.START) + info_right.append(manage_group) + + # Two info columns on top, the tips banner spanning the full width below. + info_columns = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=18) + info_columns.append(info_left) + info_columns.append(info_right) + + info_page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16) + info_page.append(info_columns) + info_page.append(tips) self.view_stack.add_titled_with_icon(info_page, "info", _("Information"), "dialog-information-symbolic") # 2. Configuration Page @@ -465,19 +570,83 @@ def setup_ui(self): } # Switcher setup - view_switcher = Adw.ViewSwitcher() + view_switcher = Adw.InlineViewSwitcher() view_switcher.set_stack(self.view_stack) - view_switcher.set_policy(Adw.ViewSwitcherPolicy.WIDE) + view_switcher.set_display_mode(Adw.InlineViewSwitcherDisplayMode.BOTH) view_switcher.set_halign(Gtk.Align.CENTER) view_switcher.set_margin_top(12) view_switcher.set_margin_bottom(12) - content.append(self.header) - content.append(self.perf_monitor) - content.append(button_box) + # Status card (mockup 05): left state block + right metric tiles. + # perf_monitor stays as a hidden data engine (its polling feeds the tiles). + self.perf_monitor.set_visible(False) + status_card = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=20) + status_card.add_css_class('info-card') + + # Left: circular icon + status text + subtitle + uptime chip. + left_block = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=14) + left_block.set_valign(Gtk.Align.CENTER) + circle = Gtk.Box(); circle.add_css_class('status-icon-circle') + circle.append(create_icon_widget('weather-clear-symbolic', size=26)) + circle.set_valign(Gtk.Align.CENTER) + left_block.append(circle) + + text_block = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) + text_block.set_valign(Gtk.Align.CENTER) + head = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + self.host_status_dot = create_icon_widget( + 'media-record-symbolic', size=12, css_class=['status-dot', 'status-offline']) + self.host_status_dot.set_valign(Gtk.Align.CENTER) + head.append(self.host_status_dot) + self.host_status_label = Gtk.Label(label=_('Sunshine offline')) + self.host_status_label.add_css_class('title-4') + self.host_status_label.set_halign(Gtk.Align.START) + head.append(self.host_status_label) + text_block.append(head) + self.host_status_subtitle = Gtk.Label(label=_('Start the server to stream.')) + self.host_status_subtitle.add_css_class('caption') + self.host_status_subtitle.add_css_class('dim-label') + self.host_status_subtitle.set_halign(Gtk.Align.START) + text_block.append(self.host_status_subtitle) + self.host_uptime_label = Gtk.Label(label='') + self.host_uptime_label.add_css_class('metric-chip') + self.host_uptime_label.add_css_class('caption') + self.host_uptime_label.set_halign(Gtk.Align.START) + self.host_uptime_label.set_visible(False) + text_block.append(self.host_uptime_label) + left_block.append(text_block) + status_card.append(left_block) + + # Right: three live metric tiles (hidden until hosting). + self.tile_lat = MetricTile('network-transmit-receive-symbolic', _('Latency'), + 'metric-orange', (1.0, 0.45, 0.0, 1.0)) + self.tile_fps = MetricTile('video-display-symbolic', _('FPS'), + 'metric-green', (0.13, 0.76, 0.42, 1.0)) + self.tile_bw = MetricTile('network-wireless-symbolic', _('Bandwidth'), + 'metric-blue', (0.21, 0.52, 0.89, 1.0)) + self.host_metrics_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=24) + self.host_metrics_box.set_halign(Gtk.Align.END) + self.host_metrics_box.set_hexpand(True) + for tile in (self.tile_lat, self.tile_fps, self.tile_bw): + tile.set_size_request(118, -1) + self.host_metrics_box.append(tile) + self.host_metrics_box.set_visible(False) + status_card.append(self.host_metrics_box) + + # Footer security reminder (mockup 05). + footer_note = Gtk.Label() + footer_note.set_markup(_( + 'Keep your server and network secure. ' + 'Use a VPN when sharing games.')) + footer_note.add_css_class('dim-label') + footer_note.set_halign(Gtk.Align.CENTER) + footer_note.set_margin_top(12) + + content.append(status_card) content.append(view_switcher) content.append(self.view_stack) - + content.append(footer_note) + clamp.set_child(content) scroll = Gtk.ScrolledWindow() scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) @@ -517,7 +686,7 @@ def _get_sunshine_creds(self): if user and password: return (user, password) - except: pass + except Exception: pass return None def _save_sunshine_creds(self, user, password): @@ -527,7 +696,7 @@ def _save_sunshine_creds(self, user, password): try: with open(conf_file, 'r') as f: lines = f.readlines() - except: pass + except Exception: pass new_lines = [] found_user = False @@ -578,9 +747,8 @@ def _save_sunshine_creds(self, user, password): final_lines.append(f"{k} = {v}\n") try: - conf_file.parent.mkdir(parents=True, exist_ok=True) - with open(conf_file, 'w') as f: - f.writelines(final_lines) + # sunshine.conf holds credentials: owner-only file/dir. + secure_write_text(str(conf_file), "".join(final_lines)) except Exception as e: print(f"Error saving Sunshine creds: {e}") @@ -625,10 +793,9 @@ def _ensure_sunshine_config(self): for k, v in required.items(): if k not in config_map: final_lines.append(f"{k} = {v}\n") - - with open(conf_file, 'w') as f: - f.writelines(final_lines) - + + secure_write_text(str(conf_file), "".join(final_lines)) + except Exception as e: print(f"Error ensuring sunshine config: {e}") @@ -648,7 +815,7 @@ def open_pin_dialog(self, _widget): ) dialog.set_transient_for(self.get_root()) - # Grupo de preferências para conter os campos + # Preferences group holding the fields grp = Adw.PreferencesGroup() pin_row = Adw.EntryRow(title=_("PIN")) @@ -701,7 +868,7 @@ def on_response(d, r): elif "Authentication Failed" in msg or "Falha de Autenticação" in msg or "401" in msg: self.show_error_dialog(_("Authentication Failed"), _("Invalid username or password.")) elif "307" in msg: - # Se retornar Redirect 307, significa que não tem usuário criado + # A 307 Redirect means no user has been created yet self.prompt_create_user(pin) else: self.show_error_dialog(_("PIN Error"), msg) @@ -721,9 +888,8 @@ def prompt_create_user(self, pin_retry): def on_resp(d, r): if r == "open": - import webbrowser - webbrowser.open("https://localhost:47990") - + open_uri(self, "https://localhost:47990") + dialog.connect("response", on_resp) dialog.present() @@ -812,8 +978,8 @@ def on_auth_resp(d, r): def create_summary_box(self): self.summary_box = Adw.PreferencesGroup() self.summary_box.set_title(_("Server Information")) - self.summary_box.set_header_suffix(create_icon_widget('preferences-other-symbolic', size=24)) - self.summary_box.set_visible(False); self.field_widgets = {} + self.summary_box.add_css_class('compact-rows') + self.summary_box.set_visible(True); self.field_widgets = {} for l, k, i, r in [('Host', 'hostname', 'computer-symbolic', True), ('IPv4', 'ipv4', 'network-wired-symbolic', False), ('IPv6', 'ipv6', 'network-wired-symbolic', False), ('IPv4 Global', 'ipv4_global', 'network-transmit-receive-symbolic', False), ('IPv6 Global', 'ipv6_global', 'network-transmit-receive-symbolic', False)]: self.create_masked_row(l, k, i, r) def on_audio_mode_changed(self, row, param): @@ -832,7 +998,7 @@ def on_audio_mode_changed(self, row, param): def load_audio_outputs(self): try: - from utils.audio import AudioManager + from big_remote_play.utils.audio import AudioManager if not hasattr(self, 'audio_manager'): self.audio_manager = AudioManager() @@ -881,19 +1047,15 @@ def on_configure_firewall_clicked(self, _widget): self.show_toast(_("Configuring firewall... (Password may be requested)")) try: - # Locate the script relative to this file - # src/ui/host_view.py -> src/ui -> src -> root -> scripts - script_path = Path(__file__).parent.parent / 'scripts' / 'configure_firewall.sh' - - if not script_path.exists(): - script_path = Path("/usr/share/big-remote-play/scripts/configure_firewall.sh") - - if not script_path.exists(): + # Resolve the bundled script (installed /usr/share path, dev fallback) + script_path = paths.script_path('configure_firewall.sh') + + if not os.path.exists(script_path): self.show_error_dialog(_("Error"), f"Script not found: {script_path}") return # Run with pkexec - cmd = ['pkexec', str(script_path)] + cmd = ['pkexec', script_path] def on_done(ok, out): if ok: self.show_toast(_("Success: {}").format(out.strip())) @@ -901,6 +1063,7 @@ def on_done(ok, out): def run(): try: + # No timeout: pkexec blocks on the polkit auth dialog (user-paced) res = subprocess.run(cmd, capture_output=True, text=True) GLib.idle_add(on_done, res.returncode == 0, res.stdout + res.stderr) except Exception as e: @@ -963,18 +1126,35 @@ def _perform_toggle_hosting(self): def sync_ui_state(self): if self.is_hosting: - self.header.set_description(_('Server Active - Guests can now connect')) self.perf_monitor.set_connection_status("Sunshine", _("Active - Waiting for Connections"), True) self.perf_monitor.start_monitoring() - + + # Reveal the live metric tiles; update the status subtitle. + if hasattr(self, 'host_metrics_box'): + self.host_metrics_box.set_visible(True) + self.host_status_subtitle.set_label(_('Ready to receive connections.')) + self._show_share_hint() + + # Status card header: active state + uptime baseline + 1s ticker. + if getattr(self, '_hosting_started_at', None) is None: + self._hosting_started_at = time.monotonic() + if hasattr(self, 'host_status_label'): + self.host_status_label.set_label(_('Sunshine active')) + self.host_status_dot.remove_css_class('status-offline') + self.host_status_dot.add_css_class('status-online') + self.host_uptime_label.set_visible(True) + self._tick_uptime() + if getattr(self, '_uptime_timer_id', None) is None: + self._uptime_timer_id = GLib.timeout_add_seconds(1, self._tick_uptime) + # Button State: Hosting -> Stop - self.start_btn_label.set_label(_('Stop')) + self.start_btn_label.set_label(_('Stop server')) + if hasattr(self, 'start_btn_icon'): set_icon(self.start_btn_icon, 'media-playback-stop-symbolic') self.start_button.remove_css_class('suggested-action') self.start_button.add_css_class('destructive-action') self.start_btn_spinner.set_visible(False) self.start_btn_spinner.stop() - - self.header.set_visible(True) + self.configure_button.set_sensitive(True) self.configure_button.add_css_class('suggested-action') for r in [self.game_mode_row, self.hardware_expander, self.streaming_expander, self.advanced_expander]: r.set_sensitive(False) @@ -989,10 +1169,23 @@ def sync_ui_state(self): self.pin_page.set_sensitive(True) self.pin_stack_page.set_icon_name("dialog-password-symbolic") else: - self.header.set_description(_('Configure and share your game for friends to connect')) self.perf_monitor.set_connection_status("Sunshine", _("Inactive"), False) self.perf_monitor.stop_monitoring() - self.header.set_visible(True) + + # Hide the metric tiles; the status block shows the offline prompt. + if hasattr(self, 'host_metrics_box'): + self.host_metrics_box.set_visible(False) + self.host_status_subtitle.set_label(_('Start the server to stream.')) + + self._hosting_started_at = None + if getattr(self, '_uptime_timer_id', None) is not None: + GLib.source_remove(self._uptime_timer_id) + self._uptime_timer_id = None + if hasattr(self, 'host_status_label'): + self.host_status_label.set_label(_('Sunshine offline')) + self.host_status_dot.remove_css_class('status-online') + self.host_status_dot.add_css_class('status-offline') + self.host_uptime_label.set_visible(False) if hasattr(self, 'pin_button'): self.pin_button.set_visible(False) if hasattr(self, 'summary_box'): @@ -1010,6 +1203,7 @@ def sync_ui_state(self): # Button State: Stopped -> Start self.start_btn_label.set_label(_('Start Server')) + if hasattr(self, 'start_btn_icon'): set_icon(self.start_btn_icon, 'media-playback-start-symbolic') self.start_button.remove_css_class('destructive-action') self.start_button.add_css_class('suggested-action') self.start_btn_spinner.set_visible(False) @@ -1021,7 +1215,7 @@ def sync_ui_state(self): def populate_summary_fields(self): import socket, threading - from utils.network import NetworkDiscovery + from big_remote_play.utils.network import NetworkDiscovery self.update_field('hostname', socket.gethostname()) if self.pin_code: self.update_field('pin', self.pin_code) ipv4, ipv6 = self.get_ip_addresses() @@ -1185,7 +1379,7 @@ def start_hosting(self, b=None): import time; time.sleep(1) self.pin_code = ''.join(random.choices(string.digits, k=6)) - from utils.network import NetworkDiscovery + from big_remote_play.utils.network import NetworkDiscovery self.stop_pin_listener = NetworkDiscovery().start_pin_listener(self.pin_code, socket.gethostname()) mode_idx = self.game_mode_row.get_selected() @@ -1354,7 +1548,7 @@ def start_hosting(self, b=None): self.sync_ui_state() # Revert state finally: self.loading_bar.set_visible(False) - self.start_button.set_sensitive(True) # Reabilitar botão + self.start_button.set_sensitive(True) # Re-enable button self.start_btn_spinner.stop() self.start_btn_spinner.set_visible(False) @@ -1446,7 +1640,7 @@ def _stop_game_direct(self): stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL ) print("Closing Steam Big Picture") - except: pass + except Exception: pass # Kill tracked processes for p in getattr(self, '_game_processes', []): @@ -1454,7 +1648,7 @@ def _stop_game_direct(self): if p.poll() is None: # Still running import signal os.killpg(os.getpgid(p.pid), signal.SIGTERM) - except: pass + except Exception: pass self._game_processes = [] self._game_launch_info = None @@ -1471,7 +1665,7 @@ def stop_hosting(self, b=None): if hasattr(self, 'stop_pin_listener') and self.stop_pin_listener: try: self.stop_pin_listener() - except: pass + except Exception: pass self.stop_pin_listener = None # Restore audio configuration @@ -1487,7 +1681,7 @@ def stop_hosting(self, b=None): print(f"Error stopping Sunshine: {e}") # Hard kill fallback - subprocess.run(['pkill', '-9', 'sunshine'], stderr=subprocess.DEVNULL) + subprocess.run(['pkill', '-9', 'sunshine'], stderr=subprocess.DEVNULL, timeout=10) self.is_hosting = False self.sync_ui_state() @@ -1497,6 +1691,56 @@ def stop_hosting(self, b=None): self.start_btn_spinner.set_visible(False) self.show_toast(_('Server stopped')) + def _show_share_hint(self): + """Once hosting, tell the host exactly what to give friends (LAN address).""" + def work(): + ipv4, _ipv6 = self.get_ip_addresses() + GLib.idle_add(self._set_share_hint, ipv4) + threading.Thread(target=work, daemon=True).start() + + def _set_share_hint(self, ipv4): + if not self.is_hosting or not hasattr(self, 'host_status_subtitle'): + return False + if ipv4 and ipv4 != "None": + # Plain next step: what to hand a friend so they can connect. + self.host_status_subtitle.set_label( + _('Friends connect to this PC at {} — or use a PIN code.').format(ipv4)) + return False + + def _tick_uptime(self): + """Refresh the status-card uptime chip ("Server active for HH:MM:SS").""" + started = getattr(self, '_hosting_started_at', None) + if started is None or not hasattr(self, 'host_uptime_label'): + self._uptime_timer_id = None + return False + elapsed = int(time.monotonic() - started) + hh, rem = divmod(elapsed, 3600) + mm, ss = divmod(rem, 60) + self.host_uptime_label.set_label( + _('Server active for {:02d}:{:02d}:{:02d}').format(hh, mm, ss)) + self._refresh_metric_tiles() + return True + + def _refresh_metric_tiles(self): + """Pull live values from the perf monitor into the status-card tiles.""" + chart = getattr(self.perf_monitor, 'chart', None) + if chart is None or not hasattr(self, 'tile_lat'): + return + history = list(chart._history) + if not history: + return + + def norm(values, ceiling): + top = max(1.0, ceiling) + return [v / top for v in values] + + self.tile_lat.update( + norm([p.latency for p in history], chart.max_latency), chart._cur_latency_text) + self.tile_fps.update( + norm([p.fps for p in history], chart.max_fps), chart._cur_fps_text) + self.tile_bw.update( + norm([p.bandwidth for p in history], chart.max_bandwidth), chart._cur_bw_text) + def update_status_info(self): sunshine_running = self.check_process_running('sunshine') @@ -1508,27 +1752,29 @@ def update_status_info(self): return True if not self.is_hosting: return True - + if hasattr(self, 'sunshine_val'): - self.sunshine_val.set_markup('On-line' if sunshine_running else 'Parado') + status_text = _("Online") if sunshine_running else _("Stopped") + color = "#2ec27e" if sunshine_running else "#e01b24" + self.sunshine_val.set_markup(f'{status_text}') ipv4, ipv6 = self.get_ip_addresses() self.update_field('ipv4', ipv4); self.update_field('ipv6', ipv6) return True def check_process_running(self, process_name): try: - subprocess.check_output(["pgrep", "-x", process_name]) + subprocess.check_output(["pgrep", "-x", process_name], timeout=5) return True - except: return False + except Exception: return False def get_ip_addresses(self): ipv4 = ipv6 = "None" try: with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: s.connect(("1.1.1.1", 80)); ipv4 = s.getsockname()[0] - except: pass + except Exception: pass try: - res = subprocess.run(['ip', '-j', 'addr'], capture_output=True, text=True) + res = subprocess.run(['ip', '-j', 'addr'], capture_output=True, text=True, timeout=5) if res.returncode == 0: for iface in json.loads(res.stdout): name = iface['ifname'] @@ -1546,7 +1792,7 @@ def get_ip_addresses(self): elif ipv6 == "None": # Fallback to link-local with scope ID ipv6 = f"{addr['local']}%{name}" - except: pass + except Exception: pass # No longer wrapping in brackets as per user feedback @@ -1572,12 +1818,10 @@ def on_response(d, r): if r == "logs": try: log_path = self.sunshine.config_dir / 'sunshine.log' - subprocess.Popen(['xdg-open', str(log_path)]) - except: pass + open_path(self, log_path) + except Exception: pass elif r == "fix": - app = self.get_root().get_application() - if hasattr(app, 'show_preferences'): - app.show_preferences(tab='sunshine') + self.open_advanced_settings() dialog.connect("response", on_response) dialog.present() @@ -1593,7 +1837,544 @@ def show_toast(self, message): else: print(f"Toast: {message}") def open_sunshine_config(self, button): - subprocess.Popen(['xdg-open', 'https://localhost:47990']) + # Gtk.show_uri (not xdg-open) so the browser is raised under Wayland. + open_uri(self, 'https://localhost:47990') + + # --- Paired devices (Sunshine clients API) --------------------------- + + def open_paired_devices_dialog(self, _widget): + dialog = Adw.MessageDialog( + heading=_("Paired Devices"), + body=_("Devices paired with Sunshine. Disable to block access without " + "re-pairing; remove to revoke (a new PIN will be required)."), + ) + dialog.set_transient_for(self.get_root()) + dialog.add_response("close", _("Close")) + dialog.add_response("unpair_all", _("Remove All")) + dialog.set_response_appearance("unpair_all", Adw.ResponseAppearance.DESTRUCTIVE) + + group = Adw.PreferencesGroup() + self._device_rows = [] + loading = Adw.ActionRow(title=_("Loading…")) + group.add(loading) + self._device_rows.append(loading) + + scroll = Gtk.ScrolledWindow() + scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + scroll.set_min_content_height(260) + scroll.set_child(group) + dialog.set_extra_child(scroll) + + def on_response(d, r): + if r == "unpair_all": + self._unpair_all_devices() + dialog.connect("response", on_response) + + self._devices_dialog_group = group + self._refresh_paired_devices() + dialog.present() + + def _refresh_paired_devices(self): + if getattr(self, "_devices_dialog_group", None) is None: + return + import threading + auth = self._get_sunshine_creds() + + def work(): + clients = self.sunshine.list_clients(auth=auth) + GLib.idle_add(self._populate_paired_devices, clients) + + threading.Thread(target=work, daemon=True).start() + + def _populate_paired_devices(self, clients): + group = getattr(self, "_devices_dialog_group", None) + if group is None: + return False + for row in getattr(self, "_device_rows", []): + group.remove(row) + self._device_rows = [] + + if not clients: + empty = Adw.ActionRow(title=_("No paired devices")) + group.add(empty) + self._device_rows.append(empty) + return False + + for client in clients: + uuid = client.get("uuid", "") + name = client.get("name") or _("Unknown device") + enabled = bool(client.get("enabled", True)) + row = Adw.ActionRow(title=name, subtitle=uuid) + + switch = Gtk.Switch() + switch.set_valign(Gtk.Align.CENTER) + switch.set_active(enabled) + switch.update_property([Gtk.AccessibleProperty.LABEL], [_("Device enabled")]) + switch.connect("notify::active", self._on_device_toggle, uuid) + row.add_suffix(switch) + + remove = Gtk.Button() + remove.set_child(create_icon_widget("user-trash-symbolic", size=16)) + remove.add_css_class("flat") + remove.set_valign(Gtk.Align.CENTER) + remove.set_tooltip_text(_("Remove device")) + remove.update_property([Gtk.AccessibleProperty.LABEL], [_("Remove device")]) + remove.connect("clicked", self._on_device_remove, uuid, name) + row.add_suffix(remove) + + group.add(row) + self._device_rows.append(row) + return False + + def _on_device_toggle(self, switch, _pspec, uuid): + enabled = switch.get_active() + import threading + auth = self._get_sunshine_creds() + + def work(): + if not self.sunshine.set_client_enabled(uuid, enabled, auth=auth): + GLib.idle_add(self.show_toast, _("Failed to update device")) + + threading.Thread(target=work, daemon=True).start() + + def _on_device_remove(self, _button, uuid, name): + confirm = Adw.MessageDialog( + heading=_("Remove Device"), + body=_("Remove “{}”? It will need to pair again with a new PIN.").format(name), + ) + confirm.set_transient_for(self.get_root()) + confirm.add_response("cancel", _("Cancel")) + confirm.add_response("remove", _("Remove")) + confirm.set_response_appearance("remove", Adw.ResponseAppearance.DESTRUCTIVE) + + def on_resp(d, r): + if r == "remove": + import threading + auth = self._get_sunshine_creds() + + def work(): + ok = self.sunshine.unpair_client(uuid, auth=auth) + GLib.idle_add(self._after_device_change, ok) + + threading.Thread(target=work, daemon=True).start() + + confirm.connect("response", on_resp) + confirm.present() + + def _unpair_all_devices(self): + import threading + auth = self._get_sunshine_creds() + + def work(): + ok = self.sunshine.unpair_all_clients(auth=auth) + GLib.idle_add(self.show_toast, + _("Devices updated") if ok else _("Operation failed")) + + threading.Thread(target=work, daemon=True).start() + + def _after_device_change(self, ok): + self.show_toast(_("Devices updated") if ok else _("Operation failed")) + self._refresh_paired_devices() + return False + + # --- Sunshine logs (logs API) ---------------------------------------- + + def open_logs_dialog(self, _widget): + dialog = Adw.MessageDialog(heading=_("Sunshine Logs"), body="") + dialog.set_transient_for(self.get_root()) + dialog.add_response("close", _("Close")) + + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + toolbar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + toolbar.set_halign(Gtk.Align.END) + + refresh = Gtk.Button() + refresh.set_child(create_icon_widget("view-refresh-symbolic", size=16)) + refresh.add_css_class("flat") + refresh.set_tooltip_text(_("Refresh")) + refresh.update_property([Gtk.AccessibleProperty.LABEL], [_("Refresh logs")]) + refresh.connect("clicked", lambda b: self._refresh_logs()) + toolbar.append(refresh) + + copy = Gtk.Button() + copy.set_child(create_icon_widget("edit-copy-symbolic", size=16)) + copy.add_css_class("flat") + copy.set_tooltip_text(_("Copy")) + copy.update_property([Gtk.AccessibleProperty.LABEL], [_("Copy logs")]) + copy.connect("clicked", lambda b: self._copy_logs()) + toolbar.append(copy) + + textview = Gtk.TextView() + textview.set_editable(False) + textview.set_monospace(True) + textview.set_cursor_visible(False) + self._logs_textview = textview + + scroll = Gtk.ScrolledWindow() + scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + scroll.set_min_content_height(360) + scroll.set_min_content_width(520) + scroll.set_vexpand(True) + scroll.set_child(textview) + + box.append(toolbar) + box.append(scroll) + dialog.set_extra_child(box) + self._refresh_logs() + dialog.present() + + def _refresh_logs(self): + tv = getattr(self, "_logs_textview", None) + if tv is None: + return + tv.get_buffer().set_text(_("Loading…")) + import threading + auth = self._get_sunshine_creds() + + def work(): + text = self.sunshine.get_logs(auth=auth) + GLib.idle_add(self._set_logs_text, text) + + threading.Thread(target=work, daemon=True).start() + + def _set_logs_text(self, text): + tv = getattr(self, "_logs_textview", None) + if tv is None: + return False + tv.get_buffer().set_text( + text or _("No logs available (Sunshine not running or no credentials).")) + return False + + def _copy_logs(self): + tv = getattr(self, "_logs_textview", None) + if tv is None: + return + buf = tv.get_buffer() + text = buf.get_text(buf.get_start_iter(), buf.get_end_iter(), False) + self.get_root().get_clipboard().set(text) + self.show_toast(_("Copied!")) + + # --- Game library (Sunshine apps API) -------------------------------- + + def open_game_library_dialog(self, _widget): + if not self.sunshine.is_running(): + self.show_error_dialog( + _("Server Not Running"), + _("Start the server first to manage the game library.")) + return + + dialog = Adw.MessageDialog( + heading=_("Game Library"), + body=_("Games offered to guests in Moonlight. Add your detected games " + "or remove entries."), + ) + dialog.set_transient_for(self.get_root()) + dialog.add_response("close", _("Close")) + + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + toolbar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + toolbar.set_halign(Gtk.Align.END) + add_btn = Gtk.Button(label=_("Add Detected Games")) + add_btn.add_css_class("suggested-action") + add_btn.connect("clicked", lambda b: self._add_detected_games()) + toolbar.append(add_btn) + + group = Adw.PreferencesGroup() + self._library_rows = [] + loading = Adw.ActionRow(title=_("Loading…")) + group.add(loading) + self._library_rows.append(loading) + + scroll = Gtk.ScrolledWindow() + scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + scroll.set_min_content_height(280) + scroll.set_min_content_width(420) + scroll.set_vexpand(True) + scroll.set_child(group) + + box.append(toolbar) + box.append(scroll) + dialog.set_extra_child(box) + + self._library_group = group + self._refresh_game_library() + dialog.present() + + def _refresh_game_library(self): + if getattr(self, "_library_group", None) is None: + return + import threading + auth = self._get_sunshine_creds() + + def work(): + apps = self.sunshine.get_apps(auth=auth) + GLib.idle_add(self._populate_game_library, apps) + + threading.Thread(target=work, daemon=True).start() + + def _populate_game_library(self, apps): + group = getattr(self, "_library_group", None) + if group is None: + return False + for row in getattr(self, "_library_rows", []): + group.remove(row) + self._library_rows = [] + + if not apps: + empty = Adw.ActionRow(title=_("No apps configured")) + group.add(empty) + self._library_rows.append(empty) + return False + + # Sunshine identifies apps by their position in the list (DELETE + # /api/apps/{index}); the entries themselves carry no index field. + for index, app in enumerate(apps): + name = app.get("name") or _("Unnamed") + row = Adw.ActionRow(title=name) + cmd = app.get("cmd") + if cmd: + row.set_subtitle(cmd) + # Desktop is the fallback target; do not let the user delete it. + if name != "Desktop": + remove = Gtk.Button() + remove.set_child(create_icon_widget("user-trash-symbolic", size=16)) + remove.add_css_class("flat") + remove.set_valign(Gtk.Align.CENTER) + remove.set_tooltip_text(_("Remove app")) + remove.update_property([Gtk.AccessibleProperty.LABEL], [_("Remove app")]) + remove.connect("clicked", self._on_library_remove, index) + row.add_suffix(remove) + group.add(row) + self._library_rows.append(row) + return False + + def _on_library_remove(self, _button, index): + import threading + auth = self._get_sunshine_creds() + + def work(): + ok = self.sunshine.delete_app(index, auth=auth) + GLib.idle_add(self._after_library_change, ok) + + threading.Thread(target=work, daemon=True).start() + + def _add_detected_games(self): + import threading + auth = self._get_sunshine_creds() + + def work(): + games = self.game_detector.detect_all() + existing = {a.get("name") for a in self.sunshine.get_apps(auth=auth)} + added = 0 + for game in games: + if game["name"] in existing: + continue + entry = { + "name": game["name"], + "cmd": game.get("cmd", ""), + "output": "", + "image-path": "", + "detached": [], + } + if self.sunshine.add_app(entry, auth=auth): + added += 1 + GLib.idle_add(self._after_library_add, added) + + threading.Thread(target=work, daemon=True).start() + + def _after_library_add(self, added): + self.show_toast(_("Added {} game(s)").format(added)) + self._refresh_game_library() + return False + + def _after_library_change(self, ok): + self.show_toast(_("Library updated") if ok else _("Operation failed")) + self._refresh_game_library() + return False + + # --- Host file browser (browse API) ---------------------------------- + + def open_host_browse_dialog(self): + if not self.sunshine.is_running(): + self.show_error_dialog( + _("Server Not Running"), + _("Start the server first to browse the host filesystem.")) + return + + dialog = Adw.MessageDialog(heading=_("Select Executable"), body="") + dialog.set_transient_for(self.get_root()) + dialog.add_response("close", _("Close")) + self._browse_dialog = dialog + + group = Adw.PreferencesGroup() + self._browse_group = group + self._browse_rows = [] + + scroll = Gtk.ScrolledWindow() + scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + scroll.set_min_content_height(360) + scroll.set_min_content_width(480) + scroll.set_child(group) + dialog.set_extra_child(scroll) + + import os + self._browse_load(os.path.expanduser("~")) + dialog.present() + + def _browse_load(self, path): + if getattr(self, "_browse_group", None) is None: + return + import threading + auth = self._get_sunshine_creds() + + def work(): + data = self.sunshine.browse(path, "any", auth=auth) + GLib.idle_add(self._browse_populate, data) + + threading.Thread(target=work, daemon=True).start() + + def _browse_populate(self, data): + group = getattr(self, "_browse_group", None) + if group is None: + return False + for row in getattr(self, "_browse_rows", []): + group.remove(row) + self._browse_rows = [] + + if not data: + empty = Adw.ActionRow(title=_("Cannot browse (no access or credentials)")) + group.add(empty) + self._browse_rows.append(empty) + return False + + group.set_title(data.get("path", "")) + parent = data.get("parent") + if parent: + up = Adw.ActionRow(title=_("Up one level")) + up.set_activatable(True) + up.add_prefix(create_icon_widget("go-up-symbolic", size=16)) + up.connect("activated", lambda r, p=parent: self._browse_load(p)) + group.add(up) + self._browse_rows.append(up) + + for entry in data.get("entries", []): + name = entry.get("name", "") + etype = entry.get("type", "") + epath = entry.get("path", "") + row = Adw.ActionRow(title=name) + row.set_activatable(True) + if etype == "directory": + row.add_prefix(create_icon_widget("folder-symbolic", size=16)) + row.connect("activated", lambda r, p=epath: self._browse_load(p)) + else: + row.add_prefix(create_icon_widget("application-x-executable-symbolic", size=16)) + row.connect("activated", lambda r, p=epath: self._browse_pick(p)) + group.add(row) + self._browse_rows.append(row) + return False + + def _browse_pick(self, path): + self.custom_cmd_entry.set_text(path) + if getattr(self, "_browse_dialog", None) is not None: + self._browse_dialog.close() + self.show_toast(_("Selected: {}").format(path)) + + # --- Server password (change / reset) -------------------------------- + + def open_advanced_settings(self, _widget=None): + """Full Sunshine server tuning + library fix, opened from the task itself.""" + from big_remote_play.ui.sunshine_preferences import SunshinePreferencesPage + win = Adw.PreferencesWindow() + root = self.get_root() + if root: + win.set_transient_for(root) + win.set_modal(True) + win.set_title(_("Advanced server settings")) + win.add(SunshinePreferencesPage(main_config=self.config)) + win.present() + + def open_password_dialog(self, _widget): + dialog = Adw.MessageDialog( + heading=_("Server Password"), + body=_("Set the username and password used to manage the Sunshine " + "server. If you forgot the current password, switch on " + "“I forgot the current password” to reset it."), + ) + dialog.set_transient_for(self.get_root()) + + creds = self._get_sunshine_creds() + default_user = creds[0] if creds else "sunshine" + + grp = Adw.PreferencesGroup() + forgot_row = Adw.SwitchRow(title=_("I forgot the current password")) + forgot_row.set_subtitle(_("Reset directly; the server will restart")) + cur_user_row = Adw.EntryRow(title=_("Current Username")) + cur_user_row.set_text(default_user) + cur_pass_row = Adw.PasswordEntryRow(title=_("Current Password")) + if creds: + cur_pass_row.set_text(creds[1]) + new_user_row = Adw.EntryRow(title=_("New Username")) + new_user_row.set_text(default_user) + new_pass_row = Adw.PasswordEntryRow(title=_("New Password")) + confirm_row = Adw.PasswordEntryRow(title=_("Confirm New Password")) + for r in (forgot_row, cur_user_row, cur_pass_row, + new_user_row, new_pass_row, confirm_row): + grp.add(r) + dialog.set_extra_child(grp) + + def on_forgot(switch, _pspec): + reset = switch.get_active() + cur_user_row.set_sensitive(not reset) + cur_pass_row.set_sensitive(not reset) + forgot_row.connect("notify::active", on_forgot) + + dialog.add_response("cancel", _("Cancel")) + dialog.add_response("save", _("Save")) + dialog.set_response_appearance("save", Adw.ResponseAppearance.SUGGESTED) + + def on_resp(d, response): + if response != "save": + return + new_user = new_user_row.get_text().strip() + new_pass = new_pass_row.get_text() + if not new_user or not new_pass: + self.show_toast(_("Username and password cannot be empty.")) + return + if new_pass != confirm_row.get_text(): + self.show_toast(_("New passwords do not match.")) + return + if forgot_row.get_active(): + self._reset_password_async(new_user, new_pass) + else: + current = (cur_user_row.get_text().strip(), cur_pass_row.get_text()) + self._change_password_async(new_user, new_pass, current) + + dialog.connect("response", on_resp) + dialog.present() + + def _change_password_async(self, new_user, new_password, current): + import threading + + def work(): + ok, message = self.sunshine.set_credentials(new_user, new_password, current=current) + if ok: + self._save_sunshine_creds(new_user, new_password) + GLib.idle_add(self.show_toast, message) + + threading.Thread(target=work, daemon=True).start() + + def _reset_password_async(self, new_user, new_password): + import threading + + def work(): + ok, message = self.sunshine.reset_credentials(new_user, new_password) + if ok: + self._save_sunshine_creds(new_user, new_password) + if self.sunshine.is_running(): + self.sunshine.restart() + GLib.idle_add(self.show_toast, message) + + threading.Thread(target=work, daemon=True).start() def on_game_mode_changed(self, row, param): idx = row.get_selected() @@ -1659,7 +2440,7 @@ def save_host_settings(self, *args): # Sync to Sunshine Config try: - from ui.sunshine_preferences import SunshineConfigManager + from big_remote_play.ui.sunshine_preferences import SunshineConfigManager scm = SunshineConfigManager() # Map Host Settings -> Sunshine Settings @@ -1711,7 +2492,7 @@ def load_settings(self): try: # Sync from Sunshine Config first try: - from ui.sunshine_preferences import SunshineConfigManager + from big_remote_play.ui.sunshine_preferences import SunshineConfigManager scm = SunshineConfigManager() # Update Host Config based on Sunshine Config (Source of Truth for these fields) @@ -1827,6 +2608,9 @@ def reset_to_defaults(self): def cleanup(self): if hasattr(self, 'perf_monitor'): self.perf_monitor.stop_monitoring() if hasattr(self, 'stop_pin_listener'): self.stop_pin_listener() + if getattr(self, '_uptime_timer_id', None) is not None: + GLib.source_remove(self._uptime_timer_id) + self._uptime_timer_id = None # Only cleanup audio if we are NOT hosting, because Sunshine depends on these sinks. # If we are hosting, the user expects the stream to continue working. diff --git a/usr/share/big-remote-play/ui/installer_window.py b/src/big_remote_play/ui/installer_window.py similarity index 97% rename from usr/share/big-remote-play/ui/installer_window.py rename to src/big_remote_play/ui/installer_window.py index d1ef9e2..62716ad 100644 --- a/usr/share/big-remote-play/ui/installer_window.py +++ b/src/big_remote_play/ui/installer_window.py @@ -1,10 +1,10 @@ import gi gi.require_version('Gtk', '4.0'); gi.require_version('Adw', '1') try: gi.require_version('Vte', '3.91'); from gi.repository import Vte; HAS_VTE = True -except: HAS_VTE = False +except Exception: HAS_VTE = False from gi.repository import Gtk, Adw, GLib, Gio import subprocess, os, shutil -from utils.i18n import _ +from big_remote_play.utils.i18n import _ class InstallerWindow(Adw.Window): """Dependency installation window""" @@ -81,7 +81,7 @@ def start_external_installation(self): for t in terms: if shutil.which(t[0]): try: subprocess.Popen(t); started = True; break - except: continue + except Exception: continue if started: self.status_label.set_text(_('Running in external terminal. Restart after completion.')); self.close_btn.set_label(_('Close')); self.close_btn.remove_css_class('destructive-action'); self.close_btn.add_css_class('suggested-action') else: self.status_label.set_text(_('Error: No terminal detected.')); self.textview.get_buffer().set_text(_("Execute manually:\n{}").format(cmd)) diff --git a/usr/share/big-remote-play/ui/main_window.py b/src/big_remote_play/ui/main_window.py similarity index 82% rename from usr/share/big-remote-play/ui/main_window.py rename to src/big_remote_play/ui/main_window.py index 2bbca9f..a883299 100644 --- a/usr/share/big-remote-play/ui/main_window.py +++ b/src/big_remote_play/ui/main_window.py @@ -7,10 +7,15 @@ from .host_view import HostView from .guest_view import GuestView from .installer_window import InstallerWindow -from utils.network import NetworkDiscovery -from utils.system_check import SystemCheck -from utils.icons import create_icon_widget, set_icon -from utils.i18n import _ +from big_remote_play.utils.network import NetworkDiscovery +from big_remote_play.utils.system_check import SystemCheck +from big_remote_play.utils.icons import create_icon_widget, create_logo_widget +from big_remote_play.utils.widgets import ( + create_steps_strip, + create_difficulty_pill, + create_comparison_table, +) +from big_remote_play.utils.i18n import _ import subprocess import shutil @@ -95,7 +100,7 @@ 'description': _('Home Page') }, 'host': { - 'name': _('Host Server'), + 'name': _('Server'), 'icon': 'network-server-symbolic', 'description': _('Share your games') }, @@ -158,7 +163,7 @@ def on_close_request(self, window): try: if hasattr(self, 'host_view'): self.host_view.cleanup() if hasattr(self, 'guest_view'): self.guest_view.cleanup() - except: pass + except Exception: pass return False def setup_ui(self): @@ -199,17 +204,11 @@ def _build_navigation_pages(self): return pages def setup_sidebar(self): - tb = Adw.ToolbarView(); hb = Adw.HeaderBar() - btn = Gtk.Button(); btn.set_child(create_icon_widget('big-remote-play')); btn.add_css_class('flat') - btn.connect('clicked', lambda b: self.get_application().activate_action('about', None)) - hb.pack_start(btn) - - title_lbl = Gtk.Label(label="Big Remote Play") - title_lbl.add_css_class("heading") - hb.pack_start(title_lbl) - - hb.set_title_widget(Adw.WindowTitle.new('', '')) - tb.add_top_bar(hb); main = Gtk.Box(orientation=Gtk.Orientation.VERTICAL); main.set_vexpand(True) + # No sidebar header bar — the navigation list starts at the very top. + # (About stays reachable from the content header menu.) + tb = Adw.ToolbarView() + main = Gtk.Box(orientation=Gtk.Orientation.VERTICAL); main.set_vexpand(True) + main.set_margin_top(8) scroll = Gtk.ScrolledWindow(); scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC); scroll.set_vexpand(True) self.nav_list = Gtk.ListBox(); self.nav_list.add_css_class('navigation-sidebar') self.nav_list.connect('row-selected', self.on_nav_selected) @@ -280,6 +279,8 @@ def create_nav_row(self, page_id: str, page_info: dict) -> Gtk.ListBoxRow: box.append(badge) row.set_child(box) + # ListBoxRow has no auto name from its custom child; expose one for AT-SPI. + row.update_property([Gtk.AccessibleProperty.LABEL], [page_info['name']]) return row def create_status_footer(self): @@ -307,14 +308,15 @@ def create_status_footer(self): self.status_card = card def add_status_row(container, label_text, dot_attr, lbl_attr, service_id): - row = Gtk.Box(spacing=10) + # Real button: exposes button role, keyboard activation and an action + # to AT-SPI (the old Gtk.Box + GestureClick exposed none). + row = Gtk.Button() row.add_css_class("info-row") + row.add_css_class("flat") row.service_id = service_id + row.connect("clicked", lambda b, sid=service_id: self.on_service_clicked(sid)) - click = Gtk.GestureClick() - click.connect("pressed", lambda g, n, x, y, sid=service_id: self.on_service_clicked(sid)) - row.add_controller(click) - + content = Gtk.Box(spacing=10) box_key = Gtk.Box(spacing=8) box_key.set_hexpand(True) dot = create_icon_widget('media-record-symbolic', size=10, css_class=['status-dot', 'status-offline']) @@ -323,12 +325,21 @@ def add_status_row(container, label_text, dot_attr, lbl_attr, service_id): lbl_key = Gtk.Label(label=label_text) lbl_key.add_css_class('info-key') box_key.append(lbl_key) - row.append(box_key) + content.append(box_key) lbl_status = Gtk.Label(label=_('Checking...')) lbl_status.add_css_class('info-value') lbl_status.set_halign(Gtk.Align.END) setattr(self, lbl_attr, lbl_status) - row.append(lbl_status) + content.append(lbl_status) + row.set_child(content) + # Plain-language explanation of each engine for new users. + meta = SERVICE_METADATA.get(service_id, {}) + desc = meta.get('description', '') + if desc: + row.set_tooltip_text(desc) + row.update_property( + [Gtk.AccessibleProperty.LABEL, Gtk.AccessibleProperty.DESCRIPTION], + [label_text, desc]) container.append(row) add_status_row(card, 'SUNSHINE', 'sunshine_dot', 'lbl_sunshine_status', 'sunshine') @@ -400,7 +411,12 @@ def setup_content(self): ct = Adw.ToolbarView(); hb = Adw.HeaderBar(); m = Gio.Menu() m.append(_('Preferences'), 'app.preferences'); m.append(_('About'), 'app.about') hb.pack_end(Gtk.MenuButton(icon_name='open-menu-symbolic', menu_model=m)) - hb.set_title_widget(Adw.WindowTitle.new('', '')) + + # Dynamic title reflecting the current section (filled in on_nav_selected). + self.content_headerbar = hb + self.content_title = Adw.WindowTitle.new(_('Home'), _('Play together, from anywhere')) + hb.set_title_widget(self.content_title) + ct.add_top_bar(hb); self.content_stack = Gtk.Stack() self.content_stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE) self.content_stack.set_transition_duration(200) @@ -435,6 +451,7 @@ def create_vpn_selector_page(self): clamp = Adw.Clamp() clamp.set_maximum_size(900) + clamp.set_valign(Gtk.Align.CENTER) for m in ['top', 'bottom', 'start', 'end']: getattr(clamp, f'set_margin_{m}')(32) @@ -461,26 +478,34 @@ def create_vpn_selector_page(self): box.append(cards_box) - # Comparison table + # Comparison table (real grid, mockup 04) compare_group = Adw.PreferencesGroup() compare_group.set_title(_('Quick Comparison')) compare_group.set_header_suffix(create_icon_widget('preferences-other-symbolic', size=18)) + # Brand names (column headers) are not translated. + compare_group.add(create_comparison_table( + ['Tailscale', 'ZeroTier', 'Headscale'], + [ + (_('Setup difficulty'), [_('Beginner'), _('Intermediate'), _('Advanced')]), + (_('Hosting model'), [_('Cloud (free)'), _('Cloud (free)'), _('Self-hosted')]), + (_('Best for'), [_('Personal use and friends'), + _('Flexible networks and teams'), + _('Corporate environments')]), + ], + )) box.append(compare_group) - comparisons = [ - ('Headscale', _('Self-hosted'), _('Full control, needs domain + Cloudflare'), _('Advanced'), '#3584e4'), - ('Tailscale', _('Cloud (free)'), _('Easiest setup, works out of the box'), _('Beginner'), '#26a269'), - ('ZeroTier', _('Cloud (free)'), _('Flexible, works through NAT'), _('Intermediate'), '#e5a50a'), - ] - for name, host_type, desc, level, color in comparisons: - row = Adw.ActionRow() - row.set_title(name) - row.set_subtitle(f'{host_type} · {desc} · {_("Level")}: {level}') - dot = create_icon_widget('media-record-symbolic', size=14) - dot.add_css_class('status-dot') - dot.add_css_class('status-online') - row.add_prefix(dot) - compare_group.add(row) + # "How it works" strip: Install → Authenticate → Play. + steps_group = Adw.PreferencesGroup() + steps_group.add(create_steps_strip([ + ('document-save-symbolic', _('Install'), + _('Install the chosen VPN provider on your device.')), + ('system-users-symbolic', _('Authenticate'), + _('Log in and authorize your devices on the VPN.')), + ('input-gaming-symbolic', _('Play'), + _('Connect to the server and play with your friends!')), + ])) + box.append(steps_group) clamp.set_child(box) scroll.set_child(clamp) @@ -490,9 +515,15 @@ def _create_vpn_card(self, provider_id: str, info: dict) -> Gtk.Button: """Create a VPN option card button.""" btn = Gtk.Button() btn.add_css_class('action-card') - btn.add_css_class('suggested-action') + # 'card-accent' keeps readable dark text (unlike 'suggested-action', + # which forces white text on the near-white tint). + btn.add_css_class('card-accent') btn.set_size_request(240, 220) btn.connect('clicked', lambda b, pid=provider_id: self._on_vpn_selected(pid)) + btn.update_property( + [Gtk.AccessibleProperty.LABEL, Gtk.AccessibleProperty.DESCRIPTION], + [info['name'], info['description']], + ) box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=14) box.set_valign(Gtk.Align.CENTER) @@ -519,6 +550,10 @@ def _create_vpn_card(self, provider_id: str, info: dict) -> Gtk.Button: desc_lbl.set_justify(Gtk.Justification.CENTER) box.append(desc_lbl) + # Difficulty pill (mockup 04): Tailscale=beginner ... Headscale=advanced. + levels = {'tailscale': 'beginner', 'zerotier': 'intermediate', 'headscale': 'advanced'} + box.append(create_difficulty_pill(levels.get(provider_id, 'intermediate'))) + # "Choose" label choose_lbl = Gtk.Label(label=_('Choose →')) choose_lbl.add_css_class('caption-heading') @@ -561,12 +596,12 @@ def _disconnect_vpn(self, vpn_id): self.show_toast(_("Disconnecting from {}...").format(VPN_PROVIDERS[vpn_id]['name'])) def run(): if vpn_id in ('headscale', 'tailscale'): - subprocess.run(["bigsudo", "tailscale", "logout"]) + subprocess.run(["bigsudo", "tailscale", "logout"], timeout=30) elif vpn_id == 'zerotier': # Local ZT disconnection is a bit trickier, # usually means leaving all networks or stopping the service # For simplicity, we can try to leave networks found in history or just stop service - subprocess.run(["bigsudo", "systemctl", "stop", "zerotier-one"]) + subprocess.run(["bigsudo", "systemctl", "stop", "zerotier-one"], timeout=30) GLib.idle_add(lambda: self.show_toast(_("{} disconnected").format(VPN_PROVIDERS[vpn_id]['name']))) threading.Thread(target=run, daemon=True).start() @@ -615,16 +650,18 @@ def reset_vpn_choice(self): def create_welcome_page(self): scroll = Gtk.ScrolledWindow(); scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC); scroll.set_vexpand(True) + # Centred vertically so short content distributes the slack top and bottom + # (no empty "footer" band at the bottom); compact so it still fits. main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) main_box.set_valign(Gtk.Align.CENTER) main_box.set_halign(Gtk.Align.CENTER) - main_box.set_margin_top(40) - main_box.set_margin_bottom(40) + main_box.set_margin_top(24) + main_box.set_margin_bottom(24) - logo_img = create_icon_widget('big-remote-play', size=128) + logo_img = create_logo_widget('big-remote-play', 72) logo_img.set_halign(Gtk.Align.CENTER) logo_img.set_valign(Gtk.Align.CENTER) - logo_img.set_margin_bottom(20) + logo_img.set_margin_bottom(8) main_box.append(logo_img) title = Gtk.Label(label='Big Remote Play') @@ -638,70 +675,100 @@ def create_welcome_page(self): subtitle.add_css_class('delay-1') main_box.append(subtitle) - spacer = Gtk.Box() - spacer.set_size_request(-1, 32) - main_box.append(spacer) + # Frame the decision so a first-time user knows what the two cards mean. + prompt = Gtk.Label(label=_('What do you want to do?')) + prompt.add_css_class('title-4') + prompt.add_css_class('animate-fade') + prompt.add_css_class('delay-1') + prompt.set_margin_top(20) + prompt.set_margin_bottom(12) + main_box.append(prompt) cards_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=24) cards_box.set_halign(Gtk.Align.CENTER) cards_box.add_css_class('animate-fade') cards_box.add_css_class('delay-2') + # Host is the primary action (this PC shares the game); mark it visually. self.host_card = self.create_action_card( - _('Host Server'), - _('Share your games with other players on the network'), + _('Share the game'), + _('Run the game on THIS PC and let friends connect to play together'), 'network-server-symbolic', - 'suggested-action', - lambda: self.navigate_to('host') + lambda: self.navigate_to('host'), + primary=True, + badge=_('Recommended'), + cta=_('Start sharing →'), ) self.guest_card = self.create_action_card( - _('Connect to Server'), - _('Connect to a game server on the network'), + _('Join a game'), + _("Connect to a friend's PC over the network and play remotely"), 'network-workgroup-symbolic', - 'suggested-action', - lambda: self.navigate_to('guest') + lambda: self.navigate_to('guest'), + cta=_('Connect now →'), ) cards_box.append(self.host_card) cards_box.append(self.guest_card) main_box.append(cards_box) - il = Gtk.Label() - il.set_markup(_('Based on Sunshine and Moonlight')) - il.add_css_class('dim-label') - il.add_css_class('animate-fade') - il.add_css_class('delay-3') - il.set_margin_top(32) - main_box.append(il) + # "How it works" strip, framed to plain-language 3 steps (mockup 02). + steps = create_steps_strip([ + ('network-server-symbolic', + _('Start or connect'), + _('Start a server on this PC or connect to an existing one.')), + ('system-users-symbolic', + _('Invite friends'), + _('Share the PIN code or the server address.')), + ('input-gaming-symbolic', + _('Play together'), + _('Everyone connects and plays remotely.')), + ]) + steps.set_size_request(760, -1) + steps.set_halign(Gtk.Align.CENTER) + steps.set_margin_top(20) + main_box.append(steps) scroll.set_child(main_box) return scroll - def create_action_card(self, title, desc, icon, cls, cb): + def create_action_card(self, title, desc, icon, cb, primary=False, badge=None, cta=None): btn = Gtk.Button() btn.add_css_class('action-card') - if cls: btn.add_css_class(cls) - btn.set_size_request(280, 220) + # 'card-accent' tints + accent-borders the card while keeping the normal + # (dark) foreground, unlike 'suggested-action' which forces white text. + if primary: + btn.add_css_class('card-accent') + btn.set_size_request(270, 188) btn.set_hexpand(False) btn.set_vexpand(False) btn.connect('clicked', lambda b: cb()) + # Multi-label child: GTK can't infer a name, so set it (title + description). + btn.update_property( + [Gtk.AccessibleProperty.LABEL, Gtk.AccessibleProperty.DESCRIPTION], + [title, desc], + ) - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16) + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) box.set_valign(Gtk.Align.CENTER) box.set_halign(Gtk.Align.CENTER) box.set_hexpand(False) box.set_vexpand(False) - for m in ['top', 'bottom', 'start', 'end']: getattr(box, f'set_margin_{m}')(24) + for m in ['top', 'bottom', 'start', 'end']: getattr(box, f'set_margin_{m}')(16) + + if badge: + bl = Gtk.Label(label=badge) + bl.add_css_class('card-badge') + bl.set_halign(Gtk.Align.CENTER) + box.append(bl) - img = create_icon_widget(icon, size=64) - img.set_size_request(64, 64) + img = create_icon_widget(icon, size=44) + img.set_size_request(44, 44) img.set_hexpand(False) img.set_vexpand(False) img.set_valign(Gtk.Align.CENTER) img.set_halign(Gtk.Align.CENTER) - - if 'suggested-action' in cls: + if primary: img.add_css_class('accent') box.append(img) @@ -716,11 +783,18 @@ def create_action_card(self, title, desc, icon, cls, cb): dl.add_css_class('caption') dl.add_css_class('dim-label') dl.set_wrap(True) - dl.set_max_width_chars(25) + dl.set_max_width_chars(26) dl.set_justify(Gtk.Justification.CENTER) dl.set_hexpand(False) box.append(dl) + # Call-to-action link inside the card (mockup: "Start hosting →"). + if cta: + cl = Gtk.Label(label=cta) + cl.add_css_class('caption-heading') + cl.set_margin_top(4) + box.append(cl) + btn.set_child(box) return btn @@ -756,8 +830,18 @@ def on_nav_selected(self, lb, row): if self.content_stack.get_visible_child_name() != actual_pid: self.content_stack.set_visible_child_name(actual_pid) self.current_page = actual_pid - else: - pass + + # Server page: drop the header title, show the host action buttons there. + # Every other page keeps the dynamic section title. + if hasattr(self, 'content_headerbar'): + if actual_pid == 'host' and hasattr(self.host_view, 'header_action_box'): + self.content_headerbar.set_title_widget(self.host_view.header_action_box) + else: + self.content_headerbar.set_title_widget(self.content_title) + info = self._build_navigation_pages().get(actual_pid) or self._build_navigation_pages().get(pid) + if info: + self.content_title.set_title(info.get('name', 'Big Remote Play')) + self.content_title.set_subtitle(info.get('description', '')) def navigate_to(self, pid): """Programmatic navigation: find row and select it""" r = self.nav_list.get_first_child() @@ -836,7 +920,10 @@ def on_service_clicked(self, service_id): cmd = ['systemctl'] if meta.get('user'): cmd.append('--user') cmd.extend(['is-enabled', '--quiet', meta['unit']]) - is_enabled = subprocess.run(cmd).returncode == 0 + try: + is_enabled = subprocess.run(cmd, timeout=5).returncode == 0 + except Exception: + is_enabled = False dialog = Adw.Window(transient_for=self) dialog.set_modal(True) @@ -908,8 +995,8 @@ def find_moonlight(): elif action == 'stop': cmd = ["pkill", "-x", bin_name] elif action == 'restart': - try: subprocess.run(["pkill", "-x", bin_name], check=False) - except: pass + try: subprocess.run(["pkill", "-x", bin_name], check=False, timeout=10) + except Exception: pass cmd = [bin_name] if cmd: diff --git a/usr/share/big-remote-play/ui/moonlight_preferences.py b/src/big_remote_play/ui/moonlight_preferences.py similarity index 94% rename from usr/share/big-remote-play/ui/moonlight_preferences.py rename to src/big_remote_play/ui/moonlight_preferences.py index 38df4b8..f6e63ef 100644 --- a/usr/share/big-remote-play/ui/moonlight_preferences.py +++ b/src/big_remote_play/ui/moonlight_preferences.py @@ -1,23 +1,17 @@ import gi -import gettext -import locale -import configparser -import os -from pathlib import Path gi.require_version('Gtk', '4.0') gi.require_version('Adw', '1') -from gi.repository import Gtk, Adw, Gio, GLib +from gi.repository import Gtk, Adw -from utils.i18n import _ +from big_remote_play.utils.i18n import _ -from utils.i18n import _ -from utils.moonlight_config import MoonlightConfigManager +from big_remote_play.utils.moonlight_config import MoonlightConfigManager class MoonlightPreferencesPage(Adw.PreferencesPage): def __init__(self, **kwargs): super().__init__(**kwargs) - self.set_title(_("Moonlight")) + self.set_title("Moonlight") # product name — never translated self.set_icon_name("preferences-desktop-remote-desktop-symbolic") self.config = MoonlightConfigManager() @@ -26,7 +20,7 @@ def __init__(self, **kwargs): def setup_ui(self): - # 1. Configurações Básicas + # 1. Basic Settings basic_group = Adw.PreferencesGroup() basic_group.set_title(_("Basic Settings")) self.add(basic_group) @@ -82,7 +76,7 @@ def setup_ui(self): self.add_boolean_option(basic_group, "vSync", _("V-Sync"), _("Visual Synchronization"), "true") self.add_boolean_option(basic_group, "framePacing", _("Frame Pacing"), _("Improve smoothness"), "true") - # 2. Configurações de Entrada + # 2. Input Settings input_group = Adw.PreferencesGroup() input_group.set_title(_("Input Settings")) self.add(input_group) @@ -92,7 +86,7 @@ def setup_ui(self): self.add_boolean_option(input_group, "swapMouseButtons", _("Invert mouse left/right buttons"), None, "false") self.add_boolean_option(input_group, "reverseScrollDirection", _("Invert scroll wheel direction"), None, "false") - # 3. Configurações de Áudio + # 3. Audio Settings audio_group = Adw.PreferencesGroup() audio_group.set_title(_("Audio Settings")) self.add(audio_group) @@ -111,7 +105,7 @@ def setup_ui(self): self.add_boolean_option(audio_group, "muteHostSpeakers", _("Mute host PC speakers while streaming"), None, "true") self.add_boolean_option(audio_group, "muteOnFocusLost", _("Mute audio when Moonlight is not the active window"), None, "false") - # 4. Configurações do Controle + # 4. Controller Settings ctrl_group = Adw.PreferencesGroup() ctrl_group.set_title(_("Controller Settings")) self.add(ctrl_group) @@ -120,14 +114,14 @@ def setup_ui(self): self.add_boolean_option(ctrl_group, "gamepadMouseEmulation", _("Enable mouse control with gamepad"), None, "true") self.add_boolean_option(ctrl_group, "gamepadBackgroundInput", _("Process controller input in background"), None, "false") - # 5. Configurações de Host + # 5. Host Settings host_group = Adw.PreferencesGroup() host_group.set_title(_("Host Settings")) self.add(host_group) self.add_boolean_option(host_group, "optimizeGameSettings", _("Optimize game settings for streaming"), None, "true") self.add_boolean_option(host_group, "quitAfter", _("Quit app on host after streaming ends"), None, "false") - # 6. Configurações Avançadas + # 6. Advanced Settings adv_group = Adw.PreferencesGroup() adv_group.set_title(_("Advanced Settings")) self.add(adv_group) @@ -161,7 +155,7 @@ def setup_ui(self): self.add_boolean_option(adv_group, "checkBlockedConnections", _("Check for blocked connections automatically"), None, "true") self.add_boolean_option(adv_group, "performanceOverlay", _("Show performance stats while streaming"), None, "false") - # 7. Configurações de UI + # 7. UI Settings ui_group = Adw.PreferencesGroup() ui_group.set_title(_("UI Settings")) self.add(ui_group) diff --git a/usr/share/big-remote-play/ui/performance_monitor.py b/src/big_remote_play/ui/performance_monitor.py similarity index 80% rename from usr/share/big-remote-play/ui/performance_monitor.py rename to src/big_remote_play/ui/performance_monitor.py index 56e4265..5bbb52b 100644 --- a/usr/share/big-remote-play/ui/performance_monitor.py +++ b/src/big_remote_play/ui/performance_monitor.py @@ -8,21 +8,19 @@ from collections import deque from dataclasses import dataclass import time -import random import threading import subprocess import re -from pathlib import Path import queue import os -import signal +from big_remote_play import paths import gi import socket gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") -from gi.repository import Gtk, GLib, Adw, Gdk -from utils.i18n import _ +from gi.repository import Gtk, GLib, Adw +from big_remote_play.utils.i18n import _ try: import cairo @@ -31,7 +29,7 @@ CHART_MAX_HISTORY = 60 -from utils.icons import create_icon_widget, set_icon +from big_remote_play.utils.icons import create_icon_widget, set_icon @dataclass class PerformanceDataPoint: @@ -80,7 +78,7 @@ def __init__(self) -> None: self.add_controller(motion_controller) def _get_device_color(self, name): - # Remove sufixos de estado para manter a cor consistente + # Strip state suffixes to keep the color consistent base_name = name.split('(')[0].strip() if base_name not in self.device_colors: idx = len(self.device_colors) % len(self.color_palette) @@ -246,7 +244,7 @@ def draw_item(label, val_text, color, x_offset): cr.set_source_rgba(0.9, 0.9, 0.9, 1) cr.set_font_size(11) cr.move_to(margin_left + x_offset + 10, legend_y) - # Limpar nome para legenda + # Clean name for the legend clean_label = label.split('(')[0].strip() text = f"{clean_label}: {val_text}" cr.show_text(text) @@ -352,7 +350,7 @@ def __init__(self, sunshine=None): self._last_fps = 60.0 self._last_bandwidth = 10.0 - # Cache para persistência de dispositivos + # Cache for device persistence # Key: IP, Value: {'name': str, 'last_seen': float, 'last_latency': float} self._known_devices = {} @@ -370,7 +368,7 @@ def set_target_fps(self, fps): # Update current view if idle (fps == 0 or default 60) if self._last_fps == 60.0 or self._last_fps == 0: self._last_fps = val - except: pass + except Exception: pass def set_target_bandwidth(self, mbps): try: @@ -379,7 +377,7 @@ def set_target_bandwidth(self, mbps): # If idle (default 10), update if self._last_bandwidth == 10.0 or self._last_bandwidth == 0: self._last_bandwidth = val - except: pass + except Exception: pass def start_monitoring(self): if self.update_timer_active: return @@ -443,27 +441,6 @@ def _process_data_queue(self): pass return True - def _get_auth(self): - try: - paths = [ - Path.home() / '.config' / 'big-remoteplay' / 'sunshine' / 'sunshine.conf', - Path.home() / '.config' / 'sunshine' / 'sunshine.conf', - Path('/etc') / 'sunshine' / 'sunshine.conf' - ] - for p in paths: - if p.exists(): - user, pw = "", "" - with open(p, 'r') as f: - content = f.read() - user_match = re.search(r'^sunshine_user\s*=\s*(.+)', content, re.MULTILINE) - pw_match = re.search(r'^sunshine_password\s*=\s*(.+)', content, re.MULTILINE) - if user_match: user = user_match.group(1).strip() - if pw_match: pw = pw_match.group(1).strip() - if user and pw: return (user, pw) - except Exception: - pass - return None - def _resolve_hostname(self, ip): """Resolves hostname with caching to avoid lag""" if not ip or ip in ['0.0.0.0']: return None @@ -479,11 +456,72 @@ def _resolve_hostname(self, ip): if '.local' in hostname: hostname = hostname.split('.')[0] self.hostname_cache[ip] = hostname return hostname - except: + except Exception: # Cache failure too to avoid retrying constantly self.hostname_cache[ip] = None return None + def _sunshine_auth(self): + """Reads Sunshine admin credentials saved in sunshine.conf, or None.""" + from pathlib import Path + conf = Path.home() / '.config' / 'big-remoteplay' / 'sunshine' / 'sunshine.conf' + if not conf.exists(): + return None + user = password = None + try: + with open(conf) as f: + for line in f: + if '=' in line: + key, val = line.split('=', 1) + key, val = key.strip(), val.strip() + if key == 'sunshine_user': + user = val + elif key == 'sunshine_password': + password = val + except Exception: + return None + return (user, password) if user and password else None + + def _prompt_disconnect(self, session_id, ip): + """Offers a gentle app close vs a forceful IP eviction.""" + dialog = Adw.MessageDialog( + heading=_("Disconnect Guest"), + body=_("End the running game gently, or forcefully evict the network " + "address? Ending the game keeps the device paired."), + ) + root = self.get_root() + if root: + dialog.set_transient_for(root) + dialog.add_response("cancel", _("Cancel")) + if self.sunshine: + dialog.add_response("close", _("End Game")) + dialog.set_response_appearance("close", Adw.ResponseAppearance.SUGGESTED) + if ip: + dialog.add_response("evict", _("Evict IP")) + dialog.set_response_appearance("evict", Adw.ResponseAppearance.DESTRUCTIVE) + + def on_resp(d, r): + if r == "close": + self._close_running_app() + elif r == "evict": + self._disconnect_session(session_id, ip) + + dialog.connect("response", on_resp) + dialog.present() + + def _close_running_app(self): + """Gentle stop via Sunshine API (POST /api/apps/close), no root.""" + if not self.sunshine: + return + self.set_sensitive(False) + auth = self._sunshine_auth() + + def work(): + ok = self.sunshine.close_app(auth=auth) + GLib.idle_add(self._on_disconnect_done, ok) + + threading.Thread(target=work, daemon=True).start() + def _disconnect_session(self, session_id, ip): # We need at least an IP or session_id to try something if not ip and not session_id: return @@ -491,34 +529,23 @@ def _disconnect_session(self, session_id, ip): self.set_sensitive(False) def do_disconnect(): success = False - auth = self._get_auth() - - # METHOD 1: System Level Kill (Radical & Definitive) - # We prefer this because Sunshine API seems to crash/kill other sessions - # whenever we use terminate_session() in the user's environment. + + # System-level socket kill is the real mechanism to drop a live stream. + # Sunshine exposes no session-termination REST endpoint; unpairing a + # client would not end an in-progress stream. if ip: try: - base_dir = Path(__file__).parent.parent - script_path = base_dir / 'scripts' / 'drop_guest.sh' - - if script_path.exists(): - cmd = ["pkexec", str(script_path), ip] + script_path = paths.script_path('drop_guest.sh') + + if os.path.exists(script_path): + cmd = ["pkexec", script_path, ip] + # No timeout: pkexec blocks on the polkit auth dialog (user-paced) res = subprocess.run(cmd, capture_output=True, text=True) if res.returncode == 0: success = True - else: - pass - except Exception as e: + except Exception: pass - # METHOD 2: API Fallback (Only if we haven't succeeded yet and have ID) - # We skip this if we successfully killed the socket, because we want to avoid - # the API instability the user reported. - if not success and session_id and self.sunshine: - try: - # Caution: This might be unstable on user's system - success = self.sunshine.terminate_session(session_id, auth=auth) - except: pass - + GLib.idle_add(self._on_disconnect_done, success) threading.Thread(target=do_disconnect, daemon=True).start() @@ -538,7 +565,7 @@ def _ping_host(self, ip): try: import platform system = platform.system() - # FORÇAR LOCALE C para garantir ponto decimal e mensagem em inglês + # Force LOCALE C to ensure a decimal point and English message env = os.environ.copy() env["LC_ALL"] = "C" @@ -550,12 +577,12 @@ def _ping_host(self, ip): result = subprocess.run(cmd, capture_output=True, text=True, timeout=1.5, env=env) if result.returncode == 0: - # Regex robusto para tempo=1.23, time=1.23, ttl... time=1.23 + # Robust regex for tempo=1.23, time=1.23, ttl... time=1.23 match = re.search(r'(?:time|tempo|ttl)[=<]([\d\.,]+)\s*ms', result.stdout, re.IGNORECASE) if match: val_str = match.group(1).replace(',', '.') return float(val_str) - # Fallback simples + # Simple fallback match_fallback = re.search(r'([\d\.,]+)\s*ms', result.stdout) if match_fallback: val_str = match_fallback.group(1).replace(',', '.') @@ -565,7 +592,7 @@ def _ping_host(self, ip): return 0.0 def _detect_sessions_via_ss(self): - """Retorna um Dicionário {ip: dados} para facilitar busca""" + """Return a dict {ip: data} for easy lookup.""" found_sessions = {} try: sunshine_ports = ['47984', '47989', '48010', '47998', '47999', '48000', '48002', '47990', '48001'] @@ -580,7 +607,7 @@ def _detect_sessions_via_ss(self): parts = line.split() if len(parts) >= 5: last_part = parts[-1] - # Regex mais permissivo para pegar IP + # More permissive regex to capture the IP ip_match = re.search(r'(\d+\.\d+\.\d+\.\d+)', last_part) if ip_match: ip = ip_match.group(1) @@ -596,82 +623,35 @@ def _detect_sessions_via_ss(self): def _fetch_and_process_data(self): try: - auth = self._get_auth() - api_stats = {} - api_sessions_list = [] - - # 1. Tentar pegar dados oficiais da API - if self.sunshine: - try: - api_stats = self.sunshine.get_performance_stats(auth=auth) - api_sessions_list = self.sunshine.get_active_sessions(auth=auth) - except Exception: - pass - - def safe_float(v): - try: return float(v) - except: return 0.0 - - # Métricas Globais - latency_avg = safe_float(api_stats.get('average_latency', 0)) - fps = safe_float(api_stats.get('fps', 0)) - bandwidth = safe_float(api_stats.get('bitrate', 0)) / 1000.0 + # Sunshine exposes no stats/sessions REST endpoint. Global metrics start + # at 0 and fall back to per-device ping / target values further below. + latency_avg = 0.0 + fps = 0.0 + bandwidth = 0.0 - # 2. Pegar dados do SS (Sistema Operacional) + # Live sessions come from socket inspection (ss). ss_sessions_dict = self._detect_sessions_via_ss() - # 3. Mesclar API com SS para garantir IPs - # Normalizar lista da API normalized_api_sessions = [] - if api_sessions_list: - for s in api_sessions_list: - if not isinstance(s, dict): continue - # Normalizar chaves - s_ip = s.get('ip') or s.get('clientAddress') or '' - s_name = s.get('name') or s.get('clientName') or _('Guest') - - # Se API não tem IP, tenta achar no SS - if not s_ip and ss_sessions_dict: - # Pega o primeiro IP disponível do SS como "chute" se só tiver 1 convidado - if len(ss_sessions_dict) == 1: - s_ip = list(ss_sessions_dict.keys())[0] - - # Try to resolve hostname if name is generic - if s_name == _('Guest') or s_name == 'Unknown': - if s_ip: - resolved = self._resolve_hostname(s_ip) - if resolved: s_name = resolved - - normalized_api_sessions.append({'ip': s_ip, 'name': s_name, 'source': 'api', 'id': s.get('id')}) - - # Adicionar sessões do SS que não estão na API current_cycle_ips = set() - - # Adicionar da API - for s in normalized_api_sessions: - if s['ip']: current_cycle_ips.add(s['ip']) - - # Adicionar do SS (se não estiver na API) for ip, data in ss_sessions_dict.items(): - if ip not in current_cycle_ips: - # Resolve hostname for SS sessions too - hname = self._resolve_hostname(ip) or _('Guest') - normalized_api_sessions.append({'ip': ip, 'name': hname, 'source': 'ss', 'id': None}) - current_cycle_ips.add(ip) - - # 4. ATUALIZAR LISTA DE DISPOSITIVOS CONHECIDOS (Persistência) - # Se um IP apareceu agora, atualizamos o timestamp. - # Se não apareceu agora, mantemos ele na lista se ele responder ao ping. + hname = self._resolve_hostname(ip) or _('Guest') + normalized_api_sessions.append({'ip': ip, 'name': hname, 'source': 'ss', 'id': None}) + current_cycle_ips.add(ip) + + # 4. UPDATE THE KNOWN DEVICES LIST (persistence) + # If an IP showed up now, update the timestamp. + # If it didn't show up now, keep it in the list if it answers ping. now = time.time() - # Inserir novos ou atualizar existentes detectados agora + # Insert new ones or update existing ones detected now for s in normalized_api_sessions: ip = s['ip'] name = s['name'] if not ip: continue - # Se já conhecemos, preservar nome se for "Guest" agora + # If already known, preserve the name if it is "Guest" now if ip in self._known_devices: if name == _('Guest') and self._known_devices[ip]['name'] != _('Guest'): name = self._known_devices[ip]['name'] @@ -683,8 +663,8 @@ def safe_float(v): 'status': 'active' } - # 5. PING E LIMPEZA - # Vamos iterar sobre TODOS os dispositivos conhecidos, não só os ativos + # 5. PING AND CLEANUP + # Iterate over ALL known devices, not only the active ones final_display_list = [] device_latencies = {} @@ -693,38 +673,38 @@ def safe_float(v): ips_to_remove = [] for ip, data in self._known_devices.items(): - # Verificar se está "ativo" neste ciclo (veio da API ou SS) + # Check whether it is "active" this cycle (came from API or SS) is_active_cycle = ip in current_cycle_ips - # SEMPRE PINGAR para ter dados no gráfico - # Isso resolve o problema de dados faltando + # ALWAYS PING to have data in the chart + # This fixes the missing-data problem lat = self._ping_host(ip) - # Lógica de Persistência: - # Se pingou > 0: Mantém na lista como 'Online' - # Se pingou 0: - # Se estava ativo no ciclo (API disse que ta lá), mantém (pode ser firewall bloqueando ping) - # Se NÃO estava ativo no ciclo, marca para remoção (timeout) + # Persistence logic: + # If ping > 0: keep it in the list as 'Online' + # If ping == 0: + # If it was active this cycle (API said it's there), keep it (could be a firewall blocking ping) + # If it was NOT active this cycle, mark it for removal (timeout) if lat > 0: data['last_latency'] = lat data['last_seen'] = now # Renovamos "visto" se ping responde else: - # Se falhou ping, usa ultimo conhecido ou 0 + # If ping failed, use the last known value or 0 lat = data.get('last_latency', 0) - # Definir nome de exibição + # Set the display name display_name = data['name'] if ip not in display_name: display_name = f"{display_name} ({ip})" - # Adicionar sufixo se estiver apenas em "modo ping" (sem stream ativo) + # Add a suffix if only in "ping mode" (no active stream) if not is_active_cycle and lat > 0: - # Opcional: Indicar que está idle, mas o usuário pediu PERPÉTUO + # Optional: indicate idle, but the user asked for PERPETUAL pass - # Se não temos sinal de vida (nem API, nem SS, nem Ping) por X tempo, remover - if not is_active_cycle and lat == 0 and (now - data['last_seen'] > 30): # 30 segundos tolerância + # If there is no sign of life (no API, no SS, no Ping) for X time, remove it + if not is_active_cycle and lat == 0 and (now - data['last_seen'] > 30): # 30 seconds tolerance ips_to_remove.append(ip) continue @@ -749,19 +729,19 @@ def safe_float(v): final_display_list.append(session_obj) - # Adicionar ao gráfico se tiver latência + # Add to the chart if it has latency if lat > 0: device_latencies[display_name] = lat - # Limpar antigos + # Clean up old ones for ip in ips_to_remove: del self._known_devices[ip] - # Calcular médias para linha geral + # Compute averages for the overall line if not latency_avg and device_latencies: latency_avg = sum(device_latencies.values()) / len(device_latencies) - # Manter FPS/BW estáveis + # Keep FPS/BW stable if fps == 0: fps = self._last_fps if self._last_fps > 0 else self._target_fps else: self._last_fps = fps @@ -776,7 +756,7 @@ def safe_float(v): if self._target_bw == 0: bw_txt_override = f"{bandwidth:.1f} Mbps (Unlim)" - # Enviar para UI + # Send to the UI self._data_queue.put((latency_avg, fps, bandwidth, final_display_list, device_latencies, bw_txt_override)) except Exception: @@ -787,7 +767,7 @@ def update_stats(self, latency, fps, bandwidth, sessions=None, device_latencies= if not self.update_timer_active: return sessions, device_latencies = sessions or [], device_latencies or {} - # O gráfico recebe device_latencies, que contém TODOS que responderam ao ping + # The chart receives device_latencies, containing ALL that answered the ping self.chart.add_data_point(latency, fps, bandwidth, users=len(sessions), device_latencies=device_latencies, bw_text_override=bw_text) if len(sessions) > 0: @@ -816,7 +796,7 @@ def _update_guest_list(self, sessions): ip = s.get('ip', 'Unknown IP') latency = s.get('latency', 0) - # Separar Nome e IP para visual mais limpo + # Split Name and IP for a cleaner look if '(' in full_name: name_part = full_name.split('(')[0].strip() else: @@ -835,7 +815,7 @@ def _update_guest_list(self, sessions): if hasattr(self.chart, '_get_device_color'): try: - # Usa o nome completo para garantir a mesma cor do gráfico + # Use the full name to keep the same color as the chart color = self.chart._get_device_color(full_name) da = Gtk.DrawingArea() da.set_content_width(24) @@ -847,7 +827,7 @@ def draw_indicator(area, cr, width, height, color=color): cr.fill() da.set_draw_func(draw_indicator) row.add_prefix(da) - except: pass + except Exception: pass row.add_suffix(ping_lbl) @@ -857,9 +837,10 @@ def draw_indicator(area, cr, width, height, color=color): disc_btn.set_icon_name("network-offline-symbolic") disc_btn.add_css_class("flat") disc_btn.add_css_class("destructive-action") - disc_btn.set_tooltip_text(_("Disconnect this specific guest (Admin)")) + disc_btn.set_tooltip_text(_("Disconnect this specific guest (Admin)")) disc_btn.set_valign(Gtk.Align.CENTER) - disc_btn.connect("clicked", lambda b, sid=s.get('id'), sip=s.get('ip'): self._disconnect_session(sid, sip)) + disc_btn.update_property([Gtk.AccessibleProperty.LABEL], [_("Disconnect guest")]) + disc_btn.connect("clicked", lambda b, sid=s.get('id'), sip=s.get('ip'): self._prompt_disconnect(sid, sip)) row.add_suffix(disc_btn) self._details_list.append(row) diff --git a/usr/share/big-remote-play/ui/preferences.py b/src/big_remote_play/ui/preferences.py similarity index 78% rename from usr/share/big-remote-play/ui/preferences.py rename to src/big_remote_play/ui/preferences.py index 7f61b5d..7453eee 100644 --- a/usr/share/big-remote-play/ui/preferences.py +++ b/src/big_remote_play/ui/preferences.py @@ -6,13 +6,13 @@ gi.require_version('Gtk', '4.0') gi.require_version('Adw', '1') -from gi.repository import Gtk, Adw, GLib, Gio -import subprocess, os, tempfile, threading, shutil, sys +from gi.repository import Gtk, Adw +import os, shutil, sys from pathlib import Path -from utils.icons import create_icon_widget -import utils.logger as logger -from utils.i18n import _ +from big_remote_play.utils.icons import create_icon_widget +import big_remote_play.utils.logger as logger +from big_remote_play.utils.i18n import _ class PreferencesWindow(Adw.PreferencesWindow): """Preferences window""" @@ -22,13 +22,13 @@ def __init__(self, **kwargs): self.initial_tab = kwargs.pop('initial_tab', None) super().__init__(**kwargs) - self.set_title(_('Preferências')) + self.set_title(_('Preferences')) self.set_default_size(600, 500) self.set_modal(True) self.logger = logger.Logger() if not self.config: - from utils.config import Config + from big_remote_play.utils.config import Config self.config = Config() self.setup_ui() @@ -45,7 +45,7 @@ def setup_ui(self): # Appearance group appearance_group = Adw.PreferencesGroup() - appearance_group.set_title(_('Aparência')) + appearance_group.set_title(_('Appearance')) # Theme theme_row = Adw.ComboRow() @@ -53,7 +53,7 @@ def setup_ui(self): theme_row.set_subtitle(_('Escolha o esquema de cores')) theme_model = Gtk.StringList() - theme_model.append(_('Automático')) + theme_model.append(_('Automatic')) theme_model.append(_('Claro')) theme_model.append(_('Escuro')) @@ -78,8 +78,8 @@ def setup_ui(self): # Restore Defaults restore_row = Adw.ActionRow() - restore_row.set_title(_('Restaurar Padrões')) - restore_row.set_subtitle(_('Redefinir todas as configurações para o padrão')) + restore_row.set_title(_('Restore Defaults')) + restore_row.set_subtitle(_('Reset all settings to default')) restore_btn = Gtk.Button(label=_('Restaurar')) restore_btn.set_valign(Gtk.Align.CENTER) @@ -90,7 +90,7 @@ def setup_ui(self): # Clear All clear_all_row = Adw.ActionRow() clear_all_row.set_title(_('Limpar Tudo')) - clear_all_row.set_subtitle(_('Remover logs, configurações, servidores e dados salvos')) + clear_all_row.set_subtitle(_('Remove logs, settings, servers and saved data')) clear_all_btn = Gtk.Button(label=_('Limpar Tudo')) clear_all_btn.add_css_class('destructive-action') @@ -104,15 +104,15 @@ def setup_ui(self): # Advanced page advanced_page = Adw.PreferencesPage() - advanced_page.set_title(_('Avançado')) + advanced_page.set_title(_('Advanced')) advanced_page.set_icon_name('preferences-other-symbolic') # Paths group paths_group = Adw.PreferencesGroup() - paths_group.set_title(_('Caminhos')) + paths_group.set_title(_('Paths')) config_row = Adw.ActionRow() - config_row.set_title(_('Diretório de Configuração')) + config_row.set_title(_('Configuration Directory')) config_row.set_subtitle('~/.config/big-remoteplay') copy_config_btn = Gtk.Button() @@ -128,17 +128,17 @@ def setup_ui(self): # Logs group logs_group = Adw.PreferencesGroup() - logs_group.set_title(_('Logs e Depuração')) + logs_group.set_title(_('Logs and Debugging')) verbose_row = Adw.SwitchRow() verbose_row.set_title(_('Logs Detalhados')) - verbose_row.set_subtitle(_('Ativar logging verbose para depuração')) + verbose_row.set_subtitle(_('Enable verbose logging for debugging')) verbose_row.set_active(False) logs_group.add(verbose_row) clear_logs_row = Adw.ActionRow() clear_logs_row.set_title(_('Limpar Logs')) - clear_logs_row.set_subtitle(_('Remover arquivos de log antigos')) + clear_logs_row.set_subtitle(_('Remove old log files')) clear_btn = Gtk.Button(label=_('Limpar')) clear_btn.add_css_class('destructive-action') @@ -157,20 +157,10 @@ def setup_ui(self): - # Add pages + # Add pages. Sunshine/Moonlight tuning is NOT duplicated here — it lives + # with its task (Server → Advanced server settings; Connect → Advanced + # client settings), so there is one place per task. self.add(general_page) - - # Sunshine page - from .sunshine_preferences import SunshinePreferencesPage - self.sunshine_page = SunshinePreferencesPage(main_config=self.config) - self.add(self.sunshine_page) - - # Moonlight page - from .moonlight_preferences import MoonlightPreferencesPage - self.moonlight_page = MoonlightPreferencesPage() - self.add(self.moonlight_page) - - self.add(advanced_page) def on_theme_changed(self, row, param): @@ -205,16 +195,16 @@ def on_clear_logs_clicked(self, button): def copy_config_path(self, btn): path = os.path.expanduser('~/.config/big-remoteplay') self.get_clipboard().set(path) - self.add_toast(Adw.Toast.new(_("Caminho copiado!"))) + self.add_toast(Adw.Toast.new(_("Path copied!"))) def on_restore_defaults_clicked(self, button): # Updated text as requested - msg = _("Todas as suas modificações no Big Remote Play serão restauradas! Isso inclui as configurações do Sunshine e Moonlight.") + msg = _("All your changes in Big Remote Play will be restored! This includes Sunshine and Moonlight settings.") - dialog = Adw.MessageDialog(heading=_("Restaurar Padrões?"), body=msg) - dialog.add_response("cancel", _("Cancelar")) - dialog.add_response("restore", _("Restaurar")) + dialog = Adw.MessageDialog(heading=_("Restore Defaults?"), body=msg) + dialog.add_response("cancel", _("Cancel")) + dialog.add_response("restore", _("Restore")) dialog.set_response_appearance("restore", Adw.ResponseAppearance.DESTRUCTIVE) dialog.set_transient_for(self) @@ -241,36 +231,36 @@ def on_response(d, r): self.sunshine_page.config.load() # 3. Reset Moonlight Config - from utils.moonlight_config import MoonlightConfigManager + from big_remote_play.utils.moonlight_config import MoonlightConfigManager mc = MoonlightConfigManager() if mc.config_file and mc.config_file.exists(): os.remove(mc.config_file) mc.reload() # Recreates Default mc.save() - self.add_toast(Adw.Toast.new(_("Configurações Restauradas! Reinicie o aplicativo para aplicar todas as alterações."))) + self.add_toast(Adw.Toast.new(_("Settings restored! Restart the application to apply all changes."))) self.close() except Exception as e: print(f"Error resetting configs: {e}") - self.add_toast(Adw.Toast.new(_("Erro ao restaurar: {}").format(e))) + self.add_toast(Adw.Toast.new(_("Error restoring: {}").format(e))) dialog.connect("response", on_response) dialog.present() def on_clear_all_clicked(self, button): # Double check implementation - dialog1 = Adw.MessageDialog(heading=_("Limpar TUDO?"), body=_("ATENÇÃO: Isso apagará TODOS os dados, logs, configurações e servidores salvos. O aplicativo será fechado.")) - dialog1.add_response("cancel", _("Cancelar")) - dialog1.add_response("clear", _("Apagar Tudo")) + dialog1 = Adw.MessageDialog(heading=_("Clear EVERYTHING?"), body=_("WARNING: This will delete ALL data, logs, settings and saved servers. The application will close.")) + dialog1.add_response("cancel", _("Cancel")) + dialog1.add_response("clear", _("Delete All")) dialog1.set_response_appearance("clear", Adw.ResponseAppearance.DESTRUCTIVE) dialog1.set_transient_for(self) def on_response1(d, r): if r == "clear": # Second confirmation (Double Check) - dialog2 = Adw.MessageDialog(heading=_("Tem Certeza Absoluta?"), body=_("Esta ação é IRREVERSÍVEL. Você perderá todos os dados configurados.")) - dialog2.add_response("cancel", _("Cancelar")) - dialog2.add_response("destroy", _("Sim, Apagar Tudo")) + dialog2 = Adw.MessageDialog(heading=_("Are You Absolutely Sure?"), body=_("This action is IRREVERSIBLE. You will lose all configured data.")) + dialog2.add_response("cancel", _("Cancel")) + dialog2.add_response("destroy", _("Yes, Delete All")) dialog2.set_response_appearance("destroy", Adw.ResponseAppearance.DESTRUCTIVE) dialog2.set_transient_for(self) @@ -313,7 +303,7 @@ def _perform_clear_all(self): sys.exit(0) except Exception as e: - err_dlg = Adw.MessageDialog(heading=_("Erro ao Limpar"), body=str(e)) + err_dlg = Adw.MessageDialog(heading=_("Error Clearing"), body=str(e)) err_dlg.add_response("ok", _("OK")) err_dlg.set_transient_for(self) err_dlg.present() diff --git a/usr/share/big-remote-play/ui/private_network_view.py b/src/big_remote_play/ui/private_network_view.py similarity index 67% rename from usr/share/big-remote-play/ui/private_network_view.py rename to src/big_remote_play/ui/private_network_view.py index d8a1de9..3b28b56 100644 --- a/usr/share/big-remote-play/ui/private_network_view.py +++ b/src/big_remote_play/ui/private_network_view.py @@ -1,51 +1,68 @@ import gi + gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") import json, os, re, subprocess, threading, time, shutil from gi.repository import Adw, Gdk, GLib, Gtk -from utils.i18n import _ -from utils.icons import create_icon_widget +from big_remote_play.utils.i18n import _ +from big_remote_play.utils.icons import create_icon_widget +from big_remote_play.utils.widgets import create_helper_card, create_wizard_stepper +from big_remote_play import paths +from big_remote_play.utils.secure_io import secure_write_text +from big_remote_play.utils.secret_store import SecretKey, SecretStore, SecretStoreUnavailable, new_secret_id +from big_remote_play.utils.script_protocol import parse_script_line + + +def open_uri(uri: str) -> None: + """Open a URI in the default handler without a shell (no injection risk)""" + subprocess.Popen(["xdg-open", uri], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + # ─── Config ─────────────────────────────────────────────────────────────────── _OLD_CFG = os.path.expanduser("~/.config/big-remoteplay") _NEW_CFG = os.path.expanduser("~/.config/big-remote-play") if os.path.exists(_OLD_CFG) and not os.path.exists(_NEW_CFG): - try: shutil.move(_OLD_CFG, _NEW_CFG) - except: pass + try: + shutil.move(_OLD_CFG, _NEW_CFG) + except Exception: + pass HISTORY_FILE = os.path.join(_NEW_CFG, "private_network/history.json") -ZT_TOKEN_FILE = os.path.join(_NEW_CFG, "zerotier/api_token.txt") +LEGACY_ZT_TOKEN_FILE = os.path.join(_NEW_CFG, "zerotier/api_token.txt") +_SECRET_STORE = SecretStore() +_ZEROTIER_TOKEN_KEY = SecretKey("zerotier", "api_token", "default") +_HISTORY_SECRET_KEYS = {"auth_key", "api_key"} VPN_META = { - 'headscale': { - 'name': 'Headscale', - 'icon': 'headscale-symbolic', - 'color': '#3584e4', - 'create_title': _('Create Headscale Server'), - 'create_desc': _('Set up your own private VPN server using Docker + Cloudflare DNS.'), - 'connect_title': _('Connect to Headscale Network'), - 'connect_desc': _('Enter the server domain and auth key provided by the administrator.'), - 'script': 'create-network_headscale.sh', + "headscale": { + "name": "Headscale", + "icon": "headscale-symbolic", + "color": "#3584e4", + "create_title": _("Create Headscale Server"), + "create_desc": _("Set up your own private VPN server using Docker + Cloudflare DNS."), + "connect_title": _("Connect to Headscale Network"), + "connect_desc": _("Enter the server domain and auth key provided by the administrator."), + "script": "create-network_headscale.sh", }, - 'tailscale': { - 'name': 'Tailscale', - 'icon': 'tailscale-symbolic', - 'color': '#26a269', - 'create_title': _('Login to Tailscale'), - 'create_desc': _('Login to your Tailscale account. No server required.'), - 'connect_title': _('Connect to Tailscale Network'), - 'connect_desc': _('Enter your auth key or login server to join a Tailscale network.'), - 'script': 'create-network_tailscale.sh', + "tailscale": { + "name": "Tailscale", + "icon": "tailscale-symbolic", + "color": "#26a269", + "create_title": _("Login to Tailscale"), + "create_desc": _("Login to your Tailscale account. No server required."), + "connect_title": _("Connect to Tailscale Network"), + "connect_desc": _("Enter your auth key or login server to join a Tailscale network."), + "script": "create-network_tailscale.sh", }, - 'zerotier': { - 'name': 'ZeroTier', - 'icon': 'zerotier-symbolic', - 'color': '#e5a50a', - 'create_title': _('Create ZeroTier Network'), - 'create_desc': _('Create a new ZeroTier virtual network using your API token.'), - 'connect_title': _('Connect to ZeroTier Network'), - 'connect_desc': _('Enter the 16-character Network ID to join a ZeroTier network.'), - 'script': 'create-network_zerotier.sh', + "zerotier": { + "name": "ZeroTier", + "icon": "zerotier-symbolic", + "color": "#e5a50a", + "create_title": _("Create ZeroTier Network"), + "create_desc": _("Create a new ZeroTier virtual network using your API token."), + "connect_title": _("Connect to ZeroTier Network"), + "connect_desc": _("Enter the 16-character Network ID to join a ZeroTier network."), + "script": "create-network_zerotier.sh", }, } @@ -60,23 +77,90 @@ def _load_history(): return [] +def _secret_label(provider: str, kind: str) -> str: + return f"Big Remote Play {provider} {kind.replace('_', ' ')}" + + +def _history_secret_key(entry: dict, key: str) -> SecretKey | None: + refs = entry.get("secret_refs", {}) + if not isinstance(refs, dict): + return None + secret_id = refs.get(key) + if not secret_id: + return None + return SecretKey(str(entry.get("vpn", "private-network")), key, str(secret_id)) + + +def _entry_secret(entry: dict, key: str) -> str: + secret_key = _history_secret_key(entry, key) + if secret_key: + try: + value = _SECRET_STORE.lookup(secret_key) + if value: + return value + except SecretStoreUnavailable: + return "" + # Compatibility for old history files. New writes never persist this. + return str(entry.get(key, "")) + + +def _store_history_secrets(entry: dict) -> dict: + sanitized = dict(entry) + refs = dict(sanitized.get("secret_refs", {}) or {}) + provider = str(sanitized.get("vpn", "private-network")) + + for key in _HISTORY_SECRET_KEYS: + value = str(sanitized.pop(key, "") or "") + if not value: + continue + secret_id = str(refs.get(key) or new_secret_id()) + secret_key = SecretKey(provider, key, secret_id) + try: + _SECRET_STORE.store(secret_key, value, _secret_label(provider, key)) + refs[key] = secret_id + except SecretStoreUnavailable: + refs.pop(key, None) + + if refs: + sanitized["secret_refs"] = refs + else: + sanitized.pop("secret_refs", None) + return sanitized + + +def _clear_history_secrets(entry: dict) -> None: + refs = entry.get("secret_refs", {}) + if not isinstance(refs, dict): + return + provider = str(entry.get("vpn", "private-network")) + for key, secret_id in refs.items(): + try: + _SECRET_STORE.clear(SecretKey(provider, str(key), str(secret_id))) + except SecretStoreUnavailable: + pass + + def _save_history(entry): - os.makedirs(os.path.dirname(HISTORY_FILE), exist_ok=True) history = _load_history() new_id = max((h.get("id", 0) for h in history), default=0) + 1 - entry["id"] = new_id - entry["timestamp"] = time.strftime("%Y-%m-%d %H:%M:%S") - history.append(entry) - with open(HISTORY_FILE, "w") as f: - json.dump({"history": history}, f, indent=2) + history_entry = dict(entry) + history_entry["id"] = new_id + history_entry["timestamp"] = time.strftime("%Y-%m-%d %H:%M:%S") + history.append(_store_history_secrets(history_entry)) + # History stores only metadata plus keyring references. + secure_write_text(HISTORY_FILE, json.dumps({"history": history}, indent=2)) return new_id def _delete_history(entry_id): - history = [h for h in _load_history() if h.get("id") != entry_id] - os.makedirs(os.path.dirname(HISTORY_FILE), exist_ok=True) - with open(HISTORY_FILE, "w") as f: - json.dump({"history": history}, f, indent=2) + history = _load_history() + kept = [] + for entry in history: + if entry.get("id") == entry_id: + _clear_history_secrets(entry) + else: + kept.append(entry) + secure_write_text(HISTORY_FILE, json.dumps({"history": kept}, indent=2)) def _update_history(entry_id, updated_entry): @@ -85,19 +169,60 @@ def _update_history(entry_id, updated_entry): for i, h in enumerate(history): if h.get("id") == entry_id: # Preserve id and timestamp, update the rest - updated_entry["id"] = entry_id - updated_entry["timestamp"] = h.get("timestamp", time.strftime("%Y-%m-%d %H:%M:%S")) - history[i] = updated_entry + merged = dict(updated_entry) + merged["id"] = entry_id + merged["timestamp"] = h.get("timestamp", time.strftime("%Y-%m-%d %H:%M:%S")) + merged["secret_refs"] = h.get("secret_refs", {}) + history[i] = _store_history_secrets(merged) break - os.makedirs(os.path.dirname(HISTORY_FILE), exist_ok=True) - with open(HISTORY_FILE, "w") as f: - json.dump({"history": history}, f, indent=2) + secure_write_text(HISTORY_FILE, json.dumps({"history": history}, indent=2)) + + +def _get_zerotier_api_token() -> str: + try: + token = _SECRET_STORE.lookup(_ZEROTIER_TOKEN_KEY) + if token: + return token + except SecretStoreUnavailable: + return "" + + if os.path.exists(LEGACY_ZT_TOKEN_FILE): + try: + token = open(LEGACY_ZT_TOKEN_FILE).read().strip() + except Exception: + token = "" + if token: + try: + _set_zerotier_api_token(token) + os.remove(LEGACY_ZT_TOKEN_FILE) + return token + except (OSError, SecretStoreUnavailable): + return "" + return "" -def _get_script(name): - base = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - p = os.path.join(base, "scripts", name) - return p if os.path.exists(p) else f"/usr/share/big-remote-play/scripts/{name}" +def _set_zerotier_api_token(token: str) -> None: + _SECRET_STORE.store(_ZEROTIER_TOKEN_KEY, token, _secret_label("zerotier", "api_token")) + + +def _clear_zerotier_api_token() -> None: + try: + _SECRET_STORE.clear(_ZEROTIER_TOKEN_KEY) + except SecretStoreUnavailable: + pass + try: + if os.path.exists(LEGACY_ZT_TOKEN_FILE): + os.remove(LEGACY_ZT_TOKEN_FILE) + except OSError: + pass + + +def _has_zerotier_api_token() -> bool: + return bool(_get_zerotier_api_token()) + + +def _get_script(name: str) -> str: + return paths.script_path(name) # ─── Terminal Log Widget ─────────────────────────────────────────────────────── @@ -111,8 +236,7 @@ def __init__(self): self.set_child(self._tv) buf = self._tv.get_buffer() table = buf.get_tag_table() - for name, color in [("green","#2ec27e"),("blue","#3584e4"),("yellow","#f5c211"), - ("red","#ed333b"),("cyan","#33c7de")]: + for name, color in [("green", "#2ec27e"), ("blue", "#3584e4"), ("yellow", "#f5c211"), ("red", "#ed333b"), ("cyan", "#33c7de")]: t = Gtk.TextTag(name=name) t.set_property("foreground", color) table.add(t) @@ -133,13 +257,20 @@ def _append_idle(self, text): tags = [] for p in parts: if p.startswith("\x1b["): - if p == "\x1b[0m": tags = [] - elif "0;32" in p: tags = ["green"] - elif "0;34" in p: tags = ["blue"] - elif "1;33" in p: tags = ["yellow"] - elif "0;31" in p: tags = ["red"] - elif "0;36" in p: tags = ["cyan"] - elif "1;" in p: tags = ["bold"] + if p == "\x1b[0m": + tags = [] + elif "0;32" in p: + tags = ["green"] + elif "0;34" in p: + tags = ["blue"] + elif "1;33" in p: + tags = ["yellow"] + elif "0;31" in p: + tags = ["red"] + elif "0;36" in p: + tags = ["cyan"] + elif "1;" in p: + tags = ["bold"] elif p: buf.insert_with_tags_by_name(buf.get_end_iter(), p, *tags) buf.insert(buf.get_end_iter(), "\n") @@ -180,7 +311,7 @@ def update(self, fraction, status=""): def _set(self, fraction, status): self.set_visible(True) self._bar.set_value(fraction) - self._pct.set_text(f"{int(fraction*100)}%") + self._pct.set_text(f"{int(fraction * 100)}%") if status: self._status.set_text(status) @@ -202,19 +333,20 @@ def __init__(self, vpn_id, main_window): self._build() def _check_logged_in(self): - if self.vpn_id in ('tailscale', 'headscale'): + if self.vpn_id in ("tailscale", "headscale"): try: - r = subprocess.run(["tailscale", "status"], capture_output=True, text=True) + r = subprocess.run(["tailscale", "status"], capture_output=True, text=True, timeout=10) # If logged in and has a valid IP/DNS return r.returncode == 0 and "Logged out" not in r.stdout except Exception: return False - if self.vpn_id == 'zerotier': - if os.path.exists(ZT_TOKEN_FILE): return True + if self.vpn_id == "zerotier": + if _has_zerotier_api_token(): + return True try: - r = subprocess.run(["zerotier-cli", "listnetworks"], capture_output=True, text=True) + r = subprocess.run(["zerotier-cli", "listnetworks"], capture_output=True, text=True, timeout=10) return r.returncode == 0 and "200 listnetworks " in r.stdout.lower() or len(r.stdout.splitlines()) > 1 - except: + except Exception: return False return False @@ -222,15 +354,15 @@ def _build(self): scroll = Gtk.ScrolledWindow(vexpand=True) scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) clamp = Adw.Clamp(maximum_size=780) - for m in ['top','bottom','start','end']: - getattr(clamp, f'set_margin_{m}')(24) + for m in ["top", "bottom", "start", "end"]: + getattr(clamp, f"set_margin_{m}")(24) content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=20) # Header hdr = Adw.PreferencesGroup() - hdr.set_title(self.vpn['create_title']) - hdr.set_description(self.vpn['create_desc']) - hdr.set_header_suffix(create_icon_widget(self.vpn['icon'], size=24)) + hdr.set_title(self.vpn["create_title"]) + hdr.set_description(self.vpn["create_desc"]) + hdr.set_header_suffix(create_icon_widget(self.vpn["icon"], size=24)) content.append(hdr) # Form group @@ -288,7 +420,7 @@ def _build(self): self._networks_group = Adw.PreferencesGroup() self._networks_group.set_title(_("Your Networks")) self._networks_group.set_visible(False) - self._network_rows = [] # track rows added via .add() so we can remove them safely + self._network_rows = [] # track rows added via .add() so we can remove them safely content.append(self._networks_group) clamp.set_child(content) @@ -299,16 +431,18 @@ def _build(self): GLib.idle_add(self._refresh_networks) def _action_label(self): - if self.vpn_id == 'tailscale': return _("Login / Connect") - if self.vpn_id == 'zerotier': return _("Save Token & Create Network") + if self.vpn_id == "tailscale": + return _("Login / Connect") + if self.vpn_id == "zerotier": + return _("Save Token & Create Network") return _("Install Server") def _on_instructions_clicked(self, btn): - if self.vpn_id == 'headscale': + if self.vpn_id == "headscale": self._show_headscale_instructions() - elif self.vpn_id == 'tailscale': + elif self.vpn_id == "tailscale": self._show_tailscale_instructions() - elif self.vpn_id == 'zerotier': + elif self.vpn_id == "zerotier": self._show_zerotier_instructions() def _build_form(self): @@ -316,19 +450,17 @@ def _build_form(self): self._form_group.remove(r) self._form_rows.clear() - if self.vpn_id == 'headscale': - - + if self.vpn_id == "headscale": self._e_domain = Adw.EntryRow(title=_("Domain (e.g. vpn.ruscher.org)")) self._e_zone = Adw.EntryRow(title=_("Cloudflare Zone ID")) self._e_token = Adw.PasswordEntryRow(title=_("Cloudflare API Token")) self._form_group.add(self._e_domain) self._form_group.add(self._e_zone) self._form_group.add(self._e_token) - + self._form_rows.extend([self._e_domain, self._e_zone, self._e_token]) - elif self.vpn_id == 'tailscale': + elif self.vpn_id == "tailscale": self._e_authkey = Adw.PasswordEntryRow(title=_("Auth Key (optional – leave empty for browser login)")) self._form_group.add(self._e_authkey) self._form_rows.append(self._e_authkey) @@ -337,18 +469,13 @@ def _build_form(self): btn = Gtk.Button(label=_("Open")) btn.add_css_class("flat") btn.set_valign(Gtk.Align.CENTER) - btn.connect("clicked", lambda b: os.system("xdg-open https://login.tailscale.com/admin/settings/keys")) + btn.connect("clicked", lambda b: open_uri("https://login.tailscale.com/admin/settings/keys")) link.add_suffix(btn) self._form_group.add(link) self._form_rows.append(link) - elif self.vpn_id == 'zerotier': - saved = "" - if os.path.exists(ZT_TOKEN_FILE): - try: - saved = open(ZT_TOKEN_FILE).read().strip() - except Exception: - pass + elif self.vpn_id == "zerotier": + saved = _get_zerotier_api_token() self._e_zt_token = Adw.PasswordEntryRow(title=_("ZeroTier API Token")) if saved: self._e_zt_token.set_text(saved) @@ -362,7 +489,7 @@ def _build_form(self): btn = Gtk.Button(label=_("Open")) btn.add_css_class("flat") btn.set_valign(Gtk.Align.CENTER) - btn.connect("clicked", lambda b: os.system("xdg-open https://my.zerotier.com")) + btn.connect("clicked", lambda b: open_uri("https://my.zerotier.com")) link.add_suffix(btn) self._form_group.add(link) self._form_rows.append(link) @@ -374,14 +501,14 @@ def _on_action(self, btn): self._progress.update(0.05, _("Starting...")) self._log.clear() - if self.vpn_id == 'headscale': + if self.vpn_id == "headscale": self._run_headscale_create() - elif self.vpn_id == 'tailscale': + elif self.vpn_id == "tailscale": self._run_tailscale_login() - elif self.vpn_id == 'zerotier': + elif self.vpn_id == "zerotier": self._run_zerotier_create() - def _run_script(self, script_name, inputs, phases, on_done): + def _run_script(self, script_name, inputs, on_done): script = _get_script(script_name) try: os.chmod(script, 0o755) @@ -389,11 +516,10 @@ def _run_script(self, script_name, inputs, phases, on_done): pass def run(): - proc = subprocess.Popen( - ["bigsudo", script], - stdin=subprocess.PIPE, stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, text=True, bufsize=1 - ) + # LC_ALL=C / LANGUAGE= force the script's gettext prose back to its + # English msgid; data is parsed from locale-independent BRP_* markers. + # `env` is passed through bigsudo so it survives privilege elevation. + proc = subprocess.Popen(["bigsudo", "env", "LC_ALL=C", "LANGUAGE=", script], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1) for s in inputs: try: proc.stdin.write(s) @@ -402,28 +528,18 @@ def run(): break captured = {} + phase = 0.1 for line in proc.stdout: - clean = re.sub(r"\x1b\[[0-9;]*[mK]", "", line).strip() - self._log.append(line.strip()) - # Detect progress - lower = clean.lower() - phase = 0.1 - for frac, keys in phases: - if any(k in lower for k in keys): - phase = frac - self._progress.update(phase, clean[:80] if clean else "") - # Capture key data - for label, key in [ - ("Interface Web:", "web_ui"), ("URL da API:", "api_url"), - ("Seu IP Público:", "public_ip"), ("IP Local", "local_ip"), - ("API Key (para UI):", "api_key"), ("Chave para Amigos:", "auth_key"), - ("Network ID:", "network_id"), ("Chave para Amigos:", "auth_key"), - ("✅ Rede criada! ID:", "network_id"), - ]: - if label in clean: - val = clean.split(label, 1)[-1].strip() - if val: - captured[key] = val + kind = parse_script_line(line) + if kind[0] == "data": + if kind[2]: + captured[kind[1]] = kind[2] + elif kind[0] == "phase": + phase = kind[1] + self._progress.update(phase, "") + elif kind[1]: + self._log.append(kind[1]) + self._progress.update(phase, kind[1][:80]) code = proc.wait() GLib.idle_add(on_done, code, captured) @@ -439,15 +555,6 @@ def _run_headscale_create(self): self._finish_action() return - phases = [ - (0.1, ['verificando','checking','deps']), - (0.2, ['docker','container']), - (0.4, ['headscale','config']), - (0.6, ['caddy','proxy']), - (0.7, ['dns','cloudflare']), - (0.85, ['auth key','chave','criando usuário']), - (0.95, ['success','concluí','✅']), - ] inputs = ["1\n", f"{d}\n", f"{z}\n", f"{t}\n", "n\n"] def done(code, data): @@ -463,16 +570,10 @@ def done(code, data): self._progress.update(0, _("❌ Failed")) self.main_window.show_toast(_("Installation failed. Check the log.")) - self._run_script('create-network_headscale.sh', inputs, phases, done) + self._run_script("create-network_headscale.sh", inputs, done) def _run_tailscale_login(self): - key = self._e_authkey.get_text().strip() if hasattr(self, '_e_authkey') else '' - phases = [ - (0.1, ['verificando','checking']), - (0.3, ['instaling','instalando']), - (0.5, ['login','auth','up']), - (0.8, ['conectado','connected','success']), - ] + key = self._e_authkey.get_text().strip() if hasattr(self, "_e_authkey") else "" # Script needs: 1 (Login), 2 (Auth Key), the Key, then Enter to clear prompt, then 0 to exit. if key: inputs = ["1\n", "2\n", f"{key}\n", "\n", "0\n"] @@ -492,26 +593,22 @@ def done(code, data): self._progress.update(0, _("❌ Failed")) self.main_window.show_toast(_("Login failed")) - self._run_script('create-network_tailscale.sh', inputs, phases, done) + self._run_script("create-network_tailscale.sh", inputs, done) def _run_zerotier_create(self): - token = self._e_zt_token.get_text().strip() if hasattr(self, '_e_zt_token') else '' - name = self._e_zt_name.get_text().strip() if hasattr(self, '_e_zt_name') else 'my-network' + token = self._e_zt_token.get_text().strip() if hasattr(self, "_e_zt_token") else "" + name = self._e_zt_name.get_text().strip() if hasattr(self, "_e_zt_name") else "my-network" if not token: self.main_window.show_toast(_("API Token is required")) self._finish_action() return - # Save token - os.makedirs(os.path.dirname(ZT_TOKEN_FILE), exist_ok=True) - with open(ZT_TOKEN_FILE, 'w') as f: - f.write(token) - - phases = [ - (0.1, ['verificando','checking']), - (0.3, ['criando','creating']), - (0.6, ['rede criada','network id']), - (0.9, ['✅','success']), - ] + try: + _set_zerotier_api_token(token) + except SecretStoreUnavailable: + self.main_window.show_toast(_("System keyring is unavailable. Token was not saved.")) + self._finish_action() + return + inputs = ["1\n", f"{token}\n", f"{name}\n", "\n"] def done(code, data): @@ -530,7 +627,7 @@ def done(code, data): self._progress.update(0, _("❌ Failed")) self.main_window.show_toast(_("Network creation failed")) - self._run_script('create-network_zerotier.sh', inputs, phases, done) + self._run_script("create-network_zerotier.sh", inputs, done) def _finish_action(self): GLib.idle_add(self._do_finish) @@ -554,10 +651,7 @@ def _show_headscale_instructions(self): # Header bar hb = Adw.HeaderBar() - hb.set_title_widget(Adw.WindowTitle.new( - _("Setup Instructions"), - _("Step-by-step guide to create your private network") - )) + hb.set_title_widget(Adw.WindowTitle.new(_("Setup Instructions"), _("Step-by-step guide to create your private network"))) toolbar_view.add_top_bar(hb) # Scrollable content @@ -567,8 +661,8 @@ def _show_headscale_instructions(self): clamp = Adw.Clamp() clamp.set_maximum_size(650) - for m in ['top', 'bottom', 'start', 'end']: - getattr(clamp, f'set_margin_{m}')(16) + for m in ["top", "bottom", "start", "end"]: + getattr(clamp, f"set_margin_{m}")(16) main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=20) @@ -588,20 +682,13 @@ def _show_headscale_instructions(self): btn_digitalplat.add_css_class("pill") btn_digitalplat.add_css_class("suggested-action") btn_digitalplat.set_valign(Gtk.Align.CENTER) - btn_digitalplat.connect( - "clicked", lambda b: os.system("xdg-open https://dash.domain.digitalplat.org/") - ) + btn_digitalplat.connect("clicked", lambda b: open_uri("https://dash.domain.digitalplat.org/")) r1_1.add_suffix(btn_digitalplat) g1.add(r1_1) r1_2 = Adw.ActionRow() r1_2.set_title(_("Steps at DigitalPlat")) - r1_2.set_subtitle( - _("1. Create an account or login\n" - "2. Choose a .us.kg domain\n" - "3. Complete the simple registration\n" - "4. Write down the domain you obtained (e.g.: myserver.us.kg)") - ) + r1_2.set_subtitle(_("1. Create an account or login\n2. Choose a .us.kg domain\n3. Complete the simple registration\n4. Write down the domain you obtained (e.g.: myserver.us.kg)")) r1_2.add_prefix(create_icon_widget("preferences-other-symbolic", size=20)) g1.add(r1_2) @@ -623,21 +710,21 @@ def _show_headscale_instructions(self): btn_cloudflare.add_css_class("pill") btn_cloudflare.add_css_class("suggested-action") btn_cloudflare.set_valign(Gtk.Align.CENTER) - btn_cloudflare.connect( - "clicked", lambda b: os.system("xdg-open https://dash.cloudflare.com/") - ) + btn_cloudflare.connect("clicked", lambda b: open_uri("https://dash.cloudflare.com/")) r2_1.add_suffix(btn_cloudflare) g2.add(r2_1) r2_2 = Adw.ActionRow() r2_2.set_title(_("Setup Steps")) r2_2.set_subtitle( - _("1. Click 'Add a site' → Enter your domain\n" - "2. Choose the FREE plan\n" - "3. Write down the 2 nameservers provided\n" - "4. Go back to your domain provider (DigitalPlat)\n" - "5. Replace the DNS with Cloudflare's nameservers\n" - "6. Wait for DNS propagation (may take a few minutes)") + _( + "1. Click 'Add a site' → Enter your domain\n" + "2. Choose the FREE plan\n" + "3. Write down the 2 nameservers provided\n" + "4. Go back to your domain provider (DigitalPlat)\n" + "5. Replace the DNS with Cloudflare's nameservers\n" + "6. Wait for DNS propagation (may take a few minutes)" + ) ) r2_2.add_prefix(create_icon_widget("preferences-other-symbolic", size=20)) g2.add(r2_2) @@ -653,9 +740,7 @@ def _show_headscale_instructions(self): btn_domain_panel.set_valign(Gtk.Align.CENTER) btn_domain_panel.connect( "clicked", - lambda b: os.system( - "xdg-open https://dash.domain.digitalplat.org/panel/main?page=%2Fpanel%2Fdomains" - ), + lambda b: open_uri("https://dash.domain.digitalplat.org/panel/main?page=%2Fpanel%2Fdomains"), ) r2_3.add_suffix(btn_domain_panel) g2.add(r2_3) @@ -671,26 +756,24 @@ def _show_headscale_instructions(self): r3_1 = Adw.ActionRow() r3_1.set_title(_("Zone ID")) - r3_1.set_subtitle( - _("1. In Cloudflare, click on your domain\n" - "2. Scroll down to the 'API' section on the right\n" - "3. Copy the 'Zone ID' value") - ) + r3_1.set_subtitle(_("1. In Cloudflare, click on your domain\n2. Scroll down to the 'API' section on the right\n3. Copy the 'Zone ID' value")) r3_1.add_prefix(create_icon_widget("view-conceal-symbolic", size=20)) g3.add(r3_1) r3_2 = Adw.ActionRow() r3_2.set_title(_("API Token")) r3_2.set_subtitle( - _("1. Click 'Get your API token' (below Zone ID)\n" - "2. Click 'Create Token'\n" - "3. Use template: 'Edit zone DNS' → 'Use template'\n" - "4. Configure:\n" - " • Token name: VPN-Token\n" - " • Permissions: Zone - DNS - Edit\n" - " • Zone: Select your domain\n" - "5. Click 'Continue to summary' → 'Create Token'\n" - "6. Copy token IMMEDIATELY (shown only once!)") + _( + "1. Click 'Get your API token' (below Zone ID)\n" + "2. Click 'Create Token'\n" + "3. Use template: 'Edit zone DNS' → 'Use template'\n" + "4. Configure:\n" + " • Token name: VPN-Token\n" + " • Permissions: Zone - DNS - Edit\n" + " • Zone: Select your domain\n" + "5. Click 'Continue to summary' → 'Create Token'\n" + "6. Copy token IMMEDIATELY (shown only once!)" + ) ) r3_2.add_prefix(create_icon_widget("view-reveal-symbolic", size=20)) g3.add(r3_2) @@ -735,36 +818,22 @@ def copy_ip(b): # Fetch IP in background def fetch_ip(): try: - ip = subprocess.check_output( - ["curl", "-s", "ipinfo.io/ip"], timeout=10 - ).decode().strip() + ip = subprocess.check_output(["curl", "-s", "ipinfo.io/ip"], timeout=10).decode().strip() GLib.idle_add(self.instructions_ip_label.set_label, ip) - except: + except Exception: GLib.idle_add(self.instructions_ip_label.set_label, _("Error")) threading.Thread(target=fetch_ip, daemon=True).start() r4_a = Adw.ActionRow() r4_a.set_title(_("A Record")) - r4_a.set_subtitle( - _("In Cloudflare → DNS → Records → 'Add record':\n" - "• Type: A\n" - "• Name: @\n" - "• Content: YOUR-PUBLIC-IP (shown above)\n" - "• Proxy: OFF (gray cloud — IMPORTANT!)") - ) + r4_a.set_subtitle(_("In Cloudflare → DNS → Records → 'Add record':\n• Type: A\n• Name: @\n• Content: YOUR-PUBLIC-IP (shown above)\n• Proxy: OFF (gray cloud — IMPORTANT!)")) r4_a.add_prefix(create_icon_widget("network-server-symbolic", size=20)) g4.add(r4_a) r4_cname = Adw.ActionRow() r4_cname.set_title(_("CNAME Record")) - r4_cname.set_subtitle( - _("Add another record:\n" - "• Type: CNAME\n" - "• Name: www\n" - "• Target: @\n" - "• Proxy: OFF") - ) + r4_cname.set_subtitle(_("Add another record:\n• Type: CNAME\n• Name: www\n• Target: @\n• Proxy: OFF")) r4_cname.add_prefix(create_icon_widget("network-server-symbolic", size=20)) g4.add(r4_cname) @@ -775,20 +844,17 @@ def fetch_ip(): # ════════════════════════════════════════════ group5 = Adw.PreferencesGroup() group5.set_title(_("5. Configure Router (Port Forwarding)")) - group5.set_description(_("Open ports 8080, 9443, 41641. Note: The installation script will attempt to configure these automatically via UPnP.")) + group5.set_description(_("Open ports 80, 443, and 41641. The installation script will attempt to configure these automatically via UPnP.")) r5_1 = Adw.ActionRow() r5_1.set_title(_("Access Your Router")) - r5_1.set_subtitle( - _("1. Open your browser and go to 192.168.1.1 (or your router's IP)\n" - "2. Find 'Port Forwarding' or 'NAT' settings") - ) + r5_1.set_subtitle(_("1. Open your browser and go to 192.168.1.1 (or your router's IP)\n2. Find 'Port Forwarding' or 'NAT' settings")) r5_1.add_prefix(create_icon_widget("network-wired-symbolic", size=20)) group5.add(r5_1) ports_data = [ - ("8080/TCP", _("Web Interface and API")), - ("9443/TCP", _("Admin Panel (Headscale UI)")), + ("80/TCP", _("TLS certificate challenge and HTTP redirect")), + ("443/TCP", _("Secure Headscale API and web interface")), ("41641/UDP", _("Peer-to-peer VPN data")), ] for port, desc_text in ports_data: @@ -815,19 +881,53 @@ def fetch_ip(): dialog.present() def _show_tailscale_instructions(self): - self._show_simple_instructions(_("Tailscale Instructions"), [ - (_("1. Criar Conta"), _("Registro no Tailscale"), _("Crie sua conta gratuita para começar a gerenciar sua malha VPN."), "network-wired-symbolic", _("Abrir Site"), "https://login.tailscale.com"), - (_("2. Login no Host"), _("Vincular este dispositivo"), _("Utilize o botão 'Login / Connect' na aba anterior para autorizar este PC."), "network-wired-symbolic", None, None), - (_("3. Painel Admin"), _("Gerenciar Máquinas"), _("Visualize e autorize as máquinas conectadas à sua rede no painel oficial."), "preferences-system-symbolic", _("Abrir Painel"), "https://login.tailscale.com/admin/machines") - ]) + self._show_simple_instructions( + _("Tailscale Instructions"), + [ + ( + _("1. Create Account"), + _("Tailscale Registration"), + _("Create your free account to start managing your VPN mesh."), + "network-wired-symbolic", + _("Open Site"), + "https://login.tailscale.com", + ), + (_("2. Login on Host"), _("Link this device"), _("Use the 'Login / Connect' button on the previous tab to authorize this PC."), "network-wired-symbolic", None, None), + ( + _("3. Admin Panel"), + _("Manage Machines"), + _("View and authorize the machines connected to your network in the official panel."), + "preferences-system-symbolic", + _("Open Panel"), + "https://login.tailscale.com/admin/machines", + ), + ], + ) def _show_zerotier_instructions(self): - self._show_simple_instructions(_("ZeroTier Instructions"), [ - (_("1. Criar Conta"), _("Acessar ZeroTier Central"), _("Registre-se para criar e gerenciar suas redes virtuais."), "network-wired-symbolic", _("Abrir Portal"), "https://my.zerotier.com"), - (_("2. Autenticação"), _("Gerar Token de API"), _("Vá em 'Account Settings' e crie um novo 'API Access Token'."), "view-reveal-symbolic", _("Gerar Token"), "https://my.zerotier.com/account"), - (_("3. Configuração"), _("Vincular Rede"), _("Cole o Token no campo correspondente e clique em 'Save Token & Create Network'."), "edit-copy-symbolic", None, None), - (_("4. Administração"), _("Autorizar Membros"), _("Clique no Network ID da sua rede para gerenciar e autorizar novos dispositivos."), "network-server-symbolic", _("Minhas Redes"), "https://my.zerotier.com/network") - ]) + self._show_simple_instructions( + _("ZeroTier Instructions"), + [ + (_("1. Create Account"), _("Access ZeroTier Central"), _("Sign up to create and manage your virtual networks."), "network-wired-symbolic", _("Open Portal"), "https://my.zerotier.com"), + ( + _("2. Authentication"), + _("Generate API Token"), + _("Go to 'Account Settings' and create a new 'API Access Token'."), + "view-reveal-symbolic", + _("Generate Token"), + "https://my.zerotier.com/account", + ), + (_("3. Configuration"), _("Link Network"), _("Paste the Token in the matching field and click 'Save Token & Create Network'."), "edit-copy-symbolic", None, None), + ( + _("4. Administration"), + _("Authorize Members"), + _("Click your network's Network ID to manage and authorize new devices."), + "network-server-symbolic", + _("My Networks"), + "https://my.zerotier.com/network", + ), + ], + ) def _show_simple_instructions(self, title_text, items): """Show instructions using the premium Adwaita style.""" @@ -841,10 +941,7 @@ def _show_simple_instructions(self, title_text, items): # Header bar hb = Adw.HeaderBar() - hb.set_title_widget(Adw.WindowTitle.new( - title_text, - _("Step-by-step guide") - )) + hb.set_title_widget(Adw.WindowTitle.new(title_text, _("Step-by-step guide"))) toolbar_view.add_top_bar(hb) # Scrollable content @@ -854,8 +951,8 @@ def _show_simple_instructions(self, title_text, items): clamp = Adw.Clamp() clamp.set_maximum_size(600) - for m in ['top', 'bottom', 'start', 'end']: - getattr(clamp, f'set_margin_{m}')(24) + for m in ["top", "bottom", "start", "end"]: + getattr(clamp, f"set_margin_{m}")(24) main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=20) @@ -870,20 +967,20 @@ def _show_simple_instructions(self, title_text, items): group = Adw.PreferencesGroup() group.set_title(g_title) - + row = Adw.ActionRow() row.set_title(r_title) row.set_subtitle(r_subtitle) row.add_prefix(create_icon_widget(icon, size=22)) - + if btn_label and btn_url: btn = Gtk.Button(label=btn_label) btn.add_css_class("pill") btn.add_css_class("suggested-action") btn.set_valign(Gtk.Align.CENTER) - btn.connect("clicked", lambda b, u=btn_url: os.system(f"xdg-open {u}")) + btn.connect("clicked", lambda b, u=btn_url: open_uri(u)) row.add_suffix(btn) - + group.add(row) main_box.append(group) @@ -896,36 +993,35 @@ def _show_simple_instructions(self, title_text, items): def _on_logout(self, btn): self.main_window.show_toast(_("Disconnecting...")) - if self.vpn_id == 'tailscale': + if self.vpn_id == "tailscale": + def do_logout(): - subprocess.run(["bigsudo", "tailscale", "logout"]) - GLib.idle_add(lambda: ( - self.main_window.show_toast(_("Tailscale disconnected")), - self._btn_logout.set_visible(False), - self._networks_group.set_visible(False), - self._refresh_networks() - )) + subprocess.run(["bigsudo", "tailscale", "logout"], timeout=30) + GLib.idle_add( + lambda: (self.main_window.show_toast(_("Tailscale disconnected")), self._btn_logout.set_visible(False), self._networks_group.set_visible(False), self._refresh_networks()) + ) + threading.Thread(target=do_logout, daemon=True).start() - elif self.vpn_id == 'zerotier': - if os.path.exists(ZT_TOKEN_FILE): - os.remove(ZT_TOKEN_FILE) + elif self.vpn_id == "zerotier": + _clear_zerotier_api_token() self._logged_in = False self._btn_logout.set_visible(False) self._networks_group.set_visible(False) self.main_window.show_toast(_("ZeroTier token removed")) + def do_stop(): - subprocess.run(["bigsudo", "systemctl", "stop", "zerotier-one"]) + subprocess.run(["bigsudo", "systemctl", "stop", "zerotier-one"], timeout=30) GLib.idle_add(self._refresh_networks) + threading.Thread(target=do_stop, daemon=True).start() - elif self.vpn_id == 'headscale': + elif self.vpn_id == "headscale": + def do_logout(): - subprocess.run(["bigsudo", "tailscale", "logout"]) - GLib.idle_add(lambda: ( - self.main_window.show_toast(_("Disconnected from Headscale")), - self._btn_logout.set_visible(False), - self._networks_group.set_visible(False), - self._refresh_networks() - )) + subprocess.run(["bigsudo", "tailscale", "logout"], timeout=30) + GLib.idle_add( + lambda: (self.main_window.show_toast(_("Disconnected from Headscale")), self._btn_logout.set_visible(False), self._networks_group.set_visible(False), self._refresh_networks()) + ) + threading.Thread(target=do_logout, daemon=True).start() def _refresh_networks(self): @@ -933,45 +1029,38 @@ def _refresh_networks(self): def _fetch_networks(self): rows = [] - if self.vpn_id == 'tailscale': + if self.vpn_id == "tailscale": try: - r = subprocess.run(["tailscale", "status", "--json"], capture_output=True, text=True) + r = subprocess.run(["tailscale", "status", "--json"], capture_output=True, text=True, timeout=10) if r.returncode == 0: data = json.loads(r.stdout) self_node = data.get("Self", {}) - rows.append({ - "title": self_node.get("DNSName", "This device").split(".")[0], - "subtitle": ", ".join(self_node.get("TailscaleIPs", [])), - "icon": "computer-symbolic", "is_self": True - }) + rows.append( + {"title": self_node.get("DNSName", "This device").split(".")[0], "subtitle": ", ".join(self_node.get("TailscaleIPs", [])), "icon": "computer-symbolic", "is_self": True} + ) for k, peer in data.get("Peer", {}).items(): - rows.append({ - "title": peer.get("DNSName", k).split(".")[0], - "subtitle": ", ".join(peer.get("TailscaleIPs", [])), - "icon": "network-workgroup-symbolic", - "online": peer.get("Online", False) - }) + rows.append( + { + "title": peer.get("DNSName", k).split(".")[0], + "subtitle": ", ".join(peer.get("TailscaleIPs", [])), + "icon": "network-workgroup-symbolic", + "online": peer.get("Online", False), + } + ) except Exception: pass try: - if not os.path.exists(ZT_TOKEN_FILE): return - token = open(ZT_TOKEN_FILE).read().strip() - if not token: return - r = subprocess.run(["curl", "-sL", "-H", f"Authorization: token {token}", - "https://api.zerotier.com/api/v1/network"], - capture_output=True, text=True) + token = _get_zerotier_api_token() + if not token: + return + r = subprocess.run(["curl", "-sL", "-H", f"Authorization: token {token}", "https://api.zerotier.com/api/v1/network"], capture_output=True, text=True, timeout=10) stdout = r.stdout.strip() - networks = json.loads(stdout) if (stdout and stdout.startswith('[')) else [] - for net in (networks if isinstance(networks, list) else []): + networks = json.loads(stdout) if (stdout and stdout.startswith("[")) else [] + for net in networks if isinstance(networks, list) else []: nid = net.get("id", "") nname = net.get("config", {}).get("name", nid) - rows.append({ - "title": nname, - "subtitle": f"ID: {nid}", - "icon": "network-wired-symbolic", - "network_id": nid - }) + rows.append({"title": nname, "subtitle": f"ID: {nid}", "icon": "network-wired-symbolic", "network_id": nid}) except Exception: pass @@ -991,7 +1080,6 @@ def _populate_networks(self, rows): else: for r in rows: row = Adw.ActionRow(title=r["title"], subtitle=r.get("subtitle", "")) - icon_name = r.get("icon", "network-wired-symbolic") row.add_prefix(create_icon_widget("preferences-other-symbolic", size=18)) if r.get("is_self"): badge = Gtk.Label(label=_("This device")) @@ -1027,8 +1115,8 @@ def _show_access_info(self, data): scroll = Gtk.ScrolledWindow(vexpand=True) scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) clamp = Adw.Clamp(maximum_size=560) - for m in ['top','bottom','start','end']: - getattr(clamp, f'set_margin_{m}')(16) + for m in ["top", "bottom", "start", "end"]: + getattr(clamp, f"set_margin_{m}")(16) content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16) grp = Adw.PreferencesGroup() @@ -1037,11 +1125,11 @@ def _show_access_info(self, data): ("domain", _("Domain"), "network-server-symbolic"), ("network_id", _("Network ID"), "network-wired-symbolic"), ("auth_key", _("Auth Key (Friends)"), "key-symbolic"), - ("api_key", _("API Key (Admin)"), "dialog-password-symbolic"), ("web_ui", _("Web Interface"), "web-browser-symbolic"), ]: val = data.get(key, "") - if not val: continue + if not val: + continue row = Adw.ActionRow(title=label, subtitle=val) row.add_prefix(create_icon_widget(icon, size=16)) btn = Gtk.Button() @@ -1066,6 +1154,7 @@ def on_save_file(b): msg = "\n".join([f"{k}: {data.get(k)}" for k in data if data.get(k)]) dialog_file = Gtk.FileDialog(title=_("Save Network Information")) dialog_file.set_initial_name("network_info.txt") + def on_save_finish(source, result): try: file_handle = dialog_file.save_finish(result) @@ -1082,18 +1171,20 @@ def on_save_finish(source, result): def on_share(b): msg = _("VPN Network Details:\n\n") for k, l, _icon in [("domain", _("Domain"), ""), ("network_id", _("Network ID"), ""), ("auth_key", _("Auth Key"), "")]: - if data.get(k): msg += f"{l}: {data.get(k)}\n" - + if data.get(k): + msg += f"{l}: {data.get(k)}\n" + import urllib.parse + body = urllib.parse.quote(msg) - os.system(f"xdg-open 'mailto:?subject=VPN Network Info&body={body}'") + open_uri(f"mailto:?subject=VPN Network Info&body={body}") self.main_window.show_toast(_("Opening mail client...")) btn_save = Gtk.Button(label=_("Save")) btn_save.add_css_class("pill") btn_save.add_css_class("suggested-action") btn_save.connect("clicked", lambda b: self.main_window.show_toast(_("Saved to history!"))) - + btn_file = Gtk.Button() btn_file.set_child(Gtk.Box(spacing=8)) btn_file.get_child().append(create_icon_widget("folder-open-symbolic", size=16)) @@ -1155,17 +1246,46 @@ def _build(self): conn_box.set_margin_start(32) conn_box.set_margin_end(32) + # Wizard stepper mirroring the Connect / Status / Previous Networks flow. + conn_box.append(create_wizard_stepper([_("Connect"), _("Status"), _("Previous Networks")], 0)) + hdr = Adw.PreferencesGroup() - hdr.set_title(self.vpn['connect_title']) - hdr.set_description(self.vpn['connect_desc']) - hdr.set_header_suffix(create_icon_widget(self.vpn['icon'], size=24)) + hdr.set_title(self.vpn["connect_title"]) + hdr.set_description(self.vpn["connect_desc"]) + hdr.set_header_suffix(create_icon_widget(self.vpn["icon"], size=24)) conn_box.append(hdr) + # Two columns: connection fields (left) + "what you'll need" helper (right). fields_group = Adw.PreferencesGroup() fields_group.set_title(_("Connection Details")) - conn_box.append(fields_group) + fields_group.set_hexpand(True) self._build_connect_fields(fields_group) + helper = create_helper_card( + _("What you'll need"), + "dialog-question-symbolic", + [ + ("network-server-symbolic", _("Server domain"), _("The address of your VPN server, e.g. vpn.yourcompany.com.")), + ("dialog-password-symbolic", _("Auth key"), _("A key generated on the server to connect this device.")), + ("network-wireless-symbolic", _("Working internet"), _("An internet connection is required to establish the link.")), + ], + ) + helper.set_valign(Gtk.Align.START) + helper.set_size_request(280, -1) + + columns = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=18) + columns.append(fields_group) + columns.append(helper) + conn_box.append(columns) + + # Privacy reassurance banner (credentials stay local). + privacy = Gtk.Label() + privacy.set_markup(_('Your credentials stay only on this device and are used exclusively to authenticate on the private network.')) + privacy.add_css_class("dim-label") + privacy.set_wrap(True) + privacy.set_halign(Gtk.Align.START) + conn_box.append(privacy) + self._c_progress = ProgressRow(on_show_log=self._show_connect_log) conn_box.append(self._c_progress) self._c_log = LogView() @@ -1213,7 +1333,7 @@ def _build(self): btn_ref.set_halign(Gtk.Align.END) btn_ref.set_hexpand(True) btn_ref.connect("clicked", lambda b: self._refresh_status()) - + hdr2.append(btn_ref) status_box.append(hdr2) @@ -1225,16 +1345,12 @@ def _build(self): self._peers_store = Gtk.ListStore(str, str, str, str, str, str, str) self._peers_tree = Gtk.TreeView(model=self._peers_store) - cols = [ - _("Auth"), _("ID"), _("Name"), - _("Managed IP"), _("Last Seen"), - _("Version"), _("Physical IP") - ] + cols = [_("Auth"), _("ID"), _("Name"), _("Managed IP"), _("Last Seen"), _("Version"), _("Physical IP")] for i, col in enumerate(cols): rend = Gtk.CellRendererText() c = Gtk.TreeViewColumn(col, rend, text=i) c.set_resizable(True) - c.set_expand(i in [2, 3]) # Expand Name and IP + c.set_expand(i in [2, 3]) # Expand Name and IP self._peers_tree.append_column(c) scroll_tree = Gtk.ScrolledWindow(vexpand=True) @@ -1244,7 +1360,7 @@ def _build(self): # Prominent API Token buttons self._btn_api_box = Gtk.Box(spacing=12, halign=Gtk.Align.CENTER, margin_top=12) - self._btn_api_box.set_visible(self.vpn_id == 'zerotier') + self._btn_api_box.set_visible(self.vpn_id == "zerotier") self._btn_api_token = Gtk.Button(label=_("Set ZeroTier API Token")) self._btn_api_token.add_css_class("suggested-action") @@ -1254,7 +1370,7 @@ def _build(self): self._btn_get_token = Gtk.Button(label=_("Create API Access Token")) self._btn_get_token.add_css_class("pill") - self._btn_get_token.connect("clicked", lambda b: os.system("xdg-open https://my.zerotier.com/account")) + self._btn_get_token.connect("clicked", lambda b: open_uri("https://my.zerotier.com/account")) self._btn_api_box.append(self._btn_get_token) status_box.append(self._btn_api_box) @@ -1281,7 +1397,9 @@ def _build(self): header = Adw.HeaderBar() header.set_show_start_title_buttons(False) header.set_show_end_title_buttons(False) - switcher = Adw.ViewSwitcher(stack=stack) + switcher = Adw.InlineViewSwitcher() + switcher.set_stack(stack) + switcher.set_display_mode(Adw.InlineViewSwitcherDisplayMode.BOTH) header.set_title_widget(switcher) toolbar.add_top_bar(header) toolbar.set_content(stack) @@ -1289,27 +1407,27 @@ def _build(self): self.set_child(toolbar) def _on_instructions_clicked(self, btn): - if self.vpn_id == 'headscale': + if self.vpn_id == "headscale": self._show_headscale_instructions() - elif self.vpn_id == 'tailscale': + elif self.vpn_id == "tailscale": self._show_tailscale_instructions() - elif self.vpn_id == 'zerotier': + elif self.vpn_id == "zerotier": self._show_zerotier_instructions() def _build_connect_fields(self, group): - if self.vpn_id == 'headscale': + if self.vpn_id == "headscale": self._e_domain = Adw.EntryRow(title=_("Server Domain (e.g. vpn.ruscher.org)")) self._e_key = Adw.PasswordEntryRow(title=_("Auth Key")) group.add(self._e_domain) group.add(self._e_key) - elif self.vpn_id == 'tailscale': + elif self.vpn_id == "tailscale": self._e_server = Adw.EntryRow(title=_("Login Server (leave empty for tailscale.com)")) self._e_key = Adw.PasswordEntryRow(title=_("Auth Key")) group.add(self._e_server) group.add(self._e_key) - elif self.vpn_id == 'zerotier': + elif self.vpn_id == "zerotier": self._e_netid = Adw.EntryRow(title=_("Network ID (16 characters)")) self._e_netid.set_tooltip_text(_("e.g. a1b2c3d4e5f6a7b8")) group.add(self._e_netid) @@ -1318,7 +1436,7 @@ def _build_connect_fields(self, group): btn = Gtk.Button(label=_("Open")) btn.add_css_class("flat") btn.set_valign(Gtk.Align.CENTER) - btn.connect("clicked", lambda b: os.system("xdg-open https://my.zerotier.com/network")) + btn.connect("clicked", lambda b: open_uri("https://my.zerotier.com/network")) link.add_suffix(btn) group.add(link) @@ -1329,69 +1447,76 @@ def _prefill_from_history(self): history = _load_history() # Find the latest entry for this vpn last_entry = next((h for h in reversed(history) if h.get("vpn") == self.vpn_id), None) - if not last_entry: return + if not last_entry: + return - if self.vpn_id == 'headscale': - if hasattr(self, '_e_domain'): self._e_domain.set_text(last_entry.get("domain", "")) - if hasattr(self, '_e_key'): self._e_key.set_text(last_entry.get("auth_key", "")) - elif self.vpn_id == 'tailscale': - if hasattr(self, '_e_server'): self._e_server.set_text(last_entry.get("domain", "") if last_entry.get("domain") != "tailscale.com" else "") - if hasattr(self, '_e_key'): self._e_key.set_text(last_entry.get("auth_key", "")) - elif self.vpn_id == 'zerotier': - if hasattr(self, '_e_netid'): self._e_netid.set_text(last_entry.get("network_id", "")) + if self.vpn_id == "headscale": + if hasattr(self, "_e_domain"): + self._e_domain.set_text(last_entry.get("domain", "")) + if hasattr(self, "_e_key"): + self._e_key.set_text(_entry_secret(last_entry, "auth_key")) + elif self.vpn_id == "tailscale": + if hasattr(self, "_e_server"): + self._e_server.set_text(last_entry.get("domain", "") if last_entry.get("domain") != "tailscale.com" else "") + if hasattr(self, "_e_key"): + self._e_key.set_text(_entry_secret(last_entry, "auth_key")) + elif self.vpn_id == "zerotier": + if hasattr(self, "_e_netid"): + self._e_netid.set_text(last_entry.get("network_id", "")) def _prompt_api_token(self, btn=None): dialog = Adw.Window(transient_for=self.main_window) dialog.set_modal(True) dialog.set_title(_("ZeroTier API Token")) dialog.set_default_size(400, 250) - + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16) box.set_margin_top(24) box.set_margin_bottom(24) box.set_margin_start(24) box.set_margin_end(24) - + lbl = Gtk.Label(label=_("Enter your API Token to view managed devices.")) lbl.set_wrap(True) box.append(lbl) - + entry = Adw.PasswordEntryRow(title=_("API Token")) - if os.path.exists(ZT_TOKEN_FILE): - try: entry.set_text(open(ZT_TOKEN_FILE).read().strip()) - except: pass - + saved_token = _get_zerotier_api_token() + if saved_token: + entry.set_text(saved_token) + grp = Adw.PreferencesGroup() grp.add(entry) box.append(grp) - + btn_box = Gtk.Box(spacing=12) btn_box.set_halign(Gtk.Align.CENTER) - + btn_save = Gtk.Button(label=_("Save & Refresh")) btn_save.add_css_class("suggested-action") btn_save.add_css_class("pill") - + btn_close = Gtk.Button(label=_("Close")) btn_close.add_css_class("pill") - + def on_save(btn): token = entry.get_text().strip() if token: - os.makedirs(os.path.dirname(ZT_TOKEN_FILE), exist_ok=True) - with open(ZT_TOKEN_FILE, 'w') as f: - f.write(token) - self.main_window.show_toast(_("Token saved")) - self._refresh_status() + try: + _set_zerotier_api_token(token) + self.main_window.show_toast(_("Token saved in the system keyring")) + self._refresh_status() + except SecretStoreUnavailable: + self.main_window.show_toast(_("System keyring is unavailable. Token was not saved.")) dialog.close() - + btn_save.connect("clicked", on_save) btn_close.connect("clicked", lambda b: dialog.close()) - + btn_box.append(btn_save) btn_box.append(btn_close) box.append(btn_box) - + dialog.set_content(box) dialog.present() @@ -1403,7 +1528,7 @@ def _on_connect(self, btn): self._c_progress.update(0.05, _("Starting...")) self._c_log.clear() - if self.vpn_id == 'headscale': + if self.vpn_id == "headscale": domain = self._e_domain.get_text().strip() key = self._e_key.get_text().strip() if not domain or not key: @@ -1411,36 +1536,27 @@ def _on_connect(self, btn): self._c_done(False) return inputs = ["2\n", f"{domain}\n", f"{key}\n", "n\n"] - script = 'create-network_headscale.sh' + script = "create-network_headscale.sh" - elif self.vpn_id == 'tailscale': - server = self._e_server.get_text().strip() if hasattr(self, '_e_server') else '' - key = self._e_key.get_text().strip() if hasattr(self, '_e_key') else '' + elif self.vpn_id == "tailscale": + key = self._e_key.get_text().strip() if hasattr(self, "_e_key") else "" if not key: self.main_window.show_toast(_("Auth Key is required")) self._c_done(False) return # Using Login option (1) then Auth Key option (2) - # Future: support custom server in script + # Future: support a custom server (self._e_server) in the script inputs = ["1\n", "2\n", f"{key}\n", "\n", "0\n"] - script = 'create-network_tailscale.sh' + script = "create-network_tailscale.sh" - elif self.vpn_id == 'zerotier': - nid = self._e_netid.get_text().strip() if hasattr(self, '_e_netid') else '' + elif self.vpn_id == "zerotier": + nid = self._e_netid.get_text().strip() if hasattr(self, "_e_netid") else "" if not nid: self.main_window.show_toast(_("Network ID is required")) self._c_done(False) return inputs = ["2\n", f"{nid}\n"] - script = 'create-network_zerotier.sh' - - phases = [ - (0.1, ['preparando','checking']), - (0.3, ['instalando','tailscale']), - (0.6, ['conectando','connecting','login','up','join']), - (0.85, ['verificando','verif','status']), - (0.95, ['✅','success','concluí','established']), - ] + script = "create-network_zerotier.sh" def run(): spath = _get_script(script) @@ -1448,24 +1564,25 @@ def run(): os.chmod(spath, 0o755) except Exception: pass - proc = subprocess.Popen( - ["bigsudo", spath], - stdin=subprocess.PIPE, stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, text=True, bufsize=1 - ) + # LC_ALL=C: parse locale-independent BRP_* markers, not localized prose. + proc = subprocess.Popen(["bigsudo", "env", "LC_ALL=C", "LANGUAGE=", spath], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1) for s in inputs: try: proc.stdin.write(s) proc.stdin.flush() except Exception: break + phase = 0.1 for line in proc.stdout: - clean = re.sub(r"\x1b\[[0-9;]*[mK]", "", line).strip() - self._c_log.append(line.strip()) - lower = clean.lower() - for frac, keys in phases: - if any(k in lower for k in keys): - self._c_progress.update(frac, clean[:80]) + kind = parse_script_line(line) + if kind[0] == "data": + continue + if kind[0] == "phase": + phase = kind[1] + self._c_progress.update(phase, "") + elif kind[0] == "text" and kind[1]: + self._c_log.append(kind[1]) + self._c_progress.update(phase, kind[1][:80]) code = proc.wait() GLib.idle_add(lambda: self._c_done(code == 0)) @@ -1478,22 +1595,22 @@ def _c_done(self, success): if success: self._c_progress.update(1.0, _("✅ Connected!")) self._c_lbl.set_label(_("Establish Connection")) - + # Save ALL filled fields to history entry = {"vpn": self.vpn_id} - if self.vpn_id == 'headscale': + if self.vpn_id == "headscale": entry["domain"] = self._e_domain.get_text().strip() - if hasattr(self, '_e_key'): + if hasattr(self, "_e_key"): key_val = self._e_key.get_text().strip() if key_val: entry["auth_key"] = key_val - elif self.vpn_id == 'tailscale': + elif self.vpn_id == "tailscale": entry["domain"] = self._e_server.get_text().strip() or "tailscale.com" - if hasattr(self, '_e_key'): + if hasattr(self, "_e_key"): key_val = self._e_key.get_text().strip() if key_val: entry["auth_key"] = key_val - elif self.vpn_id == 'zerotier': + elif self.vpn_id == "zerotier": entry["network_id"] = self._e_netid.get_text().strip() _save_history(entry) @@ -1514,10 +1631,10 @@ def _on_tab_changed(self, stack, pspec): def _refresh_status(self): if self._fetching: return - + # Show banner if ZeroTier and no token - if self.vpn_id == 'zerotier': - self._status_banner.set_revealed(not os.path.exists(ZT_TOKEN_FILE)) + if self.vpn_id == "zerotier": + self._status_banner.set_revealed(not _has_zerotier_api_token()) else: self._status_banner.set_revealed(False) @@ -1530,19 +1647,23 @@ def _fetch_status(self): now = time.time() * 1000 def fmt_time(ts): - if not ts: return "" + if not ts: + return "" try: ts = float(ts) except (ValueError, TypeError): return str(ts) diff = (now - ts) / 1000 - if diff < 60: return _("Just now") - if diff < 3600: return f"{int(diff/60)} min" - if diff < 86400: return f"{int(diff/3600)} hr" - return f"{int(diff/86400)} days" - - if self.vpn_id in ('headscale', 'tailscale'): - r = subprocess.run(["tailscale", "status", "--json"], capture_output=True, text=True) + if diff < 60: + return _("Just now") + if diff < 3600: + return f"{int(diff / 60)} min" + if diff < 86400: + return f"{int(diff / 3600)} hr" + return f"{int(diff / 86400)} days" + + if self.vpn_id in ("headscale", "tailscale"): + r = subprocess.run(["tailscale", "status", "--json"], capture_output=True, text=True, timeout=10) if r.returncode == 0: data = json.loads(r.stdout) self_node = data.get("Self", {}) @@ -1550,64 +1671,63 @@ def fmt_time(ts): name = self_node.get("DNSName", "").split(".")[0] or "This device" # Auth, ID, Name, IP, LastSeen, Ver, Phys rows.append(("✅", _("Self"), name, ips, _("Online"), "", "")) - + for peer in data.get("Peer", {}).values(): ip = ", ".join(peer.get("TailscaleIPs", [])) h = peer.get("DNSName", "").split(".")[0] uid = str(peer.get("UserID", "")) - # Tailscale JSON LastHandshake is ISO str, complex to parse without datetime. + # Tailscale JSON LastHandshake is ISO str, complex to parse without datetime. # Simplifying for now: last = _("Online") if peer.get("Online") else _("Offline") rows.append(("✅", uid, h, ip, last, "", "")) - elif self.vpn_id == 'zerotier': - token_path = ZT_TOKEN_FILE - if os.path.exists(token_path): - token = open(token_path).read().strip() - if token: - net_id_input = self._e_netid.get_text().strip() - if net_id_input: - # Se temos um ID manual, focamos apenas nele - networks = [{"id": net_id_input}] - else: - # Senão, listamos todas as redes deste token - nets_r = subprocess.run( - ["curl", "-sL", "-m", "10", "-H", f"Authorization: token {token}", "https://api.zerotier.com/api/v1/network"], - capture_output=True, text=True) - nets_stdout = nets_r.stdout.strip() - networks = json.loads(nets_stdout) if (nets_stdout and nets_stdout.startswith('[')) else [] - - for net in (networks if isinstance(networks, list) else []): - nid = net.get("id", "") - mem_r = subprocess.run( - ["curl", "-sL", "-m", "10", "-H", f"Authorization: token {token}", - f"https://api.zerotier.com/api/v1/network/{nid}/member"], - capture_output=True, text=True) - mem_stdout = mem_r.stdout.strip() - members = [] - if mem_stdout and mem_stdout.startswith('['): - try: members = json.loads(mem_stdout) - except: pass - - for m in (members if isinstance(members, list) else []): - cfg = m.get("config", {}) - mid = m.get("nodeId", "") - name = m.get("name") or m.get("description") or mid - ip_list = cfg.get("ipAssignments") or [] - ip = ip_list[0] if ip_list else "" - authorized = cfg.get("authorized", False) - auth_icon = "✅" if authorized else "❌" - last_seen = m.get("lastSeen", 0) - seen_str = fmt_time(last_seen) if last_seen else "" - if m.get("online", False): - seen_str = _("Online") - ver = m.get("clientVersion", "") - phys = m.get("physicalAddress", "") - rows.append((auth_icon, mid, name, ip, seen_str, ver, phys)) - + elif self.vpn_id == "zerotier": + token = _get_zerotier_api_token() + if token: + net_id_input = self._e_netid.get_text().strip() + if net_id_input: + # If we have a manual ID, focus only on it + networks = [{"id": net_id_input}] + else: + # Otherwise, list every network for this token + nets_r = subprocess.run( + ["curl", "-sL", "-m", "10", "-H", f"Authorization: token {token}", "https://api.zerotier.com/api/v1/network"], capture_output=True, text=True, timeout=10 + ) + nets_stdout = nets_r.stdout.strip() + networks = json.loads(nets_stdout) if (nets_stdout and nets_stdout.startswith("[")) else [] + + for net in networks if isinstance(networks, list) else []: + nid = net.get("id", "") + mem_r = subprocess.run( + ["curl", "-sL", "-m", "10", "-H", f"Authorization: token {token}", f"https://api.zerotier.com/api/v1/network/{nid}/member"], capture_output=True, text=True, timeout=10 + ) + mem_stdout = mem_r.stdout.strip() + members = [] + if mem_stdout and mem_stdout.startswith("["): + try: + members = json.loads(mem_stdout) + except Exception: + pass + + for m in members if isinstance(members, list) else []: + cfg = m.get("config", {}) + mid = m.get("nodeId", "") + name = m.get("name") or m.get("description") or mid + ip_list = cfg.get("ipAssignments") or [] + ip = ip_list[0] if ip_list else "" + authorized = cfg.get("authorized", False) + auth_icon = "✅" if authorized else "❌" + last_seen = m.get("lastSeen", 0) + seen_str = fmt_time(last_seen) if last_seen else "" + if m.get("online", False): + seen_str = _("Online") + ver = m.get("clientVersion", "") + phys = m.get("physicalAddress", "") + rows.append((auth_icon, mid, name, ip, seen_str, ver, phys)) + # If rows still empty, try CLI fallbacks if not rows: - r = subprocess.run(["zerotier-cli", "-j", "listnetworks"], capture_output=True, text=True) + r = subprocess.run(["zerotier-cli", "-j", "listnetworks"], capture_output=True, text=True, timeout=10) if r.returncode == 0 and r.stdout.strip(): try: nets = json.loads(r.stdout) @@ -1617,17 +1737,18 @@ def fmt_time(ts): n_status = net.get("status", "") n_ips = ", ".join(net.get("assignedAddresses", [])) rows.append(("❓", n_id, n_name, n_ips, n_status, "", "")) - except: pass - + except Exception: + pass + # If STILL empty, show peers (last resort) if not rows: - rp = subprocess.run(["zerotier-cli", "listpeers"], capture_output=True, text=True) + rp = subprocess.run(["zerotier-cli", "listpeers"], capture_output=True, text=True, timeout=10) if rp.returncode == 0: for line in rp.stdout.splitlines()[1:]: p = line.split() if len(p) >= 3: # - rows.append(("🌐", p[0], _("Peer"), p[7] if len(p)>7 else "", p[4], p[1], "")) + rows.append(("🌐", p[0], _("Peer"), p[7] if len(p) > 7 else "", p[4], p[1], "")) except Exception as e: print(f"Status fetch error: {e}") finally: @@ -1638,7 +1759,7 @@ def fmt_time(ts): def _update_status_ui(self, rows): self._peers_store.clear() if not rows: - if self.vpn_id == 'zerotier' and not os.path.exists(ZT_TOKEN_FILE): + if self.vpn_id == "zerotier" and not _has_zerotier_api_token(): self._peers_store.append(("ℹ️", _("API Token Missing"), _("See banner above"), "", "", "", "")) else: self._peers_store.append(("ℹ️", _("No devices found"), _("Try refreshing..."), "", "", "", "")) @@ -1656,11 +1777,7 @@ def _refresh_history(self): history = _load_history() if not history: - sp = Adw.StatusPage( - title=_("No previous networks"), - icon_name="document-open-recent-symbolic", - description=_("Your created/connected networks will appear here.") - ) + sp = Adw.StatusPage(title=_("No previous networks"), icon_name="document-open-recent-symbolic", description=_("Your created/connected networks will appear here.")) self._hist_list.append(sp) return @@ -1723,10 +1840,12 @@ def _refresh_history(self): (_("Web UI"), "web_ui", "network-wired-symbolic"), (_("VPN"), "vpn", "network-wired-symbolic"), ]: - val = entry.get(key, "") + is_secret = key in _HISTORY_SECRET_KEYS + val = _entry_secret(entry, key) if is_secret else entry.get(key, "") if not val: continue - row = Adw.ActionRow(title=label, subtitle=val) + subtitle = _("Stored in system keyring") if is_secret and _history_secret_key(entry, key) else val + row = Adw.ActionRow(title=label, subtitle=subtitle) row.add_prefix(create_icon_widget(icon, size=14)) btn_c = Gtk.Button() btn_c.set_child(create_icon_widget("edit-copy-symbolic", size=12)) @@ -1740,49 +1859,50 @@ def _refresh_history(self): def _reconnect_from_history(self, entry): vpn_id = entry.get("vpn", self.vpn_id) - vpn_name = VPN_META.get(vpn_id, {}).get('name', vpn_id) + vpn_name = VPN_META.get(vpn_id, {}).get("name", vpn_id) # Navigate to the respective VPN provider's Connect page in main_window - if hasattr(self.main_window, '_apply_vpn_selection'): + if hasattr(self.main_window, "_apply_vpn_selection"): self.main_window._apply_vpn_selection(vpn_id) # After applying, navigate to connect_private and switch to connect tab - GLib.idle_add(lambda: self.main_window.navigate_to('connect_private')) + GLib.idle_add(lambda: self.main_window.navigate_to("connect_private")) # Fill the form fields after the new view is built def _fill_form(): - if hasattr(self.main_window, 'connect_private_view'): + if hasattr(self.main_window, "connect_private_view"): view = self.main_window.connect_private_view # ConnectPage is the child of PrivateNetworkView (Adw.Bin) - page = view.get_child() if hasattr(view, 'get_child') else None - if page and hasattr(page, '_stack'): + page = view.get_child() if hasattr(view, "get_child") else None + if page and hasattr(page, "_stack"): page._stack.set_visible_child_name("connect") - if vpn_id == 'headscale': - if hasattr(page, '_e_domain'): + if vpn_id == "headscale": + if hasattr(page, "_e_domain"): page._e_domain.set_text(entry.get("domain", "")) - if hasattr(page, '_e_key'): - page._e_key.set_text(entry.get("auth_key", "")) - elif vpn_id == 'tailscale': - if hasattr(page, '_e_server'): + if hasattr(page, "_e_key"): + page._e_key.set_text(_entry_secret(entry, "auth_key")) + elif vpn_id == "tailscale": + if hasattr(page, "_e_server"): page._e_server.set_text(entry.get("domain", "") if entry.get("domain") != "tailscale.com" else "") - if hasattr(page, '_e_key'): - page._e_key.set_text(entry.get("auth_key", "")) - elif vpn_id == 'zerotier': - if hasattr(page, '_e_netid'): + if hasattr(page, "_e_key"): + page._e_key.set_text(_entry_secret(entry, "auth_key")) + elif vpn_id == "zerotier": + if hasattr(page, "_e_netid"): page._e_netid.set_text(entry.get("network_id", "")) self.main_window.show_toast(_("Form filled for {}").format(vpn_name)) + GLib.timeout_add(300, _fill_form) else: # Fallback: just switch to connect tab locally self._stack.set_visible_child_name("connect") - if vpn_id == 'headscale' and hasattr(self, '_e_domain'): + if vpn_id == "headscale" and hasattr(self, "_e_domain"): self._e_domain.set_text(entry.get("domain", "")) - if hasattr(self, '_e_key'): - self._e_key.set_text(entry.get("auth_key", "")) - elif vpn_id == 'tailscale' and hasattr(self, '_e_server'): + if hasattr(self, "_e_key"): + self._e_key.set_text(_entry_secret(entry, "auth_key")) + elif vpn_id == "tailscale" and hasattr(self, "_e_server"): self._e_server.set_text(entry.get("domain", "") if entry.get("domain") != "tailscale.com" else "") - if hasattr(self, '_e_key'): - self._e_key.set_text(entry.get("auth_key", "")) - elif vpn_id == 'zerotier' and hasattr(self, '_e_netid'): + if hasattr(self, "_e_key"): + self._e_key.set_text(_entry_secret(entry, "auth_key")) + elif vpn_id == "zerotier" and hasattr(self, "_e_netid"): self._e_netid.set_text(entry.get("network_id", "")) self.main_window.show_toast(_("Form filled for {}").format(vpn_name)) @@ -1798,10 +1918,7 @@ def _edit_history_entry(self, entry): toolbar_view = Adw.ToolbarView() hb = Adw.HeaderBar() - hb.set_title_widget(Adw.WindowTitle.new( - _("Edit Network Entry"), - vpn_name - )) + hb.set_title_widget(Adw.WindowTitle.new(_("Edit Network Entry"), vpn_name)) toolbar_view.add_top_bar(hb) box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16) @@ -1812,44 +1929,44 @@ def _edit_history_entry(self, entry): grp = Adw.PreferencesGroup() grp.set_title(_("Connection Details")) - grp.set_header_suffix(create_icon_widget(VPN_META.get(vpn_id, {}).get('icon', 'network-wired-symbolic'), size=20)) + grp.set_header_suffix(create_icon_widget(VPN_META.get(vpn_id, {}).get("icon", "network-wired-symbolic"), size=20)) edit_fields = {} - if vpn_id == 'headscale': + if vpn_id == "headscale": e_domain = Adw.EntryRow(title=_("Domain")) e_domain.set_text(entry.get("domain", "")) grp.add(e_domain) - edit_fields['domain'] = e_domain + edit_fields["domain"] = e_domain e_key = Adw.PasswordEntryRow(title=_("Auth Key")) - e_key.set_text(entry.get("auth_key", "")) + e_key.set_text(_entry_secret(entry, "auth_key")) grp.add(e_key) - edit_fields['auth_key'] = e_key + edit_fields["auth_key"] = e_key - elif vpn_id == 'tailscale': + elif vpn_id == "tailscale": e_domain = Adw.EntryRow(title=_("Login Server")) e_domain.set_text(entry.get("domain", "") if entry.get("domain") != "tailscale.com" else "") grp.add(e_domain) - edit_fields['domain'] = e_domain + edit_fields["domain"] = e_domain e_key = Adw.PasswordEntryRow(title=_("Auth Key")) - e_key.set_text(entry.get("auth_key", "")) + e_key.set_text(_entry_secret(entry, "auth_key")) grp.add(e_key) - edit_fields['auth_key'] = e_key + edit_fields["auth_key"] = e_key - elif vpn_id == 'zerotier': + elif vpn_id == "zerotier": e_netid = Adw.EntryRow(title=_("Network ID")) e_netid.set_text(entry.get("network_id", "")) grp.add(e_netid) - edit_fields['network_id'] = e_netid + edit_fields["network_id"] = e_netid # Web UI (read-only display, editable) if entry.get("web_ui"): e_webui = Adw.EntryRow(title=_("Web UI")) e_webui.set_text(entry.get("web_ui", "")) grp.add(e_webui) - edit_fields['web_ui'] = e_webui + edit_fields["web_ui"] = e_webui box.append(grp) @@ -1871,7 +1988,7 @@ def on_save(b): val = widget.get_text().strip() if val: updated[key] = val - elif key == 'domain' and vpn_id == 'tailscale': + elif key == "domain" and vpn_id == "tailscale": updated[key] = "tailscale.com" _update_history(entry.get("id"), updated) self._refresh_history() @@ -1890,18 +2007,16 @@ def on_save(b): dialog.present() def _delete_history_entry(self, entry): - d = Adw.MessageDialog( - transient_for=self.main_window, - heading=_("Delete entry?"), - body=_("Remove this network from history?") - ) + d = Adw.MessageDialog(transient_for=self.main_window, heading=_("Delete entry?"), body=_("Remove this network from history?")) d.add_response("cancel", _("Cancel")) d.add_response("delete", _("Delete")) d.set_response_appearance("delete", Adw.ResponseAppearance.DESTRUCTIVE) + def on_resp(dlg, resp): if resp == "delete": _delete_history(entry.get("id")) self._refresh_history() + d.connect("response", on_resp) d.present() @@ -1931,10 +2046,7 @@ def _show_headscale_instructions(self): # Header bar hb = Adw.HeaderBar() - hb.set_title_widget(Adw.WindowTitle.new( - _("Setup Instructions"), - _("Step-by-step guide to join a private network") - )) + hb.set_title_widget(Adw.WindowTitle.new(_("Setup Instructions"), _("Step-by-step guide to join a private network"))) toolbar_view.add_top_bar(hb) # Scrollable content @@ -1944,8 +2056,8 @@ def _show_headscale_instructions(self): clamp = Adw.Clamp() clamp.set_maximum_size(650) - for m in ['top', 'bottom', 'start', 'end']: - getattr(clamp, f'set_margin_{m}')(16) + for m in ["top", "bottom", "start", "end"]: + getattr(clamp, f"set_margin_{m}")(16) main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=20) @@ -1954,7 +2066,7 @@ def _show_headscale_instructions(self): # ════════════════════════════════════════════ g1 = Adw.PreferencesGroup() g1.set_title(_("Steps to join Headscale")) - + r1_1 = Adw.ActionRow() r1_1.set_title(_("Step 1: Get credentials")) r1_1.set_subtitle(_("Ask the network administrator for the Server Domain and Auth Key.")) @@ -1984,18 +2096,38 @@ def _show_headscale_instructions(self): dialog.present() def _show_tailscale_instructions(self): - self._show_simple_instructions(_("Tailscale Instructions"), [ - (_("1. Criar Conta"), _("Acessar Tailscale"), _("Todos os participantes precisam de uma conta (Google, GitHub, etc)."), "network-wired-symbolic", _("Criar Conta"), "https://login.tailscale.com"), - (_("2. Convite"), _("Solicitar Acesso"), _("O administrador deve compartilhar o nó ou convidar seu e-mail para a rede."), "text-x-generic-symbolic", None, None), - (_("3. Conectar"), _("Estabelecer Ligação"), _("Com o convite aceito, use a aba anterior para se conectar."), "view-refresh-symbolic", None, None) - ]) + self._show_simple_instructions( + _("Tailscale Instructions"), + [ + ( + _("1. Create Account"), + _("Access Tailscale"), + _("All participants need an account (Google, GitHub, etc)."), + "network-wired-symbolic", + _("Create Account"), + "https://login.tailscale.com", + ), + (_("2. Invitation"), _("Request Access"), _("The administrator must share the node or invite your email to the network."), "text-x-generic-symbolic", None, None), + (_("3. Connect"), _("Establish Connection"), _("Once the invitation is accepted, use the previous tab to connect."), "view-refresh-symbolic", None, None), + ], + ) def _show_zerotier_instructions(self): - self._show_simple_instructions(_("ZeroTier Instructions"), [ - (_("1. Identificação"), _("Obter Network ID"), _("Solicite o ID de 16 caracteres ao dono da rede."), "preferences-other-symbolic", None, None), - (_("2. Ingressar"), _("Digitar ID"), _("Insira o ID na aba 'Connect' e clique em 'Establish Connection'."), "edit-copy-symbolic", None, None), - (_("3. Autorização"), _("Aguardar Aprovação"), _("O administrador precisa marcar a opção 'Auth' para o seu PC no painel dele."), "network-idle-symbolic", _("Painel ZT"), "https://my.zerotier.com") - ]) + self._show_simple_instructions( + _("ZeroTier Instructions"), + [ + (_("1. Identification"), _("Get Network ID"), _("Request the 16-character ID from the network owner."), "preferences-other-symbolic", None, None), + (_("2. Join"), _("Enter ID"), _("Enter the ID on the 'Connect' tab and click 'Establish Connection'."), "edit-copy-symbolic", None, None), + ( + _("3. Authorization"), + _("Wait for Approval"), + _("The administrator must check the 'Auth' option for your PC in their panel."), + "network-idle-symbolic", + _("ZT Panel"), + "https://my.zerotier.com", + ), + ], + ) def _show_simple_instructions(self, title_text, items): """Show instructions using the premium Adwaita style.""" @@ -2009,10 +2141,7 @@ def _show_simple_instructions(self, title_text, items): # Header bar hb = Adw.HeaderBar() - hb.set_title_widget(Adw.WindowTitle.new( - title_text, - _("Step-by-step guide") - )) + hb.set_title_widget(Adw.WindowTitle.new(title_text, _("Step-by-step guide"))) toolbar_view.add_top_bar(hb) # Scrollable content @@ -2022,8 +2151,8 @@ def _show_simple_instructions(self, title_text, items): clamp = Adw.Clamp() clamp.set_maximum_size(600) - for m in ['top', 'bottom', 'start', 'end']: - getattr(clamp, f'set_margin_{m}')(24) + for m in ["top", "bottom", "start", "end"]: + getattr(clamp, f"set_margin_{m}")(24) main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=20) @@ -2038,20 +2167,20 @@ def _show_simple_instructions(self, title_text, items): group = Adw.PreferencesGroup() group.set_title(g_title) - + row = Adw.ActionRow() row.set_title(r_title) row.set_subtitle(r_subtitle) row.add_prefix(create_icon_widget(icon, size=22)) - + if btn_label and btn_url: btn = Gtk.Button(label=btn_label) btn.add_css_class("pill") btn.add_css_class("suggested-action") btn.set_valign(Gtk.Align.CENTER) - btn.connect("clicked", lambda b, u=btn_url: os.system(f"xdg-open {u}")) + btn.connect("clicked", lambda b, u=btn_url: open_uri(u)) row.add_suffix(btn) - + group.add(row) main_box.append(group) diff --git a/src/big_remote_play/ui/sunshine_preferences.py b/src/big_remote_play/ui/sunshine_preferences.py new file mode 100644 index 0000000..1479cbe --- /dev/null +++ b/src/big_remote_play/ui/sunshine_preferences.py @@ -0,0 +1,797 @@ +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") +from gi.repository import Gtk, Gdk, Adw, GLib +from pathlib import Path +import os + +from big_remote_play.utils.i18n import _ +from big_remote_play.utils.icons import create_icon_widget +from big_remote_play.utils.secure_io import secure_write_text +import socket +from big_remote_play.utils.system_check import SystemCheck +from big_remote_play.host.sunshine_manager import SunshineHost +from big_remote_play.utils.uri import open_path + + +class SunshineConfigManager: + def __init__(self): + self.config_dir = Path.home() / ".config" / "big-remoteplay" / "sunshine" + self.config_file = self.config_dir / "sunshine.conf" + self.config_dir.mkdir(parents=True, exist_ok=True) + self.config = {} + self.load() + + def load(self): + self.config = {} + if self.config_file.exists(): + try: + with open(self.config_file, "r") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + if "=" in line: + parts = line.split("=", 1) + if len(parts) == 2: + self.config[parts[0].strip()] = parts[1].strip() + except Exception as e: + print(f"Error loading Sunshine config: {e}") + + def save(self): + try: + # sunshine.conf holds credentials: owner-only file/dir. + body = "".join(f"{key} = {value}\n" for key, value in self.config.items()) + secure_write_text(str(self.config_file), body) + except Exception as e: + print(f"Error saving Sunshine config: {e}") + + def get(self, key, default=None): + return self.config.get(key, str(default)) + + def set(self, key, value): + self.config[key] = str(value) + self.save() + + +class SunshinePreferencesPage(Adw.PreferencesPage): + def __init__(self, **kwargs): + self.main_config = kwargs.pop("main_config", None) + super().__init__(**kwargs) + self.set_title("Sunshine") # product name — never translated + self.set_icon_name("preferences-desktop-remote-desktop-symbolic") + + self.config = SunshineConfigManager() + # Shares the same config dir as SunshineHost; used for live API apply/reload. + self.sunshine = SunshineHost() + + # Standard Adwaita Layout: Multiple Groups in one Page + # This creates a long scrollable page with sections. + self.setup_groups() + + def setup_groups(self): + # Categories mapping to (Function, Icon, Description) + categories = { + _("General"): (self.get_general_options(), "preferences-system-symbolic", _("Server general settings")), + _("Input"): (self.get_input_options(), "input-keyboard-symbolic", _("Keyboard, mouse and gamepad")), + _("Audio/Video"): (self.get_av_options(), "video-display-symbolic", _("Resolution, bitrate and quality")), + _("Network"): (self.get_network_options(), "network-workgroup-symbolic", _("Ports and connectivity")), + _("Config Files"): ([], "document-properties-symbolic", _("Configuration files and logs")), + _("Advanced"): (self.get_advanced_options(), "preferences-other-symbolic", _("Advanced options")), + # Encoders + _("NVIDIA NVENC"): (self.get_nvenc_options(), "media-flash-symbolic", _("NVIDIA Encoder")), + _("Intel QuickSync"): (self.get_qsv_options(), "media-memory-symbolic", _("Intel Encoder")), + _("AMD AMF"): (self.get_amf_options(), "media-tape-symbolic", _("AMD Encoder")), + _("VideoToolbox"): (self.get_vt_options(), "media-optical-symbolic", _("Apple Encoder")), + _("VA-API"): (self.get_vaapi_options(), "media-view-subtitles-symbolic", _("VA-API Encoder (Linux)")), + _("Software"): (self.get_software_options(), "software-update-available-symbolic", _("CPU Encoding")), + } + + for title, (options, icon, desc) in categories.items(): + # Create Group + group = Adw.PreferencesGroup() + group.set_title(title) + group.set_description(desc) + + has_content = False + + if title == _("Config Files"): + self.setup_config_files_tab(group) + has_content = True + else: + if title == _("General"): + self.setup_maintenance_tab(group) + has_content = True + + if options: + for opt in options: + row = self.create_option_row(opt) + if row: + group.add(row) + has_content = True + + if not has_content and title != _("Config Files"): + # Add placeholder row + row = Adw.ActionRow() + row.set_title(_("No options available")) + row.set_sensitive(False) + group.add(row) + + self.add(group) + + def create_option_row(self, opt): + # Unpack option tuple + # Now supports optional description: (key, label, type, default, choices, description) + if len(opt) == 6: + key, label, type_, default, choices, description = opt + else: + key, label, type_, default, choices = opt + description = None + + current_val = self.config.get(key, default) + + row = None + if type_ == "switch": + row = Adw.SwitchRow() + row.set_title(label) + active = current_val.lower() in ("true", "enabled", "1", "on") + row.set_active(active) + row.set_active(active) + + def on_switch_change(w, p): + val = str(w.get_active()).lower() + self.config.set(key, val) + + # Sync 'stream_audio' with Main Config Host Audio + if key == "stream_audio" and self.main_config: + h = self.main_config.get("host", {}) + h["audio"] = w.get_active() + self.main_config.set("host", h) + + row.connect("notify::active", on_switch_change) + + elif type_ == "password": + row = Adw.PasswordEntryRow() + row.set_title(label) + row.set_text(str(current_val)) + row.connect("changed", lambda w: self.config.set(key, w.get_text())) + + elif type_ == "entry": + row = Adw.EntryRow() + row.set_title(label) + row.set_text(str(current_val)) + row.connect("changed", lambda w: self.config.set(key, w.get_text())) + + elif type_ == "spin": + row = Adw.ActionRow() + row.set_title(label) + spin = Gtk.SpinButton.new_with_range(0, 100000, 1) # Generic range + try: + val = float(current_val) + except Exception: + val = float(default) if default else 0 + spin.set_value(val) + spin.connect("value-changed", lambda w: self.config.set(key, int(w.get_value()))) + spin.set_valign(Gtk.Align.CENTER) + row.add_suffix(spin) + + elif type_ == "combo": + row = Adw.ComboRow() + row.set_title(label) + + # Check if choices are strings or tuples (key, label) + if choices and isinstance(choices[0], tuple): + display_values = [c[1] for c in choices] + keys = [c[0] for c in choices] + else: + display_values = choices + keys = choices + + model = Gtk.StringList() + for c in display_values: + model.append(c) + row.set_model(model) + + # Find index + try: + # current_val should be the key + idx = 0 + if current_val in keys: + idx = keys.index(current_val) + except Exception: + idx = 0 + row.set_selected(idx) + + row.connect("notify::selected", lambda w, p, k=keys, key_name=key: self.config.set(key_name, k[w.get_selected()])) + + if row and description: + if isinstance(row, Adw.ActionRow): + row.set_subtitle(description) + else: + row.set_tooltip_text(description) + + return row + + def setup_config_files_tab(self, group): + # Determine paths + # Sunshine defaults usually: + # apps.json: alongside sunshine.conf or in config_dir + # sunshine.log: in config_dir + # credentials: in config_dir (often credentials.json) + # pkey: sunshine.key (in config_dir) + # cert: sunshine.cert (in config_dir) + # state: sunshine_state.json (in config_dir) + + # We can check specific config keys if they exist, otherwise assume defaults + + def get_path(key, default_filename): + base = self.config.config_dir + val = self.config.get(key) + if val and val != str(None): + p = Path(val) + if p.is_absolute(): + return p + else: + return base / p + return base / default_filename + + files = [ + (_("Apps File"), get_path("apps_file", "apps.json"), _("The file where current apps of Sunshine are stored.")), + (_("Credentials File"), get_path("credentials_file", "credentials.json"), _("Store Username/Password separately from Sunshine's state file.")), + (_("Logfile Path"), get_path("log_path", "sunshine.log"), _("The file where the current logs of Sunshine are stored.")), + ( + _("Private Key"), + get_path("pkey", "sunshine.key"), + _("The private key used for the web UI and Moonlight client pairing. For best compatibility, this should be an RSA-2048 private key."), + ), + ( + _("Certificate"), + get_path("cert", "sunshine.cert"), + _("The certificate used for the web UI and Moonlight client pairing. For best compatibility, this should have an RSA-2048 public key."), + ), + (_("State File"), get_path("state_file", "sunshine_state.json"), _("The file where current state of Sunshine is stored")), + (_("Configuration File"), self.config.config_file, _("The main configuration file for Sunshine.")), + ] + + for name, path, desc in files: + row = Adw.ActionRow() + row.set_title(name) + row.set_subtitle(str(path)) + row.set_tooltip_text(desc) + + # Check if file exists + if not path.exists(): + row.add_css_class("error") + row.add_prefix(create_icon_widget("preferences-other-symbolic", size=16)) + + btn = Gtk.Button() + btn.set_child(create_icon_widget("folder-open-symbolic", size=16)) + btn.set_valign(Gtk.Align.CENTER) + btn.add_css_class("flat") + btn.connect("clicked", lambda _, p=path: self.open_file(p)) + + # Check if file exists, if not, try to create default + if not path.exists(): + try: + if "credentials" in str(path): + with open(path, "w") as f: + f.write("[]") + elif "state" in str(path): + with open(path, "w") as f: + f.write("{}") + except Exception: + pass + + row.add_suffix(btn) + group.add(row) + + def open_file(self, path): + # Gtk.show_uri (not a shell xdg-open) for correct Wayland activation. + try: + open_path(self, path) + except Exception: + pass + + def setup_maintenance_tab(self, group): + """ + Setup maintenance actions + """ + sys_check = SystemCheck() + runtime_ok, runtime_detail = sys_check.check_sunshine_runtime() + + row = Adw.ActionRow() + row.set_title(_("Sunshine Runtime")) + if runtime_ok: + row.set_subtitle(runtime_detail or _("Sunshine is available.")) + row.add_prefix(create_icon_widget("network-wired-symbolic", size=24)) + else: + row.set_subtitle(runtime_detail or _("Sunshine is not available.")) + row.add_css_class("error") + row.add_prefix(create_icon_widget("network-offline-symbolic", size=24)) + group.add(row) + + # Live config via the Sunshine API (only meaningful while it runs). The + # file (SunshineConfigManager) stays the pre-start source of truth. + live_row = Adw.ActionRow() + live_row.set_title(_("Apply to Running Server")) + live_row.set_subtitle(_("Push these settings to Sunshine and restart it (no manual stop).")) + apply_btn = Gtk.Button(label=_("Apply")) + apply_btn.add_css_class("suggested-action") + apply_btn.set_valign(Gtk.Align.CENTER) + apply_btn.connect("clicked", self.on_apply_live_clicked) + live_row.add_suffix(apply_btn) + group.add(live_row) + + reload_row = Adw.ActionRow() + reload_row.set_title(_("Reload from Running Server")) + reload_row.set_subtitle(_("Read the server's current configuration into these settings.")) + reload_btn = Gtk.Button(label=_("Reload")) + reload_btn.set_valign(Gtk.Align.CENTER) + reload_btn.connect("clicked", self.on_reload_live_clicked) + reload_row.add_suffix(reload_btn) + group.add(reload_row) + + # NOTE: server password change/reset lives in the body (Server → + # Management → Server Password). Not duplicated here. + + def _api_auth(self): + user = self.config.get("sunshine_user", "") + password = self.config.get("sunshine_password", "") + return (user, password) if user and password else None + + def _toast(self, widget, message): + root = widget.get_root() + if root and hasattr(root, "add_toast"): + root.add_toast(Adw.Toast.new(message)) + elif root and hasattr(root, "show_toast"): + root.show_toast(message) + return False + + def on_apply_live_clicked(self, btn): + if not self.sunshine.is_running(): + self._toast(btn, _("Sunshine is not running. Settings are saved to file.")) + return + import threading + + auth = self._api_auth() + # Credentials are managed separately; do not push them as config keys. + settings = {k: v for k, v in self.config.config.items() if k not in ("sunshine_user", "sunshine_password", "credentials")} + + def work(): + ok = self.sunshine.save_config(settings, auth=auth) + if ok: + self.sunshine.restart_via_api(auth=auth) + GLib.idle_add(self._toast, btn, _("Settings applied; server restarting.") if ok else _("Failed to apply settings (check credentials).")) + + threading.Thread(target=work, daemon=True).start() + + def on_reload_live_clicked(self, btn): + if not self.sunshine.is_running(): + self._toast(btn, _("Sunshine is not running.")) + return + import threading + + auth = self._api_auth() + + def work(): + cfg = self.sunshine.get_config(auth=auth) + GLib.idle_add(self._apply_reloaded_config, cfg, btn) + + threading.Thread(target=work, daemon=True).start() + + def _apply_reloaded_config(self, cfg, btn): + if not cfg: + self._toast(btn, _("Could not read server configuration.")) + return False + for key, value in cfg.items(): + if key in ("status", "platform", "version"): + continue + self.config.config[key] = str(value) + self.config.save() + self._toast(btn, _("Configuration reloaded. Reopen preferences to see updated values.")) + return False + + def get_general_options(self): + return [ + ( + "locale", + _("Locale"), + "combo", + "en", + ["bg", "cs", "de", "en", "en_GB", "en_US", "es", "fr", "hu", "it", "ja", "ko", "pl", "pt", "pt_BR", "ru", "sv", "tr", "uk", "vi", "zh", "zh_TW"], + _("The locale used for Sunshine's user interface."), + ), + ("sunshine_name", _("Sunshine Name"), "entry", socket.gethostname(), None, _("The name of the Sunshine instance as seen by clients.")), + ("sunshine_user", _("Sunshine User"), "entry", "", None, _("Username for API access (Monitoring)")), + ("sunshine_password", _("Sunshine Password"), "password", "", None, _("Password for API access (Monitoring)")), + ( + "min_log_level", + _("Log Level"), + "combo", + "2", + [("0", "Verbose"), ("1", "Debug"), ("2", "Info"), ("3", "Warning"), ("4", "Error"), ("5", "Fatal"), ("6", "None")], + _("The minimum log level printed to standard out"), + ), + ("notify_pre_releases", _("Pre-Release Notifications"), "switch", "false", None, _("Whether to be notified of new pre-release versions of Sunshine")), + ("system_tray", _("Enable System Tray"), "switch", "true", None, _("Show icon in system tray and display desktop notifications")), + ] + + def get_network_options(self): + return [ + ("upnp", _("UPnP"), "switch", "false", None, _("Automatically configure port forwarding for streaming over the Internet")), + ("address_family", _("Address Family"), "combo", "ipv4", [("ipv4", "IPv4"), ("both", "IPv4 + IPv6")], _("Set the address family used by Sunshine")), + ("bind_address", _("Bind Address"), "entry", "0.0.0.0", None, _("IP address to bind the service to")), + ( + "port", + _("Port (Moonlight)"), + "spin", + "47989", + None, + _("Set the family of ports used by Sunshine.\nTCP: 47984, 47989, 48010\nUDP: 47998 - 48012\nThe Web UI port stays local by default."), + ), + ( + "origin_web_ui_allowed", + _("Web UI Access"), + "combo", + "lan", + [("pc", "Localhost"), ("lan", "LAN"), ("wan", "WAN")], + _("The origin of the remote endpoint address that is not denied access to Web UI.\nWarning: Exposing the Web UI to the internet is a security risk! Proceed at your own risk!"), + ), + ("external_ip", _("External IP"), "entry", "", None, _("If no external IP address is given, Sunshine will automatically detect external IP")), + ( + "csrf_allowed_origins", + _("Trusted Web UI Origins"), + "entry", + "", + None, + _("Comma-separated trusted HTTPS origins allowed to call Sunshine state-changing API endpoints. Leave empty to use Sunshine's built-in localhost defaults."), + ), + ( + "lan_encryption_mode", + _("LAN Encryption"), + "combo", + "0", + [("0", _("Disabled")), ("1", _("Mode 1")), ("2", _("Mode 2"))], + _("This determines when encryption will be used when streaming over your local network. Encryption can reduce streaming performance, particularly on less powerful hosts and clients."), + ), + ( + "wan_encryption_mode", + _("WAN Encryption"), + "combo", + "1", + [("0", _("Disabled")), ("1", _("Mode 1")), ("2", _("Mode 2"))], + _("This determines when encryption will be used when streaming over the Internet. Encryption can reduce streaming performance, particularly on less powerful hosts and clients."), + ), + ("ping_timeout", _("Ping Timeout (ms)"), "spin", "10000", None, _("How long to wait in milliseconds for data from Moonlight before shutting down the stream")), + ( + "packetsize", + _("Packet Size"), + "spin", + "0", + None, + _("Limit UDP packet size to avoid fragmentation on low-MTU VPN links. Use 0 for Sunshine's default."), + ), + ] + + def get_input_options(self): + return [ + ("controller", _("Enable Gamepad Input"), "switch", "true", None, _("Allows guests to control the host system with a gamepad / controller")), + ( + "gamepad", + _("Emulated Gamepad Type"), + "combo", + "auto", + [("auto", _("Automatic")), ("x360", "Xbox 360"), ("ds4", "DualShock 4"), ("ds5", "DualSense (Linux)"), ("switch", "Nintendo Switch"), ("xone", "Xbox One")], + _("Choose which type of gamepad to emulate on the host"), + ), + ("motion_as_ds4", _("Motion as DS4"), "switch", "true", None, _("Emulate motion controls as DS4")), + ("touchpad_as_ds4", _("Touchpad as DS4"), "switch", "true", None, _("Emulate touchpad as DS4")), + ("ds4_back_as_touchpad_click", _("Back Button as Touchpad Click"), "switch", "true", None, _("Use Back button for touchpad click on DS4")), + ("ds5_inputtino_randomize_mac", _("Randomize MAC (DS5)"), "switch", "true", None, _("Randomize virtual MAC address for DS5")), + ("back_button_timeout", _("Home/Guide Timeout (ms)"), "spin", "-1", None, _("Hold Back/Select to emulate Guide button. < 0 to disable.")), + ("keyboard", _("Enable Keyboard Input"), "switch", "true", None, _("Allows guests to control the host system with the keyboard")), + ("mouse", _("Enable Mouse Input"), "switch", "true", None, _("Allows guests to control the host system with the mouse")), + ("always_send_scancodes", _("Always Send Scancodes"), "switch", "true", None, _("Always send raw key scancodes")), + ("key_rightalt_to_key_win", _("Map Right Alt to Windows"), "switch", "false", None, _("Make Sunshine think the Right Alt key is the Windows key")), + ("high_resolution_scrolling", _("High Resolution Scrolling"), "switch", "true", None, _("Pass through high resolution scroll events from Moonlight clients")), + ] + + def get_monitors(self): + monitors = [] + is_wayland = os.environ.get("XDG_SESSION_TYPE") == "wayland" + try: + display = Gdk.Display.get_default() + if display: + monitor_list = display.get_monitors() + for i in range(monitor_list.get_n_items()): + monitor = monitor_list.get_item(i) + name = monitor.get_connector() + if name: + manufacturer = monitor.get_manufacturer() or "" + model = monitor.get_model() or "" + label_parts = [] + if manufacturer: + label_parts.append(manufacturer) + if model: + label_parts.append(model) + label = " ".join(label_parts) if label_parts else "Monitor" + + # Value logic: Wayland uses 0, 1, 2... | X11 uses HDMI-A-1, etc. + val = str(i) if is_wayland else name + full_label = f"{label} ({name})" + monitors.append((full_label, val)) + except Exception as e: + print(f"Error getting monitors: {e}") + + if not monitors: + return [("auto", _("Auto / Primary"))] + + return monitors + + def get_av_options(self): + monitors = self.get_monitors() + # NOTE: validating the configured output_name against detected monitors is + # not yet wired up; the option list below only offers detected monitors. + # If current_output is set but not in detected monitors, we should potentially clear it + # or default to auto/first one to avoid Error 503. + # However, if detection is flaky, maybe we should keep it? + # But here the user issue IS that an invalid one persists. + # So providing only detected ones (plus auto) is safer. + + return [ + ("audio_sink", _("Audio Sink"), "entry", "", None, _("Listing all available audio sinks is possible by running `pactl list short sinks` (PulseAudio) or `wpctl status` (PipeWire).")), + ("stream_audio", _("Stream Audio"), "switch", "true", None, _("Whether to stream audio or not. Disabling this can be useful for streaming headless displays as second monitors.")), + ("adapter_name", _("Graphics Adapter"), "entry", "", None, _("Specific GPU to use. Default is usually correct.")), + ( + "output_name", + _("Display Name"), + "combo", + monitors[0][0] if monitors else "auto", + monitors, + _("Select the display monitor to capture. Corresponds to the output connector name (e.g., DP-1, HDMI-A-1)."), + ), + ( + "max_bitrate", + _("Maximum Bitrate"), + "spin", + "0", + None, + _("The maximum bitrate (in Kbps) that Sunshine will encode the stream at. If set to 0, it will always use the bitrate requested by Moonlight."), + ), + ( + "min_fps", + _("Minimum FPS Target"), + "spin", + "0", + None, + _("The lowest effective FPS a stream can reach. A value of 0 is treated as roughly half of the stream's FPS. A setting of 20 is recommended if you stream 24 or 30fps content."), + ), + ] + + def get_advanced_options(self): + return [ + ( + "fec_percentage", + _("FEC Percentage"), + "spin", + "20", + None, + _("Percentage of error correcting packets per data packet in each video frame. Higher values can correct for more network packet loss, but at the cost of increasing bandwidth usage."), + ), + ( + "qp", + _("Quantization Parameter"), + "spin", + "28", + None, + _("Some devices may not support Constant Bit Rate. For those devices, QP is used instead. Higher value means more compression, but less quality."), + ), + ( + "min_threads", + _("Minimum CPU Thread Count"), + "spin", + "4", + None, + _( + "Increasing the value slightly reduces encoding efficiency, but the tradeoff is usually worth it to gain the use of more CPU cores for encoding. The ideal value is the lowest value that can reliably encode at your desired streaming settings on your hardware." + ), + ), + ( + "hevc_mode", + _("HEVC Support"), + "combo", + "0", + [("0", _("Disabled")), ("1", _("Advertised (Recommended)")), ("2", "Main 10"), ("3", "Main 10 + HDR")], + _("Allows the client to request HEVC Main or HEVC Main10 video streams. HEVC is more CPU-intensive to encode, so enabling this may reduce performance when using software encoding."), + ), + ( + "av1_mode", + _("AV1 Support"), + "combo", + "0", + [("0", _("Disabled")), ("1", _("Advertised (Recommended)")), ("2", "Main 10"), ("3", "Main 10 + HDR")], + _("Allows the client to request AV1 Main 8-bit or 10-bit video streams. AV1 is more CPU-intensive to encode, so enabling this may reduce performance when using software encoding."), + ), + ( + "capture", + _("Force a Specific Capture Method"), + "combo", + "auto", + [("auto", _("Autodetect (Recommended)")), ("nvfbc", "NvFBC"), ("wlr", "wlroots"), ("kms", "KMS"), ("x11", "X11"), ("portal", "XDG Desktop Portal")], + _("On automatic mode Sunshine will use the first one that works. NvFBC requires patched nvidia drivers."), + ), + ( + "encoder", + _("Force a Specific Encoder"), + "combo", + "auto", + [("auto", _("Autodetect")), ("nvenc", "NVIDIA NVENC"), ("vaapi", "VA-API"), ("software", "Software"), ("quicksync", "Intel QuickSync"), ("amdvce", "AMD AMF")], + _( + "Force a specific encoder, otherwise Sunshine will select the best available option. Note: If you specify a hardware encoder on Windows, it must match the GPU where the display is connected." + ), + ), + ] + + def get_nvenc_options(self): + return [ + ( + "nvenc_preset", + _("Performance Preset"), + "combo", + "1", + [("1", "P1 (Fastest)"), ("2", "P2"), ("3", "P3"), ("4", "P4 (Default)"), ("5", "P5"), ("6", "P6"), ("7", "P7 (Best Quality)")], + _( + "Higher numbers improve compression (quality at given bitrate) at the cost of increased encoding latency. Recommended to change only when limited by network or decoder, otherwise similar effect can be accomplished by increasing bitrate." + ), + ), + ( + "nvenc_twopass", + _("Two-Pass Mode"), + "combo", + "quarter_res", + [("disabled", _("Disabled")), ("quarter_res", _("Quarter Resolution")), ("full_res", _("Full Resolution"))], + _( + "Adds preliminary encoding pass. This allows to detect more motion vectors, better distribute bitrate across the frame and more strictly adhere to bitrate limits. Disabling it is not recommended since this can lead to occasional bitrate overshoot and subsequent packet loss." + ), + ), + ("nvenc_spatial_aq", _("Spatial AQ"), "switch", "false", None, _("Assign higher QP values to flat regions of the video. Recommended to enable when streaming at lower bitrates.")), + ( + "nvenc_vbv_increase", + _("Single-frame VBV/HRD percentage increase"), + "spin", + "0", + None, + _( + "By default sunshine uses single-frame VBV/HRD, which means any encoded video frame size is not expected to exceed requested bitrate divided by requested frame rate. Relaxing this restriction can be beneficial and act as low-latency variable bitrate, but may also lead to packet loss if the network doesn't have buffer headroom to handle bitrate spikes. Maximum accepted value is 400, which corresponds to 5x increased encoded video frame upper size limit." + ), + ), + ( + "nvenc_h264_cavlc", + _("Prefer CAVLC over CABAC in H.264"), + "switch", + "false", + None, + _("Simpler form of entropy coding. CAVLC needs around 10% more bitrate for same quality. Only relevant for really old decoding devices."), + ), + ] + + def get_qsv_options(self): + return [ + ("qsv_preset", _("QuickSync Preset"), "combo", "medium", ["veryfast", "faster", "fast", "medium", "slow", "slower", "slowest"], _("Performance preset")), + ("qsv_coder", _("QuickSync Coder (H264)"), "combo", "auto", [("auto", _("Auto")), ("cabac", "CABAC"), ("cavlc", "CAVLC")], _("Entropy coding mode")), + ("qsv_slow_hevc", _("Allow Slow HEVC Encoding"), "switch", "false", None, _("This can enable HEVC encoding on older Intel GPUs, at the cost of higher GPU usage and worse performance.")), + ] + + def get_amf_options(self): + return [ + ( + "amd_usage", + _("AMF Usage"), + "combo", + "ultralowlatency", + [ + ("transcoding", "Transcoding"), + ("webcam", "Webcam"), + ("lowlatency_high_quality", "Low Latency High Quality"), + ("lowlatency", "Low Latency"), + ("ultralowlatency", "Ultra Low Latency"), + ], + _( + "This sets the base encoding profile. All options presented below will override a subset of the usage profile, but there are additional hidden settings applied that cannot be configured elsewhere." + ), + ), + ( + "amd_rc", + _("AMF Rate Control"), + "combo", + "vbr_latency", + [("cbr", "CBR"), ("cqp", "CQP"), ("vbr_latency", "VBR Latency"), ("vbr_peak", "VBR Peak")], + _( + "This controls the rate control method to ensure we are not exceeding the client bitrate target. 'cqp' is not suitable for bitrate targeting, and other options besides 'vbr_latency' depend on HRD Enforcement to help constrain bitrate overflows." + ), + ), + ( + "amd_enforce_hrd", + _("AMF Hypothetical Reference Decoder (HRD) Enforcement"), + "switch", + "false", + None, + _( + "Increases the constraints on rate control to meet HRD model requirements. This greatly reduces bitrate overflows, but may cause encoding artifacts or reduced quality on certain cards." + ), + ), + ( + "amd_quality", + _("AMF Quality"), + "combo", + "balanced", + [("speed", _("Speed")), ("balanced", _("Balanced")), ("quality", _("Quality"))], + _("This controls the balance between encoding speed and quality."), + ), + ("amd_preanalysis", _("AMF Preanalysis"), "switch", "false", None, _("This enables rate-control preanalysis, which may increase quality at the expense of increased encoding latency.")), + ( + "amd_vbaq", + _("AMF Variance Based Adaptive Quantization (VBAQ)"), + "switch", + "true", + None, + _( + "The human visual system is typically less sensitive to artifacts in highly textured areas. In VBAQ mode, pixel variance is used to indicate the complexity of spatial textures, allowing the encoder to allocate more bits to smoother areas. Enabling this feature leads to improvements in subjective visual quality with some content." + ), + ), + ( + "amd_coder", + _("AMF Coder (H264)"), + "combo", + "auto", + [("auto", _("Auto")), ("cabac", "CABAC"), ("cavlc", "CAVLC")], + _("Allows you to select the entropy encoding to prioritize quality or encoding speed. H.264 only."), + ), + ] + + def get_vt_options(self): + return [ + ("vt_coder", _("VideoToolbox Coder"), "combo", "auto", [("auto", _("Auto")), ("cabac", "CABAC"), ("cavlc", "CAVLC")], _("Entropy coding mode")), + ( + "vt_software", + _("VideoToolbox Software Encoding"), + "combo", + "auto", + [("auto", _("Auto")), ("disabled", _("Disabled")), ("allowed", _("Allowed")), ("forced", _("Forced"))], + _("Allow fallback to software encoding"), + ), + ("vt_realtime", _("VideoToolbox Realtime Encoding"), "switch", "true", None, _("Realtime encoding priority")), + ] + + def get_vaapi_options(self): + return [ + ( + "vaapi_strict_rc_buffer", + _("Strictly enforce frame bitrate limits for H.264/HEVC on AMD GPUs"), + "switch", + "false", + None, + _("Enabling this option can avoid dropped frames over the network during scene changes, but video quality may be reduced during motion."), + ), + ] + + def get_software_options(self): + return [ + ( + "sw_preset", + _("SW Presets"), + "combo", + "superfast", + ["ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow"], + _("Optimize the trade-off between encoding speed (encoded frames per second) and compression efficiency (quality per bit in the bitstream). Defaults to superfast."), + ), + ( + "sw_tune", + _("SW Tune"), + "combo", + "zerolatency", + [("film", "Film"), ("animation", "Animation"), ("grain", "Grain"), ("stillimage", "Still Image"), ("fastdecode", "Fast Decode"), ("zerolatency", "Zero Latency")], + _("Tuning options, which are applied after the preset. Defaults to zerolatency."), + ), + ] diff --git a/usr/share/big-remote-play/utils/__init__.py b/src/big_remote_play/utils/__init__.py similarity index 100% rename from usr/share/big-remote-play/utils/__init__.py rename to src/big_remote_play/utils/__init__.py diff --git a/usr/share/big-remote-play/utils/audio.py b/src/big_remote_play/utils/audio.py similarity index 82% rename from usr/share/big-remote-play/utils/audio.py rename to src/big_remote_play/utils/audio.py index 5cdd4f5..ba5096a 100644 --- a/usr/share/big-remote-play/utils/audio.py +++ b/src/big_remote_play/utils/audio.py @@ -1,7 +1,9 @@ +import logging import subprocess from typing import List, Dict, Optional import time -from utils.i18n import _ +from big_remote_play.utils.i18n import _ +_log = logging.getLogger("big-remoteplay") class AudioManager: """ @@ -27,7 +29,7 @@ def get_passive_sinks(self) -> List[Dict[str, str]]: sinks = [] try: # Get sinks with pactl - res = subprocess.run(['pactl', 'list', 'sinks'], capture_output=True, text=True) + res = subprocess.run(['pactl', 'list', 'sinks'], capture_output=True, text=True, timeout=10) if res.returncode != 0: return [] current = {} @@ -50,19 +52,19 @@ def get_passive_sinks(self) -> List[Dict[str, str]]: return valid_sinks except Exception as e: - print(f"Error listing sinks: {e}") + _log.error(f"Error listing sinks: {e}") return [] def get_default_sink(self) -> Optional[str]: try: - res = subprocess.run(['pactl', 'get-default-sink'], capture_output=True, text=True) + res = subprocess.run(['pactl', 'get-default-sink'], capture_output=True, text=True, timeout=10) return res.stdout.strip() if res.returncode == 0 else None - except: return None + except Exception: return None def set_default_sink(self, sink_name: str): try: - subprocess.run(['pactl', 'set-default-sink', sink_name], check=False) - except: pass + subprocess.run(['pactl', 'set-default-sink', sink_name], check=False, timeout=10) + except Exception: pass def enable_streaming_audio(self, host_sink: str, guest_only: bool = False) -> bool: """ @@ -75,23 +77,23 @@ def enable_streaming_audio(self, host_sink: str, guest_only: bool = False) -> bo hardware_devices = self.get_passive_sinks() if hardware_devices: host_sink = hardware_devices[0]['name'] - print(f"Host sink was virtual or null, fallback to hardware: {host_sink}") + _log.info(f"Host sink was virtual or null, fallback to hardware: {host_sink}") else: - print("ERROR: No hardware audio device found.") + _log.error("ERROR: No hardware audio device found.") return False # Clear before creating to avoid duplicates self.disable_streaming_audio(None) try: - print(f"Enabling Isolated Audio -> Sink: SunshineGameSink (Guest Only: {guest_only})") + _log.info(f"Enabling Isolated Audio -> Sink: SunshineGameSink (Guest Only: {guest_only})") # 1. Create Null Sink subprocess.run([ 'pactl', 'load-module', 'module-null-sink', 'sink_name=SunshineGameSink', 'sink_properties=device.description=SunshineGameSink' - ], check=True) + ], check=True, timeout=10) # 2. Add Loopback if not guest_only if not guest_only: @@ -100,37 +102,37 @@ def enable_streaming_audio(self, host_sink: str, guest_only: bool = False) -> bo # Check if host_sink is safe if host_sink == "SunshineGameSink": - print("ERROR: Cannot loopback to itself. Creating loopback skipped.") + _log.error("ERROR: Cannot loopback to itself. Creating loopback skipped.") else: - print(f"Adding Loopback to {host_sink} for Host Monitoring") + _log.info(f"Adding Loopback to {host_sink} for Host Monitoring") subprocess.run([ 'pactl', 'load-module', 'module-loopback', 'source=SunshineGameSink.monitor', f'sink={host_sink}', 'sink_properties=device.description=SunshineLoopback', 'latency_msec=60' # Stable latency - ], check=True) + ], check=True, timeout=10) # 3. Small delay and ensure volumes time.sleep(0.5) - subprocess.run(['pactl', 'set-sink-mute', 'SunshineGameSink', '0'], check=False) - subprocess.run(['pactl', 'set-sink-volume', 'SunshineGameSink', '100%'], check=False) + subprocess.run(['pactl', 'set-sink-mute', 'SunshineGameSink', '0'], check=False, timeout=10) + subprocess.run(['pactl', 'set-sink-volume', 'SunshineGameSink', '100%'], check=False, timeout=10) # 4. Set SunshineGameSink as default self.set_default_sink("SunshineGameSink") # 5. Verify creation time.sleep(0.2) - sinks = subprocess.run(['pactl', 'list', 'short', 'sinks'], capture_output=True, text=True).stdout + sinks = subprocess.run(['pactl', 'list', 'short', 'sinks'], capture_output=True, text=True, timeout=10).stdout if 'SunshineGameSink' not in sinks: - print("CRITICAL ERROR: SunshineGameSink was not created!") + _log.error("CRITICAL ERROR: SunshineGameSink was not created!") return False - print(f"Audio Activated: SunshineGameSink (Loopback to {host_sink}: {not guest_only})") + _log.info(f"Audio Activated: SunshineGameSink (Loopback to {host_sink}: {not guest_only})") return True except Exception as e: - print(f"Falha ao ativar streaming de áudio: {e}") + _log.error(f"Failed to enable audio streaming: {e}") self.disable_streaming_audio(host_sink) return False @@ -143,37 +145,37 @@ def set_host_monitoring(self, host_sink: str, enabled: bool) -> bool: # 1. Always try to unload existing loopback first try: - res = subprocess.run(['pactl', 'list', 'short', 'modules'], capture_output=True, text=True) + res = subprocess.run(['pactl', 'list', 'short', 'modules'], capture_output=True, text=True, timeout=10) if res.returncode == 0: for line in res.stdout.splitlines(): if 'SunshineLoopback' in line or 'source=SunshineGameSink.monitor' in line: mod_id = line.split()[0] - print(f"Unloading old loopback: {mod_id}") - subprocess.run(['pactl', 'unload-module', mod_id], check=False) - except: pass + _log.info(f"Unloading old loopback: {mod_id}") + subprocess.run(['pactl', 'unload-module', mod_id], check=False, timeout=10) + except Exception: pass if not enabled: - print("Host monitoring disabled (Muted)") + _log.info("Host monitoring disabled (Muted)") return True # 2. Load Loopback try: # Check if host_sink is safe if host_sink == "SunshineGameSink": - print("ERROR: Cannot loopback to itself.") + _log.error("ERROR: Cannot loopback to itself.") return False - print(f"Loading host loopback monitoring -> {host_sink}") + _log.info(f"Loading host loopback monitoring -> {host_sink}") subprocess.run([ 'pactl', 'load-module', 'module-loopback', 'source=SunshineGameSink.monitor', f'sink={host_sink}', 'sink_properties=device.description=SunshineLoopback', 'latency_msec=60' - ], check=True) + ], check=True, timeout=10) return True except Exception as e: - print(f"Error loading loopback: {e}") + _log.error(f"Error loading loopback: {e}") return False def get_sink_monitor_source(self, sink_name: str) -> Optional[str]: @@ -184,7 +186,7 @@ def get_sink_monitor_source(self, sink_name: str) -> Optional[str]: """ try: # pactl list sources short returns: ID Name ... - res = subprocess.run(['pactl', 'list', 'short', 'sources'], capture_output=True, text=True) + res = subprocess.run(['pactl', 'list', 'short', 'sources'], capture_output=True, text=True, timeout=10) candidate = f"{sink_name}.monitor" for line in res.stdout.splitlines(): @@ -205,7 +207,7 @@ def get_sink_monitor_source(self, sink_name: str) -> Optional[str]: return nm return candidate # Fallback - except: + except Exception: return f"{sink_name}.monitor" def disable_streaming_audio(self, host_sink: str): @@ -223,14 +225,14 @@ def disable_streaming_audio(self, host_sink: str): for app in apps: # Move all apps from virtual sinks back to host_sink if self.is_virtual(app.get('sink_name', '')): - print(f"Restoring {app.get('name')} to {host_sink}") + _log.info(f"Restoring {app.get('name')} to {host_sink}") self.move_app(app['id'], host_sink) except Exception as e: - print(f"Error restoring apps to hardware: {e}") + _log.error(f"Error restoring apps to hardware: {e}") # 2. Unload specific modules try: - res = subprocess.run(['pactl', 'list', 'short', 'modules'], capture_output=True, text=True) + res = subprocess.run(['pactl', 'list', 'short', 'modules'], capture_output=True, text=True, timeout=10) if res.returncode == 0: for line in res.stdout.splitlines(): # Search criteria to unload: @@ -243,10 +245,10 @@ def disable_streaming_audio(self, host_sink: str): 'SunshineLoopback' in line: mod_id = line.split()[0] - print(f"Cleaning audio module: {mod_id}") - subprocess.run(['pactl', 'unload-module', mod_id], check=False) + _log.info(f"Cleaning audio module: {mod_id}") + subprocess.run(['pactl', 'unload-module', mod_id], check=False, timeout=10) except Exception as e: - print(f"Error cleaning modules: {e}") + _log.error(f"Error cleaning modules: {e}") def get_apps(self) -> List[Dict]: """ @@ -256,12 +258,12 @@ def get_apps(self) -> List[Dict]: try: # ID mapping -> Sink Name for reference sinks_map = {} - res_s = subprocess.run(['pactl', 'list', 'short', 'sinks'], capture_output=True, text=True) + res_s = subprocess.run(['pactl', 'list', 'short', 'sinks'], capture_output=True, text=True, timeout=10) for l in res_s.stdout.splitlines(): p = l.split() if len(p) > 1: sinks_map[p[0]] = p[1] - res = subprocess.run(['pactl', 'list', 'sink-inputs'], capture_output=True, text=True) + res = subprocess.run(['pactl', 'list', 'sink-inputs'], capture_output=True, text=True, timeout=10) current = {} for line in res.stdout.splitlines(): @@ -298,8 +300,8 @@ def is_internal(name): def move_app(self, app_id: str, sink_name: str): try: - subprocess.run(['pactl', 'move-sink-input', str(app_id), sink_name], check=False) - except: pass + subprocess.run(['pactl', 'move-sink-input', str(app_id), sink_name], check=False, timeout=10) + except Exception: pass def cleanup(self): """Cleans everything and tries to restore original sound""" diff --git a/usr/share/big-remote-play/utils/config.py b/src/big_remote_play/utils/config.py similarity index 70% rename from usr/share/big-remote-play/utils/config.py rename to src/big_remote_play/utils/config.py index c029d17..979b45a 100644 --- a/usr/share/big-remote-play/utils/config.py +++ b/src/big_remote_play/utils/config.py @@ -4,7 +4,10 @@ import json import os +import tempfile from pathlib import Path +import logging +_log = logging.getLogger("big-remoteplay") class Config: """Configuration manager""" @@ -24,18 +27,29 @@ def load(self): with open(self.config_file, 'r') as f: return json.load(f) except Exception as e: - print(f"Error loading configuration: {e}") + _log.error(f"Error loading configuration: {e}") return self.default_config() else: return self.default_config() def save(self): - """Saves configuration""" + """Saves configuration atomically (write temp + replace) to avoid corruption on crash""" try: - with open(self.config_file, 'w') as f: - json.dump(self.config, f, indent=2) + fd, tmp_path = tempfile.mkstemp(dir=self.config_dir, prefix='.config-', suffix='.tmp') + try: + with os.fdopen(fd, 'w') as f: + json.dump(self.config, f, indent=2) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_path, self.config_file) + except Exception: + try: + os.unlink(tmp_path) + except OSError: + pass + raise except Exception as e: - print(f"Error saving configuration: {e}") + _log.error(f"Error saving configuration: {e}") def get(self, key, default=None): """Gets configuration value""" diff --git a/usr/share/big-remote-play/utils/game_detector.py b/src/big_remote_play/utils/game_detector.py similarity index 96% rename from usr/share/big-remote-play/utils/game_detector.py rename to src/big_remote_play/utils/game_detector.py index 87f64fc..fe96bc0 100644 --- a/usr/share/big-remote-play/utils/game_detector.py +++ b/src/big_remote_play/utils/game_detector.py @@ -1,7 +1,8 @@ -import os +import logging import json import re from pathlib import Path +_log = logging.getLogger("big-remoteplay") class GameDetector: """Detects installed games on various platforms""" @@ -42,7 +43,7 @@ def detect_steam(self): if lib_path.resolve() != (steam_root / 'steamapps').resolve(): library_folders.append(lib_path) except Exception as e: - print(f"Error reading Steam library: {e}") + _log.error(f"Error reading Steam library: {e}") pass for lib in library_folders: @@ -66,7 +67,7 @@ def detect_steam(self): 'cmd': f'steam steam://rungameid/{id_match.group(1)}', 'icon': 'steam' # Placeholder }) - except: + except Exception: pass return games @@ -96,7 +97,7 @@ def detect_lutris(self): 'cmd': f'lutris lutris:rungame/{slug}', 'icon': 'lutris' }) - except: + except Exception: pass return games @@ -186,7 +187,7 @@ def detect_heroic(self): processed_ids.add(app_name) except Exception as e: - print(f"Error reading Heroic {p}: {e}") + _log.error(f"Error reading Heroic {p}: {e}") pass return games diff --git a/src/big_remote_play/utils/i18n.py b/src/big_remote_play/utils/i18n.py new file mode 100644 index 0000000..837b07e --- /dev/null +++ b/src/big_remote_play/utils/i18n.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Internationalization support for Big Remote Play.""" + +import gettext + +from big_remote_play import paths + +DOMAIN = "big-remote-play" +locale_dir = str(paths.LOCALE_DIR) + +# Configure the translation text domain (used by C-side libraries / Gtk too). +gettext.bindtextdomain(DOMAIN, locale_dir) +gettext.textdomain(DOMAIN) + +# Resolve catalogs explicitly so the domain works regardless of process locale +# setup; fallback=True returns the source string when no translation exists. +_translation = gettext.translation(DOMAIN, localedir=locale_dir, fallback=True) +_ = _translation.gettext diff --git a/usr/share/big-remote-play/utils/icons.py b/src/big_remote_play/utils/icons.py similarity index 52% rename from usr/share/big-remote-play/utils/icons.py rename to src/big_remote_play/utils/icons.py index f422a5e..955d5c7 100644 --- a/usr/share/big-remote-play/utils/icons.py +++ b/src/big_remote_play/utils/icons.py @@ -1,11 +1,17 @@ import os -from gi.repository import Gtk, Gio +import gi +gi.require_version('Gtk', '4.0') +gi.require_version('GdkPixbuf', '2.0') +from gi.repository import Gtk, Gio, Gdk, GdkPixbuf -# Base directory for icons: src/icons -# src/utils/icons.py -> src/icons -BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -ICONS_DIR = os.path.join(BASE_DIR, "icons") -IMG_DIR = os.path.join(BASE_DIR, "img") +from big_remote_play import paths + +# Oversample full-color SVG logos so the SVG is rasterized at (at least) HiDPI +# device resolution instead of GTK upscaling a small icon-pipeline bitmap. +_LOGO_OVERSAMPLE = 2 + +ICONS_DIR = str(paths.ICONS_DIR) +IMG_DIR = str(paths.IMG_DIR) def get_icon_file_path(icon_name): """Returns absolute path to icon file if it exists in icons or img dir.""" @@ -50,6 +56,35 @@ def create_icon_widget(icon_name, size=None, css_class=None): return img +def create_logo_widget(icon_name, size, css_class=None): + """Crisp full-color logo from an SVG/PNG, rasterized at the target size. + + Use for app logos (NOT symbolic icons — those are recolored via CSS and must + stay on the gicon path). GdkPixbuf rasterizes the SVG via librsvg at exactly + the requested pixel size, so the result is sharp instead of an upscaled + low-res bitmap. Oversampled so it stays crisp on HiDPI (scale 2) displays. + """ + img = Gtk.Image() + path = get_icon_file_path(icon_name) + if path: + try: + target = max(1, int(size)) * _LOGO_OVERSAMPLE + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(path, target, target) + img.set_from_paintable(Gdk.Texture.new_for_pixbuf(pixbuf)) + except Exception: + gicon = get_gicon(icon_name) + if gicon: + img.set_from_gicon(gicon) + else: + img.set_from_icon_name(icon_name) + + img.set_pixel_size(size) + if css_class: + for c in ([css_class] if isinstance(css_class, str) else css_class): + img.add_css_class(c) + return img + + def set_icon(image_widget, icon_name): """Sets the content of an existing Gtk.Image to a local icon.""" gicon = get_gicon(icon_name) diff --git a/usr/share/big-remote-play/utils/logger.py b/src/big_remote_play/utils/logger.py similarity index 98% rename from usr/share/big-remote-play/utils/logger.py rename to src/big_remote_play/utils/logger.py index 5262647..98bf57c 100644 --- a/usr/share/big-remote-play/utils/logger.py +++ b/src/big_remote_play/utils/logger.py @@ -3,7 +3,6 @@ """ import logging -import os from pathlib import Path from datetime import datetime @@ -81,4 +80,4 @@ def clear_old_logs(self): try: for f in self.log_dir.glob('*.log'): f.unlink() - except: pass + except Exception: pass diff --git a/usr/share/big-remote-play/utils/moonlight_config.py b/src/big_remote_play/utils/moonlight_config.py similarity index 90% rename from usr/share/big-remote-play/utils/moonlight_config.py rename to src/big_remote_play/utils/moonlight_config.py index 09adb0c..c820d48 100644 --- a/usr/share/big-remote-play/utils/moonlight_config.py +++ b/src/big_remote_play/utils/moonlight_config.py @@ -1,6 +1,7 @@ +import logging import configparser -import os from pathlib import Path +_log = logging.getLogger("big-remoteplay") class MoonlightConfigManager: _shared_state = {} @@ -36,7 +37,7 @@ def load(self): try: self.cp.read(self.config_file) except Exception as e: - print(f"Error loading Moonlight config: {e}") + _log.error(f"Error loading Moonlight config: {e}") if 'General' not in self.cp: self.cp.add_section('General') @@ -51,7 +52,7 @@ def save(self): with open(self.config_file, 'w') as f: self.cp.write(f) except Exception as e: - print(f"Error saving Moonlight config: {e}") + _log.error(f"Error saving Moonlight config: {e}") def get(self, key, default=None): return self.cp.get('General', key, fallback=str(default)) diff --git a/usr/share/big-remote-play/utils/network.py b/src/big_remote_play/utils/network.py similarity index 92% rename from usr/share/big-remote-play/utils/network.py rename to src/big_remote_play/utils/network.py index 114a438..107bc28 100644 --- a/usr/share/big-remote-play/utils/network.py +++ b/src/big_remote_play/utils/network.py @@ -4,11 +4,10 @@ import socket import subprocess -import re from typing import List, Dict -from utils.i18n import _ +from big_remote_play.utils.i18n import _ -from utils.logger import Logger +from big_remote_play.utils.logger import Logger class NetworkDiscovery: """Sunshine host discovery on network""" @@ -25,7 +24,7 @@ def run(): res = subprocess.run(['avahi-browse', '-t', '-r', '-p', '_nvstream._tcp'], capture_output=True, text=True, timeout=5) if res.returncode == 0 and res.stdout: hosts = self.parse_avahi_output(res.stdout) if not hosts: hosts = self.manual_scan() - except: hosts = self.manual_scan() + except Exception: hosts = self.manual_scan() if callback: from gi.repository import GLib GLib.idle_add(callback, hosts) @@ -83,7 +82,7 @@ def parse_avahi_output(self, output: str) -> List[Dict]: ipv4 = socket.gethostbyname(hostname) if ipv4 and not ipv4.startswith('127.'): data['ips'].append({'ip': ipv4, 'type': 'ipv4', 'raw': ipv4}) - except: + except Exception: pass final_hosts = [] @@ -110,7 +109,6 @@ def parse_avahi_output(self, output: str) -> List[Dict]: return final_hosts def manual_scan(self) -> List[Dict]: - import concurrent.futures from concurrent.futures import ThreadPoolExecutor hosts = []; local_ip = self.get_local_ip() targets = ['127.0.0.1', '::1'] @@ -133,25 +131,19 @@ def manual_scan(self) -> List[Dict]: try: dev_idx = parts.index('dev') if dev_idx + 1 < len(parts): targets.append(f"{ip}%{parts[dev_idx+1]}") - except: pass + except Exception: pass else: targets.append(ip) # 2. Flush neighbor cache to avoid stale entries try: subprocess.run(['ip', '-6', 'neigh', 'flush', 'all'], capture_output=True, timeout=1) - except: pass + except Exception: pass # 3. Ping all-nodes multicast briefly to populate neighbor cache subprocess.run(['ping', '-6', '-c', '1', '-W', '1', 'ff02::1%lo'], capture_output=True, timeout=1) - except: pass + except Exception: pass def check(ip): if self.check_sunshine_port(ip): - try: - # Try to resolve name, but don't fail if it takes too long - name = socket.gethostbyaddr(ip)[0] - except: - name = ip - # User reported Moonlight CLI on Linux prefers raw IP without brackets return {'name': _("Host ({})").format(ip), 'ip': ip, 'port': 47989, 'status': 'online'} return None @@ -163,20 +155,20 @@ def check(ip): def check_sunshine_port(self, ip: str, port: int = 47989, timeout: float = 0.5) -> bool: try: - with socket.create_connection((ip, port), timeout=timeout) as s: return True - except: return False + with socket.create_connection((ip, port), timeout=timeout): return True + except Exception: return False def get_local_ip(self) -> str: # Try IPv4 try: with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: s.connect(("8.8.8.8", 80)); return s.getsockname()[0] - except: pass + except Exception: pass # Try IPv6 try: with socket.socket(socket.AF_INET6, socket.SOCK_DGRAM) as s: s.connect(("2001:4860:4860::8888", 80)); return s.getsockname()[0] - except: pass + except Exception: pass return "" def resolve_pin(self, pin: str, timeout: int = 3) -> str: @@ -191,7 +183,7 @@ def try_v4(): s.sendto(f"WHO_HAS_PIN {pin}".encode(), ('', 48011)) data, addr = s.recvfrom(1024) if data.decode().startswith("I_HAVE_PIN"): results['v4'] = addr[0] - except: pass + except Exception: pass def try_v6(): try: @@ -202,7 +194,7 @@ def try_v6(): data, addr = s.recvfrom(1024) if data.decode().startswith("I_HAVE_PIN"): results['v6'] = addr[0] - except: pass + except Exception: pass t1 = threading.Thread(target=try_v4); t2 = threading.Thread(target=try_v6) t1.start(); t2.start() @@ -222,7 +214,7 @@ def run(): if family == socket.AF_INET6: # Ensure IPv6 socket doesn't block IPv4 try: s.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1) - except: pass + except Exception: pass s.bind(('::', 48011)) else: s.bind(('0.0.0.0', 48011)) @@ -234,11 +226,11 @@ def listener(sock): data, addr = sock.recvfrom(1024) if data.decode().strip() == f"WHO_HAS_PIN {pin}": sock.sendto(f"I_HAVE_PIN {name}".encode(), addr) - except: pass + except Exception: pass sock.close() threading.Thread(target=listener, args=(s,), daemon=True).start() - except: pass + except Exception: pass run() return lambda: running.__setitem__(0, False) @@ -248,7 +240,7 @@ def get_global_ipv4(self) -> str: try: res = subprocess.run(['curl', '-s', '-4', '--connect-timeout', '3', url], capture_output=True, text=True) if res.returncode == 0 and res.stdout.strip(): return res.stdout.strip() - except: pass + except Exception: pass return "None" def get_global_ipv6(self) -> str: @@ -256,7 +248,7 @@ def get_global_ipv6(self) -> str: try: res = subprocess.run(['curl', '-s', '-6', '--connect-timeout', '3', url], capture_output=True, text=True) if res.returncode == 0 and res.stdout.strip(): return res.stdout.strip() - except: pass + except Exception: pass return "None" def resolve_pin_to_ip(pin: str) -> dict | None: diff --git a/src/big_remote_play/utils/script_protocol.py b/src/big_remote_play/utils/script_protocol.py new file mode 100644 index 0000000..97405ff --- /dev/null +++ b/src/big_remote_play/utils/script_protocol.py @@ -0,0 +1,36 @@ +"""Parser for the machine-readable markers emitted by the network setup scripts. + +The scripts print locale-independent ASCII markers next to their human prose so +that parsing is decoupled from translation (the prose can be localized via +gettext without breaking capture): + + BRP_DATA = captured key/value data (key is [A-Za-z0-9_]) + BRP_PHASE progress fraction in 0..1 + +Any other line is human prose, shown verbatim in the progress UI. +""" + +import re + +_ANSI = re.compile(r"\x1b\[[0-9;]*[mK]") +_DATA = re.compile(r"^BRP_DATA\s+([A-Za-z0-9_]+)=(.*)$") +_PHASE = re.compile(r"^BRP_PHASE\s+([0-9]*\.?[0-9]+)$") + + +def parse_script_line(raw: str) -> tuple: + """Classify one script output line. + + Returns one of: + ("data", key, value) - a BRP_DATA marker + ("phase", fraction) - a BRP_PHASE marker (float, clamped to 0..1) + ("text", clean) - human prose (ANSI-stripped, trimmed) + """ + clean = _ANSI.sub("", raw).strip() + m = _DATA.match(clean) + if m: + return ("data", m.group(1), m.group(2).strip()) + m = _PHASE.match(clean) + if m: + frac = float(m.group(1)) + return ("phase", max(0.0, min(1.0, frac))) + return ("text", clean) diff --git a/src/big_remote_play/utils/secret_store.py b/src/big_remote_play/utils/secret_store.py new file mode 100644 index 0000000..5e39986 --- /dev/null +++ b/src/big_remote_play/utils/secret_store.py @@ -0,0 +1,146 @@ +"""Secret storage backed by the Freedesktop Secret Service. + +Persistent VPN/API credentials belong in the user's keyring, not in JSON +history files or shell-script config directories. The app uses libsecret +through GObject Introspection at runtime; tests inject the in-memory backend. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol +import uuid + + +class SecretStoreUnavailable(RuntimeError): + """Raised when no Secret Service backend is available.""" + + +@dataclass(frozen=True) +class SecretKey: + """Stable lookup key for one app secret.""" + + provider: str + kind: str + identifier: str + + @property + def attributes(self) -> dict[str, str]: + return { + "provider": self.provider, + "kind": self.kind, + "id": self.identifier, + } + + +class SecretBackend(Protocol): + def is_available(self) -> bool: ... + + def store(self, key: SecretKey, value: str, label: str) -> None: ... + + def lookup(self, key: SecretKey) -> str | None: ... + + def clear(self, key: SecretKey) -> bool: ... + + +class LibsecretBackend: + """libsecret backend loaded lazily so tests can run without a session bus.""" + + def __init__(self) -> None: + self._error: Exception | None = None + self._secret = None + self._schema = None + try: + import gi + + gi.require_version("Secret", "1") + from gi.repository import Secret + + self._secret = Secret + self._schema = Secret.Schema.new( + "org.biglinux.BigRemotePlay", + Secret.SchemaFlags.NONE, + { + "provider": Secret.SchemaAttributeType.STRING, + "kind": Secret.SchemaAttributeType.STRING, + "id": Secret.SchemaAttributeType.STRING, + }, + ) + except Exception as exc: + self._error = exc + + def is_available(self) -> bool: + return self._secret is not None and self._schema is not None + + def _require_secret(self): + if not self.is_available(): + raise SecretStoreUnavailable(str(self._error) if self._error else "Secret Service unavailable") + return self._secret + + def store(self, key: SecretKey, value: str, label: str) -> None: + secret = self._require_secret() + ok = secret.password_store_sync( + self._schema, + key.attributes, + secret.COLLECTION_DEFAULT, + label, + value, + None, + ) + if not ok: + raise SecretStoreUnavailable("Secret Service refused to store the secret") + + def lookup(self, key: SecretKey) -> str | None: + secret = self._require_secret() + return secret.password_lookup_sync(self._schema, key.attributes, None) + + def clear(self, key: SecretKey) -> bool: + secret = self._require_secret() + return bool(secret.password_clear_sync(self._schema, key.attributes, None)) + + +class InMemorySecretBackend: + """Small test backend with the same semantics as SecretBackend.""" + + def __init__(self) -> None: + self._values: dict[tuple[str, str, str], str] = {} + + def is_available(self) -> bool: + return True + + def store(self, key: SecretKey, value: str, label: str) -> None: + self._values[(key.provider, key.kind, key.identifier)] = value + + def lookup(self, key: SecretKey) -> str | None: + return self._values.get((key.provider, key.kind, key.identifier)) + + def clear(self, key: SecretKey) -> bool: + return self._values.pop((key.provider, key.kind, key.identifier), None) is not None + + +class SecretStore: + """App-facing wrapper for the configured secret backend.""" + + def __init__(self, backend: SecretBackend | None = None) -> None: + self._backend = backend or LibsecretBackend() + + def is_available(self) -> bool: + return self._backend.is_available() + + def store(self, key: SecretKey, value: str, label: str) -> None: + if not value: + self.clear(key) + return + self._backend.store(key, value, label) + + def lookup(self, key: SecretKey) -> str: + value = self._backend.lookup(key) + return value or "" + + def clear(self, key: SecretKey) -> bool: + return self._backend.clear(key) + + +def new_secret_id() -> str: + """Return an opaque, non-secret identifier safe to store in JSON history.""" + return uuid.uuid4().hex diff --git a/src/big_remote_play/utils/secure_io.py b/src/big_remote_play/utils/secure_io.py new file mode 100644 index 0000000..7149e28 --- /dev/null +++ b/src/big_remote_play/utils/secure_io.py @@ -0,0 +1,40 @@ +"""Filesystem helpers for writing secrets and config with owner-only permissions. + +Auth tokens, VPN history (contains auth keys) and sunshine.conf (credentials) +must not be world-readable. These helpers create the file mode 0o600 and tighten +the containing directory to 0o700. +""" + +import os +import tempfile + + +def _secure_dir(path: str) -> None: + directory = os.path.dirname(path) + if directory: + os.makedirs(directory, exist_ok=True) + try: + os.chmod(directory, 0o700) + except OSError: + pass + + +def secure_write_text(path: str, text: str) -> None: + """Atomically write text to a 0o600 file inside a 0o700 directory.""" + _secure_dir(path) + directory = os.path.dirname(path) or "." + fd, tmp = tempfile.mkstemp(dir=directory, prefix=".tmp-") + try: + os.fchmod(fd, 0o600) + with os.fdopen(fd, "w") as f: + f.write(text) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, path) + os.chmod(path, 0o600) + except Exception: + try: + os.unlink(tmp) + except OSError: + pass + raise diff --git a/src/big_remote_play/utils/system_check.py b/src/big_remote_play/utils/system_check.py new file mode 100644 index 0000000..b80a915 --- /dev/null +++ b/src/big_remote_play/utils/system_check.py @@ -0,0 +1,161 @@ +""" +System component verification +""" + +import subprocess +import shutil +from big_remote_play.utils.i18n import _ + + +class SystemCheck: + """System component checker""" + + def __init__(self): + pass + + def has_sunshine(self) -> bool: + """Checks if Sunshine is installed""" + return shutil.which("sunshine") is not None + + def has_moonlight(self) -> bool: + """Checks if Moonlight is installed""" + # Moonlight may have different names + return shutil.which("moonlight") is not None or shutil.which("moonlight-qt") is not None + + def has_avahi(self) -> bool: + """Checks if Avahi is installed""" + return shutil.which("avahi-browse") is not None + + def has_docker(self) -> bool: + """Checks if Docker is installed""" + return shutil.which("docker") is not None + + def has_zerotier(self) -> bool: + """Checks if ZeroTier is installed""" + return shutil.which("zerotier-cli") is not None + + def has_tailscale(self) -> bool: + """Checks if Tailscale is installed""" + return shutil.which("tailscale") is not None + + def check_all(self) -> dict: + """Checks all components""" + return { + "sunshine": self.has_sunshine(), + "moonlight": self.has_moonlight(), + "avahi": self.has_avahi(), + "docker": self.has_docker(), + "tailscale": self.has_tailscale(), + "zerotier": self.has_zerotier(), + } + + def is_sunshine_running(self) -> bool: + """Checks if Sunshine process is running""" + try: + result = subprocess.run(["pgrep", "-x", "sunshine"], capture_output=True, timeout=2) + return result.returncode == 0 + except Exception: + return False + + def is_docker_running(self) -> bool: + """Checks if Docker daemon is running""" + try: + return subprocess.run(["systemctl", "is-active", "--quiet", "docker"], timeout=5).returncode == 0 + except Exception: + return False + + def are_containers_running(self) -> bool: + """Checks if app containers (caddy, headscale) are running""" + try: + result = subprocess.run(["docker", "ps", "--format", "{{.Names}}"], capture_output=True, text=True, timeout=2) + if result.returncode == 0: + output = result.stdout.strip() + return "caddy" in output and "headscale" in output + return False + except Exception: + return False + + def is_tailscale_running(self) -> bool: + """Checks if Tailscale daemon is running""" + try: + return subprocess.run(["systemctl", "is-active", "--quiet", "tailscaled"], timeout=5).returncode == 0 + except Exception: + return False + + def is_zerotier_running(self) -> bool: + """Checks if ZeroTier daemon is running""" + try: + return subprocess.run(["systemctl", "is-active", "--quiet", "zerotier-one"], timeout=5).returncode == 0 + except Exception: + return False + + def is_moonlight_running(self) -> bool: + """Checks if Moonlight process is running (ignores zombies)""" + try: + for process_name in ["moonlight", "moonlight-qt"]: + # Get PIDs + result = subprocess.run( + ["pgrep", "-x", process_name], + capture_output=True, + text=True, # Important to read output as text + timeout=2, + ) + + if result.returncode == 0 and result.stdout: + pids = result.stdout.strip().split() + for pid in pids: + # Check process state + try: + state_check = subprocess.run(["ps", "-o", "state=", "-p", pid], capture_output=True, text=True, timeout=1) + if state_check.returncode == 0: + state = state_check.stdout.strip() + # If state is not Z (Zombie) or T (Stopped), consider running + if state and state not in ["Z", "T", "Z+"]: + return True + except Exception: + continue + + return False + except Exception: + return False + + def get_sunshine_version(self) -> str: + """Gets Sunshine version""" + try: + result = subprocess.run(["sunshine", "--version"], capture_output=True, text=True, timeout=2) + + if result.returncode == 0: + return result.stdout.strip() + else: + return _("Unknown") + + except Exception: + return _("Unknown") + + def check_sunshine_runtime(self) -> tuple[bool, str]: + """Verify that the installed Sunshine binary can start far enough to print its version.""" + if not self.has_sunshine(): + return False, _("Sunshine executable not found") + try: + result = subprocess.run(["sunshine", "--version"], capture_output=True, text=True, timeout=5) + detail = (result.stdout or result.stderr or "").strip() + if result.returncode == 0: + return True, detail or _("Sunshine runtime is available") + return False, detail or _("Sunshine failed to report its version") + except Exception as exc: + return False, str(exc) + + def get_moonlight_version(self) -> str: + """Gets Moonlight version""" + try: + # Try different variants + for cmd in ["moonlight-qt", "moonlight"]: + result = subprocess.run([cmd, "--version"], capture_output=True, text=True, timeout=2) + + if result.returncode == 0: + return result.stdout.strip() + + return _("Unknown") + + except Exception: + return _("Unknown") diff --git a/src/big_remote_play/utils/uri.py b/src/big_remote_play/utils/uri.py new file mode 100644 index 0000000..56853cf --- /dev/null +++ b/src/big_remote_play/utils/uri.py @@ -0,0 +1,27 @@ +"""URI/file opening with correct Wayland activation. + +A bare `xdg-open` subprocess receives no activation token, so under Wayland the +compositor's focus-stealing prevention opens the target app in the background. +`Gtk.show_uri()` with the requesting toplevel as launcher hands the compositor a +proper activation token, so the browser/handler is raised to the foreground. +""" + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk, Gdk, GLib # noqa: E402 + + +def _toplevel(widget): + root = widget.get_root() if widget is not None else None + return root if isinstance(root, Gtk.Window) else None + + +def open_uri(widget, uri: str) -> None: + """Open an http(s)/file URI, transferring focus to the handler app.""" + Gtk.show_uri(_toplevel(widget), uri, Gdk.CURRENT_TIME) + + +def open_path(widget, path) -> None: + """Open a local filesystem path (converted to a file:// URI).""" + open_uri(widget, GLib.filename_to_uri(str(path), None)) diff --git a/src/big_remote_play/utils/widgets.py b/src/big_remote_play/utils/widgets.py new file mode 100644 index 0000000..31afb45 --- /dev/null +++ b/src/big_remote_play/utils/widgets.py @@ -0,0 +1,298 @@ +"""Reusable UI builders shared across the Big Remote Play screens. + +Pure widget factories (no domain logic), so welcome, VPN selector, guest +discover, host and the VPN connect wizard share the same explanatory components +seen in the mockups: "how it works" step strips, side helper cards, a wizard +stepper, difficulty pills and the VPN comparison table. + +Every interactive/iconographic element gets an accessible label so AT-SPI +exposes it (icon-only widgets have no inferable name). +""" + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk # noqa: E402 + +from big_remote_play.utils.icons import create_icon_widget # noqa: E402 +from big_remote_play.utils.i18n import _ # noqa: E402 + +# Difficulty level -> (translated label, CSS modifier). Keys are stable English. +_DIFFICULTY_LEVELS: dict[str, tuple[str, str]] = { + "beginner": (_("Beginner"), "easy"), + "intermediate": (_("Intermediate"), "medium"), + "advanced": (_("Advanced"), "hard"), +} + + +def create_steps_strip(steps: list[tuple[str, str, str]]) -> Gtk.Widget: + """ "How it works" strip: numbered steps with icon + title + description. + + `steps` is an ordered list of (icon_name, title, description). Steps are laid + out horizontally with a chevron between them; the whole strip sits in a card. + """ + card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + card.add_css_class("steps-strip") + + header = Gtk.Label(label=_("How it works")) + header.add_css_class("title-4") + header.set_halign(Gtk.Align.START) + card.append(header) + + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + row.set_homogeneous(False) + + for index, (icon_name, title, description) in enumerate(steps): + if index > 0: + chevron = create_icon_widget("go-next-symbolic", size=16) + chevron.add_css_class("dim-label") + chevron.set_valign(Gtk.Align.CENTER) + row.append(chevron) + + step = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + step.set_hexpand(True) + step.set_valign(Gtk.Align.CENTER) + + number = Gtk.Label(label=str(index + 1)) + number.add_css_class("step-number") + number.set_valign(Gtk.Align.CENTER) + step.append(number) + + icon = create_icon_widget(icon_name, size=22) + icon.add_css_class("accent") + icon.set_valign(Gtk.Align.CENTER) + step.append(icon) + + text = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + text.set_valign(Gtk.Align.CENTER) + title_lbl = Gtk.Label(label=title) + title_lbl.add_css_class("caption-heading") + title_lbl.set_halign(Gtk.Align.START) + title_lbl.set_wrap(True) + text.append(title_lbl) + desc_lbl = Gtk.Label(label=description) + desc_lbl.add_css_class("caption") + desc_lbl.add_css_class("dim-label") + desc_lbl.set_halign(Gtk.Align.START) + desc_lbl.set_wrap(True) + desc_lbl.set_max_width_chars(28) + text.append(desc_lbl) + step.append(text) + + row.append(step) + + card.append(row) + return card + + +def create_helper_card(title: str, icon_name: str, items: list[tuple[str, str, str]]) -> Gtk.Widget: + """Side "what you'll need / if you can't find it" explanatory card. + + `items` is a list of (icon_name, title, description) rows. Used as the right + column next to a form or list. + """ + card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=14) + card.add_css_class("helper-card") + + head = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + head_icon = create_icon_widget(icon_name, size=20) + head_icon.add_css_class("accent") + head_icon.set_valign(Gtk.Align.CENTER) + head.append(head_icon) + head_lbl = Gtk.Label(label=title) + head_lbl.add_css_class("title-4") + head_lbl.set_halign(Gtk.Align.START) + head_lbl.set_wrap(True) + head.append(head_lbl) + card.append(head) + + for row_icon, row_title, row_desc in items: + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + row.add_css_class("helper-row") + + ricon = create_icon_widget(row_icon, size=18) + ricon.add_css_class("dim-label") + ricon.set_valign(Gtk.Align.START) + ricon.set_margin_top(2) + row.append(ricon) + + text = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + text.set_hexpand(True) + t = Gtk.Label(label=row_title) + t.add_css_class("caption-heading") + t.set_halign(Gtk.Align.START) + t.set_wrap(True) + text.append(t) + d = Gtk.Label(label=row_desc) + d.add_css_class("caption") + d.add_css_class("dim-label") + d.set_halign(Gtk.Align.START) + d.set_wrap(True) + d.set_max_width_chars(32) + text.append(d) + row.append(text) + + card.append(row) + + return card + + +def create_wizard_stepper(steps: list[str], active_index: int) -> Gtk.Widget: + """Top 1-2-3 wizard stepper. `steps` are the labels; `active_index` is 0-based. + + Steps before the active one render as completed, the active one is highlighted. + """ + strip = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + strip.add_css_class("wizard-stepper") + strip.set_halign(Gtk.Align.CENTER) + + for index, label_text in enumerate(steps): + if index > 0: + sep = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL) + sep.add_css_class("step-connector") + sep.set_valign(Gtk.Align.CENTER) + sep.set_hexpand(True) + sep.set_size_request(40, -1) + strip.append(sep) + + step = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + step.set_valign(Gtk.Align.CENTER) + + number = Gtk.Label(label=str(index + 1)) + number.add_css_class("step-number") + if index < active_index: + number.add_css_class("completed") + elif index == active_index: + number.add_css_class("active") + number.set_valign(Gtk.Align.CENTER) + step.append(number) + + lbl = Gtk.Label(label=label_text) + lbl.add_css_class("caption-heading" if index == active_index else "caption") + if index != active_index: + lbl.add_css_class("dim-label") + step.append(lbl) + + strip.append(step) + + return strip + + +def create_difficulty_pill(level: str) -> Gtk.Widget: + """Colored difficulty pill. `level` in {beginner, intermediate, advanced}.""" + label_text, modifier = _DIFFICULTY_LEVELS.get(level.lower(), (level, "medium")) + pill = Gtk.Label(label=label_text) + pill.add_css_class("difficulty-pill") + pill.add_css_class(modifier) + pill.set_halign(Gtk.Align.CENTER) + return pill + + +class MetricTile(Gtk.Box): + """Compact metric tile: icon + label, a big current value and a mini sparkline. + + Used by the Server status card (mockup 05) to show Latency / FPS / Bandwidth. + Feed it with `update(values_norm, value_text)` where `values_norm` is a list of + points already normalized to 0..1; `rgba` is the sparkline color (0..1 tuple). + """ + + def __init__(self, icon_name: str, label: str, color_class: str, rgba: tuple) -> None: + super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=4) + self.add_css_class("metric-tile") + self._values: list[float] = [] + self._rgba = rgba + + head = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + icon = create_icon_widget(icon_name, size=16) + icon.add_css_class(color_class) + head.append(icon) + lbl = Gtk.Label(label=label) + lbl.add_css_class("caption") + lbl.add_css_class("dim-label") + head.append(lbl) + self.append(head) + + self._value_label = Gtk.Label(label="--") + self._value_label.add_css_class("title-3") + self._value_label.set_halign(Gtk.Align.START) + self.append(self._value_label) + + self._spark = Gtk.DrawingArea() + self._spark.set_content_height(28) + self._spark.set_hexpand(True) + self._spark.set_draw_func(self._draw_sparkline) + self.append(self._spark) + + def update(self, values_norm: list[float], value_text: str) -> None: + self._values = values_norm + self._value_label.set_label(value_text) + self._spark.queue_draw() + + def _draw_sparkline(self, _area, cr, width: int, height: int) -> None: + vals = self._values + r, g, b, a = self._rgba + if len(vals) < 2: + return + n = len(vals) + step = width / max(n - 1, 1) + pad = 3.0 + usable = max(height - 2 * pad, 1) + + def y(v: float) -> float: + return pad + (1.0 - max(0.0, min(1.0, v))) * usable + + # Soft area fill under the line. + cr.move_to(0, height) + for i, v in enumerate(vals): + cr.line_to(i * step, y(v)) + cr.line_to((n - 1) * step, height) + cr.close_path() + cr.set_source_rgba(r, g, b, 0.12) + cr.fill() + + # The line itself. + cr.set_line_width(2.0) + cr.set_source_rgba(r, g, b, a) + cr.move_to(0, y(vals[0])) + for i, v in enumerate(vals[1:], start=1): + cr.line_to(i * step, y(v)) + cr.stroke() + + +def create_comparison_table(headers: list[str], rows: list[tuple[str, list[str]]]) -> Gtk.Widget: + """Comparison grid: first column = row label, remaining = one cell per header. + + `headers` are the provider column titles (the top-left corner stays blank). + `rows` is a list of (row_label, [cell, cell, ...]) with one cell per header. + """ + grid = Gtk.Grid() + grid.add_css_class("comparison-table") + grid.set_column_homogeneous(True) + grid.set_row_spacing(0) + grid.set_column_spacing(0) + + # Header row (corner stays empty so the provider names align over the cells). + for col, header_text in enumerate(headers): + cell = Gtk.Label(label=header_text) + cell.add_css_class("comparison-header") + cell.set_halign(Gtk.Align.CENTER) + cell.set_hexpand(True) + grid.attach(cell, col + 1, 0, 1, 1) + + for r, (row_label, cells) in enumerate(rows): + label = Gtk.Label(label=row_label) + label.add_css_class("comparison-cell") + label.add_css_class("comparison-rowlabel") + label.set_halign(Gtk.Align.START) + label.set_hexpand(True) + grid.attach(label, 0, r + 1, 1, 1) + + for col, value in enumerate(cells): + cell = Gtk.Label(label=value) + cell.add_css_class("comparison-cell") + cell.set_halign(Gtk.Align.CENTER) + cell.set_wrap(True) + cell.set_hexpand(True) + grid.attach(cell, col + 1, r + 1, 1, 1) + + return grid diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..08d61d2 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,11 @@ +"""Shared fixtures. Keep the filesystem hermetic: every test that touches config +or logs runs against a temporary HOME so nothing writes to the real user dir.""" + +import pytest + + +@pytest.fixture +def fake_home(tmp_path, monkeypatch): + """Point HOME at a temp dir so Config/Logger write under tmp_path.""" + monkeypatch.setenv("HOME", str(tmp_path)) + return tmp_path diff --git a/tests/test_audio.py b/tests/test_audio.py new file mode 100644 index 0000000..9eadea2 --- /dev/null +++ b/tests/test_audio.py @@ -0,0 +1,21 @@ +"""AudioManager.is_virtual classification (pure, no subprocess).""" + +import pytest + +from big_remote_play.utils.audio import AudioManager + + +@pytest.mark.parametrize( + "name,description,expected", + [ + ("SunshineGameSink", "", True), + ("alsa_output.pci-0000_00_1f.3.analog-stereo", "Built-in Audio", False), + ("alsa_output.pci.analog-stereo.monitor", "", True), + ("combined", "", True), + ("null-sink", "", True), + ("regular_sink", "easyeffects sink", True), + ("hw_card", "USB Headset", False), + ], +) +def test_is_virtual(name: str, description: str, expected: bool) -> None: + assert AudioManager().is_virtual(name, description) is expected diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..c4efbd2 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,44 @@ +"""Config: atomic persistence, defaults, and corruption tolerance.""" + +import json +from pathlib import Path + +from big_remote_play.utils.config import Config + + +def _config_path(home: Path) -> Path: + return home / ".config" / "big-remoteplay" / "config.json" + + +def test_defaults_when_no_file(fake_home: Path) -> None: + cfg = Config() + assert cfg.get("theme") == "auto" + assert cfg.get("network")["sunshine_port"] == 47989 + assert cfg.get("missing", "fallback") == "fallback" + + +def test_set_persists_to_disk(fake_home: Path) -> None: + cfg = Config() + cfg.set("theme", "dark") + on_disk = json.loads(_config_path(fake_home).read_text()) + assert on_disk["theme"] == "dark" + + +def test_set_is_atomic_no_temp_leftover(fake_home: Path) -> None: + cfg = Config() + cfg.set("theme", "light") + cfg_dir = _config_path(fake_home).parent + assert not list(cfg_dir.glob(".config-*.tmp")), "atomic write left a temp file" + + +def test_reload_reads_persisted_value(fake_home: Path) -> None: + Config().set("theme", "dark") + assert Config().get("theme") == "dark" + + +def test_corrupt_file_falls_back_to_defaults(fake_home: Path) -> None: + path = _config_path(fake_home) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("{ this is not valid json ") + cfg = Config() + assert cfg.get("theme") == "auto" diff --git a/tests/test_drop_guest_validation.py b/tests/test_drop_guest_validation.py new file mode 100644 index 0000000..04be73a --- /dev/null +++ b/tests/test_drop_guest_validation.py @@ -0,0 +1,36 @@ +"""drop_guest.sh input validation (security: runs as root via pkexec).""" + +import subprocess + +from big_remote_play import paths + +_SCRIPT = paths.script_path("drop_guest.sh") + + +def _run(arg: str) -> subprocess.CompletedProcess: + return subprocess.run( + ["bash", _SCRIPT, arg], capture_output=True, text=True, timeout=10 + ) + + +def test_rejects_empty() -> None: + assert subprocess.run(["bash", _SCRIPT], capture_output=True, timeout=10).returncode == 1 + + +def test_rejects_command_injection() -> None: + r = _run("1.2.3.4 evilarg") + assert r.returncode == 1 + assert "invalid IP" in r.stdout + + +def test_rejects_garbage() -> None: + assert _run("999.bad@x").returncode == 1 + + +def test_accepts_valid_ipv4() -> None: + # Validation passes; ss may fail without CAP_NET_ADMIN but the script exits 0. + assert _run("10.0.0.5").returncode == 0 + + +def test_accepts_valid_ipv6() -> None: + assert _run("fe80::1").returncode == 0 diff --git a/tests/test_network.py b/tests/test_network.py new file mode 100644 index 0000000..a1aac66 --- /dev/null +++ b/tests/test_network.py @@ -0,0 +1,29 @@ +"""NetworkDiscovery.parse_avahi_output (pure parsing, no network).""" + +from pathlib import Path + +from big_remote_play.utils.network import NetworkDiscovery + +# avahi-browse -p fields: =;iface;proto;name;type;domain;hostname;ip;port;txt +_IPV4_LINE = "=;eth0;IPv4;MyHost;_nvstream._tcp;local;myhost.local;192.168.1.50;47989;\"x\"" +# Empty hostname avoids the IPv4-enrichment DNS lookup, keeping the test offline. +_IPV6_LL_LINE = "=;eth0;IPv6;V6Host;_nvstream._tcp;local;;fe80::1;47989;\"x\"" + + +def test_parse_ipv4(fake_home: Path) -> None: + hosts = NetworkDiscovery().parse_avahi_output(_IPV4_LINE) + assert len(hosts) == 1 + assert hosts[0]["ip"] == "192.168.1.50" + assert hosts[0]["port"] == 47989 + assert hosts[0]["name"] == "MyHost" + + +def test_parse_ipv6_link_local_gets_scope_id(fake_home: Path) -> None: + hosts = NetworkDiscovery().parse_avahi_output(_IPV6_LL_LINE) + assert len(hosts) == 1 + assert hosts[0]["ip"] == "fe80::1%eth0" + assert "IPv6 Local" in hosts[0]["name"] + + +def test_parse_ignores_malformed_lines(fake_home: Path) -> None: + assert NetworkDiscovery().parse_avahi_output("garbage;line\nanother") == [] diff --git a/tests/test_paths.py b/tests/test_paths.py new file mode 100644 index 0000000..0ad9a1b --- /dev/null +++ b/tests/test_paths.py @@ -0,0 +1,30 @@ +"""Resource path resolution and overrides.""" + +import importlib +import os +from pathlib import Path + +from big_remote_play import paths + + +def test_script_path_resolves_existing_bundled_script() -> None: + p = paths.script_path("drop_guest.sh") + assert p.endswith("drop_guest.sh") + assert os.path.exists(p) + + +def test_data_dir_has_icons() -> None: + assert paths.ICONS_DIR.is_dir() + assert (paths.ICONS_DIR / "big-remote-play.svg").exists() + + +def test_datadir_env_override(tmp_path: Path, monkeypatch) -> None: + (tmp_path / "icons").mkdir() + monkeypatch.setenv("BIG_REMOTE_PLAY_DATADIR", str(tmp_path)) + reloaded = importlib.reload(paths) + try: + assert reloaded.DATA_DIR == tmp_path + assert reloaded.ICONS_DIR == tmp_path / "icons" + finally: + monkeypatch.delenv("BIG_REMOTE_PLAY_DATADIR", raising=False) + importlib.reload(paths) diff --git a/tests/test_script_protocol.py b/tests/test_script_protocol.py new file mode 100644 index 0000000..3d95004 --- /dev/null +++ b/tests/test_script_protocol.py @@ -0,0 +1,53 @@ +"""Network-script marker protocol: capture is locale-independent.""" + +from big_remote_play.utils.script_protocol import parse_script_line + + +def test_data_marker() -> None: + assert parse_script_line("BRP_DATA network_id=abc123") == ("data", "network_id", "abc123") + + +def test_data_marker_value_with_url() -> None: + assert parse_script_line("BRP_DATA api_url=https://api.zerotier.com/api/v1") == ( + "data", "api_url", "https://api.zerotier.com/api/v1", + ) + + +def test_phase_marker_and_clamp() -> None: + assert parse_script_line("BRP_PHASE 0.6") == ("phase", 0.6) + assert parse_script_line("BRP_PHASE 1.5") == ("phase", 1.0) + + +def test_text_strips_ansi() -> None: + assert parse_script_line("\x1b[0;32mChecking dependencies...\x1b[0m") == ( + "text", "Checking dependencies...", + ) + + +def test_capture_is_locale_independent() -> None: + """Same data captured whether prose is English or Portuguese.""" + def capture(lines): + captured, phase = {}, 0.1 + for ln in lines: + kind = parse_script_line(ln) + if kind[0] == "data": + captured[kind[1]] = kind[2] + elif kind[0] == "phase": + phase = kind[1] + return captured, phase + + en = [ + "Creating network via API...", + "BRP_DATA network_id=NET42", + "BRP_PHASE 0.6", + "BRP_DATA public_ip=1.2.3.4", + "BRP_PHASE 0.95", + ] + pt = [ + "Criando rede via API...", + "BRP_DATA network_id=NET42", + "BRP_PHASE 0.6", + "BRP_DATA public_ip=1.2.3.4", + "BRP_PHASE 0.95", + ] + assert capture(en) == capture(pt) == ({"network_id": "NET42", "public_ip": "1.2.3.4"}, 0.95) diff --git a/tests/test_secret_store.py b/tests/test_secret_store.py new file mode 100644 index 0000000..0c5b4b4 --- /dev/null +++ b/tests/test_secret_store.py @@ -0,0 +1,23 @@ +"""SecretStore behavior without requiring a live Secret Service.""" + +from big_remote_play.utils.secret_store import InMemorySecretBackend, SecretKey, SecretStore, new_secret_id + + +def test_store_lookup_and_clear_secret() -> None: + store = SecretStore(InMemorySecretBackend()) + key = SecretKey("zerotier", "api_token", "default") + + store.store(key, "zt-token", "ZeroTier token") + + assert store.lookup(key) == "zt-token" + assert store.clear(key) is True + assert store.lookup(key) == "" + + +def test_new_secret_id_is_opaque() -> None: + first = new_secret_id() + second = new_secret_id() + + assert first != second + assert len(first) == 32 + assert first.isalnum() diff --git a/tests/test_secure_io.py b/tests/test_secure_io.py new file mode 100644 index 0000000..6f2e9b2 --- /dev/null +++ b/tests/test_secure_io.py @@ -0,0 +1,32 @@ +"""secure_write_text: secrets land 0o600 in a 0o700 directory, atomically.""" + +import stat +from pathlib import Path + +from big_remote_play.utils.secure_io import secure_write_text + + +def test_writes_content(tmp_path: Path) -> None: + target = tmp_path / "sub" / "token.txt" + secure_write_text(str(target), "s3cret") + assert target.read_text() == "s3cret" + + +def test_file_is_owner_only(tmp_path: Path) -> None: + target = tmp_path / "sub" / "token.txt" + secure_write_text(str(target), "x") + assert stat.S_IMODE(target.stat().st_mode) == 0o600 + + +def test_dir_is_owner_only(tmp_path: Path) -> None: + target = tmp_path / "sub" / "token.txt" + secure_write_text(str(target), "x") + assert stat.S_IMODE(target.parent.stat().st_mode) == 0o700 + + +def test_overwrite_no_temp_leftover(tmp_path: Path) -> None: + target = tmp_path / "token.txt" + secure_write_text(str(target), "one") + secure_write_text(str(target), "two") + assert target.read_text() == "two" + assert not list(tmp_path.glob(".tmp-*")) diff --git a/tests/test_security_regressions.py b/tests/test_security_regressions.py new file mode 100644 index 0000000..2f58581 --- /dev/null +++ b/tests/test_security_regressions.py @@ -0,0 +1,130 @@ +"""Regression tests for security findings from the Codex Security scan.""" + +from __future__ import annotations + +import json +import stat +import importlib +import sys +from pathlib import Path + +from big_remote_play.utils.secret_store import InMemorySecretBackend, SecretStore + +ROOT = Path(__file__).resolve().parents[1] + + +def _import_private_network_view(tmp_path: Path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + sys.modules.pop("big_remote_play.ui.private_network_view", None) + return importlib.import_module("big_remote_play.ui.private_network_view") + + +def test_icu_repair_script_is_not_shipped() -> None: + assert not (ROOT / "usr/share/big-remote-play/scripts/fix_sunshine_libs.sh").exists() + + +def test_firewall_does_not_open_sunshine_web_ui(tmp_path: Path) -> None: + script = ROOT / "usr/share/big-remote-play/scripts/configure_firewall.sh" + executable_lines = "\n".join(line for line in script.read_text().splitlines() if line.strip() and not line.lstrip().startswith("#")) + + assert "47990" not in executable_lines + assert "47984-48020" not in executable_lines + assert "TCP_PORTS=(47984 47989 48010)" in executable_lines + assert "${port}/tcp" in executable_lines + assert 'UDP_START="47998"' in executable_lines + assert 'UDP_END="48020"' in executable_lines + assert "${UDP_START}-${UDP_END}/udp" in executable_lines + + +def test_network_scripts_do_not_persist_raw_tokens() -> None: + tailscale = (ROOT / "usr/share/big-remote-play/scripts/create-network_tailscale.sh").read_text() + zerotier = (ROOT / "usr/share/big-remote-play/scripts/create-network_zerotier.sh").read_text() + headscale = (ROOT / "usr/share/big-remote-play/scripts/create-network_headscale.sh").read_text() + + assert "AUTH_KEY_FILE" not in tailscale + assert ".tailscale-script" not in tailscale + assert "API_TOKEN_FILE" not in zerotier + assert "api_token.txt" not in zerotier + assert "big-remoteplay/zerotier" not in zerotier + assert "brp_data api_key" not in zerotier + assert "brp_data api_key" not in headscale + assert "chmod -R 777" not in headscale + assert "/var/lib/tailscale" not in headscale + assert '--auth-key="$AUTH_KEY"' not in tailscale + assert '--authkey="$AUTH_KEY"' not in headscale + assert "file:/path/to/auth-key" in tailscale + assert "write_auth_key_file" in tailscale + assert "write_auth_key_file" in headscale + assert "--force-reauth" in tailscale + assert "--force-reauth" in headscale + + +def test_network_scripts_match_current_provider_docs() -> None: + zerotier = (ROOT / "usr/share/big-remote-play/scripts/create-network_zerotier.sh").read_text() + headscale = (ROOT / "usr/share/big-remote-play/scripts/create-network_headscale.sh").read_text() + + assert "https://api.zerotier.com/api/v1" in zerotier + assert "Authorization: token $API_TOKEN" in zerotier + assert "Authorization: bearer $API_TOKEN" not in zerotier + assert "HEADSCALE_VERSION=\"${HEADSCALE_VERSION:-0.29.1}\"" in headscale + assert "raw.githubusercontent.com/juanfont/headscale/v$HEADSCALE_VERSION/config-example.yaml" in headscale + assert "ip_prefixes:" not in headscale + assert "0.0.0.0/0" not in headscale + assert "read_only: true" in headscale + assert "./config:/etc/headscale:ro" in headscale + assert "./caddy_config:/config" in headscale + assert '"443:443/udp"' in headscale + assert "handle /generate_204" in headscale + assert "$HEADSCALE_IMAGE\" configtest" in headscale + + +def test_sunshine_network_options_cover_current_docs() -> None: + source = (ROOT / "src/big_remote_play/ui/sunshine_preferences.py").read_text() + + assert '"csrf_allowed_origins"' in source + assert '"packetsize"' in source + assert '"wan_encryption_mode",\n _("WAN Encryption"),\n "combo",\n "1",' in source + assert '"ping_timeout", _("Ping Timeout (ms)"), "spin", "10000"' in source + + +def test_history_saves_secret_refs_not_plaintext(tmp_path: Path, monkeypatch) -> None: + private_network_view = _import_private_network_view(tmp_path, monkeypatch) + + backend = InMemorySecretBackend() + monkeypatch.setattr(private_network_view, "_SECRET_STORE", SecretStore(backend)) + monkeypatch.setattr(private_network_view, "HISTORY_FILE", str(tmp_path / "history.json")) + + private_network_view._save_history( + { + "vpn": "headscale", + "domain": "vpn.example.test", + "auth_key": "hs-auth-secret", + "api_key": "hs-admin-secret", + } + ) + + raw_history = Path(private_network_view.HISTORY_FILE).read_text() + assert "hs-auth-secret" not in raw_history + assert "hs-admin-secret" not in raw_history + + history = json.loads(raw_history)["history"] + entry = history[0] + assert entry["domain"] == "vpn.example.test" + assert set(entry["secret_refs"]) == {"auth_key", "api_key"} + assert private_network_view._entry_secret(entry, "auth_key") == "hs-auth-secret" + assert private_network_view._entry_secret(entry, "api_key") == "hs-admin-secret" + assert stat.S_IMODE(Path(private_network_view.HISTORY_FILE).stat().st_mode) == 0o600 + + +def test_zerotier_token_uses_secret_store(tmp_path: Path, monkeypatch) -> None: + private_network_view = _import_private_network_view(tmp_path, monkeypatch) + + backend = InMemorySecretBackend() + legacy_file = tmp_path / "api_token.txt" + monkeypatch.setattr(private_network_view, "_SECRET_STORE", SecretStore(backend)) + monkeypatch.setattr(private_network_view, "LEGACY_ZT_TOKEN_FILE", str(legacy_file)) + + private_network_view._set_zerotier_api_token("zt-secret") + + assert private_network_view._get_zerotier_api_token() == "zt-secret" + assert not legacy_file.exists() diff --git a/tests/test_sunshine_api.py b/tests/test_sunshine_api.py new file mode 100644 index 0000000..3e9569b --- /dev/null +++ b/tests/test_sunshine_api.py @@ -0,0 +1,345 @@ +"""Sunshine config-API client: request shape, response parsing, TOFU cert pinning. + +Hermetic: the network call (`_api_request`) is captured, never executed. Assertions +target request structure and booleans (locale-independent), not translated prose. +""" + +import hashlib +import json +import stat + +import pytest + +from big_remote_play.host.sunshine_manager import SunshineHost, _cert_fingerprint + + +@pytest.fixture +def host(tmp_path): + return SunshineHost(cdir=tmp_path) + + +def _capture(host, status, body=b""): + """Replace _api_request with a recorder returning a canned response.""" + calls = [] + + def recorder(method, path, payload=None, auth=None, timeout=5.0): + calls.append({"method": method, "path": path, "payload": payload, "auth": auth}) + return status, body + + host._api_request = recorder + return calls + + +# --- fingerprint + TOFU --------------------------------------------------- + +def test_cert_fingerprint_is_sha256_hex() -> None: + assert _cert_fingerprint(b"abc") == hashlib.sha256(b"abc").hexdigest() + + +def test_trust_fingerprint_pins_on_first_use_then_matches(host) -> None: + fp = "a" * 64 + assert host._trust_fingerprint(fp) is True # first contact pins + assert host.cert_fp_file.exists() + assert host._trust_fingerprint(fp) is True # same cert -> trusted + + +def test_trust_fingerprint_rejects_mismatch(host) -> None: + host._trust_fingerprint("a" * 64) + assert host._trust_fingerprint("b" * 64) is False # cert changed -> refuse + + +def test_pinned_fingerprint_file_is_owner_only(host) -> None: + host._trust_fingerprint("c" * 64) + mode = stat.S_IMODE(host.cert_fp_file.stat().st_mode) + assert mode == 0o600 + + +# --- send_pin ------------------------------------------------------------- + +def test_send_pin_posts_pin_and_name(host) -> None: + calls = _capture(host, 200, b'{"status": true}') + ok, _msg = host.send_pin("1234", name="laptop", auth=("admin", "pw")) + assert ok is True + assert calls == [{ + "method": "POST", "path": "/api/pin", + "payload": {"pin": "1234", "name": "laptop"}, "auth": ("admin", "pw"), + }] + + +def test_send_pin_omits_empty_name(host) -> None: + calls = _capture(host, 200, b'{"status": true}') + host.send_pin("9999") + assert calls[0]["payload"] == {"pin": "9999"} + + +def test_send_pin_status_false_is_rejected(host) -> None: + _capture(host, 200, b'{"status": false}') + ok, _msg = host.send_pin("0000") + assert ok is False + + +def test_send_pin_401_is_auth_failure(host) -> None: + _capture(host, 401) + ok, _msg = host.send_pin("1234") + assert ok is False + + +def test_send_pin_connection_failure(host) -> None: + _capture(host, 0) + ok, _msg = host.send_pin("1234") + assert ok is False + + +# --- create_user (POST /api/password, not the nonexistent /api/users) ----- + +def test_create_user_uses_password_endpoint(host) -> None: + calls = _capture(host, 200, b'{"status": true}') + ok, _msg = host.create_user("admin", "secret") + assert ok is True + call = calls[0] + assert call["method"] == "POST" + assert call["path"] == "/api/password" + assert call["payload"] == { + "currentUsername": "", "currentPassword": "", + "newUsername": "admin", "newPassword": "secret", "confirmNewPassword": "secret", + } + assert call["auth"] is None # first-run: no credentials yet + + +def test_create_user_rejected(host) -> None: + _capture(host, 200, b'{"status": false}') + ok, _msg = host.create_user("admin", "secret") + assert ok is False + + +def test_set_credentials_change_sends_current_and_authenticates(host) -> None: + calls = _capture(host, 200, b'{"status": true}') + ok, _msg = host.set_credentials("admin", "newpass", current=("admin", "oldpass")) + assert ok is True + call = calls[0] + assert call["path"] == "/api/password" + assert call["payload"] == { + "currentUsername": "admin", "currentPassword": "oldpass", + "newUsername": "admin", "newPassword": "newpass", "confirmNewPassword": "newpass", + } + assert call["auth"] == ("admin", "oldpass") # change is authenticated + + +def test_set_credentials_change_wrong_password_is_401(host) -> None: + _capture(host, 401) + ok, _msg = host.set_credentials("admin", "newpass", current=("admin", "bad")) + assert ok is False + + +# --- reset_credentials (sunshine --creds, no old password) ---------------- + +class _FakeProc: + def __init__(self, returncode, stderr=""): + self.returncode = returncode + self.stdout = "" + self.stderr = stderr + + +def test_reset_credentials_runs_creds_cli(host, monkeypatch) -> None: + import big_remote_play.host.sunshine_manager as sm + calls = {} + monkeypatch.setattr(sm.shutil, "which", lambda _n: "/usr/bin/sunshine") + + def fake_run(argv, **kwargs): + calls["argv"] = argv + return _FakeProc(0) + + monkeypatch.setattr(sm.subprocess, "run", fake_run) + ok, _msg = host.reset_credentials("admin", "newpass") + assert ok is True + assert calls["argv"] == ["/usr/bin/sunshine", "--creds", "admin", "newpass"] + + +def test_reset_credentials_reports_failure(host, monkeypatch) -> None: + import big_remote_play.host.sunshine_manager as sm + monkeypatch.setattr(sm.shutil, "which", lambda _n: "/usr/bin/sunshine") + monkeypatch.setattr(sm.subprocess, "run", lambda argv, **kw: _FakeProc(1, "boom")) + ok, msg = host.reset_credentials("admin", "newpass") + assert ok is False + assert "boom" in msg + + +def test_reset_credentials_requires_nonempty(host, monkeypatch) -> None: + import big_remote_play.host.sunshine_manager as sm + called = {"ran": False} + monkeypatch.setattr(sm.shutil, "which", lambda _n: "/usr/bin/sunshine") + + def fake_run(*a, **k): + called["ran"] = True + return _FakeProc(0) + + monkeypatch.setattr(sm.subprocess, "run", fake_run) + ok, _msg = host.reset_credentials("", "newpass") + assert ok is False + assert called["ran"] is False # no exec on empty input + + +def test_reset_credentials_no_binary(host, monkeypatch) -> None: + import big_remote_play.host.sunshine_manager as sm + monkeypatch.setattr(sm.shutil, "which", lambda _n: None) + ok, _msg = host.reset_credentials("admin", "newpass") + assert ok is False + + +# --- client management ---------------------------------------------------- + +def test_list_clients_parses_named_certs(host) -> None: + body = json.dumps({"status": True, "named_certs": [ + {"name": "phone", "uuid": "u1", "enabled": True}, + ]}).encode() + _capture(host, 200, body) + clients = host.list_clients(auth=("admin", "pw")) + assert clients == [{"name": "phone", "uuid": "u1", "enabled": True}] + + +def test_list_clients_empty_on_error(host) -> None: + _capture(host, 0) + assert host.list_clients() == [] + + +def test_unpair_client_posts_uuid(host) -> None: + calls = _capture(host, 200) + assert host.unpair_client("u1") is True + assert calls[0] == { + "method": "POST", "path": "/api/clients/unpair", + "payload": {"uuid": "u1"}, "auth": None, + } + + +def test_unpair_client_rejects_empty_uuid(host) -> None: + calls = _capture(host, 200) + assert host.unpair_client("") is False + assert calls == [] # no request issued + + +def test_set_client_enabled_posts_uuid_and_flag(host) -> None: + calls = _capture(host, 200) + assert host.set_client_enabled("u2", False) is True + assert calls[0]["path"] == "/api/clients/update" + assert calls[0]["payload"] == {"uuid": "u2", "enabled": False} + + +# --- logs ----------------------------------------------------------------- + +def test_get_logs_decodes_text(host) -> None: + _capture(host, 200, b"line one\nline two") + assert host.get_logs() == "line one\nline two" + + +def test_get_logs_empty_on_error(host) -> None: + _capture(host, 0) + assert host.get_logs() == "" + + +# --- close_app ------------------------------------------------------------ + +def test_close_app_posts_close(host) -> None: + calls = _capture(host, 200, b'{"status": true}') + assert host.close_app() is True + assert calls[0]["method"] == "POST" + assert calls[0]["path"] == "/api/apps/close" + assert calls[0]["payload"] == {} + + +# --- apps ----------------------------------------------------------------- + +def test_get_apps_parses_array(host) -> None: + _capture(host, 200, json.dumps({"apps": [{"name": "Game", "index": 0}]}).encode()) + assert host.get_apps() == [{"name": "Game", "index": 0}] + + +def test_add_app_defaults_index_to_append(host) -> None: + calls = _capture(host, 200, b'{"status": true}') + assert host.add_app({"name": "Steam", "cmd": "steam"}) is True + assert calls[0]["path"] == "/api/apps" + assert calls[0]["payload"] == {"name": "Steam", "cmd": "steam", "index": -1} + + +def test_add_app_preserves_explicit_index_and_prep_cmd(host) -> None: + calls = _capture(host, 200, b'{"status": true}') + entry = {"name": "G", "cmd": "g", "index": 3, + "prep-cmd": [{"do": "a", "undo": "b", "elevated": False}]} + host.add_app(entry) + assert calls[0]["payload"]["index"] == 3 + assert calls[0]["payload"]["prep-cmd"] == [{"do": "a", "undo": "b", "elevated": False}] + + +def test_delete_app_uses_index_in_path(host) -> None: + calls = _capture(host, 200, b'{"status": true}') + assert host.delete_app(2) is True + assert calls[0] == {"method": "DELETE", "path": "/api/apps/2", "payload": None, "auth": None} + + +def test_delete_app_rejects_negative_index(host) -> None: + calls = _capture(host, 200) + assert host.delete_app(-1) is False + assert calls == [] + + +# --- covers --------------------------------------------------------------- + +def test_upload_cover_with_url_returns_path(host) -> None: + calls = _capture(host, 200, b'{"status": true, "path": "/cov/x.png"}') + out = host.upload_cover("igdb_42", url="https://images.igdb.com/x.png") + assert out == "/cov/x.png" + assert calls[0]["payload"] == {"key": "igdb_42", "url": "https://images.igdb.com/x.png"} + + +def test_upload_cover_requires_key_and_source(host) -> None: + calls = _capture(host, 200) + assert host.upload_cover("", url="https://images.igdb.com/x.png") == "" + assert host.upload_cover("igdb_42") == "" # no url and no data + assert calls == [] + + +# --- config --------------------------------------------------------------- + +def test_get_config_parses_dict(host) -> None: + _capture(host, 200, json.dumps({"status": True, "fps": "60", "platform": "linux"}).encode()) + cfg = host.get_config() + assert cfg["fps"] == "60" + assert cfg["platform"] == "linux" + + +def test_save_config_posts_settings(host) -> None: + calls = _capture(host, 200, b'{"status": true}') + assert host.save_config({"bitrate": "20000"}) is True + assert calls[0]["method"] == "POST" + assert calls[0]["path"] == "/api/config" + assert calls[0]["payload"] == {"bitrate": "20000"} + + +# --- restart -------------------------------------------------------------- + +def test_restart_via_api_success_on_200(host) -> None: + _capture(host, 200, b'{"status": true}') + assert host.restart_via_api() is True + + +def test_restart_via_api_tolerates_connection_drop(host) -> None: + _capture(host, 0) # restart drops the connection + assert host.restart_via_api() is True + + +# --- browse --------------------------------------------------------------- + +def test_browse_builds_query_and_parses_entries(host) -> None: + calls = _capture(host, 200, json.dumps({ + "path": "/home", "parent": "/", "entries": [{"name": "g", "type": "file", "path": "/home/g"}], + }).encode()) + out = host.browse("/home", "executable") + assert out["entries"][0]["name"] == "g" + assert calls[0]["method"] == "GET" + assert calls[0]["path"].startswith("/api/browse?") + assert "path=%2Fhome" in calls[0]["path"] + assert "type=executable" in calls[0]["path"] + + +def test_browse_empty_on_error(host) -> None: + _capture(host, 0) + assert host.browse("/home") == {} diff --git a/usr/bin/big-remote-play b/usr/bin/big-remote-play index ee364a8..5a45570 100755 --- a/usr/bin/big-remote-play +++ b/usr/bin/big-remote-play @@ -1,28 +1,33 @@ -#!/bin/bash -# Wrapper to launch Big Remote Play +#!/usr/bin/env bash +# Resilient launcher: survives a system Python upgrade without repackaging. +# The wheel installs under /usr/lib/pythonX.Y/site-packages, a path bound to the +# Python minor version at build time. A bare `python3 -m big_remote_play` wrapper +# would break with ModuleNotFoundError after a 3.X -> 3.Y upgrade; this scans for +# the installed tree and runs it with the current interpreter. +set -euo pipefail +module="big_remote_play" -APP_DIR="" +# 1. Fast path: importable under the current python3. +if python3 -c "import ${module}" 2>/dev/null; then + exec python3 -m "${module}" "$@" +fi -# Detect installation directory -# Check Python site-packages (newest to oldest supported versions) -# Then fallback to /usr/share/big-remote-play -for i in /usr/lib/python3.{25..6}/site-packages/big-remote-play /usr/share/big-remote-play; do - if [[ -d "$i" ]]; then - APP_DIR="$i" - break - fi +# 2. Fallback: locate the tree under any site-packages and run it explicitly. +for dir in /usr/lib/python3.*/site-packages \ + /usr/lib/python3/dist-packages \ + /usr/lib64/python3.*/site-packages; do + if [ -d "${dir}/${module}" ]; then + exec env PYTHONPATH="${dir}${PYTHONPATH:+:${PYTHONPATH}}" python3 -m "${module}" "$@" + fi done -# If not found, try local directory (development) relative to script -if [[ -z "$APP_DIR" ]]; then - SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" - APP_DIR="$SCRIPT_DIR/../share/big-remote-play" -fi - -if [[ ! -f "$APP_DIR/main.py" ]]; then - echo "Error: Application not found in $APP_DIR" - exit 1 -fi +# 3. Development fallback: run from a source checkout relative to this script. +script_dir="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)" +for src in "${script_dir}/../../src" "${script_dir}/../src"; do + if [ -d "${src}/${module}" ]; then + exec env PYTHONPATH="${src}${PYTHONPATH:+:${PYTHONPATH}}" python3 -m "${module}" "$@" + fi +done -# Execute main.py -exec python3 "$APP_DIR/main.py" "$@" +printf 'big-remote-play: cannot locate the %s package in any site-packages\n' "${module}" >&2 +exit 1 diff --git a/usr/share/big-remote-play/host/sunshine_manager.py b/usr/share/big-remote-play/host/sunshine_manager.py deleted file mode 100644 index ceea599..0000000 --- a/usr/share/big-remote-play/host/sunshine_manager.py +++ /dev/null @@ -1,449 +0,0 @@ -import subprocess, signal, os, shutil -from pathlib import Path -from utils.i18n import _ -class SunshineHost: - def __init__(self, cdir: Path = None): - self.config_dir = cdir or (Path.home() / '.config' / 'big-remoteplay' / 'sunshine') - self.config_dir.mkdir(parents=True, exist_ok=True) - self.process = None - self.pid = None - - def start(self, **kwargs): - if self.is_running(): - return True, "Already running" - - sc = shutil.which('sunshine') - if not sc: - return False, "Sunshine executable not found" - try: - config_file = self.config_dir / 'sunshine.conf' - # Prepare environment - env = os.environ.copy() - if 'DISPLAY' not in env: - env['DISPLAY'] = ':0' - - if 'XAUTHORITY' not in env: - home = os.path.expanduser('~') - xauth = os.path.join(home, '.Xauthority') - if os.path.exists(xauth): - env['XAUTHORITY'] = xauth - - if 'XDG_RUNTIME_DIR' not in env: - uid = os.getuid() - runtime_dir = f'/run/user/{uid}' - if os.path.exists(runtime_dir): - env['XDG_RUNTIME_DIR'] = runtime_dir - - # Pass WAYLAND_DISPLAY if exists - if 'WAYLAND_DISPLAY' in os.environ: - env['WAYLAND_DISPLAY'] = os.environ['WAYLAND_DISPLAY'] - - custom_paths = env.get('LD_LIBRARY_PATH', '') - base_libs = '/usr/share/big-remote-play/libs' - env['LD_LIBRARY_PATH'] = f"{base_libs}:{custom_paths}" if custom_paths else base_libs - - cmd = [ - sc, - str(config_file) - ] - - # Start process redirecting logs to file - log_path = self.config_dir / 'sunshine.log' - self.log_file = open(log_path, 'a') - - self.process = subprocess.Popen( - cmd, - text=True, - stdout=self.log_file, - stderr=subprocess.STDOUT, - env=env, - cwd=str(self.config_dir), # Force CWD for local configs - start_new_session=True # Create new group ID - ) - - self.pid = self.process.pid - - # Check if process died immediately (e.g. library error) - try: - # Wait a bit to see if startup fails - exit_code = self.process.wait(timeout=2.0) - - # If reached here, process ended (failed) - self.log_file.flush() - # Try to read the error from the log - error_detail = "" - try: - with open(log_path, 'r') as f: - lines = f.readlines() - if lines: - # Look for shared library errors in the last 10 lines - for line in lines[-10:]: - if "error while loading shared libraries" in line or "symbol lookup error" in line: - error_detail = line.strip() - break - except: - pass - - self.log_file.write(_("Sunshine failed to start (Exit code {}).\n").format(exit_code)) - if error_detail: - print(_("Sunshine failed to start: {}").format(error_detail)) - else: - print(_("Sunshine failed to start (Exit code {}). Check logs.").format(exit_code)) - - self.process = None - self.pid = None - return False, error_detail if error_detail else f"Exit code {exit_code}" - - except subprocess.TimeoutExpired: - # Process continues running after timeout, success! - pass - - # Save PID - pid_file = self.config_dir / 'sunshine.pid' - with open(pid_file, 'w') as f: - f.write(str(self.pid)) - - print(_("Sunshine started (PID: {})").format(self.pid)) - return True, None - - except Exception as e: - print(_("Error starting Sunshine: {}").format(e)) - return False, str(e) # Return tuple (success, error_message) - - def stop(self) -> bool: - """Stops Sunshine server""" - if not self.is_running(): - print(_("Sunshine is not running")) - return False - - try: - if self.process: - try: - pgid = os.getpgid(self.process.pid) - os.killpg(pgid, signal.SIGTERM) - try: - self.process.wait(timeout=2) - except subprocess.TimeoutExpired: - os.killpg(pgid, signal.SIGKILL) - except Exception as e: - try: self.process.terminate() - except: pass - else: - pid_file = self.config_dir / 'sunshine.pid' - if pid_file.exists(): - try: - with open(pid_file, 'r') as f: - pid = int(f.read().strip()) - os.kill(pid, signal.SIGTERM) - except: pass - - # Fallback for orphan processes - subprocess.run(['pkill', 'sunshine'], stderr=subprocess.DEVNULL) - - # Close log - if hasattr(self, 'log_file'): - try: self.log_file.close() - except: pass - del self.log_file - - pid_file = self.config_dir / 'sunshine.pid' - if pid_file.exists(): pid_file.unlink() - - self.process = None - self.pid = None - return True - except Exception as e: - subprocess.run(['pkill', '-9', 'sunshine'], stderr=subprocess.DEVNULL) - return False - - def restart(self) -> bool: - """Restarts the server""" - self.stop() - return self.start() - - def is_running(self) -> bool: - """Checks if Sunshine is running""" - # Check process directly - if self.process and self.process.poll() is None: - return True - - # Check PID file - pid_file = self.config_dir / 'sunshine.pid' - if pid_file.exists(): - try: - with open(pid_file, 'r') as f: - pid = int(f.read().strip()) - - # Check if process exists - os.kill(pid, 0) - return True - - except (OSError, ValueError): - # Process does not exist, clear PID file - pid_file.unlink() - return False - - # Check via pgrep - try: - result = subprocess.run( - ['pgrep', '-x', 'sunshine'], - capture_output=True - ) - return result.returncode == 0 - except: - return False - - def get_status(self) -> dict: - """Gets server status""" - return { - 'running': self.is_running(), - 'pid': self.pid, - 'config_dir': str(self.config_dir), - } - - def update_apps(self, apps_list: list) -> bool: - """ - Updates application list (apps.json) - - Args: - apps_list: List of dictionaries describing apps - Ex: [{'name': 'Steam', 'cmd': 'steam', ...}] - """ - try: - import json - apps_file = self.config_dir / 'apps.json' - - # Sunshine apps.json format - data = { - "env": { - "PATH": "$(PATH):$(HOME)/.local/bin" - }, - "apps": apps_list - } - - with open(apps_file, 'w') as f: - json.dump(data, f, indent=4) - - return True - except Exception as e: - print(f"Error saving apps.json: {e}") - return False - - def configure(self, settings: dict) -> bool: - """ - Configures Sunshine - - Args: - settings: Dictionary with settings - """ - try: - config_file = self.config_dir / 'sunshine.conf' - - # Load existing config - current_config = {} - if config_file.exists(): - try: - with open(config_file, 'r') as f: - for line in f: - line = line.strip() - if not line or line.startswith('#'): continue - if '=' in line: - parts = line.split('=', 1) - if len(parts) == 2: - current_config[parts[0].strip()] = parts[1].strip() - except Exception as e: - print(f"Error reading existing config: {e}") - - # Update with new settings - for k, v in settings.items(): - if v is None: - # Remove key if value is None - if k in current_config: - del current_config[k] - else: - current_config[k] = str(v) - - # Ensure pointing to apps.json - if 'apps_file' not in current_config: - current_config['apps_file'] = 'apps.json' - - # Save merged config - with open(config_file, 'w') as f: - for key, value in current_config.items(): - f.write(f"{key} = {value}\n") - - return True - - except Exception as e: - print(f"Error configuring Sunshine: {e}") - return False - - def send_pin(self, pin: str, name: str = None, auth: tuple[str, str] = None) -> tuple[bool, str]: - """Sends PIN to Sunshine via API""" - import urllib.request, ssl, json, base64 - - # Use 127.0.0.1 to avoid IPv6 (::1) issues if Sunshine binds to 0.0.0.0 - url = "https://127.0.0.1:47990/api/pin" - headers = { - "Content-Type": "application/json", - } - - if auth: - username, password = auth - auth_str = f"{username}:{password}" - b64_auth = base64.b64encode(auth_str.encode()).decode() - headers["Authorization"] = f"Basic {b64_auth}" - - ctx = ssl._create_unverified_context() - ctx.check_hostname = False - ctx.verify_mode = ssl.CERT_NONE - try: - # Fix for legacy server support or specific SSL options - ctx.options |= 0x4 # ssl.OP_LEGACY_SERVER_CONNECT - except: pass - - try: - payload = {"pin": pin} - if name: - payload["name"] = name - data = json.dumps(payload).encode('utf-8') - req = urllib.request.Request(url, data=data, headers=headers, method='POST') - - with urllib.request.urlopen(req, context=ctx, timeout=5) as response: - if response.status == 200: - return True, _("PIN sent successfully") - return False, f"HTTP Status {response.status}" - - except urllib.error.HTTPError as e: - if e.code == 401: - return False, _("Authentication Failed. Configure a user in Sunshine.") - return False, _("API Error: {} - {}").format(e.code, e.reason) - except Exception as e: - return False, _("Connection Error: {}").format(e) - - def create_user(self, username, password) -> tuple[bool, str]: - """Creates new admin user in Sunshine via API""" - import urllib.request, ssl, json - - url = "https://127.0.0.1:47990/api/users" - headers = { - "Content-Type": "application/json", - } - - ctx = ssl.create_default_context() - ctx.check_hostname = False - ctx.verify_mode = ssl.CERT_NONE - - try: - # Try sending password confirmation too, as error 400 suggests missing fields. - # Based on web form that requires confirmation. - # And on field IDs: usernameInput, passwordInput, confirmPasswordInput - data_dict = { - "usernameInput": username, - "passwordInput": password, - "confirmPasswordInput": password - } - data = json.dumps(data_dict).encode('utf-8') - req = urllib.request.Request(url, data=data, headers=headers, method='POST') - - with urllib.request.urlopen(req, context=ctx, timeout=5) as response: - if response.status == 200: - return True, _("User created successfully") - return False, f"HTTP Status {response.status}" - - except urllib.error.HTTPError as e: - msg = e.read().decode('utf-8') if e.fp else e.reason - return False, _("API Error: {} - {}").format(e.code, msg) - except Exception as e: - return False, _("Connection Error: {}").format(e) - def terminate_session(self, session_id: str, auth: tuple[str, str] = None) -> bool: - """Terminates a specific session via Sunshine API""" - if not session_id: - return False - - import urllib.request, ssl, base64 - - # Use 127.0.0.1 to avoid IPv6 issues - url = f"https://127.0.0.1:47990/api/sessions/{session_id}" - - headers = {} - if auth: - u, p = auth - headers["Authorization"] = f"Basic {base64.b64encode(f'{u}:{p}'.encode()).decode()}" - - ctx = ssl.create_default_context() - ctx.check_hostname = False - ctx.verify_mode = ssl.CERT_NONE - - try: - req = urllib.request.Request(url, headers=headers, method='DELETE') - with urllib.request.urlopen(req, context=ctx, timeout=5) as response: - return response.status in [200, 204] - except Exception as e: - return False - - def get_performance_stats(self, auth=None) -> dict: - """Fetches performance stats from Sunshine API""" - import urllib.request, ssl, json, base64 - url = "https://127.0.0.1:47990/api/stats" - headers = {"Content-Type": "application/json"} - if auth: - u, p = auth - headers["Authorization"] = f"Basic {base64.b64encode(f'{u}:{p}'.encode()).decode()}" - - ctx = ssl.create_default_context() - ctx.check_hostname = False - ctx.verify_mode = ssl.CERT_NONE - - try: - req = urllib.request.Request(url, headers=headers, method='GET') - with urllib.request.urlopen(req, context=ctx, timeout=2) as r: - body = r.read().decode('utf-8') - return json.loads(body) - except urllib.error.HTTPError as e: - if e.code == 404: - # Endpoint doesn't exist on this version, silent fail - return {} - return {} - except Exception as e: - return {} - - def get_active_sessions(self, auth=None) -> list: - """Fetches active sessions from Sunshine API""" - import urllib.request, ssl, json, base64 - url = "https://127.0.0.1:47990/api/sessions" - headers = {"Content-Type": "application/json"} - if auth: - u, p = auth - headers["Authorization"] = f"Basic {base64.b64encode(f'{u}:{p}'.encode()).decode()}" - - ctx = ssl.create_default_context() - ctx.check_hostname = False - ctx.verify_mode = ssl.CERT_NONE - - try: - req = urllib.request.Request(url, headers=headers, method='GET') - with urllib.request.urlopen(req, context=ctx, timeout=2) as r: - body = r.read().decode('utf-8') - data = json.loads(body) - # Handle different Sunshine versions response format - if isinstance(data, dict): return data.get('sessions', []) - return data if isinstance(data, list) else [] - except urllib.error.HTTPError as e: - if e.code == 404: - # Try fallback for older Sunshine versions - try: - url_fallback = "https://127.0.0.1:47990/api/clients/list" - req = urllib.request.Request(url_fallback, headers=headers, method='GET') - with urllib.request.urlopen(req, context=ctx, timeout=2) as r: - data = json.loads(r.read().decode('utf-8')) - if isinstance(data, dict): - # In older versions, connected clients are in 'clients' and have a 'connected' flag - clients = data.get('clients', []) - return [c for c in clients if c.get('connected')] - return data if isinstance(data, list) else [] - except: return [] - - return [] - except Exception as e: - return [] diff --git a/usr/share/big-remote-play/scripts/big-remoteplay-install.sh b/usr/share/big-remote-play/scripts/big-remoteplay-install.sh deleted file mode 100755 index 050bf0c..0000000 --- a/usr/share/big-remote-play/scripts/big-remoteplay-install.sh +++ /dev/null @@ -1,170 +0,0 @@ -#!/bin/bash -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" -APP_NAME="big-remote-play" -INSTALL_DIR="/opt/big-remote-play" -BIN_DIR="/usr/local/bin" -DESKTOP_DIR="/usr/share/applications" -ICON_DIR="/usr/share/icons/hicolor" - -echo "╔════════════════════════════════════════════════╗" -echo "║ Big Remote Play - Instalador ║" -echo "║ Sistema de Jogo Cooperativo Remoto ║" -echo "╚════════════════════════════════════════════════╝" -echo "" - -if [ "$EUID" -ne 0 ]; then - echo "⚠️ Este script precisa de permissões de root." - echo " Execute: sudo $0" - exit 1 -fi - -echo "📦 Verificando dependências..." - -MISSING_DEPS=() - -check_dependency() { - if ! command -v "$1" &> /dev/null; then - MISSING_DEPS+=("$2") - fi -} - -check_dependency "python3" "python" -check_dependency "glib-compile-schemas" "glib2" - -if ! python3 -c "import gi" 2>/dev/null; then - MISSING_DEPS+=("python-gobject") -fi - -if ! python3 -c "import gi; gi.require_version('Gtk', '4.0')" 2>/dev/null; then - MISSING_DEPS+=("gtk4") -fi - -if ! python3 -c "import gi; gi.require_version('Adw', '1')" 2>/dev/null; then - MISSING_DEPS+=("libadwaita") -fi - -check_dependency "avahi-daemon" "avahi" -check_dependency "systemctl" "systemd" - -if [ ${#MISSING_DEPS[@]} -ne 0 ]; then - echo "❌ Dependências faltando: ${MISSING_DEPS[*]}" - echo "" - echo "Instalando dependências com pacman..." - pacman -S --needed --noconfirm "${MISSING_DEPS[@]}" -fi - -echo "✅ Todas as dependências básicas instaladas!" -echo "" - -echo "🔍 Verificando Sunshine e Moonlight..." - -if ! command -v sunshine &> /dev/null; then - echo "⚠️ Sunshine não encontrado." - echo " Você pode instalar com: yay -S sunshine" - read -p " Deseja continuar sem Sunshine? (modo Host não funcionará) [s/N]: " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Ss]$ ]]; then - exit 1 - fi -else - echo "✅ Sunshine instalado" -fi - -if ! command -v moonlight &> /dev/null && ! command -v moonlight-qt &> /dev/null; then - echo "⚠️ Moonlight não encontrado." - echo " Você pode instalar com: yay -S moonlight-qt" - read -p " Deseja continuar sem Moonlight? (modo Guest não funcionará) [s/N]: " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Ss]$ ]]; then - exit 1 - fi -else - echo "✅ Moonlight instalado" -fi - -echo "" -echo "📂 Criando estrutura de diretórios..." - -mkdir -p "$INSTALL_DIR"/{bin,config,scripts,ui,docker,games,logs} -mkdir -p "$INSTALL_DIR/config"/{sunshine,moonlight} -mkdir -p ~/.config/big-remoteplay - -echo "📋 Copiando arquivos..." - -cp -r "$PROJECT_ROOT/src/"* "$INSTALL_DIR/" -cp -r "$PROJECT_ROOT/scripts/"* "$INSTALL_DIR/scripts/" -cp -r "$PROJECT_ROOT/data/"* "$INSTALL_DIR/" 2>/dev/null || true - -if [ -d "$PROJECT_ROOT/docker" ]; then - cp -r "$PROJECT_ROOT/docker/"* "$INSTALL_DIR/docker/" 2>/dev/null || true -fi - -echo "🔗 Criando executável..." - -cat > "$BIN_DIR/$APP_NAME" << 'EOF' -#!/bin/bash -cd /opt/big-remote-play -exec python3 main.py "$@" -EOF - -chmod +x "$BIN_DIR/$APP_NAME" - -echo "🖼️ Instalando ícone e entrada desktop..." - -if [ -f "$PROJECT_ROOT/data/icons/big-remoteplay.svg" ]; then - mkdir -p "$ICON_DIR/scalable/apps" - cp "$PROJECT_ROOT/data/icons/big-remoteplay.svg" "$ICON_DIR/scalable/apps/" -elif [ -f "$PROJECT_ROOT/data/icons/big-remoteplay.png" ]; then - mkdir -p "$ICON_DIR/256x256/apps" - cp "$PROJECT_ROOT/data/icons/big-remoteplay.png" "$ICON_DIR/256x256/apps/" -fi - -cat > "$DESKTOP_DIR/$APP_NAME.desktop" << EOF -[Desktop Entry] -Type=Application -Name=Big Remote Play -GenericName=Remote Gaming -Comment=Jogue cooperativamente através da rede -Icon=big-remoteplay -Exec=$APP_NAME -Terminal=false -Categories=Game;Network; -Keywords=gaming;remote;streaming;sunshine;moonlight; -StartupNotify=true -EOF - -chmod +x "$DESKTOP_DIR/$APP_NAME.desktop" - -echo "🔒 Configurando firewall (opcional)..." - -if command -v ufw &> /dev/null; then - read -p "Configurar firewall (UFW) automaticamente? [S/n]: " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Nn]$ ]]; then - ufw allow 47984:47990/tcp comment "Sunshine Control" - ufw allow 48010/tcp comment "Sunshine Streaming" - ufw allow 47998:48000/udp comment "Sunshine Streaming" - echo "✅ Regras de firewall configuradas" - fi -fi - -echo "🌐 Habilitando serviço Avahi (descoberta de rede)..." -systemctl enable --now avahi-daemon 2>/dev/null || true - -echo "" -echo "════════════════════════════════════════════════" -echo "✅ Instalação concluída com sucesso!" -echo "════════════════════════════════════════════════" -echo "" -echo "🚀 Para iniciar o aplicativo, execute:" -echo " $APP_NAME" -echo "" -echo "Ou procure por 'Big Remote Play' no menu de aplicativos." -echo "" -echo "📚 Documentação: $PROJECT_ROOT/docs/" -echo "⚙️ Configurações: ~/.config/big-remoteplay/" -echo "" -echo "Divirta-se! 🎮✨" diff --git a/usr/share/big-remote-play/scripts/configure_firewall.sh b/usr/share/big-remote-play/scripts/configure_firewall.sh index ae03b3c..bc301ef 100755 --- a/usr/share/big-remote-play/scripts/configure_firewall.sh +++ b/usr/share/big-remote-play/scripts/configure_firewall.sh @@ -2,54 +2,53 @@ # Description: Configures the firewall to allow Sunshine/BigRemotePlay traffic # Supports: firewalld, ufw, iptables +set -euo pipefail + echo "Starting firewall configuration for BigRemotePlay..." # Sunshine Ports: -# TCP Range: 47984-48020 (Include potential dynamic ports) +# TCP streaming/control ports. Do not open 47990/tcp: Sunshine Web UI stays local. # UDP Range: 47998-48020 (Include potential dynamic ports) -TCP_START="47984" -TCP_END="48020" +TCP_PORTS=(47984 47989 48010) UDP_START="47998" UDP_END="48020" -if command -v firewall-cmd &> /dev/null; then - echo "Detected: firewalld" - firewall-cmd --permanent --add-port=${TCP_START}-${TCP_END}/tcp - firewall-cmd --permanent --add-port=${UDP_START}-${UDP_END}/udp - firewall-cmd --permanent --add-port=47990/tcp - firewall-cmd --permanent --add-port=48011/udp - firewall-cmd --permanent --add-port=1900/udp - firewall-cmd --permanent --add-port=5353/udp - firewall-cmd --reload - echo "Firewalld configured." -elif command -v ufw &> /dev/null; then - echo "Detected: ufw" - ufw allow ${TCP_START}:${TCP_END}/tcp - ufw allow ${UDP_START}:${UDP_END}/udp - ufw allow 47990/tcp - ufw allow 48011/udp - ufw allow 1900/udp - ufw allow 5353/udp - ufw reload - echo "UFW configured." +if command -v firewall-cmd &>/dev/null; then + echo "Detected: firewalld" + for port in "${TCP_PORTS[@]}"; do + firewall-cmd --permanent --add-port="${port}/tcp" + done + firewall-cmd --permanent --add-port="${UDP_START}-${UDP_END}/udp" + firewall-cmd --permanent --add-port=1900/udp + firewall-cmd --permanent --add-port=5353/udp + firewall-cmd --reload + echo "Firewalld configured." +elif command -v ufw &>/dev/null; then + echo "Detected: ufw" + for port in "${TCP_PORTS[@]}"; do + ufw allow "${port}/tcp" + done + ufw allow "${UDP_START}:${UDP_END}/udp" + ufw allow 1900/udp + ufw allow 5353/udp + ufw reload + echo "UFW configured." else - echo "Fallback: Using iptables/ip6tables directly..." - # TCP - iptables -I INPUT -p tcp --dport ${TCP_START}:${TCP_END} -j ACCEPT - iptables -I INPUT -p tcp --dport 47990 -j ACCEPT - ip6tables -I INPUT -p tcp --dport ${TCP_START}:${TCP_END} -j ACCEPT - ip6tables -I INPUT -p tcp --dport 47990 -j ACCEPT - # UDP - iptables -I INPUT -p udp --dport ${UDP_START}:${UDP_END} -j ACCEPT - iptables -I INPUT -p udp --dport 48011 -j ACCEPT - iptables -I INPUT -p udp --dport 1900 -j ACCEPT - ip6tables -I INPUT -p udp --dport ${UDP_START}:${UDP_END} -j ACCEPT - ip6tables -I INPUT -p udp --dport 48011 -j ACCEPT - ip6tables -I INPUT -p udp --dport 1900 -j ACCEPT - # mDNS - iptables -I INPUT -p udp --dport 5353 -j ACCEPT - ip6tables -I INPUT -p udp --dport 5353 -j ACCEPT - echo "Iptables rules applied." + echo "Fallback: Using iptables/ip6tables directly..." + # TCP + for port in "${TCP_PORTS[@]}"; do + iptables -I INPUT -p tcp --dport "$port" -j ACCEPT + ip6tables -I INPUT -p tcp --dport "$port" -j ACCEPT + done + # UDP + iptables -I INPUT -p udp --dport "${UDP_START}:${UDP_END}" -j ACCEPT + iptables -I INPUT -p udp --dport 1900 -j ACCEPT + ip6tables -I INPUT -p udp --dport "${UDP_START}:${UDP_END}" -j ACCEPT + ip6tables -I INPUT -p udp --dport 1900 -j ACCEPT + # mDNS + iptables -I INPUT -p udp --dport 5353 -j ACCEPT + ip6tables -I INPUT -p udp --dport 5353 -j ACCEPT + echo "Iptables rules applied." fi # Enable IPv6 Forwarding and disable ICMPv6 blocks (essential for discovery) diff --git a/usr/share/big-remote-play/scripts/create-network_headscale.sh b/usr/share/big-remote-play/scripts/create-network_headscale.sh index 9168f14..6f34e90 100755 --- a/usr/share/big-remote-play/scripts/create-network_headscale.sh +++ b/usr/share/big-remote-play/scripts/create-network_headscale.sh @@ -1,4 +1,7 @@ #!/bin/bash +# shellcheck disable=SC1091,SC2005,SC2129 +# gettext fallback intentionally emits no newline, so translated menu strings are +# wrapped in echo throughout this legacy interactive script. # ============================================================================== # HEADSCALE ULTIMATE MANAGER - Rafael Ruscher Edition # Com Caddy Proxy para resolver erro de CORS (Failed to Fetch) @@ -7,8 +10,9 @@ # ============================================================================== set -e +umask 077 -# Cores para interface +# Interface colors GREEN='\033[0;32m' BLUE='\033[0;34m' YELLOW='\033[1;33m' @@ -16,95 +20,133 @@ RED='\033[0;31m' CYAN='\033[0;36m' NC='\033[0m' +# Machine-readable markers for the GUI (locale-independent ASCII); other output +# is human prose translated via gettext. +brp_data() { printf 'BRP_DATA %s=%s\n' "$1" "$2"; } +brp_phase() { printf 'BRP_PHASE %s\n' "$1"; } +AUTH_KEY_TMP="" + +cleanup_auth_key_tmp() { + if [ -n "$AUTH_KEY_TMP" ] && [ -f "$AUTH_KEY_TMP" ]; then + rm -f "$AUTH_KEY_TMP" + fi +} + +trap cleanup_auth_key_tmp EXIT INT TERM + +write_auth_key_file() { + cleanup_auth_key_tmp + AUTH_KEY_TMP=$(mktemp "${TMPDIR:-/tmp}/brp-headscale-auth.XXXXXX") + chmod 600 "$AUTH_KEY_TMP" + printf '%s' "$1" >"$AUTH_KEY_TMP" + printf 'file:%s' "$AUTH_KEY_TMP" +} + +export TEXTDOMAIN=big-remote-play +if [ -f /usr/bin/gettext.sh ]; then + . /usr/bin/gettext.sh +else + gettext() { printf '%s' "$1"; } + eval_gettext() { printf '%s' "$1"; } +fi + header() { - clear - echo -e "${BLUE}====================================================${NC}" - echo -e "${GREEN} HEADSCALE & CLOUDFLARE - REDE PRIVADA ${NC}" - echo -e "${BLUE}====================================================${NC}" + clear + echo -e "${BLUE}====================================================${NC}" + echo -e "${GREEN} $(gettext 'HEADSCALE & CLOUDFLARE - PRIVATE NETWORK') ${NC}" + echo -e "${BLUE}====================================================${NC}" } check_deps() { - echo -e "${YELLOW}Verificando dependências...${NC}" - for pkg in docker docker-compose jq curl miniupnpc; do - if ! command -v $pkg &> /dev/null; then - echo -e "${YELLOW}Instalando $pkg...${NC}" - sudo pacman -S $pkg --noconfirm 2>/dev/null || echo -e "${RED}Falha ao instalar $pkg. Instale manualmente.${NC}" - fi - done + echo -e "${YELLOW}$(gettext 'Checking dependencies...')${NC}" + for pkg in docker docker-compose jq curl miniupnpc; do + if ! command -v $pkg &>/dev/null; then + echo -e "${YELLOW}$(gettext 'Installing') $pkg...${NC}" + sudo pacman -S $pkg --noconfirm 2>/dev/null || echo -e "${RED}$(gettext 'Failed to install') $pkg. $(gettext 'Install it manually.')${NC}" + fi + done } -# --- FUNÇÃO PARA VERIFICAR PORTAS --- +# --- PORT CHECK FUNCTION --- check_ports() { - echo -e "${YELLOW}Verificando portas abertas...${NC}" - - # Verificar se Docker está rodando - if ! systemctl is-active --quiet docker; then - echo -e "${RED}Docker não está rodando. Iniciando...${NC}" - sudo systemctl start docker - sudo systemctl enable docker - fi - - # Verificar portas locais - echo -e "${CYAN}Portas locais em uso:${NC}" - sudo netstat -tulpn | grep -E ':(80|8080|41641)' || true - - # Tentar abrir portas no firewall - echo -e "${YELLOW}Configurando firewall...${NC}" - - # Para UFW - if command -v ufw &> /dev/null; then - sudo ufw allow 80/tcp 2>/dev/null || true - sudo ufw allow 8080/tcp 2>/dev/null || true - sudo ufw allow 41641/udp 2>/dev/null || true - sudo ufw reload 2>/dev/null || true - echo -e "${GREEN}Firewall UFW configurado${NC}" - fi - - # Para firewalld - if command -v firewall-cmd &> /dev/null; then - sudo firewall-cmd --permanent --add-port=80/tcp 2>/dev/null || true - sudo firewall-cmd --permanent --add-port=8080/tcp 2>/dev/null || true - sudo firewall-cmd --permanent --add-port=41641/udp 2>/dev/null || true - sudo firewall-cmd --reload 2>/dev/null || true - echo -e "${GREEN}Firewalld configurado${NC}" - fi + echo -e "${YELLOW}$(gettext 'Checking open ports...')${NC}" + + # Check whether Docker is running + if ! systemctl is-active --quiet docker; then + echo -e "${RED}$(gettext 'Docker is not running. Starting...')${NC}" + sudo systemctl start docker + sudo systemctl enable docker + fi + + # Check local ports + echo -e "${CYAN}$(gettext 'Local ports in use:')${NC}" + sudo netstat -tulpn | grep -E ':(80|443|41641)' || true + + # Try to open firewall ports + echo -e "${YELLOW}$(gettext 'Configuring firewall...')${NC}" + + # For UFW + if command -v ufw &>/dev/null; then + sudo ufw allow 80/tcp 2>/dev/null || true + sudo ufw allow 443/tcp 2>/dev/null || true + sudo ufw allow 41641/udp 2>/dev/null || true + sudo ufw reload 2>/dev/null || true + echo -e "${GREEN}$(gettext 'UFW firewall configured')${NC}" + fi + + # For firewalld + if command -v firewall-cmd &>/dev/null; then + sudo firewall-cmd --permanent --add-port=80/tcp 2>/dev/null || true + sudo firewall-cmd --permanent --add-port=443/tcp 2>/dev/null || true + sudo firewall-cmd --permanent --add-port=41641/udp 2>/dev/null || true + sudo firewall-cmd --reload 2>/dev/null || true + echo -e "${GREEN}$(gettext 'Firewalld configured')${NC}" + fi } -# --- FUNÇÃO PARA O SERVIDOR (HOST) --- +# --- SERVER (HOST) FUNCTION --- setup_host() { - header - echo -e "${YELLOW}CONFIGURAÇÃO DE HOST (SERVIDOR)${NC}" - - # Obter informações - read -p "Domínio (ex: vpn.ruscher.org): " DOMAIN - read -p "Cloudflare Zone ID: " ZONE_ID - read -p "Cloudflare API Token: " API_TOKEN - - # Verificar dependências - check_deps - check_ports - - # Criar diretórios - mkdir -p ~/headscale-server/{config,data,caddy_data} - cd ~/headscale-server - - # 1. Criando Caddyfile OTIMIZADO - echo -e "${YELLOW}Gerando configuração do Proxy Reverso (Caddy)...${NC}" - cat < Caddyfile + header + echo -e "${YELLOW}$(gettext 'HOST (SERVER) SETUP')${NC}" + + # Gather information + read -r -p "$(gettext 'Domain (e.g. vpn.ruscher.org): ')" DOMAIN + read -r -p "Cloudflare Zone ID: " ZONE_ID + read -r -p "Cloudflare API Token: " API_TOKEN + HEADSCALE_VERSION="${HEADSCALE_VERSION:-0.29.1}" + HEADSCALE_IMAGE="${HEADSCALE_IMAGE:-docker.io/headscale/headscale:v$HEADSCALE_VERSION}" + CADDY_IMAGE="${CADDY_IMAGE:-caddy:2}" + + # Check dependencies + check_deps + check_ports + + # Create directories + mkdir -p ~/headscale-server/{config,data,caddy_data,caddy_config} + cd ~/headscale-server + + # 1. Creating optimized Caddyfile + echo -e "${YELLOW}$(gettext 'Generating reverse proxy config (Caddy)...')${NC}" + cat <Caddyfile { - # Habilitar logs para debug + # Enable logs for debugging debug admin off } -:80, :8080 { - # Log de todas as requisições +$DOMAIN { + # Tailscale captive portal detection + handle /generate_204 { + respond 204 + } + + # Log every request log { output stdout level INFO } - # CORS headers para todas as respostas + # CORS headers for all responses header { Access-Control-Allow-Origin "*" Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" @@ -115,8 +157,9 @@ setup_host() { handle /web/* { reverse_proxy headscale-ui:80 { header_up Host {host} - header_up X-Real-IP {remote} - header_up X-Forwarded-For {remote} + header_up True-Client-IP {remote_host} + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} header_up X-Forwarded-Proto {scheme} } } @@ -125,8 +168,9 @@ setup_host() { handle /api/* { reverse_proxy headscale:8080 { header_up Host {host} - header_up X-Real-IP {remote} - header_up X-Forwarded-For {remote} + header_up True-Client-IP {remote_host} + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} header_up X-Forwarded-Proto {scheme} } } @@ -135,8 +179,9 @@ setup_host() { handle /ts2021/* { reverse_proxy headscale:8080 { header_up Host {host} - header_up X-Real-IP {remote} - header_up X-Forwarded-For {remote} + header_up True-Client-IP {remote_host} + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} header_up X-Forwarded-Proto {scheme} } } @@ -145,39 +190,48 @@ setup_host() { handle /register/* { reverse_proxy headscale:8080 { header_up Host {host} - header_up X-Real-IP {remote} - header_up X-Forwarded-For {remote} + header_up True-Client-IP {remote_host} + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} header_up X-Forwarded-Proto {scheme} } } - # Para todos os outros endpoints + # For all other endpoints handle { reverse_proxy headscale:8080 { header_up Host {host} - header_up X-Real-IP {remote} - header_up X-Forwarded-For {remote} + header_up True-Client-IP {remote_host} + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} header_up X-Forwarded-Proto {scheme} } } } EOF - # 2. Docker Compose ATUALIZADO - cat < docker-compose.yml + # 2. Updated Docker Compose + cat <docker-compose.yml services: headscale: - image: headscale/headscale:latest + image: $HEADSCALE_IMAGE container_name: headscale + read_only: true + tmpfs: + - /var/run/headscale volumes: - - ./config:/etc/headscale + - ./config:/etc/headscale:ro - ./data:/var/lib/headscale command: serve restart: unless-stopped + healthcheck: + test: ["CMD", "headscale", "health"] networks: - headscale-network ports: - - "41642:41641/udp" + - "41641:41641/udp" + - "127.0.0.1:8080:8080" + - "127.0.0.1:9090:9090" headscale-ui: image: ghcr.io/gurucomputing/headscale-ui:latest @@ -189,14 +243,16 @@ services: - headscale caddy: - image: caddy:latest + image: $CADDY_IMAGE container_name: caddy ports: - "80:80" - - "8080:8080" + - "443:443" + - "443:443/udp" volumes: - ./Caddyfile:/etc/caddy/Caddyfile - ./caddy_data:/data + - ./caddy_config:/config restart: unless-stopped networks: - headscale-network @@ -209,356 +265,359 @@ networks: driver: bridge EOF - # 3. Configuração do Headscale - echo -e "${YELLOW}Configurando Headscale...${NC}" - if [ ! -f ./config/config.yaml ]; then - curl -s https://raw.githubusercontent.com/juanfont/headscale/main/config-example.yaml -o ./config/config.yaml - - # Configurações essenciais - sed -i "s|server_url: .*|server_url: http://$DOMAIN|" ./config/config.yaml - sed -i 's|listen_addr: 127.0.0.1:8080|listen_addr: 0.0.0.0:8080|' ./config/config.yaml - sed -i 's|db_path: .*|db_path: /var/lib/headscale/db.sqlite|' ./config/config.yaml - sed -i 's|disable_check_updates: false|disable_check_updates: true|' ./config/config.yaml - sed -i 's|# magic_dns: true|magic_dns: true|' ./config/config.yaml - - # Permitir todas as rotas - echo "ip_prefixes:" >> ./config/config.yaml - echo " - 0.0.0.0/0" >> ./config/config.yaml - echo " - ::/0" >> ./config/config.yaml - fi - - # Permissões - sudo chmod -R 777 config data 2>/dev/null || true + # 3. Headscale configuration + echo -e "${YELLOW}$(gettext 'Configuring Headscale...')${NC}" + if [ ! -f ./config/config.yaml ]; then + curl -fsSL "https://raw.githubusercontent.com/juanfont/headscale/v$HEADSCALE_VERSION/config-example.yaml" -o ./config/config.yaml - # 4. DNS Cloudflare - echo -e "${YELLOW}Atualizando DNS na Cloudflare...${NC}" - CURRENT_IP=$(curl -s https://api.ipify.org) - - # Verificar se já existe registro - RESPONSE=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records?name=$DOMAIN" \ - -H "Authorization: Bearer $API_TOKEN" \ - -H "Content-Type: application/json") - - RECORD_ID=$(echo $RESPONSE | jq -r '.result[0].id // empty') - - if [ -n "$RECORD_ID" ] && [ "$RECORD_ID" != "null" ]; then - # Atualizar registro existente - curl -s -X PUT "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/$RECORD_ID" \ - -H "Authorization: Bearer $API_TOKEN" \ - -H "Content-Type: application/json" \ - --data "{\"type\":\"A\",\"name\":\"$DOMAIN\",\"content\":\"$CURRENT_IP\",\"ttl\":120,\"proxied\":false}" \ - | jq -r '.success' - echo -e "${GREEN}Registro DNS atualizado${NC}" - else - # Criar novo registro - curl -s -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records" \ - -H "Authorization: Bearer $API_TOKEN" \ - -H "Content-Type: application/json" \ - --data "{\"type\":\"A\",\"name\":\"$DOMAIN\",\"content\":\"$CURRENT_IP\",\"ttl\":120,\"proxied\":false}" \ - | jq -r '.success' - echo -e "${GREEN}Novo registro DNS criado${NC}" - fi - - # 5. Subindo containers - echo -e "${YELLOW}Iniciando containers...${NC}" - docker-compose down 2>/dev/null || true - docker-compose up -d - - # Aguardar inicialização - echo -e "${YELLOW}Aguardando inicialização dos serviços...${NC}" - sleep 15 + # Essential settings + sed -i "s|server_url: .*|server_url: https://$DOMAIN|" ./config/config.yaml + sed -i 's|listen_addr: 127.0.0.1:8080|listen_addr: 0.0.0.0:8080|' ./config/config.yaml + sed -i 's|disable_check_updates: false|disable_check_updates: true|' ./config/config.yaml + fi - # 6. Configurar UPnP (Roteador) - IP_LOCAL=$(ip route get 1 | awk '{print $7;exit}') - echo -e "${YELLOW}Configurando portas no roteador via UPnP...${NC}" - - # Tentar abrir portas - for port in 80 8080; do - upnpc -d $port TCP 2>/dev/null || true - upnpc -e "Headscale HTTP" -a $IP_LOCAL $port $port TCP 2>/dev/null || true - done - - upnpc -d 41642 UDP 2>/dev/null || true - upnpc -e "Headscale Data" -a $IP_LOCAL 41642 41642 UDP 2>/dev/null || true + # Permissions + chmod 700 config data caddy_data caddy_config 2>/dev/null || true - # 7. Criar Usuário e Chaves - echo -e "${YELLOW}Criando usuário e chaves...${NC}" - - # Tentar criar usuário - docker exec headscale headscale users create amigos 2>/dev/null || true - - # Obter USER_ID - USER_ID=$(docker exec headscale headscale users list -o json 2>/dev/null | jq -r '.[] | select(.name=="amigos") | .id') - - if [ -z "$USER_ID" ] || [ "$USER_ID" = "null" ]; then - echo -e "${YELLOW}Criando novo usuário...${NC}" - docker exec headscale headscale users create amigos - USER_ID=$(docker exec headscale headscale users list -o json | jq -r '.[] | select(.name=="amigos") | .id') - fi - - # Criar Auth Key (válida por 7 dias) - AUTH_KEY=$(docker exec headscale headscale preauthkeys create --user "$USER_ID" --reusable --expiration 168h 2>/dev/null) - - # Criar API Key para UI - API_KEY=$(docker exec headscale headscale apikeys create 2>/dev/null | tr -d '\n') + # 4. Validate generated config with the selected Headscale image. + echo -e "${YELLOW}$(gettext 'Validating Headscale configuration...')${NC}" + if ! docker run --rm -v "$PWD/config:/etc/headscale:ro" "$HEADSCALE_IMAGE" configtest; then + echo -e "${RED}$(gettext 'Invalid Headscale configuration; aborting before changing DNS or starting services.')${NC}" + return 1 + fi - # 8. Testar serviços - echo -e "${YELLOW}Testando serviços...${NC}" - - # Testar Headscale - if curl -s http://localhost:8080/health > /dev/null; then - echo -e "${GREEN}✓ Headscale está funcionando${NC}" - else - echo -e "${RED}✗ Headscale não responde${NC}" - docker-compose logs headscale --tail=20 - fi - - # Testar Caddy - if curl -s http://localhost:80 > /dev/null; then - echo -e "${GREEN}✓ Caddy está funcionando${NC}" - else - echo -e "${RED}✗ Caddy não responde${NC}" - docker-compose logs caddy --tail=20 - fi - - # 9. Mostrar informações finais - header - echo -e "${GREEN}✅ SERVIDOR CONFIGURADO COM SUCESSO!${NC}" - echo "" - echo -e "${CYAN}=== INFORMAÇÕES DE ACESSO ===${NC}" - echo -e "Interface Web: ${YELLOW}http://$DOMAIN/web${NC}" - echo -e "URL da API: ${CYAN}http://$DOMAIN${NC}" - echo -e "Seu IP Público: ${GREEN}$CURRENT_IP${NC}" - echo -e "IP Local do Servidor: ${GREEN}$IP_LOCAL${NC}" - echo "" - echo -e "${CYAN}=== CREDENCIAIS ===${NC}" - echo -e "API Key (para UI): ${CYAN}$API_KEY${NC}" - echo -e "${GREEN}Chave para Amigos: $AUTH_KEY${NC}" - echo "" - echo -e "${CYAN}=== COMANDOS ÚTEIS ===${NC}" - echo -e "Ver logs: ${YELLOW}cd ~/headscale-server && docker-compose logs -f${NC}" - echo -e "Reiniciar: ${YELLOW}cd ~/headscale-server && docker-compose restart${NC}" - echo -e "Parar: ${YELLOW}cd ~/headscale-server && docker-compose down${NC}" - echo "" - echo -e "${YELLOW}⚠️ COMPARTILHE APENAS A 'CHAVE PARA AMIGOS'${NC}" - echo -e "${BLUE}====================================================${NC}" - - # Iniciar monitoramento de logs - echo "" - read -p "Deseja ver os logs em tempo real? (s/N): " -n 1 -r - echo - if [[ $REPLY =~ ^[Ss]$ ]]; then - cd ~/headscale-server - docker-compose logs -f --tail=50 - fi + # 5. DNS Cloudflare + echo -e "${YELLOW}Atualizando DNS na Cloudflare...${NC}" + CURRENT_IP=$(curl -s https://api.ipify.org) + + # Check whether a record already exists + RESPONSE=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records?name=$DOMAIN" \ + -H "Authorization: Bearer $API_TOKEN" \ + -H "Content-Type: application/json") + + RECORD_ID=$(echo "$RESPONSE" | jq -r '.result[0].id // empty') + + if [ -n "$RECORD_ID" ] && [ "$RECORD_ID" != "null" ]; then + # Atualizar registro existente + curl -s -X PUT "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/$RECORD_ID" \ + -H "Authorization: Bearer $API_TOKEN" \ + -H "Content-Type: application/json" \ + --data "{\"type\":\"A\",\"name\":\"$DOMAIN\",\"content\":\"$CURRENT_IP\",\"ttl\":120,\"proxied\":false}" | + jq -r '.success' + echo -e "${GREEN}Registro DNS atualizado${NC}" + else + # Create new record + curl -s -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records" \ + -H "Authorization: Bearer $API_TOKEN" \ + -H "Content-Type: application/json" \ + --data "{\"type\":\"A\",\"name\":\"$DOMAIN\",\"content\":\"$CURRENT_IP\",\"ttl\":120,\"proxied\":false}" | + jq -r '.success' + echo -e "${GREEN}$(gettext 'New DNS record created')${NC}" + fi + + # 5. Subindo containers + echo -e "${YELLOW}$(gettext 'Starting containers...')${NC}" + docker-compose down 2>/dev/null || true + docker-compose up -d + + # Wait for startup + echo -e "${YELLOW}$(gettext 'Waiting for services to start...')${NC}" + sleep 15 + + # 6. Configurar UPnP (Roteador) + IP_LOCAL=$(ip route get 1 | awk '{print $7;exit}') + echo -e "${YELLOW}$(gettext 'Configuring router ports via UPnP...')${NC}" + + # Try to open ports + for port in 80 443; do + upnpc -d $port TCP 2>/dev/null || true + upnpc -e "Headscale HTTPS" -a "$IP_LOCAL" "$port" "$port" TCP 2>/dev/null || true + done + + upnpc -d 41641 UDP 2>/dev/null || true + upnpc -e "Headscale Data" -a "$IP_LOCAL" 41641 41641 UDP 2>/dev/null || true + + # 7. Create user and keys + echo -e "${YELLOW}$(gettext 'Creating user and keys...')${NC}" + + # Try to create user + docker exec headscale headscale users create amigos 2>/dev/null || true + + # Get USER_ID + USER_ID=$(docker exec headscale headscale users list -o json 2>/dev/null | jq -r '.[] | select(.name=="amigos") | .id') + + if [ -z "$USER_ID" ] || [ "$USER_ID" = "null" ]; then + echo -e "${YELLOW}$(gettext 'Creating new user...')${NC}" + docker exec headscale headscale users create amigos + USER_ID=$(docker exec headscale headscale users list -o json | jq -r '.[] | select(.name=="amigos") | .id') + fi + + # Create Auth Key (valid for 7 days) + AUTH_KEY=$(docker exec headscale headscale preauthkeys create --user "$USER_ID" --reusable --expiration 168h 2>/dev/null) + + # 8. Test services + echo -e "${YELLOW}$(gettext 'Testing services...')${NC}" + + # Test Headscale + if curl -s http://localhost:8080/health >/dev/null; then + echo -e "${GREEN}✓ $(gettext 'Headscale is working')${NC}" + else + echo -e "${RED}✗ $(gettext 'Headscale is not responding')${NC}" + docker-compose logs headscale --tail=20 + fi + + # Test Caddy + if curl -s http://localhost:80 >/dev/null; then + echo -e "${GREEN}✓ $(gettext 'Caddy is working')${NC}" + else + echo -e "${RED}✗ $(gettext 'Caddy is not responding')${NC}" + docker-compose logs caddy --tail=20 + fi + + # 9. Show final information + header + # Machine-readable data for the GUI (raw, locale-independent). + brp_data web_ui "https://$DOMAIN/web" + brp_data api_url "https://$DOMAIN" + brp_data public_ip "$CURRENT_IP" + brp_data local_ip "$IP_LOCAL" + brp_data auth_key "$AUTH_KEY" + brp_data domain "$DOMAIN" + brp_phase 0.95 + + echo -e "${GREEN}✅ $(gettext 'SERVER CONFIGURED SUCCESSFULLY!')${NC}" + echo "" + echo -e "${CYAN}=== $(gettext 'ACCESS INFORMATION') ===${NC}" + echo -e "$(gettext 'Web Interface:') ${YELLOW}https://$DOMAIN/web${NC}" + echo -e "$(gettext 'API URL:') ${CYAN}https://$DOMAIN${NC}" + echo -e "$(gettext 'Your Public IP:') ${GREEN}$CURRENT_IP${NC}" + echo -e "$(gettext 'Server Local IP:') ${GREEN}$IP_LOCAL${NC}" + echo "" + echo -e "${CYAN}=== $(gettext 'CREDENTIALS') ===${NC}" + echo -e "${GREEN}$(gettext 'Key for Friends:') $AUTH_KEY${NC}" + echo "" + echo -e "${CYAN}=== $(gettext 'USEFUL COMMANDS') ===${NC}" + echo -e "$(gettext 'View logs: ')${YELLOW}cd ~/headscale-server && docker-compose logs -f${NC}" + echo -e "$(gettext 'Restart: ')${YELLOW}cd ~/headscale-server && docker-compose restart${NC}" + echo -e "Parar: ${YELLOW}cd ~/headscale-server && docker-compose down${NC}" + echo "" + echo -e "${YELLOW}⚠️ $(gettext 'SHARE ONLY THE KEY FOR FRIENDS')${NC}" + echo -e "${BLUE}====================================================${NC}" + + # Start log monitoring + echo "" + read -p "$(gettext 'Show real-time logs? (y/N): ')" -n 1 -r + echo + if [[ $REPLY =~ ^[Ss]$ ]]; then + cd ~/headscale-server + docker-compose logs -f --tail=50 + fi } # --- FUNÇÃO PARA O CLIENTE (GUEST) --- setup_guest() { - header - echo -e "${YELLOW}CONFIGURAÇÃO DE CLIENTE (GUEST)${NC}" - - # Obter informações - read -p "Domínio do Servidor (ex: vpn.ruscher.org): " HOST_DOMAIN - read -p "Chave de Acesso (Auth Key): " AUTH_KEY - - echo -e "${YELLOW}Verificando dependências...${NC}" - - # 1. Testar conexão com servidor ANTES de instalar - echo -e "${CYAN}Testando conexão com o servidor...${NC}" - if curl -s --connect-timeout 10 "http://$HOST_DOMAIN/health" > /dev/null 2>&1; then - echo -e "${GREEN}✓ Servidor acessível${NC}" - elif curl -s --connect-timeout 10 "http://$HOST_DOMAIN" > /dev/null 2>&1; then - echo -e "${GREEN}✓ Servidor acessível${NC}" - else - echo -e "${RED}✗ Não foi possível conectar ao servidor${NC}" - echo -e "${YELLOW}Verifique:${NC}" - echo "1. O domínio está correto?" - echo "2. O servidor está online?" - echo "3. A Auth Key é válida?" - read -p "Continuar mesmo assim? (s/N): " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Ss]$ ]]; then - exit 1 - fi - fi - - # 2. Instalar Tailscale - echo -e "${YELLOW}Instalando Tailscale...${NC}" - if ! command -v tailscale &> /dev/null; then - sudo pacman -S tailscale --noconfirm - else - echo -e "${GREEN}✓ Tailscale já instalado${NC}" - fi - - # 3. Parar e limpar estado anterior - echo -e "${YELLOW}Preparando ambiente...${NC}" - sudo systemctl stop tailscaled 2>/dev/null || true - sudo rm -rf /var/lib/tailscale/* 2>/dev/null || true - - # 4. Iniciar serviço - sudo systemctl start tailscaled - sudo systemctl enable tailscaled - - # 5. Conectar à rede - echo -e "${YELLOW}Conectando à rede privada...${NC}" - - # Tentar conexão com timeout - if timeout 60 sudo tailscale up \ - --login-server="http://$HOST_DOMAIN" \ - --authkey="$AUTH_KEY" \ - --reset \ - --accept-routes=true \ - --accept-dns=true \ - --hostname="guest-$(hostname)-$(date +%s)" \ - --advertise-exit-node=false; then - - echo -e "${GREEN}✅ Conexão estabelecida!${NC}" - else - echo -e "${RED}✗ Falha na conexão${NC}" - echo -e "${YELLOW}Tentando método alternativo...${NC}" - - # Método alternativo - sudo tailscale up \ - --login-server="http://$HOST_DOMAIN:8080" \ - --authkey="$AUTH_KEY" \ - --reset - fi - - # 6. Aguardar e verificar - echo -e "${YELLOW}Aguardando conexão...${NC}" - sleep 5 - - # 7. Verificar status - echo -e "${CYAN}=== STATUS DA CONEXÃO ===${NC}" - STATUS_JSON=$(tailscale status --json 2>/dev/null) - BACKEND_STATE=$(echo "$STATUS_JSON" | jq -r .BackendState) - - if [ "$BACKEND_STATE" = "Running" ]; then - echo -e "${GREEN}✅ Conectado com sucesso!${NC}" - - # Obter IP - YOUR_IP=$(tailscale ip -4 2>/dev/null || tailscale ip 2>/dev/null) - echo -e "Seu IP na rede: ${GREEN}$YOUR_IP${NC}" - - # Testar ping para servidor (Headscale Server IP usually starts with 100.64.0.1 if using magic dns, but not always pingable) - # Instead, just show success since BackendState is Running - - # Mostrar peers - echo "" - echo -e "${CYAN}=== DISPOSITIVOS CONECTADOS ===${NC}" - PEERS_COUNT=$(echo "$STATUS_JSON" | jq '.Peer | length') - if [ "$PEERS_COUNT" -gt 0 ]; then - tailscale status - else - echo -e "${YELLOW}Nenhum outro dispositivo conectado ainda. Você é o primeiro!${NC}" - echo -e "${YELLOW}Convide amigos usando a mesma Chave de Acesso.${NC}" - fi - - else - echo -e "${RED}✗ Falha na conexão${NC}" - echo "" - echo -e "${YELLOW}=== TROUBLESHOOTING ===${NC}" - echo "1. Verifique se o servidor está online" - echo "2. Confirme a Auth Key" - echo "3. Tente reiniciar: sudo systemctl restart tailscaled" - echo "4. Verifique logs: sudo journalctl -u tailscaled -f" - - # Mostrar logs - echo "" - read -p "Ver logs do Tailscale? (s/N): " -n 1 -r - echo - if [[ $REPLY =~ ^[Ss]$ ]]; then - sudo journalctl -u tailscaled -n 50 --no-pager - fi - fi - - # 8. Configurar firewall se necessário - if command -v ufw &> /dev/null; then - echo -e "${YELLOW}Configurando firewall...${NC}" - sudo ufw allow in on tailscale0 2>/dev/null || true - sudo ufw reload 2>/dev/null || true - fi - - echo -e "${BLUE}====================================================${NC}" - echo -e "${GREEN}Configuração do cliente concluída!${NC}" - echo -e "${YELLOW}Para desconectar: sudo tailscale down${NC}" - echo -e "${YELLOW}Para reconectar: sudo tailscale up${NC}" + header + echo -e "${YELLOW}$(gettext 'CLIENT (GUEST) SETUP')${NC}" + + # Gather information + read -r -p "$(gettext 'Server domain (e.g. vpn.ruscher.org): ')" HOST_DOMAIN + read -r -p "$(gettext 'Access Key (Auth Key): ')" AUTH_KEY + + echo -e "${YELLOW}$(gettext 'Checking dependencies...')${NC}" + + # 1. Test connection to the server BEFORE installing + echo -e "${CYAN}$(gettext 'Testing connection to the server...')${NC}" + if curl -fsS --connect-timeout 10 "https://$HOST_DOMAIN/health" >/dev/null 2>&1; then + echo -e "${GREEN}✓ $(gettext 'Server reachable')${NC}" + elif curl -fsS --connect-timeout 10 "https://$HOST_DOMAIN" >/dev/null 2>&1; then + echo -e "${GREEN}✓ $(gettext 'Server reachable')${NC}" + else + echo -e "${RED}✗ $(gettext 'Could not connect to the server')${NC}" + echo -e "${YELLOW}$(gettext 'Check:')${NC}" + echo "$(gettext '1. Is the domain correct?')" + echo "$(gettext '2. Is the server online?')" + echo "$(gettext '3. Is the Auth Key valid?')" + read -p "Continuar mesmo assim? (s/N): " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Ss]$ ]]; then + exit 1 + fi + fi + + # 2. Install Tailscale + echo -e "${YELLOW}$(gettext 'Installing Tailscale...')${NC}" + if ! command -v tailscale &>/dev/null; then + sudo pacman -S tailscale --noconfirm + else + echo -e "${GREEN}✓ $(gettext 'Tailscale already installed')${NC}" + fi + + # 3. Start service + sudo systemctl start tailscaled + sudo systemctl enable tailscaled + + # 4. Connect to the network + echo -e "${YELLOW}$(gettext 'Connecting to the private network...')${NC}" + AUTH_KEY_ARG=$(write_auth_key_file "$AUTH_KEY") + + # Try connection with timeout + if timeout 60 sudo tailscale up \ + --login-server="https://$HOST_DOMAIN" \ + --auth-key="$AUTH_KEY_ARG" \ + --reset \ + --force-reauth \ + --accept-routes=true \ + --accept-dns=true \ + --hostname="guest-$(hostname)-$(date +%s)" \ + --advertise-exit-node=false; then + + echo -e "${GREEN}✅ $(gettext 'Connection established!')${NC}" + else + echo -e "${RED}✗ $(gettext 'Connection failed')${NC}" + echo -e "${YELLOW}$(gettext 'Trying alternative method...')${NC}" + + # Alternative method + sudo tailscale up \ + --login-server="https://$HOST_DOMAIN" \ + --auth-key="$AUTH_KEY_ARG" \ + --force-reauth \ + --reset + fi + + # 5. Wait and verify + echo -e "${YELLOW}$(gettext 'Waiting for connection...')${NC}" + sleep 5 + + # 6. Check status + echo -e "${CYAN}=== $(gettext 'CONNECTION STATUS') ===${NC}" + STATUS_JSON=$(tailscale status --json 2>/dev/null) + BACKEND_STATE=$(echo "$STATUS_JSON" | jq -r .BackendState) + + if [ "$BACKEND_STATE" = "Running" ]; then + echo -e "${GREEN}✅ $(gettext 'Connected successfully!')${NC}" + + # Get IP + YOUR_IP=$(tailscale ip -4 2>/dev/null || tailscale ip 2>/dev/null) + echo -e "$(gettext 'Your network IP: ')${GREEN}$YOUR_IP${NC}" + + # Testar ping para servidor (Headscale Server IP usually starts with 100.64.0.1 if using magic dns, but not always pingable) + # Instead, just show success since BackendState is Running + + # Show peers + echo "" + echo -e "${CYAN}=== $(gettext 'CONNECTED DEVICES') ===${NC}" + PEERS_COUNT=$(echo "$STATUS_JSON" | jq '.Peer | length') + if [ "$PEERS_COUNT" -gt 0 ]; then + tailscale status + else + echo -e "${YELLOW}$(gettext 'No other device connected yet. You are the first!')${NC}" + echo -e "${YELLOW}$(gettext 'Invite friends using the same Access Key.')${NC}" + fi + + else + echo -e "${RED}✗ $(gettext 'Connection failed')${NC}" + echo "" + echo -e "${YELLOW}=== $(gettext 'TROUBLESHOOTING') ===${NC}" + echo "$(gettext '1. Check that the server is online')" + echo "$(gettext '2. Check the Auth Key')" + echo "$(gettext '3. Try restarting:') sudo systemctl restart tailscaled" + echo "$(gettext '4. Check logs:') sudo journalctl -u tailscaled -f" + + # Show logs + echo "" + read -p "$(gettext 'View Tailscale logs? (y/N): ')" -n 1 -r + echo + if [[ $REPLY =~ ^[Ss]$ ]]; then + sudo journalctl -u tailscaled -n 50 --no-pager + fi + fi + + # 7. Configure firewall if needed + if command -v ufw &>/dev/null; then + echo -e "${YELLOW}$(gettext 'Configuring firewall...')${NC}" + sudo ufw allow in on tailscale0 2>/dev/null || true + sudo ufw reload 2>/dev/null || true + fi + + echo -e "${BLUE}====================================================${NC}" + echo -e "${GREEN}$(gettext 'Client setup complete!')${NC}" + echo -e "${YELLOW}Para desconectar: sudo tailscale down${NC}" + echo -e "${YELLOW}Para reconectar: sudo tailscale up${NC}" } # --- FUNÇÃO DE TROUBLESHOOTING --- troubleshoot() { - header - echo -e "${YELLOW}=== TROUBLESHOOTING AVANÇADO ===${NC}" - echo "1) Verificar status do servidor" - echo "2) Verificar logs do servidor" - echo "3) Testar conexão externa" - echo "4) Recriar chaves de acesso" - echo "5) Voltar ao menu principal" - read -p "Escolha uma opção: " TROUBLE_OPT - - case $TROUBLE_OPT in - 1) - if [ -d ~/headscale-server ]; then - cd ~/headscale-server - docker-compose ps - docker-compose logs --tail=20 - else - echo -e "${RED}Diretório do servidor não encontrado${NC}" - fi - ;; - 2) - if [ -d ~/headscale-server ]; then - cd ~/headscale-server - echo -e "${CYAN}=== LOGS DO HEADSCALE ===${NC}" - docker-compose logs headscale --tail=50 - echo -e "${CYAN}=== LOGS DO CADDY ===${NC}" - docker-compose logs caddy --tail=50 - fi - ;; - 3) - read -p "Domínio para testar: " TEST_DOMAIN - echo -e "${YELLOW}Testando $TEST_DOMAIN...${NC}" - curl -v --connect-timeout 10 "http://$TEST_DOMAIN/health" || \ - curl -v --connect-timeout 10 "http://$TEST_DOMAIN" || \ - echo -e "${RED}Falha na conexão${NC}" - ;; - 4) - if [ -d ~/headscale-server ]; then - cd ~/headscale-server - echo -e "${YELLOW}Recriando chaves...${NC}" - docker exec headscale headscale preauthkeys list --user amigos - read -p "Deseja criar nova chave? (s/N): " -n 1 -r - echo - if [[ $REPLY =~ ^[Ss]$ ]]; then - NEW_KEY=$(docker exec headscale headscale preauthkeys create --user amigos --reusable --expiration 168h) - echo -e "${GREEN}Nova chave: $NEW_KEY${NC}" - fi - fi - ;; - esac - - read -p "Pressione Enter para continuar..." - main_menu + header + echo -e "${YELLOW}=== $(gettext 'ADVANCED TROUBLESHOOTING') ===${NC}" + echo "$(gettext '1) Check server status')" + echo "$(gettext '2) Check server logs')" + echo "$(gettext '3) Test external connection')" + echo "$(gettext '4) Recreate access keys')" + echo "$(gettext '5) Back to main menu')" + read -r -p "$(gettext 'Choose an option: ')" TROUBLE_OPT + + case $TROUBLE_OPT in + 1) + if [ -d ~/headscale-server ]; then + cd ~/headscale-server + docker-compose ps + docker-compose logs --tail=20 + else + echo -e "${RED}$(gettext 'Server directory not found')${NC}" + fi + ;; + 2) + if [ -d ~/headscale-server ]; then + cd ~/headscale-server + echo -e "${CYAN}=== $(gettext 'HEADSCALE LOGS') ===${NC}" + docker-compose logs headscale --tail=50 + echo -e "${CYAN}=== $(gettext 'CADDY LOGS') ===${NC}" + docker-compose logs caddy --tail=50 + fi + ;; + 3) + read -r -p "$(gettext 'Domain to test: ')" TEST_DOMAIN + echo -e "${YELLOW}$(gettext 'Testing') $TEST_DOMAIN...${NC}" + curl -v --connect-timeout 10 "https://$TEST_DOMAIN/health" || + curl -v --connect-timeout 10 "https://$TEST_DOMAIN" || + echo -e "${RED}$(gettext 'Connection failed')${NC}" + ;; + 4) + if [ -d ~/headscale-server ]; then + cd ~/headscale-server + echo -e "${YELLOW}$(gettext 'Recreating keys...')${NC}" + docker exec headscale headscale preauthkeys list --user amigos + read -p "$(gettext 'Create a new key? (y/N): ')" -n 1 -r + echo + if [[ $REPLY =~ ^[Ss]$ ]]; then + NEW_KEY=$(docker exec headscale headscale preauthkeys create --user amigos --reusable --expiration 168h) + echo -e "${GREEN}$(gettext 'New key: ')$NEW_KEY${NC}" + fi + fi + ;; + esac + + read -r -p "$(gettext 'Press Enter to continue...')" + main_menu } # --- MENU PRINCIPAL --- main_menu() { - header - check_deps - echo "Selecione uma opção:" - echo "1) Ser o HOST (Criar e Gerenciar a Rede)" - echo "2) Ser o GUEST (Entrar na rede de um amigo)" - echo "3) Troubleshooting" - echo "4) Sair" - read -p "Opção: " OPT - - case $OPT in - 1) setup_host ;; - 2) setup_guest ;; - 3) troubleshoot ;; - *) exit 0 ;; - esac + header + check_deps + echo "$(gettext 'Select an option:')" + echo "$(gettext '1) Be the HOST (create and manage the network)')" + echo "$(gettext '2) Be the GUEST (join a friend network)')" + echo "$(gettext '3) Troubleshooting')" + echo "$(gettext '4) Exit')" + read -r -p "$(gettext 'Option: ')" OPT + + case $OPT in + 1) setup_host ;; + 2) setup_guest ;; + 3) troubleshoot ;; + *) exit 0 ;; + esac } # Executar menu principal diff --git a/usr/share/big-remote-play/scripts/create-network_tailscale.sh b/usr/share/big-remote-play/scripts/create-network_tailscale.sh index 1afa18e..4059a28 100755 --- a/usr/share/big-remote-play/scripts/create-network_tailscale.sh +++ b/usr/share/big-remote-play/scripts/create-network_tailscale.sh @@ -1,703 +1,744 @@ #!/bin/bash +# shellcheck disable=SC1091,SC2005 +# gettext fallback intentionally emits no newline, so translated menu strings are +# wrapped in echo throughout this legacy interactive script. # Tailscale Network Manager Script -# Versão: 1.0 -# Desenvolvido para BigLinux/Manjaro +# $(gettext 'Version:') 1.0 +# Developed for BigLinux/Manjaro -# Cores para melhor visualização +umask 077 + +# Colors for better visualization RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' -PURPLE='\033[0;35m' CYAN='\033[0;36m' WHITE='\033[1;37m' NC='\033[0m' # No Color -# Variáveis globais -TAILSCALE_CONFIG="$HOME/.tailscale-script" -AUTH_KEY_FILE="$TAILSCALE_CONFIG/auth_keys.txt" +# Machine-readable markers for the GUI (locale-independent ASCII); other output +# is human prose translated via gettext. +brp_phase() { printf 'BRP_PHASE %s\n' "$1"; } +export TEXTDOMAIN=big-remote-play +if [ -f /usr/bin/gettext.sh ]; then + . /usr/bin/gettext.sh +else + gettext() { printf '%s' "$1"; } + eval_gettext() { printf '%s' "$1"; } +fi + +# Global variables +TAILSCALE_CONFIG="${XDG_CONFIG_HOME:-$HOME/.config}/big-remote-play/tailscale" ACCOUNTS_FILE="$TAILSCALE_CONFIG/accounts.txt" +AUTH_KEY_TMP="" + +cleanup_auth_key_tmp() { + if [ -n "$AUTH_KEY_TMP" ] && [ -f "$AUTH_KEY_TMP" ]; then + rm -f "$AUTH_KEY_TMP" + fi +} + +trap cleanup_auth_key_tmp EXIT INT TERM -# Função para verificar dependências +write_auth_key_file() { + cleanup_auth_key_tmp + AUTH_KEY_TMP=$(mktemp "${TMPDIR:-/tmp}/brp-tailscale-auth.XXXXXX") + chmod 600 "$AUTH_KEY_TMP" + printf '%s' "$1" >"$AUTH_KEY_TMP" + printf 'file:%s' "$AUTH_KEY_TMP" +} + +# Function to check dependencies check_dependencies() { - echo -e "${YELLOW}Verificando dependências...${NC}" - - # Verificar se o Tailscale está instalado - if ! command -v tailscale &> /dev/null; then - echo -e "${RED}Tailscale não encontrado. Instalando...${NC}" - - # Adicionar repositório AUR (yay necessário) - if ! command -v yay &> /dev/null; then - echo -e "${YELLOW}Instalando yay (AUR helper)...${NC}" - sudo pacman -S --needed git base-devel --noconfirm - git clone https://aur.archlinux.org/yay.git /tmp/yay - cd /tmp/yay || exit 1 - makepkg -si --noconfirm - cd - || exit 1 - fi - - # Instalar tailscale do AUR - yay -S tailscale-bin --noconfirm - - # Habilitar e iniciar serviço - sudo systemctl enable tailscaled - sudo systemctl start tailscaled - fi - - # Verificar se o jq está instalado - if ! command -v jq &> /dev/null; then - echo -e "${RED}jq não encontrado. Instalando...${NC}" - sudo pacman -S jq --noconfirm - fi - - # Verificar se o curl está instalado - if ! command -v curl &> /dev/null; then - echo -e "${RED}curl não encontrado. Instalando...${NC}" - sudo pacman -S curl --noconfirm - fi - - # Criar diretório de configuração - mkdir -p "$TAILSCALE_CONFIG" + brp_phase 0.1 + echo -e "${YELLOW}$(gettext 'Checking dependencies...')${NC}" + + # Check whether Tailscale is installed + if ! command -v tailscale &>/dev/null; then + echo -e "${RED}$(gettext 'Tailscale not found. Installing...')${NC}" + + # Add AUR repository (yay required) + if ! command -v yay &>/dev/null; then + echo -e "${YELLOW}$(gettext 'Installing yay (AUR helper)...')${NC}" + sudo pacman -S --needed git base-devel --noconfirm + git clone https://aur.archlinux.org/yay.git /tmp/yay + cd /tmp/yay || exit 1 + makepkg -si --noconfirm + cd - || exit 1 + fi + + # Install tailscale from AUR + yay -S tailscale-bin --noconfirm + + # Enable and start service + sudo systemctl enable tailscaled + sudo systemctl start tailscaled + fi + + # Check whether jq is installed + if ! command -v jq &>/dev/null; then + echo -e "${RED}$(gettext 'jq not found. Installing...')${NC}" + sudo pacman -S jq --noconfirm + fi + + # Check whether curl is installed + if ! command -v curl &>/dev/null; then + echo -e "${RED}$(gettext 'curl not found. Installing...')${NC}" + sudo pacman -S curl --noconfirm + fi + + # Create config directory + mkdir -p "$TAILSCALE_CONFIG" + chmod 700 "$TAILSCALE_CONFIG" 2>/dev/null || true } -# Função para fazer login no Tailscale +# Function to log in to Tailscale login_tailscale() { - echo -e "${BLUE}=== LOGIN NO TAILSCALE ===${NC}" - - # Verificar e iniciar o serviço primeiro - echo -e "${YELLOW}Verificando serviço tailscaled...${NC}" - - if ! systemctl is-active --quiet tailscaled; then - echo -e "${YELLOW}Serviço tailscaled não está rodando. Iniciando...${NC}" - sudo systemctl start tailscaled - sleep 3 - - if ! systemctl is-active --quiet tailscaled; then - echo -e "${RED}✗ Falha ao iniciar tailscaled. Verifique manualmente:${NC}" - echo "sudo systemctl status tailscaled" - echo "sudo journalctl -u tailscaled -f" - return 1 - fi - - sudo systemctl enable tailscaled - fi - - echo -e "${GREEN}✓ Serviço tailscaled está rodando${NC}" - sleep 2 - - echo -e "${YELLOW}Escolha o método de login:${NC}" - echo "1) Login via browser (recomendado)" - echo "2) Login com chave de autenticação" - echo "3) Voltar" - - read -r LOGIN_OPTION - - case $LOGIN_OPTION in - 1) - echo -e "${GREEN}Iniciando login via browser...${NC}" - echo -e "${YELLOW}Uma URL será aberta no seu navegador. Faça login com sua conta.${NC}" - - if sudo tailscale up --reset 2>&1 | grep -q "https://"; then - echo -e "${GREEN}URL de login gerada. Siga as instruções no navegador.${NC}" - else - sudo tailscale login - fi - - echo -e "${YELLOW}Aguardando autenticação...${NC}" - for i in {1..15}; do - sleep 2 - if sudo tailscale status &>/dev/null; then - echo -e "${GREEN}✓ Login confirmado!${NC}" - break - fi - echo -n "." - done - echo "" - ;; - 2) - echo -e "${CYAN}Digite sua chave de autenticação:${NC}" - echo -e "${YELLOW}(Formato esperado: tskey-auth-xxxxxx-yyyyyy)${NC}" - read -r AUTH_KEY - - if [ -n "$AUTH_KEY" ]; then - echo -e "${YELLOW}Autenticando com chave...${NC}" - - if ! sudo tailscale status &>/dev/null; then - echo -e "${YELLOW}Serviço não responde. Tentando reconectar...${NC}" - sudo systemctl restart tailscaled - sleep 3 - fi - - OUTPUT=$(sudo tailscale up --reset --auth-key="$AUTH_KEY" 2>&1) - EXIT_CODE=$? - - if [ $EXIT_CODE -ne 0 ]; then - echo -e "${YELLOW}Primeira tentativa falhou. Tentando método alternativo...${NC}" - - if echo "$OUTPUT" | grep -q "interactive"; then - sudo tailscale up --auth-key="$AUTH_KEY" - elif echo "$OUTPUT" | grep -q "unauthenticated"; then - sudo tailscale login --auth-key="$AUTH_KEY" - else - echo -e "${YELLOW}Reiniciando serviço e tentando novamente...${NC}" - sudo systemctl stop tailscaled - sleep 2 - sudo systemctl start tailscaled - sleep 3 - sudo tailscale up --auth-key="$AUTH_KEY" - fi - fi - - if sudo tailscale status &>/dev/null; then - echo -e "${GREEN}✓ Login realizado com sucesso!${NC}" - echo "$(date): $AUTH_KEY" >> "$AUTH_KEY_FILE" - else - echo -e "${RED}✗ Erro ao fazer login. Diagnóstico:${NC}" - echo "1. Verifique se a chave é válida (não expirada)" - echo "2. Verifique a conectividade: ping 8.8.8.8" - echo "3. Verifique o serviço: sudo journalctl -u tailscaled -n 20" - echo "" - echo -e "${YELLOW}Comando manual alternativo:${NC}" - echo "sudo tailscale up --auth-key=\"$AUTH_KEY\" --reset" - fi - else - echo -e "${RED}Chave não pode estar vazia!${NC}" - return 1 - fi - ;; - 3) - return - ;; - *) - echo -e "${RED}Opção inválida!${NC}" - ;; - esac - - # Verificação de sucesso - echo -e "${YELLOW}Verificando conexão...${NC}" - - MAX_RETRIES=8 - RETRY_COUNT=0 - LOGIN_SUCCESS=false - - while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do - if sudo tailscale status &>/dev/null; then - LOGIN_SUCCESS=true - break - fi - echo -e "${YELLOW}Aguardando conexão... (tentativa $((RETRY_COUNT+1))/$MAX_RETRIES)${NC}" - sleep 3 - RETRY_COUNT=$((RETRY_COUNT + 1)) - done - - if [ "$LOGIN_SUCCESS" = true ]; then - echo -e "${GREEN}✓ Login realizado e conexão estabelecida com sucesso!${NC}" - echo "" - echo -e "${CYAN}Informações do dispositivo:${NC}" - - if command -v jq &> /dev/null; then - DEVICE_NAME=$(tailscale status --json 2>/dev/null | jq -r '.Self.DNSName' 2>/dev/null | sed 's/\.$//') - fi - - if [ -z "$DEVICE_NAME" ]; then - DEVICE_NAME=$(tailscale status 2>/dev/null | head -1 | awk '{print $2}') - fi - - [ -n "$DEVICE_NAME" ] && echo -e "Nome: ${GREEN}$DEVICE_NAME${NC}" - - IPV4=$(tailscale ip -4 2>/dev/null) - IPV6=$(tailscale ip -6 2>/dev/null) - - [ -n "$IPV4" ] && echo -e "IPv4: ${GREEN}$IPV4${NC}" - [ -n "$IPV6" ] && echo -e "IPv6: ${GREEN}$IPV6${NC}" - - echo "" - echo -e "${CYAN}Dispositivos na rede:${NC}" - tailscale status 2>/dev/null | head -5 || echo "Nenhum dispositivo encontrado" - - if [ -n "$DEVICE_NAME" ] && [ -n "$IPV4" ]; then - echo "$DEVICE_NAME:$IPV4:$(date)" >> "$ACCOUNTS_FILE" - fi - else - echo -e "${RED}✗ Falha na conexão. Diagnóstico completo:${NC}" - echo "" - echo -e "${YELLOW}1. Status do serviço:${NC}" - systemctl status tailscaled --no-pager | head -3 - - echo -e "${YELLOW}2. Logs recentes:${NC}" - sudo journalctl -u tailscaled -n 5 --no-pager - - echo -e "${YELLOW}3. Conectividade:${NC}" - if ping -c 1 8.8.8.8 &>/dev/null; then - echo -e "${GREEN} ✓ Internet OK${NC}" - else - echo -e "${RED} ✗ Sem internet${NC}" - fi - - echo "" - echo -e "${YELLOW}Comandos para resolver manualmente:${NC}" - echo "sudo systemctl restart tailscaled" - echo "sudo tailscale down" - echo "sudo tailscale up --reset" - echo "" - echo -e "${CYAN}Após executar os comandos acima, tente fazer login novamente.${NC}" - fi + echo -e "${BLUE}=== $(gettext 'TAILSCALE LOGIN') ===${NC}" + + # Check and start the service first + echo -e "${YELLOW}$(gettext 'Checking tailscaled service...')${NC}" + + if ! systemctl is-active --quiet tailscaled; then + echo -e "${YELLOW}$(gettext 'tailscaled service is not running. Starting...')${NC}" + sudo systemctl start tailscaled + sleep 3 + + if ! systemctl is-active --quiet tailscaled; then + echo -e "${RED}✗ $(gettext 'Failed to start tailscaled. Check manually:')${NC}" + echo "sudo systemctl status tailscaled" + echo "sudo journalctl -u tailscaled -f" + return 1 + fi + + sudo systemctl enable tailscaled + fi + + echo -e "${GREEN}✓ $(gettext 'tailscaled service is running')${NC}" + sleep 2 + + echo -e "${YELLOW}$(gettext 'Choose the login method:')${NC}" + echo "$(gettext '1) Browser login (recommended)')" + echo "$(gettext '2) Login with auth key')" + echo "$(gettext '3) Back')" + + read -r LOGIN_OPTION + + case $LOGIN_OPTION in + 1) + brp_phase 0.5 + echo -e "${GREEN}$(gettext 'Starting browser login...')${NC}" + echo -e "${YELLOW}$(gettext 'A URL will open in your browser. Log in with your account.')${NC}" + + if sudo tailscale up --reset 2>&1 | grep -q "https://"; then + echo -e "${GREEN}$(gettext 'Login URL generated. Follow the instructions in the browser.')${NC}" + else + sudo tailscale login + fi + + echo -e "${YELLOW}$(gettext 'Waiting for authentication...')${NC}" + for _ in {1..15}; do + sleep 2 + if sudo tailscale status &>/dev/null; then + echo -e "${GREEN}✓ $(gettext 'Login confirmed!')${NC}" + break + fi + echo -n "." + done + echo "" + ;; + 2) + echo -e "${CYAN}$(gettext 'Enter your auth key:')${NC}" + echo -e "${YELLOW}($(gettext 'Expected format:') tskey-auth-xxxxxx-yyyyyy)${NC}" + read -r AUTH_KEY + + if [ -n "$AUTH_KEY" ]; then + echo -e "${YELLOW}$(gettext 'Authenticating with key...')${NC}" + AUTH_KEY_ARG=$(write_auth_key_file "$AUTH_KEY") + + if ! sudo tailscale status &>/dev/null; then + echo -e "${YELLOW}$(gettext 'Service not responding. Reconnecting...')${NC}" + sudo systemctl restart tailscaled + sleep 3 + fi + + OUTPUT=$(sudo tailscale up --reset --force-reauth --auth-key="$AUTH_KEY_ARG" 2>&1) + EXIT_CODE=$? + + if [ $EXIT_CODE -ne 0 ]; then + echo -e "${YELLOW}$(gettext 'First attempt failed. Trying alternative method...')${NC}" + + if echo "$OUTPUT" | grep -q "interactive"; then + sudo tailscale up --force-reauth --auth-key="$AUTH_KEY_ARG" + elif echo "$OUTPUT" | grep -q "unauthenticated"; then + sudo tailscale login --auth-key="$AUTH_KEY_ARG" + else + echo -e "${YELLOW}$(gettext 'Restarting service and trying again...')${NC}" + sudo systemctl stop tailscaled + sleep 2 + sudo systemctl start tailscaled + sleep 3 + sudo tailscale up --force-reauth --auth-key="$AUTH_KEY_ARG" + fi + fi + + if sudo tailscale status &>/dev/null; then + echo -e "${GREEN}✓ $(gettext 'Login successful!')${NC}" + else + echo -e "${RED}✗ $(gettext 'Login error. Diagnostics:')${NC}" + echo "$(gettext '1. Check that the key is valid (not expired)')" + echo "$(gettext '2. Check connectivity:') ping 8.8.8.8" + echo "$(gettext '3. Check the service:') sudo journalctl -u tailscaled -n 20" + echo "" + echo -e "${YELLOW}$(gettext 'Alternative manual command:')${NC}" + echo "sudo tailscale up --auth-key=file:/path/to/auth-key --force-reauth --reset" + fi + else + echo -e "${RED}$(gettext 'Key cannot be empty!')${NC}" + return 1 + fi + ;; + 3) + return + ;; + *) + echo -e "${RED}$(gettext 'Invalid option!')${NC}" + ;; + esac + + # Success check + echo -e "${YELLOW}$(gettext 'Checking connection...')${NC}" + + MAX_RETRIES=8 + RETRY_COUNT=0 + LOGIN_SUCCESS=false + + while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do + if sudo tailscale status &>/dev/null; then + LOGIN_SUCCESS=true + break + fi + echo -e "${YELLOW}$(gettext 'Waiting for connection, attempt') $((RETRY_COUNT + 1))/$MAX_RETRIES${NC}" + sleep 3 + RETRY_COUNT=$((RETRY_COUNT + 1)) + done + + if [ "$LOGIN_SUCCESS" = true ]; then + echo -e "${GREEN}✓ $(gettext 'Logged in and connection established successfully!')${NC}" + echo "" + echo -e "${CYAN}$(gettext 'Device information:')${NC}" + + if command -v jq &>/dev/null; then + DEVICE_NAME=$(tailscale status --json 2>/dev/null | jq -r '.Self.DNSName' 2>/dev/null | sed 's/\.$//') + fi + + if [ -z "$DEVICE_NAME" ]; then + DEVICE_NAME=$(tailscale status 2>/dev/null | head -1 | awk '{print $2}') + fi + + [ -n "$DEVICE_NAME" ] && echo -e "$(gettext 'Name: ')${GREEN}$DEVICE_NAME${NC}" + + IPV4=$(tailscale ip -4 2>/dev/null) + IPV6=$(tailscale ip -6 2>/dev/null) + + [ -n "$IPV4" ] && echo -e "IPv4: ${GREEN}$IPV4${NC}" + [ -n "$IPV6" ] && echo -e "IPv6: ${GREEN}$IPV6${NC}" + + echo "" + echo -e "${CYAN}$(gettext 'Devices on the network:')${NC}" + tailscale status 2>/dev/null | head -5 || echo "Nenhum dispositivo encontrado" + + if [ -n "$DEVICE_NAME" ] && [ -n "$IPV4" ]; then + printf '%s:%s:%s\n' "$DEVICE_NAME" "$IPV4" "$(date)" >>"$ACCOUNTS_FILE" + chmod 600 "$ACCOUNTS_FILE" 2>/dev/null || true + fi + else + echo -e "${RED}✗ $(gettext 'Connection failed. Full diagnostics:')${NC}" + echo "" + echo -e "${YELLOW}$(gettext '1. Service status:')${NC}" + systemctl status tailscaled --no-pager | head -3 + + echo -e "${YELLOW}2. Logs recentes:${NC}" + sudo journalctl -u tailscaled -n 5 --no-pager + + echo -e "${YELLOW}$(gettext '3. Connectivity:')${NC}" + if ping -c 1 8.8.8.8 &>/dev/null; then + echo -e "${GREEN} ✓ Internet OK${NC}" + else + echo -e "${RED} ✗ Sem internet${NC}" + fi + + echo "" + echo -e "${YELLOW}Comandos para resolver manualmente:${NC}" + echo "sudo systemctl restart tailscaled" + echo "sudo tailscale down" + echo "sudo tailscale up --reset" + echo "" + echo -e "${CYAN}$(gettext 'After running the commands above, try logging in again.')${NC}" + fi } -# Função para criar nova rede/conta +# Function to create a new network/account create_network() { - echo -e "${BLUE}=== CRIAR NOVA REDE TAILSCALE ===${NC}" - echo -e "${YELLOW}Nota: No Tailscale, 'redes' são contas/orgs diferentes.${NC}" - echo -e "${YELLOW}Você precisa de uma conta diferente para cada rede.${NC}" - - echo -e "${CYAN}Nome da rede/empresa:${NC}" - read -r NETWORK_NAME - - if [ -z "$NETWORK_NAME" ]; then - echo -e "${RED}Nome não pode estar vazio!${NC}" - return 1 - fi - - echo -e "${YELLOW}Para criar uma nova rede:${NC}" - echo "1. Acesse https://login.tailscale.com" - echo "2. Crie uma nova conta com um email diferente" - echo "3. Ou use um domínio diferente para criar um org separado" - echo "" - echo -e "${CYAN}Deseja fazer logout e login com nova conta? (s/N):${NC}" - read -r CONFIRM - - if [[ "$CONFIRM" =~ ^[Ss]$ ]]; then - logout_tailscale - login_tailscale - fi + echo -e "${BLUE}=== $(gettext 'CREATE NEW TAILSCALE NETWORK') ===${NC}" + echo -e "${YELLOW}$(gettext 'Note: in Tailscale, networks are different accounts/orgs.')${NC}" + echo -e "${YELLOW}$(gettext 'You need a different account for each network.')${NC}" + + echo -e "${CYAN}$(gettext 'Network/company name:')${NC}" + read -r NETWORK_NAME + + if [ -z "$NETWORK_NAME" ]; then + echo -e "${RED}$(gettext 'Name cannot be empty!')${NC}" + return 1 + fi + + echo -e "${YELLOW}$(gettext 'To create a new network:')${NC}" + echo "1. Acesse https://login.tailscale.com" + echo "$(gettext '2. Create a new account with a different email')" + echo "$(gettext '3. Or use a different domain to create a separate org')" + echo "" + echo -e "${CYAN}$(gettext 'Log out and log in with a new account? (y/N):')${NC}" + read -r CONFIRM + + if [[ "$CONFIRM" =~ ^[Ss]$ ]]; then + logout_tailscale + login_tailscale + fi } -# Função para listar redes/dispositivos +# Function to list networks/devices list_networks() { - echo -e "${BLUE}=== STATUS DA REDE TAILSCALE ===${NC}" + echo -e "${BLUE}=== $(gettext 'TAILSCALE NETWORK STATUS') ===${NC}" - if ! sudo tailscale status &>/dev/null; then - echo -e "${RED}Não conectado ao Tailscale. Faça login primeiro.${NC}" - return 1 - fi + if ! sudo tailscale status &>/dev/null; then + echo -e "${RED}$(gettext 'Not connected to Tailscale. Log in first.')${NC}" + return 1 + fi - echo -e "${GREEN}Status atual:${NC}" - sudo tailscale status + echo -e "${GREEN}$(gettext 'Current status:')${NC}" + sudo tailscale status - echo "" - echo -e "${YELLOW}Endereços IP:${NC}" - sudo tailscale ip + echo "" + echo -e "${YELLOW}$(gettext 'IP addresses:')${NC}" + sudo tailscale ip - echo "" - if command -v jq &> /dev/null; then - echo -e "${CYAN}Informações detalhadas:${NC}" - sudo tailscale status --json | jq '.Self' 2>/dev/null || echo "Não foi possível obter detalhes" - fi + echo "" + if command -v jq &>/dev/null; then + echo -e "${CYAN}$(gettext 'Detailed information:')${NC}" + sudo tailscale status --json | jq '.Self' 2>/dev/null || echo "$(gettext 'Could not get details')" + fi } -# Função para listar todos os dispositivos +# Function to list all devices list_devices() { - echo -e "${BLUE}=== DISPOSITIVOS NA REDE ===${NC}" - - if ! sudo tailscale status &>/dev/null; then - echo -e "${RED}Não conectado ao Tailscale. Faça login primeiro.${NC}" - return 1 - fi - - echo -e "${GREEN}Dispositivos conectados:${NC}" - - LINE_COUNT=$(sudo tailscale status | wc -l) - - if [ "$LINE_COUNT" -gt 1 ]; then - sudo tailscale status | tail -n +2 | while read -r line; do - DEVICE_IP=$(echo "$line" | awk '{print $1}') - DEVICE_NAME=$(echo "$line" | awk '{print $2}') - DEVICE_STATUS=$(echo "$line" | awk '{print $3}') - - if [ "$DEVICE_STATUS" == "online" ]; then - echo -e "${GREEN}✓ $DEVICE_NAME - $DEVICE_IP (Online)${NC}" - else - echo -e "${RED}✗ $DEVICE_NAME - $DEVICE_IP (Offline)${NC}" - fi - done - else - echo -e "${YELLOW}Apenas este dispositivo conectado à rede${NC}" - THIS_DEVICE=$(sudo tailscale status | head -1) - echo -e "${CYAN}→ $THIS_DEVICE${NC}" - fi + echo -e "${BLUE}=== $(gettext 'DEVICES ON THE NETWORK') ===${NC}" + + if ! sudo tailscale status &>/dev/null; then + echo -e "${RED}$(gettext 'Not connected to Tailscale. Log in first.')${NC}" + return 1 + fi + + echo -e "${GREEN}Dispositivos conectados:${NC}" + + LINE_COUNT=$(sudo tailscale status | wc -l) + + if [ "$LINE_COUNT" -gt 1 ]; then + sudo tailscale status | tail -n +2 | while read -r line; do + DEVICE_IP=$(echo "$line" | awk '{print $1}') + DEVICE_NAME=$(echo "$line" | awk '{print $2}') + DEVICE_STATUS=$(echo "$line" | awk '{print $3}') + + if [ "$DEVICE_STATUS" == "online" ]; then + echo -e "${GREEN}✓ $DEVICE_NAME - $DEVICE_IP (Online)${NC}" + else + echo -e "${RED}✗ $DEVICE_NAME - $DEVICE_IP (Offline)${NC}" + fi + done + else + echo -e "${YELLOW}$(gettext 'Only this device connected to the network')${NC}" + THIS_DEVICE=$(sudo tailscale status | head -1) + echo -e "${CYAN}→ $THIS_DEVICE${NC}" + fi } -# Função para adicionar novo dispositivo +# Function to add a new device add_device() { - echo -e "${BLUE}=== ADICIONAR NOVO DISPOSITIVO ===${NC}" - echo "" - echo -e "${YELLOW}Método 1: URL de convite${NC}" - echo "1. Acesse https://login.tailscale.com/admin/invite" - echo "2. Gere um link de convite" - echo "3. Execute no novo dispositivo: curl -fsSL | sh" - echo "" - echo -e "${YELLOW}Método 2: Chave de autenticação${NC}" - echo "1. Acesse https://login.tailscale.com/admin/settings/keys" - echo "2. Gere uma chave de autenticação" - echo "3. No novo dispositivo: sudo tailscale up --auth-key=" - echo "" - echo -e "${YELLOW}Método 3: Login com mesma conta${NC}" - echo "Basta fazer login com a mesma conta no novo dispositivo" - echo "" - echo -e "${CYAN}Pressione ENTER para voltar ao menu principal${NC}" - read -r + echo -e "${BLUE}=== $(gettext 'ADD NEW DEVICE') ===${NC}" + echo "" + echo -e "${YELLOW}$(gettext 'Method 1: invite URL')${NC}" + echo "1. Acesse https://login.tailscale.com/admin/invite" + echo "2. Gere um link de convite" + echo "3. Execute no novo dispositivo: curl -fsSL | sh" + echo "" + echo -e "${YELLOW}$(gettext 'Method 2: auth key')${NC}" + echo "1. Acesse https://login.tailscale.com/admin/settings/keys" + echo "$(gettext '2. Generate an auth key')" + echo "3. No novo dispositivo: sudo tailscale up --auth-key=" + echo "" + echo -e "${YELLOW}$(gettext 'Method 3: login with the same account')${NC}" + echo "$(gettext 'Just log in with the same account on the new device')" + echo "" + echo -e "${CYAN}$(gettext 'Press ENTER to return to the main menu')${NC}" + read -r } -# Função para remover dispositivo +# Function to remove a device remove_device() { - echo -e "${BLUE}=== REMOVER DISPOSITIVO ===${NC}" - echo -e "${RED}CUIDADO: Esta ação removerá o dispositivo da rede!${NC}" - - list_devices - - echo -e "${CYAN}Digite o nome do dispositivo para remover:${NC}" - read -r DEVICE_NAME - - if [ -z "$DEVICE_NAME" ]; then - echo -e "${RED}Nome não pode estar vazio!${NC}" - return 1 - fi - - echo -e "${YELLOW}Tem certeza que deseja remover '$DEVICE_NAME'? (s/N):${NC}" - read -r CONFIRM - - if [[ "$CONFIRM" =~ ^[Ss]$ ]]; then - echo -e "${YELLOW}Para remover via API, você precisa:${NC}" - echo "1. Acesse https://login.tailscale.com/admin/machines" - echo "2. Encontre o dispositivo '$DEVICE_NAME'" - echo "3. Clique nos 3 pontos e selecione 'Delete'" - echo "" - echo -e "${CYAN}Deseja apenas desconectar localmente? (s/N):${NC}" - read -r LOCAL_ONLY - - if [[ "$LOCAL_ONLY" =~ ^[Ss]$ ]]; then - sudo tailscale logout - echo -e "${GREEN}Desconectado localmente.${NC}" - fi - fi + echo -e "${BLUE}=== $(gettext 'REMOVE DEVICE') ===${NC}" + echo -e "${RED}$(gettext 'WARNING: This will remove the device from the network!')${NC}" + + list_devices + + echo -e "${CYAN}$(gettext 'Enter the device name to remove:')${NC}" + read -r DEVICE_NAME + + if [ -z "$DEVICE_NAME" ]; then + echo -e "${RED}$(gettext 'Name cannot be empty!')${NC}" + return 1 + fi + + echo -e "${YELLOW}Tem certeza que deseja remover '$DEVICE_NAME'? (s/N):${NC}" + read -r CONFIRM + + if [[ "$CONFIRM" =~ ^[Ss]$ ]]; then + echo -e "${YELLOW}$(gettext 'To remove via API, you need:')${NC}" + echo "1. Acesse https://login.tailscale.com/admin/machines" + echo "2. $(gettext 'Find the device') $DEVICE_NAME" + echo "3. Clique nos 3 pontos e selecione 'Delete'" + echo "" + echo -e "${CYAN}Deseja apenas desconectar localmente? (s/N):${NC}" + read -r LOCAL_ONLY + + if [[ "$LOCAL_ONLY" =~ ^[Ss]$ ]]; then + sudo tailscale logout + echo -e "${GREEN}Desconectado localmente.${NC}" + fi + fi } -# Função para autorizar dispositivo +# Function to authorize a device authorize_device() { - echo -e "${BLUE}=== AUTORIZAR DISPOSITIVO ===${NC}" - - if ! sudo tailscale status &>/dev/null; then - echo -e "${RED}Não conectado ao Tailscale. Faça login primeiro.${NC}" - return 1 - fi - - echo -e "${YELLOW}Verificando dispositivos pendentes...${NC}" - - if command -v jq &> /dev/null; then - PENDING_DEVICES=$(sudo tailscale status --json 2>/dev/null | jq -r '.Peer[] | select(.Authorized == false) | "\(.DNSName) (pendente)"' 2>/dev/null) - - if [ -n "$PENDING_DEVICES" ]; then - echo -e "${GREEN}Dispositivos pendentes encontrados:${NC}" - echo "$PENDING_DEVICES" - else - echo -e "${YELLOW}Nenhum dispositivo pendente encontrado${NC}" - fi - else - echo -e "${YELLOW}Não foi possível verificar dispositivos pendentes (jq não instalado)${NC}" - fi - - echo "" - echo -e "${YELLOW}Para autorizar dispositivos manualmente:${NC}" - echo "1. Acesse https://login.tailscale.com/admin/machines" - echo "2. Procure por dispositivos com status 'Pending'" - echo "3. Clique em 'Approve' ou 'Authorize'" - echo "" - echo -e "${CYAN}Pressione ENTER para continuar${NC}" - read -r + echo -e "${BLUE}=== $(gettext 'AUTHORIZE DEVICE') ===${NC}" + + if ! sudo tailscale status &>/dev/null; then + echo -e "${RED}$(gettext 'Not connected to Tailscale. Log in first.')${NC}" + return 1 + fi + + echo -e "${YELLOW}$(gettext 'Checking pending devices...')${NC}" + + if command -v jq &>/dev/null; then + PENDING_DEVICES=$(sudo tailscale status --json 2>/dev/null | jq -r '.Peer[] | select(.Authorized == false) | "\(.DNSName) (pendente)"' 2>/dev/null) + + if [ -n "$PENDING_DEVICES" ]; then + echo -e "${GREEN}Dispositivos pendentes encontrados:${NC}" + echo "$PENDING_DEVICES" + else + echo -e "${YELLOW}$(gettext 'No pending device found')${NC}" + fi + else + echo -e "${YELLOW}$(gettext 'Could not check pending devices (jq not installed)')${NC}" + fi + + echo "" + echo -e "${YELLOW}Para autorizar dispositivos manualmente:${NC}" + echo "1. Acesse https://login.tailscale.com/admin/machines" + echo "2. $(gettext 'Look for devices with status Pending')" + echo "3. Clique em 'Approve' ou 'Authorize'" + echo "" + echo -e "${CYAN}$(gettext 'Press ENTER to continue')${NC}" + read -r } -# Função para compartilhar rede +# Function to share the network share_network() { - echo -e "${BLUE}=== COMPARTILHAR REDE ===${NC}" - echo -e "${YELLOW}Opções de compartilhamento:${NC}" - echo "1) Compartilhar com usuário específico" - echo "2) Criar link de convite" - echo "3) Voltar" - - read -r SHARE_OPTION - - case $SHARE_OPTION in - 1) - echo -e "${CYAN}Digite o email do usuário:${NC}" - read -r USER_EMAIL - - if [ -n "$USER_EMAIL" ]; then - echo -e "${GREEN}Para compartilhar com $USER_EMAIL:${NC}" - echo "1. Acesse https://login.tailscale.com/admin/users" - echo "2. Clique em 'Invite user'" - echo "3. Digite o email e selecione as permissões" - fi - ;; - 2) - echo -e "${GREEN}Criando link de convite:${NC}" - echo "1. Acesse https://login.tailscale.com/admin/invite" - echo "2. Configure as opções desejadas" - echo "3. Compartilhe o link gerado" - ;; - 3) - return - ;; - *) - echo -e "${RED}Opção inválida!${NC}" - ;; - esac - - echo "" - echo -e "${CYAN}Pressione ENTER para continuar${NC}" - read -r + echo -e "${BLUE}=== $(gettext 'SHARE NETWORK') ===${NC}" + echo -e "${YELLOW}$(gettext 'Sharing options:')${NC}" + echo "$(gettext '1) Share with a specific user')" + echo "2) Criar link de convite" + echo "$(gettext '3) Back')" + + read -r SHARE_OPTION + + case $SHARE_OPTION in + 1) + echo -e "${CYAN}$(gettext 'Enter the user email:')${NC}" + read -r USER_EMAIL + + if [ -n "$USER_EMAIL" ]; then + echo -e "${GREEN}Para compartilhar com $USER_EMAIL:${NC}" + echo "1. Acesse https://login.tailscale.com/admin/users" + echo "2. Clique em 'Invite user'" + echo "$(gettext '3. Enter the email and select permissions')" + fi + ;; + 2) + echo -e "${GREEN}Criando link de convite:${NC}" + echo "1. Acesse https://login.tailscale.com/admin/invite" + echo "$(gettext '2. Configure the desired options')" + echo "3. Compartilhe o link gerado" + ;; + 3) + return + ;; + *) + echo -e "${RED}$(gettext 'Invalid option!')${NC}" + ;; + esac + + echo "" + echo -e "${CYAN}$(gettext 'Press ENTER to continue')${NC}" + read -r } -# Função para configurar ACLs +# Function to configure ACLs configure_acl() { - echo -e "${BLUE}=== CONFIGURAR ACLs ===${NC}" - echo -e "${YELLOW}ACLs controlam o acesso entre dispositivos.${NC}" - echo "" - echo "Para configurar ACLs:" - echo "1. Acesse https://login.tailscale.com/admin/acls" - echo "2. Edite o arquivo JSON de ACLs" - echo "" - echo "Exemplo básico:" - cat <<'EOF' + echo -e "${BLUE}=== CONFIGURAR ACLs ===${NC}" + echo -e "${YELLOW}ACLs controlam o acesso entre dispositivos.${NC}" + echo "" + echo "$(gettext 'To configure ACLs:')" + echo "1. Acesse https://login.tailscale.com/admin/acls" + echo "2. Edite o arquivo JSON de ACLs" + echo "" + echo "$(gettext 'Basic example:')" + cat <<'EOF' { "acls": [ {"action": "accept", "src": ["*"], "dst": ["*:*"]} ] } EOF - echo "" - echo -e "${CYAN}Deseja abrir o painel de ACLs no navegador? (s/N):${NC}" - read -r OPEN_BROWSER - - if [[ "$OPEN_BROWSER" =~ ^[Ss]$ ]]; then - xdg-open "https://login.tailscale.com/admin/acls" 2>/dev/null || \ - echo -e "${RED}Não foi possível abrir o navegador. Acesse manualmente: https://login.tailscale.com/admin/acls${NC}" - fi + echo "" + echo -e "${CYAN}$(gettext 'Open the ACL panel in the browser? (y/N):')${NC}" + read -r OPEN_BROWSER + + if [[ "$OPEN_BROWSER" =~ ^[Ss]$ ]]; then + xdg-open "https://login.tailscale.com/admin/acls" 2>/dev/null || + echo -e "${RED}$(gettext 'Could not open the browser. Open manually:') https://login.tailscale.com/admin/acls${NC}" + fi } -# Função para logout +# Function to log out logout_tailscale() { - echo -e "${BLUE}=== SAIR DO TAILSCALE ===${NC}" - echo -e "${YELLOW}Você realmente deseja sair do Tailscale? (s/N):${NC}" - read -r CONFIRM - - if [[ "$CONFIRM" =~ ^[Ss]$ ]]; then - sudo tailscale logout - echo -e "${GREEN}Logout realizado com sucesso!${NC}" - else - echo -e "${YELLOW}Operação cancelada.${NC}" - fi + echo -e "${BLUE}=== $(gettext 'LOG OUT OF TAILSCALE') ===${NC}" + echo -e "${YELLOW}$(gettext 'Do you really want to log out of Tailscale? (y/N):')${NC}" + read -r CONFIRM + + if [[ "$CONFIRM" =~ ^[Ss]$ ]]; then + sudo tailscale logout + echo -e "${GREEN}$(gettext 'Logout successful!')${NC}" + else + echo -e "${YELLOW}$(gettext 'Operation cancelled.')${NC}" + fi } -# Função para mostrar status detalhado +# Function to show detailed status show_status() { - echo -e "${BLUE}=== STATUS DETALHADO DO TAILSCALE ===${NC}" - - echo -e "${YELLOW}Status do serviço:${NC}" - systemctl status tailscaled --no-pager | grep "Active:" - - echo "" - echo -e "${YELLOW}Versão:${NC}" - tailscale version - - echo "" - if sudo tailscale status &>/dev/null; then - echo -e "${GREEN}✓ Conectado ao Tailscale${NC}" - echo "" - echo -e "${YELLOW}Informações do nó:${NC}" - if command -v jq &> /dev/null; then - sudo tailscale status --json | jq '.Self | {Name: .DNSName, IPs: .Addresses, Online: .Online}' 2>/dev/null - else - sudo tailscale status | head -1 - fi - - echo "" - echo -e "${YELLOW}Estatísticas:${NC}" - TOTAL_PEERS=$(sudo tailscale status | wc -l) - TOTAL_PEERS=$((TOTAL_PEERS - 1)) - echo "Total de peers: $TOTAL_PEERS" - - echo "" - echo -e "${YELLOW}Rotas:${NC}" - ip route show | grep tailscale || echo "Nenhuma rota tailscale específica" - else - echo -e "${RED}✗ Desconectado do Tailscale${NC}" - fi + echo -e "${BLUE}=== $(gettext 'DETAILED TAILSCALE STATUS') ===${NC}" + + echo -e "${YELLOW}$(gettext 'Service status:')${NC}" + systemctl status tailscaled --no-pager | grep "Active:" + + echo "" + echo -e "${YELLOW}$(gettext 'Version:')${NC}" + tailscale version + + echo "" + if sudo tailscale status &>/dev/null; then + echo -e "${GREEN}✓ $(gettext 'Connected to Tailscale')${NC}" + echo "" + echo -e "${YELLOW}$(gettext 'Node information:')${NC}" + if command -v jq &>/dev/null; then + sudo tailscale status --json | jq '.Self | {Name: .DNSName, IPs: .Addresses, Online: .Online}' 2>/dev/null + else + sudo tailscale status | head -1 + fi + + echo "" + echo -e "${YELLOW}$(gettext 'Statistics:')${NC}" + TOTAL_PEERS=$(sudo tailscale status | wc -l) + TOTAL_PEERS=$((TOTAL_PEERS - 1)) + echo "Total de peers: $TOTAL_PEERS" + + echo "" + echo -e "${YELLOW}Rotas:${NC}" + ip route show | grep tailscale || echo "$(gettext 'No specific tailscale route')" + else + echo -e "${RED}✗ Desconectado do Tailscale${NC}" + fi } -# Função para alterar configurações +# Function to change settings change_settings() { - echo -e "${BLUE}=== ALTERAR CONFIGURAÇÕES ===${NC}" - echo -e "${YELLOW}Opções de configuração:${NC}" - echo "1) Ativar/Desativar roteamento de sub-redes" - echo "2) Ativar/Desativar modo exit node" - echo "3) Mudar nome do dispositivo" - echo "4) Configurar servidor DNS" - echo "5) Voltar" - - read -r SETTINGS_OPTION - - case $SETTINGS_OPTION in - 1) - echo -e "${CYAN}Digite as sub-redes para rotear (ex: 192.168.1.0/24):${NC}" - read -r SUBNETS - sudo tailscale up --advertise-routes="$SUBNETS" - echo -e "${GREEN}Sub-redes configuradas!${NC}" - ;; - 2) - echo -e "${CYAN}Ativar como exit node? (s/N):${NC}" - read -r EXIT_NODE - if [[ "$EXIT_NODE" =~ ^[Ss]$ ]]; then - sudo tailscale up --advertise-exit-node - echo -e "${GREEN}Exit node ativado!${NC}" - else - sudo tailscale up --advertise-exit-node=false - echo -e "${GREEN}Exit node desativado!${NC}" - fi - ;; - 3) - echo -e "${CYAN}Novo nome para o dispositivo:${NC}" - read -r NEW_NAME - if [ -n "$NEW_NAME" ]; then - sudo tailscale set --hostname="$NEW_NAME" - echo -e "${GREEN}Nome alterado para $NEW_NAME${NC}" - fi - ;; - 4) - echo -e "${CYAN}Digite os servidores DNS (separados por vírgula):${NC}" - read -r DNS_SERVERS - sudo tailscale up --dns="$DNS_SERVERS" - echo -e "${GREEN}DNS configurado!${NC}" - ;; - 5) - return - ;; - *) - echo -e "${RED}Opção inválida!${NC}" - ;; - esac - - echo "" - echo -e "${CYAN}Pressione ENTER para continuar${NC}" - read -r + echo -e "${BLUE}=== $(gettext 'CHANGE SETTINGS') ===${NC}" + echo -e "${YELLOW}$(gettext 'Configuration options:')${NC}" + echo "1) Ativar/Desativar roteamento de sub-redes" + echo "2) Ativar/Desativar modo exit node" + echo "$(gettext '3) Change device name')" + echo "$(gettext '4) Configure DNS server')" + echo "$(gettext '5) Back')" + + read -r SETTINGS_OPTION + + case $SETTINGS_OPTION in + 1) + echo -e "${CYAN}$(gettext 'Enter subnets to route (e.g. 192.168.1.0/24):')${NC}" + read -r SUBNETS + sudo tailscale up --advertise-routes="$SUBNETS" + echo -e "${GREEN}Sub-redes configuradas!${NC}" + ;; + 2) + echo -e "${CYAN}Ativar como exit node? (s/N):${NC}" + read -r EXIT_NODE + if [[ "$EXIT_NODE" =~ ^[Ss]$ ]]; then + sudo tailscale up --advertise-exit-node + echo -e "${GREEN}Exit node ativado!${NC}" + else + sudo tailscale up --advertise-exit-node=false + echo -e "${GREEN}Exit node desativado!${NC}" + fi + ;; + 3) + echo -e "${CYAN}$(gettext 'New name for the device:')${NC}" + read -r NEW_NAME + if [ -n "$NEW_NAME" ]; then + sudo tailscale set --hostname="$NEW_NAME" + echo -e "${GREEN}$(gettext 'Name changed to') $NEW_NAME${NC}" + fi + ;; + 4) + echo -e "${CYAN}$(gettext 'Enter DNS servers (comma-separated):')${NC}" + read -r DNS_SERVERS + sudo tailscale up --dns="$DNS_SERVERS" + echo -e "${GREEN}DNS configurado!${NC}" + ;; + 5) + return + ;; + *) + echo -e "${RED}$(gettext 'Invalid option!')${NC}" + ;; + esac + + echo "" + echo -e "${CYAN}$(gettext 'Press ENTER to continue')${NC}" + read -r } -# Função para backup das configurações +# Function to back up settings backup_config() { - echo -e "${BLUE}=== BACKUP DAS CONFIGURAÇÕES ===${NC}" - - BACKUP_FILE="$HOME/tailscale-backup-$(date +%Y%m%d-%H%M%S).tar.gz" - TEMP_DIR="/tmp/tailscale-backup-$$" - - mkdir -p "$TEMP_DIR" - - if [ -d "$TAILSCALE_CONFIG" ]; then - cp -r "$TAILSCALE_CONFIG" "$TEMP_DIR/script-config" 2>/dev/null - echo -e "${GREEN}✓ Configurações do script salvas${NC}" - fi - - if [ -d "/var/lib/tailscale" ]; then - sudo tar -czf "$TEMP_DIR/tailscale-system.tar.gz" -C /var/lib tailscale 2>/dev/null - sudo chown "$USER:$USER" "$TEMP_DIR/tailscale-system.tar.gz" - echo -e "${GREEN}✓ Configurações do Tailscale salvas${NC}" - fi - - tar -czf "$BACKUP_FILE" -C "$TEMP_DIR" . 2>/dev/null - rm -rf "$TEMP_DIR" - - echo -e "${GREEN}✓ Backup criado: $BACKUP_FILE${NC}" - echo -e "${YELLOW}Tamanho: $(du -h "$BACKUP_FILE" | cut -f1)${NC}" - - echo -e "${CYAN}Verificando integridade do backup...${NC}" - if tar -tzf "$BACKUP_FILE" &>/dev/null; then - echo -e "${GREEN}✓ Backup íntegro${NC}" - else - echo -e "${RED}✗ Backup corrompido${NC}" - fi - - echo "" - echo -e "${CYAN}Pressione ENTER para continuar${NC}" - read -r + echo -e "${BLUE}=== $(gettext 'SETTINGS BACKUP') ===${NC}" + + OLD_UMASK=$(umask) + umask 077 + BACKUP_FILE="$HOME/tailscale-backup-$(date +%Y%m%d-%H%M%S).tar.gz" + if ! TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/tailscale-backup.XXXXXX")"; then + umask "$OLD_UMASK" + echo -e "${RED}$(gettext 'Could not create temporary backup directory.')${NC}" + return 1 + fi + + if [ -d "$TAILSCALE_CONFIG" ]; then + cp -r "$TAILSCALE_CONFIG" "$TEMP_DIR/script-config" 2>/dev/null + echo -e "${GREEN}✓ $(gettext 'Script settings saved')${NC}" + fi + + if [ -d "/var/lib/tailscale" ]; then + sudo tar -czf "$TEMP_DIR/tailscale-system.tar.gz" -C /var/lib tailscale 2>/dev/null + sudo chown "$USER:$USER" "$TEMP_DIR/tailscale-system.tar.gz" + echo -e "${GREEN}✓ $(gettext 'Tailscale settings saved')${NC}" + fi + + tar -czf "$BACKUP_FILE" -C "$TEMP_DIR" . 2>/dev/null + rm -rf "$TEMP_DIR" + umask "$OLD_UMASK" + chmod 600 "$BACKUP_FILE" 2>/dev/null || true + + echo -e "${GREEN}✓ $(gettext 'Backup created:') $BACKUP_FILE${NC}" + echo -e "${YELLOW}Tamanho: $(du -h "$BACKUP_FILE" | cut -f1)${NC}" + + echo -e "${CYAN}$(gettext 'Checking backup integrity...')${NC}" + if tar -tzf "$BACKUP_FILE" &>/dev/null; then + echo -e "${GREEN}✓ $(gettext 'Backup verified')${NC}" + else + echo -e "${RED}✗ $(gettext 'Backup corrupted')${NC}" + fi + + echo "" + echo -e "${CYAN}$(gettext 'Press ENTER to continue')${NC}" + read -r } -# Função para mostrar menu +# Function to show the menu show_menu() { - clear - echo -e "${BLUE}====================================${NC}" - echo -e "${GREEN} TAILSCALE NETWORK MANAGER v1.0 ${NC}" - echo -e "${BLUE}====================================${NC}" - echo -e "${WHITE}Bem-vindo ao gerenciador Tailscale${NC}" - echo -e "${BLUE}====================================${NC}" - echo "" - echo -e "${YELLOW}1)${NC} Login/Fazer login" - echo -e "${YELLOW}2)${NC} Criar nova rede/conta" - echo -e "${YELLOW}3)${NC} Ver status da rede" - echo -e "${YELLOW}4)${NC} Listar dispositivos" - echo -e "${YELLOW}5)${NC} Adicionar novo dispositivo (instruções)" - echo -e "${YELLOW}6)${NC} Remover dispositivo" - echo -e "${YELLOW}7)${NC} Autorizar dispositivo pendente" - echo -e "${YELLOW}8)${NC} Compartilhar rede" - echo -e "${YELLOW}9)${NC} Configurar ACLs" - echo -e "${YELLOW}10)${NC} Alterar configurações" - echo -e "${YELLOW}11)${NC} Status detalhado" - echo -e "${YELLOW}12)${NC} Logout/Sair" - echo -e "${YELLOW}13)${NC} Backup das configurações" - echo -e "${YELLOW}0)${NC} Sair do programa" - echo "" - echo -e "${CYAN}Escolha uma opção:${NC}" + clear + echo -e "${BLUE}====================================${NC}" + echo -e "${GREEN} TAILSCALE NETWORK MANAGER v1.0 ${NC}" + echo -e "${BLUE}====================================${NC}" + echo -e "${WHITE}Bem-vindo ao gerenciador Tailscale${NC}" + echo -e "${BLUE}====================================${NC}" + echo "" + echo -e "${YELLOW}1)${NC} Login/Fazer login" + echo -e "${YELLOW}2)${NC} $(gettext 'Create new network/account')" + echo -e "${YELLOW}3)${NC} $(gettext 'View network status')" + echo -e "${YELLOW}4)${NC} $(gettext 'List devices')" + echo -e "${YELLOW}5)${NC} $(gettext 'Add new device (instructions)')" + echo -e "${YELLOW}6)${NC} $(gettext 'Remove device')" + echo -e "${YELLOW}7)${NC} $(gettext 'Authorize pending device')" + echo -e "${YELLOW}8)${NC} $(gettext 'Share network')" + echo -e "${YELLOW}9)${NC} Configurar ACLs" + echo -e "${YELLOW}10)${NC} $(gettext 'Change settings')" + echo -e "${YELLOW}11)${NC} $(gettext 'Detailed status')" + echo -e "${YELLOW}12)${NC} $(gettext 'Logout/Exit')" + echo -e "${YELLOW}13)${NC} $(gettext 'Backup settings')" + echo -e "${YELLOW}0)${NC} $(gettext 'Exit the program')" + echo "" + echo -e "${CYAN}$(gettext 'Choose an option:')${NC}" } -# Função principal +# Main function main() { - check_dependencies - - echo -e "${BLUE}╔════════════════════════════════════════╗${NC}" - echo -e "${BLUE}║${GREEN} TAILSCALE MANAGER - BigLinux ${BLUE}║${NC}" - echo -e "${BLUE}╚════════════════════════════════════════╝${NC}" - sleep 1 - - while true; do - show_menu - read -r OPTION - - case $OPTION in - 1) login_tailscale ;; - 2) create_network ;; - 3) list_networks ;; - 4) list_devices ;; - 5) add_device ;; - 6) remove_device ;; - 7) authorize_device ;; - 8) share_network ;; - 9) configure_acl ;; - 10) change_settings ;; - 11) show_status ;; - 12) logout_tailscale ;; - 13) backup_config ;; - 0) - echo -e "${GREEN}Saindo...${NC}" - exit 0 - ;; - *) - echo -e "${RED}Opção inválida!${NC}" - ;; - esac - - echo "" - echo -e "${YELLOW}Pressione ENTER para continuar...${NC}" - read -r - done + check_dependencies + + echo -e "${BLUE}╔════════════════════════════════════════╗${NC}" + echo -e "${BLUE}║${GREEN} TAILSCALE MANAGER - BigLinux ${BLUE}║${NC}" + echo -e "${BLUE}╚════════════════════════════════════════╝${NC}" + sleep 1 + + while true; do + show_menu + read -r OPTION + + case $OPTION in + 1) login_tailscale ;; + 2) create_network ;; + 3) list_networks ;; + 4) list_devices ;; + 5) add_device ;; + 6) remove_device ;; + 7) authorize_device ;; + 8) share_network ;; + 9) configure_acl ;; + 10) change_settings ;; + 11) show_status ;; + 12) logout_tailscale ;; + 13) backup_config ;; + 0) + echo -e "${GREEN}$(gettext 'Exiting...')${NC}" + exit 0 + ;; + *) + echo -e "${RED}$(gettext 'Invalid option!')${NC}" + ;; + esac + + echo "" + echo -e "${YELLOW}$(gettext 'Press ENTER to continue')...${NC}" + read -r + done } -# Executar função principal +# Run main function main diff --git a/usr/share/big-remote-play/scripts/create-network_zerotier.sh b/usr/share/big-remote-play/scripts/create-network_zerotier.sh index 4bdadd5..a66aebb 100755 --- a/usr/share/big-remote-play/scripts/create-network_zerotier.sh +++ b/usr/share/big-remote-play/scripts/create-network_zerotier.sh @@ -1,10 +1,14 @@ #!/bin/bash +# shellcheck disable=SC1091,SC2005 +# gettext fallback intentionally emits no newline, so translated menu strings are +# wrapped in echo throughout this legacy interactive script. # ============================================================================== # ZEROTIER NETWORK MANAGER - Big Remote Play -# Desenvolvido para BigLinux/Manjaro +# Developed for BigLinux/Manjaro # ============================================================================== set -e +umask 077 GREEN='\033[0;32m' BLUE='\033[0;34m' @@ -13,192 +17,208 @@ RED='\033[0;31m' CYAN='\033[0;36m' NC='\033[0m' -ZEROTIER_CONFIG="$HOME/.config/big-remoteplay/zerotier" -API_TOKEN_FILE="$ZEROTIER_CONFIG/api_token.txt" +# Machine-readable markers for the GUI (locale-independent ASCII). The GUI parses +# these; all other output is human prose, translated via gettext. +brp_data() { printf 'BRP_DATA %s=%s\n' "$1" "$2"; } +brp_phase() { printf 'BRP_PHASE %s\n' "$1"; } + +# gettext for human-facing prose (msgids are English; catalogs translate them). +export TEXTDOMAIN=big-remote-play +if [ -f /usr/bin/gettext.sh ]; then + . /usr/bin/gettext.sh +else + gettext() { printf '%s' "$1"; } + eval_gettext() { printf '%s' "$1"; } +fi + +ZEROTIER_CONFIG="${XDG_CONFIG_HOME:-$HOME/.config}/big-remote-play/zerotier" NETWORKS_FILE="$ZEROTIER_CONFIG/networks.txt" header() { - clear - echo -e "${BLUE}====================================================${NC}" - echo -e "${GREEN} ZEROTIER - REDE PRIVADA VIRTUAL ${NC}" - echo -e "${BLUE}====================================================${NC}" + clear + echo -e "${BLUE}====================================================${NC}" + echo -e "${GREEN} $(gettext 'ZEROTIER - VIRTUAL PRIVATE NETWORK') ${NC}" + echo -e "${BLUE}====================================================${NC}" } check_deps() { - echo -e "${YELLOW}Verificando dependências...${NC}" - - if ! command -v zerotier-cli &> /dev/null; then - echo -e "${YELLOW}Instalando ZeroTier...${NC}" - sudo pacman -S zerotier-one --noconfirm - else - echo -e "${GREEN}✓ ZeroTier já instalado${NC}" - fi - - if ! command -v jq &> /dev/null; then - sudo pacman -S jq --noconfirm - fi - if ! command -v curl &> /dev/null; then - sudo pacman -S curl --noconfirm - fi - - if ! systemctl is-active --quiet zerotier-one 2>/dev/null; then - echo -e "${YELLOW}Iniciando serviço ZeroTier...${NC}" - sudo systemctl enable zerotier-one - sudo systemctl start zerotier-one - sleep 3 - else - echo -e "${GREEN}✓ ZeroTier daemon ativo${NC}" - fi - - mkdir -p "$ZEROTIER_CONFIG" + brp_phase 0.1 + echo -e "${YELLOW}$(gettext 'Checking dependencies...')${NC}" + + if ! command -v zerotier-cli &>/dev/null; then + echo -e "${YELLOW}$(gettext 'Installing ZeroTier...')${NC}" + sudo pacman -S zerotier-one --noconfirm + else + echo -e "${GREEN}✓ $(gettext 'ZeroTier already installed')${NC}" + fi + + if ! command -v jq &>/dev/null; then + sudo pacman -S jq --noconfirm + fi + if ! command -v curl &>/dev/null; then + sudo pacman -S curl --noconfirm + fi + + if ! systemctl is-active --quiet zerotier-one 2>/dev/null; then + echo -e "${YELLOW}$(gettext 'Starting ZeroTier service...')${NC}" + sudo systemctl enable zerotier-one + sudo systemctl start zerotier-one + sleep 3 + else + echo -e "${GREEN}✓ $(gettext 'ZeroTier daemon active')${NC}" + fi + + mkdir -p "$ZEROTIER_CONFIG" + chmod 700 "$ZEROTIER_CONFIG" 2>/dev/null || true } load_token() { - if [ ! -f "$API_TOKEN_FILE" ]; then - echo -e "${RED}Token da API não encontrado.${NC}" - echo -e "${CYAN}1. Acesse https://my.zerotier.com${NC}" - echo -e "${CYAN}2. Vá em Account → API Access Tokens${NC}" - echo -e "${CYAN}3. Gere um novo token${NC}" - echo "" - read -p "Cole seu API Token aqui: " API_TOKEN - if [ -z "$API_TOKEN" ]; then - echo -e "${RED}Token não pode estar vazio!${NC}" - exit 1 - fi - echo "$API_TOKEN" > "$API_TOKEN_FILE" - echo -e "${GREEN}✓ Token salvo!${NC}" - fi - API_TOKEN=$(cat "$API_TOKEN_FILE") + echo -e "${CYAN}$(gettext 'Paste your API Token here: ')${NC}" + read -r API_TOKEN + if [ -z "$API_TOKEN" ]; then + echo -e "${RED}$(gettext 'Token cannot be empty!')${NC}" + exit 1 + fi } create_network() { - header - echo -e "${YELLOW}CRIAR NOVA REDE ZEROTIER${NC}" - echo "" - - load_token - - read -p "Nome da rede: " NETWORK_NAME - if [ -z "$NETWORK_NAME" ]; then - echo -e "${RED}Nome não pode estar vazio!${NC}" - exit 1 - fi - - read -p "Descrição (opcional): " NETWORK_DESC - - echo -e "${YELLOW}Criando rede via API...${NC}" - RESPONSE=$(curl -s -X POST \ - -H "Authorization: bearer $API_TOKEN" \ - -H "Content-Type: application/json" \ - -d "{\"name\": \"$NETWORK_NAME\", \"description\": \"$NETWORK_DESC\", \"private\": true}" \ - "https://api.zerotier.com/api/v1/network") - - NETWORK_ID=$(echo "$RESPONSE" | jq -r '.id // empty') - - if [ -z "$NETWORK_ID" ] || [ "$NETWORK_ID" = "null" ]; then - echo -e "${RED}Erro ao criar rede. Verifique seu token.${NC}" - echo "$RESPONSE" | jq '.' 2>/dev/null || echo "$RESPONSE" - exit 1 - fi - - echo -e "${GREEN}✅ Rede criada! ID: $NETWORK_ID${NC}" - - # Entrar na rede automaticamente - echo -e "${YELLOW}Entrando na rede...${NC}" - sudo zerotier-cli join "$NETWORK_ID" 2>/dev/null || true - sleep 3 - - # Obter Node ID e autorizar automaticamente - NODE_ID=$(sudo zerotier-cli info 2>/dev/null | cut -d' ' -f3 || echo "") - - if [ -n "$NODE_ID" ]; then - echo -e "${YELLOW}Autorizando dispositivo local...${NC}" - curl -s -X POST \ - -H "Authorization: bearer $API_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"config": {"authorized": true}}' \ - "https://api.zerotier.com/api/v1/network/$NETWORK_ID/member/$NODE_ID" > /dev/null - echo -e "${GREEN}✓ Dispositivo autorizado!${NC}" - fi - - # Salvar localmente - echo "$NETWORK_ID:$NETWORK_NAME:$(date +%Y-%m-%d)" >> "$NETWORKS_FILE" - - IP_LOCAL=$(ip route get 1 2>/dev/null | awk '{print $7;exit}' || echo "N/A") - PUBLIC_IP=$(curl -s https://api.ipify.org 2>/dev/null || echo "N/A") - - echo "" - echo -e "${CYAN}=== INFORMAÇÕES DA REDE ===${NC}" - echo -e "Web Interface: ${YELLOW}https://my.zerotier.com/network/$NETWORK_ID${NC}" - echo -e "API URL: ${CYAN}https://api.zerotier.com/api/v1${NC}" - echo -e "Seu IP Público: ${GREEN}$PUBLIC_IP${NC}" - echo -e "IP Local do Servidor: ${GREEN}$IP_LOCAL${NC}" - echo "" - echo -e "${CYAN}=== CREDENCIAIS ===${NC}" - echo -e "API Key (para UI): ${CYAN}$API_TOKEN${NC}" - echo -e "Chave para Amigos: ${GREEN}$NETWORK_ID${NC}" - echo "" - echo -e "${YELLOW}⚠️ Compartilhe o ID da rede ($NETWORK_ID) com seus amigos${NC}" - echo -e "${YELLOW}Eles usam a opção 'Conectar' e informam o ID da rede${NC}" - echo -e "${BLUE}====================================================${NC}" + header + echo -e "${YELLOW}$(gettext 'CREATE NEW ZEROTIER NETWORK')${NC}" + echo "" + + load_token + + read -r -p "$(gettext 'Network name: ')" NETWORK_NAME + if [ -z "$NETWORK_NAME" ]; then + echo -e "${RED}$(gettext 'Name cannot be empty!')${NC}" + exit 1 + fi + + read -r -p "$(gettext 'Description (optional): ')" NETWORK_DESC + + brp_phase 0.3 + echo -e "${YELLOW}$(gettext 'Creating network via API...')${NC}" + RESPONSE=$(curl -s -X POST \ + -H "Authorization: token $API_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"name\": \"$NETWORK_NAME\", \"description\": \"$NETWORK_DESC\", \"private\": true}" \ + "https://api.zerotier.com/api/v1/network") + + NETWORK_ID=$(echo "$RESPONSE" | jq -r '.id // empty') + + if [ -z "$NETWORK_ID" ] || [ "$NETWORK_ID" = "null" ]; then + echo -e "${RED}$(gettext 'Error creating network. Check your token.')${NC}" + echo "$RESPONSE" | jq '.' 2>/dev/null || echo "$RESPONSE" + exit 1 + fi + + brp_data network_id "$NETWORK_ID" + brp_phase 0.6 + echo -e "${GREEN}✅ $(gettext 'Network created! ID:') $NETWORK_ID${NC}" + + # Join the network automatically + echo -e "${YELLOW}$(gettext 'Joining the network...')${NC}" + sudo zerotier-cli join "$NETWORK_ID" 2>/dev/null || true + sleep 3 + + # Get Node ID and authorize automatically + NODE_ID=$(sudo zerotier-cli info 2>/dev/null | cut -d' ' -f3 || echo "") + + if [ -n "$NODE_ID" ]; then + echo -e "${YELLOW}$(gettext 'Authorizing local device...')${NC}" + curl -s -X POST \ + -H "Authorization: token $API_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"config": {"authorized": true}}' \ + "https://api.zerotier.com/api/v1/network/$NETWORK_ID/member/$NODE_ID" >/dev/null + echo -e "${GREEN}✓ $(gettext 'Device authorized!')${NC}" + fi + + # Save locally + printf '%s:%s:%s\n' "$NETWORK_ID" "$NETWORK_NAME" "$(date +%Y-%m-%d)" >>"$NETWORKS_FILE" + chmod 600 "$NETWORKS_FILE" 2>/dev/null || true + + IP_LOCAL=$(ip route get 1 2>/dev/null | awk '{print $7;exit}' || echo "N/A") + PUBLIC_IP=$(curl -s https://api.ipify.org 2>/dev/null || echo "N/A") + + # Machine-readable data for the GUI (raw, locale-independent). + brp_data web_ui "https://my.zerotier.com/network/$NETWORK_ID" + brp_data api_url "https://api.zerotier.com/api/v1" + brp_data public_ip "$PUBLIC_IP" + brp_data local_ip "$IP_LOCAL" + brp_data auth_key "$NETWORK_ID" + brp_data network_id "$NETWORK_ID" + + echo "" + echo -e "${CYAN}=== $(gettext 'NETWORK INFORMATION') ===${NC}" + echo -e "$(gettext 'Web Interface:') ${YELLOW}https://my.zerotier.com/network/$NETWORK_ID${NC}" + echo -e "$(gettext 'API URL:') ${CYAN}https://api.zerotier.com/api/v1${NC}" + echo -e "$(gettext 'Your Public IP:') ${GREEN}$PUBLIC_IP${NC}" + echo -e "$(gettext 'Server Local IP:') ${GREEN}$IP_LOCAL${NC}" + echo "" + echo -e "${CYAN}=== $(gettext 'CREDENTIALS') ===${NC}" + echo -e "$(gettext 'Key for Friends:') ${GREEN}$NETWORK_ID${NC}" + brp_phase 0.95 + echo "" + echo -e "${YELLOW}⚠️ $(gettext 'Share the network ID with your friends:') $NETWORK_ID${NC}" + echo -e "${YELLOW}$(gettext 'They use the Connect option and enter the network ID')${NC}" + echo -e "${BLUE}====================================================${NC}" } join_network() { - header - echo -e "${YELLOW}ENTRAR EM REDE ZEROTIER (Cliente/Guest)${NC}" - echo "" - - check_deps - - read -p "ID da Rede ZeroTier (16 caracteres): " NETWORK_ID - if [ -z "$NETWORK_ID" ]; then - echo -e "${RED}ID da rede não pode estar vazio!${NC}" - exit 1 - fi - - echo -e "${YELLOW}Entrando na rede $NETWORK_ID...${NC}" - sudo zerotier-cli join "$NETWORK_ID" - sleep 5 - - # Verificar status - STATUS=$(sudo zerotier-cli listnetworks 2>/dev/null | grep "$NETWORK_ID" | awk '{print $6}' || echo "unknown") - - echo "" - echo -e "${CYAN}=== STATUS DA CONEXÃO ===${NC}" - sudo zerotier-cli listnetworks 2>/dev/null || true - - NODE_ID=$(sudo zerotier-cli info 2>/dev/null | cut -d' ' -f3 || echo "N/A") - echo "" - echo -e "Seu Node ID: ${GREEN}$NODE_ID${NC}" - echo -e "${YELLOW}⚠️ Você precisa ser autorizado pelo administrador da rede!${NC}" - echo -e "${CYAN}Informe ao administrador seu Node ID: ${GREEN}$NODE_ID${NC}" - - echo -e "${BLUE}====================================================${NC}" - echo -e "${GREEN}Solicitação de entrada enviada!${NC}" - echo -e "${YELLOW}Aguarde autorização do administrador${NC}" + header + echo -e "${YELLOW}$(gettext 'JOIN ZEROTIER NETWORK (Client/Guest)')${NC}" + echo "" + + check_deps + + read -r -p "$(gettext 'ZeroTier Network ID (16 characters): ')" NETWORK_ID + if [ -z "$NETWORK_ID" ]; then + echo -e "${RED}$(gettext 'Network ID cannot be empty!')${NC}" + exit 1 + fi + + echo -e "${YELLOW}$(gettext 'Joining network:') $NETWORK_ID${NC}" + sudo zerotier-cli join "$NETWORK_ID" + sleep 5 + + # Check status + echo "" + echo -e "${CYAN}=== $(gettext 'CONNECTION STATUS') ===${NC}" + sudo zerotier-cli listnetworks 2>/dev/null || true + + NODE_ID=$(sudo zerotier-cli info 2>/dev/null | cut -d' ' -f3 || echo "N/A") + echo "" + echo -e "$(gettext 'Your Node ID: ')${GREEN}$NODE_ID${NC}" + echo -e "${YELLOW}⚠️ $(gettext 'You must be authorized by the network administrator!')${NC}" + echo -e "${CYAN}$(gettext 'Give your Node ID to the administrator: ')${GREEN}$NODE_ID${NC}" + + echo -e "${BLUE}====================================================${NC}" + echo -e "${GREEN}$(gettext 'Join request sent!')${NC}" + echo -e "${YELLOW}$(gettext 'Wait for the administrator to authorize you')${NC}" } # --- MENU PRINCIPAL --- main_menu() { - header - check_deps - echo "Selecione uma opção:" - echo "1) Ser o HOST (Criar e Gerenciar a Rede)" - echo "2) Ser o GUEST (Entrar na rede de um amigo)" - echo "3) Ver status da rede" - echo "4) Sair" - read -p "Opção: " OPT - - case $OPT in - 1) create_network ;; - 2) join_network ;; - 3) - echo -e "${CYAN}=== STATUS ZEROTIER ===${NC}" - sudo zerotier-cli info 2>/dev/null || echo "ZeroTier não conectado" - sudo zerotier-cli listnetworks 2>/dev/null || true - ;; - *) exit 0 ;; - esac + header + check_deps + echo "$(gettext 'Select an option:')" + echo "$(gettext '1) Be the HOST (create and manage the network)')" + echo "$(gettext '2) Be the GUEST (join a friend network)')" + echo "$(gettext '3) View network status')" + echo "$(gettext '4) Exit')" + read -r -p "$(gettext 'Option: ')" OPT + + case $OPT in + 1) create_network ;; + 2) join_network ;; + 3) + echo -e "${CYAN}=== $(gettext 'ZEROTIER STATUS') ===${NC}" + sudo zerotier-cli info 2>/dev/null || echo "$(gettext 'ZeroTier not connected')" + sudo zerotier-cli listnetworks 2>/dev/null || true + ;; + *) exit 0 ;; + esac } main_menu diff --git a/usr/share/big-remote-play/scripts/drop_guest.sh b/usr/share/big-remote-play/scripts/drop_guest.sh index 9f5aba8..f30715e 100755 --- a/usr/share/big-remote-play/scripts/drop_guest.sh +++ b/usr/share/big-remote-play/scripts/drop_guest.sh @@ -9,6 +9,13 @@ if [ -z "$IP" ]; then exit 1 fi +# Validate IP format (IPv4 or IPv6) before passing to root-level ss. +# Defense-in-depth: this script runs via pkexec. +if ! [[ "$IP" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ || "$IP" =~ ^[0-9a-fA-F:]+$ ]]; then + echo "Error: invalid IP address: $IP" + exit 1 +fi + # Kill TCP connections to this IP # This requires root privileges (cap_net_admin) which is why this script runs via pkexec # Kill TCP connections to this IP on specific Sunshine ports @@ -18,7 +25,7 @@ PORTS=("47984" "47989" "48010") for PORT in "${PORTS[@]}"; do # We use 'sport' because on the Host, these are the Local ports (Source Port) # The 'dst' confirms we are only killing connections TO the specific guest IP. - ss -K dst "$IP" sport = :$PORT + ss -K dst "$IP" sport = :"$PORT" done # If we want to be extra thorough and kill UDP states (conntrack), we could use conntrack tool diff --git a/usr/share/big-remote-play/scripts/fix_sunshine_libs.sh b/usr/share/big-remote-play/scripts/fix_sunshine_libs.sh deleted file mode 100755 index 76e4997..0000000 --- a/usr/share/big-remote-play/scripts/fix_sunshine_libs.sh +++ /dev/null @@ -1,66 +0,0 @@ -#!/bin/bash -# Fix Sunshine ICU libraries by downloading correct version -# Version: 2.0 - -TARGET_DIR="/usr/share/big-remote-play/libs" -# URL using Arch Linux Archive -ICU_URL="https://archive.archlinux.org/packages/i/icu/icu-76.1-1-x86_64.pkg.tar.zst" -TEMP_DIR=$(mktemp -d) - -echo "Starting Sunshine Library Fix..." - -# Create target directory -mkdir -p "$TARGET_DIR" - -# Check if libraries already exist in system (real files, not symlinks to 78) -# We check if .so.76 exists and is NOT a symlink to something else (unless it's to our own files) -# But simple check: if strict .76 exists we assume it's good? NO. -# The issue is that the user MIGHT have broken symlinks now. -# So we should force overwrite if we are running this script. - -echo "Downloading ICU 76 from Arch Archive..." -if curl -L -o "$TEMP_DIR/icu.pkg.tar.zst" "$ICU_URL"; then - echo "Download successful. Extracting..." - - # Extract only the needed libraries - # We use tar with zstd. - tar --zstd -xvf "$TEMP_DIR/icu.pkg.tar.zst" -C "$TEMP_DIR" usr/lib/libicuuc.so.76.1 usr/lib/libicudata.so.76.1 usr/lib/libicui18n.so.76.1 - - # Move to target - mv "$TEMP_DIR"/usr/lib/libicu*.so.76.1 "$TARGET_DIR/" - - # Create symlinks for .so.76 - ln -sf "$TARGET_DIR/libicuuc.so.76.1" "$TARGET_DIR/libicuuc.so.76" - ln -sf "$TARGET_DIR/libicudata.so.76.1" "$TARGET_DIR/libicudata.so.76" - ln -sf "$TARGET_DIR/libicui18n.so.76.1" "$TARGET_DIR/libicui18n.so.76" - - echo "Libraries installed to $TARGET_DIR" - - # Setup global symlinks in /usr/lib ONLY IF they don't exist or are broken - # Actually, modifying /usr/lib is risky. - # Better to just use LD_LIBRARY_PATH in the app. - # But if the user runs sunshine CLI manually, it might fail. - # We will try to link in /usr/lib as fallback for convenience, but the app uses LD_LIBRARY_PATH - - for lib in libicuuc.so.76 libicudata.so.76 libicui18n.so.76; do - if [ ! -f "/usr/lib/$lib" ]; then - echo "Linking /usr/lib/$lib -> $TARGET_DIR/$lib" - ln -sf "$TARGET_DIR/$lib" "/usr/lib/$lib" - else - # Check if it is a symlink to .78 (the broken fix) - TARGET=$(readlink -f "/usr/lib/$lib") - if [[ "$TARGET" == *"78"* ]]; then - echo "Fixing broken symlink /usr/lib/$lib (was pointing to $TARGET)" - ln -sf "$TARGET_DIR/$lib" "/usr/lib/$lib" - fi - fi - done - - # Cleanup - rm -rf "$TEMP_DIR" - echo "Done." -else - echo "Failed to download ICU package." - rm -rf "$TEMP_DIR" - exit 1 -fi diff --git a/usr/share/big-remote-play/scripts/headscale_master-new.sh b/usr/share/big-remote-play/scripts/headscale_master-new.sh deleted file mode 100755 index 9168f14..0000000 --- a/usr/share/big-remote-play/scripts/headscale_master-new.sh +++ /dev/null @@ -1,565 +0,0 @@ -#!/bin/bash -# ============================================================================== -# HEADSCALE ULTIMATE MANAGER - Rafael Ruscher Edition -# Com Caddy Proxy para resolver erro de CORS (Failed to Fetch) -# Suporte para HOST (Servidor) e GUEST (Cliente) -# VERSÃO COMPLETA COM TODAS CORREÇÕES -# ============================================================================== - -set -e - -# Cores para interface -GREEN='\033[0;32m' -BLUE='\033[0;34m' -YELLOW='\033[1;33m' -RED='\033[0;31m' -CYAN='\033[0;36m' -NC='\033[0m' - -header() { - clear - echo -e "${BLUE}====================================================${NC}" - echo -e "${GREEN} HEADSCALE & CLOUDFLARE - REDE PRIVADA ${NC}" - echo -e "${BLUE}====================================================${NC}" -} - -check_deps() { - echo -e "${YELLOW}Verificando dependências...${NC}" - for pkg in docker docker-compose jq curl miniupnpc; do - if ! command -v $pkg &> /dev/null; then - echo -e "${YELLOW}Instalando $pkg...${NC}" - sudo pacman -S $pkg --noconfirm 2>/dev/null || echo -e "${RED}Falha ao instalar $pkg. Instale manualmente.${NC}" - fi - done -} - -# --- FUNÇÃO PARA VERIFICAR PORTAS --- -check_ports() { - echo -e "${YELLOW}Verificando portas abertas...${NC}" - - # Verificar se Docker está rodando - if ! systemctl is-active --quiet docker; then - echo -e "${RED}Docker não está rodando. Iniciando...${NC}" - sudo systemctl start docker - sudo systemctl enable docker - fi - - # Verificar portas locais - echo -e "${CYAN}Portas locais em uso:${NC}" - sudo netstat -tulpn | grep -E ':(80|8080|41641)' || true - - # Tentar abrir portas no firewall - echo -e "${YELLOW}Configurando firewall...${NC}" - - # Para UFW - if command -v ufw &> /dev/null; then - sudo ufw allow 80/tcp 2>/dev/null || true - sudo ufw allow 8080/tcp 2>/dev/null || true - sudo ufw allow 41641/udp 2>/dev/null || true - sudo ufw reload 2>/dev/null || true - echo -e "${GREEN}Firewall UFW configurado${NC}" - fi - - # Para firewalld - if command -v firewall-cmd &> /dev/null; then - sudo firewall-cmd --permanent --add-port=80/tcp 2>/dev/null || true - sudo firewall-cmd --permanent --add-port=8080/tcp 2>/dev/null || true - sudo firewall-cmd --permanent --add-port=41641/udp 2>/dev/null || true - sudo firewall-cmd --reload 2>/dev/null || true - echo -e "${GREEN}Firewalld configurado${NC}" - fi -} - -# --- FUNÇÃO PARA O SERVIDOR (HOST) --- -setup_host() { - header - echo -e "${YELLOW}CONFIGURAÇÃO DE HOST (SERVIDOR)${NC}" - - # Obter informações - read -p "Domínio (ex: vpn.ruscher.org): " DOMAIN - read -p "Cloudflare Zone ID: " ZONE_ID - read -p "Cloudflare API Token: " API_TOKEN - - # Verificar dependências - check_deps - check_ports - - # Criar diretórios - mkdir -p ~/headscale-server/{config,data,caddy_data} - cd ~/headscale-server - - # 1. Criando Caddyfile OTIMIZADO - echo -e "${YELLOW}Gerando configuração do Proxy Reverso (Caddy)...${NC}" - cat < Caddyfile -{ - # Habilitar logs para debug - debug - admin off -} - -:80, :8080 { - # Log de todas as requisições - log { - output stdout - level INFO - } - - # CORS headers para todas as respostas - header { - Access-Control-Allow-Origin "*" - Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" - Access-Control-Allow-Headers "*" - } - - # Headscale UI - handle /web/* { - reverse_proxy headscale-ui:80 { - header_up Host {host} - header_up X-Real-IP {remote} - header_up X-Forwarded-For {remote} - header_up X-Forwarded-Proto {scheme} - } - } - - # Headscale API - handle /api/* { - reverse_proxy headscale:8080 { - header_up Host {host} - header_up X-Real-IP {remote} - header_up X-Forwarded-For {remote} - header_up X-Forwarded-Proto {scheme} - } - } - - # Tailscale login endpoints - handle /ts2021/* { - reverse_proxy headscale:8080 { - header_up Host {host} - header_up X-Real-IP {remote} - header_up X-Forwarded-For {remote} - header_up X-Forwarded-Proto {scheme} - } - } - - # Register endpoint - handle /register/* { - reverse_proxy headscale:8080 { - header_up Host {host} - header_up X-Real-IP {remote} - header_up X-Forwarded-For {remote} - header_up X-Forwarded-Proto {scheme} - } - } - - # Para todos os outros endpoints - handle { - reverse_proxy headscale:8080 { - header_up Host {host} - header_up X-Real-IP {remote} - header_up X-Forwarded-For {remote} - header_up X-Forwarded-Proto {scheme} - } - } -} -EOF - - # 2. Docker Compose ATUALIZADO - cat < docker-compose.yml -services: - headscale: - image: headscale/headscale:latest - container_name: headscale - volumes: - - ./config:/etc/headscale - - ./data:/var/lib/headscale - command: serve - restart: unless-stopped - networks: - - headscale-network - ports: - - "41642:41641/udp" - - headscale-ui: - image: ghcr.io/gurucomputing/headscale-ui:latest - container_name: headscale-ui - restart: unless-stopped - networks: - - headscale-network - depends_on: - - headscale - - caddy: - image: caddy:latest - container_name: caddy - ports: - - "80:80" - - "8080:8080" - volumes: - - ./Caddyfile:/etc/caddy/Caddyfile - - ./caddy_data:/data - restart: unless-stopped - networks: - - headscale-network - depends_on: - - headscale - - headscale-ui - -networks: - headscale-network: - driver: bridge -EOF - - # 3. Configuração do Headscale - echo -e "${YELLOW}Configurando Headscale...${NC}" - if [ ! -f ./config/config.yaml ]; then - curl -s https://raw.githubusercontent.com/juanfont/headscale/main/config-example.yaml -o ./config/config.yaml - - # Configurações essenciais - sed -i "s|server_url: .*|server_url: http://$DOMAIN|" ./config/config.yaml - sed -i 's|listen_addr: 127.0.0.1:8080|listen_addr: 0.0.0.0:8080|' ./config/config.yaml - sed -i 's|db_path: .*|db_path: /var/lib/headscale/db.sqlite|' ./config/config.yaml - sed -i 's|disable_check_updates: false|disable_check_updates: true|' ./config/config.yaml - sed -i 's|# magic_dns: true|magic_dns: true|' ./config/config.yaml - - # Permitir todas as rotas - echo "ip_prefixes:" >> ./config/config.yaml - echo " - 0.0.0.0/0" >> ./config/config.yaml - echo " - ::/0" >> ./config/config.yaml - fi - - # Permissões - sudo chmod -R 777 config data 2>/dev/null || true - - # 4. DNS Cloudflare - echo -e "${YELLOW}Atualizando DNS na Cloudflare...${NC}" - CURRENT_IP=$(curl -s https://api.ipify.org) - - # Verificar se já existe registro - RESPONSE=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records?name=$DOMAIN" \ - -H "Authorization: Bearer $API_TOKEN" \ - -H "Content-Type: application/json") - - RECORD_ID=$(echo $RESPONSE | jq -r '.result[0].id // empty') - - if [ -n "$RECORD_ID" ] && [ "$RECORD_ID" != "null" ]; then - # Atualizar registro existente - curl -s -X PUT "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/$RECORD_ID" \ - -H "Authorization: Bearer $API_TOKEN" \ - -H "Content-Type: application/json" \ - --data "{\"type\":\"A\",\"name\":\"$DOMAIN\",\"content\":\"$CURRENT_IP\",\"ttl\":120,\"proxied\":false}" \ - | jq -r '.success' - echo -e "${GREEN}Registro DNS atualizado${NC}" - else - # Criar novo registro - curl -s -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records" \ - -H "Authorization: Bearer $API_TOKEN" \ - -H "Content-Type: application/json" \ - --data "{\"type\":\"A\",\"name\":\"$DOMAIN\",\"content\":\"$CURRENT_IP\",\"ttl\":120,\"proxied\":false}" \ - | jq -r '.success' - echo -e "${GREEN}Novo registro DNS criado${NC}" - fi - - # 5. Subindo containers - echo -e "${YELLOW}Iniciando containers...${NC}" - docker-compose down 2>/dev/null || true - docker-compose up -d - - # Aguardar inicialização - echo -e "${YELLOW}Aguardando inicialização dos serviços...${NC}" - sleep 15 - - # 6. Configurar UPnP (Roteador) - IP_LOCAL=$(ip route get 1 | awk '{print $7;exit}') - echo -e "${YELLOW}Configurando portas no roteador via UPnP...${NC}" - - # Tentar abrir portas - for port in 80 8080; do - upnpc -d $port TCP 2>/dev/null || true - upnpc -e "Headscale HTTP" -a $IP_LOCAL $port $port TCP 2>/dev/null || true - done - - upnpc -d 41642 UDP 2>/dev/null || true - upnpc -e "Headscale Data" -a $IP_LOCAL 41642 41642 UDP 2>/dev/null || true - - # 7. Criar Usuário e Chaves - echo -e "${YELLOW}Criando usuário e chaves...${NC}" - - # Tentar criar usuário - docker exec headscale headscale users create amigos 2>/dev/null || true - - # Obter USER_ID - USER_ID=$(docker exec headscale headscale users list -o json 2>/dev/null | jq -r '.[] | select(.name=="amigos") | .id') - - if [ -z "$USER_ID" ] || [ "$USER_ID" = "null" ]; then - echo -e "${YELLOW}Criando novo usuário...${NC}" - docker exec headscale headscale users create amigos - USER_ID=$(docker exec headscale headscale users list -o json | jq -r '.[] | select(.name=="amigos") | .id') - fi - - # Criar Auth Key (válida por 7 dias) - AUTH_KEY=$(docker exec headscale headscale preauthkeys create --user "$USER_ID" --reusable --expiration 168h 2>/dev/null) - - # Criar API Key para UI - API_KEY=$(docker exec headscale headscale apikeys create 2>/dev/null | tr -d '\n') - - # 8. Testar serviços - echo -e "${YELLOW}Testando serviços...${NC}" - - # Testar Headscale - if curl -s http://localhost:8080/health > /dev/null; then - echo -e "${GREEN}✓ Headscale está funcionando${NC}" - else - echo -e "${RED}✗ Headscale não responde${NC}" - docker-compose logs headscale --tail=20 - fi - - # Testar Caddy - if curl -s http://localhost:80 > /dev/null; then - echo -e "${GREEN}✓ Caddy está funcionando${NC}" - else - echo -e "${RED}✗ Caddy não responde${NC}" - docker-compose logs caddy --tail=20 - fi - - # 9. Mostrar informações finais - header - echo -e "${GREEN}✅ SERVIDOR CONFIGURADO COM SUCESSO!${NC}" - echo "" - echo -e "${CYAN}=== INFORMAÇÕES DE ACESSO ===${NC}" - echo -e "Interface Web: ${YELLOW}http://$DOMAIN/web${NC}" - echo -e "URL da API: ${CYAN}http://$DOMAIN${NC}" - echo -e "Seu IP Público: ${GREEN}$CURRENT_IP${NC}" - echo -e "IP Local do Servidor: ${GREEN}$IP_LOCAL${NC}" - echo "" - echo -e "${CYAN}=== CREDENCIAIS ===${NC}" - echo -e "API Key (para UI): ${CYAN}$API_KEY${NC}" - echo -e "${GREEN}Chave para Amigos: $AUTH_KEY${NC}" - echo "" - echo -e "${CYAN}=== COMANDOS ÚTEIS ===${NC}" - echo -e "Ver logs: ${YELLOW}cd ~/headscale-server && docker-compose logs -f${NC}" - echo -e "Reiniciar: ${YELLOW}cd ~/headscale-server && docker-compose restart${NC}" - echo -e "Parar: ${YELLOW}cd ~/headscale-server && docker-compose down${NC}" - echo "" - echo -e "${YELLOW}⚠️ COMPARTILHE APENAS A 'CHAVE PARA AMIGOS'${NC}" - echo -e "${BLUE}====================================================${NC}" - - # Iniciar monitoramento de logs - echo "" - read -p "Deseja ver os logs em tempo real? (s/N): " -n 1 -r - echo - if [[ $REPLY =~ ^[Ss]$ ]]; then - cd ~/headscale-server - docker-compose logs -f --tail=50 - fi -} - -# --- FUNÇÃO PARA O CLIENTE (GUEST) --- -setup_guest() { - header - echo -e "${YELLOW}CONFIGURAÇÃO DE CLIENTE (GUEST)${NC}" - - # Obter informações - read -p "Domínio do Servidor (ex: vpn.ruscher.org): " HOST_DOMAIN - read -p "Chave de Acesso (Auth Key): " AUTH_KEY - - echo -e "${YELLOW}Verificando dependências...${NC}" - - # 1. Testar conexão com servidor ANTES de instalar - echo -e "${CYAN}Testando conexão com o servidor...${NC}" - if curl -s --connect-timeout 10 "http://$HOST_DOMAIN/health" > /dev/null 2>&1; then - echo -e "${GREEN}✓ Servidor acessível${NC}" - elif curl -s --connect-timeout 10 "http://$HOST_DOMAIN" > /dev/null 2>&1; then - echo -e "${GREEN}✓ Servidor acessível${NC}" - else - echo -e "${RED}✗ Não foi possível conectar ao servidor${NC}" - echo -e "${YELLOW}Verifique:${NC}" - echo "1. O domínio está correto?" - echo "2. O servidor está online?" - echo "3. A Auth Key é válida?" - read -p "Continuar mesmo assim? (s/N): " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Ss]$ ]]; then - exit 1 - fi - fi - - # 2. Instalar Tailscale - echo -e "${YELLOW}Instalando Tailscale...${NC}" - if ! command -v tailscale &> /dev/null; then - sudo pacman -S tailscale --noconfirm - else - echo -e "${GREEN}✓ Tailscale já instalado${NC}" - fi - - # 3. Parar e limpar estado anterior - echo -e "${YELLOW}Preparando ambiente...${NC}" - sudo systemctl stop tailscaled 2>/dev/null || true - sudo rm -rf /var/lib/tailscale/* 2>/dev/null || true - - # 4. Iniciar serviço - sudo systemctl start tailscaled - sudo systemctl enable tailscaled - - # 5. Conectar à rede - echo -e "${YELLOW}Conectando à rede privada...${NC}" - - # Tentar conexão com timeout - if timeout 60 sudo tailscale up \ - --login-server="http://$HOST_DOMAIN" \ - --authkey="$AUTH_KEY" \ - --reset \ - --accept-routes=true \ - --accept-dns=true \ - --hostname="guest-$(hostname)-$(date +%s)" \ - --advertise-exit-node=false; then - - echo -e "${GREEN}✅ Conexão estabelecida!${NC}" - else - echo -e "${RED}✗ Falha na conexão${NC}" - echo -e "${YELLOW}Tentando método alternativo...${NC}" - - # Método alternativo - sudo tailscale up \ - --login-server="http://$HOST_DOMAIN:8080" \ - --authkey="$AUTH_KEY" \ - --reset - fi - - # 6. Aguardar e verificar - echo -e "${YELLOW}Aguardando conexão...${NC}" - sleep 5 - - # 7. Verificar status - echo -e "${CYAN}=== STATUS DA CONEXÃO ===${NC}" - STATUS_JSON=$(tailscale status --json 2>/dev/null) - BACKEND_STATE=$(echo "$STATUS_JSON" | jq -r .BackendState) - - if [ "$BACKEND_STATE" = "Running" ]; then - echo -e "${GREEN}✅ Conectado com sucesso!${NC}" - - # Obter IP - YOUR_IP=$(tailscale ip -4 2>/dev/null || tailscale ip 2>/dev/null) - echo -e "Seu IP na rede: ${GREEN}$YOUR_IP${NC}" - - # Testar ping para servidor (Headscale Server IP usually starts with 100.64.0.1 if using magic dns, but not always pingable) - # Instead, just show success since BackendState is Running - - # Mostrar peers - echo "" - echo -e "${CYAN}=== DISPOSITIVOS CONECTADOS ===${NC}" - PEERS_COUNT=$(echo "$STATUS_JSON" | jq '.Peer | length') - if [ "$PEERS_COUNT" -gt 0 ]; then - tailscale status - else - echo -e "${YELLOW}Nenhum outro dispositivo conectado ainda. Você é o primeiro!${NC}" - echo -e "${YELLOW}Convide amigos usando a mesma Chave de Acesso.${NC}" - fi - - else - echo -e "${RED}✗ Falha na conexão${NC}" - echo "" - echo -e "${YELLOW}=== TROUBLESHOOTING ===${NC}" - echo "1. Verifique se o servidor está online" - echo "2. Confirme a Auth Key" - echo "3. Tente reiniciar: sudo systemctl restart tailscaled" - echo "4. Verifique logs: sudo journalctl -u tailscaled -f" - - # Mostrar logs - echo "" - read -p "Ver logs do Tailscale? (s/N): " -n 1 -r - echo - if [[ $REPLY =~ ^[Ss]$ ]]; then - sudo journalctl -u tailscaled -n 50 --no-pager - fi - fi - - # 8. Configurar firewall se necessário - if command -v ufw &> /dev/null; then - echo -e "${YELLOW}Configurando firewall...${NC}" - sudo ufw allow in on tailscale0 2>/dev/null || true - sudo ufw reload 2>/dev/null || true - fi - - echo -e "${BLUE}====================================================${NC}" - echo -e "${GREEN}Configuração do cliente concluída!${NC}" - echo -e "${YELLOW}Para desconectar: sudo tailscale down${NC}" - echo -e "${YELLOW}Para reconectar: sudo tailscale up${NC}" -} - -# --- FUNÇÃO DE TROUBLESHOOTING --- -troubleshoot() { - header - echo -e "${YELLOW}=== TROUBLESHOOTING AVANÇADO ===${NC}" - echo "1) Verificar status do servidor" - echo "2) Verificar logs do servidor" - echo "3) Testar conexão externa" - echo "4) Recriar chaves de acesso" - echo "5) Voltar ao menu principal" - read -p "Escolha uma opção: " TROUBLE_OPT - - case $TROUBLE_OPT in - 1) - if [ -d ~/headscale-server ]; then - cd ~/headscale-server - docker-compose ps - docker-compose logs --tail=20 - else - echo -e "${RED}Diretório do servidor não encontrado${NC}" - fi - ;; - 2) - if [ -d ~/headscale-server ]; then - cd ~/headscale-server - echo -e "${CYAN}=== LOGS DO HEADSCALE ===${NC}" - docker-compose logs headscale --tail=50 - echo -e "${CYAN}=== LOGS DO CADDY ===${NC}" - docker-compose logs caddy --tail=50 - fi - ;; - 3) - read -p "Domínio para testar: " TEST_DOMAIN - echo -e "${YELLOW}Testando $TEST_DOMAIN...${NC}" - curl -v --connect-timeout 10 "http://$TEST_DOMAIN/health" || \ - curl -v --connect-timeout 10 "http://$TEST_DOMAIN" || \ - echo -e "${RED}Falha na conexão${NC}" - ;; - 4) - if [ -d ~/headscale-server ]; then - cd ~/headscale-server - echo -e "${YELLOW}Recriando chaves...${NC}" - docker exec headscale headscale preauthkeys list --user amigos - read -p "Deseja criar nova chave? (s/N): " -n 1 -r - echo - if [[ $REPLY =~ ^[Ss]$ ]]; then - NEW_KEY=$(docker exec headscale headscale preauthkeys create --user amigos --reusable --expiration 168h) - echo -e "${GREEN}Nova chave: $NEW_KEY${NC}" - fi - fi - ;; - esac - - read -p "Pressione Enter para continuar..." - main_menu -} - -# --- MENU PRINCIPAL --- -main_menu() { - header - check_deps - echo "Selecione uma opção:" - echo "1) Ser o HOST (Criar e Gerenciar a Rede)" - echo "2) Ser o GUEST (Entrar na rede de um amigo)" - echo "3) Troubleshooting" - echo "4) Sair" - read -p "Opção: " OPT - - case $OPT in - 1) setup_host ;; - 2) setup_guest ;; - 3) troubleshoot ;; - *) exit 0 ;; - esac -} - -# Executar menu principal -main_menu diff --git a/usr/share/big-remote-play/ui/style.css b/usr/share/big-remote-play/ui/style.css index e1b544e..edaff19 100644 --- a/usr/share/big-remote-play/ui/style.css +++ b/usr/share/big-remote-play/ui/style.css @@ -179,6 +179,32 @@ entry:focus { 0 0 0 1px alpha(@accent_bg_color, 0.1); } +/* Primary card: accent border + faint tint, but readable window foreground + (NOT .suggested-action, which forces white text on a near-white tint). */ +.action-card.card-accent { + background: alpha(@accent_bg_color, 0.08); + border: 2px solid alpha(@accent_bg_color, 0.55); + color: @window_fg_color; + box-shadow: 0 4px 16px alpha(@accent_bg_color, 0.08); +} + +.action-card.card-accent:hover { + background: alpha(@accent_bg_color, 0.14); + border: 2px solid @accent_bg_color; + box-shadow: 0 12px 40px alpha(@accent_bg_color, 0.22); + transform: translateY(-2px); +} + +/* Small "recommended/primary" pill on a card. */ +.card-badge { + font-size: 0.75em; + font-weight: 700; + color: @accent_fg_color; + background: @accent_bg_color; + border-radius: 9px; + padding: 1px 8px; +} + /* Animations */ @keyframes fadeIn { from { @@ -398,4 +424,164 @@ levelbar block.filled { .action-card.suggested-action button.flat { opacity: 0.7; font-size: 0.85em; +} + +/* ─── Shared explanatory components (widgets.py) ─────────────────────────── */ + +/* "How it works" step strip */ +.steps-strip { + background: alpha(@card_bg_color, 0.55); + border-radius: 16px; + padding: 16px 20px; + border: 1px solid alpha(@borders, 0.06); + border-top: 1px solid alpha(white, 0.1); + box-shadow: 0 4px 20px alpha(black, 0.03), + 0 1px 3px alpha(black, 0.02); +} + +/* Numbered circle used by steps strip and wizard stepper */ +.step-number { + min-width: 24px; + min-height: 24px; + border-radius: 50%; + font-size: 0.8em; + font-weight: 700; + color: @accent_fg_color; + background: @accent_bg_color; + box-shadow: 0 2px 8px alpha(@accent_bg_color, 0.3); +} + +.step-number.completed { + background: alpha(@accent_bg_color, 0.45); +} + +.step-number.active { + background: @accent_bg_color; + box-shadow: 0 2px 10px alpha(@accent_bg_color, 0.45); +} + +/* In the "how it works" strip the numbers are subtle (mockup 02), not the solid + accent circles the wizard stepper uses. */ +.steps-strip .step-number { + color: @accent_color; + background: alpha(@accent_bg_color, 0.15); + box-shadow: none; +} + +/* Wizard stepper (top 1-2-3) */ +.wizard-stepper { + padding: 6px 0 14px 0; +} + +.wizard-stepper .step-connector { + background: alpha(@theme_fg_color, 0.15); + min-height: 2px; +} + +/* Side helper card ("what you'll need" / "if you can't find it") */ +.helper-card { + background: alpha(@accent_bg_color, 0.05); + border-radius: 16px; + padding: 18px 20px; + border: 1px solid alpha(@accent_bg_color, 0.18); + box-shadow: 0 2px 12px alpha(@accent_bg_color, 0.05); +} + +.helper-row { + padding: 4px 0; +} + +/* Difficulty pills on the VPN cards */ +.difficulty-pill { + font-size: 0.78em; + font-weight: 700; + border-radius: 9999px; + padding: 2px 12px; +} + +.difficulty-pill.easy { + color: #1a7f4b; + background: alpha(#26a269, 0.18); +} + +.difficulty-pill.medium { + color: #9a6a00; + background: alpha(#e5a50a, 0.2); +} + +.difficulty-pill.hard { + color: #1c5fb4; + background: alpha(#3584e4, 0.18); +} + +/* VPN comparison table */ +.comparison-table { + background: alpha(@card_bg_color, 0.55); + border-radius: 16px; + padding: 8px; + border: 1px solid alpha(@borders, 0.06); + box-shadow: 0 4px 20px alpha(black, 0.03); +} + +.comparison-header { + font-weight: 700; + color: @accent_color; + padding: 12px 10px; +} + +.comparison-cell { + padding: 12px 10px; + border-top: 1px solid alpha(@borders, 0.5); +} + +.comparison-rowlabel { + font-weight: 600; + opacity: 0.85; +} + +/* Inline metric chip (host status card) */ +.metric-chip { + background: alpha(@theme_fg_color, 0.05); + border-radius: 10px; + padding: 8px 14px; +} + +/* Server status card: circular Sunshine icon (mockup 05) */ +.status-icon-circle { + background: alpha(@accent_bg_color, 0.15); + border-radius: 9999px; + padding: 14px; +} + +.status-icon-circle image { + color: @accent_color; +} + +/* Live metric tiles (Latency / FPS / Bandwidth) */ +.metric-tile { + padding: 2px 4px; +} + +/* Compact rows for the Server info / management lists */ +.compact-rows row { + min-height: 0; +} + +.compact-rows row > box { + margin-top: 4px; + margin-bottom: 4px; +} + +.metric-orange { color: #e66100; } +.metric-green { color: #1a9b54; } +.metric-blue { color: #3584e4; } + +/* "Generate PIN" button: light accent tint (mockup 05) */ +button.pin-action { + background: alpha(@accent_bg_color, 0.15); + color: @accent_color; +} + +button.pin-action:hover { + background: alpha(@accent_bg_color, 0.25); } \ No newline at end of file diff --git a/usr/share/big-remote-play/ui/sunshine_preferences.py b/usr/share/big-remote-play/ui/sunshine_preferences.py deleted file mode 100644 index 9f072ac..0000000 --- a/usr/share/big-remote-play/ui/sunshine_preferences.py +++ /dev/null @@ -1,569 +0,0 @@ -import gi -import gettext -import locale - -gi.require_version('Gtk', '4.0') -gi.require_version('Adw', '1') -from gi.repository import Gtk, Gdk, Adw, Gio, GLib, GObject -from pathlib import Path -import configparser -import os -import subprocess - -from utils.i18n import _ -from utils.icons import create_icon_widget -import socket -from utils.system_check import SystemCheck - -class SunshineConfigManager: - def __init__(self): - self.config_dir = Path.home() / '.config' / 'big-remoteplay' / 'sunshine' - self.config_file = self.config_dir / 'sunshine.conf' - self.config_dir.mkdir(parents=True, exist_ok=True) - self.config = {} - self.load() - - def load(self): - self.config = {} - if self.config_file.exists(): - try: - with open(self.config_file, 'r') as f: - for line in f: - line = line.strip() - if not line or line.startswith('#'): continue - if '=' in line: - parts = line.split('=', 1) - if len(parts) == 2: - self.config[parts[0].strip()] = parts[1].strip() - except Exception as e: - print(f"Error loading Sunshine config: {e}") - - def save(self): - try: - with open(self.config_file, 'w') as f: - for key, value in self.config.items(): - f.write(f"{key} = {value}\n") - except Exception as e: - print(f"Error saving Sunshine config: {e}") - - def get(self, key, default=None): - return self.config.get(key, str(default)) - - def set(self, key, value): - self.config[key] = str(value) - self.save() - - -class SunshinePreferencesPage(Adw.PreferencesPage): - def __init__(self, **kwargs): - self.main_config = kwargs.pop('main_config', None) - super().__init__(**kwargs) - self.set_title(_("Sunshine")) - self.set_icon_name("preferences-desktop-remote-desktop-symbolic") - - self.config = SunshineConfigManager() - - # Standard Adwaita Layout: Multiple Groups in one Page - # This creates a long scrollable page with sections. - self.setup_groups() - - def setup_groups(self): - # Categories mapping to (Function, Icon, Description) - categories = { - _("General"): (self.get_general_options(), "preferences-system-symbolic", _("Server general settings")), - _("Input"): (self.get_input_options(), "input-keyboard-symbolic", _("Keyboard, mouse and gamepad")), - _("Audio/Video"): (self.get_av_options(), "video-display-symbolic", _("Resolution, bitrate and quality")), - _("Network"): (self.get_network_options(), "network-workgroup-symbolic", _("Ports and connectivity")), - _("Config Files"): ([], "document-properties-symbolic", _("Configuration files and logs")), - _("Advanced"): (self.get_advanced_options(), "preferences-other-symbolic", _("Advanced options")), - - # Encoders - _("NVIDIA NVENC"): (self.get_nvenc_options(), "media-flash-symbolic", _("NVIDIA Encoder")), - _("Intel QuickSync"): (self.get_qsv_options(), "media-memory-symbolic", _("Intel Encoder")), - _("AMD AMF"): (self.get_amf_options(), "media-tape-symbolic", _("AMD Encoder")), - _("VideoToolbox"): (self.get_vt_options(), "media-optical-symbolic", _("Apple Encoder")), - _("VA-API"): (self.get_vaapi_options(), "media-view-subtitles-symbolic", _("VA-API Encoder (Linux)")), - _("Software"): (self.get_software_options(), "software-update-available-symbolic", _("CPU Encoding")), - } - - for title, (options, icon, desc) in categories.items(): - # Create Group - group = Adw.PreferencesGroup() - group.set_title(title) - group.set_description(desc) - - has_content = False - - if title == _("Config Files"): - self.setup_config_files_tab(group) - has_content = True - else: - if title == _("General"): - self.setup_maintenance_tab(group) - has_content = True - - if options: - for opt in options: - row = self.create_option_row(opt) - if row: - group.add(row) - has_content = True - - if not has_content and title != _("Config Files"): - # Add placeholder row - row = Adw.ActionRow() - row.set_title(_("No options available")) - row.set_sensitive(False) - group.add(row) - - self.add(group) - - def create_option_row(self, opt): - # Unpack option tuple - # Now supports optional description: (key, label, type, default, choices, description) - if len(opt) == 6: - key, label, type_, default, choices, description = opt - else: - key, label, type_, default, choices = opt - description = None - - current_val = self.config.get(key, default) - - row = None - if type_ == "switch": - row = Adw.SwitchRow() - row.set_title(label) - active = current_val.lower() in ('true', 'enabled', '1', 'on') - row.set_active(active) - row.set_active(active) - - def on_switch_change(w, p): - val = str(w.get_active()).lower() - self.config.set(key, val) - - # Sync 'stream_audio' with Main Config Host Audio - if key == "stream_audio" and self.main_config: - h = self.main_config.get('host', {}) - h['audio'] = w.get_active() - self.main_config.set('host', h) - - row.connect('notify::active', on_switch_change) - - elif type_ == "password": - row = Adw.PasswordEntryRow() - row.set_title(label) - row.set_text(str(current_val)) - row.connect('changed', lambda w: self.config.set(key, w.get_text())) - - elif type_ == "entry": - row = Adw.EntryRow() - row.set_title(label) - row.set_text(str(current_val)) - row.connect('changed', lambda w: self.config.set(key, w.get_text())) - - elif type_ == "spin": - row = Adw.ActionRow() - row.set_title(label) - spin = Gtk.SpinButton.new_with_range(0, 100000, 1) # Generic range - try: - val = float(current_val) - except: - val = float(default) if default else 0 - spin.set_value(val) - spin.connect('value-changed', lambda w: self.config.set(key, int(w.get_value()))) - spin.set_valign(Gtk.Align.CENTER) - row.add_suffix(spin) - - elif type_ == "combo": - row = Adw.ComboRow() - row.set_title(label) - - # Check if choices are strings or tuples (key, label) - is_kv = False - if choices and isinstance(choices[0], tuple): - is_kv = True - display_values = [c[1] for c in choices] - keys = [c[0] for c in choices] - else: - display_values = choices - keys = choices - - model = Gtk.StringList() - for c in display_values: - model.append(c) - row.set_model(model) - - # Find index - try: - # current_val should be the key - idx = 0 - if current_val in keys: - idx = keys.index(current_val) - except: - idx = 0 - row.set_selected(idx) - - row.connect('notify::selected', lambda w, p, k=keys, key_name=key: self.config.set(key_name, k[w.get_selected()])) - - if row and description: - if isinstance(row, Adw.ActionRow): - row.set_subtitle(description) - else: - row.set_tooltip_text(description) - - return row - - - def setup_config_files_tab(self, group): - # Determine paths - # Sunshine defaults usually: - # apps.json: alongside sunshine.conf or in config_dir - # sunshine.log: in config_dir - # credentials: in config_dir (often credentials.json) - # pkey: sunshine.key (in config_dir) - # cert: sunshine.cert (in config_dir) - # state: sunshine_state.json (in config_dir) - - # We can check specific config keys if they exist, otherwise assume defaults - - def get_path(key, default_filename): - base = self.config.config_dir - val = self.config.get(key) - if val and val != str(None): - p = Path(val) - if p.is_absolute(): - return p - else: - return base / p - return base / default_filename - - files = [ - (_("Apps File"), get_path("apps_file", "apps.json"), _("The file where current apps of Sunshine are stored.")), - (_("Credentials File"), get_path("credentials_file", "credentials.json"), _("Store Username/Password separately from Sunshine's state file.")), - (_("Logfile Path"), get_path("log_path", "sunshine.log"), _("The file where the current logs of Sunshine are stored.")), - (_("Private Key"), get_path("pkey", "sunshine.key"), _("The private key used for the web UI and Moonlight client pairing. For best compatibility, this should be an RSA-2048 private key.")), - (_("Certificate"), get_path("cert", "sunshine.cert"), _("The certificate used for the web UI and Moonlight client pairing. For best compatibility, this should have an RSA-2048 public key.")), - (_("State File"), get_path("state_file", "sunshine_state.json"), _("The file where current state of Sunshine is stored")), - (_("Configuration File"), self.config.config_file, _("The main configuration file for Sunshine.")) - ] - - for name, path, desc in files: - row = Adw.ActionRow() - row.set_title(name) - row.set_subtitle(str(path)) - row.set_tooltip_text(desc) - - # Check if file exists - if not path.exists(): - row.add_css_class("error") - row.add_prefix(create_icon_widget("preferences-other-symbolic", size=16)) - - btn = Gtk.Button() - btn.set_child(create_icon_widget("folder-open-symbolic", size=16)) - btn.set_valign(Gtk.Align.CENTER) - btn.add_css_class("flat") - btn.connect('clicked', lambda _, p=path: self.open_file(p)) - - # Check if file exists, if not, try to create default - if not path.exists(): - try: - if "credentials" in str(path): - with open(path, 'w') as f: f.write("[]") - elif "state" in str(path): - with open(path, 'w') as f: f.write("{}") - except: pass - - row.add_suffix(btn) - group.add(row) - - def open_file(self, path): - # Try to open with xdg-open - try: - GLib.spawn_command_line_async(f"xdg-open '{path}'") - except: - pass - - def setup_maintenance_tab(self, group): - """ - Setup maintenance actions - """ - # Check ICU libraries - sys_check = SystemCheck() - missing_icu = sys_check.check_icu_libs() - - if missing_icu: - row = Adw.ActionRow() - row.set_title(_("Missing Libraries Detected")) - row.set_subtitle(_("Sunshine requires older ICU libraries ({}) which are missing.").format(", ".join(missing_icu))) - row.add_css_class("error") - row.add_prefix(create_icon_widget("network-offline-symbolic", size=24)) - - fix_btn = Gtk.Button(label=_("Fix Dependencies")) - fix_btn.add_css_class("suggested-action") - fix_btn.set_valign(Gtk.Align.CENTER) - fix_btn.connect('clicked', self.on_fix_libs_clicked) - - row.add_suffix(fix_btn) - group.add(row) - else: - row = Adw.ActionRow() - row.set_title(_("System Status")) - row.set_subtitle(_("All required libraries appear to be present.")) - row.add_prefix(create_icon_widget("network-wired-symbolic", size=24)) - group.add(row) - - # Force Fix button (advanced) - force_row = Adw.ActionRow() - force_row.set_title(_("Re-apply Library Fix")) - force_row.set_subtitle(_("Run the library fix script manually.")) - - force_btn = Gtk.Button(label=_("Run Fix")) - force_btn.set_valign(Gtk.Align.CENTER) - force_btn.connect('clicked', self.on_fix_libs_clicked) - force_row.add_suffix(force_btn) - group.add(force_row) - - def on_fix_libs_clicked(self, btn): - script_path = "/usr/share/big-remote-play/scripts/fix_sunshine_libs.sh" - - # Check if script exists, if not use local dev path for testing - if not os.path.exists(script_path): - # Try to find it in current source if running from source - possible_local = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "scripts", "fix_sunshine_libs.sh")) - if os.path.exists(possible_local): - script_path = possible_local - - try: - # properly double quote the command for pkexec / sh -c - cmd = f"pkexec bash '{script_path}'" - subprocess.Popen(cmd, shell=True) - - # Toast - root = btn.get_root() - if hasattr(root, 'add_toast'): - root.add_toast(Adw.Toast.new(_("Fix script started. Please authenticate."))) - - # Start polling - self.fix_attempts = 0 - GLib.timeout_add(2000, self._poll_fix_status) - - except Exception as e: - print(f"Error running fix: {e}") - - def _poll_fix_status(self): - from utils.system_check import SystemCheck - missing = SystemCheck().check_icu_libs() - - if not missing: - if hasattr(self, 'get_root') and self.get_root(): - root = self.get_root() - if hasattr(root, 'add_toast'): - root.add_toast(Adw.Toast.new(_("Libraries fixed! You can now start the server."))) - return False - - self.fix_attempts += 1 - return self.fix_attempts < 30 # Stop after ~60s - - - def get_general_options(self): - return [ - ("locale", _("Locale"), "combo", "en", [ - "bg", "cs", "de", "en", "en_GB", "en_US", "es", "fr", "hu", - "it", "ja", "ko", "pl", "pt", "pt_BR", "ru", "sv", "tr", - "uk", "vi", "zh", "zh_TW" - ], _("The locale used for Sunshine's user interface.")), - ("sunshine_name", _("Sunshine Name"), "entry", socket.gethostname(), None, _("The name of the Sunshine instance as seen by clients.")), - ("sunshine_user", _("Sunshine User"), "entry", "", None, _("Username for API access (Monitoring)")), - ("sunshine_password", _("Sunshine Password"), "password", "", None, _("Password for API access (Monitoring)")), - ("min_log_level", _("Log Level"), "combo", "2", [ - ("0", "Verbose"), ("1", "Debug"), ("2", "Info"), - ("3", "Warning"), ("4", "Error"), ("5", "Fatal"), ("6", "None") - ], _("The minimum log level printed to standard out")), - ("notify_pre_releases", _("Pre-Release Notifications"), "switch", "false", None, _("Whether to be notified of new pre-release versions of Sunshine")), - ("system_tray", _("Enable System Tray"), "switch", "true", None, _("Show icon in system tray and display desktop notifications")), - ] - - def get_network_options(self): - return [ - ("upnp", _("UPnP"), "switch", "false", None, _("Automatically configure port forwarding for streaming over the Internet")), - ("address_family", _("Address Family"), "combo", "ipv4", [ - ("ipv4", "IPv4"), ("both", "IPv4 + IPv6") - ], _("Set the address family used by Sunshine")), - ("bind_address", _("Bind Address"), "entry", "0.0.0.0", None, _("IP address to bind the service to")), - ("port", _("Port (Moonlight)"), "spin", "47989", None, _("Set the family of ports used by Sunshine.\nTCP: 47984, 47989, 47990 (Web UI), 48010\nUDP: 47998 - 48012")), - ("origin_web_ui_allowed", _("Web UI Access"), "combo", "lan", [ - ("pc", "Localhost"), ("lan", "LAN"), ("wan", "WAN") - ], _("The origin of the remote endpoint address that is not denied access to Web UI.\nWarning: Exposing the Web UI to the internet is a security risk! Proceed at your own risk!")), - ("external_ip", _("External IP"), "entry", "", None, _("If no external IP address is given, Sunshine will automatically detect external IP")), - ("lan_encryption_mode", _("LAN Encryption"), "combo", "0", [ - ("0", _("Disabled")), ("1", _("Mode 1")), ("2", _("Mode 2")) - ], _("This determines when encryption will be used when streaming over your local network. Encryption can reduce streaming performance, particularly on less powerful hosts and clients.")), - ("wan_encryption_mode", _("WAN Encryption"), "combo", "0", [ - ("0", _("Disabled")), ("1", _("Mode 1")), ("2", _("Mode 2")) - ], _("This determines when encryption will be used when streaming over the Internet. Encryption can reduce streaming performance, particularly on less powerful hosts and clients.")), - ("ping_timeout", _("Ping Timeout (ms)"), "spin", "2000", None, _("How long to wait in milliseconds for data from moonlight before shutting down the stream")), - ] - - def get_input_options(self): - return [ - ("controller", _("Enable Gamepad Input"), "switch", "true", None, _("Allows guests to control the host system with a gamepad / controller")), - ("gamepad", _("Emulated Gamepad Type"), "combo", "auto", [ - ("auto", _("Automatic")), ("x360", "Xbox 360"), ("ds4", "DualShock 4"), - ("ds5", "DualSense (Linux)"), ("switch", "Nintendo Switch"), ("xone", "Xbox One") - ], _("Choose which type of gamepad to emulate on the host")), - ("motion_as_ds4", _("Motion as DS4"), "switch", "true", None, _("Emulate motion controls as DS4")), - ("touchpad_as_ds4", _("Touchpad as DS4"), "switch", "true", None, _("Emulate touchpad as DS4")), - ("ds4_back_as_touchpad_click", _("Back Button as Touchpad Click"), "switch", "true", None, _("Use Back button for touchpad click on DS4")), - ("ds5_inputtino_randomize_mac", _("Randomize MAC (DS5)"), "switch", "true", None, _("Randomize virtual MAC address for DS5")), - ("back_button_timeout", _("Home/Guide Timeout (ms)"), "spin", "-1", None, _("Hold Back/Select to emulate Guide button. < 0 to disable.")), - ("keyboard", _("Enable Keyboard Input"), "switch", "true", None, _("Allows guests to control the host system with the keyboard")), - ("mouse", _("Enable Mouse Input"), "switch", "true", None, _("Allows guests to control the host system with the mouse")), - ("always_send_scancodes", _("Always Send Scancodes"), "switch", "true", None, _("Always send raw key scancodes")), - ("key_rightalt_to_key_win", _("Map Right Alt to Windows"), "switch", "false", None, _("Make Sunshine think the Right Alt key is the Windows key")), - ("high_resolution_scrolling", _("High Resolution Scrolling"), "switch", "true", None, _("Pass through high resolution scroll events from Moonlight clients")), - ] - - def get_monitors(self): - monitors = [] - is_wayland = os.environ.get('XDG_SESSION_TYPE') == 'wayland' - try: - display = Gdk.Display.get_default() - if display: - monitor_list = display.get_monitors() - for i in range(monitor_list.get_n_items()): - monitor = monitor_list.get_item(i) - name = monitor.get_connector() - if name: - manufacturer = monitor.get_manufacturer() or "" - model = monitor.get_model() or "" - label_parts = [] - if manufacturer: label_parts.append(manufacturer) - if model: label_parts.append(model) - label = " ".join(label_parts) if label_parts else "Monitor" - - # Value logic: Wayland uses 0, 1, 2... | X11 uses HDMI-A-1, etc. - val = str(i) if is_wayland else name - full_label = f"{label} ({name})" - monitors.append((full_label, val)) - except Exception as e: - print(f"Error getting monitors: {e}") - - if not monitors: - return [("auto", _("Auto / Primary"))] - - return monitors - - def get_av_options(self): - monitors = self.get_monitors() - # Verify if configured output_name is in monitors - current_output = self.config.get("output_name") - - # If current_output is set but not in detected monitors, we should potentially clear it - # or default to auto/first one to avoid Error 503. - # However, if detection is flaky, maybe we should keep it? - # But here the user issue IS that an invalid one persists. - # So providing only detected ones (plus auto) is safer. - - return [ - ("audio_sink", _("Audio Sink"), "entry", "", None, _("Listing all available audio sinks is possible by running `pactl list short sinks` (PulseAudio) or `wpctl status` (PipeWire).")), - ("stream_audio", _("Stream Audio"), "switch", "true", None, _("Whether to stream audio or not. Disabling this can be useful for streaming headless displays as second monitors.")), - ("adapter_name", _("Graphics Adapter"), "entry", "", None, _("Specific GPU to use. Default is usually correct.")), - ("output_name", _("Display Name"), "combo", monitors[0][0] if monitors else "auto", monitors, _("Select the display monitor to capture. Corresponds to the output connector name (e.g., DP-1, HDMI-A-1).")), - ("max_bitrate", _("Maximum Bitrate"), "spin", "0", None, _("The maximum bitrate (in Kbps) that Sunshine will encode the stream at. If set to 0, it will always use the bitrate requested by Moonlight.")), - ("min_fps", _("Minimum FPS Target"), "spin", "0", None, _("The lowest effective FPS a stream can reach. A value of 0 is treated as roughly half of the stream's FPS. A setting of 20 is recommended if you stream 24 or 30fps content.")), - ] - - def get_advanced_options(self): - return [ - ("fec_percentage", _("FEC Percentage"), "spin", "20", None, _("Percentage of error correcting packets per data packet in each video frame. Higher values can correct for more network packet loss, but at the cost of increasing bandwidth usage.")), - ("qp", _("Quantization Parameter"), "spin", "28", None, _("Some devices may not support Constant Bit Rate. For those devices, QP is used instead. Higher value means more compression, but less quality.")), - ("min_threads", _("Minimum CPU Thread Count"), "spin", "4", None, _("Increasing the value slightly reduces encoding efficiency, but the tradeoff is usually worth it to gain the use of more CPU cores for encoding. The ideal value is the lowest value that can reliably encode at your desired streaming settings on your hardware.")), - ("hevc_mode", _("HEVC Support"), "combo", "0", [ - ("0", _("Disabled")), ("1", _("Advertised (Recommended)")), ("2", "Main 10"), ("3", "Main 10 + HDR") - ], _("Allows the client to request HEVC Main or HEVC Main10 video streams. HEVC is more CPU-intensive to encode, so enabling this may reduce performance when using software encoding.")), - ("av1_mode", _("AV1 Support"), "combo", "0", [ - ("0", _("Disabled")), ("1", _("Advertised (Recommended)")), ("2", "Main 10"), ("3", "Main 10 + HDR") - ], _("Allows the client to request AV1 Main 8-bit or 10-bit video streams. AV1 is more CPU-intensive to encode, so enabling this may reduce performance when using software encoding.")), - ("capture", _("Force a Specific Capture Method"), "combo", "auto", [ - ("auto", _("Autodetect (Recommended)")), ("nvfbc", "NvFBC"), ("wlr", "wlroots"), ("kms", "KMS"), ("x11", "X11"), ("portal", "XDG Desktop Portal") - ], _("On automatic mode Sunshine will use the first one that works. NvFBC requires patched nvidia drivers.")), - ("encoder", _("Force a Specific Encoder"), "combo", "auto", [ - ("auto", _("Autodetect")), ("nvenc", "NVIDIA NVENC"), - ("vaapi", "VA-API"), ("software", "Software"), - ("quicksync", "Intel QuickSync"), ("amdvce", "AMD AMF") - ], _("Force a specific encoder, otherwise Sunshine will select the best available option. Note: If you specify a hardware encoder on Windows, it must match the GPU where the display is connected.")), - ] - - def get_nvenc_options(self): - return [ - ("nvenc_preset", _("Performance Preset"), "combo", "1", [ - ("1", "P1 (Fastest)"), ("2", "P2"), ("3", "P3"), - ("4", "P4 (Default)"), ("5", "P5"), ("6", "P6"), ("7", "P7 (Best Quality)") - ], _("Higher numbers improve compression (quality at given bitrate) at the cost of increased encoding latency. Recommended to change only when limited by network or decoder, otherwise similar effect can be accomplished by increasing bitrate.")), - ("nvenc_twopass", _("Two-Pass Mode"), "combo", "quarter_res", [ - ("disabled", _("Disabled")), ("quarter_res", _("Quarter Resolution")), ("full_res", _("Full Resolution")) - ], _("Adds preliminary encoding pass. This allows to detect more motion vectors, better distribute bitrate across the frame and more strictly adhere to bitrate limits. Disabling it is not recommended since this can lead to occasional bitrate overshoot and subsequent packet loss.")), - ("nvenc_spatial_aq", _("Spatial AQ"), "switch", "false", None, _("Assign higher QP values to flat regions of the video. Recommended to enable when streaming at lower bitrates.")), - ("nvenc_vbv_increase", _("Single-frame VBV/HRD percentage increase"), "spin", "0", None, _("By default sunshine uses single-frame VBV/HRD, which means any encoded video frame size is not expected to exceed requested bitrate divided by requested frame rate. Relaxing this restriction can be beneficial and act as low-latency variable bitrate, but may also lead to packet loss if the network doesn't have buffer headroom to handle bitrate spikes. Maximum accepted value is 400, which corresponds to 5x increased encoded video frame upper size limit.")), - ("nvenc_h264_cavlc", _("Prefer CAVLC over CABAC in H.264"), "switch", "false", None, _("Simpler form of entropy coding. CAVLC needs around 10% more bitrate for same quality. Only relevant for really old decoding devices.")), - ] - - def get_qsv_options(self): - return [ - ("qsv_preset", _("QuickSync Preset"), "combo", "medium", [ - "veryfast", "faster", "fast", "medium", "slow", "slower", "slowest" - ], _("Performance preset")), - ("qsv_coder", _("QuickSync Coder (H264)"), "combo", "auto", [ - ("auto", _("Auto")), ("cabac", "CABAC"), ("cavlc", "CAVLC") - ], _("Entropy coding mode")), - ("qsv_slow_hevc", _("Allow Slow HEVC Encoding"), "switch", "false", None, _("This can enable HEVC encoding on older Intel GPUs, at the cost of higher GPU usage and worse performance.")), - ] - - def get_amf_options(self): - return [ - ("amd_usage", _("AMF Usage"), "combo", "ultralowlatency", [ - ("transcoding", "Transcoding"), ("webcam", "Webcam"), - ("lowlatency_high_quality", "Low Latency High Quality"), - ("lowlatency", "Low Latency"), ("ultralowlatency", "Ultra Low Latency") - ], _("This sets the base encoding profile. All options presented below will override a subset of the usage profile, but there are additional hidden settings applied that cannot be configured elsewhere.")), - ("amd_rc", _("AMF Rate Control"), "combo", "vbr_latency", [ - ("cbr", "CBR"), ("cqp", "CQP"), - ("vbr_latency", "VBR Latency"), ("vbr_peak", "VBR Peak") - ], _("This controls the rate control method to ensure we are not exceeding the client bitrate target. 'cqp' is not suitable for bitrate targeting, and other options besides 'vbr_latency' depend on HRD Enforcement to help constrain bitrate overflows.")), - ("amd_enforce_hrd", _("AMF Hypothetical Reference Decoder (HRD) Enforcement"), "switch", "false", None, _("Increases the constraints on rate control to meet HRD model requirements. This greatly reduces bitrate overflows, but may cause encoding artifacts or reduced quality on certain cards.")), - ("amd_quality", _("AMF Quality"), "combo", "balanced", [ - ("speed", _("Speed")), ("balanced", _("Balanced")), ("quality", _("Quality")) - ], _("This controls the balance between encoding speed and quality.")), - ("amd_preanalysis", _("AMF Preanalysis"), "switch", "false", None, _("This enables rate-control preanalysis, which may increase quality at the expense of increased encoding latency.")), - ("amd_vbaq", _("AMF Variance Based Adaptive Quantization (VBAQ)"), "switch", "true", None, _("The human visual system is typically less sensitive to artifacts in highly textured areas. In VBAQ mode, pixel variance is used to indicate the complexity of spatial textures, allowing the encoder to allocate more bits to smoother areas. Enabling this feature leads to improvements in subjective visual quality with some content.")), - ("amd_coder", _("AMF Coder (H264)"), "combo", "auto", [ - ("auto", _("Auto")), ("cabac", "CABAC"), ("cavlc", "CAVLC") - ], _("Allows you to select the entropy encoding to prioritize quality or encoding speed. H.264 only.")), - ] - - def get_vt_options(self): - return [ - ("vt_coder", _("VideoToolbox Coder"), "combo", "auto", [ - ("auto", _("Auto")), ("cabac", "CABAC"), ("cavlc", "CAVLC") - ], _("Entropy coding mode")), - ("vt_software", _("VideoToolbox Software Encoding"), "combo", "auto", [ - ("auto", _("Auto")), ("disabled", _("Disabled")), ("allowed", _("Allowed")), ("forced", _("Forced")) - ], _("Allow fallback to software encoding")), - ("vt_realtime", _("VideoToolbox Realtime Encoding"), "switch", "true", None, _("Realtime encoding priority")), - ] - - def get_vaapi_options(self): - return [ - ("vaapi_strict_rc_buffer", _("Strictly enforce frame bitrate limits for H.264/HEVC on AMD GPUs"), "switch", "false", None, _("Enabling this option can avoid dropped frames over the network during scene changes, but video quality may be reduced during motion.")), - ] - - def get_software_options(self): - return [ - ("sw_preset", _("SW Presets"), "combo", "superfast", [ - "ultrafast", "superfast", "veryfast", "faster", "fast", - "medium", "slow", "slower", "veryslow" - ], _("Optimize the trade-off between encoding speed (encoded frames per second) and compression efficiency (quality per bit in the bitstream). Defaults to superfast.")), - ("sw_tune", _("SW Tune"), "combo", "zerolatency", [ - ("film", "Film"), ("animation", "Animation"), ("grain", "Grain"), ("stillimage", "Still Image"), ("fastdecode", "Fast Decode"), ("zerolatency", "Zero Latency") - ], _("Tuning options, which are applied after the preset. Defaults to zerolatency.")), - ] diff --git a/usr/share/big-remote-play/utils/i18n.py b/usr/share/big-remote-play/utils/i18n.py deleted file mode 100644 index ae09202..0000000 --- a/usr/share/big-remote-play/utils/i18n.py +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -"""Internationalization support for LangForge.""" - -import gettext -import os - -# Determine locale directory (works in AppImage and system install) -locale_dir = '/usr/share/locale' # Default for system install - -# Check if we're in an AppImage -if 'APPIMAGE' in os.environ or 'APPDIR' in os.environ: - script_dir = os.path.dirname(os.path.abspath(__file__)) # utils/ - app_dir = os.path.dirname(script_dir) # big-remote-play/ - share_dir = os.path.dirname(app_dir) # share/ - appimage_locale = os.path.join(share_dir, 'locale') # share/locale - - if os.path.isdir(appimage_locale): - locale_dir = appimage_locale - -# Configure the translation text domain for big-remote-play -gettext.bindtextdomain("big-remote-play", locale_dir) -gettext.textdomain("big-remote-play") - -# Export _ directly as the translation function -_ = gettext.gettext diff --git a/usr/share/big-remote-play/utils/system_check.py b/usr/share/big-remote-play/utils/system_check.py deleted file mode 100644 index 3f4241d..0000000 --- a/usr/share/big-remote-play/utils/system_check.py +++ /dev/null @@ -1,207 +0,0 @@ -""" -System component verification -""" - -import subprocess -import shutil -from typing import Tuple -from utils.i18n import _ - -class SystemCheck: - """System component checker""" - - def __init__(self): - pass - - def has_sunshine(self) -> bool: - """Checks if Sunshine is installed""" - return shutil.which('sunshine') is not None - - def has_moonlight(self) -> bool: - """Checks if Moonlight is installed""" - # Moonlight may have different names - return ( - shutil.which('moonlight') is not None or - shutil.which('moonlight-qt') is not None - ) - - def has_avahi(self) -> bool: - """Checks if Avahi is installed""" - return shutil.which('avahi-browse') is not None - - def has_docker(self) -> bool: - """Checks if Docker is installed""" - return shutil.which('docker') is not None - - def has_zerotier(self) -> bool: - """Checks if ZeroTier is installed""" - return shutil.which('zerotier-cli') is not None - - def has_tailscale(self) -> bool: - """Checks if Tailscale is installed""" - return shutil.which('tailscale') is not None - - def check_all(self) -> dict: - """Checks all components""" - return { - 'sunshine': self.has_sunshine(), - 'moonlight': self.has_moonlight(), - 'avahi': self.has_avahi(), - 'docker': self.has_docker(), - 'tailscale': self.has_tailscale(), - 'zerotier': self.has_zerotier(), - } - - def is_sunshine_running(self) -> bool: - """Checks if Sunshine process is running""" - try: - result = subprocess.run( - ['pgrep', '-x', 'sunshine'], - capture_output=True, - timeout=2 - ) - return result.returncode == 0 - except: - return False - - def is_docker_running(self) -> bool: - """Checks if Docker daemon is running""" - try: - return subprocess.run(['systemctl', 'is-active', '--quiet', 'docker']).returncode == 0 - except: - return False - - def are_containers_running(self) -> bool: - """Checks if app containers (caddy, headscale) are running""" - try: - result = subprocess.run( - ['docker', 'ps', '--format', '{{.Names}}'], - capture_output=True, - text=True, - timeout=2 - ) - if result.returncode == 0: - output = result.stdout.strip() - return 'caddy' in output and 'headscale' in output - return False - except: - return False - - def is_tailscale_running(self) -> bool: - """Checks if Tailscale daemon is running""" - try: - return subprocess.run(['systemctl', 'is-active', '--quiet', 'tailscaled']).returncode == 0 - except: - return False - - def is_zerotier_running(self) -> bool: - """Checks if ZeroTier daemon is running""" - try: - return subprocess.run(['systemctl', 'is-active', '--quiet', 'zerotier-one']).returncode == 0 - except: - return False - - def is_moonlight_running(self) -> bool: - """Checks if Moonlight process is running (ignores zombies)""" - try: - for process_name in ['moonlight', 'moonlight-qt']: - # Get PIDs - result = subprocess.run( - ['pgrep', '-x', process_name], - capture_output=True, - text=True, # Important to read output as text - timeout=2 - ) - - if result.returncode == 0 and result.stdout: - pids = result.stdout.strip().split() - for pid in pids: - # Check process state - try: - state_check = subprocess.run( - ['ps', '-o', 'state=', '-p', pid], - capture_output=True, - text=True, - timeout=1 - ) - if state_check.returncode == 0: - state = state_check.stdout.strip() - # If state is not Z (Zombie) or T (Stopped), consider running - if state and state not in ['Z', 'T', 'Z+']: - return True - except: - continue - - return False - except: - return False - - def get_sunshine_version(self) -> str: - """Gets Sunshine version""" - try: - result = subprocess.run( - ['sunshine', '--version'], - capture_output=True, - text=True, - timeout=2 - ) - - if result.returncode == 0: - return result.stdout.strip() - else: - return _("Unknown") - - except: - return _("Unknown") - - def get_moonlight_version(self) -> str: - """Gets Moonlight version""" - try: - # Try different variants - for cmd in ['moonlight-qt', 'moonlight']: - result = subprocess.run( - [cmd, '--version'], - capture_output=True, - text=True, - timeout=2 - ) - - if result.returncode == 0: - return result.stdout.strip() - - return _("Unknown") - - except: - return _("Unknown") - - def check_icu_libs(self) -> list: - """ - Checks for missing ICU libraries required by Sunshine (specifically .76) - Returns a list of missing libraries. - """ - import os - - missing = [] - required_libs = ['libicuuc.so.76', 'libicudata.so.76', 'libicui18n.so.76'] - lib_paths = ['/usr/share/big-remote-play/libs', '/usr/lib', '/usr/lib64'] - - for lib in required_libs: - found = False - for path in lib_paths: - full_path = os.path.join(path, lib) - if os.path.exists(full_path): - # Check if it's a symlink to .78 (which is broken) - try: - real_path = os.path.realpath(full_path) - if "78" in os.path.basename(real_path) and "76" not in os.path.basename(real_path): - # It's a fake symlink - continue - except: pass - - found = True - break - - if not found: - missing.append(lib) - - return missing From c2b5b203c56be0a1de23fbe47de1e5c9fc1ce928 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Gon=C3=A7alves?= Date: Wed, 24 Jun 2026 20:49:25 -0300 Subject: [PATCH 02/47] Reduce low-risk pyright warnings --- src/big_remote_play/guest/moonlight_client.py | 42 +++++++++++++------ src/big_remote_play/host/sunshine_manager.py | 4 +- src/big_remote_play/utils/audio.py | 2 +- src/big_remote_play/utils/moonlight_config.py | 16 +++---- src/big_remote_play/utils/secret_store.py | 10 ++--- 5 files changed, 47 insertions(+), 27 deletions(-) diff --git a/src/big_remote_play/guest/moonlight_client.py b/src/big_remote_play/guest/moonlight_client.py index 60cc654..06d74ad 100644 --- a/src/big_remote_play/guest/moonlight_client.py +++ b/src/big_remote_play/guest/moonlight_client.py @@ -1,7 +1,16 @@ -import subprocess, shutil +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, TextIO +import shutil +import subprocess + + class MoonlightClient: - def __init__(self, logger=None): - self.process = None; self.connected_host = None; self.logger = logger + def __init__(self, logger: Any | None = None) -> None: + self.process: subprocess.Popen[str] | None = None + self.connected_host: str | None = None + self.logger = logger self.moonlight_cmd = next((c for c in ['moonlight-qt', 'moonlight'] if shutil.which(c)), None) def _prepare_ip(self, ip): @@ -37,13 +46,14 @@ def _prepare_ip(self, ip): return clean_ip - def connect(self, ip, **kw): + def connect(self, ip: str, **kw: Any) -> bool: if not self.moonlight_cmd or self.is_connected(): return False + moonlight_cmd = self.moonlight_cmd try: target_ip = self._prepare_ip(ip) - cmd = [self.moonlight_cmd, 'stream', target_ip, 'Desktop'] + cmd = [moonlight_cmd, 'stream', target_ip, 'Desktop'] if kw.get('width') and kw.get('height') and kw.get('width') != 'custom': cmd.extend(['--resolution', f"{kw['width']}x{kw['height']}"]) if kw.get('fps') and kw.get('fps') != 'custom': cmd.extend(['--fps', str(kw['fps'])]) if kw.get('bitrate'): cmd.extend(['--bitrate', str(kw['bitrate'])]) @@ -63,11 +73,12 @@ def connect(self, ip, **kw): self.process = subprocess.Popen(cmd, stdout=stdout_target, stderr=stderr_target, text=True) self.connected_host = ip - if self.logger: + logger = self.logger + if logger: import threading - def log_output(pipe, level): + def log_output(pipe: TextIO, level: str) -> None: for line in iter(pipe.readline, ''): - if line: getattr(self.logger, level, self.logger.info)(f"[Moonlight] {line.strip()}") + if line: getattr(logger, level, logger.info)(f"[Moonlight] {line.strip()}") pipe.close() if self.process.stdout: threading.Thread(target=log_output, args=(self.process.stdout, 'info'), daemon=True).start() if self.process.stderr: threading.Thread(target=log_output, args=(self.process.stderr, 'error'), daemon=True).start() @@ -84,7 +95,7 @@ def log_output(pipe, level): if self.logger: self.logger.error(f"Error connecting: {e}") return False - def is_connected(self): return self.process and self.process.poll() is None + def is_connected(self) -> bool: return bool(self.process and self.process.poll() is None) def disconnect(self): if not self.is_connected(): return False @@ -95,7 +106,9 @@ def disconnect(self): if self.process: self.process.kill(); self.process = None; self.connected_host = None return False - def probe_host(self, host_ip): + def probe_host(self, host_ip: str) -> bool: + if not self.moonlight_cmd: + return False try: target_ip = self._prepare_ip(host_ip) # Aggressive timeout for probe @@ -105,17 +118,22 @@ def probe_host(self, host_ip): if self.logger: self.logger.error(f"Probe error: {e}") return False - def pair(self, host_ip, on_pin_callback=None): + def pair(self, host_ip: str, on_pin_callback: Callable[[str], None] | None = None) -> bool: + if not self.moonlight_cmd: + return False try: target_ip = self._prepare_ip(host_ip) cmd = [self.moonlight_cmd, 'pair', target_ip] if self.logger: self.logger.info(f"Starting pair with {host_ip} (target: {target_ip}): {' '.join(cmd)}") self.process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1) + stdout = self.process.stdout + if stdout is None: + return False success = False while True: - line = self.process.stdout.readline() + line = stdout.readline() # If no line and process ended, break if not line and self.process.poll() is not None: break if not line: continue diff --git a/src/big_remote_play/host/sunshine_manager.py b/src/big_remote_play/host/sunshine_manager.py index 7e41459..0d1db1e 100644 --- a/src/big_remote_play/host/sunshine_manager.py +++ b/src/big_remote_play/host/sunshine_manager.py @@ -25,7 +25,7 @@ def _cert_fingerprint(cert_der: bytes) -> str: class SunshineHost: - def __init__(self, cdir: Path = None): + def __init__(self, cdir: Path | None = None): self.config_dir = cdir or (Path.home() / ".config" / "big-remoteplay" / "sunshine") self.config_dir.mkdir(parents=True, exist_ok=True) # TOFU store for Sunshine's self-signed API certificate fingerprint. @@ -312,7 +312,7 @@ def _trust_fingerprint(self, fingerprint: str) -> bool: _log.error(_("Certificate trust error: {}").format(exc)) return False - def _api_request(self, method: str, path: str, payload: dict = None, auth: tuple[str, str] | None = None, timeout: float = 5.0) -> tuple[int, bytes]: + def _api_request(self, method: str, path: str, payload: dict | None = None, auth: tuple[str, str] | None = None, timeout: float = 5.0) -> tuple[int, bytes]: """Calls the Sunshine config API over TLS with TOFU cert pinning. Returns (status_code, body_bytes). status 0 means the connection failed diff --git a/src/big_remote_play/utils/audio.py b/src/big_remote_play/utils/audio.py index ba5096a..74add88 100644 --- a/src/big_remote_play/utils/audio.py +++ b/src/big_remote_play/utils/audio.py @@ -210,7 +210,7 @@ def get_sink_monitor_source(self, sink_name: str) -> Optional[str]: except Exception: return f"{sink_name}.monitor" - def disable_streaming_audio(self, host_sink: str): + def disable_streaming_audio(self, host_sink: Optional[str]) -> None: """ Disables Streaming mode. Restores default sink and removes virtual modules. diff --git a/src/big_remote_play/utils/moonlight_config.py b/src/big_remote_play/utils/moonlight_config.py index c820d48..1782b39 100644 --- a/src/big_remote_play/utils/moonlight_config.py +++ b/src/big_remote_play/utils/moonlight_config.py @@ -6,7 +6,7 @@ class MoonlightConfigManager: _shared_state = {} - def __init__(self): + def __init__(self) -> None: self.__dict__ = self._shared_state if hasattr(self, 'cp'): @@ -18,7 +18,7 @@ def __init__(self): Path.home() / '.var' / 'app' / 'com.moonlight_stream.Moonlight' / 'config' / 'Moonlight Game Streaming Project' / 'Moonlight.conf' ] - self.config_file = None + self.config_file: Path | None = None for p in paths: if p.exists(): self.config_file = p @@ -32,7 +32,7 @@ def __init__(self): self.cp = configparser.ConfigParser() self.load() - def load(self): + def load(self) -> None: if self.config_file and self.config_file.exists(): try: self.cp.read(self.config_file) @@ -42,21 +42,23 @@ def load(self): if 'General' not in self.cp: self.cp.add_section('General') - def reload(self): + def reload(self) -> None: """Force reload from file""" self.cp = configparser.ConfigParser() self.load() - def save(self): + def save(self) -> None: try: + if self.config_file is None: + return with open(self.config_file, 'w') as f: self.cp.write(f) except Exception as e: _log.error(f"Error saving Moonlight config: {e}") - def get(self, key, default=None): + def get(self, key: str, default: object = None) -> str: return self.cp.get('General', key, fallback=str(default)) - def set(self, key, value): + def set(self, key: str, value: object) -> None: self.cp.set('General', key, str(value)) self.save() diff --git a/src/big_remote_play/utils/secret_store.py b/src/big_remote_play/utils/secret_store.py index 5e39986..503c9e3 100644 --- a/src/big_remote_play/utils/secret_store.py +++ b/src/big_remote_play/utils/secret_store.py @@ -8,7 +8,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Protocol +from typing import Any, Protocol import uuid @@ -48,13 +48,13 @@ class LibsecretBackend: def __init__(self) -> None: self._error: Exception | None = None - self._secret = None - self._schema = None + self._secret: Any | None = None + self._schema: Any | None = None try: import gi gi.require_version("Secret", "1") - from gi.repository import Secret + from gi.repository import Secret # type: ignore[reportMissingModuleSource] self._secret = Secret self._schema = Secret.Schema.new( @@ -72,7 +72,7 @@ def __init__(self) -> None: def is_available(self) -> bool: return self._secret is not None and self._schema is not None - def _require_secret(self): + def _require_secret(self) -> Any: if not self.is_available(): raise SecretStoreUnavailable(str(self._error) if self._error else "Secret Service unavailable") return self._secret From 3d9a6e23b21a1c551b21686abdd71241b1a17472 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Gon=C3=A7alves?= Date: Wed, 24 Jun 2026 20:56:52 -0300 Subject: [PATCH 03/47] Tighten GTK idle and display typing --- src/big_remote_play/app.py | 34 +++++++++++++------- src/big_remote_play/ui/guest_view.py | 45 +++++++++++++++++++++------ src/big_remote_play/ui/main_window.py | 10 +++--- 3 files changed, 65 insertions(+), 24 deletions(-) diff --git a/src/big_remote_play/app.py b/src/big_remote_play/app.py index 937376d..dc56b1f 100644 --- a/src/big_remote_play/app.py +++ b/src/big_remote_play/app.py @@ -1,7 +1,9 @@ +from __future__ import annotations + import sys, os, gi gi.require_version('Gtk', '4.0') gi.require_version('Adw', '1') -from gi.repository import Gtk, Adw, Gio, Gdk, GLib +from gi.repository import Gtk, Adw, Gio, Gdk, GLib # type: ignore from big_remote_play.ui.main_window import MainWindow from big_remote_play.utils.config import Config from big_remote_play.utils.logger import Logger @@ -42,7 +44,11 @@ def setup_theme(self): self.load_custom_css() def setup_icon(self): - it = Gtk.IconTheme.get_for_display(Gdk.Display.get_default()) + display = Gdk.Display.get_default() + if display is None: + self.logger.error(_("Could not load icon theme: no GTK display")) + return + it = Gtk.IconTheme.get_for_display(display) if os.path.exists(ICONS_DIR): it.add_search_path(ICONS_DIR) if os.path.exists(IMG_DIR): @@ -51,7 +57,10 @@ def setup_icon(self): def load_custom_css(self): cp = Gtk.CssProvider(); cp_path = paths.STYLE_CSS - if cp_path.exists(): cp.load_from_path(str(cp_path)); Gtk.StyleContext.add_provider_for_display(self.window.get_display() if self.window else Gdk.Display.get_default(), cp, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) + display = self.window.get_display() if self.window else Gdk.Display.get_default() + if cp_path.exists() and display is not None: + cp.load_from_path(str(cp_path)) + Gtk.StyleContext.add_provider_for_display(display, cp, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) def show_about(self, *args): story = _("The Story Behind the Project\n\n" @@ -80,19 +89,22 @@ def show_about(self, *args): about.present() def show_preferences(self, *args, tab=None): + window = self.window + if window is None: + return from big_remote_play.ui.preferences import PreferencesWindow - pref_win = PreferencesWindow(transient_for=self.window, config=self.config, initial_tab=tab) + pref_win = PreferencesWindow(transient_for=window, config=self.config, initial_tab=tab) # Reload GuestView settings when preferences close def on_close(*_): - if hasattr(self.window, 'guest_view') and hasattr(self.window.guest_view, 'load_guest_settings'): - self.window.guest_view.load_guest_settings() - - if hasattr(self.window, 'host_view') and hasattr(self.window.host_view, 'load_settings'): + if hasattr(window, 'guest_view') and hasattr(window.guest_view, 'load_guest_settings'): + window.guest_view.load_guest_settings() + + if hasattr(window, 'host_view') and hasattr(window.host_view, 'load_settings'): # Reload config from file first if needed - if hasattr(self.window.host_view, 'config') and hasattr(self.window.host_view.config, 'load'): - self.window.host_view.config.load() - self.window.host_view.load_settings() + if hasattr(window.host_view, 'config') and hasattr(window.host_view.config, 'load'): + window.host_view.config.load() + window.host_view.load_settings() pref_win.connect('close-request', on_close) pref_win.present() diff --git a/src/big_remote_play/ui/guest_view.py b/src/big_remote_play/ui/guest_view.py index 34a769b..567bad0 100644 --- a/src/big_remote_play/ui/guest_view.py +++ b/src/big_remote_play/ui/guest_view.py @@ -584,10 +584,21 @@ def run(): 'hw_decode': hw_decode_active } - if self.moonlight.connect(host['ip'], **opts): - GLib.idle_add(lambda: (self.show_loading(False), self.perf_monitor.set_connection_status(host['name'], _("Active Stream"), True), self.perf_monitor.start_monitoring())) - else: - GLib.idle_add(lambda: (self.show_loading(False), self.show_error_dialog(_('Error'), _('Failed to connect. Verify if Moonlight is paired.')))) + if self.moonlight.connect(host['ip'], **opts): + def finish_connect_success(): + self.show_loading(False) + self.perf_monitor.set_connection_status(host['name'], _("Active Stream"), True) + self.perf_monitor.start_monitoring() + return False + + GLib.idle_add(finish_connect_success) + else: + def finish_connect_error(): + self.show_loading(False) + self.show_error_dialog(_('Error'), _('Failed to connect. Verify if Moonlight is paired.')) + return False + + GLib.idle_add(finish_connect_error) # Insert automatic resolution logic BEFORE thread for total safety if scale_active: @@ -621,10 +632,21 @@ def run_patched(): # Check for cancellation if not getattr(self, 'is_connecting', False): return - if self.moonlight.connect(host['ip'], **opts): - GLib.idle_add(lambda: (self.show_loading(False), self.perf_monitor.set_connection_status(host['name'], _("Active Stream"), True), self.perf_monitor.start_monitoring())) - else: - GLib.idle_add(lambda: (self.show_loading(False), self.show_error_dialog(_('Error'), _('Failed to connect')))) + if self.moonlight.connect(host['ip'], **opts): + def finish_auto_connect_success(): + self.show_loading(False) + self.perf_monitor.set_connection_status(host['name'], _("Active Stream"), True) + self.perf_monitor.start_monitoring() + return False + + GLib.idle_add(finish_auto_connect_success) + else: + def finish_auto_connect_error(): + self.show_loading(False) + self.show_error_dialog(_('Error'), _('Failed to connect')) + return False + + GLib.idle_add(finish_auto_connect_error) threading.Thread(target=run_patched, daemon=True).start() else: @@ -672,7 +694,12 @@ def do_pair(): success = True if success: - GLib.idle_add(lambda: (self.show_toast(_("Paired successfully!")), self.connect_to_host(host, paired_retry=True))) + def finish_pair_success(): + self.show_toast(_("Paired successfully!")) + self.connect_to_host(host, paired_retry=True) + return False + + GLib.idle_add(finish_pair_success) else: GLib.idle_add(lambda: self.show_error_dialog(_("Pairing Error"), _("Could not pair with host.\nVerify the PIN was entered correctly."))) diff --git a/src/big_remote_play/ui/main_window.py b/src/big_remote_play/ui/main_window.py index a883299..84a7df0 100644 --- a/src/big_remote_play/ui/main_window.py +++ b/src/big_remote_play/ui/main_window.py @@ -869,11 +869,13 @@ def check(): r_tail = self.system_check.is_tailscale_running() r_zt = self.system_check.is_zerotier_running() - GLib.idle_add(lambda: ( - self.update_status(h_sun, h_moon), - self.update_server_status(r_sun, r_moon, r_docker, r_tail, r_zt), + def finish_system_check(): + self.update_status(h_sun, h_moon) + self.update_server_status(r_sun, r_moon, r_docker, r_tail, r_zt) self.update_dependency_ui(h_sun, h_moon, h_docker, h_tail, h_zt) - )) + return False + + GLib.idle_add(finish_system_check) threading.Thread(target=check, daemon=True).start() GLib.timeout_add_seconds(3, self.p_check) From d43eed69e7b64663a93df1982d341b8090855875 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Gon=C3=A7alves?= Date: Wed, 24 Jun 2026 20:58:45 -0300 Subject: [PATCH 04/47] Replace dynamic main window widget state --- src/big_remote_play/ui/main_window.py | 49 +++++++++++++++++---------- 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/src/big_remote_play/ui/main_window.py b/src/big_remote_play/ui/main_window.py index 84a7df0..1f6612f 100644 --- a/src/big_remote_play/ui/main_window.py +++ b/src/big_remote_play/ui/main_window.py @@ -1,6 +1,8 @@ +from __future__ import annotations + import gi gi.require_version('Gtk', '4.0'); gi.require_version('Adw', '1') -from gi.repository import Gtk, Adw, GLib, Gio +from gi.repository import Gtk, Adw, GLib, Gio # type: ignore import threading import json import os @@ -152,6 +154,10 @@ def __init__(self, **kwargs): # Current State self.current_page = 'welcome' self._vpn_choice = load_vpn_choice() # None if not yet chosen + self._nav_page_by_row = {} + self._service_by_row = {} + self._status_dots = {} + self._status_labels = {} self.setup_ui() self.check_system() @@ -223,6 +229,7 @@ def _refresh_nav_list(self): # Clear existing rows while child := self.nav_list.get_first_child(): self.nav_list.remove(child) + self._nav_page_by_row.clear() nav_pages = self._build_navigation_pages() for pid, info in nav_pages.items(): @@ -252,7 +259,7 @@ def _refresh_nav_list(self): def create_nav_row(self, page_id: str, page_info: dict) -> Gtk.ListBoxRow: """Creates navigation row in sidebar""" row = Gtk.ListBoxRow() - row.page_id = page_id + self._nav_page_by_row[row] = page_id row.add_css_class('category-row') box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) @@ -313,13 +320,14 @@ def add_status_row(container, label_text, dot_attr, lbl_attr, service_id): row = Gtk.Button() row.add_css_class("info-row") row.add_css_class("flat") - row.service_id = service_id + self._service_by_row[row] = service_id row.connect("clicked", lambda b, sid=service_id: self.on_service_clicked(sid)) content = Gtk.Box(spacing=10) box_key = Gtk.Box(spacing=8) box_key.set_hexpand(True) dot = create_icon_widget('media-record-symbolic', size=10, css_class=['status-dot', 'status-offline']) + self._status_dots[service_id] = dot setattr(self, dot_attr, dot) box_key.append(dot) lbl_key = Gtk.Label(label=label_text) @@ -329,6 +337,7 @@ def add_status_row(container, label_text, dot_attr, lbl_attr, service_id): lbl_status = Gtk.Label(label=_('Checking...')) lbl_status.add_css_class('info-value') lbl_status.set_halign(Gtk.Align.END) + self._status_labels[service_id] = lbl_status setattr(self, lbl_attr, lbl_status) content.append(lbl_status) row.set_child(content) @@ -368,19 +377,20 @@ def _filter_status_rows(self): child = self.status_card.get_first_child() while child: - sid = getattr(child, 'service_id', None) + sid = self._service_by_row.get(child) if sid: child.set_visible(sid in visible_services) child = child.get_next_sibling() def update_server_status(self, has_sun, has_moon, has_docker, has_tailscale, has_zt=False): - for dot, has in [ - (self.sunshine_dot, has_sun), - (self.moonlight_dot, has_moon), - (self.docker_dot, has_docker), - (self.tailscale_dot, has_tailscale), - (getattr(self, 'zerotier_dot', None), has_zt) + for service_id, has in [ + ('sunshine', has_sun), + ('moonlight', has_moon), + ('docker', has_docker), + ('tailscale', has_tailscale), + ('zerotier', has_zt) ]: + dot = self._status_dots.get(service_id) if not dot: continue dot.remove_css_class('status-online') dot.remove_css_class('status-offline') @@ -388,14 +398,17 @@ def update_server_status(self, has_sun, has_moon, has_docker, has_tailscale, has def update_dependency_ui(self, has_sun, has_moon, has_docker, has_tailscale, has_zt=False): status_items = [ - (self.lbl_sunshine_status, self.host_card, has_sun, 'Sunshine'), - (self.lbl_moonlight_status, self.guest_card, has_moon, 'Moonlight'), - (self.lbl_docker_status, None, has_docker, 'Docker'), - (self.lbl_tailscale_status, None, has_tailscale, 'Tailscale'), - (getattr(self, 'lbl_zerotier_status', None), None, has_zt, 'ZeroTier') + ('sunshine', self.host_card, has_sun, 'Sunshine'), + ('moonlight', self.guest_card, has_moon, 'Moonlight'), + ('docker', None, has_docker, 'Docker'), + ('tailscale', None, has_tailscale, 'Tailscale'), + ('zerotier', None, has_zt, 'ZeroTier') ] - for lbl, card, has, name in status_items: + for service_id, card, has, name in status_items: + lbl = self._status_labels.get(service_id) + if not lbl: + continue status_text = _("Installed") if has else _("Missing") lbl.set_markup(f'{status_text}') @@ -804,7 +817,7 @@ def create_action_card(self, title, desc, icon, cb, primary=False, badge=None, c def on_nav_selected(self, lb, row): if not row: return - pid = getattr(row, 'page_id', None) + pid = self._nav_page_by_row.get(row) if not pid: return # Update visual style active state @@ -846,7 +859,7 @@ def navigate_to(self, pid): """Programmatic navigation: find row and select it""" r = self.nav_list.get_first_child() while r: - if getattr(r, 'page_id', None) == pid: + if isinstance(r, Gtk.ListBoxRow) and self._nav_page_by_row.get(r) == pid: self.nav_list.select_row(r) break r = r.get_next_sibling() From 3ce14be04e5cdd93dacc0d6e888d28579ca60e64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Gon=C3=A7alves?= Date: Wed, 24 Jun 2026 21:00:26 -0300 Subject: [PATCH 05/47] Mark dynamic GI imports for pyright --- src/big_remote_play/ui/guest_view.py | 2 +- src/big_remote_play/ui/host_view.py | 3 +-- src/big_remote_play/ui/installer_window.py | 10 +++++++--- src/big_remote_play/ui/moonlight_preferences.py | 2 +- src/big_remote_play/ui/performance_monitor.py | 4 ++-- src/big_remote_play/ui/preferences.py | 2 +- src/big_remote_play/ui/private_network_view.py | 2 +- src/big_remote_play/ui/sunshine_preferences.py | 2 +- src/big_remote_play/utils/icons.py | 2 +- src/big_remote_play/utils/network.py | 2 +- src/big_remote_play/utils/secret_store.py | 2 +- src/big_remote_play/utils/uri.py | 2 +- src/big_remote_play/utils/widgets.py | 2 +- 13 files changed, 20 insertions(+), 17 deletions(-) diff --git a/src/big_remote_play/ui/guest_view.py b/src/big_remote_play/ui/guest_view.py index 567bad0..aaf95db 100644 --- a/src/big_remote_play/ui/guest_view.py +++ b/src/big_remote_play/ui/guest_view.py @@ -4,7 +4,7 @@ gi.require_version('Gtk', '4.0') gi.require_version('Adw', '1') -from gi.repository import Gtk, Adw, GLib, Gdk +from gi.repository import Gtk, Adw, GLib, Gdk # type: ignore import threading, time from big_remote_play.utils.config import Config from big_remote_play.guest.moonlight_client import MoonlightClient diff --git a/src/big_remote_play/ui/host_view.py b/src/big_remote_play/ui/host_view.py index 05a8ed9..7196127 100644 --- a/src/big_remote_play/ui/host_view.py +++ b/src/big_remote_play/ui/host_view.py @@ -2,7 +2,7 @@ gi.require_version('Gtk', '4.0') gi.require_version('Adw', '1') -from gi.repository import Gtk, Gdk, Adw, GLib +from gi.repository import Gtk, Gdk, Adw, GLib # type: ignore import subprocess, random, string, json, socket, os, time from pathlib import Path from big_remote_play.utils.game_detector import GameDetector @@ -2617,4 +2617,3 @@ def cleanup(self): # This also avoids the feedback loop (microfonia) when the app is closed while Moonlight/Sunshine are active. if not self.is_hosting: if hasattr(self, 'audio_manager'): self.audio_manager.cleanup() - diff --git a/src/big_remote_play/ui/installer_window.py b/src/big_remote_play/ui/installer_window.py index 62716ad..e5f551d 100644 --- a/src/big_remote_play/ui/installer_window.py +++ b/src/big_remote_play/ui/installer_window.py @@ -1,8 +1,12 @@ import gi gi.require_version('Gtk', '4.0'); gi.require_version('Adw', '1') -try: gi.require_version('Vte', '3.91'); from gi.repository import Vte; HAS_VTE = True -except Exception: HAS_VTE = False -from gi.repository import Gtk, Adw, GLib, Gio +try: + gi.require_version('Vte', '3.91') + from gi.repository import Vte # type: ignore + HAS_VTE = True +except Exception: + HAS_VTE = False +from gi.repository import Gtk, Adw, GLib, Gio # type: ignore import subprocess, os, shutil from big_remote_play.utils.i18n import _ diff --git a/src/big_remote_play/ui/moonlight_preferences.py b/src/big_remote_play/ui/moonlight_preferences.py index f6e63ef..7c7624e 100644 --- a/src/big_remote_play/ui/moonlight_preferences.py +++ b/src/big_remote_play/ui/moonlight_preferences.py @@ -2,7 +2,7 @@ gi.require_version('Gtk', '4.0') gi.require_version('Adw', '1') -from gi.repository import Gtk, Adw +from gi.repository import Gtk, Adw # type: ignore from big_remote_play.utils.i18n import _ diff --git a/src/big_remote_play/ui/performance_monitor.py b/src/big_remote_play/ui/performance_monitor.py index 5bbb52b..e6396cc 100644 --- a/src/big_remote_play/ui/performance_monitor.py +++ b/src/big_remote_play/ui/performance_monitor.py @@ -19,7 +19,7 @@ gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") -from gi.repository import Gtk, GLib, Adw +from gi.repository import Gtk, GLib, Adw # type: ignore from big_remote_play.utils.i18n import _ try: @@ -855,4 +855,4 @@ def set_connection_status(self, name, status, conn=True): self._status_icon.remove_css_class("warning") else: set_icon(self._status_icon, "network-idle-symbolic") - self._status_icon.remove_css_class("success") \ No newline at end of file + self._status_icon.remove_css_class("success") diff --git a/src/big_remote_play/ui/preferences.py b/src/big_remote_play/ui/preferences.py index 7453eee..e51342e 100644 --- a/src/big_remote_play/ui/preferences.py +++ b/src/big_remote_play/ui/preferences.py @@ -6,7 +6,7 @@ gi.require_version('Gtk', '4.0') gi.require_version('Adw', '1') -from gi.repository import Gtk, Adw +from gi.repository import Gtk, Adw # type: ignore import os, shutil, sys from pathlib import Path diff --git a/src/big_remote_play/ui/private_network_view.py b/src/big_remote_play/ui/private_network_view.py index 3b28b56..447c71f 100644 --- a/src/big_remote_play/ui/private_network_view.py +++ b/src/big_remote_play/ui/private_network_view.py @@ -3,7 +3,7 @@ gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") import json, os, re, subprocess, threading, time, shutil -from gi.repository import Adw, Gdk, GLib, Gtk +from gi.repository import Adw, Gdk, GLib, Gtk # type: ignore from big_remote_play.utils.i18n import _ from big_remote_play.utils.icons import create_icon_widget from big_remote_play.utils.widgets import create_helper_card, create_wizard_stepper diff --git a/src/big_remote_play/ui/sunshine_preferences.py b/src/big_remote_play/ui/sunshine_preferences.py index 1479cbe..cc49d57 100644 --- a/src/big_remote_play/ui/sunshine_preferences.py +++ b/src/big_remote_play/ui/sunshine_preferences.py @@ -2,7 +2,7 @@ gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") -from gi.repository import Gtk, Gdk, Adw, GLib +from gi.repository import Gtk, Gdk, Adw, GLib # type: ignore from pathlib import Path import os diff --git a/src/big_remote_play/utils/icons.py b/src/big_remote_play/utils/icons.py index 955d5c7..7eaea50 100644 --- a/src/big_remote_play/utils/icons.py +++ b/src/big_remote_play/utils/icons.py @@ -2,7 +2,7 @@ import gi gi.require_version('Gtk', '4.0') gi.require_version('GdkPixbuf', '2.0') -from gi.repository import Gtk, Gio, Gdk, GdkPixbuf +from gi.repository import Gtk, Gio, Gdk, GdkPixbuf # type: ignore from big_remote_play import paths diff --git a/src/big_remote_play/utils/network.py b/src/big_remote_play/utils/network.py index 107bc28..1f16068 100644 --- a/src/big_remote_play/utils/network.py +++ b/src/big_remote_play/utils/network.py @@ -26,7 +26,7 @@ def run(): if not hosts: hosts = self.manual_scan() except Exception: hosts = self.manual_scan() if callback: - from gi.repository import GLib + from gi.repository import GLib # type: ignore GLib.idle_add(callback, hosts) threading.Thread(target=run, daemon=True).start() diff --git a/src/big_remote_play/utils/secret_store.py b/src/big_remote_play/utils/secret_store.py index 503c9e3..694daed 100644 --- a/src/big_remote_play/utils/secret_store.py +++ b/src/big_remote_play/utils/secret_store.py @@ -54,7 +54,7 @@ def __init__(self) -> None: import gi gi.require_version("Secret", "1") - from gi.repository import Secret # type: ignore[reportMissingModuleSource] + from gi.repository import Secret # type: ignore self._secret = Secret self._schema = Secret.Schema.new( diff --git a/src/big_remote_play/utils/uri.py b/src/big_remote_play/utils/uri.py index 56853cf..274620b 100644 --- a/src/big_remote_play/utils/uri.py +++ b/src/big_remote_play/utils/uri.py @@ -9,7 +9,7 @@ import gi gi.require_version("Gtk", "4.0") -from gi.repository import Gtk, Gdk, GLib # noqa: E402 +from gi.repository import Gtk, Gdk, GLib # type: ignore # noqa: E402 def _toplevel(widget): diff --git a/src/big_remote_play/utils/widgets.py b/src/big_remote_play/utils/widgets.py index 31afb45..6a128dd 100644 --- a/src/big_remote_play/utils/widgets.py +++ b/src/big_remote_play/utils/widgets.py @@ -12,7 +12,7 @@ import gi gi.require_version("Gtk", "4.0") -from gi.repository import Gtk # noqa: E402 +from gi.repository import Gtk # type: ignore # noqa: E402 from big_remote_play.utils.icons import create_icon_widget # noqa: E402 from big_remote_play.utils.i18n import _ # noqa: E402 From 11e8fcbc56d83972c355209e5209f2b5e30d24a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Gon=C3=A7alves?= Date: Wed, 24 Jun 2026 21:05:15 -0300 Subject: [PATCH 06/47] Resolve small UI pyright warnings --- src/big_remote_play/ui/installer_window.py | 8 ++++- src/big_remote_play/ui/performance_monitor.py | 34 +++++++++++++++---- src/big_remote_play/ui/preferences.py | 9 +---- .../ui/sunshine_preferences.py | 2 ++ src/big_remote_play/utils/icons.py | 7 +++- 5 files changed, 43 insertions(+), 17 deletions(-) diff --git a/src/big_remote_play/ui/installer_window.py b/src/big_remote_play/ui/installer_window.py index e5f551d..d5080f9 100644 --- a/src/big_remote_play/ui/installer_window.py +++ b/src/big_remote_play/ui/installer_window.py @@ -91,8 +91,14 @@ def start_external_installation(self): def start_installation(self): self.status_label.set_text(_('Installing...')) + + def on_spawn_done(terminal, pid, error, user_data): + if error: + GLib.idle_add(lambda: self.status_label.set_text(_("Error: {}").format(error))) + GLib.idle_add(self.start_external_installation) + try: - self.terminal.spawn_async(Vte.PtyFlags.DEFAULT, None, ['yay', '-S', '--noconfirm', '--needed', 'sunshine', 'moonlight-qt'], None, GLib.SpawnFlags.SEARCH_PATH, None, -1, Gio.Cancellable(), lambda t, p, e, u: (GLib.idle_add(lambda: self.status_label.set_text(_("Error: {}").format(e))) if e else None, GLib.idle_add(self.start_external_installation) if e else None), None) + self.terminal.spawn_async(Vte.PtyFlags.DEFAULT, None, ['yay', '-S', '--noconfirm', '--needed', 'sunshine', 'moonlight-qt'], None, GLib.SpawnFlags.SEARCH_PATH, None, -1, Gio.Cancellable(), on_spawn_done, None) except Exception as e: self.status_label.set_text(_('Embedded terminal error: {}. Trying external...').format(e)); GLib.idle_add(self.start_external_installation) def on_process_exit(self, terminal, status): diff --git a/src/big_remote_play/ui/performance_monitor.py b/src/big_remote_play/ui/performance_monitor.py index e6396cc..d62b2d6 100644 --- a/src/big_remote_play/ui/performance_monitor.py +++ b/src/big_remote_play/ui/performance_monitor.py @@ -37,7 +37,7 @@ class PerformanceDataPoint: latency: float fps: float bandwidth: float - device_latencies: dict + device_latencies: dict[str, float] latency_text: str fps_text: str bandwidth_text: str @@ -85,7 +85,15 @@ def _get_device_color(self, name): self.device_colors[base_name] = self.color_palette[idx] return self.device_colors[base_name] - def add_data_point(self, latency: float, fps: float, bandwidth: float, users: int = 0, device_latencies: dict = None, bw_text_override: str = None): + def add_data_point( + self, + latency: float, + fps: float, + bandwidth: float, + users: int = 0, + device_latencies: dict[str, float] | None = None, + bw_text_override: str | None = None, + ): if latency > self.max_latency: self.max_latency = latency * 1.2 if fps > self.max_fps: self.max_fps = fps * 1.2 if bandwidth > self.max_bandwidth: self.max_bandwidth = bandwidth * 1.2 @@ -265,11 +273,14 @@ def draw_item(label, val_text, color, x_offset): draw_item("BW", self._cur_bw_text, (0.0, 0.6, 1.0, 1.0), offset) def _draw_tooltip(self, cr, w, h, mx, my, cw, ch): - point = list(self._history)[self._hover_index] + hover_index = self._hover_index + if hover_index is None: + return + point = list(self._history)[hover_index] num_points = len(self._history) x_step = cw / max(CHART_MAX_HISTORY - 1, 1) start_x = mx + cw - (num_points - 1) * x_step - hover_x = start_x + self._hover_index * x_step + hover_x = start_x + hover_index * x_step cr.set_source_rgba(1, 1, 1, 0.4) cr.set_line_width(1) cr.move_to(hover_x, my) @@ -490,7 +501,7 @@ def _prompt_disconnect(self, session_id, ip): "address? Ending the game keeps the device paired."), ) root = self.get_root() - if root: + if isinstance(root, Gtk.Window): dialog.set_transient_for(root) dialog.add_response("cancel", _("Cancel")) if self.sunshine: @@ -513,11 +524,12 @@ def _close_running_app(self): """Gentle stop via Sunshine API (POST /api/apps/close), no root.""" if not self.sunshine: return + sunshine = self.sunshine self.set_sensitive(False) auth = self._sunshine_auth() def work(): - ok = self.sunshine.close_app(auth=auth) + ok = sunshine.close_app(auth=auth) GLib.idle_add(self._on_disconnect_done, ok) threading.Thread(target=work, daemon=True).start() @@ -762,7 +774,15 @@ def _fetch_and_process_data(self): except Exception: pass - def update_stats(self, latency, fps, bandwidth, sessions=None, device_latencies=None, bw_text=None): + def update_stats( + self, + latency, + fps, + bandwidth, + sessions=None, + device_latencies: dict[str, float] | None = None, + bw_text: str | None = None, + ): try: if not self.update_timer_active: return sessions, device_latencies = sessions or [], device_latencies or {} diff --git a/src/big_remote_play/ui/preferences.py b/src/big_remote_play/ui/preferences.py index e51342e..92c1da6 100644 --- a/src/big_remote_play/ui/preferences.py +++ b/src/big_remote_play/ui/preferences.py @@ -33,9 +33,6 @@ def __init__(self, **kwargs): self.setup_ui() - if self.initial_tab == 'sunshine' and hasattr(self, 'sunshine_page'): - self.set_visible_page(self.sunshine_page) - def setup_ui(self): """Configures interface""" # General page @@ -148,7 +145,7 @@ def setup_ui(self): logs_group.add(clear_logs_row) # Connect signals - verbose_row.set_active(self.config.get('verbose_logging', False)) + verbose_row.set_active(bool(self.config.get('verbose_logging', False))) verbose_row.connect('notify::active', self.on_verbose_toggled) clear_btn.connect('clicked', self.on_clear_logs_clicked) @@ -226,10 +223,6 @@ def on_response(d, r): os.remove(sunshine_conf) print("Sunshine config deleted (reset)") - # Reload UI config manager if active - if hasattr(self, 'sunshine_page') and hasattr(self.sunshine_page, 'config'): - self.sunshine_page.config.load() - # 3. Reset Moonlight Config from big_remote_play.utils.moonlight_config import MoonlightConfigManager mc = MoonlightConfigManager() diff --git a/src/big_remote_play/ui/sunshine_preferences.py b/src/big_remote_play/ui/sunshine_preferences.py index cc49d57..9deaff9 100644 --- a/src/big_remote_play/ui/sunshine_preferences.py +++ b/src/big_remote_play/ui/sunshine_preferences.py @@ -506,6 +506,8 @@ def get_monitors(self): monitor_list = display.get_monitors() for i in range(monitor_list.get_n_items()): monitor = monitor_list.get_item(i) + if monitor is None: + continue name = monitor.get_connector() if name: manufacturer = monitor.get_manufacturer() or "" diff --git a/src/big_remote_play/utils/icons.py b/src/big_remote_play/utils/icons.py index 7eaea50..e14273d 100644 --- a/src/big_remote_play/utils/icons.py +++ b/src/big_remote_play/utils/icons.py @@ -70,7 +70,12 @@ def create_logo_widget(icon_name, size, css_class=None): try: target = max(1, int(size)) * _LOGO_OVERSAMPLE pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(path, target, target) - img.set_from_paintable(Gdk.Texture.new_for_pixbuf(pixbuf)) + if pixbuf is not None: + img.set_from_paintable(Gdk.Texture.new_for_pixbuf(pixbuf)) + else: + gicon = get_gicon(icon_name) + if gicon: + img.set_from_gicon(gicon) except Exception: gicon = get_gicon(icon_name) if gicon: From 799278fdac835f8caa5f4168e632278481eaef26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Gon=C3=A7alves?= Date: Wed, 24 Jun 2026 21:07:28 -0300 Subject: [PATCH 07/47] Tighten guest view GTK typing --- src/big_remote_play/ui/guest_view.py | 49 +++++++++++++++++----------- 1 file changed, 30 insertions(+), 19 deletions(-) diff --git a/src/big_remote_play/ui/guest_view.py b/src/big_remote_play/ui/guest_view.py index aaf95db..74b17bc 100644 --- a/src/big_remote_play/ui/guest_view.py +++ b/src/big_remote_play/ui/guest_view.py @@ -1,4 +1,4 @@ - +from __future__ import annotations import gi gi.require_version('Gtk', '4.0') @@ -34,6 +34,10 @@ def __init__(self): self.setup_ui() self.discover_hosts() GLib.timeout_add(1000, self.monitor_connection) + + def _root_window(self): + root = self.get_root() + return root if isinstance(root, Gtk.Window) else None def detect_bitrate(self, button=None): self.show_toast(_("Detecting bandwidth...")) @@ -227,9 +231,10 @@ def update_btn(btn, label_widget, spinner, default_text, default_sensitive=True) # Update Discover Button # Logic specific for discover: only sensitive if host selected (when disconnected) - has_host = self.selected_host_card_data is not None + selected_host = self.selected_host_card_data + has_host = selected_host is not None update_btn('main_connect_btn', 'connect_btn_label', 'connect_btn_spinner', - _("Connect to {}").format(self.selected_host_card_data['name']) if has_host else _("Connect to Selected"), + _("Connect to {}").format(selected_host['name']) if selected_host is not None else _("Connect to Selected"), default_sensitive=has_host) # Update Manual Button @@ -397,7 +402,9 @@ def on_toggled(btn): copy_btn.set_child(create_icon_widget("edit-copy-symbolic", size=16)) copy_btn.add_css_class("flat"); copy_btn.set_valign(Gtk.Align.CENTER); copy_btn.set_tooltip_text(_("Copy IP")) def copy_ip(btn): - Gdk.Display.get_default().get_clipboard().set(host['ip']) + display = Gdk.Display.get_default() + if display is not None: + display.get_clipboard().set(host['ip']) self.show_toast(_("IP Copied: {}").format(host['ip'])) copy_btn.connect("clicked", copy_ip) box.append(copy_btn) @@ -746,10 +753,12 @@ def close_pairing_dialog(self): def get_auto_resolution(self): try: - display = Gdk.Display.get_default(); monitor = None - if root := self.get_root(): - if native := root.get_native(): - if surface := native.get_surface(): monitor = display.get_monitor_at_surface(surface) + display = Gdk.Display.get_default() + if display is None: + return "1920x1080" + monitor = None + if native := self.get_native(): + if surface := native.get_surface(): monitor = display.get_monitor_at_surface(surface) if not monitor: monitors = display.get_monitors() if monitors.get_n_items() > 0: monitor = monitors.get_item(0) @@ -796,7 +805,7 @@ def _on_pin_failed(self): self.show_error_dialog(_("Host Not Found"), _("Could not find a host with this PIN on the local network...")) def show_error_dialog(self, title, message): - dialog = Adw.MessageDialog.new(self.get_root()) + dialog = Adw.MessageDialog.new(self._root_window()) dialog.set_heading(title); dialog.set_body(message) dialog.add_response('ok', _('OK')); dialog.present() @@ -825,7 +834,7 @@ def on_scale_changed(self, row, param): def show_custom_input_dialog(self, title, subtitle, callback): dialog = Adw.MessageDialog(heading=title, body=subtitle) - dialog.set_transient_for(self.get_root()) + dialog.set_transient_for(self._root_window()) grp = Adw.PreferencesGroup() entry = Adw.EntryRow(title=_("Value")) @@ -897,6 +906,8 @@ def save_guest_settings(self): # 2. Save Local Settings (Not in Moonlight.conf or specific to GuestView) s = self.config.get('guest', {}) + if not isinstance(s, dict): + s = {} s['scale_native'] = self.scale_row.get_active() s['audio'] = self.audio_row.get_active() self.config.set('guest', s) @@ -949,12 +960,14 @@ def load_guest_settings(self): # 2. Load Local Settings try: s = self.config.get('guest', {}) - self.scale_row.set_active(s.get('scale_native', False)) - self.audio_row.set_active(s.get('audio', True)) + if not isinstance(s, dict): + s = {} + self.scale_row.set_active(bool(s.get('scale_native', False))) + self.audio_row.set_active(bool(s.get('audio', True))) except Exception: pass def on_reset_clicked(self, _widget): dialog = Adw.MessageDialog(heading=_("Reset defaults?"), body=_("All client settings will be restored.")) - dialog.set_transient_for(self.get_root()) + dialog.set_transient_for(self._root_window()) dialog.add_response("cancel", _("No")) dialog.add_response("ok", _("Yes")) dialog.set_response_appearance("ok", Adw.ResponseAppearance.DESTRUCTIVE) @@ -973,17 +986,15 @@ def reset_to_defaults(self): self.scale_row.set_active(False); self.resolution_row.set_selected(1); self.fps_row.set_selected(1); self.bitrate_scale.set_value(20.0); self.display_mode_row.set_selected(0); self.audio_row.set_active(True); self.hw_decode_row.set_active(True) self.custom_resolution_val = self.custom_fps_val = ''; self.show_toast(_("Restored")); self.save_guest_settings() def show_toast(self, m): - w = self.get_root() - if hasattr(w, 'show_toast'): w.show_toast(m) + show_toast = getattr(self.get_root(), 'show_toast', None) + if callable(show_toast): show_toast(m) else: print(f"Toast: {m}") def open_advanced_client_settings(self, _widget=None): """Full Moonlight client tuning, opened from the Connect task itself.""" from big_remote_play.ui.moonlight_preferences import MoonlightPreferencesPage win = Adw.PreferencesWindow() - root = self.get_root() - if root: - win.set_transient_for(root) + win.set_transient_for(self._root_window()) win.set_modal(True) win.set_title(_('Advanced client settings')) win.add(MoonlightPreferencesPage()) @@ -992,7 +1003,7 @@ def open_advanced_client_settings(self, _widget=None): win.present() def show_shortcuts_dialog(self): - dialog = Adw.Window(transient_for=self.get_root()) + dialog = Adw.Window(transient_for=self._root_window()) dialog.set_modal(True) dialog.set_title(_("Shortcuts & Instructions")) dialog.set_default_size(500, 600) From acad43fc08a8e188d9b1f7a429258ea045bc7e22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Gon=C3=A7alves?= Date: Wed, 24 Jun 2026 21:09:27 -0300 Subject: [PATCH 08/47] Tighten private network async typing --- .../ui/private_network_view.py | 100 ++++++++++++------ 1 file changed, 65 insertions(+), 35 deletions(-) diff --git a/src/big_remote_play/ui/private_network_view.py b/src/big_remote_play/ui/private_network_view.py index 447c71f..16071ed 100644 --- a/src/big_remote_play/ui/private_network_view.py +++ b/src/big_remote_play/ui/private_network_view.py @@ -520,16 +520,21 @@ def run(): # English msgid; data is parsed from locale-independent BRP_* markers. # `env` is passed through bigsudo so it survives privilege elevation. proc = subprocess.Popen(["bigsudo", "env", "LC_ALL=C", "LANGUAGE=", script], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1) - for s in inputs: - try: - proc.stdin.write(s) - proc.stdin.flush() - except Exception: - break + proc_stdin = proc.stdin + if proc_stdin is not None: + for s in inputs: + try: + proc_stdin.write(s) + proc_stdin.flush() + except Exception: + break captured = {} phase = 0.1 - for line in proc.stdout: + proc_stdout = proc.stdout + if proc_stdout is None: + return + for line in proc_stdout: kind = parse_script_line(line) if kind[0] == "data": if kind[2]: @@ -799,7 +804,9 @@ def _show_headscale_instructions(self): def copy_ip(b): txt = self.instructions_ip_label.get_label() - Gdk.Display.get_default().get_clipboard().set(txt) + display = Gdk.Display.get_default() + if display is not None: + display.get_clipboard().set(txt) self.main_window.show_toast(_("Copied!")) btn_copy_ip = Gtk.Button() @@ -997,9 +1004,15 @@ def _on_logout(self, btn): def do_logout(): subprocess.run(["bigsudo", "tailscale", "logout"], timeout=30) - GLib.idle_add( - lambda: (self.main_window.show_toast(_("Tailscale disconnected")), self._btn_logout.set_visible(False), self._networks_group.set_visible(False), self._refresh_networks()) - ) + + def finish_logout(): + self.main_window.show_toast(_("Tailscale disconnected")) + self._btn_logout.set_visible(False) + self._networks_group.set_visible(False) + self._refresh_networks() + return False + + GLib.idle_add(finish_logout) threading.Thread(target=do_logout, daemon=True).start() elif self.vpn_id == "zerotier": @@ -1018,9 +1031,15 @@ def do_stop(): def do_logout(): subprocess.run(["bigsudo", "tailscale", "logout"], timeout=30) - GLib.idle_add( - lambda: (self.main_window.show_toast(_("Disconnected from Headscale")), self._btn_logout.set_visible(False), self._networks_group.set_visible(False), self._refresh_networks()) - ) + + def finish_logout(): + self.main_window.show_toast(_("Disconnected from Headscale")) + self._btn_logout.set_visible(False) + self._networks_group.set_visible(False) + self._refresh_networks() + return False + + GLib.idle_add(finish_logout) threading.Thread(target=do_logout, daemon=True).start() @@ -1160,9 +1179,10 @@ def on_save_finish(source, result): file_handle = dialog_file.save_finish(result) if file_handle: path = file_handle.get_path() - with open(path, "w") as f: - f.write(msg) - self.main_window.show_toast(_("Saved to file!")) + if path: + with open(path, "w") as f: + f.write(msg) + self.main_window.show_toast(_("Saved to file!")) except Exception as e: print(f"Error saving: {e}") @@ -1186,16 +1206,18 @@ def on_share(b): btn_save.connect("clicked", lambda b: self.main_window.show_toast(_("Saved to history!"))) btn_file = Gtk.Button() - btn_file.set_child(Gtk.Box(spacing=8)) - btn_file.get_child().append(create_icon_widget("folder-open-symbolic", size=16)) - btn_file.get_child().append(Gtk.Label(label=_("Save to File"))) + btn_file_box = Gtk.Box(spacing=8) + btn_file_box.append(create_icon_widget("folder-open-symbolic", size=16)) + btn_file_box.append(Gtk.Label(label=_("Save to File"))) + btn_file.set_child(btn_file_box) btn_file.add_css_class("pill") btn_file.connect("clicked", on_save_file) btn_share = Gtk.Button() - btn_share.set_child(Gtk.Box(spacing=8)) - btn_share.get_child().append(create_icon_widget("open-menu-symbolic", size=16)) - btn_share.get_child().append(Gtk.Label(label=_("Share"))) + btn_share_box = Gtk.Box(spacing=8) + btn_share_box.append(create_icon_widget("open-menu-symbolic", size=16)) + btn_share_box.append(Gtk.Label(label=_("Share"))) + btn_share.set_child(btn_share_box) btn_share.add_css_class("pill") btn_share.connect("clicked", on_share) @@ -1215,8 +1237,9 @@ def on_share(b): dialog.present() def _copy(self, text): - clipboard = Gdk.Display.get_default().get_clipboard() - clipboard.set(text) + display = Gdk.Display.get_default() + if display is not None: + display.get_clipboard().set(text) self.main_window.show_toast(_("Copied!")) @@ -1566,14 +1589,19 @@ def run(): pass # LC_ALL=C: parse locale-independent BRP_* markers, not localized prose. proc = subprocess.Popen(["bigsudo", "env", "LC_ALL=C", "LANGUAGE=", spath], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1) - for s in inputs: - try: - proc.stdin.write(s) - proc.stdin.flush() - except Exception: - break + proc_stdin = proc.stdin + if proc_stdin is not None: + for s in inputs: + try: + proc_stdin.write(s) + proc_stdin.flush() + except Exception: + break phase = 0.1 - for line in proc.stdout: + proc_stdout = proc.stdout + if proc_stdout is None: + return + for line in proc_stdout: kind = parse_script_line(line) if kind[0] == "data": continue @@ -1908,8 +1936,8 @@ def _fill_form(): def _edit_history_entry(self, entry): """Open a dialog to edit the fields of a history entry.""" - vpn_id = entry.get("vpn", "headscale") - vpn_name = VPN_META.get(vpn_id, {}).get("name", vpn_id) + vpn_id = str(entry.get("vpn") or "headscale") + vpn_name = str(VPN_META.get(vpn_id, {}).get("name") or vpn_id) dialog = Adw.Window(transient_for=self.main_window) dialog.set_modal(True) @@ -2192,7 +2220,9 @@ def _show_simple_instructions(self, title_text, items): dialog.present() def _copy(self, text): - Gdk.Display.get_default().get_clipboard().set(text) + display = Gdk.Display.get_default() + if display is not None: + display.get_clipboard().set(text) self.main_window.show_toast(_("Copied!")) From f01d3338d7940b602fc91681b8c6bd3ed923d6f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Gon=C3=A7alves?= Date: Wed, 24 Jun 2026 21:12:40 -0300 Subject: [PATCH 09/47] Tighten host view optional state handling --- src/big_remote_play/ui/host_view.py | 96 +++++++++++++++++++---------- 1 file changed, 65 insertions(+), 31 deletions(-) diff --git a/src/big_remote_play/ui/host_view.py b/src/big_remote_play/ui/host_view.py index 7196127..c1b827f 100644 --- a/src/big_remote_play/ui/host_view.py +++ b/src/big_remote_play/ui/host_view.py @@ -25,6 +25,11 @@ def __init__(self): self.process = None # Initialize to avoid AttributeError self.pin_code = None self.private_audio_apps = set() + self.audio_devices = [] + self.active_host_sink = "" + self.stop_pin_listener = None + self._uptime_timer_id = None + self._hosting_started_at = None from big_remote_play.host.sunshine_manager import SunshineHost self.sunshine = SunshineHost(Path.home() / '.config' / 'big-remoteplay' / 'sunshine') @@ -47,6 +52,10 @@ def __init__(self): self._ensure_sunshine_config() self.sync_ui_state() + + def _root_window(self): + root = self.get_root() + return root if isinstance(root, Gtk.Window) else None def detect_monitors(self): monitors = [(_('Automatic'), 'auto')] @@ -60,6 +69,8 @@ def detect_monitors(self): monitor_list = display.get_monitors() for i in range(monitor_list.get_n_items()): monitor = monitor_list.get_item(i) + if monitor is None: + continue conn = monitor.get_connector() if conn: manufacturer = monitor.get_manufacturer() or "" @@ -813,7 +824,7 @@ def open_pin_dialog(self, _widget): heading=_("Insert PIN"), body=_("Enter the PIN displayed on the client device (Moonlight).") ) - dialog.set_transient_for(self.get_root()) + dialog.set_transient_for(self._root_window()) # Preferences group holding the fields grp = Adw.PreferencesGroup() @@ -881,7 +892,7 @@ def prompt_create_user(self, pin_retry): heading=_("User Not Found"), body=_("No user has been created in Sunshine. It is necessary to configure a user through the browser.") ) - dialog.set_transient_for(self.get_root()) + dialog.set_transient_for(self._root_window()) dialog.add_response("cancel", _("Cancel")) dialog.add_response("open", _("Open Configuration")) dialog.set_response_appearance("open", Adw.ResponseAppearance.SUGGESTED) @@ -899,7 +910,7 @@ def open_create_user_dialog(self, pin_retry): heading=_("Create Sunshine User"), body=_("Define a username and password for Sunshine.") ) - dialog.set_transient_for(self.get_root()) + dialog.set_transient_for(self._root_window()) grp = Adw.PreferencesGroup() user_row = Adw.EntryRow(title=_("New User")) @@ -934,7 +945,7 @@ def on_create(d, r): dialog.connect("response", on_create) dialog.present() - def open_sunshine_auth_dialog(self, pin_to_retry: str, device_name: str = None): + def open_sunshine_auth_dialog(self, pin_to_retry: str, device_name: str | None = None): # Prefill with existing if available (for correction) creds = self._get_sunshine_creds() curr_user = creds[0] if creds else "admin" @@ -943,7 +954,7 @@ def open_sunshine_auth_dialog(self, pin_to_retry: str, device_name: str = None): heading=_("Sunshine Authentication"), body=_("Sunshine requires login. Enter your credentials (default: admin / password created during installation).") ) - dialog.set_transient_for(self.get_root()) + dialog.set_transient_for(self._root_window()) grp = Adw.PreferencesGroup() user_row = Adw.EntryRow(title=_("Username")) @@ -1015,6 +1026,8 @@ def load_audio_outputs(self): self.audio_output_row.set_model(model) # Try to keep selection if possible, or use config h = self.config.get('host', {}) + if not isinstance(h, dict): + h = {} self.audio_output_row.set_selected(h.get('audio_output_idx', 0)) except Exception as e: @@ -1031,6 +1044,9 @@ def on_audio_output_changed(self, row, param): new_sink = self.audio_devices[idx]['name'] else: new_sink = self.audio_manager.get_default_sink() + if not new_sink: + self.save_host_settings() + return # If hosting and audio active, need to reconfigure loopback if self.is_hosting and self.audio_mode_row.get_selected() in [0, 1, 3]: @@ -1108,7 +1124,10 @@ def toggle_field_visibility(self, key): def copy_field_value(self, key): if val := self.field_widgets[key]['real_value']: - self.get_root().get_clipboard().set(val); self.show_toast(_("Copied!")) + display = Gdk.Display.get_default() + if display is not None: + display.get_clipboard().set(val) + self.show_toast(_("Copied!")) def toggle_hosting(self, button): self.show_toast(_("Clicked Start Server...")) @@ -1178,8 +1197,9 @@ def sync_ui_state(self): self.host_status_subtitle.set_label(_('Start the server to stream.')) self._hosting_started_at = None - if getattr(self, '_uptime_timer_id', None) is not None: - GLib.source_remove(self._uptime_timer_id) + uptime_timer_id = self._uptime_timer_id + if uptime_timer_id is not None: + GLib.source_remove(uptime_timer_id) self._uptime_timer_id = None if hasattr(self, 'host_status_label'): self.host_status_label.set_label(_('Sunshine offline')) @@ -1465,7 +1485,7 @@ def start_hosting(self, b=None): host_sink = self.audio_manager.get_default_sink() # PROTECT AGAINST SELF-LOOP IF DEFAULT SINK IS STILL VIRTUAL - if host_sink == "SunshineGameSink" or self.audio_manager.is_virtual(host_sink): + if host_sink and (host_sink == "SunshineGameSink" or self.audio_manager.is_virtual(host_sink)): print(f"WARNING: Host sink '{host_sink}' is virtual. finding fallback hardware sink.") hw_sinks = self.audio_manager.get_passive_sinks() if hw_sinks: @@ -1473,11 +1493,15 @@ def start_hosting(self, b=None): # usually get_passive_sinks returns dict with 'name' (id) and description # wait, check utils/audio.py get_passive_sinks returns dict with 'name' -> PA Name - # Store it for the enforcer - self.active_host_sink = host_sink + if not host_sink: + print("WARNING: No hardware audio sink found, disabling audio streaming.") + sunshine_config['audio'] = 'none' + else: + # Store it for the enforcer + self.active_host_sink = host_sink # Enable Host+Guest Streaming (Create Sink) - if self.audio_manager: + if host_sink and self.audio_manager: # Determine loopback based on Mode mode_idx = self.audio_mode_row.get_selected() # If mode is Guest (1), guest_only is True @@ -1753,10 +1777,11 @@ def update_status_info(self): if not self.is_hosting: return True - if hasattr(self, 'sunshine_val'): + sunshine_val = getattr(self, 'sunshine_val', None) + if sunshine_val is not None: status_text = _("Online") if sunshine_running else _("Stopped") color = "#2ec27e" if sunshine_running else "#e01b24" - self.sunshine_val.set_markup(f'{status_text}') + sunshine_val.set_markup(f'{status_text}') ipv4, ipv6 = self.get_ip_addresses() self.update_field('ipv4', ipv4); self.update_field('ipv6', ipv6) return True @@ -1807,7 +1832,7 @@ def show_start_error_dialog(self, message): # Use simple MessageDialog constructor for custom response handling if needed, # or simplified new() if we connect signal later. dialog = Adw.MessageDialog(heading=_("Server Failed to Start"), body=body) - dialog.set_transient_for(self.get_root()) + dialog.set_transient_for(self._root_window()) dialog.add_response("cancel", _("Close")) dialog.add_response("logs", _("View Logs")) dialog.add_response("fix", _("Fix Dependencies")) @@ -1827,13 +1852,13 @@ def on_response(d, r): dialog.present() def show_error_dialog(self, title, message): - dialog = Adw.MessageDialog.new(self.get_root(), title, message) + dialog = Adw.MessageDialog.new(self._root_window(), title, message) dialog.add_response('ok', 'OK') dialog.present() def show_toast(self, message): - window = self.get_root() - if hasattr(window, 'show_toast'): window.show_toast(message) + show_toast = getattr(self.get_root(), 'show_toast', None) + if callable(show_toast): show_toast(message) else: print(f"Toast: {message}") def open_sunshine_config(self, button): @@ -1848,7 +1873,7 @@ def open_paired_devices_dialog(self, _widget): body=_("Devices paired with Sunshine. Disable to block access without " "re-pairing; remove to revoke (a new PIN will be required)."), ) - dialog.set_transient_for(self.get_root()) + dialog.set_transient_for(self._root_window()) dialog.add_response("close", _("Close")) dialog.add_response("unpair_all", _("Remove All")) dialog.set_response_appearance("unpair_all", Adw.ResponseAppearance.DESTRUCTIVE) @@ -1942,7 +1967,7 @@ def _on_device_remove(self, _button, uuid, name): heading=_("Remove Device"), body=_("Remove “{}”? It will need to pair again with a new PIN.").format(name), ) - confirm.set_transient_for(self.get_root()) + confirm.set_transient_for(self._root_window()) confirm.add_response("cancel", _("Cancel")) confirm.add_response("remove", _("Remove")) confirm.set_response_appearance("remove", Adw.ResponseAppearance.DESTRUCTIVE) @@ -1981,7 +2006,7 @@ def _after_device_change(self, ok): def open_logs_dialog(self, _widget): dialog = Adw.MessageDialog(heading=_("Sunshine Logs"), body="") - dialog.set_transient_for(self.get_root()) + dialog.set_transient_for(self._root_window()) dialog.add_response("close", _("Close")) box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) @@ -2051,7 +2076,9 @@ def _copy_logs(self): return buf = tv.get_buffer() text = buf.get_text(buf.get_start_iter(), buf.get_end_iter(), False) - self.get_root().get_clipboard().set(text) + display = Gdk.Display.get_default() + if display is not None: + display.get_clipboard().set(text) self.show_toast(_("Copied!")) # --- Game library (Sunshine apps API) -------------------------------- @@ -2068,7 +2095,7 @@ def open_game_library_dialog(self, _widget): body=_("Games offered to guests in Moonlight. Add your detected games " "or remove entries."), ) - dialog.set_transient_for(self.get_root()) + dialog.set_transient_for(self._root_window()) dialog.add_response("close", _("Close")) box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) @@ -2202,7 +2229,7 @@ def open_host_browse_dialog(self): return dialog = Adw.MessageDialog(heading=_("Select Executable"), body="") - dialog.set_transient_for(self.get_root()) + dialog.set_transient_for(self._root_window()) dialog.add_response("close", _("Close")) self._browse_dialog = dialog @@ -2285,9 +2312,7 @@ def open_advanced_settings(self, _widget=None): """Full Sunshine server tuning + library fix, opened from the task itself.""" from big_remote_play.ui.sunshine_preferences import SunshinePreferencesPage win = Adw.PreferencesWindow() - root = self.get_root() - if root: - win.set_transient_for(root) + win.set_transient_for(self._root_window()) win.set_modal(True) win.set_title(_("Advanced server settings")) win.add(SunshinePreferencesPage(main_config=self.config)) @@ -2300,7 +2325,7 @@ def open_password_dialog(self, _widget): "server. If you forgot the current password, switch on " "“I forgot the current password” to reset it."), ) - dialog.set_transient_for(self.get_root()) + dialog.set_transient_for(self._root_window()) creds = self._get_sunshine_creds() default_user = creds[0] if creds else "sunshine" @@ -2403,6 +2428,8 @@ def populate_game_list(self, mode_idx): def save_host_settings(self, *args): if getattr(self, 'loading_settings', False): return h = self.config.get('host', {}) + if not isinstance(h, dict): + h = {} h.update({ 'mode_idx': self.game_mode_row.get_selected(), 'game_list_idx': self.game_list_row.get_selected(), @@ -2497,6 +2524,8 @@ def load_settings(self): # Update Host Config based on Sunshine Config (Source of Truth for these fields) h = self.config.get('host', {}) + if not isinstance(h, dict): + h = {} h['upnp'] = scm.get('upnp', 'enabled') == 'enabled' h['ipv6'] = scm.get('address_family', 'both') == 'both' h['webui_anyone'] = scm.get('origin_web_ui_allowed', 'lan') == 'wan' @@ -2530,6 +2559,8 @@ def load_settings(self): h = self.config.get('host', {}) + if not isinstance(h, dict): + h = {} if not h: return self.game_mode_row.set_selected(h.get('mode_idx', 0)) # Restore game list selection after populating @@ -2607,9 +2638,12 @@ def reset_to_defaults(self): def cleanup(self): if hasattr(self, 'perf_monitor'): self.perf_monitor.stop_monitoring() - if hasattr(self, 'stop_pin_listener'): self.stop_pin_listener() - if getattr(self, '_uptime_timer_id', None) is not None: - GLib.source_remove(self._uptime_timer_id) + stop_pin_listener = self.stop_pin_listener + if callable(stop_pin_listener): + stop_pin_listener() + uptime_timer_id = self._uptime_timer_id + if uptime_timer_id is not None: + GLib.source_remove(uptime_timer_id) self._uptime_timer_id = None # Only cleanup audio if we are NOT hosting, because Sunshine depends on these sinks. From 0eccb997f4c72ef0b6a43ef30c6e3242f26e4e42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Gon=C3=A7alves?= Date: Wed, 24 Jun 2026 22:38:06 -0300 Subject: [PATCH 10/47] Fix AT-SPI navigation controls --- src/big_remote_play/ui/main_window.py | 68 ++++++++++++++++++++++++--- 1 file changed, 62 insertions(+), 6 deletions(-) diff --git a/src/big_remote_play/ui/main_window.py b/src/big_remote_play/ui/main_window.py index 1f6612f..bd15459 100644 --- a/src/big_remote_play/ui/main_window.py +++ b/src/big_remote_play/ui/main_window.py @@ -159,6 +159,7 @@ def __init__(self, **kwargs): self._status_dots = {} self._status_labels = {} + self._install_window_actions() self.setup_ui() self.check_system() @@ -178,6 +179,16 @@ def setup_ui(self): self.setup_sidebar(); self.setup_content() self.split_view.set_min_sidebar_width(220); self.split_view.set_max_sidebar_width(280) + def _install_window_actions(self): + nav_action = Gio.SimpleAction.new("navigate", GLib.VariantType.new("s")) + nav_action.connect("activate", self._on_navigate_action) + self.add_action(nav_action) + + def _on_navigate_action(self, _action, parameter): + if parameter is None: + return + self.navigate_to(parameter.get_string()) + def _build_navigation_pages(self): """Build the navigation page list based on current VPN choice.""" pages = dict(BASE_NAVIGATION_PAGES) @@ -285,9 +296,19 @@ def create_nav_row(self, page_id: str, page_info: dict) -> Gtk.ListBoxRow: badge.set_halign(Gtk.Align.END) box.append(badge) - row.set_child(box) - # ListBoxRow has no auto name from its custom child; expose one for AT-SPI. - row.update_property([Gtk.AccessibleProperty.LABEL], [page_info['name']]) + button = Gtk.Button() + button.add_css_class('flat') + button.set_hexpand(True) + button.set_halign(Gtk.Align.FILL) + button.set_action_name("win.navigate") + button.set_action_target_value(GLib.Variant("s", page_id)) + button.set_child(box) + button.update_property( + [Gtk.AccessibleProperty.LABEL, Gtk.AccessibleProperty.DESCRIPTION], + [page_info['name'], page_info.get('description', '')], + ) + + row.set_child(button) return row def create_status_footer(self): @@ -421,9 +442,8 @@ def update_dependency_ui(self, has_sun, has_moon, has_docker, has_tailscale, has card.set_tooltip_text(tooltip) def setup_content(self): - ct = Adw.ToolbarView(); hb = Adw.HeaderBar(); m = Gio.Menu() - m.append(_('Preferences'), 'app.preferences'); m.append(_('About'), 'app.about') - hb.pack_end(Gtk.MenuButton(icon_name='open-menu-symbolic', menu_model=m)) + ct = Adw.ToolbarView(); hb = Adw.HeaderBar() + hb.pack_end(self._create_header_menu_button()) # Dynamic title reflecting the current section (filled in on_nav_selected). self.content_headerbar = hb @@ -452,6 +472,42 @@ def setup_content(self): ct.set_content(self.content_stack) self.split_view.set_content(Adw.NavigationPage.new(ct, 'Big Remote Play')) + def _create_header_menu_button(self): + menu_button = Gtk.MenuButton(icon_name='open-menu-symbolic') + menu_button.update_property([Gtk.AccessibleProperty.LABEL], [_('Application menu')]) + + popover = Gtk.Popover() + menu_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + menu_box.set_margin_top(6) + menu_box.set_margin_bottom(6) + menu_box.set_margin_start(6) + menu_box.set_margin_end(6) + + for label, action_name in ( + (_('Preferences'), 'preferences'), + (_('About'), 'about'), + ): + button = Gtk.Button(label=label) + button.add_css_class('flat') + button.set_hexpand(True) + button.set_halign(Gtk.Align.FILL) + button.connect( + 'clicked', + lambda _button, menu_popover=popover, name=action_name: self._activate_app_menu_action(menu_popover, name), + ) + button.update_property([Gtk.AccessibleProperty.LABEL], [label]) + menu_box.append(button) + + popover.set_child(menu_box) + menu_button.set_popover(popover) + return menu_button + + def _activate_app_menu_action(self, popover, action_name): + popover.popdown() + app = self.get_application() + if app is not None: + app.activate_action(action_name, None) + # ───────────────────────────────────────────────────────────────────────── # VPN SELECTOR PAGE # ───────────────────────────────────────────────────────────────────────── From 0435dadd7e2aaf4cd933a77f633e8f83ee2b0bb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Gon=C3=A7alves?= Date: Wed, 24 Jun 2026 22:46:18 -0300 Subject: [PATCH 11/47] Refresh Portuguese UI translations --- locale/big-remote-play.pot | 9719 +++++++------- locale/pt.po | 10548 ++++++++++------ src/big_remote_play/ui/preferences.py | 18 +- .../LC_MESSAGES/big-remote-play-together.mo | Bin 64221 -> 74787 bytes .../locale/pt/LC_MESSAGES/big-remote-play.mo | Bin 79786 -> 74787 bytes 5 files changed, 11966 insertions(+), 8319 deletions(-) diff --git a/locale/big-remote-play.pot b/locale/big-remote-play.pot index 4afd4ef..befc44f 100644 --- a/locale/big-remote-play.pot +++ b/locale/big-remote-play.pot @@ -1,4592 +1,5363 @@ # SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the big-remote-play package. +# This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # #, fuzzy -msgid "" -msgstr "Project-Id-Version: big-remote-play\n" - "Report-Msgid-Bugs-To: \n" - "Last-Translator: FULL NAME \n" - "Language-Team: LANGUAGE \n" - "Language: \n" - "MIME-Version: 1.0\n" - "Content-Type: text/plain; charset=UTF-8\n" - "Content-Transfer-Encoding: 8bit\n" +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-06-24 22:43-0300\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: src/big_remote_play/app.py:49 +msgid "Could not load icon theme: no GTK display" +msgstr "" + +#: src/big_remote_play/app.py:56 +msgid "Icons and images paths added" +msgstr "" + +#: src/big_remote_play/app.py:66 +msgid "" +"The Story Behind the Project\n" +"\n" +"Big Remote Play was born from a real story of friendship, determination, and " +"the passion for Free Software.\n" +"\n" +"Alessandro e Silva Xavier (known as Alessandro) and Alexasandro Pacheco " +"Feliciano (known as Pacheco) wanted to play games together on BigLinux using " +"a feature that only existed on proprietary platforms like Steam Remote Play " +"and GeForce NOW. The problem? These systems are proprietary, locked to their " +"own ecosystems. If a game wasn't available on their platform, it was nearly " +"impossible to play remotely with friends.\n" +"\n" +"Refusing to accept this limitation, Alessandro and Pacheco embarked on a " +"journey of countless attempts and extensive research. After trying many " +"different approaches, they finally found a working solution by combining " +"multiple free software programs — including Sunshine, Moonlight, scripts, " +"and VPN tools. They had achieved what the proprietary platforms kept locked " +"behind their walls, and the best part: it was Free Software and multi-" +"platform!\n" +"\n" +"Excited by their success, they started sharing their achievement during " +"their live streams, which generated tremendous enthusiasm from the " +"community. However, there was a catch — the setup was complicated. It " +"required configuring multiple separate solutions: Sunshine, Moonlight, " +"custom scripts, VPN connections... it was a lot for anyone to handle.\n" +"\n" +"That's when a friend decided to step in and help develop a unified " +"application to simplify the entire process. And so, Big Remote Play was " +"born! 🎉\n" +"\n" +"An all-in-one application that integrates everything you need for remote " +"cooperative gaming — no proprietary platforms, no restrictions, no limits on " +"which games you can play." +msgstr "" + +#: src/big_remote_play/host/sunshine_manager.py:105 +#, python-brace-format +msgid "Sunshine failed to start (Exit code {}).\n" +msgstr "" + +#: src/big_remote_play/host/sunshine_manager.py:107 +#, python-brace-format +msgid "Sunshine failed to start: {}" +msgstr "" + +#: src/big_remote_play/host/sunshine_manager.py:109 +#, python-brace-format +msgid "Sunshine failed to start (Exit code {}). Check logs." +msgstr "" + +#: src/big_remote_play/host/sunshine_manager.py:124 +#, python-brace-format +msgid "Sunshine started (PID: {})" +msgstr "" + +#: src/big_remote_play/host/sunshine_manager.py:128 +#, python-brace-format +msgid "Error starting Sunshine: {}" +msgstr "" + +#: src/big_remote_play/host/sunshine_manager.py:134 +msgid "Sunshine is not running" +msgstr "" + +#: src/big_remote_play/host/sunshine_manager.py:312 +#, python-brace-format +msgid "Certificate trust error: {}" +msgstr "" + +#: src/big_remote_play/host/sunshine_manager.py:349 +#, python-brace-format +msgid "Sunshine API request failed: {}" +msgstr "" + +#: src/big_remote_play/host/sunshine_manager.py:362 +#: src/big_remote_play/host/sunshine_manager.py:393 +msgid "Connection Error: Sunshine unreachable or certificate mismatch" +msgstr "" + +#: src/big_remote_play/host/sunshine_manager.py:368 +#: src/big_remote_play/ui/host_view.py:878 +#: src/big_remote_play/ui/host_view.py:940 +#: src/big_remote_play/ui/host_view.py:980 +msgid "PIN sent successfully" +msgstr "" + +#: src/big_remote_play/host/sunshine_manager.py:368 +msgid "Sunshine rejected the PIN" +msgstr "" + +#: src/big_remote_play/host/sunshine_manager.py:370 +msgid "Authentication Failed. Configure a user in Sunshine." +msgstr "" + +#: src/big_remote_play/host/sunshine_manager.py:372 +#: src/big_remote_play/host/sunshine_manager.py:403 +#, python-brace-format +msgid "API Error: {}" +msgstr "" + +#: src/big_remote_play/host/sunshine_manager.py:395 +msgid "Authentication Failed. Check the current password." +msgstr "" + +#: src/big_remote_play/host/sunshine_manager.py:399 +msgid "Sunshine rejected the credentials" +msgstr "" + +#: src/big_remote_play/host/sunshine_manager.py:402 +msgid "Credentials updated successfully" +msgstr "" + +#: src/big_remote_play/host/sunshine_manager.py:417 +msgid "Username and password cannot be empty" +msgstr "" + +#: src/big_remote_play/host/sunshine_manager.py:420 +#: src/big_remote_play/utils/system_check.py:138 +msgid "Sunshine executable not found" +msgstr "" + +#: src/big_remote_play/host/sunshine_manager.py:426 +msgid "Credentials reset successfully" +msgstr "" + +#: src/big_remote_play/host/sunshine_manager.py:428 +#, python-brace-format +msgid "Reset failed: {}" +msgstr "" + +#: src/big_remote_play/host/sunshine_manager.py:428 +msgid "Reset failed" +msgstr "" + +#: src/big_remote_play/host/sunshine_manager.py:430 +#, python-brace-format +msgid "Reset error: {}" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:43 +msgid "Detecting bandwidth..." +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:49 +#, python-brace-format +msgid "Suggested bitrate: {} Mbps" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:63 +msgid "Discover" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:66 +msgid "Manual" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:69 +#: src/big_remote_play/ui/guest_view.py:478 +#: src/big_remote_play/ui/host_view.py:549 +#: src/big_remote_play/ui/host_view.py:570 +msgid "PIN Code" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:82 +#: src/big_remote_play/ui/guest_view.py:83 +#: src/big_remote_play/ui/guest_view.py:1008 +msgid "Shortcuts & Instructions" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:94 +msgid "Client Settings" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:95 +#: src/big_remote_play/ui/host_view.py:165 +msgid "Reset to Defaults" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:97 +#: src/big_remote_play/ui/host_view.py:216 +#: src/big_remote_play/ui/moonlight_preferences.py:30 +msgid "Resolution" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:97 +#: src/big_remote_play/ui/host_view.py:217 +msgid "Stream resolution" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:99 +#: src/big_remote_play/ui/guest_view.py:110 +#: src/big_remote_play/ui/guest_view.py:814 +#: src/big_remote_play/ui/guest_view.py:825 +#: src/big_remote_play/ui/host_view.py:219 +#: src/big_remote_play/ui/host_view.py:230 +msgid "Custom" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:103 +msgid "Native Resolution (Adaptive)" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:103 +msgid "Use screen/window resolution" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:108 +#: src/big_remote_play/ui/host_view.py:227 +#: src/big_remote_play/ui/moonlight_preferences.py:42 +msgid "Frame Rate (FPS)" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:108 +msgid "Video smoothness" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:121 +msgid "Apply and Reconnect" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:127 +msgid "Bitrate (Quality)" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:127 +msgid "Adjust bandwidth (0.5 - 150 Mbps)" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:131 +msgid "Detect" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:135 +#: src/big_remote_play/ui/moonlight_preferences.py:66 +msgid "Display Mode" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:135 +msgid "How the window will be displayed" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:137 +#: src/big_remote_play/ui/moonlight_preferences.py:68 +msgid "Borderless Window" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:137 +#: src/big_remote_play/ui/moonlight_preferences.py:68 +msgid "Fullscreen" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:137 +#: src/big_remote_play/ui/moonlight_preferences.py:68 +msgid "Windowed" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:140 +#: src/big_remote_play/ui/host_view.py:310 +#: src/big_remote_play/ui/host_view.py:540 +msgid "Audio" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:140 +msgid "Receive audio streaming" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:142 +msgid "Hardware Decoding" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:142 +msgid "Use GPU for decoding" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:145 +#: src/big_remote_play/ui/guest_view.py:999 +msgid "Advanced client settings" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:146 +msgid "Input, controller, codec and more" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:169 +msgid "Active Session" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:178 +#: src/big_remote_play/ui/performance_monitor.py:339 +msgid "Disconnected" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:182 +msgid "Moonlight closed" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:214 +#: src/big_remote_play/ui/guest_view.py:222 +#: src/big_remote_play/ui/main_window.py:1083 +msgid "Stop" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:237 +#, python-brace-format +msgid "Connect to {}" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:237 +#: src/big_remote_play/ui/guest_view.py:295 +msgid "Connect to Selected" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:241 +#: src/big_remote_play/ui/guest_view.py:457 +#: src/big_remote_play/ui/private_network_view.py:1273 +#: src/big_remote_play/ui/private_network_view.py:1339 +msgid "Connect" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:244 +#: src/big_remote_play/ui/guest_view.py:474 +#: src/big_remote_play/ui/guest_view.py:498 +msgid "Connect with PIN" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:248 +msgid "Applying settings..." +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:272 +msgid "Discovered Hosts" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:273 +msgid "Scroll to list all found devices." +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:316 +msgid "If you can't find the host" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:319 +msgid "Check your local network" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:320 +msgid "" +"Make sure the host and this device are on the same Wi-Fi or wired network." +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:321 +msgid "Use the manual connection" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:322 +msgid "Enter the host IP address or hostname and port to connect directly." +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:323 +msgid "Use the PIN code" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:324 +msgid "Ask the host for the PIN code and connect quickly and securely." +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:345 +msgid "Searching for hosts..." +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:357 +msgid "No hosts found" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:403 +#: src/big_remote_play/ui/private_network_view.py:816 +msgid "Copy IP" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:408 +#, python-brace-format +msgid "IP Copied: {}" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:421 +msgid "Connection Data" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:422 +msgid "Enter server IP and port" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:425 +msgid "IP/Hostname" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:429 +msgid "Port" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:433 +msgid "Use IPv6" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:475 +msgid "Enter the 6-digit PIN code provided by the host" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:511 +msgid "Clicked Connect..." +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:597 +#: src/big_remote_play/ui/guest_view.py:645 +msgid "Active Stream" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:605 +#: src/big_remote_play/ui/guest_view.py:653 +#: src/big_remote_play/ui/host_view.py:1070 +#: src/big_remote_play/ui/host_view.py:1570 +#: src/big_remote_play/ui/private_network_view.py:831 +msgid "Error" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:605 +msgid "Failed to connect. Verify if Moonlight is paired." +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:653 +msgid "Failed to connect" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:676 +#, python-brace-format +msgid "Attempting automatic pairing PIN: {}" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:679 +msgid "Automatic pairing sent!" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:690 +msgid "Starting pairing..." +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:705 +msgid "Paired successfully!" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:711 +msgid "Pairing Error" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:711 +msgid "" +"Could not pair with host.\n" +"Verify the PIN was entered correctly." +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:721 +msgid "Canceling connection..." +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:729 +msgid "Pairing Required" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:729 +#, python-brace-format +msgid "" +"Moonlight needs to be paired.\n" +"\n" +"{}" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:730 +#: src/big_remote_play/ui/guest_view.py:844 +#: src/big_remote_play/ui/host_view.py:855 +#: src/big_remote_play/ui/host_view.py:896 +#: src/big_remote_play/ui/host_view.py:923 +#: src/big_remote_play/ui/host_view.py:967 +#: src/big_remote_play/ui/host_view.py:1971 +#: src/big_remote_play/ui/host_view.py:2356 +#: src/big_remote_play/ui/host_view.py:2629 +#: src/big_remote_play/ui/installer_window.py:74 +#: src/big_remote_play/ui/main_window.py:967 +#: src/big_remote_play/ui/performance_monitor.py:506 +#: src/big_remote_play/ui/preferences.py:203 +#: src/big_remote_play/ui/preferences.py:246 +#: src/big_remote_play/ui/preferences.py:255 +#: src/big_remote_play/ui/private_network_view.py:2009 +#: src/big_remote_play/ui/private_network_view.py:2039 +msgid "Cancel" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:736 +#: src/big_remote_play/ui/guest_view.py:740 +#, python-brace-format +msgid "" +"Follow instructions.\n" +"\n" +"1. Provide PIN and Host {} to the server.\n" +"2. On the host, access Sunshine Configuration.\n" +"3. Enter PIN and Host.\n" +"4. Click Send." +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:742 +msgid "Pairing Started" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:744 +#: src/big_remote_play/ui/guest_view.py:810 +#: src/big_remote_play/ui/preferences.py:186 +#: src/big_remote_play/ui/preferences.py:300 +msgid "OK" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:779 +msgid "Invalid PIN" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:779 +msgid "Must contain 6 digits." +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:800 +#, python-brace-format +msgid "Host found: {}" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:805 +msgid "Host Not Found" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:805 +msgid "Could not find a host with this PIN on the local network..." +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:817 +#: src/big_remote_play/ui/guest_view.py:827 +#, python-brace-format +msgid "Set: {}" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:819 +msgid "Custom Resolution" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:819 +msgid "Enter WxH:" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:828 +msgid "Custom FPS" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:828 +msgid "Use a number" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:832 +#: src/big_remote_play/ui/host_view.py:61 +#: src/big_remote_play/ui/host_view.py:275 +#: src/big_remote_play/ui/host_view.py:328 +#: src/big_remote_play/ui/preferences.py:53 +#: src/big_remote_play/ui/sunshine_preferences.py:485 +msgid "Automatic" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:840 +msgid "Value" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:845 +#: src/big_remote_play/ui/sunshine_preferences.py:319 +msgid "Apply" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:969 +msgid "Reset defaults?" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:969 +msgid "All client settings will be restored." +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:971 +msgid "No" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:972 +msgid "Yes" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:981 +#: src/big_remote_play/ui/guest_view.py:987 +msgid "Restored" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:1029 +msgid "Keyboard Shortcuts" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:1030 +msgid "Common shortcuts used during streaming" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:1033 +msgid "Quit Stream" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:1034 +msgid "Toggle Mouse Capture" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:1035 +msgid "Toggle Stats Overlay" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:1036 +msgid "Toggle Mouse Mode (Remote/Absolute)" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:1037 +msgid "Switch Monitor (Multi-Head Host)" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:1038 +msgid "Toggle Fullscreen" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:1053 +msgid "Multi-Monitor Support" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:1054 +msgid "Instructions for hosts with multiple displays" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:1058 +msgid "Switching Monitors" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:1060 +msgid "" +"If the Host PC has multiple monitors, you can switch between them easily.\n" +"\n" +"1. Use Ctrl+Alt+Shift + F1 (Screen 1), F2 (Screen 2), etc.\n" +"2. If this shortcut doesn't work, ensure 'Grab Input' is active " +"(Ctrl+Alt+Shift + Z).\n" +"3. Why did F1/F2 stop working? The system likely updated and now requires " +"the full shortcut combo to avoid conflicts." +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:1068 +msgid "Troubleshooting: Shortcuts Not Working?" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:1070 +msgid "" +"If shortcuts like F1/F2/F3 are not switching screens:\n" +"• Press Ctrl+Alt+Shift + Z to toggle Mouse/Keyboard Capture.\n" +"• When capture is ON, your F-keys are sent to the remote PC.\n" +"• When capture is OFF, your local PC intercepts them." +msgstr "" + +#: src/big_remote_play/ui/host_view.py:156 +msgid "Sunshine Offline" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:160 +msgid "Game Configuration" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:169 +msgid "Game Mode" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:169 +msgid "Select game source" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:171 +msgid "Full Desktop" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:171 +msgid "Custom App" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:175 +msgid "Game Selection" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:176 +#: src/big_remote_play/ui/host_view.py:181 +msgid "Choose game from list" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:180 +msgid "Select Game" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:188 +msgid "Application Details" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:189 +msgid "Configure name and command" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:193 +msgid "Application Name" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:197 +msgid "Command" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:202 +msgid "Browse host for executable" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:203 +msgid "Browse host filesystem" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:210 +msgid "Streaming Settings" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:211 +msgid "Quality and Players" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:228 +msgid "Frames per second" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:238 +msgid "Bandwidth Limit (Mbps)" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:239 +msgid "Max bitrate (0 = Unlimited)" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:249 +msgid "Hardware and Capture" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:250 +msgid "Monitor, GPU, and Capture Method" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:254 +msgid "Monitor / Display" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:255 +msgid "Select the display to capture" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:263 +msgid "Graphics Card / Encoder" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:264 +msgid "Choose hardware for video encoding" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:272 +msgid "Capture Method" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:273 +msgid "Wayland (recommended), X11 (legacy), or KMS (direct)" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:275 +msgid "KMS (Direct)" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:284 +msgid "Efficient Codecs (HEVC/AV1)" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:285 +msgid "Enable H.265/AV1 for better quality at lower bitrate (Requires support)" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:290 +msgid "Optimization Mode" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:291 +msgid "Balance between responsiveness and image quality" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:293 +msgid "Low Latency (Fastest)" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:294 +msgid "Balanced (Default)" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:295 +msgid "High Quality (Best Image)" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:301 +msgid "Wi-Fi / Unstable Network Mode" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:302 +msgid "Increases error correction (FEC) to prevent glitches" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:311 +msgid "Sound settings" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:315 +msgid "Host Audio Output" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:316 +msgid "Where YOU will hear the game sound" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:323 +msgid "Audio Output Mode" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:324 +msgid "Determine where the game sound will be played" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:329 +#: src/big_remote_play/ui/performance_monitor.py:630 +#: src/big_remote_play/ui/performance_monitor.py:650 +#: src/big_remote_play/ui/performance_monitor.py:668 +#: src/big_remote_play/ui/performance_monitor.py:815 +msgid "Guest" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:330 +#: src/big_remote_play/utils/network.py:259 +msgid "Host" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:331 +msgid "Guest + Host" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:341 +msgid "Audio Mixer (Sources)" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:342 +msgid "Manage audio sources" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:351 +#: src/big_remote_play/ui/moonlight_preferences.py:126 +msgid "Advanced Settings" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:352 +msgid "Input, Network, and Access" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:356 +msgid "Automatic UPnP" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:357 +msgid "Automatically configure router ports" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:362 +msgid "Address Family (IPv4 + IPv6)" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:363 +msgid "Enable simultaneous IPv4 and IPv6 support on server" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:368 +msgid "Origin Web UI Allowed (WAN)" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:369 +msgid "Allows anyone to access the web interface (Anyone may access Web UI)" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:374 +msgid "Configure Firewall (IPv6)" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:375 +msgid "Open TCP/UDP ports required for external connection" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:378 +#: src/big_remote_play/ui/host_view.py:410 +msgid "Configure" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:402 +#: src/big_remote_play/ui/host_view.py:1225 +msgid "Start Server" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:411 +msgid "Configure Sunshine" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:415 +msgid "Generate PIN" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:416 +msgid "Generate a PIN for a guest" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:438 +msgid "Management" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:441 +#: src/big_remote_play/ui/host_view.py:1872 +msgid "Paired Devices" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:442 +msgid "View, disable or remove paired clients" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:449 +#: src/big_remote_play/ui/host_view.py:2008 +msgid "Sunshine Logs" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:450 +msgid "View the Sunshine server log" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:457 +#: src/big_remote_play/ui/host_view.py:2094 +msgid "Game Library" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:458 +msgid "Manage games shown to guests in Moonlight" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:465 +#: src/big_remote_play/ui/host_view.py:2323 +msgid "Server Password" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:466 +msgid "Change or reset the Sunshine login (if you forgot it)" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:473 +#: src/big_remote_play/ui/host_view.py:2317 +msgid "Advanced server settings" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:474 +msgid "Server tuning, codecs, network and library fix" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:511 +msgid "Share your PIN or address" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:512 +msgid "Share your PIN code or the server address so friends can connect." +msgstr "" + +#: src/big_remote_play/ui/host_view.py:513 +msgid "Keep it secure" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:514 +msgid "Keep your server and network secure. Use a VPN when sharing games." +msgstr "" + +#: src/big_remote_play/ui/host_view.py:530 +msgid "Information" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:535 +msgid "Game" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:545 +msgid "Use PIN Code to Connect" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:546 +msgid "Share this with the guest. Local network is required." +msgstr "" + +#: src/big_remote_play/ui/host_view.py:560 +msgid "Copy PIN" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:612 +#: src/big_remote_play/ui/host_view.py:1205 +msgid "Sunshine offline" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:617 +#: src/big_remote_play/ui/host_view.py:1197 +msgid "Start the server to stream." +msgstr "" + +#: src/big_remote_play/ui/host_view.py:632 +#: src/big_remote_play/ui/performance_monitor.py:262 +msgid "Latency" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:634 +msgid "FPS" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:636 +msgid "Bandwidth" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:650 +msgid "" +"Keep your server and network secure. Use a VPN when " +"sharing games." +msgstr "" + +#: src/big_remote_play/ui/host_view.py:824 +msgid "Insert PIN" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:825 +msgid "Enter the PIN displayed on the client device (Moonlight)." +msgstr "" + +#: src/big_remote_play/ui/host_view.py:832 +msgid "PIN" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:834 +msgid "Device Name" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:837 +#: src/big_remote_play/ui/sunshine_preferences.py:406 +msgid "Sunshine User" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:840 +#: src/big_remote_play/ui/sunshine_preferences.py:407 +msgid "Sunshine Password" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:843 +msgid "Save Password" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:844 +msgid "Save credentials to Sunshine preferences" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:856 +msgid "Send" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:880 +msgid "Authentication Failed" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:880 +msgid "Invalid username or password." +msgstr "" + +#: src/big_remote_play/ui/host_view.py:885 +msgid "PIN Error" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:892 +msgid "User Not Found" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:893 +msgid "" +"No user has been created in Sunshine. It is necessary to configure a user " +"through the browser." +msgstr "" + +#: src/big_remote_play/ui/host_view.py:897 +msgid "Open Configuration" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:910 +msgid "Create Sunshine User" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:911 +msgid "Define a username and password for Sunshine." +msgstr "" + +#: src/big_remote_play/ui/host_view.py:916 +msgid "New User" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:918 +#: src/big_remote_play/ui/host_view.py:2343 +msgid "New Password" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:924 +msgid "Save and Continue" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:935 +msgid "User created!" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:941 +msgid "Error sending PIN after creation" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:943 +msgid "Error creating user" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:954 +msgid "Sunshine Authentication" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:955 +msgid "" +"Sunshine requires login. Enter your credentials (default: admin / password " +"created during installation)." +msgstr "" + +#: src/big_remote_play/ui/host_view.py:960 +msgid "Username" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:962 +msgid "Password" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:968 +msgid "Confirm" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:984 +msgid "Failed with credentials" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:991 +msgid "Server Information" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1021 +msgid "System Default" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1036 +msgid "Error loading audio" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1058 +#, python-brace-format +msgid "Output changed to: {}" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1063 +msgid "Configuring firewall... (Password may be requested)" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1077 +#, python-brace-format +msgid "Success: {}" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1078 +msgid "Firewall Error" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1078 +msgid "Execution failed or cancelled." +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1091 +#, python-brace-format +msgid "Error executing script: {}" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1130 +#: src/big_remote_play/ui/host_view.py:2082 +#: src/big_remote_play/ui/private_network_view.py:810 +#: src/big_remote_play/ui/private_network_view.py:1243 +#: src/big_remote_play/ui/private_network_view.py:2226 +msgid "Copied!" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1133 +msgid "Clicked Start Server..." +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1148 +msgid "Active - Waiting for Connections" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1154 +msgid "Ready to receive connections." +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1161 +msgid "Sunshine active" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1170 +msgid "Stop server" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1191 +msgid "Inactive" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1363 +#: src/big_remote_play/ui/host_view.py:1367 +#: src/big_remote_play/ui/host_view.py:1390 +msgid "Host + Guest" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1363 +#: src/big_remote_play/ui/host_view.py:1367 +#: src/big_remote_play/ui/host_view.py:1390 +msgid "Host Only" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1518 +#, python-brace-format +msgid "Host output restored: {}" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1523 +msgid "Failed to create Virtual Audio" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1560 +msgid "Server started" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1604 +msgid "Opening Steam Big Picture..." +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1617 +#: src/big_remote_play/ui/host_view.py:1636 +#: src/big_remote_play/ui/host_view.py:1649 +#, python-brace-format +msgid "Launching {}..." +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1621 +#: src/big_remote_play/ui/host_view.py:1653 +#, python-brace-format +msgid "Error launching game: {}" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1681 +msgid "Stopping server..." +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1716 +msgid "Server stopped" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1731 +#, python-brace-format +msgid "Friends connect to this PC at {} — or use a PIN code." +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1744 +#, python-brace-format +msgid "Server active for {:02d}:{:02d}:{:02d}" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1775 +msgid "Sunshine stopped unexpectedly" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1782 +#: src/big_remote_play/ui/private_network_view.py:1701 +#: src/big_remote_play/ui/private_network_view.py:1709 +#: src/big_remote_play/ui/private_network_view.py:1751 +msgid "Online" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1782 +#: src/big_remote_play/ui/main_window.py:1023 +#: src/big_remote_play/ui/main_window.py:1113 +msgid "Stopped" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1828 +msgid "Check logs for details." +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1830 +#, python-brace-format +msgid "" +"Sunshine failed to start.\n" +"\n" +"Error: {}\n" +"\n" +"If this is a dependency issue (missing libraries), try the 'Fix " +"Dependencies' button." +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1834 +msgid "Server Failed to Start" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1836 +#: src/big_remote_play/ui/host_view.py:1877 +#: src/big_remote_play/ui/host_view.py:2010 +#: src/big_remote_play/ui/host_view.py:2099 +#: src/big_remote_play/ui/host_view.py:2233 +#: src/big_remote_play/ui/installer_window.py:89 +#: src/big_remote_play/ui/installer_window.py:136 +#: src/big_remote_play/ui/private_network_view.py:1522 +msgid "Close" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1837 +msgid "View Logs" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1838 +msgid "Fix Dependencies" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1873 +msgid "" +"Devices paired with Sunshine. Disable to block access without re-pairing; " +"remove to revoke (a new PIN will be required)." +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1878 +msgid "Remove All" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1883 +#: src/big_remote_play/ui/host_view.py:2055 +#: src/big_remote_play/ui/host_view.py:2111 +msgid "Loading…" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1923 +msgid "No paired devices" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1930 +msgid "Unknown device" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1937 +msgid "Device enabled" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1945 +#: src/big_remote_play/ui/host_view.py:1946 +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:688 +msgid "Remove device" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1961 +msgid "Failed to update device" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1967 +msgid "Remove Device" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1968 +#, python-brace-format +msgid "Remove “{}”? It will need to pair again with a new PIN." +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1972 +msgid "Remove" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1996 +#: src/big_remote_play/ui/host_view.py:2001 +msgid "Devices updated" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1996 +#: src/big_remote_play/ui/host_view.py:2001 +#: src/big_remote_play/ui/host_view.py:2218 +msgid "Operation failed" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2019 +msgid "Refresh" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2020 +msgid "Refresh logs" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2027 +msgid "Copy" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2028 +msgid "Copy logs" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2070 +msgid "No logs available (Sunshine not running or no credentials)." +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2089 +#: src/big_remote_play/ui/host_view.py:2227 +msgid "Server Not Running" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2090 +msgid "Start the server first to manage the game library." +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2095 +msgid "" +"Games offered to guests in Moonlight. Add your detected games or remove " +"entries." +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2104 +msgid "Add Detected Games" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2151 +msgid "No apps configured" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2159 +msgid "Unnamed" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2170 +#: src/big_remote_play/ui/host_view.py:2171 +msgid "Remove app" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2213 +#, python-brace-format +msgid "Added {} game(s)" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2218 +msgid "Library updated" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2228 +msgid "Start the server first to browse the host filesystem." +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2231 +msgid "Select Executable" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2272 +msgid "Cannot browse (no access or credentials)" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2280 +msgid "Up one level" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2307 +#, python-brace-format +msgid "Selected: {}" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2324 +msgid "" +"Set the username and password used to manage the Sunshine server. If you " +"forgot the current password, switch on “I forgot the current password” to " +"reset it." +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2334 +msgid "I forgot the current password" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2335 +msgid "Reset directly; the server will restart" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2336 +msgid "Current Username" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2338 +msgid "Current Password" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2341 +msgid "New Username" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2344 +msgid "Confirm New Password" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2357 +#: src/big_remote_play/ui/private_network_view.py:1203 +#: src/big_remote_play/ui/private_network_view.py:2004 +msgid "Save" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2366 +msgid "Username and password cannot be empty." +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2369 +msgid "New passwords do not match." +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2628 +#: src/big_remote_play/ui/preferences.py:78 +msgid "Restore Defaults" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2628 +msgid "Do you want to restore default settings?" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2629 +#: src/big_remote_play/ui/preferences.py:74 +#: src/big_remote_play/ui/preferences.py:204 +msgid "Restore" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2637 +msgid "Settings Restored" +msgstr "" + +#: src/big_remote_play/ui/installer_window.py:21 +msgid "Install Dependencies" +msgstr "" + +#: src/big_remote_play/ui/installer_window.py:34 +msgid "Installing required components..." +msgstr "" + +#: src/big_remote_play/ui/installer_window.py:60 +msgid "" +"Embedded terminal not available (vte4 not found).\n" +"Opening external terminal for installation...\n" +"Please wait for the installation to finish in the other window." +msgstr "" + +#: src/big_remote_play/ui/installer_window.py:71 +#: src/big_remote_play/ui/private_network_view.py:501 +#: src/big_remote_play/ui/private_network_view.py:1551 +msgid "Starting..." +msgstr "" + +#: src/big_remote_play/ui/installer_window.py:81 +msgid "Done! Press Enter to close..." +msgstr "" + +#: src/big_remote_play/ui/installer_window.py:89 +msgid "Running in external terminal. Restart after completion." +msgstr "" + +#: src/big_remote_play/ui/installer_window.py:90 +msgid "Error: No terminal detected." +msgstr "" + +#: src/big_remote_play/ui/installer_window.py:90 +#, python-brace-format +msgid "" +"Execute manually:\n" +"{}" +msgstr "" + +#: src/big_remote_play/ui/installer_window.py:93 +msgid "Installing..." +msgstr "" + +#: src/big_remote_play/ui/installer_window.py:97 +#, python-brace-format +msgid "Error: {}" +msgstr "" + +#: src/big_remote_play/ui/installer_window.py:102 +#, python-brace-format +msgid "Embedded terminal error: {}. Trying external..." +msgstr "" + +#: src/big_remote_play/ui/installer_window.py:119 +msgid "Installation completed successfully!" +msgstr "" + +#: src/big_remote_play/ui/installer_window.py:123 +msgid "Finish" +msgstr "" + +#: src/big_remote_play/ui/installer_window.py:134 +#, python-brace-format +msgid "Installation failed. Exit code: {}" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:31 +msgid "Self-hosted VPN server with Cloudflare DNS. Full control." +msgstr "" + +#: src/big_remote_play/ui/main_window.py:39 +msgid "Easy mesh VPN. No server required. Free tier available." +msgstr "" + +#: src/big_remote_play/ui/main_window.py:47 +msgid "Flexible virtual network. Works through NAT and firewalls." +msgstr "" + +#: src/big_remote_play/ui/main_window.py:58 +msgid "Sunshine Game Stream Host" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:59 +msgid "High-performance game stream host. Required to share your games." +msgstr "" + +#: src/big_remote_play/ui/main_window.py:66 +msgid "Moonlight Game Stream Client" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:67 +msgid "Game stream client. Required to connect to other hosts." +msgstr "" + +#: src/big_remote_play/ui/main_window.py:73 +msgid "Docker Engine" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:74 +msgid "Container platform. Required for the private network server." +msgstr "" + +#: src/big_remote_play/ui/main_window.py:81 +msgid "Tailscale" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:82 +msgid "Mesh VPN service. Required for Tailscale connectivity." +msgstr "" + +#: src/big_remote_play/ui/main_window.py:89 +msgid "ZeroTier" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:90 +msgid "Virtual network service. Required for ZeroTier connectivity." +msgstr "" + +#: src/big_remote_play/ui/main_window.py:100 +#: src/big_remote_play/ui/main_window.py:450 +msgid "Home" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:102 +msgid "Home Page" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:105 +msgid "Server" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:107 +msgid "Share your games" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:110 +msgid "Connect to Server" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:112 +msgid "Connect to a host" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:115 +msgid "Private Network" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:198 +msgid "Create Private Network" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:200 +#, python-brace-format +msgid "{} - Setup server" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:204 +msgid "Connect to Private Network" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:206 +#, python-brace-format +msgid "{} - Join network" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:210 +msgid "Change VPN" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:212 +msgid "Switch provider" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:217 +msgid "Select VPN" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:219 +msgid "Choose your VPN provider" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:327 +msgid "Service Status" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:358 +msgid "Checking..." +msgstr "" + +#: src/big_remote_play/ui/main_window.py:433 +msgid "Installed" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:433 +msgid "Missing" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:439 +msgid "host" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:439 +msgid "connect" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:440 +#, python-brace-format +msgid "Need to install {} to {}" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:450 +msgid "Play together, from anywhere" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:477 +msgid "Application menu" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:487 +#: src/big_remote_play/ui/preferences.py:25 +msgid "Preferences" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:488 +msgid "About" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:531 +msgid "Choose Your VPN Provider" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:534 +msgid "" +"Select a VPN solution to create or join a Private Network. Your choice will " +"be saved and shown in the sidebar menu." +msgstr "" + +#: src/big_remote_play/ui/main_window.py:552 +msgid "Quick Comparison" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:558 +msgid "Setup difficulty" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:558 +#: src/big_remote_play/utils/widgets.py:22 +msgid "Beginner" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:558 +#: src/big_remote_play/utils/widgets.py:23 +msgid "Intermediate" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:558 +#: src/big_remote_play/ui/preferences.py:104 +#: src/big_remote_play/ui/sunshine_preferences.py:81 +#: src/big_remote_play/utils/widgets.py:24 +msgid "Advanced" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:559 +msgid "Hosting model" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:559 +msgid "Cloud (free)" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:559 +msgid "Self-hosted" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:560 +msgid "Best for" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:560 +msgid "Personal use and friends" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:561 +msgid "Flexible networks and teams" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:562 +msgid "Corporate environments" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:570 +#: src/big_remote_play/ui/main_window.py:967 +msgid "Install" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:571 +msgid "Install the chosen VPN provider on your device." +msgstr "" + +#: src/big_remote_play/ui/main_window.py:572 +msgid "Authenticate" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:573 +msgid "Log in and authorize your devices on the VPN." +msgstr "" + +#: src/big_remote_play/ui/main_window.py:574 +msgid "Play" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:575 +msgid "Connect to the server and play with your friends!" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:627 +msgid "Choose →" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:641 +msgid "Switch VPN Provider?" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:642 +#, python-brace-format +msgid "You are switching from {} to {}. Do you want to disconnect from {}?" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:648 +msgid "Keep Connected" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:649 +msgid "Disconnect previous" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:665 +#, python-brace-format +msgid "Disconnecting from {}..." +msgstr "" + +#: src/big_remote_play/ui/main_window.py:674 +#, python-brace-format +msgid "{} disconnected" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:701 +#, python-brace-format +msgid "{} selected! Setting up private network..." +msgstr "" + +#: src/big_remote_play/ui/main_window.py:741 +msgid "Play cooperatively over the local network or the internet" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:748 +msgid "What do you want to do?" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:763 +msgid "Share the game" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:764 +msgid "Run the game on THIS PC and let friends connect to play together" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:768 +msgid "Recommended" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:769 +msgid "Start sharing →" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:773 +msgid "Join a game" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:774 +msgid "Connect to a friend's PC over the network and play remotely" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:777 +msgid "Connect now →" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:787 +msgid "Start or connect" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:788 +msgid "Start a server on this PC or connect to an existing one." +msgstr "" + +#: src/big_remote_play/ui/main_window.py:790 +msgid "Invite friends" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:791 +msgid "Share the PIN code or the server address." +msgstr "" + +#: src/big_remote_play/ui/main_window.py:793 +msgid "Play together" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:794 +msgid "Everyone connects and plays remotely." +msgstr "" + +#: src/big_remote_play/ui/main_window.py:966 +msgid "Missing Components" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:966 +msgid "Sunshine and Moonlight are required. Install now?" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:1023 +msgid "Running" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:1060 +msgid "Moonlight not found" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:1076 +msgid "Containers" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:1077 +#, python-brace-format +msgid "Action {} sent to {}" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:1081 +#, python-brace-format +msgid "Error executing command: {}" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/utils/network.py, line: 98 -msgid " (IPv6 Local)" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/utils/network.py, line: 100 -msgid " (IPv6 Global)" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/utils/network.py, line: 156 -msgid "Host ({})" -msgstr "" - -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/utils/network.py, line: 267 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 312 -msgid "Host" -msgstr "" - -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/utils/system_check.py, line: 152 -# File: big-remote-play/usr/share/big-remote-play/utils/system_check.py, line: 155 -# File: big-remote-play/usr/share/big-remote-play/utils/system_check.py, line: 172 -# File: big-remote-play/usr/share/big-remote-play/utils/system_check.py, line: 175 -# File: big-remote-play/usr/share/big-remote-play/utils/audio.py, line: 271 -# File: big-remote-play/usr/share/big-remote-play/utils/audio.py, line: 282 -msgid "Unknown" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/main.py, line: 52 -msgid "Icons and images paths added" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/main.py, line: 59 -msgid "The Story Behind the Project\n" - "\n" - "Big Remote Play was born from a real story of friendship, " - "determination, and the passion for Free Software.\n" - "\n" - "Alessandro e Silva Xavier (known as Alessandro) and Alexasandro " - "Pacheco Feliciano (known as Pacheco) wanted to play games together " - "on BigLinux using a feature that only existed on proprietary " - "platforms like Steam Remote Play and GeForce NOW. The problem? These " - "systems are proprietary, locked to their own ecosystems. If a game " - "wasn't available on their platform, it was nearly impossible to play " - "remotely with friends.\n" - "\n" - "Refusing to accept this limitation, Alessandro and Pacheco embarked " - "on a journey of countless attempts and extensive research. After " - "trying many different approaches, they finally found a working " - "solution by combining multiple free software programs — including " - "Sunshine, Moonlight, scripts, and VPN tools. They had achieved what " - "the proprietary platforms kept locked behind their walls, and the " - "best part: it was Free Software and multi-platform!\n" - "\n" - "Excited by their success, they started sharing their achievement " - "during their live streams, which generated tremendous enthusiasm " - "from the community. However, there was a catch — the setup was " - "complicated. It required configuring multiple separate solutions: " - "Sunshine, Moonlight, custom scripts, VPN connections... it was a lot " - "for anyone to handle.\n" - "\n" - "That's when a friend decided to step in and help develop a unified " - "application to simplify the entire process. And so, Big Remote Play " - "was born! 🎉\n" - "\n" - "An all-in-one application that integrates everything you need for " - "remote cooperative gaming — no proprietary platforms, no " - "restrictions, no limits on which games you can play." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 17 -msgid "Install Dependencies" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 30 -msgid "Installing required components..." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 56 -msgid "Embedded terminal not available (vte4 not found).\n" - "Opening external terminal for installation...\n" - "Please wait for the installation to finish in the other window." -msgstr "" - -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 67 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 374 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1403 -msgid "Starting..." -msgstr "" - -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 70 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1864 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1898 -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 812 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 677 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 718 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 746 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 790 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1817 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 677 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 789 -msgid "Cancel" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 77 -msgid "Done! Press Enter to close..." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 85 -msgid "Running in external terminal. Restart after completion." -msgstr "" - -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 85 -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 126 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1375 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1565 -msgid "Close" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 86 -msgid "Error: No terminal detected." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 86 -msgid "Execute manually:\n" - "{}" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 89 -msgid "Installing..." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 91 -msgid "Error: {}" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 92 -msgid "Embedded terminal error: {}. Trying external..." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 109 -msgid "Installation completed successfully!" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 113 -msgid "Finish" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 124 -msgid "Installation failed. Exit code: {}" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 25 -msgid "Preferências" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 43 -msgid "Geral" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 48 -msgid "Aparência" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 52 -msgid "Tema" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 53 -msgid "Escolha o esquema de cores" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 56 -msgid "Automático" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 57 -msgid "Claro" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 58 -msgid "Escuro" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 77 -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 84 -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 217 -msgid "Restaurar" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 81 -msgid "Restaurar Padrões" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 82 -msgid "Redefinir todas as configurações para o padrão" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 92 -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 95 -msgid "Limpar Tudo" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 93 -msgid "Remover logs, configurações, servidores e dados salvos" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 107 -msgid "Avançado" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 112 -msgid "Caminhos" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 115 -msgid "Diretório de Configuração" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 122 -msgid "Copiar Caminho" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 131 -msgid "Logs e Depuração" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 134 -msgid "Logs Detalhados" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 135 -msgid "Ativar logging verbose para depuração" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 140 -msgid "Limpar Logs" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 141 -msgid "Remover arquivos de log antigos" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 143 -msgid "Limpar" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 198 -msgid "Logs Limpos" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 198 -msgid "Arquivos de log antigos foram removidos." -msgstr "" - -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 199 -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 317 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 691 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 755 -msgid "OK" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 208 -msgid "Caminho copiado!" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 213 -msgid "Todas as suas modificações no Big Remote Play serão restauradas! " - "Isso inclui as configurações do Sunshine e Moonlight." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 215 -msgid "Restaurar Padrões?" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 216 -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 263 -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 272 -msgid "Cancelar" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 251 -msgid "Configurações Restauradas! Reinicie o aplicativo para aplicar todas " - "as alterações." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 255 -msgid "Erro ao restaurar: {}" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 262 -msgid "Limpar TUDO?" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 262 -msgid "ATENÇÃO: Isso apagará TODOS os dados, logs, configurações e " - "servidores salvos. O aplicativo será fechado." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 264 -msgid "Apagar Tudo" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 271 -msgid "Tem Certeza Absoluta?" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 271 -msgid "Esta ação é IRREVERSÍVEL. Você perderá todos os dados configurados." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 273 -msgid "Sim, Apagar Tudo" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 316 -msgid "Erro ao Limpar" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 61 -msgid "Sunshine" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 73 -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 101 -msgid "General" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 73 -msgid "Server general settings" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 74 -msgid "Input" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 74 -msgid "Keyboard, mouse and gamepad" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 75 -msgid "Audio/Video" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 75 -msgid "Resolution, bitrate and quality" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 76 -msgid "Network" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 76 -msgid "Ports and connectivity" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 77 -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 97 -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 112 -msgid "Config Files" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 77 -msgid "Configuration files and logs" -msgstr "" - -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 78 -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 471 -msgid "Advanced" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 78 -msgid "Advanced options" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 81 -msgid "NVIDIA NVENC" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 81 -msgid "NVIDIA Encoder" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 82 -msgid "Intel QuickSync" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 82 -msgid "Intel Encoder" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 83 -msgid "AMD AMF" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 83 -msgid "AMD Encoder" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 84 -msgid "VideoToolbox" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 84 -msgid "Apple Encoder" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 85 -msgid "VA-API" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 85 -msgid "VA-API Encoder (Linux)" -msgstr "" - -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 86 -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 138 -msgid "Software" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 86 -msgid "CPU Encoding" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 115 -msgid "No options available" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 241 -msgid "Apps File" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 241 -msgid "The file where current apps of Sunshine are stored." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 242 -msgid "Credentials File" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 242 -msgid "Store Username/Password separately from Sunshine's state file." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 243 -msgid "Logfile Path" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 243 -msgid "The file where the current logs of Sunshine are stored." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 244 -msgid "Private Key" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 244 -msgid "The private key used for the web UI and Moonlight client pairing. " - "For best compatibility, this should be an RSA-2048 private key." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 245 -msgid "Certificate" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 245 -msgid "The certificate used for the web UI and Moonlight client pairing. " - "For best compatibility, this should have an RSA-2048 public key." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 246 -msgid "State File" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 246 -msgid "The file where current state of Sunshine is stored" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 247 -msgid "Configuration File" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 247 -msgid "The main configuration file for Sunshine." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 296 -msgid "Missing Libraries Detected" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 297 -msgid "Sunshine requires older ICU libraries ({}) which are missing." -msgstr "" - -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 301 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1567 -msgid "Fix Dependencies" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 310 -msgid "System Status" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 311 -msgid "All required libraries appear to be present." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 317 -msgid "Re-apply Library Fix" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 318 -msgid "Run the library fix script manually." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 320 -msgid "Run Fix" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 344 -msgid "Fix script started. Please authenticate." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 361 -msgid "Libraries fixed! You can now start the server." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 370 -msgid "Locale" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 374 -msgid "The locale used for Sunshine's user interface." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 375 -msgid "Sunshine Name" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 375 -msgid "The name of the Sunshine instance as seen by clients." -msgstr "" - -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 376 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 659 -msgid "Sunshine User" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 376 -msgid "Username for API access (Monitoring)" -msgstr "" - -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 377 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 662 -msgid "Sunshine Password" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 377 -msgid "Password for API access (Monitoring)" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 378 -msgid "Log Level" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 381 -msgid "The minimum log level printed to standard out" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 382 -msgid "Pre-Release Notifications" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 382 -msgid "Whether to be notified of new pre-release versions of Sunshine" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 383 -msgid "Enable System Tray" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 383 -msgid "Show icon in system tray and display desktop notifications" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 388 -msgid "UPnP" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 388 -msgid "Automatically configure port forwarding for streaming over the " - "Internet" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 389 -msgid "Address Family" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 391 -msgid "Set the address family used by Sunshine" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 392 -msgid "Bind Address" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 392 -msgid "IP address to bind the service to" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 393 -msgid "Port (Moonlight)" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 393 -msgid "Set the family of ports used by Sunshine.\n" - "TCP: 47984, 47989, 47990 (Web UI), 48010\n" - "UDP: 47998 - 48012" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 394 -msgid "Web UI Access" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 396 -msgid "The origin of the remote endpoint address that is not denied access " - "to Web UI.\n" - "Warning: Exposing the Web UI to the internet is a security risk! " - "Proceed at your own risk!" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 397 -msgid "External IP" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 397 -msgid "If no external IP address is given, Sunshine will automatically " - "detect external IP" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 398 -msgid "LAN Encryption" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 399 -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 402 -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 482 -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 485 -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 504 -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 550 -msgid "Disabled" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 399 -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 402 -msgid "Mode 1" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 399 -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 402 -msgid "Mode 2" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 400 -msgid "This determines when encryption will be used when streaming over " - "your local network. Encryption can reduce streaming performance, " - "particularly on less powerful hosts and clients." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 401 -msgid "WAN Encryption" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 403 -msgid "This determines when encryption will be used when streaming over the " - "Internet. Encryption can reduce streaming performance, particularly " - "on less powerful hosts and clients." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 404 -msgid "Ping Timeout (ms)" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 404 -msgid "How long to wait in milliseconds for data from moonlight before " - "shutting down the stream" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 409 -msgid "Enable Gamepad Input" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 409 -msgid "Allows guests to control the host system with a gamepad / controller" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 410 -msgid "Emulated Gamepad Type" -msgstr "" - -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 411 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 49 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 257 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 310 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 777 -msgid "Automatic" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 413 -msgid "Choose which type of gamepad to emulate on the host" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 414 -msgid "Motion as DS4" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 414 -msgid "Emulate motion controls as DS4" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 415 -msgid "Touchpad as DS4" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 415 -msgid "Emulate touchpad as DS4" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 416 -msgid "Back Button as Touchpad Click" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 416 -msgid "Use Back button for touchpad click on DS4" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 417 -msgid "Randomize MAC (DS5)" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 417 -msgid "Randomize virtual MAC address for DS5" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 418 -msgid "Home/Guide Timeout (ms)" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 418 -msgid "Hold Back/Select to emulate Guide button. < 0 to disable." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 419 -msgid "Enable Keyboard Input" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 419 -msgid "Allows guests to control the host system with the keyboard" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 420 -msgid "Enable Mouse Input" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 420 -msgid "Allows guests to control the host system with the mouse" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 421 -msgid "Always Send Scancodes" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 421 -msgid "Always send raw key scancodes" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 422 -msgid "Map Right Alt to Windows" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 422 -msgid "Make Sunshine think the Right Alt key is the Windows key" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 423 -msgid "High Resolution Scrolling" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 423 -msgid "Pass through high resolution scroll events from Moonlight clients" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 452 -msgid "Auto / Primary" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 468 -msgid "Audio Sink" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 468 -msgid "Listing all available audio sinks is possible by running `pactl list " - "short sinks` (PulseAudio) or `wpctl status` (PipeWire)." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 469 -msgid "Stream Audio" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 469 -msgid "Whether to stream audio or not. Disabling this can be useful for " - "streaming headless displays as second monitors." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 470 -msgid "Graphics Adapter" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 470 -msgid "Specific GPU to use. Default is usually correct." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 471 -msgid "Display Name" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 471 -msgid "Select the display monitor to capture. Corresponds to the output " - "connector name (e.g., DP-1, HDMI-A-1)." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 472 -msgid "Maximum Bitrate" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 472 -msgid "The maximum bitrate (in Kbps) that Sunshine will encode the stream " - "at. If set to 0, it will always use the bitrate requested by " - "Moonlight." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 473 -msgid "Minimum FPS Target" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 473 -msgid "The lowest effective FPS a stream can reach. A value of 0 is treated " - "as roughly half of the stream's FPS. A setting of 20 is recommended " - "if you stream 24 or 30fps content." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 478 -msgid "FEC Percentage" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 478 -msgid "Percentage of error correcting packets per data packet in each video " - "frame. Higher values can correct for more network packet loss, but " - "at the cost of increasing bandwidth usage." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 479 -msgid "Quantization Parameter" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 479 -msgid "Some devices may not support Constant Bit Rate. For those devices, " - "QP is used instead. Higher value means more compression, but less " - "quality." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 480 -msgid "Minimum CPU Thread Count" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 480 -msgid "Increasing the value slightly reduces encoding efficiency, but the " - "tradeoff is usually worth it to gain the use of more CPU cores for " - "encoding. The ideal value is the lowest value that can reliably " - "encode at your desired streaming settings on your hardware." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 481 -msgid "HEVC Support" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 482 -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 485 -msgid "Advertised (Recommended)" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 483 -msgid "Allows the client to request HEVC Main or HEVC Main10 video streams. " - "HEVC is more CPU-intensive to encode, so enabling this may reduce " - "performance when using software encoding." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 484 -msgid "AV1 Support" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 486 -msgid "Allows the client to request AV1 Main 8-bit or 10-bit video streams. " - "AV1 is more CPU-intensive to encode, so enabling this may reduce " - "performance when using software encoding." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 487 -msgid "Force a Specific Capture Method" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 488 -msgid "Autodetect (Recommended)" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 489 -msgid "On automatic mode Sunshine will use the first one that works. NvFBC " - "requires patched nvidia drivers." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 490 -msgid "Force a Specific Encoder" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 491 -msgid "Autodetect" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 494 -msgid "Force a specific encoder, otherwise Sunshine will select the best " - "available option. Note: If you specify a hardware encoder on " - "Windows, it must match the GPU where the display is connected." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 499 -msgid "Performance Preset" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 502 -msgid "Higher numbers improve compression (quality at given bitrate) at the " - "cost of increased encoding latency. Recommended to change only when " - "limited by network or decoder, otherwise similar effect can be " - "accomplished by increasing bitrate." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 503 -msgid "Two-Pass Mode" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 504 -msgid "Quarter Resolution" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 504 -msgid "Full Resolution" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 505 -msgid "Adds preliminary encoding pass. This allows to detect more motion " - "vectors, better distribute bitrate across the frame and more " - "strictly adhere to bitrate limits. Disabling it is not recommended " - "since this can lead to occasional bitrate overshoot and subsequent " - "packet loss." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 506 -msgid "Spatial AQ" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 506 -msgid "Assign higher QP values to flat regions of the video. Recommended to " - "enable when streaming at lower bitrates." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 507 -msgid "Single-frame VBV/HRD percentage increase" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 507 -msgid "By default sunshine uses single-frame VBV/HRD, which means any " - "encoded video frame size is not expected to exceed requested bitrate " - "divided by requested frame rate. Relaxing this restriction can be " - "beneficial and act as low-latency variable bitrate, but may also " - "lead to packet loss if the network doesn't have buffer headroom to " - "handle bitrate spikes. Maximum accepted value is 400, which " - "corresponds to 5x increased encoded video frame upper size limit." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 508 -msgid "Prefer CAVLC over CABAC in H.264" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 508 -msgid "Simpler form of entropy coding. CAVLC needs around 10% more bitrate " - "for same quality. Only relevant for really old decoding devices." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 513 -msgid "QuickSync Preset" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 515 -msgid "Performance preset" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 516 -msgid "QuickSync Coder (H264)" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 517 -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 540 -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 547 -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 550 -msgid "Auto" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 518 -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 548 -msgid "Entropy coding mode" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 519 -msgid "Allow Slow HEVC Encoding" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 519 -msgid "This can enable HEVC encoding on older Intel GPUs, at the cost of " - "higher GPU usage and worse performance." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 524 -msgid "AMF Usage" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 528 -msgid "This sets the base encoding profile. All options presented below " - "will override a subset of the usage profile, but there are " - "additional hidden settings applied that cannot be configured " - "elsewhere." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 529 -msgid "AMF Rate Control" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 532 -msgid "This controls the rate control method to ensure we are not exceeding " - "the client bitrate target. 'cqp' is not suitable for bitrate " - "targeting, and other options besides 'vbr_latency' depend on HRD " - "Enforcement to help constrain bitrate overflows." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 533 -msgid "AMF Hypothetical Reference Decoder (HRD) Enforcement" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 533 -msgid "Increases the constraints on rate control to meet HRD model " - "requirements. This greatly reduces bitrate overflows, but may cause " - "encoding artifacts or reduced quality on certain cards." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 534 -msgid "AMF Quality" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 535 -msgid "Speed" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 535 -msgid "Balanced" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 535 -msgid "Quality" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 536 -msgid "This controls the balance between encoding speed and quality." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 537 -msgid "AMF Preanalysis" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 537 -msgid "This enables rate-control preanalysis, which may increase quality at " - "the expense of increased encoding latency." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 538 -msgid "AMF Variance Based Adaptive Quantization (VBAQ)" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 538 -msgid "The human visual system is typically less sensitive to artifacts in " - "highly textured areas. In VBAQ mode, pixel variance is used to " - "indicate the complexity of spatial textures, allowing the encoder to " - "allocate more bits to smoother areas. Enabling this feature leads to " - "improvements in subjective visual quality with some content." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 539 -msgid "AMF Coder (H264)" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 541 -msgid "Allows you to select the entropy encoding to prioritize quality or " - "encoding speed. H.264 only." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 546 -msgid "VideoToolbox Coder" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 549 -msgid "VideoToolbox Software Encoding" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 550 -msgid "Allowed" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 550 -msgid "Forced" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 551 -msgid "Allow fallback to software encoding" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 552 -msgid "VideoToolbox Realtime Encoding" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 552 -msgid "Realtime encoding priority" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 557 -msgid "Strictly enforce frame bitrate limits for H.264/HEVC on AMD GPUs" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 557 -msgid "Enabling this option can avoid dropped frames over the network " - "during scene changes, but video quality may be reduced during motion." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 562 -msgid "SW Presets" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 565 -msgid "Optimize the trade-off between encoding speed (encoded frames per " - "second) and compression efficiency (quality per bit in the " - "bitstream). Defaults to superfast." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 566 -msgid "SW Tune" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 568 -msgid "Tuning options, which are applied after the preset. Defaults to " - "zerolatency." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, line: 170 -msgid "Waiting for data..." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, line: 202 -msgid "{} Active Devices" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, line: 256 -msgid "Latency" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, line: 323 -# File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, line: 869 -msgid "Real-time Monitoring" -msgstr "" - -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, line: 330 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 173 -msgid "Disconnected" -msgstr "" - -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, line: 591 -# File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, line: 631 -# File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, line: 640 -# File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, line: 658 -# File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, line: 676 -# File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, line: 815 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 311 -msgid "Guest" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, line: 798 -msgid "Active Connection" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, line: 800 -msgid "{} devices monitoring" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, line: 803 -msgid "Active - No devices" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, line: 860 -msgid "Disconnect this specific guest (Admin)" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, line: 868 -msgid "Connected to {}" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 24 -msgid "Create Headscale Server" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 25 -msgid "Set up your own private VPN server using Docker + Cloudflare DNS." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 26 -msgid "Connect to Headscale Network" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 27 -msgid "Enter the server domain and auth key provided by the administrator." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 34 -msgid "Login to Tailscale" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 35 -msgid "Login to your Tailscale account. No server required." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 36 -msgid "Connect to Tailscale Network" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 37 -msgid "Enter your auth key or login server to join a Tailscale network." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 44 -msgid "Create ZeroTier Network" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 45 -msgid "Create a new ZeroTier virtual network using your API token." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 46 -msgid "Connect to ZeroTier Network" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 47 -msgid "Enter the 16-character Network ID to join a ZeroTier network." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 238 -msgid "Configuration" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 271 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1188 -msgid "Instructions" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 277 -msgid "Logout / Disconnect" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 289 -msgid "Your Networks" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 302 -msgid "Login / Connect" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 303 -msgid "Save Token & Create Network" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 304 -msgid "Install Server" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 322 -msgid "Domain (e.g. vpn.ruscher.org)" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 323 -msgid "Cloudflare Zone ID" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 324 -msgid "Cloudflare API Token" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 332 -msgid "Auth Key (optional – leave empty for browser login)" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 335 -msgid "Get auth key" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 335 -msgid "login.tailscale.com/admin/settings/keys" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 337 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 362 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1318 -msgid "Open" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 352 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1346 -msgid "ZeroTier API Token" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 355 -msgid "Network Name" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 360 -msgid "Get API Token" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 360 -msgid "my.zerotier.com → Account → API Access Tokens" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 438 -msgid "All fields are required" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 456 -msgid "✅ Server installed!" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 460 -msgid "Headscale server installed!" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 463 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 492 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 530 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1503 -msgid "❌ Failed" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 464 -msgid "Installation failed. Check the log." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 485 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1479 -msgid "✅ Connected!" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 489 -msgid "Tailscale connected!" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 493 -msgid "Login failed" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 501 -msgid "API Token is required" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 520 -msgid "✅ Network created!" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 526 -msgid "ZeroTier network created!" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 531 -msgid "Network creation failed" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 549 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 558 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1926 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1935 -msgid "Setup Instructions" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 559 -msgid "Step-by-step guide to create your private network" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 579 -msgid "1. Register a Free Domain" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 580 -msgid "Get a free .us.kg domain to use with your server" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 583 -msgid "Access DigitalPlat Domain" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 584 -msgid "Register and get your free domain (e.g.: myserver.us.kg)" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 587 -msgid "Open Site" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 598 -msgid "Steps at DigitalPlat" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 600 -msgid "1. Create an account or login\n" - "2. Choose a .us.kg domain\n" - "3. Complete the simple registration\n" - "4. Write down the domain you obtained (e.g.: myserver.us.kg)" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 614 -msgid "2. Configure Cloudflare" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 615 -msgid "Point your domain to Cloudflare for DNS management" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 618 -msgid "Access Cloudflare Dashboard" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 619 -msgid "Create a free account and add your domain" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 622 -msgid "Open Cloudflare" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 633 -msgid "Setup Steps" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 635 -msgid "1. Click 'Add a site' → Enter your domain\n" - "2. Choose the FREE plan\n" - "3. Write down the 2 nameservers provided\n" - "4. Go back to your domain provider (DigitalPlat)\n" - "5. Replace the DNS with Cloudflare's nameservers\n" - "6. Wait for DNS propagation (may take a few minutes)" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 647 -msgid "Update Nameservers" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 648 -msgid "Open domain panel to change NS records" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 651 -msgid "Domain Panel" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 669 -msgid "3. Get API Credentials" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 670 -msgid "Obtain your Zone ID and API Token from Cloudflare" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 673 -msgid "Zone ID" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 675 -msgid "1. In Cloudflare, click on your domain\n" - "2. Scroll down to the 'API' section on the right\n" - "3. Copy the 'Zone ID' value" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 683 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1359 -msgid "API Token" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 685 -msgid "1. Click 'Get your API token' (below Zone ID)\n" - "2. Click 'Create Token'\n" - "3. Use template: 'Edit zone DNS' → 'Use template'\n" - "4. Configure:\n" - " • Token name: VPN-Token\n" - " • Permissions: Zone - DNS - Edit\n" - " • Zone: Select your domain\n" - "5. Click 'Continue to summary' → 'Create Token'\n" - "6. Copy token IMMEDIATELY (shown only once!)" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 704 -msgid "4. Configure DNS Records in Cloudflare" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 705 -msgid "Create DNS records pointing to your public IP" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 709 -msgid "Your Public IP" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 710 -msgid "Use this IP for the A record below" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 713 -msgid "Loading..." -msgstr "" - -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 720 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1129 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 2067 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 948 -msgid "Copied!" -msgstr "" - -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 726 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 379 -msgid "Copy IP" -msgstr "" - -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 743 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 892 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1352 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 571 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 608 -msgid "Error" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 748 -msgid "A Record" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 750 -msgid "In Cloudflare → DNS → Records → 'Add record':\n" - "• Type: A\n" - "• Name: @\n" - "• Content: YOUR-PUBLIC-IP (shown above)\n" - "• Proxy: OFF (gray cloud — IMPORTANT!)" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 760 -msgid "CNAME Record" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 762 -msgid "Add another record:\n" - "• Type: CNAME\n" - "• Name: www\n" - "• Target: @\n" - "• Proxy: OFF" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 777 -msgid "5. Configure Router (Port Forwarding)" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 778 -msgid "Open ports 8080, 9443, 41641. Note: The installation script will " - "attempt to configure these automatically via UPnP." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 781 -msgid "Access Your Router" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 783 -msgid "1. Open your browser and go to 192.168.1.1 (or your router's IP)\n" - "2. Find 'Port Forwarding' or 'NAT' settings" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 790 -msgid "Web Interface and API" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 791 -msgid "Admin Panel (Headscale UI)" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 792 -msgid "Peer-to-peer VPN data" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 802 -msgid "Find your local IP" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 803 -msgid "Run 'hostname -I' in a terminal to find your local IP address" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 818 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1987 -msgid "Tailscale Instructions" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 819 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 826 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1988 -msgid "1. Criar Conta" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 819 -msgid "Registro no Tailscale" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 819 -msgid "Crie sua conta gratuita para começar a gerenciar sua malha VPN." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 819 -msgid "Abrir Site" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 820 -msgid "2. Login no Host" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 820 -msgid "Vincular este dispositivo" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 820 -msgid "Utilize o botão 'Login / Connect' na aba anterior para autorizar " - "este PC." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 821 -msgid "3. Painel Admin" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 821 -msgid "Gerenciar Máquinas" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 821 -msgid "Visualize e autorize as máquinas conectadas à sua rede no painel " - "oficial." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 821 -msgid "Abrir Painel" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 825 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1994 -msgid "ZeroTier Instructions" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 826 -msgid "Acessar ZeroTier Central" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 826 -msgid "Registre-se para criar e gerenciar suas redes virtuais." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 826 -msgid "Abrir Portal" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 827 -msgid "2. Autenticação" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 827 -msgid "Gerar Token de API" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 827 -msgid "Vá em 'Account Settings' e crie um novo 'API Access Token'." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 827 -msgid "Gerar Token" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 828 -msgid "3. Configuração" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 828 -msgid "Vincular Rede" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 828 -msgid "Cole o Token no campo correspondente e clique em 'Save Token & " - "Create Network'." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 829 -msgid "4. Administração" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 829 -msgid "Autorizar Membros" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 829 -msgid "Clique no Network ID da sua rede para gerenciar e autorizar novos " - "dispositivos." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 829 -msgid "Minhas Redes" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 846 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 2014 -msgid "Step-by-step guide" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 898 -msgid "Disconnecting..." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 903 -msgid "Tailscale disconnected" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 915 -msgid "ZeroTier token removed" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 924 -msgid "Disconnected from Headscale" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 987 -msgid "No networks found" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 987 -msgid "Connect or create a network first" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 997 -msgid "This device" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1013 -msgid "Installation Log" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1035 -msgid "🎉 Success! Share with friends:" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1037 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1084 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1720 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1820 -msgid "Domain" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1038 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1084 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1721 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1842 -msgid "Network ID" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1039 -msgid "Auth Key (Friends)" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1040 -msgid "API Key (Admin)" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1041 -msgid "Web Interface" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1067 -msgid "Save Network Information" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1076 -msgid "Saved to file!" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1083 -msgid "VPN Network Details:\n" - "\n" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1084 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1302 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1308 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1722 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1825 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1836 -msgid "Auth Key" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1090 -msgid "Opening mail client..." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1092 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1859 -msgid "Save" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1095 -msgid "Saved to history!" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1100 -msgid "Save to File" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1107 -msgid "Share" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1121 -msgid "Network Information" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1165 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1814 -msgid "Connection Details" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1176 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1480 -msgid "Establish Connection" -msgstr "" - -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1196 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 238 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 431 -msgid "Connect" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1207 -msgid "Network Devices" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1220 -msgid "Set API Token to see all network devices" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1222 -msgid "Set Token" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1229 -msgid "Auth" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1229 -msgid "ID" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1229 -msgid "Name" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1230 -msgid "Managed IP" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1230 -msgid "Last Seen" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1231 -msgid "Version" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1231 -msgid "Physical IP" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1249 -msgid "Set ZeroTier API Token" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1255 -msgid "Create API Access Token" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1262 -msgid "Status" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1276 -msgid "Previous Networks" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1301 -msgid "Server Domain (e.g. vpn.ruscher.org)" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1307 -msgid "Login Server (leave empty for tailscale.com)" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1313 -msgid "Network ID (16 characters)" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1314 -msgid "e.g. a1b2c3d4e5f6a7b8" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1316 -msgid "Find Network ID" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1316 -msgid "my.zerotier.com → Networks" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1355 -msgid "Enter your API Token to view managed devices." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1371 -msgid "Save & Refresh" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1384 -msgid "Token saved" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1402 -msgid "Connecting..." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1410 -msgid "Domain and Auth Key required" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1420 -msgid "Auth Key is required" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1431 -msgid "Network ID is required" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1500 -msgid "Connected successfully!" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1504 -msgid "Try Again" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1505 -msgid "Connection failed" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1539 -msgid "Just now" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1552 -msgid "Self" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1552 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1560 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1603 -msgid "Online" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1560 -msgid "Offline" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1630 -msgid "Peer" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1642 -msgid "API Token Missing" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1642 -msgid "See banner above" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1644 -msgid "No devices found" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1644 -msgid "Try refreshing..." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1660 -msgid "No previous networks" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1662 -msgid "Your created/connected networks will appear here." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1696 -msgid "Reconnect" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1704 -msgid "Edit" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1713 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1899 -msgid "Delete" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1723 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1849 -msgid "Web UI" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1724 -msgid "VPN" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1772 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1787 -msgid "Form filled for {}" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1796 -msgid "Edit – {}" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1802 -msgid "Edit Network Entry" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1831 -msgid "Login Server" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1878 -msgid "Entry updated" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1895 -msgid "Delete entry?" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1896 -msgid "Remove this network from history?" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1911 -msgid "Connection Log" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1936 -msgid "Step-by-step guide to join a private network" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1956 -msgid "Steps to join Headscale" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1959 -msgid "Step 1: Get credentials" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1960 -msgid "Ask the network administrator for the Server Domain and Auth Key." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1965 -msgid "Step 2: Enter details" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1966 -msgid "Paste the Domain and Key in the fields on the previous tab." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1971 -msgid "Step 3: Connect" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1972 -msgid "Click 'Establish Connection' and wait for the success message." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1988 -msgid "Acessar Tailscale" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1988 -msgid "Todos os participantes precisam de uma conta (Google, GitHub, etc)." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1988 -msgid "Criar Conta" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1989 -msgid "2. Convite" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1989 -msgid "Solicitar Acesso" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1989 -msgid "O administrador deve compartilhar o nó ou convidar seu e-mail para a " - "rede." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1990 -msgid "3. Conectar" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1990 -msgid "Estabelecer Ligação" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1990 -msgid "Com o convite aceito, use a aba anterior para se conectar." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1995 -msgid "1. Identificação" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1995 -msgid "Obter Network ID" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1995 -msgid "Solicite o ID de 16 caracteres ao dono da rede." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1996 -msgid "2. Ingressar" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1996 -msgid "Digitar ID" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1996 -msgid "Insira o ID na aba 'Connect' e clique em 'Establish Connection'." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1997 -msgid "3. Autorização" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1997 -msgid "Aguardar Aprovação" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1997 -msgid "O administrador precisa marcar a opção 'Auth' para o seu PC no " - "painel dele." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1997 -msgid "Painel ZT" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 24 -msgid "Self-hosted VPN server with Cloudflare DNS. Full control." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 32 -msgid "Easy mesh VPN. No server required. Free tier available." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 40 -msgid "Flexible virtual network. Works through NAT and firewalls." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 51 -msgid "Sunshine Game Stream Host" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 52 -msgid "High-performance game stream host. Required to share your games." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 59 -msgid "Moonlight Game Stream Client" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 60 -msgid "Game stream client. Required to connect to other hosts." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 66 -msgid "Docker Engine" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 67 -msgid "Container platform. Required for the private network server." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 74 -msgid "Tailscale" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 75 -msgid "Mesh VPN service. Required for Tailscale connectivity." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 82 -msgid "ZeroTier" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 83 -msgid "Virtual network service. Required for ZeroTier connectivity." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 93 -msgid "Home" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 95 -msgid "Home Page" -msgstr "" - -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 98 -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 651 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 145 -msgid "Host Server" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 100 -msgid "Share your games" -msgstr "" - -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 103 -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 659 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 58 -msgid "Connect to Server" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 105 -msgid "Connect to a host" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 108 -msgid "Private Network" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 176 -msgid "Create Private Network" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 178 -msgid "{} - Setup server" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 182 -msgid "Connect to Private Network" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 184 -msgid "{} - Join network" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 188 -msgid "Change VPN" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 190 -msgid "Switch provider" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 195 -msgid "Select VPN" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 197 -msgid "Choose your VPN provider" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 298 -msgid "Service Status" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 327 -msgid "Checking..." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 388 -msgid "Installed" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 388 -msgid "Missing" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 394 -msgid "host" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 394 -msgid "connect" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 395 -msgid "Need to install {} to {}" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 401 -msgid "Preferences" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 401 -msgid "About" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 445 -msgid "Choose Your VPN Provider" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 448 -msgid "Select a VPN solution to create or join a Private Network. Your " - "choice will be saved and shown in the sidebar menu." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 466 -msgid "Quick Comparison" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 471 -msgid "Self-hosted" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 471 -msgid "Full control, needs domain + Cloudflare" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 472 -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 473 -msgid "Cloud (free)" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 472 -msgid "Easiest setup, works out of the box" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 472 -msgid "Beginner" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 473 -msgid "Flexible, works through NAT" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 473 -msgid "Intermediate" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 523 -msgid "Choose →" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 537 -msgid "Switch VPN Provider?" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 538 -msgid "You are switching from {} to {}. Do you want to disconnect from {}?" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 544 -msgid "Keep Connected" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 545 -msgid "Disconnect previous" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 561 -msgid "Disconnecting from {}..." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 570 -msgid "{} disconnected" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 597 -msgid "{} selected! Setting up private network..." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 635 -msgid "Play cooperatively over the local network or the internet" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 652 -msgid "Share your games with other players on the network" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 660 -msgid "Connect to a game server on the network" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 671 -msgid "Based on Sunshine and Moonlight" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 811 -msgid "Missing Components" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 811 -msgid "Sunshine and Moonlight are required. Install now?" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 812 -msgid "Install" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 865 -msgid "Running" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 865 -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 955 -msgid "Stopped" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 902 -msgid "Moonlight not found" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 918 -msgid "Containers" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 919 -msgid "Action {} sent to {}" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 923 -msgid "Error executing command: {}" -msgstr "" - -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 925 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 971 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 212 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 220 -msgid "Stop" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 925 -msgid "Start" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 930 -msgid "Restart" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 935 -msgid "Disable" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 935 -msgid "Enable" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 948 -msgid "Private Network Containers" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 955 -msgid "Running (Caddy + Headscale)" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 959 -msgid "Stop Containers" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 959 -msgid "Start Containers" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 964 -msgid "Restart Containers" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 20 -msgid "Moonlight" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 31 -msgid "Basic Settings" -msgstr "" - -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 36 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 198 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 99 -msgid "Resolution" -msgstr "" - -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 48 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 209 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 110 -msgid "Frame Rate (FPS)" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 60 -msgid "Video Bitrate" -msgstr "" - -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 72 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 137 -msgid "Display Mode" -msgstr "" - -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 74 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 139 -msgid "Borderless Window" -msgstr "" - -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 74 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 139 -msgid "Fullscreen" -msgstr "" - -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 74 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 139 -msgid "Windowed" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 82 -msgid "V-Sync" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 82 -msgid "Visual Synchronization" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 83 -msgid "Frame Pacing" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 83 -msgid "Improve smoothness" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 87 -msgid "Input Settings" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 89 -msgid "Optimize mouse for Remote Desktop" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 90 -msgid "Capture system shortcuts" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 91 -msgid "Use touchscreen as virtual trackpad" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 92 -msgid "Invert mouse left/right buttons" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 93 -msgid "Invert scroll wheel direction" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 97 -msgid "Audio Settings" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 101 -msgid "Audio Configuration" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 103 -msgid "Stereo" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 111 -msgid "Mute host PC speakers while streaming" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 112 -msgid "Mute audio when Moonlight is not the active window" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 116 -msgid "Controller Settings" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 118 -msgid "Swap A/B and X/Y buttons" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 119 -msgid "Force controller #1 always connected" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 120 -msgid "Enable mouse control with gamepad" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 121 -msgid "Process controller input in background" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 125 -msgid "Host Settings" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 127 -msgid "Optimize game settings for streaming" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 128 -msgid "Quit app on host after streaming ends" -msgstr "" - -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 132 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 333 -msgid "Advanced Settings" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 136 -msgid "Video Decoder" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 138 -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 149 -msgid "Automatic (Recommended)" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 138 -msgid "Hardware" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 147 -msgid "Video Codec" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 157 -msgid "Enable HDR (Experimental)" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 158 -msgid "Enable YUV 4:4:4 (Experimental)" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 159 -msgid "Unlock bitrate limit (Experimental)" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 160 -msgid "Discover PCs automatically" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 161 -msgid "Check for blocked connections automatically" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 162 -msgid "Show performance stats while streaming" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 166 -msgid "UI Settings" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 168 -msgid "Show connection quality warnings" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 169 -msgid "Keep display active during streaming" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 141 -msgid "Sunshine Offline" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 146 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 992 -msgid "Configure and share your game for friends to connect" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 150 -msgid "Game Configuration" -msgstr "" - -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 155 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 97 -msgid "Reset to Defaults" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 159 -msgid "Game Mode" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 159 -msgid "Select game source" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 161 -msgid "Full Desktop" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 161 -msgid "Custom App" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 165 -msgid "Game Selection" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 166 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 171 -msgid "Choose game from list" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 170 -msgid "Select Game" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 178 -msgid "Application Details" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 179 -msgid "Configure name and command" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 183 -msgid "Application Name" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 187 -msgid "Command" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 192 -msgid "Streaming Settings" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 193 -msgid "Quality and Players" -msgstr "" - -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 199 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 99 -msgid "Stream resolution" -msgstr "" - -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 201 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 212 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 101 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 112 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 759 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 770 -msgid "Custom" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 210 -msgid "Frames per second" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 220 -msgid "Bandwidth Limit (Mbps)" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 221 -msgid "Max bitrate (0 = Unlimited)" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 231 -msgid "Hardware and Capture" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 232 -msgid "Monitor, GPU, and Capture Method" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 236 -msgid "Monitor / Display" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 237 -msgid "Select the display to capture" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 245 -msgid "Graphics Card / Encoder" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 246 -msgid "Choose hardware for video encoding" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 254 -msgid "Capture Method" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 255 -msgid "Wayland (recommended), X11 (legacy), or KMS (direct)" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 257 -msgid "KMS (Direct)" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 266 -msgid "Efficient Codecs (HEVC/AV1)" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 267 -msgid "Enable H.265/AV1 for better quality at lower bitrate (Requires " - "support)" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 272 -msgid "Optimization Mode" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 273 -msgid "Balance between responsiveness and image quality" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 275 -msgid "Low Latency (Fastest)" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 276 -msgid "Balanced (Default)" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 277 -msgid "High Quality (Best Image)" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 283 -msgid "Wi-Fi / Unstable Network Mode" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 284 -msgid "Increases error correction (FEC) to prevent glitches" -msgstr "" - -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 292 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 424 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 142 -msgid "Audio" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 293 -msgid "Sound settings" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 297 -msgid "Host Audio Output" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 298 -msgid "Where YOU will hear the game sound" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 305 -msgid "Audio Output Mode" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 306 -msgid "Determine where the game sound will be played" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 313 -msgid "Guest + Host" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 323 -msgid "Audio Mixer (Sources)" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 324 -msgid "Manage audio sources" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 334 -msgid "Input, Network, and Access" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 338 -msgid "Automatic UPnP" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 339 -msgid "Automatically configure router ports" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 344 -msgid "Address Family (IPv4 + IPv6)" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 345 -msgid "Enable simultaneous IPv4 and IPv6 support on server" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 350 -msgid "Origin Web UI Allowed (WAN)" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 351 -msgid "Allows anyone to access the web interface (Anyone may access Web UI)" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 356 -msgid "Configure Firewall (IPv6)" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 357 -msgid "Open TCP/UDP ports required for external connection" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 360 -msgid "Configure" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 382 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1012 -msgid "Start Server" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 389 -msgid "Configure Sunshine" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 397 -msgid "Enter PIN" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 414 -msgid "Information" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 419 -msgid "Game" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 429 -msgid "Use PIN Code to Connect" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 430 -msgid "Share this with the guest. Local network is required." -msgstr "" - -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 433 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 454 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 84 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 452 -msgid "PIN Code" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 444 -msgid "Copy PIN" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 646 -msgid "Insert PIN" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 647 -msgid "Enter the PIN displayed on the client device (Moonlight)." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 654 -msgid "PIN" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 656 -msgid "Device Name" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 665 -msgid "Save Password" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 666 -msgid "Save credentials to Sunshine preferences" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 678 -msgid "Send" -msgstr "" - -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 700 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 763 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 803 -# File: big-remote-play/usr/share/big-remote-play/host/sunshine_manager.py, line: 314 -msgid "PIN sent successfully" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 702 -msgid "Authentication Failed" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 702 -msgid "Invalid username or password." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 707 -msgid "PIN Error" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 714 -msgid "User Not Found" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 715 -msgid "No user has been created in Sunshine. It is necessary to configure a " - "user through the browser." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 719 -msgid "Open Configuration" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 733 -msgid "Create Sunshine User" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 734 -msgid "Define a username and password for Sunshine." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 739 -msgid "New User" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 741 -msgid "New Password" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 747 -msgid "Save and Continue" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 758 -msgid "User created!" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 764 -msgid "Error sending PIN after creation" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 766 -msgid "Error creating user" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 777 -msgid "Sunshine Authentication" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 778 -msgid "Sunshine requires login. Enter your credentials (default: admin / " - "password created during installation)." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 783 -msgid "Username" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 785 -msgid "Password" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 791 -msgid "Confirm" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 807 -msgid "Failed with credentials" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 814 -msgid "Server Information" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 844 -msgid "System Default" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 857 -msgid "Error loading audio" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 876 -msgid "Output changed to: {}" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 881 -msgid "Configuring firewall... (Password may be requested)" -msgstr "" +#: src/big_remote_play/ui/main_window.py:1083 +msgid "Start" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 899 -msgid "Success: {}" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 900 -msgid "Firewall Error" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 900 -msgid "Execution failed or cancelled." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 912 -msgid "Error executing script: {}" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 951 -msgid "Clicked Start Server..." -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 966 -msgid "Server Active - Guests can now connect" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 967 -msgid "Active - Waiting for Connections" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 993 -msgid "Inactive" -msgstr "" - -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1149 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1153 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1176 -msgid "Host + Guest" -msgstr "" +#: src/big_remote_play/ui/main_window.py:1088 +msgid "Restart" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1149 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1153 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1176 -msgid "Host Only" -msgstr "" +#: src/big_remote_play/ui/main_window.py:1093 +msgid "Disable" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1300 -msgid "Host output restored: {}" -msgstr "" +#: src/big_remote_play/ui/main_window.py:1093 +msgid "Enable" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1305 -msgid "Failed to create Virtual Audio" -msgstr "" +#: src/big_remote_play/ui/main_window.py:1106 +msgid "Private Network Containers" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1342 -msgid "Server started" -msgstr "" +#: src/big_remote_play/ui/main_window.py:1113 +msgid "Running (Caddy + Headscale)" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1386 -msgid "Opening Steam Big Picture..." -msgstr "" +#: src/big_remote_play/ui/main_window.py:1117 +msgid "Stop Containers" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1399 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1418 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1431 -msgid "Launching {}..." -msgstr "" +#: src/big_remote_play/ui/main_window.py:1117 +msgid "Start Containers" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1403 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1435 -msgid "Error launching game: {}" -msgstr "" +#: src/big_remote_play/ui/main_window.py:1122 +msgid "Restart Containers" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1463 -msgid "Stopping server..." -msgstr "" +#: src/big_remote_play/ui/moonlight_preferences.py:25 +msgid "Basic Settings" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1498 -msgid "Server stopped" -msgstr "" +#: src/big_remote_play/ui/moonlight_preferences.py:54 +msgid "Video Bitrate" +msgstr "" + +#: src/big_remote_play/ui/moonlight_preferences.py:76 +msgid "V-Sync" +msgstr "" + +#: src/big_remote_play/ui/moonlight_preferences.py:76 +msgid "Visual Synchronization" +msgstr "" + +#: src/big_remote_play/ui/moonlight_preferences.py:77 +msgid "Frame Pacing" +msgstr "" + +#: src/big_remote_play/ui/moonlight_preferences.py:77 +msgid "Improve smoothness" +msgstr "" + +#: src/big_remote_play/ui/moonlight_preferences.py:81 +msgid "Input Settings" +msgstr "" + +#: src/big_remote_play/ui/moonlight_preferences.py:83 +msgid "Optimize mouse for Remote Desktop" +msgstr "" + +#: src/big_remote_play/ui/moonlight_preferences.py:84 +msgid "Capture system shortcuts" +msgstr "" + +#: src/big_remote_play/ui/moonlight_preferences.py:85 +msgid "Use touchscreen as virtual trackpad" +msgstr "" + +#: src/big_remote_play/ui/moonlight_preferences.py:86 +msgid "Invert mouse left/right buttons" +msgstr "" + +#: src/big_remote_play/ui/moonlight_preferences.py:87 +msgid "Invert scroll wheel direction" +msgstr "" + +#: src/big_remote_play/ui/moonlight_preferences.py:91 +msgid "Audio Settings" +msgstr "" + +#: src/big_remote_play/ui/moonlight_preferences.py:95 +msgid "Audio Configuration" +msgstr "" + +#: src/big_remote_play/ui/moonlight_preferences.py:97 +msgid "Stereo" +msgstr "" + +#: src/big_remote_play/ui/moonlight_preferences.py:105 +msgid "Mute host PC speakers while streaming" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1507 -msgid "Sunshine stopped unexpectedly" -msgstr "" +#: src/big_remote_play/ui/moonlight_preferences.py:106 +msgid "Mute audio when Moonlight is not the active window" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1557 -msgid "Check logs for details." -msgstr "" +#: src/big_remote_play/ui/moonlight_preferences.py:110 +msgid "Controller Settings" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1559 -msgid "Sunshine failed to start.\n" - "\n" - "Error: {}\n" - "\n" - "If this is a dependency issue (missing libraries), try the 'Fix " - "Dependencies' button." -msgstr "" +#: src/big_remote_play/ui/moonlight_preferences.py:112 +msgid "Swap A/B and X/Y buttons" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1563 -msgid "Server Failed to Start" -msgstr "" +#: src/big_remote_play/ui/moonlight_preferences.py:113 +msgid "Force controller #1 always connected" +msgstr "" + +#: src/big_remote_play/ui/moonlight_preferences.py:114 +msgid "Enable mouse control with gamepad" +msgstr "" + +#: src/big_remote_play/ui/moonlight_preferences.py:115 +msgid "Process controller input in background" +msgstr "" + +#: src/big_remote_play/ui/moonlight_preferences.py:119 +msgid "Host Settings" +msgstr "" + +#: src/big_remote_play/ui/moonlight_preferences.py:121 +msgid "Optimize game settings for streaming" +msgstr "" + +#: src/big_remote_play/ui/moonlight_preferences.py:122 +msgid "Quit app on host after streaming ends" +msgstr "" + +#: src/big_remote_play/ui/moonlight_preferences.py:130 +msgid "Video Decoder" +msgstr "" + +#: src/big_remote_play/ui/moonlight_preferences.py:132 +#: src/big_remote_play/ui/moonlight_preferences.py:143 +msgid "Automatic (Recommended)" +msgstr "" + +#: src/big_remote_play/ui/moonlight_preferences.py:132 +msgid "Hardware" +msgstr "" + +#: src/big_remote_play/ui/moonlight_preferences.py:132 +#: src/big_remote_play/ui/sunshine_preferences.py:88 +msgid "Software" +msgstr "" + +#: src/big_remote_play/ui/moonlight_preferences.py:141 +msgid "Video Codec" +msgstr "" + +#: src/big_remote_play/ui/moonlight_preferences.py:151 +msgid "Enable HDR (Experimental)" +msgstr "" + +#: src/big_remote_play/ui/moonlight_preferences.py:152 +msgid "Enable YUV 4:4:4 (Experimental)" +msgstr "" + +#: src/big_remote_play/ui/moonlight_preferences.py:153 +msgid "Unlock bitrate limit (Experimental)" +msgstr "" + +#: src/big_remote_play/ui/moonlight_preferences.py:154 +msgid "Discover PCs automatically" +msgstr "" + +#: src/big_remote_play/ui/moonlight_preferences.py:155 +msgid "Check for blocked connections automatically" +msgstr "" + +#: src/big_remote_play/ui/moonlight_preferences.py:156 +msgid "Show performance stats while streaming" +msgstr "" + +#: src/big_remote_play/ui/moonlight_preferences.py:160 +msgid "UI Settings" +msgstr "" + +#: src/big_remote_play/ui/moonlight_preferences.py:162 +msgid "Show connection quality warnings" +msgstr "" + +#: src/big_remote_play/ui/moonlight_preferences.py:163 +msgid "Keep display active during streaming" +msgstr "" + +#: src/big_remote_play/ui/performance_monitor.py:176 +msgid "Waiting for data..." +msgstr "" + +#: src/big_remote_play/ui/performance_monitor.py:208 +#, python-brace-format +msgid "{} Active Devices" +msgstr "" + +#: src/big_remote_play/ui/performance_monitor.py:332 +#: src/big_remote_play/ui/performance_monitor.py:870 +msgid "Real-time Monitoring" +msgstr "" + +#: src/big_remote_play/ui/performance_monitor.py:499 +msgid "Disconnect Guest" +msgstr "" + +#: src/big_remote_play/ui/performance_monitor.py:500 +msgid "" +"End the running game gently, or forcefully evict the network address? Ending " +"the game keeps the device paired." +msgstr "" + +#: src/big_remote_play/ui/performance_monitor.py:508 +msgid "End Game" +msgstr "" + +#: src/big_remote_play/ui/performance_monitor.py:511 +msgid "Evict IP" +msgstr "" + +#: src/big_remote_play/ui/performance_monitor.py:798 +msgid "Active Connection" +msgstr "" + +#: src/big_remote_play/ui/performance_monitor.py:800 +#, python-brace-format +msgid "{} devices monitoring" +msgstr "" + +#: src/big_remote_play/ui/performance_monitor.py:803 +msgid "Active - No devices" +msgstr "" + +#: src/big_remote_play/ui/performance_monitor.py:860 +msgid "Disconnect this specific guest (Admin)" +msgstr "" + +#: src/big_remote_play/ui/performance_monitor.py:862 +msgid "Disconnect guest" +msgstr "" + +#: src/big_remote_play/ui/performance_monitor.py:869 +#, python-brace-format +msgid "Connected to {}" +msgstr "" + +#: src/big_remote_play/ui/preferences.py:40 +#: src/big_remote_play/ui/sunshine_preferences.py:76 +#: src/big_remote_play/ui/sunshine_preferences.py:103 +msgid "General" +msgstr "" + +#: src/big_remote_play/ui/preferences.py:45 +msgid "Appearance" +msgstr "" + +#: src/big_remote_play/ui/preferences.py:49 +msgid "Theme" +msgstr "" + +#: src/big_remote_play/ui/preferences.py:50 +msgid "Choose the color scheme" +msgstr "" + +#: src/big_remote_play/ui/preferences.py:54 +msgid "Light" +msgstr "" + +#: src/big_remote_play/ui/preferences.py:55 +msgid "Dark" +msgstr "" + +#: src/big_remote_play/ui/preferences.py:79 +msgid "Reset all settings to default" +msgstr "" + +#: src/big_remote_play/ui/preferences.py:81 +msgid "Restaurar" +msgstr "" + +#: src/big_remote_play/ui/preferences.py:89 +msgid "Clear Everything" +msgstr "" + +#: src/big_remote_play/ui/preferences.py:90 +msgid "Remove logs, settings, servers and saved data" +msgstr "" + +#: src/big_remote_play/ui/preferences.py:92 +msgid "Limpar Tudo" +msgstr "" + +#: src/big_remote_play/ui/preferences.py:109 +msgid "Paths" +msgstr "" + +#: src/big_remote_play/ui/preferences.py:112 +msgid "Configuration Directory" +msgstr "" + +#: src/big_remote_play/ui/preferences.py:119 +msgid "Copiar Caminho" +msgstr "" + +#: src/big_remote_play/ui/preferences.py:128 +msgid "Logs and Debugging" +msgstr "" + +#: src/big_remote_play/ui/preferences.py:131 +msgid "Detailed Logs" +msgstr "" + +#: src/big_remote_play/ui/preferences.py:132 +msgid "Enable verbose logging for debugging" +msgstr "" + +#: src/big_remote_play/ui/preferences.py:137 +msgid "Clear Logs" +msgstr "" + +#: src/big_remote_play/ui/preferences.py:138 +msgid "Remove old log files" +msgstr "" + +#: src/big_remote_play/ui/preferences.py:140 +msgid "Limpar" +msgstr "" + +#: src/big_remote_play/ui/preferences.py:185 +msgid "Logs Limpos" +msgstr "" + +#: src/big_remote_play/ui/preferences.py:185 +msgid "Arquivos de log antigos foram removidos." +msgstr "" + +#: src/big_remote_play/ui/preferences.py:195 +msgid "Path copied!" +msgstr "" + +#: src/big_remote_play/ui/preferences.py:200 +msgid "" +"All your changes in Big Remote Play will be restored! This includes Sunshine " +"and Moonlight settings." +msgstr "" + +#: src/big_remote_play/ui/preferences.py:202 +msgid "Restore Defaults?" +msgstr "" + +#: src/big_remote_play/ui/preferences.py:234 +msgid "Settings restored! Restart the application to apply all changes." +msgstr "" + +#: src/big_remote_play/ui/preferences.py:238 +#, python-brace-format +msgid "Error restoring: {}" +msgstr "" + +#: src/big_remote_play/ui/preferences.py:245 +msgid "Clear EVERYTHING?" +msgstr "" + +#: src/big_remote_play/ui/preferences.py:245 +msgid "" +"WARNING: This will delete ALL data, logs, settings and saved servers. The " +"application will close." +msgstr "" + +#: src/big_remote_play/ui/preferences.py:247 +msgid "Delete All" +msgstr "" + +#: src/big_remote_play/ui/preferences.py:254 +msgid "Are You Absolutely Sure?" +msgstr "" + +#: src/big_remote_play/ui/preferences.py:254 +msgid "This action is IRREVERSIBLE. You will lose all configured data." +msgstr "" + +#: src/big_remote_play/ui/preferences.py:256 +msgid "Yes, Delete All" +msgstr "" + +#: src/big_remote_play/ui/preferences.py:299 +msgid "Error Clearing" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:41 +msgid "Create Headscale Server" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:42 +msgid "Set up your own private VPN server using Docker + Cloudflare DNS." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:43 +msgid "Connect to Headscale Network" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:44 +msgid "Enter the server domain and auth key provided by the administrator." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:51 +msgid "Login to Tailscale" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:52 +msgid "Login to your Tailscale account. No server required." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:53 +msgid "Connect to Tailscale Network" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:54 +msgid "Enter your auth key or login server to join a Tailscale network." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:61 +msgid "Create ZeroTier Network" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:62 +msgid "Create a new ZeroTier virtual network using your API token." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:63 +msgid "Connect to ZeroTier Network" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:64 +msgid "Enter the 16-character Network ID to join a ZeroTier network." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:370 +msgid "Configuration" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:403 +#: src/big_remote_play/ui/private_network_view.py:1331 +msgid "Instructions" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:409 +msgid "Logout / Disconnect" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:421 +msgid "Your Networks" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:435 +msgid "Login / Connect" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:437 +msgid "Save Token & Create Network" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:438 +msgid "Install Server" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:454 +msgid "Domain (e.g. vpn.ruscher.org)" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:455 +msgid "Cloudflare Zone ID" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:456 +msgid "Cloudflare API Token" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:464 +msgid "Auth Key (optional – leave empty for browser login)" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:467 +msgid "Get auth key" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:467 +msgid "login.tailscale.com/admin/settings/keys" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:469 +#: src/big_remote_play/ui/private_network_view.py:489 +#: src/big_remote_play/ui/private_network_view.py:1459 +msgid "Open" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:479 +#: src/big_remote_play/ui/private_network_view.py:1493 +msgid "ZeroTier API Token" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:482 +msgid "Network Name" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:487 +msgid "Get API Token" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:487 +msgid "my.zerotier.com → Account → API Access Tokens" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:559 +msgid "All fields are required" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:568 +msgid "✅ Server installed!" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:572 +msgid "Headscale server installed!" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:575 +#: src/big_remote_play/ui/private_network_view.py:598 +#: src/big_remote_play/ui/private_network_view.py:632 +#: src/big_remote_play/ui/private_network_view.py:1648 +msgid "❌ Failed" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:576 +msgid "Installation failed. Check the log." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:591 +#: src/big_remote_play/ui/private_network_view.py:1624 +msgid "✅ Connected!" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:595 +msgid "Tailscale connected!" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:599 +msgid "Login failed" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:607 +msgid "API Token is required" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:613 +#: src/big_remote_play/ui/private_network_view.py:1533 +msgid "System keyring is unavailable. Token was not saved." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:622 +msgid "✅ Network created!" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:628 +msgid "ZeroTier network created!" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:633 +msgid "Network creation failed" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:651 +#: src/big_remote_play/ui/private_network_view.py:659 +#: src/big_remote_play/ui/private_network_view.py:2069 +#: src/big_remote_play/ui/private_network_view.py:2077 +msgid "Setup Instructions" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:659 +msgid "Step-by-step guide to create your private network" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:678 +msgid "1. Register a Free Domain" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:679 +msgid "Get a free .us.kg domain to use with your server" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:682 +msgid "Access DigitalPlat Domain" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:683 +msgid "Register and get your free domain (e.g.: myserver.us.kg)" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:686 +#: src/big_remote_play/ui/private_network_view.py:899 +msgid "Open Site" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:695 +msgid "Steps at DigitalPlat" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:696 +msgid "" +"1. Create an account or login\n" +"2. Choose a .us.kg domain\n" +"3. Complete the simple registration\n" +"4. Write down the domain you obtained (e.g.: myserver.us.kg)" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:706 +msgid "2. Configure Cloudflare" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:707 +msgid "Point your domain to Cloudflare for DNS management" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:710 +msgid "Access Cloudflare Dashboard" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:711 +msgid "Create a free account and add your domain" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:714 +msgid "Open Cloudflare" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:723 +msgid "Setup Steps" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:726 +msgid "" +"1. Click 'Add a site' → Enter your domain\n" +"2. Choose the FREE plan\n" +"3. Write down the 2 nameservers provided\n" +"4. Go back to your domain provider (DigitalPlat)\n" +"5. Replace the DNS with Cloudflare's nameservers\n" +"6. Wait for DNS propagation (may take a few minutes)" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:739 +msgid "Update Nameservers" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:740 +msgid "Open domain panel to change NS records" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:743 +msgid "Domain Panel" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:759 +msgid "3. Get API Credentials" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:760 +msgid "Obtain your Zone ID and API Token from Cloudflare" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:763 +msgid "Zone ID" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:764 +msgid "" +"1. In Cloudflare, click on your domain\n" +"2. Scroll down to the 'API' section on the right\n" +"3. Copy the 'Zone ID' value" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:769 +#: src/big_remote_play/ui/private_network_view.py:1506 +msgid "API Token" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:772 +msgid "" +"1. Click 'Get your API token' (below Zone ID)\n" +"2. Click 'Create Token'\n" +"3. Use template: 'Edit zone DNS' → 'Use template'\n" +"4. Configure:\n" +" • Token name: VPN-Token\n" +" • Permissions: Zone - DNS - Edit\n" +" • Zone: Select your domain\n" +"5. Click 'Continue to summary' → 'Create Token'\n" +"6. Copy token IMMEDIATELY (shown only once!)" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:792 +msgid "4. Configure DNS Records in Cloudflare" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:793 +msgid "Create DNS records pointing to your public IP" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:797 +msgid "Your Public IP" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:798 +msgid "Use this IP for the A record below" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:801 +msgid "Loading..." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:836 +msgid "A Record" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:837 +msgid "" +"In Cloudflare → DNS → Records → 'Add record':\n" +"• Type: A\n" +"• Name: @\n" +"• Content: YOUR-PUBLIC-IP (shown above)\n" +"• Proxy: OFF (gray cloud — IMPORTANT!)" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:842 +msgid "CNAME Record" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:843 +msgid "" +"Add another record:\n" +"• Type: CNAME\n" +"• Name: www\n" +"• Target: @\n" +"• Proxy: OFF" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:853 +msgid "5. Configure Router (Port Forwarding)" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:854 +msgid "" +"Open ports 80, 443, and 41641. The installation script will attempt to " +"configure these automatically via UPnP." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:857 +msgid "Access Your Router" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:858 +msgid "" +"1. Open your browser and go to 192.168.1.1 (or your router's IP)\n" +"2. Find 'Port Forwarding' or 'NAT' settings" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:863 +msgid "TLS certificate challenge and HTTP redirect" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:864 +msgid "Secure Headscale API and web interface" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:865 +msgid "Peer-to-peer VPN data" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:870 +msgid "Forward to your local IP" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:875 +msgid "Find your local IP" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:876 +msgid "Run 'hostname -I' in a terminal to find your local IP address" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:892 +#: src/big_remote_play/ui/private_network_view.py:2128 +msgid "Tailscale Instructions" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:895 +#: src/big_remote_play/ui/private_network_view.py:918 +#: src/big_remote_play/ui/private_network_view.py:2131 +msgid "1. Create Account" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:896 +msgid "Tailscale Registration" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:897 +msgid "Create your free account to start managing your VPN mesh." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:902 +msgid "2. Login on Host" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:902 +msgid "Link this device" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:902 +msgid "" +"Use the 'Login / Connect' button on the previous tab to authorize this PC." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:904 +msgid "3. Admin Panel" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:905 +msgid "Manage Machines" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:906 +msgid "" +"View and authorize the machines connected to your network in the official " +"panel." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:908 +msgid "Open Panel" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:916 +#: src/big_remote_play/ui/private_network_view.py:2145 +msgid "ZeroTier Instructions" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:918 +msgid "Access ZeroTier Central" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:918 +msgid "Sign up to create and manage your virtual networks." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:918 +msgid "Open Portal" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:920 +msgid "2. Authentication" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:921 +msgid "Generate API Token" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:922 +msgid "Go to 'Account Settings' and create a new 'API Access Token'." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:924 +msgid "Generate Token" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:927 +msgid "3. Configuration" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:927 +msgid "Link Network" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:927 +msgid "" +"Paste the Token in the matching field and click 'Save Token & Create " +"Network'." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:929 +msgid "4. Administration" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:930 +msgid "Authorize Members" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:931 +msgid "Click your network's Network ID to manage and authorize new devices." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:933 +msgid "My Networks" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:951 +#: src/big_remote_play/ui/private_network_view.py:2172 +msgid "Step-by-step guide" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1002 +msgid "Disconnecting..." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1009 +msgid "Tailscale disconnected" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1023 +msgid "ZeroTier token removed" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1036 +msgid "Disconnected from Headscale" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1095 +msgid "No networks found" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1095 +msgid "Connect or create a network first" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1104 +msgid "This device" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1120 +msgid "Installation Log" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1142 +msgid "🎉 Success! Share with friends:" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1144 +#: src/big_remote_play/ui/private_network_view.py:1193 +#: src/big_remote_play/ui/private_network_view.py:1865 +#: src/big_remote_play/ui/private_network_view.py:1965 +msgid "Domain" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1145 +#: src/big_remote_play/ui/private_network_view.py:1193 +#: src/big_remote_play/ui/private_network_view.py:1866 +#: src/big_remote_play/ui/private_network_view.py:1987 +msgid "Network ID" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1146 +msgid "Auth Key (Friends)" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1147 +msgid "Web Interface" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1174 +msgid "Save Network Information" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1185 +msgid "Saved to file!" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1192 +msgid "" +"VPN Network Details:\n" +"\n" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1193 +#: src/big_remote_play/ui/private_network_view.py:1443 +#: src/big_remote_play/ui/private_network_view.py:1449 +#: src/big_remote_play/ui/private_network_view.py:1867 +#: src/big_remote_play/ui/private_network_view.py:1970 +#: src/big_remote_play/ui/private_network_view.py:1981 +msgid "Auth Key" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1201 +msgid "Opening mail client..." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1206 +msgid "Saved to history!" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1211 +msgid "Save to File" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1219 +msgid "Share" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1234 +msgid "Network Information" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1273 +#: src/big_remote_play/ui/private_network_view.py:1401 +msgid "Status" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1273 +#: src/big_remote_play/ui/private_network_view.py:1415 +msgid "Previous Networks" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1283 +#: src/big_remote_play/ui/private_network_view.py:1959 +msgid "Connection Details" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1288 +msgid "What you'll need" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1291 +msgid "Server domain" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1291 +msgid "The address of your VPN server, e.g. vpn.yourcompany.com." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1292 +msgid "Auth key" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1292 +msgid "A key generated on the server to connect this device." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1293 +msgid "Working internet" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1293 +msgid "An internet connection is required to establish the link." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1306 +msgid "" +"Your credentials stay only on this device and are used " +"exclusively to authenticate on the private network." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1319 +#: src/big_remote_play/ui/private_network_view.py:1625 +#: src/big_remote_play/ui/private_network_view.py:2139 +msgid "Establish Connection" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1350 +msgid "Network Devices" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1363 +msgid "Set API Token to see all network devices" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1365 +msgid "Set Token" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1371 +msgid "Auth" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1371 +msgid "ID" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1371 +msgid "Name" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1371 +msgid "Managed IP" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1371 +msgid "Last Seen" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1371 +msgid "Version" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1371 +msgid "Physical IP" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1388 +msgid "Set ZeroTier API Token" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1394 +msgid "Create API Access Token" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1442 +msgid "Server Domain (e.g. vpn.ruscher.org)" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1448 +msgid "Login Server (leave empty for tailscale.com)" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1454 +msgid "Network ID (16 characters)" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1455 +msgid "e.g. a1b2c3d4e5f6a7b8" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1457 +msgid "Find Network ID" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1457 +msgid "my.zerotier.com → Networks" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1502 +msgid "Enter your API Token to view managed devices." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1518 +msgid "Save & Refresh" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1530 +msgid "Token saved in the system keyring" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1550 +msgid "Connecting..." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1558 +msgid "Domain and Auth Key required" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1567 +msgid "Auth Key is required" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1578 +msgid "Network ID is required" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1645 +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:497 +msgid "Connected successfully!" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1649 +msgid "Try Again" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1650 +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:476 +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:518 +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:583 +msgid "Connection failed" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1686 +msgid "Just now" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1701 +msgid "Self" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1709 +msgid "Offline" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1779 +msgid "Peer" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1791 +msgid "API Token Missing" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1791 +msgid "See banner above" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1793 +msgid "No devices found" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1793 +msgid "Try refreshing..." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1808 +msgid "No previous networks" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1808 +msgid "Your created/connected networks will appear here." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1841 +msgid "Reconnect" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1849 +msgid "Edit" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1858 +#: src/big_remote_play/ui/private_network_view.py:2040 +msgid "Delete" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1868 +#: src/big_remote_play/ui/private_network_view.py:1994 +msgid "Web UI" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1869 +msgid "VPN" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1875 +msgid "Stored in system keyring" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1919 +#: src/big_remote_play/ui/private_network_view.py:1935 +#, python-brace-format +msgid "Form filled for {}" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1944 +#, python-brace-format +msgid "Edit – {}" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1949 +msgid "Edit Network Entry" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1976 +msgid "Login Server" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:2023 +msgid "Entry updated" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:2038 +msgid "Delete entry?" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:2038 +msgid "Remove this network from history?" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:2054 +msgid "Connection Log" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:2077 +msgid "Step-by-step guide to join a private network" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:2096 +msgid "Steps to join Headscale" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:2099 +msgid "Step 1: Get credentials" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:2100 +msgid "Ask the network administrator for the Server Domain and Auth Key." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:2105 +msgid "Step 2: Enter details" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:2106 +msgid "Paste the Domain and Key in the fields on the previous tab." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:2111 +msgid "Step 3: Connect" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:2112 +msgid "Click 'Establish Connection' and wait for the success message." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:2132 +msgid "Access Tailscale" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:2133 +msgid "All participants need an account (Google, GitHub, etc)." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:2135 +msgid "Create Account" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:2138 +msgid "2. Invitation" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:2138 +msgid "Request Access" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:2138 +msgid "" +"The administrator must share the node or invite your email to the network." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:2139 +msgid "3. Connect" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:2139 +msgid "Once the invitation is accepted, use the previous tab to connect." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:2147 +msgid "1. Identification" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:2147 +msgid "Get Network ID" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:2147 +msgid "Request the 16-character ID from the network owner." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:2148 +msgid "2. Join" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:2148 +msgid "Enter ID" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:2148 +msgid "Enter the ID on the 'Connect' tab and click 'Establish Connection'." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:2150 +msgid "3. Authorization" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:2151 +msgid "Wait for Approval" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:2152 +msgid "" +"The administrator must check the 'Auth' option for your PC in their panel." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:2154 +msgid "ZT Panel" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:76 +msgid "Server general settings" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:77 +msgid "Input" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:77 +msgid "Keyboard, mouse and gamepad" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:78 +msgid "Audio/Video" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:78 +msgid "Resolution, bitrate and quality" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:79 +msgid "Network" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:79 +msgid "Ports and connectivity" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:80 +#: src/big_remote_play/ui/sunshine_preferences.py:99 +#: src/big_remote_play/ui/sunshine_preferences.py:114 +msgid "Config Files" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:80 +msgid "Configuration files and logs" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:81 +msgid "Advanced options" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:83 +msgid "NVIDIA NVENC" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:83 +msgid "NVIDIA Encoder" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:84 +msgid "Intel QuickSync" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:84 +msgid "Intel Encoder" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:85 +msgid "AMD AMF" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:85 +msgid "AMD Encoder" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:86 +msgid "VideoToolbox" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:86 +msgid "Apple Encoder" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:87 +msgid "VA-API" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:87 +msgid "VA-API Encoder (Linux)" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:88 +msgid "CPU Encoding" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:117 +msgid "No options available" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:240 +msgid "Apps File" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:240 +msgid "The file where current apps of Sunshine are stored." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:241 +msgid "Credentials File" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:241 +msgid "Store Username/Password separately from Sunshine's state file." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:242 +msgid "Logfile Path" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:242 +msgid "The file where the current logs of Sunshine are stored." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:244 +msgid "Private Key" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:246 +msgid "" +"The private key used for the web UI and Moonlight client pairing. For best " +"compatibility, this should be an RSA-2048 private key." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:249 +msgid "Certificate" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:251 +msgid "" +"The certificate used for the web UI and Moonlight client pairing. For best " +"compatibility, this should have an RSA-2048 public key." +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1566 -msgid "View Logs" -msgstr "" +#: src/big_remote_play/ui/sunshine_preferences.py:253 +msgid "State File" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1816 -msgid "Restore Defaults" -msgstr "" +#: src/big_remote_play/ui/sunshine_preferences.py:253 +msgid "The file where current state of Sunshine is stored" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1816 -msgid "Do you want to restore default settings?" -msgstr "" +#: src/big_remote_play/ui/sunshine_preferences.py:254 +msgid "Configuration File" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1817 -msgid "Restore" -msgstr "" +#: src/big_remote_play/ui/sunshine_preferences.py:254 +msgid "The main configuration file for Sunshine." +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1825 -msgid "Settings Restored" -msgstr "" +#: src/big_remote_play/ui/sunshine_preferences.py:304 +msgid "Sunshine Runtime" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 39 -msgid "Detecting bandwidth..." -msgstr "" +#: src/big_remote_play/ui/sunshine_preferences.py:306 +msgid "Sunshine is available." +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 45 -msgid "Suggested bitrate: {} Mbps" -msgstr "" +#: src/big_remote_play/ui/sunshine_preferences.py:309 +msgid "Sunshine is not available." +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 59 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 172 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 191 -msgid "Find and connect to the host using the options below." -msgstr "" +#: src/big_remote_play/ui/sunshine_preferences.py:317 +msgid "Apply to Running Server" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 66 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 937 -msgid "Shortcuts & Instructions" -msgstr "" +#: src/big_remote_play/ui/sunshine_preferences.py:318 +msgid "Push these settings to Sunshine and restart it (no manual stop)." +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 78 -msgid "Discover" -msgstr "" +#: src/big_remote_play/ui/sunshine_preferences.py:327 +msgid "Reload from Running Server" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 81 -msgid "Manual" -msgstr "" +#: src/big_remote_play/ui/sunshine_preferences.py:328 +msgid "Read the server's current configuration into these settings." +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 96 -msgid "Client Settings" -msgstr "" +#: src/big_remote_play/ui/sunshine_preferences.py:329 +msgid "Reload" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 105 -msgid "Native Resolution (Adaptive)" -msgstr "" +#: src/big_remote_play/ui/sunshine_preferences.py:353 +msgid "Sunshine is not running. Settings are saved to file." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:365 +msgid "Settings applied; server restarting." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:365 +msgid "Failed to apply settings (check credentials)." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:371 +msgid "Sunshine is not running." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:385 +msgid "Could not read server configuration." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:392 +msgid "Configuration reloaded. Reopen preferences to see updated values." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:399 +msgid "Locale" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:403 +msgid "The locale used for Sunshine's user interface." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:405 +msgid "Sunshine Name" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:405 +msgid "The name of the Sunshine instance as seen by clients." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:406 +msgid "Username for API access (Monitoring)" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:407 +msgid "Password for API access (Monitoring)" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:410 +msgid "Log Level" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:414 +msgid "The minimum log level printed to standard out" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:416 +msgid "Pre-Release Notifications" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:416 +msgid "Whether to be notified of new pre-release versions of Sunshine" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:417 +msgid "Enable System Tray" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:417 +msgid "Show icon in system tray and display desktop notifications" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:422 +msgid "UPnP" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:422 +msgid "Automatically configure port forwarding for streaming over the Internet" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:423 +msgid "Address Family" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:423 +msgid "Set the address family used by Sunshine" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:424 +msgid "Bind Address" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:424 +msgid "IP address to bind the service to" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:427 +msgid "Port (Moonlight)" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:431 +msgid "" +"Set the family of ports used by Sunshine.\n" +"TCP: 47984, 47989, 48010\n" +"UDP: 47998 - 48012\n" +"The Web UI port stays local by default." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:435 +msgid "Web UI Access" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:439 +msgid "" +"The origin of the remote endpoint address that is not denied access to Web " +"UI.\n" +"Warning: Exposing the Web UI to the internet is a security risk! Proceed at " +"your own risk!" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:441 +msgid "External IP" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:441 +msgid "" +"If no external IP address is given, Sunshine will automatically detect " +"external IP" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:444 +msgid "Trusted Web UI Origins" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:448 +msgid "" +"Comma-separated trusted HTTPS origins allowed to call Sunshine state-" +"changing API endpoints. Leave empty to use Sunshine's built-in localhost " +"defaults." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:452 +msgid "LAN Encryption" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:455 +#: src/big_remote_play/ui/sunshine_preferences.py:463 +#: src/big_remote_play/ui/sunshine_preferences.py:607 +#: src/big_remote_play/ui/sunshine_preferences.py:615 +#: src/big_remote_play/ui/sunshine_preferences.py:655 +#: src/big_remote_play/ui/sunshine_preferences.py:763 +msgid "Disabled" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:455 +#: src/big_remote_play/ui/sunshine_preferences.py:463 +msgid "Mode 1" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:455 +#: src/big_remote_play/ui/sunshine_preferences.py:463 +msgid "Mode 2" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:456 +msgid "" +"This determines when encryption will be used when streaming over your local " +"network. Encryption can reduce streaming performance, particularly on less " +"powerful hosts and clients." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:460 +msgid "WAN Encryption" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:464 +msgid "" +"This determines when encryption will be used when streaming over the " +"Internet. Encryption can reduce streaming performance, particularly on less " +"powerful hosts and clients." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:466 +msgid "Ping Timeout (ms)" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:466 +msgid "" +"How long to wait in milliseconds for data from Moonlight before shutting " +"down the stream" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:469 +msgid "Packet Size" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:473 +msgid "" +"Limit UDP packet size to avoid fragmentation on low-MTU VPN links. Use 0 for " +"Sunshine's default." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:479 +msgid "Enable Gamepad Input" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:479 +msgid "Allows guests to control the host system with a gamepad / controller" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:482 +msgid "Emulated Gamepad Type" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:486 +msgid "Choose which type of gamepad to emulate on the host" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:488 +msgid "Motion as DS4" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:488 +msgid "Emulate motion controls as DS4" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:489 +msgid "Touchpad as DS4" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:489 +msgid "Emulate touchpad as DS4" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:490 +msgid "Back Button as Touchpad Click" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:490 +msgid "Use Back button for touchpad click on DS4" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:491 +msgid "Randomize MAC (DS5)" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:491 +msgid "Randomize virtual MAC address for DS5" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:492 +msgid "Home/Guide Timeout (ms)" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:492 +msgid "Hold Back/Select to emulate Guide button. < 0 to disable." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:493 +msgid "Enable Keyboard Input" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:493 +msgid "Allows guests to control the host system with the keyboard" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:494 +msgid "Enable Mouse Input" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:494 +msgid "Allows guests to control the host system with the mouse" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:495 +msgid "Always Send Scancodes" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:495 +msgid "Always send raw key scancodes" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:496 +msgid "Map Right Alt to Windows" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:496 +msgid "Make Sunshine think the Right Alt key is the Windows key" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:497 +msgid "High Resolution Scrolling" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:497 +msgid "Pass through high resolution scroll events from Moonlight clients" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:530 +msgid "Auto / Primary" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:545 +msgid "Audio Sink" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:545 +msgid "" +"Listing all available audio sinks is possible by running `pactl list short " +"sinks` (PulseAudio) or `wpctl status` (PipeWire)." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:546 +msgid "Stream Audio" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:546 +msgid "" +"Whether to stream audio or not. Disabling this can be useful for streaming " +"headless displays as second monitors." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:547 +msgid "Graphics Adapter" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:547 +msgid "Specific GPU to use. Default is usually correct." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:550 +msgid "Display Name" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:554 +msgid "" +"Select the display monitor to capture. Corresponds to the output connector " +"name (e.g., DP-1, HDMI-A-1)." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:558 +msgid "Maximum Bitrate" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:562 +msgid "" +"The maximum bitrate (in Kbps) that Sunshine will encode the stream at. If " +"set to 0, it will always use the bitrate requested by Moonlight." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:566 +msgid "Minimum FPS Target" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:570 +msgid "" +"The lowest effective FPS a stream can reach. A value of 0 is treated as " +"roughly half of the stream's FPS. A setting of 20 is recommended if you " +"stream 24 or 30fps content." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:578 +msgid "FEC Percentage" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:582 +msgid "" +"Percentage of error correcting packets per data packet in each video frame. " +"Higher values can correct for more network packet loss, but at the cost of " +"increasing bandwidth usage." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:586 +msgid "Quantization Parameter" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:590 +msgid "" +"Some devices may not support Constant Bit Rate. For those devices, QP is " +"used instead. Higher value means more compression, but less quality." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:594 +msgid "Minimum CPU Thread Count" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:599 +msgid "" +"Increasing the value slightly reduces encoding efficiency, but the tradeoff " +"is usually worth it to gain the use of more CPU cores for encoding. The " +"ideal value is the lowest value that can reliably encode at your desired " +"streaming settings on your hardware." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:604 +msgid "HEVC Support" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:607 +#: src/big_remote_play/ui/sunshine_preferences.py:615 +msgid "Advertised (Recommended)" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:608 +msgid "" +"Allows the client to request HEVC Main or HEVC Main10 video streams. HEVC is " +"more CPU-intensive to encode, so enabling this may reduce performance when " +"using software encoding." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:612 +msgid "AV1 Support" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:616 +msgid "" +"Allows the client to request AV1 Main 8-bit or 10-bit video streams. AV1 is " +"more CPU-intensive to encode, so enabling this may reduce performance when " +"using software encoding." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:620 +msgid "Force a Specific Capture Method" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:623 +msgid "Autodetect (Recommended)" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:624 +msgid "" +"On automatic mode Sunshine will use the first one that works. NvFBC requires " +"patched nvidia drivers." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:628 +msgid "Force a Specific Encoder" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:631 +msgid "Autodetect" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:633 +msgid "" +"Force a specific encoder, otherwise Sunshine will select the best available " +"option. Note: If you specify a hardware encoder on Windows, it must match " +"the GPU where the display is connected." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:642 +msgid "Performance Preset" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:647 +msgid "" +"Higher numbers improve compression (quality at given bitrate) at the cost of " +"increased encoding latency. Recommended to change only when limited by " +"network or decoder, otherwise similar effect can be accomplished by " +"increasing bitrate." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:652 +msgid "Two-Pass Mode" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:655 +msgid "Quarter Resolution" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:655 +msgid "Full Resolution" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:657 +msgid "" +"Adds preliminary encoding pass. This allows to detect more motion vectors, " +"better distribute bitrate across the frame and more strictly adhere to " +"bitrate limits. Disabling it is not recommended since this can lead to " +"occasional bitrate overshoot and subsequent packet loss." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:660 +msgid "Spatial AQ" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:660 +msgid "" +"Assign higher QP values to flat regions of the video. Recommended to enable " +"when streaming at lower bitrates." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:663 +msgid "Single-frame VBV/HRD percentage increase" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:668 +msgid "" +"By default sunshine uses single-frame VBV/HRD, which means any encoded video " +"frame size is not expected to exceed requested bitrate divided by requested " +"frame rate. Relaxing this restriction can be beneficial and act as low-" +"latency variable bitrate, but may also lead to packet loss if the network " +"doesn't have buffer headroom to handle bitrate spikes. Maximum accepted " +"value is 400, which corresponds to 5x increased encoded video frame upper " +"size limit." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:673 +msgid "Prefer CAVLC over CABAC in H.264" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:677 +msgid "" +"Simpler form of entropy coding. CAVLC needs around 10% more bitrate for same " +"quality. Only relevant for really old decoding devices." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:683 +msgid "QuickSync Preset" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:683 +msgid "Performance preset" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:684 +msgid "QuickSync Coder (H264)" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:684 +#: src/big_remote_play/ui/sunshine_preferences.py:750 +#: src/big_remote_play/ui/sunshine_preferences.py:757 +#: src/big_remote_play/ui/sunshine_preferences.py:763 +msgid "Auto" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:684 +#: src/big_remote_play/ui/sunshine_preferences.py:757 +msgid "Entropy coding mode" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:685 +msgid "Allow Slow HEVC Encoding" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:685 +msgid "" +"This can enable HEVC encoding on older Intel GPUs, at the cost of higher GPU " +"usage and worse performance." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:692 +msgid "AMF Usage" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:703 +msgid "" +"This sets the base encoding profile. All options presented below will " +"override a subset of the usage profile, but there are additional hidden " +"settings applied that cannot be configured elsewhere." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:708 +msgid "AMF Rate Control" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:713 +msgid "" +"This controls the rate control method to ensure we are not exceeding the " +"client bitrate target. 'cqp' is not suitable for bitrate targeting, and " +"other options besides 'vbr_latency' depend on HRD Enforcement to help " +"constrain bitrate overflows." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:718 +msgid "AMF Hypothetical Reference Decoder (HRD) Enforcement" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:723 +msgid "" +"Increases the constraints on rate control to meet HRD model requirements. " +"This greatly reduces bitrate overflows, but may cause encoding artifacts or " +"reduced quality on certain cards." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:728 +msgid "AMF Quality" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:731 +msgid "Speed" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:731 +msgid "Balanced" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:731 +msgid "Quality" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:732 +msgid "This controls the balance between encoding speed and quality." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:734 +msgid "AMF Preanalysis" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:734 +msgid "" +"This enables rate-control preanalysis, which may increase quality at the " +"expense of increased encoding latency." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:737 +msgid "AMF Variance Based Adaptive Quantization (VBAQ)" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:742 +msgid "" +"The human visual system is typically less sensitive to artifacts in highly " +"textured areas. In VBAQ mode, pixel variance is used to indicate the " +"complexity of spatial textures, allowing the encoder to allocate more bits " +"to smoother areas. Enabling this feature leads to improvements in subjective " +"visual quality with some content." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:747 +msgid "AMF Coder (H264)" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:751 +msgid "" +"Allows you to select the entropy encoding to prioritize quality or encoding " +"speed. H.264 only." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:757 +msgid "VideoToolbox Coder" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:760 +msgid "VideoToolbox Software Encoding" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:763 +msgid "Allowed" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:763 +msgid "Forced" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:764 +msgid "Allow fallback to software encoding" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:766 +msgid "VideoToolbox Realtime Encoding" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:766 +msgid "Realtime encoding priority" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:773 +msgid "Strictly enforce frame bitrate limits for H.264/HEVC on AMD GPUs" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:777 +msgid "" +"Enabling this option can avoid dropped frames over the network during scene " +"changes, but video quality may be reduced during motion." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:785 +msgid "SW Presets" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:789 +msgid "" +"Optimize the trade-off between encoding speed (encoded frames per second) " +"and compression efficiency (quality per bit in the bitstream). Defaults to " +"superfast." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:793 +msgid "SW Tune" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:797 +msgid "" +"Tuning options, which are applied after the preset. Defaults to zerolatency." +msgstr "" + +#: src/big_remote_play/utils/audio.py:273 +#: src/big_remote_play/utils/audio.py:284 +#: src/big_remote_play/utils/system_check.py:130 +#: src/big_remote_play/utils/system_check.py:133 +#: src/big_remote_play/utils/system_check.py:158 +#: src/big_remote_play/utils/system_check.py:161 +msgid "Unknown" +msgstr "" + +#: src/big_remote_play/utils/network.py:97 +msgid " (IPv6 Local)" +msgstr "" + +#: src/big_remote_play/utils/network.py:99 +msgid " (IPv6 Global)" +msgstr "" + +#: src/big_remote_play/utils/network.py:148 +#, python-brace-format +msgid "Host ({})" +msgstr "" + +#: src/big_remote_play/utils/system_check.py:143 +msgid "Sunshine runtime is available" +msgstr "" + +#: src/big_remote_play/utils/system_check.py:144 +msgid "Sunshine failed to report its version" +msgstr "" + +#: src/big_remote_play/utils/widgets.py:37 +msgid "How it works" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:56 +msgid "HEADSCALE & CLOUDFLARE - PRIVATE NETWORK" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:61 +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:426 +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:56 +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:46 +msgid "Checking dependencies..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:64 +msgid "Installing" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:65 +msgid "Failed to install" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:65 +msgid "Install it manually." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:72 +msgid "Checking open ports..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:76 +msgid "Docker is not running. Starting..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:82 +msgid "Local ports in use:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:86 +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:537 +msgid "Configuring firewall..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:94 +msgid "UFW firewall configured" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:103 +msgid "Firewalld configured" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:110 +msgid "HOST (SERVER) SETUP" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:113 +msgid "Domain (e.g. vpn.ruscher.org): " +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:129 +msgid "Generating reverse proxy config (Caddy)..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:269 +msgid "Configuring Headscale..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:283 +msgid "Validating Headscale configuration..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:285 +msgid "" +"Invalid Headscale configuration; aborting before changing DNS or starting " +"services." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:315 +msgid "New DNS record created" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:319 +msgid "Starting containers..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:324 +msgid "Waiting for services to start..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:329 +msgid "Configuring router ports via UPnP..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:341 +msgid "Creating user and keys..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:350 +msgid "Creating new user..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:359 +msgid "Testing services..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:363 +msgid "Headscale is working" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:365 +msgid "Headscale is not responding" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:371 +msgid "Caddy is working" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:373 +msgid "Caddy is not responding" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:388 +msgid "SERVER CONFIGURED SUCCESSFULLY!" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:390 +msgid "ACCESS INFORMATION" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:391 +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:154 +msgid "Web Interface:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:392 +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:155 +msgid "API URL:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:393 +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:156 +msgid "Your Public IP:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:394 +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:157 +msgid "Server Local IP:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:396 +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:159 +msgid "CREDENTIALS" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:397 +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:160 +msgid "Key for Friends:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:399 +msgid "USEFUL COMMANDS" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:400 +msgid "View logs: " +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:401 +msgid "Restart: " +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:404 +msgid "SHARE ONLY THE KEY FOR FRIENDS" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:409 +msgid "Show real-time logs? (y/N): " +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:420 +msgid "CLIENT (GUEST) SETUP" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:423 +msgid "Server domain (e.g. vpn.ruscher.org): " +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:424 +msgid "Access Key (Auth Key): " +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:429 +msgid "Testing connection to the server..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:431 +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:433 +msgid "Server reachable" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:435 +msgid "Could not connect to the server" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:436 +msgid "Check:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:437 +msgid "1. Is the domain correct?" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:438 +msgid "2. Is the server online?" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:439 +msgid "3. Is the Auth Key valid?" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:448 +msgid "Installing Tailscale..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:452 +msgid "Tailscale already installed" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:460 +msgid "Connecting to the private network..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:474 +msgid "Connection established!" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:477 +msgid "Trying alternative method..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:488 +msgid "Waiting for connection..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:492 +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:187 +msgid "CONNECTION STATUS" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 105 -msgid "Use screen/window resolution" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:501 +msgid "Your network IP: " +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 110 -msgid "Video smoothness" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:508 +msgid "CONNECTED DEVICES" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 123 -msgid "Apply and Reconnect" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:513 +msgid "No other device connected yet. You are the first!" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:514 +msgid "Invite friends using the same Access Key." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:520 +msgid "TROUBLESHOOTING" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:521 +msgid "1. Check that the server is online" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:522 +msgid "2. Check the Auth Key" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:523 +msgid "3. Try restarting:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:524 +msgid "4. Check logs:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:528 +msgid "View Tailscale logs? (y/N): " +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:543 +msgid "Client setup complete!" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:551 +msgid "ADVANCED TROUBLESHOOTING" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:552 +msgid "1) Check server status" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:553 +msgid "2) Check server logs" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:554 +msgid "3) Test external connection" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:555 +msgid "4) Recreate access keys" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:556 +msgid "5) Back to main menu" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:557 +msgid "Choose an option: " +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:566 +msgid "Server directory not found" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:572 +msgid "HEADSCALE LOGS" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:574 +msgid "CADDY LOGS" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:579 +msgid "Domain to test: " +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:580 +msgid "Testing" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:588 +msgid "Recreating keys..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:590 +msgid "Create a new key? (y/N): " +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:594 +msgid "New key: " +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:600 +msgid "Press Enter to continue..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:608 +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:205 +msgid "Select an option:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:609 +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:206 +msgid "1) Be the HOST (create and manage the network)" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:610 +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:207 +msgid "2) Be the GUEST (join a friend network)" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:611 +msgid "3) Troubleshooting" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:612 +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:209 +msgid "4) Exit" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:613 +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:210 +msgid "Option: " +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:60 +msgid "Tailscale not found. Installing..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:64 +msgid "Installing yay (AUR helper)..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:82 +msgid "jq not found. Installing..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:88 +msgid "curl not found. Installing..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:99 +msgid "TAILSCALE LOGIN" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:102 +msgid "Checking tailscaled service..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:105 +msgid "tailscaled service is not running. Starting..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:110 +msgid "Failed to start tailscaled. Check manually:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:119 +msgid "tailscaled service is running" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:122 +msgid "Choose the login method:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:123 +msgid "1) Browser login (recommended)" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:124 +msgid "2) Login with auth key" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:125 +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:461 +msgid "3) Back" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:132 +msgid "Starting browser login..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:133 +msgid "A URL will open in your browser. Log in with your account." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:136 +msgid "Login URL generated. Follow the instructions in the browser." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:141 +msgid "Waiting for authentication..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:145 +msgid "Login confirmed!" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:153 +msgid "Enter your auth key:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:154 +msgid "Expected format:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:158 +msgid "Authenticating with key..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:162 +msgid "Service not responding. Reconnecting..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:171 +msgid "First attempt failed. Trying alternative method..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:178 +msgid "Restarting service and trying again..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:188 +msgid "Login successful!" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:190 +msgid "Login error. Diagnostics:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:191 +msgid "1. Check that the key is valid (not expired)" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:192 +msgid "2. Check connectivity:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:193 +msgid "3. Check the service:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:195 +msgid "Alternative manual command:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:199 +msgid "Key cannot be empty!" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:207 +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:487 +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:621 +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:733 +msgid "Invalid option!" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:212 +msgid "Checking connection..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:223 +msgid "Waiting for connection, attempt" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:229 +msgid "Logged in and connection established successfully!" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:231 +msgid "Device information:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:241 +msgid "Name: " +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:250 +msgid "Devices on the network:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:258 +msgid "Connection failed. Full diagnostics:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:260 +msgid "1. Service status:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:266 +msgid "3. Connectivity:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:279 +msgid "After running the commands above, try logging in again." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:285 +msgid "CREATE NEW TAILSCALE NETWORK" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:286 +msgid "Note: in Tailscale, networks are different accounts/orgs." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:287 +msgid "You need a different account for each network." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:289 +msgid "Network/company name:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:293 +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:398 +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:93 +msgid "Name cannot be empty!" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:297 +msgid "To create a new network:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:299 +msgid "2. Create a new account with a different email" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:300 +msgid "3. Or use a different domain to create a separate org" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:302 +msgid "Log out and log in with a new account? (y/N):" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:313 +msgid "TAILSCALE NETWORK STATUS" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:316 +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:339 +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:426 +msgid "Not connected to Tailscale. Log in first." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:320 +msgid "Current status:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:324 +msgid "IP addresses:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:329 +msgid "Detailed information:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:330 +msgid "Could not get details" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:336 +msgid "DEVICES ON THE NETWORK" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:360 +msgid "Only this device connected to the network" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:368 +msgid "ADD NEW DEVICE" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:370 +msgid "Method 1: invite URL" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:375 +msgid "Method 2: auth key" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:377 +msgid "2. Generate an auth key" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:380 +msgid "Method 3: login with the same account" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:381 +msgid "Just log in with the same account on the new device" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:383 +msgid "Press ENTER to return to the main menu" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:389 +msgid "REMOVE DEVICE" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:390 +msgid "WARNING: This will remove the device from the network!" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:394 +msgid "Enter the device name to remove:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:406 +msgid "To remove via API, you need:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:408 +msgid "Find the device" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:423 +msgid "AUTHORIZE DEVICE" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:430 +msgid "Checking pending devices..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:439 +msgid "No pending device found" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:442 +msgid "Could not check pending devices (jq not installed)" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:448 +msgid "Look for devices with status Pending" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:451 +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:492 +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:626 +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:670 +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:738 +msgid "Press ENTER to continue" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 129 -msgid "Bitrate (Quality)" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:457 +msgid "SHARE NETWORK" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 129 -msgid "Adjust bandwidth (0.5 - 150 Mbps)" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:458 +msgid "Sharing options:" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 133 -msgid "Detect" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:459 +msgid "1) Share with a specific user" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 137 -msgid "How the window will be displayed" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:467 +msgid "Enter the user email:" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 142 -msgid "Receive audio streaming" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:474 +msgid "3. Enter the email and select permissions" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 144 -msgid "Hardware Decoding" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:480 +msgid "2. Configure the desired options" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 144 -msgid "Use GPU for decoding" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:501 +msgid "To configure ACLs:" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 162 -msgid "Host connected: {}" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:505 +msgid "Basic example:" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 163 -msgid "Active Session" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:514 +msgid "Open the ACL panel in the browser? (y/N):" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 177 -msgid "Moonlight closed" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:519 +msgid "Could not open the browser. Open manually:" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 191 -msgid "Host connected. Click Stop to disconnect." -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:525 +msgid "LOG OUT OF TAILSCALE" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 234 -msgid "Connect to {}" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:526 +msgid "Do you really want to log out of Tailscale? (y/N):" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 234 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 292 -msgid "Connect to Selected" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:531 +msgid "Logout successful!" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 241 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 448 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 472 -msgid "Connect with PIN" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:533 +msgid "Operation cancelled." +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 245 -msgid "Applying settings..." -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:539 +msgid "DETAILED TAILSCALE STATUS" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 269 -msgid "Discovered Hosts" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:541 +msgid "Service status:" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 270 -msgid "Scroll to list all found devices." -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:545 +msgid "Version:" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 321 -msgid "Searching for hosts..." -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:550 +msgid "Connected to Tailscale" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 333 -msgid "No hosts found" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:552 +msgid "Node information:" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 382 -msgid "IP Copied: {}" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:560 +msgid "Statistics:" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 395 -msgid "Connection Data" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:567 +msgid "No specific tailscale route" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 396 -msgid "Enter server IP and port" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:575 +msgid "CHANGE SETTINGS" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 399 -msgid "IP/Hostname" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:576 +msgid "Configuration options:" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 403 -msgid "Port" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:579 +msgid "3) Change device name" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 407 -msgid "Use IPv6" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:580 +msgid "4) Configure DNS server" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 449 -msgid "Enter the 6-digit PIN code provided by the host" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:581 +msgid "5) Back" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 485 -msgid "Clicked Connect..." -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:587 +msgid "Enter subnets to route (e.g. 192.168.1.0/24):" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 569 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 606 -msgid "Active Stream" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:604 +msgid "New name for the device:" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 571 -msgid "Failed to connect. Verify if Moonlight is paired." -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:608 +msgid "Name changed to" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 608 -msgid "Failed to connect" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:612 +msgid "Enter DNS servers (comma-separated):" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 628 -msgid "Attempting automatic pairing PIN: {}" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:632 +msgid "SETTINGS BACKUP" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 631 -msgid "Automatic pairing sent!" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:639 +msgid "Could not create temporary backup directory." +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 642 -msgid "Starting pairing..." -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:645 +msgid "Script settings saved" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 656 -msgid "Paired successfully!" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:651 +msgid "Tailscale settings saved" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 658 -msgid "Pairing Error" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:659 +msgid "Backup created:" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 658 -msgid "Could not pair with host.\n" - "Verify the PIN was entered correctly." -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:662 +msgid "Checking backup integrity..." +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 668 -msgid "Canceling connection..." -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:664 +msgid "Backup verified" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 676 -msgid "Pairing Required" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:666 +msgid "Backup corrupted" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 676 -msgid "Moonlight needs to be paired.\n" - "\n" - "{}" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:684 +msgid "Create new network/account" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 683 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 687 -msgid "Follow instructions.\n" - "\n" - "1. Provide PIN and Host {} to the server.\n" - "2. On the host, access Sunshine Configuration.\n" - "3. Enter PIN and Host.\n" - "4. Click Send." -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:685 +msgid "View network status" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 689 -msgid "Pairing Started" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:686 +msgid "List devices" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 724 -msgid "Invalid PIN" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:687 +msgid "Add new device (instructions)" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 724 -msgid "Must contain 6 digits." -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:689 +msgid "Authorize pending device" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 745 -msgid "Host found: {}" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:690 +msgid "Share network" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 750 -msgid "Host Not Found" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:692 +msgid "Change settings" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 750 -msgid "Could not find a host with this PIN on the local network..." -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:693 +msgid "Detailed status" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 762 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 772 -msgid "Set: {}" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:694 +msgid "Logout/Exit" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 764 -msgid "Custom Resolution" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:695 +msgid "Backup settings" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 764 -msgid "Enter WxH:" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:696 +msgid "Exit the program" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 773 -msgid "Custom FPS" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:698 +msgid "Choose an option:" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 773 -msgid "Use a number" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:729 +msgid "Exiting..." +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 785 -msgid "Value" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:40 +msgid "ZEROTIER - VIRTUAL PRIVATE NETWORK" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 790 -msgid "Apply" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:49 +msgid "Installing ZeroTier..." +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 910 -msgid "Reset defaults?" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:52 +msgid "ZeroTier already installed" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 910 -msgid "All client settings will be restored." -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:63 +msgid "Starting ZeroTier service..." +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 912 -msgid "No" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:68 +msgid "ZeroTier daemon active" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 913 -msgid "Yes" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:76 +msgid "Paste your API Token here: " +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 922 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 928 -msgid "Restored" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:79 +msgid "Token cannot be empty!" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 958 -msgid "Keyboard Shortcuts" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:86 +msgid "CREATE NEW ZEROTIER NETWORK" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 959 -msgid "Common shortcuts used during streaming" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:91 +msgid "Network name: " +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 962 -msgid "Quit Stream" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:97 +msgid "Description (optional): " +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 963 -msgid "Toggle Mouse Capture" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:100 +msgid "Creating network via API..." +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 964 -msgid "Toggle Stats Overlay" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:110 +msgid "Error creating network. Check your token." +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 965 -msgid "Toggle Mouse Mode (Remote/Absolute)" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:117 +msgid "Network created! ID:" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 966 -msgid "Switch Monitor (Multi-Head Host)" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:120 +msgid "Joining the network..." +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 967 -msgid "Toggle Fullscreen" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:128 +msgid "Authorizing local device..." +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 982 -msgid "Multi-Monitor Support" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:134 +msgid "Device authorized!" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 983 -msgid "Instructions for hosts with multiple displays" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:153 +msgid "NETWORK INFORMATION" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 987 -msgid "Switching Monitors" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:163 +msgid "Share the network ID with your friends:" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 989 -msgid "If the Host PC has multiple monitors, you can switch between them " - "easily.\n" - "\n" - "1. Use Ctrl+Alt+Shift + F1 (Screen 1), F2 (Screen 2), etc.\n" - "2. If this shortcut doesn't work, ensure 'Grab Input' is active " - "(Ctrl+Alt+Shift + Z).\n" - "3. Why did F1/F2 stop working? The system likely updated and now " - "requires the full shortcut combo to avoid conflicts." -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:164 +msgid "They use the Connect option and enter the network ID" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 997 -msgid "Troubleshooting: Shortcuts Not Working?" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:170 +msgid "JOIN ZEROTIER NETWORK (Client/Guest)" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 999 -msgid "If shortcuts like F1/F2/F3 are not switching screens:\n" - "• Press Ctrl+Alt+Shift + Z to toggle Mouse/Keyboard Capture.\n" - "• When capture is ON, your F-keys are sent to the remote PC.\n" - "• When capture is OFF, your local PC intercepts them." -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:175 +msgid "ZeroTier Network ID (16 characters): " +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/host/sunshine_manager.py, line: 87 -msgid "Sunshine failed to start (Exit code {}).\n" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:177 +msgid "Network ID cannot be empty!" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/host/sunshine_manager.py, line: 89 -msgid "Sunshine failed to start: {}" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:181 +msgid "Joining network:" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/host/sunshine_manager.py, line: 91 -msgid "Sunshine failed to start (Exit code {}). Check logs." -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:192 +msgid "Your Node ID: " +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/host/sunshine_manager.py, line: 106 -msgid "Sunshine started (PID: {})" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:193 +msgid "You must be authorized by the network administrator!" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/host/sunshine_manager.py, line: 110 -msgid "Error starting Sunshine: {}" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:194 +msgid "Give your Node ID to the administrator: " +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/host/sunshine_manager.py, line: 116 -msgid "Sunshine is not running" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:197 +msgid "Join request sent!" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/host/sunshine_manager.py, line: 319 -msgid "Authentication Failed. Configure a user in Sunshine." -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:198 +msgid "Wait for the administrator to authorize you" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/host/sunshine_manager.py, line: 320 -# File: big-remote-play/usr/share/big-remote-play/host/sunshine_manager.py, line: 356 -msgid "API Error: {} - {}" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:208 +msgid "3) View network status" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/host/sunshine_manager.py, line: 322 -# File: big-remote-play/usr/share/big-remote-play/host/sunshine_manager.py, line: 358 -msgid "Connection Error: {}" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:216 +msgid "ZEROTIER STATUS" +msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/host/sunshine_manager.py, line: 351 -msgid "User created successfully" -msgstr "" +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:217 +msgid "ZeroTier not connected" +msgstr "" diff --git a/locale/pt.po b/locale/pt.po index 3ab0d25..d9c1ac4 100644 --- a/locale/pt.po +++ b/locale/pt.po @@ -2,4204 +2,7080 @@ # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the big-remote-play package. # FIRST AUTHOR , YEAR. -# +# msgid "" msgstr "" "Project-Id-Version: big-remote-play\n" "Report-Msgid-Bugs-To: \n" -"Last-Translator: FULL NAME \n" -"Language-Team: LANGUAGE \n" -"Language: \n" +"POT-Creation-Date: 2026-06-24 22:43-0300\n" +"PO-Revision-Date: 2026-06-24 22:55-0300\n" +"Last-Translator: BigLinux Team\n" +"Language-Team: Portuguese\n" +"Language: pt\n" "MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=utf-8\n" +"Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "X-Generator: attranslate\n" -# -# File: big-remote-play/usr/share/big-remote-play/utils/network.py, line: 98 -msgid " (IPv6 Local)" -msgstr "(IPv6 Local)" -# -# File: big-remote-play/usr/share/big-remote-play/utils/network.py, line: 100 -msgid " (IPv6 Global)" -msgstr "(IPv6 Global)" -# -# File: big-remote-play/usr/share/big-remote-play/utils/network.py, line: 156 -msgid "Host ({})" -msgstr "Host ({})" -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/utils/network.py, line: 267 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 992 -msgid "Host" -msgstr "Hospedeiro" -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/utils/system_check.py, line: 124 -# File: big-remote-play/usr/share/big-remote-play/utils/system_check.py, line: 127 -# File: big-remote-play/usr/share/big-remote-play/utils/system_check.py, line: 144 -# File: big-remote-play/usr/share/big-remote-play/utils/system_check.py, line: 147 -# File: big-remote-play/usr/share/big-remote-play/utils/audio.py, line: 215 -# File: big-remote-play/usr/share/big-remote-play/utils/audio.py, line: 226 -msgid "Unknown" -msgstr "Desconhecido" -# + +#: src/big_remote_play/app.py:49 +msgid "Could not load icon theme: no GTK display" +msgstr "" + # File: big-remote-play/usr/share/big-remote-play/main.py, line: 52 +#: src/big_remote_play/app.py:56 msgid "Icons and images paths added" msgstr "Caminhos de ícones e imagens adicionados" -# + # File: big-remote-play/usr/share/big-remote-play/main.py, line: 58 +#: src/big_remote_play/app.py:66 msgid "" "The Story Behind the Project\n" "\n" -"Big Remote Play was born from a real story of friendship, determination, and the passion for Free " -"Software.\n" +"Big Remote Play was born from a real story of friendship, determination, and the passion for Free Software.\n" "\n" -"Alessandro e Silva Xavier (known as Alessandro) and Alexasandro Pacheco Feliciano (known as " -"Pacheco) wanted to play games together on BigLinux using a feature that only existed on proprietary " -"platforms like Steam Remote Play and GeForce NOW. The problem? These systems are proprietary, " -"locked to their own ecosystems. If a game wasn't available on their platform, it was nearly " -"impossible to play remotely with friends.\n" +"Alessandro e Silva Xavier (known as Alessandro) and Alexasandro Pacheco Feliciano (known as Pacheco) wanted to play games together on BigLinux using a feature that only existed on proprietary platforms like Steam Remote Play and GeForce NOW. The problem? These systems are proprietary, locked to their own ecosystems. If a game wasn't available on their platform, it was nearly impossible to play remotely with friends.\n" "\n" -"Refusing to accept this limitation, Alessandro and Pacheco embarked on a journey of countless " -"attempts and extensive research. After trying many different approaches, they finally found a " -"working solution by combining multiple free software programs — including Sunshine, Moonlight, " -"scripts, and VPN tools. They had achieved what the proprietary platforms kept locked behind their " -"walls, and the best part: it was Free Software and multi-platform!\n" +"Refusing to accept this limitation, Alessandro and Pacheco embarked on a journey of countless attempts and extensive research. After trying many different approaches, they finally found a working solution by combining multiple free software programs — including Sunshine, Moonlight, scripts, and VPN tools. They had achieved what the proprietary platforms kept locked behind their walls, and the best part: it was Free Software and multi-platform!\n" "\n" -"Excited by their success, they started sharing their achievement during their live streams, which " -"generated tremendous enthusiasm from the community. However, there was a catch — the setup was " -"complicated. It required configuring multiple separate solutions: Sunshine, Moonlight, custom " -"scripts, VPN connections... it was a lot for anyone to handle.\n" +"Excited by their success, they started sharing their achievement during their live streams, which generated tremendous enthusiasm from the community. However, there was a catch — the setup was complicated. It required configuring multiple separate solutions: Sunshine, Moonlight, custom scripts, VPN connections... it was a lot for anyone to handle.\n" "\n" -"That's when a friend decided to step in and help develop a unified application to simplify the " -"entire process. And so, Big Remote Play was born! 🎉\n" +"That's when a friend decided to step in and help develop a unified application to simplify the entire process. And so, Big Remote Play was born! 🎉\n" "\n" -"An all-in-one application that integrates everything you need for remote cooperative gaming — no " -"proprietary platforms, no restrictions, no limits on which games you can play." +"An all-in-one application that integrates everything you need for remote cooperative gaming — no proprietary platforms, no restrictions, no limits on which games you can play." msgstr "" "A História Por Trás do Projeto\n" "\n" -"O Big Remote Play nasceu de uma verdadeira história de amizade, determinação e paixão pelo Software " -"Livre.\n" +"O Big Remote Play nasceu de uma verdadeira história de amizade, determinação e paixão pelo Software Livre.\n" "\n" -"Alessandro e Silva Xavier (conhecido como Alessandro) e Alexasandro Pacheco Feliciano (conhecido " -"como Pacheco) queriam jogar juntos no BigLinux usando um recurso que só existia em plataformas " -"proprietárias como Steam Remote Play e GeForce NOW. O problema? Esses sistemas são proprietários, " -"bloqueados em seus próprios ecossistemas. Se um jogo não estava disponível em sua plataforma, era " -"quase impossível jogar remotamente com amigos.\n" +"Alessandro e Silva Xavier (conhecido como Alessandro) e Alexasandro Pacheco Feliciano (conhecido como Pacheco) queriam jogar juntos no BigLinux usando um recurso que só existia em plataformas proprietárias como Steam Remote Play e GeForce NOW. O problema? Esses sistemas são proprietários, bloqueados em seus próprios ecossistemas. Se um jogo não estava disponível em sua plataforma, era quase impossível jogar remotamente com amigos.\n" "\n" -"Recusando-se a aceitar essa limitação, Alessandro e Pacheco embarcaram em uma jornada de inúmeras " -"tentativas e extensa pesquisa. Depois de tentar muitas abordagens diferentes, eles finalmente " -"encontraram uma solução funcional combinando vários programas de software livre — incluindo " -"Sunshine, Moonlight, scripts e ferramentas de VPN. Eles conseguiram o que as plataformas " -"proprietárias mantinham trancado atrás de suas paredes, e a melhor parte: era Software Livre e " -"multiplataforma!\n" +"Recusando-se a aceitar essa limitação, Alessandro e Pacheco embarcaram em uma jornada de inúmeras tentativas e extensa pesquisa. Depois de tentar muitas abordagens diferentes, eles finalmente encontraram uma solução funcional combinando vários programas de software livre — incluindo Sunshine, Moonlight, scripts e ferramentas de VPN. Eles conseguiram o que as plataformas proprietárias mantinham trancado atrás de suas paredes, e a melhor parte: era Software Livre e multiplataforma!\n" "\n" -"Empolgados com seu sucesso, começaram a compartilhar sua conquista durante suas transmissões ao " -"vivo, o que gerou um enorme entusiasmo da comunidade. No entanto, havia um porém — a configuração " -"era complicada. Era necessário configurar várias soluções separadas: Sunshine, Moonlight, scripts " -"personalizados, conexões VPN... era muito para qualquer um lidar.\n" +"Empolgados com seu sucesso, começaram a compartilhar sua conquista durante suas transmissões ao vivo, o que gerou um enorme entusiasmo da comunidade. No entanto, havia um porém — a configuração era complicada. Era necessário configurar várias soluções separadas: Sunshine, Moonlight, scripts personalizados, conexões VPN... era muito para qualquer um lidar.\n" "\n" -"Foi então que um amigo decidiu intervir e ajudar a desenvolver um aplicativo unificado para " -"simplificar todo o processo. E assim, o Big Remote Play nasceu! 🎉\n" +"Foi então que um amigo decidiu intervir e ajudar a desenvolver um aplicativo unificado para simplificar todo o processo. E assim, o Big Remote Play nasceu! 🎉\n" "\n" -"Um aplicativo tudo-em-um que integra tudo o que você precisa para jogos cooperativos remotos — sem " -"plataformas proprietárias, sem restrições, sem limites sobre quais jogos você pode jogar." -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 17 -msgid "Install Dependencies" -msgstr "Instalar Dependências" -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 30 -msgid "Installing required components..." -msgstr "Instalando componentes necessários..." -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 56 -msgid "" -"Embedded terminal not available (vte4 not found).\n" -"Opening external terminal for installation...\n" -"Please wait for the installation to finish in the other window." +"Um aplicativo tudo-em-um que integra tudo o que você precisa para jogos cooperativos remotos — sem plataformas proprietárias, sem restrições, sem limites sobre quais jogos você pode jogar." + +# File: big-remote-play/usr/share/big-remote-play/host/sunshine_manager.py, +# line: 86 +#: src/big_remote_play/host/sunshine_manager.py:105 +#, python-brace-format +msgid "Sunshine failed to start (Exit code {}).\n" +msgstr "O Sunshine falhou ao iniciar (código de saída {}).\n" + +# File: big-remote-play/usr/share/big-remote-play/host/sunshine_manager.py, +# line: 88 +#: src/big_remote_play/host/sunshine_manager.py:107 +#, python-brace-format +msgid "Sunshine failed to start: {}" +msgstr "O Sunshine falhou ao iniciar: {}" + +# File: big-remote-play/usr/share/big-remote-play/host/sunshine_manager.py, +# line: 90 +#: src/big_remote_play/host/sunshine_manager.py:109 +#, python-brace-format +msgid "Sunshine failed to start (Exit code {}). Check logs." +msgstr "O Sunshine falhou ao iniciar (Código de saída {}). Verifique os logs." + +# File: big-remote-play/usr/share/big-remote-play/host/sunshine_manager.py, +# line: 105 +#: src/big_remote_play/host/sunshine_manager.py:124 +#, python-brace-format +msgid "Sunshine started (PID: {})" +msgstr "Sunshine iniciado (PID: {})" + +# File: big-remote-play/usr/share/big-remote-play/host/sunshine_manager.py, +# line: 109 +#: src/big_remote_play/host/sunshine_manager.py:128 +#, python-brace-format +msgid "Error starting Sunshine: {}" +msgstr "Erro ao iniciar Sunshine: {}" + +# File: big-remote-play/usr/share/big-remote-play/host/sunshine_manager.py, +# line: 115 +#: src/big_remote_play/host/sunshine_manager.py:134 +msgid "Sunshine is not running" +msgstr "Sunshine não está em execução." + +#: src/big_remote_play/host/sunshine_manager.py:312 +#, python-brace-format +msgid "Certificate trust error: {}" msgstr "" -"Terminal embutido não disponível (vte4 não encontrado). \n" -"Abrindo terminal externo para instalação... \n" -"Por favor, aguarde a conclusão da instalação na outra janela." -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 67 -msgid "Starting..." -msgstr "Iniciando..." -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 70 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1262 -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 464 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 587 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 620 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 648 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 692 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1443 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 672 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 784 -msgid "Cancel" -msgstr "Cancelar" -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 77 -msgid "Done! Press Enter to close..." -msgstr "Concluído! Pressione Enter para fechar..." -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 85 -msgid "Running in external terminal. Restart after completion." -msgstr "Executando no terminal externo. Reinicie após a conclusão." -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 85 -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 126 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1232 -msgid "Close" -msgstr "Fechar" -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 86 -msgid "Error: No terminal detected." -msgstr "Erro: Nenhum terminal detectado." -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 86 -msgid "" -"Execute manually:\n" -"{}" + +#: src/big_remote_play/host/sunshine_manager.py:349 +#, python-brace-format +msgid "Sunshine API request failed: {}" msgstr "" -"Executar manualmente:\n" -"{}" -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 89 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1669 -msgid "Installing..." -msgstr "Instalando..." -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 91 -msgid "Error: {}" -msgstr "Erro: {}" -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 92 -msgid "Embedded terminal error: {}. Trying external..." -msgstr "Erro de terminal incorporado: {}. Tentando externo..." -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 109 -msgid "Installation completed successfully!" -msgstr "Instalação concluída com sucesso!" -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 113 -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 113 -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 113 -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 113 -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 113 -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 113 -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 113 -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 113 -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 113 -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 113 -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 113 -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 113 -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 113 -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 113 -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 113 -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 113 -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 113 -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 113 -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 113 -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 113 -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 113 -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 113 -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 113 -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 113 -# -# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, line: 124 -msgid "Installation failed. Exit code: {}" -msgstr "A instalação falhou. Código de saída: {}" -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 22 -msgid "Preferências" -msgstr "Preferências" -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 38 -msgid "Geral" -msgstr "Geral" -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 43 -msgid "Aparência" -msgstr "Aparência" -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 47 -msgid "Tema" -msgstr "Tema" -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 48 -msgid "Escolha o esquema de cores" -msgstr "Choose the color scheme" -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 51 -msgid "Automático" -msgstr "Automático" -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 57 -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 57 -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 57 -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 57 -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 57 -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 57 -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 54 -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 54 -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 54 -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 54 -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 54 -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 54 -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 54 -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 54 -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 54 -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 54 -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 54 -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 54 -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 54 -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 54 -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 52 -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 52 -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 52 -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 52 -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 53 -msgid "Escuro" -msgstr "Escuro" -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 74 -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 81 -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 249 -msgid "Restaurar" -msgstr "Restaurar" -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 78 -msgid "Restaurar Padrões" -msgstr "Restaurar Padrões" -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 79 -msgid "Redefinir todas as configurações para o padrão" -msgstr "Redefinir todas as configurações para o padrão" -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 89 -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 92 -msgid "Limpar Tudo" -msgstr "Limpar Tudo" -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 90 -msgid "Remover logs, configurações, servidores e dados salvos" -msgstr "Remove logs, settings, servers, and saved data." -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 64 -msgid "Avançado" -msgstr "Avançado" -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 69 -msgid "Caminhos" -msgstr "Caminhos" -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 72 -msgid "Diretório de Configuração" -msgstr "Configuration Directory" -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 79 -msgid "Copiar Caminho" -msgstr "Copiar Caminho" -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 88 -msgid "Logs e Depuração" -msgstr "Logs and Debugging" -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 91 -msgid "Logs Detalhados" -msgstr "Logs Detalhados" -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 92 -msgid "Ativar logging verbose para depuração" -msgstr "Ativar logging detalhado para depuração" -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 97 -msgid "Limpar Logs" -msgstr "Limpar Registros" -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 98 -msgid "Remover arquivos de log antigos" -msgstr "Remove arquivos de log antigos" -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 100 -msgid "Limpar" -msgstr "Limpar" -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 158 -msgid "Logs Limpos" -msgstr "Logs Limpos" -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 158 -msgid "Arquivos de log antigos foram removidos." -msgstr "Old log files have been removed." -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 199 -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 317 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 691 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 755 -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 199 -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 317 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 691 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 755 -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 199 -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 317 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 701 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 765 -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 199 -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 317 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 701 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 765 -# + +#: src/big_remote_play/host/sunshine_manager.py:362 +#: src/big_remote_play/host/sunshine_manager.py:393 +msgid "Connection Error: Sunshine unreachable or certificate mismatch" +msgstr "" + # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 199 -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 305 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 701 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 765 -# +# +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 601 +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 665 +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 705 +# File: big-remote-play/usr/share/big-remote-play/host/sunshine_manager.py, +# line: 307 +#: src/big_remote_play/host/sunshine_manager.py:368 +#: src/big_remote_play/ui/host_view.py:878 +#: src/big_remote_play/ui/host_view.py:940 +#: src/big_remote_play/ui/host_view.py:980 +msgid "PIN sent successfully" +msgstr "PIN enviado com sucesso" + +#: src/big_remote_play/host/sunshine_manager.py:368 +msgid "Sunshine rejected the PIN" +msgstr "" + +# File: big-remote-play/usr/share/big-remote-play/host/sunshine_manager.py, +# line: 312 +#: src/big_remote_play/host/sunshine_manager.py:370 +msgid "Authentication Failed. Configure a user in Sunshine." +msgstr "Autenticação Falhou. Configure um usuário no Sunshine." + +#: src/big_remote_play/host/sunshine_manager.py:372 +#: src/big_remote_play/host/sunshine_manager.py:403 +#, python-brace-format +msgid "API Error: {}" +msgstr "" + +#: src/big_remote_play/host/sunshine_manager.py:395 +msgid "Authentication Failed. Check the current password." +msgstr "" + +#: src/big_remote_play/host/sunshine_manager.py:399 +msgid "Sunshine rejected the credentials" +msgstr "" + +#: src/big_remote_play/host/sunshine_manager.py:402 +msgid "Credentials updated successfully" +msgstr "" + +#: src/big_remote_play/host/sunshine_manager.py:417 +msgid "Username and password cannot be empty" +msgstr "" + +#: src/big_remote_play/host/sunshine_manager.py:420 +#: src/big_remote_play/utils/system_check.py:138 +msgid "Sunshine executable not found" +msgstr "" + +#: src/big_remote_play/host/sunshine_manager.py:426 +msgid "Credentials reset successfully" +msgstr "" + +#: src/big_remote_play/host/sunshine_manager.py:428 +#, python-brace-format +msgid "Reset failed: {}" +msgstr "" + +#: src/big_remote_play/host/sunshine_manager.py:428 +msgid "Reset failed" +msgstr "" + +#: src/big_remote_play/host/sunshine_manager.py:430 +#, python-brace-format +msgid "Reset error: {}" +msgstr "" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 36 +#: src/big_remote_play/ui/guest_view.py:43 +msgid "Detecting bandwidth..." +msgstr "Detectando largura de banda..." + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 42 +#: src/big_remote_play/ui/guest_view.py:49 +#, python-brace-format +msgid "Suggested bitrate: {} Mbps" +msgstr "Taxa de bits sugerida: {} Mbps" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 64 +#: src/big_remote_play/ui/guest_view.py:63 +msgid "Discover" +msgstr "Descobrir" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 67 +#: src/big_remote_play/ui/guest_view.py:66 +msgid "Manual" +msgstr "Manual" + # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 199 -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 305 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 701 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 765 -# +# +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 361 +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 382 +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 70 +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 438 +#: src/big_remote_play/ui/guest_view.py:69 +#: src/big_remote_play/ui/guest_view.py:478 +#: src/big_remote_play/ui/host_view.py:549 +#: src/big_remote_play/ui/host_view.py:570 +msgid "PIN Code" +msgstr "Código PIN" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 67 +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 947 +#: src/big_remote_play/ui/guest_view.py:82 +#: src/big_remote_play/ui/guest_view.py:83 +#: src/big_remote_play/ui/guest_view.py:1008 +msgid "Shortcuts & Instructions" +msgstr "Atalhos e Instruções" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 82 +#: src/big_remote_play/ui/guest_view.py:94 +msgid "Client Settings" +msgstr "Configurações do Cliente" + # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 196 -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 302 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 701 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 765 -# +# +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 127 +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 83 +#: src/big_remote_play/ui/guest_view.py:95 +#: src/big_remote_play/ui/host_view.py:165 +msgid "Reset to Defaults" +msgstr "Redefinir para Padrões" + # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 196 -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 302 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 701 +# +# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, +# line: 78 +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 85 +#: src/big_remote_play/ui/guest_view.py:97 +#: src/big_remote_play/ui/host_view.py:216 +#: src/big_remote_play/ui/moonlight_preferences.py:30 +msgid "Resolution" +msgstr "Resolução" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 85 +#: src/big_remote_play/ui/guest_view.py:97 +#: src/big_remote_play/ui/host_view.py:217 +msgid "Stream resolution" +msgstr "Resolução de stream" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 87 +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 98 +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 754 # File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 765 -# +#: src/big_remote_play/ui/guest_view.py:99 +#: src/big_remote_play/ui/guest_view.py:110 +#: src/big_remote_play/ui/guest_view.py:814 +#: src/big_remote_play/ui/guest_view.py:825 +#: src/big_remote_play/ui/host_view.py:219 +#: src/big_remote_play/ui/host_view.py:230 +msgid "Custom" +msgstr "Personalizado" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 91 +#: src/big_remote_play/ui/guest_view.py:103 +msgid "Native Resolution (Adaptive)" +msgstr "Resolução Nativa (Adaptativa)" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 91 +#: src/big_remote_play/ui/guest_view.py:103 +msgid "Use screen/window resolution" +msgstr "Use a resolução de tela/janela" + # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 196 -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 302 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 701 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 765 -# +# +# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, +# line: 90 +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 96 +#: src/big_remote_play/ui/guest_view.py:108 +#: src/big_remote_play/ui/host_view.py:227 +#: src/big_remote_play/ui/moonlight_preferences.py:42 +msgid "Frame Rate (FPS)" +msgstr "Taxa de Quadros (FPS)" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 96 +#: src/big_remote_play/ui/guest_view.py:108 +msgid "Video smoothness" +msgstr "Suavidade do vídeo" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 109 +#: src/big_remote_play/ui/guest_view.py:121 +msgid "Apply and Reconnect" +msgstr "Aplicar e Reconectar" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 115 +#: src/big_remote_play/ui/guest_view.py:127 +msgid "Bitrate (Quality)" +msgstr "Taxa de bits (Qualidade)" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 115 +#: src/big_remote_play/ui/guest_view.py:127 +msgid "Adjust bandwidth (0.5 - 150 Mbps)" +msgstr "Ajustar largura de banda (0,5 - 150 Mbps)" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 119 +#: src/big_remote_play/ui/guest_view.py:131 +msgid "Detect" +msgstr "Detectar" + # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 196 -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 302 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 701 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 765 -# +# +# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, +# line: 114 +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 123 +#: src/big_remote_play/ui/guest_view.py:135 +#: src/big_remote_play/ui/moonlight_preferences.py:66 +msgid "Display Mode" +msgstr "Modo de Exibição" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 123 +#: src/big_remote_play/ui/guest_view.py:135 +msgid "How the window will be displayed" +msgstr "Como a janela será exibida" + # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 196 -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 302 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 701 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 765 -# +# +# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, +# line: 116 +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 125 +#: src/big_remote_play/ui/guest_view.py:137 +#: src/big_remote_play/ui/moonlight_preferences.py:68 +msgid "Borderless Window" +msgstr "Janela Sem Bordas" + # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 196 -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 302 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 701 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 765 -# +# +# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, +# line: 116 +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 125 +#: src/big_remote_play/ui/guest_view.py:137 +#: src/big_remote_play/ui/moonlight_preferences.py:68 +msgid "Fullscreen" +msgstr "Tela cheia" + # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 196 -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 302 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 701 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 765 -# +# +# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, +# line: 116 +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 125 +#: src/big_remote_play/ui/guest_view.py:137 +#: src/big_remote_play/ui/moonlight_preferences.py:68 +msgid "Windowed" +msgstr "Em janela" + # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 196 -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 302 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 701 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 765 -# +# +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 224 +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 352 +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 128 +#: src/big_remote_play/ui/guest_view.py:140 +#: src/big_remote_play/ui/host_view.py:310 +#: src/big_remote_play/ui/host_view.py:540 +msgid "Audio" +msgstr "Áudio" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 128 +#: src/big_remote_play/ui/guest_view.py:140 +msgid "Receive audio streaming" +msgstr "Receber streaming de áudio" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 130 +#: src/big_remote_play/ui/guest_view.py:142 +msgid "Hardware Decoding" +msgstr "Decodificação de Hardware" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 130 +#: src/big_remote_play/ui/guest_view.py:142 +msgid "Use GPU for decoding" +msgstr "Usar GPU para decodificação" + +#: src/big_remote_play/ui/guest_view.py:145 +#: src/big_remote_play/ui/guest_view.py:999 +msgid "Advanced client settings" +msgstr "" + +#: src/big_remote_play/ui/guest_view.py:146 +msgid "Input, controller, codec and more" +msgstr "" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 149 +#: src/big_remote_play/ui/guest_view.py:169 +msgid "Active Session" +msgstr "Sessão Ativa" + # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 196 -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 302 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 701 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 765 -# +# +# File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, +# line: 325 +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 159 +#: src/big_remote_play/ui/guest_view.py:178 +#: src/big_remote_play/ui/performance_monitor.py:339 +msgid "Disconnected" +msgstr "Desconectado" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 163 +#: src/big_remote_play/ui/guest_view.py:182 +msgid "Moonlight closed" +msgstr "Moonlight fechado" + # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 196 -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 302 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 701 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 765 -# +# # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 196 -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 302 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 701 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 765 -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 196 -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 302 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 701 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 765 -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 141 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 686 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 750 -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 141 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 686 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 750 -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 190 -msgid "Caminho copiado!" -msgstr "Caminho copiado!" -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 213 -msgid "" -"Todas as suas modificações no Big Remote Play serão restauradas! Isso inclui as configurações do " -"Sunshine e Moonlight." -msgstr "" -"All your changes in Big Remote Play will be restored! This includes the settings for Sunshine and " -"Moonlight." -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 247 -msgid "Restaurar Padrões?" -msgstr "Restaurar Padrões?" -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 248 -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 286 -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 295 -msgid "Cancelar" -msgstr "Cancelar" -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 251 -msgid "Configurações Restauradas! Reinicie o aplicativo para aplicar todas as alterações." -msgstr "Restored Settings! Restart the application to apply all changes." -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 255 -msgid "Erro ao restaurar: {}" -msgstr "Erro ao restaurar: {}" -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 285 -msgid "Limpar TUDO?" -msgstr "Limpar TUDO?" -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 285 -msgid "" -"ATENÇÃO: Isso apagará TODOS os dados, logs, configurações e servidores salvos. O aplicativo será " -"fechado." -msgstr "" -"ATENÇÃO: Isso apagará TODOS os dados, logs, configurações e servidores salvos. O aplicativo será " -"fechado." -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 287 -msgid "Apagar Tudo" -msgstr "Delete All" -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 271 -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 271 -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 271 -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 271 -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 259 -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 259 -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 256 -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 256 -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 256 -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 256 -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 256 -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 256 -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 256 -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 256 -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 256 -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 256 -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 256 -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 256 -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 294 -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 294 -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 294 -msgid "Esta ação é IRREVERSÍVEL. Você perderá todos os dados configurados." -msgstr "This action is IRREVERSIBLE. You will lose all configured data." -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 296 -msgid "Sim, Apagar Tudo" -msgstr "Sim, Apagar Tudo" -# -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 339 -msgid "Erro ao Limpar" -msgstr "Error while clearing" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 62 -msgid "Sunshine" -msgstr "Sol brilhante" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 74 -msgid "General" -msgstr "Geral" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 74 -msgid "Server general settings" -msgstr "Configurações gerais do servidor" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 75 -msgid "Input" -msgstr "Entrada" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 75 -msgid "Keyboard, mouse and gamepad" -msgstr "Teclado, mouse e controle de jogo" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 76 -msgid "Audio/Video" -msgstr "Áudio/Vídeo" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 76 -msgid "Resolution, bitrate and quality" -msgstr "Resolução, taxa de bits e qualidade" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 77 -msgid "Network" -msgstr "Rede" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 77 -msgid "Ports and connectivity" -msgstr "Portas e conectividade" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 78 -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 98 -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 109 -msgid "Config Files" -msgstr "Arquivos de Configuração" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 78 -msgid "Configuration files and logs" -msgstr "Arquivos de configuração e logs" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 79 -msgid "Advanced" -msgstr "Avançado" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 79 -msgid "Advanced options" -msgstr "Opções avançadas" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 82 -msgid "NVIDIA NVENC" -msgstr "NVIDIA NVENC" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 82 -msgid "NVIDIA Encoder" -msgstr "Codificador NVIDIA" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 83 -msgid "Intel QuickSync" -msgstr "Intel QuickSync" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 83 -msgid "Intel Encoder" -msgstr "Codificador Intel" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 84 -msgid "AMD AMF" -msgstr "AMD AMF" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 84 -msgid "AMD Encoder" -msgstr "Codificador AMD" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 85 -msgid "VideoToolbox" -msgstr "VideoToolbox" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 85 -msgid "Apple Encoder" -msgstr "Codificador Apple" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 86 -msgid "VA-API" -msgstr "VA-API" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 86 -msgid "VA-API Encoder (Linux)" -msgstr "Codificador VA-API (Linux)" -# +# +# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 564 +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 868 +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 198 +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 206 +#: src/big_remote_play/ui/guest_view.py:214 +#: src/big_remote_play/ui/guest_view.py:222 +#: src/big_remote_play/ui/main_window.py:1083 +msgid "Stop" +msgstr "Parar" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 220 +#: src/big_remote_play/ui/guest_view.py:237 +#, python-brace-format +msgid "Connect to {}" +msgstr "Conectar a {}" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 220 +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 278 +#: src/big_remote_play/ui/guest_view.py:237 +#: src/big_remote_play/ui/guest_view.py:295 +msgid "Connect to Selected" +msgstr "Conectar ao Selecionado" + # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 87 -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 180 -msgid "Software" -msgstr "Software" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 87 -msgid "CPU Encoding" -msgstr "Codificação da CPU" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 112 -msgid "No options available" -msgstr "Nenhuma opção disponível" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 220 -msgid "Apps File" -msgstr "Arquivo de Aplicativos" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 220 -msgid "The file where current apps of Sunshine are stored." -msgstr "O arquivo onde os aplicativos atuais do Sunshine estão armazenados." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 221 -msgid "Credentials File" -msgstr "Arquivo de Credenciais" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 221 -msgid "Store Username/Password separately from Sunshine's state file." -msgstr "Armazene o nome de usuário/senha separadamente do arquivo de estado do Sunshine." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 222 -msgid "Logfile Path" -msgstr "Caminho do Arquivo de Log" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 222 -msgid "The file where the current logs of Sunshine are stored." -msgstr "O arquivo onde os logs atuais do Sunshine estão armazenados." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 223 -msgid "Private Key" -msgstr "Chave Privada" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 223 -msgid "" -"The private key used for the web UI and Moonlight client pairing. For best compatibility, this " -"should be an RSA-2048 private key." +# +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 962 +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 224 +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 417 +#: src/big_remote_play/ui/guest_view.py:241 +#: src/big_remote_play/ui/guest_view.py:457 +#: src/big_remote_play/ui/private_network_view.py:1273 +#: src/big_remote_play/ui/private_network_view.py:1339 +msgid "Connect" +msgstr "Conectar" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 227 +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 434 +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 458 +#: src/big_remote_play/ui/guest_view.py:244 +#: src/big_remote_play/ui/guest_view.py:474 +#: src/big_remote_play/ui/guest_view.py:498 +msgid "Connect with PIN" +msgstr "Conectar com PIN" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 231 +#: src/big_remote_play/ui/guest_view.py:248 +msgid "Applying settings..." +msgstr "Aplicando configurações..." + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 255 +#: src/big_remote_play/ui/guest_view.py:272 +msgid "Discovered Hosts" +msgstr "Hosts Descobertos" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 256 +#: src/big_remote_play/ui/guest_view.py:273 +msgid "Scroll to list all found devices." +msgstr "Role para listar todos os dispositivos encontrados." + +#: src/big_remote_play/ui/guest_view.py:316 +msgid "If you can't find the host" +msgstr "Se você não encontrar o host" + +#: src/big_remote_play/ui/guest_view.py:319 +msgid "Check your local network" msgstr "" -"A chave privada usada para a interface da web e emparelhamento do cliente Moonlight. Para melhor " -"compatibilidade, isso deve ser uma chave privada RSA-2048." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 224 -msgid "Certificate" -msgstr "Certificado" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 224 + +#: src/big_remote_play/ui/guest_view.py:320 msgid "" -"The certificate used for the web UI and Moonlight client pairing. For best compatibility, this " -"should have an RSA-2048 public key." +"Make sure the host and this device are on the same Wi-Fi or wired network." msgstr "" -"O certificado usado para a interface da web e emparelhamento do cliente Moonlight. Para melhor " -"compatibilidade, isso deve ter uma chave pública RSA-2048." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 225 -msgid "State File" -msgstr "Arquivo de Estado" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 225 -msgid "The file where current state of Sunshine is stored" -msgstr "O arquivo onde o estado atual do Sunshine está armazenado." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 226 -msgid "Configuration File" -msgstr "Arquivo de Configuração" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 226 -msgid "The main configuration file for Sunshine." -msgstr "O arquivo de configuração principal para Sunshine." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 297 -msgid "Missing Libraries Detected" -msgstr "Bibliotecas Ausentes Detectadas" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 298 -msgid "Sunshine requires older ICU libraries ({}) which are missing." -msgstr "O Sunshine requer bibliotecas ICU mais antigas ({}) que estão ausentes." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 302 -msgid "Fix Dependencies" -msgstr "Corrigir Dependências" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 311 -msgid "System Status" -msgstr "Status do Sistema" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 312 -msgid "All required libraries appear to be present." -msgstr "Todas as bibliotecas necessárias parecem estar presentes." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 318 -msgid "Re-apply Library Fix" -msgstr "Reaplicar Correção da Biblioteca" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 319 -msgid "Run the library fix script manually." -msgstr "Execute o script de correção da biblioteca manualmente." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 321 -msgid "Run Fix" -msgstr "Executar Correção" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 345 -msgid "Fix script started. Please authenticate." -msgstr "O script de correção foi iniciado. Por favor, autentique-se." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 362 -msgid "Libraries fixed! You can now start the server." -msgstr "Bibliotecas corrigidas! Você pode agora iniciar o servidor." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 258 -msgid "Locale" -msgstr "Localidade" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 262 -msgid "The locale used for Sunshine's user interface." -msgstr "O idioma utilizado para a interface do usuário do Sunshine." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 263 -msgid "Sunshine Name" -msgstr "Nome do Sunshine" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 263 -msgid "The name of the Sunshine instance as seen by clients." -msgstr "O nome da instância Sunshine visto pelos clientes." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 264 -msgid "Sunshine User" -msgstr "Usuário Sunshine" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 264 -msgid "Username for API access (Monitoring)" -msgstr "Nome de usuário para acesso à API (Monitoramento)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 265 -msgid "Sunshine Password" -msgstr "Senha Sunshine" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 265 -msgid "Password for API access (Monitoring)" -msgstr "Senha para acesso à API (Monitoramento)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 266 -msgid "Log Level" -msgstr "Nível de Log" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 269 -msgid "The minimum log level printed to standard out" -msgstr "O nível mínimo de log impresso na saída padrão" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 270 -msgid "Pre-Release Notifications" -msgstr "Notificações de Pré-Lançamento" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 270 -msgid "Whether to be notified of new pre-release versions of Sunshine" -msgstr "Se deseja ser notificado sobre novas versões pré-lançamento do Sunshine." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 271 -msgid "Enable System Tray" -msgstr "Ativar Área de Notificação" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 271 -msgid "Show icon in system tray and display desktop notifications" -msgstr "Mostrar ícone na área de notificação e exibir notificações na área de trabalho" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 276 -msgid "UPnP" -msgstr "UPnP" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 276 -msgid "Automatically configure port forwarding for streaming over the Internet" -msgstr "Configurar automaticamente o encaminhamento de porta para streaming pela Internet" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 277 -msgid "Address Family" -msgstr "Família de Endereços" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 279 -msgid "Set the address family used by Sunshine" -msgstr "Defina a família de endereços usada pelo Sunshine." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 280 -msgid "Bind Address" -msgstr "Endereço de Ligação" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 280 -msgid "IP address to bind the service to" -msgstr "Endereço IP para vincular o serviço" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 281 -msgid "Port (Moonlight)" -msgstr "Porto (Luz da Lua)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 281 -msgid "" -"Set the family of ports used by Sunshine.\n" -"TCP: 47984, 47989, 47990 (Web UI), 48010\n" -"UDP: 47998 - 48012" -msgstr "" -"Defina a família de portas usadas pelo Sunshine. \n" -"TCP: 47984, 47989, 47990 (Interface Web), 48010 \n" -"UDP: 47998 - 48012" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 282 -msgid "Web UI Access" -msgstr "Acesso à Interface Web" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 284 -msgid "" -"The origin of the remote endpoint address that is not denied access to Web UI.\n" -"Warning: Exposing the Web UI to the internet is a security risk! Proceed at your own risk!" + +#: src/big_remote_play/ui/guest_view.py:321 +msgid "Use the manual connection" msgstr "" -"A origem do endereço do ponto final remoto que não teve acesso negado à interface da Web. \n" -"Aviso: Expor a interface da Web à internet é um risco de segurança! Prossiga por sua conta e risco!" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 285 -msgid "External IP" -msgstr "IP Externo" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 285 -msgid "If no external IP address is given, Sunshine will automatically detect external IP" -msgstr "Se nenhum endereço IP externo for fornecido, o Sunshine detectará automaticamente o IP externo." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 286 -msgid "LAN Encryption" -msgstr "Criptografia de LAN" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 287 -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 290 -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 330 -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 333 -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 352 -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 398 -msgid "Disabled" -msgstr "Desativado" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 287 -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 290 -msgid "Mode 1" -msgstr "Modo 1" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 287 -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 290 -msgid "Mode 2" -msgstr "Modo 2" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 288 -msgid "" -"This determines when encryption will be used when streaming over your local network. Encryption can " -"reduce streaming performance, particularly on less powerful hosts and clients." + +#: src/big_remote_play/ui/guest_view.py:322 +msgid "Enter the host IP address or hostname and port to connect directly." msgstr "" -"Isso determina quando a criptografia será usada ao transmitir pela sua rede local. A criptografia " -"pode reduzir o desempenho de streaming, particularmente em hosts e clientes menos poderosos." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 289 -msgid "WAN Encryption" -msgstr "Criptografia WAN" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 291 -msgid "" -"This determines when encryption will be used when streaming over the Internet. Encryption can " -"reduce streaming performance, particularly on less powerful hosts and clients." + +#: src/big_remote_play/ui/guest_view.py:323 +msgid "Use the PIN code" +msgstr "Use o código PIN" + +#: src/big_remote_play/ui/guest_view.py:324 +msgid "Ask the host for the PIN code and connect quickly and securely." msgstr "" -"Isso determina quando a criptografia será usada ao transmitir pela Internet. A criptografia pode " -"reduzir o desempenho de streaming, particularmente em hosts e clientes menos poderosos." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 292 -msgid "Ping Timeout (ms)" -msgstr "Tempo limite de Ping (ms)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 292 -msgid "How long to wait in milliseconds for data from moonlight before shutting down the stream" -msgstr "Quanto tempo esperar em milissegundos pelos dados do moonlight antes de desligar o stream" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 297 -msgid "Enable Gamepad Input" -msgstr "Ativar Entrada de Gamepad" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 297 -msgid "Allows guests to control the host system with a gamepad / controller" -msgstr "Permite que os convidados controlem o sistema do anfitrião com um gamepad / controle." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 298 -msgid "Emulated Gamepad Type" -msgstr "Tipo de Gamepad Emulado" -# + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 307 +#: src/big_remote_play/ui/guest_view.py:345 +msgid "Searching for hosts..." +msgstr "Buscando por hosts..." + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 319 +#: src/big_remote_play/ui/guest_view.py:357 +msgid "No hosts found" +msgstr "Nenhum host encontrado" + # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# +# +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 720 +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 365 +#: src/big_remote_play/ui/guest_view.py:403 +#: src/big_remote_play/ui/private_network_view.py:816 +msgid "Copy IP" +msgstr "Copiar IP" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 368 +#: src/big_remote_play/ui/guest_view.py:408 +#, python-brace-format +msgid "IP Copied: {}" +msgstr "IP Copiado: {}" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 381 +#: src/big_remote_play/ui/guest_view.py:421 +msgid "Connection Data" +msgstr "Dados de Conexão" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 382 +#: src/big_remote_play/ui/guest_view.py:422 +msgid "Enter server IP and port" +msgstr "Digite o IP do servidor e a porta" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 385 +#: src/big_remote_play/ui/guest_view.py:425 +msgid "IP/Hostname" +msgstr "IP/Nome do Host" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 389 +#: src/big_remote_play/ui/guest_view.py:429 +msgid "Port" +msgstr "Porta" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 393 +#: src/big_remote_play/ui/guest_view.py:433 +msgid "Use IPv6" +msgstr "Use IPv6" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 435 +#: src/big_remote_play/ui/guest_view.py:475 +msgid "Enter the 6-digit PIN code provided by the host" +msgstr "Digite o código PIN de 6 dígitos fornecido pelo anfitrião." + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 472 +#: src/big_remote_play/ui/guest_view.py:511 +msgid "Clicked Connect..." +msgstr "Clicou em Conectar..." + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 560 +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 598 +#: src/big_remote_play/ui/guest_view.py:597 +#: src/big_remote_play/ui/guest_view.py:645 +msgid "Active Stream" +msgstr "Fluxo Ativo" + # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 299 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 48 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 214 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 772 -msgid "Automatic" -msgstr "Automático" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 301 -msgid "Choose which type of gamepad to emulate on the host" -msgstr "Escolha qual tipo de gamepad emular no host." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 302 -msgid "Motion as DS4" -msgstr "Movimento como DS4" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 302 -msgid "Emulate motion controls as DS4" -msgstr "Emular controles de movimento como DS4" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 303 -msgid "Touchpad as DS4" -msgstr "Touchpad como DS4" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 303 -msgid "Emulate touchpad as DS4" -msgstr "Emular touchpad como DS4" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 304 -msgid "Back Button as Touchpad Click" -msgstr "Botão Voltar como Clique do Touchpad" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 304 -msgid "Use Back button for touchpad click on DS4" -msgstr "Use o botão Voltar para clicar no touchpad do DS4." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 305 -msgid "Randomize MAC (DS5)" -msgstr "Randomizar MAC (DS5)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 305 -msgid "Randomize virtual MAC address for DS5" -msgstr "Randomizar endereço MAC virtual para DS5" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 306 -msgid "Home/Guide Timeout (ms)" -msgstr "Tempo limite (ms) Home/Guia" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 306 -msgid "Hold Back/Select to emulate Guide button. < 0 to disable." -msgstr "Segure Voltar/Selecionar para emular o botão Guia. < 0 para desativar." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 307 -msgid "Enable Keyboard Input" -msgstr "Ativar Entrada de Teclado" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 307 -msgid "Allows guests to control the host system with the keyboard" -msgstr "Permite que os convidados controlem o sistema do anfitrião com o teclado." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 308 -msgid "Enable Mouse Input" -msgstr "Ativar Entrada do Mouse" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 308 -msgid "Allows guests to control the host system with the mouse" -msgstr "Permite que os convidados controlem o sistema do anfitrião com o mouse." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 309 -msgid "Always Send Scancodes" -msgstr "Sempre Enviar Códigos de Varredura" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 309 -msgid "Always send raw key scancodes" -msgstr "Sempre envie códigos de varredura de tecla brutos" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 310 -msgid "Map Right Alt to Windows" -msgstr "Map Alt Gr para Windows" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 310 -msgid "Make Sunshine think the Right Alt key is the Windows key" -msgstr "Faça o Sunshine pensar que a tecla Alt direita é a tecla Windows." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 311 -msgid "High Resolution Scrolling" -msgstr "Rolagem de Alta Resolução" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 311 -msgid "Pass through high resolution scroll events from Moonlight clients" -msgstr "Passe eventos de rolagem de alta resolução dos clientes Moonlight." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 365 -msgid "Auto / Primary" -msgstr "Automático / Primário" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 316 -msgid "Audio Sink" -msgstr "Receptor de Áudio" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 316 -msgid "" -"Listing all available audio sinks is possible by running `pactl list short sinks` (PulseAudio) or " -"`wpctl status` (PipeWire)." -msgstr "" -"Listar todos os dispositivos de áudio disponíveis é possível executando `pactl list short sinks` " -"(PulseAudio) ou `wpctl status` (PipeWire)." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 317 -msgid "Stream Audio" -msgstr "Transmitir Áudio" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 317 -msgid "" -"Whether to stream audio or not. Disabling this can be useful for streaming headless displays as " -"second monitors." -msgstr "" -"Se deve ou não transmitir áudio. Desativar isso pode ser útil para transmitir displays sem cabeça " -"como monitores secundários." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 318 -msgid "Graphics Adapter" -msgstr "Adaptador Gráfico" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 383 -msgid "Specific GPU to use. Default is usually correct." -msgstr "GPU específico a ser utilizado. O padrão geralmente está correto." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 319 -msgid "Display Name" -msgstr "Nome de Exibição" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 384 -msgid "" -"Select the display monitor to capture. Corresponds to the output connector name (e.g., DP-1, " -"HDMI-A-1)." -msgstr "" -"Selecione o monitor de exibição para capturar. Corresponde ao nome do conector de saída (por " -"exemplo, DP-1, HDMI-A-1)." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 320 -msgid "Maximum Bitrate" -msgstr "Taxa de bits máxima" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 320 -msgid "" -"The maximum bitrate (in Kbps) that Sunshine will encode the stream at. If set to 0, it will always " -"use the bitrate requested by Moonlight." -msgstr "" -"A taxa de bits máxima (em Kbps) que o Sunshine irá codificar o stream. Se definida como 0, sempre " -"usará a taxa de bits solicitada pelo Moonlight." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 321 -msgid "Minimum FPS Target" -msgstr "Meta de FPS Mínima" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 321 -msgid "" -"The lowest effective FPS a stream can reach. A value of 0 is treated as roughly half of the " -"stream's FPS. A setting of 20 is recommended if you stream 24 or 30fps content." -msgstr "" -"A menor FPS efetiva que um stream pode alcançar. Um valor de 0 é tratado como aproximadamente " -"metade do FPS do stream. Uma configuração de 20 é recomendada se você transmitir conteúdo a 24 ou " -"30fps." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 326 -msgid "FEC Percentage" -msgstr "Porcentagem de FEC" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 326 -msgid "" -"Percentage of error correcting packets per data packet in each video frame. Higher values can " -"correct for more network packet loss, but at the cost of increasing bandwidth usage." -msgstr "" -"Porcentagem de pacotes de correção de erro por pacote de dados em cada quadro de vídeo. Valores " -"mais altos podem corrigir mais perda de pacotes de rede, mas à custa de aumentar o uso de largura " -"de banda." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 327 -msgid "Quantization Parameter" -msgstr "Parâmetro de Quantização" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 327 +# +# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# +# +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 740 +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 741 +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 787 +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1239 +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1245 +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 562 +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 600 +#: src/big_remote_play/ui/guest_view.py:605 +#: src/big_remote_play/ui/guest_view.py:653 +#: src/big_remote_play/ui/host_view.py:1070 +#: src/big_remote_play/ui/host_view.py:1570 +#: src/big_remote_play/ui/private_network_view.py:831 +msgid "Error" +msgstr "Erro" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 562 +#: src/big_remote_play/ui/guest_view.py:605 +msgid "Failed to connect. Verify if Moonlight is paired." +msgstr "Falha ao conectar. Verifique se o Moonlight está emparelhado." + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 600 +#: src/big_remote_play/ui/guest_view.py:653 +msgid "Failed to connect" +msgstr "Falha ao conectar" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 620 +#: src/big_remote_play/ui/guest_view.py:676 +#, python-brace-format +msgid "Attempting automatic pairing PIN: {}" +msgstr "Tentando emparelhamento automático PIN: {}" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 623 +#: src/big_remote_play/ui/guest_view.py:679 +msgid "Automatic pairing sent!" +msgstr "Emparelhamento automático enviado!" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 634 +#: src/big_remote_play/ui/guest_view.py:690 +msgid "Starting pairing..." +msgstr "Iniciando emparelhamento..." + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 651 +#: src/big_remote_play/ui/guest_view.py:705 +msgid "Paired successfully!" +msgstr "Pareado com sucesso!" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 653 +#: src/big_remote_play/ui/guest_view.py:711 +msgid "Pairing Error" +msgstr "Erro de emparelhamento" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 653 +#: src/big_remote_play/ui/guest_view.py:711 msgid "" -"Some devices may not support Constant Bit Rate. For those devices, QP is used instead. Higher value " -"means more compression, but less quality." +"Could not pair with host.\n" +"Verify the PIN was entered correctly." msgstr "" -"Alguns dispositivos podem não suportar Taxa de Bits Constante. Para esses dispositivos, QP é usado " -"em vez disso. Um valor mais alto significa mais compressão, mas menos qualidade." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 328 -msgid "Minimum CPU Thread Count" -msgstr "Contagem Mínima de Threads da CPU" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 328 -msgid "" -"Increasing the value slightly reduces encoding efficiency, but the tradeoff is usually worth it to " -"gain the use of more CPU cores for encoding. The ideal value is the lowest value that can reliably " -"encode at your desired streaming settings on your hardware." -msgstr "" -"Aumentar o valor reduz ligeiramente a eficiência de codificação, mas a compensação geralmente vale " -"a pena para ganhar o uso de mais núcleos de CPU para codificação. O valor ideal é o menor valor que " -"pode codificar de forma confiável nas configurações de streaming desejadas no seu hardware." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 329 -msgid "HEVC Support" -msgstr "Suporte HEVC" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 330 -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 333 -msgid "Advertised (Recommended)" -msgstr "Anunciado (Recomendado)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 331 -msgid "" -"Allows the client to request HEVC Main or HEVC Main10 video streams. HEVC is more CPU-intensive to " -"encode, so enabling this may reduce performance when using software encoding." -msgstr "" -"Permite que o cliente solicite streams de vídeo HEVC Main ou HEVC Main10. HEVC é mais intensivo em " -"CPU para codificar, portanto, habilitar isso pode reduzir o desempenho ao usar codificação de " -"software." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 332 -msgid "AV1 Support" -msgstr "Suporte AV1" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 334 -msgid "" -"Allows the client to request AV1 Main 8-bit or 10-bit video streams. AV1 is more CPU-intensive to " -"encode, so enabling this may reduce performance when using software encoding." -msgstr "" -"Permite que o cliente solicite streams de vídeo AV1 Main de 8 bits ou 10 bits. O AV1 é mais " -"intensivo em CPU para codificar, portanto, habilitar isso pode reduzir o desempenho ao usar " -"codificação de software." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 335 -msgid "Force a Specific Capture Method" -msgstr "Forçar um Método de Captura Específico" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 336 -msgid "Autodetect (Recommended)" -msgstr "Detecção automática (Recomendado)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 337 -msgid "On automatic mode Sunshine will use the first one that works. NvFBC requires patched nvidia drivers." -msgstr "" -"No modo automático, o Sunshine usará o primeiro que funcionar. NvFBC requer drivers nvidia " -"modificados." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 338 -msgid "Force a Specific Encoder" -msgstr "Forçar um Codificador Específico" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 339 -msgid "Autodetect" -msgstr "Detecção automática" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 342 -msgid "" -"Force a specific encoder, otherwise Sunshine will select the best available option. Note: If you " -"specify a hardware encoder on Windows, it must match the GPU where the display is connected." -msgstr "" -"Force um codificador específico, caso contrário, o Sunshine selecionará a melhor opção disponível. " -"Nota: Se você especificar um codificador de hardware no Windows, ele deve corresponder à GPU onde o " -"display está conectado." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 347 -msgid "Performance Preset" -msgstr "Pré-configuração de Desempenho" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 350 -msgid "" -"Higher numbers improve compression (quality at given bitrate) at the cost of increased encoding " -"latency. Recommended to change only when limited by network or decoder, otherwise similar effect " -"can be accomplished by increasing bitrate." -msgstr "" -"Números mais altos melhoram a compressão (qualidade em uma determinada taxa de bits) à custa de um " -"aumento na latência de codificação. Recomenda-se alterar apenas quando limitado pela rede ou " -"decodificador; caso contrário, um efeito semelhante pode ser alcançado aumentando a taxa de bits." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 351 -msgid "Two-Pass Mode" -msgstr "Modo de Dupla Passagem" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 352 -msgid "Quarter Resolution" -msgstr "Resolução de Quarto" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 352 -msgid "Full Resolution" -msgstr "Resolução Completa" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 353 +"Não foi possível emparelhar com o host. \n" +"Verifique se o PIN foi inserido corretamente." + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 663 +#: src/big_remote_play/ui/guest_view.py:721 +msgid "Canceling connection..." +msgstr "Cancelando conexão..." + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 671 +#: src/big_remote_play/ui/guest_view.py:729 +msgid "Pairing Required" +msgstr "Emparelhamento Necessário" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 671 +#: src/big_remote_play/ui/guest_view.py:729 +#, python-brace-format msgid "" -"Adds preliminary encoding pass. This allows to detect more motion vectors, better distribute " -"bitrate across the frame and more strictly adhere to bitrate limits. Disabling it is not " -"recommended since this can lead to occasional bitrate overshoot and subsequent packet loss." +"Moonlight needs to be paired.\n" +"\n" +"{}" msgstr "" -"Adiciona uma passagem de codificação preliminar. Isso permite detectar mais vetores de movimento, " -"distribuir melhor a taxa de bits pelo quadro e aderir mais estritamente aos limites de taxa de " -"bits. Desativá-lo não é recomendado, pois isso pode levar a picos ocasionais de taxa de bits e " -"subsequente perda de pacotes." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 354 -msgid "Spatial AQ" -msgstr "AQ Espacial" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 354 +"A luz da lua precisa ser emparelhada.\n" +"\n" +"{}" + +# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# +# +# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# +# +# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# +# +# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# +# +# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, +# line: 70 +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1262 +# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 464 +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 587 +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 620 +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 648 +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 692 +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1443 +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 672 +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 784 +#: src/big_remote_play/ui/guest_view.py:730 +#: src/big_remote_play/ui/guest_view.py:844 +#: src/big_remote_play/ui/host_view.py:855 +#: src/big_remote_play/ui/host_view.py:896 +#: src/big_remote_play/ui/host_view.py:923 +#: src/big_remote_play/ui/host_view.py:967 +#: src/big_remote_play/ui/host_view.py:1971 +#: src/big_remote_play/ui/host_view.py:2356 +#: src/big_remote_play/ui/host_view.py:2629 +#: src/big_remote_play/ui/installer_window.py:74 +#: src/big_remote_play/ui/main_window.py:967 +#: src/big_remote_play/ui/performance_monitor.py:506 +#: src/big_remote_play/ui/preferences.py:203 +#: src/big_remote_play/ui/preferences.py:246 +#: src/big_remote_play/ui/preferences.py:255 +#: src/big_remote_play/ui/private_network_view.py:2009 +#: src/big_remote_play/ui/private_network_view.py:2039 +msgid "Cancel" +msgstr "Cancelar" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 678 +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 682 +#: src/big_remote_play/ui/guest_view.py:736 +#: src/big_remote_play/ui/guest_view.py:740 +#, python-brace-format msgid "" -"Assign higher QP values to flat regions of the video. Recommended to enable when streaming at lower " -"bitrates." +"Follow instructions.\n" +"\n" +"1. Provide PIN and Host {} to the server.\n" +"2. On the host, access Sunshine Configuration.\n" +"3. Enter PIN and Host.\n" +"4. Click Send." msgstr "" -"Atribua valores QP mais altos a regiões planas do vídeo. Recomenda-se ativar ao transmitir em taxas " -"de bits mais baixas." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 355 -msgid "Single-frame VBV/HRD percentage increase" -msgstr "Aumento percentual de VBV/HRD de quadro único" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 355 -msgid "" -"By default sunshine uses single-frame VBV/HRD, which means any encoded video frame size is not " -"expected to exceed requested bitrate divided by requested frame rate. Relaxing this restriction can " -"be beneficial and act as low-latency variable bitrate, but may also lead to packet loss if the " -"network doesn't have buffer headroom to handle bitrate spikes. Maximum accepted value is 400, which " -"corresponds to 5x increased encoded video frame upper size limit." -msgstr "" -"Por padrão, o sunshine usa VBV/HRD de quadro único, o que significa que qualquer tamanho de quadro " -"de vídeo codificado não deve exceder a taxa de bits solicitada dividida pela taxa de quadros " -"solicitada. Relaxar essa restrição pode ser benéfico e atuar como taxa de bits variável de baixa " -"latência, mas também pode levar à perda de pacotes se a rede não tiver espaço de buffer suficiente " -"para lidar com picos de taxa de bits. O valor máximo aceito é 400, o que corresponde a um limite " -"superior de tamanho de quadro de vídeo codificado aumentado em 5x." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 356 -msgid "Prefer CAVLC over CABAC in H.264" -msgstr "Prefira CAVLC em vez de CABAC no H.264." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 356 -msgid "" -"Simpler form of entropy coding. CAVLC needs around 10% more bitrate for same quality. Only relevant " -"for really old decoding devices." +"1. Forneça o PIN e o Host {} ao servidor.\n" +"2. No host, acesse a Configuração do Sunshine.\n" +"3. Insira o PIN e o Host.\n" +"4. Clique em Enviar." + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 684 +#: src/big_remote_play/ui/guest_view.py:742 +msgid "Pairing Started" +msgstr "Emparelhamento Iniciado" + +#: src/big_remote_play/ui/guest_view.py:744 +#: src/big_remote_play/ui/guest_view.py:810 +#: src/big_remote_play/ui/preferences.py:186 +#: src/big_remote_play/ui/preferences.py:300 +msgid "OK" msgstr "" -"Forma mais simples de codificação de entropia. CAVLC precisa de cerca de 10% a mais de taxa de bits " -"para a mesma qualidade. Apenas relevante para dispositivos de decodificação realmente antigos." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 361 -msgid "QuickSync Preset" -msgstr "Predefinição QuickSync" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 363 -msgid "Performance preset" -msgstr "Pré-configuração de desempenho" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 364 -msgid "QuickSync Coder (H264)" -msgstr "QuickSync Codificador (H264)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 365 -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 388 -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 395 -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 398 -msgid "Auto" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 719 +#: src/big_remote_play/ui/guest_view.py:779 +msgid "Invalid PIN" +msgstr "PIN inválido" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 719 +#: src/big_remote_play/ui/guest_view.py:779 +msgid "Must contain 6 digits." +msgstr "Deve conter 6 dígitos." + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 740 +#: src/big_remote_play/ui/guest_view.py:800 +#, python-brace-format +msgid "Host found: {}" +msgstr "Host encontrado: {}" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 745 +#: src/big_remote_play/ui/guest_view.py:805 +msgid "Host Not Found" +msgstr "Host Não Encontrado" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 745 +#: src/big_remote_play/ui/guest_view.py:805 +msgid "Could not find a host with this PIN on the local network..." +msgstr "Não foi possível encontrar um host com este PIN na rede local..." + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 757 +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 767 +#: src/big_remote_play/ui/guest_view.py:817 +#: src/big_remote_play/ui/guest_view.py:827 +#, python-brace-format +msgid "Set: {}" +msgstr "Conjunto: {}" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 759 +#: src/big_remote_play/ui/guest_view.py:819 +msgid "Custom Resolution" +msgstr "Resolução Personalizada" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 759 +#: src/big_remote_play/ui/guest_view.py:819 +msgid "Enter WxH:" +msgstr "Digite WxH:" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 768 +#: src/big_remote_play/ui/guest_view.py:828 +msgid "Custom FPS" +msgstr "FPS Personalizado" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 768 +#: src/big_remote_play/ui/guest_view.py:828 +msgid "Use a number" +msgstr "Use um número" + +# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# +# +# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# +# +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 299 +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 48 +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 214 +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 772 +#: src/big_remote_play/ui/guest_view.py:832 +#: src/big_remote_play/ui/host_view.py:61 +#: src/big_remote_play/ui/host_view.py:275 +#: src/big_remote_play/ui/host_view.py:328 +#: src/big_remote_play/ui/preferences.py:53 +#: src/big_remote_play/ui/sunshine_preferences.py:485 +msgid "Automatic" msgstr "Automático" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 366 -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 396 -msgid "Entropy coding mode" -msgstr "Modo de codificação de entropia" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 367 -msgid "Allow Slow HEVC Encoding" -msgstr "Permitir Codificação HEVC Lenta" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 367 -msgid "" -"This can enable HEVC encoding on older Intel GPUs, at the cost of higher GPU usage and worse " -"performance." + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 780 +#: src/big_remote_play/ui/guest_view.py:840 +msgid "Value" +msgstr "Valor" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 785 +#: src/big_remote_play/ui/guest_view.py:845 +#: src/big_remote_play/ui/sunshine_preferences.py:319 +msgid "Apply" +msgstr "Aplicar" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 813 +#: src/big_remote_play/ui/guest_view.py:969 +msgid "Reset defaults?" +msgstr "Redefinir padrões?" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 813 +#: src/big_remote_play/ui/guest_view.py:969 +msgid "All client settings will be restored." +msgstr "Todas as configurações do cliente serão restauradas." + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 815 +#: src/big_remote_play/ui/guest_view.py:971 +msgid "No" msgstr "" -"Isso pode habilitar a codificação HEVC em GPUs Intel mais antigas, com o custo de maior uso da GPU " -"e pior desempenho." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 372 -msgid "AMF Usage" -msgstr "Uso do AMF" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 376 -msgid "" -"This sets the base encoding profile. All options presented below will override a subset of the " -"usage profile, but there are additional hidden settings applied that cannot be configured elsewhere." -msgstr "" -"Isso define o perfil de codificação base. Todas as opções apresentadas abaixo substituirão um " -"subconjunto do perfil de uso, mas existem configurações adicionais ocultas aplicadas que não podem " -"ser configuradas em outro lugar." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 377 -msgid "AMF Rate Control" -msgstr "Controle de Taxa AMF" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 380 -msgid "" -"This controls the rate control method to ensure we are not exceeding the client bitrate target. " -"'cqp' is not suitable for bitrate targeting, and other options besides 'vbr_latency' depend on HRD " -"Enforcement to help constrain bitrate overflows." -msgstr "" -"Isto controla o método de controle de taxa para garantir que não estamos excedendo a meta de " -"bitrate do cliente. 'cqp' não é adequado para direcionamento de bitrate, e outras opções além de " -"'vbr_latency' dependem da Aplicação de HRD para ajudar a restringir transbordamentos de bitrate." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 381 -msgid "AMF Hypothetical Reference Decoder (HRD) Enforcement" -msgstr "Aplicação de Decodificador de Referência Hipotética AMF (HRD)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 381 -msgid "" -"Increases the constraints on rate control to meet HRD model requirements. This greatly reduces " -"bitrate overflows, but may cause encoding artifacts or reduced quality on certain cards." -msgstr "" -"Aumenta as restrições no controle de taxa para atender aos requisitos do modelo HRD. Isso reduz " -"significativamente os transbordamentos de taxa de bits, mas pode causar artefatos de codificação ou " -"qualidade reduzida em certos cartões." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 382 -msgid "AMF Quality" -msgstr "Qualidade AMF" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 383 -msgid "Speed" -msgstr "Velocidade" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 383 -msgid "Balanced" -msgstr "Equilibrado" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 383 -msgid "Quality" -msgstr "Qualidade" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 384 -msgid "This controls the balance between encoding speed and quality." -msgstr "Isto controla o equilíbrio entre a velocidade de codificação e a qualidade." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 385 -msgid "AMF Preanalysis" -msgstr "Pré-análise AMF" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 385 -msgid "" -"This enables rate-control preanalysis, which may increase quality at the expense of increased " -"encoding latency." + +#: src/big_remote_play/ui/guest_view.py:972 +msgid "Yes" msgstr "" -"Isso permite a pré-análise de controle de taxa, o que pode aumentar a qualidade à custa de um " -"aumento na latência de codificação." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 386 -msgid "AMF Variance Based Adaptive Quantization (VBAQ)" -msgstr "Quantização Adaptativa Baseada em Variância AMF (VBAQ)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 386 -msgid "" -"The human visual system is typically less sensitive to artifacts in highly textured areas. In VBAQ " -"mode, pixel variance is used to indicate the complexity of spatial textures, allowing the encoder " -"to allocate more bits to smoother areas. Enabling this feature leads to improvements in subjective " -"visual quality with some content." -msgstr "" -"O sistema visual humano é tipicamente menos sensível a artefatos em áreas altamente texturizadas. " -"No modo VBAQ, a variância de pixels é usada para indicar a complexidade das texturas espaciais, " -"permitindo que o codificador aloque mais bits para áreas mais suaves. Habilitar este recurso leva a " -"melhorias na qualidade visual subjetiva com alguns conteúdos." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 387 -msgid "AMF Coder (H264)" -msgstr "Codificador AMF (H264)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 389 -msgid "Allows you to select the entropy encoding to prioritize quality or encoding speed. H.264 only." -msgstr "" -"Permite selecionar a codificação de entropia para priorizar qualidade ou velocidade de codificação. " -"Apenas H.264." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 394 -msgid "VideoToolbox Coder" -msgstr "VideoToolbox Codificador" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 397 -msgid "VideoToolbox Software Encoding" -msgstr "Codificação de Software VideoToolbox" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 398 -msgid "Allowed" -msgstr "Permitido" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 398 -msgid "Forced" -msgstr "Forçado" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 399 -msgid "Allow fallback to software encoding" -msgstr "Permitir retorno para codificação de software" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 400 -msgid "VideoToolbox Realtime Encoding" -msgstr "Codificação em Tempo Real do VideoToolbox" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 400 -msgid "Realtime encoding priority" -msgstr "Prioridade de codificação em tempo real" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 405 -msgid "Strictly enforce frame bitrate limits for H.264/HEVC on AMD GPUs" -msgstr "Imponha rigorosamente os limites de taxa de bits de quadro para H.264/HEVC em GPUs AMD." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 405 + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 913 +# +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 913 +# +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 +# +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 +# +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 +# +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 +# +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 +# +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 +# +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 +# +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 +# +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 +# +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 +# +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 +# +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 +# +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 +# +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 +# +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 +# +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 +# +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 +# +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 +# +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 816 +# +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 816 +# +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 816 +# +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 816 +# +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 825 +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 831 +#: src/big_remote_play/ui/guest_view.py:981 +#: src/big_remote_play/ui/guest_view.py:987 +msgid "Restored" +msgstr "Restaurado" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 968 +#: src/big_remote_play/ui/guest_view.py:1029 +msgid "Keyboard Shortcuts" +msgstr "Atalhos de Teclado" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 969 +#: src/big_remote_play/ui/guest_view.py:1030 +msgid "Common shortcuts used during streaming" +msgstr "Atalhos comuns usados durante a transmissão" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 972 +#: src/big_remote_play/ui/guest_view.py:1033 +msgid "Quit Stream" +msgstr "Sair do Stream" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 973 +#: src/big_remote_play/ui/guest_view.py:1034 +msgid "Toggle Mouse Capture" +msgstr "Alternar Captura do Mouse" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 974 +#: src/big_remote_play/ui/guest_view.py:1035 +msgid "Toggle Stats Overlay" +msgstr "Alternar Sobreposição de Estatísticas" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 975 +#: src/big_remote_play/ui/guest_view.py:1036 +msgid "Toggle Mouse Mode (Remote/Absolute)" +msgstr "Alternar Modo do Mouse (Remoto/Absoluto)" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 976 +#: src/big_remote_play/ui/guest_view.py:1037 +msgid "Switch Monitor (Multi-Head Host)" +msgstr "Alternar Monitor (Host Multi-Cabeça)" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 977 +#: src/big_remote_play/ui/guest_view.py:1038 +msgid "Toggle Fullscreen" +msgstr "Alternar Tela Cheia" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 992 +#: src/big_remote_play/ui/guest_view.py:1053 +msgid "Multi-Monitor Support" +msgstr "Suporte a Múltiplos Monitores" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 993 +#: src/big_remote_play/ui/guest_view.py:1054 +msgid "Instructions for hosts with multiple displays" +msgstr "Instruções para anfitriões com múltiplos monitores" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 997 +#: src/big_remote_play/ui/guest_view.py:1058 +msgid "Switching Monitors" +msgstr "Alternando Monitores" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 999 +#: src/big_remote_play/ui/guest_view.py:1060 msgid "" -"Enabling this option can avoid dropped frames over the network during scene changes, but video " -"quality may be reduced during motion." +"If the Host PC has multiple monitors, you can switch between them easily.\n" +"\n" +"1. Use Ctrl+Alt+Shift + F1 (Screen 1), F2 (Screen 2), etc.\n" +"2. If this shortcut doesn't work, ensure 'Grab Input' is active (Ctrl+Alt+Shift + Z).\n" +"3. Why did F1/F2 stop working? The system likely updated and now requires the full shortcut combo to avoid conflicts." msgstr "" -"Ativar esta opção pode evitar quadros perdidos na rede durante as mudanças de cena, mas a qualidade " -"do vídeo pode ser reduzida durante o movimento." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 410 -msgid "SW Presets" -msgstr "Predefinições de SW" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 413 +"Se o PC Host tiver vários monitores, você pode alternar entre eles facilmente.\n" +"\n" +"1. Use Ctrl+Alt+Shift + F1 (Tela 1), F2 (Tela 2), etc.\n" +"2. Se este atalho não funcionar, certifique-se de que 'Capturar Entrada' está ativo (Ctrl+Alt+Shift + Z).\n" +"3. Por que F1/F2 pararam de funcionar? O sistema provavelmente foi atualizado e agora requer a combinação completa de atalhos para evitar conflitos." + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 1007 +#: src/big_remote_play/ui/guest_view.py:1068 +msgid "Troubleshooting: Shortcuts Not Working?" +msgstr "Solução de Problemas: Atalhos Não Funcionando?" + +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 1009 +#: src/big_remote_play/ui/guest_view.py:1070 msgid "" -"Optimize the trade-off between encoding speed (encoded frames per second) and compression " -"efficiency (quality per bit in the bitstream). Defaults to superfast." +"If shortcuts like F1/F2/F3 are not switching screens:\n" +"• Press Ctrl+Alt+Shift + Z to toggle Mouse/Keyboard Capture.\n" +"• When capture is ON, your F-keys are sent to the remote PC.\n" +"• When capture is OFF, your local PC intercepts them." msgstr "" -"Otimize a relação entre a velocidade de codificação (quadros codificados por segundo) e a " -"eficiência de compressão (qualidade por bit no fluxo de bits). O padrão é superrápido." -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 414 -msgid "SW Tune" -msgstr "Ajuste de SW" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 416 -msgid "Tuning options, which are applied after the preset. Defaults to zerolatency." -msgstr "Opções de ajuste, que são aplicadas após o predefinido. O padrão é zerolatência." -# -# File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, line: 166 -msgid "Waiting for data..." -msgstr "Aguardando dados..." -# -# File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, line: 198 -msgid "{} Active Devices" -msgstr "{} Dispositivos Ativos" -# -# File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, line: 252 -msgid "Latency" -msgstr "Latência" -# -# File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, line: 318 -# File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, line: 723 -msgid "Real-time Monitoring" -msgstr "Monitoramento em tempo real" -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, line: 325 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 159 -msgid "Disconnected" -msgstr "Desconectado" -# -# File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, line: 488 -# File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, line: 528 -# File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, line: 548 -# File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, line: 565 -# File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, line: 681 +"Se atalhos como F1/F2/F3 não estão trocando de tela:\n" +"• Pressione Ctrl+Alt+Shift + Z para alternar a Captura de Mouse/Teclado.\n" +"• Quando a captura está ATIVADA, suas teclas F são enviadas para o PC remoto.\n" +"• Quando a captura está DESATIVADA, seu PC local as intercepta." + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 113 +#: src/big_remote_play/ui/host_view.py:156 +msgid "Sunshine Offline" +msgstr "Sol Offline" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 122 +#: src/big_remote_play/ui/host_view.py:160 +msgid "Game Configuration" +msgstr "Configuração do Jogo" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 131 +#: src/big_remote_play/ui/host_view.py:169 +msgid "Game Mode" +msgstr "Modo de Jogo" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 131 +#: src/big_remote_play/ui/host_view.py:169 +msgid "Select game source" +msgstr "Selecione a fonte do jogo" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 133 +#: src/big_remote_play/ui/host_view.py:171 +msgid "Full Desktop" +msgstr "Área de Trabalho Completa" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 133 +#: src/big_remote_play/ui/host_view.py:171 +msgid "Custom App" +msgstr "Aplicativo Personalizado" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 137 +#: src/big_remote_play/ui/host_view.py:175 +msgid "Game Selection" +msgstr "Seleção de Jogo" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 138 +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 143 +#: src/big_remote_play/ui/host_view.py:176 +#: src/big_remote_play/ui/host_view.py:181 +msgid "Choose game from list" +msgstr "Escolha um jogo da lista" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 142 +#: src/big_remote_play/ui/host_view.py:180 +msgid "Select Game" +msgstr "Selecionar Jogo" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 150 +#: src/big_remote_play/ui/host_view.py:188 +msgid "Application Details" +msgstr "Detalhes do Aplicativo" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 151 +#: src/big_remote_play/ui/host_view.py:189 +msgid "Configure name and command" +msgstr "Configurar nome e comando" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 155 +#: src/big_remote_play/ui/host_view.py:193 +msgid "Application Name" +msgstr "Nome do Aplicativo" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 159 +#: src/big_remote_play/ui/host_view.py:197 +msgid "Command" +msgstr "Comando" + +#: src/big_remote_play/ui/host_view.py:202 +msgid "Browse host for executable" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:203 +msgid "Browse host filesystem" +msgstr "" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 164 +#: src/big_remote_play/ui/host_view.py:210 +msgid "Streaming Settings" +msgstr "Configurações de Streaming" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 165 +#: src/big_remote_play/ui/host_view.py:211 +msgid "Quality and Players" +msgstr "Qualidade e Jogadores" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 182 +#: src/big_remote_play/ui/host_view.py:228 +msgid "Frames per second" +msgstr "Quadros por segundo" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 192 +#: src/big_remote_play/ui/host_view.py:238 +msgid "Bandwidth Limit (Mbps)" +msgstr "Limite de Largura de Banda (Mbps)" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 193 +#: src/big_remote_play/ui/host_view.py:239 +msgid "Max bitrate (0 = Unlimited)" +msgstr "Taxa de bits máxima (0 = Ilimitado)" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 188 +#: src/big_remote_play/ui/host_view.py:249 +msgid "Hardware and Capture" +msgstr "Hardware e Captura" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 189 +#: src/big_remote_play/ui/host_view.py:250 +msgid "Monitor, GPU, and Capture Method" +msgstr "Monitor, GPU e Método de Captura" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 193 +#: src/big_remote_play/ui/host_view.py:254 +msgid "Monitor / Display" +msgstr "Monitor / Exibição" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 194 +#: src/big_remote_play/ui/host_view.py:255 +msgid "Select the display to capture" +msgstr "Selecione a tela para capturar" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 202 +#: src/big_remote_play/ui/host_view.py:263 +msgid "Graphics Card / Encoder" +msgstr "Placa de Vídeo / Codificador" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 203 +#: src/big_remote_play/ui/host_view.py:264 +msgid "Choose hardware for video encoding" +msgstr "Escolha hardware para codificação de vídeo" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 211 +#: src/big_remote_play/ui/host_view.py:272 +msgid "Capture Method" +msgstr "Método de Captura" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 212 +#: src/big_remote_play/ui/host_view.py:273 +msgid "Wayland (recommended), X11 (legacy), or KMS (direct)" +msgstr "Wayland (recomendado), X11 (legado) ou KMS (direto)" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 214 +#: src/big_remote_play/ui/host_view.py:275 +msgid "KMS (Direct)" +msgstr "KMS (Direto)" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 238 +#: src/big_remote_play/ui/host_view.py:284 +msgid "Efficient Codecs (HEVC/AV1)" +msgstr "Codecs Eficientes (HEVC/AV1)" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 239 +#: src/big_remote_play/ui/host_view.py:285 +msgid "" +"Enable H.265/AV1 for better quality at lower bitrate (Requires support)" +msgstr "" +"Ativar H.265/AV1 para melhor qualidade com menor taxa de bits (Requer " +"suporte)" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 244 +#: src/big_remote_play/ui/host_view.py:290 +msgid "Optimization Mode" +msgstr "Modo de Otimização" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 245 +#: src/big_remote_play/ui/host_view.py:291 +msgid "Balance between responsiveness and image quality" +msgstr "Equilíbrio entre capacidade de resposta e qualidade da imagem" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 247 +#: src/big_remote_play/ui/host_view.py:293 +msgid "Low Latency (Fastest)" +msgstr "Baixa Latência (Mais Rápido)" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 248 +#: src/big_remote_play/ui/host_view.py:294 +msgid "Balanced (Default)" +msgstr "Equilibrado (Padrão)" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 249 +#: src/big_remote_play/ui/host_view.py:295 +msgid "High Quality (Best Image)" +msgstr "Alta Qualidade (Melhor Imagem)" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 255 +#: src/big_remote_play/ui/host_view.py:301 +msgid "Wi-Fi / Unstable Network Mode" +msgstr "Wi-Fi / Modo de Rede Instável" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 256 +#: src/big_remote_play/ui/host_view.py:302 +msgid "Increases error correction (FEC) to prevent glitches" +msgstr "Aumenta a correção de erros (FEC) para prevenir falhas." + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 225 +#: src/big_remote_play/ui/host_view.py:311 +msgid "Sound settings" +msgstr "Configurações de som" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 229 +#: src/big_remote_play/ui/host_view.py:315 +msgid "Host Audio Output" +msgstr "Saída de Áudio do Host" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 230 +#: src/big_remote_play/ui/host_view.py:316 +msgid "Where YOU will hear the game sound" +msgstr "Onde VOCÊ ouvirá o som do jogo" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 305 +#: src/big_remote_play/ui/host_view.py:323 +msgid "Audio Output Mode" +msgstr "Modo de Saída de Áudio" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 306 +#: src/big_remote_play/ui/host_view.py:324 +msgid "Determine where the game sound will be played" +msgstr "Determine onde o som do jogo será reproduzido." + +# File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, +# line: 488 +# File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, +# line: 528 +# File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, +# line: 548 +# File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, +# line: 565 +# File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, +# line: 681 +#: src/big_remote_play/ui/host_view.py:329 +#: src/big_remote_play/ui/performance_monitor.py:630 +#: src/big_remote_play/ui/performance_monitor.py:650 +#: src/big_remote_play/ui/performance_monitor.py:668 +#: src/big_remote_play/ui/performance_monitor.py:815 msgid "Guest" msgstr "Convidado" -# -# File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, line: 802 -msgid "Active Connection" -msgstr "Conexão Ativa" -# -# File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, line: 666 -msgid "{} devices monitoring" -msgstr "monitoramento de dispositivos {}" -# -# File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, line: 669 -msgid "Active - No devices" -msgstr "Ativo - Nenhum dispositivo" -# -# File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, line: 864 -msgid "Disconnect this specific guest (Admin)" -msgstr "Desconectar este convidado específico (Admin)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, line: 722 -msgid "Connected to {}" -msgstr "Conectado a {}" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 24 -msgid "Create Headscale Server" -msgstr "Criar Servidor Headscale" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 25 -msgid "Set up your own private VPN server using Docker + Cloudflare DNS." -msgstr "Configure seu próprio servidor VPN privado usando Docker + DNS do Cloudflare." -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 26 -msgid "Connect to Headscale Network" -msgstr "Conectar à Rede Headscale" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 27 -msgid "Enter the server domain and auth key provided by the administrator." -msgstr "Insira o domínio do servidor e a chave de autenticação fornecida pelo administrador." -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 34 -msgid "Login to Tailscale" -msgstr "Faça login no Tailscale" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 35 -msgid "Login to your Tailscale account. No server required." -msgstr "Faça login na sua conta Tailscale. Nenhum servidor necessário." -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 36 -msgid "Connect to Tailscale Network" -msgstr "Conectar à Rede Tailscale" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 37 -msgid "Enter your auth key or login server to join a Tailscale network." -msgstr "Insira sua chave de autenticação ou servidor de login para ingressar em uma rede Tailscale." -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 44 -msgid "Create ZeroTier Network" -msgstr "Criar Rede ZeroTier" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 45 -msgid "Create a new ZeroTier virtual network using your API token." -msgstr "Crie uma nova rede virtual ZeroTier usando seu token de API." -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 46 -msgid "Connect to ZeroTier Network" -msgstr "Conectar à Rede ZeroTier" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 47 -msgid "Enter the 16-character Network ID to join a ZeroTier network." -msgstr "Insira o ID de Rede de 16 caracteres para ingressar em uma rede ZeroTier." -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 238 -msgid "Configuration" -msgstr "Configuração" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 508 -msgid "Instructions" -msgstr "Instruções" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 277 -msgid "Logout / Disconnect" -msgstr "Sair / Desconectar" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 289 -msgid "Your Networks" -msgstr "Suas Redes" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 302 -msgid "Login / Connect" -msgstr "Entrar / Conectar" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 303 -msgid "Save Token & Create Network" -msgstr "Salvar Token e Criar Rede" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 502 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1764 -msgid "Install Server" -msgstr "Instalar Servidor" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 322 -msgid "Domain (e.g. vpn.ruscher.org)" -msgstr "Domínio (por exemplo, vpn.ruscher.org)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 323 -msgid "Cloudflare Zone ID" -msgstr "ID da Zona Cloudflare" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 324 -msgid "Cloudflare API Token" -msgstr "Token da API do Cloudflare" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 332 -msgid "Auth Key (optional – leave empty for browser login)" -msgstr "Chave de autenticação (opcional – deixe em branco para login no navegador)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 335 -msgid "Get auth key" -msgstr "Obter chave de autenticação" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 335 -msgid "login.tailscale.com/admin/settings/keys" -msgstr "login.tailscale.com/admin/configurações/chaves" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 337 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 362 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1318 -msgid "Open" -msgstr "Abrir" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 352 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1346 -msgid "ZeroTier API Token" -msgstr "Token da API do ZeroTier" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 355 -msgid "Network Name" -msgstr "Nome da Rede" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 360 -msgid "Get API Token" -msgstr "Obter Token da API" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 360 -msgid "my.zerotier.com → Account → API Access Tokens" -msgstr "my.zerotier.com → Conta → Tokens de Acesso à API" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 438 -msgid "All fields are required" -msgstr "Todos os campos são obrigatórios" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 456 -msgid "✅ Server installed!" -msgstr "✅ Servidor instalado!" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 460 -msgid "Headscale server installed!" -msgstr "Servidor Headscale instalado!" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 463 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 492 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 530 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1503 -msgid "❌ Failed" -msgstr "❌ Falhou" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 464 -msgid "Installation failed. Check the log." -msgstr "A instalação falhou. Verifique o log." -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 485 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1479 -msgid "✅ Connected!" -msgstr "✅ Conectado!" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 489 -msgid "Tailscale connected!" -msgstr "Tailscale conectado!" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 493 -msgid "Login failed" -msgstr "Falha no login" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 533 -msgid "API Token is required" -msgstr "Token de API é necessário" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 520 -msgid "✅ Network created!" -msgstr "✅ Rede criada!" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 526 -msgid "ZeroTier network created!" -msgstr "Rede ZeroTier criada!" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 531 -msgid "Network creation failed" -msgstr "Falha na criação da rede" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 548 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 557 -msgid "Setup Instructions" -msgstr "Instruções de Configuração" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 558 -msgid "Step-by-step guide to create your private network" -msgstr "Guia passo a passo para criar sua rede privada" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 578 -msgid "1. Register a Free Domain" -msgstr "1. Registre um Domínio Gratuito" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 579 -msgid "Get a free .us.kg domain to use with your server" -msgstr "Obtenha um domínio .us.kg gratuito para usar com seu servidor." -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 582 -msgid "Access DigitalPlat Domain" -msgstr "Acessar o Domínio DigitalPlat" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 583 -msgid "Register and get your free domain (e.g.: myserver.us.kg)" -msgstr "Registre-se e obtenha seu domínio gratuito (por exemplo: meuServidor.us.kg)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 586 -msgid "Open Site" -msgstr "Abrir Site" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 597 -msgid "Steps at DigitalPlat" -msgstr "Etapas no DigitalPlat" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 599 -msgid "" -"1. Create an account or login\n" -"2. Choose a .us.kg domain\n" -"3. Complete the simple registration\n" -"4. Write down the domain you obtained (e.g.: myserver.us.kg)" + +# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# +# +# File: big-remote-play/usr/share/big-remote-play/utils/network.py, line: 267 +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 992 +#: src/big_remote_play/ui/host_view.py:330 +#: src/big_remote_play/utils/network.py:259 +msgid "Host" +msgstr "Hospedeiro" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 313 +#: src/big_remote_play/ui/host_view.py:331 +msgid "Guest + Host" +msgstr "Convidado + Anfitrião" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 245 +#: src/big_remote_play/ui/host_view.py:341 +msgid "Audio Mixer (Sources)" +msgstr "Misturador de Áudio (Fontes)" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 246 +#: src/big_remote_play/ui/host_view.py:342 +msgid "Manage audio sources" +msgstr "Gerenciar fontes de áudio" + +# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# +# +# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, +# line: 174 +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 255 +#: src/big_remote_play/ui/host_view.py:351 +#: src/big_remote_play/ui/moonlight_preferences.py:126 +msgid "Advanced Settings" +msgstr "Configurações Avançadas" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 256 +#: src/big_remote_play/ui/host_view.py:352 +msgid "Input, Network, and Access" +msgstr "Entrada, Rede e Acesso" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 266 +#: src/big_remote_play/ui/host_view.py:356 +msgid "Automatic UPnP" +msgstr "UPnP automático" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 267 +#: src/big_remote_play/ui/host_view.py:357 +msgid "Automatically configure router ports" +msgstr "Configurar automaticamente as portas do roteador" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 272 +#: src/big_remote_play/ui/host_view.py:362 +msgid "Address Family (IPv4 + IPv6)" +msgstr "Família de Endereços (IPv4 + IPv6)" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 273 +#: src/big_remote_play/ui/host_view.py:363 +msgid "Enable simultaneous IPv4 and IPv6 support on server" +msgstr "Ativar suporte simultâneo a IPv4 e IPv6 no servidor" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 278 +#: src/big_remote_play/ui/host_view.py:368 +msgid "Origin Web UI Allowed (WAN)" +msgstr "Interface Web de Origem Permitida (WAN)" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 279 +#: src/big_remote_play/ui/host_view.py:369 +msgid "Allows anyone to access the web interface (Anyone may access Web UI)" msgstr "" -"1. Crie uma conta ou faça login\n" -"2. Escolha um domínio .us.kg\n" -"3. Complete o registro simples\n" -"4. Anote o domínio que você obteve (por exemplo: meuServidor.us.kg)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 613 -msgid "2. Configure Cloudflare" -msgstr "2. Configurar o Cloudflare" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 614 -msgid "Point your domain to Cloudflare for DNS management" -msgstr "Aponte seu domínio para o Cloudflare para gerenciamento de DNS." -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 617 -msgid "Access Cloudflare Dashboard" -msgstr "Acessar o Painel do Cloudflare" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 618 -msgid "Create a free account and add your domain" -msgstr "Crie uma conta gratuita e adicione seu domínio." -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 621 -msgid "Open Cloudflare" -msgstr "Abra o Cloudflare" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 632 -msgid "Setup Steps" -msgstr "Etapas de Configuração" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 634 -msgid "" -"1. Click 'Add a site' → Enter your domain\n" -"2. Choose the FREE plan\n" -"3. Write down the 2 nameservers provided\n" -"4. Go back to your domain provider (DigitalPlat)\n" -"5. Replace the DNS with Cloudflare's nameservers\n" -"6. Wait for DNS propagation (may take a few minutes)" +"Permite que qualquer pessoa acesse a interface da web (Qualquer um pode " +"acessar a interface da Web)" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 284 +#: src/big_remote_play/ui/host_view.py:374 +msgid "Configure Firewall (IPv6)" +msgstr "Configurar Firewall (IPv6)" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 285 +#: src/big_remote_play/ui/host_view.py:375 +msgid "Open TCP/UDP ports required for external connection" +msgstr "Abra as portas TCP/UDP necessárias para conexão externa." + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 288 +#: src/big_remote_play/ui/host_view.py:378 +#: src/big_remote_play/ui/host_view.py:410 +msgid "Configure" +msgstr "Configurar" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 310 +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 909 +#: src/big_remote_play/ui/host_view.py:402 +#: src/big_remote_play/ui/host_view.py:1225 +msgid "Start Server" +msgstr "Iniciar Servidor" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 317 +#: src/big_remote_play/ui/host_view.py:411 +msgid "Configure Sunshine" +msgstr "Configurar Sunshine" + +#: src/big_remote_play/ui/host_view.py:415 +msgid "Generate PIN" msgstr "" -"1. Clique em 'Adicionar um site' → Insira seu domínio\n" -"2. Escolha o plano GRATUITO\n" -"3. Anote os 2 servidores de nomes fornecidos\n" -"4. Volte para o seu provedor de domínio (DigitalPlat)\n" -"5. Substitua o DNS pelos servidores de nomes do Cloudflare\n" -"6. Aguarde a propagação do DNS (pode levar alguns minutos)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 646 -msgid "Update Nameservers" -msgstr "Atualizar Servidores de Nomes" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 647 -msgid "Open domain panel to change NS records" -msgstr "Abra o painel de domínio para alterar os registros NS." -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 650 -msgid "Domain Panel" -msgstr "Painel de Domínio" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 668 -msgid "3. Get API Credentials" -msgstr "3. Obter Credenciais da API" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 669 -msgid "Obtain your Zone ID and API Token from Cloudflare" -msgstr "Obtenha seu ID de Zona e Token da API do Cloudflare." -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 382 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 672 -msgid "Zone ID" -msgstr "ID da Zona" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 674 -msgid "" -"1. In Cloudflare, click on your domain\n" -"2. Scroll down to the 'API' section on the right\n" -"3. Copy the 'Zone ID' value" + +#: src/big_remote_play/ui/host_view.py:416 +msgid "Generate a PIN for a guest" msgstr "" -"1. No Cloudflare, clique no seu domínio\n" -"2. Role para baixo até a seção 'API' à direita\n" -"3. Copie o valor 'ID da Zona'" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 385 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 682 -msgid "API Token" -msgstr "Token da API" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 684 -msgid "" -"1. Click 'Get your API token' (below Zone ID)\n" -"2. Click 'Create Token'\n" -"3. Use template: 'Edit zone DNS' → 'Use template'\n" -"4. Configure:\n" -" • Token name: VPN-Token\n" -" • Permissions: Zone - DNS - Edit\n" -" • Zone: Select your domain\n" -"5. Click 'Continue to summary' → 'Create Token'\n" -"6. Copy token IMMEDIATELY (shown only once!)" + +#: src/big_remote_play/ui/host_view.py:438 +msgid "Management" +msgstr "Gerenciamento" + +#: src/big_remote_play/ui/host_view.py:441 +#: src/big_remote_play/ui/host_view.py:1872 +msgid "Paired Devices" +msgstr "Dispositivos pareados" + +#: src/big_remote_play/ui/host_view.py:442 +msgid "View, disable or remove paired clients" msgstr "" -"1. Clique em 'Obter seu token de API' (abaixo do ID da Zona)\n" -"2. Clique em 'Criar Token'\n" -"3. Use o modelo: 'Editar DNS da zona' → 'Usar modelo'\n" -"4. Configure:\n" -" • Nome do token: VPN-Token\n" -" • Permissões: Zona - DNS - Editar\n" -" • Zona: Selecione seu domínio\n" -"5. Clique em 'Continuar para resumo' → 'Criar Token'\n" -"6. Copie o token IMEDIATAMENTE (exibido apenas uma vez!)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 703 -msgid "4. Configure DNS Records in Cloudflare" -msgstr "4. Configurar Registros DNS no Cloudflare" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 704 -msgid "Create DNS records pointing to your public IP" -msgstr "Crie registros DNS apontando para o seu IP público." -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 708 -msgid "Your Public IP" -msgstr "Seu IP Público" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 709 -msgid "Use this IP for the A record below" -msgstr "Use este IP para o registro A abaixo" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 712 -msgid "Loading..." -msgstr "Carregando..." -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 843 -msgid "Copied!" -msgstr "Copiado!" -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 720 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 365 -msgid "Copy IP" -msgstr "Copiar IP" -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 740 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 741 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 787 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1239 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1245 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 562 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 600 -msgid "Error" -msgstr "Erro" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 746 -msgid "A Record" -msgstr "Um Registro" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 748 -msgid "" -"In Cloudflare → DNS → Records → 'Add record':\n" -"• Type: A\n" -"• Name: @\n" -"• Content: YOUR-PUBLIC-IP (shown above)\n" -"• Proxy: OFF (gray cloud — IMPORTANT!)" + +#: src/big_remote_play/ui/host_view.py:449 +#: src/big_remote_play/ui/host_view.py:2008 +msgid "Sunshine Logs" +msgstr "Logs do Sunshine" + +#: src/big_remote_play/ui/host_view.py:450 +msgid "View the Sunshine server log" msgstr "" -"No Cloudflare → DNS → Registros → 'Adicionar registro':\n" -"• Tipo: A\n" -"• Nome: @\n" -"• Conteúdo: SEU-IP-PÚBLICO (mostrado acima)\n" -"• Proxy: DESLIGADO (nuvem cinza — IMPORTANTE!)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 758 -msgid "CNAME Record" -msgstr "Registro CNAME" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 760 -msgid "" -"Add another record:\n" -"• Type: CNAME\n" -"• Name: www\n" -"• Target: @\n" -"• Proxy: OFF" + +#: src/big_remote_play/ui/host_view.py:457 +#: src/big_remote_play/ui/host_view.py:2094 +msgid "Game Library" +msgstr "Biblioteca de jogos" + +#: src/big_remote_play/ui/host_view.py:458 +msgid "Manage games shown to guests in Moonlight" msgstr "" -"Adicionar outro registro:\n" -"• Tipo: CNAME\n" -"• Nome: www\n" -"• Destino: @\n" -"• Proxy: DESLIGADO" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 775 -msgid "5. Configure Router (Port Forwarding)" -msgstr "5. Configurar Roteador (Encaminhamento de Porta)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 778 -msgid "" -"Open ports 8080, 9443, 41641. Note: The installation script will attempt to configure these " -"automatically via UPnP." + +#: src/big_remote_play/ui/host_view.py:465 +#: src/big_remote_play/ui/host_view.py:2323 +msgid "Server Password" msgstr "" -"Abra as portas 8080, 9443, 41641. Nota: O script de instalação tentará configurá-las " -"automaticamente via UPnP." -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 779 -msgid "Access Your Router" -msgstr "Acesse seu Roteador" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 781 + +#: src/big_remote_play/ui/host_view.py:466 +msgid "Change or reset the Sunshine login (if you forgot it)" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:473 +#: src/big_remote_play/ui/host_view.py:2317 +msgid "Advanced server settings" +msgstr "Configurações avançadas do servidor" + +#: src/big_remote_play/ui/host_view.py:474 +msgid "Server tuning, codecs, network and library fix" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:511 +msgid "Share your PIN or address" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:512 +msgid "Share your PIN code or the server address so friends can connect." +msgstr "" + +#: src/big_remote_play/ui/host_view.py:513 +msgid "Keep it secure" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:514 +msgid "Keep your server and network secure. Use a VPN when sharing games." +msgstr "" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 342 +#: src/big_remote_play/ui/host_view.py:530 +msgid "Information" +msgstr "Informação" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 347 +#: src/big_remote_play/ui/host_view.py:535 +msgid "Game" +msgstr "Jogo" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 357 +#: src/big_remote_play/ui/host_view.py:545 +msgid "Use PIN Code to Connect" +msgstr "Use o Código PIN para Conectar" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 358 +#: src/big_remote_play/ui/host_view.py:546 +msgid "Share this with the guest. Local network is required." +msgstr "Compartilhe isso com o convidado. A rede local é necessária." + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 372 +#: src/big_remote_play/ui/host_view.py:560 +msgid "Copy PIN" +msgstr "Copiar PIN" + +#: src/big_remote_play/ui/host_view.py:612 +#: src/big_remote_play/ui/host_view.py:1205 +msgid "Sunshine offline" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:617 +#: src/big_remote_play/ui/host_view.py:1197 +msgid "Start the server to stream." +msgstr "" + +# File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, +# line: 252 +#: src/big_remote_play/ui/host_view.py:632 +#: src/big_remote_play/ui/performance_monitor.py:262 +msgid "Latency" +msgstr "Latência" + +#: src/big_remote_play/ui/host_view.py:634 +msgid "FPS" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:636 +msgid "Bandwidth" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:650 msgid "" -"1. Open your browser and go to 192.168.1.1 (or your router's IP)\n" -"2. Find 'Port Forwarding' or 'NAT' settings" +"Keep your server and network secure. Use a VPN when " +"sharing games." msgstr "" -"1. Abra seu navegador e vá para 192.168.1.1 (ou o IP do seu roteador) \n" -"2. Encontre as configurações de 'Encaminhamento de Porta' ou 'NAT'" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 788 -msgid "Web Interface and API" -msgstr "Interface Web e API" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 789 -msgid "Admin Panel (Headscale UI)" -msgstr "Painel de Administração (Interface do Headscale)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 790 -msgid "Peer-to-peer VPN data" -msgstr "Dados de VPN ponto a ponto" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 800 -msgid "Find your local IP" -msgstr "Encontre seu IP local" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 801 -msgid "Run 'hostname -I' in a terminal to find your local IP address" -msgstr "Execute 'hostname -I' em um terminal para encontrar seu endereço IP local." -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 818 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1987 -msgid "Tailscale Instructions" -msgstr "Instruções do Tailscale" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 819 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 826 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1988 -msgid "1. Criar Conta" -msgstr "1. Create Account" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 819 -msgid "Registro no Tailscale" -msgstr "Registro no Tailscale" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 819 -msgid "Crie sua conta gratuita para começar a gerenciar sua malha VPN." -msgstr "Create your free account to start managing your VPN mesh." -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 819 -msgid "Abrir Site" -msgstr "Abrir Site" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 820 -msgid "2. Login no Host" -msgstr "2. Login no Host" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 820 -msgid "Vincular este dispositivo" -msgstr "Link this device" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 820 -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 820 -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 821 -msgid "3. Painel Admin" -msgstr "3. Painel de Administração" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 821 -msgid "Gerenciar Máquinas" -msgstr "Manage Machines" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 821 -msgid "Visualize e autorize as máquinas conectadas à sua rede no painel oficial." -msgstr "Visualize e autorize as máquinas conectadas à sua rede no painel oficial." -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 821 -msgid "Abrir Painel" -msgstr "Abrir Painel" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 825 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1994 -msgid "ZeroTier Instructions" -msgstr "Instruções do ZeroTier" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 826 -msgid "Acessar ZeroTier Central" -msgstr "Acessar o ZeroTier Central" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 826 -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 826 -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 826 -msgid "Abrir Portal" -msgstr "Abrir Portal" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 827 -msgid "2. Autenticação" -msgstr "2. Authentication" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 827 -msgid "Gerar Token de API" -msgstr "Generate API Token" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 827 -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 827 -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 827 -msgid "Gerar Token" -msgstr "Generate Token" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 828 -msgid "3. Configuração" -msgstr "3. Configuration" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 828 -msgid "Vincular Rede" -msgstr "Vincular Rede" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 828 -msgid "Cole o Token no campo correspondente e clique em 'Save Token & Create Network'." -msgstr "Cole o Token no campo correspondente e clique em 'Salvar Token e Criar Rede'." -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 829 -msgid "4. Administração" -msgstr "4. Administration" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 829 -msgid "Autorizar Membros" -msgstr "Authorize Members" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 829 -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 829 -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 829 -msgid "Minhas Redes" -msgstr "Minhas Redes" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 846 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 2014 -msgid "Step-by-step guide" -msgstr "Guia passo a passo" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 898 -msgid "Disconnecting..." -msgstr "Desconectando..." -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 903 -msgid "Tailscale disconnected" -msgstr "Tailscale desconectado" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 915 -msgid "ZeroTier token removed" -msgstr "Token do ZeroTier removido" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 924 -msgid "Disconnected from Headscale" -msgstr "Desconectado do Headscale" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 987 -msgid "No networks found" -msgstr "Nenhuma rede encontrada" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 987 -msgid "Connect or create a network first" -msgstr "Conecte-se ou crie uma rede primeiro." -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 997 -msgid "This device" -msgstr "Este dispositivo" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1498 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1529 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1542 -msgid "Installation Log" -msgstr "Registro de Instalação" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1035 -msgid "🎉 Success! Share with friends:" -msgstr "🎉 Sucesso! Compartilhe com amigos:" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1137 -msgid "Domain" -msgstr "Domínio" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1038 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1084 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1721 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1842 -msgid "Network ID" -msgstr "ID da Rede" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 107 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1197 -msgid "Auth Key (Friends)" -msgstr "Chave de Autenticação (Amigos)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1040 -msgid "API Key (Admin)" -msgstr "Chave de API (Admin)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 146 -msgid "Web Interface" -msgstr "Interface Web" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1225 -msgid "Save Network Information" -msgstr "Salvar Informações da Rede" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1076 -msgid "Saved to file!" -msgstr "Salvo em arquivo!" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1083 + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 569 +#: src/big_remote_play/ui/host_view.py:824 +msgid "Insert PIN" +msgstr "Insira o PIN" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 570 +#: src/big_remote_play/ui/host_view.py:825 +msgid "Enter the PIN displayed on the client device (Moonlight)." +msgstr "Digite o PIN exibido no dispositivo cliente (Moonlight)." + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 577 +#: src/big_remote_play/ui/host_view.py:832 +msgid "PIN" +msgstr "PIN" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 652 +#: src/big_remote_play/ui/host_view.py:834 +msgid "Device Name" +msgstr "Nome do Dispositivo" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 264 +#: src/big_remote_play/ui/host_view.py:837 +#: src/big_remote_play/ui/sunshine_preferences.py:406 +msgid "Sunshine User" +msgstr "Usuário Sunshine" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 265 +#: src/big_remote_play/ui/host_view.py:840 +#: src/big_remote_play/ui/sunshine_preferences.py:407 +msgid "Sunshine Password" +msgstr "Senha Sunshine" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 669 +#: src/big_remote_play/ui/host_view.py:843 +msgid "Save Password" +msgstr "Salvar Senha" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 670 +#: src/big_remote_play/ui/host_view.py:844 +msgid "Save credentials to Sunshine preferences" +msgstr "Salvar credenciais nas preferências do Sunshine" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 588 +#: src/big_remote_play/ui/host_view.py:856 +msgid "Send" +msgstr "Enviar" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 707 +#: src/big_remote_play/ui/host_view.py:880 +msgid "Authentication Failed" +msgstr "Autenticação Falhou" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 707 +#: src/big_remote_play/ui/host_view.py:880 +msgid "Invalid username or password." +msgstr "Nome de usuário ou senha inválidos." + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 609 +#: src/big_remote_play/ui/host_view.py:885 +msgid "PIN Error" +msgstr "Erro de PIN" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 616 +#: src/big_remote_play/ui/host_view.py:892 +msgid "User Not Found" +msgstr "Usuário Não Encontrado" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 617 +#: src/big_remote_play/ui/host_view.py:893 msgid "" -"VPN Network Details:\n" +"No user has been created in Sunshine. It is necessary to configure a user " +"through the browser." +msgstr "" +"Nenhum usuário foi criado no Sunshine. É necessário configurar um usuário " +"através do navegador." + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 621 +#: src/big_remote_play/ui/host_view.py:897 +msgid "Open Configuration" +msgstr "Abrir Configuração" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 635 +#: src/big_remote_play/ui/host_view.py:910 +msgid "Create Sunshine User" +msgstr "Criar Usuário Sunshine" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 636 +#: src/big_remote_play/ui/host_view.py:911 +msgid "Define a username and password for Sunshine." +msgstr "Defina um nome de usuário e uma senha para Sunshine." + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 641 +#: src/big_remote_play/ui/host_view.py:916 +msgid "New User" +msgstr "Novo Usuário" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 643 +#: src/big_remote_play/ui/host_view.py:918 +#: src/big_remote_play/ui/host_view.py:2343 +msgid "New Password" +msgstr "Nova Senha" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 649 +#: src/big_remote_play/ui/host_view.py:924 +msgid "Save and Continue" +msgstr "Salvar e Continuar" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 660 +#: src/big_remote_play/ui/host_view.py:935 +msgid "User created!" +msgstr "Usuário criado!" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 666 +#: src/big_remote_play/ui/host_view.py:941 +msgid "Error sending PIN after creation" +msgstr "Erro ao enviar PIN após a criação" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 668 +#: src/big_remote_play/ui/host_view.py:943 +msgid "Error creating user" +msgstr "Erro ao criar usuário" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 679 +#: src/big_remote_play/ui/host_view.py:954 +msgid "Sunshine Authentication" +msgstr "Autenticação Sunshine" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 680 +#: src/big_remote_play/ui/host_view.py:955 +msgid "" +"Sunshine requires login. Enter your credentials (default: admin / password " +"created during installation)." +msgstr "" +"O Sunshine requer login. Insira suas credenciais (padrão: admin / senha " +"criada durante a instalação)." + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 685 +#: src/big_remote_play/ui/host_view.py:960 +msgid "Username" +msgstr "Nome de usuário" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 687 +#: src/big_remote_play/ui/host_view.py:962 +msgid "Password" +msgstr "Senha" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 693 +#: src/big_remote_play/ui/host_view.py:968 +msgid "Confirm" +msgstr "Confirmar" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 709 +#: src/big_remote_play/ui/host_view.py:984 +msgid "Failed with credentials" +msgstr "Falhou com as credenciais" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 716 +#: src/big_remote_play/ui/host_view.py:991 +msgid "Server Information" +msgstr "Informações do Servidor" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 739 +#: src/big_remote_play/ui/host_view.py:1021 +msgid "System Default" +msgstr "Padrão do Sistema" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 752 +#: src/big_remote_play/ui/host_view.py:1036 +msgid "Error loading audio" +msgstr "Erro ao carregar áudio" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 771 +#: src/big_remote_play/ui/host_view.py:1058 +#, python-brace-format +msgid "Output changed to: {}" +msgstr "Saída alterada para: {}" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 776 +#: src/big_remote_play/ui/host_view.py:1063 +msgid "Configuring firewall... (Password may be requested)" +msgstr "Configurando o firewall... (A senha pode ser solicitada)" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 794 +#: src/big_remote_play/ui/host_view.py:1077 +#, python-brace-format +msgid "Success: {}" +msgstr "Sucesso: {}" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 795 +#: src/big_remote_play/ui/host_view.py:1078 +msgid "Firewall Error" +msgstr "Erro de Firewall" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 795 +#: src/big_remote_play/ui/host_view.py:1078 +msgid "Execution failed or cancelled." +msgstr "A execução falhou ou foi cancelada." + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 807 +#: src/big_remote_play/ui/host_view.py:1091 +#, python-brace-format +msgid "Error executing script: {}" +msgstr "Erro ao executar o script: {}" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 843 +#: src/big_remote_play/ui/host_view.py:1130 +#: src/big_remote_play/ui/host_view.py:2082 +#: src/big_remote_play/ui/private_network_view.py:810 +#: src/big_remote_play/ui/private_network_view.py:1243 +#: src/big_remote_play/ui/private_network_view.py:2226 +msgid "Copied!" +msgstr "Copiado!" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 847 +#: src/big_remote_play/ui/host_view.py:1133 +msgid "Clicked Start Server..." +msgstr "Cliquei em Iniciar Servidor..." + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 864 +#: src/big_remote_play/ui/host_view.py:1148 +msgid "Active - Waiting for Connections" +msgstr "Ativo - Aguardando Conexões" + +#: src/big_remote_play/ui/host_view.py:1154 +msgid "Ready to receive connections." +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1161 +msgid "Sunshine active" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1170 +msgid "Stop server" +msgstr "" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 890 +#: src/big_remote_play/ui/host_view.py:1191 +msgid "Inactive" +msgstr "Inativo" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1041 +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1045 +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1068 +#: src/big_remote_play/ui/host_view.py:1363 +#: src/big_remote_play/ui/host_view.py:1367 +#: src/big_remote_play/ui/host_view.py:1390 +msgid "Host + Guest" +msgstr "Anfitrião + Convidado" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1041 +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1045 +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1068 +#: src/big_remote_play/ui/host_view.py:1363 +#: src/big_remote_play/ui/host_view.py:1367 +#: src/big_remote_play/ui/host_view.py:1390 +msgid "Host Only" +msgstr "Apenas Host" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1257 +#: src/big_remote_play/ui/host_view.py:1518 +#, python-brace-format +msgid "Host output restored: {}" +msgstr "Saída do host restaurada: {}" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1153 +#: src/big_remote_play/ui/host_view.py:1523 +msgid "Failed to create Virtual Audio" +msgstr "Falha ao criar Áudio Virtual" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1196 +#: src/big_remote_play/ui/host_view.py:1560 +msgid "Server started" +msgstr "Servidor iniciado" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1386 +#: src/big_remote_play/ui/host_view.py:1604 +msgid "Opening Steam Big Picture..." +msgstr "Abrindo o Steam Big Picture..." + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1399 +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1418 +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1431 +#: src/big_remote_play/ui/host_view.py:1617 +#: src/big_remote_play/ui/host_view.py:1636 +#: src/big_remote_play/ui/host_view.py:1649 +#, python-brace-format +msgid "Launching {}..." +msgstr "Iniciando {}..." + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1403 +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1435 +#: src/big_remote_play/ui/host_view.py:1621 +#: src/big_remote_play/ui/host_view.py:1653 +#, python-brace-format +msgid "Error launching game: {}" +msgstr "Erro ao iniciar o jogo: {}" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1256 +#: src/big_remote_play/ui/host_view.py:1681 +msgid "Stopping server..." +msgstr "Parando o servidor..." + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1287 +#: src/big_remote_play/ui/host_view.py:1716 +msgid "Server stopped" +msgstr "Servidor parado" + +#: src/big_remote_play/ui/host_view.py:1731 +#, python-brace-format +msgid "Friends connect to this PC at {} — or use a PIN code." +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1744 +#, python-brace-format +msgid "Server active for {:02d}:{:02d}:{:02d}" +msgstr "" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1297 +#: src/big_remote_play/ui/host_view.py:1775 +msgid "Sunshine stopped unexpectedly" +msgstr "O Sunshine parou inesperadamente." + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1552 +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1560 +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1603 +#: src/big_remote_play/ui/host_view.py:1782 +#: src/big_remote_play/ui/private_network_view.py:1701 +#: src/big_remote_play/ui/private_network_view.py:1709 +#: src/big_remote_play/ui/private_network_view.py:1751 +msgid "Online" +msgstr "Online" + +# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 513 +#: src/big_remote_play/ui/host_view.py:1782 +#: src/big_remote_play/ui/main_window.py:1023 +#: src/big_remote_play/ui/main_window.py:1113 +msgid "Stopped" +msgstr "Parado" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1202 +#: src/big_remote_play/ui/host_view.py:1828 +msgid "Check logs for details." +msgstr "Verifique os logs para mais detalhes." + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1411 +#: src/big_remote_play/ui/host_view.py:1830 +#, python-brace-format +msgid "" +"Sunshine failed to start.\n" "\n" -msgstr "Detalhes da Rede VPN:" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 865 -msgid "Auth Key" -msgstr "Chave de Autenticação" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1090 -msgid "Opening mail client..." -msgstr "Abrindo cliente de e-mail..." -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1092 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1859 +"Error: {}\n" +"\n" +"If this is a dependency issue (missing libraries), try the 'Fix Dependencies' button." +msgstr "" +"O Sunshine falhou ao iniciar.\n" +"\n" +"Erro: {}\n" +"\n" +"Se este for um problema de dependência (bibliotecas ausentes), tente o botão 'Corrigir Dependências'." + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1415 +#: src/big_remote_play/ui/host_view.py:1834 +msgid "Server Failed to Start" +msgstr "O servidor falhou ao iniciar." + +# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# +# +# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, +# line: 85 +# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, +# line: 126 +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1232 +#: src/big_remote_play/ui/host_view.py:1836 +#: src/big_remote_play/ui/host_view.py:1877 +#: src/big_remote_play/ui/host_view.py:2010 +#: src/big_remote_play/ui/host_view.py:2099 +#: src/big_remote_play/ui/host_view.py:2233 +#: src/big_remote_play/ui/installer_window.py:89 +#: src/big_remote_play/ui/installer_window.py:136 +#: src/big_remote_play/ui/private_network_view.py:1522 +msgid "Close" +msgstr "Fechar" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1418 +#: src/big_remote_play/ui/host_view.py:1837 +msgid "View Logs" +msgstr "Ver Registros" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 302 +#: src/big_remote_play/ui/host_view.py:1838 +msgid "Fix Dependencies" +msgstr "Corrigir Dependências" + +#: src/big_remote_play/ui/host_view.py:1873 +msgid "" +"Devices paired with Sunshine. Disable to block access without re-pairing; " +"remove to revoke (a new PIN will be required)." +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1878 +msgid "Remove All" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1883 +#: src/big_remote_play/ui/host_view.py:2055 +#: src/big_remote_play/ui/host_view.py:2111 +msgid "Loading…" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1923 +msgid "No paired devices" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1930 +msgid "Unknown device" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1937 +msgid "Device enabled" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1945 +#: src/big_remote_play/ui/host_view.py:1946 +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:688 +msgid "Remove device" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1961 +msgid "Failed to update device" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1967 +msgid "Remove Device" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1968 +#, python-brace-format +msgid "Remove “{}”? It will need to pair again with a new PIN." +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1972 +msgid "Remove" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1996 +#: src/big_remote_play/ui/host_view.py:2001 +msgid "Devices updated" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:1996 +#: src/big_remote_play/ui/host_view.py:2001 +#: src/big_remote_play/ui/host_view.py:2218 +msgid "Operation failed" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2019 +msgid "Refresh" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2020 +msgid "Refresh logs" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2027 +msgid "Copy" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2028 +msgid "Copy logs" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2070 +msgid "No logs available (Sunshine not running or no credentials)." +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2089 +#: src/big_remote_play/ui/host_view.py:2227 +msgid "Server Not Running" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2090 +msgid "Start the server first to manage the game library." +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2095 +msgid "" +"Games offered to guests in Moonlight. Add your detected games or remove " +"entries." +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2104 +msgid "Add Detected Games" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2151 +msgid "No apps configured" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2159 +msgid "Unnamed" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2170 +#: src/big_remote_play/ui/host_view.py:2171 +msgid "Remove app" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2213 +#, python-brace-format +msgid "Added {} game(s)" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2218 +msgid "Library updated" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2228 +msgid "Start the server first to browse the host filesystem." +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2231 +msgid "Select Executable" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2272 +msgid "Cannot browse (no access or credentials)" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2280 +msgid "Up one level" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2307 +#, python-brace-format +msgid "Selected: {}" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2324 +msgid "" +"Set the username and password used to manage the Sunshine server. If you " +"forgot the current password, switch on “I forgot the current password” to " +"reset it." +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2334 +msgid "I forgot the current password" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2335 +msgid "Reset directly; the server will restart" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2336 +msgid "Current Username" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2338 +msgid "Current Password" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2341 +msgid "New Username" +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2344 +msgid "Confirm New Password" +msgstr "" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1092 +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1859 +#: src/big_remote_play/ui/host_view.py:2357 +#: src/big_remote_play/ui/private_network_view.py:1203 +#: src/big_remote_play/ui/private_network_view.py:2004 msgid "Save" msgstr "Salvar" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1095 -msgid "Saved to history!" -msgstr "Salvo no histórico!" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 204 -msgid "Save to File" -msgstr "Salvar em Arquivo" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 215 -msgid "Share" -msgstr "Compartilhar" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1121 -msgid "Network Information" -msgstr "Informações da Rede" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 863 -msgid "Connection Details" -msgstr "Detalhes da Conexão" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 937 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1908 -msgid "Establish Connection" -msgstr "Estabelecer Conexão" -# + +#: src/big_remote_play/ui/host_view.py:2366 +msgid "Username and password cannot be empty." +msgstr "" + +#: src/big_remote_play/ui/host_view.py:2369 +msgid "New passwords do not match." +msgstr "" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1442 +#: src/big_remote_play/ui/host_view.py:2628 +#: src/big_remote_play/ui/preferences.py:78 +msgid "Restore Defaults" +msgstr "Restaurar padrões" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1442 +#: src/big_remote_play/ui/host_view.py:2628 +msgid "Do you want to restore default settings?" +msgstr "Você deseja restaurar as configurações padrão?" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1443 +#: src/big_remote_play/ui/host_view.py:2629 +#: src/big_remote_play/ui/preferences.py:74 +#: src/big_remote_play/ui/preferences.py:204 +msgid "Restore" +msgstr "Restaurar" + +# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1451 +#: src/big_remote_play/ui/host_view.py:2637 +msgid "Settings Restored" +msgstr "Configurações Restauradas" + +# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, +# line: 17 +#: src/big_remote_play/ui/installer_window.py:21 +msgid "Install Dependencies" +msgstr "Instalar Dependências" + +# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, +# line: 30 +#: src/big_remote_play/ui/installer_window.py:34 +msgid "Installing required components..." +msgstr "Instalando componentes necessários..." + +# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, +# line: 56 +#: src/big_remote_play/ui/installer_window.py:60 +msgid "" +"Embedded terminal not available (vte4 not found).\n" +"Opening external terminal for installation...\n" +"Please wait for the installation to finish in the other window." +msgstr "" +"Terminal embutido não disponível (vte4 não encontrado). \n" +"Abrindo terminal externo para instalação... \n" +"Por favor, aguarde a conclusão da instalação na outra janela." + +# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, +# line: 67 +#: src/big_remote_play/ui/installer_window.py:71 +#: src/big_remote_play/ui/private_network_view.py:501 +#: src/big_remote_play/ui/private_network_view.py:1551 +msgid "Starting..." +msgstr "Iniciando..." + +# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, +# line: 77 +#: src/big_remote_play/ui/installer_window.py:81 +msgid "Done! Press Enter to close..." +msgstr "Concluído! Pressione Enter para fechar..." + +# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, +# line: 85 +#: src/big_remote_play/ui/installer_window.py:89 +msgid "Running in external terminal. Restart after completion." +msgstr "Executando no terminal externo. Reinicie após a conclusão." + +# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, +# line: 86 +#: src/big_remote_play/ui/installer_window.py:90 +msgid "Error: No terminal detected." +msgstr "Erro: Nenhum terminal detectado." + +# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, +# line: 86 +#: src/big_remote_play/ui/installer_window.py:90 +#, python-brace-format +msgid "" +"Execute manually:\n" +"{}" +msgstr "" +"Executar manualmente:\n" +"{}" + # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 962 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 224 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 417 -msgid "Connect" -msgstr "Conectar" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 973 -msgid "Network Devices" -msgstr "Dispositivos de Rede" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1220 -msgid "Set API Token to see all network devices" -msgstr "Defina o Token da API para ver todos os dispositivos de rede." -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1222 -msgid "Set Token" -msgstr "Definir Token" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1229 -msgid "Auth" -msgstr "Autenticação" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1229 -msgid "ID" -msgstr "ID" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1229 -msgid "Name" -msgstr "Nome" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1230 -msgid "Managed IP" -msgstr "IP Gerenciado" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1230 -msgid "Last Seen" -msgstr "Última vez visto" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1231 -msgid "Version" -msgstr "Versão" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1231 -msgid "Physical IP" -msgstr "IP físico" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1249 -msgid "Set ZeroTier API Token" -msgstr "Definir Token da API ZeroTier" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1255 -msgid "Create API Access Token" -msgstr "Criar Token de Acesso à API" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1013 -msgid "Status" -msgstr "Status" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1031 -msgid "Previous Networks" -msgstr "Redes Anteriores" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1301 -msgid "Server Domain (e.g. vpn.ruscher.org)" -msgstr "Domínio do Servidor (ex: vpn.ruscher.org)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1307 -msgid "Login Server (leave empty for tailscale.com)" -msgstr "Servidor de Login (deixe em branco para tailscale.com)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1313 -msgid "Network ID (16 characters)" -msgstr "ID da Rede (16 caracteres)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1314 -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1314 -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1316 -msgid "Find Network ID" -msgstr "Encontrar ID da Rede" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1316 -msgid "my.zerotier.com → Networks" -msgstr "my.zerotier.com → Redes" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1355 -msgid "Enter your API Token to view managed devices." -msgstr "Insira seu Token de API para visualizar dispositivos gerenciados." -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1371 -msgid "Save & Refresh" -msgstr "Salvar e Atualizar" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1384 -msgid "Token saved" -msgstr "Token salvo" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1848 -msgid "Connecting..." -msgstr "Conectando..." -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1410 -msgid "Domain and Auth Key required" -msgstr "Domínio e chave de autenticação necessárias" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1420 -msgid "Auth Key is required" -msgstr "A chave de autenticação é necessária." -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1431 -msgid "Network ID is required" -msgstr "ID da rede é obrigatório" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1907 -msgid "Connected successfully!" -msgstr "Conectado com sucesso!" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1769 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1914 -msgid "Try Again" -msgstr "Tente Novamente" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1913 -msgid "Connection failed" -msgstr "Conexão falhou" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1539 -msgid "Just now" -msgstr "Agora mesmo" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1552 -msgid "Self" -msgstr "Auto" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1552 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1560 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1603 -msgid "Online" -msgstr "Online" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1560 -msgid "Offline" -msgstr "Offline" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1630 -msgid "Peer" -msgstr "Paritário" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1642 -msgid "API Token Missing" -msgstr "Token da API ausente" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1642 -msgid "See banner above" -msgstr "Veja o banner acima" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1644 -msgid "No devices found" -msgstr "Nenhum dispositivo encontrado" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1644 -msgid "Try refreshing..." -msgstr "Tente atualizar..." -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1660 -msgid "No previous networks" -msgstr "Nenhuma rede anterior" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1662 -msgid "Your created/connected networks will appear here." -msgstr "Suas redes criadas/conectadas aparecerão aqui." -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1111 -msgid "Reconnect" -msgstr "Reconectar" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1704 -msgid "Edit" -msgstr "Editar" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1263 -msgid "Delete" -msgstr "Excluir" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1723 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1849 -msgid "Web UI" -msgstr "Interface Web" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1724 -msgid "VPN" -msgstr "VPN" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1772 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1787 -msgid "Form filled for {}" -msgstr "Formulário preenchido para {}" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1796 -msgid "Edit – {}" -msgstr "Editar – {}" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1802 -msgid "Edit Network Entry" -msgstr "Editar Entrada de Rede" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1831 -msgid "Login Server" -msgstr "Servidor de Login" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1878 -msgid "Entry updated" -msgstr "Entrada atualizada" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1895 -msgid "Delete entry?" -msgstr "Excluir entrada?" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1896 -msgid "Remove this network from history?" -msgstr "Remover esta rede do histórico?" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1498 -msgid "Connection Log" -msgstr "Registro de Conexão" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1936 -msgid "Step-by-step guide to join a private network" -msgstr "Guia passo a passo para ingressar em uma rede privada." -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1956 -msgid "Steps to join Headscale" -msgstr "Passos para se juntar ao Headscale" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1959 -msgid "Step 1: Get credentials" -msgstr "Passo 1: Obter credenciais" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1960 -msgid "Ask the network administrator for the Server Domain and Auth Key." -msgstr "Peça ao administrador da rede o Domínio do Servidor e a Chave de Autenticação." -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1965 -msgid "Step 2: Enter details" -msgstr "Passo 2: Insira os detalhes" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1966 -msgid "Paste the Domain and Key in the fields on the previous tab." -msgstr "Cole o Domínio e a Chave nos campos na aba anterior." -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1971 -msgid "Step 3: Connect" -msgstr "Passo 3: Conectar" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1972 -msgid "Click 'Establish Connection' and wait for the success message." -msgstr "Clique em 'Estabelecer Conexão' e aguarde a mensagem de sucesso." -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1988 -msgid "Acessar Tailscale" -msgstr "Acessar Tailscale" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1988 -msgid "Todos os participantes precisam de uma conta (Google, GitHub, etc)." -msgstr "All participants need an account (Google, GitHub, etc)." -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1988 -msgid "Criar Conta" -msgstr "Create Account" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1989 -msgid "2. Convite" -msgstr "2. Invitation" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1989 -msgid "Solicitar Acesso" -msgstr "Solicitar Acesso" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1989 -msgid "O administrador deve compartilhar o nó ou convidar seu e-mail para a rede." -msgstr "O administrador deve compartilhar o nó ou convidar seu e-mail para a rede." -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1990 -msgid "3. Conectar" -msgstr "3. Connectar" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1990 -msgid "Estabelecer Ligação" -msgstr "Establish Connection" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1990 -msgid "Com o convite aceito, use a aba anterior para se conectar." -msgstr "Com o convite aceito, use a aba anterior para se conectar." -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1995 -msgid "1. Identificação" -msgstr "1. Identification" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1995 -msgid "Obter Network ID" -msgstr "Obter ID da Rede" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1995 -msgid "Solicite o ID de 16 caracteres ao dono da rede." -msgstr "Solicite o ID de 16 caracteres ao proprietário da rede." -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1996 -msgid "2. Ingressar" -msgstr "2. Entrar" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1996 -msgid "Digitar ID" -msgstr "Digite o ID" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1996 -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1996 -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1997 -msgid "3. Autorização" -msgstr "3. Authorization" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1997 -msgid "Aguardar Aprovação" -msgstr "Aguardando Aprovação" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1997 -msgid "O administrador precisa marcar a opção 'Auth' para o seu PC no painel dele." -msgstr "O administrador precisa marcar a opção 'Auth' para o seu PC no painel dele." -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1997 -msgid "Painel ZT" -msgstr "Painel ZT" -# +# +# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, +# line: 89 +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1669 +#: src/big_remote_play/ui/installer_window.py:93 +msgid "Installing..." +msgstr "Instalando..." + +# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, +# line: 91 +#: src/big_remote_play/ui/installer_window.py:97 +#, python-brace-format +msgid "Error: {}" +msgstr "Erro: {}" + +# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, +# line: 92 +#: src/big_remote_play/ui/installer_window.py:102 +#, python-brace-format +msgid "Embedded terminal error: {}. Trying external..." +msgstr "Erro de terminal incorporado: {}. Tentando externo..." + +# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, +# line: 109 +#: src/big_remote_play/ui/installer_window.py:119 +msgid "Installation completed successfully!" +msgstr "Instalação concluída com sucesso!" + +#: src/big_remote_play/ui/installer_window.py:123 +msgid "Finish" +msgstr "" + +# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, +# line: 113 +# +# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, +# line: 113 +# +# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, +# line: 113 +# +# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, +# line: 113 +# +# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, +# line: 113 +# +# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, +# line: 113 +# +# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, +# line: 113 +# +# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, +# line: 113 +# +# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, +# line: 113 +# +# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, +# line: 113 +# +# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, +# line: 113 +# +# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, +# line: 113 +# +# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, +# line: 113 +# +# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, +# line: 113 +# +# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, +# line: 113 +# +# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, +# line: 113 +# +# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, +# line: 113 +# +# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, +# line: 113 +# +# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, +# line: 113 +# +# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, +# line: 113 +# +# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, +# line: 113 +# +# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, +# line: 113 +# +# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, +# line: 113 +# +# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, +# line: 113 +# +# File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, +# line: 124 +#: src/big_remote_play/ui/installer_window.py:134 +#, python-brace-format +msgid "Installation failed. Exit code: {}" +msgstr "A instalação falhou. Código de saída: {}" + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 24 +#: src/big_remote_play/ui/main_window.py:31 msgid "Self-hosted VPN server with Cloudflare DNS. Full control." msgstr "Servidor VPN auto-hospedado com DNS Cloudflare. Controle total." -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 32 +#: src/big_remote_play/ui/main_window.py:39 msgid "Easy mesh VPN. No server required. Free tier available." -msgstr "VPN de malha fácil. Nenhum servidor necessário. Camada gratuita disponível." -# +msgstr "" +"VPN de malha fácil. Nenhum servidor necessário. Camada gratuita disponível." + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 40 +#: src/big_remote_play/ui/main_window.py:47 msgid "Flexible virtual network. Works through NAT and firewalls." msgstr "Rede virtual flexível. Funciona através de NAT e firewalls." -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 20 +#: src/big_remote_play/ui/main_window.py:58 msgid "Sunshine Game Stream Host" msgstr "Anfitrião de Transmissão do Jogo Sunshine" -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 21 +#: src/big_remote_play/ui/main_window.py:59 msgid "High-performance game stream host. Required to share your games." -msgstr "Host de stream de jogos de alto desempenho. Necessário para compartilhar seus jogos." -# +msgstr "" +"Host de stream de jogos de alto desempenho. Necessário para compartilhar " +"seus jogos." + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 28 +#: src/big_remote_play/ui/main_window.py:66 msgid "Moonlight Game Stream Client" msgstr "Cliente de Stream de Jogo Moonlight" -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 29 +#: src/big_remote_play/ui/main_window.py:67 msgid "Game stream client. Required to connect to other hosts." -msgstr "Cliente de streaming de jogos. Necessário para se conectar a outros hosts." -# +msgstr "" +"Cliente de streaming de jogos. Necessário para se conectar a outros hosts." + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 35 +#: src/big_remote_play/ui/main_window.py:73 msgid "Docker Engine" msgstr "Motor Docker" -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 36 +#: src/big_remote_play/ui/main_window.py:74 msgid "Container platform. Required for the private network server." msgstr "Plataforma de contêiner. Necessária para o servidor de rede privada." -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 74 +#: src/big_remote_play/ui/main_window.py:81 msgid "Tailscale" msgstr "Tailscale" -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 75 +#: src/big_remote_play/ui/main_window.py:82 msgid "Mesh VPN service. Required for Tailscale connectivity." msgstr "Serviço de VPN Mesh. Necessário para conectividade Tailscale." -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 82 +#: src/big_remote_play/ui/main_window.py:89 msgid "ZeroTier" msgstr "ZeroTier" -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 83 +#: src/big_remote_play/ui/main_window.py:90 msgid "Virtual network service. Required for ZeroTier connectivity." msgstr "Serviço de rede virtual. Necessário para conectividade ZeroTier." -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 54 +#: src/big_remote_play/ui/main_window.py:100 +#: src/big_remote_play/ui/main_window.py:450 msgid "Home" msgstr "Início" -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 56 +#: src/big_remote_play/ui/main_window.py:102 msgid "Home Page" -msgstr "Página Inicial" -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 59 -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 330 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 117 -msgid "Host Server" -msgstr "Servidor Host" -# +msgstr "Página inicial" + +#: src/big_remote_play/ui/main_window.py:105 +msgid "Server" +msgstr "Servidor" + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 61 +#: src/big_remote_play/ui/main_window.py:107 msgid "Share your games" msgstr "Compartilhe seus jogos" -# + # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# +# # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 64 # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 338 # File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 55 +#: src/big_remote_play/ui/main_window.py:110 msgid "Connect to Server" -msgstr "Conectar ao Servidor" -# +msgstr "Conectar ao servidor" + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 66 +#: src/big_remote_play/ui/main_window.py:112 msgid "Connect to a host" msgstr "Conectar a um host" -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 69 +#: src/big_remote_play/ui/main_window.py:115 msgid "Private Network" msgstr "Rede Privada" -# + # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 367 +# +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 367 # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 73 +#: src/big_remote_play/ui/main_window.py:198 msgid "Create Private Network" msgstr "Criar Rede Privada" -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 178 +#: src/big_remote_play/ui/main_window.py:200 +#, python-brace-format msgid "{} - Setup server" msgstr "{} - Configurar servidor" -# + # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 855 +# +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 855 # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 78 +#: src/big_remote_play/ui/main_window.py:204 msgid "Connect to Private Network" msgstr "Conectar à Rede Privada" -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 184 +#: src/big_remote_play/ui/main_window.py:206 +#, python-brace-format msgid "{} - Join network" msgstr "{} - Juntar rede" -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 188 +#: src/big_remote_play/ui/main_window.py:210 msgid "Change VPN" msgstr "Mudar VPN" -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 190 +#: src/big_remote_play/ui/main_window.py:212 msgid "Switch provider" msgstr "Mudar provedor" -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 195 +#: src/big_remote_play/ui/main_window.py:217 msgid "Select VPN" msgstr "Selecionar VPN" -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 197 +#: src/big_remote_play/ui/main_window.py:219 msgid "Choose your VPN provider" msgstr "Escolha seu provedor de VPN" -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 194 +#: src/big_remote_play/ui/main_window.py:327 msgid "Service Status" msgstr "Status do Serviço" -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 224 +#: src/big_remote_play/ui/main_window.py:358 msgid "Checking..." msgstr "Verificando..." -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 259 +#: src/big_remote_play/ui/main_window.py:433 msgid "Installed" msgstr "Instalado" -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 259 +#: src/big_remote_play/ui/main_window.py:433 msgid "Missing" msgstr "Faltando" -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 265 +#: src/big_remote_play/ui/main_window.py:439 msgid "host" msgstr "anfitrião" -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 265 +#: src/big_remote_play/ui/main_window.py:439 msgid "connect" msgstr "conectar" -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 266 +#: src/big_remote_play/ui/main_window.py:440 +#, python-brace-format msgid "Need to install {} to {}" msgstr "Precisa instalar {} para {}" -# + +#: src/big_remote_play/ui/main_window.py:450 +msgid "Play together, from anywhere" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:477 +msgid "Application menu" +msgstr "" + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 273 +#: src/big_remote_play/ui/main_window.py:487 +#: src/big_remote_play/ui/preferences.py:25 msgid "Preferences" msgstr "Preferências" -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 273 +#: src/big_remote_play/ui/main_window.py:488 msgid "About" msgstr "Sobre" -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 445 +#: src/big_remote_play/ui/main_window.py:531 msgid "Choose Your VPN Provider" msgstr "Escolha seu provedor de VPN" -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 448 +#: src/big_remote_play/ui/main_window.py:534 msgid "" -"Select a VPN solution to create or join a Private Network. Your choice will be saved and shown in " -"the sidebar menu." +"Select a VPN solution to create or join a Private Network. Your choice will " +"be saved and shown in the sidebar menu." msgstr "" -"Selecione uma solução de VPN para criar ou ingressar em uma Rede Privada. Sua escolha será salva e " -"exibida no menu lateral." -# +"Selecione uma solução de VPN para criar ou entrar em uma rede privada. Sua " +"escolha será salva e exibida no menu lateral." + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 466 +#: src/big_remote_play/ui/main_window.py:552 msgid "Quick Comparison" -msgstr "Comparação Rápida" -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 471 -msgid "Self-hosted" -msgstr "Auto-hospedado" -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 471 -msgid "Full control, needs domain + Cloudflare" -msgstr "Controle total, precisa de domínio + Cloudflare" -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 472 -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 473 -msgid "Cloud (free)" -msgstr "Nuvem (grátis)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 472 -msgid "Easiest setup, works out of the box" -msgstr "Configuração mais fácil, funciona imediatamente." -# +msgstr "Comparação rápida" + +#: src/big_remote_play/ui/main_window.py:558 +msgid "Setup difficulty" +msgstr "Dificuldade de configuração" + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 472 +#: src/big_remote_play/ui/main_window.py:558 +#: src/big_remote_play/utils/widgets.py:22 msgid "Beginner" msgstr "Iniciante" -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 473 -msgid "Flexible, works through NAT" -msgstr "Flexível, funciona através de NAT" -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 473 +#: src/big_remote_play/ui/main_window.py:558 +#: src/big_remote_play/utils/widgets.py:23 msgid "Intermediate" msgstr "Intermediário" -# + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 79 +#: src/big_remote_play/ui/main_window.py:558 +#: src/big_remote_play/ui/preferences.py:104 +#: src/big_remote_play/ui/sunshine_preferences.py:81 +#: src/big_remote_play/utils/widgets.py:24 +msgid "Advanced" +msgstr "Avançado" + +#: src/big_remote_play/ui/main_window.py:559 +msgid "Hosting model" +msgstr "Modelo de hospedagem" + +# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 472 +# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 473 +#: src/big_remote_play/ui/main_window.py:559 +msgid "Cloud (free)" +msgstr "Nuvem (gratuito)" + +# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 471 +#: src/big_remote_play/ui/main_window.py:559 +msgid "Self-hosted" +msgstr "Auto-hospedado" + +#: src/big_remote_play/ui/main_window.py:560 +msgid "Best for" +msgstr "Ideal para" + +#: src/big_remote_play/ui/main_window.py:560 +msgid "Personal use and friends" +msgstr "Uso pessoal e amigos" + +#: src/big_remote_play/ui/main_window.py:561 +msgid "Flexible networks and teams" +msgstr "Redes flexíveis e equipes" + +#: src/big_remote_play/ui/main_window.py:562 +msgid "Corporate environments" +msgstr "Ambientes corporativos" + +# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 464 +#: src/big_remote_play/ui/main_window.py:570 +#: src/big_remote_play/ui/main_window.py:967 +msgid "Install" +msgstr "Instalar" + +#: src/big_remote_play/ui/main_window.py:571 +msgid "Install the chosen VPN provider on your device." +msgstr "Instale o provedor de VPN escolhido no seu dispositivo." + +#: src/big_remote_play/ui/main_window.py:572 +msgid "Authenticate" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:573 +msgid "Log in and authorize your devices on the VPN." +msgstr "Entre na conta e autorize seus dispositivos na VPN." + +#: src/big_remote_play/ui/main_window.py:574 +msgid "Play" +msgstr "" + +#: src/big_remote_play/ui/main_window.py:575 +msgid "Connect to the server and play with your friends!" +msgstr "Conecte-se ao servidor e jogue com seus amigos!" + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 523 +#: src/big_remote_play/ui/main_window.py:627 msgid "Choose →" msgstr "Escolher →" -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 537 +#: src/big_remote_play/ui/main_window.py:641 msgid "Switch VPN Provider?" msgstr "Mudar Provedor de VPN?" -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 538 +#: src/big_remote_play/ui/main_window.py:642 +#, python-brace-format msgid "You are switching from {} to {}. Do you want to disconnect from {}?" msgstr "Você está mudando de {} para {}. Você deseja desconectar de {}?" -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 544 +#: src/big_remote_play/ui/main_window.py:648 msgid "Keep Connected" msgstr "Mantenha-se Conectado" -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 545 +#: src/big_remote_play/ui/main_window.py:649 msgid "Disconnect previous" msgstr "Desconectar anterior" -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 561 +#: src/big_remote_play/ui/main_window.py:665 +#, python-brace-format msgid "Disconnecting from {}..." msgstr "Desconectando de {}..." -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 570 +#: src/big_remote_play/ui/main_window.py:674 +#, python-brace-format msgid "{} disconnected" msgstr "{} desconectado" -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 597 +#: src/big_remote_play/ui/main_window.py:701 +#, python-brace-format msgid "{} selected! Setting up private network..." msgstr "{} selecionado! Configurando rede privada..." -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 635 +#: src/big_remote_play/ui/main_window.py:741 msgid "Play cooperatively over the local network or the internet" -msgstr "Jogue cooperativamente pela rede local ou pela internet." -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 331 -msgid "Share your games with other players on the network" -msgstr "Compartilhe seus jogos com outros jogadores na rede." -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 339 -msgid "Connect to a game server on the network" -msgstr "Conecte-se a um servidor de jogo na rede" -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 351 -msgid "Based on Sunshine and Moonlight" -msgstr "Baseado em Sunshine e Moonlight" -# +msgstr "Jogue em cooperação pela rede local ou pela internet" + +#: src/big_remote_play/ui/main_window.py:748 +msgid "What do you want to do?" +msgstr "O que você quer fazer?" + +#: src/big_remote_play/ui/main_window.py:763 +msgid "Share the game" +msgstr "Compartilhar o jogo" + +#: src/big_remote_play/ui/main_window.py:764 +msgid "Run the game on THIS PC and let friends connect to play together" +msgstr "" +"Execute o jogo NESTE PC e permita que amigos se conectem para jogar juntos" + +#: src/big_remote_play/ui/main_window.py:768 +msgid "Recommended" +msgstr "Recomendado" + +#: src/big_remote_play/ui/main_window.py:769 +msgid "Start sharing →" +msgstr "Começar a compartilhar →" + +#: src/big_remote_play/ui/main_window.py:773 +msgid "Join a game" +msgstr "Entrar em um jogo" + +#: src/big_remote_play/ui/main_window.py:774 +msgid "Connect to a friend's PC over the network and play remotely" +msgstr "Conecte-se ao PC de um amigo pela rede e jogue remotamente" + +#: src/big_remote_play/ui/main_window.py:777 +msgid "Connect now →" +msgstr "Conectar agora →" + +#: src/big_remote_play/ui/main_window.py:787 +msgid "Start or connect" +msgstr "Iniciar ou conectar" + +#: src/big_remote_play/ui/main_window.py:788 +msgid "Start a server on this PC or connect to an existing one." +msgstr "Inicie um servidor neste PC ou conecte-se a um existente." + +#: src/big_remote_play/ui/main_window.py:790 +msgid "Invite friends" +msgstr "Convidar amigos" + +#: src/big_remote_play/ui/main_window.py:791 +msgid "Share the PIN code or the server address." +msgstr "Compartilhe o código PIN ou o endereço do servidor." + +#: src/big_remote_play/ui/main_window.py:793 +msgid "Play together" +msgstr "Jogar juntos" + +#: src/big_remote_play/ui/main_window.py:794 +msgid "Everyone connects and plays remotely." +msgstr "Todos se conectam e jogam remotamente." + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 463 +#: src/big_remote_play/ui/main_window.py:966 msgid "Missing Components" msgstr "Componentes Ausentes" -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 463 +#: src/big_remote_play/ui/main_window.py:966 msgid "Sunshine and Moonlight are required. Install now?" msgstr "Sol e Lua são necessários. Instalar agora?" -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 464 -msgid "Install" -msgstr "Instalar" -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 513 +#: src/big_remote_play/ui/main_window.py:1023 msgid "Running" msgstr "Executando" -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 513 -msgid "Stopped" -msgstr "Parado" -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 541 +#: src/big_remote_play/ui/main_window.py:1060 msgid "Moonlight not found" msgstr "Luz da lua não encontrada" -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 582 +#: src/big_remote_play/ui/main_window.py:1076 msgid "Containers" msgstr "Contêineres" -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 557 +#: src/big_remote_play/ui/main_window.py:1077 +#, python-brace-format msgid "Action {} sent to {}" msgstr "Ação {} enviada para {}" -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 561 +#: src/big_remote_play/ui/main_window.py:1081 +#, python-brace-format msgid "Error executing command: {}" msgstr "Erro ao executar o comando: {}" -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 564 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 868 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 198 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 206 -msgid "Stop" -msgstr "Parar" -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 564 +#: src/big_remote_play/ui/main_window.py:1083 msgid "Start" msgstr "Iniciar" -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 570 +#: src/big_remote_play/ui/main_window.py:1088 msgid "Restart" msgstr "Reiniciar" -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 576 +#: src/big_remote_play/ui/main_window.py:1093 msgid "Disable" msgstr "Desativar" -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 576 +#: src/big_remote_play/ui/main_window.py:1093 msgid "Enable" msgstr "Ativar" -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 619 +#: src/big_remote_play/ui/main_window.py:1106 msgid "Private Network Containers" msgstr "Contêineres de Rede Privada" -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 627 +#: src/big_remote_play/ui/main_window.py:1113 msgid "Running (Caddy + Headscale)" msgstr "Executando (Caddy + Headscale)" -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 633 +#: src/big_remote_play/ui/main_window.py:1117 msgid "Stop Containers" msgstr "Parar Contêineres" -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 633 +#: src/big_remote_play/ui/main_window.py:1117 msgid "Start Containers" msgstr "Iniciar Contêineres" -# + # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 639 +#: src/big_remote_play/ui/main_window.py:1122 msgid "Restart Containers" msgstr "Reiniciar Contêineres" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 63 -msgid "Moonlight" -msgstr "Luz da Lua" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 73 + +# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, +# line: 73 +#: src/big_remote_play/ui/moonlight_preferences.py:25 msgid "Basic Settings" msgstr "Configurações Básicas" -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 78 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 85 -msgid "Resolution" -msgstr "Resolução" -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 90 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 96 -msgid "Frame Rate (FPS)" -msgstr "Taxa de Quadros (FPS)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 102 + +# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, +# line: 102 +#: src/big_remote_play/ui/moonlight_preferences.py:54 msgid "Video Bitrate" msgstr "Taxa de bits de vídeo" -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 114 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 123 -msgid "Display Mode" -msgstr "Modo de Exibição" -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 116 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 125 -msgid "Borderless Window" -msgstr "Janela Sem Bordas" -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 116 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 125 -msgid "Fullscreen" -msgstr "Tela cheia" -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 116 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 125 -msgid "Windowed" -msgstr "Em janela" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 124 + +# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, +# line: 124 +#: src/big_remote_play/ui/moonlight_preferences.py:76 msgid "V-Sync" msgstr "V-Sync" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 124 + +# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, +# line: 124 +#: src/big_remote_play/ui/moonlight_preferences.py:76 msgid "Visual Synchronization" msgstr "Sincronização Visual" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 125 + +# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, +# line: 125 +#: src/big_remote_play/ui/moonlight_preferences.py:77 msgid "Frame Pacing" msgstr "Sincronização de Quadros" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 125 + +# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, +# line: 125 +#: src/big_remote_play/ui/moonlight_preferences.py:77 msgid "Improve smoothness" msgstr "Melhorar a suavidade" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 129 + +# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, +# line: 129 +#: src/big_remote_play/ui/moonlight_preferences.py:81 msgid "Input Settings" msgstr "Configurações de Entrada" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 131 + +# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, +# line: 131 +#: src/big_remote_play/ui/moonlight_preferences.py:83 msgid "Optimize mouse for Remote Desktop" msgstr "Otimize o mouse para Área de Trabalho Remota" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 132 + +# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, +# line: 132 +#: src/big_remote_play/ui/moonlight_preferences.py:84 msgid "Capture system shortcuts" msgstr "Capturar atalhos do sistema" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 133 + +# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, +# line: 133 +#: src/big_remote_play/ui/moonlight_preferences.py:85 msgid "Use touchscreen as virtual trackpad" msgstr "Use o touchscreen como trackpad virtual" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 134 + +# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, +# line: 134 +#: src/big_remote_play/ui/moonlight_preferences.py:86 msgid "Invert mouse left/right buttons" msgstr "Inverter botões do mouse esquerdo/direito" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 135 + +# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, +# line: 135 +#: src/big_remote_play/ui/moonlight_preferences.py:87 msgid "Invert scroll wheel direction" msgstr "Inverter a direção da roda de rolagem" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 139 + +# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, +# line: 139 +#: src/big_remote_play/ui/moonlight_preferences.py:91 msgid "Audio Settings" msgstr "Configurações de Áudio" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 143 + +# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, +# line: 143 +#: src/big_remote_play/ui/moonlight_preferences.py:95 msgid "Audio Configuration" msgstr "Configuração de Áudio" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 145 + +# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, +# line: 145 +#: src/big_remote_play/ui/moonlight_preferences.py:97 msgid "Stereo" msgstr "Estéreo" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 153 + +# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, +# line: 153 +#: src/big_remote_play/ui/moonlight_preferences.py:105 msgid "Mute host PC speakers while streaming" msgstr "Silenciar os alto-falantes do PC host enquanto transmite" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 154 + +# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, +# line: 154 +#: src/big_remote_play/ui/moonlight_preferences.py:106 msgid "Mute audio when Moonlight is not the active window" msgstr "Silenciar áudio quando o Moonlight não é a janela ativa." -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 158 + +# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, +# line: 158 +#: src/big_remote_play/ui/moonlight_preferences.py:110 msgid "Controller Settings" msgstr "Configurações do Controlador" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 160 + +# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, +# line: 160 +#: src/big_remote_play/ui/moonlight_preferences.py:112 msgid "Swap A/B and X/Y buttons" msgstr "Trocar os botões A/B e X/Y" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 161 + +# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, +# line: 161 +#: src/big_remote_play/ui/moonlight_preferences.py:113 msgid "Force controller #1 always connected" msgstr "Controlador de força #1 sempre conectado" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 162 + +# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, +# line: 162 +#: src/big_remote_play/ui/moonlight_preferences.py:114 msgid "Enable mouse control with gamepad" msgstr "Ativar controle do mouse com gamepad" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 163 + +# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, +# line: 163 +#: src/big_remote_play/ui/moonlight_preferences.py:115 msgid "Process controller input in background" msgstr "Processar entrada do controlador em segundo plano" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 167 + +# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, +# line: 167 +#: src/big_remote_play/ui/moonlight_preferences.py:119 msgid "Host Settings" msgstr "Configurações do Host" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 169 + +# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, +# line: 169 +#: src/big_remote_play/ui/moonlight_preferences.py:121 msgid "Optimize game settings for streaming" msgstr "Otimize as configurações do jogo para streaming" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 170 + +# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, +# line: 170 +#: src/big_remote_play/ui/moonlight_preferences.py:122 msgid "Quit app on host after streaming ends" msgstr "Fechar aplicativo no host após o término da transmissão." -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 174 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 255 -msgid "Advanced Settings" -msgstr "Configurações Avançadas" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 178 + +# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, +# line: 178 +#: src/big_remote_play/ui/moonlight_preferences.py:130 msgid "Video Decoder" msgstr "Decodificador de Vídeo" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 180 -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 191 + +# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, +# line: 180 +# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, +# line: 191 +#: src/big_remote_play/ui/moonlight_preferences.py:132 +#: src/big_remote_play/ui/moonlight_preferences.py:143 msgid "Automatic (Recommended)" msgstr "Automático (Recomendado)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 180 + +# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, +# line: 180 +#: src/big_remote_play/ui/moonlight_preferences.py:132 msgid "Hardware" msgstr "Hardware" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 189 + +# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# +# +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 87 +# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, +# line: 180 +#: src/big_remote_play/ui/moonlight_preferences.py:132 +#: src/big_remote_play/ui/sunshine_preferences.py:88 +msgid "Software" +msgstr "Software" + +# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, +# line: 189 +#: src/big_remote_play/ui/moonlight_preferences.py:141 msgid "Video Codec" msgstr "Codec de Vídeo" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 199 + +# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, +# line: 199 +#: src/big_remote_play/ui/moonlight_preferences.py:151 msgid "Enable HDR (Experimental)" msgstr "Ativar HDR (Experimental)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 200 + +# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, +# line: 200 +#: src/big_remote_play/ui/moonlight_preferences.py:152 msgid "Enable YUV 4:4:4 (Experimental)" msgstr "Ativar YUV 4:4:4 (Experimental)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 201 + +# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, +# line: 201 +#: src/big_remote_play/ui/moonlight_preferences.py:153 msgid "Unlock bitrate limit (Experimental)" msgstr "Desbloquear limite de bitrate (Experimental)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 202 + +# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, +# line: 202 +#: src/big_remote_play/ui/moonlight_preferences.py:154 msgid "Discover PCs automatically" msgstr "Descubra PCs automaticamente" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 203 + +# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, +# line: 203 +#: src/big_remote_play/ui/moonlight_preferences.py:155 msgid "Check for blocked connections automatically" msgstr "Verifique automaticamente as conexões bloqueadas" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 204 + +# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, +# line: 204 +#: src/big_remote_play/ui/moonlight_preferences.py:156 msgid "Show performance stats while streaming" msgstr "Mostre estatísticas de desempenho durante a transmissão." -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 208 + +# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, +# line: 208 +#: src/big_remote_play/ui/moonlight_preferences.py:160 msgid "UI Settings" msgstr "Configurações de UI" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 210 + +# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, +# line: 210 +#: src/big_remote_play/ui/moonlight_preferences.py:162 msgid "Show connection quality warnings" msgstr "Mostrar avisos de qualidade de conexão" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 211 + +# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, +# line: 211 +#: src/big_remote_play/ui/moonlight_preferences.py:163 msgid "Keep display active during streaming" msgstr "Mantenha a tela ativa durante a transmissão" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 113 -msgid "Sunshine Offline" -msgstr "Sol Offline" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 118 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 889 -msgid "Configure and share your game for friends to connect" -msgstr "Configure e compartilhe seu jogo para que amigos se conectem." -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 122 -msgid "Game Configuration" -msgstr "Configuração do Jogo" -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 127 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 83 -msgid "Reset to Defaults" -msgstr "Redefinir para Padrões" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 131 -msgid "Game Mode" -msgstr "Modo de Jogo" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 131 -msgid "Select game source" -msgstr "Selecione a fonte do jogo" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 133 -msgid "Full Desktop" -msgstr "Área de Trabalho Completa" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 133 -msgid "Custom App" -msgstr "Aplicativo Personalizado" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 137 -msgid "Game Selection" -msgstr "Seleção de Jogo" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 138 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 143 -msgid "Choose game from list" -msgstr "Escolha um jogo da lista" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 142 -msgid "Select Game" -msgstr "Selecionar Jogo" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 150 -msgid "Application Details" -msgstr "Detalhes do Aplicativo" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 151 -msgid "Configure name and command" -msgstr "Configurar nome e comando" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 155 -msgid "Application Name" -msgstr "Nome do Aplicativo" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 159 -msgid "Command" -msgstr "Comando" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 164 -msgid "Streaming Settings" -msgstr "Configurações de Streaming" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 165 -msgid "Quality and Players" -msgstr "Qualidade e Jogadores" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 85 -msgid "Stream resolution" -msgstr "Resolução de stream" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 87 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 98 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 754 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 765 -msgid "Custom" -msgstr "Personalizado" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 182 -msgid "Frames per second" -msgstr "Quadros por segundo" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 192 -msgid "Bandwidth Limit (Mbps)" -msgstr "Limite de Largura de Banda (Mbps)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 193 -msgid "Max bitrate (0 = Unlimited)" -msgstr "Taxa de bits máxima (0 = Ilimitado)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 188 -msgid "Hardware and Capture" -msgstr "Hardware e Captura" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 189 -msgid "Monitor, GPU, and Capture Method" -msgstr "Monitor, GPU e Método de Captura" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 193 -msgid "Monitor / Display" -msgstr "Monitor / Exibição" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 194 -msgid "Select the display to capture" -msgstr "Selecione a tela para capturar" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 202 -msgid "Graphics Card / Encoder" -msgstr "Placa de Vídeo / Codificador" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 203 -msgid "Choose hardware for video encoding" -msgstr "Escolha hardware para codificação de vídeo" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 211 -msgid "Capture Method" -msgstr "Método de Captura" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 212 -msgid "Wayland (recommended), X11 (legacy), or KMS (direct)" -msgstr "Wayland (recomendado), X11 (legado) ou KMS (direto)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 214 -msgid "KMS (Direct)" -msgstr "KMS (Direto)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 238 -msgid "Efficient Codecs (HEVC/AV1)" -msgstr "Codecs Eficientes (HEVC/AV1)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 239 -msgid "Enable H.265/AV1 for better quality at lower bitrate (Requires support)" -msgstr "Ativar H.265/AV1 para melhor qualidade com menor taxa de bits (Requer suporte)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 244 -msgid "Optimization Mode" -msgstr "Modo de Otimização" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 245 -msgid "Balance between responsiveness and image quality" -msgstr "Equilíbrio entre capacidade de resposta e qualidade da imagem" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 247 -msgid "Low Latency (Fastest)" -msgstr "Baixa Latência (Mais Rápido)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 248 -msgid "Balanced (Default)" -msgstr "Equilibrado (Padrão)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 249 -msgid "High Quality (Best Image)" -msgstr "Alta Qualidade (Melhor Imagem)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 255 -msgid "Wi-Fi / Unstable Network Mode" -msgstr "Wi-Fi / Modo de Rede Instável" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 256 -msgid "Increases error correction (FEC) to prevent glitches" -msgstr "Aumenta a correção de erros (FEC) para prevenir falhas." -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 224 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 352 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 128 -msgid "Audio" -msgstr "Áudio" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 225 -msgid "Sound settings" -msgstr "Configurações de som" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 229 -msgid "Host Audio Output" -msgstr "Saída de Áudio do Host" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 230 -msgid "Where YOU will hear the game sound" -msgstr "Onde VOCÊ ouvirá o som do jogo" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 305 -msgid "Audio Output Mode" -msgstr "Modo de Saída de Áudio" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 306 -msgid "Determine where the game sound will be played" -msgstr "Determine onde o som do jogo será reproduzido." -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 313 -msgid "Guest + Host" -msgstr "Convidado + Anfitrião" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 245 -msgid "Audio Mixer (Sources)" -msgstr "Misturador de Áudio (Fontes)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 246 -msgid "Manage audio sources" -msgstr "Gerenciar fontes de áudio" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 256 -msgid "Input, Network, and Access" -msgstr "Entrada, Rede e Acesso" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 266 -msgid "Automatic UPnP" -msgstr "UPnP automático" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 267 -msgid "Automatically configure router ports" -msgstr "Configurar automaticamente as portas do roteador" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 272 -msgid "Address Family (IPv4 + IPv6)" -msgstr "Família de Endereços (IPv4 + IPv6)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 273 -msgid "Enable simultaneous IPv4 and IPv6 support on server" -msgstr "Ativar suporte simultâneo a IPv4 e IPv6 no servidor" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 278 -msgid "Origin Web UI Allowed (WAN)" -msgstr "Interface Web de Origem Permitida (WAN)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 279 -msgid "Allows anyone to access the web interface (Anyone may access Web UI)" -msgstr "Permite que qualquer pessoa acesse a interface da web (Qualquer um pode acessar a interface da Web)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 284 -msgid "Configure Firewall (IPv6)" -msgstr "Configurar Firewall (IPv6)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 285 -msgid "Open TCP/UDP ports required for external connection" -msgstr "Abra as portas TCP/UDP necessárias para conexão externa." -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 288 -msgid "Configure" -msgstr "Configurar" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 310 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 909 -msgid "Start Server" -msgstr "Iniciar Servidor" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 317 -msgid "Configure Sunshine" -msgstr "Configurar Sunshine" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 325 -msgid "Enter PIN" -msgstr "Digite o PIN" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 342 -msgid "Information" -msgstr "Informação" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 347 -msgid "Game" -msgstr "Jogo" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 357 -msgid "Use PIN Code to Connect" -msgstr "Use o Código PIN para Conectar" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 358 -msgid "Share this with the guest. Local network is required." -msgstr "Compartilhe isso com o convidado. A rede local é necessária." -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 361 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 382 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 70 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 438 -msgid "PIN Code" -msgstr "Código PIN" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 372 -msgid "Copy PIN" -msgstr "Copiar PIN" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 569 -msgid "Insert PIN" -msgstr "Insira o PIN" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 570 -msgid "Enter the PIN displayed on the client device (Moonlight)." -msgstr "Digite o PIN exibido no dispositivo cliente (Moonlight)." -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 577 -msgid "PIN" -msgstr "PIN" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 652 -msgid "Device Name" -msgstr "Nome do Dispositivo" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 669 -msgid "Save Password" -msgstr "Salvar Senha" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 670 -msgid "Save credentials to Sunshine preferences" -msgstr "Salvar credenciais nas preferências do Sunshine" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 588 -msgid "Send" -msgstr "Enviar" -# -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 601 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 665 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 705 -# File: big-remote-play/usr/share/big-remote-play/host/sunshine_manager.py, line: 307 -msgid "PIN sent successfully" -msgstr "PIN enviado com sucesso" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 707 -msgid "Authentication Failed" -msgstr "Autenticação Falhou" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 707 -msgid "Invalid username or password." -msgstr "Nome de usuário ou senha inválidos." -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 609 -msgid "PIN Error" -msgstr "Erro de PIN" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 616 -msgid "User Not Found" -msgstr "Usuário Não Encontrado" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 617 -msgid "No user has been created in Sunshine. It is necessary to configure a user through the browser." -msgstr "Nenhum usuário foi criado no Sunshine. É necessário configurar um usuário através do navegador." -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 621 -msgid "Open Configuration" -msgstr "Abrir Configuração" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 635 -msgid "Create Sunshine User" -msgstr "Criar Usuário Sunshine" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 636 -msgid "Define a username and password for Sunshine." -msgstr "Defina um nome de usuário e uma senha para Sunshine." -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 641 -msgid "New User" -msgstr "Novo Usuário" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 643 -msgid "New Password" -msgstr "Nova Senha" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 649 -msgid "Save and Continue" -msgstr "Salvar e Continuar" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 660 -msgid "User created!" -msgstr "Usuário criado!" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 666 -msgid "Error sending PIN after creation" -msgstr "Erro ao enviar PIN após a criação" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 668 -msgid "Error creating user" -msgstr "Erro ao criar usuário" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 679 -msgid "Sunshine Authentication" -msgstr "Autenticação Sunshine" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 680 -msgid "" -"Sunshine requires login. Enter your credentials (default: admin / password created during " -"installation)." + +# File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, +# line: 166 +#: src/big_remote_play/ui/performance_monitor.py:176 +msgid "Waiting for data..." +msgstr "Aguardando dados..." + +# File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, +# line: 198 +#: src/big_remote_play/ui/performance_monitor.py:208 +#, python-brace-format +msgid "{} Active Devices" +msgstr "{} Dispositivos Ativos" + +# File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, +# line: 318 +# File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, +# line: 723 +#: src/big_remote_play/ui/performance_monitor.py:332 +#: src/big_remote_play/ui/performance_monitor.py:870 +msgid "Real-time Monitoring" +msgstr "Monitoramento em tempo real" + +#: src/big_remote_play/ui/performance_monitor.py:499 +msgid "Disconnect Guest" msgstr "" -"O Sunshine requer login. Insira suas credenciais (padrão: admin / senha criada durante a " -"instalação)." -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 685 -msgid "Username" -msgstr "Nome de usuário" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 687 -msgid "Password" -msgstr "Senha" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 693 -msgid "Confirm" -msgstr "Confirmar" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 709 -msgid "Failed with credentials" -msgstr "Falhou com as credenciais" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 716 -msgid "Server Information" -msgstr "Informações do Servidor" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 739 -msgid "System Default" -msgstr "Padrão do Sistema" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 752 -msgid "Error loading audio" -msgstr "Erro ao carregar áudio" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 771 -msgid "Output changed to: {}" -msgstr "Saída alterada para: {}" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 776 -msgid "Configuring firewall... (Password may be requested)" -msgstr "Configurando o firewall... (A senha pode ser solicitada)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 794 -msgid "Success: {}" -msgstr "Sucesso: {}" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 795 -msgid "Firewall Error" -msgstr "Erro de Firewall" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 795 -msgid "Execution failed or cancelled." -msgstr "A execução falhou ou foi cancelada." -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 807 -msgid "Error executing script: {}" -msgstr "Erro ao executar o script: {}" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 847 -msgid "Clicked Start Server..." -msgstr "Cliquei em Iniciar Servidor..." -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 863 -msgid "Server Active - Guests can now connect" -msgstr "Servidor Ativo - Os convidados agora podem se conectar" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 864 -msgid "Active - Waiting for Connections" -msgstr "Ativo - Aguardando Conexões" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 890 -msgid "Inactive" -msgstr "Inativo" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1041 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1045 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1068 -msgid "Host + Guest" -msgstr "Anfitrião + Convidado" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1041 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1045 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1068 -msgid "Host Only" -msgstr "Apenas Host" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1257 -msgid "Host output restored: {}" -msgstr "Saída do host restaurada: {}" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1153 -msgid "Failed to create Virtual Audio" -msgstr "Falha ao criar Áudio Virtual" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1196 -msgid "Server started" -msgstr "Servidor iniciado" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1386 -msgid "Opening Steam Big Picture..." -msgstr "Abrindo o Steam Big Picture..." -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1399 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1418 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1431 -msgid "Launching {}..." -msgstr "Iniciando {}..." -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1403 -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1435 -msgid "Error launching game: {}" -msgstr "Erro ao iniciar o jogo: {}" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1256 -msgid "Stopping server..." -msgstr "Parando o servidor..." -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1287 -msgid "Server stopped" -msgstr "Servidor parado" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1297 -msgid "Sunshine stopped unexpectedly" -msgstr "O Sunshine parou inesperadamente." -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1202 -msgid "Check logs for details." -msgstr "Verifique os logs para mais detalhes." -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1411 + +#: src/big_remote_play/ui/performance_monitor.py:500 msgid "" -"Sunshine failed to start.\n" -"\n" -"Error: {}\n" -"\n" -"If this is a dependency issue (missing libraries), try the 'Fix Dependencies' button." +"End the running game gently, or forcefully evict the network address? Ending" +" the game keeps the device paired." msgstr "" -"O Sunshine falhou ao iniciar.\n" -"\n" -"Erro: {}\n" -"\n" -"Se este for um problema de dependência (bibliotecas ausentes), tente o botão 'Corrigir " -"Dependências'." -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1415 -msgid "Server Failed to Start" -msgstr "O servidor falhou ao iniciar." -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1418 -msgid "View Logs" -msgstr "Ver Registros" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1442 -msgid "Restore Defaults" -msgstr "Restaurar Padrões" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1442 -msgid "Do you want to restore default settings?" -msgstr "Você deseja restaurar as configurações padrão?" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1443 -msgid "Restore" + +#: src/big_remote_play/ui/performance_monitor.py:508 +msgid "End Game" +msgstr "" + +#: src/big_remote_play/ui/performance_monitor.py:511 +msgid "Evict IP" +msgstr "" + +# File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, +# line: 802 +#: src/big_remote_play/ui/performance_monitor.py:798 +msgid "Active Connection" +msgstr "Conexão Ativa" + +# File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, +# line: 666 +#: src/big_remote_play/ui/performance_monitor.py:800 +#, python-brace-format +msgid "{} devices monitoring" +msgstr "monitoramento de dispositivos {}" + +# File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, +# line: 669 +#: src/big_remote_play/ui/performance_monitor.py:803 +msgid "Active - No devices" +msgstr "Ativo - Nenhum dispositivo" + +# File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, +# line: 864 +#: src/big_remote_play/ui/performance_monitor.py:860 +msgid "Disconnect this specific guest (Admin)" +msgstr "Desconectar este convidado específico (Admin)" + +#: src/big_remote_play/ui/performance_monitor.py:862 +msgid "Disconnect guest" +msgstr "" + +# File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, +# line: 722 +#: src/big_remote_play/ui/performance_monitor.py:869 +#, python-brace-format +msgid "Connected to {}" +msgstr "Conectado a {}" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 74 +#: src/big_remote_play/ui/preferences.py:40 +#: src/big_remote_play/ui/sunshine_preferences.py:76 +#: src/big_remote_play/ui/sunshine_preferences.py:103 +msgid "General" +msgstr "Geral" + +#: src/big_remote_play/ui/preferences.py:45 +msgid "Appearance" +msgstr "Aparência" + +#: src/big_remote_play/ui/preferences.py:49 +msgid "Theme" +msgstr "Tema" + +#: src/big_remote_play/ui/preferences.py:50 +msgid "Choose the color scheme" +msgstr "Escolha o esquema de cores" + +#: src/big_remote_play/ui/preferences.py:54 +msgid "Light" +msgstr "Claro" + +#: src/big_remote_play/ui/preferences.py:55 +msgid "Dark" +msgstr "Escuro" + +#: src/big_remote_play/ui/preferences.py:79 +msgid "Reset all settings to default" +msgstr "Redefinir todas as configurações para o padrão" + +# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 74 +# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 81 +# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 249 +#: src/big_remote_play/ui/preferences.py:81 +msgid "Restaurar" msgstr "Restaurar" -# -# File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1451 -msgid "Settings Restored" -msgstr "Configurações Restauradas" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 36 -msgid "Detecting bandwidth..." -msgstr "Detectando largura de banda..." -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 42 -msgid "Suggested bitrate: {} Mbps" -msgstr "Taxa de bits sugerida: {} Mbps" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 56 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 158 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 177 -msgid "Find and connect to the host using the options below." -msgstr "Encontre e conecte-se ao host usando as opções abaixo." -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 67 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 947 -msgid "Shortcuts & Instructions" -msgstr "Atalhos e Instruções" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 64 -msgid "Discover" -msgstr "Descobrir" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 67 -msgid "Manual" -msgstr "Manual" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 82 -msgid "Client Settings" -msgstr "Configurações do Cliente" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 91 -msgid "Native Resolution (Adaptive)" -msgstr "Resolução Nativa (Adaptativa)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 91 -msgid "Use screen/window resolution" -msgstr "Use a resolução de tela/janela" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 96 -msgid "Video smoothness" -msgstr "Suavidade do vídeo" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 109 -msgid "Apply and Reconnect" -msgstr "Aplicar e Reconectar" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 115 -msgid "Bitrate (Quality)" -msgstr "Taxa de bits (Qualidade)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 115 -msgid "Adjust bandwidth (0.5 - 150 Mbps)" -msgstr "Ajustar largura de banda (0,5 - 150 Mbps)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 119 -msgid "Detect" -msgstr "Detectar" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 123 -msgid "How the window will be displayed" -msgstr "Como a janela será exibida" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 128 -msgid "Receive audio streaming" -msgstr "Receber streaming de áudio" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 130 -msgid "Hardware Decoding" -msgstr "Decodificação de Hardware" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 130 -msgid "Use GPU for decoding" -msgstr "Usar GPU para decodificação" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 148 -msgid "Host connected: {}" -msgstr "Host conectado: {}" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 149 -msgid "Active Session" -msgstr "Sessão Ativa" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 163 -msgid "Moonlight closed" -msgstr "Moonlight fechado" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 177 -msgid "Host connected. Click Stop to disconnect." -msgstr "Host conectado. Clique em Parar para desconectar." -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 220 -msgid "Connect to {}" -msgstr "Conectar a {}" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 220 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 278 -msgid "Connect to Selected" -msgstr "Conectar ao Selecionado" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 227 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 434 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 458 -msgid "Connect with PIN" -msgstr "Conectar com PIN" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 231 -msgid "Applying settings..." -msgstr "Aplicando configurações..." -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 255 -msgid "Discovered Hosts" -msgstr "Hosts Descobertos" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 256 -msgid "Scroll to list all found devices." -msgstr "Role para listar todos os dispositivos encontrados." -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 307 -msgid "Searching for hosts..." -msgstr "Buscando por hosts..." -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 319 -msgid "No hosts found" -msgstr "Nenhum host encontrado" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 368 -msgid "IP Copied: {}" -msgstr "IP Copiado: {}" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 381 -msgid "Connection Data" -msgstr "Dados de Conexão" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 382 -msgid "Enter server IP and port" -msgstr "Digite o IP do servidor e a porta" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 385 -msgid "IP/Hostname" -msgstr "IP/Nome do Host" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 389 -msgid "Port" -msgstr "Porta" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 393 -msgid "Use IPv6" -msgstr "Use IPv6" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 435 -msgid "Enter the 6-digit PIN code provided by the host" -msgstr "Digite o código PIN de 6 dígitos fornecido pelo anfitrião." -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 472 -msgid "Clicked Connect..." -msgstr "Clicou em Conectar..." -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 560 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 598 -msgid "Active Stream" -msgstr "Fluxo Ativo" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 562 -msgid "Failed to connect. Verify if Moonlight is paired." -msgstr "Falha ao conectar. Verifique se o Moonlight está emparelhado." -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 600 -msgid "Failed to connect" -msgstr "Falha ao conectar" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 620 -msgid "Attempting automatic pairing PIN: {}" -msgstr "Tentando emparelhamento automático PIN: {}" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 623 -msgid "Automatic pairing sent!" -msgstr "Emparelhamento automático enviado!" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 634 -msgid "Starting pairing..." -msgstr "Iniciando emparelhamento..." -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 651 -msgid "Paired successfully!" -msgstr "Pareado com sucesso!" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 653 -msgid "Pairing Error" -msgstr "Erro de emparelhamento" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 653 + +#: src/big_remote_play/ui/preferences.py:89 +msgid "Clear Everything" +msgstr "Limpar tudo" + +#: src/big_remote_play/ui/preferences.py:90 +msgid "Remove logs, settings, servers and saved data" +msgstr "Remove logs, configurações, servidores e dados salvos" + +# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 89 +# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 92 +#: src/big_remote_play/ui/preferences.py:92 +msgid "Limpar Tudo" +msgstr "Limpar Tudo" + +#: src/big_remote_play/ui/preferences.py:109 +msgid "Paths" +msgstr "Caminhos" + +#: src/big_remote_play/ui/preferences.py:112 +msgid "Configuration Directory" +msgstr "Diretório de configuração" + +# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 79 +#: src/big_remote_play/ui/preferences.py:119 +msgid "Copiar Caminho" +msgstr "Copiar Caminho" + +#: src/big_remote_play/ui/preferences.py:128 +msgid "Logs and Debugging" +msgstr "Logs e depuração" + +#: src/big_remote_play/ui/preferences.py:131 +msgid "Detailed Logs" +msgstr "Logs detalhados" + +#: src/big_remote_play/ui/preferences.py:132 +msgid "Enable verbose logging for debugging" +msgstr "Ativar logs detalhados para depuração" + +#: src/big_remote_play/ui/preferences.py:137 +msgid "Clear Logs" +msgstr "Limpar logs" + +#: src/big_remote_play/ui/preferences.py:138 +msgid "Remove old log files" +msgstr "Remove arquivos de log antigos" + +# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 100 +#: src/big_remote_play/ui/preferences.py:140 +msgid "Limpar" +msgstr "Limpar" + +# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 158 +#: src/big_remote_play/ui/preferences.py:185 +msgid "Logs Limpos" +msgstr "Logs Limpos" + +# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 158 +#: src/big_remote_play/ui/preferences.py:185 +msgid "Arquivos de log antigos foram removidos." +msgstr "Old log files have been removed." + +#: src/big_remote_play/ui/preferences.py:195 +msgid "Path copied!" +msgstr "" + +#: src/big_remote_play/ui/preferences.py:200 +msgid "" +"All your changes in Big Remote Play will be restored! This includes Sunshine" +" and Moonlight settings." +msgstr "" + +#: src/big_remote_play/ui/preferences.py:202 +msgid "Restore Defaults?" +msgstr "" + +#: src/big_remote_play/ui/preferences.py:234 +msgid "Settings restored! Restart the application to apply all changes." +msgstr "" + +#: src/big_remote_play/ui/preferences.py:238 +#, python-brace-format +msgid "Error restoring: {}" +msgstr "" + +#: src/big_remote_play/ui/preferences.py:245 +msgid "Clear EVERYTHING?" +msgstr "" + +#: src/big_remote_play/ui/preferences.py:245 +msgid "" +"WARNING: This will delete ALL data, logs, settings and saved servers. The " +"application will close." +msgstr "" + +#: src/big_remote_play/ui/preferences.py:247 +msgid "Delete All" +msgstr "" + +#: src/big_remote_play/ui/preferences.py:254 +msgid "Are You Absolutely Sure?" +msgstr "" + +#: src/big_remote_play/ui/preferences.py:254 +msgid "This action is IRREVERSIBLE. You will lose all configured data." +msgstr "" + +#: src/big_remote_play/ui/preferences.py:256 +msgid "Yes, Delete All" +msgstr "" + +#: src/big_remote_play/ui/preferences.py:299 +msgid "Error Clearing" +msgstr "" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 24 +#: src/big_remote_play/ui/private_network_view.py:41 +msgid "Create Headscale Server" +msgstr "Criar Servidor Headscale" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 25 +#: src/big_remote_play/ui/private_network_view.py:42 +msgid "Set up your own private VPN server using Docker + Cloudflare DNS." +msgstr "" +"Configure seu próprio servidor VPN privado usando Docker + DNS do " +"Cloudflare." + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 26 +#: src/big_remote_play/ui/private_network_view.py:43 +msgid "Connect to Headscale Network" +msgstr "Conectar à Rede Headscale" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 27 +#: src/big_remote_play/ui/private_network_view.py:44 +msgid "Enter the server domain and auth key provided by the administrator." +msgstr "" +"Insira o domínio do servidor e a chave de autenticação fornecida pelo " +"administrador." + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 34 +#: src/big_remote_play/ui/private_network_view.py:51 +msgid "Login to Tailscale" +msgstr "Faça login no Tailscale" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 35 +#: src/big_remote_play/ui/private_network_view.py:52 +msgid "Login to your Tailscale account. No server required." +msgstr "Faça login na sua conta Tailscale. Nenhum servidor necessário." + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 36 +#: src/big_remote_play/ui/private_network_view.py:53 +msgid "Connect to Tailscale Network" +msgstr "Conectar à Rede Tailscale" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 37 +#: src/big_remote_play/ui/private_network_view.py:54 +msgid "Enter your auth key or login server to join a Tailscale network." +msgstr "" +"Insira sua chave de autenticação ou servidor de login para ingressar em uma " +"rede Tailscale." + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 44 +#: src/big_remote_play/ui/private_network_view.py:61 +msgid "Create ZeroTier Network" +msgstr "Criar Rede ZeroTier" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 45 +#: src/big_remote_play/ui/private_network_view.py:62 +msgid "Create a new ZeroTier virtual network using your API token." +msgstr "Crie uma nova rede virtual ZeroTier usando seu token de API." + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 46 +#: src/big_remote_play/ui/private_network_view.py:63 +msgid "Connect to ZeroTier Network" +msgstr "Conectar à Rede ZeroTier" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 47 +#: src/big_remote_play/ui/private_network_view.py:64 +msgid "Enter the 16-character Network ID to join a ZeroTier network." +msgstr "" +"Insira o ID de Rede de 16 caracteres para ingressar em uma rede ZeroTier." + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 238 +#: src/big_remote_play/ui/private_network_view.py:370 +msgid "Configuration" +msgstr "Configuração" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 508 +#: src/big_remote_play/ui/private_network_view.py:403 +#: src/big_remote_play/ui/private_network_view.py:1331 +msgid "Instructions" +msgstr "Instruções" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 277 +#: src/big_remote_play/ui/private_network_view.py:409 +msgid "Logout / Disconnect" +msgstr "Sair / Desconectar" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 289 +#: src/big_remote_play/ui/private_network_view.py:421 +msgid "Your Networks" +msgstr "Suas Redes" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 302 +#: src/big_remote_play/ui/private_network_view.py:435 +msgid "Login / Connect" +msgstr "Entrar / Conectar" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 303 +#: src/big_remote_play/ui/private_network_view.py:437 +msgid "Save Token & Create Network" +msgstr "Salvar Token e Criar Rede" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 502 +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1764 +#: src/big_remote_play/ui/private_network_view.py:438 +msgid "Install Server" +msgstr "Instalar Servidor" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 322 +#: src/big_remote_play/ui/private_network_view.py:454 +msgid "Domain (e.g. vpn.ruscher.org)" +msgstr "Domínio (por exemplo, vpn.ruscher.org)" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 323 +#: src/big_remote_play/ui/private_network_view.py:455 +msgid "Cloudflare Zone ID" +msgstr "ID da Zona Cloudflare" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 324 +#: src/big_remote_play/ui/private_network_view.py:456 +msgid "Cloudflare API Token" +msgstr "Token da API do Cloudflare" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 332 +#: src/big_remote_play/ui/private_network_view.py:464 +msgid "Auth Key (optional – leave empty for browser login)" +msgstr "" +"Chave de autenticação (opcional – deixe em branco para login no navegador)" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 335 +#: src/big_remote_play/ui/private_network_view.py:467 +msgid "Get auth key" +msgstr "Obter chave de autenticação" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 335 +#: src/big_remote_play/ui/private_network_view.py:467 +msgid "login.tailscale.com/admin/settings/keys" +msgstr "login.tailscale.com/admin/configurações/chaves" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 337 +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 362 +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1318 +#: src/big_remote_play/ui/private_network_view.py:469 +#: src/big_remote_play/ui/private_network_view.py:489 +#: src/big_remote_play/ui/private_network_view.py:1459 +msgid "Open" +msgstr "Abrir" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 352 +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1346 +#: src/big_remote_play/ui/private_network_view.py:479 +#: src/big_remote_play/ui/private_network_view.py:1493 +msgid "ZeroTier API Token" +msgstr "Token da API do ZeroTier" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 355 +#: src/big_remote_play/ui/private_network_view.py:482 +msgid "Network Name" +msgstr "Nome da Rede" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 360 +#: src/big_remote_play/ui/private_network_view.py:487 +msgid "Get API Token" +msgstr "Obter Token da API" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 360 +#: src/big_remote_play/ui/private_network_view.py:487 +msgid "my.zerotier.com → Account → API Access Tokens" +msgstr "my.zerotier.com → Conta → Tokens de Acesso à API" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 438 +#: src/big_remote_play/ui/private_network_view.py:559 +msgid "All fields are required" +msgstr "Todos os campos são obrigatórios" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 456 +#: src/big_remote_play/ui/private_network_view.py:568 +msgid "✅ Server installed!" +msgstr "✅ Servidor instalado!" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 460 +#: src/big_remote_play/ui/private_network_view.py:572 +msgid "Headscale server installed!" +msgstr "Servidor Headscale instalado!" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 463 +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 492 +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 530 +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1503 +#: src/big_remote_play/ui/private_network_view.py:575 +#: src/big_remote_play/ui/private_network_view.py:598 +#: src/big_remote_play/ui/private_network_view.py:632 +#: src/big_remote_play/ui/private_network_view.py:1648 +msgid "❌ Failed" +msgstr "❌ Falhou" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 464 +#: src/big_remote_play/ui/private_network_view.py:576 +msgid "Installation failed. Check the log." +msgstr "A instalação falhou. Verifique o log." + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 485 +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1479 +#: src/big_remote_play/ui/private_network_view.py:591 +#: src/big_remote_play/ui/private_network_view.py:1624 +msgid "✅ Connected!" +msgstr "✅ Conectado!" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 489 +#: src/big_remote_play/ui/private_network_view.py:595 +msgid "Tailscale connected!" +msgstr "Tailscale conectado!" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 493 +#: src/big_remote_play/ui/private_network_view.py:599 +msgid "Login failed" +msgstr "Falha no login" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 533 +#: src/big_remote_play/ui/private_network_view.py:607 +msgid "API Token is required" +msgstr "Token de API é necessário" + +#: src/big_remote_play/ui/private_network_view.py:613 +#: src/big_remote_play/ui/private_network_view.py:1533 +msgid "System keyring is unavailable. Token was not saved." +msgstr "" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 520 +#: src/big_remote_play/ui/private_network_view.py:622 +msgid "✅ Network created!" +msgstr "✅ Rede criada!" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 526 +#: src/big_remote_play/ui/private_network_view.py:628 +msgid "ZeroTier network created!" +msgstr "Rede ZeroTier criada!" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 531 +#: src/big_remote_play/ui/private_network_view.py:633 +msgid "Network creation failed" +msgstr "Falha na criação da rede" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 548 +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 557 +#: src/big_remote_play/ui/private_network_view.py:651 +#: src/big_remote_play/ui/private_network_view.py:659 +#: src/big_remote_play/ui/private_network_view.py:2069 +#: src/big_remote_play/ui/private_network_view.py:2077 +msgid "Setup Instructions" +msgstr "Instruções de Configuração" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 558 +#: src/big_remote_play/ui/private_network_view.py:659 +msgid "Step-by-step guide to create your private network" +msgstr "Guia passo a passo para criar sua rede privada" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 578 +#: src/big_remote_play/ui/private_network_view.py:678 +msgid "1. Register a Free Domain" +msgstr "1. Registre um Domínio Gratuito" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 579 +#: src/big_remote_play/ui/private_network_view.py:679 +msgid "Get a free .us.kg domain to use with your server" +msgstr "Obtenha um domínio .us.kg gratuito para usar com seu servidor." + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 582 +#: src/big_remote_play/ui/private_network_view.py:682 +msgid "Access DigitalPlat Domain" +msgstr "Acessar o Domínio DigitalPlat" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 583 +#: src/big_remote_play/ui/private_network_view.py:683 +msgid "Register and get your free domain (e.g.: myserver.us.kg)" +msgstr "" +"Registre-se e obtenha seu domínio gratuito (por exemplo: meuServidor.us.kg)" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 586 +#: src/big_remote_play/ui/private_network_view.py:686 +#: src/big_remote_play/ui/private_network_view.py:899 +msgid "Open Site" +msgstr "Abrir Site" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 597 +#: src/big_remote_play/ui/private_network_view.py:695 +msgid "Steps at DigitalPlat" +msgstr "Etapas no DigitalPlat" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 599 +#: src/big_remote_play/ui/private_network_view.py:696 +msgid "" +"1. Create an account or login\n" +"2. Choose a .us.kg domain\n" +"3. Complete the simple registration\n" +"4. Write down the domain you obtained (e.g.: myserver.us.kg)" +msgstr "" +"1. Crie uma conta ou faça login\n" +"2. Escolha um domínio .us.kg\n" +"3. Complete o registro simples\n" +"4. Anote o domínio que você obteve (por exemplo: meuServidor.us.kg)" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 613 +#: src/big_remote_play/ui/private_network_view.py:706 +msgid "2. Configure Cloudflare" +msgstr "2. Configurar o Cloudflare" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 614 +#: src/big_remote_play/ui/private_network_view.py:707 +msgid "Point your domain to Cloudflare for DNS management" +msgstr "Aponte seu domínio para o Cloudflare para gerenciamento de DNS." + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 617 +#: src/big_remote_play/ui/private_network_view.py:710 +msgid "Access Cloudflare Dashboard" +msgstr "Acessar o Painel do Cloudflare" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 618 +#: src/big_remote_play/ui/private_network_view.py:711 +msgid "Create a free account and add your domain" +msgstr "Crie uma conta gratuita e adicione seu domínio." + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 621 +#: src/big_remote_play/ui/private_network_view.py:714 +msgid "Open Cloudflare" +msgstr "Abra o Cloudflare" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 632 +#: src/big_remote_play/ui/private_network_view.py:723 +msgid "Setup Steps" +msgstr "Etapas de Configuração" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 634 +#: src/big_remote_play/ui/private_network_view.py:726 +msgid "" +"1. Click 'Add a site' → Enter your domain\n" +"2. Choose the FREE plan\n" +"3. Write down the 2 nameservers provided\n" +"4. Go back to your domain provider (DigitalPlat)\n" +"5. Replace the DNS with Cloudflare's nameservers\n" +"6. Wait for DNS propagation (may take a few minutes)" +msgstr "" +"1. Clique em 'Adicionar um site' → Insira seu domínio\n" +"2. Escolha o plano GRATUITO\n" +"3. Anote os 2 servidores de nomes fornecidos\n" +"4. Volte para o seu provedor de domínio (DigitalPlat)\n" +"5. Substitua o DNS pelos servidores de nomes do Cloudflare\n" +"6. Aguarde a propagação do DNS (pode levar alguns minutos)" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 646 +#: src/big_remote_play/ui/private_network_view.py:739 +msgid "Update Nameservers" +msgstr "Atualizar Servidores de Nomes" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 647 +#: src/big_remote_play/ui/private_network_view.py:740 +msgid "Open domain panel to change NS records" +msgstr "Abra o painel de domínio para alterar os registros NS." + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 650 +#: src/big_remote_play/ui/private_network_view.py:743 +msgid "Domain Panel" +msgstr "Painel de Domínio" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 668 +#: src/big_remote_play/ui/private_network_view.py:759 +msgid "3. Get API Credentials" +msgstr "3. Obter Credenciais da API" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 669 +#: src/big_remote_play/ui/private_network_view.py:760 +msgid "Obtain your Zone ID and API Token from Cloudflare" +msgstr "Obtenha seu ID de Zona e Token da API do Cloudflare." + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 382 +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 672 +#: src/big_remote_play/ui/private_network_view.py:763 +msgid "Zone ID" +msgstr "ID da Zona" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 674 +#: src/big_remote_play/ui/private_network_view.py:764 +msgid "" +"1. In Cloudflare, click on your domain\n" +"2. Scroll down to the 'API' section on the right\n" +"3. Copy the 'Zone ID' value" +msgstr "" +"1. No Cloudflare, clique no seu domínio\n" +"2. Role para baixo até a seção 'API' à direita\n" +"3. Copie o valor 'ID da Zona'" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 385 +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 682 +#: src/big_remote_play/ui/private_network_view.py:769 +#: src/big_remote_play/ui/private_network_view.py:1506 +msgid "API Token" +msgstr "Token da API" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 684 +#: src/big_remote_play/ui/private_network_view.py:772 +msgid "" +"1. Click 'Get your API token' (below Zone ID)\n" +"2. Click 'Create Token'\n" +"3. Use template: 'Edit zone DNS' → 'Use template'\n" +"4. Configure:\n" +" • Token name: VPN-Token\n" +" • Permissions: Zone - DNS - Edit\n" +" • Zone: Select your domain\n" +"5. Click 'Continue to summary' → 'Create Token'\n" +"6. Copy token IMMEDIATELY (shown only once!)" +msgstr "" +"1. Clique em 'Obter seu token de API' (abaixo do ID da Zona)\n" +"2. Clique em 'Criar Token'\n" +"3. Use o modelo: 'Editar DNS da zona' → 'Usar modelo'\n" +"4. Configure:\n" +" • Nome do token: VPN-Token\n" +" • Permissões: Zona - DNS - Editar\n" +" • Zona: Selecione seu domínio\n" +"5. Clique em 'Continuar para resumo' → 'Criar Token'\n" +"6. Copie o token IMEDIATAMENTE (exibido apenas uma vez!)" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 703 +#: src/big_remote_play/ui/private_network_view.py:792 +msgid "4. Configure DNS Records in Cloudflare" +msgstr "4. Configurar Registros DNS no Cloudflare" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 704 +#: src/big_remote_play/ui/private_network_view.py:793 +msgid "Create DNS records pointing to your public IP" +msgstr "Crie registros DNS apontando para o seu IP público." + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 708 +#: src/big_remote_play/ui/private_network_view.py:797 +msgid "Your Public IP" +msgstr "Seu IP Público" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 709 +#: src/big_remote_play/ui/private_network_view.py:798 +msgid "Use this IP for the A record below" +msgstr "Use este IP para o registro A abaixo" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 712 +#: src/big_remote_play/ui/private_network_view.py:801 +msgid "Loading..." +msgstr "Carregando..." + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 746 +#: src/big_remote_play/ui/private_network_view.py:836 +msgid "A Record" +msgstr "Um Registro" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 748 +#: src/big_remote_play/ui/private_network_view.py:837 +msgid "" +"In Cloudflare → DNS → Records → 'Add record':\n" +"• Type: A\n" +"• Name: @\n" +"• Content: YOUR-PUBLIC-IP (shown above)\n" +"• Proxy: OFF (gray cloud — IMPORTANT!)" +msgstr "" +"No Cloudflare → DNS → Registros → 'Adicionar registro':\n" +"• Tipo: A\n" +"• Nome: @\n" +"• Conteúdo: SEU-IP-PÚBLICO (mostrado acima)\n" +"• Proxy: DESLIGADO (nuvem cinza — IMPORTANTE!)" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 758 +#: src/big_remote_play/ui/private_network_view.py:842 +msgid "CNAME Record" +msgstr "Registro CNAME" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 760 +#: src/big_remote_play/ui/private_network_view.py:843 +msgid "" +"Add another record:\n" +"• Type: CNAME\n" +"• Name: www\n" +"• Target: @\n" +"• Proxy: OFF" +msgstr "" +"Adicionar outro registro:\n" +"• Tipo: CNAME\n" +"• Nome: www\n" +"• Destino: @\n" +"• Proxy: DESLIGADO" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 775 +#: src/big_remote_play/ui/private_network_view.py:853 +msgid "5. Configure Router (Port Forwarding)" +msgstr "5. Configurar Roteador (Encaminhamento de Porta)" + +#: src/big_remote_play/ui/private_network_view.py:854 +msgid "" +"Open ports 80, 443, and 41641. The installation script will attempt to " +"configure these automatically via UPnP." +msgstr "" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 779 +#: src/big_remote_play/ui/private_network_view.py:857 +msgid "Access Your Router" +msgstr "Acesse seu Roteador" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 781 +#: src/big_remote_play/ui/private_network_view.py:858 +msgid "" +"1. Open your browser and go to 192.168.1.1 (or your router's IP)\n" +"2. Find 'Port Forwarding' or 'NAT' settings" +msgstr "" +"1. Abra seu navegador e vá para 192.168.1.1 (ou o IP do seu roteador) \n" +"2. Encontre as configurações de 'Encaminhamento de Porta' ou 'NAT'" + +#: src/big_remote_play/ui/private_network_view.py:863 +msgid "TLS certificate challenge and HTTP redirect" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:864 +msgid "Secure Headscale API and web interface" +msgstr "" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 790 +#: src/big_remote_play/ui/private_network_view.py:865 +msgid "Peer-to-peer VPN data" +msgstr "Dados de VPN ponto a ponto" + +#: src/big_remote_play/ui/private_network_view.py:870 +msgid "Forward to your local IP" +msgstr "" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 800 +#: src/big_remote_play/ui/private_network_view.py:875 +msgid "Find your local IP" +msgstr "Encontre seu IP local" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 801 +#: src/big_remote_play/ui/private_network_view.py:876 +msgid "Run 'hostname -I' in a terminal to find your local IP address" +msgstr "" +"Execute 'hostname -I' em um terminal para encontrar seu endereço IP local." + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 818 +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1987 +#: src/big_remote_play/ui/private_network_view.py:892 +#: src/big_remote_play/ui/private_network_view.py:2128 +msgid "Tailscale Instructions" +msgstr "Instruções do Tailscale" + +#: src/big_remote_play/ui/private_network_view.py:895 +#: src/big_remote_play/ui/private_network_view.py:918 +#: src/big_remote_play/ui/private_network_view.py:2131 +msgid "1. Create Account" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:896 +msgid "Tailscale Registration" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:897 +msgid "Create your free account to start managing your VPN mesh." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:902 +msgid "2. Login on Host" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:902 +msgid "Link this device" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:902 +msgid "" +"Use the 'Login / Connect' button on the previous tab to authorize this PC." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:904 +msgid "3. Admin Panel" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:905 +msgid "Manage Machines" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:906 +msgid "" +"View and authorize the machines connected to your network in the official " +"panel." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:908 +msgid "Open Panel" +msgstr "" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 825 +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1994 +#: src/big_remote_play/ui/private_network_view.py:916 +#: src/big_remote_play/ui/private_network_view.py:2145 +msgid "ZeroTier Instructions" +msgstr "Instruções do ZeroTier" + +#: src/big_remote_play/ui/private_network_view.py:918 +msgid "Access ZeroTier Central" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:918 +msgid "Sign up to create and manage your virtual networks." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:918 +msgid "Open Portal" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:920 +msgid "2. Authentication" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:921 +msgid "Generate API Token" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:922 +msgid "Go to 'Account Settings' and create a new 'API Access Token'." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:924 +msgid "Generate Token" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:927 +msgid "3. Configuration" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:927 +msgid "Link Network" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:927 +msgid "" +"Paste the Token in the matching field and click 'Save Token & Create " +"Network'." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:929 +msgid "4. Administration" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:930 +msgid "Authorize Members" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:931 +msgid "Click your network's Network ID to manage and authorize new devices." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:933 +msgid "My Networks" +msgstr "" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 846 +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 2014 +#: src/big_remote_play/ui/private_network_view.py:951 +#: src/big_remote_play/ui/private_network_view.py:2172 +msgid "Step-by-step guide" +msgstr "Guia passo a passo" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 898 +#: src/big_remote_play/ui/private_network_view.py:1002 +msgid "Disconnecting..." +msgstr "Desconectando..." + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 903 +#: src/big_remote_play/ui/private_network_view.py:1009 +msgid "Tailscale disconnected" +msgstr "Tailscale desconectado" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 915 +#: src/big_remote_play/ui/private_network_view.py:1023 +msgid "ZeroTier token removed" +msgstr "Token do ZeroTier removido" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 924 +#: src/big_remote_play/ui/private_network_view.py:1036 +msgid "Disconnected from Headscale" +msgstr "Desconectado do Headscale" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 987 +#: src/big_remote_play/ui/private_network_view.py:1095 +msgid "No networks found" +msgstr "Nenhuma rede encontrada" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 987 +#: src/big_remote_play/ui/private_network_view.py:1095 +msgid "Connect or create a network first" +msgstr "Conecte-se ou crie uma rede primeiro." + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 997 +#: src/big_remote_play/ui/private_network_view.py:1104 +msgid "This device" +msgstr "Este dispositivo" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1498 +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1529 +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1542 +#: src/big_remote_play/ui/private_network_view.py:1120 +msgid "Installation Log" +msgstr "Registro de Instalação" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1035 +#: src/big_remote_play/ui/private_network_view.py:1142 +msgid "🎉 Success! Share with friends:" +msgstr "🎉 Sucesso! Compartilhe com amigos:" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1137 +#: src/big_remote_play/ui/private_network_view.py:1144 +#: src/big_remote_play/ui/private_network_view.py:1193 +#: src/big_remote_play/ui/private_network_view.py:1865 +#: src/big_remote_play/ui/private_network_view.py:1965 +msgid "Domain" +msgstr "Domínio" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1038 +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1084 +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1721 +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1842 +#: src/big_remote_play/ui/private_network_view.py:1145 +#: src/big_remote_play/ui/private_network_view.py:1193 +#: src/big_remote_play/ui/private_network_view.py:1866 +#: src/big_remote_play/ui/private_network_view.py:1987 +msgid "Network ID" +msgstr "ID da Rede" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 107 +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1197 +#: src/big_remote_play/ui/private_network_view.py:1146 +msgid "Auth Key (Friends)" +msgstr "Chave de Autenticação (Amigos)" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 146 +#: src/big_remote_play/ui/private_network_view.py:1147 +msgid "Web Interface" +msgstr "Interface Web" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1225 +#: src/big_remote_play/ui/private_network_view.py:1174 +msgid "Save Network Information" +msgstr "Salvar Informações da Rede" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1076 +#: src/big_remote_play/ui/private_network_view.py:1185 +msgid "Saved to file!" +msgstr "Salvo em arquivo!" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1083 +#: src/big_remote_play/ui/private_network_view.py:1192 +msgid "" +"VPN Network Details:\n" +"\n" +msgstr "" +"Detalhes da Rede VPN:\n" +"\n" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 865 +#: src/big_remote_play/ui/private_network_view.py:1193 +#: src/big_remote_play/ui/private_network_view.py:1443 +#: src/big_remote_play/ui/private_network_view.py:1449 +#: src/big_remote_play/ui/private_network_view.py:1867 +#: src/big_remote_play/ui/private_network_view.py:1970 +#: src/big_remote_play/ui/private_network_view.py:1981 +msgid "Auth Key" +msgstr "Chave de Autenticação" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1090 +#: src/big_remote_play/ui/private_network_view.py:1201 +msgid "Opening mail client..." +msgstr "Abrindo cliente de e-mail..." + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1095 +#: src/big_remote_play/ui/private_network_view.py:1206 +msgid "Saved to history!" +msgstr "Salvo no histórico!" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 204 +#: src/big_remote_play/ui/private_network_view.py:1211 +msgid "Save to File" +msgstr "Salvar em Arquivo" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 215 +#: src/big_remote_play/ui/private_network_view.py:1219 +msgid "Share" +msgstr "Compartilhar" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1121 +#: src/big_remote_play/ui/private_network_view.py:1234 +msgid "Network Information" +msgstr "Informações da Rede" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1013 +#: src/big_remote_play/ui/private_network_view.py:1273 +#: src/big_remote_play/ui/private_network_view.py:1401 +msgid "Status" +msgstr "Status" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1031 +#: src/big_remote_play/ui/private_network_view.py:1273 +#: src/big_remote_play/ui/private_network_view.py:1415 +msgid "Previous Networks" +msgstr "Redes Anteriores" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 863 +#: src/big_remote_play/ui/private_network_view.py:1283 +#: src/big_remote_play/ui/private_network_view.py:1959 +msgid "Connection Details" +msgstr "Detalhes da Conexão" + +#: src/big_remote_play/ui/private_network_view.py:1288 +msgid "What you'll need" +msgstr "O que você vai precisar" + +#: src/big_remote_play/ui/private_network_view.py:1291 +msgid "Server domain" +msgstr "Domínio do servidor" + +#: src/big_remote_play/ui/private_network_view.py:1291 +msgid "The address of your VPN server, e.g. vpn.yourcompany.com." +msgstr "O endereço do seu servidor VPN, por exemplo vpn.suaempresa.com." + +#: src/big_remote_play/ui/private_network_view.py:1292 +msgid "Auth key" +msgstr "Chave de autenticação" + +#: src/big_remote_play/ui/private_network_view.py:1292 +msgid "A key generated on the server to connect this device." +msgstr "Uma chave gerada no servidor para conectar este dispositivo." + +#: src/big_remote_play/ui/private_network_view.py:1293 +msgid "Working internet" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1293 +msgid "An internet connection is required to establish the link." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:1306 +msgid "" +"Your credentials stay only on this device and are used " +"exclusively to authenticate on the private network." +msgstr "" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 937 +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1908 +#: src/big_remote_play/ui/private_network_view.py:1319 +#: src/big_remote_play/ui/private_network_view.py:1625 +#: src/big_remote_play/ui/private_network_view.py:2139 +msgid "Establish Connection" +msgstr "Estabelecer Conexão" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 973 +#: src/big_remote_play/ui/private_network_view.py:1350 +msgid "Network Devices" +msgstr "Dispositivos de Rede" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1220 +#: src/big_remote_play/ui/private_network_view.py:1363 +msgid "Set API Token to see all network devices" +msgstr "Defina o Token da API para ver todos os dispositivos de rede." + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1222 +#: src/big_remote_play/ui/private_network_view.py:1365 +msgid "Set Token" +msgstr "Definir Token" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1229 +#: src/big_remote_play/ui/private_network_view.py:1371 +msgid "Auth" +msgstr "Autenticação" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1229 +#: src/big_remote_play/ui/private_network_view.py:1371 +msgid "ID" +msgstr "ID" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1229 +#: src/big_remote_play/ui/private_network_view.py:1371 +msgid "Name" +msgstr "Nome" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1230 +#: src/big_remote_play/ui/private_network_view.py:1371 +msgid "Managed IP" +msgstr "IP Gerenciado" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1230 +#: src/big_remote_play/ui/private_network_view.py:1371 +msgid "Last Seen" +msgstr "Última vez visto" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1231 +#: src/big_remote_play/ui/private_network_view.py:1371 +msgid "Version" +msgstr "Versão" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1231 +#: src/big_remote_play/ui/private_network_view.py:1371 +msgid "Physical IP" +msgstr "IP físico" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1249 +#: src/big_remote_play/ui/private_network_view.py:1388 +msgid "Set ZeroTier API Token" +msgstr "Definir Token da API ZeroTier" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1255 +#: src/big_remote_play/ui/private_network_view.py:1394 +msgid "Create API Access Token" +msgstr "Criar Token de Acesso à API" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1301 +#: src/big_remote_play/ui/private_network_view.py:1442 +msgid "Server Domain (e.g. vpn.ruscher.org)" +msgstr "Domínio do Servidor (ex: vpn.ruscher.org)" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1307 +#: src/big_remote_play/ui/private_network_view.py:1448 +msgid "Login Server (leave empty for tailscale.com)" +msgstr "Servidor de Login (deixe em branco para tailscale.com)" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1313 +#: src/big_remote_play/ui/private_network_view.py:1454 +msgid "Network ID (16 characters)" +msgstr "ID da Rede (16 caracteres)" + +#: src/big_remote_play/ui/private_network_view.py:1455 +msgid "e.g. a1b2c3d4e5f6a7b8" +msgstr "" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1314 +# +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1314 +# +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1316 +#: src/big_remote_play/ui/private_network_view.py:1457 +msgid "Find Network ID" +msgstr "Encontrar ID da Rede" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1316 +#: src/big_remote_play/ui/private_network_view.py:1457 +msgid "my.zerotier.com → Networks" +msgstr "my.zerotier.com → Redes" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1355 +#: src/big_remote_play/ui/private_network_view.py:1502 +msgid "Enter your API Token to view managed devices." +msgstr "Insira seu Token de API para visualizar dispositivos gerenciados." + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1371 +#: src/big_remote_play/ui/private_network_view.py:1518 +msgid "Save & Refresh" +msgstr "Salvar e Atualizar" + +#: src/big_remote_play/ui/private_network_view.py:1530 +msgid "Token saved in the system keyring" +msgstr "" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1848 +#: src/big_remote_play/ui/private_network_view.py:1550 +msgid "Connecting..." +msgstr "Conectando..." + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1410 +#: src/big_remote_play/ui/private_network_view.py:1558 +msgid "Domain and Auth Key required" +msgstr "Domínio e chave de autenticação necessárias" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1420 +#: src/big_remote_play/ui/private_network_view.py:1567 +msgid "Auth Key is required" +msgstr "A chave de autenticação é necessária." + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1431 +#: src/big_remote_play/ui/private_network_view.py:1578 +msgid "Network ID is required" +msgstr "ID da rede é obrigatório" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1907 +#: src/big_remote_play/ui/private_network_view.py:1645 +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:497 +msgid "Connected successfully!" +msgstr "Conectado com sucesso!" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1769 +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1914 +#: src/big_remote_play/ui/private_network_view.py:1649 +msgid "Try Again" +msgstr "Tente Novamente" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1913 +#: src/big_remote_play/ui/private_network_view.py:1650 +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:476 +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:518 +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:583 +msgid "Connection failed" +msgstr "Conexão falhou" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1539 +#: src/big_remote_play/ui/private_network_view.py:1686 +msgid "Just now" +msgstr "Agora mesmo" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1552 +#: src/big_remote_play/ui/private_network_view.py:1701 +msgid "Self" +msgstr "Auto" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1560 +#: src/big_remote_play/ui/private_network_view.py:1709 +msgid "Offline" +msgstr "Offline" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1630 +#: src/big_remote_play/ui/private_network_view.py:1779 +msgid "Peer" +msgstr "Paritário" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1642 +#: src/big_remote_play/ui/private_network_view.py:1791 +msgid "API Token Missing" +msgstr "Token da API ausente" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1642 +#: src/big_remote_play/ui/private_network_view.py:1791 +msgid "See banner above" +msgstr "Veja o banner acima" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1644 +#: src/big_remote_play/ui/private_network_view.py:1793 +msgid "No devices found" +msgstr "Nenhum dispositivo encontrado" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1644 +#: src/big_remote_play/ui/private_network_view.py:1793 +msgid "Try refreshing..." +msgstr "Tente atualizar..." + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1660 +#: src/big_remote_play/ui/private_network_view.py:1808 +msgid "No previous networks" +msgstr "Nenhuma rede anterior" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1662 +#: src/big_remote_play/ui/private_network_view.py:1808 +msgid "Your created/connected networks will appear here." +msgstr "Suas redes criadas/conectadas aparecerão aqui." + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1111 +#: src/big_remote_play/ui/private_network_view.py:1841 +msgid "Reconnect" +msgstr "Reconectar" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1704 +#: src/big_remote_play/ui/private_network_view.py:1849 +msgid "Edit" +msgstr "Editar" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1263 +#: src/big_remote_play/ui/private_network_view.py:1858 +#: src/big_remote_play/ui/private_network_view.py:2040 +msgid "Delete" +msgstr "Excluir" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1723 +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1849 +#: src/big_remote_play/ui/private_network_view.py:1868 +#: src/big_remote_play/ui/private_network_view.py:1994 +msgid "Web UI" +msgstr "Interface Web" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1724 +#: src/big_remote_play/ui/private_network_view.py:1869 +msgid "VPN" +msgstr "VPN" + +#: src/big_remote_play/ui/private_network_view.py:1875 +msgid "Stored in system keyring" +msgstr "" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1772 +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1787 +#: src/big_remote_play/ui/private_network_view.py:1919 +#: src/big_remote_play/ui/private_network_view.py:1935 +#, python-brace-format +msgid "Form filled for {}" +msgstr "Formulário preenchido para {}" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1796 +#: src/big_remote_play/ui/private_network_view.py:1944 +#, python-brace-format +msgid "Edit – {}" +msgstr "Editar – {}" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1802 +#: src/big_remote_play/ui/private_network_view.py:1949 +msgid "Edit Network Entry" +msgstr "Editar Entrada de Rede" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1831 +#: src/big_remote_play/ui/private_network_view.py:1976 +msgid "Login Server" +msgstr "Servidor de Login" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1878 +#: src/big_remote_play/ui/private_network_view.py:2023 +msgid "Entry updated" +msgstr "Entrada atualizada" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1895 +#: src/big_remote_play/ui/private_network_view.py:2038 +msgid "Delete entry?" +msgstr "Excluir entrada?" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1896 +#: src/big_remote_play/ui/private_network_view.py:2038 +msgid "Remove this network from history?" +msgstr "Remover esta rede do histórico?" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1498 +#: src/big_remote_play/ui/private_network_view.py:2054 +msgid "Connection Log" +msgstr "Registro de Conexão" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1936 +#: src/big_remote_play/ui/private_network_view.py:2077 +msgid "Step-by-step guide to join a private network" +msgstr "Guia passo a passo para ingressar em uma rede privada." + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1956 +#: src/big_remote_play/ui/private_network_view.py:2096 +msgid "Steps to join Headscale" +msgstr "Passos para se juntar ao Headscale" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1959 +#: src/big_remote_play/ui/private_network_view.py:2099 +msgid "Step 1: Get credentials" +msgstr "Passo 1: Obter credenciais" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1960 +#: src/big_remote_play/ui/private_network_view.py:2100 +msgid "Ask the network administrator for the Server Domain and Auth Key." +msgstr "" +"Peça ao administrador da rede o Domínio do Servidor e a Chave de " +"Autenticação." + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1965 +#: src/big_remote_play/ui/private_network_view.py:2105 +msgid "Step 2: Enter details" +msgstr "Passo 2: Insira os detalhes" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1966 +#: src/big_remote_play/ui/private_network_view.py:2106 +msgid "Paste the Domain and Key in the fields on the previous tab." +msgstr "Cole o Domínio e a Chave nos campos na aba anterior." + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1971 +#: src/big_remote_play/ui/private_network_view.py:2111 +msgid "Step 3: Connect" +msgstr "Passo 3: Conectar" + +# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, +# line: 1972 +#: src/big_remote_play/ui/private_network_view.py:2112 +msgid "Click 'Establish Connection' and wait for the success message." +msgstr "Clique em 'Estabelecer Conexão' e aguarde a mensagem de sucesso." + +#: src/big_remote_play/ui/private_network_view.py:2132 +msgid "Access Tailscale" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:2133 +msgid "All participants need an account (Google, GitHub, etc)." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:2135 +msgid "Create Account" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:2138 +msgid "2. Invitation" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:2138 +msgid "Request Access" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:2138 +msgid "" +"The administrator must share the node or invite your email to the network." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:2139 +msgid "3. Connect" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:2139 +msgid "Once the invitation is accepted, use the previous tab to connect." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:2147 +msgid "1. Identification" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:2147 +msgid "Get Network ID" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:2147 +msgid "Request the 16-character ID from the network owner." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:2148 +msgid "2. Join" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:2148 +msgid "Enter ID" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:2148 +msgid "Enter the ID on the 'Connect' tab and click 'Establish Connection'." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:2150 +msgid "3. Authorization" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:2151 +msgid "Wait for Approval" +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:2152 +msgid "" +"The administrator must check the 'Auth' option for your PC in their panel." +msgstr "" + +#: src/big_remote_play/ui/private_network_view.py:2154 +msgid "ZT Panel" +msgstr "" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 74 +#: src/big_remote_play/ui/sunshine_preferences.py:76 +msgid "Server general settings" +msgstr "Configurações gerais do servidor" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 75 +#: src/big_remote_play/ui/sunshine_preferences.py:77 +msgid "Input" +msgstr "Entrada" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 75 +#: src/big_remote_play/ui/sunshine_preferences.py:77 +msgid "Keyboard, mouse and gamepad" +msgstr "Teclado, mouse e controle de jogo" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 76 +#: src/big_remote_play/ui/sunshine_preferences.py:78 +msgid "Audio/Video" +msgstr "Áudio/Vídeo" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 76 +#: src/big_remote_play/ui/sunshine_preferences.py:78 +msgid "Resolution, bitrate and quality" +msgstr "Resolução, taxa de bits e qualidade" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 77 +#: src/big_remote_play/ui/sunshine_preferences.py:79 +msgid "Network" +msgstr "Rede" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 77 +#: src/big_remote_play/ui/sunshine_preferences.py:79 +msgid "Ports and connectivity" +msgstr "Portas e conectividade" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 78 +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 98 +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 109 +#: src/big_remote_play/ui/sunshine_preferences.py:80 +#: src/big_remote_play/ui/sunshine_preferences.py:99 +#: src/big_remote_play/ui/sunshine_preferences.py:114 +msgid "Config Files" +msgstr "Arquivos de Configuração" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 78 +#: src/big_remote_play/ui/sunshine_preferences.py:80 +msgid "Configuration files and logs" +msgstr "Arquivos de configuração e logs" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 79 +#: src/big_remote_play/ui/sunshine_preferences.py:81 +msgid "Advanced options" +msgstr "Opções avançadas" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 82 +#: src/big_remote_play/ui/sunshine_preferences.py:83 +msgid "NVIDIA NVENC" +msgstr "NVIDIA NVENC" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 82 +#: src/big_remote_play/ui/sunshine_preferences.py:83 +msgid "NVIDIA Encoder" +msgstr "Codificador NVIDIA" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 83 +#: src/big_remote_play/ui/sunshine_preferences.py:84 +msgid "Intel QuickSync" +msgstr "Intel QuickSync" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 83 +#: src/big_remote_play/ui/sunshine_preferences.py:84 +msgid "Intel Encoder" +msgstr "Codificador Intel" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 84 +#: src/big_remote_play/ui/sunshine_preferences.py:85 +msgid "AMD AMF" +msgstr "AMD AMF" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 84 +#: src/big_remote_play/ui/sunshine_preferences.py:85 +msgid "AMD Encoder" +msgstr "Codificador AMD" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 85 +#: src/big_remote_play/ui/sunshine_preferences.py:86 +msgid "VideoToolbox" +msgstr "VideoToolbox" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 85 +#: src/big_remote_play/ui/sunshine_preferences.py:86 +msgid "Apple Encoder" +msgstr "Codificador Apple" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 86 +#: src/big_remote_play/ui/sunshine_preferences.py:87 +msgid "VA-API" +msgstr "VA-API" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 86 +#: src/big_remote_play/ui/sunshine_preferences.py:87 +msgid "VA-API Encoder (Linux)" +msgstr "Codificador VA-API (Linux)" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 87 +#: src/big_remote_play/ui/sunshine_preferences.py:88 +msgid "CPU Encoding" +msgstr "Codificação da CPU" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 112 +#: src/big_remote_play/ui/sunshine_preferences.py:117 +msgid "No options available" +msgstr "Nenhuma opção disponível" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 220 +#: src/big_remote_play/ui/sunshine_preferences.py:240 +msgid "Apps File" +msgstr "Arquivo de Aplicativos" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 220 +#: src/big_remote_play/ui/sunshine_preferences.py:240 +msgid "The file where current apps of Sunshine are stored." +msgstr "O arquivo onde os aplicativos atuais do Sunshine estão armazenados." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 221 +#: src/big_remote_play/ui/sunshine_preferences.py:241 +msgid "Credentials File" +msgstr "Arquivo de Credenciais" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 221 +#: src/big_remote_play/ui/sunshine_preferences.py:241 +msgid "Store Username/Password separately from Sunshine's state file." +msgstr "" +"Armazene o nome de usuário/senha separadamente do arquivo de estado do " +"Sunshine." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 222 +#: src/big_remote_play/ui/sunshine_preferences.py:242 +msgid "Logfile Path" +msgstr "Caminho do Arquivo de Log" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 222 +#: src/big_remote_play/ui/sunshine_preferences.py:242 +msgid "The file where the current logs of Sunshine are stored." +msgstr "O arquivo onde os logs atuais do Sunshine estão armazenados." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 223 +#: src/big_remote_play/ui/sunshine_preferences.py:244 +msgid "Private Key" +msgstr "Chave Privada" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 223 +#: src/big_remote_play/ui/sunshine_preferences.py:246 +msgid "" +"The private key used for the web UI and Moonlight client pairing. For best " +"compatibility, this should be an RSA-2048 private key." +msgstr "" +"A chave privada usada para a interface da web e emparelhamento do cliente " +"Moonlight. Para melhor compatibilidade, isso deve ser uma chave privada " +"RSA-2048." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 224 +#: src/big_remote_play/ui/sunshine_preferences.py:249 +msgid "Certificate" +msgstr "Certificado" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 224 +#: src/big_remote_play/ui/sunshine_preferences.py:251 +msgid "" +"The certificate used for the web UI and Moonlight client pairing. For best " +"compatibility, this should have an RSA-2048 public key." +msgstr "" +"O certificado usado para a interface da web e emparelhamento do cliente " +"Moonlight. Para melhor compatibilidade, isso deve ter uma chave pública " +"RSA-2048." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 225 +#: src/big_remote_play/ui/sunshine_preferences.py:253 +msgid "State File" +msgstr "Arquivo de Estado" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 225 +#: src/big_remote_play/ui/sunshine_preferences.py:253 +msgid "The file where current state of Sunshine is stored" +msgstr "O arquivo onde o estado atual do Sunshine está armazenado." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 226 +#: src/big_remote_play/ui/sunshine_preferences.py:254 +msgid "Configuration File" +msgstr "Arquivo de Configuração" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 226 +#: src/big_remote_play/ui/sunshine_preferences.py:254 +msgid "The main configuration file for Sunshine." +msgstr "O arquivo de configuração principal para Sunshine." + +#: src/big_remote_play/ui/sunshine_preferences.py:304 +msgid "Sunshine Runtime" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:306 +msgid "Sunshine is available." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:309 +msgid "Sunshine is not available." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:317 +msgid "Apply to Running Server" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:318 +msgid "Push these settings to Sunshine and restart it (no manual stop)." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:327 +msgid "Reload from Running Server" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:328 +msgid "Read the server's current configuration into these settings." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:329 +msgid "Reload" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:353 +msgid "Sunshine is not running. Settings are saved to file." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:365 +msgid "Settings applied; server restarting." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:365 +msgid "Failed to apply settings (check credentials)." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:371 +msgid "Sunshine is not running." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:385 +msgid "Could not read server configuration." +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:392 +msgid "Configuration reloaded. Reopen preferences to see updated values." +msgstr "" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 258 +#: src/big_remote_play/ui/sunshine_preferences.py:399 +msgid "Locale" +msgstr "Localidade" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 262 +#: src/big_remote_play/ui/sunshine_preferences.py:403 +msgid "The locale used for Sunshine's user interface." +msgstr "O idioma utilizado para a interface do usuário do Sunshine." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 263 +#: src/big_remote_play/ui/sunshine_preferences.py:405 +msgid "Sunshine Name" +msgstr "Nome do Sunshine" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 263 +#: src/big_remote_play/ui/sunshine_preferences.py:405 +msgid "The name of the Sunshine instance as seen by clients." +msgstr "O nome da instância Sunshine visto pelos clientes." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 264 +#: src/big_remote_play/ui/sunshine_preferences.py:406 +msgid "Username for API access (Monitoring)" +msgstr "Nome de usuário para acesso à API (Monitoramento)" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 265 +#: src/big_remote_play/ui/sunshine_preferences.py:407 +msgid "Password for API access (Monitoring)" +msgstr "Senha para acesso à API (Monitoramento)" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 266 +#: src/big_remote_play/ui/sunshine_preferences.py:410 +msgid "Log Level" +msgstr "Nível de Log" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 269 +#: src/big_remote_play/ui/sunshine_preferences.py:414 +msgid "The minimum log level printed to standard out" +msgstr "O nível mínimo de log impresso na saída padrão" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 270 +#: src/big_remote_play/ui/sunshine_preferences.py:416 +msgid "Pre-Release Notifications" +msgstr "Notificações de Pré-Lançamento" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 270 +#: src/big_remote_play/ui/sunshine_preferences.py:416 +msgid "Whether to be notified of new pre-release versions of Sunshine" +msgstr "" +"Se deseja ser notificado sobre novas versões pré-lançamento do Sunshine." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 271 +#: src/big_remote_play/ui/sunshine_preferences.py:417 +msgid "Enable System Tray" +msgstr "Ativar Área de Notificação" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 271 +#: src/big_remote_play/ui/sunshine_preferences.py:417 +msgid "Show icon in system tray and display desktop notifications" +msgstr "" +"Mostrar ícone na área de notificação e exibir notificações na área de " +"trabalho" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 276 +#: src/big_remote_play/ui/sunshine_preferences.py:422 +msgid "UPnP" +msgstr "UPnP" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 276 +#: src/big_remote_play/ui/sunshine_preferences.py:422 +msgid "" +"Automatically configure port forwarding for streaming over the Internet" +msgstr "" +"Configurar automaticamente o encaminhamento de porta para streaming pela " +"Internet" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 277 +#: src/big_remote_play/ui/sunshine_preferences.py:423 +msgid "Address Family" +msgstr "Família de Endereços" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 279 +#: src/big_remote_play/ui/sunshine_preferences.py:423 +msgid "Set the address family used by Sunshine" +msgstr "Defina a família de endereços usada pelo Sunshine." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 280 +#: src/big_remote_play/ui/sunshine_preferences.py:424 +msgid "Bind Address" +msgstr "Endereço de Ligação" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 280 +#: src/big_remote_play/ui/sunshine_preferences.py:424 +msgid "IP address to bind the service to" +msgstr "Endereço IP para vincular o serviço" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 281 +#: src/big_remote_play/ui/sunshine_preferences.py:427 +msgid "Port (Moonlight)" +msgstr "Porto (Luz da Lua)" + +#: src/big_remote_play/ui/sunshine_preferences.py:431 +msgid "" +"Set the family of ports used by Sunshine.\n" +"TCP: 47984, 47989, 48010\n" +"UDP: 47998 - 48012\n" +"The Web UI port stays local by default." +msgstr "" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 282 +#: src/big_remote_play/ui/sunshine_preferences.py:435 +msgid "Web UI Access" +msgstr "Acesso à Interface Web" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 284 +#: src/big_remote_play/ui/sunshine_preferences.py:439 +msgid "" +"The origin of the remote endpoint address that is not denied access to Web UI.\n" +"Warning: Exposing the Web UI to the internet is a security risk! Proceed at your own risk!" +msgstr "" +"A origem do endereço do ponto final remoto que não teve acesso negado à interface da Web. \n" +"Aviso: Expor a interface da Web à internet é um risco de segurança! Prossiga por sua conta e risco!" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 285 +#: src/big_remote_play/ui/sunshine_preferences.py:441 +msgid "External IP" +msgstr "IP Externo" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 285 +#: src/big_remote_play/ui/sunshine_preferences.py:441 +msgid "" +"If no external IP address is given, Sunshine will automatically detect " +"external IP" +msgstr "" +"Se nenhum endereço IP externo for fornecido, o Sunshine detectará " +"automaticamente o IP externo." + +#: src/big_remote_play/ui/sunshine_preferences.py:444 +msgid "Trusted Web UI Origins" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:448 +msgid "" +"Comma-separated trusted HTTPS origins allowed to call Sunshine state-" +"changing API endpoints. Leave empty to use Sunshine's built-in localhost " +"defaults." +msgstr "" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 286 +#: src/big_remote_play/ui/sunshine_preferences.py:452 +msgid "LAN Encryption" +msgstr "Criptografia de LAN" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 287 +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 290 +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 330 +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 333 +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 352 +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 398 +#: src/big_remote_play/ui/sunshine_preferences.py:455 +#: src/big_remote_play/ui/sunshine_preferences.py:463 +#: src/big_remote_play/ui/sunshine_preferences.py:607 +#: src/big_remote_play/ui/sunshine_preferences.py:615 +#: src/big_remote_play/ui/sunshine_preferences.py:655 +#: src/big_remote_play/ui/sunshine_preferences.py:763 +msgid "Disabled" +msgstr "Desativado" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 287 +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 290 +#: src/big_remote_play/ui/sunshine_preferences.py:455 +#: src/big_remote_play/ui/sunshine_preferences.py:463 +msgid "Mode 1" +msgstr "Modo 1" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 287 +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 290 +#: src/big_remote_play/ui/sunshine_preferences.py:455 +#: src/big_remote_play/ui/sunshine_preferences.py:463 +msgid "Mode 2" +msgstr "Modo 2" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 288 +#: src/big_remote_play/ui/sunshine_preferences.py:456 +msgid "" +"This determines when encryption will be used when streaming over your local " +"network. Encryption can reduce streaming performance, particularly on less " +"powerful hosts and clients." +msgstr "" +"Isso determina quando a criptografia será usada ao transmitir pela sua rede " +"local. A criptografia pode reduzir o desempenho de streaming, " +"particularmente em hosts e clientes menos poderosos." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 289 +#: src/big_remote_play/ui/sunshine_preferences.py:460 +msgid "WAN Encryption" +msgstr "Criptografia WAN" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 291 +#: src/big_remote_play/ui/sunshine_preferences.py:464 +msgid "" +"This determines when encryption will be used when streaming over the " +"Internet. Encryption can reduce streaming performance, particularly on less " +"powerful hosts and clients." +msgstr "" +"Isso determina quando a criptografia será usada ao transmitir pela Internet." +" A criptografia pode reduzir o desempenho de streaming, particularmente em " +"hosts e clientes menos poderosos." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 292 +#: src/big_remote_play/ui/sunshine_preferences.py:466 +msgid "Ping Timeout (ms)" +msgstr "Tempo limite de Ping (ms)" + +#: src/big_remote_play/ui/sunshine_preferences.py:466 +msgid "" +"How long to wait in milliseconds for data from Moonlight before shutting " +"down the stream" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:469 +msgid "Packet Size" +msgstr "" + +#: src/big_remote_play/ui/sunshine_preferences.py:473 +msgid "" +"Limit UDP packet size to avoid fragmentation on low-MTU VPN links. Use 0 for" +" Sunshine's default." +msgstr "" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 297 +#: src/big_remote_play/ui/sunshine_preferences.py:479 +msgid "Enable Gamepad Input" +msgstr "Ativar Entrada de Gamepad" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 297 +#: src/big_remote_play/ui/sunshine_preferences.py:479 +msgid "Allows guests to control the host system with a gamepad / controller" +msgstr "" +"Permite que os convidados controlem o sistema do anfitrião com um gamepad / " +"controle." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 298 +#: src/big_remote_play/ui/sunshine_preferences.py:482 +msgid "Emulated Gamepad Type" +msgstr "Tipo de Gamepad Emulado" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 301 +#: src/big_remote_play/ui/sunshine_preferences.py:486 +msgid "Choose which type of gamepad to emulate on the host" +msgstr "Escolha qual tipo de gamepad emular no host." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 302 +#: src/big_remote_play/ui/sunshine_preferences.py:488 +msgid "Motion as DS4" +msgstr "Movimento como DS4" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 302 +#: src/big_remote_play/ui/sunshine_preferences.py:488 +msgid "Emulate motion controls as DS4" +msgstr "Emular controles de movimento como DS4" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 303 +#: src/big_remote_play/ui/sunshine_preferences.py:489 +msgid "Touchpad as DS4" +msgstr "Touchpad como DS4" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 303 +#: src/big_remote_play/ui/sunshine_preferences.py:489 +msgid "Emulate touchpad as DS4" +msgstr "Emular touchpad como DS4" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 304 +#: src/big_remote_play/ui/sunshine_preferences.py:490 +msgid "Back Button as Touchpad Click" +msgstr "Botão Voltar como Clique do Touchpad" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 304 +#: src/big_remote_play/ui/sunshine_preferences.py:490 +msgid "Use Back button for touchpad click on DS4" +msgstr "Use o botão Voltar para clicar no touchpad do DS4." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 305 +#: src/big_remote_play/ui/sunshine_preferences.py:491 +msgid "Randomize MAC (DS5)" +msgstr "Randomizar MAC (DS5)" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 305 +#: src/big_remote_play/ui/sunshine_preferences.py:491 +msgid "Randomize virtual MAC address for DS5" +msgstr "Randomizar endereço MAC virtual para DS5" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 306 +#: src/big_remote_play/ui/sunshine_preferences.py:492 +msgid "Home/Guide Timeout (ms)" +msgstr "Tempo limite (ms) Home/Guia" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 306 +#: src/big_remote_play/ui/sunshine_preferences.py:492 +msgid "Hold Back/Select to emulate Guide button. < 0 to disable." +msgstr "" +"Segure Voltar/Selecionar para emular o botão Guia. < 0 para desativar." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 307 +#: src/big_remote_play/ui/sunshine_preferences.py:493 +msgid "Enable Keyboard Input" +msgstr "Ativar Entrada de Teclado" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 307 +#: src/big_remote_play/ui/sunshine_preferences.py:493 +msgid "Allows guests to control the host system with the keyboard" +msgstr "" +"Permite que os convidados controlem o sistema do anfitrião com o teclado." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 308 +#: src/big_remote_play/ui/sunshine_preferences.py:494 +msgid "Enable Mouse Input" +msgstr "Ativar Entrada do Mouse" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 308 +#: src/big_remote_play/ui/sunshine_preferences.py:494 +msgid "Allows guests to control the host system with the mouse" +msgstr "" +"Permite que os convidados controlem o sistema do anfitrião com o mouse." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 309 +#: src/big_remote_play/ui/sunshine_preferences.py:495 +msgid "Always Send Scancodes" +msgstr "Sempre Enviar Códigos de Varredura" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 309 +#: src/big_remote_play/ui/sunshine_preferences.py:495 +msgid "Always send raw key scancodes" +msgstr "Sempre envie códigos de varredura de tecla brutos" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 310 +#: src/big_remote_play/ui/sunshine_preferences.py:496 +msgid "Map Right Alt to Windows" +msgstr "Map Alt Gr para Windows" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 310 +#: src/big_remote_play/ui/sunshine_preferences.py:496 +msgid "Make Sunshine think the Right Alt key is the Windows key" +msgstr "Faça o Sunshine pensar que a tecla Alt direita é a tecla Windows." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 311 +#: src/big_remote_play/ui/sunshine_preferences.py:497 +msgid "High Resolution Scrolling" +msgstr "Rolagem de Alta Resolução" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 311 +#: src/big_remote_play/ui/sunshine_preferences.py:497 +msgid "Pass through high resolution scroll events from Moonlight clients" +msgstr "Passe eventos de rolagem de alta resolução dos clientes Moonlight." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 365 +#: src/big_remote_play/ui/sunshine_preferences.py:530 +msgid "Auto / Primary" +msgstr "Automático / Primário" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 316 +#: src/big_remote_play/ui/sunshine_preferences.py:545 +msgid "Audio Sink" +msgstr "Receptor de Áudio" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 316 +#: src/big_remote_play/ui/sunshine_preferences.py:545 +msgid "" +"Listing all available audio sinks is possible by running `pactl list short " +"sinks` (PulseAudio) or `wpctl status` (PipeWire)." +msgstr "" +"Listar todos os dispositivos de áudio disponíveis é possível executando " +"`pactl list short sinks` (PulseAudio) ou `wpctl status` (PipeWire)." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 317 +#: src/big_remote_play/ui/sunshine_preferences.py:546 +msgid "Stream Audio" +msgstr "Transmitir Áudio" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 317 +#: src/big_remote_play/ui/sunshine_preferences.py:546 +msgid "" +"Whether to stream audio or not. Disabling this can be useful for streaming " +"headless displays as second monitors." +msgstr "" +"Se deve ou não transmitir áudio. Desativar isso pode ser útil para " +"transmitir displays sem cabeça como monitores secundários." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 318 +#: src/big_remote_play/ui/sunshine_preferences.py:547 +msgid "Graphics Adapter" +msgstr "Adaptador Gráfico" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 383 +#: src/big_remote_play/ui/sunshine_preferences.py:547 +msgid "Specific GPU to use. Default is usually correct." +msgstr "GPU específico a ser utilizado. O padrão geralmente está correto." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 319 +#: src/big_remote_play/ui/sunshine_preferences.py:550 +msgid "Display Name" +msgstr "Nome de Exibição" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 384 +#: src/big_remote_play/ui/sunshine_preferences.py:554 +msgid "" +"Select the display monitor to capture. Corresponds to the output connector " +"name (e.g., DP-1, HDMI-A-1)." +msgstr "" +"Selecione o monitor de exibição para capturar. Corresponde ao nome do " +"conector de saída (por exemplo, DP-1, HDMI-A-1)." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 320 +#: src/big_remote_play/ui/sunshine_preferences.py:558 +msgid "Maximum Bitrate" +msgstr "Taxa de bits máxima" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 320 +#: src/big_remote_play/ui/sunshine_preferences.py:562 +msgid "" +"The maximum bitrate (in Kbps) that Sunshine will encode the stream at. If " +"set to 0, it will always use the bitrate requested by Moonlight." +msgstr "" +"A taxa de bits máxima (em Kbps) que o Sunshine irá codificar o stream. Se " +"definida como 0, sempre usará a taxa de bits solicitada pelo Moonlight." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 321 +#: src/big_remote_play/ui/sunshine_preferences.py:566 +msgid "Minimum FPS Target" +msgstr "Meta de FPS Mínima" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 321 +#: src/big_remote_play/ui/sunshine_preferences.py:570 +msgid "" +"The lowest effective FPS a stream can reach. A value of 0 is treated as " +"roughly half of the stream's FPS. A setting of 20 is recommended if you " +"stream 24 or 30fps content." +msgstr "" +"A menor FPS efetiva que um stream pode alcançar. Um valor de 0 é tratado " +"como aproximadamente metade do FPS do stream. Uma configuração de 20 é " +"recomendada se você transmitir conteúdo a 24 ou 30fps." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 326 +#: src/big_remote_play/ui/sunshine_preferences.py:578 +msgid "FEC Percentage" +msgstr "Porcentagem de FEC" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 326 +#: src/big_remote_play/ui/sunshine_preferences.py:582 +msgid "" +"Percentage of error correcting packets per data packet in each video frame. " +"Higher values can correct for more network packet loss, but at the cost of " +"increasing bandwidth usage." +msgstr "" +"Porcentagem de pacotes de correção de erro por pacote de dados em cada " +"quadro de vídeo. Valores mais altos podem corrigir mais perda de pacotes de " +"rede, mas à custa de aumentar o uso de largura de banda." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 327 +#: src/big_remote_play/ui/sunshine_preferences.py:586 +msgid "Quantization Parameter" +msgstr "Parâmetro de Quantização" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 327 +#: src/big_remote_play/ui/sunshine_preferences.py:590 +msgid "" +"Some devices may not support Constant Bit Rate. For those devices, QP is " +"used instead. Higher value means more compression, but less quality." +msgstr "" +"Alguns dispositivos podem não suportar Taxa de Bits Constante. Para esses " +"dispositivos, QP é usado em vez disso. Um valor mais alto significa mais " +"compressão, mas menos qualidade." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 328 +#: src/big_remote_play/ui/sunshine_preferences.py:594 +msgid "Minimum CPU Thread Count" +msgstr "Contagem Mínima de Threads da CPU" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 328 +#: src/big_remote_play/ui/sunshine_preferences.py:599 +msgid "" +"Increasing the value slightly reduces encoding efficiency, but the tradeoff " +"is usually worth it to gain the use of more CPU cores for encoding. The " +"ideal value is the lowest value that can reliably encode at your desired " +"streaming settings on your hardware." +msgstr "" +"Aumentar o valor reduz ligeiramente a eficiência de codificação, mas a " +"compensação geralmente vale a pena para ganhar o uso de mais núcleos de CPU " +"para codificação. O valor ideal é o menor valor que pode codificar de forma " +"confiável nas configurações de streaming desejadas no seu hardware." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 329 +#: src/big_remote_play/ui/sunshine_preferences.py:604 +msgid "HEVC Support" +msgstr "Suporte HEVC" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 330 +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 333 +#: src/big_remote_play/ui/sunshine_preferences.py:607 +#: src/big_remote_play/ui/sunshine_preferences.py:615 +msgid "Advertised (Recommended)" +msgstr "Anunciado (Recomendado)" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 331 +#: src/big_remote_play/ui/sunshine_preferences.py:608 +msgid "" +"Allows the client to request HEVC Main or HEVC Main10 video streams. HEVC is" +" more CPU-intensive to encode, so enabling this may reduce performance when " +"using software encoding." +msgstr "" +"Permite que o cliente solicite streams de vídeo HEVC Main ou HEVC Main10. " +"HEVC é mais intensivo em CPU para codificar, portanto, habilitar isso pode " +"reduzir o desempenho ao usar codificação de software." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 332 +#: src/big_remote_play/ui/sunshine_preferences.py:612 +msgid "AV1 Support" +msgstr "Suporte AV1" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 334 +#: src/big_remote_play/ui/sunshine_preferences.py:616 +msgid "" +"Allows the client to request AV1 Main 8-bit or 10-bit video streams. AV1 is " +"more CPU-intensive to encode, so enabling this may reduce performance when " +"using software encoding." +msgstr "" +"Permite que o cliente solicite streams de vídeo AV1 Main de 8 bits ou 10 " +"bits. O AV1 é mais intensivo em CPU para codificar, portanto, habilitar isso" +" pode reduzir o desempenho ao usar codificação de software." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 335 +#: src/big_remote_play/ui/sunshine_preferences.py:620 +msgid "Force a Specific Capture Method" +msgstr "Forçar um Método de Captura Específico" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 336 +#: src/big_remote_play/ui/sunshine_preferences.py:623 +msgid "Autodetect (Recommended)" +msgstr "Detecção automática (Recomendado)" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 337 +#: src/big_remote_play/ui/sunshine_preferences.py:624 +msgid "" +"On automatic mode Sunshine will use the first one that works. NvFBC requires" +" patched nvidia drivers." +msgstr "" +"No modo automático, o Sunshine usará o primeiro que funcionar. NvFBC requer " +"drivers nvidia modificados." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 338 +#: src/big_remote_play/ui/sunshine_preferences.py:628 +msgid "Force a Specific Encoder" +msgstr "Forçar um Codificador Específico" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 339 +#: src/big_remote_play/ui/sunshine_preferences.py:631 +msgid "Autodetect" +msgstr "Detecção automática" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 342 +#: src/big_remote_play/ui/sunshine_preferences.py:633 +msgid "" +"Force a specific encoder, otherwise Sunshine will select the best available " +"option. Note: If you specify a hardware encoder on Windows, it must match " +"the GPU where the display is connected." +msgstr "" +"Force um codificador específico, caso contrário, o Sunshine selecionará a " +"melhor opção disponível. Nota: Se você especificar um codificador de " +"hardware no Windows, ele deve corresponder à GPU onde o display está " +"conectado." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 347 +#: src/big_remote_play/ui/sunshine_preferences.py:642 +msgid "Performance Preset" +msgstr "Pré-configuração de Desempenho" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 350 +#: src/big_remote_play/ui/sunshine_preferences.py:647 +msgid "" +"Higher numbers improve compression (quality at given bitrate) at the cost of" +" increased encoding latency. Recommended to change only when limited by " +"network or decoder, otherwise similar effect can be accomplished by " +"increasing bitrate." +msgstr "" +"Números mais altos melhoram a compressão (qualidade em uma determinada taxa " +"de bits) à custa de um aumento na latência de codificação. Recomenda-se " +"alterar apenas quando limitado pela rede ou decodificador; caso contrário, " +"um efeito semelhante pode ser alcançado aumentando a taxa de bits." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 351 +#: src/big_remote_play/ui/sunshine_preferences.py:652 +msgid "Two-Pass Mode" +msgstr "Modo de Dupla Passagem" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 352 +#: src/big_remote_play/ui/sunshine_preferences.py:655 +msgid "Quarter Resolution" +msgstr "Resolução de Quarto" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 352 +#: src/big_remote_play/ui/sunshine_preferences.py:655 +msgid "Full Resolution" +msgstr "Resolução Completa" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 353 +#: src/big_remote_play/ui/sunshine_preferences.py:657 +msgid "" +"Adds preliminary encoding pass. This allows to detect more motion vectors, " +"better distribute bitrate across the frame and more strictly adhere to " +"bitrate limits. Disabling it is not recommended since this can lead to " +"occasional bitrate overshoot and subsequent packet loss." +msgstr "" +"Adiciona uma passagem de codificação preliminar. Isso permite detectar mais " +"vetores de movimento, distribuir melhor a taxa de bits pelo quadro e aderir " +"mais estritamente aos limites de taxa de bits. Desativá-lo não é " +"recomendado, pois isso pode levar a picos ocasionais de taxa de bits e " +"subsequente perda de pacotes." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 354 +#: src/big_remote_play/ui/sunshine_preferences.py:660 +msgid "Spatial AQ" +msgstr "AQ Espacial" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 354 +#: src/big_remote_play/ui/sunshine_preferences.py:660 +msgid "" +"Assign higher QP values to flat regions of the video. Recommended to enable " +"when streaming at lower bitrates." +msgstr "" +"Atribua valores QP mais altos a regiões planas do vídeo. Recomenda-se ativar" +" ao transmitir em taxas de bits mais baixas." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 355 +#: src/big_remote_play/ui/sunshine_preferences.py:663 +msgid "Single-frame VBV/HRD percentage increase" +msgstr "Aumento percentual de VBV/HRD de quadro único" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 355 +#: src/big_remote_play/ui/sunshine_preferences.py:668 +msgid "" +"By default sunshine uses single-frame VBV/HRD, which means any encoded video" +" frame size is not expected to exceed requested bitrate divided by requested" +" frame rate. Relaxing this restriction can be beneficial and act as low-" +"latency variable bitrate, but may also lead to packet loss if the network " +"doesn't have buffer headroom to handle bitrate spikes. Maximum accepted " +"value is 400, which corresponds to 5x increased encoded video frame upper " +"size limit." +msgstr "" +"Por padrão, o sunshine usa VBV/HRD de quadro único, o que significa que " +"qualquer tamanho de quadro de vídeo codificado não deve exceder a taxa de " +"bits solicitada dividida pela taxa de quadros solicitada. Relaxar essa " +"restrição pode ser benéfico e atuar como taxa de bits variável de baixa " +"latência, mas também pode levar à perda de pacotes se a rede não tiver " +"espaço de buffer suficiente para lidar com picos de taxa de bits. O valor " +"máximo aceito é 400, o que corresponde a um limite superior de tamanho de " +"quadro de vídeo codificado aumentado em 5x." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 356 +#: src/big_remote_play/ui/sunshine_preferences.py:673 +msgid "Prefer CAVLC over CABAC in H.264" +msgstr "Prefira CAVLC em vez de CABAC no H.264." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 356 +#: src/big_remote_play/ui/sunshine_preferences.py:677 msgid "" -"Could not pair with host.\n" -"Verify the PIN was entered correctly." +"Simpler form of entropy coding. CAVLC needs around 10% more bitrate for same" +" quality. Only relevant for really old decoding devices." +msgstr "" +"Forma mais simples de codificação de entropia. CAVLC precisa de cerca de 10%" +" a mais de taxa de bits para a mesma qualidade. Apenas relevante para " +"dispositivos de decodificação realmente antigos." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 361 +#: src/big_remote_play/ui/sunshine_preferences.py:683 +msgid "QuickSync Preset" +msgstr "Predefinição QuickSync" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 363 +#: src/big_remote_play/ui/sunshine_preferences.py:683 +msgid "Performance preset" +msgstr "Pré-configuração de desempenho" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 364 +#: src/big_remote_play/ui/sunshine_preferences.py:684 +msgid "QuickSync Coder (H264)" +msgstr "QuickSync Codificador (H264)" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 365 +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 388 +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 395 +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 398 +#: src/big_remote_play/ui/sunshine_preferences.py:684 +#: src/big_remote_play/ui/sunshine_preferences.py:750 +#: src/big_remote_play/ui/sunshine_preferences.py:757 +#: src/big_remote_play/ui/sunshine_preferences.py:763 +msgid "Auto" +msgstr "Automático" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 366 +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 396 +#: src/big_remote_play/ui/sunshine_preferences.py:684 +#: src/big_remote_play/ui/sunshine_preferences.py:757 +msgid "Entropy coding mode" +msgstr "Modo de codificação de entropia" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 367 +#: src/big_remote_play/ui/sunshine_preferences.py:685 +msgid "Allow Slow HEVC Encoding" +msgstr "Permitir Codificação HEVC Lenta" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 367 +#: src/big_remote_play/ui/sunshine_preferences.py:685 +msgid "" +"This can enable HEVC encoding on older Intel GPUs, at the cost of higher GPU" +" usage and worse performance." +msgstr "" +"Isso pode habilitar a codificação HEVC em GPUs Intel mais antigas, com o " +"custo de maior uso da GPU e pior desempenho." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 372 +#: src/big_remote_play/ui/sunshine_preferences.py:692 +msgid "AMF Usage" +msgstr "Uso do AMF" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 376 +#: src/big_remote_play/ui/sunshine_preferences.py:703 +msgid "" +"This sets the base encoding profile. All options presented below will " +"override a subset of the usage profile, but there are additional hidden " +"settings applied that cannot be configured elsewhere." +msgstr "" +"Isso define o perfil de codificação base. Todas as opções apresentadas " +"abaixo substituirão um subconjunto do perfil de uso, mas existem " +"configurações adicionais ocultas aplicadas que não podem ser configuradas em" +" outro lugar." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 377 +#: src/big_remote_play/ui/sunshine_preferences.py:708 +msgid "AMF Rate Control" +msgstr "Controle de Taxa AMF" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 380 +#: src/big_remote_play/ui/sunshine_preferences.py:713 +msgid "" +"This controls the rate control method to ensure we are not exceeding the " +"client bitrate target. 'cqp' is not suitable for bitrate targeting, and " +"other options besides 'vbr_latency' depend on HRD Enforcement to help " +"constrain bitrate overflows." +msgstr "" +"Isto controla o método de controle de taxa para garantir que não estamos " +"excedendo a meta de bitrate do cliente. 'cqp' não é adequado para " +"direcionamento de bitrate, e outras opções além de 'vbr_latency' dependem da" +" Aplicação de HRD para ajudar a restringir transbordamentos de bitrate." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 381 +#: src/big_remote_play/ui/sunshine_preferences.py:718 +msgid "AMF Hypothetical Reference Decoder (HRD) Enforcement" +msgstr "Aplicação de Decodificador de Referência Hipotética AMF (HRD)" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 381 +#: src/big_remote_play/ui/sunshine_preferences.py:723 +msgid "" +"Increases the constraints on rate control to meet HRD model requirements. " +"This greatly reduces bitrate overflows, but may cause encoding artifacts or " +"reduced quality on certain cards." +msgstr "" +"Aumenta as restrições no controle de taxa para atender aos requisitos do " +"modelo HRD. Isso reduz significativamente os transbordamentos de taxa de " +"bits, mas pode causar artefatos de codificação ou qualidade reduzida em " +"certos cartões." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 382 +#: src/big_remote_play/ui/sunshine_preferences.py:728 +msgid "AMF Quality" +msgstr "Qualidade AMF" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 383 +#: src/big_remote_play/ui/sunshine_preferences.py:731 +msgid "Speed" +msgstr "Velocidade" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 383 +#: src/big_remote_play/ui/sunshine_preferences.py:731 +msgid "Balanced" +msgstr "Equilibrado" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 383 +#: src/big_remote_play/ui/sunshine_preferences.py:731 +msgid "Quality" +msgstr "Qualidade" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 384 +#: src/big_remote_play/ui/sunshine_preferences.py:732 +msgid "This controls the balance between encoding speed and quality." +msgstr "" +"Isto controla o equilíbrio entre a velocidade de codificação e a qualidade." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 385 +#: src/big_remote_play/ui/sunshine_preferences.py:734 +msgid "AMF Preanalysis" +msgstr "Pré-análise AMF" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 385 +#: src/big_remote_play/ui/sunshine_preferences.py:734 +msgid "" +"This enables rate-control preanalysis, which may increase quality at the " +"expense of increased encoding latency." +msgstr "" +"Isso permite a pré-análise de controle de taxa, o que pode aumentar a " +"qualidade à custa de um aumento na latência de codificação." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 386 +#: src/big_remote_play/ui/sunshine_preferences.py:737 +msgid "AMF Variance Based Adaptive Quantization (VBAQ)" +msgstr "Quantização Adaptativa Baseada em Variância AMF (VBAQ)" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 386 +#: src/big_remote_play/ui/sunshine_preferences.py:742 +msgid "" +"The human visual system is typically less sensitive to artifacts in highly " +"textured areas. In VBAQ mode, pixel variance is used to indicate the " +"complexity of spatial textures, allowing the encoder to allocate more bits " +"to smoother areas. Enabling this feature leads to improvements in subjective" +" visual quality with some content." +msgstr "" +"O sistema visual humano é tipicamente menos sensível a artefatos em áreas " +"altamente texturizadas. No modo VBAQ, a variância de pixels é usada para " +"indicar a complexidade das texturas espaciais, permitindo que o codificador " +"aloque mais bits para áreas mais suaves. Habilitar este recurso leva a " +"melhorias na qualidade visual subjetiva com alguns conteúdos." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 387 +#: src/big_remote_play/ui/sunshine_preferences.py:747 +msgid "AMF Coder (H264)" +msgstr "Codificador AMF (H264)" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 389 +#: src/big_remote_play/ui/sunshine_preferences.py:751 +msgid "" +"Allows you to select the entropy encoding to prioritize quality or encoding " +"speed. H.264 only." +msgstr "" +"Permite selecionar a codificação de entropia para priorizar qualidade ou " +"velocidade de codificação. Apenas H.264." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 394 +#: src/big_remote_play/ui/sunshine_preferences.py:757 +msgid "VideoToolbox Coder" +msgstr "VideoToolbox Codificador" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 397 +#: src/big_remote_play/ui/sunshine_preferences.py:760 +msgid "VideoToolbox Software Encoding" +msgstr "Codificação de Software VideoToolbox" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 398 +#: src/big_remote_play/ui/sunshine_preferences.py:763 +msgid "Allowed" +msgstr "Permitido" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 398 +#: src/big_remote_play/ui/sunshine_preferences.py:763 +msgid "Forced" +msgstr "Forçado" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 399 +#: src/big_remote_play/ui/sunshine_preferences.py:764 +msgid "Allow fallback to software encoding" +msgstr "Permitir retorno para codificação de software" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 400 +#: src/big_remote_play/ui/sunshine_preferences.py:766 +msgid "VideoToolbox Realtime Encoding" +msgstr "Codificação em Tempo Real do VideoToolbox" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 400 +#: src/big_remote_play/ui/sunshine_preferences.py:766 +msgid "Realtime encoding priority" +msgstr "Prioridade de codificação em tempo real" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 405 +#: src/big_remote_play/ui/sunshine_preferences.py:773 +msgid "Strictly enforce frame bitrate limits for H.264/HEVC on AMD GPUs" +msgstr "" +"Imponha rigorosamente os limites de taxa de bits de quadro para H.264/HEVC " +"em GPUs AMD." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 405 +#: src/big_remote_play/ui/sunshine_preferences.py:777 +msgid "" +"Enabling this option can avoid dropped frames over the network during scene " +"changes, but video quality may be reduced during motion." +msgstr "" +"Ativar esta opção pode evitar quadros perdidos na rede durante as mudanças " +"de cena, mas a qualidade do vídeo pode ser reduzida durante o movimento." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 410 +#: src/big_remote_play/ui/sunshine_preferences.py:785 +msgid "SW Presets" +msgstr "Predefinições de SW" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 413 +#: src/big_remote_play/ui/sunshine_preferences.py:789 +msgid "" +"Optimize the trade-off between encoding speed (encoded frames per second) " +"and compression efficiency (quality per bit in the bitstream). Defaults to " +"superfast." +msgstr "" +"Otimize a relação entre a velocidade de codificação (quadros codificados por" +" segundo) e a eficiência de compressão (qualidade por bit no fluxo de bits)." +" O padrão é superrápido." + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 414 +#: src/big_remote_play/ui/sunshine_preferences.py:793 +msgid "SW Tune" +msgstr "Ajuste de SW" + +# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, +# line: 416 +#: src/big_remote_play/ui/sunshine_preferences.py:797 +msgid "" +"Tuning options, which are applied after the preset. Defaults to zerolatency." +msgstr "" +"Opções de ajuste, que são aplicadas após o predefinido. O padrão é " +"zerolatência." + +# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# +# +# File: big-remote-play/usr/share/big-remote-play/utils/system_check.py, line: +# 124 +# File: big-remote-play/usr/share/big-remote-play/utils/system_check.py, line: +# 127 +# File: big-remote-play/usr/share/big-remote-play/utils/system_check.py, line: +# 144 +# File: big-remote-play/usr/share/big-remote-play/utils/system_check.py, line: +# 147 +# File: big-remote-play/usr/share/big-remote-play/utils/audio.py, line: 215 +# File: big-remote-play/usr/share/big-remote-play/utils/audio.py, line: 226 +#: src/big_remote_play/utils/audio.py:273 +#: src/big_remote_play/utils/audio.py:284 +#: src/big_remote_play/utils/system_check.py:130 +#: src/big_remote_play/utils/system_check.py:133 +#: src/big_remote_play/utils/system_check.py:158 +#: src/big_remote_play/utils/system_check.py:161 +msgid "Unknown" +msgstr "Desconhecido" + +# File: big-remote-play/usr/share/big-remote-play/utils/network.py, line: 98 +#: src/big_remote_play/utils/network.py:97 +msgid " (IPv6 Local)" +msgstr "(IPv6 Local)" + +# File: big-remote-play/usr/share/big-remote-play/utils/network.py, line: 100 +#: src/big_remote_play/utils/network.py:99 +msgid " (IPv6 Global)" +msgstr "(IPv6 Global)" + +# File: big-remote-play/usr/share/big-remote-play/utils/network.py, line: 156 +#: src/big_remote_play/utils/network.py:148 +#, python-brace-format +msgid "Host ({})" +msgstr "Host ({})" + +#: src/big_remote_play/utils/system_check.py:143 +msgid "Sunshine runtime is available" +msgstr "" + +#: src/big_remote_play/utils/system_check.py:144 +msgid "Sunshine failed to report its version" +msgstr "" + +#: src/big_remote_play/utils/widgets.py:37 +msgid "How it works" +msgstr "Como funciona" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:56 +msgid "HEADSCALE & CLOUDFLARE - PRIVATE NETWORK" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:61 +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:426 +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:56 +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:46 +msgid "Checking dependencies..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:64 +msgid "Installing" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:65 +msgid "Failed to install" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:65 +msgid "Install it manually." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:72 +msgid "Checking open ports..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:76 +msgid "Docker is not running. Starting..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:82 +msgid "Local ports in use:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:86 +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:537 +msgid "Configuring firewall..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:94 +msgid "UFW firewall configured" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:103 +msgid "Firewalld configured" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:110 +msgid "HOST (SERVER) SETUP" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:113 +msgid "Domain (e.g. vpn.ruscher.org): " +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:129 +msgid "Generating reverse proxy config (Caddy)..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:269 +msgid "Configuring Headscale..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:283 +msgid "Validating Headscale configuration..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:285 +msgid "" +"Invalid Headscale configuration; aborting before changing DNS or starting " +"services." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:315 +msgid "New DNS record created" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:319 +msgid "Starting containers..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:324 +msgid "Waiting for services to start..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:329 +msgid "Configuring router ports via UPnP..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:341 +msgid "Creating user and keys..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:350 +msgid "Creating new user..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:359 +msgid "Testing services..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:363 +msgid "Headscale is working" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:365 +msgid "Headscale is not responding" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:371 +msgid "Caddy is working" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:373 +msgid "Caddy is not responding" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:388 +msgid "SERVER CONFIGURED SUCCESSFULLY!" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:390 +msgid "ACCESS INFORMATION" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:391 +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:154 +msgid "Web Interface:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:392 +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:155 +msgid "API URL:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:393 +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:156 +msgid "Your Public IP:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:394 +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:157 +msgid "Server Local IP:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:396 +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:159 +msgid "CREDENTIALS" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:397 +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:160 +msgid "Key for Friends:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:399 +msgid "USEFUL COMMANDS" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:400 +msgid "View logs: " +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:401 +msgid "Restart: " +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:404 +msgid "SHARE ONLY THE KEY FOR FRIENDS" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:409 +msgid "Show real-time logs? (y/N): " +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:420 +msgid "CLIENT (GUEST) SETUP" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:423 +msgid "Server domain (e.g. vpn.ruscher.org): " +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:424 +msgid "Access Key (Auth Key): " +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:429 +msgid "Testing connection to the server..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:431 +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:433 +msgid "Server reachable" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:435 +msgid "Could not connect to the server" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:436 +msgid "Check:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:437 +msgid "1. Is the domain correct?" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:438 +msgid "2. Is the server online?" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:439 +msgid "3. Is the Auth Key valid?" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:448 +msgid "Installing Tailscale..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:452 +msgid "Tailscale already installed" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:460 +msgid "Connecting to the private network..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:474 +msgid "Connection established!" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:477 +msgid "Trying alternative method..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:488 +msgid "Waiting for connection..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:492 +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:187 +msgid "CONNECTION STATUS" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:501 +msgid "Your network IP: " +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:508 +msgid "CONNECTED DEVICES" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:513 +msgid "No other device connected yet. You are the first!" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:514 +msgid "Invite friends using the same Access Key." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:520 +msgid "TROUBLESHOOTING" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:521 +msgid "1. Check that the server is online" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:522 +msgid "2. Check the Auth Key" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:523 +msgid "3. Try restarting:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:524 +msgid "4. Check logs:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:528 +msgid "View Tailscale logs? (y/N): " +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:543 +msgid "Client setup complete!" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:551 +msgid "ADVANCED TROUBLESHOOTING" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:552 +msgid "1) Check server status" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:553 +msgid "2) Check server logs" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:554 +msgid "3) Test external connection" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:555 +msgid "4) Recreate access keys" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:556 +msgid "5) Back to main menu" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:557 +msgid "Choose an option: " +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:566 +msgid "Server directory not found" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:572 +msgid "HEADSCALE LOGS" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:574 +msgid "CADDY LOGS" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:579 +msgid "Domain to test: " +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:580 +msgid "Testing" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:588 +msgid "Recreating keys..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:590 +msgid "Create a new key? (y/N): " +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:594 +msgid "New key: " +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:600 +msgid "Press Enter to continue..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:608 +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:205 +msgid "Select an option:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:609 +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:206 +msgid "1) Be the HOST (create and manage the network)" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:610 +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:207 +msgid "2) Be the GUEST (join a friend network)" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:611 +msgid "3) Troubleshooting" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:612 +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:209 +msgid "4) Exit" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:613 +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:210 +msgid "Option: " +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:60 +msgid "Tailscale not found. Installing..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:64 +msgid "Installing yay (AUR helper)..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:82 +msgid "jq not found. Installing..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:88 +msgid "curl not found. Installing..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:99 +msgid "TAILSCALE LOGIN" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:102 +msgid "Checking tailscaled service..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:105 +msgid "tailscaled service is not running. Starting..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:110 +msgid "Failed to start tailscaled. Check manually:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:119 +msgid "tailscaled service is running" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:122 +msgid "Choose the login method:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:123 +msgid "1) Browser login (recommended)" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:124 +msgid "2) Login with auth key" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:125 +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:461 +msgid "3) Back" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:132 +msgid "Starting browser login..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:133 +msgid "A URL will open in your browser. Log in with your account." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:136 +msgid "Login URL generated. Follow the instructions in the browser." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:141 +msgid "Waiting for authentication..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:145 +msgid "Login confirmed!" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:153 +msgid "Enter your auth key:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:154 +msgid "Expected format:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:158 +msgid "Authenticating with key..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:162 +msgid "Service not responding. Reconnecting..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:171 +msgid "First attempt failed. Trying alternative method..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:178 +msgid "Restarting service and trying again..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:188 +msgid "Login successful!" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:190 +msgid "Login error. Diagnostics:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:191 +msgid "1. Check that the key is valid (not expired)" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:192 +msgid "2. Check connectivity:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:193 +msgid "3. Check the service:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:195 +msgid "Alternative manual command:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:199 +msgid "Key cannot be empty!" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:207 +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:487 +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:621 +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:733 +msgid "Invalid option!" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:212 +msgid "Checking connection..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:223 +msgid "Waiting for connection, attempt" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:229 +msgid "Logged in and connection established successfully!" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:231 +msgid "Device information:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:241 +msgid "Name: " +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:250 +msgid "Devices on the network:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:258 +msgid "Connection failed. Full diagnostics:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:260 +msgid "1. Service status:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:266 +msgid "3. Connectivity:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:279 +msgid "After running the commands above, try logging in again." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:285 +msgid "CREATE NEW TAILSCALE NETWORK" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:286 +msgid "Note: in Tailscale, networks are different accounts/orgs." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:287 +msgid "You need a different account for each network." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:289 +msgid "Network/company name:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:293 +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:398 +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:93 +msgid "Name cannot be empty!" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:297 +msgid "To create a new network:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:299 +msgid "2. Create a new account with a different email" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:300 +msgid "3. Or use a different domain to create a separate org" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:302 +msgid "Log out and log in with a new account? (y/N):" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:313 +msgid "TAILSCALE NETWORK STATUS" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:316 +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:339 +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:426 +msgid "Not connected to Tailscale. Log in first." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:320 +msgid "Current status:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:324 +msgid "IP addresses:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:329 +msgid "Detailed information:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:330 +msgid "Could not get details" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:336 +msgid "DEVICES ON THE NETWORK" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:360 +msgid "Only this device connected to the network" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:368 +msgid "ADD NEW DEVICE" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:370 +msgid "Method 1: invite URL" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:375 +msgid "Method 2: auth key" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:377 +msgid "2. Generate an auth key" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:380 +msgid "Method 3: login with the same account" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:381 +msgid "Just log in with the same account on the new device" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:383 +msgid "Press ENTER to return to the main menu" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:389 +msgid "REMOVE DEVICE" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:390 +msgid "WARNING: This will remove the device from the network!" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:394 +msgid "Enter the device name to remove:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:406 +msgid "To remove via API, you need:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:408 +msgid "Find the device" msgstr "" -"Não foi possível emparelhar com o host. \n" -"Verifique se o PIN foi inserido corretamente." -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 663 -msgid "Canceling connection..." -msgstr "Cancelando conexão..." -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 671 -msgid "Pairing Required" -msgstr "Emparelhamento Necessário" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 671 -msgid "" -"Moonlight needs to be paired.\n" -"\n" -"{}" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:423 +msgid "AUTHORIZE DEVICE" msgstr "" -"A luz da lua precisa ser emparelhada.\n" -"\n" -"{}" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 678 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 682 -msgid "" -"Follow instructions.\n" -"\n" -"1. Provide PIN and Host {} to the server.\n" -"2. On the host, access Sunshine Configuration.\n" -"3. Enter PIN and Host.\n" -"4. Click Send." + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:430 +msgid "Checking pending devices..." msgstr "" -"1. Forneça o PIN e o Host {} ao servidor.\n" -"2. No host, acesse a Configuração do Sunshine.\n" -"3. Insira o PIN e o Host.\n" -"4. Clique em Enviar." -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 684 -msgid "Pairing Started" -msgstr "Emparelhamento Iniciado" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 719 -msgid "Invalid PIN" -msgstr "PIN inválido" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 719 -msgid "Must contain 6 digits." -msgstr "Deve conter 6 dígitos." -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 740 -msgid "Host found: {}" -msgstr "Host encontrado: {}" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 745 -msgid "Host Not Found" -msgstr "Host Não Encontrado" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 745 -msgid "Could not find a host with this PIN on the local network..." -msgstr "Não foi possível encontrar um host com este PIN na rede local..." -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 757 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 767 -msgid "Set: {}" -msgstr "Conjunto: {}" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 759 -msgid "Custom Resolution" -msgstr "Resolução Personalizada" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 759 -msgid "Enter WxH:" -msgstr "Digite WxH:" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 768 -msgid "Custom FPS" -msgstr "FPS Personalizado" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 768 -msgid "Use a number" -msgstr "Use um número" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 780 -msgid "Value" -msgstr "Valor" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 785 -msgid "Apply" -msgstr "Aplicar" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 813 -msgid "Reset defaults?" -msgstr "Redefinir padrões?" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 813 -msgid "All client settings will be restored." -msgstr "Todas as configurações do cliente serão restauradas." -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 815 -msgid "No" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:439 +msgid "No pending device found" msgstr "" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 913 -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 913 -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 816 -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 816 -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 816 -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 816 -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 825 -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 831 -msgid "Restored" -msgstr "Restaurado" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 968 -msgid "Keyboard Shortcuts" -msgstr "Atalhos de Teclado" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 969 -msgid "Common shortcuts used during streaming" -msgstr "Atalhos comuns usados durante a transmissão" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 972 -msgid "Quit Stream" -msgstr "Sair do Stream" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 973 -msgid "Toggle Mouse Capture" -msgstr "Alternar Captura do Mouse" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 974 -msgid "Toggle Stats Overlay" -msgstr "Alternar Sobreposição de Estatísticas" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 975 -msgid "Toggle Mouse Mode (Remote/Absolute)" -msgstr "Alternar Modo do Mouse (Remoto/Absoluto)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 976 -msgid "Switch Monitor (Multi-Head Host)" -msgstr "Alternar Monitor (Host Multi-Cabeça)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 977 -msgid "Toggle Fullscreen" -msgstr "Alternar Tela Cheia" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 992 -msgid "Multi-Monitor Support" -msgstr "Suporte a Múltiplos Monitores" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 993 -msgid "Instructions for hosts with multiple displays" -msgstr "Instruções para anfitriões com múltiplos monitores" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 997 -msgid "Switching Monitors" -msgstr "Alternando Monitores" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 999 -msgid "" -"If the Host PC has multiple monitors, you can switch between them easily.\n" -"\n" -"1. Use Ctrl+Alt+Shift + F1 (Screen 1), F2 (Screen 2), etc.\n" -"2. If this shortcut doesn't work, ensure 'Grab Input' is active (Ctrl+Alt+Shift + Z).\n" -"3. Why did F1/F2 stop working? The system likely updated and now requires the full shortcut combo " -"to avoid conflicts." + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:442 +msgid "Could not check pending devices (jq not installed)" msgstr "" -"Se o PC Host tiver vários monitores, você pode alternar entre eles facilmente.\n" -"\n" -"1. Use Ctrl+Alt+Shift + F1 (Tela 1), F2 (Tela 2), etc.\n" -"2. Se este atalho não funcionar, certifique-se de que 'Capturar Entrada' está ativo (Ctrl+Alt+Shift " -"+ Z).\n" -"3. Por que F1/F2 pararam de funcionar? O sistema provavelmente foi atualizado e agora requer a " -"combinação completa de atalhos para evitar conflitos." -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 1007 -msgid "Troubleshooting: Shortcuts Not Working?" -msgstr "Solução de Problemas: Atalhos Não Funcionando?" -# -# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 1009 -msgid "" -"If shortcuts like F1/F2/F3 are not switching screens:\n" -"• Press Ctrl+Alt+Shift + Z to toggle Mouse/Keyboard Capture.\n" -"• When capture is ON, your F-keys are sent to the remote PC.\n" -"• When capture is OFF, your local PC intercepts them." + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:448 +msgid "Look for devices with status Pending" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:451 +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:492 +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:626 +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:670 +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:738 +msgid "Press ENTER to continue" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:457 +msgid "SHARE NETWORK" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:458 +msgid "Sharing options:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:459 +msgid "1) Share with a specific user" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:467 +msgid "Enter the user email:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:474 +msgid "3. Enter the email and select permissions" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:480 +msgid "2. Configure the desired options" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:501 +msgid "To configure ACLs:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:505 +msgid "Basic example:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:514 +msgid "Open the ACL panel in the browser? (y/N):" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:519 +msgid "Could not open the browser. Open manually:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:525 +msgid "LOG OUT OF TAILSCALE" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:526 +msgid "Do you really want to log out of Tailscale? (y/N):" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:531 +msgid "Logout successful!" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:533 +msgid "Operation cancelled." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:539 +msgid "DETAILED TAILSCALE STATUS" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:541 +msgid "Service status:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:545 +msgid "Version:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:550 +msgid "Connected to Tailscale" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:552 +msgid "Node information:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:560 +msgid "Statistics:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:567 +msgid "No specific tailscale route" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:575 +msgid "CHANGE SETTINGS" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:576 +msgid "Configuration options:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:579 +msgid "3) Change device name" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:580 +msgid "4) Configure DNS server" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:581 +msgid "5) Back" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:587 +msgid "Enter subnets to route (e.g. 192.168.1.0/24):" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:604 +msgid "New name for the device:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:608 +msgid "Name changed to" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:612 +msgid "Enter DNS servers (comma-separated):" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:632 +msgid "SETTINGS BACKUP" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:639 +msgid "Could not create temporary backup directory." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:645 +msgid "Script settings saved" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:651 +msgid "Tailscale settings saved" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:659 +msgid "Backup created:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:662 +msgid "Checking backup integrity..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:664 +msgid "Backup verified" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:666 +msgid "Backup corrupted" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:684 +msgid "Create new network/account" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:685 +msgid "View network status" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:686 +msgid "List devices" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:687 +msgid "Add new device (instructions)" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:689 +msgid "Authorize pending device" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:690 +msgid "Share network" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:692 +msgid "Change settings" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:693 +msgid "Detailed status" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:694 +msgid "Logout/Exit" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:695 +msgid "Backup settings" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:696 +msgid "Exit the program" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:698 +msgid "Choose an option:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:729 +msgid "Exiting..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:40 +msgid "ZEROTIER - VIRTUAL PRIVATE NETWORK" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:49 +msgid "Installing ZeroTier..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:52 +msgid "ZeroTier already installed" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:63 +msgid "Starting ZeroTier service..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:68 +msgid "ZeroTier daemon active" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:76 +msgid "Paste your API Token here: " +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:79 +msgid "Token cannot be empty!" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:86 +msgid "CREATE NEW ZEROTIER NETWORK" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:91 +msgid "Network name: " +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:97 +msgid "Description (optional): " +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:100 +msgid "Creating network via API..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:110 +msgid "Error creating network. Check your token." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:117 +msgid "Network created! ID:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:120 +msgid "Joining the network..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:128 +msgid "Authorizing local device..." +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:134 +msgid "Device authorized!" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:153 +msgid "NETWORK INFORMATION" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:163 +msgid "Share the network ID with your friends:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:164 +msgid "They use the Connect option and enter the network ID" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:170 +msgid "JOIN ZEROTIER NETWORK (Client/Guest)" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:175 +msgid "ZeroTier Network ID (16 characters): " +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:177 +msgid "Network ID cannot be empty!" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:181 +msgid "Joining network:" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:192 +msgid "Your Node ID: " +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:193 +msgid "You must be authorized by the network administrator!" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:194 +msgid "Give your Node ID to the administrator: " +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:197 +msgid "Join request sent!" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:198 +msgid "Wait for the administrator to authorize you" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:208 +msgid "3) View network status" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:216 +msgid "ZEROTIER STATUS" +msgstr "" + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:217 +msgid "ZeroTier not connected" msgstr "" -"Se atalhos como F1/F2/F3 não estão trocando de tela:\n" -"• Pressione Ctrl+Alt+Shift + Z para alternar a Captura de Mouse/Teclado.\n" -"• Quando a captura está ATIVADA, suas teclas F são enviadas para o PC remoto.\n" -"• Quando a captura está DESATIVADA, seu PC local as intercepta." -# -# File: big-remote-play/usr/share/big-remote-play/host/sunshine_manager.py, line: 86 -msgid "Sunshine failed to start (Exit code {}).\n" -msgstr "Sunshine falhou ao iniciar (Código de saída {})." -# -# File: big-remote-play/usr/share/big-remote-play/host/sunshine_manager.py, line: 88 -msgid "Sunshine failed to start: {}" -msgstr "O Sunshine falhou ao iniciar: {}" -# -# File: big-remote-play/usr/share/big-remote-play/host/sunshine_manager.py, line: 90 -msgid "Sunshine failed to start (Exit code {}). Check logs." -msgstr "O Sunshine falhou ao iniciar (Código de saída {}). Verifique os logs." -# -# File: big-remote-play/usr/share/big-remote-play/host/sunshine_manager.py, line: 105 -msgid "Sunshine started (PID: {})" -msgstr "Sunshine iniciado (PID: {})" -# -# File: big-remote-play/usr/share/big-remote-play/host/sunshine_manager.py, line: 109 -msgid "Error starting Sunshine: {}" -msgstr "Erro ao iniciar Sunshine: {}" -# -# File: big-remote-play/usr/share/big-remote-play/host/sunshine_manager.py, line: 115 -msgid "Sunshine is not running" -msgstr "Sunshine não está em execução." -# -# File: big-remote-play/usr/share/big-remote-play/host/sunshine_manager.py, line: 312 -msgid "Authentication Failed. Configure a user in Sunshine." -msgstr "Autenticação Falhou. Configure um usuário no Sunshine." -# -# File: big-remote-play/usr/share/big-remote-play/host/sunshine_manager.py, line: 313 -# File: big-remote-play/usr/share/big-remote-play/host/sunshine_manager.py, line: 349 -msgid "API Error: {} - {}" -msgstr "Erro de API: {} - {}" -# -# File: big-remote-play/usr/share/big-remote-play/host/sunshine_manager.py, line: 315 -# File: big-remote-play/usr/share/big-remote-play/host/sunshine_manager.py, line: 351 -msgid "Connection Error: {}" -msgstr "Erro de Conexão: {}" -# -# File: big-remote-play/usr/share/big-remote-play/host/sunshine_manager.py, line: 344 -msgid "User created successfully" -msgstr "Usuário criado com sucesso" diff --git a/src/big_remote_play/ui/preferences.py b/src/big_remote_play/ui/preferences.py index 92c1da6..a87ceb7 100644 --- a/src/big_remote_play/ui/preferences.py +++ b/src/big_remote_play/ui/preferences.py @@ -37,7 +37,7 @@ def setup_ui(self): """Configures interface""" # General page general_page = Adw.PreferencesPage() - general_page.set_title(_('Geral')) + general_page.set_title(_('General')) general_page.set_icon_name('preferences-system-symbolic') # Appearance group @@ -46,13 +46,13 @@ def setup_ui(self): # Theme theme_row = Adw.ComboRow() - theme_row.set_title(_('Tema')) - theme_row.set_subtitle(_('Escolha o esquema de cores')) + theme_row.set_title(_('Theme')) + theme_row.set_subtitle(_('Choose the color scheme')) theme_model = Gtk.StringList() theme_model.append(_('Automatic')) - theme_model.append(_('Claro')) - theme_model.append(_('Escuro')) + theme_model.append(_('Light')) + theme_model.append(_('Dark')) theme_row.set_model(theme_model) theme_row.set_model(theme_model) @@ -71,7 +71,7 @@ def setup_ui(self): # Restore group restore_group = Adw.PreferencesGroup() - restore_group.set_title(_('Restaurar')) + restore_group.set_title(_('Restore')) # Restore Defaults restore_row = Adw.ActionRow() @@ -86,7 +86,7 @@ def setup_ui(self): # Clear All clear_all_row = Adw.ActionRow() - clear_all_row.set_title(_('Limpar Tudo')) + clear_all_row.set_title(_('Clear Everything')) clear_all_row.set_subtitle(_('Remove logs, settings, servers and saved data')) clear_all_btn = Gtk.Button(label=_('Limpar Tudo')) @@ -128,13 +128,13 @@ def setup_ui(self): logs_group.set_title(_('Logs and Debugging')) verbose_row = Adw.SwitchRow() - verbose_row.set_title(_('Logs Detalhados')) + verbose_row.set_title(_('Detailed Logs')) verbose_row.set_subtitle(_('Enable verbose logging for debugging')) verbose_row.set_active(False) logs_group.add(verbose_row) clear_logs_row = Adw.ActionRow() - clear_logs_row.set_title(_('Limpar Logs')) + clear_logs_row.set_title(_('Clear Logs')) clear_logs_row.set_subtitle(_('Remove old log files')) clear_btn = Gtk.Button(label=_('Limpar')) diff --git a/usr/share/locale/pt/LC_MESSAGES/big-remote-play-together.mo b/usr/share/locale/pt/LC_MESSAGES/big-remote-play-together.mo index 879aab5902a5fa27702b092bcd6082e9678e854c..fdc5cce0613858c5f3110ee6cd5742a5414ef2e0 100644 GIT binary patch literal 74787 zcmd443w&Hv)%SnOogx;H`}LGt5^d58ZGp%wP0}{dOK8%TiwKisk_=5|!px*;P()A= z1Vt1TML`ht0Yybb5V@!o5m6CQ5XBpU`iKG|h$24zzrVHiJ~Juse4hXNzMuE=PJ8k@ z`*QZ#d+oK?UTf`r&iUm|TYNC#H*NDI*&94?_axbRizJyfN3lt=^q3@hE!YKa51s&S zJ}pTG!7lLhwj{Zn_%AO?k_x!IJxNBuZ-9G%Egea+8@LqQ6+8_*7@PnP0z?of@RBP;B@dfQ0-X*s@(&i>bC(D zy)F##mw=+rC&6vOTfs)~n;QOMsORj8C3r)1jR31pz3jUNWTnJxgQ6`FE@d@{tw`5!9Rm4|0PiL-g>3i zKLthS*MrKpD1?s(>AIu(v5^&)Yzia{{RJp@3uHYY0z* zYWMj8F9+{|Pp%K?= zsQj&<=+gu44;Db>yBItKycSfxpMomyFW?T~OCh{{FJpu7?%+<~T2R-Y4$c721=Y{* z2i4BY!JWZRfy#dyD8BtZcrf@AQ2Aa0w+FXf?d9zTD*tRya%mALK06iEI4y+qcYu2m z{t$Qwcq6Ftp9uJ8Q1yBq)cD(SjhEK|>bef_b>OL>`lAxk&jTeFt^wZ&-Wk$g1m_Zd z&04STQK0Cv3=|zs0d>D|Q0+M{gg*ePJ)Z>Ce_sK0zq`RBz=uK6d*>6pKKp^ncL=EV zEDCrmD1KT6?h2j^s(xkgXz=skZs1d3Gq}}>9-a>>|1xlAus_6a0GkNE4^%m~fojj+ zK=IwyCwaYI8*mR$^*#{PxLOF#0^bNK|1hX_mja#@;x7Vq|M!Ec*L9%y_F+)%`z@$; zJqfBmo&&`n+nntDF&o^A@Dbqd;0d7Wc?PKK&IR`bFADLW2E|vm1pF4*NcaIz^?MOi zeYbg&kLMYn@;8HfgRLQcEqEy5aZv5O0aQPJ2~_>>2KNFV0B3`L2CoA5IVDN<1@8dW zACH2&fX{)t?{tK)`e8?K33xEL2UrA^?*dTwxia95;C_U^1&WU!2gUDOo$7L_6?`Y* zQ$g|3iB;4StzAu7m_jW_xF9(21Zwj~&)OctIRljAR=>29;bf|#h zvr7VA2OdLsBlsk^#jwkxXF%1zx#0Dk2kO2@fQs)8@h5`1e?O@Eje%;{xgq^hQ0=)Q z#9s%B-nWMM2S8o_D=-EB1a1ayf4X0{12~iLzM%Nx7*PDQ3|s{kK-K$p@c3y-@*nG* zFJBySy6-ZYB%dI?5fokS1?Pi50&fAgE~5K#-Cdx{|3=B_`h8ICcn}nyKL$<*{{?Oh zZZqa|-2qg-gTUF~d~gSF02CjMg9m^g1@{N<1h)l$1FBt5g4=`JpW$@a3v4Fb2r7OJ zI0GCA=~YnmJO|tdd=I!E_}LJ?4^(~r6I8yxgKGB{WiNkwQ0aSus$Ubh6Sx8tf8;== zp9QL(7lUf=hd|ZiN^nc?^WawCmqFdXaR9iG@FAemPX|^0J3woH2ww@RoErn)3hqewE-(cj0L7orf;)gS)dcFzVoNyTw->(OCpR+^yg`nDX6{z-n9#ns91l8^bK-Kdx zunl|`R5|luZr%S#@Qq*}sBv&5sPU{yI@~;T- zp90m68$k8no#2t+J>W6mmgoBQi^07JuK*7PPX~3MYeM=Bp!(rvP<(S6sD8W`RDV4N z>iTCu)$=v)a6NNhP~&U~sOx$`rT2p~!EsP@|1hX}ejXH^?gDlH`@jnL5UBPY`%ZKw z@Dxz=xf@iyehcclKY-$+=R$nTc|QIQ0S_d;71aGs1y$a9Q1!nMR6l(fR6V~C@XmnW z2OEih6cl~8Jm1Ti0X7gm7;FPqfa;I;fokXFpzd=$sQTRjs-5?OTY^6Wo4{X#y6^ON zxjnxJsC+9y(fKqm1t-B)@T1_t;7>rBn!E(6e&<}^{d_(69>O<)D!1)Ir)M`fhw#au z?tgK>_k+6M<)HfOI#B%il@R|lsP-i9c7ERpRC^8v)lc)lt-vEe(Xj)R9C#C`@mB^# zhjYQX;0M83;60%D{y9+BZF`ZIGZWl`a3iSeo58KYBSDqZ2_6NW0II$p235anL;6?1 zV+h|1ZU;`k*vI3ppzbpll>F!d)&Kng&j3Y-vq6>jz7T&6sCs`9Yyuwx=Yy|-$cVT;!pDIhgYbQz`2OD?gvQ|EAM*L+c5oiyXTU?j13v8j%9Fts!k@YvoeBIgsCK>m zBi6;q^O><6zS{1NaRu>A_>pS!^V;rl?zl?5N8?qCJ{960oGp9gln()-~a zutfZW;6||hDyQ?-pYV3=0UkhnO9-z5XAm9&8^8&0TktAS{c|0-4E!3nJ-E%)exF@I z@z4IC_@)6=xwAphw+B>v2SL&Eec(3Wr@-C7&x5Z4zYVHi9|Xl0&w%@b)2{LI4g}Rt zOF`X#EvRzxpyW*v)VTOSz>k9J$E(53!COQ4c2MK-K~VL51XOvy0mZ*hhwwi^)nnUF zdj0kU)t&}$U$8aciJ<7T0Xzac7u0?40#)CKK+RiEfa2qSfVyAHwO-#NK!w{u(c^ef z_bq_?gJ*(AfgcCQz(+yJ_7_OAom30Fatb1SI(ejC&{`UxmHZT)Gl{|=z!#Q~tk z<>8>}+XIRorw1&9TM#}I6hE8|s=UvD>ZeCQ@x|W)Zu1!r?+L2>S)ll75%_v=6{vch z1&R+Y29@t>a5{JkI1~IPxc4;t3830D?K-Fb3~&zddxMkU^6T+!k$w+@Y7$(>Ab1b> z1ek)A8(bb-2JS`p3t$s?A2FUvzrU z1z$^e1*rR<0;;@q;K|?wsQNw#9ti#sTm+qF-01CI^(E)iw}LA7d{Fnh8ax=h85I9L z3LXkR2a5k@-Q@kU5}Zx=3~*cUYOo*tEVu@o@nv)da1dMy-U$8}d=dNsc>m4lAmEi> z@$tO#EiNw)14X}epy<0ER68yJcLJ{h)h}NHcLr|*#eesM%J(NQ1vkId?ehb{GYLNKoa!71Vt%0B3?%g1duX2h~rHfTHhjLEZnUkiO*|PVareLpS3dpxQfpm(zIy zl-#%&)O8;OMW4@sdx3X>y8q8X-RJkUqG|y}T|^`A+~t<^m`ao{>Q-Af&T)PZ^wExxESmQC9kdrMbBG6mGcm&c02*9T~C3c!{0&OZ}V>>U%)-V z6TwqJ(eD;;KKNr$?cDYrumAp_+PeT0{g#8`>l}Cl_3 z><4Z|_;7Fmcnr8TxE>Te-Uh0Dmw?-VSAlBRe}JOjJ)rLY0;qOvcdxf=7f^Ic!4_}< zD7p-Q>gO{+)$bxu_qhh#7Q6{myYB#1&!2(nuRjI+H#m>*PT%$NI>BuSuL2vulR@Qw z2dMge2z)*GZEywn7f|gz=03mw5>WJcGpPHHfTF{N;0@qi;KAVN_ndy0fuj2jpvt)$ zRC~S$s{G%D@PhAq{7O*y3*bKBIpBWaRp5c(U7-5+aZvT&;|JITU>mp{_z0+R^90xh zZhgP&tH*%I<(9*==rfX{=X`-`CZamNR|UUNW=rxl>p z7gYVu0Y#TjgDJQX6uln@)xUoRMW4-noDL3yJAoB&NAP^`DDZMn^mrH) z{hkL^-i!xbz8nbdK)4lDeUAfGzdrCt@GS6B@M~ZbxctXHzfOR^C;U-R@#p@;>-#xS z?Y$XPIX?z>0iOn62X6V0$L|5E9`ixfzY7%Idcj@66G7!4178Ea6I8n{1&;$i32q5K z4ekU!4;~F}^RTyfDX93t5S|3LCH!G_GattXgDP(!sP?S_b^lX9-G2z&9ef)oI(!%uAASzp z4ZIap`+o?k9*={{x7p8q9PI|G{DZ*Fz&7x;U4d7FRe-E~B-9x{2`fd4`=i3R~lk`17wQm6^ z`Yr=S-xEW8AE@=t%<>9RZcdcsG6x=#UA{!2k!e>JFnxCvDGcY)&H?}4+x$3XGN zj3<2D?+>bsQNYk&iUkxpu&s6L%?28 z;2Xiy!Nb5$gKF=uK=s?R;1=M&!TrE3p7il@ zAgK6bK((V2oDQA@>VElvZwJ-ySAZ(-#t?resQizBqSxb~#^I)$?PZ?t3HH3f>NC{Ja2)F0&b& znm<;6%D)br3%(s}2EPP;75p{W0Dkmo=a+ASn-l&yxE=VbfKP&=^9!KrKjRrM_aIR1 zYz0NH<3Z&c0Xx8Vf~x<$U?X@xxB&b+D86Xvel>)@3#z=I zf}-OS;9PLC=X^ZR2StaYK=o^H2#S1s?##r@sbw2KRp6CfXcS8K@{_gkR7d(RaBf(w3GeObseW3XG z3F$uu)sMdk>Hh@h5Pt1HoZpWQcswY2^@E3i8$jLXbD-M!O>hSI5GXqT z4txvvJh%sV#*6M>xd_zw*z%t~?hgc&zX_E5It)~OH-O^Lv%%Mc7l0bqH-Ree0Z{dM z5)?lrFL`|r09B6#pxWCB9toZRiqAd-s+?=V`QSIf72pe?=(YG?&Idi9>RAGJ2j2_s z1AYcnzPrH%;EzDHf1Ceu{cdki{dsc0GAKU35L^x32)2S-|C_l2Tm-g&+a}Yjy@!Ec zA>0P4oWFzOn;Fxl*?8Uy6hE8>N?xr4MTZKg@pA!qIQS7z^?DH83;cb+%{QB7_1_QN zk@#7l=zSEZ@!tt5|7jupt)S@mUQqSC8e9ne5LEwfz4ljeuz6;cKD?ydt z2P%I7JRUq7)cqd_@xKN4Cj1X@Ex79z-tHo}FX1!6-N284Zvbxu)!ttOdFed>wfDH2M)dg7B)HrX}A8F9F5Jr@U@javAu3a30vO^E4Z$CxLepelK_h*t^R# zn{S^1CkQ_et^h|-LK`UmcJQl&AKz_Sau;~z?oQ`7?=dZT7vU0!Dv-PYiobrdr?>BU zu#xbLy&Mk%)xSA#E?5CIEGNfA_g)`M#AdqCy;I4FAE460rCg1Y}hp!oS|@L+JS z{id0mS_Cd4+zqO{4}hxY^`Of68mM~S4@$0Wo;vOb>VEqLJQUP@R)Q%w3?2tw2%Zc6 z1l0ICVW#uz1gQI-14_<(3sgJq2WNx70!8l``+K>&2b>G4|BeMkr_(@P{}xdB&kx~C zz&V6J3Tiyw2WoswJHXqw7pVHq0@dzLFa_TPsvc*7;)nNu8h5vWqRYLY+Wj+7_k9-B zIQUxRT>Wj`KtOw?lc7JAdau#Mti`@B;D-bDYi5LhN5d+WWy{Ia)c!Ij$k? zd*t~ti0Dp6IG9qC^EuWNe>`}b;yBk&vaJ5Mk$Caf+K}E4{*mKWuGyLRC+adENc_DV z`*8e~V;hc#$tV7QrQZ_L^=ss~(9Ya8XyzR8XyfQ`jxORpKwN=Cqu? zLfY54?q<%1h}$yM=RZJ1RPt|*CB#cce~!3r4yG=X0Yur{bTwi9+QYS)fAm{McoFy- zj%PUjOn4~d`3U$(IG=`KK818dRPuFj7WfH{6&z<0{s;I)@L2M9g9m^)j)#c927D_= zBgaQM&L-}8@>DqgBFAqz{|<+KpXAt{IQ{-YzH!b6!BOxfa3M#ZCnv8V?mdLR&oPVh zhd7!z){*{ajw6Ww701aT|2Ii{F`NsQhVu`Sr-Snz@betUbFAhl6W7Gi%lYHrA>bpR zWN7@MVkFlS$bp(O1~`7teQxK_T%zA~oa^__fWw?WL;m*>-j(w=f-iEc;rv|UKh6<< z*K+?o) z;R?qD=l>vmCFhrf^g{{n8_wTHe9EC;M~Kt;e{udko$y=hko*TY7V_K#KF+a$V;aZZ zl<_gr@8Q^s@EAA`yfKvhJm>ng5#Gpg0p~Y_v~E!Tyj#fgXo!0+>8~SvG{<4YZA(}> zLxn@S*E_&pb4Wkh$f4gd;#ZRYR*o(X>3o}Wd|HY8-bwli9G7~~{=SXBJsi)FU%vx6 zK1!b19M4(|JV4r82)_xu1l+)(-{#<(Ij-S2jqqute~9z5IL_lZljDmV%Sihu$9dr% zrxK^%s6+A#mCx}Y;SYdS(s$rI58g(&f%A!w_iw~44%cr%-0qw|2VNV}uO~dnH3tzs z5!CN+;y%dvyTG?|==U7BOGv*m#N8FbA0~V^=MRK<wK9lor)Lr*9c@HLhG8lhvQ#l+TA!34S4(9wx zjx=O=bIb@HLwtebYaE~8(Cip1f{z-5K;g5jQ&;Jsx=>*%AG5r1temSJY zxU)F<0mr{X!~)V!q?~5(t6+Wh76M=5c#8M~IWFLMo?{2%)^q(+;2Xg?p{&n>!=(QP zTmZ)38R7ifD68}x`OCjwb;@dgKI6h3;_TZ7= zZKVGj)NgB0dgyyN{zSTd;||G>NV}N0 zoxF>|Ey(kYx_sM(^T)tmjzc;A;+H1paLtc6K0)|0;%9R#4{2{AUcWB~)cH*u=jw#t z%^atYKmNW!zISu%PTa4->o~ULC=xD_={Mk9<(I(Xw>RfMo=eC_?yMa=ZJq4yp#CPhCFwIO9?;6 z@ga^?grDHJC|(GDoOu0?;5du(ojC60{8o-1leY?fjzhoClh#k%ig1nM<`I63L%*vy zUdypB;ZKw2?;+oV;MK~&@5PYUL$o{Oxt;i3IsXv}r4aX%koO|Ot>J?GIR9`se>3OX z#ez1$7r6H0#GT0TJI?du{S?Q~2p&meN{+!D3AH;u*<7Zs= z6yd`|*}J|%eqGl@{0ZPsIetmr?}dD?4cGmN_)+5i72;1P{4|GtZ6S|`*h@wDzsPqn z_$}~w@C_tB8Lm~nn~6Imgue;?Fr0sdw11NJ8Srb$7=B+RZZ(H~KNoVGz|qKcOE^Bp z`FBDY%9H2VDu&a>uI}{<(ny=)`>+|JGI#w>NFAU@drq6Flmz2`J9Ji~M>Whw9%4uUqVW?2ejdYLXs!h`u zw4|%@+@s%b*|DN0ohVd?sd8z2U~nW?&No!*Z#8`(Rml~q>0qgxDmS+t%MInKg;FtX z9L-Ip)!e!~%^b{6q@#u6cr{?w5&(Z2$%O@(V7HRUMRJfXNO&dA~sL7eSj;c6O8|t$(h#>8y;$UHDyqs^Ho~G$z zZ@`qKm4X6kIX_gWRLjL;E|sh4;!=4cR~{%7hZ;oE zh81nSs#KL;9IEJMtBhK@N4mJ2hv{t8CK|Po$Lc7MP<64pIGr9J$ioh~kxEj}lEzu9 z^8KaqKqW2I(=QPtWu>mNTBnVh)UzpRqf)w>lXdyYbSPiU)0=ctAfk7YsL@|47R9X9 z;X)-H$geN-=UbAt$$WeR~har!3#01PG-hl4_BQi@$KD1 zX5-(toCVh~EFt<^&ay}<-<+-}1+qg7GFqHODOELT3Nh3{5AZIM1_sdjg>=9GoI6&|j}(yhh`1EKC}|9(W4TJDCG8cv zl;yxm@KFabTT51!l%rch(ht24U2w+>PS8} zpqiHY`*R{S{2ogzX|iR0uG;-8<9!vFN$cKR_*s7%b#R=shO+y5XU&N_+HYY3G`D#Ha|oH%l3{h1wL?rZ zG)|q3caUI`7FNn|2@+Q(D<}i5u;kJq7-NjSn=MhNj3W|eUX%Y}A*vr@Gz|U!-`rHw(+m1NbinA*f9uPDvz2Rofu}49G6Vwl1=)|IKc+zE7sR|z$#2t zv%uUr=_d8Hr2o4WW;z!!Dz1Zx+^{^_Xq^Zmd8}M0p*k1N%%^Ag^dq8YLaH*BXEdZs zTbSug(Goo-a+4K?B#J=~5;q{zH(|4FrItE2md}+%?4)f>ij@r;@gE`P zOcQh+c~mGAfciK|WQ#z|l9fR~`5MkP7kHfYTM!T}EmUx94B1>5;leVL+WL~_b7_EF zTrCU{q~~*^aKfm}f|6uX1%m(z74y}JQh6QH5^3$4AJibbrS_OP5hggRN!vK`>$v=+ zN~jcuis>*)88Z6#ZdbuF!$5YBTHjK%tbh>2Ibg8Kpzq^1=oRZR}x&M*1L7 zEa%Gc>ezTST~4otn40jl3_XS7I!~FsMuU>`>ahJ0C9;#Wad8L@elYaoy>r6G^1JW$`}PWU7fd($(F??z(KTtE30bti>~V zOiDVtJ(fxb6J>(dZA7ZEg=Smhr7q1DaPF&?<2t<+S4kGh_+2z!t)cIt%`u_IE_I&rdag1e0fCmSxe5+M6zg-5pFld zlJss>KUbu?42|TQT`5|#XwB@Ut2$;ez6$-r=_ty#S`_3VdVp24BAkB!x%ak z88o+S=tm+NLsDJNqVh9P(B*_R#n@y)Fu6n*B;nG*?$ zUhV9H&O}iQHZaWk(Z8jF`IJ1B#26Xe-3V>EXiH+6xCzDbK*F?M)2`aZl_QCRh!|!7 z$S0BCjtPVZBVC@a4wtwz1cL&EPFk+^k5?;6yHrAG3_X)}+6p7%;NUdRppcmF8!4fx z4%8J7i^Nf^;TiHGqihgmDxU&k5n=LQ<+TWy^<;hZ$R=9CYcyJ zj6r4kqAKFN80X=b#gwMf2r_ozFgtyIS4vmk&P!eRsErO|uUNc~#!pxi? zpu!!>qbgR`bwsSd2{~c2Ff6CBEMqg%lDLQ1$*gH8&H0y%FOyQ@N&cwEh7qAjDYvT02v%dx@4CT4?SxaM#19M z(4^6ktL9=U77p5TxRat7t@SwuT}L8=xhl(QbQM`Ms=3L%KPCg!K+FQ`tVko2CS(DF zH(qS~8?4B>0a_X>AUE1^j1-4U3Y1#_u9b4-7pd!7rmxAD3xoKNX_g9@U{d97cta%S znX=>`p=Hk95+cEM<$kjeFJC@c8AP~qN|>7(WOR{r`q(&vu-{tfrPKyOWMhwi*%EiC5c)BWIDUFOvnMpcw<#h=vAgVPTC~Z5FQ}TG37}5k6 z+uTBCx!nV(Ys!;H6Vkj&7ul3UNW#fMl+V0J>Uu@9D8UV}oX`T)(>BFcaQ_cv&K-?$ zWK4(oc2lz~-S@@4%?GB$LZv?pb)0tV3wSK*BfVZ4w?Z|E(hP%~P(x>l^9%v0_VkTuMl}rBoiG^_D5^&J|J9LO`sU zS#g;O*MZM#CMuEi_fCyN4WoX|?4m(ut}=;gj#hzc(jup@iyl*|nbUAa*xEu>vOc#S z9+5)Kbu2#FpEv=iY-VL!0+Y3gEbSbWdJTi6?O3<{6&(1u@MhynYDzjs`|<V#21D=~{}ZFQs-T4TnMz zb!r5G%ow8l>D?$`Eapv7rRwvDXpAwbUl_CEcz174m_-CVg5npP$S}2pHOx~$`VH1iNG5Gh@ac2{-uC~fM&kRfdo4i=g( zHP8Wek929rsmM$eV5CpI5n!r-JZSW9Nq7wp168VI=_{F zuTr*oCZ+U?NIrK2B|4VCf*>|6+|B*u+MjQlm#u4uCTM1IUX9lPt1 zeBZc*Jd>hLsJLguOu^jxQUR3|SKXKi4|)Q>kw(9|InKyYW$njmO#982(Ha5vpqmbn z_OcF5<&)bp({!CJU=tgFYd0)y4X0tWb#+_&Y!%4!=-zV|Hgl&kmMN845qxD`9pc5) znG=!s*<=%E=1{DzUf4XKrFENxbtEvP?8SHh_9GkaS84`CnJS#W9*yLtK7C9?Mpd z!3r?fpIwE#ifNVpa$$_6ED1psn2zMei~Ta}Y@)1BDCKN*Dre3^&n%~|vZ_OJgX()T z1vJ}-46@slBTnkI0$U}*GnqPI^SIVm>_X4>3U*{-mZug+!ffnzlcq8`16@t05NjYS zWw|1X&B$cybT!ah4Zpa;s~(XfI?sZKZXATTw6(>Z?J4^i7(!J#HgPASO-HT+yga5Q zUE|s$ewB>((7MdK8k5ZofQV7kN^F_EHljRijOB{1DjO`i)JR8g zL)wvNPN57YwMw#hB)_2`=TP7-Cs~#C&$_ftbU=1A;O^GK6F$PB;gltP8~4$AgvS@N zP=qb0xu-nt%daidr?c80oEpv*%=FSe(j)qgddpcy%l-^?7Rm}UK)d$s$zC-HtM2##ueu5w9W$B$^4L>Gis+{|$ z%CmeHonYB8<7L+$>y}aa(3|RJdY9Z%RkY`(Yfzdvjx*_qELY%B3tpXY3kfoJTOzKJ zhND^x#d(ZJQuJDa*Q=(*2Jt5b3j@J;%$gl$D^~H+1Lm@XY^yR^E~m|aU=&3`yl*oz z17xu+5qIbM8L=T?yA2vq=$kwOex_J9oRo?bfn^G-Rn}EYV;-ne_mzlr!B?C}Ww1X} zJJX|<75d6@tSgf*F4&n{{*ojXbO%fj@^xk@%oJKCzH&`Rd<1_ku`L}Vpl>B{t4*O` zC{5Yuqsyf*)C~48W!nXDabZb0H-?Y6f)g4~BCCWw5rM2nV*{bj{pBOtMr zzPN#6Y3TeUoL@#BU(;g3P5lI`(S>7dAe3Pft`h-HjVmx~-YrRGgt{sKPAHC}?y`}NT~W9)C6zfrfv4lr z#;9qa4)D z*MoeMTt|y%%A7K6QPi*rSi;1&jR!sW)@3cTqYHJ}%Lr9b6@o9Fww7={EMj?NwsWO; zCHha7jN{ywcEz$uOM37~^^NHqr87X?{#aUq`Sk}Ah^a(pv;Cr86j`>>u=|gtFlQ0b zqwy_gc@V~NHc}e)l9uay5zcZ;uB3})LwIl{B=NIs1;s8LR2G|1Eyx|Uj7KNghK4Lb zEtZ-wLK#T)YO3|liUrhFZa%okLu<^`ucWIZK__&Fjuj>iF>HK+4t?_1M0wBFDQH9f z*{X`n|D=oI>l2bKA8Ed<4ih1hD?w%!wX(uU({_@&l97AHQnNOZ##3DK^1-XC>k3ej zH5cy)ix}zRxw99~o4xoD+s+|dt%3}270BgR#rFicVcK3TkG!F6r22-Q;lf~*E;>oQ zT}6>+f;F3Cc4m)+!P_!jTZ-0d?Y2LxKT!RZD`xqCTHMUCB`s!vY1Pk)gW#^rVAsFO z<%<`G>s=4Pe`%XT`txJj^uZcHi`pv6Tep)wj43BurJ!|oeBA{YfT>2ak_Lk}J6qsW z5hp)~zY0P*QMn0O|Q&7{*J#{j| zBIwPg#mI*p+zv*oR6f>P?T`q4wh__|E;w`$-9mE;4GH98smxrFBJeQGoOA^VSQ%xX z)36prldk%GiE^5!auy0D)Z{SKxovND4`M_8?z1*y8~JDAPP2B*P&lMDJ#ppgRn6V2 z7cJ{*Z)Q-0?XI~#Byf{mg$fq-q@|5Rn7eYLj1RE5ei{2@yH~F2ZClaHrr55c)0h(} z?pheln-vh;pC-l8g`32ba(jtChnQz@@2Gk4A`wC3m<3sw5Sx_sVfk>0GrO6qNApa) zEV8vAL`Q2;vN;_GH6aGdcAd}D()+m6*Cs=Zl9ITd|}R^{zNsaXSTwUM1sw>u=;!v+N`OH?>T0h{;DE$dDb`&)C@TX0si zOv&U{9-BOD5!XpNq{i1AYQYl3rr1EoNoDXU%(G|)@-y*D^iz^;l&ud8g^K)rwHz@N ztszT_0qb)Kii)e4nIH}`sfH6vOql}dq2tF{6788R_FHOsG(S*aToHgPkbKxc**To% z4t2&r^rx$K5MSFy#fT&M!Rl<=9Uc@G-PF>V=(M#Qg^DT+z({7#DOC?ogr%$GSa;<} z15A#Ub%GpCj$7WNMGUJo{;^r;d+Q@}{XZNfw

TqRZalq!C%c6Vgn&}Gckp=;SWMsFh&o08=@G`Gx0fTOhN z`}9_+vs3gl+h<7siA=$&w(Vid%~c0orA@O8q#bUQ=KedJDSoPXNvBA5U@WZ_3k^bX zBy7kgsm3|!k?CqYj%;Vj52${=FD7hKN|uL(3p3yJ1OX~Q-O6f>d`y|PnVo^h+T(H@ z`PyEiEib*pOpsWFsPohjAbv&k^5tvaffCL?QaewU3*mh9Z=ZK!XeJ~OojD| z1x~bTe9gv0_aZ&m?6+Jp@;*>0HsC*vi`qCOCB5Ivbr;LyQC>o01jUPzJ*l zd=kP*s}PQw;17(*Xwjru$--S7b*E|L+=W^PaPK^(X3d)@0T-vWT6CRq5VLw+gI`}- zpOHEUhZT6h<3%Gf}c~5DTH0PgWweQ;2dR! zi7q^gt|BhOV@b9sy%NSJCN9#4WoJ{JYGW$PiEW<~Re};j(BC{MKwW;dO($(YE z)u?%E*h1b$3v*(7GV2|j_^FtRr0zwRR?QZudAH;c#Z@HUGcH1+n0RdB2^!yf$}GfO zGcI=<^B43$2P3yXufb#`O`m$KZClZlxPvE9Sfk$*QYm_Jc()_S?ZjREiZY);{Z^r* z+XQVU6hg2*%S_9o{tD%Ag+g&o%94AueuE;X;Z5s&EkIk@VVtxr&tln|o0@v0l~o63KWVtuPWfJb;7Ipgfs?VBcX2 z*Wy4*y7L$v`qNx3;pn7~WNxr@mLoPU?8r2mYVbk?lsleDaz0T05TRS=b2#ID71fx9 z=O3U2Wt)+PE1r2Rn9kNbGy8&+i^b?!CpRQt*$JwC$TqPc=5QL)raDh;H>78}ua6Rc zdQ`=pyONj@>qf=BdzeQFwJiz-r&~9e;(F}X7PtOV3I4R%2;(waRGzY%N-C}BZH^|I zmDrW|zSvwVYHRmX&{KVZ6}GR*CZmWPAfu*jnTpk|hY9tke!8K8ZHwCE5%ULBLIwtU-ZJ}i5+*mr_?|WxEISX0 zn}#J@r92>rNh%rVA}!;7w}N&)=(c?@(GbIhX)So>BU-yeI2Az}8^(eN>}HA{)s7`a zGwdYS;vgeRduyfBvDKSi_6V7VCKR$~PBV)!4SYfb29il6UBl|f*qE$s(_MVgHj}Nm zR0b-^DkxtvF}xfaHg@zZXsU~g3x!IHeqPC7vL&lQB(dGn0_`=<`>eu;yBOiWts-=bY7S&s&(FUgZ&MO%L2<1Ve*ozrw7dGk*zy z7_Gv#ch>JE9zt&U{JBC)WH3#Lv>h37K%1n)bEQqbN0Wn+^}uF!2TA<_p8-N@2vuTk zGQ=rDIpR*a+9`q}KFLWXgG)v!G^)IGZ9)xsiGPlU3uzv_WhjM||4M zgXA!7uC^vm?aak#23}lkj(u)dOBl#57eDnb?dp*(ENZfCFUlUNTry}x4A~lB5EtBD z*0c&TtGclrcAC^`S==lP+l4gA-na_Dz+hV)o2}A7hq(2CvB8ZI2rWs^+Q9x5o%W6w z^GOdqY=6>&xe%_Y40|w)^t9e7rp#%k(^|-Pd>ov%OZ%;DR<<;?S%N`tE$~87NaGT| zS{{{BO;zC$aB8NTkhmBQvw$i4;7*y0EBm^I#-r)l z(!OwxXhK0`rRE_f!d|{0OD>)>H^$SPO_Y%rRXc6Qr_q}8Vv@k>f#G1IQ6DJQ=;r) zWh*co)iu60nJ_Eu=x&}ni%n$9yPDgY=i-2gOL=wosgvF$n|@Oz^z~rN*lE@*R^Q(_-J|N~KH-3q?$2Q?GaBd%S9XguptUNWy~5&@<2fze*6WKNsrdK{jb$s3bwq$QS%A)2~Fb8wl1z# ziiNWvX0`!} zx3Hc>d079#0P?3}<8WfBKsz?%*r?N?xaq`{ho9jWIjcAEX!PKy)6NntAej0rE@u-z zRH6oqOVh#J?~^kHhzGR~@&%AMmq|QTG;M_XYX^|6yo}^|zTY18!@hv&P(+!9Z7p96 zkZrH)A++h!d#ZcbuSMe}vv?w?Cp@*y3AYQ^qkytkKnuOT#Z;o3#&To4b*Xl|FodZh zo>w%@aM3J1x=Xhi`^q81BsEq256=yn&R=IUx~^{zCC27b8x+G#Pe^)L^VY+CZO3yO zwvcPP52KIUWoBgkVpFg(U>@1934<4X!2Z^210Lb>B&6~lB^Mg-h-ouS*|aqXP2Cb3 zhW%ph8plJyEmU}2rVyb$A?g*@Fg->AIr%`FcXV0poH^4cx*%s5fa%=UbO}gFyRTv4zDN%ksNggNe& zGGmie@myzbfwB_JxVMWky+!fmdSWZ6WhLP@na&H_lHwVyY5#Lp_yxTcYG?M z!or8vQyCAYb4Jn0V@nmNDR+`~y1SC^8%1IOH79D7o~%1YIjKx|X(%@DFQB1#4a+!A z&71A3mG+$fY}>*Fj(jJIEHEy=`MT1BY&SIJ*`v zT_g?lU&@4AOVR2;Eo6SkthDHL%OWDhY8Qx@wUD%tnri}4pZUM=76vCPYg$Lwu5cHp z&4V@y*ny6WuqlCUIxW-x$2r3)q-+p+D$-+<;@N0n4jEBy`t-PK0;z-dFYf5X2U4l< za;!#s*%rMyVjJk#=*fl)Z4lO^)3E6#(1y50=~eF?`Y?19_mqSz2OT}DaPSuE>0?}2 zaWAF~uewp3HU>+lmDwLx*a}dJF^IrN8!9i~>%ColR zJE*AL=~zT%P8E(SvJ!r84`P<$KH7+$sHz%W-f8C(v+VY?kDyTTe2Re5FV1 zqRx>%p^4cZqKWe+4B4isi-A*SBUai|7+If7Psp+B9go90#tIfAyCJhY?lL93Ar~%U zJ1Oh$SUGH5;1Tv>sqVUv)?`l}(b>#CW+R#!M93{gS(h|<8P?T?pbF|Wd8n%lVDq1| z+=L9%q(vd*rrVI;%pFjG0_|BwSXR5e5Y0HK>!~N9uq4lfj}p9M<=Pe&+UyAgc1(}j zMk15|SEU$-+_A3kSquYvWSNRXN1iSeL1=Brk5n0?KhzwWXwTQz7MiK2UlVI+yMEP| zMGQF_WJ{vH_#6pS4m;dky$n4|Ct=XKj;#3`f9jZ2Z^p1ma2s2*>N?3?P@!M)qnKlQ z?3()Iu<*utV}$XIJw<4GOU_>^;x0Ma;r>D$<`%O!Thcb$`;YL^0KvVdeOY>g0HR** zrgBO5!uLXZnZjheYbrA(C>Qr^u^8(Q3Th`O`V$)mt+cpd&HOysTX-mweNCh6pkhlC z>fQ+JxtiI6_?T4_^Z0q}83%5!p4Kq0mU!UF`gxKiBo^7Rn(j(9E^(3%=o4#PUR3Y)vYD*3yT$|EGdF8H{jinT6y$o_R5>(?Z?J+&WUL7ZaXA4qOIy7oE zS;zC*$XD#Yet*C;>qd295S0imZNiCU&e1z=KGfcz@i-mkj4eunV;r1LcjS!Xkzex!`4MpJXK?&E5qWpCFc6mgM~_)8aLEF&eTktDVY z1xS*4@Fj6XYjqI@pi( zR&KS>SJ19^u_w}w?WKB5++JZqZCCZQHP4$f|FASXg}qL%W9sEjQkTUCbXWg4&w0C) zVpq*z#(=&Z*ZicfdX4Me^%toQ?pxjDHfmqIl~ln0_Ey8=h)|Z9w68E4@s!L3wJU3eF`m?6VaKIEAfUTwcMS%!iBVhJgOdHjjP0;1lfIuLt6Ui*yBXTH zL<;qXb0dT5Wzoe8;HH$Pi;koc0HTt;(CYP%cId9ZaR;btCiD3<_z-YvMEht zExo9i+Csmxb$v6vRij}mQnczCx$ro>PLaAHU?NT2aqC@JS8GgO(_*Nz7>#%OXiC#t z5yOK;5Yn>8;wAKh88}%R$e}7>*AhbjCPumkf0@1`=kqGMpSfg7vQ}%?t6bdq%l$3U8DOS_yVn8m{ zSX5>HACrXax|$*fgJ(4gLOhnelA99k`S6srUR{|j)G-ab zH7CWm9_ex{?pI<;LXD`T4eR^Lr($r>6y90wHoFvSZG5kL$*(GEDhBjmplc|{+i2z8 zcDsw4p;)xoDBLs`N)B6JeH7BgCf+R3g@&4%E#Rx?^bRe-dPk=$N)S-YgJx0oI@ zqu>)aLxcv{lrx4hSmsfQu#Za}R5$egH!Euc!~}NqlK)?-vbN4St$hq2!|6cw9Qv~h ztk|(%!QRCj6>0g8ql-DRXdD)DNw7m+@+W2qFF8}U1l94NGov4kj56^DvGnh)@hUglOtC0ZT}7sVm0g>0Q<1@&CZvqVH6O}@#rO;2Fh z84IqEPEWMU#mZ8!`XP+l*r7%h>Gm|xw?v4DMsfyDT%y;gJ(G9l)H-pZ)ZC5hglP^3 zG`ed~pkrchbzEIE2T8)VV7vs`2sagb)7MR`wx{}2`8VAkTuD~L6ME<{c(P0wWpCwR z+>ye-6k(K8@>k+2E|yrN-a=s~aqpO#&>no~x{4P8*#n%G2?L7>b+&JCm1z(?$I2;! z#MLm%Z5lTZAL*t_Y~u=6B?5nX7ImPPs_8oJ96{jquhUk3$}MXrPk4&ag26pF(-Nft zWG8l~SKBbQrrDm7Sku-_j>I8uyGo%3IamzvkV@F>xY(Gla zepok0B=v7t*S;8yp0&A2rfi(DbqXt9=M(1g6yQjHDAzwp2!60a2w zAh{4=Zn`6n_0B8CbTf}oX>l+`%&51g7-RL92Nm2mC($a(LKtK(MYjQgB70>gbuU_sY4kh<39pB!WsH2AjrJ^m)jsASwH=3C5_VrFp+1Q=V8aP>gLoQ zfeN)xEC!j+9s(EIifMBLwIgiFCnj5VuI-6aY}P@vlW@hh!muU~XAz!Qm3dVEwmpr( z$3Avm6yaJxP%u<6?t=Ho9$)4S>noP^;}@sCsU(r>3CqtK&kb!C1B^8s7YkYwOc&YO z&0Pb{K`OQ~gAFlwwAEUU%SpV>dJRzX^2$(Qpn1{wP=yb|w5HR$SF%kL1D&ze+<^dX z#mb$xuzAkHW~|wH^I8`yXr6P(oH^6==vH$tFaNGEqw0-XnUlX4A3e#%p>cMh$GZ%R6gToZB*Idb_&-n)QaNR&#^Qrn-gV8|^6wc;v{{y^EU< ztL0M_@K!fRIoz5ahB`j|gl2z*gw+inc&KT9dNTF1MIKrvUqk$24<;~g2+*Uwta+em zl~W$#seQnRK~X5nS{&DBW;WhXER^&flulNFMuu~#t%Bf;Te7OHcXe0qNwcF5}5rYC*csEw}T<;y4(+)Cdy+W47`pJe-^7t}b$>?eyK zLgYqQWVfp9f`H4I(D~F+t}j>EU1r7ToJj%Mmyd|{Y_Ia+qZPJ&M9+ltsi9Z@_R+?h z^ZxCl9HK(^@oyjH%5`5q%7w2V!4~qJ*KxoCcF&9yvc|y0tawo~>qmNfe6*B(2dS>1 z{u!i#n(8!TK}4FvY5Y+CjaU0krhX-f4?XGq6wq1K zV%A~uFO{3p)ToGphc(IYFc*mX?5Gv&q7m7E;E}_A7V5A5o$Z z`l2I9jg(g=RN2&Bs{fV}EK^T1eNM@TRl99UWf4}UdQJnY{+mi%<~ywUWQ;K{B(4qe z`p+sUXJ{aH%q!QuDOs&$R{Kg;iE~zs%r8I0Lk8U2y3f1XsR@CYZYWv4L(`b>xZ&z8 z@fv=1o9J6hsL-}>pnf23lkno~t4p><6XcH`3X*co20!aRNXuGjzP#~z-i?*x@eRJt zwDCG^NYO1Qg3mEA_iencIal0xH9KvQKiv9wT=9j0oTQOe#9RZ~8~DQy*Z9yqW(P#roKpDuVU` ze?v7Cn2lGnr9R;)N7#m>HFMcw*2hl9HYFNA@Dz|9-kKt~>yk5us^8vZSG$mk{hhQq zVNqE0f?hSoMjQ_%wyC#}cq{TnRxs+jFiY}T0t*7pqQ5t20MLy(W3o^IxkMjos)ZJh zj3f1}Xg>NACsRDcY)6P9C%iO+uAK78CPa4alTADthBPI^ly5e5boMOkTGH0BlGm>> z4fAFNQMQwJ4VCj7KU3oMXs;OkvM)J#y-bj5D+Njm?U&oc8P7?E=^@h}WdMhI1#ZFN zrl*C;skhY7OZfD$lJiI4|fOg;SP3?JNrB?@H! z5tXl-(OFEw8_b8F{JT$*E;tP8YX=i$AAK^vFnmLvv0OiL1S6-TywR!O|#dMTxh#6O4^FNJpjIDo60IA zhnC3+$U`-N@O~xv&8Bo)f6zcFi)i#-HAgGBKu$7rGU;tdcoq>-<88)fflohfyopiE zb411mbXOE`&M&svZW$ATJ+TI-kfSW-Kz7=go6260GwzOvB|;=GTRUxiw=IZ5!4NCu z*!BQQWwSL5S?7{GT70liYFJhVvh}p(rGqxfjBSs&41BD6sG%i2unN&KcR!;+dI5r+ zX#mRR=s37pw31!MJ!HS*5yA}SYHgdxy!_uS7Ii4!KSDkJPZVX(nr>21t=~)9+Qdk9 z+uzuK{l**QL(+S1mm_+`?T7hDWlrZaP#Uz%%a(yrW+PSopsh7}yO=%lKc$=5gdy?H zK4k{taL!h~>5t_5iKA6 zKhhjCsNMKN`IsV#=?umdh6)1TXpEh$w`@QsTXC|Pp0;WK2S%0Veg01=1MSfDY1W?BPA3b%`^_~8?}Dwl}p=A zPt^T*D}(J7HhD-@YzD-7k+RNR*^qC;1}`d4CBQ>n4`W$93`k3vj?D9W29@T6xT z$`BAT>0Poi;xuDfkJ*H3Maz&dDK0TA3ekf(=JBYh+r$1FuSS0?$vbX)Xg8i?Z^&)0 zqa}il_Jjo9&%wS7=9q*hKgWluB$%e)-^^{#dCIwE>YjZ8HcH>PNhI0$eX#AC9Wvn`Ln*qg3yG&dm_GwAaDK0{OMkUgid_zKaRC=st#{!~>%i$|hJR+Y|IXF6BHOEYWQ5R9!$ zVRAGMdP!p~|1ulRW)kn3n!_3$GKa&~7mWYA5*a2|fQ_+e>+<=ns-|=~pBP@u?6M!t zp8-q9#k{1GJ%J+|Z$JZ5y<`;gOdqeDjZ&78jh=)vB0L`C@#r0TrRk_&t{v|vH&&FN z>r*xZ(!O|ck-dXQ18!MOo75M_&?fymW04bNplK*A+IY1dkFO+MMdooP*nHAu?_<;4 zkYwh76^Y(vozKqgI_=xPL8huUCCBD4v~tKa-h;t<7{zuoHqmZWJ(fpMg|jb_@vxr&Erc`X(A}XH^e@X@fXu=SlV*G=|smaS_NC zP4#MX8_Ao>7J3P#!7X`xBMp+s?nziP2v<-(!XJSlZ?sLS*t@2-w1ix&p`oNx*XaM2 z2d&;?tt!McD116E5tAaJ=b#y$!D-GKhJPf@ZZt(#Qy=))$Z|vD3U!q|VttseWJ%Hh zQ(ls4yb}!*d)=>=0B!Hf|D>w$APY{CMpP)R`U~gmT;xdVJiVPXgH0XG;wX;68PcGE zllj!)i_EFOws4tmOp1(LbX0gn7s-Jj8hW_}MY}X(*JJdlD!EQ8F)hMmw5EwU@GVe`$SeJF^cxioWLT#n6|>A2UQYp)_I5N{ATO zzRcYd^-R;h>MQxlsfIp5y=%}-XYae3%1UCLy1Gdz^`Ct0ucHDpmcJUJMjHlG%)s+7 z-0qdWyhf)+#i2gCmSZT8HX9AHf;2AmJ_Rp~oqr z>^o~qte4QKSy{wWXLHuqBI6CR$C_TA6jypfiq)O6cnp<_F}iVtGrVmMvS#Ep-VwC6 z@aP&%tx%J=wkTXjiMpE66Mi-QL3MfF!HV~b zY}Z;ph=Q@})rr>g;X(u)_o%n-Y%IICg^G*MUiOJL;?-u0=hZ>sO!R5v`5Z|NiGTlM zn?9$UtdIgYSSrBN4C@;-xubjPCJYxkS6yWk#YN`(iHgA>w4HwvV6Rj})PH@t4lcJrh#Q8M)_JltSrICS~j38YIU?bYO3Ko;qEe#{qnq!Q=c2QFV@ zZROA^;v>IUVwu5sAnvw`g-ZTP2XqkWNj|9{H&; zt8C|(Ybo_9Hdl|Ak>L6WoDE%*mKyhwEZ&MB7o{;oIx*#L;3=;HpSN)|;{hf=I$0+5 z!j%e*KsUvh4&#YhjfOPEu`EuL$>v{&Gwlu1F!;%9`UM=sLSH$L9wM)ryok}S!WJNH z2S?9TQ_N*;#0!H&&ZQ}l#{Fb*KdE$8Y{wf+U(P-nmz6Q4K&r?8n*6Ta zO09&F;e&D6)loJn0-MY0F|LKpsb7pUQ<1m*>jlMDD4%tZ11kC>c<#B+wfsN_d|kDu zHMXcbfZOqOw+Q6_M!_`gaEj0gcF@rjESH79(<6z_pzLJI%J8h0zau3sXW0wlYy(B@ zQfA**aa7PJ=uWcZb0!X9C_E&knV9#)+lqXr`pcD7J`;gLBu>$$L1;UQZ057RT=ZceH{Yo6p2JACb)NJHx3s|XW5)n>j|1BKjfXfi+=oXCxMvM-hro-!}57Ko; z+hyvv>3CVYD&~BQ+RBz=7smrEb=e2%5Yjg6{q;$vX7?J`lbc6B`9c(Y<7Q&5it!_L zlIBrnAxp<$GZ39T$b0nhUf1+lSRc~cQV0#NzX<=3 zp8tS7Pd76fV)3LTtRzXObpcZlvxPCuCAbpV0~@l{Hps?=-k`}Z2}=x?Q3B1pfC1_| zA!Ljx^6&ch>)e9z)wi0PSo#5jhi2AWkNzP$>2$s(i?_?Yj<4_;yO2`H2fe;<5wFQo zO6)Xd9XQ5sJcqsM>VNjonvcLe6QR6U{j^<3j}())!LsOcl%(en#}QeDqE_W@?E^F z0RwS62^7oZnHu_Pf;85~oJG_|FL}@kbq+P%D#+&_AeXPa zvT7P?#uJdQS_SN#tg4U-o@15mzoB+!lHd~w4w08R2@saHD1^_?WwLx%BT|6SU68f* zM+*m7T6AXATC!;Wt{y37Hc0N;ihwTFRdvb!F!PjDD2Ve>*Gd7Ik_11Ynv%BhYFk^# z3iQ<`^?Pn?<4sxwQO$gi#Z0l>s7Yld^94TNs*z0}j9E8t5WCdbbH0LiBCKkobR$G` z1z(C$t-(4oO)8tdGf2HLh9RWs81^Z?Hm8M;d^dS)^BKxJ-XJ-&WWAtkciLP3SjX1u z8JNoczuwMeN3QES!!_1Zltz{StCb8gF#-rJin0ZW7rSX?nleQREi~B-n`8hzK!9YJ zp+|9M9(d%Lru`;F4*4}4VcashRBLKm!s&m_C@3q(EU$>5|peG5j8A@Xpqjjy@ zzjRZ+AXl*vw!~t15BlA7=cwOpH_Ym`XQo5#b5t7}9;sU64whWc0gk4HQ+SAsVEkB$ zIxiCYrU(UU9iRdx6$w1wzT;fuEIOSS1h7;G$}+P*YYROeh7Od3wAeM^VF#eh!xo`+>|A;DFj z2}kM&j3#}erm(^Cl%`c5gNb!`8>`&k-&Err10QFuzR(+| z5XFF@IkvW5wne=reDFZfZCmCwdwv4RU^PKYvaG zj&lNq2-0I%a>vas$q52fbJ%sK4$0yC&A&A+UO|+efsK{vB1wE1Zf&h08wtKTTv!wy zb#4Vi^OFD`^fo1EHx+Efzwh1=7=fUs)S?b}Zut%wOSl_2eOCCF(4)qos8G$pHx*s- zDf2sn0wjtuD7Q~kQ$3xU!fwO|8l^GY3}ToLa-CE7tH#pjNidP&JveL7Z!vKdx z!pEN<9SAuB@BTW{TRs=NAfR}f`;JZK4R4u1Qe-*4Y(~dc?>t1 z7(*bcRqqsYWO#xI`&4=l7YJ1t1b1y8lTSK+rIXzmn;36wxo@k}VMWh`MYIEihJNMc zrG??)WdD!UKgeMg=MV!&-R=5MNTS{2>p!59Y2K;VTkP zTFM^eSJAB+Hp6&F zfNU1VkxXorFcR@rT)}n+$!Gc#A#&<{jkbyb7nERZF@#pUeGZt40*9ruV$he{t^R5< z*tZr%ls7jY@(wYEKW$^V+9bkyingDYle0zV>65R%IsM|4T2OCL-jF00{Q0tiF)M_E z@0s>^BBI1zzW0uV4sSQb(7}HZ6;I_b@s!5opu;jPxuxrcS5enA0k+0{VtA%Tx`nYS zDIyzozN*Funv#$Qv6<(kJyXXB70$$#TT^8##t78Wh%sN*HjIpgbr|bZsL!BR@R_d6 zgMKD^Vt&=se%AB-3WY5;5uV9tBW`zi=B;6Lb4GC=udn8ePA%oSyy<-4XZuGpC{%AR zITrPfaUe{~OZQwkyi!?25`ey_4%UhaU!q}n@8QtSN;a0juAnyyCUn-KCb~#+s_nJM zcO|PrWROWj=`+C2#kHBVLGyb4@^AkMGq2=^*~JkyhyHDlZXFoi921z{-5e8?UNPu! zqu0-_f0Eyv-Ty6p96xz{{d-$FK7u7c z^>%cHh-1?1!Yp9gup+07e};hvn3(R4nJ?rZ{T(qyw8sp3n^)Q-i17&j>N7@*4XZTW zhDncL%pJ(}leu7%bz3382R`x|&x>D@UD2`cm``th+=5#a9kC7#Am+_qTu~zH+x+AW z`XT%^G_Qc3Y!(|qx?p5Ie0RvRZpd7%N+fHmBYCRm5At1V=iZpsuYIfex*FgW_fcb{ zFC=oQN5_={v7J4_c!pTcv@1X3GFN7u{Q!n;8G@22HmcqFKE*eTEkfSu34=ygjzc0rd$N!IS7xq4AF9j z%3G%ufU$xN=sB0)LsEC2*U`b!HAF&~XLO1l20BZBQjkeXh;=vxOEA3`Ff+Uo+iBz5 zD-A4&IfurVmY{70xPiIp{`vJ!=>!a!O*zE!O>1Ir9H6Z|`5%S;RvXI5u$}h(`3nRZ z8|-pAh6XO?<||j;JcoT&Ehk|UYLX5o`~g^-C4+8XeS7(7W#~B($V<&G^lTz0YPQ+F z_~fE_YXTb-c&{uU&P$|fkz<3cF$8v}37H^M_W9-3vGO(1f)7-c*@TKbZd35r{FMju z{$eUHsEHZh3|$#vRAhrSaNmDn2x+`2pI{p_!_35@;M8(qG6U!%)=g_|K(dc2xyuZ2r!%KFjBX_8m8{QQmRce}aj#5m?AqsJ@)o{Z|731edt%8MMC<#-4 zr~0x`lxQtTNsht`#z&<(p@ds2O`q_~_OSMCC&E;L_X)Q&^^2C8Uk!VhIJ7n3wqi0( zslYAMah`f$`w~&SGALvA8??E2bXp>i~2+Ie+SN?3EL!TuP z#I4Hp8-G5n6wSt_{=VD{YrDlS-2J*@Fee#+01*1HRZDICwdrh9u zpQxP(jHJ1{zd)~I5jQ>4nBldP$L6c|O$TWK7E>lo=CREthdI>-Aa~A?tPd336I) z&&tLm!*HfL+zW3Ba1yQ4&|TJalY-T;bM*1-#+@kfy9K$TYE#5Rb?(e(bBJs1&s(K| zZI>VhJx~L}zc`~vvWxQL;y~CwRs+xJQlxErDj=YqV)di>Ey;w4iJ_H6vL2#swP5Zq z&@PyzoOZa1Mph2zA?3~#hkm@o&a5nnT|0^vF1*AAjH5lD5p~jHUDVFb39oSL01=z# zH+QYrhRfaRiY{P{38j`-S4+c<(zG*bY8R8uP|Gsf|>g<)B<_a|GR zTYZkspIA@vEg;c(Vs-yC4ugCy19{dF`3qtLwl zkBL7qd6kx37!goLKkVh9GRa|0!)=@k*kZvm*=@rDzS`TK$EVk(JxlR*%hxa6LC4{z z8^e3{$#dF%s4oDt#V~q=Vl;dcyZVBPaEJBM;aeHev}5KKu9BK&4&U}x{Kn!ohk3T7 zdr=lnqRq73-jfm-0Ga%2M>QABiH4-&ANg3d=C%cSCj#wdh(qc7yX3$_kBUcva2_%I zB$K%N3DCfjFA=Y9%^D<=*Q#4w@(AxD(v2`Y)7+2?AEnD^qnov4{OWwf2tlFW4fDj$ zaUydcKdbC_zLAW5m_x^$j5}HRf~1%Fu%7whyxsJ>mU?i11bF7_{^4fm5ZB`!;aGL3 z)L@v)WAf~=7(guA>~CAZmsU6nSxkqMs&JE+#rj%kA|k@-@_&&;9Gs+hBqr3JltJj~ z*~VmJz9Y{D*}Ize5^DCs2ULPLj1}J(0UE`-P%#>$bwI_dnt!5lz8<^~hN{a#T``dVnuul(_ILYQ3t`I>2y`}Ai&Jm+- zqVX)xmLC_qv3fq*o3H>FSS81CmQ_q+a^g1OEXy&xIqy1S^Y`qmtx-wq1g#At4}J5y z>pzjWb$RSs;DyuUR2PG8#d0@yb@Y1v7F}MLEp}>t3UEtxFE2yI{eenhQ1Yn@gQ<>O zIOcdO<1P9D>xsTn#L7qj3r}29llb``z_j(IuiqR{5J0F{l^h5QFH4^}4 z=(u-2I{3I+pE5`6KfNN#MP6b$r5RB(J&VS_xYS+TcHjKOdppnguo(%Pu4hP`a%_@$XicB8DJ*>iEg1CME>0ueZP@a&DsWPEGrac8T|bIa7?4NB4OOc+9H zOqNEqiYkeU8txYlk05Pd1U>m5kar8m zaVt|Z0k0W6^@4q3l6w4;tjCZsMbXD+nh{L=?h%kTVuz*=5+>?r3GZOL(16x~{$X-> zriU&xX@2^&|7z7Hn_{0xjqaei`X871C|o%JSm^ zM&ejshN|k4)6lr%59iyUJNRa}-U!c9r2U{>`SAYPllwZd(2t@oC_@IjW!0KX@vPjR zYc?L)kzUaL2>Mdv`{<6AuG^XFHgm}Bi9Ff&oblY39nm&}h2f;j4rXE`kh6cP>M;;Q zzMMfyOnCj_RZcsEZ~k<*iPYz}Mv!~7;tx|zJeb6_-PUDHdA9M>5)p<~ffBuPUI}%W zDdPg>u~Do1f}e{xf};En`xho?BkvE5o@muIsgLgh`NqP>T=@JAiC(US3Z*m?==dAs z^zr3)TUeb;C_^LqsiJO{?`SFWaA#<2x)mVa2f0(r`V6d7UQgJ5 zOTMm*ope2JR(y~t;^M;OZ)6~RacBS7IIRWAfX-?L`avllkpFBNYlHwhVatNS%sWaX-|qQ-cPZHpq`uU)F(%U9QbhNic7 zu5A!1Pt6@S-E3Lw((-+U4!B(u%-CJy-l{WB=@19zQtRiL&K&4-Js0Et{_Dd__|5GD zUkDn|5a+-l{ykk*jmt`Xs)(uOi_&0?LT59Z12Tlivc^t`8dwKi^@iMjBM`+xVG?%s zSH-5C>`&2V7*1*4Sg;H9u--DW++kcK78p&azOYUP+7dGZpTSvwRq6spPy2f7jRRrK z^D0J0>a~KBF+?o5k!9Enmv?xK67&kz z9BV60q)L<>t8~18Y}D*a*jDs>WJ&;C-;Hj!c>%lY&cym-RU|vdIMyp3HfM$vsF*0s z>h#-fnX-U;{Tj#PQ3uudfC-mA2C~y(Z>S4KXEXN_+Pg|xP?PYx*R(1IgsK{_M^7wE zG*EISov^M0^8eflcaS|Z&GS$8kE7oJ05H%6Moo`iCXnFnD6nfqzvxpsYDDiks9demf>IGjaeC3;+`z4WDziq z4ZM+S_Jl%0h{4$RuP-f&z~(3?*;lPU_FpGqT4gYXD3_D>*AIz*9*mgXl}uZj{2IHi zvR@h3>WfH84mzXrR`V=WD7Lb3;(A}5o4L~oe(CdNamP?jd7|4@>6gVZcc0~(j30wf z?m!3Nj)IjbbU=L_q6sjYT_ZPJTMmH6T*s;va9J)!A|iafTmRkN{%O@ubj5`QwMz2N z)9+5(8H&tcyl5+H(aa36F;sMBS-exYCT%hi*pJTyvEW|y|MALtfT{A2bG!+en$qWo zAD>Y@c&IO|cmwrRKrge6)xs*T?aB>WuXJ}p5AF{BZIbOtRbsHh;H)V%Z5=NvB=xQh zP$sk83BJ-327V6WcnJvF4`=P-!YdAUWGQi9HkTNcqbRfIvrtjBM}mzId1J5CNF&_QC<`4Q z_0jKU;6Ao^dq?e394RtGiGQX}eme=~XYU_T)TS}<xw17GpExXM0*18&qG>uZ<2-rdZeIgLE>!N9`E6G?6SR*8?&)E4^c8kY}Br zM|9M0a^P#>NOrdKMcyhTyv-C;|oqBYt(M2ZwLe1kBg{nd z!8%U$BG$Q}Wi8#=^Z>2JRbfM+<-MWyf|tNG{#rF2K>qbo{2$RPr7H3#K{6g8^arp^q zh=UK5G-AFfvb#hYnQ@*#nC&gdgJL}q1BlARwRF{x0+(LJA6HV0*K6dTwF9`U=}IC+ zRpw$=U8c^U2n`%HJ!U?7wbJsK-NtG#AoFp5ASW&;whjG~F~Wq(rsB=l?hv0Mq*FHp z;N%r5IiZPGLxwtYBAO+I*tzVG&{dl4s+?96WKquwS~zu6hY)gNX- z?MzNG)%<*WqRo-fEiQBwgv=$w;e8ZeE;W?_+)z|i^mrTB%MhMs+!>NXrSc~_faV=i z_AP+39(&&xwE`Bcf=pV!<(x2W(jqx{KhL9yn5+n|y|%GNNlNlk`qisI+N@gVYUKG7 zD5zt8tYCvdnUWFafg>@{IH&T5$qCP2t|7ZJR3b&)Jv=@#alyLO2- zmzlZ@4nuNn)k_;k{^aO2pI@=Xz9hzicQb3lIpIixD7P=S@1l|&e=lYe{k2urMe z^6dF1JlFv{K6Xkful2fimPH%U8Hn6Uni{Q>`4XnXU)K*G<$Fk9SBBgg`Pi}X^~+Fr zudKn(UiYiF@i^I3hy4jF;nFEt zGZe67k0%Q8B9WgdTI@Q>4O>9Jbf#s&>_0CTtK55JRUv7@&!_JI`sBRdy>JnYkQU zkkn9vaTFND&wpnW4dNhHF~fuWU_})Coc1$bX((S(^9lT^yLy{j{GIb?>C7FOALNqF z1B8G&!Kq`jO0(Q+ii1*VfgXTJUJei6It53Tbw|h2`{#MLdajip z7wpfG&yx-e-x#li4p%NzhMmcv;j@7n@G&URtKg`syA$aI+QS5?8o{DAHp2F=pgwX9 zjIEVx#GQ+tC(Z_X!PJx?JKV8c5^KARtKJ85)QO4DV_@0EwxFHUw0!1GS(zhVT5&bH z6fT;nM=(S;P4}UW_IdG4&BCCcBZf%?RSF)7EmsR@x7^KB=4FJ9xb8?FIy^tk%c@49 zXJDOusF{OM`<_|C_-VxirG$;-*`|~<8!b0kFwEZXHy--0M0Wjc8F?Vejgj{VHar&U2=UR@%x7OOAva-N>`+r#nv(^T>EZ3Y@`uQBaUA zZw11EBa~=5Y$4R#JKnc?L^);3XJN?Vu`gyMyQ5KFLNPJK+se|1)3w2!5>brE-!ilu4!>1P zsf#ykcSOnn+@9#zzn20f^S4fjc6>=d-_EXJ<^I_Ua6}VdjxN_E;LKa3IhQpg=SF4G zHz(q56wBu=5u28`14i!RDNH!BB!DYs;AuNXA3A|wcI^RnDqXOdYb(QbVr_qq)@*c| zj)X^pv2afq1a$ZFOjW93l&YS^dU;K0DOSTBVR#j(T>D3C&KZRKBYO{kpkUvV-3Z$L z;tfVTs0*k}XX#B-SZkK2(g`@NvcCvaSeV<6FO53U5Na0lZ_aeHarWs5@0nh(y0fT# z_0XO;b2lakKZJc~dxF2sih-;rGkrp^DF^i96@x~PTSOd3tL|Ms;Lu+Rw&nbLBcRR` zq3A-BE_1p(WrKNmed9(xt*n*4-@y2dxAqRvwTaluy|ObQvMG0y^;J8m2CT=2J`+<>EnLf_ZEwzdvmWnxxYYo zRv#OzM`+AQif+n%Nt12B45%AKdTF@8NpBO?j86(gLR$K!zHjAhx;Rq*el}A2-MD@2 zH}?5{SG(>`xzG=~5P-a*Dw+Jo%xms-bd%$A8Px>0j!xs9c+;*QusiDj7~djNr^7v5JTk^khu+NU+L9Mc4G2^%=>>K&Wqa_#C(?x6O` zZ0c>=Y()D=G`R8YG&qhz=J>2fVJ-!Mz>zI90OQc>C4a8p-yWzPtxCiuB-O=beRS{o zKX^WKg06wgz=CAf#Z#C#rYtpQW$7c7gQJDKSPa>QdeD#jCW4b-Wc2aYwpJ@L_abPO zH;t;c*lLw~CgR&gZkDE7KKS}4{QI>&%T=DTdyGY60|~QaDQhU(IE- z0w4S{dqMY0+GLOl(M3^r-=y3wynz`>9ZX)CsoGM^YyoVW)t(*x6ydfxO}P z)=$HQRN-IS4Pw3#KSV}&Qr~R;yBlw`+iGF%%C+~J44hW`yro9UjSs{?IsrdY1aSLxAop@{+CI&BL>nuzJ5{32L3U#^H_- iQ#ameoqB&s>+n>jAFac+;C);__%$qrT_!tDcX%c|0$v9P!KYzY$4NQ|iS#0)(Gx)Jpd zC{a&@3*c<1CAb1gQn$eNi2f-k3B8u-I6dGYC<(NmL4ZA=WY_kaadlb6pn}6;N9>&cn`do$}8aXY$K5^us7-ZVGH;w)P&xJ z9pOQ!fjP5S|HiQWEK|@GN;E^EL_8gifpcLxJQbF~ZEz6mJliPS6sUGqL3L09wKOL~ zNp3ULK(<1y{T)zC^~h}GFOlsgLu>Ogl#=X+jbQgV=EFWvA{-1guo)2Tb#kFPD75J~ zMiKt??TvK5rRD~QU6R-qIl&8Ud@Cv9U zc^FD!pF@4`%r~O#2DKFFuqn)dt>BSR^=CpU@lx1F`#(;kIT@Rww#l_n6(4|FiszuT z`!~z(3(P>r!oK9shZ;~BREJwFw?ln*FO*I^4<*_6p(Of^pJx4gEHv6V3`&%fpk_1= zwuJ?-HHDjT3AcZDs# z7j`4P3u=wug)aOOs(!oWW=V!Y4J-%h`(!>5Ngx9KHMQIbC9*A0%J>+RcJF~2=$mj9 zJOEX%_pwGtCd00zbD_Q~g!-=BmM38k(ig*V+W)r`(ahe5s`ziH4jLTCPJo@DM1Bv{ z4EMrE;lH3v?6%|01fGXlidUg@=sPG$G&sSeJ3=Y33#IHMVI%r?jv}G~OotLpKGZ-? zfa75)losC%yTd!6mgX5a5A#$?>53TcqLSOJ1w7qi%7o$)!x8W z97u2i>bqJ%nm|{@F%F5G%Ygu$3iJn3Df}B z!@lq=*c;vqyTGU5N$^dmc9s;Ifvkc(NuQe}qLJPNo5CHi3ETcIq5dv8HW;4`QR{R+px zrV-8&`ge{}1g?V8((|E|@GiIo?uAm8@~AnrHo=CZZ-g4ytxyf$ZOb2q{=i^s^7lhY z?i<(^HZ13L1v|o|8dyR^9j}0$;hAs;yb5aMxoZtq!bYTHusvJ{2gCEB3u~Z6{~A=i zrZLlTD>#XCZ>S|Y2Fl%wJwt**DmRgkMNSDBIbf{jV1Lv@q^+0xDwI1X-ylHhxAH2ey>u>S_5bXianNy4+>tuP%9#V$9~ z{$EVwLNZQ(>fivBsDFaeiN@Gp4WPSaUntQIgeo5mCE{67-z|bo;0bUPEQFHa6*m7b zP!iq&o6^7YAQ8>@aX1ye0@XqLQ;cN#SdN6MHv>+GOQ3Y-3a9~Wfj!_~Z2mK_E9sYD zclZg^?h2f0B-R!tC3=^L3PwYXcskU+j={0;TG$J|4AtNlumm4uD5O zEm86~8(9Ul{lZY%ei~HA=RnQqde{=)Vbc#mZM$b+E4UA8BKx5v^}S`63r#!YpxU1g zHNZlMM0o!bk&>MYTf;}7lx2@izYjIjFW^+z{vzx-%!8WIlTZ!rfigI6!bvc2u@UuT zDA8xx^f6EpJ`wu&{{#{3f(=j|Z-PDHjZoVCm@R+PmLGtUOyCkTuM= z4X_4|gfBwr!hfJ9(7)PPx@mAQ=@O`Vn=NZ$as(OQ5}6GLTxr(06iQ3i!?y4=*acn! zGvPKUiTw*|W4~N3DZTjA;SbvT5F)~`fx1f~lpHNEp zHT0Fqvdh&*)Z<`x%5$J3Pyi+RQ=tZQ4OBbZ;c@VOs0p;c#%O;!oPp%$U&H>FXfC}S78bzY;8M8idSg!>hI2?a z+QKaf&QB6~kx0KAu!8Vws5LC!Y6f%xlw=-*68UpbGx-P9cKZfusT$vCMBN(pBHa(F z-ZZFPvlP1Ua<~CL05$RCC^SzaKMu+uoC~|bi(qfK4NBCzpgMdHs^M>-MBV&mBblzy zB|QR8gn3X)uo0^M6>tE&)8@YdX*cP-OQanIKfwO5`7K;F6v4d4UV41QzzGgO0(wwsyv zgi_WFr~%D|YIrHsfX;&w{asKU{0&ZlZ$S;P+wJDC8U=@uUI{zXzq5&mwERXWrF#-K zfp0^t?MF8M0Mt@6`HN9L7xp7P1Ga!6sPD_6l=cFs0c?T#?j5Kl{T^x}o$o-p68R`1 z6W|iq99{$^i7TN-z7=+bJE8XTD=-~?0;Ln}?lij4A4-y=pd>c~N)pR$c?ndzl~7BQ zyc7AW!Ar>yZ?*g@R0DgV4A3WV5&Ry`f^+XO61x{&4QfLBp(OML zl%#*O<=yT^{xir(znf9QHE=B41vRsupl06s9;1u{p$4`TwuWU;5;_TLMwdZ}`c^m& zJ^{6ahoF|^H`p0=xYx9ko+P4?PlFoS9Lr*;j?aOb!SzrxxDB>~H8%eVD5cv2+rv+w z2L7XEs~yIU^o9Cv5mbLGVF#EjAtGfs2@ZkP@EiCP)C}&gVb8(?@CbO-eP+f_Kn?Is zC}sT`YUV#!cDUc9he6ew1*Lo|U|)DL98LeuwL}Jx@e&*hzlGIs@B?PVPeQHfi!cqo z1*O%$Kq>8{2aQg|p+vkBs@{vR5&Q~vf#2EkRu36TyRf(R|1=_9sc-_+$P%yvyc}v^ zw?T>c1?a-hphVd6Ve?%lCHKxCG9Dg9Gdu zIGXhCCyYdYhO(W%&(iKm%&cT8@Q5(%DcQZ-WoP zN1*B@o;EW%8}=c6A^ZZ~0W$*JgmxJT?s>-4+XuC@??Fv0`4bU|xc#$6#C@O&20^Xi zRH&Kf!2vJ~r4tvyVen=sNxcm9-9O+F@Ea(}bl+{-9|VshJr=6nOCS?SIyVwg#T`%v z=m|Io9)J>6$LCl_I09;*FG6+v510m<^p2Xhc68hDlF@NsW9v5zXvrsFCl3N5L=Q1UU31Q(g#LkzNP;!n2^3=w{dmJ^?j= zT~KTM7SsR^!q)J6*akMu=gwGs@>;%t)((db^zI)cDKeFl8Z<-|>40E;r z#}bj{IvsX|H^8p&KBxgb4`mtOfEw6ua0YDlmPyZplF$+;-6(L>+z()zN>T2GaH&vm5%s^`w`<9dIwy+MW5Xk<2BqC+P=Z zZ}xiC4E$cwh1_4}s70Z>Y{94>*U!%1)-d&3Ch)26jB`1>aP%*c2WIr3(d65~zfY;3jxAycA}^VV|09xE4+!eJ5NUK=g1j>7}3f$B5&> zv800sFmfh_wD9s5QABYOM}I&A9cqX2$(tN7CuA z6`Tx*!5k>9uY_aZWj4JVHY5EploEakwN!1tGfUA24$%IeMnoekg!*6;)V8@CYANo5 zTJt@Y|A6Y~AZ!BNLxzK(_IU?WX>&PlfL_y(-Mey34@;vW88N$d(+>2=~KQ2uv94e=4S)>ovni9bsCh43<=CwbTN z9rnlhH*x)j!)0(Qp(|xi+x#<0-;utVz zHQ`+1`=EXo6K_Sho%j^OcH)2hO(!Ete!eZc!}2${p0ZVt4U}|7(9!vXHDt;s`zoye zJCD4N36BzHQ!s~c4`D0cOeSv?O#Ma@8E4ZAD7%&L7s?8F26dJadXd+e zbVtH@R{zLmPu6R61L-C8(jM^+grS5fe5h^vvMv8R@tK6PNbi9EfnVA(=~oHyBAcez z`N78XVDell%&%9d3XhQBNsCoKHBD^jyLl zgw!vacyGRMSuamzV+fyB;q#`?jc{lrw#nG#YUY|sT(6C50Y~_6|W?4PB_QF^C;*~ zya*1oU)(^v$~Jfc@o5D8+Q6lRQz%;ix0r1IzZdb}GsycHPPN~TCw>HZ`i;~22Z=PH za1%V0@H+8{R9I<0=wmBqQg$-&x5>ZEmK{gBg8T`DzYzXPekWUhENT5llXo)Q?5E5> zLQu4Z{7JI^M-zFR5GQQn9-ToowaQ zSxR|lTjxPrrvv#*h)=d@-~I-vbPNSu;aXcz3*RAqS*jvDgRq{wb%fL}Q7^8%YiQ?K z!d~JNpnjLb4TSrN-$1Ca-&{gm|Ng{zo}k~kgfv1QD!c=qAn2z{$Un(@i!jPIs{Br* zxz;&1K>g+tmfLt|;)4lWNgoeq5y}Y@NMA*Gmasa&{11?METJjkWHL+P_i#T!zn*m1 zhIl{N&~{!$etY6`$p4wpi1Z@TJ&4~=XhC{0j6wYx8#p8F_gyI4NqEDTKZRu^^n1y` z|G#c{hcvUl z3{N7s_9O0YN&hNaX)}b`RQ!cX{~|t-(4KG(>3O!n;iQX6hvAEaf#lyt`WoVWZ5^Wi z9bFtw-ZGm%24+xKzy8F()H+;i(*9k1wXLYEe^K!hTUqJ0#8)W7?@)d(^h>{)yuTXaQ6cX=I*piGx zFbfWX&k*$MPWpV}xv>7PlJtePP8$kZ+rk#)-A*`%@+!g@;wQoZln>U2S|IasB>7_=fnmgrEEz^T&Qg7t-c5 zTW^46Q}TTI-$#X`Ni2e&+QK35ebO#Gl5iLCSD;J1O9}eTvJAt&kuD*Gsnd|4-z_j7 z-Up|_k6?R3U&3kTTfcw*-&E;?)Nh4tcrgswxXQ*8|2MpqFxlomOk?{=w;+8l@goR5 zNH>7b!8ZvJ;%CE0;1}=;cnS5N46tdp*oMe=hEbs3g$B+%n?4a9MZIn0hiu+^#E&BW zjIAq7B>g_&$ObjDrk~lcSHGFjNF-EH;Z{W5yjZw0SP^m;geulWV{7_(Ps}_oIV!_l z6e9F2h0(HLIN}B?+^leMxFT4ZR~oEvMrXJ)OM;Q&kejt2UyaQyjV20< zN`tYG8wr+$;-Oe&C>HnYMI%Mw;sgbCb(EhSQ6H)1BZY2eqM~GgJ1@jD<9t%)A_Tadb^6;#Fp) z4;@QOHs6hf3Zk(>cU`!m#I1@ZVs1{Jn_eD^uCH!GlZZzV?q6r4>OpccaBW^srAvC35yewE++HdNNU_4Z) zDw9`D%}+$)CE-YD@`zPa-5|3gZ+&psQ-- z1tXzSH+@bhSQsw|mWKGuTQ_Hv_vD;uUbmcSJ>B9&D9%h-!GdU{A{H%mD@sCcF-ur; zbj~jgngwIEw?_)XL2pTJbW})kbK_b|Mb-r)z9Pj!WzkB16&Q0-EL!H4pg_@B)im$P z++p5``H_KWTFfnt78i#j#V(q$DjE;D<-u6cEew?>V!_&NwKqk*tLGo@HCxcVi<@7; zqA~trCo@qPj*eIwE(}GzVGGi{2Nz7*yzuqT=t{J#c56krAnIgR1|zlGf`w7^Ho?2<~Qylh1Buo63U8`sT^ z7S~Urd1iBm3X!u}1Wmk%0fY+c=Y%6gW=W`tPGgiu+FBMh;H4cWo zca|OH^*?6F)NrU!TZ!!QDmTlwWhS1JXPB3>z(j(WA9oEK&97?glDGDlv%J>JGraue z!@TO{M|eLjpXyCMcG9RhR>7UDP-%$K)Yp2OSNOY3Z{x9PeJaBRm|HDb`oeOpajd>i1<{JoBzHMmI}$Rg zmtdRfqi~^H87xf@iNx89ejeKw?I;Pw!;AQKMyNEpj`6T0ZfJe5th_XIlwU1aP>~3h zx>(Kf1bdC*RZ`gmip&D4@x_=_<{U4HMzv*Kv?8PC;PEp8Uge6!5kXl1shZJ7H$C6~ zmW(-3_SsN(woI7!;ffQy!jEb9EXL(bc}YG+bft(A0U`8&cKUG*m6%wnS$JCRl| z?)OMhsVjR^6b_XZ#wkYI!W%R&)=-wnpQ<)LMTPzuWfe`kNh0Zu*{g69aebq7)YsWK z>&H@OcqbJMYVF%$8scmzt*U8U_*9^;UD$eJUx>Y{42RZX@QXvk2Q7X1oWRJl_Sxu3BLE4eOQ$bL@5>C&;gRoa7uup7R#vf|>0{5Hp_1Y%aao|aEQ4UIDt;$%FaGc|wqi2F|whMz5L1QP% zQkR3&$q_F}_$L@wq;+PW#QjszI}q+4$n)B+&TK>nK`*rW7_Up&{p0*4(&k*nQCk*@ zB%GWGdoNrWB2p05hD6o<4%v5BIcu6k#x?NPl+W-UEuYvtD_XE76qCLGtbBU2icm4D zgVnj{@}9%vRT$l}793*b*sDT!RTYOcXEe8$GOUKnt+0xFS8)hMiFr%brZvZ2U^TQI zZ(lntFxPu`W0w=>Mtz%&fxv!h-47o^Zn?dU+QTHH&I|?Zk!j7huc=rORIfxABfgAQ zGHs2?`y#hTn+#{({CqcEcD0~lu$Lao@WQcy-6}Y-tDM|$8FtG;2U6U7CU#MhyP%UB z)eQ+0ySbstP^n@?+F*IXiV`LK%ZqGQGoRJsu-H9_-8yt_gYPR?PQ4t4NEckDX`qTlwpKsp=X{JY1G2b7zDrv?Pbqkwn=l zHUQQ6PFwB>B@1**~*t$#e7I>H5)V1v&B<~%USe6XO{as%g zUKI<*s-(~@TI7|6xZt`4y6%|U$KmtlcNWE&|KGk|<{v_sR1Tmum|RRutV$>fuh(I2 zjj^#Lfoa}7iFw}FiTk}rDmQua*DY>-*hqS<*7s^_ceJyB+Yv^}Te#l!&RT!5*Rv|* zZK}GUpT8Mmp|uHLSF#IABbEv(W3#tEsnkYo}rLN~c;@{KQ$2&X+JCzgmrXg|YS zdFra8ibD269e46#AyiSjz?v1-OA1w&ABt3lW6=nk*}sH6dg>^zd2(R$JWfk)xH);= z@MNyHF`4c?mfYs8JuN#B^Hv_}>5V)6xFhTh4f%3CD%H6q1F#-3>ohI!-)`{?@8;7B z8)8=E?##M7SPnNXZ}%B}$LYdJKD#v$a`L&7`umR~gGZT3(}&KX!->VmEcPnS zoZP&W?dQ(sw&UfTHOPD8tkx?cg^s!Xx|zOV;(_BI-e@cvI>OGdl1NpfWB=`FPG$S8 zy~Wv-1{&iO4@bSr&z|QEIVU}-v#N+|JJV)on8jzAjM1|1vWymsXXZ_E$Bv&kVJwe3 zFww`vk#733&?=Yb73m2hM~!T;B+FEsIDzphYc#{-Tr4;0BL%n?hJ*DU#To8mtg_DR=(@OnbCc|SmHqSWo<0hAhiQs`)X~Km8lQg%*^^CU zof#}dVPhD!n4VsG_Db1OXCHQ4Pd2XIbg>UhTa?3muLH;L>J5x5NZ8SNquZK}mU#%O59L{#a*mELc>8 zWl3MWe#D~ngGpDpnZ>w&yqz~hI+SwuaE^=}o4SJy_Kw)P*K2xX_F!iTTB7HJJ4=^y zljNeR>jVA*|8iH9;EL!iy|JK|9um&dP%K~n{w zOvoM0VH7I!-v_>)`ui_+DRHd3DMjDB+f&9#*{)DVamFOKtjcVhj6^(RO|hd>%E3K? z;q%;W{gNMEdv<+u%jTI6BE~k;_YdI7%0Z^B9rO zYu1w7Tz5g{{A_n}_WaD8-07KFS&Ops^QTfZQk>u}KYTHl&Pi@==7QNvGG|jSH+Onz zI3pS>uB$`K^K<5BdzaoevSFg4sOIV01~=-V9mGty8`s`|vEvQv`#%eV{k;8m+>?FS z?}>Tgy2Pum_Ra_=rLMqtAx-L*>?}k0y23WV}T3*?qGVA)}by>j9(%9GIA?xSeb!Tt6J{PW{Z}~k7gK~ie zeg9{e?uUlo1vh#CuW@lzC9PE92=V2)U8`v^ZLGLrp=WsY{~;wYR&N z5|;(62yKw3lb0&P$I7e@auUmFD*a#;nIIC=n@op&i)_ErW9vXErW4sJ1{V}^Uy$hep z@9sOGId4lVgIq-GErs{`Q!~f0BYh)`FD6{dRY!YBOKny`t7>I%c=n;P46j{nI;XP! zm=dEa4q#1&t!!?9jDy8Le9!tXWyax!e=OyYrH(hGcB%JT?bwm2393QtT4*?32W7f+ z>}ckaj6X)K1NS;F`E*Zj&(rgprEH1UWmkHq)HX|HYOi@ycJ)pA2a>E$xG?BDXMOv_ zwYn5;BAEZ{IT$ zy!2<&y~1ZRyqBI`?M>f3Yl0qkCSGF#dikI&yGGpuf-duHQZQ$OTbcxs>3e z<+UI7r-o;@zj5?chcxnr2(H=gv+ui7@ShzUo z4>6kI?b$~Fki(r)ZbD=EQqc0SAb6&i%F@N;T zS>V0#;t22Pmrk7?PjJ2&cPet^x{Uzq4r{ZwQ_r^zNJ0|Vt;~o!!|^h=ck~|LJHR`% zcVKg0b^Ptq?&VqD+LyZx@r{Y(S7+CKUG^svOzk39}6 z>m`<|32F5{`xZStk>5LQn@;1GL^;@@ji&h z{d2?r*3nfd;(hg(&R)NLM|eg1j`AMbm))MrTQubqGY;hmZ(JXk=Rf#R8FVzkLn(~j zPjUGJI85+_C7(ZW^T%v|h;lOrorU$**Q$SIa0Bj$*WNrfkmHSb>-=UZTkY+5tN%3A z-fTMGzie-Nd0Zc3sWV+}X8)?1aoEpnzVOxSrMEgK8K#b6ZWra+a*UL(v92ij_|7 zjbP2k@7>bCtNk#4^GDsh=^qVkiCI1Dj$ZlE%ue~(FRTIT?>nZMP+_QgPn$rmp{{jH z)4o6F|K*u}qqu>rJFS-TYnb<~@(e{phdf<{v(sDE6_MZ(RCS^tl+y3^Zuw58S= zf3Tsb4AwAZ-K9Y(ZQxFkU_RVqy{8`TG)R9BVA&axSvfa&_!jG*=4MzK-edc_c%SVb z==J;Ok!iV{4E~?z|KQiwUE05Oc-Q`OOEVU&RK9I*(#Jif*}rE?AyRgMG2z5!>3yx! zZsLi=l_BI`zJBlC_U2c(?PvI=zMdD`yZ@7*H~Q1Qz2&s7@58R$x<1358+U;^y{?2vnZT$4ewtS-DrIyHh z?$c$-8DX5FJWC3&kvRKU3GEiV)@$Eea}yi%;>b;S1^?RXE&A+EuhD^} z6a0s0HVxu8^Y2=Cw7G%ihax3GMWgPLc%pVIhRDfhe;>ZBcx?`*d9x4p@YWr?%`{XSFhI0_dV$gdNU*eb?kq?!+Uo)7?*e}h6D7m9<^JLApAj|Z{kEe)^~yk?*I`wvZqOhwvwCZT zz=#3a6~S^QgaxYq&j+!1wcF)s_wPN`!G?h=yVPC1*lPKye*jkfL&Lx+fwbzfMuB0i zc^m|-tFBHb+Y%} z@DFZk8z^kjLcR;L{i{D{9O(Gqx19s48&*fV2hLlPjU@lL271@vPx0^Uaap-Ke_{~V z6+=$B6J6gOudKM6$vAb148{9ZAL<@BaIA4eNzr|cJ?x~xGw<(6nHiM4&NEL{MCbeF zYqu7Ka5)@y&dciIbGNp-`q`d=3py_Ovy=Y8qk9FK1*+Hd4jf%w(>pM@rBS4W&WGy1 z^$whYKRy~W{&?=@Qgk+!Ml>O`Nwi)mpFOuaMpht2w$$!N`WyLxlqz@{eLXz5#J%jVf-_9j{VVp^bo5*r)jY00G$Q3i3r=T z@e$NpCb~|!xuEC__Z85obAxgjH;E1MiT?5V+~3i}`2S!3eZ7YF%hQ zceAWqd<`pOdUwl8!QNOA$74O5fpsk_Vyz*QM8zA}3g5*4A_>*wI#?DvVtE{lro0EDP%#=3!cYqKd0g*T0Io@08@@SsB72-UEwDGx@b&+?;2vJDln6WA0lqdHc1klV3z)N1d7y3ehs z^Et+;Sd#Lr2pNrhzHucUVzF#84MIcQh7l}EyCtX&I;b0OMh##WDpJQ$Bl`+(#vf4a zD-Lzfr=!|updu6*N~RVWKdRvpY=KXq8h(tr;m=qa|1{-N!`LB|D`8n2iMoC=CgVKR zTrWqBbS0L@4XF0Jki{FZ-X&9yijPnY|HM*QY`A;F6x0PxP%o&isKs_WYPunWj$J`*!y+SeKjyza8C}>N%i-;)DGA3KSk`^0_rrR;1^1cr*RUDo zk|W&?cSJ>~A1V^#P!B9X4d^~oc2EO)5^vJ}f0>LPco5s;NmR(o-|BX#25PQbphncy z*ax+!hG0b;gX&-iJK^(~f)_9ii;i;3Em7_JVWd2nEOTNeHl+Lz>PEXzBl;D!_!39E z9lOa`8P)MRsO{An8{;jg_LEQ}4;t?@^@~v*SU#Hgt7jXj(CR&j8sRsnk)1ktSG$a(k?Vx1u^a4Rzf-OvS~f{%O?O+F^VH8&EzrhWM+8*QijBlg7IHIvI6A z8dk*&Q$G^hP%c1?cnfMSUqlVyAXdR+*aUyXwODPOWmU($s44jzE8yh_89lf-FJw(c z8SI7iurdZv4Huvuw8pp%YfydzwVKbMR(;Xi-4|2_-b?v*)LJ@&iqu8yfJx)s4n}&C zX-CC$3f}hW7svGOMX5n(c;kJ!%B6<6is>>)@k4cdGWGZhR26Mm|Q}_$;bpznc1_ ziEc+Kpr)WMCSX(4^{ucZ&$oJ$(Hm_zs;9T37Fhtb8=gjm{&{SQdr%Ml5%u6}sF9b* zai^v>>U=|EYt*jjhU#EHRK#z?h(Zx2qs6w=xDh*3K7i*iVUqh|`Uy3HG{4*7=BNj^ zN7WBA^`lT7$U;4EDr#W!%=u-g0j>5Ef1TJ!g+jj5oH&NM!B^MlTi|X)k)M`JC#qlpp#H6WiXiKBo)x{>*5=&z?YAqFDZCn*0Q;W<#EQVjB zMs^MrqEge`NL0Zz$_-HUBQP1W&G~#(N9STSd;n|UW>Y?b>d=2s?S4ZIJdzM{A5aR_ zAQjcahFBH{pw@^Nb^cCNN0*>R{0OQeYp@7Dk45n%)Pr{896W@IRQIrZejsw6h&6(Y zMm8Qb=MSQ8xZ0FAV`IuYP5CUArF;oBWkvGc7gPr7fdf$IN2A`HdDs^3!Sc8V)sbT{ zng7qpsK?)-7U2cdh!YFkj?~81lv|+APewi99*jl6l-Ho{v(2~@%TV5r9z2Fxq?fQX zCQm1-+W#Ihm^;Z8*(RK(h$wqH-w1Nx#mItkUmxu}RP zM2-AW)Z%>#BYMDoGU~ty)D3^Y6ufH6$ur%>QWe$nx~R3#8a2WmSRO|jbCK_nH5Z%V zDO5+w&SJr0Dk@TuSLzk~SeK@rnnA!=l6Q6qXDH6;g7BR_`f=xOYNmryrq z$r4uwI^Zoh0ks>}pa%F9YDzcai?|;(&`{(~H*+6WqGBa#6>me0Y%jLMqo|G)yUSe* zDX1IwL3L~(YAVNLWt@Q?T!y;ei>UkUGv)VD1BrZ2CY8*2REQIah~_FCwI*7lZrlge zW35;{FyGYQXUdPE9<&9uNMAu+|CK5Kf{JM6yWQudBWo;TWs=c_Uer|NVOyMmX}A@2 z;g_hn{ROLGg}LqsTA)VO4Yj(5V+|aS8pz$KjxRvnf3>OKfTel9wS|o4a36NSL)aOM z%yVzh6RS`jfNgLx>Ot$x`7Nla*p6B|yHHbk7&T?5QP=;3>S)P(+#jFSG4}oMMMf76 zLp8|4npl7eeIY93&!Zx>AJu^)7{(K*5%#&4Ur{&?6`_Nuj(vl=?t9c)x@_vx?<4-& zhb_p|!3@*`Z%5s5I%)(9k-4%8Q5}83xX<`5HlY4CsTK$(%7ZzLW-l!%fP;P*_J`EGG z1L{6Kup{1z>Tn^dgHM_BFJouQk;7z4kSV^z-IoroxwiVg8Jw#1UenU|#11*_s-)crrjN!tIH$mj($=0SJWPDjn@3iRL>R0Q5bjr1GT z2!Fwwu;@~E(N#b#-WsU(txdTvK1S>(pjQ7s4-;i<_lUkN%>N!T&8hea+hFZN_g^;0 zU^?XuEBP6PAEHKf_oK0Cwl-lJ3web_yLVLY? zzdER?>W%8aNYs6N>xsW!JOL`SJsjgI)LgE^c-(2qdr;f*II6>+pl!Bo_M>SJ}xFpheX_$yR1sc4V$P!HaZ>hTHGSL`fmHUEx!VER*Thufpd-B1x3 zjC!yiYvF9{h>zn`{2cXa?!Uo}z?2A?ZdBx>ZnP8i;5Sj*=_6FA5}$TQP#X2(sEyj5 z?NA*aii*f&V+a!{&ql3@2%pr+~*)Y^#rYBEXBxD~0W2Q)@4s;<}whoCxkCu%J$ zLA6_l#c>DL#MiN^%|AGx24rt^ryv=dQePEkVgF713z+u*A@Y8IBro-!iYd4sJ$Moa;BWesszudnm)l?(YE>`9cDNQb=bxDKXHgyg3pGXM zcf04?VP(oaur%I|wQ(vA#ueBUzs0(kvd4Wdbcm4A1LmL}yZ~$B8mxq`qUP!oREWPp zb>MKUl_#}d?a52GUV99F@|elqI7XQ&5#ht2S3R8MQa z>fSIDb-}Huj^$$-K7gsX8yn*ZRJ-`s++9=yb^pf3w%CPocVxg3Yd#rmqsK6IBh*}< zK!x%PQ-1*!p<=JQi>U_cf%Q?>4MUAQh*j|})Re5onz$Vm!IP-=r?H&&|6gR(u*?DX zH{H#sNMxfz7eIw<1}Y+pP&e3$io_Ar;yQy`-G7?%W!`Y_+ZZ#b?}@t4T+}vPfu(r9 z^#&P@^aIo!eS;dwH8sFu2i-YNK|P?Mu_Go^9*R9N3-#jKgo@}6)O}8%267fPunVY2 z{D!f=|Hr?{i-w9+9EIahA>4s2@k7)|iyd-DPzyEUR#*=EqgJ;U+v9T7K#rmY@Ga{4 z%c%E5skhw7)Od^d7p0;d6|Jx{CgOBdMD9Y3a49C?TGYs%MTPJXssmS1BP(&(9a#lb zq&%38tx%E4MoslJR0kIyCjNTRdMb)x6gBd_sGfd`nzQr9f3P{_vTwUL?14#?hhTji zgKB>ds)LVUBYYDF;LoT5cRu2FpjU*9LUbGI!MUhNEW|CiAM0V>J8lS9phCX|b)$o* z5xs+Yz_+H{>Rq>fAga9|tKnR%fori2M)s4@9G^jrpz?eCWdXZj3H$`Lea>PgCLVQv z#SX@{l$YX2d<(mv=Y7@>PC#AvDk>tUF#)fjLVpdJ%7|6wnA@|asBJX>V?9Roa4sq` zPooD9phA8IHOD`qA{6(58=;0+mvUDuj+3w~hOrFJ$BwuXt7-qAB%=^sLESL1>hJEo#EP@xYEMCD*m~_&ObZ=DsM2u)r z%py|^3$Y@uL)~~MD%1y2@AUUj7yf{{?l07cs(kF8&%oi7M_@ynj|}#reBu*#TUYM+>R&ViYUV^9rBya=^x4xFg*8rTc)BxRZA0F%dU><^E3C zf)^-%hv{5*;*=Y~BB$MUWigfWsi*_t@EvT7r%`Jp`K-JDYoVs9JL>vdQ5~L)+&5z7lPN;Qy{I?ZBGdzx z;Zl4Y)xorH-Q92ts@xM>;4swoU4VMPdgDu|k-v!=>0hWwmi&&_FV@Af+W!;D=z+6h z75tY?)T&;I-EaqL?*2eMxZ?M&b+9Pq=BP#44(s4x)MA{Dop3SM!Xv0iT)-rZ`w#7S zzEy&ZZsal6*9pq$SQEQpZSf7F zFbzIL&E?nT{2$nq@=d?HtG<(QFe+kM*aByw9`qb)q_1N#o=TK|P`qS-jZB$2Ep$6RJ zPvYN!%&k;tu|0yi(NowGU&jG>6&13cf4OU6D5|4Dtb`9@HGBru?jW|p4^R@;+m7w)DyTIv z9`)jyf{H{KwTl*DJA4$?vEvakRmgm2jE}QpBdCF8sBer4c}LWHpa-h`cyoRZDwGeR zI=T*9$UriBzy#C}CteD`A zJb=|H&&Cvd6mQ0Ds1bi*ynu?-Kd3cTqDbui5i6BUV=5Y>=4=%9!lk$jPof9?Mct7s z!bz03pdwK*(YEwPY>p}qLv7=!s402`^}sEtsn~-W`7tb}{eOmxR_z7TASubcuo|W? zi7l}+=ldnw)+&4e^$u@P+K#=j@~{KtjX2q6DzQD~A!TjrU0jM<%;U=0)(TvX&9Q!Y z+iJn{tf0*Y*8P-& z$WH_7Dr%8^o$3zo3O1meT*b95Ml{D>GR-iI+MgRxA=-|b>$gy$^zaY6S|e>xzYqGO zw%ZWY6yA>2a0#}+O{V-MYVrMvHLyrEcS`D3vm>#&ZApbXG92q*4%WvdsQvvCYB9ZQ z{1Wwmi>Sq0ySi&5)Gld;`ds%zEy_WtRX+)tQ7eEN(DdpNcf=2vhL58{wjDLH!>A6N zK&|SFSP!ezuwyT*uGp3GAk+;VR7W?V?z0=!(W9srT)fA%460)_A|}%Y^`L?1!AaN` z7venp2(`^_t*KSZKSrS*JQwxCc>^_dN3jWhg$j9cE%&~ajLlF}*asD<$apfk!5ydz z=9}_TY)W|*YFi#b?Gn4TJHjfc4mU=Pya#%4EUF`SqSnL%sO`546`8}Rk$;Ljm;e4p zM*H^iFxX*B5@>exlr_oVB`3np^{Q_MBoGWOT8TceBzlj>hkx!cg*t3x8D|$06FCCut>D#*@g-w2qLzqO4;k zsS{}pX&Y(2sejYlPcNX~NW5xdN2D{E1vHvOijdN&{>7YJj(tcOqyo}<&b>pMmyp-E zl}q9aYt1K3r+zT*QXP36da2!b9H3rnY@|7#6-WGkpsOx})X{NrfA%gTBc8{-qC0i@ZKf5)xZhYJQ_ZS;~(P`@7MkQ$Iyks{Pxp-q_l zR?;`*-y-RFl2nR19Y52qfc!+vizEJjl4(tv;GVZia`FMn?~)pmKS63pn!@=XN$sis ziZsTwf1PvJOkVUh`G;xKo%~RIo-~*=oD`z2A!!)-Gn)SvWIjQ?POl&Q=CC$V@S;8- z*`)7y&>oULEIKxl*Kx0L68WEK{}APhGbv1xRjJnT^C%4WoY}OY2TBwjvq*`Qulx| zZVC@^!b8&0-P9@nH~EK@;TY*+J&RLKn<$+=&IPWhLV2b0v&Zot7z1ObSYNDI^f(Ll*i&yoJrCV zkGGN5lg3jX&-q8l-$}ZUG@G=Q)Q@wkNcWj%L~f^2N1luIg&sgUPT9eH&X*?d!(Ei? zlb>PQ{z_d>bA1AJmB?Slr_A|HlqYgcUCN_SM?2~sCVxNP&F{}xSW5Tv!Uf zrtT>D`^mS*OQesft3`PPzD?O7uVWAjYyADF@uZhkv~WBm-eHMSC{&3q^YDrz5h#*=|H1hH24Q~BqF~~WB((;^PJaF;9`Bixh2%~#VO|8yW}U3 zKSb(JUdR17!sOi=_s>5eK>CfgJvILcG%~OVBJ82y4uOF|`F0zO}Dp7d~HF8s*-=7 zlt!Bts^^%1-{LWn2J;2ybnL}!QYPs*=@sgR&~_z$MG8@urAxM%8+~Z3#);mfeKcBu zjZnua&VTP_t%=3~SeZ7JagnJLQR=o}ajvOFz7{Um^`xVeb+jb!b{QIzp5w&lxQ`Q? zO{0C-oAPDSBcvge&yp6??)vdK^*Y*Xe_R(Ql^Vaa^Yc9M?=+x5UUzn?2N5B>w>?f~M{x({?fC40AyZ@`WZJ zxs5`J>v#G;UghG)sT)Q5mb{O)8%Up0u5H@hY##7A^&gYIr2Hl4p1};t6>yqqTimq$ zkaA1%pXmKRi^3PCK|B{MDWjb7R&J|=6p-?JxG6W{w>O9NpmSr zlcf5j_ej@|XH0%JHq!pjH5Hv{(VG0z=0UpP1@cAl81;uueTf^+t4(v#?dBf;!DXbA zq+&O;+iT8GHtM=dobRA5^EPQHC)SXblYgJ2qY({`;IbQT6g$Bmx09Zw{xQ<0Tz7$T zJ9G1j|4(~e*O2;K@nh1Lw0(!No^Rb`E<8^@kIKKy4JK2*s0JKeOdGev|1690-?UqT zZ{T3OnUm+twQ9GWx^brbI=*l6FLUk>&OM{QV(eB+bG$<3aFUMCBf-_vDKrl(uT zm+cGW`@Okgr+?;vj$J%Md|AOzw$;Tm#W%~7;|usg-h5xSCm8VLPx5)fzR+}E$de!R zWCa5OUsgV~{;((8H{GA*OLxv?zUh3}r->8pTh`gsw@hp%wDmraQZ`=8L=pPB{LG1f-^j)=P!3Uk2{n;G356Jvg?OEgSvTwu~9PMfG>YW zFf_&E4P<+!=6Yv&LcY9UzAtx{Q}ed!^==5szYS*i^Cx*`1q(tR_lDt`PK(>_b>6w{ zvGSp*K}FN!3rzQif`L3DACCHNFSMOZZ?ah&N=|g*l4xemTH7h*Kjkd{x_li4BpC2rpR4e{ zqX_>yjC5!28Z>rqWM9 z6~0NqaDKGh^!o7)V#O@VzJO=MpaGt#q2P3XHmi-b7mKG^Wzpud=G&>6#E?*$7+ZaU z{(#5p$?@j-oE@{b6wjHIZ}s!%P4$MHt#@o}f6b$)i`)<5r`1Ivp%YfTW< zTwmC^wy16u_L#?;o6DASr$<{h+c(i$kelxmES}+%TQa`A!mog7_``Z<4%Na}FLSxp z^z{1D(dDt_f4#@i)k`kNIWH_7StL7{=k+^(E#2#Qmd)(%%P*Mf$@Wj2=+EL&v#g<$ z^u~$(8JpihnFF|IHqYcO^4}MVH#Elw zmq_e|#H&Y}HFRS)y#Ws^*tHjj^9*!+N9+~>I-11En=R`lmBHEk#3g(1$zFHCMXJkuKKo8TFq z>FJV{MLevLlf3z!>|ku;%)%AFsIS-oSxpcov_q|31v>5-12{`Mp#% z&>qxwq|neBR`_b7UCG)1YS(D{*QzE&vku;GN6R0IFwR)NS=PK@^gYe;@`NXY(dBO~ z<>mhNJH<1zeY^)WifH*G>*AsxzuU@oe(_gyl8z2dabHxP{DSPDd1+}khn>+!pG@-R z7x?pewlntcRHyp;m7Q1LAD66;S^j}&$gfT9$#Rm9^>J1m>*}O_kX@4Scysv9#J+1Y zK8V<1=jsQUX+iB28W-ewym|f{wzDtS%V&TppJ#HA`E|buybpNcjX7SWp)XBut^bDQ z-*!ZVi76m_x)I;E;PE@0e~url*d=cQpU`{)%WsI-w~o09M-P1XuI+qsVrbDG;jDsC z(7E~Kpk!?{x|8qCokVEE&g_$cXvvSy+R?h7Hi?go{)V&e>}UI?7KGfmI{m(QBKrFm zmE)War!L>pBb@J@;LG)8@vUHneKU!?H7v+&>E`uCljnBdn|H%7ed*CxzAj=rS5LP} z9YQ3+o{4+~4{Vw4CT6?c}NORLpi-@@1|@nM_l%hngg>K4okdM0X(@pXt+ zIKLvk`cOXZ!K?$DJOMh-4+NHaKA%~3P3!~t?8WWzZ88H4lrJ=M$EVS!Z9y0Gsq}@j zg1M8}DDIvq_+NV?I`)^Nc1;4q*HnumHfdH5_x`>-tq2XnX>{d-lHK@$czq2z<9>ak zg_`l3Ah!4Ny#$2??tVaHE8vDb7*?y;7u0k0YGp0yVAzUHGh6kBsq6gP10{R;LJZOG zuC*h-t#-n{4|l%*y+^d|wNSj%{qI)MMSs_`x7W76uq$-`?=}x>A^5ajb`*=Vi`z-v z7)*cw?I;^>-(r^^9@erBhl6Z2dKw#OVZQ{saWccq3I?b0UWx6?!UYL-%~Jem$RQw; z3j+DUaN(u|`$*wEMeI-QP~mMw?L&pV6YYs*&EDrj%Z$WcK84>V+Jh4G*>$=vZdsU@ zWY;bnlw?kt-?q#+t*!lrWkt$^cXsF)m6Cz%(Sro)p}*sHW%zXQMH4@9%|I?zXnUuRl-{O0lTlB;{RcRC(;cnwfJs$I3MgCf%0}?Tw%EsyL(ah zO;Xq|#hzYxw31yl-qRqf@T(NNf?d4uy-Ieq!p~FeN;?`>vWMD*2P@m}6>dni2kp3) zYNsZ4_&;MU_;0^i$c;U`!sw(gZz?-jU-)o=mjXXjy!ycJC|%3;6-^lC%k%EYZ(v_c zC`?MX@7}Q@-Tu`s^}hmJcwbXGm(t8$7+1V_;S|m{=+by>4iI#Of?r3OP=}DF~t($T!t9XcIRm01%DlWuCpJm;Q8Mq?dvR0A* z@ldZ^$uP^RMS3wta3vOCmEo4fsjazK6IWn;%ZgcRiBu+I2cCy7VGVr8co?gYK7~3k zA;YrjVI8cF9Z(m%5ZmA=Y>g$zwXM6bB7Tk)@EGcRC$R?Cx2%h3J=8>0o=oJeK77aQXSR9C%# zy3i-66CcM0n3PHDu?g13u4v;>RJlCVkj+C4*$p@f?=bm?QA2$aV>KDWnjxE zA>$+&J+adm%i4^2_!!n7YgyCrWmK289Y?FNA6CJssGiG1wKRKFQ2gyjo3ga!S5HqkXZo^ji3F<^i6TAylMpdXOs)xE`H5`hn&_q-P zFT*4(K=tV5SPSQ44_p=_Qin(>>V)rOcl-`@V9SZ#iIY($?uqK!L8ul?MRk1uRe>u_ zdMRr5uSGTB7^*@wv%Cs7K^-6KL_}TM8$079R84O{)pR-5!?majJcHW*DxQb?Q9baf z@i@N3oJyMH?SE~u=Le`E`4V;f&sbjT-@4e#sDNro4b+8_u>tl()qD)92c}^M%tMuT z%>IW_<+ov7d=p#Xm)H#}P4V{kL!BoJ`|J9CB5LXVs1wGqI_^b{`A4Xh9>O~K9jbid zRLf#oSoN?E&P2`fb*LNBCe(57peE-b)N$XVmS4g&Dxvk?jEKf!Ha5hCsDgK*=E5dy zgrA{W@;mB6T`uwLjat5Gcs^#M$}hy)co%AjHkF{W95+7xKPT2oECqsD3w zsz=77dSsT_zrdt#L{;=3sGfNQHFVFTF8C()#m`V(-t1DZLhVuI`d>=_t0g1JkP}gp z;}X0Ob8!GZfU3x6*bz^gbn<21yh4ts@Qv|3zVDTov$WpNX|z!us5oKX{e#O zC`Lq+WjboIT#Fi;6{y+#D5~Z!p$>c>HMWOL{%@$`s%CpO!er8IQ57A7s`zwlf&o6!~2oR9<#n8qJjyz-h~<%J76>N`(tyQj+*@oaWOuDS~b1%ysjRM>akJS5A#qJ zT!+2!T^x+n^Sz-Oj{~*-uO_0g*p2G41E|^g1*&2vupCwjdgZF4dY~n0C_Z?HVqx604tfrC|17o3FZszT#6sIFa!8iFTL72J)Qq;Fvt z{2W!T?ksNzx}%nDCicK-sO7o>b^JPvX>vV5BpF}8KKLD~=Isi&d0;wfC=Q`o>MOLY zo!AIl;rmz~e@9&)smPmT^-23FJlGN`;~^KB38j_sN));hO{GA#Wd`UqfxVeA*y1_LiE3G826H)6Fq4P>@dE9 z70Ew(QFp*amwOG{g=)|XsO!EFGdqr$9mh~L zJcTteG3>RxF6zKmsFtRpTGj{K;c%>lVN_3DiyEria58Q}8_P#nY1kHZe5^eYEw5gv z6Hh_S)?6Hii%=~&fMb13x~O-dI^$# z_%^Z%V%8xd8vEZ+V_IRJ*TvOQ1zTYa?1q|bLr^!WAhy6|sGfNY)sXF|mVJ!s$)ng2 ze>VB8=X;Z{8=j~2KbVMWI0ki#oQ9h1^G*6rR12R%o$z^7#rB~p^iQmX2@AYAQ5Utp z3#wuRQ4PBYtK&FK#Tn?X|K&tffmNsrtTpM!@e0yAQ9aa$gqB%4>cp9-@>!@8XPa~c zQ%El~=}o8#ZbuE#>sT8TuB88UU_BzLKpRxSuBaPPI`+b;s9Ap-sv;Xu6?p<1;4|15 z-$9-42fP5StGo*KM3ozYI{zi89x1+x{#V9aG8*9$)QKN7ZbGesC(yMY|doKAWn zj=)cFBzCyQyXVhCo#5?{jB3a>?5*|x z3X!g4B;Mei=mJy)E<}xKCN{)uwDC&RiS9*Jcq8hBPnrBzQ58Rcnj2qY8}!}i4N)>` zj&#O)T;Cc(L^Yj?x^R(pV9ccNKwa=5Y>jc$2|h6CZ&5v1`zFt1)P?(@Cf{VNgvHne z=cAVOBN$Vc9VVi&JcG@!$r7(XKU7O*qgr?!R>oDRa%->xmZBQ47j>b7CjAwvqGwR$ zsxS2#SRX5rPF_m?_aoAU3>65W#&(WL-;J77Yq2NpLRH`wvp;c}H)K^%tEdKQxi-V9 z*b8<1P*ekEU@}JWJiKQa{jUQzngUOwdSWkX2);!PNz%>U5Y$CgpgC%lq@Y@UKjvW^ zn_%Nxyo&Y4Dx~{kZ@kFlFGkIY8)HP&;>S=Id>3`XW2mwF6RTnUTRqztd!Sk}1l8q* zsN?5gTf7+q_#|reoWI=b;pwQJ$VFWzHkXKMxCG1LO6-C6U=7@lUGWQ4*VbR*t%A1HDEt#Xgm{s$3BAtQ=)a5d_{Cs8Nb zhq}SMi`DT%)MP!1I?-|LkLB+0DmDabk{*lNpN#`?HmaiAP|LFvD{_77AQ4^g2!CMV+u0D*qzvf|Icw-h|!p30#0*qi)@SRo;28!F43Gg7K7LkPht;jv5uD#oQzjv z!h@8-#i$m4hbJ)QA?|u;ulJr2udk>7H6{x=I)qEH6n{f?<*tq1M`K@Vp&?j^YT*iO zi4U3dUaUg;Q&f)~LoLJKuoAZ7syaRuGjSlQ2baZ&=)k*BUG)%Zay^2&z!Rvhe;?c7 zH>e8K-Q+E+_E?{EH#`qVpvHbOw!jk9c~+r@ZU;8Ry;v1vhlx}qaspf7Z^%$t%^&ql z#(JbXVImGS>5EXy(~qif9_oZ4RD-TG=^IhYb|tEU>roBbicPitU-lx_5mZ-|-|U^J z2CAk_u`l*QRip$*;8LuH@1yPmN3c7dK~<#lW8S*&k802aRQ^;{&jiu^{ckCeVlwuk zRzu&%y&jo@gGpzj4txl8!L8T@_n><26skpkVqL7Y#jAKSs=|X%J#?|rj~ePAw$=JC zBBB%Cff~D)u_hii{%oui_b$*FtFylgw#PJ7#R^b!V*#onx1#Qd4`OqC94oTA_M;l~ z@e}mF#^flG&g}RZ=V9`bJln~CV4GKwp-*{E#(Ct=#AWye+8BP?+wWi_((6zc*o7L> zeOLqEH-3$}?&+r)f9K8lk$p zJ*HtA>O6~36}lbOLu*hy^VBo+e{UkCX2De@O;v{P-FW~R11!wE_@oNV)nd<|246zJo__AMPk+3FDGI?u{vC`Cjm9*c#Pkla1M^ zE-FG*Y$4Xdm8j*k5j8YhP(Ae$s@xZ7;~5->=fB8<4MV6NIf@;y&P%#4F#ZFHG$3Ob zHo?nG`YP0rtVCV-anum(K@G{q?2jQ-xkphI+K$cf zO^m7K$BF2`Dto;ytc%J|K@CAV_DtZ1NmR>kdEM*!dr=jPV@2F!eBF2eH6)*)dhir# z6;)y)RKeE!=zm?f3mIMTLR8HQu^Kw410FzC=vnND?_mm-d&Aq`6}9|EVH!?Fwe%ir zigDDee;f5I_!g_<$u}5(?MQmlJD?ekB;5lw>la}Kd=_=V7g6O;U^T4vmN!&QP+i&+ z2jaz88Sg}uUx!+5yRaX=gBs#mvA4ZXtrS#G1W;WWLiNO4)CulI^}y4p$@4a9_8&F- z|3saz(SGl>yF1n*eF;{@VyuBzp@!rZR0CoUngUy~CK;uu6TfRbYWx$`Wp!B0L$MXA z%V(jwcrNOMD^U%25Y?c~sGfKl2jL#P7%RN%F58&pC(@mat57YBqiX&ds->S`ZTuNK zW0eEm`#@h*OXi_2d@J_G`%o?Z0Cm12sD@eZnJ&j3BvY`0*8dD5>YACTnlC`LXfbxe zHK+>gN6p$JSRYTIyK3I|T38qBkZ)ru_CWPe4r*vF$9lK~b-f3$64$rlM05w+iK^*` zs2(_G{1dy9uJwU;!gN$c$6;HXiYmVdRpC3ZJ-&$D@JAeltv~c;e-u@b1sGFTtstTb z+>h#-owx~4U>jWhk=K0k?tG|p(6|Uy{vOnwbt^W*{iui4iI3@j zjeUcEGI4Mus>=?ey81Y3S^a?-Xdm>J&rIw^dOc3UL#TD%`x9n7EuFp71b$n)I1B2kK*g((O43#NA*l^)D0-p zq^F^}IE3n<%_jeKll~Mdk^cwk!WF;rF5C>YdQz~l)_)Hoy1-b}iE>aazQ!%U?8k|u zcVasqt^V433GH{(dp2B)s@O7YjQ3z=d>U1OJ*W!oLyi4mR8ReZRk*%Y`y20$)eJR> zI${@0M^$VN>IQTZs)G08#kd1?!FtEMf79uT>aiZ!8i$}NHWM|Z^U;rMu^tU6|1JH$ zgh-R`yc%uzo>yWDJc|`@&JW%#HHH+ip2k$lum92O%J+_Y<-S18fn%taRyg79Z;BO3 zcR=NLN5YpAFvIcM%}2IpY%@L2`?bs7j?o2s-@SW zdf;|c`L(E?*n;ixebjXlfAa1x9e$$!&m*HJ8Jg8YP!*Vh>iX-kIo^r2aR=&xZ=x#r z8S28vurL0G<+1lEZ#fP`oqsG=!RdGl&O}x4bc{%4A{Br3GHRpNd2`f-E;NqB3rJsz zYT+HIE?kEL@mW-b|3p=&)-T=#TcGk&QIk3i2jgX^A&adgqMAI1s`=|!AKyU@%`vQl zHGlP%TN_l1aWL#;hu*y&mX@x2+jK((a&8Ly(P@j}x5uouolmD_@<=nJSD(A%gAy@!qQXE&emul<`>;|8dq z=!m*tFXMQuOS%Yk!o?a^sJp@N^eQN`e zk@ypK$H9MilV>h!8QzHMiRGxSJcGJJCj9C3R7F&mH$qjY6ZXMA*b_@IjGM42+Lq6K zhz-D)4xC1$3to;LaSc9=Z=sgU5}(hVY;jB^{TgaEziIp!)zYs}tHMg~xfQC6>WOBk z9=!lnZY&PLV1h5^w&YPVlF4`id*DA&Eve0aX{rO8;v3i-wHzxYd3HdR8;K2ZGHMQl zaUed7>ZxO>IdL3Sp>pMX?&LkcT+D0X05UX&dBzf~L3)u%--a6d2k?B{jOwx1P%Zug zHAgCz_qk);6qW9S>fte{;{wSD~KRSL;8Eh-&Phx_AR>mOqKQ;7(N6 zy=nG;iyG_GW`CV3KKF&AE$Y58**Fu`qgP-zT#mZV3#f{Hgw^%@|DK4(;!nH^tDWav zU>VLM{Sc}Pn^*O@HsvF4 zsHO9E)qU>!{5n*RokZPWd^Nm^HbG6YzNq~Zu|Lj0&HmM> z^F4(zWqeCS>pY477FKWyqsMT~M>LK+A>b|hGF6&>yEqoVK~-sTE%^cSv35hB`!m}Ws2+I%b%#7;(us|{W!(%l)aj@TPeb*5Y!(sqz(Q0-9MpPW zhnlsUQ3YQ?o!}tqarzV1=jn7B?<8HTg*V2V@IunZQIk2frO%q_#EA zo!0Ioj9H%%xs8naZM>F0f?BVi;0sv0t$+5at)W%X_6J*;xt`>f~K z-?W3*)d%r<(%<9PI48yD{vQ$i9ewUoaUa&!`Y)gAwX_lHA=KS?32LmaL-ouG?1F1i zuUH3AJ@+MQF8qq>p|PF3xss3SsjEmgK&HltenBI==W0Cfxf4%M=Ru3kl|qh@Y$#3)OW;PcBCmmikc_numH^FGd!DwLXDAY#(}9Z=1A| zvxzS=`9hO?H{n4J)ckY1<{L%WYLR)I1D+&)FK!{^kf$}SEk^oc{8n+om!!2_MVZdT z=U^YwJBW`Wj3b>vs6|{oQV-w3jmQ?Wrm<6FtF0a3Vs^CEtYjPL6|z1>?kD;Q&)SHe zyL%~5xue9h35Uqn)}Fwv!D`LET;i2*CF#$I?;}*_TzXh(UNvR@x!bM$t-y|72-mG{9rJMizDZsS%H2n*3m2Mb_Kh^>DJ!~;a?g?1f--;GoXffeDPni5)iBBYGd(^m>vOf_2p1gX*YnzL{NL~?P zJNdeg>>=KS{jqucDcdrM3@1Fv&ay2^q$?-++2mbdcBGL0lhA=MkUZ|hR{e8Y$Rcr9 z!&WRH+(4Q6lr7s*y|OVYgUp}F-9i{Z_=dtqa5DaiW6ePykk}GdNi9Y zWE_ufYbveEl&mecZ0naB4CH0{S5U+xIqUmv3hjc@%MBd%RFF7p$_h zy-XNIUR?_HB5WhwgbSB#C&^pD{woQ*cUZR&wB3x02v2!Q>j3etl)Vj`nLP0$dG4n9 zH-Vkknv4#lzw&mue}CZb683Am59^#$i9GUV62jy^WXkm8JWrZ<67kXIykF=9Y@d;S zT6vTiNznDJ2MK&rSPR%uh4{C4iFUA^G$(t{n83c936GGja86}@^NL#m_ElitkE9P{ z3Sk`a)A$P^pP+34;X2+ytxHVCVh((XP`0f$k+x?4H|OlDN&FV}S0mg&d?jX6t^pMq zMtmK4R}lVTj=chpkgkTC*srZQVZ7G=a&|0cM^z3Ufc@F=7I|gc??f7reunTJ;cCk4 z#m(f`CO!r~Aj}|s80WDsPPm)6woc@CC1@)$?qdHGWvKtvi-lyAZ7&dcmV$H1J5BhA z_+IkX5jvSJKMyZ8Cu>f;gmQnBodBzmZp{9xQCktAko5N^tlWjSmiR2WBAg_&Bfq!V_p;RZYx~l}I&Lg)E~MltlUIqncL?{J6J(kED{(4$-?Q&s zlP7ALo=r9??xvyNdh=C^OKMTS$5y`OUGR$!kx1 z4e`gwznyq>;&H-k!XCmCoEJk9`zUZZmLv3ZYIdvbz8>$P zXeyy8p&RXJj1NK;3+aN+R2i!w?4t656F&CU`%CsjPB_4N=;G!mf zCS|WRd12yD5w@@|f$%wTZTpG$H{~5Y-Io$>W5;sB7sT(t$0@wq96-|j?Q|&nz9#&n z46~`o9pt@E*ksC&Cq9+@TX70`%Lrl8T}YR0Hu0OtkKIeoON3N1eo!W%KIwzl7B9kw zu^E=*fN_N9h-;gITX7h9LkVjL+8PkLn=)IVb)r@y3)}h_$qnt(f$iUeu~feqtiy--FEa zxo9VLEG0ZddIUjROA1|qt?)9!TH;BBR|t(bMq5Q3&c5for2F?qbG#Bs?Dw&MAHh#Z zGy8TDcW+Neh&)2(*MyqnrRgZP?c|44h^-m>_G42lAiPGnoqdB*+Y6Xv%06aP{>}Qs zcFL3sVMi+T3J#{+&=%}$uFs~#W zum=}}N^)`w{NaGfpB;?4(Tt+Ja3B)#hn1fh$_p0RMIn25C=#{0rP`b_6b{b!E}{(1 z7|4!N+KanqH+!AShSYCC_85P#C{SRh%J6JI&~C`-^gvH!>6|DhTG~N0r&PlTOgz zO0&lWvP0n@?B6f~HClpDs3qpDP%{INKdT$Lx?0_8!gl7kHLUfoN z@fXYvMN;k2oTY$1j|OLlw4bx(2D0-xPpXxc)x{oPQd}GgNBKX_hf3Ora4>ATs4T&5 ze}PGiXIPvHLo<`zF|jB3g9VXne?h=X%l2}n1j3;S!2nZ%u@3tS;$wz(NT^R&b5eDC z^6-E^$34WJnBmMB-o<%-c>UUWCH`;@^Czu19GY#$;lS`g?d)*i@{(YfjxPw#2>Zi9 zTIMe<4p1~2vS$SBV%o}uQsZ?pdMDOVci9t4aza*GF&($DC_CtnPs|+dYaN`;9=eVB zlV{HkglB{z9PAJK?VLbyN!iffIr0x@+o+oHEu&ib+G=hUmab;Paym25!uH5O;f!!7 zVx`UY7nMFlH#+Z*zB@jDOr3}(Mbr(&l z^V)=qoqiMBk1Cr|;{uVWpYF-=N18L0gGIsYpelUMoN_zSOKVUlY3qLlT*RCu)tNqV zP}@pC4QYb>bLX4{%A>%h+F6EP+@=( z4AU)nfp9=`CF~Yvz32PwtTCffo$^zjM9_QlHwFw!w|7UB~d$+>(+TjXl^_@ZEu2;bLn-7V=_j?Kfm-rpKbd? zc4n}Us#t1-k_@`r+wM)mS?oXT?A&#JtRx(=(%E5q>%uPGU}uaQm!6eAZhYyotn|!O zJ1dl3y3sBUgmc{W%^+~AD7#yz?cQyW+*J2WGXe#HZ2B}anCG3-%Pj~-@^u5%7z9H_ zR{GpPc1hG(KjVYW8ay*P8YqoKKDS_ImDZDzp<*2qVNDi<=A=3cvU{`*&b1@i;b1X6 zL(fG6Iox{-0^CMze+h@UkAi3*)p<3$U!#J++~ACYKw0la^TVN%ynK69+JtzyoK^`f z-4P6j3R0K>0cJ~1sF2&Z-Qk=Ehtn#zoiimj>jLgN)-Yxtw*sxJbK|UboBOV^%z%$9 zUCkY)$RCMs%Ka%}wC0+nr;9y45RKBfh?g>rR~rU%JDp{ZkA{lX6*<9(*_Zk+C3UBF z)(1Oe&EdwQUJBWB{K2SAwF`p(qVMogAHVd9X{1Dx&Q4bJk&?sh{0-2U~zU_XW2Nm~{V*h9{_eHKI`&e7}U1l1X3-YcYiw<4~Xnw#BUe+!`U3> zXQH}qB)0DF`EA{U8*qM|Z_Hr5TNHcm2RSSf=iuBP)n{-gE4#h2{Npv}mG{MLJv1YB zug<+Xr`Wx_ckh;BckgmRciwzPg`$Bz_5_whu!wnIP{5;?o0nM$a~OHsAB_eISr*QY~=jyy0qm)HOE?4iNAPJ@Lb%XzojRi}D5^A`55 zou|&Hmw6VIJ<^@y3tN{f46z`bOHVa-l23JWdR!fCp~r#V5OTtL>9ES45PxM2aMoTO zNaixuxDs!|de828Hq)}K9}4|NC2C@7JpG!J3AUZaEr&txXb&slCKx$8ZOz(pdS5%q zx%b*9{JPcT@?fPw++Dpv;L(<2&zQ%njQ3WST6x0YF@5arJ$v`+&agmlm)@Q2>FC(0>%f!Et5H5wGmV_g?A!MTz9=dotOyxX6BY`HH9?u%=%D7&B}sP_QI@9aCc z&XN;4J50RSoPB3bwI=AH)lW|?chzSHd4G;_fLXtV>Zg*zvL|`+uuv#ZkBec!=ETx}O(^B0;?_J6ErnI3g=ZXB6-uKy`U+>1%@sy;JtwP!sj`q^>Ky-+h75 zo#>o9Nr=y&puZs1d3VjMHW_;R%H^%h{cg~+$eWS|7J{l8pSSkG#EUcik!Z&WVSiDC zn}|LghEB}PVA_x&{x^dc!yxyZY~oA}R!8Y--tpZV1<68NZ_V71xp(SwDUuo=6>pMMc?i#M zwZEVsK6Bd*zIJIWX^NG71Ll;y)p)ND=X?dme|&18&$(}V-T1xR=l$DfpgUW+bMYC- z{r{ZLz*aF}?i%F`dH#V4`oN5|vz+gbHF7HMZrJ&M z`#tC!`lVsN|Ls>FckZ&=RyfsZy8ANcuHAhH>QT-cXU^GIUw!hqukP-9qh2H29}@J; z(Up5>`w1RlXp~J@=I4|2l;x*E#W&e zL|cM|ft(<3{e_yd@$X;llIRTIx2jyo)(7JI`5*)GDNe}eYP?Et5t~@qmtw}C%=A?EgTQ?QEzr*&?%0`zFzWgSG2e&(_9aLs zm3=gpb-4LFWJ$U2a!${;PjxDrRe_E=eW>gkk*`PQ3olE0_10SeKSKoPhEkoD`v>$N zUG_@IK)XMDc)gYz3i1N22PeN&*<<)B$o0<-g;Vqp)~6#giHoE_48mPe)2${L|=x}>BAcJmt$W00(m9$IyXta$Ib7Cdinb6^U{00Qp*Doez)XUtp)eXveieoCC1-7_(Wp7 z^s{|FC;r7-{z2#5gf&X61^0E&{PYmwJpQ~8E8H71_hhEQ|N5m__6+@(&&^v8?{RMU z@)Y;&vd>LtWYl(69a-vh{pug*jpVM%6w(`8#Ci3rtfuGID2-15_ifSpwB%jPY5MiB zN|`0|^%k9363_lRn)v_kSLQp%dU;=&`W*1PZH!muKf{gjn-3$X-(UaJr>4J;eeMGC zJ~_<~MOr8B&&Nz9_a(e^b;knEq}N#Q8`Hh{ob#2bHw3L!UefiQ8SnqyTfR!>={{vb zywwktees?@_VGE_9q&*hI~3BK(__^=kF)D|b6=|S-SNH7!zaG-jdN~0`BIBoZ*9}YzedlQ@>3pPvYP6SzD5uHH1fav8l7{hl{58JOXtaj4Qg^1(65Pl&G&9w z&a0=KZvX8^spUS(^zHgr%pT(ee+~>|>i<XPQTM-INW5ELf!qAl zc&%S*B-H0d&5H>CUZJI^4;IZ>drbU-(_?)dZM}qq_|zQjei`YP46RXb76sr>VC0VCGhQ`5$l97#}SB+xPF9 zv_E|r=7X0$`5%7)^X~w~{CFGHtKMIq!dnOWru(*4Ozdx!34A(l>sHa%xWcwus{8!qxBXPt_g&KX0`I47^FE{> z`u_Ec*?su(&ldEX`>NHPABuQNyWh*)*v#*9-VcxN4};EQOY8OGUut;GatFlwF4l`M zQ=gyq%3fst%Map}pEU90w#49&6^Ocn6! Date: Wed, 24 Jun 2026 22:49:33 -0300 Subject: [PATCH 12/47] Compact VPN selector layout --- locale/big-remote-play.pot | 16 ++++------- locale/pt.po | 27 +++++------------- src/big_remote_play/ui/main_window.py | 20 ++++++------- src/big_remote_play/ui/preferences.py | 8 +++--- .../LC_MESSAGES/big-remote-play-together.mo | Bin 74787 -> 74705 bytes .../locale/pt/LC_MESSAGES/big-remote-play.mo | Bin 74787 -> 74705 bytes 6 files changed, 26 insertions(+), 45 deletions(-) diff --git a/locale/big-remote-play.pot b/locale/big-remote-play.pot index befc44f..f1cb777 100644 --- a/locale/big-remote-play.pot +++ b/locale/big-remote-play.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-06-24 22:43-0300\n" +"POT-Creation-Date: 2026-06-24 22:47-0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -1579,6 +1579,7 @@ msgstr "" #: src/big_remote_play/ui/host_view.py:2629 #: src/big_remote_play/ui/preferences.py:74 +#: src/big_remote_play/ui/preferences.py:81 #: src/big_remote_play/ui/preferences.py:204 msgid "Restore" msgstr "" @@ -2311,11 +2312,8 @@ msgstr "" msgid "Reset all settings to default" msgstr "" -#: src/big_remote_play/ui/preferences.py:81 -msgid "Restaurar" -msgstr "" - #: src/big_remote_play/ui/preferences.py:89 +#: src/big_remote_play/ui/preferences.py:92 msgid "Clear Everything" msgstr "" @@ -2323,10 +2321,6 @@ msgstr "" msgid "Remove logs, settings, servers and saved data" msgstr "" -#: src/big_remote_play/ui/preferences.py:92 -msgid "Limpar Tudo" -msgstr "" - #: src/big_remote_play/ui/preferences.py:109 msgid "Paths" msgstr "" @@ -2336,7 +2330,7 @@ msgid "Configuration Directory" msgstr "" #: src/big_remote_play/ui/preferences.py:119 -msgid "Copiar Caminho" +msgid "Copy Path" msgstr "" #: src/big_remote_play/ui/preferences.py:128 @@ -2360,7 +2354,7 @@ msgid "Remove old log files" msgstr "" #: src/big_remote_play/ui/preferences.py:140 -msgid "Limpar" +msgid "Clear" msgstr "" #: src/big_remote_play/ui/preferences.py:185 diff --git a/locale/pt.po b/locale/pt.po index d9c1ac4..6a32147 100644 --- a/locale/pt.po +++ b/locale/pt.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: big-remote-play\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-06-24 22:43-0300\n" -"PO-Revision-Date: 2026-06-24 22:55-0300\n" +"POT-Creation-Date: 2026-06-24 22:47-0300\n" +"PO-Revision-Date: 2026-06-24 23:05-0300\n" "Last-Translator: BigLinux Team\n" "Language-Team: Portuguese\n" "Language: pt\n" @@ -2049,6 +2049,7 @@ msgstr "Você deseja restaurar as configurações padrão?" # File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1443 #: src/big_remote_play/ui/host_view.py:2629 #: src/big_remote_play/ui/preferences.py:74 +#: src/big_remote_play/ui/preferences.py:81 #: src/big_remote_play/ui/preferences.py:204 msgid "Restore" msgstr "Restaurar" @@ -3084,14 +3085,8 @@ msgstr "Escuro" msgid "Reset all settings to default" msgstr "Redefinir todas as configurações para o padrão" -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 74 -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 81 -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 249 -#: src/big_remote_play/ui/preferences.py:81 -msgid "Restaurar" -msgstr "Restaurar" - #: src/big_remote_play/ui/preferences.py:89 +#: src/big_remote_play/ui/preferences.py:92 msgid "Clear Everything" msgstr "Limpar tudo" @@ -3099,12 +3094,6 @@ msgstr "Limpar tudo" msgid "Remove logs, settings, servers and saved data" msgstr "Remove logs, configurações, servidores e dados salvos" -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 89 -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 92 -#: src/big_remote_play/ui/preferences.py:92 -msgid "Limpar Tudo" -msgstr "Limpar Tudo" - #: src/big_remote_play/ui/preferences.py:109 msgid "Paths" msgstr "Caminhos" @@ -3113,10 +3102,9 @@ msgstr "Caminhos" msgid "Configuration Directory" msgstr "Diretório de configuração" -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 79 #: src/big_remote_play/ui/preferences.py:119 -msgid "Copiar Caminho" -msgstr "Copiar Caminho" +msgid "Copy Path" +msgstr "Copiar caminho" #: src/big_remote_play/ui/preferences.py:128 msgid "Logs and Debugging" @@ -3138,9 +3126,8 @@ msgstr "Limpar logs" msgid "Remove old log files" msgstr "Remove arquivos de log antigos" -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 100 #: src/big_remote_play/ui/preferences.py:140 -msgid "Limpar" +msgid "Clear" msgstr "Limpar" # File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 158 diff --git a/src/big_remote_play/ui/main_window.py b/src/big_remote_play/ui/main_window.py index bd15459..bbabe30 100644 --- a/src/big_remote_play/ui/main_window.py +++ b/src/big_remote_play/ui/main_window.py @@ -520,11 +520,11 @@ def create_vpn_selector_page(self): clamp = Adw.Clamp() clamp.set_maximum_size(900) - clamp.set_valign(Gtk.Align.CENTER) + clamp.set_valign(Gtk.Align.START) for m in ['top', 'bottom', 'start', 'end']: - getattr(clamp, f'set_margin_{m}')(32) + getattr(clamp, f'set_margin_{m}')(24) - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=32) + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=24) # Header header_group = Adw.PreferencesGroup() @@ -537,7 +537,7 @@ def create_vpn_selector_page(self): box.append(header_group) # Cards row - cards_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=20) + cards_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=16) cards_box.set_halign(Gtk.Align.CENTER) cards_box.set_homogeneous(True) @@ -587,27 +587,27 @@ def _create_vpn_card(self, provider_id: str, info: dict) -> Gtk.Button: # 'card-accent' keeps readable dark text (unlike 'suggested-action', # which forces white text on the near-white tint). btn.add_css_class('card-accent') - btn.set_size_request(240, 220) + btn.set_size_request(220, 190) btn.connect('clicked', lambda b, pid=provider_id: self._on_vpn_selected(pid)) btn.update_property( [Gtk.AccessibleProperty.LABEL, Gtk.AccessibleProperty.DESCRIPTION], [info['name'], info['description']], ) - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=14) + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) box.set_valign(Gtk.Align.CENTER) box.set_halign(Gtk.Align.CENTER) for m in ['top', 'bottom', 'start', 'end']: - getattr(box, f'set_margin_{m}')(20) + getattr(box, f'set_margin_{m}')(16) # Icon - icon = create_icon_widget(info['icon'], size=52) + icon = create_icon_widget(info['icon'], size=44) icon.add_css_class('accent') box.append(icon) # Name name_lbl = Gtk.Label(label=info['name']) - name_lbl.add_css_class('title-2') + name_lbl.add_css_class('title-3') box.append(name_lbl) # Description @@ -615,7 +615,7 @@ def _create_vpn_card(self, provider_id: str, info: dict) -> Gtk.Button: desc_lbl.add_css_class('caption') desc_lbl.add_css_class('dim-label') desc_lbl.set_wrap(True) - desc_lbl.set_max_width_chars(28) + desc_lbl.set_max_width_chars(24) desc_lbl.set_justify(Gtk.Justification.CENTER) box.append(desc_lbl) diff --git a/src/big_remote_play/ui/preferences.py b/src/big_remote_play/ui/preferences.py index a87ceb7..1a8f6d1 100644 --- a/src/big_remote_play/ui/preferences.py +++ b/src/big_remote_play/ui/preferences.py @@ -78,7 +78,7 @@ def setup_ui(self): restore_row.set_title(_('Restore Defaults')) restore_row.set_subtitle(_('Reset all settings to default')) - restore_btn = Gtk.Button(label=_('Restaurar')) + restore_btn = Gtk.Button(label=_('Restore')) restore_btn.set_valign(Gtk.Align.CENTER) restore_btn.connect('clicked', self.on_restore_defaults_clicked) restore_row.add_suffix(restore_btn) @@ -89,7 +89,7 @@ def setup_ui(self): clear_all_row.set_title(_('Clear Everything')) clear_all_row.set_subtitle(_('Remove logs, settings, servers and saved data')) - clear_all_btn = Gtk.Button(label=_('Limpar Tudo')) + clear_all_btn = Gtk.Button(label=_('Clear Everything')) clear_all_btn.add_css_class('destructive-action') clear_all_btn.set_valign(Gtk.Align.CENTER) clear_all_btn.connect('clicked', self.on_clear_all_clicked) @@ -116,7 +116,7 @@ def setup_ui(self): copy_config_btn.set_child(create_icon_widget('edit-copy-symbolic')) copy_config_btn.add_css_class('flat') copy_config_btn.set_valign(Gtk.Align.CENTER) - copy_config_btn.set_tooltip_text(_("Copiar Caminho")) + copy_config_btn.set_tooltip_text(_("Copy Path")) copy_config_btn.connect('clicked', self.copy_config_path) config_row.add_suffix(copy_config_btn) config_row.set_activatable_widget(copy_config_btn) @@ -137,7 +137,7 @@ def setup_ui(self): clear_logs_row.set_title(_('Clear Logs')) clear_logs_row.set_subtitle(_('Remove old log files')) - clear_btn = Gtk.Button(label=_('Limpar')) + clear_btn = Gtk.Button(label=_('Clear')) clear_btn.add_css_class('destructive-action') clear_btn.set_valign(Gtk.Align.CENTER) clear_logs_row.add_suffix(clear_btn) diff --git a/usr/share/locale/pt/LC_MESSAGES/big-remote-play-together.mo b/usr/share/locale/pt/LC_MESSAGES/big-remote-play-together.mo index fdc5cce0613858c5f3110ee6cd5742a5414ef2e0..ef722de366cd45133d3a5688c6678f8632972479 100644 GIT binary patch delta 16534 zcmYk@2YgRgAII_gj|@SAgv5#@#0VmGA_-!))ZVj-Qc;`Q^+%N2Rc-CPTD2;6DQeH! z)F`!A?b4dh=leg$^Xh%Qj`vyj-gAEEoSV?+S-8*p*j{hM1FJbsC>F*L zOvZxP3=24p+ZjqGJr#?vEUrL5yowp|E@r{!SO(Kqcbv>v0o7gy)!qXAu?xmxUt3;; zSt+l??6@6u-)Z#W{mvCKxv98?8nJf`#|gr`m<>x}FxJ2bY>9f{U{qu#pdvF1>*6w7 z|1T<{>EEM2kt%?Zl;hqv6K;W7dB4-1jAqamiN5oNbq*Gwya5%$i?;r6%uhM6rioN3 zBsflj)r|?1cVQg9#Z?$r%W+oYP5cn2eL(yblILW?Frc>MWWb`R8I?oLyc%kN&Cv%_ zY<&+@gi_HTr(t1SfNTrrAa=umI*t>CgHiX-Lp^s%9pbM6Y^FjXJ&FqH71Tf;q1NgZ zdSiOxn;rvDOB90IGtsDlw6*PhQP2GxwP&WF?%#)jco_Bk3w4RVLVlA9&A_Q=LY)EC zF`q4$M3&E~iJHk|RK(U}2|S4!m`{B(ut3yqk3>Bu9@YMywLWH|+`>&pGw);_fP2|2 zV{M0q4Nb?k=u5w@sJ+k!_26-+3Cuu6Dh)NWZCDf!p!z?v?SYL<|6HgDxl5DDN2VsK zV^=JVBT*0Dhj~7r&SRv` z|6MXVrvXjPjYTk+a&^?wv_Q3giuz7`iHW$#wqM4Qlpmu89M#N3s1$18RZ%Z&g=+uQ zmIq>x&i@!PTFY6e7ygJzxD^%p7pMXGG&eKJX^li}s%TUsN?`~lp$6CpE8!#z#osUn zZ`yM97Q|mS#E{8`)leNsvpx^<7&Me+{4y6&l%C)GpqLTC-iK86HAK;1p_)+(zw@EUnCvgkcWK z=EAPFel+U7Y1YN9h<{-!)=;4lo=1)NHtKvov>gLmn>~;lRUeOW*cA0)nTXnaQ&1CF zg1K=G#^Mp2i*GP5&S_&Jz12-7I~Au;FTRUfipQuAjvpWDoLCRlF9r1?xAkibr@R>T z!(}&W*WW~asB*XEC}T2e$#$b6brdV0`!*Sku+T?@51S!Bx}C|WB{_x3n68~!f@-Kp zHAkK6PPV=)YR1D*A)keM(FWUo6eB2KL-q4%?}@0}i6o=lTM;$$_pt!BvE@|EOL-b9 zv>Q;TWhZLW9Y-C%Yp8x99ZYD;qK;i1RK(h#?(2!#BW}#C^Zyl@1S*!}OuUKVnEJ7q z!9v_hc?(A2ppIs#=As_F1huI)p&q;kHLx?b{x)hL&rnO?+sW*itmrxaA!PJ`XjCN1 zp+=gF+GO=mr(rZI^pmgz&PLsL1oh(csN;JNebD<8)1JW^iaHfhr~$^HTOqGRMr+a- zwb^=F$6_+&MR*slV-grIdfcv(?OVkK6^fsZ2MeUI)s0VzA8fZ7vjQgVo;zn)0 zNjL<*MMWm4k2y7AsQaRjiMXBOWc1?Js0XCj@<1#`d6X@uVG!l*s3kgq!I-|UnMod0 zdtua9(L|cnUR;M_3MBpPBY})C(G*X8f@&cST>y zZtEz_N_hfC;9Sgu+c67X#CCWegL%JGzn@vlk1;#t-WY;kVkw-91@Jg3^#7nD=GWgG zw``~vOG+yKVU*YH!>@jr<8}0Nw-4 z41+NnFsy5bCtl#<|!XwUmK_O+<@eI?BnY2~|VAr_NyFuaGyRLNn`zn$Zx{ zv73yV`CQaMS7T+|j(U(6TU@6kBPL=A)b8(!-q;)U{QkHGC!i*p{JF7_n@kQWI-%!0 zqGmP@%j0a+K+d4{!gbVxa||^D3qwVuIOfFa7=dk3&l`q%-gsM{gPO=P%!TgFWEA34 zsI_{9+7sSHMi0(`8gVQt5>;${BU|o*dXXZjO*$HN{|Z~)fr{u&)C*r>D25L6+~;;; z$!IMSF&?X>nJ6RC?DcvD+WK~10+YUxH` z1)cwKWRj`akGe6#2(x*DFphF@)C)SH+IyjvU?6JG3`H&B1k_S3!Q8kO75bxC7_XpC zS;kazUk>#A{l5^INE*tcLf#5B(%z`hrlJNg3H#s-)XV}$a;7m76`3)pfh|Sdw;Z*H zHro2fsN?q>qwo!Se*e$^g?V5CY6f*sYt#xg!#>s#*2$EEU?IGNl`&|P ziCAM)BwC{e-W@f-;iHJZW;%fiADn~HxCr&)BUlzMqWT4oHldEf2+9>O0YAioI31ak zvmG_S>R+0*?v4{E_eVX~XN-wxwlS=K2`a*=&;aUNo1$LW8ntHKP@8j*txrSEXcua? zpGM8-4r-~Mp)b0|nn|p-S{4?rI2`r-IoKK3qh1_7(X(mYP68PnpXL~Wy-*SO8a30U zs8DUdKs=d{Yz%} zRL(jbQl{}&aGZvkS+(i>@_^kj2LDBE%0e?7=Lc+q!_jZ1*+XM6h4Lh|aTLDbAHVt#yV%OUejWQt-T>Jw2B>3~|IZde=L z7=VW`Bc4O;p&O{Za~Iuu@Dnl$ap-(A<07aRH^%gsf?C7gm+_9xEuAn zTNsF$7MKC#Mm;AQqp%ceQ#V^c{FV8TiV*CG>2QcG4?`Wxsi+apM$Kq3YDQ_cybU#w z!>9rNftt`=%!_ZVVGB*9k}!$-8VlX##i>+i#4}Lm`3Ee6TTw53j2fu3$dvt15y_5v zaSZ0eidYfbVNaZg`ZQ--5ez{MtTJjZ)JOH} zh#7D&M&c;U<8qwuQ8U{8y;*`2Sc3Y?I1sZga~y)`jN@nx#8{5P1oofv1DOaa;#Qas zLvzecxerF;BrJiOP-}h%wRX?Y4@<8!15ZRn<^!yZ?NKvcgaNo7HIZGY3H*-bb^foB z(Wc3}%5*4-8bC7Yg{{#CJEJyJck2YyURjP>n(e4B+c{Lo|HeQJT5SdpiF#fvw!%aV z;r-53GEuk^s}QOasF{bXF}pPx_262l7k0#gI1sf7=V2UfL~X){7>U7Y<}aOPQQwD- z*a3TBQ#^t0T4ai><)2;fGdzjE;1Qg%jyi0=-kj^RSc>v1R0v}?m=Gsm2<6%sgdI>z z(+{)ZP*g;wp!%)G2;94Y_%|eTi;6**xY7Kp^?LNBdbbR=r6W}`y96g7Y~wtYWp zS6{(6FUNU`nsKphCe(?To^pNEeJxNC>WR5A6*a-RsQ0*kBvX>iI@CySq8=Eq-Q17| zHL!S$!3LNM-B=7~pdP#zb&9T{mhK_yMbEA2c9>(C88!0;$Z2vrpODc5N1<+`oKH@~F^NK}Dts>H&jM5%~_a$yT9u`Ci+80X2Z`P2?;p zGI!7yz4sCSvSc#uGizHJwe~eJGq%L^*abDSKBy3WjatKBQ8PS^n&D+sr0!yIboQIb z#G+oDgc@K|)O)(^C;t9qhEk!;^CfDei%~P)U_F4PC|^W{I?DkwurMq{xge^4UDN>E zVG*2+b#V)7#u*Qq0R*8U6zwLX7ssI@(HK|YSS*O~hfE0DqC!6a^`Ng%Gn$He!B4jA z95(g2Q2ooGzMQo&9J^o?jzul8dlebY;2KWFOh?SQU4%M5tFa~?#|oI|H*;EAVl&E9 zurl7oHW+i%+&2ytkrn8T+fkw4jateJ$iUprOENlEIgi;9qefU86`5WbffF%3u0pNx zMpT3jqc+)7EPw&W&0Z*t8bC$NiVd(Lc0fgB0ea5=b~1Y4In;;d0cJs;6K2FAr~$^H zBJe&=#!(oJIZm1%yH)Te<&LQOI;YHl`=e$&4E3D3m>oBx=imR1+J5fcGh{!Q$LE|FVhT(LYVU zi!=AjS4?{-D#B52GHQrHtz|Wg!=|W6OhlcArKn?i2#euUROBMBnp02|6@iAR zCHx4>VK>xs7oldp9s_X?s=xab8HMI5>Wk%n&Ag}#>W0>+2XsX(#Zc4>#$tAyiW<-g z)E+s9k@y#CsWM+T_vc3qxGd_qmC#4$zaE)MR5U@2=sIfmKe6RESekOC8|KTFj2ci& zYgg1vhoWY-4i&jwn2Z-u1Ix?V)C5YR$`vp#?|15xsY*o;)LNyX26DuD0evaoL2bfE z7=`|~%-$%Dl_*!me3*)w`4UWzn^Bu|C+az8t(R2K`<)wP6tb6?AH!~&8CJ#=%8gJ1 z+lm_bS=8~niyBadJA4x`AI9T{sD4vXOSKHWaRY|q7R-$o(5;4l$Y_MGF#`tOH7^Xa z#-rA{5$b^{w!R;#|729eW}%MdGSsHthZ^7|)C-@Zo)dh}{P%$9d&IvC6%(oO!=0Fd z2T`Fa`fP9zgRrUPcdUbZ{}^}w%Cp`48h;b!#5d#Fe}L#=J5r>0yO6`6{t`x>E+V^0jk zF{sTt4YTR|pCl7a#ckAsg8w$75OVAJ%k&d=~1m@KFpJY4yfF&qz!L0a~^(_)AC+olFOIQ%~qL!!`_QcFM z5*6Zaa4;^!oEZMX{F6(0)PU1a$No6Fb;B7lmGA~?#06iP-5G;LD3?Q>>vpIIx={m~ zj{4%QL=E^DY9LopGk$^<(D#+uW7Sa4X^3U8=PTl0m&{@+wAr3wZgjmiBMrkGl#@_5 zHbM34gJp3TDgx`VA|603rT-ga1ZtPZVH0eN3AhgT;KMh>zc`rK}>?Fa|XMx7#*M z#ynIk#b&q*bzJgzyF8nwFosfo4~t@3)QrbjXQBG9K<%kbs2A+RVt5XQEBeT~|* zvuyi%)O`mr)Q|PQLMEAt7lAHk1}0`UYke9MDd*1O^6cVf*u}+KViNUEkV}8taw?#9 zbH-qo^9?3rDLjRxF?}|dvm29eCVFLed5+&4?9cn1g=FetZhiwT#0$FMCdyxjx}0rT zJBJB%rkpNkH03b7jf+v6Y*H>W!-c3HGFz=TP)m?Gx6AWIj6@yJMyLpNK)2Sq9~p(@ z2x^mDM|~h(qIRqPfmdr7fMJ+`nptyO9*^35%P<_%P)l+GBk&4pAnC$fp5L6ZqmE@l zn9J=s-|eZ;W*TfAk9xsu)b2fIJ&QUemry^ZU!XSS8`Q23$!m^f9@K=QP%}mEd48tExyg*6A{FzZZ=~6^1yC=JMSXC3qh|6s#^MB2$hV>%yvKS8wS@nm zA{Cg=+#ikVU&fXzpnlf4-zTGUIS_SBR-u++KkC8fP&0pm5tt#r8Awsoo=8Nkb!XIl z{ZTXj5;gF7s2{gKB755z$sbcmJxRZ_|C}GZsO92RImx6}q(7UFxQTMm)+m5m#!;csz$@aeo?NhLik{3jIK zc`bE1$Ms0%NK;93Y#UiV*_;Scagr{5yxx+=Q=Wk7=&SFA3j^NWr}sZ6>Do=lJ0$Jo zP&|vA9OnS(B4u3zNR>!)Rp1(F>v!7+eoNiIBt9vgt1^zFPbbpnq~g>*wr!Kq{Q-X@ zlKPPr(s+PQYmu+A(~ZPWO3z29FZB(`Z=&ucd0l%9|NnCb_1a`jYb z7L2Ft!p|vtk-nh(>y)gegWWSI=^8?6NIFcpBCaCoszF(&qa~@X?fWB7`kqviy059z z&lnx?9=81g`Tt&{D3r8qzi{7L@}E%W?WNylxt37L=frtKdXI8AX&H5ON&HOm9C!7b zMVd!hS7q9CZTI}iFS?Ygkk?o85$P`Fj<(NKJY)0vzYVpaov(wlL+_6vGnZ74G>C?O z@F#qqj`cAT+mcREKMx0y3X-OghEey7KE24VBwZuFpQLLMDHC9djdJNs>iU@H2PuDT8bf}$+O+-+$c!beqtkg+a!n!y zQLaELPn|zyKT;o3Vag-%Pm+Es+C|b;hx$77Ur&0Ebc8zn&aqUDTwl=Mg7md1dj6b} zCoaAFKAm+%k*3q31nG&VhP^=B5XvoaA`T?!^2WBL`J|61e@Oec@F zQtdlFvhCf}iR-++0Rw3~NntX6Mu#lqJK;vkg~<0O>H3?x_v{0_Dd!~rH!fBq*AJ9G z=AHuNo1v}*>ZXt%g~Lg@o?tfHKHJsYRBp8u-_f{}{88Ikt;v+DQ*KRuHu)xah4Nf` z&r@8Yj$F0veYsuwxu6AJZ)oK&&m9zf_+wL zZHtx^iqdf-{Z_4@k-nvkzmqwM_%rRVaRmCJezlxP zx=XvR&kW8{+Vl_jHL->Cn)(=09Zw5C7OB^jX7%LxBUKr$^`utserBs;I~~W6LU_m} z{GQ}X>Poo>?Uyi?^oFD>7ak!Mr%y>+_YvNt?Knw?e^CKNE;uh+ck=L~qYfyen`i9hq@-@a2@nnwyGg;QQcpJ%q;F*PDox{!39a(;V9G5g4K z)So6@qQjo;#y;aFP9mKmW%y6OpKW_*E4Q?A9?_u!xr3wzG|VDRA%Bddt0?V<@T>nk z$kV_dZAnY1pGi8)eGe$d*@uVz|A$i%TJ;m~chUt~585XM*q(n;-<`Tww!RDHha_E< zY#&qNH%!W}={FX4Vm&NM;~jgi`lV6#p)I%ALFPAG*g)gIG=7g;)YD!YsB1*h^*g?$ ze^ZRc_enFz@3qfRpN=HIw93ibgVSC%O7fmkqj{)b&w&y3+x6|7*0Oo)Txt7zpAJm> zVfZ%%)0Ivrk(T`Z;~Z&4cdYg<^l^_!r)rO$DeZbk)U5w5SHEW6ci9f@x}|jQ+#}6v T&)D#^xz~Tmn)b=_Eyex^%V7W$ delta 16604 zcmZA82Yim#|NrqTOA;#)l1PLEiPa=#jMRv|sl96!v3F2<*R0j5y{XxnMU2>LtD=gi zQPgZ}E2T>Nd%f>-eE%N*>+v{!p3gbwI@ej(b*KIQerJw)FF)q(x*Fs?%i;3ya-0Z^ z&gnS5-i{MnOr?%fE6H)PVr|TVEz!%vak^q{>`~cq=2E_;isKB#`c)mL7k-Cf7+=kC zLa`QxUjtd9mfW|YnL!k4 zsf(Z*Refi$|UI%BX%?qUyU@`(b9{VJS1MQ}u1~>!N@mkabcA{45ENW)g@ICwk)jp(&sgFmsPeiSds|lHW zWRg)0r(j83fok|Os>3H3h_7v&r73R+aZU`v7O4AsU;vIr?e%ojOy^<_T#0JG6FIyt z=Omc|6r4vje2rPqznSSU6m>&U)CZ~x>aev(y{^f&ehh{a&&HCt3H5f|wmv`&>^bT+ z^l7gC*#ClLbYnHlj_pxfGSJZ~zftt{A%&PZ)8yP+DFqX#)s3p(Q(hMjPwbvz4Gpb^(gE~}=F$CM71~>pK;RX!F zhZu($TA8>Ms(n3lW6nrX%tc%tb7D)>K>MTa z8;#*O*_N+DovrQGLs*FTOdHl;BYZ`HM(o$tyw?G!8{#km6K#15EJHjPHRJWDz1)nN zz+udVXRs(fz$KWwo#W)eJ*X|Yg2DLAMMe+K$OleOdwZ$40oIF`6Q>MY$tt<)o|fPNj!09`f6l%rq>@{{dsL~Y44Ou~E}%@%Y< ztyBu?y&h-FC!=P(1hwS5P!CGC^^Y)$*sGIi7mX3bNyuyLa@vv6%)4O>jaD1X8el!tihqbM zEyX}GI&4#|t1*fAAl^go?&ibv7&U{qWHaCrs0Wuvl{dBJtxyB#jCx={)Wk;H`e~>M zEl6hlRk4}^EqR))ID_io8b;xL^u#PZ%#DGVmpBjVY$Tx$RXuEs$*6(v#zr2F^G#24 zIA8TLOCQ|ZalWFyP;b^>OLLrpQuq^YN8djDMa+%+P!IU7uUXoYr~#ivo%S1;5#OLM z`t>tQ8;EKbgGI3v24WY~SsIM_alVU8J~DgJAAd#7>>g@~vh+7AkqhI93!%!JV*qxs z^@C6Y9fi5^bBx5bHa>wG(C?^rFHsYBc@HoT$bxDRjv8TM48jJeGtv=NKN2<2DX1CG zMh#>!`rro4fLl-x+Jz(VC~Bpu4K(!)k$zlGb26G)2h^T_f$DI9jn`r9V-WFE z)Ry@SG9Rc!)B_u!>RY3}oV~Fuj=>zb8#RzKZrT4UWHjQts6+S=HDlkwW+3^oG;v8( zeGk+F#-MuzY`hrN&n9acW+UE*QFsP*NS|UL1`J_U_5MeZ$&RB@dpjG0aRr9pPAq|E zFb2I-%vKaZtynqK>sJHyfV!xGc1I0x6lz5$qGmo1b$C~xOApvbMgvGkb@&H{;tLxG z3^j)(0yXj&)LAHvnqhU!fi0}Pkl!O`6c)$psDT6xsEInXtx#{zC%6Q^LTzQz$7V&_q8IT1)af7cG3&1f zxom@psF^K6&1eH^OAexDeg-wr8(0~iqB<(Y5!V1J;QQDK^)@UhNjOIPD&+=rUz z0M|&98HYJ3n2S2an@}^`gXQovY9Rifn6nUy>bMSSU=2}Q*#W~a1*32ps=v*s{`T7V zN7O`ISIC5uxsO_6UsgnW6^}X-rBNN%L5)}^Ru3Fx%g5PxA?iWvQHOK~>i%ms{u8yL zVV|1k#Up3T<OsqF{d&|^Y(<@&ov5um zj@q&tsQVwI2AX+{`QtMWx_|#`lF^M#Q4Km{UL1^C`Z=g2-+)@FeW(GPz=4>Knqi%> z{1t`mP%Cs8HL#ng`+h^6rDwK0ejMws_pl_HXiP*susy27A*dNlME1&=gBs|!*1gt~ zScvi~s3rCpZ~6(qg2V-|GB!YM$(N{!&K=MC>p^QM&!=53{LK9O zei*t>J!+{tU=$9+M4XQW@H{eE=QV18qb8WGUW1<#r%qu1)p6yCW=TK5V#IAw1DIl+ zj(Xr+)E9C!>X2@;<&RJ^aweHmAB37v0n}ENzzkRcwUX6P--EU;GI|dOpjKiu7ROnr zU%jKK)Bg;0qyJ>nQC{>WE`+*24t=o#s-Nmu5nG}LJO?$v6}EmGCK0=ilgUIT;}r8= zhM*o)9Q7fojXDFJt^H9e;X-xzr7d5E8u&&mj5n|pW@erFNII1<0{5W$zl7cO{y!z7 z4^W#g%&8rM+S8dBh3io(a0)fko2VK7iCHnjRCDNpQHM7Y)xNZi>*7Mzz7y*7|2K^jK#4i$-l#Ez|&7p!)e}8SAePPag{Odd#rSNA2ZO^ujb7 z??%0rXHf&bi0bfH)FFOk<9|>C@n3ER7>=4yLCk}R)>g||e=XHe3d-YX)Pwh-Mx2iN z6}yc(&3~gF7{9^{xI8MZidvCIs0SxwJ{*n}aS`^zE2vL%{gq|~db-F|rC<=Mqcqfm zkDy+s^QfirU1eqvi288kN4=iqPy=p)T9F>s0q9LU9CapKsQ$i2ZPi88*>L@3Gk#y2 zf^gIWVo`^x3Kqe}sDX_{orNi=c1tlMZpXZM03$s34-Tjad8{^D5P-!fkHBGAe+~Zy zOz;0u-mPIY?77Z-lW$`b@xb-wyD$@T5r2z?@dOsb=cqj|u)%Cy3G^lIiyC+eYGppd z%D5Oc3X@D-lI(_8tY1Q%~J z?{$vt=F3?YwSqlSOFRTMkqH=tOHf<08FS!H)FC{*o%L74`xHc>SDN|v_h=kWoPzJ+ zQ_O&Qc9`FW7}RT2(Z+2tjCe5Wfm2aSybQJE8!;63VH94#2KZ8crRq@C+-Vy0N1f`4 zSPqw<_WYu)zl|F38`Ku%*k$U=VHk0B48-=BANye=oQcKo4#r^UZu4EJ;3A_3j6gkj z0_MfVm=pJ-_Ua;PiEp9?@X*%#>@iE82g`W!5Y&vj?=wrCg8F_;LESeCwLA!@IPSt&Oh>iz`p&#Xk*NM-t!1$?aW!PZ zE@wO$y+#Ys-4SZ9(@{(Li!FbMS|R@f<}gK~9#{}{UsKe~`(gxsg4&V=m>0LAR`3F< z{SC~n_x}wUHOzL<{7v^BY9+d$maY$K$x=`&G6~heM$}52Kpn1IsMG!0)@M6p`i;dz z%4?wd8HIWcXJQtf?;Ikdnf`>@qnoIiyix=7KWz3m6!n0@)`}QF+yrZ2XViyl4QfTV zqxwllP2@IeVh>R(@e1{3AOVxm7l)u$E6q1U2EL6J`K4U1YRGAEF-I3$+pxaXs$C0@(Wp zvxGBIOTQk~(P7k#en36oj*Syen(~II_Q{wVM`0u`!Dw{tBcnaOg_=RwDgI>vD`O_S zhFC_IQ-@>{4qet=pb&!5Z+6~-9iDwq+wV-OC+Y&ae(;#|zF_x}PJ zE#Y%ihXH5Jhb9^Wi4##Hu8SIAC#--YaT@Nz!dU;D`MFKOyTtQR<)hD=0k1erq`q|VcVl(3ASlELf8LUa1 ze$l+vc`ljPxd5ua(wA6&&9E^A8bCYL0J>pL`~#seUNI^$T#Slgwe1(q##{Obvn0D1X-~y)6?mqhB z%4_EDg!TB4_%6nCU;1^kf<8A)yC4jwJ{&c{1k?)GbCJ;!x3U$TPsJ`Ha?Me1K?lr&pP?Qw3rpiNRKFKdGrxyg5!XLtbVJ5lW@&O_5#oxd z2PLC!n1GTr|HwpA;C;`$Hqq!!oP-)kb8Ck?4bCQ7bYDHPD5q2XDee z+>LsRUZ7T{DDS9#OB$fs_k76u7bo*61#!3;ci?p_i1QzrLvsMVh%aL%yk@eRXdCDtbFb+qp;Fp*|@Bbn)>Tm^WX|~%2 z=TLk3tF8YBixFr2+noAJ)<&om>x?CFDC$A$P%}M%0ho?j={xu_K1Wv=nf|Z%`r%~M zYvS{ddH+&Va9%hkFZhqqZyw_5RmJ-REjZMi1zOx*-`GsRA{CGq(IDMiBpvEilB}%)Afg zAs&vQI1k^$O{f|FVtt5OssB)CDwB`9f0q+ZCYFL&)Sk7%nm84w;RTGs!uOi9Ef^9SE80^D{8Ngp_Vd=|FEkwQU>++L4DNg z))=*g?J+k_!IHSf##d2??=?oEPj0g%F}Xcl?!7HVfd zJ>U`Q@aE5BEP{GVilct6>!A+i2dGou9obQ*4{Aa~^0>^5Ker7Rp_XhbYG%h#14u`m z>PJ`rb47Z%KUh_;3h@W14ribSx(3zHF4RCzqdst6QO0bjfknD(rVQ#q4KWJ4V_lqx zqwzfIHEWqyrA%-<;Oss4c96S}9itGV0)C)D7cp zJQa%(&quwMCs1#RM}9NIT&MxZqGnzlqp&S%AR|#{;&asNw-dE8$5AuCgglr3{f~^^ z*Eh)FcE<3Jxum|NOQf})6ms#_Ih9DQNjE9uEpmP%y}iD*nRE=b_t2&LU≠rZ+;@ z7xtdZI7{#U3){E@1zMVaNblQ5QMRrpeoXndn3ptwq&G&F-g#ZyC_97=Nu^1%NlR=U zSto${)}&%2UHVw*_lIvcCmlU$`~zwkGQV@V}{UHc49J>n|lGm{>Z9uRl4 zZRX)coA=-|+>UxaN6vmdKbFi_qz0tnRQ!z_u?{zUfcdc_DV_3VID%A&G@s<6>^W@) zlHW+WN&Xl~*K$%8%5*)U-C*)vu{XZP(xgsi{*0Q!&nY-biY1>;DopCh%@0WBDZfT) zqu#gHLcyGi=((6ySpuCdnc8Df1`R-w6Xr`ipA}UMJ~~qJtz|^(b#h z`!rH*lK#~5BCS#-*I4RXlBSu^{m&V%OZ&MV(^ywDX+8~#lAgLtI0MvuOxzZy;!u(< zFZ__SjMRa+1NF1Xk0gyF4JU0R)uV1cX`FpVd&+e6HaNfN8}KoeXDOJ0gJ=*){v+H; zT#$TMBRsEb^b>rzBm^Fxb{F_GoDe_t}Cu zR30XO#x_=M72;~dZOJbo-wbaOFSYmlg)7yNtFFB-+(W+$+TiWB=Mdrr)K|n67>L72-AEC(T@~_q$tQgG&W(>~ zT!6R@zP&zCN2GZaq;OLK^7m%sEf?xaB9~0sMf!@QYcnl$ePUI%4*x>v`-4x2|VozIll6)uqL3ETV$Xj3`sA` zFVyMUgI!3qNoPqrC~HjHxoUNdU;u@kag*)noHaM)wMcuZn~6nG*LCWDGg+srwE>3F zCJZOpGLcHzdd$c@`N-$P>AIhET0i=_N)f!hV#%zd{0i=+e64M>7i$qeBh4l?CcaIY z{Psp%M7gf=q>dS%<1h?(0qyD1ML{}UB`ZP|I-b~16Ixgp4jB$#9G{*Zj8|NRC0g64~;X+^q2 z{v&Evk}eVFw|Bf}ySzgA&!nrwSE>6N6N!Vdzipe*wmnB&iu^^=F!I0HdN0~fq%0h{ zT+XLtc2N*P@+Ik7O={}S@k2^|De~1x|4@I7_%>-2aeqlFNIFG&dwp&5!?6gdmyMHX zP@4QI`yBTH{+1vE6=$e8ZYwhVuYu{Z1hluE{EpK|7fAl^wA*9rdsubnQ|c?=_oOD2 zFD6YV|079Pk#aORL1x-J9l2}xM|;vYlrJP*;>L%>Y}=TK|3fL_e`z-b4`CyGkIH-YUbWjwSvwoIIzZ+}8*HQU zA1c4bU218s9h5aA>AEaQElGvAuO?|B`D3;ZwfTsYAvJH({_LrDo0Rs>*l|Eq)sDTB z`*iO+BWKHq)QT-zg$MTT$Nw`?y<`#9ba0oxsWS&$%<9u|@PLj3QoE0uSHP=8VnV9- vH&1h>zS+Op+yDRd@YUhS)KiBiMh5-=JvjAd6#qLSb-?ZA*-|sTI1>ARo<9xa diff --git a/usr/share/locale/pt/LC_MESSAGES/big-remote-play.mo b/usr/share/locale/pt/LC_MESSAGES/big-remote-play.mo index fdc5cce0613858c5f3110ee6cd5742a5414ef2e0..ef722de366cd45133d3a5688c6678f8632972479 100644 GIT binary patch delta 16534 zcmYk@2YgRgAII_gj|@SAgv5#@#0VmGA_-!))ZVj-Qc;`Q^+%N2Rc-CPTD2;6DQeH! z)F`!A?b4dh=leg$^Xh%Qj`vyj-gAEEoSV?+S-8*p*j{hM1FJbsC>F*L zOvZxP3=24p+ZjqGJr#?vEUrL5yowp|E@r{!SO(Kqcbv>v0o7gy)!qXAu?xmxUt3;; zSt+l??6@6u-)Z#W{mvCKxv98?8nJf`#|gr`m<>x}FxJ2bY>9f{U{qu#pdvF1>*6w7 z|1T<{>EEM2kt%?Zl;hqv6K;W7dB4-1jAqamiN5oNbq*Gwya5%$i?;r6%uhM6rioN3 zBsflj)r|?1cVQg9#Z?$r%W+oYP5cn2eL(yblILW?Frc>MWWb`R8I?oLyc%kN&Cv%_ zY<&+@gi_HTr(t1SfNTrrAa=umI*t>CgHiX-Lp^s%9pbM6Y^FjXJ&FqH71Tf;q1NgZ zdSiOxn;rvDOB90IGtsDlw6*PhQP2GxwP&WF?%#)jco_Bk3w4RVLVlA9&A_Q=LY)EC zF`q4$M3&E~iJHk|RK(U}2|S4!m`{B(ut3yqk3>Bu9@YMywLWH|+`>&pGw);_fP2|2 zV{M0q4Nb?k=u5w@sJ+k!_26-+3Cuu6Dh)NWZCDf!p!z?v?SYL<|6HgDxl5DDN2VsK zV^=JVBT*0Dhj~7r&SRv` z|6MXVrvXjPjYTk+a&^?wv_Q3giuz7`iHW$#wqM4Qlpmu89M#N3s1$18RZ%Z&g=+uQ zmIq>x&i@!PTFY6e7ygJzxD^%p7pMXGG&eKJX^li}s%TUsN?`~lp$6CpE8!#z#osUn zZ`yM97Q|mS#E{8`)leNsvpx^<7&Me+{4y6&l%C)GpqLTC-iK86HAK;1p_)+(zw@EUnCvgkcWK z=EAPFel+U7Y1YN9h<{-!)=;4lo=1)NHtKvov>gLmn>~;lRUeOW*cA0)nTXnaQ&1CF zg1K=G#^Mp2i*GP5&S_&Jz12-7I~Au;FTRUfipQuAjvpWDoLCRlF9r1?xAkibr@R>T z!(}&W*WW~asB*XEC}T2e$#$b6brdV0`!*Sku+T?@51S!Bx}C|WB{_x3n68~!f@-Kp zHAkK6PPV=)YR1D*A)keM(FWUo6eB2KL-q4%?}@0}i6o=lTM;$$_pt!BvE@|EOL-b9 zv>Q;TWhZLW9Y-C%Yp8x99ZYD;qK;i1RK(h#?(2!#BW}#C^Zyl@1S*!}OuUKVnEJ7q z!9v_hc?(A2ppIs#=As_F1huI)p&q;kHLx?b{x)hL&rnO?+sW*itmrxaA!PJ`XjCN1 zp+=gF+GO=mr(rZI^pmgz&PLsL1oh(csN;JNebD<8)1JW^iaHfhr~$^HTOqGRMr+a- zwb^=F$6_+&MR*slV-grIdfcv(?OVkK6^fsZ2MeUI)s0VzA8fZ7vjQgVo;zn)0 zNjL<*MMWm4k2y7AsQaRjiMXBOWc1?Js0XCj@<1#`d6X@uVG!l*s3kgq!I-|UnMod0 zdtua9(L|cnUR;M_3MBpPBY})C(G*X8f@&cST>y zZtEz_N_hfC;9Sgu+c67X#CCWegL%JGzn@vlk1;#t-WY;kVkw-91@Jg3^#7nD=GWgG zw``~vOG+yKVU*YH!>@jr<8}0Nw-4 z41+NnFsy5bCtl#<|!XwUmK_O+<@eI?BnY2~|VAr_NyFuaGyRLNn`zn$Zx{ zv73yV`CQaMS7T+|j(U(6TU@6kBPL=A)b8(!-q;)U{QkHGC!i*p{JF7_n@kQWI-%!0 zqGmP@%j0a+K+d4{!gbVxa||^D3qwVuIOfFa7=dk3&l`q%-gsM{gPO=P%!TgFWEA34 zsI_{9+7sSHMi0(`8gVQt5>;${BU|o*dXXZjO*$HN{|Z~)fr{u&)C*r>D25L6+~;;; z$!IMSF&?X>nJ6RC?DcvD+WK~10+YUxH` z1)cwKWRj`akGe6#2(x*DFphF@)C)SH+IyjvU?6JG3`H&B1k_S3!Q8kO75bxC7_XpC zS;kazUk>#A{l5^INE*tcLf#5B(%z`hrlJNg3H#s-)XV}$a;7m76`3)pfh|Sdw;Z*H zHro2fsN?q>qwo!Se*e$^g?V5CY6f*sYt#xg!#>s#*2$EEU?IGNl`&|P ziCAM)BwC{e-W@f-;iHJZW;%fiADn~HxCr&)BUlzMqWT4oHldEf2+9>O0YAioI31ak zvmG_S>R+0*?v4{E_eVX~XN-wxwlS=K2`a*=&;aUNo1$LW8ntHKP@8j*txrSEXcua? zpGM8-4r-~Mp)b0|nn|p-S{4?rI2`r-IoKK3qh1_7(X(mYP68PnpXL~Wy-*SO8a30U zs8DUdKs=d{Yz%} zRL(jbQl{}&aGZvkS+(i>@_^kj2LDBE%0e?7=Lc+q!_jZ1*+XM6h4Lh|aTLDbAHVt#yV%OUejWQt-T>Jw2B>3~|IZde=L z7=VW`Bc4O;p&O{Za~Iuu@Dnl$ap-(A<07aRH^%gsf?C7gm+_9xEuAn zTNsF$7MKC#Mm;AQqp%ceQ#V^c{FV8TiV*CG>2QcG4?`Wxsi+apM$Kq3YDQ_cybU#w z!>9rNftt`=%!_ZVVGB*9k}!$-8VlX##i>+i#4}Lm`3Ee6TTw53j2fu3$dvt15y_5v zaSZ0eidYfbVNaZg`ZQ--5ez{MtTJjZ)JOH} zh#7D&M&c;U<8qwuQ8U{8y;*`2Sc3Y?I1sZga~y)`jN@nx#8{5P1oofv1DOaa;#Qas zLvzecxerF;BrJiOP-}h%wRX?Y4@<8!15ZRn<^!yZ?NKvcgaNo7HIZGY3H*-bb^foB z(Wc3}%5*4-8bC7Yg{{#CJEJyJck2YyURjP>n(e4B+c{Lo|HeQJT5SdpiF#fvw!%aV z;r-53GEuk^s}QOasF{bXF}pPx_262l7k0#gI1sf7=V2UfL~X){7>U7Y<}aOPQQwD- z*a3TBQ#^t0T4ai><)2;fGdzjE;1Qg%jyi0=-kj^RSc>v1R0v}?m=Gsm2<6%sgdI>z z(+{)ZP*g;wp!%)G2;94Y_%|eTi;6**xY7Kp^?LNBdbbR=r6W}`y96g7Y~wtYWp zS6{(6FUNU`nsKphCe(?To^pNEeJxNC>WR5A6*a-RsQ0*kBvX>iI@CySq8=Eq-Q17| zHL!S$!3LNM-B=7~pdP#zb&9T{mhK_yMbEA2c9>(C88!0;$Z2vrpODc5N1<+`oKH@~F^NK}Dts>H&jM5%~_a$yT9u`Ci+80X2Z`P2?;p zGI!7yz4sCSvSc#uGizHJwe~eJGq%L^*abDSKBy3WjatKBQ8PS^n&D+sr0!yIboQIb z#G+oDgc@K|)O)(^C;t9qhEk!;^CfDei%~P)U_F4PC|^W{I?DkwurMq{xge^4UDN>E zVG*2+b#V)7#u*Qq0R*8U6zwLX7ssI@(HK|YSS*O~hfE0DqC!6a^`Ng%Gn$He!B4jA z95(g2Q2ooGzMQo&9J^o?jzul8dlebY;2KWFOh?SQU4%M5tFa~?#|oI|H*;EAVl&E9 zurl7oHW+i%+&2ytkrn8T+fkw4jateJ$iUprOENlEIgi;9qefU86`5WbffF%3u0pNx zMpT3jqc+)7EPw&W&0Z*t8bC$NiVd(Lc0fgB0ea5=b~1Y4In;;d0cJs;6K2FAr~$^H zBJe&=#!(oJIZm1%yH)Te<&LQOI;YHl`=e$&4E3D3m>oBx=imR1+J5fcGh{!Q$LE|FVhT(LYVU zi!=AjS4?{-D#B52GHQrHtz|Wg!=|W6OhlcArKn?i2#euUROBMBnp02|6@iAR zCHx4>VK>xs7oldp9s_X?s=xab8HMI5>Wk%n&Ag}#>W0>+2XsX(#Zc4>#$tAyiW<-g z)E+s9k@y#CsWM+T_vc3qxGd_qmC#4$zaE)MR5U@2=sIfmKe6RESekOC8|KTFj2ci& zYgg1vhoWY-4i&jwn2Z-u1Ix?V)C5YR$`vp#?|15xsY*o;)LNyX26DuD0evaoL2bfE z7=`|~%-$%Dl_*!me3*)w`4UWzn^Bu|C+az8t(R2K`<)wP6tb6?AH!~&8CJ#=%8gJ1 z+lm_bS=8~niyBadJA4x`AI9T{sD4vXOSKHWaRY|q7R-$o(5;4l$Y_MGF#`tOH7^Xa z#-rA{5$b^{w!R;#|729eW}%MdGSsHthZ^7|)C-@Zo)dh}{P%$9d&IvC6%(oO!=0Fd z2T`Fa`fP9zgRrUPcdUbZ{}^}w%Cp`48h;b!#5d#Fe}L#=J5r>0yO6`6{t`x>E+V^0jk zF{sTt4YTR|pCl7a#ckAsg8w$75OVAJ%k&d=~1m@KFpJY4yfF&qz!L0a~^(_)AC+olFOIQ%~qL!!`_QcFM z5*6Zaa4;^!oEZMX{F6(0)PU1a$No6Fb;B7lmGA~?#06iP-5G;LD3?Q>>vpIIx={m~ zj{4%QL=E^DY9LopGk$^<(D#+uW7Sa4X^3U8=PTl0m&{@+wAr3wZgjmiBMrkGl#@_5 zHbM34gJp3TDgx`VA|603rT-ga1ZtPZVH0eN3AhgT;KMh>zc`rK}>?Fa|XMx7#*M z#ynIk#b&q*bzJgzyF8nwFosfo4~t@3)QrbjXQBG9K<%kbs2A+RVt5XQEBeT~|* zvuyi%)O`mr)Q|PQLMEAt7lAHk1}0`UYke9MDd*1O^6cVf*u}+KViNUEkV}8taw?#9 zbH-qo^9?3rDLjRxF?}|dvm29eCVFLed5+&4?9cn1g=FetZhiwT#0$FMCdyxjx}0rT zJBJB%rkpNkH03b7jf+v6Y*H>W!-c3HGFz=TP)m?Gx6AWIj6@yJMyLpNK)2Sq9~p(@ z2x^mDM|~h(qIRqPfmdr7fMJ+`nptyO9*^35%P<_%P)l+GBk&4pAnC$fp5L6ZqmE@l zn9J=s-|eZ;W*TfAk9xsu)b2fIJ&QUemry^ZU!XSS8`Q23$!m^f9@K=QP%}mEd48tExyg*6A{FzZZ=~6^1yC=JMSXC3qh|6s#^MB2$hV>%yvKS8wS@nm zA{Cg=+#ikVU&fXzpnlf4-zTGUIS_SBR-u++KkC8fP&0pm5tt#r8Awsoo=8Nkb!XIl z{ZTXj5;gF7s2{gKB755z$sbcmJxRZ_|C}GZsO92RImx6}q(7UFxQTMm)+m5m#!;csz$@aeo?NhLik{3jIK zc`bE1$Ms0%NK;93Y#UiV*_;Scagr{5yxx+=Q=Wk7=&SFA3j^NWr}sZ6>Do=lJ0$Jo zP&|vA9OnS(B4u3zNR>!)Rp1(F>v!7+eoNiIBt9vgt1^zFPbbpnq~g>*wr!Kq{Q-X@ zlKPPr(s+PQYmu+A(~ZPWO3z29FZB(`Z=&ucd0l%9|NnCb_1a`jYb z7L2Ft!p|vtk-nh(>y)gegWWSI=^8?6NIFcpBCaCoszF(&qa~@X?fWB7`kqviy059z z&lnx?9=81g`Tt&{D3r8qzi{7L@}E%W?WNylxt37L=frtKdXI8AX&H5ON&HOm9C!7b zMVd!hS7q9CZTI}iFS?Ygkk?o85$P`Fj<(NKJY)0vzYVpaov(wlL+_6vGnZ74G>C?O z@F#qqj`cAT+mcREKMx0y3X-OghEey7KE24VBwZuFpQLLMDHC9djdJNs>iU@H2PuDT8bf}$+O+-+$c!beqtkg+a!n!y zQLaELPn|zyKT;o3Vag-%Pm+Es+C|b;hx$77Ur&0Ebc8zn&aqUDTwl=Mg7md1dj6b} zCoaAFKAm+%k*3q31nG&VhP^=B5XvoaA`T?!^2WBL`J|61e@Oec@F zQtdlFvhCf}iR-++0Rw3~NntX6Mu#lqJK;vkg~<0O>H3?x_v{0_Dd!~rH!fBq*AJ9G z=AHuNo1v}*>ZXt%g~Lg@o?tfHKHJsYRBp8u-_f{}{88Ikt;v+DQ*KRuHu)xah4Nf` z&r@8Yj$F0veYsuwxu6AJZ)oK&&m9zf_+wL zZHtx^iqdf-{Z_4@k-nvkzmqwM_%rRVaRmCJezlxP zx=XvR&kW8{+Vl_jHL->Cn)(=09Zw5C7OB^jX7%LxBUKr$^`utserBs;I~~W6LU_m} z{GQ}X>Poo>?Uyi?^oFD>7ak!Mr%y>+_YvNt?Knw?e^CKNE;uh+ck=L~qYfyen`i9hq@-@a2@nnwyGg;QQcpJ%q;F*PDox{!39a(;V9G5g4K z)So6@qQjo;#y;aFP9mKmW%y6OpKW_*E4Q?A9?_u!xr3wzG|VDRA%Bddt0?V<@T>nk z$kV_dZAnY1pGi8)eGe$d*@uVz|A$i%TJ;m~chUt~585XM*q(n;-<`Tww!RDHha_E< zY#&qNH%!W}={FX4Vm&NM;~jgi`lV6#p)I%ALFPAG*g)gIG=7g;)YD!YsB1*h^*g?$ ze^ZRc_enFz@3qfRpN=HIw93ibgVSC%O7fmkqj{)b&w&y3+x6|7*0Oo)Txt7zpAJm> zVfZ%%)0Ivrk(T`Z;~Z&4cdYg<^l^_!r)rO$DeZbk)U5w5SHEW6ci9f@x}|jQ+#}6v T&)D#^xz~Tmn)b=_Eyex^%V7W$ delta 16604 zcmZA82Yim#|NrqTOA;#)l1PLEiPa=#jMRv|sl96!v3F2<*R0j5y{XxnMU2>LtD=gi zQPgZ}E2T>Nd%f>-eE%N*>+v{!p3gbwI@ej(b*KIQerJw)FF)q(x*Fs?%i;3ya-0Z^ z&gnS5-i{MnOr?%fE6H)PVr|TVEz!%vak^q{>`~cq=2E_;isKB#`c)mL7k-Cf7+=kC zLa`QxUjtd9mfW|YnL!k4 zsf(Z*Refi$|UI%BX%?qUyU@`(b9{VJS1MQ}u1~>!N@mkabcA{45ENW)g@ICwk)jp(&sgFmsPeiSds|lHW zWRg)0r(j83fok|Os>3H3h_7v&r73R+aZU`v7O4AsU;vIr?e%ojOy^<_T#0JG6FIyt z=Omc|6r4vje2rPqznSSU6m>&U)CZ~x>aev(y{^f&ehh{a&&HCt3H5f|wmv`&>^bT+ z^l7gC*#ClLbYnHlj_pxfGSJZ~zftt{A%&PZ)8yP+DFqX#)s3p(Q(hMjPwbvz4Gpb^(gE~}=F$CM71~>pK;RX!F zhZu($TA8>Ms(n3lW6nrX%tc%tb7D)>K>MTa z8;#*O*_N+DovrQGLs*FTOdHl;BYZ`HM(o$tyw?G!8{#km6K#15EJHjPHRJWDz1)nN zz+udVXRs(fz$KWwo#W)eJ*X|Yg2DLAMMe+K$OleOdwZ$40oIF`6Q>MY$tt<)o|fPNj!09`f6l%rq>@{{dsL~Y44Ou~E}%@%Y< ztyBu?y&h-FC!=P(1hwS5P!CGC^^Y)$*sGIi7mX3bNyuyLa@vv6%)4O>jaD1X8el!tihqbM zEyX}GI&4#|t1*fAAl^go?&ibv7&U{qWHaCrs0Wuvl{dBJtxyB#jCx={)Wk;H`e~>M zEl6hlRk4}^EqR))ID_io8b;xL^u#PZ%#DGVmpBjVY$Tx$RXuEs$*6(v#zr2F^G#24 zIA8TLOCQ|ZalWFyP;b^>OLLrpQuq^YN8djDMa+%+P!IU7uUXoYr~#ivo%S1;5#OLM z`t>tQ8;EKbgGI3v24WY~SsIM_alVU8J~DgJAAd#7>>g@~vh+7AkqhI93!%!JV*qxs z^@C6Y9fi5^bBx5bHa>wG(C?^rFHsYBc@HoT$bxDRjv8TM48jJeGtv=NKN2<2DX1CG zMh#>!`rro4fLl-x+Jz(VC~Bpu4K(!)k$zlGb26G)2h^T_f$DI9jn`r9V-WFE z)Ry@SG9Rc!)B_u!>RY3}oV~Fuj=>zb8#RzKZrT4UWHjQts6+S=HDlkwW+3^oG;v8( zeGk+F#-MuzY`hrN&n9acW+UE*QFsP*NS|UL1`J_U_5MeZ$&RB@dpjG0aRr9pPAq|E zFb2I-%vKaZtynqK>sJHyfV!xGc1I0x6lz5$qGmo1b$C~xOApvbMgvGkb@&H{;tLxG z3^j)(0yXj&)LAHvnqhU!fi0}Pkl!O`6c)$psDT6xsEInXtx#{zC%6Q^LTzQz$7V&_q8IT1)af7cG3&1f zxom@psF^K6&1eH^OAexDeg-wr8(0~iqB<(Y5!V1J;QQDK^)@UhNjOIPD&+=rUz z0M|&98HYJ3n2S2an@}^`gXQovY9Rifn6nUy>bMSSU=2}Q*#W~a1*32ps=v*s{`T7V zN7O`ISIC5uxsO_6UsgnW6^}X-rBNN%L5)}^Ru3Fx%g5PxA?iWvQHOK~>i%ms{u8yL zVV|1k#Up3T<OsqF{d&|^Y(<@&ov5um zj@q&tsQVwI2AX+{`QtMWx_|#`lF^M#Q4Km{UL1^C`Z=g2-+)@FeW(GPz=4>Knqi%> z{1t`mP%Cs8HL#ng`+h^6rDwK0ejMws_pl_HXiP*susy27A*dNlME1&=gBs|!*1gt~ zScvi~s3rCpZ~6(qg2V-|GB!YM$(N{!&K=MC>p^QM&!=53{LK9O zei*t>J!+{tU=$9+M4XQW@H{eE=QV18qb8WGUW1<#r%qu1)p6yCW=TK5V#IAw1DIl+ zj(Xr+)E9C!>X2@;<&RJ^aweHmAB37v0n}ENzzkRcwUX6P--EU;GI|dOpjKiu7ROnr zU%jKK)Bg;0qyJ>nQC{>WE`+*24t=o#s-Nmu5nG}LJO?$v6}EmGCK0=ilgUIT;}r8= zhM*o)9Q7fojXDFJt^H9e;X-xzr7d5E8u&&mj5n|pW@erFNII1<0{5W$zl7cO{y!z7 z4^W#g%&8rM+S8dBh3io(a0)fko2VK7iCHnjRCDNpQHM7Y)xNZi>*7Mzz7y*7|2K^jK#4i$-l#Ez|&7p!)e}8SAePPag{Odd#rSNA2ZO^ujb7 z??%0rXHf&bi0bfH)FFOk<9|>C@n3ER7>=4yLCk}R)>g||e=XHe3d-YX)Pwh-Mx2iN z6}yc(&3~gF7{9^{xI8MZidvCIs0SxwJ{*n}aS`^zE2vL%{gq|~db-F|rC<=Mqcqfm zkDy+s^QfirU1eqvi288kN4=iqPy=p)T9F>s0q9LU9CapKsQ$i2ZPi88*>L@3Gk#y2 zf^gIWVo`^x3Kqe}sDX_{orNi=c1tlMZpXZM03$s34-Tjad8{^D5P-!fkHBGAe+~Zy zOz;0u-mPIY?77Z-lW$`b@xb-wyD$@T5r2z?@dOsb=cqj|u)%Cy3G^lIiyC+eYGppd z%D5Oc3X@D-lI(_8tY1Q%~J z?{$vt=F3?YwSqlSOFRTMkqH=tOHf<08FS!H)FC{*o%L74`xHc>SDN|v_h=kWoPzJ+ zQ_O&Qc9`FW7}RT2(Z+2tjCe5Wfm2aSybQJE8!;63VH94#2KZ8crRq@C+-Vy0N1f`4 zSPqw<_WYu)zl|F38`Ku%*k$U=VHk0B48-=BANye=oQcKo4#r^UZu4EJ;3A_3j6gkj z0_MfVm=pJ-_Ua;PiEp9?@X*%#>@iE82g`W!5Y&vj?=wrCg8F_;LESeCwLA!@IPSt&Oh>iz`p&#Xk*NM-t!1$?aW!PZ zE@wO$y+#Ys-4SZ9(@{(Li!FbMS|R@f<}gK~9#{}{UsKe~`(gxsg4&V=m>0LAR`3F< z{SC~n_x}wUHOzL<{7v^BY9+d$maY$K$x=`&G6~heM$}52Kpn1IsMG!0)@M6p`i;dz z%4?wd8HIWcXJQtf?;Ikdnf`>@qnoIiyix=7KWz3m6!n0@)`}QF+yrZ2XViyl4QfTV zqxwllP2@IeVh>R(@e1{3AOVxm7l)u$E6q1U2EL6J`K4U1YRGAEF-I3$+pxaXs$C0@(Wp zvxGBIOTQk~(P7k#en36oj*Syen(~II_Q{wVM`0u`!Dw{tBcnaOg_=RwDgI>vD`O_S zhFC_IQ-@>{4qet=pb&!5Z+6~-9iDwq+wV-OC+Y&ae(;#|zF_x}PJ zE#Y%ihXH5Jhb9^Wi4##Hu8SIAC#--YaT@Nz!dU;D`MFKOyTtQR<)hD=0k1erq`q|VcVl(3ASlELf8LUa1 ze$l+vc`ljPxd5ua(wA6&&9E^A8bCYL0J>pL`~#seUNI^$T#Slgwe1(q##{Obvn0D1X-~y)6?mqhB z%4_EDg!TB4_%6nCU;1^kf<8A)yC4jwJ{&c{1k?)GbCJ;!x3U$TPsJ`Ha?Me1K?lr&pP?Qw3rpiNRKFKdGrxyg5!XLtbVJ5lW@&O_5#oxd z2PLC!n1GTr|HwpA;C;`$Hqq!!oP-)kb8Ck?4bCQ7bYDHPD5q2XDee z+>LsRUZ7T{DDS9#OB$fs_k76u7bo*61#!3;ci?p_i1QzrLvsMVh%aL%yk@eRXdCDtbFb+qp;Fp*|@Bbn)>Tm^WX|~%2 z=TLk3tF8YBixFr2+noAJ)<&om>x?CFDC$A$P%}M%0ho?j={xu_K1Wv=nf|Z%`r%~M zYvS{ddH+&Va9%hkFZhqqZyw_5RmJ-REjZMi1zOx*-`GsRA{CGq(IDMiBpvEilB}%)Afg zAs&vQI1k^$O{f|FVtt5OssB)CDwB`9f0q+ZCYFL&)Sk7%nm84w;RTGs!uOi9Ef^9SE80^D{8Ngp_Vd=|FEkwQU>++L4DNg z))=*g?J+k_!IHSf##d2??=?oEPj0g%F}Xcl?!7HVfd zJ>U`Q@aE5BEP{GVilct6>!A+i2dGou9obQ*4{Aa~^0>^5Ker7Rp_XhbYG%h#14u`m z>PJ`rb47Z%KUh_;3h@W14ribSx(3zHF4RCzqdst6QO0bjfknD(rVQ#q4KWJ4V_lqx zqwzfIHEWqyrA%-<;Oss4c96S}9itGV0)C)D7cp zJQa%(&quwMCs1#RM}9NIT&MxZqGnzlqp&S%AR|#{;&asNw-dE8$5AuCgglr3{f~^^ z*Eh)FcE<3Jxum|NOQf})6ms#_Ih9DQNjE9uEpmP%y}iD*nRE=b_t2&LU≠rZ+;@ z7xtdZI7{#U3){E@1zMVaNblQ5QMRrpeoXndn3ptwq&G&F-g#ZyC_97=Nu^1%NlR=U zSto${)}&%2UHVw*_lIvcCmlU$`~zwkGQV@V}{UHc49J>n|lGm{>Z9uRl4 zZRX)coA=-|+>UxaN6vmdKbFi_qz0tnRQ!z_u?{zUfcdc_DV_3VID%A&G@s<6>^W@) zlHW+WN&Xl~*K$%8%5*)U-C*)vu{XZP(xgsi{*0Q!&nY-biY1>;DopCh%@0WBDZfT) zqu#gHLcyGi=((6ySpuCdnc8Df1`R-w6Xr`ipA}UMJ~~qJtz|^(b#h z`!rH*lK#~5BCS#-*I4RXlBSu^{m&V%OZ&MV(^ywDX+8~#lAgLtI0MvuOxzZy;!u(< zFZ__SjMRa+1NF1Xk0gyF4JU0R)uV1cX`FpVd&+e6HaNfN8}KoeXDOJ0gJ=*){v+H; zT#$TMBRsEb^b>rzBm^Fxb{F_GoDe_t}Cu zR30XO#x_=M72;~dZOJbo-wbaOFSYmlg)7yNtFFB-+(W+$+TiWB=Mdrr)K|n67>L72-AEC(T@~_q$tQgG&W(>~ zT!6R@zP&zCN2GZaq;OLK^7m%sEf?xaB9~0sMf!@QYcnl$ePUI%4*x>v`-4x2|VozIll6)uqL3ETV$Xj3`sA` zFVyMUgI!3qNoPqrC~HjHxoUNdU;u@kag*)noHaM)wMcuZn~6nG*LCWDGg+srwE>3F zCJZOpGLcHzdd$c@`N-$P>AIhET0i=_N)f!hV#%zd{0i=+e64M>7i$qeBh4l?CcaIY z{Psp%M7gf=q>dS%<1h?(0qyD1ML{}UB`ZP|I-b~16Ixgp4jB$#9G{*Zj8|NRC0g64~;X+^q2 z{v&Evk}eVFw|Bf}ySzgA&!nrwSE>6N6N!Vdzipe*wmnB&iu^^=F!I0HdN0~fq%0h{ zT+XLtc2N*P@+Ik7O={}S@k2^|De~1x|4@I7_%>-2aeqlFNIFG&dwp&5!?6gdmyMHX zP@4QI`yBTH{+1vE6=$e8ZYwhVuYu{Z1hluE{EpK|7fAl^wA*9rdsubnQ|c?=_oOD2 zFD6YV|079Pk#aORL1x-J9l2}xM|;vYlrJP*;>L%>Y}=TK|3fL_e`z-b4`CyGkIH-YUbWjwSvwoIIzZ+}8*HQU zA1c4bU218s9h5aA>AEaQElGvAuO?|B`D3;ZwfTsYAvJH({_LrDo0Rs>*l|Eq)sDTB z`*iO+BWKHq)QT-zg$MTT$Nw`?y<`#9ba0oxsWS&$%<9u|@PLj3QoE0uSHP=8VnV9- vH&1h>zS+Op+yDRd@YUhS)KiBiMh5-=JvjAd6#qLSb-?ZA*-|sTI1>ARo<9xa From af33de3c2d4318b869d9f1fbc698f789ac45056f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Gon=C3=A7alves?= Date: Wed, 24 Jun 2026 23:30:03 -0300 Subject: [PATCH 13/47] Fix server page vertical layout --- src/big_remote_play/ui/host_view.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/big_remote_play/ui/host_view.py b/src/big_remote_play/ui/host_view.py index c1b827f..c8c033b 100644 --- a/src/big_remote_play/ui/host_view.py +++ b/src/big_remote_play/ui/host_view.py @@ -139,9 +139,9 @@ def setup_ui(self): clamp = Adw.Clamp() clamp.set_maximum_size(1040) - clamp.set_valign(Gtk.Align.CENTER) + clamp.set_valign(Gtk.Align.START) for margin in ['top', 'bottom', 'start', 'end']: - getattr(clamp, f'set_margin_{margin}')(24) + getattr(clamp, f'set_margin_{margin}')(20) content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=18) @@ -427,7 +427,7 @@ def _icon_label(icon_name, label_widget): # View Switcher and Stack self.view_stack = Adw.ViewStack() - self.view_stack.set_vexpand(True) + self.view_stack.set_vexpand(False) # 1. Information Page — two columns (mockup 05): info + helper | management. info_left = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) From e7487cb8b0119a24fa3838b5ccea575b089be34e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Gon=C3=A7alves?= Date: Wed, 24 Jun 2026 23:42:06 -0300 Subject: [PATCH 14/47] Fix libadwaita bottom bar spacing --- src/big_remote_play/ui/private_network_view.py | 1 + tests/test_css_regressions.py | 10 ++++++++++ usr/share/big-remote-play/ui/style.css | 4 ++-- 3 files changed, 13 insertions(+), 2 deletions(-) create mode 100644 tests/test_css_regressions.py diff --git a/src/big_remote_play/ui/private_network_view.py b/src/big_remote_play/ui/private_network_view.py index 16071ed..ccb6790 100644 --- a/src/big_remote_play/ui/private_network_view.py +++ b/src/big_remote_play/ui/private_network_view.py @@ -1221,6 +1221,7 @@ def on_share(b): btn_share.add_css_class("pill") btn_share.connect("clicked", on_share) + actions_box.add_css_class("network-info-actions") actions_box.append(btn_save) actions_box.append(btn_file) actions_box.append(btn_share) diff --git a/tests/test_css_regressions.py b/tests/test_css_regressions.py new file mode 100644 index 0000000..0b8c2de --- /dev/null +++ b/tests/test_css_regressions.py @@ -0,0 +1,10 @@ +"""CSS regressions that affect GTK/libadwaita widget internals.""" + +from pathlib import Path + + +def test_stylesheet_does_not_pad_libadwaita_bottom_bar_revealer() -> None: + stylesheet = Path("usr/share/big-remote-play/ui/style.css").read_text() + + assert ".network-info-actions" in stylesheet + assert ".bottom-bar" not in stylesheet diff --git a/usr/share/big-remote-play/ui/style.css b/usr/share/big-remote-play/ui/style.css index edaff19..af7f1e3 100644 --- a/usr/share/big-remote-play/ui/style.css +++ b/usr/share/big-remote-play/ui/style.css @@ -355,7 +355,7 @@ entry:focus { box-shadow: 0 6px 24px alpha(@accent_bg_color, 0.4); } -.bottom-bar { +.network-info-actions { padding: 14px 28px 14px 28px; } @@ -584,4 +584,4 @@ button.pin-action { button.pin-action:hover { background: alpha(@accent_bg_color, 0.25); -} \ No newline at end of file +} From 9cd04163cade6195025a2f97deedaf296a53e956 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Gon=C3=A7alves?= Date: Wed, 24 Jun 2026 23:54:28 -0300 Subject: [PATCH 15/47] Fix server management a11y actions --- src/big_remote_play/ui/host_view.py | 114 +++++++++++++++-------- tests/test_host_view_a11y_regressions.py | 17 ++++ usr/share/big-remote-play/ui/style.css | 25 +++++ 3 files changed, 117 insertions(+), 39 deletions(-) create mode 100644 tests/test_host_view_a11y_regressions.py diff --git a/src/big_remote_play/ui/host_view.py b/src/big_remote_play/ui/host_view.py index c8c033b..675f047 100644 --- a/src/big_remote_play/ui/host_view.py +++ b/src/big_remote_play/ui/host_view.py @@ -2,6 +2,7 @@ gi.require_version('Gtk', '4.0') gi.require_version('Adw', '1') +from collections.abc import Callable from gi.repository import Gtk, Gdk, Adw, GLib # type: ignore import subprocess, random, string, json, socket, os, time from pathlib import Path @@ -438,45 +439,80 @@ def _icon_label(icon_name, label_widget): manage_group.set_title(_("Management")) manage_group.add_css_class('compact-rows') - devices_row = Adw.ActionRow(title=_("Paired Devices"), - subtitle=_("View, disable or remove paired clients")) - devices_row.set_activatable(True) - devices_row.add_prefix(create_icon_widget("network-workgroup-symbolic", size=20)) - devices_row.add_suffix(create_icon_widget("go-next-symbolic", size=16)) - devices_row.connect("activated", self.open_paired_devices_dialog) - manage_group.add(devices_row) - - logs_row = Adw.ActionRow(title=_("Sunshine Logs"), - subtitle=_("View the Sunshine server log")) - logs_row.set_activatable(True) - logs_row.add_prefix(create_icon_widget("text-x-generic-symbolic", size=20)) - logs_row.add_suffix(create_icon_widget("go-next-symbolic", size=16)) - logs_row.connect("activated", self.open_logs_dialog) - manage_group.add(logs_row) - - library_row = Adw.ActionRow(title=_("Game Library"), - subtitle=_("Manage games shown to guests in Moonlight")) - library_row.set_activatable(True) - library_row.add_prefix(create_icon_widget("applications-games-symbolic", size=20)) - library_row.add_suffix(create_icon_widget("go-next-symbolic", size=16)) - library_row.connect("activated", self.open_game_library_dialog) - manage_group.add(library_row) - - password_row = Adw.ActionRow(title=_("Server Password"), - subtitle=_("Change or reset the Sunshine login (if you forgot it)")) - password_row.set_activatable(True) - password_row.add_prefix(create_icon_widget("dialog-password-symbolic", size=20)) - password_row.add_suffix(create_icon_widget("go-next-symbolic", size=16)) - password_row.connect("activated", self.open_password_dialog) - manage_group.add(password_row) - - advanced_row = Adw.ActionRow(title=_("Advanced server settings"), - subtitle=_("Server tuning, codecs, network and library fix")) - advanced_row.set_activatable(True) - advanced_row.add_prefix(create_icon_widget("preferences-system-symbolic", size=20)) - advanced_row.add_suffix(create_icon_widget("go-next-symbolic", size=16)) - advanced_row.connect("activated", self.open_advanced_settings) - manage_group.add(advanced_row) + def _add_management_button(title: str, subtitle: str, icon_name: str, callback: Callable[[Gtk.Widget], None]) -> None: + button = Gtk.Button() + button.add_css_class("flat") + button.add_css_class("management-row-button") + button.set_halign(Gtk.Align.FILL) + button.set_hexpand(True) + button.connect("clicked", callback) + button.update_property( + [Gtk.AccessibleProperty.LABEL, Gtk.AccessibleProperty.DESCRIPTION], + [title, subtitle], + ) + + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + row.set_margin_top(10) + row.set_margin_bottom(10) + row.set_margin_start(14) + row.set_margin_end(14) + + icon = create_icon_widget(icon_name, size=20) + icon.set_valign(Gtk.Align.CENTER) + row.append(icon) + + text = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + text.set_hexpand(True) + title_label = Gtk.Label(label=title) + title_label.add_css_class("management-row-title") + title_label.set_halign(Gtk.Align.START) + title_label.set_xalign(0) + text.append(title_label) + subtitle_label = Gtk.Label(label=subtitle) + subtitle_label.add_css_class("management-row-subtitle") + subtitle_label.set_halign(Gtk.Align.START) + subtitle_label.set_xalign(0) + subtitle_label.set_wrap(True) + text.append(subtitle_label) + row.append(text) + + arrow = create_icon_widget("go-next-symbolic", size=16) + arrow.set_valign(Gtk.Align.CENTER) + row.append(arrow) + + button.set_child(row) + manage_group.add(button) + + _add_management_button( + _("Paired Devices"), + _("View, disable or remove paired clients"), + "network-workgroup-symbolic", + self.open_paired_devices_dialog, + ) + _add_management_button( + _("Sunshine Logs"), + _("View the Sunshine server log"), + "text-x-generic-symbolic", + self.open_logs_dialog, + ) + _add_management_button( + _("Game Library"), + _("Manage games shown to guests in Moonlight"), + "applications-games-symbolic", + self.open_game_library_dialog, + ) + _add_management_button( + _("Server Password"), + _("Change or reset the Sunshine login (if you forgot it)"), + "dialog-password-symbolic", + self.open_password_dialog, + ) + _add_management_button( + _("Advanced server settings"), + _("Server tuning, codecs, network and library fix"), + "preferences-system-symbolic", + self.open_advanced_settings, + ) # Getting-started tips, two side by side (no heading). def _tip(icon_name, title, desc): diff --git a/tests/test_host_view_a11y_regressions.py b/tests/test_host_view_a11y_regressions.py new file mode 100644 index 0000000..f682691 --- /dev/null +++ b/tests/test_host_view_a11y_regressions.py @@ -0,0 +1,17 @@ +"""Static a11y guards for the Server page.""" + +from pathlib import Path + + +def test_management_rows_are_real_accessible_buttons() -> None: + source = Path("src/big_remote_play/ui/host_view.py").read_text() + management_block = source.split("manage_group = Adw.PreferencesGroup()", 1)[1].split( + "# Getting-started tips", + 1, + )[0] + + assert "Gtk.Button()" in management_block + assert "Gtk.AccessibleProperty.LABEL" in management_block + assert "Gtk.AccessibleProperty.DESCRIPTION" in management_block + assert "Adw.ActionRow" not in management_block + assert "set_activatable(True)" not in management_block diff --git a/usr/share/big-remote-play/ui/style.css b/usr/share/big-remote-play/ui/style.css index af7f1e3..885b454 100644 --- a/usr/share/big-remote-play/ui/style.css +++ b/usr/share/big-remote-play/ui/style.css @@ -572,6 +572,31 @@ levelbar block.filled { margin-bottom: 4px; } +.compact-rows list { + background: alpha(@card_bg_color, 0.55); + border-radius: 12px; + border: 1px solid alpha(@borders, 0.35); + box-shadow: 0 2px 12px alpha(black, 0.04); +} + +.management-row-button { + padding: 0; + border-radius: 0; +} + +.management-row-button:hover { + background: alpha(@theme_fg_color, 0.06); +} + +.management-row-title { + font-weight: 500; +} + +.management-row-subtitle { + font-size: 0.85em; + opacity: 0.65; +} + .metric-orange { color: #e66100; } .metric-green { color: #1a9b54; } .metric-blue { color: #3584e4; } From 9ac024ae961c254a351ea5d0f900b4873867f12f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Gon=C3=A7alves?= Date: Thu, 25 Jun 2026 00:01:54 -0300 Subject: [PATCH 16/47] Fix guest advanced settings a11y action --- src/big_remote_play/ui/guest_view.py | 50 +++++++++++++++++++---- tests/test_guest_view_a11y_regressions.py | 17 ++++++++ usr/share/big-remote-play/ui/style.css | 12 ++++-- 3 files changed, 68 insertions(+), 11 deletions(-) create mode 100644 tests/test_guest_view_a11y_regressions.py diff --git a/src/big_remote_play/ui/guest_view.py b/src/big_remote_play/ui/guest_view.py index 74b17bc..eb3eafd 100644 --- a/src/big_remote_play/ui/guest_view.py +++ b/src/big_remote_play/ui/guest_view.py @@ -142,13 +142,49 @@ def setup_ui(self): self.hw_decode_row = Adw.SwitchRow(); self.hw_decode_row.set_title(_('Hardware Decoding')); self.hw_decode_row.set_subtitle(_('Use GPU for decoding')) self.hw_decode_row.set_active(True); settings_group.add(self.hw_decode_row) - advanced_row = Adw.ActionRow(title=_('Advanced client settings'), - subtitle=_('Input, controller, codec and more')) - advanced_row.set_activatable(True) - advanced_row.add_prefix(create_icon_widget('preferences-system-symbolic', size=20)) - advanced_row.add_suffix(create_icon_widget('go-next-symbolic', size=16)) - advanced_row.connect('activated', self.open_advanced_client_settings) - settings_group.add(advanced_row) + advanced_title = _('Advanced client settings') + advanced_subtitle = _('Input, controller, codec and more') + advanced_button = Gtk.Button() + advanced_button.add_css_class("flat") + advanced_button.add_css_class("settings-action-row-button") + advanced_button.set_halign(Gtk.Align.FILL) + advanced_button.set_hexpand(True) + advanced_button.connect("clicked", self.open_advanced_client_settings) + advanced_button.update_property( + [Gtk.AccessibleProperty.LABEL, Gtk.AccessibleProperty.DESCRIPTION], + [advanced_title, advanced_subtitle], + ) + + advanced_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + advanced_box.set_margin_top(10) + advanced_box.set_margin_bottom(10) + advanced_box.set_margin_start(14) + advanced_box.set_margin_end(14) + advanced_icon = create_icon_widget('preferences-system-symbolic', size=20) + advanced_icon.set_valign(Gtk.Align.CENTER) + advanced_box.append(advanced_icon) + + advanced_text = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + advanced_text.set_hexpand(True) + advanced_title_label = Gtk.Label(label=advanced_title) + advanced_title_label.add_css_class("settings-action-row-title") + advanced_title_label.set_halign(Gtk.Align.START) + advanced_title_label.set_xalign(0) + advanced_text.append(advanced_title_label) + advanced_subtitle_label = Gtk.Label(label=advanced_subtitle) + advanced_subtitle_label.add_css_class("settings-action-row-subtitle") + advanced_subtitle_label.set_halign(Gtk.Align.START) + advanced_subtitle_label.set_xalign(0) + advanced_subtitle_label.set_wrap(True) + advanced_text.append(advanced_subtitle_label) + advanced_box.append(advanced_text) + + advanced_arrow = create_icon_widget('go-next-symbolic', size=16) + advanced_arrow.set_valign(Gtk.Align.CENTER) + advanced_box.append(advanced_arrow) + + advanced_button.set_child(advanced_box) + settings_group.add(advanced_button) content.append(self.switcher_box); content.append(settings_group) self.load_guest_settings(); self.connect_settings_signals(); clamp.set_child(content) diff --git a/tests/test_guest_view_a11y_regressions.py b/tests/test_guest_view_a11y_regressions.py new file mode 100644 index 0000000..466b8fa --- /dev/null +++ b/tests/test_guest_view_a11y_regressions.py @@ -0,0 +1,17 @@ +"""Static a11y guards for the Connect to Server page.""" + +from pathlib import Path + + +def test_advanced_client_settings_is_a_real_accessible_button() -> None: + source = Path("src/big_remote_play/ui/guest_view.py").read_text() + advanced_block = source.split("advanced_title = _('Advanced client settings')", 1)[1].split( + "content.append(self.switcher_box)", + 1, + )[0] + + assert "Gtk.Button()" in advanced_block + assert "Gtk.AccessibleProperty.LABEL" in advanced_block + assert "Gtk.AccessibleProperty.DESCRIPTION" in advanced_block + assert "Adw.ActionRow" not in advanced_block + assert "set_activatable(True)" not in advanced_block diff --git a/usr/share/big-remote-play/ui/style.css b/usr/share/big-remote-play/ui/style.css index 885b454..9b53b00 100644 --- a/usr/share/big-remote-play/ui/style.css +++ b/usr/share/big-remote-play/ui/style.css @@ -579,20 +579,24 @@ levelbar block.filled { box-shadow: 0 2px 12px alpha(black, 0.04); } -.management-row-button { +.management-row-button, +.settings-action-row-button { padding: 0; border-radius: 0; } -.management-row-button:hover { +.management-row-button:hover, +.settings-action-row-button:hover { background: alpha(@theme_fg_color, 0.06); } -.management-row-title { +.management-row-title, +.settings-action-row-title { font-weight: 500; } -.management-row-subtitle { +.management-row-subtitle, +.settings-action-row-subtitle { font-size: 0.85em; opacity: 0.65; } From b56c62b3a5e9eb92fbfecb5cf523d70dd3c42d41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Gon=C3=A7alves?= Date: Thu, 25 Jun 2026 00:32:55 -0300 Subject: [PATCH 17/47] Fix host browser a11y actions --- src/big_remote_play/ui/host_view.py | 74 ++++++++++++++++++---- tests/test_host_browse_a11y_regressions.py | 18 ++++++ usr/share/big-remote-play/ui/style.css | 6 +- 3 files changed, 82 insertions(+), 16 deletions(-) create mode 100644 tests/test_host_browse_a11y_regressions.py diff --git a/src/big_remote_play/ui/host_view.py b/src/big_remote_play/ui/host_view.py index 675f047..26d2fd8 100644 --- a/src/big_remote_play/ui/host_view.py +++ b/src/big_remote_play/ui/host_view.py @@ -2284,19 +2284,57 @@ def open_host_browse_dialog(self): self._browse_load(os.path.expanduser("~")) dialog.present() - def _browse_load(self, path): + def _browse_load(self, path: str) -> None: if getattr(self, "_browse_group", None) is None: return import threading auth = self._get_sunshine_creds() - def work(): + def work() -> None: data = self.sunshine.browse(path, "any", auth=auth) GLib.idle_add(self._browse_populate, data) threading.Thread(target=work, daemon=True).start() - def _browse_populate(self, data): + def _create_browse_button( + self, + title: str, + icon_name: str, + description: str, + callback: Callable[[Gtk.Widget], None], + ) -> Gtk.Button: + button = Gtk.Button() + button.add_css_class("flat") + button.add_css_class("file-browser-row-button") + button.set_halign(Gtk.Align.FILL) + button.set_hexpand(True) + button.connect("clicked", callback) + button.update_property( + [Gtk.AccessibleProperty.LABEL, Gtk.AccessibleProperty.DESCRIPTION], + [title, description], + ) + + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + row.set_margin_top(10) + row.set_margin_bottom(10) + row.set_margin_start(14) + row.set_margin_end(14) + + icon = create_icon_widget(icon_name, size=16) + icon.set_valign(Gtk.Align.CENTER) + row.append(icon) + + label = Gtk.Label(label=title) + label.set_halign(Gtk.Align.START) + label.set_xalign(0) + label.set_hexpand(True) + label.set_wrap(True) + row.append(label) + + button.set_child(row) + return button + + def _browse_populate(self, data: dict | None) -> bool: group = getattr(self, "_browse_group", None) if group is None: return False @@ -2313,10 +2351,12 @@ def _browse_populate(self, data): group.set_title(data.get("path", "")) parent = data.get("parent") if parent: - up = Adw.ActionRow(title=_("Up one level")) - up.set_activatable(True) - up.add_prefix(create_icon_widget("go-up-symbolic", size=16)) - up.connect("activated", lambda r, p=parent: self._browse_load(p)) + up = self._create_browse_button( + _("Up one level"), + "go-up-symbolic", + _("Open parent folder"), + lambda _button, p=parent: self._browse_load(p), + ) group.add(up) self._browse_rows.append(up) @@ -2324,19 +2364,25 @@ def _browse_populate(self, data): name = entry.get("name", "") etype = entry.get("type", "") epath = entry.get("path", "") - row = Adw.ActionRow(title=name) - row.set_activatable(True) if etype == "directory": - row.add_prefix(create_icon_widget("folder-symbolic", size=16)) - row.connect("activated", lambda r, p=epath: self._browse_load(p)) + row = self._create_browse_button( + name, + "folder-symbolic", + _("Open folder"), + lambda _button, p=epath: self._browse_load(p), + ) else: - row.add_prefix(create_icon_widget("application-x-executable-symbolic", size=16)) - row.connect("activated", lambda r, p=epath: self._browse_pick(p)) + row = self._create_browse_button( + name, + "application-x-executable-symbolic", + _("Select executable"), + lambda _button, p=epath: self._browse_pick(p), + ) group.add(row) self._browse_rows.append(row) return False - def _browse_pick(self, path): + def _browse_pick(self, path: str) -> None: self.custom_cmd_entry.set_text(path) if getattr(self, "_browse_dialog", None) is not None: self._browse_dialog.close() diff --git a/tests/test_host_browse_a11y_regressions.py b/tests/test_host_browse_a11y_regressions.py new file mode 100644 index 0000000..86e1af3 --- /dev/null +++ b/tests/test_host_browse_a11y_regressions.py @@ -0,0 +1,18 @@ +"""Static a11y guards for the host file browser dialog.""" + +from pathlib import Path + + +def test_host_browser_entries_are_real_accessible_buttons() -> None: + source = Path("src/big_remote_play/ui/host_view.py").read_text() + browse_block = source.split("def _browse_populate", 1)[1].split( + "def _browse_pick", + 1, + )[0] + + assert "_create_browse_button(" in browse_block + assert "Gtk.AccessibleProperty.LABEL" in source + assert "Gtk.AccessibleProperty.DESCRIPTION" in source + assert "Adw.ActionRow(title=name)" not in browse_block + assert 'Adw.ActionRow(title=_("Up one level"))' not in browse_block + assert "set_activatable(True)" not in browse_block diff --git a/usr/share/big-remote-play/ui/style.css b/usr/share/big-remote-play/ui/style.css index 9b53b00..552d147 100644 --- a/usr/share/big-remote-play/ui/style.css +++ b/usr/share/big-remote-play/ui/style.css @@ -580,13 +580,15 @@ levelbar block.filled { } .management-row-button, -.settings-action-row-button { +.settings-action-row-button, +.file-browser-row-button { padding: 0; border-radius: 0; } .management-row-button:hover, -.settings-action-row-button:hover { +.settings-action-row-button:hover, +.file-browser-row-button:hover { background: alpha(@theme_fg_color, 0.06); } From b2013e4c74678b7af1f6b35f89585b20e4d0dbdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Gon=C3=A7alves?= Date: Thu, 25 Jun 2026 00:59:25 -0300 Subject: [PATCH 18/47] Store Sunshine credentials in keyring --- src/big_remote_play/ui/host_view.py | 157 +++--------------- src/big_remote_play/ui/performance_monitor.py | 25 +-- .../ui/sunshine_preferences.py | 21 +-- src/big_remote_play/utils/secure_io.py | 2 +- .../utils/sunshine_credentials.py | 152 +++++++++++++++++ tests/test_sunshine_credentials.py | 68 ++++++++ 6 files changed, 256 insertions(+), 169 deletions(-) create mode 100644 src/big_remote_play/utils/sunshine_credentials.py create mode 100644 tests/test_sunshine_credentials.py diff --git a/src/big_remote_play/ui/host_view.py b/src/big_remote_play/ui/host_view.py index 26d2fd8..4ed0cec 100644 --- a/src/big_remote_play/ui/host_view.py +++ b/src/big_remote_play/ui/host_view.py @@ -14,9 +14,9 @@ from big_remote_play.utils.icons import create_icon_widget, set_icon from big_remote_play.utils.widgets import MetricTile from big_remote_play import paths -from big_remote_play.utils.secure_io import secure_write_text +from big_remote_play.utils.secret_store import SecretStoreUnavailable +from big_remote_play.utils.sunshine_credentials import ensure_sunshine_api_config, load_sunshine_credentials, save_sunshine_credentials from big_remote_play.utils.uri import open_uri, open_path -from big_remote_play.ui.sunshine_preferences import SunshineConfigManager class HostView(Gtk.Box): def __init__(self): self.loading_settings = True @@ -710,151 +710,37 @@ def start_audio_watchdog(self): def _check_audio_state(self): return True - def _get_sunshine_conf_path(self): + def _get_sunshine_conf_path(self) -> Path: from pathlib import Path return Path.home() / '.config' / 'big-remoteplay' / 'sunshine' / 'sunshine.conf' - def _get_sunshine_creds(self): - conf_file = self._get_sunshine_conf_path() - if not conf_file.exists(): return None - - user = None - password = None - - try: - with open(conf_file, 'r') as f: - for line in f: - if '=' in line: - key, val = line.strip().split('=', 1) - key = key.strip() - val = val.strip() - if key == 'sunshine_user': user = val - elif key == 'sunshine_password': password = val - - if user and password: - return (user, password) - except Exception: pass - return None + def _get_sunshine_creds(self) -> tuple[str, str] | None: + return load_sunshine_credentials(conf_path=self._get_sunshine_conf_path()) - def _save_sunshine_creds(self, user, password): - conf_file = self._get_sunshine_conf_path() - lines = [] - if conf_file.exists(): - try: - with open(conf_file, 'r') as f: - lines = f.readlines() - except Exception: pass - - new_lines = [] - found_user = False - found_pass = False - - for line in lines: - if '=' in line: - key, _ = line.split('=', 1) - key = key.strip() - if key == 'sunshine_user': - new_lines.append(f"sunshine_user = {user}\n") - found_user = True - continue - elif key == 'sunshine_password': - new_lines.append(f"sunshine_password = {password}\n") - found_pass = True - continue - new_lines.append(line) - - if not found_user: new_lines.append(f"sunshine_user = {user}\n") - if not found_pass: new_lines.append(f"sunshine_password = {password}\n") - - # Configs required for API and operation - required = { - "credentials": f"sunshine:{password}", - "log_level": "2", - "port": "47989", - "webserver": "0.0.0.0", - "enable_api_endpoints": "true" - } - - final_lines = [] - existing_keys = set() - - # Process existing + updated user/pass lines - for line in new_lines: - if '=' in line: - key = line.split('=')[0].strip() - if key in required: - final_lines.append(f"{key} = {required[key]}\n") - existing_keys.add(key) - continue - final_lines.append(line) - - # Append missing required configs - for k, v in required.items(): - if k not in existing_keys: - final_lines.append(f"{k} = {v}\n") - + def _save_sunshine_creds(self, user: str, password: str) -> bool: try: - # sunshine.conf holds credentials: owner-only file/dir. - secure_write_text(str(conf_file), "".join(final_lines)) + save_sunshine_credentials(user, password, conf_path=self._get_sunshine_conf_path()) + return True + except SecretStoreUnavailable: + self.show_toast(_("System keyring is unavailable. Password was not saved.")) except Exception as e: - print(f"Error saving Sunshine creds: {e}") + print(f"Error saving Sunshine credentials: {e}") + return False - def _ensure_sunshine_config(self): + def _ensure_sunshine_config(self) -> None: """Ensures sunshine.conf has required API settings""" - conf_file = self._get_sunshine_conf_path() - if not conf_file.exists(): return - try: - lines = [] - with open(conf_file, 'r') as f: - lines = f.readlines() - - config_map = {} - for line in lines: - if '=' in line: - k, v = line.split('=', 1) - config_map[k.strip()] = v.strip() - - pwd = config_map.get('sunshine_password', '') - - # If we have a password but no credentials line or missing API config - updates_needed = False - required = { - "log_level": "2", - "port": "47989", - "webserver": "0.0.0.0", - "enable_api_endpoints": "true" - } - if pwd and 'credentials' not in config_map: - required['credentials'] = f"sunshine:{pwd}" - - for k, v in required.items(): - if k not in config_map: - updates_needed = True - - if updates_needed: - final_lines = lines.copy() - if final_lines and not final_lines[-1].endswith('\n'): - final_lines[-1] += '\n' - - for k, v in required.items(): - if k not in config_map: - final_lines.append(f"{k} = {v}\n") - - secure_write_text(str(conf_file), "".join(final_lines)) - + ensure_sunshine_api_config(conf_path=self._get_sunshine_conf_path()) except Exception as e: print(f"Error ensuring sunshine config: {e}") - def open_pin_dialog(self, _widget): + def open_pin_dialog(self, _widget: Gtk.Widget) -> None: self._ensure_sunshine_config() # Ensure config before trying to use API - # Load saved credentials - conf = SunshineConfigManager() - saved_user = conf.get('sunshine_user', '') - saved_pass = conf.get('sunshine_password', '') - if saved_user == 'None': saved_user = '' - if saved_pass == 'None': saved_pass = '' + # Load saved credentials from the system keyring when available. + saved_creds = self._get_sunshine_creds() + saved_user = saved_creds[0] if saved_creds else '' + saved_pass = saved_creds[1] if saved_creds else '' dialog = Adw.MessageDialog( heading=_("Insert PIN"), @@ -877,7 +763,7 @@ def open_pin_dialog(self, _widget): if saved_pass: pass_row.set_text(saved_pass) save_chk = Adw.SwitchRow(title=_("Save Password")) - save_chk.set_subtitle(_("Save credentials to Sunshine preferences")) + save_chk.set_subtitle(_("Save credentials to the system keyring")) save_chk.set_active(bool(saved_user and saved_pass)) grp.add(pin_row) @@ -904,8 +790,7 @@ def on_response(d, r): # Update saved credentials if requested if save and u and p: - conf.set('sunshine_user', u) - conf.set('sunshine_password', p) + self._save_sunshine_creds(u, p) auth = (u, p) if (u and p) else None success, msg = self.sunshine.send_pin(pin, name=device_name, auth=auth) diff --git a/src/big_remote_play/ui/performance_monitor.py b/src/big_remote_play/ui/performance_monitor.py index d62b2d6..c36e19c 100644 --- a/src/big_remote_play/ui/performance_monitor.py +++ b/src/big_remote_play/ui/performance_monitor.py @@ -472,26 +472,11 @@ def _resolve_hostname(self, ip): self.hostname_cache[ip] = None return None - def _sunshine_auth(self): - """Reads Sunshine admin credentials saved in sunshine.conf, or None.""" - from pathlib import Path - conf = Path.home() / '.config' / 'big-remoteplay' / 'sunshine' / 'sunshine.conf' - if not conf.exists(): - return None - user = password = None - try: - with open(conf) as f: - for line in f: - if '=' in line: - key, val = line.split('=', 1) - key, val = key.strip(), val.strip() - if key == 'sunshine_user': - user = val - elif key == 'sunshine_password': - password = val - except Exception: - return None - return (user, password) if user and password else None + def _sunshine_auth(self) -> tuple[str, str] | None: + """Reads Sunshine admin credentials from the system keyring, or None.""" + from big_remote_play.utils.sunshine_credentials import load_sunshine_credentials + + return load_sunshine_credentials() def _prompt_disconnect(self, session_id, ip): """Offers a gentle app close vs a forceful IP eviction.""" diff --git a/src/big_remote_play/ui/sunshine_preferences.py b/src/big_remote_play/ui/sunshine_preferences.py index 9deaff9..2924552 100644 --- a/src/big_remote_play/ui/sunshine_preferences.py +++ b/src/big_remote_play/ui/sunshine_preferences.py @@ -9,6 +9,7 @@ from big_remote_play.utils.i18n import _ from big_remote_play.utils.icons import create_icon_widget from big_remote_play.utils.secure_io import secure_write_text +from big_remote_play.utils.sunshine_credentials import LEGACY_SECRET_KEYS, load_sunshine_credentials import socket from big_remote_play.utils.system_check import SystemCheck from big_remote_play.host.sunshine_manager import SunshineHost @@ -39,10 +40,10 @@ def load(self): except Exception as e: print(f"Error loading Sunshine config: {e}") - def save(self): + def save(self) -> None: try: - # sunshine.conf holds credentials: owner-only file/dir. - body = "".join(f"{key} = {value}\n" for key, value in self.config.items()) + settings = {key: value for key, value in self.config.items() if key not in LEGACY_SECRET_KEYS} + body = "".join(f"{key} = {value}\n" for key, value in settings.items()) secure_write_text(str(self.config_file), body) except Exception as e: print(f"Error saving Sunshine config: {e}") @@ -335,10 +336,8 @@ def setup_maintenance_tab(self, group): # NOTE: server password change/reset lives in the body (Server → # Management → Server Password). Not duplicated here. - def _api_auth(self): - user = self.config.get("sunshine_user", "") - password = self.config.get("sunshine_password", "") - return (user, password) if user and password else None + def _api_auth(self) -> tuple[str, str] | None: + return load_sunshine_credentials(conf_path=self.config.config_file) def _toast(self, widget, message): root = widget.get_root() @@ -348,7 +347,7 @@ def _toast(self, widget, message): root.show_toast(message) return False - def on_apply_live_clicked(self, btn): + def on_apply_live_clicked(self, btn: Gtk.Widget) -> None: if not self.sunshine.is_running(): self._toast(btn, _("Sunshine is not running. Settings are saved to file.")) return @@ -356,9 +355,9 @@ def on_apply_live_clicked(self, btn): auth = self._api_auth() # Credentials are managed separately; do not push them as config keys. - settings = {k: v for k, v in self.config.config.items() if k not in ("sunshine_user", "sunshine_password", "credentials")} + settings = {k: v for k, v in self.config.config.items() if k not in LEGACY_SECRET_KEYS} - def work(): + def work() -> None: ok = self.sunshine.save_config(settings, auth=auth) if ok: self.sunshine.restart_via_api(auth=auth) @@ -403,8 +402,6 @@ def get_general_options(self): _("The locale used for Sunshine's user interface."), ), ("sunshine_name", _("Sunshine Name"), "entry", socket.gethostname(), None, _("The name of the Sunshine instance as seen by clients.")), - ("sunshine_user", _("Sunshine User"), "entry", "", None, _("Username for API access (Monitoring)")), - ("sunshine_password", _("Sunshine Password"), "password", "", None, _("Password for API access (Monitoring)")), ( "min_log_level", _("Log Level"), diff --git a/src/big_remote_play/utils/secure_io.py b/src/big_remote_play/utils/secure_io.py index 7149e28..237c734 100644 --- a/src/big_remote_play/utils/secure_io.py +++ b/src/big_remote_play/utils/secure_io.py @@ -1,6 +1,6 @@ """Filesystem helpers for writing secrets and config with owner-only permissions. -Auth tokens, VPN history (contains auth keys) and sunshine.conf (credentials) +Auth tokens, VPN history (contains secret references), and Sunshine config files must not be world-readable. These helpers create the file mode 0o600 and tighten the containing directory to 0o700. """ diff --git a/src/big_remote_play/utils/sunshine_credentials.py b/src/big_remote_play/utils/sunshine_credentials.py new file mode 100644 index 0000000..2d8a03a --- /dev/null +++ b/src/big_remote_play/utils/sunshine_credentials.py @@ -0,0 +1,152 @@ +"""Sunshine admin credential storage. + +The Sunshine server owns its credential database. Big Remote Play only needs a +local copy to authenticate API calls; that copy belongs in the user's Secret +Service wallet, not in ``sunshine.conf``. +""" + +from __future__ import annotations + +from pathlib import Path + +from big_remote_play.utils.secret_store import SecretKey, SecretStore, SecretStoreUnavailable +from big_remote_play.utils.secure_io import secure_write_text + +SUNSHINE_PASSWORD_KEY = SecretKey("sunshine", "api_password", "default") +SUNSHINE_PASSWORD_LABEL = "Big Remote Play Sunshine API password" +LEGACY_SECRET_KEYS = {"sunshine_password", "credentials"} +API_CONFIG_DEFAULTS = { + "log_level": "2", + "port": "47989", + "webserver": "0.0.0.0", + "enable_api_endpoints": "true", +} + + +def default_sunshine_conf_path() -> Path: + return Path.home() / ".config" / "big-remoteplay" / "sunshine" / "sunshine.conf" + + +def _read_lines(conf_path: Path) -> list[str]: + if not conf_path.exists(): + return [] + try: + return conf_path.read_text().splitlines(keepends=True) + except OSError: + return [] + + +def _parse_config(lines: list[str]) -> dict[str, str]: + config: dict[str, str] = {} + for line in lines: + if "=" not in line: + continue + key, value = line.split("=", 1) + config[key.strip()] = value.strip() + return config + + +def _legacy_credentials(config: dict[str, str]) -> tuple[str, str] | None: + user = config.get("sunshine_user", "").strip() + password = config.get("sunshine_password", "").strip() + if user and password: + return (user, password) + + credentials = config.get("credentials", "").strip() + if ":" not in credentials: + return None + legacy_user, legacy_password = credentials.split(":", 1) + legacy_user = legacy_user.strip() + legacy_password = legacy_password.strip() + return (legacy_user, legacy_password) if legacy_user and legacy_password else None + + +def _rewrite_config(conf_path: Path, updates: dict[str, str], removed_keys: set[str]) -> None: + lines = _read_lines(conf_path) + final_lines: list[str] = [] + written: set[str] = set() + + for line in lines: + if "=" not in line: + final_lines.append(line) + continue + + key = line.split("=", 1)[0].strip() + if key in removed_keys: + continue + if key in updates: + final_lines.append(f"{key} = {updates[key]}\n") + written.add(key) + continue + final_lines.append(line) + + if final_lines and not final_lines[-1].endswith("\n"): + final_lines[-1] += "\n" + for key, value in updates.items(): + if key not in written: + final_lines.append(f"{key} = {value}\n") + + secure_write_text(str(conf_path), "".join(final_lines)) + + +def save_sunshine_credentials( + user: str, + password: str, + *, + conf_path: Path | None = None, + secret_store: SecretStore | None = None, +) -> None: + user = user.strip() + if not user or not password: + raise ValueError("Sunshine username and password cannot be empty") + + store = secret_store or SecretStore() + store.store(SUNSHINE_PASSWORD_KEY, password, SUNSHINE_PASSWORD_LABEL) + + updates = dict(API_CONFIG_DEFAULTS) + updates["sunshine_user"] = user + _rewrite_config(conf_path or default_sunshine_conf_path(), updates, LEGACY_SECRET_KEYS) + + +def load_sunshine_credentials( + *, + conf_path: Path | None = None, + secret_store: SecretStore | None = None, + migrate_legacy: bool = True, +) -> tuple[str, str] | None: + path = conf_path or default_sunshine_conf_path() + config = _parse_config(_read_lines(path)) + user = config.get("sunshine_user", "").strip() + store = secret_store or SecretStore() + + if user: + try: + password = store.lookup(SUNSHINE_PASSWORD_KEY) + except SecretStoreUnavailable: + password = "" + if password: + if migrate_legacy and LEGACY_SECRET_KEYS.intersection(config): + _rewrite_config(path, {"sunshine_user": user, **API_CONFIG_DEFAULTS}, LEGACY_SECRET_KEYS) + return (user, password) + + legacy = _legacy_credentials(config) + if not legacy: + return None + + if migrate_legacy: + try: + save_sunshine_credentials(legacy[0], legacy[1], conf_path=path, secret_store=store) + except (OSError, SecretStoreUnavailable): + pass + return legacy + + +def ensure_sunshine_api_config(*, conf_path: Path | None = None, secret_store: SecretStore | None = None) -> None: + path = conf_path or default_sunshine_conf_path() + if not path.exists(): + return + + load_sunshine_credentials(conf_path=path, secret_store=secret_store, migrate_legacy=True) + config = _parse_config(_read_lines(path)) + removed_keys = LEGACY_SECRET_KEYS if not LEGACY_SECRET_KEYS.intersection(config) else set() + _rewrite_config(path, dict(API_CONFIG_DEFAULTS), removed_keys) diff --git a/tests/test_sunshine_credentials.py b/tests/test_sunshine_credentials.py new file mode 100644 index 0000000..0261eab --- /dev/null +++ b/tests/test_sunshine_credentials.py @@ -0,0 +1,68 @@ +"""Sunshine API credentials use the system secret store, not sunshine.conf.""" + +from pathlib import Path +import stat + +from pytest import MonkeyPatch + +from big_remote_play.utils.secret_store import InMemorySecretBackend, SecretStore +from big_remote_play.utils.sunshine_credentials import ( + SUNSHINE_PASSWORD_KEY, + load_sunshine_credentials, + save_sunshine_credentials, +) + + +def test_sunshine_credentials_save_password_in_secret_store(tmp_path: Path) -> None: + conf = tmp_path / "sunshine.conf" + store = SecretStore(InMemorySecretBackend()) + + save_sunshine_credentials("admin", "api-secret", conf_path=conf, secret_store=store) + + config_text = conf.read_text() + assert "sunshine_user = admin" in config_text + assert "api-secret" not in config_text + assert "sunshine_password" not in config_text + assert "credentials =" not in config_text + assert "enable_api_endpoints = true" in config_text + assert store.lookup(SUNSHINE_PASSWORD_KEY) == "api-secret" + assert stat.S_IMODE(conf.stat().st_mode) == 0o600 + + +def test_sunshine_credentials_migrate_legacy_plaintext(tmp_path: Path) -> None: + conf = tmp_path / "sunshine.conf" + conf.write_text("sunshine_user = legacy\nsunshine_password = legacy-secret\ncredentials = legacy:legacy-secret\nport = 47989\n") + store = SecretStore(InMemorySecretBackend()) + + assert load_sunshine_credentials(conf_path=conf, secret_store=store) == ("legacy", "legacy-secret") + + config_text = conf.read_text() + assert "legacy-secret" not in config_text + assert "sunshine_password" not in config_text + assert "credentials =" not in config_text + assert "sunshine_user = legacy" in config_text + assert store.lookup(SUNSHINE_PASSWORD_KEY) == "legacy-secret" + + +def test_sunshine_config_manager_save_filters_legacy_secrets(tmp_path: Path, monkeypatch: MonkeyPatch) -> None: + monkeypatch.setenv("HOME", str(tmp_path)) + from big_remote_play.ui.sunshine_preferences import SunshineConfigManager + + manager = SunshineConfigManager() + manager.config.update( + { + "sunshine_user": "admin", + "sunshine_password": "plain-secret", + "credentials": "admin:plain-secret", + "min_log_level": "2", + } + ) + + manager.save() + + config_text = manager.config_file.read_text() + assert "plain-secret" not in config_text + assert "sunshine_password" not in config_text + assert "credentials =" not in config_text + assert "sunshine_user = admin" in config_text + assert "min_log_level = 2" in config_text From 19ee5c52ff6f79467c91912bb08d6473a2907aca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Gon=C3=A7alves?= Date: Thu, 25 Jun 2026 01:04:34 -0300 Subject: [PATCH 19/47] Remove shell execution from host launch paths --- src/big_remote_play/ui/host_view.py | 37 +++++++++++++++++++++----- tests/test_host_view_command_safety.py | 32 ++++++++++++++++++++++ 2 files changed, 63 insertions(+), 6 deletions(-) create mode 100644 tests/test_host_view_command_safety.py diff --git a/src/big_remote_play/ui/host_view.py b/src/big_remote_play/ui/host_view.py index 4ed0cec..d2e0e79 100644 --- a/src/big_remote_play/ui/host_view.py +++ b/src/big_remote_play/ui/host_view.py @@ -5,6 +5,7 @@ from collections.abc import Callable from gi.repository import Gtk, Gdk, Adw, GLib # type: ignore import subprocess, random, string, json, socket, os, time +import shlex from pathlib import Path from big_remote_play.utils.game_detector import GameDetector @@ -17,6 +18,24 @@ from big_remote_play.utils.secret_store import SecretStoreUnavailable from big_remote_play.utils.sunshine_credentials import ensure_sunshine_api_config, load_sunshine_credentials, save_sunshine_credentials from big_remote_play.utils.uri import open_uri, open_path + + +def _parse_xrandr_monitor_names(output: str) -> list[str]: + names: list[str] = [] + for line in output.splitlines()[1:]: + parts = line.split() + if parts: + names.append(parts[-1]) + return names + + +def _split_launch_command(command: str) -> list[str]: + try: + return shlex.split(command) + except ValueError: + return [] + + class HostView(Gtk.Box): def __init__(self): self.loading_settings = True @@ -93,10 +112,8 @@ def detect_monitors(self): if not is_wayland: # Xrandr (Reinforcement for X11) try: - cmd = "xrandr --listmonitors | tail -n +2 | awk '{print $NF}'" - res = subprocess.check_output(cmd, shell=True, text=True, timeout=5) - for n in res.splitlines(): - n = n.strip() + res = subprocess.check_output(["xrandr", "--listmonitors"], text=True, timeout=5) + for n in _parse_xrandr_monitor_names(res): if n and n not in names: monitors.append((f"Display ({n})", n)) names.append(n) @@ -1547,9 +1564,13 @@ def _delayed_game_launch(): cmd = info['cmd'] game_name = info['name'] print(f"DIRECT LAUNCH: Lutris - {game_name} ({cmd})") + argv = _split_launch_command(cmd) + if not argv: + self.show_toast(_("Invalid launch command")) + return p = subprocess.Popen( - cmd.split(), + argv, env=env, start_new_session=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL ) @@ -1560,9 +1581,13 @@ def _delayed_game_launch(): cmd = info['cmd'] game_name = info['name'] print(f"DIRECT LAUNCH: Custom - {game_name} ({cmd})") + argv = _split_launch_command(cmd) + if not argv: + self.show_toast(_("Invalid launch command")) + return p = subprocess.Popen( - cmd, shell=True, + argv, env=env, start_new_session=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL ) diff --git a/tests/test_host_view_command_safety.py b/tests/test_host_view_command_safety.py new file mode 100644 index 0000000..b688100 --- /dev/null +++ b/tests/test_host_view_command_safety.py @@ -0,0 +1,32 @@ +"""HostView subprocess command parsing stays shell-free.""" + +from pathlib import Path + +from big_remote_play.ui.host_view import _parse_xrandr_monitor_names, _split_launch_command + + +def test_parse_xrandr_monitor_names() -> None: + output = """Monitors: 2 + 0: +*HDMI-1 1920/520x1080/290+0+0 HDMI-1 + 1: +DP-2 2560/600x1440/340+1920+0 DP-2 +""" + + assert _parse_xrandr_monitor_names(output) == ["HDMI-1", "DP-2"] + + +def test_split_launch_command_preserves_quoted_arguments() -> None: + assert _split_launch_command('/usr/bin/game --profile "Living Room"') == [ + "/usr/bin/game", + "--profile", + "Living Room", + ] + + +def test_split_launch_command_rejects_invalid_shell_syntax() -> None: + assert _split_launch_command('/usr/bin/game "unterminated') == [] + + +def test_host_view_does_not_use_shell_true() -> None: + source = Path("src/big_remote_play/ui/host_view.py").read_text() + + assert "shell=True" not in source From cedabae231fb1beebbe4e0999a676b019a5428ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Gon=C3=A7alves?= Date: Thu, 25 Jun 2026 01:08:39 -0300 Subject: [PATCH 20/47] Name server icon buttons for accessibility --- src/big_remote_play/ui/host_view.py | 26 +++++++++++++++++++++--- tests/test_host_view_a11y_regressions.py | 22 ++++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/big_remote_play/ui/host_view.py b/src/big_remote_play/ui/host_view.py index d2e0e79..57de1e7 100644 --- a/src/big_remote_play/ui/host_view.py +++ b/src/big_remote_play/ui/host_view.py @@ -611,6 +611,10 @@ def _tip(icon_name, title, desc): copy_btn.add_css_class("flat") copy_btn.set_valign(Gtk.Align.CENTER) copy_btn.set_tooltip_text(_("Copy PIN")) + copy_btn.update_property( + [Gtk.AccessibleProperty.LABEL, Gtk.AccessibleProperty.DESCRIPTION], + [_("Copy PIN"), _("Copy PIN code to clipboard")], + ) copy_btn.connect("clicked", lambda b: self.copy_field_value('pin')) pin_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) @@ -1028,7 +1032,7 @@ def run(): except Exception as e: self.show_toast(_("Error executing script: {}").format(e)) - def create_masked_row(self, title, key, icon_name='text-x-generic-symbolic', default_revealed=False): + def create_masked_row(self, title: str, key: str, icon_name: str = 'text-x-generic-symbolic', default_revealed: bool = False) -> None: row = Adw.ActionRow() row.set_title(title) row.add_prefix(create_icon_widget(icon_name, size=16)) @@ -1042,22 +1046,38 @@ def create_masked_row(self, title, key, icon_name='text-x-generic-symbolic', def eye_btn = Gtk.Button() eye_btn.set_child(create_icon_widget('view-reveal-symbolic' if not default_revealed else 'view-conceal-symbolic', size=16)) eye_btn.add_css_class('flat') + eye_btn.update_property( + [Gtk.AccessibleProperty.LABEL, Gtk.AccessibleProperty.DESCRIPTION], + [ + _("Hide {}").format(title) if default_revealed else _("Reveal {}").format(title), + _("Show or hide the {} value").format(title), + ], + ) copy_btn = Gtk.Button() copy_btn.set_child(create_icon_widget('edit-copy-symbolic', size=16)) copy_btn.add_css_class('flat') + copy_btn.update_property( + [Gtk.AccessibleProperty.LABEL, Gtk.AccessibleProperty.DESCRIPTION], + [_("Copy {}").format(title), _("Copy the {} value to clipboard").format(title)], + ) box.append(value_lbl); box.append(eye_btn); box.append(copy_btn) row.add_suffix(box) self.summary_box.add(row) - self.field_widgets[key] = {'label': value_lbl, 'real_value': '', 'revealed': default_revealed, 'btn_eye': eye_btn} + self.field_widgets[key] = {'label': value_lbl, 'real_value': '', 'revealed': default_revealed, 'btn_eye': eye_btn, 'title': title} eye_btn.connect('clicked', lambda b: self.toggle_field_visibility(key)) copy_btn.connect('clicked', lambda b: self.copy_field_value(key)) - def toggle_field_visibility(self, key): + def toggle_field_visibility(self, key: str) -> None: field = self.field_widgets[key] field['revealed'] = not field['revealed'] + title = field.get('title', _("value")) field['btn_eye'].set_child(create_icon_widget('view-conceal-symbolic' if field['revealed'] else 'view-reveal-symbolic', size=16)) + field['btn_eye'].update_property( + [Gtk.AccessibleProperty.LABEL], + [_("Hide {}").format(title) if field['revealed'] else _("Reveal {}").format(title)], + ) field['label'].set_text(field['real_value'] if field['revealed'] else '••••••') def copy_field_value(self, key): diff --git a/tests/test_host_view_a11y_regressions.py b/tests/test_host_view_a11y_regressions.py index f682691..e0ba680 100644 --- a/tests/test_host_view_a11y_regressions.py +++ b/tests/test_host_view_a11y_regressions.py @@ -15,3 +15,25 @@ def test_management_rows_are_real_accessible_buttons() -> None: assert "Gtk.AccessibleProperty.DESCRIPTION" in management_block assert "Adw.ActionRow" not in management_block assert "set_activatable(True)" not in management_block + + +def test_server_secret_icon_buttons_have_accessible_names() -> None: + source = Path("src/big_remote_play/ui/host_view.py").read_text() + masked_row_block = source.split("def create_masked_row(", 1)[1].split("def toggle_field_visibility", 1)[0] + + assert "Gtk.AccessibleProperty.LABEL" in masked_row_block + assert "Gtk.AccessibleProperty.DESCRIPTION" in masked_row_block + assert '"Reveal {}"' in masked_row_block + assert '"Copy {}"' in masked_row_block + + +def test_copy_pin_button_has_accessible_name() -> None: + source = Path("src/big_remote_play/ui/host_view.py").read_text() + pin_block = source.split('copy_btn.set_tooltip_text(_("Copy PIN"))', 1)[1].split( + 'copy_btn.connect("clicked"', + 1, + )[0] + + assert "Gtk.AccessibleProperty.LABEL" in pin_block + assert "Gtk.AccessibleProperty.DESCRIPTION" in pin_block + assert '"Copy PIN"' in pin_block From 65ec886f3b4037a8694ee43a14e2e1cda2486d14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Gon=C3=A7alves?= Date: Thu, 25 Jun 2026 01:12:12 -0300 Subject: [PATCH 21/47] Avoid broad Sunshine kill on stop --- src/big_remote_play/ui/host_view.py | 5 +---- tests/test_security_regressions.py | 8 ++++++++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/big_remote_play/ui/host_view.py b/src/big_remote_play/ui/host_view.py index 57de1e7..cf9f694 100644 --- a/src/big_remote_play/ui/host_view.py +++ b/src/big_remote_play/ui/host_view.py @@ -1643,7 +1643,7 @@ def _stop_game_direct(self): self._game_processes = [] self._game_launch_info = None - def stop_hosting(self, b=None): + def stop_hosting(self, b=None) -> None: self.show_toast(_("Stopping server...")) self.loading_bar.set_visible(True); self.loading_bar.pulse() @@ -1669,9 +1669,6 @@ def stop_hosting(self, b=None): self.sunshine.stop() except Exception as e: print(f"Error stopping Sunshine: {e}") - - # Hard kill fallback - subprocess.run(['pkill', '-9', 'sunshine'], stderr=subprocess.DEVNULL, timeout=10) self.is_hosting = False self.sync_ui_state() diff --git a/tests/test_security_regressions.py b/tests/test_security_regressions.py index 2f58581..fcf07fe 100644 --- a/tests/test_security_regressions.py +++ b/tests/test_security_regressions.py @@ -87,6 +87,14 @@ def test_sunshine_network_options_cover_current_docs() -> None: assert '"ping_timeout", _("Ping Timeout (ms)"), "spin", "10000"' in source +def test_stop_hosting_does_not_broad_kill_sunshine() -> None: + source = (ROOT / "src/big_remote_play/ui/host_view.py").read_text() + stop_hosting = source.split("def stop_hosting(", 1)[1].split("def _show_share_hint", 1)[0] + + assert "pkill" not in stop_hosting + assert "self.sunshine.stop()" in stop_hosting + + def test_history_saves_secret_refs_not_plaintext(tmp_path: Path, monkeypatch) -> None: private_network_view = _import_private_network_view(tmp_path, monkeypatch) From 0a6129ab0b01b92beb558c577a6b10c78656c117 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Gon=C3=A7alves?= Date: Thu, 25 Jun 2026 03:04:59 -0300 Subject: [PATCH 22/47] Polish main UI from mockups --- src/big_remote_play/ui/guest_view.py | 10 +- src/big_remote_play/ui/host_view.py | 8 +- src/big_remote_play/ui/main_window.py | 110 +++++++++++++----- .../ui/private_network_view.py | 25 ++-- src/big_remote_play/utils/widgets.py | 31 +++++ tests/test_css_regressions.py | 6 + tests/test_ui_premium_layout_regressions.py | 22 ++++ usr/share/big-remote-play/ui/style.css | 88 ++++++++++++-- 8 files changed, 251 insertions(+), 49 deletions(-) create mode 100644 tests/test_ui_premium_layout_regressions.py diff --git a/src/big_remote_play/ui/guest_view.py b/src/big_remote_play/ui/guest_view.py index eb3eafd..475dcfd 100644 --- a/src/big_remote_play/ui/guest_view.py +++ b/src/big_remote_play/ui/guest_view.py @@ -10,7 +10,7 @@ from big_remote_play.guest.moonlight_client import MoonlightClient from big_remote_play.utils.i18n import _ from big_remote_play.utils.icons import create_icon_widget -from big_remote_play.utils.widgets import create_helper_card +from big_remote_play.utils.widgets import create_helper_card, create_page_header from big_remote_play.utils.moonlight_config import MoonlightConfigManager class GuestView(Gtk.Box): @@ -50,7 +50,7 @@ def run_detect(): threading.Thread(target=run_detect, daemon=True).start() def setup_ui(self): - clamp = Adw.Clamp(); clamp.set_maximum_size(1040); clamp.set_valign(Gtk.Align.CENTER) + clamp = Adw.Clamp(); clamp.set_maximum_size(1040); clamp.set_valign(Gtk.Align.START) for m in ['top', 'bottom', 'start', 'end']: getattr(clamp, f'set_margin_{m}')(24) content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=18) @@ -58,6 +58,12 @@ def setup_ui(self): self.perf_monitor = PerformanceMonitor(); self.perf_monitor.set_visible(False) content.append(self.perf_monitor) + content.append(create_page_header( + _('Connect to Server'), + _('Connect to a host'), + 'network-workgroup-symbolic', + )) + self.method_stack = Adw.ViewStack() page_dis = self.method_stack.add_titled(self.create_discover_page(), 'discover', _('Discover')) diff --git a/src/big_remote_play/ui/host_view.py b/src/big_remote_play/ui/host_view.py index cf9f694..48d99ee 100644 --- a/src/big_remote_play/ui/host_view.py +++ b/src/big_remote_play/ui/host_view.py @@ -13,7 +13,7 @@ import threading from big_remote_play.utils.i18n import _ from big_remote_play.utils.icons import create_icon_widget, set_icon -from big_remote_play.utils.widgets import MetricTile +from big_remote_play.utils.widgets import MetricTile, create_page_header from big_remote_play import paths from big_remote_play.utils.secret_store import SecretStoreUnavailable from big_remote_play.utils.sunshine_credentials import ensure_sunshine_api_config, load_sunshine_credentials, save_sunshine_credentials @@ -168,6 +168,12 @@ def setup_ui(self): self.loading_bar.set_visible(False) content.append(self.loading_bar) + content.append(create_page_header( + _('Server'), + _('Share your games'), + 'network-server-symbolic', + )) + from .performance_monitor import PerformanceMonitor self.perf_monitor = PerformanceMonitor(sunshine=self.sunshine) self.perf_monitor.set_visible(True) diff --git a/src/big_remote_play/ui/main_window.py b/src/big_remote_play/ui/main_window.py index bbabe30..7b1da8e 100644 --- a/src/big_remote_play/ui/main_window.py +++ b/src/big_remote_play/ui/main_window.py @@ -16,6 +16,7 @@ create_steps_strip, create_difficulty_pill, create_comparison_table, + create_page_header, ) from big_remote_play.utils.i18n import _ import subprocess @@ -57,6 +58,7 @@ 'name': 'SUNSHINE', 'full_name': _('Sunshine Game Stream Host'), 'description': _('High-performance game stream host. Required to share your games.'), + 'icon': 'network-server-symbolic', 'type': 'service', 'unit': 'sunshine.service', 'user': True @@ -65,6 +67,7 @@ 'name': 'MOONLIGHT', 'full_name': _('Moonlight Game Stream Client'), 'description': _('Game stream client. Required to connect to other hosts.'), + 'icon': 'network-workgroup-symbolic', 'type': 'app', 'bin': 'moonlight-qt' }, @@ -72,6 +75,7 @@ 'name': 'DOCKER', 'full_name': _('Docker Engine'), 'description': _('Container platform. Required for the private network server.'), + 'icon': 'preferences-system-symbolic', 'type': 'service', 'unit': 'docker.service', 'user': False @@ -80,6 +84,7 @@ 'name': 'TAILSCALE', 'full_name': _('Tailscale'), 'description': _('Mesh VPN service. Required for Tailscale connectivity.'), + 'icon': 'tailscale-symbolic', 'type': 'service', 'unit': 'tailscaled.service', 'user': False @@ -88,6 +93,7 @@ 'name': 'ZEROTIER', 'full_name': _('ZeroTier'), 'description': _('Virtual network service. Required for ZeroTier connectivity.'), + 'icon': 'zerotier-symbolic', 'type': 'service', 'unit': 'zerotier-one.service', 'user': False @@ -177,7 +183,7 @@ def setup_ui(self): self.toast_overlay = Adw.ToastOverlay(); self.set_content(self.toast_overlay) self.split_view = Adw.NavigationSplitView(); self.toast_overlay.set_child(self.split_view) self.setup_sidebar(); self.setup_content() - self.split_view.set_min_sidebar_width(220); self.split_view.set_max_sidebar_width(280) + self.split_view.set_min_sidebar_width(260); self.split_view.set_max_sidebar_width(320) def _install_window_actions(self): nav_action = Gio.SimpleAction.new("navigate", GLib.VariantType.new("s")) @@ -221,11 +227,10 @@ def _build_navigation_pages(self): return pages def setup_sidebar(self): - # No sidebar header bar — the navigation list starts at the very top. - # (About stays reachable from the content header menu.) tb = Adw.ToolbarView() main = Gtk.Box(orientation=Gtk.Orientation.VERTICAL); main.set_vexpand(True) - main.set_margin_top(8) + main.set_margin_top(12) + main.append(self._create_sidebar_identity()) scroll = Gtk.ScrolledWindow(); scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC); scroll.set_vexpand(True) self.nav_list = Gtk.ListBox(); self.nav_list.add_css_class('navigation-sidebar') self.nav_list.connect('row-selected', self.on_nav_selected) @@ -235,6 +240,34 @@ def setup_sidebar(self): scroll.set_child(self.nav_list); main.append(scroll); main.append(self.create_status_footer()) tb.set_content(main); self.split_view.set_sidebar(Adw.NavigationPage.new(tb, 'Navigation')) + def _create_sidebar_identity(self) -> Gtk.Widget: + identity = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + identity.add_css_class("sidebar-identity") + identity.set_margin_start(14) + identity.set_margin_end(14) + identity.set_margin_bottom(16) + + logo = create_logo_widget('big-remote-play', 48) + logo.add_css_class("sidebar-logo") + logo.set_valign(Gtk.Align.CENTER) + identity.append(logo) + + text = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + text.set_valign(Gtk.Align.CENTER) + title = Gtk.Label(label='Big Remote Play') + title.add_css_class("sidebar-title") + title.set_halign(Gtk.Align.START) + text.append(title) + + subtitle = Gtk.Label(label=_('Play together, from anywhere')) + subtitle.add_css_class("sidebar-subtitle") + subtitle.set_halign(Gtk.Align.START) + subtitle.set_wrap(True) + text.append(subtitle) + identity.append(text) + + return identity + def _refresh_nav_list(self): """Rebuild the navigation list based on VPN choice.""" # Clear existing rows @@ -264,8 +297,12 @@ def _refresh_nav_list(self): else: self.nav_list.append(self.create_nav_row(pid, info)) - if r := self.nav_list.get_row_at_index(0): - self.nav_list.select_row(r) + child = self.nav_list.get_first_child() + while child: + if isinstance(child, Gtk.ListBoxRow) and child in self._nav_page_by_row: + self.nav_list.select_row(child) + break + child = child.get_next_sibling() def create_nav_row(self, page_id: str, page_info: dict) -> Gtk.ListBoxRow: """Creates navigation row in sidebar""" @@ -333,6 +370,7 @@ def create_status_footer(self): card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) card.add_css_class("info-card") + card.add_css_class("service-card") self.status_card = card def add_status_row(container, label_text, dot_attr, lbl_attr, service_id): @@ -340,30 +378,45 @@ def add_status_row(container, label_text, dot_attr, lbl_attr, service_id): # to AT-SPI (the old Gtk.Box + GestureClick exposed none). row = Gtk.Button() row.add_css_class("info-row") + row.add_css_class("service-status-button") row.add_css_class("flat") + row.set_size_request(-1, 48) self._service_by_row[row] = service_id row.connect("clicked", lambda b, sid=service_id: self.on_service_clicked(sid)) content = Gtk.Box(spacing=10) - box_key = Gtk.Box(spacing=8) + content.add_css_class("service-status-row") + meta = SERVICE_METADATA.get(service_id, {}) + + icon_frame = Gtk.Box() + icon_frame.add_css_class("service-icon-frame") + service_icon = create_icon_widget(meta.get('icon', 'preferences-system-symbolic'), size=20) + service_icon.set_valign(Gtk.Align.CENTER) + for margin in ['top', 'bottom', 'start', 'end']: + getattr(service_icon, f'set_margin_{margin}')(6) + icon_frame.append(service_icon) + content.append(icon_frame) + + box_key = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) box_key.set_hexpand(True) dot = create_icon_widget('media-record-symbolic', size=10, css_class=['status-dot', 'status-offline']) self._status_dots[service_id] = dot setattr(self, dot_attr, dot) - box_key.append(dot) lbl_key = Gtk.Label(label=label_text) - lbl_key.add_css_class('info-key') + lbl_key.add_css_class('service-name') + lbl_key.set_halign(Gtk.Align.START) box_key.append(lbl_key) - content.append(box_key) lbl_status = Gtk.Label(label=_('Checking...')) - lbl_status.add_css_class('info-value') - lbl_status.set_halign(Gtk.Align.END) + lbl_status.add_css_class('service-status') + lbl_status.set_halign(Gtk.Align.START) self._status_labels[service_id] = lbl_status setattr(self, lbl_attr, lbl_status) - content.append(lbl_status) + box_key.append(lbl_status) + content.append(box_key) + dot.set_valign(Gtk.Align.CENTER) + content.append(dot) row.set_child(content) # Plain-language explanation of each engine for new users. - meta = SERVICE_METADATA.get(service_id, {}) desc = meta.get('description', '') if desc: row.set_tooltip_text(desc) @@ -387,7 +440,7 @@ def _filter_status_rows(self): if not hasattr(self, 'status_card'): return vpn = self._vpn_choice - visible_services = ['sunshine', 'moonlight'] + visible_services = ['sunshine', 'moonlight', 'docker', 'tailscale'] if vpn is None else ['sunshine', 'moonlight'] if vpn == 'headscale': visible_services.extend(['docker', 'tailscale']) @@ -526,22 +579,20 @@ def create_vpn_selector_page(self): box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=24) - # Header - header_group = Adw.PreferencesGroup() - header_group.set_title(_('Choose Your VPN Provider')) - header_group.set_header_suffix(create_icon_widget('network-private-symbolic', size=18)) - header_group.set_description( + box.append(create_page_header( + _('Choose Your VPN Provider'), _('Select a VPN solution to create or join a Private Network. ' - 'Your choice will be saved and shown in the sidebar menu.') - ) - box.append(header_group) + 'Your choice will be saved and shown in the sidebar menu.'), + 'network-private-symbolic', + )) # Cards row cards_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=16) cards_box.set_halign(Gtk.Align.CENTER) cards_box.set_homogeneous(True) - for pid, info in VPN_PROVIDERS.items(): + for pid in ('tailscale', 'zerotier', 'headscale'): + info = VPN_PROVIDERS[pid] card = self._create_vpn_card(pid, info) cards_box.append(card) @@ -586,8 +637,9 @@ def _create_vpn_card(self, provider_id: str, info: dict) -> Gtk.Button: btn.add_css_class('action-card') # 'card-accent' keeps readable dark text (unlike 'suggested-action', # which forces white text on the near-white tint). - btn.add_css_class('card-accent') - btn.set_size_request(220, 190) + if provider_id == 'tailscale': + btn.add_css_class('card-accent') + btn.set_size_request(220, 210) btn.connect('clicked', lambda b, pid=provider_id: self._on_vpn_selected(pid)) btn.update_property( [Gtk.AccessibleProperty.LABEL, Gtk.AccessibleProperty.DESCRIPTION], @@ -600,6 +652,12 @@ def _create_vpn_card(self, provider_id: str, info: dict) -> Gtk.Button: for m in ['top', 'bottom', 'start', 'end']: getattr(box, f'set_margin_{m}')(16) + if provider_id == 'tailscale': + badge = Gtk.Label(label=_('Recommended')) + badge.add_css_class('card-badge') + badge.set_halign(Gtk.Align.CENTER) + box.append(badge) + # Icon icon = create_icon_widget(info['icon'], size=44) icon.add_css_class('accent') diff --git a/src/big_remote_play/ui/private_network_view.py b/src/big_remote_play/ui/private_network_view.py index ccb6790..9a5e457 100644 --- a/src/big_remote_play/ui/private_network_view.py +++ b/src/big_remote_play/ui/private_network_view.py @@ -6,7 +6,7 @@ from gi.repository import Adw, Gdk, GLib, Gtk # type: ignore from big_remote_play.utils.i18n import _ from big_remote_play.utils.icons import create_icon_widget -from big_remote_play.utils.widgets import create_helper_card, create_wizard_stepper +from big_remote_play.utils.widgets import create_helper_card, create_page_header, create_wizard_stepper from big_remote_play import paths from big_remote_play.utils.secure_io import secure_write_text from big_remote_play.utils.secret_store import SecretKey, SecretStore, SecretStoreUnavailable, new_secret_id @@ -358,12 +358,11 @@ def _build(self): getattr(clamp, f"set_margin_{m}")(24) content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=20) - # Header - hdr = Adw.PreferencesGroup() - hdr.set_title(self.vpn["create_title"]) - hdr.set_description(self.vpn["create_desc"]) - hdr.set_header_suffix(create_icon_widget(self.vpn["icon"], size=24)) - content.append(hdr) + content.append(create_page_header( + self.vpn["create_title"], + self.vpn["create_desc"], + self.vpn["icon"], + )) # Form group self._form_group = Adw.PreferencesGroup() @@ -1270,15 +1269,15 @@ def _build(self): conn_box.set_margin_start(32) conn_box.set_margin_end(32) + conn_box.append(create_page_header( + self.vpn["connect_title"], + self.vpn["connect_desc"], + self.vpn["icon"], + )) + # Wizard stepper mirroring the Connect / Status / Previous Networks flow. conn_box.append(create_wizard_stepper([_("Connect"), _("Status"), _("Previous Networks")], 0)) - hdr = Adw.PreferencesGroup() - hdr.set_title(self.vpn["connect_title"]) - hdr.set_description(self.vpn["connect_desc"]) - hdr.set_header_suffix(create_icon_widget(self.vpn["icon"], size=24)) - conn_box.append(hdr) - # Two columns: connection fields (left) + "what you'll need" helper (right). fields_group = Adw.PreferencesGroup() fields_group.set_title(_("Connection Details")) diff --git a/src/big_remote_play/utils/widgets.py b/src/big_remote_play/utils/widgets.py index 6a128dd..2da1cf1 100644 --- a/src/big_remote_play/utils/widgets.py +++ b/src/big_remote_play/utils/widgets.py @@ -25,6 +25,37 @@ } +def create_page_header(title: str, subtitle: str, icon_name: str | None = None) -> Gtk.Widget: + """Large page heading used by the main content pages.""" + header = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + header.add_css_class("page-header") + + title_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + title_row.set_halign(Gtk.Align.START) + + title_label = Gtk.Label(label=title) + title_label.add_css_class("page-title") + title_label.set_halign(Gtk.Align.START) + title_label.set_wrap(True) + title_row.append(title_label) + + if icon_name: + icon = create_icon_widget(icon_name, size=30) + icon.add_css_class("page-title-icon") + icon.set_valign(Gtk.Align.CENTER) + title_row.append(icon) + + subtitle_label = Gtk.Label(label=subtitle) + subtitle_label.add_css_class("page-subtitle") + subtitle_label.set_halign(Gtk.Align.START) + subtitle_label.set_wrap(True) + subtitle_label.set_max_width_chars(88) + + header.append(title_row) + header.append(subtitle_label) + return header + + def create_steps_strip(steps: list[tuple[str, str, str]]) -> Gtk.Widget: """ "How it works" strip: numbered steps with icon + title + description. diff --git a/tests/test_css_regressions.py b/tests/test_css_regressions.py index 0b8c2de..6ec5cbe 100644 --- a/tests/test_css_regressions.py +++ b/tests/test_css_regressions.py @@ -8,3 +8,9 @@ def test_stylesheet_does_not_pad_libadwaita_bottom_bar_revealer() -> None: assert ".network-info-actions" in stylesheet assert ".bottom-bar" not in stylesheet + + +def test_stylesheet_does_not_use_negative_letter_spacing() -> None: + stylesheet = Path("usr/share/big-remote-play/ui/style.css").read_text() + + assert "letter-spacing: -" not in stylesheet diff --git a/tests/test_ui_premium_layout_regressions.py b/tests/test_ui_premium_layout_regressions.py new file mode 100644 index 0000000..cc8bf46 --- /dev/null +++ b/tests/test_ui_premium_layout_regressions.py @@ -0,0 +1,22 @@ +"""Static guards for the premium layout elements mirrored from the mockups.""" + +from pathlib import Path + + +def test_main_content_pages_use_shared_page_headers() -> None: + for path in [ + Path("src/big_remote_play/ui/host_view.py"), + Path("src/big_remote_play/ui/guest_view.py"), + Path("src/big_remote_play/ui/main_window.py"), + Path("src/big_remote_play/ui/private_network_view.py"), + ]: + assert "create_page_header(" in path.read_text() + + +def test_sidebar_keeps_brand_identity_and_richer_service_rows() -> None: + source = Path("src/big_remote_play/ui/main_window.py").read_text() + + assert "_create_sidebar_identity" in source + assert "create_logo_widget('big-remote-play', 48)" in source + assert "service-status-row" in source + assert "service-icon-frame" in source diff --git a/usr/share/big-remote-play/ui/style.css b/usr/share/big-remote-play/ui/style.css index 552d147..4c024fd 100644 --- a/usr/share/big-remote-play/ui/style.css +++ b/usr/share/big-remote-play/ui/style.css @@ -3,12 +3,12 @@ /* Navigation Sidebar */ .navigation-sidebar { background-color: transparent; - padding: 4px; + padding: 4px 8px; } .navigation-sidebar row { - border-radius: 8px; - margin: 2px 4px; + border-radius: 10px; + margin: 3px 2px; } .navigation-sidebar row:hover { @@ -19,10 +19,29 @@ background-color: alpha(@accent_bg_color, 0.15); color: @accent_color; border: 1px solid alpha(@accent_bg_color, 0.4); + border-left: 4px solid @accent_bg_color; box-shadow: 0 4px 12px alpha(@accent_bg_color, 0.15); font-weight: 700; } +.sidebar-identity { + padding: 8px 2px 2px 2px; +} + +.sidebar-logo { + -gtk-icon-shadow: 0 6px 18px alpha(@accent_bg_color, 0.25); +} + +.sidebar-title { + font-size: 1.08em; + font-weight: 800; +} + +.sidebar-subtitle { + font-size: 0.92em; + opacity: 0.68; +} + .category-icon { opacity: 0.85; } @@ -124,6 +143,26 @@ entry:focus { font-weight: 600; } +.page-header { + margin-bottom: 4px; +} + +.page-title { + font-size: 2.1em; + font-weight: 800; + letter-spacing: 0; + color: @theme_fg_color; +} + +.page-title-icon { + color: @accent_color; +} + +.page-subtitle { + font-size: 1.02em; + opacity: 0.68; +} + .caption { font-size: 0.9em; } @@ -238,7 +277,7 @@ entry:focus { .hero-title { font-size: 32px; font-weight: 800; - letter-spacing: -0.8px; + letter-spacing: 0; color: @theme_fg_color; } @@ -246,7 +285,7 @@ entry:focus { font-size: 15px; font-weight: 400; opacity: 0.55; - letter-spacing: 0.1px; + letter-spacing: 0; } @@ -281,7 +320,7 @@ entry:focus { font-size: 10px; font-weight: 700; opacity: 0.4; - letter-spacing: 1px; + letter-spacing: 0; text-transform: uppercase; } @@ -290,6 +329,41 @@ entry:focus { font-weight: 500; } +.service-card { + padding: 8px 16px; +} + +button.service-status-button { + min-height: 0; + padding: 0; +} + +.service-status-row { + padding: 4px 2px; +} + +.service-icon-frame { + min-width: 32px; + min-height: 32px; + border-radius: 11px; + background: alpha(@accent_bg_color, 0.08); + color: @accent_color; +} + +.service-icon-frame image { + color: @accent_color; +} + +.service-name { + font-size: 0.82em; + font-weight: 800; +} + +.service-status { + font-size: 0.86em; + font-weight: 600; +} + /* Progress Indicator - iOS-inspired pills */ .progress-container { padding: 6px 0; @@ -345,7 +419,7 @@ entry:focus { border-radius: 100px; font-weight: 700; font-size: 14px; - letter-spacing: 0.3px; + letter-spacing: 0; background: @accent_bg_color; color: white; box-shadow: 0 4px 16px alpha(@accent_bg_color, 0.3); From 9d78584f417890eba33f2808dd55f8cb250b7d62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Gon=C3=A7alves?= Date: Thu, 25 Jun 2026 04:08:58 -0300 Subject: [PATCH 23/47] Remove duplicate home branding --- src/big_remote_play/ui/main_window.py | 805 ++++++++++---------- tests/test_ui_premium_layout_regressions.py | 19 +- usr/share/big-remote-play/ui/style.css | 16 - 3 files changed, 423 insertions(+), 417 deletions(-) diff --git a/src/big_remote_play/ui/main_window.py b/src/big_remote_play/ui/main_window.py index 7b1da8e..06725d4 100644 --- a/src/big_remote_play/ui/main_window.py +++ b/src/big_remote_play/ui/main_window.py @@ -1,7 +1,9 @@ from __future__ import annotations import gi -gi.require_version('Gtk', '4.0'); gi.require_version('Adw', '1') + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") from gi.repository import Gtk, Adw, GLib, Gio # type: ignore import threading import json @@ -26,101 +28,86 @@ VPN_CONFIG_FILE = os.path.expanduser("~/.config/big-remoteplay/vpn_choice.json") VPN_PROVIDERS = { - 'headscale': { - 'name': 'Headscale', - 'icon': 'headscale-symbolic', - 'description': _('Self-hosted VPN server with Cloudflare DNS. Full control.'), - 'color': '#3584e4', - 'script_create': 'create-network_headscale.sh', - 'script_connect': 'create-network_headscale.sh', + "headscale": { + "name": "Headscale", + "icon": "headscale-symbolic", + "description": _("Self-hosted VPN server with Cloudflare DNS. Full control."), + "color": "#3584e4", + "script_create": "create-network_headscale.sh", + "script_connect": "create-network_headscale.sh", }, - 'tailscale': { - 'name': 'Tailscale', - 'icon': 'tailscale-symbolic', - 'description': _('Easy mesh VPN. No server required. Free tier available.'), - 'color': '#26a269', - 'script_create': 'create-network_tailscale.sh', - 'script_connect': 'create-network_tailscale.sh', + "tailscale": { + "name": "Tailscale", + "icon": "tailscale-symbolic", + "description": _("Easy mesh VPN. No server required. Free tier available."), + "color": "#26a269", + "script_create": "create-network_tailscale.sh", + "script_connect": "create-network_tailscale.sh", }, - 'zerotier': { - 'name': 'ZeroTier', - 'icon': 'zerotier-symbolic', - 'description': _('Flexible virtual network. Works through NAT and firewalls.'), - 'color': '#e5a50a', - 'script_create': 'create-network_zerotier.sh', - 'script_connect': 'create-network_zerotier.sh', + "zerotier": { + "name": "ZeroTier", + "icon": "zerotier-symbolic", + "description": _("Flexible virtual network. Works through NAT and firewalls."), + "color": "#e5a50a", + "script_create": "create-network_zerotier.sh", + "script_connect": "create-network_zerotier.sh", }, } # Service Definitions SERVICE_METADATA = { - 'sunshine': { - 'name': 'SUNSHINE', - 'full_name': _('Sunshine Game Stream Host'), - 'description': _('High-performance game stream host. Required to share your games.'), - 'icon': 'network-server-symbolic', - 'type': 'service', - 'unit': 'sunshine.service', - 'user': True + "sunshine": { + "name": "SUNSHINE", + "full_name": _("Sunshine Game Stream Host"), + "description": _("High-performance game stream host. Required to share your games."), + "icon": "network-server-symbolic", + "type": "service", + "unit": "sunshine.service", + "user": True, + }, + "moonlight": { + "name": "MOONLIGHT", + "full_name": _("Moonlight Game Stream Client"), + "description": _("Game stream client. Required to connect to other hosts."), + "icon": "network-workgroup-symbolic", + "type": "app", + "bin": "moonlight-qt", }, - 'moonlight': { - 'name': 'MOONLIGHT', - 'full_name': _('Moonlight Game Stream Client'), - 'description': _('Game stream client. Required to connect to other hosts.'), - 'icon': 'network-workgroup-symbolic', - 'type': 'app', - 'bin': 'moonlight-qt' + "docker": { + "name": "DOCKER", + "full_name": _("Docker Engine"), + "description": _("Container platform. Required for the private network server."), + "icon": "preferences-system-symbolic", + "type": "service", + "unit": "docker.service", + "user": False, }, - 'docker': { - 'name': 'DOCKER', - 'full_name': _('Docker Engine'), - 'description': _('Container platform. Required for the private network server.'), - 'icon': 'preferences-system-symbolic', - 'type': 'service', - 'unit': 'docker.service', - 'user': False + "tailscale": { + "name": "TAILSCALE", + "full_name": _("Tailscale"), + "description": _("Mesh VPN service. Required for Tailscale connectivity."), + "icon": "tailscale-symbolic", + "type": "service", + "unit": "tailscaled.service", + "user": False, }, - 'tailscale': { - 'name': 'TAILSCALE', - 'full_name': _('Tailscale'), - 'description': _('Mesh VPN service. Required for Tailscale connectivity.'), - 'icon': 'tailscale-symbolic', - 'type': 'service', - 'unit': 'tailscaled.service', - 'user': False + "zerotier": { + "name": "ZEROTIER", + "full_name": _("ZeroTier"), + "description": _("Virtual network service. Required for ZeroTier connectivity."), + "icon": "zerotier-symbolic", + "type": "service", + "unit": "zerotier-one.service", + "user": False, }, - 'zerotier': { - 'name': 'ZEROTIER', - 'full_name': _('ZeroTier'), - 'description': _('Virtual network service. Required for ZeroTier connectivity.'), - 'icon': 'zerotier-symbolic', - 'type': 'service', - 'unit': 'zerotier-one.service', - 'user': False - } } # Navigation Categories – built dynamically based on VPN choice BASE_NAVIGATION_PAGES = { - 'welcome': { - 'name': _('Home'), - 'icon': 'go-home-symbolic', - 'description': _('Home Page') - }, - 'host': { - 'name': _('Server'), - 'icon': 'network-server-symbolic', - 'description': _('Share your games') - }, - 'guest': { - 'name': _('Connect to Server'), - 'icon': 'network-workgroup-symbolic', - 'description': _('Connect to a host') - }, - 'section_private': { - 'name': _('Private Network'), - 'type': 'separator' - }, + "welcome": {"name": _("Home"), "icon": "go-home-symbolic", "description": _("Home Page")}, + "host": {"name": _("Server"), "icon": "network-server-symbolic", "description": _("Share your games")}, + "guest": {"name": _("Connect to Server"), "icon": "network-workgroup-symbolic", "description": _("Connect to a host")}, + "section_private": {"name": _("Private Network"), "type": "separator"}, } @@ -128,9 +115,9 @@ def load_vpn_choice(): """Load saved VPN provider choice. Returns None if not set.""" try: if os.path.exists(VPN_CONFIG_FILE): - with open(VPN_CONFIG_FILE, 'r') as f: + with open(VPN_CONFIG_FILE, "r") as f: data = json.load(f) - choice = data.get('vpn_provider') + choice = data.get("vpn_provider") if choice in VPN_PROVIDERS: return choice except Exception: @@ -141,8 +128,8 @@ def load_vpn_choice(): def save_vpn_choice(provider_id): """Persist VPN provider choice.""" os.makedirs(os.path.dirname(VPN_CONFIG_FILE), exist_ok=True) - with open(VPN_CONFIG_FILE, 'w') as f: - json.dump({'vpn_provider': provider_id}, f) + with open(VPN_CONFIG_FILE, "w") as f: + json.dump({"vpn_provider": provider_id}, f) class MainWindow(Adw.ApplicationWindow): @@ -151,14 +138,14 @@ class MainWindow(Adw.ApplicationWindow): def __init__(self, **kwargs): super().__init__(**kwargs) - self.set_title('Big Remote Play') + self.set_title("Big Remote Play") self.set_default_size(950, 720) self.system_check = SystemCheck() self.network = NetworkDiscovery() # Current State - self.current_page = 'welcome' + self.current_page = "welcome" self._vpn_choice = load_vpn_choice() # None if not yet chosen self._nav_page_by_row = {} self._service_by_row = {} @@ -170,20 +157,27 @@ def __init__(self, **kwargs): self.check_system() # Connect close signal - self.connect('close-request', self.on_close_request) + self.connect("close-request", self.on_close_request) def on_close_request(self, window): try: - if hasattr(self, 'host_view'): self.host_view.cleanup() - if hasattr(self, 'guest_view'): self.guest_view.cleanup() - except Exception: pass + if hasattr(self, "host_view"): + self.host_view.cleanup() + if hasattr(self, "guest_view"): + self.guest_view.cleanup() + except Exception: + pass return False def setup_ui(self): - self.toast_overlay = Adw.ToastOverlay(); self.set_content(self.toast_overlay) - self.split_view = Adw.NavigationSplitView(); self.toast_overlay.set_child(self.split_view) - self.setup_sidebar(); self.setup_content() - self.split_view.set_min_sidebar_width(260); self.split_view.set_max_sidebar_width(320) + self.toast_overlay = Adw.ToastOverlay() + self.set_content(self.toast_overlay) + self.split_view = Adw.NavigationSplitView() + self.toast_overlay.set_child(self.split_view) + self.setup_sidebar() + self.setup_content() + self.split_view.set_min_sidebar_width(260) + self.split_view.set_max_sidebar_width(320) def _install_window_actions(self): nav_action = Gio.SimpleAction.new("navigate", GLib.VariantType.new("s")) @@ -200,45 +194,52 @@ def _build_navigation_pages(self): pages = dict(BASE_NAVIGATION_PAGES) if self._vpn_choice: vpn_info = VPN_PROVIDERS[self._vpn_choice] - pages['create_private'] = { - 'name': _('Create Private Network'), - 'icon': vpn_info['icon'], - 'description': _('{} - Setup server').format(vpn_info['name']), - 'badge': vpn_info['name'], + pages["create_private"] = { + "name": _("Create Private Network"), + "icon": vpn_info["icon"], + "description": _("{} - Setup server").format(vpn_info["name"]), + "badge": vpn_info["name"], } - pages['connect_private'] = { - 'name': _('Connect to Private Network'), - 'icon': vpn_info['icon'], - 'description': _('{} - Join network').format(vpn_info['name']), - 'badge': vpn_info['name'], + pages["connect_private"] = { + "name": _("Connect to Private Network"), + "icon": vpn_info["icon"], + "description": _("{} - Join network").format(vpn_info["name"]), + "badge": vpn_info["name"], } - pages['change_vpn'] = { - 'name': _('Change VPN'), - 'icon': 'network-private-symbolic', - 'description': _('Switch provider'), + pages["change_vpn"] = { + "name": _("Change VPN"), + "icon": "network-private-symbolic", + "description": _("Switch provider"), } else: # Single "connect" entry that leads to VPN selector - pages['vpn_selector'] = { - 'name': _('Select VPN'), - 'icon': 'network-private-symbolic', - 'description': _('Choose your VPN provider'), + pages["vpn_selector"] = { + "name": _("Select VPN"), + "icon": "network-private-symbolic", + "description": _("Choose your VPN provider"), } return pages def setup_sidebar(self): tb = Adw.ToolbarView() - main = Gtk.Box(orientation=Gtk.Orientation.VERTICAL); main.set_vexpand(True) + main = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + main.set_vexpand(True) main.set_margin_top(12) main.append(self._create_sidebar_identity()) - scroll = Gtk.ScrolledWindow(); scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC); scroll.set_vexpand(True) - self.nav_list = Gtk.ListBox(); self.nav_list.add_css_class('navigation-sidebar') - self.nav_list.connect('row-selected', self.on_nav_selected) + scroll = Gtk.ScrolledWindow() + scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + scroll.set_vexpand(True) + self.nav_list = Gtk.ListBox() + self.nav_list.add_css_class("navigation-sidebar") + self.nav_list.connect("row-selected", self.on_nav_selected) self._refresh_nav_list() - scroll.set_child(self.nav_list); main.append(scroll); main.append(self.create_status_footer()) - tb.set_content(main); self.split_view.set_sidebar(Adw.NavigationPage.new(tb, 'Navigation')) + scroll.set_child(self.nav_list) + main.append(scroll) + main.append(self.create_status_footer()) + tb.set_content(main) + self.split_view.set_sidebar(Adw.NavigationPage.new(tb, "Navigation")) def _create_sidebar_identity(self) -> Gtk.Widget: identity = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) @@ -247,19 +248,19 @@ def _create_sidebar_identity(self) -> Gtk.Widget: identity.set_margin_end(14) identity.set_margin_bottom(16) - logo = create_logo_widget('big-remote-play', 48) + logo = create_logo_widget("big-remote-play", 48) logo.add_css_class("sidebar-logo") logo.set_valign(Gtk.Align.CENTER) identity.append(logo) text = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) text.set_valign(Gtk.Align.CENTER) - title = Gtk.Label(label='Big Remote Play') + title = Gtk.Label(label="Big Remote Play") title.add_css_class("sidebar-title") title.set_halign(Gtk.Align.START) text.append(title) - subtitle = Gtk.Label(label=_('Play together, from anywhere')) + subtitle = Gtk.Label(label=_("Play together, from anywhere")) subtitle.add_css_class("sidebar-subtitle") subtitle.set_halign(Gtk.Align.START) subtitle.set_wrap(True) @@ -277,15 +278,15 @@ def _refresh_nav_list(self): nav_pages = self._build_navigation_pages() for pid, info in nav_pages.items(): - if info.get('type') == 'separator': + if info.get("type") == "separator": sep_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) sep_box.set_margin_top(12) sep_box.set_margin_bottom(4) sep_box.set_margin_start(12) - label = Gtk.Label(label=info['name']) - label.add_css_class('caption') - label.add_css_class('dim-label') + label = Gtk.Label(label=info["name"]) + label.add_css_class("caption") + label.add_css_class("dim-label") label.set_halign(Gtk.Align.START) sep_box.append(label) @@ -308,7 +309,7 @@ def create_nav_row(self, page_id: str, page_info: dict) -> Gtk.ListBoxRow: """Creates navigation row in sidebar""" row = Gtk.ListBoxRow() self._nav_page_by_row[row] = page_id - row.add_css_class('category-row') + row.add_css_class("category-row") box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) box.set_margin_start(8) @@ -316,25 +317,25 @@ def create_nav_row(self, page_id: str, page_info: dict) -> Gtk.ListBoxRow: box.set_margin_top(6) box.set_margin_bottom(6) - icon = create_icon_widget(page_info['icon'], size=20, css_class='category-icon') + icon = create_icon_widget(page_info["icon"], size=20, css_class="category-icon") box.append(icon) - label = Gtk.Label(label=page_info['name']) + label = Gtk.Label(label=page_info["name"]) label.set_halign(Gtk.Align.START) label.set_hexpand(True) - label.add_css_class('category-label') + label.add_css_class("category-label") box.append(label) # Badge showing selected VPN name - if badge_text := page_info.get('badge'): + if badge_text := page_info.get("badge"): badge = Gtk.Label(label=badge_text) - badge.add_css_class('caption') - badge.add_css_class('dim-label') + badge.add_css_class("caption") + badge.add_css_class("dim-label") badge.set_halign(Gtk.Align.END) box.append(badge) button = Gtk.Button() - button.add_css_class('flat') + button.add_css_class("flat") button.set_hexpand(True) button.set_halign(Gtk.Align.FILL) button.set_action_name("win.navigate") @@ -342,7 +343,7 @@ def create_nav_row(self, page_id: str, page_info: dict) -> Gtk.ListBoxRow: button.set_child(box) button.update_property( [Gtk.AccessibleProperty.LABEL, Gtk.AccessibleProperty.DESCRIPTION], - [page_info['name'], page_info.get('description', '')], + [page_info["name"], page_info.get("description", "")], ) row.set_child(button) @@ -361,9 +362,9 @@ def create_status_footer(self): separator.set_margin_bottom(8) footer.append(separator) - status_title = Gtk.Label(label=_('Service Status')) - status_title.add_css_class('caption') - status_title.add_css_class('dim-label') + status_title = Gtk.Label(label=_("Service Status")) + status_title.add_css_class("caption") + status_title.add_css_class("dim-label") status_title.set_halign(Gtk.Align.START) status_title.set_margin_bottom(4) footer.append(status_title) @@ -390,24 +391,24 @@ def add_status_row(container, label_text, dot_attr, lbl_attr, service_id): icon_frame = Gtk.Box() icon_frame.add_css_class("service-icon-frame") - service_icon = create_icon_widget(meta.get('icon', 'preferences-system-symbolic'), size=20) + service_icon = create_icon_widget(meta.get("icon", "preferences-system-symbolic"), size=20) service_icon.set_valign(Gtk.Align.CENTER) - for margin in ['top', 'bottom', 'start', 'end']: - getattr(service_icon, f'set_margin_{margin}')(6) + for margin in ["top", "bottom", "start", "end"]: + getattr(service_icon, f"set_margin_{margin}")(6) icon_frame.append(service_icon) content.append(icon_frame) box_key = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) box_key.set_hexpand(True) - dot = create_icon_widget('media-record-symbolic', size=10, css_class=['status-dot', 'status-offline']) + dot = create_icon_widget("media-record-symbolic", size=10, css_class=["status-dot", "status-offline"]) self._status_dots[service_id] = dot setattr(self, dot_attr, dot) lbl_key = Gtk.Label(label=label_text) - lbl_key.add_css_class('service-name') + lbl_key.add_css_class("service-name") lbl_key.set_halign(Gtk.Align.START) box_key.append(lbl_key) - lbl_status = Gtk.Label(label=_('Checking...')) - lbl_status.add_css_class('service-status') + lbl_status = Gtk.Label(label=_("Checking...")) + lbl_status.add_css_class("service-status") lbl_status.set_halign(Gtk.Align.START) self._status_labels[service_id] = lbl_status setattr(self, lbl_attr, lbl_status) @@ -417,19 +418,17 @@ def add_status_row(container, label_text, dot_attr, lbl_attr, service_id): content.append(dot) row.set_child(content) # Plain-language explanation of each engine for new users. - desc = meta.get('description', '') + desc = meta.get("description", "") if desc: row.set_tooltip_text(desc) - row.update_property( - [Gtk.AccessibleProperty.LABEL, Gtk.AccessibleProperty.DESCRIPTION], - [label_text, desc]) + row.update_property([Gtk.AccessibleProperty.LABEL, Gtk.AccessibleProperty.DESCRIPTION], [label_text, desc]) container.append(row) - add_status_row(card, 'SUNSHINE', 'sunshine_dot', 'lbl_sunshine_status', 'sunshine') - add_status_row(card, 'MOONLIGHT', 'moonlight_dot', 'lbl_moonlight_status', 'moonlight') - add_status_row(card, 'DOCKER', 'docker_dot', 'lbl_docker_status', 'docker') - add_status_row(card, 'TAILSCALE', 'tailscale_dot', 'lbl_tailscale_status', 'tailscale') - add_status_row(card, 'ZEROTIER', 'zerotier_dot', 'lbl_zerotier_status', 'zerotier') + add_status_row(card, "SUNSHINE", "sunshine_dot", "lbl_sunshine_status", "sunshine") + add_status_row(card, "MOONLIGHT", "moonlight_dot", "lbl_moonlight_status", "moonlight") + add_status_row(card, "DOCKER", "docker_dot", "lbl_docker_status", "docker") + add_status_row(card, "TAILSCALE", "tailscale_dot", "lbl_tailscale_status", "tailscale") + add_status_row(card, "ZEROTIER", "zerotier_dot", "lbl_zerotier_status", "zerotier") footer.append(card) self._filter_status_rows() @@ -437,18 +436,19 @@ def add_status_row(container, label_text, dot_attr, lbl_attr, service_id): def _filter_status_rows(self): """Show only relevant services based on VPN choice.""" - if not hasattr(self, 'status_card'): return - + if not hasattr(self, "status_card"): + return + vpn = self._vpn_choice - visible_services = ['sunshine', 'moonlight', 'docker', 'tailscale'] if vpn is None else ['sunshine', 'moonlight'] - - if vpn == 'headscale': - visible_services.extend(['docker', 'tailscale']) - elif vpn == 'tailscale': - visible_services.append('tailscale') - elif vpn == 'zerotier': - visible_services.append('zerotier') - + visible_services = ["sunshine", "moonlight", "docker", "tailscale"] if vpn is None else ["sunshine", "moonlight"] + + if vpn == "headscale": + visible_services.extend(["docker", "tailscale"]) + elif vpn == "tailscale": + visible_services.append("tailscale") + elif vpn == "zerotier": + visible_services.append("zerotier") + child = self.status_card.get_first_child() while child: sid = self._service_by_row.get(child) @@ -457,26 +457,21 @@ def _filter_status_rows(self): child = child.get_next_sibling() def update_server_status(self, has_sun, has_moon, has_docker, has_tailscale, has_zt=False): - for service_id, has in [ - ('sunshine', has_sun), - ('moonlight', has_moon), - ('docker', has_docker), - ('tailscale', has_tailscale), - ('zerotier', has_zt) - ]: + for service_id, has in [("sunshine", has_sun), ("moonlight", has_moon), ("docker", has_docker), ("tailscale", has_tailscale), ("zerotier", has_zt)]: dot = self._status_dots.get(service_id) - if not dot: continue - dot.remove_css_class('status-online') - dot.remove_css_class('status-offline') - dot.add_css_class('status-online' if has else 'status-offline') + if not dot: + continue + dot.remove_css_class("status-online") + dot.remove_css_class("status-offline") + dot.add_css_class("status-online" if has else "status-offline") def update_dependency_ui(self, has_sun, has_moon, has_docker, has_tailscale, has_zt=False): status_items = [ - ('sunshine', self.host_card, has_sun, 'Sunshine'), - ('moonlight', self.guest_card, has_moon, 'Moonlight'), - ('docker', None, has_docker, 'Docker'), - ('tailscale', None, has_tailscale, 'Tailscale'), - ('zerotier', None, has_zt, 'ZeroTier') + ("sunshine", self.host_card, has_sun, "Sunshine"), + ("moonlight", self.guest_card, has_moon, "Moonlight"), + ("docker", None, has_docker, "Docker"), + ("tailscale", None, has_tailscale, "Tailscale"), + ("zerotier", None, has_zt, "ZeroTier"), ] for service_id, card, has, name in status_items: @@ -489,45 +484,50 @@ def update_dependency_ui(self, has_sun, has_moon, has_docker, has_tailscale, has if card: tooltip = "" if not has: - action = _("host") if name == 'Sunshine' else _("connect") + action = _("host") if name == "Sunshine" else _("connect") tooltip = _("Need to install {} to {}").format(name, action) card.set_sensitive(has) card.set_tooltip_text(tooltip) def setup_content(self): - ct = Adw.ToolbarView(); hb = Adw.HeaderBar() + ct = Adw.ToolbarView() + hb = Adw.HeaderBar() hb.pack_end(self._create_header_menu_button()) # Dynamic title reflecting the current section (filled in on_nav_selected). self.content_headerbar = hb - self.content_title = Adw.WindowTitle.new(_('Home'), _('Play together, from anywhere')) + self.content_title = Adw.WindowTitle.new(_("Home"), _("Play together, from anywhere")) hb.set_title_widget(self.content_title) - ct.add_top_bar(hb); self.content_stack = Gtk.Stack() + ct.add_top_bar(hb) + self.content_stack = Gtk.Stack() self.content_stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE) self.content_stack.set_transition_duration(200) - self.content_stack.add_named(self.create_welcome_page(), 'welcome') - self.host_view = HostView(); self.content_stack.add_named(self.host_view, 'host') - self.guest_view = GuestView(); self.content_stack.add_named(self.guest_view, 'guest') + self.content_stack.add_named(self.create_welcome_page(), "welcome") + self.host_view = HostView() + self.content_stack.add_named(self.host_view, "host") + self.guest_view = GuestView() + self.content_stack.add_named(self.guest_view, "guest") # VPN Selector page (shown when no VPN is chosen yet) self.vpn_selector_page = self.create_vpn_selector_page() - self.content_stack.add_named(self.vpn_selector_page, 'vpn_selector') + self.content_stack.add_named(self.vpn_selector_page, "vpn_selector") # Private Network Views (Headscale/Tailscale/ZeroTier) from .private_network_view import PrivateNetworkView - vpn = self._vpn_choice or 'headscale' - self.create_private_view = PrivateNetworkView(self, mode='create', vpn_provider=vpn) - self.connect_private_view = PrivateNetworkView(self, mode='connect', vpn_provider=vpn) - self.content_stack.add_named(self.create_private_view, 'create_private') - self.content_stack.add_named(self.connect_private_view, 'connect_private') + + vpn = self._vpn_choice or "headscale" + self.create_private_view = PrivateNetworkView(self, mode="create", vpn_provider=vpn) + self.connect_private_view = PrivateNetworkView(self, mode="connect", vpn_provider=vpn) + self.content_stack.add_named(self.create_private_view, "create_private") + self.content_stack.add_named(self.connect_private_view, "connect_private") ct.set_content(self.content_stack) - self.split_view.set_content(Adw.NavigationPage.new(ct, 'Big Remote Play')) + self.split_view.set_content(Adw.NavigationPage.new(ct, "Big Remote Play")) def _create_header_menu_button(self): - menu_button = Gtk.MenuButton(icon_name='open-menu-symbolic') - menu_button.update_property([Gtk.AccessibleProperty.LABEL], [_('Application menu')]) + menu_button = Gtk.MenuButton(icon_name="open-menu-symbolic") + menu_button.update_property([Gtk.AccessibleProperty.LABEL], [_("Application menu")]) popover = Gtk.Popover() menu_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) @@ -537,15 +537,15 @@ def _create_header_menu_button(self): menu_box.set_margin_end(6) for label, action_name in ( - (_('Preferences'), 'preferences'), - (_('About'), 'about'), + (_("Preferences"), "preferences"), + (_("About"), "about"), ): button = Gtk.Button(label=label) - button.add_css_class('flat') + button.add_css_class("flat") button.set_hexpand(True) button.set_halign(Gtk.Align.FILL) button.connect( - 'clicked', + "clicked", lambda _button, menu_popover=popover, name=action_name: self._activate_app_menu_action(menu_popover, name), ) button.update_property([Gtk.AccessibleProperty.LABEL], [label]) @@ -574,24 +574,25 @@ def create_vpn_selector_page(self): clamp = Adw.Clamp() clamp.set_maximum_size(900) clamp.set_valign(Gtk.Align.START) - for m in ['top', 'bottom', 'start', 'end']: - getattr(clamp, f'set_margin_{m}')(24) + for m in ["top", "bottom", "start", "end"]: + getattr(clamp, f"set_margin_{m}")(24) box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=24) - box.append(create_page_header( - _('Choose Your VPN Provider'), - _('Select a VPN solution to create or join a Private Network. ' - 'Your choice will be saved and shown in the sidebar menu.'), - 'network-private-symbolic', - )) + box.append( + create_page_header( + _("Choose Your VPN Provider"), + _("Select a VPN solution to create or join a Private Network. Your choice will be saved and shown in the sidebar menu."), + "network-private-symbolic", + ) + ) # Cards row cards_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=16) cards_box.set_halign(Gtk.Align.CENTER) cards_box.set_homogeneous(True) - for pid in ('tailscale', 'zerotier', 'headscale'): + for pid in ("tailscale", "zerotier", "headscale"): info = VPN_PROVIDERS[pid] card = self._create_vpn_card(pid, info) cards_box.append(card) @@ -600,31 +601,32 @@ def create_vpn_selector_page(self): # Comparison table (real grid, mockup 04) compare_group = Adw.PreferencesGroup() - compare_group.set_title(_('Quick Comparison')) - compare_group.set_header_suffix(create_icon_widget('preferences-other-symbolic', size=18)) + compare_group.set_title(_("Quick Comparison")) + compare_group.set_header_suffix(create_icon_widget("preferences-other-symbolic", size=18)) # Brand names (column headers) are not translated. - compare_group.add(create_comparison_table( - ['Tailscale', 'ZeroTier', 'Headscale'], - [ - (_('Setup difficulty'), [_('Beginner'), _('Intermediate'), _('Advanced')]), - (_('Hosting model'), [_('Cloud (free)'), _('Cloud (free)'), _('Self-hosted')]), - (_('Best for'), [_('Personal use and friends'), - _('Flexible networks and teams'), - _('Corporate environments')]), - ], - )) + compare_group.add( + create_comparison_table( + ["Tailscale", "ZeroTier", "Headscale"], + [ + (_("Setup difficulty"), [_("Beginner"), _("Intermediate"), _("Advanced")]), + (_("Hosting model"), [_("Cloud (free)"), _("Cloud (free)"), _("Self-hosted")]), + (_("Best for"), [_("Personal use and friends"), _("Flexible networks and teams"), _("Corporate environments")]), + ], + ) + ) box.append(compare_group) # "How it works" strip: Install → Authenticate → Play. steps_group = Adw.PreferencesGroup() - steps_group.add(create_steps_strip([ - ('document-save-symbolic', _('Install'), - _('Install the chosen VPN provider on your device.')), - ('system-users-symbolic', _('Authenticate'), - _('Log in and authorize your devices on the VPN.')), - ('input-gaming-symbolic', _('Play'), - _('Connect to the server and play with your friends!')), - ])) + steps_group.add( + create_steps_strip( + [ + ("document-save-symbolic", _("Install"), _("Install the chosen VPN provider on your device.")), + ("system-users-symbolic", _("Authenticate"), _("Log in and authorize your devices on the VPN.")), + ("input-gaming-symbolic", _("Play"), _("Connect to the server and play with your friends!")), + ] + ) + ) box.append(steps_group) clamp.set_child(box) @@ -634,56 +636,56 @@ def create_vpn_selector_page(self): def _create_vpn_card(self, provider_id: str, info: dict) -> Gtk.Button: """Create a VPN option card button.""" btn = Gtk.Button() - btn.add_css_class('action-card') + btn.add_css_class("action-card") # 'card-accent' keeps readable dark text (unlike 'suggested-action', # which forces white text on the near-white tint). - if provider_id == 'tailscale': - btn.add_css_class('card-accent') + if provider_id == "tailscale": + btn.add_css_class("card-accent") btn.set_size_request(220, 210) - btn.connect('clicked', lambda b, pid=provider_id: self._on_vpn_selected(pid)) + btn.connect("clicked", lambda b, pid=provider_id: self._on_vpn_selected(pid)) btn.update_property( [Gtk.AccessibleProperty.LABEL, Gtk.AccessibleProperty.DESCRIPTION], - [info['name'], info['description']], + [info["name"], info["description"]], ) box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) box.set_valign(Gtk.Align.CENTER) box.set_halign(Gtk.Align.CENTER) - for m in ['top', 'bottom', 'start', 'end']: - getattr(box, f'set_margin_{m}')(16) + for m in ["top", "bottom", "start", "end"]: + getattr(box, f"set_margin_{m}")(16) - if provider_id == 'tailscale': - badge = Gtk.Label(label=_('Recommended')) - badge.add_css_class('card-badge') + if provider_id == "tailscale": + badge = Gtk.Label(label=_("Recommended")) + badge.add_css_class("card-badge") badge.set_halign(Gtk.Align.CENTER) box.append(badge) # Icon - icon = create_icon_widget(info['icon'], size=44) - icon.add_css_class('accent') + icon = create_icon_widget(info["icon"], size=44) + icon.add_css_class("accent") box.append(icon) # Name - name_lbl = Gtk.Label(label=info['name']) - name_lbl.add_css_class('title-3') + name_lbl = Gtk.Label(label=info["name"]) + name_lbl.add_css_class("title-3") box.append(name_lbl) # Description - desc_lbl = Gtk.Label(label=info['description']) - desc_lbl.add_css_class('caption') - desc_lbl.add_css_class('dim-label') + desc_lbl = Gtk.Label(label=info["description"]) + desc_lbl.add_css_class("caption") + desc_lbl.add_css_class("dim-label") desc_lbl.set_wrap(True) desc_lbl.set_max_width_chars(24) desc_lbl.set_justify(Gtk.Justification.CENTER) box.append(desc_lbl) # Difficulty pill (mockup 04): Tailscale=beginner ... Headscale=advanced. - levels = {'tailscale': 'beginner', 'zerotier': 'intermediate', 'headscale': 'advanced'} - box.append(create_difficulty_pill(levels.get(provider_id, 'intermediate'))) + levels = {"tailscale": "beginner", "zerotier": "intermediate", "headscale": "advanced"} + box.append(create_difficulty_pill(levels.get(provider_id, "intermediate"))) # "Choose" label - choose_lbl = Gtk.Label(label=_('Choose →')) - choose_lbl.add_css_class('caption-heading') + choose_lbl = Gtk.Label(label=_("Choose →")) + choose_lbl.add_css_class("caption-heading") box.append(choose_lbl) btn.set_child(box) @@ -692,27 +694,25 @@ def _create_vpn_card(self, provider_id: str, info: dict) -> Gtk.Button: def _on_vpn_selected(self, provider_id: str): """Handle VPN provider selection.""" old_vpn = self._vpn_choice - + if old_vpn and old_vpn != provider_id: dialog = Adw.MessageDialog( transient_for=self, heading=_("Switch VPN Provider?"), body=_("You are switching from {} to {}. Do you want to disconnect from {}?").format( - VPN_PROVIDERS[old_vpn]['name'], - VPN_PROVIDERS[provider_id]['name'], - VPN_PROVIDERS[old_vpn]['name'] - ) + VPN_PROVIDERS[old_vpn]["name"], VPN_PROVIDERS[provider_id]["name"], VPN_PROVIDERS[old_vpn]["name"] + ), ) dialog.add_response("keep", _("Keep Connected")) dialog.add_response("disconnect", _("Disconnect previous")) dialog.set_response_appearance("disconnect", Adw.ResponseAppearance.DESTRUCTIVE) dialog.set_default_response("keep") - + def on_resp(dlg, resp): if resp == "disconnect": self._disconnect_vpn(old_vpn) self._apply_vpn_selection(provider_id) - + dialog.connect("response", on_resp) dialog.present() else: @@ -720,16 +720,18 @@ def on_resp(dlg, resp): def _disconnect_vpn(self, vpn_id): """Disconnect from a specific VPN provider.""" - self.show_toast(_("Disconnecting from {}...").format(VPN_PROVIDERS[vpn_id]['name'])) + self.show_toast(_("Disconnecting from {}...").format(VPN_PROVIDERS[vpn_id]["name"])) + def run(): - if vpn_id in ('headscale', 'tailscale'): + if vpn_id in ("headscale", "tailscale"): subprocess.run(["bigsudo", "tailscale", "logout"], timeout=30) - elif vpn_id == 'zerotier': - # Local ZT disconnection is a bit trickier, + elif vpn_id == "zerotier": + # Local ZT disconnection is a bit trickier, # usually means leaving all networks or stopping the service # For simplicity, we can try to leave networks found in history or just stop service subprocess.run(["bigsudo", "systemctl", "stop", "zerotier-one"], timeout=30) - GLib.idle_add(lambda: self.show_toast(_("{} disconnected").format(VPN_PROVIDERS[vpn_id]['name']))) + GLib.idle_add(lambda: self.show_toast(_("{} disconnected").format(VPN_PROVIDERS[vpn_id]["name"]))) + threading.Thread(target=run, daemon=True).start() def _apply_vpn_selection(self, provider_id): @@ -740,24 +742,24 @@ def _apply_vpn_selection(self, provider_id): from .private_network_view import PrivateNetworkView # Remove old views if they exist - for page_name in ['create_private', 'connect_private']: + for page_name in ["create_private", "connect_private"]: old = self.content_stack.get_child_by_name(page_name) if old: self.content_stack.remove(old) - self.create_private_view = PrivateNetworkView(self, mode='create', vpn_provider=provider_id) - self.connect_private_view = PrivateNetworkView(self, mode='connect', vpn_provider=provider_id) - self.content_stack.add_named(self.create_private_view, 'create_private') - self.content_stack.add_named(self.connect_private_view, 'connect_private') + self.create_private_view = PrivateNetworkView(self, mode="create", vpn_provider=provider_id) + self.connect_private_view = PrivateNetworkView(self, mode="connect", vpn_provider=provider_id) + self.content_stack.add_named(self.create_private_view, "create_private") + self.content_stack.add_named(self.connect_private_view, "connect_private") # Refresh sidebar self._refresh_nav_list() self._filter_status_rows() # Navigate to the create page - vpn_name = VPN_PROVIDERS[provider_id]['name'] - self.show_toast(_('{} selected! Setting up private network...').format(vpn_name)) - GLib.idle_add(lambda: self.navigate_to('create_private')) + vpn_name = VPN_PROVIDERS[provider_id]["name"] + self.show_toast(_("{} selected! Setting up private network...").format(vpn_name)) + GLib.idle_add(lambda: self.navigate_to("create_private")) def reset_vpn_choice(self): """Clear VPN choice and show selector again.""" @@ -768,14 +770,16 @@ def reset_vpn_choice(self): except Exception: pass self._refresh_nav_list() - self.navigate_to('vpn_selector') + self.navigate_to("vpn_selector") # ───────────────────────────────────────────────────────────────────────── # WELCOME PAGE # ───────────────────────────────────────────────────────────────────────── def create_welcome_page(self): - scroll = Gtk.ScrolledWindow(); scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC); scroll.set_vexpand(True) + scroll = Gtk.ScrolledWindow() + scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + scroll.set_vexpand(True) # Centred vertically so short content distributes the slack top and bottom # (no empty "footer" band at the bottom); compact so it still fits. @@ -785,54 +789,34 @@ def create_welcome_page(self): main_box.set_margin_top(24) main_box.set_margin_bottom(24) - logo_img = create_logo_widget('big-remote-play', 72) - logo_img.set_halign(Gtk.Align.CENTER) - logo_img.set_valign(Gtk.Align.CENTER) - logo_img.set_margin_bottom(8) - main_box.append(logo_img) - - title = Gtk.Label(label='Big Remote Play') - title.add_css_class('hero-title') - title.add_css_class('animate-fade') - main_box.append(title) - - subtitle = Gtk.Label(label=_('Play cooperatively over the local network or the internet')) - subtitle.add_css_class('hero-subtitle') - subtitle.add_css_class('animate-fade') - subtitle.add_css_class('delay-1') - main_box.append(subtitle) - - # Frame the decision so a first-time user knows what the two cards mean. - prompt = Gtk.Label(label=_('What do you want to do?')) - prompt.add_css_class('title-4') - prompt.add_css_class('animate-fade') - prompt.add_css_class('delay-1') - prompt.set_margin_top(20) - prompt.set_margin_bottom(12) + prompt = Gtk.Label(label=_("What do you want to do?")) + prompt.add_css_class("title-4") + prompt.add_css_class("animate-fade") + prompt.set_margin_bottom(16) main_box.append(prompt) cards_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=24) cards_box.set_halign(Gtk.Align.CENTER) - cards_box.add_css_class('animate-fade') - cards_box.add_css_class('delay-2') + cards_box.add_css_class("animate-fade") + cards_box.add_css_class("delay-2") # Host is the primary action (this PC shares the game); mark it visually. self.host_card = self.create_action_card( - _('Share the game'), - _('Run the game on THIS PC and let friends connect to play together'), - 'network-server-symbolic', - lambda: self.navigate_to('host'), + _("Share the game"), + _("Run the game on THIS PC and let friends connect to play together"), + "network-server-symbolic", + lambda: self.navigate_to("host"), primary=True, - badge=_('Recommended'), - cta=_('Start sharing →'), + badge=_("Recommended"), + cta=_("Start sharing →"), ) self.guest_card = self.create_action_card( - _('Join a game'), + _("Join a game"), _("Connect to a friend's PC over the network and play remotely"), - 'network-workgroup-symbolic', - lambda: self.navigate_to('guest'), - cta=_('Connect now →'), + "network-workgroup-symbolic", + lambda: self.navigate_to("guest"), + cta=_("Connect now →"), ) cards_box.append(self.host_card) @@ -840,17 +824,13 @@ def create_welcome_page(self): main_box.append(cards_box) # "How it works" strip, framed to plain-language 3 steps (mockup 02). - steps = create_steps_strip([ - ('network-server-symbolic', - _('Start or connect'), - _('Start a server on this PC or connect to an existing one.')), - ('system-users-symbolic', - _('Invite friends'), - _('Share the PIN code or the server address.')), - ('input-gaming-symbolic', - _('Play together'), - _('Everyone connects and plays remotely.')), - ]) + steps = create_steps_strip( + [ + ("network-server-symbolic", _("Start or connect"), _("Start a server on this PC or connect to an existing one.")), + ("system-users-symbolic", _("Invite friends"), _("Share the PIN code or the server address.")), + ("input-gaming-symbolic", _("Play together"), _("Everyone connects and plays remotely.")), + ] + ) steps.set_size_request(760, -1) steps.set_halign(Gtk.Align.CENTER) steps.set_margin_top(20) @@ -861,15 +841,15 @@ def create_welcome_page(self): def create_action_card(self, title, desc, icon, cb, primary=False, badge=None, cta=None): btn = Gtk.Button() - btn.add_css_class('action-card') + btn.add_css_class("action-card") # 'card-accent' tints + accent-borders the card while keeping the normal # (dark) foreground, unlike 'suggested-action' which forces white text. if primary: - btn.add_css_class('card-accent') + btn.add_css_class("card-accent") btn.set_size_request(270, 188) btn.set_hexpand(False) btn.set_vexpand(False) - btn.connect('clicked', lambda b: cb()) + btn.connect("clicked", lambda b: cb()) # Multi-label child: GTK can't infer a name, so set it (title + description). btn.update_property( [Gtk.AccessibleProperty.LABEL, Gtk.AccessibleProperty.DESCRIPTION], @@ -881,11 +861,12 @@ def create_action_card(self, title, desc, icon, cb, primary=False, badge=None, c box.set_halign(Gtk.Align.CENTER) box.set_hexpand(False) box.set_vexpand(False) - for m in ['top', 'bottom', 'start', 'end']: getattr(box, f'set_margin_{m}')(16) + for m in ["top", "bottom", "start", "end"]: + getattr(box, f"set_margin_{m}")(16) if badge: bl = Gtk.Label(label=badge) - bl.add_css_class('card-badge') + bl.add_css_class("card-badge") bl.set_halign(Gtk.Align.CENTER) box.append(bl) @@ -896,19 +877,19 @@ def create_action_card(self, title, desc, icon, cb, primary=False, badge=None, c img.set_valign(Gtk.Align.CENTER) img.set_halign(Gtk.Align.CENTER) if primary: - img.add_css_class('accent') + img.add_css_class("accent") box.append(img) tl = Gtk.Label(label=title) - tl.add_css_class('title-3') + tl.add_css_class("title-3") tl.set_wrap(True) tl.set_justify(Gtk.Justification.CENTER) tl.set_hexpand(False) box.append(tl) dl = Gtk.Label(label=desc) - dl.add_css_class('caption') - dl.add_css_class('dim-label') + dl.add_css_class("caption") + dl.add_css_class("dim-label") dl.set_wrap(True) dl.set_max_width_chars(26) dl.set_justify(Gtk.Justification.CENTER) @@ -918,7 +899,7 @@ def create_action_card(self, title, desc, icon, cb, primary=False, badge=None, c # Call-to-action link inside the card (mockup: "Start hosting →"). if cta: cl = Gtk.Label(label=cta) - cl.add_css_class('caption-heading') + cl.add_css_class("caption-heading") cl.set_margin_top(4) box.append(cl) @@ -930,29 +911,31 @@ def create_action_card(self, title, desc, icon, cb, primary=False, badge=None, c # ───────────────────────────────────────────────────────────────────────── def on_nav_selected(self, lb, row): - if not row: return + if not row: + return pid = self._nav_page_by_row.get(row) - if not pid: return + if not pid: + return # Update visual style active state c = self.nav_list.get_first_child() while c: - c.remove_css_class('active-category') + c.remove_css_class("active-category") c = c.get_next_sibling() - row.add_css_class('active-category') + row.add_css_class("active-category") - if not hasattr(self, 'content_stack'): + if not hasattr(self, "content_stack"): return # If no VPN is set yet and user clicks vpn_selector, show the selector actual_pid = pid - - if pid == 'change_vpn': + + if pid == "change_vpn": self.reset_vpn_choice() return - if pid == 'vpn_selector' or (pid in ('create_private', 'connect_private') and not self._vpn_choice): - actual_pid = 'vpn_selector' + if pid == "vpn_selector" or (pid in ("create_private", "connect_private") and not self._vpn_choice): + actual_pid = "vpn_selector" if self.content_stack.get_visible_child_name() != actual_pid: self.content_stack.set_visible_child_name(actual_pid) @@ -960,15 +943,16 @@ def on_nav_selected(self, lb, row): # Server page: drop the header title, show the host action buttons there. # Every other page keeps the dynamic section title. - if hasattr(self, 'content_headerbar'): - if actual_pid == 'host' and hasattr(self.host_view, 'header_action_box'): + if hasattr(self, "content_headerbar"): + if actual_pid == "host" and hasattr(self.host_view, "header_action_box"): self.content_headerbar.set_title_widget(self.host_view.header_action_box) else: self.content_headerbar.set_title_widget(self.content_title) info = self._build_navigation_pages().get(actual_pid) or self._build_navigation_pages().get(pid) if info: - self.content_title.set_title(info.get('name', 'Big Remote Play')) - self.content_title.set_subtitle(info.get('description', '')) + self.content_title.set_title(info.get("name", "Big Remote Play")) + self.content_title.set_subtitle(info.get("description", "")) + def navigate_to(self, pid): """Programmatic navigation: find row and select it""" r = self.nav_list.get_first_child() @@ -1003,6 +987,7 @@ def finish_system_check(): return False GLib.idle_add(finish_system_check) + threading.Thread(target=check, daemon=True).start() GLib.timeout_add_seconds(3, self.p_check) @@ -1018,14 +1003,21 @@ def check(): threading.Thread(target=check, daemon=True).start() return True - def update_status(self, h_sun, h_moon): (self.show_missing_dialog() if not h_sun and not h_moon else None) + def update_status(self, h_sun, h_moon): + (self.show_missing_dialog() if not h_sun and not h_moon else None) def show_missing_dialog(self): - d = Adw.MessageDialog.new(self); d.set_heading(_('Missing Components')); d.set_body(_('Sunshine and Moonlight are required. Install now?')) - d.add_response('cancel', _('Cancel')); d.add_response('install', _('Install')); d.set_response_appearance('install', Adw.ResponseAppearance.SUGGESTED) - d.connect('response', lambda dlg, r: (InstallerWindow(parent=self, on_success=self.check_system).present() if r == 'install' else None)); d.present() - - def show_toast(self, m): (self.toast_overlay.add_toast(Adw.Toast.new(m)) if hasattr(self, 'toast_overlay') else print(m)) + d = Adw.MessageDialog.new(self) + d.set_heading(_("Missing Components")) + d.set_body(_("Sunshine and Moonlight are required. Install now?")) + d.add_response("cancel", _("Cancel")) + d.add_response("install", _("Install")) + d.set_response_appearance("install", Adw.ResponseAppearance.SUGGESTED) + d.connect("response", lambda dlg, r: InstallerWindow(parent=self, on_success=self.check_system).present() if r == "install" else None) + d.present() + + def show_toast(self, m): + (self.toast_overlay.add_toast(Adw.Toast.new(m)) if hasattr(self, "toast_overlay") else print(m)) # ───────────────────────────────────────────────────────────────────────── # SERVICE CONTROL DIALOG @@ -1034,21 +1026,28 @@ def show_toast(self, m): (self.toast_overlay.add_toast(Adw.Toast.new(m)) if hasa def on_service_clicked(self, service_id): """Open service control dialog""" meta = SERVICE_METADATA.get(service_id) - if not meta: return + if not meta: + return is_running = False is_enabled = False - if service_id == 'sunshine': is_running = self.system_check.is_sunshine_running() - elif service_id == 'moonlight': is_running = self.system_check.is_moonlight_running() - elif service_id == 'docker': is_running = self.system_check.is_docker_running() - elif service_id == 'tailscale': is_running = self.system_check.is_tailscale_running() - elif service_id == 'zerotier': is_running = self.system_check.is_zerotier_running() - - if meta['type'] == 'service': - cmd = ['systemctl'] - if meta.get('user'): cmd.append('--user') - cmd.extend(['is-enabled', '--quiet', meta['unit']]) + if service_id == "sunshine": + is_running = self.system_check.is_sunshine_running() + elif service_id == "moonlight": + is_running = self.system_check.is_moonlight_running() + elif service_id == "docker": + is_running = self.system_check.is_docker_running() + elif service_id == "tailscale": + is_running = self.system_check.is_tailscale_running() + elif service_id == "zerotier": + is_running = self.system_check.is_zerotier_running() + + if meta["type"] == "service": + cmd = ["systemctl"] + if meta.get("user"): + cmd.append("--user") + cmd.extend(["is-enabled", "--quiet", meta["unit"]]) try: is_enabled = subprocess.run(cmd, timeout=5).returncode == 0 except Exception: @@ -1056,27 +1055,31 @@ def on_service_clicked(self, service_id): dialog = Adw.Window(transient_for=self) dialog.set_modal(True) - dialog.set_title(meta['full_name']) + dialog.set_title(meta["full_name"]) dialog.set_default_size(400, -1) content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=20) - content.set_margin_top(24); content.set_margin_bottom(24); content.set_margin_start(24); content.set_margin_end(24) + content.set_margin_top(24) + content.set_margin_bottom(24) + content.set_margin_start(24) + content.set_margin_end(24) - icon = create_icon_widget('preferences-system-symbolic', size=48) + icon = create_icon_widget("preferences-system-symbolic", size=48) icon.set_halign(Gtk.Align.CENTER) content.append(icon) - title = Gtk.Label(label=meta['full_name']) - title.add_css_class('title-2') + title = Gtk.Label(label=meta["full_name"]) + title.add_css_class("title-2") content.append(title) - desc = Gtk.Label(label=meta['description']) - desc.set_wrap(True); desc.set_justify(Gtk.Justification.CENTER) - desc.add_css_class('dim-label') + desc = Gtk.Label(label=meta["description"]) + desc.set_wrap(True) + desc.set_justify(Gtk.Justification.CENTER) + desc.add_css_class("dim-label") content.append(desc) status_box = Gtk.Box(spacing=10, halign=Gtk.Align.CENTER) - dot = create_icon_widget('media-record-symbolic', size=12, css_class=['status-dot', 'status-online' if is_running else 'status-offline']) + dot = create_icon_widget("media-record-symbolic", size=12, css_class=["status-dot", "status-online" if is_running else "status-offline"]) status_box.append(dot) status_lbl = Gtk.Label(label=_("Running") if is_running else _("Stopped")) status_box.append(status_lbl) @@ -1089,49 +1092,52 @@ def run_cmd(action, sid=service_id, force_type=None): cmd = [] def find_moonlight(): - for b in ['moonlight-qt', 'moonlight']: - if shutil.which(b): return b + for b in ["moonlight-qt", "moonlight"]: + if shutil.which(b): + return b return None - current_type = force_type or m['type'] + current_type = force_type or m["type"] - if current_type == 'service': + if current_type == "service": cmd = ["bigsudo", "systemctl"] - if m.get('user'): + if m.get("user"): cmd = ["systemctl", "--user"] cmd.append(action) - cmd.append(m['unit']) - if sid == 'docker' and action == 'stop': - cmd.append('docker.socket') - elif current_type == 'containers': - if action == 'start': - cmd = ['docker', 'start', 'caddy', 'headscale'] - elif action == 'stop': - cmd = ['docker', 'stop', 'caddy', 'headscale'] - elif action == 'restart': - cmd = ['docker', 'restart', 'caddy', 'headscale'] + cmd.append(m["unit"]) + if sid == "docker" and action == "stop": + cmd.append("docker.socket") + elif current_type == "containers": + if action == "start": + cmd = ["docker", "start", "caddy", "headscale"] + elif action == "stop": + cmd = ["docker", "stop", "caddy", "headscale"] + elif action == "restart": + cmd = ["docker", "restart", "caddy", "headscale"] else: - bin_name = m['bin'] - if sid == 'moonlight': + bin_name = m["bin"] + if sid == "moonlight": found = find_moonlight() - if not found and action in ['start', 'restart']: + if not found and action in ["start", "restart"]: self.show_toast(_("Moonlight not found")) return bin_name = found or bin_name - if action == 'start': + if action == "start": cmd = [bin_name] - elif action == 'stop': + elif action == "stop": cmd = ["pkill", "-x", bin_name] - elif action == 'restart': - try: subprocess.run(["pkill", "-x", bin_name], check=False, timeout=10) - except Exception: pass + elif action == "restart": + try: + subprocess.run(["pkill", "-x", bin_name], check=False, timeout=10) + except Exception: + pass cmd = [bin_name] if cmd: try: subprocess.Popen(cmd) - name = _("Containers") if current_type == 'containers' else m['name'] + name = _("Containers") if current_type == "containers" else m["name"] self.show_toast(_("Action {} sent to {}").format(action, name)) dialog.destroy() GLib.timeout_add(1000, self.check_system) @@ -1147,12 +1153,12 @@ def find_moonlight(): btn_restart.connect("clicked", lambda b: run_cmd("restart")) actions.append(btn_restart) - if meta['type'] == 'service': + if meta["type"] == "service": btn_enable = Gtk.Button(label=_("Disable") if is_enabled else _("Enable")) btn_enable.connect("clicked", lambda b: run_cmd("disable" if is_enabled else "enable")) actions.append(btn_enable) - if service_id == 'docker': + if service_id == "docker": actions.append(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)) cont_running = self.system_check.are_containers_running() @@ -1160,13 +1166,12 @@ def find_moonlight(): cont_header = Gtk.Box(spacing=8) cont_header.set_halign(Gtk.Align.CENTER) - cont_header.append(create_icon_widget('network-wired-symbolic', size=16)) + cont_header.append(create_icon_widget("network-wired-symbolic", size=16)) cont_header.append(Gtk.Label(label=_("Private Network Containers"))) cont_box.append(cont_header) c_status_box = Gtk.Box(spacing=10, halign=Gtk.Align.CENTER) - c_dot = create_icon_widget('media-record-symbolic', size=12, - css_class=['status-dot', 'status-online' if cont_running else 'status-offline']) + c_dot = create_icon_widget("media-record-symbolic", size=12, css_class=["status-dot", "status-online" if cont_running else "status-offline"]) c_status_box.append(c_dot) c_status_lbl = Gtk.Label(label=_("Running (Caddy + Headscale)") if cont_running else _("Stopped")) c_status_box.append(c_status_lbl) @@ -1174,11 +1179,11 @@ def find_moonlight(): c_btn_main = Gtk.Button(label=_("Stop Containers") if cont_running else _("Start Containers")) c_btn_main.add_css_class("suggested-action" if not cont_running else "destructive-action") - c_btn_main.connect("clicked", lambda b: run_cmd("stop" if cont_running else "start", force_type='containers')) + c_btn_main.connect("clicked", lambda b: run_cmd("stop" if cont_running else "start", force_type="containers")) cont_box.append(c_btn_main) c_btn_restart = Gtk.Button(label=_("Restart Containers")) - c_btn_restart.connect("clicked", lambda b: run_cmd("restart", force_type='containers')) + c_btn_restart.connect("clicked", lambda b: run_cmd("restart", force_type="containers")) cont_box.append(c_btn_restart) cont_box.set_sensitive(is_running) diff --git a/tests/test_ui_premium_layout_regressions.py b/tests/test_ui_premium_layout_regressions.py index cc8bf46..cc1fd6d 100644 --- a/tests/test_ui_premium_layout_regressions.py +++ b/tests/test_ui_premium_layout_regressions.py @@ -1,5 +1,6 @@ """Static guards for the premium layout elements mirrored from the mockups.""" +import re from pathlib import Path @@ -17,6 +18,22 @@ def test_sidebar_keeps_brand_identity_and_richer_service_rows() -> None: source = Path("src/big_remote_play/ui/main_window.py").read_text() assert "_create_sidebar_identity" in source - assert "create_logo_widget('big-remote-play', 48)" in source + assert re.search(r"create_logo_widget\([\"']big-remote-play[\"'], 48\)", source) assert "service-status-row" in source assert "service-icon-frame" in source + + +def test_home_does_not_repeat_sidebar_branding() -> None: + source = Path("src/big_remote_play/ui/main_window.py").read_text() + welcome_page = source.split("def create_welcome_page", 1)[1].split( + "def create_action_card", + 1, + )[0] + stylesheet = Path("usr/share/big-remote-play/ui/style.css").read_text() + + assert "create_logo_widget('big-remote-play', 72)" not in welcome_page + assert "hero-title" not in welcome_page + assert "hero-subtitle" not in welcome_page + assert "Play cooperatively over the local network or the internet" not in welcome_page + assert ".hero-title" not in stylesheet + assert ".hero-subtitle" not in stylesheet diff --git a/usr/share/big-remote-play/ui/style.css b/usr/share/big-remote-play/ui/style.css index 4c024fd..9bf6749 100644 --- a/usr/share/big-remote-play/ui/style.css +++ b/usr/share/big-remote-play/ui/style.css @@ -273,22 +273,6 @@ entry:focus { animation-delay: 0.3s; } -/* Typography */ -.hero-title { - font-size: 32px; - font-weight: 800; - letter-spacing: 0; - color: @theme_fg_color; -} - -.hero-subtitle { - font-size: 15px; - font-weight: 400; - opacity: 0.55; - letter-spacing: 0; -} - - /* System Info Card - Refined glassmorphism with subtle shine */ .info-card { background: alpha(@card_bg_color, 0.55); From 2de320d35ab8424695229e80ed1b686b9a2df28c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Gon=C3=A7alves?= Date: Thu, 25 Jun 2026 04:11:38 -0300 Subject: [PATCH 24/47] Compact page headers --- src/big_remote_play/ui/guest_view.py | 6 +----- src/big_remote_play/ui/host_view.py | 6 +----- src/big_remote_play/ui/main_window.py | 16 +++++--------- src/big_remote_play/utils/widgets.py | 24 ++++++++++----------- tests/test_ui_premium_layout_regressions.py | 19 ++++++++++++++++ usr/share/big-remote-play/ui/style.css | 8 +++---- 6 files changed, 42 insertions(+), 37 deletions(-) diff --git a/src/big_remote_play/ui/guest_view.py b/src/big_remote_play/ui/guest_view.py index 475dcfd..8353749 100644 --- a/src/big_remote_play/ui/guest_view.py +++ b/src/big_remote_play/ui/guest_view.py @@ -58,11 +58,7 @@ def setup_ui(self): self.perf_monitor = PerformanceMonitor(); self.perf_monitor.set_visible(False) content.append(self.perf_monitor) - content.append(create_page_header( - _('Connect to Server'), - _('Connect to a host'), - 'network-workgroup-symbolic', - )) + content.append(create_page_header(_('Connect to Server'), icon_name='network-workgroup-symbolic')) self.method_stack = Adw.ViewStack() diff --git a/src/big_remote_play/ui/host_view.py b/src/big_remote_play/ui/host_view.py index 48d99ee..6d719bc 100644 --- a/src/big_remote_play/ui/host_view.py +++ b/src/big_remote_play/ui/host_view.py @@ -168,11 +168,7 @@ def setup_ui(self): self.loading_bar.set_visible(False) content.append(self.loading_bar) - content.append(create_page_header( - _('Server'), - _('Share your games'), - 'network-server-symbolic', - )) + content.append(create_page_header(_('Server'), icon_name='network-server-symbolic')) from .performance_monitor import PerformanceMonitor self.perf_monitor = PerformanceMonitor(sunshine=self.sunshine) diff --git a/src/big_remote_play/ui/main_window.py b/src/big_remote_play/ui/main_window.py index 06725d4..177c7a8 100644 --- a/src/big_remote_play/ui/main_window.py +++ b/src/big_remote_play/ui/main_window.py @@ -494,10 +494,8 @@ def setup_content(self): hb = Adw.HeaderBar() hb.pack_end(self._create_header_menu_button()) - # Dynamic title reflecting the current section (filled in on_nav_selected). self.content_headerbar = hb - self.content_title = Adw.WindowTitle.new(_("Home"), _("Play together, from anywhere")) - hb.set_title_widget(self.content_title) + hb.set_title_widget(None) ct.add_top_bar(hb) self.content_stack = Gtk.Stack() @@ -581,8 +579,8 @@ def create_vpn_selector_page(self): box.append( create_page_header( - _("Choose Your VPN Provider"), - _("Select a VPN solution to create or join a Private Network. Your choice will be saved and shown in the sidebar menu."), + _("Select VPN"), + _("Choose the private network provider for remote play."), "network-private-symbolic", ) ) @@ -942,16 +940,12 @@ def on_nav_selected(self, lb, row): self.current_page = actual_pid # Server page: drop the header title, show the host action buttons there. - # Every other page keeps the dynamic section title. + # Every other page keeps the title inside its own content header. if hasattr(self, "content_headerbar"): if actual_pid == "host" and hasattr(self.host_view, "header_action_box"): self.content_headerbar.set_title_widget(self.host_view.header_action_box) else: - self.content_headerbar.set_title_widget(self.content_title) - info = self._build_navigation_pages().get(actual_pid) or self._build_navigation_pages().get(pid) - if info: - self.content_title.set_title(info.get("name", "Big Remote Play")) - self.content_title.set_subtitle(info.get("description", "")) + self.content_headerbar.set_title_widget(None) def navigate_to(self, pid): """Programmatic navigation: find row and select it""" diff --git a/src/big_remote_play/utils/widgets.py b/src/big_remote_play/utils/widgets.py index 2da1cf1..bc57f9b 100644 --- a/src/big_remote_play/utils/widgets.py +++ b/src/big_remote_play/utils/widgets.py @@ -25,12 +25,12 @@ } -def create_page_header(title: str, subtitle: str, icon_name: str | None = None) -> Gtk.Widget: - """Large page heading used by the main content pages.""" - header = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) +def create_page_header(title: str, subtitle: str | None = None, icon_name: str | None = None) -> Gtk.Widget: + """Compact page heading used by the main content pages.""" + header = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) header.add_css_class("page-header") - title_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + title_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) title_row.set_halign(Gtk.Align.START) title_label = Gtk.Label(label=title) @@ -40,19 +40,19 @@ def create_page_header(title: str, subtitle: str, icon_name: str | None = None) title_row.append(title_label) if icon_name: - icon = create_icon_widget(icon_name, size=30) + icon = create_icon_widget(icon_name, size=24) icon.add_css_class("page-title-icon") icon.set_valign(Gtk.Align.CENTER) title_row.append(icon) - subtitle_label = Gtk.Label(label=subtitle) - subtitle_label.add_css_class("page-subtitle") - subtitle_label.set_halign(Gtk.Align.START) - subtitle_label.set_wrap(True) - subtitle_label.set_max_width_chars(88) - header.append(title_row) - header.append(subtitle_label) + if subtitle: + subtitle_label = Gtk.Label(label=subtitle) + subtitle_label.add_css_class("page-subtitle") + subtitle_label.set_halign(Gtk.Align.START) + subtitle_label.set_wrap(True) + subtitle_label.set_max_width_chars(80) + header.append(subtitle_label) return header diff --git a/tests/test_ui_premium_layout_regressions.py b/tests/test_ui_premium_layout_regressions.py index cc1fd6d..0d21409 100644 --- a/tests/test_ui_premium_layout_regressions.py +++ b/tests/test_ui_premium_layout_regressions.py @@ -37,3 +37,22 @@ def test_home_does_not_repeat_sidebar_branding() -> None: assert "Play cooperatively over the local network or the internet" not in welcome_page assert ".hero-title" not in stylesheet assert ".hero-subtitle" not in stylesheet + + +def test_content_headerbar_does_not_duplicate_page_titles() -> None: + source = Path("src/big_remote_play/ui/main_window.py").read_text() + + assert "self.content_title" not in source + assert "Adw.WindowTitle.new" not in source + assert "set_title_widget(None)" in source + + +def test_operational_pages_use_compact_headers_without_trivial_subtitles() -> None: + host_source = Path("src/big_remote_play/ui/host_view.py").read_text() + guest_source = Path("src/big_remote_play/ui/guest_view.py").read_text() + widget_source = Path("src/big_remote_play/utils/widgets.py").read_text() + + assert "subtitle: str | None = None" in widget_source + assert "create_icon_widget(icon_name, size=24)" in widget_source + assert "_('Share your games')" not in host_source + assert "_('Connect to a host')" not in guest_source diff --git a/usr/share/big-remote-play/ui/style.css b/usr/share/big-remote-play/ui/style.css index 9bf6749..735c347 100644 --- a/usr/share/big-remote-play/ui/style.css +++ b/usr/share/big-remote-play/ui/style.css @@ -144,11 +144,11 @@ entry:focus { } .page-header { - margin-bottom: 4px; + margin-bottom: 0; } .page-title { - font-size: 2.1em; + font-size: 1.72em; font-weight: 800; letter-spacing: 0; color: @theme_fg_color; @@ -159,8 +159,8 @@ entry:focus { } .page-subtitle { - font-size: 1.02em; - opacity: 0.68; + font-size: 0.96em; + opacity: 0.66; } .caption { From 6ca4afc702f1abc574f3d04dd54078aab1592f20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Gon=C3=A7alves?= Date: Thu, 25 Jun 2026 04:15:24 -0300 Subject: [PATCH 25/47] Make stack tabs explicit --- src/big_remote_play/ui/guest_view.py | 10 ++----- src/big_remote_play/ui/host_view.py | 8 ++--- .../ui/private_network_view.py | 21 +++++-------- src/big_remote_play/utils/widgets.py | 19 +++++++++++- tests/test_ui_premium_layout_regressions.py | 30 +++++++++++++++++++ usr/share/big-remote-play/ui/style.css | 14 +++++++++ 6 files changed, 74 insertions(+), 28 deletions(-) diff --git a/src/big_remote_play/ui/guest_view.py b/src/big_remote_play/ui/guest_view.py index 8353749..0607103 100644 --- a/src/big_remote_play/ui/guest_view.py +++ b/src/big_remote_play/ui/guest_view.py @@ -10,7 +10,7 @@ from big_remote_play.guest.moonlight_client import MoonlightClient from big_remote_play.utils.i18n import _ from big_remote_play.utils.icons import create_icon_widget -from big_remote_play.utils.widgets import create_helper_card, create_page_header +from big_remote_play.utils.widgets import create_helper_card, create_page_header, create_stack_tab_strip from big_remote_play.utils.moonlight_config import MoonlightConfigManager class GuestView(Gtk.Box): @@ -71,14 +71,8 @@ def setup_ui(self): page_pin = self.method_stack.add_titled(self.create_pin_page(), 'pin', _('PIN Code')) page_pin.set_icon_name('dialog-password-symbolic') - # Segmented inline switcher (mockup 01): icon + label pill, not the wide bar. - switcher = Adw.InlineViewSwitcher() - switcher.set_stack(self.method_stack) - switcher.set_display_mode(Adw.InlineViewSwitcherDisplayMode.BOTH) - switcher.set_halign(Gtk.Align.CENTER) + switcher = create_stack_tab_strip(self.method_stack, _("Connection methods")) - # Title is carried by the window header; keep only the contextual help - # action here, right-aligned, with the segmented switcher centered. help_btn = Gtk.Button(icon_name="help-about-symbolic") help_btn.add_css_class("flat") help_btn.set_tooltip_text(_("Shortcuts & Instructions")) diff --git a/src/big_remote_play/ui/host_view.py b/src/big_remote_play/ui/host_view.py index 6d719bc..3cd3c84 100644 --- a/src/big_remote_play/ui/host_view.py +++ b/src/big_remote_play/ui/host_view.py @@ -13,7 +13,7 @@ import threading from big_remote_play.utils.i18n import _ from big_remote_play.utils.icons import create_icon_widget, set_icon -from big_remote_play.utils.widgets import MetricTile, create_page_header +from big_remote_play.utils.widgets import MetricTile, create_page_header, create_stack_tab_strip from big_remote_play import paths from big_remote_play.utils.secret_store import SecretStoreUnavailable from big_remote_play.utils.sunshine_credentials import ensure_sunshine_api_config, load_sunshine_credentials, save_sunshine_credentials @@ -639,11 +639,7 @@ def _tip(icon_name, title, desc): 'revealed': True } - # Switcher setup - view_switcher = Adw.InlineViewSwitcher() - view_switcher.set_stack(self.view_stack) - view_switcher.set_display_mode(Adw.InlineViewSwitcherDisplayMode.BOTH) - view_switcher.set_halign(Gtk.Align.CENTER) + view_switcher = create_stack_tab_strip(self.view_stack, _("Server sections")) view_switcher.set_margin_top(12) view_switcher.set_margin_bottom(12) diff --git a/src/big_remote_play/ui/private_network_view.py b/src/big_remote_play/ui/private_network_view.py index 9a5e457..01ba121 100644 --- a/src/big_remote_play/ui/private_network_view.py +++ b/src/big_remote_play/ui/private_network_view.py @@ -6,7 +6,7 @@ from gi.repository import Adw, Gdk, GLib, Gtk # type: ignore from big_remote_play.utils.i18n import _ from big_remote_play.utils.icons import create_icon_widget -from big_remote_play.utils.widgets import create_helper_card, create_page_header, create_wizard_stepper +from big_remote_play.utils.widgets import create_helper_card, create_page_header, create_stack_tab_strip from big_remote_play import paths from big_remote_play.utils.secure_io import secure_write_text from big_remote_play.utils.secret_store import SecretKey, SecretStore, SecretStoreUnavailable, new_secret_id @@ -1261,6 +1261,7 @@ def __init__(self, vpn_id, main_window): def _build(self): toolbar = Adw.ToolbarView() stack = Adw.ViewStack() + stack.set_vexpand(True) # ── Tab 1: Connect ── conn_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=20) @@ -1275,9 +1276,6 @@ def _build(self): self.vpn["icon"], )) - # Wizard stepper mirroring the Connect / Status / Previous Networks flow. - conn_box.append(create_wizard_stepper([_("Connect"), _("Status"), _("Previous Networks")], 0)) - # Two columns: connection fields (left) + "what you'll need" helper (right). fields_group = Adw.PreferencesGroup() fields_group.set_title(_("Connection Details")) @@ -1417,15 +1415,12 @@ def _build(self): stack.connect("notify::visible-child-name", self._on_tab_changed) - header = Adw.HeaderBar() - header.set_show_start_title_buttons(False) - header.set_show_end_title_buttons(False) - switcher = Adw.InlineViewSwitcher() - switcher.set_stack(stack) - switcher.set_display_mode(Adw.InlineViewSwitcherDisplayMode.BOTH) - header.set_title_widget(switcher) - toolbar.add_top_bar(header) - toolbar.set_content(stack) + content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16) + content.set_margin_top(16) + content.set_vexpand(True) + content.append(create_stack_tab_strip(stack, _("Private network sections"))) + content.append(stack) + toolbar.set_content(content) self._stack = stack self.set_child(toolbar) diff --git a/src/big_remote_play/utils/widgets.py b/src/big_remote_play/utils/widgets.py index bc57f9b..8c9ea45 100644 --- a/src/big_remote_play/utils/widgets.py +++ b/src/big_remote_play/utils/widgets.py @@ -12,7 +12,8 @@ import gi gi.require_version("Gtk", "4.0") -from gi.repository import Gtk # type: ignore # noqa: E402 +gi.require_version("Adw", "1") +from gi.repository import Adw, Gtk # type: ignore # noqa: E402 from big_remote_play.utils.icons import create_icon_widget # noqa: E402 from big_remote_play.utils.i18n import _ # noqa: E402 @@ -56,6 +57,22 @@ def create_page_header(title: str, subtitle: str | None = None, icon_name: str | return header +def create_stack_tab_strip(stack: Adw.ViewStack, accessible_label: str) -> Gtk.Widget: + """Framed view switcher that reads visually as tabs and stays AT-SPI actionable.""" + switcher = Adw.InlineViewSwitcher() + switcher.set_stack(stack) + switcher.set_display_mode(Adw.InlineViewSwitcherDisplayMode.BOTH) + switcher.add_css_class("round") + switcher.update_property([Gtk.AccessibleProperty.LABEL], [accessible_label]) + + strip = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + strip.add_css_class("tab-strip") + strip.set_halign(Gtk.Align.CENTER) + strip.update_property([Gtk.AccessibleProperty.LABEL], [accessible_label]) + strip.append(switcher) + return strip + + def create_steps_strip(steps: list[tuple[str, str, str]]) -> Gtk.Widget: """ "How it works" strip: numbered steps with icon + title + description. diff --git a/tests/test_ui_premium_layout_regressions.py b/tests/test_ui_premium_layout_regressions.py index 0d21409..919a6b7 100644 --- a/tests/test_ui_premium_layout_regressions.py +++ b/tests/test_ui_premium_layout_regressions.py @@ -56,3 +56,33 @@ def test_operational_pages_use_compact_headers_without_trivial_subtitles() -> No assert "create_icon_widget(icon_name, size=24)" in widget_source assert "_('Share your games')" not in host_source assert "_('Connect to a host')" not in guest_source + + +def test_stack_tabs_use_shared_accessible_tab_strip() -> None: + widget_source = Path("src/big_remote_play/utils/widgets.py").read_text() + stylesheet = Path("usr/share/big-remote-play/ui/style.css").read_text() + + assert "def create_stack_tab_strip(" in widget_source + assert "Adw.InlineViewSwitcher()" in widget_source + assert 'switcher.add_css_class("round")' in widget_source + assert "Gtk.AccessibleProperty.LABEL" in widget_source + assert ".tab-strip" in stylesheet + assert ".tab-strip:focus-within" in stylesheet + + for path in [ + Path("src/big_remote_play/ui/host_view.py"), + Path("src/big_remote_play/ui/guest_view.py"), + Path("src/big_remote_play/ui/private_network_view.py"), + ]: + source = path.read_text() + assert "create_stack_tab_strip(" in source + assert "Adw.InlineViewSwitcher()" not in source + + +def test_private_network_tabs_do_not_duplicate_wizard_stepper_or_headerbar() -> None: + source = Path("src/big_remote_play/ui/private_network_view.py").read_text() + connect_build = source.split("def _build(self):", 2)[2].split("def _on_instructions_clicked", 1)[0] + + assert "create_wizard_stepper" not in connect_build + assert "toolbar.add_top_bar(header)" not in connect_build + assert "create_stack_tab_strip(stack" in connect_build diff --git a/usr/share/big-remote-play/ui/style.css b/usr/share/big-remote-play/ui/style.css index 735c347..f0c663d 100644 --- a/usr/share/big-remote-play/ui/style.css +++ b/usr/share/big-remote-play/ui/style.css @@ -536,6 +536,20 @@ levelbar block.filled { min-height: 2px; } +/* Explicit stack tabs */ +.tab-strip { + padding: 4px; + border-radius: 10px; + background: alpha(@card_bg_color, 0.72); + border: 1px solid alpha(@borders, 0.42); + box-shadow: 0 2px 8px alpha(black, 0.04); +} + +.tab-strip:focus-within { + border-color: alpha(@accent_bg_color, 0.72); + box-shadow: 0 0 0 2px alpha(@accent_bg_color, 0.16); +} + /* Side helper card ("what you'll need" / "if you can't find it") */ .helper-card { background: alpha(@accent_bg_color, 0.05); From eac4033f16d5935b894a4d73d709dbfca7841e0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Gon=C3=A7alves?= Date: Thu, 25 Jun 2026 04:17:58 -0300 Subject: [PATCH 26/47] Format touched UI modules --- src/big_remote_play/ui/guest_view.py | 1133 ++++++----- src/big_remote_play/ui/host_view.py | 1671 +++++++++-------- .../ui/private_network_view.py | 24 +- tests/test_guest_view_a11y_regressions.py | 12 +- 4 files changed, 1562 insertions(+), 1278 deletions(-) diff --git a/src/big_remote_play/ui/guest_view.py b/src/big_remote_play/ui/guest_view.py index 0607103..aed6485 100644 --- a/src/big_remote_play/ui/guest_view.py +++ b/src/big_remote_play/ui/guest_view.py @@ -1,8 +1,9 @@ from __future__ import annotations import gi -gi.require_version('Gtk', '4.0') -gi.require_version('Adw', '1') + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") from gi.repository import Gtk, Adw, GLib, Gdk # type: ignore import threading, time @@ -13,22 +14,24 @@ from big_remote_play.utils.widgets import create_helper_card, create_page_header, create_stack_tab_strip from big_remote_play.utils.moonlight_config import MoonlightConfigManager + class GuestView(Gtk.Box): def __init__(self): super().__init__(orientation=Gtk.Orientation.VERTICAL) - + self.discovered_hosts = [] self.is_connected = False self.pin_dialog = None - + from big_remote_play.utils.logger import Logger + self.config = Config() self.logger = None - if self.config.get('verbose_logging', False): + if self.config.get("verbose_logging", False): self.logger = Logger() - + self.logger = Logger() - + self.moonlight_config = MoonlightConfigManager() self.moonlight = MoonlightClient(logger=self.logger) self.setup_ui() @@ -38,38 +41,46 @@ def __init__(self): def _root_window(self): root = self.get_root() return root if isinstance(root, Gtk.Window) else None - + def detect_bitrate(self, button=None): self.show_toast(_("Detecting bandwidth...")) + def run_detect(): import time, random + time.sleep(1.5) val = random.randint(15, 80) GLib.idle_add(lambda: self.bitrate_scale.set_value(val)) GLib.idle_add(lambda: self.show_toast(_("Suggested bitrate: {} Mbps").format(val))) + threading.Thread(target=run_detect, daemon=True).start() - + def setup_ui(self): - clamp = Adw.Clamp(); clamp.set_maximum_size(1040); clamp.set_valign(Gtk.Align.START) - for m in ['top', 'bottom', 'start', 'end']: getattr(clamp, f'set_margin_{m}')(24) + clamp = Adw.Clamp() + clamp.set_maximum_size(1040) + clamp.set_valign(Gtk.Align.START) + for m in ["top", "bottom", "start", "end"]: + getattr(clamp, f"set_margin_{m}")(24) content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=18) from .performance_monitor import PerformanceMonitor - self.perf_monitor = PerformanceMonitor(); self.perf_monitor.set_visible(False) + + self.perf_monitor = PerformanceMonitor() + self.perf_monitor.set_visible(False) content.append(self.perf_monitor) - content.append(create_page_header(_('Connect to Server'), icon_name='network-workgroup-symbolic')) + content.append(create_page_header(_("Connect to Server"), icon_name="network-workgroup-symbolic")) self.method_stack = Adw.ViewStack() - page_dis = self.method_stack.add_titled(self.create_discover_page(), 'discover', _('Discover')) - page_dis.set_icon_name('system-search-symbolic') + page_dis = self.method_stack.add_titled(self.create_discover_page(), "discover", _("Discover")) + page_dis.set_icon_name("system-search-symbolic") - page_man = self.method_stack.add_titled(self.create_manual_page(), 'manual', _('Manual')) - page_man.set_icon_name('network-wired-symbolic') + page_man = self.method_stack.add_titled(self.create_manual_page(), "manual", _("Manual")) + page_man.set_icon_name("network-wired-symbolic") - page_pin = self.method_stack.add_titled(self.create_pin_page(), 'pin', _('PIN Code')) - page_pin.set_icon_name('dialog-password-symbolic') + page_pin = self.method_stack.add_titled(self.create_pin_page(), "pin", _("PIN Code")) + page_pin.set_icon_name("dialog-password-symbolic") switcher = create_stack_tab_strip(self.method_stack, _("Connection methods")) @@ -86,60 +97,97 @@ def setup_ui(self): self.switcher_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) self.switcher_box.append(switcher_bar) self.switcher_box.append(self.method_stack) - - settings_group = Adw.PreferencesGroup(); settings_group.set_title(_('Client Settings')); settings_group.set_margin_top(12) - reset_btn = Gtk.Button(icon_name="edit-undo-symbolic"); reset_btn.add_css_class("flat"); reset_btn.set_tooltip_text(_("Reset to Defaults")) - reset_btn.connect("clicked", self.on_reset_clicked); settings_group.set_header_suffix(reset_btn) - self.resolution_row = Adw.ComboRow(); self.resolution_row.set_title(_('Resolution')); self.resolution_row.set_subtitle(_('Stream resolution')) + + settings_group = Adw.PreferencesGroup() + settings_group.set_title(_("Client Settings")) + settings_group.set_margin_top(12) + reset_btn = Gtk.Button(icon_name="edit-undo-symbolic") + reset_btn.add_css_class("flat") + reset_btn.set_tooltip_text(_("Reset to Defaults")) + reset_btn.connect("clicked", self.on_reset_clicked) + settings_group.set_header_suffix(reset_btn) + self.resolution_row = Adw.ComboRow() + self.resolution_row.set_title(_("Resolution")) + self.resolution_row.set_subtitle(_("Stream resolution")) res_model = Gtk.StringList() - for r in ['720p', '1080p', '1440p', '4K', _('Custom')]: res_model.append(r) - self.resolution_row.set_model(res_model); self.resolution_row.set_selected(1); settings_group.add(self.resolution_row) - + for r in ["720p", "1080p", "1440p", "4K", _("Custom")]: + res_model.append(r) + self.resolution_row.set_model(res_model) + self.resolution_row.set_selected(1) + settings_group.add(self.resolution_row) + self.scale_row = Adw.SwitchRow() - self.scale_row.set_title(_('Native Resolution (Adaptive)')); self.scale_row.set_subtitle(_('Use screen/window resolution')) - self.scale_row.set_active(False); self.scale_row.connect("notify::active", self.on_scale_changed) + self.scale_row.set_title(_("Native Resolution (Adaptive)")) + self.scale_row.set_subtitle(_("Use screen/window resolution")) + self.scale_row.set_active(False) + self.scale_row.connect("notify::active", self.on_scale_changed) settings_group.add(self.scale_row) - + self.fps_row = Adw.ComboRow() - self.fps_row.set_title(_('Frame Rate (FPS)')); self.fps_row.set_subtitle(_('Video smoothness')) + self.fps_row.set_title(_("Frame Rate (FPS)")) + self.fps_row.set_subtitle(_("Video smoothness")) fps_model = Gtk.StringList() - for f in ['30 FPS', '60 FPS', '120 FPS', _('Custom')]: fps_model.append(f) - self.fps_row.set_model(fps_model); self.fps_row.set_selected(1) + for f in ["30 FPS", "60 FPS", "120 FPS", _("Custom")]: + fps_model.append(f) + self.fps_row.set_model(fps_model) + self.fps_row.set_selected(1) settings_group.add(self.fps_row) - + # Connect signals for Custom handling self.custom_resolution_val = None self.custom_fps_val = None - + self.resolution_row.connect("notify::selected-item", self.on_resolution_changed) self.fps_row.connect("notify::selected-item", self.on_fps_changed) - - self.apply_settings_btn = Gtk.Button(label=_('Apply and Reconnect')) - self.apply_settings_btn.add_css_class('suggested-action'); self.apply_settings_btn.add_css_class('pill') - self.apply_settings_btn.set_size_request(-1, 50); self.apply_settings_btn.set_margin_top(24) - self.apply_settings_btn.set_visible(False); self.apply_settings_btn.connect('clicked', lambda b: self.check_reconnect()) + + self.apply_settings_btn = Gtk.Button(label=_("Apply and Reconnect")) + self.apply_settings_btn.add_css_class("suggested-action") + self.apply_settings_btn.add_css_class("pill") + self.apply_settings_btn.set_size_request(-1, 50) + self.apply_settings_btn.set_margin_top(24) + self.apply_settings_btn.set_visible(False) + self.apply_settings_btn.connect("clicked", lambda b: self.check_reconnect()) settings_group.add(self.apply_settings_btn) - - bitrate_row = Adw.ActionRow(); bitrate_row.set_title(_("Bitrate (Quality)")); bitrate_row.set_subtitle(_("Adjust bandwidth (0.5 - 150 Mbps)")) + + bitrate_row = Adw.ActionRow() + bitrate_row.set_title(_("Bitrate (Quality)")) + bitrate_row.set_subtitle(_("Adjust bandwidth (0.5 - 150 Mbps)")) bitrate_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) self.bitrate_scale = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, 0.5, 150.0, 0.5) - self.bitrate_scale.set_hexpand(True); self.bitrate_scale.set_value(20.0); self.bitrate_scale.set_draw_value(True) - detect_btn = Gtk.Button(label=_("Detect")); detect_btn.add_css_class("flat"); detect_btn.connect("clicked", self.detect_bitrate) - bitrate_box.append(self.bitrate_scale); bitrate_box.append(detect_btn) - bitrate_row.add_suffix(bitrate_box); settings_group.add(bitrate_row) - - self.display_mode_row = Adw.ComboRow(); self.display_mode_row.set_title(_('Display Mode')); self.display_mode_row.set_subtitle(_('How the window will be displayed')) + self.bitrate_scale.set_hexpand(True) + self.bitrate_scale.set_value(20.0) + self.bitrate_scale.set_draw_value(True) + detect_btn = Gtk.Button(label=_("Detect")) + detect_btn.add_css_class("flat") + detect_btn.connect("clicked", self.detect_bitrate) + bitrate_box.append(self.bitrate_scale) + bitrate_box.append(detect_btn) + bitrate_row.add_suffix(bitrate_box) + settings_group.add(bitrate_row) + + self.display_mode_row = Adw.ComboRow() + self.display_mode_row.set_title(_("Display Mode")) + self.display_mode_row.set_subtitle(_("How the window will be displayed")) disp_model = Gtk.StringList() - for d in [_('Borderless Window'), _('Fullscreen'), _('Windowed')]: disp_model.append(d) - self.display_mode_row.set_model(disp_model); self.display_mode_row.set_selected(0); settings_group.add(self.display_mode_row) - - self.audio_row = Adw.SwitchRow(); self.audio_row.set_title(_('Audio')); self.audio_row.set_subtitle(_('Receive audio streaming')) - self.audio_row.set_active(True); settings_group.add(self.audio_row) - self.hw_decode_row = Adw.SwitchRow(); self.hw_decode_row.set_title(_('Hardware Decoding')); self.hw_decode_row.set_subtitle(_('Use GPU for decoding')) - self.hw_decode_row.set_active(True); settings_group.add(self.hw_decode_row) - - advanced_title = _('Advanced client settings') - advanced_subtitle = _('Input, controller, codec and more') + for d in [_("Borderless Window"), _("Fullscreen"), _("Windowed")]: + disp_model.append(d) + self.display_mode_row.set_model(disp_model) + self.display_mode_row.set_selected(0) + settings_group.add(self.display_mode_row) + + self.audio_row = Adw.SwitchRow() + self.audio_row.set_title(_("Audio")) + self.audio_row.set_subtitle(_("Receive audio streaming")) + self.audio_row.set_active(True) + settings_group.add(self.audio_row) + self.hw_decode_row = Adw.SwitchRow() + self.hw_decode_row.set_title(_("Hardware Decoding")) + self.hw_decode_row.set_subtitle(_("Use GPU for decoding")) + self.hw_decode_row.set_active(True) + settings_group.add(self.hw_decode_row) + + advanced_title = _("Advanced client settings") + advanced_subtitle = _("Input, controller, codec and more") advanced_button = Gtk.Button() advanced_button.add_css_class("flat") advanced_button.add_css_class("settings-action-row-button") @@ -156,7 +204,7 @@ def setup_ui(self): advanced_box.set_margin_bottom(10) advanced_box.set_margin_start(14) advanced_box.set_margin_end(14) - advanced_icon = create_icon_widget('preferences-system-symbolic', size=20) + advanced_icon = create_icon_widget("preferences-system-symbolic", size=20) advanced_icon.set_valign(Gtk.Align.CENTER) advanced_box.append(advanced_icon) @@ -175,33 +223,40 @@ def setup_ui(self): advanced_text.append(advanced_subtitle_label) advanced_box.append(advanced_text) - advanced_arrow = create_icon_widget('go-next-symbolic', size=16) + advanced_arrow = create_icon_widget("go-next-symbolic", size=16) advanced_arrow.set_valign(Gtk.Align.CENTER) advanced_box.append(advanced_arrow) advanced_button.set_child(advanced_box) settings_group.add(advanced_button) - content.append(self.switcher_box); content.append(settings_group) - self.load_guest_settings(); self.connect_settings_signals(); clamp.set_child(content) - scroll = Gtk.ScrolledWindow(); scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC); scroll.set_vexpand(True); scroll.set_child(clamp); self.append(scroll) - + content.append(self.switcher_box) + content.append(settings_group) + self.load_guest_settings() + self.connect_settings_signals() + clamp.set_child(content) + scroll = Gtk.ScrolledWindow() + scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + scroll.set_vexpand(True) + scroll.set_child(clamp) + self.append(scroll) + # create_status_card removed def monitor_connection(self): """Monitors Moonlight connection state""" - if hasattr(self, 'moonlight'): + if hasattr(self, "moonlight"): is_running = self.moonlight.is_connected() - + if is_running: if not self.is_connected: - # Update state if it was disconnected - self.is_connected = True - host_name = self.moonlight.connected_host if self.moonlight.connected_host else "Host" - self.perf_monitor.set_connection_status(host_name, _("Active Session"), True) - - self.perf_monitor.set_visible(True) - self.perf_monitor.start_monitoring() + # Update state if it was disconnected + self.is_connected = True + host_name = self.moonlight.connected_host if self.moonlight.connected_host else "Host" + self.perf_monitor.set_connection_status(host_name, _("Active Session"), True) + + self.perf_monitor.set_visible(True) + self.perf_monitor.start_monitoring() else: if self.is_connected: @@ -210,126 +265,157 @@ def monitor_connection(self): self.perf_monitor.set_connection_status("None", _("Disconnected"), False) self.perf_monitor.stop_monitoring() self.perf_monitor.set_visible(False) - + self.show_toast(_("Moonlight closed")) - + # Update UI visibility self.update_ui_state() - return True # Continue polling + return True # Continue polling def update_ui_state(self): c = self.is_connected # switcher_box must remain visible so the "Stop" button is accessible - if hasattr(self, 'switcher_box'): self.switcher_box.set_visible(True) - if hasattr(self, 'apply_settings_btn'): self.apply_settings_btn.set_visible(c) + if hasattr(self, "switcher_box"): + self.switcher_box.set_visible(True) + if hasattr(self, "apply_settings_btn"): + self.apply_settings_btn.set_visible(c) # Update connection buttons state self._update_all_buttons_state() - + def _update_all_buttons_state(self): - is_connecting = getattr(self, 'is_connecting', False) + is_connecting = getattr(self, "is_connecting", False) connected = self.is_connected - + # Helper to update a button def update_btn(btn, label_widget, spinner, default_text, default_sensitive=True): if hasattr(self, btn): b = getattr(self, btn) - if hasattr(self, label_widget): l = getattr(self, label_widget) - if hasattr(self, spinner): s = getattr(self, spinner) - + if hasattr(self, label_widget): + l = getattr(self, label_widget) + if hasattr(self, spinner): + s = getattr(self, spinner) + if connected: # State: Connected -> Button becomes Stop b.set_sensitive(True) - b.remove_css_class('suggested-action') - b.add_css_class('destructive-action') + b.remove_css_class("suggested-action") + b.add_css_class("destructive-action") l.set_label(_("Stop")) - s.set_visible(False); s.stop() - + s.set_visible(False) + s.stop() + elif is_connecting: # State: Connecting -> Button becomes Stop (Cancel) - b.set_sensitive(True) - b.remove_css_class('suggested-action') - b.add_css_class('destructive-action') + b.set_sensitive(True) + b.remove_css_class("suggested-action") + b.add_css_class("destructive-action") l.set_label(_("Stop")) - s.set_visible(True); s.start() - - else: # Disconnected + s.set_visible(True) + s.start() + + else: # Disconnected b.set_sensitive(default_sensitive) - b.remove_css_class('destructive-action') - b.add_css_class('suggested-action') + b.remove_css_class("destructive-action") + b.add_css_class("suggested-action") l.set_label(default_text) - s.set_visible(False); s.stop() + s.set_visible(False) + s.stop() # Update Discover Button # Logic specific for discover: only sensitive if host selected (when disconnected) selected_host = self.selected_host_card_data has_host = selected_host is not None - update_btn('main_connect_btn', 'connect_btn_label', 'connect_btn_spinner', - _("Connect to {}").format(selected_host['name']) if selected_host is not None else _("Connect to Selected"), - default_sensitive=has_host) + update_btn( + "main_connect_btn", + "connect_btn_label", + "connect_btn_spinner", + _("Connect to {}").format(selected_host["name"]) if selected_host is not None else _("Connect to Selected"), + default_sensitive=has_host, + ) # Update Manual Button - update_btn('manual_connect_btn', 'manual_btn_label', 'manual_btn_spinner', _("Connect")) + update_btn("manual_connect_btn", "manual_btn_label", "manual_btn_spinner", _("Connect")) # Update PIN Button - update_btn('pin_connect_btn', 'pin_btn_label', 'pin_btn_spinner', _("Connect with PIN")) - + update_btn("pin_connect_btn", "pin_btn_label", "pin_btn_spinner", _("Connect with PIN")) + def check_reconnect(self): - if self.is_connected and hasattr(self, 'current_host_ctx'): + if self.is_connected and hasattr(self, "current_host_ctx"): self.show_toast(_("Applying settings...")) ctx = self.current_host_ctx - if self.is_connected: self.moonlight.disconnect() - if ctx['type'] == 'auto': self.connect_to_host(ctx['host']) - elif ctx['type'] == 'manual': self.connect_manual(ctx['ip'], str(ctx['port']), ctx['ipv6']) - + if self.is_connected: + self.moonlight.disconnect() + if ctx["type"] == "auto": + self.connect_to_host(ctx["host"]) + elif ctx["type"] == "manual": + self.connect_manual(ctx["ip"], str(ctx["port"]), ctx["ipv6"]) + def check_reconnect_debounced(self): """Checks if reconnection is needed (with debounce)""" # Cancel previous - if hasattr(self, '_reconnect_timer') and self._reconnect_timer: + if hasattr(self, "_reconnect_timer") and self._reconnect_timer: GLib.source_remove(self._reconnect_timer) - + self._reconnect_timer = GLib.timeout_add(1000, self._do_reconnect_timer) - + def _do_reconnect_timer(self): self._reconnect_timer = None self.check_reconnect() return False - + def create_discover_page(self): self.selected_host_card_data = self.first_radio_in_list = None box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) - for m in ['top', 'bottom', 'start', 'end']: getattr(header, f'set_margin_{m}')(12) - lbl = Gtk.Label(label=_("Discovered Hosts")); lbl.add_css_class("heading"); lbl.set_halign(Gtk.Align.START) - desc = Gtk.Label(label=_("Scroll to list all found devices.")); desc.add_css_class("dim-label"); desc.set_halign(Gtk.Align.START) - + for m in ["top", "bottom", "start", "end"]: + getattr(header, f"set_margin_{m}")(12) + lbl = Gtk.Label(label=_("Discovered Hosts")) + lbl.add_css_class("heading") + lbl.set_halign(Gtk.Align.START) + desc = Gtk.Label(label=_("Scroll to list all found devices.")) + desc.add_css_class("dim-label") + desc.set_halign(Gtk.Align.START) + text_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) text_box.set_hexpand(True) text_box.append(lbl) text_box.append(desc) - - refresh = Gtk.Button(icon_name='view-refresh-symbolic'); refresh.connect('clicked', lambda b: self.discover_hosts()) - header.append(text_box); header.append(refresh) - self.hosts_list = Gtk.ListBox(); self.hosts_list.add_css_class('boxed-list'); self.hosts_list.set_selection_mode(Gtk.SelectionMode.NONE) - for m in ['start', 'end']: getattr(self.hosts_list, f'set_margin_{m}')(12) + + refresh = Gtk.Button(icon_name="view-refresh-symbolic") + refresh.connect("clicked", lambda b: self.discover_hosts()) + header.append(text_box) + header.append(refresh) + self.hosts_list = Gtk.ListBox() + self.hosts_list.add_css_class("boxed-list") + self.hosts_list.set_selection_mode(Gtk.SelectionMode.NONE) + for m in ["start", "end"]: + getattr(self.hosts_list, f"set_margin_{m}")(12) action = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) - for m in ['top', 'bottom', 'start', 'end']: getattr(action, f'set_margin_{m}')(12) - + for m in ["top", "bottom", "start", "end"]: + getattr(action, f"set_margin_{m}")(12) + buttons_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) buttons_box.set_halign(Gtk.Align.CENTER) - - self.main_connect_btn = Gtk.Button(); self.main_connect_btn.add_css_class('suggested-action') - self.main_connect_btn.add_css_class('pill'); self.main_connect_btn.set_size_request(250, 50); self.main_connect_btn.set_sensitive(False) - - btn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12); btn_box.set_halign(Gtk.Align.CENTER) - self.connect_btn_spinner = Gtk.Spinner(); self.connect_btn_spinner.set_visible(False) - self.connect_btn_label = Gtk.Label(label=_('Connect to Selected')) - btn_box.append(self.connect_btn_spinner); btn_box.append(self.connect_btn_label) + + self.main_connect_btn = Gtk.Button() + self.main_connect_btn.add_css_class("suggested-action") + self.main_connect_btn.add_css_class("pill") + self.main_connect_btn.set_size_request(250, 50) + self.main_connect_btn.set_sensitive(False) + + btn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + btn_box.set_halign(Gtk.Align.CENTER) + self.connect_btn_spinner = Gtk.Spinner() + self.connect_btn_spinner.set_visible(False) + self.connect_btn_label = Gtk.Label(label=_("Connect to Selected")) + btn_box.append(self.connect_btn_spinner) + btn_box.append(self.connect_btn_label) self.main_connect_btn.set_child(btn_box) - - self.main_connect_btn.connect('clicked', lambda b: self.on_main_button_clicked('discover')) - + + self.main_connect_btn.connect("clicked", lambda b: self.on_main_button_clicked("discover")) + buttons_box.append(self.main_connect_btn) host_scroll = Gtk.ScrolledWindow() @@ -340,20 +426,20 @@ def create_discover_page(self): host_scroll.set_propagate_natural_height(True) host_scroll.set_child(self.hosts_list) - action.append(buttons_box); box.append(header); box.append(host_scroll); box.append(action) + action.append(buttons_box) + box.append(header) + box.append(host_scroll) + box.append(action) box.set_hexpand(True) # Side helper card: what to try if the host doesn't show up (mockup 01). helper = create_helper_card( _("If you can't find the host"), - 'dialog-question-symbolic', + "dialog-question-symbolic", [ - ('network-wireless-symbolic', _('Check your local network'), - _('Make sure the host and this device are on the same Wi-Fi or wired network.')), - ('network-wired-symbolic', _('Use the manual connection'), - _('Enter the host IP address or hostname and port to connect directly.')), - ('dialog-password-symbolic', _('Use the PIN code'), - _('Ask the host for the PIN code and connect quickly and securely.')), + ("network-wireless-symbolic", _("Check your local network"), _("Make sure the host and this device are on the same Wi-Fi or wired network.")), + ("network-wired-symbolic", _("Use the manual connection"), _("Enter the host IP address or hostname and port to connect directly.")), + ("dialog-password-symbolic", _("Use the PIN code"), _("Ask the host for the PIN code and connect quickly and securely.")), ], ) helper.set_valign(Gtk.Align.START) @@ -366,31 +452,54 @@ def create_discover_page(self): def discover_hosts(self): from big_remote_play.utils.network import NetworkDiscovery + self.first_radio_in_list = self.selected_host_card_data = None self._update_all_buttons_state() - while row := self.hosts_list.get_row_at_index(0): self.hosts_list.remove(row) - self.loading_row = Gtk.ListBoxRow(); self.loading_row.set_selectable(False) - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6); box.set_halign(Gtk.Align.CENTER); box.set_valign(Gtk.Align.CENTER) + while row := self.hosts_list.get_row_at_index(0): + self.hosts_list.remove(row) + self.loading_row = Gtk.ListBoxRow() + self.loading_row.set_selectable(False) + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + box.set_halign(Gtk.Align.CENTER) + box.set_valign(Gtk.Align.CENTER) box.set_size_request(-1, 150) - for m in ['top', 'bottom']: getattr(box, f'set_margin_{m}')(24) - spinner = Gtk.Spinner(); spinner.set_size_request(48, 48); spinner.start() - lbl = Gtk.Label(label=_('Searching for hosts...')); lbl.add_css_class('title-2') - box.append(spinner); box.append(lbl) - self.loading_row.set_child(box); self.hosts_list.append(self.loading_row) + for m in ["top", "bottom"]: + getattr(box, f"set_margin_{m}")(24) + spinner = Gtk.Spinner() + spinner.set_size_request(48, 48) + spinner.start() + lbl = Gtk.Label(label=_("Searching for hosts...")) + lbl.add_css_class("title-2") + box.append(spinner) + box.append(lbl) + self.loading_row.set_child(box) + self.hosts_list.append(self.loading_row) + def on_hosts_discovered(hosts): - if self.loading_row.get_parent(): self.hosts_list.remove(self.loading_row) + if self.loading_row.get_parent(): + self.hosts_list.remove(self.loading_row) self.first_radio_in_list = None if not hosts: - row = Gtk.ListBoxRow(); row.set_selectable(False) - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6); box.set_halign(Gtk.Align.CENTER); box.set_valign(Gtk.Align.CENTER) - box.set_size_request(-1, 150) # Match host_scroll min height - for m in ['top', 'bottom']: getattr(box, f'set_margin_{m}')(24) - icon = create_icon_widget('network-offline-symbolic', size=48, css_class='dim-label') - lbl = Gtk.Label(label=_('No hosts found')); lbl.add_css_class('title-2') - box.append(icon); box.append(lbl); row.set_child(box); self.hosts_list.append(row) + row = Gtk.ListBoxRow() + row.set_selectable(False) + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + box.set_halign(Gtk.Align.CENTER) + box.set_valign(Gtk.Align.CENTER) + box.set_size_request(-1, 150) # Match host_scroll min height + for m in ["top", "bottom"]: + getattr(box, f"set_margin_{m}")(24) + icon = create_icon_widget("network-offline-symbolic", size=48, css_class="dim-label") + lbl = Gtk.Label(label=_("No hosts found")) + lbl.add_css_class("title-2") + box.append(icon) + box.append(lbl) + row.set_child(box) + self.hosts_list.append(row) else: - for h in hosts: self.hosts_list.append(self.create_host_row_custom(h)) + for h in hosts: + self.hosts_list.append(self.create_host_row_custom(h)) return False + NetworkDiscovery().discover_hosts(callback=on_hosts_discovered) def update_hosts_list(self, hosts): @@ -399,164 +508,190 @@ def update_hosts_list(self, hosts): self.selected_host_card_data = None self.selected_host_card_data = None self._update_all_buttons_state() - + while True: row = self.hosts_list.get_row_at_index(0) if row is None: break self.hosts_list.remove(row) - + for host in hosts: self.hosts_list.append(self.create_host_row_custom(host)) def create_host_row_custom(self, host): - row = Gtk.ListBoxRow(); row.set_activatable(False) + row = Gtk.ListBoxRow() + row.set_activatable(False) box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) - for m in ['start', 'end', 'top', 'bottom']: getattr(box, f'set_margin_{m}')(12) - radio = Gtk.CheckButton(); radio.set_valign(Gtk.Align.CENTER) - if self.first_radio_in_list is None: self.first_radio_in_list = radio - else: radio.set_group(self.first_radio_in_list) + for m in ["start", "end", "top", "bottom"]: + getattr(box, f"set_margin_{m}")(12) + radio = Gtk.CheckButton() + radio.set_valign(Gtk.Align.CENTER) + if self.first_radio_in_list is None: + self.first_radio_in_list = radio + else: + radio.set_group(self.first_radio_in_list) + def on_toggled(btn): if btn.get_active(): self.selected_host_card_data = host self._update_all_buttons_state() - radio.connect('toggled', on_toggled) - icon = create_icon_widget('computer-symbolic', size=32) - info = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2); info.set_valign(Gtk.Align.CENTER) - n = Gtk.Label(label=host['name']); n.set_halign(Gtk.Align.START); n.add_css_class('heading') - i = Gtk.Label(label=host['ip']); i.set_halign(Gtk.Align.START); i.add_css_class('dim-label') - info.append(n); info.append(i); box.append(radio); box.append(icon); box.append(info) - - spacer = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL); spacer.set_hexpand(True) + + radio.connect("toggled", on_toggled) + icon = create_icon_widget("computer-symbolic", size=32) + info = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + info.set_valign(Gtk.Align.CENTER) + n = Gtk.Label(label=host["name"]) + n.set_halign(Gtk.Align.START) + n.add_css_class("heading") + i = Gtk.Label(label=host["ip"]) + i.set_halign(Gtk.Align.START) + i.add_css_class("dim-label") + info.append(n) + info.append(i) + box.append(radio) + box.append(icon) + box.append(info) + + spacer = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + spacer.set_hexpand(True) box.append(spacer) - + copy_btn = Gtk.Button() copy_btn.set_child(create_icon_widget("edit-copy-symbolic", size=16)) - copy_btn.add_css_class("flat"); copy_btn.set_valign(Gtk.Align.CENTER); copy_btn.set_tooltip_text(_("Copy IP")) + copy_btn.add_css_class("flat") + copy_btn.set_valign(Gtk.Align.CENTER) + copy_btn.set_tooltip_text(_("Copy IP")) + def copy_ip(btn): display = Gdk.Display.get_default() if display is not None: - display.get_clipboard().set(host['ip']) - self.show_toast(_("IP Copied: {}").format(host['ip'])) + display.get_clipboard().set(host["ip"]) + self.show_toast(_("IP Copied: {}").format(host["ip"])) + copy_btn.connect("clicked", copy_ip) box.append(copy_btn) - + row.set_child(box) - gesture = Gtk.GestureClick(); gesture.connect("pressed", lambda g, n, x, y: radio.set_active(True)); row.add_controller(gesture) + gesture = Gtk.GestureClick() + gesture.connect("pressed", lambda g, n, x, y: radio.set_active(True)) + row.add_controller(gesture) return row def create_manual_page(self): box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=24) - for m in ['top', 'bottom', 'start', 'end']: getattr(box, f'set_margin_{m}')(24) - + for m in ["top", "bottom", "start", "end"]: + getattr(box, f"set_margin_{m}")(24) + grp = Adw.PreferencesGroup() grp.set_title(_("Connection Data")) grp.set_description(_("Enter server IP and port")) - + ip = Adw.EntryRow() - ip.set_title(_('IP/Hostname')) - ip.set_text('192.168.') - + ip.set_title(_("IP/Hostname")) + ip.set_text("192.168.") + port = Adw.EntryRow() - port.set_title(_('Port')) - port.set_text('47989') - + port.set_title(_("Port")) + port.set_text("47989") + ipv6 = Adw.SwitchRow() ipv6.set_title(_("Use IPv6")) - + self.manual_ip_entry = ip self.manual_port_entry = port self.manual_ipv6_switch = ipv6 - + grp.add(ip) grp.add(port) grp.add(ipv6) - + box.append(grp) - + buttons_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) buttons_box.set_halign(Gtk.Align.CENTER) - + self.manual_connect_btn = Gtk.Button() - self.manual_connect_btn.add_css_class('suggested-action') - self.manual_connect_btn.add_css_class('pill') + self.manual_connect_btn.add_css_class("suggested-action") + self.manual_connect_btn.add_css_class("pill") self.manual_connect_btn.set_size_request(200, 50) - + btn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) btn_box.set_halign(Gtk.Align.CENTER) self.manual_btn_spinner = Gtk.Spinner() self.manual_btn_spinner.set_visible(False) - self.manual_btn_label = Gtk.Label(label=_('Connect')) + self.manual_btn_label = Gtk.Label(label=_("Connect")) btn_box.append(self.manual_btn_spinner) btn_box.append(self.manual_btn_label) self.manual_connect_btn.set_child(btn_box) - - self.manual_connect_btn.connect('clicked', lambda b: self.on_main_button_clicked('manual')) - + + self.manual_connect_btn.connect("clicked", lambda b: self.on_main_button_clicked("manual")) + buttons_box.append(self.manual_connect_btn) - + box.append(buttons_box) return box - + def create_pin_page(self): box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=24) - for m in ['top', 'bottom', 'start', 'end']: getattr(box, f'set_margin_{m}')(24) - + for m in ["top", "bottom", "start", "end"]: + getattr(box, f"set_margin_{m}")(24) + grp = Adw.PreferencesGroup() grp.set_title(_("Connect with PIN")) grp.set_description(_("Enter the 6-digit PIN code provided by the host")) - + pin = Adw.EntryRow() pin.set_title(_("PIN Code")) # pin.set_input_purpose(Gtk.InputPurpose.NUMBER) # Gtk 4.10+ self.pin_entry = pin - + grp.add(pin) - + box.append(grp) - + buttons_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) buttons_box.set_halign(Gtk.Align.CENTER) - + self.pin_connect_btn = Gtk.Button() - self.pin_connect_btn.add_css_class('suggested-action') - self.pin_connect_btn.add_css_class('pill') + self.pin_connect_btn.add_css_class("suggested-action") + self.pin_connect_btn.add_css_class("pill") self.pin_connect_btn.set_size_request(200, 50) - + btn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) btn_box.set_halign(Gtk.Align.CENTER) self.pin_btn_spinner = Gtk.Spinner() self.pin_btn_spinner.set_visible(False) - self.pin_btn_label = Gtk.Label(label=_('Connect with PIN')) + self.pin_btn_label = Gtk.Label(label=_("Connect with PIN")) btn_box.append(self.pin_btn_spinner) btn_box.append(self.pin_btn_label) self.pin_connect_btn.set_child(btn_box) - - self.pin_connect_btn.connect('clicked', lambda b: self.on_main_button_clicked('pin')) - + + self.pin_connect_btn.connect("clicked", lambda b: self.on_main_button_clicked("pin")) + buttons_box.append(self.pin_connect_btn) - + box.append(buttons_box) return box def on_main_button_clicked(self, source): self.show_toast(_("Clicked Connect...")) # If connecting (loading) or connected, button becomes "Stop" - if getattr(self, 'is_connecting', False) or self.is_connected: + if getattr(self, "is_connecting", False) or self.is_connected: self.on_cancel_connection(None) else: # Start connection based on source - if source == 'discover': + if source == "discover": if self.selected_host_card_data: - self.connect_manual(self.selected_host_card_data['ip'], str(self.selected_host_card_data.get('port', 47989))) - elif source == 'manual': - self.connect_manual(self.manual_ip_entry.get_text(), self.manual_port_entry.get_text(), self.manual_ipv6_switch.get_active()) - elif source == 'pin': - self.connect_pin(self.pin_entry.get_text()) + self.connect_manual(self.selected_host_card_data["ip"], str(self.selected_host_card_data.get("port", 47989))) + elif source == "manual": + self.connect_manual(self.manual_ip_entry.get_text(), self.manual_port_entry.get_text(), self.manual_ipv6_switch.get_active()) + elif source == "pin": + self.connect_pin(self.pin_entry.get_text()) + def connect_to_host(self, host, paired_retry=False, override_check=False): - if getattr(self, 'is_connecting', False) and not paired_retry and not override_check: + if getattr(self, "is_connecting", False) and not paired_retry and not override_check: return - + # Collect UI values on Main Thread (GTK is not thread-safe) scale_active = self.scale_row.get_active() res_idx = self.resolution_row.get_selected() @@ -565,174 +700,175 @@ def connect_to_host(self, host, paired_retry=False, override_check=False): display_mode_idx = self.display_mode_row.get_selected() audio_active = self.audio_row.get_active() hw_decode_active = self.hw_decode_row.get_active() - + # Custom values are already safe attributes - custom_res = getattr(self, 'custom_resolution_val', '1920x1080') - custom_fps = getattr(self, 'custom_fps_val', '60') + custom_res = getattr(self, "custom_resolution_val", "1920x1080") + custom_fps = getattr(self, "custom_fps_val", "60") self.show_loading(True) - + def run(): # 1. Check if already paired (with retries if we just successfuly paired) is_paired = False checks = 10 if paired_retry else 1 for i in range(checks): - apps = self.moonlight.list_apps(host['ip']) + apps = self.moonlight.list_apps(host["ip"]) if apps is not None: is_paired = True break if i < checks - 1: - time.sleep(1.0) # Larger delay for host sync + time.sleep(1.0) # Larger delay for host sync if not is_paired and not paired_retry: GLib.idle_add(self.show_loading, False) GLib.idle_add(lambda: self.start_pairing_flow(host)) return - + if not is_paired and paired_retry: # If we just paired, but list_apps STILL says not paired, it might be a bug in detection. # Try to connect anyway as a desperate fallback. pass - + # Check for cancellation - if not getattr(self, 'is_connecting', False): return + if not getattr(self, "is_connecting", False): + return if scale_active: # res = self.get_auto_resolution() # RISK - res = "1920x1080" # Fallback/Default for now + res = "1920x1080" # Fallback/Default for now else: res_map = {0: "1280x720", 1: "1920x1080", 2: "2560x1440", 3: "3840x2160"} res = custom_res if res_idx == 4 else res_map.get(res_idx, "1920x1080") - + # ATTENTION: If scale_active was True, we correct by fetching EVERYTHING beforehand. pass - w, h = res.split('x') if 'x' in res else ("1920", "1080") + w, h = res.split("x") if "x" in res else ("1920", "1080") fps_map = {0: "30", 1: "60", 2: "120"} fps = custom_fps if fps_idx == 3 else fps_map.get(fps_idx, "60") - - display_mode = ['borderless', 'fullscreen', 'windowed'][display_mode_idx] - - opts = { - 'width': w, - 'height': h, - 'fps': fps, - 'bitrate': int(bitrate_val * 1000), - 'display_mode': display_mode, - 'audio': audio_active, - 'hw_decode': hw_decode_active - } - - if self.moonlight.connect(host['ip'], **opts): + + display_mode = ["borderless", "fullscreen", "windowed"][display_mode_idx] + + opts = {"width": w, "height": h, "fps": fps, "bitrate": int(bitrate_val * 1000), "display_mode": display_mode, "audio": audio_active, "hw_decode": hw_decode_active} + + if self.moonlight.connect(host["ip"], **opts): + def finish_connect_success(): self.show_loading(False) - self.perf_monitor.set_connection_status(host['name'], _("Active Stream"), True) + self.perf_monitor.set_connection_status(host["name"], _("Active Stream"), True) self.perf_monitor.start_monitoring() return False GLib.idle_add(finish_connect_success) else: + def finish_connect_error(): self.show_loading(False) - self.show_error_dialog(_('Error'), _('Failed to connect. Verify if Moonlight is paired.')) + self.show_error_dialog(_("Error"), _("Failed to connect. Verify if Moonlight is paired.")) return False GLib.idle_add(finish_connect_error) - + # Insert automatic resolution logic BEFORE thread for total safety if scale_active: - res_auto = self.get_auto_resolution() - def run_patched(): - # Use res_auto captured in scope - w, h = res_auto.split('x') if 'x' in res_auto else ("1920", "1080") - fps_map = {0: "30", 1: "60", 2: "120"} - fps = custom_fps if fps_idx == 3 else fps_map.get(fps_idx, "60") - display_mode = ['borderless', 'fullscreen', 'windowed'][display_mode_idx] - opts = {'width': w, 'height': h, 'fps': fps, 'bitrate': int(bitrate_val * 1000), 'display_mode': display_mode, 'audio': audio_active, 'hw_decode': hw_decode_active} - - # Pairing check (with retries if retry) - is_paired = False - checks = 10 if paired_retry else 1 - for i in range(checks): - apps = self.moonlight.list_apps(host['ip']) - if apps is not None: - is_paired = True - break - if i < checks - 1: - time.sleep(1.0) - - if not is_paired and not paired_retry: + res_auto = self.get_auto_resolution() + + def run_patched(): + # Use res_auto captured in scope + w, h = res_auto.split("x") if "x" in res_auto else ("1920", "1080") + fps_map = {0: "30", 1: "60", 2: "120"} + fps = custom_fps if fps_idx == 3 else fps_map.get(fps_idx, "60") + display_mode = ["borderless", "fullscreen", "windowed"][display_mode_idx] + opts = {"width": w, "height": h, "fps": fps, "bitrate": int(bitrate_val * 1000), "display_mode": display_mode, "audio": audio_active, "hw_decode": hw_decode_active} + + # Pairing check (with retries if retry) + is_paired = False + checks = 10 if paired_retry else 1 + for i in range(checks): + apps = self.moonlight.list_apps(host["ip"]) + if apps is not None: + is_paired = True + break + if i < checks - 1: + time.sleep(1.0) + + if not is_paired and not paired_retry: GLib.idle_add(self.show_loading, False) GLib.idle_add(lambda: self.start_pairing_flow(host)) return - - if not is_paired and paired_retry: - pass - # Check for cancellation - if not getattr(self, 'is_connecting', False): return - - if self.moonlight.connect(host['ip'], **opts): - def finish_auto_connect_success(): - self.show_loading(False) - self.perf_monitor.set_connection_status(host['name'], _("Active Stream"), True) - self.perf_monitor.start_monitoring() - return False - - GLib.idle_add(finish_auto_connect_success) - else: - def finish_auto_connect_error(): - self.show_loading(False) - self.show_error_dialog(_('Error'), _('Failed to connect')) - return False - - GLib.idle_add(finish_auto_connect_error) - - threading.Thread(target=run_patched, daemon=True).start() + + if not is_paired and paired_retry: + pass + # Check for cancellation + if not getattr(self, "is_connecting", False): + return + + if self.moonlight.connect(host["ip"], **opts): + + def finish_auto_connect_success(): + self.show_loading(False) + self.perf_monitor.set_connection_status(host["name"], _("Active Stream"), True) + self.perf_monitor.start_monitoring() + return False + + GLib.idle_add(finish_auto_connect_success) + else: + + def finish_auto_connect_error(): + self.show_loading(False) + self.show_error_dialog(_("Error"), _("Failed to connect")) + return False + + GLib.idle_add(finish_auto_connect_error) + + threading.Thread(target=run_patched, daemon=True).start() else: - threading.Thread(target=run, daemon=True).start() + threading.Thread(target=run, daemon=True).start() def start_pairing_flow(self, host): """Starts pairing flow (Automatic for localhost, Manual for remote)""" - + def on_pin_callback(pin): # Check if localhost - is_local = host['ip'] in ['127.0.0.1', 'localhost', '::1'] - + is_local = host["ip"] in ["127.0.0.1", "localhost", "::1"] + if is_local: # Attempt automation try: from big_remote_play.host.sunshine_manager import SunshineHost from pathlib import Path - sun = SunshineHost(Path.home() / '.config' / 'big-remoteplay' / 'sunshine') + + sun = SunshineHost(Path.home() / ".config" / "big-remoteplay" / "sunshine") if sun.is_running(): GLib.idle_add(lambda: self.show_toast(_("Attempting automatic pairing PIN: {}").format(pin))) ok, msg = sun.send_pin(pin) if ok: GLib.idle_add(lambda: self.show_toast(_("Automatic pairing sent!"))) - return # Success, moonlight should detect and finish + return # Success, moonlight should detect and finish else: print(f"Automatic pairing failed: {msg}") except Exception as e: print(f"Error in pairing automation: {e}") - + # Fallback to Dialog UI - GLib.idle_add(lambda: self.show_pairing_dialog(host['ip'], pin, hostname=host.get('name'))) + GLib.idle_add(lambda: self.show_pairing_dialog(host["ip"], pin, hostname=host.get("name"))) def do_pair(): self.show_toast(_("Starting pairing...")) - success = self.moonlight.pair(host['ip'], on_pin_callback=on_pin_callback) - + success = self.moonlight.pair(host["ip"], on_pin_callback=on_pin_callback) + GLib.idle_add(self.close_pairing_dialog) - + # Record current connection attempt status # Double check: If pair returns False, check if it really failed by listing apps. # Moonlight sometimes closes pipe abruptly after success. if not success: - if self.moonlight.list_apps(host['ip']) is not None: + if self.moonlight.list_apps(host["ip"]) is not None: success = True if success: + def finish_pair_success(): self.show_toast(_("Paired successfully!")) self.connect_to_host(host, paired_retry=True) @@ -740,11 +876,10 @@ def finish_pair_success(): GLib.idle_add(finish_pair_success) else: - GLib.idle_add(lambda: self.show_error_dialog(_("Pairing Error"), _("Could not pair with host.\nVerify the PIN was entered correctly."))) + GLib.idle_add(lambda: self.show_error_dialog(_("Pairing Error"), _("Could not pair with host.\nVerify the PIN was entered correctly."))) threading.Thread(target=do_pair, daemon=True).start() - def show_loading(self, show=True, message=""): self.is_connecting = show self._update_all_buttons_state() @@ -753,33 +888,55 @@ def on_cancel_connection(self, btn): self.show_toast(_("Canceling connection...")) self.is_connecting = False self.show_loading(False) - if hasattr(self, 'moonlight'): + if hasattr(self, "moonlight"): self.moonlight.disconnect() - + def show_pin_dialog(self, pin): - if self.pin_dialog: self.pin_dialog.close() - self.pin_dialog = Adw.MessageDialog(heading=_('Pairing Required'), body=_('Moonlight needs to be paired.\n\n{}').format(pin)) - self.pin_dialog.set_body_use_markup(True); self.pin_dialog.add_response('cancel', _('Cancel')); self.pin_dialog.present() - def close_pin_dialog(self): (self.pin_dialog.close() if self.pin_dialog else None); self.pin_dialog = None + if self.pin_dialog: + self.pin_dialog.close() + self.pin_dialog = Adw.MessageDialog(heading=_("Pairing Required"), body=_('Moonlight needs to be paired.\n\n{}').format(pin)) + self.pin_dialog.set_body_use_markup(True) + self.pin_dialog.add_response("cancel", _("Cancel")) + self.pin_dialog.present() + + def close_pin_dialog(self): + (self.pin_dialog.close() if self.pin_dialog else None) + self.pin_dialog = None def show_pairing_dialog(self, host_ip, pin=None, on_confirm=None, hostname=None): - if hasattr(self, 'pairing_dialog') and self.pairing_dialog: + if hasattr(self, "pairing_dialog") and self.pairing_dialog: if pin: - self.pairing_dialog.set_body(f'{pin}\n\n' + _('Follow instructions.\n\n1. Provide PIN and Host {} to the server.\n2. On the host, access Sunshine Configuration.\n3. Enter PIN and Host.\n4. Click Send.').format(hostname or "")) + self.pairing_dialog.set_body( + f'{pin}\n\n' + + _("Follow instructions.\n\n1. Provide PIN and Host {} to the server.\n2. On the host, access Sunshine Configuration.\n3. Enter PIN and Host.\n4. Click Send.").format( + hostname or "" + ) + ) return - if hasattr(self, 'pairing_dialog') and self.pairing_dialog: self.pairing_dialog.close() - body = _('Follow instructions.\n\n1. Provide PIN and Host {} to the server.\n2. On the host, access Sunshine Configuration.\n3. Enter PIN and Host.\n4. Click Send.').format(hostname or "") - if pin: body = f'{pin}\n\n' + body - self.pairing_dialog = Adw.MessageDialog(heading=_('Pairing Started'), body=body) - self.pairing_dialog.set_body_use_markup(True); self.pairing_dialog.set_default_size(600, 450); self.pairing_dialog.set_resizable(True) - self.pairing_dialog.add_response('ok', _('OK')); self.pairing_dialog.set_response_appearance('ok', Adw.ResponseAppearance.SUGGESTED) + if hasattr(self, "pairing_dialog") and self.pairing_dialog: + self.pairing_dialog.close() + body = _("Follow instructions.\n\n1. Provide PIN and Host {} to the server.\n2. On the host, access Sunshine Configuration.\n3. Enter PIN and Host.\n4. Click Send.").format( + hostname or "" + ) + if pin: + body = f'{pin}\n\n' + body + self.pairing_dialog = Adw.MessageDialog(heading=_("Pairing Started"), body=body) + self.pairing_dialog.set_body_use_markup(True) + self.pairing_dialog.set_default_size(600, 450) + self.pairing_dialog.set_resizable(True) + self.pairing_dialog.add_response("ok", _("OK")) + self.pairing_dialog.set_response_appearance("ok", Adw.ResponseAppearance.SUGGESTED) + def on_resp(dlg, resp): - if resp == 'ok' and on_confirm: on_confirm() - self.pairing_dialog.connect('response', on_resp); self.pairing_dialog.present() - + if resp == "ok" and on_confirm: + on_confirm() + + self.pairing_dialog.connect("response", on_resp) + self.pairing_dialog.present() + def close_pairing_dialog(self): - if hasattr(self, 'pairing_dialog') and self.pairing_dialog: + if hasattr(self, "pairing_dialog") and self.pairing_dialog: self.pairing_dialog.close() self.pairing_dialog = None @@ -790,19 +947,24 @@ def get_auto_resolution(self): return "1920x1080" monitor = None if native := self.get_native(): - if surface := native.get_surface(): monitor = display.get_monitor_at_surface(surface) + if surface := native.get_surface(): + monitor = display.get_monitor_at_surface(surface) if not monitor: monitors = display.get_monitors() - if monitors.get_n_items() > 0: monitor = monitors.get_item(0) + if monitors.get_n_items() > 0: + monitor = monitors.get_item(0) if monitor: - r = monitor.get_geometry(); return f"{r.width}x{r.height}" - except Exception: pass + r = monitor.get_geometry() + return f"{r.width}x{r.height}" + except Exception: + pass return "1920x1080" def connect_manual(self, ip, port, ipv6=False): - if not ip: return - self.current_host_ctx = {'type': 'manual', 'ip': ip, 'port': port, 'ipv6': ipv6} - self.connect_to_host({'name': ip, 'ip': ip, 'port': int(port) if port else 47989}) + if not ip: + return + self.current_host_ctx = {"type": "manual", "ip": ip, "port": port, "ipv6": ipv6} + self.connect_to_host({"name": ip, "ip": ip, "port": int(port) if port else 47989}) def connect_pin(self, _widget): # Reverse Connection via PIN (Custom Feature) @@ -810,228 +972,276 @@ def connect_pin(self, _widget): if len(pin) != 6: self.show_error_dialog(_("Invalid PIN"), _("Must contain 6 digits.")) return - + self.show_loading(True) # 1. Resolve PIN to IP via Utils from big_remote_play.utils.network import resolve_pin_to_ip - + def run_resolve(): res = resolve_pin_to_ip(pin) if res: GLib.idle_add(self._on_pin_resolved, res, pin) else: GLib.idle_add(self._on_pin_failed) - + threading.Thread(target=run_resolve, daemon=True).start() def _on_pin_resolved(self, ip_info, pin): - ip = ip_info.get('ip') - port = ip_info.get('port', 47989) - hostname = ip_info.get('hostname', 'Host') - + ip = ip_info.get("ip") + port = ip_info.get("port", 47989) + hostname = ip_info.get("hostname", "Host") + self.show_toast(_("Host found: {}").format(hostname)) - self.connect_to_host({'name': hostname, 'ip': ip, 'port': port}, override_check=True) + self.connect_to_host({"name": hostname, "ip": ip, "port": port}, override_check=True) def _on_pin_failed(self): self.show_loading(False) self.show_error_dialog(_("Host Not Found"), _("Could not find a host with this PIN on the local network...")) - + def show_error_dialog(self, title, message): dialog = Adw.MessageDialog.new(self._root_window()) - dialog.set_heading(title); dialog.set_body(message) - dialog.add_response('ok', _('OK')); dialog.present() - + dialog.set_heading(title) + dialog.set_body(message) + dialog.add_response("ok", _("OK")) + dialog.present() + def on_resolution_changed(self, row, item): val = row.get_selected_item().get_string() - if val == _('Custom'): + if val == _("Custom"): + def set_custom(v): # Validate WxH self.show_toast(_("Set: {}").format(v)) # Store custom resolution logic + self.show_custom_input_dialog(_("Custom Resolution"), _("Enter WxH:"), set_custom) else: print(f"Resolution: {val}") def on_fps_changed(self, row, item): val = row.get_selected_item().get_string() - if val == _('Custom'): + if val == _("Custom"): + def set_custom(v): self.show_toast(_("Set: {}").format(v)) + self.show_custom_input_dialog(_("Custom FPS"), _("Use a number"), set_custom) def on_scale_changed(self, row, param): self.resolution_row.set_sensitive(not row.get_active()) - if row.get_active(): self.show_toast(_("Automatic")) + if row.get_active(): + self.show_toast(_("Automatic")) self.save_guest_settings() def show_custom_input_dialog(self, title, subtitle, callback): dialog = Adw.MessageDialog(heading=title, body=subtitle) dialog.set_transient_for(self._root_window()) - + grp = Adw.PreferencesGroup() entry = Adw.EntryRow(title=_("Value")) grp.add(entry) dialog.set_extra_child(grp) - + dialog.add_response("cancel", _("Cancel")) dialog.add_response("ok", _("Apply")) dialog.set_response_appearance("ok", Adw.ResponseAppearance.SUGGESTED) - + def on_response(d, r): - if r == 'ok': + if r == "ok": val = entry.get_text() - if val: callback(val) - + if val: + callback(val) + dialog.connect("response", on_response) dialog.present() - def cleanup(self): (self.perf_monitor.stop_monitoring() if hasattr(self, 'perf_monitor') else None) + def cleanup(self): + (self.perf_monitor.stop_monitoring() if hasattr(self, "perf_monitor") else None) + def connect_settings_signals(self): self.bitrate_scale.connect("value-changed", lambda w: self.save_guest_settings()) - for r in [self.display_mode_row, self.audio_row, self.hw_decode_row]: r.connect("notify::selected-item" if isinstance(r, Adw.ComboRow) else "notify::active", lambda *x: self.save_guest_settings()) + for r in [self.display_mode_row, self.audio_row, self.hw_decode_row]: + r.connect("notify::selected-item" if isinstance(r, Adw.ComboRow) else "notify::active", lambda *x: self.save_guest_settings()) + def save_guest_settings(self): # 1. Save to Moonlight.conf (Global Sync) - + # Resolution idx = self.resolution_row.get_selected() w, h = "1920", "1080" - if idx == 0: w, h = "1280", "720" - elif idx == 1: w, h = "1920", "1080" - elif idx == 2: w, h = "2560", "1440" - elif idx == 3: w, h = "3840", "2160" + if idx == 0: + w, h = "1280", "720" + elif idx == 1: + w, h = "1920", "1080" + elif idx == 2: + w, h = "2560", "1440" + elif idx == 3: + w, h = "3840", "2160" elif idx == 4: - if hasattr(self, 'custom_resolution_val') and 'x' in str(self.custom_resolution_val): - parts = str(self.custom_resolution_val).split('x') - if len(parts) >= 2: w, h = parts[0], parts[1] - - self.moonlight_config.set('width', w) - self.moonlight_config.set('height', h) - + if hasattr(self, "custom_resolution_val") and "x" in str(self.custom_resolution_val): + parts = str(self.custom_resolution_val).split("x") + if len(parts) >= 2: + w, h = parts[0], parts[1] + + self.moonlight_config.set("width", w) + self.moonlight_config.set("height", h) + # FPS f_idx = self.fps_row.get_selected() fps = "60" - if f_idx == 0: fps = "30" - elif f_idx == 1: fps = "60" - elif f_idx == 2: fps = "120" - elif f_idx == 3: fps = getattr(self, 'custom_fps_val', "60") - - self.moonlight_config.set('fps', fps) - + if f_idx == 0: + fps = "30" + elif f_idx == 1: + fps = "60" + elif f_idx == 2: + fps = "120" + elif f_idx == 3: + fps = getattr(self, "custom_fps_val", "60") + + self.moonlight_config.set("fps", fps) + # Bitrate (kbps) br = int(self.bitrate_scale.get_value() * 1000) - self.moonlight_config.set('bitrate', br) - + self.moonlight_config.set("bitrate", br) + # Display Mode # UI: 0=Borderless, 1=Full, 2=Windowed # Conf: 3=Borderless, 1=Full, 2=Windowed m_idx = self.display_mode_row.get_selected() mode = "3" - if m_idx == 1: mode = "1" - elif m_idx == 2: mode = "2" - self.moonlight_config.set('windowMode', mode) - + if m_idx == 1: + mode = "1" + elif m_idx == 2: + mode = "2" + self.moonlight_config.set("windowMode", mode) + # HW Decode # Conf: 0=Auto, 1=HW, 2=SW # If UI ON -> 0 (Auto), If OFF -> 2 (SW) hw = self.hw_decode_row.get_active() - self.moonlight_config.set('videoDecoder', '0' if hw else '2') - + self.moonlight_config.set("videoDecoder", "0" if hw else "2") + # 2. Save Local Settings (Not in Moonlight.conf or specific to GuestView) - s = self.config.get('guest', {}) + s = self.config.get("guest", {}) if not isinstance(s, dict): s = {} - s['scale_native'] = self.scale_row.get_active() - s['audio'] = self.audio_row.get_active() - self.config.set('guest', s) + s["scale_native"] = self.scale_row.get_active() + s["audio"] = self.audio_row.get_active() + self.config.set("guest", s) def load_guest_settings(self): # Force reload from file to catch external changes (e.g. from Preferences) self.moonlight_config.reload() - + # 1. Load from Moonlight.conf try: # Resolution - w = int(float(self.moonlight_config.get('width', 1920))) - h = int(float(self.moonlight_config.get('height', 1080))) - + w = int(float(self.moonlight_config.get("width", 1920))) + h = int(float(self.moonlight_config.get("height", 1080))) + # Map back to index - if h == 720: self.resolution_row.set_selected(0) - elif h == 1080: self.resolution_row.set_selected(1) - elif h == 1440: self.resolution_row.set_selected(2) - elif h == 2160: self.resolution_row.set_selected(3) + if h == 720: + self.resolution_row.set_selected(0) + elif h == 1080: + self.resolution_row.set_selected(1) + elif h == 1440: + self.resolution_row.set_selected(2) + elif h == 2160: + self.resolution_row.set_selected(3) else: - self.resolution_row.set_selected(4) # Custom - self.custom_resolution_val = f"{w}x{h}" - + self.resolution_row.set_selected(4) # Custom + self.custom_resolution_val = f"{w}x{h}" + # FPS - fps = int(float(self.moonlight_config.get('fps', 60))) - if fps == 30: self.fps_row.set_selected(0) - elif fps == 60: self.fps_row.set_selected(1) - elif fps == 120: self.fps_row.set_selected(2) + fps = int(float(self.moonlight_config.get("fps", 60))) + if fps == 30: + self.fps_row.set_selected(0) + elif fps == 60: + self.fps_row.set_selected(1) + elif fps == 120: + self.fps_row.set_selected(2) else: - self.fps_row.set_selected(3) - self.custom_fps_val = str(fps) - + self.fps_row.set_selected(3) + self.custom_fps_val = str(fps) + # Bitrate - br = int(float(self.moonlight_config.get('bitrate', 10000))) / 1000.0 + br = int(float(self.moonlight_config.get("bitrate", 10000))) / 1000.0 self.bitrate_scale.set_value(br) - + # Display Mode (3=Borderless, 1=Full, 2=Windowed) -> UI (0, 1, 2) - mode = self.moonlight_config.get('windowMode', '3') - if mode == '3': self.display_mode_row.set_selected(0) - elif mode == '1': self.display_mode_row.set_selected(1) - elif mode == '2': self.display_mode_row.set_selected(2) - + mode = self.moonlight_config.get("windowMode", "3") + if mode == "3": + self.display_mode_row.set_selected(0) + elif mode == "1": + self.display_mode_row.set_selected(1) + elif mode == "2": + self.display_mode_row.set_selected(2) + # HW Decode - dec = self.moonlight_config.get('videoDecoder', '0') - self.hw_decode_row.set_active(dec != '2') + dec = self.moonlight_config.get("videoDecoder", "0") + self.hw_decode_row.set_active(dec != "2") except Exception as e: print(f"Error loading Moonlight global settings: {e}") - + # 2. Load Local Settings try: - s = self.config.get('guest', {}) + s = self.config.get("guest", {}) if not isinstance(s, dict): s = {} - self.scale_row.set_active(bool(s.get('scale_native', False))) - self.audio_row.set_active(bool(s.get('audio', True))) - except Exception: pass + self.scale_row.set_active(bool(s.get("scale_native", False))) + self.audio_row.set_active(bool(s.get("audio", True))) + except Exception: + pass + def on_reset_clicked(self, _widget): dialog = Adw.MessageDialog(heading=_("Reset defaults?"), body=_("All client settings will be restored.")) dialog.set_transient_for(self._root_window()) dialog.add_response("cancel", _("No")) dialog.add_response("ok", _("Yes")) dialog.set_response_appearance("ok", Adw.ResponseAppearance.DESTRUCTIVE) - + def on_resp(d, r): - if r == 'ok': + if r == "ok": self.bitrate_scale.set_value(20.0) self.resolution_row.set_selected(1) self.fps_row.set_selected(1) self.scale_row.set_active(False) self.show_toast(_("Restored")) - + dialog.connect("response", on_resp) dialog.present() + def reset_to_defaults(self): - self.scale_row.set_active(False); self.resolution_row.set_selected(1); self.fps_row.set_selected(1); self.bitrate_scale.set_value(20.0); self.display_mode_row.set_selected(0); self.audio_row.set_active(True); self.hw_decode_row.set_active(True) - self.custom_resolution_val = self.custom_fps_val = ''; self.show_toast(_("Restored")); self.save_guest_settings() + self.scale_row.set_active(False) + self.resolution_row.set_selected(1) + self.fps_row.set_selected(1) + self.bitrate_scale.set_value(20.0) + self.display_mode_row.set_selected(0) + self.audio_row.set_active(True) + self.hw_decode_row.set_active(True) + self.custom_resolution_val = self.custom_fps_val = "" + self.show_toast(_("Restored")) + self.save_guest_settings() + def show_toast(self, m): - show_toast = getattr(self.get_root(), 'show_toast', None) - if callable(show_toast): show_toast(m) - else: print(f"Toast: {m}") + show_toast = getattr(self.get_root(), "show_toast", None) + if callable(show_toast): + show_toast(m) + else: + print(f"Toast: {m}") def open_advanced_client_settings(self, _widget=None): """Full Moonlight client tuning, opened from the Connect task itself.""" from big_remote_play.ui.moonlight_preferences import MoonlightPreferencesPage + win = Adw.PreferencesWindow() win.set_transient_for(self._root_window()) win.set_modal(True) - win.set_title(_('Advanced client settings')) + win.set_title(_("Advanced client settings")) win.add(MoonlightPreferencesPage()) # Refresh the inline quick-settings after the advanced sheet closes. - win.connect('close-request', lambda w: (self.load_guest_settings(), False)[1]) + win.connect("close-request", lambda w: (self.load_guest_settings(), False)[1]) win.present() def show_shortcuts_dialog(self): @@ -1039,28 +1249,29 @@ def show_shortcuts_dialog(self): dialog.set_modal(True) dialog.set_title(_("Shortcuts & Instructions")) dialog.set_default_size(500, 600) - + content = Adw.ToolbarView() - + # Header header = Adw.HeaderBar() content.add_top_bar(header) - + # Body scroll = Gtk.ScrolledWindow() scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) - + clamp = Adw.Clamp() clamp.set_maximum_size(600) - for m in ['top', 'bottom', 'start', 'end']: getattr(clamp, f'set_margin_{m}')(16) - + for m in ["top", "bottom", "start", "end"]: + getattr(clamp, f"set_margin_{m}")(16) + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=24) - + # Shortcuts Group grp = Adw.PreferencesGroup() grp.set_title(_("Keyboard Shortcuts")) grp.set_description(_("Common shortcuts used during streaming")) - + shortcuts = [ ("Ctrl+Alt+Shift+Q", _("Quit Stream")), ("Ctrl+Alt+Shift+Z", _("Toggle Mouse Capture")), @@ -1069,7 +1280,7 @@ def show_shortcuts_dialog(self): ("Ctrl+Alt+Shift+F1..F12", _("Switch Monitor (Multi-Head Host)")), ("Ctrl+Alt+Shift+X", _("Toggle Fullscreen")), ] - + for keys, desc in shortcuts: row = Adw.ActionRow() row.set_title(keys) @@ -1077,40 +1288,44 @@ def show_shortcuts_dialog(self): # Make keys bold/styled # We can't style title easily without custom child, but title is fine. grp.add(row) - + box.append(grp) - + # Instructions Group (Multi-Monitor) grp_mon = Adw.PreferencesGroup() grp_mon.set_title(_("Multi-Monitor Support")) grp_mon.set_description(_("Instructions for hosts with multiple displays")) - + # Monitor Switching explanation row_mon = Adw.ActionRow() row_mon.set_title(_("Switching Monitors")) row_mon.set_subtitle( - _("If the Host PC has multiple monitors, you can switch between them easily.\n\n" - "1. Use Ctrl+Alt+Shift + F1 (Screen 1), F2 (Screen 2), etc.\n" - "2. If this shortcut doesn't work, ensure 'Grab Input' is active (Ctrl+Alt+Shift + Z).\n" - "3. Why did F1/F2 stop working? The system likely updated and now requires the full shortcut combo to avoid conflicts.") + _( + "If the Host PC has multiple monitors, you can switch between them easily.\n\n" + "1. Use Ctrl+Alt+Shift + F1 (Screen 1), F2 (Screen 2), etc.\n" + "2. If this shortcut doesn't work, ensure 'Grab Input' is active (Ctrl+Alt+Shift + Z).\n" + "3. Why did F1/F2 stop working? The system likely updated and now requires the full shortcut combo to avoid conflicts." + ) ) grp_mon.add(row_mon) - + row_trouble = Adw.ActionRow() row_trouble.set_title(_("Troubleshooting: Shortcuts Not Working?")) row_trouble.set_subtitle( - _("If shortcuts like F1/F2/F3 are not switching screens:\n" - "• Press Ctrl+Alt+Shift + Z to toggle Mouse/Keyboard Capture.\n" - "• When capture is ON, your F-keys are sent to the remote PC.\n" - "• When capture is OFF, your local PC intercepts them.") + _( + "If shortcuts like F1/F2/F3 are not switching screens:\n" + "• Press Ctrl+Alt+Shift + Z to toggle Mouse/Keyboard Capture.\n" + "• When capture is ON, your F-keys are sent to the remote PC.\n" + "• When capture is OFF, your local PC intercepts them." + ) ) grp_mon.add(row_trouble) - + box.append(grp_mon) - + clamp.set_child(box) scroll.set_child(clamp) content.set_content(scroll) - + dialog.set_content(content) dialog.present() diff --git a/src/big_remote_play/ui/host_view.py b/src/big_remote_play/ui/host_view.py index 3cd3c84..71f9412 100644 --- a/src/big_remote_play/ui/host_view.py +++ b/src/big_remote_play/ui/host_view.py @@ -1,6 +1,7 @@ import gi -gi.require_version('Gtk', '4.0') -gi.require_version('Adw', '1') + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") from collections.abc import Callable from gi.repository import Gtk, Gdk, Adw, GLib # type: ignore @@ -42,7 +43,7 @@ def __init__(self): super().__init__(orientation=Gtk.Orientation.VERTICAL) self.config = Config() self.is_hosting = False - self.process = None # Initialize to avoid AttributeError + self.process = None # Initialize to avoid AttributeError self.pin_code = None self.private_audio_apps = set() self.audio_devices = [] @@ -50,37 +51,38 @@ def __init__(self): self.stop_pin_listener = None self._uptime_timer_id = None self._hosting_started_at = None - + from big_remote_play.host.sunshine_manager import SunshineHost - self.sunshine = SunshineHost(Path.home() / '.config' / 'big-remoteplay' / 'sunshine') - + + self.sunshine = SunshineHost(Path.home() / ".config" / "big-remoteplay" / "sunshine") + if self.sunshine.is_running(): self.is_hosting = True - + self.available_monitors = self.detect_monitors() self.available_gpus = self.detect_gpus() self.setup_ui() - + self.game_detector = GameDetector() - self.detected_games = {'Steam': [], 'Lutris': []} + self.detected_games = {"Steam": [], "Lutris": []} self.load_settings() self.connect_settings_signals() self.loading_settings = False - + # Ensure config is correct (API enabled) - if hasattr(self, '_ensure_sunshine_config'): - self._ensure_sunshine_config() - + if hasattr(self, "_ensure_sunshine_config"): + self._ensure_sunshine_config() + self.sync_ui_state() def _root_window(self): root = self.get_root() return root if isinstance(root, Gtk.Window) else None - + def detect_monitors(self): - monitors = [(_('Automatic'), 'auto')] - is_wayland = os.environ.get('XDG_SESSION_TYPE') == 'wayland' - + monitors = [(_("Automatic"), "auto")] + is_wayland = os.environ.get("XDG_SESSION_TYPE") == "wayland" + # Method: GDK (Most consistent for labels and Wayland indices) names = [] try: @@ -96,10 +98,12 @@ def detect_monitors(self): manufacturer = monitor.get_manufacturer() or "" model = monitor.get_model() or "" label_parts = [] - if manufacturer: label_parts.append(manufacturer) - if model: label_parts.append(model) + if manufacturer: + label_parts.append(manufacturer) + if model: + label_parts.append(model) label = " ".join(label_parts) if label_parts else "Monitor" - + # Value logic: Wayland uses 0, 1, 2... | X11 uses HDMI-A-1, etc. val = str(i) if is_wayland else conn full_label = f"{label} ({conn})" @@ -117,104 +121,118 @@ def detect_monitors(self): if n and n not in names: monitors.append((f"Display ({n})", n)) names.append(n) - except Exception: pass + except Exception: + pass # DRM (Reinforcement for KMS/DRM) try: from pathlib import Path - for p in Path('/sys/class/drm').glob('card*-*'): - if (p/'status').exists() and (p/'status').read_text().strip() == 'connected': - name = p.name.split('-', 1)[1] + + for p in Path("/sys/class/drm").glob("card*-*"): + if (p / "status").exists() and (p / "status").read_text().strip() == "connected": + name = p.name.split("-", 1)[1] if name not in names: monitors.append((f"DRM Display ({name})", name)) names.append(name) - except Exception: pass - + except Exception: + pass + return monitors def detect_gpus(self): gpus = [] try: - lspci = subprocess.check_output(['lspci'], text=True, timeout=5).lower() - if 'nvidia' in lspci: + lspci = subprocess.check_output(["lspci"], text=True, timeout=5).lower() + if "nvidia" in lspci: try: - subprocess.check_call(['nvidia-smi'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5) - gpus.append({'label': 'NVENC (NVIDIA)', 'encoder': 'nvenc', 'adapter': 'auto'}) - except Exception: pass - if 'intel' in lspci: gpus.append({'label': 'VAAPI (Intel Quicksync)', 'encoder': 'vaapi', 'adapter': '/dev/dri/renderD128'}) - except Exception: pass + subprocess.check_call(["nvidia-smi"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5) + gpus.append({"label": "NVENC (NVIDIA)", "encoder": "nvenc", "adapter": "auto"}) + except Exception: + pass + if "intel" in lspci: + gpus.append({"label": "VAAPI (Intel Quicksync)", "encoder": "vaapi", "adapter": "/dev/dri/renderD128"}) + except Exception: + pass try: from pathlib import Path - if Path('/dev/dri').exists(): - for node in sorted(list(Path('/dev/dri').glob('renderD*'))): - if not any(str(node) == g['adapter'] for g in gpus): - gpus.append({'label': f"VAAPI (Adapter {node.name})", 'encoder': 'vaapi', 'adapter': str(node)}) - except Exception: pass - gpus.extend([{'label':'Vulkan (Exp)', 'encoder':'vulkan', 'adapter':'auto'}, {'label':'Software', 'encoder':'software', 'adapter':'auto'}]) + + if Path("/dev/dri").exists(): + for node in sorted(list(Path("/dev/dri").glob("renderD*"))): + if not any(str(node) == g["adapter"] for g in gpus): + gpus.append({"label": f"VAAPI (Adapter {node.name})", "encoder": "vaapi", "adapter": str(node)}) + except Exception: + pass + gpus.extend([{"label": "Vulkan (Exp)", "encoder": "vulkan", "adapter": "auto"}, {"label": "Software", "encoder": "software", "adapter": "auto"}]) return gpus - + def setup_ui(self): clamp = Adw.Clamp() clamp.set_maximum_size(1040) clamp.set_valign(Gtk.Align.START) - for margin in ['top', 'bottom', 'start', 'end']: - getattr(clamp, f'set_margin_{margin}')(20) + for margin in ["top", "bottom", "start", "end"]: + getattr(clamp, f"set_margin_{margin}")(20) content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=18) self.loading_bar = Gtk.ProgressBar() - self.loading_bar.add_css_class('osd') + self.loading_bar.add_css_class("osd") self.loading_bar.set_visible(False) content.append(self.loading_bar) - content.append(create_page_header(_('Server'), icon_name='network-server-symbolic')) + content.append(create_page_header(_("Server"), icon_name="network-server-symbolic")) from .performance_monitor import PerformanceMonitor + self.perf_monitor = PerformanceMonitor(sunshine=self.sunshine) self.perf_monitor.set_visible(True) self.perf_monitor.set_connection_status("Localhost", _("Sunshine Offline"), False) - game_group = Adw.PreferencesGroup() - game_group.set_title(_('Game Configuration')) - + game_group.set_title(_("Game Configuration")) + reset_btn = Gtk.Button() reset_btn.set_child(create_icon_widget("edit-undo-symbolic", size=16)) reset_btn.add_css_class("flat") reset_btn.set_tooltip_text(_("Reset to Defaults")) reset_btn.connect("clicked", self.on_reset_clicked) game_group.set_header_suffix(reset_btn) - - self.game_mode_row = Adw.ComboRow(); self.game_mode_row.set_title(_('Game Mode')); self.game_mode_row.set_subtitle(_('Select game source')) + + self.game_mode_row = Adw.ComboRow() + self.game_mode_row.set_title(_("Game Mode")) + self.game_mode_row.set_subtitle(_("Select game source")) modes = Gtk.StringList() - for m in [_('Full Desktop'), 'Steam', 'Lutris', _('Custom App')]: modes.append(m) - self.game_mode_row.set_model(modes); self.game_mode_row.set_selected(0); self.game_mode_row.connect('notify::selected', self.on_game_mode_changed); game_group.add(self.game_mode_row) - + for m in [_("Full Desktop"), "Steam", "Lutris", _("Custom App")]: + modes.append(m) + self.game_mode_row.set_model(modes) + self.game_mode_row.set_selected(0) + self.game_mode_row.connect("notify::selected", self.on_game_mode_changed) + game_group.add(self.game_mode_row) + self.platform_games_expander = Adw.ExpanderRow() self.platform_games_expander.set_title(_("Game Selection")) self.platform_games_expander.set_subtitle(_("Choose game from list")) self.platform_games_expander.set_visible(False) - + self.game_list_row = Adw.ComboRow() - self.game_list_row.set_title(_('Select Game')) - self.game_list_row.set_subtitle(_('Choose game from list')) + self.game_list_row.set_title(_("Select Game")) + self.game_list_row.set_subtitle(_("Choose game from list")) self.game_list_model = Gtk.StringList() self.game_list_row.set_model(self.game_list_model) self.platform_games_expander.add_row(self.game_list_row) game_group.add(self.platform_games_expander) - + self.custom_app_expander = Adw.ExpanderRow() self.custom_app_expander.set_title(_("Application Details")) self.custom_app_expander.set_subtitle(_("Configure name and command")) self.custom_app_expander.set_visible(False) - + self.custom_name_entry = Adw.EntryRow() - self.custom_name_entry.set_title(_('Application Name')) + self.custom_name_entry.set_title(_("Application Name")) self.custom_app_expander.add_row(self.custom_name_entry) - + self.custom_cmd_entry = Adw.EntryRow() - self.custom_cmd_entry.set_title(_('Command')) + self.custom_cmd_entry.set_title(_("Command")) browse_btn = Gtk.Button() browse_btn.set_child(create_icon_widget("folder-open-symbolic", size=16)) browse_btn.add_css_class("flat") @@ -225,12 +243,12 @@ def setup_ui(self): self.custom_cmd_entry.add_suffix(browse_btn) self.custom_app_expander.add_row(self.custom_cmd_entry) game_group.add(self.custom_app_expander) - + self.streaming_expander = Adw.ExpanderRow() - self.streaming_expander.set_title(_('Streaming Settings')) - self.streaming_expander.set_subtitle(_('Quality and Players')) - self.streaming_expander.set_icon_name('preferences-desktop-display-symbolic') - + self.streaming_expander.set_title(_("Streaming Settings")) + self.streaming_expander.set_subtitle(_("Quality and Players")) + self.streaming_expander.set_icon_name("preferences-desktop-display-symbolic") + # Resolution Row self.resolution_row = Adw.ComboRow() self.resolution_row.set_title(_("Resolution")) @@ -239,9 +257,9 @@ def setup_ui(self): for res in ["720p", "1080p", "1440p", "4K", _("Custom")]: res_model.append(res) self.resolution_row.set_model(res_model) - self.resolution_row.set_selected(1) # Default 1080p + self.resolution_row.set_selected(1) # Default 1080p self.streaming_expander.add_row(self.resolution_row) - + # FPS Row self.fps_row = Adw.ComboRow() self.fps_row.set_title(_("Frame Rate (FPS)")) @@ -250,62 +268,66 @@ def setup_ui(self): for fps in ["30", "60", "120", "144", _("Custom")]: fps_model.append(fps) self.fps_row.set_model(fps_model) - self.fps_row.set_selected(1) # Default 60 + self.fps_row.set_selected(1) # Default 60 self.streaming_expander.add_row(self.fps_row) - + # Bandwidth Row self.bandwidth_row = Adw.SpinRow() self.bandwidth_row.set_title(_("Bandwidth Limit (Mbps)")) self.bandwidth_row.set_subtitle(_("Max bitrate (0 = Unlimited)")) - + # Use simple numeric adjustment adj = Gtk.Adjustment(value=0, lower=0, upper=500, step_increment=5, page_increment=10) self.bandwidth_row.set_adjustment(adj) self.streaming_expander.add_row(self.bandwidth_row) game_group.add(self.streaming_expander) - + self.hardware_expander = Adw.ExpanderRow() - self.hardware_expander.set_title(_('Hardware and Capture')) - self.hardware_expander.set_subtitle(_('Monitor, GPU, and Capture Method')) - self.hardware_expander.set_icon_name('video-display-symbolic') + self.hardware_expander.set_title(_("Hardware and Capture")) + self.hardware_expander.set_subtitle(_("Monitor, GPU, and Capture Method")) + self.hardware_expander.set_icon_name("video-display-symbolic") self.monitor_row = Adw.ComboRow() - self.monitor_row.set_title(_('Monitor / Display')) - self.monitor_row.set_subtitle(_('Select the display to capture')) + self.monitor_row.set_title(_("Monitor / Display")) + self.monitor_row.set_subtitle(_("Select the display to capture")) monitor_model = Gtk.StringList() - for label, _val in self.available_monitors: monitor_model.append(label) + for label, _val in self.available_monitors: + monitor_model.append(label) self.monitor_row.set_model(monitor_model) self.monitor_row.set_selected(0) self.hardware_expander.add_row(self.monitor_row) - + self.gpu_row = Adw.ComboRow() - self.gpu_row.set_title(_('Graphics Card / Encoder')) - self.gpu_row.set_subtitle(_('Choose hardware for video encoding')) + self.gpu_row.set_title(_("Graphics Card / Encoder")) + self.gpu_row.set_subtitle(_("Choose hardware for video encoding")) gpu_model = Gtk.StringList() - for gpu_info in self.available_gpus: gpu_model.append(gpu_info['label']) + for gpu_info in self.available_gpus: + gpu_model.append(gpu_info["label"]) self.gpu_row.set_model(gpu_model) self.gpu_row.set_selected(0) self.hardware_expander.add_row(self.gpu_row) - + self.platform_row = Adw.ComboRow() - self.platform_row.set_title(_('Capture Method')) - self.platform_row.set_subtitle(_('Wayland (recommended), X11 (legacy), or KMS (direct)')) + self.platform_row.set_title(_("Capture Method")) + self.platform_row.set_subtitle(_("Wayland (recommended), X11 (legacy), or KMS (direct)")) platform_model = Gtk.StringList() - for p in [_('Automatic'), 'Wayland', 'X11', _('KMS (Direct)')]: platform_model.append(p) + for p in [_("Automatic"), "Wayland", "X11", _("KMS (Direct)")]: + platform_model.append(p) self.platform_row.set_model(platform_model) import os - session_type = os.environ.get('XDG_SESSION_TYPE', '').lower() - self.platform_row.set_selected(1 if session_type == 'wayland' else 2 if session_type == 'x11' else 0) + + session_type = os.environ.get("XDG_SESSION_TYPE", "").lower() + self.platform_row.set_selected(1 if session_type == "wayland" else 2 if session_type == "x11" else 0) self.hardware_expander.add_row(self.platform_row) - + # New "Performance" settings as requested self.codecs_row = Adw.SwitchRow() self.codecs_row.set_title(_("Efficient Codecs (HEVC/AV1)")) self.codecs_row.set_subtitle(_("Enable H.265/AV1 for better quality at lower bitrate (Requires support)")) self.codecs_row.set_active(True) self.hardware_expander.add_row(self.codecs_row) - + self.optimization_row = Adw.ComboRow() self.optimization_row.set_title(_("Optimization Mode")) self.optimization_row.set_subtitle(_("Balance between responsiveness and image quality")) @@ -314,95 +336,93 @@ def setup_ui(self): opt_model.append(_("Balanced (Default)")) opt_model.append(_("High Quality (Best Image)")) self.optimization_row.set_model(opt_model) - self.optimization_row.set_selected(1) # Balanced default + self.optimization_row.set_selected(1) # Balanced default self.hardware_expander.add_row(self.optimization_row) - + self.wifi_row = Adw.SwitchRow() self.wifi_row.set_title(_("Wi-Fi / Unstable Network Mode")) self.wifi_row.set_subtitle(_("Increases error correction (FEC) to prevent glitches")) self.wifi_row.set_active(False) self.hardware_expander.add_row(self.wifi_row) - + game_group.add(self.hardware_expander) - + # --- Audio Group --- audio_group = Adw.PreferencesGroup() - audio_group.set_title(_('Audio')) - audio_group.set_description(_('Sound settings')) + audio_group.set_title(_("Audio")) + audio_group.set_description(_("Sound settings")) # 1. Host Output (Always visible, serves as the "Host" part of Host+Guest) self.audio_output_row = Adw.ComboRow() - self.audio_output_row.set_title(_('Host Audio Output')) - self.audio_output_row.set_subtitle(_('Where YOU will hear the game sound')) - self.audio_output_row.set_icon_name('audio-speakers-symbolic') - self.audio_output_row.connect('notify::selected', self.on_audio_output_changed) + self.audio_output_row.set_title(_("Host Audio Output")) + self.audio_output_row.set_subtitle(_("Where YOU will hear the game sound")) + self.audio_output_row.set_icon_name("audio-speakers-symbolic") + self.audio_output_row.connect("notify::selected", self.on_audio_output_changed) audio_group.add(self.audio_output_row) # 2. Audio Mode ComboRow self.audio_mode_row = Adw.ComboRow() - self.audio_mode_row.set_title(_('Audio Output Mode')) - self.audio_mode_row.set_subtitle(_('Determine where the game sound will be played')) - self.audio_mode_row.set_icon_name('audio-volume-medium-symbolic') - + self.audio_mode_row.set_title(_("Audio Output Mode")) + self.audio_mode_row.set_subtitle(_("Determine where the game sound will be played")) + self.audio_mode_row.set_icon_name("audio-volume-medium-symbolic") + mode_model = Gtk.StringList() - mode_model.append(_('Automatic')) # Index 0 - mode_model.append(_('Guest')) # Index 1 - mode_model.append(_('Host')) # Index 2 - mode_model.append(_('Guest + Host')) # Index 3 - + mode_model.append(_("Automatic")) # Index 0 + mode_model.append(_("Guest")) # Index 1 + mode_model.append(_("Host")) # Index 2 + mode_model.append(_("Guest + Host")) # Index 3 + self.audio_mode_row.set_model(mode_model) - self.audio_mode_row.connect('notify::selected', self.on_audio_mode_changed) + self.audio_mode_row.connect("notify::selected", self.on_audio_mode_changed) audio_group.add(self.audio_mode_row) - - # 3. Mixer (Only if Streaming is Enabled) self.audio_mixer_expander = Adw.ExpanderRow() self.audio_mixer_expander.set_title(_("Audio Mixer (Sources)")) self.audio_mixer_expander.set_subtitle(_("Manage audio sources")) - self.audio_mixer_expander.set_icon_name('audio-volume-high-symbolic') - self.audio_mixer_expander.set_visible(True) # Visibility controlled by switch - + self.audio_mixer_expander.set_icon_name("audio-volume-high-symbolic") + self.audio_mixer_expander.set_visible(True) # Visibility controlled by switch + audio_group.add(self.audio_mixer_expander) - + self.load_audio_outputs() - + self.advanced_expander = Adw.ExpanderRow() - self.advanced_expander.set_title(_('Advanced Settings')) - self.advanced_expander.set_subtitle(_('Input, Network, and Access')) - self.advanced_expander.set_icon_name('preferences-system-symbolic') - + self.advanced_expander.set_title(_("Advanced Settings")) + self.advanced_expander.set_subtitle(_("Input, Network, and Access")) + self.advanced_expander.set_icon_name("preferences-system-symbolic") + self.upnp_row = Adw.SwitchRow() - self.upnp_row.set_title(_('Automatic UPnP')) - self.upnp_row.set_subtitle(_('Automatically configure router ports')) + self.upnp_row.set_title(_("Automatic UPnP")) + self.upnp_row.set_subtitle(_("Automatically configure router ports")) self.upnp_row.set_active(True) self.advanced_expander.add_row(self.upnp_row) self.ipv6_row = Adw.SwitchRow() - self.ipv6_row.set_title(_('Address Family (IPv4 + IPv6)')) - self.ipv6_row.set_subtitle(_('Enable simultaneous IPv4 and IPv6 support on server')) + self.ipv6_row.set_title(_("Address Family (IPv4 + IPv6)")) + self.ipv6_row.set_subtitle(_("Enable simultaneous IPv4 and IPv6 support on server")) self.ipv6_row.set_active(True) self.advanced_expander.add_row(self.ipv6_row) - + self.webui_anyone_row = Adw.SwitchRow() - self.webui_anyone_row.set_title(_('Origin Web UI Allowed (WAN)')) - self.webui_anyone_row.set_subtitle(_('Allows anyone to access the web interface (Anyone may access Web UI)')) + self.webui_anyone_row.set_title(_("Origin Web UI Allowed (WAN)")) + self.webui_anyone_row.set_subtitle(_("Allows anyone to access the web interface (Anyone may access Web UI)")) self.webui_anyone_row.set_active(False) self.advanced_expander.add_row(self.webui_anyone_row) - + self.firewall_row = Adw.ActionRow() self.firewall_row.set_title(_("Configure Firewall (IPv6)")) self.firewall_row.set_subtitle(_("Open TCP/UDP ports required for external connection")) - self.firewall_row.set_icon_name('network-workgroup-symbolic') - + self.firewall_row.set_icon_name("network-workgroup-symbolic") + fw_btn = Gtk.Button(label=_("Configure")) fw_btn.connect("clicked", self.on_configure_firewall_clicked) fw_btn.set_valign(Gtk.Align.CENTER) self.firewall_row.add_suffix(fw_btn) self.advanced_expander.add_row(self.firewall_row) - + game_group.add(self.advanced_expander) - + # Normal-sized header actions (live in the window header bar on the Server # page; the title is dropped there in favour of these). def _icon_label(icon_name, label_widget): @@ -413,42 +433,42 @@ def _icon_label(icon_name, label_widget): return box self.start_button = Gtk.Button() - self.start_button.add_css_class('suggested-action') + self.start_button.add_css_class("suggested-action") sb = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) sb.set_halign(Gtk.Align.CENTER) self.start_btn_spinner = Gtk.Spinner() self.start_btn_spinner.set_visible(False) - self.start_btn_icon = create_icon_widget('media-playback-start-symbolic', size=14) - self.start_btn_label = Gtk.Label(label=_('Start Server')) + self.start_btn_icon = create_icon_widget("media-playback-start-symbolic", size=14) + self.start_btn_label = Gtk.Label(label=_("Start Server")) sb.append(self.start_btn_spinner) sb.append(self.start_btn_icon) sb.append(self.start_btn_label) self.start_button.set_child(sb) - self.start_button.connect('clicked', self.toggle_hosting) + self.start_button.connect("clicked", self.toggle_hosting) self.configure_button = Gtk.Button() - self.configure_button.set_child(_icon_label('emblem-system-symbolic', Gtk.Label(label=_('Configure')))) - self.configure_button.set_tooltip_text(_('Configure Sunshine')) - self.configure_button.connect('clicked', self.open_sunshine_config) + self.configure_button.set_child(_icon_label("emblem-system-symbolic", Gtk.Label(label=_("Configure")))) + self.configure_button.set_tooltip_text(_("Configure Sunshine")) + self.configure_button.connect("clicked", self.open_sunshine_config) self.pin_button = Gtk.Button() - self.pin_button.set_child(_icon_label('network-wireless-symbolic', Gtk.Label(label=_('Generate PIN')))) - self.pin_button.set_tooltip_text(_('Generate a PIN for a guest')) + self.pin_button.set_child(_icon_label("network-wireless-symbolic", Gtk.Label(label=_("Generate PIN")))) + self.pin_button.set_tooltip_text(_("Generate a PIN for a guest")) self.pin_button.set_visible(False) - self.pin_button.connect('clicked', self.open_pin_dialog) + self.pin_button.connect("clicked", self.open_pin_dialog) # Standalone box reparented into the header bar by the main window. self.header_action_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) - self.header_action_box.add_css_class('header-actions') + self.header_action_box.add_css_class("header-actions") for b in (self.start_button, self.configure_button, self.pin_button): self.header_action_box.append(b) self.create_summary_box() - + # View Switcher and Stack self.view_stack = Adw.ViewStack() self.view_stack.set_vexpand(False) - + # 1. Information Page — two columns (mockup 05): info + helper | management. info_left = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) info_left.set_hexpand(True) @@ -456,7 +476,7 @@ def _icon_label(icon_name, label_widget): manage_group = Adw.PreferencesGroup() manage_group.set_title(_("Management")) - manage_group.add_css_class('compact-rows') + manage_group.add_css_class("compact-rows") def _add_management_button(title: str, subtitle: str, icon_name: str, callback: Callable[[Gtk.Widget], None]) -> None: button = Gtk.Button() @@ -537,22 +557,22 @@ def _add_management_button(title: str, subtitle: str, icon_name: str, callback: def _tip(icon_name, title, desc): row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) row.set_hexpand(True) - row.add_css_class('helper-row') + row.add_css_class("helper-row") ic = create_icon_widget(icon_name, size=18) - ic.add_css_class('accent') + ic.add_css_class("accent") ic.set_valign(Gtk.Align.START) ic.set_margin_top(2) row.append(ic) text = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) text.set_hexpand(True) t = Gtk.Label(label=title) - t.add_css_class('caption-heading') + t.add_css_class("caption-heading") t.set_halign(Gtk.Align.START) t.set_wrap(True) text.append(t) d = Gtk.Label(label=desc) - d.add_css_class('caption') - d.add_css_class('dim-label') + d.add_css_class("caption") + d.add_css_class("dim-label") d.set_halign(Gtk.Align.START) d.set_wrap(True) d.set_max_width_chars(34) @@ -561,12 +581,10 @@ def _tip(icon_name, title, desc): return row tips = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=24) - tips.add_css_class('helper-card') + tips.add_css_class("helper-card") tips.set_homogeneous(True) - tips.append(_tip('dialog-password-symbolic', _('Share your PIN or address'), - _('Share your PIN code or the server address so friends can connect.'))) - tips.append(_tip('security-high-symbolic', _('Keep it secure'), - _('Keep your server and network secure. Use a VPN when sharing games.'))) + tips.append(_tip("dialog-password-symbolic", _("Share your PIN or address"), _("Share your PIN code or the server address so friends can connect."))) + tips.append(_tip("security-high-symbolic", _("Keep it secure"), _("Keep your server and network secure. Use a VPN when sharing games."))) info_left.set_valign(Gtk.Align.START) info_right = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) @@ -583,12 +601,12 @@ def _tip(icon_name, title, desc): info_page.append(info_columns) info_page.append(tips) self.view_stack.add_titled_with_icon(info_page, "info", _("Information"), "dialog-information-symbolic") - + # 2. Configuration Page config_page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) config_page.append(game_group) self.view_stack.add_titled_with_icon(config_page, "config", _("Game"), "preferences-system-symbolic") - + # 3. Audio Page audio_page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) audio_page.append(audio_group) @@ -599,15 +617,15 @@ def _tip(icon_name, title, desc): pin_group = Adw.PreferencesGroup() pin_group.set_title(_("Use PIN Code to Connect")) pin_group.set_description(_("Share this with the guest. Local network is required.")) - + pin_row = Adw.ActionRow() pin_row.set_title(_("PIN Code")) pin_row.set_icon_name("dialog-password-symbolic") - + self.pin_display_label = Gtk.Label(label="000000") self.pin_display_label.add_css_class("title-1") self.pin_display_label.set_selectable(True) - + copy_btn = Gtk.Button() copy_btn.set_child(create_icon_widget("edit-copy-symbolic", size=16)) copy_btn.add_css_class("flat") @@ -617,66 +635,62 @@ def _tip(icon_name, title, desc): [Gtk.AccessibleProperty.LABEL, Gtk.AccessibleProperty.DESCRIPTION], [_("Copy PIN"), _("Copy PIN code to clipboard")], ) - copy_btn.connect("clicked", lambda b: self.copy_field_value('pin')) - + copy_btn.connect("clicked", lambda b: self.copy_field_value("pin")) + pin_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) pin_box.append(self.pin_display_label) pin_box.append(copy_btn) - + pin_row.add_suffix(pin_box) pin_group.add(pin_row) pin_page.append(pin_group) self.view_stack.add_titled_with_icon(pin_page, "pin_code", _("PIN Code"), "dialog-password-symbolic") self.pin_page = pin_page self.pin_stack_page = self.view_stack.get_page(pin_page) - self.pin_stack_page.set_visible(True) # Always visible - self.pin_page.set_sensitive(False) # But blocked by default - + self.pin_stack_page.set_visible(True) # Always visible + self.pin_page.set_sensitive(False) # But blocked by default + # Register PIN for updates (it was removed from summary_box) - self.field_widgets['pin'] = { - 'label': self.pin_display_label, - 'real_value': '', - 'revealed': True - } + self.field_widgets["pin"] = {"label": self.pin_display_label, "real_value": "", "revealed": True} view_switcher = create_stack_tab_strip(self.view_stack, _("Server sections")) view_switcher.set_margin_top(12) view_switcher.set_margin_bottom(12) - + # Status card (mockup 05): left state block + right metric tiles. # perf_monitor stays as a hidden data engine (its polling feeds the tiles). self.perf_monitor.set_visible(False) status_card = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=20) - status_card.add_css_class('info-card') + status_card.add_css_class("info-card") # Left: circular icon + status text + subtitle + uptime chip. left_block = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=14) left_block.set_valign(Gtk.Align.CENTER) - circle = Gtk.Box(); circle.add_css_class('status-icon-circle') - circle.append(create_icon_widget('weather-clear-symbolic', size=26)) + circle = Gtk.Box() + circle.add_css_class("status-icon-circle") + circle.append(create_icon_widget("weather-clear-symbolic", size=26)) circle.set_valign(Gtk.Align.CENTER) left_block.append(circle) text_block = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) text_block.set_valign(Gtk.Align.CENTER) head = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) - self.host_status_dot = create_icon_widget( - 'media-record-symbolic', size=12, css_class=['status-dot', 'status-offline']) + self.host_status_dot = create_icon_widget("media-record-symbolic", size=12, css_class=["status-dot", "status-offline"]) self.host_status_dot.set_valign(Gtk.Align.CENTER) head.append(self.host_status_dot) - self.host_status_label = Gtk.Label(label=_('Sunshine offline')) - self.host_status_label.add_css_class('title-4') + self.host_status_label = Gtk.Label(label=_("Sunshine offline")) + self.host_status_label.add_css_class("title-4") self.host_status_label.set_halign(Gtk.Align.START) head.append(self.host_status_label) text_block.append(head) - self.host_status_subtitle = Gtk.Label(label=_('Start the server to stream.')) - self.host_status_subtitle.add_css_class('caption') - self.host_status_subtitle.add_css_class('dim-label') + self.host_status_subtitle = Gtk.Label(label=_("Start the server to stream.")) + self.host_status_subtitle.add_css_class("caption") + self.host_status_subtitle.add_css_class("dim-label") self.host_status_subtitle.set_halign(Gtk.Align.START) text_block.append(self.host_status_subtitle) - self.host_uptime_label = Gtk.Label(label='') - self.host_uptime_label.add_css_class('metric-chip') - self.host_uptime_label.add_css_class('caption') + self.host_uptime_label = Gtk.Label(label="") + self.host_uptime_label.add_css_class("metric-chip") + self.host_uptime_label.add_css_class("caption") self.host_uptime_label.set_halign(Gtk.Align.START) self.host_uptime_label.set_visible(False) text_block.append(self.host_uptime_label) @@ -684,12 +698,9 @@ def _tip(icon_name, title, desc): status_card.append(left_block) # Right: three live metric tiles (hidden until hosting). - self.tile_lat = MetricTile('network-transmit-receive-symbolic', _('Latency'), - 'metric-orange', (1.0, 0.45, 0.0, 1.0)) - self.tile_fps = MetricTile('video-display-symbolic', _('FPS'), - 'metric-green', (0.13, 0.76, 0.42, 1.0)) - self.tile_bw = MetricTile('network-wireless-symbolic', _('Bandwidth'), - 'metric-blue', (0.21, 0.52, 0.89, 1.0)) + self.tile_lat = MetricTile("network-transmit-receive-symbolic", _("Latency"), "metric-orange", (1.0, 0.45, 0.0, 1.0)) + self.tile_fps = MetricTile("video-display-symbolic", _("FPS"), "metric-green", (0.13, 0.76, 0.42, 1.0)) + self.tile_bw = MetricTile("network-wireless-symbolic", _("Bandwidth"), "metric-blue", (0.21, 0.52, 0.89, 1.0)) self.host_metrics_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=24) self.host_metrics_box.set_halign(Gtk.Align.END) self.host_metrics_box.set_hexpand(True) @@ -701,10 +712,8 @@ def _tip(icon_name, title, desc): # Footer security reminder (mockup 05). footer_note = Gtk.Label() - footer_note.set_markup(_( - 'Keep your server and network secure. ' - 'Use a VPN when sharing games.')) - footer_note.add_css_class('dim-label') + footer_note.set_markup(_('Keep your server and network secure. Use a VPN when sharing games.')) + footer_note.add_css_class("dim-label") footer_note.set_halign(Gtk.Align.CENTER) footer_note.set_margin_top(12) @@ -719,7 +728,7 @@ def _tip(icon_name, title, desc): scroll.set_vexpand(True) scroll.set_child(clamp) self.append(scroll) - + self.start_audio_watchdog() def start_audio_watchdog(self): @@ -731,7 +740,8 @@ def _check_audio_state(self): def _get_sunshine_conf_path(self) -> Path: from pathlib import Path - return Path.home() / '.config' / 'big-remoteplay' / 'sunshine' / 'sunshine.conf' + + return Path.home() / ".config" / "big-remoteplay" / "sunshine" / "sunshine.conf" def _get_sunshine_creds(self) -> tuple[str, str] | None: return load_sunshine_credentials(conf_path=self._get_sunshine_conf_path()) @@ -754,49 +764,48 @@ def _ensure_sunshine_config(self) -> None: print(f"Error ensuring sunshine config: {e}") def open_pin_dialog(self, _widget: Gtk.Widget) -> None: - self._ensure_sunshine_config() # Ensure config before trying to use API - + self._ensure_sunshine_config() # Ensure config before trying to use API + # Load saved credentials from the system keyring when available. saved_creds = self._get_sunshine_creds() - saved_user = saved_creds[0] if saved_creds else '' - saved_pass = saved_creds[1] if saved_creds else '' - - dialog = Adw.MessageDialog( - heading=_("Insert PIN"), - body=_("Enter the PIN displayed on the client device (Moonlight).") - ) + saved_user = saved_creds[0] if saved_creds else "" + saved_pass = saved_creds[1] if saved_creds else "" + + dialog = Adw.MessageDialog(heading=_("Insert PIN"), body=_("Enter the PIN displayed on the client device (Moonlight).")) dialog.set_transient_for(self._root_window()) - + # Preferences group holding the fields grp = Adw.PreferencesGroup() - + pin_row = Adw.EntryRow(title=_("PIN")) - + name_row = Adw.EntryRow(title=_("Device Name")) name_row.set_text(socket.gethostname()) - + user_row = Adw.EntryRow(title=_("Sunshine User")) - if saved_user: user_row.set_text(saved_user) - + if saved_user: + user_row.set_text(saved_user) + pass_row = Adw.PasswordEntryRow(title=_("Sunshine Password")) - if saved_pass: pass_row.set_text(saved_pass) - + if saved_pass: + pass_row.set_text(saved_pass) + save_chk = Adw.SwitchRow(title=_("Save Password")) save_chk.set_subtitle(_("Save credentials to the system keyring")) save_chk.set_active(bool(saved_user and saved_pass)) - + grp.add(pin_row) grp.add(name_row) grp.add(user_row) grp.add(pass_row) grp.add(save_chk) - + dialog.set_extra_child(grp) - + dialog.add_response("cancel", _("Cancel")) dialog.add_response("ok", _("Send")) dialog.set_response_appearance("ok", Adw.ResponseAppearance.SUGGESTED) - + def on_response(d, r): if r == "ok": pin = pin_row.get_text().strip() @@ -805,15 +814,16 @@ def on_response(d, r): p = pass_row.get_text().strip() save = save_chk.get_active() - if not pin: return - + if not pin: + return + # Update saved credentials if requested if save and u and p: self._save_sunshine_creds(u, p) - + auth = (u, p) if (u and p) else None success, msg = self.sunshine.send_pin(pin, name=device_name, auth=auth) - + if success: self.show_toast(_("PIN sent successfully")) elif "Authentication Failed" in msg or "Falha de Autenticação" in msg or "401" in msg: @@ -823,153 +833,159 @@ def on_response(d, r): self.prompt_create_user(pin) else: self.show_error_dialog(_("PIN Error"), msg) - + dialog.connect("response", on_response) dialog.present() - + def prompt_create_user(self, pin_retry): - dialog = Adw.MessageDialog( - heading=_("User Not Found"), - body=_("No user has been created in Sunshine. It is necessary to configure a user through the browser.") - ) + dialog = Adw.MessageDialog(heading=_("User Not Found"), body=_("No user has been created in Sunshine. It is necessary to configure a user through the browser.")) dialog.set_transient_for(self._root_window()) dialog.add_response("cancel", _("Cancel")) dialog.add_response("open", _("Open Configuration")) dialog.set_response_appearance("open", Adw.ResponseAppearance.SUGGESTED) - + def on_resp(d, r): if r == "open": open_uri(self, "https://localhost:47990") dialog.connect("response", on_resp) dialog.present() - + def open_create_user_dialog(self, pin_retry): # This seems unused given the web prompt above, but keping for reference or alternative flow - dialog = Adw.MessageDialog( - heading=_("Create Sunshine User"), - body=_("Define a username and password for Sunshine.") - ) + dialog = Adw.MessageDialog(heading=_("Create Sunshine User"), body=_("Define a username and password for Sunshine.")) dialog.set_transient_for(self._root_window()) - + grp = Adw.PreferencesGroup() user_row = Adw.EntryRow(title=_("New User")) user_row.set_text("admin") pass_row = Adw.PasswordEntryRow(title=_("New Password")) - - grp.add(user_row); grp.add(pass_row) + + grp.add(user_row) + grp.add(pass_row) dialog.set_extra_child(grp) - + dialog.add_response("cancel", _("Cancel")) dialog.add_response("save", _("Save and Continue")) dialog.set_response_appearance("save", Adw.ResponseAppearance.SUGGESTED) - + def on_create(d, r): if r == "save": user = user_row.get_text().strip() pwd = pass_row.get_text().strip() - if not user or not pwd: return - + if not user or not pwd: + return + success, msg = self.sunshine.create_user(user, pwd) if success: self.show_toast(_("User created!")) # Save creds since we just created them self._save_sunshine_creds(user, pwd) - + ok, p_msg = self.sunshine.send_pin(pin_retry, auth=(user, pwd)) - if ok: self.show_toast(_("PIN sent successfully")) - else: self.show_error_dialog(_("Error sending PIN after creation"), p_msg) + if ok: + self.show_toast(_("PIN sent successfully")) + else: + self.show_error_dialog(_("Error sending PIN after creation"), p_msg) else: self.show_error_dialog(_("Error creating user"), msg) - + dialog.connect("response", on_create) dialog.present() - + def open_sunshine_auth_dialog(self, pin_to_retry: str, device_name: str | None = None): # Prefill with existing if available (for correction) creds = self._get_sunshine_creds() curr_user = creds[0] if creds else "admin" - - dialog = Adw.MessageDialog( - heading=_("Sunshine Authentication"), - body=_("Sunshine requires login. Enter your credentials (default: admin / password created during installation).") - ) + + dialog = Adw.MessageDialog(heading=_("Sunshine Authentication"), body=_("Sunshine requires login. Enter your credentials (default: admin / password created during installation).")) dialog.set_transient_for(self._root_window()) - + grp = Adw.PreferencesGroup() user_row = Adw.EntryRow(title=_("Username")) user_row.set_text(curr_user) pass_row = Adw.PasswordEntryRow(title=_("Password")) - - grp.add(user_row); grp.add(pass_row) + + grp.add(user_row) + grp.add(pass_row) dialog.set_extra_child(grp) - + dialog.add_response("cancel", _("Cancel")) dialog.add_response("login", _("Confirm")) dialog.set_response_appearance("login", Adw.ResponseAppearance.SUGGESTED) - + def on_auth_resp(d, r): if r == "login": user = user_row.get_text().strip() pwd = pass_row.get_text().strip() - if not user or not pwd: return - + if not user or not pwd: + return + # Tentar novamente com credenciais success, msg = self.sunshine.send_pin(pin_to_retry, name=device_name, auth=(user, pwd)) - if success: + if success: self.show_toast(_("PIN sent successfully")) # Save credentials on success self._save_sunshine_creds(user, pwd) - else: + else: self.show_error_dialog(_("Failed with credentials"), msg) - + dialog.connect("response", on_auth_resp) dialog.present() def create_summary_box(self): self.summary_box = Adw.PreferencesGroup() self.summary_box.set_title(_("Server Information")) - self.summary_box.add_css_class('compact-rows') - self.summary_box.set_visible(True); self.field_widgets = {} - for l, k, i, r in [('Host', 'hostname', 'computer-symbolic', True), ('IPv4', 'ipv4', 'network-wired-symbolic', False), ('IPv6', 'ipv6', 'network-wired-symbolic', False), ('IPv4 Global', 'ipv4_global', 'network-transmit-receive-symbolic', False), ('IPv6 Global', 'ipv6_global', 'network-transmit-receive-symbolic', False)]: self.create_masked_row(l, k, i, r) + self.summary_box.add_css_class("compact-rows") + self.summary_box.set_visible(True) + self.field_widgets = {} + for l, k, i, r in [ + ("Host", "hostname", "computer-symbolic", True), + ("IPv4", "ipv4", "network-wired-symbolic", False), + ("IPv6", "ipv6", "network-wired-symbolic", False), + ("IPv4 Global", "ipv4_global", "network-transmit-receive-symbolic", False), + ("IPv6 Global", "ipv6_global", "network-transmit-receive-symbolic", False), + ]: + self.create_masked_row(l, k, i, r) def on_audio_mode_changed(self, row, param): - if getattr(self, 'loading_settings', False): return - + if getattr(self, "loading_settings", False): + return + idx = row.get_selected() # Mode Guest (1), Automatic (0) or Guest+Host (3) means mixer should be visible show_mixer = idx in [0, 1, 3] self.audio_mixer_expander.set_visible(show_mixer) - + if self.is_hosting: self._run_audio_enforcer() self.save_host_settings() - def load_audio_outputs(self): try: from big_remote_play.utils.audio import AudioManager - if not hasattr(self, 'audio_manager'): + + if not hasattr(self, "audio_manager"): self.audio_manager = AudioManager() - + devices = self.audio_manager.get_passive_sinks() self.audio_devices = devices - + model = Gtk.StringList() if not devices: model.append(_("System Default")) else: for dev in devices: - model.append(dev.get('description', dev.get('name', 'Unknown'))) - + model.append(dev.get("description", dev.get("name", "Unknown"))) + self.audio_output_row.set_model(model) # Try to keep selection if possible, or use config - h = self.config.get('host', {}) + h = self.config.get("host", {}) if not isinstance(h, dict): h = {} - self.audio_output_row.set_selected(h.get('audio_output_idx', 0)) - + self.audio_output_row.set_selected(h.get("audio_output_idx", 0)) + except Exception as e: print(f"Error loading audio: {e}") model = Gtk.StringList() @@ -977,46 +993,49 @@ def load_audio_outputs(self): self.audio_output_row.set_model(model) def on_audio_output_changed(self, row, param): - if getattr(self, 'loading_settings', False): return - + if getattr(self, "loading_settings", False): + return + idx = row.get_selected() if self.audio_devices and 0 <= idx < len(self.audio_devices): - new_sink = self.audio_devices[idx]['name'] + new_sink = self.audio_devices[idx]["name"] else: new_sink = self.audio_manager.get_default_sink() if not new_sink: self.save_host_settings() return - + # If hosting and audio active, need to reconfigure loopback if self.is_hosting and self.audio_mode_row.get_selected() in [0, 1, 3]: - if hasattr(self, 'active_host_sink') and self.active_host_sink != new_sink: + if hasattr(self, "active_host_sink") and self.active_host_sink != new_sink: print(f"Changing host output in real-time to: {new_sink}") self.active_host_sink = new_sink # Restart audio streaming to change loopback destination self.audio_manager.enable_streaming_audio(new_sink) self.show_toast(_("Output changed to: {}").format(new_sink)) - + self.save_host_settings() def on_configure_firewall_clicked(self, _widget): self.show_toast(_("Configuring firewall... (Password may be requested)")) - + try: # Resolve the bundled script (installed /usr/share path, dev fallback) - script_path = paths.script_path('configure_firewall.sh') + script_path = paths.script_path("configure_firewall.sh") if not os.path.exists(script_path): self.show_error_dialog(_("Error"), f"Script not found: {script_path}") return # Run with pkexec - cmd = ['pkexec', script_path] - + cmd = ["pkexec", script_path] + def on_done(ok, out): - if ok: self.show_toast(_("Success: {}").format(out.strip())) - else: self.show_error_dialog(_("Firewall Error"), out if out else _("Execution failed or cancelled.")) - + if ok: + self.show_toast(_("Success: {}").format(out.strip())) + else: + self.show_error_dialog(_("Firewall Error"), out if out else _("Execution failed or cancelled.")) + def run(): try: # No timeout: pkexec blocks on the polkit auth dialog (user-paced) @@ -1024,26 +1043,26 @@ def run(): GLib.idle_add(on_done, res.returncode == 0, res.stdout + res.stderr) except Exception as e: GLib.idle_add(on_done, False, str(e)) - + threading.Thread(target=run, daemon=True).start() - + except Exception as e: self.show_toast(_("Error executing script: {}").format(e)) - def create_masked_row(self, title: str, key: str, icon_name: str = 'text-x-generic-symbolic', default_revealed: bool = False) -> None: + def create_masked_row(self, title: str, key: str, icon_name: str = "text-x-generic-symbolic", default_revealed: bool = False) -> None: row = Adw.ActionRow() row.set_title(title) row.add_prefix(create_icon_widget(icon_name, size=16)) - + box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) box.set_valign(Gtk.Align.CENTER) - - value_lbl = Gtk.Label(label='••••••' if not default_revealed else '') + + value_lbl = Gtk.Label(label="••••••" if not default_revealed else "") value_lbl.set_margin_end(8) - + eye_btn = Gtk.Button() - eye_btn.set_child(create_icon_widget('view-reveal-symbolic' if not default_revealed else 'view-conceal-symbolic', size=16)) - eye_btn.add_css_class('flat') + eye_btn.set_child(create_icon_widget("view-reveal-symbolic" if not default_revealed else "view-conceal-symbolic", size=16)) + eye_btn.add_css_class("flat") eye_btn.update_property( [Gtk.AccessibleProperty.LABEL, Gtk.AccessibleProperty.DESCRIPTION], [ @@ -1052,95 +1071,102 @@ def create_masked_row(self, title: str, key: str, icon_name: str = 'text-x-gener ], ) copy_btn = Gtk.Button() - copy_btn.set_child(create_icon_widget('edit-copy-symbolic', size=16)) - copy_btn.add_css_class('flat') + copy_btn.set_child(create_icon_widget("edit-copy-symbolic", size=16)) + copy_btn.add_css_class("flat") copy_btn.update_property( [Gtk.AccessibleProperty.LABEL, Gtk.AccessibleProperty.DESCRIPTION], [_("Copy {}").format(title), _("Copy the {} value to clipboard").format(title)], ) - - box.append(value_lbl); box.append(eye_btn); box.append(copy_btn) + + box.append(value_lbl) + box.append(eye_btn) + box.append(copy_btn) row.add_suffix(box) self.summary_box.add(row) - - self.field_widgets[key] = {'label': value_lbl, 'real_value': '', 'revealed': default_revealed, 'btn_eye': eye_btn, 'title': title} - eye_btn.connect('clicked', lambda b: self.toggle_field_visibility(key)) - copy_btn.connect('clicked', lambda b: self.copy_field_value(key)) - + + self.field_widgets[key] = {"label": value_lbl, "real_value": "", "revealed": default_revealed, "btn_eye": eye_btn, "title": title} + eye_btn.connect("clicked", lambda b: self.toggle_field_visibility(key)) + copy_btn.connect("clicked", lambda b: self.copy_field_value(key)) + def toggle_field_visibility(self, key: str) -> None: field = self.field_widgets[key] - field['revealed'] = not field['revealed'] - title = field.get('title', _("value")) - field['btn_eye'].set_child(create_icon_widget('view-conceal-symbolic' if field['revealed'] else 'view-reveal-symbolic', size=16)) - field['btn_eye'].update_property( + field["revealed"] = not field["revealed"] + title = field.get("title", _("value")) + field["btn_eye"].set_child(create_icon_widget("view-conceal-symbolic" if field["revealed"] else "view-reveal-symbolic", size=16)) + field["btn_eye"].update_property( [Gtk.AccessibleProperty.LABEL], - [_("Hide {}").format(title) if field['revealed'] else _("Reveal {}").format(title)], + [_("Hide {}").format(title) if field["revealed"] else _("Reveal {}").format(title)], ) - field['label'].set_text(field['real_value'] if field['revealed'] else '••••••') - + field["label"].set_text(field["real_value"] if field["revealed"] else "••••••") + def copy_field_value(self, key): - if val := self.field_widgets[key]['real_value']: - display = Gdk.Display.get_default() - if display is not None: - display.get_clipboard().set(val) - self.show_toast(_("Copied!")) + if val := self.field_widgets[key]["real_value"]: + display = Gdk.Display.get_default() + if display is not None: + display.get_clipboard().set(val) + self.show_toast(_("Copied!")) def toggle_hosting(self, button): self.show_toast(_("Clicked Start Server...")) self.start_button.set_sensitive(False) self.start_btn_spinner.set_visible(True) self.start_btn_spinner.start() - + # Defer action slightly to allow UI to paint GLib.timeout_add(100, self._perform_toggle_hosting) def _perform_toggle_hosting(self): - if self.is_hosting: self.stop_hosting() - else: self.start_hosting() + if self.is_hosting: + self.stop_hosting() + else: + self.start_hosting() return False - + def sync_ui_state(self): if self.is_hosting: self.perf_monitor.set_connection_status("Sunshine", _("Active - Waiting for Connections"), True) self.perf_monitor.start_monitoring() # Reveal the live metric tiles; update the status subtitle. - if hasattr(self, 'host_metrics_box'): + if hasattr(self, "host_metrics_box"): self.host_metrics_box.set_visible(True) - self.host_status_subtitle.set_label(_('Ready to receive connections.')) + self.host_status_subtitle.set_label(_("Ready to receive connections.")) self._show_share_hint() # Status card header: active state + uptime baseline + 1s ticker. - if getattr(self, '_hosting_started_at', None) is None: + if getattr(self, "_hosting_started_at", None) is None: self._hosting_started_at = time.monotonic() - if hasattr(self, 'host_status_label'): - self.host_status_label.set_label(_('Sunshine active')) - self.host_status_dot.remove_css_class('status-offline') - self.host_status_dot.add_css_class('status-online') + if hasattr(self, "host_status_label"): + self.host_status_label.set_label(_("Sunshine active")) + self.host_status_dot.remove_css_class("status-offline") + self.host_status_dot.add_css_class("status-online") self.host_uptime_label.set_visible(True) self._tick_uptime() - if getattr(self, '_uptime_timer_id', None) is None: + if getattr(self, "_uptime_timer_id", None) is None: self._uptime_timer_id = GLib.timeout_add_seconds(1, self._tick_uptime) # Button State: Hosting -> Stop - self.start_btn_label.set_label(_('Stop server')) - if hasattr(self, 'start_btn_icon'): set_icon(self.start_btn_icon, 'media-playback-stop-symbolic') - self.start_button.remove_css_class('suggested-action') - self.start_button.add_css_class('destructive-action') + self.start_btn_label.set_label(_("Stop server")) + if hasattr(self, "start_btn_icon"): + set_icon(self.start_btn_icon, "media-playback-stop-symbolic") + self.start_button.remove_css_class("suggested-action") + self.start_button.add_css_class("destructive-action") self.start_btn_spinner.set_visible(False) self.start_btn_spinner.stop() self.configure_button.set_sensitive(True) - self.configure_button.add_css_class('suggested-action') - for r in [self.game_mode_row, self.hardware_expander, self.streaming_expander, self.advanced_expander]: r.set_sensitive(False) - - if hasattr(self, 'pin_button'): self.pin_button.set_visible(True) - if hasattr(self, 'summary_box'): + self.configure_button.add_css_class("suggested-action") + for r in [self.game_mode_row, self.hardware_expander, self.streaming_expander, self.advanced_expander]: + r.set_sensitive(False) + + if hasattr(self, "pin_button"): + self.pin_button.set_visible(True) + if hasattr(self, "summary_box"): self.summary_box.set_visible(True) self.populate_summary_fields() - + # Enable PIN tab when hosting - if hasattr(self, 'pin_page'): + if hasattr(self, "pin_page"): self.pin_page.set_sensitive(True) self.pin_stack_page.set_icon_name("dialog-password-symbolic") else: @@ -1148,125 +1174,137 @@ def sync_ui_state(self): self.perf_monitor.stop_monitoring() # Hide the metric tiles; the status block shows the offline prompt. - if hasattr(self, 'host_metrics_box'): + if hasattr(self, "host_metrics_box"): self.host_metrics_box.set_visible(False) - self.host_status_subtitle.set_label(_('Start the server to stream.')) + self.host_status_subtitle.set_label(_("Start the server to stream.")) self._hosting_started_at = None uptime_timer_id = self._uptime_timer_id if uptime_timer_id is not None: GLib.source_remove(uptime_timer_id) self._uptime_timer_id = None - if hasattr(self, 'host_status_label'): - self.host_status_label.set_label(_('Sunshine offline')) - self.host_status_dot.remove_css_class('status-online') - self.host_status_dot.add_css_class('status-offline') + if hasattr(self, "host_status_label"): + self.host_status_label.set_label(_("Sunshine offline")) + self.host_status_dot.remove_css_class("status-online") + self.host_status_dot.add_css_class("status-offline") self.host_uptime_label.set_visible(False) - - if hasattr(self, 'pin_button'): self.pin_button.set_visible(False) - if hasattr(self, 'summary_box'): + + if hasattr(self, "pin_button"): + self.pin_button.set_visible(False) + if hasattr(self, "summary_box"): self.summary_box.set_visible(True) self.populate_summary_fields() - + # Disable PIN tab when not hosting - if hasattr(self, 'pin_page'): + if hasattr(self, "pin_page"): self.pin_page.set_sensitive(False) self.pin_stack_page.set_icon_name("changes-prevent-symbolic") - + # Switch to info tab if we were on PIN tab and it's now blocked if self.view_stack.get_visible_child_name() == "pin_code": self.view_stack.set_visible_child_name("info") - + # Button State: Stopped -> Start - self.start_btn_label.set_label(_('Start Server')) - if hasattr(self, 'start_btn_icon'): set_icon(self.start_btn_icon, 'media-playback-start-symbolic') - self.start_button.remove_css_class('destructive-action') - self.start_button.add_css_class('suggested-action') + self.start_btn_label.set_label(_("Start Server")) + if hasattr(self, "start_btn_icon"): + set_icon(self.start_btn_icon, "media-playback-start-symbolic") + self.start_button.remove_css_class("destructive-action") + self.start_button.add_css_class("suggested-action") self.start_btn_spinner.set_visible(False) self.start_btn_spinner.stop() - + self.configure_button.set_sensitive(False) - self.configure_button.remove_css_class('suggested-action') - for r in [self.game_mode_row, self.hardware_expander, self.streaming_expander, self.advanced_expander]: r.set_sensitive(True) + self.configure_button.remove_css_class("suggested-action") + for r in [self.game_mode_row, self.hardware_expander, self.streaming_expander, self.advanced_expander]: + r.set_sensitive(True) def populate_summary_fields(self): import socket, threading from big_remote_play.utils.network import NetworkDiscovery - self.update_field('hostname', socket.gethostname()) - if self.pin_code: self.update_field('pin', self.pin_code) + + self.update_field("hostname", socket.gethostname()) + if self.pin_code: + self.update_field("pin", self.pin_code) ipv4, ipv6 = self.get_ip_addresses() - self.update_field('ipv4', ipv4); self.update_field('ipv6', ipv6) - + self.update_field("ipv4", ipv4) + self.update_field("ipv6", ipv6) + def fetch_globals(): net = NetworkDiscovery() - g_ipv4 = net.get_global_ipv4(); g_ipv6 = net.get_global_ipv6() - + g_ipv4 = net.get_global_ipv4() + g_ipv6 = net.get_global_ipv6() + # Wrap IPv6 in brackets for compatibility - if g_ipv6 and g_ipv6 != "None" and ':' in g_ipv6 and not g_ipv6.startswith('['): + if g_ipv6 and g_ipv6 != "None" and ":" in g_ipv6 and not g_ipv6.startswith("["): g_ipv6 = f"[{g_ipv6}]" - - GLib.idle_add(self.update_field, 'ipv4_global', g_ipv4) - GLib.idle_add(self.update_field, 'ipv6_global', g_ipv6) + + GLib.idle_add(self.update_field, "ipv4_global", g_ipv4) + GLib.idle_add(self.update_field, "ipv6_global", g_ipv6) + threading.Thread(target=fetch_globals, daemon=True).start() - + def update_field(self, key, value): if key in self.field_widgets: - self.field_widgets[key]['real_value'] = value - if self.field_widgets[key]['revealed']: self.field_widgets[key]['label'].set_text(value) + self.field_widgets[key]["real_value"] = value + if self.field_widgets[key]["revealed"]: + self.field_widgets[key]["label"].set_text(value) def start_audio_mixer_refresh(self): self.stop_audio_mixer_refresh() - self.private_audio_apps = set() # Track names of private apps (unchecked in UI) + self.private_audio_apps = set() # Track names of private apps (unchecked in UI) self.mixer_source_id = GLib.timeout_add(2000, self._refresh_audio_mixer_ui) self.enforcer_source_id = GLib.timeout_add(1000, self._run_audio_enforcer) self._refresh_audio_mixer_ui() return True def stop_audio_mixer_refresh(self): - if hasattr(self, 'mixer_source_id'): + if hasattr(self, "mixer_source_id"): GLib.source_remove(self.mixer_source_id) del self.mixer_source_id - if hasattr(self, 'enforcer_source_id'): + if hasattr(self, "enforcer_source_id"): GLib.source_remove(self.enforcer_source_id) del self.enforcer_source_id def _run_audio_enforcer(self): - if not self.is_hosting: return True - if not hasattr(self, 'active_host_sink') or not self.active_host_sink: return True - + if not self.is_hosting: + return True + if not hasattr(self, "active_host_sink") or not self.active_host_sink: + return True + shared_sink = "SunshineGameSink" private_sink = self.active_host_sink - + # Determine behavior based on Audio Mode Selection # 0: Automatic, 1: Guest, 2: Host, 3: Guest + Host mode_idx = self.audio_mode_row.get_selected() - + streaming_enabled = mode_idx in [0, 1, 3] - + # Audio Monitoring logic should_monitor = False - if mode_idx == 3: # Guest + Host + if mode_idx == 3: # Guest + Host should_monitor = True - elif mode_idx == 2: # Host Only - should_monitor = True # Doesn't matter much as everything moves to private_sink + elif mode_idx == 2: # Host Only + should_monitor = True # Doesn't matter much as everything moves to private_sink streaming_enabled = False - elif mode_idx == 1: # Guest Only + elif mode_idx == 1: # Guest Only should_monitor = False - elif mode_idx == 0: # Automatic + elif mode_idx == 0: # Automatic # Check for localhost guest has_localhost = False - if hasattr(self, 'perf_monitor'): - for guest in getattr(self.perf_monitor, '_known_devices', {}).values(): - if guest.get('status') == 'active' or (time.time() - guest.get('last_seen', 0) < 5): - ip = guest.get('ip', '') - if ip in ['127.0.0.1', '::1', 'localhost']: - has_localhost = True; break + if hasattr(self, "perf_monitor"): + for guest in getattr(self.perf_monitor, "_known_devices", {}).values(): + if guest.get("status") == "active" or (time.time() - guest.get("last_seen", 0) < 5): + ip = guest.get("ip", "") + if ip in ["127.0.0.1", "::1", "localhost"]: + has_localhost = True + break should_monitor = not has_localhost - if hasattr(self, 'audio_manager'): + if hasattr(self, "audio_manager"): try: # Update loopback - if not hasattr(self, '_last_monitor_state') or self._last_monitor_state != should_monitor: + if not hasattr(self, "_last_monitor_state") or self._last_monitor_state != should_monitor: self.audio_manager.set_host_monitoring(private_sink, should_monitor) self._last_monitor_state = should_monitor @@ -1278,61 +1316,64 @@ def _run_audio_enforcer(self): apps = self.audio_manager.get_apps() for app in apps: - app_id, name = app['id'], app.get('name', '') - if 'sunshine' in name.lower() or 'loopback' in name.lower() or 'moonlight' in name.lower(): continue - + app_id, name = app["id"], app.get("name", "") + if "sunshine" in name.lower() or "loopback" in name.lower() or "moonlight" in name.lower(): + continue + target = private_sink if (not streaming_enabled or name in self.private_audio_apps) else shared_sink - if app.get('sink_name', '') != target: + if app.get("sink_name", "") != target: print(f"Enforcer: Moving {name} -> {target}") self.audio_manager.move_app(app_id, target) except Exception as e: print(f"Enforcer Error: {e}") return True - - def _refresh_audio_mixer_ui(self): - if not self.audio_mixer_expander.get_visible(): return True - if not hasattr(self, 'audio_manager'): return True - + if not self.audio_mixer_expander.get_visible(): + return True + if not hasattr(self, "audio_manager"): + return True + apps = self.audio_manager.get_apps() seen_ids = set() - - if not hasattr(self, 'mixer_rows'): self.mixer_rows = {} - + + if not hasattr(self, "mixer_rows"): + self.mixer_rows = {} + for app in apps: - app_id = app['id'] - app_name = app.get('name', 'App') + app_id = app["id"] + app_name = app.get("name", "App") seen_ids.add(app_id) - + # Default state: Active (Shared) unless explicitly set to Private - is_shared = (app_name not in self.private_audio_apps) - + is_shared = app_name not in self.private_audio_apps + if app_id in self.mixer_rows: row = self.mixer_rows[app_id] # Avoid signal loop if row.get_active() != is_shared: row.disconnect_by_func(self._on_app_toggled) row.set_active(is_shared) - row.connect('notify::active', self._on_app_toggled, app_name) - + row.connect("notify::active", self._on_app_toggled, app_name) + row.set_subtitle(_("Host + Guest") if is_shared else _("Host Only")) else: row = Adw.SwitchRow() row.set_title(app_name) row.set_subtitle(_("Host + Guest") if is_shared else _("Host Only")) - if app.get('icon'): row.set_icon_name(app['icon']) + if app.get("icon"): + row.set_icon_name(app["icon"]) row.set_active(is_shared) - row.connect('notify::active', self._on_app_toggled, app_name) + row.connect("notify::active", self._on_app_toggled, app_name) self.audio_mixer_expander.add_row(row) self.mixer_rows[app_id] = row - + # Cleanup to_remove = [aid for aid in self.mixer_rows if aid not in seen_ids] for aid in to_remove: self.audio_mixer_expander.remove(self.mixer_rows[aid]) del self.mixer_rows[aid] - + return True def _on_app_toggled(self, row, param, app_name): @@ -1342,116 +1383,113 @@ def _on_app_toggled(self, row, param, app_name): self.private_audio_apps.remove(app_name) else: self.private_audio_apps.add(app_name) - + row.set_subtitle(_("Host + Guest") if is_shared else _("Host Only")) self._run_audio_enforcer() - + def start_hosting(self, b=None): - self.loading_bar.set_visible(True); self.loading_bar.pulse() - + self.loading_bar.set_visible(True) + self.loading_bar.pulse() + try: if self.sunshine.is_running(): self.sunshine.stop() - import time; time.sleep(1) - - self.pin_code = ''.join(random.choices(string.digits, k=6)) + import time + + time.sleep(1) + + self.pin_code = "".join(random.choices(string.digits, k=6)) from big_remote_play.utils.network import NetworkDiscovery + self.stop_pin_listener = NetworkDiscovery().start_pin_listener(self.pin_code, socket.gethostname()) - + mode_idx = self.game_mode_row.get_selected() - + # Always use Desktop mode in apps.json - we launch games directly self.sunshine.update_apps([{"name": "Desktop", "output": "", "cmd": "", "detached": ["sleep infinity"]}]) - + # Store game launch info to execute AFTER Sunshine starts self._game_launch_info = None self._game_processes = [] - + if mode_idx == 1: # Steam idx = self.game_list_row.get_selected() if idx != Gtk.INVALID_LIST_POSITION: - games = self.detected_games.get('Steam', []) + games = self.detected_games.get("Steam", []) if 0 <= idx < len(games): game = games[idx] - app_id = game.get('id', '') - self._game_launch_info = { - 'type': 'steam', - 'app_id': app_id, - 'name': game['name'] - } + app_id = game.get("id", "") + self._game_launch_info = {"type": "steam", "app_id": app_id, "name": game["name"]} elif mode_idx == 2: # Lutris idx = self.game_list_row.get_selected() if idx != Gtk.INVALID_LIST_POSITION: - games = self.detected_games.get('Lutris', []) + games = self.detected_games.get("Lutris", []) if 0 <= idx < len(games): game = games[idx] - self._game_launch_info = { - 'type': 'lutris', - 'cmd': game['cmd'], - 'name': game['name'] - } + self._game_launch_info = {"type": "lutris", "cmd": game["cmd"], "name": game["name"]} elif mode_idx == 3: # Custom App - name = self.custom_name_entry.get_text(); cmd = self.custom_cmd_entry.get_text() + name = self.custom_name_entry.get_text() + cmd = self.custom_cmd_entry.get_text() if name and cmd: - self._game_launch_info = { - 'type': 'custom', - 'cmd': cmd, - 'name': name - } - + self._game_launch_info = {"type": "custom", "cmd": cmd, "name": name} + # Determine FPS fps_idx = self.fps_row.get_selected() - fps_map = {0: 30, 1: 60, 2: 120, 3: 144, 4: 60} # Custom defaults to 60 + fps_map = {0: 30, 1: 60, 2: 120, 3: 144, 4: 60} # Custom defaults to 60 fps = fps_map.get(fps_idx, 60) - + # Determine Bitrate (Kbps) # Sunshine uses min_bitrate in config, but we can pass 'bitrate' (CBR target) here if needed. # However, Sunshine v0.20+ generally prefers min_bitrate in config for VBR floor. # If bandwidth_row > 0, set bitrate. Else default. bw_mbps = self.bandwidth_row.get_value() - bitrate = int(bw_mbps * 1000) if bw_mbps > 0 else 20000 # Default 20Mbps if unlim - + bitrate = int(bw_mbps * 1000) if bw_mbps > 0 else 20000 # Default 20Mbps if unlim + selected_gpu_info = self.available_gpus[self.gpu_row.get_selected()] sunshine_config = { - 'sunshine_name': socket.gethostname(), - 'encoder': selected_gpu_info['encoder'], 'bitrate': bitrate, 'fps': fps, - 'videocodec': 'h264', 'gamepad': 'x360', 'min_threads': 4, - 'min_log_level': 2, # Info level to see connections - 'channels': 2, # Force Stereo - 'pkey': 'pkey.pem', 'cert': 'cert.pem', - 'upnp': 'enabled' if self.upnp_row.get_active() else 'disabled', - 'address_family': 'both' if self.ipv6_row.get_active() else 'ipv4', - 'origin_web_ui_allowed': 'wan' if self.webui_anyone_row.get_active() else 'lan', - 'webserver': '0.0.0.0', - 'enable_api_endpoints': 'true', - 'port': '47989' + "sunshine_name": socket.gethostname(), + "encoder": selected_gpu_info["encoder"], + "bitrate": bitrate, + "fps": fps, + "videocodec": "h264", + "gamepad": "x360", + "min_threads": 4, + "min_log_level": 2, # Info level to see connections + "channels": 2, # Force Stereo + "pkey": "pkey.pem", + "cert": "cert.pem", + "upnp": "enabled" if self.upnp_row.get_active() else "disabled", + "address_family": "both" if self.ipv6_row.get_active() else "ipv4", + "origin_web_ui_allowed": "wan" if self.webui_anyone_row.get_active() else "lan", + "webserver": "0.0.0.0", + "enable_api_endpoints": "true", + "port": "47989", } - # Audio Configuration # Audio Configuration - ALWAYS setup PulseAudio infrastructure # This allows toggling streaming on/off without restarting server or destroying sinks - sunshine_config['audio'] = 'pulse' - + sunshine_config["audio"] = "pulse" + # Identify Host Sink host_sink_idx = self.audio_output_row.get_selected() - if hasattr(self, 'audio_devices') and 0 <= host_sink_idx < len(self.audio_devices): - host_sink = self.audio_devices[host_sink_idx]['name'] + if hasattr(self, "audio_devices") and 0 <= host_sink_idx < len(self.audio_devices): + host_sink = self.audio_devices[host_sink_idx]["name"] else: host_sink = self.audio_manager.get_default_sink() - + # PROTECT AGAINST SELF-LOOP IF DEFAULT SINK IS STILL VIRTUAL if host_sink and (host_sink == "SunshineGameSink" or self.audio_manager.is_virtual(host_sink)): print(f"WARNING: Host sink '{host_sink}' is virtual. finding fallback hardware sink.") hw_sinks = self.audio_manager.get_passive_sinks() if hw_sinks: - host_sink = hw_sinks[0]['name'] # or 'id' depending on get_passive_sinks return + host_sink = hw_sinks[0]["name"] # or 'id' depending on get_passive_sinks return # usually get_passive_sinks returns dict with 'name' (id) and description # wait, check utils/audio.py get_passive_sinks returns dict with 'name' -> PA Name - + if not host_sink: print("WARNING: No hardware audio sink found, disabling audio streaming.") - sunshine_config['audio'] = 'none' + sunshine_config["audio"] = "none" else: # Store it for the enforcer self.active_host_sink = host_sink @@ -1463,253 +1501,238 @@ def start_hosting(self, b=None): # If mode is Guest (1), guest_only is True # If mode is Guest+Host (3), guest_only is False # If mode is Automatic (0), we start with guest_only=False (will be auto-muted by enforcer if needed) - guest_only = (mode_idx == 1) - + guest_only = mode_idx == 1 + if self.audio_manager.enable_streaming_audio(host_sink, guest_only=guest_only): - sunshine_config['audio_sink'] = "SunshineGameSink" - + sunshine_config["audio_sink"] = "SunshineGameSink" + self._last_monitor_state = not guest_only self.start_audio_mixer_refresh() GLib.timeout_add(500, lambda: (self.audio_manager.set_default_sink(host_sink), self.show_toast(_("Host output restored: {}").format(host_sink)))[1]) else: + print("Failed to enable streaming sinks, falling back to default") + self.show_toast(_("Failed to create Virtual Audio")) + # Fallback to none if creation failed + sunshine_config["audio"] = "none" + self.audio_manager.disable_streaming_audio(None) - - print("Failed to enable streaming sinks, falling back to default") - self.show_toast(_("Failed to create Virtual Audio")) - # Fallback to none if creation failed - sunshine_config['audio'] = 'none' - self.audio_manager.disable_streaming_audio(None) - - platforms = ['auto', 'wayland', 'x11', 'kms'] + platforms = ["auto", "wayland", "x11", "kms"] platform = platforms[self.platform_row.get_selected()] - if platform == 'auto': - session = os.environ.get('XDG_SESSION_TYPE', '').lower() - platform = 'wayland' if session == 'wayland' else 'x11' - sunshine_config['platform'] = platform - + if platform == "auto": + session = os.environ.get("XDG_SESSION_TYPE", "").lower() + platform = "wayland" if session == "wayland" else "x11" + sunshine_config["platform"] = platform # Set output_name if a specific monitor is selected, others set to None to remove from config - sunshine_config['output_name'] = None + sunshine_config["output_name"] = None monitor_idx = self.monitor_row.get_selected() if 0 < monitor_idx < len(self.available_monitors): mon_name = self.available_monitors[monitor_idx][1] - if mon_name != 'auto': - sunshine_config['output_name'] = mon_name - + if mon_name != "auto": + sunshine_config["output_name"] = mon_name + # Set adapter_name only if specific adapter chosen - sunshine_config['adapter_name'] = None - if selected_gpu_info['encoder'] == 'vaapi' and selected_gpu_info['adapter'] != 'auto': - sunshine_config['adapter_name'] = selected_gpu_info['adapter'] - - if platform == 'wayland': - sunshine_config['wayland.display'] = os.environ.get('WAYLAND_DISPLAY', 'wayland-0') - if platform == 'x11' and (not sunshine_config['output_name']): - sunshine_config['output_name'] = ':0' - + sunshine_config["adapter_name"] = None + if selected_gpu_info["encoder"] == "vaapi" and selected_gpu_info["adapter"] != "auto": + sunshine_config["adapter_name"] = selected_gpu_info["adapter"] + + if platform == "wayland": + sunshine_config["wayland.display"] = os.environ.get("WAYLAND_DISPLAY", "wayland-0") + if platform == "x11" and (not sunshine_config["output_name"]): + sunshine_config["output_name"] = ":0" + self.sunshine.configure(sunshine_config) success, msg = self.sunshine.start() - + if success: self.is_hosting = True self.sync_ui_state() - self.show_toast(_('Server started')) - + self.show_toast(_("Server started")) + # DIRECT LAUNCH: Open game/platform immediately self._launch_game_direct() else: self.is_hosting = False self.sync_ui_state() self.show_start_error_dialog(msg) - + except Exception as e: - self.show_error_dialog(_('Error'), str(e)) + self.show_error_dialog(_("Error"), str(e)) self.is_hosting = False - self.sync_ui_state() # Revert state - finally: + self.sync_ui_state() # Revert state + finally: self.loading_bar.set_visible(False) - self.start_button.set_sensitive(True) # Re-enable button + self.start_button.set_sensitive(True) # Re-enable button self.start_btn_spinner.stop() self.start_btn_spinner.set_visible(False) - + def _launch_game_direct(self): """Directly launch game/platform via subprocess - radical approach""" - info = getattr(self, '_game_launch_info', None) + info = getattr(self, "_game_launch_info", None) if not info: print("Game Mode: Desktop (no game to launch)") return - - if not hasattr(self, '_game_processes'): + + if not hasattr(self, "_game_processes"): self._game_processes = [] - + env = os.environ.copy() - + try: - if info['type'] == 'steam': - app_id = info['app_id'] - game_name = info['name'] + if info["type"] == "steam": + app_id = info["app_id"] + game_name = info["name"] print(f"DIRECT LAUNCH: Steam Big Picture + {game_name} (ID: {app_id})") - + # 1. Open Steam Big Picture Mode - p1 = subprocess.Popen( - ['steam', 'steam://open/bigpicture'], - env=env, start_new_session=True, - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL - ) + p1 = subprocess.Popen(["steam", "steam://open/bigpicture"], env=env, start_new_session=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) self._game_processes.append(p1) self.show_toast(_("Opening Steam Big Picture...")) - + # 2. Launch the game after a delay (give Big Picture time to open) def _delayed_game_launch(): import time + time.sleep(4) try: - p2 = subprocess.Popen( - ['steam', f'steam://rungameid/{app_id}'], - env=env, start_new_session=True, - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL - ) + p2 = subprocess.Popen(["steam", f"steam://rungameid/{app_id}"], env=env, start_new_session=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) self._game_processes.append(p2) GLib.idle_add(self.show_toast, _("Launching {}...").format(game_name)) print(f"DIRECT LAUNCH: Game {game_name} launched (PID: {p2.pid})") except Exception as e: print(f"Error launching game: {e}") GLib.idle_add(self.show_toast, _("Error launching game: {}").format(e)) - + threading.Thread(target=_delayed_game_launch, daemon=True).start() - - elif info['type'] == 'lutris': - cmd = info['cmd'] - game_name = info['name'] + + elif info["type"] == "lutris": + cmd = info["cmd"] + game_name = info["name"] print(f"DIRECT LAUNCH: Lutris - {game_name} ({cmd})") argv = _split_launch_command(cmd) if not argv: self.show_toast(_("Invalid launch command")) return - - p = subprocess.Popen( - argv, - env=env, start_new_session=True, - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL - ) + + p = subprocess.Popen(argv, env=env, start_new_session=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) self._game_processes.append(p) self.show_toast(_("Launching {}...").format(game_name)) - - elif info['type'] == 'custom': - cmd = info['cmd'] - game_name = info['name'] + + elif info["type"] == "custom": + cmd = info["cmd"] + game_name = info["name"] print(f"DIRECT LAUNCH: Custom - {game_name} ({cmd})") argv = _split_launch_command(cmd) if not argv: self.show_toast(_("Invalid launch command")) return - - p = subprocess.Popen( - argv, - env=env, start_new_session=True, - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL - ) + + p = subprocess.Popen(argv, env=env, start_new_session=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) self._game_processes.append(p) self.show_toast(_("Launching {}...").format(game_name)) - + except Exception as e: print(f"Error in _launch_game_direct: {e}") self.show_toast(_("Error launching game: {}").format(e)) - + def _stop_game_direct(self): """Kill any directly launched game processes""" - info = getattr(self, '_game_launch_info', None) - + info = getattr(self, "_game_launch_info", None) + # Close Steam Big Picture if we opened it - if info and info.get('type') == 'steam': + if info and info.get("type") == "steam": try: - subprocess.Popen( - ['steam', 'steam://close/bigpicture'], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL - ) + subprocess.Popen(["steam", "steam://close/bigpicture"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) print("Closing Steam Big Picture") - except Exception: pass - + except Exception: + pass + # Kill tracked processes - for p in getattr(self, '_game_processes', []): + for p in getattr(self, "_game_processes", []): try: if p.poll() is None: # Still running import signal + os.killpg(os.getpgid(p.pid), signal.SIGTERM) - except Exception: pass - + except Exception: + pass + self._game_processes = [] self._game_launch_info = None def stop_hosting(self, b=None) -> None: self.show_toast(_("Stopping server...")) - self.loading_bar.set_visible(True); self.loading_bar.pulse() - + self.loading_bar.set_visible(True) + self.loading_bar.pulse() + # Stop directly launched games FIRST self._stop_game_direct() # Update mixer visibility based on mode self.audio_mixer_expander.set_visible(self.audio_mode_row.get_selected() in [0, 1, 3]) self.stop_audio_mixer_refresh() - - if hasattr(self, 'stop_pin_listener') and self.stop_pin_listener: - try: self.stop_pin_listener() - except Exception: pass + + if hasattr(self, "stop_pin_listener") and self.stop_pin_listener: + try: + self.stop_pin_listener() + except Exception: + pass self.stop_pin_listener = None - + # Restore audio configuration - if hasattr(self, 'audio_manager') and hasattr(self, 'active_host_sink') and self.active_host_sink: + if hasattr(self, "audio_manager") and hasattr(self, "active_host_sink") and self.active_host_sink: try: self.audio_manager.disable_streaming_audio(self.active_host_sink) except Exception as e: print(f"Error restoring audio: {e}") - + try: self.sunshine.stop() except Exception as e: print(f"Error stopping Sunshine: {e}") - + self.is_hosting = False self.sync_ui_state() self.loading_bar.set_visible(False) - self.start_button.set_sensitive(True) + self.start_button.set_sensitive(True) self.start_btn_spinner.stop() self.start_btn_spinner.set_visible(False) - self.show_toast(_('Server stopped')) - + self.show_toast(_("Server stopped")) + def _show_share_hint(self): """Once hosting, tell the host exactly what to give friends (LAN address).""" + def work(): ipv4, _ipv6 = self.get_ip_addresses() GLib.idle_add(self._set_share_hint, ipv4) + threading.Thread(target=work, daemon=True).start() def _set_share_hint(self, ipv4): - if not self.is_hosting or not hasattr(self, 'host_status_subtitle'): + if not self.is_hosting or not hasattr(self, "host_status_subtitle"): return False if ipv4 and ipv4 != "None": # Plain next step: what to hand a friend so they can connect. - self.host_status_subtitle.set_label( - _('Friends connect to this PC at {} — or use a PIN code.').format(ipv4)) + self.host_status_subtitle.set_label(_("Friends connect to this PC at {} — or use a PIN code.").format(ipv4)) return False def _tick_uptime(self): """Refresh the status-card uptime chip ("Server active for HH:MM:SS").""" - started = getattr(self, '_hosting_started_at', None) - if started is None or not hasattr(self, 'host_uptime_label'): + started = getattr(self, "_hosting_started_at", None) + if started is None or not hasattr(self, "host_uptime_label"): self._uptime_timer_id = None return False elapsed = int(time.monotonic() - started) hh, rem = divmod(elapsed, 3600) mm, ss = divmod(rem, 60) - self.host_uptime_label.set_label( - _('Server active for {:02d}:{:02d}:{:02d}').format(hh, mm, ss)) + self.host_uptime_label.set_label(_("Server active for {:02d}:{:02d}:{:02d}").format(hh, mm, ss)) self._refresh_metric_tiles() return True def _refresh_metric_tiles(self): """Pull live values from the perf monitor into the status-card tiles.""" - chart = getattr(self.perf_monitor, 'chart', None) - if chart is None or not hasattr(self, 'tile_lat'): + chart = getattr(self.perf_monitor, "chart", None) + if chart is None or not hasattr(self, "tile_lat"): return history = list(chart._history) if not history: @@ -1719,120 +1742,128 @@ def norm(values, ceiling): top = max(1.0, ceiling) return [v / top for v in values] - self.tile_lat.update( - norm([p.latency for p in history], chart.max_latency), chart._cur_latency_text) - self.tile_fps.update( - norm([p.fps for p in history], chart.max_fps), chart._cur_fps_text) - self.tile_bw.update( - norm([p.bandwidth for p in history], chart.max_bandwidth), chart._cur_bw_text) + self.tile_lat.update(norm([p.latency for p in history], chart.max_latency), chart._cur_latency_text) + self.tile_fps.update(norm([p.fps for p in history], chart.max_fps), chart._cur_fps_text) + self.tile_bw.update(norm([p.bandwidth for p in history], chart.max_bandwidth), chart._cur_bw_text) def update_status_info(self): - sunshine_running = self.check_process_running('sunshine') - + sunshine_running = self.check_process_running("sunshine") + # If it was supposed to be hosting but sunshine is not running if self.is_hosting and not sunshine_running: - self.is_hosting = False - self.sync_ui_state() - self.show_toast(_("Sunshine stopped unexpectedly")) - return True + self.is_hosting = False + self.sync_ui_state() + self.show_toast(_("Sunshine stopped unexpectedly")) + return True - if not self.is_hosting: return True + if not self.is_hosting: + return True - sunshine_val = getattr(self, 'sunshine_val', None) + sunshine_val = getattr(self, "sunshine_val", None) if sunshine_val is not None: status_text = _("Online") if sunshine_running else _("Stopped") color = "#2ec27e" if sunshine_running else "#e01b24" sunshine_val.set_markup(f'{status_text}') ipv4, ipv6 = self.get_ip_addresses() - self.update_field('ipv4', ipv4); self.update_field('ipv6', ipv6) + self.update_field("ipv4", ipv4) + self.update_field("ipv6", ipv6) return True - + def check_process_running(self, process_name): try: subprocess.check_output(["pgrep", "-x", process_name], timeout=5) return True - except Exception: return False - + except Exception: + return False + def get_ip_addresses(self): ipv4 = ipv6 = "None" try: with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: - s.connect(("1.1.1.1", 80)); ipv4 = s.getsockname()[0] - except Exception: pass + s.connect(("1.1.1.1", 80)) + ipv4 = s.getsockname()[0] + except Exception: + pass try: - res = subprocess.run(['ip', '-j', 'addr'], capture_output=True, text=True, timeout=5) + res = subprocess.run(["ip", "-j", "addr"], capture_output=True, text=True, timeout=5) if res.returncode == 0: for iface in json.loads(res.stdout): - name = iface['ifname'] + name = iface["ifname"] # Pular interfaces de loopback, desligadas ou virtuais conhecidas - if name == 'lo' or 'UP' not in iface['flags']: continue - if any(x in name for x in ['docker', 'veth', 'virbr', 'vboxnet', 'tailscale', 'zerotier', 'br-']): continue - for addr in iface.get('addr_info', []): - if addr['family'] == 'inet': - if ipv4 == "None": ipv4 = addr['local'] - elif addr['family'] == 'inet6': + if name == "lo" or "UP" not in iface["flags"]: + continue + if any(x in name for x in ["docker", "veth", "virbr", "vboxnet", "tailscale", "zerotier", "br-"]): + continue + for addr in iface.get("addr_info", []): + if addr["family"] == "inet": + if ipv4 == "None": + ipv4 = addr["local"] + elif addr["family"] == "inet6": # Prioritize global but accept link-local - if addr.get('scope') == 'global': - ipv6 = addr['local'] - break # Found global, stop searching for this interface + if addr.get("scope") == "global": + ipv6 = addr["local"] + break # Found global, stop searching for this interface elif ipv6 == "None": # Fallback to link-local with scope ID ipv6 = f"{addr['local']}%{name}" - except Exception: pass + except Exception: + pass - # No longer wrapping in brackets as per user feedback - + return ipv4, ipv6 - + def show_start_error_dialog(self, message): - if not message: message = _("Check logs for details.") - + if not message: + message = _("Check logs for details.") + body = _("Sunshine failed to start.\n\nError: {}\n\nIf this is a dependency issue (missing libraries), try the 'Fix Dependencies' button.").format(message) - - # Use simple MessageDialog constructor for custom response handling if needed, + + # Use simple MessageDialog constructor for custom response handling if needed, # or simplified new() if we connect signal later. dialog = Adw.MessageDialog(heading=_("Server Failed to Start"), body=body) dialog.set_transient_for(self._root_window()) dialog.add_response("cancel", _("Close")) dialog.add_response("logs", _("View Logs")) dialog.add_response("fix", _("Fix Dependencies")) - + dialog.set_response_appearance("fix", Adw.ResponseAppearance.SUGGESTED) - + def on_response(d, r): if r == "logs": try: - log_path = self.sunshine.config_dir / 'sunshine.log' + log_path = self.sunshine.config_dir / "sunshine.log" open_path(self, log_path) - except Exception: pass + except Exception: + pass elif r == "fix": self.open_advanced_settings() - + dialog.connect("response", on_response) dialog.present() def show_error_dialog(self, title, message): dialog = Adw.MessageDialog.new(self._root_window(), title, message) - dialog.add_response('ok', 'OK') + dialog.add_response("ok", "OK") dialog.present() - + def show_toast(self, message): - show_toast = getattr(self.get_root(), 'show_toast', None) - if callable(show_toast): show_toast(message) - else: print(f"Toast: {message}") - + show_toast = getattr(self.get_root(), "show_toast", None) + if callable(show_toast): + show_toast(message) + else: + print(f"Toast: {message}") + def open_sunshine_config(self, button): # Gtk.show_uri (not xdg-open) so the browser is raised under Wayland. - open_uri(self, 'https://localhost:47990') + open_uri(self, "https://localhost:47990") # --- Paired devices (Sunshine clients API) --------------------------- def open_paired_devices_dialog(self, _widget): dialog = Adw.MessageDialog( heading=_("Paired Devices"), - body=_("Devices paired with Sunshine. Disable to block access without " - "re-pairing; remove to revoke (a new PIN will be required)."), + body=_("Devices paired with Sunshine. Disable to block access without re-pairing; remove to revoke (a new PIN will be required)."), ) dialog.set_transient_for(self._root_window()) dialog.add_response("close", _("Close")) @@ -1854,6 +1885,7 @@ def open_paired_devices_dialog(self, _widget): def on_response(d, r): if r == "unpair_all": self._unpair_all_devices() + dialog.connect("response", on_response) self._devices_dialog_group = group @@ -1864,6 +1896,7 @@ def _refresh_paired_devices(self): if getattr(self, "_devices_dialog_group", None) is None: return import threading + auth = self._get_sunshine_creds() def work(): @@ -1915,6 +1948,7 @@ def _populate_paired_devices(self, clients): def _on_device_toggle(self, switch, _pspec, uuid): enabled = switch.get_active() import threading + auth = self._get_sunshine_creds() def work(): @@ -1936,6 +1970,7 @@ def _on_device_remove(self, _button, uuid, name): def on_resp(d, r): if r == "remove": import threading + auth = self._get_sunshine_creds() def work(): @@ -1949,12 +1984,12 @@ def work(): def _unpair_all_devices(self): import threading + auth = self._get_sunshine_creds() def work(): ok = self.sunshine.unpair_all_clients(auth=auth) - GLib.idle_add(self.show_toast, - _("Devices updated") if ok else _("Operation failed")) + GLib.idle_add(self.show_toast, _("Devices updated") if ok else _("Operation failed")) threading.Thread(target=work, daemon=True).start() @@ -2015,6 +2050,7 @@ def _refresh_logs(self): return tv.get_buffer().set_text(_("Loading…")) import threading + auth = self._get_sunshine_creds() def work(): @@ -2027,8 +2063,7 @@ def _set_logs_text(self, text): tv = getattr(self, "_logs_textview", None) if tv is None: return False - tv.get_buffer().set_text( - text or _("No logs available (Sunshine not running or no credentials).")) + tv.get_buffer().set_text(text or _("No logs available (Sunshine not running or no credentials).")) return False def _copy_logs(self): @@ -2046,15 +2081,12 @@ def _copy_logs(self): def open_game_library_dialog(self, _widget): if not self.sunshine.is_running(): - self.show_error_dialog( - _("Server Not Running"), - _("Start the server first to manage the game library.")) + self.show_error_dialog(_("Server Not Running"), _("Start the server first to manage the game library.")) return dialog = Adw.MessageDialog( heading=_("Game Library"), - body=_("Games offered to guests in Moonlight. Add your detected games " - "or remove entries."), + body=_("Games offered to guests in Moonlight. Add your detected games or remove entries."), ) dialog.set_transient_for(self._root_window()) dialog.add_response("close", _("Close")) @@ -2092,6 +2124,7 @@ def _refresh_game_library(self): if getattr(self, "_library_group", None) is None: return import threading + auth = self._get_sunshine_creds() def work(): @@ -2138,6 +2171,7 @@ def _populate_game_library(self, apps): def _on_library_remove(self, _button, index): import threading + auth = self._get_sunshine_creds() def work(): @@ -2148,6 +2182,7 @@ def work(): def _add_detected_games(self): import threading + auth = self._get_sunshine_creds() def work(): @@ -2184,9 +2219,7 @@ def _after_library_change(self, ok): def open_host_browse_dialog(self): if not self.sunshine.is_running(): - self.show_error_dialog( - _("Server Not Running"), - _("Start the server first to browse the host filesystem.")) + self.show_error_dialog(_("Server Not Running"), _("Start the server first to browse the host filesystem.")) return dialog = Adw.MessageDialog(heading=_("Select Executable"), body="") @@ -2206,6 +2239,7 @@ def open_host_browse_dialog(self): dialog.set_extra_child(scroll) import os + self._browse_load(os.path.expanduser("~")) dialog.present() @@ -2213,6 +2247,7 @@ def _browse_load(self, path: str) -> None: if getattr(self, "_browse_group", None) is None: return import threading + auth = self._get_sunshine_creds() def work() -> None: @@ -2318,6 +2353,7 @@ def _browse_pick(self, path: str) -> None: def open_advanced_settings(self, _widget=None): """Full Sunshine server tuning + library fix, opened from the task itself.""" from big_remote_play.ui.sunshine_preferences import SunshinePreferencesPage + win = Adw.PreferencesWindow() win.set_transient_for(self._root_window()) win.set_modal(True) @@ -2328,9 +2364,7 @@ def open_advanced_settings(self, _widget=None): def open_password_dialog(self, _widget): dialog = Adw.MessageDialog( heading=_("Server Password"), - body=_("Set the username and password used to manage the Sunshine " - "server. If you forgot the current password, switch on " - "“I forgot the current password” to reset it."), + body=_("Set the username and password used to manage the Sunshine server. If you forgot the current password, switch on “I forgot the current password” to reset it."), ) dialog.set_transient_for(self._root_window()) @@ -2349,8 +2383,7 @@ def open_password_dialog(self, _widget): new_user_row.set_text(default_user) new_pass_row = Adw.PasswordEntryRow(title=_("New Password")) confirm_row = Adw.PasswordEntryRow(title=_("Confirm New Password")) - for r in (forgot_row, cur_user_row, cur_pass_row, - new_user_row, new_pass_row, confirm_row): + for r in (forgot_row, cur_user_row, cur_pass_row, new_user_row, new_pass_row, confirm_row): grp.add(r) dialog.set_extra_child(grp) @@ -2358,6 +2391,7 @@ def on_forgot(switch, _pspec): reset = switch.get_active() cur_user_row.set_sensitive(not reset) cur_pass_row.set_sensitive(not reset) + forgot_row.connect("notify::active", on_forgot) dialog.add_response("cancel", _("Cancel")) @@ -2415,109 +2449,116 @@ def on_game_mode_changed(self, row, param): self.custom_app_expander.set_visible(idx == 3) self.custom_app_expander.set_expanded(idx == 3) if idx in [1, 2]: - plat = {1: 'Steam', 2: 'Lutris'}[idx] + plat = {1: "Steam", 2: "Lutris"}[idx] self.platform_games_expander.set_title(f"{plat} Games") self.populate_game_list(idx) - + def populate_game_list(self, mode_idx): - plat = {1: 'Steam', 2: 'Lutris'}.get(mode_idx) - if not plat: return + plat = {1: "Steam", 2: "Lutris"}.get(mode_idx) + if not plat: + return if not self.detected_games[plat]: - if plat == 'Steam': self.detected_games['Steam'] = self.game_detector.detect_steam() - elif plat == 'Lutris': self.detected_games['Lutris'] = self.game_detector.detect_lutris() + if plat == "Steam": + self.detected_games["Steam"] = self.game_detector.detect_steam() + elif plat == "Lutris": + self.detected_games["Lutris"] = self.game_detector.detect_lutris() games = self.detected_games[plat] new_model = Gtk.StringList() - if not games: new_model.append(f"No games found on {plat}") + if not games: + new_model.append(f"No games found on {plat}") else: - for game in games: new_model.append(game['name']) + for game in games: + new_model.append(game["name"]) self.game_list_row.set_model(new_model) def save_host_settings(self, *args): - if getattr(self, 'loading_settings', False): return - h = self.config.get('host', {}) + if getattr(self, "loading_settings", False): + return + h = self.config.get("host", {}) if not isinstance(h, dict): h = {} - h.update({ - 'mode_idx': self.game_mode_row.get_selected(), - 'game_list_idx': self.game_list_row.get_selected(), - 'custom_name': self.custom_name_entry.get_text(), - 'custom_cmd': self.custom_cmd_entry.get_text(), - - # New Separate Settings - 'resolution_idx': self.resolution_row.get_selected(), - 'fps_idx': self.fps_row.get_selected(), - 'bandwidth_mbps': self.bandwidth_row.get_value(), - 'monitor_idx': self.monitor_row.get_selected(), - 'gpu_idx': self.gpu_row.get_selected(), - 'platform_idx': self.platform_row.get_selected(), - 'audio_mode': self.audio_mode_row.get_selected(), - 'audio_output_idx': self.audio_output_row.get_selected(), - 'upnp': self.upnp_row.get_active(), - - 'ipv6': self.ipv6_row.get_active(), - 'webui_anyone': self.webui_anyone_row.get_active(), - # New settings - 'efficient_codecs': self.codecs_row.get_active(), - 'optimization_mode': self.optimization_row.get_selected(), - 'wifi_mode': self.wifi_row.get_active() - }) + h.update( + { + "mode_idx": self.game_mode_row.get_selected(), + "game_list_idx": self.game_list_row.get_selected(), + "custom_name": self.custom_name_entry.get_text(), + "custom_cmd": self.custom_cmd_entry.get_text(), + # New Separate Settings + "resolution_idx": self.resolution_row.get_selected(), + "fps_idx": self.fps_row.get_selected(), + "bandwidth_mbps": self.bandwidth_row.get_value(), + "monitor_idx": self.monitor_row.get_selected(), + "gpu_idx": self.gpu_row.get_selected(), + "platform_idx": self.platform_row.get_selected(), + "audio_mode": self.audio_mode_row.get_selected(), + "audio_output_idx": self.audio_output_row.get_selected(), + "upnp": self.upnp_row.get_active(), + "ipv6": self.ipv6_row.get_active(), + "webui_anyone": self.webui_anyone_row.get_active(), + # New settings + "efficient_codecs": self.codecs_row.get_active(), + "optimization_mode": self.optimization_row.get_selected(), + "wifi_mode": self.wifi_row.get_active(), + } + ) + + self.config.set("host", h) - self.config.set('host', h) - # Update monitor target FPS live fps_idx = self.fps_row.get_selected() fps_val = {0: 30, 1: 60, 2: 120, 3: 144, 4: 60}.get(fps_idx, 60.0) self.perf_monitor.set_target_fps(fps_val) - + # Update monitor target Bandwidth live self.perf_monitor.set_target_bandwidth(self.bandwidth_row.get_value()) - + # Sync to Sunshine Config try: from big_remote_play.ui.sunshine_preferences import SunshineConfigManager + scm = SunshineConfigManager() - + # Map Host Settings -> Sunshine Settings - scm.set('upnp', 'enabled' if self.upnp_row.get_active() else 'disabled') - scm.set('address_family', 'both' if self.ipv6_row.get_active() else 'ipv4') - scm.set('origin_web_ui_allowed', 'wan' if self.webui_anyone_row.get_active() else 'lan') + scm.set("upnp", "enabled" if self.upnp_row.get_active() else "disabled") + scm.set("address_family", "both" if self.ipv6_row.get_active() else "ipv4") + scm.set("origin_web_ui_allowed", "wan" if self.webui_anyone_row.get_active() else "lan") streaming_active = self.audio_mode_row.get_selected() in [0, 1, 3] - scm.set('stream_audio', 'true' if streaming_active else 'false') - + scm.set("stream_audio", "true" if streaming_active else "false") + # Map Codecs # If enabled -> advertised(1). If disabled -> disabled(0) - codec_val = '1' if self.codecs_row.get_active() else '0' - scm.set('hevc_mode', codec_val) - scm.set('av1_mode', codec_val) - + codec_val = "1" if self.codecs_row.get_active() else "0" + scm.set("hevc_mode", codec_val) + scm.set("av1_mode", codec_val) + # Map Wi-Fi Mode (FEC) # Enabled -> 20%. Disabled -> 5% - scm.set('fec_percentage', '20' if self.wifi_row.get_active() else '5') - + scm.set("fec_percentage", "20" if self.wifi_row.get_active() else "5") + # Map Optimization Mode # 0=Low Latency, 1=Balanced, 2=High Quality opt_idx = self.optimization_row.get_selected() - if opt_idx == 0: # Low Latency - scm.set('nvenc_preset', '1') # P1 - scm.set('amd_quality', 'speed') - scm.set('sw_preset', 'ultrafast') - scm.set('nvenc_twopass', 'disabled') - elif opt_idx == 2: # High Quality - scm.set('nvenc_preset', '7') # P7 - scm.set('amd_quality', 'quality') - scm.set('sw_preset', 'medium') - scm.set('nvenc_twopass', 'quarter_res') - else: # Balanced - scm.set('nvenc_preset', '4') # P4 - scm.set('amd_quality', 'balanced') - scm.set('sw_preset', 'veryfast') - scm.set('nvenc_twopass', 'disabled') # Or quarter_res depending on preference - + if opt_idx == 0: # Low Latency + scm.set("nvenc_preset", "1") # P1 + scm.set("amd_quality", "speed") + scm.set("sw_preset", "ultrafast") + scm.set("nvenc_twopass", "disabled") + elif opt_idx == 2: # High Quality + scm.set("nvenc_preset", "7") # P7 + scm.set("amd_quality", "quality") + scm.set("sw_preset", "medium") + scm.set("nvenc_twopass", "quarter_res") + else: # Balanced + scm.set("nvenc_preset", "4") # P4 + scm.set("amd_quality", "balanced") + scm.set("sw_preset", "veryfast") + scm.set("nvenc_twopass", "disabled") # Or quarter_res depending on preference + # Map Bandwidth to min_bitrate # 0 = Unlimited (default 0 or very high) - bw = int(self.bandwidth_row.get_value() * 1000) # Mbps -> Kbps - scm.set('min_bitrate', str(bw) if bw > 0 else '0') - + bw = int(self.bandwidth_row.get_value() * 1000) # Mbps -> Kbps + scm.set("min_bitrate", str(bw) if bw > 0 else "0") + except Exception as e: print(f"Error syncing to Sunshine config: {e}") @@ -2527,124 +2568,143 @@ def load_settings(self): # Sync from Sunshine Config first try: from big_remote_play.ui.sunshine_preferences import SunshineConfigManager + scm = SunshineConfigManager() - + # Update Host Config based on Sunshine Config (Source of Truth for these fields) - h = self.config.get('host', {}) + h = self.config.get("host", {}) if not isinstance(h, dict): h = {} - h['upnp'] = scm.get('upnp', 'enabled') == 'enabled' - h['ipv6'] = scm.get('address_family', 'both') == 'both' - h['webui_anyone'] = scm.get('origin_web_ui_allowed', 'lan') == 'wan' - h['audio'] = scm.get('stream_audio', 'true').lower() == 'true' - + h["upnp"] = scm.get("upnp", "enabled") == "enabled" + h["ipv6"] = scm.get("address_family", "both") == "both" + h["webui_anyone"] = scm.get("origin_web_ui_allowed", "lan") == "wan" + h["audio"] = scm.get("stream_audio", "true").lower() == "true" + # Reverse Map Codecs # If hevc_mode >= 1 OR av1_mode >= 1 -> Enabled - hevc = scm.get('hevc_mode', '0') - av1 = scm.get('av1_mode', '0') - h['efficient_codecs'] = (hevc != '0' or av1 != '0') - + hevc = scm.get("hevc_mode", "0") + av1 = scm.get("av1_mode", "0") + h["efficient_codecs"] = hevc != "0" or av1 != "0" + # Reverse Map Wi-Fi (FEC) # If FEC >= 15 -> Enabled - fec = int(scm.get('fec_percentage', '10')) - h['wifi_mode'] = (fec >= 15) - + fec = int(scm.get("fec_percentage", "10")) + h["wifi_mode"] = fec >= 15 + # Reverse Map Optimization # Heuristic based on nvenc_preset - nv_preset = scm.get('nvenc_preset', '4') - if nv_preset in ['1', '2']: h['optimization_mode'] = 0 # Low Latency - elif nv_preset in ['5', '6', '7']: h['optimization_mode'] = 2 # High Quality - else: h['optimization_mode'] = 1 # Balanced - + nv_preset = scm.get("nvenc_preset", "4") + if nv_preset in ["1", "2"]: + h["optimization_mode"] = 0 # Low Latency + elif nv_preset in ["5", "6", "7"]: + h["optimization_mode"] = 2 # High Quality + else: + h["optimization_mode"] = 1 # Balanced + # Reverse Map Bandwidth - bw_kbps = int(scm.get('min_bitrate', '0')) - h['bandwidth_mbps'] = bw_kbps / 1000.0 - - self.config.set('host', h) - except Exception as e: - print(f"Error syncing from Sunshine config: {e}") + bw_kbps = int(scm.get("min_bitrate", "0")) + h["bandwidth_mbps"] = bw_kbps / 1000.0 + self.config.set("host", h) + except Exception as e: + print(f"Error syncing from Sunshine config: {e}") - h = self.config.get('host', {}) + h = self.config.get("host", {}) if not isinstance(h, dict): h = {} - if not h: return - self.game_mode_row.set_selected(h.get('mode_idx', 0)) + if not h: + return + self.game_mode_row.set_selected(h.get("mode_idx", 0)) # Restore game list selection after populating - mode_idx = h.get('mode_idx', 0) + mode_idx = h.get("mode_idx", 0) if mode_idx in [1, 2]: self.populate_game_list(mode_idx) - game_list_idx = h.get('game_list_idx', 0) + game_list_idx = h.get("game_list_idx", 0) if game_list_idx is not None: self.game_list_row.set_selected(game_list_idx) - self.custom_name_entry.set_text(h.get('custom_name', '')) - self.custom_cmd_entry.set_text(h.get('custom_cmd', '')) - + self.custom_name_entry.set_text(h.get("custom_name", "")) + self.custom_cmd_entry.set_text(h.get("custom_cmd", "")) + # New Separate Settings - self.resolution_row.set_selected(h.get('resolution_idx', 1)) # Default 1080p - fps_idx = h.get('fps_idx', 1) - self.fps_row.set_selected(fps_idx) - + self.resolution_row.set_selected(h.get("resolution_idx", 1)) # Default 1080p + fps_idx = h.get("fps_idx", 1) + self.fps_row.set_selected(fps_idx) + # Update monitor target FPS fps_val = {0: 30, 1: 60, 2: 120, 3: 144, 4: 60}.get(fps_idx, 60.0) self.perf_monitor.set_target_fps(fps_val) - - bw_val = h.get('bandwidth_mbps', 0) - self.bandwidth_row.set_value(bw_val) + + bw_val = h.get("bandwidth_mbps", 0) + self.bandwidth_row.set_value(bw_val) self.perf_monitor.set_target_bandwidth(bw_val) - - self.monitor_row.set_selected(h.get('monitor_idx', 0)) - self.gpu_row.set_selected(h.get('gpu_idx', 0)) - self.platform_row.set_selected(h.get('platform_idx', 0)) - + + self.monitor_row.set_selected(h.get("monitor_idx", 0)) + self.gpu_row.set_selected(h.get("gpu_idx", 0)) + self.platform_row.set_selected(h.get("platform_idx", 0)) + # Audio Mode - audio_mode = h.get('audio_mode', 0) + audio_mode = h.get("audio_mode", 0) self.audio_mode_row.set_selected(audio_mode) show_mixer = audio_mode in [0, 1, 3] self.audio_mixer_expander.set_visible(show_mixer) + self.audio_output_row.set_selected(h.get("audio_output_idx", 0)) + + self.upnp_row.set_active(h.get("upnp", True)) + self.ipv6_row.set_active(h.get("ipv6", True)) + self.webui_anyone_row.set_active(h.get("webui_anyone", False)) - - self.audio_output_row.set_selected(h.get('audio_output_idx', 0)) - - self.upnp_row.set_active(h.get('upnp', True)) - self.ipv6_row.set_active(h.get('ipv6', True)) - self.webui_anyone_row.set_active(h.get('webui_anyone', False)) - # New settings - self.codecs_row.set_active(h.get('efficient_codecs', True)) - self.optimization_row.set_selected(h.get('optimization_mode', 1)) - self.wifi_row.set_active(h.get('wifi_mode', False)) + self.codecs_row.set_active(h.get("efficient_codecs", True)) + self.optimization_row.set_selected(h.get("optimization_mode", 1)) + self.wifi_row.set_active(h.get("wifi_mode", False)) finally: self.loading_settings = False def connect_settings_signals(self): for r in [self.upnp_row, self.ipv6_row, self.webui_anyone_row, self.codecs_row, self.wifi_row]: - r.connect('notify::active', self.save_host_settings) - - for r in [self.audio_mode_row, self.game_mode_row, self.game_list_row, self.monitor_row, self.gpu_row, self.platform_row, self.audio_output_row, self.optimization_row, self.resolution_row, self.fps_row]: - r.connect('notify::selected', self.save_host_settings) - + r.connect("notify::active", self.save_host_settings) + + for r in [ + self.audio_mode_row, + self.game_mode_row, + self.game_list_row, + self.monitor_row, + self.gpu_row, + self.platform_row, + self.audio_output_row, + self.optimization_row, + self.resolution_row, + self.fps_row, + ]: + r.connect("notify::selected", self.save_host_settings) for r in [self.bandwidth_row]: - r.connect('notify::value', self.save_host_settings) + r.connect("notify::value", self.save_host_settings) for r in [self.custom_name_entry, self.custom_cmd_entry]: - r.connect('notify::text', self.save_host_settings) + r.connect("notify::text", self.save_host_settings) def on_reset_clicked(self, button): - diag = Adw.MessageDialog(heading=_('Restore Defaults'), body=_('Do you want to restore default settings?')) - diag.add_response('cancel', _('Cancel')); diag.add_response('reset', _('Restore')) - diag.set_response_appearance('reset', Adw.ResponseAppearance.DESTRUCTIVE) + diag = Adw.MessageDialog(heading=_("Restore Defaults"), body=_("Do you want to restore default settings?")) + diag.add_response("cancel", _("Cancel")) + diag.add_response("reset", _("Restore")) + diag.set_response_appearance("reset", Adw.ResponseAppearance.DESTRUCTIVE) + def on_resp(d, r): - if r == 'reset': self.reset_to_defaults() - diag.connect('response', on_resp); diag.present() + if r == "reset": + self.reset_to_defaults() + + diag.connect("response", on_resp) + diag.present() def reset_to_defaults(self): - self.config.set('host', self.config.default_config()['host']) - self.load_settings(); self.show_toast(_("Settings Restored")) + self.config.set("host", self.config.default_config()["host"]) + self.load_settings() + self.show_toast(_("Settings Restored")) def cleanup(self): - if hasattr(self, 'perf_monitor'): self.perf_monitor.stop_monitoring() + if hasattr(self, "perf_monitor"): + self.perf_monitor.stop_monitoring() stop_pin_listener = self.stop_pin_listener if callable(stop_pin_listener): stop_pin_listener() @@ -2652,9 +2712,10 @@ def cleanup(self): if uptime_timer_id is not None: GLib.source_remove(uptime_timer_id) self._uptime_timer_id = None - + # Only cleanup audio if we are NOT hosting, because Sunshine depends on these sinks. # If we are hosting, the user expects the stream to continue working. # This also avoids the feedback loop (microfonia) when the app is closed while Moonlight/Sunshine are active. if not self.is_hosting: - if hasattr(self, 'audio_manager'): self.audio_manager.cleanup() + if hasattr(self, "audio_manager"): + self.audio_manager.cleanup() diff --git a/src/big_remote_play/ui/private_network_view.py b/src/big_remote_play/ui/private_network_view.py index 01ba121..3a25a99 100644 --- a/src/big_remote_play/ui/private_network_view.py +++ b/src/big_remote_play/ui/private_network_view.py @@ -358,11 +358,13 @@ def _build(self): getattr(clamp, f"set_margin_{m}")(24) content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=20) - content.append(create_page_header( - self.vpn["create_title"], - self.vpn["create_desc"], - self.vpn["icon"], - )) + content.append( + create_page_header( + self.vpn["create_title"], + self.vpn["create_desc"], + self.vpn["icon"], + ) + ) # Form group self._form_group = Adw.PreferencesGroup() @@ -1270,11 +1272,13 @@ def _build(self): conn_box.set_margin_start(32) conn_box.set_margin_end(32) - conn_box.append(create_page_header( - self.vpn["connect_title"], - self.vpn["connect_desc"], - self.vpn["icon"], - )) + conn_box.append( + create_page_header( + self.vpn["connect_title"], + self.vpn["connect_desc"], + self.vpn["icon"], + ) + ) # Two columns: connection fields (left) + "what you'll need" helper (right). fields_group = Adw.PreferencesGroup() diff --git a/tests/test_guest_view_a11y_regressions.py b/tests/test_guest_view_a11y_regressions.py index 466b8fa..2d45a98 100644 --- a/tests/test_guest_view_a11y_regressions.py +++ b/tests/test_guest_view_a11y_regressions.py @@ -1,14 +1,18 @@ """Static a11y guards for the Connect to Server page.""" +import re from pathlib import Path def test_advanced_client_settings_is_a_real_accessible_button() -> None: source = Path("src/big_remote_play/ui/guest_view.py").read_text() - advanced_block = source.split("advanced_title = _('Advanced client settings')", 1)[1].split( - "content.append(self.switcher_box)", - 1, - )[0] + match = re.search( + r"advanced_title = _\([\"']Advanced client settings[\"']\)(?P.*?)content\.append\(self\.switcher_box\)", + source, + re.S, + ) + assert match is not None + advanced_block = match.group("block") assert "Gtk.Button()" in advanced_block assert "Gtk.AccessibleProperty.LABEL" in advanced_block From 9b24829a5d6b6f3d5d2b30de6ca310573870bf3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Gon=C3=A7alves?= Date: Thu, 25 Jun 2026 04:22:54 -0300 Subject: [PATCH 27/47] Hide duplicate header title --- src/big_remote_play/ui/main_window.py | 5 +++-- tests/test_ui_premium_layout_regressions.py | 4 +++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/big_remote_play/ui/main_window.py b/src/big_remote_play/ui/main_window.py index 177c7a8..aca1c17 100644 --- a/src/big_remote_play/ui/main_window.py +++ b/src/big_remote_play/ui/main_window.py @@ -495,7 +495,8 @@ def setup_content(self): hb.pack_end(self._create_header_menu_button()) self.content_headerbar = hb - hb.set_title_widget(None) + self.header_blank_title = Gtk.Box() + hb.set_title_widget(self.header_blank_title) ct.add_top_bar(hb) self.content_stack = Gtk.Stack() @@ -945,7 +946,7 @@ def on_nav_selected(self, lb, row): if actual_pid == "host" and hasattr(self.host_view, "header_action_box"): self.content_headerbar.set_title_widget(self.host_view.header_action_box) else: - self.content_headerbar.set_title_widget(None) + self.content_headerbar.set_title_widget(self.header_blank_title) def navigate_to(self, pid): """Programmatic navigation: find row and select it""" diff --git a/tests/test_ui_premium_layout_regressions.py b/tests/test_ui_premium_layout_regressions.py index 919a6b7..0c08743 100644 --- a/tests/test_ui_premium_layout_regressions.py +++ b/tests/test_ui_premium_layout_regressions.py @@ -44,7 +44,9 @@ def test_content_headerbar_does_not_duplicate_page_titles() -> None: assert "self.content_title" not in source assert "Adw.WindowTitle.new" not in source - assert "set_title_widget(None)" in source + assert "set_title_widget(None)" not in source + assert "self.header_blank_title = Gtk.Box()" in source + assert "set_title_widget(self.header_blank_title)" in source def test_operational_pages_use_compact_headers_without_trivial_subtitles() -> None: From de649094c6849a538331a72045567eecfd5be0aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Gon=C3=A7alves?= Date: Thu, 25 Jun 2026 04:31:02 -0300 Subject: [PATCH 28/47] Reuse translated VPN subtitle --- src/big_remote_play/ui/main_window.py | 2 +- tests/test_ui_premium_layout_regressions.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/big_remote_play/ui/main_window.py b/src/big_remote_play/ui/main_window.py index aca1c17..c9351a0 100644 --- a/src/big_remote_play/ui/main_window.py +++ b/src/big_remote_play/ui/main_window.py @@ -581,7 +581,7 @@ def create_vpn_selector_page(self): box.append( create_page_header( _("Select VPN"), - _("Choose the private network provider for remote play."), + _("Select a VPN solution to create or join a Private Network. Your choice will be saved and shown in the sidebar menu."), "network-private-symbolic", ) ) diff --git a/tests/test_ui_premium_layout_regressions.py b/tests/test_ui_premium_layout_regressions.py index 0c08743..3e0f783 100644 --- a/tests/test_ui_premium_layout_regressions.py +++ b/tests/test_ui_premium_layout_regressions.py @@ -47,6 +47,7 @@ def test_content_headerbar_does_not_duplicate_page_titles() -> None: assert "set_title_widget(None)" not in source assert "self.header_blank_title = Gtk.Box()" in source assert "set_title_widget(self.header_blank_title)" in source + assert "Choose the private network provider for remote play." not in source def test_operational_pages_use_compact_headers_without_trivial_subtitles() -> None: From 302ac36efbc5e49d0422c75484be9d23f942ce0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Gon=C3=A7alves?= Date: Thu, 25 Jun 2026 04:39:10 -0300 Subject: [PATCH 29/47] Move section titles to headerbar --- src/big_remote_play/ui/guest_view.py | 4 +- src/big_remote_play/ui/host_view.py | 4 +- src/big_remote_play/ui/main_window.py | 24 ++++++------ .../ui/private_network_view.py | 18 +-------- src/big_remote_play/utils/widgets.py | 31 --------------- tests/test_ui_premium_layout_regressions.py | 39 ++++++++++--------- usr/share/big-remote-play/ui/style.css | 20 ---------- 7 files changed, 35 insertions(+), 105 deletions(-) diff --git a/src/big_remote_play/ui/guest_view.py b/src/big_remote_play/ui/guest_view.py index aed6485..f0bdd24 100644 --- a/src/big_remote_play/ui/guest_view.py +++ b/src/big_remote_play/ui/guest_view.py @@ -11,7 +11,7 @@ from big_remote_play.guest.moonlight_client import MoonlightClient from big_remote_play.utils.i18n import _ from big_remote_play.utils.icons import create_icon_widget -from big_remote_play.utils.widgets import create_helper_card, create_page_header, create_stack_tab_strip +from big_remote_play.utils.widgets import create_helper_card, create_stack_tab_strip from big_remote_play.utils.moonlight_config import MoonlightConfigManager @@ -69,8 +69,6 @@ def setup_ui(self): self.perf_monitor.set_visible(False) content.append(self.perf_monitor) - content.append(create_page_header(_("Connect to Server"), icon_name="network-workgroup-symbolic")) - self.method_stack = Adw.ViewStack() page_dis = self.method_stack.add_titled(self.create_discover_page(), "discover", _("Discover")) diff --git a/src/big_remote_play/ui/host_view.py b/src/big_remote_play/ui/host_view.py index 71f9412..b7f1b7e 100644 --- a/src/big_remote_play/ui/host_view.py +++ b/src/big_remote_play/ui/host_view.py @@ -14,7 +14,7 @@ import threading from big_remote_play.utils.i18n import _ from big_remote_play.utils.icons import create_icon_widget, set_icon -from big_remote_play.utils.widgets import MetricTile, create_page_header, create_stack_tab_strip +from big_remote_play.utils.widgets import MetricTile, create_stack_tab_strip from big_remote_play import paths from big_remote_play.utils.secret_store import SecretStoreUnavailable from big_remote_play.utils.sunshine_credentials import ensure_sunshine_api_config, load_sunshine_credentials, save_sunshine_credentials @@ -180,8 +180,6 @@ def setup_ui(self): self.loading_bar.set_visible(False) content.append(self.loading_bar) - content.append(create_page_header(_("Server"), icon_name="network-server-symbolic")) - from .performance_monitor import PerformanceMonitor self.perf_monitor = PerformanceMonitor(sunshine=self.sunshine) diff --git a/src/big_remote_play/ui/main_window.py b/src/big_remote_play/ui/main_window.py index c9351a0..a2d3fc9 100644 --- a/src/big_remote_play/ui/main_window.py +++ b/src/big_remote_play/ui/main_window.py @@ -18,7 +18,6 @@ create_steps_strip, create_difficulty_pill, create_comparison_table, - create_page_header, ) from big_remote_play.utils.i18n import _ import subprocess @@ -560,6 +559,9 @@ def _activate_app_menu_action(self, popover, action_name): if app is not None: app.activate_action(action_name, None) + def _set_header_title(self, title: str) -> None: + self.content_headerbar.set_title_widget(Adw.WindowTitle.new(title, "")) + # ───────────────────────────────────────────────────────────────────────── # VPN SELECTOR PAGE # ───────────────────────────────────────────────────────────────────────── @@ -578,14 +580,6 @@ def create_vpn_selector_page(self): box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=24) - box.append( - create_page_header( - _("Select VPN"), - _("Select a VPN solution to create or join a Private Network. Your choice will be saved and shown in the sidebar menu."), - "network-private-symbolic", - ) - ) - # Cards row cards_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=16) cards_box.set_halign(Gtk.Align.CENTER) @@ -940,13 +934,19 @@ def on_nav_selected(self, lb, row): self.content_stack.set_visible_child_name(actual_pid) self.current_page = actual_pid - # Server page: drop the header title, show the host action buttons there. - # Every other page keeps the title inside its own content header. + # Server uses header actions as the title widget; other sections use + # compact headerbar titles so the content can start directly with work. if hasattr(self, "content_headerbar"): if actual_pid == "host" and hasattr(self.host_view, "header_action_box"): self.content_headerbar.set_title_widget(self.host_view.header_action_box) - else: + elif actual_pid == "welcome": self.content_headerbar.set_title_widget(self.header_blank_title) + else: + info = self._build_navigation_pages().get(actual_pid) or self._build_navigation_pages().get(pid) + if info: + self._set_header_title(info.get("name", "Big Remote Play")) + else: + self.content_headerbar.set_title_widget(self.header_blank_title) def navigate_to(self, pid): """Programmatic navigation: find row and select it""" diff --git a/src/big_remote_play/ui/private_network_view.py b/src/big_remote_play/ui/private_network_view.py index 3a25a99..8073e02 100644 --- a/src/big_remote_play/ui/private_network_view.py +++ b/src/big_remote_play/ui/private_network_view.py @@ -6,7 +6,7 @@ from gi.repository import Adw, Gdk, GLib, Gtk # type: ignore from big_remote_play.utils.i18n import _ from big_remote_play.utils.icons import create_icon_widget -from big_remote_play.utils.widgets import create_helper_card, create_page_header, create_stack_tab_strip +from big_remote_play.utils.widgets import create_helper_card, create_stack_tab_strip from big_remote_play import paths from big_remote_play.utils.secure_io import secure_write_text from big_remote_play.utils.secret_store import SecretKey, SecretStore, SecretStoreUnavailable, new_secret_id @@ -358,14 +358,6 @@ def _build(self): getattr(clamp, f"set_margin_{m}")(24) content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=20) - content.append( - create_page_header( - self.vpn["create_title"], - self.vpn["create_desc"], - self.vpn["icon"], - ) - ) - # Form group self._form_group = Adw.PreferencesGroup() self._form_group.set_title(_("Configuration")) @@ -1272,14 +1264,6 @@ def _build(self): conn_box.set_margin_start(32) conn_box.set_margin_end(32) - conn_box.append( - create_page_header( - self.vpn["connect_title"], - self.vpn["connect_desc"], - self.vpn["icon"], - ) - ) - # Two columns: connection fields (left) + "what you'll need" helper (right). fields_group = Adw.PreferencesGroup() fields_group.set_title(_("Connection Details")) diff --git a/src/big_remote_play/utils/widgets.py b/src/big_remote_play/utils/widgets.py index 8c9ea45..fb9a996 100644 --- a/src/big_remote_play/utils/widgets.py +++ b/src/big_remote_play/utils/widgets.py @@ -26,37 +26,6 @@ } -def create_page_header(title: str, subtitle: str | None = None, icon_name: str | None = None) -> Gtk.Widget: - """Compact page heading used by the main content pages.""" - header = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) - header.add_css_class("page-header") - - title_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) - title_row.set_halign(Gtk.Align.START) - - title_label = Gtk.Label(label=title) - title_label.add_css_class("page-title") - title_label.set_halign(Gtk.Align.START) - title_label.set_wrap(True) - title_row.append(title_label) - - if icon_name: - icon = create_icon_widget(icon_name, size=24) - icon.add_css_class("page-title-icon") - icon.set_valign(Gtk.Align.CENTER) - title_row.append(icon) - - header.append(title_row) - if subtitle: - subtitle_label = Gtk.Label(label=subtitle) - subtitle_label.add_css_class("page-subtitle") - subtitle_label.set_halign(Gtk.Align.START) - subtitle_label.set_wrap(True) - subtitle_label.set_max_width_chars(80) - header.append(subtitle_label) - return header - - def create_stack_tab_strip(stack: Adw.ViewStack, accessible_label: str) -> Gtk.Widget: """Framed view switcher that reads visually as tabs and stays AT-SPI actionable.""" switcher = Adw.InlineViewSwitcher() diff --git a/tests/test_ui_premium_layout_regressions.py b/tests/test_ui_premium_layout_regressions.py index 3e0f783..7abd2e4 100644 --- a/tests/test_ui_premium_layout_regressions.py +++ b/tests/test_ui_premium_layout_regressions.py @@ -4,16 +4,6 @@ from pathlib import Path -def test_main_content_pages_use_shared_page_headers() -> None: - for path in [ - Path("src/big_remote_play/ui/host_view.py"), - Path("src/big_remote_play/ui/guest_view.py"), - Path("src/big_remote_play/ui/main_window.py"), - Path("src/big_remote_play/ui/private_network_view.py"), - ]: - assert "create_page_header(" in path.read_text() - - def test_sidebar_keeps_brand_identity_and_richer_service_rows() -> None: source = Path("src/big_remote_play/ui/main_window.py").read_text() @@ -43,22 +33,33 @@ def test_content_headerbar_does_not_duplicate_page_titles() -> None: source = Path("src/big_remote_play/ui/main_window.py").read_text() assert "self.content_title" not in source - assert "Adw.WindowTitle.new" not in source assert "set_title_widget(None)" not in source assert "self.header_blank_title = Gtk.Box()" in source assert "set_title_widget(self.header_blank_title)" in source + assert "def _set_header_title(self, title: str)" in source + assert 'Adw.WindowTitle.new(title, "")' in source assert "Choose the private network provider for remote play." not in source + assert 'actual_pid == "host"' in source + assert 'actual_pid == "welcome"' in source + assert 'self._set_header_title(info.get("name", "Big Remote Play"))' in source -def test_operational_pages_use_compact_headers_without_trivial_subtitles() -> None: - host_source = Path("src/big_remote_play/ui/host_view.py").read_text() - guest_source = Path("src/big_remote_play/ui/guest_view.py").read_text() - widget_source = Path("src/big_remote_play/utils/widgets.py").read_text() +def test_primary_sections_do_not_repeat_titles_inside_content() -> None: + stylesheet = Path("usr/share/big-remote-play/ui/style.css").read_text() + + assert "def create_page_header(" not in Path("src/big_remote_play/utils/widgets.py").read_text() + assert ".page-header" not in stylesheet + assert ".page-title" not in stylesheet + assert ".page-subtitle" not in stylesheet - assert "subtitle: str | None = None" in widget_source - assert "create_icon_widget(icon_name, size=24)" in widget_source - assert "_('Share your games')" not in host_source - assert "_('Connect to a host')" not in guest_source + for path in [ + Path("src/big_remote_play/ui/host_view.py"), + Path("src/big_remote_play/ui/guest_view.py"), + Path("src/big_remote_play/ui/main_window.py"), + Path("src/big_remote_play/ui/private_network_view.py"), + ]: + source = path.read_text() + assert "create_page_header" not in source def test_stack_tabs_use_shared_accessible_tab_strip() -> None: diff --git a/usr/share/big-remote-play/ui/style.css b/usr/share/big-remote-play/ui/style.css index f0c663d..e9727bb 100644 --- a/usr/share/big-remote-play/ui/style.css +++ b/usr/share/big-remote-play/ui/style.css @@ -143,26 +143,6 @@ entry:focus { font-weight: 600; } -.page-header { - margin-bottom: 0; -} - -.page-title { - font-size: 1.72em; - font-weight: 800; - letter-spacing: 0; - color: @theme_fg_color; -} - -.page-title-icon { - color: @accent_color; -} - -.page-subtitle { - font-size: 0.96em; - opacity: 0.66; -} - .caption { font-size: 0.9em; } From 65a8144633a2c43e08bb0df0e9c4b00d8449adb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Gon=C3=A7alves?= Date: Thu, 25 Jun 2026 05:08:45 -0300 Subject: [PATCH 30/47] Format Python sources with Ruff --- src/big_remote_play/app.py | 99 ++--- src/big_remote_play/guest/__init__.py | 2 +- src/big_remote_play/guest/moonlight_client.py | 222 ++++++----- src/big_remote_play/host/__init__.py | 2 +- src/big_remote_play/ui/__init__.py | 2 +- src/big_remote_play/ui/installer_window.py | 116 +++--- .../ui/moonlight_preferences.py | 28 +- src/big_remote_play/ui/performance_monitor.py | 357 ++++++++++-------- src/big_remote_play/ui/preferences.py | 233 ++++++------ src/big_remote_play/utils/__init__.py | 2 +- src/big_remote_play/utils/audio.py | 245 ++++++------ src/big_remote_play/utils/config.py | 70 ++-- src/big_remote_play/utils/game_detector.py | 173 ++++----- src/big_remote_play/utils/icons.py | 24 +- src/big_remote_play/utils/logger.py | 50 ++- src/big_remote_play/utils/moonlight_config.py | 30 +- src/big_remote_play/utils/network.py | 266 +++++++------ tests/test_drop_guest_validation.py | 4 +- tests/test_network.py | 4 +- tests/test_script_protocol.py | 8 +- tests/test_security_regressions.py | 4 +- tests/test_sunshine_api.py | 94 +++-- 22 files changed, 1127 insertions(+), 908 deletions(-) diff --git a/src/big_remote_play/app.py b/src/big_remote_play/app.py index dc56b1f..2daef9f 100644 --- a/src/big_remote_play/app.py +++ b/src/big_remote_play/app.py @@ -1,8 +1,9 @@ from __future__ import annotations import sys, os, gi -gi.require_version('Gtk', '4.0') -gi.require_version('Adw', '1') + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") from gi.repository import Gtk, Adw, Gio, Gdk, GLib # type: ignore from big_remote_play.ui.main_window import MainWindow from big_remote_play.utils.config import Config @@ -14,35 +15,38 @@ ICONS_DIR = str(paths.ICONS_DIR) IMG_DIR = str(paths.IMG_DIR) + class BigRemotePlayApp(Adw.Application): def __init__(self): - super().__init__(application_id='br.com.biglinux.remoteplay', flags=Gio.ApplicationFlags.FLAGS_NONE) + super().__init__(application_id="br.com.biglinux.remoteplay", flags=Gio.ApplicationFlags.FLAGS_NONE) self.config = Config() self.logger = Logger() self.window = None - + def do_activate(self): - if not self.window: self.window = MainWindow(application=self) + if not self.window: + self.window = MainWindow(application=self) self.window.present() - + def do_startup(self): Adw.Application.do_startup(self) self.setup_icon() self.setup_actions() self.setup_theme() - + def setup_actions(self): - actions = [('quit', lambda *_: self.quit()), ('about', self.show_about), ('preferences', self.show_preferences)] + actions = [("quit", lambda *_: self.quit()), ("about", self.show_about), ("preferences", self.show_preferences)] for name, callback in actions: action = Gio.SimpleAction.new(name, None) - action.connect('activate', callback) + action.connect("activate", callback) self.add_action(action) - + def setup_theme(self): - sm = Adw.StyleManager.get_default(); theme = self.config.get('theme', 'auto') - sm.set_color_scheme(Adw.ColorScheme.FORCE_DARK if theme == 'dark' else Adw.ColorScheme.FORCE_LIGHT if theme == 'light' else Adw.ColorScheme.DEFAULT) + sm = Adw.StyleManager.get_default() + theme = self.config.get("theme", "auto") + sm.set_color_scheme(Adw.ColorScheme.FORCE_DARK if theme == "dark" else Adw.ColorScheme.FORCE_LIGHT if theme == "light" else Adw.ColorScheme.DEFAULT) self.load_custom_css() - + def setup_icon(self): display = Gdk.Display.get_default() if display is None: @@ -54,73 +58,82 @@ def setup_icon(self): if os.path.exists(IMG_DIR): it.add_search_path(IMG_DIR) self.logger.info(_("Icons and images paths added")) - + def load_custom_css(self): - cp = Gtk.CssProvider(); cp_path = paths.STYLE_CSS + cp = Gtk.CssProvider() + cp_path = paths.STYLE_CSS display = self.window.get_display() if self.window else Gdk.Display.get_default() if cp_path.exists() and display is not None: cp.load_from_path(str(cp_path)) Gtk.StyleContext.add_provider_for_display(display, cp, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) def show_about(self, *args): - story = _("The Story Behind the Project\n\n" - "Big Remote Play was born from a real story of friendship, determination, and the passion for Free Software.\n\n" - "Alessandro e Silva Xavier (known as Alessandro) and Alexasandro Pacheco Feliciano (known as Pacheco) wanted to play games together on BigLinux using a feature that only existed on proprietary platforms like Steam Remote Play and GeForce NOW. The problem? These systems are proprietary, locked to their own ecosystems. If a game wasn't available on their platform, it was nearly impossible to play remotely with friends.\n\n" - "Refusing to accept this limitation, Alessandro and Pacheco embarked on a journey of countless attempts and extensive research. After trying many different approaches, they finally found a working solution by combining multiple free software programs — including Sunshine, Moonlight, scripts, and VPN tools. They had achieved what the proprietary platforms kept locked behind their walls, and the best part: it was Free Software and multi-platform!\n\n" - "Excited by their success, they started sharing their achievement during their live streams, which generated tremendous enthusiasm from the community. However, there was a catch — the setup was complicated. It required configuring multiple separate solutions: Sunshine, Moonlight, custom scripts, VPN connections... it was a lot for anyone to handle.\n\n" - "That's when a friend decided to step in and help develop a unified application to simplify the entire process. And so, Big Remote Play was born! 🎉\n\n" - "An all-in-one application that integrates everything you need for remote cooperative gaming — no proprietary platforms, no restrictions, no limits on which games you can play.") + story = _( + "The Story Behind the Project\n\n" + "Big Remote Play was born from a real story of friendship, determination, and the passion for Free Software.\n\n" + "Alessandro e Silva Xavier (known as Alessandro) and Alexasandro Pacheco Feliciano (known as Pacheco) wanted to play games together on BigLinux using a feature that only existed on proprietary platforms like Steam Remote Play and GeForce NOW. The problem? These systems are proprietary, locked to their own ecosystems. If a game wasn't available on their platform, it was nearly impossible to play remotely with friends.\n\n" + "Refusing to accept this limitation, Alessandro and Pacheco embarked on a journey of countless attempts and extensive research. After trying many different approaches, they finally found a working solution by combining multiple free software programs — including Sunshine, Moonlight, scripts, and VPN tools. They had achieved what the proprietary platforms kept locked behind their walls, and the best part: it was Free Software and multi-platform!\n\n" + "Excited by their success, they started sharing their achievement during their live streams, which generated tremendous enthusiasm from the community. However, there was a catch — the setup was complicated. It required configuring multiple separate solutions: Sunshine, Moonlight, custom scripts, VPN connections... it was a lot for anyone to handle.\n\n" + "That's when a friend decided to step in and help develop a unified application to simplify the entire process. And so, Big Remote Play was born! 🎉\n\n" + "An all-in-one application that integrates everything you need for remote cooperative gaming — no proprietary platforms, no restrictions, no limits on which games you can play." + ) about = Adw.AboutWindow( transient_for=self.window, - application_name='Big Remote Play', - application_icon='big-remote-play', - developer_name='BigLinux Team', - version='2.0.0', - developers=['Rafael Ruscher ', 'Alexasandro Pacheco Feliciano <@pachecogameroficial>', 'Alessandro e Silva Xavier <@alessandro741>'], - copyright='© 2026 BigLinux', + application_name="Big Remote Play", + application_icon="big-remote-play", + developer_name="BigLinux Team", + version="2.0.0", + developers=["Rafael Ruscher ", "Alexasandro Pacheco Feliciano <@pachecogameroficial>", "Alessandro e Silva Xavier <@alessandro741>"], + copyright="© 2026 BigLinux", license_type=Gtk.License.GPL_3_0, - website='https://github.com/biglinux/', - issue_url='https://github.com/biglinux/big-remote-play/issues', + website="https://github.com/biglinux/", + issue_url="https://github.com/biglinux/big-remote-play/issues", comments=story, ) about.add_link("System-infotech", "https://www.youtube.com/@System-infotech") about.add_link("Youtube (Project Story)", "https://www.youtube.com/watch?v=D2l9o_wXW5M") about.present() - + def show_preferences(self, *args, tab=None): window = self.window if window is None: return from big_remote_play.ui.preferences import PreferencesWindow + pref_win = PreferencesWindow(transient_for=window, config=self.config, initial_tab=tab) - + # Reload GuestView settings when preferences close def on_close(*_): - if hasattr(window, 'guest_view') and hasattr(window.guest_view, 'load_guest_settings'): + if hasattr(window, "guest_view") and hasattr(window.guest_view, "load_guest_settings"): window.guest_view.load_guest_settings() - if hasattr(window, 'host_view') and hasattr(window.host_view, 'load_settings'): + if hasattr(window, "host_view") and hasattr(window.host_view, "load_settings"): # Reload config from file first if needed - if hasattr(window.host_view, 'config') and hasattr(window.host_view.config, 'load'): + if hasattr(window.host_view, "config") and hasattr(window.host_view.config, "load"): window.host_view.config.load() window.host_view.load_settings() - - pref_win.connect('close-request', on_close) + + pref_win.connect("close-request", on_close) pref_win.present() - + def do_shutdown(self): - try: Adw.Application.do_shutdown(self) - except Exception: pass + try: + Adw.Application.do_shutdown(self) + except Exception: + pass os._exit(0) + def main(): import signal + signal.signal(signal.SIGINT, signal.SIG_DFL) # Without this the AT-SPI/process name is "__main__.py" (from `python -m`). - GLib.set_prgname('big-remote-play') - GLib.set_application_name('Big Remote Play') + GLib.set_prgname("big-remote-play") + GLib.set_application_name("Big Remote Play") return BigRemotePlayApp().run(sys.argv) -if __name__ == '__main__': + +if __name__ == "__main__": sys.exit(main()) diff --git a/src/big_remote_play/guest/__init__.py b/src/big_remote_play/guest/__init__.py index c00166a..07f62f9 100644 --- a/src/big_remote_play/guest/__init__.py +++ b/src/big_remote_play/guest/__init__.py @@ -2,4 +2,4 @@ from .moonlight_client import MoonlightClient -__all__ = ['MoonlightClient'] +__all__ = ["MoonlightClient"] diff --git a/src/big_remote_play/guest/moonlight_client.py b/src/big_remote_play/guest/moonlight_client.py index 06d74ad..bbdd0be 100644 --- a/src/big_remote_play/guest/moonlight_client.py +++ b/src/big_remote_play/guest/moonlight_client.py @@ -11,24 +11,25 @@ def __init__(self, logger: Any | None = None) -> None: self.process: subprocess.Popen[str] | None = None self.connected_host: str | None = None self.logger = logger - self.moonlight_cmd = next((c for c in ['moonlight-qt', 'moonlight'] if shutil.which(c)), None) - + self.moonlight_cmd = next((c for c in ["moonlight-qt", "moonlight"] if shutil.which(c)), None) + def _prepare_ip(self, ip): """Prepares IP for Moonlight CLI.""" - if not ip: return "" + if not ip: + return "" clean_ip = ip.strip() - + # Remove brackets if present to facilitate processing - was_bracketed = clean_ip.startswith('[') and clean_ip.endswith(']') + was_bracketed = clean_ip.startswith("[") and clean_ip.endswith("]") if was_bracketed: clean_ip = clean_ip[1:-1] - - if ':' in clean_ip and '%' not in clean_ip and clean_ip.startswith('fe80'): + + if ":" in clean_ip and "%" not in clean_ip and clean_ip.startswith("fe80"): try: # Get interface with default route - route = subprocess.check_output(['ip', '-6', 'route', 'show', 'default'], text=True, timeout=5).split() - if 'dev' in route: - dev_idx = route.index('dev') + 1 + route = subprocess.check_output(["ip", "-6", "route", "show", "default"], text=True, timeout=5).split() + if "dev" in route: + dev_idx = route.index("dev") + 1 if dev_idx < len(route): iface = route[dev_idx] clean_ip = f"{clean_ip}%{iface}" @@ -36,86 +37,114 @@ def _prepare_ip(self, ip): # Dumb fallback: get first UP interface that is not lo try: import json - out = subprocess.check_output(['ip', '-j', 'addr'], text=True, timeout=5) + + out = subprocess.check_output(["ip", "-j", "addr"], text=True, timeout=5) for i in json.loads(out): - if i['ifname'] != 'lo' and 'UP' in i['flags']: + if i["ifname"] != "lo" and "UP" in i["flags"]: clean_ip = f"{clean_ip}%{i['ifname']}" break - except Exception: pass - except Exception: pass - + except Exception: + pass + except Exception: + pass + return clean_ip def connect(self, ip: str, **kw: Any) -> bool: - if not self.moonlight_cmd or self.is_connected(): return False + if not self.moonlight_cmd or self.is_connected(): + return False moonlight_cmd = self.moonlight_cmd - + try: target_ip = self._prepare_ip(ip) - - cmd = [moonlight_cmd, 'stream', target_ip, 'Desktop'] - if kw.get('width') and kw.get('height') and kw.get('width') != 'custom': cmd.extend(['--resolution', f"{kw['width']}x{kw['height']}"]) - if kw.get('fps') and kw.get('fps') != 'custom': cmd.extend(['--fps', str(kw['fps'])]) - if kw.get('bitrate'): cmd.extend(['--bitrate', str(kw['bitrate'])]) - cmd.extend(['--display-mode', kw.get('display_mode', 'borderless')]) - if not kw.get('audio', True): cmd.append('--audio-on-host') - cmd.append('--quit-after') # Close when app ends - if kw.get('hw_decode', True): cmd.extend(['--video-decoder', 'hardware']) - else: cmd.extend(['--video-decoder', 'software']) - + + cmd = [moonlight_cmd, "stream", target_ip, "Desktop"] + if kw.get("width") and kw.get("height") and kw.get("width") != "custom": + cmd.extend(["--resolution", f"{kw['width']}x{kw['height']}"]) + if kw.get("fps") and kw.get("fps") != "custom": + cmd.extend(["--fps", str(kw["fps"])]) + if kw.get("bitrate"): + cmd.extend(["--bitrate", str(kw["bitrate"])]) + cmd.extend(["--display-mode", kw.get("display_mode", "borderless")]) + if not kw.get("audio", True): + cmd.append("--audio-on-host") + cmd.append("--quit-after") # Close when app ends + if kw.get("hw_decode", True): + cmd.extend(["--video-decoder", "hardware"]) + else: + cmd.extend(["--video-decoder", "software"]) + if self.logger: self.logger.info(f"Connecting to {ip} (target: {target_ip}) with options: {kw}") self.logger.info(f"Command: {' '.join(cmd)}") - + stdout_target = subprocess.PIPE if self.logger else None stderr_target = subprocess.PIPE if self.logger else None - + self.process = subprocess.Popen(cmd, stdout=stdout_target, stderr=stderr_target, text=True) self.connected_host = ip - + logger = self.logger if logger: import threading + def log_output(pipe: TextIO, level: str) -> None: - for line in iter(pipe.readline, ''): - if line: getattr(logger, level, logger.info)(f"[Moonlight] {line.strip()}") + for line in iter(pipe.readline, ""): + if line: + getattr(logger, level, logger.info)(f"[Moonlight] {line.strip()}") pipe.close() - if self.process.stdout: threading.Thread(target=log_output, args=(self.process.stdout, 'info'), daemon=True).start() - if self.process.stderr: threading.Thread(target=log_output, args=(self.process.stderr, 'error'), daemon=True).start() - + + if self.process.stdout: + threading.Thread(target=log_output, args=(self.process.stdout, "info"), daemon=True).start() + if self.process.stderr: + threading.Thread(target=log_output, args=(self.process.stderr, "error"), daemon=True).start() + try: exit_code = self.process.wait(timeout=1.0) msg = f"Moonlight ended prematurely (Code {exit_code})" - if self.logger: self.logger.error(msg) + if self.logger: + self.logger.error(msg) return False - except subprocess.TimeoutExpired: pass - + except subprocess.TimeoutExpired: + pass + return True - except Exception as e: - if self.logger: self.logger.error(f"Error connecting: {e}") + except Exception as e: + if self.logger: + self.logger.error(f"Error connecting: {e}") return False - def is_connected(self) -> bool: return bool(self.process and self.process.poll() is None) + def is_connected(self) -> bool: + return bool(self.process and self.process.poll() is None) def disconnect(self): - if not self.is_connected(): return False + if not self.is_connected(): + return False try: - if self.process: self.process.terminate(); self.process.wait(timeout=5) - self.process = None; self.connected_host = None; return True + if self.process: + self.process.terminate() + self.process.wait(timeout=5) + self.process = None + self.connected_host = None + return True except Exception: - if self.process: self.process.kill(); self.process = None; self.connected_host = None + if self.process: + self.process.kill() + self.process = None + self.connected_host = None return False def probe_host(self, host_ip: str) -> bool: if not self.moonlight_cmd: return False - try: + try: target_ip = self._prepare_ip(host_ip) # Aggressive timeout for probe - res = subprocess.run([self.moonlight_cmd, 'list', target_ip], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=1.5) + res = subprocess.run([self.moonlight_cmd, "list", target_ip], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=1.5) return res.returncode == 0 - except Exception as e: - if self.logger: self.logger.error(f"Probe error: {e}") + except Exception as e: + if self.logger: + self.logger.error(f"Probe error: {e}") return False def pair(self, host_ip: str, on_pin_callback: Callable[[str], None] | None = None) -> bool: @@ -123,80 +152,97 @@ def pair(self, host_ip: str, on_pin_callback: Callable[[str], None] | None = Non return False try: target_ip = self._prepare_ip(host_ip) - cmd = [self.moonlight_cmd, 'pair', target_ip] - if self.logger: self.logger.info(f"Starting pair with {host_ip} (target: {target_ip}): {' '.join(cmd)}") - + cmd = [self.moonlight_cmd, "pair", target_ip] + if self.logger: + self.logger.info(f"Starting pair with {host_ip} (target: {target_ip}): {' '.join(cmd)}") + self.process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1) stdout = self.process.stdout if stdout is None: return False success = False - + while True: line = stdout.readline() # If no line and process ended, break - if not line and self.process.poll() is not None: break - if not line: continue - - if self.logger: self.logger.debug(f"[Pair] {line.strip()}") - + if not line and self.process.poll() is not None: + break + if not line: + continue + + if self.logger: + self.logger.debug(f"[Pair] {line.strip()}") + if "PIN" in line and "target PC" in line: - pin = ''.join(filter(str.isdigit, line.strip().split()[-1])) - if self.logger: self.logger.info(f"PIN detected: {pin}") - if pin and on_pin_callback: on_pin_callback(pin) - - if "successfully paired" in line.lower() or "already paired" in line.lower(): - if self.logger: self.logger.info("Pairing successful") + pin = "".join(filter(str.isdigit, line.strip().split()[-1])) + if self.logger: + self.logger.info(f"PIN detected: {pin}") + if pin and on_pin_callback: + on_pin_callback(pin) + + if "successfully paired" in line.lower() or "already paired" in line.lower(): + if self.logger: + self.logger.info("Pairing successful") success = True # Do not terminate immediately, let it finish and sync continue - + # Record return code and cleanup ret = self.process.wait() self.process = None - + # Return success flag or 0 exit code (if it finished naturally with success) return success or ret == 0 except Exception as e: - if self.logger: self.logger.error(f"Pairing exception: {e}") + if self.logger: + self.logger.error(f"Pairing exception: {e}") return False def list_apps(self, host_ip): - if not self.moonlight_cmd: return [] - + if not self.moonlight_cmd: + return [] + try: target_ip = self._prepare_ip(host_ip) # Try clearing neighbor cache if IPv6 to avoid dead routes - if ':' in target_ip: + if ":" in target_ip: # 2. Flush neighbor cache for the specific target interface to avoid stale routes - try: subprocess.run(['ip', '-6', 'neigh', 'flush', 'all'], capture_output=True, timeout=1) - except Exception: pass - + try: + subprocess.run(["ip", "-6", "neigh", "flush", "all"], capture_output=True, timeout=1) + except Exception: + pass + # 3. Ping all-nodes multicast briefly to populate neighbor cache - try: subprocess.run(['ping', '-6', '-c', '1', '-W', '1', 'ff02::1%lo'], capture_output=True, timeout=1) - except Exception: pass - except Exception: pass + try: + subprocess.run(["ping", "-6", "-c", "1", "-W", "1", "ff02::1%lo"], capture_output=True, timeout=1) + except Exception: + pass + except Exception: + pass try: target_ip = self._prepare_ip(host_ip) # Uses start_new_session=True instead of external setsid for better compatibility - r = subprocess.run([self.moonlight_cmd, 'list', target_ip], capture_output=True, text=True, timeout=5, start_new_session=True) - - if self.logger: + r = subprocess.run([self.moonlight_cmd, "list", target_ip], capture_output=True, text=True, timeout=5, start_new_session=True) + + if self.logger: self.logger.debug(f"List apps {host_ip} (target: {target_ip}) stdout: {r.stdout}") - if r.stderr: self.logger.error(f"List apps {host_ip} stderr: {r.stderr}") - + if r.stderr: + self.logger.error(f"List apps {host_ip} stderr: {r.stderr}") + if r.returncode == 0: return [l.strip() for l in r.stdout.splitlines() if l.strip()] - + # Check for explicit pairing error in stderr err = (r.stderr or "").lower() if "not paired" in err or "não foi pareado" in err or "unpaired" in err: return None - - return [] # Other error (timeout, connection refused, etc) - except Exception as e: - if self.logger: self.logger.error(f"List apps error: {e}") + + return [] # Other error (timeout, connection refused, etc) + except Exception as e: + if self.logger: + self.logger.error(f"List apps error: {e}") return [] - def get_status(self): return {'connected': self.is_connected(), 'host': self.connected_host, 'moonlight_cmd': self.moonlight_cmd} + def get_status(self): + return {"connected": self.is_connected(), "host": self.connected_host, "moonlight_cmd": self.moonlight_cmd} diff --git a/src/big_remote_play/host/__init__.py b/src/big_remote_play/host/__init__.py index 988bf4d..85c3057 100644 --- a/src/big_remote_play/host/__init__.py +++ b/src/big_remote_play/host/__init__.py @@ -2,4 +2,4 @@ from .sunshine_manager import SunshineHost -__all__ = ['SunshineHost'] +__all__ = ["SunshineHost"] diff --git a/src/big_remote_play/ui/__init__.py b/src/big_remote_play/ui/__init__.py index e0e5d5f..9f89636 100644 --- a/src/big_remote_play/ui/__init__.py +++ b/src/big_remote_play/ui/__init__.py @@ -5,4 +5,4 @@ from .guest_view import GuestView from .preferences import PreferencesWindow -__all__ = ['MainWindow', 'HostView', 'GuestView', 'PreferencesWindow'] +__all__ = ["MainWindow", "HostView", "GuestView", "PreferencesWindow"] diff --git a/src/big_remote_play/ui/installer_window.py b/src/big_remote_play/ui/installer_window.py index d5080f9..ed69dcc 100644 --- a/src/big_remote_play/ui/installer_window.py +++ b/src/big_remote_play/ui/installer_window.py @@ -1,8 +1,11 @@ import gi -gi.require_version('Gtk', '4.0'); gi.require_version('Adw', '1') + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") try: - gi.require_version('Vte', '3.91') + gi.require_version("Vte", "3.91") from gi.repository import Vte # type: ignore + HAS_VTE = True except Exception: HAS_VTE = False @@ -10,18 +13,19 @@ import subprocess, os, shutil from big_remote_play.utils.i18n import _ + class InstallerWindow(Adw.Window): """Dependency installation window""" - + def __init__(self, parent=None, on_success=None): super().__init__(transient_for=parent) - + self.on_success_callback = on_success - - self.set_title(_('Install Dependencies')) + + self.set_title(_("Install Dependencies")) self.set_default_size(700, 500) self.set_modal(True) - + # Main content box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) box.set_margin_top(12) @@ -29,23 +33,23 @@ def __init__(self, parent=None, on_success=None): box.set_margin_start(12) box.set_margin_end(12) self.set_content(box) - + # Header - header = Gtk.Label(label=_('Installing required components...')) - header.add_css_class('title-2') + header = Gtk.Label(label=_("Installing required components...")) + header.add_css_class("title-2") box.append(header) - + # Terminal/Log area frame frame = Gtk.Frame() frame.set_vexpand(True) box.append(frame) - + if HAS_VTE: self.terminal = Vte.Terminal() self.terminal.set_scrollback_lines(1000) - self.terminal.connect('child-exited', self.on_process_exit) + self.terminal.connect("child-exited", self.on_process_exit) frame.set_child(self.terminal) - + # Start installation directly GLib.idle_add(self.start_installation) else: @@ -55,42 +59,50 @@ def __init__(self, parent=None, on_success=None): self.textview.set_editable(False) self.textview.set_monospace(True) self.textview.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) - + buffer = self.textview.get_buffer() - buffer.set_text(_("Embedded terminal not available (vte4 not found).\n" - "Opening external terminal for installation...\n" - "Please wait for the installation to finish in the other window.")) - + buffer.set_text(_("Embedded terminal not available (vte4 not found).\nOpening external terminal for installation...\nPlease wait for the installation to finish in the other window.")) + scrolled.set_child(self.textview) frame.set_child(scrolled) - + # Start external installation GLib.idle_add(self.start_external_installation) - + # Status/Action area - self.status_label = Gtk.Label(label=_('Starting...')) + self.status_label = Gtk.Label(label=_("Starting...")) box.append(self.status_label) - - self.close_btn = Gtk.Button(label=_('Cancel')) - self.close_btn.add_css_class('destructive-action') - self.close_btn.connect('clicked', lambda b: self.close()) + + self.close_btn = Gtk.Button(label=_("Cancel")) + self.close_btn.add_css_class("destructive-action") + self.close_btn.connect("clicked", lambda b: self.close()) box.append(self.close_btn) def start_external_installation(self): cmd = "yay -S --noconfirm --needed sunshine moonlight-qt vte4" done_msg = _("Done! Press Enter to close...") sc = f'{cmd}; echo "\n{done_msg}"; read' - terms = [(['konsole', '-e', 'bash', '-c', sc]), (['gnome-terminal', '--', 'bash', '-c', sc]), (['xfce4-terminal', '-x', 'bash', '-c', sc]), (['xterm', '-e', 'bash', '-c', sc])] + terms = [(["konsole", "-e", "bash", "-c", sc]), (["gnome-terminal", "--", "bash", "-c", sc]), (["xfce4-terminal", "-x", "bash", "-c", sc]), (["xterm", "-e", "bash", "-c", sc])] started = False for t in terms: if shutil.which(t[0]): - try: subprocess.Popen(t); started = True; break - except Exception: continue - if started: self.status_label.set_text(_('Running in external terminal. Restart after completion.')); self.close_btn.set_label(_('Close')); self.close_btn.remove_css_class('destructive-action'); self.close_btn.add_css_class('suggested-action') - else: self.status_label.set_text(_('Error: No terminal detected.')); self.textview.get_buffer().set_text(_("Execute manually:\n{}").format(cmd)) - + try: + subprocess.Popen(t) + started = True + break + except Exception: + continue + if started: + self.status_label.set_text(_("Running in external terminal. Restart after completion.")) + self.close_btn.set_label(_("Close")) + self.close_btn.remove_css_class("destructive-action") + self.close_btn.add_css_class("suggested-action") + else: + self.status_label.set_text(_("Error: No terminal detected.")) + self.textview.get_buffer().set_text(_("Execute manually:\n{}").format(cmd)) + def start_installation(self): - self.status_label.set_text(_('Installing...')) + self.status_label.set_text(_("Installing...")) def on_spawn_done(terminal, pid, error, user_data): if error: @@ -98,14 +110,18 @@ def on_spawn_done(terminal, pid, error, user_data): GLib.idle_add(self.start_external_installation) try: - self.terminal.spawn_async(Vte.PtyFlags.DEFAULT, None, ['yay', '-S', '--noconfirm', '--needed', 'sunshine', 'moonlight-qt'], None, GLib.SpawnFlags.SEARCH_PATH, None, -1, Gio.Cancellable(), on_spawn_done, None) - except Exception as e: self.status_label.set_text(_('Embedded terminal error: {}. Trying external...').format(e)); GLib.idle_add(self.start_external_installation) + self.terminal.spawn_async( + Vte.PtyFlags.DEFAULT, None, ["yay", "-S", "--noconfirm", "--needed", "sunshine", "moonlight-qt"], None, GLib.SpawnFlags.SEARCH_PATH, None, -1, Gio.Cancellable(), on_spawn_done, None + ) + except Exception as e: + self.status_label.set_text(_("Embedded terminal error: {}. Trying external...").format(e)) + GLib.idle_add(self.start_external_installation) def on_process_exit(self, terminal, status): # status is the exit code # VTE usage might return a complex status, need to extract exit code # Usually it matches waitpid status - + if os.WIFEXITED(status): exit_code = os.WEXITSTATUS(status) if exit_code == 0: @@ -114,23 +130,23 @@ def on_process_exit(self, terminal, status): self.on_failure(exit_code) else: self.on_failure(-1) - + def on_success(self): - self.status_label.set_text(_('Installation completed successfully!')) - self.status_label.remove_css_class('error') - self.status_label.add_css_class('success') - - self.close_btn.set_label(_('Finish')) - self.close_btn.remove_css_class('destructive-action') - self.close_btn.add_css_class('suggested-action') - + self.status_label.set_text(_("Installation completed successfully!")) + self.status_label.remove_css_class("error") + self.status_label.add_css_class("success") + + self.close_btn.set_label(_("Finish")) + self.close_btn.remove_css_class("destructive-action") + self.close_btn.add_css_class("suggested-action") + # Update behavior: Close button now just closes, functionality is done - + # Trigger parent update if self.on_success_callback: self.on_success_callback() - + def on_failure(self, code): - self.status_label.set_text(_('Installation failed. Exit code: {}').format(code)) - self.status_label.add_css_class('error') - self.close_btn.set_label(_('Close')) + self.status_label.set_text(_("Installation failed. Exit code: {}").format(code)) + self.status_label.add_css_class("error") + self.close_btn.set_label(_("Close")) diff --git a/src/big_remote_play/ui/moonlight_preferences.py b/src/big_remote_play/ui/moonlight_preferences.py index 7c7624e..db4987d 100644 --- a/src/big_remote_play/ui/moonlight_preferences.py +++ b/src/big_remote_play/ui/moonlight_preferences.py @@ -1,19 +1,20 @@ import gi -gi.require_version('Gtk', '4.0') -gi.require_version('Adw', '1') +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") from gi.repository import Gtk, Adw # type: ignore from big_remote_play.utils.i18n import _ from big_remote_play.utils.moonlight_config import MoonlightConfigManager + class MoonlightPreferencesPage(Adw.PreferencesPage): def __init__(self, **kwargs): super().__init__(**kwargs) self.set_title("Moonlight") # product name — never translated self.set_icon_name("preferences-desktop-remote-desktop-symbolic") - + self.config = MoonlightConfigManager() self.config.reload() self.setup_ui() @@ -31,7 +32,8 @@ def setup_ui(self): res_model = Gtk.StringList() # Use simple labels to match screenshot style resolutions = [("720", "720p"), ("1080", "1080p"), ("1440", "1440p"), ("2160", "4K")] - for __, label in resolutions: res_model.append(label) + for __, label in resolutions: + res_model.append(label) res_row.set_model(res_model) curr_h = self.config.get("height", "1080") res_row.set_selected(next((i for i, (h, _) in enumerate(resolutions) if h == curr_h), 1)) @@ -42,7 +44,8 @@ def setup_ui(self): fps_row.set_title(_("Frame Rate (FPS)")) fps_model = Gtk.StringList() fps_options = ["30", "60", "90", "120"] - for f in fps_options: fps_model.append(f + " FPS") + for f in fps_options: + fps_model.append(f + " FPS") fps_row.set_model(fps_model) curr_fps = self.config.get("fps", "60") fps_row.set_selected(next((i for i, f in enumerate(fps_options) if f == curr_fps), 1)) @@ -66,7 +69,8 @@ def setup_ui(self): mode_row.set_title(_("Display Mode")) mode_model = Gtk.StringList() modes = [("3", _("Borderless Window")), ("1", _("Fullscreen")), ("2", _("Windowed"))] - for __, label in modes: mode_model.append(label) + for __, label in modes: + mode_model.append(label) mode_row.set_model(mode_model) curr_mode = self.config.get("windowMode", "3") mode_row.set_selected(next((i for i, (m, _) in enumerate(modes) if m == curr_mode), 0)) @@ -95,7 +99,8 @@ def setup_ui(self): audio_cfg_row.set_title(_("Audio Configuration")) audio_cfg_model = Gtk.StringList() audio_cfgs = [("0", _("Stereo")), ("1", "5.1 Surround"), ("2", "7.1 Surround")] - for __, label in audio_cfgs: audio_cfg_model.append(label) + for __, label in audio_cfgs: + audio_cfg_model.append(label) audio_cfg_row.set_model(audio_cfg_model) curr_audio = self.config.get("audioConfig", "0") audio_cfg_row.set_selected(next((i for i, (c, _) in enumerate(audio_cfgs) if c == curr_audio), 0)) @@ -130,7 +135,8 @@ def setup_ui(self): decoder_row.set_title(_("Video Decoder")) dec_model = Gtk.StringList() decs = [("0", _("Automatic (Recommended)")), ("1", _("Hardware")), ("2", _("Software"))] - for __, l in decs: dec_model.append(l) + for __, l in decs: + dec_model.append(l) decoder_row.set_model(dec_model) curr_dec = self.config.get("videoDecoder", "0") decoder_row.set_selected(next((i for i, (d, _) in enumerate(decs) if d == curr_dec), 0)) @@ -141,7 +147,8 @@ def setup_ui(self): codec_row.set_title(_("Video Codec")) codec_model = Gtk.StringList() codecs = [("0", _("Automatic (Recommended)")), ("1", "H.264"), ("2", "HEVC"), ("3", "AV1")] - for __, l in codecs: codec_model.append(l) + for __, l in codecs: + codec_model.append(l) codec_row.set_model(codec_model) curr_codec = self.config.get("videoCodec", "0") codec_row.set_selected(next((i for i, (c, _) in enumerate(codecs) if c == curr_codec), 0)) @@ -165,7 +172,8 @@ def setup_ui(self): def add_boolean_option(self, group, key, title, subtitle, default): row = Adw.SwitchRow() row.set_title(title) - if subtitle: row.set_subtitle(subtitle) + if subtitle: + row.set_subtitle(subtitle) val = self.config.get(key, default).lower() == "true" row.set_active(val) row.connect("notify::active", lambda w, p: self.config.set(key, str(w.get_active()).lower())) diff --git a/src/big_remote_play/ui/performance_monitor.py b/src/big_remote_play/ui/performance_monitor.py index c36e19c..4db3ba1 100644 --- a/src/big_remote_play/ui/performance_monitor.py +++ b/src/big_remote_play/ui/performance_monitor.py @@ -31,9 +31,11 @@ from big_remote_play.utils.icons import create_icon_widget, set_icon + @dataclass class PerformanceDataPoint: """Single data point for performance chart.""" + latency: float fps: float bandwidth: float @@ -43,6 +45,7 @@ class PerformanceDataPoint: bandwidth_text: str users_count: int = 0 + class PerformanceChartWidget(Gtk.DrawingArea): """ Modern chart widget for network/video performance. @@ -76,15 +79,15 @@ def __init__(self) -> None: motion_controller.connect("motion", self._on_motion) motion_controller.connect("leave", self._on_leave) self.add_controller(motion_controller) - + def _get_device_color(self, name): # Strip state suffixes to keep the color consistent - base_name = name.split('(')[0].strip() + base_name = name.split("(")[0].strip() if base_name not in self.device_colors: idx = len(self.device_colors) % len(self.color_palette) self.device_colors[base_name] = self.color_palette[idx] return self.device_colors[base_name] - + def add_data_point( self, latency: float, @@ -94,15 +97,19 @@ def add_data_point( device_latencies: dict[str, float] | None = None, bw_text_override: str | None = None, ): - if latency > self.max_latency: self.max_latency = latency * 1.2 - if fps > self.max_fps: self.max_fps = fps * 1.2 - if bandwidth > self.max_bandwidth: self.max_bandwidth = bandwidth * 1.2 + if latency > self.max_latency: + self.max_latency = latency * 1.2 + if fps > self.max_fps: + self.max_fps = fps * 1.2 + if bandwidth > self.max_bandwidth: + self.max_bandwidth = bandwidth * 1.2 if device_latencies: for lat in device_latencies.values(): - if lat > self.max_latency: self.max_latency = lat * 1.2 - + if lat > self.max_latency: + self.max_latency = lat * 1.2 + bw_txt = bw_text_override if bw_text_override else f"{bandwidth:.1f} Mbps" - + point = PerformanceDataPoint( latency=latency, fps=fps, @@ -111,7 +118,7 @@ def add_data_point( latency_text=f"{latency:.0f} ms", fps_text=f"{fps:.0f} FPS", bandwidth_text=bw_txt, - users_count=users + users_count=users, ) self._history.append(point) self._cur_latency_text = point.latency_text @@ -162,7 +169,8 @@ def _on_draw(self, area, cr, width, height): margin_bottom = 30 chart_width = width - margin_left - margin_right chart_height = height - margin_top - margin_bottom - if chart_width <= 0 or chart_height <= 0: return + if chart_width <= 0 or chart_height <= 0: + return cr.set_source_rgba(0.3, 0.3, 0.3, 0.3) cr.set_line_width(1) for i in range(4): @@ -175,7 +183,7 @@ def _on_draw(self, area, cr, width, height): cr.set_font_size(14) text = _("Waiting for data...") extents = cr.text_extents(text) - cr.move_to(margin_left + (chart_width - extents.width)/2, margin_top + chart_height/2) + cr.move_to(margin_left + (chart_width - extents.width) / 2, margin_top + chart_height / 2) cr.show_text(text) return lat_vals = [p.latency for p in self._history] @@ -195,7 +203,7 @@ def _on_draw(self, area, cr, width, height): for dev_name in active_devices: dev_vals = [] for p in self._history: - val = p.device_latencies.get(dev_name, 0) + val = p.device_latencies.get(dev_name, 0) dev_vals.append(val / max(1, self.max_latency)) color = self._get_device_color(dev_name) self._draw_line(cr, chart_width, chart_height, margin_left, margin_top, dev_vals, color, fill=False) @@ -220,12 +228,13 @@ def _on_draw(self, area, cr, width, height): pass def _draw_line(self, cr, w, h, mx, my, vals, color, fill=False): - if not vals: return + if not vals: + return cr.set_source_rgba(*color) cr.set_line_width(2) x_step = w / max(CHART_MAX_HISTORY - 1, 1) sx = mx + w - (len(vals) - 1) * x_step - for i, v in enumerate(vals): + for i, v in enumerate(vals): if i == 0: cr.move_to(sx + i * x_step, my + h * (1 - v)) else: @@ -233,7 +242,7 @@ def _draw_line(self, cr, w, h, mx, my, vals, color, fill=False): cr.stroke() if fill: cr.set_source_rgba(color[0], color[1], color[2], 0.15) - for i, v in enumerate(vals): + for i, v in enumerate(vals): if i == 0: cr.move_to(sx + i * x_step, my + h * (1 - v)) else: @@ -245,18 +254,20 @@ def _draw_line(self, cr, w, h, mx, my, vals, color, fill=False): def _draw_legend(self, cr, w, h, margin_left, active_devices=None): legend_y = h - 10 + def draw_item(label, val_text, color, x_offset): cr.set_source_rgba(*color) - cr.arc(margin_left + x_offset, legend_y - 4, 4, 0, 2*3.14159) + cr.arc(margin_left + x_offset, legend_y - 4, 4, 0, 2 * 3.14159) cr.fill() cr.set_source_rgba(0.9, 0.9, 0.9, 1) cr.set_font_size(11) cr.move_to(margin_left + x_offset + 10, legend_y) # Clean name for the legend - clean_label = label.split('(')[0].strip() + clean_label = label.split("(")[0].strip() text = f"{clean_label}: {val_text}" cr.show_text(text) return cr.text_extents(text).width + 30 + offset = 0 if not active_devices: offset += draw_item(_("Latency"), self._cur_latency_text, (1.0, 0.4, 0.0, 1.0), offset) @@ -267,7 +278,7 @@ def draw_item(label, val_text, color, x_offset): color = self._get_device_color(dev) offset += draw_item(dev, f"{val:.0f}ms", color, offset) offset += draw_item("FPS", self._cur_fps_text, (0.0, 0.8, 0.2, 1.0), offset) - if offset > w - 100: + if offset > w - 100: legend_y -= 15 offset = 0 draw_item("BW", self._cur_bw_text, (0.0, 0.6, 1.0, 1.0), offset) @@ -309,17 +320,18 @@ def _draw_tooltip(self, cr, w, h, mx, my, cw, ch): cr.show_text(line) y_off += 14 + class PerformanceMonitor(Gtk.Box): """ Wrapper for performance chart. Replaces old text box. """ - + def __init__(self, sunshine=None): super().__init__(orientation=Gtk.Orientation.VERTICAL) self.sunshine = sunshine self.hostname_cache = {} - self.add_css_class('card') + self.add_css_class("card") self.set_margin_top(12) self.set_margin_bottom(12) self.set_margin_start(12) @@ -349,27 +361,27 @@ def __init__(self, sunshine=None): self._details_frame.set_margin_end(12) self._details_frame.set_visible(False) self._details_list = Gtk.ListBox() - self._details_list.add_css_class('boxed-list') + self._details_list.add_css_class("boxed-list") self._details_frame.set_child(self._details_list) self.append(self._details_frame) self.chart = PerformanceChartWidget() self.append(self.chart) - + self.update_timer_active = False self._target_fps = 60.0 self._target_bw = 10.0 self._last_fps = 60.0 self._last_bandwidth = 10.0 - + # Cache for device persistence # Key: IP, Value: {'name': str, 'last_seen': float, 'last_latency': float} - self._known_devices = {} - + self._known_devices = {} + self._data_queue = queue.Queue() self._worker_thread = None self._worker_running = False self._worker_event = threading.Event() - + def set_target_fps(self, fps): """Sets the expected FPS for idle display""" try: @@ -379,7 +391,8 @@ def set_target_fps(self, fps): # Update current view if idle (fps == 0 or default 60) if self._last_fps == 60.0 or self._last_fps == 0: self._last_fps = val - except Exception: pass + except Exception: + pass def set_target_bandwidth(self, mbps): try: @@ -388,29 +401,29 @@ def set_target_bandwidth(self, mbps): # If idle (default 10), update if self._last_bandwidth == 10.0 or self._last_bandwidth == 0: self._last_bandwidth = val - except Exception: pass + except Exception: + pass def start_monitoring(self): - if self.update_timer_active: return + if self.update_timer_active: + return self.update_timer_active = True GLib.timeout_add(100, self._process_data_queue) self._start_worker_thread() self.update_stats(0, 0, 0, []) - def stop_monitoring(self): - if not self.update_timer_active: return + def stop_monitoring(self): + if not self.update_timer_active: + return self.update_timer_active = False self._stop_worker_thread() def _start_worker_thread(self): - if self._worker_running: return + if self._worker_running: + return self._worker_running = True self._worker_event.clear() - self._worker_thread = threading.Thread( - target=self._worker_loop, - name="PerformanceMonitor-Worker", - daemon=True - ) + self._worker_thread = threading.Thread(target=self._worker_loop, name="PerformanceMonitor-Worker", daemon=True) self._worker_thread.start() def _stop_worker_thread(self): @@ -423,16 +436,18 @@ def _worker_loop(self): time.sleep(1) while self._worker_running: try: - if not self._worker_running: break + if not self._worker_running: + break self._fetch_and_process_data() - for _ in range(10): # ~1 segundo de pausa + for _ in range(10): # ~1 segundo de pausa if not self._worker_running or self._worker_event.wait(timeout=0.1): break except Exception: time.sleep(2) def _process_data_queue(self): - if not self.update_timer_active: return False + if not self.update_timer_active: + return False try: processed_count = 0 while not self._data_queue.empty() and processed_count < 10: @@ -443,7 +458,7 @@ def _process_data_queue(self): else: latency, fps, bandwidth, sessions, device_latencies = data bw_text = None - + self.update_stats(latency, fps, bandwidth, sessions, device_latencies, bw_text) processed_count += 1 except queue.Empty: @@ -454,24 +469,27 @@ def _process_data_queue(self): def _resolve_hostname(self, ip): """Resolves hostname with caching to avoid lag""" - if not ip or ip in ['0.0.0.0']: return None - if ip in ['127.0.0.1', '::1', 'localhost']: return "Localhost" + if not ip or ip in ["0.0.0.0"]: + return None + if ip in ["127.0.0.1", "::1", "localhost"]: + return "Localhost" if ip in self.hostname_cache: return self.hostname_cache[ip] - + try: # Short timeout socket.setdefaulttimeout(0.5) hostname = socket.gethostbyaddr(ip)[0] # Remove domain part if looks like a local domain - if '.local' in hostname: hostname = hostname.split('.')[0] + if ".local" in hostname: + hostname = hostname.split(".")[0] self.hostname_cache[ip] = hostname return hostname except Exception: # Cache failure too to avoid retrying constantly self.hostname_cache[ip] = None return None - + def _sunshine_auth(self) -> tuple[str, str] | None: """Reads Sunshine admin credentials from the system keyring, or None.""" from big_remote_play.utils.sunshine_credentials import load_sunshine_credentials @@ -482,8 +500,7 @@ def _prompt_disconnect(self, session_id, ip): """Offers a gentle app close vs a forceful IP eviction.""" dialog = Adw.MessageDialog( heading=_("Disconnect Guest"), - body=_("End the running game gently, or forcefully evict the network " - "address? Ending the game keeps the device paired."), + body=_("End the running game gently, or forcefully evict the network address? Ending the game keeps the device paired."), ) root = self.get_root() if isinstance(root, Gtk.Window): @@ -521,9 +538,11 @@ def work(): def _disconnect_session(self, session_id, ip): # We need at least an IP or session_id to try something - if not ip and not session_id: return - + if not ip and not session_id: + return + self.set_sensitive(False) + def do_disconnect(): success = False @@ -532,7 +551,7 @@ def do_disconnect(): # client would not end an in-progress stream. if ip: try: - script_path = paths.script_path('drop_guest.sh') + script_path = paths.script_path("drop_guest.sh") if os.path.exists(script_path): cmd = ["pkexec", script_path, ip] @@ -544,45 +563,51 @@ def do_disconnect(): pass GLib.idle_add(self._on_disconnect_done, success) - + threading.Thread(target=do_disconnect, daemon=True).start() - + def _on_disconnect_done(self, success): self.set_sensitive(True) if success: - # Force immediate update - self._process_data_queue() + # Force immediate update + self._process_data_queue() else: - # Show error (optional, toast would be better but we are inside widget) - pass + # Show error (optional, toast would be better but we are inside widget) + pass def _ping_host(self, ip): # Allow pinging localhost or ::1 for local testing - if not ip or ip in ['', 'Unknown IP', '0.0.0.0']: return 0.0 + if not ip or ip in ["", "Unknown IP", "0.0.0.0"]: + return 0.0 try: import platform + system = platform.system() # Force LOCALE C to ensure a decimal point and English message env = os.environ.copy() env["LC_ALL"] = "C" - - if system == "Linux": cmd = ["ping", "-c", "1", "-W", "1", "-n", ip] - elif system == "Darwin": cmd = ["ping", "-c", "1", "-t", "1", "-n", ip] - elif system == "Windows": cmd = ["ping", "-n", "1", "-w", "1000", ip] - else: cmd = ["ping", "-c", "1", "-n", ip] - + + if system == "Linux": + cmd = ["ping", "-c", "1", "-W", "1", "-n", ip] + elif system == "Darwin": + cmd = ["ping", "-c", "1", "-t", "1", "-n", ip] + elif system == "Windows": + cmd = ["ping", "-n", "1", "-w", "1000", ip] + else: + cmd = ["ping", "-c", "1", "-n", ip] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=1.5, env=env) - + if result.returncode == 0: # Robust regex for tempo=1.23, time=1.23, ttl... time=1.23 - match = re.search(r'(?:time|tempo|ttl)[=<]([\d\.,]+)\s*ms', result.stdout, re.IGNORECASE) + match = re.search(r"(?:time|tempo|ttl)[=<]([\d\.,]+)\s*ms", result.stdout, re.IGNORECASE) if match: - val_str = match.group(1).replace(',', '.') + val_str = match.group(1).replace(",", ".") return float(val_str) # Simple fallback - match_fallback = re.search(r'([\d\.,]+)\s*ms', result.stdout) + match_fallback = re.search(r"([\d\.,]+)\s*ms", result.stdout) if match_fallback: - val_str = match_fallback.group(1).replace(',', '.') + val_str = match_fallback.group(1).replace(",", ".") return float(val_str) return 0.0 except Exception: @@ -592,27 +617,29 @@ def _detect_sessions_via_ss(self): """Return a dict {ip: data} for easy lookup.""" found_sessions = {} try: - sunshine_ports = ['47984', '47989', '48010', '47998', '47999', '48000', '48002', '47990', '48001'] + sunshine_ports = ["47984", "47989", "48010", "47998", "47999", "48000", "48002", "47990", "48001"] cmd = ["ss", "-tun", "-a"] result = subprocess.run(cmd, capture_output=True, text=True, timeout=5) - if result.returncode != 0: return {} - + if result.returncode != 0: + return {} + for line in result.stdout.splitlines(): - if 'ESTAB' in line or 'UNCONN' in line: + if "ESTAB" in line or "UNCONN" in line: for port in sunshine_ports: if f":{port}" in line: parts = line.split() if len(parts) >= 5: last_part = parts[-1] # More permissive regex to capture the IP - ip_match = re.search(r'(\d+\.\d+\.\d+\.\d+)', last_part) + ip_match = re.search(r"(\d+\.\d+\.\d+\.\d+)", last_part) if ip_match: ip = ip_match.group(1) - if ip == '0.0.0.0': continue + if ip == "0.0.0.0": + continue # Allow localhost for testing (127.0.0.1) - + if ip not in found_sessions: - found_sessions[ip] = {'ip': ip, 'name': _('Guest'), 'latency': 0, 'fps': 60} + found_sessions[ip] = {"ip": ip, "name": _("Guest"), "latency": 0, "fps": 60} break except Exception: pass @@ -632,100 +659,91 @@ def _fetch_and_process_data(self): normalized_api_sessions = [] current_cycle_ips = set() for ip, data in ss_sessions_dict.items(): - hname = self._resolve_hostname(ip) or _('Guest') - normalized_api_sessions.append({'ip': ip, 'name': hname, 'source': 'ss', 'id': None}) + hname = self._resolve_hostname(ip) or _("Guest") + normalized_api_sessions.append({"ip": ip, "name": hname, "source": "ss", "id": None}) current_cycle_ips.add(ip) # 4. UPDATE THE KNOWN DEVICES LIST (persistence) # If an IP showed up now, update the timestamp. # If it didn't show up now, keep it in the list if it answers ping. - + now = time.time() - + # Insert new ones or update existing ones detected now for s in normalized_api_sessions: - ip = s['ip'] - name = s['name'] - if not ip: continue - + ip = s["ip"] + name = s["name"] + if not ip: + continue + # If already known, preserve the name if it is "Guest" now if ip in self._known_devices: - if name == _('Guest') and self._known_devices[ip]['name'] != _('Guest'): - name = self._known_devices[ip]['name'] - - self._known_devices[ip] = { - 'ip': ip, - 'name': name, - 'last_seen': now, - 'status': 'active' - } + if name == _("Guest") and self._known_devices[ip]["name"] != _("Guest"): + name = self._known_devices[ip]["name"] + + self._known_devices[ip] = {"ip": ip, "name": name, "last_seen": now, "status": "active"} # 5. PING AND CLEANUP # Iterate over ALL known devices, not only the active ones final_display_list = [] device_latencies = {} - + active_sessions_count = 0 - + ips_to_remove = [] - + for ip, data in self._known_devices.items(): # Check whether it is "active" this cycle (came from API or SS) is_active_cycle = ip in current_cycle_ips - + # ALWAYS PING to have data in the chart # This fixes the missing-data problem lat = self._ping_host(ip) - + # Persistence logic: # If ping > 0: keep it in the list as 'Online' # If ping == 0: # If it was active this cycle (API said it's there), keep it (could be a firewall blocking ping) # If it was NOT active this cycle, mark it for removal (timeout) - + if lat > 0: - data['last_latency'] = lat - data['last_seen'] = now # Renovamos "visto" se ping responde + data["last_latency"] = lat + data["last_seen"] = now # Renovamos "visto" se ping responde else: # If ping failed, use the last known value or 0 - lat = data.get('last_latency', 0) - + lat = data.get("last_latency", 0) + # Set the display name - display_name = data['name'] + display_name = data["name"] if ip not in display_name: display_name = f"{display_name} ({ip})" - + # Add a suffix if only in "ping mode" (no active stream) if not is_active_cycle and lat > 0: # Optional: indicate idle, but the user asked for PERPETUAL - pass - + pass + # If there is no sign of life (no API, no SS, no Ping) for X time, remove it - if not is_active_cycle and lat == 0 and (now - data['last_seen'] > 30): # 30 seconds tolerance + if not is_active_cycle and lat == 0 and (now - data["last_seen"] > 30): # 30 seconds tolerance ips_to_remove.append(ip) continue # Preparar objeto para a UI - session_obj = { - 'ip': ip, - 'name': display_name, - 'latency': lat, - 'id': None - } - + session_obj = {"ip": ip, "name": display_name, "latency": lat, "id": None} + # Find matching session to get ID for s in normalized_api_sessions: - if s['ip'] == ip: - session_obj['id'] = s.get('id') + if s["ip"] == ip: + session_obj["id"] = s.get("id") break - + # Find ID from api list if ip matches - + if is_active_cycle: active_sessions_count += 1 - + final_display_list.append(session_obj) - + # Add to the chart if it has latency if lat > 0: device_latencies[display_name] = lat @@ -739,23 +757,26 @@ def _fetch_and_process_data(self): latency_avg = sum(device_latencies.values()) / len(device_latencies) # Keep FPS/BW stable - if fps == 0: fps = self._last_fps if self._last_fps > 0 else self._target_fps - else: self._last_fps = fps - + if fps == 0: + fps = self._last_fps if self._last_fps > 0 else self._target_fps + else: + self._last_fps = fps + bw_txt_override = None - if bandwidth == 0: + if bandwidth == 0: bandwidth = self._last_bandwidth if self._last_bandwidth > 0 else (self._target_bw if self._target_bw > 0 else 1.0) - if self._target_bw == 0: - bw_txt_override = "Unlimited" - if bandwidth < 100: bandwidth = 100.0 # Dummy value for visual scale - else: + if self._target_bw == 0: + bw_txt_override = "Unlimited" + if bandwidth < 100: + bandwidth = 100.0 # Dummy value for visual scale + else: self._last_bandwidth = bandwidth if self._target_bw == 0: - bw_txt_override = f"{bandwidth:.1f} Mbps (Unlim)" + bw_txt_override = f"{bandwidth:.1f} Mbps (Unlim)" # Send to the UI self._data_queue.put((latency_avg, fps, bandwidth, final_display_list, device_latencies, bw_txt_override)) - + except Exception: pass @@ -769,17 +790,19 @@ def update_stats( bw_text: str | None = None, ): try: - if not self.update_timer_active: return + if not self.update_timer_active: + return sessions, device_latencies = sessions or [], device_latencies or {} - + # The chart receives device_latencies, containing ALL that answered the ping self.chart.add_data_point(latency, fps, bandwidth, users=len(sessions), device_latencies=device_latencies, bw_text_override=bw_text) - + if len(sessions) > 0: if len(sessions) == 1: - guest_name = sessions[0].get('name', 'Sunshine') + guest_name = sessions[0].get("name", "Sunshine") # Clean name for title - if '(' in guest_name: guest_name = guest_name.split('(')[0].strip() + if "(" in guest_name: + guest_name = guest_name.split("(")[0].strip() self.set_connection_status(guest_name, _("Active Connection"), True) else: self.set_connection_status("Sunshine", _("{} devices monitoring").format(len(sessions)), True) @@ -787,7 +810,7 @@ def update_stats( else: self.set_connection_status("Sunshine", _("Active - No devices"), True) self._details_frame.set_visible(False) - + self._update_guest_list(sessions) except Exception: pass @@ -797,28 +820,31 @@ def _update_guest_list(self, sessions): self._details_list.remove(child) for s in sessions: row = Adw.ActionRow() - full_name = s.get('name', _('Guest')) - ip = s.get('ip', 'Unknown IP') - latency = s.get('latency', 0) - + full_name = s.get("name", _("Guest")) + ip = s.get("ip", "Unknown IP") + latency = s.get("latency", 0) + # Split Name and IP for a cleaner look - if '(' in full_name: - name_part = full_name.split('(')[0].strip() + if "(" in full_name: + name_part = full_name.split("(")[0].strip() else: name_part = full_name - + row.set_title(name_part) row.set_subtitle(f"IP: {ip}") - + ping_lbl = Gtk.Label(label=f"{latency:.0f} ms") if latency <= 0: ping_lbl.set_label("-- ms") - ping_lbl.add_css_class('error') - elif latency < 15: ping_lbl.add_css_class('success') - elif latency < 50: ping_lbl.add_css_class('warning') - else: ping_lbl.add_css_class('error') - - if hasattr(self.chart, '_get_device_color'): + ping_lbl.add_css_class("error") + elif latency < 15: + ping_lbl.add_css_class("success") + elif latency < 50: + ping_lbl.add_css_class("warning") + else: + ping_lbl.add_css_class("error") + + if hasattr(self.chart, "_get_device_color"): try: # Use the full name to keep the same color as the chart color = self.chart._get_device_color(full_name) @@ -826,33 +852,38 @@ def _update_guest_list(self, sessions): da.set_content_width(24) da.set_content_height(24) da.set_valign(Gtk.Align.CENTER) + def draw_indicator(area, cr, width, height, color=color): cr.set_source_rgba(*color) - cr.arc(width/2, height/2, 5, 0, 2 * 3.14159) + cr.arc(width / 2, height / 2, 5, 0, 2 * 3.14159) cr.fill() + da.set_draw_func(draw_indicator) row.add_prefix(da) - except Exception: pass - + except Exception: + pass + row.add_suffix(ping_lbl) - + # Disconnect button (If we have an ID or IP) - if s.get('id') or s.get('ip'): + if s.get("id") or s.get("ip"): disc_btn = Gtk.Button() - disc_btn.set_icon_name("network-offline-symbolic") + disc_btn.set_icon_name("network-offline-symbolic") disc_btn.add_css_class("flat") disc_btn.add_css_class("destructive-action") disc_btn.set_tooltip_text(_("Disconnect this specific guest (Admin)")) disc_btn.set_valign(Gtk.Align.CENTER) disc_btn.update_property([Gtk.AccessibleProperty.LABEL], [_("Disconnect guest")]) - disc_btn.connect("clicked", lambda b, sid=s.get('id'), sip=s.get('ip'): self._prompt_disconnect(sid, sip)) + disc_btn.connect("clicked", lambda b, sid=s.get("id"), sip=s.get("ip"): self._prompt_disconnect(sid, sip)) row.add_suffix(disc_btn) - + self._details_list.append(row) def set_connection_status(self, name, status, conn=True): - if conn: self._title_label.set_label(_("Connected to {}").format(name)) - else: self._title_label.set_label(_("Real-time Monitoring")) + if conn: + self._title_label.set_label(_("Connected to {}").format(name)) + else: + self._title_label.set_label(_("Real-time Monitoring")) self._status_label.set_label(status) if conn: set_icon(self._status_icon, "network-transmit-receive-symbolic") diff --git a/src/big_remote_play/ui/preferences.py b/src/big_remote_play/ui/preferences.py index 1a8f6d1..50e66c2 100644 --- a/src/big_remote_play/ui/preferences.py +++ b/src/big_remote_play/ui/preferences.py @@ -3,8 +3,9 @@ """ import gi -gi.require_version('Gtk', '4.0') -gi.require_version('Adw', '1') + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") from gi.repository import Gtk, Adw # type: ignore import os, shutil, sys @@ -14,146 +15,147 @@ import big_remote_play.utils.logger as logger from big_remote_play.utils.i18n import _ + class PreferencesWindow(Adw.PreferencesWindow): """Preferences window""" - + def __init__(self, **kwargs): - self.config = kwargs.pop('config', None) - self.initial_tab = kwargs.pop('initial_tab', None) + self.config = kwargs.pop("config", None) + self.initial_tab = kwargs.pop("initial_tab", None) super().__init__(**kwargs) - - self.set_title(_('Preferences')) + + self.set_title(_("Preferences")) self.set_default_size(600, 500) self.set_modal(True) - + self.logger = logger.Logger() if not self.config: from big_remote_play.utils.config import Config + self.config = Config() - + self.setup_ui() - + def setup_ui(self): """Configures interface""" # General page general_page = Adw.PreferencesPage() - general_page.set_title(_('General')) - general_page.set_icon_name('preferences-system-symbolic') - + general_page.set_title(_("General")) + general_page.set_icon_name("preferences-system-symbolic") + # Appearance group appearance_group = Adw.PreferencesGroup() - appearance_group.set_title(_('Appearance')) - + appearance_group.set_title(_("Appearance")) + # Theme theme_row = Adw.ComboRow() - theme_row.set_title(_('Theme')) - theme_row.set_subtitle(_('Choose the color scheme')) - + theme_row.set_title(_("Theme")) + theme_row.set_subtitle(_("Choose the color scheme")) + theme_model = Gtk.StringList() - theme_model.append(_('Automatic')) - theme_model.append(_('Light')) - theme_model.append(_('Dark')) - + theme_model.append(_("Automatic")) + theme_model.append(_("Light")) + theme_model.append(_("Dark")) + theme_row.set_model(theme_model) theme_row.set_model(theme_model) - + # Load current theme - current_theme = self.config.get('theme', 'auto') + current_theme = self.config.get("theme", "auto") idx = 0 - if current_theme == 'light': idx = 1 - elif current_theme == 'dark': idx = 2 + if current_theme == "light": + idx = 1 + elif current_theme == "dark": + idx = 2 theme_row.set_selected(idx) - - theme_row.connect('notify::selected', self.on_theme_changed) - + + theme_row.connect("notify::selected", self.on_theme_changed) + appearance_group.add(theme_row) general_page.add(appearance_group) - + # Restore group restore_group = Adw.PreferencesGroup() - restore_group.set_title(_('Restore')) - + restore_group.set_title(_("Restore")) + # Restore Defaults restore_row = Adw.ActionRow() - restore_row.set_title(_('Restore Defaults')) - restore_row.set_subtitle(_('Reset all settings to default')) - - restore_btn = Gtk.Button(label=_('Restore')) + restore_row.set_title(_("Restore Defaults")) + restore_row.set_subtitle(_("Reset all settings to default")) + + restore_btn = Gtk.Button(label=_("Restore")) restore_btn.set_valign(Gtk.Align.CENTER) - restore_btn.connect('clicked', self.on_restore_defaults_clicked) + restore_btn.connect("clicked", self.on_restore_defaults_clicked) restore_row.add_suffix(restore_btn) restore_group.add(restore_row) - + # Clear All clear_all_row = Adw.ActionRow() - clear_all_row.set_title(_('Clear Everything')) - clear_all_row.set_subtitle(_('Remove logs, settings, servers and saved data')) - - clear_all_btn = Gtk.Button(label=_('Clear Everything')) - clear_all_btn.add_css_class('destructive-action') + clear_all_row.set_title(_("Clear Everything")) + clear_all_row.set_subtitle(_("Remove logs, settings, servers and saved data")) + + clear_all_btn = Gtk.Button(label=_("Clear Everything")) + clear_all_btn.add_css_class("destructive-action") clear_all_btn.set_valign(Gtk.Align.CENTER) - clear_all_btn.connect('clicked', self.on_clear_all_clicked) + clear_all_btn.connect("clicked", self.on_clear_all_clicked) clear_all_row.add_suffix(clear_all_btn) restore_group.add(clear_all_row) - + general_page.add(restore_group) - # Advanced page advanced_page = Adw.PreferencesPage() - advanced_page.set_title(_('Advanced')) - advanced_page.set_icon_name('preferences-other-symbolic') - + advanced_page.set_title(_("Advanced")) + advanced_page.set_icon_name("preferences-other-symbolic") + # Paths group paths_group = Adw.PreferencesGroup() - paths_group.set_title(_('Paths')) - + paths_group.set_title(_("Paths")) + config_row = Adw.ActionRow() - config_row.set_title(_('Configuration Directory')) - config_row.set_subtitle('~/.config/big-remoteplay') - + config_row.set_title(_("Configuration Directory")) + config_row.set_subtitle("~/.config/big-remoteplay") + copy_config_btn = Gtk.Button() - copy_config_btn.set_child(create_icon_widget('edit-copy-symbolic')) - copy_config_btn.add_css_class('flat') + copy_config_btn.set_child(create_icon_widget("edit-copy-symbolic")) + copy_config_btn.add_css_class("flat") copy_config_btn.set_valign(Gtk.Align.CENTER) copy_config_btn.set_tooltip_text(_("Copy Path")) - copy_config_btn.connect('clicked', self.copy_config_path) + copy_config_btn.connect("clicked", self.copy_config_path) config_row.add_suffix(copy_config_btn) config_row.set_activatable_widget(copy_config_btn) - + paths_group.add(config_row) - + # Logs group logs_group = Adw.PreferencesGroup() - logs_group.set_title(_('Logs and Debugging')) - + logs_group.set_title(_("Logs and Debugging")) + verbose_row = Adw.SwitchRow() - verbose_row.set_title(_('Detailed Logs')) - verbose_row.set_subtitle(_('Enable verbose logging for debugging')) + verbose_row.set_title(_("Detailed Logs")) + verbose_row.set_subtitle(_("Enable verbose logging for debugging")) verbose_row.set_active(False) logs_group.add(verbose_row) - + clear_logs_row = Adw.ActionRow() - clear_logs_row.set_title(_('Clear Logs')) - clear_logs_row.set_subtitle(_('Remove old log files')) - - clear_btn = Gtk.Button(label=_('Clear')) - clear_btn.add_css_class('destructive-action') + clear_logs_row.set_title(_("Clear Logs")) + clear_logs_row.set_subtitle(_("Remove old log files")) + + clear_btn = Gtk.Button(label=_("Clear")) + clear_btn.add_css_class("destructive-action") clear_btn.set_valign(Gtk.Align.CENTER) clear_logs_row.add_suffix(clear_btn) - + logs_group.add(clear_logs_row) - + # Connect signals - verbose_row.set_active(bool(self.config.get('verbose_logging', False))) - verbose_row.connect('notify::active', self.on_verbose_toggled) - clear_btn.connect('clicked', self.on_clear_logs_clicked) - + verbose_row.set_active(bool(self.config.get("verbose_logging", False))) + verbose_row.connect("notify::active", self.on_verbose_toggled) + clear_btn.connect("clicked", self.on_clear_logs_clicked) + advanced_page.add(paths_group) advanced_page.add(logs_group) - - # Add pages. Sunshine/Moonlight tuning is NOT duplicated here — it lives # with its task (Server → Advanced server settings; Connect → Advanced # client settings), so there is one place per task. @@ -162,24 +164,29 @@ def setup_ui(self): def on_theme_changed(self, row, param): idx = row.get_selected() - theme = 'auto' - if idx == 1: theme = 'light' - elif idx == 2: theme = 'dark' - - self.config.set('theme', theme) - + theme = "auto" + if idx == 1: + theme = "light" + elif idx == 2: + theme = "dark" + + self.config.set("theme", theme) + sm = Adw.StyleManager.get_default() - if theme == 'dark': sm.set_color_scheme(Adw.ColorScheme.FORCE_DARK) - elif theme == 'light': sm.set_color_scheme(Adw.ColorScheme.FORCE_LIGHT) - else: sm.set_color_scheme(Adw.ColorScheme.DEFAULT) + if theme == "dark": + sm.set_color_scheme(Adw.ColorScheme.FORCE_DARK) + elif theme == "light": + sm.set_color_scheme(Adw.ColorScheme.FORCE_LIGHT) + else: + sm.set_color_scheme(Adw.ColorScheme.DEFAULT) def on_verbose_toggled(self, row, param): enabled = row.get_active() - self.config.set('verbose_logging', enabled) + self.config.set("verbose_logging", enabled) self.logger = logger.Logger(force_new=True) self.logger.set_verbose(enabled) self.logger.info(f"Verbose logging {'enabled' if enabled else 'disabled'}") - + def on_clear_logs_clicked(self, button): self.logger.clear_old_logs() diag = Adw.MessageDialog(heading=_("Logs Limpos"), body=_("Arquivos de log antigos foram removidos.")) @@ -187,56 +194,54 @@ def on_clear_logs_clicked(self, button): diag.set_transient_for(self) diag.present() - - def copy_config_path(self, btn): - path = os.path.expanduser('~/.config/big-remoteplay') + path = os.path.expanduser("~/.config/big-remoteplay") self.get_clipboard().set(path) self.add_toast(Adw.Toast.new(_("Path copied!"))) - def on_restore_defaults_clicked(self, button): # Updated text as requested msg = _("All your changes in Big Remote Play will be restored! This includes Sunshine and Moonlight settings.") - + dialog = Adw.MessageDialog(heading=_("Restore Defaults?"), body=msg) dialog.add_response("cancel", _("Cancel")) dialog.add_response("restore", _("Restore")) dialog.set_response_appearance("restore", Adw.ResponseAppearance.DESTRUCTIVE) dialog.set_transient_for(self) - + def on_response(d, r): if r == "restore": try: # 1. Reset Main Config (config.json) - # We load defaults and apply them. + # We load defaults and apply them. # Ideally we should clear unknown keys too, but setting defaults covers most. default_conf = self.config.default_config() for k, v in default_conf.items(): self.config.set(k, v) self.config.save() - + # 2. Reset Sunshine Config (sunshine.conf) # Delete the file so it regenerates cleanly or starts empty - sunshine_conf = Path.home() / '.config' / 'big-remoteplay' / 'sunshine' / 'sunshine.conf' + sunshine_conf = Path.home() / ".config" / "big-remoteplay" / "sunshine" / "sunshine.conf" if sunshine_conf.exists(): os.remove(sunshine_conf) print("Sunshine config deleted (reset)") - + # 3. Reset Moonlight Config from big_remote_play.utils.moonlight_config import MoonlightConfigManager + mc = MoonlightConfigManager() if mc.config_file and mc.config_file.exists(): os.remove(mc.config_file) - mc.reload() # Recreates Default + mc.reload() # Recreates Default mc.save() - + self.add_toast(Adw.Toast.new(_("Settings restored! Restart the application to apply all changes."))) self.close() except Exception as e: print(f"Error resetting configs: {e}") self.add_toast(Adw.Toast.new(_("Error restoring: {}").format(e))) - + dialog.connect("response", on_response) dialog.present() @@ -247,7 +252,7 @@ def on_clear_all_clicked(self, button): dialog1.add_response("clear", _("Delete All")) dialog1.set_response_appearance("clear", Adw.ResponseAppearance.DESTRUCTIVE) dialog1.set_transient_for(self) - + def on_response1(d, r): if r == "clear": # Second confirmation (Double Check) @@ -256,37 +261,37 @@ def on_response1(d, r): dialog2.add_response("destroy", _("Yes, Delete All")) dialog2.set_response_appearance("destroy", Adw.ResponseAppearance.DESTRUCTIVE) dialog2.set_transient_for(self) - + def on_response2(d2, r2): if r2 == "destroy": self._perform_clear_all() - + dialog2.connect("response", on_response2) dialog2.present() - + dialog1.connect("response", on_response1) dialog1.present() def _perform_clear_all(self): try: # 1. Config Dir (~/.config/big-remoteplay) - config_dir = Path.home() / '.config' / 'big-remoteplay' + config_dir = Path.home() / ".config" / "big-remoteplay" if config_dir.exists(): shutil.rmtree(config_dir) - + # 2. Cache/Logs (~/.cache/big-remoteplay) - cache_dir = Path.home() / '.cache' / 'big-remoteplay' + cache_dir = Path.home() / ".cache" / "big-remoteplay" if cache_dir.exists(): shutil.rmtree(cache_dir) - + # 3. Moonlight Config (~/.config/Moonlight Game Streaming Project) - moon_dir = Path.home() / '.config' / 'Moonlight Game Streaming Project' + moon_dir = Path.home() / ".config" / "Moonlight Game Streaming Project" if moon_dir.exists(): - shutil.rmtree(moon_dir) - + shutil.rmtree(moon_dir) + # 4. Moonlight Flatpak/Var Config (if any) # Not deleting global flatpak data to be safe, but can check specific paths if needed. - + print("All data cleared.") # Quit app app = self.get_application() @@ -294,7 +299,7 @@ def _perform_clear_all(self): app.quit() else: sys.exit(0) - + except Exception as e: err_dlg = Adw.MessageDialog(heading=_("Error Clearing"), body=str(e)) err_dlg.add_response("ok", _("OK")) diff --git a/src/big_remote_play/utils/__init__.py b/src/big_remote_play/utils/__init__.py index 5b5afdb..110404f 100644 --- a/src/big_remote_play/utils/__init__.py +++ b/src/big_remote_play/utils/__init__.py @@ -5,4 +5,4 @@ from .network import NetworkDiscovery from .system_check import SystemCheck -__all__ = ['Config', 'Logger', 'NetworkDiscovery', 'SystemCheck'] +__all__ = ["Config", "Logger", "NetworkDiscovery", "SystemCheck"] diff --git a/src/big_remote_play/utils/audio.py b/src/big_remote_play/utils/audio.py index 74add88..b508b70 100644 --- a/src/big_remote_play/utils/audio.py +++ b/src/big_remote_play/utils/audio.py @@ -3,8 +3,10 @@ from typing import List, Dict, Optional import time from big_remote_play.utils.i18n import _ + _log = logging.getLogger("big-remoteplay") + class AudioManager: """ Simplified and robust Audio Manager for Big Remote Play. @@ -18,8 +20,8 @@ def is_virtual(self, name: str, description: str = "") -> bool: n = name.lower() d = description.lower() # Filtra nomes de sinks conhecidamente virtuais ou nossos - virtual_patterns = ['sunshine', 'null-sink', 'module-combine-sink', 'combined', 'easyeffects'] - return any(x in n or x in d for x in virtual_patterns) or n.endswith('.monitor') + virtual_patterns = ["sunshine", "null-sink", "module-combine-sink", "combined", "easyeffects"] + return any(x in n or x in d for x in virtual_patterns) or n.endswith(".monitor") def get_passive_sinks(self) -> List[Dict[str, str]]: """ @@ -29,27 +31,30 @@ def get_passive_sinks(self) -> List[Dict[str, str]]: sinks = [] try: # Get sinks with pactl - res = subprocess.run(['pactl', 'list', 'sinks'], capture_output=True, text=True, timeout=10) - if res.returncode != 0: return [] - + res = subprocess.run(["pactl", "list", "sinks"], capture_output=True, text=True, timeout=10) + if res.returncode != 0: + return [] + current = {} for line in res.stdout.splitlines(): line = line.strip() - if line.startswith('Sink #'): - if current: sinks.append(current) - current = {'id': line.split('#')[1]} - elif line.startswith('Name:'): - current['name'] = line.split(':', 1)[1].strip() - elif line.startswith('Description:'): - current['description'] = line.split(':', 1)[1].strip() - if current: sinks.append(current) - + if line.startswith("Sink #"): + if current: + sinks.append(current) + current = {"id": line.split("#")[1]} + elif line.startswith("Name:"): + current["name"] = line.split(":", 1)[1].strip() + elif line.startswith("Description:"): + current["description"] = line.split(":", 1)[1].strip() + if current: + sinks.append(current) + # Filtrar valid_sinks = [] for s in sinks: - if not self.is_virtual(s.get('name', ''), s.get('description', '')): + if not self.is_virtual(s.get("name", ""), s.get("description", "")): valid_sinks.append(s) - + return valid_sinks except Exception as e: _log.error(f"Error listing sinks: {e}") @@ -57,14 +62,16 @@ def get_passive_sinks(self) -> List[Dict[str, str]]: def get_default_sink(self) -> Optional[str]: try: - res = subprocess.run(['pactl', 'get-default-sink'], capture_output=True, text=True, timeout=10) + res = subprocess.run(["pactl", "get-default-sink"], capture_output=True, text=True, timeout=10) return res.stdout.strip() if res.returncode == 0 else None - except Exception: return None + except Exception: + return None def set_default_sink(self, sink_name: str): try: - subprocess.run(['pactl', 'set-default-sink', sink_name], check=False, timeout=10) - except Exception: pass + subprocess.run(["pactl", "set-default-sink", sink_name], check=False, timeout=10) + except Exception: + pass def enable_streaming_audio(self, host_sink: str, guest_only: bool = False) -> bool: """ @@ -76,83 +83,86 @@ def enable_streaming_audio(self, host_sink: str, guest_only: bool = False) -> bo if not host_sink or self.is_virtual(host_sink): hardware_devices = self.get_passive_sinks() if hardware_devices: - host_sink = hardware_devices[0]['name'] + host_sink = hardware_devices[0]["name"] _log.info(f"Host sink was virtual or null, fallback to hardware: {host_sink}") else: _log.error("ERROR: No hardware audio device found.") return False # Clear before creating to avoid duplicates - self.disable_streaming_audio(None) - + self.disable_streaming_audio(None) + try: _log.info(f"Enabling Isolated Audio -> Sink: SunshineGameSink (Guest Only: {guest_only})") - + # 1. Create Null Sink - subprocess.run([ - 'pactl', 'load-module', 'module-null-sink', - 'sink_name=SunshineGameSink', - 'sink_properties=device.description=SunshineGameSink' - ], check=True, timeout=10) - + subprocess.run(["pactl", "load-module", "module-null-sink", "sink_name=SunshineGameSink", "sink_properties=device.description=SunshineGameSink"], check=True, timeout=10) + # 2. Add Loopback if not guest_only if not guest_only: # Ensure the monitor source exists time.sleep(0.2) - + # Check if host_sink is safe if host_sink == "SunshineGameSink": _log.error("ERROR: Cannot loopback to itself. Creating loopback skipped.") else: _log.info(f"Adding Loopback to {host_sink} for Host Monitoring") - subprocess.run([ - 'pactl', 'load-module', 'module-loopback', - 'source=SunshineGameSink.monitor', - f'sink={host_sink}', - 'sink_properties=device.description=SunshineLoopback', - 'latency_msec=60' # Stable latency - ], check=True, timeout=10) + subprocess.run( + [ + "pactl", + "load-module", + "module-loopback", + "source=SunshineGameSink.monitor", + f"sink={host_sink}", + "sink_properties=device.description=SunshineLoopback", + "latency_msec=60", # Stable latency + ], + check=True, + timeout=10, + ) # 3. Small delay and ensure volumes time.sleep(0.5) - subprocess.run(['pactl', 'set-sink-mute', 'SunshineGameSink', '0'], check=False, timeout=10) - subprocess.run(['pactl', 'set-sink-volume', 'SunshineGameSink', '100%'], check=False, timeout=10) + subprocess.run(["pactl", "set-sink-mute", "SunshineGameSink", "0"], check=False, timeout=10) + subprocess.run(["pactl", "set-sink-volume", "SunshineGameSink", "100%"], check=False, timeout=10) # 4. Set SunshineGameSink as default self.set_default_sink("SunshineGameSink") # 5. Verify creation time.sleep(0.2) - sinks = subprocess.run(['pactl', 'list', 'short', 'sinks'], capture_output=True, text=True, timeout=10).stdout - if 'SunshineGameSink' not in sinks: + sinks = subprocess.run(["pactl", "list", "short", "sinks"], capture_output=True, text=True, timeout=10).stdout + if "SunshineGameSink" not in sinks: _log.error("CRITICAL ERROR: SunshineGameSink was not created!") return False - + _log.info(f"Audio Activated: SunshineGameSink (Loopback to {host_sink}: {not guest_only})") return True - + except Exception as e: _log.error(f"Failed to enable audio streaming: {e}") - self.disable_streaming_audio(host_sink) + self.disable_streaming_audio(host_sink) return False - def set_host_monitoring(self, host_sink: str, enabled: bool) -> bool: """ Enables or disables local monitoring (Loopback) of the GameSink. """ - if not host_sink: return False - + if not host_sink: + return False + # 1. Always try to unload existing loopback first try: - res = subprocess.run(['pactl', 'list', 'short', 'modules'], capture_output=True, text=True, timeout=10) + res = subprocess.run(["pactl", "list", "short", "modules"], capture_output=True, text=True, timeout=10) if res.returncode == 0: for line in res.stdout.splitlines(): - if 'SunshineLoopback' in line or 'source=SunshineGameSink.monitor' in line: + if "SunshineLoopback" in line or "source=SunshineGameSink.monitor" in line: mod_id = line.split()[0] _log.info(f"Unloading old loopback: {mod_id}") - subprocess.run(['pactl', 'unload-module', mod_id], check=False, timeout=10) - except Exception: pass + subprocess.run(["pactl", "unload-module", mod_id], check=False, timeout=10) + except Exception: + pass if not enabled: _log.info("Host monitoring disabled (Muted)") @@ -166,29 +176,26 @@ def set_host_monitoring(self, host_sink: str, enabled: bool) -> bool: return False _log.info(f"Loading host loopback monitoring -> {host_sink}") - subprocess.run([ - 'pactl', 'load-module', 'module-loopback', - 'source=SunshineGameSink.monitor', - f'sink={host_sink}', - 'sink_properties=device.description=SunshineLoopback', - 'latency_msec=60' - ], check=True, timeout=10) + subprocess.run( + ["pactl", "load-module", "module-loopback", "source=SunshineGameSink.monitor", f"sink={host_sink}", "sink_properties=device.description=SunshineLoopback", "latency_msec=60"], + check=True, + timeout=10, + ) return True except Exception as e: _log.error(f"Error loading loopback: {e}") return False def get_sink_monitor_source(self, sink_name: str) -> Optional[str]: - """ Returns monitor name for a sink. Avoids issues where monitor name is not exactly .monitor """ try: # pactl list sources short returns: ID Name ... - res = subprocess.run(['pactl', 'list', 'short', 'sources'], capture_output=True, text=True, timeout=10) + res = subprocess.run(["pactl", "list", "short", "sources"], capture_output=True, text=True, timeout=10) candidate = f"{sink_name}.monitor" - + for line in res.stdout.splitlines(): parts = line.split() if len(parts) > 1: @@ -196,19 +203,19 @@ def get_sink_monitor_source(self, sink_name: str) -> Optional[str]: # Exact match or default monitor if source_name == candidate: return source_name - + # If not found exact, try finding one containing sink name and 'monitor' # risky but better than failing for line in res.stdout.splitlines(): - parts = line.split() - if len(parts) > 1: - nm = parts[1] - if sink_name in nm and 'monitor' in nm: - return nm - - return candidate # Fallback + parts = line.split() + if len(parts) > 1: + nm = parts[1] + if sink_name in nm and "monitor" in nm: + return nm + + return candidate # Fallback except Exception: - return f"{sink_name}.monitor" + return f"{sink_name}.monitor" def disable_streaming_audio(self, host_sink: Optional[str]) -> None: """ @@ -218,35 +225,36 @@ def disable_streaming_audio(self, host_sink: Optional[str]) -> None: # 1. Restore default (if not virtual) if host_sink and not self.is_virtual(host_sink): self.set_default_sink(host_sink) - + # Restore apps that might be stuck on the virtual sink try: apps = self.get_apps() for app in apps: # Move all apps from virtual sinks back to host_sink - if self.is_virtual(app.get('sink_name', '')): - _log.info(f"Restoring {app.get('name')} to {host_sink}") - self.move_app(app['id'], host_sink) + if self.is_virtual(app.get("sink_name", "")): + _log.info(f"Restoring {app.get('name')} to {host_sink}") + self.move_app(app["id"], host_sink) except Exception as e: _log.error(f"Error restoring apps to hardware: {e}") - + # 2. Unload specific modules try: - res = subprocess.run(['pactl', 'list', 'short', 'modules'], capture_output=True, text=True, timeout=10) + res = subprocess.run(["pactl", "list", "short", "modules"], capture_output=True, text=True, timeout=10) if res.returncode == 0: for line in res.stdout.splitlines(): # Search criteria to unload: # - null-sink module with our name (GameSink) # - Our loopbacks - if 'sink_name=SunshineGameSink' in line or \ - 'sink_name=SunshineStereo' in line or \ - 'sink_name=SunshineHybrid' in line or \ - 'source=SunshineGameSink.monitor' in line or \ - 'SunshineLoopback' in line: - + if ( + "sink_name=SunshineGameSink" in line + or "sink_name=SunshineStereo" in line + or "sink_name=SunshineHybrid" in line + or "source=SunshineGameSink.monitor" in line + or "SunshineLoopback" in line + ): mod_id = line.split()[0] _log.info(f"Cleaning audio module: {mod_id}") - subprocess.run(['pactl', 'unload-module', mod_id], check=False, timeout=10) + subprocess.run(["pactl", "unload-module", mod_id], check=False, timeout=10) except Exception as e: _log.error(f"Error cleaning modules: {e}") @@ -258,54 +266,61 @@ def get_apps(self) -> List[Dict]: try: # ID mapping -> Sink Name for reference sinks_map = {} - res_s = subprocess.run(['pactl', 'list', 'short', 'sinks'], capture_output=True, text=True, timeout=10) + res_s = subprocess.run(["pactl", "list", "short", "sinks"], capture_output=True, text=True, timeout=10) for l in res_s.stdout.splitlines(): p = l.split() - if len(p) > 1: sinks_map[p[0]] = p[1] + if len(p) > 1: + sinks_map[p[0]] = p[1] - res = subprocess.run(['pactl', 'list', 'sink-inputs'], capture_output=True, text=True, timeout=10) + res = subprocess.run(["pactl", "list", "sink-inputs"], capture_output=True, text=True, timeout=10) current = {} - + for line in res.stdout.splitlines(): line = line.strip() - if line.startswith('Sink Input #'): - if current: apps.append(current) - current = {'id': line.split('#')[1], 'name': _('Unknown'), 'icon': 'audio-x-generic-symbolic'} - elif line.startswith('Sink:'): - sid = line.split(':')[1].strip() - current['sink_id'] = sid - current['sink_name'] = sinks_map.get(sid, sid) - elif 'application.name = ' in line: - val = line.split('=', 1)[1].strip().strip('"') - if val: current['name'] = val - elif 'application.icon_name = ' in line: - val = line.split('=', 1)[1].strip().strip('"') - if val: current['icon'] = val - elif 'media.name = ' in line and current.get('name') == _('Unknown'): - val = line.split('=', 1)[1].strip().strip('"') - if val: current['name'] = val - - if current: apps.append(current) - + if line.startswith("Sink Input #"): + if current: + apps.append(current) + current = {"id": line.split("#")[1], "name": _("Unknown"), "icon": "audio-x-generic-symbolic"} + elif line.startswith("Sink:"): + sid = line.split(":")[1].strip() + current["sink_id"] = sid + current["sink_name"] = sinks_map.get(sid, sid) + elif "application.name = " in line: + val = line.split("=", 1)[1].strip().strip('"') + if val: + current["name"] = val + elif "application.icon_name = " in line: + val = line.split("=", 1)[1].strip().strip('"') + if val: + current["icon"] = val + elif "media.name = " in line and current.get("name") == _("Unknown"): + val = line.split("=", 1)[1].strip().strip('"') + if val: + current["name"] = val + + if current: + apps.append(current) + # Filter internal streams if necessary # Ignore internal PulseAudio/Pipewire streams that cause loops if moved def is_internal(name): n = name.lower() - return any(x in n for x in ['sunshine', 'monitor', 'loopback', 'simultaneous', 'combine', 'output to']) - - return [a for a in apps if not is_internal(a.get('name', ''))] - - except Exception: + return any(x in n for x in ["sunshine", "monitor", "loopback", "simultaneous", "combine", "output to"]) + + return [a for a in apps if not is_internal(a.get("name", ""))] + + except Exception: return [] def move_app(self, app_id: str, sink_name: str): try: - subprocess.run(['pactl', 'move-sink-input', str(app_id), sink_name], check=False, timeout=10) - except Exception: pass + subprocess.run(["pactl", "move-sink-input", str(app_id), sink_name], check=False, timeout=10) + except Exception: + pass def cleanup(self): """Cleans everything and tries to restore original sound""" # Tries to find real hardware to restore hardware = self.get_passive_sinks() - target = hardware[0]['name'] if hardware else None + target = hardware[0]["name"] if hardware else None self.disable_streaming_audio(target) diff --git a/src/big_remote_play/utils/config.py b/src/big_remote_play/utils/config.py index 979b45a..a10e469 100644 --- a/src/big_remote_play/utils/config.py +++ b/src/big_remote_play/utils/config.py @@ -7,37 +7,39 @@ import tempfile from pathlib import Path import logging + _log = logging.getLogger("big-remoteplay") + class Config: """Configuration manager""" - + def __init__(self): - self.config_dir = Path.home() / '.config' / 'big-remoteplay' - self.config_file = self.config_dir / 'config.json' - + self.config_dir = Path.home() / ".config" / "big-remoteplay" + self.config_file = self.config_dir / "config.json" + self.config_dir.mkdir(parents=True, exist_ok=True) - + self.config = self.load() - + def load(self): """Loads configuration""" if self.config_file.exists(): try: - with open(self.config_file, 'r') as f: + with open(self.config_file, "r") as f: return json.load(f) except Exception as e: _log.error(f"Error loading configuration: {e}") return self.default_config() else: return self.default_config() - + def save(self): """Saves configuration atomically (write temp + replace) to avoid corruption on crash""" try: - fd, tmp_path = tempfile.mkstemp(dir=self.config_dir, prefix='.config-', suffix='.tmp') + fd, tmp_path = tempfile.mkstemp(dir=self.config_dir, prefix=".config-", suffix=".tmp") try: - with os.fdopen(fd, 'w') as f: + with os.fdopen(fd, "w") as f: json.dump(self.config, f, indent=2) f.flush() os.fsync(f.fileno()) @@ -50,41 +52,41 @@ def save(self): raise except Exception as e: _log.error(f"Error saving configuration: {e}") - + def get(self, key, default=None): """Gets configuration value""" return self.config.get(key, default) - + def set(self, key, value): """Sets configuration value""" self.config[key] = value self.save() - + def default_config(self): """Returns default configuration""" return { - 'theme': 'auto', - 'network': { - 'upnp': True, - 'ipv6': True, - 'discovery': True, - 'sunshine_port': 47989, - 'streaming_port': 48010, + "theme": "auto", + "network": { + "upnp": True, + "ipv6": True, + "discovery": True, + "sunshine_port": 47989, + "streaming_port": 48010, + }, + "host": { + "max_players": 2, + "quality": "high", + "audio": True, + "input_sharing": True, }, - 'host': { - 'max_players': 2, - 'quality': 'high', - 'audio': True, - 'input_sharing': True, + "guest": { + "quality": "auto", + "audio": True, + "hw_decode": True, + "fullscreen": False, }, - 'guest': { - 'quality': 'auto', - 'audio': True, - 'hw_decode': True, - 'fullscreen': False, + "advanced": { + "verbose_logging": False, + "auto_start_sunshine": False, }, - 'advanced': { - 'verbose_logging': False, - 'auto_start_sunshine': False, - } } diff --git a/src/big_remote_play/utils/game_detector.py b/src/big_remote_play/utils/game_detector.py index fe96bc0..e015137 100644 --- a/src/big_remote_play/utils/game_detector.py +++ b/src/big_remote_play/utils/game_detector.py @@ -2,71 +2,76 @@ import json import re from pathlib import Path + _log = logging.getLogger("big-remoteplay") + class GameDetector: """Detects installed games on various platforms""" - + def __init__(self): self.home = Path.home() - + def detect_all(self): """Detects all supported games""" games = [] games.extend(self.detect_steam()) games.extend(self.detect_lutris()) games.extend(self.detect_heroic()) - return sorted(games, key=lambda x: x['name']) + return sorted(games, key=lambda x: x["name"]) def detect_steam(self): """Detects Steam games""" games = [] - steam_root = self.home / '.local/share/Steam' + steam_root = self.home / ".local/share/Steam" if not steam_root.exists(): - steam_root = self.home / '.steam/steam' - + steam_root = self.home / ".steam/steam" + if not steam_root.exists(): return [] - - library_folders = [steam_root / 'steamapps'] - + + library_folders = [steam_root / "steamapps"] + # Try reading libraryfolders.vdf to find other libraries - vdf_path = steam_root / 'steamapps' / 'libraryfolders.vdf' + vdf_path = steam_root / "steamapps" / "libraryfolders.vdf" if vdf_path.exists(): try: content = vdf_path.read_text() # Simple regex extraction paths = re.findall(r'"path"\s+"([^"]+)"', content) for p in paths: - lib_path = Path(p) / 'steamapps' + lib_path = Path(p) / "steamapps" # Avoid duplicates - if lib_path.resolve() != (steam_root / 'steamapps').resolve(): + if lib_path.resolve() != (steam_root / "steamapps").resolve(): library_folders.append(lib_path) except Exception as e: _log.error(f"Error reading Steam library: {e}") pass - + for lib in library_folders: - if not lib.exists(): continue - for acf in lib.glob('appmanifest_*.acf'): + if not lib.exists(): + continue + for acf in lib.glob("appmanifest_*.acf"): try: content = acf.read_text() name_match = re.search(r'"name"\s+"([^"]+)"', content) id_match = re.search(r'"appid"\s+"(\d+)"', content) - + if name_match and id_match: name = name_match.group(1) # Filter 'Steamworks Common Redistributables' and 'Proton' if "Steamworks" in name or "Proton" in name or "Runtime" in name: continue - - games.append({ - 'name': name, - 'id': id_match.group(1), - 'platform': 'Steam', - 'cmd': f'steam steam://rungameid/{id_match.group(1)}', - 'icon': 'steam' # Placeholder - }) + + games.append( + { + "name": name, + "id": id_match.group(1), + "platform": "Steam", + "cmd": f"steam steam://rungameid/{id_match.group(1)}", + "icon": "steam", # Placeholder + } + ) except Exception: pass return games @@ -74,29 +79,23 @@ def detect_steam(self): def detect_lutris(self): """Detects Lutris games via YAML config files""" games = [] - games_dir = self.home / '.config/lutris/games' - + games_dir = self.home / ".config/lutris/games" + if games_dir.exists(): - for p in games_dir.glob('*.yml'): + for p in games_dir.glob("*.yml"): try: content = p.read_text() name = None slug = p.stem - + # Simple linewise YAML parser for line in content.splitlines(): - if line.strip().startswith('name:'): - name = line.split(':', 1)[1].strip().strip('"\'') + if line.strip().startswith("name:"): + name = line.split(":", 1)[1].strip().strip("\"'") break - + if name: - games.append({ - 'name': name, - 'id': slug, - 'platform': 'Lutris', - 'cmd': f'lutris lutris:rungame/{slug}', - 'icon': 'lutris' - }) + games.append({"name": name, "id": slug, "platform": "Lutris", "cmd": f"lutris lutris:rungame/{slug}", "icon": "lutris"}) except Exception: pass return games @@ -104,14 +103,14 @@ def detect_lutris(self): def detect_heroic(self): """Detects Heroic Launcher games""" games = [] - + # Possible paths for Heroic configurations # v2.5+ structure vs older versions - heroic_config = self.home / '.config/heroic' - + heroic_config = self.home / ".config/heroic" + if not heroic_config.exists(): # Try flatpak - flatpak_config = self.home / '.var/app/com.heroicgameslauncher.hgl/config/heroic' + flatpak_config = self.home / ".var/app/com.heroicgameslauncher.hgl/config/heroic" if flatpak_config.exists(): heroic_config = flatpak_config else: @@ -119,27 +118,27 @@ def detect_heroic(self): # List of files to check possible_files = [] - + # 1. GOG - possible_files.append(heroic_config / 'gog_store' / 'library.json') - possible_files.append(heroic_config / 'gog_store' / 'installed.json') - + possible_files.append(heroic_config / "gog_store" / "library.json") + possible_files.append(heroic_config / "gog_store" / "installed.json") + # 2. Epic (Legendary) - possible_files.append(heroic_config / 'legendary' / 'library.json') - possible_files.append(heroic_config / 'legendary' / 'installed.json') - + possible_files.append(heroic_config / "legendary" / "library.json") + possible_files.append(heroic_config / "legendary" / "installed.json") + # 3. Amazon (Nile) - possible_files.append(heroic_config / 'nile' / 'library.json') - possible_files.append(heroic_config / 'nile' / 'installed.json') - + possible_files.append(heroic_config / "nile" / "library.json") + possible_files.append(heroic_config / "nile" / "installed.json") + # 4. Sideloaded / Other - possible_files.append(heroic_config / 'GamesConfig' / 'installed.json') - + possible_files.append(heroic_config / "GamesConfig" / "installed.json") + # 5. Store Cache (Newer versions often store game data here) - store_cache = heroic_config / 'store_cache' + store_cache = heroic_config / "store_cache" if store_cache.exists(): - for f in store_cache.glob('*_library.json'): - possible_files.append(f) + for f in store_cache.glob("*_library.json"): + possible_files.append(f) processed_ids = set() @@ -147,47 +146,51 @@ def detect_heroic(self): if p.exists(): try: content = p.read_text() - if not content: continue - + if not content: + continue + data = json.loads(content) items = [] - + if isinstance(data, dict): - if 'library' in data: - items = data['library'] - elif 'installed' in data: - items = data['installed'] + if "library" in data: + items = data["library"] + elif "installed" in data: + items = data["installed"] else: - # Try iterating values if it is a game dictionary - # Ex: {'AppName': {...}, ...} - items = data.values() + # Try iterating values if it is a game dictionary + # Ex: {'AppName': {...}, ...} + items = data.values() elif isinstance(data, list): items = data - + for item in items: - if not isinstance(item, dict): continue - + if not isinstance(item, dict): + continue + # Try extracting info - app_name = item.get('app_name') or item.get('appName') or item.get('id') - title = item.get('title') or item.get('appName') # Fallback - + app_name = item.get("app_name") or item.get("appName") or item.get("id") + title = item.get("title") or item.get("appName") # Fallback + # Check if installed (some jsons show entire library) - is_installed = item.get('is_installed', True) # Assume true if no flag + is_installed = item.get("is_installed", True) # Assume true if no flag if not is_installed: - continue + continue if app_name and title and app_name not in processed_ids: - games.append({ - 'name': title, - 'id': app_name, - 'platform': 'Heroic', - 'cmd': f'heroic://launch/{app_name}', # Protocol handler - 'icon': 'heroic' - }) - processed_ids.add(app_name) - + games.append( + { + "name": title, + "id": app_name, + "platform": "Heroic", + "cmd": f"heroic://launch/{app_name}", # Protocol handler + "icon": "heroic", + } + ) + processed_ids.add(app_name) + except Exception as e: _log.error(f"Error reading Heroic {p}: {e}") pass - + return games diff --git a/src/big_remote_play/utils/icons.py b/src/big_remote_play/utils/icons.py index e14273d..3c515d0 100644 --- a/src/big_remote_play/utils/icons.py +++ b/src/big_remote_play/utils/icons.py @@ -1,7 +1,8 @@ import os import gi -gi.require_version('Gtk', '4.0') -gi.require_version('GdkPixbuf', '2.0') + +gi.require_version("Gtk", "4.0") +gi.require_version("GdkPixbuf", "2.0") from gi.repository import Gtk, Gio, Gdk, GdkPixbuf # type: ignore from big_remote_play import paths @@ -13,6 +14,7 @@ ICONS_DIR = str(paths.ICONS_DIR) IMG_DIR = str(paths.IMG_DIR) + def get_icon_file_path(icon_name): """Returns absolute path to icon file if it exists in icons or img dir.""" # Check icons (symbolic) first, then img (non-symbolic) @@ -23,6 +25,7 @@ def get_icon_file_path(icon_name): return path return None + def get_gicon(icon_name): """Returns a Gio.FileIcon for the local icon, or None if not found.""" path = get_icon_file_path(icon_name) @@ -31,31 +34,34 @@ def get_gicon(icon_name): return Gio.FileIcon.new(gfile) return None + def create_icon_widget(icon_name, size=None, css_class=None): """ Creates a Gtk.Image using the local icon file. Falls back to theme icon_name if local file not found. """ gicon = get_gicon(icon_name) - + if gicon: img = Gtk.Image.new_from_gicon(gicon) else: - # Fallback to system theme if local not found (though user wants only local, + # Fallback to system theme if local not found (though user wants only local, # this prevents empty space if something is missing) img = Gtk.Image.new_from_icon_name(icon_name) - + if size: img.set_pixel_size(size) - + if css_class: if isinstance(css_class, list): - for c in css_class: img.add_css_class(c) + for c in css_class: + img.add_css_class(c) else: img.add_css_class(css_class) - + return img + def create_logo_widget(icon_name, size, css_class=None): """Crisp full-color logo from an SVG/PNG, rasterized at the target size. @@ -85,7 +91,7 @@ def create_logo_widget(icon_name, size, css_class=None): img.set_pixel_size(size) if css_class: - for c in ([css_class] if isinstance(css_class, str) else css_class): + for c in [css_class] if isinstance(css_class, str) else css_class: img.add_css_class(c) return img diff --git a/src/big_remote_play/utils/logger.py b/src/big_remote_play/utils/logger.py index 98bf57c..6c4aed0 100644 --- a/src/big_remote_play/utils/logger.py +++ b/src/big_remote_play/utils/logger.py @@ -6,78 +6,76 @@ from pathlib import Path from datetime import datetime + class Logger: """Log manager""" - - def __init__(self, name='big-remoteplay', force_new=False): + + def __init__(self, name="big-remoteplay", force_new=False): self.name = name self.logger = logging.getLogger(name) - + if force_new: for h in self.logger.handlers[:]: self.logger.removeHandler(h) h.close() - - # Log directory - self.log_dir = Path.home() / '.config' / 'big-remoteplay' / 'logs' + self.log_dir = Path.home() / ".config" / "big-remoteplay" / "logs" self.log_dir.mkdir(parents=True, exist_ok=True) - + # Log file - log_file = self.log_dir / f'{name}_{datetime.now().strftime("%Y%m%d")}.log' - + log_file = self.log_dir / f"{name}_{datetime.now().strftime('%Y%m%d')}.log" + # Configure logger self.logger = logging.getLogger(name) self.logger.setLevel(logging.DEBUG if force_new else logging.INFO) - + # File handler fh = logging.FileHandler(log_file) fh.setLevel(logging.DEBUG) - + # Console handler ch = logging.StreamHandler() ch.setLevel(logging.DEBUG if force_new else logging.INFO) - + # Formatter - formatter = logging.Formatter( - '%(asctime)s - %(name)s - %(levelname)s - %(message)s', - datefmt='%Y-%m-%d %H:%M:%S' - ) - + formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S") + fh.setFormatter(formatter) ch.setFormatter(formatter) - + if not self.logger.handlers: self.logger.addHandler(fh) self.logger.addHandler(ch) - + def info(self, message): """Log info""" self.logger.info(message) - + def warning(self, message): """Log warning""" self.logger.warning(message) - + def error(self, message): """Log error""" self.logger.error(message) - + def debug(self, message): """Log debug""" self.logger.debug(message) - + def set_verbose(self, enabled): """Enables/disables verbose mode""" if enabled: self.logger.setLevel(logging.DEBUG) else: self.logger.setLevel(logging.INFO) + def clear_old_logs(self): """Removes old log files""" # Remove ALL log files try: - for f in self.log_dir.glob('*.log'): - f.unlink() - except Exception: pass + for f in self.log_dir.glob("*.log"): + f.unlink() + except Exception: + pass diff --git a/src/big_remote_play/utils/moonlight_config.py b/src/big_remote_play/utils/moonlight_config.py index 1782b39..d200ef1 100644 --- a/src/big_remote_play/utils/moonlight_config.py +++ b/src/big_remote_play/utils/moonlight_config.py @@ -1,29 +1,31 @@ import logging import configparser from pathlib import Path + _log = logging.getLogger("big-remoteplay") + class MoonlightConfigManager: _shared_state = {} - + def __init__(self) -> None: self.__dict__ = self._shared_state - - if hasattr(self, 'cp'): + + if hasattr(self, "cp"): return # Possible paths for Moonlight.conf paths = [ - Path.home() / '.config' / 'Moonlight Game Streaming Project' / 'Moonlight.conf', - Path.home() / '.var' / 'app' / 'com.moonlight_stream.Moonlight' / 'config' / 'Moonlight Game Streaming Project' / 'Moonlight.conf' + Path.home() / ".config" / "Moonlight Game Streaming Project" / "Moonlight.conf", + Path.home() / ".var" / "app" / "com.moonlight_stream.Moonlight" / "config" / "Moonlight Game Streaming Project" / "Moonlight.conf", ] - + self.config_file: Path | None = None for p in paths: if p.exists(): self.config_file = p break - + # If none exist, default to the standard path if not self.config_file: self.config_file = paths[0] @@ -38,10 +40,10 @@ def load(self) -> None: self.cp.read(self.config_file) except Exception as e: _log.error(f"Error loading Moonlight config: {e}") - - if 'General' not in self.cp: - self.cp.add_section('General') - + + if "General" not in self.cp: + self.cp.add_section("General") + def reload(self) -> None: """Force reload from file""" self.cp = configparser.ConfigParser() @@ -51,14 +53,14 @@ def save(self) -> None: try: if self.config_file is None: return - with open(self.config_file, 'w') as f: + with open(self.config_file, "w") as f: self.cp.write(f) except Exception as e: _log.error(f"Error saving Moonlight config: {e}") def get(self, key: str, default: object = None) -> str: - return self.cp.get('General', key, fallback=str(default)) + return self.cp.get("General", key, fallback=str(default)) def set(self, key: str, value: object) -> None: - self.cp.set('General', key, str(value)) + self.cp.set("General", key, str(value)) self.save() diff --git a/src/big_remote_play/utils/network.py b/src/big_remote_play/utils/network.py index 1f16068..f7c9178 100644 --- a/src/big_remote_play/utils/network.py +++ b/src/big_remote_play/utils/network.py @@ -9,202 +9,225 @@ from big_remote_play.utils.logger import Logger + class NetworkDiscovery: """Sunshine host discovery on network""" - + def __init__(self): self.hosts = [] self.logger = Logger() - + def discover_hosts(self, callback=None): import threading + def run(): hosts = [] try: - res = subprocess.run(['avahi-browse', '-t', '-r', '-p', '_nvstream._tcp'], capture_output=True, text=True, timeout=5) - if res.returncode == 0 and res.stdout: hosts = self.parse_avahi_output(res.stdout) - if not hosts: hosts = self.manual_scan() - except Exception: hosts = self.manual_scan() + res = subprocess.run(["avahi-browse", "-t", "-r", "-p", "_nvstream._tcp"], capture_output=True, text=True, timeout=5) + if res.returncode == 0 and res.stdout: + hosts = self.parse_avahi_output(res.stdout) + if not hosts: + hosts = self.manual_scan() + except Exception: + hosts = self.manual_scan() if callback: from gi.repository import GLib # type: ignore + GLib.idle_add(callback, hosts) + threading.Thread(target=run, daemon=True).start() - + def parse_avahi_output(self, output: str) -> List[Dict]: """ Parses avahi output prioritizing Global IPv6 > IPv4 > Link-Local IPv6 """ host_map = {} - - for line in output.split('\n'): - p = line.split(';') - if len(p) > 7 and p[0] == '=': + + for line in output.split("\n"): + p = line.split(";") + if len(p) > 7 and p[0] == "=": service_name = p[3] hostname = p[6] ip = p[7] interface = p[1] port = int(p[8]) - + # Create entry if not exists if service_name not in host_map: - host_map[service_name] = { - 'name': service_name, - 'hostname': hostname, - 'port': port, - 'status': 'online', - 'ips': [] - } - + host_map[service_name] = {"name": service_name, "hostname": hostname, "port": port, "status": "online", "ips": []} + # Classify IP - ip_type = 'ipv4' - if ':' in ip: - if ip.startswith('fe80'): - ip_type = 'ipv6_link_local' + ip_type = "ipv4" + if ":" in ip: + if ip.startswith("fe80"): + ip_type = "ipv6_link_local" # Fix scope ID - if "%" not in ip: ip = f"{ip}%{interface}" + if "%" not in ip: + ip = f"{ip}%{interface}" else: - ip_type = 'ipv6_global' - + ip_type = "ipv6_global" + # Add formatted IP to list # User reported Moonlight CLI on Linux prefers raw IP without brackets formatted_ip = ip - host_map[service_name]['ips'].append({'ip': formatted_ip, 'type': ip_type, 'raw': ip}) - + host_map[service_name]["ips"].append({"ip": formatted_ip, "type": ip_type, "raw": ip}) + # Enrichment: Ensure IPv4 exists for name, data in host_map.items(): - has_v4 = any(ip['type'] == 'ipv4' for ip in data['ips']) - if not has_v4 and data['hostname']: + has_v4 = any(ip["type"] == "ipv4" for ip in data["ips"]) + if not has_v4 and data["hostname"]: try: # Try to resolve IPv4 explicitly - hostname = data['hostname'] + hostname = data["hostname"] # Sometimes avahi returns hostname without .local, try both if needed # But usually it is hostname.local ipv4 = socket.gethostbyname(hostname) - if ipv4 and not ipv4.startswith('127.'): - data['ips'].append({'ip': ipv4, 'type': 'ipv4', 'raw': ipv4}) + if ipv4 and not ipv4.startswith("127."): + data["ips"].append({"ip": ipv4, "type": "ipv4", "raw": ipv4}) except Exception: pass - + final_hosts = [] for name, data in host_map.items(): # Add all discovered IPs to the list so user can choose - for ip_info in data['ips']: - display_name = data['name'] - # Append protocol info to distinguish in UI if needed, + for ip_info in data["ips"]: + display_name = data["name"] + # Append protocol info to distinguish in UI if needed, # although the subtitle in UI showing the IP is usually enough. # However, to be explicit: - if ip_info['type'] == 'ipv6_link_local': + if ip_info["type"] == "ipv6_link_local": display_name += _(" (IPv6 Local)") - elif ip_info['type'] == 'ipv6_global': + elif ip_info["type"] == "ipv6_global": display_name += _(" (IPv6 Global)") - - final_hosts.append({ - 'name': display_name, - 'ip': ip_info['ip'], - 'port': data['port'], - 'status': 'online', - 'hostname': data['hostname'] - }) - + + final_hosts.append({"name": display_name, "ip": ip_info["ip"], "port": data["port"], "status": "online", "hostname": data["hostname"]}) + return final_hosts - + def manual_scan(self) -> List[Dict]: from concurrent.futures import ThreadPoolExecutor - hosts = []; local_ip = self.get_local_ip() - targets = ['127.0.0.1', '::1'] - + + hosts = [] + local_ip = self.get_local_ip() + targets = ["127.0.0.1", "::1"] + # IPv4 scan - if local_ip and '.' in local_ip: - subnet = '.'.join(local_ip.split('.')[:-1]) - for i in range(1, 255): targets.append(f"{subnet}.{i}") - + if local_ip and "." in local_ip: + subnet = ".".join(local_ip.split(".")[:-1]) + for i in range(1, 255): + targets.append(f"{subnet}.{i}") + # IPv6 Radical Scan: Check neighbor cache and active interfaces try: # 1. Check neighbor cache - res = subprocess.run(['ip', '-6', 'neigh', 'show'], capture_output=True, text=True, timeout=2) + res = subprocess.run(["ip", "-6", "neigh", "show"], capture_output=True, text=True, timeout=2) if res.returncode == 0: for line in res.stdout.splitlines(): parts = line.split() - if len(parts) >= 3 and ':' in parts[0]: + if len(parts) >= 3 and ":" in parts[0]: ip = parts[0] - if ip.startswith('fe80'): + if ip.startswith("fe80"): try: - dev_idx = parts.index('dev') - if dev_idx + 1 < len(parts): targets.append(f"{ip}%{parts[dev_idx+1]}") - except Exception: pass - else: targets.append(ip) - + dev_idx = parts.index("dev") + if dev_idx + 1 < len(parts): + targets.append(f"{ip}%{parts[dev_idx + 1]}") + except Exception: + pass + else: + targets.append(ip) + # 2. Flush neighbor cache to avoid stale entries - try: subprocess.run(['ip', '-6', 'neigh', 'flush', 'all'], capture_output=True, timeout=1) - except Exception: pass - + try: + subprocess.run(["ip", "-6", "neigh", "flush", "all"], capture_output=True, timeout=1) + except Exception: + pass + # 3. Ping all-nodes multicast briefly to populate neighbor cache - subprocess.run(['ping', '-6', '-c', '1', '-W', '1', 'ff02::1%lo'], capture_output=True, timeout=1) - except Exception: pass + subprocess.run(["ping", "-6", "-c", "1", "-W", "1", "ff02::1%lo"], capture_output=True, timeout=1) + except Exception: + pass def check(ip): if self.check_sunshine_port(ip): # User reported Moonlight CLI on Linux prefers raw IP without brackets - return {'name': _("Host ({})").format(ip), 'ip': ip, 'port': 47989, 'status': 'online'} + return {"name": _("Host ({})").format(ip), "ip": ip, "port": 47989, "status": "online"} return None with ThreadPoolExecutor(max_workers=100) as ex: for r in ex.map(check, targets): - if r: hosts.append(r) + if r: + hosts.append(r) return hosts - + def check_sunshine_port(self, ip: str, port: int = 47989, timeout: float = 0.5) -> bool: try: - with socket.create_connection((ip, port), timeout=timeout): return True - except Exception: return False - + with socket.create_connection((ip, port), timeout=timeout): + return True + except Exception: + return False + def get_local_ip(self) -> str: # Try IPv4 try: with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: - s.connect(("8.8.8.8", 80)); return s.getsockname()[0] - except Exception: pass + s.connect(("8.8.8.8", 80)) + return s.getsockname()[0] + except Exception: + pass # Try IPv6 try: with socket.socket(socket.AF_INET6, socket.SOCK_DGRAM) as s: - s.connect(("2001:4860:4860::8888", 80)); return s.getsockname()[0] - except Exception: pass + s.connect(("2001:4860:4860::8888", 80)) + return s.getsockname()[0] + except Exception: + pass return "" def resolve_pin(self, pin: str, timeout: int = 3) -> str: - if not pin or len(pin) != 6: return "" + if not pin or len(pin) != 6: + return "" import threading - results = {'v4': None, 'v6': None} - + + results = {"v4": None, "v6": None} + def try_v4(): try: with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: - s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1); s.settimeout(timeout) - s.sendto(f"WHO_HAS_PIN {pin}".encode(), ('', 48011)) + s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + s.settimeout(timeout) + s.sendto(f"WHO_HAS_PIN {pin}".encode(), ("", 48011)) data, addr = s.recvfrom(1024) - if data.decode().startswith("I_HAVE_PIN"): results['v4'] = addr[0] - except Exception: pass + if data.decode().startswith("I_HAVE_PIN"): + results["v4"] = addr[0] + except Exception: + pass def try_v6(): try: # ff02::1 is all-nodes link-local multicast with socket.socket(socket.AF_INET6, socket.SOCK_DGRAM) as s: s.settimeout(timeout) - s.sendto(f"WHO_HAS_PIN {pin}".encode(), ('ff02::1', 48011)) + s.sendto(f"WHO_HAS_PIN {pin}".encode(), ("ff02::1", 48011)) data, addr = s.recvfrom(1024) - if data.decode().startswith("I_HAVE_PIN"): - results['v6'] = addr[0] - except Exception: pass - - t1 = threading.Thread(target=try_v4); t2 = threading.Thread(target=try_v6) - t1.start(); t2.start() - t1.join(timeout); t2.join(timeout) - - return results['v4'] or results['v6'] or "" + if data.decode().startswith("I_HAVE_PIN"): + results["v6"] = addr[0] + except Exception: + pass + + t1 = threading.Thread(target=try_v4) + t2 = threading.Thread(target=try_v6) + t1.start() + t2.start() + t1.join(timeout) + t2.join(timeout) + + return results["v4"] or results["v6"] or "" def start_pin_listener(self, pin: str, name: str): import threading + running = [True] + def run(): # Listen on both IPv4 and IPv6 for family in [socket.AF_INET, socket.AF_INET6]: @@ -213,48 +236,57 @@ def run(): s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) if family == socket.AF_INET6: # Ensure IPv6 socket doesn't block IPv4 - try: s.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1) - except Exception: pass - s.bind(('::', 48011)) + try: + s.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1) + except Exception: + pass + s.bind(("::", 48011)) else: - s.bind(('0.0.0.0', 48011)) + s.bind(("0.0.0.0", 48011)) s.settimeout(1) - + def listener(sock): while running[0]: try: data, addr = sock.recvfrom(1024) - if data.decode().strip() == f"WHO_HAS_PIN {pin}": + if data.decode().strip() == f"WHO_HAS_PIN {pin}": sock.sendto(f"I_HAVE_PIN {name}".encode(), addr) - except Exception: pass + except Exception: + pass sock.close() - + threading.Thread(target=listener, args=(s,), daemon=True).start() - except Exception: pass - + except Exception: + pass + run() return lambda: running.__setitem__(0, False) def get_global_ipv4(self) -> str: - for url in ['ipinfo.io/ip', 'checkip.amazonaws.com']: + for url in ["ipinfo.io/ip", "checkip.amazonaws.com"]: try: - res = subprocess.run(['curl', '-s', '-4', '--connect-timeout', '3', url], capture_output=True, text=True) - if res.returncode == 0 and res.stdout.strip(): return res.stdout.strip() - except Exception: pass + res = subprocess.run(["curl", "-s", "-4", "--connect-timeout", "3", url], capture_output=True, text=True) + if res.returncode == 0 and res.stdout.strip(): + return res.stdout.strip() + except Exception: + pass return "None" - + def get_global_ipv6(self) -> str: - for url in ['ifconfig.me', 'icanhazip.com']: + for url in ["ifconfig.me", "icanhazip.com"]: try: - res = subprocess.run(['curl', '-s', '-6', '--connect-timeout', '3', url], capture_output=True, text=True) - if res.returncode == 0 and res.stdout.strip(): return res.stdout.strip() - except Exception: pass + res = subprocess.run(["curl", "-s", "-6", "--connect-timeout", "3", url], capture_output=True, text=True) + if res.returncode == 0 and res.stdout.strip(): + return res.stdout.strip() + except Exception: + pass return "None" + def resolve_pin_to_ip(pin: str) -> dict | None: """Helper for GuestView to resolve PIN to IP info""" discovery = NetworkDiscovery() ip = discovery.resolve_pin(pin) if ip: - return {'ip': ip, 'hostname': _("Host"), 'port': 47989} + return {"ip": ip, "hostname": _("Host"), "port": 47989} return None diff --git a/tests/test_drop_guest_validation.py b/tests/test_drop_guest_validation.py index 04be73a..f7efc33 100644 --- a/tests/test_drop_guest_validation.py +++ b/tests/test_drop_guest_validation.py @@ -8,9 +8,7 @@ def _run(arg: str) -> subprocess.CompletedProcess: - return subprocess.run( - ["bash", _SCRIPT, arg], capture_output=True, text=True, timeout=10 - ) + return subprocess.run(["bash", _SCRIPT, arg], capture_output=True, text=True, timeout=10) def test_rejects_empty() -> None: diff --git a/tests/test_network.py b/tests/test_network.py index a1aac66..d07cd35 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -5,9 +5,9 @@ from big_remote_play.utils.network import NetworkDiscovery # avahi-browse -p fields: =;iface;proto;name;type;domain;hostname;ip;port;txt -_IPV4_LINE = "=;eth0;IPv4;MyHost;_nvstream._tcp;local;myhost.local;192.168.1.50;47989;\"x\"" +_IPV4_LINE = '=;eth0;IPv4;MyHost;_nvstream._tcp;local;myhost.local;192.168.1.50;47989;"x"' # Empty hostname avoids the IPv4-enrichment DNS lookup, keeping the test offline. -_IPV6_LL_LINE = "=;eth0;IPv6;V6Host;_nvstream._tcp;local;;fe80::1;47989;\"x\"" +_IPV6_LL_LINE = '=;eth0;IPv6;V6Host;_nvstream._tcp;local;;fe80::1;47989;"x"' def test_parse_ipv4(fake_home: Path) -> None: diff --git a/tests/test_script_protocol.py b/tests/test_script_protocol.py index 3d95004..849a3fd 100644 --- a/tests/test_script_protocol.py +++ b/tests/test_script_protocol.py @@ -9,7 +9,9 @@ def test_data_marker() -> None: def test_data_marker_value_with_url() -> None: assert parse_script_line("BRP_DATA api_url=https://api.zerotier.com/api/v1") == ( - "data", "api_url", "https://api.zerotier.com/api/v1", + "data", + "api_url", + "https://api.zerotier.com/api/v1", ) @@ -20,12 +22,14 @@ def test_phase_marker_and_clamp() -> None: def test_text_strips_ansi() -> None: assert parse_script_line("\x1b[0;32mChecking dependencies...\x1b[0m") == ( - "text", "Checking dependencies...", + "text", + "Checking dependencies...", ) def test_capture_is_locale_independent() -> None: """Same data captured whether prose is English or Portuguese.""" + def capture(lines): captured, phase = {}, 0.1 for ln in lines: diff --git a/tests/test_security_regressions.py b/tests/test_security_regressions.py index fcf07fe..b253d8e 100644 --- a/tests/test_security_regressions.py +++ b/tests/test_security_regressions.py @@ -66,7 +66,7 @@ def test_network_scripts_match_current_provider_docs() -> None: assert "https://api.zerotier.com/api/v1" in zerotier assert "Authorization: token $API_TOKEN" in zerotier assert "Authorization: bearer $API_TOKEN" not in zerotier - assert "HEADSCALE_VERSION=\"${HEADSCALE_VERSION:-0.29.1}\"" in headscale + assert 'HEADSCALE_VERSION="${HEADSCALE_VERSION:-0.29.1}"' in headscale assert "raw.githubusercontent.com/juanfont/headscale/v$HEADSCALE_VERSION/config-example.yaml" in headscale assert "ip_prefixes:" not in headscale assert "0.0.0.0/0" not in headscale @@ -75,7 +75,7 @@ def test_network_scripts_match_current_provider_docs() -> None: assert "./caddy_config:/config" in headscale assert '"443:443/udp"' in headscale assert "handle /generate_204" in headscale - assert "$HEADSCALE_IMAGE\" configtest" in headscale + assert '$HEADSCALE_IMAGE" configtest' in headscale def test_sunshine_network_options_cover_current_docs() -> None: diff --git a/tests/test_sunshine_api.py b/tests/test_sunshine_api.py index 3e9569b..10bab7f 100644 --- a/tests/test_sunshine_api.py +++ b/tests/test_sunshine_api.py @@ -32,20 +32,21 @@ def recorder(method, path, payload=None, auth=None, timeout=5.0): # --- fingerprint + TOFU --------------------------------------------------- + def test_cert_fingerprint_is_sha256_hex() -> None: assert _cert_fingerprint(b"abc") == hashlib.sha256(b"abc").hexdigest() def test_trust_fingerprint_pins_on_first_use_then_matches(host) -> None: fp = "a" * 64 - assert host._trust_fingerprint(fp) is True # first contact pins + assert host._trust_fingerprint(fp) is True # first contact pins assert host.cert_fp_file.exists() - assert host._trust_fingerprint(fp) is True # same cert -> trusted + assert host._trust_fingerprint(fp) is True # same cert -> trusted def test_trust_fingerprint_rejects_mismatch(host) -> None: host._trust_fingerprint("a" * 64) - assert host._trust_fingerprint("b" * 64) is False # cert changed -> refuse + assert host._trust_fingerprint("b" * 64) is False # cert changed -> refuse def test_pinned_fingerprint_file_is_owner_only(host) -> None: @@ -56,14 +57,19 @@ def test_pinned_fingerprint_file_is_owner_only(host) -> None: # --- send_pin ------------------------------------------------------------- + def test_send_pin_posts_pin_and_name(host) -> None: calls = _capture(host, 200, b'{"status": true}') ok, _msg = host.send_pin("1234", name="laptop", auth=("admin", "pw")) assert ok is True - assert calls == [{ - "method": "POST", "path": "/api/pin", - "payload": {"pin": "1234", "name": "laptop"}, "auth": ("admin", "pw"), - }] + assert calls == [ + { + "method": "POST", + "path": "/api/pin", + "payload": {"pin": "1234", "name": "laptop"}, + "auth": ("admin", "pw"), + } + ] def test_send_pin_omits_empty_name(host) -> None: @@ -92,6 +98,7 @@ def test_send_pin_connection_failure(host) -> None: # --- create_user (POST /api/password, not the nonexistent /api/users) ----- + def test_create_user_uses_password_endpoint(host) -> None: calls = _capture(host, 200, b'{"status": true}') ok, _msg = host.create_user("admin", "secret") @@ -100,10 +107,13 @@ def test_create_user_uses_password_endpoint(host) -> None: assert call["method"] == "POST" assert call["path"] == "/api/password" assert call["payload"] == { - "currentUsername": "", "currentPassword": "", - "newUsername": "admin", "newPassword": "secret", "confirmNewPassword": "secret", + "currentUsername": "", + "currentPassword": "", + "newUsername": "admin", + "newPassword": "secret", + "confirmNewPassword": "secret", } - assert call["auth"] is None # first-run: no credentials yet + assert call["auth"] is None # first-run: no credentials yet def test_create_user_rejected(host) -> None: @@ -119,10 +129,13 @@ def test_set_credentials_change_sends_current_and_authenticates(host) -> None: call = calls[0] assert call["path"] == "/api/password" assert call["payload"] == { - "currentUsername": "admin", "currentPassword": "oldpass", - "newUsername": "admin", "newPassword": "newpass", "confirmNewPassword": "newpass", + "currentUsername": "admin", + "currentPassword": "oldpass", + "newUsername": "admin", + "newPassword": "newpass", + "confirmNewPassword": "newpass", } - assert call["auth"] == ("admin", "oldpass") # change is authenticated + assert call["auth"] == ("admin", "oldpass") # change is authenticated def test_set_credentials_change_wrong_password_is_401(host) -> None: @@ -133,6 +146,7 @@ def test_set_credentials_change_wrong_password_is_401(host) -> None: # --- reset_credentials (sunshine --creds, no old password) ---------------- + class _FakeProc: def __init__(self, returncode, stderr=""): self.returncode = returncode @@ -142,6 +156,7 @@ def __init__(self, returncode, stderr=""): def test_reset_credentials_runs_creds_cli(host, monkeypatch) -> None: import big_remote_play.host.sunshine_manager as sm + calls = {} monkeypatch.setattr(sm.shutil, "which", lambda _n: "/usr/bin/sunshine") @@ -157,6 +172,7 @@ def fake_run(argv, **kwargs): def test_reset_credentials_reports_failure(host, monkeypatch) -> None: import big_remote_play.host.sunshine_manager as sm + monkeypatch.setattr(sm.shutil, "which", lambda _n: "/usr/bin/sunshine") monkeypatch.setattr(sm.subprocess, "run", lambda argv, **kw: _FakeProc(1, "boom")) ok, msg = host.reset_credentials("admin", "newpass") @@ -166,6 +182,7 @@ def test_reset_credentials_reports_failure(host, monkeypatch) -> None: def test_reset_credentials_requires_nonempty(host, monkeypatch) -> None: import big_remote_play.host.sunshine_manager as sm + called = {"ran": False} monkeypatch.setattr(sm.shutil, "which", lambda _n: "/usr/bin/sunshine") @@ -176,11 +193,12 @@ def fake_run(*a, **k): monkeypatch.setattr(sm.subprocess, "run", fake_run) ok, _msg = host.reset_credentials("", "newpass") assert ok is False - assert called["ran"] is False # no exec on empty input + assert called["ran"] is False # no exec on empty input def test_reset_credentials_no_binary(host, monkeypatch) -> None: import big_remote_play.host.sunshine_manager as sm + monkeypatch.setattr(sm.shutil, "which", lambda _n: None) ok, _msg = host.reset_credentials("admin", "newpass") assert ok is False @@ -188,10 +206,16 @@ def test_reset_credentials_no_binary(host, monkeypatch) -> None: # --- client management ---------------------------------------------------- + def test_list_clients_parses_named_certs(host) -> None: - body = json.dumps({"status": True, "named_certs": [ - {"name": "phone", "uuid": "u1", "enabled": True}, - ]}).encode() + body = json.dumps( + { + "status": True, + "named_certs": [ + {"name": "phone", "uuid": "u1", "enabled": True}, + ], + } + ).encode() _capture(host, 200, body) clients = host.list_clients(auth=("admin", "pw")) assert clients == [{"name": "phone", "uuid": "u1", "enabled": True}] @@ -206,15 +230,17 @@ def test_unpair_client_posts_uuid(host) -> None: calls = _capture(host, 200) assert host.unpair_client("u1") is True assert calls[0] == { - "method": "POST", "path": "/api/clients/unpair", - "payload": {"uuid": "u1"}, "auth": None, + "method": "POST", + "path": "/api/clients/unpair", + "payload": {"uuid": "u1"}, + "auth": None, } def test_unpair_client_rejects_empty_uuid(host) -> None: calls = _capture(host, 200) assert host.unpair_client("") is False - assert calls == [] # no request issued + assert calls == [] # no request issued def test_set_client_enabled_posts_uuid_and_flag(host) -> None: @@ -226,6 +252,7 @@ def test_set_client_enabled_posts_uuid_and_flag(host) -> None: # --- logs ----------------------------------------------------------------- + def test_get_logs_decodes_text(host) -> None: _capture(host, 200, b"line one\nline two") assert host.get_logs() == "line one\nline two" @@ -238,6 +265,7 @@ def test_get_logs_empty_on_error(host) -> None: # --- close_app ------------------------------------------------------------ + def test_close_app_posts_close(host) -> None: calls = _capture(host, 200, b'{"status": true}') assert host.close_app() is True @@ -248,6 +276,7 @@ def test_close_app_posts_close(host) -> None: # --- apps ----------------------------------------------------------------- + def test_get_apps_parses_array(host) -> None: _capture(host, 200, json.dumps({"apps": [{"name": "Game", "index": 0}]}).encode()) assert host.get_apps() == [{"name": "Game", "index": 0}] @@ -262,8 +291,7 @@ def test_add_app_defaults_index_to_append(host) -> None: def test_add_app_preserves_explicit_index_and_prep_cmd(host) -> None: calls = _capture(host, 200, b'{"status": true}') - entry = {"name": "G", "cmd": "g", "index": 3, - "prep-cmd": [{"do": "a", "undo": "b", "elevated": False}]} + entry = {"name": "G", "cmd": "g", "index": 3, "prep-cmd": [{"do": "a", "undo": "b", "elevated": False}]} host.add_app(entry) assert calls[0]["payload"]["index"] == 3 assert calls[0]["payload"]["prep-cmd"] == [{"do": "a", "undo": "b", "elevated": False}] @@ -283,6 +311,7 @@ def test_delete_app_rejects_negative_index(host) -> None: # --- covers --------------------------------------------------------------- + def test_upload_cover_with_url_returns_path(host) -> None: calls = _capture(host, 200, b'{"status": true, "path": "/cov/x.png"}') out = host.upload_cover("igdb_42", url="https://images.igdb.com/x.png") @@ -293,12 +322,13 @@ def test_upload_cover_with_url_returns_path(host) -> None: def test_upload_cover_requires_key_and_source(host) -> None: calls = _capture(host, 200) assert host.upload_cover("", url="https://images.igdb.com/x.png") == "" - assert host.upload_cover("igdb_42") == "" # no url and no data + assert host.upload_cover("igdb_42") == "" # no url and no data assert calls == [] # --- config --------------------------------------------------------------- + def test_get_config_parses_dict(host) -> None: _capture(host, 200, json.dumps({"status": True, "fps": "60", "platform": "linux"}).encode()) cfg = host.get_config() @@ -316,22 +346,32 @@ def test_save_config_posts_settings(host) -> None: # --- restart -------------------------------------------------------------- + def test_restart_via_api_success_on_200(host) -> None: _capture(host, 200, b'{"status": true}') assert host.restart_via_api() is True def test_restart_via_api_tolerates_connection_drop(host) -> None: - _capture(host, 0) # restart drops the connection + _capture(host, 0) # restart drops the connection assert host.restart_via_api() is True # --- browse --------------------------------------------------------------- + def test_browse_builds_query_and_parses_entries(host) -> None: - calls = _capture(host, 200, json.dumps({ - "path": "/home", "parent": "/", "entries": [{"name": "g", "type": "file", "path": "/home/g"}], - }).encode()) + calls = _capture( + host, + 200, + json.dumps( + { + "path": "/home", + "parent": "/", + "entries": [{"name": "g", "type": "file", "path": "/home/g"}], + } + ).encode(), + ) out = host.browse("/home", "executable") assert out["entries"][0]["name"] == "g" assert calls[0]["method"] == "GET" From 045ec2ed68d0509c0cd31e29f2350da0df4d8acd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Gon=C3=A7alves?= Date: Thu, 25 Jun 2026 05:09:26 -0300 Subject: [PATCH 31/47] Move home branding into headerbar --- src/big_remote_play/ui/main_window.py | 34 +++++++++++---------- tests/test_ui_premium_layout_regressions.py | 21 +++++++++++-- usr/share/big-remote-play/ui/style.css | 16 +++++----- 3 files changed, 44 insertions(+), 27 deletions(-) diff --git a/src/big_remote_play/ui/main_window.py b/src/big_remote_play/ui/main_window.py index a2d3fc9..45cab1b 100644 --- a/src/big_remote_play/ui/main_window.py +++ b/src/big_remote_play/ui/main_window.py @@ -4,7 +4,7 @@ gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") -from gi.repository import Gtk, Adw, GLib, Gio # type: ignore +from gi.repository import Gtk, Adw, GLib, Gio, Pango # type: ignore import threading import json import os @@ -224,7 +224,6 @@ def setup_sidebar(self): main = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) main.set_vexpand(True) main.set_margin_top(12) - main.append(self._create_sidebar_identity()) scroll = Gtk.ScrolledWindow() scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) scroll.set_vexpand(True) @@ -240,29 +239,31 @@ def setup_sidebar(self): tb.set_content(main) self.split_view.set_sidebar(Adw.NavigationPage.new(tb, "Navigation")) - def _create_sidebar_identity(self) -> Gtk.Widget: - identity = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) - identity.add_css_class("sidebar-identity") - identity.set_margin_start(14) - identity.set_margin_end(14) - identity.set_margin_bottom(16) + def _create_home_header_identity(self) -> Gtk.Widget: + identity = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + identity.add_css_class("header-identity") + identity.set_valign(Gtk.Align.CENTER) + identity.update_property( + [Gtk.AccessibleProperty.LABEL, Gtk.AccessibleProperty.DESCRIPTION], + ["Big Remote Play", _("Play together, from anywhere")], + ) - logo = create_logo_widget("big-remote-play", 48) - logo.add_css_class("sidebar-logo") + logo = create_logo_widget("big-remote-play", 28) + logo.add_css_class("header-logo") logo.set_valign(Gtk.Align.CENTER) identity.append(logo) - text = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + text = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) text.set_valign(Gtk.Align.CENTER) title = Gtk.Label(label="Big Remote Play") - title.add_css_class("sidebar-title") + title.add_css_class("header-title") title.set_halign(Gtk.Align.START) text.append(title) subtitle = Gtk.Label(label=_("Play together, from anywhere")) - subtitle.add_css_class("sidebar-subtitle") + subtitle.add_css_class("header-subtitle") subtitle.set_halign(Gtk.Align.START) - subtitle.set_wrap(True) + subtitle.set_ellipsize(Pango.EllipsizeMode.END) text.append(subtitle) identity.append(text) @@ -495,7 +496,8 @@ def setup_content(self): self.content_headerbar = hb self.header_blank_title = Gtk.Box() - hb.set_title_widget(self.header_blank_title) + self.home_header_identity = self._create_home_header_identity() + hb.set_title_widget(self.home_header_identity) ct.add_top_bar(hb) self.content_stack = Gtk.Stack() @@ -940,7 +942,7 @@ def on_nav_selected(self, lb, row): if actual_pid == "host" and hasattr(self.host_view, "header_action_box"): self.content_headerbar.set_title_widget(self.host_view.header_action_box) elif actual_pid == "welcome": - self.content_headerbar.set_title_widget(self.header_blank_title) + self.content_headerbar.set_title_widget(self.home_header_identity) else: info = self._build_navigation_pages().get(actual_pid) or self._build_navigation_pages().get(pid) if info: diff --git a/tests/test_ui_premium_layout_regressions.py b/tests/test_ui_premium_layout_regressions.py index 7abd2e4..8bdb19e 100644 --- a/tests/test_ui_premium_layout_regressions.py +++ b/tests/test_ui_premium_layout_regressions.py @@ -4,11 +4,24 @@ from pathlib import Path -def test_sidebar_keeps_brand_identity_and_richer_service_rows() -> None: +def test_home_headerbar_owns_brand_identity_and_sidebar_keeps_service_rows() -> None: source = Path("src/big_remote_play/ui/main_window.py").read_text() + stylesheet = Path("usr/share/big-remote-play/ui/style.css").read_text() - assert "_create_sidebar_identity" in source - assert re.search(r"create_logo_widget\([\"']big-remote-play[\"'], 48\)", source) + assert "_create_sidebar_identity" not in source + assert "main.append(self._create_sidebar_identity())" not in source + assert "def _create_home_header_identity(self) -> Gtk.Widget:" in source + assert "self.home_header_identity = self._create_home_header_identity()" in source + assert "set_title_widget(self.home_header_identity)" in source + assert re.search(r"create_logo_widget\([\"']big-remote-play[\"'], 28\)", source) + assert ".header-identity" in stylesheet + assert ".header-logo" in stylesheet + assert ".header-title" in stylesheet + assert ".header-subtitle" in stylesheet + assert ".sidebar-identity" not in stylesheet + assert ".sidebar-logo" not in stylesheet + assert ".sidebar-title" not in stylesheet + assert ".sidebar-subtitle" not in stylesheet assert "service-status-row" in source assert "service-icon-frame" in source @@ -35,7 +48,9 @@ def test_content_headerbar_does_not_duplicate_page_titles() -> None: assert "self.content_title" not in source assert "set_title_widget(None)" not in source assert "self.header_blank_title = Gtk.Box()" in source + assert "self.home_header_identity = self._create_home_header_identity()" in source assert "set_title_widget(self.header_blank_title)" in source + assert "set_title_widget(self.home_header_identity)" in source assert "def _set_header_title(self, title: str)" in source assert 'Adw.WindowTitle.new(title, "")' in source assert "Choose the private network provider for remote play." not in source diff --git a/usr/share/big-remote-play/ui/style.css b/usr/share/big-remote-play/ui/style.css index e9727bb..4fcc0f0 100644 --- a/usr/share/big-remote-play/ui/style.css +++ b/usr/share/big-remote-play/ui/style.css @@ -24,21 +24,21 @@ font-weight: 700; } -.sidebar-identity { - padding: 8px 2px 2px 2px; +.header-identity { + padding: 0 4px; } -.sidebar-logo { - -gtk-icon-shadow: 0 6px 18px alpha(@accent_bg_color, 0.25); +.header-logo { + -gtk-icon-shadow: 0 4px 12px alpha(@accent_bg_color, 0.2); } -.sidebar-title { - font-size: 1.08em; +.header-title { + font-size: 0.98em; font-weight: 800; } -.sidebar-subtitle { - font-size: 0.92em; +.header-subtitle { + font-size: 0.82em; opacity: 0.68; } From 00109124c596801074fe867767bd69e257d20318 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Gon=C3=A7alves?= Date: Thu, 25 Jun 2026 07:25:57 -0300 Subject: [PATCH 32/47] fix(connect): stop host-list clipping; compact natural-height scroller Claude-Session: https://claude.ai/code/session_01DhGCL133apRAwBMk6dktHC --- src/big_remote_play/ui/guest_view.py | 8 ++++++-- tests/test_connect_redesign_regressions.py | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 tests/test_connect_redesign_regressions.py diff --git a/src/big_remote_play/ui/guest_view.py b/src/big_remote_play/ui/guest_view.py index f0bdd24..05969b4 100644 --- a/src/big_remote_play/ui/guest_view.py +++ b/src/big_remote_play/ui/guest_view.py @@ -390,6 +390,10 @@ def create_discover_page(self): self.hosts_list.set_selection_mode(Gtk.SelectionMode.NONE) for m in ["start", "end"]: getattr(self.hosts_list, f"set_margin_{m}")(12) + # Top/bottom margin so the boxed-list card's rounded corners and the + # first/last rows are not clipped by the ScrolledWindow viewport edge. + self.hosts_list.set_margin_top(6) + self.hosts_list.set_margin_bottom(6) action = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) for m in ["top", "bottom", "start", "end"]: getattr(action, f"set_margin_{m}")(12) @@ -419,8 +423,8 @@ def create_discover_page(self): host_scroll = Gtk.ScrolledWindow() host_scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) host_scroll.set_max_content_height(400) - host_scroll.set_min_content_height(200) - host_scroll.set_vexpand(True) + host_scroll.set_min_content_height(120) + host_scroll.set_vexpand(False) host_scroll.set_propagate_natural_height(True) host_scroll.set_child(self.hosts_list) diff --git a/tests/test_connect_redesign_regressions.py b/tests/test_connect_redesign_regressions.py new file mode 100644 index 0000000..4ac0e11 --- /dev/null +++ b/tests/test_connect_redesign_regressions.py @@ -0,0 +1,16 @@ +"""Static source guards for the Connect page + Rede Privada novice redesign.""" + +from pathlib import Path + +GUEST = Path("src/big_remote_play/ui/guest_view.py") +MAIN = Path("src/big_remote_play/ui/main_window.py") +PNV = Path("src/big_remote_play/ui/private_network_view.py") + + +def test_host_scroll_has_breathing_room_and_no_tall_min() -> None: + src = GUEST.read_text() + # The host list scroller must not force a tall empty box, and the list must + # have vertical margins so the boxed-list card corners are not clipped. + assert "host_scroll.set_min_content_height(120)" in src + assert "self.hosts_list.set_margin_top(6)" in src + assert "self.hosts_list.set_margin_bottom(6)" in src From 39ec717b975f7c532b321df4c9859b4578854eec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Gon=C3=A7alves?= Date: Thu, 25 Jun 2026 07:28:20 -0300 Subject: [PATCH 33/47] feat(connect): intent-branched empty state routes novices to Rede Privada/PIN Claude-Session: https://claude.ai/code/session_01DhGCL133apRAwBMk6dktHC --- src/big_remote_play/ui/guest_view.py | 103 +++++++++++++++++---- tests/test_connect_redesign_regressions.py | 17 ++++ 2 files changed, 100 insertions(+), 20 deletions(-) diff --git a/src/big_remote_play/ui/guest_view.py b/src/big_remote_play/ui/guest_view.py index 05969b4..f665760 100644 --- a/src/big_remote_play/ui/guest_view.py +++ b/src/big_remote_play/ui/guest_view.py @@ -363,6 +363,80 @@ def _do_reconnect_timer(self): self.check_reconnect() return False + def _go_to_private_network(self) -> None: + root = self._root_window() + if hasattr(root, "navigate_to"): + root.navigate_to("vpn_selector") + + def _build_discover_empty_state(self) -> Gtk.Widget: + """Compact, plain-language empty state that routes a non-technical user to + the path that actually unlocks their case: same-network rescan, Private + Network for internet play (the real enabler), or a friend-dictated PIN. + Manual/IP is the de-emphasized advanced fallback.""" + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=14) + box.add_css_class("helper-card") + box.set_halign(Gtk.Align.CENTER) + box.set_margin_top(8) + box.set_margin_bottom(8) + + title = Gtk.Label(label=_("No host found yet")) + title.add_css_class("title-4") + title.set_halign(Gtk.Align.CENTER) + box.append(title) + + def option(icon_name, text, button_label, accessible, on_click, highlight=False): + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + row.add_css_class("helper-row") + icon = create_icon_widget(icon_name, size=18) + icon.add_css_class("accent" if highlight else "dim-label") + icon.set_valign(Gtk.Align.CENTER) + row.append(icon) + lbl = Gtk.Label(label=text) + lbl.set_halign(Gtk.Align.START) + lbl.set_wrap(True) + lbl.set_hexpand(True) + lbl.set_xalign(0) + row.append(lbl) + btn = Gtk.Button(label=button_label) + btn.add_css_class("pill") + btn.add_css_class("suggested-action" if highlight else "flat") + btn.set_valign(Gtk.Align.CENTER) + btn.update_property([Gtk.AccessibleProperty.LABEL], [accessible]) + btn.connect("clicked", lambda _b: on_click()) + row.append(btn) + box.append(row) + + option( + "view-refresh-symbolic", + _("Same house or network? Search again."), + _("Search"), + _("Search the local network again"), + self.discover_hosts, + ) + option( + "network-vpn-symbolic", + _("Playing with a friend over the internet? You need a Private Network (just once)."), + _("Set up Private Network"), + _("Open Private Network setup"), + self._go_to_private_network, + highlight=True, + ) + option( + "dialog-password-symbolic", + _("Did the host give you a 6-digit code?"), + _("Connect with PIN"), + _("Switch to PIN connection"), + lambda: self.method_stack.set_visible_child_name("pin"), + ) + option( + "network-wired-symbolic", + _("I know the IP address"), + _("Manual"), + _("Switch to manual connection"), + lambda: self.method_stack.set_visible_child_name("manual"), + ) + return box + def create_discover_page(self): self.selected_host_card_data = self.first_radio_in_list = None box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) @@ -481,25 +555,7 @@ def on_hosts_discovered(hosts): if self.loading_row.get_parent(): self.hosts_list.remove(self.loading_row) self.first_radio_in_list = None - if not hosts: - row = Gtk.ListBoxRow() - row.set_selectable(False) - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) - box.set_halign(Gtk.Align.CENTER) - box.set_valign(Gtk.Align.CENTER) - box.set_size_request(-1, 150) # Match host_scroll min height - for m in ["top", "bottom"]: - getattr(box, f"set_margin_{m}")(24) - icon = create_icon_widget("network-offline-symbolic", size=48, css_class="dim-label") - lbl = Gtk.Label(label=_("No hosts found")) - lbl.add_css_class("title-2") - box.append(icon) - box.append(lbl) - row.set_child(box) - self.hosts_list.append(row) - else: - for h in hosts: - self.hosts_list.append(self.create_host_row_custom(h)) + self.update_hosts_list(hosts) return False NetworkDiscovery().discover_hosts(callback=on_hosts_discovered) @@ -508,7 +564,6 @@ def update_hosts_list(self, hosts): # Clear self.first_radio_in_list = None self.selected_host_card_data = None - self.selected_host_card_data = None self._update_all_buttons_state() while True: @@ -517,6 +572,14 @@ def update_hosts_list(self, hosts): break self.hosts_list.remove(row) + if not hosts: + placeholder = Gtk.ListBoxRow() + placeholder.set_selectable(False) + placeholder.set_activatable(False) + placeholder.set_child(self._build_discover_empty_state()) + self.hosts_list.append(placeholder) + return + for host in hosts: self.hosts_list.append(self.create_host_row_custom(host)) diff --git a/tests/test_connect_redesign_regressions.py b/tests/test_connect_redesign_regressions.py index 4ac0e11..06e5f0c 100644 --- a/tests/test_connect_redesign_regressions.py +++ b/tests/test_connect_redesign_regressions.py @@ -14,3 +14,20 @@ def test_host_scroll_has_breathing_room_and_no_tall_min() -> None: assert "host_scroll.set_min_content_height(120)" in src assert "self.hosts_list.set_margin_top(6)" in src assert "self.hosts_list.set_margin_bottom(6)" in src + + +def test_empty_state_routes_novice_to_real_unlocks() -> None: + src = GUEST.read_text() + assert "_build_discover_empty_state" in src + # Re-scan, Private Network, PIN, Manual all reachable from the empty state. + assert 'navigate_to("vpn_selector")' in src + assert 'self.method_stack.set_visible_child_name("pin")' in src + assert 'self.method_stack.set_visible_child_name("manual")' in src + + +def test_empty_state_buttons_have_accessible_labels() -> None: + src = GUEST.read_text() + # The empty-state builder must give its action buttons accessible names + # (icon/short-label buttons have no inferable AT-SPI name). + block = src.split("def _build_discover_empty_state", 1)[1].split("def create_discover_page", 1)[0] + assert "update_property([Gtk.AccessibleProperty.LABEL]" in block From 9d0c854e61d517e1523d8789e150fbb8254f57a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Gon=C3=A7alves?= Date: Thu, 25 Jun 2026 07:29:36 -0300 Subject: [PATCH 34/47] feat(connect): single-column discover with guidance subtitle + quiet footer Claude-Session: https://claude.ai/code/session_01DhGCL133apRAwBMk6dktHC --- src/big_remote_play/ui/guest_view.py | 47 +++++++++++++--------- tests/test_connect_redesign_regressions.py | 8 ++++ 2 files changed, 36 insertions(+), 19 deletions(-) diff --git a/src/big_remote_play/ui/guest_view.py b/src/big_remote_play/ui/guest_view.py index f665760..fa90bae 100644 --- a/src/big_remote_play/ui/guest_view.py +++ b/src/big_remote_play/ui/guest_view.py @@ -11,7 +11,7 @@ from big_remote_play.guest.moonlight_client import MoonlightClient from big_remote_play.utils.i18n import _ from big_remote_play.utils.icons import create_icon_widget -from big_remote_play.utils.widgets import create_helper_card, create_stack_tab_strip +from big_remote_play.utils.widgets import create_stack_tab_strip from big_remote_play.utils.moonlight_config import MoonlightConfigManager @@ -446,9 +446,11 @@ def create_discover_page(self): lbl = Gtk.Label(label=_("Discovered Hosts")) lbl.add_css_class("heading") lbl.set_halign(Gtk.Align.START) - desc = Gtk.Label(label=_("Scroll to list all found devices.")) + desc = Gtk.Label(label=_("On the local network or with a Private Network set up, the host appears here automatically.")) desc.add_css_class("dim-label") desc.set_halign(Gtk.Align.START) + desc.set_wrap(True) + desc.set_xalign(0) text_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) text_box.set_hexpand(True) @@ -508,23 +510,30 @@ def create_discover_page(self): box.append(action) box.set_hexpand(True) - # Side helper card: what to try if the host doesn't show up (mockup 01). - helper = create_helper_card( - _("If you can't find the host"), - "dialog-question-symbolic", - [ - ("network-wireless-symbolic", _("Check your local network"), _("Make sure the host and this device are on the same Wi-Fi or wired network.")), - ("network-wired-symbolic", _("Use the manual connection"), _("Enter the host IP address or hostname and port to connect directly.")), - ("dialog-password-symbolic", _("Use the PIN code"), _("Ask the host for the PIN code and connect quickly and securely.")), - ], - ) - helper.set_valign(Gtk.Align.START) - helper.set_size_request(280, -1) - - columns = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=18) - columns.append(box) - columns.append(helper) - return columns + # Quiet footer (shown alongside a populated list): the same two real + # fallbacks the empty state offers, without promoting Manual/IP. + footer = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + footer.set_halign(Gtk.Align.CENTER) + footer.set_margin_top(4) + hint = Gtk.Label(label=_("Can't find your friend's PC?")) + hint.add_css_class("dim-label") + hint.add_css_class("caption") + footer.append(hint) + pn_link = Gtk.Button(label=_("Set up Private Network")) + pn_link.add_css_class("flat") + pn_link.add_css_class("caption") + pn_link.update_property([Gtk.AccessibleProperty.LABEL], [_("Open Private Network setup")]) + pn_link.connect("clicked", lambda _b: self._go_to_private_network()) + footer.append(pn_link) + pin_link = Gtk.Button(label=_("PIN code")) + pin_link.add_css_class("flat") + pin_link.add_css_class("caption") + pin_link.update_property([Gtk.AccessibleProperty.LABEL], [_("Switch to PIN connection")]) + pin_link.connect("clicked", lambda _b: self.method_stack.set_visible_child_name("pin")) + footer.append(pin_link) + box.append(footer) + box.set_hexpand(True) + return box def discover_hosts(self): from big_remote_play.utils.network import NetworkDiscovery diff --git a/tests/test_connect_redesign_regressions.py b/tests/test_connect_redesign_regressions.py index 06e5f0c..05099e5 100644 --- a/tests/test_connect_redesign_regressions.py +++ b/tests/test_connect_redesign_regressions.py @@ -31,3 +31,11 @@ def test_empty_state_buttons_have_accessible_labels() -> None: # (icon/short-label buttons have no inferable AT-SPI name). block = src.split("def _build_discover_empty_state", 1)[1].split("def create_discover_page", 1)[0] assert "update_property([Gtk.AccessibleProperty.LABEL]" in block + + +def test_discover_is_single_column_with_guidance() -> None: + src = GUEST.read_text() + # Side helper-card column removed from the discover page. + assert "create_helper_card(" not in src + # Fixed automatic-discovery guidance subtitle present. + assert "appears here automatically" in src From 3673e18f3b2576a8831e98fa24199f7019137901 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Gon=C3=A7alves?= Date: Thu, 25 Jun 2026 07:34:00 -0300 Subject: [PATCH 35/47] feat(connect): quality expander + render empty state outside capped scroller (no clip) Claude-Session: https://claude.ai/code/session_01DhGCL133apRAwBMk6dktHC --- src/big_remote_play/ui/guest_view.py | 86 ++++++++++++++++------ tests/test_connect_redesign_regressions.py | 7 ++ 2 files changed, 71 insertions(+), 22 deletions(-) diff --git a/src/big_remote_play/ui/guest_view.py b/src/big_remote_play/ui/guest_view.py index fa90bae..9134e64 100644 --- a/src/big_remote_play/ui/guest_view.py +++ b/src/big_remote_play/ui/guest_view.py @@ -55,6 +55,16 @@ def run_detect(): threading.Thread(target=run_detect, daemon=True).start() + def _quality_summary(self) -> str: + """One-line summary for the collapsed quality expander, e.g. + '1080p · 60 FPS · Áudio'. Reads the current row selections.""" + res_item = self.resolution_row.get_selected_item() + res = res_item.get_string() if res_item is not None else "1080p" + fps_item = self.fps_row.get_selected_item() + fps = fps_item.get_string() if fps_item is not None else "60 FPS" + audio = _("Audio") if self.audio_row.get_active() else _("No audio") + return f"{res} · {fps} · {audio}" + def setup_ui(self): clamp = Adw.Clamp() clamp.set_maximum_size(1040) @@ -104,6 +114,12 @@ def setup_ui(self): reset_btn.set_tooltip_text(_("Reset to Defaults")) reset_btn.connect("clicked", self.on_reset_clicked) settings_group.set_header_suffix(reset_btn) + # Collapse the client settings so they don't compete on first load; the + # summary shows the current quality at a glance. + self.quality_expander = Adw.ExpanderRow() + self.quality_expander.set_title(_("Adjust quality")) + self.quality_expander.set_expanded(False) + settings_group.add(self.quality_expander) self.resolution_row = Adw.ComboRow() self.resolution_row.set_title(_("Resolution")) self.resolution_row.set_subtitle(_("Stream resolution")) @@ -112,14 +128,14 @@ def setup_ui(self): res_model.append(r) self.resolution_row.set_model(res_model) self.resolution_row.set_selected(1) - settings_group.add(self.resolution_row) + self.quality_expander.add_row(self.resolution_row) self.scale_row = Adw.SwitchRow() self.scale_row.set_title(_("Native Resolution (Adaptive)")) self.scale_row.set_subtitle(_("Use screen/window resolution")) self.scale_row.set_active(False) self.scale_row.connect("notify::active", self.on_scale_changed) - settings_group.add(self.scale_row) + self.quality_expander.add_row(self.scale_row) self.fps_row = Adw.ComboRow() self.fps_row.set_title(_("Frame Rate (FPS)")) @@ -129,7 +145,7 @@ def setup_ui(self): fps_model.append(f) self.fps_row.set_model(fps_model) self.fps_row.set_selected(1) - settings_group.add(self.fps_row) + self.quality_expander.add_row(self.fps_row) # Connect signals for Custom handling self.custom_resolution_val = None @@ -145,7 +161,7 @@ def setup_ui(self): self.apply_settings_btn.set_margin_top(24) self.apply_settings_btn.set_visible(False) self.apply_settings_btn.connect("clicked", lambda b: self.check_reconnect()) - settings_group.add(self.apply_settings_btn) + self.quality_expander.add_row(self.apply_settings_btn) bitrate_row = Adw.ActionRow() bitrate_row.set_title(_("Bitrate (Quality)")) @@ -161,7 +177,7 @@ def setup_ui(self): bitrate_box.append(self.bitrate_scale) bitrate_box.append(detect_btn) bitrate_row.add_suffix(bitrate_box) - settings_group.add(bitrate_row) + self.quality_expander.add_row(bitrate_row) self.display_mode_row = Adw.ComboRow() self.display_mode_row.set_title(_("Display Mode")) @@ -171,18 +187,18 @@ def setup_ui(self): disp_model.append(d) self.display_mode_row.set_model(disp_model) self.display_mode_row.set_selected(0) - settings_group.add(self.display_mode_row) + self.quality_expander.add_row(self.display_mode_row) self.audio_row = Adw.SwitchRow() self.audio_row.set_title(_("Audio")) self.audio_row.set_subtitle(_("Receive audio streaming")) self.audio_row.set_active(True) - settings_group.add(self.audio_row) + self.quality_expander.add_row(self.audio_row) self.hw_decode_row = Adw.SwitchRow() self.hw_decode_row.set_title(_("Hardware Decoding")) self.hw_decode_row.set_subtitle(_("Use GPU for decoding")) self.hw_decode_row.set_active(True) - settings_group.add(self.hw_decode_row) + self.quality_expander.add_row(self.hw_decode_row) advanced_title = _("Advanced client settings") advanced_subtitle = _("Input, controller, codec and more") @@ -226,12 +242,17 @@ def setup_ui(self): advanced_box.append(advanced_arrow) advanced_button.set_child(advanced_box) - settings_group.add(advanced_button) + self.quality_expander.add_row(advanced_button) content.append(self.switcher_box) content.append(settings_group) self.load_guest_settings() self.connect_settings_signals() + # Keep the collapsed expander's summary in sync with the live values. + self.quality_expander.set_subtitle(self._quality_summary()) + for _row in (self.resolution_row, self.fps_row): + _row.connect("notify::selected-item", lambda *_a: self.quality_expander.set_subtitle(self._quality_summary())) + self.audio_row.connect("notify::active", lambda *_a: self.quality_expander.set_subtitle(self._quality_summary())) clamp.set_child(content) scroll = Gtk.ScrolledWindow() scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) @@ -496,17 +517,24 @@ def create_discover_page(self): buttons_box.append(self.main_connect_btn) - host_scroll = Gtk.ScrolledWindow() - host_scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) - host_scroll.set_max_content_height(400) - host_scroll.set_min_content_height(120) - host_scroll.set_vexpand(False) - host_scroll.set_propagate_natural_height(True) - host_scroll.set_child(self.hosts_list) + self._host_scroll = Gtk.ScrolledWindow() + self._host_scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + self._host_scroll.set_max_content_height(400) + self._host_scroll.set_min_content_height(120) + self._host_scroll.set_vexpand(False) + self._host_scroll.set_propagate_natural_height(True) + self._host_scroll.set_child(self.hosts_list) + + # Empty state lives OUTSIDE the height-capped scroller, so its taller + # guidance card is never clipped (the scroller is only for host rows). + self._empty_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self._empty_container.set_visible(False) action.append(buttons_box) + self._discover_action = action box.append(header) - box.append(host_scroll) + box.append(self._host_scroll) + box.append(self._empty_container) box.append(action) box.set_hexpand(True) @@ -531,6 +559,7 @@ def create_discover_page(self): pin_link.update_property([Gtk.AccessibleProperty.LABEL], [_("Switch to PIN connection")]) pin_link.connect("clicked", lambda _b: self.method_stack.set_visible_child_name("pin")) footer.append(pin_link) + self._discover_footer = footer box.append(footer) box.set_hexpand(True) return box @@ -540,6 +569,11 @@ def discover_hosts(self): self.first_radio_in_list = self.selected_host_card_data = None self._update_all_buttons_state() + # Scanning shows the spinner in the list scroller, not the empty state. + if hasattr(self, "_empty_container"): + self._empty_container.set_visible(False) + self._host_scroll.set_visible(True) + self._discover_action.set_visible(True) while row := self.hosts_list.get_row_at_index(0): self.hosts_list.remove(row) self.loading_row = Gtk.ListBoxRow() @@ -582,13 +616,21 @@ def update_hosts_list(self, hosts): self.hosts_list.remove(row) if not hosts: - placeholder = Gtk.ListBoxRow() - placeholder.set_selectable(False) - placeholder.set_activatable(False) - placeholder.set_child(self._build_discover_empty_state()) - self.hosts_list.append(placeholder) + # Show the uncapped guidance card; hide the list scroller, the + # connect button and the footer (which only make sense with hosts). + while child := self._empty_container.get_first_child(): + self._empty_container.remove(child) + self._empty_container.append(self._build_discover_empty_state()) + self._empty_container.set_visible(True) + self._host_scroll.set_visible(False) + self._discover_action.set_visible(False) + self._discover_footer.set_visible(False) return + self._empty_container.set_visible(False) + self._host_scroll.set_visible(True) + self._discover_action.set_visible(True) + self._discover_footer.set_visible(True) for host in hosts: self.hosts_list.append(self.create_host_row_custom(host)) diff --git a/tests/test_connect_redesign_regressions.py b/tests/test_connect_redesign_regressions.py index 05099e5..9b115ae 100644 --- a/tests/test_connect_redesign_regressions.py +++ b/tests/test_connect_redesign_regressions.py @@ -39,3 +39,10 @@ def test_discover_is_single_column_with_guidance() -> None: assert "create_helper_card(" not in src # Fixed automatic-discovery guidance subtitle present. assert "appears here automatically" in src + + +def test_client_settings_collapsed_behind_quality_expander() -> None: + src = GUEST.read_text() + assert "Adw.ExpanderRow" in src + assert "_quality_summary" in src + assert "Adjust quality" in src From 84bf74c1ec98195a9d554672489933c220ffda82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Gon=C3=A7alves?= Date: Thu, 25 Jun 2026 07:34:45 -0300 Subject: [PATCH 36/47] feat(vpn): plain-language role framing + collapsed comparison on selector Claude-Session: https://claude.ai/code/session_01DhGCL133apRAwBMk6dktHC --- src/big_remote_play/ui/main_window.py | 22 ++++++++++++++++------ tests/test_connect_redesign_regressions.py | 6 ++++++ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/big_remote_play/ui/main_window.py b/src/big_remote_play/ui/main_window.py index 45cab1b..dd5b5c9 100644 --- a/src/big_remote_play/ui/main_window.py +++ b/src/big_remote_play/ui/main_window.py @@ -582,6 +582,16 @@ def create_vpn_selector_page(self): box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=24) + # Plain-language role framing so a non-technical user understands what a + # Private Network is for and who does what. + intro = Gtk.Label( + label=_("To play over the internet, you set up a Private Network once: the one with the game creates it, the friend joins. After that, the PC appears automatically under Connect.") + ) + intro.add_css_class("dim-label") + intro.set_wrap(True) + intro.set_xalign(0) + box.append(intro) + # Cards row cards_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=16) cards_box.set_halign(Gtk.Align.CENTER) @@ -594,12 +604,12 @@ def create_vpn_selector_page(self): box.append(cards_box) - # Comparison table (real grid, mockup 04) - compare_group = Adw.PreferencesGroup() - compare_group.set_title(_("Quick Comparison")) - compare_group.set_header_suffix(create_icon_widget("preferences-other-symbolic", size=18)) + # Comparison table collapsed behind a disclosure — it invites analysis a + # novice doesn't need; the recommended card already guides them. + comparison_expander = Gtk.Expander(label=_("Compare the options")) + comparison_expander.set_expanded(False) # Brand names (column headers) are not translated. - compare_group.add( + comparison_expander.set_child( create_comparison_table( ["Tailscale", "ZeroTier", "Headscale"], [ @@ -609,7 +619,7 @@ def create_vpn_selector_page(self): ], ) ) - box.append(compare_group) + box.append(comparison_expander) # "How it works" strip: Install → Authenticate → Play. steps_group = Adw.PreferencesGroup() diff --git a/tests/test_connect_redesign_regressions.py b/tests/test_connect_redesign_regressions.py index 9b115ae..5409fe2 100644 --- a/tests/test_connect_redesign_regressions.py +++ b/tests/test_connect_redesign_regressions.py @@ -46,3 +46,9 @@ def test_client_settings_collapsed_behind_quality_expander() -> None: assert "Adw.ExpanderRow" in src assert "_quality_summary" in src assert "Adjust quality" in src + + +def test_vpn_selector_has_role_framing_and_collapsed_comparison() -> None: + src = MAIN.read_text() + assert "Gtk.Expander" in src # comparison table is collapsible + assert "the one with the game creates" in src From ef88e6ad647dfd8ee6b8bdcd8b525d8496b60e61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Gon=C3=A7alves?= Date: Thu, 25 Jun 2026 07:37:37 -0300 Subject: [PATCH 37/47] feat(vpn): prominent browser login on Tailscale form; auth key now advanced Claude-Session: https://claude.ai/code/session_01DhGCL133apRAwBMk6dktHC --- .../ui/private_network_view.py | 109 ++++++++++++++---- tests/test_connect_redesign_regressions.py | 6 + 2 files changed, 91 insertions(+), 24 deletions(-) diff --git a/src/big_remote_play/ui/private_network_view.py b/src/big_remote_play/ui/private_network_view.py index 8073e02..e782955 100644 --- a/src/big_remote_play/ui/private_network_view.py +++ b/src/big_remote_play/ui/private_network_view.py @@ -385,9 +385,24 @@ def _build(self): btn_inner.append(self._spinner) btn_inner.append(self._action_lbl) + # Tailscale: browser login is the prominent, no-typing primary action. + # _on_action with an empty auth key (now behind the advanced disclosure) + # triggers the browser login flow. + if self.vpn_id == "tailscale": + self._btn_browser = Gtk.Button(label=_("Sign in with browser")) + self._btn_browser.add_css_class("pill") + self._btn_browser.add_css_class("suggested-action") + self._btn_browser.set_size_request(220, 48) + self._btn_browser.update_property([Gtk.AccessibleProperty.LABEL], [_("Sign in with browser")]) + self._btn_browser.connect("clicked", self._on_action) + btn_box.append(self._btn_browser) + self._btn_action = Gtk.Button() self._btn_action.add_css_class("pill") - self._btn_action.add_css_class("suggested-action") + # On Tailscale the browser button is the single visual primary; keep the + # generic action button as a neutral secondary. + if self.vpn_id != "tailscale": + self._btn_action.add_css_class("suggested-action") self._btn_action.set_size_request(200, 48) self._btn_action.set_child(btn_inner) self._btn_action.connect("clicked", self._on_action) @@ -454,9 +469,9 @@ def _build_form(self): self._form_rows.extend([self._e_domain, self._e_zone, self._e_token]) elif self.vpn_id == "tailscale": - self._e_authkey = Adw.PasswordEntryRow(title=_("Auth Key (optional – leave empty for browser login)")) - self._form_group.add(self._e_authkey) - self._form_rows.append(self._e_authkey) + # Browser login is the primary path (button in _build); the auth key + # is for advanced users, tucked behind a collapsed disclosure. + self._e_authkey = Adw.PasswordEntryRow(title=_("Auth Key")) link = Adw.ActionRow(title=_("Get auth key"), subtitle=_("login.tailscale.com/admin/settings/keys")) link.add_prefix(create_icon_widget("network-wired-symbolic", size=18)) btn = Gtk.Button(label=_("Open")) @@ -464,8 +479,13 @@ def _build_form(self): btn.set_valign(Gtk.Align.CENTER) btn.connect("clicked", lambda b: open_uri("https://login.tailscale.com/admin/settings/keys")) link.add_suffix(btn) - self._form_group.add(link) - self._form_rows.append(link) + adv = Adw.ExpanderRow() + adv.set_title(_("I have an auth key")) + adv.set_expanded(False) + adv.add_row(self._e_authkey) + adv.add_row(link) + self._form_group.add(adv) + self._form_rows.append(adv) elif self.vpn_id == "zerotier": saved = _get_zerotier_api_token() @@ -1341,6 +1361,8 @@ def _build(self): btn_ref.add_css_class("flat") btn_ref.set_halign(Gtk.Align.END) btn_ref.set_hexpand(True) + btn_ref.set_tooltip_text(_("Refresh device list")) + btn_ref.update_property([Gtk.AccessibleProperty.LABEL], [_("Refresh device list")]) btn_ref.connect("clicked", lambda b: self._refresh_status()) hdr2.append(btn_ref) @@ -1352,19 +1374,14 @@ def _build(self): self._status_banner.connect("button-clicked", lambda b: self._prompt_api_token()) status_box.append(self._status_banner) - self._peers_store = Gtk.ListStore(str, str, str, str, str, str, str) - self._peers_tree = Gtk.TreeView(model=self._peers_store) - cols = [_("Auth"), _("ID"), _("Name"), _("Managed IP"), _("Last Seen"), _("Version"), _("Physical IP")] - for i, col in enumerate(cols): - rend = Gtk.CellRendererText() - c = Gtk.TreeViewColumn(col, rend, text=i) - c.set_resizable(True) - c.set_expand(i in [2, 3]) # Expand Name and IP - self._peers_tree.append_column(c) - + # Device list as libadwaita rows (GtkTreeView is deprecated in GTK4 and + # rendered raw, with no status colour and emoji cells). Each device is an + # AdwActionRow: auth icon prefix, name + managed IP, online/offline pill. + self._peers_list = Gtk.ListBox() + self._peers_list.add_css_class("boxed-list") + self._peers_list.set_selection_mode(Gtk.SelectionMode.NONE) scroll_tree = Gtk.ScrolledWindow(vexpand=True) - scroll_tree.set_child(self._peers_tree) - scroll_tree.add_css_class("card") + scroll_tree.set_child(self._peers_list) status_box.append(scroll_tree) # Prominent API Token buttons @@ -1768,15 +1785,59 @@ def fmt_time(ts): GLib.idle_add(self._update_status_ui, rows) def _update_status_ui(self, rows): - self._peers_store.clear() + while child := self._peers_list.get_first_child(): + self._peers_list.remove(child) + if not rows: if self.vpn_id == "zerotier" and not _has_zerotier_api_token(): - self._peers_store.append(("ℹ️", _("API Token Missing"), _("See banner above"), "", "", "", "")) + title, desc, icon = _("API Token Missing"), _("See banner above"), "dialog-information-symbolic" else: - self._peers_store.append(("ℹ️", _("No devices found"), _("Try refreshing..."), "", "", "", "")) - else: - for r in rows: - self._peers_store.append(r) + title, desc, icon = _("No devices found"), _("Try refreshing..."), "network-offline-symbolic" + empty_row = Adw.ActionRow(title=title, subtitle=desc) + empty_prefix = create_icon_widget(icon, size=16) + empty_prefix.add_css_class("dim-label") + empty_row.add_prefix(empty_prefix) + self._peers_list.append(empty_row) + return + + for auth, dev_id, name, ip, last_seen, ver, phys in rows: + self._peers_list.append(self._build_peer_row(auth, dev_id, name, ip, last_seen, ver, phys)) + + def _build_peer_row(self, auth, dev_id, name, ip, last_seen, ver, phys): + row = Adw.ActionRow() + row.set_title(name or dev_id or _("Unknown device")) + + # Subtitle: managed IP, then optional ID / version / physical address. + details = [d for d in (ip, _("ID: {}").format(dev_id) if dev_id else "", _("v{}").format(ver) if ver else "", phys) if d] + row.set_subtitle(" • ".join(details)) + + # Auth state as a coloured symbolic icon, replacing the ✅/❌ emoji cells. + auth_map = { + "✅": ("emblem-ok-symbolic", "success", _("Authorized")), + "❌": ("action-unavailable-symbolic", "error", _("Not authorized")), + "🌐": ("network-workgroup-symbolic", "accent", _("Peer")), + } + auth_icon, auth_css, auth_label = auth_map.get(auth, ("dialog-question-symbolic", "dim-label", _("Unknown"))) + prefix = create_icon_widget(auth_icon, size=16) + prefix.add_css_class(auth_css) + prefix.set_valign(Gtk.Align.CENTER) + prefix.set_tooltip_text(auth_label) + prefix.update_property([Gtk.AccessibleProperty.LABEL], [auth_label]) + row.add_prefix(prefix) + + # Online/offline as icon + label (not colour-only); time text otherwise. + is_online = last_seen == _("Online") + is_offline = last_seen == _("Offline") + status_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6, valign=Gtk.Align.CENTER) + if is_online or is_offline: + dot = create_icon_widget("media-record-symbolic", size=10) + dot.add_css_class("success" if is_online else "dim-label") + status_box.append(dot) + status_lbl = Gtk.Label(label=last_seen or _("—")) + status_lbl.add_css_class("success" if is_online else "dim-label") + status_box.append(status_lbl) + row.add_suffix(status_box) + return row def _refresh_history(self): # Clear all children from the hist_list (Gtk.Box — safe to iterate directly) diff --git a/tests/test_connect_redesign_regressions.py b/tests/test_connect_redesign_regressions.py index 5409fe2..a50338c 100644 --- a/tests/test_connect_redesign_regressions.py +++ b/tests/test_connect_redesign_regressions.py @@ -52,3 +52,9 @@ def test_vpn_selector_has_role_framing_and_collapsed_comparison() -> None: src = MAIN.read_text() assert "Gtk.Expander" in src # comparison table is collapsible assert "the one with the game creates" in src + + +def test_tailscale_browser_login_prominent_and_key_advanced() -> None: + src = PNV.read_text() + assert "Adw.ExpanderRow" in src # auth key behind an advanced disclosure + assert "Sign in with browser" in src From eb2ba466549029d4471b5d52dbcc1a26383f6512 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Gon=C3=A7alves?= Date: Thu, 25 Jun 2026 07:39:12 -0300 Subject: [PATCH 38/47] i18n(pt): translate Connect/Rede Privada novice redesign strings Claude-Session: https://claude.ai/code/session_01DhGCL133apRAwBMk6dktHC --- locale/pt.po | 1101 ++++++++++++++++++++++++++++---------------------- 1 file changed, 614 insertions(+), 487 deletions(-) diff --git a/locale/pt.po b/locale/pt.po index 6a32147..e766c2d 100644 --- a/locale/pt.po +++ b/locale/pt.po @@ -19,7 +19,7 @@ msgstr "" #: src/big_remote_play/app.py:49 msgid "Could not load icon theme: no GTK display" -msgstr "" +msgstr "Não foi possível carregar o tema de ícones: sem display GTK" # File: big-remote-play/usr/share/big-remote-play/main.py, line: 52 #: src/big_remote_play/app.py:56 @@ -101,20 +101,20 @@ msgstr "Sunshine não está em execução." #: src/big_remote_play/host/sunshine_manager.py:312 #, python-brace-format msgid "Certificate trust error: {}" -msgstr "" +msgstr "Erro de confiança no certificado: {}" #: src/big_remote_play/host/sunshine_manager.py:349 #, python-brace-format msgid "Sunshine API request failed: {}" -msgstr "" +msgstr "Falha na requisição à API do Sunshine: {}" #: src/big_remote_play/host/sunshine_manager.py:362 #: src/big_remote_play/host/sunshine_manager.py:393 msgid "Connection Error: Sunshine unreachable or certificate mismatch" -msgstr "" +msgstr "Erro de conexão: Sunshine inacessível ou certificado incompatível" # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# +# # File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 601 # File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 665 # File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 705 @@ -129,7 +129,7 @@ msgstr "PIN enviado com sucesso" #: src/big_remote_play/host/sunshine_manager.py:368 msgid "Sunshine rejected the PIN" -msgstr "" +msgstr "O Sunshine rejeitou o PIN" # File: big-remote-play/usr/share/big-remote-play/host/sunshine_manager.py, # line: 312 @@ -141,46 +141,46 @@ msgstr "Autenticação Falhou. Configure um usuário no Sunshine." #: src/big_remote_play/host/sunshine_manager.py:403 #, python-brace-format msgid "API Error: {}" -msgstr "" +msgstr "Erro de API: {}" #: src/big_remote_play/host/sunshine_manager.py:395 msgid "Authentication Failed. Check the current password." -msgstr "" +msgstr "Falha na autenticação. Verifique a senha atual." #: src/big_remote_play/host/sunshine_manager.py:399 msgid "Sunshine rejected the credentials" -msgstr "" +msgstr "O Sunshine rejeitou as credenciais" #: src/big_remote_play/host/sunshine_manager.py:402 msgid "Credentials updated successfully" -msgstr "" +msgstr "Credenciais atualizadas com sucesso" #: src/big_remote_play/host/sunshine_manager.py:417 msgid "Username and password cannot be empty" -msgstr "" +msgstr "Usuário e senha não podem ficar vazios" #: src/big_remote_play/host/sunshine_manager.py:420 #: src/big_remote_play/utils/system_check.py:138 msgid "Sunshine executable not found" -msgstr "" +msgstr "Executável do Sunshine não encontrado" #: src/big_remote_play/host/sunshine_manager.py:426 msgid "Credentials reset successfully" -msgstr "" +msgstr "Credenciais redefinidas com sucesso" #: src/big_remote_play/host/sunshine_manager.py:428 #, python-brace-format msgid "Reset failed: {}" -msgstr "" +msgstr "Falha ao redefinir: {}" #: src/big_remote_play/host/sunshine_manager.py:428 msgid "Reset failed" -msgstr "" +msgstr "Falha ao redefinir" #: src/big_remote_play/host/sunshine_manager.py:430 #, python-brace-format msgid "Reset error: {}" -msgstr "" +msgstr "Erro ao redefinir: {}" # File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 36 #: src/big_remote_play/ui/guest_view.py:43 @@ -204,7 +204,7 @@ msgid "Manual" msgstr "Manual" # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# +# # File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 361 # File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 382 # File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 70 @@ -230,7 +230,7 @@ msgid "Client Settings" msgstr "Configurações do Cliente" # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# +# # File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 127 # File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 83 #: src/big_remote_play/ui/guest_view.py:95 @@ -239,7 +239,7 @@ msgid "Reset to Defaults" msgstr "Redefinir para Padrões" # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# +# # File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, # line: 78 # File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 85 @@ -279,7 +279,7 @@ msgid "Use screen/window resolution" msgstr "Use a resolução de tela/janela" # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# +# # File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, # line: 90 # File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 96 @@ -315,7 +315,7 @@ msgid "Detect" msgstr "Detectar" # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# +# # File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, # line: 114 # File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 123 @@ -330,7 +330,7 @@ msgid "How the window will be displayed" msgstr "Como a janela será exibida" # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# +# # File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, # line: 116 # File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 125 @@ -340,7 +340,7 @@ msgid "Borderless Window" msgstr "Janela Sem Bordas" # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# +# # File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, # line: 116 # File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 125 @@ -350,7 +350,7 @@ msgid "Fullscreen" msgstr "Tela cheia" # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# +# # File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, # line: 116 # File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 125 @@ -360,7 +360,7 @@ msgid "Windowed" msgstr "Em janela" # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# +# # File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 224 # File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 352 # File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 128 @@ -388,11 +388,11 @@ msgstr "Usar GPU para decodificação" #: src/big_remote_play/ui/guest_view.py:145 #: src/big_remote_play/ui/guest_view.py:999 msgid "Advanced client settings" -msgstr "" +msgstr "Configurações avançadas do cliente" #: src/big_remote_play/ui/guest_view.py:146 msgid "Input, controller, codec and more" -msgstr "" +msgstr "Entrada, controle, codec e mais" # File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 149 #: src/big_remote_play/ui/guest_view.py:169 @@ -400,7 +400,7 @@ msgid "Active Session" msgstr "Sessão Ativa" # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# +# # File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, # line: 325 # File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 159 @@ -415,9 +415,9 @@ msgid "Moonlight closed" msgstr "Moonlight fechado" # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# +# # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# +# # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 564 # File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 868 # File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 198 @@ -442,7 +442,7 @@ msgid "Connect to Selected" msgstr "Conectar ao Selecionado" # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# +# # File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, # line: 962 # File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 224 @@ -484,20 +484,22 @@ msgstr "Se você não encontrar o host" #: src/big_remote_play/ui/guest_view.py:319 msgid "Check your local network" -msgstr "" +msgstr "Verifique sua rede local" #: src/big_remote_play/ui/guest_view.py:320 msgid "" "Make sure the host and this device are on the same Wi-Fi or wired network." msgstr "" +"Verifique se o host e este dispositivo estão na mesma rede Wi-Fi ou cabeada." #: src/big_remote_play/ui/guest_view.py:321 msgid "Use the manual connection" -msgstr "" +msgstr "Use a conexão manual" #: src/big_remote_play/ui/guest_view.py:322 msgid "Enter the host IP address or hostname and port to connect directly." msgstr "" +"Informe o endereço IP ou o nome do host e a porta para conectar diretamente." #: src/big_remote_play/ui/guest_view.py:323 msgid "Use the PIN code" @@ -505,7 +507,7 @@ msgstr "Use o código PIN" #: src/big_remote_play/ui/guest_view.py:324 msgid "Ask the host for the PIN code and connect quickly and securely." -msgstr "" +msgstr "Peça o código PIN ao host e conecte-se de forma rápida e segura." # File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 307 #: src/big_remote_play/ui/guest_view.py:345 @@ -518,7 +520,7 @@ msgid "No hosts found" msgstr "Nenhum host encontrado" # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# +# # File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, # line: 720 # File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 365 @@ -576,9 +578,9 @@ msgid "Active Stream" msgstr "Fluxo Ativo" # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# +# # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# +# # File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, # line: 740 # File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, @@ -664,13 +666,13 @@ msgstr "" "{}" # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# +# # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# +# # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# +# # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# +# # File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, # line: 70 # File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, @@ -731,7 +733,7 @@ msgstr "Emparelhamento Iniciado" #: src/big_remote_play/ui/preferences.py:186 #: src/big_remote_play/ui/preferences.py:300 msgid "OK" -msgstr "" +msgstr "OK" # File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 719 #: src/big_remote_play/ui/guest_view.py:779 @@ -788,9 +790,9 @@ msgid "Use a number" msgstr "Use um número" # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# +# # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# +# # File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, # line: 299 # File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 48 @@ -829,60 +831,60 @@ msgstr "Todas as configurações do cliente serão restauradas." # File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 815 #: src/big_remote_play/ui/guest_view.py:971 msgid "No" -msgstr "" +msgstr "Não" #: src/big_remote_play/ui/guest_view.py:972 msgid "Yes" -msgstr "" +msgstr "Sim" # File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 913 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 913 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 816 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 816 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 816 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 816 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 825 # File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 831 #: src/big_remote_play/ui/guest_view.py:981 @@ -1047,11 +1049,11 @@ msgstr "Comando" #: src/big_remote_play/ui/host_view.py:202 msgid "Browse host for executable" -msgstr "" +msgstr "Procurar executável no host" #: src/big_remote_play/ui/host_view.py:203 msgid "Browse host filesystem" -msgstr "" +msgstr "Navegar pelo sistema de arquivos do host" # File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 164 #: src/big_remote_play/ui/host_view.py:210 @@ -1215,7 +1217,7 @@ msgid "Guest" msgstr "Convidado" # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# +# # File: big-remote-play/usr/share/big-remote-play/utils/network.py, line: 267 # File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, # line: 992 @@ -1240,7 +1242,7 @@ msgid "Manage audio sources" msgstr "Gerenciar fontes de áudio" # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# +# # File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, # line: 174 # File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 255 @@ -1316,11 +1318,11 @@ msgstr "Configurar Sunshine" #: src/big_remote_play/ui/host_view.py:415 msgid "Generate PIN" -msgstr "" +msgstr "Gerar PIN" #: src/big_remote_play/ui/host_view.py:416 msgid "Generate a PIN for a guest" -msgstr "" +msgstr "Gerar um PIN para um convidado" #: src/big_remote_play/ui/host_view.py:438 msgid "Management" @@ -1333,7 +1335,7 @@ msgstr "Dispositivos pareados" #: src/big_remote_play/ui/host_view.py:442 msgid "View, disable or remove paired clients" -msgstr "" +msgstr "Ver, desativar ou remover clientes pareados" #: src/big_remote_play/ui/host_view.py:449 #: src/big_remote_play/ui/host_view.py:2008 @@ -1342,7 +1344,7 @@ msgstr "Logs do Sunshine" #: src/big_remote_play/ui/host_view.py:450 msgid "View the Sunshine server log" -msgstr "" +msgstr "Ver o log do servidor Sunshine" #: src/big_remote_play/ui/host_view.py:457 #: src/big_remote_play/ui/host_view.py:2094 @@ -1351,16 +1353,16 @@ msgstr "Biblioteca de jogos" #: src/big_remote_play/ui/host_view.py:458 msgid "Manage games shown to guests in Moonlight" -msgstr "" +msgstr "Gerenciar os jogos exibidos aos convidados no Moonlight" #: src/big_remote_play/ui/host_view.py:465 #: src/big_remote_play/ui/host_view.py:2323 msgid "Server Password" -msgstr "" +msgstr "Senha do servidor" #: src/big_remote_play/ui/host_view.py:466 msgid "Change or reset the Sunshine login (if you forgot it)" -msgstr "" +msgstr "Alterar ou redefinir o login do Sunshine (se você esqueceu)" #: src/big_remote_play/ui/host_view.py:473 #: src/big_remote_play/ui/host_view.py:2317 @@ -1369,23 +1371,26 @@ msgstr "Configurações avançadas do servidor" #: src/big_remote_play/ui/host_view.py:474 msgid "Server tuning, codecs, network and library fix" -msgstr "" +msgstr "Ajustes do servidor, codecs, rede e correção da biblioteca" #: src/big_remote_play/ui/host_view.py:511 msgid "Share your PIN or address" -msgstr "" +msgstr "Compartilhe seu PIN ou endereço" #: src/big_remote_play/ui/host_view.py:512 msgid "Share your PIN code or the server address so friends can connect." msgstr "" +"Compartilhe seu código PIN ou o endereço do servidor para que amigos possam " +"conectar." #: src/big_remote_play/ui/host_view.py:513 msgid "Keep it secure" -msgstr "" +msgstr "Mantenha seguro" #: src/big_remote_play/ui/host_view.py:514 msgid "Keep your server and network secure. Use a VPN when sharing games." msgstr "" +"Mantenha seu servidor e sua rede seguros. Use uma VPN ao compartilhar jogos." # File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 342 #: src/big_remote_play/ui/host_view.py:530 @@ -1415,12 +1420,12 @@ msgstr "Copiar PIN" #: src/big_remote_play/ui/host_view.py:612 #: src/big_remote_play/ui/host_view.py:1205 msgid "Sunshine offline" -msgstr "" +msgstr "Sunshine offline" #: src/big_remote_play/ui/host_view.py:617 #: src/big_remote_play/ui/host_view.py:1197 msgid "Start the server to stream." -msgstr "" +msgstr "Inicie o servidor para transmitir." # File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, # line: 252 @@ -1431,17 +1436,19 @@ msgstr "Latência" #: src/big_remote_play/ui/host_view.py:634 msgid "FPS" -msgstr "" +msgstr "FPS" #: src/big_remote_play/ui/host_view.py:636 msgid "Bandwidth" -msgstr "" +msgstr "Largura de banda" #: src/big_remote_play/ui/host_view.py:650 msgid "" "Keep your server and network secure. Use a VPN when " "sharing games." msgstr "" +"Mantenha seu servidor e sua rede seguros. Use uma VPN " +"ao compartilhar jogos." # File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 569 #: src/big_remote_play/ui/host_view.py:824 @@ -1670,15 +1677,15 @@ msgstr "Ativo - Aguardando Conexões" #: src/big_remote_play/ui/host_view.py:1154 msgid "Ready to receive connections." -msgstr "" +msgstr "Pronto para receber conexões." #: src/big_remote_play/ui/host_view.py:1161 msgid "Sunshine active" -msgstr "" +msgstr "Sunshine ativo" #: src/big_remote_play/ui/host_view.py:1170 msgid "Stop server" -msgstr "" +msgstr "Parar servidor" # File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 890 #: src/big_remote_play/ui/host_view.py:1191 @@ -1755,12 +1762,12 @@ msgstr "Servidor parado" #: src/big_remote_play/ui/host_view.py:1731 #, python-brace-format msgid "Friends connect to this PC at {} — or use a PIN code." -msgstr "" +msgstr "Os amigos conectam a este PC em {} — ou usam um código PIN." #: src/big_remote_play/ui/host_view.py:1744 #, python-brace-format msgid "Server active for {:02d}:{:02d}:{:02d}" -msgstr "" +msgstr "Servidor ativo há {:02d}:{:02d}:{:02d}" # File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1297 #: src/big_remote_play/ui/host_view.py:1775 @@ -1814,7 +1821,7 @@ msgid "Server Failed to Start" msgstr "O servidor falhou ao iniciar." # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# +# # File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, # line: 85 # File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, @@ -1847,144 +1854,150 @@ msgid "" "Devices paired with Sunshine. Disable to block access without re-pairing; " "remove to revoke (a new PIN will be required)." msgstr "" +"Dispositivos pareados com o Sunshine. Desative para bloquear o acesso sem " +"refazer o pareamento; remova para revogar (será necessário um novo PIN)." #: src/big_remote_play/ui/host_view.py:1878 msgid "Remove All" -msgstr "" +msgstr "Remover tudo" #: src/big_remote_play/ui/host_view.py:1883 #: src/big_remote_play/ui/host_view.py:2055 #: src/big_remote_play/ui/host_view.py:2111 msgid "Loading…" -msgstr "" +msgstr "Carregando…" #: src/big_remote_play/ui/host_view.py:1923 msgid "No paired devices" -msgstr "" +msgstr "Nenhum dispositivo pareado" #: src/big_remote_play/ui/host_view.py:1930 msgid "Unknown device" -msgstr "" +msgstr "Dispositivo desconhecido" #: src/big_remote_play/ui/host_view.py:1937 msgid "Device enabled" -msgstr "" +msgstr "Dispositivo ativado" #: src/big_remote_play/ui/host_view.py:1945 #: src/big_remote_play/ui/host_view.py:1946 #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:688 msgid "Remove device" -msgstr "" +msgstr "Remover dispositivo" #: src/big_remote_play/ui/host_view.py:1961 msgid "Failed to update device" -msgstr "" +msgstr "Falha ao atualizar o dispositivo" #: src/big_remote_play/ui/host_view.py:1967 msgid "Remove Device" -msgstr "" +msgstr "Remover dispositivo" #: src/big_remote_play/ui/host_view.py:1968 #, python-brace-format msgid "Remove “{}”? It will need to pair again with a new PIN." -msgstr "" +msgstr "Remover “{}”? Será necessário parear novamente com um novo PIN." #: src/big_remote_play/ui/host_view.py:1972 msgid "Remove" -msgstr "" +msgstr "Remover" #: src/big_remote_play/ui/host_view.py:1996 #: src/big_remote_play/ui/host_view.py:2001 msgid "Devices updated" -msgstr "" +msgstr "Dispositivos atualizados" #: src/big_remote_play/ui/host_view.py:1996 #: src/big_remote_play/ui/host_view.py:2001 #: src/big_remote_play/ui/host_view.py:2218 msgid "Operation failed" -msgstr "" +msgstr "A operação falhou" #: src/big_remote_play/ui/host_view.py:2019 msgid "Refresh" -msgstr "" +msgstr "Atualizar" #: src/big_remote_play/ui/host_view.py:2020 msgid "Refresh logs" -msgstr "" +msgstr "Atualizar logs" #: src/big_remote_play/ui/host_view.py:2027 msgid "Copy" -msgstr "" +msgstr "Copiar" #: src/big_remote_play/ui/host_view.py:2028 msgid "Copy logs" -msgstr "" +msgstr "Copiar logs" #: src/big_remote_play/ui/host_view.py:2070 msgid "No logs available (Sunshine not running or no credentials)." msgstr "" +"Nenhum log disponível (Sunshine não está em execução ou sem credenciais)." #: src/big_remote_play/ui/host_view.py:2089 #: src/big_remote_play/ui/host_view.py:2227 msgid "Server Not Running" -msgstr "" +msgstr "Servidor não está em execução" #: src/big_remote_play/ui/host_view.py:2090 msgid "Start the server first to manage the game library." -msgstr "" +msgstr "Inicie o servidor primeiro para gerenciar a biblioteca de jogos." #: src/big_remote_play/ui/host_view.py:2095 msgid "" "Games offered to guests in Moonlight. Add your detected games or remove " "entries." msgstr "" +"Jogos oferecidos aos convidados no Moonlight. Adicione os jogos detectados " +"ou remova entradas." #: src/big_remote_play/ui/host_view.py:2104 msgid "Add Detected Games" -msgstr "" +msgstr "Adicionar jogos detectados" #: src/big_remote_play/ui/host_view.py:2151 msgid "No apps configured" -msgstr "" +msgstr "Nenhum aplicativo configurado" #: src/big_remote_play/ui/host_view.py:2159 msgid "Unnamed" -msgstr "" +msgstr "Sem nome" #: src/big_remote_play/ui/host_view.py:2170 #: src/big_remote_play/ui/host_view.py:2171 msgid "Remove app" -msgstr "" +msgstr "Remover aplicativo" #: src/big_remote_play/ui/host_view.py:2213 #, python-brace-format msgid "Added {} game(s)" -msgstr "" +msgstr "{} jogo(s) adicionado(s)" #: src/big_remote_play/ui/host_view.py:2218 msgid "Library updated" -msgstr "" +msgstr "Biblioteca atualizada" #: src/big_remote_play/ui/host_view.py:2228 msgid "Start the server first to browse the host filesystem." msgstr "" +"Inicie o servidor primeiro para navegar pelo sistema de arquivos do host." #: src/big_remote_play/ui/host_view.py:2231 msgid "Select Executable" -msgstr "" +msgstr "Selecionar executável" #: src/big_remote_play/ui/host_view.py:2272 msgid "Cannot browse (no access or credentials)" -msgstr "" +msgstr "Não é possível navegar (sem acesso ou credenciais)" #: src/big_remote_play/ui/host_view.py:2280 msgid "Up one level" -msgstr "" +msgstr "Subir um nível" #: src/big_remote_play/ui/host_view.py:2307 #, python-brace-format msgid "Selected: {}" -msgstr "" +msgstr "Selecionado: {}" #: src/big_remote_play/ui/host_view.py:2324 msgid "" @@ -1992,30 +2005,32 @@ msgid "" "forgot the current password, switch on “I forgot the current password” to " "reset it." msgstr "" +"Defina o usuário e a senha usados para gerenciar o servidor Sunshine. Se " +"você esqueceu a senha atual, ative “Esqueci a senha atual” para redefini-la." #: src/big_remote_play/ui/host_view.py:2334 msgid "I forgot the current password" -msgstr "" +msgstr "Esqueci a senha atual" #: src/big_remote_play/ui/host_view.py:2335 msgid "Reset directly; the server will restart" -msgstr "" +msgstr "Redefinir diretamente; o servidor será reiniciado" #: src/big_remote_play/ui/host_view.py:2336 msgid "Current Username" -msgstr "" +msgstr "Usuário atual" #: src/big_remote_play/ui/host_view.py:2338 msgid "Current Password" -msgstr "" +msgstr "Senha atual" #: src/big_remote_play/ui/host_view.py:2341 msgid "New Username" -msgstr "" +msgstr "Novo usuário" #: src/big_remote_play/ui/host_view.py:2344 msgid "Confirm New Password" -msgstr "" +msgstr "Confirmar nova senha" # File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, # line: 1092 @@ -2029,11 +2044,11 @@ msgstr "Salvar" #: src/big_remote_play/ui/host_view.py:2366 msgid "Username and password cannot be empty." -msgstr "" +msgstr "Usuário e senha não podem ficar vazios." #: src/big_remote_play/ui/host_view.py:2369 msgid "New passwords do not match." -msgstr "" +msgstr "As novas senhas não coincidem." # File: big-remote-play/usr/share/big-remote-play/ui/host_view.py, line: 1442 #: src/big_remote_play/ui/host_view.py:2628 @@ -2121,7 +2136,7 @@ msgstr "" "{}" # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# +# # File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, # line: 89 # File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, @@ -2152,80 +2167,80 @@ msgstr "Instalação concluída com sucesso!" #: src/big_remote_play/ui/installer_window.py:123 msgid "Finish" -msgstr "" +msgstr "Concluir" # File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, # line: 113 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, # line: 113 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, # line: 113 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, # line: 113 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, # line: 113 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, # line: 113 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, # line: 113 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, # line: 113 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, # line: 113 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, # line: 113 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, # line: 113 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, # line: 113 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, # line: 113 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, # line: 113 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, # line: 113 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, # line: 113 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, # line: 113 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, # line: 113 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, # line: 113 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, # line: 113 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, # line: 113 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, # line: 113 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, # line: 113 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, # line: 113 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/installer_window.py, # line: 124 #: src/big_remote_play/ui/installer_window.py:134 @@ -2323,7 +2338,7 @@ msgid "Share your games" msgstr "Compartilhe seus jogos" # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# +# # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 64 # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 338 # File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 55 @@ -2342,7 +2357,7 @@ msgid "Private Network" msgstr "Rede Privada" # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# +# # File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, # line: 367 # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 73 @@ -2357,7 +2372,7 @@ msgid "{} - Setup server" msgstr "{} - Configurar servidor" # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# +# # File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, # line: 855 # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 78 @@ -2429,11 +2444,11 @@ msgstr "Precisa instalar {} para {}" #: src/big_remote_play/ui/main_window.py:450 msgid "Play together, from anywhere" -msgstr "" +msgstr "Jogue junto, de qualquer lugar" #: src/big_remote_play/ui/main_window.py:477 msgid "Application menu" -msgstr "" +msgstr "Menu do aplicativo" # File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 273 #: src/big_remote_play/ui/main_window.py:487 @@ -2533,7 +2548,7 @@ msgstr "Instale o provedor de VPN escolhido no seu dispositivo." #: src/big_remote_play/ui/main_window.py:572 msgid "Authenticate" -msgstr "" +msgstr "Autenticar" #: src/big_remote_play/ui/main_window.py:573 msgid "Log in and authorize your devices on the VPN." @@ -2541,7 +2556,7 @@ msgstr "Entre na conta e autorize seus dispositivos na VPN." #: src/big_remote_play/ui/main_window.py:574 msgid "Play" -msgstr "" +msgstr "Jogar" #: src/big_remote_play/ui/main_window.py:575 msgid "Connect to the server and play with your friends!" @@ -2907,7 +2922,7 @@ msgid "Hardware" msgstr "Hardware" # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# +# # File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, # line: 87 # File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, @@ -3001,21 +3016,23 @@ msgstr "Monitoramento em tempo real" #: src/big_remote_play/ui/performance_monitor.py:499 msgid "Disconnect Guest" -msgstr "" +msgstr "Desconectar convidado" #: src/big_remote_play/ui/performance_monitor.py:500 msgid "" "End the running game gently, or forcefully evict the network address? Ending" " the game keeps the device paired." msgstr "" +"Encerrar o jogo em execução de forma suave ou expulsar à força o endereço de" +" rede? Encerrar o jogo mantém o dispositivo pareado." #: src/big_remote_play/ui/performance_monitor.py:508 msgid "End Game" -msgstr "" +msgstr "Encerrar jogo" #: src/big_remote_play/ui/performance_monitor.py:511 msgid "Evict IP" -msgstr "" +msgstr "Expulsar IP" # File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, # line: 802 @@ -3044,7 +3061,7 @@ msgstr "Desconectar este convidado específico (Admin)" #: src/big_remote_play/ui/performance_monitor.py:862 msgid "Disconnect guest" -msgstr "" +msgstr "Desconectar convidado" # File: big-remote-play/usr/share/big-remote-play/ui/performance_monitor.py, # line: 722 @@ -3142,56 +3159,62 @@ msgstr "Old log files have been removed." #: src/big_remote_play/ui/preferences.py:195 msgid "Path copied!" -msgstr "" +msgstr "Caminho copiado!" #: src/big_remote_play/ui/preferences.py:200 msgid "" "All your changes in Big Remote Play will be restored! This includes Sunshine" " and Moonlight settings." msgstr "" +"Todas as suas alterações no Big Remote Play serão restauradas! Isso inclui " +"as configurações do Sunshine e do Moonlight." #: src/big_remote_play/ui/preferences.py:202 msgid "Restore Defaults?" -msgstr "" +msgstr "Restaurar padrões?" #: src/big_remote_play/ui/preferences.py:234 msgid "Settings restored! Restart the application to apply all changes." msgstr "" +"Configurações restauradas! Reinicie o aplicativo para aplicar todas as " +"alterações." #: src/big_remote_play/ui/preferences.py:238 #, python-brace-format msgid "Error restoring: {}" -msgstr "" +msgstr "Erro ao restaurar: {}" #: src/big_remote_play/ui/preferences.py:245 msgid "Clear EVERYTHING?" -msgstr "" +msgstr "Limpar TUDO?" #: src/big_remote_play/ui/preferences.py:245 msgid "" "WARNING: This will delete ALL data, logs, settings and saved servers. The " "application will close." msgstr "" +"AVISO: isso excluirá TODOS os dados, logs, configurações e servidores " +"salvos. O aplicativo será fechado." #: src/big_remote_play/ui/preferences.py:247 msgid "Delete All" -msgstr "" +msgstr "Excluir tudo" #: src/big_remote_play/ui/preferences.py:254 msgid "Are You Absolutely Sure?" -msgstr "" +msgstr "Você tem certeza absoluta?" #: src/big_remote_play/ui/preferences.py:254 msgid "This action is IRREVERSIBLE. You will lose all configured data." -msgstr "" +msgstr "Esta ação é IRREVERSÍVEL. Você perderá todos os dados configurados." #: src/big_remote_play/ui/preferences.py:256 msgid "Yes, Delete All" -msgstr "" +msgstr "Sim, excluir tudo" #: src/big_remote_play/ui/preferences.py:299 msgid "Error Clearing" -msgstr "" +msgstr "Erro ao limpar" # File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, # line: 24 @@ -3462,7 +3485,7 @@ msgstr "Token de API é necessário" #: src/big_remote_play/ui/private_network_view.py:613 #: src/big_remote_play/ui/private_network_view.py:1533 msgid "System keyring is unavailable. Token was not saved." -msgstr "" +msgstr "O chaveiro do sistema está indisponível. O token não foi salvo." # File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, # line: 520 @@ -3773,6 +3796,8 @@ msgid "" "Open ports 80, 443, and 41641. The installation script will attempt to " "configure these automatically via UPnP." msgstr "" +"Abra as portas 80, 443 e 41641. O script de instalação tentará configurá-las" +" automaticamente via UPnP." # File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, # line: 779 @@ -3792,11 +3817,11 @@ msgstr "" #: src/big_remote_play/ui/private_network_view.py:863 msgid "TLS certificate challenge and HTTP redirect" -msgstr "" +msgstr "Desafio de certificado TLS e redirecionamento HTTP" #: src/big_remote_play/ui/private_network_view.py:864 msgid "Secure Headscale API and web interface" -msgstr "" +msgstr "API e interface web seguras do Headscale" # File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, # line: 790 @@ -3806,7 +3831,7 @@ msgstr "Dados de VPN ponto a ponto" #: src/big_remote_play/ui/private_network_view.py:870 msgid "Forward to your local IP" -msgstr "" +msgstr "Encaminhar para o seu IP local" # File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, # line: 800 @@ -3834,46 +3859,47 @@ msgstr "Instruções do Tailscale" #: src/big_remote_play/ui/private_network_view.py:918 #: src/big_remote_play/ui/private_network_view.py:2131 msgid "1. Create Account" -msgstr "" +msgstr "1. Criar conta" #: src/big_remote_play/ui/private_network_view.py:896 msgid "Tailscale Registration" -msgstr "" +msgstr "Cadastro no Tailscale" #: src/big_remote_play/ui/private_network_view.py:897 msgid "Create your free account to start managing your VPN mesh." -msgstr "" +msgstr "Crie sua conta gratuita para começar a gerenciar sua malha VPN." #: src/big_remote_play/ui/private_network_view.py:902 msgid "2. Login on Host" -msgstr "" +msgstr "2. Login no host" #: src/big_remote_play/ui/private_network_view.py:902 msgid "Link this device" -msgstr "" +msgstr "Vincular este dispositivo" #: src/big_remote_play/ui/private_network_view.py:902 msgid "" "Use the 'Login / Connect' button on the previous tab to authorize this PC." msgstr "" +"Use o botão 'Login / Conectar' na aba anterior para autorizar este PC." #: src/big_remote_play/ui/private_network_view.py:904 msgid "3. Admin Panel" -msgstr "" +msgstr "3. Painel de administração" #: src/big_remote_play/ui/private_network_view.py:905 msgid "Manage Machines" -msgstr "" +msgstr "Gerenciar máquinas" #: src/big_remote_play/ui/private_network_view.py:906 msgid "" "View and authorize the machines connected to your network in the official " "panel." -msgstr "" +msgstr "Veja e autorize as máquinas conectadas à sua rede no painel oficial." #: src/big_remote_play/ui/private_network_view.py:908 msgid "Open Panel" -msgstr "" +msgstr "Abrir painel" # File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, # line: 825 @@ -3886,61 +3912,66 @@ msgstr "Instruções do ZeroTier" #: src/big_remote_play/ui/private_network_view.py:918 msgid "Access ZeroTier Central" -msgstr "" +msgstr "Acessar o ZeroTier Central" #: src/big_remote_play/ui/private_network_view.py:918 msgid "Sign up to create and manage your virtual networks." -msgstr "" +msgstr "Cadastre-se para criar e gerenciar suas redes virtuais." #: src/big_remote_play/ui/private_network_view.py:918 msgid "Open Portal" -msgstr "" +msgstr "Abrir portal" #: src/big_remote_play/ui/private_network_view.py:920 msgid "2. Authentication" -msgstr "" +msgstr "2. Autenticação" #: src/big_remote_play/ui/private_network_view.py:921 msgid "Generate API Token" -msgstr "" +msgstr "Gerar token de API" #: src/big_remote_play/ui/private_network_view.py:922 msgid "Go to 'Account Settings' and create a new 'API Access Token'." msgstr "" +"Vá em 'Configurações da conta' e crie um novo 'Token de acesso à API'." #: src/big_remote_play/ui/private_network_view.py:924 msgid "Generate Token" -msgstr "" +msgstr "Gerar token" #: src/big_remote_play/ui/private_network_view.py:927 msgid "3. Configuration" -msgstr "" +msgstr "3. Configuração" #: src/big_remote_play/ui/private_network_view.py:927 msgid "Link Network" -msgstr "" +msgstr "Vincular rede" #: src/big_remote_play/ui/private_network_view.py:927 msgid "" "Paste the Token in the matching field and click 'Save Token & Create " "Network'." msgstr "" +"Cole o token no campo correspondente e clique em 'Save Token & Create " +"Network'." #: src/big_remote_play/ui/private_network_view.py:929 msgid "4. Administration" -msgstr "" +msgstr "4. Administração" #: src/big_remote_play/ui/private_network_view.py:930 msgid "Authorize Members" -msgstr "" +msgstr "Autorizar membros" #: src/big_remote_play/ui/private_network_view.py:931 msgid "Click your network's Network ID to manage and authorize new devices." msgstr "" +"Clique no Network ID da sua rede para gerenciar e autorizar novos " +"dispositivos." #: src/big_remote_play/ui/private_network_view.py:933 msgid "My Networks" -msgstr "" +msgstr "Minhas redes" # File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, # line: 846 @@ -4153,17 +4184,19 @@ msgstr "Uma chave gerada no servidor para conectar este dispositivo." #: src/big_remote_play/ui/private_network_view.py:1293 msgid "Working internet" -msgstr "" +msgstr "Internet funcionando" #: src/big_remote_play/ui/private_network_view.py:1293 msgid "An internet connection is required to establish the link." -msgstr "" +msgstr "É necessária uma conexão com a internet para estabelecer o vínculo." #: src/big_remote_play/ui/private_network_view.py:1306 msgid "" "Your credentials stay only on this device and are used " "exclusively to authenticate on the private network." msgstr "" +"Suas credenciais permanecem apenas neste dispositivo e " +"são usadas exclusivamente para autenticar na rede privada." # File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, # line: 937 @@ -4267,14 +4300,14 @@ msgstr "ID da Rede (16 caracteres)" #: src/big_remote_play/ui/private_network_view.py:1455 msgid "e.g. a1b2c3d4e5f6a7b8" -msgstr "" +msgstr "ex.: a1b2c3d4e5f6a7b8" # File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, # line: 1314 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, # line: 1314 -# +# # File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, # line: 1316 #: src/big_remote_play/ui/private_network_view.py:1457 @@ -4301,7 +4334,7 @@ msgstr "Salvar e Atualizar" #: src/big_remote_play/ui/private_network_view.py:1530 msgid "Token saved in the system keyring" -msgstr "" +msgstr "Token salvo no chaveiro do sistema" # File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, # line: 1848 @@ -4447,7 +4480,7 @@ msgstr "VPN" #: src/big_remote_play/ui/private_network_view.py:1875 msgid "Stored in system keyring" -msgstr "" +msgstr "Armazenado no chaveiro do sistema" # File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, # line: 1772 @@ -4554,77 +4587,79 @@ msgstr "Clique em 'Estabelecer Conexão' e aguarde a mensagem de sucesso." #: src/big_remote_play/ui/private_network_view.py:2132 msgid "Access Tailscale" -msgstr "" +msgstr "Acessar o Tailscale" #: src/big_remote_play/ui/private_network_view.py:2133 msgid "All participants need an account (Google, GitHub, etc)." -msgstr "" +msgstr "Todos os participantes precisam de uma conta (Google, GitHub, etc)." #: src/big_remote_play/ui/private_network_view.py:2135 msgid "Create Account" -msgstr "" +msgstr "Criar conta" #: src/big_remote_play/ui/private_network_view.py:2138 msgid "2. Invitation" -msgstr "" +msgstr "2. Convite" #: src/big_remote_play/ui/private_network_view.py:2138 msgid "Request Access" -msgstr "" +msgstr "Solicitar acesso" #: src/big_remote_play/ui/private_network_view.py:2138 msgid "" "The administrator must share the node or invite your email to the network." msgstr "" +"O administrador deve compartilhar o nó ou convidar seu e-mail para a rede." #: src/big_remote_play/ui/private_network_view.py:2139 msgid "3. Connect" -msgstr "" +msgstr "3. Conectar" #: src/big_remote_play/ui/private_network_view.py:2139 msgid "Once the invitation is accepted, use the previous tab to connect." -msgstr "" +msgstr "Depois que o convite for aceito, use a aba anterior para conectar." #: src/big_remote_play/ui/private_network_view.py:2147 msgid "1. Identification" -msgstr "" +msgstr "1. Identificação" #: src/big_remote_play/ui/private_network_view.py:2147 msgid "Get Network ID" -msgstr "" +msgstr "Obter o Network ID" #: src/big_remote_play/ui/private_network_view.py:2147 msgid "Request the 16-character ID from the network owner." -msgstr "" +msgstr "Solicite o ID de 16 caracteres ao dono da rede." #: src/big_remote_play/ui/private_network_view.py:2148 msgid "2. Join" -msgstr "" +msgstr "2. Ingressar" #: src/big_remote_play/ui/private_network_view.py:2148 msgid "Enter ID" -msgstr "" +msgstr "Inserir ID" #: src/big_remote_play/ui/private_network_view.py:2148 msgid "Enter the ID on the 'Connect' tab and click 'Establish Connection'." -msgstr "" +msgstr "Insira o ID na aba 'Conectar' e clique em 'Estabelecer conexão'." #: src/big_remote_play/ui/private_network_view.py:2150 msgid "3. Authorization" -msgstr "" +msgstr "3. Autorização" #: src/big_remote_play/ui/private_network_view.py:2151 msgid "Wait for Approval" -msgstr "" +msgstr "Aguardar aprovação" #: src/big_remote_play/ui/private_network_view.py:2152 msgid "" "The administrator must check the 'Auth' option for your PC in their panel." msgstr "" +"O administrador deve marcar a opção 'Auth' para o seu PC no painel dele." #: src/big_remote_play/ui/private_network_view.py:2154 msgid "ZT Panel" -msgstr "" +msgstr "Painel do ZT" # File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, # line: 74 @@ -4862,59 +4897,63 @@ msgstr "O arquivo de configuração principal para Sunshine." #: src/big_remote_play/ui/sunshine_preferences.py:304 msgid "Sunshine Runtime" -msgstr "" +msgstr "Runtime do Sunshine" #: src/big_remote_play/ui/sunshine_preferences.py:306 msgid "Sunshine is available." -msgstr "" +msgstr "O Sunshine está disponível." #: src/big_remote_play/ui/sunshine_preferences.py:309 msgid "Sunshine is not available." -msgstr "" +msgstr "O Sunshine não está disponível." #: src/big_remote_play/ui/sunshine_preferences.py:317 msgid "Apply to Running Server" -msgstr "" +msgstr "Aplicar ao servidor em execução" #: src/big_remote_play/ui/sunshine_preferences.py:318 msgid "Push these settings to Sunshine and restart it (no manual stop)." msgstr "" +"Envia estas configurações ao Sunshine e o reinicia (sem parada manual)." #: src/big_remote_play/ui/sunshine_preferences.py:327 msgid "Reload from Running Server" -msgstr "" +msgstr "Recarregar do servidor em execução" #: src/big_remote_play/ui/sunshine_preferences.py:328 msgid "Read the server's current configuration into these settings." -msgstr "" +msgstr "Lê a configuração atual do servidor para estas opções." #: src/big_remote_play/ui/sunshine_preferences.py:329 msgid "Reload" -msgstr "" +msgstr "Recarregar" #: src/big_remote_play/ui/sunshine_preferences.py:353 msgid "Sunshine is not running. Settings are saved to file." msgstr "" +"O Sunshine não está em execução. As configurações são salvas em arquivo." #: src/big_remote_play/ui/sunshine_preferences.py:365 msgid "Settings applied; server restarting." -msgstr "" +msgstr "Configurações aplicadas; reiniciando o servidor." #: src/big_remote_play/ui/sunshine_preferences.py:365 msgid "Failed to apply settings (check credentials)." -msgstr "" +msgstr "Falha ao aplicar as configurações (verifique as credenciais)." #: src/big_remote_play/ui/sunshine_preferences.py:371 msgid "Sunshine is not running." -msgstr "" +msgstr "O Sunshine não está em execução." #: src/big_remote_play/ui/sunshine_preferences.py:385 msgid "Could not read server configuration." -msgstr "" +msgstr "Não foi possível ler a configuração do servidor." #: src/big_remote_play/ui/sunshine_preferences.py:392 msgid "Configuration reloaded. Reopen preferences to see updated values." msgstr "" +"Configuração recarregada. Reabra as preferências para ver os valores " +"atualizados." # File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, # line: 258 @@ -5043,6 +5082,10 @@ msgid "" "UDP: 47998 - 48012\n" "The Web UI port stays local by default." msgstr "" +"Define a família de portas usadas pelo Sunshine.\n" +"TCP: 47984, 47989, 48010\n" +"UDP: 47998 - 48012\n" +"A porta da interface web permanece local por padrão." # File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, # line: 282 @@ -5078,7 +5121,7 @@ msgstr "" #: src/big_remote_play/ui/sunshine_preferences.py:444 msgid "Trusted Web UI Origins" -msgstr "" +msgstr "Origens confiáveis da interface web" #: src/big_remote_play/ui/sunshine_preferences.py:448 msgid "" @@ -5086,6 +5129,9 @@ msgid "" "changing API endpoints. Leave empty to use Sunshine's built-in localhost " "defaults." msgstr "" +"Origens HTTPS confiáveis, separadas por vírgula, autorizadas a chamar os " +"endpoints da API do Sunshine que alteram estado. Deixe vazio para usar os " +"padrões locais embutidos do Sunshine." # File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, # line: 286 @@ -5173,16 +5219,20 @@ msgid "" "How long to wait in milliseconds for data from Moonlight before shutting " "down the stream" msgstr "" +"Quanto tempo aguardar, em milissegundos, por dados do Moonlight antes de " +"encerrar a transmissão" #: src/big_remote_play/ui/sunshine_preferences.py:469 msgid "Packet Size" -msgstr "" +msgstr "Tamanho do pacote" #: src/big_remote_play/ui/sunshine_preferences.py:473 msgid "" "Limit UDP packet size to avoid fragmentation on low-MTU VPN links. Use 0 for" " Sunshine's default." msgstr "" +"Limita o tamanho do pacote UDP para evitar fragmentação em links VPN de " +"baixo MTU. Use 0 para o padrão do Sunshine." # File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, # line: 297 @@ -5988,7 +6038,7 @@ msgstr "" "zerolatência." # #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# -# +# # File: big-remote-play/usr/share/big-remote-play/utils/system_check.py, line: # 124 # File: big-remote-play/usr/share/big-remote-play/utils/system_check.py, line: @@ -6026,11 +6076,11 @@ msgstr "Host ({})" #: src/big_remote_play/utils/system_check.py:143 msgid "Sunshine runtime is available" -msgstr "" +msgstr "O runtime do Sunshine está disponível" #: src/big_remote_play/utils/system_check.py:144 msgid "Sunshine failed to report its version" -msgstr "" +msgstr "O Sunshine não conseguiu informar sua versão" #: src/big_remote_play/utils/widgets.py:37 msgid "How it works" @@ -6038,698 +6088,701 @@ msgstr "Como funciona" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:56 msgid "HEADSCALE & CLOUDFLARE - PRIVATE NETWORK" -msgstr "" +msgstr "HEADSCALE E CLOUDFLARE - REDE PRIVADA" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:61 #: usr/share/big-remote-play/scripts/create-network_headscale.sh:426 #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:56 #: usr/share/big-remote-play/scripts/create-network_zerotier.sh:46 msgid "Checking dependencies..." -msgstr "" +msgstr "Verificando dependências..." #: usr/share/big-remote-play/scripts/create-network_headscale.sh:64 msgid "Installing" -msgstr "" +msgstr "Instalando" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:65 msgid "Failed to install" -msgstr "" +msgstr "Falha ao instalar" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:65 msgid "Install it manually." -msgstr "" +msgstr "Instale manualmente." #: usr/share/big-remote-play/scripts/create-network_headscale.sh:72 msgid "Checking open ports..." -msgstr "" +msgstr "Verificando portas abertas..." #: usr/share/big-remote-play/scripts/create-network_headscale.sh:76 msgid "Docker is not running. Starting..." -msgstr "" +msgstr "O Docker não está em execução. Iniciando..." #: usr/share/big-remote-play/scripts/create-network_headscale.sh:82 msgid "Local ports in use:" -msgstr "" +msgstr "Portas locais em uso:" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:86 #: usr/share/big-remote-play/scripts/create-network_headscale.sh:537 msgid "Configuring firewall..." -msgstr "" +msgstr "Configurando o firewall..." #: usr/share/big-remote-play/scripts/create-network_headscale.sh:94 msgid "UFW firewall configured" -msgstr "" +msgstr "Firewall UFW configurado" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:103 msgid "Firewalld configured" -msgstr "" +msgstr "Firewalld configurado" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:110 msgid "HOST (SERVER) SETUP" -msgstr "" +msgstr "CONFIGURAÇÃO DO HOST (SERVIDOR)" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:113 msgid "Domain (e.g. vpn.ruscher.org): " -msgstr "" +msgstr "Domínio (ex.: vpn.ruscher.org): " #: usr/share/big-remote-play/scripts/create-network_headscale.sh:129 msgid "Generating reverse proxy config (Caddy)..." -msgstr "" +msgstr "Gerando a configuração de proxy reverso (Caddy)..." #: usr/share/big-remote-play/scripts/create-network_headscale.sh:269 msgid "Configuring Headscale..." -msgstr "" +msgstr "Configurando o Headscale..." #: usr/share/big-remote-play/scripts/create-network_headscale.sh:283 msgid "Validating Headscale configuration..." -msgstr "" +msgstr "Validando a configuração do Headscale..." #: usr/share/big-remote-play/scripts/create-network_headscale.sh:285 msgid "" "Invalid Headscale configuration; aborting before changing DNS or starting " "services." msgstr "" +"Configuração do Headscale inválida; abortando antes de alterar o DNS ou " +"iniciar os serviços." #: usr/share/big-remote-play/scripts/create-network_headscale.sh:315 msgid "New DNS record created" -msgstr "" +msgstr "Novo registro DNS criado" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:319 msgid "Starting containers..." -msgstr "" +msgstr "Iniciando os contêineres..." #: usr/share/big-remote-play/scripts/create-network_headscale.sh:324 msgid "Waiting for services to start..." -msgstr "" +msgstr "Aguardando os serviços iniciarem..." #: usr/share/big-remote-play/scripts/create-network_headscale.sh:329 msgid "Configuring router ports via UPnP..." -msgstr "" +msgstr "Configurando as portas do roteador via UPnP..." #: usr/share/big-remote-play/scripts/create-network_headscale.sh:341 msgid "Creating user and keys..." -msgstr "" +msgstr "Criando usuário e chaves..." #: usr/share/big-remote-play/scripts/create-network_headscale.sh:350 msgid "Creating new user..." -msgstr "" +msgstr "Criando novo usuário..." #: usr/share/big-remote-play/scripts/create-network_headscale.sh:359 msgid "Testing services..." -msgstr "" +msgstr "Testando os serviços..." #: usr/share/big-remote-play/scripts/create-network_headscale.sh:363 msgid "Headscale is working" -msgstr "" +msgstr "O Headscale está funcionando" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:365 msgid "Headscale is not responding" -msgstr "" +msgstr "O Headscale não está respondendo" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:371 msgid "Caddy is working" -msgstr "" +msgstr "O Caddy está funcionando" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:373 msgid "Caddy is not responding" -msgstr "" +msgstr "O Caddy não está respondendo" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:388 msgid "SERVER CONFIGURED SUCCESSFULLY!" -msgstr "" +msgstr "SERVIDOR CONFIGURADO COM SUCESSO!" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:390 msgid "ACCESS INFORMATION" -msgstr "" +msgstr "INFORMAÇÕES DE ACESSO" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:391 #: usr/share/big-remote-play/scripts/create-network_zerotier.sh:154 msgid "Web Interface:" -msgstr "" +msgstr "Interface web:" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:392 #: usr/share/big-remote-play/scripts/create-network_zerotier.sh:155 msgid "API URL:" -msgstr "" +msgstr "URL da API:" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:393 #: usr/share/big-remote-play/scripts/create-network_zerotier.sh:156 msgid "Your Public IP:" -msgstr "" +msgstr "Seu IP público:" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:394 #: usr/share/big-remote-play/scripts/create-network_zerotier.sh:157 msgid "Server Local IP:" -msgstr "" +msgstr "IP local do servidor:" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:396 #: usr/share/big-remote-play/scripts/create-network_zerotier.sh:159 msgid "CREDENTIALS" -msgstr "" +msgstr "CREDENCIAIS" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:397 #: usr/share/big-remote-play/scripts/create-network_zerotier.sh:160 msgid "Key for Friends:" -msgstr "" +msgstr "Chave para amigos:" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:399 msgid "USEFUL COMMANDS" -msgstr "" +msgstr "COMANDOS ÚTEIS" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:400 msgid "View logs: " -msgstr "" +msgstr "Ver logs: " #: usr/share/big-remote-play/scripts/create-network_headscale.sh:401 msgid "Restart: " -msgstr "" +msgstr "Reiniciar: " #: usr/share/big-remote-play/scripts/create-network_headscale.sh:404 msgid "SHARE ONLY THE KEY FOR FRIENDS" -msgstr "" +msgstr "COMPARTILHE APENAS A CHAVE COM OS AMIGOS" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:409 msgid "Show real-time logs? (y/N): " -msgstr "" +msgstr "Mostrar logs em tempo real? (y/N): " #: usr/share/big-remote-play/scripts/create-network_headscale.sh:420 msgid "CLIENT (GUEST) SETUP" -msgstr "" +msgstr "CONFIGURAÇÃO DO CLIENTE (CONVIDADO)" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:423 msgid "Server domain (e.g. vpn.ruscher.org): " -msgstr "" +msgstr "Domínio do servidor (ex.: vpn.ruscher.org): " #: usr/share/big-remote-play/scripts/create-network_headscale.sh:424 msgid "Access Key (Auth Key): " -msgstr "" +msgstr "Chave de acesso (Auth Key): " #: usr/share/big-remote-play/scripts/create-network_headscale.sh:429 msgid "Testing connection to the server..." -msgstr "" +msgstr "Testando a conexão com o servidor..." #: usr/share/big-remote-play/scripts/create-network_headscale.sh:431 #: usr/share/big-remote-play/scripts/create-network_headscale.sh:433 msgid "Server reachable" -msgstr "" +msgstr "Servidor acessível" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:435 msgid "Could not connect to the server" -msgstr "" +msgstr "Não foi possível conectar ao servidor" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:436 msgid "Check:" -msgstr "" +msgstr "Verifique:" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:437 msgid "1. Is the domain correct?" -msgstr "" +msgstr "1. O domínio está correto?" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:438 msgid "2. Is the server online?" -msgstr "" +msgstr "2. O servidor está online?" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:439 msgid "3. Is the Auth Key valid?" -msgstr "" +msgstr "3. A Auth Key é válida?" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:448 msgid "Installing Tailscale..." -msgstr "" +msgstr "Instalando o Tailscale..." #: usr/share/big-remote-play/scripts/create-network_headscale.sh:452 msgid "Tailscale already installed" -msgstr "" +msgstr "Tailscale já instalado" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:460 msgid "Connecting to the private network..." -msgstr "" +msgstr "Conectando à rede privada..." #: usr/share/big-remote-play/scripts/create-network_headscale.sh:474 msgid "Connection established!" -msgstr "" +msgstr "Conexão estabelecida!" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:477 msgid "Trying alternative method..." -msgstr "" +msgstr "Tentando método alternativo..." #: usr/share/big-remote-play/scripts/create-network_headscale.sh:488 msgid "Waiting for connection..." -msgstr "" +msgstr "Aguardando a conexão..." #: usr/share/big-remote-play/scripts/create-network_headscale.sh:492 #: usr/share/big-remote-play/scripts/create-network_zerotier.sh:187 msgid "CONNECTION STATUS" -msgstr "" +msgstr "STATUS DA CONEXÃO" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:501 msgid "Your network IP: " -msgstr "" +msgstr "Seu IP na rede: " #: usr/share/big-remote-play/scripts/create-network_headscale.sh:508 msgid "CONNECTED DEVICES" -msgstr "" +msgstr "DISPOSITIVOS CONECTADOS" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:513 msgid "No other device connected yet. You are the first!" -msgstr "" +msgstr "Nenhum outro dispositivo conectado ainda. Você é o primeiro!" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:514 msgid "Invite friends using the same Access Key." -msgstr "" +msgstr "Convide amigos usando a mesma chave de acesso." #: usr/share/big-remote-play/scripts/create-network_headscale.sh:520 msgid "TROUBLESHOOTING" -msgstr "" +msgstr "SOLUÇÃO DE PROBLEMAS" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:521 msgid "1. Check that the server is online" -msgstr "" +msgstr "1. Verifique se o servidor está online" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:522 msgid "2. Check the Auth Key" -msgstr "" +msgstr "2. Verifique a Auth Key" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:523 msgid "3. Try restarting:" -msgstr "" +msgstr "3. Tente reiniciar:" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:524 msgid "4. Check logs:" -msgstr "" +msgstr "4. Verifique os logs:" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:528 msgid "View Tailscale logs? (y/N): " -msgstr "" +msgstr "Ver os logs do Tailscale? (y/N): " #: usr/share/big-remote-play/scripts/create-network_headscale.sh:543 msgid "Client setup complete!" -msgstr "" +msgstr "Configuração do cliente concluída!" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:551 msgid "ADVANCED TROUBLESHOOTING" -msgstr "" +msgstr "SOLUÇÃO AVANÇADA DE PROBLEMAS" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:552 msgid "1) Check server status" -msgstr "" +msgstr "1) Verificar o status do servidor" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:553 msgid "2) Check server logs" -msgstr "" +msgstr "2) Verificar os logs do servidor" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:554 msgid "3) Test external connection" -msgstr "" +msgstr "3) Testar a conexão externa" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:555 msgid "4) Recreate access keys" -msgstr "" +msgstr "4) Recriar as chaves de acesso" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:556 msgid "5) Back to main menu" -msgstr "" +msgstr "5) Voltar ao menu principal" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:557 msgid "Choose an option: " -msgstr "" +msgstr "Escolha uma opção: " #: usr/share/big-remote-play/scripts/create-network_headscale.sh:566 msgid "Server directory not found" -msgstr "" +msgstr "Diretório do servidor não encontrado" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:572 msgid "HEADSCALE LOGS" -msgstr "" +msgstr "LOGS DO HEADSCALE" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:574 msgid "CADDY LOGS" -msgstr "" +msgstr "LOGS DO CADDY" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:579 msgid "Domain to test: " -msgstr "" +msgstr "Domínio para testar: " #: usr/share/big-remote-play/scripts/create-network_headscale.sh:580 msgid "Testing" -msgstr "" +msgstr "Testando" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:588 msgid "Recreating keys..." -msgstr "" +msgstr "Recriando as chaves..." #: usr/share/big-remote-play/scripts/create-network_headscale.sh:590 msgid "Create a new key? (y/N): " -msgstr "" +msgstr "Criar uma nova chave? (y/N): " #: usr/share/big-remote-play/scripts/create-network_headscale.sh:594 msgid "New key: " -msgstr "" +msgstr "Nova chave: " #: usr/share/big-remote-play/scripts/create-network_headscale.sh:600 msgid "Press Enter to continue..." -msgstr "" +msgstr "Pressione Enter para continuar..." #: usr/share/big-remote-play/scripts/create-network_headscale.sh:608 #: usr/share/big-remote-play/scripts/create-network_zerotier.sh:205 msgid "Select an option:" -msgstr "" +msgstr "Selecione uma opção:" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:609 #: usr/share/big-remote-play/scripts/create-network_zerotier.sh:206 msgid "1) Be the HOST (create and manage the network)" -msgstr "" +msgstr "1) Ser o HOST (criar e gerenciar a rede)" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:610 #: usr/share/big-remote-play/scripts/create-network_zerotier.sh:207 msgid "2) Be the GUEST (join a friend network)" -msgstr "" +msgstr "2) Ser o CONVIDADO (entrar na rede de um amigo)" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:611 msgid "3) Troubleshooting" -msgstr "" +msgstr "3) Solução de problemas" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:612 #: usr/share/big-remote-play/scripts/create-network_zerotier.sh:209 msgid "4) Exit" -msgstr "" +msgstr "4) Sair" #: usr/share/big-remote-play/scripts/create-network_headscale.sh:613 #: usr/share/big-remote-play/scripts/create-network_zerotier.sh:210 msgid "Option: " -msgstr "" +msgstr "Opção: " #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:60 msgid "Tailscale not found. Installing..." -msgstr "" +msgstr "Tailscale não encontrado. Instalando..." #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:64 msgid "Installing yay (AUR helper)..." -msgstr "" +msgstr "Instalando o yay (auxiliar do AUR)..." #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:82 msgid "jq not found. Installing..." -msgstr "" +msgstr "jq não encontrado. Instalando..." #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:88 msgid "curl not found. Installing..." -msgstr "" +msgstr "curl não encontrado. Instalando..." #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:99 msgid "TAILSCALE LOGIN" -msgstr "" +msgstr "LOGIN NO TAILSCALE" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:102 msgid "Checking tailscaled service..." -msgstr "" +msgstr "Verificando o serviço tailscaled..." #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:105 msgid "tailscaled service is not running. Starting..." -msgstr "" +msgstr "O serviço tailscaled não está em execução. Iniciando..." #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:110 msgid "Failed to start tailscaled. Check manually:" -msgstr "" +msgstr "Falha ao iniciar o tailscaled. Verifique manualmente:" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:119 msgid "tailscaled service is running" -msgstr "" +msgstr "O serviço tailscaled está em execução" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:122 msgid "Choose the login method:" -msgstr "" +msgstr "Escolha o método de login:" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:123 msgid "1) Browser login (recommended)" -msgstr "" +msgstr "1) Login pelo navegador (recomendado)" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:124 msgid "2) Login with auth key" -msgstr "" +msgstr "2) Login com auth key" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:125 #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:461 msgid "3) Back" -msgstr "" +msgstr "3) Voltar" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:132 msgid "Starting browser login..." -msgstr "" +msgstr "Iniciando o login pelo navegador..." #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:133 msgid "A URL will open in your browser. Log in with your account." -msgstr "" +msgstr "Uma URL será aberta no seu navegador. Faça login com sua conta." #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:136 msgid "Login URL generated. Follow the instructions in the browser." -msgstr "" +msgstr "URL de login gerada. Siga as instruções no navegador." #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:141 msgid "Waiting for authentication..." -msgstr "" +msgstr "Aguardando a autenticação..." #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:145 msgid "Login confirmed!" -msgstr "" +msgstr "Login confirmado!" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:153 msgid "Enter your auth key:" -msgstr "" +msgstr "Digite sua auth key:" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:154 msgid "Expected format:" -msgstr "" +msgstr "Formato esperado:" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:158 msgid "Authenticating with key..." -msgstr "" +msgstr "Autenticando com a chave..." #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:162 msgid "Service not responding. Reconnecting..." -msgstr "" +msgstr "O serviço não está respondendo. Reconectando..." #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:171 msgid "First attempt failed. Trying alternative method..." -msgstr "" +msgstr "A primeira tentativa falhou. Tentando método alternativo..." #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:178 msgid "Restarting service and trying again..." -msgstr "" +msgstr "Reiniciando o serviço e tentando novamente..." #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:188 msgid "Login successful!" -msgstr "" +msgstr "Login bem-sucedido!" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:190 msgid "Login error. Diagnostics:" -msgstr "" +msgstr "Erro de login. Diagnóstico:" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:191 msgid "1. Check that the key is valid (not expired)" -msgstr "" +msgstr "1. Verifique se a chave é válida (não expirada)" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:192 msgid "2. Check connectivity:" -msgstr "" +msgstr "2. Verifique a conectividade:" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:193 msgid "3. Check the service:" -msgstr "" +msgstr "3. Verifique o serviço:" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:195 msgid "Alternative manual command:" -msgstr "" +msgstr "Comando manual alternativo:" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:199 msgid "Key cannot be empty!" -msgstr "" +msgstr "A chave não pode ficar vazia!" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:207 #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:487 #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:621 #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:733 msgid "Invalid option!" -msgstr "" +msgstr "Opção inválida!" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:212 msgid "Checking connection..." -msgstr "" +msgstr "Verificando a conexão..." #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:223 msgid "Waiting for connection, attempt" -msgstr "" +msgstr "Aguardando a conexão, tentativa" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:229 msgid "Logged in and connection established successfully!" -msgstr "" +msgstr "Login feito e conexão estabelecida com sucesso!" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:231 msgid "Device information:" -msgstr "" +msgstr "Informações do dispositivo:" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:241 msgid "Name: " -msgstr "" +msgstr "Nome: " #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:250 msgid "Devices on the network:" -msgstr "" +msgstr "Dispositivos na rede:" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:258 msgid "Connection failed. Full diagnostics:" -msgstr "" +msgstr "A conexão falhou. Diagnóstico completo:" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:260 msgid "1. Service status:" -msgstr "" +msgstr "1. Status do serviço:" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:266 msgid "3. Connectivity:" -msgstr "" +msgstr "3. Conectividade:" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:279 msgid "After running the commands above, try logging in again." -msgstr "" +msgstr "Após executar os comandos acima, tente fazer login novamente." #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:285 msgid "CREATE NEW TAILSCALE NETWORK" -msgstr "" +msgstr "CRIAR NOVA REDE TAILSCALE" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:286 msgid "Note: in Tailscale, networks are different accounts/orgs." msgstr "" +"Observação: no Tailscale, as redes são contas/organizações diferentes." #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:287 msgid "You need a different account for each network." -msgstr "" +msgstr "Você precisa de uma conta diferente para cada rede." #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:289 msgid "Network/company name:" -msgstr "" +msgstr "Nome da rede/empresa:" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:293 #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:398 #: usr/share/big-remote-play/scripts/create-network_zerotier.sh:93 msgid "Name cannot be empty!" -msgstr "" +msgstr "O nome não pode ficar vazio!" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:297 msgid "To create a new network:" -msgstr "" +msgstr "Para criar uma nova rede:" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:299 msgid "2. Create a new account with a different email" -msgstr "" +msgstr "2. Crie uma nova conta com um e-mail diferente" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:300 msgid "3. Or use a different domain to create a separate org" -msgstr "" +msgstr "3. Ou use um domínio diferente para criar uma organização separada" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:302 msgid "Log out and log in with a new account? (y/N):" -msgstr "" +msgstr "Sair e entrar com uma nova conta? (y/N):" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:313 msgid "TAILSCALE NETWORK STATUS" -msgstr "" +msgstr "STATUS DA REDE TAILSCALE" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:316 #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:339 #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:426 msgid "Not connected to Tailscale. Log in first." -msgstr "" +msgstr "Não conectado ao Tailscale. Faça login primeiro." #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:320 msgid "Current status:" -msgstr "" +msgstr "Status atual:" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:324 msgid "IP addresses:" -msgstr "" +msgstr "Endereços IP:" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:329 msgid "Detailed information:" -msgstr "" +msgstr "Informações detalhadas:" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:330 msgid "Could not get details" -msgstr "" +msgstr "Não foi possível obter detalhes" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:336 msgid "DEVICES ON THE NETWORK" -msgstr "" +msgstr "DISPOSITIVOS NA REDE" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:360 msgid "Only this device connected to the network" -msgstr "" +msgstr "Apenas este dispositivo conectado à rede" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:368 msgid "ADD NEW DEVICE" -msgstr "" +msgstr "ADICIONAR NOVO DISPOSITIVO" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:370 msgid "Method 1: invite URL" -msgstr "" +msgstr "Método 1: URL de convite" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:375 msgid "Method 2: auth key" -msgstr "" +msgstr "Método 2: auth key" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:377 msgid "2. Generate an auth key" -msgstr "" +msgstr "2. Gere uma auth key" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:380 msgid "Method 3: login with the same account" -msgstr "" +msgstr "Método 3: login com a mesma conta" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:381 msgid "Just log in with the same account on the new device" -msgstr "" +msgstr "Basta fazer login com a mesma conta no novo dispositivo" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:383 msgid "Press ENTER to return to the main menu" -msgstr "" +msgstr "Pressione ENTER para voltar ao menu principal" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:389 msgid "REMOVE DEVICE" -msgstr "" +msgstr "REMOVER DISPOSITIVO" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:390 msgid "WARNING: This will remove the device from the network!" -msgstr "" +msgstr "AVISO: isso removerá o dispositivo da rede!" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:394 msgid "Enter the device name to remove:" -msgstr "" +msgstr "Digite o nome do dispositivo a remover:" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:406 msgid "To remove via API, you need:" -msgstr "" +msgstr "Para remover via API, você precisa de:" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:408 msgid "Find the device" -msgstr "" +msgstr "Encontrar o dispositivo" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:423 msgid "AUTHORIZE DEVICE" -msgstr "" +msgstr "AUTORIZAR DISPOSITIVO" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:430 msgid "Checking pending devices..." -msgstr "" +msgstr "Verificando dispositivos pendentes..." #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:439 msgid "No pending device found" -msgstr "" +msgstr "Nenhum dispositivo pendente encontrado" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:442 msgid "Could not check pending devices (jq not installed)" -msgstr "" +msgstr "Não foi possível verificar dispositivos pendentes (jq não instalado)" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:448 msgid "Look for devices with status Pending" -msgstr "" +msgstr "Procure dispositivos com status Pendente" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:451 #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:492 @@ -6737,332 +6790,406 @@ msgstr "" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:670 #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:738 msgid "Press ENTER to continue" -msgstr "" +msgstr "Pressione ENTER para continuar" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:457 msgid "SHARE NETWORK" -msgstr "" +msgstr "COMPARTILHAR REDE" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:458 msgid "Sharing options:" -msgstr "" +msgstr "Opções de compartilhamento:" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:459 msgid "1) Share with a specific user" -msgstr "" +msgstr "1) Compartilhar com um usuário específico" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:467 msgid "Enter the user email:" -msgstr "" +msgstr "Digite o e-mail do usuário:" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:474 msgid "3. Enter the email and select permissions" -msgstr "" +msgstr "3. Digite o e-mail e selecione as permissões" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:480 msgid "2. Configure the desired options" -msgstr "" +msgstr "2. Configure as opções desejadas" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:501 msgid "To configure ACLs:" -msgstr "" +msgstr "Para configurar ACLs:" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:505 msgid "Basic example:" -msgstr "" +msgstr "Exemplo básico:" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:514 msgid "Open the ACL panel in the browser? (y/N):" -msgstr "" +msgstr "Abrir o painel de ACL no navegador? (y/N):" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:519 msgid "Could not open the browser. Open manually:" -msgstr "" +msgstr "Não foi possível abrir o navegador. Abra manualmente:" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:525 msgid "LOG OUT OF TAILSCALE" -msgstr "" +msgstr "SAIR DO TAILSCALE" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:526 msgid "Do you really want to log out of Tailscale? (y/N):" -msgstr "" +msgstr "Deseja realmente sair do Tailscale? (y/N):" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:531 msgid "Logout successful!" -msgstr "" +msgstr "Logout realizado com sucesso!" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:533 msgid "Operation cancelled." -msgstr "" +msgstr "Operação cancelada." #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:539 msgid "DETAILED TAILSCALE STATUS" -msgstr "" +msgstr "STATUS DETALHADO DO TAILSCALE" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:541 msgid "Service status:" -msgstr "" +msgstr "Status do serviço:" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:545 msgid "Version:" -msgstr "" +msgstr "Versão:" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:550 msgid "Connected to Tailscale" -msgstr "" +msgstr "Conectado ao Tailscale" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:552 msgid "Node information:" -msgstr "" +msgstr "Informações do nó:" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:560 msgid "Statistics:" -msgstr "" +msgstr "Estatísticas:" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:567 msgid "No specific tailscale route" -msgstr "" +msgstr "Nenhuma rota específica do tailscale" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:575 msgid "CHANGE SETTINGS" -msgstr "" +msgstr "ALTERAR CONFIGURAÇÕES" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:576 msgid "Configuration options:" -msgstr "" +msgstr "Opções de configuração:" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:579 msgid "3) Change device name" -msgstr "" +msgstr "3) Alterar o nome do dispositivo" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:580 msgid "4) Configure DNS server" -msgstr "" +msgstr "4) Configurar o servidor DNS" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:581 msgid "5) Back" -msgstr "" +msgstr "5) Voltar" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:587 msgid "Enter subnets to route (e.g. 192.168.1.0/24):" -msgstr "" +msgstr "Digite as sub-redes a rotear (ex.: 192.168.1.0/24):" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:604 msgid "New name for the device:" -msgstr "" +msgstr "Novo nome para o dispositivo:" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:608 msgid "Name changed to" -msgstr "" +msgstr "Nome alterado para" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:612 msgid "Enter DNS servers (comma-separated):" -msgstr "" +msgstr "Digite os servidores DNS (separados por vírgula):" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:632 msgid "SETTINGS BACKUP" -msgstr "" +msgstr "BACKUP DAS CONFIGURAÇÕES" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:639 msgid "Could not create temporary backup directory." -msgstr "" +msgstr "Não foi possível criar o diretório temporário de backup." #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:645 msgid "Script settings saved" -msgstr "" +msgstr "Configurações do script salvas" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:651 msgid "Tailscale settings saved" -msgstr "" +msgstr "Configurações do Tailscale salvas" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:659 msgid "Backup created:" -msgstr "" +msgstr "Backup criado:" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:662 msgid "Checking backup integrity..." -msgstr "" +msgstr "Verificando a integridade do backup..." #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:664 msgid "Backup verified" -msgstr "" +msgstr "Backup verificado" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:666 msgid "Backup corrupted" -msgstr "" +msgstr "Backup corrompido" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:684 msgid "Create new network/account" -msgstr "" +msgstr "Criar nova rede/conta" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:685 msgid "View network status" -msgstr "" +msgstr "Ver o status da rede" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:686 msgid "List devices" -msgstr "" +msgstr "Listar dispositivos" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:687 msgid "Add new device (instructions)" -msgstr "" +msgstr "Adicionar novo dispositivo (instruções)" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:689 msgid "Authorize pending device" -msgstr "" +msgstr "Autorizar dispositivo pendente" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:690 msgid "Share network" -msgstr "" +msgstr "Compartilhar rede" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:692 msgid "Change settings" -msgstr "" +msgstr "Alterar configurações" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:693 msgid "Detailed status" -msgstr "" +msgstr "Status detalhado" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:694 msgid "Logout/Exit" -msgstr "" +msgstr "Sair/Encerrar" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:695 msgid "Backup settings" -msgstr "" +msgstr "Backup das configurações" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:696 msgid "Exit the program" -msgstr "" +msgstr "Sair do programa" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:698 msgid "Choose an option:" -msgstr "" +msgstr "Escolha uma opção:" #: usr/share/big-remote-play/scripts/create-network_tailscale.sh:729 msgid "Exiting..." -msgstr "" +msgstr "Saindo..." #: usr/share/big-remote-play/scripts/create-network_zerotier.sh:40 msgid "ZEROTIER - VIRTUAL PRIVATE NETWORK" -msgstr "" +msgstr "ZEROTIER - REDE PRIVADA VIRTUAL" #: usr/share/big-remote-play/scripts/create-network_zerotier.sh:49 msgid "Installing ZeroTier..." -msgstr "" +msgstr "Instalando o ZeroTier..." #: usr/share/big-remote-play/scripts/create-network_zerotier.sh:52 msgid "ZeroTier already installed" -msgstr "" +msgstr "ZeroTier já instalado" #: usr/share/big-remote-play/scripts/create-network_zerotier.sh:63 msgid "Starting ZeroTier service..." -msgstr "" +msgstr "Iniciando o serviço do ZeroTier..." #: usr/share/big-remote-play/scripts/create-network_zerotier.sh:68 msgid "ZeroTier daemon active" -msgstr "" +msgstr "Daemon do ZeroTier ativo" #: usr/share/big-remote-play/scripts/create-network_zerotier.sh:76 msgid "Paste your API Token here: " -msgstr "" +msgstr "Cole seu token de API aqui: " #: usr/share/big-remote-play/scripts/create-network_zerotier.sh:79 msgid "Token cannot be empty!" -msgstr "" +msgstr "O token não pode ficar vazio!" #: usr/share/big-remote-play/scripts/create-network_zerotier.sh:86 msgid "CREATE NEW ZEROTIER NETWORK" -msgstr "" +msgstr "CRIAR NOVA REDE ZEROTIER" #: usr/share/big-remote-play/scripts/create-network_zerotier.sh:91 msgid "Network name: " -msgstr "" +msgstr "Nome da rede: " #: usr/share/big-remote-play/scripts/create-network_zerotier.sh:97 msgid "Description (optional): " -msgstr "" +msgstr "Descrição (opcional): " #: usr/share/big-remote-play/scripts/create-network_zerotier.sh:100 msgid "Creating network via API..." -msgstr "" +msgstr "Criando a rede via API..." #: usr/share/big-remote-play/scripts/create-network_zerotier.sh:110 msgid "Error creating network. Check your token." -msgstr "" +msgstr "Erro ao criar a rede. Verifique seu token." #: usr/share/big-remote-play/scripts/create-network_zerotier.sh:117 msgid "Network created! ID:" -msgstr "" +msgstr "Rede criada! ID:" #: usr/share/big-remote-play/scripts/create-network_zerotier.sh:120 msgid "Joining the network..." -msgstr "" +msgstr "Ingressando na rede..." #: usr/share/big-remote-play/scripts/create-network_zerotier.sh:128 msgid "Authorizing local device..." -msgstr "" +msgstr "Autorizando o dispositivo local..." #: usr/share/big-remote-play/scripts/create-network_zerotier.sh:134 msgid "Device authorized!" -msgstr "" +msgstr "Dispositivo autorizado!" #: usr/share/big-remote-play/scripts/create-network_zerotier.sh:153 msgid "NETWORK INFORMATION" -msgstr "" +msgstr "INFORMAÇÕES DA REDE" #: usr/share/big-remote-play/scripts/create-network_zerotier.sh:163 msgid "Share the network ID with your friends:" -msgstr "" +msgstr "Compartilhe o ID da rede com seus amigos:" #: usr/share/big-remote-play/scripts/create-network_zerotier.sh:164 msgid "They use the Connect option and enter the network ID" -msgstr "" +msgstr "Eles usam a opção Conectar e inserem o ID da rede" #: usr/share/big-remote-play/scripts/create-network_zerotier.sh:170 msgid "JOIN ZEROTIER NETWORK (Client/Guest)" -msgstr "" +msgstr "INGRESSAR NA REDE ZEROTIER (Cliente/Convidado)" #: usr/share/big-remote-play/scripts/create-network_zerotier.sh:175 msgid "ZeroTier Network ID (16 characters): " -msgstr "" +msgstr "Network ID do ZeroTier (16 caracteres): " #: usr/share/big-remote-play/scripts/create-network_zerotier.sh:177 msgid "Network ID cannot be empty!" -msgstr "" +msgstr "O Network ID não pode ficar vazio!" #: usr/share/big-remote-play/scripts/create-network_zerotier.sh:181 msgid "Joining network:" -msgstr "" +msgstr "Ingressando na rede:" #: usr/share/big-remote-play/scripts/create-network_zerotier.sh:192 msgid "Your Node ID: " -msgstr "" +msgstr "Seu Node ID: " #: usr/share/big-remote-play/scripts/create-network_zerotier.sh:193 msgid "You must be authorized by the network administrator!" -msgstr "" +msgstr "Você precisa ser autorizado pelo administrador da rede!" #: usr/share/big-remote-play/scripts/create-network_zerotier.sh:194 msgid "Give your Node ID to the administrator: " -msgstr "" +msgstr "Informe seu Node ID ao administrador: " #: usr/share/big-remote-play/scripts/create-network_zerotier.sh:197 msgid "Join request sent!" -msgstr "" +msgstr "Solicitação de ingresso enviada!" #: usr/share/big-remote-play/scripts/create-network_zerotier.sh:198 msgid "Wait for the administrator to authorize you" -msgstr "" +msgstr "Aguarde o administrador autorizar você" #: usr/share/big-remote-play/scripts/create-network_zerotier.sh:208 msgid "3) View network status" -msgstr "" +msgstr "3) Ver o status da rede" #: usr/share/big-remote-play/scripts/create-network_zerotier.sh:216 msgid "ZEROTIER STATUS" -msgstr "" +msgstr "STATUS DO ZEROTIER" #: usr/share/big-remote-play/scripts/create-network_zerotier.sh:217 msgid "ZeroTier not connected" +msgstr "ZeroTier não conectado" + +msgid "No host found yet" +msgstr "Nenhum host encontrado ainda" + +msgid "Same house or network? Search again." +msgstr "Mesma casa ou rede? Procurar de novo." + +msgid "Search" +msgstr "Procurar" + +msgid "Search the local network again" +msgstr "Procurar na rede local de novo" + +msgid "" +"Playing with a friend over the internet? You need a Private Network (just " +"once)." +msgstr "" +"Jogando com um amigo pela internet? Você precisa de uma Rede Privada (só uma" +" vez)." + +msgid "Set up Private Network" +msgstr "Configurar Rede Privada" + +msgid "Open Private Network setup" +msgstr "Abrir a configuração da Rede Privada" + +msgid "Did the host give you a 6-digit code?" +msgstr "O anfitrião te passou um código de 6 dígitos?" + +msgid "Switch to PIN connection" +msgstr "Mudar para conexão por PIN" + +msgid "I know the IP address" +msgstr "Sei o endereço IP" + +msgid "Switch to manual connection" +msgstr "Mudar para conexão manual" + +msgid "" +"On the local network or with a Private Network set up, the host appears here" +" automatically." +msgstr "" +"Na rede local ou com uma Rede Privada configurada, o host aparece aqui " +"automaticamente." + +msgid "Can't find your friend's PC?" +msgstr "Não encontrou o PC do amigo?" + +msgid "PIN code" +msgstr "Código PIN" + +msgid "Adjust quality" +msgstr "Ajustar qualidade" + +msgid "No audio" +msgstr "Sem áudio" + +msgid "" +"To play over the internet, you set up a Private Network once: the one with " +"the game creates it, the friend joins. After that, the PC appears " +"automatically under Connect." msgstr "" +"Para jogar pela internet, você configura uma Rede Privada uma única vez: " +"quem tem o jogo cria, o amigo entra. Depois, o PC aparece automaticamente em" +" Conectar." + +msgid "Compare the options" +msgstr "Comparar as opções" + +msgid "Sign in with browser" +msgstr "Entrar com o navegador" + +msgid "I have an auth key" +msgstr "Tenho uma chave de autenticação" From c8e9c387e668199a556550fee67ba3505dd1ffca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Gon=C3=A7alves?= Date: Thu, 25 Jun 2026 07:57:54 -0300 Subject: [PATCH 39/47] feat(vpn): detect package install status; offer one-click install in VPN flow - per-provider dependency check (tailscale/zerotier binary, headscale -> docker) - form shows install status banner; primary button becomes Install-and-connect when missing - selector cards show Installed / Will be installed badge - pt translations Claude-Session: https://claude.ai/code/session_01DhGCL133apRAwBMk6dktHC --- locale/pt.po | 18 +++++++ src/big_remote_play/ui/main_window.py | 17 ++++++ .../ui/private_network_view.py | 54 ++++++++++++++++++- tests/test_connect_redesign_regressions.py | 14 +++++ 4 files changed, 101 insertions(+), 2 deletions(-) diff --git a/locale/pt.po b/locale/pt.po index e766c2d..c808f3e 100644 --- a/locale/pt.po +++ b/locale/pt.po @@ -7193,3 +7193,21 @@ msgstr "Entrar com o navegador" msgid "I have an auth key" msgstr "Tenho uma chave de autenticação" + +msgid "{} is installed." +msgstr "{} está instalado." + +msgid "" +"{} is not installed yet. It will be installed when you continue (asks for " +"your password)." +msgstr "" +"{} ainda não está instalado. Será instalado ao continuar (pede sua senha)." + +msgid "Install and connect" +msgstr "Instalar e conectar" + +msgid "Install and sign in" +msgstr "Instalar e entrar" + +msgid "Will be installed" +msgstr "Será instalado" diff --git a/src/big_remote_play/ui/main_window.py b/src/big_remote_play/ui/main_window.py index dd5b5c9..1f4072f 100644 --- a/src/big_remote_play/ui/main_window.py +++ b/src/big_remote_play/ui/main_window.py @@ -688,6 +688,23 @@ def _create_vpn_card(self, provider_id: str, info: dict) -> Gtk.Button: levels = {"tailscale": "beginner", "zerotier": "intermediate", "headscale": "advanced"} box.append(create_difficulty_pill(levels.get(provider_id, "intermediate"))) + # Install status so the user knows before choosing whether the package is + # present (headscale is container-based, so it depends on Docker). + dep_checks = { + "tailscale": self.system_check.has_tailscale, + "zerotier": self.system_check.has_zerotier, + "headscale": self.system_check.has_docker, + } + try: + is_installed = bool(dep_checks.get(provider_id, lambda: True)()) + except Exception: + is_installed = True + install_lbl = Gtk.Label(label=_("Installed") if is_installed else _("Will be installed")) + install_lbl.add_css_class("caption") + install_lbl.add_css_class("success" if is_installed else "dim-label") + install_lbl.set_halign(Gtk.Align.CENTER) + box.append(install_lbl) + # "Choose" label choose_lbl = Gtk.Label(label=_("Choose →")) choose_lbl.add_css_class("caption-heading") diff --git a/src/big_remote_play/ui/private_network_view.py b/src/big_remote_play/ui/private_network_view.py index e782955..0506332 100644 --- a/src/big_remote_play/ui/private_network_view.py +++ b/src/big_remote_play/ui/private_network_view.py @@ -358,6 +358,9 @@ def _build(self): getattr(clamp, f"set_margin_{m}")(24) content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=20) + # Install status (above the form): is the VPN package present? + content.append(self._build_install_status()) + # Form group self._form_group = Adw.PreferencesGroup() self._form_group.set_title(_("Configuration")) @@ -389,11 +392,12 @@ def _build(self): # _on_action with an empty auth key (now behind the advanced disclosure) # triggers the browser login flow. if self.vpn_id == "tailscale": - self._btn_browser = Gtk.Button(label=_("Sign in with browser")) + browser_label = _("Install and sign in") if not self._is_vpn_installed() else _("Sign in with browser") + self._btn_browser = Gtk.Button(label=browser_label) self._btn_browser.add_css_class("pill") self._btn_browser.add_css_class("suggested-action") self._btn_browser.set_size_request(220, 48) - self._btn_browser.update_property([Gtk.AccessibleProperty.LABEL], [_("Sign in with browser")]) + self._btn_browser.update_property([Gtk.AccessibleProperty.LABEL], [browser_label]) self._btn_browser.connect("clicked", self._on_action) btn_box.append(self._btn_browser) @@ -438,7 +442,53 @@ def _build(self): if self._logged_in: GLib.idle_add(self._refresh_networks) + def _vpn_dependency(self): + """(check_callable, friendly_name) for this provider's required package.""" + sc = self.main_window.system_check + deps = { + "tailscale": (sc.has_tailscale, "Tailscale"), + "zerotier": (sc.has_zerotier, "ZeroTier"), + "headscale": (sc.has_docker, "Docker"), + } + return deps.get(self.vpn_id, (lambda: True, self.vpn_id)) + + def _is_vpn_installed(self) -> bool: + check, _name = self._vpn_dependency() + try: + return bool(check()) + except Exception: + # Detection failed; don't block the user (the script is idempotent). + return True + + def _build_install_status(self) -> Gtk.Widget: + """Row above the form telling the user whether the VPN package is + installed; if not, that continuing will install it (asks for password).""" + _check, name = self._vpn_dependency() + installed = self._is_vpn_installed() + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + row.add_css_class("helper-row") + if installed: + icon = create_icon_widget("emblem-ok-symbolic", size=18) + icon.add_css_class("success") + text = _("{} is installed.").format(name) + else: + icon = create_icon_widget("dialog-warning-symbolic", size=18) + icon.add_css_class("warning") + text = _("{} is not installed yet. It will be installed when you continue (asks for your password).").format(name) + icon.set_valign(Gtk.Align.CENTER) + row.append(icon) + lbl = Gtk.Label(label=text) + lbl.set_wrap(True) + lbl.set_xalign(0) + lbl.set_hexpand(True) + if installed: + lbl.add_css_class("dim-label") + row.append(lbl) + return row + def _action_label(self): + if not self._is_vpn_installed() and self.vpn_id != "headscale": + return _("Install and connect") if self.vpn_id == "tailscale": return _("Login / Connect") if self.vpn_id == "zerotier": diff --git a/tests/test_connect_redesign_regressions.py b/tests/test_connect_redesign_regressions.py index a50338c..ceb8ca8 100644 --- a/tests/test_connect_redesign_regressions.py +++ b/tests/test_connect_redesign_regressions.py @@ -58,3 +58,17 @@ def test_tailscale_browser_login_prominent_and_key_advanced() -> None: src = PNV.read_text() assert "Adw.ExpanderRow" in src # auth key behind an advanced disclosure assert "Sign in with browser" in src + + +def test_vpn_form_checks_install_status_and_adapts_label() -> None: + src = PNV.read_text() + assert "_is_vpn_installed" in src + assert "system_check" in src + assert "Install and connect" in src + assert "Install and sign in" in src + + +def test_vpn_selector_cards_show_install_badge() -> None: + src = MAIN.read_text() + assert "has_tailscale" in src and "has_docker" in src + assert "Will be installed" in src From d9d48495d9ac70c3d7bca73a85750f88a6018e1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Gon=C3=A7alves?= Date: Thu, 25 Jun 2026 08:18:11 -0300 Subject: [PATCH 40/47] feat(vpn): install-only step when package missing (pacman); detect Flatpak; manual fallback - missing package -> only an explicit Install button (pacman via bigsudo), no connect options - install-vpn.sh: pacman -S + enable service (tailscale/zerotier/headscale->docker) - SystemCheck: has_pacman, generic flatpak_app_id detection, tailscale_cmd (native or flatpak run) - has_tailscale/has_zerotier now recognise a Flatpak install - on success: rebuild to connect view + refresh status; no pacman -> link to official install - pt translations Claude-Session: https://claude.ai/code/session_01DhGCL133apRAwBMk6dktHC --- locale/pt.po | 15 ++ .../ui/private_network_view.py | 172 +++++++++++++----- src/big_remote_play/utils/system_check.py | 45 ++++- tests/test_connect_redesign_regressions.py | 22 ++- .../big-remote-play/scripts/install-vpn.sh | 57 ++++++ 5 files changed, 255 insertions(+), 56 deletions(-) create mode 100644 usr/share/big-remote-play/scripts/install-vpn.sh diff --git a/locale/pt.po b/locale/pt.po index c808f3e..4d26866 100644 --- a/locale/pt.po +++ b/locale/pt.po @@ -7211,3 +7211,18 @@ msgstr "Instalar e entrar" msgid "Will be installed" msgstr "Será instalado" + +msgid "" +"{} is not installed yet. Install it below to continue (asks for your " +"password)." +msgstr "" +"{} ainda não está instalado. Instale abaixo para continuar (pede sua senha)." + +msgid "Install {}" +msgstr "Instalar {}" + +msgid "How to install {}" +msgstr "Como instalar {}" + +msgid "{} installed" +msgstr "{} instalado" diff --git a/src/big_remote_play/ui/private_network_view.py b/src/big_remote_play/ui/private_network_view.py index 0506332..1b013b4 100644 --- a/src/big_remote_play/ui/private_network_view.py +++ b/src/big_remote_play/ui/private_network_view.py @@ -335,7 +335,8 @@ def __init__(self, vpn_id, main_window): def _check_logged_in(self): if self.vpn_id in ("tailscale", "headscale"): try: - r = subprocess.run(["tailscale", "status"], capture_output=True, text=True, timeout=10) + cmd = self.main_window.system_check.tailscale_cmd() + ["status"] + r = subprocess.run(cmd, capture_output=True, text=True, timeout=10) # If logged in and has a valid IP/DNS return r.returncode == 0 and "Logged out" not in r.stdout except Exception: @@ -361,12 +362,15 @@ def _build(self): # Install status (above the form): is the VPN package present? content.append(self._build_install_status()) - # Form group + installed = self._is_vpn_installed() + + # Form group — only meaningful once the package exists. self._form_group = Adw.PreferencesGroup() self._form_group.set_title(_("Configuration")) self._form_rows = [] - self._build_form() - content.append(self._form_group) + if installed: + self._build_form() + content.append(self._form_group) # Progress self._progress = ProgressRow(on_show_log=self._show_log) @@ -383,48 +387,51 @@ def _build(self): self._spinner = Gtk.Spinner() self._spinner.set_visible(False) - self._action_lbl = Gtk.Label(label=self._action_label()) - btn_inner = Gtk.Box(spacing=8, halign=Gtk.Align.CENTER) - btn_inner.append(self._spinner) - btn_inner.append(self._action_lbl) - - # Tailscale: browser login is the prominent, no-typing primary action. - # _on_action with an empty auth key (now behind the advanced disclosure) - # triggers the browser login flow. - if self.vpn_id == "tailscale": - browser_label = _("Install and sign in") if not self._is_vpn_installed() else _("Sign in with browser") - self._btn_browser = Gtk.Button(label=browser_label) - self._btn_browser.add_css_class("pill") - self._btn_browser.add_css_class("suggested-action") - self._btn_browser.set_size_request(220, 48) - self._btn_browser.update_property([Gtk.AccessibleProperty.LABEL], [browser_label]) - self._btn_browser.connect("clicked", self._on_action) - btn_box.append(self._btn_browser) - - self._btn_action = Gtk.Button() - self._btn_action.add_css_class("pill") - # On Tailscale the browser button is the single visual primary; keep the - # generic action button as a neutral secondary. - if self.vpn_id != "tailscale": - self._btn_action.add_css_class("suggested-action") - self._btn_action.set_size_request(200, 48) - self._btn_action.set_child(btn_inner) - self._btn_action.connect("clicked", self._on_action) - btn_box.append(self._btn_action) - - self._btn_instr = Gtk.Button(label=_("Instructions")) - self._btn_instr.add_css_class("pill") - self._btn_instr.set_size_request(180, 48) - self._btn_instr.connect("clicked", self._on_instructions_clicked) - btn_box.append(self._btn_instr) - self._btn_logout = Gtk.Button(label=_("Logout / Disconnect")) - self._btn_logout.add_css_class("pill") - self._btn_logout.add_css_class("destructive-action") - self._btn_logout.set_size_request(180, 48) - self._btn_logout.connect("clicked", self._on_logout) - self._btn_logout.set_visible(self._logged_in) - btn_box.append(self._btn_logout) + if not installed: + # Missing package: the only action is an explicit install (pacman) or + # manual guidance. Connect options appear only after it is installed. + self._build_install_buttons(btn_box) + else: + self._action_lbl = Gtk.Label(label=self._action_label()) + btn_inner = Gtk.Box(spacing=8, halign=Gtk.Align.CENTER) + btn_inner.append(self._spinner) + btn_inner.append(self._action_lbl) + + # Tailscale: browser login is the prominent, no-typing primary action. + if self.vpn_id == "tailscale": + self._btn_browser = Gtk.Button(label=_("Sign in with browser")) + self._btn_browser.add_css_class("pill") + self._btn_browser.add_css_class("suggested-action") + self._btn_browser.set_size_request(220, 48) + self._btn_browser.update_property([Gtk.AccessibleProperty.LABEL], [_("Sign in with browser")]) + self._btn_browser.connect("clicked", self._on_action) + btn_box.append(self._btn_browser) + + self._btn_action = Gtk.Button() + self._btn_action.add_css_class("pill") + # On Tailscale the browser button is the single visual primary; keep the + # generic action button as a neutral secondary. + if self.vpn_id != "tailscale": + self._btn_action.add_css_class("suggested-action") + self._btn_action.set_size_request(200, 48) + self._btn_action.set_child(btn_inner) + self._btn_action.connect("clicked", self._on_action) + btn_box.append(self._btn_action) + + self._btn_instr = Gtk.Button(label=_("Instructions")) + self._btn_instr.add_css_class("pill") + self._btn_instr.set_size_request(180, 48) + self._btn_instr.connect("clicked", self._on_instructions_clicked) + btn_box.append(self._btn_instr) + + self._btn_logout = Gtk.Button(label=_("Logout / Disconnect")) + self._btn_logout.add_css_class("pill") + self._btn_logout.add_css_class("destructive-action") + self._btn_logout.set_size_request(180, 48) + self._btn_logout.connect("clicked", self._on_logout) + self._btn_logout.set_visible(self._logged_in) + btn_box.append(self._btn_logout) content.append(btn_box) @@ -474,7 +481,7 @@ def _build_install_status(self) -> Gtk.Widget: else: icon = create_icon_widget("dialog-warning-symbolic", size=18) icon.add_css_class("warning") - text = _("{} is not installed yet. It will be installed when you continue (asks for your password).").format(name) + text = _("{} is not installed yet. Install it below to continue (asks for your password).").format(name) icon.set_valign(Gtk.Align.CENTER) row.append(icon) lbl = Gtk.Label(label=text) @@ -486,9 +493,78 @@ def _build_install_status(self) -> Gtk.Widget: row.append(lbl) return row + def _install_help_url(self) -> str: + return { + "tailscale": "https://tailscale.com/download", + "zerotier": "https://www.zerotier.com/download/", + "headscale": "https://docs.docker.com/engine/install/", + }.get(self.vpn_id, "https://tailscale.com/download") + + def _build_install_buttons(self, btn_box) -> None: + """When the package is missing: a single explicit Install action (pacman), + or — where pacman is unavailable and no Flatpak exists — a link to the + provider's official install page. No connect options are shown yet.""" + _check, name = self._vpn_dependency() + if self.main_window.system_check.has_pacman(): + self._btn_install = Gtk.Button() + self._btn_install.add_css_class("pill") + self._btn_install.add_css_class("suggested-action") + self._btn_install.set_size_request(220, 48) + inner = Gtk.Box(spacing=8, halign=Gtk.Align.CENTER) + inner.append(self._spinner) + inner.append(Gtk.Label(label=_("Install {}").format(name))) + self._btn_install.set_child(inner) + self._btn_install.update_property([Gtk.AccessibleProperty.LABEL], [_("Install {}").format(name)]) + self._btn_install.connect("clicked", self._on_install_clicked) + else: + self._btn_install = Gtk.Button(label=_("How to install {}").format(name)) + self._btn_install.add_css_class("pill") + self._btn_install.add_css_class("suggested-action") + self._btn_install.set_size_request(220, 48) + self._btn_install.update_property([Gtk.AccessibleProperty.LABEL], [_("How to install {}").format(name)]) + self._btn_install.connect("clicked", lambda b: open_uri(self._install_help_url())) + btn_box.append(self._btn_install) + + self._btn_instr = Gtk.Button(label=_("Instructions")) + self._btn_instr.add_css_class("pill") + self._btn_instr.set_size_request(180, 48) + self._btn_instr.connect("clicked", self._on_instructions_clicked) + btn_box.append(self._btn_instr) + + def _on_install_clicked(self, btn) -> None: + self._btn_install.set_sensitive(False) + self._spinner.set_visible(True) + self._spinner.start() + self._progress.update(0.05, _("Installing...")) + self._log.clear() + _check, name = self._vpn_dependency() + + def done(code, captured): + if captured.get("INSTALL_RESULT") == "ok" and code == 0: + self.main_window.show_toast(_("{} installed").format(name)) + if hasattr(self.main_window, "check_system"): + self.main_window.check_system() + self._rebuild() + else: + self._spinner.stop() + self._spinner.set_visible(False) + self._btn_install.set_sensitive(True) + self.main_window.show_toast(_("Installation failed. Check the log.")) + + self._run_script("install-vpn.sh", [self.vpn_id + "\n"], done) + + def _rebuild(self) -> None: + """Tear down and rebuild the page (e.g. after a successful install, to + switch from the install-only view to the connect view).""" + child = self.get_first_child() + while child is not None: + nxt = child.get_next_sibling() + self.remove(child) + child = nxt + self._logged_in = self._check_logged_in() + self._build() + def _action_label(self): - if not self._is_vpn_installed() and self.vpn_id != "headscale": - return _("Install and connect") if self.vpn_id == "tailscale": return _("Login / Connect") if self.vpn_id == "zerotier": diff --git a/src/big_remote_play/utils/system_check.py b/src/big_remote_play/utils/system_check.py index b80a915..1dff444 100644 --- a/src/big_remote_play/utils/system_check.py +++ b/src/big_remote_play/utils/system_check.py @@ -13,6 +13,43 @@ class SystemCheck: def __init__(self): pass + def has_pacman(self) -> bool: + """True on Arch-family distros where we install VPN packages via pacman.""" + return shutil.which("pacman") is not None + + def flatpak_app_id(self, keyword: str) -> str | None: + """First installed Flatpak app id whose id contains `keyword`, or None. + + Lets us recognise (and later run via `flatpak run`) a VPN tool the user + already installed as a Flatpak, without hardcoding a guessed app id.""" + if shutil.which("flatpak") is None: + return None + try: + r = subprocess.run( + ["flatpak", "list", "--app", "--columns=application"], + capture_output=True, + text=True, + timeout=5, + ) + if r.returncode == 0: + key = keyword.lower() + for line in r.stdout.splitlines(): + app = line.strip() + if key in app.lower(): + return app + except Exception: + pass + return None + + def tailscale_cmd(self) -> list[str]: + """Argv prefix to invoke the tailscale CLI (native or Flatpak).""" + if shutil.which("tailscale") is not None: + return ["tailscale"] + fid = self.flatpak_app_id("tailscale") + if fid is not None: + return ["flatpak", "run", "--command=tailscale", fid] + return ["tailscale"] + def has_sunshine(self) -> bool: """Checks if Sunshine is installed""" return shutil.which("sunshine") is not None @@ -31,12 +68,12 @@ def has_docker(self) -> bool: return shutil.which("docker") is not None def has_zerotier(self) -> bool: - """Checks if ZeroTier is installed""" - return shutil.which("zerotier-cli") is not None + """Checks if ZeroTier is installed (native or Flatpak)""" + return shutil.which("zerotier-cli") is not None or self.flatpak_app_id("zerotier") is not None def has_tailscale(self) -> bool: - """Checks if Tailscale is installed""" - return shutil.which("tailscale") is not None + """Checks if Tailscale is installed (native or Flatpak)""" + return shutil.which("tailscale") is not None or self.flatpak_app_id("tailscale") is not None def check_all(self) -> dict: """Checks all components""" diff --git a/tests/test_connect_redesign_regressions.py b/tests/test_connect_redesign_regressions.py index ceb8ca8..b26733b 100644 --- a/tests/test_connect_redesign_regressions.py +++ b/tests/test_connect_redesign_regressions.py @@ -60,12 +60,26 @@ def test_tailscale_browser_login_prominent_and_key_advanced() -> None: assert "Sign in with browser" in src -def test_vpn_form_checks_install_status_and_adapts_label() -> None: +def test_vpn_form_install_only_when_missing() -> None: src = PNV.read_text() assert "_is_vpn_installed" in src - assert "system_check" in src - assert "Install and connect" in src - assert "Install and sign in" in src + assert "_build_install_buttons" in src + # Explicit install action (pacman) gated on pacman availability; manual link otherwise. + assert "has_pacman" in src + assert "_on_install_clicked" in src + assert "install-vpn.sh" in src + # Rebuild to the connect view after a successful install. + assert "_rebuild" in src + + +def test_install_supports_pacman_and_flatpak_detection() -> None: + sc = Path("src/big_remote_play/utils/system_check.py").read_text() + assert "def has_pacman" in sc + assert "flatpak_app_id" in sc + assert "def tailscale_cmd" in sc + # has_tailscale / has_zerotier recognise a Flatpak install too. + assert "flatpak_app_id(\"tailscale\")" in sc + assert "flatpak_app_id(\"zerotier\")" in sc def test_vpn_selector_cards_show_install_badge() -> None: diff --git a/usr/share/big-remote-play/scripts/install-vpn.sh b/usr/share/big-remote-play/scripts/install-vpn.sh new file mode 100644 index 0000000..45ae287 --- /dev/null +++ b/usr/share/big-remote-play/scripts/install-vpn.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Install a VPN provider's package via pacman and enable its service. +# Invoked with elevated privilege (bigsudo). Argument: provider id. +# +# Emits machine-readable markers (see script_protocol.py): +# BRP_PHASE progress 0..1 +# BRP_DATA INSTALL_RESULT=ok success sentinel +# Other lines are human prose for the progress log. +set -u + +# Provider id comes as argv[1], or (when driven via the app's stdin runner) as +# the first stdin line. +provider="${1:-}" +if [ -z "$provider" ]; then + read -r provider || true + provider="$(printf '%s' "$provider" | tr -d '[:space:]')" +fi + +case "$provider" in +tailscale) + pkg="tailscale" + unit="tailscaled" + ;; +zerotier) + pkg="zerotier-one" + unit="zerotier-one" + ;; +headscale) + # Headscale runs in containers; the local dependency is Docker. + pkg="docker" + unit="docker" + ;; +*) + echo "Unknown provider: ${provider}" + exit 2 + ;; +esac + +if ! command -v pacman &>/dev/null; then + echo "pacman not available on this system" + exit 3 +fi + +echo "BRP_PHASE 0.1" +echo "Installing ${pkg}..." +if ! pacman -S --needed --noconfirm "$pkg"; then + echo "Failed to install ${pkg}" + exit 1 +fi + +echo "BRP_PHASE 0.7" +echo "Enabling ${unit}..." +systemctl enable --now "$unit" || true + +echo "BRP_PHASE 1.0" +echo "BRP_DATA INSTALL_RESULT=ok" +echo "Done." From dc45c07e729142eaf3c3d476849ea0f25179c9fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Gon=C3=A7alves?= Date: Thu, 25 Jun 2026 08:19:10 -0300 Subject: [PATCH 41/47] fix(vpn): install Tailscale via pacman, not AUR/yay Claude-Session: https://claude.ai/code/session_01DhGCL133apRAwBMk6dktHC --- .../scripts/create-network_tailscale.sh | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/usr/share/big-remote-play/scripts/create-network_tailscale.sh b/usr/share/big-remote-play/scripts/create-network_tailscale.sh index 4059a28..d006dbd 100755 --- a/usr/share/big-remote-play/scripts/create-network_tailscale.sh +++ b/usr/share/big-remote-play/scripts/create-network_tailscale.sh @@ -59,18 +59,8 @@ check_dependencies() { if ! command -v tailscale &>/dev/null; then echo -e "${RED}$(gettext 'Tailscale not found. Installing...')${NC}" - # Add AUR repository (yay required) - if ! command -v yay &>/dev/null; then - echo -e "${YELLOW}$(gettext 'Installing yay (AUR helper)...')${NC}" - sudo pacman -S --needed git base-devel --noconfirm - git clone https://aur.archlinux.org/yay.git /tmp/yay - cd /tmp/yay || exit 1 - makepkg -si --noconfirm - cd - || exit 1 - fi - - # Install tailscale from AUR - yay -S tailscale-bin --noconfirm + # Install tailscale from the official repos (pacman, not AUR). + sudo pacman -S --needed --noconfirm tailscale # Enable and start service sudo systemctl enable tailscaled From 66c16c050bf9942fe21d213417a87f40627fc580 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Gon=C3=A7alves?= Date: Thu, 25 Jun 2026 08:20:34 -0300 Subject: [PATCH 42/47] fix(discover): filter virtual interfaces (veth/docker/br-), dedupe link-local Claude-Session: https://claude.ai/code/session_01DhGCL133apRAwBMk6dktHC --- src/big_remote_play/utils/network.py | 45 +++++++++++++++++++++++++--- tests/test_network.py | 41 +++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 4 deletions(-) diff --git a/src/big_remote_play/utils/network.py b/src/big_remote_play/utils/network.py index f7c9178..6647094 100644 --- a/src/big_remote_play/utils/network.py +++ b/src/big_remote_play/utils/network.py @@ -9,6 +9,26 @@ from big_remote_play.utils.logger import Logger +# Virtual/container interface prefixes that pollute discovery: a host running +# Docker (or VPN/bridges) exposes its Sunshine service over every one of these +# link-local interfaces, producing a duplicate entry per veth. Real LAN peers +# are reachable over physical interfaces, so these are dropped from discovery. +_VIRTUAL_IFACE_PREFIXES = ( + "veth", + "docker", + "br-", + "virbr", + "vnet", + "vmnet", + "lo", +) + + +def _is_virtual_iface(iface: str) -> bool: + """True for container/bridge/loopback interfaces that flood discovery.""" + name = iface.strip().lower() + return name.startswith(_VIRTUAL_IFACE_PREFIXES) + class NetworkDiscovery: """Sunshine host discovery on network""" @@ -41,7 +61,7 @@ def parse_avahi_output(self, output: str) -> List[Dict]: """ Parses avahi output prioritizing Global IPv6 > IPv4 > Link-Local IPv6 """ - host_map = {} + host_map: Dict[str, dict] = {} for line in output.split("\n"): p = line.split(";") @@ -52,6 +72,11 @@ def parse_avahi_output(self, output: str) -> List[Dict]: interface = p[1] port = int(p[8]) + # Skip container/bridge interfaces: the same host is announced + # over every veth/docker iface, otherwise flooding the list. + if _is_virtual_iface(interface): + continue + # Create entry if not exists if service_name not in host_map: host_map[service_name] = {"name": service_name, "hostname": hostname, "port": port, "status": "online", "ips": []} @@ -89,8 +114,18 @@ def parse_avahi_output(self, output: str) -> List[Dict]: final_hosts = [] for name, data in host_map.items(): - # Add all discovered IPs to the list so user can choose - for ip_info in data["ips"]: + # Order: prefer routable addresses; keep at most one link-local so a + # single host never shows up as several near-identical entries. + type_rank = {"ipv4": 0, "ipv6_global": 1, "ipv6_link_local": 2} + ordered = sorted(data["ips"], key=lambda i: type_rank.get(i["type"], 3)) + + link_local_added = False + for ip_info in ordered: + if ip_info["type"] == "ipv6_link_local": + if link_local_added: + continue + link_local_added = True + display_name = data["name"] # Append protocol info to distinguish in UI if needed, # although the subtitle in UI showing the IP is usually enough. @@ -130,7 +165,9 @@ def manual_scan(self) -> List[Dict]: try: dev_idx = parts.index("dev") if dev_idx + 1 < len(parts): - targets.append(f"{ip}%{parts[dev_idx + 1]}") + dev = parts[dev_idx + 1] + if not _is_virtual_iface(dev): + targets.append(f"{ip}%{dev}") except Exception: pass else: diff --git a/tests/test_network.py b/tests/test_network.py index d07cd35..7c5adc7 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -27,3 +27,44 @@ def test_parse_ipv6_link_local_gets_scope_id(fake_home: Path) -> None: def test_parse_ignores_malformed_lines(fake_home: Path) -> None: assert NetworkDiscovery().parse_avahi_output("garbage;line\nanother") == [] + + +def test_parse_drops_virtual_interfaces(fake_home: Path) -> None: + # Same host announced over physical + docker veth ifaces: only physical kept. + lines = "\n".join( + [ + '=;eth0;IPv4;MyHost;_nvstream._tcp;local;;192.168.1.50;47989;"x"', + '=;veth71621c2;IPv6;MyHost;_nvstream._tcp;local;;fe80::1;47989;"x"', + '=;docker0;IPv6;MyHost;_nvstream._tcp;local;;fe80::2;47989;"x"', + '=;br-abc123;IPv6;MyHost;_nvstream._tcp;local;;fe80::3;47989;"x"', + ] + ) + hosts = NetworkDiscovery().parse_avahi_output(lines) + assert len(hosts) == 1 + assert hosts[0]["ip"] == "192.168.1.50" + + +def test_parse_caps_link_local_to_one_per_host(fake_home: Path) -> None: + # Two physical interfaces, both link-local: only one entry survives. + lines = "\n".join( + [ + '=;eth0;IPv6;V6Host;_nvstream._tcp;local;;fe80::1;47989;"x"', + '=;wlan0;IPv6;V6Host;_nvstream._tcp;local;;fe80::2;47989;"x"', + ] + ) + hosts = NetworkDiscovery().parse_avahi_output(lines) + assert len(hosts) == 1 + assert "IPv6 Local" in hosts[0]["name"] + + +def test_parse_prefers_ipv4_then_global_then_link_local(fake_home: Path) -> None: + lines = "\n".join( + [ + '=;eth0;IPv6;Multi;_nvstream._tcp;local;;fe80::9;47989;"x"', + '=;eth0;IPv6;Multi;_nvstream._tcp;local;;2001:db8::5;47989;"x"', + '=;eth0;IPv4;Multi;_nvstream._tcp;local;;192.168.1.7;47989;"x"', + ] + ) + hosts = NetworkDiscovery().parse_avahi_output(lines) + # IPv4 ranks first, link-local last; all three kept (single link-local). + assert [h["ip"] for h in hosts] == ["192.168.1.7", "2001:db8::5", "fe80::9%eth0"] From b87ee53e96d7dd49de417e496b83cc5a7a8d5c3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Gon=C3=A7alves?= Date: Thu, 25 Jun 2026 08:20:34 -0300 Subject: [PATCH 43/47] fix(host): don't plot synthetic metrics when no guest connected Claude-Session: https://claude.ai/code/session_01DhGCL133apRAwBMk6dktHC --- src/big_remote_play/ui/performance_monitor.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/big_remote_play/ui/performance_monitor.py b/src/big_remote_play/ui/performance_monitor.py index 4db3ba1..7d1c18a 100644 --- a/src/big_remote_play/ui/performance_monitor.py +++ b/src/big_remote_play/ui/performance_monitor.py @@ -794,8 +794,12 @@ def update_stats( return sessions, device_latencies = sessions or [], device_latencies or {} - # The chart receives device_latencies, containing ALL that answered the ping - self.chart.add_data_point(latency, fps, bandwidth, users=len(sessions), device_latencies=device_latencies, bw_text_override=bw_text) + # Only chart real activity. With no connected guest, latency/fps/bw are + # synthetic (target values, dummy "Unlimited" bandwidth); plotting them + # paints a flat fake graph that looks like a live 60 FPS session. Skip so + # the chart keeps its "Waiting for data..." idle state instead. + if sessions or device_latencies: + self.chart.add_data_point(latency, fps, bandwidth, users=len(sessions), device_latencies=device_latencies, bw_text_override=bw_text) if len(sessions) > 0: if len(sessions) == 1: From 7b30741d78ec98fc1636c9136f8ca86db680c2ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Gon=C3=A7alves?= Date: Thu, 25 Jun 2026 08:20:34 -0300 Subject: [PATCH 44/47] feat(host): show local IPs in clear; widen advanced-settings dialog past 600px clamp Claude-Session: https://claude.ai/code/session_01DhGCL133apRAwBMk6dktHC --- src/big_remote_play/ui/host_view.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/big_remote_play/ui/host_view.py b/src/big_remote_play/ui/host_view.py index b7f1b7e..f46a51b 100644 --- a/src/big_remote_play/ui/host_view.py +++ b/src/big_remote_play/ui/host_view.py @@ -937,10 +937,12 @@ def create_summary_box(self): self.summary_box.add_css_class("compact-rows") self.summary_box.set_visible(True) self.field_widgets = {} + # Local LAN addresses are low-sensitivity and need sharing to connect, so + # they show in clear; global (public) addresses stay masked behind the eye. for l, k, i, r in [ ("Host", "hostname", "computer-symbolic", True), - ("IPv4", "ipv4", "network-wired-symbolic", False), - ("IPv6", "ipv6", "network-wired-symbolic", False), + ("IPv4", "ipv4", "network-wired-symbolic", True), + ("IPv6", "ipv6", "network-wired-symbolic", True), ("IPv4 Global", "ipv4_global", "network-transmit-receive-symbolic", False), ("IPv6 Global", "ipv6_global", "network-transmit-receive-symbolic", False), ]: @@ -2356,8 +2358,25 @@ def open_advanced_settings(self, _widget=None): win.set_transient_for(self._root_window()) win.set_modal(True) win.set_title(_("Advanced server settings")) + # Wider than the stock 600px preferences width: encoder rows carry long + # combo labels + values that get squeezed at the default clamp. + win.set_default_size(980, 720) win.add(SunshinePreferencesPage(main_config=self.config)) win.present() + # AdwPreferencesPage hardcodes its internal AdwClamp to 600px with no API; + # widen it after the tree is built so the extra window width is usable. + GLib.idle_add(self._widen_preferences_clamp, win, 900) + + def _widen_preferences_clamp(self, widget: Gtk.Widget, max_width: int) -> bool: + """Walk the widget tree and relax every AdwClamp's maximum size once.""" + if isinstance(widget, Adw.Clamp): + widget.set_maximum_size(max_width) + widget.set_tightening_threshold(max_width) + child = widget.get_first_child() + while child is not None: + self._widen_preferences_clamp(child, max_width) + child = child.get_next_sibling() + return False def open_password_dialog(self, _widget): dialog = Adw.MessageDialog( From 2a559c2e2b33b1a1f802f9b0c022c946f242e5a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Gon=C3=A7alves?= Date: Thu, 25 Jun 2026 08:20:34 -0300 Subject: [PATCH 45/47] docs: connect-page novice redesign spec + implementation plan Claude-Session: https://claude.ai/code/session_01DhGCL133apRAwBMk6dktHC --- .../plans/2026-06-25-connect-page-redesign.md | 751 ++++++++++++++++++ ...2026-06-25-connect-page-redesign-design.md | 172 ++++ 2 files changed, 923 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-25-connect-page-redesign.md create mode 100644 docs/superpowers/specs/2026-06-25-connect-page-redesign-design.md diff --git a/docs/superpowers/plans/2026-06-25-connect-page-redesign.md b/docs/superpowers/plans/2026-06-25-connect-page-redesign.md new file mode 100644 index 0000000..9cb484d --- /dev/null +++ b/docs/superpowers/plans/2026-06-25-connect-page-redesign.md @@ -0,0 +1,751 @@ +# Connect Page + Rede Privada Novice Redesign — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task (inline; this project forbids subagents). Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make a non-technical user succeed end-to-end on "Conectar ao servidor": discovery-first layout, fixed host-list clipping, an intent-branched empty state that routes to the real unlock (Rede Privada / PIN), collapsed quality settings; plus a novice-clearer Rede Privada selector and Tailscale form. + +**Architecture:** Pure GTK4/libadwaita layout + copy changes in three files. No discovery/connection/VPN logic changes — only structure, prominence, and guidance text. New/changed strings go through `_()`. Verification = ruff + mypy + existing pytest regression guards (static-source assertions, the repo's established UI-guard pattern) + live screenshots on the test VM (192.168.1.21) via the `linux-ui-a11y` `aigui` harness. + +**Tech Stack:** Python 3.14, PyGObject (GTK 4 / libadwaita 1.x), pytest, ruff, mypy, gettext. + +**Spec:** `docs/superpowers/specs/2026-06-25-connect-page-redesign-design.md` + +--- + +## File Structure + +- Modify `src/big_remote_play/ui/guest_view.py` + - `setup_ui()` — move client settings into a collapsed `Adw.ExpanderRow` with a live summary. + - `create_discover_page()` — single column (drop side helper card), fixed guidance subtitle, clipping fix on the host scroller, footer hint line. + - `discover_hosts()` / `on_hosts_discovered` / `update_hosts_list()` — render the compact intent-branched empty state. + - New helper `_build_discover_empty_state()` and `_quality_summary()`. +- Modify `src/big_remote_play/ui/main_window.py` + - `create_vpn_selector_page()` — plain-language role-framing intro; wrap the comparison table in a collapsed `Gtk.Expander`. +- Modify `src/big_remote_play/ui/private_network_view.py` + - `_build()` / `_build_form()` — Tailscale: prominent "Entrar com o navegador" primary button; move the auth-key row into a collapsed `Gtk.Expander`. +- Modify `tests/test_guest_view_a11y_regressions.py` — add static guards for the new empty-state navigation hooks and quality expander. +- Create `tests/test_connect_redesign_regressions.py` — static-source guards covering clipping fix, single column, selector framing, browser-login prominence. + +--- + +## Pre-flight + +- [ ] **Step 0a: Branch (we are on `main`)** + +Run: +```bash +cd /home/bruno/codigo-pacotes/big-remote-play +git checkout -b feat/connect-page-novice-redesign +``` +Expected: `Switched to a new branch 'feat/connect-page-novice-redesign'` + +- [ ] **Step 0b: Baseline green** + +Run: `ruff check src/ tests/ && python -m pytest -q` +Expected: lint clean; `103 passed`. + +--- + +## Task 1: Host-list clipping fix (Discover page) + +**Files:** +- Modify: `src/big_remote_play/ui/guest_view.py` (`create_discover_page`, the `host_scroll` block ~419-425) +- Test: `tests/test_connect_redesign_regressions.py` + +- [ ] **Step 1: Write the failing guard test** + +Create `tests/test_connect_redesign_regressions.py`: +```python +"""Static source guards for the Connect page + Rede Privada novice redesign.""" + +from pathlib import Path + +GUEST = Path("src/big_remote_play/ui/guest_view.py") +MAIN = Path("src/big_remote_play/ui/main_window.py") +PNV = Path("src/big_remote_play/ui/private_network_view.py") + + +def test_host_scroll_has_breathing_room_and_no_tall_min() -> None: + src = GUEST.read_text() + # The host list scroller must not force a tall empty box, and the list must + # have vertical margins so the boxed-list card corners are not clipped. + assert "host_scroll.set_min_content_height(120)" in src + assert "self.hosts_list.set_margin_top(6)" in src + assert "self.hosts_list.set_margin_bottom(6)" in src +``` + +- [ ] **Step 2: Run it, verify it fails** + +Run: `python -m pytest tests/test_connect_redesign_regressions.py::test_host_scroll_has_breathing_room_and_no_tall_min -v` +Expected: FAIL (strings not present yet). + +- [ ] **Step 3: Apply the clipping fix** + +In `create_discover_page`, the host-list margins block currently is: +```python + for m in ["start", "end"]: + getattr(self.hosts_list, f"set_margin_{m}")(12) +``` +Replace with (add top/bottom margins so card corners are not flush with the viewport): +```python + for m in ["start", "end"]: + getattr(self.hosts_list, f"set_margin_{m}")(12) + # Top/bottom margin so the boxed-list card's rounded corners and the + # first/last rows are not clipped by the ScrolledWindow viewport edge. + self.hosts_list.set_margin_top(6) + self.hosts_list.set_margin_bottom(6) +``` +And the `host_scroll` block currently: +```python + host_scroll = Gtk.ScrolledWindow() + host_scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + host_scroll.set_max_content_height(400) + host_scroll.set_min_content_height(200) + host_scroll.set_vexpand(True) + host_scroll.set_propagate_natural_height(True) + host_scroll.set_child(self.hosts_list) +``` +Replace the min height so few hosts/empty state stay compact (grows naturally up to max, scrolls only on real overflow): +```python + host_scroll = Gtk.ScrolledWindow() + host_scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + host_scroll.set_max_content_height(400) + host_scroll.set_min_content_height(120) + host_scroll.set_vexpand(False) + host_scroll.set_propagate_natural_height(True) + host_scroll.set_child(self.hosts_list) +``` + +- [ ] **Step 4: Run the guard + format/lint** + +Run: +```bash +ruff format src/big_remote_play/ui/guest_view.py +ruff check src/big_remote_play/ui/guest_view.py +python -m pytest tests/test_connect_redesign_regressions.py -q +``` +Expected: lint clean; guard PASSES. + +- [ ] **Step 5: Commit** + +```bash +git add src/big_remote_play/ui/guest_view.py tests/test_connect_redesign_regressions.py +git commit -m "fix(connect): stop host-list clipping; compact natural-height scroller" +``` + +--- + +## Task 2: Intent-branched empty state + +**Files:** +- Modify: `src/big_remote_play/ui/guest_view.py` (new `_build_discover_empty_state`; used by `discover_hosts`/`on_hosts_discovered`/`update_hosts_list`) +- Test: `tests/test_connect_redesign_regressions.py`, `tests/test_guest_view_a11y_regressions.py` + +- [ ] **Step 1: Write the failing guard test** + +Append to `tests/test_connect_redesign_regressions.py`: +```python +def test_empty_state_routes_novice_to_real_unlocks() -> None: + src = GUEST.read_text() + assert "_build_discover_empty_state" in src + # Re-scan, Private Network, PIN are all reachable from the empty state. + assert 'navigate_to("vpn_selector")' in src + assert 'self.method_stack.set_visible_child_name("pin")' in src + assert 'self.method_stack.set_visible_child_name("manual")' in src + + +def test_empty_state_button_has_accessible_label() -> None: + src = GUEST.read_text() + # Icon/short-label actions in the empty state expose accessible names. + assert src.count("Gtk.AccessibleProperty.LABEL") >= 4 +``` + +- [ ] **Step 2: Run it, verify it fails** + +Run: `python -m pytest tests/test_connect_redesign_regressions.py -k empty_state -v` +Expected: FAIL. + +- [ ] **Step 3: Add the empty-state builder** + +Add this method to `GuestView` (place it just before `create_discover_page`): +```python + def _go_to_private_network(self) -> None: + root = self._root_window() + if hasattr(root, "navigate_to"): + root.navigate_to("vpn_selector") + + def _build_discover_empty_state(self) -> Gtk.Widget: + """Compact, plain-language empty state that routes a non-technical user to + the path that actually unlocks their case: same-network rescan, Private + Network for internet play (the real enabler), or a friend-dictated PIN. + Manual/IP is the de-emphasized advanced fallback.""" + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=14) + box.add_css_class("helper-card") + box.set_halign(Gtk.Align.CENTER) + box.set_margin_top(8) + box.set_margin_bottom(8) + + title = Gtk.Label(label=_("No host found yet")) + title.add_css_class("title-4") + title.set_halign(Gtk.Align.CENTER) + box.append(title) + + def option(icon_name, text, button_label, accessible, on_click, highlight=False): + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + row.add_css_class("helper-row") + icon = create_icon_widget(icon_name, size=18) + icon.add_css_class("accent" if highlight else "dim-label") + icon.set_valign(Gtk.Align.CENTER) + row.append(icon) + lbl = Gtk.Label(label=text) + lbl.set_halign(Gtk.Align.START) + lbl.set_wrap(True) + lbl.set_hexpand(True) + lbl.set_xalign(0) + row.append(lbl) + btn = Gtk.Button(label=button_label) + btn.add_css_class("pill") + if highlight: + btn.add_css_class("suggested-action") + else: + btn.add_css_class("flat") + btn.set_valign(Gtk.Align.CENTER) + btn.update_property([Gtk.AccessibleProperty.LABEL], [accessible]) + btn.connect("clicked", lambda _b: on_click()) + row.append(btn) + box.append(row) + + option( + "view-refresh-symbolic", + _("Same house or network? Search again."), + _("Search"), + _("Search the local network again"), + self.discover_hosts, + ) + option( + "network-vpn-symbolic", + _("Playing with a friend over the internet? You need a Private Network (just once)."), + _("Set up Private Network"), + _("Open Private Network setup"), + self._go_to_private_network, + highlight=True, + ) + option( + "dialog-password-symbolic", + _("Did the host give you a 6-digit code?"), + _("Connect with PIN"), + _("Switch to PIN connection"), + lambda: self.method_stack.set_visible_child_name("pin"), + ) + option( + "network-wired-symbolic", + _("I know the IP address"), + _("Manual"), + _("Switch to manual connection"), + lambda: self.method_stack.set_visible_child_name("manual"), + ) + return box +``` + +- [ ] **Step 4: Use it everywhere the old placeholder rendered** + +In `discover_hosts` and `on_hosts_discovered`, the current "no hosts" branch builds a `box` with `set_size_request(-1, 150)` and a `title-2` label. Replace those empty/idle renderings so that, when there are no hosts (and not actively scanning), `self.hosts_list` is cleared and the empty-state widget is shown instead of the tall placeholder. Concretely, in `update_hosts_list`, when `hosts` is empty, clear the list and append a single non-selectable row whose child is `self._build_discover_empty_state()`; keep the spinner/"scanning" state unchanged during an active scan. + +Read `update_hosts_list` (guest_view.py ~503-517) and apply: +```python + def update_hosts_list(self, hosts): + while child := self.hosts_list.get_first_child(): + self.hosts_list.remove(child) + if not hosts: + placeholder = Gtk.ListBoxRow() + placeholder.set_selectable(False) + placeholder.set_activatable(False) + placeholder.set_child(self._build_discover_empty_state()) + self.hosts_list.append(placeholder) + self.main_connect_btn.set_sensitive(False) + return + for host in hosts: + self.hosts_list.append(self.create_host_row_custom(host)) +``` +(Keep the existing scanning/spinner path in `discover_hosts`/`on_hosts_discovered`; only the "finished, zero hosts" outcome routes through this empty state. If those methods set a `set_size_request(-1, 150)` placeholder for the empty result, replace that call with `self.update_hosts_list([])`.) + +- [ ] **Step 5: Verify import availability** + +`create_icon_widget` is already imported in `guest_view.py` (used elsewhere). Confirm: +Run: `grep -n "create_icon_widget" src/big_remote_play/ui/guest_view.py | head -1` +Expected: an existing import line. If absent, add `from big_remote_play.utils.icons import create_icon_widget`. + +- [ ] **Step 6: Lint + guards + full suite** + +Run: +```bash +ruff format src/big_remote_play/ui/guest_view.py +ruff check src/big_remote_play/ui/guest_view.py +python -m pytest tests/test_connect_redesign_regressions.py tests/test_guest_view_a11y_regressions.py -q +python -m pytest -q +``` +Expected: lint clean; guards PASS; full suite still green (`>=103 passed`). + +- [ ] **Step 7: Commit** + +```bash +git add src/big_remote_play/ui/guest_view.py tests/test_connect_redesign_regressions.py +git commit -m "feat(connect): intent-branched empty state routes novices to Rede Privada/PIN" +``` + +--- + +## Task 3: Single column + guidance subtitle + footer hint + +**Files:** +- Modify: `src/big_remote_play/ui/guest_view.py` (`create_discover_page` — drop side helper card column; add fixed subtitle + footer line) +- Test: `tests/test_connect_redesign_regressions.py` + +- [ ] **Step 1: Write the failing guard** + +Append: +```python +def test_discover_is_single_column_with_guidance() -> None: + src = GUEST.read_text() + # Side helper-card column removed from the discover page. + assert "create_helper_card(" not in src + # Fixed automatic-discovery guidance subtitle present. + assert "o host aparece aqui automaticamente" in src or "appears here automatically" in src +``` + +- [ ] **Step 2: Run it, verify it fails** + +Run: `python -m pytest tests/test_connect_redesign_regressions.py -k single_column -v` +Expected: FAIL (`create_helper_card(` still present). + +- [ ] **Step 3: Replace the two-column return with a single column + subtitle + footer** + +In `create_discover_page`, the description label currently: +```python + desc = Gtk.Label(label=_("Scroll to list all found devices.")) +``` +Change to the automatic-discovery guidance: +```python + desc = Gtk.Label(label=_("On the local network or with a Private Network set up, the host appears here automatically.")) + desc.set_wrap(True) + desc.set_xalign(0) +``` +Remove the side helper card + columns block at the end: +```python + # Side helper card: what to try if the host doesn't show up (mockup 01). + helper = create_helper_card( + _("If you can't find the host"), + "dialog-question-symbolic", + [ + ("network-wireless-symbolic", _("Check your local network"), _("Make sure the host and this device are on the same Wi-Fi or wired network.")), + ("network-wired-symbolic", _("Use the manual connection"), _("Enter the host IP address or hostname and port to connect directly.")), + ("dialog-password-symbolic", _("Use the PIN code"), _("Ask the host for the PIN code and connect quickly and securely.")), + ], + ) + helper.set_valign(Gtk.Align.START) + helper.set_size_request(280, -1) + + columns = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=18) + columns.append(box) + columns.append(helper) + return columns +``` +Replace with a quiet footer line appended to `box`, then return `box`: +```python + # Quiet footer (shown alongside a populated list): the same two real + # fallbacks the empty state offers, without promoting Manual/IP. + footer = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + footer.set_halign(Gtk.Align.CENTER) + footer.set_margin_top(4) + hint = Gtk.Label(label=_("Can't find your friend's PC?")) + hint.add_css_class("dim-label") + hint.add_css_class("caption") + footer.append(hint) + pn_link = Gtk.Button(label=_("Set up Private Network")) + pn_link.add_css_class("flat") + pn_link.add_css_class("caption") + pn_link.update_property([Gtk.AccessibleProperty.LABEL], [_("Open Private Network setup")]) + pn_link.connect("clicked", lambda _b: self._go_to_private_network()) + footer.append(pn_link) + pin_link = Gtk.Button(label=_("PIN code")) + pin_link.add_css_class("flat") + pin_link.add_css_class("caption") + pin_link.update_property([Gtk.AccessibleProperty.LABEL], [_("Switch to PIN connection")]) + pin_link.connect("clicked", lambda _b: self.method_stack.set_visible_child_name("pin")) + footer.append(pin_link) + box.append(footer) + box.set_hexpand(True) + return box +``` +(Note `box.append(action)` already adds the Connect button area above the footer; keep that ordering: header → host_scroll → action → footer.) + +- [ ] **Step 4: Remove the now-unused `create_helper_card` import if present** + +Run: `grep -n "create_helper_card" src/big_remote_play/ui/guest_view.py` +If the only remaining hit is the import line, remove `create_helper_card` from that import. Re-run ruff to confirm no unused-import warning. + +- [ ] **Step 5: Lint + guards + suite** + +Run: +```bash +ruff format src/big_remote_play/ui/guest_view.py +ruff check src/big_remote_play/ui/guest_view.py +python -m pytest tests/test_connect_redesign_regressions.py -q && python -m pytest -q +``` +Expected: clean; guards PASS; suite green. + +- [ ] **Step 6: Commit** + +```bash +git add src/big_remote_play/ui/guest_view.py tests/test_connect_redesign_regressions.py +git commit -m "feat(connect): single-column discover with guidance subtitle + quiet footer" +``` + +--- + +## Task 4: Collapse client settings into a quality expander + +**Files:** +- Modify: `src/big_remote_play/ui/guest_view.py` (`setup_ui` — wrap the settings rows in an `Adw.ExpanderRow` with a live summary; add `_quality_summary`) +- Test: `tests/test_connect_redesign_regressions.py` + +- [ ] **Step 1: Write the failing guard** + +Append: +```python +def test_client_settings_collapsed_behind_quality_expander() -> None: + src = GUEST.read_text() + assert "Adw.ExpanderRow" in src + assert "_quality_summary" in src + assert "Ajustar qualidade" in src or "Adjust quality" in src +``` + +- [ ] **Step 2: Run it, verify it fails** + +Run: `python -m pytest tests/test_connect_redesign_regressions.py -k quality -v` +Expected: FAIL. + +- [ ] **Step 3: Add a summary helper** + +Add to `GuestView`: +```python + def _quality_summary(self) -> str: + """One-line summary for the collapsed quality expander, e.g. + '1080p · 60 FPS · Áudio'. Reads the current row selections.""" + res_item = self.resolution_row.get_selected_item() + res = res_item.get_string() if res_item is not None else "1080p" + fps_item = self.fps_row.get_selected_item() + fps = fps_item.get_string() if fps_item is not None else "60 FPS" + audio = _("Audio") if self.audio_row.get_active() else _("No audio") + return f"{res} · {fps} · {audio}" +``` + +- [ ] **Step 4: Wrap the settings rows in a collapsed ExpanderRow** + +In `setup_ui`, the rows are currently added directly to `settings_group` (a `PreferencesGroup`). Introduce an `Adw.ExpanderRow` as the single child of `settings_group`, and add each existing row to the expander instead of the group. Concretely: + +After `settings_group` is created and the reset suffix set, insert: +```python + self.quality_expander = Adw.ExpanderRow() + self.quality_expander.set_title(_("Adjust quality")) + self.quality_expander.set_subtitle(self._quality_summary()) + self.quality_expander.set_expanded(False) + settings_group.add(self.quality_expander) +``` +Then change every `settings_group.add()` in this method to `self.quality_expander.add_row()` for: `resolution_row`, `scale_row`, `fps_row`, `apply_settings_btn`, `bitrate_row`, `display_mode_row`, `audio_row`, `hw_decode_row`, and the `advanced_button`. +Finally, keep the summary live: at the end of `setup_ui` (after `connect_settings_signals()`), connect updates: +```python + for _row in (self.resolution_row, self.fps_row): + _row.connect("notify::selected-item", lambda *_a: self.quality_expander.set_subtitle(self._quality_summary())) + self.audio_row.connect("notify::active", lambda *_a: self.quality_expander.set_subtitle(self._quality_summary())) +``` +(`Gtk.Button`/`apply_settings_btn` added via `add_row` renders as a row; if libadwaita rejects a bare button as a row child, wrap it in an `Adw.ActionRow` with the button as suffix. Verify at runtime in Step 6.) + +- [ ] **Step 5: Lint + guard** + +Run: +```bash +ruff format src/big_remote_play/ui/guest_view.py +ruff check src/big_remote_play/ui/guest_view.py +python -m pytest tests/test_connect_redesign_regressions.py -k quality -q +``` +Expected: clean; guard PASS. + +- [ ] **Step 6: Headless construction smoke (catches add_row child errors)** + +Run on the test VM (deploy the file first, see Task 7 deploy step) or locally if a display is available: +```bash +python - <<'PY' +import gi; gi.require_version("Gtk","4.0"); gi.require_version("Adw","1") +from gi.repository import Adw +Adw.init() +from big_remote_play.ui.guest_view import GuestView +GuestView() # constructs setup_ui(); raises if add_row child is invalid +print("OK") +PY +``` +Expected: `OK`. If it raises on `apply_settings_btn`, wrap that button in an `Adw.ActionRow` suffix and re-run. + +- [ ] **Step 7: Full suite + commit** + +```bash +python -m pytest -q +git add src/big_remote_play/ui/guest_view.py tests/test_connect_redesign_regressions.py +git commit -m "feat(connect): collapse client settings into a quality expander with live summary" +``` + +--- + +## Task 5: Rede Privada selector — role framing + collapsed comparison + +**Files:** +- Modify: `src/big_remote_play/ui/main_window.py` (`create_vpn_selector_page`) +- Test: `tests/test_connect_redesign_regressions.py` + +- [ ] **Step 1: Write the failing guard** + +Append: +```python +def test_vpn_selector_has_role_framing_and_collapsed_comparison() -> None: + src = MAIN.read_text() + assert "Gtk.Expander" in src # comparison table is collapsible + assert "Quem tem o jogo cria" in src or "the one with the game creates" in src +``` + +- [ ] **Step 2: Run it, verify it fails** + +Run: `python -m pytest tests/test_connect_redesign_regressions.py -k vpn_selector -v` +Expected: FAIL. + +- [ ] **Step 3: Add the role-framing intro before the cards** + +In `create_vpn_selector_page`, immediately after `box = Gtk.Box(...)` and before `cards_box`, insert: +```python + intro = Gtk.Label( + label=_("To play over the internet, you set up a Private Network once: the one with the game creates it, the friend joins. After that, the PC appears automatically under Connect.") + ) + intro.add_css_class("dim-label") + intro.set_wrap(True) + intro.set_xalign(0) + box.append(intro) +``` + +- [ ] **Step 4: Collapse the comparison table** + +Replace the comparison block: +```python + compare_group = Adw.PreferencesGroup() + compare_group.set_title(_("Quick Comparison")) + compare_group.set_header_suffix(create_icon_widget("preferences-other-symbolic", size=18)) + compare_group.add( + create_comparison_table( ... ) + ) + box.append(compare_group) +``` +with a collapsed `Gtk.Expander` wrapping the same table (keep the `create_comparison_table(...)` call verbatim): +```python + comparison_expander = Gtk.Expander(label=_("Compare the options")) + comparison_expander.set_expanded(False) + comparison_expander.set_child( + create_comparison_table( + ["Tailscale", "ZeroTier", "Headscale"], + [ + (_("Setup difficulty"), [_("Beginner"), _("Intermediate"), _("Advanced")]), + (_("Hosting model"), [_("Cloud (free)"), _("Cloud (free)"), _("Self-hosted")]), + (_("Best for"), [_("Personal use and friends"), _("Flexible networks and teams"), _("Corporate environments")]), + ], + ) + ) + box.append(comparison_expander) +``` + +- [ ] **Step 5: Lint + guard + suite** + +Run: +```bash +ruff format src/big_remote_play/ui/main_window.py +ruff check src/big_remote_play/ui/main_window.py +python -m pytest tests/test_connect_redesign_regressions.py -k vpn_selector -q && python -m pytest -q +``` +Expected: clean; guard PASS; suite green. + +- [ ] **Step 6: Commit** + +```bash +git add src/big_remote_play/ui/main_window.py tests/test_connect_redesign_regressions.py +git commit -m "feat(vpn): plain-language role framing + collapsed comparison on selector" +``` + +--- + +## Task 6: Tailscale form — prominent browser login, auth key advanced + +**Files:** +- Modify: `src/big_remote_play/ui/private_network_view.py` (`_build` / `_build_form`, Tailscale branch) +- Test: `tests/test_connect_redesign_regressions.py` + +The connect/create primary button is `self._btn_action` (created in `_build` ~line 388), wired to `self._on_action` (line 490). `_on_action` reads `self._e_authkey.get_text().strip()` (line 574); an empty key triggers Tailscale browser login. We reuse `_on_action` for the new prominent button — no logic change. + +- [ ] **Step 1: Write the failing guard** + +Append: +```python +def test_tailscale_browser_login_is_prominent_and_key_is_advanced() -> None: + src = PNV.read_text() + assert "Gtk.Expander" in src # auth key tucked behind an advanced disclosure + assert "Entrar com o navegador" in src or "Sign in with browser" in src +``` + +- [ ] **Step 3: Add a prominent browser-login button (Tailscale) and move the key into an Expander** + +In `_build_form`, the Tailscale branch currently: +```python + elif self.vpn_id == "tailscale": + self._e_authkey = Adw.PasswordEntryRow(title=_("Auth Key (optional – leave empty for browser login)")) + self._form_group.add(self._e_authkey) + self._form_rows.append(self._e_authkey) + link = Adw.ActionRow(title=_("Get auth key"), subtitle=_("login.tailscale.com/admin/settings/keys")) + ... +``` +Change so the key + link live inside a collapsed advanced section instead of the main group. Build a `Gtk.Expander` titled `_("I have an auth key")`, set expanded False, put a small box containing `self._e_authkey` (re-titled `_("Auth Key")`) and the existing `link` row, and add that expander to the form area. Keep `self._e_authkey` assigned (the connect handler reads it). + +Then in `_build`, where `self._btn_action` is appended to `btn_box` (~line 393), for the Tailscale provider add a prominent primary button BEFORE `self._btn_action`: +```python + if self.vpn_id == "tailscale": + self._btn_browser = Gtk.Button(label=_("Sign in with browser")) + self._btn_browser.add_css_class("suggested-action") + self._btn_browser.add_css_class("pill") + self._btn_browser.set_size_request(220, 48) + self._btn_browser.update_property([Gtk.AccessibleProperty.LABEL], [_("Sign in with browser")]) + # Browser login = the existing _on_action flow with an empty auth key. + self._btn_browser.connect("clicked", self._on_action) + btn_box.append(self._btn_browser) +``` +`_on_action` (line 490) reads `self._e_authkey` (line 574); since the key now lives in a collapsed advanced expander and starts empty, clicking this button triggers Tailscale browser login with no typing. No logic change. (Append order: place `_btn_browser` before `_btn_action` so the browser button reads as primary; optionally drop the `suggested-action` class from `_btn_action` for Tailscale so there is a single visual primary.) + +- [ ] **Step 4: Lint + guard** + +Run: +```bash +ruff format src/big_remote_play/ui/private_network_view.py +ruff check src/big_remote_play/ui/private_network_view.py +python -m pytest tests/test_connect_redesign_regressions.py -k tailscale -q +``` +Expected: clean; guard PASS. + +- [ ] **Step 5: Headless construction smoke** + +```bash +python - <<'PY' +import gi; gi.require_version("Gtk","4.0"); gi.require_version("Adw","1") +from gi.repository import Adw +Adw.init() +from big_remote_play.ui.private_network_view import PrivateNetworkView +PrivateNetworkView(None, mode="connect", vpn_provider="tailscale") +PrivateNetworkView(None, mode="create", vpn_provider="tailscale") +print("OK") +PY +``` +Expected: `OK` (run on the VM after deploy if no local display). Fix any constructor arg mismatch by matching the real `PrivateNetworkView.__init__` signature. + +- [ ] **Step 6: Full suite + commit** + +```bash +python -m pytest -q +git add src/big_remote_play/ui/private_network_view.py tests/test_connect_redesign_regressions.py +git commit -m "feat(vpn): prominent browser login on Tailscale form; auth key now advanced" +``` + +--- + +## Task 7: Live verification on the test VM + i18n + +**Files:** none (verification). Uses `linux-ui-a11y` `aigui` against `bruno@192.168.1.21` (pass `big`). See `[[test-vm-aigui]]` memory for the single-instance/`.mo` gotchas. + +- [ ] **Step 1: Deploy changed files to the VM package** + +```bash +PKG=/usr/lib/python3.14/site-packages/big_remote_play +for f in ui/guest_view.py ui/main_window.py ui/private_network_view.py; do + sshpass -p big scp -o StrictHostKeyChecking=no "src/big_remote_play/$f" "bruno@192.168.1.21:/tmp/dep_$(basename $f)" + sshpass -p big ssh -o StrictHostKeyChecking=no bruno@192.168.1.21 "sudo cp $PKG/$f $PKG/$f.bak2 2>/dev/null; sudo cp /tmp/dep_$(basename $f) $PKG/$f" +done +sshpass -p big ssh -o StrictHostKeyChecking=no bruno@192.168.1.21 "python -c 'import big_remote_play.ui.guest_view, big_remote_play.ui.main_window, big_remote_play.ui.private_network_view; print(\"IMPORT_OK\")'" +``` +Expected: `IMPORT_OK`. + +- [ ] **Step 2: Kill the single-instance primary (cmdline is `big_remote_play`, not the hyphen)** + +```bash +sshpass -p big ssh -o StrictHostKeyChecking=no bruno@192.168.1.21 'pkill -9 -f big_remote_play; sleep 2; echo "left=$(pgrep -fc big_remote_play)"' +``` +Expected: `left=0`. + +- [ ] **Step 3: Launch fresh (forced PT) and screenshot Connect — empty state** + +```bash +cd /home/bruno/.claude/skills/linux-ui-a11y +export AIGUI_HOST=bruno@192.168.1.21 AIGUI_PASS=big +scripts/aigui launch brpv -- env LANGUAGE=pt LANG=pt_BR.UTF-8 /usr/bin/big-remote-play +sleep 7 +scripts/aigui act "=Conectar ao servidor" >/dev/null 2>&1 || scripts/aigui tap 171 137 +sleep 3 +scripts/aigui --out /tmp/verify_connect_empty.png shot +``` +Then `Read` `/tmp/verify_connect_empty.png`. Expected: single column; compact empty state with the 4 routed options (Procurar, Configurar Rede Privada highlighted, Conectar com PIN, Manual); no clipped card corners; quality settings collapsed under "Ajustar qualidade". + +- [ ] **Step 4: Screenshot Rede Privada selector + Tailscale form** + +```bash +scripts/aigui act "=Configurar Rede Privada" >/dev/null 2>&1 +sleep 3 +scripts/aigui --out /tmp/verify_vpn_selector.png shot +``` +`Read` it. Expected: role-framing intro on top; comparison table collapsed behind "Comparar as opções". Then pick Tailscale and screenshot the form; expect "Entrar com o navegador" prominent and auth key under an "advanced" expander. + +- [ ] **Step 5: Verify host-list NOT clipped with items** + +Inject sample rows via a headless render (same pattern as the earlier peer-row check) OR, if real hosts exist on the VM LAN, screenshot the populated list. Confirm first/last rows and card corners are fully visible. + +- [ ] **Step 6: i18n — confirm new strings extract and are wrapped** + +Run locally: +```bash +grep -nE '_\("(No host found yet|Set up Private Network|Adjust quality|Sign in with browser|Compare the options)' src/big_remote_play/ui/*.py +``` +Expected: each new user-facing string is inside `_()`. (CI auto-translator fills catalogs on push — no manual `.po` edits.) + +- [ ] **Step 7: Restore VM to packaged state (leave clean) — optional** + +The `.bak2` copies are the pre-deploy packaged files. To revert: `sudo cp $PKG/.bak2 $PKG/`. Otherwise leave the improved code deployed. Stop the app: `pkill -9 -f big_remote_play`. + +--- + +## Task 8: Final gate + finish + +- [ ] **Step 1: Whole-project gate** + +Run: +```bash +cd /home/bruno/codigo-pacotes/big-remote-play +ruff check src/ tests/ && ruff format --check src/ tests/ +python -m mypy src/big_remote_play/ui/guest_view.py src/big_remote_play/ui/main_window.py src/big_remote_play/ui/private_network_view.py 2>&1 | tail -15 +python -m pytest -q +``` +Expected: lint clean; mypy introduces no NEW errors vs baseline (pre-existing GUI-binding warnings allowed); all tests pass. + +- [ ] **Step 2: Fresh-eyes diff review** + +Run: `git diff main...HEAD` and re-read each hunk against the spec's success criteria (novice path, clipping, single column, collapsed settings, selector framing, browser login). Scope: correctness + stated requirements only. + +- [ ] **Step 3: Finish the branch** + +Invoke `superpowers:finishing-a-development-branch` to choose merge/PR. Do NOT push or open a PR without explicit user approval (per project rules). + +--- + +## Self-Review (author) + +- **Spec coverage:** clipping (T1), intent-branched empty state → Rede Privada/PIN (T2), single column + guidance subtitle + footer (T3), quality expander (T4), selector role framing + collapsed comparison (T5), Tailscale browser-login + advanced key (T6), live novice verification + i18n (T7), gate (T8). All spec sections mapped. +- **Placeholders:** none — each code step shows the code; navigation hooks (`navigate_to("vpn_selector")`, `set_visible_child_name`) are concrete and verified to exist. +- **Type/name consistency:** `_build_discover_empty_state`, `_go_to_private_network`, `_quality_summary`, `quality_expander`, `method_stack`, `main_connect_btn`, `resolution_row`/`fps_row`/`audio_row` match existing `guest_view.py` symbols. Rede Privada handler `_on_action` (line 490), button `_btn_action` (~388), key `_e_authkey` (574) verified in `private_network_view.py`. No undefined references remain. diff --git a/docs/superpowers/specs/2026-06-25-connect-page-redesign-design.md b/docs/superpowers/specs/2026-06-25-connect-page-redesign-design.md new file mode 100644 index 0000000..d1eb4bb --- /dev/null +++ b/docs/superpowers/specs/2026-06-25-connect-page-redesign-design.md @@ -0,0 +1,172 @@ +# Connect-to-server page redesign — "Descoberta em foco" + +Date: 2026-06-25 +Scope: `src/big_remote_play/ui/guest_view.py` (Connect page) and the Rede Privada +flow — `main_window.create_vpn_selector_page` + `private_network_view.py` (layout/ +copy/prominence only). +Goal: let a non-technical user succeed end-to-end — reduce cognitive load on +"Conectar ao servidor", fix the host-list clipping, and make the Private Network +hand-off (the real enabler for internet play) novice-clear. **No change** to +discovery, connection, reconnect, VPN install/connect, or settings-persistence +logic — reuse existing handlers, rows, and the network paths unchanged. + +## Problems (current state) + +1. **Host-list clipping.** `self.hosts_list` (`Gtk.ListBox` + `boxed-list`) sits + flush inside a fixed `Gtk.ScrolledWindow` (`min_content_height=200`, + `max_content_height=400`). With no inner padding the card's rounded + top/bottom edges and the first/last overflowing rows are visually cut. +2. **Cognitive overload on first load.** Three equally-weighted connection + methods (Descobrir / Manual / Código PIN) as tabs, an always-expanded + 7-row "Configurações do Cliente" group, and a side helper card all compete + for attention before the user does the one thing they came for: pick a host. +3. **No guidance — and worse, the wrong fallback for novices.** Nothing explains + that discovery is automatic only when both PCs share a local network or a + Private Network (VPN). The common case for a non-technical user is "play with + a friend over the internet" — where discovery (and PIN, which uses + broadcast/multicast) finds nothing until a Private Network exists. Pointing + that user to **Manual** (type an IP) sends them to the *most* technical option + they cannot complete. The real unlock is **Rede Privada (VPN) setup**, and the + friendliest connect identifier is the **PIN** (6 digits a friend reads aloud), + not an IP. + +## Decisions (approved) + +- Layout direction: **discovery-first** (list is the centerpiece). +- Manual & PIN: keep the existing `Adw.ViewStack` (lowest risk) but make + **Descobrir** the visually primary tab; the empty-state and a footer line also + route to Manual/PIN. +- Guidance copy: **fixed subtitle** under "Descobrir hosts" + **intent-branched + empty state** that routes the novice to the path that actually unlocks their + case (Private Network for internet play; PIN for a friend-dictated code), + with Manual/IP demoted to an advanced option. +- Client settings: **single collapsed expander** with a live summary. +- **Plain language, minimal jargon:** "VPN" → "Rede Privada"; "host" → "o PC do + amigo / este PC"; avoid "IP" in primary copy (only under the advanced option). + +## Design + +### 1. Method selector (top) +- Reuse `create_stack_tab_strip` over the existing `method_stack` + (discover/manual/pin). Style so Descobrir reads as the default/primary tab; + Manual and Código PIN remain available but visually quieter. +- Keep the existing help button (`help-about-symbolic`) in the switcher bar. + +### 2. Discover page — single column, list-centered +- **Remove the side helper-card column.** The list + primary action take the + full content width (the `Adw.Clamp` max stays ~1040, content single-column). +- Header: `Descobrir hosts` (heading) + fixed subtitle: + *"Na rede local ou com a VPN configurada, o host aparece aqui + automaticamente."* + refresh button (with tooltip + accessible label). +- **Host list — clipping fix:** + - Lower `min_content_height` so few hosts render compact (no tall empty box). + - Add vertical margin/padding inside the scroller so the `boxed-list` card + edges are not flush with the viewport (no clipped corners). + - Keep `propagate_natural_height=True` and a sane `max_content_height` so the + list grows naturally and only scrolls when it genuinely overflows. +- **Empty state — intent-branched (the core novice fix):** replaces the current + tall empty box and the `set_size_request(-1, 150)` placeholder with a compact + card that offers, in plain language, the three real paths in priority order: + - 🏠 *"Estão na mesma casa/rede? Toque em ⟳ para procurar de novo."* + → re-runs `discover_hosts()`. + - 🌐 *"Jogando com um amigo pela internet? Vocês precisam de uma Rede Privada + (uma vez só)."* → **highlighted** button **"Configurar Rede Privada"** → + `self._root_window().navigate_to("vpn_selector")` (guarded with `hasattr`). + This is the actual unlock; after it, hosts appear automatically in Descobrir. + - 🔑 *"O anfitrião te passou um código de 6 dígitos?"* → button + **"Conectar com PIN"** → `method_stack.set_visible_child_name("pin")`. + - Advanced, de-emphasized: *"Sei o endereço IP"* → `method_stack` → manual. +- **Primary action:** the existing `main_connect_btn` ("Conectar"), enabled only + when a host is selected, directly under the list (shown once hosts exist). +- **Footer hint line (when hosts ARE listed):** one quiet line — + *"Não encontrou o PC do amigo? Configure uma Rede Privada · Código PIN"* — + links that route as above. Replaces the removed side helper card. Manual/IP is + not promoted here; it lives in the empty-state advanced option and the switcher. + +### 3. Client settings → collapsed expander +- Replace the always-open `settings_group` body with a single + `Adw.ExpanderRow`-style disclosure titled **"Ajustar qualidade"** and a live + subtitle summary, e.g. `1080p · 60 FPS · Áudio` (built from current + resolution/fps/audio values; updated when they change). +- Expanding reveals the existing rows unchanged: Resolução, Resolução Nativa, + Taxa de Quadros, Bitrate, Modo de Exibição, Áudio, Decodificação de Hardware, + and the "Configurações avançadas do cliente" button. +- Collapsed by default. The reset button stays as the group/expander suffix. +- `apply_settings_btn` ("Aplicar e Reconectar") keeps its current + visible-only-when-connected behavior, inside the expander. + +## Components touched + +- `setup_ui()` — move client settings into the collapsed expander; keep + page composition (perf monitor, switcher box, settings). +- `create_discover_page()` — single column, fixed guidance subtitle, compact + guiding empty state, clipping fix on the host scroller, footer hint line; + drop the side helper-card column. +- `discover_hosts()` / `on_hosts_discovered` / `update_hosts_list()` — adjust the + empty/placeholder rendering to the compact guiding state; discovery logic + unchanged. +- A small helper to compute the quality summary string for the expander subtitle. +- Navigation hooks used by the empty state (all already exist): `discover_hosts()`, + `self._root_window().navigate_to("vpn_selector")` (the main window exposes + `navigate_to`; guard with `hasattr` for non-main roots/tests), + `self.method_stack.set_visible_child_name("pin"|"manual")`. + +Manual page, PIN page, connection handlers, reconnect, and settings persistence +are unchanged. No discovery/PIN/network logic changes — only what the empty state +*points the user toward*. + +## Rede Privada (private network) — novice flow + +The Connect page now routes the "internet" novice to **Configurar Rede Privada**, +so that destination must also be novice-clear. Two light-touch changes +(no change to VPN install/connect logic). + +### A. VPN selector (`main_window.create_vpn_selector_page`) +- **Role framing at the top, plain language:** a short intro before the cards — + *"Para jogar pela internet, vocês criam uma Rede Privada (uma vez só). Quem tem + o jogo cria a rede; o amigo entra. Depois, o PC aparece sozinho em Conectar."* +- **Keep the 3 provider cards** (Tailscale stays "Recomendado · Iniciante"), but + soften jargon in the card descriptions ("VPN de malha", "NAT e firewalls", + "auto-hospedado com DNS Cloudflare" → plainer or moved to the comparison). +- **Collapse the "Comparação rápida" table** behind a disclosure ("▸ Comparar as + opções"), collapsed by default — it invites analysis the novice doesn't need. +- "Como funciona" strip stays. + +### B. Create/Connect form (`private_network_view`, Tailscale path) +- **Make browser login the prominent primary action:** a clear + **"Entrar com o navegador"** button (triggers the existing connect flow with an + empty auth key — the path already supported). No typing for the novice. +- **Demote the auth key:** move `_e_authkey` ("Auth Key") into an **advanced, + collapsed** disclosure ("▸ Tenho uma chave de autenticação"). Keep the "Get auth + key" link inside it. +- ZeroTier/Headscale forms unchanged in fields (their audiences are advanced); they + still benefit from the role-framing intro on the selector. + +Scope of Rede Privada changes is **layout/copy/prominence only** — VPN install, +connect, history, and status logic are unchanged. + +## Out of scope + +- Discovery/connection/reconnect logic, network code, settings storage. +- Manual and PIN page internals (only their prominence in the switcher changes). +- Translations: any new/changed strings go through `_()`; the CI auto-translator + fills catalogs on push (existing project workflow). + +## Success criteria + +- Host list shows no clipped card corners or cut rows for 0, 1, few, and many + hosts (verified via live screenshots on the test VM). +- First load presents one clear path (pick a host → Conectar); secondary methods + and quality settings are present but not competing for first attention. +- **Novice success path:** a user who doesn't understand networks, playing with a + friend over the internet, reaches a working connection without typing an IP — + the empty state routes them to "Configurar Rede Privada" (one-time) or to PIN, + in plain language. Manual/IP never blocks the primary path. +- Rede Privada: selector leads with a plain-language role framing; the + recommended path (Tailscale) is obvious; the comparison table no longer demands + attention up front; on the Tailscale form, browser login is the prominent + action and the auth key is tucked behind "advanced". +- No regression in existing tests (`test_guest_view_a11y_regressions.py`, + `test_drop_guest_validation.py`, CSS/premium-layout regressions, and any + private-network tests); icon-only controls keep accessible names/tooltips; + changed/new strings go through `_()`. From dd5546717bb433efedcdeb12d3cd6434609f3912 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Gon=C3=A7alves?= Date: Thu, 25 Jun 2026 08:25:55 -0300 Subject: [PATCH 46/47] fix(vpn): open Tailscale auth URL in user's browser, not as root Root (bigsudo) cannot open a desktop browser. Capture the login URL from 'tailscale up', emit it as BRP_DATA LOGIN_URL, and open it in the user's session via open_uri. Verified live: Brave opens as user with the auth URL. Claude-Session: https://claude.ai/code/session_01DhGCL133apRAwBMk6dktHC --- .../ui/private_network_view.py | 4 ++++ tests/test_connect_redesign_regressions.py | 10 ++++++++ .../scripts/create-network_tailscale.sh | 23 +++++++++++++++---- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/big_remote_play/ui/private_network_view.py b/src/big_remote_play/ui/private_network_view.py index 1b013b4..e394736 100644 --- a/src/big_remote_play/ui/private_network_view.py +++ b/src/big_remote_play/ui/private_network_view.py @@ -678,6 +678,10 @@ def run(): if kind[0] == "data": if kind[2]: captured[kind[1]] = kind[2] + # The script runs as root (bigsudo) and cannot open a + # browser; open the auth URL in the user's session here. + if kind[1] == "LOGIN_URL": + GLib.idle_add(open_uri, kind[2]) elif kind[0] == "phase": phase = kind[1] self._progress.update(phase, "") diff --git a/tests/test_connect_redesign_regressions.py b/tests/test_connect_redesign_regressions.py index b26733b..7cbf83d 100644 --- a/tests/test_connect_redesign_regressions.py +++ b/tests/test_connect_redesign_regressions.py @@ -86,3 +86,13 @@ def test_vpn_selector_cards_show_install_badge() -> None: src = MAIN.read_text() assert "has_tailscale" in src and "has_docker" in src assert "Will be installed" in src + + +def test_tailscale_browser_login_opens_url_as_user_not_root() -> None: + script = Path("usr/share/big-remote-play/scripts/create-network_tailscale.sh").read_text() + # Script must emit the auth URL as a marker, not try to open a root browser. + assert "BRP_DATA LOGIN_URL=" in script + pnv = PNV.read_text() + # App opens the captured URL in the user's session. + assert 'kind[1] == "LOGIN_URL"' in pnv + assert "open_uri" in pnv diff --git a/usr/share/big-remote-play/scripts/create-network_tailscale.sh b/usr/share/big-remote-play/scripts/create-network_tailscale.sh index d006dbd..b83aea5 100755 --- a/usr/share/big-remote-play/scripts/create-network_tailscale.sh +++ b/usr/share/big-remote-play/scripts/create-network_tailscale.sh @@ -122,22 +122,35 @@ login_tailscale() { echo -e "${GREEN}$(gettext 'Starting browser login...')${NC}" echo -e "${YELLOW}$(gettext 'A URL will open in your browser. Log in with your account.')${NC}" - if sudo tailscale up --reset 2>&1 | grep -q "https://"; then + # `tailscale up` (run as root via bigsudo) prints the auth URL then blocks + # waiting for authentication; opening a browser as root does not work on a + # desktop. So run it in the background, capture the URL, and emit it as a + # BRP_DATA marker — the app opens it in the *user's* browser. + TS_OUT=$(mktemp "${TMPDIR:-/tmp}/brp-tailscale-up.XXXXXX") + # Script already runs as root (bigsudo); no inner sudo so the redirect is root-owned. + tailscale up --reset >"$TS_OUT" 2>&1 & + url="" + for _ in {1..40}; do + url=$(grep -oE 'https://login\.tailscale\.com/[A-Za-z0-9/_.-]+' "$TS_OUT" | head -1) + [ -n "$url" ] && break + sleep 0.5 + done + if [ -n "$url" ]; then + echo "BRP_DATA LOGIN_URL=$url" echo -e "${GREEN}$(gettext 'Login URL generated. Follow the instructions in the browser.')${NC}" - else - sudo tailscale login fi echo -e "${YELLOW}$(gettext 'Waiting for authentication...')${NC}" - for _ in {1..15}; do + for _ in {1..60}; do sleep 2 - if sudo tailscale status &>/dev/null; then + if sudo tailscale status 2>/dev/null | grep -qv "Logged out" && sudo tailscale status &>/dev/null; then echo -e "${GREEN}✓ $(gettext 'Login confirmed!')${NC}" break fi echo -n "." done echo "" + rm -f "$TS_OUT" 2>/dev/null || true ;; 2) echo -e "${CYAN}$(gettext 'Enter your auth key:')${NC}" From b057ecd22ff1f9e248d011db00d282f41de05e00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Gon=C3=A7alves?= Date: Thu, 25 Jun 2026 08:32:27 -0300 Subject: [PATCH 47/47] refactor(vpn): replace BigLinux bigsudo with pkexec (cross-distro elevation) pkexec (polkit) works on any distro with a polkit agent; matches the existing pkexec usage for drop_guest/firewall. Absolute program paths (/usr/bin/env, /usr/bin/systemctl, /usr/bin/tailscale). Verified live: install + browser login elevate via pkexec and open the auth URL in the user's browser. Claude-Session: https://claude.ai/code/session_01DhGCL133apRAwBMk6dktHC --- src/big_remote_play/ui/main_window.py | 6 +++--- src/big_remote_play/ui/private_network_view.py | 15 ++++++++------- tests/test_connect_redesign_regressions.py | 10 ++++++++++ .../scripts/create-network_tailscale.sh | 4 ++-- usr/share/big-remote-play/scripts/install-vpn.sh | 2 +- 5 files changed, 24 insertions(+), 13 deletions(-) diff --git a/src/big_remote_play/ui/main_window.py b/src/big_remote_play/ui/main_window.py index 1f4072f..02ec517 100644 --- a/src/big_remote_play/ui/main_window.py +++ b/src/big_remote_play/ui/main_window.py @@ -746,12 +746,12 @@ def _disconnect_vpn(self, vpn_id): def run(): if vpn_id in ("headscale", "tailscale"): - subprocess.run(["bigsudo", "tailscale", "logout"], timeout=30) + subprocess.run(["pkexec", "/usr/bin/tailscale", "logout"], timeout=30) elif vpn_id == "zerotier": # Local ZT disconnection is a bit trickier, # usually means leaving all networks or stopping the service # For simplicity, we can try to leave networks found in history or just stop service - subprocess.run(["bigsudo", "systemctl", "stop", "zerotier-one"], timeout=30) + subprocess.run(["pkexec", "/usr/bin/systemctl", "stop", "zerotier-one"], timeout=30) GLib.idle_add(lambda: self.show_toast(_("{} disconnected").format(VPN_PROVIDERS[vpn_id]["name"]))) threading.Thread(target=run, daemon=True).start() @@ -1124,7 +1124,7 @@ def find_moonlight(): current_type = force_type or m["type"] if current_type == "service": - cmd = ["bigsudo", "systemctl"] + cmd = ["pkexec", "/usr/bin/systemctl"] if m.get("user"): cmd = ["systemctl", "--user"] cmd.append(action) diff --git a/src/big_remote_play/ui/private_network_view.py b/src/big_remote_play/ui/private_network_view.py index e394736..fb52ae0 100644 --- a/src/big_remote_play/ui/private_network_view.py +++ b/src/big_remote_play/ui/private_network_view.py @@ -657,8 +657,9 @@ def _run_script(self, script_name, inputs, on_done): def run(): # LC_ALL=C / LANGUAGE= force the script's gettext prose back to its # English msgid; data is parsed from locale-independent BRP_* markers. - # `env` is passed through bigsudo so it survives privilege elevation. - proc = subprocess.Popen(["bigsudo", "env", "LC_ALL=C", "LANGUAGE=", script], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1) + # pkexec (polkit) elevates on any distro; /usr/bin/env applies the vars + # in the elevated child (pkexec sanitises the environment). + proc = subprocess.Popen(["pkexec", "/usr/bin/env", "LC_ALL=C", "LANGUAGE=", script], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1) proc_stdin = proc.stdin if proc_stdin is not None: for s in inputs: @@ -678,7 +679,7 @@ def run(): if kind[0] == "data": if kind[2]: captured[kind[1]] = kind[2] - # The script runs as root (bigsudo) and cannot open a + # The script runs as root (pkexec) and cannot open a # browser; open the auth URL in the user's session here. if kind[1] == "LOGIN_URL": GLib.idle_add(open_uri, kind[2]) @@ -1146,7 +1147,7 @@ def _on_logout(self, btn): if self.vpn_id == "tailscale": def do_logout(): - subprocess.run(["bigsudo", "tailscale", "logout"], timeout=30) + subprocess.run(["pkexec", "/usr/bin/tailscale", "logout"], timeout=30) def finish_logout(): self.main_window.show_toast(_("Tailscale disconnected")) @@ -1166,14 +1167,14 @@ def finish_logout(): self.main_window.show_toast(_("ZeroTier token removed")) def do_stop(): - subprocess.run(["bigsudo", "systemctl", "stop", "zerotier-one"], timeout=30) + subprocess.run(["pkexec", "/usr/bin/systemctl", "stop", "zerotier-one"], timeout=30) GLib.idle_add(self._refresh_networks) threading.Thread(target=do_stop, daemon=True).start() elif self.vpn_id == "headscale": def do_logout(): - subprocess.run(["bigsudo", "tailscale", "logout"], timeout=30) + subprocess.run(["pkexec", "/usr/bin/tailscale", "logout"], timeout=30) def finish_logout(): self.main_window.show_toast(_("Disconnected from Headscale")) @@ -1718,7 +1719,7 @@ def run(): except Exception: pass # LC_ALL=C: parse locale-independent BRP_* markers, not localized prose. - proc = subprocess.Popen(["bigsudo", "env", "LC_ALL=C", "LANGUAGE=", spath], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1) + proc = subprocess.Popen(["pkexec", "/usr/bin/env", "LC_ALL=C", "LANGUAGE=", spath], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1) proc_stdin = proc.stdin if proc_stdin is not None: for s in inputs: diff --git a/tests/test_connect_redesign_regressions.py b/tests/test_connect_redesign_regressions.py index 7cbf83d..ba3013d 100644 --- a/tests/test_connect_redesign_regressions.py +++ b/tests/test_connect_redesign_regressions.py @@ -96,3 +96,13 @@ def test_tailscale_browser_login_opens_url_as_user_not_root() -> None: # App opens the captured URL in the user's session. assert 'kind[1] == "LOGIN_URL"' in pnv assert "open_uri" in pnv + + +def test_no_bigsudo_uses_pkexec_for_cross_distro() -> None: + for p in (MAIN, PNV): + src = p.read_text() + assert "bigsudo" not in src, f"{p} still uses bigsudo" + assert "pkexec" in PNV.read_text() + # Privilege-elevation scripts no longer reference the BigLinux-only helper. + for s in ("install-vpn.sh", "create-network_tailscale.sh"): + assert "bigsudo" not in Path(f"usr/share/big-remote-play/scripts/{s}").read_text() diff --git a/usr/share/big-remote-play/scripts/create-network_tailscale.sh b/usr/share/big-remote-play/scripts/create-network_tailscale.sh index b83aea5..ebd680d 100755 --- a/usr/share/big-remote-play/scripts/create-network_tailscale.sh +++ b/usr/share/big-remote-play/scripts/create-network_tailscale.sh @@ -122,12 +122,12 @@ login_tailscale() { echo -e "${GREEN}$(gettext 'Starting browser login...')${NC}" echo -e "${YELLOW}$(gettext 'A URL will open in your browser. Log in with your account.')${NC}" - # `tailscale up` (run as root via bigsudo) prints the auth URL then blocks + # `tailscale up` (run as root via pkexec) prints the auth URL then blocks # waiting for authentication; opening a browser as root does not work on a # desktop. So run it in the background, capture the URL, and emit it as a # BRP_DATA marker — the app opens it in the *user's* browser. TS_OUT=$(mktemp "${TMPDIR:-/tmp}/brp-tailscale-up.XXXXXX") - # Script already runs as root (bigsudo); no inner sudo so the redirect is root-owned. + # Script already runs as root (pkexec); no inner sudo so the redirect is root-owned. tailscale up --reset >"$TS_OUT" 2>&1 & url="" for _ in {1..40}; do diff --git a/usr/share/big-remote-play/scripts/install-vpn.sh b/usr/share/big-remote-play/scripts/install-vpn.sh index 45ae287..8943f76 100644 --- a/usr/share/big-remote-play/scripts/install-vpn.sh +++ b/usr/share/big-remote-play/scripts/install-vpn.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Install a VPN provider's package via pacman and enable its service. -# Invoked with elevated privilege (bigsudo). Argument: provider id. +# Invoked with elevated privilege (pkexec). Argument: provider id. # # Emits machine-readable markers (see script_protocol.py): # BRP_PHASE progress 0..1