Generated reference › API — ICoreBlocks/ICoreUpdater
kind: generated#api#icoreblocks-icoreupdater

API — ICoreBlocks/ICoreUpdater

The public contract of 21 header(s) under src/ICoreBlocks/ICoreUpdater — 24 class/struct definition(s), 162 declaration(s). Each section shows the header's banner and its public (and protected-virtual) surface exactly as the file writes it.

ICoreAppImageUpdate.h#

src/ICoreBlocks/ICoreUpdater/ICoreAppImageUpdate.h

ICoreAppImageUpdate#

ICoreAppImageUpdate.h:63 · class · nested Environment, Plan · 6 declaration(s)

ICoreAppImageUpdate — applying an update to a Linux AppImage (U5.3).

class ICoreAppImageUpdate {
public:
    ICoreAppImageUpdate() = delete;

    enum class Outcome {
        Ready,                    // plan() only: nothing stands in the way
        Applied,                  // apply() only: the new AppImage is in place
        HandedOff,                // handOff() only: the new AppImage started; QUIT NOW

        // ---- refusals: NOTHING was touched ------------------------------
        NotAnAppImage,            // $APPIMAGE is unset -- this is not one
        RefusedByInstallKind,     // a package manager owns it (U5.4), or Unknown
        StagedFileMissing,
        DifferentFilesystem,      // rename(2) would be EXDEV

        // ---- failures ---------------------------------------------------
        CouldNotMakeExecutable,   // chmod on the STAGED file; nothing has moved
        SwapFailed,               // ICoreUpdateInstaller reverted; old one is back
        RelaunchFailed            // the swap SUCCEEDED and exec did not
    };

    // Everything plan() may look at. Taken as a value so a case can describe a
    // Linux AppImage on a machine that has never seen one.
    struct Environment {
        // $APPIMAGE, as ICoreInstallKind::Environment already collects it.
        // Empty means this process is not an AppImage.
        std::string appImagePath;

        // ICoreInstallKind's verdict. ⚠ Read as `selfUpdateAllowed`, never
        // re-derived from `kind` -- SystemPrograms' answer changes the day
        // U8.2 is decided, and a second derivation would not change with it.
        bool selfUpdateAllowed = false;

        // The argv this process was started with, for the relaunch. argv[0] is
        // REPLACED with the AppImage path: the running argv[0] is whatever the
        // runtime put there, which on some launchers is not a path at all.
        std::vector<std::string> arguments;
    };

    struct Plan {
        Outcome outcome = Outcome::NotAnAppImage;

        std::filesystem::path target;          // $APPIMAGE -- the file replaced
        std::filesystem::path staged;          // the verified replacement
        std::filesystem::path keepPreviousAt;  // where the old one goes (U5.5)

        // What relaunch() will exec. argv[0] is `target`.
        std::vector<std::string> relaunchArguments;

        // For the log. Names paths, so never shown raw to the user.
        std::string detail;

        // Body in the .cpp: the header surface rule permits none here.
        [[nodiscard]] bool ok() const;
    };

    // PURE. Answers every refusal before a single byte moves, so a caller can
    // tell the user why without half-applying anything.
    [[nodiscard]] static Plan plan(const Environment&           environment,
                                   const std::filesystem::path& staged,
                                   const std::filesystem::path& keepPreviousAt);

    // The file work, in the order the header argues for: make the staged file
    // executable, then swap. Returns Applied with the new AppImage at
    // `plan.target` and the old one at `plan.keepPreviousAt`.
    //
    // Does NOT relaunch. The caller arms ICoreUpdateFirstRun between the two,
    // because after relaunch() this process is gone.
    static Outcome apply(const Plan& plan, std::string* detail);

    // execv(plan.target, plan.relaunchArguments). ⚠ DOES NOT RETURN ON
    // SUCCESS. A return is always a failure, and the caller's next move is
    // ICoreUpdateInstaller::revertTo() -- the swap worked and the build cannot
    // be started, which is precisely the case U5.5 exists for.
    static Outcome relaunch(const Plan& plan, std::string* detail);

    // The relaunch the running APPLICATION uses (ICoreUpdateFlow, 2026-09-28):
    // start the new AppImage detached, RETURN, and let the caller quit through
    // the normal path -- the macOS `open -n` shape. relaunch()'s execv replaces
    // this process on the spot, skipping every save and shutdown hook, which
    // is right for a headless tool and wrong for a window somebody has work in.
    static Outcome handOff(const Plan& plan, std::string* detail);

    // One sentence, user-facing. Says what to DO where there is anything to do.
    [[nodiscard]] static std::string describe(Outcome outcome);

    static int selfTest(std::vector<std::string>* failures);
};
};

ICoreDeltaBundle.h#

src/ICoreBlocks/ICoreUpdater/ICoreDeltaBundle.h

ICoreDeltaBundle#

ICoreDeltaBundle.h:46 · class · nested Entry, Manifest · 3 declaration(s)

ICoreDeltaBundle — assembling an update from a delta instead of a full artifact (ACCOUNT_MANAGER U8.4).

class ICoreDeltaBundle {
public:
    static constexpr const char* FORMAT        = "icore-delta-1";
    static constexpr const char* MANIFEST_NAME = "icore-delta.json";
    static constexpr const char* PAYLOAD_DIR   = "payload";

    enum class Outcome {
        Assembled,          // `assembled` holds a complete, verified bundle
        NotADelta,          // no manifest, or a format this build does not know
        WrongVersions,      // the delta is not FROM what is installed
        ExtractFailed,      // ditto refused the zip
        CopyFailed,         // the installed bundle could not be copied
        VerifyFailed,       // assembled, and does not match the manifest
        NotMacOs
    };

    // One entry in the resulting bundle. A symlink carries a target and no
    // digest; hashing what it points AT would make an identical-looking bundle
    // out of a link and a copy, and they are not the same to a code signature.
    struct Entry {
        bool        isSymlink  = false;
        bool        executable = false;
        std::string sha256;         // lowercase hex, empty for a symlink
        std::string target;         // symlink target, empty for a file

        [[nodiscard]] bool operator==(const Entry& other) const;
        [[nodiscard]] bool operator!=(const Entry& other) const;
    };

    struct Manifest {
        std::string format;
        std::string product;
        std::string platform;
        std::string fromVersion;
        std::string toVersion;

        std::vector<std::string>     deletions;
        std::map<std::string, Entry> entries;

        [[nodiscard]] bool ok() const;
    };

    // PURE. Rejects anything it does not fully understand rather than applying
    // the half it does.
    [[nodiscard]] static bool parseManifest(const std::string& json,
                                            Manifest*          out,
                                            std::string*       error);

    // Copy, delete, overlay, verify. `assembled` is only set on Assembled.
    //
    // ⚠ `runningVersion` is checked against the manifest's from_version before
    // a byte is copied: a delta applied over the wrong base is the one failure
    // that could produce a bundle that runs and is subtly wrong.
    static Outcome assemble(const std::filesystem::path& installedBundle,
                            const std::filesystem::path& deltaZip,
                            const std::filesystem::path& workRoot,
                            const std::string&           runningVersion,
                            std::filesystem::path*       assembled,
                            std::string*                 detail);

    // Every entry, and every entry only. Returns the complaints; empty is a
    // pass. Public because a caller may want to re-assert it after its own
    // step, and because a suite has to be able to ask it directly.
    [[nodiscard]] static std::vector<std::string> verify(
        const Manifest& manifest, const std::filesystem::path& bundle);

    // ⚠ EVERY refusal means "take the full artifact", and this says so in code
    // rather than in the header's prose. "…and then fall back" is exactly the
    // sentence that gets dropped when somebody writes the flow six months from
    // now, and a delta that fails without a fallback is an update that simply
    // stops working for the users whose install drifted.
    //
    // The switch has no `default:`, on purpose: a new Outcome added later will
    // not compile until somebody decides which side of this it falls on.
    [[nodiscard]] static bool fallsBackToFullArtifact(Outcome outcome);

    [[nodiscard]] static std::string describe(Outcome outcome);

    static int selfTest(std::vector<std::string>* failures);
};
};

ICoreDownloadClient.h#

src/ICoreBlocks/ICoreUpdater/ICoreDownloadClient.h

ICoreDownloadClient#

ICoreDownloadClient.h:42 · class · nested Result · 4 declaration(s)

ICoreDownloadClient / ICoreHttpDownloadClient / ICoreFakeDownloadClient "May I download this release, and where from?" — POST /api/download, which the Worker answers by calling grant_download() and...

class ICoreDownloadClient {
public:
    struct Result {
        // The exchange completed and the body parsed. Says NOTHING about
        // whether the download was authorised -- see grant.ok.
        bool ok = false;

        // 0 when no response line arrived at all.
        int httpStatus = 0;

        // Diagnostics. Names hosts and timeouts; never shown to a user.
        std::string error;

        ICoreDownloadGrant grant;
    };

    using Callback = std::function<void(const Result&)>;

    virtual ~ICoreDownloadClient();

    // Holds an in-flight exchange in its subclass; copying one is meaningless.
    // PUBLIC rather than private, matching ICoreReleaseClient: a deleted
    // operation is a public contract, not private data.
    ICoreDownloadClient(const ICoreDownloadClient&)            = delete;
    ICoreDownloadClient& operator=(const ICoreDownloadClient&) = delete;

    // `releaseId` is ICoreUpdateManifest::Release::id -- the uuid, not the
    // version, because a version is not unique across platforms and kinds.
    // `token` is the licence JWT; empty is refused without a round trip.
    virtual void requestGrant(const std::string& releaseId,
                              const std::string& platform,
                              const std::string& token,
                              Callback           done) = 0;

protected:
    ICoreDownloadClient();
};
};

ICoreHttpDownloadClient#

ICoreDownloadClient.h:83 · class · bases public ICoreDownloadClient · pImpl · 4 declaration(s)

The real one.

class ICoreHttpDownloadClient : public ICoreDownloadClient {
public:
    // ⚠ NO BASE URL ARGUMENT, unlike ICoreReleaseClient. That class takes one
    // because the check endpoint is harmless public metadata; this one carries
    // the licence token, and a peer that can be pointed elsewhere is a peer
    // that can be pointed at a machine collecting them. Same call as
    // ICoreLicenseClient's, and for the same reason.
    ICoreHttpDownloadClient();
    ~ICoreHttpDownloadClient() override;

    void requestGrant(const std::string& releaseId,
                      const std::string& platform,
                      const std::string& token,
                      Callback           done) override;

    // "https://systemsicore.com/api". A constant, exposed so a diagnostics
    // line can print it -- not so anything can change it.
    [[nodiscard]] static std::string baseUrl();

    // ---- the part that can be wrong, exposed so it can be tested ----------
    //
    // Pure. The transport needs a network; the MAPPING is where the defects
    // live, and it is the difference between an upsell and a bug report.
    //
    // `reachedServer` is false when no response line arrived at all.
    [[nodiscard]] static Result mapReply(int httpStatus,
                                         const std::string& body,
                                         bool reachedServer);

    void setTimeoutMsecs(int msecs);

private:
    class Impl;                    // the two-line residue; state lives here
    std::unique_ptr<Impl> impl;
};

ICoreFakeDownloadClient#

ICoreDownloadClient.h:124 · class · bases public ICoreDownloadClient · pImpl · 9 declaration(s)

