Repository navigation
feat(formulus): support settings deep links #1009
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,6 @@ | ||
| import React, { useCallback, useEffect, useRef, useState } from 'react'; | ||
| import type { BottomTabScreenProps } from '@react-navigation/bottom-tabs'; | ||
| import type { MainTabParamList } from '../navigation/ProfileNavigationTypes'; | ||
| import { | ||
| ScrollView, | ||
| StyleSheet, | ||
|
|
@@ -21,7 +23,10 @@ import { profileRegistry } from '../profiles/ProfileRegistry'; | |
| import { switchProfile, deleteProfile } from '../profiles/ProfileTransitions'; | ||
| import { ToastService } from '../services/ToastService'; | ||
| import { setCredentialsForProfile } from '../profiles/ProfileKeychain'; | ||
| import type { SettingsUpdate } from '../services/QRSettingsService'; | ||
| import { | ||
| QRSettingsService, | ||
| type SettingsUpdate, | ||
| } from '../services/QRSettingsService'; | ||
| import { | ||
| odeSpacing, | ||
| odeTypography, | ||
|
|
@@ -41,7 +46,8 @@ type LabelEditor = { | |
| initialSettings?: SettingsUpdate; | ||
| }; | ||
|
|
||
| const ProfilesScreen = () => { | ||
| type ProfilesScreenProps = BottomTabScreenProps<MainTabParamList, 'Profiles'>; | ||
| const ProfilesScreen = ({ route, navigation }: ProfilesScreenProps) => { | ||
| const { t } = useTranslation(); | ||
| const { themeColors } = useAppTheme(); | ||
| const shellStyle = useScreenShellStyle(); | ||
|
|
@@ -61,6 +67,34 @@ const ProfilesScreen = () => { | |
| }; | ||
| }, []); | ||
|
|
||
| useEffect(() => { | ||
| const payload = route?.params?.payload; | ||
| if (!payload) return; | ||
|
|
||
| const processDeepLink = async () => { | ||
| try { | ||
| const settings = await QRSettingsService.processQRCode( | ||
| `formulus://settings?payload=${encodeURIComponent(payload)}`, | ||
| ); | ||
|
Comment on lines
+76
to
+78
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. React Navigation has already decoded Suggestion: add a method like |
||
|
|
||
| if (!mountedRef.current) return; | ||
|
|
||
| setDropdownOpen(false); | ||
| setEditor({ label: '', initialSettings: settings }); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If the user already has the new-profile form open, a link replaces it and discards what they typed. Maybe skip the link, or ask first, when |
||
| } catch { | ||
| if (mountedRef.current) { | ||
| ToastService.showLong(t('settings.qrInvalid')); | ||
| } | ||
| } finally { | ||
| if (mountedRef.current) { | ||
| navigation.setParams({ payload: undefined }); | ||
| } | ||
| } | ||
| }; | ||
|
|
||
| void processDeepLink(); | ||
| }, [navigation, route?.params?.payload, t]); | ||
|
|
||
| const runAction = useCallback<RunProfileAction>( | ||
| async (operation, fallbackKey) => { | ||
| if (busyRef.current || !mountedRef.current) return; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,23 @@ | ||
| import { decodeFRMLS } from '../utils/FRMLSHelpers'; | ||
| import { normalizeServerUrl } from './normalizeServerUrl'; | ||
|
|
||
| const FORMULUS_SETTINGS_URL = 'formulus://settings'; | ||
|
|
||
| function unwrapQRCode(qrString: string): string { | ||
| if (qrString.startsWith('FRMLS:')) return qrString; | ||
|
|
||
| const prefix = `${FORMULUS_SETTINGS_URL}?payload=`; | ||
| if (!qrString.startsWith(prefix)) { | ||
| throw new Error('Not a Formulus settings link'); | ||
| } | ||
|
Comment on lines
+6
to
+12
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The strict Could we extract the value more flexibly (e.g. |
||
|
|
||
| const encodedPayload = qrString.slice(prefix.length); | ||
| if (!encodedPayload) { | ||
| throw new Error('Missing FRMLS payload'); | ||
| } | ||
|
|
||
| return decodeURIComponent(encodedPayload); | ||
| } | ||
| export interface SettingsUpdate { | ||
| serverUrl: string; | ||
| username: string; | ||
|
|
@@ -13,7 +30,7 @@ export class QRSettingsService { | |
| */ | ||
| static parseQRCode(qrString: string): SettingsUpdate { | ||
| try { | ||
| const frmls = decodeFRMLS(qrString); | ||
| const frmls = decodeFRMLS(unwrapQRCode(qrString)); | ||
|
|
||
| return { | ||
| serverUrl: frmls.s, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| /// <reference types="jest" /> | ||
|
|
||
| import { describe, it, expect } from '@jest/globals'; | ||
| import { encodeFRMLS } from '../../utils/FRMLSHelpers'; | ||
| import { QRSettingsService } from '../QRSettingsService'; | ||
|
|
||
| describe('QRSettingsService', () => { | ||
| it('parses a bare FRMLS payload', () => { | ||
| const qr = encodeFRMLS({ | ||
| v: 1, | ||
| s: 'https://example.org/', | ||
| u: 'allen', | ||
| p: 'secret', | ||
| }); | ||
|
|
||
| const settings = QRSettingsService.parseQRCode(qr); | ||
|
|
||
| expect(settings).toEqual({ | ||
| serverUrl: 'https://example.org/', | ||
| username: 'allen', | ||
| password: 'secret', | ||
| }); | ||
| }); | ||
| it('parses an FRMLS payload wrapped in a Formulus settings deep link', () => { | ||
| const frmls = encodeFRMLS({ | ||
| v: 1, | ||
| s: 'https://example.org/', | ||
| u: 'allen', | ||
| p: 'secret', | ||
| }); | ||
| const qr = `formulus://settings?payload=${encodeURIComponent(frmls)}`; | ||
|
|
||
| const settings = QRSettingsService.parseQRCode(qr); | ||
|
|
||
| expect(settings).toEqual({ | ||
| serverUrl: 'https://example.org/', | ||
| username: 'allen', | ||
| password: 'secret', | ||
| }); | ||
| }); | ||
| it('rejects a non-Formulus deep link', () => { | ||
| const frmls = encodeFRMLS({ | ||
| v: 1, | ||
| s: 'https://example.org/', | ||
| u: 'allen', | ||
| p: 'secret', | ||
| }); | ||
| const qr = `https://example.org/settings?payload=${encodeURIComponent(frmls)}`; | ||
|
|
||
| expect(() => QRSettingsService.parseQRCode(qr)).toThrow( | ||
| 'Not a Formulus settings link', | ||
| ); | ||
| }); | ||
| it('rejects a Formulus settings deep link with no payload', () => { | ||
| expect(() => | ||
| QRSettingsService.parseQRCode('formulus://settings?payload='), | ||
| ).toThrow('Missing FRMLS payload'); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -32,10 +32,11 @@ export function encodeFormulusLoginQr( | |
| username: string, | ||
| password: string, | ||
| ): string { | ||
| return encodeFRMLS({ | ||
| const frmls = encodeFRMLS({ | ||
| v: 1, | ||
| s: serverUrl, | ||
| u: username, | ||
| p: password, | ||
| }); | ||
| return `formulus://settings?payload=${encodeURIComponent(frmls)}`; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This makes the portal emit only the new Could we keep a way to generate the old format for a release or two (a toggle in the portal and a |
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The scheme is registered here, but I don't think iOS will hand the link to React Native yet. This app uses the scene lifecycle (UIApplicationSceneManifest + SceneDelegate), and SceneDelegate.swift only implements
willConnectTo. With scenes, incoming URLs arrive throughscene(_:openURLContexts:)(app already running) andconnectionOptions.urlContexts(cold start), notapplication(_:open:options:). Neither is forwarded toRCTLinkingManagertoday.Could we add that and test on an iOS device, or limit this PR to Android and say so in the description? Right now it reads as if iOS is supported, but it's untested.