Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions docs/architecture/updates.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,17 @@ How to cut a release is in [`surfsense_local/RELEASE.md`](../../surfsense_local/

Updates are a call the app makes on its own, so they are off until the user turns them on.

- A new install asks in onboarding's Privacy choices step, beside usage statistics, with neither answer chosen for the user ([usage statistics](usage-statistics.md#consent)). An install that finished onboarding earlier keeps the setting it had.
- A new install asks in onboarding's first step, Privacy choices, beside usage statistics, with neither answer chosen for the user ([usage statistics](usage-statistics.md#consent)). An install that finished onboarding earlier keeps the setting it had.
- The preference is `updates.json` in Electron's user data: `automatic`, false by default; `lastCheckedAt`, which Settings › Network shows as the App updates row's last call; and `lastAutomaticDay`, the UTC day of the last automatic check.
- The automatic check runs at most once a UTC day: at launch, and from an hourly tick once the day turns while the app stays open. The day is marked only once a check reads a manifest, from either host. A check that reads none is tried again on the hourly tick, at most three times a UTC day. A day's automatic checks therefore approximate the installs open that day, with no identifier on the wire; a retry after a failed check can add a second event from one install.
- Check now in Settings › About, the sidebar's update row and Check for Updates… in the macOS app menu share one check (`useCheckForUpdates`), which asks first while `automatic` is off, naming the host it asks. The menu shows only that prompt; the sidebar and Settings show the progress. Allow turns `automatic` on, which also enables the daily check.
- Check now in Settings › About and Check for Updates… in the macOS app menu share one check (`useCheckForUpdates`), which asks first while `automatic` is off, naming the host it asks. Allow turns `automatic` on, which also enables the daily check. Settings shows a check's progress and result. A check from the menu, with Settings perhaps closed, reports up to date or failed in a toast; a found update shows in the title bar.
- The title bar shows updates only while one is downloading or ready, as a small pill beside the right-panel toggle. While downloading, a ring around its arrow fills with the percentage electron-updater reports, sent to the window once per whole percent; with no size known yet, part of the ring turns. Once ready, the pill fills and restarts into the update on click. On hover or focus it widens to show its label, "Downloading 64%" or "Update", with no tooltip. It shows nothing for checking, up to date or a failed check.
- An admin policy file with `"updates": "off"` turns both off and locks the setting; Check now is disabled and says why. `"updateFeed"` names an `https` mirror instead of `updates.surfsense.com`, with no GitHub fallback ([usage statistics](usage-statistics.md#policy) says where the file lives).
- An install a package manager owns never checks: Scoop, the AUR and Nix, whose packages write `resources/install-channel`, a snap or a flatpak, known from their environment, and a deb from SurfSense's apt repository, known from `/etc/apt/sources.list.d/surfsense.list` ([contract 5](../contracts/05-desktop-telemetry.md), part C, has the order). The setting is locked as under the policy, which names the lock when both apply. Settings › About then shows only "Updates come from" that package manager, with no daily check described and no "Show exactly what's sent". The app does not construct electron-updater at all for such an install, so a repackaged deb's `package-type` cannot start its deb updater.
- The sidebar's update row is gone while either lock holds, since a check it started would be refused.
- Scoop and the AUR must first be submitted with the first release that carries `install-channel/`, not earlier: an older release has no channel lock, so it would update itself out from under them ([packaging](../../surfsense_local/packaging/README.md)).
- Homebrew and winget run the release's own installer, so nothing marks them and the in-app updater stays available beside `brew upgrade` and `winget upgrade`. The Homebrew cask declares `auto_updates` inside `on_macos` only, so plain `brew upgrade` updates the Mac app while its bundle's version is older than the cask's, as it is whenever the in-app updater is off, and always updates the Linux AppImage, which has no bundle version to compare.
- `autoDownload` is false. The download starts from the `update-available` event, so it only ever follows a check the user enabled or clicked.
- A downloaded update installs when the user restarts from the prompt in Settings, the sidebar or the title bar, which calls `quitAndInstall()` once the download is ready, or else the next time they quit: `autoInstallOnAppQuit`, electron-updater's default, is set to true on purpose. The update prompt says both.
- A downloaded update installs when the user restarts from Settings or the title bar's pill, which calls `quitAndInstall()` once the download is ready, or else the next time they quit: `autoInstallOnAppQuit`, electron-updater's default, is set to true on purpose. The update prompt says both.

## What a check sends

Expand All @@ -45,7 +45,7 @@ Settings › About's "Show exactly what's sent" shows the last request to the fe

This is not an `egress.require()` destination, because electron-updater runs in the Electron main process where the backend's check cannot see it ([egress](egress.md)). A development build shows the same controls and never checks.

An inactive electron-updater, such as its AppImage updater in an AppImage run without `$APPIMAGE`, answers a check with null and emits nothing. The check then puts back the state it found, idle unless an update was already downloaded, so Settings and the sidebar never stay on "Checking…". A check that throws keeps the `error` state electron-updater emitted first.
An inactive electron-updater, such as its AppImage updater in an AppImage run without `$APPIMAGE`, answers a check with null and emits nothing. The check then puts back the state it found, idle unless an update was already downloaded, so Settings never stays on "Checking…". A check that throws keeps the `error` state electron-updater emitted first.

## Update size

Expand Down
6 changes: 3 additions & 3 deletions docs/architecture/usage-statistics.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@ No identifier ties one install's reports together, but the values that never cha

## Consent

- A new install answers in the Privacy choices step, right after the welcome in onboarding: two rows, update checks and usage statistics, each with equal Yes and No buttons, neither chosen beforehand, and Continue only once both are answered. Continue calls `completeFirstRun`, which writes both answers and `firstRunAsked`. Each row opens exactly what it sends.
- An install that finished onboarding never sees the step, and one that answered (`firstRunAsked`) skips it. Its usage statistics stay `null`, read as off, until the user turns them on. A bridge that cannot read its state skips the step too, with both left off, rather than holding setup on an empty card.
- Settings › About has the Usage statistics switch beside App updates, with "Show exactly what's sent": the next report as Electron main would build it now (`previewUsageReport`, not sent), the last report sent and when, and the host. Settings › Network lists the same switch as a row with its host and its last call.
- Privacy choices is onboarding's first step, right after the welcome, counted in its progress like the model steps: two rows, update checks and usage statistics, each with equal Yes and No buttons, neither chosen beforehand, and Continue only once both are answered. Continue calls `completeFirstRun`, which writes both answers and `firstRunAsked`. Each row says in one line what it does; the info icon beside its title shows the full description with every host its answer reaches, and the link below opens exactly what it sends.
- Onboarding always shows the step in the desktop app. An install that answered already (`firstRunAsked`), or that comes back from the first model step, sees its answers chosen. An install that finished onboarding never sees it; its usage statistics stay `null`, read as off, until the user turns them on. A bridge that cannot read its state skips the step, with both left off, rather than holding setup on an empty card.
- Settings › About has the Usage statistics switch beside App updates. Both say what they do as onboarding does, in the same one-line summary with the full description behind an info icon, and each has "Show exactly what's sent". For usage statistics that shows the next report as Electron main would build it now (`previewUsageReport`, not sent), the last report sent and when, and the host. Settings › Network lists the same switch as a row with its host and its last call.
- Turning the switch off stops the next report; nothing already sent can be recalled, because no identifier marks which reports were this install's.

## Policy
Expand Down
33 changes: 32 additions & 1 deletion surfsense_local/electron/src/main/updater.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ test("a found update is downloaded and offered, and waits for a restart or a qui
assert.deepEqual(fake.calls, ["check", "download"])
assert.deepEqual(states, [
{ status: "checking" },
{ status: "downloading", version: "1.0.1" },
{ status: "downloading", version: "1.0.1", percent: null },
{ status: "ready", version: "1.0.1" },
])
assert.deepEqual(updates.state(), { status: "ready", version: "1.0.1" })
Expand All @@ -73,6 +73,37 @@ test("a found update is downloaded and offered, and waits for a restart or a qui
assert.deepEqual(fake.calls, ["check", "download", "install"])
})

test("a download reports its progress in whole percents, once per step", async () => {
const fake = fakeAutoUpdater()
const states: UpdateState[] = []
const updates = attachUpdater(fake.updater, (state) => states.push(state), FEED)

await updates.check()
fake.emit("update-available", { version: "1.0.1" })
for (const percent of [0.4, 12.2, 12.9, 64.5, 100])
fake.emit("download-progress", { percent, total: 1000 })
// No size known yet, so no percentage to show.
fake.emit("download-progress", { percent: 0, total: 0 })

assert.deepEqual(
states.slice(1).map((state) => (state.status === "downloading" ? state.percent : state.status)),
[null, 0, 12, 64, 100, null]
)
assert.deepEqual(updates.state(), { status: "downloading", version: "1.0.1", percent: null })
})

test("progress after the download finished leaves it ready", async () => {
const fake = fakeAutoUpdater()
const updates = attachUpdater(fake.updater, () => undefined, FEED)

await updates.check()
fake.emit("update-available", { version: "1.0.1" })
fake.emit("update-downloaded", { version: "1.0.1" })
fake.emit("download-progress", { percent: 100, total: 1000 })

assert.deepEqual(updates.state(), { status: "ready", version: "1.0.1" })
})

test("checks go through SurfSense's feed provider, with no headers left for downloads", () => {
const fake = fakeAutoUpdater()
fake.updater.requestHeaders = { "x-user-staging-id": "0" }
Expand Down
12 changes: 10 additions & 2 deletions surfsense_local/electron/src/main/updater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ export type UpdateState =
| { status: "idle" }
| { status: "checking" }
| { status: "up-to-date" }
| { status: "downloading"; version: string }
// percent: whole percents, null until the download's size is known.
| { status: "downloading"; version: string; percent: number | null }
| { status: "ready"; version: string }
| { status: "error"; message: string }

Expand Down Expand Up @@ -104,9 +105,16 @@ export function attachUpdater(
// `updater.channel`, whose setter turns on allowDowngrade.
updater.setFeedURL(surfSenseFeedOptions(feed))
updater.on("update-available", (info: { version: string }) => {
set({ status: "downloading", version: info.version })
set({ status: "downloading", version: info.version, percent: null })
updater.downloadUpdate().catch(() => undefined)
})
// Sent once per whole percent, so the window is not redrawn many times a second.
updater.on("download-progress", (progress: { percent: number; total: number }) => {
if (current.status !== "downloading") return
const percent =
progress.total > 0 ? Math.min(100, Math.max(0, Math.floor(progress.percent))) : null
if (percent !== current.percent) set({ ...current, percent })
})
updater.on("update-downloaded", (info: { version: string }) => {
set({ status: "ready", version: info.version })
})
Expand Down
2 changes: 2 additions & 0 deletions surfsense_local/frontend/src/components/ui/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
AiImageEditIcon as AiImageEditIconData,
AiSearchLinesIcon as AiSearchLinesIconData,
ArrowDownIcon as ArrowDownIconData,
ArrowDown02Icon as ArrowDown02IconData,
ArrowExpand01Icon as ArrowExpand01IconData,
ArrowLeftIcon as ArrowLeftIconData,
ArrowRightIcon as ArrowRightIconData,
Expand Down Expand Up @@ -135,6 +136,7 @@ export const AiEditingIcon = createIcon(AiEditingIconData)
export const AiImageEditIcon = createIcon(AiImageEditIconData)
export const AiSearchLinesIcon = createIcon(AiSearchLinesIconData)
export const ArrowDownIcon = createIcon(ArrowDownIconData)
export const ArrowDown02Icon = createIcon(ArrowDown02IconData)
export const ArrowExpand01Icon = createIcon(ArrowExpand01IconData)
export const ArrowLeftIcon = createIcon(ArrowLeftIconData)
export const ArrowRightIcon = createIcon(ArrowRightIconData)
Expand Down
40 changes: 20 additions & 20 deletions surfsense_local/frontend/src/features/about/data-folder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,29 +32,29 @@ export function DataFolder() {
}

return (
<div className="mt-8 flex flex-col gap-1">
<h3 className="text-sm font-medium">
{intl.formatMessage({
id: "about_data_folder_title",
defaultMessage: "Your data",
})}
</h3>
<p className="text-sm text-pretty text-muted-foreground">
{intl.formatMessage({
id: "about_data_folder_body",
defaultMessage:
"Models, workspaces and settings are kept here, not in the folder SurfSense is installed in.",
})}
</p>
<code className="text-sm break-all">{folder}</code>
<div className="mt-1">
<Button type="button" variant="outline" onClick={() => void open()}>
<div className="mt-8 flex items-start justify-between gap-8">
<div className="flex min-w-0 flex-col gap-1">
<h3 className="text-sm font-medium">
{intl.formatMessage({
id: "about_data_folder_open_button",
defaultMessage: "Open folder",
id: "about_data_folder_title",
defaultMessage: "Your data",
})}
</Button>
</h3>
<p className="text-sm text-pretty text-muted-foreground">
{intl.formatMessage({
id: "about_data_folder_body",
defaultMessage:
"Models, workspaces and settings are kept here, not in the folder SurfSense is installed in.",
})}
</p>
<code className="text-sm break-all">{folder}</code>
</div>
<Button type="button" variant="outline" onClick={() => void open()}>
{intl.formatMessage({
id: "about_data_folder_open_button",
defaultMessage: "Open folder",
})}
</Button>
</div>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ import {
} from "@/features/office-support/office-offer"
import { StudioPanel } from "@/features/studio/studio-panel"
import { useStudio } from "@/features/studio/use-studio"
import { UpdateButton } from "@/features/updates/update-settings"
import { TitleBarUpdate } from "@/features/updates/title-bar-update"
import type { Workspace } from "@/features/workspaces/api"
import { useWorkspaces } from "@/features/workspaces/use-workspaces"
import { useStableCallback } from "@/hooks/use-stable-callback"
Expand Down Expand Up @@ -385,7 +385,7 @@ function WorkspaceDashboard({
<OpenArtifactContext.Provider value={openArtifact}>
<div className="titlebar-controls">
<div className="titlebar-controls-end">
<UpdateButton />
<TitleBarUpdate />
<Tooltip>
<TooltipTrigger
render={
Expand Down
Loading
Loading