| //! `bundle`: an application as a desktop application. | |
| //! | |
| //! On macOS and Linux a bundle holds the application's directory and the | |
| //! scripts that start it, not `gpui-shell`: the first launch — or, on Linux, | |
| //! the installer — uses a `gpui-shell` already on the computer, or downloads | |
| //! the release this command belongs to. Any platform's bundle is made on any | |
| //! platform. One platform per invocation: | |
| //! | |
| //! - **macOS**: `Name.app`, unsigned, whose executable is a launcher script. | |
| //! Signing and notarization need the author's identity, so they stay the | |
| //! author's step. | |
| //! - **Linux**: a `.tar.gz` holding `app/` and the launcher, which runs where | |
| //! it is unpacked, and beside it an `install.sh` that installs the archive | |
| //! for the current user with a desktop entry — from the file beside it, or | |
| //! downloaded, so `curl … | sh` works. | |
| //! - **Windows**: a portable `.zip`: `Name.exe`, which is the release's | |
| //! `gpui-shell.exe` marked as a GUI program, with `app\` beside it. | |
| //! | |
| //! The scripts run the application with `run --dir <app> --packaged`, and the | |
| //! Windows executable finds `app\` beside itself, so either way it keeps its | |
| //! data under its `id` wherever it is. | |
| use std::{ | |
| collections::BTreeSet, | |
| fs, | |
| io::{self, Write as _}, | |
| path::{Path, PathBuf}, | |
| }; | |
| use anyhow::{Context as _, Result, bail}; | |
| use image::{ImageFormat, RgbaImage, imageops::FilterType}; | |
| use super::{ENTRY, HostBrand, agents::AGENTS_FILE}; | |
| use crate::{ | |
| plugin::{MANIFEST_FILE, PluginManifest}, | |
| typings, | |
| }; | |
| /// The functions every POSIX launcher shares to find or download the runtime. | |
| const RUNTIME_FUNCTIONS: &str = include_str!("bundle/runtime.sh"); | |
| const LINUX_LAUNCHER: &str = include_str!("bundle/linux-launcher.sh"); | |
| const LINUX_INSTALLER: &str = include_str!("bundle/linux-install.sh"); | |
| const MACOS_LAUNCHER: &str = include_str!("bundle/macos-launcher.sh"); | |
| /// The Windows release `gpui-shell.exe` is copied from. | |
| const WINDOWS_TARGET: &str = "x86_64-pc-windows-msvc"; | |
| /// The operating system a bundle is for. | |
| #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| pub(super) enum Platform { | |
| MacOS, | |
| Linux, | |
| Windows, | |
| } | |
| impl Platform { | |
| /// The platform this command runs on, the default. | |
| fn host() -> Self { | |
| if cfg!(target_os = "macos") { | |
| Self::MacOS | |
| } else if cfg!(target_os = "windows") { | |
| Self::Windows | |
| } else { | |
| Self::Linux | |
| } | |
| } | |
| pub(super) fn parse(value: &str) -> Option<Self> { | |
| match value { | |
| "macos" => Some(Self::MacOS), | |
| "linux" => Some(Self::Linux), | |
| "windows" => Some(Self::Windows), | |
| _ => None, | |
| } | |
| } | |
| /// The `--platform` spelling, also used in archive names. | |
| fn as_str(self) -> &'static str { | |
| match self { | |
| Self::MacOS => "macos", | |
| Self::Linux => "linux", | |
| Self::Windows => "windows", | |
| } | |
| } | |
| } | |
| /// What `bundle` was asked for, beyond the application. | |
| #[derive(Debug, Default, PartialEq, Eq)] | |
| pub(super) struct BundleOptions { | |
| pub(super) platform: Option<Platform>, | |
| pub(super) icon: Option<PathBuf>, | |
| pub(super) out: Option<PathBuf>, | |
| /// Where the Linux archive will be published, so `install.sh` can be run | |
| /// on its own (`curl …/install.sh | sh`) and download it. | |
| pub(super) download_url: Option<String>, | |
| } | |
| /// Bundles the application at `directory`, returning the lines to print. | |
| pub(super) fn create( | |
| directory: &Path, | |
| options: &BundleOptions, | |
| brand: HostBrand, | |
| ) -> Result<String> { | |
| // The scripts download `gpui-shell` releases. An embedding command's | |
| // catalog is not in them, so its applications would start without it. | |
| if brand.name() != "gpui-shell" { | |
| bail!( | |
| "`bundle` installs gpui-shell releases on the user's computer, which do not \ | |
| carry {}'s components", | |
| brand.name() | |
| ); | |
| } | |
| let root = crate::resolve_app_root(directory, ENTRY)?; | |
| if !root.join(MANIFEST_FILE).is_file() { | |
| bail!( | |
| "{} has no {MANIFEST_FILE}. A bundle needs one: its `id` keeps the user's data \ | |
| when the application is moved or updated, and its `name` and `version` name the bundle.", | |
| root.display() | |
| ); | |
| } | |
| let manifest = PluginManifest::read(&root)?; | |
| let platform = options.platform.unwrap_or_else(Platform::host); | |
| if options.download_url.is_some() && platform != Platform::Linux { | |
| bail!("`--download-url` is for the Linux bundle, whose install.sh downloads the archive"); | |
| } | |
| let icon = options.icon.as_deref().map(read_icon).transpose()?; | |
| let out = match &options.out { | |
| Some(out) => out.clone(), | |
| None => std::env::current_dir()?.join("dist"), | |
| }; | |
| fs::create_dir_all(&out).with_context(|| format!("cannot create {}", out.display()))?; | |
| // The output directory is often inside the application (`dist/` beside | |
| // `main.js`), and a bundle must not contain the previous bundle. | |
| let exclude = out.canonicalize()?; | |
| let names = Names::new(&manifest, platform); | |
| let mut values = ScriptValues { | |
| id: manifest.id(), | |
| name: manifest.name(), | |
| command: &names.command, | |
| app_version: manifest.version(), | |
| runtime_version: brand.version(), | |
| icon_size: String::new(), | |
| archive: String::new(), | |
| archive_sha256: String::new(), | |
| download_url: options | |
| .download_url | |
| .as_deref() | |
| .unwrap_or_default() | |
| .trim_end_matches('/'), | |
| stem: &names.archive_stem, | |
| }; | |
| let runtime = format!( | |
| "It uses gpui-shell {} or later when one is installed, and downloads v{} otherwise.", | |
| brand.version(), | |
| brand.version() | |
| ); | |
| let mut tree = Tree::default(); | |
| let report = match platform { | |
| Platform::MacOS => { | |
| let contents = format!("{}/Contents", names.app_bundle); | |
| tree.bytes( | |
| format!("{contents}/Info.plist"), | |
| info_plist(&manifest, &names, icon.is_some()).into_bytes(), | |
| false, | |
| ); | |
| tree.bytes(format!("{contents}/PkgInfo"), b"APPL????".to_vec(), false); | |
| tree.bytes( | |
| format!("{contents}/MacOS/{}", names.display), | |
| shell_script(MACOS_LAUNCHER, &values).into_bytes(), | |
| true, | |
| ); | |
| if let Some(icon) = &icon { | |
| tree.bytes( | |
| format!("{contents}/Resources/{}.icns", names.display), | |
| icns(icon)?, | |
| false, | |
| ); | |
| } | |
| add_application( | |
| &mut tree, | |
| &root, | |
| &format!("{contents}/Resources/app"), | |
| &exclude, | |
| )?; | |
| let bundle = out.join(&names.app_bundle); | |
| replace_directory(&tree, &out, &bundle)?; | |
| format!( | |
| "bundled {}\n{runtime} The first launch downloads it when needed.\n\ | |
| The bundle is not signed. Sign and notarize it before giving it to anyone; \ | |
| see https://gpui-kit.com/shell/packaging#signing-on-macos", | |
| bundle.display() | |
| ) | |
| } | |
| Platform::Linux => { | |
| let top = &names.archive_stem; | |
| if let Some(icon) = &icon { | |
| let size = [512, 256, 128, 64, 48, 32, 16] | |
| .into_iter() | |
| .find(|size| *size <= icon.width()) | |
| .unwrap_or(icon.width()); | |
| tree.bytes( | |
| format!("{top}/{}.png", names.command), | |
| png(icon, size)?, | |
| false, | |
| ); | |
| values.icon_size = size.to_string(); | |
| } | |
| tree.bytes( | |
| format!("{top}/{}", names.command), | |
| shell_script(LINUX_LAUNCHER, &values).into_bytes(), | |
| true, | |
| ); | |
| add_application(&mut tree, &root, &format!("{top}/app"), &exclude)?; | |
| let archive = out.join(format!("{top}.tar.gz")); | |
| replace_file(&archive, |file| write_tar_gz(&tree, file))?; | |
| // Outside the archive, and holding its checksum: it installs the | |
| // archive beside it, or downloads it, and trusts neither unchecked. | |
| values.archive = format!("{top}.tar.gz"); | |
| values.archive_sha256 = sha256_hex( | |
| &fs::read(&archive) | |
| .with_context(|| format!("cannot read {}", archive.display()))?, | |
| ); | |
| let installer = out.join("install.sh"); | |
| let script = shell_script(LINUX_INSTALLER, &values); | |
| replace_file(&installer, |mut file| file.write_all(script.as_bytes()))?; | |
| set_executable(&installer)?; | |
| let remote = match options.download_url.as_deref() { | |
| Some(url) => format!( | |
| "\nPublish both at {url}; `curl -fsSL {}/install.sh | sh` then installs it.", | |
| url.trim_end_matches('/') | |
| ), | |
| None => String::new(), | |
| }; | |
| format!( | |
| "bundled {}\nbundled {}\n\ | |
| Unpack the archive and run the launcher in it, or run `sh install.sh` beside the \ | |
| archive to install it for the current user; `sh install.sh --uninstall` removes \ | |
| it.{remote}\n{runtime}", | |
| archive.display(), | |
| installer.display() | |
| ) | |
| } | |
| Platform::Windows => { | |
| // The one bundle with a runtime in it: a portable folder has no | |
| // installer to fetch one. | |
| let mut executable = windows_runtime(brand, download_verified)?; | |
| mark_as_gui_program(&mut executable)?; | |
| let top = format!("{}-x86_64", names.archive_stem); | |
| tree.bytes(format!("{top}/{}.exe", names.display), executable, true); | |
| add_application(&mut tree, &root, &format!("{top}/app"), &exclude)?; | |
| let archive = out.join(format!("{top}.zip")); | |
| replace_file(&archive, |file| write_zip(&tree, file))?; | |
| let mut report = format!( | |
| "bundled {}\nUnzip it anywhere and open {}.exe; it needs no installation. It \ | |
| carries gpui-shell {}.", | |
| archive.display(), | |
| names.display, | |
| brand.version() | |
| ); | |
| if icon.is_some() { | |
| report.push_str("\nThe icon is not embedded: the executable keeps gpui-shell's."); | |
| } | |
| report | |
| } | |
| }; | |
| Ok(report) | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Names | |
| // --------------------------------------------------------------------------- | |
| /// The names one bundle uses, all derived from the manifest. | |
| struct Names { | |
| /// The manifest's `name`, safe as a file name: `Folio Desk`. | |
| display: String, | |
| /// A command name: `folio-desk`. | |
| command: String, | |
| /// `Folio Desk.app`. | |
| app_bundle: String, | |
| /// `folio-desk-0.1.0-linux`, the archive's name and top directory. No | |
| /// architecture: the bundle holds no binary. | |
| archive_stem: String, | |
| } | |
| impl Names { | |
| fn new(manifest: &PluginManifest, platform: Platform) -> Self { | |
| let command = command_name(manifest); | |
| let display = file_name(manifest.name()).unwrap_or_else(|| command.clone()); | |
| Self { | |
| app_bundle: format!("{display}.app"), | |
| archive_stem: format!("{command}-{}-{}", manifest.version(), platform.as_str()), | |
| display, | |
| command, | |
| } | |
| } | |
| } | |
| /// The manifest's `name` as a command: lowercase ASCII words joined by `-`. | |
| /// A name with no ASCII letters or digits falls back to the last part of the | |
| /// `id`, which the manifest already restricts to that alphabet. | |
| fn command_name(manifest: &PluginManifest) -> String { | |
| let slug = |value: &str| { | |
| value | |
| .split(|character: char| !character.is_ascii_alphanumeric()) | |
| .filter(|word| !word.is_empty()) | |
| .map(str::to_ascii_lowercase) | |
| .collect::<Vec<_>>() | |
| .join("-") | |
| }; | |
| [ | |
| slug(manifest.name()), | |
| slug(manifest.id().rsplit('.').next().unwrap_or_default()), | |
| ] | |
| .into_iter() | |
| .find(|name| !name.is_empty()) | |
| .unwrap_or_else(|| "app".to_owned()) | |
| } | |
| /// `name` without the characters a file name cannot hold on any of the three | |
| /// platforms, or `None` when nothing is left. | |
| fn file_name(name: &str) -> Option<String> { | |
| let cleaned: String = name | |
| .chars() | |
| .map(|character| match character { | |
| '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-', | |
| character if character.is_control() => '-', | |
| character => character, | |
| }) | |
| .collect(); | |
| let cleaned = cleaned.trim().trim_end_matches('.').trim(); | |
| (!cleaned.is_empty()).then(|| cleaned.to_owned()) | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Scripts | |
| // --------------------------------------------------------------------------- | |
| /// What a script template's `@NAME@` placeholders stand for. | |
| struct ScriptValues<'a> { | |
| id: &'a str, | |
| name: &'a str, | |
| command: &'a str, | |
| app_version: &'a str, | |
| /// The release the scripts download, and the oldest one they accept. | |
| runtime_version: &'a str, | |
| /// The Linux icon's size in pixels, empty without an icon. | |
| icon_size: String, | |
| /// The Linux archive's file name and SHA-256, for `install.sh`. | |
| archive: String, | |
| archive_sha256: String, | |
| /// Where the Linux archive is published, empty when nowhere. | |
| download_url: &'a str, | |
| /// The archive's top directory. | |
| stem: &'a str, | |
| } | |
| impl ScriptValues<'_> { | |
| /// The raw value of one placeholder, or `None` for one this does not know. | |
| fn get(&self, placeholder: &str) -> Option<&str> { | |
| Some(match placeholder { | |
| "ID" => self.id, | |
| "NAME" => self.name, | |
| "COMMAND" => self.command, | |
| "APP_VERSION" => self.app_version, | |
| "RUNTIME_VERSION" => self.runtime_version, | |
| "ICON_SIZE" => &self.icon_size, | |
| "ARCHIVE" => &self.archive, | |
| "ARCHIVE_SHA256" => &self.archive_sha256, | |
| "DOWNLOAD_URL" => self.download_url, | |
| "STEM" => self.stem, | |
| _ => return None, | |
| }) | |
| } | |
| } | |
| /// A POSIX shell template with every value as one single-quoted word. A line | |
| /// holding only `@RUNTIME_FUNCTIONS@` becomes the shared runtime functions. | |
| fn shell_script(template: &str, values: &ScriptValues) -> String { | |
| substitute(template, |placeholder| match placeholder { | |
| "RUNTIME_FUNCTIONS" => Some(shell_script(RUNTIME_FUNCTIONS, values)), | |
| placeholder => values.get(placeholder).map(shell_quote), | |
| }) | |
| } | |
| /// Replaces each `@NAME@` — uppercase letters, digits and underscores — that | |
| /// `replacement` knows, in one pass, so a value containing `@ID@` is never | |
| /// expanded again. Anything else between `@`s, such as an address in a | |
| /// comment, is left as written. | |
| fn substitute(template: &str, replacement: impl Fn(&str) -> Option<String>) -> String { | |
| let mut out = String::with_capacity(template.len()); | |
| let mut rest = template; | |
| while let Some(start) = rest.find('@') { | |
| out.push_str(&rest[..start]); | |
| let after = &rest[start + 1..]; | |
| let name_length = after | |
| .find(|character: char| { | |
| !(character.is_ascii_uppercase() || character.is_ascii_digit() || character == '_') | |
| }) | |
| .unwrap_or(after.len()); | |
| let name = &after[..name_length]; | |
| match after[name_length..] | |
| .starts_with('@') | |
| .then(|| replacement(name)) | |
| .flatten() | |
| { | |
| Some(value) => { | |
| out.push_str(&value); | |
| rest = &after[name_length + 1..]; | |
| } | |
| None => { | |
| out.push('@'); | |
| rest = after; | |
| } | |
| } | |
| } | |
| out.push_str(rest); | |
| out | |
| } | |
| /// One POSIX shell word whose value is `value`, whatever it contains. | |
| fn shell_quote(value: &str) -> String { | |
| format!("'{}'", value.replace('\'', r"'\''")) | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Metadata | |
| // --------------------------------------------------------------------------- | |
| fn info_plist(manifest: &PluginManifest, names: &Names, has_icon: bool) -> String { | |
| // `CFBundleVersion` takes up to three integers, so a pre-release or build | |
| // suffix stays in the short version only. The manifest checked it is semver. | |
| let build = semver::Version::parse(manifest.version()) | |
| .map(|version| format!("{}.{}.{}", version.major, version.minor, version.patch)) | |
| .unwrap_or_else(|_| manifest.version().to_owned()); | |
| let mut entries = vec![ | |
| ("CFBundleDevelopmentRegion", "en".to_owned()), | |
| ("CFBundleDisplayName", manifest.name().to_owned()), | |
| ("CFBundleExecutable", names.display.clone()), | |
| ("CFBundleIdentifier", manifest.id().to_owned()), | |
| ("CFBundleInfoDictionaryVersion", "6.0".to_owned()), | |
| ("CFBundleName", manifest.name().to_owned()), | |
| ("CFBundlePackageType", "APPL".to_owned()), | |
| ("CFBundleShortVersionString", manifest.version().to_owned()), | |
| ("CFBundleVersion", build), | |
| ("LSMinimumSystemVersion", "11.0".to_owned()), | |
| ]; | |
| if has_icon { | |
| entries.push(("CFBundleIconFile", names.display.clone())); | |
| } | |
| entries.sort_by_key(|(key, _)| *key); | |
| let mut plist = String::from( | |
| "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\ | |
| <!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \ | |
| \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\ | |
| <plist version=\"1.0\">\n<dict>\n", | |
| ); | |
| for (key, value) in entries { | |
| plist.push_str(&format!( | |
| " <key>{key}</key>\n <string>{}</string>\n", | |
| xml_escape(&value) | |
| )); | |
| } | |
| plist.push_str(" <key>NSHighResolutionCapable</key>\n <true/>\n</dict>\n</plist>\n"); | |
| plist | |
| } | |
| fn xml_escape(value: &str) -> String { | |
| value | |
| .replace('&', "&") | |
| .replace('<', "<") | |
| .replace('>', ">") | |
| .replace('"', """) | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Icons | |
| // --------------------------------------------------------------------------- | |
| fn read_icon(path: &Path) -> Result<RgbaImage> { | |
| let bytes = fs::read(path).with_context(|| format!("cannot read {}", path.display()))?; | |
| let icon = image::load_from_memory_with_format(&bytes, ImageFormat::Png) | |
| .with_context(|| format!("{} is not a PNG image", path.display()))? | |
| .into_rgba8(); | |
| if icon.width() != icon.height() || icon.width() < 16 { | |
| bail!( | |
| "{} is {}×{}; the icon must be a square PNG, ideally 1024×1024", | |
| path.display(), | |
| icon.width(), | |
| icon.height() | |
| ); | |
| } | |
| Ok(icon) | |
| } | |
| /// `icon` scaled to `size`, as PNG bytes. | |
| fn png(icon: &RgbaImage, size: u32) -> Result<Vec<u8>> { | |
| let scaled; | |
| let image = if icon.width() == size { | |
| icon | |
| } else { | |
| scaled = image::imageops::resize(icon, size, size, FilterType::Lanczos3); | |
| &scaled | |
| }; | |
| let mut bytes = io::Cursor::new(Vec::new()); | |
| image.write_to(&mut bytes, ImageFormat::Png)?; | |
| Ok(bytes.into_inner()) | |
| } | |
| /// An `.icns` file holding every standard size up to the source's, each as PNG. | |
| fn icns(icon: &RgbaImage) -> Result<Vec<u8>> { | |
| const SIZES: [(u32, &[u8; 4]); 7] = [ | |
| (16, b"icp4"), | |
| (32, b"icp5"), | |
| (64, b"icp6"), | |
| (128, b"ic07"), | |
| (256, b"ic08"), | |
| (512, b"ic09"), | |
| (1024, b"ic10"), | |
| ]; | |
| let mut body = Vec::new(); | |
| for (size, kind) in SIZES.iter().filter(|(size, _)| *size <= icon.width()) { | |
| let image = png(icon, *size)?; | |
| body.extend_from_slice(*kind); | |
| body.extend_from_slice(&(image.len() as u32 + 8).to_be_bytes()); | |
| body.extend_from_slice(&image); | |
| } | |
| let mut file = b"icns".to_vec(); | |
| file.extend_from_slice(&(body.len() as u32 + 8).to_be_bytes()); | |
| file.extend_from_slice(&body); | |
| Ok(file) | |
| } | |
| // --------------------------------------------------------------------------- | |
| // The tree | |
| // --------------------------------------------------------------------------- | |
| /// The files of one bundle, by their `/`-separated path inside it, written | |
| /// out as a directory or an archive. | |
| #[derive(Default)] | |
| struct Tree { | |
| entries: Vec<Entry>, | |
| directories: BTreeSet<String>, | |
| } | |
| struct Entry { | |
| path: String, | |
| contents: Contents, | |
| executable: bool, | |
| } | |
| enum Contents { | |
| Directory, | |
| File(PathBuf), | |
| Bytes(Vec<u8>), | |
| } | |
| impl Tree { | |
| fn directory(&mut self, path: &str) { | |
| if path.is_empty() || self.directories.contains(path) { | |
| return; | |
| } | |
| if let Some((parent, _)) = path.rsplit_once('/') { | |
| self.directory(parent); | |
| } | |
| self.directories.insert(path.to_owned()); | |
| self.entries.push(Entry { | |
| path: path.to_owned(), | |
| contents: Contents::Directory, | |
| executable: true, | |
| }); | |
| } | |
| fn add(&mut self, path: String, contents: Contents, executable: bool) { | |
| if let Some((parent, _)) = path.rsplit_once('/') { | |
| self.directory(parent); | |
| } | |
| self.entries.push(Entry { | |
| path, | |
| contents, | |
| executable, | |
| }); | |
| } | |
| fn bytes(&mut self, path: String, bytes: Vec<u8>, executable: bool) { | |
| self.add(path, Contents::Bytes(bytes), executable); | |
| } | |
| } | |
| impl Entry { | |
| fn read(&self) -> io::Result<Vec<u8>> { | |
| match &self.contents { | |
| Contents::Directory => Ok(Vec::new()), | |
| Contents::File(path) => fs::read(path), | |
| Contents::Bytes(bytes) => Ok(bytes.clone()), | |
| } | |
| } | |
| fn mode(&self) -> u32 { | |
| if self.executable { 0o755 } else { 0o644 } | |
| } | |
| } | |
| /// Adds the application's files under `prefix`, leaving out what only | |
| /// development needs: hidden entries, `node_modules`, and the editor files | |
| /// `gpui-shell` writes (`gpui-kit.d.ts`, `jsconfig.json`, `AGENTS.md`). A | |
| /// bundled application does not write them back; see `set_editor_files`. | |
| fn add_application(tree: &mut Tree, root: &Path, prefix: &str, exclude: &Path) -> Result<()> { | |
| tree.directory(prefix); | |
| let mut visited = BTreeSet::new(); | |
| add_directory(tree, root, prefix, exclude, true, &mut visited) | |
| } | |
| fn add_directory( | |
| tree: &mut Tree, | |
| directory: &Path, | |
| prefix: &str, | |
| exclude: &Path, | |
| is_root: bool, | |
| visited: &mut BTreeSet<PathBuf>, | |
| ) -> Result<()> { | |
| // A symbolic link back up the tree would otherwise copy forever. | |
| if !visited.insert(directory.canonicalize()?) { | |
| return Ok(()); | |
| } | |
| let mut entries = fs::read_dir(directory) | |
| .with_context(|| format!("cannot read {}", directory.display()))? | |
| .collect::<io::Result<Vec<_>>>()?; | |
| entries.sort_by_key(|entry| entry.file_name()); | |
| for entry in entries { | |
| let name = entry.file_name(); | |
| let Some(name) = name.to_str() else { | |
| bail!( | |
| "{} is not a UTF-8 file name, which an archive cannot hold portably", | |
| entry.path().display() | |
| ); | |
| }; | |
| let left_out = name.starts_with('.') | |
| || name == "node_modules" | |
| || name == typings::FILE_NAME | |
| || (is_root && (name == typings::CONFIG_FILE_NAME || name == AGENTS_FILE)); | |
| let path = entry.path(); | |
| if left_out || path.canonicalize().is_ok_and(|path| path == exclude) { | |
| continue; | |
| } | |
| // Follows symbolic links: the bundle holds what they point at. | |
| let metadata = | |
| fs::metadata(&path).with_context(|| format!("cannot read {}", path.display()))?; | |
| let inner = format!("{prefix}/{name}"); | |
| if metadata.is_dir() { | |
| tree.directory(&inner); | |
| add_directory(tree, &path, &inner, exclude, false, visited)?; | |
| } else if metadata.is_file() { | |
| tree.add(inner, Contents::File(path), is_executable(&metadata)); | |
| } | |
| } | |
| Ok(()) | |
| } | |
| #[cfg(unix)] | |
| fn is_executable(metadata: &fs::Metadata) -> bool { | |
| use std::os::unix::fs::PermissionsExt as _; | |
| metadata.permissions().mode() & 0o111 != 0 | |
| } | |
| #[cfg(not(unix))] | |
| fn is_executable(_: &fs::Metadata) -> bool { | |
| false | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Output | |
| // --------------------------------------------------------------------------- | |
| /// Writes `tree` into `out` and moves its single top-level directory to | |
| /// `destination`, replacing an earlier bundle there and nothing else. | |
| fn replace_directory(tree: &Tree, out: &Path, destination: &Path) -> Result<()> { | |
| if destination.exists() && !destination.join("Contents").join("Info.plist").is_file() { | |
| bail!( | |
| "{} exists and is not an application bundle; move it away first", | |
| destination.display() | |
| ); | |
| } | |
| let staging = out.join(format!(".bundle-{}", std::process::id())); | |
| let _ = fs::remove_dir_all(&staging); | |
| let written = write_directory(tree, &staging).and_then(|()| { | |
| let top = fs::read_dir(&staging)? | |
| .next() | |
| .ok_or_else(|| io::Error::other("the bundle is empty"))?? | |
| .path(); | |
| if destination.exists() { | |
| fs::remove_dir_all(destination)?; | |
| } | |
| fs::rename(top, destination) | |
| }); | |
| let _ = fs::remove_dir_all(&staging); | |
| written.with_context(|| format!("cannot write {}", destination.display())) | |
| } | |
| /// Writes an archive beside `destination` and renames it into place, so an | |
| /// interrupted run never leaves half an archive under the final name. | |
| fn replace_file(destination: &Path, write: impl FnOnce(fs::File) -> io::Result<()>) -> Result<()> { | |
| let mut partial = destination.as_os_str().to_owned(); | |
| partial.push(".partial"); | |
| let partial = PathBuf::from(partial); | |
| let written = fs::File::create(&partial) | |
| .and_then(write) | |
| .and_then(|()| fs::rename(&partial, destination)); | |
| if written.is_err() { | |
| let _ = fs::remove_file(&partial); | |
| } | |
| written.with_context(|| format!("cannot write {}", destination.display())) | |
| } | |
| fn write_directory(tree: &Tree, destination: &Path) -> io::Result<()> { | |
| for entry in &tree.entries { | |
| let path = destination.join(&entry.path); | |
| match &entry.contents { | |
| Contents::Directory => fs::create_dir_all(&path)?, | |
| Contents::File(source) => { | |
| fs::copy(source, &path)?; | |
| } | |
| Contents::Bytes(bytes) => fs::write(&path, bytes)?, | |
| } | |
| #[cfg(unix)] | |
| { | |
| use std::os::unix::fs::PermissionsExt as _; | |
| fs::set_permissions(&path, fs::Permissions::from_mode(entry.mode()))?; | |
| } | |
| } | |
| Ok(()) | |
| } | |
| fn write_tar_gz(tree: &Tree, out: impl io::Write) -> io::Result<()> { | |
| let encoder = | |
| flate2::write::GzEncoder::new(io::BufWriter::new(out), flate2::Compression::default()); | |
| let mut archive = tar::Builder::new(encoder); | |
| let modified = std::time::SystemTime::now() | |
| .duration_since(std::time::UNIX_EPOCH) | |
| .map_or(0, |time| time.as_secs()); | |
| for entry in &tree.entries { | |
| let mut header = tar::Header::new_gnu(); | |
| header.set_mode(entry.mode()); | |
| header.set_mtime(modified); | |
| match &entry.contents { | |
| Contents::Directory => { | |
| header.set_entry_type(tar::EntryType::Directory); | |
| header.set_size(0); | |
| archive.append_data(&mut header, format!("{}/", entry.path), io::empty())?; | |
| } | |
| _ => { | |
| let bytes = entry.read()?; | |
| header.set_entry_type(tar::EntryType::Regular); | |
| header.set_size(bytes.len() as u64); | |
| archive.append_data(&mut header, &entry.path, bytes.as_slice())?; | |
| } | |
| } | |
| } | |
| archive.into_inner()?.finish()?.flush() | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Downloads | |
| // --------------------------------------------------------------------------- | |
| fn sha256_hex(bytes: &[u8]) -> String { | |
| use sha2::Digest as _; | |
| sha2::Sha256::digest(bytes) | |
| .iter() | |
| .map(|byte| format!("{byte:02x}")) | |
| .collect() | |
| } | |
| /// Downloads `url` and checks it against the `.sha256` the release carries | |
| /// beside it, in either the `<hex> <name>` or the `<hex> *<name>` form. | |
| #[cfg(feature = "quickjs")] | |
| fn download_verified(url: &str) -> Result<Vec<u8>> { | |
| let get = |url: &str| -> Result<Vec<u8>> { | |
| let response = reqwest::blocking::get(url)?.error_for_status()?; | |
| Ok(response.bytes()?.to_vec()) | |
| }; | |
| let bytes = get(url)?; | |
| let checksum = String::from_utf8(get(&format!("{url}.sha256"))?)?; | |
| let Some(expected) = checksum.split_whitespace().next() else { | |
| bail!("{url}.sha256 is empty"); | |
| }; | |
| if !expected.eq_ignore_ascii_case(&sha256_hex(&bytes)) { | |
| bail!("{url} does not match its checksum"); | |
| } | |
| Ok(bytes) | |
| } | |
| #[cfg(not(feature = "quickjs"))] | |
| fn download_verified(_: &str) -> Result<Vec<u8>> { | |
| bail!("this build of gpui-shell cannot download; it was built without the `quickjs` feature") | |
| } | |
| #[cfg(unix)] | |
| fn set_executable(path: &Path) -> Result<()> { | |
| use std::os::unix::fs::PermissionsExt as _; | |
| fs::set_permissions(path, fs::Permissions::from_mode(0o755)) | |
| .with_context(|| format!("cannot make {} executable", path.display())) | |
| } | |
| #[cfg(not(unix))] | |
| fn set_executable(_: &Path) -> Result<()> { | |
| Ok(()) | |
| } | |
| // --------------------------------------------------------------------------- | |
| // The Windows executable | |
| // --------------------------------------------------------------------------- | |
| /// The `gpui-shell.exe` a Windows bundle carries, of this command's version. | |
| /// | |
| /// On Windows that is the running executable. Elsewhere it is the release's, | |
| /// downloaded once into the cache beside the dependency cache — the shell home, | |
| /// which `GPUI_SHELL_HOME` moves. | |
| fn windows_runtime( | |
| brand: HostBrand, | |
| download: impl FnOnce(&str) -> Result<Vec<u8>>, | |
| ) -> Result<Vec<u8>> { | |
| if cfg!(target_os = "windows") { | |
| let executable = std::env::current_exe().context("cannot locate the running executable")?; | |
| return fs::read(&executable) | |
| .with_context(|| format!("cannot read {}", executable.display())); | |
| } | |
| let version = brand.version(); | |
| let applications = super::installed::applications_directory(brand); | |
| let cache = applications | |
| .parent() | |
| .unwrap_or(&applications) | |
| .join("cache") | |
| .join("runtime") | |
| .join(format!("v{version}")) | |
| .join(WINDOWS_TARGET) | |
| .join("gpui-shell.exe"); | |
| windows_runtime_from(&cache, version, download) | |
| } | |
| /// The cached copy at `cache`, else the release `v<version>` unpacked into it. | |
| fn windows_runtime_from( | |
| cache: &Path, | |
| version: &str, | |
| download: impl FnOnce(&str) -> Result<Vec<u8>>, | |
| ) -> Result<Vec<u8>> { | |
| if cache.is_file() { | |
| let executable = | |
| fs::read(cache).with_context(|| format!("cannot read {}", cache.display()))?; | |
| pe_header(&executable) | |
| .ok_or_else(|| anyhow::anyhow!("{} is not a Windows executable", cache.display()))?; | |
| return Ok(executable); | |
| } | |
| let url = format!( | |
| "https://github.com/longbridge/gpui-kit/releases/download/v{version}/gpui-shell-{WINDOWS_TARGET}.zip" | |
| ); | |
| let archive = download(&url).with_context(|| { | |
| format!( | |
| "cannot download gpui-shell {version} for Windows from {url}; a Windows bundle \ | |
| carries it. Offline, put that release's gpui-shell.exe at {}", | |
| cache.display() | |
| ) | |
| })?; | |
| let executable = zip_entry( | |
| &archive, | |
| &format!("gpui-shell-{WINDOWS_TARGET}/gpui-shell.exe"), | |
| ) | |
| .with_context(|| url.clone())?; | |
| pe_header(&executable).ok_or_else(|| anyhow::anyhow!("{url} holds no Windows executable"))?; | |
| if let Some(directory) = cache.parent() { | |
| fs::create_dir_all(directory) | |
| .with_context(|| format!("cannot create {}", directory.display()))?; | |
| } | |
| replace_file(cache, |mut file| file.write_all(&executable))?; | |
| Ok(executable) | |
| } | |
| /// The contents of the file `name` in a zip archive, stored or deflated. | |
| fn zip_entry(archive: &[u8], name: &str) -> Result<Vec<u8>> { | |
| use std::io::Read as _; | |
| let u16_at = |at: usize| -> Result<usize> { | |
| let bytes = archive | |
| .get(at..at + 2) | |
| .ok_or_else(|| anyhow::anyhow!("the zip archive is truncated"))?; | |
| Ok(u16::from_le_bytes([bytes[0], bytes[1]]) as usize) | |
| }; | |
| let u32_at = |at: usize| -> Result<usize> { | |
| let bytes = archive | |
| .get(at..at + 4) | |
| .ok_or_else(|| anyhow::anyhow!("the zip archive is truncated"))?; | |
| Ok(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as usize) | |
| }; | |
| // The end record is the last thing in the file, before a comment of up | |
| // to 64 KiB. | |
| let end = (0..archive.len().saturating_sub(21)) | |
| .rev() | |
| .take(65_557) | |
| .find(|at| archive[*at..].starts_with(&0x0605_4b50u32.to_le_bytes())) | |
| .ok_or_else(|| anyhow::anyhow!("not a zip archive"))?; | |
| let count = u16_at(end + 10)?; | |
| let mut at = u32_at(end + 16)?; | |
| for _ in 0..count { | |
| if u32_at(at)? != 0x0201_4b50 { | |
| bail!("the zip archive's directory is damaged"); | |
| } | |
| let method = u16_at(at + 10)?; | |
| let compressed = u32_at(at + 20)?; | |
| let size = u32_at(at + 24)?; | |
| let name_length = u16_at(at + 28)?; | |
| let skip = name_length + u16_at(at + 30)? + u16_at(at + 32)?; | |
| let local = u32_at(at + 42)?; | |
| let entry = archive | |
| .get(at + 46..at + 46 + name_length) | |
| .ok_or_else(|| anyhow::anyhow!("the zip archive is truncated"))?; | |
| if entry == name.as_bytes() { | |
| if u32_at(local)? != 0x0403_4b50 { | |
| bail!("the zip archive's entry for {name} is damaged"); | |
| } | |
| let data = local + 30 + u16_at(local + 26)? + u16_at(local + 28)?; | |
| let stored = archive | |
| .get(data..data + compressed) | |
| .ok_or_else(|| anyhow::anyhow!("the zip archive is truncated"))?; | |
| let mut contents = Vec::with_capacity(size); | |
| match method { | |
| 0 => contents.extend_from_slice(stored), | |
| 8 => { | |
| flate2::read::DeflateDecoder::new(stored).read_to_end(&mut contents)?; | |
| } | |
| other => bail!("{name} is compressed with method {other}, which is not supported"), | |
| } | |
| if contents.len() != size { | |
| bail!("{name} in the zip archive is damaged"); | |
| } | |
| return Ok(contents); | |
| } | |
| at += 46 + skip; | |
| } | |
| bail!("the zip archive holds no {name}") | |
| } | |
| /// Where a PE file's `PE\0\0` signature is. | |
| fn pe_header(bytes: &[u8]) -> Option<usize> { | |
| if !bytes.starts_with(b"MZ") { | |
| return None; | |
| } | |
| let at = u32::from_le_bytes(bytes.get(0x3c..0x40)?.try_into().ok()?) as usize; | |
| (bytes.get(at..at + 4)? == b"PE\0\0").then_some(at) | |
| } | |
| /// Marks a Windows executable as a GUI program rather than a console one. | |
| /// | |
| /// `gpui-shell.exe` is a console program, so its commands can print; started | |
| /// from Explorer, a console program opens a console window beside its own. A | |
| /// bundle is started from Explorer, so its copy is switched to the GUI | |
| /// subsystem — one field of the optional header, the same one the linker's | |
| /// `/SUBSYSTEM:WINDOWS` sets. Done before anyone signs it, so it breaks no | |
| /// signature. | |
| fn mark_as_gui_program(bytes: &mut [u8]) -> Result<()> { | |
| const IMAGE_SUBSYSTEM_WINDOWS_GUI: u16 = 2; | |
| let optional = pe_header(bytes).ok_or_else(|| anyhow::anyhow!("not a PE executable"))? + 24; | |
| let magic = bytes | |
| .get(optional..optional + 2) | |
| .map(|magic| u16::from_le_bytes([magic[0], magic[1]])); | |
| // PE32 and PE32+ keep the subsystem at the same offset. | |
| if !matches!(magic, Some(0x10b | 0x20b)) { | |
| bail!("the PE executable has no optional header"); | |
| } | |
| let subsystem = optional + 68; | |
| let field = bytes | |
| .get_mut(subsystem..subsystem + 2) | |
| .ok_or_else(|| anyhow::anyhow!("the PE optional header is truncated"))?; | |
| field.copy_from_slice(&IMAGE_SUBSYSTEM_WINDOWS_GUI.to_le_bytes()); | |
| Ok(()) | |
| } | |
| /// A zip archive, deflated, with Unix modes so an executable stays one when | |
| /// it is unpacked on macOS or Linux. No Zip64: a bundle is far below 4 GiB. | |
| fn write_zip(tree: &Tree, file: fs::File) -> io::Result<()> { | |
| let too_large = || io::Error::other("the bundle is too large for a zip archive"); | |
| let mut out = io::BufWriter::new(file); | |
| let (time, date) = dos_time(chrono::Local::now().naive_local()); | |
| let mut central = Vec::new(); | |
| let mut offset = 0u32; | |
| for entry in &tree.entries { | |
| let is_directory = matches!(entry.contents, Contents::Directory); | |
| let name = if is_directory { | |
| format!("{}/", entry.path) | |
| } else { | |
| entry.path.clone() | |
| }; | |
| let data = entry.read()?; | |
| let mut crc = flate2::Crc::new(); | |
| crc.update(&data); | |
| let (method, stored) = if is_directory { | |
| (0u16, Vec::new()) | |
| } else { | |
| let mut encoder = | |
| flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); | |
| encoder.write_all(&data)?; | |
| (8u16, encoder.finish()?) | |
| }; | |
| let compressed = u32::try_from(stored.len()).map_err(|_| too_large())?; | |
| let size = u32::try_from(data.len()).map_err(|_| too_large())?; | |
| let name_length = u16::try_from(name.len()).map_err(|_| too_large())?; | |
| // The bit for UTF-8 names. | |
| let flags = 0x0800u16; | |
| let mut local = Vec::with_capacity(30 + name.len()); | |
| local.extend_from_slice(&0x0403_4b50u32.to_le_bytes()); | |
| local.extend_from_slice(&20u16.to_le_bytes()); | |
| local.extend_from_slice(&flags.to_le_bytes()); | |
| local.extend_from_slice(&method.to_le_bytes()); | |
| local.extend_from_slice(&time.to_le_bytes()); | |
| local.extend_from_slice(&date.to_le_bytes()); | |
| local.extend_from_slice(&crc.sum().to_le_bytes()); | |
| local.extend_from_slice(&compressed.to_le_bytes()); | |
| local.extend_from_slice(&size.to_le_bytes()); | |
| local.extend_from_slice(&name_length.to_le_bytes()); | |
| local.extend_from_slice(&0u16.to_le_bytes()); | |
| local.extend_from_slice(name.as_bytes()); | |
| out.write_all(&local)?; | |
| out.write_all(&stored)?; | |
| let file_type = if is_directory { 0o040000 } else { 0o100000 }; | |
| let attributes = ((file_type | entry.mode()) << 16) | if is_directory { 0x10 } else { 0 }; | |
| central.extend_from_slice(&0x0201_4b50u32.to_le_bytes()); | |
| // Made by Unix (3), so readers honour the mode in the attributes. | |
| central.extend_from_slice(&((3u16 << 8) | 20).to_le_bytes()); | |
| central.extend_from_slice(&20u16.to_le_bytes()); | |
| central.extend_from_slice(&flags.to_le_bytes()); | |
| central.extend_from_slice(&method.to_le_bytes()); | |
| central.extend_from_slice(&time.to_le_bytes()); | |
| central.extend_from_slice(&date.to_le_bytes()); | |
| central.extend_from_slice(&crc.sum().to_le_bytes()); | |
| central.extend_from_slice(&compressed.to_le_bytes()); | |
| central.extend_from_slice(&size.to_le_bytes()); | |
| central.extend_from_slice(&name_length.to_le_bytes()); | |
| // Extra field, comment, disk number, internal attributes. | |
| central.extend_from_slice(&[0; 8]); | |
| central.extend_from_slice(&attributes.to_le_bytes()); | |
| central.extend_from_slice(&offset.to_le_bytes()); | |
| central.extend_from_slice(name.as_bytes()); | |
| offset = u32::try_from(local.len() + stored.len()) | |
| .ok() | |
| .and_then(|length| offset.checked_add(length)) | |
| .ok_or_else(too_large)?; | |
| } | |
| let count = u16::try_from(tree.entries.len()).map_err(|_| too_large())?; | |
| let central_size = u32::try_from(central.len()).map_err(|_| too_large())?; | |
| out.write_all(¢ral)?; | |
| let mut end = Vec::with_capacity(22); | |
| end.extend_from_slice(&0x0605_4b50u32.to_le_bytes()); | |
| end.extend_from_slice(&[0; 4]); | |
| end.extend_from_slice(&count.to_le_bytes()); | |
| end.extend_from_slice(&count.to_le_bytes()); | |
| end.extend_from_slice(¢ral_size.to_le_bytes()); | |
| end.extend_from_slice(&offset.to_le_bytes()); | |
| end.extend_from_slice(&0u16.to_le_bytes()); | |
| out.write_all(&end)?; | |
| out.flush() | |
| } | |
| /// A zip entry's MS-DOS time and date, which cannot say anything before 1980. | |
| fn dos_time(time: chrono::NaiveDateTime) -> (u16, u16) { | |
| use chrono::{Datelike as _, Timelike as _}; | |
| if time.year() < 1980 { | |
| return (0, (1 << 5) | 1); | |
| } | |
| ( | |
| ((time.hour() << 11) | (time.minute() << 5) | (time.second() / 2)) as u16, | |
| ((((time.year() - 1980) as u32) << 9) | (time.month() << 5) | time.day()) as u16, | |
| ) | |
| } | |
| #[cfg(test)] | |
| mod tests { | |
| use super::*; | |
| fn manifest(name: &str, id: &str) -> PluginManifest { | |
| PluginManifest::parse(&format!( | |
| r#"{{ "id": "{id}", "name": "{name}", "version": "1.2.3-beta.1", "entry": "main.js" }}"# | |
| )) | |
| .expect("manifest") | |
| } | |
| #[test] | |
| fn names_come_from_the_manifest() { | |
| let names = Names::new(&manifest("Folio Desk", "com.folio.desk"), Platform::Linux); | |
| assert_eq!(names.display, "Folio Desk"); | |
| assert_eq!(names.command, "folio-desk"); | |
| assert_eq!(names.app_bundle, "Folio Desk.app"); | |
| assert_eq!(names.archive_stem, "folio-desk-1.2.3-beta.1-linux"); | |
| // A name with nothing ASCII in it takes its command from the id. | |
| let names = Names::new(&manifest("时钟", "com.example.clock"), Platform::MacOS); | |
| assert_eq!(names.command, "clock"); | |
| assert_eq!(names.display, "时钟"); | |
| assert_eq!(file_name("A/B: C?").as_deref(), Some("A-B- C-")); | |
| assert_eq!(file_name(" .. "), None); | |
| } | |
| #[test] | |
| fn the_info_plist_names_the_executable_and_escapes_values() { | |
| let manifest = manifest("Tom & Jerry", "com.example.tom"); | |
| let names = Names::new(&manifest, Platform::MacOS); | |
| let plist = info_plist(&manifest, &names, true); | |
| assert!( | |
| plist.contains("<key>CFBundleExecutable</key>\n <string>Tom & Jerry</string>") | |
| ); | |
| assert!( | |
| plist.contains("<key>CFBundleIdentifier</key>\n <string>com.example.tom</string>") | |
| ); | |
| assert!(plist.contains("<key>CFBundleVersion</key>\n <string>1.2.3</string>")); | |
| assert!(plist.contains("<string>1.2.3-beta.1</string>")); | |
| assert!(plist.contains("<key>CFBundleIconFile</key>")); | |
| } | |
| fn values<'a>(name: &'a str) -> ScriptValues<'a> { | |
| ScriptValues { | |
| id: "com.example.its", | |
| name, | |
| command: "its", | |
| app_version: "1.0.0", | |
| runtime_version: "0.8.0", | |
| icon_size: "256".to_owned(), | |
| archive: "its-1.0.0-linux.tar.gz".to_owned(), | |
| archive_sha256: "ab".repeat(32), | |
| download_url: "", | |
| stem: "its-1.0.0-linux", | |
| } | |
| } | |
| #[test] | |
| fn shell_templates_quote_every_value_once() { | |
| let script = shell_script( | |
| "id=@ID@\nname=@NAME@\nsize=@ICON_SIZE@\nmail=me@example.com @UNKNOWN@\n", | |
| &values("It's @ID@"), | |
| ); | |
| assert_eq!( | |
| script, | |
| "id='com.example.its'\nname='It'\\''s @ID@'\nsize='256'\n\ | |
| mail=me@example.com @UNKNOWN@\n" | |
| ); | |
| // Every placeholder the real templates use is one this fills. | |
| for template in [LINUX_LAUNCHER, LINUX_INSTALLER, MACOS_LAUNCHER] { | |
| let script = shell_script(template, &values("A")); | |
| // Independent of `substitute`, so a name it fails to read is caught. | |
| let left = script.split('@').skip(1).step_by(2).find(|name| { | |
| !name.is_empty() | |
| && name | |
| .chars() | |
| .all(|character| character.is_ascii_alphanumeric() || character == '_') | |
| }); | |
| assert_eq!(left, None, "a placeholder is left unfilled"); | |
| } | |
| // The runtime functions come in whole, with their own placeholders filled. | |
| let launcher = shell_script("#!/bin/sh\n@RUNTIME_FUNCTIONS@\nexec x\n", &values("A")); | |
| assert!(launcher.starts_with("#!/bin/sh\n")); | |
| assert!(launcher.ends_with("\nexec x\n")); | |
| assert!(!launcher.contains("@RUNTIME_FUNCTIONS@")); | |
| } | |
| #[test] | |
| fn an_icns_file_holds_every_size_up_to_the_source() { | |
| let icns = icns(&RgbaImage::new(64, 64)).expect("icns"); | |
| assert_eq!(&icns[..4], b"icns"); | |
| assert_eq!( | |
| u32::from_be_bytes(icns[4..8].try_into().unwrap()) as usize, | |
| icns.len() | |
| ); | |
| let mut kinds = Vec::new(); | |
| let mut at = 8; | |
| while at < icns.len() { | |
| kinds.push(String::from_utf8_lossy(&icns[at..at + 4]).into_owned()); | |
| at += u32::from_be_bytes(icns[at + 4..at + 8].try_into().unwrap()) as usize; | |
| } | |
| assert_eq!(kinds, ["icp4", "icp5", "icp6"]); | |
| } | |
| #[test] | |
| fn the_application_leaves_out_development_files_and_the_output() { | |
| let root = | |
| std::env::temp_dir().join(format!("gpui-shell-bundle-tree-{}", std::process::id())); | |
| let _ = fs::remove_dir_all(&root); | |
| for directory in ["lib", "node_modules/x", ".git", "dist"] { | |
| fs::create_dir_all(root.join(directory)).expect("directory"); | |
| } | |
| for file in [ | |
| "main.js", | |
| "gpui-shell.json", | |
| "gpui-kit.d.ts", | |
| "jsconfig.json", | |
| "AGENTS.md", | |
| "lib/util.js", | |
| "lib/gpui-kit.d.ts", | |
| "lib/AGENTS.md", | |
| "node_modules/x/index.js", | |
| ".git/HEAD", | |
| "dist/old.tar.gz", | |
| ] { | |
| fs::write(root.join(file), "").expect("file"); | |
| } | |
| let mut tree = Tree::default(); | |
| let exclude = root.join("dist").canonicalize().expect("dist"); | |
| add_application(&mut tree, &root, "top/app", &exclude).expect("tree"); | |
| let paths: Vec<_> = tree | |
| .entries | |
| .iter() | |
| .map(|entry| entry.path.as_str()) | |
| .collect(); | |
| assert_eq!( | |
| paths, | |
| [ | |
| "top", | |
| "top/app", | |
| "top/app/gpui-shell.json", | |
| "top/app/lib", | |
| "top/app/lib/AGENTS.md", | |
| "top/app/lib/util.js", | |
| "top/app/main.js", | |
| ] | |
| ); | |
| let _ = fs::remove_dir_all(&root); | |
| } | |
| #[test] | |
| fn archives_round_trip_with_their_modes() { | |
| let directory = | |
| std::env::temp_dir().join(format!("gpui-shell-bundle-archive-{}", std::process::id())); | |
| let _ = fs::remove_dir_all(&directory); | |
| fs::create_dir_all(&directory).expect("directory"); | |
| let mut tree = Tree::default(); | |
| tree.bytes("top/run".to_owned(), b"#!/bin/sh\n".to_vec(), true); | |
| tree.bytes( | |
| "top/app/main.js".to_owned(), | |
| b"export {}".repeat(100), | |
| false, | |
| ); | |
| let archive = directory.join("bundle.tar.gz"); | |
| replace_file(&archive, |file| write_tar_gz(&tree, file)).expect("tar.gz"); | |
| let mut read = tar::Archive::new(flate2::read::GzDecoder::new( | |
| fs::File::open(&archive).expect("open"), | |
| )); | |
| let entries: Vec<_> = read | |
| .entries() | |
| .expect("entries") | |
| .map(|entry| { | |
| let entry = entry.expect("entry"); | |
| ( | |
| entry.path().expect("path").display().to_string(), | |
| entry.header().mode().expect("mode"), | |
| ) | |
| }) | |
| .collect(); | |
| assert!(entries.contains(&("top/run".to_owned(), 0o755))); | |
| assert!(entries.contains(&("top/app/main.js".to_owned(), 0o644))); | |
| let zip = directory.join("bundle.zip"); | |
| replace_file(&zip, |file| write_zip(&tree, file)).expect("zip"); | |
| let bytes = fs::read(&zip).expect("zip bytes"); | |
| let end = bytes.len() - 22; | |
| assert_eq!(&bytes[end..end + 4], &0x0605_4b50u32.to_le_bytes()); | |
| assert_eq!(u16::from_le_bytes([bytes[end + 10], bytes[end + 11]]), 4); | |
| assert!(!directory.join("bundle.zip.partial").exists()); | |
| let _ = fs::remove_dir_all(&directory); | |
| } | |
| #[test] | |
| fn the_windows_executable_comes_from_the_cache_or_the_release_zip() { | |
| let directory = | |
| std::env::temp_dir().join(format!("gpui-shell-bundle-runtime-{}", std::process::id())); | |
| let _ = fs::remove_dir_all(&directory); | |
| fs::create_dir_all(&directory).expect("directory"); | |
| let mut executable = vec![0u8; 0x200]; | |
| executable[..2].copy_from_slice(b"MZ"); | |
| executable[0x3c..0x40].copy_from_slice(&0x80u32.to_le_bytes()); | |
| executable[0x80..0x84].copy_from_slice(b"PE\0\0"); | |
| executable[0x98..0x9a].copy_from_slice(&0x20bu16.to_le_bytes()); | |
| // The console subsystem, as `gpui-shell.exe` is linked. | |
| executable[0x98 + 68] = 3; | |
| // The release zip, as written by the same zip writer the bundle uses. | |
| let mut tree = Tree::default(); | |
| tree.bytes( | |
| format!("gpui-shell-{WINDOWS_TARGET}/README.md"), | |
| b"readme".to_vec(), | |
| false, | |
| ); | |
| tree.bytes( | |
| format!("gpui-shell-{WINDOWS_TARGET}/gpui-shell.exe"), | |
| executable.clone(), | |
| true, | |
| ); | |
| let release = directory.join("release.zip"); | |
| replace_file(&release, |file| write_zip(&tree, file)).expect("zip"); | |
| let release = fs::read(&release).expect("zip bytes"); | |
| let cache = directory.join("cache").join("gpui-shell.exe"); | |
| let downloaded = windows_runtime_from(&cache, "0.8.0", |url| { | |
| assert!(url.ends_with(&format!("/v0.8.0/gpui-shell-{WINDOWS_TARGET}.zip"))); | |
| Ok(release.clone()) | |
| }) | |
| .expect("downloaded"); | |
| assert_eq!(downloaded, executable); | |
| // The second bundle reads the cache and downloads nothing. | |
| let cached = | |
| windows_runtime_from(&cache, "0.8.0", |_| panic!("no download")).expect("cached"); | |
| assert_eq!(cached, executable); | |
| let mut marked = cached; | |
| mark_as_gui_program(&mut marked).expect("a PE32+ header"); | |
| assert_eq!(marked[0x98 + 68], 2); | |
| assert!(mark_as_gui_program(&mut b"#!/bin/sh\n".to_vec()).is_err()); | |
| assert!(zip_entry(&release, "missing.exe").is_err()); | |
| let _ = fs::remove_dir_all(&directory); | |
| } | |
| } |