Skip to content

Commit 5b2f348

Browse files
enaboappsOwen McGirr
andauthored
fix: show Windows PC names on iOS (#100)
* fix: show Windows PC names on iOS * fix: distinguish iOS Windows advertisements --------- Co-authored-by: Owen McGirr <owenmcgirr@Owens-Mac-Studio-2.local>
1 parent b587bd1 commit 5b2f348

5 files changed

Lines changed: 128 additions & 14 deletions

File tree

src/connection/integration.test.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ class LoopbackTransport implements BleTransport {
3333
requestIds: { id: string; type: string; authenticated: boolean }[] = [];
3434
connectCount = 0;
3535
connectFailures = 0;
36+
resolvedDesktop = desktop;
3637
readinessGate: Promise<void> | null = null;
3738
responseGates = new Map<string, Promise<void>>();
3839
responseGateQueues = new Map<string, Promise<void>[]>();
@@ -44,7 +45,7 @@ class LoopbackTransport implements BleTransport {
4445
resolveAndConnect = async () => {
4546
this.connectCount += 1;
4647
if (this.connectFailures > 0) { this.connectFailures -= 1; throw new Error('connect failed'); }
47-
return desktop;
48+
return this.resolvedDesktop;
4849
};
4950
connect = async () => undefined;
5051
disconnect = async () => undefined;
@@ -109,6 +110,20 @@ describe('pairing and authenticated connection integration', () => {
109110
expect(transport.requestPayloads.find(({ type }) => type === 'connection.ping')?.payload).toEqual({ deviceName: 'OPD2403' });
110111
});
111112

113+
it('refreshes a saved Windows name after successful authentication', async () => {
114+
const transport = new LoopbackTransport();
115+
transport.resolvedDesktop = { ...desktop, displayName: 'Owen’s Windows PC' };
116+
const storage = new MemoryStorage();
117+
storage.saved = [{ ...desktop, displayName: 'Switchify PC', lastConnectedAt: 1 }];
118+
storage.tokens.set(desktop.desktopId, 'fixture-secret');
119+
const manager = new ConnectionManager(transport, storage, new DiagnosticLog(), async () => true);
120+
121+
await manager.connectSaved(storage.saved[0]!);
122+
123+
expect(storage.saved[0]).toMatchObject({ desktopId: desktop.desktopId, displayName: 'Owen’s Windows PC' });
124+
expect(manager.snapshot()).toMatchObject({ kind: 'connected', desktop: { displayName: 'Owen’s Windows PC' } });
125+
});
126+
112127
it('updates a connected PC and defers when offline', async () => {
113128
let remoteName = 'OPD2403';
114129
const transport = new LoopbackTransport();

src/transport/ReactNativeBleTransport.test.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ const descriptor = (value: string): Descriptor => ({ value } as Descriptor);
66

77
function device(overrides: Partial<Device> = {}): Device {
88
const base: Record<string, unknown> = {
9-
id: 'ble-1', name: null, mtu: 185, rssi: -42,
9+
id: 'ble-1', name: null, localName: null, mtu: 185, rssi: -42,
1010
isConnected: jest.fn(async () => true), cancelConnection: jest.fn(async () => null as unknown as Device),
1111
requestConnectionPriority: jest.fn(async () => base),
1212
requestMTU: jest.fn(async () => ({ ...base, mtu: 517 })),
@@ -410,7 +410,7 @@ describe('ReactNativeBleTransport', () => {
410410

411411
it('publishes the actual Windows Bluetooth device name', async () => {
412412
let scanCallback!: (error: Error | null, value: Device | null) => void;
413-
const advertised = device({ name: 'Oliver Laptop' });
413+
const advertised = device({ name: 'Oliver Laptop', localName: 'Switchify PC' });
414414
const native = manager({ startDeviceScan: jest.fn((_uuids, _options, callback) => { scanCallback = callback; }) });
415415
const transport = new ReactNativeBleTransport(native, 'android');
416416
const found = jest.fn();
@@ -422,6 +422,45 @@ describe('ReactNativeBleTransport', () => {
422422
stop();
423423
});
424424

425+
it('publishes the advertised Windows local name on iOS', async () => {
426+
let scanCallback!: (error: Error | null, value: Device | null) => void;
427+
const advertised = device({ name: 'Switchify PC', localName: 'Owen’s Windows PC' });
428+
const native = manager({ startDeviceScan: jest.fn((_uuids, _options, callback) => { scanCallback = callback; }) });
429+
const transport = new ReactNativeBleTransport(native, 'ios');
430+
const found = jest.fn();
431+
const stop = transport.scan(found, jest.fn());
432+
433+
scanCallback(null, advertised);
434+
await waitFor(() => found.mock.calls.length === 1);
435+
436+
expect(found).toHaveBeenCalledWith(expect.objectContaining({ displayName: 'Owen’s Windows PC', platform: 'windows' }));
437+
stop();
438+
});
439+
440+
it('discovers multiple Windows PCs with a generic iOS device name', async () => {
441+
let scanCallback!: (error: Error | null, value: Device | null) => void;
442+
const makeWindowsPc = (id: string, desktopId: string, localName: string) => device({
443+
id,
444+
name: 'Switchify PC',
445+
localName,
446+
readCharacteristicForService: jest.fn(async () => ({ value: fromByteArray(new TextEncoder().encode(JSON.stringify({ protocolVersion: 1, desktopId, displayName: 'Switchify PC', platform: 'windows' }))) } as Characteristic)),
447+
});
448+
const first = makeWindowsPc('windows-1', 'pc-1', 'Office PC');
449+
const second = makeWindowsPc('windows-2', 'pc-2', 'Living Room PC');
450+
const native = manager({ startDeviceScan: jest.fn((_uuids, _options, callback) => { scanCallback = callback; }) });
451+
const transport = new ReactNativeBleTransport(native, 'ios');
452+
const found = jest.fn();
453+
const stop = transport.scan(found, jest.fn());
454+
455+
scanCallback(null, first);
456+
await waitFor(() => found.mock.calls.some(([desktop]) => desktop.desktopId === 'pc-1'));
457+
scanCallback(null, second);
458+
await waitFor(() => found.mock.calls.some(([desktop]) => desktop.desktopId === 'pc-2'));
459+
460+
expect(found.mock.calls.map(([desktop]) => desktop.displayName)).toEqual(['Office PC', 'Living Room PC']);
461+
stop();
462+
});
463+
425464
it('hands a matching discovery connection directly to the authenticated session', async () => {
426465
let scanCallback!: (error: Error | null, value: Device | null) => void;
427466
const configured = device({ isConnected: jest.fn(async () => true), mtu: 517 });

src/transport/ReactNativeBleTransport.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { toByteArray } from 'base64-js';
55
import { BLE_DESCRIPTORS, BLE_UUIDS } from '@/domain/protocol/constants';
66
import { parseStatus } from '@/domain/protocol/responses';
77
import type { BleAvailability, BleTransport, DiscoveredDesktop, Unsubscribe } from './BleTransport';
8-
import { desktopDisplayName } from './desktopDisplayName';
8+
import { bluetoothDeviceDisplayName, desktopDisplayName } from './desktopDisplayName';
99

1010
export class ReactNativeBleTransport implements BleTransport {
1111
#manager: BleManager | null;
@@ -310,7 +310,7 @@ export class ReactNativeBleTransport implements BleTransport {
310310
const status = parseStatus(raw);
311311
const desktop = status ? {
312312
...status,
313-
displayName: desktopDisplayName(status, device.name),
313+
displayName: desktopDisplayName(status, { name: device.name, localName: device.localName }, this.platform),
314314
peripheralId: device.id,
315315
rssi: device.rssi ?? null,
316316
} : null;
@@ -371,7 +371,8 @@ export class ReactNativeBleTransport implements BleTransport {
371371
}
372372

373373
#scanKey(device: Device): string {
374-
return device.name ? `name:${device.name}` : `id:${device.id}`;
374+
const name = bluetoothDeviceDisplayName({ name: device.name, localName: device.localName }, this.platform);
375+
return name ? `name:${name}` : `id:${device.id}`;
375376
}
376377

377378
#bounded<T>(operation: Promise<T>, timeoutMs = this.nativeTimeoutMs): Promise<T> {

src/transport/desktopDisplayName.test.ts

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,60 @@ describe('desktopDisplayName', () => {
44
it('prefers the Bluetooth device name for Windows', () => {
55
expect(desktopDisplayName(
66
{ desktopId: 'pc-1', displayName: 'Switchify PC', platform: 'windows' },
7-
' Oliver Laptop ',
7+
{ name: ' Oliver Laptop ', localName: 'Switchify PC' },
8+
'android',
89
)).toBe('Oliver Laptop');
910
});
1011

12+
it('prefers the advertised local name for Windows on iOS', () => {
13+
expect(desktopDisplayName(
14+
{ desktopId: 'pc-1', displayName: 'Switchify PC', platform: 'windows' },
15+
{ name: 'Cached Windows Name', localName: ' Owen’s Windows PC ' },
16+
'ios',
17+
)).toBe('Owen’s Windows PC');
18+
});
19+
20+
it('preserves Android device-name precedence', () => {
21+
expect(desktopDisplayName(
22+
{ desktopId: 'pc-1', displayName: 'Status Name', platform: 'windows' },
23+
{ name: 'Android Device Name', localName: 'Advertisement Name' },
24+
'android',
25+
)).toBe('Android Device Name');
26+
});
27+
28+
it('uses the advertised local name as an Android fallback', () => {
29+
expect(desktopDisplayName(
30+
{ desktopId: 'pc-1', displayName: 'Switchify PC', platform: 'windows' },
31+
{ name: null, localName: 'Windows Local Name' },
32+
'android',
33+
)).toBe('Windows Local Name');
34+
});
35+
1136
it('prefers the status display name for macOS', () => {
1237
expect(desktopDisplayName(
1338
{ desktopId: 'pc-1', displayName: 'Owen’s Mac Studio', platform: 'macos' },
14-
'Mac',
39+
{ name: 'Mac', localName: 'Switchify PC' },
40+
'ios',
1541
)).toBe('Owen’s Mac Studio');
1642
});
1743

1844
it.each([
19-
[null, 'Office PC'],
20-
[' ', 'Office PC'],
21-
])('falls back from a missing Windows Bluetooth name', (bluetoothName, expected) => {
45+
[{ name: null, localName: null }, 'Office PC'],
46+
[{ name: ' ', localName: ' ' }, 'Office PC'],
47+
[{ name: 'Switchify PC', localName: 'SWITCHIFY PC' }, 'Office PC'],
48+
])('falls back from missing or generic Windows Bluetooth names', (bluetooth, expected) => {
2249
expect(desktopDisplayName(
2350
{ desktopId: 'pc-1', displayName: 'Office PC', platform: 'windows' },
24-
bluetoothName,
51+
bluetooth,
52+
'ios',
2553
)).toBe(expected);
2654
});
55+
56+
it('uses a Unicode local name when the iOS device name is unavailable', () => {
57+
expect(desktopDisplayName(
58+
{ desktopId: 'pc-1', displayName: 'Switchify PC', platform: 'windows' },
59+
{ name: null, localName: ' Büro-PC 日本語 ' },
60+
'ios',
61+
)).toBe('Büro-PC 日本語');
62+
});
2763
});

src/transport/desktopDisplayName.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,36 @@ import type { PcStatus } from '@/domain/protocol/types';
22

33
const PRODUCT_NAME = 'Switchify PC';
44

5-
export function desktopDisplayName(status: PcStatus, bluetoothDeviceName: string | null | undefined): string {
5+
export type BluetoothDeviceNames = {
6+
name: string | null | undefined;
7+
localName: string | null | undefined;
8+
};
9+
10+
export function bluetoothDeviceDisplayName(bluetooth: BluetoothDeviceNames, remotePlatform: string): string | null {
11+
const deviceName = normalized(bluetooth.name);
12+
const localName = normalized(bluetooth.localName);
13+
const candidates = remotePlatform === 'ios'
14+
? [localName, deviceName]
15+
: [deviceName, localName];
16+
return candidates.find((candidate) => candidate !== null && !isGeneric(candidate))
17+
?? candidates.find((candidate) => candidate !== null)
18+
?? null;
19+
}
20+
21+
export function desktopDisplayName(status: PcStatus, bluetooth: BluetoothDeviceNames, remotePlatform: string): string {
622
const statusName = normalized(status.displayName);
723
if (status.platform === 'macos') return statusName ?? PRODUCT_NAME;
8-
return normalized(bluetoothDeviceName) ?? statusName ?? PRODUCT_NAME;
24+
const candidates = [bluetoothDeviceDisplayName(bluetooth, remotePlatform), statusName];
25+
return candidates.find((candidate) => candidate !== null && !isGeneric(candidate))
26+
?? candidates.find((candidate) => candidate !== null)
27+
?? PRODUCT_NAME;
928
}
1029

1130
function normalized(value: string | null | undefined): string | null {
1231
const name = value?.trim();
1332
return name ? name : null;
1433
}
34+
35+
function isGeneric(value: string): boolean {
36+
return value.toLowerCase() === PRODUCT_NAME.toLowerCase();
37+
}

0 commit comments

Comments
 (0)