From 568900b6707c575e674d5e482c7718292bef5bcf Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Thu, 18 Jun 2026 14:43:39 +0100 Subject: [PATCH] Add Android download QR and link --- src/main/external-url-ipc.ts | 26 +++++++++ src/main/index.ts | 2 + src/preload/index.ts | 3 ++ src/renderer/App.tsx | 5 ++ src/renderer/api.d.ts | 1 + .../switchify-android-play-store-qr.svg | 1 + .../components/AndroidDownloadPanel.tsx | 28 ++++++++++ src/renderer/styles.css | 53 +++++++++++++++++++ src/shared/ipc-channels.ts | 1 + 9 files changed, 120 insertions(+) create mode 100644 src/main/external-url-ipc.ts create mode 100644 src/renderer/assets/switchify-android-play-store-qr.svg create mode 100644 src/renderer/components/AndroidDownloadPanel.tsx diff --git a/src/main/external-url-ipc.ts b/src/main/external-url-ipc.ts new file mode 100644 index 0000000..c7d870c --- /dev/null +++ b/src/main/external-url-ipc.ts @@ -0,0 +1,26 @@ +import { ipcMain, shell } from 'electron'; +import { OPEN_EXTERNAL_URL_CHANNEL } from '../shared/ipc-channels'; + +export function registerExternalUrlIpc(): void { + ipcMain.handle(OPEN_EXTERNAL_URL_CHANNEL, async (_event, rawUrl: unknown): Promise<{ ok: boolean; reason?: string }> => { + if (typeof rawUrl !== 'string') return { ok: false, reason: 'invalid_url' }; + + let url: URL; + try { + url = new URL(rawUrl); + } catch { + return { ok: false, reason: 'invalid_url' }; + } + + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + return { ok: false, reason: 'unsupported_protocol' }; + } + + try { + await shell.openExternal(url.toString()); + return { ok: true }; + } catch { + return { ok: false, reason: 'open_failed' }; + } + }); +} diff --git a/src/main/index.ts b/src/main/index.ts index 57e7f54..c081b5b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -9,6 +9,7 @@ import { CursorOverlay } from './cursor-overlay'; import { registerCursorOverlayIpc } from './cursor-overlay-ipc'; import { JsonCursorOverlaySettingsStore } from './cursor-overlay-settings-store'; import { registerAppWindowIpc } from './app-window-ipc'; +import { registerExternalUrlIpc } from './external-url-ipc'; import { DesktopCommandExecutor } from './input/command-executor'; import { LibnutWin32InputAdapter } from './input/libnut-win32-adapter'; import { createPointerMovementProfile } from './input/pointer-profile'; @@ -282,6 +283,7 @@ app.whenReady().then(() => { registerPairingApprovalIpc(controlService); registerSettingsWindowIpc(showSettingsWindow); registerAppWindowIpc(); + registerExternalUrlIpc(); registerSystemStartupIpc(systemStartup); registerUpdateIpc( new UpdateService({ diff --git a/src/preload/index.ts b/src/preload/index.ts index d3d5815..0ba3d2c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -16,6 +16,7 @@ import { GET_SYSTEM_STARTUP_SETTINGS_CHANNEL, GET_UPDATE_STATE_CHANNEL, INSTALL_DOWNLOADED_UPDATE_CHANNEL, + OPEN_EXTERNAL_URL_CHANNEL, OPEN_SETTINGS_WINDOW_CHANNEL, RESPOND_TO_PAIRING_REQUEST_CHANNEL, SERVER_STATUS_CHANNEL, @@ -45,6 +46,8 @@ contextBridge.exposeInMainWorld('switchifyPc', { setCursorOverlaySettings: (settings: CursorOverlaySettings): Promise => ipcRenderer.invoke(SET_CURSOR_OVERLAY_SETTINGS_CHANNEL, settings), openSettingsWindow: (): Promise => ipcRenderer.invoke(OPEN_SETTINGS_WINDOW_CHANNEL), + openExternalUrl: (url: string): Promise<{ ok: boolean }> => + ipcRenderer.invoke(OPEN_EXTERNAL_URL_CHANNEL, url), getPendingPairingRequests: (): Promise => ipcRenderer.invoke(GET_PENDING_PAIRING_REQUESTS_CHANNEL), respondToPairingRequest: (requestId: string, decision: PairingApprovalDecision): Promise<{ ok: boolean; reason?: string }> => diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 2725e05..397614d 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,4 +1,5 @@ import type { ReactElement } from 'react'; +import { AndroidDownloadPanel } from './components/AndroidDownloadPanel'; import { PairingApprovalRequests } from './components/PairingApprovalRequests'; import { PrimaryContent } from './components/PrimaryContent'; import { StatusHeader } from './components/StatusHeader'; @@ -42,6 +43,10 @@ function MainApp(): ReactElement { onRefresh={status.refresh} /> + {status.uiState === 'connected' ? null : ( + + )} + Promise; setCursorOverlaySettings: (settings: CursorOverlaySettings) => Promise; openSettingsWindow: () => Promise; + openExternalUrl: (url: string) => Promise<{ ok: boolean }>; getPendingPairingRequests: () => Promise; respondToPairingRequest: ( requestId: string, diff --git a/src/renderer/assets/switchify-android-play-store-qr.svg b/src/renderer/assets/switchify-android-play-store-qr.svg new file mode 100644 index 0000000..41c43e5 --- /dev/null +++ b/src/renderer/assets/switchify-android-play-store-qr.svg @@ -0,0 +1 @@ + diff --git a/src/renderer/components/AndroidDownloadPanel.tsx b/src/renderer/components/AndroidDownloadPanel.tsx new file mode 100644 index 0000000..a37694a --- /dev/null +++ b/src/renderer/components/AndroidDownloadPanel.tsx @@ -0,0 +1,28 @@ +import type { MouseEvent, ReactElement } from 'react'; +import androidQrCodeUrl from '../assets/switchify-android-play-store-qr.svg'; + +export const SWITCHIFY_ANDROID_DOWNLOAD_URL = 'https://play.google.com/store/apps/details?id=com.enaboapps.switchify'; + +export function AndroidDownloadPanel({ + onOpenDownload +}: { + onOpenDownload: (url: string) => Promise<{ ok: boolean }>; +}): ReactElement { + const openDownload = (event: MouseEvent): void => { + event.preventDefault(); + void onOpenDownload(SWITCHIFY_ANDROID_DOWNLOAD_URL); + }; + + return ( +
+ QR code for Switchify on Google Play +
+

Get Switchify for Android

+

Scan the QR code or open the Google Play listing on your Android device.

+ + Download Switchify for Android + +
+
+ ); +} diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 6d8f7e5..3444774 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -601,6 +601,49 @@ p { background: var(--color-status-error); } +.android-download-panel { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: var(--space-m); + align-items: center; + margin-top: var(--space-l); + border: 1px solid var(--color-border); + border-radius: var(--radius-control); + padding: var(--space-m); + background: var(--color-surface-muted); + text-align: left; +} + +.android-download-panel img { + width: 112px; + height: 112px; + border: 1px solid var(--color-border); + border-radius: 8px; + padding: 6px; + background: #ffffff; +} + +.android-download-panel div { + display: grid; + gap: var(--space-xs); + min-width: 0; +} + +.android-download-panel h2 { + font-size: 1.05rem; +} + +.android-download-panel a { + width: fit-content; + color: var(--brand-secondary); + font-weight: 800; + text-decoration: none; +} + +.android-download-panel a:hover { + text-decoration: underline; +} + .settings-window-shell { display: grid; align-content: start; @@ -935,6 +978,16 @@ p { white-space: normal; } + .android-download-panel { + grid-template-columns: 1fr; + justify-items: center; + text-align: center; + } + + .android-download-panel a { + justify-self: center; + } + .detail-item { grid-template-columns: 1fr; gap: 3px; diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index 6c15f3a..a4e5d76 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -11,6 +11,7 @@ export const RESPOND_TO_PAIRING_REQUEST_CHANNEL = 'pairing-approval:respond'; export const OPEN_SETTINGS_WINDOW_CHANNEL = 'settings:open-window'; export const APP_WINDOW_MINIMIZE_CHANNEL = 'app-window:minimize'; export const APP_WINDOW_CLOSE_CHANNEL = 'app-window:close'; +export const OPEN_EXTERNAL_URL_CHANNEL = 'shell:open-external-url'; export const GET_SYSTEM_STARTUP_SETTINGS_CHANNEL = 'system-startup:get-settings'; export const SET_START_WITH_SYSTEM_CHANNEL = 'system-startup:set-start-with-system'; export const GET_UPDATE_STATE_CHANNEL = 'updates:get-state';