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/ProfileStep.tsx b/desktop/src/features/onboarding/ui/ProfileStep.tsx index 9236f9e8b..29392897e 100644 --- a/desktop/src/features/onboarding/ui/ProfileStep.tsx +++ b/desktop/src/features/onboarding/ui/ProfileStep.tsx @@ -1,6 +1,17 @@ import * as React from "react"; +import { toast } from "sonner"; +import { + SidebarBlockAccessRefreshCompactCard, + SidebarBlockVpnOffCompactCard, +} 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 { @@ -17,11 +28,152 @@ type ProfileStepProps = { state: ProfileStepState; }; +type OnboardingConnectivityAction = "connect-vpn" | "refresh-access"; + +const ONBOARDING_CONNECTIVITY_SUCCESS_AUTO_DISMISS_MS = 2_500; + +function shouldRefreshVpnAccess(errorMessage: string) { + const detail = relayErrorDetail(errorMessage).toLowerCase(); + return ( + detail.includes("cloudflare") || + detail.includes("access") || + detail.includes("sign-in") || + detail.includes("re-authenticate") || + detail.includes("reauth") || + detail.includes("proxy") + ); +} + +function OnboardingRelayConnectionErrorCard({ message }: { message: string }) { + 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 isRefreshAccessCard = shouldRefreshVpnAccess(message); + const isActionPending = connectivityAction !== null || isReconnectPending; + + React.useEffect(() => { + return () => { + if (successTimeoutRef.current !== null) { + window.clearTimeout(successTimeoutRef.current); + } + }; + }, []); + + 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." + : "Could not turn on VPN."; + 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 handleRefreshWarpAccess = React.useCallback(() => { + runConnectivityAction("refresh-access", async () => { + await refreshWarpAccess(); + return reconnect(); + }); + }, [reconnect, runConnectivityAction]); + + if (dismissedErrorMessage === message) { + return null; + } + + return ( +
+ {isRefreshAccessCard ? ( + setDismissedErrorMessage(message)} + surface="secondary" + testId="onboarding-vpn-access-refresh-card" + /> + ) : ( + setDismissedErrorMessage(message)} + surface="secondary" + testId="onboarding-vpn-off-card" + /> + )} +
+ ); +} + function ErrorBanner({ message }: { message: string | null }) { if (!message) { return null; } + if (isRelayUnreachableError(message)) { + return ( + + ); + } + return (

{message} 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 ( +

- - {RELAY_UNREACHABLE_SHORT}{" "} - - -
- ) : ( -
- {errorMessage} -
- ) + {errorMessage && + !sidebarRelayConnectionCard.hasRelayUnreachableError ? ( +
+ {errorMessage} +
) : null} {unreadBelowCount > 0 ? ( } onClick={scrollToNextBelow} @@ -765,6 +776,29 @@ export function AppSidebar({ ) : null} + {sidebarRelayConnectionCard.showSidebarRelayConnectionCard ? ( +
+ +
+ ) : null} + {showSidebarUpdateCard ? ( +
+ setIsSidebarUpdateCardDismissed(true)} + /> +
+ ) : null} void; + onReconnect: () => void; + surface?: SidebarActionCardSurface; + testId?: string; +}; + +type SidebarBlockConnectivityCardProps = { + actionTestId?: string; + isActionDisabled: boolean; + isActionPending: boolean; + isActionSuccess?: boolean; + onAction: () => void; + onDismiss?: () => void; + surface?: SidebarActionCardSurface; + testId?: string; +}; + +export function SidebarRelayConnectionCard({ + actionTestId, + isActionDisabled = false, + isConnected = false, + isReconnectPending, + onDismiss, + onReconnect, + surface, +}: SidebarRelayConnectionCardProps) { + return ( + + ); +} + +export function SidebarRelayConnectionCompactCard({ + actionTestId, + isActionDisabled = false, + isConnected = false, + isReconnectPending, + onDismiss, + onReconnect, + surface, + testId = "sidebar-relay-unreachable-compact", +}: SidebarRelayConnectionCardProps) { + return ( +