diff --git a/desktop/public/pow/LICENSE.txt b/desktop/public/pow/LICENSE.txt new file mode 100644 index 000000000..b0d56df20 --- /dev/null +++ b/desktop/public/pow/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Emerge Tools, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/desktop/public/pow/plop.m4a b/desktop/public/pow/plop.m4a new file mode 100644 index 000000000..593f4e8b7 Binary files /dev/null and b/desktop/public/pow/plop.m4a differ diff --git a/desktop/public/pow/poof1@3x.png b/desktop/public/pow/poof1@3x.png new file mode 100644 index 000000000..48b5166c1 Binary files /dev/null and b/desktop/public/pow/poof1@3x.png differ diff --git a/desktop/public/pow/poof2@3x.png b/desktop/public/pow/poof2@3x.png new file mode 100644 index 000000000..2e1d6f269 Binary files /dev/null and b/desktop/public/pow/poof2@3x.png differ diff --git a/desktop/public/pow/poof3@3x.png b/desktop/public/pow/poof3@3x.png new file mode 100644 index 000000000..a8d303e15 Binary files /dev/null and b/desktop/public/pow/poof3@3x.png differ diff --git a/desktop/public/pow/poof4@3x.png b/desktop/public/pow/poof4@3x.png new file mode 100644 index 000000000..5ab8dc528 Binary files /dev/null and b/desktop/public/pow/poof4@3x.png differ diff --git a/desktop/public/pow/poof5@3x.png b/desktop/public/pow/poof5@3x.png new file mode 100644 index 000000000..b4d05aff9 Binary files /dev/null and b/desktop/public/pow/poof5@3x.png differ diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 2f7c343dc..9f61a4063 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -23,6 +23,7 @@ mod profile; mod relay_members; mod social; mod teams; +mod warp; mod workflows; mod workspace; @@ -49,5 +50,6 @@ pub use profile::*; pub use relay_members::*; pub use social::*; pub use teams::*; +pub use warp::*; pub use workflows::*; pub use workspace::*; diff --git a/desktop/src-tauri/src/commands/warp.rs b/desktop/src-tauri/src/commands/warp.rs new file mode 100644 index 000000000..13cac3ef6 --- /dev/null +++ b/desktop/src-tauri/src/commands/warp.rs @@ -0,0 +1,58 @@ +use std::io; +use std::process::{Command, Output}; + +type CmdResult = Result; + +const WARP_CLI_CANDIDATES: &[&str] = &[ + "warp-cli", + "/Applications/Cloudflare WARP.app/Contents/Resources/warp-cli", +]; + +fn handle_warp_cli_output(command: &str, args: &[&str], output: Output) -> CmdResult<()> { + if output.status.success() { + return Ok(()); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let details = stderr.trim(); + let details = if details.is_empty() { + stdout.trim() + } else { + details + }; + + if details.is_empty() { + Err(format!("{command} {} failed.", args.join(" "))) + } else { + Err(format!("{command} {} failed: {details}", args.join(" "))) + } +} + +fn run_warp_cli(args: &[&str]) -> CmdResult<()> { + for command in WARP_CLI_CANDIDATES { + let output = match Command::new(command).args(args).output() { + Ok(output) => output, + Err(error) if error.kind() == io::ErrorKind::NotFound => continue, + Err(error) => return Err(format!("Failed to run {command}: {error}")), + }; + + return handle_warp_cli_output(command, args, output); + } + + Err("Cloudflare WARP CLI is not installed or is not on PATH.".to_string()) +} + +#[tauri::command] +pub async fn connect_warp_vpn() -> CmdResult<()> { + tokio::task::spawn_blocking(|| run_warp_cli(&["connect"])) + .await + .map_err(|error| format!("Failed to run WARP command: {error}"))? +} + +#[tauri::command] +pub async fn refresh_warp_access() -> CmdResult<()> { + tokio::task::spawn_blocking(|| run_warp_cli(&["debug", "access-reauth"])) + .await + .map_err(|error| format!("Failed to run WARP command: {error}"))? +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 47b0e3e6b..648ae610c 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -835,6 +835,8 @@ pub fn run() { cancel_pairing, apply_workspace, get_active_workspace, + connect_warp_vpn, + refresh_warp_access, set_prevent_sleep_active, get_agent_memory, ]) diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 81cd477f5..5a15143cf 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -75,7 +75,6 @@ import { MainInsetProvider } from "@/shared/layout/MainInsetContext"; import { chromeCssVarDefaults } from "@/shared/layout/chromeLayout"; import { hasPrimaryShortcutModifier } from "@/shared/lib/platform"; import { useMessageDeepLinks } from "@/shared/useMessageDeepLinks"; -import { ConnectionBanner } from "@/shared/ui/ConnectionBanner"; import { SidebarInset, SidebarProvider } from "@/shared/ui/sidebar"; type AppView = @@ -941,7 +940,6 @@ export function AppShell() { className="min-h-0 min-w-0 overflow-hidden" style={chromeCssVarDefaults} > - diff --git a/desktop/src/features/onboarding/ui/OnboardingFlow.tsx b/desktop/src/features/onboarding/ui/OnboardingFlow.tsx index 7e9618f6e..dce2e7810 100644 --- a/desktop/src/features/onboarding/ui/OnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/OnboardingFlow.tsx @@ -5,6 +5,7 @@ import { profileQueryKey, useUpdateProfileMutation, } from "@/features/profile/hooks"; +import { useWorkspaces } from "@/features/workspaces/useWorkspaces"; import { relayClient } from "@/shared/api/relayClient"; import { getMyRelayMembershipLookup } from "@/shared/api/relayMembers"; import { getIdentity, importIdentity } from "@/shared/api/tauri"; @@ -146,6 +147,7 @@ export function OnboardingFlow({ onBackToWorkspaceSetup, }: OnboardingFlowProps) { const { complete, skipForNow } = actions; + const { activeWorkspace } = useWorkspaces(); const queryClient = useQueryClient(); const savedProfile = resolveSavedProfile(initialProfile); const profileUpdateMutation = useUpdateProfileMutation(); @@ -486,6 +488,7 @@ export function OnboardingFlow({ updateDisplayName: updateDisplayNameDraft, }} direction={transitionDirection} + relayUrl={activeWorkspace?.relayUrl} state={profileStepState} /> ) : currentPage === "key-import" ? ( diff --git a/desktop/src/features/onboarding/ui/ProfileStep.tsx b/desktop/src/features/onboarding/ui/ProfileStep.tsx index 9236f9e8b..a88d6b595 100644 --- a/desktop/src/features/onboarding/ui/ProfileStep.tsx +++ b/desktop/src/features/onboarding/ui/ProfileStep.tsx @@ -1,6 +1,18 @@ import * as React from "react"; +import { toast } from "sonner"; +import { + SidebarBlockAccessRefreshCompactCard, + SidebarBlockVpnOffCompactCard, + SidebarRelayConnectionCompactCard, +} from "@/features/sidebar/ui/SidebarRelayConnectionCard"; +import { connectWarpVpn, refreshWarpAccess } from "@/shared/api/warp"; +import { useReconnectRelay } from "@/shared/api/useReconnectRelay"; import { cn } from "@/shared/lib/cn"; +import { + isRelayUnreachableError, + relayErrorDetail, +} from "@/shared/lib/relayError"; import { Button } from "@/shared/ui/button"; import { Spinner } from "@/shared/ui/spinner"; import { @@ -13,15 +25,265 @@ import type { ProfileStepActions, ProfileStepState } from "./types"; type ProfileStepProps = { actions: ProfileStepActions; direction: OnboardingTransitionDirection; + relayUrl?: string | null; transitionEffect?: OnboardingTransitionEffect; state: ProfileStepState; }; -function ErrorBanner({ message }: { message: string | null }) { +type OnboardingConnectivityAction = + | "connect-vpn" + | "reconnect-relay" + | "refresh-access"; +type OnboardingRelayCardVariant = + | "connect-vpn" + | "reconnect-relay" + | "refresh-access"; + +const ONBOARDING_CONNECTIVITY_SUCCESS_AUTO_DISMISS_MS = 2_500; + +function isBlockRelayUrl(relayUrl: string | null | undefined) { + if (!relayUrl) { + return false; + } + + try { + const url = new URL( + relayUrl.replace("ws://", "http://").replace("wss://", "https://"), + ); + const host = url.hostname.toLowerCase(); + return ( + host === "block.xyz" || + host.endsWith(".block.xyz") || + host === "sqprod.co" || + host.endsWith(".sqprod.co") || + host === "squareup.com" || + host.endsWith(".squareup.com") + ); + } catch { + return false; + } +} + +function shouldRefreshVpnAccess( + errorMessage: string, + relayUrl: string | null | undefined, +) { + const detail = relayErrorDetail(errorMessage).toLowerCase(); + if (detail.includes("cloudflare")) { + return true; + } + + if (!isBlockRelayUrl(relayUrl)) { + return false; + } + + return ( + detail.includes("access") || + detail.includes("sign-in") || + detail.includes("re-authenticate") || + detail.includes("reauth") || + detail.includes("proxy") + ); +} + +function resolveOnboardingRelayCardVariant( + errorMessage: string, + relayUrl: string | null | undefined, +): OnboardingRelayCardVariant { + if (shouldRefreshVpnAccess(errorMessage, relayUrl)) { + return "refresh-access"; + } + + if (isBlockRelayUrl(relayUrl)) { + return "connect-vpn"; + } + + return "reconnect-relay"; +} + +function OnboardingRelayConnectionErrorCard({ + isSaving, + message, + relayUrl, +}: { + isSaving: boolean; + message: string; + relayUrl?: string | null; +}) { + const { isPending: isReconnectPending, reconnect } = useReconnectRelay(); + const [dismissedErrorMessage, setDismissedErrorMessage] = React.useState< + string | null + >(null); + const [connectivityAction, setConnectivityAction] = + React.useState(null); + const [successAction, setSuccessAction] = + React.useState(null); + const connectivityActionRef = + React.useRef(null); + const successTimeoutRef = React.useRef(null); + const wasSavingRef = React.useRef(isSaving); + const cardVariant = resolveOnboardingRelayCardVariant(message, relayUrl); + const isActionPending = connectivityAction !== null || isReconnectPending; + + React.useEffect(() => { + return () => { + if (successTimeoutRef.current !== null) { + window.clearTimeout(successTimeoutRef.current); + } + }; + }, []); + + React.useEffect(() => { + if (isSaving && !wasSavingRef.current) { + if (successTimeoutRef.current !== null) { + window.clearTimeout(successTimeoutRef.current); + successTimeoutRef.current = null; + } + setDismissedErrorMessage(null); + setSuccessAction(null); + } + wasSavingRef.current = isSaving; + }, [isSaving]); + + const markSuccess = React.useCallback( + (action: OnboardingConnectivityAction) => { + setSuccessAction(action); + if (successTimeoutRef.current !== null) { + window.clearTimeout(successTimeoutRef.current); + } + successTimeoutRef.current = window.setTimeout(() => { + successTimeoutRef.current = null; + setDismissedErrorMessage(message); + }, ONBOARDING_CONNECTIVITY_SUCCESS_AUTO_DISMISS_MS); + }, + [message], + ); + + const runConnectivityAction = React.useCallback( + ( + action: OnboardingConnectivityAction, + runAction: () => Promise, + ) => { + if (connectivityActionRef.current !== null) { + return; + } + + connectivityActionRef.current = action; + setConnectivityAction(action); + setSuccessAction(null); + void Promise.resolve() + .then(runAction) + .then((didReconnect) => { + if (didReconnect !== false) { + markSuccess(action); + } + }) + .catch((error) => { + const detail = error instanceof Error ? error.message : String(error); + const label = + action === "refresh-access" + ? "Could not refresh VPN access." + : action === "connect-vpn" + ? "Could not turn on VPN." + : "Could not reconnect to the relay."; + toast.error(`${label} ${detail}`); + }) + .finally(() => { + connectivityActionRef.current = null; + setConnectivityAction(null); + }); + }, + [markSuccess], + ); + + const handleConnectWarpVpn = React.useCallback(() => { + runConnectivityAction("connect-vpn", async () => { + await connectWarpVpn(); + return reconnect(); + }); + }, [reconnect, runConnectivityAction]); + + const handleReconnectRelay = React.useCallback(() => { + runConnectivityAction("reconnect-relay", reconnect); + }, [reconnect, runConnectivityAction]); + + const handleRefreshWarpAccess = React.useCallback(() => { + runConnectivityAction("refresh-access", async () => { + await refreshWarpAccess(); + return reconnect(); + }); + }, [reconnect, runConnectivityAction]); + + if (dismissedErrorMessage === message) { + return null; + } + + return ( +
+ {cardVariant === "refresh-access" ? ( + setDismissedErrorMessage(message)} + surface="secondary" + testId="onboarding-vpn-access-refresh-card" + /> + ) : cardVariant === "connect-vpn" ? ( + setDismissedErrorMessage(message)} + surface="secondary" + testId="onboarding-vpn-off-card" + /> + ) : ( + setDismissedErrorMessage(message)} + onReconnect={handleReconnectRelay} + surface="secondary" + testId="onboarding-relay-reconnect-card" + /> + )} +
+ ); +} + +function ErrorBanner({ + isSaving, + message, + relayUrl, +}: { + isSaving: boolean; + message: string | null; + relayUrl?: string | null; +}) { if (!message) { return null; } + if (isRelayUnreachableError(message)) { + return ( + + ); + } + return (

{message} @@ -32,6 +294,7 @@ function ErrorBanner({ message }: { message: string | null }) { export function ProfileStep({ actions, direction, + relayUrl, transitionEffect = "line-slide", state, }: ProfileStepProps) { @@ -116,7 +379,11 @@ export function ProfileStep({ {saveRecovery.errorMessage ? ( - + ) : null}

diff --git a/desktop/src/features/settings/SidebarUpdateCard.tsx b/desktop/src/features/settings/SidebarUpdateCard.tsx new file mode 100644 index 000000000..71ed7aa2f --- /dev/null +++ b/desktop/src/features/settings/SidebarUpdateCard.tsx @@ -0,0 +1,100 @@ +import * as React from "react"; +import { CircleArrowUp, Loader2 } from "lucide-react"; + +import { useUpdaterContext } from "./hooks/UpdaterProvider"; +import { shouldShowSidebarUpdateCard } from "./sidebarUpdateCardVisibility"; +import { SidebarCompactActionCard } from "@/shared/ui/sidebar-action-card"; + +type SidebarUpdateCardProps = { + onDismiss: () => void; +}; + +type SidebarUpdateCompactCardProps = SidebarUpdateCardProps & { + actionTestId?: string; + testId?: string; +}; + +export function SidebarUpdateCompactCard({ + actionTestId, + onDismiss, + testId = "sidebar-update-card-compact", +}: SidebarUpdateCompactCardProps) { + const { relaunch } = useUpdaterContext(); + const [isRestartPending, setIsRestartPending] = React.useState(false); + const restartPendingRef = React.useRef(false); + const restartFrameRef = React.useRef(null); + const restartTimeoutRef = React.useRef(null); + + React.useEffect(() => { + return () => { + if (restartFrameRef.current !== null) { + window.cancelAnimationFrame(restartFrameRef.current); + } + if (restartTimeoutRef.current !== null) { + window.clearTimeout(restartTimeoutRef.current); + } + restartPendingRef.current = false; + }; + }, []); + + const handleRestart = React.useCallback(() => { + if (restartPendingRef.current) { + return; + } + + restartPendingRef.current = true; + setIsRestartPending(true); + restartFrameRef.current = window.requestAnimationFrame(() => { + restartFrameRef.current = null; + restartTimeoutRef.current = window.setTimeout(() => { + restartTimeoutRef.current = null; + void relaunch() + .catch((error) => { + console.error("[SidebarUpdateCard] relaunch failed:", error); + }) + .finally(() => { + restartPendingRef.current = false; + setIsRestartPending(false); + }); + }, 0); + }); + }, [relaunch]); + + return ( +