21 Commits
Author SHA1 Message Date
Pixelplanet 2f684e23a2 fix(ui): fix HUD dismissal flicker and center transport recessed island alignment
- Prevent in.selectHeld and trigger activity from resetting hudDismissed_ after manual dismiss
- Add App::dismissHud() to reset activity anchors to current controller poses and establish a 500ms cooldown
- Center transport recessed island background at cx=0.0f, hw=0.34f, giving symmetrical 34mm padding across all 5 buttons
- Add hover tooltip pills for [Hide UI] and [Stop]
2026-10-20 12:29:45 +02:00
Pixelplanet 7aede92244 feat: release v1.6.0 with virtual keyboard laser autofocus, SMB credentials, bookmarks overview, and 1-click updater
- Virtual Keyboard Laser Auto-Focus: vrcompositor laser mouse overlay configuration for instant typing without manual grip toggle; tracking persistence across session states
- Protected SMB Network Share Manager: in-browser 'Add Share' modal supporting multiple authenticated shares, AES-GCM credential encryption with strict 0600 file permissions, automatic credential lookup during streaming
- IPv4 Validation: numeric filtering, section clamping to 255, and stripping leading zeros
- Bookmarks Overview: renamed Quick Paths to Bookmarks with dedicated 'bookmarks://' overview panel, ASCII button labels replacing multi-byte glyphs
- 1-Click Seamless Updater: desktop shortcut for SteamOS Desktop Mode, standalone update.sh script, remote Wi-Fi PC updaters, and updated release documentation
2026-10-20 11:42:13 +02:00
Pixelplanet bce434a115 docs: add uninstallation instructions, remote uninstaller scripts, and grid cleanup 2026-10-18 18:48:21 +02:00
Pixelplanet 18a50d38ab chore: enforce LF line endings via .gitattributes for shell scripts and CI 2026-10-18 16:15:31 +02:00
Pixelplanet c404131924 feat(branding): add portrait capsule, landscape header, and transparent logo for Steam library 2026-10-18 15:15:30 +02:00
Pixelplanet 09a5f73393 feat: embed official hero banner in README and integrate hero artwork across all deploy scripts 2026-10-18 15:00:53 +02:00
Pixelplanet dfc1664806 feat(assets): finalize Steam hero banner with headset on left, uncut title, and refined specs 2026-10-18 14:54:17 +02:00
Pixelplanet 6c0bfd4e4a feat(assets): update hero banner with Steam Frame on left, uncut title, and refined specs 2026-10-18 14:49:01 +02:00
Pixelplanet 6bfec8bf19 feat(assets): adopt Style 2 as official icon and add 3 Steam hero banner options 2026-10-18 14:44:10 +02:00
Pixelplanet 1c18c2c328 feat(assets): add authentic Steam Frame 16-bit pixel art icon styles 1 and 2 2026-10-18 14:27:10 +02:00
Pixelplanet c8e738ef21 feat(assets): add authentic Valve Steam Frame app icon variants for 1, 2, and 4 2026-10-18 09:56:17 +02:00
Pixelplanet 7e5915e115 feat: add authentic Steam Frame oval visor and rear battery icon designs 2026-10-18 09:43:21 +02:00
Pixelplanet be5ea10875 feat: add refined Steam Frame standalone icon redesigns (pancake optics, 4-camera array, rear battery) 2026-10-18 09:37:45 +02:00
Pixelplanet ba662f848b feat: add Steam Frame hardware-accurate app icon variants 2026-10-18 09:33:46 +02:00
Pixelplanet aa896397fe feat: add pixelated app icons and remove devkit prefix from steam shortcuts 2026-10-18 09:29:59 +02:00
Pixelplanet 7bf367419f docs: update deploy guide, readme links, and discreet vault instructions 2026-10-17 22:39:45 +02:00
Pixelplanet 83cf192d2d docs: clarify install options and SSH prerequisites 2026-10-17 22:26:49 +02:00
Pixelplanet 2a613ebed5 feat: add remote PC installer and double-clickable USB desktop installer 2026-10-17 22:21:37 +02:00
Pixelplanet 8454ab5de3 docs: blur real filenames in mockup screenshots for privacy 2026-10-17 22:15:06 +02:00
Pixelplanet 2c74923701 docs: add UI screenshot gallery to README and automated capture tool 2026-10-17 21:59:13 +02:00
Pixelplanet 22ed9e1ede fix(install): support case-insensitive Shortcuts key in shortcuts.vdf and copy install.sh from unpacked tarball 2026-10-17 20:55:13 +02:00
90 changed files with 9562 additions and 296 deletions

No files matched your search

