From b80e92f2db667c626aac9d265ab208c6cf2b290c Mon Sep 17 00:00:00 2001 From: Zenitsu <174907517+nayandas69@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:48:12 +0000 Subject: [PATCH] .fix(core): stabilize connection lifecycle and presence for v1.3.0 --- .github/workflows/publish.yml | 2 +- package.json | 2 +- src/activity-manager.ts | 24 ++++--- src/discord-client.ts | 126 ++++++++++++++++++++++++++-------- src/extension.ts | 8 +++ src/test/util.test.ts | 40 +++++++++++ src/util.ts | 33 +++++++-- 7 files changed, 193 insertions(+), 42 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4ccdfe6..cc5ab57 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,4 +1,4 @@ -name: Publis +name: Publish on: push: diff --git a/package.json b/package.json index 2d13c2c..00ba0c3 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "statuscode", "displayName": "StatusCode - Discord Rich Presence", "description": "Show your Visual Studio Code activity on Discord with beautiful Rich Presence", - "version": "1.2.0", + "version": "1.3.0", "publisher": "extrise", "author": { "name": "Extrise", diff --git a/src/activity-manager.ts b/src/activity-manager.ts index fa1951d..bda501e 100644 --- a/src/activity-manager.ts +++ b/src/activity-manager.ts @@ -79,14 +79,16 @@ export class ActivityManager { this.disposables.push( vscode.window.onDidChangeActiveTextEditor((editor) => { this.currentEditor = editor - this.schedulePresenceUpdate(true) + // Switching files is user activity: it must also leave the idle state, + // otherwise the idle timer re-triggers and overwrites the new presence + this.recordActivity(true) }), ) // Track text document changes (typing) - throttled by the update interval this.disposables.push( vscode.workspace.onDidChangeTextDocument(() => { - this.handleActivity() + this.recordActivity(false) }), ) @@ -94,14 +96,17 @@ export class ActivityManager { this.disposables.push( vscode.window.onDidChangeWindowState((state) => { if (state.focused) { - this.handleActivity() + this.recordActivity(false) } }), ) this.disposables.push( vscode.languages.onDidChangeDiagnostics(() => { - this.schedulePresenceUpdate(false) + // Diagnostics are not user activity; they must not wake the idle state + if (!this.idle) { + this.schedulePresenceUpdate(false) + } }), ) @@ -162,16 +167,15 @@ export class ActivityManager { * Record user activity and refresh the presence. * Returning from idle always updates immediately. */ - private handleActivity(): void { + private recordActivity(immediate: boolean): void { this.lastActivityTime = Date.now() if (this.idle) { this.idle = false - this.schedulePresenceUpdate(true) - return + immediate = true } - this.schedulePresenceUpdate(false) + this.schedulePresenceUpdate(immediate) } /** @@ -324,7 +328,9 @@ export class ActivityManager { * then send it to Discord */ private async updatePresence(): Promise { - if (!this.active || !this.discordClient.isConnected()) { + // While idle the "Idle / Away from keyboard" presence stands; a queued or + // racing update must not overwrite it + if (!this.active || this.idle || !this.discordClient.isConnected()) { return } diff --git a/src/discord-client.ts b/src/discord-client.ts index b595706..d5834fa 100644 --- a/src/discord-client.ts +++ b/src/discord-client.ts @@ -8,6 +8,7 @@ import * as DiscordRPC from "discord-rpc" import { CLIENT_ID, LARGE_IMAGE_KEY, LARGE_IMAGE_TEXT } from "./constants.js" +import { truncateDiscordText } from "./util.js" const MAX_RECONNECT_ATTEMPTS = 5 const BASE_RECONNECT_DELAY_MS = 1000 @@ -17,8 +18,18 @@ const MAX_RECONNECT_DELAY_MS = 30000 * Manages the Discord RPC connection and presence updates */ export class DiscordClient { - private readonly client: DiscordRPC.Client + /** + * The RPC client for the current connection. + * + * discord-rpc 4.0.1 memoizes its internal connect promise and never resets + * it after the transport closes, so a reused client can never log in again + * (it either returns the settled promise or resolves without connecting). + * A fresh client instance is therefore created for every connection attempt. + */ + private client: DiscordRPC.Client | undefined private connected = false + /** Promise for the login currently in flight, if any. */ + private connecting: Promise | undefined private reconnectAttempts = 0 /** Set while a disconnect was requested by the user; auto-reconnect then stays off. */ private manualDisconnect = false @@ -27,28 +38,31 @@ export class DiscordClient { private disconnectedCallback: (() => void) | undefined private errorCallback: ((error: unknown) => void) | undefined - constructor() { - this.client = new DiscordRPC.Client({ transport: "ipc" }) - this.setupEventHandlers() - } - /** - * Setup event handlers for the Discord client + * Create a fresh RPC client with its event handlers attached. + * Handlers only act on unexpected disconnects; intentional disconnects + * (manual disconnect, client replacement) must set `manualDisconnect` first. */ - private setupEventHandlers(): void { - // Handle successful connection - this.client.on("ready", () => { + private createClient(): DiscordRPC.Client { + const client = new DiscordRPC.Client({ transport: "ipc" }) + + client.on("ready", () => { this.connected = true this.reconnectAttempts = 0 this.readyCallback?.() }) - // Handle disconnection (e.g. Discord was closed or restarted) - this.client.on("disconnected", () => { + client.on("disconnected", () => { + if (this.manualDisconnect) { + return + } + this.connected = false this.disconnectedCallback?.() this.scheduleReconnect() }) + + return client } /** @@ -75,15 +89,46 @@ export class DiscordClient { /** * Connect to the Discord client running on the user's machine. * Uses IPC (Inter-Process Communication) - no authentication needed. + * Concurrent calls share the same login attempt. */ async connect(): Promise { if (this.connected) { return } - this.manualDisconnect = false + if (this.connecting) { + return this.connecting + } + + this.connecting = this.performConnect().finally(() => { + this.connecting = undefined + }) + + return this.connecting + } + + /** + * Run one connection attempt with a fresh RPC client + */ + private async performConnect(): Promise { + // Suppress late "disconnected" events from the client being discarded + this.manualDisconnect = true + await this.destroyCurrentClient() + + const client = this.createClient() + this.client = client + + try { + await client.login({ clientId: CLIENT_ID }) + } catch (error) { + // Restore auto-reconnect behavior, then discard the half-open client so + // its late events cannot corrupt the next attempt's state + this.manualDisconnect = false + await this.destroyCurrentClient() + throw error + } - await this.client.login({ clientId: CLIENT_ID }) + this.manualDisconnect = false this.connected = true this.reconnectAttempts = 0 } @@ -94,37 +139,42 @@ export class DiscordClient { async disconnect(): Promise { this.manualDisconnect = true this.cancelReconnect() - this.connected = false - try { - await this.client.destroy() - } catch { - // Discord may already be gone; there is nothing left to clean up + if (this.connecting) { + // Wait for the in-flight login so it cannot resurrect the connection + // after the disconnect completes + await this.connecting.catch(() => undefined) } + + this.connected = false + await this.destroyCurrentClient() } /** * Update Discord Rich Presence with activity data */ async setActivity(activity: DiscordRPC.Activity): Promise { - if (!this.connected) { + const client = this.client + + if (!this.connected || !client) { return } const payload: DiscordRPC.Activity = { - details: activity.details ?? "Coding in VS Code", - state: activity.state ?? "Working on a project", + // Discord rejects text fields longer than 128 characters + details: truncateDiscordText(activity.details ?? "Coding in VS Code"), + state: truncateDiscordText(activity.state ?? "Working on a project"), startTimestamp: activity.startTimestamp, largeImageKey: activity.largeImageKey ?? LARGE_IMAGE_KEY, - largeImageText: activity.largeImageText ?? LARGE_IMAGE_TEXT, + largeImageText: truncateDiscordText(activity.largeImageText ?? LARGE_IMAGE_TEXT), smallImageKey: activity.smallImageKey, - smallImageText: activity.smallImageText, + smallImageText: truncateDiscordText(activity.smallImageText), buttons: activity.buttons, instance: false, } try { - await this.client.setActivity(payload) + await client.setActivity(payload) } catch (error) { if (this.isConnectionError(error)) { this.connected = false @@ -139,12 +189,14 @@ export class DiscordClient { * Clear Discord Rich Presence */ async clearActivity(): Promise { - if (!this.connected) { + const client = this.client + + if (!this.connected || !client) { return } try { - await this.client.clearActivity() + await client.clearActivity() } catch (error) { if (this.isConnectionError(error)) { this.connected = false @@ -203,4 +255,24 @@ export class DiscordClient { this.reconnectTimer = undefined } } + + /** + * Discard the current RPC client, if any. Errors are swallowed because the + * transport may already be gone; a fresh client is created for the next + * connection attempt either way. + */ + private async destroyCurrentClient(): Promise { + const client = this.client + this.client = undefined + + if (!client) { + return + } + + try { + await client.destroy() + } catch { + // Discord may already be gone; there is nothing left to clean up + } + } } diff --git a/src/extension.ts b/src/extension.ts index ccfda80..be424b7 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -132,6 +132,7 @@ function startRetryLoop(): void { void startPresence().catch((error) => { log("Failed to connect to Discord, retrying...", error) + updateStatusBar("Retrying…", "$(sync~spin)") }) }, interval) } @@ -146,6 +147,7 @@ function scheduleAutoStart(): void { startupTimer = undefined void startPresence().catch((error) => { log("Initial connection to Discord failed, retrying in the background...", error) + updateStatusBar("Retrying…", "$(sync~spin)") startRetryLoop() }) }, STARTUP_DELAY_MS) @@ -169,6 +171,7 @@ async function handleConfigurationChange(): Promise { await startPresence() } catch (error) { log("Failed to connect after configuration change, retrying in the background...", error) + updateStatusBar("Retrying…", "$(sync~spin)") startRetryLoop() } return @@ -181,6 +184,11 @@ async function handleConfigurationChange(): Promise { } if (enabled && active) { + // A changed reconnect interval must restart the retry loop to take effect + stopRetryLoop() + if (!discordClient?.isConnected()) { + startRetryLoop() + } activityManager?.refresh() } } diff --git a/src/test/util.test.ts b/src/test/util.test.ts index d38e295..dee3f5c 100644 --- a/src/test/util.test.ts +++ b/src/test/util.test.ts @@ -13,6 +13,7 @@ import { describe, it } from "node:test" import { LANGUAGE_ICONS } from "../constants.js" import { + DISCORD_TEXT_LIMIT, extractRepoName, formatIssueText, formatTemplate, @@ -20,6 +21,7 @@ import { getLanguageIcon, normalizeNumber, toGitHubHttpsUrl, + truncateDiscordText, } from "../util.js" describe("formatTemplate", () => { @@ -80,6 +82,10 @@ describe("extractRepoName", () => { assert.equal(extractRepoName("git@github.com:extrise/statuscode"), "extrise/statuscode") }) + it("drops the .git suffix", () => { + assert.equal(extractRepoName("https://github.com/extrise/statuscode.git"), "extrise/statuscode") + }) + it("falls back when the URL is not GitHub", () => { assert.equal(extractRepoName("https://gitlab.com/group/project"), "GitHub Repository") }) @@ -100,6 +106,18 @@ describe("toGitHubHttpsUrl", () => { it("keeps HTTPS URLs and drops the .git suffix", () => { assert.equal(toGitHubHttpsUrl("https://github.com/extrise/statuscode.git"), "https://github.com/extrise/statuscode") }) + + it("normalizes git:// URLs", () => { + assert.equal(toGitHubHttpsUrl("git://github.com/extrise/statuscode.git"), "https://github.com/extrise/statuscode") + }) + + it("upgrades http:// URLs to https", () => { + assert.equal(toGitHubHttpsUrl("http://github.com/extrise/statuscode"), "https://github.com/extrise/statuscode") + }) + + it("strips trailing slashes", () => { + assert.equal(toGitHubHttpsUrl("https://github.com/extrise/statuscode/"), "https://github.com/extrise/statuscode") + }) }) describe("formatIssueText", () => { @@ -132,6 +150,28 @@ describe("getLanguageIcon", () => { }) }) +describe("truncateDiscordText", () => { + it("passes short text through unchanged", () => { + assert.equal(truncateDiscordText("Coding in VS Code"), "Coding in VS Code") + }) + + it("keeps undefined undefined", () => { + assert.equal(truncateDiscordText(undefined), undefined) + }) + + it("trims text at the Discord limit with an ellipsis", () => { + const long = "x".repeat(DISCORD_TEXT_LIMIT + 10) + const result = truncateDiscordText(long) + assert.equal(result?.length, DISCORD_TEXT_LIMIT) + assert.equal(result?.endsWith("…"), true) + }) + + it("keeps text exactly at the limit untouched", () => { + const exact = "y".repeat(DISCORD_TEXT_LIMIT) + assert.equal(truncateDiscordText(exact), exact) + }) +}) + describe("normalizeNumber", () => { it("falls back when the value is missing or NaN", () => { assert.equal(normalizeNumber(undefined, 5000, 1000), 5000) diff --git a/src/util.ts b/src/util.ts index 7ef3878..4b1fd35 100644 --- a/src/util.ts +++ b/src/util.ts @@ -42,15 +42,16 @@ export function getFileName(filePath: string): string { /** * Extract the "owner/repo" name from a GitHub URL, with a readable fallback. + * A trailing ".git" suffix is dropped so it never shows in the presence. */ export function extractRepoName(url: string): string { const match = url.match(/github\.com[/:]([\w.-]+\/[\w.-]+)/) - return match ? match[1] : "GitHub Repository" + return match ? match[1].replace(/\.git$/i, "") : "GitHub Repository" } /** - * Convert a GitHub remote URL (HTTPS, SSH or ssh:// form) into an HTTPS URL - * without the .git suffix, suitable for a clickable Discord button. + * Convert a GitHub remote URL (HTTPS, SSH, git:// or http:// form) into an + * HTTPS URL without the .git suffix, suitable for a clickable Discord button. */ export function toGitHubHttpsUrl(url: string): string { let httpsUrl = url @@ -59,9 +60,12 @@ export function toGitHubHttpsUrl(url: string): string { httpsUrl = `https://github.com/${url.slice("git@github.com:".length)}` } else if (url.startsWith("ssh://git@github.com/")) { httpsUrl = `https://github.com/${url.slice("ssh://git@github.com/".length)}` + } else { + httpsUrl = httpsUrl.replace(/^git:\/\/github\.com\//i, "https://github.com/") + httpsUrl = httpsUrl.replace(/^http:\/\/github\.com\//i, "https://github.com/") } - return httpsUrl.replace(/\.git$/, "") + return httpsUrl.replace(/\.git$/i, "").replace(/\/+$/, "") } /** @@ -101,6 +105,27 @@ export function getLanguageIcon(languageId: string): LanguageIcon { } } +/** + * Discord rejects rich presence text fields longer than this many characters. + */ +export const DISCORD_TEXT_LIMIT = 128 + +/** + * Trim a rich presence text field to Discord's length limit, or pass it + * through untouched. `undefined` stays `undefined`. + */ +export function truncateDiscordText(value: string | undefined): string | undefined { + if (value === undefined) { + return undefined + } + + if (value.length <= DISCORD_TEXT_LIMIT) { + return value + } + + return `${value.slice(0, DISCORD_TEXT_LIMIT - 1)}…` +} + /** * Coerce a numeric setting: fall back when missing/NaN, clamp to a minimum. */