diff --git a/formulus/App.tsx b/formulus/App.tsx index a7b4cff2b..3e89523ce 100644 --- a/formulus/App.tsx +++ b/formulus/App.tsx @@ -29,6 +29,21 @@ import { FormInitData } from './src/webview/FormulusInterfaceDefinition.ts'; import { FormSpec } from './src/services'; import { initFormulusI18n, i18n } from './src/i18n'; import { DirtyExitGate } from './src/diagnostics/DirtyExitGate'; +import type { LinkingOptions } from '@react-navigation/native'; +import type { MainAppStackParamList } from './src/navigation/ProfileNavigationTypes'; + +const linking: LinkingOptions = { + prefixes: ['formulus://'], + config: { + screens: { + MainApp: { + screens: { + Profiles: 'settings', + }, + }, + }, + }, +}; /** * Inner component that consumes the AppTheme context to build a dynamic @@ -276,7 +291,7 @@ function AppInner(): React.JSX.Element { backgroundColor={themeColors.surface} /> - + {formplayerStack.map((entry, index) => ( + + + + + + + + { +type ProfilesScreenProps = BottomTabScreenProps; +const ProfilesScreen = ({ route, navigation }: ProfilesScreenProps) => { const { t } = useTranslation(); const { themeColors } = useAppTheme(); const shellStyle = useScreenShellStyle(); @@ -49,6 +55,11 @@ const ProfilesScreen = () => { const { profiles, activeProfile } = useProfiles(); const [editor, setEditor] = useState(null); + const editorRef = useRef(editor); + + useEffect(() => { + editorRef.current = editor; + }, [editor]); const [dropdownOpen, setDropdownOpen] = useState(false); const [busy, setBusy] = useState(false); const busyRef = useRef(false); @@ -61,6 +72,41 @@ const ProfilesScreen = () => { }; }, []); + useEffect(() => { + const payload = route?.params?.payload; + if (!payload) return; + if (editor) { + ToastService.showLong(t('profiles.finishCreationFirst')); + navigation.setParams({ payload: undefined }); + return; + } + + const processDeepLink = async () => { + try { + const settings = await QRSettingsService.processPayload(payload); + + if (!mountedRef.current) return; + if (editorRef.current) { + ToastService.showLong(t('profiles.finishCreationFirst')); + return; + } + + setDropdownOpen(false); + setEditor({ label: '', initialSettings: settings }); + } catch { + if (mountedRef.current) { + ToastService.showLong(t('settings.qrInvalid')); + } + } finally { + if (mountedRef.current) { + navigation.setParams({ payload: undefined }); + } + } + }; + + void processDeepLink(); + }, [editor, navigation, route?.params?.payload, t]); + const runAction = useCallback( async (operation, fallbackKey) => { if (busyRef.current || !mountedRef.current) return; diff --git a/formulus/src/screens/__tests__/ProfilesScreen.test.tsx b/formulus/src/screens/__tests__/ProfilesScreen.test.tsx index 52fe39363..dd30501d5 100644 --- a/formulus/src/screens/__tests__/ProfilesScreen.test.tsx +++ b/formulus/src/screens/__tests__/ProfilesScreen.test.tsx @@ -48,7 +48,13 @@ jest.mock('react-native', () => { const mockNavigate = jest.fn(); const mockReset = jest.fn(); -const mockNavigation = { navigate: mockNavigate, reset: mockReset }; +const mockSetParams = jest.fn(); + +const mockNavigation = { + navigate: mockNavigate, + reset: mockReset, + setParams: mockSetParams, +}; const mockConfirm = jest.fn(); const mockListeners = new Set<() => void>(); let mockQRResult: (result: ScannerModalResults) => void; @@ -223,7 +229,10 @@ jest.mock('../../services/ServerConfigService', () => ({ serverConfigService: { isHealthEndpointOk: jest.fn() }, })); jest.mock('../../services/QRSettingsService', () => ({ - QRSettingsService: { processQRCode: jest.fn() }, + QRSettingsService: { + processQRCode: jest.fn(), + processPayload: jest.fn(), + }, })); jest.mock('../../services/SettingsHydrationCache', () => ({ loadSettingsHydrationFromStorage: jest.fn(), @@ -345,6 +354,11 @@ beforeEach(() => { username: 'qr-user', password: 'qr-secret', }); + jest.mocked(QRSettingsService.processPayload).mockResolvedValue({ + serverUrl: 'https://scanned.example', + username: 'scan-user', + password: 'scan-secret', + }); }); async function setup() { @@ -790,6 +804,93 @@ test('new-profile panel requires a name and Cancel abandons manual entry', async expect(profileRegistry.add).not.toHaveBeenCalled(); }); +test('incoming deep link prefills a new profile without saving', async () => { + const route = { + params: { payload: 'FRMLS:test-payload' }, + }; + + const screen = render( + , + ); + + await waitFor(() => { + expect(QRSettingsService.processPayload).toHaveBeenCalledWith( + 'FRMLS:test-payload', + ); + }); + + await waitFor(() => { + expect(screen.getByPlaceholderText('Server URL')).toHaveProp( + 'value', + 'https://scanned.example', + ); + }); + + expect(screen.getByPlaceholderText('Username')).toHaveProp( + 'value', + 'scan-user', + ); + + expect(screen.getByPlaceholderText('Profile name')).toHaveProp('value', ''); + + expect(profileRegistry.add).not.toHaveBeenCalled(); + expect(mockSetParams).toHaveBeenCalledWith({ + payload: undefined, + }); +}); +test('incoming deep link does not overwrite an unfinished profile', async () => { + const route = { params: {} as { payload?: string } }; + + const screen = render( + , + ); + + await waitFor(() => + expect(screen.getByPlaceholderText('Username')).toHaveProp( + 'editable', + true, + ), + ); + + fireEvent.press(screen.getByRole('button', { name: 'Add profile' })); + + fireEvent.changeText( + screen.getByPlaceholderText('Profile name'), + 'My unfinished profile', + ); + + fireEvent.changeText( + screen.getByPlaceholderText('Server URL'), + 'https://original.example', + ); + + route.params = { payload: 'FRMLS:test-payload' }; + + screen.rerender( + , + ); + + await waitFor(() => + expect(ToastService.showLong).toHaveBeenCalledWith( + 'Finish or cancel the current profile before opening another QR link.', + ), + ); + + expect(screen.getByPlaceholderText('Profile name')).toHaveProp( + 'value', + 'My unfinished profile', + ); + + expect(screen.getByPlaceholderText('Server URL')).toHaveProp( + 'value', + 'https://original.example', + ); + + expect(QRSettingsService.processPayload).not.toHaveBeenCalled(); + expect(profileRegistry.add).not.toHaveBeenCalled(); + expect(mockSetParams).toHaveBeenCalledWith({ payload: undefined }); +}); + test('creation QR fills manual fields without saving until a name is entered', async () => { jest.mocked(QRSettingsService.processQRCode).mockResolvedValueOnce({ serverUrl: 'https://scanned.example', diff --git a/formulus/src/services/QRSettingsService.ts b/formulus/src/services/QRSettingsService.ts index 58a9535a8..e679b8587 100644 --- a/formulus/src/services/QRSettingsService.ts +++ b/formulus/src/services/QRSettingsService.ts @@ -1,6 +1,50 @@ import { decodeFRMLS } from '../utils/FRMLSHelpers'; import { normalizeServerUrl } from './normalizeServerUrl'; +function unwrapQRCode(qrString: string): string { + // Preserve legacy FRMLS QR codes. + if (qrString.startsWith('FRMLS:')) { + return qrString; + } + + // Accept formulus://settings and formulus://settings/ + // with a query string. + const match = /^formulus:\/\/settings\/?\?([^#]*)$/i.exec(qrString); + + if (!match) { + throw new Error('Not a Formulus settings link'); + } + + const query = match[1]; + const payloads: string[] = []; + + for (const parameter of query.split('&')) { + const separator = parameter.indexOf('='); + const rawKey = separator === -1 ? parameter : parameter.slice(0, separator); + const rawValue = separator === -1 ? '' : parameter.slice(separator + 1); + + let key: string; + let value: string; + + try { + key = decodeURIComponent(rawKey.replace(/\+/g, ' ')); + value = decodeURIComponent(rawValue.replace(/\+/g, ' ')); + } catch { + throw new Error('Malformed settings link encoding'); + } + + if (key === 'payload') { + payloads.push(value); + } + } + + if (payloads.length !== 1 || !payloads[0]) { + throw new Error('Missing or duplicate FRMLS payload'); + } + + return payloads[0]; +} + export interface SettingsUpdate { serverUrl: string; username: string; @@ -9,11 +53,11 @@ export interface SettingsUpdate { export class QRSettingsService { /** - * Parses a QR code string and extracts settings + * Parses an already extracted FRMLS payload. */ - static parseQRCode(qrString: string): SettingsUpdate { + static parsePayload(payload: string): SettingsUpdate { try { - const frmls = decodeFRMLS(qrString); + const frmls = decodeFRMLS(payload); return { serverUrl: frmls.s, @@ -29,6 +73,35 @@ export class QRSettingsService { } } + /** + * Parses a complete QR code string and extracts settings. + */ + static parseQRCode(qrString: string): SettingsUpdate { + try { + return this.parsePayload(unwrapQRCode(qrString)); + } catch (error) { + throw new Error( + `Invalid QR code format: ${ + error instanceof Error ? error.message : 'Unknown error' + }`, + ); + } + } + + /** + * Parse and normalize an already extracted FRMLS payload. + */ + static async processPayload(payload: string): Promise { + const settings = this.parsePayload(payload); + const normalized = normalizeServerUrl(settings.serverUrl); + + if (!normalized.ok) { + throw new Error(normalized.message); + } + + return { ...settings, serverUrl: normalized.href }; + } + /** * Parse and normalize QR settings without persisting credentials. The caller * saves the server choice and login() stores credentials only after auth succeeds. @@ -36,9 +109,11 @@ export class QRSettingsService { static async processQRCode(qrString: string): Promise { const settings = this.parseQRCode(qrString); const normalized = normalizeServerUrl(settings.serverUrl); + if (!normalized.ok) { throw new Error(normalized.message); } + return { ...settings, serverUrl: normalized.href }; } } diff --git a/formulus/src/services/__tests__/QRSettingsService.test.ts b/formulus/src/services/__tests__/QRSettingsService.test.ts new file mode 100644 index 000000000..3ff34004e --- /dev/null +++ b/formulus/src/services/__tests__/QRSettingsService.test.ts @@ -0,0 +1,134 @@ +/// + +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 or duplicate FRMLS payload'); + }); + + it('parses a settings link with a trailing slash', () => { + const frmls = encodeFRMLS({ + v: 1, + s: 'https://example.org/', + u: 'allen', + p: 'secret', + }); + + const qr = `formulus://settings/?payload=${encodeURIComponent(frmls)}`; + + expect(QRSettingsService.parseQRCode(qr)).toEqual({ + serverUrl: 'https://example.org/', + username: 'allen', + password: 'secret', + }); +}); + +it('parses a settings link with additional query parameters', () => { + const frmls = encodeFRMLS({ + v: 1, + s: 'https://example.org/', + u: 'allen', + p: 'secret', + }); + + const qr = + `formulus://settings?source=qr&payload=${encodeURIComponent(frmls)}` + + '&version=1'; + + expect(QRSettingsService.parseQRCode(qr)).toEqual({ + serverUrl: 'https://example.org/', + username: 'allen', + password: 'secret', + }); +}); + +it('rejects duplicate payload parameters', () => { + expect(() => + QRSettingsService.parseQRCode( + 'formulus://settings?payload=first&payload=second', + ), + ).toThrow('Missing or duplicate FRMLS payload'); +}); + +it('rejects malformed percent-encoding', () => { + expect(() => + QRSettingsService.parseQRCode( + 'formulus://settings?payload=%ZZ', + ), + ).toThrow('Malformed settings link encoding'); +}); + +it('rejects a settings link with an unexpected path', () => { + expect(() => + QRSettingsService.parseQRCode( + 'formulus://settings/other?payload=test', + ), + ).toThrow('Not a Formulus settings link'); +}); + +it('parses an extracted payload directly', () => { + const frmls = encodeFRMLS({ + v: 1, + s: 'https://example.org/', + u: 'allen', + p: 'secret', + }); + + expect(QRSettingsService.parsePayload(frmls)).toEqual({ + serverUrl: 'https://example.org/', + username: 'allen', + password: 'secret', + }); + }); +}); diff --git a/synkronus-cli/internal/cmd/qr.go b/synkronus-cli/internal/cmd/qr.go index 9437ac8f9..a36e5bd81 100644 --- a/synkronus-cli/internal/cmd/qr.go +++ b/synkronus-cli/internal/cmd/qr.go @@ -143,6 +143,7 @@ for the Formulus mobile app. The QR encodes server URL, username, and password.` } encoded := encodeFRMLS(1, serverURL, username, password) + deepLink := "formulus://settings?payload=" + url.QueryEscape(encoded) outputFile, err := cmd.Flags().GetString("output") if err != nil { @@ -154,7 +155,7 @@ for the Formulus mobile app. The QR encodes server URL, username, and password.` // Generate QR code with logo using yeqown/go-qrcode logoPath := "qr_logo.png" - qrc, err := qrcode.New(encoded) + qrc, err := qrcode.New(deepLink) if err != nil { return fmt.Errorf("failed to create QR code: %w", err) } diff --git a/synkronus-portal/src/utils/frmls.ts b/synkronus-portal/src/utils/frmls.ts index c737c38fd..2e61026fb 100644 --- a/synkronus-portal/src/utils/frmls.ts +++ b/synkronus-portal/src/utils/frmls.ts @@ -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)}`; }