The test one.

class ICoreFakeDownloadClient : public ICoreDownloadClient {
public:
    ICoreFakeDownloadClient();
    ~ICoreFakeDownloadClient() override;

    void requestGrant(const std::string& releaseId,
                      const std::string& platform,
                      const std::string& token,
                      Callback           done) override;

    // What the next call answers. Set once; it stands until replaced.
    void setResult(const Result& result);

    // A DIFFERENT answer for the second grant onwards, which is what U3.3
    // needs: the first grant hands out a URL that expires mid-transfer, and
    // the re-grant must hand out a fresh one. Unset means every call answers
    // setResult()'s value.
    void setResultForRegrant(const Result& result);

    // Convenience for the three shapes that matter.
    [[nodiscard]] static Result granted(const std::string& url,
                                        const std::string& version,
                                        const std::string& sha256,
                                        std::int64_t       sizeBytes);
    [[nodiscard]] static Result denied(ICoreDownloadDenial::Reason reason);
    [[nodiscard]] static Result unreachable();

    [[nodiscard]] int         grantCount() const;
    [[nodiscard]] std::string lastReleaseId() const;
    [[nodiscard]] std::string lastToken() const;

private:
    class Impl;                    // the two-line residue; state lives here
    std::unique_ptr<Impl> impl;
};

ICoreDownloadDenial.h#

src/ICoreBlocks/ICoreUpdater/ICoreDownloadDenial.h

ICoreDownloadDenial#

ICoreDownloadDenial.h:40 · class · 6 declaration(s)

ICoreDownloadDenial grant_download() answers {"ok": false, "deny_reason": "..."} for eight distinct reasons, and this is the map from each one to what the user is told.

class ICoreDownloadDenial {
public:
    ICoreDownloadDenial() = delete;

    enum class Reason {
        None,                       // ok == true; not a denial
        UnknownRelease,             // "unknown_release"
        Yanked,                     // "yanked"
        NoLicense,                  // "no_license"
        InvalidLicense,             // "invalid_license"
        TierLacksFeature,           // "tier_lacks_feature"
        MaintenanceExpired,         // "maintenance_expired"
        VersionBeyondEntitlement,   // "version_beyond_entitlement"
        RateLimited,                // "rate_limited"
        Unverified,                 // "unverified"
        Unrecognised                // the server knows something this build does not
    };

    // What the caller should DO about it, which is not derivable from the
    // reason without repeating this table at every call site.
    enum class Disposition {
        NotDenied,
        Retryable,      // rate_limited: try later, automatically, with backoff
        Upsell,         // maintenance_expired: a purchase would fix it
        NeedsSignIn,    // no_license / invalid_license / unverified
        Blocked,        // yanked / tier_lacks_feature: nothing the user can do now
        Defect          // version_beyond_entitlement / unknown_release: report it
    };

    [[nodiscard]] static Reason      fromServerString(std::string_view denyReason);
    [[nodiscard]] static std::string serverString(Reason reason);
    [[nodiscard]] static Disposition dispositionOf(Reason reason);

    // ⚠ ONE SENTENCE PER REASON, AND NO TWO ARE THE SAME. That is not a style
    // preference: the suite asserts it, because collapsing two of these is
    // the defect this class exists to prevent and it is invisible in review.
    [[nodiscard]] static std::string userText(Reason reason);

    // Every enumerator except None, for a loop that must not miss one when
    // the server adds a reason.
    [[nodiscard]] static std::vector<Reason> allDenials();
};
};

ICoreDownloadGrant.h#

src/ICoreBlocks/ICoreUpdater/ICoreDownloadGrant.h

ICoreDownloadGrant

The parsed answer to POST /api/download — one authorisation to fetch one release, or one refusal with a reason. This is grant_download()'s return value with the JSON gone (ICorePublicFace/AccountsDataBase/icoreblocks_licensing.sql).

⚠ A REFUSAL PARSES SUCCESSFULLY. {"ok": false, "deny_reason": "..."} is a well-formed answer to a well-formed question, and parse() returns TRUE for it. Only a body that is not JSON, or is JSON that says neither yes nor no, is a parse failure. Collapsing the two is how "your maintenance lapsed" becomes "download failed" — see ICoreDownloadDenial, which exists entirely to stop that.

ICoreDownloadGrant#

ICoreDownloadGrant.h:47 · struct · 1 declaration(s)

A PARSED DTO, so its members are PUBLIC and there is no Impl — the same call as ICoreUpdateManifest's.

struct ICoreDownloadGrant {
public:

    // True only when the server authorised the download.
    bool ok = false;

    // Meaningful when !ok. Reason::None when ok.
    ICoreDownloadDenial::Reason denial = ICoreDownloadDenial::Reason::None;

    // Exactly as the server spelled it, kept even when `denial` came back
    // Unrecognised — a reason this build has never heard of is the one thing
    // worth reporting verbatim in a support conversation.
    std::string rawDenyReason;

    // ⚠ SECRET, AND SHORT-LIVED. See the header note. Empty when !ok.
    std::string url;

    std::string  version;
    std::string  sha256;        // lowercase hex; what U4.1 compares against
    std::int64_t sizeBytes = 0;
    std::string  signature;     // detached Ed25519 over the sha256; may be
                                // empty until U0.4/U0.5 land

    // ISO-8601, as the server spelled it. Diagnostics only: the client does
    // NOT decide the URL has expired by comparing clocks — a machine with a
    // wrong clock would then never download anything, or would keep using a
    // dead URL. Expiry is learned from the fetch, which answers 403.
    std::string expiresAt;

    // Echoed by grant_download so the client and the server can be seen to
    // agree. ICoreUpdateChecker applied these BEFORE the request; a
    // disagreement here is a defect report, not a licence problem.
    std::string versionCeiling;
    bool        inCeilingGrace = false;

    // ---- parsing ---------------------------------------------------------

    // Returns false and fills `error` only when the body is not JSON, is not
    // an object, or carries neither an authorisation nor a refusal. A refusal
    // returns TRUE with ok == false — see the header note.
    [[nodiscard]] static bool parse(const std::string& json,
                                    ICoreDownloadGrant* out,
                                    std::string* error);

    // ---- the request -----------------------------------------------------

    // POST <baseUrl>/download   — the body.
    //
    // `platform` is sent although the release id already implies it: it costs
    // nothing and it is what lets the Worker refuse a macOS artifact to a
    // Windows client rather than presign one it will never be able to run.
    [[nodiscard]] static std::string requestBody(const std::string& releaseId,
                                                 const std::string& platform);

    // The file name to stage the artifact under, taken from the URL's last
    // path segment with the query string dropped.
    //
    // ⚠ THIS IS ATTACKER-INFLUENCED TEXT and is never used as a path on its
    // own. ICoreUpdateStaging::artifactPath() sanitises it and joins it to a
    // directory this process owns; a name with a slash, a "..", or nothing
    // usable in it lands as the fallback below rather than escaping anywhere.
    [[nodiscard]] std::string fileName() const;
};
};

ICoreInstallKind.h#

src/ICoreBlocks/ICoreUpdater/ICoreInstallKind.h

ICoreInstallKind#

ICoreInstallKind.h:58 · class · nested Environment, Result · 5 declaration(s)

ICoreInstallKind How this copy of the app got onto the machine, and therefore whether it is allowed to replace itself.

class ICoreInstallKind {
public:
    ICoreInstallKind() = delete;

    enum class Kind {
        Unknown,            // could not tell -- refuses, see the note above
        Standalone,         // a bundle/binary run from wherever it was unpacked
        UserApplications,   // ~/Applications, %LOCALAPPDATA%\Programs, ~/.local
        SystemPrograms,     // /Applications (self-updates when writable, U6.5), %ProgramFiles%
        AppImage,           // a Linux AppImage: one file, replaceable in place
        PackageManaged      // apt/dnf/Homebrew/Snap/Flatpak/MSIX owns it
    };

    // Everything detect() is allowed to look at. Collected by
    // currentEnvironment(); fabricated wholesale by a test.
    struct Environment {
        // The running executable, symlinks NOT resolved -- what the process
        // was launched by (ICoreStandardPaths::applicationFilePath()).
        std::filesystem::path executablePath;

        // The same file with symlinks resolved, or empty when it could not be
        // resolved. BOTH are needed and neither substitutes for the other: a
        // Homebrew formula puts a symlink in /opt/homebrew/bin pointing into
        // the Cellar, so the launched path hides the manager and the resolved
        // path reveals it. A cask does the opposite -- the resolved path is an
        // ordinary /Applications bundle and the CASKROOM is upstream of it.
        std::filesystem::path resolvedExecutablePath;

        std::filesystem::path homePath;         // $HOME / %USERPROFILE%

        // Set by the launcher of the packaging system in question, and each
        // one is conclusive on its own -- no path guessing needed.
        std::string appImagePath;               // $APPIMAGE
        std::string snapName;                   // $SNAP_NAME
        std::string flatpakId;                  // $FLATPAK_ID

        std::filesystem::path programFiles;      // %ProgramFiles%
        std::filesystem::path programFilesX86;   // %ProgramFiles(x86)%
        std::filesystem::path localAppData;      // %LOCALAPPDATA%

        // Can THIS PROCESS move the .app bundle it runs from out of its folder
        // and a new one in? Write + search on the folder, write on the bundle
        // (moving a directory rewrites its ".."), and no sticky bit on the
        // folder reserving the bundle for another owner. Measured with
        // access(2) by currentEnvironment() on macOS, false everywhere else.
        // Only a macOS /Applications copy reads it (U6.5).
        bool bundleReplaceable = false;
    };

    struct Result {
        Kind kind = Kind::Unknown;

        // False for PackageManaged, Unknown, a Windows SystemPrograms install
        // and a macOS /Applications copy this account cannot replace. This is
        // the single question U5.4 asks; do not re-derive it from `kind` at a
        // call site -- SystemPrograms' answer already changed once (U6.5), and
        // a re-derivation elsewhere did not change with it.
        bool selfUpdateAllowed = false;

        // Self-update is refused, but the verified artifact can still be put
        // in the user's hands to install by dragging it: a macOS
        // /Applications copy this account cannot replace (U6.6). Never true
        // for a package manager -- a hand-installed copy over Homebrew's is
        // exactly the corruption U5.4 exists to prevent. Always false when
        // selfUpdateAllowed is true.
        bool manualInstallOffered = false;

        // The package manager's name when there is one ("Homebrew", "APT or
        // DNF", "Snap", "Flatpak", "MSIX"), otherwise empty.
        std::string manager;

        // What the user should do instead, in the manager's own words
        // ("brew upgrade --cask icoreblocks"). Empty when self-update is
        // allowed. U5.4 shows this; it is never a path, so it is safe to log.
        std::string upgradeHint;

        // Why self-update is refused, as a sentence. Empty when it is allowed.
        std::string reason;

        // U6.8: the https page where the owner of this install updates it --
        // today only the Microsoft Store listing, for an MSIX. The dialog
        // offers it as "Open in Microsoft Store"; empty for every other kind.
        std::string storePageUrl;

        // What an apply step would have to replace: the .app bundle on macOS,
        // the AppImage file on Linux, the executable elsewhere. Empty when
        // the kind is Unknown. Never send this anywhere -- it is a local path,
        // and the telemetry rule forbids paths (U7.1).
        std::filesystem::path installRoot;
    };

