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
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
name: CI
|
||||
name: Danger
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
|
|
|
@ -18,6 +18,7 @@ ts/protobuf/*.d.ts
|
|||
ts/protobuf/*.js
|
||||
stylesheets/manifest.css
|
||||
ts/util/lint/exceptions.json
|
||||
storybook-static
|
||||
|
||||
# Third-party files
|
||||
node_modules/**
|
||||
|
|
|
@ -3451,6 +3451,28 @@ Signal Desktop makes use of the following open source projects.
|
|||
|
||||
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
|
||||
|
||||
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 OS from '../ts/util/os/osMain';
|
||||
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 { toggleMaximizedBrowserWindow } from '../ts/util/toggleMaximizedBrowserWindow';
|
||||
import { ChallengeMainHandler } from '../ts/main/challengeMain';
|
||||
|
@ -124,6 +115,8 @@ import { load as loadLocale } from './locale';
|
|||
import type { LoggerType } from '../ts/types/Logging';
|
||||
import { HourCyclePreference } from '../ts/types/I18N';
|
||||
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';
|
||||
|
||||
|
@ -270,18 +263,10 @@ if (!process.mas) {
|
|||
return;
|
||||
}
|
||||
|
||||
const incomingCaptchaHref = getIncomingCaptchaHref(argv);
|
||||
if (incomingCaptchaHref) {
|
||||
const { captcha } = parseCaptchaHref(incomingCaptchaHref, getLogger());
|
||||
challengeHandler.handleCaptcha(captcha);
|
||||
return true;
|
||||
const route = maybeGetIncomingSignalRoute(argv);
|
||||
if (route != null) {
|
||||
handleSignalRoute(route);
|
||||
}
|
||||
// Are they trying to open a sgnl:// href?
|
||||
const incomingHref = getIncomingHref(argv);
|
||||
if (incomingHref) {
|
||||
handleSgnlHref(incomingHref);
|
||||
}
|
||||
// Handled
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
@ -475,23 +460,21 @@ async function handleUrl(rawTarget: string) {
|
|||
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 isDevServer =
|
||||
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) {
|
||||
try {
|
||||
await shell.openExternal(target);
|
||||
await shell.openExternal(rawTarget);
|
||||
} catch (error) {
|
||||
getLogger().error(`Failed to open url: ${Errors.toLogFormat(error)}`);
|
||||
}
|
||||
|
@ -1127,9 +1110,9 @@ async function readyForUpdates() {
|
|||
isReadyForUpdates = true;
|
||||
|
||||
// First, install requested sticker pack
|
||||
const incomingHref = getIncomingHref(process.argv);
|
||||
const incomingHref = maybeGetIncomingSignalRoute(process.argv);
|
||||
if (incomingHref) {
|
||||
handleSgnlHref(incomingHref);
|
||||
handleSignalRoute(incomingHref);
|
||||
}
|
||||
|
||||
// Second, start checking for app updates
|
||||
|
@ -2199,18 +2182,10 @@ app.on('will-finish-launching', () => {
|
|||
// https://stackoverflow.com/a/43949291
|
||||
app.on('open-url', (event, incomingHref) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (isCaptchaHref(incomingHref, getLogger())) {
|
||||
const { captcha } = parseCaptchaHref(incomingHref, getLogger());
|
||||
challengeHandler.handleCaptcha(captcha);
|
||||
|
||||
// Show window after handling captcha
|
||||
showWindow();
|
||||
|
||||
return;
|
||||
const route = parseSignalRoute(incomingHref);
|
||||
if (route != null) {
|
||||
handleSignalRoute(route);
|
||||
}
|
||||
|
||||
handleSgnlHref(incomingHref);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -2521,78 +2496,71 @@ ipc.on('preferences-changed', () => {
|
|||
}
|
||||
});
|
||||
|
||||
function getIncomingHref(argv: Array<string>) {
|
||||
return argv.find(arg => isSgnlHref(arg, getLogger()));
|
||||
function maybeGetIncomingSignalRoute(argv: Array<string>) {
|
||||
for (const arg of argv) {
|
||||
const route = parseSignalRoute(arg);
|
||||
if (route != null) {
|
||||
return route;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getIncomingCaptchaHref(argv: Array<string>) {
|
||||
return argv.find(arg => isCaptchaHref(arg, getLogger()));
|
||||
}
|
||||
function handleSignalRoute(route: ParsedSignalRoute) {
|
||||
const log = getLogger();
|
||||
|
||||
function handleSgnlHref(incomingHref: string) {
|
||||
let command;
|
||||
let args;
|
||||
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 == null || !mainWindow.webContents) {
|
||||
log.error('handleSignalRoute: mainWindow is null or missing webContents');
|
||||
return;
|
||||
}
|
||||
|
||||
if (mainWindow && mainWindow.webContents) {
|
||||
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');
|
||||
log.info('handleSignalRoute: Matched signal route:', route.key);
|
||||
|
||||
mainWindow.webContents.send('authorize-art-creator', {
|
||||
token,
|
||||
pubKeyBase64,
|
||||
});
|
||||
} else if (command === 'signal.group' && hash) {
|
||||
getLogger().info('Showing group from sgnl protocol link');
|
||||
mainWindow.webContents.send('show-group-via-link', { hash });
|
||||
} else if (command === 'signal.me' && hash) {
|
||||
getLogger().info('Showing conversation from sgnl protocol link');
|
||||
mainWindow.webContents.send('show-conversation-via-signal.me', { hash });
|
||||
} else if (
|
||||
command === 'show-conversation' &&
|
||||
args &&
|
||||
args.get('conversationId')
|
||||
) {
|
||||
getLogger().info('Showing conversation from notification');
|
||||
mainWindow.webContents.send('show-conversation-via-notification', {
|
||||
conversationId: args.get('conversationId'),
|
||||
messageId: args.get('messageId'),
|
||||
storyId: args.get('storyId'),
|
||||
});
|
||||
} else if (
|
||||
command === 'start-call-lobby' &&
|
||||
args &&
|
||||
args.get('conversationId')
|
||||
) {
|
||||
getLogger().info('Starting call lobby from notification');
|
||||
mainWindow.webContents.send('start-call-lobby', {
|
||||
conversationId: args.get('conversationId'),
|
||||
});
|
||||
} else if (command === 'show-window') {
|
||||
mainWindow.webContents.send('show-window');
|
||||
} else if (command === 'set-is-presenting') {
|
||||
mainWindow.webContents.send('set-is-presenting');
|
||||
} else {
|
||||
getLogger().info('Showing warning that we cannot process link');
|
||||
mainWindow.webContents.send('unknown-sgnl-link');
|
||||
}
|
||||
if (route.key === 'artAddStickers') {
|
||||
mainWindow.webContents.send('show-sticker-pack', {
|
||||
packId: route.args.packId,
|
||||
packKey: Buffer.from(route.args.packKey, 'hex').toString('base64'),
|
||||
});
|
||||
} else if (route.key === 'artAuth') {
|
||||
mainWindow.webContents.send('authorize-art-creator', {
|
||||
token: route.args.token,
|
||||
pubKeyBase64: route.args.pubKey,
|
||||
});
|
||||
} else if (route.key === 'groupInvites') {
|
||||
mainWindow.webContents.send('show-group-via-link', {
|
||||
value: route.args.inviteCode,
|
||||
});
|
||||
} else if (route.key === 'contactByPhoneNumber') {
|
||||
mainWindow.webContents.send('show-conversation-via-signal.me', {
|
||||
kind: 'phoneNumber',
|
||||
value: route.args.phoneNumber,
|
||||
});
|
||||
} else if (route.key === 'contactByEncryptedUsername') {
|
||||
mainWindow.webContents.send('show-conversation-via-signal.me', {
|
||||
kind: 'encryptedUsername',
|
||||
value: route.args.encryptedUsername,
|
||||
});
|
||||
} else if (route.key === 'showConversation') {
|
||||
mainWindow.webContents.send('show-conversation-via-notification', {
|
||||
conversationId: route.args.conversationId,
|
||||
messageId: route.args.messageId,
|
||||
storyId: route.args.storyId,
|
||||
});
|
||||
} else if (route.key === 'startCallLobby') {
|
||||
mainWindow.webContents.send('start-call-lobby', {
|
||||
conversationId: route.args.conversationId,
|
||||
});
|
||||
} else if (route.key === 'showWindow') {
|
||||
mainWindow.webContents.send('show-window');
|
||||
} 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 {
|
||||
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 { missingCaseError } from '../ts/util/missingCaseError';
|
||||
import {
|
||||
setIsPresentingRoute,
|
||||
showConversationRoute,
|
||||
showWindowRoute,
|
||||
startCallLobbyRoute,
|
||||
} from '../ts/util/signalRoutes';
|
||||
|
||||
function pathToUri(path: string) {
|
||||
return `file:///${encodeURI(path.replace(/\\/g, '/'))}`;
|
||||
|
@ -51,21 +57,19 @@ export function renderWindowsToast({
|
|||
// 1) this maps to the notify() function in services/notifications.ts
|
||||
// 2) this also maps to the url-handling in main.ts
|
||||
if (type === NotificationType.Message || type === NotificationType.Reaction) {
|
||||
launch = new URL('sgnl://show-conversation');
|
||||
launch.searchParams.set('conversationId', conversationId);
|
||||
if (messageId) {
|
||||
launch.searchParams.set('messageId', messageId);
|
||||
}
|
||||
if (storyId) {
|
||||
launch.searchParams.set('storyId', storyId);
|
||||
}
|
||||
launch = showConversationRoute.toAppUrl({
|
||||
conversationId,
|
||||
messageId: messageId ?? null,
|
||||
storyId: storyId ?? null,
|
||||
});
|
||||
} else if (type === NotificationType.IncomingGroupCall) {
|
||||
launch = new URL(`sgnl://start-call-lobby`);
|
||||
launch.searchParams.set('conversationId', conversationId);
|
||||
launch = startCallLobbyRoute.toAppUrl({
|
||||
conversationId,
|
||||
});
|
||||
} else if (type === NotificationType.IncomingCall) {
|
||||
launch = new URL('sgnl://show-window');
|
||||
launch = showWindowRoute.toAppUrl({});
|
||||
} else if (type === NotificationType.IsPresenting) {
|
||||
launch = new URL('sgnl://set-is-presenting');
|
||||
launch = setIsPresentingRoute.toAppUrl({});
|
||||
} else {
|
||||
throw missingCaseError(type);
|
||||
}
|
||||
|
|
|
@ -181,6 +181,7 @@
|
|||
"semver": "5.7.2",
|
||||
"split2": "4.0.0",
|
||||
"type-fest": "3.5.0",
|
||||
"urlpattern-polyfill": "9.0.0",
|
||||
"uuid": "3.3.2",
|
||||
"uuid-browser": "3.1.0",
|
||||
"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 { ipcInvoke } from './sql/channels';
|
||||
import { SECOND } from './util/durations';
|
||||
import { isSignalRoute } from './util/signalRoutes';
|
||||
import { strictAssert } from './util/assert';
|
||||
|
||||
type ResolveType = (data: unknown) => void;
|
||||
|
||||
|
@ -28,6 +30,7 @@ export type CIType = {
|
|||
ignorePastEvents?: boolean;
|
||||
}
|
||||
) => unknown;
|
||||
openSignalRoute(url: string): Promise<void>;
|
||||
};
|
||||
|
||||
export function getCI(deviceName: string): CIType {
|
||||
|
@ -133,6 +136,20 @@ export function getCI(deviceName: string): CIType {
|
|||
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 {
|
||||
deviceName,
|
||||
getConversationId,
|
||||
|
@ -141,5 +158,6 @@ export function getCI(deviceName: string): CIType {
|
|||
setProvisioningURL,
|
||||
solveChallenge,
|
||||
waitForEvent,
|
||||
openSignalRoute,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -95,6 +95,7 @@ import { ReadStatus } from './messages/MessageReadStatus';
|
|||
import { SeenStatus } from './MessageSeenStatus';
|
||||
import { incrementMessageCounter } from './util/incrementMessageCounter';
|
||||
import { sleep } from './util/sleep';
|
||||
import { groupInvitesRoute } from './util/signalRoutes';
|
||||
|
||||
type AccessRequiredEnum = Proto.AccessControl.AccessRequired;
|
||||
|
||||
|
@ -383,16 +384,16 @@ export function buildGroupLink(
|
|||
},
|
||||
}).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;
|
||||
inviteLinkPassword: string;
|
||||
} {
|
||||
const base64 = fromWebSafeBase64(hash);
|
||||
const base64 = fromWebSafeBase64(value);
|
||||
const buffer = Bytes.fromBase64(base64);
|
||||
|
||||
const inviteLinkProto = Proto.GroupInviteLink.decode(buffer);
|
||||
|
|
|
@ -30,11 +30,11 @@ import { isGroupV1 } from '../util/whatTypeOfConversation';
|
|||
import { longRunningTaskWrapper } from '../util/longRunningTaskWrapper';
|
||||
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 masterKey: string;
|
||||
try {
|
||||
({ inviteLinkPassword, masterKey } = parseGroupLink(hash));
|
||||
({ inviteLinkPassword, masterKey } = parseGroupLink(value));
|
||||
} catch (error: unknown) {
|
||||
const errorString = Errors.toLogFormat(error);
|
||||
log.error(`joinViaLink: Failed to parse group link ${errorString}`);
|
||||
|
|
|
@ -36,8 +36,8 @@ type NotificationDataType = Readonly<{
|
|||
|
||||
export type NotificationClickData = Readonly<{
|
||||
conversationId: string;
|
||||
messageId?: string;
|
||||
storyId?: string;
|
||||
messageId: string | null;
|
||||
storyId: string | null;
|
||||
}>;
|
||||
export type WindowsNotificationData = {
|
||||
avatarPath?: string;
|
||||
|
@ -208,8 +208,8 @@ class NotificationService extends EventEmitter {
|
|||
window.IPC.showWindow();
|
||||
window.Events.showConversationViaNotification({
|
||||
conversationId,
|
||||
messageId,
|
||||
storyId,
|
||||
messageId: messageId ?? null,
|
||||
storyId: storyId ?? null,
|
||||
});
|
||||
} else if (type === NotificationType.IncomingGroupCall) {
|
||||
window.IPC.showWindow();
|
||||
|
|
|
@ -18,9 +18,9 @@ import { DEFAULT_CONVERSATION_COLOR } from '../../types/Colors';
|
|||
import { getPreferredReactionEmoji as getPreferredReactionEmojiFromStoredValue } from '../../reactions/preferredReactionEmoji';
|
||||
import { isBeta } from '../../util/version';
|
||||
import { DurationInSeconds } from '../../util/durations';
|
||||
import { generateUsernameLink } from '../../util/sgnlHref';
|
||||
import * as Bytes from '../../Bytes';
|
||||
import { getUserNumber, getUserACI } from './user';
|
||||
import { contactByEncryptedUsernameRoute } from '../../util/signalRoutes';
|
||||
|
||||
const DEFAULT_PREFERRED_LEFT_PANE_WIDTH = 320;
|
||||
|
||||
|
@ -112,7 +112,9 @@ export const getUsernameLink = createSelector(
|
|||
|
||||
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();
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
public override on(type: 'close', callback: () => void): this;
|
||||
|
|
|
@ -9,11 +9,11 @@ import createDebug from 'debug';
|
|||
|
||||
import * as durations from '../../util/durations';
|
||||
import { uuidToBytes } from '../../util/uuidToBytes';
|
||||
import { generateUsernameLink } from '../../util/sgnlHref';
|
||||
import { MY_STORY_ID } from '../../types/Stories';
|
||||
import { Bootstrap } from '../bootstrap';
|
||||
import type { App } from '../bootstrap';
|
||||
import { bufferToUuid } from '../helpers';
|
||||
import { contactByEncryptedUsernameRoute } from '../../util/signalRoutes';
|
||||
|
||||
export const debug = createDebug('mock:test:username');
|
||||
|
||||
|
@ -310,9 +310,14 @@ describe('pnp/username', function (this: Mocha.Suite) {
|
|||
CARL_USERNAME
|
||||
);
|
||||
|
||||
const linkUrl = generateUsernameLink(
|
||||
Buffer.concat([entropy, uuidToBytes(serverId)]).toString('base64')
|
||||
);
|
||||
const linkUrl = contactByEncryptedUsernameRoute
|
||||
.toWebUrl({
|
||||
encryptedUsername: Buffer.concat([
|
||||
entropy,
|
||||
uuidToBytes(serverId),
|
||||
]).toString('base64'),
|
||||
})
|
||||
.toString();
|
||||
|
||||
debug('sending link to Note to Self');
|
||||
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
|
||||
|
||||
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 path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { range } from 'lodash';
|
||||
import { App } from '../playwright';
|
||||
import { Bootstrap } from '../bootstrap';
|
||||
import type { BootstrapOptions } from '../bootstrap';
|
||||
import { MY_STORY_ID } from '../../types/Stories';
|
||||
import { uuidToBytes } from '../../util/uuidToBytes';
|
||||
import { artAddStickersRoute } from '../../util/signalRoutes';
|
||||
|
||||
export const debug = createDebug('mock:test:storage');
|
||||
|
||||
|
@ -123,3 +132,77 @@ export async function initStorage(
|
|||
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
|
||||
|
||||
import { assert } from 'chai';
|
||||
import { range } from 'lodash';
|
||||
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 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 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) {
|
||||
this.timeout(durations.MINUTE);
|
||||
|
||||
|
@ -70,28 +25,8 @@ describe('storage service', function (this: Mocha.Suite) {
|
|||
|
||||
beforeEach(async () => {
|
||||
({ bootstrap, app } = await initStorage());
|
||||
|
||||
const { server } = bootstrap;
|
||||
|
||||
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`)
|
||||
)
|
||||
)
|
||||
),
|
||||
});
|
||||
})
|
||||
);
|
||||
await storeStickerPacks(server, STICKER_PACKS);
|
||||
});
|
||||
|
||||
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 { assertDev, strictAssert } from '../util/assert';
|
||||
import { getRegionCodeForNumber } from '../util/libphonenumberUtil';
|
||||
import { getProvisioningUrl } from '../util/getProvisioningUrl';
|
||||
import { isNotNil } from '../util/isNotNil';
|
||||
import { missingCaseError } from '../util/missingCaseError';
|
||||
import { SignalService as Proto } from '../protobuf';
|
||||
import * as log from '../logging/log';
|
||||
import type { StorageAccessType } from '../types/Storage';
|
||||
import { linkDeviceRoute } from '../util/signalRoutes';
|
||||
|
||||
type StorageKeyByServiceIdKind = {
|
||||
[kind in ServiceIdKind]: keyof StorageAccessType;
|
||||
|
@ -358,7 +358,12 @@ export default class AccountManager extends EventTarget {
|
|||
if (!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);
|
||||
|
||||
|
|
|
@ -9,6 +9,7 @@ import { maybeParseUrl } from '../util/url';
|
|||
import { replaceEmojiWithSpaces } from '../util/emoji';
|
||||
|
||||
import type { AttachmentWithHydratedData } from './Attachment';
|
||||
import { artAddStickersRoute, groupInvitesRoute } from '../util/signalRoutes';
|
||||
|
||||
export type LinkPreviewImage = AttachmentWithHydratedData;
|
||||
|
||||
|
@ -95,11 +96,13 @@ export function shouldLinkifyMessage(
|
|||
}
|
||||
|
||||
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 {
|
||||
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> {
|
||||
|
|
|
@ -35,16 +35,14 @@ import * as durations from './durations';
|
|||
import type { DurationInSeconds } from './durations';
|
||||
import { isPhoneNumberSharingEnabled } from './isPhoneNumberSharingEnabled';
|
||||
import * as Registration from './registration';
|
||||
import {
|
||||
parseE164FromSignalDotMeHash,
|
||||
parseUsernameBase64FromSignalDotMeHash,
|
||||
} from './sgnlHref';
|
||||
import { lookupConversationWithoutServiceId } from './lookupConversationWithoutServiceId';
|
||||
import * as log from '../logging/log';
|
||||
import { deleteAllMyStories } from './deleteAllMyStories';
|
||||
import { isEnabled } from '../RemoteConfig';
|
||||
import type { NotificationClickData } from '../services/notifications';
|
||||
import { StoryViewModeType, StoryViewTargetType } from '../types/Stories';
|
||||
import { isValidE164 } from './isValidE164';
|
||||
import { fromWebSafeBase64 } from './webSafeBase64';
|
||||
|
||||
type SentMediaQualityType = 'standard' | 'high';
|
||||
type ThemeType = 'light' | 'dark' | 'system';
|
||||
|
@ -121,9 +119,12 @@ export type IPCEventsCallbacksType = {
|
|||
resetAllChatColors: () => void;
|
||||
resetDefaultChatColor: () => void;
|
||||
showConversationViaNotification: (data: NotificationClickData) => void;
|
||||
showConversationViaSignalDotMe: (hash: string) => Promise<void>;
|
||||
showConversationViaSignalDotMe: (
|
||||
kind: string,
|
||||
value: string
|
||||
) => Promise<void>;
|
||||
showKeyboardShortcuts: () => void;
|
||||
showGroupViaLink: (x: string) => Promise<void>;
|
||||
showGroupViaLink: (value: string) => Promise<void>;
|
||||
showReleaseNotes: () => void;
|
||||
showStickerPack: (packId: string, key: string) => void;
|
||||
shutdown: () => Promise<void>;
|
||||
|
@ -497,14 +498,14 @@ export function createIPCEvents(
|
|||
}
|
||||
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.
|
||||
if (!Registration.everDone()) {
|
||||
log.warn('showGroupViaLink: Not registered, returning early');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await window.Signal.Groups.joinViaLink(hash);
|
||||
await window.Signal.Groups.joinViaLink(value);
|
||||
} catch (error) {
|
||||
log.error(
|
||||
'showGroupViaLink: Ran into an error!',
|
||||
|
@ -532,14 +533,14 @@ export function createIPCEvents(
|
|||
} else {
|
||||
window.reduxActions.conversations.showConversation({
|
||||
conversationId,
|
||||
messageId,
|
||||
messageId: messageId ?? undefined,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
window.reduxActions.app.openInbox();
|
||||
}
|
||||
},
|
||||
async showConversationViaSignalDotMe(hash: string) {
|
||||
async showConversationViaSignalDotMe(kind: string, value: string) {
|
||||
if (!Registration.everDone()) {
|
||||
log.info(
|
||||
'showConversationViaSignalDotMe: Not registered, returning early'
|
||||
|
@ -549,45 +550,35 @@ export function createIPCEvents(
|
|||
|
||||
const { showUserNotFoundModal } = window.reduxActions.globalModals;
|
||||
|
||||
const maybeE164 = parseE164FromSignalDotMeHash(hash);
|
||||
if (maybeE164) {
|
||||
const convoId = await lookupConversationWithoutServiceId({
|
||||
type: 'e164',
|
||||
e164: maybeE164,
|
||||
phoneNumber: maybeE164,
|
||||
showUserNotFoundModal,
|
||||
setIsFetchingUUID: noop,
|
||||
});
|
||||
if (convoId) {
|
||||
window.reduxActions.conversations.showConversation({
|
||||
conversationId: convoId,
|
||||
let conversationId: string | undefined;
|
||||
|
||||
if (kind === 'phoneNumber') {
|
||||
if (isValidE164(value, true)) {
|
||||
conversationId = await lookupConversationWithoutServiceId({
|
||||
type: 'e164',
|
||||
e164: value,
|
||||
phoneNumber: value,
|
||||
showUserNotFoundModal,
|
||||
setIsFetchingUUID: noop,
|
||||
});
|
||||
}
|
||||
} 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);
|
||||
let username: string | undefined;
|
||||
if (maybeUsernameBase64) {
|
||||
username = await resolveUsernameByLinkBase64(maybeUsernameBase64);
|
||||
}
|
||||
|
||||
if (username) {
|
||||
const convoId = await lookupConversationWithoutServiceId({
|
||||
type: 'username',
|
||||
username,
|
||||
showUserNotFoundModal,
|
||||
setIsFetchingUUID: noop,
|
||||
if (conversationId != null) {
|
||||
window.reduxActions.conversations.showConversation({
|
||||
conversationId,
|
||||
});
|
||||
if (convoId) {
|
||||
window.reduxActions.conversations.showConversation({
|
||||
conversationId: convoId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// We will show not found modal on error
|
||||
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') {
|
||||
try {
|
||||
return new URL(value);
|
||||
} catch (err) {
|
||||
} catch {
|
||||
/* Errors are ignored. */
|
||||
}
|
||||
}
|
||||
|
|
|
@ -289,24 +289,18 @@ ipc.on('delete-all-data', async () => {
|
|||
});
|
||||
|
||||
ipc.on('show-sticker-pack', (_event, info) => {
|
||||
const { packId, packKey } = info;
|
||||
const { showStickerPack } = window.Events;
|
||||
if (showStickerPack) {
|
||||
showStickerPack(packId, packKey);
|
||||
}
|
||||
window.Events.showStickerPack?.(info.packId, info.packKey);
|
||||
});
|
||||
|
||||
ipc.on('show-group-via-link', (_event, info) => {
|
||||
const { hash } = info;
|
||||
const { showGroupViaLink } = window.Events;
|
||||
if (showGroupViaLink) {
|
||||
void showGroupViaLink(hash);
|
||||
}
|
||||
strictAssert(typeof info.value === 'string', 'Got an invalid value over IPC');
|
||||
drop(window.Events.showGroupViaLink?.(info.value));
|
||||
});
|
||||
|
||||
ipc.on('open-art-creator', () => {
|
||||
drop(window.Events.openArtCreator());
|
||||
});
|
||||
|
||||
window.openArtCreator = ({
|
||||
username,
|
||||
password,
|
||||
|
@ -318,8 +312,7 @@ window.openArtCreator = ({
|
|||
};
|
||||
|
||||
ipc.on('authorize-art-creator', (_event, info) => {
|
||||
const { token, pubKeyBase64 } = info;
|
||||
window.Events.authorizeArtCreator?.({ token, pubKeyBase64 });
|
||||
window.Events.authorizeArtCreator?.(info);
|
||||
});
|
||||
|
||||
ipc.on('start-call-lobby', (_event, { conversationId }) => {
|
||||
|
@ -328,9 +321,11 @@ ipc.on('start-call-lobby', (_event, { conversationId }) => {
|
|||
isVideoCall: true,
|
||||
});
|
||||
});
|
||||
|
||||
ipc.on('show-window', () => {
|
||||
window.IPC.showWindow();
|
||||
});
|
||||
|
||||
ipc.on('set-is-presenting', () => {
|
||||
window.reduxActions?.calling?.setPresenting();
|
||||
});
|
||||
|
@ -345,12 +340,13 @@ ipc.on(
|
|||
}
|
||||
);
|
||||
ipc.on('show-conversation-via-signal.me', (_event, info) => {
|
||||
const { hash } = info;
|
||||
strictAssert(typeof hash === 'string', 'Got an invalid hash over IPC');
|
||||
const { kind, value } = info;
|
||||
strictAssert(typeof kind === 'string', 'Got an invalid kind over IPC');
|
||||
strictAssert(typeof value === 'string', 'Got an invalid value over IPC');
|
||||
|
||||
const { showConversationViaSignalDotMe } = window.Events;
|
||||
if (showConversationViaSignalDotMe) {
|
||||
void showConversationViaSignalDotMe(hash);
|
||||
void showConversationViaSignalDotMe(kind, value);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -19442,6 +19442,11 @@ url@^0.11.0:
|
|||
punycode "1.3.2"
|
||||
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:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/use-callback-ref/-/use-callback-ref-1.3.0.tgz#772199899b9c9a50526fedc4993fc7fa1f7e32d5"
|
||||
|
|
Loading…
Reference in a new issue