From 59b0c8b543924291513bd82e2da98ba46fc54ff9 Mon Sep 17 00:00:00 2001 From: squidfunk Date: Wed, 24 Jun 2026 17:22:10 +0200 Subject: [PATCH] fix: subsequent use shouldn't require an Internet connection Signed-off-by: squidfunk --- integrations/code/src/extension.ts | 131 ++++++++++++++++-- integrations/code/src/extension/client.ts | 2 +- integrations/code/src/extension/context.ts | 9 ++ .../code/src/extension/studio/fetch.ts | 47 +++++-- .../code/src/extension/studio/installer.ts | 29 ++-- 5 files changed, 185 insertions(+), 33 deletions(-) diff --git a/integrations/code/src/extension.ts b/integrations/code/src/extension.ts index 8e06e86..3aed610 100644 --- a/integrations/code/src/extension.ts +++ b/integrations/code/src/extension.ts @@ -20,6 +20,7 @@ * IN THE SOFTWARE. */ +import * as vscode from "vscode"; import type { ExtensionContext } from "vscode"; import type { LanguageClient } from "vscode-languageclient/node"; @@ -28,6 +29,7 @@ import { createLanguageClient } from "./extension/client"; import { Context } from "./extension/context"; import { activateProjectMarkdown } from "./extension/project"; import { getStudio } from "./extension/studio"; +import { NetworkError } from "./extension/studio/fetch"; /* ---------------------------------------------------------------------------- * State @@ -38,6 +40,21 @@ import { getStudio } from "./extension/studio"; */ let client: LanguageClient | undefined; +/** + * Startup timer. + */ +let retryTimer: ReturnType | undefined; + +/** + * Startup retry delay. + */ +let retryDelay = 5000; + +/** + * Whether startup is already in progress. + */ +let starting = false; + /* ---------------------------------------------------------------------------- * Functions * ------------------------------------------------------------------------- */ @@ -51,32 +68,120 @@ export async function activate(extension: ExtensionContext): Promise { const context = new Context(extension); void activateProjectMarkdown(extension, context); - // Obtain Zensical studio configuration - const studio = await getStudio(context); - if (typeof studio === "undefined") { - return; - } - // Register commands registerCommands(extension); + extension.subscriptions.push( + // The timer below is the primary recovery mechanism. These hooks only make + // retry more responsive when the user returns to the window or opens a + // Python Markdown document after VPN/proxy startup has completed. + vscode.window.onDidChangeWindowState((state) => { + if (state.focused && typeof retryTimer !== "undefined") { + void startStudio(context); + } + }), + vscode.workspace.onDidOpenTextDocument((document) => { + if ( + document.languageId === "python-markdown" && + typeof retryTimer !== "undefined" + ) { + void startStudio(context); + } + }), + ); + + // Start Zensical Studio + void startStudio(context); +} + +/** + * Deactivate extension. + */ +export async function deactivate(): Promise { + clearRetry(); + if (typeof client !== "undefined") { + await client.stop(); + client = undefined; + } +} + +/* ---------------------------------------------------------------------------- + * Helper functions + * ------------------------------------------------------------------------- */ + +/** + * Start Zensical Studio. + * + * @param context - Context + */ +async function startStudio(context: Context): Promise { + if (typeof client !== "undefined" || starting) { + return; + } - // Create and start the language client - context.log("Starting Zensical Studio"); + // Clear any scheduled retry + clearRetry(); + starting = true; try { + // Obtain Zensical studio configuration + const studio = await getStudio(context); + if (typeof studio === "undefined") { + retryDelay = 5000; + return; + } + + // Create and start the language client + retryDelay = 5000; + context.log("Starting Zensical Studio"); client = createLanguageClient(context, studio); client.start(); } catch (error) { + if (error instanceof NetworkError) { + scheduleRetry(context); + return; + } + + // Log the error const message = error instanceof Error ? error.message : String(error); context.log(`Failed to start Zensical Studio: ${message}`); + } finally { + starting = false; } } /** - * Deactivate extension. + * Schedule startup retry. + * + * @param context - Context */ -export async function deactivate(): Promise { - if (typeof client !== "undefined") { - await client.stop(); - client = undefined; +function scheduleRetry(context: Context): void { + const delay = retryDelay; + const seconds = Math.round(delay / 1000); + context.log(`Network unavailable; retrying in ${seconds}s`); + + // Schedule retry with exponential backoff and jitter + retryTimer = setTimeout(() => { + void startStudio(context); + }, jitter(delay)); + retryDelay = Math.min(delay * 2, 5 * 60 * 1000); +} + +/** + * Clear scheduled startup retry. + */ +function clearRetry(): void { + if (typeof retryTimer !== "undefined") { + clearTimeout(retryTimer); + retryTimer = undefined; } } + +/** + * Add jitter to a retry delay. + * + * @param delay - Delay in milliseconds + * + * @returns Jittered delay + */ +function jitter(delay: number): number { + return Math.round(delay * (0.8 + Math.random() * 0.4)); +} diff --git a/integrations/code/src/extension/client.ts b/integrations/code/src/extension/client.ts index 99b1828..a6830d1 100644 --- a/integrations/code/src/extension/client.ts +++ b/integrations/code/src/extension/client.ts @@ -60,7 +60,7 @@ export function createLanguageClient( // Initialize client options const clientOptions: LanguageClientOptions = { documentSelector: [{ scheme: "file", language: "python-markdown" }], - outputChannel: context.output, + outputChannel: context.getOutput(), initializationOptions: { token: studio.token }, }; diff --git a/integrations/code/src/extension/context.ts b/integrations/code/src/extension/context.ts index f553337..04b9bf4 100644 --- a/integrations/code/src/extension/context.ts +++ b/integrations/code/src/extension/context.ts @@ -76,6 +76,15 @@ export class Context { return this.context.globalStorageUri.fsPath; } + /** + * Get output channel. + * + * @returns Output channel + */ + public getOutput(): OutputChannel { + return this.output; + } + /** * Get extension state for a given key. * diff --git a/integrations/code/src/extension/studio/fetch.ts b/integrations/code/src/extension/studio/fetch.ts index f31f905..aedb0a9 100644 --- a/integrations/code/src/extension/studio/fetch.ts +++ b/integrations/code/src/extension/studio/fetch.ts @@ -59,6 +59,25 @@ export interface Message { confirm: boolean; } +/* ---------------------------------------------------------------------------- + * Class + * ------------------------------------------------------------------------- */ + +/** + * Retryable network failure. + */ +export class NetworkError extends Error { + /** + * Create error. + * + * @param message - Message + */ + public constructor(message: string) { + super(message); + this.name = "NetworkError"; + } +} + /* ---------------------------------------------------------------------------- * Functions * ------------------------------------------------------------------------- */ @@ -131,16 +150,24 @@ export async function fetchToken( token?: string, ): Promise { context.log("Renewing token for beta access"); - - // Fetch a new token const url = "https://get.zensical.org/studio/token/"; - const res = await request(context, url, { - ...(token && { headers: { authorization: `Bearer ${token}` } }), - }); - if (typeof res !== "undefined") { - return (await res.json()) as Token; - } else { - return; + try { + const res = await request(context, url, { + ...(token && { headers: { authorization: `Bearer ${token}` } }), + }); + + // Return the new token if available + if (typeof res !== "undefined") { + return (await res.json()) as Token; + } else { + return; + } + } catch (error) { + if (typeof token !== "undefined" && error instanceof NetworkError) { + context.log("Using cached token"); + return { token }; + } + throw error; } } @@ -181,7 +208,7 @@ async function request( } catch (error) { const reason = error instanceof Error ? error.message : String(error); context.log(`Fetching failed: ${reason}`); - return; + throw new NetworkError(reason); } } diff --git a/integrations/code/src/extension/studio/installer.ts b/integrations/code/src/extension/studio/installer.ts index dbe53c0..3ceddf3 100644 --- a/integrations/code/src/extension/studio/installer.ts +++ b/integrations/code/src/extension/studio/installer.ts @@ -28,7 +28,7 @@ import { coerce, satisfies, validRange } from "semver"; import type { Context } from "../context"; import { extract } from "./archive"; -import { fetchArchive, fetchRelease, Release } from "./fetch"; +import { fetchArchive, fetchRelease, NetworkError, Release } from "./fetch"; /* ---------------------------------------------------------------------------- * Functions @@ -46,14 +46,6 @@ export async function getStudioPathFromInstallation( ): Promise { let archive = ""; try { - const release = await fetchRelease(context); - if (typeof release === "undefined") { - return; - } - if (!(await checkRelease(context, release))) { - return; - } - // Determine extension storage and ensure it exists const storage = path.join(context.getStorage(), "studio"); await fs.mkdir(storage, { recursive: true }); @@ -66,6 +58,25 @@ export async function getStudioPathFromInstallation( : "zensical-studio", ); + // Try to fetch the latest release + let release: Release | undefined; + try { + release = await fetchRelease(context); + } catch (error) { + if (!existsSync(executable) || !(error instanceof NetworkError)) { + throw error; + } + context.log("Using installed Zensical Studio"); + } + + // If we don't have a release, just return the installed executable + if (typeof release === "undefined") { + return existsSync(executable) ? executable : undefined; + } + if (!(await checkRelease(context, release))) { + return; + } + // Check if we already have the latest version available const version = context.getState("version"); if (existsSync(executable) && version === release.version) {