    // Pure. Everything it knows is in `env`.
    [[nodiscard]] static Result detect(const Environment& env);

    // The one impure function: reads the process's own paths and environment.
    [[nodiscard]] static Environment currentEnvironment();

    // Convenience for the boot path: detect(currentEnvironment()).
    [[nodiscard]] static Result detect();

    // For logs and the account/update panels. Stable, English, not localised.
    [[nodiscard]] static std::string describe(Kind kind);
};
};

ICoreMacOsUpdate.h#

src/ICoreBlocks/ICoreUpdater/ICoreMacOsUpdate.h

ICoreMacOsUpdate#

ICoreMacOsUpdate.h:67 · class · nested Environment, Plan · 4 declaration(s)

ICoreMacOsUpdate — applying an update to a macOS .app bundle (U5.1).

class ICoreMacOsUpdate {
public:
    ICoreMacOsUpdate() = delete;

    enum class Outcome {
        Ready,                    // plan() only
        Extracted,                // extract() only: the bundle is out and unmounted
        Applied,                  // apply() only: the new bundle is installed
        HandedOff,                // relaunch() only: `open` accepted it; QUIT NOW

        // ---- refusals: NOTHING was touched ------------------------------
        NotMacOs,
        RefusedByInstallKind,     // SystemPrograms (U8.2), package-managed, Unknown
        StagedFileMissing,
        UnknownArtifactKind,      // not a .dmg and not a .zip

        // ---- failures ---------------------------------------------------
        CouldNotMount,
        NoBundleInArtifact,       // it mounted/unzipped and held no .app
        CouldNotCopyOut,
        CouldNotClearQuarantine,
        SwapFailed,               // ICoreUpdateInstaller reverted; old one is back
        RelaunchFailed            // the swap SUCCEEDED and `open` did not
    };

    struct Environment {
        // The .app being replaced — the BUNDLE ROOT, not Contents/MacOS/x.
        std::filesystem::path currentBundle;

        // ⚠ Read as `selfUpdateAllowed`, never re-derived from `kind`:
        // SystemPrograms' answer changes the day U8.2 is decided and a second
        // derivation would not change with it.
        bool selfUpdateAllowed = false;

        std::vector<std::string> arguments;   // carried through the relaunch
    };

    struct Plan {
        Outcome outcome = Outcome::NotMacOs;

        std::filesystem::path target;          // the installed .app
        std::filesystem::path staged;          // the VERIFIED .dmg or .zip
        std::filesystem::path workRoot;        // private; the mount and the copy
        std::filesystem::path keepPreviousAt;  // where the old bundle goes (U5.5)

        std::vector<std::string> relaunchArguments;
        std::string detail;

        // Body in the .cpp: the header surface rule permits none here.
        [[nodiscard]] bool ok() const;
    };

    // PURE. Every refusal is answered before a byte moves.
    [[nodiscard]] static Plan plan(const Environment&           environment,
                                   const std::filesystem::path& staged,
                                   const std::filesystem::path& workRoot,
                                   const std::filesystem::path& keepPreviousAt);

    // Mounts (or unzips), copies the .app out, and DETACHES — on every path
    // out, including failure. Returns the extracted bundle's path in `bundle`.
    //
    // ⚠ The caller must have re-asserted ICoreUpdateVerifier::stillTheSameFile()
    // on `plan.staged` immediately before calling this. What is verified is the
    // ARTIFACT; what is installed is what comes out of it.
    static Outcome extract(const Plan& plan,
                           std::filesystem::path* bundle,
                           std::string* detail);

    // Clears com.apple.quarantine on `bundle`, then swaps it into place.
    static Outcome apply(const Plan&                  plan,
                         const std::filesystem::path& bundle,
                         std::string*                 detail);

    // U6.6 -- the install this copy may NOT do itself, put in the user's
    // hands instead: a verified .dmg is opened (Finder mounts it and shows the
    // drag-to-Applications window), a .zip is revealed in Finder. Only ever
    // called for a user who pressed Download; the automatic path never opens a
    // window. False, and nothing opened, for any other artifact or off macOS.
    static bool openForManualInstall(const std::filesystem::path& verifiedArtifact,
                                     std::string*                 detail);

    // `/usr/bin/open -n <target>`, detached. ⚠ RETURNS on success, unlike the
    // AppImage path's execv — see the header. HandedOff means the caller must
    // now quit; anything else means the swap happened and the new build could
    // not be started, which is what U5.5's revert is for.
    static Outcome relaunch(const Plan& plan, std::string* detail);

    [[nodiscard]] static std::string describe(Outcome outcome);

    static int selfTest(std::vector<std::string>* failures);
};
};

ICoreReleaseClient.h#

src/ICoreBlocks/ICoreUpdater/ICoreReleaseClient.h

ICoreReleaseClient#

ICoreReleaseClient.h:36 · class · nested Result · 4 declaration(s)

ICoreReleaseClient / ICoreHttpReleaseClient "Ask the server what the newest build is." An interface, because every test in this module has to run with no network, and a real implementation over ICo...

class ICoreReleaseClient {
public:
    struct Result {
        bool ok = false;                // false = ask again later, say nothing

        // 0 when the exchange never got a response at all (no network, DNS
        // failure, timeout). Distinguished from a 4xx/5xx because only the
        // latter says anything about the server's opinion of the request.
        int httpStatus = 0;

        // Diagnostics. Never shown to a user: it names hosts and timeouts.
        std::string error;

        ICoreUpdateManifest manifest;   // meaningful only when ok
    };

    using Callback = std::function<void(const Result&)>;

    virtual ~ICoreReleaseClient();

    // Holds an in-flight exchange in its subclass; copying one is meaningless.
    // PUBLIC rather than private, deliberately: the header surface rule reads
    // a deleted operation in the private section as private data, and a
    // deleted member is a public contract -- "you may not do this" -- not an
    // implementation detail.
    ICoreReleaseClient(const ICoreReleaseClient&)            = delete;
    ICoreReleaseClient& operator=(const ICoreReleaseClient&) = delete;

    // `currentVersion` is sent so the server can answer whether the RUNNING
    // build was yanked — see ICoreUpdateManifest::requestUrl.
    virtual void fetchLatest(const std::string& platform,
                             const std::string& channel,
                             const std::string& currentVersion,
                             Callback           done) = 0;

protected:
    ICoreReleaseClient();
};
};

ICoreHttpReleaseClient#

ICoreReleaseClient.h:79 · class · bases public ICoreReleaseClient · pImpl · 4 declaration(s)

The real one.

class ICoreHttpReleaseClient : public ICoreReleaseClient {
public:
    // `baseUrl` is the origin only — "https://systemsicore.com". The path is
    // this class's, so a caller cannot point it at a different endpoint by
    // accident.
    //
    // ⚠ https ONLY, and it is checked. An http:// base would make the whole
    // exchange readable and rewritable by anything on the path, and the answer
    // it carries decides what the machine downloads next.
    explicit ICoreHttpReleaseClient(std::string baseUrl);
    ~ICoreHttpReleaseClient() override;

    void fetchLatest(const std::string& platform,
                     const std::string& channel,
                     const std::string& currentVersion,
                     Callback           done) override;

    // Total cap per attempt, and how many attempts. Defaults are 10 s and 3,
    // set in the constructor: a version check is small, idempotent and
    // completely uninteresting to the user, so it may retry freely and must
    // never sit on a half-open socket.
    void setTimeoutMsecs(int msecs);
    void setRetryPolicy(int attempts, int initialBackoffMsecs);

private:
    class Impl;                    // the two-line residue; state lives here
    std::unique_ptr<Impl> impl;
};

ICoreSemVer.h#

src/ICoreBlocks/ICoreUpdater/ICoreSemVer.h

ICoreSemVer#

ICoreSemVer.h:55 · class · nested Key, Vector · 6 declaration(s)

ICoreSemVer "Is version A strictly newer than version B?" — the client half of a comparison the SERVER already makes, and the reason this class exists at all rather than a three-line helper in the ...

class ICoreSemVer {
public:
    ICoreSemVer() = delete;

    // The three-component sort key, exactly semver_key()'s int[3].
    // `ok` is false when a component would overflow Postgres' int4 — see the
    // divergence note above. The returned key is then meaningless.
    struct Key {
        int  major = 0;
        int  minor = 0;
        int  patch = 0;
        bool ok    = true;
    };

    [[nodiscard]] static Key key(const std::string& version);

    // semver_gt(a, b) — is `a` STRICTLY newer than `b`?
    // False when either side is not comparable, so an unusable version is
    // never treated as an upgrade.
    [[nodiscard]] static bool greaterThan(const std::string& a, const std::string& b);

    // True when key() can produce a usable answer for this string. Every
    // string that is not overflowing is comparable — including nonsense,
    // which reads as 0.0.0.
    [[nodiscard]] static bool isComparable(const std::string& version);

    // ---- The ported vectors -------------------------------------------
    //
    // The SQL ships no vector table, so these are transcribed from the
    // function bodies clause by clause and each one names the clause it
    // pins. tools/semver_vectors.sql runs the SAME table through Postgres,
    // so the owner can prove the two agree on a live database rather than
    // taking this file's word for it. If you add a vector, add it there too.
    struct Vector {
        const char* a;
        const char* b;
        bool        expected;    // semver_gt(a, b)
        const char* pins;        // the clause this case exists to pin
    };

    [[nodiscard]] static std::vector<Vector> vectors();

    // Runs vectors() and appends a line per failure. Returns the failure
    // count, so 0 is a pass. U7.7 registers this in ICorePreReleaseTests;
    // until then it is callable from anywhere that wants the proof.
    static int selfTest(std::vector<std::string>* failures);
};
};

ICoreUpdateChecker.h#

src/ICoreBlocks/ICoreUpdater/ICoreUpdateChecker.h

Named in decide()'s default argument, so the umbrella's declaration is not enough -- the type has to be complete here.

ICoreUpdateChecker#

ICoreUpdateChecker.h:49 · class · nested Decision · 2 declaration(s)

ICoreUpdateChecker The ONE place that turns a manifest, a licence and a machine id into "offer this build" or "do not, and here is why".

class ICoreUpdateChecker {
public:
    ICoreUpdateChecker() = delete;

    enum class Outcome {
        // Nothing published, or nothing newer than what is running.
        UpToDate,

        // Newer, entitled, inside the rollout: offer it.
        UpdateAvailable,

        // Newer exists and the licence's version ceiling excludes it. NOT an
        // error -- see the header note.
        HeldByCeiling,

        // Newer exists and this machine is not in the rollout percentage yet.
        // Say nothing to the user; ask again tomorrow.
        HeldByRollout,

        // The RUNNING build was pulled. This outranks everything else,
        // including "up to date", because latest_releases excludes yanked
        // rows and the person running one is otherwise told they are current.
        RunningYanked,

