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/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 `_()`. 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/locale/big-remote-play.pot b/locale/big-remote-play.pot index 4afd4ef..f1cb777 100644 --- a/locale/big-remote-play.pot +++ b/locale/big-remote-play.pot @@ -1,4592 +1,5357 @@ # 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:47-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:81 +#: 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:89 +#: src/big_remote_play/ui/preferences.py:92 +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: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 "Copy Path" +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 "Clear" +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..4d26866 100644 --- a/locale/pt.po +++ b/locale/pt.po @@ -2,1832 +2,3567 @@ # 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: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" "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 "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 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" +"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 "Erro de confiança no certificado: {}" + +#: src/big_remote_play/host/sunshine_manager.py:349 +#, python-brace-format +msgid "Sunshine API request failed: {}" +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 "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/installer_window.py, line: 30 -msgid "Installing required components..." -msgstr "Instalando componentes necessários..." +# 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 "O Sunshine rejeitou o PIN" + +# 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 "Erro de API: {}" + +#: src/big_remote_play/host/sunshine_manager.py:395 +msgid "Authentication Failed. Check the current password." +msgstr "Falha na autenticação. Verifique a senha atual." + +#: src/big_remote_play/host/sunshine_manager.py:399 +msgid "Sunshine rejected the credentials" +msgstr "O Sunshine rejeitou as credenciais" + +#: src/big_remote_play/host/sunshine_manager.py:402 +msgid "Credentials updated successfully" +msgstr "Credenciais atualizadas com sucesso" + +#: src/big_remote_play/host/sunshine_manager.py:417 +msgid "Username and password cannot be empty" +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 "Executável do Sunshine não encontrado" + +#: src/big_remote_play/host/sunshine_manager.py:426 +msgid "Credentials reset successfully" +msgstr "Credenciais redefinidas com sucesso" + +#: src/big_remote_play/host/sunshine_manager.py:428 +#, python-brace-format +msgid "Reset failed: {}" +msgstr "Falha ao redefinir: {}" + +#: src/big_remote_play/host/sunshine_manager.py:428 +msgid "Reset failed" +msgstr "Falha ao redefinir" + +#: src/big_remote_play/host/sunshine_manager.py:430 +#, python-brace-format +msgid "Reset error: {}" +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 +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/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 "" -"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/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/installer_window.py, line: 67 -msgid "Starting..." -msgstr "Iniciando..." +# 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/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/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/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/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/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/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/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/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/installer_window.py, line: 85 -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/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 "Configurações avançadas do cliente" + +#: src/big_remote_play/ui/guest_view.py:146 +msgid "Input, controller, codec and more" +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 +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 +#: 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/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" +# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# # -# 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/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/installer_window.py, line: 86 +# 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 "Verifique sua rede local" + +#: src/big_remote_play/ui/guest_view.py:320 msgid "" -"Execute manually:\n" -"{}" +"Make sure the host and this device are on the same Wi-Fi or wired network." msgstr "" -"Executar manualmente:\n" -"{}" -# +"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 "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" +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 "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 +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/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/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/installer_window.py, line: 113 +# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# # -# 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/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 "" +"Could not pair with host.\n" +"Verify the PIN was entered correctly." +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 +#: 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 "" +"Moonlight needs to be paired.\n" +"\n" +"{}" +msgstr "" +"A luz da lua precisa ser emparelhada.\n" +"\n" +"{}" + +# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# # -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 22 -msgid "Preferências" -msgstr "Preferências" +# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# # -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 38 -msgid "Geral" -msgstr "Geral" +# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# # -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 43 -msgid "Aparência" -msgstr "Aparência" +# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# # -# 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/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 "" +"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 "" +"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 "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 +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) #-#-#-#-# # -# 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" +# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# # -# File: big-remote-play/usr/share/big-remote-play/ui/preferences.py, line: 51 -msgid "Automático" +# 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/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 "Não" + +#: src/big_remote_play/ui/guest_view.py:972 +msgid "Yes" +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/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/guest_view.py, line: 913 # -# 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/guest_view.py, line: 923 # -# 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/guest_view.py, line: 923 # -# 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/guest_view.py, line: 923 # -# 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/guest_view.py, line: 923 # -# 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/guest_view.py, line: 923 # -# 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/guest_view.py, line: 923 # -# 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/guest_view.py, line: 923 # -# 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/guest_view.py, line: 923 # -# 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/guest_view.py, line: 923 # -# 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/guest_view.py, line: 923 # -# 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/guest_view.py, line: 923 # -# 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/guest_view.py, line: 923 # -# 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." +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 # -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# +# 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/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 +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 # -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# +# 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/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 +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 923 # -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# +# 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/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 +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 816 # -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# +# 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/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 +# File: big-remote-play/usr/share/big-remote-play/ui/guest_view.py, line: 816 # -# #-#-#-#-# big-remote-play.pot (big-remote-play) #-#-#-#-# +# 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/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 -# -# #-#-#-#-# 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 -# -# #-#-#-#-# 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: 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: 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: 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 +# 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 "" -"Todas as suas modificações no Big Remote Play serão restauradas! Isso inclui as configurações do " -"Sunshine e Moonlight." +"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 "" -"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 +"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 "" -"ATENÇÃO: Isso apagará TODOS os dados, logs, configurações e servidores salvos. O aplicativo será " -"fechado." +"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 "" -"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)" +"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 "Procurar executável no host" + +#: src/big_remote_play/ui/host_view.py:203 +msgid "Browse host filesystem" +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 +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" + +# #-#-#-#-# 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/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/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 "" -"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 +"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 "Gerar PIN" + +#: src/big_remote_play/ui/host_view.py:416 +msgid "Generate a PIN for a guest" +msgstr "Gerar um PIN para um convidado" + +#: 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 "Ver, desativar ou remover clientes pareados" + +#: 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 "Ver o log do servidor Sunshine" + +#: 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 "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 "Senha do servidor" + +#: src/big_remote_play/ui/host_view.py:466 +msgid "Change or reset the Sunshine login (if you forgot it)" +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 +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 "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 "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 "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 +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 "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 "Inicie o servidor para transmitir." + +# 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 "FPS" + +#: src/big_remote_play/ui/host_view.py:636 +msgid "Bandwidth" +msgstr "Largura de banda" + +#: src/big_remote_play/ui/host_view.py:650 msgid "" -"The certificate used for the web UI and Moonlight client pairing. For best compatibility, this " -"should have an RSA-2048 public key." +"Keep your server and network secure. Use a VPN when " +"sharing games." 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 +"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 +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: 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 + +# 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 "" +"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 "Pronto para receber conexões." + +#: src/big_remote_play/ui/host_view.py:1161 +msgid "Sunshine active" +msgstr "Sunshine ativo" + +#: src/big_remote_play/ui/host_view.py:1170 +msgid "Stop server" +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 +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 "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 "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 +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" +"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/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/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 "" +"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 "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 "Carregando…" + +#: src/big_remote_play/ui/host_view.py:1923 +msgid "No paired devices" +msgstr "Nenhum dispositivo pareado" + +#: src/big_remote_play/ui/host_view.py:1930 +msgid "Unknown device" +msgstr "Dispositivo desconhecido" + +#: src/big_remote_play/ui/host_view.py:1937 +msgid "Device enabled" +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 "Remover dispositivo" + +#: src/big_remote_play/ui/host_view.py:1961 +msgid "Failed to update device" +msgstr "Falha ao atualizar o dispositivo" + +#: src/big_remote_play/ui/host_view.py:1967 +msgid "Remove Device" +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 "Remover “{}”? Será necessário parear novamente com um novo PIN." + +#: src/big_remote_play/ui/host_view.py:1972 +msgid "Remove" +msgstr "Remover" + +#: src/big_remote_play/ui/host_view.py:1996 +#: src/big_remote_play/ui/host_view.py:2001 +msgid "Devices updated" +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 "A operação falhou" + +#: src/big_remote_play/ui/host_view.py:2019 +msgid "Refresh" +msgstr "Atualizar" + +#: src/big_remote_play/ui/host_view.py:2020 +msgid "Refresh logs" +msgstr "Atualizar logs" + +#: src/big_remote_play/ui/host_view.py:2027 +msgid "Copy" +msgstr "Copiar" + +#: src/big_remote_play/ui/host_view.py:2028 +msgid "Copy logs" +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 "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 "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 "Adicionar jogos detectados" + +#: src/big_remote_play/ui/host_view.py:2151 +msgid "No apps configured" +msgstr "Nenhum aplicativo configurado" + +#: src/big_remote_play/ui/host_view.py:2159 +msgid "Unnamed" +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 "Remover aplicativo" + +#: src/big_remote_play/ui/host_view.py:2213 +#, python-brace-format +msgid "Added {} game(s)" +msgstr "{} jogo(s) adicionado(s)" + +#: src/big_remote_play/ui/host_view.py:2218 +msgid "Library updated" +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 "Selecionar executável" + +#: src/big_remote_play/ui/host_view.py:2272 +msgid "Cannot browse (no access or credentials)" +msgstr "Não é possível navegar (sem acesso ou credenciais)" + +#: src/big_remote_play/ui/host_view.py:2280 +msgid "Up one level" +msgstr "Subir um nível" + +#: src/big_remote_play/ui/host_view.py:2307 +#, python-brace-format +msgid "Selected: {}" +msgstr "Selecionado: {}" + +#: 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 "" +"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 "Esqueci a senha atual" + +#: src/big_remote_play/ui/host_view.py:2335 +msgid "Reset directly; the server will restart" +msgstr "Redefinir diretamente; o servidor será reiniciado" + +#: src/big_remote_play/ui/host_view.py:2336 +msgid "Current Username" +msgstr "Usuário atual" + +#: src/big_remote_play/ui/host_view.py:2338 +msgid "Current Password" +msgstr "Senha atual" + +#: src/big_remote_play/ui/host_view.py:2341 +msgid "New Username" +msgstr "Novo usuário" + +#: src/big_remote_play/ui/host_view.py:2344 +msgid "Confirm New Password" +msgstr "Confirmar nova senha" + +# 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" + +#: src/big_remote_play/ui/host_view.py:2366 +msgid "Username and password cannot be empty." +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 "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 +#: 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:81 +#: 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/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/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 "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/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/installer_window.py, +# line: 113 # -# 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/installer_window.py, +# line: 113 # -# 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/installer_window.py, +# line: 113 # -# 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/installer_window.py, +# line: 113 # -# 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/installer_window.py, +# line: 113 # -# 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/installer_window.py, +# line: 113 # -# 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/installer_window.py, +# line: 113 # -# 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/installer_window.py, +# line: 113 # -# 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/installer_window.py, +# line: 113 # -# 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!" -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." -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." -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" -# -# #-#-#-#-# 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 -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/installer_window.py, +# line: 113 # -# 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/installer_window.py, +# line: 113 # -# 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/installer_window.py, +# line: 113 # -# 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/installer_window.py, +# line: 113 # -# 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/installer_window.py, +# line: 113 # -# 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/installer_window.py, +# line: 113 # -# 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/installer_window.py, +# line: 113 # -# 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/installer_window.py, +# line: 113 # -# 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/installer_window.py, +# line: 113 # -# 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/installer_window.py, +# line: 113 # -# 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/installer_window.py, +# line: 113 # -# 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/installer_window.py, +# line: 113 # -# 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/installer_window.py, +# line: 113 # -# 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/installer_window.py, +# line: 113 # -# 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." +# 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 "" -"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)." +"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 "" -"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." +"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 "" -"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" +"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" + +#: 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/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/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" + +# 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/sunshine_preferences.py, line: 326 -msgid "FEC Percentage" -msgstr "Porcentagem de FEC" +# 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/sunshine_preferences.py, line: 326 +# 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 "Jogue junto, de qualquer lugar" + +#: src/big_remote_play/ui/main_window.py:477 +msgid "Application menu" +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 +#: 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 "" -"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." +"Select a VPN solution to create or join a Private Network. Your choice will " +"be saved and shown in the sidebar menu." 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 -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 -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." +"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" + +#: 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 +#: 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 "Autenticar" + +#: 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 "Jogar" + +#: 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 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 "" -"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)" +"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: 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: 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: {}" + +# 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: 73 +#: src/big_remote_play/ui/moonlight_preferences.py:25 +msgid "Basic Settings" +msgstr "Configurações Básicas" + +# 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" + +# 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 +#: 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 +#: 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 +#: 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 +#: 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 +#: 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 +#: 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 +#: 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 +#: 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 +#: 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 +#: 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 +#: 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 +#: 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 +#: 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 +#: 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 +#: 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 +#: 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 +#: 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 +#: 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 +#: 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 +#: 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 +#: 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 +#: 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." + +# 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 +#: 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 +#: src/big_remote_play/ui/moonlight_preferences.py:132 +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: 331 +# 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 +#: 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 +#: 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 +#: 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 +#: 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 +#: 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 +#: 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 +#: 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 +#: 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 +#: 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/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 "Desconectar convidado" + +#: src/big_remote_play/ui/performance_monitor.py:500 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." +"End the running game gently, or forcefully evict the network address? Ending" +" the game keeps the device paired." 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 +"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 "Encerrar jogo" + +#: src/big_remote_play/ui/performance_monitor.py:511 +msgid "Evict IP" +msgstr "Expulsar IP" + +# 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 "Desconectar convidado" + +# 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" + +#: src/big_remote_play/ui/preferences.py:89 +#: src/big_remote_play/ui/preferences.py:92 +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" + +#: 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" + +#: src/big_remote_play/ui/preferences.py:119 +msgid "Copy Path" +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" + +#: src/big_remote_play/ui/preferences.py:140 +msgid "Clear" +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 "Caminho copiado!" + +#: src/big_remote_play/ui/preferences.py:200 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." +"All your changes in Big Remote Play will be restored! This includes Sunshine" +" and Moonlight settings." 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." +"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 "Restaurar padrões?" + +#: src/big_remote_play/ui/preferences.py:234 +msgid "Settings restored! Restart the application to apply all changes." 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 +"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 "Erro ao restaurar: {}" + +#: src/big_remote_play/ui/preferences.py:245 +msgid "Clear EVERYTHING?" +msgstr "Limpar TUDO?" + +#: src/big_remote_play/ui/preferences.py:245 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." +"WARNING: This will delete ALL data, logs, settings and saved servers. The " +"application will close." 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." +"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 "Excluir tudo" + +#: src/big_remote_play/ui/preferences.py:254 +msgid "Are You Absolutely Sure?" +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 "Esta ação é IRREVERSÍVEL. Você perderá todos os dados configurados." + +#: src/big_remote_play/ui/preferences.py:256 +msgid "Yes, Delete All" +msgstr "Sim, excluir tudo" + +#: src/big_remote_play/ui/preferences.py:299 +msgid "Error Clearing" +msgstr "Erro ao limpar" + +# 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 "" -"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 -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 -msgid "Spatial AQ" -msgstr "AQ Espacial" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 354 -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 -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." -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" -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." -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." -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 -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 -msgid "SW Presets" -msgstr "Predefinições de SW" -# -# File: big-remote-play/usr/share/big-remote-play/ui/sunshine_preferences.py, line: 413 -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 -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 -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 +"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 + +# 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 +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 + +# 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 + +# 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 + +# 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 +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 + +# 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 + +# 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 + +# 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 +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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 +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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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" -# -# 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:613 +#: src/big_remote_play/ui/private_network_view.py:1533 +msgid "System keyring is unavailable. Token was not saved." +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 +#: 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 +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 + +# 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 + +# 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" @@ -1838,32 +3573,46 @@ msgstr "" "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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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" @@ -1878,33 +3627,48 @@ msgstr "" "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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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" @@ -1913,13 +3677,19 @@ 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 + +# 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 + +# 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" @@ -1940,57 +3710,46 @@ msgstr "" " • 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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/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 + +# 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 + +# 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" @@ -2003,12 +3762,16 @@ msgstr "" "• 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 + +# 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 + +# 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" @@ -2021,2185 +3784,3445 @@ msgstr "" "• Nome: www\n" "• Destino: @\n" "• Proxy: DESLIGADO" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 775 + +# 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)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 778 + +#: src/big_remote_play/ui/private_network_view.py:854 msgid "" -"Open ports 8080, 9443, 41641. Note: The installation script will attempt to configure these " -"automatically via UPnP." +"Open ports 80, 443, and 41641. The installation script will attempt to " +"configure these automatically via UPnP." 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 +"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 +#: 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 + +# 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'" -# -# 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 + +#: src/big_remote_play/ui/private_network_view.py:863 +msgid "TLS certificate challenge and HTTP redirect" +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 "API e interface web seguras do Headscale" + +# 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" -# -# 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:870 +msgid "Forward to your local IP" +msgstr "Encaminhar para o seu IP local" + +# 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 + +# 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 +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" -# -# 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 + +#: 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 "1. Criar conta" + +#: src/big_remote_play/ui/private_network_view.py:896 +msgid "Tailscale Registration" +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 "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 "2. Login no host" + +#: src/big_remote_play/ui/private_network_view.py:902 +msgid "Link this device" +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 "3. Painel de administração" + +#: src/big_remote_play/ui/private_network_view.py:905 +msgid "Manage Machines" +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 "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 "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 +#: 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" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 826 -msgid "Acessar ZeroTier Central" + +#: src/big_remote_play/ui/private_network_view.py:918 +msgid "Access 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 + +#: src/big_remote_play/ui/private_network_view.py:918 +msgid "Sign up to create and manage your virtual networks." +msgstr "Cadastre-se para criar e gerenciar suas redes virtuais." + +#: src/big_remote_play/ui/private_network_view.py:918 +msgid "Open Portal" +msgstr "Abrir portal" + +#: src/big_remote_play/ui/private_network_view.py:920 +msgid "2. Authentication" +msgstr "2. Autenticação" + +#: src/big_remote_play/ui/private_network_view.py:921 +msgid "Generate API Token" +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 "Gerar token" + +#: src/big_remote_play/ui/private_network_view.py:927 +msgid "3. Configuration" +msgstr "3. Configuração" + +#: src/big_remote_play/ui/private_network_view.py:927 +msgid "Link Network" +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 "4. Administração" + +#: src/big_remote_play/ui/private_network_view.py:930 +msgid "Authorize Members" +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 "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 +#: 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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: 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 + +# 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 + +# 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 + +# 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 + +# 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:" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 865 +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 + +# 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: 1092 -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1859 -msgid "Save" -msgstr "Salvar" -# -# File: big-remote-play/usr/share/big-remote-play/ui/private_network_view.py, line: 1095 + +# 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 + +# 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 + +# 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 + +# 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: 863 + +# 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" -# -# 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: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 "Internet funcionando" + +#: src/big_remote_play/ui/private_network_view.py:1293 +msgid "An internet connection is required to establish the link." +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 +# 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" -# -# #-#-#-#-# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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: 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 + +# 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 + +# 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 + +# 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)" -# -# 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:1455 +msgid "e.g. a1b2c3d4e5f6a7b8" +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 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 + +# 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 + +# 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 + +# 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" -# -# 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 + +#: src/big_remote_play/ui/private_network_view.py:1530 +msgid "Token saved in the system keyring" +msgstr "Token salvo no chaveiro do sistema" + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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: 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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" -# -# 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:1875 +msgid "Stored in system keyring" +msgstr "Armazenado no chaveiro do sistema" + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 + +# 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 +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 + +# 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 + +# 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 + +# 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." -# -# 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/main_window.py, line: 24 -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 -msgid "Easy mesh VPN. No server required. Free tier available." -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 -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 -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 -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." -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 28 -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 -msgid "Game stream client. Required to connect to other 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 -msgid "Docker Engine" -msgstr "Motor Docker" -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 36 -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 -msgid "Tailscale" -msgstr "Tailscale" -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 75 -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 -msgid "ZeroTier" -msgstr "ZeroTier" -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 83 -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 -msgid "Home" -msgstr "Início" -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 56 -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" -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 61 -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 -msgid "Connect to Server" -msgstr "Conectar ao Servidor" -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 66 -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 -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 -msgid "Create Private Network" -msgstr "Criar Rede Privada" -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 178 -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 -msgid "Connect to Private Network" -msgstr "Conectar à Rede Privada" -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 184 -msgid "{} - Join network" -msgstr "{} - Juntar rede" -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 188 -msgid "Change VPN" -msgstr "Mudar VPN" -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 190 -msgid "Switch provider" -msgstr "Mudar provedor" -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 195 -msgid "Select VPN" -msgstr "Selecionar VPN" -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 197 -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 -msgid "Service Status" -msgstr "Status do Serviço" -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 224 -msgid "Checking..." -msgstr "Verificando..." -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 259 -msgid "Installed" -msgstr "Instalado" -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 259 -msgid "Missing" -msgstr "Faltando" -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 265 -msgid "host" -msgstr "anfitrião" -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 265 -msgid "connect" -msgstr "conectar" -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 266 -msgid "Need to install {} to {}" -msgstr "Precisa instalar {} para {}" -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 273 -msgid "Preferences" -msgstr "Preferências" -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 273 -msgid "About" -msgstr "Sobre" -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 445 -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/private_network_view.py:2132 +msgid "Access Tailscale" +msgstr "Acessar o Tailscale" + +#: src/big_remote_play/ui/private_network_view.py:2133 +msgid "All participants need an account (Google, GitHub, etc)." +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 "Criar conta" + +#: src/big_remote_play/ui/private_network_view.py:2138 +msgid "2. Invitation" +msgstr "2. Convite" + +#: src/big_remote_play/ui/private_network_view.py:2138 +msgid "Request Access" +msgstr "Solicitar acesso" + +#: src/big_remote_play/ui/private_network_view.py:2138 msgid "" -"Select a VPN solution to create or join a Private Network. Your choice will be saved and shown in " -"the sidebar menu." +"The administrator must share the node or invite your email to the network." msgstr "" -"Selecione uma solução de VPN para criar ou ingressar 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 -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." -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 472 -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 -msgid "Intermediate" -msgstr "Intermediário" -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 523 -msgid "Choose →" -msgstr "Escolher →" -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 537 -msgid "Switch VPN Provider?" -msgstr "Mudar Provedor de VPN?" -# -# 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 "Você está mudando de {} para {}. Você deseja desconectar de {}?" -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 544 -msgid "Keep Connected" -msgstr "Mantenha-se Conectado" -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 545 -msgid "Disconnect previous" -msgstr "Desconectar anterior" -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 561 -msgid "Disconnecting from {}..." -msgstr "Desconectando de {}..." -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 570 -msgid "{} disconnected" -msgstr "{} desconectado" -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 597 -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 -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" -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 463 -msgid "Missing Components" -msgstr "Componentes Ausentes" -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 463 -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 -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 -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 -msgid "Containers" -msgstr "Contêineres" -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 557 -msgid "Action {} sent to {}" -msgstr "Ação {} enviada para {}" -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 561 -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 -msgid "Start" -msgstr "Iniciar" -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 570 -msgid "Restart" -msgstr "Reiniciar" -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 576 -msgid "Disable" -msgstr "Desativar" -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 576 -msgid "Enable" -msgstr "Ativar" -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 619 -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 -msgid "Running (Caddy + Headscale)" -msgstr "Executando (Caddy + Headscale)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 633 -msgid "Stop Containers" -msgstr "Parar Contêineres" -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 633 -msgid "Start Containers" -msgstr "Iniciar Contêineres" -# -# File: big-remote-play/usr/share/big-remote-play/ui/main_window.py, line: 639 -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 -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 -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 -msgid "V-Sync" -msgstr "V-Sync" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 124 -msgid "Visual Synchronization" -msgstr "Sincronização Visual" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 125 -msgid "Frame Pacing" -msgstr "Sincronização de Quadros" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 125 -msgid "Improve smoothness" -msgstr "Melhorar a suavidade" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 129 -msgid "Input Settings" -msgstr "Configurações de Entrada" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 131 -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 -msgid "Capture system shortcuts" -msgstr "Capturar atalhos do sistema" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 133 -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 -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 -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 -msgid "Audio Settings" -msgstr "Configurações de Áudio" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 143 -msgid "Audio Configuration" -msgstr "Configuração de Áudio" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 145 -msgid "Stereo" -msgstr "Estéreo" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 153 -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 -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 -msgid "Controller Settings" -msgstr "Configurações do Controlador" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 160 -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 -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 -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 -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 -msgid "Host Settings" -msgstr "Configurações do Host" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 169 -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 -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 -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 -msgid "Automatic (Recommended)" -msgstr "Automático (Recomendado)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 180 -msgid "Hardware" -msgstr "Hardware" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 189 -msgid "Video Codec" -msgstr "Codec de Vídeo" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 199 -msgid "Enable HDR (Experimental)" -msgstr "Ativar HDR (Experimental)" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 200 -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 -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 -msgid "Discover PCs automatically" -msgstr "Descubra PCs automaticamente" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 203 -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 -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 -msgid "UI Settings" -msgstr "Configurações de UI" -# -# File: big-remote-play/usr/share/big-remote-play/ui/moonlight_preferences.py, line: 210 -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 -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 +"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 "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 "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 "1. Identificação" + +#: src/big_remote_play/ui/private_network_view.py:2147 +msgid "Get Network ID" +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 "Solicite o ID de 16 caracteres ao dono da rede." + +#: src/big_remote_play/ui/private_network_view.py:2148 +msgid "2. Join" +msgstr "2. Ingressar" + +#: src/big_remote_play/ui/private_network_view.py:2148 +msgid "Enter ID" +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 "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 "3. Autorização" + +#: src/big_remote_play/ui/private_network_view.py:2151 +msgid "Wait for Approval" +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 "Painel do ZT" + +# 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 "Runtime do Sunshine" + +#: src/big_remote_play/ui/sunshine_preferences.py:306 +msgid "Sunshine is available." +msgstr "O Sunshine está disponível." + +#: src/big_remote_play/ui/sunshine_preferences.py:309 +msgid "Sunshine is not available." +msgstr "O Sunshine não está disponível." + +#: src/big_remote_play/ui/sunshine_preferences.py:317 +msgid "Apply to Running Server" +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 "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 "Lê a configuração atual do servidor para estas opções." + +#: src/big_remote_play/ui/sunshine_preferences.py:329 +msgid "Reload" +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 "Configurações aplicadas; reiniciando o servidor." + +#: src/big_remote_play/ui/sunshine_preferences.py:365 +msgid "Failed to apply settings (check credentials)." +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 "O Sunshine não está em execução." + +#: src/big_remote_play/ui/sunshine_preferences.py:385 +msgid "Could not read server configuration." +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 +#: 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 "" +"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 +#: 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 "Origens confiáveis da interface web" + +#: 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 "" +"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 +#: 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 "" +"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 "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 +#: 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 "" -"Sunshine requires login. Enter your credentials (default: admin / password created during " -"installation)." +"Listing all available audio sinks is possible by running `pactl list short " +"sinks` (PulseAudio) or `wpctl status` (PipeWire)." 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 +"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 "" +"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 "" -"Sunshine failed to start.\n" -"\n" -"Error: {}\n" -"\n" -"If this is a dependency issue (missing libraries), try the 'Fix Dependencies' button." +"This enables rate-control preanalysis, which may increase quality at the " +"expense of increased encoding latency." 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" -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 +"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 "" -"Could not pair with host.\n" -"Verify the PIN was entered correctly." +"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 "" -"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" +"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/ui/guest_view.py, line: 671 +# 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 "O runtime do Sunshine está disponível" + +#: src/big_remote_play/utils/system_check.py:144 +msgid "Sunshine failed to report its version" +msgstr "O Sunshine não conseguiu informar sua versão" + +#: 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 "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 "Verificando dependências..." + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:64 +msgid "Installing" +msgstr "Instalando" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:65 +msgid "Failed to install" +msgstr "Falha ao instalar" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:65 +msgid "Install it manually." +msgstr "Instale manualmente." + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:72 +msgid "Checking open ports..." +msgstr "Verificando portas abertas..." + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:76 +msgid "Docker is not running. Starting..." +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 "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 "Configurando o firewall..." + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:94 +msgid "UFW firewall configured" +msgstr "Firewall UFW configurado" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:103 +msgid "Firewalld configured" +msgstr "Firewalld configurado" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:110 +msgid "HOST (SERVER) SETUP" +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 "Domínio (ex.: vpn.ruscher.org): " + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:129 +msgid "Generating reverse proxy config (Caddy)..." +msgstr "Gerando a configuração de proxy reverso (Caddy)..." + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:269 +msgid "Configuring Headscale..." +msgstr "Configurando o Headscale..." + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:283 +msgid "Validating Headscale configuration..." +msgstr "Validando a configuração do Headscale..." + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:285 msgid "" -"Moonlight needs to be paired.\n" -"\n" -"{}" +"Invalid Headscale configuration; aborting before changing DNS or starting " +"services." 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 +"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 "Novo registro DNS criado" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:319 +msgid "Starting containers..." +msgstr "Iniciando os contêineres..." + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:324 +msgid "Waiting for services to start..." +msgstr "Aguardando os serviços iniciarem..." + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:329 +msgid "Configuring router ports via UPnP..." +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 "Criando usuário e chaves..." + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:350 +msgid "Creating new user..." +msgstr "Criando novo usuário..." + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:359 +msgid "Testing services..." +msgstr "Testando os serviços..." + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:363 +msgid "Headscale is working" +msgstr "O Headscale está funcionando" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:365 +msgid "Headscale is not responding" +msgstr "O Headscale não está respondendo" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:371 +msgid "Caddy is working" +msgstr "O Caddy está funcionando" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:373 +msgid "Caddy is not responding" +msgstr "O Caddy não está respondendo" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:388 +msgid "SERVER CONFIGURED SUCCESSFULLY!" +msgstr "SERVIDOR CONFIGURADO COM SUCESSO!" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:390 +msgid "ACCESS INFORMATION" +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 "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 "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 "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 "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 "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 "Chave para amigos:" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:399 +msgid "USEFUL COMMANDS" +msgstr "COMANDOS ÚTEIS" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:400 +msgid "View logs: " +msgstr "Ver logs: " + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:401 +msgid "Restart: " +msgstr "Reiniciar: " + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:404 +msgid "SHARE ONLY THE KEY FOR FRIENDS" +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 "Mostrar logs em tempo real? (y/N): " + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:420 +msgid "CLIENT (GUEST) SETUP" +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 "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 "Chave de acesso (Auth Key): " + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:429 +msgid "Testing connection to the server..." +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 "Servidor acessível" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:435 +msgid "Could not connect to the server" +msgstr "Não foi possível conectar ao servidor" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:436 +msgid "Check:" +msgstr "Verifique:" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:437 +msgid "1. Is the domain correct?" +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 "2. O servidor está online?" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:439 +msgid "3. Is the Auth Key valid?" +msgstr "3. A Auth Key é válida?" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:448 +msgid "Installing Tailscale..." +msgstr "Instalando o Tailscale..." + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:452 +msgid "Tailscale already installed" +msgstr "Tailscale já instalado" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:460 +msgid "Connecting to the private network..." +msgstr "Conectando à rede privada..." + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:474 +msgid "Connection established!" +msgstr "Conexão estabelecida!" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:477 +msgid "Trying alternative method..." +msgstr "Tentando método alternativo..." + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:488 +msgid "Waiting for connection..." +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 "STATUS DA CONEXÃO" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:501 +msgid "Your network IP: " +msgstr "Seu IP na rede: " + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:508 +msgid "CONNECTED DEVICES" +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 "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 "Convide amigos usando a mesma chave de acesso." + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:520 +msgid "TROUBLESHOOTING" +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 "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 "2. Verifique a Auth Key" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:523 +msgid "3. Try restarting:" +msgstr "3. Tente reiniciar:" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:524 +msgid "4. Check logs:" +msgstr "4. Verifique os logs:" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:528 +msgid "View Tailscale logs? (y/N): " +msgstr "Ver os logs do Tailscale? (y/N): " + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:543 +msgid "Client setup complete!" +msgstr "Configuração do cliente concluída!" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:551 +msgid "ADVANCED TROUBLESHOOTING" +msgstr "SOLUÇÃO AVANÇADA DE PROBLEMAS" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:552 +msgid "1) Check server status" +msgstr "1) Verificar o status do servidor" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:553 +msgid "2) Check server logs" +msgstr "2) Verificar os logs do servidor" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:554 +msgid "3) Test external connection" +msgstr "3) Testar a conexão externa" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:555 +msgid "4) Recreate access keys" +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 "5) Voltar ao menu principal" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:557 +msgid "Choose an option: " +msgstr "Escolha uma opção: " + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:566 +msgid "Server directory not found" +msgstr "Diretório do servidor não encontrado" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:572 +msgid "HEADSCALE LOGS" +msgstr "LOGS DO HEADSCALE" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:574 +msgid "CADDY LOGS" +msgstr "LOGS DO CADDY" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:579 +msgid "Domain to test: " +msgstr "Domínio para testar: " + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:580 +msgid "Testing" +msgstr "Testando" + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:588 +msgid "Recreating keys..." +msgstr "Recriando as chaves..." + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:590 +msgid "Create a new key? (y/N): " +msgstr "Criar uma nova chave? (y/N): " + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:594 +msgid "New key: " +msgstr "Nova chave: " + +#: usr/share/big-remote-play/scripts/create-network_headscale.sh:600 +msgid "Press Enter to continue..." +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 "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 "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 "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 "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 "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 "Opção: " + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:60 +msgid "Tailscale not found. Installing..." +msgstr "Tailscale não encontrado. Instalando..." + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:64 +msgid "Installing yay (AUR helper)..." +msgstr "Instalando o yay (auxiliar do AUR)..." + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:82 +msgid "jq not found. Installing..." +msgstr "jq não encontrado. Instalando..." + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:88 +msgid "curl not found. Installing..." +msgstr "curl não encontrado. Instalando..." + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:99 +msgid "TAILSCALE LOGIN" +msgstr "LOGIN NO TAILSCALE" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:102 +msgid "Checking tailscaled service..." +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 "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 "Falha ao iniciar o tailscaled. Verifique manualmente:" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:119 +msgid "tailscaled service is running" +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 "Escolha o método de login:" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:123 +msgid "1) Browser login (recommended)" +msgstr "1) Login pelo navegador (recomendado)" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:124 +msgid "2) Login with auth key" +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 "3) Voltar" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:132 +msgid "Starting browser login..." +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 "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 "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 "Aguardando a autenticação..." + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:145 +msgid "Login confirmed!" +msgstr "Login confirmado!" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:153 +msgid "Enter your auth key:" +msgstr "Digite sua auth key:" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:154 +msgid "Expected format:" +msgstr "Formato esperado:" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:158 +msgid "Authenticating with key..." +msgstr "Autenticando com a chave..." + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:162 +msgid "Service not responding. Reconnecting..." +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 "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 "Reiniciando o serviço e tentando novamente..." + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:188 +msgid "Login successful!" +msgstr "Login bem-sucedido!" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:190 +msgid "Login error. Diagnostics:" +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 "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 "2. Verifique a conectividade:" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:193 +msgid "3. Check the service:" +msgstr "3. Verifique o serviço:" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:195 +msgid "Alternative manual command:" +msgstr "Comando manual alternativo:" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:199 +msgid "Key cannot be empty!" +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 "Opção inválida!" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:212 +msgid "Checking connection..." +msgstr "Verificando a conexão..." + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:223 +msgid "Waiting for connection, attempt" +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 "Login feito e conexão estabelecida com sucesso!" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:231 +msgid "Device information:" +msgstr "Informações do dispositivo:" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:241 +msgid "Name: " +msgstr "Nome: " + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:250 +msgid "Devices on the network:" +msgstr "Dispositivos na rede:" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:258 +msgid "Connection failed. Full diagnostics:" +msgstr "A conexão falhou. Diagnóstico completo:" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:260 +msgid "1. Service status:" +msgstr "1. Status do serviço:" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:266 +msgid "3. Connectivity:" +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 "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 "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 "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 "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 "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 "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 "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 "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 "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 "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 "Não conectado ao Tailscale. Faça login primeiro." + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:320 +msgid "Current status:" +msgstr "Status atual:" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:324 +msgid "IP addresses:" +msgstr "Endereços IP:" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:329 +msgid "Detailed information:" +msgstr "Informações detalhadas:" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:330 +msgid "Could not get details" +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 "DISPOSITIVOS NA REDE" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:360 +msgid "Only this device connected to the network" +msgstr "Apenas este dispositivo conectado à rede" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:368 +msgid "ADD NEW DEVICE" +msgstr "ADICIONAR NOVO DISPOSITIVO" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:370 +msgid "Method 1: invite URL" +msgstr "Método 1: URL de convite" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:375 +msgid "Method 2: auth key" +msgstr "Método 2: auth key" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:377 +msgid "2. Generate an auth key" +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 "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 "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 "Pressione ENTER para voltar ao menu principal" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:389 +msgid "REMOVE DEVICE" +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 "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 "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 "Para remover via API, você precisa de:" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:408 +msgid "Find the device" +msgstr "Encontrar o dispositivo" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:423 +msgid "AUTHORIZE DEVICE" +msgstr "AUTORIZAR DISPOSITIVO" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:430 +msgid "Checking pending devices..." +msgstr "Verificando dispositivos pendentes..." + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:439 +msgid "No pending device found" +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 "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 "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 +#: 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 "Pressione ENTER para continuar" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:457 +msgid "SHARE NETWORK" +msgstr "COMPARTILHAR REDE" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:458 +msgid "Sharing options:" +msgstr "Opções de compartilhamento:" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:459 +msgid "1) Share with a specific user" +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 "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 "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 "2. Configure as opções desejadas" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:501 +msgid "To configure ACLs:" +msgstr "Para configurar ACLs:" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:505 +msgid "Basic example:" +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 "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 "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 "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 "Deseja realmente sair do Tailscale? (y/N):" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:531 +msgid "Logout successful!" +msgstr "Logout realizado com sucesso!" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:533 +msgid "Operation cancelled." +msgstr "Operação cancelada." + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:539 +msgid "DETAILED TAILSCALE STATUS" +msgstr "STATUS DETALHADO DO TAILSCALE" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:541 +msgid "Service status:" +msgstr "Status do serviço:" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:545 +msgid "Version:" +msgstr "Versão:" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:550 +msgid "Connected to Tailscale" +msgstr "Conectado ao Tailscale" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:552 +msgid "Node information:" +msgstr "Informações do nó:" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:560 +msgid "Statistics:" +msgstr "Estatísticas:" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:567 +msgid "No specific tailscale route" +msgstr "Nenhuma rota específica do tailscale" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:575 +msgid "CHANGE SETTINGS" +msgstr "ALTERAR CONFIGURAÇÕES" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:576 +msgid "Configuration options:" +msgstr "Opções de configuração:" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:579 +msgid "3) Change device name" +msgstr "3) Alterar o nome do dispositivo" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:580 +msgid "4) Configure DNS server" +msgstr "4) Configurar o servidor DNS" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:581 +msgid "5) Back" +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 "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 "Novo nome para o dispositivo:" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:608 +msgid "Name changed to" +msgstr "Nome alterado para" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:612 +msgid "Enter DNS servers (comma-separated):" +msgstr "Digite os servidores DNS (separados por vírgula):" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:632 +msgid "SETTINGS BACKUP" +msgstr "BACKUP DAS CONFIGURAÇÕES" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:639 +msgid "Could not create temporary backup directory." +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 "Configurações do script salvas" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:651 +msgid "Tailscale settings saved" +msgstr "Configurações do Tailscale salvas" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:659 +msgid "Backup created:" +msgstr "Backup criado:" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:662 +msgid "Checking backup integrity..." +msgstr "Verificando a integridade do backup..." + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:664 +msgid "Backup verified" +msgstr "Backup verificado" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:666 +msgid "Backup corrupted" +msgstr "Backup corrompido" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:684 +msgid "Create new network/account" +msgstr "Criar nova rede/conta" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:685 +msgid "View network status" +msgstr "Ver o status da rede" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:686 +msgid "List devices" +msgstr "Listar dispositivos" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:687 +msgid "Add new device (instructions)" +msgstr "Adicionar novo dispositivo (instruções)" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:689 +msgid "Authorize pending device" +msgstr "Autorizar dispositivo pendente" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:690 +msgid "Share network" +msgstr "Compartilhar rede" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:692 +msgid "Change settings" +msgstr "Alterar configurações" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:693 +msgid "Detailed status" +msgstr "Status detalhado" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:694 +msgid "Logout/Exit" +msgstr "Sair/Encerrar" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:695 +msgid "Backup settings" +msgstr "Backup das configurações" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:696 +msgid "Exit the program" +msgstr "Sair do programa" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:698 +msgid "Choose an option:" +msgstr "Escolha uma opção:" + +#: usr/share/big-remote-play/scripts/create-network_tailscale.sh:729 +msgid "Exiting..." +msgstr "Saindo..." + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:40 +msgid "ZEROTIER - VIRTUAL PRIVATE NETWORK" +msgstr "ZEROTIER - REDE PRIVADA VIRTUAL" + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:49 +msgid "Installing ZeroTier..." +msgstr "Instalando o ZeroTier..." + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:52 +msgid "ZeroTier already installed" +msgstr "ZeroTier já instalado" + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:63 +msgid "Starting ZeroTier service..." +msgstr "Iniciando o serviço do ZeroTier..." + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:68 +msgid "ZeroTier daemon active" +msgstr "Daemon do ZeroTier ativo" + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:76 +msgid "Paste your API Token here: " +msgstr "Cole seu token de API aqui: " + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:79 +msgid "Token cannot be empty!" +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 "CRIAR NOVA REDE ZEROTIER" + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:91 +msgid "Network name: " +msgstr "Nome da rede: " + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:97 +msgid "Description (optional): " +msgstr "Descrição (opcional): " + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:100 +msgid "Creating network via API..." +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 "Erro ao criar a rede. Verifique seu token." + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:117 +msgid "Network created! ID:" +msgstr "Rede criada! ID:" + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:120 +msgid "Joining the network..." +msgstr "Ingressando na rede..." + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:128 +msgid "Authorizing local device..." +msgstr "Autorizando o dispositivo local..." + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:134 +msgid "Device authorized!" +msgstr "Dispositivo autorizado!" + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:153 +msgid "NETWORK INFORMATION" +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 "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 "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 "INGRESSAR NA REDE ZEROTIER (Cliente/Convidado)" + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:175 +msgid "ZeroTier Network ID (16 characters): " +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 "O Network ID não pode ficar vazio!" + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:181 +msgid "Joining network:" +msgstr "Ingressando na rede:" + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:192 +msgid "Your Node ID: " +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 "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 "Informe seu Node ID ao administrador: " + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:197 +msgid "Join request sent!" +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 "Aguarde o administrador autorizar você" + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:208 +msgid "3) View network status" +msgstr "3) Ver o status da rede" + +#: usr/share/big-remote-play/scripts/create-network_zerotier.sh:216 +msgid "ZEROTIER STATUS" +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 "" -"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." +"Playing with a friend over the internet? You need a Private Network (just " +"once)." 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" +"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 "" -# -# 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 +"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 "" -"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." +"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 "" -"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 +"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" + +msgid "{} is installed." +msgstr "{} está instalado." + 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." +"{} is not installed yet. It will be installed when you continue (asks for " +"your password)." 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" +"{} 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" + +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/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/src/big_remote_play/app.py b/src/big_remote_play/app.py new file mode 100644 index 0000000..2daef9f --- /dev/null +++ b/src/big_remote_play/app.py @@ -0,0 +1,139 @@ +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 # 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 +from big_remote_play import paths + +from big_remote_play.utils.i18n import _ + +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) + self.config = Config() + self.logger = Logger() + self.window = None + + def do_activate(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)] + for name, callback in actions: + action = Gio.SimpleAction.new(name, None) + 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) + self.load_custom_css() + + def setup_icon(self): + 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): + 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 + 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." + ) + + 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", + license_type=Gtk.License.GPL_3_0, + 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"): + 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(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() + + def do_shutdown(self): + 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") + return BigRemotePlayApp().run(sys.argv) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/usr/share/big-remote-play/guest/__init__.py b/src/big_remote_play/guest/__init__.py similarity index 73% rename from usr/share/big-remote-play/guest/__init__.py rename to src/big_remote_play/guest/__init__.py index c00166a..07f62f9 100644 --- a/usr/share/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 new file mode 100644 index 0000000..bbdd0be --- /dev/null +++ b/src/big_remote_play/guest/moonlight_client.py @@ -0,0 +1,248 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, TextIO +import shutil +import subprocess + + +class MoonlightClient: + 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): + """Prepares IP for Moonlight CLI.""" + if not ip: + return "" + clean_ip = ip.strip() + + # Remove brackets if present to facilitate processing + 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"): + 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 + if dev_idx < len(route): + iface = route[dev_idx] + clean_ip = f"{clean_ip}%{iface}" + else: + # Dumb fallback: get first UP interface that is not lo + try: + import json + + 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 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 + 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"]) + + 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()}") + 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() + + try: + exit_code = self.process.wait(timeout=1.0) + msg = f"Moonlight ended prematurely (Code {exit_code})" + if self.logger: + self.logger.error(msg) + return False + except subprocess.TimeoutExpired: + pass + + return True + 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 disconnect(self): + 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 + except Exception: + 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: + 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) + return res.returncode == 0 + 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: + 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 = 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 "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") + 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}") + return False + + def list_apps(self, host_ip): + 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: + # 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 + + # 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: + 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: + 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.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 [] + + def get_status(self): + return {"connected": self.is_connected(), "host": self.connected_host, "moonlight_cmd": self.moonlight_cmd} diff --git a/usr/share/big-remote-play/host/__init__.py b/src/big_remote_play/host/__init__.py similarity index 74% rename from usr/share/big-remote-play/host/__init__.py rename to src/big_remote_play/host/__init__.py index 988bf4d..85c3057 100644 --- a/usr/share/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/host/sunshine_manager.py b/src/big_remote_play/host/sunshine_manager.py new file mode 100644 index 0000000..0d1db1e --- /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 = 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 = 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 71% rename from usr/share/big-remote-play/ui/__init__.py rename to src/big_remote_play/ui/__init__.py index e0e5d5f..9f89636 100644 --- a/usr/share/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/guest_view.py b/src/big_remote_play/ui/guest_view.py new file mode 100644 index 0000000..9134e64 --- /dev/null +++ b/src/big_remote_play/ui/guest_view.py @@ -0,0 +1,1447 @@ +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") + +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 +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_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): + self.logger = Logger() + + self.logger = Logger() + + self.moonlight_config = MoonlightConfigManager() + self.moonlight = MoonlightClient(logger=self.logger) + 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...")) + + 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 _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) + 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) + 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 = create_stack_tab_strip(self.method_stack, _("Connection methods")) + + 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_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) + # 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")) + 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) + 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) + self.quality_expander.add_row(self.scale_row) + + self.fps_row = Adw.ComboRow() + 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) + self.quality_expander.add_row(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.quality_expander.add_row(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_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) + self.quality_expander.add_row(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) + 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) + 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) + self.quality_expander.add_row(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") + 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) + 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) + 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"): + 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() + + else: + if self.is_connected: + # Detected disconnection + self.is_connected = False + 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 + + 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) + + # Update connection buttons state + self._update_all_buttons_state() + + def _update_all_buttons_state(self): + 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 connected: + # State: Connected -> Button becomes Stop + b.set_sensitive(True) + b.remove_css_class("suggested-action") + b.add_css_class("destructive-action") + l.set_label(_("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") + l.set_label(_("Stop")) + 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") + l.set_label(default_text) + 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 Manual Button + 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")) + + def check_reconnect(self): + 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"]) + + def check_reconnect_debounced(self): + """Checks if reconnection is needed (with debounce)""" + # Cancel previous + 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 _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) + 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=_("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) + 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) + # 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) + + 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.set_child(btn_box) + + self.main_connect_btn.connect("clicked", lambda b: self.on_main_button_clicked("discover")) + + buttons_box.append(self.main_connect_btn) + + 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(self._host_scroll) + box.append(self._empty_container) + box.append(action) + box.set_hexpand(True) + + # 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) + self._discover_footer = footer + box.append(footer) + box.set_hexpand(True) + return box + + 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() + # 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() + 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) + + def on_hosts_discovered(hosts): + if self.loading_row.get_parent(): + self.hosts_list.remove(self.loading_row) + self.first_radio_in_list = None + self.update_hosts_list(hosts) + return False + + NetworkDiscovery().discover_hosts(callback=on_hosts_discovered) + + def update_hosts_list(self, hosts): + # Clear + self.first_radio_in_list = 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) + + if not hosts: + # 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)) + + def create_host_row_custom(self, host): + 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) + + 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) + 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")) + + 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"])) + + 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) + 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) + + 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.") + + port = Adw.EntryRow() + 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.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")) + 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")) + + 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) + + 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.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")) + 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")) + + 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: + self.on_cancel_connection(None) + else: + # Start connection based on source + 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()) + + 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: + 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() + fps_idx = self.fps_row.get_selected() + bitrate_val = self.bitrate_scale.get_value() + 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") + + 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"]) + if apps is not None: + is_paired = True + break + if i < checks - 1: + 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 scale_active: + # res = self.get_auto_resolution() # RISK + 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") + 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): + + 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: + 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() + else: + 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"] + + 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") + 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 + 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"))) + + def do_pair(): + self.show_toast(_("Starting pairing...")) + 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: + success = True + + if success: + + 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."))) + + threading.Thread(target=do_pair, daemon=True).start() + + def show_loading(self, show=True, message=""): + self.is_connecting = show + self._update_all_buttons_state() + + def on_cancel_connection(self, btn): + self.show_toast(_("Canceling connection...")) + self.is_connecting = False + self.show_loading(False) + 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 + + def show_pairing_dialog(self, host_ip, pin=None, on_confirm=None, hostname=None): + 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 "" + ) + ) + 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) + + def on_resp(dlg, resp): + 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: + self.pairing_dialog.close() + self.pairing_dialog = None + + def get_auto_resolution(self): + try: + 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) + if monitor: + 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}) + + def connect_pin(self, _widget): + # Reverse Connection via PIN (Custom Feature) + pin = self.pin_entry.get_text() + 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") + + self.show_toast(_("Host found: {}").format(hostname)) + 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() + + def on_resolution_changed(self, row, item): + val = row.get_selected_item().get_string() + 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"): + + 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")) + 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": + val = entry.get_text() + 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 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()) + + 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" + 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) + + # 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) + + # Bitrate (kbps) + br = int(self.bitrate_scale.get_value() * 1000) + 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) + + # 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") + + # 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) + + 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))) + + # 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) + else: + 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) + else: + self.fps_row.set_selected(3) + self.custom_fps_val = str(fps) + + # Bitrate + 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) + + # HW Decode + 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", {}) + 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._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": + 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() + + 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}") + + 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.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._root_window()) + 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) + + 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")), + ("Ctrl+Alt+Shift+S", _("Toggle Stats Overlay")), + ("Ctrl+Alt+Shift+M", _("Toggle Mouse Mode (Remote/Absolute)")), + ("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) + row.set_subtitle(desc) + # 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." + ) + ) + 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." + ) + ) + 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 new file mode 100644 index 0000000..f46a51b --- /dev/null +++ b/src/big_remote_play/ui/host_view.py @@ -0,0 +1,2738 @@ +import gi + +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 +import shlex +from pathlib import Path +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, 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 +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 + super().__init__(orientation=Gtk.Orientation.VERTICAL) + self.config = Config() + self.is_hosting = False + 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") + + 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.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() + + 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" + + # Method: GDK (Most consistent for labels and Wayland indices) + names = [] + 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) + if monitor is None: + continue + conn = monitor.get_connector() + if conn: + 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 conn + full_label = f"{label} ({conn})" + monitors.append((full_label, val)) + names.append(conn) + except Exception as e: + print(f"Error detecting GDK monitors: {e}") + + # Fallback for X11/DRM if GDK didn't find everything + if not is_wayland: + # Xrandr (Reinforcement for X11) + try: + 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) + 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] + if name not in names: + monitors.append((f"DRM Display ({name})", name)) + names.append(name) + 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: + 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 + 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"}]) + 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) + + 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) + + game_group = Adw.PreferencesGroup() + 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")) + 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) + + 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_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_app_expander.add_row(self.custom_name_entry) + + 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) + + 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") + + # Resolution Row + self.resolution_row = Adw.ComboRow() + self.resolution_row.set_title(_("Resolution")) + self.resolution_row.set_subtitle(_("Stream resolution")) + res_model = Gtk.StringList() + 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.streaming_expander.add_row(self.resolution_row) + + # FPS Row + self.fps_row = Adw.ComboRow() + self.fps_row.set_title(_("Frame Rate (FPS)")) + self.fps_row.set_subtitle(_("Frames per second")) + fps_model = Gtk.StringList() + 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.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.monitor_row = Adw.ComboRow() + 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) + 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")) + gpu_model = Gtk.StringList() + 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)")) + platform_model = Gtk.StringList() + 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) + 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")) + opt_model = Gtk.StringList() + opt_model.append(_("Low Latency (Fastest)")) + 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.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")) + + # 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) + 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") + + 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 + + self.audio_mode_row.set_model(mode_model) + 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 + + 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.upnp_row = Adw.SwitchRow() + 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_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_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") + + 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): + 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("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")) + 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() + 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_visible(False) + 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") + 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) + info_left.append(self.summary_box) + + manage_group = Adw.PreferencesGroup() + manage_group.set_title(_("Management")) + 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() + 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): + 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 + 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) + self.view_stack.add_titled_with_icon(audio_page, "audio", _("Audio"), "audio-x-generic-symbolic") + + # 4. PIN Code Page + pin_page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + 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") + 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) + 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 + + # Register PIN for updates (it was removed from summary_box) + 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") + + # 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) + scroll.set_vexpand(True) + scroll.set_child(clamp) + self.append(scroll) + + self.start_audio_watchdog() + + def start_audio_watchdog(self): + # Watchdog simplified or removed, as flow is explicitly controlled + pass + + def _check_audio_state(self): + return True + + 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) -> tuple[str, str] | None: + return load_sunshine_credentials(conf_path=self._get_sunshine_conf_path()) + + def _save_sunshine_creds(self, user: str, password: str) -> bool: + try: + 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 credentials: {e}") + return False + + def _ensure_sunshine_config(self) -> None: + """Ensures sunshine.conf has required API settings""" + try: + 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: Gtk.Widget) -> None: + 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).")) + 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) + + pass_row = Adw.PasswordEntryRow(title=_("Sunshine Password")) + 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() + device_name = name_row.get_text().strip() + u = user_row.get_text().strip() + p = pass_row.get_text().strip() + save = save_chk.get_active() + + 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: + self.show_error_dialog(_("Authentication Failed"), _("Invalid username or password.")) + elif "307" in msg: + # A 307 Redirect means no user has been created yet + 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.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.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) + 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 + + 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) + 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.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) + 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 + + # Tentar novamente com credenciais + success, msg = self.sunshine.send_pin(pin_to_retry, name=device_name, auth=(user, pwd)) + if success: + self.show_toast(_("PIN sent successfully")) + # Save credentials on success + self._save_sunshine_creds(user, pwd) + 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 = {} + # 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", 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), + ]: + self.create_masked_row(l, k, i, r) + + def on_audio_mode_changed(self, row, param): + 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"): + 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"))) + + 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: + print(f"Error loading audio: {e}") + model = Gtk.StringList() + model.append(_("Error loading audio")) + self.audio_output_row.set_model(model) + + def on_audio_output_changed(self, row, param): + 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"] + 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: + 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") + + 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] + + 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.")) + + 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: + 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: + 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.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.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, "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( + [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): + 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() + 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"): + 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 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.summary_box.set_visible(True) + self.populate_summary_fields() + + # Enable PIN tab when hosting + if hasattr(self, "pin_page"): + self.pin_page.set_sensitive(True) + self.pin_stack_page.set_icon_name("dialog-password-symbolic") + else: + self.perf_monitor.set_connection_status("Sunshine", _("Inactive"), False) + self.perf_monitor.stop_monitoring() + + # 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 + 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") + self.host_uptime_label.set_visible(False) + + 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"): + 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_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) + + 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) + ipv4, ipv6 = self.get_ip_addresses() + 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() + + # Wrap IPv6 in brackets for compatibility + 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) + + 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) + + 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.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"): + GLib.source_remove(self.mixer_source_id) + del self.mixer_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 + + 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 + should_monitor = True + 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 + should_monitor = False + 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 + should_monitor = not has_localhost + + if hasattr(self, "audio_manager"): + try: + # Update loopback + 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 + + # Default sink hijack fix + current_default = self.audio_manager.get_default_sink() + if current_default and current_default != private_sink: + if "sunshine" in current_default.lower() and "stereo" in current_default.lower(): + self.audio_manager.set_default_sink(private_sink) + + 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 + + target = private_sink if (not streaming_enabled or name in self.private_audio_apps) else shared_sink + 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 + + apps = self.audio_manager.get_apps() + seen_ids = set() + + if not hasattr(self, "mixer_rows"): + self.mixer_rows = {} + + for app in apps: + 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 + + 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.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"]) + row.set_active(is_shared) + 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): + is_shared = row.get_active() + if is_shared: + if app_name in self.private_audio_apps: + 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() + + try: + if self.sunshine.is_running(): + self.sunshine.stop() + 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", []) + 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"]} + elif mode_idx == 2: # Lutris + idx = self.game_list_row.get_selected() + if idx != Gtk.INVALID_LIST_POSITION: + 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"]} + elif mode_idx == 3: # Custom App + 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} + + # 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 = 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 + + 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", + } + + # Audio Configuration + # Audio Configuration - ALWAYS setup PulseAudio infrastructure + # This allows toggling streaming on/off without restarting server or destroying sinks + 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"] + 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 + # 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" + else: + # Store it for the enforcer + self.active_host_sink = host_sink + + # Enable Host+Guest Streaming (Create Sink) + 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 + # 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 + + if self.audio_manager.enable_streaming_audio(host_sink, guest_only=guest_only): + 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) + + 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 + + # Set output_name if a specific monitor is selected, others set to None to remove from config + 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 + + # 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" + + 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")) + + # 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.is_hosting = False + self.sync_ui_state() # Revert state + finally: + self.loading_bar.set_visible(False) + 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) + if not info: + print("Game Mode: Desktop (no game to launch)") + return + + 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"] + 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) + 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) + 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"] + 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) + self._game_processes.append(p) + self.show_toast(_("Launching {}...").format(game_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) + 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) + + # Close Steam Big Picture if we opened it + if info and info.get("type") == "steam": + try: + subprocess.Popen(["steam", "steam://close/bigpicture"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + print("Closing Steam Big Picture") + except Exception: + pass + + # Kill tracked 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 + + 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() + + # 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 + self.stop_pin_listener = None + + # Restore audio configuration + 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_btn_spinner.stop() + 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") + + # 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 + + if not self.is_hosting: + return True + + 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) + 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 + + 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 + try: + 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"] + # 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": + # Prioritize global but accept link-local + 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 + + # 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.") + + 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, + # 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" + open_path(self, log_path) + 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.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}") + + 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") + + # --- 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._root_window()) + 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._root_window()) + 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._root_window()) + 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) + display = Gdk.Display.get_default() + if display is not None: + display.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._root_window()) + 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._root_window()) + 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: str) -> None: + if getattr(self, "_browse_group", None) is None: + return + import threading + + auth = self._get_sunshine_creds() + + 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 _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 + 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 = 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) + + for entry in data.get("entries", []): + name = entry.get("name", "") + etype = entry.get("type", "") + epath = entry.get("path", "") + if etype == "directory": + row = self._create_browse_button( + name, + "folder-symbolic", + _("Open folder"), + lambda _button, p=epath: self._browse_load(p), + ) + else: + 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: str) -> None: + 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() + 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( + 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._root_window()) + + 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() + self.platform_games_expander.set_visible(idx in [1, 2]) + self.platform_games_expander.set_expanded(idx in [1, 2]) + 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] + 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 + 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() + games = self.detected_games[plat] + new_model = Gtk.StringList() + if not games: + new_model.append(f"No games found on {plat}") + else: + 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 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(), + } + ) + + 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") + streaming_active = self.audio_mode_row.get_selected() in [0, 1, 3] + 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) + + # Map Wi-Fi Mode (FEC) + # Enabled -> 20%. Disabled -> 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 + + # 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") + + except Exception as e: + print(f"Error syncing to Sunshine config: {e}") + + def load_settings(self): + self.loading_settings = True + try: + # 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", {}) + 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" + + # 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" + + # Reverse Map Wi-Fi (FEC) + # If FEC >= 15 -> Enabled + 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 + + # 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}") + + 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 + 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) + 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", "")) + + # 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) + + # 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) + 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)) + + # Audio Mode + 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)) + + # 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)) + 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) + + for r in [self.bandwidth_row]: + 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) + + 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) + + def on_resp(d, r): + 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")) + + def cleanup(self): + 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() + 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. + # 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() diff --git a/src/big_remote_play/ui/installer_window.py b/src/big_remote_play/ui/installer_window.py new file mode 100644 index 0000000..ed69dcc --- /dev/null +++ b/src/big_remote_play/ui/installer_window.py @@ -0,0 +1,152 @@ +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 # 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 _ + + +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_default_size(700, 500) + self.set_modal(True) + + # Main content + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + box.set_margin_top(12) + box.set_margin_bottom(12) + 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") + 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) + frame.set_child(self.terminal) + + # Start installation directly + GLib.idle_add(self.start_installation) + else: + # Fallback text view + scrolled = Gtk.ScrolledWindow() + self.textview = Gtk.TextView() + 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).\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...")) + 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()) + 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])] + 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)) + + 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(), 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: + self.on_success() + else: + 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") + + # 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")) diff --git a/src/big_remote_play/ui/main_window.py b/src/big_remote_play/ui/main_window.py new file mode 100644 index 0000000..02ec517 --- /dev/null +++ b/src/big_remote_play/ui/main_window.py @@ -0,0 +1,1223 @@ +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, Pango # type: ignore +import threading +import json +import os +from .host_view import HostView +from .guest_view import GuestView +from .installer_window import InstallerWindow +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 + +# ─── VPN Provider Config ─────────────────────────────────────────────────── +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", + }, + "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", + }, +} + +# 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, + }, + "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, + }, + "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, + }, +} + +# 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"}, +} + + +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: + data = json.load(f) + choice = data.get("vpn_provider") + if choice in VPN_PROVIDERS: + return choice + except Exception: + pass + return None + + +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) + + +class MainWindow(Adw.ApplicationWindow): + """Main window with modern side navigation""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + 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._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._install_window_actions() + self.setup_ui() + self.check_system() + + # Connect close signal + 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 + 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) + + 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) + 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["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"), + } + else: + # Single "connect" entry that leads to VPN selector + 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.set_margin_top(12) + 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")) + + 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", 28) + logo.add_css_class("header-logo") + logo.set_valign(Gtk.Align.CENTER) + identity.append(logo) + + 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("header-title") + title.set_halign(Gtk.Align.START) + text.append(title) + + subtitle = Gtk.Label(label=_("Play together, from anywhere")) + subtitle.add_css_class("header-subtitle") + subtitle.set_halign(Gtk.Align.START) + subtitle.set_ellipsize(Pango.EllipsizeMode.END) + text.append(subtitle) + identity.append(text) + + return identity + + def _refresh_nav_list(self): + """Rebuild the navigation list based on VPN choice.""" + # 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(): + 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.set_halign(Gtk.Align.START) + sep_box.append(label) + + row = Gtk.ListBoxRow() + row.set_child(sep_box) + row.set_activatable(False) + row.set_selectable(False) + self.nav_list.append(row) + else: + self.nav_list.append(self.create_nav_row(pid, info)) + + 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""" + row = Gtk.ListBoxRow() + self._nav_page_by_row[row] = page_id + row.add_css_class("category-row") + + box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + box.set_margin_start(8) + box.set_margin_end(8) + box.set_margin_top(6) + box.set_margin_bottom(6) + + icon = create_icon_widget(page_info["icon"], size=20, css_class="category-icon") + box.append(icon) + + label = Gtk.Label(label=page_info["name"]) + label.set_halign(Gtk.Align.START) + label.set_hexpand(True) + label.add_css_class("category-label") + box.append(label) + + # Badge showing selected VPN name + 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.set_halign(Gtk.Align.END) + box.append(badge) + + 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): + """Creates footer with server status""" + footer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + footer.set_margin_start(12) + footer.set_margin_end(12) + footer.set_margin_top(8) + footer.set_margin_bottom(12) + footer.set_spacing(8) + + separator = Gtk.Separator() + 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.set_halign(Gtk.Align.START) + status_title.set_margin_bottom(4) + footer.append(status_title) + + 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): + # 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("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) + 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) + lbl_key = Gtk.Label(label=label_text) + 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.set_halign(Gtk.Align.START) + self._status_labels[service_id] = lbl_status + setattr(self, lbl_attr, 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. + 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") + 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() + return footer + + def _filter_status_rows(self): + """Show only relevant services based on VPN choice.""" + 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") + + child = self.status_card.get_first_child() + while child: + 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 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") + + 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"), + ] + + 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}') + + if card: + tooltip = "" + if not has: + 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() + hb.pack_end(self._create_header_menu_button()) + + self.content_headerbar = hb + self.header_blank_title = Gtk.Box() + 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() + 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") + + # 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") + + # 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") + + 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) + + def _set_header_title(self, title: str) -> None: + self.content_headerbar.set_title_widget(Adw.WindowTitle.new(title, "")) + + # ───────────────────────────────────────────────────────────────────────── + # VPN SELECTOR PAGE + # ───────────────────────────────────────────────────────────────────────── + + def create_vpn_selector_page(self): + """Full-page VPN provider selector shown when no VPN is chosen.""" + scroll = Gtk.ScrolledWindow() + scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + scroll.set_vexpand(True) + + 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) + + 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) + cards_box.set_homogeneous(True) + + for pid in ("tailscale", "zerotier", "headscale"): + info = VPN_PROVIDERS[pid] + card = self._create_vpn_card(pid, info) + cards_box.append(card) + + box.append(cards_box) + + # 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. + 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) + + # "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) + return scroll + + 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") + # '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") + 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], + [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) + + 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") + box.append(icon) + + # Name + 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.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"))) + + # 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") + box.append(choose_lbl) + + btn.set_child(box) + return btn + + 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"] + ), + ) + 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: + self._apply_vpn_selection(provider_id) + + def _disconnect_vpn(self, vpn_id): + """Disconnect from a specific VPN provider.""" + self.show_toast(_("Disconnecting from {}...").format(VPN_PROVIDERS[vpn_id]["name"])) + + def run(): + if vpn_id in ("headscale", "tailscale"): + 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(["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() + + def _apply_vpn_selection(self, provider_id): + self._vpn_choice = provider_id + save_vpn_choice(provider_id) + + # Rebuild private network views with the chosen provider + from .private_network_view import PrivateNetworkView + + # Remove old views if they exist + 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") + + # 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")) + + def reset_vpn_choice(self): + """Clear VPN choice and show selector again.""" + self._vpn_choice = None + try: + if os.path.exists(VPN_CONFIG_FILE): + os.remove(VPN_CONFIG_FILE) + except Exception: + pass + self._refresh_nav_list() + 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) + + # 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(24) + main_box.set_margin_bottom(24) + + 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") + + # 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"), + primary=True, + badge=_("Recommended"), + cta=_("Start sharing →"), + ) + + self.guest_card = self.create_action_card( + _("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 →"), + ) + + cards_box.append(self.host_card) + cards_box.append(self.guest_card) + 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.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, cb, primary=False, badge=None, cta=None): + btn = Gtk.Button() + 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.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=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}")(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=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 primary: + img.add_css_class("accent") + box.append(img) + + tl = Gtk.Label(label=title) + 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.set_wrap(True) + 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 + + # ───────────────────────────────────────────────────────────────────────── + # NAVIGATION + # ───────────────────────────────────────────────────────────────────────── + + def on_nav_selected(self, lb, row): + if not row: + return + pid = self._nav_page_by_row.get(row) + 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 = c.get_next_sibling() + row.add_css_class("active-category") + + 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": + 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 self.content_stack.get_visible_child_name() != actual_pid: + self.content_stack.set_visible_child_name(actual_pid) + self.current_page = actual_pid + + # 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) + elif actual_pid == "welcome": + 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: + 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""" + r = self.nav_list.get_first_child() + while r: + 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() + + # ───────────────────────────────────────────────────────────────────────── + # SYSTEM CHECK + # ───────────────────────────────────────────────────────────────────────── + + def check_system(self): + def check(): + h_sun = self.system_check.has_sunshine() + h_moon = self.system_check.has_moonlight() + h_docker = self.system_check.has_docker() + h_tail = self.system_check.has_tailscale() + h_zt = self.system_check.has_zerotier() + + r_sun = self.system_check.is_sunshine_running() + r_moon = self.system_check.is_moonlight_running() + r_docker = self.system_check.is_docker_running() + r_tail = self.system_check.is_tailscale_running() + r_zt = self.system_check.is_zerotier_running() + + 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) + + def p_check(self): + def check(): + r_sun = self.system_check.is_sunshine_running() + r_moon = self.system_check.is_moonlight_running() + r_docker = self.system_check.is_docker_running() + r_tail = self.system_check.is_tailscale_running() + r_zt = self.system_check.is_zerotier_running() + GLib.idle_add(self.update_server_status, r_sun, r_moon, r_docker, r_tail, r_zt) + + 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 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)) + + # ───────────────────────────────────────────────────────────────────────── + # SERVICE CONTROL DIALOG + # ───────────────────────────────────────────────────────────────────────── + + def on_service_clicked(self, service_id): + """Open service control dialog""" + meta = SERVICE_METADATA.get(service_id) + 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"]]) + 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) + 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) + + 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") + 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") + 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"]) + status_box.append(dot) + status_lbl = Gtk.Label(label=_("Running") if is_running else _("Stopped")) + status_box.append(status_lbl) + content.append(status_box) + + actions = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + + def run_cmd(action, sid=service_id, force_type=None): + m = SERVICE_METADATA[sid] + cmd = [] + + def find_moonlight(): + for b in ["moonlight-qt", "moonlight"]: + if shutil.which(b): + return b + return None + + current_type = force_type or m["type"] + + if current_type == "service": + cmd = ["pkexec", "/usr/bin/systemctl"] + 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"] + else: + bin_name = m["bin"] + if sid == "moonlight": + found = find_moonlight() + if not found and action in ["start", "restart"]: + self.show_toast(_("Moonlight not found")) + return + bin_name = found or bin_name + + if action == "start": + cmd = [bin_name] + 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 + cmd = [bin_name] + + if cmd: + try: + subprocess.Popen(cmd) + 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) + except Exception as e: + self.show_toast(_("Error executing command: {}").format(e)) + + btn_main = Gtk.Button(label=_("Stop") if is_running else _("Start")) + btn_main.add_css_class("suggested-action" if not is_running else "destructive-action") + btn_main.connect("clicked", lambda b: run_cmd("stop" if is_running else "start")) + actions.append(btn_main) + + btn_restart = Gtk.Button(label=_("Restart")) + btn_restart.connect("clicked", lambda b: run_cmd("restart")) + actions.append(btn_restart) + + 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": + actions.append(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)) + + cont_running = self.system_check.are_containers_running() + cont_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + + 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(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_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) + cont_box.append(c_status_box) + + 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")) + 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")) + cont_box.append(c_btn_restart) + + cont_box.set_sensitive(is_running) + actions.append(cont_box) + + content.append(actions) + + tv = Adw.ToolbarView() + hb = Adw.HeaderBar() + tv.add_top_bar(hb) + tv.set_content(content) + dialog.set_content(tv) + dialog.present() diff --git a/usr/share/big-remote-play/ui/moonlight_preferences.py b/src/big_remote_play/ui/moonlight_preferences.py similarity index 89% rename from usr/share/big-remote-play/ui/moonlight_preferences.py rename to src/big_remote_play/ui/moonlight_preferences.py index 38df4b8..db4987d 100644 --- a/usr/share/big-remote-play/ui/moonlight_preferences.py +++ b/src/big_remote_play/ui/moonlight_preferences.py @@ -1,32 +1,27 @@ 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 +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") +from gi.repository import Gtk, Adw # type: ignore -from utils.i18n import _ +from big_remote_play.utils.i18n import _ + +from big_remote_play.utils.moonlight_config import MoonlightConfigManager -from utils.i18n import _ -from 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() self.config.reload() self.setup_ui() 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) @@ -37,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)) @@ -48,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)) @@ -72,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)) @@ -82,7 +80,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 +90,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) @@ -101,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)) @@ -111,7 +110,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 +119,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) @@ -136,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)) @@ -147,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)) @@ -161,7 +162,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) @@ -171,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/usr/share/big-remote-play/ui/performance_monitor.py b/src/big_remote_play/ui/performance_monitor.py similarity index 62% rename from usr/share/big-remote-play/ui/performance_monitor.py rename to src/big_remote_play/ui/performance_monitor.py index 56e4265..7d1c18a 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 # type: ignore +from big_remote_play.utils.i18n import _ try: import cairo @@ -31,20 +29,23 @@ 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: """Single data point for performance chart.""" + latency: float fps: float bandwidth: float - device_latencies: dict + device_latencies: dict[str, float] latency_text: str fps_text: str bandwidth_text: str users_count: int = 0 + class PerformanceChartWidget(Gtk.DrawingArea): """ Modern chart widget for network/video performance. @@ -78,25 +79,37 @@ 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): - # Remove sufixos de estado para manter a cor consistente - base_name = name.split('(')[0].strip() + # 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) 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): - 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 + + 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 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, @@ -105,7 +118,7 @@ def add_data_point(self, latency: float, fps: float, bandwidth: float, users: in 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 @@ -156,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): @@ -169,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] @@ -189,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) @@ -214,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: @@ -227,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: @@ -239,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) - # Limpar nome para legenda - clean_label = label.split('(')[0].strip() + # Clean name for the legend + 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) @@ -261,17 +278,20 @@ 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) 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) @@ -300,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) @@ -340,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 para persistência de dispositivos + + # 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: @@ -370,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: pass + except Exception: + pass def set_target_bandwidth(self, mbps): try: @@ -379,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: 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): @@ -414,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: @@ -434,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: @@ -443,152 +467,179 @@ 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 - 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: + 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 + + return load_sunshine_credentials() + + 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 isinstance(root, Gtk.Window): + 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 + sunshine = self.sunshine + self.set_sensitive(False) + auth = self._sunshine_auth() + + def work(): + ok = 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 - + if not ip and not session_id: + return + 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() - + 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() - # 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" - - 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: - # Regex robusto para tempo=1.23, time=1.23, ttl... time=1.23 - match = re.search(r'(?:time|tempo|ttl)[=<]([\d\.,]+)\s*ms', result.stdout, re.IGNORECASE) + # 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(',', '.') + val_str = match.group(1).replace(",", ".") return float(val_str) - # Fallback simples - match_fallback = re.search(r'([\d\.,]+)\s*ms', result.stdout) + # Simple fallback + 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: 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'] + 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] - # Regex mais permissivo para pegar IP - ip_match = re.search(r'(\d+\.\d+\.\d+\.\d+)', last_part) + # 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) - 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 @@ -596,205 +647,166 @@ 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 + # 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 - 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 - - # 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 + 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' - } - - # 5. PING E LIMPEZA - # Vamos iterar sobre TODOS os dispositivos conhecidos, não só os ativos + 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(): - # 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 + data["last_latency"] = lat + data["last_seen"] = now # Renovamos "visto" se ping responde else: - # Se falhou ping, usa ultimo conhecido ou 0 - lat = data.get('last_latency', 0) - - # Definir nome de exibição - display_name = data['name'] + # If ping failed, use the last known value or 0 + lat = data.get("last_latency", 0) + + # 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 - 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 + # Optional: indicate idle, but the user asked for PERPETUAL + 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 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) - - # 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 - if fps == 0: fps = self._last_fps if self._last_fps > 0 else self._target_fps - else: self._last_fps = fps - + # 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 + 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)" - # 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: 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 + 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 - 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: - 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) @@ -802,7 +814,7 @@ def update_stats(self, latency, fps, bandwidth, sessions=None, device_latencies= else: self.set_connection_status("Sunshine", _("Active - No devices"), True) self._details_frame.set_visible(False) - + self._update_guest_list(sessions) except Exception: pass @@ -812,61 +824,70 @@ 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) - - # Separar Nome e IP para visual mais limpo - if '(' in full_name: - name_part = full_name.split('(')[0].strip() + 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() 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: - # 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) 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: 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_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) 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") @@ -874,4 +895,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 new file mode 100644 index 0000000..50e66c2 --- /dev/null +++ b/src/big_remote_play/ui/preferences.py @@ -0,0 +1,307 @@ +""" +Application preferences window +""" + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") + +from gi.repository import Gtk, Adw # type: ignore +import os, shutil, sys +from pathlib import Path + +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""" + + def __init__(self, **kwargs): + self.config = kwargs.pop("config", None) + self.initial_tab = kwargs.pop("initial_tab", None) + super().__init__(**kwargs) + + 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") + + # Appearance group + appearance_group = Adw.PreferencesGroup() + appearance_group.set_title(_("Appearance")) + + # Theme + theme_row = Adw.ComboRow() + 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_row.set_model(theme_model) + theme_row.set_model(theme_model) + + # Load current theme + current_theme = self.config.get("theme", "auto") + idx = 0 + 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) + + appearance_group.add(theme_row) + general_page.add(appearance_group) + + # Restore group + restore_group = Adw.PreferencesGroup() + 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_btn.set_valign(Gtk.Align.CENTER) + 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_btn.set_valign(Gtk.Align.CENTER) + 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") + + # Paths group + paths_group = Adw.PreferencesGroup() + paths_group.set_title(_("Paths")) + + config_row = Adw.ActionRow() + 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_valign(Gtk.Align.CENTER) + 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) + + paths_group.add(config_row) + + # Logs group + logs_group = Adw.PreferencesGroup() + 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_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_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) + + 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. + self.add(general_page) + self.add(advanced_page) + + 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) + + 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) + + def on_verbose_toggled(self, row, param): + enabled = row.get_active() + 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.")) + diag.add_response("ok", _("OK")) + diag.set_transient_for(self) + diag.present() + + def copy_config_path(self, btn): + 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. + # 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" + 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.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() + + def on_clear_all_clicked(self, button): + # Double check implementation + 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=_("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) + + 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" + if config_dir.exists(): + shutil.rmtree(config_dir) + + # 2. Cache/Logs (~/.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" + if moon_dir.exists(): + 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() + if app: + 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")) + 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 57% 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..fb52ae0 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 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_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 +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_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 _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 _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,21 @@ 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) + 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: 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,23 +355,22 @@ 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)) - content.append(hdr) + # Install status (above the form): is the VPN package present? + content.append(self._build_install_status()) + + installed = self._is_vpn_installed() - # Form group + # 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) @@ -255,32 +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) - - self._btn_action = Gtk.Button() - self._btn_action.add_css_class("pill") - 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) @@ -288,7 +439,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) @@ -298,17 +449,134 @@ 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. 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) + 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 _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 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,39 +584,37 @@ 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': - 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) + elif self.vpn_id == "tailscale": + # 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")) 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 + 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() self._e_zt_token = Adw.PasswordEntryRow(title=_("ZeroTier API Token")) if saved: self._e_zt_token.set_text(saved) @@ -362,7 +628,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 +640,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,41 +655,40 @@ 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 - ) - for s in inputs: - try: - proc.stdin.write(s) - proc.stdin.flush() - except Exception: - break + # LC_ALL=C / LANGUAGE= force the script's gettext prose back to its + # English msgid; data is parsed from locale-independent BRP_* markers. + # 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: + try: + proc_stdin.write(s) + proc_stdin.flush() + except Exception: + break captured = {} - 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 + phase = 0.1 + 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]: + captured[kind[1]] = kind[2] + # 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]) + 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 +704,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 +719,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 +742,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 +776,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 +800,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 +810,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 +831,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 +859,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 +889,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 +905,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) @@ -716,7 +948,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() @@ -735,36 +969,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 +995,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 +1032,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 +1092,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 +1102,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 +1118,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 +1144,47 @@ 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), + subprocess.run(["pkexec", "/usr/bin/tailscale", "logout"], timeout=30) + + 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': - 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(["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': + 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), + subprocess.run(["pkexec", "/usr/bin/tailscale", "logout"], timeout=30) + + 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() def _refresh_networks(self): @@ -933,45 +1192,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 +1243,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 +1278,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 +1288,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,14 +1317,16 @@ 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) 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}") @@ -1082,32 +1335,37 @@ 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)) - 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) + actions_box.add_css_class("network-info-actions") actions_box.append(btn_save) actions_box.append(btn_file) actions_box.append(btn_share) @@ -1124,8 +1382,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!")) @@ -1147,6 +1406,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) @@ -1155,17 +1415,37 @@ def _build(self): conn_box.set_margin_start(32) conn_box.set_margin_end(32) - 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")) - 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() @@ -1212,8 +1492,10 @@ 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) status_box.append(hdr2) @@ -1223,28 +1505,19 @@ 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 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 +1527,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) @@ -1278,38 +1551,37 @@ 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.ViewSwitcher(stack=stack) - 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) 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 +1590,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 +1601,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 +1682,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 +1690,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 +1718,30 @@ 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 - ) - for s in inputs: - try: - proc.stdin.write(s) - proc.stdin.flush() - except Exception: - break - 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]) + # LC_ALL=C: parse locale-independent BRP_* markers, not localized prose. + 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: + try: + proc_stdin.write(s) + proc_stdin.flush() + except Exception: + break + phase = 0.1 + 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 + 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 +1754,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 +1790,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 +1806,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 +1830,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 +1896,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: @@ -1636,15 +1916,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 os.path.exists(ZT_TOKEN_FILE): - self._peers_store.append(("ℹ️", _("API Token Missing"), _("See banner above"), "", "", "", "")) + if self.vpn_id == "zerotier" and not _has_zerotier_api_token(): + 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) @@ -1656,11 +1980,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 +2043,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,56 +2062,57 @@ 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)) 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) @@ -1798,10 +2121,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 +2132,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 +2191,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 +2210,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 +2249,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 +2259,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 +2269,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 +2299,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 +2344,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 +2354,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 +2370,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) @@ -2063,7 +2395,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!")) 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..2924552 --- /dev/null +++ b/src/big_remote_play/ui/sunshine_preferences.py @@ -0,0 +1,796 @@ +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") +from gi.repository import Gtk, Gdk, Adw, GLib # type: ignore +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 +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 +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) -> None: + try: + 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}") + + 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) -> tuple[str, str] | None: + return load_sunshine_credentials(conf_path=self.config.config_file) + + 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: Gtk.Widget) -> None: + 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 LEGACY_SECRET_KEYS} + + def work() -> None: + 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.")), + ( + "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) + if monitor is None: + continue + 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 72% rename from usr/share/big-remote-play/utils/__init__.py rename to src/big_remote_play/utils/__init__.py index 5b5afdb..110404f 100644 --- a/usr/share/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 new file mode 100644 index 0000000..b508b70 --- /dev/null +++ b/src/big_remote_play/utils/audio.py @@ -0,0 +1,326 @@ +import logging +import subprocess +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. + Focuses on important configurations: + 1. Host Only (Default) + 2. Host + Guest (Streaming Active) + """ + + def is_virtual(self, name: str, description: str = "") -> bool: + """Checks if a sink is virtual""" + 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") + + def get_passive_sinks(self) -> List[Dict[str, str]]: + """ + Lists physical output devices (Hardware). + Aggressively filters virtual sinks to avoid loops. + """ + sinks = [] + try: + # Get sinks with pactl + 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) + + # Filtrar + valid_sinks = [] + for s in sinks: + 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}") + return [] + + def get_default_sink(self) -> Optional[str]: + try: + 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 + + def set_default_sink(self, sink_name: str): + try: + 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: + """ + Activates Streaming mode. + If guest_only=True: Games -> Null Sink (Sunshine captures). Host is muted. + If guest_only=False: Games -> Null Sink -> Loopback -> Hardware. Host hears too. + """ + # If host_sink is virtual or null, try to find first real hardware + 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"] + _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) + + 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) + + # 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, + ) + + # 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) + + # 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: + _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) + 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 + + # 1. Always try to unload existing loopback first + try: + 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] + _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: + _log.info("Host monitoring disabled (Muted)") + return True + + # 2. Load Loopback + try: + # Check if host_sink is safe + if host_sink == "SunshineGameSink": + _log.error("ERROR: Cannot loopback to itself.") + 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, + ) + 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) + candidate = f"{sink_name}.monitor" + + for line in res.stdout.splitlines(): + parts = line.split() + if len(parts) > 1: + source_name = parts[1] + # 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 + except Exception: + return f"{sink_name}.monitor" + + def disable_streaming_audio(self, host_sink: Optional[str]) -> None: + """ + Disables Streaming mode. + Restores default sink and removes virtual modules. + """ + # 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) + 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) + 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 + ): + mod_id = line.split()[0] + _log.info(f"Cleaning audio module: {mod_id}") + subprocess.run(["pactl", "unload-module", mod_id], check=False, timeout=10) + except Exception as e: + _log.error(f"Error cleaning modules: {e}") + + def get_apps(self) -> List[Dict]: + """ + Lists applications playing audio (Sink Inputs). + """ + apps = [] + try: + # ID mapping -> Sink Name for reference + sinks_map = {} + 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, 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) + + # 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 [] + + 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 + + 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 + self.disable_streaming_audio(target) diff --git a/src/big_remote_play/utils/config.py b/src/big_remote_play/utils/config.py new file mode 100644 index 0000000..a10e469 --- /dev/null +++ b/src/big_remote_play/utils/config.py @@ -0,0 +1,92 @@ +""" +Application configuration management +""" + +import json +import os +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.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: + 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") + 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: + _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, + }, + "host": { + "max_players": 2, + "quality": "high", + "audio": True, + "input_sharing": True, + }, + "guest": { + "quality": "auto", + "audio": True, + "hw_decode": True, + "fullscreen": 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 new file mode 100644 index 0000000..e015137 --- /dev/null +++ b/src/big_remote_play/utils/game_detector.py @@ -0,0 +1,196 @@ +import logging +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"]) + + def detect_steam(self): + """Detects Steam games""" + games = [] + steam_root = self.home / ".local/share/Steam" + if not steam_root.exists(): + steam_root = self.home / ".steam/steam" + + if not steam_root.exists(): + return [] + + library_folders = [steam_root / "steamapps"] + + # Try reading libraryfolders.vdf to find other libraries + 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" + # Avoid duplicates + 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"): + 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 + } + ) + except Exception: + pass + return games + + def detect_lutris(self): + """Detects Lutris games via YAML config files""" + games = [] + games_dir = self.home / ".config/lutris/games" + + if games_dir.exists(): + 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("\"'") + break + + if name: + games.append({"name": name, "id": slug, "platform": "Lutris", "cmd": f"lutris lutris:rungame/{slug}", "icon": "lutris"}) + except Exception: + pass + return games + + 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" + + if not heroic_config.exists(): + # Try flatpak + flatpak_config = self.home / ".var/app/com.heroicgameslauncher.hgl/config/heroic" + if flatpak_config.exists(): + heroic_config = flatpak_config + else: + return [] + + # 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") + + # 2. Epic (Legendary) + 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") + + # 4. Sideloaded / Other + possible_files.append(heroic_config / "GamesConfig" / "installed.json") + + # 5. Store Cache (Newer versions often store game data here) + store_cache = heroic_config / "store_cache" + if store_cache.exists(): + for f in store_cache.glob("*_library.json"): + possible_files.append(f) + + processed_ids = set() + + for p in possible_files: + if p.exists(): + try: + content = p.read_text() + 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"] + else: + # 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 + + # 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 + + # Check if installed (some jsons show entire library) + is_installed = item.get("is_installed", True) # Assume true if no flag + if not is_installed: + 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) + + except Exception as 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/src/big_remote_play/utils/icons.py b/src/big_remote_play/utils/icons.py new file mode 100644 index 0000000..3c515d0 --- /dev/null +++ b/src/big_remote_play/utils/icons.py @@ -0,0 +1,105 @@ +import os +import gi + +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 + +# 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.""" + # Check icons (symbolic) first, then img (non-symbolic) + for folder in [ICONS_DIR, IMG_DIR]: + for ext in [".svg", ".png", ".jpg"]: + path = os.path.join(folder, f"{icon_name}{ext}") + if os.path.exists(path): + 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) + if path: + gfile = Gio.File.new_for_path(path) + 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, + # 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) + 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. + + 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) + 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: + 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) + if gicon: + image_widget.set_from_gicon(gicon) + else: + image_widget.set_from_icon_name(icon_name) diff --git a/usr/share/big-remote-play/utils/logger.py b/src/big_remote_play/utils/logger.py similarity index 73% rename from usr/share/big-remote-play/utils/logger.py rename to src/big_remote_play/utils/logger.py index 5262647..6c4aed0 100644 --- a/usr/share/big-remote-play/utils/logger.py +++ b/src/big_remote_play/utils/logger.py @@ -3,82 +3,79 @@ """ import logging -import os 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: 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 new file mode 100644 index 0000000..d200ef1 --- /dev/null +++ b/src/big_remote_play/utils/moonlight_config.py @@ -0,0 +1,66 @@ +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"): + 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", + ] + + 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] + self.config_file.parent.mkdir(parents=True, exist_ok=True) + + self.cp = configparser.ConfigParser() + self.load() + + def load(self) -> None: + if self.config_file and self.config_file.exists(): + try: + 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") + + def reload(self) -> None: + """Force reload from file""" + self.cp = configparser.ConfigParser() + self.load() + + 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: str, default: object = None) -> str: + 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.save() diff --git a/src/big_remote_play/utils/network.py b/src/big_remote_play/utils/network.py new file mode 100644 index 0000000..6647094 --- /dev/null +++ b/src/big_remote_play/utils/network.py @@ -0,0 +1,329 @@ +""" +Network host discovery +""" + +import socket +import subprocess +from typing import List, Dict +from big_remote_play.utils.i18n import _ + +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""" + + 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() + 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: Dict[str, dict] = {} + + 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]) + + # 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": []} + + # Classify IP + 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}" + else: + 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}) + + # 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"]: + try: + # Try to resolve IPv4 explicitly + 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}) + except Exception: + pass + + final_hosts = [] + for name, data in host_map.items(): + # 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. + # However, to be explicit: + if ip_info["type"] == "ipv6_link_local": + display_name += _(" (IPv6 Local)") + 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"]}) + + 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"] + + # 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}") + + # 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) + if res.returncode == 0: + for line in res.stdout.splitlines(): + parts = line.split() + if len(parts) >= 3 and ":" in parts[0]: + ip = parts[0] + if ip.startswith("fe80"): + try: + dev_idx = parts.index("dev") + if dev_idx + 1 < len(parts): + dev = parts[dev_idx + 1] + if not _is_virtual_iface(dev): + targets.append(f"{ip}%{dev}") + 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 + + # 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 + + 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 None + + with ThreadPoolExecutor(max_workers=100) as ex: + for r in ex.map(check, targets): + 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 + + 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 + # 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 + return "" + + def resolve_pin(self, pin: str, timeout: int = 3) -> str: + if not pin or len(pin) != 6: + return "" + import threading + + 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)) + data, addr = s.recvfrom(1024) + 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)) + 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 "" + + 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]: + try: + s = socket.socket(family, socket.SOCK_DGRAM) + 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)) + else: + 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}": + sock.sendto(f"I_HAVE_PIN {name}".encode(), addr) + except Exception: + pass + sock.close() + + threading.Thread(target=listener, args=(s,), daemon=True).start() + 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"]: + 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 + return "None" + + def get_global_ipv6(self) -> str: + 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 + 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 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..694daed --- /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 Any, 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: Any | None = None + self._schema: Any | None = None + try: + import gi + + gi.require_version("Secret", "1") + from gi.repository import Secret # type: ignore + + 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) -> Any: + 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..237c734 --- /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 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. +""" + +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/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/src/big_remote_play/utils/system_check.py b/src/big_remote_play/utils/system_check.py new file mode 100644 index 0000000..1dff444 --- /dev/null +++ b/src/big_remote_play/utils/system_check.py @@ -0,0 +1,198 @@ +""" +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_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 + + 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 (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 (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""" + 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..274620b --- /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 # type: ignore # 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..fb9a996 --- /dev/null +++ b/src/big_remote_play/utils/widgets.py @@ -0,0 +1,315 @@ +"""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") +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 + +# 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_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. + + `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_connect_redesign_regressions.py b/tests/test_connect_redesign_regressions.py new file mode 100644 index 0000000..ba3013d --- /dev/null +++ b/tests/test_connect_redesign_regressions.py @@ -0,0 +1,108 @@ +"""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 + + +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 + + +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 + + +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 + + +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 + + +def test_vpn_form_install_only_when_missing() -> None: + src = PNV.read_text() + assert "_is_vpn_installed" 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: + 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 + + +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/tests/test_css_regressions.py b/tests/test_css_regressions.py new file mode 100644 index 0000000..6ec5cbe --- /dev/null +++ b/tests/test_css_regressions.py @@ -0,0 +1,16 @@ +"""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 + + +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_drop_guest_validation.py b/tests/test_drop_guest_validation.py new file mode 100644 index 0000000..f7efc33 --- /dev/null +++ b/tests/test_drop_guest_validation.py @@ -0,0 +1,34 @@ +"""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_guest_view_a11y_regressions.py b/tests/test_guest_view_a11y_regressions.py new file mode 100644 index 0000000..2d45a98 --- /dev/null +++ b/tests/test_guest_view_a11y_regressions.py @@ -0,0 +1,21 @@ +"""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() + 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 + 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/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/tests/test_host_view_a11y_regressions.py b/tests/test_host_view_a11y_regressions.py new file mode 100644 index 0000000..e0ba680 --- /dev/null +++ b/tests/test_host_view_a11y_regressions.py @@ -0,0 +1,39 @@ +"""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 + + +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 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 diff --git a/tests/test_network.py b/tests/test_network.py new file mode 100644 index 0000000..7c5adc7 --- /dev/null +++ b/tests/test_network.py @@ -0,0 +1,70 @@ +"""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") == [] + + +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"] 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..849a3fd --- /dev/null +++ b/tests/test_script_protocol.py @@ -0,0 +1,57 @@ +"""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..b253d8e --- /dev/null +++ b/tests/test_security_regressions.py @@ -0,0 +1,138 @@ +"""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_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) + + 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..10bab7f --- /dev/null +++ b/tests/test_sunshine_api.py @@ -0,0 +1,385 @@ +"""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/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 diff --git a/tests/test_ui_premium_layout_regressions.py b/tests/test_ui_premium_layout_regressions.py new file mode 100644 index 0000000..8bdb19e --- /dev/null +++ b/tests/test_ui_premium_layout_regressions.py @@ -0,0 +1,107 @@ +"""Static guards for the premium layout elements mirrored from the mockups.""" + +import re +from pathlib import Path + + +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" 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 + + +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 + + +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 "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 + 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_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 + + 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: + 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/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/guest/moonlight_client.py b/usr/share/big-remote-play/guest/moonlight_client.py deleted file mode 100644 index 4fa64e6..0000000 --- a/usr/share/big-remote-play/guest/moonlight_client.py +++ /dev/null @@ -1,184 +0,0 @@ -import subprocess, shutil -class MoonlightClient: - def __init__(self, logger=None): - self.process = None; self.connected_host = 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): - """Prepares IP for Moonlight CLI.""" - if not ip: return "" - clean_ip = ip.strip() - - # Remove brackets if present to facilitate processing - 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'): - try: - # Get interface with default route - route = subprocess.check_output(['ip', '-6', 'route', 'show', 'default'], text=True).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}" - else: - # Dumb fallback: get first UP interface that is not lo - try: - import json - out = subprocess.check_output(['ip', '-j', 'addr'], text=True) - 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 - - return clean_ip - - def connect(self, ip, **kw): - if not self.moonlight_cmd or self.is_connected(): return False - - try: - target_ip = self._prepare_ip(ip) - - cmd = [self.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 - - if self.logger: - import threading - def log_output(pipe, level): - for line in iter(pipe.readline, ''): - if line: getattr(self.logger, level, self.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() - - try: - exit_code = self.process.wait(timeout=1.0) - msg = f"Moonlight ended prematurely (Code {exit_code})" - if self.logger: self.logger.error(msg) - return False - except subprocess.TimeoutExpired: pass - - return True - except Exception as e: - 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 disconnect(self): - 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 - except: - if self.process: self.process.kill(); self.process = None; self.connected_host = None - return False - - def probe_host(self, host_ip): - 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) - return res.returncode == 0 - except Exception as e: - if self.logger: self.logger.error(f"Probe error: {e}") - return False - - def pair(self, host_ip, on_pin_callback=None): - 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) - success = False - - while True: - line = self.process.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 "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") - 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}") - return False - - def list_apps(self, host_ip): - 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: - # 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 - - # 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 - - 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: - 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.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 [] - - def get_status(self): return {'connected': self.is_connected(), 'host': self.connected_host, 'moonlight_cmd': self.moonlight_cmd} 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/main.py b/usr/share/big-remote-play/main.py deleted file mode 100644 index bcbfb88..0000000 --- a/usr/share/big-remote-play/main.py +++ /dev/null @@ -1,113 +0,0 @@ -import sys, os, gi, locale, gettext -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 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") - -class BigRemotePlayApp(Adw.Application): - def __init__(self): - 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) - 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)] - for name, callback in actions: - action = Gio.SimpleAction.new(name, None) - 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) - self.load_custom_css() - - def setup_icon(self): - it = Gtk.IconTheme.get_for_display(Gdk.Display.get_default()) - if os.path.exists(ICONS_DIR): - it.add_search_path(ICONS_DIR) - 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 = Path(__file__).parent / 'ui' / '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): - 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', - license_type=Gtk.License.GPL_3_0, - 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): - from ui.preferences import PreferencesWindow - pref_win = PreferencesWindow(transient_for=self.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'): - # 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() - - pref_win.connect('close-request', on_close) - pref_win.present() - - def do_shutdown(self): - try: Adw.Application.do_shutdown(self) - except: pass - os._exit(0) - -def main(): - import signal - signal.signal(signal.SIGINT, signal.SIG_DFL) - return BigRemotePlayApp().run(sys.argv) - -if __name__ == '__main__': - sys.exit(main()) 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..ebd680d 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,747 @@ #!/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}" + + # Install tailscale from the official repos (pacman, not AUR). + sudo pacman -S --needed --noconfirm tailscale + + # 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}" + + # `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 (pkexec); 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}" + fi + + echo -e "${YELLOW}$(gettext 'Waiting for authentication...')${NC}" + for _ in {1..60}; do + sleep 2 + 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}" + 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/scripts/install-vpn.sh b/usr/share/big-remote-play/scripts/install-vpn.sh new file mode 100644 index 0000000..8943f76 --- /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 (pkexec). 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." diff --git a/usr/share/big-remote-play/ui/guest_view.py b/usr/share/big-remote-play/ui/guest_view.py deleted file mode 100644 index 61ea531..0000000 --- a/usr/share/big-remote-play/ui/guest_view.py +++ /dev/null @@ -1,1013 +0,0 @@ - - -import gi -gi.require_version('Gtk', '4.0') -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 - -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 utils.logger import Logger - self.config = Config() - self.logger = None - 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() - self.discover_hosts() - GLib.timeout_add(1000, self.monitor_connection) - - 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(800) - for m in ['top', 'bottom', 'start', 'end']: getattr(clamp, f'set_margin_{m}')(24) - content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=24) - - - 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() - switcher.set_stack(self.method_stack) - switcher.set_policy(Adw.ViewSwitcherPolicy.WIDE) - switcher.set_halign(Gtk.Align.CENTER) - - self.switcher_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) - self.switcher_box.append(switcher) - 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')) - 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) - - 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.fps_row = Adw.ComboRow() - 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) - 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()) - 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_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')) - 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) - 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'): - 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.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) - self.perf_monitor.start_monitoring() - - else: - 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) - - self.show_toast(_("Moonlight closed")) - - # Update UI visibility - self.update_ui_state() - - 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, '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() - - def _update_all_buttons_state(self): - 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 connected: - # State: Connected -> Button becomes Stop - b.set_sensitive(True) - b.remove_css_class('suggested-action') - b.add_css_class('destructive-action') - l.set_label(_("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') - l.set_label(_("Stop")) - 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') - l.set_label(default_text) - s.set_visible(False); s.stop() - - # Update Discover Button - # Logic specific for discover: only sensitive if host selected (when disconnected) - has_host = self.selected_host_card_data 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"), - default_sensitive=has_host) - - # Update Manual Button - 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")) - - def check_reconnect(self): - 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']) - - def check_reconnect_debounced(self): - """Checks if reconnection is needed (with debounce)""" - # Cancel previous - 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) - - 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) - action = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=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.set_child(btn_box) - - 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() - 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) - - action.append(buttons_box); box.append(header); box.append(host_scroll); box.append(action) - return box - - def discover_hosts(self): - from 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) - 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) - 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)) - return False - NetworkDiscovery().discover_hosts(callback=on_hosts_discovered) - - 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: - 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) - 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) - 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) - 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")) - def copy_ip(btn): - Gdk.Display.get_default().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) - 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) - - 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.') - - port = Adw.EntryRow() - 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.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')) - 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')) - - 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) - - 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.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')) - 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')) - - 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: - self.on_cancel_connection(None) - else: - # Start connection based on source - 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()) - 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: - 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() - fps_idx = self.fps_row.get_selected() - bitrate_val = self.bitrate_scale.get_value() - 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') - - 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']) - if apps is not None: - is_paired = True - break - if i < checks - 1: - 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 scale_active: - # res = self.get_auto_resolution() # RISK - 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") - 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): - 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.')))) - - # 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: - 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): - 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')))) - - threading.Thread(target=run_patched, daemon=True).start() - else: - 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'] - - if is_local: - # Attempt automation - try: - from host.sunshine_manager import SunshineHost - from pathlib import Path - 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 - 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'))) - - def do_pair(): - self.show_toast(_("Starting pairing...")) - 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: - success = True - - if success: - GLib.idle_add(lambda: (self.show_toast(_("Paired successfully!")), self.connect_to_host(host, paired_retry=True))) - else: - 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() - - def on_cancel_connection(self, btn): - self.show_toast(_("Canceling connection...")) - self.is_connecting = False - self.show_loading(False) - 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 - - def show_pairing_dialog(self, host_ip, pin=None, on_confirm=None, hostname=None): - 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 "")) - 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) - def on_resp(dlg, resp): - 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: - self.pairing_dialog.close() - self.pairing_dialog = None - - 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) - if not monitor: - monitors = display.get_monitors() - 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 - 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}) - - def connect_pin(self, _widget): - # Reverse Connection via PIN (Custom Feature) - pin = self.pin_entry.get_text() - 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 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') - - self.show_toast(_("Host found: {}").format(hostname)) - 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.get_root()) - 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'): - 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'): - 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")) - 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.get_root()) - - 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': - val = entry.get_text() - 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 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()) - 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" - 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) - - # 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) - - # Bitrate (kbps) - br = int(self.bitrate_scale.get_value() * 1000) - 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) - - # 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') - - # 2. Save Local Settings (Not in Moonlight.conf or specific to GuestView) - s = self.config.get('guest', {}) - 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))) - - # 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) - else: - 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) - else: - self.fps_row.set_selected(3) - self.custom_fps_val = str(fps) - - # Bitrate - 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) - - # HW Decode - 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', {}) - self.scale_row.set_active(s.get('scale_native', False)) - self.audio_row.set_active(s.get('audio', True)) - except: 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.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': - 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() - def show_toast(self, m): - w = self.get_root() - if hasattr(w, 'show_toast'): w.show_toast(m) - else: print(f"Toast: {m}") - - def show_shortcuts_dialog(self): - dialog = Adw.Window(transient_for=self.get_root()) - 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) - - 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")), - ("Ctrl+Alt+Shift+S", _("Toggle Stats Overlay")), - ("Ctrl+Alt+Shift+M", _("Toggle Mouse Mode (Remote/Absolute)")), - ("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) - row.set_subtitle(desc) - # 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.") - ) - 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.") - ) - 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/usr/share/big-remote-play/ui/host_view.py b/usr/share/big-remote-play/ui/host_view.py deleted file mode 100644 index 4396e10..0000000 --- a/usr/share/big-remote-play/ui/host_view.py +++ /dev/null @@ -1,1836 +0,0 @@ -import gi -gi.require_version('Gtk', '4.0') -gi.require_version('Adw', '1') - -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 -class HostView(Gtk.Box): - def __init__(self): - self.loading_settings = True - super().__init__(orientation=Gtk.Orientation.VERTICAL) - self.config = Config() - self.is_hosting = False - self.process = None # Initialize to avoid AttributeError - self.pin_code = None - self.private_audio_apps = set() - - from host.sunshine_manager import SunshineHost - 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.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() - - self.sync_ui_state() - - def detect_monitors(self): - monitors = [(_('Automatic'), 'auto')] - is_wayland = os.environ.get('XDG_SESSION_TYPE') == 'wayland' - - # Method: GDK (Most consistent for labels and Wayland indices) - names = [] - 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) - conn = monitor.get_connector() - if conn: - 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 conn - full_label = f"{label} ({conn})" - monitors.append((full_label, val)) - names.append(conn) - except Exception as e: - print(f"Error detecting GDK monitors: {e}") - - # Fallback for X11/DRM if GDK didn't find everything - 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) - 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 - - # 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] - if name not in names: - monitors.append((f"DRM Display ({name})", name)) - names.append(name) - except: pass - - return monitors - - def detect_gpus(self): - gpus = [] - try: - lspci = subprocess.check_output(['lspci'], text=True).lower() - if 'nvidia' in lspci: - try: - subprocess.check_call(['nvidia-smi'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - gpus.append({'label': 'NVENC (NVIDIA)', 'encoder': 'nvenc', 'adapter': 'auto'}) - except: pass - if 'intel' in lspci: gpus.append({'label': 'VAAPI (Intel Quicksync)', 'encoder': 'vaapi', 'adapter': '/dev/dri/renderD128'}) - except: 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 - 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) - for margin in ['top', 'bottom', 'start', 'end']: - getattr(clamp, f'set_margin_{margin}')(24) - - content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=24) - - 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')) - - 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')) - 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) - - 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_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_app_expander.add_row(self.custom_name_entry) - - self.custom_cmd_entry = Adw.EntryRow() - self.custom_cmd_entry.set_title(_('Command')) - 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') - - # Resolution Row - self.resolution_row = Adw.ComboRow() - self.resolution_row.set_title(_("Resolution")) - self.resolution_row.set_subtitle(_("Stream resolution")) - res_model = Gtk.StringList() - 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.streaming_expander.add_row(self.resolution_row) - - # FPS Row - self.fps_row = Adw.ComboRow() - self.fps_row.set_title(_("Frame Rate (FPS)")) - self.fps_row.set_subtitle(_("Frames per second")) - fps_model = Gtk.StringList() - 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.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.monitor_row = Adw.ComboRow() - 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) - 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')) - gpu_model = Gtk.StringList() - 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)')) - platform_model = Gtk.StringList() - 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) - 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")) - opt_model = Gtk.StringList() - opt_model.append(_("Low Latency (Fastest)")) - 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.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')) - - # 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) - 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') - - 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 - - self.audio_mode_row.set_model(mode_model) - 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 - - 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.upnp_row = Adw.SwitchRow() - 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_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_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') - - 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) - - 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) - - 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) - self.start_btn_spinner = Gtk.Spinner() - self.start_btn_spinner.set_visible(False) - 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) - - 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.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.set_visible(False) - self.pin_button.connect('clicked', self.open_pin_dialog) - button_box.append(self.pin_button) - - 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) - 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) - self.view_stack.add_titled_with_icon(audio_page, "audio", _("Audio"), "audio-x-generic-symbolic") - - # 4. PIN Code Page - pin_page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) - 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") - copy_btn.set_valign(Gtk.Align.CENTER) - copy_btn.set_tooltip_text(_("Copy 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 - - # Register PIN for updates (it was removed from summary_box) - self.field_widgets['pin'] = { - 'label': self.pin_display_label, - 'real_value': '', - 'revealed': True - } - - # Switcher setup - view_switcher = Adw.ViewSwitcher() - view_switcher.set_stack(self.view_stack) - view_switcher.set_policy(Adw.ViewSwitcherPolicy.WIDE) - 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) - content.append(view_switcher) - content.append(self.view_stack) - - 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) - - self.start_audio_watchdog() - - def start_audio_watchdog(self): - # Watchdog simplified or removed, as flow is explicitly controlled - pass - - def _check_audio_state(self): - return True - - def _get_sunshine_conf_path(self): - 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: pass - return None - - 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: 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") - - try: - conf_file.parent.mkdir(parents=True, exist_ok=True) - with open(conf_file, 'w') as f: - f.writelines(final_lines) - except Exception as e: - print(f"Error saving Sunshine creds: {e}") - - def _ensure_sunshine_config(self): - """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") - - with open(conf_file, 'w') as f: - f.writelines(final_lines) - - except Exception as e: - print(f"Error ensuring sunshine config: {e}") - - def open_pin_dialog(self, _widget): - 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 = '' - - dialog = Adw.MessageDialog( - heading=_("Insert PIN"), - body=_("Enter the PIN displayed on the client device (Moonlight).") - ) - dialog.set_transient_for(self.get_root()) - - # Grupo de preferências para conter os campos - 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) - - pass_row = Adw.PasswordEntryRow(title=_("Sunshine Password")) - 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_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() - device_name = name_row.get_text().strip() - u = user_row.get_text().strip() - p = pass_row.get_text().strip() - save = save_chk.get_active() - - if not pin: return - - # Update saved credentials if requested - if save and u and p: - conf.set('sunshine_user', u) - conf.set('sunshine_password', 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: - 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 - 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.set_transient_for(self.get_root()) - 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": - import webbrowser - webbrowser.open("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.set_transient_for(self.get_root()) - - 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) - 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 - - 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) - 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): - # 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.set_transient_for(self.get_root()) - - 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) - 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 - - # Tentar novamente com credenciais - success, msg = self.sunshine.send_pin(pin_to_retry, name=device_name, auth=(user, pwd)) - if success: - self.show_toast(_("PIN sent successfully")) - # Save credentials on success - self._save_sunshine_creds(user, pwd) - 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.set_header_suffix(create_icon_widget('preferences-other-symbolic', size=24)) - self.summary_box.set_visible(False); 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 - - 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 utils.audio import AudioManager - 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'))) - - self.audio_output_row.set_model(model) - # Try to keep selection if possible, or use config - h = self.config.get('host', {}) - 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() - model.append(_("Error loading audio")) - self.audio_output_row.set_model(model) - - def on_audio_output_changed(self, row, param): - 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'] - else: - new_sink = self.audio_manager.get_default_sink() - - # 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: - 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: - # 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(): - self.show_error_dialog(_("Error"), f"Script not found: {script_path}") - return - - # Run with pkexec - cmd = ['pkexec', str(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.")) - - def run(): - try: - 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: - 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, key, icon_name='text-x-generic-symbolic', default_revealed=False): - 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.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') - copy_btn = Gtk.Button() - copy_btn.set_child(create_icon_widget('edit-copy-symbolic', size=16)) - copy_btn.add_css_class('flat') - - 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} - 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): - field = self.field_widgets[key] - field['revealed'] = not field['revealed'] - field['btn_eye'].set_child(create_icon_widget('view-conceal-symbolic' if field['revealed'] else 'view-reveal-symbolic', size=16)) - 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']: - self.get_root().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() - return False - - 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() - - # Button State: Hosting -> Stop - self.start_btn_label.set_label(_('Stop')) - 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) - - 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'): - 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) - - 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'): - 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')) - 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) - - def populate_summary_fields(self): - import socket, threading - from 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() - 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() - - # Wrap IPv6 in brackets for compatibility - 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) - 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) - - 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.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'): - GLib.source_remove(self.mixer_source_id) - del self.mixer_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 - - 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 - should_monitor = True - 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 - should_monitor = False - 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 - should_monitor = not has_localhost - - if hasattr(self, 'audio_manager'): - try: - # Update loopback - 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 - - # Default sink hijack fix - current_default = self.audio_manager.get_default_sink() - if current_default and current_default != private_sink: - if "sunshine" in current_default.lower() and "stereo" in current_default.lower(): - self.audio_manager.set_default_sink(private_sink) - - 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 - - target = private_sink if (not streaming_enabled or name in self.private_audio_apps) else shared_sink - 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 - - apps = self.audio_manager.get_apps() - seen_ids = set() - - if not hasattr(self, 'mixer_rows'): self.mixer_rows = {} - - for app in apps: - 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) - - 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.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']) - row.set_active(is_shared) - 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): - is_shared = row.get_active() - if is_shared: - if app_name in self.private_audio_apps: - 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() - - try: - if self.sunshine.is_running(): - self.sunshine.stop() - import time; time.sleep(1) - - self.pin_code = ''.join(random.choices(string.digits, k=6)) - from 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', []) - 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'] - } - elif mode_idx == 2: # Lutris - idx = self.game_list_row.get_selected() - if idx != Gtk.INVALID_LIST_POSITION: - 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'] - } - elif mode_idx == 3: # Custom App - 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 - } - - # 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 = 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 - - 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' - } - - - # Audio Configuration - # Audio Configuration - ALWAYS setup PulseAudio infrastructure - # This allows toggling streaming on/off without restarting server or destroying sinks - 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'] - else: - 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): - 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 - # 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 - - # Enable Host+Guest Streaming (Create Sink) - if 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 - # 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) - - if self.audio_manager.enable_streaming_audio(host_sink, guest_only=guest_only): - 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) - - 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 - - - # Set output_name if a specific monitor is selected, others set to None to remove from config - 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 - - # 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' - - 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')) - - # 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.is_hosting = False - self.sync_ui_state() # Revert state - finally: - self.loading_bar.set_visible(False) - self.start_button.set_sensitive(True) # Reabilitar botão - 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) - if not info: - print("Game Mode: Desktop (no game to launch)") - return - - 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'] - 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 - ) - 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 - ) - 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'] - print(f"DIRECT LAUNCH: Lutris - {game_name} ({cmd})") - - p = subprocess.Popen( - cmd.split(), - 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'] - print(f"DIRECT LAUNCH: Custom - {game_name} ({cmd})") - - p = subprocess.Popen( - cmd, shell=True, - 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) - - # Close Steam Big Picture if we opened it - if info and info.get('type') == 'steam': - try: - subprocess.Popen( - ['steam', 'steam://close/bigpicture'], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL - ) - print("Closing Steam Big Picture") - except: pass - - # Kill tracked 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: pass - - self._game_processes = [] - self._game_launch_info = None - - def stop_hosting(self, b=None): - self.show_toast(_("Stopping server...")) - 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: 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: - 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}") - - # Hard kill fallback - subprocess.run(['pkill', '-9', 'sunshine'], stderr=subprocess.DEVNULL) - - self.is_hosting = False - self.sync_ui_state() - self.loading_bar.set_visible(False) - self.start_button.set_sensitive(True) - self.start_btn_spinner.stop() - self.start_btn_spinner.set_visible(False) - self.show_toast(_('Server stopped')) - - def update_status_info(self): - 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 - - if not self.is_hosting: return True - - if hasattr(self, 'sunshine_val'): - self.sunshine_val.set_markup('On-line' if sunshine_running else 'Parado') - 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]) - return True - except: 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 - try: - res = subprocess.run(['ip', '-j', 'addr'], capture_output=True, text=True) - if res.returncode == 0: - for iface in json.loads(res.stdout): - 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': - # Prioritize global but accept link-local - 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: 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.") - - 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, - # 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.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' - subprocess.Popen(['xdg-open', str(log_path)]) - except: pass - elif r == "fix": - app = self.get_root().get_application() - if hasattr(app, 'show_preferences'): - app.show_preferences(tab='sunshine') - - dialog.connect("response", on_response) - dialog.present() - - def show_error_dialog(self, title, message): - dialog = Adw.MessageDialog.new(self.get_root(), 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) - else: print(f"Toast: {message}") - - def open_sunshine_config(self, button): - subprocess.Popen(['xdg-open', 'https://localhost:47990']) - - def on_game_mode_changed(self, row, param): - idx = row.get_selected() - self.platform_games_expander.set_visible(idx in [1, 2]) - self.platform_games_expander.set_expanded(idx in [1, 2]) - 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] - 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 - 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() - games = self.detected_games[plat] - new_model = Gtk.StringList() - if not games: new_model.append(f"No games found on {plat}") - else: - 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', {}) - 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) - - # 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 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') - streaming_active = self.audio_mode_row.get_selected() in [0, 1, 3] - 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) - - # Map Wi-Fi Mode (FEC) - # Enabled -> 20%. Disabled -> 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 - - # 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') - - except Exception as e: - print(f"Error syncing to Sunshine config: {e}") - - def load_settings(self): - self.loading_settings = True - try: - # Sync from Sunshine Config first - try: - from 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['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') - - # Reverse Map Wi-Fi (FEC) - # If FEC >= 15 -> Enabled - 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 - - # 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}") - - - h = self.config.get('host', {}) - 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) - if mode_idx in [1, 2]: - self.populate_game_list(mode_idx) - 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', '')) - - # 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) - - # 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) - 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)) - - # Audio Mode - 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)) - - # 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)) - 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) - - - for r in [self.bandwidth_row]: - 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) - - 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) - def on_resp(d, r): - 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")) - - def cleanup(self): - if hasattr(self, 'perf_monitor'): self.perf_monitor.stop_monitoring() - if hasattr(self, 'stop_pin_listener'): self.stop_pin_listener() - - # 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() - diff --git a/usr/share/big-remote-play/ui/installer_window.py b/usr/share/big-remote-play/ui/installer_window.py deleted file mode 100644 index d1ef9e2..0000000 --- a/usr/share/big-remote-play/ui/installer_window.py +++ /dev/null @@ -1,126 +0,0 @@ -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 -from gi.repository import Gtk, Adw, GLib, Gio -import subprocess, os, shutil -from 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_default_size(700, 500) - self.set_modal(True) - - # Main content - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) - box.set_margin_top(12) - box.set_margin_bottom(12) - 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') - 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) - frame.set_child(self.terminal) - - # Start installation directly - GLib.idle_add(self.start_installation) - else: - # Fallback text view - scrolled = Gtk.ScrolledWindow() - self.textview = Gtk.TextView() - 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.")) - - 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...')) - 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()) - 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])] - started = False - for t in terms: - if shutil.which(t[0]): - try: subprocess.Popen(t); started = True; break - except: 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...')) - 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) - 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: - self.on_success() - else: - 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') - - # 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')) diff --git a/usr/share/big-remote-play/ui/main_window.py b/usr/share/big-remote-play/ui/main_window.py deleted file mode 100644 index 2bbca9f..0000000 --- a/usr/share/big-remote-play/ui/main_window.py +++ /dev/null @@ -1,978 +0,0 @@ -import gi -gi.require_version('Gtk', '4.0'); gi.require_version('Adw', '1') -from gi.repository import Gtk, Adw, GLib, Gio -import threading -import json -import os -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 _ -import subprocess -import shutil - -# ─── VPN Provider Config ─────────────────────────────────────────────────── -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', - }, - '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', - }, -} - -# Service Definitions -SERVICE_METADATA = { - 'sunshine': { - 'name': 'SUNSHINE', - 'full_name': _('Sunshine Game Stream Host'), - 'description': _('High-performance game stream host. Required to share your games.'), - '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.'), - 'type': 'app', - 'bin': 'moonlight-qt' - }, - 'docker': { - 'name': 'DOCKER', - 'full_name': _('Docker Engine'), - 'description': _('Container platform. Required for the private network server.'), - 'type': 'service', - 'unit': 'docker.service', - 'user': False - }, - 'tailscale': { - 'name': 'TAILSCALE', - 'full_name': _('Tailscale'), - 'description': _('Mesh VPN service. Required for Tailscale connectivity.'), - 'type': 'service', - 'unit': 'tailscaled.service', - 'user': False - }, - 'zerotier': { - 'name': 'ZEROTIER', - 'full_name': _('ZeroTier'), - 'description': _('Virtual network service. Required for ZeroTier connectivity.'), - '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': _('Host 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' - }, -} - - -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: - data = json.load(f) - choice = data.get('vpn_provider') - if choice in VPN_PROVIDERS: - return choice - except Exception: - pass - return None - - -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) - - -class MainWindow(Adw.ApplicationWindow): - """Main window with modern side navigation""" - - def __init__(self, **kwargs): - super().__init__(**kwargs) - - 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._vpn_choice = load_vpn_choice() # None if not yet chosen - - self.setup_ui() - self.check_system() - - # Connect close signal - 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: 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(220); self.split_view.set_max_sidebar_width(280) - - def _build_navigation_pages(self): - """Build the navigation page list based on current VPN choice.""" - 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['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'), - } - else: - # Single "connect" entry that leads to VPN selector - pages['vpn_selector'] = { - 'name': _('Select VPN'), - 'icon': 'network-private-symbolic', - 'description': _('Choose your VPN provider'), - } - 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) - 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')) - - def _refresh_nav_list(self): - """Rebuild the navigation list based on VPN choice.""" - # Clear existing rows - while child := self.nav_list.get_first_child(): - self.nav_list.remove(child) - - nav_pages = self._build_navigation_pages() - for pid, info in nav_pages.items(): - 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.set_halign(Gtk.Align.START) - sep_box.append(label) - - row = Gtk.ListBoxRow() - row.set_child(sep_box) - row.set_activatable(False) - row.set_selectable(False) - self.nav_list.append(row) - 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) - - 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 - row.add_css_class('category-row') - - box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) - box.set_margin_start(8) - box.set_margin_end(8) - box.set_margin_top(6) - box.set_margin_bottom(6) - - icon = create_icon_widget(page_info['icon'], size=20, css_class='category-icon') - box.append(icon) - - label = Gtk.Label(label=page_info['name']) - label.set_halign(Gtk.Align.START) - label.set_hexpand(True) - label.add_css_class('category-label') - box.append(label) - - # Badge showing selected VPN name - 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.set_halign(Gtk.Align.END) - box.append(badge) - - row.set_child(box) - return row - - def create_status_footer(self): - """Creates footer with server status""" - footer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - footer.set_margin_start(12) - footer.set_margin_end(12) - footer.set_margin_top(8) - footer.set_margin_bottom(12) - footer.set_spacing(8) - - separator = Gtk.Separator() - 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.set_halign(Gtk.Align.START) - status_title.set_margin_bottom(4) - footer.append(status_title) - - card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) - card.add_css_class("info-card") - self.status_card = card - - def add_status_row(container, label_text, dot_attr, lbl_attr, service_id): - row = Gtk.Box(spacing=10) - row.add_css_class("info-row") - row.service_id = service_id - - click = Gtk.GestureClick() - click.connect("pressed", lambda g, n, x, y, sid=service_id: self.on_service_clicked(sid)) - row.add_controller(click) - - 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']) - setattr(self, dot_attr, dot) - box_key.append(dot) - lbl_key = Gtk.Label(label=label_text) - lbl_key.add_css_class('info-key') - box_key.append(lbl_key) - row.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) - 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') - - footer.append(card) - self._filter_status_rows() - return footer - - def _filter_status_rows(self): - """Show only relevant services based on VPN choice.""" - if not hasattr(self, 'status_card'): return - - vpn = self._vpn_choice - visible_services = ['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 = getattr(child, 'service_id', None) - 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) - ]: - 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 = [ - (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') - ] - - for lbl, card, has, name in status_items: - status_text = _("Installed") if has else _("Missing") - lbl.set_markup(f'{status_text}') - - if card: - tooltip = "" - if not has: - 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(); 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('', '')) - 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') - - # 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') - - # 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') - - ct.set_content(self.content_stack) - self.split_view.set_content(Adw.NavigationPage.new(ct, 'Big Remote Play')) - - # ───────────────────────────────────────────────────────────────────────── - # VPN SELECTOR PAGE - # ───────────────────────────────────────────────────────────────────────── - - def create_vpn_selector_page(self): - """Full-page VPN provider selector shown when no VPN is chosen.""" - scroll = Gtk.ScrolledWindow() - scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) - scroll.set_vexpand(True) - - clamp = Adw.Clamp() - clamp.set_maximum_size(900) - for m in ['top', 'bottom', 'start', 'end']: - getattr(clamp, f'set_margin_{m}')(32) - - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=32) - - # 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( - _('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) - - # Cards row - cards_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=20) - cards_box.set_halign(Gtk.Align.CENTER) - cards_box.set_homogeneous(True) - - for pid, info in VPN_PROVIDERS.items(): - card = self._create_vpn_card(pid, info) - cards_box.append(card) - - box.append(cards_box) - - # Comparison table - compare_group = Adw.PreferencesGroup() - compare_group.set_title(_('Quick Comparison')) - compare_group.set_header_suffix(create_icon_widget('preferences-other-symbolic', size=18)) - 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) - - clamp.set_child(box) - scroll.set_child(clamp) - return scroll - - 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') - btn.set_size_request(240, 220) - btn.connect('clicked', lambda b, pid=provider_id: self._on_vpn_selected(pid)) - - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=14) - 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) - - # Icon - icon = create_icon_widget(info['icon'], size=52) - icon.add_css_class('accent') - box.append(icon) - - # Name - name_lbl = Gtk.Label(label=info['name']) - name_lbl.add_css_class('title-2') - 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.set_wrap(True) - desc_lbl.set_max_width_chars(28) - desc_lbl.set_justify(Gtk.Justification.CENTER) - box.append(desc_lbl) - - # "Choose" label - choose_lbl = Gtk.Label(label=_('Choose →')) - choose_lbl.add_css_class('caption-heading') - box.append(choose_lbl) - - btn.set_child(box) - return btn - - 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'] - ) - ) - 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: - self._apply_vpn_selection(provider_id) - - def _disconnect_vpn(self, vpn_id): - """Disconnect from a specific VPN provider.""" - self.show_toast(_("Disconnecting from {}...").format(VPN_PROVIDERS[vpn_id]['name'])) - def run(): - if vpn_id in ('headscale', 'tailscale'): - subprocess.run(["bigsudo", "tailscale", "logout"]) - 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"]) - 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): - self._vpn_choice = provider_id - save_vpn_choice(provider_id) - - # Rebuild private network views with the chosen provider - from .private_network_view import PrivateNetworkView - - # Remove old views if they exist - 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') - - # 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')) - - def reset_vpn_choice(self): - """Clear VPN choice and show selector again.""" - self._vpn_choice = None - try: - if os.path.exists(VPN_CONFIG_FILE): - os.remove(VPN_CONFIG_FILE) - except Exception: - pass - self._refresh_nav_list() - 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) - - 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) - - logo_img = create_icon_widget('big-remote-play', size=128) - logo_img.set_halign(Gtk.Align.CENTER) - logo_img.set_valign(Gtk.Align.CENTER) - logo_img.set_margin_bottom(20) - 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) - - spacer = Gtk.Box() - spacer.set_size_request(-1, 32) - main_box.append(spacer) - - 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') - - self.host_card = self.create_action_card( - _('Host Server'), - _('Share your games with other players on the network'), - 'network-server-symbolic', - 'suggested-action', - lambda: self.navigate_to('host') - ) - - self.guest_card = self.create_action_card( - _('Connect to Server'), - _('Connect to a game server on the network'), - 'network-workgroup-symbolic', - 'suggested-action', - lambda: self.navigate_to('guest') - ) - - 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) - - scroll.set_child(main_box) - return scroll - - def create_action_card(self, title, desc, icon, cls, cb): - btn = Gtk.Button() - btn.add_css_class('action-card') - if cls: btn.add_css_class(cls) - btn.set_size_request(280, 220) - btn.set_hexpand(False) - btn.set_vexpand(False) - btn.connect('clicked', lambda b: cb()) - - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16) - 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) - - img = create_icon_widget(icon, size=64) - img.set_size_request(64, 64) - 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: - img.add_css_class('accent') - box.append(img) - - tl = Gtk.Label(label=title) - 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.set_wrap(True) - dl.set_max_width_chars(25) - dl.set_justify(Gtk.Justification.CENTER) - dl.set_hexpand(False) - box.append(dl) - - btn.set_child(box) - return btn - - # ───────────────────────────────────────────────────────────────────────── - # NAVIGATION - # ───────────────────────────────────────────────────────────────────────── - - def on_nav_selected(self, lb, row): - if not row: return - pid = getattr(row, 'page_id', None) - 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 = c.get_next_sibling() - row.add_css_class('active-category') - - 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': - 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 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 - 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: - self.nav_list.select_row(r) - break - r = r.get_next_sibling() - - # ───────────────────────────────────────────────────────────────────────── - # SYSTEM CHECK - # ───────────────────────────────────────────────────────────────────────── - - def check_system(self): - def check(): - h_sun = self.system_check.has_sunshine() - h_moon = self.system_check.has_moonlight() - h_docker = self.system_check.has_docker() - h_tail = self.system_check.has_tailscale() - h_zt = self.system_check.has_zerotier() - - r_sun = self.system_check.is_sunshine_running() - r_moon = self.system_check.is_moonlight_running() - r_docker = self.system_check.is_docker_running() - 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), - self.update_dependency_ui(h_sun, h_moon, h_docker, h_tail, h_zt) - )) - threading.Thread(target=check, daemon=True).start() - GLib.timeout_add_seconds(3, self.p_check) - - def p_check(self): - def check(): - r_sun = self.system_check.is_sunshine_running() - r_moon = self.system_check.is_moonlight_running() - r_docker = self.system_check.is_docker_running() - r_tail = self.system_check.is_tailscale_running() - r_zt = self.system_check.is_zerotier_running() - GLib.idle_add(self.update_server_status, r_sun, r_moon, r_docker, r_tail, r_zt) - - 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 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)) - - # ───────────────────────────────────────────────────────────────────────── - # SERVICE CONTROL DIALOG - # ───────────────────────────────────────────────────────────────────────── - - def on_service_clicked(self, service_id): - """Open service control dialog""" - meta = SERVICE_METADATA.get(service_id) - 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']]) - is_enabled = subprocess.run(cmd).returncode == 0 - - dialog = Adw.Window(transient_for=self) - dialog.set_modal(True) - 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) - - 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') - 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') - 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']) - status_box.append(dot) - status_lbl = Gtk.Label(label=_("Running") if is_running else _("Stopped")) - status_box.append(status_lbl) - content.append(status_box) - - actions = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) - - def run_cmd(action, sid=service_id, force_type=None): - m = SERVICE_METADATA[sid] - cmd = [] - - def find_moonlight(): - for b in ['moonlight-qt', 'moonlight']: - if shutil.which(b): return b - return None - - current_type = force_type or m['type'] - - if current_type == 'service': - cmd = ["bigsudo", "systemctl"] - 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'] - else: - bin_name = m['bin'] - if sid == 'moonlight': - found = find_moonlight() - if not found and action in ['start', 'restart']: - self.show_toast(_("Moonlight not found")) - return - bin_name = found or bin_name - - if action == 'start': - cmd = [bin_name] - elif action == 'stop': - cmd = ["pkill", "-x", bin_name] - elif action == 'restart': - try: subprocess.run(["pkill", "-x", bin_name], check=False) - except: pass - cmd = [bin_name] - - if cmd: - try: - subprocess.Popen(cmd) - 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) - except Exception as e: - self.show_toast(_("Error executing command: {}").format(e)) - - btn_main = Gtk.Button(label=_("Stop") if is_running else _("Start")) - btn_main.add_css_class("suggested-action" if not is_running else "destructive-action") - btn_main.connect("clicked", lambda b: run_cmd("stop" if is_running else "start")) - actions.append(btn_main) - - btn_restart = Gtk.Button(label=_("Restart")) - btn_restart.connect("clicked", lambda b: run_cmd("restart")) - actions.append(btn_restart) - - 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': - actions.append(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)) - - cont_running = self.system_check.are_containers_running() - cont_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) - - 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(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_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) - cont_box.append(c_status_box) - - 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')) - 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')) - cont_box.append(c_btn_restart) - - cont_box.set_sensitive(is_running) - actions.append(cont_box) - - content.append(actions) - - tv = Adw.ToolbarView() - hb = Adw.HeaderBar() - tv.add_top_bar(hb) - tv.set_content(content) - dialog.set_content(tv) - dialog.present() diff --git a/usr/share/big-remote-play/ui/preferences.py b/usr/share/big-remote-play/ui/preferences.py deleted file mode 100644 index 7f61b5d..0000000 --- a/usr/share/big-remote-play/ui/preferences.py +++ /dev/null @@ -1,319 +0,0 @@ -""" -Application preferences window -""" - -import gi -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 pathlib import Path - -from utils.icons import create_icon_widget -import utils.logger as logger -from 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) - super().__init__(**kwargs) - - self.set_title(_('Preferências')) - self.set_default_size(600, 500) - self.set_modal(True) - - self.logger = logger.Logger() - if not self.config: - from utils.config import Config - self.config = Config() - - 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 - general_page = Adw.PreferencesPage() - general_page.set_title(_('Geral')) - general_page.set_icon_name('preferences-system-symbolic') - - # Appearance group - appearance_group = Adw.PreferencesGroup() - appearance_group.set_title(_('Aparência')) - - # Theme - theme_row = Adw.ComboRow() - theme_row.set_title(_('Tema')) - theme_row.set_subtitle(_('Escolha o esquema de cores')) - - theme_model = Gtk.StringList() - theme_model.append(_('Automático')) - theme_model.append(_('Claro')) - theme_model.append(_('Escuro')) - - theme_row.set_model(theme_model) - theme_row.set_model(theme_model) - - # Load current theme - current_theme = self.config.get('theme', 'auto') - idx = 0 - 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) - - appearance_group.add(theme_row) - general_page.add(appearance_group) - - # Restore group - restore_group = Adw.PreferencesGroup() - restore_group.set_title(_('Restaurar')) - - # 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_btn = Gtk.Button(label=_('Restaurar')) - restore_btn.set_valign(Gtk.Align.CENTER) - 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(_('Limpar Tudo')) - clear_all_row.set_subtitle(_('Remover logs, configurações, servidores e dados salvos')) - - clear_all_btn = Gtk.Button(label=_('Limpar Tudo')) - 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_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(_('Avançado')) - advanced_page.set_icon_name('preferences-other-symbolic') - - # Paths group - paths_group = Adw.PreferencesGroup() - paths_group.set_title(_('Caminhos')) - - config_row = Adw.ActionRow() - config_row.set_title(_('Diretório de Configuração')) - 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_valign(Gtk.Align.CENTER) - copy_config_btn.set_tooltip_text(_("Copiar Caminho")) - 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 e Depuração')) - - verbose_row = Adw.SwitchRow() - verbose_row.set_title(_('Logs Detalhados')) - verbose_row.set_subtitle(_('Ativar logging verbose para depuração')) - 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_btn = Gtk.Button(label=_('Limpar')) - 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(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 - 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): - idx = row.get_selected() - 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) - - def on_verbose_toggled(self, row, param): - enabled = row.get_active() - 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.")) - diag.add_response("ok", _("OK")) - diag.set_transient_for(self) - diag.present() - - - - 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!"))) - - - 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.") - - dialog = Adw.MessageDialog(heading=_("Restaurar Padrões?"), body=msg) - dialog.add_response("cancel", _("Cancelar")) - dialog.add_response("restore", _("Restaurar")) - 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. - # 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' - if sunshine_conf.exists(): - 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 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.close() - except Exception as e: - print(f"Error resetting configs: {e}") - self.add_toast(Adw.Toast.new(_("Erro ao restaurar: {}").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.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.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' - if config_dir.exists(): - shutil.rmtree(config_dir) - - # 2. Cache/Logs (~/.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' - if moon_dir.exists(): - 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() - if app: - app.quit() - else: - sys.exit(0) - - except Exception as e: - err_dlg = Adw.MessageDialog(heading=_("Erro ao Limpar"), 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/style.css b/usr/share/big-remote-play/ui/style.css index e1b544e..4fcc0f0 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; } +.header-identity { + padding: 0 4px; +} + +.header-logo { + -gtk-icon-shadow: 0 4px 12px alpha(@accent_bg_color, 0.2); +} + +.header-title { + font-size: 0.98em; + font-weight: 800; +} + +.header-subtitle { + font-size: 0.82em; + opacity: 0.68; +} + .category-icon { opacity: 0.85; } @@ -179,6 +198,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 { @@ -208,22 +253,6 @@ entry:focus { animation-delay: 0.3s; } -/* Typography */ -.hero-title { - font-size: 32px; - font-weight: 800; - letter-spacing: -0.8px; - color: @theme_fg_color; -} - -.hero-subtitle { - font-size: 15px; - font-weight: 400; - opacity: 0.55; - letter-spacing: 0.1px; -} - - /* System Info Card - Refined glassmorphism with subtle shine */ .info-card { background: alpha(@card_bg_color, 0.55); @@ -255,7 +284,7 @@ entry:focus { font-size: 10px; font-weight: 700; opacity: 0.4; - letter-spacing: 1px; + letter-spacing: 0; text-transform: uppercase; } @@ -264,6 +293,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; @@ -319,7 +383,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); @@ -329,7 +393,7 @@ entry:focus { box-shadow: 0 6px 24px alpha(@accent_bg_color, 0.4); } -.bottom-bar { +.network-info-actions { padding: 14px 28px 14px 28px; } @@ -398,4 +462,209 @@ levelbar block.filled { .action-card.suggested-action button.flat { opacity: 0.7; font-size: 0.85em; -} \ No newline at end of file +} + +/* ─── 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; +} + +/* 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); + 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; +} + +.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, +.settings-action-row-button, +.file-browser-row-button { + padding: 0; + border-radius: 0; +} + +.management-row-button:hover, +.settings-action-row-button:hover, +.file-browser-row-button:hover { + background: alpha(@theme_fg_color, 0.06); +} + +.management-row-title, +.settings-action-row-title { + font-weight: 500; +} + +.management-row-subtitle, +.settings-action-row-subtitle { + font-size: 0.85em; + opacity: 0.65; +} + +.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); +} 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/audio.py b/usr/share/big-remote-play/utils/audio.py deleted file mode 100644 index 5cdd4f5..0000000 --- a/usr/share/big-remote-play/utils/audio.py +++ /dev/null @@ -1,309 +0,0 @@ -import subprocess -from typing import List, Dict, Optional -import time -from utils.i18n import _ - -class AudioManager: - """ - Simplified and robust Audio Manager for Big Remote Play. - Focuses on important configurations: - 1. Host Only (Default) - 2. Host + Guest (Streaming Active) - """ - - def is_virtual(self, name: str, description: str = "") -> bool: - """Checks if a sink is virtual""" - 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') - - def get_passive_sinks(self) -> List[Dict[str, str]]: - """ - Lists physical output devices (Hardware). - Aggressively filters virtual sinks to avoid loops. - """ - sinks = [] - try: - # Get sinks with pactl - res = subprocess.run(['pactl', 'list', 'sinks'], capture_output=True, text=True) - 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) - - # Filtrar - valid_sinks = [] - for s in sinks: - if not self.is_virtual(s.get('name', ''), s.get('description', '')): - valid_sinks.append(s) - - return valid_sinks - except Exception as e: - print(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) - return res.stdout.strip() if res.returncode == 0 else None - except: return None - - def set_default_sink(self, sink_name: str): - try: - subprocess.run(['pactl', 'set-default-sink', sink_name], check=False) - except: pass - - def enable_streaming_audio(self, host_sink: str, guest_only: bool = False) -> bool: - """ - Activates Streaming mode. - If guest_only=True: Games -> Null Sink (Sunshine captures). Host is muted. - If guest_only=False: Games -> Null Sink -> Loopback -> Hardware. Host hears too. - """ - # If host_sink is virtual or null, try to find first real hardware - 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'] - print(f"Host sink was virtual or null, fallback to hardware: {host_sink}") - else: - print("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})") - - # 1. Create Null Sink - subprocess.run([ - 'pactl', 'load-module', 'module-null-sink', - 'sink_name=SunshineGameSink', - 'sink_properties=device.description=SunshineGameSink' - ], check=True) - - # 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": - print("ERROR: Cannot loopback to itself. Creating loopback skipped.") - else: - print(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) - - # 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) - - # 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 - if 'SunshineGameSink' not in sinks: - print("CRITICAL ERROR: SunshineGameSink was not created!") - return False - - print(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}") - 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 - - # 1. Always try to unload existing loopback first - try: - res = subprocess.run(['pactl', 'list', 'short', 'modules'], capture_output=True, text=True) - 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 - - if not enabled: - print("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.") - return False - - print(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) - return True - except Exception as e: - print(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) - candidate = f"{sink_name}.monitor" - - for line in res.stdout.splitlines(): - parts = line.split() - if len(parts) > 1: - source_name = parts[1] - # 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 - except: - return f"{sink_name}.monitor" - - def disable_streaming_audio(self, host_sink: str): - """ - Disables Streaming mode. - Restores default sink and removes virtual modules. - """ - # 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', '')): - print(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}") - - # 2. Unload specific modules - try: - res = subprocess.run(['pactl', 'list', 'short', 'modules'], capture_output=True, text=True) - 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: - - mod_id = line.split()[0] - print(f"Cleaning audio module: {mod_id}") - subprocess.run(['pactl', 'unload-module', mod_id], check=False) - except Exception as e: - print(f"Error cleaning modules: {e}") - - def get_apps(self) -> List[Dict]: - """ - Lists applications playing audio (Sink Inputs). - """ - apps = [] - try: - # ID mapping -> Sink Name for reference - sinks_map = {} - res_s = subprocess.run(['pactl', 'list', 'short', 'sinks'], capture_output=True, text=True) - 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) - 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) - - # 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 [] - - 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 - - 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 - self.disable_streaming_audio(target) diff --git a/usr/share/big-remote-play/utils/config.py b/usr/share/big-remote-play/utils/config.py deleted file mode 100644 index c029d17..0000000 --- a/usr/share/big-remote-play/utils/config.py +++ /dev/null @@ -1,76 +0,0 @@ -""" -Application configuration management -""" - -import json -import os -from pathlib import Path - -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.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: - return json.load(f) - except Exception as e: - print(f"Error loading configuration: {e}") - return self.default_config() - else: - return self.default_config() - - def save(self): - """Saves configuration""" - try: - with open(self.config_file, 'w') as f: - json.dump(self.config, f, indent=2) - except Exception as e: - print(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, - }, - 'host': { - 'max_players': 2, - 'quality': 'high', - 'audio': True, - 'input_sharing': True, - }, - 'guest': { - 'quality': 'auto', - 'audio': True, - 'hw_decode': True, - 'fullscreen': False, - }, - 'advanced': { - 'verbose_logging': False, - 'auto_start_sunshine': False, - } - } diff --git a/usr/share/big-remote-play/utils/game_detector.py b/usr/share/big-remote-play/utils/game_detector.py deleted file mode 100644 index 87f64fc..0000000 --- a/usr/share/big-remote-play/utils/game_detector.py +++ /dev/null @@ -1,192 +0,0 @@ -import os -import json -import re -from pathlib import Path - -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']) - - def detect_steam(self): - """Detects Steam games""" - games = [] - steam_root = self.home / '.local/share/Steam' - if not steam_root.exists(): - steam_root = self.home / '.steam/steam' - - if not steam_root.exists(): - return [] - - library_folders = [steam_root / 'steamapps'] - - # Try reading libraryfolders.vdf to find other libraries - 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' - # Avoid duplicates - if lib_path.resolve() != (steam_root / 'steamapps').resolve(): - library_folders.append(lib_path) - except Exception as e: - print(f"Error reading Steam library: {e}") - pass - - for lib in library_folders: - 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 - }) - except: - pass - return games - - def detect_lutris(self): - """Detects Lutris games via YAML config files""" - games = [] - games_dir = self.home / '.config/lutris/games' - - if games_dir.exists(): - 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('"\'') - break - - if name: - games.append({ - 'name': name, - 'id': slug, - 'platform': 'Lutris', - 'cmd': f'lutris lutris:rungame/{slug}', - 'icon': 'lutris' - }) - except: - pass - return games - - 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' - - if not heroic_config.exists(): - # Try flatpak - flatpak_config = self.home / '.var/app/com.heroicgameslauncher.hgl/config/heroic' - if flatpak_config.exists(): - heroic_config = flatpak_config - else: - return [] - - # 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') - - # 2. Epic (Legendary) - 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') - - # 4. Sideloaded / Other - possible_files.append(heroic_config / 'GamesConfig' / 'installed.json') - - # 5. Store Cache (Newer versions often store game data here) - store_cache = heroic_config / 'store_cache' - if store_cache.exists(): - for f in store_cache.glob('*_library.json'): - possible_files.append(f) - - processed_ids = set() - - for p in possible_files: - if p.exists(): - try: - content = p.read_text() - 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'] - else: - # 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 - - # 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 - - # Check if installed (some jsons show entire library) - is_installed = item.get('is_installed', True) # Assume true if no flag - if not is_installed: - 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) - - except Exception as e: - print(f"Error reading Heroic {p}: {e}") - pass - - return games 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/icons.py b/usr/share/big-remote-play/utils/icons.py deleted file mode 100644 index f422a5e..0000000 --- a/usr/share/big-remote-play/utils/icons.py +++ /dev/null @@ -1,59 +0,0 @@ -import os -from gi.repository import Gtk, Gio - -# 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") - -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) - for folder in [ICONS_DIR, IMG_DIR]: - for ext in [".svg", ".png", ".jpg"]: - path = os.path.join(folder, f"{icon_name}{ext}") - if os.path.exists(path): - 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) - if path: - gfile = Gio.File.new_for_path(path) - 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, - # 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) - else: - img.add_css_class(css_class) - - 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) - if gicon: - image_widget.set_from_gicon(gicon) - else: - image_widget.set_from_icon_name(icon_name) diff --git a/usr/share/big-remote-play/utils/moonlight_config.py b/usr/share/big-remote-play/utils/moonlight_config.py deleted file mode 100644 index 09adb0c..0000000 --- a/usr/share/big-remote-play/utils/moonlight_config.py +++ /dev/null @@ -1,61 +0,0 @@ -import configparser -import os -from pathlib import Path - -class MoonlightConfigManager: - _shared_state = {} - - def __init__(self): - self.__dict__ = self._shared_state - - 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' - ] - - self.config_file = 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] - self.config_file.parent.mkdir(parents=True, exist_ok=True) - - self.cp = configparser.ConfigParser() - self.load() - - def load(self): - if self.config_file and self.config_file.exists(): - try: - self.cp.read(self.config_file) - except Exception as e: - print(f"Error loading Moonlight config: {e}") - - if 'General' not in self.cp: - self.cp.add_section('General') - - def reload(self): - """Force reload from file""" - self.cp = configparser.ConfigParser() - self.load() - - def save(self): - try: - with open(self.config_file, 'w') as f: - self.cp.write(f) - except Exception as e: - print(f"Error saving Moonlight config: {e}") - - def get(self, key, default=None): - return self.cp.get('General', key, fallback=str(default)) - - def set(self, key, value): - self.cp.set('General', key, str(value)) - self.save() diff --git a/usr/share/big-remote-play/utils/network.py b/usr/share/big-remote-play/utils/network.py deleted file mode 100644 index 114a438..0000000 --- a/usr/share/big-remote-play/utils/network.py +++ /dev/null @@ -1,268 +0,0 @@ -""" -Network host discovery -""" - -import socket -import subprocess -import re -from typing import List, Dict -from utils.i18n import _ - -from 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: hosts = self.manual_scan() - if callback: - from gi.repository import GLib - 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] == '=': - 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': [] - } - - # Classify IP - 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}" - else: - 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}) - - # 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']: - try: - # Try to resolve IPv4 explicitly - 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}) - except: - 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, - # although the subtitle in UI showing the IP is usually enough. - # However, to be explicit: - if ip_info['type'] == 'ipv6_link_local': - display_name += _(" (IPv6 Local)") - 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'] - }) - - 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'] - - # 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}") - - # 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) - if res.returncode == 0: - for line in res.stdout.splitlines(): - parts = line.split() - if len(parts) >= 3 and ':' in parts[0]: - ip = parts[0] - 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: 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 - - # 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 - - 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 - - with ThreadPoolExecutor(max_workers=100) as ex: - for r in ex.map(check, targets): - 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) as s: return True - except: 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 - # 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 - return "" - - def resolve_pin(self, pin: str, timeout: int = 3) -> str: - if not pin or len(pin) != 6: return "" - import threading - 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)) - data, addr = s.recvfrom(1024) - if data.decode().startswith("I_HAVE_PIN"): results['v4'] = addr[0] - except: 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)) - data, addr = s.recvfrom(1024) - if data.decode().startswith("I_HAVE_PIN"): - results['v6'] = addr[0] - except: 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]: - try: - s = socket.socket(family, socket.SOCK_DGRAM) - 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: pass - s.bind(('::', 48011)) - else: - 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}": - sock.sendto(f"I_HAVE_PIN {name}".encode(), addr) - except: pass - sock.close() - - threading.Thread(target=listener, args=(s,), daemon=True).start() - except: pass - - run() - return lambda: running.__setitem__(0, False) - - def get_global_ipv4(self) -> str: - 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: pass - return "None" - - def get_global_ipv6(self) -> str: - 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: 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 None 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 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 879aab5..ef722de 100644 Binary files a/usr/share/locale/pt/LC_MESSAGES/big-remote-play-together.mo and b/usr/share/locale/pt/LC_MESSAGES/big-remote-play-together.mo differ 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 aa6db97..ef722de 100644 Binary files a/usr/share/locale/pt/LC_MESSAGES/big-remote-play.mo and b/usr/share/locale/pt/LC_MESSAGES/big-remote-play.mo differ