signal-desktop/ts/services/releaseNotesFetcher.ts
automated-signal e624759003
Initial logic for release notes
Co-authored-by: ayumi-signal <143036029+ayumi-signal@users.noreply.github.com>
2024-11-27 17:28:51 -08:00

330 lines
9.7 KiB
TypeScript

// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import semver from 'semver';
import { last } from 'lodash';
import * as durations from '../util/durations';
import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary';
import * as Registration from '../util/registration';
import * as log from '../logging/log';
import * as Errors from '../types/errors';
import { HTTPError } from '../textsecure/Errors';
import { drop } from '../util/drop';
import { strictAssert } from '../util/assert';
import type { MessageAttributesType } from '../model-types';
import { ReadStatus } from '../messages/MessageReadStatus';
import { incrementMessageCounter } from '../util/incrementMessageCounter';
import { SeenStatus } from '../MessageSeenStatus';
import { saveNewMessageBatcher } from '../util/messageBatcher';
import { generateMessageId } from '../util/generateMessageId';
import { BodyRange } from '../types/BodyRange';
import * as RemoteConfig from '../RemoteConfig';
import { isBeta, isProduction } from '../util/version';
import type {
ReleaseNotesManifestResponseType,
ReleaseNoteResponseType,
} from '../textsecure/WebAPI';
import type { WithRequiredProperties } from '../types/Util';
const FETCH_INTERVAL = 3 * durations.DAY;
const ERROR_RETRY_DELAY = 3 * durations.HOUR;
const NEXT_FETCH_TIME_STORAGE_KEY = 'releaseNotesNextFetchTime';
const PREVIOUS_MANIFEST_HASH_STORAGE_KEY = 'releaseNotesPreviousManifestHash';
const VERSION_WATERMARK_STORAGE_KEY = 'releaseNotesVersionWatermark';
type MinimalEventsType = {
on(event: 'timetravel', callback: () => void): void;
};
type ManifestReleaseNoteType = WithRequiredProperties<
ReleaseNotesManifestResponseType['announcements'][0],
'desktopMinVersion'
>;
export type ReleaseNoteType = ReleaseNoteResponseType &
Pick<ReleaseNotesManifestResponseType['announcements'][0], 'ctaId' | 'link'>;
let initComplete = false;
export class ReleaseNotesFetcher {
private timeout: NodeJS.Timeout | undefined;
private isRunning = false;
protected async scheduleUpdateForNow(): Promise<void> {
const now = Date.now();
await window.textsecure.storage.put(NEXT_FETCH_TIME_STORAGE_KEY, now);
}
protected setTimeoutForNextRun(): void {
const now = Date.now();
const time = window.textsecure.storage.get(
NEXT_FETCH_TIME_STORAGE_KEY,
now
);
log.info(
'ReleaseNotesFetcher: Next update scheduled for',
new Date(time).toISOString()
);
let waitTime = time - now;
if (waitTime < 0) {
waitTime = 0;
}
clearTimeoutIfNecessary(this.timeout);
this.timeout = setTimeout(() => this.runWhenOnline(), waitTime);
}
private getOrInitializeVersionWatermark(): string {
const versionWatermark = window.textsecure.storage.get(
VERSION_WATERMARK_STORAGE_KEY
);
if (versionWatermark) {
return versionWatermark;
}
log.info(
'ReleaseNotesFetcher: Initializing version high watermark to current version'
);
const currentVersion = window.getVersion();
drop(
window.textsecure.storage.put(
VERSION_WATERMARK_STORAGE_KEY,
currentVersion
)
);
return currentVersion;
}
private async getReleaseNote(
note: ManifestReleaseNoteType
): Promise<ReleaseNoteType | undefined> {
if (!window.textsecure.server) {
return undefined;
}
const { uuid, ctaId, link } = note;
const result = await window.textsecure.server.getReleaseNote({
uuid,
});
strictAssert(
result.uuid === uuid,
'UUID of localized release note should match requested UUID'
);
return {
...result,
uuid,
ctaId,
link,
};
}
private async processReleaseNotes(
notes: ReadonlyArray<ManifestReleaseNoteType>
): Promise<void> {
const sortedNotes = [...notes].sort(
(a: ManifestReleaseNoteType, b: ManifestReleaseNoteType) =>
semver.compare(a.desktopMinVersion, b.desktopMinVersion)
);
const hydratedNotes = [];
for (const note of sortedNotes) {
// eslint-disable-next-line no-await-in-loop
hydratedNotes.push(await this.getReleaseNote(note));
}
if (!hydratedNotes.length) {
log.warn('ReleaseNotesFetcher: No hydrated notes available, stopping');
return;
}
log.info('ReleaseNotesFetcher: Ensuring Signal conversation');
const signalConversation =
await window.ConversationController.getOrCreateSignalConversation();
const messages: Array<MessageAttributesType> = [];
hydratedNotes.forEach(async (note, index) => {
if (!note) {
return;
}
const { title, body } = note;
const messageBody = `${title}\n\n${body}`;
const bodyRanges = [
{ start: 0, length: title.length, style: BodyRange.Style.BOLD },
];
const timestamp = Date.now() + index;
const message: MessageAttributesType = {
...generateMessageId(incrementMessageCounter()),
body: messageBody,
bodyRanges,
conversationId: signalConversation.id,
readStatus: ReadStatus.Unread,
seenStatus: SeenStatus.Unseen,
received_at_ms: timestamp,
sent_at: timestamp,
serverTimestamp: timestamp,
sourceDevice: 1,
sourceServiceId: signalConversation.getServiceId(),
timestamp,
type: 'incoming',
};
window.MessageCache.toMessageAttributes(message);
signalConversation.trigger('newmessage', message);
messages.push(message);
});
await Promise.all(
messages.map(message => saveNewMessageBatcher.add(message))
);
signalConversation.set({ active_at: Date.now(), isArchived: false });
drop(signalConversation.updateUnread());
const newestNote = last(sortedNotes);
strictAssert(newestNote, 'processReleaseNotes requires at least 1 note');
const versionWatermark = newestNote.desktopMinVersion;
log.info(
`ReleaseNotesFetcher: Updating version watermark to ${versionWatermark}`
);
drop(
window.textsecure.storage.put(
VERSION_WATERMARK_STORAGE_KEY,
versionWatermark
)
);
}
private async scheduleForNextRun(): Promise<void> {
const now = Date.now();
const nextTime = now + FETCH_INTERVAL;
await window.textsecure.storage.put(NEXT_FETCH_TIME_STORAGE_KEY, nextTime);
}
private async run(): Promise<void> {
if (this.isRunning) {
log.warn('ReleaseNotesFetcher: Already running, preventing reentrancy');
return;
}
this.isRunning = true;
log.info('ReleaseNotesFetcher: Starting');
try {
const versionWatermark = this.getOrInitializeVersionWatermark();
log.info(`ReleaseNotesFetcher: Version watermark is ${versionWatermark}`);
if (!window.textsecure.server) {
log.info('ReleaseNotesFetcher: WebAPI unavailable');
throw new Error('WebAPI unavailable');
}
const hash = await window.textsecure.server.getReleaseNotesManifestHash();
if (!hash) {
throw new Error('Release notes manifest hash missing');
}
const previousHash = window.textsecure.storage.get(
PREVIOUS_MANIFEST_HASH_STORAGE_KEY
);
if (hash !== previousHash) {
log.info('ReleaseNotesFetcher: Manifest hash changed, fetching');
const manifest =
await window.textsecure.server.getReleaseNotesManifest();
const validNotes = manifest.announcements.filter(
(note): note is ManifestReleaseNoteType =>
note.desktopMinVersion != null &&
semver.gt(note.desktopMinVersion, versionWatermark)
);
if (validNotes.length) {
log.info(
`ReleaseNotesFetcher: Processing ${validNotes.length} new release notes`
);
drop(this.processReleaseNotes(validNotes));
} else {
log.info('ReleaseNotesFetcher: No new release notes');
}
drop(
window.textsecure.storage.put(
PREVIOUS_MANIFEST_HASH_STORAGE_KEY,
hash
)
);
} else {
log.info('ReleaseNotesFetcher: Manifest hash unchanged');
}
await this.scheduleForNextRun();
this.setTimeoutForNextRun();
} catch (error) {
const errorString =
error instanceof HTTPError
? error.code.toString()
: Errors.toLogFormat(error);
log.error(
`ReleaseNotesFetcher: Error, trying again later. ${errorString}`
);
setTimeout(() => this.setTimeoutForNextRun(), ERROR_RETRY_DELAY);
} finally {
this.isRunning = false;
}
}
private runWhenOnline() {
if (window.textsecure.server?.isOnline()) {
drop(this.run());
} else {
log.info(
'ReleaseNotesFetcher: We are offline; will fetch when we are next online'
);
const listener = () => {
window.Whisper.events.off('online', listener);
this.setTimeoutForNextRun();
};
window.Whisper.events.on('online', listener);
}
}
public static async init(
events: MinimalEventsType,
isNewVersion: boolean
): Promise<void> {
if (initComplete || !this.isEnabled()) {
return;
}
initComplete = true;
const listener = new ReleaseNotesFetcher();
if (isNewVersion) {
await listener.scheduleUpdateForNow();
}
listener.setTimeoutForNextRun();
events.on('timetravel', () => {
if (Registration.isDone()) {
listener.setTimeoutForNextRun();
}
});
}
public static isEnabled(): boolean {
const version = window.getVersion();
if (isProduction(version)) {
return RemoteConfig.isEnabled('desktop.releaseNotes');
}
if (isBeta(version)) {
return RemoteConfig.isEnabled('desktop.releaseNotes.beta');
}
return RemoteConfig.isEnabled('desktop.releaseNotes.dev');
}
}