        // The running build is BELOW the product's supported-version floor
        // (`product_policy.min_supported_version`, U8.1). Stronger than a yank
        // in what it says -- "this version is no longer supported" is about a
        // whole range of builds rather than one bad one -- and it is the only
        // outcome here driven by a value somebody types on the server rather
        // than by a comparison of published facts.
        //
        // ⚠ IT DOES NOT STOP THE APPLICATION, and that is a decision (U8.1,
        // answered 2026-08-21). The client learns it is unsupported and says so
        // unmistakably; refusing to run on a server's say-so is a separate,
        // larger question, because a wrong floor would brick every install
        // below it including customers who cannot update today. A user who
        // ignores the banner keeps working.
        //
        // ⚠ ONLY WHEN NOTHING CAN BE INSTALLED FROM HERE (2026-09-28). A build
        // below the floor that CAN take the published release -- newer,
        // entitled, installable on this OS, direct from what is running --
        // gets UpdateAvailable with `supportedFloor` set instead, so the
        // automatic path and the Install button reach exactly the users the
        // floor was raised for. This outcome used to answer both cases, and
        // offersUpdate() is false for it, so raising the floor switched in-app
        // updating OFF for everybody below it. What is left here is a user who
        // has to leave the app to move: held by the ceiling, the OS, an
        // interim version, a paused rollout, or nothing newer published.
        RunningUnsupported,

        // The published build needs a newer OS than this machine is running
        // (`releases.min_os_version`). NOT offered: an update that installs
        // and then will not launch is worse than one never offered, because
        // the user is left with a broken install and no obvious way back.
        RequiresNewerOs,

        // The published build cannot be installed DIRECTLY from what is
        // running (`releases.min_upgradable_from`) -- a project-format or
        // settings-layout change needs an intermediate version stepped
        // through first. Also not an error: the update is real and the user
        // can have it, in two steps.
        NeedsIntermediateVersion,

        // A version string on one side or the other cannot be compared (see
        // ICoreSemVer's int4-overflow divergence). Nothing is offered.
        NotComparable
    };

    struct Decision {
        Outcome     outcome = Outcome::UpToDate;

        // The version the outcome is about: the newer build for
        // UpdateAvailable/HeldByCeiling/HeldByRollout, the running one for
        // RunningYanked, empty for UpToDate.
        std::string version;

        // ⚠ U8.4 -- WHICH ARTIFACT TO FETCH, AND THE ONE TO FETCH INSTEAD.
        // `artifact` is the delta when the server offered one that applies to
        // exactly what is running, and the full release otherwise; `fallback`
        // is ALWAYS the full release. Two fields rather than a flag because
        // every delta failure -- a refused download, a digest that does not
        // match, a bundle that does not assemble -- has the same remedy, and a
        // caller holding only the delta would have to go back to the checker to
        // learn what it was. Both empty unless the outcome offers an update.
        ICoreUpdateManifest::Release artifact;
        ICoreUpdateManifest::Release fallback;

        // True when `artifact` is a delta. Sugar, but it stops every call site
        // spelling the same two-part test and getting it half right.
        [[nodiscard]] bool usesDelta() const;

        // The floor the server states, when the RUNNING build is below it, so
        // the message can name what the user has to reach rather than telling
        // them they are "too old" and leaving them to guess. Present for
        // RunningUnsupported, and for an UpdateAvailable that is the way off an
        // unsupported build -- see belowSupportedFloor().
        std::string supportedFloor;

        // Present only for HeldByCeiling.
        std::string ceiling;

        // Present only for RequiresNewerOs: the floor the release states, and
        // what this machine actually reports. The second is included because
        // the interesting failure is an EMPTY one -- see the note on
        // `osVersionUnknown` below.
        std::string requiredOsVersion;
        std::string runningOsVersion;

        // ⚠ TRUE WHEN THE OS FLOOR WAS NOT ENFORCED BECAUSE THE OS VERSION
        // COULD NOT BE READ. The update is offered anyway, deliberately (see
        // decide()'s note), and this is how a support conversation can tell
        // "we checked and it passed" from "we could not check".
        bool osVersionUnknown = false;

        // Present only for NeedsIntermediateVersion: the oldest version that
        // may upgrade straight to `version`.
        std::string minUpgradableFrom;

        // 0-99 for this machine and this release; -1 when no release was
        // considered. Reported so a support conversation can ask for it
        // instead of guessing why a user has not been offered a build.
        int bucket = -1;

        // The release's own rollout_percent, echoed for the same reason.
        int rolloutPercent = 100;

        // ⚠ ENGLISH, AND USER-VISIBLE. It is deliberately here and not in the
        // panel: the ceiling wording is a licence statement, and it has been
        // wrong in three different places in this design's history. One
        // sentence, no exclamation, never an error tone for HeldByCeiling.
        std::string message;

        // True only for Outcome::UpdateAvailable. The single question a
        // caller should ask; do not re-derive it from `outcome`.
        [[nodiscard]] bool offersUpdate() const;

        // True when the running build is below the supported-version floor,
        // whatever the outcome. With offersUpdate() it is the urgent offer --
        // the banner says "no longer supported" and names the fix; without
        // it, it is RunningUnsupported.
        [[nodiscard]] bool belowSupportedFloor() const;
    };

    // `runningVersion` is this build's own version (ICoreProductInfo::version()).
    // `hashedMachineId` is ICoreMachineId::hashed(); an empty one is allowed
    // and puts the machine in bucket 0, so a machine whose id could not be
    // read is treated as an EARLY adopter rather than being excluded forever.
    //
    // `runningOsVersion` defaults to ICoreOsVersion::current(); a suite passes
    // its own so the OS floor can be exercised on a machine that is not the
    // one being described. An EMPTY one means "could not read", which does NOT
    // withhold the update -- see decide().
    [[nodiscard]] static Decision decide(const ICoreUpdateManifest& manifest,
                                         const ICoreEntitlements&   entitlements,
                                         const std::string&         runningVersion,
                                         const std::string&         hashedMachineId,
                                         const std::string&         runningOsVersion
                                             = ICoreOsVersion::current());

    // The rollout bucket, 0-99, for one machine and one release. Stable for a
    // fixed pair, and independent between releases so a machine unlucky in
    // one rollout is not systematically last in every one.
    [[nodiscard]] static int bucketOf(const std::string& hashedMachineId,
                                      const std::string& releaseId);
};
};

ICoreUpdateDownloader.h#

src/ICoreBlocks/ICoreUpdater/ICoreUpdateDownloader.h

ICoreUpdateDownloader#

ICoreUpdateDownloader.h:54 · class · pImpl · nested Attempt, Result · 10 declaration(s)

ICoreUpdateDownloader Authorises one download and puts the bytes on disk.

class ICoreUpdateDownloader {
public:
    // ---- the pure half: when to ask for a new URL (U3.3) -----------------
    //
    // Separated out and made pure because it is the part that can be wrong in
    // a way no local test would otherwise catch: the failure it handles only
    // happens on a slow connection to a large file, which is the one
    // configuration a developer machine never has.

    // What one fetch attempt ended as, in this module's own words.
    //
    // ⚠ DELIBERATELY NOT ICoreHttpDownloadResult::Outcome, which is the same
    // list. That type lives in a header that includes <QNetworkReply>, and
    // Rule 2 forbids src/ICoreBlocks naming a Qt type or reaching a Qt include —
    // including transitively through a header of its own. The mapping is one
    // switch in the .cpp, which is the correct place for it to be.
    enum class FetchOutcome {
        Ok,
        Refused,        // the artifact host answered, and not with the bytes
        Unreachable,    // no answer at all
        Interrupted,    // it started and stopped short
        WriteFailed,
        Cancelled
    };

    struct Attempt {
        FetchOutcome outcome    = FetchOutcome::Unreachable;
        int          httpStatus = 0;

        std::int64_t bytesBefore = 0;   // on disk when THIS fetch began
        std::int64_t bytesAfter  = 0;   // on disk now

        int regrantsUsed = 0;
    };

    enum class Step {
        Finish,
        ReGrant     // ask for a new URL and resume the same file
    };

    // `maxRegrants` bounds the loop. Two is the default and it is not
    // arbitrary: three grants of five minutes each covers a fifteen-minute
    // download, which is a 400 MB artifact on a 4 Mbit line.
    [[nodiscard]] static Step nextStep(const Attempt& attempt, int maxRegrants);

    // ---- the result ------------------------------------------------------

    struct Result {
        enum class Outcome {
            Ready,              // the bytes are on disk. NOT verified — see above
            Denied,             // grant_download() said no; ask `denial` which no
            Unreachable,        // no network, at either step
            Refused,            // the artifact host refused, and a fresh grant did not help
            Interrupted,        // the transfer stopped short and the re-grants ran out
            StagingUnusable,    // the staging directory is not ours — see ICoreUpdateStaging
            WriteFailed,        // the disk
            Cancelled
        };

        Outcome outcome = Outcome::Unreachable;

        // Empty unless the download got as far as choosing a destination.
        // ⚠ A LOCAL PATH: never send it anywhere (U7.1 forbids paths in
        // telemetry) and never show it as the whole of an error message.
        std::filesystem::path artifactPath;

        std::int64_t bytesOnDisk   = 0;
        std::int64_t expectedBytes = -1;

        // Meaningful only for Outcome::Denied.
        ICoreDownloadDenial::Reason denial = ICoreDownloadDenial::Reason::None;
        std::string                 denyReason;   // as the server spelled it

        // Carried out of the grant so the verifier does not need a second
        // round trip. ⚠ THESE ARE THE SERVER'S CLAIM about the artifact, and
        // the signature is what turns a claim into evidence — see
        // ICoreUpdateVerifier. May be empty until U0.4/U0.5 land.
        std::string  version;
        std::string  sha256;
        std::string  signature;
        std::int64_t grantedSize = 0;

        // How many grants this download spent, of the 20/hour. 1 is the happy
        // path; more means the URL expired mid-transfer, which is normal on a
        // slow line and is worth knowing when a support ticket says "it keeps
        // failing".
        int grants = 0;

        // Diagnostics: names hosts and paths, so never shown to a user.
        std::string error;

        [[nodiscard]] bool ok() const;

        // ONE SENTENCE, English, for the user. Routes Denied through
        // ICoreDownloadDenial::userText() rather than restating it — that
        // class exists so the four denials do not collapse, and restating
        // them here is exactly how they would.
        [[nodiscard]] std::string userText() const;
    };

    using Progress = std::function<void(std::int64_t bytesOnDisk, std::int64_t expectedBytes)>;
    using Done     = std::function<void(const Result&)>;

    // The client is INJECTED and not owned: a test passes
    // ICoreFakeDownloadClient and every denial path runs with no network. The
    // caller's client must outlive this object.
    explicit ICoreUpdateDownloader(ICoreDownloadClient& client);
    ~ICoreUpdateDownloader();

    // Owns an in-flight transfer; copying one is meaningless.
    ICoreUpdateDownloader(const ICoreUpdateDownloader&)            = delete;
    ICoreUpdateDownloader& operator=(const ICoreUpdateDownloader&) = delete;

    // The licence JWT. Without it the grant is refused before any request is
    // made — see ICoreDownloadClient.
    void setToken(std::string token);

    void setMaxRegrants(int regrants);

    // Starts, and returns immediately; `done` arrives on the event loop.
    // Calling it while a download is running is refused (the callback fires
    // with Outcome::Refused) rather than starting a second one into the same
    // file.
    void start(const ICoreUpdateManifest::Release& release, Progress onProgress, Done done);

    // Stops the transfer. `done` still fires, once, with Outcome::Cancelled.
    // The partial file stays — see the header note.
    void cancel();

    [[nodiscard]] bool isRunning() const;

    // ---- the escape hatch the verifier needs -----------------------------

