Overview

The NetworkManager has three jobs: report whether the head unit is online (and on which network), tell the user when the main branch on GitHub has moved past the running commit, and -- when OCTAVE runs from a git checkout -- pull that update in place, rebuild and restart. It is defined in src/managers/networkmanager.{h,cpp} and registered as the QML context property networkManager in src/main.cpp.

Thread Safety
The manager has no worker thread: it uses asynchronous QProcess and QNetworkAccessManager, whose completion slots run on the main thread, so every signal is emitted from the GUI thread. Some work is still synchronous: the startup git --version probe, the click-time dirty-tree check, the 5 s waitForStarted() on each process, the short git queries (runGitSync(): status --porcelain, cat-file, merge-base, rev-list) and the rollback reset --hard -- the header comment says they run on the GUI thread "deliberately: they gate a destructive action, so their answer must be current at the moment of the click".

The manager targets the GitHub repository RobDeGeorge/OCTAVE (GITHUB_REPO) and compares the local git HEAD against the tip of the remote main branch.

Architecture

NetworkManager (src/managers/networkmanager.cpp) | |-- QNetworkAccessManager m_nam generate_204 probe, GitHub API (async replies) |-- QProcess m_networkNameProcess nmcli / networksetup / netsh (async) |-- QProcess m_gitProcess self-update steps: fetch / reset / cmake configure / build / log |-- runGitSync() short blocking git queries that gate the reset | |-- QTimer m_pollTimer kPollOnlineMs 30000 / kPollOfflineMs 120000 |-- QTimer m_resultPollTimer 250 ms progress relay (see Self-Update) |-- QTimer::singleShot(2000) checkCanSelfUpdate() + refreshNetworkStatus() | |-- busy flags: m_networkCheckBusy, m_updateCheckBusy, m_updating

Busy Flags

Each activity has its own busy flag (m_networkCheckBusy, m_updateCheckBusy, m_updating), so a connectivity probe and an update check can be in flight at the same time, while a second request for an activity that is already running returns immediately.

Connectivity Detection

Network status is polled every 30 seconds while online. After three consecutive failed probes the interval backs off to 2 minutes and returns to 30 s on the first success (kPollOnlineMs / kPollOfflineMs / kOfflineStreakForBackoff). The first probe runs 2 s after startup so the UI can paint first; the git --version probe behind canSelfUpdate runs at that same moment rather than in the constructor. The name lookup and the internet probe are started in parallel.

Network Name Detection

startNetworkNameCheck() and onNetworkNameCheckFinished() shell out to the platform's network tool. The WiFi command runs first; if it yields no SSID a second command looks for a wired link and reports the literal string "Ethernet".

PlatformWiFi SSIDWired fallback
Linux nmcli -t -f ACTIVE,SSID dev wifi list --rescan no -- first line starting yes:. Without --rescan no nmcli rescans when the last scan is over 30 s old, taking the radio off channel for ~4 s; on the Pi that stalled phone mirroring over the OCTAVE-Jeep hotspot, which shares the radio, once a minute. nmcli -t -f TYPE,STATE dev -- a line containing both ethernet and connected
macOS networksetup -getairportnetwork en0 -- text after Current Wi-Fi Network: ifconfig en0 -- output contains status: active
Windows netsh wlan show interfaces -- first SSID line that is not BSSID netsh interface show interface -- a line containing Connected and Ethernet
Android No command is run; networkName is set to the fixed string "WiFi".

The manager waits up to 5 s for each process to start (waitForStarted(5000)) and otherwise lets it run to completion asynchronously; a name check that is still running when the next poll fires is simply skipped. When neither WiFi nor Ethernet is found the name is empty: the wired-fallback lambda on every desktop platform (and the failed-to-start path) calls clearNetworkName(), which clears networkName and emits networkNameChanged("") so a stale SSID is never left on screen.

Internet Connectivity Check

Reachability is a single HTTPS GET of Google's generate_204 endpoint (a captive-portal probe URL that returns an empty 204) through QNetworkAccessManager with setTransferTimeout(5000); any QNetworkReply error counts as offline.

void NetworkManager::startConnectivityCheck()
{
    QNetworkRequest request{QUrl(QStringLiteral("https://clients3.google.com/generate_204"))};
    request.setTransferTimeout(5000);
    QNetworkReply *reply = m_nam->get(request);
    connect(reply, &QNetworkReply::finished, this, [this, reply]() {
        reply->deleteLater();
        bool connected = (reply->error() == QNetworkReply::NoError);
        // ... update isConnected, offline streak, poll interval, first-connect update check
    });
}

