blob: 0de37bf585bb7f9b411ca2020a3846b5742da8db [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { spawn } from 'node:child_process';
import { constants } from 'node:fs';
import { parseArgs } from 'node:util';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { dirname, join, resolve } from 'node:path';
import {
chmod,
copyFile,
lstat,
mkdir,
mkdtemp,
readdir,
readFile,
rename,
rm,
writeFile,
} from 'node:fs/promises';
const root = fileURLToPath(new URL('../../', import.meta.url));
export function rustTarget(platform = process.platform, arch = process.arch) {
const cpu = { x64: 'x86_64', arm64: 'aarch64' }[arch];
const os = { darwin: 'apple-darwin', linux: 'unknown-linux-gnu', win32: 'pc-windows-msvc' }[
platform
];
if (!cpu || !os) throw new Error(`Unsupported Maka target: ${platform}/${arch}`);
return `${cpu}-${os}`;
}
export async function buildCli({
release = false,
target,
repositoryRoot = root,
env = process.env,
} = {}) {
const triple = target ?? rustTarget();
const linuxRelease = release && triple.includes('-linux-');
if (linuxRelease && !/^(x86_64|aarch64)-unknown-linux-gnu$/u.test(triple)) {
throw new Error('Linux releases require an x86_64 or aarch64 GNU target');
}
// Zig is only used with an explicit target, including native Linux builds.
// Cargo writes the output under the Rust triple, without the glibc suffix.
const outputTarget = linuxRelease ? triple : target;
// Development builds use the repository target/debug path.
const targetDirectory = resolve(repositoryRoot, (release && env.CARGO_TARGET_DIR) || 'target');
const args = [
linuxRelease ? 'zigbuild' : 'build',
'--locked',
'-p',
'maka-cli',
'-p',
'maka-desktop-shell',
'-p',
'maka-python-runtime',
'--target-dir',
targetDirectory,
];
if (release) args.push('--release');
if (outputTarget) args.push('--target', linuxRelease ? `${triple}.2.28` : outputTarget);
await new Promise((resolveBuild, reject) => {
const child = spawn('cargo', args, {
cwd: repositoryRoot,
env,
stdio: 'inherit',
windowsHide: true,
});
child.once('error', reject);
child.once('exit', (code, signal) => {
if (code === 0) resolveBuild();
else reject(new Error(`Maka build failed: ${signal ?? code}`));
});
});
const windows = triple.includes('-windows-');
const binary = join(
targetDirectory,
...(outputTarget ? [outputTarget] : []),
release ? 'release' : 'debug',
windows ? 'maka.exe' : 'maka',
);
const directory = dirname(binary);
if (triple.includes('-apple-darwin')) {
await stageDesktopBundle(
repositoryRoot,
join(directory, 'maka-desktop-shell'),
join(directory, 'Maka.app'),
);
// The app now lives only in the bundle; drop an earlier build's sidecar.
await rm(join(directory, 'app'), { recursive: true, force: true });
} else {
await stageDesktopApp(repositoryRoot, join(directory, 'app'));
}
return binary;
}
/**
* The desktop shell's path from the directory holding `maka`. macOS shows a
* process with the name and icon of the bundle its executable is in, so there
* the shell runs from inside `Maka.app`.
*/
export function desktopShell(platform) {
if (platform === 'darwin') return join('Maka.app', 'Contents', 'MacOS', 'maka-desktop-shell');
return platform === 'win32' ? 'maka-desktop-shell.exe' : 'maka-desktop-shell';
}
/**
* Stage `Maka.app`: the shell, the authored app in `Contents/Resources/app`
* where the shell looks for it, and the Info.plist and icon macOS reads.
*/
export async function stageDesktopBundle(repositoryRoot, shell, destination) {
const manifest = JSON.parse(
await readFile(join(repositoryRoot, 'products/maka/desktop-shell/app/gpui-shell.json'), 'utf8'),
);
for (const key of ['id', 'name', 'version']) {
if (typeof manifest[key] !== 'string' || !manifest[key]) {
throw new Error(`Desktop app manifest has no ${key}`);
}
}
const icon = icns(await readFile(join(repositoryRoot, 'assets/logo.png')));
const metadata = await lstat(shell);
if (!metadata.isFile() || metadata.size === 0) {
throw new Error(`Desktop shell is not a regular file: ${shell}`);
}
// Replace the previous bundle only once the new one is complete.
const stage = await mkdtemp(join(dirname(destination), '.Maka.app-'));
try {
const contents = join(stage, 'Contents');
await stageDesktopApp(repositoryRoot, join(contents, 'Resources', 'app'));
await writeFile(join(contents, 'Resources', 'Maka.icns'), icon);
await writeFile(join(contents, 'Info.plist'), infoPlist(manifest));
await mkdir(join(contents, 'MacOS'));
const executable = join(contents, 'MacOS', 'maka-desktop-shell');
await copyFile(shell, executable, constants.COPYFILE_FICLONE);
await chmod(executable, 0o755);
await chmod(stage, 0o755);
await rm(destination, { recursive: true, force: true });
await rename(stage, destination);
} catch (error) {
await rm(stage, { recursive: true, force: true });
throw error;
}
}
function infoPlist({ id, name, version }) {
// As gpui-shell's own bundles: `CFBundleVersion` takes up to three integers,
// so a pre-release or build suffix stays in the short version only.
const build = /^[0-9]+\.[0-9]+\.[0-9]+/u.exec(version)?.[0] ?? version;
const escape = (value) =>
value
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
return [
'<?xml version="1.0" encoding="UTF-8"?>',
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
'<plist version="1.0">',
'<dict>',
...[
['CFBundleDevelopmentRegion', 'en'],
['CFBundleDisplayName', name],
['CFBundleExecutable', 'maka-desktop-shell'],
['CFBundleIconFile', 'Maka'],
['CFBundleIdentifier', id],
['CFBundleInfoDictionaryVersion', '6.0'],
['CFBundleName', name],
['CFBundlePackageType', 'APPL'],
['CFBundleShortVersionString', version],
['CFBundleVersion', build],
['LSMinimumSystemVersion', '11.0'],
].map(([key, value]) => ` <key>${key}</key>\n <string>${escape(value)}</string>`),
' <key>NSHighResolutionCapable</key>',
' <true/>',
'</dict>',
'</plist>',
'',
].join('\n');
}
/** One 1024-pixel PNG entry; macOS scales it to the smaller sizes. */
function icns(png) {
if (
png.length < 24 ||
png.toString('latin1', 0, 8) !== '\x89PNG\r\n\x1a\n' ||
png.toString('latin1', 12, 16) !== 'IHDR' ||
png.readUInt32BE(16) !== 1024 ||
png.readUInt32BE(20) !== 1024
) {
throw new Error('assets/logo.png must be a 1024x1024 PNG');
}
const header = Buffer.alloc(16);
header.write('icns', 0, 'latin1');
header.writeUInt32BE(header.length + png.length, 4);
header.write('ic10', 8, 'latin1');
header.writeUInt32BE(8 + png.length, 12);
return Buffer.concat([header, png]);
}
/** Stage only regular authored app files; executable lookup is independent of cwd. */
export async function stageDesktopApp(repositoryRoot, destination) {
const source = join(repositoryRoot, 'products/maka/desktop-shell/app');
// Validate before replacing a previous successful development sidecar.
const files = [];
async function collect(directory, relative = '') {
const metadata = await lstat(directory);
if (!metadata.isDirectory())
throw new Error('Desktop app directory must not be a symlink or special file');
for (const name of await readdir(directory)) {
const file = join(directory, name);
const path = join(relative, name);
const metadata = await lstat(file);
if (metadata.isDirectory()) await collect(file, path);
else if (metadata.isFile()) files.push(path);
else throw new Error('Desktop app contains a non-regular file');
}
}
await collect(source);
if (!files.includes('gpui-shell.json') || !files.includes('main.js')) {
throw new Error('Desktop app manifest and main.js are required');
}
await rm(destination, { recursive: true, force: true });
await mkdir(destination, { recursive: true });
for (const file of files) {
await mkdir(dirname(join(destination, file)), { recursive: true });
await copyFile(join(source, file), join(destination, file));
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
const { values } = parseArgs({
options: { release: { type: 'boolean' }, target: { type: 'string' } },
});
console.log(await buildCli(values));
}