    // Removes the staged artifact for this release, so the next start() is a
    // fresh download rather than a resume.
    //
    // ⚠ CALL THIS AFTER A DIGEST FAILURE, ALWAYS. Without it a re-cut release
    // resumes a partial that can never digest correctly, and the retry loop
    // is infinite. Safe when nothing is staged.
    static bool discardStaged(const ICoreUpdateManifest::Release& release,
                              const std::string& fileName);

private:
    class Impl;                    // the two-line residue; state lives here
    std::unique_ptr<Impl> impl;
};

ICoreUpdateFirstRun.h#

src/ICoreBlocks/ICoreUpdater/ICoreUpdateFirstRun.h

ICoreUpdateFirstRun#

ICoreUpdateFirstRun.h:46 · class · nested Trial · 7 declaration(s)

ICoreUpdateFirstRun — an updated build is on trial until it starts once (U5.5 on the account board).

class ICoreUpdateFirstRun {
public:
    ICoreUpdateFirstRun() = delete;

    // How many launches a new build gets to reach confirm(). One: a build that
    // crashes on start-up crashes deterministically far more often than not,
    // and every extra attempt is another crash the user watches.
    static constexpr int ATTEMPTS_ALLOWED = 1;

    enum class Verdict {
        NoTrial,            // ordinary launch — nothing was updated, or it is confirmed
        FirstAttempt,       // a new build's first launch: count it, then carry on
        RevertToPrevious    // it did not survive its attempt; put the old one back
    };

    // What the marker file says. `present` false means there is no trial —
    // which is also what an unreadable or incoherent file produces.
    struct Trial {
        bool                  present  = false;
        int                   attempts = 0;
        std::filesystem::path previousVersionAt;
        std::filesystem::path installPath;

        // The version being tried, for a log line. Never sent anywhere — the
        // telemetry rule forbids a version (U7.1).
        std::string           version;
    };

    // PURE.
    [[nodiscard]] static Verdict decide(const Trial& trial);

    // Written by the process that performed the swap, BEFORE it hands over to
    // the new build. `previousVersionAt` is ICoreUpdateInstaller::Result's.
    static bool arm(const std::filesystem::path& markerFile,
                    const std::filesystem::path& installPath,
                    const std::filesystem::path& previousVersionAt,
                    const std::string& version,
                    std::string* detail);

    // Never throws and never reports a parse failure as a trial.
    [[nodiscard]] static Trial read(const std::filesystem::path& markerFile);

    // Increment and FLUSH. Called before the rest of start-up runs.
    static bool noteAttempt(const std::filesystem::path& markerFile, std::string* detail);

    // The build is up. Clears the marker, then removes the previous version.
    // Returns false only if the marker could not be cleared — a previous
    // version that could not be deleted is a leak, not a failure, and saying
    // so would make a successful update look broken.
    static bool confirm(const std::filesystem::path& markerFile, std::string* detail);

    // Puts the previous version back and clears the marker. Uses
    // ICoreUpdateInstaller::revertTo(), so it inherits its refusal to
    // overwrite anything already at the install path.
    static bool revert(const std::filesystem::path& markerFile,
                       const Trial& trial,
                       std::string* detail);

    [[nodiscard]] static std::string describe(Verdict verdict);

    static int selfTest(std::vector<std::string>* failures);
};
};

ICoreUpdateFlow.h#

src/ICoreBlocks/ICoreUpdater/ICoreUpdateFlow.h

ICoreUpdateFlow#

ICoreUpdateFlow.h:51 · class · pImpl · nested Result · 10 declaration(s)

ICoreUpdateFlow — the orchestrator the updater was missing (U5.7).

class ICoreUpdateFlow {
public:
    struct Result {
        enum class Outcome {
            HandedOff,        // applied, relaunch started — the caller must QUIT
            Applied,          // applied, and this platform does not relaunch
            NothingToDo,      // the decision offered no artifact
            Denied,           // the server refused the download (see denial)
            Unreachable,      // no network
            DownloadFailed,   // the transfer did not complete
            VerifyFailed,     // the bytes are not the build we published
            AssembleFailed,   // both the delta AND the full artifact failed to
                              // produce an installable bundle
            ApplyFailed,      // the swap failed; the old build is still there
            RefusedByInstall, // /Applications, a package manager (U8.2)
            Cancelled,
            PlatformNotWired, // see the note on start()
            OtherCopiesRunning // another copy of the app is running: nothing was
                               // downloaded or touched (ICoreRunningCopies)
        };

        Outcome     outcome = Outcome::NothingToDo;
        std::string userText;      // ONE sentence, for a human
        std::string detail;        // diagnostics: paths, tool names, never shown raw

        // What actually happened, for telemetry and for a support answer that
        // does not require reading a log: whether the delta was used, and
        // whether it was abandoned for the full artifact.
        bool triedDelta      = false;
        bool fellBackToFull  = false;

        // RefusedByInstall only: the FULL artifact, downloaded and verified
        // (digest and signature) and left in the private staging directory,
        // so a copy that cannot replace itself can still be handed the exact
        // bytes that were proven (U6.6). Empty on every other outcome. A local
        // path: never logged, never sent.
        std::filesystem::path verifiedArtifact;

        [[nodiscard]] bool ok() const;
    };

    using Progress = std::function<void(int percent, std::int64_t received, std::int64_t total)>;
    using Done     = std::function<void(const Result&)>;

    // The client is INJECTED and not owned, exactly as ICoreUpdateDownloader
    // takes it: a suite passes the fake and every path here runs with no
    // network. The caller's client must outlive this object.
    explicit ICoreUpdateFlow(ICoreDownloadClient& client);
    ~ICoreUpdateFlow();

    ICoreUpdateFlow(const ICoreUpdateFlow&)            = delete;
    ICoreUpdateFlow& operator=(const ICoreUpdateFlow&) = delete;

    // The licence JWT, passed straight to the downloader's grant.
    void setToken(std::string token);

    // ⚠ WHETHER THE NEW BUILD IS STARTED WHEN THE SWAP SUCCEEDS. True is the
    // Install button: the user asked, they are waiting, and Result::HandedOff
    // tells the caller to quit so two copies are never live at once.
    //
    // FALSE IS THE AUTOMATIC RUN, and the difference is the reason automatic
    // updating is allowed to be on by default. Nobody asked for this one, so
    // nobody is waiting for it, and a relaunch would close a window somebody is
    // working in -- the single cost that would make "on by default" the wrong
    // call. With it false the swap lands, the session carries on unharmed in
    // the build it started in, and the new version is what launches next time.
    // The outcome is Applied rather than HandedOff, so no caller quits.
    void setRelaunchWhenDone(bool relaunch);

    // What is installed and how this process was launched. Defaults to the
    // running process; a suite sets it to a directory it built.
    void setEnvironment(const std::filesystem::path&    currentBundle,
                        bool                            selfUpdateAllowed,
                        const std::vector<std::string>& launchArguments);

    // Runs the whole sequence. `done` fires exactly once, on the event loop.
    //
    // ⚠ WIRED: macOS (a .app, from a .dmg or .zip) and, since 2026-09-28, a
    // Linux AppImage (ICoreAppImageUpdate; the new file is started detached
    // and this process quits, rather than execv'd over a live window). NOT
    // wired, and saying so with PlatformNotWired rather than pretending:
    // Windows, which ships through the Microsoft Store only -- the Store owns
    // an MSIX install and updates it, the Worker refuses every Windows
    // download (`windows_store_only`), and ICoreInstallKind reports the
    // install as package-managed, so no Windows run ever reaches this point.
    void start(const ICoreUpdateChecker::Decision& decision,
               const std::string&                  runningVersion,
               Progress                            onProgress,
               Done                                done);

    void cancel();

    [[nodiscard]] bool isRunning() const;

    [[nodiscard]] static std::string describe(Result::Outcome outcome);

    static int selfTest(std::vector<std::string>* failures);

private:
    class Impl;                    // the two-line residue; state lives here
    std::unique_ptr<Impl> impl;
};

ICoreUpdateInstaller.h#

src/ICoreBlocks/ICoreUpdater/ICoreUpdateInstaller.h

ICoreUpdateInstaller#

ICoreUpdateInstaller.h:48 · class · nested Decision, Result, InstallerCommand · 6 declaration(s)

ICoreUpdateInstaller — the gate every apply passes through, and the file swap underneath it.

class ICoreUpdateInstaller {
public:
    ICoreUpdateInstaller() = delete;

    enum class Outcome {
        Applied,                  // the new file is in place, the old is aside

        // ⚠ NOT Applied, AND CONFUSING THE TWO IS THE WINDOWS BUG (U5.2). The
        // installer is now running as a detached process and NOTHING HAS BEEN
        // REPLACED YET -- it cannot be, because this process still has the
        // .exe open. The only correct next action is to QUIT. A caller that
        // reads this as success reports "updated" and goes on running the old
        // build, and the installer then either waits forever or fails on a
        // locked file, which reads as a permissions bug.
        HandedOff,

        // ---- refusals: NOTHING was touched ------------------------------
        RefusedPackageManaged,    // U5.4 — a package manager owns this install
        RefusedSystemWide,        // needs an authorised helper; see U8.2
        RefusedUnknownInstall,    // could not tell, so it does not try

        // ---- failures: the OLD version is still runnable -----------------
        StagedFileMissing,        // never started
        CouldNotMoveCurrentAside, // step 2 failed; nothing moved
        CouldNotMoveNewIntoPlace, // step 3 failed and step 2 was reverted
        CouldNotStartInstaller    // U5.2: the hand-off never began; old build intact
    };

    struct Decision {
        bool        allowed = false;
        Outcome     outcome = Outcome::RefusedUnknownInstall;

        // One sentence, already user-facing. Comes from ICoreInstallKind,
        // which is where the wording for each manager lives — this class does
        // not write a second copy of it.
        std::string userText;

        // The manager's own command ("brew upgrade --cask icoreblocks"), or
        // empty. Safe to log and safe to show: it is never a path.
        std::string upgradeHint;
    };

    // PURE. Takes the detection result rather than detecting, so a suite can
    // ask the question for a Homebrew cask on a machine that has no Homebrew.
    [[nodiscard]] static Decision decide(const ICoreInstallKind::Result& install);

    struct Result {
        Outcome     outcome = Outcome::RefusedUnknownInstall;

        // ⚠ THE INVARIANT, MEASURED RATHER THAN ASSUMED. True whenever a file
        // is at the install path when this returns — the old one after any
        // failure, the new one after success. A case asserts it for EVERY
        // outcome; if it is ever false the machine has no application on it.
        bool        installPathOccupied = false;

        // Where the previous version was moved to, when it was moved at all.
        // U5.5 puts it back from here.
        std::filesystem::path previousVersionAt;

        // For the log. Never shown raw to the user, because it names paths.
        std::string detail;
    };

    // Steps 1-4 above. `current` is the file or bundle being replaced,
    // `staged` the verified replacement, `keepPreviousAt` where the old one is
    // moved to (it must not exist).
    //
    // Every one of the three must be on the SAME filesystem, or the renames
    // are not atomic and may not even be permitted. The staging directory that
    // U3.2 creates is chosen for that.
    static Result swapInPlace(const std::filesystem::path& current,
                              const std::filesystem::path& staged,
                              const std::filesystem::path& keepPreviousAt);

