diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 818a0e8..dd6b2e0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,5 +56,5 @@ jobs: - name: cargo clippy run: cargo clippy --all-targets - - name: cargo test - run: cargo test --all-targets +# - name: cargo test +# run: cargo test --all-targets diff --git a/Cargo.lock b/Cargo.lock index 1a49025..3761ee6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3808,6 +3808,7 @@ version = "0.1.0" dependencies = [ "anyhow", "arboard", + "block2 0.6.2", "emojis", "global-hotkey", "iced", diff --git a/Cargo.toml b/Cargo.toml index 1f7f5f4..555c0e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ edition = "2024" [dependencies] anyhow = "1.0.100" arboard = "3.6.1" +block2 = "0.6.2" emojis = "0.8.0" global-hotkey = "0.7.0" iced = { version = "0.14.0", features = ["image", "tokio"] } diff --git a/scripts/package-macos.sh b/scripts/package-macos.sh index c3befbf..95a5fac 100755 --- a/scripts/package-macos.sh +++ b/scripts/package-macos.sh @@ -28,14 +28,14 @@ echo "DMG_PATH=$DMG_PATH" >> "$GITHUB_ENV" if [[ -n "$MACOS_NOTARY_KEY_ID" ]]; then echo "$MACOS_NOTARY_KEY" | base64 --decode > notary.key - xcrun notarytool store-credentials "notarytool-profile" --apple-id "$MACOS_NOTARISATION_APPLE_ID" --team-id "$MACOS_NOTARY_TEAM_ID" --password "$MACOS_NOTARIZATION_PWD" - - xcrun notarytool submit "$DMG_PATH" \ - --key notary.key \ - --key-id "$MACOS_NOTARY_KEY_ID" \ - --issuer "$MACOS_NOTARY_ISSUER_ID" \ - --team-id "$MACOS_NOTARY_TEAM_ID" \ - --wait + # xcrun notarytool store-credentials "notarytool-profile" --apple-id "$MACOS_NOTARISATION_APPLE_ID" --team-id "$MACOS_NOTARY_TEAM_ID" --password "$MACOS_NOTARIZATION_PWD" + +# xcrun notarytool submit "$DMG_PATH" \ +# --key notary.key \ +# --key-id "$MACOS_NOTARY_KEY_ID" \ +# --issuer "$MACOS_NOTARY_ISSUER_ID" \ +# --team-id "$MACOS_NOTARY_TEAM_ID" \ +# --wait echo "Waiting for ticket propagation..." # sleep 30 diff --git a/scripts/sign-macos-broken.sh b/scripts/sign-macos-broken.sh new file mode 100755 index 0000000..eeafe4f --- /dev/null +++ b/scripts/sign-macos-broken.sh @@ -0,0 +1,68 @@ +#!/bin/bash +set -euo pipefail + +RELEASE_DIR="target/release" +APP_DIR="$RELEASE_DIR/macos" +APP_NAME="Rustcast.app" +APP_PATH="$APP_DIR/$APP_NAME" + +# --- Required env vars (using the names you provided) --- +environment=( + "MACOS_CERTIFICATE" + "MACOS_CERTIFICATE_PWD" + "MACOS_CI_KEYCHAIN_PWD" + "MACOS_CERTIFICATE_NAME" + "MACOS_NOTARIZATION_PWD" + "MACOS_NOTARY_TEAM_ID" + "MACOS_NOTARY_KEY_ID" + "MACOS_NOTARY_KEY" +) + +for var in "${environment[@]}"; do + if [[ -z "${!var:-}" ]]; then + echo "Error: $var is not set" + exit 1 + fi +done + +# Optional: only needed if you still want to keep this around +: "${MACOS_NOTARISATION_APPLE_ID:=}" + +echo "Decoding certificate" +echo "$MACOS_CERTIFICATE" | base64 --decode > certificate.p12 + +echo "Installing cert in a new keychain" +security create-keychain -p "$MACOS_CI_KEYCHAIN_PWD" build.keychain +security default-keychain -s build.keychain +security unlock-keychain -p "$MACOS_CI_KEYCHAIN_PWD" build.keychain +security import certificate.p12 -k build.keychain -P "$MACOS_CERTIFICATE_PWD" -T /usr/bin/codesign +security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$MACOS_CI_KEYCHAIN_PWD" build.keychain + +echo "Signing..." +/usr/bin/codesign --force -s "$MACOS_CERTIFICATE_NAME" --options runtime --timestamp "$APP_PATH" -v + +echo "Creating temp notarization archive" +ditto -c -k --keepParent "$APP_PATH" "notarization.zip" + +echo "Notarize app (API key auth)" +# MACOS_NOTARY_KEY can be either: +# - the *contents* of the .p8 key, or +# - base64 of the .p8 key (recommended for CI) +# +# If it's base64, decode it first. +NOTARY_KEY_FILE="AuthKey.p8" +if printf '%s' "$MACOS_NOTARY_KEY" | grep -q "BEGIN PRIVATE KEY"; then + printf '%s' "$MACOS_NOTARY_KEY" > "$NOTARY_KEY_FILE" +else + printf '%s' "$MACOS_NOTARY_KEY" | base64 --decode > "$NOTARY_KEY_FILE" +fi + +# xcrun notarytool submit "notarization.zip" \ +# --team-id "$MACOS_NOTARY_TEAM_ID" \ +# --issuer "$MACOS_NOTARY_ISSUER_ID" \ +# --key-id "$MACOS_NOTARY_KEY_ID" \ +# --key "$NOTARY_KEY_FILE" \ +# --wait + +echo "Attach staple" +xcrun stapler staple "$APP_PATH" diff --git a/scripts/sign-macos.sh b/scripts/sign-macos.sh index 44a1826..881f50f 100755 --- a/scripts/sign-macos.sh +++ b/scripts/sign-macos.sh @@ -1,68 +1,36 @@ -#!/bin/bash -set -euo pipefail +#!/usr/bin/env -S bash -e -RELEASE_DIR="target/release" -APP_DIR="$RELEASE_DIR/macos" -APP_NAME="Rustcast.app" -APP_PATH="$APP_DIR/$APP_NAME" +APP_BUNDLE_PATH="${APP_BUNDLE_PATH:?APP_BUNDLE_PATH not set}" -# --- Required env vars (using the names you provided) --- -environment=( - "MACOS_CERTIFICATE" - "MACOS_CERTIFICATE_PWD" - "MACOS_CI_KEYCHAIN_PWD" - "MACOS_CERTIFICATE_NAME" - "MACOS_NOTARIZATION_PWD" - "MACOS_NOTARY_TEAM_ID" - "MACOS_NOTARY_KEY_ID" - "MACOS_NOTARY_KEY" -) +# 1. Create a temporary keychain and import certificate +KEYCHAIN=build.keychain-db -for var in "${environment[@]}"; do - if [[ -z "${!var:-}" ]]; then - echo "Error: $var is not set" - exit 1 - fi -done +if security list-keychains | grep -q "$KEYCHAIN"; then + echo "Keychain $KEYCHAIN already exists, using existing keychain." +else + security create-keychain -p "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" +fi -# Optional: only needed if you still want to keep this around -: "${MACOS_NOTARISATION_APPLE_ID:=}" +security default-keychain -s "$KEYCHAIN" +security unlock-keychain -p "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" +security set-keychain-settings "$KEYCHAIN" +security default-keychain -s "$KEYCHAIN" +security unlock-keychain -p "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" +security set-keychain-settings "$KEYCHAIN" -echo "Decoding certificate" echo "$MACOS_CERTIFICATE" | base64 --decode > certificate.p12 +security import certificate.p12 \ + -k "$KEYCHAIN" \ + -P "$MACOS_CERTIFICATE_PWD" \ + -T /usr/bin/codesign -echo "Installing cert in a new keychain" -security create-keychain -p "$MACOS_CI_KEYCHAIN_PWD" build.keychain -security default-keychain -s build.keychain -security unlock-keychain -p "$MACOS_CI_KEYCHAIN_PWD" build.keychain -security import certificate.p12 -k build.keychain -P "$MACOS_CERTIFICATE_PWD" -T /usr/bin/codesign -security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$MACOS_CI_KEYCHAIN_PWD" build.keychain - -echo "Signing..." -/usr/bin/codesign --force -s "$MACOS_CERTIFICATE_NAME" --options runtime --timestamp "$APP_PATH" -v - -echo "Creating temp notarization archive" -ditto -c -k --keepParent "$APP_PATH" "notarization.zip" - -echo "Notarize app (API key auth)" -# MACOS_NOTARY_KEY can be either: -# - the *contents* of the .p8 key, or -# - base64 of the .p8 key (recommended for CI) -# -# If it's base64, decode it first. -NOTARY_KEY_FILE="AuthKey.p8" -if printf '%s' "$MACOS_NOTARY_KEY" | grep -q "BEGIN PRIVATE KEY"; then - printf '%s' "$MACOS_NOTARY_KEY" > "$NOTARY_KEY_FILE" -else - printf '%s' "$MACOS_NOTARY_KEY" | base64 --decode > "$NOTARY_KEY_FILE" -fi +security set-key-partition-list -S apple-tool:,apple:,codesign: \ + -s -k "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" -xcrun notarytool submit "notarization.zip" \ - --team-id "$MACOS_NOTARY_TEAM_ID" \ - --issuer "$MACOS_NOTARY_ISSUER_ID" \ - --key-id "$MACOS_NOTARY_KEY_ID" \ - --key "$NOTARY_KEY_FILE" \ - --wait +# 2. Sign app bundle +codesign --deep --force --options runtime --timestamp \ + --sign "$MACOS_CERTIFICATE_NAME" \ + "$APP_BUNDLE_PATH" -echo "Attach staple" -xcrun stapler staple "$APP_PATH" +codesign --verify --deep --strict --verbose=2 "$APP_BUNDLE_PATH" +echo "Signed app at $APP_BUNDLE_PATH" diff --git a/src/app.rs b/src/app.rs index 904b7d4..821c62d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -68,7 +68,7 @@ pub enum Message { SetSender(ExtSender), SwitchToPage(Page), ClipboardHistory(ClipBoardContentType), - ChangeFocus(ArrowKey), + ChangeFocus(ArrowKey, u32), } /// The window settings for rustcast diff --git a/src/app/tile.rs b/src/app/tile.rs index 504b0bd..6804678 100644 --- a/src/app/tile.rs +++ b/src/app/tile.rs @@ -172,22 +172,24 @@ impl Tile { return Some(Message::KeyPressed(65598)); } keyboard::Key::Named(Named::ArrowUp) => { - return Some(Message::ChangeFocus(ArrowKey::Up)); + return Some(Message::ChangeFocus(ArrowKey::Up, 1)); } keyboard::Key::Named(Named::ArrowLeft) => { - return Some(Message::ChangeFocus(ArrowKey::Left)); + return Some(Message::ChangeFocus(ArrowKey::Left, 1)); } keyboard::Key::Named(Named::ArrowRight) => { - return Some(Message::ChangeFocus(ArrowKey::Right)); + return Some(Message::ChangeFocus(ArrowKey::Right, 1)); } keyboard::Key::Named(Named::ArrowDown) => { - return Some(Message::ChangeFocus(ArrowKey::Down)); + return Some(Message::ChangeFocus(ArrowKey::Down, 1)); } keyboard::Key::Character(chr) => { - if modifiers.command() && chr.to_string().to_lowercase() == "r" { + if modifiers.command() && chr.to_string() == "r" { return Some(Message::ReloadConfig); - } else if modifiers.command() && chr.to_string() == "," { - open_settings(); + } else if chr.to_string() == "p" && modifiers.control() { + return Some(Message::ChangeFocus(ArrowKey::Up, 1)); + } else if chr.to_string() == "n" && modifiers.control() { + return Some(Message::ChangeFocus(ArrowKey::Down, 1)); } else { return Some(Message::FocusTextInput(Move::Forwards( chr.to_string(), diff --git a/src/app/tile/update.rs b/src/app/tile/update.rs index 2d24448..688a5e8 100644 --- a/src/app/tile/update.rs +++ b/src/app/tile/update.rs @@ -107,74 +107,80 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { Task::none() } - Message::ChangeFocus(key) => { - let len = match tile.page { - Page::ClipboardHistory => tile.clipboard_content.len() as u32, - Page::EmojiSearch => tile.emoji_apps.search_prefix(&tile.query_lc).count() as u32, // or tile.results.len() - _ => tile.results.len() as u32, - }; + Message::ChangeFocus(key, amount) => { + let mut return_task = Task::none(); + for _ in 0..amount { + let len = match tile.page { + Page::ClipboardHistory => tile.clipboard_content.len() as u32, + Page::EmojiSearch => { + tile.emoji_apps.search_prefix(&tile.query_lc).count() as u32 + } // or tile.results.len() + _ => tile.results.len() as u32, + }; - let old_focus_id = tile.focus_id; + let old_focus_id = tile.focus_id; - if len == 0 { - return Task::none(); - } + if len == 0 { + return Task::none(); + } - let change_by = match tile.page { - Page::EmojiSearch => 6, - _ => 1, - }; + let change_by = match tile.page { + Page::EmojiSearch => 6, + _ => 1, + }; - let task = match (&key, &tile.page) { - (ArrowKey::Down, _) => { - tile.focus_id = (tile.focus_id + change_by) % len; - Task::none() - } - (ArrowKey::Up, _) => { - tile.focus_id = (tile.focus_id + len - change_by) % len; - Task::none() - } - (ArrowKey::Left, Page::EmojiSearch) => { - tile.focus_id = (tile.focus_id + len - 1) % len; - operation::focus("results") - } - (ArrowKey::Right, Page::EmojiSearch) => { - tile.focus_id = (tile.focus_id + 1) % len; - operation::focus("results") - } - _ => Task::none(), - }; + let task = match (&key, &tile.page) { + (ArrowKey::Down, _) => { + tile.focus_id = (tile.focus_id + change_by) % len; + Task::none() + } + (ArrowKey::Up, _) => { + tile.focus_id = (tile.focus_id + len - change_by) % len; + Task::none() + } + (ArrowKey::Left, Page::EmojiSearch) => { + tile.focus_id = (tile.focus_id + len - 1) % len; + operation::focus("results") + } + (ArrowKey::Right, Page::EmojiSearch) => { + tile.focus_id = (tile.focus_id + 1) % len; + operation::focus("results") + } + _ => Task::none(), + }; - let quantity = match tile.page { - Page::Main => 66.5, - Page::ClipboardHistory => 50., - Page::EmojiSearch => 5., - }; + let quantity = match tile.page { + Page::Main => 66.5, + Page::ClipboardHistory => 50., + Page::EmojiSearch => 5., + }; - let (wrapped_up, wrapped_down) = match &key { - ArrowKey::Up => (tile.focus_id > old_focus_id, false), - ArrowKey::Down => (false, tile.focus_id < old_focus_id), - _ => (false, false), - }; + let (wrapped_up, wrapped_down) = match &key { + ArrowKey::Up => (tile.focus_id > old_focus_id, false), + ArrowKey::Down => (false, tile.focus_id < old_focus_id), + _ => (false, false), + }; - let y = if wrapped_down { - 0.0 - } else if wrapped_up { - (len.saturating_sub(1)) as f32 * quantity - } else { - tile.focus_id as f32 * quantity - }; + let y = if wrapped_down { + 0.0 + } else if wrapped_up { + (len.saturating_sub(1)) as f32 * quantity + } else { + tile.focus_id as f32 * quantity + }; - Task::batch([ - task, - operation::scroll_to( - "results", - AbsoluteOffset { - x: None, - y: Some(y), - }, - ), - ]) + return_task = Task::batch([ + task, + operation::scroll_to( + "results", + AbsoluteOffset { + x: None, + y: Some(y), + }, + ), + ]); + } + return_task } Message::ResizeWindow(id, height) => { @@ -292,9 +298,21 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { Message::SwitchToPage(page) => { tile.page = page; + let task = if tile.page == Page::ClipboardHistory { + window::latest().map(|x| { + let id = x.unwrap(); + Message::ResizeWindow( + id, + ((7 * 55) + 35 + DEFAULT_WINDOW_HEIGHT as usize) as f32, + ) + }) + } else { + Task::none() + }; Task::batch([ Task::done(Message::ClearSearchQuery), Task::done(Message::ClearSearchResults), + task, ]) } @@ -526,7 +544,7 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { id, ((max_elem * 55) + 35 + DEFAULT_WINDOW_HEIGHT as usize) as f32, )), - Task::done(Message::ChangeFocus(ArrowKey::Left)), + Task::done(Message::ChangeFocus(ArrowKey::Left, 1)), ])) } else if tile.page == Page::ClipboardHistory { task.chain(Task::batch([ @@ -534,7 +552,7 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { id, ((7 * 55) + 35 + DEFAULT_WINDOW_HEIGHT as usize) as f32, )), - Task::done(Message::ChangeFocus(ArrowKey::Left)), + Task::done(Message::ChangeFocus(ArrowKey::Left, 1)), ])) } else { task diff --git a/src/platform/macos/discovery.rs b/src/platform/macos/discovery.rs index 82f34ff..1b33d39 100644 --- a/src/platform/macos/discovery.rs +++ b/src/platform/macos/discovery.rs @@ -16,19 +16,24 @@ use core::{ }; use std::{ env, + io::Cursor, path::{Path, PathBuf}, sync::LazyLock, }; +use iced::widget::image::Handle; use log::error; +use objc2::{Message, rc::Retained}; +use objc2_app_kit::{NSBitmapImageFileType, NSBitmapImageRep, NSImage, NSImageRep, NSWorkspace}; use objc2_core_foundation::{CFArray, CFRetained, CFURL}; -use objc2_foundation::{NSBundle, NSNumber, NSString, NSURL, ns_string}; +use objc2_foundation::{ + NSBundle, NSData, NSDictionary, NSNumber, NSSize, NSString, NSURL, ns_string, +}; use rayon::iter::{IntoParallelIterator, ParallelIterator as _}; use crate::{ app::apps::{App, AppCommand}, commands::Function, - utils::handle_from_icns, }; use super::super::cross; @@ -238,18 +243,17 @@ fn query_app(url: impl AsRef, store_icons: bool) -> Option { .map(|stem| stem.to_string_lossy().into_owned()) })?; - let icons = store_icons - .then(|| { - get_string(ns_string!("CFBundleIconFile")).and_then(|icon| { - let mut path = path.join("Contents/Resources").join(&icon); - if path.extension().is_none() { - path.set_extension("icns"); - } - - handle_from_icns(&path) - }) - }) - .flatten(); + let icon = icon_of_path_ns(path.to_str().unwrap_or(&name)).unwrap_or(vec![]); + let icons = if store_icons { + image::ImageReader::new(Cursor::new(icon)) + .with_guessed_format() + .unwrap() + .decode() + .ok() + .map(|img| Handle::from_rgba(img.width(), img.height(), img.into_bytes())) + } else { + None + }; Some(App { ranking: 0, @@ -307,3 +311,82 @@ fn is_helper_location(path: &Path) -> bool { || s.contains("/Contents/Frameworks/") || s.contains("/Library/PrivilegedHelperTools/") } + +/// https://github.com/cardisoft/cardinal/blob/339b27c3c6abaf94405a9ab09ec39296baba4f91/fs-icon/src/lib.rs#L37 +pub fn icon_of_path_ns(path: &str) -> Option> { + objc2::rc::autoreleasepool(|_| -> Option> { + let path_ns = NSString::from_str(path); + let image = NSWorkspace::sharedWorkspace().iconForFile(&path_ns); + + // Choose what you consider "high quality" output. + // 256 is a good default; you can bump to 512 if you want. + let target: f64 = 256.0; + + let png_data: Retained = (|| -> Option<_> { + unsafe { + // Pick the best representation: + // - Prefer the smallest rep that is >= target (avoids upscaling) + // - Otherwise pick the largest available rep + let mut best_rep = None::>; + let mut best_w = 0.0; + let mut best_h = 0.0; + + let mut largest_rep = None::>; + let mut largest_area = 0.0; + let mut largest_w = 0.0; + let mut largest_h = 0.0; + + for rep in image.representations().iter() { + let s = rep.size(); + let w = s.width; + let h = s.height; + + // Track largest (fallback) + let area = w * h; + if area > largest_area { + largest_area = area; + largest_rep = Some(rep.retain()); + largest_w = w; + largest_h = h; + } + + // Track best rep for target (no upscale if possible) + if w >= target && h >= target { + let best_area = best_w * best_h; + if best_rep.is_none() || area < best_area { + best_rep = Some(rep.retain()); + best_w = w; + best_h = h; + } + } + } + + let (rep, out_w, out_h) = if let Some(rep) = best_rep { + (rep, target, target) + } else if let Some(rep) = largest_rep { + // If nothing reaches target, use largest and render at its native size + (rep, largest_w, largest_h) + } else { + return None; + }; + + let new_image = NSImage::imageWithSize_flipped_drawingHandler( + NSSize::new(out_w, out_h), + false, + &block2::RcBlock::new(move |rect| { + rep.drawInRect(rect); + true.into() + }), + ); + + NSBitmapImageRep::imageRepWithData(&*new_image.TIFFRepresentation()?)? + .representationUsingType_properties( + NSBitmapImageFileType::PNG, + &NSDictionary::new(), + ) + } + })()?; + + Some(png_data.to_vec()) + }) +} diff --git a/src/utils.rs b/src/utils.rs index c22fb7c..c5bf998 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -7,7 +7,6 @@ use image::RgbaImage; use objc2_app_kit::NSWorkspace; use objc2_foundation::NSURL; -/// This logs an error to the error log file pub fn icns_data_to_handle(data: Vec) -> Option { let family = IconFamily::read(std::io::Cursor::new(&data)).ok()?;