Migrate scripts to ESM and ts-check

This commit is contained in:
Jamie
2026-04-02 13:20:15 -07:00
committed by GitHub
parent 5e683b0c1a
commit f3595e0784
117 changed files with 3450 additions and 3376 deletions
+8 -5
View File
@@ -1,20 +1,23 @@
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
module.exports = {
/** @type {import("@babel/core").TransformOptions} */
const config = {
presets: ['@babel/preset-react', '@babel/preset-typescript'],
// Detects the type of file being babel'd (either esmodule or commonjs)
sourceType: 'unambiguous',
plugins: [
'lodash',
'@babel/plugin-transform-typescript',
'@babel/plugin-proposal-class-properties',
'@babel/plugin-proposal-optional-chaining',
'@babel/plugin-proposal-nullish-coalescing-operator',
// This plugin converts commonjs to esmodules which is required for
// importing commonjs modules from esmodules in storybook. As a part of
// converting to TypeScript we should use esmodules and can eventually
// remove this plugin
process.env.SIGNAL_ENV === 'storybook' && '@babel/transform-runtime',
].filter(Boolean),
].filter(plugin => {
return typeof plugin === 'string';
}),
};
export default config;
+6 -6
View File
@@ -134,9 +134,9 @@ jobs:
- name: Upload installer size
if: ${{ github.repository == 'signalapp/Signal-Desktop-Private' && github.ref == 'refs/heads/main' }}
run: |
./node_modules/.bin/tsx ts/scripts/publish-installer-size.node.ts macos-arm64
./node_modules/.bin/tsx ts/scripts/publish-installer-size.node.ts macos-x64
./node_modules/.bin/tsx ts/scripts/publish-installer-size.node.ts macos-universal
node scripts/publish-installer-size.mjs macos-arm64
node scripts/publish-installer-size.mjs macos-x64
node scripts/publish-installer-size.mjs macos-universal
- run: pnpm run test-release
env:
NODE_ENV: production
@@ -216,7 +216,7 @@ jobs:
- name: Upload installer size
if: ${{ github.repository == 'signalapp/Signal-Desktop-Private' && github.ref == 'refs/heads/main' }}
run: ./node_modules/.bin/tsx ts/scripts/publish-installer-size.node.ts linux
run: node scripts/publish-installer-size.mjs linux
- run: xvfb-run --auto-servernum pnpm run test-node
@@ -294,7 +294,7 @@ jobs:
- name: Upload installer size
if: ${{ github.repository == 'signalapp/Signal-Desktop-Private' && github.ref == 'refs/heads/main' }}
run: ./node_modules/.bin/tsx ts/scripts/publish-installer-size.node.ts windows
run: node scripts/publish-installer-size.mjs windows
- run: pnpm run test-electron
env:
@@ -483,4 +483,4 @@ jobs:
- run: pnpm generate:phase-0
- name: Run OS version check
run: |
./node_modules/.bin/tsx ts/scripts/check-min-os-version.node.ts
node scripts/check-min-os-version.mjs
+1 -1
View File
@@ -51,7 +51,7 @@ jobs:
env:
ARTIFACTS_DIR: stories
- run: pnpm run build:rolldown
- run: ./node_modules/.bin/tsx ts/scripts/compile-stories-icu-lookup.node.ts stories
- run: node scripts/compile-stories-icu-lookup.mjs stories
- name: Upload test artifacts
if: github.event_name == 'workflow_dispatch'
+3 -5
View File
@@ -150,7 +150,6 @@ const NODE_PACKAGES = new Set([
'tailwindcss',
'terser-webpack-plugin',
'tsx',
'ts-node',
'typescript',
'wait-on',
'webpack',
@@ -204,9 +203,6 @@ const DOM_PACKAGES = new Set([
// Packages that can run in both browser/node
const STD_PACKAGES = new Set([
'@babel/core',
'@babel/plugin-proposal-class-properties',
'@babel/plugin-proposal-nullish-coalescing-operator',
'@babel/plugin-proposal-optional-chaining',
'@babel/plugin-transform-runtime',
'@babel/plugin-transform-typescript',
'@babel/preset-react',
@@ -536,7 +532,9 @@ export const enforceFileSuffix = ESLintUtils.RuleCreator.withoutDocs({
return;
}
const match = filename.match(/\.([^.\/]+)(?:\.stories)?\.(?:ts|tsx)$/);
const match = filename.match(
/\.([^.\/]+)(?:\.stories)?\.(?:ts|tsx|js|mjs)$/
);
if (match == null) {
context.report({
node: node,
+2 -1
View File
@@ -1,6 +1,7 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as mocha from 'mocha';
// @ts-check
import * as mocha from 'mocha'
import { RuleTester } from '@typescript-eslint/rule-tester';
RuleTester.afterAll = mocha.after;
+22 -14
View File
@@ -62,6 +62,16 @@
"target": ["ts/util/**", "ts/types/**"],
"from": ["ts/components/**", "ts/axo/**/*.dom.*"],
"message": "Importing components is forbidden from ts/{util,types}"
},
{
"target": ["scripts/**", "codemods/**"],
"from": ["ts/**"],
"message": "Scripts should not import from ts/**"
},
{
"target": ["ts/**"],
"from": ["scripts/**", "codemods/**"],
"message": "App should not import from scripts"
}
]
}
@@ -1599,8 +1609,6 @@
"sticker-creator/**",
// Third-party files
"components/mp3lameencoder/lib/Mp3LameEncoder.js",
"components/recorderjs/recorder.js",
"components/recorderjs/recorderWorker.js",
"components/webaudiorecorder/lib/WebAudioRecorderMp3.js",
"js/WebAudioRecorderMp3.js"
],
@@ -1626,7 +1634,7 @@
// misc
".storybook/preview.tsx",
"preload.wrapper.ts",
"test/test.js"
"test/test.mjs"
],
"env": { "browser": true }
},
@@ -1642,14 +1650,14 @@
"codemods/**",
"danger/**",
"scripts/**",
".babelrc.js",
".prettierrc.js",
"test/test.m",
".babelrc.mjs",
".prettierrc.mjs",
".storybook/test-runner.ts",
".stylelintrc.js",
"ci.js",
"dangerfile.js",
"eslint-local-rules.js",
"test/setup-test-node.js",
"preload.wrapper.ts",
"rolldown.config.ts"
],
@@ -1660,14 +1668,15 @@
"ts/test-node/**",
"ts/test-electron/**",
"ts/test-mock/**",
"test/test.js"
"test/test.mjs",
"scripts/**/*_test.mjs"
],
"env": { "mocha": true }
},
// disables
{
"files": ["ts/scripts/**", "scripts/**", "danger/**", "ts/util/lint/**"],
"files": ["scripts/**", "danger/**", "ts/util/lint/**"],
"rules": {
"no-console": "off"
}
@@ -1692,8 +1701,10 @@
"danger/**",
"packages/**",
"scripts/**",
"test/**",
".*rc.js",
"test/test.mjs",
".babelrc.mjs",
".prettierrc.mjs",
".stylelintrc.mjs",
"ci.js",
"dangerfile.mjs",
"preload.wrapper.ts",
@@ -1714,16 +1725,13 @@
".oxlint/**",
".storybook/**",
"build/intl-linter/**",
"codemods/**",
"danger/**",
"scripts/**",
"test/**",
"ts/scripts/**",
"ts/test-electron/**",
"ts/test-mock/**",
"ts/test-node/**",
"ts/util/lint/**",
"ts/windows/main/*_test.*",
"test/test.mjs",
"dangerfile.js",
"preload.wrapper.ts",
"rolldown.config.ts"
-2
View File
@@ -10,9 +10,7 @@ dist/**
js/components.js
js/util_worker.js
libtextsecure/components.js
libtextsecure/test/test.js
stylesheets/*.css
test/test.js
ts/**/*.js
ts/protobuf/*.d.ts
ts/protobuf/*.js
+4 -1
View File
@@ -1,8 +1,9 @@
// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
/** @type {import("prettier").Config} */
module.exports = {
const config = {
plugins: ['prettier-plugin-tailwindcss'],
singleQuote: true,
arrowParens: 'avoid',
@@ -11,3 +12,5 @@ module.exports = {
tailwindFunctions: ['tw'],
tailwindAttributes: [],
};
export default config
+5 -1
View File
@@ -1,7 +1,9 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
module.exports = {
/** @type {import('stylelint').Config} */
const config = {
extends: [
'stylelint-config-recommended-scss',
'stylelint-config-css-modules',
@@ -45,3 +47,5 @@ module.exports = {
},
},
};
export default config
+49 -51
View File
@@ -30,57 +30,7 @@ Signal Desktop makes use of the following open source projects.
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
## @babel/plugin-proposal-class-properties
MIT License
Copyright (c) 2014-present Sebastian McKenzie and other contributors
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.
## @babel/plugin-proposal-nullish-coalescing-operator
MIT License
Copyright (c) 2014-present Sebastian McKenzie and other contributors
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.
## @babel/plugin-proposal-optional-chaining
## @babel/helper-plugin-utils
MIT License
@@ -4464,6 +4414,54 @@ Signal Desktop makes use of the following open source projects.
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
## @types/babel\_\_core
MIT License
Copyright (c) Microsoft 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
## @types/babel\_\_helper-plugin-utils
MIT License
Copyright (c) Microsoft 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
## @types/blueimp-load-image
MIT License
-12
View File
@@ -1,12 +0,0 @@
// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// For reference: https://github.com/airbnb/javascript
const rules = {
'no-console': 'off',
};
module.exports = {
rules,
};
@@ -1,6 +1,9 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export default function transform() {
// @ts-check
import { declare } from '@babel/helper-plugin-utils';
export default declare(function transform() {
return {
visitor: {
CallExpression(path) {
@@ -32,4 +35,4 @@ export default function transform() {
},
},
};
}
});
@@ -1,6 +1,9 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export default function transform(babel) {
// @ts-check
import { declare } from '@babel/helper-plugin-utils';
export default declare(function transform(babel) {
const { types: t } = babel;
return {
@@ -20,15 +23,15 @@ export default function transform(babel) {
}
path.replaceWith(
t.TSQualifiedName(
t.TSQualifiedName(
t.tsQualifiedName(
t.tsQualifiedName(
node.left,
t.Identifier(node.right.name.slice(1))
t.identifier(node.right.name.slice(1))
),
t.Identifier('Params')
t.identifier('Params')
)
);
},
},
};
}
});
@@ -1,18 +1,35 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import { declare } from '@babel/helper-plugin-utils';
import { relative, dirname, join } from 'node:path';
const importPath = join(__dirname, '..', 'ts', 'util', 'toNumber.std.js');
/** @import { NodePath, types as t, PluginPass } from '@babel/core' */
export default function transform(babel) {
const importPath = join(
import.meta.dirname,
'..',
'ts',
'util',
'toNumber.std.js'
);
export default declare(function transform(babel) {
const { types: t } = babel;
/** @type {t.Program | undefined} */
let program;
/**
* @param {NodePath<t.Program>} path
*/
function Program({ node }) {
program = node;
}
/**
* @param {string} filename
*/
function addImport(filename) {
if (program === undefined) {
return;
@@ -33,14 +50,18 @@ export default function transform(babel) {
program.body.splice(
index,
0,
t.ImportDeclaration(
[t.ImportSpecifier(t.Identifier('toNumber'), t.Identifier('toNumber'))],
t.StringLiteral(relativePath)
t.importDeclaration(
[t.importSpecifier(t.identifier('toNumber'), t.identifier('toNumber'))],
t.stringLiteral(relativePath)
)
);
program = undefined;
}
/**
* @param {NodePath<t.CallExpression | t.OptionalCallExpression>} path
* @param {PluginPass} plugin
*/
function CallExpression(path, { filename }) {
const { callee, arguments: args } = path.node;
if (args.length !== 0) {
@@ -59,10 +80,13 @@ export default function transform(babel) {
return;
}
const replacement = t.CallExpression(t.Identifier('toNumber'), [
const replacement = t.callExpression(t.identifier('toNumber'), [
callee.object,
]);
path.replaceWith(replacement);
if (filename == null) {
throw path.buildCodeFrameError('Missing filename');
}
addImport(filename);
}
@@ -73,4 +97,4 @@ export default function transform(babel) {
OptionalCallExpression: CallExpression,
},
};
}
});
@@ -1,6 +1,9 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export default function transform(babel) {
// @ts-check
import { declare } from '@babel/helper-plugin-utils';
export default declare(function transform(babel) {
const { types: t } = babel;
return {
@@ -22,11 +25,17 @@ export default function transform(babel) {
}
if (property.name === 'isLong') {
const [arg] = node.arguments;
if (!t.isExpression(arg)) {
throw path.buildCodeFrameError(
'First argument to isLong must be an expression'
);
}
path.replaceWith(
t.BinaryExpression(
t.binaryExpression(
'===',
t.UnaryExpression('typeof', node.arguments[0]),
t.StringLiteral('bigint')
t.unaryExpression('typeof', arg),
t.stringLiteral('bigint')
)
);
return;
@@ -40,14 +49,19 @@ export default function transform(babel) {
return;
}
if (node.arguments[0].type === 'NumericLiteral') {
path.replaceWith(t.BigIntLiteral(node.arguments[0].value.toString()));
const [arg] = node.arguments;
if (arg == null) {
throw path.buildCodeFrameError(
`Missing first argument to ${property.name}`
);
}
if (arg.type === 'NumericLiteral') {
path.replaceWith(t.bigIntLiteral(arg.value.toString()));
return;
}
path.replaceWith(
t.CallExpression(t.Identifier('BigInt'), [node.arguments[0]])
);
path.replaceWith(t.callExpression(t.identifier('BigInt'), [arg]));
},
},
};
}
});
@@ -1,10 +1,23 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import { declare } from '@babel/helper-plugin-utils';
import { existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
export default function transform() {
function ImportOrExport({ node }, { filename }) {
/** @import { NodePath, types as t, PluginPass } from '@babel/core' */
export default declare(function transform() {
/**
* @param {NodePath<t.ImportDeclaration | t.ExportAllDeclaration | t.ExportNamedDeclaration>} path
* @param {PluginPass} plugin
*/
function ImportOrExport(path, { filename }) {
const { node } = path;
if (filename == null) {
throw path.buildCodeFrameError('Missing filename');
}
if (
!node.source ||
!node.source.value.startsWith('.') ||
@@ -23,13 +36,10 @@ export default function transform() {
const dir = dirname(filename);
if (existsSync(join(dir, ts))) {
// oxlint-disable-next-line no-param-reassign
node.source.value = ts;
} else if (existsSync(join(dir, tsx))) {
// oxlint-disable-next-line no-param-reassign
node.source.value = tsx;
} else if (existsSync(join(dir, dts))) {
// oxlint-disable-next-line no-param-reassign
node.source.value = dts;
} else {
throw new Error(`File not found: ${join(dir, node.source.value)}`);
@@ -43,4 +53,4 @@ export default function transform() {
ExportNamedDeclaration: ImportOrExport,
},
};
}
});
-97
View File
@@ -1,97 +0,0 @@
(function(window){
var WORKER_PATH = 'recorderWorker.js';
var Recorder = function(source, cfg){
var config = cfg || {};
var bufferLen = config.bufferLen || 4096;
this.context = source.context;
this.node = (this.context.createScriptProcessor ||
this.context.createJavaScriptNode).call(this.context,
bufferLen, 2, 2);
var worker = new Worker(config.workerPath || WORKER_PATH);
worker.postMessage({
command: 'init',
config: {
sampleRate: this.context.sampleRate
}
});
var recording = false,
currCallback;
var self = this;
this.node.onaudioprocess = function(e){
if (!recording) return;
self.ondata && self.ondata(e.inputBuffer.getChannelData(0));
worker.postMessage({
command: 'record',
buffer: [
e.inputBuffer.getChannelData(0),
e.inputBuffer.getChannelData(1)
]
});
}
this.configure = function(cfg){
for (var prop in cfg){
if (cfg.hasOwnProperty(prop)){
config[prop] = cfg[prop];
}
}
}
this.record = function(){
recording = true;
}
this.stop = function(){
recording = false;
}
this.clear = function(){
worker.postMessage({ command: 'clear' });
}
this.getBuffer = function(cb) {
currCallback = cb || config.callback;
worker.postMessage({ command: 'getBuffer' })
}
this.exportWAV = function(cb, type){
currCallback = cb || config.callback;
type = type || config.type || 'audio/wav';
if (!currCallback) throw new Error('Callback not set');
worker.postMessage({
command: 'exportWAV',
type: type
});
}
this.shutdown = function(){
worker.terminate();
source.disconnect();
this.node.disconnect();
};
worker.onmessage = function(e){
var blob = e.data;
currCallback(blob);
}
source.connect(this.node);
this.node.connect(this.context.destination); //this should not be necessary
};
Recorder.forceDownload = function(blob, filename){
var url = (window.URL || window.webkitURL).createObjectURL(blob);
var link = window.document.createElement('a');
link.href = url;
link.download = filename || 'output.wav';
var click = document.createEvent("Event");
click.initEvent("click", true, true);
link.dispatchEvent(click);
}
window.Recorder = Recorder;
})(window);
-131
View File
@@ -1,131 +0,0 @@
var recLength = 0,
recBuffersL = [],
recBuffersR = [],
sampleRate;
this.onmessage = function(e){
switch(e.data.command){
case 'init':
init(e.data.config);
break;
case 'record':
record(e.data.buffer);
break;
case 'exportWAV':
exportWAV(e.data.type);
break;
case 'getBuffer':
getBuffer();
break;
case 'clear':
clear();
break;
}
};
function init(config){
sampleRate = config.sampleRate;
}
function record(inputBuffer){
recBuffersL.push(inputBuffer[0]);
recBuffersR.push(inputBuffer[1]);
recLength += inputBuffer[0].length;
}
function exportWAV(type){
var bufferL = mergeBuffers(recBuffersL, recLength);
var bufferR = mergeBuffers(recBuffersR, recLength);
var interleaved = interleave(bufferL, bufferR);
var dataview = encodeWAV(interleaved);
var audioBlob = new Blob([dataview], { type: type });
this.postMessage(audioBlob);
}
function getBuffer() {
var buffers = [];
buffers.push( mergeBuffers(recBuffersL, recLength) );
buffers.push( mergeBuffers(recBuffersR, recLength) );
this.postMessage(buffers);
}
function clear(){
recLength = 0;
recBuffersL = [];
recBuffersR = [];
}
function mergeBuffers(recBuffers, recLength){
var result = new Float32Array(recLength);
var offset = 0;
for (var i = 0; i < recBuffers.length; i++){
result.set(recBuffers[i], offset);
offset += recBuffers[i].length;
}
return result;
}
function interleave(inputL, inputR){
var length = inputL.length + inputR.length;
var result = new Float32Array(length);
var index = 0,
inputIndex = 0;
while (index < length){
result[index++] = inputL[inputIndex];
result[index++] = inputR[inputIndex];
inputIndex++;
}
return result;
}
function floatTo16BitPCM(output, offset, input){
for (var i = 0; i < input.length; i++, offset+=2){
var s = Math.max(-1, Math.min(1, input[i]));
output.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
}
}
function writeString(view, offset, string){
for (var i = 0; i < string.length; i++){
view.setUint8(offset + i, string.charCodeAt(i));
}
}
function encodeWAV(samples){
var buffer = new ArrayBuffer(44 + samples.length * 2);
var view = new DataView(buffer);
/* RIFF identifier */
writeString(view, 0, 'RIFF');
/* RIFF chunk length */
view.setUint32(4, 36 + samples.length * 2, true);
/* RIFF type */
writeString(view, 8, 'WAVE');
/* format chunk identifier */
writeString(view, 12, 'fmt ');
/* format chunk length */
view.setUint32(16, 16, true);
/* sample format (raw) */
view.setUint16(20, 1, true);
/* channel count */
view.setUint16(22, 2, true);
/* sample rate */
view.setUint32(24, sampleRate, true);
/* byte rate (sample rate * block align) */
view.setUint32(28, sampleRate * 4, true);
/* block align (channel count * bytes per sample) */
view.setUint16(32, 4, true);
/* bits per sample */
view.setUint16(34, 16, true);
/* data chunk identifier */
writeString(view, 36, 'data');
/* data chunk length */
view.setUint32(40, samples.length * 2, true);
floatTo16BitPCM(view, 44, samples);
return view;
}
+44 -44
View File
@@ -23,31 +23,31 @@
"generate:phase-1": "run-p --aggregate-output --print-label build:icu-types build:compact-locales build:styles:prod get-expire-time build:webaudio-recorder build:policy-files",
"build-release": "pnpm run build",
"notarize": "echo 'No longer necessary'",
"get-strings": "tsx ts/scripts/get-strings.node.ts && tsx ts/scripts/gen-nsis-script.node.ts && tsx ts/scripts/gen-locales-config.node.ts && run-p get-strings:locales get-strings:countries get-strings:emoji mark-unusued-strings-deleted && run-p build:compact-locales",
"get-strings:locales": "tsx ./ts/scripts/build-localized-display-names.node.ts locales ts/scripts/locale-data/locale-display-names.csv build/locale-display-names.json",
"get-strings:countries": "tsx ./ts/scripts/build-localized-display-names.node.ts countries ts/scripts/locale-data/country-display-names.csv build/country-display-names.json",
"get-strings:emoji": "tsx ./ts/scripts/get-emoji-locales.node.ts",
"push-strings": "tsx ts/scripts/remove-strings.node.ts && tsx ts/scripts/push-strings.node.ts",
"mark-unusued-strings-deleted": "tsx ./ts/scripts/mark-unused-strings-deleted.node.ts",
"get-expire-time": "tsx ts/scripts/get-expire-time.node.ts",
"get-strings": "node scripts/get-strings.mjs && node scripts/gen-nsis-script.mjs && node scripts/gen-locales-config.mjs && run-p get-strings:locales get-strings:countries get-strings:emoji mark-unusued-strings-deleted && run-p build:compact-locales",
"get-strings:locales": "node scripts/build-localized-display-names.mjs locales scripts/locale-data/locale-display-names.csv build/locale-display-names.json",
"get-strings:countries": "node scripts/build-localized-display-names.mjs countries scripts/locale-data/country-display-names.csv build/country-display-names.json",
"get-strings:emoji": "node scripts/get-emoji-locales.mjs",
"push-strings": "node scripts/remove-strings.mjs && node scripts/push-strings.mjs",
"mark-unusued-strings-deleted": "node scripts/mark-unused-strings-deleted.mjs",
"get-expire-time": "node scripts/get-expire-time.mjs",
"build:webaudio-recorder": "terser --compress ecma=2025 --toplevel components/mp3lameencoder/lib/Mp3LameEncoder.js components/webaudiorecorder/lib/WebAudioRecorderMp3.js -o js/WebAudioRecorderMp3.js",
"build:protobuf": "protopiler --module cjs --output ts/protobuf/compiled.std.js --typedefs ts/protobuf/compiled.std.d.ts protos",
"clean:protobuf": "rm -f ts/protobuf/compiled.std.d.ts ts/protobuf/compiled.std.js",
"prepare-beta-build": "tsx scripts/prepare_beta_build.js",
"prepare-alpha-build": "tsx scripts/prepare_alpha_build.js",
"prepare-alpha-version": "tsx scripts/prepare_tagged_version.js alpha",
"prepare-axolotl-build": "tsx scripts/prepare_axolotl_build.js",
"prepare-axolotl-version": "tsx scripts/prepare_tagged_version.js axolotl",
"prepare-adhoc-build": "tsx scripts/prepare_adhoc_build.js",
"prepare-adhoc-version": "tsx scripts/prepare_tagged_version.js adhoc",
"prepare-staging-build": "tsx scripts/prepare_staging_build.js",
"prepare-no-delay-release": "tsx ts/scripts/prepare-no-delay-release.node.ts",
"prepare-linux-build": "tsx scripts/prepare_linux_build.js",
"prepare-beta-build": "node scripts/prepare_beta_build.mjs",
"prepare-alpha-build": "node scripts/prepare_alpha_build.mjs",
"prepare-alpha-version": "node scripts/prepare_tagged_version.mjs alpha",
"prepare-axolotl-build": "node scripts/prepare_axolotl_build.mjs",
"prepare-axolotl-version": "node scripts/prepare_tagged_version.mjs axolotl",
"prepare-adhoc-build": "node scripts/prepare_adhoc_build.mjs",
"prepare-adhoc-version": "node scripts/prepare_tagged_version.mjs adhoc",
"prepare-staging-build": "node scripts/prepare_staging_build.mjs",
"prepare-no-delay-release": "node scripts/prepare-no-delay-release.mjs",
"prepare-linux-build": "node scripts/prepare_linux_build.mjs",
"test": "run-s test-node test-electron test-lint-intl test-oxlint",
"test-electron": "tsx ts/scripts/test-electron.node.ts",
"test-release": "tsx ts/scripts/test-release.node.ts",
"test-node": "cross-env NODE_ENV=test NODE_OPTIONS='--import=tsx' LANG=en-us electron-mocha --timeout 10000 --file ts/test-node/setup.preload.ts --recursive ts/test-node/**/*.ts ts/test-node/**/*.tsx",
"test-mock": "tsx ts/scripts/mocha-separator.node.ts --require ts/test-mock/setup-ci.node.ts -- ts/test-mock/**/*_test.node.ts",
"test-electron": "node scripts/test-electron.mjs",
"test-release": "node scripts/test-release.mjs",
"test-node": "cross-env NODE_ENV=test NODE_OPTIONS='--import=tsx' LANG=en-us electron-mocha --timeout 10000 --file ts/test-node/setup.preload.ts --recursive ts/test-node/**/*.{ts,tsx} scripts/**/*_test.mjs",
"test-mock": "node scripts/mocha-separator.mjs --require ts/test-mock/setup-ci.node.ts -- ts/test-mock/**/*_test.node.ts",
"test-mock-docker": "mocha --require ts/test-mock/setup-ci.node.ts ts/test-mock/**/*_test.docker.node.ts",
"test-oxlint": "mocha --require .oxlint/test-setup.mjs .oxlint/rules/**/*.test.mjs",
"test-lint-intl": "tsx ./build/intl-linter/linter.node.ts --test",
@@ -64,7 +64,7 @@
"svgo": "svgo --multipass images/**/*.svg images/*.svg",
"transpile": "run-p check:types build:rolldown",
"check:types": "tsgo --noEmit",
"clean-transpile": "node ./scripts/clean-transpile.js",
"clean-transpile": "node scripts/clean-transpile.mjs",
"ready": "npm-run-all --print-label clean-transpile generate --parallel lint lint-deps lint-intl test-node test-electron",
"dev": "pnpm run build:protobuf && cross-env SIGNAL_ENV=storybook storybook dev --port 6006",
"dev:transpile": "run-p \"check:types --watch\" dev:rolldown dev:icu-types dev:protobuf",
@@ -81,16 +81,16 @@
"build": "run-s --print-label generate build:rolldown:prod build:release",
"build-win32-all": "run-s --print-label generate build:rolldown:prod build:release-win32-all",
"build-linux": "run-s build:policy-files generate build:rolldown:prod && pnpm run build:release --publish=never",
"build:acknowledgments": "node scripts/generate-acknowledgments.js",
"build:dns-fallback": "tsx ts/scripts/generate-dns-fallback.node.ts",
"build:icu-types": "tsx ts/scripts/generate-icu-types.node.ts",
"build:compact-locales": "tsx ts/scripts/generate-compact-locales.node.ts",
"build:tray-icons": "tsx ts/scripts/generate-tray-icons.node.ts",
"build:acknowledgments": "node scripts/generate-acknowledgments.mjs",
"build:dns-fallback": "node scripts/generate-dns-fallback.mjs",
"build:icu-types": "node scripts/generate-icu-types.mjs",
"build:compact-locales": "node scripts/generate-compact-locales.mjs",
"build:tray-icons": "node scripts/generate-tray-icons.mjs",
"build:dev": "run-s --print-label generate build:rolldown:prod",
"build:rolldown": "rolldown -c",
"build:rolldown:prod": "pnpm build:rolldown --minify",
"build:esbuild:prod": "pnpm build:rolldown:prod",
"build:policy-files": "tsx ts/scripts/gen-policy-files.node.ts",
"build:policy-files": "node scripts/gen-policy-files.mjs",
"build:styles": "pnpm run \"/^build:styles:(sass|tailwind)$/\"",
"build:styles:prod": "pnpm run \"/^build:styles:(sass|tailwind):prod$/\"",
"build:styles:sass": "sass stylesheets/manifest.scss:stylesheets/manifest.css stylesheets/manifest_bridge.scss:stylesheets/manifest_bridge.css node_modules/@signalapp/quill-cjs/dist/quill.core.css:stylesheets/quill.css --fatal-deprecation=1.80.7",
@@ -101,12 +101,12 @@
"build:release": "cross-env SIGNAL_ENV=production pnpm run build:electron --config.directories.output=release",
"build:mas-dev": "bash ./scripts/build-mas-dev.sh",
"build:release-win32-all": "pnpm run build:release --arm64 --x64",
"build:preload-cache": "tsx ts/scripts/generate-preload-cache.node.ts",
"build:preload-cache": "node scripts/generate-preload-cache.mjs",
"build:emoji": "run-p build:emoji:32 build:emoji:64",
"build:emoji:32": "cwebp -progress -mt -preset icon -alpha_filter best -alpha_q 20 -pass 10 -q 75 ./node_modules/emoji-datasource-apple/img/apple/sheets/32.png -o ./images/emoji-sheet-32.webp",
"build:emoji:64": "cwebp -progress -mt -preset icon -alpha_filter best -alpha_q 20 -pass 10 -q 75 ./node_modules/emoji-datasource-apple/img/apple/sheets/64.png -o ./images/emoji-sheet-64.webp",
"electron:install-app-deps": "electron-builder install-app-deps",
"check-upgradeable-deps": "tsx ts/scripts/check-upgradeable-deps.node.ts",
"check-upgradeable-deps": "node scripts/check-upgradeable-deps.mjs",
"react-devtools": "react-devtools",
"run-with-devtools": "cross-env REACT_DEVTOOLS=1 run-p --print-label react-devtools start"
},
@@ -126,14 +126,12 @@
"google-libphonenumber": "3.2.39"
},
"devDependencies": {
"@babel/core": "7.26.0",
"@babel/plugin-proposal-class-properties": "7.18.6",
"@babel/plugin-proposal-nullish-coalescing-operator": "7.18.6",
"@babel/plugin-proposal-optional-chaining": "7.21.0",
"@babel/plugin-transform-runtime": "7.25.9",
"@babel/plugin-transform-typescript": "7.25.9",
"@babel/preset-react": "7.25.9",
"@babel/preset-typescript": "7.26.0",
"@babel/core": "7.29.0",
"@babel/helper-plugin-utils": "7.28.6",
"@babel/plugin-transform-runtime": "7.29.0",
"@babel/plugin-transform-typescript": "7.28.6",
"@babel/preset-react": "7.28.5",
"@babel/preset-typescript": "7.28.5",
"@electron/asar": "3.4.1",
"@electron/fuses": "1.5.0",
"@electron/notarize": "2.1.0",
@@ -178,6 +176,8 @@
"@tailwindcss/cli": "4.2.2",
"@tailwindcss/postcss": "4.2.2",
"@tanstack/react-virtual": "3.11.2",
"@types/babel__core": "7.20.5",
"@types/babel__helper-plugin-utils": "7.10.3",
"@types/blueimp-load-image": "5.16.6",
"@types/chai": "4.3.16",
"@types/chai-as-promised": "7.1.4",
@@ -437,7 +437,7 @@
}
],
"mergeASARs": true,
"sign": "./ts/scripts/sign-macos.node.ts",
"sign": "scripts/sign-macos.mjs",
"releaseInfo": {
"vendor": {
"minOSVersion": "21.0.1"
@@ -505,7 +505,7 @@
"certificateSubjectName": "Signal Messenger, LLC",
"certificateSha1": "8D5E3CD800736C5E1FE459A1F5AA48287D4F6EC6",
"publisherName": "Signal Messenger, LLC",
"sign": "./ts/scripts/sign-windows.node.ts",
"sign": "scripts/sign-windows.mjs",
"signingHashAlgorithms": [
"sha256"
]
@@ -603,10 +603,10 @@
"signalcaptcha"
]
},
"artifactBuildCompleted": "ts/scripts/artifact-build-completed.node.ts",
"afterSign": "ts/scripts/after-sign.node.ts",
"afterPack": "ts/scripts/after-pack.node.ts",
"afterAllArtifactBuild": "ts/scripts/after-all-artifact-build.node.ts",
"artifactBuildCompleted": "scripts/artifact-build-completed.mjs",
"afterSign": "scripts/after-sign.mjs",
"afterPack": "scripts/after-pack.mjs",
"afterAllArtifactBuild": "scripts/after-all-artifact-build.mjs",
"asar": {
"smartUnpack": false
},
+519 -374
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -61,7 +61,7 @@ const fn = script.runInThisContext({
importModuleDynamically: constants.USE_MAIN_CONTEXT_DEFAULT_LOADER,
});
// See `ts/scripts/generate-preload-cache.node.ts`
// See `scripts/generate-preload-cache.mjs`
if (process.env.GENERATE_PRELOAD_CACHE) {
// Use hottest cache possible in CI
if (process.env.CI) {
-16
View File
@@ -1,16 +0,0 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
module.exports = {
rules: {
'no-console': 'off',
// We still get the value of this rule, it just allows for dev deps
'import/no-extraneous-dependencies': [
'error',
{
devDependencies: true,
},
],
},
};
+15
View File
@@ -0,0 +1,15 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import { afterAllArtifactBuild as notarizeUniversalDMG } from './notarize-universal-dmg.mjs';
/** @import { BuildResult } from 'electron-builder' */
/**
* @param {BuildResult} result
* @returns {Promise<Array<string>>}
*/
export async function afterAllArtifactBuild(result) {
await notarizeUniversalDMG(result);
return [];
}
+20
View File
@@ -0,0 +1,20 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import { afterPack as fuseElectron } from './fuse-electron.mjs';
import { afterPack as copyPacks } from './copy-language-packs.mjs';
import { afterPack as pruneMacOSRelease } from './prune-macos-release.mjs';
import { afterPack as ensureLinuxFilePermissions } from './ensure-linux-file-permissions.mjs';
/** @import { AfterPackContext } from 'electron-builder' */
/**
* @param {AfterPackContext} context
* @returns {Promise<void>}
*/
export async function afterPack(context) {
await pruneMacOSRelease(context);
await fuseElectron(context);
await copyPacks(context);
await ensureLinuxFilePermissions(context);
}
+17
View File
@@ -0,0 +1,17 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import { afterSign as notarize } from './notarize.mjs';
/** @import { AfterPackContext } from 'electron-builder' */
/**
* NOTE: It is AfterPackContext here even though it is afterSign.
* See: https://www.electron.build/configuration/configuration.html#aftersign
* @param {AfterPackContext} context
* @returns {Promise<void>}
*/
export async function afterSign(context) {
// This must be the last step
await notarize(context);
}
@@ -1,22 +1,28 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import { tmpdir } from 'node:os';
import { mkdtemp, rm, rename, stat, writeFile } from 'node:fs/promises';
import { createReadStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
import { createHash } from 'node:crypto';
import path from 'node:path';
import type { ArtifactCreated } from 'electron-builder';
import { BlockMap } from 'better-blockmap';
/// <reference path="./utils/better-blockmap.d.ts">
/** @import { ArtifactCreated } from 'electron-builder' */
/**
* @param {ArtifactCreated} context
* @returns {Promise<void>}
*/
export async function artifactBuildCompleted({
target,
file,
packager,
updateInfo,
}: ArtifactCreated): Promise<void> {
}) {
if (packager.platform.name === 'linux' && file.endsWith('.AppImage')) {
const blockMapPath = `${file}.blockmap`;
console.log(`Generating blockmap ${blockMapPath}`);
@@ -1,12 +1,11 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import { parse } from 'csv-parse';
import fs from 'node:fs/promises';
import { join } from 'node:path';
import { z } from 'zod';
import { _getAvailableLocales } from '../../app/locale.node.ts';
import { parseUnknown } from '../util/schemas.std.ts';
import availableLocales from '../build/available-locales.json' with { type: 'json' };
import { assert } from './utils/assert.mjs';
const type = process.argv[2];
if (type !== 'countries' && type !== 'locales') {
@@ -22,9 +21,6 @@ if (!process.argv[4]) {
throw new Error('Missing third argument: output json file');
}
const localeDisplayNamesBuildPath = process.argv[4];
const rootDir = join(__dirname, '..', '..');
const availableLocales = _getAvailableLocales(rootDir);
const LocaleString = z.string().refine(arg => {
try {
@@ -42,12 +38,16 @@ const LocaleDisplayNames = z
])
.rest(z.tuple([LocaleString]).rest(z.string()));
type Row = ReadonlyArray<string>;
type Records = ReadonlyArray<Row>;
/** @typedef {ReadonlyArray<string>} Row */
/** @typedef {ReadonlyArray<Row>} Records */
function parseCsv(input: string) {
return new Promise<Records>((resolve, reject) => {
parse(input, { trim: true }, (error, records: Records) => {
/**
* @param {string} input
* @returns {Promise<Records>}
*/
function parseCsv(input) {
return new Promise((resolve, reject) => {
parse(input, { trim: true }, (error, records) => {
if (error) {
reject(error);
} else {
@@ -57,13 +57,17 @@ function parseCsv(input: string) {
});
}
type LocaleDisplayNamesResult = Record<string, Record<string, string>>;
/** @typedef {Record<string, Record<string, string>>} LocaleDisplayNamesResult */
function convertData(
input: z.infer<typeof LocaleDisplayNames>
): LocaleDisplayNamesResult {
/**
* @param {z.infer<typeof LocaleDisplayNames>} input
* @returns {LocaleDisplayNamesResult}
*/
function convertData(input) {
const [[, ...keys], ...rows] = input;
const result: LocaleDisplayNamesResult = {};
/** @type {LocaleDisplayNamesResult} */
const result = {};
if (type === 'locales') {
for (const row of rows) {
@@ -71,8 +75,9 @@ function convertData(
result[subKey] = {};
for (const [index, message] of messages.entries()) {
// oxlint-disable-next-line typescript/no-non-null-assertion
result[subKey][keys[index]!] = message;
const key = keys[index];
assert(key != null, 'Missing key');
result[subKey][key] = message;
}
}
} else {
@@ -85,15 +90,21 @@ function convertData(
const [subKey, ...messages] = row;
for (const [index, message] of messages.entries()) {
// oxlint-disable-next-line typescript/no-non-null-assertion
result[keys[index]!]![subKey] = message;
const key = keys[index];
assert(key != null, 'Missing key');
const values = result[key];
assert(values != null, 'Missing values');
values[subKey] = message;
}
}
}
return result;
}
function assertValuesForAllLocales(result: LocaleDisplayNamesResult) {
/**
* @param {LocaleDisplayNamesResult} result
*/
function assertValuesForAllLocales(result) {
for (const locale of availableLocales) {
const values = result[locale];
if (values == null) {
@@ -107,9 +118,12 @@ function assertValuesForAllLocales(result: LocaleDisplayNamesResult) {
}
}
function assertValuesForAllCountries(result: LocaleDisplayNamesResult) {
// oxlint-disable-next-line typescript/no-non-null-assertion
const availableCountries = Object.keys(result.en!);
/**
* @param {LocaleDisplayNamesResult} result
*/
function assertValuesForAllCountries(result) {
assert(result.en != null, 'Missing result.en');
const availableCountries = Object.keys(result.en);
for (const locale of availableLocales) {
const values = result[locale];
if (values == null) {
@@ -123,22 +137,14 @@ function assertValuesForAllCountries(result: LocaleDisplayNamesResult) {
}
}
async function main() {
const contents = await fs.readFile(localeDisplayNamesDataPath, 'utf-8');
const records = await parseCsv(contents);
const data = parseUnknown(LocaleDisplayNames, records as unknown);
const result = convertData(data);
if (type === 'locales') {
assertValuesForAllLocales(result);
} else if (type === 'countries') {
assertValuesForAllCountries(result);
}
const json = JSON.stringify(result, null, 2);
await fs.writeFile(localeDisplayNamesBuildPath, json, 'utf-8');
const contents = await fs.readFile(localeDisplayNamesDataPath, 'utf-8');
const records = await parseCsv(contents);
const data = LocaleDisplayNames.parse(records);
const result = convertData(data);
if (type === 'locales') {
assertValuesForAllLocales(result);
} else if (type === 'countries') {
assertValuesForAllCountries(result);
}
// oxlint-disable-next-line promise/prefer-await-to-then
main().catch(error => {
console.error(error);
process.exit(1);
});
const json = JSON.stringify(result, null, 2);
await fs.writeFile(localeDisplayNamesBuildPath, json, 'utf-8');
@@ -1,6 +1,6 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import { execFile as execFileCb } from 'node:child_process';
import { promisify } from 'node:util';
import { existsSync } from 'node:fs';
@@ -9,12 +9,13 @@ import { join, basename } from 'node:path';
import fastGlob from 'fast-glob';
import { gte } from 'semver';
import { Format, NtExecutable } from 'pe-library';
import packageJson from '../package.json' with { type: 'json' };
// Note: because we don't run under electron - this is a path to binary
import ELECTRON_BINARY from 'electron';
import { drop } from '../util/drop.std.ts';
import { packageJson } from '../util/packageJson.node.ts';
import electronImport from 'electron';
const ELECTRON_BINARY = /** @type {string} */ (
/** @type {unknown} */ (electronImport)
);
const { ImageDosHeader, ImageNtHeaders, ImageDirectoryEntry } = Format;
@@ -61,18 +62,23 @@ const MACOS_TO_DARWIN_VERSIONS = new Map([
['26.0', '25.0.0'],
]);
async function macosVersionCheck(file: string) {
/**
* @param {string} file
*/
async function macosVersionCheck(file) {
console.log(`${file}: checking...`);
const { stdout } = await execFile('otool', ['-l', file]);
type MacosMatch = RegExpMatchArray & { 1: string };
const match = stdout.match(/minos\s+([\d.]+)/);
if (match == null) {
throw new Error(`Failed to detect min OS version of ${file}`);
}
const [, macosVersion] = match as MacosMatch;
const [, macosVersion] = match;
if (macosVersion == null) {
throw new Error('Missing macosVersion');
}
const darwinVersion = MACOS_TO_DARWIN_VERSIONS.get(macosVersion);
if (darwinVersion == null) {
throw new Error(`No matching darwin version for macOS ${macosVersion}`);
@@ -172,7 +178,11 @@ const ALLOWED_DLLS = new Set([
'wtsapi32.dll',
]);
async function windowsDllImportCheck(file: string): Promise<void> {
/**
* @param {string} file
* @returns {Promise<void>}
*/
async function windowsDllImportCheck(file) {
console.log(`${file}: checking...`);
const fileData = await readFile(file);
@@ -183,7 +193,12 @@ async function windowsDllImportCheck(file: string): Promise<void> {
ignoreCert: true,
});
function cstr(data: Buffer<ArrayBuffer>, offset: number): string {
/**
* @param {Buffer<ArrayBuffer>} data
* @param {number} offset
* @returns {string}
*/
function cstr(data, offset) {
for (let end = offset; end < data.length; end += 1) {
if (data[end] === 0) {
return data.subarray(offset, end).toString();
@@ -192,7 +207,8 @@ async function windowsDllImportCheck(file: string): Promise<void> {
throw new Error('Invalid cstring');
}
const imports = new Set<string>();
/** @type {Set<string>} */
const imports = new Set();
for (const [entryType, { empty, nameOffset }] of DLL_TABLES) {
const section = ntExecutable.getSectionByEntry(entryType);
const imageDirectoryEntry =
@@ -248,7 +264,10 @@ async function windowsDllImportCheck(file: string): Promise<void> {
}
}
function padGlibcVersion(version: string) {
/**
* @param {string} version
*/
function padGlibcVersion(version) {
if (/^\d+\.\d+$/.test(version)) {
return `${version}.0`;
}
@@ -258,7 +277,10 @@ function padGlibcVersion(version: string) {
throw new Error(`Unsupported glibc version: ${version}`);
}
async function linuxVersionCheck(file: string) {
/**
* @param {string} file
*/
async function linuxVersionCheck(file) {
if (!existsSync(file)) {
console.log(`${file}: skipping`);
return;
@@ -270,10 +292,13 @@ async function linuxVersionCheck(file: string) {
maxBuffer: 100 * 1024 * 1024,
});
let minGlibcVersion: string | undefined;
type GlibcMatch = RegExpExecArray & { 1: string };
/** @type {string | undefined} */
let minGlibcVersion;
for (const match of stdout.matchAll(/GLIBC_([\d.]+)/g)) {
const [, unpaddedVersion] = match as GlibcMatch;
const [, unpaddedVersion] = match;
if (unpaddedVersion == null) {
throw new Error('Missing unpaddedVersion');
}
const glibcVersion = padGlibcVersion(unpaddedVersion);
if (minGlibcVersion == null || gte(glibcVersion, minGlibcVersion)) {
minGlibcVersion = glibcVersion;
@@ -291,7 +316,6 @@ async function linuxVersionCheck(file: string) {
throw new Error('Missing libc6 dependency in package.json');
}
type Libc6Match = RegExpMatchArray & { 1: string };
const match = libc6Dependency.match(/^libc6 \(>= ([\d.]+)\)$/);
if (match == null) {
throw new Error(
@@ -299,7 +323,10 @@ async function linuxVersionCheck(file: string) {
);
}
const [, unpaddedVersion] = match as Libc6Match;
const [, unpaddedVersion] = match;
if (unpaddedVersion == null) {
throw new Error('Missing unpaddedVersion');
}
const minSupported = padGlibcVersion(unpaddedVersion);
if (gte(minSupported, minGlibcVersion)) {
console.log(`${file}: required version ${minGlibcVersion}`);
@@ -312,34 +339,31 @@ async function linuxVersionCheck(file: string) {
);
}
async function main() {
const BINARY_FILES = [
ELECTRON_BINARY as unknown as string,
...(await fastGlob(
packageJson.build.files
.filter((p: unknown): p is string => typeof p === 'string')
.filter(p => p.endsWith('.node'))
.map(p => p.replace(/\${platform}/, process.platform))
.map(p => p.replace(/\${arch}/, process.arch)),
{
absolute: true,
onlyFiles: true,
cwd: join(__dirname, '..', '..'),
}
)),
];
for (const file of BINARY_FILES) {
if (process.platform === 'darwin') {
// oxlint-disable-next-line no-await-in-loop
await macosVersionCheck(file);
} else if (process.platform === 'win32') {
// oxlint-disable-next-line no-await-in-loop
await windowsDllImportCheck(file);
} else if (process.platform === 'linux') {
// oxlint-disable-next-line no-await-in-loop
await linuxVersionCheck(file);
/** @type {string[]} */
const BINARY_FILES = [
ELECTRON_BINARY,
...(await fastGlob(
packageJson.build.files
.filter(p => typeof p === 'string')
.filter(p => p.endsWith('.node'))
.map(p => p.replace(/\${platform}/, process.platform))
.map(p => p.replace(/\${arch}/, process.arch)),
{
absolute: true,
onlyFiles: true,
cwd: join(import.meta.dirname, '..'),
}
)),
];
for (const file of BINARY_FILES) {
if (process.platform === 'darwin') {
// oxlint-disable-next-line no-await-in-loop
await macosVersionCheck(file);
} else if (process.platform === 'win32') {
// oxlint-disable-next-line no-await-in-loop
await windowsDllImportCheck(file);
} else if (process.platform === 'linux') {
// oxlint-disable-next-line no-await-in-loop
await linuxVersionCheck(file);
}
}
drop(main());
+343
View File
@@ -0,0 +1,343 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import { join } from 'node:path';
import { readFile } from 'node:fs/promises';
import chalk from 'chalk';
import semver from 'semver';
import { got, HTTPError } from 'got';
import enquirer from 'enquirer';
import execa from 'execa';
import { assert } from './utils/assert.mjs';
const rootDir = join(import.meta.dirname, '..');
/**
* @param {string} path
* @returns {Promise<any>}
*/
async function readJsonFile(path) {
return JSON.parse(await readFile(path, 'utf-8'));
}
/**
* @param {string | number | null | undefined} value
* @returns {number | null}
*/
function parseNumberField(value) {
if (value == null) {
return null;
}
if (typeof value === 'number') {
return value;
}
const trimmed = value.trim();
if (trimmed === '') {
return null;
}
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return null;
}
return parsed;
}
const npm = got.extend({
prefixUrl: 'https://registry.npmjs.org/',
responseType: 'json',
retry: {
calculateDelay: retry => {
if (
retry.error instanceof HTTPError &&
retry.error.response.statusCode === 429
) {
const retryAfter = parseNumberField(
retry.error.response.headers['retry-after']
);
if (retryAfter != null) {
console.log(
chalk.gray(`Rate limited, retrying after ${retryAfter} seconds`)
);
return retryAfter * 1000;
}
}
return retry.computedValue;
},
},
});
const DependencyTypes = /** @type {const} */ ([
'dependencies',
'devDependencies',
'optionalDependencies',
'peerDependencies',
]);
/**
* @typedef {object} LocalDependency
* @prop {string} name
* @prop {(typeof DependencyTypes)[number]} depType
* @prop {string} requestedVersion
* @prop {string} resolvedVersion
*/
/**
* @typedef {object} FetchedDependencyProps
* @prop {string} latestVersion
* @prop {'commonjs' | 'esm'} moduleType
* @prop {semver.ReleaseType | null} diff
*
* @typedef {LocalDependency & FetchedDependencyProps} FetchedDependency
*/
const packageJson = await readJsonFile(join(rootDir, 'package.json'));
const packageLock = await readJsonFile(join(rootDir, 'package-lock.json'));
/** @type {ReadonlyArray<LocalDependency>} */
const localDeps = DependencyTypes.flatMap(depType => {
return Object.keys(packageJson[depType] ?? {}).map(name => {
const requestedVersion = packageJson[depType][name];
const resolvedVersion =
packageLock.packages[`node_modules/${name}`]?.version;
assert(resolvedVersion, `Could not find resolved version for ${name}`);
return { name, depType, requestedVersion, resolvedVersion };
});
});
console.log(chalk`Found {cyan ${localDeps.length}} local dependencies`);
/** @type {ReadonlyArray<FetchedDependency>} */
const fetchedDeps = await Promise.all(
localDeps.map(async dep => {
/** @type {any} */
const info = await npm(`${dep.name}/latest`).json();
const latestVersion = info.version;
const moduleType = info.type ?? 'commonjs';
assert(
moduleType === 'commonjs' || moduleType === 'module',
`Unexpected module type for ${dep.name}: ${moduleType}`
);
const diff = semver.lt(dep.resolvedVersion, latestVersion)
? semver.diff(dep.resolvedVersion, latestVersion)
: null;
return { ...dep, latestVersion, moduleType, diff };
})
);
const outdatedDeps = fetchedDeps.filter(dep => dep.diff != null);
console.log(chalk`Found {cyan ${outdatedDeps.length}} outdated dependencies`);
const upgradeableDeps = outdatedDeps.filter(dep => {
return dep.moduleType === 'commonjs';
});
console.log(
chalk`Found {cyan ${upgradeableDeps.length}} upgradeable dependencies`
);
/** @type {Map<string, Set<string>>} */
const upgradeableDepsByDiff = new Map();
for (const dep of upgradeableDeps) {
assert(dep.diff != null, 'Expected diff to be non-null');
let group = upgradeableDepsByDiff.get(dep.diff);
if (group == null) {
group = new Set();
upgradeableDepsByDiff.set(dep.diff, group);
}
group.add(dep.name);
}
for (const [diff, deps] of upgradeableDepsByDiff) {
console.log(chalk` - ${diff}: {cyan ${deps.size}}`);
}
let longestNameLength = 0;
for (const dep of upgradeableDeps) {
longestNameLength = Math.max(longestNameLength, dep.name.length);
}
/** @type {{ approvedDeps: ReadonlyArray<string>; }} */
const { approvedDeps } = await enquirer.prompt({
type: 'multiselect',
name: 'approvedDeps',
message: 'Select which dependencies to upgrade',
choices: upgradeableDeps.map(deps => {
let color = chalk.red;
if (deps.diff === 'patch') {
color = chalk.green;
} else if (deps.diff === 'minor') {
color = chalk.yellow;
}
return {
name: deps.name,
message: `${deps.name.padEnd(longestNameLength)}`,
hint: `(${color(deps.diff)}: ${deps.resolvedVersion} -> ${color(deps.latestVersion)})`,
};
}),
});
console.log(
chalk`Starting upgrade of {cyan ${approvedDeps.length}} dependencies`
);
for (const dep of upgradeableDeps) {
try {
if (!approvedDeps.includes(dep.name)) {
console.log(chalk`Skipping ${dep.name}`);
continue;
}
// oxlint-disable-next-line no-await-in-loop
const gitStatusBefore = await execa('git', ['status', '--porcelain']);
if (gitStatusBefore.stdout.trim() !== '') {
console.error(chalk`{red Found uncommitted changes, exiting}`);
console.error(chalk.red(gitStatusBefore.stdout));
process.exit(1);
}
console.log(
chalk`Upgrading {cyan ${dep.name}} from {yellow ${dep.resolvedVersion}} to {magenta ${dep.latestVersion}}`
);
// oxlint-disable-next-line no-await-in-loop
await execa(
'npm',
['install', '--save-exact', `${dep.name}@${dep.latestVersion}`],
{ stdio: 'inherit' }
);
// oxlint-disable-next-line no-constant-condition
while (true) {
try {
// oxlint-disable-next-line no-await-in-loop
await execa(
'npx',
['patch-package', '--error-on-fail', '--error-on-warn'],
{ stdio: 'inherit' }
);
break;
} catch {
/** @type {{ retry: boolean }} */
// oxlint-disable-next-line no-await-in-loop
const { retry } = await enquirer.prompt({
type: 'confirm',
name: 'retry',
message: 'Retry patch-package?',
initial: true,
});
if (!retry) {
throw new Error('Failed to apply patch-package');
}
}
}
/** @type {{ npmScriptsToRun: Array<string> }} */
// oxlint-disable-next-line no-await-in-loop
const { npmScriptsToRun } = await enquirer.prompt({
type: 'multiselect',
name: 'npmScriptsToRun',
message: 'Select which scripts to run',
choices: [
// Fast and common
{ name: 'oxlint' },
{ name: 'test-node' },
{ name: 'test-electron' },
// Long
{ name: 'test-mock' },
// Uncommon
{ name: 'test-oxlint' },
{ name: 'test-lint-intl' },
],
});
const allNpmScriptToRun = [
// Mandatory
'generate',
'check:types',
'lint-deps',
// Optional
...npmScriptsToRun,
];
for (const script of allNpmScriptToRun) {
console.log(chalk`Running {cyan npm run ${script}}`);
// oxlint-disable-next-line no-constant-condition
while (true) {
try {
// oxlint-disable-next-line no-await-in-loop
await execa('npm', ['run', script], { stdio: 'inherit' });
break;
} catch (error) {
console.log(
chalk.red(
`Failed to run ${script}, you could go make changes and try again`
)
);
/** @type {{ retry: boolean }} */
// oxlint-disable-next-line no-await-in-loop
const { retry } = await enquirer.prompt({
type: 'confirm',
name: 'retry',
message: 'Retry running script?',
initial: true,
});
if (!retry) {
throw error;
} else {
console.log(chalk`Retrying {cyan npm run ${script}}`);
continue;
}
}
}
}
console.log('Changes after upgrade:');
// oxlint-disable-next-line no-await-in-loop
await execa('git', ['status', '--porcelain'], { stdio: 'inherit' });
/** @type {{ commitChanges: boolean }} */
// oxlint-disable-next-line no-await-in-loop
const { commitChanges } = await enquirer.prompt({
type: 'select',
name: 'commitChanges',
message: 'Commit these changes?',
choices: [
{ name: 'commit', message: 'Commit and continue', value: true },
{ name: 'revert', message: 'Revert and skip', value: false },
],
});
if (!commitChanges) {
console.log('Reverting changes, and skipping');
// oxlint-disable-next-line no-await-in-loop
await execa('git', ['checkout', '.']);
continue;
}
console.log('Committing changes');
// oxlint-disable-next-line no-await-in-loop
await execa('git', ['add', '.']);
// oxlint-disable-next-line no-await-in-loop
await execa('git', [
'commit',
'-m',
`Upgrade ${dep.name} ${dep.depType} from ${dep.requestedVersion} to ${dep.latestVersion}`,
]);
} catch (error) {
console.error(chalk.red(error));
console.log(
chalk.red(`Failed to upgrade ${dep.name}, reverting and skipping`)
);
// oxlint-disable-next-line no-await-in-loop
await execa('git', ['checkout', '.']);
}
}
-47
View File
@@ -1,47 +0,0 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
const fastGlob = require('fast-glob');
const { rm } = require('node:fs/promises');
const { join } = require('node:path');
const repoRoot = join(__dirname, '..');
const PATTERNS = [
'sticker-creator/dist',
'build/**/*.js',
'app/**/*.js',
'app/*.js',
'ts/**/*.js',
'bundles',
'tsconfig.tsbuildinfo',
'preload.bundle.js',
'preload.bundle.cache',
];
const EXCEPTIONS = new Set(['ts/windows/main/tsx.preload.js']);
async function main() {
const readable = fastGlob.stream(PATTERNS, {
cwd: repoRoot,
});
const promises = [];
let count = 0;
for await (const entry of readable) {
if (EXCEPTIONS.has(entry)) {
continue;
}
count += 1;
promises.push(rm(entry, { recursive: true, force: true }));
}
await Promise.all(promises);
console.log(`Deleted ${count} files`);
}
// oxlint-disable-next-line promise/prefer-await-to-then
main().catch(error => {
console.error(error);
process.exit(1);
});
+42
View File
@@ -0,0 +1,42 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import fastGlob from 'fast-glob';
import { rm } from 'node:fs/promises';
import { join } from 'node:path';
const repoRoot = join(import.meta.dirname, '..');
const PATTERNS = [
'sticker-creator/dist',
'build/**/*.js',
'app/**/*.js',
'app/*.js',
'ts/**/*.js',
'bundles',
'tsconfig.tsbuildinfo',
'preload.bundle.js',
'preload.bundle.cache',
];
const EXCEPTIONS = new Set(['ts/windows/main/tsx.preload.js']);
const readable = fastGlob.stream(PATTERNS, {
cwd: repoRoot,
});
const promises = [];
let count = 0;
for await (const entry of readable) {
if (typeof entry !== 'string') {
throw new Error('Expected readable entry to be string');
}
if (EXCEPTIONS.has(entry)) {
continue;
}
count += 1;
promises.push(rm(entry, { recursive: true, force: true }));
}
await Promise.all(promises);
console.log(`Deleted ${count} files`);
+63
View File
@@ -0,0 +1,63 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import { readFile, writeFile, readdir, readlink } from 'node:fs/promises';
import { join, basename } from 'node:path';
import pMap from 'p-map';
const source = process.argv[2];
if (!source) {
throw new Error('Missing required source directory argument');
}
const dirEntries = await readdir(join(source, 'components'), {
withFileTypes: true,
recursive: true,
});
const enMessages = JSON.parse(
await readFile(
join(import.meta.dirname, '..', '_locales', 'en', 'messages.json'),
'utf8'
)
);
/** @type {Record<string, Array<[string, string]>>} */
const icuToStory = Object.create(null);
await pMap(
dirEntries,
async entry => {
if (!entry.isSymbolicLink()) {
return;
}
const fullPath = join(entry.parentPath, entry.name);
const image = basename(await readlink(fullPath));
const storyId = basename(entry.parentPath);
const linkFile = entry.name;
const icuId = `icu:${basename(linkFile, '.jpg')}`;
let list = icuToStory[icuId];
if (list == null) {
list = [];
icuToStory[icuId] = list;
}
list.push([storyId, image]);
},
{ concurrency: 20 }
);
const index = Object.entries(icuToStory).map(([key, icuIds]) => {
return [key, enMessages[key]?.messageformat, icuIds];
});
const html = await readFile(
join(import.meta.dirname, '..', '.storybook', 'icu-lookup.html'),
'utf8'
);
await writeFile(
join(source, 'index.html'),
html.replace('%INDEX%', JSON.stringify(index))
);
@@ -1,21 +1,24 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import fse from 'fs-extra';
import path from 'node:path';
import type { AfterPackContext } from 'electron-builder';
export async function afterPack({
appOutDir,
packager,
electronPlatformName,
}: AfterPackContext): Promise<void> {
let defaultLocale: string;
/** @import { AfterPackContext } from 'electron-builder' */
/**
* @param {AfterPackContext} context
* @returns {Promise<void>}
*/
export async function afterPack({ appOutDir, packager, electronPlatformName }) {
/** @type {string} */
let defaultLocale;
let ourLocales = await fse.readdir(
path.join(__dirname, '..', '..', '_locales')
path.join(import.meta.dirname, '..', '_locales')
);
let localesPath: string;
/** @type {string} */
let localesPath;
if (electronPlatformName === 'darwin' || electronPlatformName === 'mas') {
const { productFilename } = packager.appInfo;
@@ -55,7 +58,9 @@ export async function afterPack({
}
const electronLocales = new Set(await fse.readdir(localesPath));
const promises = new Array<Promise<void>>();
/** @type {Promise<void>[]} */
const promises = [];
for (const locale of ourLocales) {
if (electronLocales.has(locale)) {
continue;
@@ -1,19 +1,21 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import path from 'node:path';
import { execSync } from 'node:child_process';
import type { AfterPackContext } from 'electron-builder';
/** @import { AfterPackContext } from 'electron-builder' */
const FILES = [
'resources/org.signalapp.enable-backups.policy',
'resources/org.signalapp.view-aep.policy',
];
export async function afterPack({
appOutDir,
electronPlatformName,
}: AfterPackContext): Promise<void> {
/**
* @param {AfterPackContext} context
* @returns {Promise<void>}
*/
export async function afterPack({ appOutDir, electronPlatformName }) {
if (electronPlatformName !== 'linux') {
return;
}
@@ -1,15 +1,16 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import path from 'node:path';
import { flipFuses, FuseVersion, FuseV1Options } from '@electron/fuses';
import type { AfterPackContext } from 'electron-builder';
export async function afterPack({
appOutDir,
packager,
electronPlatformName,
}: AfterPackContext): Promise<void> {
/** @import { AfterPackContext, LinuxPackager } from 'electron-builder' */
/**
* @param {AfterPackContext} context
* @returns {Promise<void>}
*/
export async function afterPack({ appOutDir, packager, electronPlatformName }) {
const { productFilename } = packager.appInfo;
let target;
@@ -18,9 +19,7 @@ export async function afterPack({
} else if (electronPlatformName === 'win32') {
target = `${productFilename}.exe`;
} else if (electronPlatformName === 'linux') {
// Sadly, `LinuxPackager` type is not exported by electron-builder so we
// have to improvise
target = (packager as unknown as { executableName: string }).executableName;
target = /** @type {LinuxPackager} */ (packager).executableName;
} else {
throw new Error(`Unsupported platform: ${electronPlatformName}`);
}
+59
View File
@@ -0,0 +1,59 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import fs from 'node:fs/promises';
import path from 'node:path';
import fastGlob from 'fast-glob';
import * as LocaleMatcher from '@formatjs/intl-localematcher';
const ROOT_DIR = path.join(import.meta.dirname, '..');
/**
* @param {string} input
* @param {string} expected
*/
function matches(input, expected) {
const match = LocaleMatcher.match([input], [expected], 'en', {
algorithm: 'best fit',
});
return match === expected;
}
const dirEntries = await fastGlob('_locales/*', {
cwd: ROOT_DIR,
onlyDirectories: true,
});
const localeDirNames = [];
for (const dirEntry of dirEntries) {
const dirName = path.basename(dirEntry);
const locale = new Intl.Locale(dirName);
// Smartling doesn't always use the correct language tag, so this check and
// reverse check are to make sure we don't accidentally add a locale that
// doesn't match its directory name (using LocaleMatcher).
//
// If this check ever fails, we may need to update our get-strings script to
// manually rename language tags before writing them to disk.
//
// Such is the case for Smartling's "zh-YU" locale, which we renamed to
// "yue" to match the language tag used by... everyone else.
if (!matches(dirName, locale.baseName)) {
throw new Error(
`Matched locale "${dirName}" does not match its resolved name "${locale.baseName}"`
);
}
if (!matches(locale.baseName, dirName)) {
throw new Error(
`Matched locale "${dirName}" does not match its dir name "${dirName}"`
);
}
localeDirNames.push(dirName);
}
const jsonPath = path.join(ROOT_DIR, 'build', 'available-locales.json');
console.log(`Writing to "${jsonPath}"...`);
await fs.writeFile(jsonPath, `${JSON.stringify(localeDirNames, null, 2)}\n`);
@@ -1,6 +1,6 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import { createIntl } from '@formatjs/intl';
import path from 'node:path';
import fs from 'node:fs';
@@ -11,7 +11,7 @@ import fs from 'node:fs';
//
// Make sure to sync up the values in `util/nsis` with upstream
// `app-builder-lib`.
import { REQUIRED_LANGUAGES, LCID } from '../util/nsis.std.ts';
import { REQUIRED_LANGUAGES, LCID } from './utils/nsis.mjs';
const STRING_VARS = new Map([
[
@@ -35,19 +35,20 @@ const STRING_VARS = new Map([
console.log('Generating updates NSIS script');
console.log();
const USED = new Set<number>();
/** @type {Set<number>} */
const USED = new Set();
const ROOT_DIR = path.join(__dirname, '..', '..');
const ROOT_DIR = path.join(import.meta.dirname, '..');
const LOCALES_DIR = path.join(ROOT_DIR, '_locales');
const fallbackMessages = JSON.parse(
fs.readFileSync(path.join(LOCALES_DIR, 'en', 'messages.json')).toString()
);
const nsisStrings = new Array<string>();
/** @type {Array<string>} */
const nsisStrings = [];
for (const lang of REQUIRED_LANGUAGES) {
// oxlint-disable-next-line typescript/no-non-null-assertion
const langId = LCID[lang] ?? LCID.en_US!;
const langId = LCID[lang] ?? LCID.en_US;
if (USED.has(langId)) {
continue;
}
@@ -59,7 +60,8 @@ for (const lang of REQUIRED_LANGUAGES) {
if (lang === 'zh_TW') {
fallbacks.unshift('zh-Hant');
}
let json: Buffer<ArrayBuffer> | undefined;
/** @type {Buffer<ArrayBuffer> | undefined} */
let json;
for (const f of fallbacks) {
try {
json = fs.readFileSync(path.join(LOCALES_DIR, f, 'messages.json'));
+101
View File
@@ -0,0 +1,101 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import fs from 'node:fs/promises';
import path from 'node:path';
import fastGlob from 'fast-glob';
import { assert } from './utils/assert.mjs';
const ROOT_DIR = path.join(import.meta.dirname, '..');
const dirEntries = await fastGlob('_locales/*', {
cwd: ROOT_DIR,
onlyDirectories: true,
});
const english = JSON.parse(
await fs.readFile(path.join(ROOT_DIR, '_locales/en/messages.json'), 'utf8')
);
const templateDir = path.join(ROOT_DIR, 'build/policy-templates');
const templates = [
{
name: 'org.signalapp.enable-backups.policy',
content: await fs.readFile(
path.join(templateDir, 'org.signalapp.enable-backups.policy'),
'utf8'
),
description:
'icu:Preferences__local-backups--enable--os-prompt-description--linux',
message: 'icu:Preferences__local-backups--enable--os-prompt-message--linux',
},
{
name: 'org.signalapp.plaintext-export.policy',
content: await fs.readFile(
path.join(templateDir, 'org.signalapp.plaintext-export.policy'),
'utf8'
),
description: 'icu:PlaintextExport--OSPrompt--description--Linux',
message: 'icu:PlaintextExport--OSPrompt--message--Linux',
},
{
name: 'org.signalapp.view-aep.policy',
content: await fs.readFile(
path.join(templateDir, 'org.signalapp.view-aep.policy'),
'utf8'
),
description:
'icu:Preferences--local-backups--view-recovery-key--os-prompt-description--linux',
message:
'icu:Preferences--local-backups--view-recovery-key--os-prompt-message--linux',
},
];
for (const template of templates) {
const englishDescription = english[template.description]?.messageformat;
assert(
typeof englishDescription === 'string' && englishDescription.length !== 0,
`Must have english string for key ${template.description}`
);
const englishMessage = english[template.message]?.messageformat;
assert(
typeof englishMessage === 'string',
`Must have english string for key ${template.message}`
);
let allDescriptions = `<description>${englishDescription}</description>\n`;
let allMessages = `<message>${englishMessage}</message>\n`;
for (const dirEntry of dirEntries) {
const locale = path.basename(dirEntry);
const data = JSON.parse(
// oxlint-disable-next-line no-await-in-loop
await fs.readFile(
path.join(ROOT_DIR, '_locales', locale, 'messages.json'),
'utf8'
)
);
const localeName = locale.replace('-', '_');
const description =
data[template.description]?.messageformat ?? englishDescription;
allDescriptions += ` <description xml:lang="${localeName}">${description}</description>\n`;
const message = data[template.message]?.messageformat ?? englishMessage;
allMessages += ` <message xml:lang="${localeName}">${message}</message>\n`;
}
const targetPath = path.join(ROOT_DIR, 'build', template.name);
let targetContent = template.content;
targetContent = targetContent.replace(
'<!-- <description>{description}</description> -->',
allDescriptions
);
targetContent = targetContent.replace(
'<!-- <message>{message}</message> -->',
allMessages
);
// oxlint-disable-next-line no-await-in-loop
await fs.writeFile(targetPath, targetContent);
}
@@ -1,13 +1,12 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
const assert = require('node:assert');
const fs = require('node:fs');
const { join } = require('node:path');
const { default: pMap } = require('p-map');
const prettier = require('prettier');
const { default: packageJson } = require('./packageJson.js');
// @ts-check
import assert from 'node:assert';
import fs from 'node:fs';
import { join } from 'node:path';
import pMap from 'p-map';
import prettier from 'prettier';
import packageJson from '../package.json' with { type: 'json' };
// During development, you might use local versions of dependencies which are missing
// acknowledgment files. In this case we'll skip rebuilding the acknowledgment files.
@@ -28,14 +27,20 @@ const SIGNAL_LIBS = [
const SKIPPED_DEPENDENCIES = new Set(SIGNAL_LIBS);
const rootDir = join(__dirname, '..');
const rootDir = join(import.meta.dirname, '..');
const nodeModulesPath = join(rootDir, 'node_modules');
const destinationPath = join(rootDir, 'ACKNOWLEDGMENTS.md');
/**
* @param {string} fileName
*/
function isLicenseFileName(fileName) {
return /^licen[s|c]e/i.test(fileName);
}
/**
* @param {string} dependencyName
*/
async function getMarkdownForDependency(dependencyName) {
let licenseBody;
@@ -59,7 +64,7 @@ async function getMarkdownForDependency(dependencyName) {
} else {
const packageJsonPath = join(dependencyRootPath, 'package.json');
const { license } = JSON.parse(
await fs.promises.readFile(packageJsonPath)
await fs.promises.readFile(packageJsonPath, 'utf8')
);
if (!license) {
throw new Error(`Could not find license for ${dependencyName}`);
@@ -81,6 +86,9 @@ async function getMarkdownForDependency(dependencyName) {
].join('\n');
}
/**
* @param {string} dependencyName
*/
async function getMarkdownForSignalLib(dependencyName) {
const dependencyRootPath = join(nodeModulesPath, dependencyName);
const licenseFilePath = join(
@@ -89,20 +97,19 @@ async function getMarkdownForSignalLib(dependencyName) {
'acknowledgments.md'
);
/** @type {string} */
let licenseBody;
try {
licenseBody = await fs.promises.readFile(licenseFilePath, 'utf8');
} catch (err) {
if (err) {
if (err.code === 'ENOENT' && !REQUIRE_SIGNAL_LIB_FILES) {
console.warn(
`Missing acknowledgments file for ${dependencyName}. Skipping generation of acknowledgments.`
);
process.exit(0);
}
throw err;
if (err.code === 'ENOENT' && !REQUIRE_SIGNAL_LIB_FILES) {
console.warn(
`Missing acknowledgments file for ${dependencyName}. Skipping generation of acknowledgments.`
);
process.exit(0);
}
throw err;
}
return [
@@ -112,68 +119,60 @@ async function getMarkdownForSignalLib(dependencyName) {
].join('\n');
}
async function main() {
assert.deepStrictEqual(
Object.keys(optionalDependencies),
['fs-xattr'],
'Unexpected optionalDependencies when generating acknowledgments file. To ensure that this file is generated deterministically, make sure to special-case it the acknowledgments generation script.'
);
assert.deepStrictEqual(
Object.keys(optionalDependencies),
['fs-xattr'],
'Unexpected optionalDependencies when generating acknowledgments file. To ensure that this file is generated deterministically, make sure to special-case it the acknowledgments generation script.'
);
const dependencyNames = [
...Object.keys(dependencies),
...Object.keys(devDependencies),
...Object.keys(optionalDependencies),
]
.filter(name => !SKIPPED_DEPENDENCIES.has(name))
.sort();
const dependencyNames = [
...Object.keys(dependencies),
...Object.keys(devDependencies),
...Object.keys(optionalDependencies),
]
.filter(name => !SKIPPED_DEPENDENCIES.has(name))
.sort();
const markdownsForDependency = await pMap(
dependencyNames,
getMarkdownForDependency,
// Without this, we may run into "too many open files" errors.
{
concurrency: 100,
timeout: 1000 * 60 * 2,
}
);
const markdownsForDependency = await pMap(
dependencyNames,
getMarkdownForDependency,
// Without this, we may run into "too many open files" errors.
{
concurrency: 100,
signal: AbortSignal.timeout(1000 * 60 * 2),
}
);
// For our libraries copy the respective acknowledgement lists
const markdownsFromSignalLibs = await pMap(
SIGNAL_LIBS,
getMarkdownForSignalLib,
{
concurrency: 100,
timeout: 1000 * 60 * 2,
}
);
// For our libraries copy the respective acknowledgement lists
const markdownsFromSignalLibs = await pMap(
SIGNAL_LIBS,
getMarkdownForSignalLib,
{
concurrency: 100,
signal: AbortSignal.timeout(1000 * 60 * 2),
}
);
const unformattedOutput = [
'<!-- Copyright 2020 Signal Messenger, LLC -->',
'<!-- SPDX-License-Identifier: AGPL-3.0-only -->',
'# Acknowledgments',
'',
'Signal Desktop makes use of the following open source projects.',
'',
markdownsForDependency.join('\n\n'),
'',
'## Kyber Patent License',
'',
'<https://csrc.nist.gov/csrc/media/Projects/post-quantum-cryptography/documents/selected-algos-2022/nist-pqc-license-summary-and-excerpts.pdf>',
'',
markdownsFromSignalLibs.join('\n\n'),
].join('\n');
const unformattedOutput = [
'<!-- Copyright 2020 Signal Messenger, LLC -->',
'<!-- SPDX-License-Identifier: AGPL-3.0-only -->',
'# Acknowledgments',
'',
'Signal Desktop makes use of the following open source projects.',
'',
markdownsForDependency.join('\n\n'),
'',
'## Kyber Patent License',
'',
'<https://csrc.nist.gov/csrc/media/Projects/post-quantum-cryptography/documents/selected-algos-2022/nist-pqc-license-summary-and-excerpts.pdf>',
'',
markdownsFromSignalLibs.join('\n\n'),
].join('\n');
const prettierConfig = await prettier.resolveConfig(destinationPath);
const output = await prettier.format(unformattedOutput, {
...prettierConfig,
filepath: destinationPath,
});
await fs.promises.writeFile(destinationPath, output);
}
// oxlint-disable-next-line promise/prefer-await-to-then
main().catch(err => {
console.error(err);
process.exit(1);
const prettierConfig = await prettier.resolveConfig(destinationPath);
const output = await prettier.format(unformattedOutput, {
...prettierConfig,
filepath: destinationPath,
});
await fs.promises.writeFile(destinationPath, output);
+85
View File
@@ -0,0 +1,85 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import { readdir, mkdir, readFile, writeFile } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import pMap from 'p-map';
/**
* @param {unknown} value
* @returns {value is { messageformat: string }}
*/
function isLocaleMessageType(value) {
return (
typeof value === 'object' &&
value != null &&
Object.hasOwn(value, 'messageformat')
);
}
/**
* @param {object} options
* @param {string} options.sourceDir
* @param {string} options.targetDir
* @param {string} options.locale
* @param {ReadonlyArray<string>} options.keys
* @returns {Promise<ReadonlyArray<string>>}
*/
async function compact({ sourceDir, targetDir, locale, keys }) {
const sourcePath = join(sourceDir, locale, 'messages.json');
const targetPath = join(targetDir, locale, 'values.json');
await mkdir(dirname(targetPath), { recursive: true });
const json = JSON.parse(await readFile(sourcePath, 'utf8'));
/** @type {Array<string | null>} */
const result = [];
for (const key of keys) {
if (json[key] == null) {
// Pull English translation, or leave blank (string was deleted)
result.push(null);
continue;
}
const value = json[key];
if (!isLocaleMessageType(value)) {
continue;
}
if (value.messageformat == null) {
continue;
}
result.push(value.messageformat);
}
await writeFile(targetPath, JSON.stringify(result));
return keys;
}
const rootDir = join(import.meta.dirname, '..');
const sourceDir = join(rootDir, '_locales');
const targetDir = join(rootDir, 'build', 'compact-locales');
const locales = await readdir(sourceDir);
const allKeys = await pMap(
locales,
async locale => {
const sourcePath = join(sourceDir, locale, 'messages.json');
const json = JSON.parse(await readFile(sourcePath, 'utf8'));
return Object.entries(json)
.filter(([, value]) => isLocaleMessageType(value))
.map(([key]) => key);
},
{ concurrency: 10 }
);
// Sort keys alphabetically for better incremental updates.
const keys = Array.from(new Set(allKeys.flat())).sort();
await mkdir(targetDir, { recursive: true });
await writeFile(join(targetDir, 'keys.json'), JSON.stringify(keys));
await pMap(locales, locale => compact({ sourceDir, targetDir, locale, keys }), {
concurrency: 10,
});
+62
View File
@@ -0,0 +1,62 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import { resolve4 as resolve4Cb, resolve6 as resolve6Cb } from 'node:dns';
import { writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { promisify } from 'node:util';
const resolve4 = promisify(resolve4Cb);
const resolve6 = promisify(resolve6Cb);
const FALLBACK_DOMAINS = [
'chat.signal.org',
'storage.signal.org',
'cdsi.signal.org',
'cdn.signal.org',
'cdn2.signal.org',
'cdn3.signal.org',
'updates2.signal.org',
'sfu.voip.signal.org',
];
const config = await Promise.all(
FALLBACK_DOMAINS.sort().map(async domain => {
const ipv4endpoints = (await resolve4(domain)).map(a => ({
family: 'ipv4',
address: a,
}));
const ipv6endpoints = (await resolve6(domain)).map(a => ({
family: 'ipv6',
address: a,
}));
const endpoints = [...ipv4endpoints, ...ipv6endpoints]
.filter(value => {
return value != null;
})
.sort((a, b) => {
if (a.family < b.family) {
return -1;
}
if (a.family > b.family) {
return 1;
}
if (a.address < b.address) {
return -1;
}
if (a.address > b.address) {
return 1;
}
return 0;
});
return { domain, endpoints };
})
);
const outPath = join(import.meta.dirname, '..', 'build', 'dns-fallback.json');
await writeFile(outPath, `${JSON.stringify(config, null, 2)}\n`);
+47
View File
@@ -0,0 +1,47 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import fs from 'node:fs/promises';
import path from 'node:path';
import crypto from 'node:crypto';
import { execFileSync } from 'node:child_process';
const FIXTURES = path.join(import.meta.dirname, '..', 'fixtures');
const SIZE = 256 * 1024;
const original = crypto.randomBytes(SIZE);
const originalPath = path.join(FIXTURES, 'diff-original.bin');
await fs.writeFile(originalPath, original);
// Add a few broken bytes to help create useful blockmaps
for (let i = 0; i < 3; i += 1) {
original[Math.floor(Math.random() * original.length)] = 0;
}
const modifiedPath = path.join(FIXTURES, 'diff-modified.bin');
await fs.writeFile(modifiedPath, original);
const appBuilder = path.join(
import.meta.dirname,
'..',
'node_modules',
'app-builder-bin',
'mac',
'app-builder_amd64'
);
for (const filePath of [originalPath, modifiedPath]) {
console.log('Adding blockmap to', filePath);
// Put blockmap into a separate file
console.log(
execFileSync(appBuilder, [
'blockmap',
'--input',
filePath,
'--output',
`${filePath}.blockmap`,
]).toString()
);
}
@@ -1,24 +1,25 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import fs from 'node:fs/promises';
import { createHash } from 'node:crypto';
import path from 'node:path';
import ts from 'typescript';
import prettier from 'prettier';
import { getICUMessageParams } from './utils/getICUMessageParams.mjs';
import globalMessages from '../_locales/en/messages.json' with { type: 'json' };
import { unreachable } from './utils/assert.mjs';
import { DELETED_REGEXP } from './utils/intlMessages.mjs';
import { getICUMessageParams } from '../util/getICUMessageParams.std.ts';
import type { ICUMessageParamType } from '../util/getICUMessageParams.std.ts';
import { missingCaseError } from '../util/missingCaseError.std.ts';
import globalMessages from '../../_locales/en/messages.json';
/** @import { ICUMessageParamType } from './utils/getICUMessageParams.mjs' */
import { DELETED_REGEXP } from './constants.std.ts';
function translateParamType(
param: ICUMessageParamType,
stringType: ts.TypeNode,
componentType: ts.TypeNode
): ts.TypeNode {
/**
* @param {ICUMessageParamType} param
* @param {ts.TypeNode} stringType
* @param {ts.TypeNode} componentType
* @returns {ts.TypeNode}
*/
function translateParamType(param, stringType, componentType) {
switch (param.type) {
case 'string':
return stringType;
@@ -42,16 +43,22 @@ function translateParamType(
})
);
default:
throw missingCaseError(param);
unreachable(param);
}
}
const messageKeys = Object.keys(globalMessages).sort((a, b) => {
const messageKeys = /** @type {Array<keyof typeof globalMessages>} */ (
Object.keys(globalMessages)
).sort((a, b) => {
return a.localeCompare(b);
}) as Array<keyof typeof globalMessages>;
});
function filterDefaultParams(params: Map<string, ICUMessageParamType>) {
const filteredParams = new Map<string, ICUMessageParamType>();
/**
* @param {Map<string, ICUMessageParamType>} params
*/
function filterDefaultParams(params) {
/** @type {Map<string, ICUMessageParamType>} */
const filteredParams = new Map();
for (const [key, value] of params) {
if (key === 'emojify') {
@@ -70,8 +77,14 @@ const ComponentNode = ts.factory.createTypeReferenceNode('Component');
const StringToken = ts.factory.createToken(ts.SyntaxKind.StringKeyword);
const NeverToken = ts.factory.createToken(ts.SyntaxKind.NeverKeyword);
function generateType(name: string, supportsComponents: boolean): ts.Statement {
const props = new Array<ts.TypeElement>();
/**
* @param {string} name
* @param {boolean} supportsComponents
* @returns {ts.Statement}
*/
function generateType(name, supportsComponents) {
/** @type {Array<ts.TypeElement>} */
const props = [];
for (const key of messageKeys) {
if (key === 'smartling') {
continue;
@@ -102,11 +115,13 @@ function generateType(name: string, supportsComponents: boolean): ts.Statement {
const stringType = supportsComponents ? ComponentOrStringNode : StringToken;
const componentType = supportsComponents ? ComponentNode : NeverToken;
let paramType: ts.TypeNode;
/** @type {ts.TypeNode} */
let paramType;
if (params.size === 0) {
paramType = ts.factory.createToken(ts.SyntaxKind.UndefinedKeyword);
} else {
const subTypes = new Array<ts.TypeElement>();
/** @type {Array<ts.TypeElement>} */
const subTypes = [];
for (const [paramName, value] of params) {
subTypes.push(
@@ -140,7 +155,8 @@ function generateType(name: string, supportsComponents: boolean): ts.Statement {
);
}
const statements = new Array<ts.Statement>();
/** @type {Array<ts.Statement>} */
const statements = [];
let top = ts.factory.createImportDeclaration(
undefined,
@@ -252,40 +268,32 @@ const unformattedOutput = printer.printNode(
resultFile
);
async function main() {
const destinationPath = path.join(
__dirname,
'..',
'..',
'build',
'ICUMessageParams.d.ts'
);
const destinationPath = path.join(
import.meta.dirname,
'..',
'build',
'ICUMessageParams.d.ts'
);
let oldHash: string | undefined;
try {
oldHash = createHash('sha512')
.update(await fs.readFile(destinationPath))
.digest('hex');
} catch {
// Ignore errors
}
const prettierConfig = await prettier.resolveConfig(destinationPath);
const output = await prettier.format(unformattedOutput, {
...prettierConfig,
filepath: destinationPath,
});
const newHash = createHash('sha512').update(output).digest('hex');
if (oldHash === newHash) {
console.log('ICUMessageParams.d.ts is unchanged');
}
await fs.writeFile(destinationPath, output);
/** @type {string | undefined} */
let oldHash;
try {
oldHash = createHash('sha512')
.update(await fs.readFile(destinationPath))
.digest('hex');
} catch {
// Ignore errors
}
// oxlint-disable-next-line promise/prefer-await-to-then
main().catch(error => {
console.error(error);
process.exit(1);
const prettierConfig = await prettier.resolveConfig(destinationPath);
const output = await prettier.format(unformattedOutput, {
...prettierConfig,
filepath: destinationPath,
});
const newHash = createHash('sha512').update(output).digest('hex');
if (oldHash === newHash) {
console.log('ICUMessageParams.d.ts is unchanged');
}
await fs.writeFile(destinationPath, output);
+80
View File
@@ -0,0 +1,80 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import { spawn } from 'node:child_process';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { mkdir, mkdtemp, rm, rename } from 'node:fs/promises';
import pTimeout from 'p-timeout';
import { MINUTE } from './utils/durations.mjs';
// Note: because we don't run under electron - this is a path to binary
import electronImport from 'electron';
const ELECTRON_BINARY = /** @type {string} */ (
/** @type {unknown} */ (electronImport)
);
const ROOT_DIR = join(import.meta.dirname, '..');
const V8_ARGS = ['--predictable'];
const storagePath = await mkdtemp(join(tmpdir(), 'signal-preload-cache-'));
const argv = [`--js-flags=${V8_ARGS.join(' ')}`];
if (process.platform === 'linux') {
argv.push('--no-sandbox');
}
argv.push('ci.js');
const proc = spawn(ELECTRON_BINARY, argv, {
stdio: [null, 'inherit', 'inherit'],
cwd: ROOT_DIR,
env: {
// Linux X11 support
DISPLAY: process.env.DISPLAY,
XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR,
WAYLAND_DISPLAY: process.env.WAYLAND_DISPLAY,
XAUTHORITY: process.env.XAUTHORITY,
CI: process.env.CI ? 'on' : undefined,
GENERATE_PRELOAD_CACHE: 'on',
SIGNAL_CI_CONFIG: JSON.stringify({
storagePath,
openDevTools: false,
}),
},
});
try {
/** @type {PromiseWithResolvers<number | null>} */
const { promise, resolve, reject } = Promise.withResolvers();
proc.on('exit', status => resolve(status));
proc.on('error', error => reject(error));
const status = await pTimeout(promise, 5 * MINUTE);
if (status !== 0) {
throw new Error(`Exit code: ${status}`);
}
} catch (error) {
const { ARTIFACTS_DIR } = process.env;
if (!ARTIFACTS_DIR) {
console.error(
'Not saving artifacts. Please set ARTIFACTS_DIR env variable'
);
} else {
console.error(`Saving logs to ${ARTIFACTS_DIR}`);
await mkdir(ARTIFACTS_DIR, { recursive: true });
const logsDir = join(storagePath, 'logs');
await rename(logsDir, join(ARTIFACTS_DIR, 'logs'));
}
throw error;
} finally {
try {
proc.kill();
} catch {
// Ignore
}
await rm(storagePath, { recursive: true });
}
@@ -1,43 +1,53 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import { createCanvas, GlobalFonts, loadImage } from '@napi-rs/canvas';
import { join } from 'node:path';
import { mkdir, rm, writeFile } from 'node:fs/promises';
import { strictAssert } from '../util/assert.std.ts';
import { assert } from './utils/assert.mjs';
const cwd = __dirname;
const fontsDir = join(cwd, '..', '..', 'fonts');
const imagesDir = join(cwd, '..', '..', 'images');
const baseDir = join(import.meta.dirname, '..');
const fontsDir = join(baseDir, 'fonts');
const imagesDir = join(baseDir, 'images');
const trayIconsDir = join(imagesDir, 'tray-icons');
const trayIconsBaseDir = join(trayIconsDir, 'base');
const trayIconsAlertsDir = join(trayIconsDir, 'alert');
enum TrayIconSize {
Size16 = '16',
Size32 = '32',
Size48 = '48',
Size256 = '256',
}
const TrayIconSizes = /** @type {const} */ ({
Size16: '16',
Size32: '32',
Size48: '48',
Size256: '256',
});
type TrayIconValue = number | string | null;
/**
* @typedef {typeof TrayIconSizes[keyof typeof TrayIconSizes]} TrayIconSize
*/
type TrayIconImageRequest = Readonly<{
size: TrayIconSize;
value: TrayIconValue;
}>;
/**
* @typedef {number | string | null} TrayIconValue
*/
type TrayIconVariant = {
size: number;
maxCount: number;
badgePadding: number;
fontSize: number;
fontWeight: string;
fontOffsetY: number;
badgeShadowBlur: number;
badgeShadowOffsetY: number;
image: string;
};
/**
* @typedef {Readonly<{
* size: TrayIconSize;
* value: TrayIconValue;
* }>} TrayIconImageRequest
*/
/**
* @typedef {{
* size: number;
* maxCount: number;
* badgePadding: number;
* fontSize: number;
* fontWeight: string;
* fontOffsetY: number;
* badgeShadowBlur: number;
* badgeShadowOffsetY: number;
* image: string;
* }} TrayIconVariant
*/
GlobalFonts.loadFontsFromDir(fontsDir);
@@ -45,7 +55,7 @@ const Inter = GlobalFonts.families.find(family => {
return family.family === 'Inter';
});
strictAssert(Inter != null, `Failed to load fonts from ${fontsDir}`);
assert(Inter != null, `Failed to load fonts from ${fontsDir}`);
const Constants = {
fontFamily: 'Inter',
@@ -53,8 +63,9 @@ const Constants = {
badgeShadowColor: 'rgba(0, 0, 0, 0.25)',
};
const Variants: Record<TrayIconSize, TrayIconVariant> = {
[TrayIconSize.Size16]: {
/** @type {Record<TrayIconSize, TrayIconVariant>} */
const Variants = {
[TrayIconSizes.Size16]: {
size: 16,
maxCount: 9,
badgePadding: 2,
@@ -65,7 +76,7 @@ const Variants: Record<TrayIconSize, TrayIconVariant> = {
badgeShadowOffsetY: 0,
image: join(trayIconsBaseDir, 'signal-tray-icon-16x16-base.png'),
},
[TrayIconSize.Size32]: {
[TrayIconSizes.Size32]: {
size: 32,
maxCount: 9,
badgePadding: 4,
@@ -76,7 +87,7 @@ const Variants: Record<TrayIconSize, TrayIconVariant> = {
badgeShadowOffsetY: 1,
image: join(trayIconsBaseDir, 'signal-tray-icon-32x32-base.png'),
},
[TrayIconSize.Size48]: {
[TrayIconSizes.Size48]: {
size: 48,
maxCount: 9,
badgePadding: 6,
@@ -87,7 +98,7 @@ const Variants: Record<TrayIconSize, TrayIconVariant> = {
badgeShadowOffsetY: 1,
image: join(trayIconsBaseDir, 'signal-tray-icon-48x48-base.png'),
},
[TrayIconSize.Size256]: {
[TrayIconSizes.Size256]: {
size: 256,
maxCount: 9,
fontSize: 72,
@@ -100,10 +111,12 @@ const Variants: Record<TrayIconSize, TrayIconVariant> = {
},
};
function trayIconValueToText(
value: TrayIconValue,
variant: TrayIconVariant
): string {
/**
* @param {TrayIconValue} value
* @param {TrayIconVariant} variant
* @returns {string}
*/
function trayIconValueToText(value, variant) {
if (value == null) {
return '';
}
@@ -130,9 +143,11 @@ function trayIconValueToText(
throw new TypeError(`Invalid value ${value}`);
}
async function generateTrayIconImage(
request: TrayIconImageRequest
): Promise<Buffer<ArrayBuffer>> {
/**
* @param {TrayIconImageRequest} request
* @returns {Promise<Buffer<ArrayBuffer>>}
*/
async function generateTrayIconImage(request) {
const variant = Variants[request.size];
if (variant == null) {
throw new TypeError(`Invalid variant size (${request.size})`);
@@ -225,52 +240,50 @@ async function generateTrayIconImage(
return canvas.toBuffer('image/png');
}
function range(start: number, end: number): Array<number> {
/**
* @param {number} start
* @param {number} end
* @returns {Array<number>}
*/
function range(start, end) {
const length = end - start + 1;
return Array.from({ length }, (_, index) => start + index);
}
async function main() {
try {
await rm(trayIconsAlertsDir, { recursive: true });
} catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
try {
await rm(trayIconsAlertsDir, { recursive: true });
} catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
const requests: Array<TrayIconImageRequest> = [];
for (const size of Object.values(TrayIconSize)) {
const variant = Variants[size];
const { maxCount } = variant;
const values = range(1, maxCount + 1);
for (const value of values) {
requests.push({ size, value });
}
}
await Promise.all(
requests.map(async ({ size, value }) => {
const variant = Variants[size];
const text = trayIconValueToText(value, variant);
const fileDir = join(trayIconsAlertsDir);
const fileName = `signal-tray-icon-${size}x${size}-alert-${text}.png`;
const filePath = join(fileDir, fileName);
const fileContents = await generateTrayIconImage({ size, value });
console.log(`Writing "${fileName}"`);
await mkdir(fileDir, { recursive: true });
await writeFile(filePath, fileContents);
})
);
console.log('Done');
}
// oxlint-disable-next-line promise/prefer-await-to-then
main().catch(error => {
console.error(error);
process.exit(1);
});
/** @type {Array<TrayIconImageRequest>} */
const requests = [];
for (const size of Object.values(TrayIconSizes)) {
const variant = Variants[size];
const { maxCount } = variant;
const values = range(1, maxCount + 1);
for (const value of values) {
requests.push({ size, value });
}
}
await Promise.all(
requests.map(async ({ size, value }) => {
const variant = Variants[size];
const text = trayIconValueToText(value, variant);
const fileDir = join(trayIconsAlertsDir);
const fileName = `signal-tray-icon-${size}x${size}-alert-${text}.png`;
const filePath = join(fileDir, fileName);
const fileContents = await generateTrayIconImage({ size, value });
console.log(`Writing "${fileName}"`);
await mkdir(fileDir, { recursive: true });
await writeFile(filePath, fileContents);
})
);
console.log('Done');
+95
View File
@@ -0,0 +1,95 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import { writeFile, readFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { join } from 'node:path';
import z from 'zod';
import prettier from 'prettier';
import { OptionalResourcesDictSchema } from './utils/optionalResources.mjs';
/** @import { OptionalResourceType } from './utils/optionalResources.mjs' */
const MANIFEST_URL =
'https://updates.signal.org/dynamic/android/emoji/search/manifest.json';
const ManifestSchema = z.object({
version: z.number(),
languages: z.string().array(),
languageToSmartlingLocale: z.record(z.string(), z.string()),
});
/**
* @param {string} url
* @returns {Promise<unknown>}
*/
async function fetchJSON(url) {
const res = await fetch(url);
if (!res.ok) {
throw new Error(`Failed to fetch ${url}`);
}
return res.json();
}
const manifest = ManifestSchema.parse(await fetchJSON(MANIFEST_URL));
manifest.languageToSmartlingLocale['zh_TW'] = 'zh-Hant';
manifest.languageToSmartlingLocale['sr'] = 'sr';
/** @type {Map<string, OptionalResourceType>} */
const extraResources = new Map();
await Promise.all(
manifest.languages.map(async language => {
const langUrl =
'https://updates.signal.org/static/android/' +
`emoji/search/${manifest.version}/${language}.json`;
const res = await fetch(langUrl);
if (!res.ok) {
throw new Error(`Failed to fetch ${langUrl}`);
}
const data = Buffer.from(await res.arrayBuffer());
const digest = createHash('sha512').update(data).digest('base64');
let locale = manifest.languageToSmartlingLocale[language] ?? language;
locale = locale.replace(/_/g, '-');
const pinnedUrl =
'https://updates2.signal.org/static/android/' +
`emoji/search/${manifest.version}/${language}.json`;
extraResources.set(locale, {
url: pinnedUrl,
size: data.length,
digest,
});
})
);
const resourcesPath = join(
import.meta.dirname,
'..',
'build',
'optional-resources.json'
);
const resources = OptionalResourcesDictSchema.parse(
JSON.parse(await readFile(resourcesPath, 'utf8'))
);
for (const [locale, resource] of extraResources) {
resources[`emoji-index-${locale}.json`] = resource;
}
const prettierConfig = await prettier.resolveConfig(
join(import.meta.dirname, '..', 'build')
);
const output = await prettier.format(JSON.stringify(resources, null, 2), {
...prettierConfig,
filepath: resourcesPath,
});
await writeFile(resourcesPath, output);
@@ -1,13 +1,12 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import { join } from 'node:path';
import { execSync } from 'node:child_process';
import { writeFileSync } from 'node:fs';
import { DAY } from '../util/durations/index.std.ts';
import { packageJson } from '../util/packageJson.node.ts';
import { isNotUpdatable } from '../util/version.std.ts';
import { DAY } from './utils/durations.mjs';
import { parseVersion } from './utils/parseVersion.mjs';
import packageJson from '../package.json' with { type: 'json' };
const unixTimestamp = parseInt(
process.env.SOURCE_DATE_EPOCH ||
@@ -16,20 +15,22 @@ const unixTimestamp = parseInt(
);
const buildCreation = unixTimestamp * 1000;
const isNotUpdatable = !parseVersion(packageJson.version).isUpdatable;
// NB: Build expirations are also determined via users' auto-update settings; see
// getExpirationTimestamp
const validDuration = isNotUpdatable(packageJson.version) ? DAY * 30 : DAY * 90;
const validDuration = isNotUpdatable ? DAY * 30 : DAY * 90;
const buildExpiration = buildCreation + validDuration;
const localProductionPath = join(
__dirname,
'../../config/local-production.json'
import.meta.dirname,
'../config/local-production.json'
);
const localProductionConfig = {
buildCreation,
buildExpiration,
...(isNotUpdatable(packageJson.version) ? { updatesEnabled: false } : {}),
...(isNotUpdatable ? { updatesEnabled: false } : {}),
};
writeFileSync(
+100
View File
@@ -0,0 +1,100 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import { writeFile, readFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { join } from 'node:path';
import { Buffer } from 'node:buffer';
import z from 'zod';
import prettier from 'prettier';
import { OptionalResourcesDictSchema } from './utils/optionalResources.mjs';
import { utf16ToEmoji } from './utils/utf16ToEmoji.mjs';
/** @import { OptionalResourceType } from './utils/optionalResources.mjs'; */
const VERSION = 12;
const STATIC_URL = 'https://updates.signal.org/static/android/emoji';
const STATIC_PINNED_URL = 'https://updates2.signal.org/static/android/emoji';
const MANIFEST_URL = `${STATIC_URL}/${VERSION}/emoji_data.json`;
const ManifestSchema = z.object({
jumbomoji: z.record(z.string(), z.string().transform(utf16ToEmoji).array()),
});
/**
* @param {string} url
* @returns {Promise<unknown>}
*/
async function fetchJSON(url) {
const res = await fetch(url);
if (!res.ok) {
throw new Error(`Failed to fetch ${url}`);
}
return res.json();
}
const { jumbomoji } = ManifestSchema.parse(await fetchJSON(MANIFEST_URL));
/** @type {Map<string, OptionalResourceType>} */
const extraResources = new Map();
await Promise.all(
Array.from(Object.keys(jumbomoji)).map(async sheet => {
const publicUrl = `${STATIC_URL}/${VERSION}/xhdpi/jumbo/${sheet}.proto`;
const res = await fetch(publicUrl);
if (!res.ok) {
throw new Error(`Failed to fetch ${publicUrl}`);
}
const data = Buffer.from(await res.arrayBuffer());
const digest = createHash('sha512').update(data).digest('base64');
const pinnedUrl = `${STATIC_PINNED_URL}/${VERSION}/xhdpi/jumbo/${sheet}.proto`;
extraResources.set(sheet, {
url: pinnedUrl,
size: data.length,
digest,
});
})
);
const manifestPath = join(import.meta.dirname, '..', 'build', 'jumbomoji.json');
const resourcesPath = join(
import.meta.dirname,
'..',
'build',
'optional-resources.json'
);
const resources = OptionalResourcesDictSchema.parse(
JSON.parse(await readFile(resourcesPath, 'utf8'))
);
for (const [sheet, resource] of extraResources) {
resources[`emoji-sheet-${sheet}.proto`] = resource;
}
const prettierConfig = await prettier.resolveConfig(
join(import.meta.dirname, '..', 'build')
);
{
const output = await prettier.format(JSON.stringify(jumbomoji, null, 2), {
...prettierConfig,
filepath: manifestPath,
});
await writeFile(manifestPath, output);
}
{
const output = await prettier.format(JSON.stringify(resources, null, 2), {
...prettierConfig,
filepath: resourcesPath,
});
await writeFile(resourcesPath, output);
}
+144
View File
@@ -0,0 +1,144 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import { rm, mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import fastGlob from 'fast-glob';
import prettier from 'prettier';
import pMap from 'p-map';
import z from 'zod';
import { authenticate, API_BASE, PROJECT_ID } from './utils/smartling.mjs';
const { SMARTLING_USER, SMARTLING_SECRET } = process.env;
const RENAMES = new Map([
// Smartling uses "zh-YU" for Cantonese (or Yue Chinese).
// This is wrong.
// The language tag for Yue Chinese is "yue"
// "zh-YU" actually implies "Chinese as spoken in Yugoslavia (canonicalized to Serbia)"
['zh-YU', 'yue'],
// For most of the Chinese-speaking world, where we don't have a region specific
// locale available (e.g. zh-HK), zh-TW is a suitable choice for "Traditional Chinese".
//
// However, Intl.LocaleMatcher won't match "zh-Hant-XX" to "zh-TW",
// we need to rename it to "zh-Hant" explicitly to make it work.
['zh-TW', 'zh-Hant'],
// "YR" is not a valid region subtag. Smartling made it up.
['sr-YR', 'sr'],
]);
const StatusSchema = z.object({
response: z.object({
code: z.literal('SUCCESS'),
data: z.object({
items: z
.object({
localeId: z.string(),
})
.array(),
}),
}),
});
if (!SMARTLING_USER) {
console.error('Need to set SMARTLING_USER environment variable!');
process.exit(1);
}
if (!SMARTLING_SECRET) {
console.error('Need to set SMARTLING_SECRET environment variable!');
process.exit(1);
}
console.log('Authenticating with Smartling');
const headers = await authenticate({
userIdentifier: SMARTLING_USER,
userSecret: SMARTLING_SECRET,
});
const statusURL = new URL(
`./files-api/v2/projects/${PROJECT_ID}/file/status`,
API_BASE
);
statusURL.searchParams.set('fileUri', '_locales/en/messages.json');
console.log('Getting list of locales...');
const statusRes = await fetch(statusURL, {
headers,
});
if (!statusRes.ok) {
throw new Error('Failed to fetch the status');
}
if (!statusRes.body) {
throw new Error('Missing body');
}
const {
response: {
data: { items: locales },
},
} = StatusSchema.parse(await statusRes.json());
console.log('Cleaning _locales directory...');
const dirEntries = await fastGlob(['_locales/*', '!_locales/en'], {
onlyDirectories: true,
absolute: true,
});
await Promise.all(
dirEntries.map(dirEntry => rm(dirEntry, { recursive: true }))
);
console.log('Getting latest strings');
const prettierConfig = await prettier.resolveConfig('_locales');
await pMap(
locales,
async ({ localeId }) => {
const fileURL = new URL(
`./files-api/v2/projects/${PROJECT_ID}/` +
`locales/${encodeURIComponent(localeId)}/file`,
API_BASE
);
fileURL.searchParams.set('fileUri', '_locales/en/messages.json');
fileURL.searchParams.set('retrievalType', 'published');
fileURL.searchParams.set('includeOriginalStrings', 'true');
const fileRes = await fetch(fileURL, {
headers,
});
if (!fileRes.ok) {
throw new Error('Failed to fetch the file');
}
if (!fileRes.body) {
throw new Error('Missing body');
}
const targetLocale = RENAMES.get(localeId) ?? localeId;
const targetDir = path.join('_locales', targetLocale);
try {
await mkdir(targetDir);
} catch (error) {
console.error(error);
}
const targetFile = path.join(targetDir, 'messages.json');
console.log('Writing', targetLocale);
const json = await fileRes.json();
for (const value of Object.values(json)) {
const typedValue = /** @type {{ description?: string }} */ (value);
delete typedValue.description;
}
delete json.smartling;
const output = await prettier.format(JSON.stringify(json, null, 2), {
...prettierConfig,
filepath: targetFile,
});
await writeFile(targetFile, output);
},
{ concurrency: 20 }
);
@@ -1,14 +1,18 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { readFileSync, writeFileSync } from 'node:fs';
import { DELETED_REGEXP } from './constants.std.ts';
import { DELETED_REGEXP } from './utils/intlMessages.mjs';
const rootDir = path.resolve(__dirname, '..', '..');
const rootDir = path.resolve(import.meta.dirname, '..');
const messagesPath = path.join(rootDir, '_locales/en/messages.json');
function getIcuLikeStrings(): Set<string> {
/**
* @returns {Set<string>}
*/
function getIcuLikeStrings() {
const { status, stdout } = spawnSync(
'grep',
[
@@ -36,7 +40,10 @@ function getIcuLikeStrings(): Set<string> {
return new Set(stdout.trim().split('\n'));
}
function getDateStr(date: Date) {
/**
* @param {Date} date
*/
function getDateStr(date) {
const year = String(date.getFullYear());
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
@@ -1,10 +1,10 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import { spawnSync } from 'node:child_process';
import { join } from 'node:path';
const MOCHA = join(__dirname, '..', '..', 'node_modules', '.bin', 'mocha');
const MOCHA = join(import.meta.dirname, '..', 'node_modules', '.bin', 'mocha');
const WORKER_COUNT = parseInt(process.env.WORKER_COUNT || '1', 10);
const WORKER_INDEX = parseInt(process.env.WORKER_INDEX || '0', 10);
@@ -1,16 +1,20 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { BuildResult } from 'electron-builder';
// @ts-check
import { notarize } from '@electron/notarize';
import { assert } from './utils/assert.mjs';
import packageJson from '../package.json' with { type: 'json' };
import { packageJson } from '../util/packageJson.node.ts';
/** @import { BuildResult } from 'electron-builder' */
/**
* @param {BuildResult} result
* @returns {Promise<void>}
*/
export async function afterAllArtifactBuild({
platformToTargets,
artifactPaths,
}: BuildResult): Promise<void> {
}) {
const platforms = Array.from(platformToTargets.keys()).map(
platform => platform.name
);
@@ -63,8 +67,8 @@ export async function afterAllArtifactBuild({
return;
}
// oxlint-disable-next-line typescript/no-non-null-assertion
const dmgPath = artifactsToStaple[0]!;
const dmgPath = artifactsToStaple[0];
assert(dmgPath != null, 'Missing dmgPath');
console.log(`Notarizing dmg: ${dmgPath}`);
await notarize({
@@ -1,18 +1,17 @@
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import path from 'node:path';
import type { AfterPackContext } from 'electron-builder';
import { notarize } from '@electron/notarize';
import packageJson from '../package.json' with { type: 'json' };
import { packageJson } from '../util/packageJson.node.ts';
/** @import { AfterPackContext } from 'electron-builder' */
export async function afterSign({
appOutDir,
packager,
electronPlatformName,
}: AfterPackContext): Promise<void> {
/**
* @param {AfterPackContext} context
* @returns {Promise<void>}
*/
export async function afterSign({ appOutDir, packager, electronPlatformName }) {
if (electronPlatformName !== 'darwin') {
console.log('notarize: Skipping, not on macOS');
return;
-15
View File
@@ -1,15 +0,0 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
const { readFileSync } = require('node:fs');
const { join } = require('node:path');
const PACKAGE_JSON_PATH = join(__dirname, '..', 'package.json');
const json = JSON.parse(readFileSync(PACKAGE_JSON_PATH, 'utf8'));
exports.default = json;
exports.name = json.name;
exports.version = json.version;
exports.productName = json.productName;
exports.build = json.build;
@@ -1,13 +1,10 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import fs from 'node:fs';
import { join } from 'node:path';
const PACKAGE_FILE = join(__dirname, '..', '..', 'package.json');
const PACKAGE_FILE = join(import.meta.dirname, '..', 'package.json');
const json = JSON.parse(fs.readFileSync(PACKAGE_FILE, { encoding: 'utf8' }));
@@ -1,11 +1,11 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
const fs = require('node:fs');
const _ = require('lodash');
const { execSync } = require('node:child_process');
const { isAdhoc } = require('../ts/util/version.std.ts');
const { default: packageJson, version } = require('./packageJson.js');
// @ts-check
import fs from 'node:fs';
import _ from 'lodash';
import { execSync } from 'node:child_process';
import { parseVersion } from './utils/parseVersion.mjs';
import packageJson from '../package.json' with { type: 'json' };
// You might be wondering why this file is necessary. It comes down to our desire to allow
// side-by-side installation of production and adhoc builds. Electron-Builder uses
@@ -13,8 +13,8 @@ const { default: packageJson, version } = require('./packageJson.js');
// debian package name, the install directory under /opt on linux, etc. We tried
// adding the ${channel} macro to these values, but Electron-Builder didn't like that.
if (!isAdhoc(version)) {
console.error(`Version '${version}' is not an adhoc version!`);
if (parseVersion(packageJson.version).channel !== 'adhoc') {
console.error(`Version '${packageJson.version}' is not an adhoc version!`);
process.exit(1);
}
@@ -73,6 +73,11 @@ const ADHOC_EXECUTABLE_NAME = `signal-desktop-adhoc-${formattedDate}-${shortSha}
// -------
/**
* @param {object} object
* @param {string} objectPath
* @param {string} expected
*/
function checkValue(object, objectPath, expected) {
const actual = _.get(object, objectPath);
if (actual !== expected) {
@@ -1,11 +1,10 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
const fs = require('node:fs');
const _ = require('lodash');
const { isAlpha } = require('../ts/util/version.std.ts');
const { default: packageJson, version } = require('./packageJson.js');
// @ts-check
import fs from 'node:fs';
import _ from 'lodash';
import { parseVersion } from './utils/parseVersion.mjs';
import packageJson from '../package.json' with { type: 'json' };
// You might be wondering why this file is necessary. It comes down to our desire to allow
// side-by-side installation of production and alpha builds. Electron-Builder uses
@@ -13,8 +12,8 @@ const { default: packageJson, version } = require('./packageJson.js');
// debian package name, the install directory under /opt on linux, etc. We tried
// adding the ${channel} macro to these values, but Electron-Builder didn't like that.
if (!isAlpha(version)) {
console.error(`Version '${version}' is not an alpha version!`);
if (parseVersion(packageJson.version).channel !== 'alpha') {
console.error(`Version '${packageJson.version}' is not an alpha version!`);
process.exit(1);
}
@@ -51,6 +50,11 @@ const ALPHA_EXECUTABLE_NAME = 'signal-desktop-alpha';
// -------
/**
* @param {object} object
* @param {string} objectPath
* @param {string} expected
*/
function checkValue(object, objectPath, expected) {
const actual = _.get(object, objectPath);
if (actual !== expected) {
@@ -1,11 +1,11 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import fs from 'node:fs';
import _ from 'lodash';
const fs = require('node:fs');
const _ = require('lodash');
const { isAxolotl } = require('../ts/util/version.std.ts');
const { default: packageJson, version } = require('./packageJson.js');
import { parseVersion } from './utils/parseVersion.mjs';
import packageJson from '../package.json' with { type: 'json' };
// You might be wondering why this file is necessary. It comes down to our desire to allow
// side-by-side installation of production and alpha builds. Electron-Builder uses
@@ -13,12 +13,12 @@ const { default: packageJson, version } = require('./packageJson.js');
// debian package name, the install directory under /opt on linux, etc. We tried
// adding the ${channel} macro to these values, but Electron-Builder didn't like that.
if (!isAxolotl(version)) {
console.error(`Version '${version}' is not an backup version!`);
if (parseVersion(packageJson.version).channel !== 'axolotl') {
console.error(`Version '${packageJson.version}' is not an axolotl version!`);
process.exit(1);
}
console.log('prepare_backup_build: updating package.json');
console.log('prepare_axolotl_build: updating package.json');
// -------
@@ -51,6 +51,11 @@ const AXOLOTL_EXECUTABLE_NAME = 'signal-desktop-axolotl';
// -------
/**
* @param {object} object
* @param {string} objectPath
* @param {string} expected
*/
function checkValue(object, objectPath, expected) {
const actual = _.get(object, objectPath);
if (actual !== expected) {
@@ -1,11 +1,11 @@
// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import fs from 'node:fs';
import _ from 'lodash';
const fs = require('node:fs');
const _ = require('lodash');
const { isBeta } = require('../ts/util/version.std.ts');
const { default: packageJson, version } = require('./packageJson.js');
import { parseVersion } from './utils/parseVersion.mjs';
import packageJson from '../package.json' with { type: 'json' };
// You might be wondering why this file is necessary. It comes down to our desire to allow
// side-by-side installation of production and beta builds. Electron-Builder uses
@@ -13,7 +13,8 @@ const { default: packageJson, version } = require('./packageJson.js');
// debian package name, the install directory under /opt on linux, etc. We tried
// adding the ${channel} macro to these values, but Electron-Builder didn't like that.
if (!isBeta(version)) {
if (parseVersion(packageJson.version).channel !== 'beta') {
// ignore
process.exit();
}
@@ -50,6 +51,11 @@ const BETA_EXECUTABLE_NAME = 'signal-desktop-beta';
// -------
/**
* @param {object} object
* @param {string} objectPath
* @param {string} expected
*/
function checkValue(object, objectPath, expected) {
const actual = _.get(object, objectPath);
if (actual !== expected) {
@@ -1,8 +1,9 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
const fs = require('node:fs');
const _ = require('lodash');
// @ts-check
import fs from 'node:fs';
import _ from 'lodash';
import packageJson from '../package.json' with { type: 'json' };
const TARGETS = new Set(['appimage', 'deb']);
@@ -17,8 +18,6 @@ if (
process.exit(1);
}
const { default: packageJson } = require('./packageJson.js');
console.log('prepare_linux_build: updating package.json');
// ------
@@ -1,11 +1,10 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
const fs = require('node:fs');
const _ = require('lodash');
const { isAlpha } = require('../ts/util/version.std.ts');
const { default: packageJson, version } = require('./packageJson.js');
// @ts-check
import fs from 'node:fs';
import _ from 'lodash';
import { parseVersion } from './utils/parseVersion.mjs';
import packageJson from '../package.json' with { type: 'json' };
// You might be wondering why this file is necessary. It comes down to our desire to allow
// side-by-side installation of production and staging builds. Electron-Builder uses
@@ -13,8 +12,8 @@ const { default: packageJson, version } = require('./packageJson.js');
// debian package name, the install directory under /opt on linux, etc. We tried
// adding the ${channel} macro to these values, but Electron-Builder didn't like that.
if (!isAlpha(version)) {
console.error(`Version '${version}' is not an alpha version!`);
if (parseVersion(packageJson.version).channel !== 'alpha') {
console.error(`Version '${packageJson.version}' is not an alpha version!`);
process.exit(1);
}
@@ -23,7 +22,7 @@ console.log('prepare_staging_build: updating package.json');
// -------
const VERSION_PATH = 'version';
const STAGING_VERSION = version.replace('alpha', 'staging');
const STAGING_VERSION = packageJson.version.replace('alpha', 'staging');
const NAME_PATH = 'name';
const PRODUCTION_NAME = 'signal-desktop';
@@ -54,6 +53,11 @@ const STAGING_EXECUTABLE_NAME = 'signal-desktop-staging';
// -------
/**
* @param {object} object
* @param {string} objectPath
* @param {string} expected
*/
function checkValue(object, objectPath, expected) {
const actual = _.get(object, objectPath);
if (actual !== expected) {
@@ -1,14 +1,11 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
const fs = require('node:fs');
const { execSync } = require('node:child_process');
const _ = require('lodash');
const {
default: packageJson,
version: currentVersion,
} = require('./packageJson.js');
// @ts-check
import fs from 'node:fs';
import { execSync } from 'node:child_process';
import _ from 'lodash';
import { generateTaggedVersion } from './utils/generateTaggedVersion.mjs';
import packageJson from '../package.json' with { type: 'json' };
const release = process.argv[2];
if (release !== 'alpha' && release !== 'axolotl' && release !== 'adhoc') {
@@ -16,12 +13,11 @@ if (release !== 'alpha' && release !== 'axolotl' && release !== 'adhoc') {
process.exit(1);
}
const { generateTaggedVersion } = require('../ts/util/version.std.ts');
const shortSha = execSync('git rev-parse --short=9 HEAD')
.toString('utf8')
.replace(/[\n\r]/g, '');
const currentVersion = packageJson.version;
const newVersion = generateTaggedVersion({ release, currentVersion, shortSha });
console.log(
@@ -1,11 +1,16 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { AfterPackContext } from 'electron-builder';
// @ts-check
import fs, { readdir, rm } from 'node:fs/promises';
import path from 'node:path';
async function safeReaddir(dir: string): Promise<Array<string> | null> {
/** @import { AfterPackContext } from 'electron-builder' */
/**
* @param {string} dir
* @returns {Promise<Array<string> | null>}
*/
async function safeReaddir(dir) {
try {
return await readdir(dir);
} catch (error) {
@@ -16,11 +21,11 @@ async function safeReaddir(dir: string): Promise<Array<string> | null> {
}
}
export async function afterPack({
appOutDir,
packager,
electronPlatformName,
}: AfterPackContext): Promise<void> {
/**
* @param {AfterPackContext} context
* @returns {Promise<void>}
*/
export async function afterPack({ appOutDir, packager, electronPlatformName }) {
if (electronPlatformName !== 'darwin') {
return;
}
+62
View File
@@ -0,0 +1,62 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import { stat } from 'node:fs/promises';
import { join } from 'node:path';
import { assert } from './utils/assert.mjs';
import packageJson from '../package.json' with { type: 'json' };
const NAME = packageJson.name;
const VERSION = packageJson.version;
const SUPPORT_CONFIG = new Set([
'linux',
'windows',
'macos-arm64',
'macos-x64',
'macos-universal',
]);
const RELEASE_DIR = join(import.meta.dirname, '..', 'release');
// TODO: DESKTOP-9836
const config = process.argv[2];
assert(config != null, 'Missing config arg');
if (!SUPPORT_CONFIG.has(config)) {
throw new Error(`Invalid argument: ${config}`);
}
/** @type {string} */
let fileName;
/** @type {string} */
let platform;
/** @type {string} */
let arch;
if (config === 'linux') {
fileName = `${NAME}_${VERSION}_amd64.deb`;
platform = 'linux';
arch = 'x64';
} else if (config === 'windows') {
fileName = `${NAME}-win-x64-${VERSION}.exe`;
platform = 'windows';
arch = 'x64';
} else if (config === 'macos-arm64') {
fileName = `${NAME}-mac-arm64-${VERSION}.zip`;
platform = 'macos';
arch = 'arm64';
} else if (config === 'macos-x64') {
fileName = `${NAME}-mac-x64-${VERSION}.zip`;
platform = 'macos';
arch = 'x64';
} else if (config === 'macos-universal') {
fileName = `${NAME}-mac-universal-${VERSION}.dmg`;
platform = 'macos';
arch = 'universal';
} else {
throw new Error(`Unsupported config: ${config}`);
}
const filePath = join(RELEASE_DIR, fileName);
const { size } = await stat(filePath);
console.log(`${platform} ${arch} release size: ${size}`);
+60
View File
@@ -0,0 +1,60 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import { randomBytes } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { API_BASE, PROJECT_ID, authenticate } from './utils/smartling.mjs';
const { SMARTLING_USER, SMARTLING_SECRET } = process.env;
if (!SMARTLING_USER) {
console.error('Need to set SMARTLING_USER environment variable!');
process.exit(1);
}
if (!SMARTLING_SECRET) {
console.error('Need to set SMARTLING_SECRET environment variable!');
process.exit(1);
}
console.log('Authenticating with Smartling');
const headers = await authenticate({
userIdentifier: SMARTLING_USER,
userSecret: SMARTLING_SECRET,
});
const boundaryString = randomBytes(32).toString('hex');
headers.set('content-type', `multipart/form-data; boundary=${boundaryString}`);
const url = new URL(`./files-api/v2/projects/${PROJECT_ID}/file`, API_BASE);
const body = [
`--${boundaryString}`,
'Content-Disposition: form-data; name="fileUri"',
'Content-Type: text/plain',
'',
'_locales/en/messages.json',
`--${boundaryString}`,
'Content-Disposition: form-data; name="fileType"',
'Content-Type: text/plain',
'',
'json',
`--${boundaryString}`,
'Content-Disposition: form-data; name="file"; filename="_locales/en/messages.json"',
'Content-Type: text/plain',
'',
await readFile('_locales/en/messages.json', 'utf8'),
`--${boundaryString}--`,
'',
];
console.log('Pushing strings');
const res = await fetch(url, {
method: 'POST',
headers,
body: body.join('\r\n'),
});
if (!res.ok) {
throw new Error(`Failed to push strings: ${await res.text()}`);
}
+103
View File
@@ -0,0 +1,103 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import chalk from 'chalk';
import execa from 'execa';
import fs from 'node:fs/promises';
import pLimit from 'p-limit';
import path from 'node:path';
import { DAY } from './utils/durations.mjs';
import { DELETED_REGEXP } from './utils/intlMessages.mjs';
import { assert } from './utils/assert.mjs';
const ROOT_DIR = path.join(import.meta.dirname, '..');
const MESSAGES_FILE = path.join(ROOT_DIR, '_locales', 'en', 'messages.json');
const MAX_AGE = 30 * DAY;
const limitter = pLimit(10);
const removeBefore = Date.now() - MAX_AGE;
const messages = JSON.parse(await fs.readFile(MESSAGES_FILE, 'utf-8'));
/** @type {Set<string>} */
const stillUsed = new Set();
await Promise.all(
Object.keys(messages).map(key =>
limitter(async () => {
/** @type {{ description?: string }} */
const value = messages[key];
const match = value.description?.match(DELETED_REGEXP);
if (!match) {
return;
}
const deletedAtStr = match[1];
assert(deletedAtStr != null, 'Missing deletedAtStr');
const deletedAt = new Date(deletedAtStr).getTime();
if (deletedAt <= removeBefore) {
return;
}
// Find uses in either:
// - `i18n('key')`
// - `<I18n id="key"/>`
try {
const result = await execa(
'git',
// prettier-ignore
[
'grep',
'--extended-regexp',
`'${key}'|id="${key}"`,
'--',
'**',
':!\\_locales/**',
':!\\sticker-creator/**',
],
{
cwd: ROOT_DIR,
stdin: 'ignore',
stdout: 'pipe',
stderr: 'inherit',
}
);
// Match found
console.error(
chalk.red(
`ERROR: String is still used: "${key}", deleted on ${match[1]}`
)
);
console.error(result.stdout.trim());
console.error('');
stillUsed.add(key);
} catch (error) {
if (error.exitCode === 1) {
console.log(
chalk.dim(`Removing string: "${key}", deleted on ${match[1]}`)
);
delete messages[key];
} else {
throw error;
}
}
})
)
);
if (stillUsed.size !== 0) {
console.error(
`ERROR: Didn't remove ${stillUsed.size} strings because of errors above`,
Array.from(stillUsed)
.map(str => `- ${str}`)
.join('\n')
);
console.error('ERROR: Not saving changes');
process.exit(1);
}
await fs.writeFile(MESSAGES_FILE, `${JSON.stringify(messages, null, 2)}\n`);
@@ -1,14 +1,22 @@
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import { execSync } from 'node:child_process';
import { realpath } from 'node:fs/promises';
import fsExtra from 'fs-extra';
/** @import { MacConfiguration } from 'electron-builder' */
const { realpath } = fsExtra;
/**
* Types not exported by electron-builder
* @typedef {Extract<MacConfiguration['sign'], Function>} CustomMacSign
* @typedef {Parameters<CustomMacSign>[0]} CustomMacSignOptions
*/
// oxlint-disable-next-line typescript/explicit-module-boundary-types, typescript/no-explicit-any
export async function sign(configuration: any): Promise<void> {
/**
* @param {CustomMacSignOptions} configuration
* @returns {Promise<void>}
*/
export async function sign(configuration) {
if (process.env.SKIP_SIGNING_SCRIPT === '1') {
console.log('SKIP_SIGNING_SCRIPT=1, skipping custom macOS signing script');
return;
@@ -1,17 +1,16 @@
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import { execSync } from 'node:child_process';
import { realpath } from 'node:fs/promises';
import fsExtra from 'fs-extra';
/** @import { CustomWindowsSignTaskConfiguration } from 'electron-builder' */
import type { CustomWindowsSignTaskConfiguration } from 'electron-builder';
const { realpath } = fsExtra;
export async function sign(
configuration: CustomWindowsSignTaskConfiguration
): Promise<void> {
/**
* @param {CustomWindowsSignTaskConfiguration} configuration
* @returns {Promise<void>}
*/
export async function sign(configuration) {
// In CI, we remove certificate information from package.json to disable signing
if (
!configuration.options.signtoolOptions ||
+111
View File
@@ -0,0 +1,111 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import { tmpdir } from 'node:os';
import { readFile, writeFile, mkdtemp } from 'node:fs/promises';
import { gunzip as gunzipCb } from 'node:zlib';
import { join, basename } from 'node:path';
import { promisify } from 'node:util';
import { symbolicate } from '@electron/symbolicate-mac';
import pMap from 'p-map';
import { assert } from './utils/assert.mjs';
/// <reference path="./utils/symbolicate-mac.d.ts">
const gunzip = promisify(gunzipCb);
if (!process.argv[2]) {
throw new Error('Usage: node symbolicate-crash-report.js <input>');
}
const INPUT_FILE = process.argv[2];
let file = await readFile(INPUT_FILE);
try {
file = await gunzip(file);
} catch (error) {
console.error('Failed to decompress, perhaps file is a plaintext?');
}
const matches = file
.toString()
.matchAll(
/WARN[^\n]*crashReports:\s+dump=\[REDACTED\]([0-9a-z]+).dmp\s+mtime="([\d\-T:.Z]+)"\s+({(\n|.)*?\n})/g
);
/**
* @param {string | undefined} filename
* @returns {string}
*/
function moduleName(filename) {
if (!filename) {
return '';
}
if (filename.startsWith('signal-desktop-')) {
return 'electron';
}
if (filename.startsWith('Signal') && filename.endsWith('.exe')) {
return 'electron.exe.pdb';
}
return filename;
}
/** @type {Array<string>} */
const dumps = [];
for (const match of matches) {
const [, dump, mtime, json] = match;
assert(dump != null, 'Missing dump');
assert(mtime != null, 'Missing mtime');
assert(json != null, 'Missing json');
const out = [];
let info;
try {
info = JSON.parse(json);
} catch (error) {
console.error('Failed to parse JSON, ignoring', dump, mtime, error);
continue;
}
out.push(`## dump=${dump} mtime=${mtime}`);
out.push('');
out.push('```');
for (const [index, frame] of info.crashing_thread.frames.entries()) {
out.push(`${index} ${moduleName(frame.module)} ${frame.offset} () + 0`);
}
out.push('');
for (const m of info.modules) {
const filename = moduleName(m.filename);
out.push(
`${m.base_addr} - ${m.end_addr} ` +
`${filename.replace(/\s+/g, '-')} (${m.version}) ` +
`<${m.debug_id.slice(0, -1)}> ${filename}`
);
}
out.push('```');
dumps.push(out.join('\n'));
}
const tmpFolder = await mkdtemp(join(tmpdir(), 'parse-crash-reports'));
const result = await pMap(
dumps,
async (text, i) => {
const tmpFile = join(tmpFolder, `${i}.txt`);
await writeFile(tmpFile, text);
console.error(`Symbolicating: ${tmpFile}`);
return symbolicate({ file: tmpFile });
},
{ concurrency: 1 }
);
console.log(`# Crash Report ${basename(INPUT_FILE)}`);
console.log('');
console.log(result.join('\n'));
@@ -1,6 +1,6 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import { spawn } from 'node:child_process';
import path, { join } from 'node:path';
import { pipeline } from 'node:stream/promises';
@@ -10,15 +10,15 @@ import crypto from 'node:crypto';
import z from 'zod';
import split2 from 'split2';
import logSymbols from 'log-symbols';
import { unreachable } from './utils/assert.mjs';
import { SECOND } from './utils/durations.mjs';
import { explodePromise } from '../util/explodePromise.std.ts';
import { missingCaseError } from '../util/missingCaseError.std.ts';
import { SECOND } from '../util/durations/index.std.ts';
import { parseUnknown } from '../util/schemas.std.ts';
const ROOT_DIR = join(import.meta.dirname, '..');
const ROOT_DIR = join(__dirname, '..', '..');
function getWorkerCount(): number {
/**
* @returns {number}
*/
function getWorkerCount() {
if (process.env.WORKER_COUNT) {
return parseInt(process.env.WORKER_COUNT, 10);
}
@@ -46,7 +46,7 @@ const failureSchema = z.object({
error: z.string(),
});
type Failure = z.infer<typeof failureSchema>;
/** @typedef {z.infer<typeof failureSchema>} Failure */
const eventSchema = z
.object({
@@ -61,10 +61,12 @@ const eventSchema = z
})
);
async function launchElectron(
worker: number,
attempt: number
): Promise<{ pass: number; failures: Array<Failure> }> {
/**
* @param {number} worker
* @param {number} attempt
* @returns {Promise<{ pass: number; failures: Array<Failure> }>}
*/
async function launchElectron(worker, attempt) {
if (attempt > MAX_RETRIES) {
console.error(`Failed after ${MAX_RETRIES} retries, exiting.`);
process.exit(1);
@@ -108,9 +110,11 @@ async function launchElectron(
}
);
const { resolve, reject, promise: exitPromise } = explodePromise<void>();
/** @type {PromiseWithResolvers<void>} */
const { resolve, reject, promise: exitPromise } = Promise.withResolvers();
let exitSignal: string | undefined;
/** @type {string | undefined} */
let exitSignal;
proc.on('exit', (code, signal) => {
if (code === 0) {
resolve();
@@ -121,7 +125,8 @@ async function launchElectron(
});
let pass = 0;
const failures = new Array<Failure>();
/** @type {Array<Failure>} */
const failures = [];
let done = false;
try {
@@ -149,10 +154,7 @@ async function launchElectron(
return;
}
const event = parseUnknown(
eventSchema,
JSON.parse(match[1]) as unknown
);
const event = eventSchema.parse(JSON.parse(match[1]));
if (event.type === 'pass') {
pass += 1;
@@ -174,7 +176,7 @@ async function launchElectron(
} else if (event.type === 'end') {
done = true;
} else {
throw missingCaseError(event);
unreachable(event);
}
})
),
@@ -211,49 +213,10 @@ async function launchElectron(
return { pass, failures };
}
async function main() {
const promises = [];
for (let i = 0; i < WORKER_COUNT; i += 1) {
promises.push(launchElectron(i, 1));
}
const results = await Promise.all(promises);
let pass = 0;
let failures = new Array<Failure>();
for (const result of results) {
pass += result.pass;
failures = failures.concat(result.failures);
}
if (failures.length) {
console.error('');
console.error('Failing tests:');
console.error('');
for (const { title, error } of failures) {
console.log(` ${logSymbols.error} ${title.join(' ')}`);
console.log(error);
console.log('');
}
}
console.log('');
console.log(
`Passed ${pass} | Failed ${failures.length} | ` +
`Total ${pass + failures.length}`
);
if (failures.length !== 0) {
process.exit(1);
}
}
// oxlint-disable-next-line promise/prefer-await-to-then
main().catch(error => {
console.error(error);
process.exit(1);
});
async function makeArtifactsDir(): Promise<string | undefined> {
/**
* @returns {Promise<string | undefined>}
*/
async function makeArtifactsDir() {
const { ARTIFACTS_DIR } = process.env;
if (!ARTIFACTS_DIR) {
console.log('\nTo save artifacts, please set ARTIFACTS_DIR env variable\n');
@@ -267,3 +230,38 @@ async function makeArtifactsDir(): Promise<string | undefined> {
return outDir;
}
const promises = [];
for (let i = 0; i < WORKER_COUNT; i += 1) {
promises.push(launchElectron(i, 1));
}
const results = await Promise.all(promises);
let pass = 0;
/** @type {Array<Failure>} */
let failures = [];
for (const result of results) {
pass += result.pass;
failures = failures.concat(result.failures);
}
if (failures.length) {
console.error('');
console.error('Failing tests:');
console.error('');
for (const { title, error } of failures) {
console.log(` ${logSymbols.error} ${title.join(' ')}`);
console.log(error);
console.log('');
}
}
console.log('');
console.log(
`Passed ${pass} | Failed ${failures.length} | ` +
`Total ${pass + failures.length}`
);
if (failures.length !== 0) {
process.exit(1);
}
@@ -1,23 +1,22 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import asar from '@electron/asar';
import assert from 'node:assert';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { mkdtemp, cp } from 'node:fs/promises';
import { mkdtemp, cp, rm } from 'node:fs/promises';
import { constants as fsConstants } from 'node:fs';
import { _electron as electron } from 'playwright';
import { packageJson } from '../util/packageJson.node.ts';
import { gracefulRmRecursive } from '../util/gracefulFs.node.ts';
import { consoleLogger } from '../util/consoleLogger.std.ts';
import packageJson from '../package.json' with { type: 'json' };
const ENVIRONMENT = 'production';
const RELEASE_DIR = join(__dirname, '..', '..', 'release');
const RELEASE_DIR = join(import.meta.dirname, '..', 'release');
let archive: string;
let exe: string;
/** @type {string} */
let archive;
/** @type {string} */
let exe;
if (process.platform === 'darwin') {
archive = join(
'mac-arm64',
@@ -60,43 +59,40 @@ for (const fileName of files) {
}
// A simple test to verify a visible window is opened with a title
const main = async () => {
const tmpFolder = await mkdtemp(join(tmpdir(), 'test-release'));
const tmpApp = join(tmpFolder, 'Signal');
try {
await cp(RELEASE_DIR, tmpApp, {
recursive: true,
mode: fsConstants.COPYFILE_FICLONE,
});
const tmpFolder = await mkdtemp(join(tmpdir(), 'test-release'));
const tmpApp = join(tmpFolder, 'Signal');
const executablePath = join(tmpApp, exe);
console.log('Starting path', executablePath);
const app = await electron.launch({
executablePath,
locale: 'en',
cwd: tmpApp,
});
try {
await cp(RELEASE_DIR, tmpApp, {
recursive: true,
mode: fsConstants.COPYFILE_FICLONE,
});
console.log('Waiting for a first window');
const window = await app.firstWindow();
const executablePath = join(tmpApp, exe);
console.log('Starting path', executablePath);
const app = await electron.launch({
executablePath,
locale: 'en',
cwd: tmpApp,
});
console.log('Waiting for app to fully load');
await window.waitForSelector(
'.App, .app-loading-screen:has-text("Optimizing")'
);
console.log('Waiting for a first window');
const window = await app.firstWindow();
console.log('Checking window title');
assert.strictEqual(await window.title(), packageJson.productName);
console.log('Waiting for app to fully load');
await window.waitForSelector(
'.App, .app-loading-screen:has-text("Optimizing")'
);
await app.close();
} finally {
await gracefulRmRecursive(consoleLogger, tmpFolder);
}
};
console.log('Checking window title');
assert.strictEqual(await window.title(), packageJson.productName);
// oxlint-disable-next-line promise/prefer-await-to-then
main().catch(error => {
console.error(error);
process.exit(1);
});
await app.close();
} finally {
await rm(tmpFolder, {
recursive: true,
force: true,
maxRetries: 100,
});
}
@@ -1,28 +1,21 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import { join } from 'node:path';
import { readFile, readdir, writeFile } from 'node:fs/promises';
import z from 'zod';
import semver from 'semver';
import { drop } from '../util/drop.std.ts';
import { strictAssert } from '../util/assert.std.ts';
import { assert } from './utils/assert.mjs';
const { GITHUB_TOKEN } = process.env;
const WORKFLOWS_DIR = join(__dirname, '..', '..', '.github', 'workflows');
const WORKFLOWS_DIR = join(import.meta.dirname, '..', '.github', 'workflows');
const REGEXP =
/uses:\s*(?<path>[^\s]+)@(?<ref>[^\s#]+)(?:\s*#\s*(?<originalRef>[^\n]+))?/g;
type RegexGroups = {
path: string;
ref: string;
originalRef?: string;
};
const CACHE = new Map<string, string>();
/** @type {Map<string, string>} */
const CACHE = new Map();
const TagsSchema = z
.object({
@@ -33,12 +26,19 @@ const TagsSchema = z
})
.array();
async function updateAction(fullPath: string): Promise<void> {
/**
* @param {string} fullPath
* @returns {Promise<void>}
*/
async function updateAction(fullPath) {
const source = await readFile(fullPath, 'utf8');
for (const { groups } of source.matchAll(REGEXP)) {
strictAssert(groups != null, 'Expected regexp to fully match');
const { path, ref, originalRef } = groups as RegexGroups;
assert(groups != null, 'Expected regexp to fully match');
const { path, ref, originalRef } = groups;
assert(path != null, 'Missing path');
assert(ref != null, 'Missing ref');
assert(originalRef != null, 'Missing originalRef');
// Skip local actions
if (path.startsWith('.')) {
@@ -56,7 +56,7 @@ async function updateAction(fullPath: string): Promise<void> {
}
const [org, repo] = path.split('/', 2);
strictAssert(repo, 'Missing repo');
assert(repo != null, 'Missing repo');
const url =
`https://api.github.com/repos/${encodeURIComponent(org)}/` +
@@ -113,12 +113,9 @@ async function updateAction(fullPath: string): Promise<void> {
await writeFile(fullPath, result);
}
async function main(): Promise<void> {
const actions = await readdir(WORKFLOWS_DIR);
const actions = await readdir(WORKFLOWS_DIR);
for (const name of actions) {
// oxlint-disable-next-line no-await-in-loop
await updateAction(join(WORKFLOWS_DIR, name));
}
for (const name of actions) {
// oxlint-disable-next-line no-await-in-loop
await updateAction(join(WORKFLOWS_DIR, name));
}
drop(main());
+22
View File
@@ -0,0 +1,22 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
/**
* @param {boolean} condition
* @param {string} message
* @returns {asserts condition}
*/
export function assert(condition, message) {
if (!condition) {
throw new TypeError(message);
}
}
/**
* @param {never} value
* @returns {never}
*/
export function unreachable(value) {
throw new TypeError(`Expected case to be unreachable, found ${value}`);
}
+8
View File
@@ -0,0 +1,8 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
export const SECOND = 1000;
export const MINUTE = 60 * SECOND;
export const HOUR = 60 * MINUTE;
export const DAY = 24 * HOUR;
+45
View File
@@ -0,0 +1,45 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import * as semver from 'semver';
/**
* @typedef {{
* release: string;
* currentVersion: string;
* shortSha: string;
* }} Options
*/
/**
* @param {Options} options
* @returns {string}
*/
export function generateTaggedVersion(options) {
const { release, currentVersion, shortSha } = options;
const parsed = semver.parse(currentVersion);
if (!parsed) {
throw new Error(`generateTaggedVersion: Invalid version ${currentVersion}`);
}
const dateTimeParts = new Intl.DateTimeFormat('en', {
day: '2-digit',
hour: '2-digit',
hourCycle: 'h23',
month: '2-digit',
timeZone: 'GMT',
year: 'numeric',
}).formatToParts(new Date());
const dateTimeMap = new Map();
dateTimeParts.forEach(({ type, value }) => {
dateTimeMap.set(type, value);
});
const formattedDate = `${dateTimeMap.get('year')}${dateTimeMap.get(
'month'
)}${dateTimeMap.get('day')}.${dateTimeMap.get('hour')}`;
const formattedVersion = `${parsed.major}.${parsed.minor}.${parsed.patch}`;
return `${formattedVersion}-${release}.${formattedDate}-${shortSha}`;
}
@@ -0,0 +1,108 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import { assert } from 'chai';
import { useFakeTimers } from 'sinon';
import * as semver from 'semver';
import { generateTaggedVersion } from './generateTaggedVersion.mjs';
/** @import { SinonFakeTimers } from 'sinon' */
describe('generateTaggedVersion', () => {
/** @type {SinonFakeTimers} */
let clock;
beforeEach(() => {
clock = useFakeTimers();
});
afterEach(() => {
clock.restore();
});
it('uses the current date and provided shortSha', () => {
clock.setSystemTime(new Date('2021-07-23T01:22:55.692Z').getTime());
const currentVersion = '5.12.0-beta.1';
const shortSha = '07f0efc45';
const expected = '5.12.0-alpha.20210723.01-07f0efc45';
const actual = generateTaggedVersion({
release: 'alpha',
currentVersion,
shortSha,
});
assert.strictEqual(expected, actual);
});
it('same production version is semver.gt', () => {
const currentVersion = '5.12.0-beta.1';
const shortSha = '07f0efc45';
clock.setSystemTime(new Date('2021-07-23T01:22:55.692Z').getTime());
const actual = generateTaggedVersion({
release: 'alpha',
currentVersion,
shortSha,
});
assert.isTrue(semver.gt('5.12.0', actual));
});
it('same beta version is semver.gt', () => {
const currentVersion = '5.12.0-beta.1';
const shortSha = '07f0efc45';
clock.setSystemTime(new Date('2021-07-23T01:22:55.692Z').getTime());
const actual = generateTaggedVersion({
release: 'alpha',
currentVersion,
shortSha,
});
assert.isTrue(semver.gt(currentVersion, actual));
});
it('build earlier same day is semver.lt', () => {
const currentVersion = '5.12.0-beta.1';
const shortSha = '07f0efc45';
clock.setSystemTime(new Date('2021-07-23T00:22:55.692Z').getTime());
const actualEarlier = generateTaggedVersion({
release: 'alpha',
currentVersion,
shortSha,
});
clock.setSystemTime(new Date('2021-07-23T01:22:55.692Z').getTime());
const actualLater = generateTaggedVersion({
release: 'alpha',
currentVersion,
shortSha,
});
assert.isTrue(semver.lt(actualEarlier, actualLater));
});
it('build previous day is semver.lt', () => {
const currentVersion = '5.12.0-beta.1';
const shortSha = '07f0efc45';
clock.setSystemTime(new Date('2021-07-22T01:22:55.692Z').getTime());
const actualEarlier = generateTaggedVersion({
release: 'alpha',
currentVersion,
shortSha,
});
clock.setSystemTime(new Date('2021-07-23T01:22:55.692Z').getTime());
const actualLater = generateTaggedVersion({
release: 'alpha',
currentVersion,
shortSha,
});
assert.isTrue(semver.lt(actualEarlier, actualLater));
});
});
@@ -1,36 +1,44 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import { TYPE, parse } from '@formatjs/icu-messageformat-parser'
import { unreachable } from './assert.mjs';
import { TYPE, parse } from '@formatjs/icu-messageformat-parser';
import type {
MessageFormatElement,
PluralOrSelectOption,
} from '@formatjs/icu-messageformat-parser';
import { missingCaseError } from './missingCaseError.std.ts';
/** @import { MessageFormatElement, PluralOrSelectOption } from '@formatjs/icu-messageformat-parser' */
export type ICUMessageParamType = Readonly<
| {
type: 'string' | 'date' | 'number' | 'jsx' | 'time';
}
| {
type: 'select';
validOptions: ReadonlyArray<string>;
}
>;
/**
* @typedef {Readonly<
* | {
* type: 'string' | 'date' | 'number' | 'jsx' | 'time';
* }
* | {
* type: 'select';
* validOptions: ReadonlyArray<string>;
* }
* >} ICUMessageParamType
*/
export function getICUMessageParams(
message: string,
defaultRichTextElementNames: Array<string> = []
): Map<string, ICUMessageParamType> {
/**
* @param {string} message
* @param {string[]} defaultRichTextElementNames
* @returns {Map<string, ICUMessageParamType>}
*/
export function getICUMessageParams(message, defaultRichTextElementNames = []) {
const params = new Map();
function visitOptions(options: Record<string, PluralOrSelectOption>) {
/**
* @param {Record<string, PluralOrSelectOption>} options
*/
function visitOptions(options) {
for (const option of Object.values(options)) {
visit(option.value);
}
}
function visit(elements: ReadonlyArray<MessageFormatElement>) {
/**
* @param {ReadonlyArray<MessageFormatElement>} elements
*/
function visit(elements) {
for (const element of elements) {
switch (element.type) {
case TYPE.argument:
@@ -68,7 +76,7 @@ export function getICUMessageParams(
params.set(element.value, { type: 'time' });
break;
default:
throw missingCaseError(element);
unreachable(element);
}
}
}
@@ -1,5 +1,5 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
export type DeletedMatch = RegExpMatchArray & { 1: string };
export const DELETED_REGEXP = /\(\s*deleted\s+(\d{2,4}\/\d{2}\/\d{2,4})\s*\)/i;
@@ -1,7 +1,9 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
// https://github.com/electron-userland/electron-builder/blob/c1448c6584bb5106aaa2766379fd0917508ce3bf/packages/app-builder-lib/src/util/langs.ts#L1
/** @type {Array<keyof typeof LCID>} */
export const REQUIRED_LANGUAGES = [
'en_US',
'de_DE',
@@ -32,7 +34,8 @@ export const REQUIRED_LANGUAGES = [
];
// https://github.com/electron-userland/electron-builder/blob/c1448c6584bb5106aaa2766379fd0917508ce3bf/packages/app-builder-lib/src/util/langs.ts#L48
export const LCID: Record<string, number> = {
/** @satisfies {Record<string, number>} */
export const LCID = {
af_ZA: 1078,
am_ET: 1118,
ar_AE: 14337,
+19
View File
@@ -0,0 +1,19 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import z from 'zod';
export const OptionalResourceSchema = z.object({
digest: z.string(),
url: z.string(),
size: z.number(),
});
/** @typedef {z.infer<typeof OptionalResourceSchema>} OptionalResourceType */
export const OptionalResourcesDictSchema = z.record(
z.string(),
OptionalResourceSchema
);
/** @typedef {z.infer<typeof OptionalResourcesDictSchema>} OptionalResourcesDictType */
+76
View File
@@ -0,0 +1,76 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import * as semver from 'semver';
import { assert } from './assert.mjs';
/**
* @typedef {'prod' | 'beta' | 'alpha' | 'staging' | 'axolotl' | 'adhoc'} VersionChannel
*/
/**
* @typedef {object} VersionInfo
* @prop {VersionChannel} channel
* @prop {number} major
* @prop {number} minor
* @prop {number} patch
* @prop {number | null} prepatch
* @prop {string[]} build
* @prop {boolean} isUpdatable
* @prop {boolean} isNightly
*/
/**
* @param {string} version
* @returns {VersionInfo}
*/
export function parseVersion(version) {
const parsed = semver.parse(version);
assert(parsed != null, `Invalid version: ${version}`);
const [pre1, pre2] = parsed.prerelease;
/** @type {VersionChannel} */
let channel;
/** @type {number | null} */
let prepatch;
if (pre1 == null) {
channel = 'prod';
prepatch = null;
assert(
parsed.build.length === 0,
`Unexpected build info in "prod" version: "${version}"`
);
} else if (
pre1 === 'beta' ||
pre1 === 'alpha' ||
pre1 === 'staging' ||
pre1 === 'axolotl' ||
pre1 === 'adhoc'
) {
channel = pre1;
assert(
typeof pre2 === 'number',
`Expected channel "${channel}" to have prepatch number in version: "${version}"`
);
prepatch = pre2;
} else {
throw new TypeError(
`Unexpected channel "${pre1}" in version: "${version}"`
);
}
const isUpdatable = channel !== 'adhoc';
const isNightly = channel === 'alpha' || channel === 'axolotl';
return {
channel,
major: parsed.major,
minor: parsed.minor,
patch: parsed.patch,
prepatch,
build: [...parsed.build],
isUpdatable,
isNightly,
};
}
@@ -1,21 +1,22 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import { packageJson } from './packageJson.node.ts';
import { getUserAgent } from './getUserAgent.node.ts';
type AuthenticateOptionsType = Readonly<{
userIdentifier: string;
userSecret: string;
}>;
/**
* @typedef {Readonly<{
* userIdentifier: string;
* userSecret: string;
* }>} AuthenticateOptionsType
*/
export const API_BASE = new URL('https://api.smartling.com/');
export const PROJECT_ID = 'ef62d1ebb';
export async function authenticate({
userIdentifier,
userSecret,
}: AuthenticateOptionsType): Promise<Headers> {
/**
* @param {AuthenticateOptionsType} options
* @returns {Promise<Headers>}
*/
export async function authenticate({ userIdentifier, userSecret }) {
const res = await fetch(new URL('./auth-api/v2/authenticate', API_BASE), {
method: 'POST',
headers: {
@@ -36,6 +37,5 @@ export async function authenticate({
return new Headers({
authorization: `${auth.tokenType} ${auth.accessToken}`,
'user-agent': getUserAgent(packageJson.version),
});
}
+17
View File
@@ -0,0 +1,17 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
/**
* @param {string} utf16
* @returns {string}
*/
export function utf16ToEmoji(utf16) {
/** @type {Array<number>} */
const codePoints = [];
const buf = Buffer.from(utf16, 'hex');
for (let i = 0; i < buf.length; i += 2) {
codePoints.push(buf.readUint16BE(i));
}
return String.fromCodePoint(...codePoints);
}
+4 -2
View File
@@ -4,7 +4,9 @@
<html>
<head>
<meta charset="utf-8" />
<title>TextSecure test runner</title>
<title>
Signal Desktop (The Artist Formerly Known as TextSecure) test runner
</title>
<link rel="stylesheet" href="../node_modules/mocha/mocha.css" />
<link rel="stylesheet" href="../stylesheets/manifest.css" />
<link rel="stylesheet" href="../stylesheets/tailwind.css" />
@@ -16,6 +18,6 @@
type="text/javascript"
src="../node_modules/mocha/mocha.js"
></script>
<script type="text/javascript" src="test.js"></script>
<script type="module" src="test.mjs"></script>
</body>
</html>
-56
View File
@@ -1,56 +0,0 @@
// Copyright 2014 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
/*
* global helpers for tests
*/
mocha.setup('bdd');
mocha.setup({ timeout: 10000 });
let themeSetting = 'light';
window.Events = {
getThemeSetting: () => themeSetting,
setThemeSetting: (newSetting) => {
themeSetting = newSetting;
},
addDarkOverlay: () => undefined,
};
/* Delete the database before running any tests */
before(async () => {
await window.testUtilities.initialize();
});
window.testUtilities.prepareTests();
delete window.testUtilities.prepareTests;
// oxlint-disable-next-line no-unused-expressions
!(function () {
// oxlint-disable-next-line no-undef
class Reporter extends Mocha.reporters.HTML {
constructor(runner, options) {
super(runner, options);
runner.on('pass', test => window.testUtilities.onTestEvent({
type: 'pass',
title: test.titlePath(),
duration: test.duration,
}));
runner.on('fail', (test, error) => window.testUtilities.onTestEvent({
type: 'fail',
title: test.titlePath(),
error: error?.stack || String(error),
}));
runner.on('end', () => window.testUtilities.onTestEvent({ type: 'end' }));
}
}
mocha.reporter(Reporter);
mocha.setup(window.testUtilities.setup);
mocha.run();
})();
+56
View File
@@ -0,0 +1,56 @@
// Copyright 2014 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
/// <reference path="../ts/window.d.ts">
/*
* global helpers for tests
*/
mocha.setup('bdd');
mocha.setup({ timeout: 10000 });
let themeSetting = 'light';
window.Events = {
getThemeSetting: () => themeSetting,
setThemeSetting: (newSetting) => {
themeSetting = newSetting;
},
addDarkOverlay: () => undefined,
};
/* Delete the database before running any tests */
before(async () => {
await window.testUtilities.initialize();
});
window.testUtilities.prepareTests();
delete window.testUtilities.prepareTests;
// oxlint-disable-next-line no-undef
class Reporter extends Mocha.reporters.HTML {
constructor(runner, options) {
super(runner, options);
runner.on('pass', test => window.testUtilities.onTestEvent({
type: 'pass',
title: test.titlePath(),
duration: test.duration,
}));
runner.on('fail', (test, error) => window.testUtilities.onTestEvent({
type: 'fail',
title: test.titlePath(),
error: error?.stack || String(error),
}));
runner.on('end', () => window.testUtilities.onTestEvent({ type: 'end' }));
}
}
mocha.reporter(Reporter);
mocha.setup(window.testUtilities.setup);
mocha.run();
-16
View File
@@ -1,16 +0,0 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
module.exports = {
rules: {
'no-console': 'off',
// We still get the value of this rule, it just allows for dev deps
'import/no-extraneous-dependencies': [
'error',
{
devDependencies: true,
},
],
},
};
@@ -1,12 +0,0 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { BuildResult } from 'electron-builder';
import { afterAllArtifactBuild as notarizeUniversalDMG } from './notarize-universal-dmg.node.ts';
export async function afterAllArtifactBuild(
result: BuildResult
): Promise<Array<string>> {
await notarizeUniversalDMG(result);
return [];
}
-15
View File
@@ -1,15 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { AfterPackContext } from 'electron-builder';
import { afterPack as fuseElectron } from './fuse-electron.node.ts';
import { afterPack as copyPacks } from './copy-language-packs.node.ts';
import { afterPack as pruneMacOSRelease } from './prune-macos-release.node.ts';
import { afterPack as ensureLinuxFilePermissions } from './ensure-linux-file-permissions.node.ts';
export async function afterPack(context: AfterPackContext): Promise<void> {
await pruneMacOSRelease(context);
await fuseElectron(context);
await copyPacks(context);
await ensureLinuxFilePermissions(context);
}
-12
View File
@@ -1,12 +0,0 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { AfterPackContext } from 'electron-builder';
import { afterSign as notarize } from './notarize.node.ts';
// NOTE: It is AfterPackContext here even though it is afterSign.
// See: https://www.electron.build/configuration/configuration.html#aftersign
export async function afterSign(context: AfterPackContext): Promise<void> {
// This must be the last step
await notarize(context);
}
-348
View File
@@ -1,348 +0,0 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { join } from 'node:path';
import { readFile } from 'node:fs/promises';
import chalk from 'chalk';
import semver from 'semver';
import { got, HTTPError } from 'got';
import enquirer from 'enquirer';
import execa from 'execa';
const rootDir = join(__dirname, '..', '..');
function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new Error(message);
}
}
// oxlint-disable-next-line typescript/no-explicit-any
async function readJsonFile(path: string): Promise<any> {
return JSON.parse(await readFile(path, 'utf-8'));
}
function parseNumberField(value: string | number | null | void): number | null {
if (value == null) {
return null;
}
if (typeof value === 'number') {
return value;
}
const trimmed = value.trim();
if (trimmed === '') {
return null;
}
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return null;
}
return parsed;
}
const npm = got.extend({
prefixUrl: 'https://registry.npmjs.org/',
responseType: 'json',
retry: {
calculateDelay: retry => {
if (
retry.error instanceof HTTPError &&
retry.error.response.statusCode === 429
) {
const retryAfter = parseNumberField(
retry.error.response.headers['retry-after']
);
if (retryAfter != null) {
console.log(
chalk.gray(`Rate limited, retrying after ${retryAfter} seconds`)
);
return retryAfter * 1000;
}
}
return retry.computedValue;
},
},
});
const DependencyTypes = [
'dependencies',
'devDependencies',
'optionalDependencies',
'peerDependencies',
] as const;
type LocalDependency = Readonly<{
name: string;
depType: (typeof DependencyTypes)[number];
requestedVersion: string;
resolvedVersion: string;
}>;
type FetchedDependency = LocalDependency &
Readonly<{
latestVersion: string;
moduleType: 'commonjs' | 'esm';
diff: semver.ReleaseType | null;
}>;
async function main() {
const packageJson = await readJsonFile(join(rootDir, 'package.json'));
const packageLock = await readJsonFile(join(rootDir, 'package-lock.json'));
const localDeps: ReadonlyArray<LocalDependency> = DependencyTypes.flatMap(
depType => {
return Object.keys(packageJson[depType] ?? {}).map(name => {
const requestedVersion = packageJson[depType][name];
const resolvedVersion =
packageLock.packages[`node_modules/${name}`]?.version;
assert(resolvedVersion, `Could not find resolved version for ${name}`);
return { name, depType, requestedVersion, resolvedVersion };
});
}
);
console.log(chalk`Found {cyan ${localDeps.length}} local dependencies`);
const fetchedDeps: ReadonlyArray<FetchedDependency> = await Promise.all(
localDeps.map(async dep => {
// oxlint-disable-next-line typescript/no-explicit-any
const info: any = await npm(`${dep.name}/latest`).json();
const latestVersion = info.version;
const moduleType = info.type ?? 'commonjs';
assert(
moduleType === 'commonjs' || moduleType === 'module',
`Unexpected module type for ${dep.name}: ${moduleType}`
);
const diff = semver.lt(dep.resolvedVersion, latestVersion)
? semver.diff(dep.resolvedVersion, latestVersion)
: null;
return { ...dep, latestVersion, moduleType, diff };
})
);
const outdatedDeps = fetchedDeps.filter(dep => dep.diff != null);
console.log(chalk`Found {cyan ${outdatedDeps.length}} outdated dependencies`);
const upgradeableDeps = outdatedDeps.filter(dep => {
return dep.moduleType === 'commonjs';
});
console.log(
chalk`Found {cyan ${upgradeableDeps.length}} upgradeable dependencies`
);
const upgradeableDepsByDiff = new Map<string, Set<string>>();
for (const dep of upgradeableDeps) {
assert(dep.diff != null, 'Expected diff to be non-null');
let group = upgradeableDepsByDiff.get(dep.diff);
if (group == null) {
group = new Set();
upgradeableDepsByDiff.set(dep.diff, group);
}
group.add(dep.name);
}
for (const [diff, deps] of upgradeableDepsByDiff) {
console.log(chalk` - ${diff}: {cyan ${deps.size}}`);
}
let longestNameLength = 0;
for (const dep of upgradeableDeps) {
longestNameLength = Math.max(longestNameLength, dep.name.length);
}
const { approvedDeps } = await enquirer.prompt<{
approvedDeps: ReadonlyArray<string>;
}>({
type: 'multiselect',
name: 'approvedDeps',
message: 'Select which dependencies to upgrade',
choices: upgradeableDeps.map(deps => {
let color = chalk.red;
if (deps.diff === 'patch') {
color = chalk.green;
} else if (deps.diff === 'minor') {
color = chalk.yellow;
}
return {
name: deps.name,
message: `${deps.name.padEnd(longestNameLength)}`,
hint: `(${color(deps.diff)}: ${deps.resolvedVersion} -> ${color(deps.latestVersion)})`,
};
}),
});
console.log(
chalk`Starting upgrade of {cyan ${approvedDeps.length}} dependencies`
);
for (const dep of upgradeableDeps) {
try {
if (!approvedDeps.includes(dep.name)) {
console.log(chalk`Skipping ${dep.name}`);
continue;
}
// oxlint-disable-next-line no-await-in-loop
const gitStatusBefore = await execa('git', ['status', '--porcelain']);
if (gitStatusBefore.stdout.trim() !== '') {
console.error(chalk`{red Found uncommitted changes, exiting}`);
console.error(chalk.red(gitStatusBefore.stdout));
process.exit(1);
}
console.log(
chalk`Upgrading {cyan ${dep.name}} from {yellow ${dep.resolvedVersion}} to {magenta ${dep.latestVersion}}`
);
// oxlint-disable-next-line no-await-in-loop
await execa(
'npm',
['install', '--save-exact', `${dep.name}@${dep.latestVersion}`],
{ stdio: 'inherit' }
);
// oxlint-disable-next-line no-constant-condition
while (true) {
try {
// oxlint-disable-next-line no-await-in-loop
await execa(
'npx',
['patch-package', '--error-on-fail', '--error-on-warn'],
{ stdio: 'inherit' }
);
break;
} catch {
// oxlint-disable-next-line no-await-in-loop
const { retry } = await enquirer.prompt<{ retry: boolean }>({
type: 'confirm',
name: 'retry',
message: 'Retry patch-package?',
initial: true,
});
if (!retry) {
throw new Error('Failed to apply patch-package');
}
}
}
// oxlint-disable-next-line no-await-in-loop
const { npmScriptsToRun } = await enquirer.prompt<{
npmScriptsToRun: Array<string>;
}>({
type: 'multiselect',
name: 'npmScriptsToRun',
message: 'Select which scripts to run',
choices: [
// Fast and common
{ name: 'oxlint' },
{ name: 'test-node' },
{ name: 'test-electron' },
// Long
{ name: 'test-mock' },
// Uncommon
{ name: 'test-oxlint' },
{ name: 'test-lint-intl' },
],
});
const allNpmScriptToRun = [
// Mandatory
'generate',
'check:types',
'lint-deps',
// Optional
...npmScriptsToRun,
];
for (const script of allNpmScriptToRun) {
console.log(chalk`Running {cyan npm run ${script}}`);
// oxlint-disable-next-line no-constant-condition
while (true) {
try {
// oxlint-disable-next-line no-await-in-loop
await execa('npm', ['run', script], { stdio: 'inherit' });
break;
} catch (error) {
console.log(
chalk.red(
`Failed to run ${script}, you could go make changes and try again`
)
);
// oxlint-disable-next-line no-await-in-loop
const { retry } = await enquirer.prompt<{
retry: boolean;
}>({
type: 'confirm',
name: 'retry',
message: 'Retry running script?',
initial: true,
});
if (!retry) {
throw error;
} else {
console.log(chalk`Retrying {cyan npm run ${script}}`);
continue;
}
}
}
}
console.log('Changes after upgrade:');
// oxlint-disable-next-line no-await-in-loop
await execa('git', ['status', '--porcelain'], { stdio: 'inherit' });
// oxlint-disable-next-line no-await-in-loop
const { commitChanges } = await enquirer.prompt<{
commitChanges: boolean;
}>({
type: 'select',
name: 'commitChanges',
message: 'Commit these changes?',
choices: [
{ name: 'commit', message: 'Commit and continue', value: true },
{ name: 'revert', message: 'Revert and skip', value: false },
],
});
if (!commitChanges) {
console.log('Reverting changes, and skipping');
// oxlint-disable-next-line no-await-in-loop
await execa('git', ['checkout', '.']);
continue;
}
console.log('Committing changes');
// oxlint-disable-next-line no-await-in-loop
await execa('git', ['add', '.']);
// oxlint-disable-next-line no-await-in-loop
await execa('git', [
'commit',
'-m',
`Upgrade ${dep.name} ${dep.depType} from ${dep.requestedVersion} to ${dep.latestVersion}`,
]);
} catch (error) {
console.error(chalk.red(error));
console.log(
chalk.red(`Failed to upgrade ${dep.name}, reverting and skipping`)
);
// oxlint-disable-next-line no-await-in-loop
await execa('git', ['checkout', '.']);
}
}
}
// oxlint-disable-next-line promise/prefer-await-to-then
main().catch(error => {
console.error(error);
process.exit(1);
});
@@ -1,70 +0,0 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { readFile, writeFile, readdir, readlink } from 'node:fs/promises';
import { join, basename } from 'node:path';
import pMap from 'p-map';
import { drop } from '../util/drop.std.ts';
async function main(): Promise<void> {
const source = process.argv[2];
if (!source) {
throw new Error('Missing required source directory argument');
}
const dirEntries = await readdir(join(source, 'components'), {
withFileTypes: true,
recursive: true,
});
const enMessages = JSON.parse(
await readFile(
join(__dirname, '..', '..', '_locales', 'en', 'messages.json'),
'utf8'
)
);
const icuToStory: Record<string, Array<[string, string]>> = Object.create(
null
);
await pMap(
dirEntries,
async entry => {
if (!entry.isSymbolicLink()) {
return;
}
const fullPath = join(entry.parentPath, entry.name);
const image = basename(await readlink(fullPath));
const storyId = basename(entry.parentPath);
const linkFile = entry.name;
const icuId = `icu:${basename(linkFile, '.jpg')}`;
let list = icuToStory[icuId];
if (list == null) {
list = [];
icuToStory[icuId] = list;
}
list.push([storyId, image]);
},
{ concurrency: 20 }
);
const index = Object.entries(icuToStory).map(([key, icuIds]) => {
return [key, enMessages[key]?.messageformat, icuIds];
});
const html = await readFile(
join(__dirname, '..', '..', '.storybook', 'icu-lookup.html'),
'utf8'
);
await writeFile(
join(source, 'index.html'),
html.replace('%INDEX%', JSON.stringify(index))
);
}
drop(main());
-63
View File
@@ -1,63 +0,0 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import fs from 'node:fs/promises';
import path from 'node:path';
import fastGlob from 'fast-glob';
import * as LocaleMatcher from '@formatjs/intl-localematcher';
const ROOT_DIR = path.join(__dirname, '..', '..');
function matches(input: string, expected: string) {
const match = LocaleMatcher.match([input], [expected], 'en', {
algorithm: 'best fit',
});
return match === expected;
}
async function main() {
const dirEntries = await fastGlob('_locales/*', {
cwd: ROOT_DIR,
onlyDirectories: true,
});
const localeDirNames = [];
for (const dirEntry of dirEntries) {
const dirName = path.basename(dirEntry);
const locale = new Intl.Locale(dirName);
// Smartling doesn't always use the correct language tag, so this check and
// reverse check are to make sure we don't accidentally add a locale that
// doesn't match its directory name (using LocaleMatcher).
//
// If this check ever fails, we may need to update our get-strings script to
// manually rename language tags before writing them to disk.
//
// Such is the case for Smartling's "zh-YU" locale, which we renamed to
// "yue" to match the language tag used by... everyone else.
if (!matches(dirName, locale.baseName)) {
throw new Error(
`Matched locale "${dirName}" does not match its resolved name "${locale.baseName}"`
);
}
if (!matches(locale.baseName, dirName)) {
throw new Error(
`Matched locale "${dirName}" does not match its dir name "${dirName}"`
);
}
localeDirNames.push(dirName);
}
const jsonPath = path.join(ROOT_DIR, 'build', 'available-locales.json');
console.log(`Writing to "${jsonPath}"...`);
await fs.writeFile(jsonPath, `${JSON.stringify(localeDirNames, null, 2)}\n`);
}
// oxlint-disable-next-line promise/prefer-await-to-then
main().catch(error => {
console.error(error);
process.exit(1);
});
-111
View File
@@ -1,111 +0,0 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import fs from 'node:fs/promises';
import path from 'node:path';
import fastGlob from 'fast-glob';
import { strictAssert } from '../util/assert.std.ts';
const ROOT_DIR = path.join(__dirname, '..', '..');
async function main() {
const dirEntries = await fastGlob('_locales/*', {
cwd: ROOT_DIR,
onlyDirectories: true,
});
const english = JSON.parse(
await fs.readFile(path.join(ROOT_DIR, '_locales/en/messages.json'), 'utf8')
);
const templateDir = path.join(ROOT_DIR, 'build/policy-templates');
const templates = [
{
name: 'org.signalapp.enable-backups.policy',
content: await fs.readFile(
path.join(templateDir, 'org.signalapp.enable-backups.policy'),
'utf8'
),
description:
'icu:Preferences__local-backups--enable--os-prompt-description--linux',
message:
'icu:Preferences__local-backups--enable--os-prompt-message--linux',
},
{
name: 'org.signalapp.plaintext-export.policy',
content: await fs.readFile(
path.join(templateDir, 'org.signalapp.plaintext-export.policy'),
'utf8'
),
description: 'icu:PlaintextExport--OSPrompt--description--Linux',
message: 'icu:PlaintextExport--OSPrompt--message--Linux',
},
{
name: 'org.signalapp.view-aep.policy',
content: await fs.readFile(
path.join(templateDir, 'org.signalapp.view-aep.policy'),
'utf8'
),
description:
'icu:Preferences--local-backups--view-recovery-key--os-prompt-description--linux',
message:
'icu:Preferences--local-backups--view-recovery-key--os-prompt-message--linux',
},
];
for (const template of templates) {
const englishDescription = english[template.description]?.messageformat;
strictAssert(
englishDescription,
`Must have english string for key ${template.description}`
);
const englishMessage = english[template.message]?.messageformat;
strictAssert(
englishMessage,
`Must have english string for key ${template.message}`
);
let allDescriptions = `<description>${englishDescription}</description>\n`;
let allMessages = `<message>${englishMessage}</message>\n`;
for (const dirEntry of dirEntries) {
const locale = path.basename(dirEntry);
const data = JSON.parse(
// oxlint-disable-next-line no-await-in-loop
await fs.readFile(
path.join(ROOT_DIR, '_locales', locale, 'messages.json'),
'utf8'
)
);
const localeName = locale.replace('-', '_');
const description =
data[template.description]?.messageformat ?? englishDescription;
allDescriptions += ` <description xml:lang="${localeName}">${description}</description>\n`;
const message = data[template.message]?.messageformat ?? englishMessage;
allMessages += ` <message xml:lang="${localeName}">${message}</message>\n`;
}
const targetPath = path.join(ROOT_DIR, 'build', template.name);
let targetContent = template.content;
targetContent = targetContent.replace(
'<!-- <description>{description}</description> -->',
allDescriptions
);
targetContent = targetContent.replace(
'<!-- <message>{message}</message> -->',
allMessages
);
// oxlint-disable-next-line no-await-in-loop
await fs.writeFile(targetPath, targetContent);
}
}
// oxlint-disable-next-line promise/prefer-await-to-then
main().catch(error => {
console.error(error);
process.exit(1);
});
@@ -1,85 +0,0 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { readdir, mkdir, readFile, writeFile } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import pMap from 'p-map';
import { isLocaleMessageType } from '../util/setupI18nMain.std.ts';
async function compact({
sourceDir,
targetDir,
locale,
keys,
}: {
sourceDir: string;
targetDir: string;
locale: string;
keys: ReadonlyArray<string>;
}): Promise<ReadonlyArray<string>> {
const sourcePath = join(sourceDir, locale, 'messages.json');
const targetPath = join(targetDir, locale, 'values.json');
await mkdir(dirname(targetPath), { recursive: true });
const json = JSON.parse(await readFile(sourcePath, 'utf8'));
const result = new Array<string | null>();
for (const key of keys) {
if (json[key] == null) {
// Pull English translation, or leave blank (string was deleted)
result.push(null);
continue;
}
const value = json[key];
if (!isLocaleMessageType(value)) {
continue;
}
if (value.messageformat == null) {
continue;
}
result.push(value.messageformat);
}
await writeFile(targetPath, JSON.stringify(result));
return keys;
}
async function main(): Promise<void> {
const rootDir = join(__dirname, '..', '..');
const sourceDir = join(rootDir, '_locales');
const targetDir = join(rootDir, 'build', 'compact-locales');
const locales = await readdir(sourceDir);
const allKeys = await pMap(
locales,
async locale => {
const sourcePath = join(sourceDir, locale, 'messages.json');
const json = JSON.parse(await readFile(sourcePath, 'utf8'));
return Object.entries(json)
.filter(([, value]) => isLocaleMessageType(value))
.map(([key]) => key);
},
{ concurrency: 10 }
);
// Sort keys alphabetically for better incremental updates.
const keys = Array.from(new Set(allKeys.flat())).sort();
await mkdir(targetDir, { recursive: true });
await writeFile(join(targetDir, 'keys.json'), JSON.stringify(keys));
await pMap(
locales,
locale => compact({ sourceDir, targetDir, locale, keys }),
{ concurrency: 10 }
);
}
// oxlint-disable-next-line promise/prefer-await-to-then
main().catch(err => {
console.error(err);
process.exit(1);
});
-70
View File
@@ -1,70 +0,0 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { resolve4 as resolve4Cb, resolve6 as resolve6Cb } from 'node:dns';
import { writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { promisify } from 'node:util';
import { isNotNil } from '../util/isNotNil.std.ts';
const resolve4 = promisify(resolve4Cb);
const resolve6 = promisify(resolve6Cb);
const FALLBACK_DOMAINS = [
'chat.signal.org',
'storage.signal.org',
'cdsi.signal.org',
'cdn.signal.org',
'cdn2.signal.org',
'cdn3.signal.org',
'updates2.signal.org',
'sfu.voip.signal.org',
];
async function main() {
const config = await Promise.all(
FALLBACK_DOMAINS.sort().map(async domain => {
const ipv4endpoints = (await resolve4(domain)).map(a => ({
family: 'ipv4',
address: a,
}));
const ipv6endpoints = (await resolve6(domain)).map(a => ({
family: 'ipv6',
address: a,
}));
const endpoints = [...ipv4endpoints, ...ipv6endpoints]
.filter(isNotNil)
.sort((a, b) => {
if (a.family < b.family) {
return -1;
}
if (a.family > b.family) {
return 1;
}
if (a.address < b.address) {
return -1;
}
if (a.address > b.address) {
return 1;
}
return 0;
});
return { domain, endpoints };
})
);
const outPath = join(__dirname, '../../build/dns-fallback.json');
await writeFile(outPath, `${JSON.stringify(config, null, 2)}\n`);
}
// oxlint-disable-next-line promise/prefer-await-to-then
main().catch(error => {
console.error(error);
process.exit(1);
});

Some files were not shown because too many files have changed in this diff Show More