Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions src/components/ConnectionNoticeDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from "@tabler/icons-react";
import type { ConnectionMethod } from "./DeviceConnection";
import { saveNoticeAcceptance } from "../lib/connectionNoticeStorage";
import { isUsbConnectionAvailable } from "../lib/transport/usb";
import { useCallback, useMemo, useState } from "react";

interface ConnectionNoticeDialogProps {
Expand All @@ -36,9 +37,9 @@ export function ConnectionNoticeDialog({
}: ConnectionNoticeDialogProps) {
const isUSB = method === "serial";
const isBLE = method === "ble";
const isSerialAvailable = useMemo(() => "serial" in navigator, []);
const isUSBAvailable = useMemo(() => isUsbConnectionAvailable(), []);
const isBLEAvailable = useMemo(() => "bluetooth" in navigator, []);
const canContinue = (isUSB && isSerialAvailable) || (isBLE && isBLEAvailable);
const canContinue = (isUSB && isUSBAvailable) || (isBLE && isBLEAvailable);
const [neverShowAgain, setNeverShowAgain] = useState(false);

const handleAgree = useCallback(() => {
Expand Down Expand Up @@ -118,7 +119,7 @@ export function ConnectionNoticeDialog({
</p>
</div>
)}
{isUSB && !isSerialAvailable && (
{isUSB && !isUSBAvailable && (
<div className="glass-card p-4 mb-4 border-l-4 border-[var(--color-warning)] bg-[var(--color-warning)]/10">
<h4 className="text-sm font-medium text-[var(--color-text)] mb-3 flex items-center gap-2">
<IconAlertTriangleFilled
Expand Down
7 changes: 4 additions & 3 deletions src/components/DeviceConnection.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { ReactNode } from "react";
import { createContext, useCallback } from "react";
import type { RpcTransport } from "@zmkfirmware/zmk-studio-ts-client/transport/index";
import { useZMKApp, ZMKAppContext } from "@cormoran/zmk-studio-react-hook";
import { connect as connectSerial } from "@zmkfirmware/zmk-studio-ts-client/transport/serial";
import { connect as connectBLE } from "@zmkfirmware/zmk-studio-ts-client/transport/gatt";
import { connect as connectUSB } from "../lib/transport/usb";
import { connect as connectDemo } from "../lib/transport/demo";

export type ConnectionMethod = "serial" | "ble" | "demo";
Expand Down Expand Up @@ -37,13 +38,13 @@ export function DeviceConnectionProvider({

const handleConnect = useCallback(
async (method: ConnectionMethod) => {
let connectFn;
let connectFn: () => Promise<RpcTransport>;
if (method === "ble") {
connectFn = connectBLE;
} else if (method === "demo") {
connectFn = connectDemo;
} else {
connectFn = connectSerial;
connectFn = connectUSB;
}
await zmkApp.connect(connectFn);
},
Expand Down
38 changes: 38 additions & 0 deletions src/components/__tests__/ConnectionNoticeDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ Object.defineProperty(window, "localStorage", {
describe("ConnectionNoticeDialog", () => {
beforeEach(() => {
mockLocalStorage.clear();
Object.defineProperty(navigator, "serial", {
writable: true,
configurable: true,
value: undefined,
});
});

test("renders USB connection dialog", () => {
Expand Down Expand Up @@ -139,6 +144,39 @@ describe("ConnectionNoticeDialog", () => {

expect(screen.queryByText("Connect via USB")).not.toBeInTheDocument();
});

test("allows USB connection on Android Chrome when WebUSB is available", () => {
const onAgree = jest.fn();
const onCancel = jest.fn();
const userAgentSpy = jest
.spyOn(navigator, "userAgent", "get")
.mockReturnValue(
"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36",
);

Object.defineProperty(navigator, "serial", {
configurable: true,
value: undefined,
});
Object.defineProperty(navigator, "usb", {
configurable: true,
value: { requestDevice: jest.fn() },
});

render(
<ConnectionNoticeDialog
open={true}
method="serial"
onAgree={onAgree}
onCancel={onCancel}
/>,
);

expect(screen.getByText("Data Collection Notice")).toBeInTheDocument();
expect(screen.getByText("Agree to start")).toBeInTheDocument();

userAgentSpy.mockRestore();
});
});

describe("hasAcceptedNotice", () => {
Expand Down
5 changes: 5 additions & 0 deletions src/components/__tests__/DeviceConnection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ jest.mock("@zmkfirmware/zmk-studio-ts-client/transport/serial", () => ({
connect: jest.fn(),
}));

// Mock the app-level USB transport selector
jest.mock("../../lib/transport/usb", () => ({
connect: jest.fn(),
}));

// Mock the BLE transport
jest.mock("@zmkfirmware/zmk-studio-ts-client/transport/gatt", () => ({
connect: jest.fn(),
Expand Down
78 changes: 78 additions & 0 deletions src/lib/transport/__tests__/usb.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { connect as connectSerial } from "@zmkfirmware/zmk-studio-ts-client/transport/serial";
import { connect as connectWebUsb } from "../webUsb";
import {
connect,
isUsbConnectionAvailable,
shouldUseWebUsbForUsbConnection,
} from "../usb";

jest.mock("@zmkfirmware/zmk-studio-ts-client/transport/serial", () => ({
connect: jest.fn(),
}));

jest.mock("../webUsb", () => ({
connect: jest.fn(),
}));

type NavigatorWithOptionalTransport = Navigator & {
serial?: unknown;
usb?: unknown;
};

const androidChromeUserAgent =
"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36";
const desktopChromeUserAgent =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";

describe("USB transport selection", () => {
beforeEach(() => {
jest.clearAllMocks();
delete (navigator as NavigatorWithOptionalTransport).serial;
delete (navigator as NavigatorWithOptionalTransport).usb;
});

test("uses WebUSB for Android Chrome", async () => {
await connectWithUserAgent(androidChromeUserAgent);

expect(connectWebUsb).toHaveBeenCalledTimes(1);
expect(connectSerial).not.toHaveBeenCalled();
});

test("uses Web Serial for non-Android Chrome", async () => {
await connectWithUserAgent(desktopChromeUserAgent);

expect(connectSerial).toHaveBeenCalledTimes(1);
expect(connectWebUsb).not.toHaveBeenCalled();
});

test("detects Android Chrome user agents", () => {
expect(shouldUseWebUsbForUsbConnection(androidChromeUserAgent)).toBe(true);
expect(shouldUseWebUsbForUsbConnection(desktopChromeUserAgent)).toBe(false);
});

test("treats Android Chrome WebUSB as USB-capable without Web Serial", () => {
const userAgentSpy = jest
.spyOn(navigator, "userAgent", "get")
.mockReturnValue(androidChromeUserAgent);
Object.defineProperty(navigator, "usb", {
configurable: true,
value: { requestDevice: jest.fn() },
});

expect(isUsbConnectionAvailable()).toBe(true);

userAgentSpy.mockRestore();
});
});

async function connectWithUserAgent(userAgent: string) {
const userAgentSpy = jest
.spyOn(navigator, "userAgent", "get")
.mockReturnValue(userAgent);

try {
await connect();
} finally {
userAgentSpy.mockRestore();
}
}
23 changes: 23 additions & 0 deletions src/lib/transport/usb.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { RpcTransport } from "@zmkfirmware/zmk-studio-ts-client/transport/index";
import { connect as connectSerial } from "@zmkfirmware/zmk-studio-ts-client/transport/serial";
import { connect as connectWebUsb } from "./webUsb";

export function shouldUseWebUsbForUsbConnection(
userAgent = navigator.userAgent,
) {
return /\bAndroid\b/i.test(userAgent) && /\bChrome\//i.test(userAgent);
}

export function isUsbConnectionAvailable() {
return (
"serial" in navigator ||
(shouldUseWebUsbForUsbConnection() && "usb" in navigator)
);
}

export async function connect(): Promise<RpcTransport> {
if (shouldUseWebUsbForUsbConnection()) {
return connectWebUsb();
}
return connectSerial();
}
Loading
Loading