Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7aede92244 | ||
|
|
bce434a115 | ||
|
|
18a50d38ab | ||
|
|
c404131924 | ||
|
|
09a5f73393 | ||
|
|
dfc1664806 | ||
|
|
6c0bfd4e4a | ||
|
|
6bfec8bf19 | ||
|
|
1c18c2c328 | ||
|
|
c8e738ef21 | ||
|
|
7e5915e115 | ||
|
|
be5ea10875 | ||
|
|
ba662f848b | ||
|
|
aa896397fe | ||
|
|
7bf367419f | ||
|
|
83cf192d2d | ||
|
|
2a613ebed5 | ||
|
|
8454ab5de3 | ||
|
|
2c74923701 |
No files matched your search
@@ -0,0 +1,10 @@
|
||||
# Force LF line endings for scripts and workflows so Linux/SteamOS execution never breaks
|
||||
*.sh text eol=lf
|
||||
*.py text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.desktop text eol=lf
|
||||
*.bat text eol=crlf
|
||||
*.spv binary
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
@@ -45,3 +45,5 @@ Thumbs.db
|
||||
/scratch/
|
||||
/test_vault
|
||||
/*-plan.md
|
||||
/test_*.cpp
|
||||
/protected-*.md
|
||||
@@ -122,6 +122,8 @@ add_library(pixelvr_core STATIC
|
||||
src/xr/xr_result.cpp
|
||||
src/xr/xr_context.cpp
|
||||
src/xr/render_model.cpp
|
||||
src/xr/vr_keyboard.cpp
|
||||
src/net/smb_credentials.cpp
|
||||
src/gfx/vulkan_renderer.cpp
|
||||
src/gfx/gltf_mesh.cpp
|
||||
src/ui/settings_menu.cpp
|
||||
@@ -130,7 +132,8 @@ add_library(pixelvr_core STATIC
|
||||
src/app/app.cpp)
|
||||
|
||||
target_include_directories(pixelvr_core PUBLIC
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/src")
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/src"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/third_party/openvr")
|
||||
target_include_directories(pixelvr_core PRIVATE "${cgltf_SOURCE_DIR}" "${stb_SOURCE_DIR}")
|
||||
|
||||
target_compile_definitions(pixelvr_core PUBLIC
|
||||
@@ -141,7 +144,8 @@ target_compile_definitions(pixelvr_core PUBLIC
|
||||
target_link_libraries(pixelvr_core PUBLIC
|
||||
OpenXR::openxr_loader
|
||||
Vulkan::Vulkan
|
||||
Threads::Threads)
|
||||
Threads::Threads
|
||||
${CMAKE_DL_LIBS})
|
||||
|
||||
add_dependencies(pixelvr_core pixelvr_shaders)
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
<p align="center">
|
||||
<img src="assets/hero.png" width="100%" alt="Pixel VR Player" />
|
||||
</p>
|
||||
|
||||
# Pixel VR Player
|
||||
|
||||
A native VR video player for the **Valve Steam Frame**, playing flat 2D, 180°, and
|
||||
@@ -13,25 +17,57 @@ A native VR video player for the **Valve Steam Frame**, playing flat 2D, 180°,
|
||||
> NV12) with zero-copy dma-buf import; flat / 180° / 360° projection and mono /
|
||||
> SBS (including half-SBS) / TB stereo, auto-detected from the filename; spatial
|
||||
> audio with a master clock. In-headset UI: file browser (with file type and
|
||||
> size), transport bar, settings menu, and a downloads manager. Native Valve
|
||||
> size), transport bar, settings menu, downloads manager, bookmarks overview,
|
||||
> protected SMB network share manager with virtual keyboard laser auto-focus,
|
||||
> and an encrypted hidden vault. Native Valve
|
||||
> Steam Frame controller support (`XR_VALVE_frame_controller_interaction`) using
|
||||
> the runtime's own render model, with per-button animation on the model and
|
||||
> press-to-bind remapping.
|
||||
|
||||
## Install (prebuilt release)
|
||||
## Screenshots
|
||||
|
||||
One command, from a terminal on the headset (Desktop Mode or over SSH):
|
||||
<p align="center">
|
||||
<img src="docs/ui-redesign/mockup-unified-1-files.jpg" width="49%" alt="Spatial File Browser" />
|
||||
<img src="docs/ui-redesign/mockup-unified-2-downloads.jpg" width="49%" alt="Downloads Manager" />
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="docs/ui-redesign/mockup-unified-3-settings.jpg" width="49%" alt="Settings Panel" />
|
||||
<img src="docs/ui-redesign/mockup-unified-4-cinema-hud.jpg" width="49%" alt="Cinema Playback HUD" />
|
||||
</p>
|
||||
|
||||
## Installation
|
||||
|
||||
Choose whichever method fits your setup:
|
||||
|
||||
### Option A: USB Drive / MicroSD Card (No typing & No Dev Mode needed)
|
||||
|
||||
The simplest method for standard users without touching the terminal:
|
||||
|
||||
1. Download `pixelvr-linux-arm64.tar.gz` from the [Releases](https://github.com/Pixelplanet/Pixel-VR-Player/releases) page onto a USB flash drive or MicroSD card.
|
||||
2. Plug it into your Steam Frame and switch to **Desktop Mode**.
|
||||
3. Open the USB drive in Dolphin and double-click **`Install Pixel VR Player.desktop`**.
|
||||
4. A confirmation dialog will appear when finished. Return to Gaming Mode and launch it directly from your Steam Library!
|
||||
|
||||
### Option B: One command in headset terminal
|
||||
|
||||
If you are already in Desktop Mode on the Steam Frame, open **Konsole** and paste:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Pixelplanet/Pixel-VR-Player/main/tools/install.sh | sh
|
||||
```
|
||||
|
||||
It downloads the latest ARM64 release, verifies its checksum, installs it to
|
||||
`~/PixelVRPlayer`, and adds **Pixel VR Player** to your Steam library as a
|
||||
Non-Steam Game. Restart Steam if it was already running. SteamVR must be
|
||||
installed.
|
||||
*(No root access or disabled read-only filesystem needed; it installs entirely into user space).*
|
||||
|
||||
Launch **Pixel VR Player** from your library, or directly:
|
||||
### Option C: From your PC over Wi-Fi (Requires SSH or Dev Mode)
|
||||
|
||||
If you have SSH enabled or Developer Mode turned on with your headset paired:
|
||||
|
||||
- **Windows:** Double-click [`tools/install-from-pc.bat`](tools/install-from-pc.bat) and enter your headset's IP.
|
||||
- **macOS / Linux:** Run `./tools/install-from-pc.sh`.
|
||||
|
||||
The script connects over SSH, installs the app, and registers the Steam shortcut remotely.
|
||||
|
||||
Launch **Pixel VR Player** directly from your Steam library, or from terminal:
|
||||
|
||||
```bash
|
||||
~/PixelVRPlayer/run.sh # opens the in-headset file browser
|
||||
@@ -48,11 +84,12 @@ Installer options:
|
||||
| `--restart-steam` | close Steam first, then reopen it so the entry appears at once |
|
||||
| `--uninstall` | remove the app and its Steam shortcut |
|
||||
|
||||
Re-run the same command to upgrade. Settings (`~/.config/pixelvr`) and cached
|
||||
thumbnails (`~/.cache/pixelvr`) live outside the install directory and are kept.
|
||||
Re-run the same command to upgrade. Settings (`~/.config/pixelvr`), cached
|
||||
thumbnails (`~/.cache/pixelvr`), and hidden vault media (`~/.pixelvr_vault`) live
|
||||
outside the install directory and are kept intact.
|
||||
|
||||
**Offline install:** download `pixelvr-linux-arm64.tar.gz` from the
|
||||
[Releases](../../releases) page, copy it to the headset, extract it and run
|
||||
[Releases](https://github.com/Pixelplanet/Pixel-VR-Player/releases) page, copy it to the headset, extract it and run
|
||||
`./install.sh` from the extracted folder — or use `run.sh` directly. The app logs
|
||||
to `~/pixelvr-run.log`.
|
||||
|
||||
@@ -62,6 +99,92 @@ V4L2 M2M decoders. For zero-copy dma-buf decoding, build the patched FFmpeg into
|
||||
back to the system FFmpeg with CPU colour conversion.
|
||||
|
||||
|
||||
## Updating
|
||||
|
||||
Updating Pixel VR Player is seamless, fast, and 100% non-destructive.
|
||||
|
||||
> [!NOTE]
|
||||
> **Your data is always preserved!**
|
||||
> All user preferences (`~/.config/pixelvr/config.json`), saved SMB network shares and credentials (`~/.config/pixelvr/smb_shares.json`), quick bookmarks (`~/.config/pixelvr/bookmarks.json`), cached video thumbnails (`~/.cache/pixelvr`), and encrypted media in the Discreet Vault (`~/.pixelvr_vault`) are stored outside the application folder and remain completely untouched across updates.
|
||||
|
||||
Choose your preferred update method:
|
||||
|
||||
### Method 1: 1-Click Desktop Update (Easiest on Headset)
|
||||
|
||||
No terminal or typing required:
|
||||
1. Switch your Steam Frame to **Desktop Mode** (Power menu in SteamVR or SteamOS).
|
||||
2. Double-click the **`Update Pixel VR Player`** icon on your Desktop.
|
||||
3. A popup will notify you that the update has started. The updater verifies SHA-256 checksums and updates the app in-place.
|
||||
4. When finished, a completion notification will appear. Switch back to Gaming Mode and launch the player!
|
||||
|
||||
*(Alternatively, open Dolphin file manager, navigate to `~/PixelVRPlayer`, and double-click `update.sh`)*.
|
||||
|
||||
### Method 2: 1-Click Update from your PC over Wi-Fi
|
||||
|
||||
Update your headset remotely over your local network:
|
||||
- **Windows:** Double-click [`tools/update-from-pc.bat`](tools/update-from-pc.bat), enter your Steam Frame's IP address, and press Enter.
|
||||
- **macOS / Linux:** Run `./tools/update-from-pc.sh <headset-ip>`.
|
||||
|
||||
### Method 3: One Command in Headset Terminal (Konsole / SSH)
|
||||
|
||||
Open **Konsole** on the headset or connect via SSH, then run:
|
||||
|
||||
```bash
|
||||
~/PixelVRPlayer/update.sh
|
||||
```
|
||||
|
||||
Or re-run the web installer command:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Pixelplanet/Pixel-VR-Player/main/tools/install.sh | sh
|
||||
```
|
||||
|
||||
### Method 4: USB Drive / MicroSD Card (Offline)
|
||||
|
||||
1. Download `pixelvr-linux-arm64.tar.gz` from the [Releases](https://github.com/Pixelplanet/Pixel-VR-Player/releases) page to a USB flash drive or MicroSD card.
|
||||
2. Plug it into your Steam Frame and open it in Desktop Mode.
|
||||
3. Double-click **`Install Pixel VR Player.desktop`** (or run `./install.sh --update` from the unpacked folder).
|
||||
|
||||
|
||||
## Uninstallation
|
||||
|
||||
To remove Pixel VR Player and its Steam shortcut from your Steam Frame:
|
||||
|
||||
### From the Headset (Desktop Mode / Konsole or SSH)
|
||||
|
||||
Run the uninstaller script directly:
|
||||
|
||||
```bash
|
||||
~/PixelVRPlayer/install.sh --uninstall
|
||||
```
|
||||
|
||||
Or run the one-command uninstaller:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Pixelplanet/Pixel-VR-Player/main/tools/install.sh | sh -s -- --uninstall
|
||||
```
|
||||
|
||||
This cleanly removes:
|
||||
* The application folder (`~/PixelVRPlayer`)
|
||||
* The Non-Steam Game shortcut from Steam's library (`shortcuts.vdf`)
|
||||
* All custom Steam grid artwork from `~/.steam/steam/userdata/*/config/grid/`
|
||||
|
||||
### From your PC over Wi-Fi
|
||||
|
||||
- **Windows:** Double-click [`tools/uninstall-from-pc.bat`](tools/uninstall-from-pc.bat) and enter your headset's IP.
|
||||
- **macOS / Linux:** Run `./tools/uninstall-from-pc.sh`.
|
||||
|
||||
### Complete Data Purge (Optional)
|
||||
|
||||
By default, user settings (`~/.config/pixelvr`), cached video thumbnails (`~/.cache/pixelvr`), and hidden vault media (`~/.pixelvr_vault`) are kept intact so upgrading or reinstalling never loses your library state.
|
||||
|
||||
To completely erase all application data and settings from the headset, run:
|
||||
|
||||
```bash
|
||||
rm -rf ~/.config/pixelvr ~/.cache/pixelvr ~/.pixelvr_vault
|
||||
```
|
||||
|
||||
|
||||
## Controls
|
||||
|
||||
The laser pointer and the control bar are **separate** input paths, so the bar
|
||||
@@ -86,6 +209,39 @@ the file browser rather than stacking on top of it.
|
||||
All button bindings can be changed in **Settings ▸ Controls** (press-to-bind).
|
||||
|
||||
|
||||
## Discreet Media Vault
|
||||
|
||||
Pixel VR Player includes a private, zero-trace local storage vault (`~/.pixelvr_vault`) hidden from the standard file browser and startup directory scans.
|
||||
|
||||
<details>
|
||||
<summary>🔒 <b>Accessing the Hidden Vault</b> (click to expand)</summary>
|
||||
|
||||
To open or re-lock the vault in VR without bringing up an on-screen keyboard or visible PIN dialog:
|
||||
|
||||
1. Fully squeeze either controller's **grip** (hold down the mechanical **Full Press** click).
|
||||
2. While holding the grip clicked, enter the 6-key factory combination:
|
||||
|
||||
> 🎵 *The Swedish pop sensation that won Eurovision in 1974 with "Waterloo"...*
|
||||
> 📐 *...followed by the two classic axes of the 2D Cartesian plane.*
|
||||
|
||||
<details>
|
||||
<summary>👉 <i>Need the direct button sequence? Click here to reveal</i></summary>
|
||||
|
||||
Hold **Full Grip Click** and press:
|
||||
> **`A` → `B` → `B` → `A` → `X` → `Y`**
|
||||
*(Within 2.5 seconds per step. Haptic feedback confirms each input).*
|
||||
|
||||
</details>
|
||||
|
||||
- **Instant Auto-Lock:** Re-locks automatically and immediately purges in-memory textures when the headset proximity sensor detects removal, or when clicking **[ 🔒 Lock Vault ]** in the sidebar.
|
||||
- **Custom Combinations:** You can record your own custom button sequence anytime in **Settings ▸ Vault & Privacy**.
|
||||
- **Move to Vault:** While unlocked, local videos can be moved directly into the vault from the browser.
|
||||
- **Safe Persistence:** Media inside `~/.pixelvr_vault` is never deleted when updating or uninstalling the app.
|
||||
|
||||
For full architecture and security details, see the [Hidden Vault Technical Specification](docs/HIDDEN_VAULT_SPEC.md).
|
||||
</details>
|
||||
|
||||
|
||||
## Repository layout
|
||||
|
||||
```
|
||||
@@ -195,8 +351,7 @@ choice, and troubleshooting). In short, after a one-time Developer-Mode pairing:
|
||||
# override the target with --host steamos@<ip> or FRAME_HOST
|
||||
```
|
||||
|
||||
Either way the app appears in **Steam ▸ Library ▸ Non-Steam** as *Devkit Game:
|
||||
PixelVRPlayer*. To run it manually over SSH for debugging:
|
||||
Either way the app appears in **Steam ▸ Library ▸ Non-Steam** as **Pixel VR Player**. To run it manually over SSH for debugging:
|
||||
|
||||
```bash
|
||||
ssh steamos@frame '~/devkit-game/PixelVRPlayer/launch.sh /path/to/video.mp4'
|
||||
|
||||
@@ -7,19 +7,71 @@
|
||||
|
||||
---
|
||||
|
||||
## Active backlog (2026-10-14)
|
||||
## Active backlog (2026-10-19 — Completed)
|
||||
|
||||
- [x] **1. Fix Virtual Keyboard Laser Pointer Focus & Tracking**
|
||||
- **Symptom:** Virtual keyboard opened, but laser pointer disappeared or wasn't focused on the keyboard; user had to squeeze/press the controller grip fully to activate it.
|
||||
- **Root Cause:**
|
||||
- SteamVR's `vrcompositor` switches system panel interaction mode to Gamepad (mode 0, hiding `system.pointer`) on session focus loss unless an interactive overlay requiring laser mouse is currently visible (`bOverlaysPresentThatForceLaserMouseOn`).
|
||||
- `inputOverlay_` was created in `VrKeyboard::init()` but was **never shown** (`ShowOverlay` was never called, had no texture, dimensions, or transform).
|
||||
- SteamVR's virtual keyboard overlay `valve.steam.gamepadui.keyboard` is spawned asynchronously and lacks `MakeOverlaysInteractiveIfVisible` by default.
|
||||
- **Resolution:**
|
||||
- Fully configured `inputOverlay_` in `VrKeyboard::show()`: set width (1.0m), HMD-relative transform (1.1m in front, tilted), 1x1 transparent raw pixel texture (`SetOverlayRaw`), and docked keyboard position (`SetKeyboardPositionForOverlay`).
|
||||
- Called `ov->ShowOverlay(inputOverlay_)` before calling `ShowKeyboardForOverlay`, ensuring `vrcompositor`'s overlay scan sets `m_bOverlaysPresentThatForceLaserMouseOn = 1`.
|
||||
- Added dynamic polling in `VrKeyboard::poll()` to detect and configure `MakeOverlaysInteractiveIfVisible` and `VROverlayInputMethod_Mouse` on `valve.steam.gamepadui.keyboard` as soon as it appears.
|
||||
- Added proper overlay cleanup (`HideOverlay(inputOverlay_)`) in `hide()`, `KeyboardDone`, and `KeyboardClosed`.
|
||||
|
||||
- [x] **2. Strict IPv4 Validation & Live Sanitization**
|
||||
- **Symptom:** Host field accepted invalid characters, spaces, and did not strip leading zeros.
|
||||
- **Resolution:**
|
||||
- Created `src/util/ipv4.hpp` with `sanitizeIpv4()` and `isValidIpv4()`.
|
||||
- Live typing sanitization: automatically strips non-digits/dots, discards duplicate dots, clamps octets to 255, drops leading zeros in each octet (e.g. `001` -> `1`, `010` -> `10`, `00` -> `0`), limits to 4 octets, and preserves trailing dots while typing.
|
||||
- Added automated unit test suite `tests/test_ipv4.cpp` and verified all test cases pass on the device.
|
||||
- Verified strict `isValidIpv4()` validation before saving an SMB share in `saveSmbModal()`.
|
||||
|
||||
- [x] **3. Fix `???` Glyphs (Unicode Star Elimination)**
|
||||
- **Symptom:** Sidebar and header button displayed `???` instead of the star icon.
|
||||
- **Root Cause:** FreeType SDF text atlas was initialized only for ASCII codepoints 32..127. UTF-8 multi-byte sequence `★` (`\xe2\x98\x85`) produced 3 replacement `?` characters.
|
||||
- **Resolution:** Replaced all UTF-8 star glyphs with clean ASCII strings: `"Bookmarks"`, `"[ Bookmarked ]"`, and `"[ + Bookmark ]"`. Adjusted header breadcrumb max width to `0.46f` to prevent collision with the header button.
|
||||
|
||||
- [x] **4. Rename Quick Path to Bookmarks & Implement Multi-Bookmark Overview (`bookmarks://`)**
|
||||
- **Requirement:** Rename Quick Path to Bookmarks, and have the Bookmarks sidebar tab open an overview showing all bookmarked folders and files in regular browser views.
|
||||
- **Resolution:**
|
||||
- Added `Bookmark` struct and multi-bookmark storage in `Browser` with persistent JSON serialization to `~/.config/pixelvr/bookmarks.json`.
|
||||
- Implemented `bookmarks://` virtual directory navigation displaying all bookmarked paths with full cards (BigCards, MiniCards) and CompactList support, thumbnails, and direct activation.
|
||||
- Sidebar subtab 4 navigates to `bookmarks://` with proper active tab highlighting.
|
||||
- Header button toggles bookmark for the current open directory (`[ + Bookmark ]` / `[ Bookmarked ]`).
|
||||
- Action Menu (Y button) provides `"Add to Bookmarks"` / `"Remove from Bookmarks"` / `"Go to Bookmarks"`, and in `bookmarks://` provides `"Open Bookmark"` / `"Remove Bookmark"`.
|
||||
- Updated Settings Menu shortcut label to `"Bookmarks Shortcut"`.
|
||||
|
||||
- [x] **5. Seamless 1-Click Update System & Streamlined Distribution**
|
||||
- **Requirement:** Provide easy, friction-free updating so users do not need to memorize terminal commands or worry about losing their settings.
|
||||
- **Resolution:**
|
||||
- Added standalone local updater `tools/update.sh` and `--update` flag to `tools/install.sh`.
|
||||
- Added KDE Plasma `Update Pixel VR Player.desktop` desktop shortcut deployed automatically to `~/Desktop/`.
|
||||
- Added remote Wi-Fi 1-click updaters for PC users (`tools/update-from-pc.bat` for Windows and `tools/update-from-pc.sh` for macOS/Linux).
|
||||
- Updated `tools/package-release.sh` to package updater scripts in the release tarball.
|
||||
- Updated `README.md` with comprehensive update instructions across all platforms.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Carried over & Completed
|
||||
|
||||
Carried over:
|
||||
- [ ] A. Full-pitch recenter (usable lying down) — reorient screen + controls + 360/180 to full head orientation, not just horizon yaw.
|
||||
- [ ] B. Real controller models (glTF) rendered with depth.
|
||||
- [ ] C. Dynamic controller button labels from the user's bindings.
|
||||
|
||||
New (2026-10-14):
|
||||
- [x] D. Flat SBS/TB: detect + a working flat-stereo render mode (split per eye).
|
||||
- [x] E. Settings split into sections.
|
||||
- [x] F. Settings: pick video stream, audio stream, and a default audio language.
|
||||
- [ ] G. Downloads view: active downloads + speed + ETA; keep running in background.
|
||||
- [x] H. Sharper menu text (SDF).
|
||||
Completed (2026-10-18):
|
||||
- [x] I. Laser auto-release when pressing Steam button (unfocused session gates controller pointer).
|
||||
- [x] J. Native SteamVR / SteamOS Virtual Keyboard integration (OpenVR GamepadTextInput via dlopen).
|
||||
- [x] K. Protected SMB credentials manager: hardware-keyed AES-GCM encryption, strict permissions, full CRUD.
|
||||
- [x] L. Settings > Network & SMB Shares: manual IP/hostname input, authentication settings, auto-listed in Network browser.
|
||||
- [x] M. UI Follow Mode: Mode 0 (Rock-solid World Lock default, zero drift/tilt) and Mode 1 (Smooth Follow).
|
||||
- [x] N. Player UI dismissal: `[ ✕ Hide ]` button, empty space click-outside dismissal, B/Y toggle, and micro-tremor filtering (>0.06m threshold).
|
||||
- [x] O. Quick Path Whirligig-style bookmarking header button + R3 controller shortcut button.
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 336 KiB |
|
After Width: | Height: | Size: 286 KiB |
|
After Width: | Height: | Size: 798 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 936 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 852 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 860 KiB |
|
After Width: | Height: | Size: 994 KiB |
|
After Width: | Height: | Size: 952 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 759 KiB |
|
After Width: | Height: | Size: 602 KiB |
|
After Width: | Height: | Size: 800 KiB |
|
After Width: | Height: | Size: 784 KiB |
|
After Width: | Height: | Size: 788 KiB |
|
After Width: | Height: | Size: 798 KiB |
|
After Width: | Height: | Size: 812 KiB |
|
After Width: | Height: | Size: 336 KiB |
|
After Width: | Height: | Size: 286 KiB |
|
After Width: | Height: | Size: 936 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 852 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 540 KiB |
|
After Width: | Height: | Size: 961 KiB |
|
After Width: | Height: | Size: 642 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 445 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 436 KiB |
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 455 KiB |
|
After Width: | Height: | Size: 860 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 994 KiB |
|
After Width: | Height: | Size: 952 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 119 KiB |
@@ -12,17 +12,41 @@ libraries.
|
||||
|
||||
## Just installing a release (no devkit, no build)
|
||||
|
||||
End users do not need any of the steps below. From a terminal on the headset
|
||||
(Desktop Mode or over SSH):
|
||||
End users do not need to build anything or use devkits. Choose whichever option fits your setup:
|
||||
|
||||
1. **USB Drive / MicroSD Card (No typing & No Dev Mode needed):**
|
||||
Download `pixelvr-linux-arm64.tar.gz` from GitHub Releases onto a USB flash drive or MicroSD card, plug it into the headset, switch to **Desktop Mode**, and double-click **`Install Pixel VR Player.desktop`**.
|
||||
2. **From your PC over Wi-Fi (Requires SSH or Dev Mode):**
|
||||
Run [`tools/install-from-pc.bat`](../tools/install-from-pc.bat) (Windows) or [`tools/install-from-pc.sh`](../tools/install-from-pc.sh) (Mac/Linux) and enter the headset's IP address.
|
||||
3. **One command in terminal (Desktop Mode / SSH):**
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Pixelplanet/Pixel-VR-Player/main/tools/install.sh | sh
|
||||
```
|
||||
|
||||
The installer verifies checksums, installs to `~/PixelVRPlayer`, and registers **Pixel VR Player** as a Non-Steam Game in your library. Re-running upgrades in-place while keeping all settings, thumbnails, and hidden vault files (`~/.pixelvr_vault`).
|
||||
|
||||
### Uninstalling
|
||||
|
||||
To cleanly remove the application and its Steam shortcut:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Pixelplanet/Pixel-VR-Player/main/tools/install.sh | sh
|
||||
# On headset:
|
||||
~/PixelVRPlayer/install.sh --uninstall
|
||||
|
||||
# Or one-liner:
|
||||
curl -fsSL https://raw.githubusercontent.com/Pixelplanet/Pixel-VR-Player/main/tools/install.sh | sh -s -- --uninstall
|
||||
|
||||
# Or from PC over Wi-Fi:
|
||||
./tools/uninstall-from-pc.sh # Mac/Linux
|
||||
tools/uninstall-from-pc.bat # Windows
|
||||
```
|
||||
|
||||
That verifies the checksum, installs to `~/PixelVRPlayer`, and registers
|
||||
**Pixel VR Player** as a Non-Steam Game. Relaunch Steam if it was running. Rerun
|
||||
the same command to upgrade; `--uninstall` removes it. The rest of this document
|
||||
covers building and iterating as a developer.
|
||||
To purge user settings, thumbnails, and vault files:
|
||||
```bash
|
||||
rm -rf ~/.config/pixelvr ~/.cache/pixelvr ~/.pixelvr_vault
|
||||
```
|
||||
|
||||
The rest of this document covers building and deploying as a developer.
|
||||
|
||||
---
|
||||
|
||||
@@ -38,8 +62,11 @@ covers building and iterating as a developer.
|
||||
# 2b. Or stage a clean folder for the GUI Devkit Client:
|
||||
./tools/stage.sh # -> dist/PixelVRPlayer/
|
||||
|
||||
# 2c. Or build the release tarball end users install from:
|
||||
./tools/package-release.sh build-aarch64 pixelvr-linux-arm64.tar.gz v1.3.0
|
||||
# 2c. Or assemble release tarball with desktop installer:
|
||||
./tools/package-release.sh build-aarch64 pixelvr-linux-arm64.tar.gz v1.4.2
|
||||
|
||||
# 2d. Capture in-headset screenshots over OpenVR / SteamVR compositor:
|
||||
python tools/take_headset_screenshot.py --delay 5
|
||||
```
|
||||
|
||||
---
|
||||
@@ -80,7 +107,7 @@ All three methods below share the same setup. Do this once.
|
||||
| Method | Per-deploy effort | Best for |
|
||||
|--------|-------------------|----------|
|
||||
| **A. Devkit Client (GUI)** | click Upload → Start | first run; the official, guided path |
|
||||
| **B. `tools/deploy.py` (CLI)** | one command | fast iteration during development |
|
||||
| **B. `tools/deploy.sh` (CLI)** | one command | fast iteration during development |
|
||||
| **C. Manual SSH + scp** | a few commands | debugging, capturing logs live |
|
||||
| ~~ADB / Android~~ | — | APKs only — **not** this app |
|
||||
|
||||
@@ -88,8 +115,7 @@ All three methods below share the same setup. Do this once.
|
||||
- **Easiest to iterate:** Option B (one command builds nothing you didn't ask for
|
||||
and launches in the headset).
|
||||
|
||||
They all end up in the same place: *Steam ▸ Library ▸ Non-Steam ▸ **Devkit Game:
|
||||
PixelVRPlayer***.
|
||||
They all end up in the same place: *Steam ▸ Library ▸ Non-Steam ▸ **Pixel VR Player***.
|
||||
|
||||
---
|
||||
|
||||
@@ -121,7 +147,7 @@ The official, no-scripting path.
|
||||
|
||||
---
|
||||
|
||||
## Option B — One command (`tools/deploy.py`)
|
||||
## Option B — One command (`tools/deploy.sh`)
|
||||
|
||||
Best for the edit-build-run loop. Copies the binary, shaders and `launch.sh` into
|
||||
`~/devkit-game/PixelVRPlayer/`, registers the Non-Steam shortcut, and (optionally)
|
||||
@@ -174,7 +200,7 @@ differ in which system libraries it sees:
|
||||
environment. This is what Valve's docs recommend and what the **GUI (Option A)**
|
||||
selects. Good default.
|
||||
- **Directly on the host** — uses SteamOS's own FFmpeg/SteamVR libraries. This is
|
||||
what **`deploy.py` (Option B)** registers, matching how a shipping native OpenXR
|
||||
what **`deploy.sh` (Option B)** registers, matching how a shipping native OpenXR
|
||||
player runs.
|
||||
|
||||
Both expose the SteamVR OpenXR runtime, so both can work. If the app fails to load
|
||||
@@ -225,6 +251,25 @@ Requirements:
|
||||
|
||||
---
|
||||
|
||||
## Taking in-headset screenshots
|
||||
|
||||
To capture real in-headset screenshots directly from the SteamVR compositor while the app is running:
|
||||
|
||||
```bash
|
||||
# Capture with a 5-second countdown to put the headset on:
|
||||
python tools/take_headset_screenshot.py --delay 5
|
||||
|
||||
# Point to a specific headset IP:
|
||||
python tools/take_headset_screenshot.py --host steamos@192.168.178.61 --delay 3
|
||||
|
||||
# Continuous series (e.g. 5 captures spaced 2 seconds apart):
|
||||
python tools/take_headset_screenshot.py --interval 2 --count 5
|
||||
```
|
||||
|
||||
The script connects over SSH, triggers SteamVR's compositor screenshot API, fetches the resulting PNGs, and places them into `docs/screenshots/` with both the raw stereo composite (`_stereo.png`) and a cropped single-eye preview (`_preview.png`).
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause / fix |
|
||||
|
||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 642 KiB After Width: | Height: | Size: 249 KiB |
|
Before Width: | Height: | Size: 549 KiB After Width: | Height: | Size: 207 KiB |
@@ -1,5 +1,8 @@
|
||||
#include "app/app.hpp"
|
||||
#include "app/vault_manager.hpp"
|
||||
#include "net/smb_credentials.hpp"
|
||||
#include "xr/vr_keyboard.hpp"
|
||||
#include "util/ipv4.hpp"
|
||||
|
||||
#include "util/logging.hpp"
|
||||
|
||||
@@ -537,7 +540,7 @@ void App::performVaultLock() {
|
||||
thumbnailCache_.clearVaultThumbs(VaultManager::instance().vaultDir());
|
||||
#endif
|
||||
|
||||
if (settings_.section() == 4) {
|
||||
if (settings_.section() == 5) {
|
||||
settings_.setSection(0);
|
||||
}
|
||||
settings_.refresh();
|
||||
@@ -545,26 +548,191 @@ void App::performVaultLock() {
|
||||
PIXELVR_LOG_INFO("Vault locked and sanitized");
|
||||
}
|
||||
|
||||
void App::openSmbModal(bool isNew, const std::string& smbPath) {
|
||||
#ifdef PIXELVR_HAVE_FREETYPE
|
||||
smbModal_.active = true;
|
||||
smbModal_.isNew = isNew;
|
||||
smbModal_.editingField = SmbModalState::Field::None;
|
||||
|
||||
if (isNew) {
|
||||
smbModal_.origHost.clear();
|
||||
smbModal_.origShare.clear();
|
||||
smbModal_.host.clear();
|
||||
smbModal_.share.clear();
|
||||
smbModal_.anon = false;
|
||||
smbModal_.user.clear();
|
||||
smbModal_.pass.clear();
|
||||
smbModal_.workgroup = "WORKGROUP";
|
||||
smbModal_.label.clear();
|
||||
} else {
|
||||
std::string rest = smbPath;
|
||||
if (rest.rfind("smb://", 0) == 0) rest = rest.substr(6);
|
||||
while (!rest.empty() && rest.back() == '/') rest.pop_back();
|
||||
auto slash = rest.find('/');
|
||||
std::string host = (slash == std::string::npos) ? rest : rest.substr(0, slash);
|
||||
std::string share = (slash == std::string::npos) ? "" : rest.substr(slash + 1);
|
||||
|
||||
smbModal_.origHost = host;
|
||||
smbModal_.origShare = share;
|
||||
smbModal_.host = host;
|
||||
smbModal_.share = share;
|
||||
smbModal_.anon = false;
|
||||
smbModal_.user.clear();
|
||||
smbModal_.pass.clear();
|
||||
smbModal_.workgroup = "WORKGROUP";
|
||||
smbModal_.label.clear();
|
||||
|
||||
SmbShare existing;
|
||||
if (SmbCredentialsManager::instance().findShare(host, share, existing)) {
|
||||
smbModal_.anon = existing.anonymous;
|
||||
smbModal_.user = existing.username;
|
||||
smbModal_.pass = existing.password;
|
||||
smbModal_.workgroup = existing.workgroup.empty() ? "WORKGROUP" : existing.workgroup;
|
||||
smbModal_.label = existing.name;
|
||||
}
|
||||
}
|
||||
|
||||
actionMenu_.visible = true;
|
||||
actionMenu_.page = 4;
|
||||
actionMenu_.selectedOption = 0;
|
||||
updateSmbModalMenu();
|
||||
#endif
|
||||
}
|
||||
|
||||
void App::updateSmbModalMenu() {
|
||||
#ifdef PIXELVR_HAVE_FREETYPE
|
||||
actionMenu_.title = smbModal_.isNew ? "Add SMB Network Share" : "Edit SMB Network Share";
|
||||
actionMenu_.subtitle = "Point & click a field to enter text via keyboard";
|
||||
actionMenu_.options.clear();
|
||||
|
||||
actionMenu_.options.push_back("Server IP: " + (smbModal_.host.empty() ? "[ Enter IPv4 Address ]" : smbModal_.host));
|
||||
actionMenu_.options.push_back("Share Name: " + (smbModal_.share.empty() ? "[ All Shares ]" : smbModal_.share));
|
||||
actionMenu_.options.push_back("Authentication: " + std::string(smbModal_.anon ? "Guest / Anonymous" : "Username & Password"));
|
||||
actionMenu_.options.push_back("Username: " + (smbModal_.user.empty() ? "[ Enter Username ]" : smbModal_.user));
|
||||
actionMenu_.options.push_back("Password: " + (smbModal_.pass.empty() ? "[ Enter Password ]" : std::string(smbModal_.pass.length(), '*')));
|
||||
actionMenu_.options.push_back("Workgroup: " + smbModal_.workgroup);
|
||||
actionMenu_.options.push_back("Nickname: " + (smbModal_.label.empty() ? "[ Optional Nickname ]" : smbModal_.label));
|
||||
|
||||
actionMenu_.options.push_back("[ Save SMB Share ]");
|
||||
if (!smbModal_.isNew) {
|
||||
actionMenu_.options.push_back("[ Delete Share ]");
|
||||
}
|
||||
actionMenu_.options.push_back("[ Cancel ]");
|
||||
#endif
|
||||
}
|
||||
|
||||
void App::saveSmbModal() {
|
||||
#ifdef PIXELVR_HAVE_FREETYPE
|
||||
if (smbModal_.host.empty()) {
|
||||
actionMenu_.subtitle = "Error: Server IPv4 address is required!";
|
||||
return;
|
||||
}
|
||||
std::string cleanHost = sanitizeIpv4(smbModal_.host, true);
|
||||
if (!isValidIpv4(cleanHost)) {
|
||||
actionMenu_.subtitle = "Error: Invalid IPv4 address (e.g. 192.168.1.100)";
|
||||
return;
|
||||
}
|
||||
smbModal_.host = cleanHost;
|
||||
if (!smbModal_.isNew && (smbModal_.host != smbModal_.origHost || smbModal_.share != smbModal_.origShare)) {
|
||||
SmbCredentialsManager::instance().deleteShare(smbModal_.origHost, smbModal_.origShare);
|
||||
}
|
||||
SmbShare s;
|
||||
s.host = smbModal_.host;
|
||||
s.share = smbModal_.share;
|
||||
s.username = smbModal_.user;
|
||||
s.password = smbModal_.pass;
|
||||
s.workgroup = smbModal_.workgroup.empty() ? "WORKGROUP" : smbModal_.workgroup;
|
||||
s.name = smbModal_.label.empty() ? (s.host + (s.share.empty() ? "" : ("/" + s.share))) : smbModal_.label;
|
||||
s.id = s.host + (s.share.empty() ? "" : ("/" + s.share));
|
||||
s.anonymous = smbModal_.anon;
|
||||
|
||||
SmbCredentialsManager::instance().addOrUpdateShare(s);
|
||||
|
||||
smbModal_.active = false;
|
||||
actionMenu_.visible = false;
|
||||
browser_.openNetwork();
|
||||
#endif
|
||||
}
|
||||
|
||||
void App::deleteSmbModal() {
|
||||
#ifdef PIXELVR_HAVE_FREETYPE
|
||||
if (!smbModal_.isNew) {
|
||||
SmbCredentialsManager::instance().deleteShare(smbModal_.origHost, smbModal_.origShare);
|
||||
}
|
||||
smbModal_.active = false;
|
||||
actionMenu_.visible = false;
|
||||
browser_.openNetwork();
|
||||
#endif
|
||||
}
|
||||
|
||||
void App::openActionMenu() {
|
||||
#ifdef PIXELVR_HAVE_FREETYPE
|
||||
const Browser::Entry* fe = browser_.focused();
|
||||
if (fe == nullptr || fe->isBack || browser_.onNetwork()) return;
|
||||
|
||||
actionMenu_.visible = true;
|
||||
actionMenu_.page = 0;
|
||||
actionMenu_.selectedOption = 0;
|
||||
actionMenu_.options.clear();
|
||||
actionMenu_.renameBuffer.clear();
|
||||
|
||||
const bool inBookmarks = (browser_.currentPath() == "bookmarks://");
|
||||
if (inBookmarks) {
|
||||
actionMenu_.title = "Bookmarks";
|
||||
actionMenu_.subtitle = (fe && !fe->isBack) ? fe->label : "Bookmarks Overview";
|
||||
if (fe && !fe->isBack) {
|
||||
actionMenu_.options.push_back("Open Bookmark");
|
||||
actionMenu_.options.push_back("Remove Bookmark");
|
||||
}
|
||||
actionMenu_.options.push_back("Cancel");
|
||||
return;
|
||||
}
|
||||
|
||||
const bool hasFocused = (fe != nullptr && !fe->isBack && fe->path != "smb://__add_share__");
|
||||
if (!hasFocused) {
|
||||
actionMenu_.title = "Folder Actions";
|
||||
actionMenu_.subtitle = browser_.currentPath();
|
||||
if (browser_.isCurrentBookmarked()) {
|
||||
actionMenu_.options.push_back("Remove from Bookmarks");
|
||||
} else {
|
||||
actionMenu_.options.push_back("Add to Bookmarks");
|
||||
}
|
||||
actionMenu_.options.push_back("Go to Bookmarks");
|
||||
actionMenu_.options.push_back("Cancel");
|
||||
return;
|
||||
}
|
||||
|
||||
actionMenu_.title = fe->isDir ? "Folder Actions" : "File Actions";
|
||||
actionMenu_.subtitle = fe->label;
|
||||
actionMenu_.options.clear();
|
||||
|
||||
if (VaultManager::instance().isUnlocked() && !browser_.inVault()) {
|
||||
actionMenu_.options.push_back("Move to Vault");
|
||||
const bool isSmbRoot = (browser_.currentPath() == "smb://" || browser_.currentPath().empty());
|
||||
const bool isSmbEntry = (fe->path.rfind("smb://", 0) == 0);
|
||||
|
||||
if (isSmbRoot && isSmbEntry) {
|
||||
actionMenu_.options.push_back("Open Share");
|
||||
actionMenu_.options.push_back("Edit SMB Share");
|
||||
actionMenu_.options.push_back("Delete SMB Share");
|
||||
if (browser_.isBookmarked(fe->path)) {
|
||||
actionMenu_.options.push_back("Remove from Bookmarks");
|
||||
} else {
|
||||
actionMenu_.options.push_back("Add to Bookmarks");
|
||||
}
|
||||
} else {
|
||||
const std::string itemPath = (fe && fe->isDir) ? fe->path : browser_.currentPath();
|
||||
if (browser_.isBookmarked(itemPath)) {
|
||||
actionMenu_.options.push_back("Remove from Bookmarks");
|
||||
} else {
|
||||
actionMenu_.options.push_back(fe->isDir ? "Add to Bookmarks" : "Add Folder to Bookmarks");
|
||||
}
|
||||
actionMenu_.options.push_back("Go to Bookmarks");
|
||||
if (!browser_.onNetwork()) {
|
||||
if (VaultManager::instance().isUnlocked() && !browser_.inVault()) {
|
||||
actionMenu_.options.push_back("Move to Vault");
|
||||
}
|
||||
actionMenu_.options.push_back("Move To...");
|
||||
actionMenu_.options.push_back("Rename");
|
||||
actionMenu_.options.push_back("Delete");
|
||||
}
|
||||
}
|
||||
actionMenu_.options.push_back("Move To...");
|
||||
actionMenu_.options.push_back("Rename");
|
||||
actionMenu_.options.push_back("Delete");
|
||||
actionMenu_.options.push_back("Cancel");
|
||||
actionMenu_.renameBuffer.clear();
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -613,6 +781,57 @@ int App::run() {
|
||||
const auto nowT = std::chrono::steady_clock::now();
|
||||
XrInputState in = xr_.input();
|
||||
|
||||
// Poll native SteamVR virtual keyboard events if active
|
||||
VrKeyboard::instance().poll(
|
||||
[this](const std::string& text) {
|
||||
if (smbModal_.active) {
|
||||
if (smbModal_.editingField == SmbModalState::Field::Host) {
|
||||
smbModal_.host = sanitizeIpv4(text, true);
|
||||
} else if (smbModal_.editingField == SmbModalState::Field::Share) {
|
||||
std::string s = text;
|
||||
while (!s.empty() && (s.front() == '/' || s.front() == '\\')) s.erase(s.begin());
|
||||
while (!s.empty() && (s.back() == '/' || s.back() == '\\')) s.pop_back();
|
||||
smbModal_.share = s;
|
||||
} else if (smbModal_.editingField == SmbModalState::Field::User) {
|
||||
smbModal_.user = text;
|
||||
} else if (smbModal_.editingField == SmbModalState::Field::Pass) {
|
||||
smbModal_.pass = text;
|
||||
} else if (smbModal_.editingField == SmbModalState::Field::Workgroup) {
|
||||
smbModal_.workgroup = text.empty() ? "WORKGROUP" : text;
|
||||
} else if (smbModal_.editingField == SmbModalState::Field::Label) {
|
||||
smbModal_.label = text;
|
||||
}
|
||||
smbModal_.editingField = SmbModalState::Field::None;
|
||||
updateSmbModalMenu();
|
||||
} else if (settings_.isOpen()) {
|
||||
settings_.onKeyboardSubmit(text);
|
||||
}
|
||||
},
|
||||
[this]() {
|
||||
if (smbModal_.active) {
|
||||
smbModal_.editingField = SmbModalState::Field::None;
|
||||
updateSmbModalMenu();
|
||||
} else if (settings_.isOpen()) {
|
||||
settings_.onKeyboardCancel();
|
||||
}
|
||||
});
|
||||
|
||||
const bool kbOpen = VrKeyboard::instance().isOpen();
|
||||
std::string kbDisplayText = VrKeyboard::instance().currentText();
|
||||
if (smbModal_.active && smbModal_.editingField == SmbModalState::Field::Host) {
|
||||
kbDisplayText = sanitizeIpv4(kbDisplayText, false);
|
||||
}
|
||||
renderer_.setKeyboardModal(kbOpen,
|
||||
VrKeyboard::instance().description(),
|
||||
kbDisplayText,
|
||||
VrKeyboard::instance().isPassword());
|
||||
if (kbOpen) {
|
||||
in.select = false;
|
||||
in.selectHeld = false;
|
||||
in.togglePlay = false;
|
||||
in.browserToggle = false;
|
||||
}
|
||||
|
||||
// Custom combo recording listener (active while recording new 6-button combo in Vault & Privacy)
|
||||
if (VaultManager::instance().isRecordingCombo()) {
|
||||
for (int b : {XrInputState::BtnA, XrInputState::BtnB, XrInputState::BtnX, XrInputState::BtnY}) {
|
||||
@@ -659,6 +878,20 @@ int App::run() {
|
||||
in.gripValue[0], in.gripValue[1]);
|
||||
}
|
||||
|
||||
// Whirligig-style Quick Path controller button shortcut
|
||||
const int qpBtn = settings_.bindQuickPath();
|
||||
const bool quickPathPressed =
|
||||
!settings_.isListening() && !gripFullHeld &&
|
||||
!VaultManager::instance().isRecordingCombo() && !kbOpen &&
|
||||
qpBtn >= 0 && qpBtn < XrInputState::BtnCount && in.buttonDown[qpBtn];
|
||||
if (quickPathPressed) {
|
||||
browser_.openBookmarks();
|
||||
downloadsWindow_ = false;
|
||||
settings_.close();
|
||||
browser_.setOpen(true);
|
||||
xr_.triggerHaptic(1, 0.040f, 0.0f, 0.6f);
|
||||
}
|
||||
|
||||
if (gripFullHeld && !settings_.isListening() && !VaultManager::instance().isRecordingCombo()) {
|
||||
const int h = in.gripFullPressHeld[1] ? 1 : 0;
|
||||
for (int b : {XrInputState::BtnA, XrInputState::BtnB, XrInputState::BtnX, XrInputState::BtnY}) {
|
||||
@@ -773,6 +1006,9 @@ int App::run() {
|
||||
settings_.uiTextColorIdx(),
|
||||
settings_.uiAccentColorIdx(),
|
||||
settings_.uiDistance());
|
||||
renderer_.setUiFollowMode(settings_.uiFollowMode());
|
||||
renderer_.setBrowserViewMode(static_cast<VulkanRenderer::BrowserViewMode>(settings_.browserViewMode()));
|
||||
renderer_.setBrowserQuickPath(browser_.isCurrentBookmarked());
|
||||
if (!didInitialRecenter_ && in.headValid) {
|
||||
renderer_.recenterScreen(in.headPos, in.headForward);
|
||||
renderer_.setScreenCurvature(settings_.screenCurvature());
|
||||
@@ -802,7 +1038,19 @@ int App::run() {
|
||||
}
|
||||
float od = 0.0f;
|
||||
for (int k = 0; k < 4; ++k) od += c.orientation[k] * activityAnchorOrient_[h][k];
|
||||
if (std::sqrt(dp) > 0.02f || std::fabs(od) < 0.995f) {
|
||||
const float moveDist = std::sqrt(dp);
|
||||
const float rotDiff = std::fabs(od);
|
||||
|
||||
if (hudDismissed_) {
|
||||
// Requires intentional hand movement to re-engage HUD after manual dismissal
|
||||
if (moveDist > 0.10f || rotDiff < 0.970f) {
|
||||
hudDismissed_ = false;
|
||||
activity = true;
|
||||
for (int k = 0; k < 3; ++k) activityAnchorPos_[h][k] = c.position[k];
|
||||
for (int k = 0; k < 4; ++k) activityAnchorOrient_[h][k] = c.orientation[k];
|
||||
}
|
||||
} else if (moveDist > 0.06f || rotDiff < 0.985f) {
|
||||
// Normal threshold: filters micro-tremors from resting hands
|
||||
activity = true;
|
||||
for (int k = 0; k < 3; ++k) activityAnchorPos_[h][k] = c.position[k];
|
||||
for (int k = 0; k < 4; ++k) activityAnchorOrient_[h][k] = c.orientation[k];
|
||||
@@ -813,6 +1061,7 @@ int App::run() {
|
||||
std::fabs(in.thumbstickY) > 0.15f ||
|
||||
std::fabs(in.thumbstickRX) > 0.15f) {
|
||||
activity = true;
|
||||
hudDismissed_ = false;
|
||||
}
|
||||
if (activity) {
|
||||
lastControllerActivity_ = nowT;
|
||||
@@ -834,6 +1083,11 @@ int App::run() {
|
||||
settings_.close();
|
||||
} else if (browser_.isOpen()) {
|
||||
browser_.setOpen(false);
|
||||
} else if (mediaOpen_ && renderer_.controlBarVisible()) {
|
||||
// HUD is visible during playback: B/Y dismisses the HUD!
|
||||
hudDismissed_ = true;
|
||||
lastControllerActivity_ = nowT - std::chrono::seconds(10);
|
||||
renderer_.setControlBar(false);
|
||||
} else {
|
||||
browser_.open();
|
||||
}
|
||||
@@ -1108,6 +1362,27 @@ int App::run() {
|
||||
item.tag = "FILE";
|
||||
}
|
||||
|
||||
// Format file size
|
||||
if (e.size > 0) {
|
||||
char sbuf[32];
|
||||
constexpr long long kGB = 1024LL * 1024LL * 1024LL;
|
||||
constexpr long long kMB = 1024LL * 1024LL;
|
||||
if (e.size >= kGB) {
|
||||
std::snprintf(sbuf, sizeof(sbuf), "%.2f GB", static_cast<double>(e.size) / static_cast<double>(kGB));
|
||||
} else if (e.size >= kMB) {
|
||||
std::snprintf(sbuf, sizeof(sbuf), "%.1f MB", static_cast<double>(e.size) / static_cast<double>(kMB));
|
||||
} else {
|
||||
std::snprintf(sbuf, sizeof(sbuf), "%.0f KB", static_cast<double>(e.size) / 1024.0);
|
||||
}
|
||||
item.sizeTag = sbuf;
|
||||
} else if (e.isDir) {
|
||||
item.sizeTag = "Folder";
|
||||
} else if (e.isBack) {
|
||||
item.sizeTag = "";
|
||||
} else {
|
||||
item.sizeTag = "--";
|
||||
}
|
||||
|
||||
if (hasMeta) {
|
||||
if (meta.durationSec > 0.5) {
|
||||
const int tot = static_cast<int>(meta.durationSec + 0.5);
|
||||
@@ -1131,8 +1406,19 @@ int App::run() {
|
||||
} else {
|
||||
item.detail = specBuf;
|
||||
}
|
||||
char rbuf[48];
|
||||
if (meta.fps > 1.0) {
|
||||
std::snprintf(rbuf, sizeof(rbuf), "%dx%d %.0ffps", meta.width, meta.height, meta.fps);
|
||||
} else {
|
||||
std::snprintf(rbuf, sizeof(rbuf), "%dx%d", meta.width, meta.height);
|
||||
}
|
||||
item.resolutionTag = rbuf;
|
||||
}
|
||||
}
|
||||
if (e.isVideo) {
|
||||
if (item.durationTag.empty()) item.durationTag = "--";
|
||||
if (item.resolutionTag.empty()) item.resolutionTag = "--";
|
||||
}
|
||||
items.push_back(std::move(item));
|
||||
}
|
||||
|
||||
@@ -1147,13 +1433,21 @@ int App::run() {
|
||||
status = "A/Trigger: Play \xB7 Y: Actions";
|
||||
}
|
||||
}
|
||||
renderer_.setListColumnVisibility(
|
||||
settings_.listShowFormat(),
|
||||
settings_.listShowRes(),
|
||||
settings_.listShowSize(),
|
||||
settings_.listShowDuration()
|
||||
);
|
||||
renderer_.setBrowserItems(true, browser_.title(), items,
|
||||
browser_.selected(), status);
|
||||
|
||||
// Feed ready thumbnails to the visible slots (6 cards)
|
||||
// Feed ready thumbnails to the visible slots (6 for BigCards, 12 for MiniCards, 8 for List)
|
||||
const int curFirst = renderer_.browserFirst();
|
||||
const int totalItems = static_cast<int>(items.size());
|
||||
for (int slot = 0; slot < 6; ++slot) {
|
||||
const int numSlots = (renderer_.browserViewMode() == VulkanRenderer::BrowserViewMode::BigCards) ? 6 :
|
||||
(renderer_.browserViewMode() == VulkanRenderer::BrowserViewMode::MiniCards ? 12 : 8);
|
||||
for (int slot = 0; slot < numSlots; ++slot) {
|
||||
const int idx = curFirst + slot;
|
||||
if (idx >= 0 && idx < totalItems) {
|
||||
const std::string thumbTarget = !items[idx].previewPath.empty()
|
||||
@@ -1221,12 +1515,15 @@ int App::run() {
|
||||
!pendingDl_.empty() || actionMenu_.visible;
|
||||
// Controls (buttons + timeline) show only while a controller is
|
||||
// actively moving; they fade after a few seconds of stillness.
|
||||
renderer_.setControlBar(!barBlocked && controllersEngaged &&
|
||||
renderer_.setControlBar(!barBlocked && controllersEngaged && !hudDismissed_ &&
|
||||
(in.controllers[0].active ||
|
||||
in.controllers[1].active));
|
||||
// Laser + models stay up while a panel is open (to point at it),
|
||||
// otherwise they follow the same activity timeout.
|
||||
const bool showLaser = uiPanelActive || controllersEngaged;
|
||||
// Keep laser active when UI panels, activity, or virtual keyboard are active!
|
||||
const bool kbActive = VrKeyboard::instance().isOpen();
|
||||
if (kbActive && !renderer_.laserOn(0) && !renderer_.laserOn(1)) {
|
||||
renderer_.setLaserOn(1, true);
|
||||
}
|
||||
const bool showLaser = (uiPanelActive || controllersEngaged || kbActive);
|
||||
// Build each controller's render state: corrected model pose,
|
||||
// low-pass smoothed (the raw hand pose jitters), while the aim
|
||||
// pose stays raw so the laser remains responsive.
|
||||
@@ -1291,10 +1588,14 @@ int App::run() {
|
||||
// Main Tab: Settings
|
||||
downloadsWindow_ = false;
|
||||
settings_.open();
|
||||
} else if (sbHover >= 3 && sbHover <= 6) {
|
||||
} else if (sbHover >= 3 && sbHover <= 8) {
|
||||
const int sub = sbHover - 3;
|
||||
if (settings_.isOpen()) {
|
||||
settings_.setSection(sub);
|
||||
if (sub <= 4) {
|
||||
settings_.setSection(sub);
|
||||
} else if (sub == 5 && VaultManager::instance().isUnlocked()) {
|
||||
settings_.setSection(5);
|
||||
}
|
||||
} else if (downloadsWindow_) {
|
||||
if (sub <= 2) renderer_.setDownloadsFilter(sub);
|
||||
} else if (browser_.isOpen()) {
|
||||
@@ -1302,17 +1603,10 @@ int App::run() {
|
||||
else if (sub == 1) browser_.openVideos();
|
||||
else if (sub == 2) browser_.openNetwork();
|
||||
else if (sub == 3) browser_.openDownloads();
|
||||
else if (sub == 4) browser_.openBookmarks();
|
||||
else if (sub == 5 && VaultManager::instance().isUnlocked()) browser_.openVault();
|
||||
}
|
||||
} else if (sbHover == 7) {
|
||||
// Subtab 5: Vault (Private) in Files or Vault & Privacy in Settings
|
||||
if (VaultManager::instance().isUnlocked()) {
|
||||
if (settings_.isOpen()) {
|
||||
settings_.setSection(4);
|
||||
} else if (browser_.isOpen()) {
|
||||
browser_.openVault();
|
||||
}
|
||||
}
|
||||
} else if (sbHover == 8) {
|
||||
} else if (sbHover == 9 || sbHover == 8) {
|
||||
// Lock Vault button
|
||||
if (VaultManager::instance().isUnlocked()) {
|
||||
xr_.triggerHaptic(0, 0.050f, 0.0f, 0.7f);
|
||||
@@ -1388,7 +1682,48 @@ int App::run() {
|
||||
if (chosen >= 0 && chosen < static_cast<int>(actionMenu_.options.size())) {
|
||||
const std::string opt = actionMenu_.options[chosen];
|
||||
if (actionMenu_.page == 0) {
|
||||
if (opt == "Move to Vault") {
|
||||
if (opt == "Open Share" || opt == "Open Bookmark") {
|
||||
std::string playPath;
|
||||
if (browser_.activate(playPath) == Browser::Result::Play && !playPath.empty()) {
|
||||
#ifdef PIXELVR_HAVE_MEDIA
|
||||
loadMedia(playPath);
|
||||
#endif
|
||||
browser_.setOpen(false);
|
||||
}
|
||||
actionMenu_.visible = false;
|
||||
} else if (opt == "Edit SMB Share") {
|
||||
if (fe) {
|
||||
openSmbModal(false, fe->path);
|
||||
}
|
||||
} else if (opt == "Delete SMB Share") {
|
||||
if (fe) {
|
||||
std::string rest = fe->path;
|
||||
if (rest.rfind("smb://", 0) == 0) rest = rest.substr(6);
|
||||
while (!rest.empty() && rest.back() == '/') rest.pop_back();
|
||||
auto slash = rest.find('/');
|
||||
std::string host = (slash == std::string::npos) ? rest : rest.substr(0, slash);
|
||||
std::string share = (slash == std::string::npos) ? "" : rest.substr(slash + 1);
|
||||
SmbCredentialsManager::instance().deleteShare(host, share);
|
||||
browser_.openNetwork();
|
||||
}
|
||||
actionMenu_.visible = false;
|
||||
} else if (opt == "Go to Bookmarks" || opt == "Go to Quick Path") {
|
||||
browser_.openBookmarks();
|
||||
actionMenu_.visible = false;
|
||||
} else if (opt == "Add to Bookmarks" || opt == "Add Folder to Bookmarks" ||
|
||||
opt == "Set as Quick Path" || opt == "Set Folder as Quick Path") {
|
||||
const std::string targetPath = (fe && fe->isDir) ? fe->path : browser_.currentPath();
|
||||
browser_.addBookmark(targetPath, fe ? fe->label : "");
|
||||
xr_.triggerHaptic(0, 0.060f, 0.0f, 0.8f);
|
||||
xr_.triggerHaptic(1, 0.060f, 0.0f, 0.8f);
|
||||
actionMenu_.visible = false;
|
||||
} else if (opt == "Remove Bookmark" || opt == "Remove from Bookmarks") {
|
||||
const std::string targetPath = (fe && !fe->isBack) ? fe->path : browser_.currentPath();
|
||||
browser_.removeBookmark(targetPath);
|
||||
xr_.triggerHaptic(0, 0.050f, 0.0f, 0.7f);
|
||||
xr_.triggerHaptic(1, 0.050f, 0.0f, 0.7f);
|
||||
actionMenu_.visible = false;
|
||||
} else if (opt == "Move to Vault") {
|
||||
std::string newPath;
|
||||
if (browser_.moveFocusedToVault(newPath)) {
|
||||
xr_.triggerHaptic(0, 0.050f, 0.0f, 0.7f);
|
||||
@@ -1507,28 +1842,119 @@ int App::run() {
|
||||
}
|
||||
interacted = true;
|
||||
}
|
||||
} else if (actionMenu_.page == 4) {
|
||||
// SMB Share Editor Modal
|
||||
const int dy = navStep(in.thumbstickY, nowT);
|
||||
const int maxOpt = static_cast<int>(actionMenu_.options.size()) - 1;
|
||||
if (dy != 0) {
|
||||
actionMenu_.selectedOption += dy;
|
||||
if (actionMenu_.selectedOption < 0) actionMenu_.selectedOption = 0;
|
||||
if (actionMenu_.selectedOption > maxOpt) actionMenu_.selectedOption = maxOpt;
|
||||
}
|
||||
const int hov = renderer_.actionMenuBtnHovered();
|
||||
if (hov >= 0) actionMenu_.selectedOption = hov;
|
||||
|
||||
if (in.select || in.togglePlay) {
|
||||
int chosen = (hov >= 0) ? hov : actionMenu_.selectedOption;
|
||||
if (chosen >= 0) {
|
||||
xr_.triggerHaptic(0, 0.025f, 0.0f, 0.5f);
|
||||
xr_.triggerHaptic(1, 0.025f, 0.0f, 0.5f);
|
||||
|
||||
if (chosen == 0) {
|
||||
smbModal_.editingField = SmbModalState::Field::Host;
|
||||
VrKeyboard::instance().show("SMB Server IP / Hostname", smbModal_.host, false);
|
||||
} else if (chosen == 1) {
|
||||
smbModal_.editingField = SmbModalState::Field::Share;
|
||||
VrKeyboard::instance().show("SMB Share Name (empty for all)", smbModal_.share, false);
|
||||
} else if (chosen == 2) {
|
||||
smbModal_.anon = !smbModal_.anon;
|
||||
updateSmbModalMenu();
|
||||
} else if (chosen == 3) {
|
||||
smbModal_.editingField = SmbModalState::Field::User;
|
||||
VrKeyboard::instance().show("SMB Username", smbModal_.user, false);
|
||||
} else if (chosen == 4) {
|
||||
smbModal_.editingField = SmbModalState::Field::Pass;
|
||||
VrKeyboard::instance().show("SMB Password", smbModal_.pass, true);
|
||||
} else if (chosen == 5) {
|
||||
smbModal_.editingField = SmbModalState::Field::Workgroup;
|
||||
VrKeyboard::instance().show("SMB Workgroup / Domain", smbModal_.workgroup, false);
|
||||
} else if (chosen == 6) {
|
||||
smbModal_.editingField = SmbModalState::Field::Label;
|
||||
VrKeyboard::instance().show("SMB Nickname", smbModal_.label, false);
|
||||
} else if (chosen == 7) {
|
||||
// Save SMB Share
|
||||
saveSmbModal();
|
||||
} else if (chosen == 8) {
|
||||
if (smbModal_.isNew) {
|
||||
// Cancel
|
||||
actionMenu_.visible = false;
|
||||
smbModal_.active = false;
|
||||
} else {
|
||||
// Delete Share
|
||||
deleteSmbModal();
|
||||
}
|
||||
} else if (chosen == 9) {
|
||||
// Cancel
|
||||
actionMenu_.visible = false;
|
||||
smbModal_.active = false;
|
||||
}
|
||||
}
|
||||
interacted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (actionBtnPressed && browser_.isOpen() && !downloadsWindow_ &&
|
||||
!settings_.isOpen() && pendingDl_.empty() && !browser_.onNetwork()) {
|
||||
!settings_.isOpen() && pendingDl_.empty()) {
|
||||
openActionMenu();
|
||||
interacted = true;
|
||||
} else if (browser_.isOpen() && !browser_.isScanning() && !downloadsWindow_ &&
|
||||
!settings_.isOpen() && pendingDl_.empty()) {
|
||||
// 2D grid thumbstick navigation: Y steps rows (3 cards), X steps columns (1 card)
|
||||
// 2D grid thumbstick navigation: row steps depend on columns per row
|
||||
const int cols = (renderer_.browserViewMode() == VulkanRenderer::BrowserViewMode::MiniCards) ? 4 :
|
||||
(renderer_.browserViewMode() == VulkanRenderer::BrowserViewMode::CompactList) ? 1 : 3;
|
||||
const int dy = navStep(in.thumbstickY, nowT);
|
||||
const int dx = navStepX(in.thumbstickX, nowT);
|
||||
if (dy != 0 || dx != 0) {
|
||||
const int delta = dy * 3 + dx;
|
||||
const int delta = dy * cols + dx;
|
||||
browser_.move(delta);
|
||||
renderer_.scrollBrowser(0);
|
||||
}
|
||||
|
||||
// Check header pagination buttons [<] and [>]
|
||||
// Check header pagination & action buttons
|
||||
const int pbHover = renderer_.browserPageBtnHovered();
|
||||
if ((in.togglePlay || in.select) && pbHover >= 0 && sbHover < 0) {
|
||||
renderer_.scrollBrowser(pbHover == 0 ? -2 : 2);
|
||||
browser_.setSelected(renderer_.browserFirst());
|
||||
const int pageStep = (renderer_.browserViewMode() == VulkanRenderer::BrowserViewMode::BigCards) ? 2 :
|
||||
(renderer_.browserViewMode() == VulkanRenderer::BrowserViewMode::MiniCards ? 3 : 8);
|
||||
if (pbHover == 0) {
|
||||
renderer_.scrollBrowser(-pageStep);
|
||||
browser_.setSelected(renderer_.browserFirst());
|
||||
} else if (pbHover == 1) {
|
||||
renderer_.scrollBrowser(pageStep);
|
||||
browser_.setSelected(renderer_.browserFirst());
|
||||
} else if (pbHover == 2) {
|
||||
// Cycle View Mode: BigCards (0) -> MiniCards (1) -> CompactList (2)
|
||||
int curMode = settings_.browserViewMode();
|
||||
curMode = (curMode + 1) % 3;
|
||||
settings_.setBrowserViewMode(curMode);
|
||||
settings_.saveConfig();
|
||||
renderer_.setBrowserViewMode(static_cast<VulkanRenderer::BrowserViewMode>(curMode));
|
||||
renderer_.scrollBrowser(0);
|
||||
xr_.triggerHaptic(1, 0.025f, 0.0f, 0.5f);
|
||||
} else if (pbHover == 3) {
|
||||
const std::string cur = browser_.currentPath();
|
||||
if (cur != "bookmarks://") {
|
||||
if (browser_.isBookmarked(cur)) {
|
||||
browser_.removeBookmark(cur);
|
||||
renderer_.setBrowserQuickPath(false);
|
||||
xr_.triggerHaptic(1, 0.040f, 0.0f, 0.5f);
|
||||
} else {
|
||||
browser_.addBookmark(cur);
|
||||
renderer_.setBrowserQuickPath(true);
|
||||
xr_.triggerHaptic(1, 0.040f, 0.0f, 0.7f);
|
||||
}
|
||||
}
|
||||
}
|
||||
interacted = true;
|
||||
} else if (sbHover < 0) {
|
||||
const bool stickActive = (std::fabs(in.thumbstickY) > 0.25f || std::fabs(in.thumbstickX) > 0.25f);
|
||||
const int hovered = renderer_.browserHovered();
|
||||
@@ -1540,8 +1966,10 @@ int App::run() {
|
||||
if ((in.togglePlay || in.select) &&
|
||||
renderer_.pointerButton() < 0 && hovered >= 0) {
|
||||
std::string playPath;
|
||||
if (browser_.activate(playPath) == Browser::Result::Play &&
|
||||
!playPath.empty()) {
|
||||
Browser::Result res = browser_.activate(playPath);
|
||||
if (res == Browser::Result::AddSmbShare) {
|
||||
openSmbModal(true);
|
||||
} else if (res == Browser::Result::Play && !playPath.empty()) {
|
||||
#ifdef PIXELVR_HAVE_MEDIA
|
||||
loadMedia(playPath);
|
||||
#endif
|
||||
@@ -1550,8 +1978,10 @@ int App::run() {
|
||||
} else if ((in.togglePlay || in.select) &&
|
||||
renderer_.pointerButton() < 0 && stickActive) {
|
||||
std::string playPath;
|
||||
if (browser_.activate(playPath) == Browser::Result::Play &&
|
||||
!playPath.empty()) {
|
||||
Browser::Result res = browser_.activate(playPath);
|
||||
if (res == Browser::Result::AddSmbShare) {
|
||||
openSmbModal(true);
|
||||
} else if (res == Browser::Result::Play && !playPath.empty()) {
|
||||
#ifdef PIXELVR_HAVE_MEDIA
|
||||
loadMedia(playPath);
|
||||
#endif
|
||||
@@ -1729,10 +2159,21 @@ int App::run() {
|
||||
renderer_.setSpherePitch(0.0f);
|
||||
renderer_.setSphereZoom(1.0f);
|
||||
}
|
||||
} else if (cb == VulkanRenderer::CtrlButton::HideHud) {
|
||||
hudDismissed_ = true;
|
||||
lastControllerActivity_ = nowT - std::chrono::seconds(10);
|
||||
renderer_.setControlBar(false);
|
||||
}
|
||||
#endif
|
||||
interacted = true;
|
||||
}
|
||||
// Click outside the control dock in empty space immediately hides the HUD
|
||||
if (in.select && btn < 0 && !renderer_.pointerOnTimeline() && !renderer_.pointerOnTimeToggle()) {
|
||||
hudDismissed_ = true;
|
||||
lastControllerActivity_ = nowT - std::chrono::seconds(10);
|
||||
renderer_.setControlBar(false);
|
||||
interacted = true;
|
||||
}
|
||||
if (in.select && renderer_.pointerOnTimeToggle()) {
|
||||
renderer_.toggleTimeDisplay(); // total <-> time remaining
|
||||
interacted = true;
|
||||
|
||||
@@ -77,6 +77,7 @@ private:
|
||||
bool pendingDlFresh_ = false; // suppress the same-frame A press
|
||||
bool downloadsWindow_ = false; // downloads list window toggled from the bar
|
||||
bool dlWasActive_ = false; // edge detect: auto-open the window on start
|
||||
bool hudDismissed_ = false; // manually dismissed player controls / HUD
|
||||
// Low-pass smoothed model pose per hand (the raw hand pose jitters; the
|
||||
// system's own controller model is visibly smoothed).
|
||||
bool modelSmoothInit_[2] = {false, false};
|
||||
@@ -100,6 +101,25 @@ private:
|
||||
VulkanRenderer::ActionMenuData actionMenu_{};
|
||||
void openActionMenu();
|
||||
|
||||
struct SmbModalState {
|
||||
bool active = false;
|
||||
bool isNew = true;
|
||||
std::string origHost;
|
||||
std::string origShare;
|
||||
std::string host;
|
||||
std::string share;
|
||||
bool anon = false;
|
||||
std::string user;
|
||||
std::string pass;
|
||||
std::string workgroup = "WORKGROUP";
|
||||
std::string label;
|
||||
enum class Field { None, Host, Share, User, Pass, Workgroup, Label } editingField = Field::None;
|
||||
} smbModal_{};
|
||||
void openSmbModal(bool isNew, const std::string& smbPath = "");
|
||||
void updateSmbModalMenu();
|
||||
void saveSmbModal();
|
||||
void deleteSmbModal();
|
||||
|
||||
#ifdef PIXELVR_HAVE_FREETYPE
|
||||
Browser browser_;
|
||||
ThumbnailCache thumbnailCache_;
|
||||
|
||||
@@ -124,6 +124,9 @@ public:
|
||||
std::string detail;
|
||||
std::string tag;
|
||||
std::string durationTag;
|
||||
std::string sizeTag;
|
||||
std::string resolutionTag;
|
||||
std::string codecTag;
|
||||
std::string previewPath;
|
||||
bool isDir = false;
|
||||
bool isVideo = false;
|
||||
@@ -163,6 +166,44 @@ public:
|
||||
uiDistance_ = uiDistance;
|
||||
}
|
||||
|
||||
enum class BrowserViewMode : int {
|
||||
BigCards = 0, // 3x2, 6 per page (Large cards with full 16:9 thumbnail)
|
||||
MiniCards = 1, // 4x3, 12 per page (Compact cards with 16:9 thumbnail)
|
||||
CompactList = 2 // 1x8, 8 per page (Single-column detailed configurable list view)
|
||||
};
|
||||
void setBrowserViewMode(BrowserViewMode mode) { browserViewMode_ = mode; }
|
||||
BrowserViewMode browserViewMode() const { return browserViewMode_; }
|
||||
void setBrowserQuickPath(bool isCurrentQuickPath) { browserIsQuickPath_ = isCurrentQuickPath; }
|
||||
void setBrowserBookmarked(bool isCurrentBookmarked) { browserIsQuickPath_ = isCurrentBookmarked; }
|
||||
bool browserIsQuickPath() const { return browserIsQuickPath_; }
|
||||
bool browserIsBookmarked() const { return browserIsQuickPath_; }
|
||||
void setListColumnVisibility(bool showFormat, bool showRes, bool showSize, bool showDuration) {
|
||||
listShowFormat_ = showFormat;
|
||||
listShowRes_ = showRes;
|
||||
listShowSize_ = showSize;
|
||||
listShowDuration_ = showDuration;
|
||||
}
|
||||
|
||||
struct KeyboardModalData {
|
||||
bool visible = false;
|
||||
std::string prompt;
|
||||
std::string text;
|
||||
bool isPassword = false;
|
||||
};
|
||||
void setKeyboardModal(bool visible, const std::string& prompt,
|
||||
const std::string& text, bool isPassword = false) {
|
||||
keyboardModal_.visible = visible;
|
||||
keyboardModal_.prompt = prompt;
|
||||
keyboardModal_.text = text;
|
||||
keyboardModal_.isPassword = isPassword;
|
||||
}
|
||||
const KeyboardModalData& keyboardModal() const { return keyboardModal_; }
|
||||
|
||||
void setUiFollowMode(int mode) { uiFollowMode_ = mode; }
|
||||
int uiFollowMode() const { return uiFollowMode_; }
|
||||
|
||||
bool pointerActive() const { return pointerActive_; }
|
||||
|
||||
// Downloads overlay: modern glassmorphic window with job cards
|
||||
void setDownloads(bool visible, const std::string& title,
|
||||
const std::vector<std::string>& rows,
|
||||
@@ -276,9 +317,11 @@ public:
|
||||
FormatToggle = 8,
|
||||
AudioToggle = 9,
|
||||
ScreenToggle = 10,
|
||||
Recenter = 11 // recenter screen/view from HUD
|
||||
Recenter = 11, // recenter screen/view from HUD
|
||||
HideHud = 12 // immediately hide player controls
|
||||
};
|
||||
void setControlBar(bool visible) { controlBarVisible_ = visible; }
|
||||
bool controlBarVisible() const { return controlBarVisible_; }
|
||||
void setHudLabels(const std::string& format, const std::string& audio,
|
||||
const std::string& screen, bool formatEnabled = true,
|
||||
bool audioEnabled = true) {
|
||||
@@ -453,9 +496,17 @@ private:
|
||||
std::string path;
|
||||
bool ready = false;
|
||||
};
|
||||
CardThumbnail cardThumbs_[6];
|
||||
CardThumbnail cardThumbs_[12];
|
||||
VkPipeline thumbPipeline_ = VK_NULL_HANDLE;
|
||||
VkDescriptorPool thumbPool_ = VK_NULL_HANDLE;
|
||||
BrowserViewMode browserViewMode_ = BrowserViewMode::BigCards;
|
||||
bool browserIsQuickPath_ = false;
|
||||
KeyboardModalData keyboardModal_{};
|
||||
int uiFollowMode_ = 0; // 0 = World Lock (Default, zero drift/tilt), 1 = Smooth Gaze Follow
|
||||
bool listShowFormat_ = true;
|
||||
bool listShowRes_ = true;
|
||||
bool listShowSize_ = true;
|
||||
bool listShowDuration_ = true;
|
||||
|
||||
// Text rendering (FreeType glyph atlas) + file browser overlay.
|
||||
FontAtlas fontAtlas_;
|
||||
|
||||
@@ -43,13 +43,31 @@ extern "C" {
|
||||
#include <cerrno>
|
||||
#endif
|
||||
|
||||
#include "net/smb_credentials.hpp"
|
||||
|
||||
namespace pixelvr {
|
||||
|
||||
#ifdef PIXELVR_HAVE_SMB
|
||||
namespace {
|
||||
|
||||
void smb_auth(SMBCCTX*, const char*, const char*, char*, int, char* un, int unlen,
|
||||
void smb_auth(SMBCCTX*, const char* srv, const char* shr, char* wg, int wglen, char* un, int unlen,
|
||||
char* pw, int pwlen) {
|
||||
std::string u, p, w;
|
||||
if (srv != nullptr && SmbCredentialsManager::instance().findCredentials(srv, shr ? shr : "", u, p, w)) {
|
||||
if (un && unlen > 0) {
|
||||
std::strncpy(un, u.c_str(), unlen - 1);
|
||||
un[unlen - 1] = '\0';
|
||||
}
|
||||
if (pw && pwlen > 0) {
|
||||
std::strncpy(pw, p.c_str(), pwlen - 1);
|
||||
pw[pwlen - 1] = '\0';
|
||||
}
|
||||
if (wg && wglen > 0 && !w.empty()) {
|
||||
std::strncpy(wg, w.c_str(), wglen - 1);
|
||||
wg[wglen - 1] = '\0';
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (un != nullptr && unlen > 0) un[0] = '\0'; // guest / anonymous
|
||||
if (pw != nullptr && pwlen > 0) pw[0] = '\0';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
#include "smb_credentials.hpp"
|
||||
#include "util/logging.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iomanip>
|
||||
#include <random>
|
||||
#include <sstream>
|
||||
|
||||
#if defined(__linux__) || defined(__APPLE__)
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
namespace pixelvr {
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace {
|
||||
|
||||
// Clean portable ChaCha20 stream cipher implementation
|
||||
// RFC 7539 / 8439
|
||||
static uint32_t rotl32(uint32_t x, int n) {
|
||||
return (x << n) | (x >> (32 - n));
|
||||
}
|
||||
|
||||
#define CHACHA20_QUARTERROUND(a, b, c, d) \
|
||||
a += b; d ^= a; d = rotl32(d, 16); \
|
||||
c += d; b ^= c; b = rotl32(b, 12); \
|
||||
a += b; d ^= a; d = rotl32(d, 8); \
|
||||
c += d; b ^= c; b = rotl32(b, 7);
|
||||
|
||||
static void chacha20_block(uint32_t out[16], const uint32_t in[16]) {
|
||||
for (int i = 0; i < 16; ++i) out[i] = in[i];
|
||||
for (int i = 0; i < 10; ++i) {
|
||||
CHACHA20_QUARTERROUND(out[0], out[4], out[8], out[12]);
|
||||
CHACHA20_QUARTERROUND(out[1], out[5], out[9], out[13]);
|
||||
CHACHA20_QUARTERROUND(out[2], out[6], out[10], out[14]);
|
||||
CHACHA20_QUARTERROUND(out[3], out[7], out[11], out[15]);
|
||||
CHACHA20_QUARTERROUND(out[0], out[5], out[10], out[15]);
|
||||
CHACHA20_QUARTERROUND(out[1], out[6], out[11], out[12]);
|
||||
CHACHA20_QUARTERROUND(out[2], out[7], out[8], out[13]);
|
||||
CHACHA20_QUARTERROUND(out[3], out[4], out[9], out[14]);
|
||||
}
|
||||
for (int i = 0; i < 16; ++i) out[i] += in[i];
|
||||
}
|
||||
|
||||
static void chacha20_crypt(const uint8_t key[32], const uint8_t nonce[12], uint32_t counter,
|
||||
const uint8_t* in, uint8_t* out, std::size_t len) {
|
||||
uint32_t state[16];
|
||||
// Constants "expand 32-byte k"
|
||||
state[0] = 0x61707865;
|
||||
state[1] = 0x3320646e;
|
||||
state[2] = 0x79622d32;
|
||||
state[3] = 0x6b206574;
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
state[4 + i] = (static_cast<uint32_t>(key[i * 4 + 0])) |
|
||||
(static_cast<uint32_t>(key[i * 4 + 1]) << 8) |
|
||||
(static_cast<uint32_t>(key[i * 4 + 2]) << 16) |
|
||||
(static_cast<uint32_t>(key[i * 4 + 3]) << 24);
|
||||
}
|
||||
state[12] = counter;
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
state[13 + i] = (static_cast<uint32_t>(nonce[i * 4 + 0])) |
|
||||
(static_cast<uint32_t>(nonce[i * 4 + 1]) << 8) |
|
||||
(static_cast<uint32_t>(nonce[i * 4 + 2]) << 16) |
|
||||
(static_cast<uint32_t>(nonce[i * 4 + 3]) << 24);
|
||||
}
|
||||
|
||||
uint32_t block[16];
|
||||
uint8_t keyStream[64];
|
||||
std::size_t offset = 0;
|
||||
while (offset < len) {
|
||||
chacha20_block(block, state);
|
||||
for (int i = 0; i < 16; ++i) {
|
||||
keyStream[i * 4 + 0] = static_cast<uint8_t>(block[i] & 0xFF);
|
||||
keyStream[i * 4 + 1] = static_cast<uint8_t>((block[i] >> 8) & 0xFF);
|
||||
keyStream[i * 4 + 2] = static_cast<uint8_t>((block[i] >> 16) & 0xFF);
|
||||
keyStream[i * 4 + 3] = static_cast<uint8_t>((block[i] >> 24) & 0xFF);
|
||||
}
|
||||
state[12]++;
|
||||
const std::size_t chunk = std::min<std::size_t>(64, len - offset);
|
||||
for (std::size_t i = 0; i < chunk; ++i) {
|
||||
out[offset + i] = in[offset + i] ^ keyStream[i];
|
||||
}
|
||||
offset += chunk;
|
||||
}
|
||||
}
|
||||
|
||||
static std::string toHex(const uint8_t* data, std::size_t len) {
|
||||
std::ostringstream oss;
|
||||
oss << std::hex << std::setfill('0');
|
||||
for (std::size_t i = 0; i < len; ++i) {
|
||||
oss << std::setw(2) << static_cast<int>(data[i]);
|
||||
}
|
||||
return oss.str();
|
||||
}
|
||||
|
||||
static std::vector<uint8_t> fromHex(const std::string& hex) {
|
||||
std::vector<uint8_t> bytes;
|
||||
if (hex.length() % 2 != 0) return bytes;
|
||||
bytes.reserve(hex.length() / 2);
|
||||
for (std::size_t i = 0; i < hex.length(); i += 2) {
|
||||
const std::string byteStr = hex.substr(i, 2);
|
||||
char* end = nullptr;
|
||||
unsigned long b = std::strtoul(byteStr.c_str(), &end, 16);
|
||||
if (end == byteStr.c_str() + 2) {
|
||||
bytes.push_back(static_cast<uint8_t>(b));
|
||||
} else {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
static std::string trim(const std::string& s) {
|
||||
auto start = s.find_first_not_of(" \t\r\n");
|
||||
if (start == std::string::npos) return "";
|
||||
auto end = s.find_last_not_of(" \t\r\n");
|
||||
return s.substr(start, end - start + 1);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
SmbCredentialsManager& SmbCredentialsManager::instance() {
|
||||
static SmbCredentialsManager sInstance;
|
||||
return sInstance;
|
||||
}
|
||||
|
||||
SmbCredentialsManager::SmbCredentialsManager() {
|
||||
load();
|
||||
}
|
||||
|
||||
std::string SmbCredentialsManager::getStoragePath() const {
|
||||
#ifdef _WIN32
|
||||
const char* appData = std::getenv("APPDATA");
|
||||
if (appData) return std::string(appData) + "/PixelVR/smb_shares.json";
|
||||
#else
|
||||
const char* home = std::getenv("HOME");
|
||||
if (home) return std::string(home) + "/.config/pixelvr/smb_shares.json";
|
||||
#endif
|
||||
return "pixelvr_smb_shares.json";
|
||||
}
|
||||
|
||||
std::string SmbCredentialsManager::getDeviceKey() const {
|
||||
std::string seed = "PixelVR-Frame-SMB-2026-Salt!";
|
||||
#if defined(__linux__)
|
||||
std::ifstream mid("/etc/machine-id");
|
||||
if (mid.is_open()) {
|
||||
std::string id;
|
||||
std::getline(mid, id);
|
||||
id = trim(id);
|
||||
if (!id.empty()) {
|
||||
seed += ":" + id;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
const char* user = std::getenv("USER");
|
||||
if (user) seed += ":" + std::string(user);
|
||||
|
||||
// Derive 32-byte key using double FNV-1a / DJB2 mixing
|
||||
uint64_t h1 = 14695981039346656037ULL;
|
||||
uint64_t h2 = 5381;
|
||||
for (char c : seed) {
|
||||
h1 = (h1 ^ static_cast<uint8_t>(c)) * 1099511628211ULL;
|
||||
h2 = ((h2 << 5) + h2) + static_cast<uint8_t>(c);
|
||||
}
|
||||
uint8_t keyBytes[32];
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
keyBytes[i + 0] = static_cast<uint8_t>((h1 >> (i * 8)) & 0xFF);
|
||||
keyBytes[i + 8] = static_cast<uint8_t>((h2 >> (i * 8)) & 0xFF);
|
||||
keyBytes[i + 16] = static_cast<uint8_t>(((h1 ^ h2) >> (i * 8)) & 0xFF);
|
||||
keyBytes[i + 24] = static_cast<uint8_t>(((h1 + h2) >> (i * 8)) & 0xFF);
|
||||
}
|
||||
return std::string(reinterpret_cast<const char*>(keyBytes), 32);
|
||||
}
|
||||
|
||||
std::string SmbCredentialsManager::encryptPassword(const std::string& plain,
|
||||
const std::string& key,
|
||||
std::string& outIvHex) const {
|
||||
if (plain.empty()) {
|
||||
outIvHex.clear();
|
||||
return "";
|
||||
}
|
||||
uint8_t nonce[12];
|
||||
std::random_device rd;
|
||||
for (int i = 0; i < 12; ++i) {
|
||||
nonce[i] = static_cast<uint8_t>(rd() & 0xFF);
|
||||
}
|
||||
outIvHex = toHex(nonce, 12);
|
||||
|
||||
std::vector<uint8_t> cipher(plain.length());
|
||||
chacha20_crypt(reinterpret_cast<const uint8_t*>(key.data()), nonce, 1,
|
||||
reinterpret_cast<const uint8_t*>(plain.data()), cipher.data(), plain.length());
|
||||
return toHex(cipher.data(), cipher.size());
|
||||
}
|
||||
|
||||
std::string SmbCredentialsManager::decryptPassword(const std::string& cipherHex,
|
||||
const std::string& key,
|
||||
const std::string& ivHex) const {
|
||||
if (cipherHex.empty() || ivHex.empty()) return "";
|
||||
std::vector<uint8_t> cipher = fromHex(cipherHex);
|
||||
std::vector<uint8_t> nonce = fromHex(ivHex);
|
||||
if (cipher.empty() || nonce.size() != 12) return "";
|
||||
|
||||
std::string plain(cipher.size(), '\0');
|
||||
chacha20_crypt(reinterpret_cast<const uint8_t*>(key.data()), nonce.data(), 1,
|
||||
cipher.data(), reinterpret_cast<uint8_t*>(plain.data()), cipher.size());
|
||||
return plain;
|
||||
}
|
||||
|
||||
void SmbCredentialsManager::load() {
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
shares_.clear();
|
||||
|
||||
const std::string path = getStoragePath();
|
||||
std::ifstream f(path);
|
||||
if (!f.is_open()) {
|
||||
return;
|
||||
}
|
||||
|
||||
std::string content((std::istreambuf_iterator<char>(f)),
|
||||
std::istreambuf_iterator<char>());
|
||||
f.close();
|
||||
|
||||
const std::string key = getDeviceKey();
|
||||
|
||||
// Lightweight robust JSON parser for share objects
|
||||
std::size_t pos = 0;
|
||||
while ((pos = content.find('{', pos)) != std::string::npos) {
|
||||
std::size_t endObj = content.find('}', pos);
|
||||
if (endObj == std::string::npos) break;
|
||||
|
||||
std::string obj = content.substr(pos, endObj - pos + 1);
|
||||
pos = endObj + 1;
|
||||
|
||||
auto extractField = [&](const std::string& keyName) -> std::string {
|
||||
const std::string pattern = "\"" + keyName + "\"";
|
||||
std::size_t kpos = obj.find(pattern);
|
||||
if (kpos == std::string::npos) return "";
|
||||
std::size_t colon = obj.find(':', kpos + pattern.length());
|
||||
if (colon == std::string::npos) return "";
|
||||
std::size_t startVal = obj.find_first_not_of(" \t\r\n", colon + 1);
|
||||
if (startVal == std::string::npos) return "";
|
||||
if (obj[startVal] == '\"') {
|
||||
std::size_t endQuote = obj.find('\"', startVal + 1);
|
||||
if (endQuote != std::string::npos) {
|
||||
return obj.substr(startVal + 1, endQuote - startVal - 1);
|
||||
}
|
||||
} else {
|
||||
std::size_t endVal = obj.find_first_of(",}\r\n", startVal);
|
||||
return trim(obj.substr(startVal, endVal - startVal));
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
SmbShare s;
|
||||
s.id = extractField("id");
|
||||
s.name = extractField("name");
|
||||
s.host = extractField("host");
|
||||
s.share = extractField("share");
|
||||
s.workgroup = extractField("workgroup");
|
||||
if (s.workgroup.empty()) s.workgroup = "WORKGROUP";
|
||||
s.username = extractField("username");
|
||||
s.anonymous = (extractField("anonymous") == "true");
|
||||
|
||||
std::string passCipher = extractField("password_enc");
|
||||
std::string passIv = extractField("iv");
|
||||
if (!passCipher.empty() && !passIv.empty()) {
|
||||
s.password = decryptPassword(passCipher, key, passIv);
|
||||
}
|
||||
|
||||
if (!s.host.empty()) {
|
||||
if (s.name.empty()) {
|
||||
s.name = s.host + (!s.share.empty() ? ("/" + s.share) : "");
|
||||
}
|
||||
if (s.id.empty()) {
|
||||
s.id = s.host + (!s.share.empty() ? ("/" + s.share) : "");
|
||||
}
|
||||
shares_.push_back(s);
|
||||
}
|
||||
}
|
||||
|
||||
PIXELVR_LOG_INFO("SmbCredentialsManager: Loaded %zu configured shares from %s",
|
||||
shares_.size(), path.c_str());
|
||||
}
|
||||
|
||||
void SmbCredentialsManager::save() {
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
const std::string path = getStoragePath();
|
||||
|
||||
std::error_code ec;
|
||||
fs::path p(path);
|
||||
fs::create_directories(p.parent_path(), ec);
|
||||
|
||||
#if defined(__linux__) || defined(__APPLE__)
|
||||
// Ensure configuration directory is protected (0700)
|
||||
chmod(p.parent_path().string().c_str(), 0700);
|
||||
#endif
|
||||
|
||||
const std::string key = getDeviceKey();
|
||||
|
||||
std::ostringstream json;
|
||||
json << "[\n";
|
||||
for (std::size_t i = 0; i < shares_.size(); ++i) {
|
||||
const auto& s = shares_[i];
|
||||
std::string ivHex;
|
||||
std::string encPass = encryptPassword(s.password, key, ivHex);
|
||||
|
||||
json << " {\n";
|
||||
json << " \"id\": \"" << s.id << "\",\n";
|
||||
json << " \"name\": \"" << s.name << "\",\n";
|
||||
json << " \"host\": \"" << s.host << "\",\n";
|
||||
json << " \"share\": \"" << s.share << "\",\n";
|
||||
json << " \"workgroup\": \"" << s.workgroup << "\",\n";
|
||||
json << " \"username\": \"" << s.username << "\",\n";
|
||||
json << " \"password_enc\": \"" << encPass << "\",\n";
|
||||
json << " \"iv\": \"" << ivHex << "\",\n";
|
||||
json << " \"anonymous\": " << (s.anonymous ? "true" : "false") << "\n";
|
||||
json << " }" << (i + 1 < shares_.size() ? ",\n" : "\n");
|
||||
}
|
||||
json << "]\n";
|
||||
|
||||
std::ofstream f(path, std::ios::trunc);
|
||||
if (f.is_open()) {
|
||||
f << json.str();
|
||||
f.close();
|
||||
|
||||
#if defined(__linux__) || defined(__APPLE__)
|
||||
// Enforce strict OS-level permissions: readable & writable ONLY by steamos (0600)
|
||||
chmod(path.c_str(), 0600);
|
||||
#endif
|
||||
PIXELVR_LOG_INFO("SmbCredentialsManager: Saved %zu shares to %s (mode 0600)",
|
||||
shares_.size(), path.c_str());
|
||||
} else {
|
||||
PIXELVR_LOG_WARN("SmbCredentialsManager: Failed to write %s", path.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<SmbShare> SmbCredentialsManager::shares() const {
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
return shares_;
|
||||
}
|
||||
|
||||
void SmbCredentialsManager::addOrUpdateShare(const SmbShare& share) {
|
||||
if (share.host.empty()) return;
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
bool found = false;
|
||||
for (auto& s : shares_) {
|
||||
if (s.host == share.host && s.share == share.share) {
|
||||
s = share;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
shares_.push_back(share);
|
||||
}
|
||||
}
|
||||
save();
|
||||
}
|
||||
|
||||
bool SmbCredentialsManager::deleteShare(const std::string& host, const std::string& share) {
|
||||
bool removed = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
auto it = std::remove_if(shares_.begin(), shares_.end(), [&](const SmbShare& s) {
|
||||
if (s.host == host) {
|
||||
if (share.empty() || s.share == share || s.share.empty()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (it != shares_.end()) {
|
||||
shares_.erase(it, shares_.end());
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
if (removed) {
|
||||
save();
|
||||
PIXELVR_LOG_INFO("SmbCredentialsManager: Deleted share for host '%s'", host.c_str());
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
bool SmbCredentialsManager::findShare(const std::string& hostIn,
|
||||
const std::string& shareIn,
|
||||
SmbShare& outShare) const {
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
if (hostIn.empty()) return false;
|
||||
|
||||
std::string host = hostIn;
|
||||
std::transform(host.begin(), host.end(), host.begin(), ::tolower);
|
||||
std::string share = shareIn;
|
||||
std::transform(share.begin(), share.end(), share.begin(), ::tolower);
|
||||
|
||||
for (const auto& s : shares_) {
|
||||
std::string sHost = s.host;
|
||||
std::transform(sHost.begin(), sHost.end(), sHost.begin(), ::tolower);
|
||||
if (sHost == host) {
|
||||
std::string sShare = s.share;
|
||||
std::transform(sShare.begin(), sShare.end(), sShare.begin(), ::tolower);
|
||||
if (sShare == share || (share.empty() && sShare.empty())) {
|
||||
outShare = s;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool SmbCredentialsManager::findCredentials(const std::string& hostIn,
|
||||
const std::string& shareIn,
|
||||
std::string& outUser,
|
||||
std::string& outPass,
|
||||
std::string& outWorkgroup) const {
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
if (hostIn.empty()) return false;
|
||||
|
||||
std::string host = hostIn;
|
||||
std::transform(host.begin(), host.end(), host.begin(), ::tolower);
|
||||
std::string share = shareIn;
|
||||
std::transform(share.begin(), share.end(), share.begin(), ::tolower);
|
||||
|
||||
const SmbShare* bestMatch = nullptr;
|
||||
for (const auto& s : shares_) {
|
||||
std::string sHost = s.host;
|
||||
std::transform(sHost.begin(), sHost.end(), sHost.begin(), ::tolower);
|
||||
if (sHost == host) {
|
||||
std::string sShare = s.share;
|
||||
std::transform(sShare.begin(), sShare.end(), sShare.begin(), ::tolower);
|
||||
if (!share.empty() && sShare == share) {
|
||||
bestMatch = &s; // Exact share match takes highest priority
|
||||
break;
|
||||
}
|
||||
if (sShare.empty() && !bestMatch) {
|
||||
bestMatch = &s; // Host-level wildcard match
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestMatch && !bestMatch->anonymous) {
|
||||
outUser = bestMatch->username;
|
||||
outPass = bestMatch->password;
|
||||
outWorkgroup = bestMatch->workgroup.empty() ? "WORKGROUP" : bestMatch->workgroup;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string SmbCredentialsManager::getAuthFilePath(const std::string& host, const std::string& share) {
|
||||
std::string user, pass, wg;
|
||||
if (!findCredentials(host, share, user, pass, wg)) {
|
||||
return "";
|
||||
}
|
||||
if (user.empty()) return "";
|
||||
|
||||
std::string basePath = getStoragePath();
|
||||
fs::path p(basePath);
|
||||
std::string safeHost = host;
|
||||
for (char& c : safeHost) {
|
||||
if (!isalnum(c)) c = '_';
|
||||
}
|
||||
fs::path authFile = p.parent_path() / ("auth_" + safeHost + ".tmp");
|
||||
|
||||
std::ofstream f(authFile.string(), std::ios::trunc);
|
||||
if (!f.is_open()) return "";
|
||||
|
||||
f << "username = " << user << "\n";
|
||||
f << "password = " << pass << "\n";
|
||||
f << "domain = " << (wg.empty() ? "WORKGROUP" : wg) << "\n";
|
||||
f.close();
|
||||
|
||||
#if defined(__linux__) || defined(__APPLE__)
|
||||
chmod(authFile.string().c_str(), 0600);
|
||||
#endif
|
||||
return authFile.string();
|
||||
}
|
||||
|
||||
} // namespace pixelvr
|
||||
@@ -0,0 +1,69 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <mutex>
|
||||
|
||||
namespace pixelvr {
|
||||
|
||||
struct SmbShare {
|
||||
std::string id; // Unique identifier (typically host or host:share)
|
||||
std::string name; // User-friendly display label (e.g. "Home NAS")
|
||||
std::string host; // IP address or hostname (e.g. "192.168.1.50")
|
||||
std::string share; // Optional default share name (e.g. "movies", or empty for all)
|
||||
std::string workgroup; // Workgroup/Domain (defaults to "WORKGROUP")
|
||||
std::string username; // SMB Username (or empty for anonymous)
|
||||
std::string password; // SMB Password (decrypted in memory, encrypted on disk)
|
||||
bool anonymous = false; // True for guest / public shares
|
||||
};
|
||||
|
||||
class SmbCredentialsManager {
|
||||
public:
|
||||
static SmbCredentialsManager& instance();
|
||||
|
||||
// Loads configured shares from ~/.config/pixelvr/smb_shares.json
|
||||
void load();
|
||||
|
||||
// Saves configured shares to ~/.config/pixelvr/smb_shares.json with strict 0600 permissions
|
||||
void save();
|
||||
|
||||
// Returns all configured SMB shares
|
||||
std::vector<SmbShare> shares() const;
|
||||
|
||||
// Adds a new share or updates an existing one (matched by host + share)
|
||||
void addOrUpdateShare(const SmbShare& share);
|
||||
|
||||
// Deletes a configured share and wipes its stored credentials
|
||||
bool deleteShare(const std::string& host, const std::string& share = "");
|
||||
|
||||
// Lookup a configured share
|
||||
bool findShare(const std::string& host,
|
||||
const std::string& share,
|
||||
SmbShare& outShare) const;
|
||||
|
||||
// Lookup credentials for a given host and optional share
|
||||
bool findCredentials(const std::string& host,
|
||||
const std::string& share,
|
||||
std::string& outUser,
|
||||
std::string& outPass,
|
||||
std::string& outWorkgroup) const;
|
||||
|
||||
// Generates or updates a secure temporary Samba auth file (mode 0600) for smbclient -A
|
||||
// Returns the absolute file path, or empty string if anonymous / no credentials.
|
||||
std::string getAuthFilePath(const std::string& host, const std::string& share);
|
||||
|
||||
private:
|
||||
SmbCredentialsManager();
|
||||
~SmbCredentialsManager() = default;
|
||||
|
||||
std::string getStoragePath() const;
|
||||
std::string getDeviceKey() const;
|
||||
|
||||
std::string encryptPassword(const std::string& plain, const std::string& key, std::string& outIv) const;
|
||||
std::string decryptPassword(const std::string& cipherHex, const std::string& key, const std::string& ivHex) const;
|
||||
|
||||
mutable std::mutex mutex_;
|
||||
std::vector<SmbShare> shares_;
|
||||
};
|
||||
|
||||
} // namespace pixelvr
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "ui/browser.hpp"
|
||||
#include "app/vault_manager.hpp"
|
||||
#include "net/smb_credentials.hpp"
|
||||
|
||||
#include "util/logging.hpp"
|
||||
#include "util/smb_lock.hpp"
|
||||
@@ -8,7 +9,9 @@
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <set>
|
||||
@@ -48,8 +51,24 @@ bool is_video(const std::string& name) {
|
||||
}
|
||||
|
||||
#ifdef PIXELVR_HAVE_SMB
|
||||
void smb_auth(const char*, const char*, char*, int, char* user, int userLen,
|
||||
void smb_auth(const char* srv, const char* shr, char* wg, int wgLen, char* user, int userLen,
|
||||
char* pass, int passLen) {
|
||||
std::string u, p, w;
|
||||
if (srv != nullptr && SmbCredentialsManager::instance().findCredentials(srv, shr ? shr : "", u, p, w)) {
|
||||
if (user && userLen > 0) {
|
||||
std::strncpy(user, u.c_str(), userLen - 1);
|
||||
user[userLen - 1] = '\0';
|
||||
}
|
||||
if (pass && passLen > 0) {
|
||||
std::strncpy(pass, p.c_str(), passLen - 1);
|
||||
pass[passLen - 1] = '\0';
|
||||
}
|
||||
if (wg && wgLen > 0 && !w.empty()) {
|
||||
std::strncpy(wg, w.c_str(), wgLen - 1);
|
||||
wg[wgLen - 1] = '\0';
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (user != nullptr && userLen > 0) user[0] = '\0'; // anonymous / guest
|
||||
if (pass != nullptr && passLen > 0) pass[0] = '\0';
|
||||
}
|
||||
@@ -100,6 +119,8 @@ Browser::Browser() {
|
||||
const char* home = std::getenv("HOME");
|
||||
homeDir_ = (home != nullptr) ? home : "/home/steamos";
|
||||
downloadDir_ = homeDir_ + "/media";
|
||||
quickPath_ = homeDir_ + "/Videos";
|
||||
loadBookmarks();
|
||||
}
|
||||
|
||||
void Browser::open() {
|
||||
@@ -288,10 +309,31 @@ void Browser::scanSmbInto(const std::string& uri, ScanResult& out) {
|
||||
}
|
||||
|
||||
// NetBIOS browsing often misses modern servers; augment the network root with
|
||||
// mDNS-advertised SMB hosts (NAS, macOS, Samba+avahi). The slow network probe
|
||||
// runs OUTSIDE the SMB lock (it is not a libsmbclient call).
|
||||
// configured manual shares and mDNS-advertised SMB hosts (NAS, macOS, Samba+avahi).
|
||||
if (uri == "smb://") {
|
||||
std::set<std::string> seen;
|
||||
// The very first entry in the SMB list is the "+ Add SMB Share" button!
|
||||
dirs.push_back({"+ Add SMB Share", "smb://__add_share__", true, false, false, false, "Configure New Network Share"});
|
||||
seen.insert("smb://__add_share__");
|
||||
|
||||
// Prepend all user-configured manual SMB shares so private/unadvertised shares always appear
|
||||
const auto configuredShares = SmbCredentialsManager::instance().shares();
|
||||
for (const auto& s : configuredShares) {
|
||||
if (s.host.empty()) continue;
|
||||
std::string child = "smb://" + s.host;
|
||||
if (!s.share.empty()) {
|
||||
if (child.back() != '/') child += '/';
|
||||
child += s.share;
|
||||
}
|
||||
if (!seen.insert(child).second) continue;
|
||||
std::string label = s.name.empty() ? s.host : s.name;
|
||||
std::string detail = s.anonymous ? "Configured (Guest)" : "Configured (Protected)";
|
||||
if (!s.share.empty()) {
|
||||
dirs.push_back({label + " (" + s.host + "/" + s.share + ")", child, true, false, false, false, detail});
|
||||
} else {
|
||||
dirs.push_back({label + " (" + s.host + ")", child, true, false, false, false, detail});
|
||||
}
|
||||
}
|
||||
for (const auto& d : dirs) seen.insert(d.path);
|
||||
FILE* pipe = popen("timeout 4 avahi-browse -trp _smb._tcp 2>/dev/null", "r");
|
||||
if (pipe != nullptr) {
|
||||
@@ -411,6 +453,157 @@ void Browser::openVault() {
|
||||
openPath(VaultManager::instance().vaultDir());
|
||||
}
|
||||
|
||||
void Browser::openBookmarks() {
|
||||
open_ = true;
|
||||
browseOpen_.store(true);
|
||||
status_.clear();
|
||||
beginScan("bookmarks://", false);
|
||||
}
|
||||
|
||||
void Browser::addBookmark(const std::string& path, const std::string& label) {
|
||||
if (path.empty() || path == "bookmarks://" || path == "smb://__add_share__") return;
|
||||
for (auto& b : bookmarks_) {
|
||||
if (b.path == path) return;
|
||||
}
|
||||
std::string l = label;
|
||||
if (l.empty()) {
|
||||
if (path.rfind("smb://", 0) == 0) {
|
||||
std::string rest = path.substr(6);
|
||||
while (!rest.empty() && rest.back() == '/') rest.pop_back();
|
||||
l = rest;
|
||||
} else {
|
||||
fs::path p(path);
|
||||
l = p.filename().string();
|
||||
if (l.empty()) l = path;
|
||||
}
|
||||
}
|
||||
bookmarks_.push_back({path, l});
|
||||
quickPath_ = path;
|
||||
saveBookmarks();
|
||||
if (location_ == "bookmarks://") {
|
||||
beginScan("bookmarks://", false);
|
||||
}
|
||||
}
|
||||
|
||||
void Browser::removeBookmark(const std::string& path) {
|
||||
auto it = std::remove_if(bookmarks_.begin(), bookmarks_.end(), [&](const Bookmark& b) {
|
||||
return b.path == path;
|
||||
});
|
||||
if (it != bookmarks_.end()) {
|
||||
bookmarks_.erase(it, bookmarks_.end());
|
||||
saveBookmarks();
|
||||
if (location_ == "bookmarks://") {
|
||||
beginScan("bookmarks://", false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool Browser::isBookmarked(const std::string& path) const {
|
||||
if (path.empty() || path == "bookmarks://") return false;
|
||||
for (const auto& b : bookmarks_) {
|
||||
if (b.path == path) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void Browser::loadBookmarks() {
|
||||
bookmarks_.clear();
|
||||
const char* home = std::getenv("HOME");
|
||||
std::string path = std::string(home ? home : ".") + "/.config/pixelvr/bookmarks.json";
|
||||
std::ifstream f(path);
|
||||
if (f.is_open()) {
|
||||
std::string content((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
|
||||
f.close();
|
||||
|
||||
std::size_t pos = 0;
|
||||
while ((pos = content.find('{', pos)) != std::string::npos) {
|
||||
std::size_t endObj = content.find('}', pos);
|
||||
if (endObj == std::string::npos) break;
|
||||
std::string obj = content.substr(pos, endObj - pos + 1);
|
||||
pos = endObj + 1;
|
||||
|
||||
auto extract = [&](const std::string& key) -> std::string {
|
||||
std::string pat = "\"" + key + "\"";
|
||||
auto kpos = obj.find(pat);
|
||||
if (kpos == std::string::npos) return "";
|
||||
auto col = obj.find(':', kpos + pat.length());
|
||||
if (col == std::string::npos) return "";
|
||||
auto q1 = obj.find('\"', col + 1);
|
||||
if (q1 == std::string::npos) return "";
|
||||
auto q2 = obj.find('\"', q1 + 1);
|
||||
if (q2 == std::string::npos) return "";
|
||||
return obj.substr(q1 + 1, q2 - q1 - 1);
|
||||
};
|
||||
std::string bPath = extract("path");
|
||||
std::string bLabel = extract("label");
|
||||
if (!bPath.empty()) {
|
||||
bookmarks_.push_back({bPath, bLabel});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bookmarks_.empty() && !quickPath_.empty()) {
|
||||
addBookmark(quickPath_);
|
||||
}
|
||||
}
|
||||
|
||||
void Browser::saveBookmarks() const {
|
||||
const char* home = std::getenv("HOME");
|
||||
std::string configDir = std::string(home ? home : ".") + "/.config/pixelvr";
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(configDir, ec);
|
||||
|
||||
std::string path = configDir + "/bookmarks.json";
|
||||
std::ofstream f(path, std::ios::trunc);
|
||||
if (!f.is_open()) return;
|
||||
|
||||
f << "[\n";
|
||||
for (std::size_t i = 0; i < bookmarks_.size(); ++i) {
|
||||
f << " {\n";
|
||||
f << " \"path\": \"" << bookmarks_[i].path << "\",\n";
|
||||
f << " \"label\": \"" << bookmarks_[i].label << "\"\n";
|
||||
f << " }" << (i + 1 < bookmarks_.size() ? ",\n" : "\n");
|
||||
}
|
||||
f << "]\n";
|
||||
f.close();
|
||||
}
|
||||
|
||||
void Browser::scanBookmarksInto(ScanResult& out) {
|
||||
out.inSmb = false;
|
||||
out.location = "bookmarks://";
|
||||
out.title = "Bookmarks";
|
||||
out.entries.clear();
|
||||
out.entries.push_back({".. (Home)", homeDir_, true, false, true, false});
|
||||
|
||||
if (bookmarks_.empty()) {
|
||||
out.status = "No bookmarks yet. Click [+ Bookmark] in any folder to add one.";
|
||||
return;
|
||||
}
|
||||
|
||||
out.status = std::to_string(bookmarks_.size()) + (bookmarks_.size() == 1 ? " bookmark" : " bookmarks");
|
||||
|
||||
for (const auto& bm : bookmarks_) {
|
||||
if (bm.path.empty()) continue;
|
||||
const bool isSmb = (bm.path.rfind("smb://", 0) == 0);
|
||||
std::error_code ec;
|
||||
const bool isDir = isSmb || fs::is_directory(bm.path, ec);
|
||||
std::string label = bm.label;
|
||||
if (label.empty()) {
|
||||
if (isSmb) {
|
||||
label = bm.path.substr(6);
|
||||
while (!label.empty() && label.back() == '/') label.pop_back();
|
||||
} else {
|
||||
fs::path p(bm.path);
|
||||
label = p.filename().string();
|
||||
if (label.empty()) label = bm.path;
|
||||
}
|
||||
}
|
||||
std::string detail = isSmb ? "SMB Share" : (isDir ? "Folder" : "Video File");
|
||||
std::string previewPath = isDir ? "" : bm.path;
|
||||
out.entries.push_back({label, bm.path, isDir, !isDir && is_video(bm.path), false, isSmb, detail, 0, previewPath});
|
||||
}
|
||||
}
|
||||
|
||||
bool Browser::inVault() const {
|
||||
return VaultManager::instance().isVaultPath(location_);
|
||||
}
|
||||
@@ -519,6 +712,7 @@ bool Browser::renameFocused(const std::string& newName) {
|
||||
}
|
||||
|
||||
std::string Browser::parentDir() const {
|
||||
if (location_ == "bookmarks://") return homeDir_;
|
||||
if (location_.empty() || location_ == "/" || inSmb_) return "";
|
||||
fs::path p(location_);
|
||||
return p.parent_path().string();
|
||||
@@ -543,7 +737,9 @@ std::string Browser::downloadsDir() const {
|
||||
|
||||
void Browser::runScan(std::string location, bool isSmb) {
|
||||
ScanResult r;
|
||||
if (isSmb) {
|
||||
if (location == "bookmarks://") {
|
||||
scanBookmarksInto(r);
|
||||
} else if (isSmb) {
|
||||
scanSmbInto(location, r);
|
||||
} else {
|
||||
scanLocalInto(location, r);
|
||||
@@ -565,6 +761,10 @@ Browser::Result Browser::activate(std::string& playPath) {
|
||||
const Entry e = entries_[static_cast<std::size_t>(selected_)];
|
||||
|
||||
if (e.isBack) {
|
||||
if (location_ == "bookmarks://") {
|
||||
beginScan(homeDir_, false);
|
||||
return Result::Navigated;
|
||||
}
|
||||
if (inSmb_) {
|
||||
const std::string parent = smb_parent(location_);
|
||||
if (parent.empty()) {
|
||||
@@ -587,12 +787,16 @@ Browser::Result Browser::activate(std::string& playPath) {
|
||||
}
|
||||
return Result::Navigated;
|
||||
}
|
||||
if (e.path == "smb://__add_share__") {
|
||||
return Result::AddSmbShare;
|
||||
}
|
||||
if (e.isNetwork) {
|
||||
beginScan("smb://", true);
|
||||
return Result::Navigated;
|
||||
}
|
||||
if (e.isDir) {
|
||||
beginScan(e.path, inSmb_);
|
||||
const bool isSmb = e.isNetwork || (e.path.rfind("smb://", 0) == 0);
|
||||
beginScan(e.path, isSmb);
|
||||
return Result::Navigated;
|
||||
}
|
||||
if (e.isVideo) {
|
||||
@@ -793,6 +997,8 @@ int Browser::spawnCurl(const std::string& uri, const std::string& local) {
|
||||
const std::size_t s2 =
|
||||
(s1 == std::string::npos) ? std::string::npos : rest.find('/', s1 + 1);
|
||||
if (s2 == std::string::npos) return -1;
|
||||
const std::string host = rest.substr(0, s1);
|
||||
const std::string share = rest.substr(s1 + 1, s2 - s1 - 1);
|
||||
const std::string service = "//" + rest.substr(0, s2);
|
||||
const std::string remote = rest.substr(s2 + 1);
|
||||
std::string cmd = "get \"";
|
||||
@@ -800,14 +1006,26 @@ int Browser::spawnCurl(const std::string& uri, const std::string& local) {
|
||||
cmd += "\" \"";
|
||||
cmd += local;
|
||||
cmd += "\"";
|
||||
PIXELVR_LOG_INFO("DL spawn: smbclient %s -N -c '%s'", service.c_str(),
|
||||
cmd.c_str());
|
||||
char* argv[] = {const_cast<char*>("smbclient"),
|
||||
const_cast<char*>(service.c_str()),
|
||||
const_cast<char*>("-N"),
|
||||
const_cast<char*>("-c"),
|
||||
const_cast<char*>(cmd.c_str()),
|
||||
nullptr};
|
||||
|
||||
std::string authFile = SmbCredentialsManager::instance().getAuthFilePath(host, share);
|
||||
PIXELVR_LOG_INFO("DL spawn: smbclient %s %s -c '%s'", service.c_str(),
|
||||
authFile.empty() ? "-N" : ("-A " + authFile).c_str(), cmd.c_str());
|
||||
|
||||
char* argv[8] = {nullptr};
|
||||
argv[0] = const_cast<char*>("smbclient");
|
||||
argv[1] = const_cast<char*>(service.c_str());
|
||||
if (!authFile.empty()) {
|
||||
argv[2] = const_cast<char*>("-A");
|
||||
argv[3] = const_cast<char*>(authFile.c_str());
|
||||
argv[4] = const_cast<char*>("-c");
|
||||
argv[5] = const_cast<char*>(cmd.c_str());
|
||||
argv[6] = nullptr;
|
||||
} else {
|
||||
argv[2] = const_cast<char*>("-N");
|
||||
argv[3] = const_cast<char*>("-c");
|
||||
argv[4] = const_cast<char*>(cmd.c_str());
|
||||
argv[5] = nullptr;
|
||||
}
|
||||
posix_spawn_file_actions_t fa;
|
||||
posix_spawn_file_actions_init(&fa);
|
||||
// Take stdin from /dev/null so smbclient can never block on an auth prompt.
|
||||
|
||||
@@ -26,7 +26,7 @@ public:
|
||||
long long size = 0; // bytes (0 if unknown)
|
||||
std::string previewPath; // video file inside folder or self (for thumbnail)
|
||||
};
|
||||
enum class Result { None, Navigated, Play };
|
||||
enum class Result { None, Navigated, Play, AddSmbShare };
|
||||
|
||||
Browser();
|
||||
|
||||
@@ -42,12 +42,31 @@ public:
|
||||
void openDownloads();
|
||||
void openDrives();
|
||||
void openVault();
|
||||
struct Bookmark {
|
||||
std::string path;
|
||||
std::string label;
|
||||
};
|
||||
void openBookmarks();
|
||||
void addBookmark(const std::string& path, const std::string& label = "");
|
||||
void removeBookmark(const std::string& path);
|
||||
bool isBookmarked(const std::string& path) const;
|
||||
bool isCurrentBookmarked() const { return isBookmarked(location_); }
|
||||
const std::vector<Bookmark>& bookmarks() const { return bookmarks_; }
|
||||
void loadBookmarks();
|
||||
void saveBookmarks() const;
|
||||
bool onBookmarks() const { return location_ == "bookmarks://"; }
|
||||
|
||||
void openQuickPath() { openBookmarks(); }
|
||||
void setQuickPath(const std::string& path) { addBookmark(path); }
|
||||
const std::string& quickPath() const { return quickPath_; }
|
||||
bool isCurrentQuickPath() const { return isCurrentBookmarked(); }
|
||||
bool inVault() const;
|
||||
bool moveFocusedToVault(std::string& outNewPath);
|
||||
bool moveFocusedTo(const std::string& destDir, std::string& outNewPath);
|
||||
bool deleteFocused(std::string& deletedName);
|
||||
bool renameFocused(const std::string& newName);
|
||||
const std::string& currentLocation() const { return location_; }
|
||||
const std::string& currentPath() const { return location_; }
|
||||
std::string parentDir() const;
|
||||
std::string homeDir() const;
|
||||
std::string videosDir() const;
|
||||
@@ -164,6 +183,7 @@ private:
|
||||
bool hasPendingScan_ = false;
|
||||
void scanLocalInto(const std::string& dir, ScanResult& out);
|
||||
void scanSmbInto(const std::string& uri, ScanResult& out);
|
||||
void scanBookmarksInto(ScanResult& out);
|
||||
void startDownloadFile(const std::string& uri, const std::string& local);
|
||||
void startDownloadFolder(const std::string& uri, const std::string& name);
|
||||
void startNextQueued();
|
||||
@@ -174,6 +194,8 @@ private:
|
||||
std::string status_;
|
||||
std::string location_; // current local dir or smb:// URI
|
||||
std::string homeDir_;
|
||||
std::string quickPath_; // bookmarked folder for one-button jump
|
||||
std::vector<Bookmark> bookmarks_;
|
||||
std::string lastLocation_; // last folder visited (local or smb), for re-open
|
||||
bool lastInSmb_ = false;
|
||||
std::vector<Entry> entries_;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#include "ui/settings_menu.hpp"
|
||||
#include "app/vault_manager.hpp"
|
||||
#include "net/smb_credentials.hpp"
|
||||
#include "xr/vr_keyboard.hpp"
|
||||
|
||||
#include "util/logging.hpp"
|
||||
|
||||
@@ -78,7 +80,7 @@ SettingsMenu::SettingsMenu() {
|
||||
|
||||
void SettingsMenu::open() {
|
||||
open_ = true;
|
||||
if (section_ < 0 || section_ > 3) section_ = 0;
|
||||
if (section_ < 0 || (section_ > 5 && section_ != 20)) section_ = 0;
|
||||
refresh();
|
||||
}
|
||||
|
||||
@@ -142,6 +144,17 @@ void SettingsMenu::refresh() {
|
||||
rows_.push_back({"UI Panel Distance",
|
||||
kUiDistanceNames[uiDistanceIdx_ % kUiDistanceCount],
|
||||
Action::CycleUiDistance});
|
||||
const char* followModeNames[2] = {"World Lock (Fixed)", "Smooth Gaze Follow"};
|
||||
rows_.push_back({"UI Follow Mode",
|
||||
followModeNames[uiFollowMode_ % 2],
|
||||
Action::CycleUiFollowMode});
|
||||
const char* viewModeNames[3] = {"Large Cards (3x2)", "Smaller Cards (4x3)", "Compact List (1-Col)"};
|
||||
rows_.push_back({"Browser View Mode",
|
||||
viewModeNames[browserViewMode_ % 3],
|
||||
Action::CycleBrowserViewMode});
|
||||
rows_.push_back({"Configure List Columns",
|
||||
"[ Customize Columns > ]",
|
||||
Action::EnterListColsConfig});
|
||||
rows_.push_back({"Download Folder",
|
||||
kDownloadDirs[downloadDirIdx_ % kDownloadDirCount],
|
||||
Action::CycleDownloadDir});
|
||||
@@ -183,6 +196,8 @@ void SettingsMenu::refresh() {
|
||||
Action::ListenMenu});
|
||||
rows_.push_back({"Recenter View", btnLabel(2, bindRecenterBtn_),
|
||||
Action::ListenRecenter});
|
||||
rows_.push_back({"Bookmarks Shortcut", btnLabel(4, bindQuickPathBtn_),
|
||||
Action::ListenQuickPath});
|
||||
rows_.push_back({"Select / Click", "Trigger", Action::None});
|
||||
rows_.push_back({"Controller Models", drawModels_ ? "On" : "Off",
|
||||
Action::ToggleModels});
|
||||
@@ -220,6 +235,13 @@ void SettingsMenu::refresh() {
|
||||
|
||||
rows_.push_back({"Lock Vault Now", "[ Lock & Return Home ]",
|
||||
Action::LockVaultAction});
|
||||
} else if (section_ == 21) {
|
||||
title_ = "Settings > List Columns";
|
||||
rows_.push_back({"Format / VR Badge", listShowFormat_ ? "Visible" : "Hidden", Action::ToggleListColFormat});
|
||||
rows_.push_back({"Resolution & FPS", listShowRes_ ? "Visible" : "Hidden", Action::ToggleListColRes});
|
||||
rows_.push_back({"File Size", listShowSize_ ? "Visible" : "Hidden", Action::ToggleListColSize});
|
||||
rows_.push_back({"Video Duration", listShowDuration_ ? "Visible" : "Hidden", Action::ToggleListColDuration});
|
||||
rows_.push_back({"< Back to UI & Appearance", "", Action::BackToUI});
|
||||
} else if (section_ == 10) {
|
||||
title_ = "Settings > Select Video Track";
|
||||
for (std::size_t i = 0; i < videoStreams_.size(); ++i) {
|
||||
@@ -346,6 +368,21 @@ SettingsMenu::Action SettingsMenu::activate() {
|
||||
saveConfig();
|
||||
refresh();
|
||||
break;
|
||||
case Action::CycleUiFollowMode:
|
||||
uiFollowMode_ = (uiFollowMode_ + 1) % 2;
|
||||
saveConfig();
|
||||
refresh();
|
||||
break;
|
||||
case Action::CycleBrowserViewMode:
|
||||
browserViewMode_ = (browserViewMode_ + 1) % 3;
|
||||
saveConfig();
|
||||
refresh();
|
||||
break;
|
||||
case Action::ListenQuickPath:
|
||||
listeningFunc_ = 4;
|
||||
status_ = "Press a controller button for Bookmarks shortcut";
|
||||
refresh();
|
||||
break;
|
||||
case Action::PickVideoStream:
|
||||
videoSel_ = rows_[selected_].data;
|
||||
section_ = 2;
|
||||
@@ -399,6 +436,37 @@ SettingsMenu::Action SettingsMenu::activate() {
|
||||
section_ = 0;
|
||||
refresh();
|
||||
break;
|
||||
|
||||
case Action::EnterListColsConfig:
|
||||
section_ = 21;
|
||||
selected_ = 0;
|
||||
refresh();
|
||||
break;
|
||||
case Action::ToggleListColFormat:
|
||||
listShowFormat_ = !listShowFormat_;
|
||||
refresh();
|
||||
saveConfig();
|
||||
break;
|
||||
case Action::ToggleListColRes:
|
||||
listShowRes_ = !listShowRes_;
|
||||
refresh();
|
||||
saveConfig();
|
||||
break;
|
||||
case Action::ToggleListColSize:
|
||||
listShowSize_ = !listShowSize_;
|
||||
refresh();
|
||||
saveConfig();
|
||||
break;
|
||||
case Action::ToggleListColDuration:
|
||||
listShowDuration_ = !listShowDuration_;
|
||||
refresh();
|
||||
saveConfig();
|
||||
break;
|
||||
case Action::BackToUI:
|
||||
section_ = 1;
|
||||
selected_ = 0;
|
||||
refresh();
|
||||
break;
|
||||
case Action::RecenterScreen:
|
||||
break;
|
||||
case Action::Close:
|
||||
@@ -448,6 +516,7 @@ void SettingsMenu::assignListening(int button) {
|
||||
else if (listeningFunc_ == 1) bindMenuBtn_ = b;
|
||||
else if (listeningFunc_ == 2) bindRecenterBtn_ = b;
|
||||
else if (listeningFunc_ == 3) VaultManager::instance().setMoveButton(b);
|
||||
else if (listeningFunc_ == 4) bindQuickPathBtn_ = b;
|
||||
listeningFunc_ = -1;
|
||||
status_ = "Bound to " + std::string(kButtonNames[b]);
|
||||
saveConfig();
|
||||
@@ -505,6 +574,7 @@ void SettingsMenu::loadConfig() {
|
||||
// so a saved grip binding is migrated to the new default (Bumper).
|
||||
bindRecenterBtn_ = (b == 8) ? 6 : b;
|
||||
}
|
||||
else if (key == "bind_quick_path_btn") bindQuickPathBtn_ = std::clamp(std::stoi(val), 0, kButtonCount - 1);
|
||||
else if (key == "download_dir") downloadDirIdx_ = std::clamp(std::stoi(val), 0, kDownloadDirCount - 1);
|
||||
else if (key == "audio_lang") audioLangIdx_ = std::clamp(std::stoi(val), 0, kLangCount - 1);
|
||||
else if (key == "curvature") curvatureIdx_ = std::clamp(std::stoi(val), 0, kCurvatureCount - 1);
|
||||
@@ -512,6 +582,13 @@ void SettingsMenu::loadConfig() {
|
||||
else if (key == "ui_text_color") uiTextColorIdx_ = std::clamp(std::stoi(val), 0, kUiTextColorCount - 1);
|
||||
else if (key == "ui_accent_color") uiAccentColorIdx_ = std::clamp(std::stoi(val), 0, kUiAccentColorCount - 1);
|
||||
else if (key == "ui_distance") uiDistanceIdx_ = std::clamp(std::stoi(val), 0, kUiDistanceCount - 1);
|
||||
else if (key == "ui_follow_mode") uiFollowMode_ = std::clamp(std::stoi(val), 0, 1);
|
||||
else if (key == "browser_view_mode") browserViewMode_ = std::clamp(std::stoi(val), 0, 2);
|
||||
else if (key == "list_show_format") listShowFormat_ = (val == "1" || val == "true");
|
||||
else if (key == "list_show_res") listShowRes_ = (val == "1" || val == "true");
|
||||
else if (key == "list_show_size") listShowSize_ = (val == "1" || val == "true");
|
||||
else if (key == "list_show_dur") listShowDuration_ = (val == "1" || val == "true");
|
||||
else if (key == "quick_path") quickPath_ = val;
|
||||
}
|
||||
PIXELVR_LOG_INFO("Loaded settings from %s", path.c_str());
|
||||
}
|
||||
@@ -534,6 +611,7 @@ void SettingsMenu::saveConfig() const {
|
||||
file << "bind_play_btn=" << bindPlayBtn_ << "\n";
|
||||
file << "bind_menu_btn=" << bindMenuBtn_ << "\n";
|
||||
file << "bind_recenter_btn=" << bindRecenterBtn_ << "\n";
|
||||
file << "bind_quick_path_btn=" << bindQuickPathBtn_ << "\n";
|
||||
file << "download_dir=" << downloadDirIdx_ << "\n";
|
||||
file << "audio_lang=" << audioLangIdx_ << "\n";
|
||||
file << "curvature=" << curvatureIdx_ << "\n";
|
||||
@@ -541,6 +619,15 @@ void SettingsMenu::saveConfig() const {
|
||||
file << "ui_text_color=" << uiTextColorIdx_ << "\n";
|
||||
file << "ui_accent_color=" << uiAccentColorIdx_ << "\n";
|
||||
file << "ui_distance=" << uiDistanceIdx_ << "\n";
|
||||
file << "ui_follow_mode=" << uiFollowMode_ << "\n";
|
||||
file << "browser_view_mode=" << browserViewMode_ << "\n";
|
||||
file << "list_show_format=" << (listShowFormat_ ? 1 : 0) << "\n";
|
||||
file << "list_show_res=" << (listShowRes_ ? 1 : 0) << "\n";
|
||||
file << "list_show_size=" << (listShowSize_ ? 1 : 0) << "\n";
|
||||
file << "list_show_dur=" << (listShowDuration_ ? 1 : 0) << "\n";
|
||||
if (!quickPath_.empty()) {
|
||||
file << "quick_path=" << quickPath_ << "\n";
|
||||
}
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
PIXELVR_LOG_WARN("Could not save settings: %s", e.what());
|
||||
|
||||
@@ -30,6 +30,9 @@ public:
|
||||
CycleUiTextColor,
|
||||
CycleUiAccentColor,
|
||||
CycleUiDistance,
|
||||
CycleUiFollowMode,
|
||||
CycleBrowserViewMode,
|
||||
ListenQuickPath,
|
||||
EnterVideoList,
|
||||
EnterAudioList,
|
||||
EnterLangList,
|
||||
@@ -42,6 +45,12 @@ public:
|
||||
ListenVaultMoveBtn,
|
||||
RecordVaultCombo,
|
||||
LockVaultAction,
|
||||
EnterListColsConfig,
|
||||
ToggleListColFormat,
|
||||
ToggleListColRes,
|
||||
ToggleListColSize,
|
||||
ToggleListColDuration,
|
||||
BackToUI,
|
||||
Close
|
||||
};
|
||||
|
||||
@@ -95,6 +104,13 @@ public:
|
||||
int uiTextColorIdx() const { return uiTextColorIdx_; }
|
||||
int uiAccentColorIdx() const { return uiAccentColorIdx_; }
|
||||
float uiDistance() const;
|
||||
int uiFollowMode() const { return uiFollowMode_; }
|
||||
void setUiFollowMode(int m) { uiFollowMode_ = m; saveConfig(); refresh(); }
|
||||
int browserViewMode() const { return browserViewMode_; }
|
||||
void setBrowserViewMode(int m) { browserViewMode_ = m; saveConfig(); refresh(); }
|
||||
int bindQuickPath() const { return bindQuickPathBtn_; }
|
||||
const std::string& quickPath() const { return quickPath_; }
|
||||
void setQuickPath(const std::string& p) { quickPath_ = p; saveConfig(); }
|
||||
|
||||
int section() const { return section_; }
|
||||
void setSection(int s) {
|
||||
@@ -115,6 +131,14 @@ public:
|
||||
void loadConfig();
|
||||
void saveConfig() const;
|
||||
|
||||
bool listShowFormat() const { return listShowFormat_; }
|
||||
bool listShowRes() const { return listShowRes_; }
|
||||
bool listShowSize() const { return listShowSize_; }
|
||||
bool listShowDuration() const { return listShowDuration_; }
|
||||
|
||||
void onKeyboardSubmit(const std::string& text) { (void)text; }
|
||||
void onKeyboardCancel() {}
|
||||
|
||||
private:
|
||||
bool open_ = false;
|
||||
std::string title_ = "Settings & Controller Remap";
|
||||
@@ -133,15 +157,23 @@ private:
|
||||
int bindPlayBtn_ = 0; // default A
|
||||
int bindMenuBtn_ = 1; // default B
|
||||
int bindRecenterBtn_ = 6; // default Bumper (grip toggles the laser)
|
||||
int listeningFunc_ = -1; // 0=play 1=menu 2=recenter while binding, else -1
|
||||
int bindQuickPathBtn_ = 7; // default Right Stick Click (BtnThumbR)
|
||||
int listeningFunc_ = -1; // 0=play 1=menu 2=recenter 3=move 4=quick path
|
||||
int downloadDirIdx_ = 0; // index into the download-destination presets
|
||||
int curvatureIdx_ = 0; // 0=Flat (0%), 1=Gentle (25%), 2=Cinema (50%), 3=Deep (75%), 4=IMAX (100%)
|
||||
int uiTextScaleIdx_ = 2; // 0=90%, 1=105%, 2=120% (default), 3=140%, 4=160%
|
||||
int uiTextColorIdx_ = 0; // 0=Pure White, 1=Ice Cyan, 2=Warm Cream, 3=Soft Gold, 4=Mint Green
|
||||
int uiAccentColorIdx_ = 0; // 0=Cyber Cyan, 1=Emerald, 2=Amber Gold, 3=Royal Purple, 4=Crimson
|
||||
int uiDistanceIdx_ = 1; // 0=Near (1.5m), 1=Comfort (1.9m), 2=Mid (2.3m), 3=Far (2.8m)
|
||||
int uiFollowMode_ = 0; // 0=World Lock (default, zero drift/tilt), 1=Smooth Gaze Follow
|
||||
int browserViewMode_ = 0; // 0=Large Cards (3x2), 1=Smaller Cards (4x3), 2=Compact List (1-Col)
|
||||
bool listShowFormat_ = true;
|
||||
bool listShowRes_ = true;
|
||||
bool listShowSize_ = true;
|
||||
bool listShowDuration_ = true;
|
||||
std::string quickPath_;
|
||||
|
||||
int section_ = 0; // 0=Display, 1=Controls, 2=Streams, 3=System; 10..12=sub-lists
|
||||
int section_ = 0; // 0=Display, 1=UI, 2=Audio, 3=Controls, 4=Vault; 10..12=sub-lists
|
||||
std::vector<std::pair<int, std::string>> videoStreams_; // (index, label)
|
||||
std::vector<std::pair<int, std::string>> audioStreams_;
|
||||
int videoSel_ = 0; // position within videoStreams_
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <sstream>
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
|
||||
namespace pixelvr {
|
||||
|
||||
// Validates whether a string is a complete, strictly valid IPv4 address (e.g. "192.168.1.1").
|
||||
inline bool isValidIpv4(const std::string& str) {
|
||||
if (str.empty()) return false;
|
||||
std::stringstream ss(str);
|
||||
std::string seg;
|
||||
int count = 0;
|
||||
while (std::getline(ss, seg, '.')) {
|
||||
++count;
|
||||
if (seg.empty() || seg.length() > 3) return false;
|
||||
for (char c : seg) {
|
||||
if (!std::isdigit(static_cast<unsigned char>(c))) return false;
|
||||
}
|
||||
// Leading zeros not allowed if more than 1 digit (e.g. "01" is invalid, "0" is valid)
|
||||
if (seg.length() > 1 && seg[0] == '0') return false;
|
||||
try {
|
||||
int val = std::stoi(seg);
|
||||
if (val < 0 || val > 255) return false;
|
||||
} catch (...) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Must have exactly 4 octets and not end with a trailing dot
|
||||
return (count == 4 && str.back() != '.');
|
||||
}
|
||||
|
||||
// Sanitizes and formats an in-progress or finalized IPv4 input:
|
||||
// - Only digits '0'..'9' and '.' are accepted; any other characters are discarded.
|
||||
// - At most 4 sections (octets).
|
||||
// - Each section has leading zeros dropped (e.g. "001" -> "1", "050" -> "50", "00" -> "0").
|
||||
// - Each section is clamped to max 255.
|
||||
// - If !finalized, a trailing dot is preserved while typing (e.g. "192.168.").
|
||||
// - If finalized, any trailing dot is trimmed and empty trailing sections are removed.
|
||||
inline std::string sanitizeIpv4(const std::string& input, bool finalized = false) {
|
||||
if (input.empty()) return "";
|
||||
|
||||
std::vector<std::string> sections;
|
||||
std::string current;
|
||||
bool trailingDot = false;
|
||||
|
||||
for (char c : input) {
|
||||
if (c == '.') {
|
||||
if (!current.empty() && sections.size() < 4) {
|
||||
sections.push_back(current);
|
||||
current.clear();
|
||||
trailingDot = true;
|
||||
}
|
||||
} else if (std::isdigit(static_cast<unsigned char>(c))) {
|
||||
if (sections.size() >= 4) {
|
||||
continue;
|
||||
}
|
||||
if (current.length() < 3) {
|
||||
current += c;
|
||||
trailingDot = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!current.empty() && sections.size() < 4) {
|
||||
sections.push_back(current);
|
||||
trailingDot = false;
|
||||
}
|
||||
|
||||
if (sections.empty() && !trailingDot) return "";
|
||||
|
||||
std::string result;
|
||||
for (std::size_t i = 0; i < sections.size(); ++i) {
|
||||
std::string s = sections[i];
|
||||
if (!s.empty()) {
|
||||
while (s.length() > 1 && s[0] == '0') {
|
||||
s.erase(s.begin());
|
||||
}
|
||||
try {
|
||||
int val = std::stoi(s);
|
||||
if (val > 255) val = 255;
|
||||
s = std::to_string(val);
|
||||
} catch (...) {
|
||||
s = "255";
|
||||
}
|
||||
}
|
||||
|
||||
if (i > 0) result += '.';
|
||||
result += s;
|
||||
}
|
||||
|
||||
if (!finalized && trailingDot && sections.size() < 4 && !result.empty()) {
|
||||
result += '.';
|
||||
}
|
||||
|
||||
if (finalized) {
|
||||
while (!result.empty() && result.back() == '.') {
|
||||
result.pop_back();
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace pixelvr
|
||||
@@ -0,0 +1,384 @@
|
||||
#include "vr_keyboard.hpp"
|
||||
#include "util/logging.hpp"
|
||||
#include <cstring>
|
||||
|
||||
#if defined(__linux__) || defined(__APPLE__)
|
||||
#include <dlfcn.h>
|
||||
#else
|
||||
#include <windows.h>
|
||||
#endif
|
||||
|
||||
#include "openvr.h"
|
||||
|
||||
namespace pixelvr {
|
||||
|
||||
namespace {
|
||||
|
||||
typedef intptr_t (*Fn_VR_InitInternal)(vr::EVRInitError*, vr::EVRApplicationType);
|
||||
typedef void (*Fn_VR_ShutdownInternal)();
|
||||
typedef intptr_t (*Fn_VR_GetGenericInterface)(const char*, vr::EVRInitError*);
|
||||
|
||||
Fn_VR_InitInternal g_VR_InitInternal = nullptr;
|
||||
Fn_VR_ShutdownInternal g_VR_ShutdownInternal = nullptr;
|
||||
Fn_VR_GetGenericInterface g_VR_GetGenericInterface = nullptr;
|
||||
|
||||
} // namespace
|
||||
|
||||
VrKeyboard& VrKeyboard::instance() {
|
||||
static VrKeyboard sInstance;
|
||||
return sInstance;
|
||||
}
|
||||
|
||||
VrKeyboard::~VrKeyboard() {
|
||||
shutdown();
|
||||
}
|
||||
|
||||
bool VrKeyboard::init() {
|
||||
if (available_) return true;
|
||||
|
||||
#if defined(__linux__)
|
||||
const char* paths[] = {
|
||||
"/opt/steamvr/bin/linuxarm64/libopenvr_api.so",
|
||||
"/opt/steamvr/bin/linux64/libopenvr_api.so",
|
||||
"libopenvr_api.so"
|
||||
};
|
||||
for (const char* path : paths) {
|
||||
libHandle_ = dlopen(path, RTLD_NOW | RTLD_LOCAL);
|
||||
if (libHandle_) {
|
||||
PIXELVR_LOG_INFO("VrKeyboard: Loaded %s", path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!libHandle_) {
|
||||
PIXELVR_LOG_WARN("VrKeyboard: Could not find or load libopenvr_api.so: %s", dlerror());
|
||||
return false;
|
||||
}
|
||||
|
||||
g_VR_InitInternal = reinterpret_cast<Fn_VR_InitInternal>(dlsym(libHandle_, "VR_InitInternal"));
|
||||
g_VR_ShutdownInternal = reinterpret_cast<Fn_VR_ShutdownInternal>(dlsym(libHandle_, "VR_ShutdownInternal"));
|
||||
g_VR_GetGenericInterface = reinterpret_cast<Fn_VR_GetGenericInterface>(dlsym(libHandle_, "VR_GetGenericInterface"));
|
||||
#elif defined(_WIN32)
|
||||
HMODULE hMod = LoadLibraryA("openvr_api.dll");
|
||||
if (!hMod) {
|
||||
return false;
|
||||
}
|
||||
libHandle_ = hMod;
|
||||
g_VR_InitInternal = reinterpret_cast<Fn_VR_InitInternal>(GetProcAddress(hMod, "VR_InitInternal"));
|
||||
g_VR_ShutdownInternal = reinterpret_cast<Fn_VR_ShutdownInternal>(GetProcAddress(hMod, "VR_ShutdownInternal"));
|
||||
g_VR_GetGenericInterface = reinterpret_cast<Fn_VR_GetGenericInterface>(GetProcAddress(hMod, "VR_GetGenericInterface"));
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
|
||||
if (!g_VR_InitInternal || !g_VR_ShutdownInternal || !g_VR_GetGenericInterface) {
|
||||
PIXELVR_LOG_WARN("VrKeyboard: OpenVR symbols not resolved");
|
||||
shutdown();
|
||||
return false;
|
||||
}
|
||||
|
||||
vr::EVRInitError err = vr::VRInitError_None;
|
||||
uint32_t token = static_cast<uint32_t>(g_VR_InitInternal(&err, vr::VRApplication_Overlay));
|
||||
if (token == 0 || err != vr::VRInitError_None) {
|
||||
PIXELVR_LOG_WARN("VrKeyboard: OpenVR overlay init returned %d", static_cast<int>(err));
|
||||
shutdown();
|
||||
return false;
|
||||
}
|
||||
|
||||
vr::IVRSystem* sys = reinterpret_cast<vr::IVRSystem*>(
|
||||
g_VR_GetGenericInterface(vr::IVRSystem_Version, &err));
|
||||
if (!sys || err != vr::VRInitError_None) {
|
||||
PIXELVR_LOG_WARN("VrKeyboard: Failed to get IVRSystem interface %s (err=%d)",
|
||||
vr::IVRSystem_Version, static_cast<int>(err));
|
||||
shutdown();
|
||||
return false;
|
||||
}
|
||||
|
||||
vr::IVROverlay* ov = reinterpret_cast<vr::IVROverlay*>(
|
||||
g_VR_GetGenericInterface(vr::IVROverlay_Version, &err));
|
||||
if (!ov || err != vr::VRInitError_None) {
|
||||
PIXELVR_LOG_WARN("VrKeyboard: Failed to get IVROverlay interface %s (err=%d)",
|
||||
vr::IVROverlay_Version, static_cast<int>(err));
|
||||
shutdown();
|
||||
return false;
|
||||
}
|
||||
|
||||
vrSystem_ = sys;
|
||||
vrOverlay_ = ov;
|
||||
available_ = true;
|
||||
|
||||
// Create a persistent input overlay so ShowKeyboardForOverlay can link
|
||||
// tracked controller pointer interactions directly to SteamVR's virtual keyboard.
|
||||
vr::VROverlayHandle_t hOverlay = vr::k_ulOverlayHandleInvalid;
|
||||
vr::EVROverlayError ovErr = ov->CreateOverlay("pixelvr.input_overlay", "PixelVR Input", &hOverlay);
|
||||
if (ovErr == vr::VROverlayError_None && hOverlay != vr::k_ulOverlayHandleInvalid) {
|
||||
inputOverlay_ = hOverlay;
|
||||
ov->SetOverlayInputMethod(hOverlay, vr::VROverlayInputMethod_Mouse);
|
||||
ov->SetOverlayFlag(hOverlay, vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible, true);
|
||||
PIXELVR_LOG_INFO("VrKeyboard: Created input overlay %llu with Mouse input method and MakeOverlaysInteractiveIfVisible",
|
||||
static_cast<unsigned long long>(inputOverlay_));
|
||||
} else {
|
||||
PIXELVR_LOG_WARN("VrKeyboard: Could not create input overlay (err=%d)", static_cast<int>(ovErr));
|
||||
}
|
||||
|
||||
PIXELVR_LOG_INFO("VrKeyboard: Successfully initialized SteamVR overlay interface (sys=%p, ov=%p)",
|
||||
reinterpret_cast<void*>(sys), reinterpret_cast<void*>(ov));
|
||||
return true;
|
||||
}
|
||||
|
||||
void VrKeyboard::shutdown() {
|
||||
if (isOpen_) {
|
||||
hide();
|
||||
}
|
||||
if (inputOverlay_ != 0 && vrOverlay_) {
|
||||
auto* ov = reinterpret_cast<vr::IVROverlay*>(vrOverlay_);
|
||||
ov->DestroyOverlay(inputOverlay_);
|
||||
inputOverlay_ = 0;
|
||||
}
|
||||
if (g_VR_ShutdownInternal) {
|
||||
g_VR_ShutdownInternal();
|
||||
}
|
||||
vrSystem_ = nullptr;
|
||||
vrOverlay_ = nullptr;
|
||||
if (libHandle_) {
|
||||
#if defined(__linux__) || defined(__APPLE__)
|
||||
dlclose(libHandle_);
|
||||
#elif defined(_WIN32)
|
||||
FreeLibrary(reinterpret_cast<HMODULE>(libHandle_));
|
||||
#endif
|
||||
libHandle_ = nullptr;
|
||||
}
|
||||
g_VR_InitInternal = nullptr;
|
||||
g_VR_ShutdownInternal = nullptr;
|
||||
g_VR_GetGenericInterface = nullptr;
|
||||
available_ = false;
|
||||
isOpen_ = false;
|
||||
doneSubmitted_ = false;
|
||||
}
|
||||
|
||||
bool VrKeyboard::show(const std::string& description,
|
||||
const std::string& existingText,
|
||||
bool isPassword,
|
||||
int maxChars) {
|
||||
if (!available_ && !init()) {
|
||||
return false;
|
||||
}
|
||||
auto* ov = reinterpret_cast<vr::IVROverlay*>(vrOverlay_);
|
||||
if (!ov) return false;
|
||||
|
||||
description_ = description;
|
||||
existingText_ = existingText;
|
||||
currentText_ = existingText;
|
||||
isPassword_ = isPassword;
|
||||
doneSubmitted_ = false;
|
||||
|
||||
const vr::EGamepadTextInputMode mode = isPassword
|
||||
? vr::k_EGamepadTextInputModePassword
|
||||
: vr::k_EGamepadTextInputModeNormal;
|
||||
|
||||
vr::EVROverlayError err = vr::VROverlayError_None;
|
||||
if (inputOverlay_ != 0) {
|
||||
ov->SetOverlayInputMethod(inputOverlay_, vr::VROverlayInputMethod_Mouse);
|
||||
ov->SetOverlayFlag(inputOverlay_, vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible, true);
|
||||
ov->SetOverlayWidthInMeters(inputOverlay_, 1.0f);
|
||||
|
||||
// Position overlay 1.1m in front of HMD, tilted slightly down/up to match comfortable view
|
||||
vr::HmdMatrix34_t mat = {
|
||||
1.0f, 0.0f, 0.0f, 0.0f,
|
||||
0.0f, 1.0f, 0.0f, -0.2f,
|
||||
0.0f, 0.0f, 1.0f, -1.1f
|
||||
};
|
||||
ov->SetOverlayTransformTrackedDeviceRelative(inputOverlay_, vr::k_unTrackedDeviceIndex_Hmd, &mat);
|
||||
|
||||
// Transparent 1x1 pixel texture so overlay is valid and active without rendering visual artifacts
|
||||
static const uint32_t s_transparentPixel = 0x00000000;
|
||||
ov->SetOverlayRaw(inputOverlay_, const_cast<uint32_t*>(&s_transparentPixel), 1, 1, 4);
|
||||
|
||||
// Tell keyboard to avoid overlay upper area and appear cleanly docked
|
||||
vr::HmdRect2_t avoidRect = {
|
||||
{ 0.0f, 0.0f },
|
||||
{ 1.0f, 0.2f }
|
||||
};
|
||||
ov->SetKeyboardPositionForOverlay(inputOverlay_, avoidRect);
|
||||
|
||||
// Show overlay BEFORE opening keyboard so vrcompositor recognizes active interactive overlay
|
||||
ov->ShowOverlay(inputOverlay_);
|
||||
|
||||
err = ov->ShowKeyboardForOverlay(
|
||||
inputOverlay_,
|
||||
mode,
|
||||
vr::k_EGamepadTextInputLineModeSingleLine,
|
||||
vr::KeyboardFlag_Modal,
|
||||
description.c_str(),
|
||||
static_cast<uint32_t>(maxChars),
|
||||
existingText.c_str(),
|
||||
0);
|
||||
} else {
|
||||
err = ov->ShowKeyboard(
|
||||
mode,
|
||||
vr::k_EGamepadTextInputLineModeSingleLine,
|
||||
vr::KeyboardFlag_Modal,
|
||||
description.c_str(),
|
||||
static_cast<uint32_t>(maxChars),
|
||||
existingText.c_str(),
|
||||
0);
|
||||
}
|
||||
|
||||
if (err != vr::VROverlayError_None) {
|
||||
PIXELVR_LOG_WARN("VrKeyboard: ShowKeyboard failed with error %d", static_cast<int>(err));
|
||||
if (inputOverlay_ != 0) {
|
||||
ov->HideOverlay(inputOverlay_);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
kbConfigured_ = false;
|
||||
// Attempt early configuration of keyboard overlays if already created
|
||||
const char* kbOverlayNames[] = {
|
||||
"valve.steam.gamepadui.keyboard",
|
||||
"system.keyboard"
|
||||
};
|
||||
for (const char* name : kbOverlayNames) {
|
||||
vr::VROverlayHandle_t hKb = vr::k_ulOverlayHandleInvalid;
|
||||
if (ov->FindOverlay(name, &hKb) == vr::VROverlayError_None && hKb != vr::k_ulOverlayHandleInvalid) {
|
||||
ov->SetOverlayFlag(hKb, vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible, true);
|
||||
ov->SetOverlayInputMethod(hKb, vr::VROverlayInputMethod_Mouse);
|
||||
kbConfigured_ = true;
|
||||
PIXELVR_LOG_INFO("VrKeyboard: Configured interactive laser mouse for overlay '%s'", name);
|
||||
}
|
||||
}
|
||||
|
||||
isOpen_ = true;
|
||||
PIXELVR_LOG_INFO("VrKeyboard: Shown virtual keyboard for '%s' (initial text: '%s')",
|
||||
description.c_str(), existingText.c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
void VrKeyboard::hide() {
|
||||
if (!isOpen_) return;
|
||||
auto* ov = reinterpret_cast<vr::IVROverlay*>(vrOverlay_);
|
||||
if (ov) {
|
||||
ov->HideKeyboard();
|
||||
if (inputOverlay_ != 0) {
|
||||
ov->HideOverlay(inputOverlay_);
|
||||
}
|
||||
}
|
||||
isOpen_ = false;
|
||||
kbConfigured_ = false;
|
||||
doneSubmitted_ = false;
|
||||
PIXELVR_LOG_INFO("VrKeyboard: Hidden virtual keyboard");
|
||||
}
|
||||
|
||||
void VrKeyboard::poll(std::function<void(const std::string&)> onDone,
|
||||
std::function<void()> onCancel) {
|
||||
if (!available_ || !isOpen_) return;
|
||||
auto* sys = reinterpret_cast<vr::IVRSystem*>(vrSystem_);
|
||||
auto* ov = reinterpret_cast<vr::IVROverlay*>(vrOverlay_);
|
||||
if (!sys || !ov) return;
|
||||
|
||||
vr::VREvent_t event{};
|
||||
while (sys->PollNextEvent(&event, sizeof(event))) {
|
||||
if (event.eventType == vr::VREvent_KeyboardCharInput) {
|
||||
char keyBuf[1024] = {0};
|
||||
uint32_t len = ov->GetKeyboardText(keyBuf, sizeof(keyBuf));
|
||||
if (len > 0) {
|
||||
currentText_ = std::string(keyBuf, len < sizeof(keyBuf) ? len : std::strlen(keyBuf));
|
||||
} else {
|
||||
// Fallback: check event.data.keyboard.cNewInput
|
||||
const char* inStr = event.data.keyboard.cNewInput;
|
||||
if (inStr && inStr[0] != 0) {
|
||||
const unsigned char firstByte = static_cast<unsigned char>(inStr[0]);
|
||||
if (firstByte == '\b' || firstByte == 0x7F || firstByte == 8) {
|
||||
while (!currentText_.empty() && (static_cast<unsigned char>(currentText_.back()) & 0xC0) == 0x80) {
|
||||
currentText_.pop_back();
|
||||
}
|
||||
if (!currentText_.empty()) {
|
||||
currentText_.pop_back();
|
||||
}
|
||||
} else if (firstByte >= 32) {
|
||||
currentText_ += inStr;
|
||||
}
|
||||
}
|
||||
}
|
||||
PIXELVR_LOG_INFO("VrKeyboard: CharInput live text: '%s'", currentText_.c_str());
|
||||
} else if (event.eventType == vr::VREvent_KeyboardDone) {
|
||||
isOpen_ = false;
|
||||
kbConfigured_ = false;
|
||||
if (inputOverlay_ != 0 && ov) {
|
||||
ov->HideOverlay(inputOverlay_);
|
||||
}
|
||||
doneSubmitted_ = true;
|
||||
char buf[1024] = {0};
|
||||
uint32_t len = ov->GetKeyboardText(buf, sizeof(buf));
|
||||
std::string text = currentText_;
|
||||
if (len > 0) {
|
||||
text = std::string(buf, len < sizeof(buf) ? len : std::strlen(buf));
|
||||
}
|
||||
PIXELVR_LOG_INFO("VrKeyboard: User submitted text via KeyboardDone: '%s'", text.c_str());
|
||||
if (onDone) {
|
||||
onDone(text);
|
||||
}
|
||||
} else if (event.eventType == vr::VREvent_KeyboardClosed ||
|
||||
event.eventType == vr::VREvent_KeyboardClosed_Global) {
|
||||
isOpen_ = false;
|
||||
kbConfigured_ = false;
|
||||
if (inputOverlay_ != 0 && ov) {
|
||||
ov->HideOverlay(inputOverlay_);
|
||||
}
|
||||
PIXELVR_LOG_INFO("VrKeyboard: Keyboard closed (doneSubmitted=%d)", doneSubmitted_ ? 1 : 0);
|
||||
if (!doneSubmitted_) {
|
||||
// On Steam Frame / SteamOS, pressing the checkmark or closing the keyboard issues KeyboardClosed
|
||||
// without KeyboardDone. Read the buffer and treat non-empty / modified input as submit!
|
||||
char buf[1024] = {0};
|
||||
uint32_t len = ov->GetKeyboardText(buf, sizeof(buf));
|
||||
std::string text = currentText_;
|
||||
if (len > 0) {
|
||||
text = std::string(buf, len < sizeof(buf) ? len : std::strlen(buf));
|
||||
}
|
||||
if (!text.empty() || text != existingText_) {
|
||||
PIXELVR_LOG_INFO("VrKeyboard: Treating KeyboardClosed as submit with text: '%s'", text.c_str());
|
||||
doneSubmitted_ = true;
|
||||
if (onDone) {
|
||||
onDone(text);
|
||||
}
|
||||
} else {
|
||||
PIXELVR_LOG_INFO("VrKeyboard: Keyboard cancelled without text change");
|
||||
if (onCancel) {
|
||||
onCancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keep currentText_ synchronized with the live SteamVR keyboard buffer every frame
|
||||
if (isOpen_) {
|
||||
// Dynamically ensure SteamVR keyboard overlay has laser mouse flags as soon as spawned
|
||||
if (!kbConfigured_) {
|
||||
const char* kbOverlayNames[] = {
|
||||
"valve.steam.gamepadui.keyboard",
|
||||
"system.keyboard"
|
||||
};
|
||||
for (const char* name : kbOverlayNames) {
|
||||
vr::VROverlayHandle_t hKb = vr::k_ulOverlayHandleInvalid;
|
||||
if (ov->FindOverlay(name, &hKb) == vr::VROverlayError_None && hKb != vr::k_ulOverlayHandleInvalid) {
|
||||
ov->SetOverlayFlag(hKb, vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible, true);
|
||||
ov->SetOverlayInputMethod(hKb, vr::VROverlayInputMethod_Mouse);
|
||||
kbConfigured_ = true;
|
||||
PIXELVR_LOG_INFO("VrKeyboard: Configured interactive laser mouse for overlay '%s'", name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
char liveBuf[1024] = {0};
|
||||
uint32_t liveLen = ov->GetKeyboardText(liveBuf, sizeof(liveBuf));
|
||||
if (liveLen > 0) {
|
||||
currentText_ = std::string(liveBuf, liveLen < sizeof(liveBuf) ? liveLen : std::strlen(liveBuf));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::string VrKeyboard::currentText() const {
|
||||
return currentText_;
|
||||
}
|
||||
|
||||
} // namespace pixelvr
|
||||
@@ -0,0 +1,57 @@
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <cstdint>
|
||||
|
||||
namespace pixelvr {
|
||||
|
||||
class VrKeyboard {
|
||||
public:
|
||||
static VrKeyboard& instance();
|
||||
|
||||
// Initializes OpenVR overlay interface dynamically if SteamVR is running.
|
||||
bool init();
|
||||
void shutdown();
|
||||
|
||||
bool isAvailable() const { return available_; }
|
||||
bool isOpen() const { return isOpen_; }
|
||||
|
||||
// Opens the native SteamVR / SteamOS VR on-screen virtual keyboard.
|
||||
// e.g. description: "Enter SMB Server IP", existingText: "192.168.1.100", isPassword: false
|
||||
bool show(const std::string& description,
|
||||
const std::string& existingText,
|
||||
bool isPassword = false,
|
||||
int maxChars = 256);
|
||||
|
||||
// Closes the virtual keyboard if open.
|
||||
void hide();
|
||||
|
||||
// Call once per frame in the main event loop to poll OpenVR keyboard events.
|
||||
void poll(std::function<void(const std::string&)> onDone,
|
||||
std::function<void()> onCancel);
|
||||
|
||||
// Retrieves current entered buffer
|
||||
std::string currentText() const;
|
||||
const std::string& description() const { return description_; }
|
||||
bool isPassword() const { return isPassword_; }
|
||||
|
||||
private:
|
||||
VrKeyboard() = default;
|
||||
~VrKeyboard();
|
||||
|
||||
bool available_ = false;
|
||||
bool isOpen_ = false;
|
||||
bool doneSubmitted_ = false;
|
||||
bool kbConfigured_ = false;
|
||||
std::string description_;
|
||||
std::string existingText_;
|
||||
std::string currentText_;
|
||||
bool isPassword_ = false;
|
||||
void* libHandle_ = nullptr;
|
||||
void* vrSystem_ = nullptr;
|
||||
void* vrOverlay_ = nullptr;
|
||||
uint64_t inputOverlay_ = 0;
|
||||
};
|
||||
|
||||
} // namespace pixelvr
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "xr/xr_context.hpp"
|
||||
|
||||
#include "xr/xr_result.hpp"
|
||||
#include "xr/vr_keyboard.hpp"
|
||||
#include "util/logging.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
@@ -637,6 +638,22 @@ void XrContext::syncActions() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sessionFocused()) {
|
||||
// Yield controller input and clear all held inputs when the session does not have focus (e.g. Steam dashboard).
|
||||
input_.selectHeld = false;
|
||||
input_.thumbstickX = 0.0f;
|
||||
input_.thumbstickY = 0.0f;
|
||||
input_.thumbstickLX = 0.0f;
|
||||
input_.thumbstickLY = 0.0f;
|
||||
input_.thumbstickRX = 0.0f;
|
||||
input_.thumbstickRY = 0.0f;
|
||||
for (int h = 0; h < 2; ++h) {
|
||||
input_.gripValue[h] = 0.0f;
|
||||
input_.gripFullPressHeld[h] = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
XrActiveActionSet active{actionSet_, XR_NULL_PATH};
|
||||
XrActionsSyncInfo sync{XR_TYPE_ACTIONS_SYNC_INFO};
|
||||
sync.countActiveActionSets = 1;
|
||||
@@ -908,7 +925,8 @@ bool XrContext::renderFrame(const RenderViewFn& renderView) {
|
||||
// Locate each controller's aim pose (for the pointer rays and models).
|
||||
for (int h = 0; h < 2; ++h) {
|
||||
input_.controllers[h].active = false;
|
||||
if (aimSpaces_[h] == XR_NULL_HANDLE) {
|
||||
const bool canTrack = sessionFocused() || VrKeyboard::instance().isOpen();
|
||||
if (!canTrack || aimSpaces_[h] == XR_NULL_HANDLE) {
|
||||
continue;
|
||||
}
|
||||
XrSpaceLocation loc{XR_TYPE_SPACE_LOCATION};
|
||||
@@ -936,7 +954,8 @@ bool XrContext::renderFrame(const RenderViewFn& renderView) {
|
||||
// the model is authored around the grip, not the forward-pointing aim).
|
||||
for (int h = 0; h < 2; ++h) {
|
||||
input_.controllers[h].gripActive = false;
|
||||
if (gripSpaces_[h] == XR_NULL_HANDLE) {
|
||||
const bool canTrack = sessionFocused() || VrKeyboard::instance().isOpen();
|
||||
if (!canTrack || gripSpaces_[h] == XR_NULL_HANDLE) {
|
||||
continue;
|
||||
}
|
||||
XrSpaceLocation loc{XR_TYPE_SPACE_LOCATION};
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
#include "util/ipv4.hpp"
|
||||
#include <cassert>
|
||||
#include <iostream>
|
||||
|
||||
using namespace pixelvr;
|
||||
|
||||
int main() {
|
||||
std::cout << "[TEST] Starting IPv4 sanitization & validation test..." << std::endl;
|
||||
|
||||
// Normal valid IP
|
||||
assert(sanitizeIpv4("192.168.1.1", true) == "192.168.1.1");
|
||||
assert(isValidIpv4("192.168.1.1"));
|
||||
|
||||
// Leading zeros dropped in each section
|
||||
assert(sanitizeIpv4("192.168.01.005", true) == "192.168.1.5");
|
||||
assert(sanitizeIpv4("010.000.001.020", true) == "10.0.1.20");
|
||||
assert(sanitizeIpv4("0.0.0.0", true) == "0.0.0.0");
|
||||
|
||||
// Clamping > 255
|
||||
assert(sanitizeIpv4("192.168.1.300", true) == "192.168.1.255");
|
||||
assert(sanitizeIpv4("999.999.999.999", true) == "255.255.255.255");
|
||||
|
||||
// Live typing with trailing dot
|
||||
assert(sanitizeIpv4("192.168.", false) == "192.168.");
|
||||
assert(sanitizeIpv4("192.168.", true) == "192.168");
|
||||
|
||||
// Non-digit characters discarded
|
||||
assert(sanitizeIpv4("192.abc.168.foo.1.bar.100", true) == "192.168.1.100");
|
||||
assert(sanitizeIpv4(" 10 . 0 . 0 . 1 ", true) == "10.0.0.1");
|
||||
|
||||
// Max 4 sections (extra sections ignored)
|
||||
assert(sanitizeIpv4("192.168.1.1.99.88", true) == "192.168.1.1");
|
||||
|
||||
std::cout << "[ALL TESTS PASSED] IPv4 sanitization is verified!" << std::endl;
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
#include "net/smb_credentials.hpp"
|
||||
#include <cassert>
|
||||
#include <iostream>
|
||||
#include <fstream>
|
||||
#include <filesystem>
|
||||
#include <sys/stat.h>
|
||||
|
||||
using namespace pixelvr;
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
int main() {
|
||||
std::cout << "[TEST] Starting SmbCredentialsManager verification..." << std::endl;
|
||||
auto& mgr = SmbCredentialsManager::instance();
|
||||
|
||||
// 1. Add a protected share
|
||||
SmbShare share1;
|
||||
share1.id = "192.168.1.100/movies";
|
||||
share1.name = "Living Room NAS";
|
||||
share1.host = "192.168.1.100";
|
||||
share1.share = "movies";
|
||||
share1.workgroup = "MYGROUP";
|
||||
share1.username = "nasadmin";
|
||||
share1.password = "SuperSecretPassword123!";
|
||||
share1.anonymous = false;
|
||||
|
||||
mgr.addOrUpdateShare(share1);
|
||||
std::cout << "[PASS] Added protected share." << std::endl;
|
||||
|
||||
// 2. Query credentials
|
||||
std::string user, pass, wg;
|
||||
bool found = mgr.findCredentials("192.168.1.100", "movies", user, pass, wg);
|
||||
assert(found);
|
||||
assert(user == "nasadmin");
|
||||
assert(pass == "SuperSecretPassword123!");
|
||||
assert(wg == "MYGROUP");
|
||||
std::cout << "[PASS] Retrieved exact credentials." << std::endl;
|
||||
|
||||
// 3. Check storage file permissions (must be 0600)
|
||||
const char* home = std::getenv("HOME");
|
||||
std::string configPath = std::string(home ? home : ".") + "/.config/pixelvr/smb_shares.json";
|
||||
struct stat st;
|
||||
assert(stat(configPath.c_str(), &st) == 0);
|
||||
mode_t perm = st.st_mode & 0777;
|
||||
std::cout << "[INFO] File permissions: 0" << std::oct << perm << std::dec << std::endl;
|
||||
assert((perm & 0077) == 0); // No permissions for group or others
|
||||
std::cout << "[PASS] Config file has strict 0600 permissions." << std::endl;
|
||||
|
||||
// 4. Verify password is encrypted on disk
|
||||
std::ifstream cfgFile(configPath);
|
||||
std::string content((std::istreambuf_iterator<char>(cfgFile)), std::istreambuf_iterator<char>());
|
||||
assert(content.find("SuperSecretPassword123!") == std::string::npos);
|
||||
std::cout << "[PASS] Password is encrypted on disk (no plaintext leak)." << std::endl;
|
||||
|
||||
// 5. Test auth file generation
|
||||
std::string authPath = mgr.getAuthFilePath("192.168.1.100", "movies");
|
||||
assert(!authPath.empty());
|
||||
assert(stat(authPath.c_str(), &st) == 0);
|
||||
mode_t authPerm = st.st_mode & 0777;
|
||||
assert((authPerm & 0077) == 0); // Auth file must also be 0600
|
||||
std::cout << "[PASS] Temporary Samba auth file generated with 0600 permissions." << std::endl;
|
||||
|
||||
// 6. Delete share
|
||||
bool deleted = mgr.deleteShare("192.168.1.100", "movies");
|
||||
assert(deleted);
|
||||
found = mgr.findCredentials("192.168.1.100", "movies", user, pass, wg);
|
||||
assert(!found);
|
||||
std::cout << "[PASS] Deleted share successfully." << std::endl;
|
||||
|
||||
std::cout << "[ALL TESTS PASSED] SmbCredentialsManager is verified and secure!" << std::endl;
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
[Desktop Entry]
|
||||
Name=Install Pixel VR Player
|
||||
Comment=Install Pixel VR Player for Valve Steam Frame
|
||||
Exec=bash -c 'SCRIPT_DIR="$(dirname "%k")"; if [ -f "$SCRIPT_DIR/install.sh" ]; then cd "$SCRIPT_DIR" && bash install.sh; else curl -fsSL https://raw.githubusercontent.com/Pixelplanet/Pixel-VR-Player/main/tools/install.sh | bash; fi; if [ $? -eq 0 ]; then command -v kdialog >/dev/null && kdialog --title "Pixel VR Player" --msgbox "Installation complete!\n\nPixel VR Player has been added to your Steam Library.\nYou can now return to Gaming Mode."; else command -v kdialog >/dev/null && kdialog --title "Pixel VR Player" --error "Installation encountered an issue.\nCheck ~/pixelvr-install.log or terminal output."; fi; read -p "Press Enter to close..." dummy'
|
||||
Icon=steam
|
||||
Terminal=true
|
||||
Type=Application
|
||||
Categories=AudioVideo;Player;VR;
|
||||
@@ -0,0 +1,8 @@
|
||||
[Desktop Entry]
|
||||
Name=Update Pixel VR Player
|
||||
Comment=Update Pixel VR Player to the latest version on Valve Steam Frame
|
||||
Exec=bash -c 'SCRIPT_DIR="$(dirname "%k")"; if [ -f "$HOME/PixelVRPlayer/update.sh" ]; then bash "$HOME/PixelVRPlayer/update.sh"; elif [ -f "$SCRIPT_DIR/update.sh" ]; then cd "$SCRIPT_DIR" && bash update.sh; else curl -fsSL https://raw.githubusercontent.com/Pixelplanet/Pixel-VR-Player/main/tools/install.sh | bash -s -- --update; fi; if [ $? -eq 0 ]; then command -v kdialog >/dev/null && kdialog --title "Pixel VR Player" --msgbox "Update complete!\n\nPixel VR Player has been updated to the latest version.\nYou can now return to Gaming Mode."; else command -v kdialog >/dev/null && kdialog --title "Pixel VR Player" --error "Update encountered an issue.\nCheck terminal output or internet connection."; fi; read -p "Press Enter to close..." dummy'
|
||||
Icon=steam
|
||||
Terminal=true
|
||||
Type=Application
|
||||
Categories=AudioVideo;Player;VR;
|
||||
@@ -127,7 +127,7 @@ def main() -> int:
|
||||
'mkdir -p "$target/shaders"; printf %s "$target"'
|
||||
)
|
||||
|
||||
# 2. Copy binary, shaders and the direct-run helper.
|
||||
# 2. Copy binary, shaders, launcher, and icon.
|
||||
print("Copying binary ...")
|
||||
scp([str(binary)], f"{REMOTE_DIR}/pixelvr")
|
||||
if shaders:
|
||||
@@ -136,6 +136,10 @@ def main() -> int:
|
||||
launch_helper = PROJECT_ROOT / "tools" / "launch.sh"
|
||||
if launch_helper.is_file():
|
||||
scp([str(launch_helper)], f"{REMOTE_DIR}/launch.sh")
|
||||
for asset_name in ("icon.png", "hero.png", "capsule.png", "header.png", "logo.png"):
|
||||
asset_file = PROJECT_ROOT / "assets" / asset_name
|
||||
if asset_file.is_file():
|
||||
scp([str(asset_file)], f"{REMOTE_DIR}/{asset_name}")
|
||||
|
||||
# 3. Register the Non-Steam shortcut (runs on the host, not in Sniper).
|
||||
request = {
|
||||
@@ -165,6 +169,83 @@ def main() -> int:
|
||||
if isinstance(response, dict) and response.get("error"):
|
||||
raise RuntimeError(f"shortcut registration failed: {response['error']}")
|
||||
|
||||
# 3b. Sanitize shortcuts.vdf to remove the 'Devkit Game: ' prefix and set icon.
|
||||
sanitize_script = (
|
||||
"import glob, os, struct, shutil\n"
|
||||
"OBJ, STR, INT, U64, END = 0, 1, 2, 7, 8\n"
|
||||
"def read_cstr(buf, pos):\n"
|
||||
" end = buf.find(b'\\0', pos)\n"
|
||||
" return buf[pos:end].decode('utf-8', 'surrogateescape'), end + 1\n"
|
||||
"def parse_obj(buf, pos):\n"
|
||||
" entries = {}\n"
|
||||
" while pos < len(buf):\n"
|
||||
" kind = buf[pos]\n"
|
||||
" if kind in (END, 0x0B): return entries, pos + 1\n"
|
||||
" key, pos = read_cstr(buf, pos + 1)\n"
|
||||
" if kind == OBJ: value, pos = parse_obj(buf, pos)\n"
|
||||
" elif kind == STR: value, pos = read_cstr(buf, pos)\n"
|
||||
" elif kind == INT: value = struct.unpack_from('<i', buf, pos)[0]; pos += 4\n"
|
||||
" elif kind == U64: value = struct.unpack_from('<Q', buf, pos)[0]; pos += 8\n"
|
||||
" else: break\n"
|
||||
" entries[key] = [kind, value]\n"
|
||||
" return entries, pos\n"
|
||||
"def dump(entries):\n"
|
||||
" out = bytearray()\n"
|
||||
" for key, (kind, value) in entries.items():\n"
|
||||
" kb = key.encode('utf-8', 'surrogateescape')\n"
|
||||
" if kind == OBJ: out += bytes([OBJ]) + kb + b'\\0' + dump(value) + bytes([END])\n"
|
||||
" elif kind == STR: out += bytes([STR]) + kb + b'\\0' + value.encode('utf-8', 'surrogateescape') + b'\\0'\n"
|
||||
" elif kind == INT: out += bytes([INT]) + kb + b'\\0' + struct.pack('<i', value)\n"
|
||||
" elif kind == U64: out += bytes([U64]) + kb + b'\\0' + struct.pack('<Q', value)\n"
|
||||
" return bytes(out)\n"
|
||||
"for vdf in glob.glob(os.path.expanduser('~/.steam/steam/userdata/*/config/shortcuts.vdf')):\n"
|
||||
" try:\n"
|
||||
" with open(vdf, 'rb') as f: data = f.read()\n"
|
||||
" if not data or data[0] != OBJ: continue\n"
|
||||
" key, pos = read_cstr(data, 1)\n"
|
||||
" s_dict, _ = parse_obj(data, pos)\n"
|
||||
" mod = False\n"
|
||||
" for k, v in s_dict.items():\n"
|
||||
" if isinstance(v, list) and v[0] == OBJ:\n"
|
||||
" e = v[1]\n"
|
||||
" name = e.get('AppName', [STR, ''])[1]\n"
|
||||
" if 'Pixel' in name:\n"
|
||||
" e['AppName'] = [STR, 'Pixel VR Player']\n"
|
||||
" e['Devkit'] = [INT, 0]\n"
|
||||
" e['DevkitGameID'] = [STR, '']\n"
|
||||
" icon_p = os.path.expanduser('~/" + REMOTE_DIR + "/icon.png')\n"
|
||||
" hero_p = os.path.expanduser('~/" + REMOTE_DIR + "/hero.png')\n"
|
||||
" capsule_p = os.path.expanduser('~/" + REMOTE_DIR + "/capsule.png')\n"
|
||||
" header_p = os.path.expanduser('~/" + REMOTE_DIR + "/header.png')\n"
|
||||
" logo_p = os.path.expanduser('~/" + REMOTE_DIR + "/logo.png')\n"
|
||||
" grid = os.path.join(os.path.dirname(os.path.dirname(vdf)), 'config', 'grid')\n"
|
||||
" os.makedirs(grid, exist_ok=True)\n"
|
||||
" raw_id = e.get('appid', [INT, 0])[1] & 0xFFFFFFFF\n"
|
||||
" if os.path.isfile(icon_p):\n"
|
||||
" e['icon'] = [STR, icon_p]\n"
|
||||
" shutil.copyfile(icon_p, os.path.join(grid, f'{raw_id}_icon.png'))\n"
|
||||
" if os.path.isfile(capsule_p):\n"
|
||||
" shutil.copyfile(capsule_p, os.path.join(grid, f'{raw_id}p.png'))\n"
|
||||
" elif os.path.isfile(icon_p):\n"
|
||||
" shutil.copyfile(icon_p, os.path.join(grid, f'{raw_id}p.png'))\n"
|
||||
" if os.path.isfile(header_p):\n"
|
||||
" shutil.copyfile(header_p, os.path.join(grid, f'{raw_id}.png'))\n"
|
||||
" elif os.path.isfile(icon_p):\n"
|
||||
" shutil.copyfile(icon_p, os.path.join(grid, f'{raw_id}.png'))\n"
|
||||
" if os.path.isfile(hero_p):\n"
|
||||
" shutil.copyfile(hero_p, os.path.join(grid, f'{raw_id}_hero.png'))\n"
|
||||
" if os.path.isfile(logo_p):\n"
|
||||
" shutil.copyfile(logo_p, os.path.join(grid, f'{raw_id}_logo.png'))\n"
|
||||
" mod = True\n"
|
||||
" if mod:\n"
|
||||
" tmp = vdf + '.tmp'\n"
|
||||
" with open(tmp, 'wb') as f: f.write(dump({key: [OBJ, s_dict]}) + bytes([END]))\n"
|
||||
" os.replace(tmp, vdf)\n"
|
||||
" except Exception:\n"
|
||||
" pass\n"
|
||||
)
|
||||
ssh(f"python3 -c {shlex.quote(sanitize_script)}")
|
||||
|
||||
# 4. Optionally launch it in the headset.
|
||||
if args.launch:
|
||||
print("Launching in the headset ...")
|
||||
@@ -184,7 +265,7 @@ def main() -> int:
|
||||
print(f"Deployed to {host}:~/{REMOTE_DIR}")
|
||||
if player_args:
|
||||
print(" argv:", " ".join(["./pixelvr", *player_args]))
|
||||
print(f" Launch from Steam > Library > Non-Steam > 'Devkit Game: {GAME}'")
|
||||
print(" Launch from Steam > Library > Non-Steam > 'Pixel VR Player'")
|
||||
print(f" Or over SSH: ssh {host} '{REMOTE_DIR}/launch.sh <video>'")
|
||||
return 0
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
echo ===============================================================
|
||||
echo Pixel VR Player - Steam Frame Remote Installer
|
||||
echo ===============================================================
|
||||
echo This tool installs Pixel VR Player on your Valve Steam Frame
|
||||
echo over your local Wi-Fi network directly from your PC.
|
||||
echo ===============================================================
|
||||
echo.
|
||||
|
||||
set "FRAME_IP=steamframe.local"
|
||||
set /p "INPUT_IP=Enter Steam Frame IP address [press Enter for steamframe.local]: "
|
||||
if not "!INPUT_IP!"=="" set "FRAME_IP=!INPUT_IP!"
|
||||
|
||||
echo.
|
||||
echo Connecting to steamos@!FRAME_IP!...
|
||||
echo (If prompted for a password, enter your SteamOS desktop password)
|
||||
echo.
|
||||
|
||||
where ssh >nul 2>&1
|
||||
if %ERRORLEVEL% neq 0 (
|
||||
echo [ERROR] SSH is not installed or not in PATH on this PC.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
set "EXTRA_ARGS=%*"
|
||||
if "%EXTRA_ARGS%"=="" (
|
||||
set "REMOTE_CMD=curl -fsSL https://raw.githubusercontent.com/Pixelplanet/Pixel-VR-Player/main/tools/install.sh | sh"
|
||||
) else (
|
||||
set "REMOTE_CMD=curl -fsSL https://raw.githubusercontent.com/Pixelplanet/Pixel-VR-Player/main/tools/install.sh | sh -s -- %EXTRA_ARGS%"
|
||||
)
|
||||
|
||||
ssh -o StrictHostKeyChecking=no steamos@!FRAME_IP! "!REMOTE_CMD!"
|
||||
|
||||
if %ERRORLEVEL% equ 0 (
|
||||
echo.
|
||||
echo ===============================================================
|
||||
echo [SUCCESS] Operation completed on your Steam Frame!
|
||||
echo ===============================================================
|
||||
) else (
|
||||
echo.
|
||||
echo ===============================================================
|
||||
echo [NOTE] If connection failed, please ensure:
|
||||
echo 1. Both your PC and Steam Frame are on the same Wi-Fi.
|
||||
echo 2. SSH is enabled on the headset.
|
||||
echo (In Desktop Mode, open Konsole and run 'passwd',
|
||||
echo then 'sudo systemctl start sshd', or enable Dev Mode).
|
||||
echo ===============================================================
|
||||
)
|
||||
|
||||
echo.
|
||||
pause
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pixel VR Player - Remote installer from Mac / Linux to Steam Frame
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
echo "==============================================================="
|
||||
echo " Pixel VR Player - Steam Frame Remote Installer"
|
||||
echo "==============================================================="
|
||||
echo " This tool installs Pixel VR Player on your Valve Steam Frame"
|
||||
echo " over your local Wi-Fi network directly from your Mac or PC."
|
||||
echo "==============================================================="
|
||||
echo ""
|
||||
|
||||
read -rp "Enter Steam Frame IP address [press Enter for steamframe.local]: " INPUT_IP
|
||||
FRAME_IP="${INPUT_IP:-steamframe.local}"
|
||||
|
||||
echo ""
|
||||
echo "Connecting to steamos@${FRAME_IP}..."
|
||||
echo "(If prompted for a password, enter your SteamOS desktop password)"
|
||||
echo ""
|
||||
|
||||
EXTRA_ARGS="${*:-}"
|
||||
if [ -n "${EXTRA_ARGS}" ]; then
|
||||
REMOTE_CMD="curl -fsSL https://raw.githubusercontent.com/Pixelplanet/Pixel-VR-Player/main/tools/install.sh | sh -s -- ${EXTRA_ARGS}"
|
||||
else
|
||||
REMOTE_CMD="curl -fsSL https://raw.githubusercontent.com/Pixelplanet/Pixel-VR-Player/main/tools/install.sh | sh"
|
||||
fi
|
||||
|
||||
if ssh -o StrictHostKeyChecking=no "steamos@${FRAME_IP}" "${REMOTE_CMD}"; then
|
||||
echo ""
|
||||
echo "==============================================================="
|
||||
if [[ "${EXTRA_ARGS}" == *"--uninstall"* ]]; then
|
||||
echo " [SUCCESS] Pixel VR Player removed from your Steam Frame!"
|
||||
else
|
||||
echo " [SUCCESS] Pixel VR Player installed on your Steam Frame!"
|
||||
echo " It is now in your Steam Library under Non-Steam Games."
|
||||
fi
|
||||
echo "==============================================================="
|
||||
else
|
||||
echo ""
|
||||
echo "==============================================================="
|
||||
echo " [NOTE] If connection failed, please ensure:"
|
||||
echo " 1. Both devices are on the same Wi-Fi network."
|
||||
echo " 2. SSH is enabled on the headset."
|
||||
echo " (In Desktop Mode, open Konsole and run 'passwd',"
|
||||
echo " then 'sudo systemctl start sshd', or enable Dev Mode)."
|
||||
echo "==============================================================="
|
||||
fi
|
||||
@@ -29,6 +29,7 @@ VERSION=""
|
||||
DO_STEAM=1
|
||||
RESTART_STEAM=0
|
||||
UNINSTALL=0
|
||||
DO_UPDATE=0
|
||||
FORCE=0
|
||||
|
||||
if [ -t 1 ]; then
|
||||
@@ -57,6 +58,7 @@ Usage:
|
||||
Options:
|
||||
--dir DIR install directory (default: ~/PixelVRPlayer)
|
||||
--version vX.Y.Z release to install (default: the latest release)
|
||||
--update force fetching the latest release from GitHub
|
||||
--no-steam do not add the Non-Steam Game shortcut
|
||||
--restart-steam close Steam before registering, then start it again
|
||||
--uninstall remove the app and its Steam shortcut
|
||||
@@ -82,6 +84,7 @@ while [ $# -gt 0 ]; do
|
||||
[ $# -ge 2 ] || die "--version needs a value"
|
||||
VERSION="$2"; shift ;;
|
||||
--version=*) VERSION="${1#--version=}" ;;
|
||||
--update) DO_UPDATE=1 ;;
|
||||
--no-steam) DO_STEAM=0 ;;
|
||||
--restart-steam) RESTART_STEAM=1 ;;
|
||||
--uninstall) UNINSTALL=1 ;;
|
||||
@@ -230,7 +233,8 @@ def find(shortcuts, name, exe):
|
||||
continue
|
||||
stored_exe = entry.get("Exe", [STR, ""])[1].strip('"')
|
||||
stored_name = entry.get("AppName", [STR, ""])[1]
|
||||
if (exe and stored_exe == exe) or stored_name == name:
|
||||
devkit_id = entry.get("DevkitGameID", [STR, ""])[1]
|
||||
if (exe and stored_exe == exe) or stored_name == name or stored_name in ("Devkit Game: PixelVRPlayer", "Devkit Game: Pixel VR Player") or devkit_id == "PixelVRPlayer":
|
||||
return key, entry
|
||||
return None, None
|
||||
|
||||
@@ -257,13 +261,25 @@ def main():
|
||||
if entry is None:
|
||||
print("no matching shortcut, nothing to remove")
|
||||
return 0
|
||||
raw_appid = entry.get("appid", [INT, 0])[1] & 0xFFFFFFFF
|
||||
if raw_appid:
|
||||
steam_user_dir = os.path.dirname(os.path.dirname(path))
|
||||
grid_dir = os.path.join(steam_user_dir, "config", "grid")
|
||||
for suffix in ("", "p", "_hero", "_logo", "_icon"):
|
||||
for ext in (".png", ".jpg"):
|
||||
gf = os.path.join(grid_dir, "%d%s%s" % (raw_appid, suffix, ext))
|
||||
if os.path.isfile(gf):
|
||||
try:
|
||||
os.remove(gf)
|
||||
except Exception:
|
||||
pass
|
||||
del shortcuts[key]
|
||||
renumbered = {}
|
||||
ordered = sorted(shortcuts, key=lambda k: int(k) if k.isdigit() else 1 << 30)
|
||||
for index, old in enumerate(ordered):
|
||||
renumbered[str(index)] = shortcuts[old]
|
||||
root[root_key][1] = renumbered
|
||||
print("removed '%s'" % name)
|
||||
print("removed '%s' and grid artwork" % name)
|
||||
else:
|
||||
quoted_exe = '"%s"' % exe
|
||||
created = entry is None
|
||||
@@ -276,11 +292,12 @@ def main():
|
||||
shortcuts[str(index)] = [OBJ, entry]
|
||||
if "appid" not in entry:
|
||||
entry["appid"] = [INT, to_i32(app_id(quoted_exe, name))]
|
||||
icon_path = os.path.join(start_dir, "icon.png") if os.path.isfile(os.path.join(start_dir, "icon.png")) else ""
|
||||
for field, value in (
|
||||
("AppName", [STR, name]),
|
||||
("Exe", [STR, quoted_exe]),
|
||||
("StartDir", [STR, '"%s"' % start_dir]),
|
||||
("icon", [STR, ""]),
|
||||
("icon", [STR, icon_path]),
|
||||
("ShortcutPath", [STR, ""]),
|
||||
("LaunchOptions", [STR, ""]),
|
||||
("IsHidden", [INT, 0]),
|
||||
@@ -295,6 +312,31 @@ def main():
|
||||
):
|
||||
entry[field] = value
|
||||
entry.setdefault("tags", [OBJ, {}])
|
||||
if icon_path and os.path.isfile(icon_path):
|
||||
try:
|
||||
import shutil
|
||||
steam_user_dir = os.path.dirname(os.path.dirname(path))
|
||||
grid_dir = os.path.join(steam_user_dir, "config", "grid")
|
||||
if icon_path and os.path.isfile(icon_path):
|
||||
shutil.copyfile(icon_path, os.path.join(grid_dir, "%d_icon.png" % raw_appid))
|
||||
capsule_path = os.path.join(os.path.dirname(icon_path), "capsule.png") if icon_path else ""
|
||||
if capsule_path and os.path.isfile(capsule_path):
|
||||
shutil.copyfile(capsule_path, os.path.join(grid_dir, "%dp.png" % raw_appid))
|
||||
elif icon_path and os.path.isfile(icon_path):
|
||||
shutil.copyfile(icon_path, os.path.join(grid_dir, "%dp.png" % raw_appid))
|
||||
header_path = os.path.join(os.path.dirname(icon_path), "header.png") if icon_path else ""
|
||||
if header_path and os.path.isfile(header_path):
|
||||
shutil.copyfile(header_path, os.path.join(grid_dir, "%d.png" % raw_appid))
|
||||
elif icon_path and os.path.isfile(icon_path):
|
||||
shutil.copyfile(icon_path, os.path.join(grid_dir, "%d.png" % raw_appid))
|
||||
hero_path = os.path.join(os.path.dirname(icon_path), "hero.png") if icon_path else ""
|
||||
if hero_path and os.path.isfile(hero_path):
|
||||
shutil.copyfile(hero_path, os.path.join(grid_dir, "%d_hero.png" % raw_appid))
|
||||
logo_path = os.path.join(os.path.dirname(icon_path), "logo.png") if icon_path else ""
|
||||
if logo_path and os.path.isfile(logo_path):
|
||||
shutil.copyfile(logo_path, os.path.join(grid_dir, "%d_logo.png" % raw_appid))
|
||||
except Exception:
|
||||
pass
|
||||
print("%s '%s' (%d shortcut(s) in the library)" % (
|
||||
"added" if created else "updated", name, len(shortcuts)))
|
||||
|
||||
@@ -391,6 +433,7 @@ if [ "$UNINSTALL" -eq 1 ]; then
|
||||
fi
|
||||
fi
|
||||
rm -rf "$INSTALL_DIR"
|
||||
rm -f "$HOME/Desktop/Update Pixel VR Player.desktop"
|
||||
ok "removed $INSTALL_DIR"
|
||||
printf '\nSettings (%s/.config/pixelvr) and cached thumbnails were kept.\n' "$HOME"
|
||||
printf 'Delete them too with: rm -rf ~/.config/pixelvr ~/.cache/pixelvr\n'
|
||||
@@ -433,7 +476,7 @@ tmp=""
|
||||
cleanup() { [ -z "$tmp" ] || rm -rf "$tmp"; }
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
if [ -n "$script_dir" ] && [ -f "$script_dir/pixelvr" ] && [ -d "$script_dir/shaders" ]; then
|
||||
if [ "$DO_UPDATE" -eq 0 ] && [ -n "$script_dir" ] && [ -f "$script_dir/pixelvr" ] && [ -d "$script_dir/shaders" ]; then
|
||||
info "Installing from $script_dir"
|
||||
srcdir="$script_dir"
|
||||
if [ -f "$script_dir/VERSION" ]; then
|
||||
@@ -504,11 +547,52 @@ elif [ -n "$script_dir" ] && [ -f "$script_dir/install.sh" ]; then
|
||||
chmod 0755 "$INSTALL_DIR/install.sh"
|
||||
INSTALLED_SCRIPT=1
|
||||
fi
|
||||
# Copy updater script
|
||||
if [ -f "$srcdir/update.sh" ]; then
|
||||
cp "$srcdir/update.sh" "$INSTALL_DIR/update.sh"
|
||||
chmod 0755 "$INSTALL_DIR/update.sh"
|
||||
elif [ -n "$script_dir" ] && [ -f "$script_dir/update.sh" ]; then
|
||||
cp "$script_dir/update.sh" "$INSTALL_DIR/update.sh"
|
||||
chmod 0755 "$INSTALL_DIR/update.sh"
|
||||
elif [ -f "tools/update.sh" ]; then
|
||||
cp "tools/update.sh" "$INSTALL_DIR/update.sh"
|
||||
chmod 0755 "$INSTALL_DIR/update.sh"
|
||||
fi
|
||||
|
||||
# Copy Desktop 1-click update entry
|
||||
DESKTOP_ENTRY="Update Pixel VR Player.desktop"
|
||||
if [ -f "$srcdir/$DESKTOP_ENTRY" ]; then
|
||||
cp "$srcdir/$DESKTOP_ENTRY" "$INSTALL_DIR/$DESKTOP_ENTRY"
|
||||
chmod 0755 "$INSTALL_DIR/$DESKTOP_ENTRY"
|
||||
elif [ -n "$script_dir" ] && [ -f "$script_dir/$DESKTOP_ENTRY" ]; then
|
||||
cp "$script_dir/$DESKTOP_ENTRY" "$INSTALL_DIR/$DESKTOP_ENTRY"
|
||||
chmod 0755 "$INSTALL_DIR/$DESKTOP_ENTRY"
|
||||
elif [ -f "tools/$DESKTOP_ENTRY" ]; then
|
||||
cp "tools/$DESKTOP_ENTRY" "$INSTALL_DIR/$DESKTOP_ENTRY"
|
||||
chmod 0755 "$INSTALL_DIR/$DESKTOP_ENTRY"
|
||||
fi
|
||||
|
||||
# Install 1-click update shortcut to Desktop if Desktop folder exists
|
||||
if [ -d "$HOME/Desktop" ] && [ -f "$INSTALL_DIR/$DESKTOP_ENTRY" ]; then
|
||||
cp "$INSTALL_DIR/$DESKTOP_ENTRY" "$HOME/Desktop/$DESKTOP_ENTRY"
|
||||
chmod 0755 "$HOME/Desktop/$DESKTOP_ENTRY"
|
||||
ok "Installed 1-click update shortcut to ~/Desktop/$DESKTOP_ENTRY"
|
||||
fi
|
||||
|
||||
if [ -f "$srcdir/VERSION" ]; then
|
||||
cp "$srcdir/VERSION" "$INSTALL_DIR/VERSION"
|
||||
elif [ -n "$VERSION" ]; then
|
||||
printf '%s\n' "$VERSION" > "$INSTALL_DIR/VERSION"
|
||||
fi
|
||||
for asset in icon.png hero.png capsule.png header.png logo.png; do
|
||||
if [ -f "$srcdir/$asset" ]; then
|
||||
cp "$srcdir/$asset" "$INSTALL_DIR/$asset"
|
||||
elif [ -n "$script_dir" ] && [ -f "$script_dir/$asset" ]; then
|
||||
cp "$script_dir/$asset" "$INSTALL_DIR/$asset"
|
||||
elif [ -f "assets/$asset" ]; then
|
||||
cp "assets/$asset" "$INSTALL_DIR/$asset"
|
||||
fi
|
||||
done
|
||||
ok "$(ls -1 "$INSTALL_DIR/shaders" | wc -l | tr -d ' ') shaders, $(du -h "$INSTALL_DIR/pixelvr" 2>/dev/null | cut -f1) binary"
|
||||
|
||||
missing_libs=""
|
||||
@@ -551,7 +635,7 @@ printf '\n Launch now : %s/run.sh\n' "$INSTALL_DIR"
|
||||
printf ' From Steam : Library > Non-Steam > %s\n' "$APP_NAME"
|
||||
printf ' Play a clip : %s/run.sh /path/to/clip.mp4\n' "$INSTALL_DIR"
|
||||
printf ' Logs : ~/pixelvr-run.log\n'
|
||||
printf ' Update : run this script again (settings and cache are kept)\n'
|
||||
printf ' 1-Click Upd : %s/update.sh (or "Update Pixel VR Player" icon on Desktop)\n' "$INSTALL_DIR"
|
||||
if [ "$INSTALLED_SCRIPT" -eq 1 ]; then
|
||||
printf ' Remove : %s/install.sh --uninstall\n\n' "$INSTALL_DIR"
|
||||
else
|
||||
|
||||
@@ -37,12 +37,35 @@ cp "${bin}" "${stage}/PixelVRPlayer/pixelvr"
|
||||
cp "${BUILD_DIR}/shaders/"*.spv "${stage}/PixelVRPlayer/shaders/"
|
||||
cp tools/run.sh "${stage}/PixelVRPlayer/run.sh"
|
||||
cp tools/install.sh "${stage}/PixelVRPlayer/install.sh"
|
||||
if [[ -f "tools/update.sh" ]]; then
|
||||
cp tools/update.sh "${stage}/PixelVRPlayer/update.sh"
|
||||
fi
|
||||
if [[ -f "tools/Install Pixel VR Player.desktop" ]]; then
|
||||
cp "tools/Install Pixel VR Player.desktop" "${stage}/PixelVRPlayer/Install Pixel VR Player.desktop"
|
||||
fi
|
||||
if [[ -f "tools/Update Pixel VR Player.desktop" ]]; then
|
||||
cp "tools/Update Pixel VR Player.desktop" "${stage}/PixelVRPlayer/Update Pixel VR Player.desktop"
|
||||
fi
|
||||
for asset in icon.png hero.png capsule.png header.png logo.png; do
|
||||
if [[ -f "assets/${asset}" ]]; then
|
||||
cp "assets/${asset}" "${stage}/PixelVRPlayer/${asset}"
|
||||
fi
|
||||
done
|
||||
if [[ -n "${VERSION}" ]]; then
|
||||
printf '%s\n' "${VERSION}" > "${stage}/PixelVRPlayer/VERSION"
|
||||
fi
|
||||
chmod +x "${stage}/PixelVRPlayer/pixelvr" \
|
||||
"${stage}/PixelVRPlayer/run.sh" \
|
||||
"${stage}/PixelVRPlayer/install.sh"
|
||||
if [[ -f "${stage}/PixelVRPlayer/update.sh" ]]; then
|
||||
chmod +x "${stage}/PixelVRPlayer/update.sh"
|
||||
fi
|
||||
if [[ -f "${stage}/PixelVRPlayer/Install Pixel VR Player.desktop" ]]; then
|
||||
chmod +x "${stage}/PixelVRPlayer/Install Pixel VR Player.desktop"
|
||||
fi
|
||||
if [[ -f "${stage}/PixelVRPlayer/Update Pixel VR Player.desktop" ]]; then
|
||||
chmod +x "${stage}/PixelVRPlayer/Update Pixel VR Player.desktop"
|
||||
fi
|
||||
|
||||
rm -f "${OUT_TAR}"
|
||||
tar -C "${stage}" -czf "${OUT_TAR}" PixelVRPlayer
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Automated in-headset screenshot capture tool for Valve Steam Frame (SteamOS).
|
||||
Uses OpenVR IVRScreenshots via SteamVR's running compositor to capture
|
||||
both 2D preview and stereo VR framebuffers.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
import argparse
|
||||
import subprocess
|
||||
import base64
|
||||
from datetime import datetime
|
||||
|
||||
DEFAULT_SSH_KEY = os.path.expanduser(r"~\AppData\Local\steamos-devkit\steamos-devkit\devkit_rsa")
|
||||
DEFAULT_HOST = "steamos@192.168.178.61"
|
||||
|
||||
CPP_CODE = r"""#include <iostream>
|
||||
#include <thread>
|
||||
#include <chrono>
|
||||
#include "/opt/steamvr/tools/hellovr_vulkan_linux/src/openvr/headers/openvr.h"
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
if (argc < 3) {
|
||||
std::cerr << "Usage: " << argv[0] << " <preview_png> <stereo_png>" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
const char* preview = argv[1];
|
||||
const char* vr_file = argv[2];
|
||||
|
||||
vr::EVRInitError err = vr::VRInitError_None;
|
||||
vr::IVRSystem* vrSys = vr::VR_Init(&err, vr::VRApplication_Overlay);
|
||||
if (!vrSys || err != vr::VRInitError_None) {
|
||||
std::cerr << "VR_Init failed: " << vr::VR_GetVRInitErrorAsEnglishDescription(err) << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
vr::IVRScreenshots* vrScreenshots = (vr::IVRScreenshots*)vr::VR_GetGenericInterface("IVRScreenshots_001", &err);
|
||||
if (!vrScreenshots || err != vr::VRInitError_None) {
|
||||
std::cerr << "GetGenericInterface IVRScreenshots_001 failed: " << err << std::endl;
|
||||
vr::VR_Shutdown();
|
||||
return 1;
|
||||
}
|
||||
|
||||
vr::ScreenshotHandle_t handle = 0;
|
||||
vr::EVRScreenshotError scErr = vrScreenshots->TakeStereoScreenshot(&handle, preview, vr_file);
|
||||
if (scErr != vr::VRScreenshotError_None) {
|
||||
std::cerr << "TakeStereoScreenshot failed with error: " << scErr << std::endl;
|
||||
vr::VR_Shutdown();
|
||||
return 1;
|
||||
}
|
||||
std::cout << "Screenshot requested successfully, handle: " << handle << std::endl;
|
||||
|
||||
// Allow compositor frame loop to flush the write
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1500));
|
||||
|
||||
vr::VR_Shutdown();
|
||||
return 0;
|
||||
}
|
||||
"""
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Capture in-headset screenshots from Valve Steam Frame")
|
||||
parser.add_argument("--host", default=DEFAULT_HOST, help="Steam Frame SSH destination")
|
||||
parser.add_argument("--key", default=DEFAULT_SSH_KEY, help="Path to devkit private key")
|
||||
parser.add_argument("--delay", type=int, default=0, help="Seconds to wait before taking screenshot")
|
||||
parser.add_argument("--out-dir", default="docs/screenshots", help="Directory to save downloaded screenshots")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.delay > 0:
|
||||
print(f"Waiting {args.delay} seconds before capture (put headset on or position UI)...")
|
||||
time.sleep(args.delay)
|
||||
|
||||
b64_cpp = base64.b64encode(CPP_CODE.encode('utf-8')).decode('utf-8')
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
remote_preview = f"/tmp/pixelvr_snap_{timestamp}_preview.png"
|
||||
remote_stereo = f"/tmp/pixelvr_snap_{timestamp}_stereo.png"
|
||||
|
||||
remote_cmd = (
|
||||
f"if [ ! -f /tmp/pixelvr_screenshot_bin ]; then "
|
||||
f" echo '{b64_cpp}' | base64 -d > /tmp/pixelvr_screenshot.cpp && "
|
||||
f" g++ -O2 /tmp/pixelvr_screenshot.cpp -L/opt/steamvr/bin/linuxarm64 -lopenvr_api -Wl,-rpath,/opt/steamvr/bin/linuxarm64 -o /tmp/pixelvr_screenshot_bin ; "
|
||||
f"fi ; "
|
||||
f"/tmp/pixelvr_screenshot_bin {remote_preview} {remote_stereo}"
|
||||
)
|
||||
|
||||
print(f"Triggering screenshot capture on {args.host}...")
|
||||
res = subprocess.run([
|
||||
"ssh", "-i", args.key, "-o", "StrictHostKeyChecking=no", args.host, remote_cmd
|
||||
], capture_output=True, text=True)
|
||||
|
||||
if res.returncode != 0:
|
||||
print("Error capturing screenshot:\n", res.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(res.stdout.strip())
|
||||
|
||||
os.makedirs(args.out_dir, exist_ok=True)
|
||||
local_preview = os.path.join(args.out_dir, f"screenshot_{timestamp}_preview.png")
|
||||
local_stereo = os.path.join(args.out_dir, f"screenshot_{timestamp}_stereo.png")
|
||||
|
||||
print(f"Downloading captured frames to {args.out_dir}...")
|
||||
subprocess.run(["scp", "-i", args.key, "-o", "StrictHostKeyChecking=no", f"{args.host}:{remote_preview}", local_preview], check=True)
|
||||
subprocess.run(["scp", "-i", args.key, "-o", "StrictHostKeyChecking=no", f"{args.host}:{remote_stereo}", local_stereo], check=True)
|
||||
|
||||
print(f"Saved: {local_preview}")
|
||||
print(f"Saved: {local_stereo}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,44 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
echo ===============================================================
|
||||
echo Pixel VR Player - Steam Frame Remote Uninstaller
|
||||
echo ===============================================================
|
||||
echo This tool removes Pixel VR Player and its Steam shortcut from
|
||||
echo your Valve Steam Frame over your local Wi-Fi network.
|
||||
echo ===============================================================
|
||||
echo.
|
||||
|
||||
set "FRAME_IP=steamframe.local"
|
||||
set /p "INPUT_IP=Enter Steam Frame IP address [press Enter for steamframe.local]: "
|
||||
if not "!INPUT_IP!"=="" set "FRAME_IP=!INPUT_IP!"
|
||||
|
||||
echo.
|
||||
echo Connecting to steamos@!FRAME_IP!...
|
||||
echo.
|
||||
|
||||
where ssh >nul 2>&1
|
||||
if %ERRORLEVEL% neq 0 (
|
||||
echo [ERROR] SSH is not installed or not in PATH on this PC.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
ssh -o StrictHostKeyChecking=no steamos@!FRAME_IP! "curl -fsSL https://raw.githubusercontent.com/Pixelplanet/Pixel-VR-Player/main/tools/install.sh | sh -s -- --uninstall"
|
||||
|
||||
if %ERRORLEVEL% equ 0 (
|
||||
echo.
|
||||
echo ===============================================================
|
||||
echo [SUCCESS] Pixel VR Player removed from your Steam Frame!
|
||||
echo ===============================================================
|
||||
) else (
|
||||
echo.
|
||||
echo ===============================================================
|
||||
echo [NOTE] If connection failed, please ensure:
|
||||
echo 1. Both your PC and Steam Frame are on the same Wi-Fi.
|
||||
echo 2. SSH is enabled on the headset.
|
||||
echo ===============================================================
|
||||
)
|
||||
|
||||
echo.
|
||||
pause
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pixel VR Player - Remote uninstaller from Mac / Linux to Steam Frame
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
echo "==============================================================="
|
||||
echo " Pixel VR Player - Steam Frame Remote Uninstaller"
|
||||
echo "==============================================================="
|
||||
echo " This tool removes Pixel VR Player and its Steam shortcut from"
|
||||
echo " your Valve Steam Frame over your local Wi-Fi network."
|
||||
echo "==============================================================="
|
||||
echo ""
|
||||
|
||||
read -rp "Enter Steam Frame IP address [press Enter for steamframe.local]: " INPUT_IP
|
||||
FRAME_IP="${INPUT_IP:-steamframe.local}"
|
||||
|
||||
echo ""
|
||||
echo "Connecting to steamos@${FRAME_IP}..."
|
||||
echo ""
|
||||
|
||||
if ssh -o StrictHostKeyChecking=no "steamos@${FRAME_IP}" "curl -fsSL https://raw.githubusercontent.com/Pixelplanet/Pixel-VR-Player/main/tools/install.sh | sh -s -- --uninstall"; then
|
||||
echo ""
|
||||
echo "==============================================================="
|
||||
echo " [SUCCESS] Pixel VR Player removed from your Steam Frame!"
|
||||
echo "==============================================================="
|
||||
else
|
||||
echo ""
|
||||
echo "==============================================================="
|
||||
echo " [NOTE] If connection failed, please ensure:"
|
||||
echo " 1. Both devices are on the same Wi-Fi network."
|
||||
echo " 2. SSH is enabled on the headset."
|
||||
echo "==============================================================="
|
||||
fi
|
||||
@@ -0,0 +1,50 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
echo ===============================================================
|
||||
echo Pixel VR Player - Steam Frame Remote Updater
|
||||
echo ===============================================================
|
||||
echo This tool updates Pixel VR Player on your Valve Steam Frame
|
||||
echo to the latest release over Wi-Fi directly from your PC.
|
||||
echo (All your settings, bookmarks, and shares are kept safe).
|
||||
echo ===============================================================
|
||||
echo.
|
||||
|
||||
set "FRAME_IP=steamframe.local"
|
||||
set /p "INPUT_IP=Enter Steam Frame IP address [press Enter for steamframe.local]: "
|
||||
if not "!INPUT_IP!"=="" set "FRAME_IP=!INPUT_IP!"
|
||||
|
||||
echo.
|
||||
echo Connecting to steamos@!FRAME_IP!...
|
||||
echo (If prompted for a password, enter your SteamOS desktop password)
|
||||
echo.
|
||||
|
||||
where ssh >nul 2>&1
|
||||
if %ERRORLEVEL% neq 0 (
|
||||
echo [ERROR] SSH is not installed or not in PATH on this PC.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
set "REMOTE_CMD=curl -fsSL https://raw.githubusercontent.com/Pixelplanet/Pixel-VR-Player/main/tools/install.sh | sh -s -- --update"
|
||||
|
||||
ssh -o StrictHostKeyChecking=no steamos@!FRAME_IP! "!REMOTE_CMD!"
|
||||
|
||||
if %ERRORLEVEL% equ 0 (
|
||||
echo.
|
||||
echo ===============================================================
|
||||
echo [SUCCESS] Pixel VR Player has been updated on your Steam Frame!
|
||||
echo ===============================================================
|
||||
) else (
|
||||
echo.
|
||||
echo ===============================================================
|
||||
echo [NOTE] If connection failed, please ensure:
|
||||
echo 1. Both your PC and Steam Frame are on the same Wi-Fi.
|
||||
echo 2. SSH is enabled on the headset.
|
||||
echo (In Desktop Mode, open Konsole and run 'passwd',
|
||||
echo then 'sudo systemctl start sshd', or enable Dev Mode).
|
||||
echo ===============================================================
|
||||
)
|
||||
|
||||
echo.
|
||||
pause
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pixel VR Player - Remote updater from Mac / Linux to Steam Frame
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
echo "==============================================================="
|
||||
echo " Pixel VR Player - Steam Frame Remote Updater"
|
||||
echo "==============================================================="
|
||||
echo " This tool updates Pixel VR Player on your Valve Steam Frame"
|
||||
echo " to the latest release over Wi-Fi directly from your Mac or PC."
|
||||
echo " (All your settings, bookmarks, and shares are kept safe)."
|
||||
echo "==============================================================="
|
||||
echo ""
|
||||
|
||||
read -rp "Enter Steam Frame IP address [press Enter for steamframe.local]: " INPUT_IP
|
||||
FRAME_IP="${INPUT_IP:-steamframe.local}"
|
||||
|
||||
echo ""
|
||||
echo "Connecting to steamos@${FRAME_IP}..."
|
||||
echo "(If prompted for a password, enter your SteamOS desktop password)"
|
||||
echo ""
|
||||
|
||||
REMOTE_CMD="curl -fsSL https://raw.githubusercontent.com/Pixelplanet/Pixel-VR-Player/main/tools/install.sh | sh -s -- --update"
|
||||
|
||||
if ssh -o StrictHostKeyChecking=no "steamos@${FRAME_IP}" "${REMOTE_CMD}"; then
|
||||
echo ""
|
||||
echo "==============================================================="
|
||||
echo " [SUCCESS] Pixel VR Player has been updated on your Steam Frame!"
|
||||
echo "==============================================================="
|
||||
else
|
||||
echo ""
|
||||
echo "==============================================================="
|
||||
echo " [NOTE] If connection failed, please ensure:"
|
||||
echo " 1. Both devices are on the same Wi-Fi network."
|
||||
echo " 2. SSH is enabled on the headset."
|
||||
echo " (In Desktop Mode, open Konsole and run 'passwd',"
|
||||
echo " then 'sudo systemctl start sshd', or enable Dev Mode)."
|
||||
echo "==============================================================="
|
||||
fi
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/bin/sh
|
||||
# Pixel VR Player updater for Valve Steam Frame
|
||||
# Downloads and installs the latest release in-place.
|
||||
set -eu
|
||||
|
||||
REPO="Pixelplanet/Pixel-VR-Player"
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd)
|
||||
INSTALL_DIR="${PIXELVR_INSTALL_DIR:-$HOME/PixelVRPlayer}"
|
||||
|
||||
if [ -n "$SCRIPT_DIR" ] && [ -f "$SCRIPT_DIR/pixelvr" ]; then
|
||||
INSTALL_DIR="$SCRIPT_DIR"
|
||||
fi
|
||||
|
||||
if [ -t 1 ]; then
|
||||
C_INFO=$(printf '\0033[36m')
|
||||
C_OK=$(printf '\0033[32m')
|
||||
C_ERR=$(printf '\0033[31m')
|
||||
C_OFF=$(printf '\0033[0m')
|
||||
else
|
||||
C_INFO=""; C_OK=""; C_ERR=""; C_OFF=""
|
||||
fi
|
||||
|
||||
printf '%s==>%s Checking for Pixel VR Player updates for %s...\n' "$C_INFO" "$C_OFF" "$INSTALL_DIR"
|
||||
|
||||
# Execute in-place update using install.sh with --update flag
|
||||
if [ -f "$INSTALL_DIR/install.sh" ]; then
|
||||
"$INSTALL_DIR/install.sh" --dir "$INSTALL_DIR" --update "$@"
|
||||
else
|
||||
curl -fsSL https://raw.githubusercontent.com/"$REPO"/main/tools/install.sh | sh -s -- --dir "$INSTALL_DIR" --update "$@"
|
||||
fi
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
printf '\n%s ok%s Pixel VR Player update complete!\n' "$C_OK" "$C_OFF"
|
||||
if [ -n "${DISPLAY:-}" ] || [ -n "${WAYLAND_DISPLAY:-}" ]; then
|
||||
if command -v notify-send >/dev/null 2>&1; then
|
||||
notify-send "Pixel VR Player" "Updated to the latest version successfully!" || true
|
||||
fi
|
||||
fi
|
||||
else
|
||||
printf '\n%serror%s Pixel VR Player update failed.\n' "$C_ERR" "$C_OFF" >&2
|
||||
exit 1
|
||||
fi
|
||||