2bc0e4755c
Co-authored-by: Fedor Indutny <238531+indutny@users.noreply.github.com>
761 lines
19 KiB
TypeScript
761 lines
19 KiB
TypeScript
// Copyright 2023 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
import 'urlpattern-polyfill';
|
|
// We need to use the Node.js version of `URL` because chromium's `URL` doesn't
|
|
// support custom protocols correctly.
|
|
import { URL as NodeURL } from 'url';
|
|
import { z } from 'zod';
|
|
import { strictAssert } from './assert';
|
|
import * as log from '../logging/log';
|
|
import * as Errors from '../types/errors';
|
|
|
|
function toUrl(input: URL | string): URL | null {
|
|
if (input instanceof URL) {
|
|
return input;
|
|
}
|
|
try {
|
|
return new NodeURL(input) as URL;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* List of protocols that are used by Signal routes.
|
|
*/
|
|
const SignalRouteProtocols = ['https:', 'sgnl:', 'signalcaptcha:'] as const;
|
|
|
|
/**
|
|
* List of hostnames that are used by Signal routes.
|
|
* This doesn't include app-only routes like `linkdevice` or `verify`.
|
|
*/
|
|
const SignalRouteHostnames = [
|
|
'signal.me',
|
|
'signal.group',
|
|
'signal.link',
|
|
'signal.art',
|
|
] as const;
|
|
|
|
/**
|
|
* Type to help maintain {@link SignalRouteHostnames}, real hostnames should go there.
|
|
*/
|
|
type AllHostnamePatterns =
|
|
| typeof SignalRouteHostnames[number]
|
|
| 'verify'
|
|
| 'linkdevice'
|
|
| 'addstickers'
|
|
| 'art-auth'
|
|
| 'joingroup'
|
|
| 'show-conversation'
|
|
| 'start-call-lobby'
|
|
| 'show-window'
|
|
| 'set-is-presenting'
|
|
| ':captchaId(.+)'
|
|
| '';
|
|
|
|
/**
|
|
* Uses the `URLPattern` syntax to match URLs.
|
|
*/
|
|
type PatternString = string & { __pattern?: never };
|
|
|
|
type PatternInput = {
|
|
hash?: PatternString;
|
|
search?: PatternString;
|
|
};
|
|
|
|
type URLMatcher = (input: URL) => URLPatternResult | null;
|
|
|
|
function _pattern(
|
|
protocol: typeof SignalRouteProtocols[number],
|
|
hostname: AllHostnamePatterns,
|
|
pathname: PatternString,
|
|
init: PatternInput
|
|
): URLMatcher {
|
|
strictAssert(protocol.endsWith(':'), 'protocol must end with `:`');
|
|
strictAssert(!hostname.endsWith('/'), 'hostname must not end with `/`');
|
|
strictAssert(
|
|
!(hostname === '' && pathname !== ''),
|
|
'hostname cannot be empty string if pathname is not empty string'
|
|
);
|
|
strictAssert(
|
|
!pathname.endsWith('/'),
|
|
'pathname trailing slash must be optional `{/}?`'
|
|
);
|
|
const urlPattern = new URLPattern({
|
|
username: '',
|
|
password: '',
|
|
port: '',
|
|
// any of these can be patterns
|
|
hostname,
|
|
pathname,
|
|
search: init.search ?? '',
|
|
hash: init.hash ?? '',
|
|
} satisfies Omit<Required<URLPatternInit>, 'baseURL' | 'protocol'>);
|
|
return function match(input) {
|
|
const url = toUrl(input);
|
|
if (url == null) {
|
|
return null;
|
|
}
|
|
// We need to check protocol separately because `URL` and `URLPattern` don't
|
|
// properly support custom protocols
|
|
if (url.protocol !== protocol) {
|
|
return null;
|
|
}
|
|
return urlPattern.exec(url);
|
|
};
|
|
}
|
|
|
|
type PartialNullable<T> = {
|
|
[P in keyof T]?: T[P] | null;
|
|
};
|
|
|
|
type RouteConfig<Args extends object> = {
|
|
patterns: Array<URLMatcher>;
|
|
schema: z.ZodType<Args>;
|
|
parse(result: URLPatternResult, url: URL): PartialNullable<Args>;
|
|
toWebUrl?(args: Args): URL;
|
|
toAppUrl?(args: Args): URL;
|
|
};
|
|
|
|
type SignalRoute<Key extends string, Args extends object> = {
|
|
isMatch(input: URL | string): boolean;
|
|
fromUrl(input: URL | string): RouteResult<Key, Args> | null;
|
|
toWebUrl(args: Args): URL;
|
|
toAppUrl(args: Args): URL;
|
|
};
|
|
|
|
type RouteResult<Key extends string, Args extends object> = {
|
|
key: Key;
|
|
args: Args;
|
|
};
|
|
|
|
let _routeCount = 0;
|
|
|
|
function _route<Key extends string, Args extends object>(
|
|
key: Key,
|
|
config: RouteConfig<Args>
|
|
): SignalRoute<Key, Args> {
|
|
_routeCount += 1;
|
|
return {
|
|
isMatch(input) {
|
|
const url = toUrl(input);
|
|
if (url == null) {
|
|
return false;
|
|
}
|
|
return config.patterns.some(matcher => {
|
|
return matcher(url) != null;
|
|
});
|
|
},
|
|
fromUrl(input) {
|
|
const url = toUrl(input);
|
|
if (url == null) {
|
|
return null;
|
|
}
|
|
for (const matcher of config.patterns) {
|
|
const result = matcher(url);
|
|
if (result) {
|
|
let rawArgs;
|
|
try {
|
|
rawArgs = config.parse(result, url);
|
|
} catch (error) {
|
|
log.error(
|
|
`Failed to parse route ${key} from URL ${url.toString()}`,
|
|
Errors.toLogFormat(error)
|
|
);
|
|
return null;
|
|
}
|
|
const parseResult = config.schema.safeParse(rawArgs);
|
|
if (parseResult.success) {
|
|
const args = parseResult.data;
|
|
return {
|
|
key,
|
|
args,
|
|
};
|
|
}
|
|
log.error(
|
|
`Failed to parse route ${key} from URL ${url.toString()}`,
|
|
parseResult.error.format()
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
return null;
|
|
},
|
|
toWebUrl(args) {
|
|
if (config.toWebUrl) {
|
|
return config.toWebUrl(config.schema.parse(args));
|
|
}
|
|
throw new Error('Route does not support web URLs');
|
|
},
|
|
toAppUrl(args) {
|
|
if (config.toAppUrl) {
|
|
return config.toAppUrl(config.schema.parse(args));
|
|
}
|
|
throw new Error('Route does not support app URLs');
|
|
},
|
|
};
|
|
}
|
|
|
|
const paramSchema = z.string().min(1);
|
|
const optionalParamSchema = paramSchema.nullish().default(null);
|
|
|
|
/**
|
|
* signal.me by phone number
|
|
* @example
|
|
* ```ts
|
|
* contactByPhoneNumberRoute.toWebUrl({
|
|
* phoneNumber: "+1234567890",
|
|
* })
|
|
* // URL { "https://signal.me/#p/+1234567890" }
|
|
* ```
|
|
*/
|
|
export const contactByPhoneNumberRoute = _route('contactByPhoneNumber', {
|
|
patterns: [
|
|
_pattern('https:', 'signal.me', '{/}?', { hash: 'p/:phoneNumber' }),
|
|
_pattern('sgnl:', 'signal.me', '{/}?', { hash: 'p/:phoneNumber' }),
|
|
],
|
|
schema: z.object({
|
|
phoneNumber: paramSchema, // E164 (with +)
|
|
}),
|
|
parse(result) {
|
|
return {
|
|
phoneNumber: paramSchema.parse(result.hash.groups.phoneNumber),
|
|
};
|
|
},
|
|
toWebUrl(args) {
|
|
return new URL(`https://signal.me/#p/${args.phoneNumber}`);
|
|
},
|
|
toAppUrl(args) {
|
|
return new URL(`sgnl://signal.me/#p/${args.phoneNumber}`);
|
|
},
|
|
});
|
|
|
|
/**
|
|
* signal.me by encrypted username
|
|
* @example
|
|
* ```ts
|
|
* contactByEncryptedUsernameRoute.toWebUrl({
|
|
* encryptedUsername: "123",
|
|
* })
|
|
* // URL { "https://signal.me/#eu/123" }
|
|
* ```
|
|
*/
|
|
export const contactByEncryptedUsernameRoute = _route(
|
|
'contactByEncryptedUsername',
|
|
{
|
|
patterns: [
|
|
_pattern('https:', 'signal.me', '{/}?', {
|
|
hash: 'eu/:encryptedUsername',
|
|
}),
|
|
_pattern('sgnl:', 'signal.me', '{/}?', { hash: 'eu/:encryptedUsername' }),
|
|
],
|
|
schema: z.object({
|
|
encryptedUsername: paramSchema, // base64url (32 bytes of entropy + 16 bytes of big-endian UUID)
|
|
}),
|
|
parse(result) {
|
|
return {
|
|
encryptedUsername: result.hash.groups.encryptedUsername,
|
|
};
|
|
},
|
|
toWebUrl(args) {
|
|
return new URL(`https://signal.me/#eu/${args.encryptedUsername}`);
|
|
},
|
|
toAppUrl(args) {
|
|
return new URL(`sgnl://signal.me/#eu/${args.encryptedUsername}`);
|
|
},
|
|
}
|
|
);
|
|
|
|
/**
|
|
* Group invites
|
|
* @example
|
|
* ```ts
|
|
* groupInvitesRoute.toWebUrl({
|
|
* inviteCode: "123",
|
|
* })
|
|
* // URL { "https://signal.group/#123" }
|
|
* ```
|
|
*/
|
|
export const groupInvitesRoute = _route('groupInvites', {
|
|
patterns: [
|
|
_pattern('https:', 'signal.group', '{/}?', {
|
|
hash: ':inviteCode([^\\/]+)',
|
|
}),
|
|
_pattern('sgnl:', 'signal.group', '{/}?', {
|
|
hash: ':inviteCode([^\\/]+)',
|
|
}),
|
|
_pattern('sgnl:', 'joingroup', '{/}?', { hash: ':inviteCode([^\\/]+)' }),
|
|
],
|
|
schema: z.object({
|
|
inviteCode: paramSchema, // base64url (GroupInviteLink proto)
|
|
}),
|
|
parse(result) {
|
|
return {
|
|
inviteCode: result.hash.groups.inviteCode,
|
|
};
|
|
},
|
|
toWebUrl(args) {
|
|
return new URL(`https://signal.group/#${args.inviteCode}`);
|
|
},
|
|
toAppUrl(args) {
|
|
return new URL(`sgnl://signal.group/#${args.inviteCode}`);
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Device linking QR code
|
|
* @example
|
|
* ```ts
|
|
* linkDeviceRoute.toAppUrl({
|
|
* uuid: "123",
|
|
* pubKey: "abc",
|
|
* })
|
|
* // URL { "sgnl://linkdevice?uuid=123&pub_key=abc" }
|
|
* ```
|
|
*/
|
|
export const linkDeviceRoute = _route('linkDevice', {
|
|
patterns: [_pattern('sgnl:', 'linkdevice', '{/}?', { search: ':params' })],
|
|
schema: z.object({
|
|
uuid: paramSchema, // base64url?
|
|
pubKey: paramSchema, // percent-encoded base64 (with padding) of PublicKey with type byte included
|
|
}),
|
|
parse(result) {
|
|
const params = new URLSearchParams(result.search.groups.params);
|
|
return {
|
|
uuid: params.get('uuid'),
|
|
pubKey: params.get('pub_key'),
|
|
};
|
|
},
|
|
toAppUrl(args) {
|
|
const params = new URLSearchParams({
|
|
uuid: args.uuid,
|
|
pub_key: args.pubKey,
|
|
});
|
|
return new URL(`sgnl://linkdevice?${params.toString()}`);
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Captchas
|
|
* @example
|
|
* ```ts
|
|
* captchaRoute.toAppUrl({
|
|
* captchaId: "123",
|
|
* })
|
|
* // URL { "signalcaptcha://123" }
|
|
* ```
|
|
*/
|
|
export const captchaRoute = _route('captcha', {
|
|
// needs `(.+)` to capture `.` in hostname
|
|
patterns: [_pattern('signalcaptcha:', ':captchaId(.+)', '{/}?', {})],
|
|
schema: z.object({
|
|
captchaId: paramSchema, // opaque
|
|
}),
|
|
parse(_result, url) {
|
|
return {
|
|
captchaId: url.hostname,
|
|
};
|
|
},
|
|
toAppUrl(args) {
|
|
return new URL(`signalcaptcha://${args.captchaId}`);
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Join a call with a link.
|
|
* @example
|
|
* ```ts
|
|
* linkCallRoute.toWebUrl({
|
|
* key: "123",
|
|
* })
|
|
* // URL { "https://signal.link/call#key=123" }
|
|
*/
|
|
export const linkCallRoute = _route('linkCall', {
|
|
patterns: [
|
|
_pattern('https:', 'signal.link', '/call{/}?', { hash: ':params' }),
|
|
_pattern('sgnl:', 'signal.link', '/call{/}?', { hash: ':params' }),
|
|
],
|
|
schema: z.object({
|
|
key: paramSchema, // ConsonantBase16
|
|
}),
|
|
parse(result) {
|
|
const params = new URLSearchParams(result.hash.groups.params);
|
|
return {
|
|
key: params.get('key'),
|
|
};
|
|
},
|
|
toWebUrl(args) {
|
|
const params = new URLSearchParams({ key: args.key });
|
|
return new URL(`https://signal.link/call#${params.toString()}`);
|
|
},
|
|
toAppUrl(args) {
|
|
const params = new URLSearchParams({ key: args.key });
|
|
return new URL(`sgnl://signal.link/call#${params.toString()}`);
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Sticker packs
|
|
* @example
|
|
* ```ts
|
|
* artAddStickersRoute.toWebUrl({
|
|
* packId: "123",
|
|
* packKey: "abc",
|
|
* })
|
|
* // URL { "https://signal.art/addstickers#pack_id=123&pack_key=abc" }
|
|
* ```
|
|
*/
|
|
export const artAddStickersRoute = _route('artAddStickers', {
|
|
patterns: [
|
|
_pattern('https:', 'signal.art', '/addstickers{/}?', { hash: ':params' }),
|
|
_pattern('sgnl:', 'addstickers', '{/}?', { search: ':params' }),
|
|
],
|
|
schema: z.object({
|
|
packId: paramSchema, // hexadecimal
|
|
packKey: paramSchema, // hexadecimal
|
|
}),
|
|
parse(result) {
|
|
const params = new URLSearchParams(
|
|
result.hash.groups.params ?? result.search.groups.params
|
|
);
|
|
return {
|
|
packId: params.get('pack_id'),
|
|
packKey: params.get('pack_key'),
|
|
};
|
|
},
|
|
toWebUrl(args) {
|
|
const params = new URLSearchParams({
|
|
pack_id: args.packId,
|
|
pack_key: args.packKey,
|
|
});
|
|
return new URL(`https://signal.art/addstickers#${params.toString()}`);
|
|
},
|
|
toAppUrl(args) {
|
|
const params = new URLSearchParams({
|
|
pack_id: args.packId,
|
|
pack_key: args.packKey,
|
|
});
|
|
return new URL(`sgnl://addstickers?${params.toString()}`);
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Art Service Authentication
|
|
* @example
|
|
* ```ts
|
|
* artAuthRoute.toAppUrl({
|
|
* token: "123",
|
|
* pubKey: "abc",
|
|
* })
|
|
* // URL { "sgnl://art-auth?token=123&pub_key=abc" }
|
|
*/
|
|
export const artAuthRoute = _route('artAuth', {
|
|
patterns: [_pattern('sgnl:', 'art-auth', '{/}?', { search: ':params' })],
|
|
schema: z.object({
|
|
token: paramSchema, // opaque
|
|
pubKey: paramSchema, // base64url
|
|
}),
|
|
parse(result) {
|
|
const params = new URLSearchParams(result.search.groups.params);
|
|
return {
|
|
token: params.get('token'),
|
|
pubKey: params.get('pub_key'),
|
|
};
|
|
},
|
|
toAppUrl(args) {
|
|
const params = new URLSearchParams({
|
|
token: args.token,
|
|
pub_key: args.pubKey,
|
|
});
|
|
return new URL(`sgnl://art-auth?${params.toString()}`);
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Show a conversation
|
|
* @example
|
|
* ```ts
|
|
* showConversationRoute.toAppUrl({
|
|
* conversationId: "123",
|
|
* messageId: "abc",
|
|
* storyId: "def",
|
|
* })
|
|
* // URL { "sgnl://show-conversation?conversationId=123&messageId=abc&storyId=def" }
|
|
* ```
|
|
*/
|
|
export const showConversationRoute = _route('showConversation', {
|
|
patterns: [
|
|
_pattern('sgnl:', 'show-conversation', '{/}?', { search: ':params' }),
|
|
],
|
|
schema: z.object({
|
|
conversationId: paramSchema,
|
|
messageId: optionalParamSchema,
|
|
storyId: optionalParamSchema,
|
|
}),
|
|
parse(result) {
|
|
const params = new URLSearchParams(result.search.groups.params);
|
|
return {
|
|
conversationId: params.get('conversationId'),
|
|
messageId: params.get('messageId'),
|
|
storyId: params.get('storyId'),
|
|
};
|
|
},
|
|
toAppUrl(args) {
|
|
const params = new URLSearchParams({
|
|
conversationId: args.conversationId,
|
|
});
|
|
if (args.messageId != null) {
|
|
params.set('messageId', args.messageId);
|
|
}
|
|
if (args.storyId != null) {
|
|
params.set('storyId', args.storyId);
|
|
}
|
|
return new URL(`sgnl://show-conversation?${params.toString()}`);
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Start a call lobby
|
|
* @example
|
|
* ```ts
|
|
* startCallLobbyRoute.toAppUrl({
|
|
* conversationId: "123",
|
|
* })
|
|
* // URL { "sgnl://start-call-lobby?conversationId=123" }
|
|
* ```
|
|
*/
|
|
export const startCallLobbyRoute = _route('startCallLobby', {
|
|
patterns: [
|
|
_pattern('sgnl:', 'start-call-lobby', '{/}?', { search: ':params' }),
|
|
],
|
|
schema: z.object({
|
|
conversationId: paramSchema,
|
|
}),
|
|
parse(result) {
|
|
const params = new URLSearchParams(result.search.groups.params);
|
|
return {
|
|
conversationId: params.get('conversationId'),
|
|
};
|
|
},
|
|
toAppUrl(args) {
|
|
const params = new URLSearchParams({
|
|
conversationId: args.conversationId,
|
|
});
|
|
return new URL(`sgnl://start-call-lobby?${params.toString()}`);
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Show window
|
|
* @example
|
|
* ```ts
|
|
* showWindowRoute.toAppUrl({})
|
|
* // URL { "sgnl://show-window" }
|
|
*/
|
|
export const showWindowRoute = _route('showWindow', {
|
|
patterns: [_pattern('sgnl:', 'show-window', '{/}?', {})],
|
|
schema: z.object({}),
|
|
parse() {
|
|
return {};
|
|
},
|
|
toAppUrl() {
|
|
return new URL('sgnl://show-window');
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Set is presenting
|
|
* @example
|
|
* ```ts
|
|
* setIsPresentingRoute.toAppUrl({})
|
|
* // URL { "sgnl://set-is-presenting" }
|
|
* ```
|
|
*/
|
|
export const setIsPresentingRoute = _route('setIsPresenting', {
|
|
patterns: [_pattern('sgnl:', 'set-is-presenting', '{/}?', {})],
|
|
schema: z.object({}),
|
|
parse() {
|
|
return {};
|
|
},
|
|
toAppUrl() {
|
|
return new URL('sgnl://set-is-presenting');
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Should include all routes for matching purposes.
|
|
* @internal
|
|
*/
|
|
const _allSignalRoutes = [
|
|
contactByPhoneNumberRoute,
|
|
contactByEncryptedUsernameRoute,
|
|
groupInvitesRoute,
|
|
linkDeviceRoute,
|
|
captchaRoute,
|
|
linkCallRoute,
|
|
artAddStickersRoute,
|
|
artAuthRoute,
|
|
showConversationRoute,
|
|
startCallLobbyRoute,
|
|
showWindowRoute,
|
|
setIsPresentingRoute,
|
|
] as const;
|
|
|
|
strictAssert(
|
|
_allSignalRoutes.length === _routeCount,
|
|
'Forgot to add route to routes list'
|
|
);
|
|
|
|
/**
|
|
* A parsed route with the `key` of the route and its parsed `args`.
|
|
* @example
|
|
* ```ts
|
|
* parseSignalRoute(new URL("https://signal.me/#p/+1234567890"))
|
|
* // {
|
|
* // key: "contactByPhoneNumber",
|
|
* // args: { phoneNumber: "+1234567890" },
|
|
* // }
|
|
* ```
|
|
*/
|
|
export type ParsedSignalRoute = NonNullable<
|
|
ReturnType<typeof _allSignalRoutes[number]['fromUrl']>
|
|
>;
|
|
|
|
/** @internal */
|
|
type MatchedSignalRoute = {
|
|
route: SignalRoute<string, object>;
|
|
parsed: ParsedSignalRoute;
|
|
};
|
|
|
|
/** @internal */
|
|
function _matchSignalRoute(input: URL | string): MatchedSignalRoute | null {
|
|
const url = toUrl(input);
|
|
if (url == null) {
|
|
return null;
|
|
}
|
|
for (const route of _allSignalRoutes) {
|
|
const parsed = route.fromUrl(url);
|
|
if (parsed != null) {
|
|
return { route, parsed };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** @internal */
|
|
function _normalizeUrl(url: URL | string): URL | null {
|
|
const newUrl = toUrl(url);
|
|
if (newUrl == null) {
|
|
return null;
|
|
}
|
|
newUrl.port = '';
|
|
newUrl.username = '';
|
|
newUrl.password = '';
|
|
if (newUrl.protocol === 'http:') {
|
|
newUrl.protocol = 'https:';
|
|
}
|
|
return newUrl;
|
|
}
|
|
|
|
/**
|
|
* Check if a URL matches a route.
|
|
* @example
|
|
* ```ts
|
|
* isSignalRoute(new URL("https://signal.me/#p/+1234567890")) // true
|
|
* isSignalRoute(new URL("sgnl://signal.me/#p/+1234567890")) // true
|
|
* isSignalRoute(new URL("https://signal.me")) // false
|
|
* isSignalRoute(new URL("https://example.com")) // false
|
|
* ```
|
|
*/
|
|
export function isSignalRoute(input: URL | string): boolean {
|
|
return _matchSignalRoute(input) != null;
|
|
}
|
|
|
|
/**
|
|
* Maybe parse a URL into a matching route with the 'key' of the route and its
|
|
* parsed args.
|
|
* If it we can't match it to a route, return null.
|
|
* @example
|
|
* ```ts
|
|
* parseSignalRoute(new URL("https://signal.me/#p/+1234567890"))
|
|
* // { key: "contactByPhoneNumber", args: { phoneNumber: "+1234567890" } }
|
|
* parseSignalRoute(new URL("sgnl://signal.me/#p/+1234567890"))
|
|
* // { key: "contactByPhoneNumber", args: { phoneNumber: "+1234567890" } }
|
|
* parseSignalRoute(new URL("https://example.com"))
|
|
* // null
|
|
* ```
|
|
*/
|
|
export function parseSignalRoute(
|
|
input: URL | string
|
|
): ParsedSignalRoute | null {
|
|
return _matchSignalRoute(input)?.parsed ?? null;
|
|
}
|
|
|
|
/**
|
|
* Maybe normalize a URL into a matching route URL.
|
|
* If it we can't match it to a route, return null.
|
|
* @example
|
|
* ```ts
|
|
* toSignalRouteUrl(new URL("http://username:password@signal.me/#p/+1234567890"))
|
|
* // URL { "https://signal.me/#p/+1234567890" }
|
|
* toSignalRouteUrl(new URL("sgnl://signal.me/#p/+1234567890"))
|
|
* // URL { "sgnl://signal.me/#p/+1234567890" }
|
|
* toSignalRouteUrl(new URL("https://example.com"))
|
|
* // null
|
|
* ```
|
|
*/
|
|
export function toSignalRouteUrl(input: URL | string): URL | null {
|
|
const normalizedUrl = _normalizeUrl(input);
|
|
if (normalizedUrl == null) {
|
|
return null;
|
|
}
|
|
return _matchSignalRoute(normalizedUrl) != null ? normalizedUrl : null;
|
|
}
|
|
|
|
/**
|
|
* Maybe normalize a URL into a matching route **App** URL.
|
|
* If it we can't match it to a route, return null.
|
|
* @example
|
|
* ```ts
|
|
* toSignalRouteAppUrl(new URL("https://signal.me/#p/+1234567890"))
|
|
* // URL { "sgnl://signal.me/#p/+1234567890" }
|
|
* toSignalRouteAppUrl(new URL("https://example.com"))
|
|
* // null
|
|
* ```
|
|
*/
|
|
export function toSignalRouteAppUrl(input: URL | string): URL | null {
|
|
const normalizedUrl = _normalizeUrl(input);
|
|
if (normalizedUrl == null) {
|
|
return null;
|
|
}
|
|
const match = _matchSignalRoute(normalizedUrl);
|
|
try {
|
|
return match?.route.toAppUrl(match.parsed.args) ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Maybe normalize a URL into a matching route **Web** URL.
|
|
* If it we can't match it to a route, return null.
|
|
* @example
|
|
* ```ts
|
|
* toSignalRouteWebUrl(new URL("sgnl://signal.me/#p/+1234567890"))
|
|
* // URL { "https://signal.me/#p/+1234567890" }
|
|
* toSignalRouteWebUrl(new URL("https://example.com"))
|
|
* // null
|
|
* ```
|
|
*/
|
|
export function toSignalRouteWebUrl(input: URL | string): URL | null {
|
|
const normalizedUrl = _normalizeUrl(input);
|
|
if (normalizedUrl == null) {
|
|
return null;
|
|
}
|
|
const match = _matchSignalRoute(normalizedUrl);
|
|
try {
|
|
return match?.route.toWebUrl(match.parsed.args) ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|