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 .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Publis
name: Publish

on:
push:
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
24 changes: 15 additions & 9 deletions src/activity-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,29 +79,34 @@ 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)
}),
)

// Track when the editor gets focus
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)
}
}),
)

Expand Down Expand Up @@ -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)
}

/**
Expand Down Expand Up @@ -324,7 +328,9 @@ export class ActivityManager {
* then send it to Discord
*/
private async updatePresence(): Promise<void> {
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
}

Expand Down
126 changes: 99 additions & 27 deletions src/discord-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<void> | undefined
private reconnectAttempts = 0
/** Set while a disconnect was requested by the user; auto-reconnect then stays off. */
private manualDisconnect = false
Expand All @@ -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
}

/**
Expand All @@ -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<void> {
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<void> {
// 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
}
Expand All @@ -94,37 +139,42 @@ export class DiscordClient {
async disconnect(): Promise<void> {
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<void> {
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
Expand All @@ -139,12 +189,14 @@ export class DiscordClient {
* Clear Discord Rich Presence
*/
async clearActivity(): Promise<void> {
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
Expand Down Expand Up @@ -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<void> {
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
}
}
}
8 changes: 8 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ function startRetryLoop(): void {

void startPresence().catch((error) => {
log("Failed to connect to Discord, retrying...", error)
updateStatusBar("Retrying…", "$(sync~spin)")
})
}, interval)
}
Expand All @@ -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)
Expand All @@ -169,6 +171,7 @@ async function handleConfigurationChange(): Promise<void> {
await startPresence()
} catch (error) {
log("Failed to connect after configuration change, retrying in the background...", error)
updateStatusBar("Retrying…", "$(sync~spin)")
startRetryLoop()
}
return
Expand All @@ -181,6 +184,11 @@ async function handleConfigurationChange(): Promise<void> {
}

if (enabled && active) {
// A changed reconnect interval must restart the retry loop to take effect
stopRetryLoop()
if (!discordClient?.isConnected()) {
startRetryLoop()
}
activityManager?.refresh()
}
}
Expand Down
Loading
Loading