Skip to content
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

[menu-bar] Refactor deep-link routes to comply with the URL spec #151

Merged
merged 5 commits into from
Jan 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading