diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4527fba5879..b8e82cf0d11 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -58,6 +58,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*'
@@ -364,7 +367,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 f4c6d54a14a..dc354211530 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 dd740dcf952..106cfeeaaea 100644
--- a/apps/desktop-gpui/Info.plist
+++ b/apps/desktop-gpui/Info.plist
@@ -29,5 +29,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/app-icon.png b/apps/desktop-gpui/assets/app-icon.png
new file mode 100644
index 00000000000..049b1563919
Binary files /dev/null and b/apps/desktop-gpui/assets/app-icon.png differ
diff --git a/apps/desktop-gpui/assets/cap-debug-icon-legacy.png b/apps/desktop-gpui/assets/cap-debug-icon-legacy.png
new file mode 100644
index 00000000000..1b55ba0d7e3
Binary files /dev/null and b/apps/desktop-gpui/assets/cap-debug-icon-legacy.png differ
diff --git a/apps/desktop-gpui/assets/cap-debug-icon.png b/apps/desktop-gpui/assets/cap-debug-icon.png
new file mode 100644
index 00000000000..91e1e917b90
Binary files /dev/null and b/apps/desktop-gpui/assets/cap-debug-icon.png differ
diff --git a/apps/desktop-gpui/assets/dock-icon.png b/apps/desktop-gpui/assets/dock-icon.png
deleted file mode 100644
index b7657009ef3..00000000000
Binary files a/apps/desktop-gpui/assets/dock-icon.png and /dev/null differ
diff --git a/apps/desktop-gpui/build.rs b/apps/desktop-gpui/build.rs
index 151c6bf353a..1a417c62433 100644
--- a/apps/desktop-gpui/build.rs
+++ b/apps/desktop-gpui/build.rs
@@ -22,7 +22,7 @@ fn main() {
println!("cargo:rustc-link-arg-bin=cap-gpui=/STACK:16777216");
}
- let icon = "../desktop/src-tauri/icons/icon.ico";
+ let icon = "icons/icon.ico";
println!("cargo:rerun-if-changed={icon}");
tauri_winres::WindowsResource::new()
.set_icon_with_id(icon, "1")
diff --git a/apps/desktop-gpui/icons/128x128.png b/apps/desktop-gpui/icons/128x128.png
new file mode 100644
index 00000000000..d5d3ce02baf
Binary files /dev/null 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
new file mode 100644
index 00000000000..10eaf0ba13f
Binary files /dev/null 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
new file mode 100644
index 00000000000..8e4b70091b5
Binary files /dev/null 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 00000000000..f8e6acd4869
--- /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 00000000000..d458f1c5928
--- /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/FullRing.svg b/apps/desktop-gpui/icons/cap-solarium.icon/Assets/FullRing.svg
new file mode 100644
index 00000000000..fd8f59ced87
--- /dev/null
+++ b/apps/desktop-gpui/icons/cap-solarium.icon/Assets/FullRing.svg
@@ -0,0 +1,4 @@
+
diff --git a/apps/desktop-gpui/icons/cap-solarium.icon/Assets/FullRingsSeaGlass-Shadow.png b/apps/desktop-gpui/icons/cap-solarium.icon/Assets/FullRingsSeaGlass-Shadow.png
new file mode 100644
index 00000000000..7c950add1dc
Binary files /dev/null and b/apps/desktop-gpui/icons/cap-solarium.icon/Assets/FullRingsSeaGlass-Shadow.png differ
diff --git a/apps/desktop-gpui/icons/cap-solarium.icon/Assets/FullRingsSeaGlass2.png b/apps/desktop-gpui/icons/cap-solarium.icon/Assets/FullRingsSeaGlass2.png
new file mode 100644
index 00000000000..72c4c1daadf
Binary files /dev/null and b/apps/desktop-gpui/icons/cap-solarium.icon/Assets/FullRingsSeaGlass2.png differ
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 00000000000..c470c9d4437
--- /dev/null
+++ b/apps/desktop-gpui/icons/cap-solarium.icon/icon.json
@@ -0,0 +1,251 @@
+{
+ "features" : [
+ "refractivity",
+ "specular-location"
+ ],
+ "fill-specializations" : [
+ {
+ "value" : "automatic"
+ },
+ {
+ "appearance" : "dark",
+ "value" : "automatic"
+ }
+ ],
+ "groups" : [
+ {
+ "blend-mode-specializations" : [
+ {
+ "appearance" : "dark",
+ "value" : "normal"
+ }
+ ],
+ "blur-material" : null,
+ "hidden-specializations" : [
+ {
+ "value" : false
+ },
+ {
+ "idiom" : "square",
+ "value" : false
+ }
+ ],
+ "layers" : [
+ {
+ "blend-mode" : "normal",
+ "fill-specializations" : [
+ {
+ "value" : "automatic"
+ },
+ {
+ "appearance" : "dark",
+ "value" : "none"
+ }
+ ],
+ "glass" : true,
+ "hidden" : true,
+ "image-name" : "FullRing.svg",
+ "name" : "FullRing",
+ "position" : {
+ "scale" : 9.35,
+ "translation-in-points" : [
+ 0,
+ 0
+ ]
+ }
+ }
+ ],
+ "lighting" : "individual",
+ "position" : {
+ "scale" : 1,
+ "translation-in-points" : [
+ 0,
+ 0
+ ]
+ },
+ "refractivity" : {
+ "depth" : 0.6013671875,
+ "enabled" : true,
+ "strength" : 0.1844921875
+ },
+ "shadow" : {
+ "kind" : "layer-color",
+ "opacity" : 0.6
+ },
+ "specular" : true,
+ "translucency" : {
+ "enabled" : true,
+ "value" : 0.4
+ }
+ },
+ {
+ "blur-material" : null,
+ "layers" : [
+ {
+ "image-name" : "FullRingsSeaGlass2.png",
+ "name" : "FullRingsSeaGlass2",
+ "position-specializations" : [
+ {
+ "idiom" : "square",
+ "value" : {
+ "scale" : 0.65,
+ "translation-in-points" : [
+ 0,
+ 0
+ ]
+ }
+ }
+ ]
+ }
+ ],
+ "lighting" : "individual",
+ "refractivity-specializations" : [
+ {
+ "value" : {
+ "depth" : 0.5054296875,
+ "enabled" : true,
+ "strength" : 0.34328125
+ }
+ },
+ {
+ "appearance" : "dark",
+ "value" : {
+ "depth" : 0.511875,
+ "enabled" : true,
+ "strength" : 0.4355078125
+ }
+ }
+ ],
+ "shadow-specializations" : [
+ {
+ "value" : {
+ "kind" : "layer-color",
+ "opacity" : 0.5
+ }
+ },
+ {
+ "appearance" : "dark",
+ "value" : {
+ "kind" : "layer-color",
+ "opacity" : 0.5
+ }
+ }
+ ],
+ "specular-specializations" : [
+ {
+ "value" : "inside"
+ },
+ {
+ "appearance" : "dark",
+ "value" : "inside"
+ }
+ ],
+ "translucency" : {
+ "enabled" : true,
+ "value" : 0.25
+ }
+ },
+ {
+ "blur-material" : null,
+ "layers" : [
+ {
+ "blend-mode-specializations" : [
+ {
+ "appearance" : "dark",
+ "value" : "normal"
+ }
+ ],
+ "fill-specializations" : [
+ {
+ "appearance" : "dark",
+ "value" : "none"
+ }
+ ],
+ "glass" : false,
+ "hidden-specializations" : [
+ {
+ "idiom" : "square",
+ "value" : false
+ }
+ ],
+ "image-name" : "FullRingsSeaGlass-Shadow.png",
+ "name" : "FullRingsSeaGlass-Shadow",
+ "opacity-specializations" : [
+ {
+ "appearance" : "dark",
+ "value" : 0
+ },
+ {
+ "appearance" : "tinted",
+ "value" : 0
+ }
+ ],
+ "position-specializations" : [
+ {
+ "idiom" : "square",
+ "value" : {
+ "scale" : 0.65,
+ "translation-in-points" : [
+ 0,
+ 0
+ ]
+ }
+ }
+ ]
+ }
+ ],
+ "lighting" : "individual",
+ "refractivity-specializations" : [
+ {
+ "value" : {
+ "depth" : 0.5054296875,
+ "enabled" : true,
+ "strength" : 0.34328125
+ }
+ },
+ {
+ "appearance" : "dark",
+ "value" : {
+ "depth" : 0.511875,
+ "enabled" : true,
+ "strength" : 0.4355078125
+ }
+ }
+ ],
+ "shadow-specializations" : [
+ {
+ "value" : {
+ "kind" : "layer-color",
+ "opacity" : 0.5
+ }
+ },
+ {
+ "appearance" : "dark",
+ "value" : {
+ "kind" : "layer-color",
+ "opacity" : 0.5
+ }
+ }
+ ],
+ "specular-specializations" : [
+ {
+ "value" : "outside"
+ },
+ {
+ "appearance" : "dark",
+ "value" : "inside"
+ }
+ ],
+ "translucency" : {
+ "enabled" : true,
+ "value" : 0.3
+ }
+ }
+ ],
+ "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 00000000000..c82c2f7ab87
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
new file mode 100644
index 00000000000..b55f512d5b8
Binary files /dev/null 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
new file mode 100644
index 00000000000..572a1b8d73b
Binary files /dev/null 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 b0a3ef392f3..b3a0342e813 100644
--- a/apps/desktop-gpui/src/main.rs
+++ b/apps/desktop-gpui/src/main.rs
@@ -378,9 +378,12 @@ fn main() {
app.run(move |cx: &mut App| {
gpui_tokio::init_from_handle(cx, runtime_handle);
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.
- platform::set_dock_icon(include_bytes!("../assets/dock-icon.png"));
+ #[cfg(all(debug_assertions, target_os = "macos"))]
+ platform::set_dock_icon(if objc2::available!(macos = 26.0) {
+ include_bytes!("../assets/cap-debug-icon.png")
+ } else {
+ include_bytes!("../assets/cap-debug-icon-legacy.png")
+ });
if let Err(error) = Assets.load_fonts(cx) {
tracing::error!("failed to load embedded fonts: {error:#}");
diff --git a/apps/desktop-gpui/src/permission_guide.rs b/apps/desktop-gpui/src/permission_guide.rs
index 5bd32bdcb28..9a9fc15703a 100644
--- a/apps/desktop-gpui/src/permission_guide.rs
+++ b/apps/desktop-gpui/src/permission_guide.rs
@@ -38,7 +38,7 @@ const GIVE_UP_AFTER_CLOSE: Duration = Duration::from_secs(8);
const LIFETIME: Duration = Duration::from_secs(5 * 60);
const SIDEBAR_W: f64 = 230.;
-const APP_ICON: &[u8] = include_bytes!("../assets/dock-icon.png");
+const APP_ICON: &[u8] = include_bytes!("../assets/app-icon.png");
/// The `.app` to drag: the bundle this binary runs from. A bare dev binary
/// has none -- TCC attributes it to the launching terminal anyway -- so the
diff --git a/apps/desktop-gpui/src/platform.rs b/apps/desktop-gpui/src/platform.rs
index 3866f052b66..4ab3d19265c 100644
--- a/apps/desktop-gpui/src/platform.rs
+++ b/apps/desktop-gpui/src/platform.rs
@@ -2102,66 +2102,32 @@ mod mac {
/// returned, the window stayed). Its close-button simulation is the only
/// part the contract loses, so the two real steps are spelled out
/// directly. Same retained-handle discipline as [`hide_native`].
- /// The dock icon. An unbundled dev binary carries no Info.plist icon, so
- /// one is set at runtime with `setApplicationIconImage:`.
- ///
- /// The shape matters more than the bytes on macOS 26: the system
- /// re-renders every *bundle* icon onto its standard squircle plate (the
- /// legacy-icon treatment), which is what the shipping Cap.app's dock tile
- /// shows -- but a raw `setApplicationIconImage:` bypasses that pipeline,
- /// so the classic `icon.png` renders with its own pre-26 rounded-rect
- /// shape and looks visibly different next to the original. NSWorkspace's
- /// `iconForFile:` hands back the icon *as the system presents it*,
- /// treatment included, so the installed Cap.app's is used when it exists
- /// and the raw asset only as a fallback. A future .app bundle with its
- /// own (Icon Composer) icon supersedes all of this.
- pub fn set_dock_icon(fallback_png: &[u8]) {
+ /// The debug-build dock icon. An unbundled dev binary carries no
+ /// Info.plist icon, so the PNG is set at runtime with
+ /// `setApplicationIconImage:`. Release bundles never call this: they take
+ /// their icon from the compiled Icon Composer `Assets.car`, which macOS
+ /// renders as native layers, and a runtime image would replace that.
+ pub fn set_dock_icon(png: &[u8]) {
use objc2::{class, msg_send};
- use objc2_foundation::NSString;
unsafe {
- let mut image: Option> = None;
-
- let cap_app = NSString::from_str("/Applications/Cap.app");
- let exists: bool = {
- let manager: *mut AnyObject = msg_send![class!(NSFileManager), defaultManager];
- !manager.is_null() && msg_send![manager, fileExistsAtPath: &*cap_app]
- };
- if exists {
- let workspace: *mut AnyObject = msg_send![class!(NSWorkspace), sharedWorkspace];
- if !workspace.is_null() {
- let raw: *mut AnyObject = msg_send![workspace, iconForFile: &*cap_app];
- // `iconForFile:` is autoreleased; retain for the setter.
- image = Retained::retain(raw);
- if let Some(image) = &image {
- // The workspace icon's nominal size is small (32pt);
- // the reps go up to 1024. Ask for dock resolution.
- let _: () = msg_send![
- &**image,
- setSize: objc2_foundation::NSSize::new(512., 512.)
- ];
- }
- }
- }
-
- if image.is_none() {
- let data: *mut AnyObject = msg_send![
- class!(NSData),
- dataWithBytes: fallback_png.as_ptr().cast::(),
- length: fallback_png.len(),
- ];
- if !data.is_null() {
- let alloc: *mut AnyObject = msg_send![class!(NSImage), alloc];
- let raw: *mut AnyObject = msg_send![alloc, initWithData: data];
- image = Retained::from_raw(raw);
- }
+ let data: *mut AnyObject = msg_send![
+ class!(NSData),
+ dataWithBytes: png.as_ptr().cast::(),
+ length: png.len(),
+ ];
+ if data.is_null() {
+ return;
}
-
- let Some(image) = image else { return };
+ let alloc: *mut AnyObject = msg_send![class!(NSImage), alloc];
+ let Some(image) = Retained::::from_raw(msg_send![alloc, initWithData: data])
+ else {
+ return;
+ };
let app: *mut AnyObject = msg_send![class!(NSApplication), sharedApplication];
if !app.is_null() {
let _: () = msg_send![app, setApplicationIconImage: &*image];
}
- tracing::info!(from_installed_app = exists, "dock icon set");
+ tracing::info!("debug dock icon set");
}
}
diff --git a/apps/desktop-gpui/tauri.conf.json b/apps/desktop-gpui/tauri.conf.json
index 314e0f27dd9..690013c52bc 100644
--- a/apps/desktop-gpui/tauri.conf.json
+++ b/apps/desktop-gpui/tauri.conf.json
@@ -27,11 +27,11 @@
"../desktop/src-tauri/binaries/cap-cli"
],
"icon": [
- "../desktop/src-tauri/icons/32x32.png",
- "../desktop/src-tauri/icons/128x128.png",
- "../desktop/src-tauri/icons/128x128@2x.png",
- "../desktop/src-tauri/icons/macos/icon.icns",
- "../desktop/src-tauri/icons/icon.ico"
+ "icons/32x32.png",
+ "icons/128x128.png",
+ "icons/128x128@2x.png",
+ "icons/icon.icns",
+ "icons/icon.png"
],
"resources": {
"../desktop/src-tauri/assets/backgrounds/macOS/*": "assets/backgrounds/macOS/",
@@ -70,7 +70,7 @@
"nsis": {
"headerImage": "../desktop/src-tauri/assets/nsis-header.bmp",
"sidebarImage": "../desktop/src-tauri/assets/nsis-sidebar.bmp",
- "installerIcon": "../desktop/src-tauri/icons/icon.ico",
+ "installerIcon": "icons/icon.ico",
"installerHooks": "packaging/windows/installer-hooks.nsh",
"compression": "lzma"
},
diff --git a/apps/desktop/src-tauri/assets/nsis-sidebar.bmp b/apps/desktop/src-tauri/assets/nsis-sidebar.bmp
index 07460e15131..454c042954a 100644
Binary files a/apps/desktop/src-tauri/assets/nsis-sidebar.bmp and b/apps/desktop/src-tauri/assets/nsis-sidebar.bmp differ
diff --git a/apps/desktop/src-tauri/assets/wix-banner.bmp b/apps/desktop/src-tauri/assets/wix-banner.bmp
index 3fcc7467861..045adc8acbe 100644
Binary files a/apps/desktop/src-tauri/assets/wix-banner.bmp and b/apps/desktop/src-tauri/assets/wix-banner.bmp differ
diff --git a/apps/desktop/src-tauri/assets/wix-dialog.bmp b/apps/desktop/src-tauri/assets/wix-dialog.bmp
index 36625dc9f46..6d0cf393d24 100644
Binary files a/apps/desktop/src-tauri/assets/wix-dialog.bmp and b/apps/desktop/src-tauri/assets/wix-dialog.bmp differ
diff --git a/apps/macos-bundles/.gitignore b/apps/macos-bundles/.gitignore
new file mode 100644
index 00000000000..30bcfa4ed5c
--- /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 00000000000..f833ef9e2c7
--- /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 00000000000..1f8ba80a18a
--- /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 00000000000..18aff0ce43c
--- /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 00000000000..dc0810620d1
--- /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 00000000000..b8fd243f9c6
--- /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 00000000000..8b016f8c7b6
--- /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 00000000000..06d6c573794
--- /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 189cd4a7406..b0b10f1df1a 100644
--- a/biome.json
+++ b/biome.json
@@ -20,7 +20,8 @@
"!capcodec/**/*.sh",
"!capcodec/**/*.jsonl",
"!capcodec/**/*.pyc",
- "!capcodec/capcodec.bundle"
+ "!capcodec/capcodec.bundle",
+ "!**/cap-solarium.icon/**"
],
"ignoreUnknown": false
},
diff --git a/scripts/build-macos-bundles.mjs b/scripts/build-macos-bundles.mjs
new file mode 100644
index 00000000000..144419b0b78
--- /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 00000000000..674e6e9ff85
--- /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 00000000000..a46fe409a71
--- /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 00000000000..77ab59f38d8
--- /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 966a1d2dc5f..9bad3cd9226 100644
--- a/scripts/build-macos-packages.mjs
+++ b/scripts/build-macos-packages.mjs
@@ -81,6 +81,7 @@ function validateArguments(target, args, platform) {
valid =
/^--(?:config|bundles)=.+/.test(argument) ||
argument === "--verbose" ||
+ argument === "--debug" ||
/^-v{1,2}$/.test(argument);
}
}
diff --git a/scripts/bundle-gpui.mjs b/scripts/bundle-gpui.mjs
index ffa92a6ad24..bee858e0723 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(
@@ -41,14 +43,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 },
+ }),
},
};
}
@@ -67,6 +82,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];
}
@@ -79,7 +98,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(
@@ -89,6 +108,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;
}
@@ -105,8 +127,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`);
@@ -118,8 +140,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`;
@@ -129,29 +151,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))
@@ -162,11 +199,16 @@ export async function bundleGpui(target, args, { env = process.env } = {}) {
});
}
+ const profileArgs = profileArguments(profile);
if (platform === "darwin") {
- const result = await buildMacosPackages(target, bundleArgs, {
- bundleOnly: true,
- env: bundleEnvironment(env),
- });
+ const result = await buildMacosPackages(
+ target,
+ [...bundleArgs, ...profileArgs],
+ {
+ bundleOnly: true,
+ env: bundleEnvironment(env),
+ },
+ );
if (result.signal || result.code !== 0) {
throw new Error(
`macOS GPUI bundling failed with ${result.signal ?? result.code}`,
@@ -175,10 +217,14 @@ export async function bundleGpui(target, args, { env = process.env } = {}) {
return;
}
- run("bun", ["run", ...bundleArguments(target, bundleArgs)], {
- cwd: desktopDirectory,
- env: bundleEnvironment({ ...env, RUST_TARGET_TRIPLE: target }),
- });
+ run(
+ "bun",
+ ["run", ...bundleArguments(target, [...bundleArgs, ...profileArgs])],
+ {
+ cwd: desktopDirectory,
+ env: bundleEnvironment({ ...env, RUST_TARGET_TRIPLE: target }),
+ },
+ );
}
if (
diff --git a/scripts/bundle-gpui.test.mjs b/scripts/bundle-gpui.test.mjs
index 367ff895e7f..d4a2dd11786 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";
@@ -147,6 +148,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",
@@ -165,11 +190,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 00000000000..4c6bfeb6040
--- /dev/null
+++ b/scripts/fixtures/actool-partial.plist
@@ -0,0 +1,10 @@
+
+
+
+
+ CFBundleIconFile
+ cap-solarium
+ CFBundleIconName
+ cap-solarium
+
+