Refactor Signal app routing

This commit is contained in:
Jamie Kyle 2023-11-02 12:42:31 -07:00 committed by GitHub
parent 86e6c2499c
commit 3ef0d221d1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 1347 additions and 1044 deletions

View file

@ -1,7 +1,7 @@
# Copyright 2020 Signal Messenger, LLC
# SPDX-License-Identifier: AGPL-3.0-only
name: CI
name: Danger
on:
pull_request:

View file

@ -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/**

View file

@ -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

View file

@ -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');
}
}

View file

@ -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);
}

View file

@ -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",

View file

@ -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,
};
}

View file

@ -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);

View file

@ -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}`);

View file

@ -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();

View file

@ -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();
}
);

View file

@ -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;

View file

@ -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, {

View 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());
});
});

View file

@ -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`)
)
)
),
});
})
);
}

View file

@ -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) {

View file

@ -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==');
});
});

View file

@ -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);
});
});
});
});

View 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);
});
});

View file

@ -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);

View file

@ -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> {

View file

@ -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;
}

View file

@ -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();
}

View file

@ -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
View 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;
}
}

View file

@ -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. */
}
}

View file

@ -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);
}
});

View file

@ -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"