From cb1d52e5d0a5502062a8a9c89246bc8e1f7eaceb Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 9 Sep 2026 19:14:34 +1000 Subject: [PATCH] fix: start Slack sessions in the desktop app --- backend/internal/handlers/slackHandlers.go | 25 +- .../internal/handlers/slackHandlers_test.go | 63 +++++ tauri/src/lib/deepLinkUtils.test.mjs | 256 ++++++++++++++++++ tauri/src/lib/deepLinkUtils.ts | 74 ++++- 4 files changed, 407 insertions(+), 11 deletions(-) create mode 100644 backend/internal/handlers/slackHandlers_test.go create mode 100644 tauri/src/lib/deepLinkUtils.test.mjs diff --git a/backend/internal/handlers/slackHandlers.go b/backend/internal/handlers/slackHandlers.go index ab650038..e3a86818 100644 --- a/backend/internal/handlers/slackHandlers.go +++ b/backend/internal/handlers/slackHandlers.go @@ -693,16 +693,33 @@ func addParticipantToSlackCall(botToken, callID string, user *models.User) error return api.CallAddParticipants(callID, []slack.CallParticipant{participant}) } +// buildDesktopJoinURL builds the hopp:// deep link that opens a session directly +// in the desktop app. Slack prefers this URL over the HTTPS join URL when the +// desktop app is installed, so users land in the app instead of the browser. +// The session ID is query-encoded, and the desktop app parses this shape in +// `processDeepLinkUrl` (tauri/src/lib/deepLinkUtils.ts). +func buildDesktopJoinURL(sessionID string) string { + deepLink := url.URL{ + Scheme: "hopp", + Path: "/join-session", + RawQuery: url.Values{"sessionId": []string{sessionID}}.Encode(), + } + return deepLink.String() +} + // createSlackCall creates a call using Slack's Calls API for native call UI func (h *SlackHandler) createSlackCall(botToken, externalID, createdBySlackUserID, creatorName, joinURL, channelID string) (*SlackCall, error) { api := newSlackClient(botToken) // Create the call using the SDK + // DesktopAppJoinURL sends users to the desktop app, while JoinURL stays as the + // HTTPS fallback for anyone without the app installed. call, err := api.AddCall(slack.AddCallParameters{ - JoinURL: joinURL, - ExternalUniqueID: externalID, - CreatedBy: createdBySlackUserID, - Title: fmt.Sprintf("%s started a Hopp pairing session", creatorName), + JoinURL: joinURL, + DesktopAppJoinURL: buildDesktopJoinURL(externalID), + ExternalUniqueID: externalID, + CreatedBy: createdBySlackUserID, + Title: fmt.Sprintf("%s started a Hopp pairing session", creatorName), }) if err != nil { return nil, fmt.Errorf("calls.add error: %w", err) diff --git a/backend/internal/handlers/slackHandlers_test.go b/backend/internal/handlers/slackHandlers_test.go new file mode 100644 index 00000000..c2c25dd9 --- /dev/null +++ b/backend/internal/handlers/slackHandlers_test.go @@ -0,0 +1,63 @@ +package handlers + +import ( + "net/url" + "testing" +) + +// TestBuildDesktopJoinURL checks the exact deep link shape Slack is given for +// the "Join" button, since the desktop app matches on the path and reads the +// session from the query string. +func TestBuildDesktopJoinURL(t *testing.T) { + tests := []struct { + name string + sessionID string + want string + }{ + { + name: "uuid session id", + sessionID: "0f0f2a3c-3d4b-4a1e-9c2f-8f7b6a5d4c3b", + want: "hopp:///join-session?sessionId=0f0f2a3c-3d4b-4a1e-9c2f-8f7b6a5d4c3b", + }, + { + name: "session id needing query escaping", + sessionID: "room id&other=1", + want: "hopp:///join-session?sessionId=room+id%26other%3D1", + }, + { + name: "empty session id", + sessionID: "", + want: "hopp:///join-session?sessionId=", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := buildDesktopJoinURL(tt.sessionID); got != tt.want { + t.Errorf("buildDesktopJoinURL(%q) = %q, want %q", tt.sessionID, got, tt.want) + } + }) + } +} + +// TestBuildDesktopJoinURLParsesBackToSessionID guards the contract the desktop +// app relies on: scheme "hopp", path "/join-session", and a sessionId query +// parameter that round-trips to the original room ID. +func TestBuildDesktopJoinURLParsesBackToSessionID(t *testing.T) { + const sessionID = "room id&other=1" + + parsed, err := url.Parse(buildDesktopJoinURL(sessionID)) + if err != nil { + t.Fatalf("url.Parse() returned an error: %v", err) + } + + if parsed.Scheme != "hopp" { + t.Errorf("scheme = %q, want %q", parsed.Scheme, "hopp") + } + if parsed.Path != "/join-session" { + t.Errorf("path = %q, want %q", parsed.Path, "/join-session") + } + if got := parsed.Query().Get("sessionId"); got != sessionID { + t.Errorf("sessionId = %q, want %q", got, sessionID) + } +} diff --git a/tauri/src/lib/deepLinkUtils.test.mjs b/tauri/src/lib/deepLinkUtils.test.mjs new file mode 100644 index 00000000..b5166b35 --- /dev/null +++ b/tauri/src/lib/deepLinkUtils.test.mjs @@ -0,0 +1,256 @@ +/** + * Tests for the Slack join-session deep link handler. + * + * The app has no frontend test runner, so this runs on `node --test` and + * transpiles the real `deepLinkUtils.ts` in memory, injecting fake modules for + * its imports. That keeps the assertions against the actual handler code + * instead of a copy of it. + * + * Run with: + * cd tauri && node --test src/lib/deepLinkUtils.test.mjs + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import ts from "typescript"; + +const sourcePath = path.join(path.dirname(fileURLToPath(import.meta.url)), "deepLinkUtils.ts"); + +const SESSION_ID = "session-123"; +const TOKENS = { audioToken: "audio-token", videoToken: "video-token", participant: "participant-1" }; + +/** + * Transpiles deepLinkUtils.ts and loads it with fake modules, returning the + * module exports plus the recorded calls made against those fakes. + */ +function loadDeepLinkUtils({ authToken = "auth-token", callTokens = null, fetchImpl, callStartedImpl } = {}) { + const calls = []; + const record = (name, ...args) => calls.push({ name, args }); + + const state = { + authToken, + callTokens, + user: { id: "user-1" }, + setCallTokens: (tokens) => { + state.callTokens = tokens; + record("setCallTokens", tokens); + }, + setTab: (tab) => record("setTab", tab), + }; + + const useStore = { getState: () => state }; + + const tauriUtils = { + showWindow: async (label) => record("showWindow", label), + getCallStartPreferences: async () => ({ startMic: true, startCamera: false }), + callStarted: async (audioToken, videoToken) => { + record("callStarted", audioToken, videoToken); + if (callStartedImpl) await callStartedImpl(); + }, + endCallCleanup: () => record("endCallCleanup"), + stopSharing: () => record("stopSharing"), + }; + + const toast = (message) => record("toast", message); + toast.error = (message) => record("toast.error", message); + toast.loading = (message, options) => record("toast.loading", message, options); + toast.dismiss = (id) => record("toast.dismiss", id); + toast.success = (message) => record("toast.success", message); + + const mocks = { + "react-hot-toast": { __esModule: true, default: toast }, + "@/store/store": { __esModule: true, default: useStore, ParticipantRole: { NONE: "none", SHARER: "sharer" } }, + "@/windows/window-utils": { __esModule: true, tauriUtils }, + "@/constants": { __esModule: true, Constants: { backendUrl: "https://example.test" } }, + "@/services/socket": { + __esModule: true, + socketService: { send: (message) => record("socket.send", message) }, + }, + "./authUtils": { __esModule: true, validateAndSetAuthToken: async () => record("validateAndSetAuthToken") }, + }; + + const fetchMock = async (url, options) => { + record("fetch", url, options?.method ?? "GET"); + return fetchImpl ? fetchImpl(url, options) : jsonResponse(TOKENS); + }; + + const { outputText } = ts.transpileModule(readFileSync(sourcePath, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }); + + const module = { exports: {} }; + const requireShim = (specifier) => { + if (!(specifier in mocks)) throw new Error(`Unexpected import in deepLinkUtils.ts: ${specifier}`); + return mocks[specifier]; + }; + + // eslint-disable-next-line no-new-func + new Function("module", "exports", "require", "fetch", outputText)(module, module.exports, requireShim, fetchMock); + + return { exports: module.exports, calls, state, names: () => calls.map((call) => call.name) }; +} + +const jsonResponse = (body, status = 200) => ({ + ok: status >= 200 && status < 300, + status, + json: async () => body, +}); + +const find = (calls, name) => calls.find((call) => call.name === name); + +test("starts the call in core with the fetched tokens", async () => { + const { exports, calls } = loadDeepLinkUtils(); + + const joined = await exports.handleJoinSessionDeepLink(SESSION_ID); + + assert.equal(joined, true); + assert.deepEqual(find(calls, "callStarted").args, [TOKENS.audioToken, TOKENS.videoToken]); + assert.equal(find(calls, "setCallTokens").args[0].isInitialisingCall, true); + assert.equal(find(calls, "setCallTokens").args[0].room.id, SESSION_ID); +}); + +test("does not set a tab on a successful join, leaving call navigation to the app", async () => { + const { exports, calls } = loadDeepLinkUtils(); + + await exports.handleJoinSessionDeepLink(SESSION_ID); + + assert.equal(find(calls, "setTab"), undefined); +}); + +test("clears call state and cleans up when core fails to start the call", async () => { + const { exports, calls, state } = loadDeepLinkUtils({ + callStartedImpl: async () => { + throw new Error("core refused to start the call"); + }, + }); + + const joined = await exports.handleJoinSessionDeepLink(SESSION_ID); + + assert.equal(joined, false); + // Call state must be rolled back, otherwise the UI shows a call that is not running. + assert.equal(state.callTokens, null); + assert.ok(find(calls, "endCallCleanup"), "core cleanup should run after a failed start"); + assert.deepEqual(find(calls, "toast.error").args, ["Failed to start call"]); + + // Same rollback as the shared join path: tell the server the call ended, + // using the participant from the tokens we were given. + assert.deepEqual(find(calls, "socket.send").args[0], { + type: "call_end", + payload: { participant_id: TOKENS.participant }, + }); +}); + +test("surfaces the existing call instead of rejoining the same session", async () => { + const { exports, calls } = loadDeepLinkUtils({ + callTokens: { room: { id: SESSION_ID }, participant: "p" }, + }); + + const joined = await exports.handleJoinSessionDeepLink(SESSION_ID); + + assert.equal(joined, true); + assert.deepEqual(find(calls, "setTab").args, ["call"]); + assert.equal(find(calls, "fetch"), undefined, "should not refetch tokens for the call it is already in"); + assert.equal(find(calls, "callStarted"), undefined); +}); + +test("refuses to join while a different call is active, leaving that call untouched", async () => { + const activeCall = { room: { id: "other-room" }, participant: "p-old", role: "none" }; + const { exports, calls, state } = loadDeepLinkUtils({ callTokens: activeCall }); + + const joined = await exports.handleJoinSessionDeepLink(SESSION_ID); + + assert.equal(joined, false); + assert.deepEqual(find(calls, "toast.error").args, ["Leave your current call first"]); + // The active call must survive untouched: no teardown, no token fetch, no new call. + assert.equal(state.callTokens, activeCall); + assert.equal(find(calls, "fetch"), undefined); + assert.equal(find(calls, "callStarted"), undefined); + assert.equal(find(calls, "endCallCleanup"), undefined); + assert.equal(find(calls, "socket.send"), undefined); +}); + +test("does not overwrite a call that starts while the token fetch is pending", async () => { + let state; + const { exports, calls, ...loaded } = loadDeepLinkUtils({ + fetchImpl: async () => { + // A call begins after the deep link was accepted but before tokens arrive. + state.callTokens = { room: { id: "raced-room" }, participant: "p-race" }; + return jsonResponse(TOKENS); + }, + }); + state = loaded.state; + + const joined = await exports.handleJoinSessionDeepLink(SESSION_ID); + + assert.equal(joined, false); + assert.equal(state.callTokens.room.id, "raced-room", "the racing call must survive"); + assert.equal(find(calls, "callStarted"), undefined); + assert.deepEqual(find(calls, "toast.error").args, ["Leave your current call first"]); +}); + +test("does not start a call when the session is gone", async () => { + const { exports, calls } = loadDeepLinkUtils({ fetchImpl: async () => jsonResponse({}, 404) }); + + const joined = await exports.handleJoinSessionDeepLink(SESSION_ID); + + assert.equal(joined, false); + assert.equal(find(calls, "callStarted"), undefined); + assert.deepEqual(find(calls, "toast.error").args, ["Session has ended or doesn't exist"]); +}); + +test("asks the user to log in when there is no auth token", async () => { + const { exports, calls } = loadDeepLinkUtils({ authToken: null }); + + const joined = await exports.handleJoinSessionDeepLink(SESSION_ID); + + assert.equal(joined, false); + assert.deepEqual(find(calls, "setTab").args, ["login"]); + assert.equal(find(calls, "fetch"), undefined); +}); + +test("routes a join-session deep link URL to the join handler", async () => { + const { exports, calls } = loadDeepLinkUtils(); + + const handled = await exports.processDeepLinkUrl(`hopp:///join-session?sessionId=${SESSION_ID}`); + + assert.equal(handled, true); + assert.deepEqual(find(calls, "callStarted").args, [TOKENS.audioToken, TOKENS.videoToken]); +}); + +test("ignores deep links that are not hopp:// URLs", async () => { + const { exports, calls } = loadDeepLinkUtils(); + + const handled = await exports.processDeepLinkUrl(`https://example.test/join-session?sessionId=${SESSION_ID}`); + + assert.equal(handled, false); + assert.equal(find(calls, "fetch"), undefined); +}); + +test("ignores a second deep link while a join is already in progress", async () => { + let releaseTokens; + const tokensPending = new Promise((resolve) => { + releaseTokens = resolve; + }); + const { exports, calls } = loadDeepLinkUtils({ + fetchImpl: async () => { + await tokensPending; + return jsonResponse(TOKENS); + }, + }); + + // Start one join and leave it waiting on the token fetch, then fire a second. + const firstJoin = exports.handleJoinSessionDeepLink(SESSION_ID); + const secondJoin = await exports.handleJoinSessionDeepLink(SESSION_ID); + + assert.equal(secondJoin, false, "the second deep link must not start a competing join"); + + releaseTokens(); + assert.equal(await firstJoin, true, "the first join should still complete"); + assert.equal( + calls.filter((call) => call.name === "callStarted").length, + 1, + "core should be asked to start the call exactly once", + ); +}); diff --git a/tauri/src/lib/deepLinkUtils.ts b/tauri/src/lib/deepLinkUtils.ts index 558314ee..d5b71f59 100644 --- a/tauri/src/lib/deepLinkUtils.ts +++ b/tauri/src/lib/deepLinkUtils.ts @@ -2,6 +2,7 @@ import toast from "react-hot-toast"; import useStore, { ParticipantRole } from "@/store/store"; import { tauriUtils } from "@/windows/window-utils"; import { Constants } from "@/constants"; +import { socketService } from "@/services/socket"; import { validateAndSetAuthToken } from "./authUtils"; import type { components } from "@/openapi"; @@ -58,6 +59,13 @@ export const processDeepLinkUrl = async (url: string): Promise => { } }; +/** + * Guards against two join-session deep links being handled at the same time. + * Without it, a second link could overwrite the call state the first one is + * still setting up. + */ +let isJoiningSession = false; + /** * Handles joining a Slack pairing session by fetching tokens and setting up the call. * @@ -65,7 +73,7 @@ export const processDeepLinkUrl = async (url: string): Promise => { * @returns true if successfully joined, false otherwise */ export const handleJoinSessionDeepLink = async (sessionId: string): Promise => { - const { authToken, setCallTokens, setTab, user } = useStore.getState(); + const { authToken, callTokens, setCallTokens, setTab, user } = useStore.getState(); if (!authToken) { toast.error("Please log in first to join the session"); @@ -74,15 +82,39 @@ export const handleJoinSessionDeepLink = async (sessionId: string): Promise