Refactor Signal app routing
This commit is contained in:
parent
86e6c2499c
commit
3ef0d221d1
28 changed files with 1347 additions and 1044 deletions
2
.github/workflows/danger.yml
vendored
2
.github/workflows/danger.yml
vendored
|
@ -1,7 +1,7 @@
|
||||||
# Copyright 2020 Signal Messenger, LLC
|
# Copyright 2020 Signal Messenger, LLC
|
||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
name: CI
|
name: Danger
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
|
|
|
@ -18,6 +18,7 @@ ts/protobuf/*.d.ts
|
||||||
ts/protobuf/*.js
|
ts/protobuf/*.js
|
||||||
stylesheets/manifest.css
|
stylesheets/manifest.css
|
||||||
ts/util/lint/exceptions.json
|
ts/util/lint/exceptions.json
|
||||||
|
storybook-static
|
||||||
|
|
||||||
# Third-party files
|
# Third-party files
|
||||||
node_modules/**
|
node_modules/**
|
||||||
|
|
|
@ -3451,6 +3451,28 @@ Signal Desktop makes use of the following open source projects.
|
||||||
|
|
||||||
License: (MIT OR CC0-1.0)
|
License: (MIT OR CC0-1.0)
|
||||||
|
|
||||||
|
## urlpattern-polyfill
|
||||||
|
|
||||||
|
Copyright 2020 Intel Corporation
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
|
|
||||||
## uuid
|
## uuid
|
||||||
|
|
||||||
License: MIT
|
License: MIT
|
||||||
|
|
184
app/main.ts
184
app/main.ts
|
@ -100,15 +100,6 @@ import { createTemplate } from './menu';
|
||||||
import { installFileHandler, installWebHandler } from './protocol_filter';
|
import { installFileHandler, installWebHandler } from './protocol_filter';
|
||||||
import OS from '../ts/util/os/osMain';
|
import OS from '../ts/util/os/osMain';
|
||||||
import { isProduction } from '../ts/util/version';
|
import { isProduction } from '../ts/util/version';
|
||||||
import {
|
|
||||||
isSgnlHref,
|
|
||||||
isCaptchaHref,
|
|
||||||
isSignalHttpsLink,
|
|
||||||
parseSgnlHref,
|
|
||||||
parseCaptchaHref,
|
|
||||||
parseSignalHttpsLink,
|
|
||||||
rewriteSignalHrefsIfNecessary,
|
|
||||||
} from '../ts/util/sgnlHref';
|
|
||||||
import { clearTimeoutIfNecessary } from '../ts/util/clearTimeoutIfNecessary';
|
import { clearTimeoutIfNecessary } from '../ts/util/clearTimeoutIfNecessary';
|
||||||
import { toggleMaximizedBrowserWindow } from '../ts/util/toggleMaximizedBrowserWindow';
|
import { toggleMaximizedBrowserWindow } from '../ts/util/toggleMaximizedBrowserWindow';
|
||||||
import { ChallengeMainHandler } from '../ts/main/challengeMain';
|
import { ChallengeMainHandler } from '../ts/main/challengeMain';
|
||||||
|
@ -124,6 +115,8 @@ import { load as loadLocale } from './locale';
|
||||||
import type { LoggerType } from '../ts/types/Logging';
|
import type { LoggerType } from '../ts/types/Logging';
|
||||||
import { HourCyclePreference } from '../ts/types/I18N';
|
import { HourCyclePreference } from '../ts/types/I18N';
|
||||||
import { DBVersionFromFutureError } from '../ts/sql/migrations';
|
import { DBVersionFromFutureError } from '../ts/sql/migrations';
|
||||||
|
import type { ParsedSignalRoute } from '../ts/util/signalRoutes';
|
||||||
|
import { parseSignalRoute } from '../ts/util/signalRoutes';
|
||||||
|
|
||||||
const STICKER_CREATOR_PARTITION = 'sticker-creator';
|
const STICKER_CREATOR_PARTITION = 'sticker-creator';
|
||||||
|
|
||||||
|
@ -270,18 +263,10 @@ if (!process.mas) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const incomingCaptchaHref = getIncomingCaptchaHref(argv);
|
const route = maybeGetIncomingSignalRoute(argv);
|
||||||
if (incomingCaptchaHref) {
|
if (route != null) {
|
||||||
const { captcha } = parseCaptchaHref(incomingCaptchaHref, getLogger());
|
handleSignalRoute(route);
|
||||||
challengeHandler.handleCaptcha(captcha);
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
// Are they trying to open a sgnl:// href?
|
|
||||||
const incomingHref = getIncomingHref(argv);
|
|
||||||
if (incomingHref) {
|
|
||||||
handleSgnlHref(incomingHref);
|
|
||||||
}
|
|
||||||
// Handled
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -475,23 +460,21 @@ async function handleUrl(rawTarget: string) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = rewriteSignalHrefsIfNecessary(rawTarget);
|
const signalRoute = parseSignalRoute(rawTarget);
|
||||||
|
|
||||||
|
// We only want to specially handle urls that aren't requesting the dev server
|
||||||
|
if (signalRoute != null) {
|
||||||
|
handleSignalRoute(signalRoute);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const { protocol, hostname } = parsedUrl;
|
const { protocol, hostname } = parsedUrl;
|
||||||
const isDevServer =
|
const isDevServer =
|
||||||
process.env.SIGNAL_ENABLE_HTTP && hostname === 'localhost';
|
process.env.SIGNAL_ENABLE_HTTP && hostname === 'localhost';
|
||||||
// We only want to specially handle urls that aren't requesting the dev server
|
|
||||||
if (
|
|
||||||
isSgnlHref(target, getLogger()) ||
|
|
||||||
isSignalHttpsLink(target, getLogger())
|
|
||||||
) {
|
|
||||||
handleSgnlHref(target);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((protocol === 'http:' || protocol === 'https:') && !isDevServer) {
|
if ((protocol === 'http:' || protocol === 'https:') && !isDevServer) {
|
||||||
try {
|
try {
|
||||||
await shell.openExternal(target);
|
await shell.openExternal(rawTarget);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
getLogger().error(`Failed to open url: ${Errors.toLogFormat(error)}`);
|
getLogger().error(`Failed to open url: ${Errors.toLogFormat(error)}`);
|
||||||
}
|
}
|
||||||
|
@ -1127,9 +1110,9 @@ async function readyForUpdates() {
|
||||||
isReadyForUpdates = true;
|
isReadyForUpdates = true;
|
||||||
|
|
||||||
// First, install requested sticker pack
|
// First, install requested sticker pack
|
||||||
const incomingHref = getIncomingHref(process.argv);
|
const incomingHref = maybeGetIncomingSignalRoute(process.argv);
|
||||||
if (incomingHref) {
|
if (incomingHref) {
|
||||||
handleSgnlHref(incomingHref);
|
handleSignalRoute(incomingHref);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Second, start checking for app updates
|
// Second, start checking for app updates
|
||||||
|
@ -2199,18 +2182,10 @@ app.on('will-finish-launching', () => {
|
||||||
// https://stackoverflow.com/a/43949291
|
// https://stackoverflow.com/a/43949291
|
||||||
app.on('open-url', (event, incomingHref) => {
|
app.on('open-url', (event, incomingHref) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
const route = parseSignalRoute(incomingHref);
|
||||||
if (isCaptchaHref(incomingHref, getLogger())) {
|
if (route != null) {
|
||||||
const { captcha } = parseCaptchaHref(incomingHref, getLogger());
|
handleSignalRoute(route);
|
||||||
challengeHandler.handleCaptcha(captcha);
|
|
||||||
|
|
||||||
// Show window after handling captcha
|
|
||||||
showWindow();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleSgnlHref(incomingHref);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -2521,78 +2496,71 @@ ipc.on('preferences-changed', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function getIncomingHref(argv: Array<string>) {
|
function maybeGetIncomingSignalRoute(argv: Array<string>) {
|
||||||
return argv.find(arg => isSgnlHref(arg, getLogger()));
|
for (const arg of argv) {
|
||||||
|
const route = parseSignalRoute(arg);
|
||||||
|
if (route != null) {
|
||||||
|
return route;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getIncomingCaptchaHref(argv: Array<string>) {
|
function handleSignalRoute(route: ParsedSignalRoute) {
|
||||||
return argv.find(arg => isCaptchaHref(arg, getLogger()));
|
const log = getLogger();
|
||||||
}
|
|
||||||
|
|
||||||
function handleSgnlHref(incomingHref: string) {
|
if (mainWindow == null || !mainWindow.webContents) {
|
||||||
let command;
|
log.error('handleSignalRoute: mainWindow is null or missing webContents');
|
||||||
let args;
|
return;
|
||||||
let hash;
|
|
||||||
|
|
||||||
if (isSgnlHref(incomingHref, getLogger())) {
|
|
||||||
({ command, args, hash } = parseSgnlHref(incomingHref, getLogger()));
|
|
||||||
} else if (isSignalHttpsLink(incomingHref, getLogger())) {
|
|
||||||
({ command, args, hash } = parseSignalHttpsLink(incomingHref, getLogger()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mainWindow && mainWindow.webContents) {
|
log.info('handleSignalRoute: Matched signal route:', route.key);
|
||||||
if (command === 'addstickers') {
|
|
||||||
getLogger().info('Opening sticker pack from sgnl protocol link');
|
|
||||||
const packId = args?.get('pack_id');
|
|
||||||
const packKeyHex = args?.get('pack_key');
|
|
||||||
const packKey = packKeyHex
|
|
||||||
? Buffer.from(packKeyHex, 'hex').toString('base64')
|
|
||||||
: '';
|
|
||||||
mainWindow.webContents.send('show-sticker-pack', { packId, packKey });
|
|
||||||
} else if (command === 'art-auth') {
|
|
||||||
const token = args?.get('token');
|
|
||||||
const pubKeyBase64 = args?.get('pub_key');
|
|
||||||
|
|
||||||
mainWindow.webContents.send('authorize-art-creator', {
|
if (route.key === 'artAddStickers') {
|
||||||
token,
|
mainWindow.webContents.send('show-sticker-pack', {
|
||||||
pubKeyBase64,
|
packId: route.args.packId,
|
||||||
});
|
packKey: Buffer.from(route.args.packKey, 'hex').toString('base64'),
|
||||||
} else if (command === 'signal.group' && hash) {
|
});
|
||||||
getLogger().info('Showing group from sgnl protocol link');
|
} else if (route.key === 'artAuth') {
|
||||||
mainWindow.webContents.send('show-group-via-link', { hash });
|
mainWindow.webContents.send('authorize-art-creator', {
|
||||||
} else if (command === 'signal.me' && hash) {
|
token: route.args.token,
|
||||||
getLogger().info('Showing conversation from sgnl protocol link');
|
pubKeyBase64: route.args.pubKey,
|
||||||
mainWindow.webContents.send('show-conversation-via-signal.me', { hash });
|
});
|
||||||
} else if (
|
} else if (route.key === 'groupInvites') {
|
||||||
command === 'show-conversation' &&
|
mainWindow.webContents.send('show-group-via-link', {
|
||||||
args &&
|
value: route.args.inviteCode,
|
||||||
args.get('conversationId')
|
});
|
||||||
) {
|
} else if (route.key === 'contactByPhoneNumber') {
|
||||||
getLogger().info('Showing conversation from notification');
|
mainWindow.webContents.send('show-conversation-via-signal.me', {
|
||||||
mainWindow.webContents.send('show-conversation-via-notification', {
|
kind: 'phoneNumber',
|
||||||
conversationId: args.get('conversationId'),
|
value: route.args.phoneNumber,
|
||||||
messageId: args.get('messageId'),
|
});
|
||||||
storyId: args.get('storyId'),
|
} else if (route.key === 'contactByEncryptedUsername') {
|
||||||
});
|
mainWindow.webContents.send('show-conversation-via-signal.me', {
|
||||||
} else if (
|
kind: 'encryptedUsername',
|
||||||
command === 'start-call-lobby' &&
|
value: route.args.encryptedUsername,
|
||||||
args &&
|
});
|
||||||
args.get('conversationId')
|
} else if (route.key === 'showConversation') {
|
||||||
) {
|
mainWindow.webContents.send('show-conversation-via-notification', {
|
||||||
getLogger().info('Starting call lobby from notification');
|
conversationId: route.args.conversationId,
|
||||||
mainWindow.webContents.send('start-call-lobby', {
|
messageId: route.args.messageId,
|
||||||
conversationId: args.get('conversationId'),
|
storyId: route.args.storyId,
|
||||||
});
|
});
|
||||||
} else if (command === 'show-window') {
|
} else if (route.key === 'startCallLobby') {
|
||||||
mainWindow.webContents.send('show-window');
|
mainWindow.webContents.send('start-call-lobby', {
|
||||||
} else if (command === 'set-is-presenting') {
|
conversationId: route.args.conversationId,
|
||||||
mainWindow.webContents.send('set-is-presenting');
|
});
|
||||||
} else {
|
} else if (route.key === 'showWindow') {
|
||||||
getLogger().info('Showing warning that we cannot process link');
|
mainWindow.webContents.send('show-window');
|
||||||
mainWindow.webContents.send('unknown-sgnl-link');
|
} else if (route.key === 'setIsPresenting') {
|
||||||
}
|
mainWindow.webContents.send('set-is-presenting');
|
||||||
|
} else if (route.key === 'captcha') {
|
||||||
|
challengeHandler.handleCaptcha(route.args.captchaId);
|
||||||
|
// Show window after handling captcha
|
||||||
|
showWindow();
|
||||||
} else {
|
} else {
|
||||||
getLogger().error('Unhandled sgnl link');
|
log.info('handleSignalRoute: Unknown signal route:', route.key);
|
||||||
|
mainWindow.webContents.send('unknown-sgnl-link');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -8,6 +8,12 @@ import type { WindowsNotificationData } from '../ts/services/notifications';
|
||||||
|
|
||||||
import { NotificationType } from '../ts/services/notifications';
|
import { NotificationType } from '../ts/services/notifications';
|
||||||
import { missingCaseError } from '../ts/util/missingCaseError';
|
import { missingCaseError } from '../ts/util/missingCaseError';
|
||||||
|
import {
|
||||||
|
setIsPresentingRoute,
|
||||||
|
showConversationRoute,
|
||||||
|
showWindowRoute,
|
||||||
|
startCallLobbyRoute,
|
||||||
|
} from '../ts/util/signalRoutes';
|
||||||
|
|
||||||
function pathToUri(path: string) {
|
function pathToUri(path: string) {
|
||||||
return `file:///${encodeURI(path.replace(/\\/g, '/'))}`;
|
return `file:///${encodeURI(path.replace(/\\/g, '/'))}`;
|
||||||
|
@ -51,21 +57,19 @@ export function renderWindowsToast({
|
||||||
// 1) this maps to the notify() function in services/notifications.ts
|
// 1) this maps to the notify() function in services/notifications.ts
|
||||||
// 2) this also maps to the url-handling in main.ts
|
// 2) this also maps to the url-handling in main.ts
|
||||||
if (type === NotificationType.Message || type === NotificationType.Reaction) {
|
if (type === NotificationType.Message || type === NotificationType.Reaction) {
|
||||||
launch = new URL('sgnl://show-conversation');
|
launch = showConversationRoute.toAppUrl({
|
||||||
launch.searchParams.set('conversationId', conversationId);
|
conversationId,
|
||||||
if (messageId) {
|
messageId: messageId ?? null,
|
||||||
launch.searchParams.set('messageId', messageId);
|
storyId: storyId ?? null,
|
||||||
}
|
});
|
||||||
if (storyId) {
|
|
||||||
launch.searchParams.set('storyId', storyId);
|
|
||||||
}
|
|
||||||
} else if (type === NotificationType.IncomingGroupCall) {
|
} else if (type === NotificationType.IncomingGroupCall) {
|
||||||
launch = new URL(`sgnl://start-call-lobby`);
|
launch = startCallLobbyRoute.toAppUrl({
|
||||||
launch.searchParams.set('conversationId', conversationId);
|
conversationId,
|
||||||
|
});
|
||||||
} else if (type === NotificationType.IncomingCall) {
|
} else if (type === NotificationType.IncomingCall) {
|
||||||
launch = new URL('sgnl://show-window');
|
launch = showWindowRoute.toAppUrl({});
|
||||||
} else if (type === NotificationType.IsPresenting) {
|
} else if (type === NotificationType.IsPresenting) {
|
||||||
launch = new URL('sgnl://set-is-presenting');
|
launch = setIsPresentingRoute.toAppUrl({});
|
||||||
} else {
|
} else {
|
||||||
throw missingCaseError(type);
|
throw missingCaseError(type);
|
||||||
}
|
}
|
||||||
|
|
|
@ -181,6 +181,7 @@
|
||||||
"semver": "5.7.2",
|
"semver": "5.7.2",
|
||||||
"split2": "4.0.0",
|
"split2": "4.0.0",
|
||||||
"type-fest": "3.5.0",
|
"type-fest": "3.5.0",
|
||||||
|
"urlpattern-polyfill": "9.0.0",
|
||||||
"uuid": "3.3.2",
|
"uuid": "3.3.2",
|
||||||
"uuid-browser": "3.1.0",
|
"uuid-browser": "3.1.0",
|
||||||
"websocket": "1.0.34",
|
"websocket": "1.0.34",
|
||||||
|
|
18
ts/CI.ts
18
ts/CI.ts
|
@ -9,6 +9,8 @@ import * as log from './logging/log';
|
||||||
import { explodePromise } from './util/explodePromise';
|
import { explodePromise } from './util/explodePromise';
|
||||||
import { ipcInvoke } from './sql/channels';
|
import { ipcInvoke } from './sql/channels';
|
||||||
import { SECOND } from './util/durations';
|
import { SECOND } from './util/durations';
|
||||||
|
import { isSignalRoute } from './util/signalRoutes';
|
||||||
|
import { strictAssert } from './util/assert';
|
||||||
|
|
||||||
type ResolveType = (data: unknown) => void;
|
type ResolveType = (data: unknown) => void;
|
||||||
|
|
||||||
|
@ -28,6 +30,7 @@ export type CIType = {
|
||||||
ignorePastEvents?: boolean;
|
ignorePastEvents?: boolean;
|
||||||
}
|
}
|
||||||
) => unknown;
|
) => unknown;
|
||||||
|
openSignalRoute(url: string): Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getCI(deviceName: string): CIType {
|
export function getCI(deviceName: string): CIType {
|
||||||
|
@ -133,6 +136,20 @@ export function getCI(deviceName: string): CIType {
|
||||||
return window.ConversationController.getConversationId(address);
|
return window.ConversationController.getConversationId(address);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openSignalRoute(url: string) {
|
||||||
|
strictAssert(
|
||||||
|
isSignalRoute(url),
|
||||||
|
`openSignalRoute: not a valid signal route ${url}`
|
||||||
|
);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.target = '_blank';
|
||||||
|
a.hidden = true;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
deviceName,
|
deviceName,
|
||||||
getConversationId,
|
getConversationId,
|
||||||
|
@ -141,5 +158,6 @@ export function getCI(deviceName: string): CIType {
|
||||||
setProvisioningURL,
|
setProvisioningURL,
|
||||||
solveChallenge,
|
solveChallenge,
|
||||||
waitForEvent,
|
waitForEvent,
|
||||||
|
openSignalRoute,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -95,6 +95,7 @@ import { ReadStatus } from './messages/MessageReadStatus';
|
||||||
import { SeenStatus } from './MessageSeenStatus';
|
import { SeenStatus } from './MessageSeenStatus';
|
||||||
import { incrementMessageCounter } from './util/incrementMessageCounter';
|
import { incrementMessageCounter } from './util/incrementMessageCounter';
|
||||||
import { sleep } from './util/sleep';
|
import { sleep } from './util/sleep';
|
||||||
|
import { groupInvitesRoute } from './util/signalRoutes';
|
||||||
|
|
||||||
type AccessRequiredEnum = Proto.AccessControl.AccessRequired;
|
type AccessRequiredEnum = Proto.AccessControl.AccessRequired;
|
||||||
|
|
||||||
|
@ -383,16 +384,16 @@ export function buildGroupLink(
|
||||||
},
|
},
|
||||||
}).finish();
|
}).finish();
|
||||||
|
|
||||||
const hash = toWebSafeBase64(Bytes.toBase64(bytes));
|
const inviteCode = toWebSafeBase64(Bytes.toBase64(bytes));
|
||||||
|
|
||||||
return `https://signal.group/#${hash}`;
|
return groupInvitesRoute.toWebUrl({ inviteCode }).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseGroupLink(hash: string): {
|
export function parseGroupLink(value: string): {
|
||||||
masterKey: string;
|
masterKey: string;
|
||||||
inviteLinkPassword: string;
|
inviteLinkPassword: string;
|
||||||
} {
|
} {
|
||||||
const base64 = fromWebSafeBase64(hash);
|
const base64 = fromWebSafeBase64(value);
|
||||||
const buffer = Bytes.fromBase64(base64);
|
const buffer = Bytes.fromBase64(base64);
|
||||||
|
|
||||||
const inviteLinkProto = Proto.GroupInviteLink.decode(buffer);
|
const inviteLinkProto = Proto.GroupInviteLink.decode(buffer);
|
||||||
|
|
|
@ -30,11 +30,11 @@ import { isGroupV1 } from '../util/whatTypeOfConversation';
|
||||||
import { longRunningTaskWrapper } from '../util/longRunningTaskWrapper';
|
import { longRunningTaskWrapper } from '../util/longRunningTaskWrapper';
|
||||||
import { sleep } from '../util/sleep';
|
import { sleep } from '../util/sleep';
|
||||||
|
|
||||||
export async function joinViaLink(hash: string): Promise<void> {
|
export async function joinViaLink(value: string): Promise<void> {
|
||||||
let inviteLinkPassword: string;
|
let inviteLinkPassword: string;
|
||||||
let masterKey: string;
|
let masterKey: string;
|
||||||
try {
|
try {
|
||||||
({ inviteLinkPassword, masterKey } = parseGroupLink(hash));
|
({ inviteLinkPassword, masterKey } = parseGroupLink(value));
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const errorString = Errors.toLogFormat(error);
|
const errorString = Errors.toLogFormat(error);
|
||||||
log.error(`joinViaLink: Failed to parse group link ${errorString}`);
|
log.error(`joinViaLink: Failed to parse group link ${errorString}`);
|
||||||
|
|
|
@ -36,8 +36,8 @@ type NotificationDataType = Readonly<{
|
||||||
|
|
||||||
export type NotificationClickData = Readonly<{
|
export type NotificationClickData = Readonly<{
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
messageId?: string;
|
messageId: string | null;
|
||||||
storyId?: string;
|
storyId: string | null;
|
||||||
}>;
|
}>;
|
||||||
export type WindowsNotificationData = {
|
export type WindowsNotificationData = {
|
||||||
avatarPath?: string;
|
avatarPath?: string;
|
||||||
|
@ -208,8 +208,8 @@ class NotificationService extends EventEmitter {
|
||||||
window.IPC.showWindow();
|
window.IPC.showWindow();
|
||||||
window.Events.showConversationViaNotification({
|
window.Events.showConversationViaNotification({
|
||||||
conversationId,
|
conversationId,
|
||||||
messageId,
|
messageId: messageId ?? null,
|
||||||
storyId,
|
storyId: storyId ?? null,
|
||||||
});
|
});
|
||||||
} else if (type === NotificationType.IncomingGroupCall) {
|
} else if (type === NotificationType.IncomingGroupCall) {
|
||||||
window.IPC.showWindow();
|
window.IPC.showWindow();
|
||||||
|
|
|
@ -18,9 +18,9 @@ import { DEFAULT_CONVERSATION_COLOR } from '../../types/Colors';
|
||||||
import { getPreferredReactionEmoji as getPreferredReactionEmojiFromStoredValue } from '../../reactions/preferredReactionEmoji';
|
import { getPreferredReactionEmoji as getPreferredReactionEmojiFromStoredValue } from '../../reactions/preferredReactionEmoji';
|
||||||
import { isBeta } from '../../util/version';
|
import { isBeta } from '../../util/version';
|
||||||
import { DurationInSeconds } from '../../util/durations';
|
import { DurationInSeconds } from '../../util/durations';
|
||||||
import { generateUsernameLink } from '../../util/sgnlHref';
|
|
||||||
import * as Bytes from '../../Bytes';
|
import * as Bytes from '../../Bytes';
|
||||||
import { getUserNumber, getUserACI } from './user';
|
import { getUserNumber, getUserACI } from './user';
|
||||||
|
import { contactByEncryptedUsernameRoute } from '../../util/signalRoutes';
|
||||||
|
|
||||||
const DEFAULT_PREFERRED_LEFT_PANE_WIDTH = 320;
|
const DEFAULT_PREFERRED_LEFT_PANE_WIDTH = 320;
|
||||||
|
|
||||||
|
@ -112,7 +112,9 @@ export const getUsernameLink = createSelector(
|
||||||
|
|
||||||
const content = Bytes.concatenate([entropy, serverId]);
|
const content = Bytes.concatenate([entropy, serverId]);
|
||||||
|
|
||||||
return generateUsernameLink(Bytes.toBase64(content));
|
return contactByEncryptedUsernameRoute
|
||||||
|
.toWebUrl({ encryptedUsername: Bytes.toBase64(content) })
|
||||||
|
.toString();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -136,6 +136,13 @@ export class App extends EventEmitter {
|
||||||
return this.app.firstWindow();
|
return this.app.firstWindow();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async openSignalRoute(url: URL | string): Promise<void> {
|
||||||
|
const window = await this.getWindow();
|
||||||
|
await window.evaluate(
|
||||||
|
`window.SignalCI.openSignalRoute(${JSON.stringify(url.toString())})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// EventEmitter types
|
// EventEmitter types
|
||||||
|
|
||||||
public override on(type: 'close', callback: () => void): this;
|
public override on(type: 'close', callback: () => void): this;
|
||||||
|
|
|
@ -9,11 +9,11 @@ import createDebug from 'debug';
|
||||||
|
|
||||||
import * as durations from '../../util/durations';
|
import * as durations from '../../util/durations';
|
||||||
import { uuidToBytes } from '../../util/uuidToBytes';
|
import { uuidToBytes } from '../../util/uuidToBytes';
|
||||||
import { generateUsernameLink } from '../../util/sgnlHref';
|
|
||||||
import { MY_STORY_ID } from '../../types/Stories';
|
import { MY_STORY_ID } from '../../types/Stories';
|
||||||
import { Bootstrap } from '../bootstrap';
|
import { Bootstrap } from '../bootstrap';
|
||||||
import type { App } from '../bootstrap';
|
import type { App } from '../bootstrap';
|
||||||
import { bufferToUuid } from '../helpers';
|
import { bufferToUuid } from '../helpers';
|
||||||
|
import { contactByEncryptedUsernameRoute } from '../../util/signalRoutes';
|
||||||
|
|
||||||
export const debug = createDebug('mock:test:username');
|
export const debug = createDebug('mock:test:username');
|
||||||
|
|
||||||
|
@ -310,9 +310,14 @@ describe('pnp/username', function (this: Mocha.Suite) {
|
||||||
CARL_USERNAME
|
CARL_USERNAME
|
||||||
);
|
);
|
||||||
|
|
||||||
const linkUrl = generateUsernameLink(
|
const linkUrl = contactByEncryptedUsernameRoute
|
||||||
Buffer.concat([entropy, uuidToBytes(serverId)]).toString('base64')
|
.toWebUrl({
|
||||||
);
|
encryptedUsername: Buffer.concat([
|
||||||
|
entropy,
|
||||||
|
uuidToBytes(serverId),
|
||||||
|
]).toString('base64'),
|
||||||
|
})
|
||||||
|
.toString();
|
||||||
|
|
||||||
debug('sending link to Note to Self');
|
debug('sending link to Note to Self');
|
||||||
await phone.sendText(desktop, linkUrl, {
|
await phone.sendText(desktop, linkUrl, {
|
||||||
|
|
79
ts/test-mock/routing/routing_test.ts
Normal file
79
ts/test-mock/routing/routing_test.ts
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
// Copyright 2023 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { assert } from 'chai';
|
||||||
|
|
||||||
|
import * as durations from '../../util/durations';
|
||||||
|
import type { Bootstrap, App } from '../bootstrap';
|
||||||
|
import {
|
||||||
|
artAddStickersRoute,
|
||||||
|
showConversationRoute,
|
||||||
|
} from '../../util/signalRoutes';
|
||||||
|
import {
|
||||||
|
initStorage,
|
||||||
|
STICKER_PACKS,
|
||||||
|
storeStickerPacks,
|
||||||
|
} from '../storage/fixtures';
|
||||||
|
import { strictAssert } from '../../util/assert';
|
||||||
|
|
||||||
|
describe('routing', function (this: Mocha.Suite) {
|
||||||
|
this.timeout(durations.MINUTE);
|
||||||
|
|
||||||
|
let bootstrap: Bootstrap;
|
||||||
|
let app: App;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
({ bootstrap, app } = await initStorage());
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async function (this: Mocha.Context) {
|
||||||
|
await bootstrap.maybeSaveLogs(this.currentTest, app);
|
||||||
|
await app.close();
|
||||||
|
await bootstrap.teardown();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('artAddStickersRoute', async () => {
|
||||||
|
const { server } = bootstrap;
|
||||||
|
const stickerPack = STICKER_PACKS[0];
|
||||||
|
await storeStickerPacks(server, [stickerPack]);
|
||||||
|
const stickerUrl = artAddStickersRoute.toWebUrl({
|
||||||
|
packId: stickerPack.id.toString('hex'),
|
||||||
|
packKey: stickerPack.key.toString('hex'),
|
||||||
|
});
|
||||||
|
await app.openSignalRoute(stickerUrl);
|
||||||
|
const page = await app.getWindow();
|
||||||
|
const title = page.locator(
|
||||||
|
'.module-sticker-manager__preview-modal__footer--title',
|
||||||
|
{ hasText: 'Test Stickerpack' }
|
||||||
|
);
|
||||||
|
await title.waitFor();
|
||||||
|
assert.isTrue(await title.isVisible());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('showConversationRoute', async () => {
|
||||||
|
const { contacts } = bootstrap;
|
||||||
|
const [friend] = contacts;
|
||||||
|
const page = await app.getWindow();
|
||||||
|
await page.locator('#LeftPane').waitFor();
|
||||||
|
const conversationId = await page.evaluate(
|
||||||
|
serviceId => window.SignalCI?.getConversationId(serviceId),
|
||||||
|
friend.toContact().aci
|
||||||
|
);
|
||||||
|
strictAssert(
|
||||||
|
typeof conversationId === 'string',
|
||||||
|
'conversationId must exist'
|
||||||
|
);
|
||||||
|
const conversationUrl = showConversationRoute.toAppUrl({
|
||||||
|
conversationId,
|
||||||
|
messageId: null,
|
||||||
|
storyId: null,
|
||||||
|
});
|
||||||
|
await app.openSignalRoute(conversationUrl);
|
||||||
|
const title = page.locator(
|
||||||
|
'.module-ConversationHeader__header__info__title',
|
||||||
|
{ hasText: 'Alice Smith' }
|
||||||
|
);
|
||||||
|
await title.waitFor();
|
||||||
|
assert.isTrue(await title.isVisible());
|
||||||
|
});
|
||||||
|
});
|
|
@ -2,13 +2,22 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import createDebug from 'debug';
|
import createDebug from 'debug';
|
||||||
import type { Group, PrimaryDevice } from '@signalapp/mock-server';
|
import type {
|
||||||
|
Group,
|
||||||
|
PrimaryDevice,
|
||||||
|
Server,
|
||||||
|
StorageStateRecord,
|
||||||
|
} from '@signalapp/mock-server';
|
||||||
import { StorageState, Proto } from '@signalapp/mock-server';
|
import { StorageState, Proto } from '@signalapp/mock-server';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
import { range } from 'lodash';
|
||||||
import { App } from '../playwright';
|
import { App } from '../playwright';
|
||||||
import { Bootstrap } from '../bootstrap';
|
import { Bootstrap } from '../bootstrap';
|
||||||
import type { BootstrapOptions } from '../bootstrap';
|
import type { BootstrapOptions } from '../bootstrap';
|
||||||
import { MY_STORY_ID } from '../../types/Stories';
|
import { MY_STORY_ID } from '../../types/Stories';
|
||||||
import { uuidToBytes } from '../../util/uuidToBytes';
|
import { uuidToBytes } from '../../util/uuidToBytes';
|
||||||
|
import { artAddStickersRoute } from '../../util/signalRoutes';
|
||||||
|
|
||||||
export const debug = createDebug('mock:test:storage');
|
export const debug = createDebug('mock:test:storage');
|
||||||
|
|
||||||
|
@ -123,3 +132,77 @@ export async function initStorage(
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const FIXTURES = path.join(__dirname, '..', '..', '..', 'fixtures');
|
||||||
|
|
||||||
|
export const EMPTY = new Uint8Array(0);
|
||||||
|
|
||||||
|
export type StickerPackType = Readonly<{
|
||||||
|
id: Buffer;
|
||||||
|
key: Buffer;
|
||||||
|
stickerCount: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export const STICKER_PACKS: ReadonlyArray<StickerPackType> = [
|
||||||
|
{
|
||||||
|
id: Buffer.from('c40ed069cdc2b91eccfccf25e6bcddfc', 'hex'),
|
||||||
|
key: Buffer.from(
|
||||||
|
'cefadd6e81c128680aead1711eb5c92c10f63bdfbc78528a4519ba682de396e4',
|
||||||
|
'hex'
|
||||||
|
),
|
||||||
|
stickerCount: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: Buffer.from('ae8fedafda4768fd3384d4b3b9db963d', 'hex'),
|
||||||
|
key: Buffer.from(
|
||||||
|
'53f4aa8b95e1c2e75afab2328fe67eb6d7affbcd4f50cd4da89dfc325dbc73ca',
|
||||||
|
'hex'
|
||||||
|
),
|
||||||
|
stickerCount: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getStickerPackLink(pack: StickerPackType): string {
|
||||||
|
return artAddStickersRoute
|
||||||
|
.toWebUrl({
|
||||||
|
packId: pack.id.toString('hex'),
|
||||||
|
packKey: pack.key.toString('hex'),
|
||||||
|
})
|
||||||
|
.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStickerPackRecordPredicate(
|
||||||
|
pack: StickerPackType
|
||||||
|
): (record: StorageStateRecord) => boolean {
|
||||||
|
return ({ type, record }: StorageStateRecord): boolean => {
|
||||||
|
if (type !== IdentifierType.STICKER_PACK) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return pack.id.equals(record.stickerPack?.packId ?? EMPTY);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function storeStickerPacks(
|
||||||
|
server: Server,
|
||||||
|
stickerPacks: ReadonlyArray<StickerPackType>
|
||||||
|
): Promise<void> {
|
||||||
|
await Promise.all(
|
||||||
|
stickerPacks.map(async ({ id, stickerCount }) => {
|
||||||
|
const hexId = id.toString('hex');
|
||||||
|
|
||||||
|
await server.storeStickerPack({
|
||||||
|
id,
|
||||||
|
manifest: await fs.readFile(
|
||||||
|
path.join(FIXTURES, `stickerpack-${hexId}.bin`)
|
||||||
|
),
|
||||||
|
stickers: await Promise.all(
|
||||||
|
range(0, stickerCount).map(async index =>
|
||||||
|
fs.readFile(
|
||||||
|
path.join(FIXTURES, `stickerpack-${hexId}-${index}.bin`)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -2,66 +2,21 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import { assert } from 'chai';
|
import { assert } from 'chai';
|
||||||
import { range } from 'lodash';
|
|
||||||
import { Proto } from '@signalapp/mock-server';
|
import { Proto } from '@signalapp/mock-server';
|
||||||
import type { StorageStateRecord } from '@signalapp/mock-server';
|
|
||||||
import fs from 'fs/promises';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
import * as durations from '../../util/durations';
|
import * as durations from '../../util/durations';
|
||||||
import type { App, Bootstrap } from './fixtures';
|
import type { App, Bootstrap } from './fixtures';
|
||||||
import { initStorage, debug } from './fixtures';
|
import {
|
||||||
|
initStorage,
|
||||||
|
debug,
|
||||||
|
STICKER_PACKS,
|
||||||
|
EMPTY,
|
||||||
|
storeStickerPacks,
|
||||||
|
getStickerPackRecordPredicate,
|
||||||
|
getStickerPackLink,
|
||||||
|
} from './fixtures';
|
||||||
|
|
||||||
const { StickerPackOperation } = Proto.SyncMessage;
|
const { StickerPackOperation } = Proto.SyncMessage;
|
||||||
|
|
||||||
const FIXTURES = path.join(__dirname, '..', '..', '..', 'fixtures');
|
|
||||||
const IdentifierType = Proto.ManifestRecord.Identifier.Type;
|
|
||||||
|
|
||||||
const EMPTY = new Uint8Array(0);
|
|
||||||
|
|
||||||
export type StickerPackType = Readonly<{
|
|
||||||
id: Buffer;
|
|
||||||
key: Buffer;
|
|
||||||
stickerCount: number;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
const STICKER_PACKS: ReadonlyArray<StickerPackType> = [
|
|
||||||
{
|
|
||||||
id: Buffer.from('c40ed069cdc2b91eccfccf25e6bcddfc', 'hex'),
|
|
||||||
key: Buffer.from(
|
|
||||||
'cefadd6e81c128680aead1711eb5c92c10f63bdfbc78528a4519ba682de396e4',
|
|
||||||
'hex'
|
|
||||||
),
|
|
||||||
stickerCount: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: Buffer.from('ae8fedafda4768fd3384d4b3b9db963d', 'hex'),
|
|
||||||
key: Buffer.from(
|
|
||||||
'53f4aa8b95e1c2e75afab2328fe67eb6d7affbcd4f50cd4da89dfc325dbc73ca',
|
|
||||||
'hex'
|
|
||||||
),
|
|
||||||
stickerCount: 1,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function getStickerPackLink(pack: StickerPackType): string {
|
|
||||||
return (
|
|
||||||
`https://signal.art/addstickers/#pack_id=${pack.id.toString('hex')}&` +
|
|
||||||
`pack_key=${pack.key.toString('hex')}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStickerPackRecordPredicate(
|
|
||||||
pack: StickerPackType
|
|
||||||
): (record: StorageStateRecord) => boolean {
|
|
||||||
return ({ type, record }: StorageStateRecord): boolean => {
|
|
||||||
if (type !== IdentifierType.STICKER_PACK) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return pack.id.equals(record.stickerPack?.packId ?? EMPTY);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('storage service', function (this: Mocha.Suite) {
|
describe('storage service', function (this: Mocha.Suite) {
|
||||||
this.timeout(durations.MINUTE);
|
this.timeout(durations.MINUTE);
|
||||||
|
|
||||||
|
@ -70,28 +25,8 @@ describe('storage service', function (this: Mocha.Suite) {
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
({ bootstrap, app } = await initStorage());
|
({ bootstrap, app } = await initStorage());
|
||||||
|
|
||||||
const { server } = bootstrap;
|
const { server } = bootstrap;
|
||||||
|
await storeStickerPacks(server, STICKER_PACKS);
|
||||||
await Promise.all(
|
|
||||||
STICKER_PACKS.map(async ({ id, stickerCount }) => {
|
|
||||||
const hexId = id.toString('hex');
|
|
||||||
|
|
||||||
await server.storeStickerPack({
|
|
||||||
id,
|
|
||||||
manifest: await fs.readFile(
|
|
||||||
path.join(FIXTURES, `stickerpack-${hexId}.bin`)
|
|
||||||
),
|
|
||||||
stickers: await Promise.all(
|
|
||||||
range(0, stickerCount).map(async index =>
|
|
||||||
fs.readFile(
|
|
||||||
path.join(FIXTURES, `stickerpack-${hexId}-${index}.bin`)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async function (this: Mocha.Context) {
|
afterEach(async function (this: Mocha.Context) {
|
||||||
|
|
|
@ -1,27 +0,0 @@
|
||||||
// Copyright 2021 Signal Messenger, LLC
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
|
|
||||||
import { assert } from 'chai';
|
|
||||||
import { size } from '../../util/iterables';
|
|
||||||
|
|
||||||
import { getProvisioningUrl } from '../../util/getProvisioningUrl';
|
|
||||||
|
|
||||||
// It'd be nice to run these tests in the renderer, too, but [Chromium's `URL` doesn't
|
|
||||||
// handle `sgnl:` links correctly][0].
|
|
||||||
//
|
|
||||||
// [0]: https://bugs.chromium.org/p/chromium/issues/detail?id=869291
|
|
||||||
describe('getProvisioningUrl', () => {
|
|
||||||
it('returns a URL with a UUID and public key', () => {
|
|
||||||
const uuid = 'a08bf1fd-1799-427f-a551-70af747e3956';
|
|
||||||
const publicKey = new Uint8Array([9, 8, 7, 6, 5, 4, 3]);
|
|
||||||
|
|
||||||
const result = getProvisioningUrl(uuid, publicKey);
|
|
||||||
const resultUrl = new URL(result);
|
|
||||||
|
|
||||||
assert.strictEqual(resultUrl.protocol, 'sgnl:');
|
|
||||||
assert.strictEqual(resultUrl.host, 'linkdevice');
|
|
||||||
assert.strictEqual(size(resultUrl.searchParams.entries()), 2);
|
|
||||||
assert.strictEqual(resultUrl.searchParams.get('uuid'), uuid);
|
|
||||||
assert.strictEqual(resultUrl.searchParams.get('pub_key'), 'CQgHBgUEAw==');
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,530 +0,0 @@
|
||||||
// Copyright 2020 Signal Messenger, LLC
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
|
|
||||||
import { assert } from 'chai';
|
|
||||||
import Sinon from 'sinon';
|
|
||||||
import type { LoggerType } from '../../types/Logging';
|
|
||||||
|
|
||||||
import {
|
|
||||||
isSgnlHref,
|
|
||||||
isCaptchaHref,
|
|
||||||
isSignalHttpsLink,
|
|
||||||
parseSgnlHref,
|
|
||||||
parseCaptchaHref,
|
|
||||||
parseE164FromSignalDotMeHash,
|
|
||||||
parseUsernameBase64FromSignalDotMeHash,
|
|
||||||
parseSignalHttpsLink,
|
|
||||||
generateUsernameLink,
|
|
||||||
rewriteSignalHrefsIfNecessary,
|
|
||||||
} from '../../util/sgnlHref';
|
|
||||||
|
|
||||||
function shouldNeverBeCalled() {
|
|
||||||
assert.fail('This should never be called');
|
|
||||||
}
|
|
||||||
|
|
||||||
const explodingLogger: LoggerType = {
|
|
||||||
fatal: shouldNeverBeCalled,
|
|
||||||
error: shouldNeverBeCalled,
|
|
||||||
warn: shouldNeverBeCalled,
|
|
||||||
info: shouldNeverBeCalled,
|
|
||||||
debug: shouldNeverBeCalled,
|
|
||||||
trace: shouldNeverBeCalled,
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('sgnlHref', () => {
|
|
||||||
[
|
|
||||||
{ protocol: 'sgnl', check: isSgnlHref, name: 'isSgnlHref' },
|
|
||||||
{ protocol: 'signalcaptcha', check: isCaptchaHref, name: 'isCaptchaHref' },
|
|
||||||
].forEach(({ protocol, check, name }) => {
|
|
||||||
describe(name, () => {
|
|
||||||
it('returns false for non-strings', () => {
|
|
||||||
const logger = {
|
|
||||||
...explodingLogger,
|
|
||||||
warn: Sinon.spy(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const castToString = (value: unknown): string => value as string;
|
|
||||||
|
|
||||||
assert.isFalse(check(castToString(undefined), logger));
|
|
||||||
assert.isFalse(check(castToString(null), logger));
|
|
||||||
assert.isFalse(check(castToString(123), logger));
|
|
||||||
|
|
||||||
Sinon.assert.calledThrice(logger.warn);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns false for invalid URLs', () => {
|
|
||||||
assert.isFalse(check('', explodingLogger));
|
|
||||||
assert.isFalse(check(protocol, explodingLogger));
|
|
||||||
assert.isFalse(check(`${protocol}://::`, explodingLogger));
|
|
||||||
});
|
|
||||||
|
|
||||||
it(`returns false if the protocol is not "${protocol}:"`, () => {
|
|
||||||
assert.isFalse(check('https://example', explodingLogger));
|
|
||||||
assert.isFalse(
|
|
||||||
check('https://signal.art/addstickers/?pack_id=abc', explodingLogger)
|
|
||||||
);
|
|
||||||
assert.isFalse(check('signal://example', explodingLogger));
|
|
||||||
});
|
|
||||||
|
|
||||||
it(`returns true if the protocol is "${protocol}:"`, () => {
|
|
||||||
assert.isTrue(check(`${protocol}://`, explodingLogger));
|
|
||||||
assert.isTrue(check(`${protocol}://example`, explodingLogger));
|
|
||||||
assert.isTrue(check(`${protocol}://example.com`, explodingLogger));
|
|
||||||
assert.isTrue(
|
|
||||||
check(`${protocol.toUpperCase()}://example`, explodingLogger)
|
|
||||||
);
|
|
||||||
assert.isTrue(check(`${protocol}://example?foo=bar`, explodingLogger));
|
|
||||||
assert.isTrue(check(`${protocol}://example/`, explodingLogger));
|
|
||||||
assert.isTrue(check(`${protocol}://example#`, explodingLogger));
|
|
||||||
|
|
||||||
assert.isTrue(check(`${protocol}:foo`, explodingLogger));
|
|
||||||
|
|
||||||
assert.isTrue(
|
|
||||||
check(`${protocol}://user:pass@example`, explodingLogger)
|
|
||||||
);
|
|
||||||
assert.isTrue(check(`${protocol}://example.com:1234`, explodingLogger));
|
|
||||||
assert.isTrue(
|
|
||||||
check(`${protocol}://example.com/extra/path/data`, explodingLogger)
|
|
||||||
);
|
|
||||||
assert.isTrue(
|
|
||||||
check(`${protocol}://example/?foo=bar#hash`, explodingLogger)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts URL objects', () => {
|
|
||||||
const invalid = new URL('https://example.com');
|
|
||||||
assert.isFalse(check(invalid, explodingLogger));
|
|
||||||
const valid = new URL(`${protocol}://example`);
|
|
||||||
assert.isTrue(check(valid, explodingLogger));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isSignalHttpsLink', () => {
|
|
||||||
it('returns false for non-strings', () => {
|
|
||||||
const logger = {
|
|
||||||
...explodingLogger,
|
|
||||||
warn: Sinon.spy(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const castToString = (value: unknown): string => value as string;
|
|
||||||
|
|
||||||
assert.isFalse(isSignalHttpsLink(castToString(undefined), logger));
|
|
||||||
assert.isFalse(isSignalHttpsLink(castToString(null), logger));
|
|
||||||
assert.isFalse(isSignalHttpsLink(castToString(123), logger));
|
|
||||||
|
|
||||||
Sinon.assert.calledThrice(logger.warn);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns false for invalid URLs', () => {
|
|
||||||
assert.isFalse(isSignalHttpsLink('', explodingLogger));
|
|
||||||
assert.isFalse(isSignalHttpsLink('https', explodingLogger));
|
|
||||||
assert.isFalse(isSignalHttpsLink('https://::', explodingLogger));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns false if the protocol is not "https:"', () => {
|
|
||||||
assert.isFalse(
|
|
||||||
isSignalHttpsLink(
|
|
||||||
'sgnl://signal.art/#pack_id=234234&pack_key=342342',
|
|
||||||
explodingLogger
|
|
||||||
)
|
|
||||||
);
|
|
||||||
assert.isFalse(
|
|
||||||
isSignalHttpsLink(
|
|
||||||
'sgnl://signal.art/addstickers/#pack_id=234234&pack_key=342342',
|
|
||||||
explodingLogger
|
|
||||||
)
|
|
||||||
);
|
|
||||||
assert.isFalse(
|
|
||||||
isSignalHttpsLink(
|
|
||||||
'signal://signal.group/#AD234Dq342dSDJWE',
|
|
||||||
explodingLogger
|
|
||||||
)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns false if missing path/hash/query', () => {
|
|
||||||
assert.isFalse(
|
|
||||||
isSignalHttpsLink('https://signal.group/', explodingLogger)
|
|
||||||
);
|
|
||||||
assert.isFalse(isSignalHttpsLink('https://signal.art/', explodingLogger));
|
|
||||||
assert.isFalse(isSignalHttpsLink('https://signal.me/', explodingLogger));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns false if the URL is not a valid Signal URL', () => {
|
|
||||||
assert.isFalse(isSignalHttpsLink('https://signal.org', explodingLogger));
|
|
||||||
assert.isFalse(isSignalHttpsLink('https://example.com', explodingLogger));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns true if the protocol is "https:"', () => {
|
|
||||||
assert.isTrue(
|
|
||||||
isSignalHttpsLink(
|
|
||||||
'https://signal.group/#AD234Dq342dSDJWE',
|
|
||||||
explodingLogger
|
|
||||||
)
|
|
||||||
);
|
|
||||||
assert.isTrue(
|
|
||||||
isSignalHttpsLink(
|
|
||||||
'https://signal.group/AD234Dq342dSDJWE',
|
|
||||||
explodingLogger
|
|
||||||
)
|
|
||||||
);
|
|
||||||
assert.isTrue(
|
|
||||||
isSignalHttpsLink(
|
|
||||||
'https://signal.group/?AD234Dq342dSDJWE',
|
|
||||||
explodingLogger
|
|
||||||
)
|
|
||||||
);
|
|
||||||
assert.isTrue(
|
|
||||||
isSignalHttpsLink(
|
|
||||||
'https://signal.art/addstickers/#pack_id=234234&pack_key=342342',
|
|
||||||
explodingLogger
|
|
||||||
)
|
|
||||||
);
|
|
||||||
assert.isTrue(
|
|
||||||
isSignalHttpsLink(
|
|
||||||
'HTTPS://signal.art/addstickers/#pack_id=234234&pack_key=342342',
|
|
||||||
explodingLogger
|
|
||||||
)
|
|
||||||
);
|
|
||||||
assert.isTrue(
|
|
||||||
isSignalHttpsLink('https://signal.me/#p/+32423432', explodingLogger)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns false if username or password are set', () => {
|
|
||||||
assert.isFalse(
|
|
||||||
isSignalHttpsLink('https://user:password@signal.group', explodingLogger)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns false if port is set', () => {
|
|
||||||
assert.isFalse(
|
|
||||||
isSignalHttpsLink(
|
|
||||||
'https://signal.group:1234/#AD234Dq342dSDJWE',
|
|
||||||
explodingLogger
|
|
||||||
)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts URL objects', () => {
|
|
||||||
const invalid = new URL('sgnl://example.com');
|
|
||||||
assert.isFalse(isSignalHttpsLink(invalid, explodingLogger));
|
|
||||||
const valid = new URL('https://signal.art/#AD234Dq342dSDJWE');
|
|
||||||
assert.isTrue(isSignalHttpsLink(valid, explodingLogger));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('parseSgnlHref', () => {
|
|
||||||
it('returns a null command for invalid URLs', () => {
|
|
||||||
['', 'sgnl', 'https://example/?foo=bar'].forEach(href => {
|
|
||||||
assert.deepEqual(parseSgnlHref(href, explodingLogger), {
|
|
||||||
command: null,
|
|
||||||
args: new Map<never, never>(),
|
|
||||||
hash: undefined,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses the command for URLs with no arguments', () => {
|
|
||||||
[
|
|
||||||
'sgnl://foo',
|
|
||||||
'sgnl://foo/',
|
|
||||||
'sgnl://foo?',
|
|
||||||
'SGNL://foo?',
|
|
||||||
'sgnl://user:pass@foo',
|
|
||||||
'sgnl://foo/path/data',
|
|
||||||
].forEach(href => {
|
|
||||||
assert.deepEqual(parseSgnlHref(href, explodingLogger), {
|
|
||||||
command: 'foo',
|
|
||||||
args: new Map<string, string>(),
|
|
||||||
hash: undefined,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses a command's arguments", () => {
|
|
||||||
assert.deepEqual(
|
|
||||||
parseSgnlHref(
|
|
||||||
'sgnl://Foo?bar=baz&qux=Quux&num=123&empty=&encoded=hello%20world',
|
|
||||||
explodingLogger
|
|
||||||
),
|
|
||||||
{
|
|
||||||
command: 'Foo',
|
|
||||||
args: new Map([
|
|
||||||
['bar', 'baz'],
|
|
||||||
['qux', 'Quux'],
|
|
||||||
['num', '123'],
|
|
||||||
['empty', ''],
|
|
||||||
['encoded', 'hello world'],
|
|
||||||
]),
|
|
||||||
hash: undefined,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('treats the port as part of the command', () => {
|
|
||||||
assert.propertyVal(
|
|
||||||
parseSgnlHref('sgnl://foo:1234', explodingLogger),
|
|
||||||
'command',
|
|
||||||
'foo:1234'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignores duplicate query parameters', () => {
|
|
||||||
assert.deepPropertyVal(
|
|
||||||
parseSgnlHref('sgnl://x?foo=bar&foo=totally-ignored', explodingLogger),
|
|
||||||
'args',
|
|
||||||
new Map([['foo', 'bar']])
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('includes hash', () => {
|
|
||||||
[
|
|
||||||
'sgnl://foo?bar=baz#somehash',
|
|
||||||
'sgnl://user:pass@foo?bar=baz#somehash',
|
|
||||||
].forEach(href => {
|
|
||||||
assert.deepEqual(parseSgnlHref(href, explodingLogger), {
|
|
||||||
command: 'foo',
|
|
||||||
args: new Map([['bar', 'baz']]),
|
|
||||||
hash: 'somehash',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignores other parts of the URL', () => {
|
|
||||||
[
|
|
||||||
'sgnl://foo?bar=baz',
|
|
||||||
'sgnl://foo/?bar=baz',
|
|
||||||
'sgnl://foo/lots/of/path?bar=baz',
|
|
||||||
'sgnl://user:pass@foo?bar=baz',
|
|
||||||
].forEach(href => {
|
|
||||||
assert.deepEqual(parseSgnlHref(href, explodingLogger), {
|
|
||||||
command: 'foo',
|
|
||||||
args: new Map([['bar', 'baz']]),
|
|
||||||
hash: undefined,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("doesn't do anything fancy with arrays or objects in the query string", () => {
|
|
||||||
// The `qs` module does things like this, which we don't want.
|
|
||||||
assert.deepPropertyVal(
|
|
||||||
parseSgnlHref('sgnl://x?foo[]=bar&foo[]=baz', explodingLogger),
|
|
||||||
'args',
|
|
||||||
new Map([['foo[]', 'bar']])
|
|
||||||
);
|
|
||||||
assert.deepPropertyVal(
|
|
||||||
parseSgnlHref('sgnl://x?foo[bar][baz]=foobarbaz', explodingLogger),
|
|
||||||
'args',
|
|
||||||
new Map([['foo[bar][baz]', 'foobarbaz']])
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('parseCaptchaHref', () => {
|
|
||||||
it('throws on invalid URLs', () => {
|
|
||||||
['', 'sgnl', 'https://example/?foo=bar'].forEach(href => {
|
|
||||||
assert.throws(
|
|
||||||
() => parseCaptchaHref(href, explodingLogger),
|
|
||||||
'Not a captcha href'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses the command for URLs with no arguments', () => {
|
|
||||||
[
|
|
||||||
'signalcaptcha://foo',
|
|
||||||
'signalcaptcha://foo?x=y',
|
|
||||||
'signalcaptcha://a:b@foo?x=y',
|
|
||||||
'signalcaptcha://foo#hash',
|
|
||||||
'signalcaptcha://foo/',
|
|
||||||
].forEach(href => {
|
|
||||||
assert.deepEqual(parseCaptchaHref(href, explodingLogger), {
|
|
||||||
captcha: 'foo',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('parseE164FromSignalDotMeHash', () => {
|
|
||||||
it('returns undefined for invalid inputs', () => {
|
|
||||||
[
|
|
||||||
'',
|
|
||||||
' p/+18885551234',
|
|
||||||
'p/+18885551234 ',
|
|
||||||
'x/+18885551234',
|
|
||||||
'p/+notanumber',
|
|
||||||
'p/7c7e87a0-3b74-4efd-9a00-6eb8b1dd5be8',
|
|
||||||
'p/+08885551234',
|
|
||||||
'p/18885551234',
|
|
||||||
].forEach(hash => {
|
|
||||||
assert.isUndefined(parseE164FromSignalDotMeHash(hash));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the E164 for valid inputs', () => {
|
|
||||||
assert.strictEqual(
|
|
||||||
parseE164FromSignalDotMeHash('p/+18885551234'),
|
|
||||||
'+18885551234'
|
|
||||||
);
|
|
||||||
assert.strictEqual(
|
|
||||||
parseE164FromSignalDotMeHash('p/+441632960104'),
|
|
||||||
'+441632960104'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('parseUsernameBase64FromSignalDotMeHash', () => {
|
|
||||||
it('returns undefined for invalid inputs', () => {
|
|
||||||
['', ' eu/+18885551234', 'z/18885551234'].forEach(hash => {
|
|
||||||
assert.isUndefined(parseUsernameBase64FromSignalDotMeHash(hash));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the username for valid inputs', () => {
|
|
||||||
assert.strictEqual(
|
|
||||||
parseUsernameBase64FromSignalDotMeHash(
|
|
||||||
'eu/E7wk7FTMz_UYjLAsswHpDsGku8CW7yTmlBh8gtd4yqjQlqcbh09F25x0aQT4_Efe'
|
|
||||||
),
|
|
||||||
'E7wk7FTMz/UYjLAsswHpDsGku8CW7yTmlBh8gtd4yqjQlqcbh09F25x0aQT4/Efe'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('generateUsernameLink', () => {
|
|
||||||
it('generates regular link', () => {
|
|
||||||
assert.strictEqual(
|
|
||||||
generateUsernameLink(
|
|
||||||
'E7wk7FTMz/UYjLAsswHpDsGku8CW7yTmlBh8gtd4yqjQlqcbh09F25x0aQT4/Efe'
|
|
||||||
),
|
|
||||||
'https://signal.me/#eu/E7wk7FTMz_UYjLAsswHpDsGku8CW7yTmlBh8gtd4yqjQlqcbh09F25x0aQT4_Efe'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('generates short link', () => {
|
|
||||||
assert.strictEqual(
|
|
||||||
generateUsernameLink(
|
|
||||||
'E7wk7FTMz/UYjLAsswHpDsGku8CW7yTmlBh8gtd4yqjQlqcbh09F25x0aQT4/Efe',
|
|
||||||
{ short: true }
|
|
||||||
),
|
|
||||||
'signal.me/#eu/E7wk7FTMz_UYjLAsswHpDsGku8CW7yTmlBh8gtd4yqjQlqcbh09F25x0aQT4_Efe'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('parseSignalHttpsLink', () => {
|
|
||||||
it('returns a null command for invalid URLs', () => {
|
|
||||||
['', 'https', 'https://example/?foo=bar'].forEach(href => {
|
|
||||||
assert.deepEqual(parseSignalHttpsLink(href, explodingLogger), {
|
|
||||||
command: null,
|
|
||||||
args: new Map<never, never>(),
|
|
||||||
hash: undefined,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles signal.art links', () => {
|
|
||||||
assert.deepEqual(
|
|
||||||
parseSignalHttpsLink(
|
|
||||||
'https://signal.art/addstickers/#pack_id=baz&pack_key=Quux&num=123&empty=&encoded=hello%20world',
|
|
||||||
explodingLogger
|
|
||||||
),
|
|
||||||
{
|
|
||||||
command: 'addstickers',
|
|
||||||
args: new Map([
|
|
||||||
['pack_id', 'baz'],
|
|
||||||
['pack_key', 'Quux'],
|
|
||||||
['num', '123'],
|
|
||||||
['empty', ''],
|
|
||||||
['encoded', 'hello world'],
|
|
||||||
]),
|
|
||||||
hash: 'pack_id=baz&pack_key=Quux&num=123&empty=&encoded=hello%20world',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles signal.group links', () => {
|
|
||||||
assert.deepEqual(
|
|
||||||
parseSignalHttpsLink('https://signal.group/#data', explodingLogger),
|
|
||||||
{
|
|
||||||
command: 'signal.group',
|
|
||||||
args: new Map<never, never>(),
|
|
||||||
hash: 'data',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles signal.me links', () => {
|
|
||||||
assert.deepEqual(
|
|
||||||
parseSignalHttpsLink(
|
|
||||||
'https://signal.me/#p/+18885551234',
|
|
||||||
explodingLogger
|
|
||||||
),
|
|
||||||
{
|
|
||||||
command: 'signal.me',
|
|
||||||
args: new Map<never, never>(),
|
|
||||||
hash: 'p/+18885551234',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('rewriteSignalHrefsIfNecessary', () => {
|
|
||||||
it('rewrites http://signal.group hrefs, making them use HTTPS', () => {
|
|
||||||
assert.strictEqual(
|
|
||||||
rewriteSignalHrefsIfNecessary('http://signal.group/#abc123'),
|
|
||||||
'https://signal.group/#abc123'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rewrites http://signal.art hrefs, making them use HTTPS', () => {
|
|
||||||
assert.strictEqual(
|
|
||||||
rewriteSignalHrefsIfNecessary(
|
|
||||||
'http://signal.art/addstickers/#pack_id=abc123'
|
|
||||||
),
|
|
||||||
'https://signal.art/addstickers/#pack_id=abc123'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rewrites http://signal.me hrefs, making them use HTTPS', () => {
|
|
||||||
assert.strictEqual(
|
|
||||||
rewriteSignalHrefsIfNecessary('http://signal.me/#p/+18885551234'),
|
|
||||||
'https://signal.me/#p/+18885551234'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('removes auth if present', () => {
|
|
||||||
assert.strictEqual(
|
|
||||||
rewriteSignalHrefsIfNecessary(
|
|
||||||
'http://user:pass@signal.group/ab?c=d#ef'
|
|
||||||
),
|
|
||||||
'https://signal.group/ab?c=d#ef'
|
|
||||||
);
|
|
||||||
assert.strictEqual(
|
|
||||||
rewriteSignalHrefsIfNecessary(
|
|
||||||
'https://user:pass@signal.group/ab?c=d#ef'
|
|
||||||
),
|
|
||||||
'https://signal.group/ab?c=d#ef'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does nothing to other hrefs', () => {
|
|
||||||
[
|
|
||||||
// Normal URLs
|
|
||||||
'http://example.com',
|
|
||||||
// Already HTTPS
|
|
||||||
'https://signal.art/addstickers/#pack_id=abc123',
|
|
||||||
// Different port
|
|
||||||
'http://signal.group:1234/abc?d=e#fg',
|
|
||||||
// Different subdomain
|
|
||||||
'http://subdomain.signal.group/#abcdef',
|
|
||||||
// Different protocol
|
|
||||||
'ftp://signal.group/#abc123',
|
|
||||||
'ftp://user:pass@signal.group/#abc123',
|
|
||||||
].forEach(href => {
|
|
||||||
assert.strictEqual(rewriteSignalHrefsIfNecessary(href), href);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
205
ts/test-node/util/signalRoutes_test.ts
Normal file
205
ts/test-node/util/signalRoutes_test.ts
Normal file
|
@ -0,0 +1,205 @@
|
||||||
|
// Copyright 2023 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
import { assert } from 'chai';
|
||||||
|
import type { ParsedSignalRoute } from '../../util/signalRoutes';
|
||||||
|
import {
|
||||||
|
isSignalRoute,
|
||||||
|
parseSignalRoute,
|
||||||
|
toSignalRouteAppUrl,
|
||||||
|
toSignalRouteUrl,
|
||||||
|
toSignalRouteWebUrl,
|
||||||
|
} from '../../util/signalRoutes';
|
||||||
|
|
||||||
|
describe('signalRoutes', () => {
|
||||||
|
type CheckConfig = {
|
||||||
|
hasAppUrl: boolean;
|
||||||
|
hasWebUrl: boolean;
|
||||||
|
isRoute: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createCheck(options: Partial<CheckConfig> = {}) {
|
||||||
|
const config: CheckConfig = {
|
||||||
|
hasAppUrl: true,
|
||||||
|
hasWebUrl: true,
|
||||||
|
isRoute: true,
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
// Different than `isRoute` because of normalization
|
||||||
|
const hasRouteUrl = config.hasAppUrl || config.hasWebUrl;
|
||||||
|
return function check(input: string, expected: ParsedSignalRoute | null) {
|
||||||
|
const url = new URL(input);
|
||||||
|
assert.deepEqual(parseSignalRoute(url), expected);
|
||||||
|
assert.deepEqual(isSignalRoute(url), config.isRoute);
|
||||||
|
assert.deepEqual(toSignalRouteUrl(url) != null, hasRouteUrl);
|
||||||
|
assert.deepEqual(toSignalRouteAppUrl(url) != null, config.hasAppUrl);
|
||||||
|
assert.deepEqual(toSignalRouteWebUrl(url) != null, config.hasWebUrl);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('nonsense', () => {
|
||||||
|
const check = createCheck({
|
||||||
|
isRoute: false,
|
||||||
|
hasAppUrl: false,
|
||||||
|
hasWebUrl: false,
|
||||||
|
});
|
||||||
|
// Charles Entertainment Cheese, what are you doing here?
|
||||||
|
check('https://www.chuckecheese.com/#p/+1234567890', null);
|
||||||
|
// Non-route signal urls
|
||||||
|
check('https://signal.me', null);
|
||||||
|
check('sgnl://signal.me/#p', null);
|
||||||
|
check('sgnl://signal.me/#p/', null);
|
||||||
|
check('sgnl://signal.me/p/+1234567890', null);
|
||||||
|
check('https://signal.me/?p/+1234567890', null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalize', () => {
|
||||||
|
const check = createCheck({ isRoute: false, hasAppUrl: true });
|
||||||
|
check('http://username:password@signal.me:8888/#p/+1234567890', null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('contactByPhoneNumber', () => {
|
||||||
|
const result: ParsedSignalRoute = {
|
||||||
|
key: 'contactByPhoneNumber',
|
||||||
|
args: { phoneNumber: '+1234567890' },
|
||||||
|
};
|
||||||
|
const check = createCheck();
|
||||||
|
check('https://signal.me/#p/+1234567890', result);
|
||||||
|
check('https://signal.me#p/+1234567890', result);
|
||||||
|
check('sgnl://signal.me/#p/+1234567890', result);
|
||||||
|
check('sgnl://signal.me#p/+1234567890', result);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('contactByEncryptedUsername', () => {
|
||||||
|
const result: ParsedSignalRoute = {
|
||||||
|
key: 'contactByEncryptedUsername',
|
||||||
|
args: { encryptedUsername: 'foobar' },
|
||||||
|
};
|
||||||
|
const check = createCheck();
|
||||||
|
check('https://signal.me/#eu/foobar', result);
|
||||||
|
check('https://signal.me#eu/foobar', result);
|
||||||
|
check('sgnl://signal.me/#eu/foobar', result);
|
||||||
|
check('sgnl://signal.me#eu/foobar', result);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('groupInvites', () => {
|
||||||
|
const result: ParsedSignalRoute = {
|
||||||
|
key: 'groupInvites',
|
||||||
|
args: { inviteCode: 'foobar' },
|
||||||
|
};
|
||||||
|
const check = createCheck();
|
||||||
|
check('https://signal.group/#foobar', result);
|
||||||
|
check('https://signal.group#foobar', result);
|
||||||
|
check('sgnl://signal.group/#foobar', result);
|
||||||
|
check('sgnl://signal.group#foobar', result);
|
||||||
|
check('sgnl://joingroup/#foobar', result);
|
||||||
|
check('sgnl://joingroup#foobar', result);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('linkDevice', () => {
|
||||||
|
const result: ParsedSignalRoute = {
|
||||||
|
key: 'linkDevice',
|
||||||
|
args: { uuid: 'foo', pubKey: 'bar' },
|
||||||
|
};
|
||||||
|
const check = createCheck({ hasWebUrl: false });
|
||||||
|
check('sgnl://linkdevice/?uuid=foo&pub_key=bar', result);
|
||||||
|
check('sgnl://linkdevice?uuid=foo&pub_key=bar', result);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('captcha', () => {
|
||||||
|
const result: ParsedSignalRoute = {
|
||||||
|
key: 'captcha',
|
||||||
|
args: { captchaId: 'foobar' },
|
||||||
|
};
|
||||||
|
const check = createCheck({ hasWebUrl: false });
|
||||||
|
check('signalcaptcha://foobar', result);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('linkCall', () => {
|
||||||
|
const result: ParsedSignalRoute = {
|
||||||
|
key: 'linkCall',
|
||||||
|
args: { key: 'foobar' },
|
||||||
|
};
|
||||||
|
const check = createCheck();
|
||||||
|
check('https://signal.link/call/#key=foobar', result);
|
||||||
|
check('https://signal.link/call#key=foobar', result);
|
||||||
|
check('sgnl://signal.link/call/#key=foobar', result);
|
||||||
|
check('sgnl://signal.link/call#key=foobar', result);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('artAuth', () => {
|
||||||
|
const result: ParsedSignalRoute = {
|
||||||
|
key: 'artAuth',
|
||||||
|
args: { token: 'foo', pubKey: 'bar' },
|
||||||
|
};
|
||||||
|
const check = createCheck({ hasWebUrl: false });
|
||||||
|
check('sgnl://art-auth/?token=foo&pub_key=bar', result);
|
||||||
|
check('sgnl://art-auth?token=foo&pub_key=bar', result);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('artAddStickers', () => {
|
||||||
|
const result: ParsedSignalRoute = {
|
||||||
|
key: 'artAddStickers',
|
||||||
|
args: { packId: 'foo', packKey: 'bar' },
|
||||||
|
};
|
||||||
|
const check = createCheck();
|
||||||
|
check('https://signal.art/addstickers/#pack_id=foo&pack_key=bar', result);
|
||||||
|
check('https://signal.art/addstickers#pack_id=foo&pack_key=bar', result);
|
||||||
|
check('sgnl://addstickers/?pack_id=foo&pack_key=bar', result);
|
||||||
|
check('sgnl://addstickers?pack_id=foo&pack_key=bar', result);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('showConversation', () => {
|
||||||
|
const check = createCheck({ isRoute: true, hasWebUrl: false });
|
||||||
|
const args1 = 'conversationId=abc';
|
||||||
|
const args2 = 'conversationId=abc&messageId=def';
|
||||||
|
const args3 = 'conversationId=abc&messageId=def&storyId=ghi';
|
||||||
|
const result1: ParsedSignalRoute = {
|
||||||
|
key: 'showConversation',
|
||||||
|
args: { conversationId: 'abc', messageId: null, storyId: null },
|
||||||
|
};
|
||||||
|
const result2: ParsedSignalRoute = {
|
||||||
|
key: 'showConversation',
|
||||||
|
args: { conversationId: 'abc', messageId: 'def', storyId: null },
|
||||||
|
};
|
||||||
|
const result3: ParsedSignalRoute = {
|
||||||
|
key: 'showConversation',
|
||||||
|
args: { conversationId: 'abc', messageId: 'def', storyId: 'ghi' },
|
||||||
|
};
|
||||||
|
check(`sgnl://show-conversation/?${args1}`, result1);
|
||||||
|
check(`sgnl://show-conversation?${args1}`, result1);
|
||||||
|
check(`sgnl://show-conversation/?${args2}`, result2);
|
||||||
|
check(`sgnl://show-conversation?${args2}`, result2);
|
||||||
|
check(`sgnl://show-conversation/?${args3}`, result3);
|
||||||
|
check(`sgnl://show-conversation?${args3}`, result3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('startCallLobby', () => {
|
||||||
|
const result: ParsedSignalRoute = {
|
||||||
|
key: 'startCallLobby',
|
||||||
|
args: { conversationId: 'abc' },
|
||||||
|
};
|
||||||
|
const check = createCheck({ isRoute: true, hasWebUrl: false });
|
||||||
|
check('sgnl://start-call-lobby/?conversationId=abc', result);
|
||||||
|
check('sgnl://start-call-lobby?conversationId=abc', result);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('showWindow', () => {
|
||||||
|
const result: ParsedSignalRoute = {
|
||||||
|
key: 'showWindow',
|
||||||
|
args: {},
|
||||||
|
};
|
||||||
|
const check = createCheck({ isRoute: true, hasWebUrl: false });
|
||||||
|
check('sgnl://show-window/', result);
|
||||||
|
check('sgnl://show-window', result);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setIsPresenting', () => {
|
||||||
|
const result: ParsedSignalRoute = {
|
||||||
|
key: 'setIsPresenting',
|
||||||
|
args: {},
|
||||||
|
};
|
||||||
|
const check = createCheck({ isRoute: true, hasWebUrl: false });
|
||||||
|
check('sgnl://set-is-presenting/', result);
|
||||||
|
check('sgnl://set-is-presenting', result);
|
||||||
|
});
|
||||||
|
});
|
|
@ -52,12 +52,12 @@ import { isMoreRecentThan, isOlderThan } from '../util/timestamp';
|
||||||
import { ourProfileKeyService } from '../services/ourProfileKey';
|
import { ourProfileKeyService } from '../services/ourProfileKey';
|
||||||
import { assertDev, strictAssert } from '../util/assert';
|
import { assertDev, strictAssert } from '../util/assert';
|
||||||
import { getRegionCodeForNumber } from '../util/libphonenumberUtil';
|
import { getRegionCodeForNumber } from '../util/libphonenumberUtil';
|
||||||
import { getProvisioningUrl } from '../util/getProvisioningUrl';
|
|
||||||
import { isNotNil } from '../util/isNotNil';
|
import { isNotNil } from '../util/isNotNil';
|
||||||
import { missingCaseError } from '../util/missingCaseError';
|
import { missingCaseError } from '../util/missingCaseError';
|
||||||
import { SignalService as Proto } from '../protobuf';
|
import { SignalService as Proto } from '../protobuf';
|
||||||
import * as log from '../logging/log';
|
import * as log from '../logging/log';
|
||||||
import type { StorageAccessType } from '../types/Storage';
|
import type { StorageAccessType } from '../types/Storage';
|
||||||
|
import { linkDeviceRoute } from '../util/signalRoutes';
|
||||||
|
|
||||||
type StorageKeyByServiceIdKind = {
|
type StorageKeyByServiceIdKind = {
|
||||||
[kind in ServiceIdKind]: keyof StorageAccessType;
|
[kind in ServiceIdKind]: keyof StorageAccessType;
|
||||||
|
@ -358,7 +358,12 @@ export default class AccountManager extends EventTarget {
|
||||||
if (!uuid) {
|
if (!uuid) {
|
||||||
throw new Error('registerSecondDevice: expected a UUID');
|
throw new Error('registerSecondDevice: expected a UUID');
|
||||||
}
|
}
|
||||||
const url = getProvisioningUrl(uuid, pubKey);
|
const url = linkDeviceRoute
|
||||||
|
.toAppUrl({
|
||||||
|
uuid,
|
||||||
|
pubKey: Bytes.toBase64(pubKey),
|
||||||
|
})
|
||||||
|
.toString();
|
||||||
|
|
||||||
window.SignalCI?.setProvisioningURL(url);
|
window.SignalCI?.setProvisioningURL(url);
|
||||||
|
|
||||||
|
|
|
@ -9,6 +9,7 @@ import { maybeParseUrl } from '../util/url';
|
||||||
import { replaceEmojiWithSpaces } from '../util/emoji';
|
import { replaceEmojiWithSpaces } from '../util/emoji';
|
||||||
|
|
||||||
import type { AttachmentWithHydratedData } from './Attachment';
|
import type { AttachmentWithHydratedData } from './Attachment';
|
||||||
|
import { artAddStickersRoute, groupInvitesRoute } from '../util/signalRoutes';
|
||||||
|
|
||||||
export type LinkPreviewImage = AttachmentWithHydratedData;
|
export type LinkPreviewImage = AttachmentWithHydratedData;
|
||||||
|
|
||||||
|
@ -95,11 +96,13 @@ export function shouldLinkifyMessage(
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isStickerPack(link = ''): boolean {
|
export function isStickerPack(link = ''): boolean {
|
||||||
return link.startsWith('https://signal.art/addstickers/');
|
const url = maybeParseUrl(link);
|
||||||
|
return url?.protocol === 'https:' && artAddStickersRoute.isMatch(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isGroupLink(link = ''): boolean {
|
export function isGroupLink(link = ''): boolean {
|
||||||
return link.startsWith('https://signal.group/');
|
const url = maybeParseUrl(link);
|
||||||
|
return url?.protocol === 'https:' && groupInvitesRoute.isMatch(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findLinks(text: string, caretLocation?: number): Array<string> {
|
export function findLinks(text: string, caretLocation?: number): Array<string> {
|
||||||
|
|
|
@ -35,16 +35,14 @@ import * as durations from './durations';
|
||||||
import type { DurationInSeconds } from './durations';
|
import type { DurationInSeconds } from './durations';
|
||||||
import { isPhoneNumberSharingEnabled } from './isPhoneNumberSharingEnabled';
|
import { isPhoneNumberSharingEnabled } from './isPhoneNumberSharingEnabled';
|
||||||
import * as Registration from './registration';
|
import * as Registration from './registration';
|
||||||
import {
|
|
||||||
parseE164FromSignalDotMeHash,
|
|
||||||
parseUsernameBase64FromSignalDotMeHash,
|
|
||||||
} from './sgnlHref';
|
|
||||||
import { lookupConversationWithoutServiceId } from './lookupConversationWithoutServiceId';
|
import { lookupConversationWithoutServiceId } from './lookupConversationWithoutServiceId';
|
||||||
import * as log from '../logging/log';
|
import * as log from '../logging/log';
|
||||||
import { deleteAllMyStories } from './deleteAllMyStories';
|
import { deleteAllMyStories } from './deleteAllMyStories';
|
||||||
import { isEnabled } from '../RemoteConfig';
|
import { isEnabled } from '../RemoteConfig';
|
||||||
import type { NotificationClickData } from '../services/notifications';
|
import type { NotificationClickData } from '../services/notifications';
|
||||||
import { StoryViewModeType, StoryViewTargetType } from '../types/Stories';
|
import { StoryViewModeType, StoryViewTargetType } from '../types/Stories';
|
||||||
|
import { isValidE164 } from './isValidE164';
|
||||||
|
import { fromWebSafeBase64 } from './webSafeBase64';
|
||||||
|
|
||||||
type SentMediaQualityType = 'standard' | 'high';
|
type SentMediaQualityType = 'standard' | 'high';
|
||||||
type ThemeType = 'light' | 'dark' | 'system';
|
type ThemeType = 'light' | 'dark' | 'system';
|
||||||
|
@ -121,9 +119,12 @@ export type IPCEventsCallbacksType = {
|
||||||
resetAllChatColors: () => void;
|
resetAllChatColors: () => void;
|
||||||
resetDefaultChatColor: () => void;
|
resetDefaultChatColor: () => void;
|
||||||
showConversationViaNotification: (data: NotificationClickData) => void;
|
showConversationViaNotification: (data: NotificationClickData) => void;
|
||||||
showConversationViaSignalDotMe: (hash: string) => Promise<void>;
|
showConversationViaSignalDotMe: (
|
||||||
|
kind: string,
|
||||||
|
value: string
|
||||||
|
) => Promise<void>;
|
||||||
showKeyboardShortcuts: () => void;
|
showKeyboardShortcuts: () => void;
|
||||||
showGroupViaLink: (x: string) => Promise<void>;
|
showGroupViaLink: (value: string) => Promise<void>;
|
||||||
showReleaseNotes: () => void;
|
showReleaseNotes: () => void;
|
||||||
showStickerPack: (packId: string, key: string) => void;
|
showStickerPack: (packId: string, key: string) => void;
|
||||||
shutdown: () => Promise<void>;
|
shutdown: () => Promise<void>;
|
||||||
|
@ -497,14 +498,14 @@ export function createIPCEvents(
|
||||||
}
|
}
|
||||||
window.reduxActions.globalModals.showStickerPackPreview(packId, key);
|
window.reduxActions.globalModals.showStickerPackPreview(packId, key);
|
||||||
},
|
},
|
||||||
showGroupViaLink: async hash => {
|
showGroupViaLink: async value => {
|
||||||
// We can get these events even if the user has never linked this instance.
|
// We can get these events even if the user has never linked this instance.
|
||||||
if (!Registration.everDone()) {
|
if (!Registration.everDone()) {
|
||||||
log.warn('showGroupViaLink: Not registered, returning early');
|
log.warn('showGroupViaLink: Not registered, returning early');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await window.Signal.Groups.joinViaLink(hash);
|
await window.Signal.Groups.joinViaLink(value);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error(
|
log.error(
|
||||||
'showGroupViaLink: Ran into an error!',
|
'showGroupViaLink: Ran into an error!',
|
||||||
|
@ -532,14 +533,14 @@ export function createIPCEvents(
|
||||||
} else {
|
} else {
|
||||||
window.reduxActions.conversations.showConversation({
|
window.reduxActions.conversations.showConversation({
|
||||||
conversationId,
|
conversationId,
|
||||||
messageId,
|
messageId: messageId ?? undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
window.reduxActions.app.openInbox();
|
window.reduxActions.app.openInbox();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async showConversationViaSignalDotMe(hash: string) {
|
async showConversationViaSignalDotMe(kind: string, value: string) {
|
||||||
if (!Registration.everDone()) {
|
if (!Registration.everDone()) {
|
||||||
log.info(
|
log.info(
|
||||||
'showConversationViaSignalDotMe: Not registered, returning early'
|
'showConversationViaSignalDotMe: Not registered, returning early'
|
||||||
|
@ -549,45 +550,35 @@ export function createIPCEvents(
|
||||||
|
|
||||||
const { showUserNotFoundModal } = window.reduxActions.globalModals;
|
const { showUserNotFoundModal } = window.reduxActions.globalModals;
|
||||||
|
|
||||||
const maybeE164 = parseE164FromSignalDotMeHash(hash);
|
let conversationId: string | undefined;
|
||||||
if (maybeE164) {
|
|
||||||
const convoId = await lookupConversationWithoutServiceId({
|
if (kind === 'phoneNumber') {
|
||||||
type: 'e164',
|
if (isValidE164(value, true)) {
|
||||||
e164: maybeE164,
|
conversationId = await lookupConversationWithoutServiceId({
|
||||||
phoneNumber: maybeE164,
|
type: 'e164',
|
||||||
showUserNotFoundModal,
|
e164: value,
|
||||||
setIsFetchingUUID: noop,
|
phoneNumber: value,
|
||||||
});
|
showUserNotFoundModal,
|
||||||
if (convoId) {
|
setIsFetchingUUID: noop,
|
||||||
window.reduxActions.conversations.showConversation({
|
});
|
||||||
conversationId: convoId,
|
}
|
||||||
|
} else if (kind === 'encryptedUsername') {
|
||||||
|
const usernameBase64 = fromWebSafeBase64(value);
|
||||||
|
const username = await resolveUsernameByLinkBase64(usernameBase64);
|
||||||
|
if (username != null) {
|
||||||
|
conversationId = await lookupConversationWithoutServiceId({
|
||||||
|
type: 'username',
|
||||||
|
username,
|
||||||
|
showUserNotFoundModal,
|
||||||
|
setIsFetchingUUID: noop,
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
// We will show not found modal on error
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const maybeUsernameBase64 = parseUsernameBase64FromSignalDotMeHash(hash);
|
if (conversationId != null) {
|
||||||
let username: string | undefined;
|
window.reduxActions.conversations.showConversation({
|
||||||
if (maybeUsernameBase64) {
|
conversationId,
|
||||||
username = await resolveUsernameByLinkBase64(maybeUsernameBase64);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (username) {
|
|
||||||
const convoId = await lookupConversationWithoutServiceId({
|
|
||||||
type: 'username',
|
|
||||||
username,
|
|
||||||
showUserNotFoundModal,
|
|
||||||
setIsFetchingUUID: noop,
|
|
||||||
});
|
});
|
||||||
if (convoId) {
|
|
||||||
window.reduxActions.conversations.showConversation({
|
|
||||||
conversationId: convoId,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// We will show not found modal on error
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,14 +0,0 @@
|
||||||
// Copyright 2021 Signal Messenger, LLC
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
|
|
||||||
import * as Bytes from '../Bytes';
|
|
||||||
|
|
||||||
export function getProvisioningUrl(
|
|
||||||
uuid: string,
|
|
||||||
publicKey: Uint8Array
|
|
||||||
): string {
|
|
||||||
const url = new URL('sgnl://linkdevice');
|
|
||||||
url.searchParams.set('uuid', uuid);
|
|
||||||
url.searchParams.set('pub_key', Bytes.toBase64(publicKey));
|
|
||||||
return url.toString();
|
|
||||||
}
|
|
|
@ -1,196 +0,0 @@
|
||||||
// Copyright 2020 Signal Messenger, LLC
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
|
|
||||||
import type { LoggerType } from '../types/Logging';
|
|
||||||
import { maybeParseUrl } from './url';
|
|
||||||
import { isValidE164 } from './isValidE164';
|
|
||||||
import { fromWebSafeBase64, toWebSafeBase64 } from './webSafeBase64';
|
|
||||||
|
|
||||||
const SIGNAL_HOSTS = new Set(['signal.group', 'signal.art', 'signal.me']);
|
|
||||||
const SIGNAL_DOT_ME_E164_PREFIX = 'p/';
|
|
||||||
|
|
||||||
function parseUrl(value: string | URL, logger: LoggerType): undefined | URL {
|
|
||||||
if (value instanceof URL) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
return maybeParseUrl(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.warn('Tried to parse a sgnl:// URL but got an unexpected type');
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isSgnlHref(value: string | URL, logger: LoggerType): boolean {
|
|
||||||
const url = parseUrl(value, logger);
|
|
||||||
return Boolean(url?.protocol === 'sgnl:');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isCaptchaHref(
|
|
||||||
value: string | URL,
|
|
||||||
logger: LoggerType
|
|
||||||
): boolean {
|
|
||||||
const url = parseUrl(value, logger);
|
|
||||||
return Boolean(url?.protocol === 'signalcaptcha:');
|
|
||||||
}
|
|
||||||
|
|
||||||
// A link to a signal 'action' domain with private data in path/hash/query. We could
|
|
||||||
// open a browser, but it will just link back to us. We will parse it locally instead.
|
|
||||||
export function isSignalHttpsLink(
|
|
||||||
value: string | URL,
|
|
||||||
logger: LoggerType
|
|
||||||
): boolean {
|
|
||||||
const url = parseUrl(value, logger);
|
|
||||||
return Boolean(
|
|
||||||
url &&
|
|
||||||
!url.username &&
|
|
||||||
!url.password &&
|
|
||||||
!url.port &&
|
|
||||||
url.protocol === 'https:' &&
|
|
||||||
SIGNAL_HOSTS.has(url.host) &&
|
|
||||||
(url.hash || url.pathname !== '/' || url.search)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type ParsedSgnlHref =
|
|
||||||
| { command: null; args: Map<never, never>; hash: undefined }
|
|
||||||
| { command: string; args: Map<string, string>; hash: string | undefined };
|
|
||||||
export function parseSgnlHref(
|
|
||||||
href: string,
|
|
||||||
logger: LoggerType
|
|
||||||
): ParsedSgnlHref {
|
|
||||||
const url = parseUrl(href, logger);
|
|
||||||
if (!url || !isSgnlHref(url, logger)) {
|
|
||||||
return { command: null, args: new Map<never, never>(), hash: undefined };
|
|
||||||
}
|
|
||||||
|
|
||||||
const args = new Map<string, string>();
|
|
||||||
url.searchParams.forEach((value, key) => {
|
|
||||||
if (!args.has(key)) {
|
|
||||||
args.set(key, value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
command: url.host,
|
|
||||||
args,
|
|
||||||
hash: url.hash ? url.hash.slice(1) : undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
type ParsedCaptchaHref = {
|
|
||||||
readonly captcha: string;
|
|
||||||
};
|
|
||||||
export function parseCaptchaHref(
|
|
||||||
href: URL | string,
|
|
||||||
logger: LoggerType
|
|
||||||
): ParsedCaptchaHref {
|
|
||||||
const url = parseUrl(href, logger);
|
|
||||||
if (!url || !isCaptchaHref(url, logger)) {
|
|
||||||
throw new Error('Not a captcha href');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
captcha: url.host,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseSignalHttpsLink(
|
|
||||||
href: string,
|
|
||||||
logger: LoggerType
|
|
||||||
): ParsedSgnlHref {
|
|
||||||
const url = parseUrl(href, logger);
|
|
||||||
if (!url || !isSignalHttpsLink(url, logger)) {
|
|
||||||
return { command: null, args: new Map<never, never>(), hash: undefined };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.host === 'signal.art') {
|
|
||||||
const hash = url.hash.slice(1);
|
|
||||||
const hashParams = new URLSearchParams(hash);
|
|
||||||
|
|
||||||
const args = new Map<string, string>();
|
|
||||||
hashParams.forEach((value, key) => {
|
|
||||||
if (!args.has(key)) {
|
|
||||||
args.set(key, value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!args.get('pack_id') || !args.get('pack_key')) {
|
|
||||||
return { command: null, args: new Map<never, never>(), hash: undefined };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
command: url.pathname.replace(/\//g, ''),
|
|
||||||
args,
|
|
||||||
hash: url.hash ? url.hash.slice(1) : undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.host === 'signal.group' || url.host === 'signal.me') {
|
|
||||||
return {
|
|
||||||
command: url.host,
|
|
||||||
args: new Map<string, string>(),
|
|
||||||
hash: url.hash ? url.hash.slice(1) : undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { command: null, args: new Map<never, never>(), hash: undefined };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseE164FromSignalDotMeHash(hash: string): undefined | string {
|
|
||||||
if (!hash.startsWith(SIGNAL_DOT_ME_E164_PREFIX)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const maybeE164 = hash.slice(SIGNAL_DOT_ME_E164_PREFIX.length);
|
|
||||||
return isValidE164(maybeE164, true) ? maybeE164 : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseUsernameBase64FromSignalDotMeHash(
|
|
||||||
hash: string
|
|
||||||
): undefined | string {
|
|
||||||
const match = hash.match(/^eu\/([a-zA-Z0-9_-]{64})$/);
|
|
||||||
if (!match) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return fromWebSafeBase64(match[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts `http://signal.group/#abc` to `https://signal.group/#abc`. Does the same for
|
|
||||||
* other Signal hosts, like signal.me. Does nothing to other URLs. Expects a valid href.
|
|
||||||
*/
|
|
||||||
export function rewriteSignalHrefsIfNecessary(href: string): string {
|
|
||||||
const resultUrl = new URL(href);
|
|
||||||
|
|
||||||
const isHttp = resultUrl.protocol === 'http:';
|
|
||||||
const isHttpOrHttps = isHttp || resultUrl.protocol === 'https:';
|
|
||||||
|
|
||||||
if (SIGNAL_HOSTS.has(resultUrl.host) && isHttpOrHttps) {
|
|
||||||
if (isHttp) {
|
|
||||||
resultUrl.protocol = 'https:';
|
|
||||||
}
|
|
||||||
resultUrl.username = '';
|
|
||||||
resultUrl.password = '';
|
|
||||||
return resultUrl.href;
|
|
||||||
}
|
|
||||||
|
|
||||||
return href;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type GenerateUsernameLinkOptionsType = Readonly<{
|
|
||||||
short?: boolean;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export function generateUsernameLink(
|
|
||||||
base64: string,
|
|
||||||
{ short = false }: GenerateUsernameLinkOptionsType = {}
|
|
||||||
): string {
|
|
||||||
const shortVersion = `signal.me/#eu/${toWebSafeBase64(base64)}`;
|
|
||||||
if (short) {
|
|
||||||
return shortVersion;
|
|
||||||
}
|
|
||||||
return `https://${shortVersion}`;
|
|
||||||
}
|
|
739
ts/util/signalRoutes.ts
Normal file
739
ts/util/signalRoutes.ts
Normal file
|
@ -0,0 +1,739 @@
|
||||||
|
// 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 } from 'url';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { strictAssert } from './assert';
|
||||||
|
|
||||||
|
function toUrl(input: URL | string): URL | null {
|
||||||
|
if (input instanceof URL) {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new URL(input);
|
||||||
|
} 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): 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) {
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
args: config.schema.parse(config.parse(result)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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', {
|
||||||
|
patterns: [_pattern('signalcaptcha:', ':captchaId', '', {})],
|
||||||
|
schema: z.object({
|
||||||
|
captchaId: paramSchema, // opaque
|
||||||
|
}),
|
||||||
|
parse(result) {
|
||||||
|
return {
|
||||||
|
captchaId: result.hostname.groups.captchaId,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,7 +5,7 @@ export function maybeParseUrl(value: string): undefined | URL {
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
try {
|
try {
|
||||||
return new URL(value);
|
return new URL(value);
|
||||||
} catch (err) {
|
} catch {
|
||||||
/* Errors are ignored. */
|
/* Errors are ignored. */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -289,24 +289,18 @@ ipc.on('delete-all-data', async () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
ipc.on('show-sticker-pack', (_event, info) => {
|
ipc.on('show-sticker-pack', (_event, info) => {
|
||||||
const { packId, packKey } = info;
|
window.Events.showStickerPack?.(info.packId, info.packKey);
|
||||||
const { showStickerPack } = window.Events;
|
|
||||||
if (showStickerPack) {
|
|
||||||
showStickerPack(packId, packKey);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
ipc.on('show-group-via-link', (_event, info) => {
|
ipc.on('show-group-via-link', (_event, info) => {
|
||||||
const { hash } = info;
|
strictAssert(typeof info.value === 'string', 'Got an invalid value over IPC');
|
||||||
const { showGroupViaLink } = window.Events;
|
drop(window.Events.showGroupViaLink?.(info.value));
|
||||||
if (showGroupViaLink) {
|
|
||||||
void showGroupViaLink(hash);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
ipc.on('open-art-creator', () => {
|
ipc.on('open-art-creator', () => {
|
||||||
drop(window.Events.openArtCreator());
|
drop(window.Events.openArtCreator());
|
||||||
});
|
});
|
||||||
|
|
||||||
window.openArtCreator = ({
|
window.openArtCreator = ({
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
|
@ -318,8 +312,7 @@ window.openArtCreator = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
ipc.on('authorize-art-creator', (_event, info) => {
|
ipc.on('authorize-art-creator', (_event, info) => {
|
||||||
const { token, pubKeyBase64 } = info;
|
window.Events.authorizeArtCreator?.(info);
|
||||||
window.Events.authorizeArtCreator?.({ token, pubKeyBase64 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
ipc.on('start-call-lobby', (_event, { conversationId }) => {
|
ipc.on('start-call-lobby', (_event, { conversationId }) => {
|
||||||
|
@ -328,9 +321,11 @@ ipc.on('start-call-lobby', (_event, { conversationId }) => {
|
||||||
isVideoCall: true,
|
isVideoCall: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
ipc.on('show-window', () => {
|
ipc.on('show-window', () => {
|
||||||
window.IPC.showWindow();
|
window.IPC.showWindow();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipc.on('set-is-presenting', () => {
|
ipc.on('set-is-presenting', () => {
|
||||||
window.reduxActions?.calling?.setPresenting();
|
window.reduxActions?.calling?.setPresenting();
|
||||||
});
|
});
|
||||||
|
@ -345,12 +340,13 @@ ipc.on(
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
ipc.on('show-conversation-via-signal.me', (_event, info) => {
|
ipc.on('show-conversation-via-signal.me', (_event, info) => {
|
||||||
const { hash } = info;
|
const { kind, value } = info;
|
||||||
strictAssert(typeof hash === 'string', 'Got an invalid hash over IPC');
|
strictAssert(typeof kind === 'string', 'Got an invalid kind over IPC');
|
||||||
|
strictAssert(typeof value === 'string', 'Got an invalid value over IPC');
|
||||||
|
|
||||||
const { showConversationViaSignalDotMe } = window.Events;
|
const { showConversationViaSignalDotMe } = window.Events;
|
||||||
if (showConversationViaSignalDotMe) {
|
if (showConversationViaSignalDotMe) {
|
||||||
void showConversationViaSignalDotMe(hash);
|
void showConversationViaSignalDotMe(kind, value);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -19442,6 +19442,11 @@ url@^0.11.0:
|
||||||
punycode "1.3.2"
|
punycode "1.3.2"
|
||||||
querystring "0.2.0"
|
querystring "0.2.0"
|
||||||
|
|
||||||
|
urlpattern-polyfill@9.0.0:
|
||||||
|
version "9.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/urlpattern-polyfill/-/urlpattern-polyfill-9.0.0.tgz#bc7e386bb12fd7898b58d1509df21d3c29ab3460"
|
||||||
|
integrity sha512-WHN8KDQblxd32odxeIgo83rdVDE2bvdkb86it7bMhYZwWKJz0+O0RK/eZiHYnM+zgt/U7hAHOlCQGfjjvSkw2g==
|
||||||
|
|
||||||
use-callback-ref@^1.3.0:
|
use-callback-ref@^1.3.0:
|
||||||
version "1.3.0"
|
version "1.3.0"
|
||||||
resolved "https://registry.yarnpkg.com/use-callback-ref/-/use-callback-ref-1.3.0.tgz#772199899b9c9a50526fedc4993fc7fa1f7e32d5"
|
resolved "https://registry.yarnpkg.com/use-callback-ref/-/use-callback-ref-1.3.0.tgz#772199899b9c9a50526fedc4993fc7fa1f7e32d5"
|
||||||
|
|
Loading…
Reference in a new issue