diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a8aae26f56..9870261800 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -54,6 +54,9 @@ jobs:
- 'scripts/build-linux-packages.mjs'
- 'scripts/build-macos-packages*'
- 'scripts/bundle-gpui*'
+ - 'scripts/build-macos-bundles*'
+ - 'scripts/build-macos-icon*'
+ - 'apps/macos-bundles/**'
- 'scripts/finalize-linux-*'
- 'scripts/finalize-macos-*'
- 'scripts/linux-bundle-config*'
@@ -335,7 +338,11 @@ jobs:
uses: actions/checkout@v4
- name: Test desktop build preparation
- run: node --test scripts/prepare-gpui-dependency.test.mjs scripts/build-macos-packages.test.mjs scripts/bundle-gpui.test.mjs scripts/finalize-macos-packages.test.mjs
+ run: node --test scripts/prepare-gpui-dependency.test.mjs scripts/build-macos-packages.test.mjs scripts/bundle-gpui.test.mjs scripts/build-macos-bundles.test.mjs scripts/build-macos-icon.test.mjs scripts/finalize-macos-packages.test.mjs
+
+ - name: Compile macOS Quick Look bundles
+ if: ${{ runner.os == 'macOS' }}
+ run: swift build -c release --package-path apps/macos-bundles
- name: Test Linux packaging
if: ${{ runner.os == 'Linux' }}
diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml
index 7b93d117f9..c861255b59 100644
--- a/.github/workflows/desktop-release.yml
+++ b/.github/workflows/desktop-release.yml
@@ -231,7 +231,7 @@ jobs:
run: node --test scripts/prepare-gpui-dependency.test.mjs
- name: Test desktop packaging scripts
- run: node --test scripts/build-macos-packages.test.mjs scripts/bundle-gpui.test.mjs scripts/finalize-macos-packages.test.mjs
+ run: node --test scripts/build-macos-packages.test.mjs scripts/bundle-gpui.test.mjs scripts/build-macos-bundles.test.mjs scripts/build-macos-icon.test.mjs scripts/finalize-macos-packages.test.mjs
- name: Install Linux desktop dependencies
if: ${{ runner.os == 'Linux' }}
diff --git a/apps/desktop-gpui/Info.plist b/apps/desktop-gpui/Info.plist
index 4939978f03..72553ce1e7 100644
--- a/apps/desktop-gpui/Info.plist
+++ b/apps/desktop-gpui/Info.plist
@@ -41,5 +41,31 @@
Cap captures your screen as part of recording your Caps
NSCameraUseContinuityCameraDeviceType
+ UTExportedTypeDeclarations
+
+
+ UTTypeIdentifier
+ so.cap.desktop.project
+ UTTypeDescription
+ Cap Project
+ UTTypeConformsTo
+
+ com.apple.package
+
+ UTTypeTagSpecification
+
+ public.filename-extension
+
+ cap
+
+
+
+
+ CFBundleIconFile
+ icon.icns
+ CFBundleIconName
+ cap-solarium
+ NSAccentColorName
+ AccentColor
diff --git a/apps/desktop-gpui/assets/dock-icon.png b/apps/desktop-gpui/assets/dock-icon.png
index b7657009ef..c5be3d183b 100644
Binary files a/apps/desktop-gpui/assets/dock-icon.png and b/apps/desktop-gpui/assets/dock-icon.png differ
diff --git a/apps/desktop-gpui/icons/128x128.png b/apps/desktop-gpui/icons/128x128.png
index 70f8c47944..e9823b5f74 100644
Binary files a/apps/desktop-gpui/icons/128x128.png and b/apps/desktop-gpui/icons/128x128.png differ
diff --git a/apps/desktop-gpui/icons/128x128@2x.png b/apps/desktop-gpui/icons/128x128@2x.png
index 7c2bbaf0be..91504108c7 100644
Binary files a/apps/desktop-gpui/icons/128x128@2x.png and b/apps/desktop-gpui/icons/128x128@2x.png differ
diff --git a/apps/desktop-gpui/icons/32x32.png b/apps/desktop-gpui/icons/32x32.png
index bd5f66753f..a0c5d20b38 100644
Binary files a/apps/desktop-gpui/icons/32x32.png and b/apps/desktop-gpui/icons/32x32.png differ
diff --git a/apps/desktop-gpui/icons/Cap.xcassets/AccentColor.colorset/Contents.json b/apps/desktop-gpui/icons/Cap.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 0000000000..f8e6acd486
--- /dev/null
+++ b/apps/desktop-gpui/icons/Cap.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors": [
+ {
+ "color": {
+ "color-space": "srgb",
+ "components": {
+ "alpha": "1.000",
+ "blue": "0xFF",
+ "green": "0x7A",
+ "red": "0x00"
+ }
+ },
+ "idiom": "universal"
+ },
+ {
+ "appearances": [
+ {
+ "appearance": "luminosity",
+ "value": "dark"
+ }
+ ],
+ "color": {
+ "color-space": "srgb",
+ "components": {
+ "alpha": "1.000",
+ "blue": "0xFF",
+ "green": "0x84",
+ "red": "0x0A"
+ }
+ },
+ "idiom": "universal"
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ }
+}
diff --git a/apps/desktop-gpui/icons/Cap.xcassets/Contents.json b/apps/desktop-gpui/icons/Cap.xcassets/Contents.json
new file mode 100644
index 0000000000..d458f1c592
--- /dev/null
+++ b/apps/desktop-gpui/icons/Cap.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info": {
+ "author": "xcode",
+ "version": 1
+ }
+}
diff --git a/apps/desktop-gpui/icons/cap-solarium.icon/Assets/cap-mark.svg b/apps/desktop-gpui/icons/cap-solarium.icon/Assets/cap-mark.svg
new file mode 100644
index 0000000000..23b139de6b
--- /dev/null
+++ b/apps/desktop-gpui/icons/cap-solarium.icon/Assets/cap-mark.svg
@@ -0,0 +1,93 @@
+
\ No newline at end of file
diff --git a/apps/desktop-gpui/icons/cap-solarium.icon/icon.json b/apps/desktop-gpui/icons/cap-solarium.icon/icon.json
new file mode 100644
index 0000000000..b0af6ba867
--- /dev/null
+++ b/apps/desktop-gpui/icons/cap-solarium.icon/icon.json
@@ -0,0 +1,141 @@
+{
+ "features" : [
+ "refractivity",
+ "specular-location"
+ ],
+ "fill-specializations" : [
+ {
+ "value" : {
+ "linear-gradient" : [
+ "display-p3:1.00000,0.95753,0.96331,1.00000",
+ "display-p3:0.97104,0.97104,0.97104,1.00000"
+ ],
+ "orientation" : {
+ "start" : {
+ "x" : 0.5,
+ "y" : 1
+ },
+ "stop" : {
+ "x" : 0.5,
+ "y" : 0
+ }
+ }
+ }
+ },
+ {
+ "appearance" : "dark",
+ "value" : "automatic"
+ }
+ ],
+ "groups" : [
+ {
+ "blend-mode-specializations" : [
+ {
+ "appearance" : "dark",
+ "value" : "normal"
+ }
+ ],
+ "blur-material-specializations" : [
+ {
+ "value" : null
+ },
+ {
+ "appearance" : "tinted",
+ "value" : null
+ }
+ ],
+ "hidden-specializations" : [
+ {
+ "value" : false
+ },
+ {
+ "idiom" : "square",
+ "value" : false
+ }
+ ],
+ "layers" : [
+ {
+ "image-name" : "cap-mark.svg",
+ "name" : "cap-mark"
+ }
+ ],
+ "lighting" : "individual",
+ "position-specializations" : [
+ {
+ "value" : {
+ "scale" : 1,
+ "translation-in-points" : [
+ 0,
+ 0
+ ]
+ }
+ },
+ {
+ "idiom" : "square",
+ "value" : {
+ "scale" : 25,
+ "translation-in-points" : [
+ 0,
+ 0
+ ]
+ }
+ }
+ ],
+ "refractivity-specializations" : [
+ {
+ "value" : {
+ "depth" : 0.9164453125,
+ "enabled" : true,
+ "strength" : 0.6414453125
+ }
+ },
+ {
+ "appearance" : "tinted",
+ "value" : {
+ "depth" : 0.443515625,
+ "enabled" : true,
+ "strength" : 0.278125
+ }
+ }
+ ],
+ "shadow" : {
+ "kind" : "layer-color",
+ "opacity" : 0.6
+ },
+ "specular-specializations" : [
+ {
+ "value" : "inside"
+ },
+ {
+ "appearance" : "dark",
+ "value" : "inside"
+ },
+ {
+ "appearance" : "tinted",
+ "value" : true
+ }
+ ],
+ "translucency-specializations" : [
+ {
+ "value" : {
+ "enabled" : true,
+ "value" : 0.2
+ }
+ },
+ {
+ "appearance" : "tinted",
+ "value" : {
+ "enabled" : true,
+ "value" : 0.2
+ }
+ }
+ ]
+ }
+ ],
+ "supported-platforms" : {
+ "circles" : [
+ "watchOS"
+ ],
+ "squares" : "shared"
+ }
+}
\ No newline at end of file
diff --git a/apps/desktop-gpui/icons/icon.icns b/apps/desktop-gpui/icons/icon.icns
new file mode 100644
index 0000000000..6538d990de
Binary files /dev/null and b/apps/desktop-gpui/icons/icon.icns differ
diff --git a/apps/desktop-gpui/icons/icon.ico b/apps/desktop-gpui/icons/icon.ico
index 7108c469eb..712a1378d9 100644
Binary files a/apps/desktop-gpui/icons/icon.ico and b/apps/desktop-gpui/icons/icon.ico differ
diff --git a/apps/desktop-gpui/icons/icon.png b/apps/desktop-gpui/icons/icon.png
index 718226caf2..b28aa2b167 100644
Binary files a/apps/desktop-gpui/icons/icon.png and b/apps/desktop-gpui/icons/icon.png differ
diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs
index 0819040393..e743077a4d 100644
--- a/apps/desktop-gpui/src/main.rs
+++ b/apps/desktop-gpui/src/main.rs
@@ -376,6 +376,7 @@ fn main() {
gpui_tokio::Tokio::spawn(cx, cap_utils::operation_diagnostics::run_checkpoints()).detach();
// The dock icon: an unbundled dev binary shows the generic terminal
// document without it. The bytes are the shipping app's icon.png.
+ #[cfg(debug_assertions)]
platform::set_dock_icon(include_bytes!("../assets/dock-icon.png"));
if let Err(error) = Assets.load_fonts(cx) {
diff --git a/apps/desktop-gpui/tauri.conf.json b/apps/desktop-gpui/tauri.conf.json
index ed29600ca9..49712186b2 100644
--- a/apps/desktop-gpui/tauri.conf.json
+++ b/apps/desktop-gpui/tauri.conf.json
@@ -27,8 +27,9 @@
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
- "icons/macos/icon.icns",
- "icons/icon.ico"
+ "icons/icon.icns",
+ "icons/icon.ico",
+ "icons/icon.png"
],
"resources": {
"assets/backgrounds/macOS/*": "assets/backgrounds/macOS/",
diff --git a/apps/macos-bundles/.gitignore b/apps/macos-bundles/.gitignore
new file mode 100644
index 0000000000..30bcfa4ed5
--- /dev/null
+++ b/apps/macos-bundles/.gitignore
@@ -0,0 +1 @@
+.build/
diff --git a/apps/macos-bundles/Bundles/CapQuickLookPreview/Info.plist b/apps/macos-bundles/Bundles/CapQuickLookPreview/Info.plist
new file mode 100644
index 0000000000..f833ef9e2c
--- /dev/null
+++ b/apps/macos-bundles/Bundles/CapQuickLookPreview/Info.plist
@@ -0,0 +1,42 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleExecutable
+ CapQuickLookPreview
+ CFBundleIdentifier
+ so.cap.desktop.quicklook.preview
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ CapQuickLookPreview
+ CFBundlePackageType
+ XPC!
+ CFBundleShortVersionString
+ 0.0.0
+ CFBundleVersion
+ 0
+ LSMinimumSystemVersion
+ 12.3
+ NSExtension
+
+ NSExtensionAttributes
+
+ QLIsDataBasedPreview
+
+ QLSupportedContentTypes
+
+ so.cap.desktop.project
+
+ QLSupportsSearchableItems
+
+
+ NSExtensionPointIdentifier
+ com.apple.quicklook.preview
+ NSExtensionPrincipalClass
+ PreviewProvider
+
+
+
diff --git a/apps/macos-bundles/Bundles/CapQuickLookThumbnail/Info.plist b/apps/macos-bundles/Bundles/CapQuickLookThumbnail/Info.plist
new file mode 100644
index 0000000000..1f8ba80a18
--- /dev/null
+++ b/apps/macos-bundles/Bundles/CapQuickLookThumbnail/Info.plist
@@ -0,0 +1,40 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleExecutable
+ CapQuickLookThumbnail
+ CFBundleIdentifier
+ so.cap.desktop.quicklook.thumbnail
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ CapQuickLookThumbnail
+ CFBundlePackageType
+ XPC!
+ CFBundleShortVersionString
+ 0.0.0
+ CFBundleVersion
+ 0
+ LSMinimumSystemVersion
+ 12.3
+ NSExtension
+
+ NSExtensionAttributes
+
+ QLSupportedContentTypes
+
+ so.cap.desktop.project
+
+ QLThumbnailMinimumDimension
+ 0
+
+ NSExtensionPointIdentifier
+ com.apple.quicklook.thumbnail
+ NSExtensionPrincipalClass
+ ThumbnailProvider
+
+
+
diff --git a/apps/macos-bundles/Bundles/Sandbox.entitlements b/apps/macos-bundles/Bundles/Sandbox.entitlements
new file mode 100644
index 0000000000..18aff0ce43
--- /dev/null
+++ b/apps/macos-bundles/Bundles/Sandbox.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.security.app-sandbox
+
+ com.apple.security.files.user-selected.read-only
+
+
+
diff --git a/apps/macos-bundles/Package.swift b/apps/macos-bundles/Package.swift
new file mode 100644
index 0000000000..dc0810620d
--- /dev/null
+++ b/apps/macos-bundles/Package.swift
@@ -0,0 +1,33 @@
+// swift-tools-version:5.9
+import PackageDescription
+
+let extensionEntryPoint: [LinkerSetting] = [
+ .unsafeFlags(["-Xlinker", "-e", "-Xlinker", "_NSExtensionMain"]),
+]
+
+let package = Package(
+ name: "CapMacOSBundles",
+ platforms: [.macOS(.v12)],
+ products: [
+ .executable(name: "CapQuickLookThumbnail", targets: ["CapQuickLookThumbnail"]),
+ .executable(name: "CapQuickLookPreview", targets: ["CapQuickLookPreview"]),
+ ],
+ targets: [
+ .target(
+ name: "CapShared",
+ path: "Sources/CapShared"
+ ),
+ .executableTarget(
+ name: "CapQuickLookThumbnail",
+ dependencies: ["CapShared"],
+ path: "Sources/CapQuickLookThumbnail",
+ linkerSettings: [.linkedFramework("QuickLookThumbnailing")] + extensionEntryPoint
+ ),
+ .executableTarget(
+ name: "CapQuickLookPreview",
+ dependencies: ["CapShared"],
+ path: "Sources/CapQuickLookPreview",
+ linkerSettings: [.linkedFramework("Quartz")] + extensionEntryPoint
+ ),
+ ]
+)
diff --git a/apps/macos-bundles/Sources/CapQuickLookPreview/PreviewProvider.swift b/apps/macos-bundles/Sources/CapQuickLookPreview/PreviewProvider.swift
new file mode 100644
index 0000000000..b8fd243f9c
--- /dev/null
+++ b/apps/macos-bundles/Sources/CapQuickLookPreview/PreviewProvider.swift
@@ -0,0 +1,16 @@
+import Foundation
+import Quartz
+import CapShared
+
+@objc(PreviewProvider)
+final class PreviewProvider: QLPreviewProvider, QLPreviewingController {
+ func providePreview(for request: QLFilePreviewRequest) async throws -> QLPreviewReply {
+ if let video = CapProject.exportedVideo(in: request.fileURL) {
+ return QLPreviewReply(fileURL: video)
+ }
+ if let image = CapProject.previewImage(in: request.fileURL) {
+ return QLPreviewReply(fileURL: image)
+ }
+ throw CocoaError(.fileReadNoSuchFile)
+ }
+}
diff --git a/apps/macos-bundles/Sources/CapQuickLookThumbnail/ThumbnailProvider.swift b/apps/macos-bundles/Sources/CapQuickLookThumbnail/ThumbnailProvider.swift
new file mode 100644
index 0000000000..8b016f8c7b
--- /dev/null
+++ b/apps/macos-bundles/Sources/CapQuickLookThumbnail/ThumbnailProvider.swift
@@ -0,0 +1,17 @@
+import Foundation
+import QuickLookThumbnailing
+import CapShared
+
+@objc(ThumbnailProvider)
+final class ThumbnailProvider: QLThumbnailProvider {
+ override func provideThumbnail(
+ for request: QLFileThumbnailRequest,
+ _ handler: @escaping (QLThumbnailReply?, Error?) -> Void
+ ) {
+ guard let image = CapProject.previewImage(in: request.fileURL) else {
+ handler(nil, CocoaError(.fileReadNoSuchFile))
+ return
+ }
+ handler(QLThumbnailReply(imageFileURL: image), nil)
+ }
+}
diff --git a/apps/macos-bundles/Sources/CapShared/CapProject.swift b/apps/macos-bundles/Sources/CapShared/CapProject.swift
new file mode 100644
index 0000000000..06d6c57379
--- /dev/null
+++ b/apps/macos-bundles/Sources/CapShared/CapProject.swift
@@ -0,0 +1,42 @@
+import Foundation
+
+public enum CapProject {
+ public static func previewImage(in bundle: URL) -> URL? {
+ let screenshot = bundle.appendingPathComponent("screenshots/display.jpg")
+ if isReadableFile(screenshot) {
+ return screenshot
+ }
+ guard let path = recordingDisplayPath(in: bundle) else {
+ return nil
+ }
+ let original = bundle.appendingPathComponent(path)
+ return isReadableFile(original) ? original : nil
+ }
+
+ public static func exportedVideo(in bundle: URL) -> URL? {
+ let video = bundle.appendingPathComponent("output/result.mp4")
+ return isReadableFile(video) ? video : nil
+ }
+
+ private static func recordingDisplayPath(in bundle: URL) -> String? {
+ let meta = bundle.appendingPathComponent("recording-meta.json")
+ guard let data = try? Data(contentsOf: meta),
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let display = json["display"] as? [String: Any],
+ let path = display["path"] as? String,
+ !path.isEmpty,
+ !path.hasPrefix("/"),
+ !path.split(separator: "/").contains("..")
+ else {
+ return nil
+ }
+ return path
+ }
+
+ private static func isReadableFile(_ url: URL) -> Bool {
+ var isDirectory: ObjCBool = false
+ return FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory)
+ && !isDirectory.boolValue
+ && FileManager.default.isReadableFile(atPath: url.path)
+ }
+}
diff --git a/biome.json b/biome.json
index c96a5a2d0d..0e65cd96af 100644
--- a/biome.json
+++ b/biome.json
@@ -15,7 +15,8 @@
"!capcodec/**/*.sh",
"!capcodec/**/*.jsonl",
"!capcodec/**/*.pyc",
- "!capcodec/capcodec.bundle"
+ "!capcodec/capcodec.bundle",
+ "!**/*.icon"
],
"ignoreUnknown": false
},
diff --git a/scripts/build-macos-bundles.mjs b/scripts/build-macos-bundles.mjs
new file mode 100644
index 0000000000..144419b0b7
--- /dev/null
+++ b/scripts/build-macos-bundles.mjs
@@ -0,0 +1,193 @@
+import { spawnSync } from "node:child_process";
+import * as fs from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const repoRoot = path.resolve(
+ path.dirname(fileURLToPath(import.meta.url)),
+ "..",
+);
+export const macosBundlesDirectory = path.join(
+ repoRoot,
+ "apps",
+ "macos-bundles",
+);
+const gpuiManifest = path.join(repoRoot, "apps", "desktop-gpui", "Cargo.toml");
+const minimumSystemVersion = "12.3";
+
+export const extensions = [
+ {
+ product: "CapQuickLookThumbnail",
+ bundleId: "so.cap.desktop.quicklook.thumbnail",
+ },
+ {
+ product: "CapQuickLookPreview",
+ bundleId: "so.cap.desktop.quicklook.preview",
+ },
+];
+
+const swiftArchitectures = {
+ "aarch64-apple-darwin": "arm64",
+ "x86_64-apple-darwin": "x86_64",
+};
+
+export function swiftArchitecture(target) {
+ const architecture = swiftArchitectures[target];
+ if (!architecture) {
+ throw new Error(`Unsupported macOS bundle target: ${target}`);
+ }
+ return architecture;
+}
+
+export function signingArguments({ identity, keychain, entitlements, bundle }) {
+ return [
+ "--force",
+ "--timestamp",
+ "--options",
+ "runtime",
+ "--entitlements",
+ entitlements,
+ "--sign",
+ identity,
+ ...(keychain ? ["--keychain", keychain] : []),
+ bundle,
+ ];
+}
+
+export function plistVersion(source) {
+ const packageStart = source.indexOf("[package]");
+ if (packageStart === -1) throw new Error("[package] not found");
+ const packageEnd = source.indexOf("\n[", packageStart + 1);
+ const section = source.slice(
+ packageStart,
+ packageEnd === -1 ? source.length : packageEnd,
+ );
+ const match = /^version\s*=\s*"([^"]+)"$/m.exec(section);
+ if (!match) throw new Error("package.version not found");
+ return match[1];
+}
+
+function run(command, args, options = {}) {
+ const result = spawnSync(command, args, { stdio: "inherit", ...options });
+ if (result.error) throw result.error;
+ if (result.status !== 0) {
+ throw new Error(
+ `${command} ${args.join(" ")} failed with ${result.signal ?? result.status}`,
+ );
+ }
+}
+
+async function assemble(extension, executable, info, version, output) {
+ const contents = path.join(output, `${extension.product}.appex`, "Contents");
+ await fs.rm(path.dirname(contents), { recursive: true, force: true });
+ await fs.mkdir(path.join(contents, "MacOS"), { recursive: true });
+ await fs.copyFile(
+ executable,
+ path.join(contents, "MacOS", extension.product),
+ );
+ await fs.chmod(path.join(contents, "MacOS", extension.product), 0o755);
+ const plist = (await fs.readFile(info, "utf8"))
+ .replace(
+ /(CFBundleShortVersionString<\/key>\s*)[^<]*(<\/string>)/,
+ `$1${version}$2`,
+ )
+ .replace(
+ /(CFBundleVersion<\/key>\s*)[^<]*(<\/string>)/,
+ `$1${version}$2`,
+ );
+ await fs.writeFile(path.join(contents, "Info.plist"), plist);
+ return path.dirname(contents);
+}
+
+export async function buildMacosBundles(
+ target,
+ {
+ env = process.env,
+ platform = process.platform,
+ outputDirectory = path.join(macosBundlesDirectory, ".build", "appex"),
+ } = {},
+) {
+ if (platform !== "darwin") {
+ throw new Error("The macOS bundles can only be built on macOS");
+ }
+ const architecture = swiftArchitecture(target);
+ const version = plistVersion(await fs.readFile(gpuiManifest, "utf8"));
+ const identity = env.APPLE_SIGNING_IDENTITY;
+ const keychain = env.APPLE_KEYCHAIN;
+ const ci = env.CI === "true" || env.CI === "1";
+ if (ci && !identity) {
+ throw new Error(
+ "APPLE_SIGNING_IDENTITY is required to sign the macOS bundles in CI",
+ );
+ }
+
+ const swiftArguments = [
+ "-c",
+ "release",
+ "--package-path",
+ macosBundlesDirectory,
+ "--triple",
+ `${architecture}-apple-macosx${minimumSystemVersion}`,
+ ];
+ run("swift", ["build", ...swiftArguments]);
+ const shown = spawnSync(
+ "swift",
+ ["build", ...swiftArguments, "--show-bin-path"],
+ {
+ encoding: "utf8",
+ },
+ );
+ if (shown.status !== 0) throw new Error("swift build --show-bin-path failed");
+ const binDirectory = shown.stdout.trim();
+
+ await fs.mkdir(outputDirectory, { recursive: true });
+ const bundles = [];
+ for (const extension of extensions) {
+ const executable = path.join(binDirectory, extension.product);
+ const info = path.join(
+ macosBundlesDirectory,
+ "Bundles",
+ extension.product,
+ "Info.plist",
+ );
+ const bundle = await assemble(
+ extension,
+ executable,
+ info,
+ version,
+ outputDirectory,
+ );
+ if (identity) {
+ run("codesign", [
+ ...signingArguments({
+ identity,
+ keychain,
+ entitlements: path.join(
+ macosBundlesDirectory,
+ "Bundles",
+ "Sandbox.entitlements",
+ ),
+ bundle,
+ }),
+ ]);
+ run("codesign", ["--verify", "--strict", bundle]);
+ } else {
+ console.warn(
+ `APPLE_SIGNING_IDENTITY is not set; ${path.basename(bundle)} is unsigned`,
+ );
+ }
+ bundles.push(bundle);
+ }
+ return bundles;
+}
+
+if (
+ process.argv[1] &&
+ path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
+) {
+ const [target] = process.argv.slice(2);
+ buildMacosBundles(target).catch((error) => {
+ console.error(error?.message ?? error);
+ process.exitCode = 1;
+ });
+}
diff --git a/scripts/build-macos-bundles.test.mjs b/scripts/build-macos-bundles.test.mjs
new file mode 100644
index 0000000000..674e6e9ff8
--- /dev/null
+++ b/scripts/build-macos-bundles.test.mjs
@@ -0,0 +1,91 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import test from "node:test";
+import {
+ extensions,
+ plistVersion,
+ signingArguments,
+ swiftArchitecture,
+} from "./build-macos-bundles.mjs";
+
+const readPlist = (relative) =>
+ readFileSync(
+ new URL(`../apps/macos-bundles/${relative}`, import.meta.url),
+ "utf8",
+ );
+
+test("each Quick Look extension is its own bundle with its own extension point", () => {
+ const points = {
+ CapQuickLookThumbnail: "com.apple.quicklook.thumbnail",
+ CapQuickLookPreview: "com.apple.quicklook.preview",
+ };
+ for (const extension of extensions) {
+ const plist = readPlist(`Bundles/${extension.product}/Info.plist`);
+ assert.match(plist, new RegExp(`${extension.bundleId}`));
+ assert.match(
+ plist,
+ new RegExp(`${points[extension.product]}`),
+ );
+ assert.match(plist, /so\.cap\.desktop\.project<\/string>/);
+ assert.match(plist, /XPC!<\/string>/);
+ }
+});
+
+test("each principal class is the @objc name its provider exports", () => {
+ for (const extension of extensions) {
+ const plist = readPlist(`Bundles/${extension.product}/Info.plist`);
+ const source = readPlist(
+ `Sources/${extension.product}/${extension.product.replace("CapQuickLook", "")}Provider.swift`,
+ );
+ const principal =
+ /NSExtensionPrincipalClass<\/key>\s*([^<]+)<\/string>/.exec(
+ plist,
+ )[1];
+ const objc = /@objc\(([A-Za-z]+)\)/.exec(source)[1];
+ assert.equal(principal, objc);
+ }
+});
+
+test("signing uses hardened runtime, a timestamp and the sandbox entitlements", () => {
+ assert.deepEqual(
+ signingArguments({
+ identity: "Developer ID Application: Cap",
+ keychain: "/tmp/build.keychain",
+ entitlements: "/repo/Sandbox.entitlements",
+ bundle: "/tmp/Thumb.appex",
+ }),
+ [
+ "--force",
+ "--timestamp",
+ "--options",
+ "runtime",
+ "--entitlements",
+ "/repo/Sandbox.entitlements",
+ "--sign",
+ "Developer ID Application: Cap",
+ "--keychain",
+ "/tmp/build.keychain",
+ "/tmp/Thumb.appex",
+ ],
+ );
+});
+
+test("targets map to Swift architectures and unknown targets fail", () => {
+ assert.equal(swiftArchitecture("aarch64-apple-darwin"), "arm64");
+ assert.equal(swiftArchitecture("x86_64-apple-darwin"), "x86_64");
+ assert.throws(
+ () => swiftArchitecture("x86_64-pc-windows-msvc"),
+ /Unsupported/,
+ );
+});
+
+test("the bundle version comes from the GPUI crate", () => {
+ assert.equal(
+ plistVersion('[package]\nname = "cap-gpui"\nversion = "0.6.1"\n'),
+ "0.6.1",
+ );
+ assert.throws(
+ () => plistVersion('[package]\nname = "x"\n'),
+ /package.version/,
+ );
+});
diff --git a/scripts/build-macos-icon.mjs b/scripts/build-macos-icon.mjs
new file mode 100644
index 0000000000..a46fe409a7
--- /dev/null
+++ b/scripts/build-macos-icon.mjs
@@ -0,0 +1,94 @@
+import { spawnSync } from "node:child_process";
+import * as fs from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const repoRoot = path.resolve(
+ path.dirname(fileURLToPath(import.meta.url)),
+ "..",
+);
+export const gpuiIconsDirectory = path.join(
+ repoRoot,
+ "apps",
+ "desktop-gpui",
+ "icons",
+);
+export const iconComposerName = "cap-solarium";
+export const accentColorName = "AccentColor";
+
+export function actoolArguments({ source, catalog, output, partialPlist }) {
+ return [
+ source,
+ catalog,
+ "--compile",
+ output,
+ "--output-format",
+ "human-readable-text",
+ "--notices",
+ "--warnings",
+ "--output-partial-info-plist",
+ partialPlist,
+ "--app-icon",
+ iconComposerName,
+ "--accent-color",
+ accentColorName,
+ "--include-all-app-icons",
+ "--enable-on-demand-resources",
+ "NO",
+ "--development-region",
+ "en",
+ "--target-device",
+ "mac",
+ "--minimum-deployment-target",
+ "12.3",
+ "--platform",
+ "macosx",
+ ];
+}
+
+function plistString(contents, key) {
+ const match = new RegExp(
+ `${key}\\s*([^<]+)`,
+ ).exec(contents);
+ return match?.[1];
+}
+
+export function iconNameFromPartialPlist(contents) {
+ const name = plistString(contents, "CFBundleIconName");
+ if (!name) throw new Error("actool did not report CFBundleIconName");
+ return name;
+}
+
+export function accentNameFromPartialPlist(contents) {
+ return plistString(contents, "NSAccentColorName");
+}
+
+export async function buildMacosIcon({
+ source = path.join(gpuiIconsDirectory, `${iconComposerName}.icon`),
+ catalog = path.join(gpuiIconsDirectory, "Cap.xcassets"),
+ outputDirectory,
+ platform = process.platform,
+ run = (command, args) => spawnSync(command, args, { encoding: "utf8" }),
+} = {}) {
+ if (platform !== "darwin") {
+ throw new Error("The Icon Composer icon can only be compiled on macOS");
+ }
+ const output = path.resolve(outputDirectory);
+ await fs.mkdir(output, { recursive: true });
+ const partialPlist = path.join(output, "partial.plist");
+ const result = run("xcrun", [
+ "actool",
+ ...actoolArguments({ source, catalog, output, partialPlist }),
+ ]);
+ if (result.status !== 0) {
+ throw new Error(`actool failed: ${result.stderr || result.stdout}`);
+ }
+ const assets = path.join(output, "Assets.car");
+ await fs.access(assets);
+ const plist = await fs.readFile(partialPlist, "utf8");
+ return {
+ assets,
+ iconName: iconNameFromPartialPlist(plist),
+ accentName: accentNameFromPartialPlist(plist),
+ };
+}
diff --git a/scripts/build-macos-icon.test.mjs b/scripts/build-macos-icon.test.mjs
new file mode 100644
index 0000000000..77ab59f38d
--- /dev/null
+++ b/scripts/build-macos-icon.test.mjs
@@ -0,0 +1,59 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import test from "node:test";
+import {
+ accentColorName,
+ accentNameFromPartialPlist,
+ actoolArguments,
+ iconComposerName,
+ iconNameFromPartialPlist,
+} from "./build-macos-icon.mjs";
+
+test("actool compiles the Icon Composer file for macOS 12.3 and later", () => {
+ const args = actoolArguments({
+ source: "/tmp/cap-solarium.icon",
+ output: "/tmp/out",
+ partialPlist: "/tmp/out/partial.plist",
+ });
+ assert.equal(args[0], "/tmp/cap-solarium.icon");
+ assert.deepEqual(
+ args.slice(args.indexOf("--app-icon"), args.indexOf("--app-icon") + 2),
+ ["--app-icon", iconComposerName],
+ );
+ assert.equal(args[args.indexOf("--minimum-deployment-target") + 1], "12.3");
+ assert.equal(args[args.indexOf("--platform") + 1], "macosx");
+});
+
+test("actool also compiles the Cap asset catalog for the accent color", () => {
+ const args = actoolArguments({
+ source: "/tmp/cap-solarium.icon",
+ catalog: "/tmp/Cap.xcassets",
+ output: "/tmp/out",
+ partialPlist: "/tmp/out/partial.plist",
+ });
+ assert.equal(args[1], "/tmp/Cap.xcassets");
+ assert.equal(args[args.indexOf("--accent-color") + 1], accentColorName);
+});
+
+test("the accent name comes from the partial plist when actool writes one", () => {
+ const plist = readFileSync(
+ new URL("./fixtures/actool-partial.plist", import.meta.url),
+ "utf8",
+ );
+ assert.equal(accentNameFromPartialPlist(plist), undefined);
+ assert.equal(
+ accentNameFromPartialPlist(
+ "NSAccentColorName\nAccentColor",
+ ),
+ "AccentColor",
+ );
+});
+
+test("the icon name comes from the partial plist actool writes", () => {
+ const plist = readFileSync(
+ new URL("./fixtures/actool-partial.plist", import.meta.url),
+ "utf8",
+ );
+ assert.equal(iconNameFromPartialPlist(plist), "cap-solarium");
+ assert.throws(() => iconNameFromPartialPlist(""), /CFBundleIconName/);
+});
diff --git a/scripts/build-macos-packages.mjs b/scripts/build-macos-packages.mjs
index ca9ed4552b..da4af83ad7 100644
--- a/scripts/build-macos-packages.mjs
+++ b/scripts/build-macos-packages.mjs
@@ -78,6 +78,7 @@ function validateArguments(target, args, platform) {
valid =
/^--(?:config|bundles)=.+/.test(argument) ||
argument === "--verbose" ||
+ argument === "--debug" ||
/^-v{1,2}$/.test(argument);
}
}
@@ -150,6 +151,7 @@ export async function buildMacosPackages(
args = [],
{
platform = process.platform,
+ platformConfig = null,
env = process.env,
signal,
execute = executeMacosCommand,
@@ -177,6 +179,9 @@ export async function buildMacosPackages(
},
}),
);
+ if (platformConfig) {
+ commandArguments.push("--config", JSON.stringify(platformConfig));
+ }
for (let attempt = 0; ; attempt++) {
signal?.throwIfAborted();
let outputTail = "";
diff --git a/scripts/build-macos-packages.test.mjs b/scripts/build-macos-packages.test.mjs
index 8dcf4cc822..d670239b8e 100644
--- a/scripts/build-macos-packages.test.mjs
+++ b/scripts/build-macos-packages.test.mjs
@@ -166,6 +166,28 @@ test("an unsupported macOS override fails before starting a compiler or packager
assert.deepEqual(fixture.calls, []);
});
+test("a platform config reaches tauri bundle as its own --config layer", async () => {
+ const fixture = harness([{ code: 0 }]);
+ const platformConfig = {
+ bundle: {
+ macOS: {
+ files: { "PlugIns/CapQuickLookThumbnail.appex": "/tmp/Thumb.appex" },
+ },
+ },
+ };
+ await buildMacosPackages(target, ["--bundles", "app"], {
+ ...fixture.options,
+ platformConfig,
+ });
+ const configs = fixture.calls[0].args.flatMap((arg, index, all) =>
+ all[index - 1] === "--config" ? [arg] : [],
+ );
+ assert.deepEqual(configs, [
+ JSON.stringify({ bundle: { macOS: { minimumSystemVersion: "12.3" } } }),
+ JSON.stringify(platformConfig),
+ ]);
+});
+
test("caller configs cannot lower the validated build target in final bundle metadata", async () => {
for (const deploymentTarget of [undefined, "14.0"]) {
const fixture = harness([
diff --git a/scripts/bundle-gpui.mjs b/scripts/bundle-gpui.mjs
index e73b655602..f85abc0626 100644
--- a/scripts/bundle-gpui.mjs
+++ b/scripts/bundle-gpui.mjs
@@ -3,6 +3,8 @@ import { createHash } from "node:crypto";
import * as fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
+import { buildMacosBundles } from "./build-macos-bundles.mjs";
+import { buildMacosIcon } from "./build-macos-icon.mjs";
import { buildMacosPackages } from "./build-macos-packages.mjs";
const repoRoot = path.resolve(
@@ -40,14 +42,27 @@ export function gpuiBinaryPath(target, profile = "release") {
);
}
-export function gpuiPlatformConfig(platform) {
+export function gpuiPlatformConfig(
+ platform,
+ { appexBundles = [], icon = null } = {},
+) {
if (platform === "darwin") {
+ const files = Object.fromEntries([
+ ...appexBundles.map((bundle) => [
+ `PlugIns/${path.basename(bundle)}`,
+ bundle,
+ ]),
+ ...(icon ? [["Resources/Assets.car", icon.assets]] : []),
+ ]);
return {
bundle: {
resources: {
"../../target/native-deps/onnxruntime/lib/libonnxruntime.dylib":
"onnxruntime/lib/libonnxruntime.dylib",
},
+ ...(Object.keys(files).length > 0 && {
+ macOS: { files },
+ }),
},
};
}
@@ -66,6 +81,10 @@ export function gpuiPlatformConfig(platform) {
return null;
}
+export function profileArguments(profile) {
+ return profile === "debug" ? ["--debug"] : [];
+}
+
export function bundleArguments(target, args) {
return ["tauri", "bundle", "--target", target, ...args];
}
@@ -78,7 +97,7 @@ export function bundleEnvironment(env) {
};
}
-function validate(target, args) {
+function validate(target, args, profile) {
const platform = targetPlatform(target ?? "");
if (!platform || platform !== process.platform) {
throw new Error(
@@ -88,6 +107,9 @@ function validate(target, args) {
if (args.some((argument) => /^--(?:target|debug)(?:=|$)/.test(argument))) {
throw new Error("The GPUI bundle is always a release build for ");
}
+ if (profile === "debug" && process.env.CI) {
+ throw new Error("--profile=debug is for local bundles only");
+ }
return platform;
}
@@ -104,8 +126,8 @@ function run(command, args, options = {}) {
const sha256 = (contents) =>
createHash("sha256").update(contents).digest("hex");
-export async function stageMainBinary(target) {
- const source = gpuiBinaryPath(target);
+export async function stageMainBinary(target, profile = "release") {
+ const source = gpuiBinaryPath(target, profile);
const destination = path.join(path.dirname(source), mainBinaryName(target));
const built = await fs.readFile(source);
if (built.length === 0) throw new Error(`${source} is empty`);
@@ -117,8 +139,8 @@ export async function stageMainBinary(target) {
return destination;
}
-export async function writePlatformConfig(platform) {
- const config = gpuiPlatformConfig(platform);
+export async function writePlatformConfig(platform, options) {
+ const config = gpuiPlatformConfig(platform, options);
if (!config) return null;
const file = path.join(gpuiDirectory, platformConfigNames[platform]);
const contents = `${JSON.stringify(config, null, "\t")}\n`;
@@ -128,29 +150,44 @@ export async function writePlatformConfig(platform) {
}
export function parseOptions(args) {
- const flags = new Set(["--skip-build", "--no-bundle"]);
+ const flags = new Set(["--skip-build", "--no-bundle", "--profile=debug"]);
return {
skipBuild: args.includes("--skip-build"),
noBundle: args.includes("--no-bundle"),
+ profile: args.includes("--profile=debug") ? "debug" : "release",
bundleArgs: args.filter((argument) => !flags.has(argument)),
};
}
export async function bundleGpui(target, args, { env = process.env } = {}) {
- const { skipBuild, noBundle, bundleArgs } = parseOptions(args);
- const platform = validate(target, bundleArgs);
+ const { skipBuild, noBundle, profile, bundleArgs } = parseOptions(args);
+ const platform = validate(target, bundleArgs, profile);
if (!skipBuild) {
run("bash", [
path.join(repoRoot, "scripts", "build-gpui-binary.sh"),
- "release",
+ profile,
target,
]);
}
- const staged = await stageMainBinary(target);
+ const staged = await stageMainBinary(target, profile);
console.log(`Staged ${staged}`);
if (noBundle) return;
- await writePlatformConfig(platform);
+ const appexBundles =
+ platform === "darwin" ? await buildMacosBundles(target, { env }) : [];
+ const icon =
+ platform === "darwin"
+ ? await buildMacosIcon({
+ outputDirectory: path.join(
+ gpuiDirectory,
+ "target",
+ target,
+ profile,
+ "icon",
+ ),
+ })
+ : null;
+ await writePlatformConfig(platform, { appexBundles, icon });
if (platform === "linux") {
await fs
.access(path.join(gpuiDirectory, platformConfigNames.linux))
@@ -161,10 +198,16 @@ export async function bundleGpui(target, args, { env = process.env } = {}) {
});
}
+ const profileArgs = profileArguments(profile);
if (platform === "darwin") {
- const result = await buildMacosPackages(target, bundleArgs, {
- env: bundleEnvironment(env),
- });
+ const result = await buildMacosPackages(
+ target,
+ [...bundleArgs, ...profileArgs],
+ {
+ platformConfig: gpuiPlatformConfig(platform, { appexBundles, icon }),
+ env: bundleEnvironment(env),
+ },
+ );
if (result.signal || result.code !== 0) {
throw new Error(
`macOS GPUI bundling failed with ${result.signal ?? result.code}`,
@@ -173,10 +216,14 @@ export async function bundleGpui(target, args, { env = process.env } = {}) {
return;
}
- run("bun", ["run", ...bundleArguments(target, bundleArgs)], {
- cwd: gpuiDirectory,
- env: bundleEnvironment({ ...env, RUST_TARGET_TRIPLE: target }),
- });
+ run(
+ "bun",
+ ["run", ...bundleArguments(target, [...bundleArgs, ...profileArgs])],
+ {
+ cwd: gpuiDirectory,
+ env: bundleEnvironment({ ...env, RUST_TARGET_TRIPLE: target }),
+ },
+ );
}
if (
diff --git a/scripts/bundle-gpui.test.mjs b/scripts/bundle-gpui.test.mjs
index d0fa08ee49..f0b4fe4236 100644
--- a/scripts/bundle-gpui.test.mjs
+++ b/scripts/bundle-gpui.test.mjs
@@ -10,6 +10,7 @@ import {
gpuiPlatformConfig,
mainBinaryName,
parseOptions,
+ profileArguments,
targetPlatform,
} from "./bundle-gpui.mjs";
@@ -135,6 +136,30 @@ test("platform resources point at the shared native dependencies", () => {
assert.equal(gpuiPlatformConfig("linux"), null);
});
+test("signed Quick Look extensions are copied into PlugIns", () => {
+ const appex = [
+ "/tmp/build/CapQuickLookThumbnail.appex",
+ "/tmp/build/CapQuickLookPreview.appex",
+ ];
+ assert.deepEqual(
+ gpuiPlatformConfig("darwin", { appexBundles: appex }).bundle.macOS.files,
+ {
+ "PlugIns/CapQuickLookThumbnail.appex": appex[0],
+ "PlugIns/CapQuickLookPreview.appex": appex[1],
+ },
+ );
+ assert.equal(gpuiPlatformConfig("darwin").bundle.macOS, undefined);
+});
+
+test("the GPUI app declares the Cap project type for Quick Look", () => {
+ const info = readFileSync(
+ new URL("../apps/desktop-gpui/Info.plist", import.meta.url),
+ "utf8",
+ );
+ assert.match(info, /so\.cap\.desktop\.project<\/string>/);
+ assert.match(info, /com\.apple\.package<\/string>/);
+});
+
test("tauri bundle runs against the GPUI crate with its pinned toolchain", () => {
assert.deepEqual(bundleArguments("x86_64-unknown-linux-gnu", ["--verbose"]), [
"tauri",
@@ -153,11 +178,47 @@ test("signing builds can stop before bundling and rebundle without rebuilding",
assert.deepEqual(parseOptions(["--no-bundle"]), {
skipBuild: false,
noBundle: true,
+ profile: "release",
bundleArgs: [],
});
assert.deepEqual(parseOptions(["--skip-build", "--bundles", "nsis"]), {
skipBuild: true,
noBundle: false,
+ profile: "release",
bundleArgs: ["--bundles", "nsis"],
});
});
+
+test("the debug profile reaches Tauri on every platform, not only macOS", () => {
+ assert.deepEqual(profileArguments("debug"), ["--debug"]);
+ assert.deepEqual(profileArguments("release"), []);
+ assert.deepEqual(
+ bundleArguments("x86_64-pc-windows-msvc", [
+ "--bundles",
+ "nsis",
+ ...profileArguments("debug"),
+ ]),
+ [
+ "tauri",
+ "bundle",
+ "--target",
+ "x86_64-pc-windows-msvc",
+ "--bundles",
+ "nsis",
+ "--debug",
+ ],
+ );
+});
+
+test("a local debug bundle is opt-in and never reaches the release path", () => {
+ assert.deepEqual(parseOptions(["--profile=debug", "--bundles", "app"]), {
+ skipBuild: false,
+ noBundle: false,
+ profile: "debug",
+ bundleArgs: ["--bundles", "app"],
+ });
+ assert.equal(
+ gpuiBinaryPath("aarch64-apple-darwin", "debug"),
+ path.join(gpuiDirectory, "target/aarch64-apple-darwin/debug/cap-gpui"),
+ );
+});
diff --git a/scripts/fixtures/actool-partial.plist b/scripts/fixtures/actool-partial.plist
new file mode 100644
index 0000000000..4c6bfeb604
--- /dev/null
+++ b/scripts/fixtures/actool-partial.plist
@@ -0,0 +1,10 @@
+
+
+
+
+ CFBundleIconFile
+ cap-solarium
+ CFBundleIconName
+ cap-solarium
+
+