diff --git a/apps/desktop-client/src/main/runtime-controller.ts b/apps/desktop-client/src/main/runtime-controller.ts index 8ce6bf2..88d4236 100644 --- a/apps/desktop-client/src/main/runtime-controller.ts +++ b/apps/desktop-client/src/main/runtime-controller.ts @@ -203,6 +203,7 @@ export interface RuntimeControllerSnapshot { lastPullStatus: 'idle' | 'success' | 'failed' lastAccountsSyncAt: number lastServerTime: string | null + onlineClientCount: number | null lastError: string | null } @@ -251,6 +252,7 @@ interface RuntimeState { lastPullStatus: 'idle' | 'success' | 'failed' lastAccountsSyncAt: number lastServerTime: string | null + onlineClientCount: number | null lastError: string | null activitySeq: number } @@ -280,6 +282,7 @@ const state: RuntimeState = { lastPullStatus: 'idle', lastAccountsSyncAt: 0, lastServerTime: null, + onlineClientCount: null, lastError: null, activitySeq: 0, } @@ -532,6 +535,7 @@ export function getRuntimeControllerSnapshot(): RuntimeControllerSnapshot { lastPullStatus: state.lastPullStatus, lastAccountsSyncAt: state.lastAccountsSyncAt, lastServerTime: state.lastServerTime, + onlineClientCount: state.onlineClientCount, lastError: state.lastError, } } @@ -627,6 +631,7 @@ function clearRuntimeState(): void { state.lastPullStatus = 'idle' state.lastAccountsSyncAt = 0 state.lastServerTime = null + state.onlineClientCount = null state.lastError = null state.publishFallbackBackoffUntil = 0 setSchedulerQueueDepth(localQueueDepth()) @@ -977,6 +982,8 @@ async function sendHeartbeat(source: 'startup' | 'timer'): Promise { state.lastHeartbeatAt = Date.now() state.lastHeartbeatStatus = 'success' state.lastServerTime = response.server_time + state.onlineClientCount = + response.current_user_online_client_count ?? response.online_client_count ?? null state.lastError = null noteTransportHeartbeat(true) diff --git a/apps/desktop-client/src/main/runtime-snapshot.ts b/apps/desktop-client/src/main/runtime-snapshot.ts index d4321da..c289f68 100644 --- a/apps/desktop-client/src/main/runtime-snapshot.ts +++ b/apps/desktop-client/src/main/runtime-snapshot.ts @@ -144,6 +144,10 @@ function createLiveRuntimeSnapshot(controller: RuntimeControllerSnapshot) { channel: controller.client?.channel ?? 'dev', }, ] as const + const onlineClientCount = + typeof controller.onlineClientCount === 'number' + ? Math.max(0, controller.onlineClientCount) + : clients.filter((item) => item.status === 'online').length const accounts = controller.accounts.map((account) => createRuntimeAccountView(controller, account, { @@ -185,7 +189,7 @@ function createLiveRuntimeSnapshot(controller: RuntimeControllerSnapshot) { lastFullSyncAt: controller.lastAccountsSyncAt || now, }, summary: { - onlineClients: clients.filter((item) => item.status === 'online').length, + onlineClients: onlineClientCount, accountsBound: accounts.length, queuedTasks: tasks.filter((item) => item.status === 'queued').length, issuesOpen: diff --git a/apps/desktop-client/src/renderer/views/SettingsView.vue b/apps/desktop-client/src/renderer/views/SettingsView.vue index 3d5729d..1bf03e1 100644 --- a/apps/desktop-client/src/renderer/views/SettingsView.vue +++ b/apps/desktop-client/src/renderer/views/SettingsView.vue @@ -53,7 +53,7 @@ const subsystemEntries = computed(() => })), ) -const clientCount = computed(() => snapshot.value?.clients.length ?? 0) +const clientCount = computed(() => snapshot.value?.summary.onlineClients ?? 0) const appVersion = computed(() => snapshot.value?.app.version ?? '0.1.0') const hasServerSettingChanged = computed( () => normalizeServerURLInput(serverBaseURL.value) !== apiBaseURL.value, diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index b8fe065..5a2c1e1 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -115,6 +115,8 @@ export interface DesktopClientHeartbeatRequest { export interface DesktopClientHeartbeatResponse { client: DesktopClientInfo server_time: string + online_client_count?: number + current_user_online_client_count?: number } export interface DesktopClientRotateResponse { diff --git a/server/internal/tenant/app/desktop_client_service.go b/server/internal/tenant/app/desktop_client_service.go index 4326610..70de3c5 100644 --- a/server/internal/tenant/app/desktop_client_service.go +++ b/server/internal/tenant/app/desktop_client_service.go @@ -84,8 +84,10 @@ type RotateDesktopClientResponse struct { } type HeartbeatDesktopClientResponse struct { - Client DesktopClientView `json:"client"` - ServerTime time.Time `json:"server_time"` + Client DesktopClientView `json:"client"` + ServerTime time.Time `json:"server_time"` + OnlineClientCount int `json:"online_client_count"` + CurrentUserOnlineClientCount int `json:"current_user_online_client_count"` } type OfflineDesktopClientResponse struct { @@ -128,7 +130,7 @@ func (s *DesktopClientService) Register(ctx context.Context, actor auth.Actor, r return nil, response.ErrInternal(50072, "desktop_client_register_failed", "failed to persist desktop client") } - markDesktopClientPresent(ctx, s.redis, client.ID) + markDesktopClientPresentForUser(ctx, s.redis, client.ID, client.TenantID, client.WorkspaceID, client.UserID) return &RegisterDesktopClientResponse{ ClientID: client.ID.String(), @@ -160,7 +162,7 @@ func (s *DesktopClientService) Rotate(ctx context.Context, client *repository.De return nil, response.ErrInternal(50074, "desktop_client_rotate_failed", "failed to rotate desktop client token") } - markDesktopClientPresent(ctx, s.redis, updated.ID) + markDesktopClientPresentForUser(ctx, s.redis, updated.ID, updated.TenantID, updated.WorkspaceID, updated.UserID) return &RotateDesktopClientResponse{ ClientToken: rawToken, @@ -190,8 +192,12 @@ func (s *DesktopClientService) Heartbeat(ctx context.Context, client *repository return nil, response.ErrInternal(50075, "desktop_client_heartbeat_failed", "failed to persist desktop client heartbeat") } - markDesktopClientPresent(ctx, s.redis, updated.ID) + markDesktopClientPresentForUser(ctx, s.redis, updated.ID, updated.TenantID, updated.WorkspaceID, updated.UserID) markDesktopAccountsPresent(ctx, s.redis, updated.ID, parseDesktopAccountIDs(req.AccountIDs)) + onlineClientCount, err := s.currentUserOnlineClientCount(ctx, updated) + if err != nil { + return nil, err + } if req.Startup && s.taskSvc != nil { if err := s.taskSvc.recoverClientTasksOnStartup(ctx, updated); err != nil { @@ -200,8 +206,10 @@ func (s *DesktopClientService) Heartbeat(ctx context.Context, client *repository } return &HeartbeatDesktopClientResponse{ - Client: buildDesktopClientView(updated), - ServerTime: time.Now().UTC(), + Client: buildDesktopClientView(updated), + ServerTime: time.Now().UTC(), + OnlineClientCount: onlineClientCount, + CurrentUserOnlineClientCount: onlineClientCount, }, nil } @@ -218,7 +226,7 @@ func (s *DesktopClientService) Revoke(ctx context.Context, client *repository.De return nil, response.ErrInternal(50076, "desktop_client_revoke_failed", "failed to revoke desktop client") } - clearDesktopClientPresenceState(ctx, s.redis, updated.ID) + clearDesktopClientPresenceStateForUser(ctx, s.redis, updated.ID, updated.TenantID, updated.WorkspaceID, updated.UserID) if s.taskSvc != nil { if err := s.taskSvc.recoverClientTasksOnDisconnect(ctx, updated); err != nil { return nil, err @@ -234,7 +242,7 @@ func (s *DesktopClientService) Offline(ctx context.Context, client *repository.D return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required") } - clearDesktopClientPresenceState(ctx, s.redis, client.ID) + clearDesktopClientPresenceStateForUser(ctx, s.redis, client.ID, client.TenantID, client.WorkspaceID, client.UserID) if s.taskSvc != nil { if err := s.taskSvc.recoverClientTasksOnDisconnect(ctx, client); err != nil { return nil, err @@ -246,6 +254,45 @@ func (s *DesktopClientService) Offline(ctx context.Context, client *repository.D }, nil } +func (s *DesktopClientService) currentUserOnlineClientCount(ctx context.Context, client *repository.DesktopClient) (int, error) { + if count, ok := loadDesktopUserOnlineClientCount(ctx, s.redis, client.TenantID, client.WorkspaceID, client.UserID, time.Now().UTC()); ok { + return count, nil + } + + clients, err := s.repo.ListByUser(ctx, client.TenantID, client.WorkspaceID, client.UserID) + if err != nil { + return 0, response.ErrInternal(50077, "desktop_client_online_count_failed", "failed to inspect online desktop clients") + } + + return countOnlineDesktopClients(ctx, s.redis, clients, time.Now().UTC()), nil +} + +func countOnlineDesktopClients(ctx context.Context, redis *goredis.Client, clients []repository.DesktopClient, now time.Time) int { + if len(clients) == 0 { + return 0 + } + + clientIDs := make([]uuid.UUID, 0, len(clients)) + for _, client := range clients { + if client.ID == uuid.Nil || client.RevokedAt != nil { + continue + } + clientIDs = append(clientIDs, client.ID) + } + presence := loadDesktopClientPresence(ctx, redis, clientIDs) + + onlineCount := 0 + for _, client := range clients { + if client.ID == uuid.Nil || client.RevokedAt != nil { + continue + } + if resolveDesktopClientOnline(&client, presence[client.ID], presence != nil, now) { + onlineCount++ + } + } + return onlineCount +} + func newDesktopClientToken() (string, []byte, error) { raw := make([]byte, 32) if _, err := rand.Read(raw); err != nil { diff --git a/server/internal/tenant/app/desktop_presence.go b/server/internal/tenant/app/desktop_presence.go index 8f7e46f..f5eb8c3 100644 --- a/server/internal/tenant/app/desktop_presence.go +++ b/server/internal/tenant/app/desktop_presence.go @@ -27,6 +27,13 @@ func desktopClientAccountsPresenceKey(clientID uuid.UUID) string { return "desktop:presence:client_accounts:" + clientID.String() } +func desktopUserClientsPresenceKey(tenantID, workspaceID, userID int64) string { + return "desktop:presence:user_clients:" + + strconv.FormatInt(tenantID, 10) + ":" + + strconv.FormatInt(workspaceID, 10) + ":" + + strconv.FormatInt(userID, 10) +} + func markDesktopClientPresent(ctx context.Context, redis *goredis.Client, clientID uuid.UUID) { if redis == nil { return @@ -35,6 +42,25 @@ func markDesktopClientPresent(ctx context.Context, redis *goredis.Client, client _ = redis.Set(ctx, desktopClientPresenceKey(clientID), time.Now().UTC().Format(time.RFC3339), desktopClientPresenceTTL).Err() } +func markDesktopClientPresentForUser(ctx context.Context, redis *goredis.Client, clientID uuid.UUID, tenantID, workspaceID, userID int64) { + markDesktopClientPresent(ctx, redis, clientID) + if redis == nil || clientID == uuid.Nil || tenantID == 0 || workspaceID == 0 || userID == 0 { + return + } + + now := time.Now().UTC() + staleBefore := strconv.FormatInt(now.Add(-desktopClientPresenceTTL).UnixMilli(), 10) + key := desktopUserClientsPresenceKey(tenantID, workspaceID, userID) + pipe := redis.Pipeline() + pipe.ZRemRangeByScore(ctx, key, "-inf", staleBefore) + pipe.ZAdd(ctx, key, goredis.Z{ + Score: float64(now.UnixMilli()), + Member: clientID.String(), + }) + pipe.Expire(ctx, key, desktopClientPresenceTTL*4) + _, _ = pipe.Exec(ctx) +} + func clearDesktopClientPresence(ctx context.Context, redis *goredis.Client, clientID uuid.UUID) { if redis == nil { return @@ -43,6 +69,20 @@ func clearDesktopClientPresence(ctx context.Context, redis *goredis.Client, clie _ = redis.Del(ctx, desktopClientPresenceKey(clientID)).Err() } +func clearDesktopClientPresenceStateForUser(ctx context.Context, redis *goredis.Client, clientID uuid.UUID, tenantID, workspaceID, userID int64) { + clearDesktopClientPresenceState(ctx, redis, clientID) + if redis == nil || clientID == uuid.Nil || tenantID == 0 || workspaceID == 0 || userID == 0 { + return + } + + staleBefore := strconv.FormatInt(time.Now().UTC().Add(-desktopClientPresenceTTL).UnixMilli(), 10) + key := desktopUserClientsPresenceKey(tenantID, workspaceID, userID) + pipe := redis.Pipeline() + pipe.ZRem(ctx, key, clientID.String()) + pipe.ZRemRangeByScore(ctx, key, "-inf", staleBefore) + _, _ = pipe.Exec(ctx) +} + func loadDesktopClientPresence(ctx context.Context, redis *goredis.Client, clientIDs []uuid.UUID) map[uuid.UUID]bool { if redis == nil || len(clientIDs) == 0 { return nil @@ -75,6 +115,22 @@ func loadDesktopClientPresence(ctx context.Context, redis *goredis.Client, clien return result } +func loadDesktopUserOnlineClientCount(ctx context.Context, redis *goredis.Client, tenantID, workspaceID, userID int64, now time.Time) (int, bool) { + if redis == nil || tenantID == 0 || workspaceID == 0 || userID == 0 { + return 0, false + } + + staleBefore := strconv.FormatInt(now.UTC().Add(-desktopClientPresenceTTL).UnixMilli(), 10) + key := desktopUserClientsPresenceKey(tenantID, workspaceID, userID) + pipe := redis.Pipeline() + pipe.ZRemRangeByScore(ctx, key, "-inf", staleBefore) + countCmd := pipe.ZCard(ctx, key) + if _, err := pipe.Exec(ctx); err != nil && err != goredis.Nil { + return 0, false + } + return int(countCmd.Val()), true +} + func markDesktopAccountsPresent(ctx context.Context, redis *goredis.Client, clientID uuid.UUID, accountIDs []uuid.UUID) { if redis == nil { return diff --git a/server/internal/tenant/app/desktop_presence_test.go b/server/internal/tenant/app/desktop_presence_test.go new file mode 100644 index 0000000..f1cee7f --- /dev/null +++ b/server/internal/tenant/app/desktop_presence_test.go @@ -0,0 +1,86 @@ +package app + +import ( + "context" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/google/uuid" + goredis "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/geo-platform/tenant-api/internal/tenant/repository" +) + +func TestDesktopUserOnlineClientCountPrefersRedisPresenceIndex(t *testing.T) { + ctx := context.Background() + server := miniredis.RunT(t) + redis := goredis.NewClient(&goredis.Options{Addr: server.Addr()}) + t.Cleanup(func() { + require.NoError(t, redis.Close()) + }) + + tenantID := int64(7) + workspaceID := int64(11) + userID := int64(23) + firstClientID := uuid.New() + secondClientID := uuid.New() + + markDesktopClientPresentForUser(ctx, redis, firstClientID, tenantID, workspaceID, userID) + markDesktopClientPresentForUser(ctx, redis, secondClientID, tenantID, workspaceID, userID) + + count, ok := loadDesktopUserOnlineClientCount(ctx, redis, tenantID, workspaceID, userID, time.Now().UTC()) + + require.True(t, ok) + assert.Equal(t, 2, count) +} + +func TestDesktopClientServiceOnlineCountReturnsRedisCountBeforeRepoFallback(t *testing.T) { + ctx := context.Background() + server := miniredis.RunT(t) + redis := goredis.NewClient(&goredis.Options{Addr: server.Addr()}) + t.Cleanup(func() { + require.NoError(t, redis.Close()) + }) + + client := &repository.DesktopClient{ + ID: uuid.New(), + TenantID: 7, + WorkspaceID: 11, + UserID: 23, + } + markDesktopClientPresentForUser(ctx, redis, client.ID, client.TenantID, client.WorkspaceID, client.UserID) + markDesktopClientPresentForUser(ctx, redis, uuid.New(), client.TenantID, client.WorkspaceID, client.UserID) + + count, err := (&DesktopClientService{redis: redis}).currentUserOnlineClientCount(ctx, client) + + require.NoError(t, err) + assert.Equal(t, 2, count) +} + +func TestDesktopUserOnlineClientCountPrunesStaleRedisPresence(t *testing.T) { + ctx := context.Background() + server := miniredis.RunT(t) + redis := goredis.NewClient(&goredis.Options{Addr: server.Addr()}) + t.Cleanup(func() { + require.NoError(t, redis.Close()) + }) + + tenantID := int64(7) + workspaceID := int64(11) + userID := int64(23) + key := desktopUserClientsPresenceKey(tenantID, workspaceID, userID) + now := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC) + + require.NoError(t, redis.ZAdd(ctx, key, + goredis.Z{Score: float64(now.Add(-2 * desktopClientPresenceTTL).UnixMilli()), Member: uuid.NewString()}, + goredis.Z{Score: float64(now.Add(-time.Second).UnixMilli()), Member: uuid.NewString()}, + ).Err()) + + count, ok := loadDesktopUserOnlineClientCount(ctx, redis, tenantID, workspaceID, userID, now) + + require.True(t, ok) + assert.Equal(t, 1, count) +} diff --git a/server/internal/tenant/repository/desktop_client_repo.go b/server/internal/tenant/repository/desktop_client_repo.go index fa5c803..c681642 100644 --- a/server/internal/tenant/repository/desktop_client_repo.go +++ b/server/internal/tenant/repository/desktop_client_repo.go @@ -63,6 +63,7 @@ type DesktopClientRepository interface { Heartbeat(ctx context.Context, params HeartbeatDesktopClientParams) (*DesktopClient, error) Revoke(ctx context.Context, id uuid.UUID, workspaceID int64) (*DesktopClient, error) ListByWorkspace(ctx context.Context, workspaceID int64) ([]DesktopClient, error) + ListByUser(ctx context.Context, tenantID, workspaceID, userID int64) ([]DesktopClient, error) } type desktopClientRepository struct { @@ -156,6 +157,23 @@ func (r *desktopClientRepository) ListByWorkspace(ctx context.Context, workspace return nil, err } + return desktopClientsFromGenerated(rows), nil +} + +func (r *desktopClientRepository) ListByUser(ctx context.Context, tenantID, workspaceID, userID int64) ([]DesktopClient, error) { + rows, err := r.q.ListDesktopClientsByUser(ctx, generated.ListDesktopClientsByUserParams{ + TenantID: tenantID, + WorkspaceID: workspaceID, + UserID: userID, + }) + if err != nil { + return nil, err + } + + return desktopClientsFromGenerated(rows), nil +} + +func desktopClientsFromGenerated(rows []generated.DesktopClient) []DesktopClient { items := make([]DesktopClient, 0, len(rows)) for _, row := range rows { item := desktopClientFromGenerated(row) @@ -163,7 +181,7 @@ func (r *desktopClientRepository) ListByWorkspace(ctx context.Context, workspace items = append(items, *item) } } - return items, nil + return items } func desktopClientFromGenerated(row generated.DesktopClient) *DesktopClient { diff --git a/server/internal/tenant/repository/generated/desktop_client.sql.go b/server/internal/tenant/repository/generated/desktop_client.sql.go index b791665..b44801b 100644 --- a/server/internal/tenant/repository/generated/desktop_client.sql.go +++ b/server/internal/tenant/repository/generated/desktop_client.sql.go @@ -131,6 +131,57 @@ func (q *Queries) HeartbeatDesktopClient(ctx context.Context, arg HeartbeatDeskt return i, err } +const listDesktopClientsByUser = `-- name: ListDesktopClientsByUser :many +SELECT id, tenant_id, workspace_id, user_id, token_hash, device_name, os, cpu_arch, client_version, channel, created_at, last_seen_at, last_rotated_at, revoked_at +FROM desktop_clients +WHERE tenant_id = $1 + AND workspace_id = $2 + AND user_id = $3 + AND revoked_at IS NULL +ORDER BY last_seen_at DESC NULLS LAST, created_at DESC +` + +type ListDesktopClientsByUserParams struct { + TenantID int64 `json:"tenant_id"` + WorkspaceID int64 `json:"workspace_id"` + UserID int64 `json:"user_id"` +} + +func (q *Queries) ListDesktopClientsByUser(ctx context.Context, arg ListDesktopClientsByUserParams) ([]DesktopClient, error) { + rows, err := q.db.Query(ctx, listDesktopClientsByUser, arg.TenantID, arg.WorkspaceID, arg.UserID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []DesktopClient + for rows.Next() { + var i DesktopClient + if err := rows.Scan( + &i.ID, + &i.TenantID, + &i.WorkspaceID, + &i.UserID, + &i.TokenHash, + &i.DeviceName, + &i.Os, + &i.CpuArch, + &i.ClientVersion, + &i.Channel, + &i.CreatedAt, + &i.LastSeenAt, + &i.LastRotatedAt, + &i.RevokedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listDesktopClientsByWorkspace = `-- name: ListDesktopClientsByWorkspace :many SELECT id, tenant_id, workspace_id, user_id, token_hash, device_name, os, cpu_arch, client_version, channel, created_at, last_seen_at, last_rotated_at, revoked_at FROM desktop_clients diff --git a/server/internal/tenant/repository/generated/querier.go b/server/internal/tenant/repository/generated/querier.go index 9e27e6b..d9d349d 100644 --- a/server/internal/tenant/repository/generated/querier.go +++ b/server/internal/tenant/repository/generated/querier.go @@ -117,6 +117,7 @@ type Querier interface { ListCompetitors(ctx context.Context, arg ListCompetitorsParams) ([]ListCompetitorsRow, error) ListDesktopAccountsByClient(ctx context.Context, arg ListDesktopAccountsByClientParams) ([]ListDesktopAccountsByClientRow, error) ListDesktopAccountsByUser(ctx context.Context, arg ListDesktopAccountsByUserParams) ([]ListDesktopAccountsByUserRow, error) + ListDesktopClientsByUser(ctx context.Context, arg ListDesktopClientsByUserParams) ([]DesktopClient, error) ListDesktopClientsByWorkspace(ctx context.Context, workspaceID int64) ([]DesktopClient, error) ListImageAssets(ctx context.Context, arg ListImageAssetsParams) ([]ListImageAssetsRow, error) ListImageFolders(ctx context.Context, tenantID int64) ([]ListImageFoldersRow, error) diff --git a/server/internal/tenant/repository/queries/desktop_client.sql b/server/internal/tenant/repository/queries/desktop_client.sql index 3a14dc2..03e20d8 100644 --- a/server/internal/tenant/repository/queries/desktop_client.sql +++ b/server/internal/tenant/repository/queries/desktop_client.sql @@ -89,3 +89,12 @@ FROM desktop_clients WHERE workspace_id = sqlc.arg(workspace_id) AND revoked_at IS NULL ORDER BY last_seen_at DESC NULLS LAST, created_at DESC; + +-- name: ListDesktopClientsByUser :many +SELECT * +FROM desktop_clients +WHERE tenant_id = sqlc.arg(tenant_id) + AND workspace_id = sqlc.arg(workspace_id) + AND user_id = sqlc.arg(user_id) + AND revoked_at IS NULL +ORDER BY last_seen_at DESC NULLS LAST, created_at DESC; diff --git a/server/internal/tenant/transport/desktop_client_auth_test.go b/server/internal/tenant/transport/desktop_client_auth_test.go index 7ea31b5..81de585 100644 --- a/server/internal/tenant/transport/desktop_client_auth_test.go +++ b/server/internal/tenant/transport/desktop_client_auth_test.go @@ -49,6 +49,10 @@ func (s *stubDesktopClientRepo) ListByWorkspace(ctx context.Context, workspaceID panic("unexpected call") } +func (s *stubDesktopClientRepo) ListByUser(ctx context.Context, tenantID, workspaceID, userID int64) ([]repository.DesktopClient, error) { + panic("unexpected call") +} + func TestDesktopClientMiddleware_RequiresBearerToken(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New()