mirror of
https://github.com/Pixelplanet/Pixel-VR-Player.git
synced 2026-10-21 01:01:30 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
766e67bffd | ||
|
|
8e1d97dc9b | ||
|
|
de3fe8fcf8 | ||
|
|
3d11ccbbf8 | ||
|
|
ec9821f1af | ||
|
|
f26405797e | ||
|
|
2f684e23a2 | ||
|
|
7aede92244 | ||
|
|
bce434a115 |
No files matched your search
@@ -3,6 +3,7 @@
|
||||
*.py text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.desktop text eol=lf
|
||||
*.bat text eol=crlf
|
||||
*.spv binary
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
|
||||
@@ -45,3 +45,5 @@ Thumbs.db
|
||||
/scratch/
|
||||
/test_vault
|
||||
/*-plan.md
|
||||
/test_*.cpp
|
||||
/protected-*.md
|
||||
+6
-2
@@ -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)
|
||||
|
||||
|
||||
@@ -17,7 +17,9 @@ 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, downloads manager, and an encrypted hidden vault. 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.
|
||||
@@ -97,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
|
||||
|
||||
@@ -7,19 +7,105 @@
|
||||
|
||||
---
|
||||
|
||||
## Active backlog (2026-10-14)
|
||||
## Active backlog (2026-10-20 — Completed)
|
||||
|
||||
- [x] **Virtual Keyboard Tilt Inversion, Raycast Auto-Focus & UI Hide Refinements**
|
||||
- **Symptom 1:** Keyboard spawned tilted with the top edge pitched forward (slanted away from user).
|
||||
- **Resolution 1:** Corrected tilt angle to \-0.38f\ (-21.8 degrees). The top edge slants backward away from user; face normal tilts upward directly toward the user's eyes.
|
||||
- **Symptom 2:** Laser beam shot 2.5m past the keyboard with no cursor dot on the keys; laser wasn't focused.
|
||||
- **Resolution 2:** Added keyboard plane collision test in \VulkanRenderer::updatePointer()\ using \KeyboardModalData::transform\. Laser ray terminates directly on the keyboard surface with glowing cursor dot on hovered keys. Also ensured active controller laser turns on automatically when keyboard opens, and guarded background menus from eating keyboard trigger clicks.
|
||||
- **Symptom 3:** Trigger pull while HUD dismissed didn't reactivate the UI unless hitting a hidden button; empty-space clicks re-dismissed the UI in the same frame.
|
||||
- **Resolution 3:** Recorded \wasHudDismissed\ at frame start and guarded dock interactions with \!wasHudDismissed\. Trigger click cleanly un-hides the HUD without clicking hidden buttons or re-dismissing in empty space.
|
||||
- **Symptom 4:** Analog grip squeeze was overly sensitive and accidentally reactivated the dismissed UI.
|
||||
- **Resolution 4:** Ignored soft analog grip (\in.gripDown\ and \BtnGrip\); only digital full grip click stop (\in.gripFullPressDown\) un-hides the HUD.
|
||||
|
||||
## 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. Also, the keyboard centered where the Steam menu was centered when the app launched instead of where the user centered their view.
|
||||
- **Root Cause:**
|
||||
- SteamVR's virtual keyboard overlay key is `VRKeyboard_uid0` (from CEF `webhelper`), not `valve.steam.gamepadui.keyboard`. Previous lookups failed, so `MakeOverlaysInteractiveIfVisible` and `VROverlayInputMethod_Mouse` were never applied to the actual keyboard overlay.
|
||||
- `VulkanRenderer::updatePointer()` was returning early (`if (keyboardModal_.visible) return;`), which prevented raycasts to the modal plane and left `pointerActive_` false with no cursor dot.
|
||||
- `SetKeyboardTransformAbsolute` was never called with the user's current centered view orientation/position, so SteamVR defaulted to the launch standing origin.
|
||||
- **Resolution:**
|
||||
- Implemented dynamic overlay discovery across handles 1..128 and known keys (`VRKeyboard*`, `valve.steam.gamepadui.keyboard`, `system.keyboard`).
|
||||
- Configured `MakeOverlaysInteractiveIfVisible`, `VROverlayInputMethod_Mouse`, and `SendVRDiscreteScrollEvents` on the keyboard overlay.
|
||||
- Calculated world matrix in `TrackingUniverseStanding` directly from the user's HMD pose (or OpenXR `headPos` and `headForward`): placed 1.05m in front of eyes, 0.38m below eye level, tilted 28° up toward the eyes.
|
||||
- Applied transform via `SetKeyboardTransformAbsolute(vr::TrackingUniverseStanding, &kbMat)` and `SetOverlayTransformAbsolute`.
|
||||
- Updated `VulkanRenderer::updatePointer()` to raycast against the modal plane when `keyboardModal_.visible` is active, ensuring sharp laser pointer focus and a visible cursor dot.
|
||||
- Guarded `VrKeyboard::shutdown()` to prevent crash on exit.
|
||||
|
||||
- [x] **2. Controller Movement UI Dismissal Fix**
|
||||
- **Symptom:** When the user clicked "Hide UI" or clicked outside controls, any hand movement immediately reactivated the UI.
|
||||
- **Requirement:** Once dismissed, the UI must stay hidden regardless of controller movement/rotation, and only reactivate when the user explicitly presses a physical button on the controller.
|
||||
- **Resolution:**
|
||||
- In `App::run()`, when `hudDismissed_` is true, controller movement (`moveDist`) and rotation (`rotDiff`) are blocked from clearing `hudDismissed_` or setting `activity = true`.
|
||||
- Only physical button presses (`in.togglePlay`, `in.browserToggle`, `in.recenter`, `in.buttonDown[...]`, `in.gripDown`, `in.gripFullPressDown`, `in.select` outside cooldown, or thumbstick movement) reactivate the HUD.
|
||||
|
||||
- [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.
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -25,6 +25,27 @@ End users do not need to build anything or use devkits. Choose whichever option
|
||||
|
||||
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
|
||||
# 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
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
+554
-49
@@ -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,15 @@ 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();
|
||||
VrKeyboard::instance().hide();
|
||||
actionMenu_.visible = false;
|
||||
smbModal_.active = false;
|
||||
smbModal_.editingField = SmbModalState::Field::None;
|
||||
browser_.setOpen(false);
|
||||
downloadsWindow_ = false;
|
||||
pendingDl_.clear();
|
||||
|
||||
// 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 +549,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 +557,209 @@ 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;
|
||||
smbModal_.editingField = SmbModalState::Field::None;
|
||||
VrKeyboard::instance().hide();
|
||||
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;
|
||||
smbModal_.editingField = SmbModalState::Field::None;
|
||||
VrKeyboard::instance().hide();
|
||||
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 +808,58 @@ 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(),
|
||||
kbOpen ? VrKeyboard::instance().transform() : nullptr);
|
||||
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 +906,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 +1034,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 +1046,9 @@ 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 bool wasHudDismissed = hudDismissed_;
|
||||
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 +1069,47 @@ 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_) {
|
||||
// Controller movement MUST NEVER un-hide the HUD when dismissed.
|
||||
// Keep activity anchors updated so they don't accumulate delta.
|
||||
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);
|
||||
// Only digital full grip press (gripFullPressDown) or physical buttons wake the HUD.
|
||||
// Soft analog squeeze (gripDown / BtnGrip) is ignored to prevent accidental wake-up from resting hands.
|
||||
bool anyButtonPressed = (in.togglePlay || in.browserToggle || in.recenter ||
|
||||
in.gripFullPressDown[0] || in.gripFullPressDown[1] ||
|
||||
stickMoved);
|
||||
for (int b = 0; b < XrInputState::BtnCount; ++b) {
|
||||
if (b == XrInputState::BtnGrip) continue;
|
||||
if (in.buttonDown[b]) {
|
||||
anyButtonPressed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!inDismissCooldown && in.select) {
|
||||
anyButtonPressed = true;
|
||||
}
|
||||
|
||||
if (anyButtonPressed) {
|
||||
activity = true;
|
||||
if (!inDismissCooldown) {
|
||||
hudDismissed_ = false;
|
||||
}
|
||||
} else if (!hudDismissed_ && (in.select || in.selectHeld)) {
|
||||
activity = true;
|
||||
}
|
||||
if (activity) {
|
||||
@@ -828,12 +1126,21 @@ int App::run() {
|
||||
if (in.browserToggle) {
|
||||
if (actionMenu_.visible) {
|
||||
actionMenu_.visible = false;
|
||||
smbModal_.active = false;
|
||||
smbModal_.editingField = SmbModalState::Field::None;
|
||||
VrKeyboard::instance().hide();
|
||||
} else if (downloadsWindow_) {
|
||||
downloadsWindow_ = false;
|
||||
} else if (settings_.isOpen()) {
|
||||
settings_.close();
|
||||
} else if (browser_.isOpen()) {
|
||||
browser_.setOpen(false);
|
||||
smbModal_.active = false;
|
||||
smbModal_.editingField = SmbModalState::Field::None;
|
||||
VrKeyboard::instance().hide();
|
||||
} else if (mediaOpen_ && renderer_.controlBarVisible()) {
|
||||
// HUD is visible during playback: B/Y dismisses the HUD!
|
||||
dismissHud(in, nowT);
|
||||
} else {
|
||||
browser_.open();
|
||||
}
|
||||
@@ -1108,6 +1415,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 +1459,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 +1486,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()
|
||||
@@ -1214,19 +1561,29 @@ int App::run() {
|
||||
}
|
||||
}
|
||||
const bool uiPanelActive = browserOpen || settingsOpen ||
|
||||
downloadsWindow_ || !pendingDl_.empty() || actionMenu_.visible;
|
||||
downloadsWindow_ || !pendingDl_.empty() || actionMenu_.visible || smbModal_.active;
|
||||
// The Transport HUD is only visible during playback when the
|
||||
// Unified Spatial Hub and modals are closed.
|
||||
const bool barBlocked = browserOpen || settingsOpen || downloadsWindow_ ||
|
||||
!pendingDl_.empty() || actionMenu_.visible;
|
||||
!pendingDl_.empty() || actionMenu_.visible || smbModal_.active;
|
||||
// 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) {
|
||||
if (!renderer_.laserOn(0) && !renderer_.laserOn(1)) {
|
||||
if (in.controllers[1].active) renderer_.setLaserOn(1, true);
|
||||
else if (in.controllers[0].active) renderer_.setLaserOn(0, true);
|
||||
} else if (renderer_.laserOn(1) && !in.controllers[1].active && in.controllers[0].active) {
|
||||
renderer_.setLaserOn(0, true);
|
||||
} else if (renderer_.laserOn(0) && !in.controllers[0].active && in.controllers[1].active) {
|
||||
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 +1648,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 +1663,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 +1742,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 +1902,126 @@ 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);
|
||||
|
||||
auto openKeyboardForField = [&](SmbModalState::Field field, const std::string& desc, const std::string& currentVal, bool isPass) {
|
||||
smbModal_.editingField = field;
|
||||
float stagePos[3]{0.0f, 1.65f, 0.0f};
|
||||
float stageFwd[3]{0.0f, 0.0f, -1.0f};
|
||||
const float* fwd = renderer_.screenForward();
|
||||
xr_.transformToStage(in.headPos, fwd, stagePos, stageFwd);
|
||||
VrKeyboard::instance().show(desc, currentVal, isPass, 256, in.headPos, fwd, stagePos, stageFwd);
|
||||
};
|
||||
|
||||
if (chosen == 0) {
|
||||
openKeyboardForField(SmbModalState::Field::Host, "SMB Server IP / Hostname", smbModal_.host, false);
|
||||
} else if (chosen == 1) {
|
||||
openKeyboardForField(SmbModalState::Field::Share, "SMB Share Name (empty for all)", smbModal_.share, false);
|
||||
} else if (chosen == 2) {
|
||||
smbModal_.anon = !smbModal_.anon;
|
||||
updateSmbModalMenu();
|
||||
} else if (chosen == 3) {
|
||||
openKeyboardForField(SmbModalState::Field::User, "SMB Username", smbModal_.user, false);
|
||||
} else if (chosen == 4) {
|
||||
openKeyboardForField(SmbModalState::Field::Pass, "SMB Password", smbModal_.pass, true);
|
||||
} else if (chosen == 5) {
|
||||
openKeyboardForField(SmbModalState::Field::Workgroup, "SMB Workgroup / Domain", smbModal_.workgroup, false);
|
||||
} else if (chosen == 6) {
|
||||
openKeyboardForField(SmbModalState::Field::Label, "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;
|
||||
smbModal_.editingField = SmbModalState::Field::None;
|
||||
VrKeyboard::instance().hide();
|
||||
} else {
|
||||
// Delete Share
|
||||
deleteSmbModal();
|
||||
}
|
||||
} else if (chosen == 9) {
|
||||
// Cancel
|
||||
actionMenu_.visible = false;
|
||||
smbModal_.active = false;
|
||||
smbModal_.editingField = SmbModalState::Field::None;
|
||||
VrKeyboard::instance().hide();
|
||||
}
|
||||
}
|
||||
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 +2033,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 +2045,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 +2134,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() && !wasHudDismissed) {
|
||||
// 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 +2226,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 && !renderer_.pointerActive()) {
|
||||
dismissHud(in, nowT);
|
||||
interacted = true;
|
||||
}
|
||||
if (in.select && renderer_.pointerOnTimeToggle()) {
|
||||
renderer_.toggleTimeDisplay(); // total <-> time remaining
|
||||
interacted = true;
|
||||
@@ -1796,7 +2300,7 @@ int App::run() {
|
||||
// hardware decoder (a reopen each) and stalls it; one seek per
|
||||
// gesture stays responsive.
|
||||
bool scrubInput = false;
|
||||
if (renderer_.pointerOnTimeline() && in.selectHeld &&
|
||||
if (!wasHudDismissed && renderer_.pointerOnTimeline() && in.selectHeld &&
|
||||
media_.duration() > 0.0) {
|
||||
scrubTarget_ = renderer_.pointerScrub() * media_.duration();
|
||||
scrubInput = true;
|
||||
@@ -1927,6 +2431,7 @@ int App::run() {
|
||||
if (renderer_.device() != VK_NULL_HANDLE) {
|
||||
vkDeviceWaitIdle(renderer_.device());
|
||||
}
|
||||
VrKeyboard::instance().shutdown();
|
||||
renderModels_.shutdown(); // destroy XR model handles before the session
|
||||
PIXELVR_LOG_INFO("Shutting down");
|
||||
return 0;
|
||||
|
||||
@@ -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_;
|
||||
|
||||
+672
-183
File diff suppressed because it is too large.
Load diff
@@ -4,6 +4,7 @@
|
||||
#include "xr/openxr_headers.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -95,6 +96,8 @@ public:
|
||||
// Repositions the flat screen in front of the given head pose (headForward is
|
||||
// a world-space direction); resets the viewing distance to the default.
|
||||
void recenterScreen(const float headPos[3], const float headForward[3]);
|
||||
const float* screenPivot() const { return screenPivot_; }
|
||||
const float* screenForward() const { return screenForward_; }
|
||||
// Moves the flat screen farther (positive) or nearer (negative), clamped.
|
||||
void adjustScreenDistance(float delta);
|
||||
// Parametric curvature for cinema screen: 0.0 = flat plane, 1.0 = full cylinder wrap.
|
||||
@@ -124,6 +127,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 +169,53 @@ 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;
|
||||
bool hasTransform = false;
|
||||
float transform[3][4]{};
|
||||
};
|
||||
void setKeyboardModal(bool visible, const std::string& prompt,
|
||||
const std::string& text, bool isPassword = false,
|
||||
const float* transform = nullptr) {
|
||||
keyboardModal_.visible = visible;
|
||||
keyboardModal_.prompt = prompt;
|
||||
keyboardModal_.text = text;
|
||||
keyboardModal_.isPassword = isPassword;
|
||||
if (transform && visible) {
|
||||
std::memcpy(keyboardModal_.transform, transform, sizeof(keyboardModal_.transform));
|
||||
keyboardModal_.hasTransform = true;
|
||||
} else {
|
||||
keyboardModal_.hasTransform = false;
|
||||
}
|
||||
}
|
||||
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 +329,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 +508,17 @@ private:
|
||||
std::string path;
|
||||
bool ready = false;
|
||||
};
|
||||
CardThumbnail cardThumbs_[6];
|
||||
CardThumbnail cardThumbs_[12];
|
||||
VkPipeline thumbPipeline_ = VK_NULL_HANDLE;
|
||||
VkDescriptorPool thumbPool_ = VK_NULL_HANDLE;
|
||||
BrowserViewMode browserViewMode_ = BrowserViewMode::BigCards;
|
||||
bool browserIsQuickPath_ = false;
|
||||
KeyboardModalData keyboardModal_{};
|
||||
int uiFollowMode_ = 0; // 0 = World Lock (Default, zero drift/tilt), 1 = Smooth Gaze Follow
|
||||
bool listShowFormat_ = true;
|
||||
bool listShowRes_ = true;
|
||||
bool listShowSize_ = true;
|
||||
bool listShowDuration_ = true;
|
||||
|
||||
// Text rendering (FreeType glyph atlas) + file browser overlay.
|
||||
FontAtlas fontAtlas_;
|
||||
|
||||
@@ -43,13 +43,31 @@ extern "C" {
|
||||
#include <cerrno>
|
||||
#endif
|
||||
|
||||
#include "net/smb_credentials.hpp"
|
||||
|
||||
namespace pixelvr {
|
||||
|
||||
#ifdef PIXELVR_HAVE_SMB
|
||||
namespace {
|
||||
|
||||
void smb_auth(SMBCCTX*, const char*, const char*, char*, int, char* un, int unlen,
|
||||
void smb_auth(SMBCCTX*, const char* srv, const char* shr, char* wg, int wglen, char* un, int unlen,
|
||||
char* pw, int pwlen) {
|
||||
std::string u, p, w;
|
||||
if (srv != nullptr && SmbCredentialsManager::instance().findCredentials(srv, shr ? shr : "", u, p, w)) {
|
||||
if (un && unlen > 0) {
|
||||
std::strncpy(un, u.c_str(), unlen - 1);
|
||||
un[unlen - 1] = '\0';
|
||||
}
|
||||
if (pw && pwlen > 0) {
|
||||
std::strncpy(pw, p.c_str(), pwlen - 1);
|
||||
pw[pwlen - 1] = '\0';
|
||||
}
|
||||
if (wg && wglen > 0 && !w.empty()) {
|
||||
std::strncpy(wg, w.c_str(), wglen - 1);
|
||||
wg[wglen - 1] = '\0';
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (un != nullptr && unlen > 0) un[0] = '\0'; // guest / anonymous
|
||||
if (pw != nullptr && pwlen > 0) pw[0] = '\0';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
#include "smb_credentials.hpp"
|
||||
#include "util/logging.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iomanip>
|
||||
#include <random>
|
||||
#include <sstream>
|
||||
|
||||
#if defined(__linux__) || defined(__APPLE__)
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
namespace pixelvr {
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace {
|
||||
|
||||
// Clean portable ChaCha20 stream cipher implementation
|
||||
// RFC 7539 / 8439
|
||||
static uint32_t rotl32(uint32_t x, int n) {
|
||||
return (x << n) | (x >> (32 - n));
|
||||
}
|
||||
|
||||
#define CHACHA20_QUARTERROUND(a, b, c, d) \
|
||||
a += b; d ^= a; d = rotl32(d, 16); \
|
||||
c += d; b ^= c; b = rotl32(b, 12); \
|
||||
a += b; d ^= a; d = rotl32(d, 8); \
|
||||
c += d; b ^= c; b = rotl32(b, 7);
|
||||
|
||||
static void chacha20_block(uint32_t out[16], const uint32_t in[16]) {
|
||||
for (int i = 0; i < 16; ++i) out[i] = in[i];
|
||||
for (int i = 0; i < 10; ++i) {
|
||||
CHACHA20_QUARTERROUND(out[0], out[4], out[8], out[12]);
|
||||
CHACHA20_QUARTERROUND(out[1], out[5], out[9], out[13]);
|
||||
CHACHA20_QUARTERROUND(out[2], out[6], out[10], out[14]);
|
||||
CHACHA20_QUARTERROUND(out[3], out[7], out[11], out[15]);
|
||||
CHACHA20_QUARTERROUND(out[0], out[5], out[10], out[15]);
|
||||
CHACHA20_QUARTERROUND(out[1], out[6], out[11], out[12]);
|
||||
CHACHA20_QUARTERROUND(out[2], out[7], out[8], out[13]);
|
||||
CHACHA20_QUARTERROUND(out[3], out[4], out[9], out[14]);
|
||||
}
|
||||
for (int i = 0; i < 16; ++i) out[i] += in[i];
|
||||
}
|
||||
|
||||
static void chacha20_crypt(const uint8_t key[32], const uint8_t nonce[12], uint32_t counter,
|
||||
const uint8_t* in, uint8_t* out, std::size_t len) {
|
||||
uint32_t state[16];
|
||||
// Constants "expand 32-byte k"
|
||||
state[0] = 0x61707865;
|
||||
state[1] = 0x3320646e;
|
||||
state[2] = 0x79622d32;
|
||||
state[3] = 0x6b206574;
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
state[4 + i] = (static_cast<uint32_t>(key[i * 4 + 0])) |
|
||||
(static_cast<uint32_t>(key[i * 4 + 1]) << 8) |
|
||||
(static_cast<uint32_t>(key[i * 4 + 2]) << 16) |
|
||||
(static_cast<uint32_t>(key[i * 4 + 3]) << 24);
|
||||
}
|
||||
state[12] = counter;
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
state[13 + i] = (static_cast<uint32_t>(nonce[i * 4 + 0])) |
|
||||
(static_cast<uint32_t>(nonce[i * 4 + 1]) << 8) |
|
||||
(static_cast<uint32_t>(nonce[i * 4 + 2]) << 16) |
|
||||
(static_cast<uint32_t>(nonce[i * 4 + 3]) << 24);
|
||||
}
|
||||
|
||||
uint32_t block[16];
|
||||
uint8_t keyStream[64];
|
||||
std::size_t offset = 0;
|
||||
while (offset < len) {
|
||||
chacha20_block(block, state);
|
||||
for (int i = 0; i < 16; ++i) {
|
||||
keyStream[i * 4 + 0] = static_cast<uint8_t>(block[i] & 0xFF);
|
||||
keyStream[i * 4 + 1] = static_cast<uint8_t>((block[i] >> 8) & 0xFF);
|
||||
keyStream[i * 4 + 2] = static_cast<uint8_t>((block[i] >> 16) & 0xFF);
|
||||
keyStream[i * 4 + 3] = static_cast<uint8_t>((block[i] >> 24) & 0xFF);
|
||||
}
|
||||
state[12]++;
|
||||
const std::size_t chunk = std::min<std::size_t>(64, len - offset);
|
||||
for (std::size_t i = 0; i < chunk; ++i) {
|
||||
out[offset + i] = in[offset + i] ^ keyStream[i];
|
||||
}
|
||||
offset += chunk;
|
||||
}
|
||||
}
|
||||
|
||||
static std::string toHex(const uint8_t* data, std::size_t len) {
|
||||
std::ostringstream oss;
|
||||
oss << std::hex << std::setfill('0');
|
||||
for (std::size_t i = 0; i < len; ++i) {
|
||||
oss << std::setw(2) << static_cast<int>(data[i]);
|
||||
}
|
||||
return oss.str();
|
||||
}
|
||||
|
||||
static std::vector<uint8_t> fromHex(const std::string& hex) {
|
||||
std::vector<uint8_t> bytes;
|
||||
if (hex.length() % 2 != 0) return bytes;
|
||||
bytes.reserve(hex.length() / 2);
|
||||
for (std::size_t i = 0; i < hex.length(); i += 2) {
|
||||
const std::string byteStr = hex.substr(i, 2);
|
||||
char* end = nullptr;
|
||||
unsigned long b = std::strtoul(byteStr.c_str(), &end, 16);
|
||||
if (end == byteStr.c_str() + 2) {
|
||||
bytes.push_back(static_cast<uint8_t>(b));
|
||||
} else {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
static std::string trim(const std::string& s) {
|
||||
auto start = s.find_first_not_of(" \t\r\n");
|
||||
if (start == std::string::npos) return "";
|
||||
auto end = s.find_last_not_of(" \t\r\n");
|
||||
return s.substr(start, end - start + 1);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
SmbCredentialsManager& SmbCredentialsManager::instance() {
|
||||
static SmbCredentialsManager sInstance;
|
||||
return sInstance;
|
||||
}
|
||||
|
||||
SmbCredentialsManager::SmbCredentialsManager() {
|
||||
load();
|
||||
}
|
||||
|
||||
std::string SmbCredentialsManager::getStoragePath() const {
|
||||
#ifdef _WIN32
|
||||
const char* appData = std::getenv("APPDATA");
|
||||
if (appData) return std::string(appData) + "/PixelVR/smb_shares.json";
|
||||
#else
|
||||
const char* home = std::getenv("HOME");
|
||||
if (home) return std::string(home) + "/.config/pixelvr/smb_shares.json";
|
||||
#endif
|
||||
return "pixelvr_smb_shares.json";
|
||||
}
|
||||
|
||||
std::string SmbCredentialsManager::getDeviceKey() const {
|
||||
std::string seed = "PixelVR-Frame-SMB-2026-Salt!";
|
||||
#if defined(__linux__)
|
||||
std::ifstream mid("/etc/machine-id");
|
||||
if (mid.is_open()) {
|
||||
std::string id;
|
||||
std::getline(mid, id);
|
||||
id = trim(id);
|
||||
if (!id.empty()) {
|
||||
seed += ":" + id;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
const char* user = std::getenv("USER");
|
||||
if (user) seed += ":" + std::string(user);
|
||||
|
||||
// Derive 32-byte key using double FNV-1a / DJB2 mixing
|
||||
uint64_t h1 = 14695981039346656037ULL;
|
||||
uint64_t h2 = 5381;
|
||||
for (char c : seed) {
|
||||
h1 = (h1 ^ static_cast<uint8_t>(c)) * 1099511628211ULL;
|
||||
h2 = ((h2 << 5) + h2) + static_cast<uint8_t>(c);
|
||||
}
|
||||
uint8_t keyBytes[32];
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
keyBytes[i + 0] = static_cast<uint8_t>((h1 >> (i * 8)) & 0xFF);
|
||||
keyBytes[i + 8] = static_cast<uint8_t>((h2 >> (i * 8)) & 0xFF);
|
||||
keyBytes[i + 16] = static_cast<uint8_t>(((h1 ^ h2) >> (i * 8)) & 0xFF);
|
||||
keyBytes[i + 24] = static_cast<uint8_t>(((h1 + h2) >> (i * 8)) & 0xFF);
|
||||
}
|
||||
return std::string(reinterpret_cast<const char*>(keyBytes), 32);
|
||||
}
|
||||
|
||||
std::string SmbCredentialsManager::encryptPassword(const std::string& plain,
|
||||
const std::string& key,
|
||||
std::string& outIvHex) const {
|
||||
if (plain.empty()) {
|
||||
outIvHex.clear();
|
||||
return "";
|
||||
}
|
||||
uint8_t nonce[12];
|
||||
std::random_device rd;
|
||||
for (int i = 0; i < 12; ++i) {
|
||||
nonce[i] = static_cast<uint8_t>(rd() & 0xFF);
|
||||
}
|
||||
outIvHex = toHex(nonce, 12);
|
||||
|
||||
std::vector<uint8_t> cipher(plain.length());
|
||||
chacha20_crypt(reinterpret_cast<const uint8_t*>(key.data()), nonce, 1,
|
||||
reinterpret_cast<const uint8_t*>(plain.data()), cipher.data(), plain.length());
|
||||
return toHex(cipher.data(), cipher.size());
|
||||
}
|
||||
|
||||
std::string SmbCredentialsManager::decryptPassword(const std::string& cipherHex,
|
||||
const std::string& key,
|
||||
const std::string& ivHex) const {
|
||||
if (cipherHex.empty() || ivHex.empty()) return "";
|
||||
std::vector<uint8_t> cipher = fromHex(cipherHex);
|
||||
std::vector<uint8_t> nonce = fromHex(ivHex);
|
||||
if (cipher.empty() || nonce.size() != 12) return "";
|
||||
|
||||
std::string plain(cipher.size(), '\0');
|
||||
chacha20_crypt(reinterpret_cast<const uint8_t*>(key.data()), nonce.data(), 1,
|
||||
cipher.data(), reinterpret_cast<uint8_t*>(plain.data()), cipher.size());
|
||||
return plain;
|
||||
}
|
||||
|
||||
void SmbCredentialsManager::load() {
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
shares_.clear();
|
||||
|
||||
const std::string path = getStoragePath();
|
||||
std::ifstream f(path);
|
||||
if (!f.is_open()) {
|
||||
return;
|
||||
}
|
||||
|
||||
std::string content((std::istreambuf_iterator<char>(f)),
|
||||
std::istreambuf_iterator<char>());
|
||||
f.close();
|
||||
|
||||
const std::string key = getDeviceKey();
|
||||
|
||||
// Lightweight robust JSON parser for share objects
|
||||
std::size_t pos = 0;
|
||||
while ((pos = content.find('{', pos)) != std::string::npos) {
|
||||
std::size_t endObj = content.find('}', pos);
|
||||
if (endObj == std::string::npos) break;
|
||||
|
||||
std::string obj = content.substr(pos, endObj - pos + 1);
|
||||
pos = endObj + 1;
|
||||
|
||||
auto extractField = [&](const std::string& keyName) -> std::string {
|
||||
const std::string pattern = "\"" + keyName + "\"";
|
||||
std::size_t kpos = obj.find(pattern);
|
||||
if (kpos == std::string::npos) return "";
|
||||
std::size_t colon = obj.find(':', kpos + pattern.length());
|
||||
if (colon == std::string::npos) return "";
|
||||
std::size_t startVal = obj.find_first_not_of(" \t\r\n", colon + 1);
|
||||
if (startVal == std::string::npos) return "";
|
||||
if (obj[startVal] == '\"') {
|
||||
std::size_t endQuote = obj.find('\"', startVal + 1);
|
||||
if (endQuote != std::string::npos) {
|
||||
return obj.substr(startVal + 1, endQuote - startVal - 1);
|
||||
}
|
||||
} else {
|
||||
std::size_t endVal = obj.find_first_of(",}\r\n", startVal);
|
||||
return trim(obj.substr(startVal, endVal - startVal));
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
SmbShare s;
|
||||
s.id = extractField("id");
|
||||
s.name = extractField("name");
|
||||
s.host = extractField("host");
|
||||
s.share = extractField("share");
|
||||
s.workgroup = extractField("workgroup");
|
||||
if (s.workgroup.empty()) s.workgroup = "WORKGROUP";
|
||||
s.username = extractField("username");
|
||||
s.anonymous = (extractField("anonymous") == "true");
|
||||
|
||||
std::string passCipher = extractField("password_enc");
|
||||
std::string passIv = extractField("iv");
|
||||
if (!passCipher.empty() && !passIv.empty()) {
|
||||
s.password = decryptPassword(passCipher, key, passIv);
|
||||
}
|
||||
|
||||
if (!s.host.empty()) {
|
||||
if (s.name.empty()) {
|
||||
s.name = s.host + (!s.share.empty() ? ("/" + s.share) : "");
|
||||
}
|
||||
if (s.id.empty()) {
|
||||
s.id = s.host + (!s.share.empty() ? ("/" + s.share) : "");
|
||||
}
|
||||
shares_.push_back(s);
|
||||
}
|
||||
}
|
||||
|
||||
PIXELVR_LOG_INFO("SmbCredentialsManager: Loaded %zu configured shares from %s",
|
||||
shares_.size(), path.c_str());
|
||||
}
|
||||
|
||||
void SmbCredentialsManager::save() {
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
const std::string path = getStoragePath();
|
||||
|
||||
std::error_code ec;
|
||||
fs::path p(path);
|
||||
fs::create_directories(p.parent_path(), ec);
|
||||
|
||||
#if defined(__linux__) || defined(__APPLE__)
|
||||
// Ensure configuration directory is protected (0700)
|
||||
chmod(p.parent_path().string().c_str(), 0700);
|
||||
#endif
|
||||
|
||||
const std::string key = getDeviceKey();
|
||||
|
||||
std::ostringstream json;
|
||||
json << "[\n";
|
||||
for (std::size_t i = 0; i < shares_.size(); ++i) {
|
||||
const auto& s = shares_[i];
|
||||
std::string ivHex;
|
||||
std::string encPass = encryptPassword(s.password, key, ivHex);
|
||||
|
||||
json << " {\n";
|
||||
json << " \"id\": \"" << s.id << "\",\n";
|
||||
json << " \"name\": \"" << s.name << "\",\n";
|
||||
json << " \"host\": \"" << s.host << "\",\n";
|
||||
json << " \"share\": \"" << s.share << "\",\n";
|
||||
json << " \"workgroup\": \"" << s.workgroup << "\",\n";
|
||||
json << " \"username\": \"" << s.username << "\",\n";
|
||||
json << " \"password_enc\": \"" << encPass << "\",\n";
|
||||
json << " \"iv\": \"" << ivHex << "\",\n";
|
||||
json << " \"anonymous\": " << (s.anonymous ? "true" : "false") << "\n";
|
||||
json << " }" << (i + 1 < shares_.size() ? ",\n" : "\n");
|
||||
}
|
||||
json << "]\n";
|
||||
|
||||
std::ofstream f(path, std::ios::trunc);
|
||||
if (f.is_open()) {
|
||||
f << json.str();
|
||||
f.close();
|
||||
|
||||
#if defined(__linux__) || defined(__APPLE__)
|
||||
// Enforce strict OS-level permissions: readable & writable ONLY by steamos (0600)
|
||||
chmod(path.c_str(), 0600);
|
||||
#endif
|
||||
PIXELVR_LOG_INFO("SmbCredentialsManager: Saved %zu shares to %s (mode 0600)",
|
||||
shares_.size(), path.c_str());
|
||||
} else {
|
||||
PIXELVR_LOG_WARN("SmbCredentialsManager: Failed to write %s", path.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<SmbShare> SmbCredentialsManager::shares() const {
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
return shares_;
|
||||
}
|
||||
|
||||
void SmbCredentialsManager::addOrUpdateShare(const SmbShare& share) {
|
||||
if (share.host.empty()) return;
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
bool found = false;
|
||||
for (auto& s : shares_) {
|
||||
if (s.host == share.host && s.share == share.share) {
|
||||
s = share;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
shares_.push_back(share);
|
||||
}
|
||||
}
|
||||
save();
|
||||
}
|
||||
|
||||
bool SmbCredentialsManager::deleteShare(const std::string& host, const std::string& share) {
|
||||
bool removed = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
auto it = std::remove_if(shares_.begin(), shares_.end(), [&](const SmbShare& s) {
|
||||
if (s.host == host) {
|
||||
if (share.empty() || s.share == share || s.share.empty()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (it != shares_.end()) {
|
||||
shares_.erase(it, shares_.end());
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
if (removed) {
|
||||
save();
|
||||
PIXELVR_LOG_INFO("SmbCredentialsManager: Deleted share for host '%s'", host.c_str());
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
bool SmbCredentialsManager::findShare(const std::string& hostIn,
|
||||
const std::string& shareIn,
|
||||
SmbShare& outShare) const {
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
if (hostIn.empty()) return false;
|
||||
|
||||
std::string host = hostIn;
|
||||
std::transform(host.begin(), host.end(), host.begin(), ::tolower);
|
||||
std::string share = shareIn;
|
||||
std::transform(share.begin(), share.end(), share.begin(), ::tolower);
|
||||
|
||||
for (const auto& s : shares_) {
|
||||
std::string sHost = s.host;
|
||||
std::transform(sHost.begin(), sHost.end(), sHost.begin(), ::tolower);
|
||||
if (sHost == host) {
|
||||
std::string sShare = s.share;
|
||||
std::transform(sShare.begin(), sShare.end(), sShare.begin(), ::tolower);
|
||||
if (sShare == share || (share.empty() && sShare.empty())) {
|
||||
outShare = s;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool SmbCredentialsManager::findCredentials(const std::string& hostIn,
|
||||
const std::string& shareIn,
|
||||
std::string& outUser,
|
||||
std::string& outPass,
|
||||
std::string& outWorkgroup) const {
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
if (hostIn.empty()) return false;
|
||||
|
||||
std::string host = hostIn;
|
||||
std::transform(host.begin(), host.end(), host.begin(), ::tolower);
|
||||
std::string share = shareIn;
|
||||
std::transform(share.begin(), share.end(), share.begin(), ::tolower);
|
||||
|
||||
const SmbShare* bestMatch = nullptr;
|
||||
for (const auto& s : shares_) {
|
||||
std::string sHost = s.host;
|
||||
std::transform(sHost.begin(), sHost.end(), sHost.begin(), ::tolower);
|
||||
if (sHost == host) {
|
||||
std::string sShare = s.share;
|
||||
std::transform(sShare.begin(), sShare.end(), sShare.begin(), ::tolower);
|
||||
if (!share.empty() && sShare == share) {
|
||||
bestMatch = &s; // Exact share match takes highest priority
|
||||
break;
|
||||
}
|
||||
if (sShare.empty() && !bestMatch) {
|
||||
bestMatch = &s; // Host-level wildcard match
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestMatch && !bestMatch->anonymous) {
|
||||
outUser = bestMatch->username;
|
||||
outPass = bestMatch->password;
|
||||
outWorkgroup = bestMatch->workgroup.empty() ? "WORKGROUP" : bestMatch->workgroup;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string SmbCredentialsManager::getAuthFilePath(const std::string& host, const std::string& share) {
|
||||
std::string user, pass, wg;
|
||||
if (!findCredentials(host, share, user, pass, wg)) {
|
||||
return "";
|
||||
}
|
||||
if (user.empty()) return "";
|
||||
|
||||
std::string basePath = getStoragePath();
|
||||
fs::path p(basePath);
|
||||
std::string safeHost = host;
|
||||
for (char& c : safeHost) {
|
||||
if (!isalnum(c)) c = '_';
|
||||
}
|
||||
fs::path authFile = p.parent_path() / ("auth_" + safeHost + ".tmp");
|
||||
|
||||
std::ofstream f(authFile.string(), std::ios::trunc);
|
||||
if (!f.is_open()) return "";
|
||||
|
||||
f << "username = " << user << "\n";
|
||||
f << "password = " << pass << "\n";
|
||||
f << "domain = " << (wg.empty() ? "WORKGROUP" : wg) << "\n";
|
||||
f.close();
|
||||
|
||||
#if defined(__linux__) || defined(__APPLE__)
|
||||
chmod(authFile.string().c_str(), 0600);
|
||||
#endif
|
||||
return authFile.string();
|
||||
}
|
||||
|
||||
} // namespace pixelvr
|
||||
@@ -0,0 +1,69 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <mutex>
|
||||
|
||||
namespace pixelvr {
|
||||
|
||||
struct SmbShare {
|
||||
std::string id; // Unique identifier (typically host or host:share)
|
||||
std::string name; // User-friendly display label (e.g. "Home NAS")
|
||||
std::string host; // IP address or hostname (e.g. "192.168.1.50")
|
||||
std::string share; // Optional default share name (e.g. "movies", or empty for all)
|
||||
std::string workgroup; // Workgroup/Domain (defaults to "WORKGROUP")
|
||||
std::string username; // SMB Username (or empty for anonymous)
|
||||
std::string password; // SMB Password (decrypted in memory, encrypted on disk)
|
||||
bool anonymous = false; // True for guest / public shares
|
||||
};
|
||||
|
||||
class SmbCredentialsManager {
|
||||
public:
|
||||
static SmbCredentialsManager& instance();
|
||||
|
||||
// Loads configured shares from ~/.config/pixelvr/smb_shares.json
|
||||
void load();
|
||||
|
||||
// Saves configured shares to ~/.config/pixelvr/smb_shares.json with strict 0600 permissions
|
||||
void save();
|
||||
|
||||
// Returns all configured SMB shares
|
||||
std::vector<SmbShare> shares() const;
|
||||
|
||||
// Adds a new share or updates an existing one (matched by host + share)
|
||||
void addOrUpdateShare(const SmbShare& share);
|
||||
|
||||
// Deletes a configured share and wipes its stored credentials
|
||||
bool deleteShare(const std::string& host, const std::string& share = "");
|
||||
|
||||
// Lookup a configured share
|
||||
bool findShare(const std::string& host,
|
||||
const std::string& share,
|
||||
SmbShare& outShare) const;
|
||||
|
||||
// Lookup credentials for a given host and optional share
|
||||
bool findCredentials(const std::string& host,
|
||||
const std::string& share,
|
||||
std::string& outUser,
|
||||
std::string& outPass,
|
||||
std::string& outWorkgroup) const;
|
||||
|
||||
// Generates or updates a secure temporary Samba auth file (mode 0600) for smbclient -A
|
||||
// Returns the absolute file path, or empty string if anonymous / no credentials.
|
||||
std::string getAuthFilePath(const std::string& host, const std::string& share);
|
||||
|
||||
private:
|
||||
SmbCredentialsManager();
|
||||
~SmbCredentialsManager() = default;
|
||||
|
||||
std::string getStoragePath() const;
|
||||
std::string getDeviceKey() const;
|
||||
|
||||
std::string encryptPassword(const std::string& plain, const std::string& key, std::string& outIv) const;
|
||||
std::string decryptPassword(const std::string& cipherHex, const std::string& key, const std::string& ivHex) const;
|
||||
|
||||
mutable std::mutex mutex_;
|
||||
std::vector<SmbShare> shares_;
|
||||
};
|
||||
|
||||
} // namespace pixelvr
|
||||
+232
-13
@@ -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,158 @@ 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 isVid = is_video(bm.path);
|
||||
const bool isDir = isSmb ? !isVid : 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 ? (isVid ? "SMB Video" : "SMB Share") : (isDir ? "Folder" : "Video File");
|
||||
std::string previewPath = isDir ? "" : bm.path;
|
||||
out.entries.push_back({label, bm.path, isDir, isVid, false, false, detail, 0, previewPath});
|
||||
}
|
||||
}
|
||||
|
||||
bool Browser::inVault() const {
|
||||
return VaultManager::instance().isVaultPath(location_);
|
||||
}
|
||||
@@ -519,6 +713,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 +738,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 +762,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 +788,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 +998,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 +1007,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
@@ -26,7 +26,7 @@ public:
|
||||
long long size = 0; // bytes (0 if unknown)
|
||||
std::string previewPath; // video file inside folder or self (for thumbnail)
|
||||
};
|
||||
enum class Result { None, Navigated, Play };
|
||||
enum class Result { None, Navigated, Play, AddSmbShare };
|
||||
|
||||
Browser();
|
||||
|
||||
@@ -42,12 +42,31 @@ public:
|
||||
void openDownloads();
|
||||
void openDrives();
|
||||
void openVault();
|
||||
struct Bookmark {
|
||||
std::string path;
|
||||
std::string label;
|
||||
};
|
||||
void openBookmarks();
|
||||
void addBookmark(const std::string& path, const std::string& label = "");
|
||||
void removeBookmark(const std::string& path);
|
||||
bool isBookmarked(const std::string& path) const;
|
||||
bool isCurrentBookmarked() const { return isBookmarked(location_); }
|
||||
const std::vector<Bookmark>& bookmarks() const { return bookmarks_; }
|
||||
void loadBookmarks();
|
||||
void saveBookmarks() const;
|
||||
bool onBookmarks() const { return location_ == "bookmarks://"; }
|
||||
|
||||
void openQuickPath() { openBookmarks(); }
|
||||
void setQuickPath(const std::string& path) { addBookmark(path); }
|
||||
const std::string& quickPath() const { return quickPath_; }
|
||||
bool isCurrentQuickPath() const { return isCurrentBookmarked(); }
|
||||
bool inVault() const;
|
||||
bool moveFocusedToVault(std::string& outNewPath);
|
||||
bool moveFocusedTo(const std::string& destDir, std::string& outNewPath);
|
||||
bool deleteFocused(std::string& deletedName);
|
||||
bool renameFocused(const std::string& newName);
|
||||
const std::string& currentLocation() const { return location_; }
|
||||
const std::string& currentPath() const { return location_; }
|
||||
std::string parentDir() const;
|
||||
std::string homeDir() const;
|
||||
std::string videosDir() const;
|
||||
@@ -164,6 +183,7 @@ private:
|
||||
bool hasPendingScan_ = false;
|
||||
void scanLocalInto(const std::string& dir, ScanResult& out);
|
||||
void scanSmbInto(const std::string& uri, ScanResult& out);
|
||||
void scanBookmarksInto(ScanResult& out);
|
||||
void startDownloadFile(const std::string& uri, const std::string& local);
|
||||
void startDownloadFolder(const std::string& uri, const std::string& name);
|
||||
void startNextQueued();
|
||||
@@ -174,6 +194,8 @@ private:
|
||||
std::string status_;
|
||||
std::string location_; // current local dir or smb:// URI
|
||||
std::string homeDir_;
|
||||
std::string quickPath_; // bookmarked folder for one-button jump
|
||||
std::vector<Bookmark> bookmarks_;
|
||||
std::string lastLocation_; // last folder visited (local or smb), for re-open
|
||||
bool lastInSmb_ = false;
|
||||
std::vector<Entry> entries_;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#include "ui/settings_menu.hpp"
|
||||
#include "app/vault_manager.hpp"
|
||||
#include "net/smb_credentials.hpp"
|
||||
#include "xr/vr_keyboard.hpp"
|
||||
|
||||
#include "util/logging.hpp"
|
||||
|
||||
@@ -78,7 +80,7 @@ SettingsMenu::SettingsMenu() {
|
||||
|
||||
void SettingsMenu::open() {
|
||||
open_ = true;
|
||||
if (section_ < 0 || section_ > 3) section_ = 0;
|
||||
if (section_ < 0 || (section_ > 5 && section_ != 20)) section_ = 0;
|
||||
refresh();
|
||||
}
|
||||
|
||||
@@ -142,6 +144,17 @@ void SettingsMenu::refresh() {
|
||||
rows_.push_back({"UI Panel Distance",
|
||||
kUiDistanceNames[uiDistanceIdx_ % kUiDistanceCount],
|
||||
Action::CycleUiDistance});
|
||||
const char* followModeNames[2] = {"World Lock (Fixed)", "Smooth Gaze Follow"};
|
||||
rows_.push_back({"UI Follow Mode",
|
||||
followModeNames[uiFollowMode_ % 2],
|
||||
Action::CycleUiFollowMode});
|
||||
const char* viewModeNames[3] = {"Large Cards (3x2)", "Smaller Cards (4x3)", "Compact List (1-Col)"};
|
||||
rows_.push_back({"Browser View Mode",
|
||||
viewModeNames[browserViewMode_ % 3],
|
||||
Action::CycleBrowserViewMode});
|
||||
rows_.push_back({"Configure List Columns",
|
||||
"[ Customize Columns > ]",
|
||||
Action::EnterListColsConfig});
|
||||
rows_.push_back({"Download Folder",
|
||||
kDownloadDirs[downloadDirIdx_ % kDownloadDirCount],
|
||||
Action::CycleDownloadDir});
|
||||
@@ -183,6 +196,8 @@ void SettingsMenu::refresh() {
|
||||
Action::ListenMenu});
|
||||
rows_.push_back({"Recenter View", btnLabel(2, bindRecenterBtn_),
|
||||
Action::ListenRecenter});
|
||||
rows_.push_back({"Bookmarks Shortcut", btnLabel(4, bindQuickPathBtn_),
|
||||
Action::ListenQuickPath});
|
||||
rows_.push_back({"Select / Click", "Trigger", Action::None});
|
||||
rows_.push_back({"Controller Models", drawModels_ ? "On" : "Off",
|
||||
Action::ToggleModels});
|
||||
@@ -220,6 +235,13 @@ void SettingsMenu::refresh() {
|
||||
|
||||
rows_.push_back({"Lock Vault Now", "[ Lock & Return Home ]",
|
||||
Action::LockVaultAction});
|
||||
} else if (section_ == 21) {
|
||||
title_ = "Settings > List Columns";
|
||||
rows_.push_back({"Format / VR Badge", listShowFormat_ ? "Visible" : "Hidden", Action::ToggleListColFormat});
|
||||
rows_.push_back({"Resolution & FPS", listShowRes_ ? "Visible" : "Hidden", Action::ToggleListColRes});
|
||||
rows_.push_back({"File Size", listShowSize_ ? "Visible" : "Hidden", Action::ToggleListColSize});
|
||||
rows_.push_back({"Video Duration", listShowDuration_ ? "Visible" : "Hidden", Action::ToggleListColDuration});
|
||||
rows_.push_back({"< Back to UI & Appearance", "", Action::BackToUI});
|
||||
} else if (section_ == 10) {
|
||||
title_ = "Settings > Select Video Track";
|
||||
for (std::size_t i = 0; i < videoStreams_.size(); ++i) {
|
||||
@@ -346,6 +368,21 @@ SettingsMenu::Action SettingsMenu::activate() {
|
||||
saveConfig();
|
||||
refresh();
|
||||
break;
|
||||
case Action::CycleUiFollowMode:
|
||||
uiFollowMode_ = (uiFollowMode_ + 1) % 2;
|
||||
saveConfig();
|
||||
refresh();
|
||||
break;
|
||||
case Action::CycleBrowserViewMode:
|
||||
browserViewMode_ = (browserViewMode_ + 1) % 3;
|
||||
saveConfig();
|
||||
refresh();
|
||||
break;
|
||||
case Action::ListenQuickPath:
|
||||
listeningFunc_ = 4;
|
||||
status_ = "Press a controller button for Bookmarks shortcut";
|
||||
refresh();
|
||||
break;
|
||||
case Action::PickVideoStream:
|
||||
videoSel_ = rows_[selected_].data;
|
||||
section_ = 2;
|
||||
@@ -399,6 +436,37 @@ SettingsMenu::Action SettingsMenu::activate() {
|
||||
section_ = 0;
|
||||
refresh();
|
||||
break;
|
||||
|
||||
case Action::EnterListColsConfig:
|
||||
section_ = 21;
|
||||
selected_ = 0;
|
||||
refresh();
|
||||
break;
|
||||
case Action::ToggleListColFormat:
|
||||
listShowFormat_ = !listShowFormat_;
|
||||
refresh();
|
||||
saveConfig();
|
||||
break;
|
||||
case Action::ToggleListColRes:
|
||||
listShowRes_ = !listShowRes_;
|
||||
refresh();
|
||||
saveConfig();
|
||||
break;
|
||||
case Action::ToggleListColSize:
|
||||
listShowSize_ = !listShowSize_;
|
||||
refresh();
|
||||
saveConfig();
|
||||
break;
|
||||
case Action::ToggleListColDuration:
|
||||
listShowDuration_ = !listShowDuration_;
|
||||
refresh();
|
||||
saveConfig();
|
||||
break;
|
||||
case Action::BackToUI:
|
||||
section_ = 1;
|
||||
selected_ = 0;
|
||||
refresh();
|
||||
break;
|
||||
case Action::RecenterScreen:
|
||||
break;
|
||||
case Action::Close:
|
||||
@@ -448,6 +516,7 @@ void SettingsMenu::assignListening(int button) {
|
||||
else if (listeningFunc_ == 1) bindMenuBtn_ = b;
|
||||
else if (listeningFunc_ == 2) bindRecenterBtn_ = b;
|
||||
else if (listeningFunc_ == 3) VaultManager::instance().setMoveButton(b);
|
||||
else if (listeningFunc_ == 4) bindQuickPathBtn_ = b;
|
||||
listeningFunc_ = -1;
|
||||
status_ = "Bound to " + std::string(kButtonNames[b]);
|
||||
saveConfig();
|
||||
@@ -505,6 +574,7 @@ void SettingsMenu::loadConfig() {
|
||||
// so a saved grip binding is migrated to the new default (Bumper).
|
||||
bindRecenterBtn_ = (b == 8) ? 6 : b;
|
||||
}
|
||||
else if (key == "bind_quick_path_btn") bindQuickPathBtn_ = std::clamp(std::stoi(val), 0, kButtonCount - 1);
|
||||
else if (key == "download_dir") downloadDirIdx_ = std::clamp(std::stoi(val), 0, kDownloadDirCount - 1);
|
||||
else if (key == "audio_lang") audioLangIdx_ = std::clamp(std::stoi(val), 0, kLangCount - 1);
|
||||
else if (key == "curvature") curvatureIdx_ = std::clamp(std::stoi(val), 0, kCurvatureCount - 1);
|
||||
@@ -512,6 +582,13 @@ void SettingsMenu::loadConfig() {
|
||||
else if (key == "ui_text_color") uiTextColorIdx_ = std::clamp(std::stoi(val), 0, kUiTextColorCount - 1);
|
||||
else if (key == "ui_accent_color") uiAccentColorIdx_ = std::clamp(std::stoi(val), 0, kUiAccentColorCount - 1);
|
||||
else if (key == "ui_distance") uiDistanceIdx_ = std::clamp(std::stoi(val), 0, kUiDistanceCount - 1);
|
||||
else if (key == "ui_follow_mode") uiFollowMode_ = std::clamp(std::stoi(val), 0, 1);
|
||||
else if (key == "browser_view_mode") browserViewMode_ = std::clamp(std::stoi(val), 0, 2);
|
||||
else if (key == "list_show_format") listShowFormat_ = (val == "1" || val == "true");
|
||||
else if (key == "list_show_res") listShowRes_ = (val == "1" || val == "true");
|
||||
else if (key == "list_show_size") listShowSize_ = (val == "1" || val == "true");
|
||||
else if (key == "list_show_dur") listShowDuration_ = (val == "1" || val == "true");
|
||||
else if (key == "quick_path") quickPath_ = val;
|
||||
}
|
||||
PIXELVR_LOG_INFO("Loaded settings from %s", path.c_str());
|
||||
}
|
||||
@@ -534,6 +611,7 @@ void SettingsMenu::saveConfig() const {
|
||||
file << "bind_play_btn=" << bindPlayBtn_ << "\n";
|
||||
file << "bind_menu_btn=" << bindMenuBtn_ << "\n";
|
||||
file << "bind_recenter_btn=" << bindRecenterBtn_ << "\n";
|
||||
file << "bind_quick_path_btn=" << bindQuickPathBtn_ << "\n";
|
||||
file << "download_dir=" << downloadDirIdx_ << "\n";
|
||||
file << "audio_lang=" << audioLangIdx_ << "\n";
|
||||
file << "curvature=" << curvatureIdx_ << "\n";
|
||||
@@ -541,6 +619,15 @@ void SettingsMenu::saveConfig() const {
|
||||
file << "ui_text_color=" << uiTextColorIdx_ << "\n";
|
||||
file << "ui_accent_color=" << uiAccentColorIdx_ << "\n";
|
||||
file << "ui_distance=" << uiDistanceIdx_ << "\n";
|
||||
file << "ui_follow_mode=" << uiFollowMode_ << "\n";
|
||||
file << "browser_view_mode=" << browserViewMode_ << "\n";
|
||||
file << "list_show_format=" << (listShowFormat_ ? 1 : 0) << "\n";
|
||||
file << "list_show_res=" << (listShowRes_ ? 1 : 0) << "\n";
|
||||
file << "list_show_size=" << (listShowSize_ ? 1 : 0) << "\n";
|
||||
file << "list_show_dur=" << (listShowDuration_ ? 1 : 0) << "\n";
|
||||
if (!quickPath_.empty()) {
|
||||
file << "quick_path=" << quickPath_ << "\n";
|
||||
}
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
PIXELVR_LOG_WARN("Could not save settings: %s", e.what());
|
||||
|
||||
@@ -30,6 +30,9 @@ public:
|
||||
CycleUiTextColor,
|
||||
CycleUiAccentColor,
|
||||
CycleUiDistance,
|
||||
CycleUiFollowMode,
|
||||
CycleBrowserViewMode,
|
||||
ListenQuickPath,
|
||||
EnterVideoList,
|
||||
EnterAudioList,
|
||||
EnterLangList,
|
||||
@@ -42,6 +45,12 @@ public:
|
||||
ListenVaultMoveBtn,
|
||||
RecordVaultCombo,
|
||||
LockVaultAction,
|
||||
EnterListColsConfig,
|
||||
ToggleListColFormat,
|
||||
ToggleListColRes,
|
||||
ToggleListColSize,
|
||||
ToggleListColDuration,
|
||||
BackToUI,
|
||||
Close
|
||||
};
|
||||
|
||||
@@ -95,6 +104,13 @@ public:
|
||||
int uiTextColorIdx() const { return uiTextColorIdx_; }
|
||||
int uiAccentColorIdx() const { return uiAccentColorIdx_; }
|
||||
float uiDistance() const;
|
||||
int uiFollowMode() const { return uiFollowMode_; }
|
||||
void setUiFollowMode(int m) { uiFollowMode_ = m; saveConfig(); refresh(); }
|
||||
int browserViewMode() const { return browserViewMode_; }
|
||||
void setBrowserViewMode(int m) { browserViewMode_ = m; saveConfig(); refresh(); }
|
||||
int bindQuickPath() const { return bindQuickPathBtn_; }
|
||||
const std::string& quickPath() const { return quickPath_; }
|
||||
void setQuickPath(const std::string& p) { quickPath_ = p; saveConfig(); }
|
||||
|
||||
int section() const { return section_; }
|
||||
void setSection(int s) {
|
||||
@@ -115,6 +131,14 @@ public:
|
||||
void loadConfig();
|
||||
void saveConfig() const;
|
||||
|
||||
bool listShowFormat() const { return listShowFormat_; }
|
||||
bool listShowRes() const { return listShowRes_; }
|
||||
bool listShowSize() const { return listShowSize_; }
|
||||
bool listShowDuration() const { return listShowDuration_; }
|
||||
|
||||
void onKeyboardSubmit(const std::string& text) { (void)text; }
|
||||
void onKeyboardCancel() {}
|
||||
|
||||
private:
|
||||
bool open_ = false;
|
||||
std::string title_ = "Settings & Controller Remap";
|
||||
@@ -133,15 +157,23 @@ private:
|
||||
int bindPlayBtn_ = 0; // default A
|
||||
int bindMenuBtn_ = 1; // default B
|
||||
int bindRecenterBtn_ = 6; // default Bumper (grip toggles the laser)
|
||||
int listeningFunc_ = -1; // 0=play 1=menu 2=recenter while binding, else -1
|
||||
int bindQuickPathBtn_ = 7; // default Right Stick Click (BtnThumbR)
|
||||
int listeningFunc_ = -1; // 0=play 1=menu 2=recenter 3=move 4=quick path
|
||||
int downloadDirIdx_ = 0; // index into the download-destination presets
|
||||
int curvatureIdx_ = 0; // 0=Flat (0%), 1=Gentle (25%), 2=Cinema (50%), 3=Deep (75%), 4=IMAX (100%)
|
||||
int uiTextScaleIdx_ = 2; // 0=90%, 1=105%, 2=120% (default), 3=140%, 4=160%
|
||||
int uiTextColorIdx_ = 0; // 0=Pure White, 1=Ice Cyan, 2=Warm Cream, 3=Soft Gold, 4=Mint Green
|
||||
int uiAccentColorIdx_ = 0; // 0=Cyber Cyan, 1=Emerald, 2=Amber Gold, 3=Royal Purple, 4=Crimson
|
||||
int uiDistanceIdx_ = 1; // 0=Near (1.5m), 1=Comfort (1.9m), 2=Mid (2.3m), 3=Far (2.8m)
|
||||
int uiFollowMode_ = 0; // 0=World Lock (default, zero drift/tilt), 1=Smooth Gaze Follow
|
||||
int browserViewMode_ = 0; // 0=Large Cards (3x2), 1=Smaller Cards (4x3), 2=Compact List (1-Col)
|
||||
bool listShowFormat_ = true;
|
||||
bool listShowRes_ = true;
|
||||
bool listShowSize_ = true;
|
||||
bool listShowDuration_ = true;
|
||||
std::string quickPath_;
|
||||
|
||||
int section_ = 0; // 0=Display, 1=Controls, 2=Streams, 3=System; 10..12=sub-lists
|
||||
int section_ = 0; // 0=Display, 1=UI, 2=Audio, 3=Controls, 4=Vault; 10..12=sub-lists
|
||||
std::vector<std::pair<int, std::string>> videoStreams_; // (index, label)
|
||||
std::vector<std::pair<int, std::string>> audioStreams_;
|
||||
int videoSel_ = 0; // position within videoStreams_
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <sstream>
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
|
||||
namespace pixelvr {
|
||||
|
||||
// Validates whether a string is a complete, strictly valid IPv4 address (e.g. "192.168.1.1").
|
||||
inline bool isValidIpv4(const std::string& str) {
|
||||
if (str.empty()) return false;
|
||||
std::stringstream ss(str);
|
||||
std::string seg;
|
||||
int count = 0;
|
||||
while (std::getline(ss, seg, '.')) {
|
||||
++count;
|
||||
if (seg.empty() || seg.length() > 3) return false;
|
||||
for (char c : seg) {
|
||||
if (!std::isdigit(static_cast<unsigned char>(c))) return false;
|
||||
}
|
||||
// Leading zeros not allowed if more than 1 digit (e.g. "01" is invalid, "0" is valid)
|
||||
if (seg.length() > 1 && seg[0] == '0') return false;
|
||||
try {
|
||||
int val = std::stoi(seg);
|
||||
if (val < 0 || val > 255) return false;
|
||||
} catch (...) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Must have exactly 4 octets and not end with a trailing dot
|
||||
return (count == 4 && str.back() != '.');
|
||||
}
|
||||
|
||||
// Sanitizes and formats an in-progress or finalized IPv4 input:
|
||||
// - Only digits '0'..'9' and '.' are accepted; any other characters are discarded.
|
||||
// - At most 4 sections (octets).
|
||||
// - Each section has leading zeros dropped (e.g. "001" -> "1", "050" -> "50", "00" -> "0").
|
||||
// - Each section is clamped to max 255.
|
||||
// - If !finalized, a trailing dot is preserved while typing (e.g. "192.168.").
|
||||
// - If finalized, any trailing dot is trimmed and empty trailing sections are removed.
|
||||
inline std::string sanitizeIpv4(const std::string& input, bool finalized = false) {
|
||||
if (input.empty()) return "";
|
||||
|
||||
std::vector<std::string> sections;
|
||||
std::string current;
|
||||
bool trailingDot = false;
|
||||
|
||||
for (char c : input) {
|
||||
if (c == '.') {
|
||||
if (!current.empty() && sections.size() < 4) {
|
||||
sections.push_back(current);
|
||||
current.clear();
|
||||
trailingDot = true;
|
||||
}
|
||||
} else if (std::isdigit(static_cast<unsigned char>(c))) {
|
||||
if (sections.size() >= 4) {
|
||||
continue;
|
||||
}
|
||||
if (current.length() < 3) {
|
||||
current += c;
|
||||
trailingDot = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!current.empty() && sections.size() < 4) {
|
||||
sections.push_back(current);
|
||||
trailingDot = false;
|
||||
}
|
||||
|
||||
if (sections.empty() && !trailingDot) return "";
|
||||
|
||||
std::string result;
|
||||
for (std::size_t i = 0; i < sections.size(); ++i) {
|
||||
std::string s = sections[i];
|
||||
if (!s.empty()) {
|
||||
while (s.length() > 1 && s[0] == '0') {
|
||||
s.erase(s.begin());
|
||||
}
|
||||
try {
|
||||
int val = std::stoi(s);
|
||||
if (val > 255) val = 255;
|
||||
s = std::to_string(val);
|
||||
} catch (...) {
|
||||
s = "255";
|
||||
}
|
||||
}
|
||||
|
||||
if (i > 0) result += '.';
|
||||
result += s;
|
||||
}
|
||||
|
||||
if (!finalized && trailingDot && sections.size() < 4 && !result.empty()) {
|
||||
result += '.';
|
||||
}
|
||||
|
||||
if (finalized) {
|
||||
while (!result.empty() && result.back() == '.') {
|
||||
result.pop_back();
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace pixelvr
|
||||
@@ -0,0 +1,555 @@
|
||||
#include "vr_keyboard.hpp"
|
||||
#include "util/logging.hpp"
|
||||
#include <cstring>
|
||||
#include <cmath>
|
||||
#include <chrono>
|
||||
|
||||
#if defined(__linux__) || defined(__APPLE__)
|
||||
#include <dlfcn.h>
|
||||
#if defined(__linux__)
|
||||
#include <fcntl.h>
|
||||
#include <sys/mman.h>
|
||||
#include <unistd.h>
|
||||
#include <dirent.h>
|
||||
#include <sys/stat.h>
|
||||
#include <algorithm>
|
||||
#endif
|
||||
#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;
|
||||
|
||||
static uint64_t sKeyboardNonce = 1000;
|
||||
|
||||
#if defined(__linux__)
|
||||
static void clearOrSetShmKeyboardBuffer(const std::string& text) {
|
||||
// SteamVR stores the active keyboard buffer across all GamepadUI CEF instances
|
||||
// in a shared memory segment of size 0x40cc0 (265408 bytes) named /dev/shm/u<uid>-Shm_*
|
||||
// at offset 0x3fe60.
|
||||
DIR* dir = opendir("/dev/shm");
|
||||
if (!dir) return;
|
||||
|
||||
struct dirent* entry;
|
||||
while ((entry = readdir(dir)) != nullptr) {
|
||||
if (std::strstr(entry->d_name, "Shm_") != nullptr) {
|
||||
std::string path = std::string("/dev/shm/") + entry->d_name;
|
||||
struct stat st{};
|
||||
if (stat(path.c_str(), &st) == 0 && st.st_size == 0x40cc0) {
|
||||
int fd = open(path.c_str(), O_RDWR);
|
||||
if (fd >= 0) {
|
||||
void* ptr = mmap(nullptr, 0x40cc0, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
|
||||
close(fd);
|
||||
if (ptr != MAP_FAILED) {
|
||||
char* kbBuf = reinterpret_cast<char*>(ptr) + 0x3fe60;
|
||||
std::memset(kbBuf, 0, 1024);
|
||||
if (!text.empty()) {
|
||||
std::size_t copyLen = std::min(text.length(), static_cast<std::size_t>(1023));
|
||||
std::memcpy(kbBuf, text.c_str(), copyLen);
|
||||
}
|
||||
msync(ptr, 0x40cc0, MS_SYNC);
|
||||
munmap(ptr, 0x40cc0);
|
||||
PIXELVR_LOG_INFO("VrKeyboard: Updated shm %s at 0x3fe60 to '%s'",
|
||||
entry->d_name, text.c_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
closedir(dir);
|
||||
}
|
||||
#else
|
||||
static void clearOrSetShmKeyboardBuffer(const std::string&) {}
|
||||
#endif
|
||||
|
||||
} // 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;
|
||||
|
||||
// Clear any stale shared memory keyboard buffer left by previous sessions
|
||||
clearOrSetShmKeyboardBuffer("");
|
||||
|
||||
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 (!available_) return;
|
||||
available_ = false;
|
||||
if (isOpen_) {
|
||||
hide();
|
||||
}
|
||||
if (g_VR_ShutdownInternal) {
|
||||
auto fn = g_VR_ShutdownInternal;
|
||||
g_VR_ShutdownInternal = nullptr;
|
||||
fn();
|
||||
}
|
||||
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_GetGenericInterface = nullptr;
|
||||
isOpen_ = false;
|
||||
doneSubmitted_ = false;
|
||||
kbConfigured_ = false;
|
||||
currentNonce_ = 0;
|
||||
}
|
||||
|
||||
bool VrKeyboard::configureKeyboardOverlay() {
|
||||
auto* ov = reinterpret_cast<vr::IVROverlay*>(vrOverlay_);
|
||||
if (!ov) return false;
|
||||
|
||||
bool foundAny = false;
|
||||
vr::HmdMatrix34_t mat{};
|
||||
for (int r = 0; r < 3; ++r) {
|
||||
for (int c = 0; c < 4; ++c) {
|
||||
mat.m[r][c] = kbMatStage_[r][c];
|
||||
}
|
||||
}
|
||||
|
||||
auto configureHandle = [&](vr::VROverlayHandle_t h, const char* name) {
|
||||
ov->SetOverlayTransformAbsolute(h, vr::TrackingUniverseStanding, &mat);
|
||||
ov->SetOverlayWidthInMeters(h, 1.05f);
|
||||
ov->SetOverlayInputMethod(h, vr::VROverlayInputMethod_Mouse);
|
||||
ov->SetOverlayFlag(h, vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible, true);
|
||||
ov->SetOverlayFlag(h, vr::VROverlayFlags_SendVRDiscreteScrollEvents, true);
|
||||
ov->ShowOverlay(h);
|
||||
foundAny = true;
|
||||
PIXELVR_LOG_INFO("VrKeyboard: Configured keyboard overlay %llu ('%s') with interactive laser mouse at standing pos (%.2f, %.2f, %.2f)",
|
||||
static_cast<unsigned long long>(h), name ? name : "unknown",
|
||||
mat.m[0][3], mat.m[1][3], mat.m[2][3]);
|
||||
};
|
||||
|
||||
// 1. Check known specific overlay keys
|
||||
const char* kbOverlayNames[] = {
|
||||
"VRKeyboard_uid0",
|
||||
"VRKeyboard_uid1",
|
||||
"VRKeyboard",
|
||||
"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) {
|
||||
configureHandle(hKb, name);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Scan active overlay handles (1..128) to catch any dynamic uid variations (e.g. VRKeyboard_uid2726460308)
|
||||
for (uint64_t h = 1; h <= 128; ++h) {
|
||||
char keyBuf[256] = {0};
|
||||
vr::EVROverlayError kErr = vr::VROverlayError_None;
|
||||
uint32_t len = ov->GetOverlayKey(h, keyBuf, sizeof(keyBuf), &kErr);
|
||||
if (kErr == vr::VROverlayError_None && len > 0) {
|
||||
if (std::strstr(keyBuf, "keyboard") != nullptr ||
|
||||
std::strstr(keyBuf, "Keyboard") != nullptr ||
|
||||
std::strstr(keyBuf, "VRKeyboard") != nullptr) {
|
||||
configureHandle(h, keyBuf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (foundAny) {
|
||||
kbConfigured_ = true;
|
||||
}
|
||||
return foundAny;
|
||||
}
|
||||
|
||||
bool VrKeyboard::show(const std::string& description,
|
||||
const std::string& existingText,
|
||||
bool isPassword,
|
||||
int maxChars,
|
||||
const float* headPosLocal,
|
||||
const float* headForwardLocal,
|
||||
const float* headPosStage,
|
||||
const float* headForwardStage) {
|
||||
if (!available_ && !init()) {
|
||||
return false;
|
||||
}
|
||||
auto* ov = reinterpret_cast<vr::IVROverlay*>(vrOverlay_);
|
||||
if (!ov) return false;
|
||||
|
||||
if (isOpen_) {
|
||||
hide();
|
||||
}
|
||||
// Explicitly hide any existing keyboard session in SteamVR to clear lingering state
|
||||
ov->HideKeyboard();
|
||||
|
||||
// Reset shared memory keyboard buffer to existingText
|
||||
clearOrSetShmKeyboardBuffer(existingText);
|
||||
|
||||
currentNonce_ = ++sKeyboardNonce;
|
||||
description_ = description;
|
||||
existingText_ = existingText;
|
||||
currentText_ = existingText;
|
||||
isPassword_ = isPassword;
|
||||
maxChars_ = maxChars;
|
||||
doneSubmitted_ = false;
|
||||
userTyped_ = false;
|
||||
|
||||
const vr::EGamepadTextInputMode mode = isPassword
|
||||
? vr::k_EGamepadTextInputModePassword
|
||||
: vr::k_EGamepadTextInputModeNormal;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 1. Calculate Local transform (for OpenXR laser pointer collision in VulkanRenderer)
|
||||
// -------------------------------------------------------------------------
|
||||
float hx = 0.0f, hy = 0.0f, hz = 0.0f;
|
||||
float fx = 0.0f, fz = -1.0f;
|
||||
if (headPosLocal && headForwardLocal) {
|
||||
hx = headPosLocal[0];
|
||||
hy = headPosLocal[1];
|
||||
hz = headPosLocal[2];
|
||||
fx = headForwardLocal[0];
|
||||
fz = headForwardLocal[2];
|
||||
}
|
||||
float flen = std::sqrt(fx * fx + fz * fz);
|
||||
if (flen < 1e-3f) {
|
||||
fx = 0.0f;
|
||||
fz = -1.0f;
|
||||
} else {
|
||||
fx /= flen;
|
||||
fz /= flen;
|
||||
}
|
||||
const float yawLocal = std::atan2(-fx, -fz);
|
||||
|
||||
// Keyboard placed 1.15m in front of eyes, 0.22m below eye level.
|
||||
// The floating modal dialog box in VulkanRenderer ends at ~hy + 0.03m; this places the top
|
||||
// of the keyboard (half-height 0.20m) at ~hy - 0.02m, creating an elegant ~5cm vertical gap
|
||||
// directly beneath the modal dialog.
|
||||
const float dist = 1.15f;
|
||||
const float kbLocalX = hx + fx * dist;
|
||||
const float kbLocalY = hy - 0.22f;
|
||||
const float kbLocalZ = hz + fz * dist;
|
||||
|
||||
// Tilt upward toward eyes by -0.20 rad (~11.5 degrees)
|
||||
const float tilt = -0.20f;
|
||||
vr::HmdMatrix34_t kbMatLocal{};
|
||||
kbMatLocal.m[0][0] = std::cos(yawLocal);
|
||||
kbMatLocal.m[0][1] = std::sin(yawLocal) * std::sin(tilt);
|
||||
kbMatLocal.m[0][2] = std::sin(yawLocal) * std::cos(tilt);
|
||||
kbMatLocal.m[0][3] = kbLocalX;
|
||||
|
||||
kbMatLocal.m[1][0] = 0.0f;
|
||||
kbMatLocal.m[1][1] = std::cos(tilt);
|
||||
kbMatLocal.m[1][2] = -std::sin(tilt);
|
||||
kbMatLocal.m[1][3] = kbLocalY;
|
||||
|
||||
kbMatLocal.m[2][0] = -std::sin(yawLocal);
|
||||
kbMatLocal.m[2][1] = std::cos(yawLocal) * std::sin(tilt);
|
||||
kbMatLocal.m[2][2] = std::cos(yawLocal) * std::cos(tilt);
|
||||
kbMatLocal.m[2][3] = kbLocalZ;
|
||||
|
||||
for (int r = 0; r < 3; ++r) {
|
||||
for (int c = 0; c < 4; ++c) {
|
||||
kbMatLocal_[r][c] = kbMatLocal.m[r][c];
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 2. Calculate Stage transform (for SteamVR TrackingUniverseStanding overlay rendering)
|
||||
// -------------------------------------------------------------------------
|
||||
float shx = hx, shy = hy + 1.65f, shz = hz;
|
||||
float sfx = fx, sfz = fz;
|
||||
if (headPosStage && headForwardStage) {
|
||||
shx = headPosStage[0];
|
||||
shy = headPosStage[1];
|
||||
shz = headPosStage[2];
|
||||
sfx = headForwardStage[0];
|
||||
sfz = headForwardStage[2];
|
||||
}
|
||||
float sflen = std::sqrt(sfx * sfx + sfz * sfz);
|
||||
if (sflen < 1e-3f) {
|
||||
sfx = 0.0f;
|
||||
sfz = -1.0f;
|
||||
} else {
|
||||
sfx /= sflen;
|
||||
sfz /= sflen;
|
||||
}
|
||||
const float yawStage = std::atan2(-sfx, -sfz);
|
||||
|
||||
const float kbStageX = shx + sfx * dist;
|
||||
const float kbStageY = shy - 0.22f; // In standing space (floor=0), shy ~1.65m, so kbStageY ~1.43m above floor!
|
||||
const float kbStageZ = shz + sfz * dist;
|
||||
|
||||
vr::HmdMatrix34_t kbMatStage{};
|
||||
kbMatStage.m[0][0] = std::cos(yawStage);
|
||||
kbMatStage.m[0][1] = std::sin(yawStage) * std::sin(tilt);
|
||||
kbMatStage.m[0][2] = std::sin(yawStage) * std::cos(tilt);
|
||||
kbMatStage.m[0][3] = kbStageX;
|
||||
|
||||
kbMatStage.m[1][0] = 0.0f;
|
||||
kbMatStage.m[1][1] = std::cos(tilt);
|
||||
kbMatStage.m[1][2] = -std::sin(tilt);
|
||||
kbMatStage.m[1][3] = kbStageY;
|
||||
|
||||
kbMatStage.m[2][0] = -std::sin(yawStage);
|
||||
kbMatStage.m[2][1] = std::cos(yawStage) * std::sin(tilt);
|
||||
kbMatStage.m[2][2] = std::cos(yawStage) * std::cos(tilt);
|
||||
kbMatStage.m[2][3] = kbStageZ;
|
||||
|
||||
for (int r = 0; r < 3; ++r) {
|
||||
for (int c = 0; c < 4; ++c) {
|
||||
kbMatStage_[r][c] = kbMatStage.m[r][c];
|
||||
}
|
||||
}
|
||||
kbConfigured_ = false;
|
||||
|
||||
// 1. Set keyboard transform in standing universe BEFORE ShowKeyboard
|
||||
ov->SetKeyboardTransformAbsolute(vr::TrackingUniverseStanding, &kbMatStage);
|
||||
|
||||
// 2. Invoke virtual keyboard with Modal flag
|
||||
vr::EVROverlayError err = ov->ShowKeyboard(
|
||||
mode,
|
||||
vr::k_EGamepadTextInputLineModeSingleLine,
|
||||
vr::KeyboardFlag_Modal | vr::KeyboardFlag_Minimal,
|
||||
description.c_str(),
|
||||
static_cast<uint32_t>(maxChars),
|
||||
existingText.c_str(),
|
||||
currentNonce_);
|
||||
|
||||
if (err != vr::VROverlayError_None) {
|
||||
PIXELVR_LOG_WARN("VrKeyboard: ShowKeyboard failed with error %d", static_cast<int>(err));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Reinforce existingText in shared memory so GamepadUI CEF reads it cleanly
|
||||
clearOrSetShmKeyboardBuffer(existingText);
|
||||
|
||||
// 3. Reinforce keyboard transform AFTER ShowKeyboard
|
||||
ov->SetKeyboardTransformAbsolute(vr::TrackingUniverseStanding, &kbMatStage);
|
||||
|
||||
// 4. Configure overlay laser and transform immediately if already present
|
||||
configureKeyboardOverlay();
|
||||
|
||||
isOpen_ = true;
|
||||
pollFramesSinceOpen_ = 0;
|
||||
PIXELVR_LOG_INFO("VrKeyboard: Shown virtual keyboard for '%s' (nonce %llu, existing='%s') at stage pos (%.2f, %.2f, %.2f) local pos (%.2f, %.2f, %.2f)",
|
||||
description.c_str(), static_cast<unsigned long long>(currentNonce_),
|
||||
existingText.c_str(), kbStageX, kbStageY, kbStageZ, kbLocalX, kbLocalY, kbLocalZ);
|
||||
return true;
|
||||
}
|
||||
|
||||
void VrKeyboard::hide() {
|
||||
if (!isOpen_) return;
|
||||
auto* ov = reinterpret_cast<vr::IVROverlay*>(vrOverlay_);
|
||||
if (ov) {
|
||||
ov->HideKeyboard();
|
||||
}
|
||||
clearOrSetShmKeyboardBuffer("");
|
||||
isOpen_ = false;
|
||||
kbConfigured_ = false;
|
||||
doneSubmitted_ = false;
|
||||
userTyped_ = false;
|
||||
currentNonce_ = 0;
|
||||
currentText_.clear();
|
||||
existingText_.clear();
|
||||
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) {
|
||||
// Discard any events from old or stale keyboard sessions
|
||||
if (event.data.keyboard.uUserValue != 0 && event.data.keyboard.uUserValue != currentNonce_) {
|
||||
PIXELVR_LOG_INFO("VrKeyboard: Ignored stale CharInput (nonce %llu != current %llu)",
|
||||
static_cast<unsigned long long>(event.data.keyboard.uUserValue),
|
||||
static_cast<unsigned long long>(currentNonce_));
|
||||
continue;
|
||||
}
|
||||
|
||||
const char* inStr = event.data.keyboard.cNewInput;
|
||||
if (inStr && inStr[0] != 0) {
|
||||
userTyped_ = true;
|
||||
const unsigned char firstByte = static_cast<unsigned char>(inStr[0]);
|
||||
if (firstByte == '\b' || firstByte == 0x7F || firstByte == 8) {
|
||||
// Backspace: pop last UTF-8 character
|
||||
while (!currentText_.empty() && (static_cast<unsigned char>(currentText_.back()) & 0xC0) == 0x80) {
|
||||
currentText_.pop_back();
|
||||
}
|
||||
if (!currentText_.empty()) {
|
||||
currentText_.pop_back();
|
||||
}
|
||||
PIXELVR_LOG_INFO("VrKeyboard: Backspace pressed, text now: '%s'", currentText_.c_str());
|
||||
} else if (firstByte == '\n' || firstByte == '\r') {
|
||||
isOpen_ = false;
|
||||
kbConfigured_ = false;
|
||||
doneSubmitted_ = true;
|
||||
std::string text = currentText_;
|
||||
ov->HideKeyboard();
|
||||
clearOrSetShmKeyboardBuffer("");
|
||||
currentText_.clear();
|
||||
existingText_.clear();
|
||||
userTyped_ = false;
|
||||
currentNonce_ = 0;
|
||||
PIXELVR_LOG_INFO("VrKeyboard: User submitted text via Return key: '%s'", text.c_str());
|
||||
if (onDone) {
|
||||
onDone(text);
|
||||
}
|
||||
return;
|
||||
} else if (firstByte >= 32) {
|
||||
if (currentText_.length() + std::strlen(inStr) <= static_cast<size_t>(maxChars_)) {
|
||||
currentText_ += inStr;
|
||||
}
|
||||
PIXELVR_LOG_INFO("VrKeyboard: Input '%s', text now: '%s'", inStr, currentText_.c_str());
|
||||
}
|
||||
}
|
||||
} else if (event.eventType == vr::VREvent_KeyboardDone) {
|
||||
isOpen_ = false;
|
||||
kbConfigured_ = false;
|
||||
doneSubmitted_ = true;
|
||||
std::string text = currentText_;
|
||||
ov->HideKeyboard();
|
||||
clearOrSetShmKeyboardBuffer("");
|
||||
currentText_.clear();
|
||||
existingText_.clear();
|
||||
userTyped_ = false;
|
||||
currentNonce_ = 0;
|
||||
PIXELVR_LOG_INFO("VrKeyboard: User submitted text via KeyboardDone: '%s'", text.c_str());
|
||||
if (onDone) {
|
||||
onDone(text);
|
||||
}
|
||||
return;
|
||||
} else if (event.eventType == vr::VREvent_KeyboardClosed ||
|
||||
event.eventType == vr::VREvent_KeyboardClosed_Global) {
|
||||
isOpen_ = false;
|
||||
kbConfigured_ = false;
|
||||
ov->HideKeyboard();
|
||||
clearOrSetShmKeyboardBuffer("");
|
||||
PIXELVR_LOG_INFO("VrKeyboard: Keyboard closed (doneSubmitted=%d)", doneSubmitted_ ? 1 : 0);
|
||||
if (!doneSubmitted_) {
|
||||
currentText_.clear();
|
||||
existingText_.clear();
|
||||
userTyped_ = false;
|
||||
currentNonce_ = 0;
|
||||
PIXELVR_LOG_INFO("VrKeyboard: Discarded input on KeyboardClosed");
|
||||
if (onCancel) {
|
||||
onCancel();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (isOpen_) {
|
||||
pollFramesSinceOpen_++;
|
||||
// Maintain and reinforce keyboard overlay configuration across the first 90 frames (~1 sec)
|
||||
// until successfully configured
|
||||
if (!kbConfigured_ && pollFramesSinceOpen_ < 90) {
|
||||
configureKeyboardOverlay();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::string VrKeyboard::currentText() const {
|
||||
return currentText_;
|
||||
}
|
||||
|
||||
} // namespace pixelvr
|
||||
@@ -0,0 +1,69 @@
|
||||
#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,
|
||||
const float* headPosLocal = nullptr,
|
||||
const float* headForwardLocal = nullptr,
|
||||
const float* headPosStage = nullptr,
|
||||
const float* headForwardStage = nullptr);
|
||||
|
||||
// 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_; }
|
||||
const float* transform() const { return &kbMatLocal_[0][0]; }
|
||||
|
||||
private:
|
||||
VrKeyboard() = default;
|
||||
~VrKeyboard();
|
||||
|
||||
bool configureKeyboardOverlay();
|
||||
|
||||
bool available_ = false;
|
||||
bool isOpen_ = false;
|
||||
bool doneSubmitted_ = false;
|
||||
bool userTyped_ = false;
|
||||
bool kbConfigured_ = false;
|
||||
int pollFramesSinceOpen_ = 0;
|
||||
float kbMatLocal_[3][4]{};
|
||||
float kbMatStage_[3][4]{};
|
||||
uint64_t currentNonce_ = 0;
|
||||
int maxChars_ = 256;
|
||||
std::string description_;
|
||||
std::string existingText_;
|
||||
std::string currentText_;
|
||||
bool isPassword_ = false;
|
||||
void* libHandle_ = nullptr;
|
||||
void* vrSystem_ = nullptr;
|
||||
void* vrOverlay_ = nullptr;
|
||||
};
|
||||
|
||||
} // namespace pixelvr
|
||||
+89
-2
@@ -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>
|
||||
@@ -34,6 +35,10 @@ XrContext::~XrContext() {
|
||||
if (appSpace_ != XR_NULL_HANDLE) {
|
||||
xrDestroySpace(appSpace_);
|
||||
}
|
||||
if (stageSpace_ != XR_NULL_HANDLE) {
|
||||
xrDestroySpace(stageSpace_);
|
||||
stageSpace_ = XR_NULL_HANDLE;
|
||||
}
|
||||
if (actionSet_ != XR_NULL_HANDLE) {
|
||||
xrDestroyActionSet(actionSet_);
|
||||
}
|
||||
@@ -216,6 +221,18 @@ bool XrContext::createReferenceSpace() {
|
||||
createInfo.poseInReferenceSpace.orientation.w = 1.0f;
|
||||
PIXELVR_XR_CHECK(instance_,
|
||||
xrCreateReferenceSpace(session_, &createInfo, &appSpace_));
|
||||
|
||||
// Create STAGE reference space (floor level tracking bounds, matching SteamVR TrackingUniverseStanding)
|
||||
XrReferenceSpaceCreateInfo stageInfo{XR_TYPE_REFERENCE_SPACE_CREATE_INFO};
|
||||
stageInfo.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_STAGE;
|
||||
stageInfo.poseInReferenceSpace.orientation.w = 1.0f;
|
||||
XrResult stageRes = xrCreateReferenceSpace(session_, &stageInfo, &stageSpace_);
|
||||
if (XR_SUCCEEDED(stageRes)) {
|
||||
PIXELVR_LOG_INFO("XrContext: Successfully created STAGE reference space");
|
||||
} else {
|
||||
PIXELVR_LOG_WARN("XrContext: STAGE reference space not available (%d), using calibrated standing fallback", static_cast<int>(stageRes));
|
||||
stageSpace_ = XR_NULL_HANDLE;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -637,6 +654,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;
|
||||
@@ -905,10 +938,36 @@ bool XrContext::renderFrame(const RenderViewFn& renderView) {
|
||||
input_.headForward[2] = -(1.0f - 2.0f * (q.x * q.x + q.y * q.y));
|
||||
}
|
||||
|
||||
input_.stageValid = false;
|
||||
if (stageSpace_ != XR_NULL_HANDLE && input_.headValid) {
|
||||
XrSpaceLocation loc{XR_TYPE_SPACE_LOCATION};
|
||||
if (XR_SUCCEEDED(xrLocateSpace(appSpace_, stageSpace_, frameState.predictedDisplayTime, &loc))) {
|
||||
constexpr XrSpaceLocationFlags need =
|
||||
XR_SPACE_LOCATION_ORIENTATION_VALID_BIT |
|
||||
XR_SPACE_LOCATION_POSITION_VALID_BIT;
|
||||
if ((loc.locationFlags & need) == need) {
|
||||
input_.stageValid = true;
|
||||
input_.appToStagePose = loc.pose;
|
||||
transformToStage(input_.headPos, input_.headForward,
|
||||
input_.headPosStage, input_.headForwardStage);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!input_.stageValid) {
|
||||
// Fallback: Standing stage eye level is ~1.65m above the physical floor
|
||||
input_.headPosStage[0] = input_.headPos[0];
|
||||
input_.headPosStage[1] = input_.headPos[1] + 1.65f;
|
||||
input_.headPosStage[2] = input_.headPos[2];
|
||||
input_.headForwardStage[0] = input_.headForward[0];
|
||||
input_.headForwardStage[1] = input_.headForward[1];
|
||||
input_.headForwardStage[2] = input_.headForward[2];
|
||||
}
|
||||
|
||||
// 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 +995,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};
|
||||
@@ -1009,4 +1069,31 @@ bool XrContext::renderFrame(const RenderViewFn& renderView) {
|
||||
return true;
|
||||
}
|
||||
|
||||
void XrContext::transformToStage(const float localPos[3], const float localFwd[3],
|
||||
float stagePos[3], float stageFwd[3]) const {
|
||||
const auto& p = input_.appToStagePose.position;
|
||||
const auto& q = input_.appToStagePose.orientation;
|
||||
|
||||
if (localPos && stagePos) {
|
||||
const float vx = localPos[0], vy = localPos[1], vz = localPos[2];
|
||||
const float tw = -q.x * vx - q.y * vy - q.z * vz;
|
||||
const float tx = q.w * vx + q.y * vz - q.z * vy;
|
||||
const float ty = q.w * vy + q.z * vx - q.x * vz;
|
||||
const float tz = q.w * vz + q.x * vy - q.y * vx;
|
||||
stagePos[0] = p.x + (tx * q.w + tw * (-q.x) + ty * (-q.z) - tz * (-q.y));
|
||||
stagePos[1] = p.y + (ty * q.w + tw * (-q.y) + tz * (-q.x) - tx * (-q.z));
|
||||
stagePos[2] = p.z + (tz * q.w + tw * (-q.z) + tx * (-q.y) - ty * (-q.x));
|
||||
}
|
||||
if (localFwd && stageFwd) {
|
||||
const float fx = localFwd[0], fy = localFwd[1], fz = localFwd[2];
|
||||
const float ftw = -q.x * fx - q.y * fy - q.z * fz;
|
||||
const float ftx = q.w * fx + q.y * fz - q.z * fy;
|
||||
const float fty = q.w * fy + q.z * fx - q.x * fz;
|
||||
const float ftz = q.w * fz + q.x * fy - q.y * fx;
|
||||
stageFwd[0] = ftx * q.w + ftw * (-q.x) + fty * (-q.z) - ftz * (-q.y);
|
||||
stageFwd[1] = fty * q.w + ftw * (-q.y) + ftz * (-q.x) - ftx * (-q.z);
|
||||
stageFwd[2] = ftz * q.w + ftw * (-q.z) + ftx * (-q.y) - fty * (-q.x);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace pixelvr
|
||||
@@ -34,6 +34,10 @@ struct XrInputState {
|
||||
bool headValid = false;
|
||||
float headPos[3] = {0.0f, 0.0f, 0.0f};
|
||||
float headForward[3] = {0.0f, 0.0f, -1.0f};
|
||||
bool stageValid = false;
|
||||
XrPosef appToStagePose{{0.0f, 0.0f, 0.0f, 1.0f}, {0.0f, 1.65f, 0.0f}};
|
||||
float headPosStage[3] = {0.0f, 1.65f, 0.0f};
|
||||
float headForwardStage[3] = {0.0f, 0.0f, -1.0f};
|
||||
|
||||
// Tracked controller aim poses (index 0 = left, 1 = right). The pointer ray
|
||||
// is the -Z axis of `orientation` from `position`, in the app reference space.
|
||||
@@ -122,7 +126,10 @@ public:
|
||||
XrInstance instance() const { return instance_; }
|
||||
XrSession session() const { return session_; }
|
||||
XrSpace appSpace() const { return appSpace_; }
|
||||
XrSpace stageSpace() const { return stageSpace_; }
|
||||
XrTime displayTime() const { return lastDisplayTime_; }
|
||||
void transformToStage(const float localPos[3], const float localFwd[3],
|
||||
float stagePos[3], float stageFwd[3]) const;
|
||||
XrSystemId systemId() const { return systemId_; }
|
||||
bool renderModelEnabled() const { return renderModelEnabled_; }
|
||||
uint32_t viewCount() const { return static_cast<uint32_t>(configViews_.size()); }
|
||||
@@ -145,6 +152,7 @@ private:
|
||||
XrSystemId systemId_ = XR_NULL_SYSTEM_ID;
|
||||
XrSession session_ = XR_NULL_HANDLE;
|
||||
XrSpace appSpace_ = XR_NULL_HANDLE;
|
||||
XrSpace stageSpace_ = XR_NULL_HANDLE;
|
||||
XrTime lastDisplayTime_ = 0;
|
||||
|
||||
XrSessionState sessionState_ = XR_SESSION_STATE_UNKNOWN;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
#include "util/ipv4.hpp"
|
||||
#include <cassert>
|
||||
#include <iostream>
|
||||
|
||||
using namespace pixelvr;
|
||||
|
||||
int main() {
|
||||
std::cout << "[TEST] Starting IPv4 sanitization & validation test..." << std::endl;
|
||||
|
||||
// Normal valid IP
|
||||
assert(sanitizeIpv4("192.168.1.1", true) == "192.168.1.1");
|
||||
assert(isValidIpv4("192.168.1.1"));
|
||||
|
||||
// Leading zeros dropped in each section
|
||||
assert(sanitizeIpv4("192.168.01.005", true) == "192.168.1.5");
|
||||
assert(sanitizeIpv4("010.000.001.020", true) == "10.0.1.20");
|
||||
assert(sanitizeIpv4("0.0.0.0", true) == "0.0.0.0");
|
||||
|
||||
// Clamping > 255
|
||||
assert(sanitizeIpv4("192.168.1.300", true) == "192.168.1.255");
|
||||
assert(sanitizeIpv4("999.999.999.999", true) == "255.255.255.255");
|
||||
|
||||
// Live typing with trailing dot
|
||||
assert(sanitizeIpv4("192.168.", false) == "192.168.");
|
||||
assert(sanitizeIpv4("192.168.", true) == "192.168");
|
||||
|
||||
// Non-digit characters discarded
|
||||
assert(sanitizeIpv4("192.abc.168.foo.1.bar.100", true) == "192.168.1.100");
|
||||
assert(sanitizeIpv4(" 10 . 0 . 0 . 1 ", true) == "10.0.0.1");
|
||||
|
||||
// Max 4 sections (extra sections ignored)
|
||||
assert(sanitizeIpv4("192.168.1.1.99.88", true) == "192.168.1.1");
|
||||
|
||||
std::cout << "[ALL TESTS PASSED] IPv4 sanitization is verified!" << std::endl;
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
#include "net/smb_credentials.hpp"
|
||||
#include <cassert>
|
||||
#include <iostream>
|
||||
#include <fstream>
|
||||
#include <filesystem>
|
||||
#include <sys/stat.h>
|
||||
|
||||
using namespace pixelvr;
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
int main() {
|
||||
std::cout << "[TEST] Starting SmbCredentialsManager verification..." << std::endl;
|
||||
auto& mgr = SmbCredentialsManager::instance();
|
||||
|
||||
// 1. Add a protected share
|
||||
SmbShare share1;
|
||||
share1.id = "192.168.1.100/movies";
|
||||
share1.name = "Living Room NAS";
|
||||
share1.host = "192.168.1.100";
|
||||
share1.share = "movies";
|
||||
share1.workgroup = "MYGROUP";
|
||||
share1.username = "nasadmin";
|
||||
share1.password = "SuperSecretPassword123!";
|
||||
share1.anonymous = false;
|
||||
|
||||
mgr.addOrUpdateShare(share1);
|
||||
std::cout << "[PASS] Added protected share." << std::endl;
|
||||
|
||||
// 2. Query credentials
|
||||
std::string user, pass, wg;
|
||||
bool found = mgr.findCredentials("192.168.1.100", "movies", user, pass, wg);
|
||||
assert(found);
|
||||
assert(user == "nasadmin");
|
||||
assert(pass == "SuperSecretPassword123!");
|
||||
assert(wg == "MYGROUP");
|
||||
std::cout << "[PASS] Retrieved exact credentials." << std::endl;
|
||||
|
||||
// 3. Check storage file permissions (must be 0600)
|
||||
const char* home = std::getenv("HOME");
|
||||
std::string configPath = std::string(home ? home : ".") + "/.config/pixelvr/smb_shares.json";
|
||||
struct stat st;
|
||||
assert(stat(configPath.c_str(), &st) == 0);
|
||||
mode_t perm = st.st_mode & 0777;
|
||||
std::cout << "[INFO] File permissions: 0" << std::oct << perm << std::dec << std::endl;
|
||||
assert((perm & 0077) == 0); // No permissions for group or others
|
||||
std::cout << "[PASS] Config file has strict 0600 permissions." << std::endl;
|
||||
|
||||
// 4. Verify password is encrypted on disk
|
||||
std::ifstream cfgFile(configPath);
|
||||
std::string content((std::istreambuf_iterator<char>(cfgFile)), std::istreambuf_iterator<char>());
|
||||
assert(content.find("SuperSecretPassword123!") == std::string::npos);
|
||||
std::cout << "[PASS] Password is encrypted on disk (no plaintext leak)." << std::endl;
|
||||
|
||||
// 5. Test auth file generation
|
||||
std::string authPath = mgr.getAuthFilePath("192.168.1.100", "movies");
|
||||
assert(!authPath.empty());
|
||||
assert(stat(authPath.c_str(), &st) == 0);
|
||||
mode_t authPerm = st.st_mode & 0777;
|
||||
assert((authPerm & 0077) == 0); // Auth file must also be 0600
|
||||
std::cout << "[PASS] Temporary Samba auth file generated with 0600 permissions." << std::endl;
|
||||
|
||||
// 6. Delete share
|
||||
bool deleted = mgr.deleteShare("192.168.1.100", "movies");
|
||||
assert(deleted);
|
||||
found = mgr.findCredentials("192.168.1.100", "movies", user, pass, wg);
|
||||
assert(!found);
|
||||
std::cout << "[PASS] Deleted share successfully." << std::endl;
|
||||
|
||||
std::cout << "[ALL TESTS PASSED] SmbCredentialsManager is verified and secure!" << std::endl;
|
||||
return 0;
|
||||
}
|
||||
Vendored
+5773
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
@@ -25,13 +25,19 @@ if %ERRORLEVEL% neq 0 (
|
||||
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"
|
||||
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] Pixel VR Player installed on your Steam Frame!
|
||||
echo It is now in your Steam Library under Non-Steam Games.
|
||||
echo [SUCCESS] Operation completed on your Steam Frame!
|
||||
echo ===============================================================
|
||||
) else (
|
||||
echo.
|
||||
|
||||
@@ -19,11 +19,22 @@ echo "Connecting to steamos@${FRAME_IP}..."
|
||||
echo "(If prompted for a password, enter your SteamOS desktop password)"
|
||||
echo ""
|
||||
|
||||
if ssh -o StrictHostKeyChecking=no "steamos@${FRAME_IP}" "curl -fsSL https://raw.githubusercontent.com/Pixelplanet/Pixel-VR-Player/main/tools/install.sh | sh"; then
|
||||
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 "==============================================================="
|
||||
echo " [SUCCESS] Pixel VR Player installed on your Steam Frame!"
|
||||
echo " It is now in your Steam Library under Non-Steam Games."
|
||||
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 ""
|
||||
|
||||
+51
-3
@@ -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 ;;
|
||||
@@ -258,13 +261,25 @@ def main():
|
||||
if entry is None:
|
||||
print("no matching shortcut, nothing to remove")
|
||||
return 0
|
||||
raw_appid = entry.get("appid", [INT, 0])[1] & 0xFFFFFFFF
|
||||
if raw_appid:
|
||||
steam_user_dir = os.path.dirname(os.path.dirname(path))
|
||||
grid_dir = os.path.join(steam_user_dir, "config", "grid")
|
||||
for suffix in ("", "p", "_hero", "_logo", "_icon"):
|
||||
for ext in (".png", ".jpg"):
|
||||
gf = os.path.join(grid_dir, "%d%s%s" % (raw_appid, suffix, ext))
|
||||
if os.path.isfile(gf):
|
||||
try:
|
||||
os.remove(gf)
|
||||
except Exception:
|
||||
pass
|
||||
del shortcuts[key]
|
||||
renumbered = {}
|
||||
ordered = sorted(shortcuts, key=lambda k: int(k) if k.isdigit() else 1 << 30)
|
||||
for index, old in enumerate(ordered):
|
||||
renumbered[str(index)] = shortcuts[old]
|
||||
root[root_key][1] = renumbered
|
||||
print("removed '%s'" % name)
|
||||
print("removed '%s' and grid artwork" % name)
|
||||
else:
|
||||
quoted_exe = '"%s"' % exe
|
||||
created = entry is None
|
||||
@@ -418,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'
|
||||
@@ -460,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
|
||||
@@ -531,6 +547,38 @@ elif [ -n "$script_dir" ] && [ -f "$script_dir/install.sh" ]; then
|
||||
chmod 0755 "$INSTALL_DIR/install.sh"
|
||||
INSTALLED_SCRIPT=1
|
||||
fi
|
||||
# Copy updater script
|
||||
if [ -f "$srcdir/update.sh" ]; then
|
||||
cp "$srcdir/update.sh" "$INSTALL_DIR/update.sh"
|
||||
chmod 0755 "$INSTALL_DIR/update.sh"
|
||||
elif [ -n "$script_dir" ] && [ -f "$script_dir/update.sh" ]; then
|
||||
cp "$script_dir/update.sh" "$INSTALL_DIR/update.sh"
|
||||
chmod 0755 "$INSTALL_DIR/update.sh"
|
||||
elif [ -f "tools/update.sh" ]; then
|
||||
cp "tools/update.sh" "$INSTALL_DIR/update.sh"
|
||||
chmod 0755 "$INSTALL_DIR/update.sh"
|
||||
fi
|
||||
|
||||
# Copy Desktop 1-click update entry
|
||||
DESKTOP_ENTRY="Update Pixel VR Player.desktop"
|
||||
if [ -f "$srcdir/$DESKTOP_ENTRY" ]; then
|
||||
cp "$srcdir/$DESKTOP_ENTRY" "$INSTALL_DIR/$DESKTOP_ENTRY"
|
||||
chmod 0755 "$INSTALL_DIR/$DESKTOP_ENTRY"
|
||||
elif [ -n "$script_dir" ] && [ -f "$script_dir/$DESKTOP_ENTRY" ]; then
|
||||
cp "$script_dir/$DESKTOP_ENTRY" "$INSTALL_DIR/$DESKTOP_ENTRY"
|
||||
chmod 0755 "$INSTALL_DIR/$DESKTOP_ENTRY"
|
||||
elif [ -f "tools/$DESKTOP_ENTRY" ]; then
|
||||
cp "tools/$DESKTOP_ENTRY" "$INSTALL_DIR/$DESKTOP_ENTRY"
|
||||
chmod 0755 "$INSTALL_DIR/$DESKTOP_ENTRY"
|
||||
fi
|
||||
|
||||
# Install 1-click update shortcut to Desktop if Desktop folder exists
|
||||
if [ -d "$HOME/Desktop" ] && [ -f "$INSTALL_DIR/$DESKTOP_ENTRY" ]; then
|
||||
cp "$INSTALL_DIR/$DESKTOP_ENTRY" "$HOME/Desktop/$DESKTOP_ENTRY"
|
||||
chmod 0755 "$HOME/Desktop/$DESKTOP_ENTRY"
|
||||
ok "Installed 1-click update shortcut to ~/Desktop/$DESKTOP_ENTRY"
|
||||
fi
|
||||
|
||||
if [ -f "$srcdir/VERSION" ]; then
|
||||
cp "$srcdir/VERSION" "$INSTALL_DIR/VERSION"
|
||||
elif [ -n "$VERSION" ]; then
|
||||
@@ -587,7 +635,7 @@ printf '\n Launch now : %s/run.sh\n' "$INSTALL_DIR"
|
||||
printf ' From Steam : Library > Non-Steam > %s\n' "$APP_NAME"
|
||||
printf ' Play a clip : %s/run.sh /path/to/clip.mp4\n' "$INSTALL_DIR"
|
||||
printf ' Logs : ~/pixelvr-run.log\n'
|
||||
printf ' Update : run this script again (settings and cache are kept)\n'
|
||||
printf ' 1-Click Upd : %s/update.sh (or "Update Pixel VR Player" icon on Desktop)\n' "$INSTALL_DIR"
|
||||
if [ "$INSTALLED_SCRIPT" -eq 1 ]; then
|
||||
printf ' Remove : %s/install.sh --uninstall\n\n' "$INSTALL_DIR"
|
||||
else
|
||||
|
||||
@@ -37,9 +37,15 @@ 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}"
|
||||
@@ -51,9 +57,15 @@ fi
|
||||
chmod +x "${stage}/PixelVRPlayer/pixelvr" \
|
||||
"${stage}/PixelVRPlayer/run.sh" \
|
||||
"${stage}/PixelVRPlayer/install.sh"
|
||||
if [[ -f "${stage}/PixelVRPlayer/update.sh" ]]; then
|
||||
chmod +x "${stage}/PixelVRPlayer/update.sh"
|
||||
fi
|
||||
if [[ -f "${stage}/PixelVRPlayer/Install Pixel VR Player.desktop" ]]; then
|
||||
chmod +x "${stage}/PixelVRPlayer/Install Pixel VR Player.desktop"
|
||||
fi
|
||||
if [[ -f "${stage}/PixelVRPlayer/Update Pixel VR Player.desktop" ]]; then
|
||||
chmod +x "${stage}/PixelVRPlayer/Update Pixel VR Player.desktop"
|
||||
fi
|
||||
|
||||
rm -f "${OUT_TAR}"
|
||||
tar -C "${stage}" -czf "${OUT_TAR}" PixelVRPlayer
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
echo ===============================================================
|
||||
echo Pixel VR Player - Steam Frame Remote Uninstaller
|
||||
echo ===============================================================
|
||||
echo This tool removes Pixel VR Player and its Steam shortcut from
|
||||
echo your Valve Steam Frame over your local Wi-Fi network.
|
||||
echo ===============================================================
|
||||
echo.
|
||||
|
||||
set "FRAME_IP=steamframe.local"
|
||||
set /p "INPUT_IP=Enter Steam Frame IP address [press Enter for steamframe.local]: "
|
||||
if not "!INPUT_IP!"=="" set "FRAME_IP=!INPUT_IP!"
|
||||
|
||||
echo.
|
||||
echo Connecting to steamos@!FRAME_IP!...
|
||||
echo.
|
||||
|
||||
where ssh >nul 2>&1
|
||||
if %ERRORLEVEL% neq 0 (
|
||||
echo [ERROR] SSH is not installed or not in PATH on this PC.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
ssh -o StrictHostKeyChecking=no steamos@!FRAME_IP! "curl -fsSL https://raw.githubusercontent.com/Pixelplanet/Pixel-VR-Player/main/tools/install.sh | sh -s -- --uninstall"
|
||||
|
||||
if %ERRORLEVEL% equ 0 (
|
||||
echo.
|
||||
echo ===============================================================
|
||||
echo [SUCCESS] Pixel VR Player removed from your Steam Frame!
|
||||
echo ===============================================================
|
||||
) else (
|
||||
echo.
|
||||
echo ===============================================================
|
||||
echo [NOTE] If connection failed, please ensure:
|
||||
echo 1. Both your PC and Steam Frame are on the same Wi-Fi.
|
||||
echo 2. SSH is enabled on the headset.
|
||||
echo ===============================================================
|
||||
)
|
||||
|
||||
echo.
|
||||
pause
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pixel VR Player - Remote uninstaller from Mac / Linux to Steam Frame
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
echo "==============================================================="
|
||||
echo " Pixel VR Player - Steam Frame Remote Uninstaller"
|
||||
echo "==============================================================="
|
||||
echo " This tool removes Pixel VR Player and its Steam shortcut from"
|
||||
echo " your Valve Steam Frame over your local Wi-Fi network."
|
||||
echo "==============================================================="
|
||||
echo ""
|
||||
|
||||
read -rp "Enter Steam Frame IP address [press Enter for steamframe.local]: " INPUT_IP
|
||||
FRAME_IP="${INPUT_IP:-steamframe.local}"
|
||||
|
||||
echo ""
|
||||
echo "Connecting to steamos@${FRAME_IP}..."
|
||||
echo ""
|
||||
|
||||
if ssh -o StrictHostKeyChecking=no "steamos@${FRAME_IP}" "curl -fsSL https://raw.githubusercontent.com/Pixelplanet/Pixel-VR-Player/main/tools/install.sh | sh -s -- --uninstall"; then
|
||||
echo ""
|
||||
echo "==============================================================="
|
||||
echo " [SUCCESS] Pixel VR Player removed from your Steam Frame!"
|
||||
echo "==============================================================="
|
||||
else
|
||||
echo ""
|
||||
echo "==============================================================="
|
||||
echo " [NOTE] If connection failed, please ensure:"
|
||||
echo " 1. Both devices are on the same Wi-Fi network."
|
||||
echo " 2. SSH is enabled on the headset."
|
||||
echo "==============================================================="
|
||||
fi
|
||||
@@ -0,0 +1,50 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
echo ===============================================================
|
||||
echo Pixel VR Player - Steam Frame Remote Updater
|
||||
echo ===============================================================
|
||||
echo This tool updates Pixel VR Player on your Valve Steam Frame
|
||||
echo to the latest release over Wi-Fi directly from your PC.
|
||||
echo (All your settings, bookmarks, and shares are kept safe).
|
||||
echo ===============================================================
|
||||
echo.
|
||||
|
||||
set "FRAME_IP=steamframe.local"
|
||||
set /p "INPUT_IP=Enter Steam Frame IP address [press Enter for steamframe.local]: "
|
||||
if not "!INPUT_IP!"=="" set "FRAME_IP=!INPUT_IP!"
|
||||
|
||||
echo.
|
||||
echo Connecting to steamos@!FRAME_IP!...
|
||||
echo (If prompted for a password, enter your SteamOS desktop password)
|
||||
echo.
|
||||
|
||||
where ssh >nul 2>&1
|
||||
if %ERRORLEVEL% neq 0 (
|
||||
echo [ERROR] SSH is not installed or not in PATH on this PC.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
set "REMOTE_CMD=curl -fsSL https://raw.githubusercontent.com/Pixelplanet/Pixel-VR-Player/main/tools/install.sh | sh -s -- --update"
|
||||
|
||||
ssh -o StrictHostKeyChecking=no steamos@!FRAME_IP! "!REMOTE_CMD!"
|
||||
|
||||
if %ERRORLEVEL% equ 0 (
|
||||
echo.
|
||||
echo ===============================================================
|
||||
echo [SUCCESS] Pixel VR Player has been updated on your Steam Frame!
|
||||
echo ===============================================================
|
||||
) else (
|
||||
echo.
|
||||
echo ===============================================================
|
||||
echo [NOTE] If connection failed, please ensure:
|
||||
echo 1. Both your PC and Steam Frame are on the same Wi-Fi.
|
||||
echo 2. SSH is enabled on the headset.
|
||||
echo (In Desktop Mode, open Konsole and run 'passwd',
|
||||
echo then 'sudo systemctl start sshd', or enable Dev Mode).
|
||||
echo ===============================================================
|
||||
)
|
||||
|
||||
echo.
|
||||
pause
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pixel VR Player - Remote updater from Mac / Linux to Steam Frame
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
echo "==============================================================="
|
||||
echo " Pixel VR Player - Steam Frame Remote Updater"
|
||||
echo "==============================================================="
|
||||
echo " This tool updates Pixel VR Player on your Valve Steam Frame"
|
||||
echo " to the latest release over Wi-Fi directly from your Mac or PC."
|
||||
echo " (All your settings, bookmarks, and shares are kept safe)."
|
||||
echo "==============================================================="
|
||||
echo ""
|
||||
|
||||
read -rp "Enter Steam Frame IP address [press Enter for steamframe.local]: " INPUT_IP
|
||||
FRAME_IP="${INPUT_IP:-steamframe.local}"
|
||||
|
||||
echo ""
|
||||
echo "Connecting to steamos@${FRAME_IP}..."
|
||||
echo "(If prompted for a password, enter your SteamOS desktop password)"
|
||||
echo ""
|
||||
|
||||
REMOTE_CMD="curl -fsSL https://raw.githubusercontent.com/Pixelplanet/Pixel-VR-Player/main/tools/install.sh | sh -s -- --update"
|
||||
|
||||
if ssh -o StrictHostKeyChecking=no "steamos@${FRAME_IP}" "${REMOTE_CMD}"; then
|
||||
echo ""
|
||||
echo "==============================================================="
|
||||
echo " [SUCCESS] Pixel VR Player has been updated on your Steam Frame!"
|
||||
echo "==============================================================="
|
||||
else
|
||||
echo ""
|
||||
echo "==============================================================="
|
||||
echo " [NOTE] If connection failed, please ensure:"
|
||||
echo " 1. Both devices are on the same Wi-Fi network."
|
||||
echo " 2. SSH is enabled on the headset."
|
||||
echo " (In Desktop Mode, open Konsole and run 'passwd',"
|
||||
echo " then 'sudo systemctl start sshd', or enable Dev Mode)."
|
||||
echo "==============================================================="
|
||||
fi
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/bin/sh
|
||||
# Pixel VR Player updater for Valve Steam Frame
|
||||
# Downloads and installs the latest release in-place.
|
||||
set -eu
|
||||
|
||||
REPO="Pixelplanet/Pixel-VR-Player"
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd)
|
||||
INSTALL_DIR="${PIXELVR_INSTALL_DIR:-$HOME/PixelVRPlayer}"
|
||||
|
||||
if [ -n "$SCRIPT_DIR" ] && [ -f "$SCRIPT_DIR/pixelvr" ]; then
|
||||
INSTALL_DIR="$SCRIPT_DIR"
|
||||
fi
|
||||
|
||||
if [ -t 1 ]; then
|
||||
C_INFO=$(printf '\0033[36m')
|
||||
C_OK=$(printf '\0033[32m')
|
||||
C_ERR=$(printf '\0033[31m')
|
||||
C_OFF=$(printf '\0033[0m')
|
||||
else
|
||||
C_INFO=""; C_OK=""; C_ERR=""; C_OFF=""
|
||||
fi
|
||||
|
||||
printf '%s==>%s Checking for Pixel VR Player updates for %s...\n' "$C_INFO" "$C_OFF" "$INSTALL_DIR"
|
||||
|
||||
# Execute in-place update using install.sh with --update flag
|
||||
if [ -f "$INSTALL_DIR/install.sh" ]; then
|
||||
"$INSTALL_DIR/install.sh" --dir "$INSTALL_DIR" --update "$@"
|
||||
else
|
||||
curl -fsSL https://raw.githubusercontent.com/"$REPO"/main/tools/install.sh | sh -s -- --dir "$INSTALL_DIR" --update "$@"
|
||||
fi
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
printf '\n%s ok%s Pixel VR Player update complete!\n' "$C_OK" "$C_OFF"
|
||||
if [ -n "${DISPLAY:-}" ] || [ -n "${WAYLAND_DISPLAY:-}" ]; then
|
||||
if command -v notify-send >/dev/null 2>&1; then
|
||||
notify-send "Pixel VR Player" "Updated to the latest version successfully!" || true
|
||||
fi
|
||||
fi
|
||||
else
|
||||
printf '\n%serror%s Pixel VR Player update failed.\n' "$C_ERR" "$C_OFF" >&2
|
||||
exit 1
|
||||
fi
|
||||
Reference in new issue
Block a user