diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 58acb12..c8b805c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/README.md b/README.md index 285d116..fb7bc6a 100644 --- a/README.md +++ b/README.md @@ -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 | |---|---| diff --git a/docs/studio-guide.md b/docs/studio-guide.md index e4b6dbe..091b23a 100644 --- a/docs/studio-guide.md +++ b/docs/studio-guide.md @@ -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 | diff --git a/docs/using-frame-home.md b/docs/using-frame-home.md index 8ece2dd..59597e8 100644 --- a/docs/using-frame-home.md +++ b/docs/using-frame-home.md @@ -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: diff --git a/frame/payload/bin/frame-switcher b/frame/payload/bin/frame-switcher index a401c13..4571621 100755 Binary files a/frame/payload/bin/frame-switcher and b/frame/payload/bin/frame-switcher differ diff --git a/src/switcher.cpp b/src/switcher.cpp index 64ba379..a3e02a5 100644 --- a/src/switcher.cpp +++ b/src/switcher.cpp @@ -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 +#ifndef _WIN32 +#include +#endif #include #include @@ -37,6 +42,7 @@ #include #include #include +#include #include #include #include @@ -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 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 l(m); return state; } + void set(State s, const std::string& e = {}) { std::lock_guard l(m); state = s; error = e; } + void checkAgainIn(std::chrono::minutes d) { std::lock_guard l(m); next = std::chrono::steady_clock::now() + d; } + bool due() { std::lock_guard 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 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(panel.envs.size())) { panel.hotHover = hoverId; panel.hoverT[c] = 1; } diff --git a/studio/CMakeLists.txt b/studio/CMakeLists.txt index 50d2be7..2086eb5 100644 --- a/studio/CMakeLists.txt +++ b/studio/CMakeLists.txt @@ -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") diff --git a/studio/platform.h b/studio/platform.h index 60a2a38..6d8f148 100644 --- a/studio/platform.h +++ b/studio/platform.h @@ -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. diff --git a/studio/platform_posix.cpp b/studio/platform_posix.cpp index 0f5201a..7405a20 100644 --- a/studio/platform_posix.cpp +++ b/studio/platform_posix.cpp @@ -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__ diff --git a/studio/platform_win32.cpp b/studio/platform_win32.cpp index 592220f..63cfff7 100644 --- a/studio/platform_win32.cpp +++ b/studio/platform_win32.cpp @@ -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(nativeWindow); } namespace { diff --git a/studio/studio.cpp b/studio/studio.cpp index 808894f..c8d1993 100644 --- a/studio/studio.cpp +++ b/studio/studio.cpp @@ -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> 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 members; }; std::vector 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& 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(); g_app = app.get(); app->window = window.hooks(); @@ -9598,6 +9661,8 @@ int runStudio(StudioWindow& window, const std::vector& 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); diff --git a/studio/updater.h b/studio/updater.h new file mode 100644 index 0000000..0993a4f --- /dev/null +++ b/studio/updater.h @@ -0,0 +1,251 @@ +// Frame Studio's own updates. Every push to main replaces the GitHub release "latest", and with it latest.json: +// { "build": "", "home": "", "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 +#include +#include +#include +#include +#include +#include + +#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 swapsFor(const fs::path& unpacked) { + std::vector 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 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 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 l(s_->m); + if (s_->state != State::Available && s_->state != State::Failed) return; + } + if (!begin(State::Installing, "Downloading the update…")) return; + std::shared_ptr s = s_; + std::thread([s] { + const std::string err = download(*s); + std::lock_guard 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 l(s_->m); return s_->state; } + std::string text() const { std::lock_guard l(s_->m); return s_->text; } + +private: + struct Shared { + std::mutex m; + State state = State::Idle; + std::string text, latest; + }; + std::shared_ptr s_ = std::make_shared(); + + bool begin(State st, const char* text) { + std::lock_guard 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 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 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 done; // what was put in place, to undo on a failure + std::vector 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