Updates: Frame Studio and Frame Home update themselves from GitHub

CI stamps every Studio build with its commit (FRAME_BUILD) and the
release gets latest.json: the commit, Frame Home's identity (a hash of
the headset programs in frame/payload) and the files in the release.

Frame Studio (studio/updater.h): checks a few seconds after start and
every 6 hours (Help > Check for Updates any time). A newer build shows
"Update Available" in the top bar; Download and Install fetches this
computer's build with the system's curl, unpacks it with tar, and swaps
each item in (the old one renamed aside first, so the running copy
keeps working and a failure rolls everything back). Restart Now closes
through the normal unsaved-changes prompt, then starts the new copy;
the next start deletes what was set aside. Local builds never update.
New platform calls: systemTool (System32's curl/tar on Windows) and
launch.

Frame Home (src/switcher.cpp): on the Steam Frame the Environments
panel checks a minute after it starts and every 6 hours. When the
installed programs' hash isn't the release's, "Update Frame Home"
shows beside Restart Frame Home; it downloads FrameHome-installer.zip
and runs its install.sh in its own session (which restarts Frame Home
and the panel), and the new panel says "Frame Home is updated". Also
frame-switcher --check-update / --update from a terminal.
frame/payload: frame-switcher rebuilt on the Steam Frame with this.

Tested: Studio's full cycle (check, download, unpack, swap, relaunch,
cleanup) and its rollback against a local fake release on Windows; the
headset's check and update against a fake release on the Frame (old
panel and Frame Home replaced, then up to date); Linux compile of
Studio; Studio --selftest unchanged (98 passed, same 6 known fails).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ToastconcernandClaude Opus 5.5 committed 2026-10-23 11:40:23 -05:00
1 parent 39c8424391
commit fedadd5ae3
12 files changed
+590 -3

No files matched your search