Auto Update Check on First Connect

The first time a probe succeeds (m_autoUpdateChecked) the manager schedules checkForUpdates() 500 ms later. The flag is never reset, so this happens once per process; later checks are user-initiated from the About page.

Update Checking Mechanism

The update checker is git-based: it compares the local HEAD against the latest commit on the main branch and asks git how the two are related. There is no release-tag or version-number comparison, which has a consequence for packaged builds (see the box below).

Step-by-Step Flow

  1. Get local commit hash -- git rev-parse --short HEAD in the repository root, repoDir() = QCoreApplication::applicationDirPath() + "/..", which assumes the binary lives one level below the checkout (e.g. build/octave). Failure yields "error" / "Could not determine local version".
  2. Query GitHub API -- GET https://api.github.com/repos/RobDeGeorge/OCTAVE/commits/main with a User-Agent: OCTAVE-Updater header and a 10 s timeout. The response's sha is truncated to 7 characters; the first line of commit.message becomes remoteCommitMsg. There is no separate connectivity gate: a failed request yields "error" / "Could not reach GitHub", and an unparseable body "Could not parse GitHub response".
  3. Classify the relationship -- localVsRemote() returns one of same, ahead, diverged, behind (see below), and the working tree is checked for uncommitted changes at the same time.
  4. Emit result -- updateStatus becomes "up-to-date", "ahead", "diverged" or "update-available" with a matching updateMessage; remoteCommit, remoteCommitMsg and workingTreeDirty are refreshed. When an update is available but the tree is dirty, the message gains " — uncommitted changes block updating". The manager logs local=… built=… remote=… relation=… dirty=… status=… at info level.
  5. Stale binary -- the manager also compares HEAD with the commit baked into the running binary (OCTAVE_GIT_COMMIT in the generated buildinfo.h). If the checkout matches origin/main but the binary was built from another commit, the status is "update-available" with "Source is at <head> but this build is <built> — update to rebuild", so the Update button finishes the job by rebuilding.

Ancestry-Aware Classification

A bare remote != local test cannot tell "behind" from "ahead". Before commit 0c29e64 a source checkout with local commits reported an update to a commit older than HEAD, and the self-update path would then have hard-reset onto it. The classifier now asks git:

QString NetworkManager::localVsRemote(const QString &remoteShort, const QString &localShort) const
{
    if (remoteShort.isEmpty())
        return QStringLiteral("behind");
    if (remoteShort == localShort)
        return QStringLiteral("same");

    // Do we even have the remote commit locally? If the object is missing we
    // cannot be a descendant of it, so this is a normal "remote moved ahead".
    bool haveRemote = false;
    runGitSync({"cat-file", "-e", remoteShort + "^{commit}"}, &haveRemote, 5000);
    if (haveRemote) {
        bool isAncestor = false;
        runGitSync({"merge-base", "--is-ancestor", remoteShort, "HEAD"}, &isAncestor, 5000);
        if (isAncestor)
            return QStringLiteral("ahead");  // remote commit is already in our history
    }

    // Remote commit is unknown to us. If we also carry commits the last-fetched
    // origin/main lacks, both sides moved — that is a divergence, not an update.
    bool ok = false;
    const QString aheadCount = runGitSync({"rev-list", "--count", "origin/main..HEAD"}, &ok, 5000);
    if (ok && aheadCount.toInt() > 0)
        return QStringLiteral("diverged");

    return QStringLiteral("behind");
}

The three git commands run through the blocking runGitSync() helper. Note that the diverged test compares against the last-fetched origin/main; the check itself never fetches.

Update Status Values

// updateStatus can be one of:
""                  // No check has run yet (About page hides the status row)
"checking"          // Currently checking
"up-to-date"        // Local matches remote and the binary was built from HEAD
"update-available"  // Behind origin/main, or the binary is stale
"ahead"             // Source checkout with local commits (dev build)
"diverged"          // Both sides moved -- not a safe update
"error"             // Check failed (GitHub unreachable, bad response, not a git checkout)

Only "update-available" raises the badge on the settings button (frontend/BottomBar.qml, frontend/SettingsMenu.qml) and offers the self-update button. "ahead" and "diverged" are normal states when running from the source tree and must never trigger an "update" that would move the checkout backwards.

