From 62e3b214ff963ce46955c0746ed4b012f6574385 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 18 Feb 2026 11:06:22 +0700 Subject: [PATCH 1/4] fix: simplify tmux session creation in paseo.json --- paseo.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/paseo.json b/paseo.json index 2e68019e0..865022162 100644 --- a/paseo.json +++ b/paseo.json @@ -8,7 +8,7 @@ "terminals": [ { "name": "dev", - "command": "SESSION_NAME=\"${PWD##*/}\"; if ! tmux has-session -t \"$SESSION_NAME\" 2>/dev/null; then tmux new-session -d -s \"$SESSION_NAME\" -c \"$PWD\"; tmux send-keys -t \"$SESSION_NAME\":0.0 \"npm run dev\" Enter; fi" + "command": "SESSION_NAME=\"${PWD##*/}\"; if ! tmux has-session -t \"$SESSION_NAME\" 2>/dev/null; then tmux new-session -d -s \"$SESSION_NAME\" -c \"$PWD\" \"npm run dev\"; fi" } ] } From e88a0c1db6bce3583b14fdef54b563e743a15480 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 18 Feb 2026 13:08:40 +0700 Subject: [PATCH 2/4] feat(server): add Claude /rewind command support --- .../claude-agent-commands.e2e.test.ts | 1 + .../providers/claude-agent-commands.test.ts | 1 + .../agent/providers/claude-agent.test.ts | 57 +++ .../server/agent/providers/claude-agent.ts | 338 +++++++++++++++++- ...ude-agent.voice-history-regression.test.ts | 73 +++- .../server/test-utils/fake-agent-client.ts | 14 +- 6 files changed, 476 insertions(+), 8 deletions(-) diff --git a/packages/server/src/server/agent/providers/claude-agent-commands.e2e.test.ts b/packages/server/src/server/agent/providers/claude-agent-commands.e2e.test.ts index 3fe8cf0ac..e800e5b5b 100644 --- a/packages/server/src/server/agent/providers/claude-agent-commands.e2e.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent-commands.e2e.test.ts @@ -51,6 +51,7 @@ describe("claude agent commands E2E", () => { // These are skills that come from CLAUDE.md configurations // At minimum we should have some commands available expect(commandNames.length).toBeGreaterThan(0); + expect(commandNames).toContain("rewind"); }, 60000); test("returns error for non-existent agent", async () => { diff --git a/packages/server/src/server/agent/providers/claude-agent-commands.test.ts b/packages/server/src/server/agent/providers/claude-agent-commands.test.ts index 530cc8b8e..ac9098e5f 100644 --- a/packages/server/src/server/agent/providers/claude-agent-commands.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent-commands.test.ts @@ -85,6 +85,7 @@ const hasClaudeCredentials = // Should have at least one command (skills are loaded from user/project settings) // The exact commands depend on what skills are configured expect(commands.length).toBeGreaterThan(0); + expect(commandNames).toContain("rewind"); await session.close(); }, 30000); diff --git a/packages/server/src/server/agent/providers/claude-agent.test.ts b/packages/server/src/server/agent/providers/claude-agent.test.ts index 81aff983e..51d4d343c 100644 --- a/packages/server/src/server/agent/providers/claude-agent.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.test.ts @@ -789,6 +789,63 @@ async function startAgentMcpServer(): Promise { 120_000 ); + test( + "supports /rewind by reverting the latest file changes", + async () => { + const cwd = tmpCwd(); + const client = new ClaudeAgentClient({ logger }); + const config = buildConfig(cwd, { maxThinkingTokens: 2048 }); + const session = await client.createSession(config); + const filePath = path.join(cwd, "rewind-target.txt"); + const tokenA = `REWIND_A_${Date.now().toString(36)}`; + const tokenB = `REWIND_B_${Date.now().toString(36)}`; + + const runPrompt = async (prompt: string): Promise => { + for await (const event of session.stream(prompt)) { + await autoApprove(session, event); + if (event.type === "turn_failed") { + throw new Error(event.error); + } + if (event.type === "turn_completed") { + break; + } + } + }; + + try { + await runPrompt( + [ + "Create a file named rewind-target.txt in the current directory.", + `Set the file content to exactly: ${tokenA}`, + "Do not add extra text or commentary.", + ].join(" ") + ); + expect(existsSync(filePath)).toBe(true); + expect(readFileSync(filePath, "utf8")).toContain(tokenA); + + await runPrompt( + [ + "Edit rewind-target.txt in place.", + `Replace the entire file content with exactly: ${tokenB}`, + "Do not add extra text or commentary.", + ].join(" ") + ); + expect(readFileSync(filePath, "utf8")).toContain(tokenB); + + const rewind = await session.run("/rewind"); + expect(rewind.finalText.toLowerCase()).toContain("rewound"); + + const contentAfterRewind = readFileSync(filePath, "utf8"); + expect(contentAfterRewind).toContain(tokenA); + expect(contentAfterRewind).not.toContain(tokenB); + } finally { + await session.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, + 240_000 + ); + test( "resumes a persisted session with context preserved", async () => { diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index f6c378351..ee614fb7f 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -212,6 +212,21 @@ const VALID_CLAUDE_MODES = new Set( DEFAULT_MODES.map((mode) => mode.id) ); +const REWIND_COMMAND_NAME = "rewind"; +const REWIND_COMMAND: AgentSlashCommand = { + name: REWIND_COMMAND_NAME, + description: "Rewind tracked files to a previous user message", + argumentHint: "[user_message_uuid]", +}; +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +type SlashCommandInvocation = { + commandName: string; + args?: string; + rawInput: string; +}; + // Orchestrator instructions moved to shared module. type ClaudeAgentConfig = AgentSessionConfig & { provider: "claude" }; @@ -672,6 +687,7 @@ class ClaudeAgentSession implements AgentSession { private activeSidechains = new Map(); private compacting = false; private queryRestartNeeded = false; + private userMessageIds: string[] = []; constructor( config: ClaudeAgentConfig, @@ -787,6 +803,12 @@ class ClaudeAgentSession implements AgentSession { // Reset cancel flag at the start of each turn to prevent stale state from previous turns this.turnCancelRequested = false; + const slashCommand = this.resolveSlashCommandInvocation(prompt); + if (slashCommand?.commandName === REWIND_COMMAND_NAME) { + yield* this.streamRewindCommand(slashCommand); + return; + } + const sdkMessage = this.toSdkUserMessage(prompt); const queue = new Pushable(); this.eventQueue = queue; @@ -1007,11 +1029,284 @@ class ClaudeAgentSession implements AgentSession { async listCommands(): Promise { const q = await this.ensureQuery(); const commands = await q.supportedCommands(); - return commands.map((cmd) => ({ - name: cmd.name, - description: cmd.description, - argumentHint: cmd.argumentHint, - })); + const commandMap = new Map(); + for (const cmd of commands) { + if (!commandMap.has(cmd.name)) { + commandMap.set(cmd.name, { + name: cmd.name, + description: cmd.description, + argumentHint: cmd.argumentHint, + }); + } + } + if (!commandMap.has(REWIND_COMMAND_NAME)) { + commandMap.set(REWIND_COMMAND_NAME, REWIND_COMMAND); + } + return Array.from(commandMap.values()).sort((a, b) => + a.name.localeCompare(b.name) + ); + } + + private resolveSlashCommandInvocation( + prompt: AgentPromptInput + ): SlashCommandInvocation | null { + if (typeof prompt !== "string") { + return null; + } + const parsed = this.parseSlashCommandInput(prompt); + if (!parsed) { + return null; + } + return parsed.commandName === REWIND_COMMAND_NAME ? parsed : null; + } + + private parseSlashCommandInput(text: string): SlashCommandInvocation | null { + const trimmed = text.trim(); + if (!trimmed.startsWith("/") || trimmed.length <= 1) { + return null; + } + const withoutPrefix = trimmed.slice(1); + const firstWhitespaceIdx = withoutPrefix.search(/\s/); + const commandName = + firstWhitespaceIdx === -1 + ? withoutPrefix + : withoutPrefix.slice(0, firstWhitespaceIdx); + if (!commandName || commandName.includes("/")) { + return null; + } + const rawArgs = + firstWhitespaceIdx === -1 + ? "" + : withoutPrefix.slice(firstWhitespaceIdx + 1).trim(); + return rawArgs.length > 0 + ? { commandName, args: rawArgs, rawInput: trimmed } + : { commandName, rawInput: trimmed }; + } + + private async *streamRewindCommand( + invocation: SlashCommandInvocation + ): AsyncGenerator { + yield { type: "turn_started", provider: "claude" }; + yield { + type: "timeline", + provider: "claude", + item: { type: "user_message", text: invocation.rawInput }, + }; + + try { + const rewindAttempt = await this.attemptRewind(invocation.args); + if (!rewindAttempt.messageId || !rewindAttempt.result) { + yield { + type: "turn_failed", + provider: "claude", + error: + rewindAttempt.error ?? + "No prior user message available to rewind. Use /rewind .", + }; + return; + } + yield { + type: "timeline", + provider: "claude", + item: { + type: "assistant_message", + text: this.buildRewindSuccessMessage( + rewindAttempt.messageId, + rewindAttempt.result + ), + }, + }; + yield { type: "turn_completed", provider: "claude" }; + } catch (error) { + yield { + type: "turn_failed", + provider: "claude", + error: + error instanceof Error + ? error.message + : "Failed to rewind tracked files", + }; + } + } + + private buildRewindSuccessMessage( + targetUserMessageId: string, + rewindResult: { filesChanged?: string[]; insertions?: number; deletions?: number } + ): string { + const fileCount = Array.isArray(rewindResult.filesChanged) + ? rewindResult.filesChanged.length + : undefined; + const stats: string[] = []; + if (typeof fileCount === "number") { + stats.push(`${fileCount} file${fileCount === 1 ? "" : "s"}`); + } + if (typeof rewindResult.insertions === "number") { + stats.push(`${rewindResult.insertions} insertions`); + } + if (typeof rewindResult.deletions === "number") { + stats.push(`${rewindResult.deletions} deletions`); + } + if (stats.length > 0) { + return `Rewound tracked files to message ${targetUserMessageId} (${stats.join(", ")}).`; + } + return `Rewound tracked files to message ${targetUserMessageId}.`; + } + + private async attemptRewind( + args: string | undefined + ): Promise<{ + messageId: string | null; + result?: { filesChanged?: string[]; insertions?: number; deletions?: number }; + error?: string; + }> { + if (typeof args === "string" && args.trim().length > 0) { + const candidate = args.trim().split(/\s+/)[0] ?? ""; + if (!UUID_PATTERN.test(candidate)) { + return { + messageId: null, + error: + "Invalid message UUID. Usage: /rewind or /rewind", + }; + } + const rewindResult = await this.rewindFilesOnce(candidate); + if (rewindResult.canRewind) { + return { messageId: candidate, result: rewindResult }; + } + return { + messageId: null, + error: + rewindResult.error ?? + `No file checkpoint found for message ${candidate}.`, + }; + } + + const candidates = this.getRewindCandidateUserMessageIds(); + if (candidates.length === 0) { + return { + messageId: null, + error: + "No prior user message available to rewind. Use /rewind .", + }; + } + + let lastError: string | undefined; + for (const candidate of candidates) { + try { + const rewindResult = await this.rewindFilesOnce(candidate); + if (rewindResult.canRewind) { + return { messageId: candidate, result: rewindResult }; + } + if (rewindResult.error) { + lastError = rewindResult.error; + } + } catch (error) { + lastError = + error instanceof Error + ? error.message + : "Failed to rewind tracked files."; + } + } + + return { + messageId: null, + error: + lastError ?? + "No rewind checkpoints are currently available for this session.", + }; + } + + private async rewindFilesOnce(messageId: string): Promise<{ + canRewind: boolean; + error?: string; + filesChanged?: string[]; + insertions?: number; + deletions?: number; + }> { + try { + const query = await this.ensureFreshQuery(); + return await query.rewindFiles(messageId, { dryRun: false }); + } catch (error) { + // The Claude SDK transport can close after a rewind call. + // If that happens, mark the query stale so a follow-up attempt uses a fresh query. + this.queryRestartNeeded = true; + throw error; + } + } + + private async ensureFreshQuery(): Promise { + if (this.query) { + this.queryRestartNeeded = true; + } + return this.ensureQuery(); + } + + private getRewindCandidateUserMessageIds(): string[] { + const candidates: string[] = []; + const pushUnique = (value: string | null | undefined) => { + if ( + typeof value === "string" && + value.length > 0 && + !candidates.includes(value) + ) { + candidates.push(value); + } + }; + + const historyIds = this.readUserMessageIdsFromHistoryFile(); + for (let idx = historyIds.length - 1; idx >= 0; idx -= 1) { + pushUnique(historyIds[idx]); + } + for (let idx = this.persistedHistory.length - 1; idx >= 0; idx -= 1) { + const item = this.persistedHistory[idx]; + if (item?.type === "user_message") { + pushUnique(item.messageId); + } + } + for (let idx = this.userMessageIds.length - 1; idx >= 0; idx -= 1) { + pushUnique(this.userMessageIds[idx]); + } + + return candidates; + } + + private readUserMessageIdsFromHistoryFile(): string[] { + if (!this.claudeSessionId) { + return []; + } + const historyPath = this.resolveHistoryPath(this.claudeSessionId); + if (!historyPath || !fs.existsSync(historyPath)) { + return []; + } + try { + const ids: string[] = []; + const content = fs.readFileSync(historyPath, "utf8"); + for (const line of content.split(/\n+/)) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const entry = JSON.parse(trimmed); + if (entry?.type === "user" && typeof entry.uuid === "string") { + ids.push(entry.uuid); + } + } catch { + // ignore malformed lines + } + } + return ids; + } catch { + return []; + } + } + + private rememberUserMessageId(messageId: string | null | undefined): void { + if (typeof messageId !== "string" || messageId.length === 0) { + return; + } + const last = this.userMessageIds[this.userMessageIds.length - 1]; + if (last === messageId) { + return; + } + this.userMessageIds.push(messageId); } private async primeSelectableModelIds(query: Query): Promise { @@ -1095,6 +1390,8 @@ class ClaudeAgentSession implements AgentSession { MCP_TIMEOUT: "600000", MCP_TOOL_TIMEOUT: "600000", }, + // Required for provider-level /rewind support. + enableFileCheckpointing: true, // If we have a session ID from a previous query (e.g., after interrupt), // resume that session to continue the conversation history. ...(this.claudeSessionId ? { resume: this.claudeSessionId } : {}), @@ -1174,6 +1471,9 @@ class ClaudeAgentSession implements AgentSession { content.push({ type: "text", text: prompt }); } + const messageId = randomUUID(); + this.rememberUserMessageId(messageId); + return { type: "user", message: { @@ -1181,6 +1481,7 @@ class ClaudeAgentSession implements AgentSession { content, }, parent_tool_use_id: null, + uuid: messageId, session_id: this.claudeSessionId ?? "", }; } @@ -1426,17 +1727,34 @@ class ClaudeAgentSession implements AgentSession { this.compacting = false; break; } + const messageId = + typeof message.uuid === "string" && message.uuid.length > 0 + ? message.uuid + : undefined; + this.rememberUserMessageId(messageId); const content = message.message?.content; if (typeof content === "string" && content.length > 0) { // String content from user messages (e.g., local command output) events.push({ type: "timeline", - item: { type: "user_message", text: content }, + item: { + type: "user_message", + text: content, + ...(messageId ? { messageId } : {}), + }, provider: "claude", }); } else if (Array.isArray(content)) { const timelineItems = this.mapBlocksToTimeline(content, { turnContext }); for (const item of timelineItems) { + if (item.type === "user_message" && messageId && !item.messageId) { + events.push({ + type: "timeline", + item: { ...item, messageId }, + provider: "claude", + }); + continue; + } events.push({ type: "timeline", item, provider: "claude" }); } } @@ -1723,6 +2041,9 @@ class ClaudeAgentSession implements AgentSession { if (entry.isSidechain) { continue; } + if (entry.type === "user" && typeof entry.uuid === "string") { + this.rememberUserMessageId(entry.uuid); + } const items = this.convertHistoryEntry(entry); if (items.length > 0) { timeline.push(...items); @@ -2300,9 +2621,14 @@ export function convertClaudeHistoryEntry( if (entry.type === "user") { const text = extractUserMessageText(content); if (text) { + const messageId = + typeof entry.uuid === "string" && entry.uuid.length > 0 + ? entry.uuid + : undefined; timeline.push({ type: "user_message", text, + ...(messageId ? { messageId } : {}), }); } } diff --git a/packages/server/src/server/agent/providers/claude-agent.voice-history-regression.test.ts b/packages/server/src/server/agent/providers/claude-agent.voice-history-regression.test.ts index fd6e2a3f4..1a1e25912 100644 --- a/packages/server/src/server/agent/providers/claude-agent.voice-history-regression.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.voice-history-regression.test.ts @@ -12,6 +12,7 @@ import type { const sdkMocks = vi.hoisted(() => ({ query: vi.fn(), + lastQuery: null as ReturnType | null, })); vi.mock("@anthropic-ai/claude-agent-sdk", () => ({ @@ -65,6 +66,7 @@ function buildSdkQueryMock() { setModel: vi.fn(async () => undefined), supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]), supportedCommands: vi.fn(async () => []), + rewindFiles: vi.fn(async () => ({ canRewind: true })), }; } @@ -91,7 +93,11 @@ describe("ClaudeAgentSession history replay regression", () => { let previousClaudeConfigDir: string | undefined; beforeEach(() => { - sdkMocks.query.mockImplementation(() => buildSdkQueryMock()); + sdkMocks.query.mockImplementation(() => { + const mock = buildSdkQueryMock(); + sdkMocks.lastQuery = mock; + return mock; + }); tempRoot = mkdtempSync(path.join(os.tmpdir(), "claude-history-regression-")); cwd = path.join(tempRoot, "repo"); @@ -107,6 +113,7 @@ describe("ClaudeAgentSession history replay regression", () => { [ JSON.stringify({ type: "user", + uuid: "history-user-uuid", sessionId: "history-session", cwd, message: { @@ -133,6 +140,7 @@ describe("ClaudeAgentSession history replay regression", () => { afterEach(() => { sdkMocks.query.mockReset(); + sdkMocks.lastQuery = null; if (previousClaudeConfigDir === undefined) { delete process.env.CLAUDE_CONFIG_DIR; } else { @@ -206,4 +214,67 @@ describe("ClaudeAgentSession history replay regression", () => { expect(timelineText).toContain(HISTORY_USER_MARKER); expect(timelineText).toContain(HISTORY_ASSISTANT_MARKER); }); + + test("listCommands includes rewind command", async () => { + const logger = createTestLogger(); + const client = new ClaudeAgentClient({ logger }); + const handle: AgentPersistenceHandle = { + provider: "claude", + sessionId: "history-session", + nativeHandle: "history-session", + metadata: { + provider: "claude", + cwd, + }, + }; + + const session = await client.resumeSession(handle, { cwd }); + try { + const commands = await session.listCommands?.(); + expect(commands?.some((command) => command.name === "rewind")).toBe(true); + } finally { + await session.close(); + } + }); + + test("slash /rewind uses latest user message id from persisted history", async () => { + const logger = createTestLogger(); + const client = new ClaudeAgentClient({ logger }); + const handle: AgentPersistenceHandle = { + provider: "claude", + sessionId: "history-session", + nativeHandle: "history-session", + metadata: { + provider: "claude", + cwd, + }, + }; + + const session = await client.resumeSession(handle, { cwd }); + const events: AgentStreamEvent[] = []; + + try { + for await (const event of session.stream("/rewind")) { + events.push(event); + if ( + event.type === "turn_completed" || + event.type === "turn_failed" || + event.type === "turn_canceled" + ) { + break; + } + } + } finally { + await session.close(); + } + + expect(events.some((event) => event.type === "turn_started")).toBe(true); + expect(events.some((event) => event.type === "turn_completed")).toBe(true); + expect(sdkMocks.lastQuery).toBeTruthy(); + expect(sdkMocks.lastQuery?.rewindFiles).toHaveBeenCalledTimes(1); + expect(sdkMocks.lastQuery?.rewindFiles).toHaveBeenCalledWith( + "history-user-uuid", + { dryRun: false } + ); + }); }); diff --git a/packages/server/src/server/test-utils/fake-agent-client.ts b/packages/server/src/server/test-utils/fake-agent-client.ts index 71a817438..c71f6e6cd 100644 --- a/packages/server/src/server/test-utils/fake-agent-client.ts +++ b/packages/server/src/server/test-utils/fake-agent-client.ts @@ -570,7 +570,19 @@ class FakeAgentSession implements AgentSession { return commands; } - // claude/opencode: keep stable/deterministic. + // Keep deterministic defaults for non-codex providers. + if (this.providerName === "claude") { + return [ + { name: "help", description: "Help", argumentHint: "" }, + { name: "context", description: "Context", argumentHint: "" }, + { + name: "rewind", + description: "Rewind tracked files to a previous user message", + argumentHint: "[user_message_uuid]", + }, + ]; + } + return [ { name: "help", description: "Help", argumentHint: "" }, { name: "context", description: "Context", argumentHint: "" }, From e36d3180011d09534372f3e71a645a5e49d7c832 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 18 Feb 2026 13:12:36 +0700 Subject: [PATCH 3/4] Improve post-ship worktree UX and merged PR detection --- packages/app/e2e/checkout-ship.spec.ts | 2 +- packages/app/src/components/git-diff-pane.tsx | 87 ++++++++---- packages/server/src/shared/messages.ts | 1 + .../server/src/utils/checkout-git.test.ts | 103 ++++++++++++++ packages/server/src/utils/checkout-git.ts | 128 ++++++++++++++---- 5 files changed, 270 insertions(+), 51 deletions(-) diff --git a/packages/app/e2e/checkout-ship.spec.ts b/packages/app/e2e/checkout-ship.spec.ts index e378faf5d..32a9f37bb 100644 --- a/packages/app/e2e/checkout-ship.spec.ts +++ b/packages/app/e2e/checkout-ship.spec.ts @@ -394,7 +394,7 @@ test('checkout-first Changes panel ship loop', async ({ page }) => { await expect(page.getByTestId('changes-menu-archive-worktree')).toBeVisible(); await page.getByTestId('changes-menu-archive-worktree').click(); // Archiving a worktree deletes agents and redirects to home - await expect(page).toHaveURL(/\/agent\/?$/, { timeout: 30000 }); + await expect(page).toHaveURL(/\/agent\/?(?:\?.*)?$/, { timeout: 30000 }); await setWorkingDirectory(page, repo.path); await ensureHostSelected(page); // Repo inspection is async; wait until git options are interactive again. diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index 0043e1747..12dc5fe1a 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -50,7 +50,7 @@ import { type ActionStatus, } from "@/components/ui/dropdown-menu"; import { GitHubIcon } from "@/components/icons/github-icon"; -import { buildHostAgentDraftRoute } from "@/utils/host-routes"; +import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing"; import { openExternalUrl } from "@/utils/open-external-url"; // ============================================================================= @@ -472,6 +472,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { const router = useRouter(); const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null); const [actionError, setActionError] = useState(null); + const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false); const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge"); const { status, isLoading: isStatusLoading, isFetching: isStatusFetching, isError: isStatusError, error: statusError, refresh: refreshStatus } = useCheckoutStatusQuery({ serverId, cwd }); @@ -796,10 +797,14 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { } void persistShipDefault("merge"); setActionError(null); - void runMergeBranch({ serverId, cwd, baseRef }).catch((err) => { - const message = err instanceof Error ? err.message : "Failed to merge"; - setActionError(message); - }); + void runMergeBranch({ serverId, cwd, baseRef }) + .then(() => { + setPostShipArchiveSuggested(true); + }) + .catch((err) => { + const message = err instanceof Error ? err.message : "Failed to merge"; + setActionError(message); + }); }, [baseRef, persistShipDefault, runMergeBranch, serverId, cwd]); const handleMergeFromBase = useCallback(() => { @@ -821,15 +826,16 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { return; } setActionError(null); + const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null); void runArchiveWorktree({ serverId, cwd, worktreePath }) .then(() => { - router.replace(buildHostAgentDraftRoute(serverId) as any); + router.replace(buildNewAgentRoute(serverId, targetWorkingDir) as any); }) .catch((err) => { const message = err instanceof Error ? err.message : "Failed to archive worktree"; setActionError(message); }); - }, [runArchiveWorktree, router, serverId, cwd, status?.cwd]); + }, [runArchiveWorktree, router, serverId, cwd, status]); const renderFlatItem = useCallback( ({ item }: { item: DiffFlatItem }) => { @@ -887,12 +893,27 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { ? undefined : `${branchLabel} -> ${baseRefLabel}`; }, [baseRefLabel, branchLabel]); + const hasPullRequest = Boolean(prStatus?.url); + const hasRemote = gitStatus?.hasRemote ?? false; + const isPaseoOwnedWorktree = gitStatus?.isPaseoOwnedWorktree ?? false; + const isMergedPullRequest = Boolean(prStatus?.isMerged); + const currentBranch = gitStatus?.currentBranch; + const isOnBaseBranch = currentBranch === baseRefLabel; + const shouldPromoteArchive = + isPaseoOwnedWorktree && + !hasUncommittedChanges && + (postShipArchiveSuggested || isMergedPullRequest); + const commitDisabled = actionsDisabled || commitStatus === "pending"; const prDisabled = actionsDisabled || prCreateStatus === "pending"; const mergeDisabled = actionsDisabled || mergeStatus === "pending" || hasUncommittedChanges || !baseRef; const mergeFromBaseDisabled = - actionsDisabled || mergeFromBaseStatus === "pending" || hasUncommittedChanges || !baseRef; + actionsDisabled || + mergeFromBaseStatus === "pending" || + hasUncommittedChanges || + !baseRef || + (isOnBaseBranch && !hasRemote); const pushDisabled = actionsDisabled || pushStatus === "pending" || !(gitStatus?.hasRemote ?? false); const archiveDisabled = @@ -969,11 +990,9 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { ); } - const hasPullRequest = Boolean(prStatus?.url); - const hasRemote = gitStatus?.hasRemote ?? false; - const isPaseoOwnedWorktree = gitStatus?.isPaseoOwnedWorktree ?? false; - const currentBranch = gitStatus?.currentBranch; - const isOnBaseBranch = currentBranch === baseRefLabel; + useEffect(() => { + setPostShipArchiveSuggested(false); + }, [cwd]); // ========================================================================== // Git Actions (Data-Oriented) @@ -1063,16 +1082,21 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { }); } - // Update from base - only when not on base branch - if (!isOnBaseBranch) { + // Update/sync from base + if (!isOnBaseBranch || hasRemote) { allActions.set("merge-from-base", { id: "merge-from-base", - label: `Update from ${baseRefLabel}`, + label: isOnBaseBranch ? `Sync ${baseRefLabel} from origin` : `Update from ${baseRefLabel}`, pendingLabel: "Updating...", successLabel: "Updated", disabled: mergeFromBaseDisabled, status: mergeFromBaseStatus, - description: hasUncommittedChanges ? "Requires clean working tree" : undefined, + description: + hasUncommittedChanges + ? "Requires clean working tree" + : isOnBaseBranch && !hasRemote + ? "No remote configured" + : undefined, icon: , handler: handleMergeFromBase, }); @@ -1095,8 +1119,12 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { // Select primary action (priority rules) let primaryActionId: GitActionId | null = null; + // Rule 0: Post-ship in worktree -> Archive + if (shouldPromoteArchive && allActions.has("archive-worktree")) { + primaryActionId = "archive-worktree"; + } // Rule 1: Uncommitted changes → Commit - if (hasUncommittedChanges) { + else if (hasUncommittedChanges) { primaryActionId = "commit"; } // Rule 2: Ahead of origin → Push @@ -1107,7 +1135,11 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { else if (hasPullRequest) { primaryActionId = "view-pr"; } - // Rule 4: Ahead of base → Ship action based on preference + // Rule 4: On base branch -> surface sync explicitly + else if (isOnBaseBranch && allActions.has("merge-from-base")) { + primaryActionId = "merge-from-base"; + } + // Rule 5: Ahead of base → Ship action based on preference else if (aheadCount > 0) { const preferred: GitActionId = shipDefault === "merge" ? "merge-branch" : "create-pr"; const fallback: GitActionId = shipDefault === "merge" ? "create-pr" : "merge-branch"; @@ -1127,20 +1159,25 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { const primary = primaryActionId ? allActions.get(primaryActionId) ?? null : null; // Secondary actions: ship-related + merge from base + push (excluding primary) - const secondaryIds: GitActionId[] = ["merge-branch", "create-pr", "view-pr", "merge-from-base", "push"]; + const secondaryIds: GitActionId[] = [ + "merge-branch", + "create-pr", + "view-pr", + "merge-from-base", + "push", + "archive-worktree", + ]; const secondary = secondaryIds .filter(id => id !== primaryActionId && allActions.has(id)) .map(id => allActions.get(id)!); - // Menu actions: archive worktree only - const menu = allActions.has("archive-worktree") - ? [allActions.get("archive-worktree")!] - : []; + // Menu actions: none for now (all actionable items are in primary/secondary) + const menu: GitAction[] = []; return { primary, secondary, menu }; }, [ isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isOnBaseBranch, githubFeaturesEnabled, - hasUncommittedChanges, aheadOfOrigin, shipDefault, baseRefLabel, + hasUncommittedChanges, aheadOfOrigin, shipDefault, baseRefLabel, shouldPromoteArchive, commitDisabled, pushDisabled, prDisabled, mergeDisabled, mergeFromBaseDisabled, archiveDisabled, commitStatus, pushStatus, prCreateStatus, mergeStatus, mergeFromBaseStatus, archiveStatus, handleCommit, handlePush, handleCreatePr, handleMergeBranch, handleMergeFromBase, handleArchiveWorktree, diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 48395cc3e..e21a9a335 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -1690,6 +1690,7 @@ const CheckoutPrStatusSchema = z.object({ state: z.string(), baseRefName: z.string(), headRefName: z.string(), + isMerged: z.boolean(), }); export const CheckoutPrStatusResponseSchema = z.object({ diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 5363407e2..66ddfd875 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -471,6 +471,109 @@ describe("checkout git utilities", () => { } }); + it("returns merged PR status when no open PR exists for the current branch", async () => { + execSync("git checkout -b feature", { cwd: repoDir }); + execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir }); + + const fakeBinDir = join(tempDir, "fake-bin-gh-merged"); + mkdirSync(fakeBinDir); + const gitPath = execSync("command -v git", { stdio: "pipe" }).toString().trim(); + symlinkSync(gitPath, join(fakeBinDir, "git")); + writeFileSync( + join(fakeBinDir, "gh"), + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + "if [[ \"${1-}\" == \"--version\" ]]; then", + " echo \"gh version 2.0.0\"", + " exit 0", + "fi", + "args=\"$*\"", + "if [[ \"$args\" == *\"state=open\"* ]]; then", + " echo \"[]\"", + " exit 0", + "fi", + "if [[ \"$args\" == *\"state=closed\"* ]]; then", + " echo '[{\"html_url\":\"https://github.com/getpaseo/paseo/pull/123\",\"title\":\"Ship feature\",\"state\":\"closed\",\"merged_at\":\"2026-02-18T00:00:00Z\",\"base\":{\"ref\":\"main\"},\"head\":{\"ref\":\"feature\"}}]'", + " exit 0", + "fi", + "echo \"unexpected gh args: $args\" >&2", + "exit 1", + "", + ].join("\n"), + "utf8" + ); + execSync(`chmod +x ${join(fakeBinDir, "gh")}`); + + const originalPath = process.env.PATH; + process.env.PATH = `${fakeBinDir}:${originalPath ?? ""}`; + try { + const status = await getPullRequestStatus(repoDir); + expect(status.githubFeaturesEnabled).toBe(true); + expect(status.status).not.toBeNull(); + expect(status.status?.url).toContain("/pull/123"); + expect(status.status?.baseRefName).toBe("main"); + expect(status.status?.headRefName).toBe("feature"); + expect(status.status?.isMerged).toBe(true); + expect(status.status?.state).toBe("merged"); + } finally { + if (originalPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = originalPath; + } + } + }); + + it("does not treat closed-unmerged PRs as shipped status", async () => { + execSync("git checkout -b feature", { cwd: repoDir }); + execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir }); + + const fakeBinDir = join(tempDir, "fake-bin-gh-closed"); + mkdirSync(fakeBinDir); + const gitPath = execSync("command -v git", { stdio: "pipe" }).toString().trim(); + symlinkSync(gitPath, join(fakeBinDir, "git")); + writeFileSync( + join(fakeBinDir, "gh"), + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + "if [[ \"${1-}\" == \"--version\" ]]; then", + " echo \"gh version 2.0.0\"", + " exit 0", + "fi", + "args=\"$*\"", + "if [[ \"$args\" == *\"state=open\"* ]]; then", + " echo \"[]\"", + " exit 0", + "fi", + "if [[ \"$args\" == *\"state=closed\"* ]]; then", + " echo '[{\"html_url\":\"https://github.com/getpaseo/paseo/pull/999\",\"title\":\"Closed without merge\",\"state\":\"closed\",\"merged_at\":null,\"base\":{\"ref\":\"main\"},\"head\":{\"ref\":\"feature\"}}]'", + " exit 0", + "fi", + "echo \"unexpected gh args: $args\" >&2", + "exit 1", + "", + ].join("\n"), + "utf8" + ); + execSync(`chmod +x ${join(fakeBinDir, "gh")}`); + + const originalPath = process.env.PATH; + process.env.PATH = `${fakeBinDir}:${originalPath ?? ""}`; + try { + const status = await getPullRequestStatus(repoDir); + expect(status.githubFeaturesEnabled).toBe(true); + expect(status.status).toBeNull(); + } finally { + if (originalPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = originalPath; + } + } + }); + it("returns typed MergeConflictError on merge conflicts", async () => { const conflictFile = join(repoDir, "conflict.txt"); writeFileSync(conflictFile, "base\n"); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 721b3f658..553517660 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -1517,6 +1517,7 @@ export interface PullRequestStatus { state: string; baseRefName: string; headRefName: string; + isMerged: boolean; } export interface PullRequestStatusResult { @@ -1592,6 +1593,62 @@ async function resolveGitHubRepo(cwd: string): Promise { return null; } +async function listPullRequestsForHead(options: { + cwd: string; + repo: string; + owner: string; + head: string; + state: "open" | "closed"; +}): Promise { + const { stdout } = await execFileAsync( + "gh", + [ + "api", + `repos/${options.repo}/pulls`, + "-X", + "GET", + "-F", + `head=${options.owner}:${options.head}`, + "-F", + `state=${options.state}`, + ], + { cwd: options.cwd } + ); + const parsed = JSON.parse(stdout.trim()); + return Array.isArray(parsed) ? parsed : []; +} + +function buildPullRequestStatus(current: any, fallbackHead: string): PullRequestStatus | null { + if (!current || typeof current !== "object") { + return null; + } + const url = current.html_url ?? current.url; + const title = current.title; + if (typeof url !== "string" || typeof title !== "string" || !url || !title) { + return null; + } + + const mergedAt = + typeof current.merged_at === "string" && current.merged_at.trim().length > 0 + ? current.merged_at + : null; + const state = + mergedAt !== null + ? "merged" + : typeof current.state === "string" && current.state.trim().length > 0 + ? current.state + : ""; + + return { + url, + title, + state, + baseRefName: current.base?.ref ?? "", + headRefName: current.head?.ref ?? fallbackHead, + isMerged: mergedAt !== null, + }; +} + export async function createPullRequest( cwd: string, options: CreatePullRequestOptions @@ -1652,22 +1709,15 @@ export async function getPullRequestStatus(cwd: string): Promise 0 ? parsed[0] : null; - if (!current) { + const openPull = openPulls[0] ?? null; + const openStatus = buildPullRequestStatus(openPull, head); + if (openStatus) { + return { + status: openStatus, + githubFeaturesEnabled: true, + }; + } + + let closedPulls: any[]; + try { + closedPulls = await listPullRequestsForHead({ + cwd, + repo, + owner, + head, + state: "closed", + }); + } catch (error) { + if (isGhAuthError(error)) { + return { + status: null, + githubFeaturesEnabled: false, + }; + } + throw error; + } + const mergedClosedPull = + closedPulls.find( + (entry) => + entry && + typeof entry === "object" && + typeof entry.merged_at === "string" && + entry.merged_at.trim().length > 0 + ) ?? null; + const mergedStatus = buildPullRequestStatus(mergedClosedPull, head); + if (!mergedStatus) { return { status: null, githubFeaturesEnabled: true, }; } return { - status: { - url: current.html_url ?? current.url, - title: current.title, - state: current.state, - baseRefName: current.base?.ref ?? "", - headRefName: current.head?.ref ?? head, - }, + status: mergedStatus, githubFeaturesEnabled: true, }; } From 4affac9ce9bf0fb6bc04047a21d410c069d66133 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 18 Feb 2026 18:13:20 +0700 Subject: [PATCH 4/4] fix(desktop): improve tauri permissions and settings UX --- package.json | 2 +- packages/app/src/app/_layout.tsx | 11 + packages/app/src/constants/layout.ts | 8 +- packages/app/src/contexts/session-context.tsx | 9 + .../app/src/hooks/use-audio-recorder.web.ts | 20 +- .../hooks/use-dictation-audio-source.web.ts | 19 +- packages/app/src/hooks/use-favicon-status.ts | 5 +- packages/app/src/hooks/use-file-drop-zone.ts | 11 +- .../src/hooks/use-speechmatics-audio.web.ts | 19 +- packages/app/src/screens/settings-screen.tsx | 406 +++++++++++++----- packages/app/src/utils/confirm-dialog.ts | 27 +- .../app/src/utils/desktop-permissions.test.ts | 192 +++++++++ packages/app/src/utils/desktop-permissions.ts | 387 +++++++++++++++++ packages/app/src/utils/open-external-url.ts | 20 +- .../app/src/utils/os-notifications.test.ts | 32 ++ packages/app/src/utils/os-notifications.ts | 106 ++++- .../app/src/utils/tauri-daemon-transport.ts | 14 +- packages/app/src/utils/tauri-window.ts | 20 +- packages/app/src/utils/tauri.ts | 92 ++++ packages/desktop/src-tauri/Cargo.lock | 71 ++- packages/desktop/src-tauri/Cargo.toml | 4 +- packages/desktop/src-tauri/Entitlements.plist | 8 + packages/desktop/src-tauri/Info.plist | 4 +- .../src-tauri/capabilities/default.json | 1 + packages/desktop/src-tauri/src/lib.rs | 27 ++ packages/desktop/src-tauri/src/main.rs | 4 +- packages/desktop/src-tauri/tauri.conf.json | 8 +- scripts/sync-workspace-versions.mjs | 104 +++++ 28 files changed, 1433 insertions(+), 198 deletions(-) create mode 100644 packages/app/src/utils/desktop-permissions.test.ts create mode 100644 packages/app/src/utils/desktop-permissions.ts create mode 100644 packages/app/src/utils/tauri.ts create mode 100644 packages/desktop/src-tauri/Entitlements.plist diff --git a/package.json b/package.json index d8d7356b2..e5dbb7d7a 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "ios": "npm run ios --workspace=@getpaseo/app", "web": "npm run web --workspace=@getpaseo/app", "dev:desktop": "npm run dev --workspace=@getpaseo/desktop", - "build:desktop": "npm run build --workspace=@getpaseo/desktop", + "build:desktop": "npm run version:sync-internal && npm run build:web --workspace=@getpaseo/app && npm run build --workspace=@getpaseo/desktop", "cli": "npx tsx packages/cli/src/index.js", "version": "npm run version:sync-internal && npm run release:prepare && git add -A", "version:sync-internal": "node scripts/sync-workspace-versions.mjs", diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index dac040cba..eee47d6aa 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -42,12 +42,14 @@ import { queryClient } from "@/query/query-client"; import { WEB_NOTIFICATION_CLICK_EVENT, type WebNotificationClickDetail, + ensureOsNotificationPermission, } from "@/utils/os-notifications"; import { buildNotificationRoute } from "@/utils/notification-routing"; import { buildHostAgentDraftRoute, parseHostAgentRouteFromPathname, } from "@/utils/host-routes"; +import { getTauri } from "@/utils/tauri"; polyfillCrypto(); @@ -57,6 +59,15 @@ function PushNotificationRouter() { useEffect(() => { if (Platform.OS === "web") { + if (getTauri()) { + void ensureOsNotificationPermission().then((granted) => { + console.log( + "[OSNotifications][Tauri] Startup permission preflight result:", + granted ? "granted" : "not-granted" + ); + }); + } + const target = globalThis as unknown as EventTarget; const openFromWebClick = (event: Event) => { const customEvent = event as CustomEvent; diff --git a/packages/app/src/constants/layout.ts b/packages/app/src/constants/layout.ts index 391c880de..6a901a92c 100644 --- a/packages/app/src/constants/layout.ts +++ b/packages/app/src/constants/layout.ts @@ -1,4 +1,5 @@ import { Platform } from "react-native"; +import { getTauri } from "@/utils/tauri"; export const FOOTER_HEIGHT = 75; @@ -19,21 +20,20 @@ export const TAURI_TRAFFIC_LIGHT_HEIGHT = 56; // Check if running in Tauri desktop app (any OS) function isTauri(): boolean { if (Platform.OS !== "web") return false; - if (typeof window === "undefined") return false; - return "__TAURI__" in window; + return getTauri() !== null; } // Check if running in Tauri desktop app on macOS function isTauriMac(): boolean { if (Platform.OS !== "web") return false; if (typeof window === "undefined") return false; - if (!("__TAURI__" in window)) return false; + if (getTauri() === null) return false; // Check for macOS via user agent const ua = navigator.userAgent; return ua.includes("Mac OS") || ua.includes("Macintosh"); } -// Cached result - only cache true, keep checking if false (in case __TAURI__ loads later) +// Cached result - only cache true, keep checking if false (in case Tauri globals load later) let _isTauriMacCached: boolean | null = null; let _isTauriCached: boolean | null = null; diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index e203c0457..8a218525c 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -351,6 +351,15 @@ export function SessionProvider({ const isActive = appState ? appState === "active" : true; const isAwayFromAgent = !isActive || focusedAgentId !== params.agentId; if (!isAwayFromAgent) { + console.log( + "[OSNotifications] Skipping attention notification: user already focused on agent", + { + agentId: params.agentId, + reason: params.reason, + appState, + focusedAgentId, + } + ); return; } diff --git a/packages/app/src/hooks/use-audio-recorder.web.ts b/packages/app/src/hooks/use-audio-recorder.web.ts index a458bfc25..bc9fe5bc7 100644 --- a/packages/app/src/hooks/use-audio-recorder.web.ts +++ b/packages/app/src/hooks/use-audio-recorder.web.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { AttemptCancelledError, AttemptGuard } from "@/utils/attempt-guard"; +import { getTauri } from "@/utils/tauri"; export interface AudioCaptureConfig { sampleRate?: number; @@ -156,16 +157,33 @@ export function useAudioRecorder(config?: AudioCaptureConfig) { : true; const currentOrigin = typeof window !== "undefined" && window.location ? window.location.origin : "unknown"; + const isTauri = getTauri() !== null; if (missingNavigator) { throw new Error("Microphone capture is not supported in this environment"); } - if (!secureContext) { + console.log("[AudioRecorder][Web] Microphone preflight", { + secureContext, + currentOrigin, + isTauri, + hasMediaDevices: + typeof navigator !== "undefined" && + !!navigator.mediaDevices && + typeof navigator.mediaDevices.getUserMedia === "function", + }); + + if (!secureContext && !isTauri) { throw new Error( `Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}` ); } + if (!secureContext && isTauri) { + console.warn( + "[AudioRecorder][Web] Insecure context reported under Tauri; attempting getUserMedia anyway", + { currentOrigin } + ); + } const options = configRef.current; const constraints: MediaStreamConstraints = { diff --git a/packages/app/src/hooks/use-dictation-audio-source.web.ts b/packages/app/src/hooks/use-dictation-audio-source.web.ts index 46fdd34da..d3c737b03 100644 --- a/packages/app/src/hooks/use-dictation-audio-source.web.ts +++ b/packages/app/src/hooks/use-dictation-audio-source.web.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { getTauri } from "@/utils/tauri"; import type { DictationAudioSource, DictationAudioSourceConfig } from "./use-dictation-audio-source.types"; @@ -148,13 +149,29 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic : true; const currentOrigin = typeof window !== "undefined" && window.location ? window.location.origin : "unknown"; + const isTauri = getTauri() !== null; if (missingNavigator) { throw new Error("Microphone capture is not supported in this environment"); } - if (!secureContext) { + console.log("[DictationAudio][Web] Microphone preflight", { + secureContext, + currentOrigin, + isTauri, + hasMediaDevices: + typeof navigator !== "undefined" && + !!navigator.mediaDevices && + typeof navigator.mediaDevices.getUserMedia === "function", + }); + if (!secureContext && !isTauri) { throw new Error(`Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}`); } + if (!secureContext && isTauri) { + console.warn( + "[DictationAudio][Web] Insecure context reported under Tauri; attempting getUserMedia anyway", + { currentOrigin } + ); + } const AudioContextCtor = getAudioContextCtor(); if (!AudioContextCtor) { diff --git a/packages/app/src/hooks/use-favicon-status.ts b/packages/app/src/hooks/use-favicon-status.ts index 52431df35..812b04491 100644 --- a/packages/app/src/hooks/use-favicon-status.ts +++ b/packages/app/src/hooks/use-favicon-status.ts @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { Platform } from "react-native"; import { getIsTauriMac } from "@/constants/layout"; import { useAggregatedAgents } from "./use-aggregated-agents"; +import { getCurrentTauriWindow } from "@/utils/tauri"; type FaviconStatus = "none" | "running" | "attention"; type ColorScheme = "dark" | "light"; @@ -90,9 +91,9 @@ function getSystemColorScheme(): ColorScheme { } async function updateMacDockBadge(count?: number) { - if (Platform.OS !== "web" || typeof window === "undefined" || !getIsTauriMac()) return; + if (Platform.OS !== "web" || !getIsTauriMac()) return; - const tauriWindow = (window as any).__TAURI__?.window?.getCurrentWindow?.(); + const tauriWindow = getCurrentTauriWindow(); if (!tauriWindow || typeof tauriWindow.setBadgeCount !== "function") { return; } diff --git a/packages/app/src/hooks/use-file-drop-zone.ts b/packages/app/src/hooks/use-file-drop-zone.ts index b58e93e0a..138b95af1 100644 --- a/packages/app/src/hooks/use-file-drop-zone.ts +++ b/packages/app/src/hooks/use-file-drop-zone.ts @@ -1,6 +1,7 @@ import { useState, useRef, useEffect } from "react"; import { Platform } from "react-native"; import type { ImageAttachment } from "@/components/message-input"; +import { getCurrentTauriWindow, getTauri } from "@/utils/tauri"; interface UseFileDropZoneOptions { onFilesDropped: (files: ImageAttachment[]) => void; @@ -52,10 +53,6 @@ function isImageFile(file: File): boolean { return file.type.startsWith("image/"); } -function isTauriEnvironment(): boolean { - return typeof window !== "undefined" && (window as any).__TAURI__ !== undefined; -} - function getFileExtension(path: string): string { const normalizedPath = path.split("#", 1)[0]?.split("?", 1)[0] ?? path; const extensionIndex = normalizedPath.lastIndexOf("."); @@ -72,7 +69,7 @@ function isImagePath(path: string): boolean { function filePathToImageAttachment(path: string): ImageAttachment { const extension = getFileExtension(path); const mimeType = IMAGE_MIME_BY_EXTENSION[extension] ?? "image/jpeg"; - const convertFileSrc = (window as any).__TAURI__?.core?.convertFileSrc; + const convertFileSrc = getTauri()?.core?.convertFileSrc; const uri = typeof convertFileSrc === "function" ? convertFileSrc(path) : path; @@ -145,11 +142,11 @@ export function useFileDropZone({ } async function setupTauriDragDrop(): Promise { - if (!isTauriEnvironment()) { + if (getTauri() === null) { return false; } - const tauriWindow = (window as any).__TAURI__?.window?.getCurrentWindow?.(); + const tauriWindow = getCurrentTauriWindow(); if (!tauriWindow || typeof tauriWindow.onDragDropEvent !== "function") { return false; } diff --git a/packages/app/src/hooks/use-speechmatics-audio.web.ts b/packages/app/src/hooks/use-speechmatics-audio.web.ts index d05661f5a..8f6f312cb 100644 --- a/packages/app/src/hooks/use-speechmatics-audio.web.ts +++ b/packages/app/src/hooks/use-speechmatics-audio.web.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { SpeechSegmenter } from "@/voice/speech-segmenter"; +import { getTauri } from "@/utils/tauri"; export interface SpeechmaticsAudioConfig { onAudioSegment?: (segment: { audioData: string; isLast: boolean }) => void; @@ -245,14 +246,30 @@ export function useSpeechmaticsAudio(config: SpeechmaticsAudioConfig): Speechmat ? window.isSecureContext : true; const currentOrigin = typeof window !== "undefined" && window.location ? window.location.origin : "unknown"; + const isTauri = getTauri() !== null; try { if (missingNavigator) { throw new Error("Microphone capture is not supported in this environment"); } - if (!secureContext) { + console.log("[Voice][Web] Microphone preflight", { + secureContext, + currentOrigin, + isTauri, + hasMediaDevices: + typeof navigator !== "undefined" && + !!navigator.mediaDevices && + typeof navigator.mediaDevices.getUserMedia === "function", + }); + if (!secureContext && !isTauri) { throw new Error(`Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}`); } + if (!secureContext && isTauri) { + console.warn( + "[Voice][Web] Insecure context reported under Tauri; attempting getUserMedia anyway", + { currentOrigin } + ); + } const AudioContextCtor = getAudioContextCtor(); if (!AudioContextCtor) { diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 9c8764a17..939ee4ba6 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -13,7 +13,7 @@ import Constants from "expo-constants"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { useQueries } from "@tanstack/react-query"; -import { Sun, Moon, Monitor, Globe, Settings, RotateCw, Trash2 } from "lucide-react-native"; +import { Sun, Moon, Monitor, Globe, Settings, RotateCw, Trash2, Check } from "lucide-react-native"; import { useAppSettings, type AppSettings } from "@/hooks/use-settings"; import { useDaemonRegistry, type HostProfile, type HostConnection } from "@/contexts/daemon-registry-context"; import { useDaemonConnections, type ActiveConnection, type ConnectionStatus } from "@/contexts/daemon-connections-context"; @@ -35,6 +35,14 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet"; +import { + getDesktopPermissionSnapshot, + requestDesktopPermission, + shouldShowDesktopPermissionSection, + type DesktopPermissionKind, + type DesktopPermissionSnapshot, + type DesktopPermissionStatus, +} from "@/utils/desktop-permissions"; const delay = (ms: number) => new Promise((resolve) => { @@ -76,7 +84,6 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, - letterSpacing: 0.6, marginBottom: theme.spacing[3], marginLeft: theme.spacing[1], }, @@ -84,7 +91,6 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, - letterSpacing: 0.4, marginBottom: theme.spacing[2], }, input: { @@ -301,39 +307,58 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foreground, fontSize: theme.fontSize.base, }, - audioRowDescription: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - marginTop: 2, - }, - // Footer - footer: { - borderTopWidth: 1, - borderTopColor: theme.colors.border, - paddingTop: theme.spacing[6], - paddingBottom: theme.spacing[4], + permissionSectionHeader: { + flexDirection: "row", alignItems: "center", - gap: theme.spacing[3], + justifyContent: "space-between", + gap: theme.spacing[2], + marginBottom: theme.spacing[3], }, - footerAppInfo: { + permissionRefreshButton: { + width: 34, + height: 34, + borderRadius: theme.borderRadius.md, + borderWidth: 1, + borderColor: theme.colors.border, + backgroundColor: theme.colors.surface2, alignItems: "center", + justifyContent: "center", + }, + permissionRefreshButtonDisabled: { + opacity: theme.opacity[50], + }, + permissionRowActions: { + alignItems: "flex-end", gap: theme.spacing[1], }, - footerText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, + permissionStatusPill: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + borderRadius: theme.borderRadius.full, + borderWidth: 1, + borderColor: theme.colors.border, + backgroundColor: theme.colors.surface3, + paddingHorizontal: theme.spacing[2], + paddingVertical: 4, + minWidth: 88, + justifyContent: "center", }, - footerVersion: { + permissionStatusText: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.normal, + color: theme.colors.foregroundMuted, + }, + permissionDetailText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, + maxWidth: 220, + textAlign: "right", }, - resetButton: { - paddingVertical: theme.spacing[2], - paddingHorizontal: theme.spacing[3], - }, - resetButtonText: { - color: theme.colors.palette.red[500], + aboutValue: { + color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.normal, }, // Empty state emptyCard: { @@ -352,26 +377,27 @@ const styles = StyleSheet.create((theme) => ({ // Theme toggle themeToggleContainer: { flexDirection: "row", - backgroundColor: theme.colors.surface2, - borderRadius: theme.borderRadius.lg, - padding: 4, - gap: 4, + backgroundColor: theme.colors.surface3, + borderRadius: theme.borderRadius.md, + borderWidth: 1, + borderColor: theme.colors.border, + padding: 2, + gap: 2, }, themeToggleButton: { - flex: 1, flexDirection: "row", alignItems: "center", justifyContent: "center", - gap: theme.spacing[2], - paddingVertical: theme.spacing[3], - paddingHorizontal: theme.spacing[3], - borderRadius: theme.borderRadius.md, + gap: theme.spacing[1], + paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[2], + borderRadius: theme.borderRadius.sm, }, themeToggleButtonActive: { - backgroundColor: theme.colors.surface3, + backgroundColor: theme.colors.surface2, }, themeToggleText: { - fontSize: theme.fontSize.sm, + fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, color: theme.colors.foregroundMuted, }, @@ -417,7 +443,7 @@ export default function SettingsScreen() { const insets = useSafeAreaInsets(); const params = useLocalSearchParams<{ editHost?: string; serverId?: string }>(); const routeServerId = typeof params.serverId === "string" ? params.serverId.trim() : ""; - const { settings, isLoading: settingsLoading, updateSettings, resetSettings } = useAppSettings(); + const { settings, isLoading: settingsLoading, updateSettings } = useAppSettings(); const { daemons, isLoading: daemonLoading, @@ -436,7 +462,14 @@ export default function SettingsScreen() { const [isRemovingHost, setIsRemovingHost] = useState(false); const [editingDaemon, setEditingDaemon] = useState(null); const [isSavingEdit, setIsSavingEdit] = useState(false); + const [desktopPermissionSnapshot, setDesktopPermissionSnapshot] = + useState(null); + const [isRefreshingDesktopPermissions, setIsRefreshingDesktopPermissions] = + useState(false); + const [requestingDesktopPermission, setRequestingDesktopPermission] = + useState(null); const isLoading = settingsLoading || daemonLoading; + const showDesktopPermissionSection = shouldShowDesktopPermissionSection(); const isMountedRef = useRef(true); const lastHandledEditHostRef = useRef(null); const appVersion = Constants.expoConfig?.version ?? (Constants as any).manifest?.version ?? "0.1.0"; @@ -534,6 +567,73 @@ export default function SettingsScreen() { pendingEditReopenServerId, ]); + const refreshDesktopPermissions = useCallback(async () => { + if (!showDesktopPermissionSection) return; + + setIsRefreshingDesktopPermissions(true); + try { + const snapshot = await getDesktopPermissionSnapshot(); + if (!isMountedRef.current) return; + setDesktopPermissionSnapshot(snapshot); + } catch (error) { + console.error("[Settings] Failed to load desktop permission status", error); + } finally { + if (isMountedRef.current) { + setIsRefreshingDesktopPermissions(false); + } + } + }, [showDesktopPermissionSection]); + + const handleRequestDesktopPermission = useCallback( + async (kind: DesktopPermissionKind) => { + if (!showDesktopPermissionSection) return; + + setRequestingDesktopPermission(kind); + try { + const status = await requestDesktopPermission({ kind }); + if (!isMountedRef.current) return; + setDesktopPermissionSnapshot((previous) => { + const base: DesktopPermissionSnapshot = previous ?? { + checkedAt: Date.now(), + notifications: { + state: "unknown", + detail: "Notification status has not been checked yet.", + }, + microphone: { + state: "unknown", + detail: "Microphone status has not been checked yet.", + }, + }; + + return kind === "notifications" + ? { + ...base, + checkedAt: Date.now(), + notifications: status, + } + : { + ...base, + checkedAt: Date.now(), + microphone: status, + }; + }); + } catch (error) { + console.error(`[Settings] Failed to request ${kind} permission`, error); + } finally { + if (isMountedRef.current) { + setRequestingDesktopPermission(null); + } + await refreshDesktopPermissions(); + } + }, + [refreshDesktopPermissions, showDesktopPermissionSection] + ); + + useEffect(() => { + if (!showDesktopPermissionSection) return; + void refreshDesktopPermissions(); + }, [refreshDesktopPermissions, showDesktopPermissionSection]); + const handleSaveEditDaemon = useCallback(async (nextLabelRaw: string) => { if (!editingServerId) return; if (isSavingEdit) return; @@ -590,34 +690,6 @@ export default function SettingsScreen() { [updateSettings] ); - function handleReset() { - Alert.alert( - "Reset settings", - "Are you sure you want to reset all settings to defaults?", - [ - { text: "Cancel", style: "cancel" }, - { - text: "Reset", - style: "destructive", - onPress: async () => { - try { - await resetSettings(); - Alert.alert( - "Settings reset", - "All settings have been reset to defaults." - ); - } catch (error) { - Alert.alert( - "Error", - "Failed to reset settings. Please try again." - ); - } - }, - }, - ] - ); - } - const restartConfirmationMessage = "This will immediately stop the Paseo daemon process. The app will disconnect until it restarts."; @@ -804,55 +876,113 @@ export default function SettingsScreen() { {/* Appearance */} Appearance - - handleThemeChange("light")} - > - - - Light - - - handleThemeChange("dark")} - > - - - Dark - - - handleThemeChange("auto")} - > - - - System - - + + + + Theme + + + handleThemeChange("light")} + > + + + Light + + + handleThemeChange("dark")} + > + + + Dark + + + handleThemeChange("auto")} + > + + + System + + + + - {/* Footer */} - - - Paseo - Version {appVersion} + {showDesktopPermissionSection ? ( + + + + Desktop permissions + + [ + styles.permissionRefreshButton, + (isRefreshingDesktopPermissions || + requestingDesktopPermission !== null) && + styles.permissionRefreshButtonDisabled, + pressed && { opacity: 0.85 }, + ]} + onPress={() => { + void refreshDesktopPermissions(); + }} + disabled={ + isRefreshingDesktopPermissions || + requestingDesktopPermission !== null + } + accessibilityRole="button" + accessibilityLabel="Refresh desktop permissions" + > + + + + + { + void handleRequestDesktopPermission("notifications"); + }} + /> + { + void handleRequestDesktopPermission("microphone"); + }} + /> + + + ) : null} + + {/* About */} + + About + + + + Version + + {appVersion} + - - Reset to defaults - @@ -1228,6 +1358,60 @@ function HostDetailModal({ ); } +interface DesktopPermissionRowProps { + title: string; + status: DesktopPermissionStatus | null; + isRequesting: boolean; + showBorder?: boolean; + onRequest: () => void; +} + +function DesktopPermissionRow({ + title, + status, + isRequesting, + showBorder, + onRequest, +}: DesktopPermissionRowProps) { + const { theme } = useUnistyles(); + const state = status?.state ?? "unknown"; + const isGranted = state === "granted"; + const shouldShowDetail = + status !== null && + status.detail.trim().length > 0 && + state !== "granted" && + state !== "prompt" && + state !== "not-granted"; + + return ( + + + {title} + + + {isGranted ? ( + + + Granted + + ) : ( + + )} + {shouldShowDetail ? ( + {status?.detail} + ) : null} + + + ); +} + function ConnectionRow({ connection, latencyMs, diff --git a/packages/app/src/utils/confirm-dialog.ts b/packages/app/src/utils/confirm-dialog.ts index fdc7adf39..15a3515a4 100644 --- a/packages/app/src/utils/confirm-dialog.ts +++ b/packages/app/src/utils/confirm-dialog.ts @@ -1,4 +1,5 @@ import { Alert, Platform } from "react-native"; +import { getTauri, type TauriDialogAskOptions } from "@/utils/tauri"; export interface ConfirmDialogInput { title: string; @@ -8,26 +9,6 @@ export interface ConfirmDialogInput { destructive?: boolean; } -interface TauriDialogAskOptions { - title?: string; - okLabel?: string; - cancelLabel?: string; - kind?: "info" | "warning" | "error"; -} - -interface TauriDialogApi { - ask?: (message: string, options?: TauriDialogAskOptions) => Promise; -} - -interface TauriCoreApi { - invoke?: (command: string, args?: Record) => Promise; -} - -interface TauriGlobalApi { - dialog?: TauriDialogApi; - core?: TauriCoreApi; -} - interface ConfirmButtonConfig { confirmLabel: string; cancelLabel: string; @@ -67,13 +48,11 @@ async function showNativeConfirmDialog(input: ConfirmDialogInput): Promise ({ Platform: { OS: platform } })) + return import('./desktop-permissions') +} + +describe('desktop-permissions', () => { + afterEach(() => { + vi.doUnmock('react-native') + vi.restoreAllMocks() + vi.resetModules() + restoreGlobals() + }) + + it('shows section only in Tauri web runtime', async () => { + const { shouldShowDesktopPermissionSection } = await loadModuleForPlatform('web') + + expect(shouldShowDesktopPermissionSection()).toBe(false) + + ;(globalThis as { __TAURI__?: unknown }).__TAURI__ = { notification: {} } + expect(shouldShowDesktopPermissionSection()).toBe(true) + }) + + it('reads notification and microphone status', async () => { + const isPermissionGranted = vi.fn(async () => false) + ;(globalThis as { __TAURI__?: unknown }).__TAURI__ = { + notification: { isPermissionGranted }, + } + setNavigator({ + permissions: { + query: vi.fn(async () => ({ state: 'granted' })), + }, + mediaDevices: { + getUserMedia: vi.fn(), + }, + }) + + const { getDesktopPermissionSnapshot } = await loadModuleForPlatform('web') + const snapshot = await getDesktopPermissionSnapshot() + + expect(snapshot.notifications.state).toBe('not-granted') + expect(snapshot.microphone.state).toBe('granted') + expect(isPermissionGranted).toHaveBeenCalledTimes(1) + expect(snapshot.checkedAt).toBeTypeOf('number') + }) + + it('queries microphone permission with correct Permissions instance binding', async () => { + const permissions = { + query(this: unknown, _descriptor: { name: string }) { + if (this !== permissions) { + throw new TypeError( + 'Can only call Permissions.query on instances of Permissions' + ) + } + return Promise.resolve({ state: 'granted' as const }) + }, + } + + setNavigator({ + permissions, + mediaDevices: { + getUserMedia: vi.fn(), + }, + }) + + const { getDesktopPermissionSnapshot } = await loadModuleForPlatform('web') + const snapshot = await getDesktopPermissionSnapshot() + + expect(snapshot.microphone.state).toBe('granted') + }) + + it('returns a fallback message when runtime blocks Permissions.query', async () => { + setNavigator({ + permissions: { + query: vi.fn(async () => { + throw new TypeError( + 'Can only call Permissions.query on instances of Permissions' + ) + }), + }, + mediaDevices: { + getUserMedia: vi.fn(), + }, + }) + + const { getDesktopPermissionSnapshot } = await loadModuleForPlatform('web') + const snapshot = await getDesktopPermissionSnapshot() + + expect(snapshot.microphone.state).toBe('unknown') + expect(snapshot.microphone.detail).toContain( + 'Microphone status API is unavailable in this runtime.' + ) + }) + + it('requests notification permission via Tauri', async () => { + const requestPermission = vi.fn(async () => 'granted') + ;(globalThis as { __TAURI__?: unknown }).__TAURI__ = { + notification: { requestPermission }, + } + + const { requestDesktopPermission } = await loadModuleForPlatform('web') + const result = await requestDesktopPermission({ kind: 'notifications' }) + + expect(result.state).toBe('granted') + expect(requestPermission).toHaveBeenCalledTimes(1) + }) + + it('falls back to browser Notification permission when Tauri API is unavailable', async () => { + class MockNotification { + static permission = 'denied' + } + ;(globalThis as { Notification?: unknown }).Notification = MockNotification + setNavigator({}) + + const { getDesktopPermissionSnapshot } = await loadModuleForPlatform('web') + const snapshot = await getDesktopPermissionSnapshot() + + expect(snapshot.notifications.state).toBe('denied') + }) + + it('requests microphone permission and stops acquired tracks', async () => { + const stop = vi.fn() + const getUserMedia = vi.fn(async () => ({ + getTracks: () => [{ stop }], + })) + setNavigator({ + permissions: { + query: vi.fn(async () => ({ state: 'granted' })), + }, + mediaDevices: { + getUserMedia, + }, + }) + + const { requestDesktopPermission } = await loadModuleForPlatform('web') + const result = await requestDesktopPermission({ kind: 'microphone' }) + + expect(result.state).toBe('granted') + expect(getUserMedia).toHaveBeenCalledWith({ audio: true }) + expect(stop).toHaveBeenCalledTimes(1) + }) + + it('maps microphone request denial to denied status', async () => { + setNavigator({ + mediaDevices: { + getUserMedia: vi.fn(async () => { + throw { name: 'NotAllowedError', message: 'denied' } + }), + }, + }) + + const { requestDesktopPermission } = await loadModuleForPlatform('web') + const result = await requestDesktopPermission({ kind: 'microphone' }) + + expect(result.state).toBe('denied') + }) +}) diff --git a/packages/app/src/utils/desktop-permissions.ts b/packages/app/src/utils/desktop-permissions.ts new file mode 100644 index 000000000..7632fa89c --- /dev/null +++ b/packages/app/src/utils/desktop-permissions.ts @@ -0,0 +1,387 @@ +import { Platform } from 'react-native' +import { getTauri, type TauriNotificationPermission } from '@/utils/tauri' + +export type DesktopPermissionKind = 'notifications' | 'microphone' + +export type DesktopPermissionState = + | 'granted' + | 'denied' + | 'prompt' + | 'not-granted' + | 'unavailable' + | 'unknown' + +export interface DesktopPermissionStatus { + state: DesktopPermissionState + detail: string +} + +export interface DesktopPermissionSnapshot { + checkedAt: number + notifications: DesktopPermissionStatus + microphone: DesktopPermissionStatus +} + +type NotificationConstructorLike = { + permission?: string + requestPermission?: () => Promise +} + +type MediaStreamTrackLike = { + stop?: () => void +} + +type MediaStreamLike = { + getTracks?: () => MediaStreamTrackLike[] +} + +type NavigatorLike = { + mediaDevices?: { + getUserMedia?: (constraints: { audio: boolean }) => Promise + } + permissions?: { + query?: (descriptor: { name: string }) => Promise<{ state?: string }> + } +} + +export function shouldShowDesktopPermissionSection(): boolean { + return Platform.OS === 'web' && getTauri() !== null +} + +function status(input: DesktopPermissionStatus): DesktopPermissionStatus { + return input +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function getErrorMessage(error: unknown): string { + if (error instanceof Error && typeof error.message === 'string') { + return error.message + } + return String(error) +} + +function getErrorName(error: unknown): string | null { + if (!isObject(error)) { + return null + } + const name = error.name + return typeof name === 'string' && name.length > 0 ? name : null +} + +function isPermissionsQueryRuntimeUnsupported(error: unknown): boolean { + const message = getErrorMessage(error) + if ( + message.includes('Can only call Permissions.query on instances of Permissions') || + message.includes('Illegal invocation') + ) { + return true + } + return false +} + +function getWebNotificationConstructor(): NotificationConstructorLike | null { + if (Platform.OS !== 'web') { + return null + } + const NotificationConstructor = (globalThis as { Notification?: unknown }).Notification + if ( + NotificationConstructor == null || + (typeof NotificationConstructor !== 'function' && typeof NotificationConstructor !== 'object') + ) { + return null + } + return NotificationConstructor as NotificationConstructorLike +} + +function getNavigatorLike(): NavigatorLike | null { + if (Platform.OS !== 'web') { + return null + } + const webNavigator = (globalThis as { navigator?: unknown }).navigator + if (!isObject(webNavigator)) { + return null + } + return webNavigator as NavigatorLike +} + +function mapNotificationPermissionString(permission: string): DesktopPermissionStatus { + if (permission === 'granted') { + return status({ + state: 'granted', + detail: 'Notifications are allowed by the OS.', + }) + } + if (permission === 'denied') { + return status({ + state: 'denied', + detail: 'Notifications are denied in system settings.', + }) + } + if (permission === 'default') { + return status({ + state: 'prompt', + detail: 'Notifications have not been granted yet.', + }) + } + return status({ + state: 'unknown', + detail: `Unexpected notification permission state: ${permission}`, + }) +} + +function mapTauriNotificationPermissionResult( + permission: TauriNotificationPermission +): DesktopPermissionStatus { + if (permission === 'granted') { + return status({ + state: 'granted', + detail: 'Notifications are allowed by the OS.', + }) + } + if (permission === 'denied') { + return status({ + state: 'denied', + detail: 'Notifications are denied in system settings.', + }) + } + return status({ + state: 'prompt', + detail: 'Notifications have not been granted yet.', + }) +} + +async function getNotificationPermissionStatus(): Promise { + if (Platform.OS !== 'web') { + return status({ + state: 'unavailable', + detail: 'Desktop notification status is only available on web runtime.', + }) + } + + const tauriNotification = getTauri()?.notification + if (tauriNotification) { + if (typeof tauriNotification.isPermissionGranted !== 'function') { + return status({ + state: 'unavailable', + detail: 'Tauri notification plugin is missing isPermissionGranted().', + }) + } + + try { + const granted = await tauriNotification.isPermissionGranted() + if (granted) { + return status({ + state: 'granted', + detail: 'Tauri reports notifications are granted.', + }) + } + return status({ + state: 'not-granted', + detail: 'Tauri reports notifications are not granted. Use Request to prompt.', + }) + } catch (error) { + return status({ + state: 'unknown', + detail: `Failed to read notification status: ${getErrorMessage(error)}`, + }) + } + } + + const NotificationConstructor = getWebNotificationConstructor() + if (!NotificationConstructor || typeof NotificationConstructor.permission !== 'string') { + return status({ + state: 'unavailable', + detail: 'Web Notification API is unavailable in this environment.', + }) + } + + return mapNotificationPermissionString(NotificationConstructor.permission) +} + +async function getMicrophonePermissionStatus(): Promise { + if (Platform.OS !== 'web') { + return status({ + state: 'unavailable', + detail: 'Desktop microphone status is only available on web runtime.', + }) + } + + const webNavigator = getNavigatorLike() + if (!webNavigator) { + return status({ + state: 'unavailable', + detail: 'Navigator is unavailable in this environment.', + }) + } + + const permissionsApi = webNavigator.permissions + if (permissionsApi && typeof permissionsApi.query === 'function') { + try { + const result = await permissionsApi.query({ name: 'microphone' }) + if (result?.state === 'granted') { + return status({ + state: 'granted', + detail: 'Microphone access is granted.', + }) + } + if (result?.state === 'denied') { + return status({ + state: 'denied', + detail: 'Microphone access is denied in system settings.', + }) + } + if (result?.state === 'prompt') { + return status({ + state: 'prompt', + detail: 'Microphone permission has not been granted yet.', + }) + } + return status({ + state: 'unknown', + detail: `Unexpected microphone permission state: ${result?.state ?? 'unknown'}`, + }) + } catch (error) { + if (isPermissionsQueryRuntimeUnsupported(error)) { + return status({ + state: 'unknown', + detail: + 'Microphone status API is unavailable in this runtime. Use Request to check access.', + }) + } + return status({ + state: 'unknown', + detail: `Failed to query microphone status: ${getErrorMessage(error)}`, + }) + } + } + + if (typeof webNavigator.mediaDevices?.getUserMedia !== 'function') { + return status({ + state: 'unavailable', + detail: 'Microphone capture is unavailable in this environment.', + }) + } + + return status({ + state: 'unknown', + detail: 'Permission status API is unavailable. Use Request to check access.', + }) +} + +async function requestNotificationPermissionStatus(): Promise { + if (Platform.OS !== 'web') { + return status({ + state: 'unavailable', + detail: 'Desktop notification requests are only available on web runtime.', + }) + } + + const tauriNotification = getTauri()?.notification + if (tauriNotification) { + if (typeof tauriNotification.requestPermission !== 'function') { + return status({ + state: 'unavailable', + detail: 'Tauri notification plugin is missing requestPermission().', + }) + } + + try { + const permission = await tauriNotification.requestPermission() + return mapTauriNotificationPermissionResult(permission) + } catch (error) { + return status({ + state: 'unknown', + detail: `Failed to request notification permission: ${getErrorMessage(error)}`, + }) + } + } + + const NotificationConstructor = getWebNotificationConstructor() + if (!NotificationConstructor || typeof NotificationConstructor.requestPermission !== 'function') { + return status({ + state: 'unavailable', + detail: 'Web Notification API requestPermission() is unavailable.', + }) + } + + try { + const permission = await NotificationConstructor.requestPermission() + return mapNotificationPermissionString(permission) + } catch (error) { + return status({ + state: 'unknown', + detail: `Failed to request notification permission: ${getErrorMessage(error)}`, + }) + } +} + +async function requestMicrophonePermissionStatus(): Promise { + if (Platform.OS !== 'web') { + return status({ + state: 'unavailable', + detail: 'Desktop microphone requests are only available on web runtime.', + }) + } + + const webNavigator = getNavigatorLike() + if (!webNavigator || typeof webNavigator.mediaDevices?.getUserMedia !== 'function') { + return status({ + state: 'unavailable', + detail: 'Microphone capture API is unavailable in this environment.', + }) + } + + try { + const stream = await webNavigator.mediaDevices.getUserMedia({ audio: true }) + const tracks = stream && typeof stream.getTracks === 'function' ? stream.getTracks() : [] + tracks.forEach((track) => { + if (typeof track.stop === 'function') { + track.stop() + } + }) + return await getMicrophonePermissionStatus() + } catch (error) { + const errorName = getErrorName(error) + if (errorName === 'NotAllowedError' || errorName === 'PermissionDeniedError') { + return status({ + state: 'denied', + detail: 'Microphone permission was denied by the user or system.', + }) + } + if (errorName === 'NotFoundError' || errorName === 'DevicesNotFoundError') { + return status({ + state: 'unavailable', + detail: 'No microphone device was found.', + }) + } + return status({ + state: 'unknown', + detail: `Failed to request microphone permission: ${getErrorMessage(error)}`, + }) + } +} + +export async function requestDesktopPermission(input: { + kind: DesktopPermissionKind +}): Promise { + if (input.kind === 'notifications') { + return await requestNotificationPermissionStatus() + } + return await requestMicrophonePermissionStatus() +} + +export async function getDesktopPermissionSnapshot(): Promise { + const [notifications, microphone] = await Promise.all([ + getNotificationPermissionStatus(), + getMicrophonePermissionStatus(), + ]) + + return { + checkedAt: Date.now(), + notifications, + microphone, + } +} diff --git a/packages/app/src/utils/open-external-url.ts b/packages/app/src/utils/open-external-url.ts index ee8318456..f27307a70 100644 --- a/packages/app/src/utils/open-external-url.ts +++ b/packages/app/src/utils/open-external-url.ts @@ -1,23 +1,13 @@ import * as Linking from "expo-linking"; import { Platform } from "react-native"; -import { getIsTauri } from "@/constants/layout"; - -interface TauriWindowWithOpener { - __TAURI__?: { - opener?: { - openUrl?: (url: string) => Promise; - }; - }; -} +import { getTauri } from "@/utils/tauri"; export async function openExternalUrl(url: string): Promise { if (Platform.OS === "web") { - if (typeof window !== "undefined" && getIsTauri()) { - const opener = (window as TauriWindowWithOpener).__TAURI__?.opener?.openUrl; - if (typeof opener === "function") { - await opener(url); - return; - } + const opener = getTauri()?.opener?.openUrl; + if (typeof opener === "function") { + await opener(url); + return; } window.open(url, "_blank", "noopener,noreferrer"); diff --git a/packages/app/src/utils/os-notifications.test.ts b/packages/app/src/utils/os-notifications.test.ts index 2d3ed3f03..2f53dfc6c 100644 --- a/packages/app/src/utils/os-notifications.test.ts +++ b/packages/app/src/utils/os-notifications.test.ts @@ -18,6 +18,7 @@ type GlobalSnapshot = { dispatchEvent: unknown; focus: unknown; location: unknown; + __TAURI__: unknown; }; const originalGlobals: GlobalSnapshot = { @@ -26,6 +27,7 @@ const originalGlobals: GlobalSnapshot = { dispatchEvent: (globalThis as { dispatchEvent?: unknown }).dispatchEvent, focus: (globalThis as { focus?: unknown }).focus, location: (globalThis as { location?: unknown }).location, + __TAURI__: (globalThis as { __TAURI__?: unknown }).__TAURI__, }; async function loadModuleForPlatform(platform: "web" | "ios" | "android") { @@ -40,6 +42,7 @@ function restoreGlobals(): void { (globalThis as { dispatchEvent?: unknown }).dispatchEvent = originalGlobals.dispatchEvent; (globalThis as { focus?: unknown }).focus = originalGlobals.focus; (globalThis as { location?: unknown }).location = originalGlobals.location; + (globalThis as { __TAURI__?: unknown }).__TAURI__ = originalGlobals.__TAURI__; } describe("sendOsNotification", () => { @@ -163,4 +166,33 @@ describe("sendOsNotification", () => { expect(assign).toHaveBeenCalledWith("/h/srv%20with%20space/agent/agent%2F1"); }); + + it("uses Tauri notification module when available", async () => { + const isPermissionGranted = vi.fn(async () => false); + const requestPermission = vi.fn(async () => "granted"); + const sendNotification = vi.fn(async () => undefined); + + (globalThis as { __TAURI__?: unknown }).__TAURI__ = { + notification: { + isPermissionGranted, + requestPermission, + sendNotification, + }, + }; + + const { sendOsNotification } = await loadModuleForPlatform("web"); + const sent = await sendOsNotification({ + title: "Agent finished", + body: "Done", + data: { serverId: "srv-1", agentId: "agent-1" }, + }); + + expect(sent).toBe(true); + expect(isPermissionGranted).toHaveBeenCalledTimes(1); + expect(requestPermission).toHaveBeenCalledTimes(1); + expect(sendNotification).toHaveBeenCalledWith({ + title: "Agent finished", + body: "Done", + }); + }); }); diff --git a/packages/app/src/utils/os-notifications.ts b/packages/app/src/utils/os-notifications.ts index 8c3629568..e951e688c 100644 --- a/packages/app/src/utils/os-notifications.ts +++ b/packages/app/src/utils/os-notifications.ts @@ -1,5 +1,6 @@ import { Platform } from "react-native"; import { buildNotificationRoute } from "./notification-routing"; +import { getTauri, type TauriNotificationApi } from "@/utils/tauri"; type OsNotificationPayload = { title: string; @@ -21,6 +22,13 @@ export const WEB_NOTIFICATION_CLICK_EVENT = "paseo:web-notification-click"; let permissionRequest: Promise | null = null; +function getTauriNotificationModule(): TauriNotificationApi | null { + if (Platform.OS !== "web") { + return null; + } + return getTauri()?.notification ?? null; +} + function getWebNotificationConstructor(): { permission: string; requestPermission?: () => Promise; @@ -54,6 +62,86 @@ async function ensureNotificationPermission(): Promise { return result; } +export async function ensureOsNotificationPermission(): Promise { + if (Platform.OS !== "web") { + return false; + } + + const tauriNotification = getTauriNotificationModule(); + if (tauriNotification) { + return await ensureTauriNotificationPermission(tauriNotification); + } + + return await ensureNotificationPermission(); +} + +async function ensureTauriNotificationPermission( + notificationModule: TauriNotificationApi +): Promise { + if (typeof notificationModule.isPermissionGranted === "function") { + try { + const granted = await notificationModule.isPermissionGranted(); + if (granted) { + console.log("[OSNotifications][Tauri] Permission already granted"); + return true; + } + } catch (error) { + console.warn( + "[OSNotifications][Tauri] Failed to check notification permission", + error + ); + } + } + + if (typeof notificationModule.requestPermission !== "function") { + console.warn( + "[OSNotifications][Tauri] notification.requestPermission is unavailable" + ); + return false; + } + + try { + const result = await notificationModule.requestPermission(); + console.log("[OSNotifications][Tauri] requestPermission result:", result); + return result === "granted"; + } catch (error) { + console.warn( + "[OSNotifications][Tauri] Failed to request notification permission", + error + ); + return false; + } +} + +async function sendTauriNotification( + payload: OsNotificationPayload, + notificationModule: TauriNotificationApi +): Promise { + if (typeof notificationModule.sendNotification !== "function") { + console.warn( + "[OSNotifications][Tauri] notification.sendNotification is unavailable" + ); + return false; + } + + const granted = await ensureTauriNotificationPermission(notificationModule); + if (!granted) { + console.log("[OSNotifications][Tauri] Permission not granted"); + return false; + } + + try { + await notificationModule.sendNotification({ + title: payload.title, + body: payload.body, + }); + return true; + } catch (error) { + console.warn("[OSNotifications][Tauri] Failed to send notification", error); + return false; + } +} + function dispatchWebNotificationClick(detail: WebNotificationClickDetail): boolean { const dispatch = (globalThis as { dispatchEvent?: (event: Event) => boolean }).dispatchEvent; const CustomEventConstructor = (globalThis as { CustomEvent?: typeof CustomEvent }) @@ -127,12 +215,28 @@ export async function sendOsNotification( return false; } + const tauriNotification = getTauriNotificationModule(); + if (tauriNotification) { + console.log("[OSNotifications] Using Tauri notification module"); + return await sendTauriNotification(payload, tauriNotification); + } + const NotificationConstructor = getWebNotificationConstructor(); if (!NotificationConstructor) { + console.log( + "[OSNotifications][Web] Notification constructor unavailable", + typeof window !== "undefined" && window.location + ? window.location.origin + : "unknown-origin" + ); return false; } - const granted = await ensureNotificationPermission(); + const granted = await ensureOsNotificationPermission(); if (!granted) { + console.log( + "[OSNotifications][Web] Permission not granted:", + NotificationConstructor.permission + ); return false; } const notification = new NotificationConstructor(payload.title, { diff --git a/packages/app/src/utils/tauri-daemon-transport.ts b/packages/app/src/utils/tauri-daemon-transport.ts index 1f97193af..c23a07cbe 100644 --- a/packages/app/src/utils/tauri-daemon-transport.ts +++ b/packages/app/src/utils/tauri-daemon-transport.ts @@ -1,4 +1,5 @@ import type { DaemonTransport, DaemonTransportFactory } from "@server/client/daemon-client"; +import { getTauri } from "@/utils/tauri"; type TauriWebSocketMessage = | { type: "Text"; data: string } @@ -29,15 +30,8 @@ function toTauriOutgoingMessage( return Array.from(data); } -function isTauriEnvironment(): boolean { - return typeof window !== "undefined" && (window as any).__TAURI__ !== undefined; -} - function getTauriWebSocketModule(): TauriWebSocketModule | null { - if (!isTauriEnvironment()) { - return null; - } - const ws = (window as any).__TAURI__?.websocket; + const ws = getTauri()?.websocket; if (ws && typeof ws.connect === "function") { return ws as TauriWebSocketModule; } @@ -45,7 +39,7 @@ function getTauriWebSocketModule(): TauriWebSocketModule | null { } export function createTauriWebSocketTransportFactory(): DaemonTransportFactory | null { - if (!isTauriEnvironment()) { + if (getTauri() === null) { return null; } @@ -122,7 +116,7 @@ export function createTauriWebSocketTransportFactory(): DaemonTransportFactory | } if (!module) { throw new Error( - "Tauri WebSocket plugin is not available (expected window.__TAURI__.websocket). Did you enable tauri-plugin-websocket and websocket:default capability?" + "Tauri WebSocket plugin is not available (expected getTauri().websocket). Did you enable tauri-plugin-websocket and websocket:default capability?" ); } diff --git a/packages/app/src/utils/tauri-window.ts b/packages/app/src/utils/tauri-window.ts index 41a8f86aa..4ebd60235 100644 --- a/packages/app/src/utils/tauri-window.ts +++ b/packages/app/src/utils/tauri-window.ts @@ -1,15 +1,10 @@ import { Platform } from "react-native"; import { useState, useEffect } from "react"; import { getIsTauriMac, TAURI_TRAFFIC_LIGHT_WIDTH, TAURI_TRAFFIC_LIGHT_HEIGHT } from "@/constants/layout"; +import { getCurrentTauriWindow, getTauri, isTauriEnvironment } from "@/utils/tauri"; let tauriWindow: any = null; -// Runtime check for Tauri environment -function isTauriEnvironment(): boolean { - return typeof window !== "undefined" && - (window as any).__TAURI__ !== undefined; -} - async function getTauriWindow() { if (tauriWindow) return tauriWindow; @@ -19,15 +14,18 @@ async function getTauriWindow() { } try { - // When `app.withGlobalTauri` is enabled, Tauri exposes its JS APIs on `window.__TAURI__`. + // When `app.withGlobalTauri` is enabled, Tauri exposes its JS APIs via getTauri(). // We must use that here because importing `@tauri-apps/api/*` would be bundled into // the native (Hermes) builds and break parsing/runtime on mobile. - const tauri = (window as any).__TAURI__ as any; + const tauri = getTauri(); + if (!tauri) { + return null; + } // Prefer the public global window module. - const windowModule = tauri?.window; - if (windowModule && typeof windowModule.getCurrentWindow === "function") { - tauriWindow = windowModule.getCurrentWindow(); + const publicWindow = getCurrentTauriWindow(); + if (publicWindow) { + tauriWindow = publicWindow; return tauriWindow; } diff --git a/packages/app/src/utils/tauri.ts b/packages/app/src/utils/tauri.ts new file mode 100644 index 000000000..a62221e98 --- /dev/null +++ b/packages/app/src/utils/tauri.ts @@ -0,0 +1,92 @@ +export type TauriNotificationPermission = "granted" | "denied" | "default"; + +export interface TauriDialogAskOptions { + title?: string; + okLabel?: string; + cancelLabel?: string; + kind?: "info" | "warning" | "error"; +} + +export interface TauriDialogApi { + ask?: (message: string, options?: TauriDialogAskOptions) => Promise; +} + +export interface TauriCoreApi { + invoke?: (command: string, args?: Record) => Promise; + convertFileSrc?: (path: string) => string; +} + +export interface TauriEventApi { + listen?: ( + event: string, + handler: (event: unknown) => void + ) => Promise<() => void> | (() => void); +} + +export interface TauriWindowApi { + label?: string; + startDragging?: () => Promise; + toggleMaximize?: () => Promise; + isFullscreen?: () => Promise; + onResized?: ( + handler: (event: TEvent) => void + ) => Promise<() => void> | (() => void); + setBadgeCount?: (count?: number) => Promise; + onDragDropEvent?: ( + handler: (event: TEvent) => void + ) => Promise<() => void> | (() => void); +} + +export interface TauriWindowModule { + getCurrentWindow?: () => TauriWindowApi; +} + +export interface TauriOpenerApi { + openUrl?: (url: string) => Promise; +} + +export interface TauriNotificationApi { + isPermissionGranted?: () => Promise; + requestPermission?: () => Promise; + sendNotification?: ( + payload: string | { title: string; body?: string } + ) => Promise; +} + +export interface TauriWebSocketApi { + connect?: (url: string, config?: unknown) => Promise; +} + +export interface TauriApi { + core?: TauriCoreApi; + dialog?: TauriDialogApi; + event?: TauriEventApi; + notification?: TauriNotificationApi; + opener?: TauriOpenerApi; + websocket?: TauriWebSocketApi; + window?: TauriWindowModule; +} + +export function getTauri(): TauriApi | null { + const tauri = (globalThis as { __TAURI__?: unknown }).__TAURI__; + if (!tauri || typeof tauri !== "object") { + return null; + } + return tauri as TauriApi; +} + +export function isTauriEnvironment(): boolean { + return getTauri() !== null; +} + +export function getCurrentTauriWindow(): TauriWindowApi | null { + const getter = getTauri()?.window?.getCurrentWindow; + if (typeof getter !== "function") { + return null; + } + try { + return getter() ?? null; + } catch { + return null; + } +} diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index 9b8df883e..507ee0f13 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -2028,6 +2028,18 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "mac-notification-sys" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65fd3f75411f4725061682ed91f131946e912859d0044d39c4ec0aac818d7621" +dependencies = [ + "cc", + "objc2", + "objc2-foundation", + "time", +] + [[package]] name = "markup5ever" version = "0.14.1" @@ -2164,6 +2176,20 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "notify-rust" +version = "4.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21af20a1b50be5ac5861f74af1a863da53a11c38684d9818d82f1c42f7fdc6c2" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -2512,7 +2538,7 @@ dependencies = [ [[package]] name = "paseo" -version = "0.1.0" +version = "0.1.13" dependencies = [ "log", "serde", @@ -2521,6 +2547,7 @@ dependencies = [ "tauri-build", "tauri-plugin-dialog", "tauri-plugin-log", + "tauri-plugin-notification", "tauri-plugin-opener", "tauri-plugin-websocket", ] @@ -2708,7 +2735,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", "indexmap 2.13.0", - "quick-xml", + "quick-xml 0.38.4", "serde", "time", ] @@ -2858,6 +2885,15 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.38.4" @@ -4035,6 +4071,25 @@ dependencies = [ "time", ] +[[package]] +name = "tauri-plugin-notification" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc" +dependencies = [ + "log", + "notify-rust", + "rand 0.9.2", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "time", + "url", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.3" @@ -4178,6 +4233,18 @@ dependencies = [ "toml 0.9.11+spec-1.1.0", ] +[[package]] +name = "tauri-winrt-notification" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" +dependencies = [ + "quick-xml 0.37.5", + "thiserror 2.0.18", + "windows", + "windows-version", +] + [[package]] name = "tempfile" version = "3.25.0" diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index b69373ca7..3c9cf0704 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "paseo" -version = "0.1.0" +version = "0.1.13" description = "Paseo Desktop" authors = ["moboudra"] license = "MIT" @@ -28,5 +28,7 @@ log = "0.4" tauri = { version = "2.9.5", features = [] } tauri-plugin-dialog = "2" tauri-plugin-log = "2" +tauri-plugin-notification = "2" tauri-plugin-opener = "2" tauri-plugin-websocket = "2" + diff --git a/packages/desktop/src-tauri/Entitlements.plist b/packages/desktop/src-tauri/Entitlements.plist new file mode 100644 index 000000000..d637c5893 --- /dev/null +++ b/packages/desktop/src-tauri/Entitlements.plist @@ -0,0 +1,8 @@ + + + + + com.apple.security.device.audio-input + + + diff --git a/packages/desktop/src-tauri/Info.plist b/packages/desktop/src-tauri/Info.plist index 343992bb6..f39ec3ac4 100644 --- a/packages/desktop/src-tauri/Info.plist +++ b/packages/desktop/src-tauri/Info.plist @@ -7,9 +7,9 @@ CFBundleName Paseo CFBundleShortVersionString - 0.1.0 + 0.1.13 CFBundleVersion - 0.1.0 + 0.1.13 NSMicrophoneUsageDescription Paseo needs access to your microphone for voice dictation and voice mode. diff --git a/packages/desktop/src-tauri/capabilities/default.json b/packages/desktop/src-tauri/capabilities/default.json index 21bd32027..8e304233a 100644 --- a/packages/desktop/src-tauri/capabilities/default.json +++ b/packages/desktop/src-tauri/capabilities/default.json @@ -17,6 +17,7 @@ "core:window:allow-set-badge-count", "core:window:allow-toggle-maximize", "dialog:default", + "notification:default", "opener:default", "websocket:default" ] diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index 7c12e3e32..b10ebbe1b 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -1,5 +1,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use tauri::menu::{Menu, MenuItemBuilder, MenuItemKind, PredefinedMenuItem, Submenu}; +#[cfg(target_os = "macos")] +use tauri::menu::AboutMetadata; use tauri::Manager; use tauri::WebviewWindow; @@ -20,6 +22,7 @@ fn set_zoom_factor(webview: &WebviewWindow, factor: f64) { pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_websocket::init()) .setup(|app| { @@ -38,6 +41,30 @@ pub fn run() { // responder-chain shortcuts across the whole app. let menu = Menu::default(app.handle())?; + #[cfg(target_os = "macos")] + { + let app_menu = menu.items()?.into_iter().find_map(|item| match item { + MenuItemKind::Submenu(submenu) => Some(submenu), + _ => None, + }); + + if let Some(submenu) = app_menu { + // Tauri's default about item sets only `version`, which macOS renders as + // "Version ()". Set only `short_version` instead. + let about_metadata = AboutMetadata { + name: Some(app.package_info().name.clone()), + short_version: Some(app.package_info().version.to_string()), + copyright: app.config().bundle.copyright.clone(), + ..Default::default() + }; + let about = PredefinedMenuItem::about(app.handle(), None, Some(about_metadata))?; + + if submenu.remove_at(0)?.is_some() { + submenu.insert(&about, 0)?; + } + } + } + let zoom_in = MenuItemBuilder::with_id("zoom_in", "Zoom In") .accelerator("CmdOrCtrl+=") .build(app)?; diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index 8c38d1778..549683263 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -2,8 +2,8 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // `tauri dev` runs the raw executable (`target/debug/Paseo`) on macOS (not the .app bundle), -// so we must embed an Info.plist containing `NSMicrophoneUsageDescription` for WebKit -// microphone access to trigger the OS permission prompt in dev. +// so we must embed an Info.plist containing usage descriptions for WebKit media +// permission prompts in dev. #[cfg(all(target_os = "macos", debug_assertions))] tauri::embed_plist::embed_info_plist!("../Info.plist"); diff --git a/packages/desktop/src-tauri/tauri.conf.json b/packages/desktop/src-tauri/tauri.conf.json index d357f33ac..f69d75b0d 100644 --- a/packages/desktop/src-tauri/tauri.conf.json +++ b/packages/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "Paseo", - "version": "0.1.0", + "version": "0.1.13", "identifier": "dev.paseo.desktop", "build": { "frontendDist": "../../app/dist", @@ -20,7 +20,10 @@ "fullscreen": false, "titleBarStyle": "Overlay", "hiddenTitle": true, - "trafficLightPosition": { "x": 16, "y": 22 } + "trafficLightPosition": { + "x": 16, + "y": 22 + } } ], "security": { @@ -38,6 +41,7 @@ "icons/icon.ico" ], "macOS": { + "entitlements": "Entitlements.plist", "infoPlist": "Info.plist" } } diff --git a/scripts/sync-workspace-versions.mjs b/scripts/sync-workspace-versions.mjs index cecb16a34..e1f6201ef 100644 --- a/scripts/sync-workspace-versions.mjs +++ b/scripts/sync-workspace-versions.mjs @@ -23,6 +23,106 @@ const dependencySections = [ const touched = [] +function syncDesktopTauriConfigVersion(version) { + const tauriConfigPath = path.join( + rootDir, + 'packages', + 'desktop', + 'src-tauri', + 'tauri.conf.json' + ) + + if (!existsSync(tauriConfigPath)) { + return + } + + const tauriConfig = JSON.parse(readFileSync(tauriConfigPath, 'utf8')) + if (tauriConfig.version === version) { + return + } + + tauriConfig.version = version + writeFileSync(tauriConfigPath, `${JSON.stringify(tauriConfig, null, 2)}\n`) + touched.push(path.relative(rootDir, tauriConfigPath)) +} + +function syncDesktopCargoPackageVersion(version) { + const cargoTomlPath = path.join( + rootDir, + 'packages', + 'desktop', + 'src-tauri', + 'Cargo.toml' + ) + + if (!existsSync(cargoTomlPath)) { + return + } + + const cargoLines = readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/) + let inPackageSection = false + let updated = false + + const nextLines = cargoLines.map((line) => { + if (/^\[package\]\s*$/.test(line)) { + inPackageSection = true + return line + } + + if (inPackageSection && /^\[/.test(line)) { + inPackageSection = false + return line + } + + if (inPackageSection && /^version\s*=\s*".*"\s*$/.test(line)) { + const expectedLine = `version = "${version}"` + if (line === expectedLine) { + return line + } + updated = true + return expectedLine + } + + return line + }) + + if (!updated) { + return + } + + writeFileSync(cargoTomlPath, `${nextLines.join('\n')}\n`) + touched.push(path.relative(rootDir, cargoTomlPath)) +} + +function syncDesktopInfoPlistVersion(version) { + const infoPlistPath = path.join( + rootDir, + 'packages', + 'desktop', + 'src-tauri', + 'Info.plist' + ) + + if (!existsSync(infoPlistPath)) { + return + } + + const current = readFileSync(infoPlistPath, 'utf8') + + const shortVersionPattern = /(CFBundleShortVersionString<\/key>\s*)([^<]+)(<\/string>)/ + const bundleVersionPattern = /(CFBundleVersion<\/key>\s*)([^<]+)(<\/string>)/ + + let next = current.replace(shortVersionPattern, `$1${version}$3`) + next = next.replace(bundleVersionPattern, `$1${version}$3`) + + if (next === current) { + return + } + + writeFileSync(infoPlistPath, next) + touched.push(path.relative(rootDir, infoPlistPath)) +} + for (const workspacePath of workspacePaths) { const packagePath = path.join(rootDir, workspacePath, 'package.json') if (!existsSync(packagePath)) { @@ -63,6 +163,10 @@ for (const workspacePath of workspacePaths) { } } +syncDesktopTauriConfigVersion(rootVersion) +syncDesktopCargoPackageVersion(rootVersion) +syncDesktopInfoPlistVersion(rootVersion) + if (touched.length === 0) { console.log(`Workspace versions and internal deps already synced to ${rootVersion}`) } else {