New sticker creator button

This commit is contained in:
Fedor Indutny 2023-02-27 14:34:43 -08:00 committed by GitHub
parent 85adb39d31
commit fad0529080
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 442 additions and 11 deletions

View file

@ -2947,6 +2947,18 @@
"message": "Conversation marked unread", "message": "Conversation marked unread",
"description": "A toast that shows up when user marks a conversation as unread" "description": "A toast that shows up when user marks a conversation as unread"
}, },
"icu:AuthArtCreator--dialog--message": {
"messageformat": "Would you like to open Signal Sticker Pack Creator?",
"description": "A body of the dialog that is presented when user tries to open Signal Sticker Pack Creator from a link"
},
"icu:AuthArtCreator--dialog--confirm": {
"messageformat": "Confirm",
"description": "A buttle title for confirming Signal Sticker Pack Creator dialog"
},
"icu:AuthArtCreator--dialog--dismiss": {
"messageformat": "Dismiss",
"description": "A buttle title for dismissing Signal Sticker Pack Creator dialog"
},
"StickerCreator--title": { "StickerCreator--title": {
"message": "Sticker pack creator", "message": "Sticker pack creator",
"description": "The title of the Sticker Pack Creator window" "description": "The title of the Sticker Pack Creator window"
@ -3147,6 +3159,10 @@
"message": "Please set up Signal on your phone and desktop to use the Sticker Pack Creator", "message": "Please set up Signal on your phone and desktop to use the Sticker Pack Creator",
"description": "The error message which appears when the user has not linked their account and attempts to use the Sticker Creator" "description": "The error message which appears when the user has not linked their account and attempts to use the Sticker Creator"
}, },
"icu:ArtCreator--Authentication--error": {
"messageformat": "Please set up Signal on your phone and desktop to use the Sticker Art Creator",
"description": "The error message which appears when the user has not linked their account and attempts to use the Art Creator"
},
"Reactions--remove": { "Reactions--remove": {
"message": "Remove reaction", "message": "Remove reaction",
"describe": "Shown when you want to remove a reaction you've made" "describe": "Shown when you want to remove a reaction you've made"

View file

@ -417,6 +417,7 @@ async function prepareUrl(
storageUrl: config.get<string>('storageUrl'), storageUrl: config.get<string>('storageUrl'),
updatesUrl: config.get<string>('updatesUrl'), updatesUrl: config.get<string>('updatesUrl'),
resourcesUrl: config.get<string>('resourcesUrl'), resourcesUrl: config.get<string>('resourcesUrl'),
artCreatorUrl: config.get<string>('artCreatorUrl'),
cdnUrl0: config.get<ConfigType>('cdn').get<string>('0'), cdnUrl0: config.get<ConfigType>('cdn').get<string>('0'),
cdnUrl2: config.get<ConfigType>('cdn').get<string>('2'), cdnUrl2: config.get<ConfigType>('cdn').get<string>('2'),
certificateAuthority: config.get<string>('certificateAuthority'), certificateAuthority: config.get<string>('certificateAuthority'),
@ -1004,6 +1005,11 @@ ipc.handle('database-ready', async () => {
getLogger().info('sending `database-ready`'); getLogger().info('sending `database-ready`');
}); });
ipc.handle('open-art-creator', (_event, { username, password }) => {
const baseUrl = config.get<string>('artCreatorUrl');
drop(shell.openExternal(`${baseUrl}/#auth=${username}:${password}`));
});
ipc.on('show-window', () => { ipc.on('show-window', () => {
showWindow(); showWindow();
}); });
@ -1409,6 +1415,25 @@ async function showStickerCreator() {
await safeLoadURL(stickerCreatorWindow, await appUrl); await safeLoadURL(stickerCreatorWindow, await appUrl);
} }
async function openArtCreator() {
if (!(await getIsLinked())) {
const message = getResolvedMessagesLocale().i18n(
'icu:ArtCreator--Authentication--error'
);
await dialog.showMessageBox({
type: 'warning',
message,
});
return;
}
if (mainWindow) {
mainWindow.webContents.send('open-art-creator');
}
}
let debugLogWindow: BrowserWindow | undefined; let debugLogWindow: BrowserWindow | undefined;
async function showDebugLogWindow() { async function showDebugLogWindow() {
if (debugLogWindow) { if (debugLogWindow) {
@ -1963,6 +1988,7 @@ function setupMenu(options?: Partial<CreateTemplateOptionsType>) {
// actions // actions
forceUpdate, forceUpdate,
openArtCreator,
openContactUs, openContactUs,
openForums, openForums,
openJoinTheBeta, openJoinTheBeta,
@ -2348,6 +2374,14 @@ function handleSgnlHref(incomingHref: string) {
? Buffer.from(packKeyHex, 'hex').toString('base64') ? Buffer.from(packKeyHex, 'hex').toString('base64')
: ''; : '';
mainWindow.webContents.send('show-sticker-pack', { packId, packKey }); mainWindow.webContents.send('show-sticker-pack', { packId, packKey });
} else if (command === 'art-auth') {
const token = args?.get('token');
const pubKeyBase64 = args?.get('pub_key');
mainWindow.webContents.send('authorize-art-creator', {
token,
pubKeyBase64,
});
} else if (command === 'signal.group' && hash) { } else if (command === 'signal.group' && hash) {
getLogger().info('Showing group from sgnl protocol link'); getLogger().info('Showing group from sgnl protocol link');
mainWindow.webContents.send('show-group-via-link', { hash }); mainWindow.webContents.send('show-group-via-link', { hash });
@ -2549,6 +2583,8 @@ ipc.handle('getMenuOptions', async () => {
ipc.handle('executeMenuAction', async (_event, action: MenuActionType) => { ipc.handle('executeMenuAction', async (_event, action: MenuActionType) => {
if (action === 'forceUpdate') { if (action === 'forceUpdate') {
drop(forceUpdate()); drop(forceUpdate());
} else if (action === 'openArtCreator') {
drop(openArtCreator());
} else if (action === 'openContactUs') { } else if (action === 'openContactUs') {
openContactUs(); openContactUs();
} else if (action === 'openForums') { } else if (action === 'openForums') {

View file

@ -38,6 +38,7 @@ export const createTemplate = (
showKeyboardShortcuts, showKeyboardShortcuts,
showSettings, showSettings,
showStickerCreator, showStickerCreator,
openArtCreator,
} = options; } = options;
const template: MenuListType = [ const template: MenuListType = [
@ -46,7 +47,7 @@ export const createTemplate = (
submenu: [ submenu: [
{ {
label: i18n('mainMenuCreateStickers'), label: i18n('mainMenuCreateStickers'),
click: showStickerCreator, click: isProduction ? showStickerCreator : openArtCreator,
}, },
{ {
label: i18n('mainMenuSettings'), label: i18n('mainMenuSettings'),

View file

@ -10,6 +10,7 @@
"contentProxyUrl": "http://contentproxy.signal.org:443", "contentProxyUrl": "http://contentproxy.signal.org:443",
"updatesUrl": "https://updates2.signal.org/desktop", "updatesUrl": "https://updates2.signal.org/desktop",
"resourcesUrl": "https://updates2.signal.org", "resourcesUrl": "https://updates2.signal.org",
"artCreatorUrl": "https://create.staging.signal.art",
"updatesPublicKey": "05fd7dd3de7149dc0a127909fee7de0f7620ddd0de061b37a2c303e37de802a401", "updatesPublicKey": "05fd7dd3de7149dc0a127909fee7de0f7620ddd0de061b37a2c303e37de802a401",
"sfuUrl": "https://sfu.voip.signal.org/", "sfuUrl": "https://sfu.voip.signal.org/",
"updatesEnabled": false, "updatesEnabled": false,

14
protos/ArtCreator.proto Normal file
View file

@ -0,0 +1,14 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
package signalservice;
message ArtProvisioningEnvelope {
optional bytes publicKey = 1;
optional bytes ciphertext = 2;
}
message ArtProvisioningMessage {
optional string username = 1;
optional string password = 2;
}

View file

@ -38,6 +38,7 @@ const WebAPI = initializeWebAPI({
storageUrl: config.storageUrl, storageUrl: config.storageUrl,
updatesUrl: config.updatesUrl, updatesUrl: config.updatesUrl,
resourcesUrl: config.resourcesUrl, resourcesUrl: config.resourcesUrl,
artCreatorUrl: config.artCreatorUrl,
directoryConfig: config.directoryConfig, directoryConfig: config.directoryConfig,
cdnUrlObject: { cdnUrlObject: {
0: config.cdnUrl0, 0: config.cdnUrl0,

View file

@ -3522,7 +3522,7 @@ button.module-image__border-overlay:focus {
} }
} }
.module-spinner__circle--on-captcha { .module-spinner__circle--on-primary-button {
background-color: $color-white-alpha-40; background-color: $color-white-alpha-40;
} }
@ -3540,7 +3540,7 @@ button.module-image__border-overlay:focus {
.module-spinner__arc--on-avatar { .module-spinner__arc--on-avatar {
background-color: $color-white; background-color: $color-white;
} }
.module-spinner__arc--on-captcha { .module-spinner__arc--on-primary-button {
background-color: $color-white; background-color: $color-white;
} }

View file

@ -82,7 +82,7 @@ export function CaptchaDialog(props: Readonly<PropsType>): JSX.Element {
variant={ButtonVariant.Primary} variant={ButtonVariant.Primary}
> >
{isPending ? ( {isPending ? (
<Spinner size="22px" svgSize="small" direction="on-captcha" /> <Spinner size="22px" svgSize="small" direction="on-primary-button" />
) : ( ) : (
'Continue' 'Continue'
)} )}

View file

@ -10,11 +10,13 @@ import { ModalHost } from './ModalHost';
import { ModalPage } from './Modal'; import { ModalPage } from './Modal';
import type { Theme } from '../util/theme'; import type { Theme } from '../util/theme';
import { useAnimated } from '../hooks/useAnimated'; import { useAnimated } from '../hooks/useAnimated';
import { Spinner } from './Spinner';
export type ActionSpec = { export type ActionSpec = {
text: string; text: string;
action: () => unknown; action: () => unknown;
style?: 'affirmative' | 'negative'; style?: 'affirmative' | 'negative';
autoClose?: boolean;
}; };
export type OwnProps = Readonly<{ export type OwnProps = Readonly<{
@ -22,6 +24,7 @@ export type OwnProps = Readonly<{
dialogName: string; dialogName: string;
cancelButtonVariant?: ButtonVariant; cancelButtonVariant?: ButtonVariant;
cancelText?: string; cancelText?: string;
isSpinning?: boolean;
children?: React.ReactNode; children?: React.ReactNode;
hasXButton?: boolean; hasXButton?: boolean;
i18n: LocalizerType; i18n: LocalizerType;
@ -65,6 +68,7 @@ export const ConfirmationDialog = React.memo(function ConfirmationDialogInner({
children, children,
hasXButton, hasXButton,
i18n, i18n,
isSpinning,
moduleClassName, moduleClassName,
noMouseClose, noMouseClose,
noDefaultCancelButton, noDefaultCancelButton,
@ -99,7 +103,7 @@ export const ConfirmationDialog = React.memo(function ConfirmationDialogInner({
const footer = ( const footer = (
<> <>
{!noDefaultCancelButton ? ( {!isSpinning && !noDefaultCancelButton ? (
<Button <Button
onClick={handleCancel} onClick={handleCancel}
ref={focusRef} ref={focusRef}
@ -114,14 +118,25 @@ export const ConfirmationDialog = React.memo(function ConfirmationDialogInner({
{actions.map((action, i) => ( {actions.map((action, i) => (
<Button <Button
key={action.text} key={action.text}
disabled={isSpinning}
onClick={() => { onClick={() => {
action.action(); action.action();
close(); if (action.autoClose !== false) {
close();
}
}} }}
data-action={i} data-action={i}
variant={getButtonVariant(action.style)} variant={getButtonVariant(action.style)}
> >
{action.text} {isSpinning ? (
<Spinner
size="22px"
svgSize="small"
direction="on-primary-button"
/>
) : (
action.text
)}
</Button> </Button>
))} ))}
</> </>

View file

@ -7,6 +7,7 @@ import type {
ForwardMessagePropsType, ForwardMessagePropsType,
UserNotFoundModalStateType, UserNotFoundModalStateType,
SafetyNumberChangedBlockingDataType, SafetyNumberChangedBlockingDataType,
AuthorizeArtCreatorDataType,
} from '../state/ducks/globalModals'; } from '../state/ducks/globalModals';
import type { LocalizerType, ThemeType } from '../types/Util'; import type { LocalizerType, ThemeType } from '../types/Util';
import { missingCaseError } from '../util/missingCaseError'; import { missingCaseError } from '../util/missingCaseError';
@ -66,6 +67,11 @@ export type PropsType = {
// WhatsNewModal // WhatsNewModal
isWhatsNewVisible: boolean; isWhatsNewVisible: boolean;
hideWhatsNewModal: () => unknown; hideWhatsNewModal: () => unknown;
// AuthArtCreatorModal
authArtCreatorData?: AuthorizeArtCreatorDataType;
isAuthorizingArtCreator?: boolean;
cancelAuthorizeArtCreator: () => unknown;
confirmAuthorizeArtCreator: () => unknown;
}; };
export function GlobalModalContainer({ export function GlobalModalContainer({
@ -110,6 +116,11 @@ export function GlobalModalContainer({
// WhatsNewModal // WhatsNewModal
hideWhatsNewModal, hideWhatsNewModal,
isWhatsNewVisible, isWhatsNewVisible,
// AuthArtCreatorModal
authArtCreatorData,
isAuthorizingArtCreator,
cancelAuthorizeArtCreator,
confirmAuthorizeArtCreator,
}: PropsType): JSX.Element | null { }: PropsType): JSX.Element | null {
// We want the following dialogs to show in this order: // We want the following dialogs to show in this order:
// 1. Errors // 1. Errors
@ -200,5 +211,28 @@ export function GlobalModalContainer({
); );
} }
if (authArtCreatorData) {
return (
<ConfirmationDialog
dialogName="GlobalModalContainer.authArtCreator"
cancelText={i18n('icu:AuthArtCreator--dialog--dismiss')}
cancelButtonVariant={ButtonVariant.Secondary}
i18n={i18n}
isSpinning={isAuthorizingArtCreator}
onClose={cancelAuthorizeArtCreator}
actions={[
{
text: i18n('icu:AuthArtCreator--dialog--confirm'),
style: 'affirmative',
action: confirmAuthorizeArtCreator,
autoClose: false,
},
]}
>
{i18n('icu:AuthArtCreator--dialog--message')}
</ConfirmationDialog>
);
}
return null; return null;
} }

View file

@ -13,7 +13,7 @@ export const SpinnerDirections = [
'outgoing', 'outgoing',
'incoming', 'incoming',
'on-background', 'on-background',
'on-captcha', 'on-primary-button',
'on-progress-dialog', 'on-progress-dialog',
'on-avatar', 'on-avatar',
] as const; ] as const;

View file

@ -226,6 +226,7 @@ export function TitleBarContainer(props: PropsType): JSX.Element {
// actions // actions
forceUpdate: () => executeMenuAction('forceUpdate'), forceUpdate: () => executeMenuAction('forceUpdate'),
openArtCreator: () => executeMenuAction('openArtCreator'),
openContactUs: () => executeMenuAction('openContactUs'), openContactUs: () => executeMenuAction('openContactUs'),
openForums: () => executeMenuAction('openForums'), openForums: () => executeMenuAction('openForums'),
openJoinTheBeta: () => executeMenuAction('openJoinTheBeta'), openJoinTheBeta: () => executeMenuAction('openJoinTheBeta'),

View file

@ -12,13 +12,20 @@ import type { StateType as RootStateType } from '../reducer';
import type { UUIDStringType } from '../../types/UUID'; import type { UUIDStringType } from '../../types/UUID';
import * as SingleServePromise from '../../services/singleServePromise'; import * as SingleServePromise from '../../services/singleServePromise';
import * as Stickers from '../../types/Stickers'; import * as Stickers from '../../types/Stickers';
import * as Errors from '../../types/errors';
import { getMessageById } from '../../messages/getMessageById'; import { getMessageById } from '../../messages/getMessageById';
import { getMessagePropsSelector } from '../selectors/message'; import { getMessagePropsSelector } from '../selectors/message';
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions'; import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
import { longRunningTaskWrapper } from '../../util/longRunningTaskWrapper'; import { longRunningTaskWrapper } from '../../util/longRunningTaskWrapper';
import { useBoundActions } from '../../hooks/useBoundActions'; import { useBoundActions } from '../../hooks/useBoundActions';
import { isGroupV1 } from '../../util/whatTypeOfConversation'; import { isGroupV1 } from '../../util/whatTypeOfConversation';
import { authorizeArtCreator } from '../../textsecure/authorizeArtCreator';
import type { AuthorizeArtCreatorOptionsType } from '../../textsecure/authorizeArtCreator';
import { getGroupMigrationMembers } from '../../groups'; import { getGroupMigrationMembers } from '../../groups';
import * as log from '../../logging/log';
import { ToastType } from '../../types/Toast';
import { SHOW_TOAST } from './toast';
import type { ShowToastActionType } from './toast';
// State // State
@ -29,6 +36,8 @@ export type SafetyNumberChangedBlockingDataType = ReadonlyDeep<{
promiseUuid: UUIDStringType; promiseUuid: UUIDStringType;
source?: SafetyNumberChangeSource; source?: SafetyNumberChangeSource;
}>; }>;
export type AuthorizeArtCreatorDataType =
ReadonlyDeep<AuthorizeArtCreatorOptionsType>;
type MigrateToGV2PropsType = ReadonlyDeep<{ type MigrateToGV2PropsType = ReadonlyDeep<{
areWeInvited: boolean; areWeInvited: boolean;
@ -56,6 +65,8 @@ export type GlobalModalsStateType = ReadonlyDeep<{
safetyNumberChangedBlockingData?: SafetyNumberChangedBlockingDataType; safetyNumberChangedBlockingData?: SafetyNumberChangedBlockingDataType;
safetyNumberModalContactId?: string; safetyNumberModalContactId?: string;
stickerPackPreviewId?: string; stickerPackPreviewId?: string;
isAuthorizingArtCreator?: boolean;
authArtCreatorData?: AuthorizeArtCreatorDataType;
userNotFoundModalState?: UserNotFoundModalStateType; userNotFoundModalState?: UserNotFoundModalStateType;
}>; }>;
@ -89,6 +100,12 @@ const CLOSE_ERROR_MODAL = 'globalModals/CLOSE_ERROR_MODAL';
const SHOW_ERROR_MODAL = 'globalModals/SHOW_ERROR_MODAL'; const SHOW_ERROR_MODAL = 'globalModals/SHOW_ERROR_MODAL';
const CLOSE_SHORTCUT_GUIDE_MODAL = 'globalModals/CLOSE_SHORTCUT_GUIDE_MODAL'; const CLOSE_SHORTCUT_GUIDE_MODAL = 'globalModals/CLOSE_SHORTCUT_GUIDE_MODAL';
const SHOW_SHORTCUT_GUIDE_MODAL = 'globalModals/SHOW_SHORTCUT_GUIDE_MODAL'; const SHOW_SHORTCUT_GUIDE_MODAL = 'globalModals/SHOW_SHORTCUT_GUIDE_MODAL';
const SHOW_AUTH_ART_CREATOR = 'globalModals/SHOW_AUTH_ART_CREATOR';
const CANCEL_AUTH_ART_CREATOR = 'globalModals/CANCEL_AUTH_ART_CREATOR';
const CONFIRM_AUTH_ART_CREATOR_PENDING =
'globalModals/CONFIRM_AUTH_ART_CREATOR_PENDING';
const CONFIRM_AUTH_ART_CREATOR_FULFILLED =
'globalModals/CONFIRM_AUTH_ART_CREATOR_FULFILLED';
export type ContactModalStateType = ReadonlyDeep<{ export type ContactModalStateType = ReadonlyDeep<{
contactId: string; contactId: string;
@ -216,6 +233,23 @@ type ShowShortcutGuideModalActionType = ReadonlyDeep<{
type: typeof SHOW_SHORTCUT_GUIDE_MODAL; type: typeof SHOW_SHORTCUT_GUIDE_MODAL;
}>; }>;
export type ShowAuthArtCreatorActionType = ReadonlyDeep<{
type: typeof SHOW_AUTH_ART_CREATOR;
payload: AuthorizeArtCreatorDataType;
}>;
type CancelAuthArtCreatorActionType = ReadonlyDeep<{
type: typeof CANCEL_AUTH_ART_CREATOR;
}>;
type ConfirmAuthArtCreatorPendingActionType = ReadonlyDeep<{
type: typeof CONFIRM_AUTH_ART_CREATOR_PENDING;
}>;
type ConfirmAuthArtCreatorFulfilledActionType = ReadonlyDeep<{
type: typeof CONFIRM_AUTH_ART_CREATOR_FULFILLED;
}>;
export type GlobalModalsActionType = ReadonlyDeep< export type GlobalModalsActionType = ReadonlyDeep<
| StartMigrationToGV2ActionType | StartMigrationToGV2ActionType
| CloseGV2MigrationDialogActionType | CloseGV2MigrationDialogActionType
@ -235,6 +269,10 @@ export type GlobalModalsActionType = ReadonlyDeep<
| ShowErrorModalActionType | ShowErrorModalActionType
| CloseShortcutGuideModalActionType | CloseShortcutGuideModalActionType
| ShowShortcutGuideModalActionType | ShowShortcutGuideModalActionType
| CancelAuthArtCreatorActionType
| ConfirmAuthArtCreatorPendingActionType
| ConfirmAuthArtCreatorFulfilledActionType
| ShowAuthArtCreatorActionType
| ToggleForwardMessageModalActionType | ToggleForwardMessageModalActionType
| ToggleProfileEditorActionType | ToggleProfileEditorActionType
| ToggleProfileEditorErrorActionType | ToggleProfileEditorErrorActionType
@ -270,6 +308,9 @@ export const actions = {
showErrorModal, showErrorModal,
closeShortcutGuideModal, closeShortcutGuideModal,
showShortcutGuideModal, showShortcutGuideModal,
showAuthorizeArtCreator,
cancelAuthorizeArtCreator,
confirmAuthorizeArtCreator,
}; };
export const useGlobalModalActions = (): BoundActionCreatorsMapObject< export const useGlobalModalActions = (): BoundActionCreatorsMapObject<
@ -540,6 +581,73 @@ function showShortcutGuideModal(): ShowShortcutGuideModalActionType {
}; };
} }
function cancelAuthorizeArtCreator(): ThunkAction<
void,
RootStateType,
unknown,
CancelAuthArtCreatorActionType
> {
return async (dispatch, getState) => {
const data = getState().globalModals.authArtCreatorData;
if (!data) {
return;
}
dispatch({
type: CANCEL_AUTH_ART_CREATOR,
});
};
}
export function showAuthorizeArtCreator(
data: AuthorizeArtCreatorDataType
): ShowAuthArtCreatorActionType {
return {
type: SHOW_AUTH_ART_CREATOR,
payload: data,
};
}
export function confirmAuthorizeArtCreator(): ThunkAction<
void,
RootStateType,
unknown,
| ConfirmAuthArtCreatorPendingActionType
| ConfirmAuthArtCreatorFulfilledActionType
| CancelAuthArtCreatorActionType
| ShowToastActionType
> {
return async (dispatch, getState) => {
const data = getState().globalModals.authArtCreatorData;
if (!data) {
dispatch({ type: CANCEL_AUTH_ART_CREATOR });
return;
}
dispatch({
type: CONFIRM_AUTH_ART_CREATOR_PENDING,
});
try {
await authorizeArtCreator(data);
} catch (err) {
log.error('authorizeArtCreator failed', Errors.toLogFormat(err));
dispatch({
type: SHOW_TOAST,
payload: {
toastType: ToastType.Error,
},
});
}
dispatch({
type: CONFIRM_AUTH_ART_CREATOR_FULFILLED,
});
};
}
// Reducer // Reducer
export function getEmptyState(): GlobalModalsStateType { export function getEmptyState(): GlobalModalsStateType {
@ -718,5 +826,35 @@ export function reducer(
}; };
} }
if (action.type === CANCEL_AUTH_ART_CREATOR) {
return {
...state,
authArtCreatorData: undefined,
};
}
if (action.type === SHOW_AUTH_ART_CREATOR) {
return {
...state,
isAuthorizingArtCreator: false,
authArtCreatorData: action.payload,
};
}
if (action.type === CONFIRM_AUTH_ART_CREATOR_PENDING) {
return {
...state,
isAuthorizingArtCreator: true,
};
}
if (action.type === CONFIRM_AUTH_ART_CREATOR_FULFILLED) {
return {
...state,
isAuthorizingArtCreator: false,
authArtCreatorData: undefined,
};
}
return state; return state;
} }

View file

@ -66,6 +66,8 @@ export function SmartGlobalModalContainer(): JSX.Element {
safetyNumberModalContactId, safetyNumberModalContactId,
stickerPackPreviewId, stickerPackPreviewId,
userNotFoundModalState, userNotFoundModalState,
isAuthorizingArtCreator,
authArtCreatorData,
} = useSelector<StateType, GlobalModalsStateType>( } = useSelector<StateType, GlobalModalsStateType>(
state => state.globalModals state => state.globalModals
); );
@ -75,6 +77,8 @@ export function SmartGlobalModalContainer(): JSX.Element {
hideWhatsNewModal, hideWhatsNewModal,
hideUserNotFoundModal, hideUserNotFoundModal,
toggleSignalConnectionsModal, toggleSignalConnectionsModal,
cancelAuthorizeArtCreator,
confirmAuthorizeArtCreator,
} = useGlobalModalActions(); } = useGlobalModalActions();
const renderAddUserToAnotherGroup = useCallback(() => { const renderAddUserToAnotherGroup = useCallback(() => {
@ -143,6 +147,10 @@ export function SmartGlobalModalContainer(): JSX.Element {
theme={theme} theme={theme}
toggleSignalConnectionsModal={toggleSignalConnectionsModal} toggleSignalConnectionsModal={toggleSignalConnectionsModal}
userNotFoundModalState={userNotFoundModalState} userNotFoundModalState={userNotFoundModalState}
isAuthorizingArtCreator={isAuthorizingArtCreator}
authArtCreatorData={authArtCreatorData}
cancelAuthorizeArtCreator={cancelAuthorizeArtCreator}
confirmAuthorizeArtCreator={confirmAuthorizeArtCreator}
/> />
); );
} }

View file

@ -11,6 +11,7 @@ import { load as loadLocale } from '../../../app/locale';
import type { MenuListType } from '../../types/menu'; import type { MenuListType } from '../../types/menu';
const forceUpdate = stub(); const forceUpdate = stub();
const openArtCreator = stub();
const openContactUs = stub(); const openContactUs = stub();
const openForums = stub(); const openForums = stub();
const openJoinTheBeta = stub(); const openJoinTheBeta = stub();
@ -213,6 +214,7 @@ describe('createTemplate', () => {
const actions = { const actions = {
forceUpdate, forceUpdate,
openArtCreator,
openContactUs, openContactUs,
openForums, openForums,
openJoinTheBeta, openJoinTheBeta,

View file

@ -34,6 +34,7 @@ const JITTER = 5 * durations.SECOND;
export type SocketManagerOptions = Readonly<{ export type SocketManagerOptions = Readonly<{
url: string; url: string;
artCreatorUrl: string;
certificateAuthority: string; certificateAuthority: string;
version: string; version: string;
proxyUrl?: string; proxyUrl?: string;
@ -276,6 +277,27 @@ export class SocketManager extends EventListener {
}).getResult(); }).getResult();
} }
// Creates new WebSocket for Art Creator provisioning
public async connectExternalSocket({
url,
extraHeaders,
}: {
url: string;
extraHeaders?: Record<string, string>;
}): Promise<WebSocket> {
return connectWebSocket({
name: 'art-creator-provisioning',
url,
version: this.options.version,
proxyAgent: this.proxyAgent,
extraHeaders,
createResource(socket: WebSocket): WebSocket {
return socket;
},
}).getResult();
}
// Fetch-compatible wrapper around underlying unauthenticated/authenticated // Fetch-compatible wrapper around underlying unauthenticated/authenticated
// websocket resources. This wrapper supports only limited number of features // websocket resources. This wrapper supports only limited number of features
// of node-fetch despite being API compatible. // of node-fetch despite being API compatible.

View file

@ -15,6 +15,7 @@ import PQueue from 'p-queue';
import { v4 as getGuid } from 'uuid'; import { v4 as getGuid } from 'uuid';
import { z } from 'zod'; import { z } from 'zod';
import type { Readable } from 'stream'; import type { Readable } from 'stream';
import type { connection as WebSocket } from 'websocket';
import { assertDev, strictAssert } from '../util/assert'; import { assertDev, strictAssert } from '../util/assert';
import { isRecord } from '../util/isRecord'; import { isRecord } from '../util/isRecord';
@ -477,7 +478,6 @@ const URL_CALLS = {
config: 'v1/config', config: 'v1/config',
deliveryCert: 'v1/certificate/delivery', deliveryCert: 'v1/certificate/delivery',
devices: 'v1/devices', devices: 'v1/devices',
directoryAuth: 'v1/directory/auth',
directoryAuthV2: 'v2/directory/auth', directoryAuthV2: 'v2/directory/auth',
discovery: 'v1/discovery', discovery: 'v1/discovery',
getGroupAvatarUpload: 'v1/groups/avatar/form', getGroupAvatarUpload: 'v1/groups/avatar/form',
@ -486,6 +486,7 @@ const URL_CALLS = {
getOnboardingStoryManifest: getOnboardingStoryManifest:
'dynamic/desktop/stories/onboarding/manifest.json', 'dynamic/desktop/stories/onboarding/manifest.json',
getStickerPackUpload: 'v1/sticker/pack/form', getStickerPackUpload: 'v1/sticker/pack/form',
getArtAuth: 'v1/art/auth',
groupLog: 'v1/groups/logs', groupLog: 'v1/groups/logs',
groupJoinedAtVersion: 'v1/groups/joined_at_version', groupJoinedAtVersion: 'v1/groups/joined_at_version',
groups: 'v1/groups', groups: 'v1/groups',
@ -537,7 +538,6 @@ const WEBSOCKET_CALLS = new Set<keyof typeof URL_CALLS>([
'supportUnauthenticatedDelivery', 'supportUnauthenticatedDelivery',
// Directory // Directory
'directoryAuth',
'directoryAuthV2', 'directoryAuthV2',
// Storage // Storage
@ -552,6 +552,7 @@ type InitializeOptionsType = {
storageUrl: string; storageUrl: string;
updatesUrl: string; updatesUrl: string;
resourcesUrl: string; resourcesUrl: string;
artCreatorUrl: string;
cdnUrlObject: { cdnUrlObject: {
readonly '0': string; readonly '0': string;
readonly [propName: string]: string; readonly [propName: string]: string;
@ -831,6 +832,13 @@ export type ReportMessageOptionsType = Readonly<{
token?: string; token?: string;
}>; }>;
const artAuthZod = z.object({
username: z.string(),
password: z.string(),
});
export type ArtAuthType = z.infer<typeof artAuthZod>;
export type WebAPIType = { export type WebAPIType = {
startRegistration(): unknown; startRegistration(): unknown;
finishRegistration(baton: unknown): void; finishRegistration(baton: unknown): void;
@ -848,6 +856,7 @@ export type WebAPIType = {
version: string, version: string,
imageFiles: Array<string> imageFiles: Array<string>
) => Promise<Array<Uint8Array>>; ) => Promise<Array<Uint8Array>>;
getArtAuth: () => Promise<ArtAuthType>;
getAttachment: (cdnKey: string, cdnNumber?: number) => Promise<Uint8Array>; getAttachment: (cdnKey: string, cdnNumber?: number) => Promise<Uint8Array>;
getAvatar: (path: string) => Promise<Uint8Array>; getAvatar: (path: string) => Promise<Uint8Array>;
getDevices: () => Promise<GetDevicesResultType>; getDevices: () => Promise<GetDevicesResultType>;
@ -901,6 +910,7 @@ export type WebAPIType = {
getProvisioningResource: ( getProvisioningResource: (
handler: IRequestHandler handler: IRequestHandler
) => Promise<WebSocketResource>; ) => Promise<WebSocketResource>;
getArtProvisioningSocket: (token: string) => Promise<WebSocket>;
getSenderCertificate: ( getSenderCertificate: (
withUuid?: boolean withUuid?: boolean
) => Promise<GetSenderCertificateResultType>; ) => Promise<GetSenderCertificateResultType>;
@ -1073,6 +1083,7 @@ export function initialize({
storageUrl, storageUrl,
updatesUrl, updatesUrl,
resourcesUrl, resourcesUrl,
artCreatorUrl,
directoryConfig, directoryConfig,
cdnUrlObject, cdnUrlObject,
certificateAuthority, certificateAuthority,
@ -1092,6 +1103,9 @@ export function initialize({
if (!isString(resourcesUrl)) { if (!isString(resourcesUrl)) {
throw new Error('WebAPI.initialize: Invalid updatesUrl (general)'); throw new Error('WebAPI.initialize: Invalid updatesUrl (general)');
} }
if (!isString(artCreatorUrl)) {
throw new Error('WebAPI.initialize: Invalid artCreatorUrl');
}
if (!isObject(cdnUrlObject)) { if (!isObject(cdnUrlObject)) {
throw new Error('WebAPI.initialize: Invalid cdnUrlObject'); throw new Error('WebAPI.initialize: Invalid cdnUrlObject');
} }
@ -1139,6 +1153,7 @@ export function initialize({
const socketManager = new SocketManager({ const socketManager = new SocketManager({
url, url,
artCreatorUrl,
certificateAuthority, certificateAuthority,
version, version,
proxyUrl, proxyUrl,
@ -1224,6 +1239,8 @@ export function initialize({
fetchLinkPreviewMetadata, fetchLinkPreviewMetadata,
finishRegistration, finishRegistration,
getAccountForUsername, getAccountForUsername,
getArtAuth,
getArtProvisioningSocket,
getAttachment, getAttachment,
getAvatar, getAvatar,
getBadgeImageFile, getBadgeImageFile,
@ -2982,6 +2999,15 @@ export function initialize({
return socketManager.getProvisioningResource(handler); return socketManager.getProvisioningResource(handler);
} }
function getArtProvisioningSocket(token: string): Promise<WebSocket> {
return socketManager.connectExternalSocket({
url: `${artCreatorUrl}/api/socket?token=${token}`,
extraHeaders: {
origin: artCreatorUrl,
},
});
}
async function cdsLookup({ async function cdsLookup({
e164s, e164s,
acis = [], acis = [],
@ -2995,5 +3021,19 @@ export function initialize({
returnAcisWithoutUaks, returnAcisWithoutUaks,
}); });
} }
//
// Art
//
async function getArtAuth(): Promise<ArtAuthType> {
const response = await _ajax({
call: 'getArtAuth',
httpType: 'GET',
responseType: 'json',
});
return artAuthZod.parse(response);
}
} }
} }

View file

@ -24,7 +24,7 @@ export type IResource = {
export type ConnectOptionsType<Resource extends IResource> = Readonly<{ export type ConnectOptionsType<Resource extends IResource> = Readonly<{
name: string; name: string;
url: string; url: string;
certificateAuthority: string; certificateAuthority?: string;
version: string; version: string;
proxyAgent?: ReturnType<typeof ProxyAgent>; proxyAgent?: ReturnType<typeof ProxyAgent>;
timeout?: number; timeout?: number;

View file

@ -0,0 +1,59 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { promisify } from 'util';
import { SignalService as Proto } from '../protobuf';
import { calculateAgreement, generateKeyPair } from '../Curve';
import { encryptAttachment, deriveSecrets } from '../Crypto';
import * as Bytes from '../Bytes';
const PROVISIONING_INFO = 'Art Service Provisioning Message';
export type AuthorizeArtCreatorOptionsType = Readonly<{
token: string;
pubKeyBase64: string;
}>;
export async function authorizeArtCreator({
token,
pubKeyBase64: theirPubKeyBase64,
}: AuthorizeArtCreatorOptionsType): Promise<void> {
const { server } = window.textsecure;
if (!server) {
throw new Error('Server not ready');
}
const auth = await server.getArtAuth();
const ourKeys = generateKeyPair();
const theirPubKey = Bytes.fromBase64(theirPubKeyBase64);
const secret = calculateAgreement(theirPubKey, ourKeys.privKey);
const [aesKey, macKey] = deriveSecrets(
secret,
new Uint8Array(64),
Bytes.fromString(PROVISIONING_INFO)
);
const keys = Bytes.concatenate([aesKey, macKey]);
const { ciphertext } = encryptAttachment(
Proto.ArtProvisioningMessage.encode({
...auth,
}).finish(),
keys
);
const envelope = Proto.ArtProvisioningEnvelope.encode({
publicKey: ourKeys.pubKey,
ciphertext,
}).finish();
const socket = await server.getArtProvisioningSocket(token);
try {
await promisify(socket.sendBytes).call(socket, Buffer.from(envelope));
} finally {
socket.close(1000, 'goodbye');
}
}

View file

@ -26,6 +26,7 @@ export type DirectoryConfigType = z.infer<typeof directoryConfigSchema>;
export const rendererConfigSchema = z.object({ export const rendererConfigSchema = z.object({
appInstance: configOptionalStringSchema, appInstance: configOptionalStringSchema,
appStartInitialSpellcheckSetting: z.boolean(), appStartInitialSpellcheckSetting: z.boolean(),
artCreatorUrl: configRequiredStringSchema,
buildCreation: z.number(), buildCreation: z.number(),
buildExpiration: z.number(), buildExpiration: z.number(),
cdnUrl0: configRequiredStringSchema, cdnUrl0: configRequiredStringSchema,

View file

@ -15,6 +15,7 @@ export type MenuOptionsType = Readonly<{
export type MenuActionsType = Readonly<{ export type MenuActionsType = Readonly<{
forceUpdate: () => unknown; forceUpdate: () => unknown;
openArtCreator: () => unknown;
openContactUs: () => unknown; openContactUs: () => unknown;
openForums: () => unknown; openForums: () => unknown;
openJoinTheBeta: () => unknown; openJoinTheBeta: () => unknown;

View file

@ -19,6 +19,7 @@ import type { SystemTraySetting } from '../types/SystemTraySetting';
import { parseSystemTraySetting } from '../types/SystemTraySetting'; import { parseSystemTraySetting } from '../types/SystemTraySetting';
import type { ConversationType } from '../state/ducks/conversations'; import type { ConversationType } from '../state/ducks/conversations';
import type { AuthorizeArtCreatorDataType } from '../state/ducks/globalModals';
import { calling } from '../services/calling'; import { calling } from '../services/calling';
import { getConversationsWithCustomColorSelector } from '../state/selectors/conversations'; import { getConversationsWithCustomColorSelector } from '../state/selectors/conversations';
import { getCustomColors } from '../state/selectors/items'; import { getCustomColors } from '../state/selectors/items';
@ -85,6 +86,7 @@ export type IPCEventsValuesType = {
}; };
export type IPCEventsCallbacksType = { export type IPCEventsCallbacksType = {
openArtCreator(): Promise<void>;
getAvailableIODevices(): Promise<{ getAvailableIODevices(): Promise<{
availableCameras: Array< availableCameras: Array<
Pick<MediaDeviceInfo, 'deviceId' | 'groupId' | 'kind' | 'label'> Pick<MediaDeviceInfo, 'deviceId' | 'groupId' | 'kind' | 'label'>
@ -94,6 +96,7 @@ export type IPCEventsCallbacksType = {
}>; }>;
addCustomColor: (customColor: CustomColorType) => void; addCustomColor: (customColor: CustomColorType) => void;
addDarkOverlay: () => void; addDarkOverlay: () => void;
authorizeArtCreator: (data: AuthorizeArtCreatorDataType) => void;
deleteAllData: () => Promise<void>; deleteAllData: () => Promise<void>;
deleteAllMyStories: () => Promise<void>; deleteAllMyStories: () => Promise<void>;
closeDB: () => Promise<void>; closeDB: () => Promise<void>;
@ -188,6 +191,15 @@ export function createIPCEvents(
}; };
return { return {
openArtCreator: async () => {
const auth = await window.textsecure.server?.getArtAuth();
if (!auth) {
return;
}
window.openArtCreator(auth);
},
getDeviceName: () => window.textsecure.storage.user.getDeviceName(), getDeviceName: () => window.textsecure.storage.user.getDeviceName(),
getZoomFactor: () => window.storage.get('zoomFactor', 1), getZoomFactor: () => window.storage.get('zoomFactor', 1),
@ -439,6 +451,14 @@ export function createIPCEvents(
}); });
document.body.prepend(newOverlay); document.body.prepend(newOverlay);
}, },
authorizeArtCreator: (data: AuthorizeArtCreatorDataType) => {
// We can get these events even if the user has never linked this instance.
if (!window.Signal.Util.Registration.everDone()) {
log.warn('authorizeArtCreator: Not registered, returning early');
return;
}
window.reduxActions.globalModals.showAuthorizeArtCreator(data);
},
removeDarkOverlay: () => { removeDarkOverlay: () => {
const elems = document.querySelectorAll('.dark-overlay'); const elems = document.querySelectorAll('.dark-overlay');

1
ts/window.d.ts vendored
View file

@ -148,6 +148,7 @@ declare global {
localeMessages: { [key: string]: { message: string } }; localeMessages: { [key: string]: { message: string } };
isBehindProxy: () => boolean; isBehindProxy: () => boolean;
openArtCreator: (opts: { username: string; password: string }) => void;
enterKeyboardMode: () => void; enterKeyboardMode: () => void;
enterMouseMode: () => void; enterMouseMode: () => void;

View file

@ -15,6 +15,7 @@ import * as log from '../../logging/log';
import * as Errors from '../../types/errors'; import * as Errors from '../../types/errors';
import { strictAssert } from '../../util/assert'; import { strictAssert } from '../../util/assert';
import { drop } from '../../util/drop';
// It is important to call this as early as possible // It is important to call this as early as possible
window.i18n = SignalContext.i18n; window.i18n = SignalContext.i18n;
@ -297,6 +298,24 @@ ipc.on('show-group-via-link', (_event, info) => {
} }
}); });
ipc.on('open-art-creator', () => {
drop(window.Events.openArtCreator());
});
window.openArtCreator = ({
username,
password,
}: {
username: string;
password: string;
}) => {
return ipc.invoke('open-art-creator', { username, password });
};
ipc.on('authorize-art-creator', (_event, info) => {
const { token, pubKeyBase64 } = info;
window.Events.authorizeArtCreator?.({ token, pubKeyBase64 });
});
ipc.on('show-conversation-via-signal.me', (_event, info) => { ipc.on('show-conversation-via-signal.me', (_event, info) => {
const { hash } = info; const { hash } = info;
strictAssert(typeof hash === 'string', 'Got an invalid hash over IPC'); strictAssert(typeof hash === 'string', 'Got an invalid hash over IPC');

View file

@ -26,6 +26,7 @@ window.WebAPI = window.textsecure.WebAPI.initialize({
storageUrl: config.storageUrl, storageUrl: config.storageUrl,
updatesUrl: config.updatesUrl, updatesUrl: config.updatesUrl,
resourcesUrl: config.resourcesUrl, resourcesUrl: config.resourcesUrl,
artCreatorUrl: config.artCreatorUrl,
directoryConfig: config.directoryConfig, directoryConfig: config.directoryConfig,
cdnUrlObject: { cdnUrlObject: {
0: config.cdnUrl0, 0: config.cdnUrl0,