diff --git a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx index 8350304389..1879252b6d 100644 --- a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx +++ b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx @@ -1,5 +1,12 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, screen, userEvent, waitFor, within } from "storybook/test"; +import { + expect, + screen, + spyOn, + userEvent, + waitFor, + within, +} from "storybook/test"; import { meAISpendKey } from "#/api/queries/users"; import type { FeatureName, UserAISpendStatus } from "#/api/typesGenerated"; import { MockBuildInfo, MockUserOwner } from "#/testHelpers/entities"; @@ -52,6 +59,21 @@ const openDropdown = async (canvasElement: HTMLElement) => { ); }; +// Overrides platform detection so the Coder Desktop gating can be exercised in +// a story. Returns a cleanup that restores the spied getters. +const mockPlatform = (platform: string, maxTouchPoints = 0) => { + const platformSpy = spyOn(navigator, "platform", "get").mockReturnValue( + platform, + ); + const touchSpy = spyOn(navigator, "maxTouchPoints", "get").mockReturnValue( + maxTouchPoints, + ); + return () => { + platformSpy.mockRestore(); + touchSpy.mockRestore(); + }; +}; + const Example: Story = { parameters: { queries: [{ key: meAISpendKey, data: mockAISpend }], @@ -325,4 +347,77 @@ export const AISpendHiddenOnNegativeLimit: Story = { }, }; +export const InstallCoderDesktopMacOS: Story = { + parameters: { + queries: [{ key: meAISpendKey, data: mockAISpend }], + }, + beforeEach: () => mockPlatform("MacIntel"), + play: async ({ canvasElement, step }) => { + await step( + "links Install Coder Desktop to the docs alongside Install CLI", + async () => { + const menu = await openDropdown(canvasElement); + expect( + menu.getByRole("menuitem", { name: "Install Coder Desktop" }), + ).toHaveAttribute("href", "https://coder.com/docs/user-guides/desktop"); + expect( + menu.getByRole("menuitem", { name: "Install CLI" }), + ).toBeInTheDocument(); + }, + ); + }, +}; + +export const InstallCoderDesktopWindows: Story = { + parameters: { + queries: [{ key: meAISpendKey, data: mockAISpend }], + }, + beforeEach: () => mockPlatform("Win32"), + play: async ({ canvasElement, step }) => { + await step("shows Install Coder Desktop on Windows", async () => { + const menu = await openDropdown(canvasElement); + expect( + menu.getByRole("menuitem", { name: "Install Coder Desktop" }), + ).toBeInTheDocument(); + }); + }, +}; + +export const InstallCoderDesktopHiddenOnLinux: Story = { + parameters: { + queries: [{ key: meAISpendKey, data: mockAISpend }], + }, + beforeEach: () => mockPlatform("Linux x86_64"), + play: async ({ canvasElement, step }) => { + await step( + "hides Install Coder Desktop but keeps Install CLI", + async () => { + const menu = await openDropdown(canvasElement); + expect( + menu.queryByRole("menuitem", { name: "Install Coder Desktop" }), + ).not.toBeInTheDocument(); + expect( + menu.getByRole("menuitem", { name: "Install CLI" }), + ).toBeInTheDocument(); + }, + ); + }, +}; + +export const InstallCoderDesktopHiddenOniPadOS: Story = { + parameters: { + queries: [{ key: meAISpendKey, data: mockAISpend }], + }, + // iPadOS 13+ reports "MacIntel" but exposes a touchscreen. + beforeEach: () => mockPlatform("MacIntel", 5), + play: async ({ canvasElement, step }) => { + await step("hides Install Coder Desktop on iPadOS", async () => { + const menu = await openDropdown(canvasElement); + expect( + menu.queryByRole("menuitem", { name: "Install Coder Desktop" }), + ).not.toBeInTheDocument(); + }); + }, +}; + export { Example as UserDropdown }; diff --git a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx index ac26223268..12250f6cb6 100644 --- a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx +++ b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx @@ -2,8 +2,9 @@ import { CircleUserIcon, CopyIcon, LogOutIcon, - MonitorDownIcon, + MonitorIcon, SquareArrowOutUpRightIcon, + TerminalIcon, } from "lucide-react"; import type { FC, ReactNode } from "react"; import { Link } from "react-router"; @@ -19,8 +20,11 @@ import { TooltipTrigger, } from "#/components/Tooltip/Tooltip"; import { useClipboard } from "#/hooks/useClipboard"; +import { supportsCoderDesktop } from "#/utils/platform"; import { SupportIcon } from "../SupportIcon"; +const CODER_DESKTOP_DOCS_URL = "https://coder.com/docs/user-guides/desktop"; + interface UserDropdownContentProps { user: TypesGen.User; buildInfo?: TypesGen.BuildInfoResponse; @@ -52,9 +56,17 @@ export const UserDropdownContent: FC = ({ {profileExtra} + {supportsCoderDesktop() && ( + + + + Install Coder Desktop + + + )} - + Install CLI diff --git a/site/src/utils/platform.test.ts b/site/src/utils/platform.test.ts new file mode 100644 index 0000000000..0052d8c5ad --- /dev/null +++ b/site/src/utils/platform.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { isMac, isWindows, supportsCoderDesktop } from "./platform"; + +const stubPlatform = (platform: string, maxTouchPoints = 0) => { + vi.stubGlobal("navigator", { + ...navigator, + platform, + maxTouchPoints, + }); +}; + +describe("platform", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("detects macOS", () => { + stubPlatform("MacIntel"); + expect(isMac()).toBe(true); + expect(isWindows()).toBe(false); + expect(supportsCoderDesktop()).toBe(true); + }); + + it("detects Windows", () => { + stubPlatform("Win32"); + expect(isWindows()).toBe(true); + expect(isMac()).toBe(false); + expect(supportsCoderDesktop()).toBe(true); + }); + + it("treats Linux and other platforms as unsupported", () => { + stubPlatform("Linux x86_64"); + expect(isMac()).toBe(false); + expect(isWindows()).toBe(false); + expect(supportsCoderDesktop()).toBe(false); + }); + + it("excludes iPadOS masquerading as macOS", () => { + // iPadOS 13+ reports "MacIntel" but exposes a touchscreen. + stubPlatform("MacIntel", 5); + expect(isMac()).toBe(true); + expect(supportsCoderDesktop()).toBe(false); + }); +}); diff --git a/site/src/utils/platform.ts b/site/src/utils/platform.ts index 5e2c07eab5..5bc8ad042d 100644 --- a/site/src/utils/platform.ts +++ b/site/src/utils/platform.ts @@ -1,10 +1,32 @@ /** * Returns true if the current platform is macOS. + * + * Note: iPadOS 13+ also reports `navigator.platform === "MacIntel"`. Callers + * that need to distinguish a real Mac from an iPad should additionally check + * `navigator.maxTouchPoints` (see `supportsCoderDesktop`). */ export function isMac(): boolean { return Boolean(navigator.platform.match("Mac")); } +/** + * Returns true if the current platform is Windows. + */ +export function isWindows(): boolean { + return navigator.platform.startsWith("Win"); +} + +/** + * Returns true if Coder Desktop is available for the current platform. + * Coder Desktop currently ships for macOS and Windows only, so we hide the + * install affordance everywhere else (e.g. Linux). iPadOS masquerades as macOS + * via `navigator.platform`, so it is excluded using the touchscreen tell. + */ +export function supportsCoderDesktop(): boolean { + const isIpadOS = isMac() && navigator.maxTouchPoints > 1; + return (isMac() || isWindows()) && !isIpadOS; +} + /** * Returns the platform-appropriate modifier key label: ⌘ on macOS, * Ctrl on everything else.