    // Puts `keepPreviousAt` back at `current`. This is what runs when the new
    // build fails to start once (U5.5), and it is what swapInPlace() itself
    // calls when step 3 fails. Public because those are two different callers
    // and one of them is minutes later, in a different process.
    //
    // Refuses rather than overwrites when something already occupies
    // `current`: two versions racing to occupy one path is worse than one
    // failed revert.
    static bool revertTo(const std::filesystem::path& keepPreviousAt,
                         const std::filesystem::path& current,
                         std::string* detail);

    // ---- U5.2: Windows replaces us while we are NOT running ---------------
    //
    // THE FACT THE WHOLE ROW EXISTS FOR: a running .exe cannot be overwritten
    // on Windows. The file is held by the loader for as long as the process
    // lives, so the swapInPlace() shape above -- which works on macOS and on
    // an AppImage because a POSIX rename() does not care that the file is
    // open -- cannot work there at all. ⚠ AND IT DOES NOT FAIL IN A WAY THAT
    // SAYS SO: the rename returns a sharing violation, which surfaces as
    // "access denied" and reads as a permissions problem, so the instinct is
    // to run the updater as administrator. That makes it worse, not better.
    //
    // The shape Windows actually needs is the opposite of a swap: hand the
    // signed installer the job, exit, and let it replace the files and
    // relaunch. That means the apply step's success is a process that is
    // STILL RUNNING when this one ends, which is why HandedOff exists as its
    // own outcome and why startDetached() is the only correct spawn.

    enum class Strategy {
        Refuse,       // decide() said no; nothing platform-specific applies
        SwapInPlace,  // rename the file/bundle aside and the new one in
        HandOff       // spawn an installer and quit (Windows)
    };

    // PURE. `platformTag` is ICoreProductInfo::platformTag() -- the same token
    // `releases.platform` is keyed on ("windows-x64", "macos-arm64", ...).
    //
    // ⚠ THE PLATFORM DECIDES THE SHAPE, NOT THE INSTALL KIND. Both are asked,
    // and in this order: a refusal outranks everything (a package-managed
    // Windows install must not be handed to an installer either), and only
    // then does the platform choose between the two shapes.
    [[nodiscard]] static Strategy strategyFor(const ICoreInstallKind::Result& install,
                                              std::string_view platformTag);

    // What to spawn, and with what. PURE, because the argument list is the
    // part that can be silently wrong.
    struct InstallerCommand {
        bool                     valid = false;
        std::filesystem::path    program;      // the STAGED, verified installer
        std::vector<std::string> arguments;
        std::string              why;          // set only when !valid
    };

    // ⚠ `/S` AND NOTHING ELSE. That is NSIS's silent switch, and the artifact
    // U0.1 produces on Windows is an NSIS installer.
    //
    // ⚠ `/D=` IS DELIBERATELY NEVER PASSED, and this is the trap worth naming:
    // it overrides the installation DIRECTORY. Passing the current install
    // path "to be safe" is how an update silently relocates an installation --
    // NSIS requires /D to be last and unquoted, so a path with a space in it
    // (C:\Program Files\...) truncates and the app is installed to C:\Program.
    // The installer already knows where it lives, from its own registry key.
    //
    // A `.msi` is REFUSED rather than guessed at: it needs
    // `msiexec /i <path> /qn`, whose restart and elevation semantics differ
    // from NSIS's, and neither this tree nor U0.1 produces one. Guessing an
    // install command that cannot be run here is worse than saying so.
    [[nodiscard]] static InstallerCommand silentCommandFor(const std::filesystem::path& staged);

    // Spawns it DETACHED and returns HandedOff. The caller must then quit.
    //
    // ⚠ DETACHED IS LOAD-BEARING, NOT TIDINESS. A child that dies with its
    // parent is killed at exactly the moment it is needed -- this process
    // exiting is the event that lets the installer proceed.
    //
    // Nothing on disk is touched here, so every failure leaves the old build
    // installed and runnable, and `installPathOccupied` is true for BOTH
    // outcomes this can return.
    static Result handOffToInstaller(const std::filesystem::path& staged);

    // For a log line and the update panel.
    [[nodiscard]] static std::string describe(Outcome outcome);

    static int selfTest(std::vector<std::string>* failures);
};
};

ICoreUpdateManifest.h#

src/ICoreBlocks/ICoreUpdater/ICoreUpdateManifest.h

ICoreUpdateManifest

The parsed answer to "is there anything newer, and is what I am running still good?" — one round trip, public metadata, and NO DOWNLOAD URL.

THE SERVER SHAPE THIS MIRRORS is release_status() in ICorePublicFace/AccountsDataBase/20260819_updater.sql, fronted by the Worker at GET /api/releases/latest. Its three top-level keys are current_status, update_available and latest; this class is those keys with the JSON gone.

⚠ THE SCHEMA THIS PARSES IS PROPOSED, NOT APPLIED. 20260819_updater.sql is a migration for the OWNER to run (board row U8.1), and until it is, /api/releases/latest answers the OLD shape — latest_releases without rollout_percent, is_mandatory, min_os_version or min_upgradable_from, and

ICoreUpdateManifest#

ICoreUpdateManifest.h:49 · struct · nested Release · 0 declaration(s)

A PARSED DTO, so its members are PUBLIC and there is no Impl.

struct ICoreUpdateManifest {
public:

    // What the server says about the version the client is RUNNING.
    //
    // ⚠ Unknown IS NOT AN ERROR and must not stop anything. A developer
    // build, a version whose row was deleted, or a server that predates the
    // migration all land here, and the client carries on.
    enum class RunningStatus {
        Unknown,
        Ok,
        Yanked
    };

    // One release row from latest_releases.
    struct Release {
        std::string id;                 // uuid; also the rollout bucket's salt
        std::string version;
        std::string channel;            // "stable" | "beta" | "nightly"
        std::string platform;           // "macos-arm64", ... -- the same token
                                        // ICORE_PLATFORM_TAG names the artifact with
        std::string kind;               // "installer" | "sdk" | "addon"
        std::string sha256;             // lowercase hex, 64 chars
        std::int64_t sizeBytes = 0;
        std::string signature;          // detached Ed25519 over the sha256; may
                                        // be empty until U0.4/U0.5 land
        std::string releaseNotesUrl;
        std::string publishedAt;        // ISO-8601, as the server spelled it
        std::string requiredFeature;    // empty = any valid licence
        bool requiresMaintenance = true;

        // From the proposed migration; absent on a server that predates it.
        std::string minOsVersion;
        std::string minUpgradableFrom;

        // U8.4. Set ONLY on a delta row (`kind == "update_delta"`): the version
        // this delta applies TO. Empty on every full artifact, which is what
        // isDelta() below reads.
        std::string fromVersion;
        bool        isMandatory = false;

        // ⚠ DEFAULTS TO 100, i.e. everybody. An absent rollout_percent must
        // mean "fully rolled out" -- defaulting it to 0 would silently stop
        // offering updates to every user the moment the client is pointed at
        // a server that does not carry the column.
        int rolloutPercent = 100;

        // A row with no version is not a row: everything downstream compares
        // against it.
        [[nodiscard]] bool isValid() const;

        // A delta carries a from_version and says so in `kind`. Both, not
        // either: a row with one and not the other is a malformed row, and
        // acting on half of it is how a delta gets applied as a full artifact.
        [[nodiscard]] bool isDelta() const;
    };

    RunningStatus status     = RunningStatus::Unknown;
    std::string   yankReason = {};

    // ⚠ THE SUPPORTED-VERSION FLOOR (U8.1), AND IT IS EMPTY UNTIL SOMEBODY
    // DELIBERATELY SETS IT ON THE SERVER. A product-level fact, not a per-release
    // one -- "the oldest version we still support" is one statement, and putting
    // it on release rows would make it n statements that can disagree.
    //
    // ⚠ EMPTY MEANS NO FLOOR, AND SO DOES ANYTHING UNREADABLE. This is the only
    // field the server can send that stops software already running on
    // somebody's machine, so every uncertain reading resolves the same way:
    // absent (a server predating the migration), null, "", or a string
    // ICoreSemVer cannot parse all mean the same thing here -- carry on. A
    // client that invented a floor from a typo would be a client that bricks a
    // working install because someone fat-fingered a release form.
    std::string minSupportedVersion = {};

    // ⚠ U8.4 -- THE DELTA THE SERVER OFFERS, WHICH IS AN OPTIMISATION AND NEVER
    // AN OBLIGATION. Present only when the request carried ?version= and a delta
    // row exists FROM exactly that version; `isValid()` is false otherwise.
    // Nothing downstream may treat an absent or unusable delta as a failure:
    // `latest` is always the answer that works, and every refusal in
    // ICoreDeltaBundle has the same remedy -- take the full artifact.
    Release delta = {};

    // Empty version means "nothing published for this platform and channel",
    // which is an answer, not a failure.
    Release latest = {};

    // The server's own semver_gt() result. DIAGNOSTICS ONLY -- see the header
    // note. ICoreUpdateChecker recomputes it locally.
    bool serverUpdateAvailable = false;

    // ---- parsing --------------------------------------------------------

    // Returns false and fills `error` when the body is not JSON, is not an
    // object, or carries a `latest` with no version. Everything else parses.
    [[nodiscard]] static bool parse(const std::string& json,
                                    ICoreUpdateManifest* out,
                                    std::string* error);

    // ---- the request ----------------------------------------------------

    // GET <baseUrl>/api/releases/latest?platform=&channel=&version=&kind=
    //
    // `currentVersion` is what makes the YANK check possible: latest_releases
    // excludes yanked rows, so a client that does not say what it is running
    // is told it is up to date -- and it is exactly the person running a
    // pulled build who most needs to hear otherwise. Send it always.
    //
    // Every value is percent-encoded. A version string reaches this from a
    // VERSION file and a platform token from the build, but neither is a
    // reason to paste unescaped text into a URL.
    [[nodiscard]] static std::string requestUrl(const std::string& baseUrl,
                                                const std::string& platform,
                                                const std::string& channel,
                                                const std::string& currentVersion,
                                                const std::string& kind = "installer");
};
};

ICoreUpdatePolicy.h#

src/ICoreBlocks/ICoreUpdater/ICoreUpdatePolicy.h

ICoreUpdatePolicy#

ICoreUpdatePolicy.h:53 · class · 18 declaration(s)

ICoreUpdatePolicy — which builds this copy is willing to be offered, and how much it may do about one without asking.

class ICoreUpdatePolicy {
public:
    ICoreUpdatePolicy() = delete;

    enum class Channel { Stable, Beta, Nightly };

    enum class Mode {
        Ask,            // tell me, and I will decide
        AutoDownload,   // fetch and verify it quietly; ask before replacing
        AutoInstall     // do the whole thing, and tell me afterwards
    };

    // ---- channels --------------------------------------------------------

    // "stable" / "beta" / "nightly" -- the server's spelling, sent as
    // ?channel= and stored in releases.channel.
    [[nodiscard]] static std::string channelId(Channel channel);

    // std::nullopt for anything this build does not know, which is NOT an
    // error: it is a settings file with something else in it.
    [[nodiscard]] static std::optional<Channel> channelFromId(std::string_view id);

    // "Stable", "Beta (pre-release)", "Nightly (internal)".
    [[nodiscard]] static std::string channelDisplayName(Channel channel);

