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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
95 changes: 94 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -9,6 +20,7 @@ import { memoriesDir } from "./utils/agentPaths";
import { resetAvailableModels, useAvailableModels } from "./hooks/useAvailableModels.svelte";
import { isMobileUI } from "./utils/platform";
import { planUpdateAnnouncement } from "./utils/releaseNotes";
import { COMMUNITY_PLUGIN_URL, UPDATE_MANIFEST_URL, runUpdateCheck } from "./utils/updateCheck";
import { StartupProfiler } from "./utils/startupProfiler";
import { persistStartupRecord, recordStartupEnvironment } from "./utils/startupTimingsStore";
import "./styles.css";
Expand Down Expand Up @@ -84,6 +96,8 @@ export default class SecondBrainPlugin extends Plugin {
/** `performance.now()` when `onload` finished; -1 until then. Used to attribute the
* Obsidian pre-layout gap (onload:end → onLayoutReady). */
private onloadEndAt = -1;
/** Set in onunload, so async work that outlives the plugin (the update check) stays quiet. */
private unloaded = false;
/** Mounted status-bar running-agent indicator (unmounted on plugin unload). */
private runningIndicator: ReturnType<typeof mount> | null = null;

Expand Down Expand Up @@ -689,6 +703,13 @@ export default class SecondBrainPlugin extends Plugin {

this.announceUpdate();

// 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
Expand Down Expand Up @@ -895,6 +916,7 @@ export default class SecondBrainPlugin extends Plugin {
}

onunload() {
this.unloaded = true;
Log.info("Unloading plugin");
// The model store's module state survives a disable/enable cycle. Without this
// reset, its QueryObservers keep fetching with the unloaded plugin's credentials
Expand Down Expand Up @@ -1008,6 +1030,77 @@ 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;
await runUpdateCheck({
isEnabled: () => data.checkForUpdates,
obsidianChecksUpdates: () => this.obsidianChecksPluginUpdates(),
isActive: () => !this.unloaded,
now: () => Date.now(),
currentVersion: this.manifest.version,
appVersion: apiVersion,
getLastCheckAt: () => data.lastUpdateCheckAt,
setLastCheckAt: (at) => {
data.lastUpdateCheckAt = at;
},
getLastNotified: () => data.lastNotifiedUpdateVersion,
setLastNotified: (version) => {
data.lastNotifiedUpdateVersion = version;
},
fetchManifest: async () => {
const response = await requestUrl({ url: UPDATE_MANIFEST_URL, throw: false });
return response.status === 200 ? response.json : null;
},
notify: (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;
try {
if (setting?.open && setting.openTabById) {
setting.open();
setting.openTabById("community-plugins");
return;
}
} catch (error) {
Log.debug("[UpdateCheck] Could not open Community plugins settings:", error);
}
window.open(COMMUNITY_PLUGIN_URL);
}

/**
* Open (or focus) the "What's new" tab with the `expanded` newest releases open:
* just the latest from the command and settings, every announced one after an update.
Expand Down
27 changes: 27 additions & 0 deletions src/stores/dataStore.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,9 @@ export const DEFAULT_SETTINGS: PluginData = {
lastActiveChatId: null,
onboardingComplete: false,
onboardingSplashSeen: false,
checkForUpdates: true,
lastUpdateCheckAt: null,
lastNotifiedUpdateVersion: null,
lastSeenVersion: null,
dismissedRecommendations: [],
thinkingProcessExpanded: true,
Expand Down Expand Up @@ -1236,6 +1239,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 lastSeenVersion() {
return this.#data.lastSeenVersion ?? null;
}
Expand Down
6 changes: 6 additions & 0 deletions src/types/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
* Last stable plugin version whose release notes were announced (or that was
* installed fresh). Null for data written before release notes existed.
Expand Down
99 changes: 99 additions & 0 deletions src/utils/updateCheck.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* "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 { Logger } from "./logging";
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<string, unknown>;
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;
}

/** What {@link runUpdateCheck} reads and does, injected so the flow is testable. */
export interface UpdateCheckDeps {
/** The user's "Check for updates" setting, read live (it can flip mid-request). */
isEnabled(): boolean;
/** Obsidian's own automatic plugin-update check is on, so this one steps aside. */
obsidianChecksUpdates(): boolean;
/** False once the plugin has unloaded; a late response must not act. */
isActive(): boolean;
now(): number;
currentVersion: string;
appVersion: string;
getLastCheckAt(): number | null;
setLastCheckAt(at: number): void;
getLastNotified(): string | null;
setLastNotified(version: string): void;
/** Parsed JSON of the remote manifest, or null on a non-200; may throw offline. */
fetchManifest(): Promise<unknown>;
notify(version: string): void;
}

/**
* One run of the update check: gate, fetch (recording the attempt first, so a
* failure also waits a day), and announce at most once per version. Returns the
* announced version, or null. Never throws: offline is normal.
*/
export async function runUpdateCheck(deps: UpdateCheckDeps): Promise<string | null> {
if (!deps.isEnabled() || deps.obsidianChecksUpdates()) return null;
if (!isUpdateCheckDue(deps.now(), deps.getLastCheckAt())) return null;
deps.setLastCheckAt(deps.now());
let remote: RemoteManifest | null;
try {
remote = parseRemoteManifest(await deps.fetchManifest());
} catch (error) {
Logger.debug("[UpdateCheck] Could not reach GitHub; next attempt in a day:", error);
return null;
}
if (!remote || !deps.isActive() || !deps.isEnabled()) return null;
const version = updateToAnnounce(deps.currentVersion, deps.appVersion, remote, deps.getLastNotified());
if (!version) return null;
deps.setLastNotified(version);
deps.notify(version);
return version;
}
11 changes: 11 additions & 0 deletions src/views/settings/GeneralSettings.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -63,6 +64,16 @@ function handleOpenProviderSetup() {

<Button onClick={() => privacyListModal.open()} buttonText="Manage" />
</SettingItem>

<SettingItem
name="Check for updates"
desc="Once a day, fetch this plugin's version number from GitHub and show a notice when a newer one is available. Nothing about you or your vault is sent. Skipped while Obsidian's own automatic plugin update check is on."
>
<Toggle
checked={pluginData.checkForUpdates}
onchange={(checked) => (pluginData.checkForUpdates = checked)}
/>
</SettingItem>
</SettingGroup>

<style>
Expand Down
3 changes: 3 additions & 0 deletions test/__mocks__/obsidian.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,9 @@ export interface RequestUrlParam {
throw?: boolean;
}

/** Obsidian's API version (the running app's version). */
export const apiVersion = "1.11.4";

export const requestUrl = vi.fn(async (_param: RequestUrlParam) => ({
status: 200,
headers: {} as Record<string, string>,
Expand Down
Loading
Loading