Packaged builds normally cannot check for updates
A normally installed binary has no source checkout at the expected repository path, so git rev-parse fails in step 2 and the About page shows "Could not determine local version". The update check is a source-checkout feature today; there is no fallback that compares the build's version tag against GitHub releases.

Self-Update System

Availability
Self-update is only available when running from a git checkout. canSelfUpdate is probed 2 s after startup and requires a .git path in repoDir(), a CMakeCache.txt in the binary's directory (the build dir it rebuilds) and a cmake (the cache's CMAKE_COMMAND, else PATH) and a working git --version; it returns false unconditionally on Android. Packaged builds therefore report false and the About page shows its "not available" card instead.
Preconditions (checked at click time)
The apply step below is git reset --hard origin/main, which discards every uncommitted change to tracked files. applySelfUpdate() therefore re-verifies state at the moment it is called for uncommitted changes, and refuses to run when:
  • the working tree is dirty (git status --porcelain is non-empty) -- selfUpdateMessage "Uncommitted changes — commit or stash them first", or
  • updateStatus is "ahead" or "diverged", since resetting would discard local commits -- "Local commits would be lost — refusing to reset".
If git itself cannot be queried, the tree is assumed dirty. The ahead/diverged check uses the cached updateStatus; ancestry is not rechecked after fetch or immediately before reset. These checks therefore do not guarantee preservation of commits or edits made after the checks. The About page additionally hides the update and "Retry Update" buttons while workingTreeDirty is true and shows a "commit or stash first" warning instead.

Pre-Conditions

bool NetworkManager::checkCanSelfUpdate()
{
#ifdef Q_OS_ANDROID
    return false;
#endif
    // Packaged AppImage / installer / DMG builds have no .git above the binary
    if (!QFileInfo::exists(QDir(repoDir()).filePath(".git")))
        return false;
    // The update rebuilds the binary in place: it needs the build dir and a cmake
    if (!QFileInfo::exists(QDir(buildDir()).filePath("CMakeCache.txt")))
        return false;
    if (findCmake().isEmpty())
        return false;
    QProcess gitCheck;
    gitCheck.start("git", {"--version"});
    if (!gitCheck.waitForFinished(5000))
        return false;
    return gitCheck.exitCode() == 0;
}

Update Pipeline

The update pulls the new source and then rebuilds the binary in place. The sequence is a step machine (runSelfUpdateStep() / onSelfUpdateStepFinished()): each step starts one QProcess and the finished slot starts the next. A double-tap guard (m_updating) prevents concurrent update attempts.

  1. Save rollback point -- git rev-parse HEAD captures the full current hash (progress "Saving rollback point..."). A failure here is not fatal; the update proceeds without rollback.
  2. Fetch latest changes -- git fetch origin main (no timeout). Progress "Fetching latest changes...". Failure ends with "Fetch failed: <stderr>".
  3. Apply update -- git reset --hard origin/main (no timeout). Progress "Applying update...". Failure ends with "Apply failed: <stderr>".
  4. Configure -- cmake -S <repo> -B <build dir> against the existing cache, so the baked commit hash matches the new HEAD. Progress "Configuring build...".
  5. Build -- nice -n 10 cmake --build <build dir> --parallel <cores> (no timeout; an incremental build can take minutes on a Pi). Progress "Building... [45%]" (Makefiles) or "Building... [123/456]" (Ninja), parsed from the build output. The binary is copied to octave.pre-update first. On a failed configure or build the source is rolled back, the copy replaces the binary if the build touched it, and the update ends with "Build failed — update rolled back to <hash>" plus the first error line. The linker writes the new binary as a new inode, so the running process is unaffected; the commit hash lives in a generated header included only by SettingsManager and NetworkManager, so a new commit does not rebuild every source file.
  6. Confirm -- git log -1 --format=%h %s produces the final message "Update applied. Now on: <hash> <subject>" (if git log fails to start, "Update applied — restart to run it") and selfUpdateStatus becomes "restart-required"; updateStatus flips to "up-to-date" with "Update applied — restart to complete".

Rollback Mechanism

Rollback is git reset --hard <old_commit> (30 s timeout) and is a no-op when step 1 failed to record a hash. rollback() runs on fetch, reset, configure and build failures, and when git or cmake fails to start for steps 1-4; configure and build failures also restore the previous binary (restoreBinary()).

