Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .github/scripts/make-macos-app.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Wraps a built openmouse-gpui binary into OpenMouse.app and a .dmg.
# usage: make-macos-app.sh <binary> <version> <output-dir>
set -euo pipefail

binary=$1
version=$2
out=$3
logo="$(dirname "$0")/../../gpui/assets/logo.png"

app="$out/OpenMouse.app"
rm -rf "$app" "$out/OpenMouse.dmg"
mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources"
cp "$binary" "$app/Contents/MacOS/openmouse-gpui"

# The logo is portrait, so pad it to a square before making the icon set.
work=$(mktemp -d)
sips --padToHeightWidth 268 268 --padColor 000000 "$logo" --out "$work/square.png" >/dev/null
iconset="$work/OpenMouse.iconset"
mkdir "$iconset"
for size in 16 32 128 256 512; do
sips -z $size $size "$work/square.png" --out "$iconset/icon_${size}x${size}.png" >/dev/null
sips -z $((size * 2)) $((size * 2)) "$work/square.png" --out "$iconset/icon_${size}x${size}@2x.png" >/dev/null
done
iconutil -c icns "$iconset" -o "$app/Contents/Resources/OpenMouse.icns"

cat > "$app/Contents/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key><string>OpenMouse</string>
<key>CFBundleDisplayName</key><string>OpenMouse Desktop</string>
<key>CFBundleIdentifier</key><string>app.openmouse.desktop</string>
<key>CFBundleExecutable</key><string>openmouse-gpui</string>
<key>CFBundleIconFile</key><string>OpenMouse</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>$version</string>
<key>CFBundleVersion</key><string>$version</string>
<key>LSMinimumSystemVersion</key><string>11.0</string>
<key>NSHighResolutionCapable</key><true/>
</dict>
</plist>
PLIST

# Ad-hoc signature: Apple Silicon refuses to run unsigned code. Not a
# substitute for a Developer ID signature and notarization.
codesign --force --deep --sign - "$app"

mkdir "$work/dmg-root"
cp -R "$app" "$work/dmg-root/"
hdiutil create -volname "OpenMouse" -srcfolder "$work/dmg-root" -ov -format UDZO "$out/OpenMouse.dmg" >/dev/null
56 changes: 56 additions & 0 deletions .github/workflows/build-gpui-macos.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
name: Build (macOS)

# Builds and tests the native GPUI app (gpui/) on macOS and uploads an
# ad-hoc-signed OpenMouse.dmg so any push can be downloaded and tried. Same
# triggers as the Windows build. Not yet part of release.yml.
on:
workflow_dispatch:
push:
branches: [main, gpui]
pull_request:

concurrency:
group: build-macos-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}