+10
View File
@@ -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
+2
View File
@@ -45,3 +45,5 @@ Thumbs.db
/scratch/
/test_vault
/*-plan.md
/test_*.cpp
/protected-*.md
+6 -2
View File
@@ -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)
+168 -13
View File
@@ -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'
+73 -8
View File
@@ -7,19 +7,84 @@
---
## 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.
- [x] **6. Fix UI Dismissal Flicker and Recessed Island Alignment**
- **Symptom:** Clicking outside the controls or clicking the Hide UI button caused the UI to briefly flicker for 1 frame and immediately reappear. The Hide UI button was also visually misaligned (half outside the dark island).
- **Root Cause:**
- `in.selectHeld` and trigger activity handling in `App::tick()` continuously reset `hudDismissed_ = false` and set `activity = true` on subsequent frames of the trigger pull, fighting the dismissal call.
- Controller activity anchors were not reset on dismissal, and there was no dismissal grace cooldown, causing slight hand tremor during trigger release to immediately re-trigger movement thresholds.
- The center transport recessed island background in `VulkanRenderer` was rendered at `cx = -0.05f, hw = 0.30f`, clipping through the center of the Hide UI button at `+0.26f`.
- **Resolution:**
- Created `App::dismissHud()` which sets `hudDismissed_ = true`, records `hudDismissTime_`, zeroes out activity, hides the control bar and laser ray, and snaps controller activity anchors to their exact current positions.
- Added a 500ms dismissal cooldown preventing accidental re-engagement from trigger release tremors.
- Blocked `in.select` and `in.selectHeld` from clearing `hudDismissed_` while the HUD is dismissed.
- Re-centered the transport recessed island background to `cx = 0.0f, hw = 0.34f`, creating perfect 34mm symmetrical padding for all 5 buttons (`Stop`, `SeekBack`, `PlayPause`, `SeekFwd`, `HideHud`).
- Added hover tooltip pills for `[Hide UI]` and `[Stop]`.
---
## 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.
---
Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 798 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 936 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 852 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 860 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 994 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 952 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 759 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 800 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 784 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 788 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 798 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 812 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 936 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 852 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 540 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 961 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 642 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 445 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 455 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 860 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 994 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 952 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

+59 -14
View File
@@ -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 |
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 642 KiB

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 549 KiB

After

Width:  |  Height:  |  Size: 207 KiB

+504 -46
View File
@@ -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"
@@ -308,6 +311,8 @@ void App::loadMedia(const std::string& path) {
PIXELVR_LOG_WARN("Failed to open %s", path.c_str());
return;
}
hudDismissed_ = false;
lastControllerActivity_ = std::chrono::steady_clock::now();
// Refine the stereo layout from the frame aspect ratio when the filename did
// not state it: once the projection is a VR type the aspect is unambiguous
@@ -537,7 +542,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 +550,205 @@ void App::performVaultLock() {
PIXELVR_LOG_INFO("Vault locked and sanitized");
}
void App::dismissHud(const XrInputState& in, std::chrono::steady_clock::time_point nowT) {
hudDismissed_ = true;
hudDismissTime_ = nowT;
lastControllerActivity_ = nowT - std::chrono::seconds(10);
renderer_.setControlBar(false);
for (int h = 0; h < 2; ++h) {
if (in.controllers[h].active) {
for (int k = 0; k < 3; ++k) activityAnchorPos_[h][k] = in.controllers[h].position[k];
for (int k = 0; k < 4; ++k) activityAnchorOrient_[h][k] = in.controllers[h].orientation[k];
}
}
PIXELVR_LOG_INFO("Player HUD dismissed");
}
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 +797,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 +894,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 +1022,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());
@@ -782,6 +1034,8 @@ int App::run() {
// Controller activity: moving a controller past a small threshold (or
// any button/stick input) keeps the laser, models and controls shown;
// after a few seconds of stillness they fade away.
const auto timeSinceDismiss = nowT - hudDismissTime_;
const bool inDismissCooldown = hudDismissed_ && (timeSinceDismiss < std::chrono::milliseconds(500));
bool activity = false;
for (int h = 0; h < 2; ++h) {
const auto& c = in.controllers[h];
@@ -802,16 +1056,34 @@ 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 after dismissal cooldown to re-engage HUD
if (!inDismissCooldown && (moveDist > 0.12f || rotDiff < 0.950f)) {
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];
}
}
if (in.togglePlay || in.browserToggle || in.select || in.selectHeld ||
in.recenter || std::fabs(in.thumbstickX) > 0.15f ||
std::fabs(in.thumbstickY) > 0.15f ||
std::fabs(in.thumbstickRX) > 0.15f) {
const bool stickMoved = (std::fabs(in.thumbstickX) > 0.25f ||
std::fabs(in.thumbstickY) > 0.25f ||
std::fabs(in.thumbstickRX) > 0.25f ||
std::fabs(in.thumbstickRY) > 0.25f);
if (in.togglePlay || in.browserToggle || in.recenter || stickMoved) {
activity = true;
if (!inDismissCooldown) {
hudDismissed_ = false;
}
} else if (!hudDismissed_ && (in.select || in.selectHeld)) {
activity = true;
}
if (activity) {
@@ -834,6 +1106,9 @@ 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!
dismissHud(in, nowT);
} else {
browser_.open();
}
@@ -1108,6 +1383,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 +1427,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 +1454,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 +1536,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 && !hudDismissed_) || 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 +1609,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 +1624,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 +1703,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 +1863,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 +1987,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 +1999,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
@@ -1637,8 +2088,8 @@ int App::run() {
// Cinema Transport Dock is only active when the Unified Spatial Hub is closed.
const bool barOk = !browserOpen && !settingsOpen && !downloadsWindow_ &&
pendingDl_.empty();
if (barOk) {
pendingDl_.empty() && !actionMenu_.visible && !smbModal_.active;
if (barOk && renderer_.controlBarVisible()) {
// Point-and-click the media control bar with the trigger.
const int btn = renderer_.pointerButton();
// The trigger clicks the bar; A does too (A is the reliable
@@ -1729,10 +2180,17 @@ int App::run() {
renderer_.setSpherePitch(0.0f);
renderer_.setSphereZoom(1.0f);
}
} else if (cb == VulkanRenderer::CtrlButton::HideHud) {
dismissHud(in, nowT);
}
#endif
interacted = true;
}
// Click outside the control dock in empty space immediately hides the HUD
if (in.select && btn < 0 && !renderer_.pointerOnTimeline() && !renderer_.pointerOnTimeToggle()) {
dismissHud(in, nowT);
interacted = true;
}
if (in.select && renderer_.pointerOnTimeToggle()) {
renderer_.toggleTimeDisplay(); // total <-> time remaining
interacted = true;
+22
View File
@@ -77,6 +77,9 @@ 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
std::chrono::steady_clock::time_point hudDismissTime_{}; // timestamp of manual dismissal
void dismissHud(const XrInputState& in, std::chrono::steady_clock::time_point nowT);
// 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 +103,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_;
File diff suppressed because it is too large. Load diff
+53 -2
View File
@@ -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_;
+19 -1
View File
@@ -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';
}
+486
View File
@@ -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
+69
View File
@@ -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
+231 -13
View File
@@ -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.
+23 -1
View File
@@ -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_;
+88 -1
View File
@@ -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());
+34 -2
View File
@@ -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_
+107
View File
@@ -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
+384
View File
@@ -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
+57
View File
@@ -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
+21 -2
View File
@@ -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};
+36
View File
@@ -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;
}
+71
View File
@@ -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;
}
+5773
View File
File diff suppressed because it is too large. Load diff
+8
View File
@@ -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;
+8
View File
@@ -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;
+83 -2
View File
@@ -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
+54
View File
@@ -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
+48
View File
@@ -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
+103 -9
View File
@@ -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
@@ -245,7 +249,11 @@ def main():
with open(path, "rb") as handle:
buf = handle.read()
root = parse_file(buf)
shortcuts = root["shortcuts"][1]
root_key = next((k for k in root if k.lower() == "shortcuts"), None)
if root_key is None:
root_key = "shortcuts"
root[root_key] = [OBJ, {}]
shortcuts = root[root_key][1]
key, entry = find(shortcuts, name, exe)
@@ -253,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["shortcuts"][1] = renumbered
print("removed '%s'" % name)
root[root_key][1] = renumbered
print("removed '%s' and grid artwork" % name)
else:
quoted_exe = '"%s"' % exe
created = entry is None
@@ -272,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]),
@@ -291,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)))
@@ -387,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'
@@ -429,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
@@ -491,14 +538,61 @@ cp "$srcdir/shaders/"*.spv "$INSTALL_DIR/shaders/"
cp "$srcdir/run.sh" "$INSTALL_DIR/run.sh"
chmod 0755 "$INSTALL_DIR/run.sh"
INSTALLED_SCRIPT=0
if [ -n "$script_dir" ] && [ -f "$script_dir/install.sh" ]; then
if [ -f "$srcdir/install.sh" ]; then
cp "$srcdir/install.sh" "$INSTALL_DIR/install.sh"
chmod 0755 "$INSTALL_DIR/install.sh"
INSTALLED_SCRIPT=1
elif [ -n "$script_dir" ] && [ -f "$script_dir/install.sh" ]; then
cp "$script_dir/install.sh" "$INSTALL_DIR/install.sh"
chmod 0755 "$INSTALL_DIR/install.sh"
INSTALLED_SCRIPT=1
fi
if [ -n "$VERSION" ]; then
# 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=""
@@ -541,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
+23
View File
@@ -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
+111
View File
@@ -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()
+44
View File
@@ -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
+33
View File
@@ -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
+50
View File
@@ -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
+39
View File
@@ -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
+42
View File
@@ -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