void NetworkManager::rollback()
{
    if (m_oldCommit.isEmpty())
        return;
    QProcess rollbackProc;
    rollbackProc.setWorkingDirectory(repoDir());
    rollbackProc.start("git", {"reset", "--hard", m_oldCommit});
    if (rollbackProc.waitForFinished(30000)) {
        // log "Rolled back to <hash>" or "Rollback failed: <stderr>"
    } else {
        // log "Rollback timed out"
    }
}

Progress Reporting

Step progress is relayed through a mutex-protected message slot. Each step calls setProgress(msg), and the 250 ms m_resultPollTimer picks the message up and emits selfUpdateMessageChanged when it differs from the current text. applySelfUpdate() starts the timer before setting the initial "Fetching latest changes..." and finishSelfUpdate() stops it, so every step's message ("Applying update...", "Configuring build...", "Building... [n%]") reaches the About page.

Self-Update Status Values

"idle"              // No update in progress (initial value)
"fetching"          // Pipeline running (About page shows a spinner + selfUpdateMessage)
"error"             // Refused, failed, or rolled back -- message says which
"restart-required"  // Update applied, restart needed

Application Restart

After a successful self-update the About page offers restartApp(). It first checks that the binary still exists and is executable, and otherwise aborts with selfUpdateStatus = "error" / "Restart aborted — the OCTAVE binary is missing". The restart itself is platform-dependent:

  • Windows: QProcess::startDetached(appPath, args) immediately, then QCoreApplication::quit()
  • Linux/macOS: the same startDetached() deferred to aboutToQuit, then quit(), so the new process starts once manager cleanup has run.
Restart Safety
If the pre-flight check fails, the restart is aborted and the user is notified via selfUpdateMessage. The build step already proved the code compiles; the check only guards against quitting with no binary to restart into.

Device Shutdown

The shutdown_device() slot (the power button on frontend/settings/DeviceSettingsPage.qml) gives vehicle-mounted installs a clean power-off request: it launches the OS shutdown command detached and then quits OCTAVE, triggering aboutToQuit cleanup. The manager does not verify that the OS accepted the request or wait for cleanup before requesting shutdown; the command also depends on the host's permissions.

// Platform-specific shutdown commands (QProcess::startDetached):
Linux:   systemctl --no-block poweroff
macOS:   sudo shutdown -h +0
Windows: shutdown /s /t 5

Signals Reference

SignalParametersDescription
networkNameChangedstrWiFi SSID or "Ethernet" changed
isConnectedChangedboolInternet reachability changed
updateStatusChangedstrUpdate check state changed
updateMessageChangedstrHuman-readable update status
remoteCommitChangedstrLatest remote commit hash
remoteCommitMsgChangedstrLatest remote commit message
workingTreeDirtyChangedboolCheckout gained/lost uncommitted changes
selfUpdateStatusChangedstrSelf-update state machine
selfUpdateMessageChangedstrSelf-update progress text
canSelfUpdateChangedboolWhether git-based update is possible

Properties Reference

PropertyTypeNotify SignalDescription
networkNamestrnetworkNameChangedCurrent WiFi SSID or "Ethernet" ("WiFi" on Android)
isConnectedboolisConnectedChangedTrue if internet is reachable
updateStatusstrupdateStatusChangedCurrent update check status
updateMessagestrupdateMessageChangedHuman-readable update message
remoteCommitstrremoteCommitChangedLatest remote commit short hash
remoteCommitMsgstrremoteCommitMsgChangedLatest remote commit message (first line)
workingTreeDirtyboolworkingTreeDirtyChangedCheckout has uncommitted changes; self-update is refused while true
selfUpdateStatusstrselfUpdateStatusChangedSelf-update state
selfUpdateMessagestrselfUpdateMessageChangedSelf-update progress message
canSelfUpdateboolcanSelfUpdateChangedGit-based self-update available

Slots Reference

SlotParametersDescription
refreshNetwork()--Manually trigger a network status check (Device settings "refresh")
checkForUpdates()--Start a GitHub update check (About page button; also auto-run once on first connectivity)
applySelfUpdate()--Begin the git self-update pipeline after re-checking the preconditions
restartApp()--Restart the OCTAVE application
shutdown_device()--Shut down the host OS and quit OCTAVE
cleanup()--Stop timers and kill any running git / network-name process at exit

