// frame-switcher — the Steam Frame's environment switcher: a SteamVR dashboard panel listing every environment // in ~/frame-home/envs (Frame environments .fenv, home APKs, and footprint + vista pairs from pairs.txt) with // thumbnails. Point and click one: ~/frame-home/current changes and Frame Home switches to it live. A bundle of // environments (.fbundle) put there is unpacked into its .fenv files, which the panel then lists one by one. // // frame-switcher the dashboard panel (SteamVR starts it: see --install) // frame-switcher --install register with SteamVR and start it with SteamVR from now on // frame-switcher --list the environments, the current one marked with * // frame-switcher --set NAME switch (NAME: the file name, or a number from --list) // frame-switcher --thumbs render missing thumbnails (frame-home --snapshot) into envs/.thumbs // frame-switcher --delete NAME delete an environment (like the panel's delete button; NAME as for --set) // frame-switcher --unpack-bundles unpack the bundles (.fbundle) in envs now (the panel does it by itself) // frame-switcher --test-delete DIR self-check of deleting, in a fake root made in DIR (must not exist yet) #include "fenv.h" #include "fenv_bundle.h" #include "overlay_texture.h" #include "overlays.h" #include "perf_settings.h" #include "proc_util.h" #include #define STB_TRUETYPE_IMPLEMENTATION #include "stb_truetype.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include extern "C" unsigned char* stbi_load_from_memory(const unsigned char* buffer, int len, int* x, int* y, int* comp, int req_comp); extern "C" void stbi_image_free(void* retval_from_stbi_load); #define LOG(...) do { std::fprintf(stderr, "[frame-switcher] " __VA_ARGS__); std::fputc('\n', stderr); } while (0) namespace fs = std::filesystem; namespace { constexpr const char* kAppKey = "framehome.switcher"; constexpr int W = 1280, H = 800; fs::path rootDir() { const char* home = std::getenv("HOME"); return fs::path(home && *home ? home : "/home/steamos") / "frame-home"; } std::string readText(const fs::path& p) { std::ifstream f(p); return std::string((std::istreambuf_iterator(f)), std::istreambuf_iterator()); } struct Image { std::vector rgba; int w = 0, h = 0; }; Image loadImage(const fs::path& p) { Image img; std::ifstream f(p, std::ios::binary); std::vector b((std::istreambuf_iterator(f)), std::istreambuf_iterator()); if (b.size() > 16 && b[0] == 'P' && b[1] == '6') { // frame-home --snapshot writes binary PPM std::istringstream hdr(std::string(b.begin(), b.begin() + std::min(b.size(), 64))); std::string magic; int w = 0, h = 0, maxv = 0; hdr >> magic >> w >> h >> maxv; const size_t off = static_cast(hdr.tellg()) + 1; if (w > 0 && h > 0 && off + static_cast(w) * h * 3 <= b.size()) { img.w = w; img.h = h; img.rgba.resize(static_cast(w) * h * 4); for (size_t i = 0; i < static_cast(w) * h; ++i) { img.rgba[i * 4] = b[off + i * 3]; img.rgba[i * 4 + 1] = b[off + i * 3 + 1]; img.rgba[i * 4 + 2] = b[off + i * 3 + 2]; img.rgba[i * 4 + 3] = 255; } } return img; } if (b.empty()) return img; int w = 0, h = 0, c = 0; if (unsigned char* px = stbi_load_from_memory(b.data(), static_cast(b.size()), &w, &h, &c, 4)) { img.w = w; img.h = h; img.rgba.assign(px, px + static_cast(w) * h * 4); stbi_image_free(px); } return img; } Image decodeBytes(const std::vector& b) { Image img; int w = 0, h = 0, c = 0; if (!b.empty()) if (unsigned char* px = stbi_load_from_memory(b.data(), static_cast(b.size()), &w, &h, &c, 4)) { img.w = w; img.h = h; img.rgba.assign(px, px + static_cast(w) * h * 4); stbi_image_free(px); } return img; } // ── The environments ── struct Env { std::string name, kind; // shown std::vector files; // what goes into `current` (names in envs/) std::string key; // `current` contents, one file per line Image thumb; Image fitted; // thumb scaled to the card (made when first drawn) }; std::string pretty(std::string stem) { // Meta's shell homes: "shell-env-vista-calming-quest3s-buck_206.0.0.0.70_974701191" -> "calming" if (stem.rfind("shell-env-", 0) == 0) { stem = stem.substr(10); for (const char* role : {"footprint-", "vista-"}) if (stem.rfind(role, 0) == 0) stem = stem.substr(std::strlen(role)); if (const size_t q = stem.find("-quest"); q != std::string::npos && q > 0) stem = stem.substr(0, q); } // package names: "com.oculus.environment.prod.courtyard" -> "courtyard" if (stem.rfind("com.", 0) == 0 && stem.find_last_of('.') + 1 < stem.size()) stem = stem.substr(stem.find_last_of('.') + 1); // trailing version numbers: "_206.0.0.0.70_974701191" for (size_t u; (u = stem.find_last_of('_')) != std::string::npos && u > 0 && u + 1 < stem.size() && stem.find_first_not_of("0123456789.", u + 1) == std::string::npos;) stem.erase(u); for (char& c : stem) if (c == '_' || c == '-') c = ' '; if (!stem.empty()) stem[0] = static_cast(std::toupper(static_cast(stem[0]))); return stem; } fs::path thumbPath(const std::string& key) { std::string n = key; for (char& c : n) if (c == '\n' || c == '/') c = '+'; return rootDir() / "envs" / ".thumbs" / (n + ".ppm"); } std::vector scanEnvs() { const fs::path envs = rootDir() / "envs"; std::vector out; std::error_code ec; std::vector files; for (const auto& e : fs::directory_iterator(envs, ec)) { const std::string ext = e.path().extension().string(); if (e.is_regular_file(ec) && (ext == ".fenv" || ext == ".apk")) files.push_back(e.path()); } std::sort(files.begin(), files.end()); // Footprint + vista pairs the user (or Frame Studio) listed: "haven2025.apk calming.apk" per line. std::istringstream pairs(readText(rootDir() / "pairs.txt")); std::string line; while (std::getline(pairs, line)) { std::istringstream l(line); std::string a, b; if (!(l >> a >> b) || !fs::exists(envs / a, ec) || !fs::exists(envs / b, ec)) continue; Env e; e.name = pretty(fs::path(a).stem().string()) + " + " + pretty(fs::path(b).stem().string()); e.kind = "Footprint + Vista"; e.files = {a, b}; out.push_back(e); } for (const fs::path& p : files) { Env e; e.files = {p.filename().string()}; if (p.extension() == ".fenv") { FenvManifest m; std::vector thumb; if (!fenvReadManifest(p.string(), m, &thumb)) continue; e.name = m.name.empty() ? pretty(p.stem().string()) : m.name; e.kind = m.sources.size() > 1 ? "Frame environment, footprint + vista" : "Frame environment"; e.thumb = decodeBytes(thumb); } else { e.name = pretty(p.stem().string()); const std::string fn = p.filename().string(); e.kind = fn.find("-vista-") != std::string::npos ? "Meta home, vista only" : fn.find("-footprint-") != std::string::npos ? "Meta home, footprint only" : "Meta home"; } out.push_back(e); } for (Env& e : out) { for (const auto& f : e.files) e.key += f + "\n"; if (e.thumb.rgba.empty()) e.thumb = loadImage(thumbPath(e.key)); } return out; } std::string currentKey() { std::istringstream lines(readText(rootDir() / "current")); std::string key, n; while (std::getline(lines, n)) if (!n.empty()) key += n + "\n"; return key; } bool setCurrent(const Env& e) { const fs::path tmp = rootDir() / "current.part"; { std::ofstream f(tmp); for (const auto& n : e.files) f << n << "\n"; if (!f) return false; } std::error_code ec; fs::rename(tmp, rootDir() / "current", ec); // atomic: Frame Home never reads half a file if (ec) return false; LOG("switched to %s", e.name.c_str()); return true; } // ── Deleting an environment ── // Removes the card's files from envs/ (both of a footprint + vista pair), their Frame Studio companions // (.world.json, .edits.json), the thumbnails naming them, the pairs.txt lines naming them and, for a .fenv, // its unpack folders in the cache. Never touches anything but plain environment file names directly in envs/. When // `current` names a deleted file it is moved to another environment first (or emptied when none is left). // A name in envs/ that may be deleted: a plain .fenv / .apk file name, no path parts. bool deletableName(const std::string& n) { if (n.empty() || n.size() > 255 || n[0] == '.' || n.find_first_of("/\\\n\r") != std::string::npos || n.find("..") != std::string::npos) return false; const std::string ext = fs::path(n).extension().string(); return ext == ".fenv" || ext == ".apk"; } // Writes `current` (atomically, like setCurrent); an empty list empties it. bool writeCurrent(const std::vector& files) { const fs::path tmp = rootDir() / "current.part"; { std::ofstream f(tmp); for (const auto& n : files) f << n << "\n"; if (!f) return false; } std::error_code ec; fs::rename(tmp, rootDir() / "current", ec); return !ec; } // The .fenv cache entries of one envs/ file: "---