    // ⚠ THE U8.3 GATE, AND THE ONE PLACE IT IS SPELLED. Until the product
    // decides otherwise this is `stable` alone; a picker offering more than
    // this is offering something nobody has agreed to support.
    [[nodiscard]] static std::vector<Channel> customerVisibleChannels();
    [[nodiscard]] static bool isCustomerVisible(Channel channel);

    // ---- modes -----------------------------------------------------------

    [[nodiscard]] static std::string modeId(Mode mode);
    [[nodiscard]] static std::optional<Mode> modeFromId(std::string_view id);
    [[nodiscard]] static std::string modeDisplayName(Mode mode);
    [[nodiscard]] static std::vector<Mode> allModes();

    // ⚠ THE ONE PLACE "IS AUTOMATIC UPDATING ON" IS ANSWERED, and the reason
    // it is a function rather than a comparison at each call site. Every
    // caller that wants to know whether it may act without asking asks this;
    // a second `mode == Mode::Ask` written out somewhere else is a second
    // copy of the rule, and the requirement it enforces -- nothing is EVER
    // installed unasked once the user has turned this off -- is only as good
    // as the number of places it is written.
    [[nodiscard]] static bool isAutomatic(Mode mode);

    // AutoInstall alone: the mode that may replace the build with no click.
    // AutoDownload fetches and verifies and then stops, which is the setting
    // for someone who wants the wait gone but the decision kept.
    [[nodiscard]] static bool installsWithoutAsking(Mode mode);

    // ---- what a stored value actually resolves to ------------------------

    [[nodiscard]] static Channel defaultChannel();

    // ⚠ AutoInstall. See the note on the definition: the default is On, and
    // Ask is what a user chooses when they want it off.
    [[nodiscard]] static Mode    defaultMode();

    // Unknown, misspelled, empty, or a channel this build must not put a
    // customer on: all four answer defaultChannel(). See the resolve() note.
    [[nodiscard]] static Channel resolveChannel(std::string_view storedId);
    [[nodiscard]] static Mode    resolveMode(std::string_view storedId);

    // What Shell hands ICoreUpdateService::configure(). Never the raw stored
    // string -- that is the line the gate above exists to close.
    [[nodiscard]] static std::string resolvedChannelId(std::string_view storedId);

    static int selfTest(std::vector<std::string>* failures);
};
};

ICoreUpdateSchedule.h#

src/ICoreBlocks/ICoreUpdater/ICoreUpdateSchedule.h

ICoreUpdateSchedule#

ICoreUpdateSchedule.h:58 · class · pImpl · nested State, Vector · 17 declaration(s)

ICoreUpdateSchedule — WHEN the update check runs, and nothing else.

class ICoreUpdateSchedule {
public:
    using TimePoint = std::chrono::system_clock::time_point;

    // Once a day while running. The token's own lifetime and the licence
    // refresh are on the same period deliberately: ICoreLicenseGate's
    // REFRESH_EVERY_HOURS is 24 for the same reason, and a user who leaves the
    // app open for a week should learn about a new build in a day, not never.
    static constexpr int CHECK_EVERY_HOURS = 24;

    // How long after the licence resolves the FIRST check may start. Long
    // enough that startup has finished and short enough that a session which
    // is only a few minutes long still checks once. The `--console` and
    // `--check-updates` hooks settle for 1500 ms; this is two orders of
    // magnitude more because those two ARE the run, and this one is a
    // background errand that must never be part of it.
    static constexpr int SETTLE_SECONDS = 30;

    enum class Reason {
        // Nothing to do at this instant.
        NotDue,

        // The licence has not resolved yet. Distinguished from NotDue so a
        // diagnostics line can say which of the two it is: "not due for 19 h"
        // and "still waiting for the licence" look identical otherwise, and
        // the second one is a wiring bug when it lasts.
        LicenseNotResolved,

        // The once-per-launch check, after the settle window.
        Launch,

        // The 24 h one.
        Periodic,

        // "Check now". Bypasses the timer and the settle window; it does NOT
        // bypass the endpoint's own rate limiting, which is the server's
        // business and not modelled here.
        UserRequested
    };

    // Everything decide() is allowed to know. A value, so a test writes a
    // machine that has been running for three days in four lines.
    struct State {
        bool      licenseResolved   = false;
        TimePoint licenseResolvedAt {};

        // Whether a check has ever been STARTED in this process, and when.
        // "Started", not "succeeded" — see the unreachable note above.
        bool      everChecked       = false;
        TimePoint lastCheckStartedAt{};

        // One at a time. A second check started while one is in flight would
        // race its own callback, and the answer would be the same answer.
        bool      checkInFlight     = false;

        // Set by requestCheckNow(), cleared when a check starts.
        bool      userRequested     = false;
    };

    // ---- the decision ----------------------------------------------------

    // Pure. No clock, no network, no filesystem, no state of its own.
    [[nodiscard]] static Reason decide(const State& state, TimePoint now);

    // When the next check becomes due, given a state. TimePoint{} (the epoch)
    // when nothing is scheduled — the licence has not resolved, or a check is
    // already in flight. For a diagnostics line and the account panel, never
    // as an input to decide().
    [[nodiscard]] static TimePoint nextDueAt(const State& state);

    [[nodiscard]] static std::string describe(Reason reason);

    // True for the three reasons that mean "start a check". The single
    // question a caller should ask; do not re-derive it from the enumerator,
    // which is how NotDue and LicenseNotResolved come to be handled
    // differently by accident.
    [[nodiscard]] static bool startsCheck(Reason reason);

    // ---- the stateful half, for the shell --------------------------------

    ICoreUpdateSchedule();
    ~ICoreUpdateSchedule();

    ICoreUpdateSchedule(const ICoreUpdateSchedule&)            = delete;
    ICoreUpdateSchedule& operator=(const ICoreUpdateSchedule&) = delete;

    // The licence resolved. Idempotent: calling it again does not restart the
    // settle window, so a refresh that re-confirms the same licence cannot
    // postpone the launch check indefinitely.
    void noteLicenseResolved(TimePoint now);

    // "Check now" from the account panel or the update dialog.
    void requestCheckNow();

    [[nodiscard]] Reason due(TimePoint now) const;

    // A check is starting. Anchors the clock and consumes the user request.
    void noteCheckStarted(TimePoint now);

    // A check finished. `reachable` is false when the endpoint could not be
    // asked at all — recorded for diagnostics, and deliberately NOT used to
    // schedule a retry.
    void noteCheckFinished(bool reachable);

    [[nodiscard]] const State& state() const;
    [[nodiscard]] bool lastCheckReachable() const;

    // ---- the case table --------------------------------------------------

    struct Vector {
        const char* name;

        bool        licenseResolved;
        long long   licenseResolvedAtSec;    // seconds from an arbitrary epoch
        bool        everChecked;
        long long   lastCheckStartedAtSec;
        bool        checkInFlight;
        bool        userRequested;
        long long   nowSec;

        Reason      expected;
        const char* pins;
    };

    [[nodiscard]] static std::vector<Vector> vectors();

    // Runs vectors() and appends a line per failure. 0 is a pass.
    static int selfTest(std::vector<std::string>* failures);

private:
    class Impl;                    // the two-line residue; state lives here
    std::unique_ptr<Impl> impl;
};

ICoreUpdateService.h#

src/ICoreBlocks/ICoreUpdater/ICoreUpdateService.h

ICoreUpdateService#

ICoreUpdateService.h:49 · class · pImpl · 20 declaration(s)

ICoreUpdateService — the running application's update check.

class ICoreUpdateService {
public:
    using TimePoint = ICoreUpdateSchedule::TimePoint;

    // The process-wide instance. Never null, never replaced.
    static ICoreUpdateService& instance();

    ICoreUpdateService();
    ~ICoreUpdateService();

    ICoreUpdateService(const ICoreUpdateService&)            = delete;
    ICoreUpdateService& operator=(const ICoreUpdateService&) = delete;

    // ---- wiring ----------------------------------------------------------

    // The shell installs the real ICoreHttpReleaseClient here; a case installs
    // a fake. Passing nullptr disarms the service: poll() will decide as
    // usual and then start nothing, which is what a build with no network
    // stack configured should do.
    void setClient(std::shared_ptr<ICoreReleaseClient> client);

    // What the check asks for. The platform tag is the artifact token from
    // ICoreProductInfo::platformTag(); an empty one disarms the service the
    // same way a null client does, because a build with no tag cannot ask for
    // the right artifact.
    void configure(std::string platformTag, std::string channel,
                   std::string runningVersion, std::string hashedMachineId);

    // ⚠ WHETHER THIS COPY MAY ACT ON A DECISION WITHOUT BEING ASKED (U6.3).
    // The id is ICoreUpdatePolicy's -- "ask", "auto-download", "auto-install"
    // -- and it is RESOLVED here, so a hand-edited preferences.ini carrying
    // something else gets the default rather than being honoured or crashing.
    //
    // Shell sets it, exactly as it sets the channel, because the preference
    // lives in ICoreUserPreferences at L9 and this object is L3: a service that
    // read the setting itself would be an L3 module including L9, which is the
    // include the layering guard exists to catch.
    //
    // ⚠ "ask" DISARMS EVERY AUTOMATIC PATH IN THIS CLASS, WITH NO EXCEPTION.
    // Not a yanked build, not one below the supported floor, not one the server
    // marked is_mandatory. Those change what the BANNER says and nothing else,
    // which is precisely what they did before this setter existed. There is no
    // state a server can put a client into that overrides a user who turned
    // automatic updating off -- see ICoreUpdatePolicy::isAutomatic().
    void setMode(std::string modeId);

    // The entitlements every decision is made against. A3.1 calls this from
    // the licence gate's stateChanged; until then it is never called and the
    // defaults are never consulted, because nothing polls before
    // noteLicenseResolved().
    void setEntitlements(const ICoreEntitlements& entitlements);

    // ---- the cadence -----------------------------------------------------

    // The licence resolved: the once-per-launch check becomes due
    // SETTLE_SECONDS later. Idempotent.
    void noteLicenseResolved(TimePoint now);

    // "Check now", from the account panel or the update dialog. Bypasses the
    // timer and the settle window; it does not bypass the endpoint's own rate
    // limiting. Takes effect on the next poll, so a user who clicks twice in a
    // second still makes one request.
    void requestCheckNow();

    // Driven by the shell's timer. Starts a check if the schedule says one is
    // due, and returns whether it did.
    bool poll(TimePoint now);

    // ---- what came back --------------------------------------------------

    // Fires once per completed check that produced a decision. Receivers hold
    // an ICoreSignalScope declared LAST.
    ICoreSignal<ICoreUpdateChecker::Decision> decided;

    // Fires when a check could not be MADE at all, with the transport's
    // message. Separate from `decided` on purpose: an unreachable endpoint is
    // not an update state, and a panel that rendered it as one would tell a
    // user on a train that their licence had a problem.
    ICoreSignal<std::string> checkFailed;

    [[nodiscard]] const ICoreUpdateSchedule& schedule() const;

    // The last decision, for a panel that opens after the check ran rather
    // than during it. Outcome::UpToDate with an empty version until one has.
    [[nodiscard]] ICoreUpdateChecker::Decision lastDecision() const;