System Integration

Registration:

// src/main.cpp
NetworkManager networkManager;
ctx->setContextProperty("networkManager", &networkManager);

QML Consumers

QML fileUses
frontend/settings/AboutPage.qml"Check for Updates" button (disabled while updateStatus === "checking"), colour-coded status row with updateMessage, remote commit hash + message card, the self-update card (hidden while workingTreeDirty, replaced by a "commit or stash" warning), progress / error / restart-required states driven by selfUpdateStatus and selfUpdateMessage, and a "not available" card when !canSelfUpdate
frontend/BottomBar.qml, frontend/SettingsMenu.qmlPulsing badge on the settings button while updateStatus === "update-available"
frontend/settings/widgets/AboutWidget.qml, frontend/settings/widgets/DeviceWidget.qmlCompact settings tiles: update status text/colour and a "Check Updates" button; connection dot, networkName and a refresh calling refreshNetwork()
frontend/settings/DeviceSettingsPage.qmlGreen/red dot from isConnected, networkName ("Not Connected" / "No internet access" texts), refreshNetwork(), and the power-off button calling shutdown_device()

Many bindings guard a null networkManager. src/main.cpp registers this context property before loading QML.

Cleanup

cleanup() is called from the aboutToQuit handler in src/main.cpp (and from the destructor). It stops both timers and kills and waits (3 s each) on any still-running self-update git/cmake process or network-name process.

Timers Summary

TimerIntervalPurpose
m_pollTimer30,000 ms online; 120,000 ms after three consecutive missesPeriodic network status check
m_resultPollTimer250 ms while a self-update runsRelay step progress to selfUpdateMessage
startup single-shot2,000 ms, oncegit --version probe for canSelfUpdate + first network check
auto-update single-shot500 ms, oncecheckForUpdates() after the first successful probe

Design Decisions

No worker thread

Every slow operation -- the connectivity probe, the name lookup, the GitHub request and each self-update step -- goes through Qt's asynchronous QProcess and QNetworkAccessManager, whose completion slots run on the GUI thread. Signals are therefore never emitted from another thread and no locking is needed beyond the small progress slot.

Back off when the car is offline

Commit ee5a23e introduced the 30 s → 2 min backoff after three misses and moved the git --version probe off the constructor. The source comment states the motivation: "a car with no internet should not pay for an HTTPS timeout twice a minute forever." Recovery is immediate on the first successful probe, so a hotspot that comes back is noticed within one cycle.

Ancestry-aware update classification

Commit 0c29e64 replaced the hash-inequality test with the same / ahead / diverged / behind classifier after a dev checkout with local commits was offered an "update" to an older commit. The cat-file -e check comes first because an unknown remote object can never be an ancestor of HEAD, which avoids a misleading merge-base failure on the common "remote moved ahead" case.

Re-verify at click time; assume the worst when git is silent

Because the apply step is a hard reset, applySelfUpdate() re-runs git status --porcelain at the moment of the tap instead of trusting a check that may be minutes old ("the tree can be dirtied, or commits made, long after the last check ran"). If git cannot be queried the tree is treated as dirty -- "the safe default when we cannot tell is the one that refuses to destroy anything". workingTreeDirty is exposed as a property purely so the UI can hide the button before it "eats real work" (header comment).

Synchronous checks alongside asynchronous work

The header explains why runGitSync() blocks the GUI thread: the queries are short, and their answer must be current at the instant of the click, not whatever an async check last cached. The network probes, GitHub request and update steps use asynchronous completion; startup and restart checks and process-start waits can still block the GUI thread.

Build before trusting the new code

Code that fails to compile would leave the head unit running a binary that no longer matches its source, or with no binary at all after a failed link. The pipeline therefore copies the binary to octave.pre-update, rebuilds, and on a failed configure or build resets the source to the saved commit and puts the old binary back. restartApp() then only has to confirm that a binary exists before quitting.

One automatic check, then user-driven

The update check runs once on first connectivity (500 ms later, so the connectivity state has settled) and afterwards only from the About page. There is no periodic polling of GitHub, which keeps the API call count negligible and avoids surprising the driver with a badge mid-journey.

Git as the source update channel

The self-updater is git fetch + git reset --hard + a CMake reconfigure and incremental build, which is the source-checkout update sequence implemented here. It intentionally does not try to replace a packaged binary; canSelfUpdate is false there and the About page says so.