jobs:
build:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: lts/*
cache: npm

- uses: dtolnay/rust-toolchain@stable

- uses: Swatinem/rust-cache@v2
with:
workspaces: gpui
shared-key: macos

- name: Install JS dependencies
run: npm ci

- name: Test
run: cargo test
working-directory: gpui

# The faster `ci` profile (no LTO, see gpui/Cargo.toml).
- name: Build app
run: cargo build --profile ci
working-directory: gpui

- name: Bundle OpenMouse.app
run: |
version=$(sed -n 's/^version = "\(.*\)"/\1/p' gpui/Cargo.toml | head -1)
mkdir dist
.github/scripts/make-macos-app.sh gpui/target/ci/openmouse-gpui "$version" dist

- uses: actions/upload-artifact@v4
with:
name: openmouse-macos
path: dist/OpenMouse.dmg
if-no-files-found: error
12 changes: 8 additions & 4 deletions .github/workflows/build-gpui-windows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,19 @@ jobs:
run: cargo test
working-directory: gpui

- name: Build release binary
run: cargo build --release
# The faster `ci` profile (no LTO, see gpui/Cargo.toml); releases use
# the full `release` profile.
- name: Build app
run: cargo build --profile ci
working-directory: gpui

# Needs the app's release .exe to exist first: its build.rs embeds
# those bytes as the installer's payload (see gpui/installer/build.rs).
# Needs the app's .exe to exist first: its build.rs embeds those bytes
# as the installer's payload (see gpui/installer/build.rs).
- name: Build installer
run: cargo build --release
working-directory: gpui/installer
env:
OPENMOUSE_GPUI_EXE: ../target/ci/openmouse-gpui.exe

- name: Rename installer artifact
run: cp gpui/installer/target/release/openmouse-installer.exe OpenMouseDesktopSetup.exe
Expand Down
9 changes: 9 additions & 0 deletions gpui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ openmouse-presence = { version = "0.0.1", path = "../crates/openmouse-presence"
[target.'cfg(target_os = "macos")'.dependencies]
cocoa = "=0.25.0"
objc = "=0.2.7"
tray-icon = { version = "0.24", default-features = false }

[dev-dependencies]
gpui-kit = "=0.7.3"
Expand All @@ -53,3 +54,11 @@ opt-level = 3
lto = true
codegen-units = 1
strip = true

# Pull-request and main builds only need to prove the app compiles and give
# testers a binary, so skip the whole-program LTO and single codegen unit that
# make `release` slow. Tagged releases still use `release`.
[profile.ci]
inherits = "release"
lto = false
codegen-units = 16
4 changes: 3 additions & 1 deletion gpui/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ use std::process::Command;
fn main() {
// 16 MiB main-thread stack (Windows default is 1 MiB): a debug build of the
// device page (dozens of controls in nested flex rows) overflows 1 MiB.
println!("cargo:rustc-link-arg-bins=/STACK:16777216");
if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") {
println!("cargo:rustc-link-arg-bins=/STACK:16777216");
}
println!("cargo:rerun-if-changed=js");
println!("cargo:rerun-if-changed=js/build.mjs");
println!("cargo:rerun-if-changed=js/shims");
Expand Down
1 change: 0 additions & 1 deletion gpui/src/bin/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@ fn main() {
if openmouse_gpui::autostart::launched_by_os() {
let _ = cx.update_window(handle, |_, window, _| {
if has_tray {
#[cfg(windows)]
openmouse_gpui::tray::hide(window);
} else {
window.minimize_window();
Expand Down
121 changes: 86 additions & 35 deletions gpui/src/tray.rs
Original file line number Diff line number Diff line change
@@ -1,70 +1,118 @@
//! System tray: closing the main window hides it to a tray icon instead of
//! quitting, and the icon brings it back. Quitting is the tray menu's job.
//!
//! Windows only for now; elsewhere [`install`] does nothing and closing the
//! window quits as before.
//! Windows and macOS (menu bar); elsewhere [`install`] does nothing and closing
//! the window quits as before.

#[cfg(windows)]
#[cfg(any(windows, target_os = "macos"))]
mod imp {
use futures::channel::mpsc;
use futures::StreamExt as _;
use gpui_kit::{AnyWindowHandle, App, AppContext as _, Window};
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
use tray_icon::menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem};
use tray_icon::{Icon, MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
use tray_icon::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};

use crate::activity::ACTIVITY;

#[link(name = "user32")]
extern "system" {
fn ShowWindow(hwnd: isize, cmd: i32) -> i32;
fn SetForegroundWindow(hwnd: isize) -> i32;
}

const SW_HIDE: i32 = 0;
/// Shows a hidden window and un-minimizes a minimized one.
const SW_RESTORE: i32 = 9;
/// The app icon `build.rs` embeds as the exe's first icon resource.
const APP_ICON_RESOURCE: u16 = 1;

enum Command {
Show,
Quit,
}

fn hwnd_of(window: &Window) -> Option<isize> {
match HasWindowHandle::window_handle(window).map(|handle| handle.as_raw()) {
Ok(RawWindowHandle::Win32(handle)) => Some(handle.hwnd.get()),
_ => None,
#[cfg(windows)]
mod platform {
use gpui_kit::Window;
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
use tray_icon::Icon;

#[link(name = "user32")]
extern "system" {
fn ShowWindow(hwnd: isize, cmd: i32) -> i32;
fn SetForegroundWindow(hwnd: isize) -> i32;
}

const SW_HIDE: i32 = 0;
/// Shows a hidden window and un-minimizes a minimized one.
const SW_RESTORE: i32 = 9;
/// The app icon `build.rs` embeds as the exe's first icon resource.
const APP_ICON_RESOURCE: u16 = 1;

fn hwnd_of(window: &Window) -> Option<isize> {
match HasWindowHandle::window_handle(window).map(|handle| handle.as_raw()) {
Ok(RawWindowHandle::Win32(handle)) => Some(handle.hwnd.get()),
_ => None,
}
}

pub fn icon() -> Option<Icon> {
Icon::from_resource(APP_ICON_RESOURCE, Some((32, 32))).ok()
}

/// Hides the window and its taskbar button.
pub fn hide_window(window: &Window) {
if let Some(hwnd) = hwnd_of(window) {
unsafe {
ShowWindow(hwnd, SW_HIDE);
}
}
}

pub fn show_window(window: &mut Window) {
if let Some(hwnd) = hwnd_of(window) {
unsafe {
ShowWindow(hwnd, SW_RESTORE);
SetForegroundWindow(hwnd);
}
}
}
}

/// Hides the window (no taskbar button either) and tells the background
/// loops nobody is looking.
pub fn hide(window: &Window) {
if let Some(hwnd) = hwnd_of(window) {
#[cfg(target_os = "macos")]
mod platform {
use cocoa::base::{id, nil};
use gpui_kit::Window;
use objc::{class, msg_send, sel, sel_impl};
use tray_icon::Icon;

pub fn icon() -> Option<Icon> {
let image = image::load_from_memory(include_bytes!("../assets/logo.png")).ok()?.into_rgba8();
let (width, height) = image.dimensions();
Icon::from_rgba(image.into_raw(), width, height).ok()
}

/// Hides the whole app (like Cmd+H); the menu bar icon stays.
pub fn hide_window(_window: &Window) {
unsafe {
ShowWindow(hwnd, SW_HIDE);
let app: id = msg_send![class!(NSApplication), sharedApplication];
let _: () = msg_send![app, hide: nil];
}
}
ACTIVITY.set_active(false);
}

fn show(window: &mut Window) {
if let Some(hwnd) = hwnd_of(window) {
pub fn show_window(_window: &mut Window) {
unsafe {
ShowWindow(hwnd, SW_RESTORE);
SetForegroundWindow(hwnd);
let app: id = msg_send![class!(NSApplication), sharedApplication];
let _: () = msg_send![app, unhide: nil];
}
}
}

/// Hides the window (no taskbar button either) and tells the background
/// loops nobody is looking.
pub fn hide(window: &Window) {
platform::hide_window(window);
ACTIVITY.set_active(false);
}

fn show(window: &mut Window) {
platform::show_window(window);
window.activate_window();
ACTIVITY.set_active(true);
}

/// Creates the tray icon and makes closing `window` hide it. Returns
/// whether the tray exists; without one, closing still quits.
pub fn install(cx: &mut App, window: AnyWindowHandle) -> bool {
let Ok(icon) = Icon::from_resource(APP_ICON_RESOURCE, Some((32, 32))) else {
let Some(icon) = platform::icon() else {
return false;
};
let open = MenuItem::with_id("open", "Open OpenMouse", true, None);
Expand Down Expand Up @@ -132,10 +180,13 @@ mod imp {
}
}

#[cfg(windows)]
#[cfg(any(windows, target_os = "macos"))]
pub use imp::{hide, install};

#[cfg(not(windows))]
#[cfg(not(any(windows, target_os = "macos")))]
pub fn install(_cx: &mut gpui_kit::App, _window: gpui_kit::AnyWindowHandle) -> bool {
false
}

#[cfg(not(any(windows, target_os = "macos")))]
pub fn hide(_window: &gpui_kit::Window) {}
Loading