diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..e11d56a --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[alias] +xtask = "run --release --package xtask --" diff --git a/.gitignore b/.gitignore index b908cff..76cd7c3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .DS_Store target/ build/ +dist/ diff --git a/Cargo.toml b/Cargo.toml index 5a65e64..0c4236e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,9 @@ [workspace] -members = ["core", "viewport", "windows"] +members = ["core", "viewport", "windows", "linux", "xtask"] +# Excludes `linux` (deps don't build on macOS/Windows) and `xtask` (build-tool, +# not part of the app). The Linux script invokes `cargo build -p acord-linux` +# directly when running on a Linux host. +default-members = ["core", "viewport", "windows"] resolver = "2" [profile.release] diff --git a/README.md b/README.md index dfcc14a..2a21efd 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,53 @@ # Acord -## Quickstart MacOS +## Quickstart - Download a pre-compiled binary from [Releases](.././../releases) -- To build, install/update/replace the release binary which will be propogated into your Applications folder, execute this script: +- Or build from source — one command, picks the right script for your platform: ```bash -#!/usr/bin/env bash -set -euo pipefail - -ROOT="$(cd "$(dirname "$0")" && pwd)" -DEST="/Applications/Acord.app" - -echo "Building release..." -"$ROOT/build.sh" - -# Kill running instance before replacing -pkill -f "Acord.app/Contents/MacOS/Acord" 2>/dev/null || true -sleep 0.5 - -echo "Installing to $DEST..." -rm -rf "$DEST" -cp -R "$ROOT/build/bin/Acord.app" "$DEST" - -echo "Installed: $DEST" +cargo xtask install ``` +On macOS this drops a release `.app` into `/Applications`. On Linux it installs the binary into `~/.local/bin`, drops a `.desktop` entry into `~/.local/share/applications`, and registers the icon. On Windows it builds the release exe (no install step yet). + +Other commands: + +```bash +cargo xtask build # release build only +cargo xtask debug # debug build, foreground launch +cargo xtask build-universal # universal arm64+x86_64 binary (macOS / Windows) +cargo xtask package --all # cross-compile + zip all six distributables +``` + +Append `-macos`, `-windows`, or `-linux` to force a platform (e.g. `cargo xtask build-universal-windows`). + +On Linux, both x11 and wayland backends are linked into the binary by default. Force one with `ACORD_FEATURES=wayland cargo xtask build` (handy for flatpak or stripped distros). + +### Releasing + +`cargo xtask package --all` produces six zips in `dist/` from a single macOS host: + +``` +dist/acord-macos-aarch64.zip # Acord.app — drag to /Applications +dist/acord-macos-x86_64.zip +dist/acord-windows-aarch64.zip # folder with Acord.exe + assets +dist/acord-windows-x86_64.zip +dist/acord-linux-aarch64.zip # folder with Acord + install.sh +dist/acord-linux-x86_64.zip +``` + +Cross-compile uses [`cargo-zigbuild`](https://github.com/rust-cross/cargo-zigbuild) (zig as the cross-linker) for the windows/linux targets — no Docker, no VMs. One-time setup: + +```bash +brew install zig librsvg +cargo install cargo-zigbuild +``` + +Or build a single target: `cargo xtask package --target windows-aarch64`. + Here's the 'sales' pitch: "Hi there, do you enjoy casually solving project euler problems and are tired of using the spotlight bar as your primary calculator?" - Then this might be your kinda thing. diff --git a/dist/acord-macos-aarch64.zip b/dist/acord-macos-aarch64.zip new file mode 100644 index 0000000..a98e851 Binary files /dev/null and b/dist/acord-macos-aarch64.zip differ diff --git a/install.sh b/install.sh deleted file mode 100755 index 4db30fb..0000000 --- a/install.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT="$(cd "$(dirname "$0")" && pwd)" -DEST="/Applications/Acord.app" - -echo "Building release..." -"$ROOT/build.sh" - -# Kill running instance before replacing -pkill -f "Acord.app/Contents/MacOS/Acord" 2>/dev/null || true -sleep 0.5 - -echo "Installing to $DEST..." -rm -rf "$DEST" -cp -R "$ROOT/build/bin/Acord.app" "$DEST" - -echo "Installed: $DEST" diff --git a/linux/Cargo.toml b/linux/Cargo.toml new file mode 100644 index 0000000..1c7d515 --- /dev/null +++ b/linux/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "acord-linux" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "acord" +path = "src/main.rs" + +# x11 and wayland are independent winit backends. Default builds enable both +# so a single binary runs on either display server. The build script can pass +# `--no-default-features --features x11` (or `wayland`) to force one — useful +# for flatpak builds or stripped-down distros. +[features] +default = ["x11", "wayland"] +x11 = ["winit/x11"] +wayland = ["winit/wayland"] + +[dependencies] +acord-core = { path = "../core" } +acord-viewport = { path = "../viewport" } +# rwh_06 = raw-window-handle 0.6 trait impls (HasWindowHandle/HasDisplayHandle). +# Required because we lift the surface handle out of the winit Window before +# handing it to wgpu via viewport_create. +winit = { version = "0.30", default-features = false, features = ["rwh_06"] } +arboard = "3" +rfd = "0.15" +raw-window-handle = "0.6" +wgpu = "27" +iced_wgpu = "0.14" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +dirs = "6" +image = "0.25" diff --git a/linux/src/app.rs b/linux/src/app.rs new file mode 100644 index 0000000..de5d3d4 --- /dev/null +++ b/linux/src/app.rs @@ -0,0 +1,451 @@ +use std::ffi::CString; +use std::path::PathBuf; +use std::time::{Duration, Instant}; + +use winit::application::ApplicationHandler; +use winit::dpi::{LogicalSize, PhysicalPosition}; +use winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent}; +use winit::event_loop::ActiveEventLoop; +use winit::keyboard::{Key, ModifiersState, NamedKey}; +use winit::window::{Window, WindowAttributes, WindowId}; + +use acord_viewport::{ + viewport_create, viewport_destroy, viewport_render, viewport_resize, + viewport_set_text, viewport_get_text, viewport_set_theme, viewport_set_lang, + viewport_set_line_indicator, viewport_set_gutter_rainbow, + viewport_send_command, viewport_free_string, + ViewportHandle, +}; + +use crate::config::Config; +use crate::shortcuts::{match_shortcut, MenuAction}; + +pub struct App { + window: Option, + handle: *mut ViewportHandle, + config: Config, + cursor_pos: PhysicalPosition, + scale: f32, + modifiers: ModifiersState, + current_file: Option, + last_autosave_attempt: Instant, + last_autosaved_hash: Option, +} + +impl App { + pub fn new() -> Self { + Self { + window: None, + handle: std::ptr::null_mut(), + config: Config::load(), + cursor_pos: PhysicalPosition::new(0.0, 0.0), + scale: 1.0, + modifiers: ModifiersState::empty(), + current_file: None, + last_autosave_attempt: Instant::now(), + last_autosaved_hash: None, + } + } + + fn sync_settings(&self) { + if self.handle.is_null() { return; } + let theme = match self.config.theme_mode() { + "dark" => "kicad", + "light" => "latte", + _ => "kicad", + }; + let c_theme = CString::new(theme).unwrap(); + viewport_set_theme(self.handle, c_theme.as_ptr()); + + let ind = CString::new(self.config.line_indicator()).unwrap(); + viewport_set_line_indicator(self.handle, ind.as_ptr()); + viewport_set_gutter_rainbow(self.handle, self.config.gutter_rainbow()); + } + + fn dispatch_menu(&mut self, action: MenuAction, event_loop: &ActiveEventLoop) { + if self.handle.is_null() { return; } + match action { + MenuAction::Quit => event_loop.exit(), + MenuAction::Bold => { viewport_send_command(self.handle, 1); } + MenuAction::Italic => { viewport_send_command(self.handle, 2); } + MenuAction::InsertTable => { viewport_send_command(self.handle, 3); } + MenuAction::Evaluate => { viewport_send_command(self.handle, 5); } + MenuAction::ZoomIn => { viewport_send_command(self.handle, 7); } + MenuAction::ZoomOut => { viewport_send_command(self.handle, 8); } + MenuAction::ZoomReset => { viewport_send_command(self.handle, 9); } + MenuAction::LiveMode => { viewport_send_command(self.handle, 11); } + MenuAction::EditorMode => { viewport_send_command(self.handle, 12); } + MenuAction::ViewMode => { viewport_send_command(self.handle, 13); } + MenuAction::Find => { viewport_send_command(self.handle, 14); } + MenuAction::Open => self.open_file(), + MenuAction::Save => self.save_file(), + MenuAction::SaveAs => self.save_file_as(), + MenuAction::NewNote => self.new_note(), + MenuAction::Settings => { + let cfg = config_path(); + // Prefer xdg-open; fall back to $EDITOR or nano. + let opened = std::process::Command::new("xdg-open").arg(&cfg).spawn().is_ok(); + if !opened { + let editor = std::env::var("EDITOR").unwrap_or_else(|_| "nano".into()); + let _ = std::process::Command::new(editor).arg(&cfg).spawn(); + } + } + MenuAction::ExportCrate => { /* TODO: wire crate export */ } + } + } + + fn open_file(&mut self) { + let dialog = rfd::FileDialog::new() + .add_filter("Markdown", &["md", "markdown"]) + .add_filter("All Files", &["*"]); + if let Some(path) = dialog.pick_file() { + if let Ok(text) = std::fs::read_to_string(&path) { + let c = CString::new(text).unwrap_or_default(); + viewport_set_text(self.handle, c.as_ptr()); + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("md"); + let c_ext = CString::new(ext).unwrap(); + viewport_set_lang(self.handle, c_ext.as_ptr()); + if let Some(w) = &self.window { + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("Acord"); + w.set_title(&format!("{name} - Acord")); + } + self.current_file = Some(path); + self.last_autosaved_hash = None; + } + } + } + + fn save_file(&mut self) { + match self.current_file.clone() { + Some(path) => self.write_to(&path), + None => self.save_file_as(), + } + } + + fn save_file_as(&mut self) { + let dialog = rfd::FileDialog::new() + .add_filter("Markdown", &["md"]) + .add_filter("All Files", &["*"]) + .set_file_name("note.md"); + if let Some(path) = dialog.save_file() { + self.write_to(&path); + self.current_file = Some(path); + } + } + + fn write_to(&mut self, path: &std::path::Path) { + let text_ptr = viewport_get_text(self.handle); + if text_ptr.is_null() { return; } + let text = unsafe { std::ffi::CStr::from_ptr(text_ptr) } + .to_string_lossy() + .into_owned(); + viewport_free_string(text_ptr); + if std::fs::write(path, &text).is_ok() { + self.last_autosaved_hash = Some(text_hash(&text)); + } + } + + fn new_note(&mut self) { + let empty = CString::new("").unwrap(); + viewport_set_text(self.handle, empty.as_ptr()); + if let Some(w) = &self.window { + w.set_title("Acord"); + } + self.current_file = None; + self.last_autosaved_hash = None; + } + + fn try_autosave(&mut self) { + if self.handle.is_null() { return; } + let text_ptr = viewport_get_text(self.handle); + if text_ptr.is_null() { return; } + let text = unsafe { std::ffi::CStr::from_ptr(text_ptr) } + .to_string_lossy() + .into_owned(); + viewport_free_string(text_ptr); + + let hash = text_hash(&text); + if Some(hash) == self.last_autosaved_hash { return; } + + let path = self.current_file.clone().unwrap_or_else(|| { + self.config.notes_dir().join("Untitled.md") + }); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if std::fs::write(&path, &text).is_ok() { + self.last_autosaved_hash = Some(hash); + } + } + + fn winit_button(button: MouseButton) -> u8 { + match button { + MouseButton::Left => 0, + MouseButton::Right => 1, + MouseButton::Middle => 2, + MouseButton::Other(n) => n as u8, + _ => 0, + } + } +} + +impl ApplicationHandler for App { + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + if self.window.is_some() { return; } + + let mut attrs = WindowAttributes::default() + .with_title("Acord") + .with_inner_size(LogicalSize::new(1100.0, 750.0)); + + if let Some(icon) = load_window_icon() { + attrs = attrs.with_window_icon(Some(icon)); + } + + let window = event_loop.create_window(attrs).expect("create window"); + self.scale = window.scale_factor() as f32; + + let size = window.inner_size(); + let w = size.width as f32 / self.scale; + let h = size.height as f32 / self.scale; + + // Pass the raw display+window handle to the viewport. wgpu picks the + // X11 or Wayland backend automatically based on which RawDisplayHandle + // variant winit hands us, so a single binary works on both. + use raw_window_handle::HasWindowHandle; + let wh = window.window_handle().expect("window handle"); + let raw = wh.as_raw(); + + let surface_handle = match raw { + #[cfg(target_os = "linux")] + raw_window_handle::RawWindowHandle::Xlib(h) => h.window as *mut std::ffi::c_void, + #[cfg(target_os = "linux")] + raw_window_handle::RawWindowHandle::Xcb(h) => h.window.get() as *mut std::ffi::c_void, + #[cfg(target_os = "linux")] + raw_window_handle::RawWindowHandle::Wayland(h) => h.surface.as_ptr(), + _ => std::ptr::null_mut(), + }; + + self.handle = viewport_create(surface_handle, w, h, self.scale); + self.sync_settings(); + self.window = Some(window); + } + + fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) { + if self.handle.is_null() { return; } + + match event { + WindowEvent::CloseRequested => { + if !self.handle.is_null() { + viewport_destroy(self.handle); + self.handle = std::ptr::null_mut(); + } + event_loop.exit(); + } + + WindowEvent::Resized(size) => { + let w = size.width as f32 / self.scale; + let h = size.height as f32 / self.scale; + viewport_resize(self.handle, w, h, self.scale); + } + + WindowEvent::ScaleFactorChanged { scale_factor, .. } => { + self.scale = scale_factor as f32; + if let Some(win) = &self.window { + let size = win.inner_size(); + let w = size.width as f32 / self.scale; + let h = size.height as f32 / self.scale; + viewport_resize(self.handle, w, h, self.scale); + } + } + + WindowEvent::RedrawRequested => { + viewport_render(self.handle); + } + + WindowEvent::CursorMoved { position, .. } => { + self.cursor_pos = position; + let x = position.x as f32 / self.scale; + let y = position.y as f32 / self.scale; + acord_viewport::viewport_mouse_event(self.handle, x, y, 255, false); + } + + WindowEvent::MouseInput { state, button, .. } => { + let x = self.cursor_pos.x as f32 / self.scale; + let y = self.cursor_pos.y as f32 / self.scale; + let pressed = state == ElementState::Pressed; + acord_viewport::viewport_mouse_event( + self.handle, x, y, Self::winit_button(button), pressed, + ); + } + + WindowEvent::MouseWheel { delta, .. } => { + let x = self.cursor_pos.x as f32 / self.scale; + let y = self.cursor_pos.y as f32 / self.scale; + let (dx, dy) = match delta { + MouseScrollDelta::LineDelta(dx, dy) => (dx * 20.0, dy * 20.0), + MouseScrollDelta::PixelDelta(d) => (d.x as f32, d.y as f32), + }; + acord_viewport::viewport_scroll_event(self.handle, x, y, dx, -dy); + } + + WindowEvent::KeyboardInput { event, .. } => { + let pressed = event.state == ElementState::Pressed; + + // App-level shortcut? Fire on press only and short-circuit so the + // viewport's text_editor doesn't also see the keystroke (otherwise + // Ctrl+S would type 's' as well as save). + if pressed { + if let Some(action) = match_shortcut(self.modifiers, &event.logical_key) { + self.dispatch_menu(action, event_loop); + return; + } + } + + let text_str = event.text.as_ref().map(|s| s.to_string()); + let text_c = text_str.as_deref().and_then(|s| CString::new(s).ok()); + let text_ptr = text_c.as_ref() + .map(|c| c.as_ptr()) + .unwrap_or(std::ptr::null()); + + let keycode = winit_key_to_code(&event.logical_key); + let mod_flags = encode_modifiers(self.modifiers); + + acord_viewport::viewport_key_event( + self.handle, keycode, mod_flags, pressed, text_ptr, + ); + } + + WindowEvent::ModifiersChanged(mods) => { + self.modifiers = mods.state(); + if !self.handle.is_null() { + let state = mods.state(); + let h = unsafe { &mut *self.handle }; + use iced_wgpu::core::keyboard; + use iced_wgpu::core::Event; + h.events.push(Event::Keyboard( + keyboard::Event::ModifiersChanged(decode_winit_modifiers(state)), + )); + h.needs_redraw = true; + } + } + + _ => {} + } + } + + fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) { + if self.last_autosave_attempt.elapsed() >= Duration::from_millis(500) { + self.last_autosave_attempt = Instant::now(); + self.try_autosave(); + } + if let Some(w) = &self.window { + if !self.handle.is_null() { + w.request_redraw(); + } + } + } +} + +fn text_hash(s: &str) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + s.hash(&mut h); + h.finish() +} + +/// Maps winit logical keys to the macOS-style virtual keycodes the bridge +/// expects. Character keys go through `text` instead, so 0 is fine for those. +fn winit_key_to_code(key: &Key) -> u32 { + match key { + Key::Named(n) => match n { + NamedKey::Enter => 36, + NamedKey::Tab => 48, + NamedKey::Backspace => 51, + NamedKey::Escape => 53, + NamedKey::Delete => 117, + NamedKey::ArrowLeft => 123, + NamedKey::ArrowRight => 124, + NamedKey::ArrowDown => 125, + NamedKey::ArrowUp => 126, + NamedKey::Home => 115, + NamedKey::End => 119, + NamedKey::PageUp => 116, + NamedKey::PageDown => 121, + NamedKey::F1 => 122, + NamedKey::F2 => 120, + NamedKey::F3 => 99, + NamedKey::F4 => 118, + NamedKey::F5 => 96, + NamedKey::F6 => 97, + NamedKey::F7 => 98, + NamedKey::F8 => 100, + NamedKey::F9 => 101, + NamedKey::F10 => 109, + NamedKey::F11 => 103, + NamedKey::F12 => 111, + _ => 0, + }, + _ => 0, + } +} + +fn encode_modifiers(state: ModifiersState) -> u32 { + let mut flags = 0u32; + if state.shift_key() { flags |= 1 << 17; } + if state.control_key() { flags |= 1 << 18; } + if state.alt_key() { flags |= 1 << 19; } + if state.super_key() { flags |= 1 << 20; } + // Mirror Ctrl→LOGO so iced text_editor's Cmd+C/V/X/Z/A bindings fire on + // Ctrl. Same trick the Windows shell uses; both action-modifier-on-Ctrl + // platforms need it. + if state.control_key() { flags |= 1 << 20; } + flags +} + +fn decode_winit_modifiers(state: ModifiersState) -> iced_wgpu::core::keyboard::Modifiers { + let mut m = iced_wgpu::core::keyboard::Modifiers::empty(); + if state.shift_key() { m |= iced_wgpu::core::keyboard::Modifiers::SHIFT; } + if state.control_key() { m |= iced_wgpu::core::keyboard::Modifiers::CTRL; } + if state.alt_key() { m |= iced_wgpu::core::keyboard::Modifiers::ALT; } + if state.control_key() { m |= iced_wgpu::core::keyboard::Modifiers::LOGO; } + m +} + +fn config_path() -> std::path::PathBuf { + if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { + if !xdg.is_empty() { + return std::path::PathBuf::from(xdg).join("acord").join("config.json"); + } + } + if let Some(cfg) = dirs::config_dir() { + return cfg.join("acord").join("config.json"); + } + dirs::home_dir() + .unwrap_or_default() + .join(".acord") + .join("config.json") +} + +/// Loads `icon.png` next to the exe. Returns None on any failure — winit +/// silently uses the WM default in that case. +fn load_window_icon() -> Option { + let exe_dir = std::env::current_exe().ok()?.parent()?.to_path_buf(); + let png_path = exe_dir.join("icon.png"); + let bytes = if png_path.exists() { + std::fs::read(&png_path).ok()? + } else { + // Fall back to repo-root assets when running via cargo run. + let svg_path = std::path::PathBuf::from("assets/Acord.svg").canonicalize().ok()?; + let output = std::process::Command::new("rsvg-convert") + .args(["--width", "256", "--height", "256"]) + .arg(&svg_path) + .output() + .ok()?; + if !output.status.success() { return None; } + output.stdout + }; + + let img = image::load_from_memory(&bytes).ok()?.into_rgba8(); + let (w, h) = img.dimensions(); + winit::window::Icon::from_rgba(img.into_raw(), w, h).ok() +} diff --git a/linux/src/config.rs b/linux/src/config.rs new file mode 100644 index 0000000..80f139d --- /dev/null +++ b/linux/src/config.rs @@ -0,0 +1,75 @@ +use std::collections::HashMap; +use std::path::PathBuf; + +#[allow(dead_code)] +pub struct Config { + path: PathBuf, + data: HashMap, +} + +#[allow(dead_code)] +impl Config { + pub fn load() -> Self { + let dir = config_dir(); + std::fs::create_dir_all(&dir).ok(); + let notes = dir.join("notes"); + std::fs::create_dir_all(¬es).ok(); + + let path = dir.join("config.json"); + let data = std::fs::read_to_string(&path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + Self { path, data } + } + + pub fn save(&self) { + if let Ok(json) = serde_json::to_string_pretty(&self.data) { + let _ = std::fs::write(&self.path, json); + } + } + + pub fn get(&self, key: &str, default: &str) -> String { + self.data.get(key).cloned().unwrap_or_else(|| default.to_string()) + } + + pub fn set(&mut self, key: &str, value: &str) { + self.data.insert(key.to_string(), value.to_string()); + self.save(); + } + + pub fn theme_mode(&self) -> &str { + self.data.get("themeMode").map(|s| s.as_str()).unwrap_or("auto") + } + + pub fn line_indicator(&self) -> &str { + self.data.get("lineIndicatorMode").map(|s| s.as_str()).unwrap_or("on") + } + + pub fn gutter_rainbow(&self) -> bool { + self.data.get("gutterRainbow").map(|s| s != "false").unwrap_or(true) + } + + pub fn notes_dir(&self) -> PathBuf { + self.data.get("autoSaveDirectory") + .map(PathBuf::from) + .unwrap_or_else(|| config_dir().join("notes")) + } +} + +/// XDG-friendly config dir with `~/.acord` fallback for parity with the +/// Windows shell. `$XDG_CONFIG_HOME/acord` if set, else `~/.config/acord`, +/// else `~/.acord`. +fn config_dir() -> PathBuf { + if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { + if !xdg.is_empty() { + return PathBuf::from(xdg).join("acord"); + } + } + if let Some(cfg) = dirs::config_dir() { + return cfg.join("acord"); + } + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".acord") +} diff --git a/linux/src/main.rs b/linux/src/main.rs new file mode 100644 index 0000000..af1c23b --- /dev/null +++ b/linux/src/main.rs @@ -0,0 +1,9 @@ +mod app; +mod config; +mod shortcuts; + +fn main() { + let event_loop = winit::event_loop::EventLoop::new().expect("event loop"); + let mut state = app::App::new(); + event_loop.run_app(&mut state).expect("run"); +} diff --git a/linux/src/shortcuts.rs b/linux/src/shortcuts.rs new file mode 100644 index 0000000..8960325 --- /dev/null +++ b/linux/src/shortcuts.rs @@ -0,0 +1,64 @@ +use winit::keyboard::{Key, ModifiersState, NamedKey, SmolStr}; + +#[derive(Clone, Copy)] +#[allow(dead_code)] +// LiveMode/EditorMode/ViewMode are dispatched but not yet bound to a shortcut +// — Linux has no menu bar to expose them via, so they wait for either a key +// binding decision or an iced-rendered menu inside the viewport. +pub enum MenuAction { + NewNote, + Open, + Save, + SaveAs, + Quit, + Bold, + Italic, + InsertTable, + Evaluate, + LiveMode, + EditorMode, + ViewMode, + ZoomIn, + ZoomOut, + ZoomReset, + Find, + Settings, + ExportCrate, +} + +/// Matches an app-level shortcut. Returns Some(action) for combos that should +/// fire a MenuAction; None for combos that should fall through to the +/// viewport (cut/copy/paste/undo/redo/select-all are handled inside iced via +/// the Ctrl→LOGO modifier alias, plain typing, navigation, etc.). +pub fn match_shortcut(modifiers: ModifiersState, key: &Key) -> Option { + if !modifiers.control_key() { + return None; + } + let shift = modifiers.shift_key(); + + match key { + Key::Character(s) => match (shift, ascii_lower(s)) { + (false, 'n') => Some(MenuAction::NewNote), + (false, 'o') => Some(MenuAction::Open), + (false, 's') => Some(MenuAction::Save), + (true, 's') => Some(MenuAction::SaveAs), + (false, 'q') => Some(MenuAction::Quit), + (false, 'b') => Some(MenuAction::Bold), + (false, 'i') => Some(MenuAction::Italic), + (false, 't') => Some(MenuAction::InsertTable), + (false, 'f') => Some(MenuAction::Find), + (true, 'e') => Some(MenuAction::ExportCrate), + (false, ',') => Some(MenuAction::Settings), + (false, '=') | (false, '+') => Some(MenuAction::ZoomIn), + (false, '-') => Some(MenuAction::ZoomOut), + (true, '0') => Some(MenuAction::ZoomReset), + _ => None, + }, + Key::Named(NamedKey::Enter) => Some(MenuAction::Evaluate), + _ => None, + } +} + +fn ascii_lower(s: &SmolStr) -> char { + s.chars().next().map(|c| c.to_ascii_lowercase()).unwrap_or('\0') +} diff --git a/scripts/linux/build.sh b/scripts/linux/build.sh new file mode 100755 index 0000000..bd8de00 --- /dev/null +++ b/scripts/linux/build.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +case "$(uname -s)" in + Linux) ;; + *) echo "wrong platform: $(uname -s) — use cargo xtask build" >&2; exit 1;; +esac + +# Pick the winit backend(s). Default builds enable both x11 and wayland so a +# single binary works on either; ACORD_FEATURES overrides for cases where +# only one backend is available (flatpak, stripped distros, debugging one +# backend in isolation). +detect_features() { + if [ -n "${ACORD_FEATURES:-}" ]; then + echo "$ACORD_FEATURES" + return + fi + # No detection — both backends are linked by default. Override only when + # you need to force one. + echo "" +} + +FEATURES="$(detect_features)" +echo "build: XDG_CURRENT_DESKTOP=${XDG_CURRENT_DESKTOP:-}, WAYLAND_DISPLAY=${WAYLAND_DISPLAY:-}" + +if [ -n "$FEATURES" ]; then + echo "build: forcing features = $FEATURES" + cargo build --release -p acord-linux --no-default-features --features "$FEATURES" +else + echo "build: linking both x11 and wayland backends" + cargo build --release -p acord-linux +fi + +STAGE="$ROOT/build/bin" +mkdir -p "$STAGE" + +cp "$ROOT/target/release/acord" "$STAGE/Acord" +chmod +x "$STAGE/Acord" + +# Rasterize the SVG icon next to the binary so load_window_icon picks it up. +if command -v rsvg-convert >/dev/null 2>&1 && [ -f "$ROOT/assets/Acord.svg" ]; then + rsvg-convert --width 256 --height 256 "$ROOT/assets/Acord.svg" -o "$STAGE/icon.png" +else + echo "rsvg-convert not found or assets/Acord.svg missing — skipping icon" +fi + +[ -f "$ROOT/LICENCE" ] && cp "$ROOT/LICENCE" "$STAGE/LICENCE" + +echo "Built: $STAGE/Acord" diff --git a/scripts/linux/debug.sh b/scripts/linux/debug.sh new file mode 100755 index 0000000..4560ea7 --- /dev/null +++ b/scripts/linux/debug.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Debug build — same wiring as build.sh but unoptimised, with -g, and +# launched in the foreground so Rust panics print straight to this terminal. + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +case "$(uname -s)" in + Linux) ;; + *) echo "wrong platform: $(uname -s) — use cargo xtask debug" >&2; exit 1;; +esac + +export RUST_BACKTRACE=1 + +if [ -n "${ACORD_FEATURES:-}" ]; then + cargo build -p acord-linux --no-default-features --features "$ACORD_FEATURES" +else + cargo build -p acord-linux +fi + +EXE="$ROOT/target/debug/acord" + +# Rasterize the icon next to the exe so the dev binary has a window icon too. +if command -v rsvg-convert >/dev/null 2>&1 && [ -f "$ROOT/assets/Acord.svg" ]; then + rsvg-convert --width 256 --height 256 "$ROOT/assets/Acord.svg" -o "$ROOT/target/debug/icon.png" +fi + +pkill -x acord 2>/dev/null || true +sleep 0.3 + +echo +echo "Launching $EXE — Rust panics will print below." +echo "(Ctrl+C to exit, or quit Acord normally.)" +echo "----------------------------------------------------------" +exec "$EXE" diff --git a/scripts/linux/install.sh b/scripts/linux/install.sh new file mode 100755 index 0000000..6e4b374 --- /dev/null +++ b/scripts/linux/install.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +case "$(uname -s)" in + Linux) ;; + *) echo "wrong platform: $(uname -s) — use cargo xtask install" >&2; exit 1;; +esac + +bash "$ROOT/scripts/linux/build.sh" + +# XDG-correct install: binary into ~/.local/bin (PATH on most distros), icon +# + .desktop into ~/.local/share for the launcher menu. +BIN_DIR="${XDG_BIN_HOME:-$HOME/.local/bin}" +APP_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/applications" +ICON_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor/256x256/apps" + +mkdir -p "$BIN_DIR" "$APP_DIR" "$ICON_DIR" + +# Kill running instance before replacing the binary. +pkill -x Acord 2>/dev/null || true +sleep 0.3 + +install -m 755 "$ROOT/build/bin/Acord" "$BIN_DIR/Acord" + +if [ -f "$ROOT/build/bin/icon.png" ]; then + install -m 644 "$ROOT/build/bin/icon.png" "$ICON_DIR/acord.png" +fi + +cat > "$APP_DIR/acord.desktop" </dev/null 2>&1; then + update-desktop-database "$APP_DIR" >/dev/null 2>&1 || true +fi + +echo "Installed:" +echo " binary → $BIN_DIR/Acord" +echo " icon → $ICON_DIR/acord.png" +echo " desktop → $APP_DIR/acord.desktop" + +case ":$PATH:" in + *":$BIN_DIR:"*) ;; + *) echo "note: $BIN_DIR is not on your PATH" >&2 ;; +esac diff --git a/build_universal.sh b/scripts/macos/build-universal.sh similarity index 77% rename from build_universal.sh rename to scripts/macos/build-universal.sh index 3180b38..5b4547e 100755 --- a/build_universal.sh +++ b/scripts/macos/build-universal.sh @@ -1,7 +1,14 @@ #!/usr/bin/env bash set -euo pipefail -ROOT="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +case "$(uname -s)" in + Darwin) ;; + *) echo "wrong platform: $(uname -s) — use cargo xtask build-universal" >&2; exit 1;; +esac + BUILD="$ROOT/build" APP="$BUILD/bin/Acord.app" CONTENTS="$APP/Contents" @@ -10,49 +17,39 @@ RESOURCES="$CONTENTS/Resources" SDK=$(xcrun --show-sdk-path) export MACOSX_DEPLOYMENT_TARGET=14.0 -# 1. Build Rust for both architectures echo "Building Rust workspace (Universal)..." rustup target add aarch64-apple-darwin x86_64-apple-darwin cargo build --release -p acord-viewport --target aarch64-apple-darwin cargo build --release -p acord-viewport --target x86_64-apple-darwin -# 2. Create Universal Rust Static Lib mkdir -p "$ROOT/target/universal" lipo -create \ "$ROOT/target/aarch64-apple-darwin/release/libacord_viewport.a" \ "$ROOT/target/x86_64-apple-darwin/release/libacord_viewport.a" \ -output "$ROOT/target/universal/libacord_viewport.a" -# --- Icon Generation (Remains the same) --- -# [Your existing rsvg-convert and iconutil code here] +# TODO: regenerate AppIcon.icns from assets/Acord.svg here (see build.sh). -# --- Bundle structure --- mkdir -p "$MACOS" "$RESOURCES" cp "$ROOT/Info.plist" "$CONTENTS/Info.plist" [ -f "$BUILD/AppIcon.icns" ] && cp "$BUILD/AppIcon.icns" "$RESOURCES/AppIcon.icns" -# 3. Compile Swift for both architectures echo "Compiling Swift (Universal)..." SWIFT_FILES=("$ROOT"/src/*.swift) RUST_INCLUDES=(-import-objc-header "$ROOT/viewport/include/acord.h" -L "$ROOT/target/universal" -lacord_viewport) -# Compile arm64 slice swiftc -target arm64-apple-macosx14.0 -sdk "$SDK" "${RUST_INCLUDES[@]}" \ -framework Cocoa -framework SwiftUI -framework Metal -framework MetalKit -O \ -o "$MACOS/Acord_arm64" "${SWIFT_FILES[@]}" -# Compile x86_64 slice swiftc -target x86_64-apple-macosx14.0 -sdk "$SDK" "${RUST_INCLUDES[@]}" \ -framework Cocoa -framework SwiftUI -framework Metal -framework MetalKit -O \ -o "$MACOS/Acord_x86" "${SWIFT_FILES[@]}" -# 4. Merge Swift binaries into one Universal binary lipo -create "$MACOS/Acord_arm64" "$MACOS/Acord_x86" -output "$MACOS/Acord" rm "$MACOS/Acord_arm64" "$MACOS/Acord_x86" -# 5. Code sign the universal bundle codesign --force --sign - --deep "$APP" -echo "Successfully built Universal App: $APP" - +echo "Built Universal App: $APP" diff --git a/build.sh b/scripts/macos/build.sh similarity index 73% rename from build.sh rename to scripts/macos/build.sh index ec5e9f4..32c4b4d 100755 --- a/build.sh +++ b/scripts/macos/build.sh @@ -1,7 +1,14 @@ #!/usr/bin/env bash set -euo pipefail -ROOT="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +case "$(uname -s)" in + Darwin) ;; + *) echo "wrong platform: $(uname -s) — use cargo xtask build" >&2; exit 1;; +esac + BUILD="$ROOT/build" APP="$BUILD/bin/Acord.app" CONTENTS="$APP/Contents" @@ -9,25 +16,22 @@ MACOS="$CONTENTS/MacOS" RESOURCES="$CONTENTS/Resources" SDK=$(xcrun --show-sdk-path) - RUST_LIB="$ROOT/target/release" + export MACOSX_DEPLOYMENT_TARGET=14.0 export ZERO_AR_DATE=0 + echo "Building Rust workspace (release)..." -cd "$ROOT" && cargo build --release -p acord-viewport -if [ $? -ne 0 ]; then - echo "ERROR: Rust build failed" - exit 1 -fi +cargo build --release -p acord-viewport if [ ! -f "$RUST_LIB/libacord_viewport.a" ]; then - echo "ERROR: libacord_viewport.a not found at $RUST_LIB" + echo "ERROR: libacord_viewport.a not found at $RUST_LIB" >&2 exit 1 fi RUST_FLAGS=(-import-objc-header "$ROOT/viewport/include/acord.h" -L "$RUST_LIB" -lacord_viewport) -# --- App icon from SVG via rsvg-convert --- +# App icon from SVG via rsvg-convert. SVG="$ROOT/assets/Acord.svg" if [ -f "$SVG" ]; then echo "Generating app icon..." @@ -46,19 +50,17 @@ if [ -f "$SVG" ]; then cp "$ICONSET/icon_512.png" "$ICONSET/icon_256x256@2x.png" cp "$ICONSET/icon_512.png" "$ICONSET/icon_512x512.png" cp "$ICONSET/icon_1024.png" "$ICONSET/icon_512x512@2x.png" - rm -f "$ICONSET"/icon_*.png.tmp "$ICONSET"/icon_16.png "$ICONSET"/icon_32.png "$ICONSET"/icon_64.png "$ICONSET"/icon_128.png "$ICONSET"/icon_256.png "$ICONSET"/icon_512.png "$ICONSET"/icon_1024.png + rm -f "$ICONSET"/icon_16.png "$ICONSET"/icon_32.png "$ICONSET"/icon_64.png \ + "$ICONSET"/icon_128.png "$ICONSET"/icon_256.png "$ICONSET"/icon_512.png \ + "$ICONSET"/icon_1024.png iconutil -c icns "$ICONSET" -o "$BUILD/AppIcon.icns" rm -rf "$ICONSET" fi -# --- Bundle structure --- mkdir -p "$MACOS" "$RESOURCES" cp "$ROOT/Info.plist" "$CONTENTS/Info.plist" -if [ -f "$BUILD/AppIcon.icns" ]; then - cp "$BUILD/AppIcon.icns" "$RESOURCES/AppIcon.icns" -fi +[ -f "$BUILD/AppIcon.icns" ] && cp "$BUILD/AppIcon.icns" "$RESOURCES/AppIcon.icns" -# --- Compile Swift --- echo "Compiling Swift (release)..." swiftc \ -target arm64-apple-macosx14.0 \ @@ -75,9 +77,7 @@ swiftc \ -o "$MACOS/Acord" \ "$ROOT"/src/*.swift -# --- Code sign --- codesign --force --sign - "$APP" echo "Built: $APP" - -open /Users/pszsh/External/Repositories/Acord/build/bin/Acord.app +open "$APP" diff --git a/debug.sh b/scripts/macos/debug.sh similarity index 80% rename from debug.sh rename to scripts/macos/debug.sh index 3508420..2cc51df 100755 --- a/debug.sh +++ b/scripts/macos/debug.sh @@ -5,7 +5,14 @@ set -euo pipefail # launched in the foreground so Rust panics print straight to this terminal # (the panic hook in viewport/src/lib.rs flushes stderr before SIGABRT). -ROOT="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +case "$(uname -s)" in + Darwin) ;; + *) echo "wrong platform: $(uname -s) — use cargo xtask debug" >&2; exit 1;; +esac + BUILD="$ROOT/build" APP="$BUILD/bin/Acord.app" CONTENTS="$APP/Contents" @@ -20,23 +27,19 @@ export ZERO_AR_DATE=0 export RUST_BACKTRACE=1 echo "Building Rust workspace (debug)..." -cd "$ROOT" && cargo build -p acord-viewport +cargo build -p acord-viewport if [ ! -f "$RUST_LIB/libacord_viewport.a" ]; then - echo "ERROR: libacord_viewport.a not found at $RUST_LIB" + echo "ERROR: libacord_viewport.a not found at $RUST_LIB" >&2 exit 1 fi RUST_FLAGS=(-import-objc-header "$ROOT/viewport/include/acord.h" -L "$RUST_LIB" -lacord_viewport) -# --- Bundle structure --- mkdir -p "$MACOS" "$RESOURCES" cp "$ROOT/Info.plist" "$CONTENTS/Info.plist" -if [ -f "$BUILD/AppIcon.icns" ]; then - cp "$BUILD/AppIcon.icns" "$RESOURCES/AppIcon.icns" -fi +[ -f "$BUILD/AppIcon.icns" ] && cp "$BUILD/AppIcon.icns" "$RESOURCES/AppIcon.icns" -# --- Compile Swift (debug) --- echo "Compiling Swift (debug)..." swiftc \ -target arm64-apple-macosx14.0 \ @@ -55,7 +58,6 @@ swiftc \ codesign --force --sign - "$APP" -# --- Kill existing, launch in foreground so stderr lands here --- pkill -f "Acord.app/Contents/MacOS/Acord" 2>/dev/null || true sleep 0.3 diff --git a/scripts/macos/install.sh b/scripts/macos/install.sh new file mode 100755 index 0000000..009e3cc --- /dev/null +++ b/scripts/macos/install.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +case "$(uname -s)" in + Darwin) ;; + *) echo "wrong platform: $(uname -s) — use cargo xtask install" >&2; exit 1;; +esac + +DEST="/Applications/Acord.app" + +bash "$ROOT/scripts/macos/build.sh" + +# Kill running instance before replacing. +pkill -f "Acord.app/Contents/MacOS/Acord" 2>/dev/null || true +sleep 0.5 + +echo "Installing to $DEST..." +rm -rf "$DEST" +cp -R "$ROOT/build/bin/Acord.app" "$DEST" + +echo "Installed: $DEST" diff --git a/scripts/macos/package.sh b/scripts/macos/package.sh new file mode 100755 index 0000000..26fa1e6 --- /dev/null +++ b/scripts/macos/package.sh @@ -0,0 +1,287 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Cross-compile + zip distributables from a single macOS host. +# +# Six targets: +# macos-aarch64 macos-x86_64 +# windows-aarch64 windows-x86_64 +# linux-aarch64 linux-x86_64 +# +# Output: dist/acord-.zip per target. +# +# Tooling: +# - rustup, swiftc, zip, codesign — assumed present on a dev mac +# - rsvg-convert — brew install librsvg (for the macOS app icon) +# - zig + cargo-zigbuild — used for windows/linux cross-compile +# brew install zig +# cargo install cargo-zigbuild + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +case "$(uname -s)" in + Darwin) ;; + *) echo "package.sh: macOS host only (need swiftc + codesign)" >&2; exit 1;; +esac + +ALL_TARGETS=( + macos-aarch64 + macos-x86_64 + windows-aarch64 + windows-x86_64 + linux-aarch64 + linux-x86_64 +) + +usage() { + cat >&2 < [--target ...] + +targets: ${ALL_TARGETS[*]} +EOF + exit 2 +} + +TARGETS=() +while [ $# -gt 0 ]; do + case "$1" in + --all) TARGETS=("${ALL_TARGETS[@]}"); shift ;; + --target) [ $# -ge 2 ] || usage; TARGETS+=("$2"); shift 2 ;; + -h|--help) usage ;; + *) echo "unknown arg: $1" >&2; usage ;; + esac +done +[ ${#TARGETS[@]} -eq 0 ] && usage + +need() { command -v "$1" >/dev/null 2>&1 || { echo "ERROR: $1 not found. $2" >&2; exit 1; }; } + +need rustup "install rustup from https://rustup.rs" +need swiftc "install Xcode Command Line Tools (xcode-select --install)" +need zip "comes with macOS" + +NEEDS_ZIG=0 +for t in "${TARGETS[@]}"; do + case "$t" in windows-*|linux-*) NEEDS_ZIG=1 ;; esac +done +if [ $NEEDS_ZIG -eq 1 ]; then + need zig "brew install zig" + need cargo-zigbuild "cargo install cargo-zigbuild" +fi + +PKG="$ROOT/build/package" +DIST="$ROOT/dist" +mkdir -p "$PKG" "$DIST" + +# Generate a 256px PNG once, reused by Windows + Linux. macOS uses a separate +# .icns set generated below. +ICON_PNG="$ROOT/build/icon.png" +if [ ! -f "$ICON_PNG" ] || [ "$ROOT/assets/Acord.svg" -nt "$ICON_PNG" ]; then + if command -v rsvg-convert >/dev/null 2>&1 && [ -f "$ROOT/assets/Acord.svg" ]; then + rsvg-convert --width 256 --height 256 "$ROOT/assets/Acord.svg" -o "$ICON_PNG" + fi +fi + +ensure_icns() { + local icns="$ROOT/build/AppIcon.icns" + if [ -f "$icns" ] && [ "$ROOT/assets/Acord.svg" -ot "$icns" ]; then return; fi + [ -f "$ROOT/assets/Acord.svg" ] || return 0 + command -v rsvg-convert >/dev/null 2>&1 || return 0 + command -v iconutil >/dev/null 2>&1 || return 0 + + local iconset="$ROOT/build/AppIcon.iconset" + rm -rf "$iconset" + mkdir -p "$iconset" + for size in 16 32 64 128 256 512 1024; do + rsvg-convert --width="$size" --height="$size" \ + "$ROOT/assets/Acord.svg" -o "$iconset/icon_${size}.png" + done + cp "$iconset/icon_16.png" "$iconset/icon_16x16.png" + cp "$iconset/icon_32.png" "$iconset/icon_16x16@2x.png" + cp "$iconset/icon_32.png" "$iconset/icon_32x32.png" + cp "$iconset/icon_64.png" "$iconset/icon_32x32@2x.png" + cp "$iconset/icon_128.png" "$iconset/icon_128x128.png" + cp "$iconset/icon_256.png" "$iconset/icon_128x128@2x.png" + cp "$iconset/icon_256.png" "$iconset/icon_256x256.png" + cp "$iconset/icon_512.png" "$iconset/icon_256x256@2x.png" + cp "$iconset/icon_512.png" "$iconset/icon_512x512.png" + cp "$iconset/icon_1024.png" "$iconset/icon_512x512@2x.png" + rm -f "$iconset"/icon_16.png "$iconset"/icon_32.png "$iconset"/icon_64.png \ + "$iconset"/icon_128.png "$iconset"/icon_256.png "$iconset"/icon_512.png \ + "$iconset"/icon_1024.png + iconutil -c icns "$iconset" -o "$icns" + rm -rf "$iconset" +} + +zip_target() { + local target="$1" path="$2" + local out="$DIST/acord-${target}.zip" + rm -f "$out" + (cd "$(dirname "$path")" && zip -r -q "$out" "$(basename "$path")") + echo " → $out ($(du -h "$out" | cut -f1))" +} + +build_macos() { + local arch="$1" rust_target swift_target + case "$arch" in + aarch64) rust_target=aarch64-apple-darwin; swift_target=arm64-apple-macosx14.0 ;; + x86_64) rust_target=x86_64-apple-darwin; swift_target=x86_64-apple-macosx14.0 ;; + esac + + rustup target add "$rust_target" >/dev/null 2>&1 || true + ensure_icns + + echo "==> macOS $arch ($rust_target / $swift_target)" + + export MACOSX_DEPLOYMENT_TARGET=14.0 + export ZERO_AR_DATE=0 + cargo build --release -p acord-viewport --target "$rust_target" + + local rust_lib="$ROOT/target/$rust_target/release" + [ -f "$rust_lib/libacord_viewport.a" ] \ + || { echo "ERROR: libacord_viewport.a missing for $rust_target" >&2; exit 1; } + + local stage="$PKG/macos-${arch}" + local app="$stage/Acord.app" + rm -rf "$stage" + mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources" + cp "$ROOT/Info.plist" "$app/Contents/Info.plist" + [ -f "$ROOT/build/AppIcon.icns" ] && cp "$ROOT/build/AppIcon.icns" "$app/Contents/Resources/AppIcon.icns" + + local sdk + sdk=$(xcrun --show-sdk-path) + swiftc \ + -target "$swift_target" \ + -sdk "$sdk" \ + -import-objc-header "$ROOT/viewport/include/acord.h" \ + -L "$rust_lib" -lacord_viewport \ + -framework Cocoa -framework SwiftUI \ + -framework Metal -framework MetalKit \ + -framework QuartzCore -framework CoreGraphics -framework CoreFoundation \ + -O \ + -o "$app/Contents/MacOS/Acord" \ + "$ROOT"/src/*.swift + + codesign --force --sign - "$app" + zip_target "macos-${arch}" "$app" +} + +build_windows() { + local arch="$1" rust_target + case "$arch" in + aarch64) rust_target=aarch64-pc-windows-msvc ;; + x86_64) rust_target=x86_64-pc-windows-msvc ;; + esac + + rustup target add "$rust_target" >/dev/null 2>&1 || true + + echo "==> Windows $arch ($rust_target via cargo-zigbuild)" + + cargo zigbuild --release -p acord-windows --target "$rust_target" + + local stage="$PKG/windows-${arch}/Acord" + rm -rf "$stage" + mkdir -p "$stage" + + cp "$ROOT/target/$rust_target/release/acord.exe" "$stage/Acord.exe" + [ -f "$ICON_PNG" ] && cp "$ICON_PNG" "$stage/icon.png" + [ -f "$ROOT/LICENCE" ] && cp "$ROOT/LICENCE" "$stage/LICENCE" + [ -f "$ROOT/README.md" ] && cp "$ROOT/README.md" "$stage/README.md" + + zip_target "windows-${arch}" "$stage" +} + +build_linux() { + local arch="$1" rust_target + case "$arch" in + aarch64) rust_target=aarch64-unknown-linux-gnu ;; + x86_64) rust_target=x86_64-unknown-linux-gnu ;; + esac + + rustup target add "$rust_target" >/dev/null 2>&1 || true + + echo "==> Linux $arch (${rust_target}.2.17 via cargo-zigbuild — both x11+wayland linked)" + + # The .2.17 suffix targets glibc 2.17 (CentOS 7 baseline) for max distro + # compatibility. zigbuild handles the symbol versioning via zig cc. + cargo zigbuild --release -p acord-linux --target "${rust_target}.2.17" + + local stage="$PKG/linux-${arch}/acord" + rm -rf "$stage" + mkdir -p "$stage" + + cp "$ROOT/target/$rust_target/release/acord" "$stage/Acord" + chmod +x "$stage/Acord" + [ -f "$ICON_PNG" ] && cp "$ICON_PNG" "$stage/icon.png" + [ -f "$ROOT/LICENCE" ] && cp "$ROOT/LICENCE" "$stage/LICENCE" + [ -f "$ROOT/README.md" ] && cp "$ROOT/README.md" "$stage/README.md" + + # Self-contained installer the user runs after unzipping. Outer EOF is + # single-quoted so $BIN_DIR / $HERE / etc. survive into the install + # script verbatim and expand only when the user runs it. + cat > "$stage/install.sh" <<'INSTALLER_EOF' +#!/usr/bin/env bash +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" + +BIN_DIR="${XDG_BIN_HOME:-$HOME/.local/bin}" +APP_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/applications" +ICON_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor/256x256/apps" +mkdir -p "$BIN_DIR" "$APP_DIR" "$ICON_DIR" + +pkill -x Acord 2>/dev/null || true +sleep 0.2 + +install -m 755 "$HERE/Acord" "$BIN_DIR/Acord" +[ -f "$HERE/icon.png" ] && install -m 644 "$HERE/icon.png" "$ICON_DIR/acord.png" + +cat > "$APP_DIR/acord.desktop" </dev/null 2>&1; then + update-desktop-database "$APP_DIR" >/dev/null 2>&1 || true +fi + +echo "Installed:" +echo " binary → $BIN_DIR/Acord" +echo " icon → $ICON_DIR/acord.png" +echo " desktop → $APP_DIR/acord.desktop" + +case ":$PATH:" in + *":$BIN_DIR:"*) ;; + *) echo "note: $BIN_DIR is not on your PATH" >&2 ;; +esac +INSTALLER_EOF + chmod +x "$stage/install.sh" + + zip_target "linux-${arch}" "$stage" +} + +echo "packaging: ${TARGETS[*]}" +echo + +for t in "${TARGETS[@]}"; do + case "$t" in + macos-aarch64) build_macos aarch64 ;; + macos-x86_64) build_macos x86_64 ;; + windows-aarch64) build_windows aarch64 ;; + windows-x86_64) build_windows x86_64 ;; + linux-aarch64) build_linux aarch64 ;; + linux-x86_64) build_linux x86_64 ;; + *) echo "unknown target: $t (valid: ${ALL_TARGETS[*]})" >&2; exit 2 ;; + esac +done + +echo +echo "done. dist:" +ls -lh "$DIST" diff --git a/windows/build_universal.ps1 b/scripts/windows/build-universal.ps1 similarity index 83% rename from windows/build_universal.ps1 rename to scripts/windows/build-universal.ps1 index c5c2983..b12a9c9 100644 --- a/windows/build_universal.ps1 +++ b/scripts/windows/build-universal.ps1 @@ -1,27 +1,23 @@ $ErrorActionPreference = "Stop" -$root = Split-Path -Parent $PSScriptRoot + +$root = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) Set-Location $root $buildDir = Join-Path $root "build\bin" -New-Item -ItemType Directory -Force -Path $buildDir +New-Item -ItemType Directory -Force -Path $buildDir | Out-Null -# 1. Add and Build Rust Targets Write-Host "Building Rust architectures..." rustup target add x86_64-pc-windows-msvc aarch64-pc-windows-msvc -# Build x64 cargo build --release -p acord-windows --target x86_64-pc-windows-msvc -# Build ARM64 cargo build --release -p acord-windows --target aarch64-pc-windows-msvc -# 2. Organize Binaries $exeX64 = Join-Path $root "target\x86_64-pc-windows-msvc\release\acord.exe" $exeArm64 = Join-Path $root "target\aarch64-pc-windows-msvc\release\acord.exe" Copy-Item $exeX64 -Destination (Join-Path $buildDir "acord_x64.exe") Copy-Item $exeArm64 -Destination (Join-Path $buildDir "acord_arm64.exe") -# 3. Handle Icons (SVG to PNG) $svg = Join-Path $root "assets\Acord.svg" $png = Join-Path $buildDir "icon.png" @@ -35,4 +31,3 @@ if (Test-Path $svg) { } Write-Host "Built binaries to: $buildDir" - diff --git a/windows/build.ps1 b/scripts/windows/build.ps1 similarity index 93% rename from windows/build.ps1 rename to scripts/windows/build.ps1 index a7a2c94..0b8b9ee 100644 --- a/windows/build.ps1 +++ b/scripts/windows/build.ps1 @@ -1,6 +1,6 @@ $ErrorActionPreference = "Stop" -$root = Split-Path -Parent $PSScriptRoot +$root = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) Set-Location $root Write-Host "Building Rust workspace (release)..." diff --git a/windows/debug.ps1 b/scripts/windows/debug.ps1 similarity index 93% rename from windows/debug.ps1 rename to scripts/windows/debug.ps1 index 0cb51ad..63aaa36 100644 --- a/windows/debug.ps1 +++ b/scripts/windows/debug.ps1 @@ -1,6 +1,6 @@ $ErrorActionPreference = "Stop" -$root = Split-Path -Parent $PSScriptRoot +$root = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) Set-Location $root Write-Host "Building Rust workspace (debug)..." diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml new file mode 100644 index 0000000..f706b5b --- /dev/null +++ b/xtask/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "xtask" +version = "0.0.0" +edition = "2021" +publish = false + +[[bin]] +name = "xtask" +path = "src/main.rs" diff --git a/xtask/src/main.rs b/xtask/src/main.rs new file mode 100644 index 0000000..3cdd939 --- /dev/null +++ b/xtask/src/main.rs @@ -0,0 +1,116 @@ +use std::env; +use std::path::PathBuf; +use std::process::{Command, ExitCode}; + +const KNOWN_PLATFORMS: &[&str] = &["macos", "windows", "linux"]; + +fn main() -> ExitCode { + let args: Vec = env::args().skip(1).collect(); + let cmd = args.first().map(String::as_str).unwrap_or(""); + + if cmd.is_empty() || cmd == "help" || cmd == "--help" || cmd == "-h" { + print_help(); + return ExitCode::from(2); + } + + let extra_args: Vec<&String> = args.iter().skip(1).collect(); + let (action, platform) = parse(cmd); + + let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("xtask manifest must have a parent") + .to_path_buf(); + + let (script, runner) = match platform.as_str() { + "windows" => ( + repo_root.join(format!("scripts/windows/{action}.ps1")), + vec![ + "powershell", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + ], + ), + "linux" | "macos" => ( + repo_root.join(format!("scripts/{platform}/{action}.sh")), + vec!["bash"], + ), + other => { + eprintln!("unknown platform: {other}"); + return ExitCode::from(2); + } + }; + + if !script.exists() { + eprintln!("script not found: {}", script.display()); + return ExitCode::from(1); + } + + let extra_display = if extra_args.is_empty() { + String::new() + } else { + format!( + " {}", + extra_args.iter().map(|s| s.as_str()).collect::>().join(" "), + ) + }; + eprintln!("→ {} {}{}", runner.join(" "), script.display(), extra_display); + + let mut command = Command::new(runner[0]); + for arg in &runner[1..] { + command.arg(arg); + } + command.arg(&script); + for a in &extra_args { + command.arg(a.as_str()); + } + command.current_dir(&repo_root); + + match command.status() { + Ok(status) if status.success() => ExitCode::SUCCESS, + Ok(status) => ExitCode::from(status.code().unwrap_or(1) as u8), + Err(e) => { + eprintln!("failed to run {}: {e}", script.display()); + ExitCode::from(1) + } + } +} + +fn parse(cmd: &str) -> (String, String) { + if let Some(idx) = cmd.rfind('-') { + let suffix = &cmd[idx + 1..]; + if KNOWN_PLATFORMS.contains(&suffix) { + return (cmd[..idx].to_string(), suffix.to_string()); + } + } + (cmd.to_string(), current_platform().to_string()) +} + +fn current_platform() -> &'static str { + match env::consts::OS { + "linux" => "linux", + "macos" => "macos", + "windows" => "windows", + other => { + eprintln!("unsupported OS: {other}"); + std::process::exit(2); + } + } +} + +fn print_help() { + eprintln!("usage: cargo xtask "); + eprintln!(); + eprintln!("commands:"); + eprintln!(" build release build for the current platform"); + eprintln!(" install release build + install (macOS: /Applications)"); + eprintln!(" debug debug build + foreground launch"); + eprintln!(" build-universal universal binary for the current platform"); + eprintln!(" package cross-compile + zip distributables"); + eprintln!(" --all all six targets"); + eprintln!(" --target e.g. macos-aarch64, windows-x86_64"); + eprintln!(); + eprintln!("append -macos / -windows / -linux to any command to force a platform."); + eprintln!(" e.g. cargo xtask build-universal-macos"); +}