+16
View File
@@ -14,6 +14,10 @@ concurrency:
group: build-${{ github.ref }}
cancel-in-progress: true
# Every Studio build knows the commit it's from (studio/updater.h compares it with the latest release's).
env:
FRAME_BUILD: ${{ github.sha }}
jobs:
linux:
name: Linux ${{ matrix.arch }}
@@ -127,6 +131,18 @@ jobs:
with:
path: dist
merge-multiple: true
- uses: actions/checkout@v4
with:
path: src
# What Frame Studio (studio/updater.h) and the headset's Environments panel check for updates: this commit,
# Frame Home's identity (a hash of the headset programs, the same one the panel works out for what's installed)
# and the files in this release.
- name: latest.json
run: |
home=$(cd src/frame/payload && sha256sum bin/frame-home bin/frame-switcher bin/fenv-tool layer/libframe_home_layer.so layer/env_ps.spv | cut -c1-64 | sha256sum | cut -c1-64)
files=$(ls dist | python3 -c 'import json, sys; print(json.dumps([l.strip() for l in sys.stdin if l.strip()]))')
printf '{"build": "%s", "home": "%s", "date": "%s", "files": %s}\n' "$GITHUB_SHA" "$home" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$files" > dist/latest.json
cat dist/latest.json
- name: Version release (tag v*)
if: ${{ startsWith(github.ref, 'refs/tags/v') && !contains(needs.*.result, 'failure') }}
run: gh release create "${{ github.ref_name }}" dist/* --repo "$REPO" --title "Frame Studio ${{ github.ref_name }}" --generate-notes
+2 -1
View File
@@ -20,7 +20,8 @@ headset runs, and lets you edit, save and send them to the headset.
Every push builds everything. The newest build of `main` is always on the Releases page as **Latest build**; tagged
versions (`v1.0`, ...) get their own release. Builds of other branches are under Actions (open a run, then
Artifacts).
Artifacts). Once installed, Frame Studio and Frame Home both update themselves from **Latest build**: Studio from its
top bar, Frame Home from the Environments panel on the headset.
| Computer | Frame Studio |
|---|---|
+10
View File
@@ -180,6 +180,16 @@ The **Headset** tab (or the **Headset** menu):
(show only the selection) and **Show Everything**.
- **Edit > Menu Search...** (F3): find any command by typing part of its name.
## Updates
Studio checks GitHub for a newer **Latest build** a few seconds after it starts and every 6 hours after that. When
there is one, an orange **Update Available** button shows in the top bar. Click it, then **Download and Install**:
Studio downloads the build for your computer and puts it in place of the copy you're running. **Restart Now** opens
the new one (it asks about unsaved changes first). **Help > Check for Updates...** checks right away.
If Studio's folder can't be written to (Program Files, for example), nothing is changed and the window offers the
download page instead. A copy you built yourself doesn't update itself.
## Keyboard shortcuts
| Key | Does | Key | Does |
+10
View File
@@ -47,8 +47,18 @@ Over SSH (`ssh frame`) you can do the same with the panel's program:
~/frame-home/bin/frame-switcher --list # the environments; the current one is marked with *
~/frame-home/bin/frame-switcher --set 3 # switch, by number from --list or by file name
~/frame-home/bin/frame-switcher --delete NAME # delete one, like the panel's delete button
~/frame-home/bin/frame-switcher --check-update # is there a newer Frame Home on GitHub?
~/frame-home/bin/frame-switcher --update # download and install it now
```
### Updates
The panel checks GitHub for a newer Frame Home a minute after it starts and every 6 hours after that. When there is
one, **Update Frame Home** shows at the top of the panel, beside **Restart Frame Home**. Click it: the headset
downloads the new version and installs it, and Frame Home and the panel restart a few seconds later with
"Frame Home is updated". Your environments and settings stay as they are. If it can't finish, the panel says why;
`~/frame-home/logs/update.log` has the details.
## Adding environments
Every environment lives in `~/frame-home/envs` on the headset. Ways to put one there:
Binary file not shown.
+196 -2
View File
@@ -12,6 +12,8 @@
// frame-switcher --thumbs render missing thumbnails (frame-home --snapshot, from the spawn)
// 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 --check-update is there a newer Frame Home on GitHub? (exit 10: yes)
// frame-switcher --update download it and install it now (the panel's Update Frame Home)
// 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"
@@ -26,6 +28,9 @@
#include "stb_truetype.h"
#include <csignal>
#ifndef _WIN32
#include <sys/wait.h>
#endif
#include <algorithm>
#include <atomic>
@@ -37,6 +42,7 @@
#include <filesystem>
#include <fstream>
#include <map>
#include <mutex>
#include <set>
#include <sstream>
#include <string>
@@ -701,6 +707,146 @@ DebugInfo readDebugInfo() {
return d;
}
// ── Updates (the Steam Frame only) ──
// Frame Home updates from GitHub. The release "latest" carries latest.json, whose "home" is a hash of the headset
// programs (frame-home, frame-switcher, fenv-tool and the compositor layer, worked out by CI from frame/payload). When
// it isn't the hash of what's installed here, the header offers Update Frame Home: that downloads
// FrameHome-installer.zip and runs its install.sh, which puts the new programs in place and restarts Frame Home and
// this panel. Checked a minute after the panel starts, then every 6 hours (30 minutes after a failed check).
#if defined(__linux__) && defined(__aarch64__)
constexpr bool kSelfUpdate = true;
#else
constexpr bool kSelfUpdate = false; // a PC gets Frame Home from Frame Studio
#endif
constexpr const char* kReleaseUrl = "https://github.com/Toastconcern/FrameStudio/releases/download/latest/";
// Where latest.json and the installer are (FH_UPDATE_URL: another place, ending in "/", for testing an update).
std::string releaseUrl() {
const char* u = std::getenv("FH_UPDATE_URL");
return u && *u ? std::string(u) : std::string(kReleaseUrl);
}
struct Updates {
enum class State { Idle, Available, Updating, Failed };
std::mutex m;
State state = State::Idle;
std::string error; // Failed: why (shown once as a toast)
std::atomic<bool> busy{false}; // a check or the update running
std::chrono::steady_clock::time_point next = std::chrono::steady_clock::now() + std::chrono::minutes(1);
State get() { std::lock_guard<std::mutex> l(m); return state; }
void set(State s, const std::string& e = {}) { std::lock_guard<std::mutex> l(m); state = s; error = e; }
void checkAgainIn(std::chrono::minutes d) { std::lock_guard<std::mutex> l(m); next = std::chrono::steady_clock::now() + d; }
bool due() { std::lock_guard<std::mutex> l(m); return std::chrono::steady_clock::now() >= next; }
// A shell line's output and exit code.
static std::string sh(const std::string& cmd, int& rc) {
std::string out;
rc = -1;
#ifndef _WIN32
if (FILE* p = popen(cmd.c_str(), "r")) {
char buf[4096];
for (size_t n; (n = std::fread(buf, 1, sizeof buf, p)) > 0;) out.append(buf, n);
const int st = pclose(p);
rc = WIFEXITED(st) ? WEXITSTATUS(st) : -1;
}
#endif
return out;
}
static std::string quoted(const fs::path& p) { return "'" + p.string() + "'"; }
// What's installed, hashed as CI hashes frame/payload.
static std::string installedHash() {
int rc = 0;
std::string h = sh("cd " + quoted(rootDir()) + " && sha256sum bin/frame-home bin/frame-switcher bin/fenv-tool "
"layer/libframe_home_layer.so layer/env_ps.spv | cut -c1-64 | sha256sum | cut -c1-64", rc);
while (!h.empty() && (h.back() == '\n' || h.back() == '\r')) h.pop_back();
return rc == 0 && h.size() == 64 ? h : std::string();
}
// What GitHub has against what's installed. `why` is set when it couldn't be told.
struct Check {
std::string latest, mine, why;
bool hasInstaller = false;
bool newer() const { return why.empty() && latest != mine && hasInstaller; }
};
static Check checkNow() {
Check c;
int rc = 0;
const std::string json = sh("curl -fsSL --max-time 30 '" + releaseUrl() + "latest.json'", rc);
try {
const tinyjson::Value v = tinyjson::parse(json);
if (v.isObject() && v.has("home")) c.latest = v["home"].asString();
if (v.isObject() && v.has("files"))
for (size_t i = 0; i < v["files"].size(); ++i) c.hasInstaller |= v["files"][i].asString() == "FrameHome-installer.zip";
} catch (...) {}
c.mine = installedHash();
if (rc != 0) c.why = "GitHub not reached";
else if (c.latest.empty()) c.why = "no latest.json in the release";
else if (c.mine.empty()) c.why = "the installed files weren't found";
return c;
}
// Downloads the installer and runs its install.sh. That stops this panel (SteamVR starts the new one), so it
// returns only when something failed (the reason) or SteamVR isn't running (""; updated for its next start).
static std::string installNow() {
const fs::path dir = rootDir() / ".update";
const fs::path log = rootDir() / "logs" / "update.log";
std::error_code ec;
fs::remove_all(dir, ec);
fs::create_directories(dir, ec);
fs::create_directories(log.parent_path(), ec);
{ std::ofstream(dir / "started") << "1\n"; } // the new panel says it's updated
LOG("updating Frame Home from %s", releaseUrl().c_str());
int rc = 0;
sh("curl -fsSL --retry 2 --max-time 1800 -o " + quoted(dir / "FrameHome-installer.zip") + " '" + releaseUrl() +
"FrameHome-installer.zip' > " + quoted(log) + " 2>&1", rc);
if (rc != 0) return "The update didn't download. Check the headset's Wi-Fi and try again.";
sh("unzip -q -o " + quoted(dir / "FrameHome-installer.zip") + " -d " + quoted(dir) + " >> " + quoted(log) + " 2>&1", rc);
const fs::path script = dir / "FrameHome-installer" / "payload" / "install.sh";
if (rc != 0 || !fs::exists(script, ec)) return "The update couldn't be unpacked.";
// Its own session: install.sh stops this panel, and must carry on without it.
sh("setsid sh " + quoted(script) + " >> " + quoted(log) + " 2>&1", rc);
if (rc == 0) { fs::remove(dir / "started", ec); return {}; }
std::string tail = sh("tail -n 1 " + quoted(log), rc);
tail = tail.substr(0, tail.find('\n'));
return "The update failed: " + (tail.empty() ? std::string("see logs/update.log") : tail);
}
// Called from the panel's loop: starts a check in the background when one is due.
void tick() {
if (!kSelfUpdate || busy || !due()) return;
const State s = get();
if (s == State::Available || s == State::Updating) return;
busy = true;
std::thread([this] {
const Check c = checkNow();
if (!c.why.empty()) {
LOG("update check: %s", c.why.c_str());
checkAgainIn(std::chrono::minutes(30));
} else {
LOG("update check: installed %.12s, latest %.12s%s", c.mine.c_str(), c.latest.c_str(), c.hasInstaller ? "" : " (no installer in it)");
if (c.newer()) set(State::Available);
checkAgainIn(std::chrono::hours(6));
}
busy = false;
}).detach();
}
// The header's Update Frame Home.
void install() {
if (!kSelfUpdate || busy || get() != State::Available) return;
busy = true;
set(State::Updating);
std::thread([this] {
const std::string err = installNow();
if (err.empty()) { set(State::Idle); checkAgainIn(std::chrono::hours(6)); }
else set(State::Failed, err);
busy = false;
}).detach();
}
};
Updates g_updates;
struct Panel {
Canvas cv;
Font title, head, body, label, small, tiny, glyphFont;
@@ -719,7 +865,7 @@ struct Panel {
enum class Home { Running, Stopped, TurnedOff, Restarting };
Home home = Home::Running;
bool restartAsked = false; // the button was clicked: the loop restarts or starts it
static constexpr int kHomeId = 9100;
static constexpr int kHomeId = 9100, kUpdateId = 9102;
// Layout
static constexpr int kCols = 3, kPad = 40, kTop = 112, kGap = 24, kThumbH = 216, kInfoH = 84, kCardH = kThumbH + kInfoH;
@@ -1267,6 +1413,19 @@ struct Panel {
cv.rect(bx0, by0, x1, by1, kR, fill);
label.drawMid(cv, bx0 + 20, cy, button, !enabled ? kFaint : primary ? kOnAccent : kText);
if (enabled) addHot(bx0, by0, x1, by1, kHomeId, false);
const Updates::State us = g_updates.get();
if (us == Updates::State::Available || us == Updates::State::Updating) { // in place of the state's text
const bool can = us == Updates::State::Available, uh = can && hotHover == kUpdateId;
const char* ub = can ? "Update Frame Home" : "Updating…";
const float ux1 = bx0 - 16, ux0 = ux1 - label.width(ub) - 40;
cv.rect(ux0, by0, ux1, by1, kR, !can ? kSurface : uh ? 0x34363b : kSurfaceHi);
label.drawMid(cv, ux0 + 20, cy, ub, can ? kText : kFaint);
if (can) {
cv.rect(ux1 - 14, by0 + 10, ux1 - 8, by0 + 16, 3, kAccent); // a dot: something new
addHot(ux0, by0, ux1, by1, kUpdateId, false);
}
return;
}
if (*text) small.drawMid(cv, bx0 - 20 - small.width(text), cy, text, kFaint);
}
void scrollbar() {
@@ -1371,6 +1530,11 @@ struct Panel {
if (home != Home::Restarting) restartAsked = true;
return;
}
if (id == kUpdateId) {
g_updates.install();
say("Updating Frame Home: it restarts in a moment", false, 6);
return;
}
if (id >= 9000 && id < 9003) {
if (page != id - 9000) { page = id - 9000; hotHover = -1; sc[page].active = now - 10; if (page == 2) info = readDebugInfo(); }
return;
@@ -1792,6 +1956,11 @@ int runPanel() {
for (const Env& e : scanEnvs()) missing |= e.thumb.rgba.empty();
if (missing) homeos::run({homeos::selfPath().string(), "--thumbs"}, 19, false);
}
Updates::State shownUpdate = Updates::State::Idle;
{ // started by an update (install.sh restarted the panel): say so
std::error_code ec;
if (fs::remove(rootDir() / ".update" / "started", ec)) { panel.now = Panel::clockNow(); panel.say("Frame Home is updated", false, 5); }
}
double lastSmooth = -10;
int scrollLogs = 0;
bool dirty = true, wasVisible = false;
@@ -1803,6 +1972,16 @@ int runPanel() {
if (!panel.restartAsked) keepSystem(t);
dirty |= homeTick(t);
keepBackground();
g_updates.tick();
const Updates::State us = g_updates.get();
if (us == Updates::State::Failed) { // said once; the button stays for another try
std::string err;
{ std::lock_guard<std::mutex> l(g_updates.m); err = g_updates.error; }
LOG("update: %s", err.c_str());
panel.say(err, true, 8);
g_updates.set(Updates::State::Available);
}
if (us != shownUpdate) { shownUpdate = us; dirty = true; }
}
panel.now = Panel::clockNow();
vr::VREvent_t ev;
@@ -2073,10 +2252,24 @@ int main(int argc, char** argv) {
if (a == "--thumbs") return runThumbs();
if (a == "--uninstall") return runUninstall();
if (a == "--unpack-bundles") { unpackBundles(); return 0; }
if (a == "--check-update") { // what the panel's update check finds. Exit 0: up to date, 10: an update, 1: unknown
const Updates::Check c = Updates::checkNow();
if (!c.why.empty()) { std::printf("couldn't check: %s\n", c.why.c_str()); return 1; }
std::printf("installed %s\nlatest %s\n%s\n", c.mine.c_str(), c.latest.c_str(),
c.newer() ? "an update is available" : c.latest == c.mine ? "up to date" : "different, but the release has no installer");
return c.newer() ? 10 : 0;
}
if (a == "--update") { // what the panel's Update Frame Home does (whether or not the check found one)
const std::string err = Updates::installNow();
if (!err.empty()) { std::fprintf(stderr, "%s\n", err.c_str()); return 1; }
std::printf("updated (SteamVR isn't running: it takes over when SteamVR starts)\n");
return 0;
}
if (a == "--test-delete" && argc > 2) return runTestDelete(argv[2]);
if (a == "--render" && argc > 2) { // the panel as it looks now, to a PPM (no SteamVR needed)
// frame-switcher --render out.ppm [hover] [page] [scroll px] [switching card]
// (FH_RENDER_CONFIRM=n: card n asks "Delete?"; FH_RENDER_TOAST=text: a toast)
// (FH_RENDER_CONFIRM=n: card n asks "Delete?"; FH_RENDER_TOAST=text: a toast; FH_RENDER_UPDATE=1|2: an update
// available | being installed)
Panel panel;
panel.loadFonts();
panel.envs = scanEnvs();
@@ -2089,6 +2282,7 @@ int main(int argc, char** argv) {
panel.info = readDebugInfo();
panel.stats = statsLine();
panel.hoverT.assign(panel.envs.size(), 0.0f);
if (const char* u = std::getenv("FH_RENDER_UPDATE"); u && *u) g_updates.set(*u == '2' ? Updates::State::Updating : Updates::State::Available);
// page 0: hover is a card number, or a raw id (>= 10000: e.g. 30000 + n is card n's delete button)
const int hoverId = panel.page == 0 && hover >= 0 ? (hover >= Panel::kCardId ? hover : Panel::kCardId + hover) : -1;
if (const int c = Panel::cardOf(hoverId); c >= 0 && c < static_cast<int>(panel.envs.size())) { panel.hotHover = hoverId; panel.hoverT[c] = 1; }
+5
View File
@@ -11,6 +11,11 @@ if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE Release)
endif()
# The build's identity for updates (studio/updater.h): the commit CI built ($FRAME_BUILD); empty for a local build,
# which never updates itself.
set(FRAME_BUILD "$ENV{FRAME_BUILD}")
add_compile_definitions(FRAME_BUILD="${FRAME_BUILD}")
set(ROOT "${CMAKE_CURRENT_SOURCE_DIR}/..")
set(TP "${ROOT}/third_party")
+5
View File
@@ -29,8 +29,13 @@ std::tm localTime(std::time_t t); // (thread-safe localtime)
int run(const std::string& commandLine, const fs::path& workingDir, std::string& out);
// ssh / scp, ready to prefix arguments: quoted when it's a path, with a trailing space.
std::string sshTool(const char* name);
// The system's own curl / tar (Windows 10 and later ship both in System32; Git's tar there can't open a .zip), ready
// to prefix arguments like sshTool.
std::string systemTool(const char* name);
// Opens a folder in the file browser, or a file / URL in its usual program.
void openPath(const std::string& pathOrUrl);
// Starts a program (or, on macOS, a .app) on its own: not waited for, it outlives Frame Studio. False: not started.
bool launch(const fs::path& program);
// ── Dialogs ── (modal; they block until answered)
// The window dialogs belong to (Windows: its HWND), so they come up over it. Null: none.
+11
View File
@@ -183,6 +183,17 @@ int run(const std::string& commandLine, const fs::path& workingDir, std::string&
}
std::string sshTool(const char* name) { return std::string(name) + " "; }
std::string systemTool(const char* name) { return std::string(name) + " "; }
bool launch(const fs::path& program) {
#ifdef __APPLE__
const std::string cmd = "open -n " + shellQuote(program.string()) + " >/dev/null 2>&1";
#else // (its own output away from run's pipe, or run would wait for it to end)
const std::string cmd = "cd " + shellQuote(program.parent_path().string()) + " && nohup " + shellQuote(program.string()) + " >/dev/null 2>&1 &";
#endif
std::string out;
return run(cmd, {}, out) == 0;
}
void openPath(const std::string& pathOrUrl) {
#ifdef __APPLE__
+19
View File
@@ -115,10 +115,29 @@ std::string sshTool(const char* name) {
return std::string(name) + " ";
}
std::string systemTool(const char* name) {
wchar_t sys[MAX_PATH] = {};
GetSystemDirectoryW(sys, MAX_PATH);
const fs::path p = fs::path(sys) / (widen(name) + L".exe");
std::error_code ec;
return fs::exists(p, ec) ? "\"" + narrow(p.wstring()) + "\" " : std::string(name) + " ";
}
void openPath(const std::string& pathOrUrl) {
ShellExecuteW(nullptr, L"open", widen(pathOrUrl).c_str(), nullptr, nullptr, SW_SHOWNORMAL);
}
bool launch(const fs::path& program) {
std::wstring cmd = L"\"" + program.wstring() + L"\"";
const std::wstring dir = program.parent_path().wstring();
STARTUPINFOW si = {sizeof si};
PROCESS_INFORMATION pi = {};
if (!CreateProcessW(program.c_str(), cmd.data(), nullptr, nullptr, FALSE, 0, nullptr, dir.c_str(), &si, &pi)) return false;
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
return true;
}
void setDialogOwner(void* nativeWindow) { g_owner = static_cast<HWND>(nativeWindow); }
namespace {
+65
View File
@@ -27,6 +27,7 @@
#include "world_runtime.h"
#include "frame_pacer.h"
#include "platform.h"
#include "updater.h"
#define STB_IMAGE_WRITE_IMPLEMENTATION
#define STBI_WRITE_NO_STDIO
#include "stb_image_write.h"
@@ -665,6 +666,10 @@ struct Studio {
int undoGroup = 0, nextUndoGroup = 1; // pushUndo tags steps with undoGroup while a batch is open
std::vector<std::vector<std::string>> recent; // File > Open Recent: each entry footprint [+ vista]
bool showControls = false; // Help > Controls
updater::Updater updates; // Frame Studio's own updates (studio/updater.h)
bool showUpdate = false; // the update window (Help > Check for Updates, or the top bar's button)
bool restartAfterClose = false; // "Restart": start the updated copy once this one has closed
std::chrono::steady_clock::time_point nextUpdateCheck = std::chrono::steady_clock::now() + std::chrono::seconds(5);
bool testing = false; // --selftest: leave the recent list and edits alone
struct TestEye { ImVec2 at; std::vector<size_t> members; };
std::vector<TestEye> testEyes; // --selftest: the explorer's eye buttons this frame (folders and rows)
@@ -6403,6 +6408,8 @@ struct Studio {
if (ImGui::BeginMenu("Help")) {
if (ImGui::MenuItem("Controls", "F1", showControls)) showControls = !showControls;
if (ImGui::MenuItem("Menu Search", "F3")) openSearch();
ImGui::Separator();
if (ImGui::MenuItem("Check for Updates…")) { showUpdate = true; updates.check(); }
ImGui::EndMenu();
}
// The workspace tabs: the active one raised like Blender's
@@ -6423,6 +6430,17 @@ struct Studio {
dl->AddText(ImVec2(a.x + (bw - ts.x) * 0.5f, a.y + (bh - ts.y) * 0.5f), col(on ? 0xffffff : kDim), kWorkspaceName[w]);
ImGui::SameLine(0, 2 * scale);
}
const updater::State us = updates.state();
if (us == updater::State::Available || us == updater::State::Installing || us == updater::State::Ready) {
ImGui::Dummy(ImVec2(10 * scale, 0));
ImGui::SameLine(0, 0);
ImGui::PushStyleColor(ImGuiCol_Button, hex(kOrange));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, hex(kOrange));
ImGui::PushStyleColor(ImGuiCol_Text, hex(0xffffff));
const char* label = us == updater::State::Ready ? "Restart to Update###upd" : us == updater::State::Installing ? "Updating…###upd" : "Update Available###upd";
if (ImGui::SmallButton(label)) showUpdate = true;
ImGui::PopStyleColor(3);
}
if (scene) { // the open home's name, at the right end as Blender shows its scene
const std::string name = shownName() + (dirty ? " *" : "");
const float w = ImGui::CalcTextSize(name.c_str()).x;
@@ -7354,6 +7372,7 @@ struct Studio {
addPopup();
searchPopup();
controlsWindow();
updateWindow();
settingsWindow();
reportWindow();
headsetDebugWindow();
@@ -7974,6 +7993,49 @@ struct Studio {
}
ImGui::End();
}
// Updates: checked a few seconds after start and every 6 hours (Help > Check for Updates any time); the top bar
// shows a button when there is one. Installing happens only when asked, here.
void updateWindow() {
const auto now = std::chrono::steady_clock::now();
if (!testing && !g_selfTestRun && updater::Updater::canUpdate() && now >= nextUpdateCheck) {
nextUpdateCheck = now + std::chrono::hours(6);
updates.check();
}
if (!showUpdate) return;
ImGui::SetNextWindowSize(ImVec2(380 * scale, 0), ImGuiCond_Appearing);
ImGui::SetNextWindowPos(ImVec2(ImGui::GetIO().DisplaySize.x * 0.5f, ImGui::GetIO().DisplaySize.y * 0.4f), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
if (!ImGui::Begin("Frame Studio Updates", &showUpdate, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoDocking)) { ImGui::End(); return; }
ImGui::PushTextWrapPos(360 * scale);
if (!updater::Updater::canUpdate()) {
ImGui::TextUnformatted("This copy of Frame Studio was built on this computer, not downloaded from GitHub, so it doesn't update itself.");
if (ImGui::Button("Open the Downloads Page")) platform::openPath(updater::kPageUrl);
ImGui::PopTextWrapPos();
ImGui::End();
return;
}
const updater::State s = updates.state();
ImGui::TextUnformatted(updates.text().c_str());
ImGui::TextDisabled("This build: %.7s", updater::kBuild);
ImGui::Spacing();
if (s == updater::State::Available) {
if (ImGui::Button("Download and Install")) updates.install();
ImGui::SameLine();
if (ImGui::Button("Later")) showUpdate = false;
} else if (s == updater::State::Ready) {
if (ImGui::Button("Restart Now")) { restartAfterClose = true; if (window.requestClose) window.requestClose(); }
ImGui::SameLine();
if (ImGui::Button("Later")) showUpdate = false;
} else if (s == updater::State::Failed) {
if (ImGui::Button("Try Again")) updates.check();
ImGui::SameLine();
if (ImGui::Button("Open the Downloads Page")) platform::openPath(updater::kPageUrl);
} else if (s == updater::State::Current) {
if (ImGui::Button("Check Again")) updates.check();
}
ImGui::PopTextWrapPos();
ImGui::End();
}
};
@@ -9416,6 +9478,7 @@ int runStudio(StudioWindow& window, const std::vector<std::string>& args) {
const float scale = window.uiScale();
platform::setDialogOwner(window.nativeHandle());
g_selfTestRun = !args.empty() && args[0] == "--selftest"; // (before anything saves)
updater::cleanUp(); // what the last update set aside
auto app = std::make_unique<Studio>();
g_app = app.get();
app->window = window.hooks();
@@ -9598,6 +9661,8 @@ int runStudio(StudioWindow& window, const std::vector<std::string>& args) {
vkDeviceWaitIdle(r.device());
window.stopHangWatchdog();
if (app->saver.joinable()) app->saver.join();
if (app->restartAfterClose && !platform::launch(updater::updatedProgram())) // Help > updates: Restart Now
platform::message("Frame Studio", "The updated Frame Studio didn't start. Open it again from its folder.", platform::Icon::Warning);
if (app->loadState == 1 || app->loadAbandoned) { // a home still being read: nothing to lose, and waiting for it is what stalls
std::fflush(stderr);
std::_Exit(0);
+251
View File
@@ -0,0 +1,251 @@
// Frame Studio's own updates. Every push to main replaces the GitHub release "latest", and with it latest.json:
// { "build": "<commit>", "home": "<Frame Home's hash>", "files": ["FrameStudio-windows-x64.zip", ...] }
// A build from another commit than this one (FRAME_BUILD, set by CI) is offered. Installing downloads this computer's
// file, unpacks it, and puts each thing in it where the old one was (the old one renamed aside first: the running
// copy keeps going, and a failure puts everything back). Frame Studio then restarts into it; the next start deletes
// what was set aside. A local build (no FRAME_BUILD) never updates itself.
#pragma once
#include "platform.h"
#include <cstdio>
#include <cstdlib>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#ifndef FRAME_BUILD
#define FRAME_BUILD ""
#endif
namespace updater {
namespace fs = std::filesystem;
inline constexpr const char* kBuild = FRAME_BUILD;
inline constexpr const char* kReleaseUrl = "https://github.com/Toastconcern/FrameStudio/releases/download/latest/";
// Where latest.json and the downloads are (FS_UPDATE_URL: another place, ending in "/", for testing an update).
inline std::string releaseUrl() {
const char* u = std::getenv("FS_UPDATE_URL");
return u && *u ? std::string(u) : std::string(kReleaseUrl);
}
inline constexpr const char* kPageUrl = "https://github.com/Toastconcern/FrameStudio/releases/tag/latest";
// This computer's download in a release (as CI names them).
inline std::string assetName() {
#if defined(_WIN32)
#if defined(_M_ARM64) || defined(__aarch64__)
return "FrameStudio-windows-arm64.zip";
#elif defined(_M_IX86) || defined(__i386__)
return "FrameStudio-windows-x86.zip";
#else
return "FrameStudio-windows-x64.zip";
#endif
#elif defined(__APPLE__)
#if defined(__aarch64__)
return "FrameStudio-macos-arm64.zip";
#else
return "FrameStudio-macos-x64.zip";
#endif
#else
#if defined(__aarch64__)
return "FrameStudio-linux-arm64.tar.gz";
#elif defined(__i386__)
return "FrameStudio-linux-x86.tar.gz";
#else
return "FrameStudio-linux-x64.tar.gz";
#endif
#endif
}
// The folder the download unpacks to: the asset's name without its extension.
inline std::string assetFolder() {
const std::string a = assetName();
return a.substr(0, a.find('.'));
}
inline fs::path workDir() { return platform::userDataDir() / L"FrameStudio" / L"update"; }
// The string value of "key" in a small JSON object ("" when it's not there).
inline std::string jsonString(const std::string& json, const std::string& key) {
size_t p = json.find("\"" + key + "\"");
if (p == std::string::npos) return {};
p = json.find(':', p);
if (p == std::string::npos) return {};
p = json.find('"', p);
if (p == std::string::npos) return {};
const size_t e = json.find('"', p + 1);
return e == std::string::npos ? std::string() : json.substr(p + 1, e - p - 1);
}
// What the update replaces: each thing the download holds, and where it goes. On macOS the whole .app.
struct Swap { fs::path from, to; };
inline std::vector<Swap> swapsFor(const fs::path& unpacked) {
std::vector<Swap> out;
std::error_code ec;
#ifdef __APPLE__
const fs::path bundle = platform::exeDir().parent_path().parent_path(); // .../Frame Studio.app/Contents/MacOS
out.push_back({unpacked / L"Frame Studio.app", bundle});
#else
for (const auto& e : fs::directory_iterator(unpacked, ec)) out.push_back({e.path(), platform::exeDir() / e.path().filename()});
#endif
return out;
}
// The program to start after an update.
inline fs::path updatedProgram() {
#if defined(__APPLE__)
return platform::exeDir().parent_path().parent_path();
#elif defined(_WIN32)
return platform::exeDir() / L"Frame Studio.exe";
#else
return platform::exeDir() / L"Frame Studio";
#endif
}
// At start: delete what the last update set aside (listed in update/aside.txt).
inline void cleanUp() {
const fs::path list = workDir() / L"aside.txt";
std::error_code ec;
if (FILE* f = platform::openFile(list, "rb")) {
char line[4096];
while (std::fgets(line, sizeof line, f)) {
std::string s = line;
while (!s.empty() && (s.back() == '\n' || s.back() == '\r')) s.pop_back();
if (!s.empty()) fs::remove_all(fs::path(platform::widen(s)), ec);
}
std::fclose(f);
}
fs::remove_all(workDir(), ec);
}
enum class State { Idle, Checking, Current, Available, Installing, Ready, Failed };
class Updater {
public:
// A local build can't tell what it is, so it has nothing to compare.
static bool canUpdate() { return kBuild[0] != 0; }
// Asks GitHub for latest.json, in the background. Nothing happens while a check or install is running.
void check() {
if (!canUpdate() || !begin(State::Checking, "Checking for updates…")) return;
std::shared_ptr<Shared> s = s_;
std::thread([s] {
std::string out;
const std::string url = releaseUrl() + "latest.json";
const int rc = platform::run(platform::systemTool("curl") + "-fsSL --max-time 30 \"" + url + "\"", {}, out);
std::lock_guard<std::mutex> l(s->m);
const std::string build = jsonString(out, "build");
if (rc != 0 || build.empty()) { s->state = State::Failed; s->text = "Couldn't reach GitHub to check for updates."; return; }
s->latest = build;
if (build == kBuild) { s->state = State::Current; s->text = "Frame Studio is up to date."; return; }
if (out.find("\"" + assetName() + "\"") == std::string::npos) { // that build has none for this computer (yet)
s->state = State::Current; s->text = "Frame Studio is up to date.";
return;
}
s->state = State::Available;
s->text = "A new version of Frame Studio is ready (build " + build.substr(0, 7) + ").";
}).detach();
}
// Downloads the update and puts it in place of this copy, in the background. When it's done: Ready.
void install() {
{
std::lock_guard<std::mutex> l(s_->m);
if (s_->state != State::Available && s_->state != State::Failed) return;
}
if (!begin(State::Installing, "Downloading the update…")) return;
std::shared_ptr<Shared> s = s_;
std::thread([s] {
const std::string err = download(*s);
std::lock_guard<std::mutex> l(s->m);
if (!err.empty()) { s->state = State::Failed; s->text = err; return; }
s->state = State::Ready;
s->text = "The update is installed. Restart Frame Studio to use it.";
}).detach();
}
State state() const { std::lock_guard<std::mutex> l(s_->m); return s_->state; }
std::string text() const { std::lock_guard<std::mutex> l(s_->m); return s_->text; }
private:
struct Shared {
std::mutex m;
State state = State::Idle;
std::string text, latest;
};
std::shared_ptr<Shared> s_ = std::make_shared<Shared>();
bool begin(State st, const char* text) {
std::lock_guard<std::mutex> l(s_->m);
if (s_->state == State::Checking || s_->state == State::Installing || s_->state == State::Ready) return false;
s_->state = st;
s_->text = text;
return true;
}
static void say(Shared& s, const std::string& t) { std::lock_guard<std::mutex> l(s.m); s.text = t; }
// The download, unpacked and swapped in. Empty when it worked, else what went wrong (nothing changed then).
static std::string download(Shared& s) {
std::error_code ec;
const fs::path dir = workDir();
fs::remove_all(dir, ec);
fs::create_directories(dir / L"unpacked", ec);
const fs::path file = dir / platform::widen(assetName());
std::string out;
const std::string url = releaseUrl() + assetName();
if (platform::run(platform::systemTool("curl") + "-fsSL --retry 2 --max-time 1800 -o \"" + platform::narrow(file.wstring()) + "\" \"" + url + "\"", {}, out) != 0)
return "The download didn't finish. Check your connection and try again.";
say(s, "Unpacking the update…");
out.clear();
if (platform::run(platform::systemTool("tar") + "-xf \"" + platform::narrow(file.wstring()) + "\" -C \"" + platform::narrow((dir / L"unpacked").wstring()) + "\"", {}, out) != 0)
return "The download couldn't be unpacked.";
const fs::path unpacked = dir / L"unpacked" / platform::widen(assetFolder());
const std::vector<Swap> swaps = swapsFor(unpacked);
if (swaps.empty() || !fs::exists(swaps.front().from, ec)) return "The download isn't a Frame Studio build.";
say(s, "Installing the update…");
std::vector<Swap> done; // what was put in place, to undo on a failure
std::vector<fs::path> aside;
auto undo = [&] {
for (auto it = done.rbegin(); it != done.rend(); ++it) {
std::error_code e;
fs::remove_all(it->to, e);
if (!it->from.empty()) fs::rename(it->from, it->to, e);
}
};
for (const Swap& sw : swaps) {
fs::path old;
if (fs::exists(sw.to, ec)) {
old = sw.to;
old += L".old";
fs::remove_all(old, ec);
ec.clear();
fs::rename(sw.to, old, ec);
if (ec) { undo(); return "Frame Studio's folder can't be changed (" + ec.message() + "). Download the update from GitHub instead."; }
aside.push_back(old);
}
done.push_back({old, sw.to});
ec.clear();
fs::rename(sw.from, sw.to, ec); // (another drive than the download: copied instead)
if (ec) { ec.clear(); fs::copy(sw.from, sw.to, fs::copy_options::recursive, ec); }
if (ec) { undo(); return "The update couldn't be put in place (" + ec.message() + "). Nothing was changed."; }
}
#ifndef _WIN32
fs::permissions(updatedProgram(), fs::perms::owner_exec | fs::perms::group_exec | fs::perms::others_exec, fs::perm_options::add, ec);
#endif
if (FILE* f = platform::openFile(dir / L"aside.txt", "wb")) {
for (const fs::path& p : aside) std::fprintf(f, "%s\n", platform::narrow(p.wstring()).c_str());
std::fclose(f);
}
fs::remove_all(dir / L"unpacked", ec);
fs::remove(file, ec);
return {};
}
};
} // namespace updater