Merge remote-tracking branch 'origin/main' into pr58-sync
# Conflicts: # packages/server/src/shared/messages.ts
This commit is contained in:
+1
-1
@@ -30,7 +30,7 @@
|
|||||||
"ios": "npm run ios --workspace=@getpaseo/app",
|
"ios": "npm run ios --workspace=@getpaseo/app",
|
||||||
"web": "npm run web --workspace=@getpaseo/app",
|
"web": "npm run web --workspace=@getpaseo/app",
|
||||||
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
|
"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",
|
"cli": "npx tsx packages/cli/src/index.js",
|
||||||
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
|
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
|
||||||
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
|
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
|
||||||
|
|||||||
@@ -394,7 +394,7 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
|
|||||||
await expect(page.getByTestId('changes-menu-archive-worktree')).toBeVisible();
|
await expect(page.getByTestId('changes-menu-archive-worktree')).toBeVisible();
|
||||||
await page.getByTestId('changes-menu-archive-worktree').click();
|
await page.getByTestId('changes-menu-archive-worktree').click();
|
||||||
// Archiving a worktree deletes agents and redirects to home
|
// 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 setWorkingDirectory(page, repo.path);
|
||||||
await ensureHostSelected(page);
|
await ensureHostSelected(page);
|
||||||
// Repo inspection is async; wait until git options are interactive again.
|
// Repo inspection is async; wait until git options are interactive again.
|
||||||
|
|||||||
@@ -42,12 +42,14 @@ import { queryClient } from "@/query/query-client";
|
|||||||
import {
|
import {
|
||||||
WEB_NOTIFICATION_CLICK_EVENT,
|
WEB_NOTIFICATION_CLICK_EVENT,
|
||||||
type WebNotificationClickDetail,
|
type WebNotificationClickDetail,
|
||||||
|
ensureOsNotificationPermission,
|
||||||
} from "@/utils/os-notifications";
|
} from "@/utils/os-notifications";
|
||||||
import { buildNotificationRoute } from "@/utils/notification-routing";
|
import { buildNotificationRoute } from "@/utils/notification-routing";
|
||||||
import {
|
import {
|
||||||
buildHostAgentDraftRoute,
|
buildHostAgentDraftRoute,
|
||||||
parseHostAgentRouteFromPathname,
|
parseHostAgentRouteFromPathname,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
|
import { getTauri } from "@/utils/tauri";
|
||||||
|
|
||||||
polyfillCrypto();
|
polyfillCrypto();
|
||||||
|
|
||||||
@@ -57,6 +59,15 @@ function PushNotificationRouter() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.OS === "web") {
|
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 target = globalThis as unknown as EventTarget;
|
||||||
const openFromWebClick = (event: Event) => {
|
const openFromWebClick = (event: Event) => {
|
||||||
const customEvent = event as CustomEvent<WebNotificationClickDetail>;
|
const customEvent = event as CustomEvent<WebNotificationClickDetail>;
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ import {
|
|||||||
type ActionStatus,
|
type ActionStatus,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
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";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -472,6 +472,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null);
|
const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null);
|
||||||
const [actionError, setActionError] = useState<string | null>(null);
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
|
||||||
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
|
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
|
||||||
const { status, isLoading: isStatusLoading, isFetching: isStatusFetching, isError: isStatusError, error: statusError, refresh: refreshStatus } =
|
const { status, isLoading: isStatusLoading, isFetching: isStatusFetching, isError: isStatusError, error: statusError, refresh: refreshStatus } =
|
||||||
useCheckoutStatusQuery({ serverId, cwd });
|
useCheckoutStatusQuery({ serverId, cwd });
|
||||||
@@ -796,10 +797,14 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
|||||||
}
|
}
|
||||||
void persistShipDefault("merge");
|
void persistShipDefault("merge");
|
||||||
setActionError(null);
|
setActionError(null);
|
||||||
void runMergeBranch({ serverId, cwd, baseRef }).catch((err) => {
|
void runMergeBranch({ serverId, cwd, baseRef })
|
||||||
const message = err instanceof Error ? err.message : "Failed to merge";
|
.then(() => {
|
||||||
setActionError(message);
|
setPostShipArchiveSuggested(true);
|
||||||
});
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
const message = err instanceof Error ? err.message : "Failed to merge";
|
||||||
|
setActionError(message);
|
||||||
|
});
|
||||||
}, [baseRef, persistShipDefault, runMergeBranch, serverId, cwd]);
|
}, [baseRef, persistShipDefault, runMergeBranch, serverId, cwd]);
|
||||||
|
|
||||||
const handleMergeFromBase = useCallback(() => {
|
const handleMergeFromBase = useCallback(() => {
|
||||||
@@ -821,15 +826,16 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setActionError(null);
|
setActionError(null);
|
||||||
|
const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null);
|
||||||
void runArchiveWorktree({ serverId, cwd, worktreePath })
|
void runArchiveWorktree({ serverId, cwd, worktreePath })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
router.replace(buildHostAgentDraftRoute(serverId) as any);
|
router.replace(buildNewAgentRoute(serverId, targetWorkingDir) as any);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
const message = err instanceof Error ? err.message : "Failed to archive worktree";
|
const message = err instanceof Error ? err.message : "Failed to archive worktree";
|
||||||
setActionError(message);
|
setActionError(message);
|
||||||
});
|
});
|
||||||
}, [runArchiveWorktree, router, serverId, cwd, status?.cwd]);
|
}, [runArchiveWorktree, router, serverId, cwd, status]);
|
||||||
|
|
||||||
const renderFlatItem = useCallback(
|
const renderFlatItem = useCallback(
|
||||||
({ item }: { item: DiffFlatItem }) => {
|
({ item }: { item: DiffFlatItem }) => {
|
||||||
@@ -887,12 +893,27 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
|||||||
? undefined
|
? undefined
|
||||||
: `${branchLabel} -> ${baseRefLabel}`;
|
: `${branchLabel} -> ${baseRefLabel}`;
|
||||||
}, [baseRefLabel, branchLabel]);
|
}, [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 commitDisabled = actionsDisabled || commitStatus === "pending";
|
||||||
const prDisabled = actionsDisabled || prCreateStatus === "pending";
|
const prDisabled = actionsDisabled || prCreateStatus === "pending";
|
||||||
const mergeDisabled =
|
const mergeDisabled =
|
||||||
actionsDisabled || mergeStatus === "pending" || hasUncommittedChanges || !baseRef;
|
actionsDisabled || mergeStatus === "pending" || hasUncommittedChanges || !baseRef;
|
||||||
const mergeFromBaseDisabled =
|
const mergeFromBaseDisabled =
|
||||||
actionsDisabled || mergeFromBaseStatus === "pending" || hasUncommittedChanges || !baseRef;
|
actionsDisabled ||
|
||||||
|
mergeFromBaseStatus === "pending" ||
|
||||||
|
hasUncommittedChanges ||
|
||||||
|
!baseRef ||
|
||||||
|
(isOnBaseBranch && !hasRemote);
|
||||||
const pushDisabled =
|
const pushDisabled =
|
||||||
actionsDisabled || pushStatus === "pending" || !(gitStatus?.hasRemote ?? false);
|
actionsDisabled || pushStatus === "pending" || !(gitStatus?.hasRemote ?? false);
|
||||||
const archiveDisabled =
|
const archiveDisabled =
|
||||||
@@ -969,11 +990,9 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasPullRequest = Boolean(prStatus?.url);
|
useEffect(() => {
|
||||||
const hasRemote = gitStatus?.hasRemote ?? false;
|
setPostShipArchiveSuggested(false);
|
||||||
const isPaseoOwnedWorktree = gitStatus?.isPaseoOwnedWorktree ?? false;
|
}, [cwd]);
|
||||||
const currentBranch = gitStatus?.currentBranch;
|
|
||||||
const isOnBaseBranch = currentBranch === baseRefLabel;
|
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// Git Actions (Data-Oriented)
|
// Git Actions (Data-Oriented)
|
||||||
@@ -1063,16 +1082,21 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update from base - only when not on base branch
|
// Update/sync from base
|
||||||
if (!isOnBaseBranch) {
|
if (!isOnBaseBranch || hasRemote) {
|
||||||
allActions.set("merge-from-base", {
|
allActions.set("merge-from-base", {
|
||||||
id: "merge-from-base",
|
id: "merge-from-base",
|
||||||
label: `Update from ${baseRefLabel}`,
|
label: isOnBaseBranch ? `Sync ${baseRefLabel} from origin` : `Update from ${baseRefLabel}`,
|
||||||
pendingLabel: "Updating...",
|
pendingLabel: "Updating...",
|
||||||
successLabel: "Updated",
|
successLabel: "Updated",
|
||||||
disabled: mergeFromBaseDisabled,
|
disabled: mergeFromBaseDisabled,
|
||||||
status: mergeFromBaseStatus,
|
status: mergeFromBaseStatus,
|
||||||
description: hasUncommittedChanges ? "Requires clean working tree" : undefined,
|
description:
|
||||||
|
hasUncommittedChanges
|
||||||
|
? "Requires clean working tree"
|
||||||
|
: isOnBaseBranch && !hasRemote
|
||||||
|
? "No remote configured"
|
||||||
|
: undefined,
|
||||||
icon: <RefreshCcw size={16} color={theme.colors.foregroundMuted} />,
|
icon: <RefreshCcw size={16} color={theme.colors.foregroundMuted} />,
|
||||||
handler: handleMergeFromBase,
|
handler: handleMergeFromBase,
|
||||||
});
|
});
|
||||||
@@ -1095,8 +1119,12 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
|||||||
// Select primary action (priority rules)
|
// Select primary action (priority rules)
|
||||||
let primaryActionId: GitActionId | null = null;
|
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
|
// Rule 1: Uncommitted changes → Commit
|
||||||
if (hasUncommittedChanges) {
|
else if (hasUncommittedChanges) {
|
||||||
primaryActionId = "commit";
|
primaryActionId = "commit";
|
||||||
}
|
}
|
||||||
// Rule 2: Ahead of origin → Push
|
// Rule 2: Ahead of origin → Push
|
||||||
@@ -1107,7 +1135,11 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
|||||||
else if (hasPullRequest) {
|
else if (hasPullRequest) {
|
||||||
primaryActionId = "view-pr";
|
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) {
|
else if (aheadCount > 0) {
|
||||||
const preferred: GitActionId = shipDefault === "merge" ? "merge-branch" : "create-pr";
|
const preferred: GitActionId = shipDefault === "merge" ? "merge-branch" : "create-pr";
|
||||||
const fallback: GitActionId = shipDefault === "merge" ? "create-pr" : "merge-branch";
|
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;
|
const primary = primaryActionId ? allActions.get(primaryActionId) ?? null : null;
|
||||||
|
|
||||||
// Secondary actions: ship-related + merge from base + push (excluding primary)
|
// 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
|
const secondary = secondaryIds
|
||||||
.filter(id => id !== primaryActionId && allActions.has(id))
|
.filter(id => id !== primaryActionId && allActions.has(id))
|
||||||
.map(id => allActions.get(id)!);
|
.map(id => allActions.get(id)!);
|
||||||
|
|
||||||
// Menu actions: archive worktree only
|
// Menu actions: none for now (all actionable items are in primary/secondary)
|
||||||
const menu = allActions.has("archive-worktree")
|
const menu: GitAction[] = [];
|
||||||
? [allActions.get("archive-worktree")!]
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return { primary, secondary, menu };
|
return { primary, secondary, menu };
|
||||||
}, [
|
}, [
|
||||||
isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isOnBaseBranch, githubFeaturesEnabled,
|
isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isOnBaseBranch, githubFeaturesEnabled,
|
||||||
hasUncommittedChanges, aheadOfOrigin, shipDefault, baseRefLabel,
|
hasUncommittedChanges, aheadOfOrigin, shipDefault, baseRefLabel, shouldPromoteArchive,
|
||||||
commitDisabled, pushDisabled, prDisabled, mergeDisabled, mergeFromBaseDisabled, archiveDisabled,
|
commitDisabled, pushDisabled, prDisabled, mergeDisabled, mergeFromBaseDisabled, archiveDisabled,
|
||||||
commitStatus, pushStatus, prCreateStatus, mergeStatus, mergeFromBaseStatus, archiveStatus,
|
commitStatus, pushStatus, prCreateStatus, mergeStatus, mergeFromBaseStatus, archiveStatus,
|
||||||
handleCommit, handlePush, handleCreatePr, handleMergeBranch, handleMergeFromBase, handleArchiveWorktree,
|
handleCommit, handlePush, handleCreatePr, handleMergeBranch, handleMergeFromBase, handleArchiveWorktree,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
|
import { getTauri } from "@/utils/tauri";
|
||||||
|
|
||||||
export const FOOTER_HEIGHT = 75;
|
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)
|
// Check if running in Tauri desktop app (any OS)
|
||||||
function isTauri(): boolean {
|
function isTauri(): boolean {
|
||||||
if (Platform.OS !== "web") return false;
|
if (Platform.OS !== "web") return false;
|
||||||
if (typeof window === "undefined") return false;
|
return getTauri() !== null;
|
||||||
return "__TAURI__" in window;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if running in Tauri desktop app on macOS
|
// Check if running in Tauri desktop app on macOS
|
||||||
function isTauriMac(): boolean {
|
function isTauriMac(): boolean {
|
||||||
if (Platform.OS !== "web") return false;
|
if (Platform.OS !== "web") return false;
|
||||||
if (typeof window === "undefined") 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
|
// Check for macOS via user agent
|
||||||
const ua = navigator.userAgent;
|
const ua = navigator.userAgent;
|
||||||
return ua.includes("Mac OS") || ua.includes("Macintosh");
|
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 _isTauriMacCached: boolean | null = null;
|
||||||
let _isTauriCached: boolean | null = null;
|
let _isTauriCached: boolean | null = null;
|
||||||
|
|
||||||
|
|||||||
@@ -351,6 +351,15 @@ export function SessionProvider({
|
|||||||
const isActive = appState ? appState === "active" : true;
|
const isActive = appState ? appState === "active" : true;
|
||||||
const isAwayFromAgent = !isActive || focusedAgentId !== params.agentId;
|
const isAwayFromAgent = !isActive || focusedAgentId !== params.agentId;
|
||||||
if (!isAwayFromAgent) {
|
if (!isAwayFromAgent) {
|
||||||
|
console.log(
|
||||||
|
"[OSNotifications] Skipping attention notification: user already focused on agent",
|
||||||
|
{
|
||||||
|
agentId: params.agentId,
|
||||||
|
reason: params.reason,
|
||||||
|
appState,
|
||||||
|
focusedAgentId,
|
||||||
|
}
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { AttemptCancelledError, AttemptGuard } from "@/utils/attempt-guard";
|
import { AttemptCancelledError, AttemptGuard } from "@/utils/attempt-guard";
|
||||||
|
import { getTauri } from "@/utils/tauri";
|
||||||
|
|
||||||
export interface AudioCaptureConfig {
|
export interface AudioCaptureConfig {
|
||||||
sampleRate?: number;
|
sampleRate?: number;
|
||||||
@@ -156,16 +157,33 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
|
|||||||
: true;
|
: true;
|
||||||
const currentOrigin =
|
const currentOrigin =
|
||||||
typeof window !== "undefined" && window.location ? window.location.origin : "unknown";
|
typeof window !== "undefined" && window.location ? window.location.origin : "unknown";
|
||||||
|
const isTauri = getTauri() !== null;
|
||||||
|
|
||||||
if (missingNavigator) {
|
if (missingNavigator) {
|
||||||
throw new Error("Microphone capture is not supported in this environment");
|
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(
|
throw new Error(
|
||||||
`Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}`
|
`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 options = configRef.current;
|
||||||
const constraints: MediaStreamConstraints = {
|
const constraints: MediaStreamConstraints = {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { getTauri } from "@/utils/tauri";
|
||||||
|
|
||||||
import type { DictationAudioSource, DictationAudioSourceConfig } from "./use-dictation-audio-source.types";
|
import type { DictationAudioSource, DictationAudioSourceConfig } from "./use-dictation-audio-source.types";
|
||||||
|
|
||||||
@@ -148,13 +149,29 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
|
|||||||
: true;
|
: true;
|
||||||
const currentOrigin =
|
const currentOrigin =
|
||||||
typeof window !== "undefined" && window.location ? window.location.origin : "unknown";
|
typeof window !== "undefined" && window.location ? window.location.origin : "unknown";
|
||||||
|
const isTauri = getTauri() !== null;
|
||||||
|
|
||||||
if (missingNavigator) {
|
if (missingNavigator) {
|
||||||
throw new Error("Microphone capture is not supported in this environment");
|
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}`);
|
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();
|
const AudioContextCtor = getAudioContextCtor();
|
||||||
if (!AudioContextCtor) {
|
if (!AudioContextCtor) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { getIsTauriMac } from "@/constants/layout";
|
import { getIsTauriMac } from "@/constants/layout";
|
||||||
import { useAggregatedAgents } from "./use-aggregated-agents";
|
import { useAggregatedAgents } from "./use-aggregated-agents";
|
||||||
|
import { getCurrentTauriWindow } from "@/utils/tauri";
|
||||||
|
|
||||||
type FaviconStatus = "none" | "running" | "attention";
|
type FaviconStatus = "none" | "running" | "attention";
|
||||||
type ColorScheme = "dark" | "light";
|
type ColorScheme = "dark" | "light";
|
||||||
@@ -90,9 +91,9 @@ function getSystemColorScheme(): ColorScheme {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function updateMacDockBadge(count?: number) {
|
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") {
|
if (!tauriWindow || typeof tauriWindow.setBadgeCount !== "function") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import type { ImageAttachment } from "@/components/message-input";
|
import type { ImageAttachment } from "@/components/message-input";
|
||||||
|
import { getCurrentTauriWindow, getTauri } from "@/utils/tauri";
|
||||||
|
|
||||||
interface UseFileDropZoneOptions {
|
interface UseFileDropZoneOptions {
|
||||||
onFilesDropped: (files: ImageAttachment[]) => void;
|
onFilesDropped: (files: ImageAttachment[]) => void;
|
||||||
@@ -52,10 +53,6 @@ function isImageFile(file: File): boolean {
|
|||||||
return file.type.startsWith("image/");
|
return file.type.startsWith("image/");
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTauriEnvironment(): boolean {
|
|
||||||
return typeof window !== "undefined" && (window as any).__TAURI__ !== undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFileExtension(path: string): string {
|
function getFileExtension(path: string): string {
|
||||||
const normalizedPath = path.split("#", 1)[0]?.split("?", 1)[0] ?? path;
|
const normalizedPath = path.split("#", 1)[0]?.split("?", 1)[0] ?? path;
|
||||||
const extensionIndex = normalizedPath.lastIndexOf(".");
|
const extensionIndex = normalizedPath.lastIndexOf(".");
|
||||||
@@ -72,7 +69,7 @@ function isImagePath(path: string): boolean {
|
|||||||
function filePathToImageAttachment(path: string): ImageAttachment {
|
function filePathToImageAttachment(path: string): ImageAttachment {
|
||||||
const extension = getFileExtension(path);
|
const extension = getFileExtension(path);
|
||||||
const mimeType = IMAGE_MIME_BY_EXTENSION[extension] ?? "image/jpeg";
|
const mimeType = IMAGE_MIME_BY_EXTENSION[extension] ?? "image/jpeg";
|
||||||
const convertFileSrc = (window as any).__TAURI__?.core?.convertFileSrc;
|
const convertFileSrc = getTauri()?.core?.convertFileSrc;
|
||||||
const uri =
|
const uri =
|
||||||
typeof convertFileSrc === "function" ? convertFileSrc(path) : path;
|
typeof convertFileSrc === "function" ? convertFileSrc(path) : path;
|
||||||
|
|
||||||
@@ -145,11 +142,11 @@ export function useFileDropZone({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function setupTauriDragDrop(): Promise<boolean> {
|
async function setupTauriDragDrop(): Promise<boolean> {
|
||||||
if (!isTauriEnvironment()) {
|
if (getTauri() === null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tauriWindow = (window as any).__TAURI__?.window?.getCurrentWindow?.();
|
const tauriWindow = getCurrentTauriWindow();
|
||||||
if (!tauriWindow || typeof tauriWindow.onDragDropEvent !== "function") {
|
if (!tauriWindow || typeof tauriWindow.onDragDropEvent !== "function") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import { SpeechSegmenter } from "@/voice/speech-segmenter";
|
import { SpeechSegmenter } from "@/voice/speech-segmenter";
|
||||||
|
import { getTauri } from "@/utils/tauri";
|
||||||
|
|
||||||
export interface SpeechmaticsAudioConfig {
|
export interface SpeechmaticsAudioConfig {
|
||||||
onAudioSegment?: (segment: { audioData: string; isLast: boolean }) => void;
|
onAudioSegment?: (segment: { audioData: string; isLast: boolean }) => void;
|
||||||
@@ -245,14 +246,30 @@ export function useSpeechmaticsAudio(config: SpeechmaticsAudioConfig): Speechmat
|
|||||||
? window.isSecureContext
|
? window.isSecureContext
|
||||||
: true;
|
: true;
|
||||||
const currentOrigin = typeof window !== "undefined" && window.location ? window.location.origin : "unknown";
|
const currentOrigin = typeof window !== "undefined" && window.location ? window.location.origin : "unknown";
|
||||||
|
const isTauri = getTauri() !== null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (missingNavigator) {
|
if (missingNavigator) {
|
||||||
throw new Error("Microphone capture is not supported in this environment");
|
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}`);
|
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();
|
const AudioContextCtor = getAudioContextCtor();
|
||||||
if (!AudioContextCtor) {
|
if (!AudioContextCtor) {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import Constants from "expo-constants";
|
|||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { useQueries } from "@tanstack/react-query";
|
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 { useAppSettings, type AppSettings } from "@/hooks/use-settings";
|
||||||
import { useDaemonRegistry, type HostProfile, type HostConnection } from "@/contexts/daemon-registry-context";
|
import { useDaemonRegistry, type HostProfile, type HostConnection } from "@/contexts/daemon-registry-context";
|
||||||
import { useDaemonConnections, type ActiveConnection, type ConnectionStatus } from "@/contexts/daemon-connections-context";
|
import { useDaemonConnections, type ActiveConnection, type ConnectionStatus } from "@/contexts/daemon-connections-context";
|
||||||
@@ -35,6 +35,14 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
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) =>
|
const delay = (ms: number) =>
|
||||||
new Promise<void>((resolve) => {
|
new Promise<void>((resolve) => {
|
||||||
@@ -76,7 +84,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
letterSpacing: 0.6,
|
|
||||||
marginBottom: theme.spacing[3],
|
marginBottom: theme.spacing[3],
|
||||||
marginLeft: theme.spacing[1],
|
marginLeft: theme.spacing[1],
|
||||||
},
|
},
|
||||||
@@ -84,7 +91,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
letterSpacing: 0.4,
|
|
||||||
marginBottom: theme.spacing[2],
|
marginBottom: theme.spacing[2],
|
||||||
},
|
},
|
||||||
input: {
|
input: {
|
||||||
@@ -301,39 +307,58 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
},
|
},
|
||||||
audioRowDescription: {
|
permissionSectionHeader: {
|
||||||
color: theme.colors.foregroundMuted,
|
flexDirection: "row",
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
marginTop: 2,
|
|
||||||
},
|
|
||||||
// Footer
|
|
||||||
footer: {
|
|
||||||
borderTopWidth: 1,
|
|
||||||
borderTopColor: theme.colors.border,
|
|
||||||
paddingTop: theme.spacing[6],
|
|
||||||
paddingBottom: theme.spacing[4],
|
|
||||||
alignItems: "center",
|
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",
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
permissionRefreshButtonDisabled: {
|
||||||
|
opacity: theme.opacity[50],
|
||||||
|
},
|
||||||
|
permissionRowActions: {
|
||||||
|
alignItems: "flex-end",
|
||||||
gap: theme.spacing[1],
|
gap: theme.spacing[1],
|
||||||
},
|
},
|
||||||
footerText: {
|
permissionStatusPill: {
|
||||||
color: theme.colors.foregroundMuted,
|
flexDirection: "row",
|
||||||
fontSize: theme.fontSize.sm,
|
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,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
|
maxWidth: 220,
|
||||||
|
textAlign: "right",
|
||||||
},
|
},
|
||||||
resetButton: {
|
aboutValue: {
|
||||||
paddingVertical: theme.spacing[2],
|
color: theme.colors.foregroundMuted,
|
||||||
paddingHorizontal: theme.spacing[3],
|
|
||||||
},
|
|
||||||
resetButtonText: {
|
|
||||||
color: theme.colors.palette.red[500],
|
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
|
fontWeight: theme.fontWeight.normal,
|
||||||
},
|
},
|
||||||
// Empty state
|
// Empty state
|
||||||
emptyCard: {
|
emptyCard: {
|
||||||
@@ -352,26 +377,27 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
// Theme toggle
|
// Theme toggle
|
||||||
themeToggleContainer: {
|
themeToggleContainer: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface3,
|
||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.md,
|
||||||
padding: 4,
|
borderWidth: 1,
|
||||||
gap: 4,
|
borderColor: theme.colors.border,
|
||||||
|
padding: 2,
|
||||||
|
gap: 2,
|
||||||
},
|
},
|
||||||
themeToggleButton: {
|
themeToggleButton: {
|
||||||
flex: 1,
|
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[1],
|
||||||
paddingVertical: theme.spacing[3],
|
paddingVertical: theme.spacing[2],
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[2],
|
||||||
borderRadius: theme.borderRadius.md,
|
borderRadius: theme.borderRadius.sm,
|
||||||
},
|
},
|
||||||
themeToggleButtonActive: {
|
themeToggleButtonActive: {
|
||||||
backgroundColor: theme.colors.surface3,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
themeToggleText: {
|
themeToggleText: {
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.xs,
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
},
|
},
|
||||||
@@ -417,7 +443,7 @@ export default function SettingsScreen() {
|
|||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const params = useLocalSearchParams<{ editHost?: string; serverId?: string }>();
|
const params = useLocalSearchParams<{ editHost?: string; serverId?: string }>();
|
||||||
const routeServerId = typeof params.serverId === "string" ? params.serverId.trim() : "";
|
const routeServerId = typeof params.serverId === "string" ? params.serverId.trim() : "";
|
||||||
const { settings, isLoading: settingsLoading, updateSettings, resetSettings } = useAppSettings();
|
const { settings, isLoading: settingsLoading, updateSettings } = useAppSettings();
|
||||||
const {
|
const {
|
||||||
daemons,
|
daemons,
|
||||||
isLoading: daemonLoading,
|
isLoading: daemonLoading,
|
||||||
@@ -436,7 +462,14 @@ export default function SettingsScreen() {
|
|||||||
const [isRemovingHost, setIsRemovingHost] = useState(false);
|
const [isRemovingHost, setIsRemovingHost] = useState(false);
|
||||||
const [editingDaemon, setEditingDaemon] = useState<HostProfile | null>(null);
|
const [editingDaemon, setEditingDaemon] = useState<HostProfile | null>(null);
|
||||||
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
||||||
|
const [desktopPermissionSnapshot, setDesktopPermissionSnapshot] =
|
||||||
|
useState<DesktopPermissionSnapshot | null>(null);
|
||||||
|
const [isRefreshingDesktopPermissions, setIsRefreshingDesktopPermissions] =
|
||||||
|
useState(false);
|
||||||
|
const [requestingDesktopPermission, setRequestingDesktopPermission] =
|
||||||
|
useState<DesktopPermissionKind | null>(null);
|
||||||
const isLoading = settingsLoading || daemonLoading;
|
const isLoading = settingsLoading || daemonLoading;
|
||||||
|
const showDesktopPermissionSection = shouldShowDesktopPermissionSection();
|
||||||
const isMountedRef = useRef(true);
|
const isMountedRef = useRef(true);
|
||||||
const lastHandledEditHostRef = useRef<string | null>(null);
|
const lastHandledEditHostRef = useRef<string | null>(null);
|
||||||
const appVersion = Constants.expoConfig?.version ?? (Constants as any).manifest?.version ?? "0.1.0";
|
const appVersion = Constants.expoConfig?.version ?? (Constants as any).manifest?.version ?? "0.1.0";
|
||||||
@@ -534,6 +567,73 @@ export default function SettingsScreen() {
|
|||||||
pendingEditReopenServerId,
|
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) => {
|
const handleSaveEditDaemon = useCallback(async (nextLabelRaw: string) => {
|
||||||
if (!editingServerId) return;
|
if (!editingServerId) return;
|
||||||
if (isSavingEdit) return;
|
if (isSavingEdit) return;
|
||||||
@@ -590,34 +690,6 @@ export default function SettingsScreen() {
|
|||||||
[updateSettings]
|
[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 =
|
const restartConfirmationMessage =
|
||||||
"This will immediately stop the Paseo daemon process. The app will disconnect until it restarts.";
|
"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 */}
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<Text style={styles.sectionTitle}>Appearance</Text>
|
<Text style={styles.sectionTitle}>Appearance</Text>
|
||||||
<View style={styles.themeToggleContainer}>
|
<View style={styles.audioCard}>
|
||||||
<Pressable
|
<View style={styles.audioRow}>
|
||||||
style={[
|
<View style={styles.audioRowContent}>
|
||||||
styles.themeToggleButton,
|
<Text style={styles.audioRowTitle}>Theme</Text>
|
||||||
settings.theme === "light" && styles.themeToggleButtonActive,
|
</View>
|
||||||
]}
|
<View style={styles.themeToggleContainer}>
|
||||||
onPress={() => handleThemeChange("light")}
|
<Pressable
|
||||||
>
|
style={[
|
||||||
<Sun size={16} color={settings.theme === "light" ? defaultTheme.colors.foreground : defaultTheme.colors.mutedForeground} />
|
styles.themeToggleButton,
|
||||||
<Text style={[styles.themeToggleText, settings.theme === "light" && styles.themeToggleTextActive]}>
|
settings.theme === "light" && styles.themeToggleButtonActive,
|
||||||
Light
|
]}
|
||||||
</Text>
|
onPress={() => handleThemeChange("light")}
|
||||||
</Pressable>
|
>
|
||||||
<Pressable
|
<Sun size={14} color={settings.theme === "light" ? defaultTheme.colors.foreground : defaultTheme.colors.mutedForeground} />
|
||||||
style={[
|
<Text style={[styles.themeToggleText, settings.theme === "light" && styles.themeToggleTextActive]}>
|
||||||
styles.themeToggleButton,
|
Light
|
||||||
settings.theme === "dark" && styles.themeToggleButtonActive,
|
</Text>
|
||||||
]}
|
</Pressable>
|
||||||
onPress={() => handleThemeChange("dark")}
|
<Pressable
|
||||||
>
|
style={[
|
||||||
<Moon size={16} color={settings.theme === "dark" ? defaultTheme.colors.foreground : defaultTheme.colors.mutedForeground} />
|
styles.themeToggleButton,
|
||||||
<Text style={[styles.themeToggleText, settings.theme === "dark" && styles.themeToggleTextActive]}>
|
settings.theme === "dark" && styles.themeToggleButtonActive,
|
||||||
Dark
|
]}
|
||||||
</Text>
|
onPress={() => handleThemeChange("dark")}
|
||||||
</Pressable>
|
>
|
||||||
<Pressable
|
<Moon size={14} color={settings.theme === "dark" ? defaultTheme.colors.foreground : defaultTheme.colors.mutedForeground} />
|
||||||
style={[
|
<Text style={[styles.themeToggleText, settings.theme === "dark" && styles.themeToggleTextActive]}>
|
||||||
styles.themeToggleButton,
|
Dark
|
||||||
settings.theme === "auto" && styles.themeToggleButtonActive,
|
</Text>
|
||||||
]}
|
</Pressable>
|
||||||
onPress={() => handleThemeChange("auto")}
|
<Pressable
|
||||||
>
|
style={[
|
||||||
<Monitor size={16} color={settings.theme === "auto" ? defaultTheme.colors.foreground : defaultTheme.colors.mutedForeground} />
|
styles.themeToggleButton,
|
||||||
<Text style={[styles.themeToggleText, settings.theme === "auto" && styles.themeToggleTextActive]}>
|
settings.theme === "auto" && styles.themeToggleButtonActive,
|
||||||
System
|
]}
|
||||||
</Text>
|
onPress={() => handleThemeChange("auto")}
|
||||||
</Pressable>
|
>
|
||||||
|
<Monitor size={14} color={settings.theme === "auto" ? defaultTheme.colors.foreground : defaultTheme.colors.mutedForeground} />
|
||||||
|
<Text style={[styles.themeToggleText, settings.theme === "auto" && styles.themeToggleTextActive]}>
|
||||||
|
System
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Footer */}
|
{showDesktopPermissionSection ? (
|
||||||
<View style={styles.footer}>
|
<View style={styles.section}>
|
||||||
<View style={styles.footerAppInfo}>
|
<View style={styles.permissionSectionHeader}>
|
||||||
<Text style={styles.footerText}>Paseo</Text>
|
<Text style={[styles.sectionTitle, { marginBottom: 0 }]}>
|
||||||
<Text style={styles.footerVersion}>Version {appVersion}</Text>
|
Desktop permissions
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.permissionRefreshButton,
|
||||||
|
(isRefreshingDesktopPermissions ||
|
||||||
|
requestingDesktopPermission !== null) &&
|
||||||
|
styles.permissionRefreshButtonDisabled,
|
||||||
|
pressed && { opacity: 0.85 },
|
||||||
|
]}
|
||||||
|
onPress={() => {
|
||||||
|
void refreshDesktopPermissions();
|
||||||
|
}}
|
||||||
|
disabled={
|
||||||
|
isRefreshingDesktopPermissions ||
|
||||||
|
requestingDesktopPermission !== null
|
||||||
|
}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Refresh desktop permissions"
|
||||||
|
>
|
||||||
|
<RotateCw size={16} color={theme.colors.foregroundMuted} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
<View style={styles.audioCard}>
|
||||||
|
<DesktopPermissionRow
|
||||||
|
title="Notifications"
|
||||||
|
status={desktopPermissionSnapshot?.notifications ?? null}
|
||||||
|
isRequesting={requestingDesktopPermission === "notifications"}
|
||||||
|
onRequest={() => {
|
||||||
|
void handleRequestDesktopPermission("notifications");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<DesktopPermissionRow
|
||||||
|
title="Microphone"
|
||||||
|
showBorder
|
||||||
|
status={desktopPermissionSnapshot?.microphone ?? null}
|
||||||
|
isRequesting={requestingDesktopPermission === "microphone"}
|
||||||
|
onRequest={() => {
|
||||||
|
void handleRequestDesktopPermission("microphone");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* About */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>About</Text>
|
||||||
|
<View style={styles.audioCard}>
|
||||||
|
<View style={styles.audioRow}>
|
||||||
|
<View style={styles.audioRowContent}>
|
||||||
|
<Text style={styles.audioRowTitle}>Version</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.aboutValue}>{appVersion}</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Pressable style={styles.resetButton} onPress={handleReset}>
|
|
||||||
<Text style={styles.resetButtonText}>Reset to defaults</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
@@ -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 (
|
||||||
|
<View style={[styles.audioRow, showBorder && styles.audioRowBorder]}>
|
||||||
|
<View style={styles.audioRowContent}>
|
||||||
|
<Text style={styles.audioRowTitle}>{title}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.permissionRowActions}>
|
||||||
|
{isGranted ? (
|
||||||
|
<View style={styles.permissionStatusPill}>
|
||||||
|
<Check size={14} color={theme.colors.foregroundMuted} />
|
||||||
|
<Text style={styles.permissionStatusText}>Granted</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onPress={onRequest}
|
||||||
|
disabled={isRequesting}
|
||||||
|
>
|
||||||
|
{isRequesting ? "Requesting..." : "Request"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{shouldShowDetail ? (
|
||||||
|
<Text style={styles.permissionDetailText}>{status?.detail}</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ConnectionRow({
|
function ConnectionRow({
|
||||||
connection,
|
connection,
|
||||||
latencyMs,
|
latencyMs,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Alert, Platform } from "react-native";
|
import { Alert, Platform } from "react-native";
|
||||||
|
import { getTauri, type TauriDialogAskOptions } from "@/utils/tauri";
|
||||||
|
|
||||||
export interface ConfirmDialogInput {
|
export interface ConfirmDialogInput {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -8,26 +9,6 @@ export interface ConfirmDialogInput {
|
|||||||
destructive?: boolean;
|
destructive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TauriDialogAskOptions {
|
|
||||||
title?: string;
|
|
||||||
okLabel?: string;
|
|
||||||
cancelLabel?: string;
|
|
||||||
kind?: "info" | "warning" | "error";
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TauriDialogApi {
|
|
||||||
ask?: (message: string, options?: TauriDialogAskOptions) => Promise<boolean>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TauriCoreApi {
|
|
||||||
invoke?: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TauriGlobalApi {
|
|
||||||
dialog?: TauriDialogApi;
|
|
||||||
core?: TauriCoreApi;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ConfirmButtonConfig {
|
interface ConfirmButtonConfig {
|
||||||
confirmLabel: string;
|
confirmLabel: string;
|
||||||
cancelLabel: string;
|
cancelLabel: string;
|
||||||
@@ -67,13 +48,11 @@ async function showNativeConfirmDialog(input: ConfirmDialogInput): Promise<boole
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTauriApi(): TauriGlobalApi | null {
|
function getTauriApi() {
|
||||||
if (Platform.OS !== "web") {
|
if (Platform.OS !== "web") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
return getTauri();
|
||||||
const tauriApi = (globalThis as { __TAURI__?: TauriGlobalApi }).__TAURI__;
|
|
||||||
return tauriApi ?? null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTauriAskOptions(input: ConfirmDialogInput): TauriDialogAskOptions {
|
function buildTauriAskOptions(input: ConfirmDialogInput): TauriDialogAskOptions {
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
type MockPlatform = 'web' | 'ios' | 'android'
|
||||||
|
|
||||||
|
type GlobalSnapshot = {
|
||||||
|
Notification: unknown
|
||||||
|
__TAURI__: unknown
|
||||||
|
navigatorDescriptor?: PropertyDescriptor
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalGlobals: GlobalSnapshot = {
|
||||||
|
Notification: (globalThis as { Notification?: unknown }).Notification,
|
||||||
|
__TAURI__: (globalThis as { __TAURI__?: unknown }).__TAURI__,
|
||||||
|
navigatorDescriptor: Object.getOwnPropertyDescriptor(globalThis, 'navigator'),
|
||||||
|
}
|
||||||
|
|
||||||
|
function setNavigator(value: unknown): void {
|
||||||
|
Object.defineProperty(globalThis, 'navigator', {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreGlobals(): void {
|
||||||
|
;(globalThis as { Notification?: unknown }).Notification = originalGlobals.Notification
|
||||||
|
;(globalThis as { __TAURI__?: unknown }).__TAURI__ = originalGlobals.__TAURI__
|
||||||
|
|
||||||
|
if (originalGlobals.navigatorDescriptor) {
|
||||||
|
Object.defineProperty(globalThis, 'navigator', originalGlobals.navigatorDescriptor)
|
||||||
|
} else {
|
||||||
|
delete (globalThis as { navigator?: unknown }).navigator
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadModuleForPlatform(platform: MockPlatform) {
|
||||||
|
vi.resetModules()
|
||||||
|
vi.doMock('react-native', () => ({ 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaStreamTrackLike = {
|
||||||
|
stop?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaStreamLike = {
|
||||||
|
getTracks?: () => MediaStreamTrackLike[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type NavigatorLike = {
|
||||||
|
mediaDevices?: {
|
||||||
|
getUserMedia?: (constraints: { audio: boolean }) => Promise<MediaStreamLike>
|
||||||
|
}
|
||||||
|
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<string, unknown> {
|
||||||
|
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<DesktopPermissionStatus> {
|
||||||
|
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<DesktopPermissionStatus> {
|
||||||
|
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<DesktopPermissionStatus> {
|
||||||
|
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<DesktopPermissionStatus> {
|
||||||
|
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<DesktopPermissionStatus> {
|
||||||
|
if (input.kind === 'notifications') {
|
||||||
|
return await requestNotificationPermissionStatus()
|
||||||
|
}
|
||||||
|
return await requestMicrophonePermissionStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDesktopPermissionSnapshot(): Promise<DesktopPermissionSnapshot> {
|
||||||
|
const [notifications, microphone] = await Promise.all([
|
||||||
|
getNotificationPermissionStatus(),
|
||||||
|
getMicrophonePermissionStatus(),
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
checkedAt: Date.now(),
|
||||||
|
notifications,
|
||||||
|
microphone,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,23 +1,13 @@
|
|||||||
import * as Linking from "expo-linking";
|
import * as Linking from "expo-linking";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { getIsTauri } from "@/constants/layout";
|
import { getTauri } from "@/utils/tauri";
|
||||||
|
|
||||||
interface TauriWindowWithOpener {
|
|
||||||
__TAURI__?: {
|
|
||||||
opener?: {
|
|
||||||
openUrl?: (url: string) => Promise<void>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function openExternalUrl(url: string): Promise<void> {
|
export async function openExternalUrl(url: string): Promise<void> {
|
||||||
if (Platform.OS === "web") {
|
if (Platform.OS === "web") {
|
||||||
if (typeof window !== "undefined" && getIsTauri()) {
|
const opener = getTauri()?.opener?.openUrl;
|
||||||
const opener = (window as TauriWindowWithOpener).__TAURI__?.opener?.openUrl;
|
if (typeof opener === "function") {
|
||||||
if (typeof opener === "function") {
|
await opener(url);
|
||||||
await opener(url);
|
return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
window.open(url, "_blank", "noopener,noreferrer");
|
window.open(url, "_blank", "noopener,noreferrer");
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type GlobalSnapshot = {
|
|||||||
dispatchEvent: unknown;
|
dispatchEvent: unknown;
|
||||||
focus: unknown;
|
focus: unknown;
|
||||||
location: unknown;
|
location: unknown;
|
||||||
|
__TAURI__: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
const originalGlobals: GlobalSnapshot = {
|
const originalGlobals: GlobalSnapshot = {
|
||||||
@@ -26,6 +27,7 @@ const originalGlobals: GlobalSnapshot = {
|
|||||||
dispatchEvent: (globalThis as { dispatchEvent?: unknown }).dispatchEvent,
|
dispatchEvent: (globalThis as { dispatchEvent?: unknown }).dispatchEvent,
|
||||||
focus: (globalThis as { focus?: unknown }).focus,
|
focus: (globalThis as { focus?: unknown }).focus,
|
||||||
location: (globalThis as { location?: unknown }).location,
|
location: (globalThis as { location?: unknown }).location,
|
||||||
|
__TAURI__: (globalThis as { __TAURI__?: unknown }).__TAURI__,
|
||||||
};
|
};
|
||||||
|
|
||||||
async function loadModuleForPlatform(platform: "web" | "ios" | "android") {
|
async function loadModuleForPlatform(platform: "web" | "ios" | "android") {
|
||||||
@@ -40,6 +42,7 @@ function restoreGlobals(): void {
|
|||||||
(globalThis as { dispatchEvent?: unknown }).dispatchEvent = originalGlobals.dispatchEvent;
|
(globalThis as { dispatchEvent?: unknown }).dispatchEvent = originalGlobals.dispatchEvent;
|
||||||
(globalThis as { focus?: unknown }).focus = originalGlobals.focus;
|
(globalThis as { focus?: unknown }).focus = originalGlobals.focus;
|
||||||
(globalThis as { location?: unknown }).location = originalGlobals.location;
|
(globalThis as { location?: unknown }).location = originalGlobals.location;
|
||||||
|
(globalThis as { __TAURI__?: unknown }).__TAURI__ = originalGlobals.__TAURI__;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("sendOsNotification", () => {
|
describe("sendOsNotification", () => {
|
||||||
@@ -163,4 +166,33 @@ describe("sendOsNotification", () => {
|
|||||||
|
|
||||||
expect(assign).toHaveBeenCalledWith("/h/srv%20with%20space/agent/agent%2F1");
|
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",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { buildNotificationRoute } from "./notification-routing";
|
import { buildNotificationRoute } from "./notification-routing";
|
||||||
|
import { getTauri, type TauriNotificationApi } from "@/utils/tauri";
|
||||||
|
|
||||||
type OsNotificationPayload = {
|
type OsNotificationPayload = {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -21,6 +22,13 @@ export const WEB_NOTIFICATION_CLICK_EVENT = "paseo:web-notification-click";
|
|||||||
|
|
||||||
let permissionRequest: Promise<boolean> | null = null;
|
let permissionRequest: Promise<boolean> | null = null;
|
||||||
|
|
||||||
|
function getTauriNotificationModule(): TauriNotificationApi | null {
|
||||||
|
if (Platform.OS !== "web") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return getTauri()?.notification ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
function getWebNotificationConstructor(): {
|
function getWebNotificationConstructor(): {
|
||||||
permission: string;
|
permission: string;
|
||||||
requestPermission?: () => Promise<string>;
|
requestPermission?: () => Promise<string>;
|
||||||
@@ -54,6 +62,86 @@ async function ensureNotificationPermission(): Promise<boolean> {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function ensureOsNotificationPermission(): Promise<boolean> {
|
||||||
|
if (Platform.OS !== "web") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tauriNotification = getTauriNotificationModule();
|
||||||
|
if (tauriNotification) {
|
||||||
|
return await ensureTauriNotificationPermission(tauriNotification);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await ensureNotificationPermission();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureTauriNotificationPermission(
|
||||||
|
notificationModule: TauriNotificationApi
|
||||||
|
): Promise<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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 {
|
function dispatchWebNotificationClick(detail: WebNotificationClickDetail): boolean {
|
||||||
const dispatch = (globalThis as { dispatchEvent?: (event: Event) => boolean }).dispatchEvent;
|
const dispatch = (globalThis as { dispatchEvent?: (event: Event) => boolean }).dispatchEvent;
|
||||||
const CustomEventConstructor = (globalThis as { CustomEvent?: typeof CustomEvent })
|
const CustomEventConstructor = (globalThis as { CustomEvent?: typeof CustomEvent })
|
||||||
@@ -127,12 +215,28 @@ export async function sendOsNotification(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tauriNotification = getTauriNotificationModule();
|
||||||
|
if (tauriNotification) {
|
||||||
|
console.log("[OSNotifications] Using Tauri notification module");
|
||||||
|
return await sendTauriNotification(payload, tauriNotification);
|
||||||
|
}
|
||||||
|
|
||||||
const NotificationConstructor = getWebNotificationConstructor();
|
const NotificationConstructor = getWebNotificationConstructor();
|
||||||
if (!NotificationConstructor) {
|
if (!NotificationConstructor) {
|
||||||
|
console.log(
|
||||||
|
"[OSNotifications][Web] Notification constructor unavailable",
|
||||||
|
typeof window !== "undefined" && window.location
|
||||||
|
? window.location.origin
|
||||||
|
: "unknown-origin"
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const granted = await ensureNotificationPermission();
|
const granted = await ensureOsNotificationPermission();
|
||||||
if (!granted) {
|
if (!granted) {
|
||||||
|
console.log(
|
||||||
|
"[OSNotifications][Web] Permission not granted:",
|
||||||
|
NotificationConstructor.permission
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const notification = new NotificationConstructor(payload.title, {
|
const notification = new NotificationConstructor(payload.title, {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { DaemonTransport, DaemonTransportFactory } from "@server/client/daemon-client";
|
import type { DaemonTransport, DaemonTransportFactory } from "@server/client/daemon-client";
|
||||||
|
import { getTauri } from "@/utils/tauri";
|
||||||
|
|
||||||
type TauriWebSocketMessage =
|
type TauriWebSocketMessage =
|
||||||
| { type: "Text"; data: string }
|
| { type: "Text"; data: string }
|
||||||
@@ -29,15 +30,8 @@ function toTauriOutgoingMessage(
|
|||||||
return Array.from(data);
|
return Array.from(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTauriEnvironment(): boolean {
|
|
||||||
return typeof window !== "undefined" && (window as any).__TAURI__ !== undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTauriWebSocketModule(): TauriWebSocketModule | null {
|
function getTauriWebSocketModule(): TauriWebSocketModule | null {
|
||||||
if (!isTauriEnvironment()) {
|
const ws = getTauri()?.websocket;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const ws = (window as any).__TAURI__?.websocket;
|
|
||||||
if (ws && typeof ws.connect === "function") {
|
if (ws && typeof ws.connect === "function") {
|
||||||
return ws as TauriWebSocketModule;
|
return ws as TauriWebSocketModule;
|
||||||
}
|
}
|
||||||
@@ -45,7 +39,7 @@ function getTauriWebSocketModule(): TauriWebSocketModule | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createTauriWebSocketTransportFactory(): DaemonTransportFactory | null {
|
export function createTauriWebSocketTransportFactory(): DaemonTransportFactory | null {
|
||||||
if (!isTauriEnvironment()) {
|
if (getTauri() === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +116,7 @@ export function createTauriWebSocketTransportFactory(): DaemonTransportFactory |
|
|||||||
}
|
}
|
||||||
if (!module) {
|
if (!module) {
|
||||||
throw new Error(
|
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?"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { getIsTauriMac, TAURI_TRAFFIC_LIGHT_WIDTH, TAURI_TRAFFIC_LIGHT_HEIGHT } from "@/constants/layout";
|
import { getIsTauriMac, TAURI_TRAFFIC_LIGHT_WIDTH, TAURI_TRAFFIC_LIGHT_HEIGHT } from "@/constants/layout";
|
||||||
|
import { getCurrentTauriWindow, getTauri, isTauriEnvironment } from "@/utils/tauri";
|
||||||
|
|
||||||
let tauriWindow: any = null;
|
let tauriWindow: any = null;
|
||||||
|
|
||||||
// Runtime check for Tauri environment
|
|
||||||
function isTauriEnvironment(): boolean {
|
|
||||||
return typeof window !== "undefined" &&
|
|
||||||
(window as any).__TAURI__ !== undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getTauriWindow() {
|
async function getTauriWindow() {
|
||||||
if (tauriWindow) return tauriWindow;
|
if (tauriWindow) return tauriWindow;
|
||||||
|
|
||||||
@@ -19,15 +14,18 @@ async function getTauriWindow() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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
|
// We must use that here because importing `@tauri-apps/api/*` would be bundled into
|
||||||
// the native (Hermes) builds and break parsing/runtime on mobile.
|
// 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.
|
// Prefer the public global window module.
|
||||||
const windowModule = tauri?.window;
|
const publicWindow = getCurrentTauriWindow();
|
||||||
if (windowModule && typeof windowModule.getCurrentWindow === "function") {
|
if (publicWindow) {
|
||||||
tauriWindow = windowModule.getCurrentWindow();
|
tauriWindow = publicWindow;
|
||||||
return tauriWindow;
|
return tauriWindow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TauriCoreApi {
|
||||||
|
invoke?: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||||
|
convertFileSrc?: (path: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TauriEventApi {
|
||||||
|
listen?: (
|
||||||
|
event: string,
|
||||||
|
handler: (event: unknown) => void
|
||||||
|
) => Promise<() => void> | (() => void);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TauriWindowApi {
|
||||||
|
label?: string;
|
||||||
|
startDragging?: () => Promise<void>;
|
||||||
|
toggleMaximize?: () => Promise<void>;
|
||||||
|
isFullscreen?: () => Promise<boolean>;
|
||||||
|
onResized?: <TEvent = unknown>(
|
||||||
|
handler: (event: TEvent) => void
|
||||||
|
) => Promise<() => void> | (() => void);
|
||||||
|
setBadgeCount?: (count?: number) => Promise<void>;
|
||||||
|
onDragDropEvent?: <TEvent = unknown>(
|
||||||
|
handler: (event: TEvent) => void
|
||||||
|
) => Promise<() => void> | (() => void);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TauriWindowModule {
|
||||||
|
getCurrentWindow?: () => TauriWindowApi;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TauriOpenerApi {
|
||||||
|
openUrl?: (url: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TauriNotificationApi {
|
||||||
|
isPermissionGranted?: () => Promise<boolean>;
|
||||||
|
requestPermission?: () => Promise<TauriNotificationPermission>;
|
||||||
|
sendNotification?: (
|
||||||
|
payload: string | { title: string; body?: string }
|
||||||
|
) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TauriWebSocketApi {
|
||||||
|
connect?: (url: string, config?: unknown) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+69
-2
@@ -2028,6 +2028,18 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
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]]
|
[[package]]
|
||||||
name = "markup5ever"
|
name = "markup5ever"
|
||||||
version = "0.14.1"
|
version = "0.14.1"
|
||||||
@@ -2164,6 +2176,20 @@ version = "0.1.14"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
|
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]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -2512,7 +2538,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "paseo"
|
name = "paseo"
|
||||||
version = "0.1.0"
|
version = "0.1.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -2521,6 +2547,7 @@ dependencies = [
|
|||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-log",
|
"tauri-plugin-log",
|
||||||
|
"tauri-plugin-notification",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"tauri-plugin-websocket",
|
"tauri-plugin-websocket",
|
||||||
]
|
]
|
||||||
@@ -2708,7 +2735,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"indexmap 2.13.0",
|
"indexmap 2.13.0",
|
||||||
"quick-xml",
|
"quick-xml 0.38.4",
|
||||||
"serde",
|
"serde",
|
||||||
"time",
|
"time",
|
||||||
]
|
]
|
||||||
@@ -2858,6 +2885,15 @@ dependencies = [
|
|||||||
"syn 1.0.109",
|
"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]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.38.4"
|
version = "0.38.4"
|
||||||
@@ -4035,6 +4071,25 @@ dependencies = [
|
|||||||
"time",
|
"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]]
|
[[package]]
|
||||||
name = "tauri-plugin-opener"
|
name = "tauri-plugin-opener"
|
||||||
version = "2.5.3"
|
version = "2.5.3"
|
||||||
@@ -4178,6 +4233,18 @@ dependencies = [
|
|||||||
"toml 0.9.11+spec-1.1.0",
|
"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]]
|
[[package]]
|
||||||
name = "tempfile"
|
name = "tempfile"
|
||||||
version = "3.25.0"
|
version = "3.25.0"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "paseo"
|
name = "paseo"
|
||||||
version = "0.1.0"
|
version = "0.1.13"
|
||||||
description = "Paseo Desktop"
|
description = "Paseo Desktop"
|
||||||
authors = ["moboudra"]
|
authors = ["moboudra"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -28,5 +28,7 @@ log = "0.4"
|
|||||||
tauri = { version = "2.9.5", features = [] }
|
tauri = { version = "2.9.5", features = [] }
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
tauri-plugin-log = "2"
|
tauri-plugin-log = "2"
|
||||||
|
tauri-plugin-notification = "2"
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
tauri-plugin-websocket = "2"
|
tauri-plugin-websocket = "2"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.device.audio-input</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -7,9 +7,9 @@
|
|||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>Paseo</string>
|
<string>Paseo</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>0.1.0</string>
|
<string>0.1.13</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>0.1.0</string>
|
<string>0.1.13</string>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
<string>Paseo needs access to your microphone for voice dictation and voice mode.</string>
|
<string>Paseo needs access to your microphone for voice dictation and voice mode.</string>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"core:window:allow-set-badge-count",
|
"core:window:allow-set-badge-count",
|
||||||
"core:window:allow-toggle-maximize",
|
"core:window:allow-toggle-maximize",
|
||||||
"dialog:default",
|
"dialog:default",
|
||||||
|
"notification:default",
|
||||||
"opener:default",
|
"opener:default",
|
||||||
"websocket:default"
|
"websocket:default"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use tauri::menu::{Menu, MenuItemBuilder, MenuItemKind, PredefinedMenuItem, Submenu};
|
use tauri::menu::{Menu, MenuItemBuilder, MenuItemKind, PredefinedMenuItem, Submenu};
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
use tauri::menu::AboutMetadata;
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
use tauri::WebviewWindow;
|
use tauri::WebviewWindow;
|
||||||
|
|
||||||
@@ -20,6 +22,7 @@ fn set_zoom_factor(webview: &WebviewWindow, factor: f64) {
|
|||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.plugin(tauri_plugin_notification::init())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.plugin(tauri_plugin_websocket::init())
|
.plugin(tauri_plugin_websocket::init())
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
@@ -38,6 +41,30 @@ pub fn run() {
|
|||||||
// responder-chain shortcuts across the whole app.
|
// responder-chain shortcuts across the whole app.
|
||||||
let menu = Menu::default(app.handle())?;
|
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 <plist short> (<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")
|
let zoom_in = MenuItemBuilder::with_id("zoom_in", "Zoom In")
|
||||||
.accelerator("CmdOrCtrl+=")
|
.accelerator("CmdOrCtrl+=")
|
||||||
.build(app)?;
|
.build(app)?;
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
// `tauri dev` runs the raw executable (`target/debug/Paseo`) on macOS (not the .app bundle),
|
// `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
|
// so we must embed an Info.plist containing usage descriptions for WebKit media
|
||||||
// microphone access to trigger the OS permission prompt in dev.
|
// permission prompts in dev.
|
||||||
#[cfg(all(target_os = "macos", debug_assertions))]
|
#[cfg(all(target_os = "macos", debug_assertions))]
|
||||||
tauri::embed_plist::embed_info_plist!("../Info.plist");
|
tauri::embed_plist::embed_info_plist!("../Info.plist");
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||||
"productName": "Paseo",
|
"productName": "Paseo",
|
||||||
"version": "0.1.0",
|
"version": "0.1.13",
|
||||||
"identifier": "dev.paseo.desktop",
|
"identifier": "dev.paseo.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../../app/dist",
|
"frontendDist": "../../app/dist",
|
||||||
@@ -20,7 +20,10 @@
|
|||||||
"fullscreen": false,
|
"fullscreen": false,
|
||||||
"titleBarStyle": "Overlay",
|
"titleBarStyle": "Overlay",
|
||||||
"hiddenTitle": true,
|
"hiddenTitle": true,
|
||||||
"trafficLightPosition": { "x": 16, "y": 22 }
|
"trafficLightPosition": {
|
||||||
|
"x": 16,
|
||||||
|
"y": 22
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
@@ -38,6 +41,7 @@
|
|||||||
"icons/icon.ico"
|
"icons/icon.ico"
|
||||||
],
|
],
|
||||||
"macOS": {
|
"macOS": {
|
||||||
|
"entitlements": "Entitlements.plist",
|
||||||
"infoPlist": "Info.plist"
|
"infoPlist": "Info.plist"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ describe("claude agent commands E2E", () => {
|
|||||||
// These are skills that come from CLAUDE.md configurations
|
// These are skills that come from CLAUDE.md configurations
|
||||||
// At minimum we should have some commands available
|
// At minimum we should have some commands available
|
||||||
expect(commandNames.length).toBeGreaterThan(0);
|
expect(commandNames.length).toBeGreaterThan(0);
|
||||||
|
expect(commandNames).toContain("rewind");
|
||||||
}, 60000);
|
}, 60000);
|
||||||
|
|
||||||
test("returns error for non-existent agent", async () => {
|
test("returns error for non-existent agent", async () => {
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ const hasClaudeCredentials =
|
|||||||
// Should have at least one command (skills are loaded from user/project settings)
|
// Should have at least one command (skills are loaded from user/project settings)
|
||||||
// The exact commands depend on what skills are configured
|
// The exact commands depend on what skills are configured
|
||||||
expect(commands.length).toBeGreaterThan(0);
|
expect(commands.length).toBeGreaterThan(0);
|
||||||
|
expect(commandNames).toContain("rewind");
|
||||||
|
|
||||||
await session.close();
|
await session.close();
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|||||||
@@ -789,6 +789,63 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
|
|||||||
120_000
|
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<void> => {
|
||||||
|
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(
|
test(
|
||||||
"resumes a persisted session with context preserved",
|
"resumes a persisted session with context preserved",
|
||||||
async () => {
|
async () => {
|
||||||
|
|||||||
@@ -212,6 +212,21 @@ const VALID_CLAUDE_MODES = new Set(
|
|||||||
DEFAULT_MODES.map((mode) => mode.id)
|
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.
|
// Orchestrator instructions moved to shared module.
|
||||||
type ClaudeAgentConfig = AgentSessionConfig & { provider: "claude" };
|
type ClaudeAgentConfig = AgentSessionConfig & { provider: "claude" };
|
||||||
|
|
||||||
@@ -672,6 +687,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
private activeSidechains = new Map<string, string>();
|
private activeSidechains = new Map<string, string>();
|
||||||
private compacting = false;
|
private compacting = false;
|
||||||
private queryRestartNeeded = false;
|
private queryRestartNeeded = false;
|
||||||
|
private userMessageIds: string[] = [];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
config: ClaudeAgentConfig,
|
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
|
// Reset cancel flag at the start of each turn to prevent stale state from previous turns
|
||||||
this.turnCancelRequested = false;
|
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 sdkMessage = this.toSdkUserMessage(prompt);
|
||||||
const queue = new Pushable<AgentStreamEvent>();
|
const queue = new Pushable<AgentStreamEvent>();
|
||||||
this.eventQueue = queue;
|
this.eventQueue = queue;
|
||||||
@@ -1007,11 +1029,284 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
async listCommands(): Promise<AgentSlashCommand[]> {
|
async listCommands(): Promise<AgentSlashCommand[]> {
|
||||||
const q = await this.ensureQuery();
|
const q = await this.ensureQuery();
|
||||||
const commands = await q.supportedCommands();
|
const commands = await q.supportedCommands();
|
||||||
return commands.map((cmd) => ({
|
const commandMap = new Map<string, AgentSlashCommand>();
|
||||||
name: cmd.name,
|
for (const cmd of commands) {
|
||||||
description: cmd.description,
|
if (!commandMap.has(cmd.name)) {
|
||||||
argumentHint: cmd.argumentHint,
|
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<AgentStreamEvent> {
|
||||||
|
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 <user_message_uuid>.",
|
||||||
|
};
|
||||||
|
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 <user_message_uuid> 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 <user_message_uuid>.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Query> {
|
||||||
|
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<void> {
|
private async primeSelectableModelIds(query: Query): Promise<void> {
|
||||||
@@ -1095,6 +1390,8 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
MCP_TIMEOUT: "600000",
|
MCP_TIMEOUT: "600000",
|
||||||
MCP_TOOL_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),
|
// If we have a session ID from a previous query (e.g., after interrupt),
|
||||||
// resume that session to continue the conversation history.
|
// resume that session to continue the conversation history.
|
||||||
...(this.claudeSessionId ? { resume: this.claudeSessionId } : {}),
|
...(this.claudeSessionId ? { resume: this.claudeSessionId } : {}),
|
||||||
@@ -1174,6 +1471,9 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
content.push({ type: "text", text: prompt });
|
content.push({ type: "text", text: prompt });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const messageId = randomUUID();
|
||||||
|
this.rememberUserMessageId(messageId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: "user",
|
type: "user",
|
||||||
message: {
|
message: {
|
||||||
@@ -1181,6 +1481,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
content,
|
content,
|
||||||
},
|
},
|
||||||
parent_tool_use_id: null,
|
parent_tool_use_id: null,
|
||||||
|
uuid: messageId,
|
||||||
session_id: this.claudeSessionId ?? "",
|
session_id: this.claudeSessionId ?? "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1426,17 +1727,34 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
this.compacting = false;
|
this.compacting = false;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
const messageId =
|
||||||
|
typeof message.uuid === "string" && message.uuid.length > 0
|
||||||
|
? message.uuid
|
||||||
|
: undefined;
|
||||||
|
this.rememberUserMessageId(messageId);
|
||||||
const content = message.message?.content;
|
const content = message.message?.content;
|
||||||
if (typeof content === "string" && content.length > 0) {
|
if (typeof content === "string" && content.length > 0) {
|
||||||
// String content from user messages (e.g., local command output)
|
// String content from user messages (e.g., local command output)
|
||||||
events.push({
|
events.push({
|
||||||
type: "timeline",
|
type: "timeline",
|
||||||
item: { type: "user_message", text: content },
|
item: {
|
||||||
|
type: "user_message",
|
||||||
|
text: content,
|
||||||
|
...(messageId ? { messageId } : {}),
|
||||||
|
},
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
});
|
});
|
||||||
} else if (Array.isArray(content)) {
|
} else if (Array.isArray(content)) {
|
||||||
const timelineItems = this.mapBlocksToTimeline(content, { turnContext });
|
const timelineItems = this.mapBlocksToTimeline(content, { turnContext });
|
||||||
for (const item of timelineItems) {
|
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" });
|
events.push({ type: "timeline", item, provider: "claude" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1723,6 +2041,9 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
if (entry.isSidechain) {
|
if (entry.isSidechain) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (entry.type === "user" && typeof entry.uuid === "string") {
|
||||||
|
this.rememberUserMessageId(entry.uuid);
|
||||||
|
}
|
||||||
const items = this.convertHistoryEntry(entry);
|
const items = this.convertHistoryEntry(entry);
|
||||||
if (items.length > 0) {
|
if (items.length > 0) {
|
||||||
timeline.push(...items);
|
timeline.push(...items);
|
||||||
@@ -2300,9 +2621,14 @@ export function convertClaudeHistoryEntry(
|
|||||||
if (entry.type === "user") {
|
if (entry.type === "user") {
|
||||||
const text = extractUserMessageText(content);
|
const text = extractUserMessageText(content);
|
||||||
if (text) {
|
if (text) {
|
||||||
|
const messageId =
|
||||||
|
typeof entry.uuid === "string" && entry.uuid.length > 0
|
||||||
|
? entry.uuid
|
||||||
|
: undefined;
|
||||||
timeline.push({
|
timeline.push({
|
||||||
type: "user_message",
|
type: "user_message",
|
||||||
text,
|
text,
|
||||||
|
...(messageId ? { messageId } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+72
-1
@@ -12,6 +12,7 @@ import type {
|
|||||||
|
|
||||||
const sdkMocks = vi.hoisted(() => ({
|
const sdkMocks = vi.hoisted(() => ({
|
||||||
query: vi.fn(),
|
query: vi.fn(),
|
||||||
|
lastQuery: null as ReturnType<typeof buildSdkQueryMock> | null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
|
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
|
||||||
@@ -65,6 +66,7 @@ function buildSdkQueryMock() {
|
|||||||
setModel: vi.fn(async () => undefined),
|
setModel: vi.fn(async () => undefined),
|
||||||
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
|
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
|
||||||
supportedCommands: vi.fn(async () => []),
|
supportedCommands: vi.fn(async () => []),
|
||||||
|
rewindFiles: vi.fn(async () => ({ canRewind: true })),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +93,11 @@ describe("ClaudeAgentSession history replay regression", () => {
|
|||||||
let previousClaudeConfigDir: string | undefined;
|
let previousClaudeConfigDir: string | undefined;
|
||||||
|
|
||||||
beforeEach(() => {
|
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-"));
|
tempRoot = mkdtempSync(path.join(os.tmpdir(), "claude-history-regression-"));
|
||||||
cwd = path.join(tempRoot, "repo");
|
cwd = path.join(tempRoot, "repo");
|
||||||
@@ -107,6 +113,7 @@ describe("ClaudeAgentSession history replay regression", () => {
|
|||||||
[
|
[
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "user",
|
type: "user",
|
||||||
|
uuid: "history-user-uuid",
|
||||||
sessionId: "history-session",
|
sessionId: "history-session",
|
||||||
cwd,
|
cwd,
|
||||||
message: {
|
message: {
|
||||||
@@ -133,6 +140,7 @@ describe("ClaudeAgentSession history replay regression", () => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
sdkMocks.query.mockReset();
|
sdkMocks.query.mockReset();
|
||||||
|
sdkMocks.lastQuery = null;
|
||||||
if (previousClaudeConfigDir === undefined) {
|
if (previousClaudeConfigDir === undefined) {
|
||||||
delete process.env.CLAUDE_CONFIG_DIR;
|
delete process.env.CLAUDE_CONFIG_DIR;
|
||||||
} else {
|
} else {
|
||||||
@@ -206,4 +214,67 @@ describe("ClaudeAgentSession history replay regression", () => {
|
|||||||
expect(timelineText).toContain(HISTORY_USER_MARKER);
|
expect(timelineText).toContain(HISTORY_USER_MARKER);
|
||||||
expect(timelineText).toContain(HISTORY_ASSISTANT_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 }
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -570,7 +570,19 @@ class FakeAgentSession implements AgentSession {
|
|||||||
return commands;
|
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 [
|
return [
|
||||||
{ name: "help", description: "Help", argumentHint: "" },
|
{ name: "help", description: "Help", argumentHint: "" },
|
||||||
{ name: "context", description: "Context", argumentHint: "" },
|
{ name: "context", description: "Context", argumentHint: "" },
|
||||||
|
|||||||
@@ -1678,6 +1678,7 @@ const CheckoutPrStatusSchema = z.object({
|
|||||||
state: z.string(),
|
state: z.string(),
|
||||||
baseRefName: z.string(),
|
baseRefName: z.string(),
|
||||||
headRefName: z.string(),
|
headRefName: z.string(),
|
||||||
|
isMerged: z.boolean(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const CheckoutPrStatusResponseSchema = z.object({
|
export const CheckoutPrStatusResponseSchema = z.object({
|
||||||
|
|||||||
@@ -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 () => {
|
it("returns typed MergeConflictError on merge conflicts", async () => {
|
||||||
const conflictFile = join(repoDir, "conflict.txt");
|
const conflictFile = join(repoDir, "conflict.txt");
|
||||||
writeFileSync(conflictFile, "base\n");
|
writeFileSync(conflictFile, "base\n");
|
||||||
|
|||||||
@@ -1517,6 +1517,7 @@ export interface PullRequestStatus {
|
|||||||
state: string;
|
state: string;
|
||||||
baseRefName: string;
|
baseRefName: string;
|
||||||
headRefName: string;
|
headRefName: string;
|
||||||
|
isMerged: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PullRequestStatusResult {
|
export interface PullRequestStatusResult {
|
||||||
@@ -1592,6 +1593,62 @@ async function resolveGitHubRepo(cwd: string): Promise<string | null> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function listPullRequestsForHead(options: {
|
||||||
|
cwd: string;
|
||||||
|
repo: string;
|
||||||
|
owner: string;
|
||||||
|
head: string;
|
||||||
|
state: "open" | "closed";
|
||||||
|
}): Promise<any[]> {
|
||||||
|
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(
|
export async function createPullRequest(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
options: CreatePullRequestOptions
|
options: CreatePullRequestOptions
|
||||||
@@ -1652,22 +1709,15 @@ export async function getPullRequestStatus(cwd: string): Promise<PullRequestStat
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
const owner = repo.split("/")[0];
|
const owner = repo.split("/")[0];
|
||||||
let stdout: string;
|
let openPulls: any[];
|
||||||
try {
|
try {
|
||||||
({ stdout } = await execFileAsync(
|
openPulls = await listPullRequestsForHead({
|
||||||
"gh",
|
cwd,
|
||||||
[
|
repo,
|
||||||
"api",
|
owner,
|
||||||
`repos/${repo}/pulls`,
|
head,
|
||||||
"-X",
|
state: "open",
|
||||||
"GET",
|
});
|
||||||
"-F",
|
|
||||||
`head=${owner}:${head}`,
|
|
||||||
"-F",
|
|
||||||
"state=open",
|
|
||||||
],
|
|
||||||
{ cwd }
|
|
||||||
));
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isGhAuthError(error)) {
|
if (isGhAuthError(error)) {
|
||||||
return {
|
return {
|
||||||
@@ -1677,22 +1727,50 @@ export async function getPullRequestStatus(cwd: string): Promise<PullRequestStat
|
|||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
const parsed = JSON.parse(stdout.trim());
|
const openPull = openPulls[0] ?? null;
|
||||||
const current = Array.isArray(parsed) && parsed.length > 0 ? parsed[0] : null;
|
const openStatus = buildPullRequestStatus(openPull, head);
|
||||||
if (!current) {
|
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 {
|
return {
|
||||||
status: null,
|
status: null,
|
||||||
githubFeaturesEnabled: true,
|
githubFeaturesEnabled: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
status: {
|
status: mergedStatus,
|
||||||
url: current.html_url ?? current.url,
|
|
||||||
title: current.title,
|
|
||||||
state: current.state,
|
|
||||||
baseRefName: current.base?.ref ?? "",
|
|
||||||
headRefName: current.head?.ref ?? head,
|
|
||||||
},
|
|
||||||
githubFeaturesEnabled: true,
|
githubFeaturesEnabled: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,106 @@ const dependencySections = [
|
|||||||
|
|
||||||
const touched = []
|
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 = /(<key>CFBundleShortVersionString<\/key>\s*<string>)([^<]+)(<\/string>)/
|
||||||
|
const bundleVersionPattern = /(<key>CFBundleVersion<\/key>\s*<string>)([^<]+)(<\/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) {
|
for (const workspacePath of workspacePaths) {
|
||||||
const packagePath = path.join(rootDir, workspacePath, 'package.json')
|
const packagePath = path.join(rootDir, workspacePath, 'package.json')
|
||||||
if (!existsSync(packagePath)) {
|
if (!existsSync(packagePath)) {
|
||||||
@@ -63,6 +163,10 @@ for (const workspacePath of workspacePaths) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
syncDesktopTauriConfigVersion(rootVersion)
|
||||||
|
syncDesktopCargoPackageVersion(rootVersion)
|
||||||
|
syncDesktopInfoPlistVersion(rootVersion)
|
||||||
|
|
||||||
if (touched.length === 0) {
|
if (touched.length === 0) {
|
||||||
console.log(`Workspace versions and internal deps already synced to ${rootVersion}`)
|
console.log(`Workspace versions and internal deps already synced to ${rootVersion}`)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Reference in New Issue
Block a user