Skip to content

Commit

Permalink
[menu-bar] Refactor deep-link routes to comply with the URL spec (#151)
Browse files Browse the repository at this point in the history
* [menu-bar] Refactor deep-link routes to comply with the URL spec

* Add changelog entry

* Update tests to use triple slashes

* Update Expo auth to use triple slashes

* Update local server to handle triple slashes
  • Loading branch information
gabrieldonadel authored Jan 29, 2024
1 parent 6a56f6c commit 40a16db
Show file tree
Hide file tree
Showing 7 changed files with 559 additions and 38 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
- Upgrade `react-native` to 0.73.2. ([#143](https://github.com/expo/orbit/pull/143) by [@gabrieldonadel](https://github.com/gabrieldonadel))
- Upgrade `react-native-svg` to 14.1.0. ([#143](https://github.com/expo/orbit/pull/143) by [@gabrieldonadel](https://github.com/gabrieldonadel))
- Migrate ProgressIndicator to Expo Modules. ([#150](https://github.com/expo/orbit/pull/150) by [@gabrieldonadel](https://github.com/gabrieldonadel))
- Refactor deep-link routes to comply with the URL specification. ([#151](https://github.com/expo/orbit/pull/151) by [@gabrieldonadel](https://github.com/gabrieldonadel))

## 1.0.2 — 2024-01-17

Expand Down
20 changes: 17 additions & 3 deletions apps/menu-bar/macos/ExpoMenuBar-macOS/SwifterWrapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,26 @@ private let WHITELISTED_DOMAINS = ["expo.dev", "expo.test", "exp.host"]
}

private func extractRootDomain(from urlString: String) -> String {
guard let originUrl = URL(string: urlString.removingPercentEncoding ?? ""),
var hostName = originUrl.host else {
guard let originUrl = URL(string: urlString.removingPercentEncoding ?? "") else {
return ""
}

// Orbit deeplink may include specific routes in the URL e.g. /update, /snack, /download, etc.
var hostName: String
if let originalHostName = originUrl.host {
hostName = originalHostName
} else {
// Orbit deeplink may include specific routes in the URL e.g. /update, /snack, /download, etc.
let components = NSURLComponents(url: originUrl, resolvingAgainstBaseURL: true)
let urlStringFromParams = components?.queryItems?.first(where: { $0.name == "url" })?.value

if urlStringFromParams != nil {
let urlFromParams = URL(string: urlStringFromParams ?? "")
hostName = urlFromParams?.host ?? ""
} else {
hostName = ""
}
}

if !hostName.contains(".") {
hostName = originUrl.pathComponents[1]
}
Expand Down
3 changes: 2 additions & 1 deletion apps/menu-bar/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,14 @@
"eslint": "^8.48.0",
"eslint-config-universe": "^12.0.0",
"jest": "^29.6.3",
"jest-expo": "~50.0.1",
"prettier": "^3.0.2",
"react-native-svg-transformer": "^1.3.0",
"react-test-renderer": "18.2.0",
"typescript": "^5.3.0"
},
"jest": {
"preset": "react-native"
"preset": "jest-expo"
},
"engines": {
"node": ">=18"
Expand Down
107 changes: 107 additions & 0 deletions apps/menu-bar/src/utils/__tests__/parseUrl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { URLType, identifyAndParseDeeplinkURL } from '../parseUrl';

jest.mock('../../modules/Storage', () => ({
saveSessionSecret: jest.fn(),
}));

describe('identifyAndParseDeeplinkURL', () => {
describe('Auth URLs', () => {
it('should support Auth callback URL from expo.dev', () => {
const authCallbackURL =
'expo-orbit:///auth?username_or_email=gabrieldonadel&session_secret=%7B%22id%22%3A%22XXXXXXXX-XXXX-XXXX-XXXXX-XXXXXXXXXXXX%22%2C%22version%22%3A1%2C%22expires_at%22%3A0000000000000%7D';

expect(identifyAndParseDeeplinkURL(authCallbackURL)).toEqual({
urlType: URLType.AUTH,
url: authCallbackURL,
});
});
});

describe('Build URLs', () => {
it('Should identiy expo.dev/artifacts URLs as Build URLs', () => {
const artifactDeeplinkURL =
'expo-orbit://expo.dev/artifacts/eas/v3WshxGCF87UzsHSxfRnAh.tar.gz';

expect(identifyAndParseDeeplinkURL(artifactDeeplinkURL)).toEqual({
urlType: URLType.EXPO_BUILD,
url: artifactDeeplinkURL.replace('expo-orbit://', 'https://'),
});
});

it('Should parse url parameter from /download route', () => {
const artifactURL = 'https://expo.dev/artifacts/eas/v3WshxGCF87UzsHSxfRnAh.tar.gz';
const artifactDeeplinkURL = `expo-orbit:///download/?url=${encodeURIComponent(artifactURL)}`;

expect(identifyAndParseDeeplinkURL(artifactDeeplinkURL)).toEqual({
urlType: URLType.EXPO_BUILD,
url: artifactURL,
});
});
});

describe('Update URLs', () => {
it('Should parse url parameter from /update route', () => {
const updateURL = 'https://u.expo.dev/update/addecbed-f477-4a75-bd88-0732dc928fe9';
const updateDeeplinkURL = `expo-orbit:///update?url=${encodeURIComponent(updateURL)}`;

expect(identifyAndParseDeeplinkURL(updateDeeplinkURL)).toEqual({
urlType: URLType.EXPO_UPDATE,
url: updateURL,
});
});

it('Should throw if url parameter is not present', () => {
const updateDeeplinkURL = `expo-orbit:///update`;

expect(() => identifyAndParseDeeplinkURL(updateDeeplinkURL)).toThrowError();
});
});

describe('Snack URLs', () => {
it('Should identiy exp.host URLs as Snack URLs', () => {
const snackDeeplinkURL = 'expo-orbit://exp.host/@gabrieldonadel/ec41d8+IH9vwTGYrg';

expect(identifyAndParseDeeplinkURL(snackDeeplinkURL)).toEqual({
urlType: URLType.SNACK,
url: snackDeeplinkURL.replace('expo-orbit://', 'exp://'),
});
});

it('Should parse url parameter from /snack route', () => {
const snackURL =
'exp://staging-u.expo.dev/2dce2748-c51f-4865-bae0-392af794d60a?runtime-version=exposdk%3A50.0.0&channel-name=production&snack-channel=Hhhqw6NhFw';
const snackDeeplinkURL = `expo-orbit:///snack?url=${encodeURIComponent(snackURL)}`;

expect(identifyAndParseDeeplinkURL(snackDeeplinkURL)).toEqual({
urlType: URLType.SNACK,
url: snackURL,
});
});
});

describe('Unsuported URLs', () => {
it('Should throw an error when the URL route is not supported', () => {
const unsuportedURL = 'expo-orbit:///some-future-route';

expect(() => identifyAndParseDeeplinkURL(unsuportedURL)).toThrowError();
});

it('Should throw an error when the URL is invalid', () => {
const unsuportedURL = '::::?///aklsdmalksjdaoijoeqw';

expect(() => identifyAndParseDeeplinkURL(unsuportedURL)).toThrowError();
});
});

describe('Unknown URLs', () => {
it('Should identiy non-expo domains that are not using specific routes as Unknown URLs', () => {
const unknownURL =
'expo-orbit://github.com/expo/orbit/releases/download/expo-orbit-v1.0.2/expo-orbit.v1.0.2.zip';

expect(identifyAndParseDeeplinkURL(unknownURL)).toEqual({
urlType: URLType.UNKNOWN,
url: unknownURL.replace('expo-orbit://', 'https://'),
});
});
});
});
64 changes: 46 additions & 18 deletions apps/menu-bar/src/utils/parseUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,32 +15,52 @@ export function handleAuthUrl(url: string) {
saveSessionSecret(sessionSecret);
}

export function identifyAndParseDeeplinkURL(deeplinkURL: string): {
export function identifyAndParseDeeplinkURL(deeplinkURLString: string): {
urlType: URLType;
url: string;
} {
const urlWithoutProtocol = deeplinkURL.replace(/^[^:]+:\/\//, '');
/**
* The URL implementation when running Jest does not support
* custom schemes + URLs without domains. That's why we
* default to http://expo.dev when creating a new URL instance.
*/
const urlWithoutProtocol = deeplinkURLString.replace(/^[^:]+:\/\//, '');
const deeplinkURL = new URL(deeplinkURLString, 'http://expo.dev');

if (urlWithoutProtocol.startsWith('auth?')) {
return { urlType: URLType.AUTH, url: deeplinkURL };
if (deeplinkURL.pathname.startsWith('/auth')) {
return { urlType: URLType.AUTH, url: deeplinkURLString };
}
if (urlWithoutProtocol.startsWith('update/')) {
if (deeplinkURL.pathname.startsWith('/update')) {
return {
urlType: URLType.EXPO_UPDATE,
url: `https://${urlWithoutProtocol.replace('update/', '')}`,
url: getUrlFromSearchParams(deeplinkURL.searchParams),
};
}
if (
urlWithoutProtocol.startsWith('expo.dev/artifacts') ||
urlWithoutProtocol.startsWith('download/')
) {
if (deeplinkURL.pathname.startsWith('/download')) {
return {
urlType: URLType.EXPO_BUILD,
url: `https://${urlWithoutProtocol.replace('download/', '')}`,
url: getUrlFromSearchParams(deeplinkURL.searchParams),
};
}
if (urlWithoutProtocol.includes('exp.host/') || urlWithoutProtocol.startsWith('snack/')) {
return { urlType: URLType.SNACK, url: `exp://${urlWithoutProtocol.replace('snack/', '')}` };
if (deeplinkURL.pathname.startsWith('/snack')) {
return {
urlType: URLType.SNACK,
url: getUrlFromSearchParams(deeplinkURL.searchParams),
};
}

// Deprecated formats
if (urlWithoutProtocol.startsWith('expo.dev/artifacts')) {
return {
urlType: URLType.EXPO_BUILD,
url: `https://${urlWithoutProtocol}`,
};
}
if (urlWithoutProtocol.includes('exp.host/')) {
return {
urlType: URLType.SNACK,
url: `exp://${urlWithoutProtocol}`,
};
}

// For future usage when we add support for other URL formats
Expand All @@ -54,10 +74,18 @@ export function identifyAndParseDeeplinkURL(deeplinkURL: string): {
return { urlType: URLType.UNKNOWN, url: `https://${urlWithoutProtocol}` };
}

function getUrlFromSearchParams(searchParams: URLSearchParams): string {
const url = searchParams.get('url');
if (!url) {
throw new Error('Missing url parameter in query');
}
return url;
}

export enum URLType {
AUTH,
EXPO_UPDATE,
EXPO_BUILD,
SNACK,
UNKNOWN,
AUTH = 'AUTH',
EXPO_UPDATE = 'EXPO_UPDATE',
EXPO_BUILD = 'EXPO_BUILD',
SNACK = 'SNACK',
UNKNOWN = 'UNKNOWN',
}
2 changes: 1 addition & 1 deletion apps/menu-bar/src/windows/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ const Settings = () => {
};

const handleAuthentication = async (type: 'signup' | 'login') => {
const redirectBase = 'expo-orbit://auth';
const redirectBase = 'expo-orbit:///auth';
const authSessionURL = `${
Config.website.origin
}/${type}?confirm_account=1&app_redirect_uri=${encodeURIComponent(redirectBase)}`;
Expand Down
Loading

0 comments on commit 40a16db

Please sign in to comment.