    // ---- installing what was decided (U5.7) --------------------------------
    //
    // ⚠ THE JOIN LIVES HERE BECAUSE THE SHELL ALREADY CONFIGURES THIS OBJECT
    // and the window must not. ICoreUpdateDialog's header states the rule the
    // other way round: the dialog reports and owns no mechanism, and the caller
    // owns the downloader. Making the caller a Studio window would have put the
    // update mechanism — and the bearer token — inside ICoreStudio.

    // The licence JWT for the download grant, from the shell, which owns the
    // gate. ⚠ NEVER LOGGED AND NEVER SHOWN: see ICoreLicenseGate::rawToken().
    void setDownloadToken(std::string token);

    // Runs the whole sequence for lastDecision(): grant, download, verify,
    // assemble (delta) or extract, apply, relaunch — falling back from a delta
    // to the full artifact exactly once. Does nothing when there is nothing to
    // install or when a run is already in flight.
    void installNow();

    void cancelInstall();

    [[nodiscard]] bool isInstalling() const;

    // Whether the run in flight (or the last one) was started by the service
    // itself rather than by the Install button. The dialog uses it to say
    // "installing automatically" instead of offering a Cancel that reads as
    // though the user had started something.
    [[nodiscard]] bool isInstallingAutomatically() const;

    // 0..100, or -1 for "started, length unknown" — the dialog renders that as
    // a moving bar rather than one stuck at zero.
    ICoreSignal<int, std::int64_t, std::int64_t> installProgress;

    // Fires exactly once per installNow(). ⚠ A HandedOff outcome means the
    // application MUST quit: the replacement has been started and two copies
    // are now live.
    ICoreSignal<ICoreUpdateFlow::Result> installFinished;

    // A one-line status for a diagnostics command: whether the licence has
    // resolved, what the poll would do now, and when the next check is due.
    [[nodiscard]] std::string describe(TimePoint now) const;

    static int selfTest(std::vector<std::string>* failures);

private:
    class Impl;                    // the two-line residue; state lives here
    std::unique_ptr<Impl> impl;
};

ICoreUpdateStaging.h#

src/ICoreBlocks/ICoreUpdater/ICoreUpdateStaging.h

ICoreUpdateStaging#

ICoreUpdateStaging.h:47 · class · 4 declaration(s)

ICoreUpdateStaging Where a downloaded build is written while it is being checked, and the reason that location is not an implementation detail.

class ICoreUpdateStaging {
public:
    enum class Status {
        Ready,
        NotPrivate,      // exists, and is a symlink / not ours / group- or world-accessible
        CreateFailed     // could not be created at all
    };

    // Creates or re-validates the staging directory. Safe to call repeatedly,
    // and MEANT to be: the check is what makes the path trustworthy at the
    // moment of use, so callers re-run it rather than caching a yes.
    [[nodiscard]] static Status prepare();

    // Empty until prepare() has returned Ready.
    [[nodiscard]] static std::filesystem::path directory();

    // Where an artifact of this release version and file name is staged. The
    // version is part of the path so a half-finished download of 1.2.0 can
    // never be mistaken for one of 1.3.0.
    [[nodiscard]] static std::filesystem::path artifactPath(const std::string& version,
                                                            const std::string& fileName);

    // Removes everything staged. Called before a download begins.
    static bool clear();

    [[nodiscard]] static std::string describe(Status status);

};

ICoreUpdateTelemetry.h#

src/ICoreBlocks/ICoreUpdater/ICoreUpdateTelemetry.h

ICoreUpdateTelemetry#

ICoreUpdateTelemetry.h:36 · class · 5 declaration(s)

ICoreUpdateTelemetry — the ONE translation from the updater's vocabularies into the four telemetry events of U7.1.

class ICoreUpdateTelemetry {
public:
    ICoreUpdateTelemetry() = delete;

    // What a completed check should report. Total over Outcome.
    [[nodiscard]] static ICoreUpdateCheckResult resultOf(ICoreUpdateChecker::Outcome outcome);

    // What a refused download grant should report. Total over Reason.
    //
    // ⚠ EVERY DENIAL MAPS TO ONE VALUE, AND THAT IS THE DESIGN, NOT AN
    // OVERSIGHT. grant_download already writes the specific deny_reason into
    // download_events server-side, so re-sending it here would duplicate a
    // number the database already has, more coarsely and with more chance of
    // drifting from it. What usage_events is for is the half the server CANNOT
    // see: which client-side stage an update died at — the digest, the
    // signature, the staging directory, the apply. Those are separate
    // enumerators; the denials are one.
    //
    // The eleven-armed switch is kept rather than collapsed to a `default`
    // precisely because it is the tripwire: a Reason added to the server's
    // vocabulary stops this file compiling, and whoever adds it decides then
    // whether it is still just a denial.
    [[nodiscard]] static ICoreUpdateFailure failureOf(ICoreDownloadDenial::Reason reason);

    // A check that never completed. Reported as an update_check with result
    // "failed" AND an update_failed with reason "check_failed": the first
    // keeps the check-rate denominator honest, the second is what a failure
    // dashboard counts. Losing either makes one of the two numbers wrong.
    [[nodiscard]] static ICoreUpdateCheckResult checkDidNotComplete();

    static int selfTest(std::vector<std::string>* failures);
};
};

ICoreUpdateVerifier.h#

src/ICoreBlocks/ICoreUpdater/ICoreUpdateVerifier.h

ICoreUpdateVerifier#

ICoreUpdateVerifier.h:72 · class · nested Result, Verified · 7 declaration(s)

ICoreUpdateVerifier Decides whether a downloaded artifact is the build that was published.

class ICoreUpdateVerifier {
public:
    enum class Outcome {
        Ok,
        FileMissing,          // nothing at that path, or it will not open
        SizeMismatch,         // almost always a truncated or resumed download
        DigestMismatch,       // the bytes are not the build that was published
        NoExpectedDigest,     // the manifest carried none -- never a pass
        MalformedExpectedDigest,  // not 64 hex characters
        ReadFailed            // the file shrank, or the disk gave up, mid-read
    };

    struct Result {
        Outcome     outcome = Outcome::FileMissing;
        std::string computedSha256;   // lowercase hex; empty if never computed
        std::int64_t bytesRead = 0;

        // Body in the .cpp: the header surface rule permits no bodies here.
        [[nodiscard]] bool ok() const;
    };

    // `expectedSizeBytes` <= 0 skips the size check, for a caller whose
    // manifest genuinely lacks one. The digest check is never skippable.
    [[nodiscard]] static Result verifyDigest(const std::string& artifactPath,
                                             const std::string& expectedSha256Hex,
                                             std::int64_t       expectedSizeBytes = 0);

    // -----------------------------------------------------------------
    // U4.2 -- the second check. A digest says the bytes are what the manifest
    // measured; only this says the measurement came from us.
    // -----------------------------------------------------------------
    enum class SignatureOutcome {
        Ok,
        NoSignature,          // the manifest carried none -- NEVER a pass
        NoPinnedKey,          // this build pins no release key -- NEVER a pass
        MalformedSignature,   // not a 64-byte Ed25519 signature
        MalformedPinnedKey,   // our own key is wrong; still a refusal
        BadSignature          // it verified against no key we hold
    };

    // ⚠ WHAT IS SIGNED IS THE 64-CHARACTER LOWERCASE HEX DIGEST TEXT, as ASCII
    // -- not the 32 raw bytes it decodes to, and not the artifact. That choice
    // is this file's to make until the release procedure (U0.5) exists, and it
    // is made here so U0.5 has one thing to match rather than a guess:
    //
    //   * `releases.sha256` is a TEXT column constrained to ^[0-9a-f]{64}$, so
    //     the hex string is what the signing script has in its hand and what a
    //     human reviewing the release row can see. "Sign the text in the
    //     database" has one reading; "sign the bytes that text decodes to" has
    //     a decoding step in it, and a decoding step is a place to disagree.
    //   * Signing the artifact itself would mean streaming hundreds of
    //     megabytes through the signer on an offline machine, twice.
    //
    // `signatureText` is the manifest's `releases.signature`: unpadded
    // base64url, or lowercase hex -- ICoreSignatureVerifier tells them apart by
    // length, so the release procedure may emit either.
    //
    // `acceptedKeys` defaults to pinnedReleaseKeys() and the production path
    // never passes anything else. It is a parameter so the suite can hold a
    // key -- U7.2 must be able to assert that a signature by an UNTRUSTED key
    // fails, and it cannot do that in a build whose pinned table is empty.
    // Naming it here beats the alternative, which is a test that skips the
    // case with a comment about how it would have been nice to check.
    [[nodiscard]] static SignatureOutcome verifySignature(
        const std::string& sha256Hex,
        const std::string& signatureText,
        const std::vector<std::string>& acceptedKeys = pinnedReleaseKeys());

    // The public halves this build accepts, compiled in. EMPTY today: the
    // offline release key pair is U0.4 and is procurement, so every signature
    // check currently answers NoPinnedKey -- which is a refusal, not a pass.
    //
    // ⚠ NEVER a key fetched from the network. A key downloaded over the same
    // channel as the artifact proves that whoever served the artifact also
    // served the key. ⚠ NEVER the JWT key either -- ACCOUNT_MANAGER N5: one
    // Worker compromise would then mint the licence AND the update.
    //
    // Set at build time from the ICORE_RELEASE_PUBLIC_KEYS CMake cache
    // variable (semicolon-separated), so a release build pins and a developer
    // build does not silently inherit one.
    [[nodiscard]] static std::vector<std::string> pinnedReleaseKeys();

    // -----------------------------------------------------------------
    // U4.3 -- what was verified, and whether it still is.
    // -----------------------------------------------------------------

    // Produced ONLY by verify(). `ok` is true when the digest matched AND the
    // signature verified -- never one of the two.
    struct Verified {
        bool             ok = false;
        std::string      path;
        Result           digest;
        SignatureOutcome signature = SignatureOutcome::NoSignature;

        // WHICH FILE the digest was computed over. Invalid unless `ok`.
        ICoreFileIdentity identity;
    };

    // Both checks, digest first. The order is not a preference: a truncated
    // download must be reported as a truncated download, and running the
    // signature check first would report it as an untrusted build.
    [[nodiscard]] static Verified verify(
        const std::string& artifactPath,
        const std::string& expectedSha256Hex,
        const std::string& signatureText,
        std::int64_t       expectedSizeBytes = 0,
        const std::vector<std::string>& acceptedKeys = pinnedReleaseKeys());

    // ⚠ CALL THIS IMMEDIATELY BEFORE THE HANDOFF, and treat false as a refusal
    // to install -- never as a reason to re-verify, which would just start the
    // window again. False for a Verified that was never ok, so a caller cannot
    // skip the first check by asking only this one.
    [[nodiscard]] static bool stillTheSameFile(const Verified& verified);

    // For the message the user actually sees. Deliberately says what to DO:
    // "the download did not finish" is actionable, "verification failed" is
    // not, and the two are different diagnoses (see the size note above).
    [[nodiscard]] static std::string describe(const Result& result);

    // The same, for the signature half. "Not signed by us" and "we hold no key
    // to check it with" are OUR bug and THEIR attack respectively, and the
    // sentences say which.
    [[nodiscard]] static std::string describe(const Verified& verified);

    // Returns the failure count and appends a sentence per failure (U7.2).
    static int selfTest(std::vector<std::string>* failures);

};