From 23ee0d569454cb8647f121740451cdafb24fada7 Mon Sep 17 00:00:00 2001 From: Leo310 Date: Sun, 27 Sep 2026 20:04:43 +0200 Subject: [PATCH 1/3] feat(plugin): notice when a new version is available MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once a day, a while after startup, fetch the plugin's manifest.json from the default branch on GitHub (the same file Obsidian's own update check reads) and show "Smart Second Brain X is available · Update" once per newer stable version. Update opens Settings -> Community plugins. It stays quiet on pre-release builds (BRAT delivers those), for versions whose minAppVersion the running Obsidian doesn't meet, and while Obsidian's own "Automatically check for plugin updates" is on. A "Check for updates" toggle under Settings -> General -> Privacy turns it off; nothing about the user or the vault is sent. The README's privacy line now discloses the request. Adds utils/version.ts (shared with the release-notes PR). Co-Authored-By: Claude --- README.md | 2 +- src/main.ts | 87 ++++++++++++++++++++++- src/stores/dataStore.svelte.ts | 27 +++++++ src/types/plugin.ts | 6 ++ src/utils/updateCheck.ts | 54 ++++++++++++++ src/utils/version.ts | 42 +++++++++++ src/views/settings/GeneralSettings.svelte | 11 +++ test/__mocks__/obsidian.ts | 3 + test/utils/updateCheck.test.ts | 64 +++++++++++++++++ 9 files changed, 294 insertions(+), 2 deletions(-) create mode 100644 src/utils/updateCheck.ts create mode 100644 src/utils/version.ts create mode 100644 test/utils/updateCheck.test.ts diff --git a/README.md b/README.md index 6883414f..41381cc4 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Install **Smart Second Brain** from Obsidian's community plugins and enable it. Add an embedding model and pick it for search and for the graph to unlock semantic search and topics that read what your notes say, not just how they link; add an AI provider to enable the agent. See the [getting started guide](https://smartsecondbrain.dev/start/installation/). -Out of the box, nothing leaves your machine. The only network requests are to the providers you configure, and there is no telemetry — see [privacy](https://smartsecondbrain.dev/privacy/model/). +Out of the box, none of your data leaves your machine. Your notes only go to the providers you configure, and there is no telemetry — see [privacy](https://smartsecondbrain.dev/privacy/model/). The one other network request is a daily update check that fetches this plugin's `manifest.json` from GitHub and sends nothing about you; turn it off under Settings → General → Check for updates. ## Contributing diff --git a/src/main.ts b/src/main.ts index ceb0c100..00e4ef98 100755 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,15 @@ -import { type EventRef, MarkdownView, Menu, Notice, Plugin, TFile, WorkspaceLeaf, debounce } from "obsidian"; +import { + type EventRef, + MarkdownView, + Menu, + Notice, + Plugin, + TFile, + WorkspaceLeaf, + apiVersion, + debounce, + requestUrl, +} from "obsidian"; import { mount, unmount } from "svelte"; import "./lib/i18n"; import "./lib/langgraphContext"; @@ -8,6 +19,13 @@ import { seedMemoryFolder } from "./agent/memoryNotes"; import { memoriesDir } from "./utils/agentPaths"; import { resetAvailableModels, useAvailableModels } from "./hooks/useAvailableModels.svelte"; import { isMobileUI } from "./utils/platform"; +import { + COMMUNITY_PLUGIN_URL, + UPDATE_MANIFEST_URL, + isUpdateCheckDue, + parseRemoteManifest, + updateToAnnounce, +} from "./utils/updateCheck"; import { StartupProfiler } from "./utils/startupProfiler"; import { persistStartupRecord, recordStartupEnvironment } from "./utils/startupTimingsStore"; import "./styles.css"; @@ -672,6 +690,13 @@ export default class SecondBrainPlugin extends Plugin { void this.activateOnboardingView(); } + // Update check: a while after startup so it never competes with init, then + // hourly re-evaluation for sessions left open for days. Each run only + // fetches once the daily interval has passed. + const startUpdateCheck = window.setTimeout(() => void this.checkForUpdate(), 30_000); + this.register(() => window.clearTimeout(startUpdateCheck)); + this.registerInterval(window.setInterval(() => void this.checkForUpdate(), 60 * 60 * 1000)); + // Agents whose customized prompt/guidance couldn't be auto-updated after a // default changed are surfaced in the new-chat recommendations view // (ChatRecommendations.svelte reads pluginData.staleGuidance), so no startup @@ -991,6 +1016,66 @@ export default class SecondBrainPlugin extends Plugin { // workspace.revealLeaf(leaf); // } + /** + * Fetch the plugin's manifest from GitHub (at most daily, unless disabled in + * settings) and show a notice once per newer version Obsidian would install. + * Failures are silent: offline is normal, and the next run retries. + * + * Steps aside entirely while Obsidian's own "Automatically check for plugin + * updates" (Community plugins, off by default) is on: that already announces + * updates, and a second notice would just be noise. + */ + private async checkForUpdate() { + const data = this.pluginData; + if (!data.checkForUpdates || this.obsidianChecksPluginUpdates()) return; + if (!isUpdateCheckDue(Date.now(), data.lastUpdateCheckAt)) return; + data.lastUpdateCheckAt = Date.now(); + let remote: ReturnType; + try { + const response = await requestUrl({ url: UPDATE_MANIFEST_URL, throw: false }); + remote = response.status === 200 ? parseRemoteManifest(response.json) : null; + } catch (error) { + Log.debug("[UpdateCheck] Could not reach GitHub:", error); + return; + } + if (!remote || !data.checkForUpdates) return; + const version = updateToAnnounce(this.manifest.version, apiVersion, remote, data.lastNotifiedUpdateVersion); + if (!version) return; + data.lastNotifiedUpdateVersion = version; + const message = createFragment((frag) => { + frag.appendText(`Smart Second Brain ${version} is available. `); + const link = frag.createEl("a", { text: "Update", href: "#" }); + link.addEventListener("click", (event) => { + event.preventDefault(); + this.openCommunityPluginSettings(); + }); + }); + new Notice(message, 15_000); + } + + /** Obsidian's own periodic plugin-update check (internal API; false if it ever moves). */ + private obsidianChecksPluginUpdates(): boolean { + const plugins = (this.app as typeof this.app & { plugins?: { autoCheckForUpdates?: unknown } }).plugins; + return plugins?.autoCheckForUpdates === true; + } + + /** + * Settings → Community plugins, where "Check for updates" and the per-plugin + * Update button live. app.setting is undocumented internal API, so every hop is + * optional; if its shape changes, fall back to the `obsidian://show-plugin` URI. + */ + private openCommunityPluginSettings() { + const setting = ( + this.app as typeof this.app & { setting?: { open?: () => void; openTabById?: (id: string) => unknown } } + ).setting; + if (setting?.open && setting.openTabById) { + setting.open(); + setting.openTabById("community-plugins"); + } else { + window.open(COMMUNITY_PLUGIN_URL); + } + } + async activateOnboardingView() { // Enabling the plugin from Settings → Community plugins fires onLayoutReady // (and this auto-open) while the Settings modal is still on top of the diff --git a/src/stores/dataStore.svelte.ts b/src/stores/dataStore.svelte.ts index e43c443b..a05c99c8 100644 --- a/src/stores/dataStore.svelte.ts +++ b/src/stores/dataStore.svelte.ts @@ -232,6 +232,9 @@ export const DEFAULT_SETTINGS: PluginData = { lastActiveChatId: null, onboardingComplete: false, onboardingSplashSeen: false, + checkForUpdates: true, + lastUpdateCheckAt: null, + lastNotifiedUpdateVersion: null, dismissedRecommendations: [], thinkingProcessExpanded: true, showActiveAgentsInStatusBar: true, @@ -1235,6 +1238,30 @@ export class PluginDataStore { void this.saveSettings(); } + get checkForUpdates() { + return this.#data.checkForUpdates ?? true; + } + set checkForUpdates(val: boolean) { + this.#data.checkForUpdates = val; + void this.saveSettings(); + } + + get lastUpdateCheckAt() { + return this.#data.lastUpdateCheckAt ?? null; + } + set lastUpdateCheckAt(val: number | null) { + this.#data.lastUpdateCheckAt = val; + void this.saveSettings(); + } + + get lastNotifiedUpdateVersion() { + return this.#data.lastNotifiedUpdateVersion ?? null; + } + set lastNotifiedUpdateVersion(val: string | null) { + this.#data.lastNotifiedUpdateVersion = val; + void this.saveSettings(); + } + get dismissedRecommendations() { return this.#data.dismissedRecommendations; } diff --git a/src/types/plugin.ts b/src/types/plugin.ts index 8a861bbe..a1246224 100644 --- a/src/types/plugin.ts +++ b/src/types/plugin.ts @@ -557,6 +557,12 @@ export interface PluginData { onboardingComplete: boolean; /** Whether the onboarding splash intro animation has already played (so it plays only once). */ onboardingSplashSeen: boolean; + /** Once a day, fetch the plugin's manifest from GitHub and announce a newer version. */ + checkForUpdates: boolean; + /** When the update check last ran (ms since epoch), successful or not. */ + lastUpdateCheckAt: number | null; + /** Newest version already announced by the update check, so each is announced once. */ + lastNotifiedUpdateVersion: string | null; /** IDs of new-chat recommendations the user has dismissed. Includes the well-known block id to dismiss the whole surface. */ dismissedRecommendations: string[]; /** diff --git a/src/utils/updateCheck.ts b/src/utils/updateCheck.ts new file mode 100644 index 00000000..58eb1505 --- /dev/null +++ b/src/utils/updateCheck.ts @@ -0,0 +1,54 @@ +/** + * "A new version is available" check. Pure: the plugin supplies the fetched + * manifest, the running versions and the stored check state. + * + * The source is the plugin's `manifest.json` on the default branch — the same + * file Obsidian's own update check reads — so the notice fires exactly when + * Obsidian would offer the update. Nothing about the user or the vault is sent. + */ +import { compareVersions, isPrerelease } from "./version"; + +export const UPDATE_MANIFEST_URL = "https://raw.githubusercontent.com/s2b-dev/smart-second-brain/HEAD/manifest.json"; +/** This plugin's page in the community plugin browser; fallback when app.setting is unavailable. */ +export const COMMUNITY_PLUGIN_URL = "obsidian://show-plugin?id=smart-second-brain"; + +/** At most one request a day, however often Obsidian starts. */ +export const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; + +export interface RemoteManifest { + version: string; + minAppVersion?: string; +} + +export function isUpdateCheckDue(now: number, lastCheckAt: number | null): boolean { + return lastCheckAt === null || now - lastCheckAt >= UPDATE_CHECK_INTERVAL_MS || now < lastCheckAt; +} + +/** Read `version`/`minAppVersion` from a fetched manifest, or null if it isn't one. */ +export function parseRemoteManifest(json: unknown): RemoteManifest | null { + if (typeof json !== "object" || json === null) return null; + const { version, minAppVersion } = json as Record; + if (typeof version !== "string" || !/^\d+\.\d+\.\d+/.test(version)) return null; + return typeof minAppVersion === "string" ? { version, minAppVersion } : { version }; +} + +/** + * The version to announce, or null to stay quiet. + * + * - A pre-release build never announces: BRAT delivers its updates. + * - Only a newer stable version the running Obsidian can install counts; + * Obsidian wouldn't offer one whose `minAppVersion` it doesn't meet. + * - Each version is announced once (`lastNotified`). + */ +export function updateToAnnounce( + current: string, + appVersion: string, + remote: RemoteManifest, + lastNotified: string | null, +): string | null { + if (isPrerelease(current) || isPrerelease(remote.version)) return null; + if (compareVersions(remote.version, current) <= 0) return null; + if (remote.minAppVersion && compareVersions(appVersion, remote.minAppVersion) < 0) return null; + if (lastNotified !== null && compareVersions(remote.version, lastNotified) <= 0) return null; + return remote.version; +} diff --git a/src/utils/version.ts b/src/utils/version.ts new file mode 100644 index 00000000..b4efbfc9 --- /dev/null +++ b/src/utils/version.ts @@ -0,0 +1,42 @@ +/** Version helpers for the `X.Y.Z` / `X.Y.Z-pre` versions this plugin ships. */ + +export function isPrerelease(version: string): boolean { + return version.includes("-"); +} + +/** + * Semver order. A pre-release sorts before its release; two pre-releases of + * the same core compare by their dot-separated identifiers (numeric ones + * numerically). + */ +export function compareVersions(a: string, b: string): number { + const [coreA, preA] = splitVersion(a); + const [coreB, preB] = splitVersion(b); + for (let i = 0; i < 3; i++) { + const diff = (coreA[i] ?? 0) - (coreB[i] ?? 0); + if (diff !== 0) return Math.sign(diff); + } + if (preA === preB) return 0; + if (preA === null) return 1; + if (preB === null) return -1; + const idsA = preA.split("."); + const idsB = preB.split("."); + for (let i = 0; i < Math.max(idsA.length, idsB.length); i++) { + if (idsA[i] === undefined) return -1; + if (idsB[i] === undefined) return 1; + const numA = /^\d+$/.test(idsA[i]) ? Number(idsA[i]) : null; + const numB = /^\d+$/.test(idsB[i]) ? Number(idsB[i]) : null; + if (numA !== null && numB !== null) { + if (numA !== numB) return Math.sign(numA - numB); + } else if (idsA[i] !== idsB[i]) { + return idsA[i] < idsB[i] ? -1 : 1; + } + } + return 0; +} + +function splitVersion(version: string): [number[], string | null] { + const dash = version.indexOf("-"); + const core = dash === -1 ? version : version.slice(0, dash); + return [core.split(".").map((n) => Number.parseInt(n, 10) || 0), dash === -1 ? null : version.slice(dash + 1)]; +} diff --git a/src/views/settings/GeneralSettings.svelte b/src/views/settings/GeneralSettings.svelte index 50af1b95..18944449 100644 --- a/src/views/settings/GeneralSettings.svelte +++ b/src/views/settings/GeneralSettings.svelte @@ -5,6 +5,7 @@ import ProviderItem from "../../components/settings/ProviderItem.svelte"; import SettingGroup from "../../components/settings/SettingGroup.svelte"; import SettingItem from "../../components/settings/SettingItem.svelte"; import Button from "../../components/ui/Button.svelte"; +import Toggle from "../../components/ui/Toggle.svelte"; import DocsLink from "../../components/ui/DocsLink.svelte"; import { getData } from "../../stores/dataStore.svelte"; import { getPlugin } from "../../stores/state.svelte"; @@ -63,6 +64,16 @@ function handleOpenProviderSetup() {