From d45332c23f0a528ca2282965a59176de03d18a1d Mon Sep 17 00:00:00 2001 From: Bearice Ren Date: Wed, 9 Jul 2025 01:27:58 +0900 Subject: [PATCH 1/3] Add comprehensive macOS support with platform abstraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit adds full macOS support to RustCat while maintaining Windows compatibility through a clean platform abstraction layer. ## Key Features Added: ### macOS Platform Support - Full macOS system integration with NSApplication - Activity Monitor integration (equivalent to Windows Task Manager) - macOS theme detection and settings persistence via defaults - LaunchAgent integration for startup behavior - Native macOS CPU usage monitoring via Mach kernel APIs ### Platform Abstraction Layer - Created comprehensive trait-based platform abstraction in `src/platform/` - Abstracted CPU monitoring, settings management, and system integration - Eliminated code duplication between Windows and macOS implementations - Simplified CPU usage calculation to return percentage directly ### CI/CD Improvements - Added universal macOS app bundle creation (Intel + Apple Silicon) - Automated DMG creation for professional macOS distribution - App icon generation from existing assets - Multi-architecture build support with proper artifact management - Universal binaries provide optimal performance on both Intel and Apple Silicon ### Code Organization - Removed obsolete files (`main_macos.rs`, `windows_api.rs`, `cpu_usage.rs`, `settings.rs`) - Consolidated platform-specific code into organized modules - Simplified event handling and menu system - Removed mnemonic support for cleaner cross-platform menus ### Distribution Improvements - Windows: `rust_cat.exe` binary - macOS: Universal DMG with drag-and-drop installation - Proper app bundle structure with Info.plist and app icon - Simplified distribution with single universal binary for macOS This architecture makes adding new platforms straightforward while maintaining clean separation of concerns and optimal performance across all supported systems. πŸ€– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .DS_Store | Bin 0 -> 6148 bytes .github/workflows/rust.yaml | 142 +++++++- CLAUDE.md | 23 +- Cargo.lock | 299 ++++++++++++++- Cargo.toml | 18 +- Info.plist | 34 ++ build.rs | 374 ++++++++++--------- build_app_icon.sh | 34 ++ create_dmg.sh | 36 ++ src/app.rs | 402 ++++++++++----------- src/cpu_usage.rs | 36 -- src/events.rs | 134 +++---- src/icon_manager.rs | 379 ++++++++++--------- src/main.rs | 89 ++--- src/platform/macos/app.rs | 46 +++ src/platform/macos/cpu_usage.rs | 78 ++++ src/platform/macos/mod.rs | 8 + src/platform/macos/settings.rs | 140 +++++++ src/platform/macos/system_integration.rs | 26 ++ src/platform/mod.rs | 46 +++ src/platform/windows/app.rs | 30 ++ src/platform/windows/cpu_usage.rs | 59 +++ src/platform/windows/mod.rs | 8 + src/platform/windows/settings.rs | 167 +++++++++ src/platform/windows/system_integration.rs | 36 ++ src/settings.rs | 171 --------- src/windows_api.rs | 52 --- 27 files changed, 1903 insertions(+), 964 deletions(-) create mode 100644 .DS_Store create mode 100644 Info.plist create mode 100755 build_app_icon.sh create mode 100755 create_dmg.sh delete mode 100644 src/cpu_usage.rs create mode 100644 src/platform/macos/app.rs create mode 100644 src/platform/macos/cpu_usage.rs create mode 100644 src/platform/macos/mod.rs create mode 100644 src/platform/macos/settings.rs create mode 100644 src/platform/macos/system_integration.rs create mode 100644 src/platform/mod.rs create mode 100644 src/platform/windows/app.rs create mode 100644 src/platform/windows/cpu_usage.rs create mode 100644 src/platform/windows/mod.rs create mode 100644 src/platform/windows/settings.rs create mode 100644 src/platform/windows/system_integration.rs delete mode 100644 src/settings.rs delete mode 100644 src/windows_api.rs diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..9687fc5087d617626227c224738dd0bbd1dd9dd3 GIT binary patch literal 6148 zcmeHK&2G~`5S~p#>L>!`01^@xOI+I$2au?GF=_e&HKK>M3O0^fgX6Vgha93veu4Id z=)Jcd5s$!;12-;w`_rmXC=ybIP&H%Ce7mzVYwu@k*F!|2H;Fx>HW4{cg|#NkZ$ynN zU6PvhG=M_SQBo02i#$qYv=q&b|B(Up?iv(Qfon*M@9%Szr%6$EI$uO>rGBrm3Tdq# z2FG$5lwmn3df|9q_1;LCMwjg{dYcTVgZ9I1nU!IZ4aYhmiH8U|*h{ilPJ42a#f8od z%s>+|Xg`_F);;f4XU+9qKJU)g+}UOqofn(k`Ml{odirc@cknSCW%9&uDe&28xo`0j zPSIFc)~8^cr7}B$U*)uDmnK+pN!wU_NgJ>^<>`?ZyqBX~PvcyCy*Y9u`+fVFJKuhM z_<8o={Pmxr-Co5&&3-TMOE%mQXpaMc-m&5F-#_Qh_E_*b+mSbogBx=Uc1{nsgGj_z?Ea!d56k z-yQS2I-G=WkS#O73@kFRV!Ca0{_ose|1TzSj~QSF?i2%}-VgdcEXkg&ON&!ytqXkz qRiff5gI6hN=%X01>L`8;)dIh(8bIG-We^?+{|IOr*f0bCl!0H21$PVp literal 0 HcmV?d00001 diff --git a/.github/workflows/rust.yaml b/.github/workflows/rust.yaml index 3d198c2..ca77cbc 100644 --- a/.github/workflows/rust.yaml +++ b/.github/workflows/rust.yaml @@ -13,7 +13,19 @@ jobs: #run build first to populate caches build: name: Build binary - runs-on: windows-latest + strategy: + matrix: + include: + - os: windows-latest + target: x86_64-pc-windows-msvc + artifact: rust_cat.exe + - os: macos-latest + target: x86_64-apple-darwin + artifact: rust_cat + - os: macos-latest + target: aarch64-apple-darwin + artifact: rust_cat + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - uses: actions/cache@v4 @@ -30,40 +42,136 @@ jobs: profile: minimal toolchain: stable override: true + target: ${{ matrix.target }} + - name: Install target + run: rustup target add ${{ matrix.target }} - uses: actions-rs/cargo@v1 with: command: build - args: --workspace --all-targets --all-features --release + args: --workspace --all-targets --all-features --release --target ${{ matrix.target }} + + + - name: Check for release id: is-release shell: bash run: | unset IS_RELEASE ; if [[ $GITHUB_REF =~ ^refs/tags/v[0-9].* ]]; then IS_RELEASE='true' ; fi echo ::set-output name=IS_RELEASE::${IS_RELEASE} - - name: Artifact Hash - id: package + + - name: Artifact upload (Windows) + if: matrix.os == 'windows-latest' + uses: actions/upload-artifact@master + with: + name: rust_cat-${{ matrix.target }}.exe + path: target/${{ matrix.target }}/release/rust_cat.exe + + - name: Artifact upload (macOS binary) + if: matrix.os == 'macos-latest' + uses: actions/upload-artifact@master + with: + name: rust_cat-${{ matrix.target }} + path: target/${{ matrix.target }}/release/rust_cat + + + - name: Publish archives and packages (Windows) + uses: softprops/action-gh-release@v1 + if: steps.is-release.outputs.IS_RELEASE && matrix.os == 'windows-latest' + with: + files: | + target/${{ matrix.target }}/release/rust_cat.exe + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish archives and packages (macOS) + uses: softprops/action-gh-release@v1 + if: steps.is-release.outputs.IS_RELEASE && matrix.os == 'macos-latest' + with: + files: | + target/${{ matrix.target }}/release/rust_cat + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Create universal macOS app bundle + universal-macos: + name: Create universal macOS app bundle + runs-on: macos-latest + needs: build + if: github.event_name != 'pull_request' || github.actor != 'dependabot[bot]' + steps: + - uses: actions/checkout@v4 + + - name: Download Intel binaries + uses: actions/download-artifact@v3 + with: + name: rust_cat-x86_64-apple-darwin + path: ./x86_64/ + + - name: Download Apple Silicon binaries + uses: actions/download-artifact@v3 + with: + name: rust_cat-aarch64-apple-darwin + path: ./aarch64/ + + - name: Create universal binary + run: | + # Create universal binary using lipo + lipo -create -output rust_cat_universal ./x86_64/rust_cat ./aarch64/rust_cat + + # Verify the universal binary + lipo -info rust_cat_universal + file rust_cat_universal + + - name: Create universal app bundle + run: | + # Create app icon + ./build_app_icon.sh || true + + # Create universal app bundle + mkdir -p RustCat.app/Contents/MacOS + mkdir -p RustCat.app/Contents/Resources + + # Copy universal binary + cp rust_cat_universal RustCat.app/Contents/MacOS/rust_cat + cp Info.plist RustCat.app/Contents/ + + # Copy app icon if it was created + if [ -f "RustCat.icns" ]; then + cp RustCat.icns RustCat.app/Contents/Resources/AppIcon.icns + fi + + # Create zip archive + zip -r RustCat-universal.app.zip RustCat.app + + # Create DMG + ./create_dmg.sh RustCat.app RustCat-universal.dmg + + - name: Check for release + id: is-release shell: bash run: | - md5sum target/release/rust_cat.exe >> hashes.txt - sha256sum target/release/rust_cat.exe >> hashes.txt - cat hashes.txt - - name: Artifact upload + unset IS_RELEASE ; if [[ $GITHUB_REF =~ ^refs/tags/v[0-9].* ]]; then IS_RELEASE='true' ; fi + echo ::set-output name=IS_RELEASE::${IS_RELEASE} + + - name: Upload universal app bundle uses: actions/upload-artifact@master with: - name: rust_cat.exe - path: target/release/rust_cat.exe - - name: Hash upload + name: RustCat-universal.app.zip + path: RustCat-universal.app.zip + + - name: Upload universal DMG uses: actions/upload-artifact@master with: - name: hashes.txt - path: hashes.txt - - name: Publish archives and packages + name: RustCat-universal.dmg + path: RustCat-universal.dmg + + - name: Publish universal macOS release uses: softprops/action-gh-release@v1 if: steps.is-release.outputs.IS_RELEASE with: files: | - target/release/rust_cat.exe - hashes.txt + RustCat-universal.app.zip + RustCat-universal.dmg env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -77,7 +185,7 @@ jobs: uses: hmarr/auto-approve-action@v4 with: github-token: ${{ secrets.GITHUB_TOKEN }} - + - name: Enable auto-merge run: | gh pr merge --auto --squash "${{ github.event.pull_request.number }}" diff --git a/CLAUDE.md b/CLAUDE.md index 53a1220..e02161a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,20 +4,26 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -RustCat is a Windows tray application that displays an animated cat or parrot icon whose animation speed reflects real-time CPU usage. It's a Rust port of RunCat_for_windows, removing the .NET runtime dependency. +RustCat is a cross-platform tray application that displays an animated cat or parrot icon whose animation speed reflects real-time CPU usage. Originally a Windows-only Rust port of RunCat_for_windows, it now supports both Windows and macOS. ## Build and Development Commands ### Building ```bash -# In WSL, use cargo.exe to build Windows binaries +# Windows (In WSL, use cargo.exe to build Windows binaries) cargo.exe build --release + +# macOS +cargo build --release ``` ### Running (Development) ```bash -# In WSL, use cargo.exe to run Windows binaries +# Windows (In WSL, use cargo.exe to run Windows binaries) cargo.exe run + +# macOS +cargo run ``` ### Testing @@ -69,12 +75,19 @@ Icons are organized in `assets/` directory: ### Platform Specifics -- Windows-only application using Windows API extensively via windows-rs crate +#### Windows +- Uses Windows API extensively via windows-rs crate - Uses Windows subsystem (no console window in release builds) - Integrates with Windows registry for settings and startup behavior -- Requires Windows for tray icon functionality and CPU monitoring - When developing in WSL, use `cargo.exe` instead of `cargo` to build Windows binaries +#### macOS +- Uses macOS NSApplication for proper integration +- Uses macOS defaults command for settings persistence +- Uses macOS LaunchAgents for startup behavior +- Detects system dark/light theme automatically +- Opens Activity Monitor instead of Task Manager + ## Development Environment Notes - When running in WSL, use `cargo.exe` to build \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 1464d2d..ad510cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,21 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "bitflags" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2", +] + [[package]] name = "cfg-if" version = "1.0.1" @@ -23,6 +38,27 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "flate2" version = "1.1.2" @@ -33,6 +69,33 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "libc" +version = "0.2.174" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" + +[[package]] +name = "libredox" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1580801010e535496706ba011c15f8532df6b42297d2e471fec38ceadd8c0638" +dependencies = [ + "bitflags", + "libc", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -42,6 +105,111 @@ dependencies = [ "adler2", ] +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags", + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", + "objc2-metal", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags", + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags", + "block2", + "objc2", + "objc2-foundation", + "objc2-metal", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "proc-macro2" version = "1.0.95" @@ -60,11 +228,26 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + [[package]] name = "rust_cat" version = "2.1.0" dependencies = [ + "dirs", "flate2", + "objc2", + "objc2-app-kit", + "objc2-foundation", "trayicon", "windows", "winreg", @@ -102,6 +285,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "toml" version = "0.5.11" @@ -114,9 +317,11 @@ dependencies = [ [[package]] name = "trayicon" version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c51617694e059fe4b83ab48e435660c1ba5b48b1573b4c143fdabd1c0f279daa" +source = "git+https://github.com/bearice/trayicon-rs.git#cd6ff5ea862e08dd6caed74e0aa6a4e662f21306" dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-foundation", "winapi", ] @@ -126,6 +331,12 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "winapi" version = "0.3.9" @@ -250,13 +461,37 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", ] [[package]] @@ -265,14 +500,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] @@ -284,18 +519,36 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -308,24 +561,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -339,7 +616,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" dependencies = [ "cfg-if", - "windows-sys", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index d59653b..e3a1d9d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,11 +6,19 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -trayicon = "0.2.0" -winreg = "0.55.0" +trayicon = { git = "https://github.com/bearice/trayicon-rs.git" } flate2 = "1.0" +dirs = "5.0" + +[target.'cfg(windows)'.dependencies] +winreg = "0.55.0" + +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.5" +objc2-app-kit = { version = "0.2", features = ["all"] } +objc2-foundation = { version = "0.2", features = ["all"] } -[dependencies.windows] +[target.'cfg(windows)'.dependencies.windows] version = "0.61" features = [ "Win32_Foundation", @@ -28,9 +36,11 @@ codegen-units = 1 # Reduce code generation units panic = "abort" # Reduce panic code size [build-dependencies] -winres = "0.1" flate2 = "1.0" +[target.'cfg(windows)'.build-dependencies] +winres = "0.1" + [package.metadata.winres] OriginalFilename = "rust_cat.exe" FileDescription = "πŸ’» => πŸˆπŸ˜ΊπŸ˜ΈπŸ˜ΉπŸ˜»πŸ˜ΌπŸ˜½πŸˆβ€β¬›" diff --git a/Info.plist b/Info.plist new file mode 100644 index 0000000..648027d --- /dev/null +++ b/Info.plist @@ -0,0 +1,34 @@ + + + + + CFBundleName + RustCat + CFBundleDisplayName + RustCat + CFBundleIdentifier + com.bearice.rustcat + CFBundleVersion + 2.1.0 + CFBundleShortVersionString + 2.1.0 + CFBundlePackageType + APPL + CFBundleExecutable + rust_cat + CFBundleIconFile + AppIcon + LSUIElement + + NSHighResolutionCapable + + NSHumanReadableCopyright + Copyright Β© 2021 Bearice Ren + CFBundleInfoDictionaryVersion + 6.0 + LSMinimumSystemVersion + 10.15 + NSPrincipalClass + NSApplication + + \ No newline at end of file diff --git a/build.rs b/build.rs index 7ddff06..fcb8a36 100644 --- a/build.rs +++ b/build.rs @@ -1,176 +1,198 @@ -use std::{io, path::Path, collections::HashMap}; -use winres::WindowsResource; -use flate2::{write::GzEncoder, Compression}; - -fn main() -> io::Result<()> { - // Get Git commit hash - let output = std::process::Command::new("git") - .args(["rev-parse", "--short", "HEAD"]) - .output(); - - match output { - Ok(output) if output.status.success() => { - let git_hash = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if !git_hash.is_empty() { - println!("cargo:rustc-env=GIT_HASH={}", git_hash); - } else { - println!("cargo:rustc-env=GIT_HASH=N/A"); - } - } - _ => { - println!("cargo:rustc-env=GIT_HASH=N/A"); - } - } - - let profile = std::env::var("PROFILE").unwrap(); - if profile == "release" { - println!("cargo:rustc-cfg=release"); - } - let mut res = WindowsResource::new(); - // This path can be absolute, or relative to your crate root. - res.set_icon("assets/appIcon.ico"); - - res.compile()?; - generate_icon_resources()?; - Ok(()) -} - -fn generate_icon_resources() -> io::Result<()> { - let out_dir = std::env::var_os("OUT_DIR").unwrap(); - let dest_path = Path::new(&out_dir).join("icon_data.rs"); - - // Define icon configurations that match the icon manager structure - let icon_configs = [ - ("cat", [("light", 5), ("dark", 5)]), - ("parrot", [("light", 10), ("dark", 10)]), - ]; - - // Concatenate ALL icons into one big chunk for maximum compression - let (compressed_data, icon_metadata) = generate_all_icons_compressed(&icon_configs)?; - - // Generate code with single compressed chunk - let code = generate_single_chunk_module(&compressed_data, &icon_metadata); - - std::fs::write(&dest_path, code.as_bytes()) -} - -// Icon metadata for the single compressed chunk -#[derive(Debug)] -struct IconGroupMetadata { - icon_name: String, - theme: String, - offset: usize, - sizes: Vec, -} - -fn generate_all_icons_compressed(icon_configs: &[(&str, [(&str, usize); 2])]) -> io::Result<(String, Vec)> { - let mut all_icons_data = Vec::new(); - let mut metadata = Vec::new(); - - // Collect all icon data - for (icon_name, themes) in icon_configs.iter() { - for (theme, count) in themes.iter() { - let base = std::fs::canonicalize(Path::new("assets").join(icon_name))?; - let icon_file_names = (0..*count) - .map(|i| format!("{}_{}_{}", theme, icon_name, i)) - .collect::>(); - - let mut group_sizes = Vec::new(); - let group_offset = all_icons_data.len(); - - // Read all icons for this group - for name in &icon_file_names { - let icon_path = base.join(format!("{}.ico", name)); - let icon_data = std::fs::read(&icon_path)?; - group_sizes.push(icon_data.len()); - all_icons_data.extend_from_slice(&icon_data); - } - - metadata.push(IconGroupMetadata { - icon_name: icon_name.to_string(), - theme: theme.to_string(), - offset: group_offset, - sizes: group_sizes, - }); - } - } - - // Compress all icons together - let mut gz_encoder = GzEncoder::new(Vec::new(), Compression::best()); - std::io::copy(&mut &all_icons_data[..], &mut gz_encoder)?; - let compressed = gz_encoder.finish()?; - - // Write compressed data to OUT_DIR - let out_dir = std::env::var_os("OUT_DIR").unwrap(); - let compressed_path = Path::new(&out_dir).join("all_icons.gz"); - std::fs::write(&compressed_path, &compressed)?; - - println!("cargo:info=All icons compressed: {} bytes -> {} bytes ({:.1}% reduction)", - all_icons_data.len(), compressed.len(), - 100.0 * (1.0 - compressed.len() as f64 / all_icons_data.len() as f64)); - - Ok((compressed_path.display().to_string(), metadata)) -} - -fn generate_single_chunk_module(compressed_path: &str, metadata: &[IconGroupMetadata]) -> String { - let mut code = String::new(); - - code.push_str("// All icons compressed into a single chunk for maximum compression\n"); - code.push_str(&format!("pub const ALL_ICONS_COMPRESSED: &[u8] = include_bytes!(r\"{}\");\n\n", compressed_path)); - - code.push_str("use std::collections::HashMap;\n\n"); - - // IconGroupInfo is now defined in main.rs - - // Generate size arrays for each group - for meta in metadata { - let sizes_array = meta.sizes - .iter() - .map(|size| size.to_string()) - .collect::>() - .join(", "); - - code.push_str(&format!( - "const {}_{}_{}_SIZES: &[u32] = &[{}];\n", - meta.theme.to_uppercase(), - meta.icon_name.to_uppercase(), - "INDIVIDUAL", - sizes_array - )); - } - - code.push('\n'); - - // Generate function to get icon metadata - code.push_str("pub fn get_icon_metadata() -> IconData {\n"); - code.push_str(" let mut icons = HashMap::new();\n\n"); - - // Group metadata by icon name - let mut grouped_metadata: HashMap> = HashMap::new(); - for meta in metadata { - grouped_metadata.entry(meta.icon_name.clone()).or_default().push(meta); - } - - for (icon_name, themes) in grouped_metadata { - code.push_str(&format!(" // {} icons\n", icon_name)); - code.push_str(&format!(" let mut {} = HashMap::new();\n", icon_name)); - - for meta in themes { - code.push_str(&format!( - " {}.insert(\"{}\", IconGroupInfo {{ offset: {}, sizes: {}_{}_INDIVIDUAL_SIZES }});\n", - icon_name, - meta.theme, - meta.offset, - meta.theme.to_uppercase(), - meta.icon_name.to_uppercase() - )); - } - - code.push_str(&format!(" icons.insert(\"{}\", {});\n\n", icon_name, icon_name)); - } - - code.push_str(" icons\n"); - code.push_str("}\n"); - - code -} +use flate2::{write::GzEncoder, Compression}; +use std::{collections::HashMap, io, path::Path}; + +// only include winres if compiling for Windows +#[cfg(target_os = "windows")] +use winres::WindowsResource; + +fn main() -> io::Result<()> { + // Get Git commit hash + let output = std::process::Command::new("git") + .args(["rev-parse", "--short", "HEAD"]) + .output(); + + match output { + Ok(output) if output.status.success() => { + let git_hash = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !git_hash.is_empty() { + println!("cargo:rustc-env=GIT_HASH={}", git_hash); + } else { + println!("cargo:rustc-env=GIT_HASH=N/A"); + } + } + _ => { + println!("cargo:rustc-env=GIT_HASH=N/A"); + } + } + + let profile = std::env::var("PROFILE").unwrap(); + if profile == "release" { + println!("cargo:rustc-cfg=release"); + } + + // Generate Windows resource file if compiling for Windows + #[cfg(target_os = "windows")] + { + let mut res = WindowsResource::new(); + res.set_icon("assets/appIcon.ico"); + res.compile()?; + } + + generate_icon_resources()?; + Ok(()) +} + +fn generate_icon_resources() -> io::Result<()> { + let out_dir = std::env::var_os("OUT_DIR").unwrap(); + let dest_path = Path::new(&out_dir).join("icon_data.rs"); + + // Define icon configurations that match the icon manager structure + let icon_configs = [ + ("cat", [("light", 5), ("dark", 5)]), + ("parrot", [("light", 10), ("dark", 10)]), + ]; + + // Concatenate ALL icons into one big chunk for maximum compression + let (compressed_data, icon_metadata) = generate_all_icons_compressed(&icon_configs)?; + + // Generate code with single compressed chunk + let code = generate_single_chunk_module(&compressed_data, &icon_metadata); + + std::fs::write(&dest_path, code.as_bytes()) +} + +// Icon metadata for the single compressed chunk +#[derive(Debug)] +struct IconGroupMetadata { + icon_name: String, + theme: String, + offset: usize, + sizes: Vec, +} + +fn generate_all_icons_compressed( + icon_configs: &[(&str, [(&str, usize); 2])], +) -> io::Result<(String, Vec)> { + let mut all_icons_data = Vec::new(); + let mut metadata = Vec::new(); + + // Collect all icon data + for (icon_name, themes) in icon_configs.iter() { + for (theme, count) in themes.iter() { + let base = std::fs::canonicalize(Path::new("assets").join(icon_name))?; + let icon_file_names = (0..*count) + .map(|i| format!("{}_{}_{}", theme, icon_name, i)) + .collect::>(); + + let mut group_sizes = Vec::new(); + let group_offset = all_icons_data.len(); + + // Read all icons for this group + for name in &icon_file_names { + let icon_path = base.join(format!("{}.ico", name)); + let icon_data = std::fs::read(&icon_path)?; + group_sizes.push(icon_data.len()); + all_icons_data.extend_from_slice(&icon_data); + } + + metadata.push(IconGroupMetadata { + icon_name: icon_name.to_string(), + theme: theme.to_string(), + offset: group_offset, + sizes: group_sizes, + }); + } + } + + // Compress all icons together + let mut gz_encoder = GzEncoder::new(Vec::new(), Compression::best()); + std::io::copy(&mut &all_icons_data[..], &mut gz_encoder)?; + let compressed = gz_encoder.finish()?; + + // Write compressed data to OUT_DIR + let out_dir = std::env::var_os("OUT_DIR").unwrap(); + let compressed_path = Path::new(&out_dir).join("all_icons.gz"); + std::fs::write(&compressed_path, &compressed)?; + + println!( + "cargo:info=All icons compressed: {} bytes -> {} bytes ({:.1}% reduction)", + all_icons_data.len(), + compressed.len(), + 100.0 * (1.0 - compressed.len() as f64 / all_icons_data.len() as f64) + ); + + Ok((compressed_path.display().to_string(), metadata)) +} + +fn generate_single_chunk_module(compressed_path: &str, metadata: &[IconGroupMetadata]) -> String { + let mut code = String::new(); + + code.push_str("// All icons compressed into a single chunk for maximum compression\n"); + code.push_str(&format!( + "pub const ALL_ICONS_COMPRESSED: &[u8] = include_bytes!(r\"{}\");\n\n", + compressed_path + )); + + code.push_str("use std::collections::HashMap;\n\n"); + + // IconGroupInfo is now defined in main.rs + + // Generate size arrays for each group + for meta in metadata { + let sizes_array = meta + .sizes + .iter() + .map(|size| size.to_string()) + .collect::>() + .join(", "); + + code.push_str(&format!( + "const {}_{}_{}_SIZES: &[u32] = &[{}];\n", + meta.theme.to_uppercase(), + meta.icon_name.to_uppercase(), + "INDIVIDUAL", + sizes_array + )); + } + + code.push('\n'); + + // Generate function to get icon metadata + code.push_str("pub fn get_icon_metadata() -> IconData {\n"); + code.push_str(" let mut icons = HashMap::new();\n\n"); + + // Group metadata by icon name + let mut grouped_metadata: HashMap> = HashMap::new(); + for meta in metadata { + grouped_metadata + .entry(meta.icon_name.clone()) + .or_default() + .push(meta); + } + + for (icon_name, themes) in grouped_metadata { + code.push_str(&format!(" // {} icons\n", icon_name)); + code.push_str(&format!(" let mut {} = HashMap::new();\n", icon_name)); + + for meta in themes { + code.push_str(&format!( + " {}.insert(\"{}\", IconGroupInfo {{ offset: {}, sizes: {}_{}_INDIVIDUAL_SIZES }});\n", + icon_name, + meta.theme, + meta.offset, + meta.theme.to_uppercase(), + meta.icon_name.to_uppercase() + )); + } + + code.push_str(&format!( + " icons.insert(\"{}\", {});\n\n", + icon_name, icon_name + )); + } + + code.push_str(" icons\n"); + code.push_str("}\n"); + + code +} diff --git a/build_app_icon.sh b/build_app_icon.sh new file mode 100755 index 0000000..a65ff15 --- /dev/null +++ b/build_app_icon.sh @@ -0,0 +1,34 @@ +#!/bin/bash + +# Script to create a macOS app icon from existing assets +# This is optional and will be run during CI if iconutil is available + +if command -v iconutil &> /dev/null; then + echo "Creating app icon..." + + # Create iconset directory + mkdir -p RustCat.iconset + + # We'll use the main app icon + # Convert ICO to PNG at required sizes using sips (macOS built-in tool) + if command -v sips &> /dev/null; then + sips -s format png -Z 16 assets/appIcon.ico --out RustCat.iconset/icon_16x16.png + sips -s format png -Z 32 assets/appIcon.ico --out RustCat.iconset/icon_32x32.png + sips -s format png -Z 128 assets/appIcon.ico --out RustCat.iconset/icon_128x128.png + sips -s format png -Z 256 assets/appIcon.ico --out RustCat.iconset/icon_256x256.png + sips -s format png -Z 512 assets/appIcon.ico --out RustCat.iconset/icon_512x512.png + else + echo "sips not found" + exit 1 + fi + + # Create the .icns file + iconutil -c icns RustCat.iconset + + # Clean up + rm -rf RustCat.iconset + + echo "App icon created: RustCat.icns" +else + echo "iconutil not found, skipping app icon creation" +fi diff --git a/create_dmg.sh b/create_dmg.sh new file mode 100755 index 0000000..3da1a60 --- /dev/null +++ b/create_dmg.sh @@ -0,0 +1,36 @@ +#!/bin/bash + +# Script to create a DMG image for macOS app distribution +# Usage: ./create_dmg.sh + +APP_PATH="$1" +DMG_NAME="$2" + +if [ -z "$APP_PATH" ] || [ -z "$DMG_NAME" ]; then + echo "Usage: $0 " + exit 1 +fi + +if [ ! -d "$APP_PATH" ]; then + echo "Error: App bundle not found at $APP_PATH" + exit 1 +fi + +# Create a temporary directory for DMG contents +TEMP_DIR=$(mktemp -d) +DMG_DIR="$TEMP_DIR/dmg_contents" +mkdir -p "$DMG_DIR" + +# Copy the app bundle to the DMG directory +cp -R "$APP_PATH" "$DMG_DIR/" + +# Create a symbolic link to Applications folder for easy installation +ln -s /Applications "$DMG_DIR/Applications" + +# Create the DMG +hdiutil create -volname "RustCat" -srcfolder "$DMG_DIR" -ov -format UDZO "$DMG_NAME" + +# Clean up +rm -rf "$TEMP_DIR" + +echo "DMG created: $DMG_NAME" diff --git a/src/app.rs b/src/app.rs index 0244fd1..5ddf20d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,210 +1,192 @@ -use std::{ - sync::{ - atomic::{AtomicBool, Ordering}, - Arc, Mutex, - }, - thread::sleep, - time::Duration, -}; -use trayicon::*; -use windows::Win32::UI::WindowsAndMessaging::*; - -use crate::{ - cpu_usage, - events::{Events, build_menu}, - icon_manager::IconManager, - settings::{set_current_icon, set_current_theme, get_current_icon, get_current_theme, is_run_on_start_enabled, set_run_on_start}, - windows_api::{safe_message_box, safe_shell_execute, safe_message_loop}, -}; - -pub struct App { - pub tray_icon: Arc>>, - pub exit: Arc, - pub icon_manager: Arc, -} - -impl App { - pub fn new(icon_manager: IconManager, icon_name: &str, theme: Option) -> Result> { - let (s, r) = std::sync::mpsc::channel::(); - - let initial_icons = icon_manager.get_icon_set(icon_name, theme) - .ok_or("Invalid initial icon name")?; - - let tray_icon = TrayIconBuilder::new() - .sender(move |e: &Events| { - let _ = s.send(e.clone()); - }) - .icon(initial_icons[0].clone()) - .tooltip("Nyan~") - .menu(build_menu(&icon_manager)) - .on_double_click(Events::RunTaskmgr) - .on_right_click(Events::ShowMenu) - .build()?; - - let exit = Arc::new(AtomicBool::new(false)); - let tray_icon = Arc::new(Mutex::new(tray_icon)); - let icon_manager = Arc::new(icon_manager); - - let app = App { - tray_icon, - exit, - icon_manager, - }; - - app.start_event_thread(r); - - Ok(app) - } - - pub fn start_event_thread(&self, receiver: std::sync::mpsc::Receiver) { - let exit = self.exit.clone(); - let tray_icon = self.tray_icon.clone(); - let icon_manager = self.icon_manager.clone(); - - std::thread::spawn(move || { - let update_menu = || { - tray_icon - .lock() - .unwrap() - .set_menu(&build_menu(&icon_manager)) - .expect("set_menu") - }; - - for event in receiver.iter() { - match event { - Events::Exit => { - exit.store(true, Ordering::Relaxed); - unsafe { - PostQuitMessage(0); - } - break; - } - Events::RunTaskmgr => { - if let Err(err) = safe_shell_execute("taskmgr.exe") { - if let Err(msg_err) = safe_message_box(&err.to_string(), "RustCat Error", MB_OK.0) { - eprintln!("Failed to show error dialog: {}", msg_err); - } - } - }, - Events::SetTheme(theme) => { - set_current_theme(Some(theme)); - update_menu(); - }, - Events::SetIcon(icon_name) => { - set_current_icon(&icon_name); - update_menu(); - }, - Events::ToggleRunOnStart => { - let current_state = is_run_on_start_enabled(); - set_run_on_start(!current_state); - update_menu(); - } - Events::ShowAboutDialog => { - let version = env!("CARGO_PKG_VERSION"); - let git_hash = option_env!("GIT_HASH").unwrap_or("N/A"); - let project_page = "https://github.com/bearice/RustCat"; - let message = format!( - "RustCat version {} (Git: {})\nProject Page: {}", - version, git_hash, project_page - ); - if let Err(err) = safe_message_box(&message, "About RustCat", (MB_OK | MB_ICONINFORMATION).0) { - eprintln!("Failed to show about dialog: {}", err); - } - }, - Events::ShowMenu => { - tray_icon.lock().unwrap().show_menu().unwrap(); - }, - } - } - }); - } - - pub fn start_animation_thread(&self) { - let exit = self.exit.clone(); - let tray_icon = self.tray_icon.clone(); - let icon_manager = self.icon_manager.clone(); - - std::thread::spawn(move || { - let sleep_interval = 10; - let mut t1 = match cpu_usage::get_cpu_totals() { - Ok(totals) => totals, - Err(e) => { - eprintln!("Failed to get initial CPU totals: {}", e); - return; - } - }; - let mut update_counter = 0; - let mut animate_counter = 0; - let mut icon_index = 0; - let mut speed = 200; - - while !exit.load(Ordering::Relaxed) { - sleep(Duration::from_millis(sleep_interval)); - let current_icon_name = get_current_icon(); - let current_theme = get_current_theme(); - let icons = match icon_manager.get_icon_set(¤t_icon_name, Some(current_theme)) { - Some(icons) => icons, - None => { - eprintln!("Invalid icon name: {}", current_icon_name); - continue; - } - }; - - if animate_counter >= speed { - animate_counter = 0; - icon_index += 1; - icon_index %= icons.len(); - tray_icon - .lock() - .unwrap() - .set_icon(&icons[icon_index]) - .map_err(|e| eprintln!("set_icon error: {:?}", e)) - .unwrap_or(()); - } - animate_counter += sleep_interval; - - if update_counter == 1000 { - update_counter = 0; - let t2 = match cpu_usage::get_cpu_totals() { - Ok(totals) => totals, - Err(e) => { - eprintln!("Failed to get CPU totals: {}", e); - continue; - } - }; - let usage = 100.0 - (t2.1 - t1.1) / (t2.0 - t1.0) * 100.0; - t1 = t2; - speed = (200.0 / (usage / 5.0).clamp(1.0, 20.0)).round() as u64; - println!("CPU Usage: {:.2}% speed: {}", usage, speed); - tray_icon - .lock() - .unwrap() - .set_tooltip(&format!("CPU Usage: {:.2}%", usage)) - .map_err(|e| eprintln!("set_tooltip error: {:?}", e)) - .unwrap_or(()); - } - update_counter += sleep_interval; - } - }); - } - - pub fn run_message_loop(&self) { - while !self.exit.load(Ordering::Relaxed) { - match safe_message_loop() { - Ok(()) => continue, - Err(err) => { - if err.to_string().contains("WM_QUIT") { - break; - } else { - eprintln!("Message loop error: {}", err); - break; - } - } - } - } - } - - pub fn shutdown(&self) { - self.exit.store(true, Ordering::Relaxed); - } -} \ No newline at end of file +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::Duration; + +use crate::events::{build_menu, Events}; +use crate::icon_manager::{IconManager, Theme}; +use crate::platform::{CpuMonitor, SettingsManager, SystemIntegration}; +use crate::platform::{CpuMonitorImpl, SettingsManagerImpl, SystemIntegrationImpl}; + +use trayicon::*; + +pub struct App { + tray_icon: Arc>>, + icon_manager: Arc, + exit_flag: Arc, + event_receiver: Option>, +} + +impl App { + pub fn new( + icon_manager: IconManager, + initial_icon: &str, + initial_theme: Option, + ) -> Result> { + let (sender, receiver) = mpsc::channel::(); + let icon_manager = Arc::new(icon_manager); + let exit_flag = Arc::new(AtomicBool::new(false)); + + let theme = initial_theme.unwrap_or_else(|| SettingsManagerImpl::get_current_theme()); + let initial_icons = icon_manager + .get_icon_set(initial_icon, Some(theme)) + .ok_or("Invalid initial icon name")?; + + let tray_icon = TrayIconBuilder::new() + .sender(move |e: &Events| { + let _ = sender.send(e.clone()); + }) + .icon(initial_icons[0].clone()) + .tooltip("~Nyan~ RustCat - CPU Usage Monitor") + .menu(build_menu(&icon_manager)) + .on_right_click(Events::ShowMenu) + .on_double_click(Events::RunTaskmgr) + .build()?; + + Ok(App { + tray_icon: Arc::new(Mutex::new(tray_icon)), + icon_manager, + exit_flag, + event_receiver: Some(receiver), + }) + } + + pub fn start_animation_thread(&self) { + let exit_flag = self.exit_flag.clone(); + let tray_icon = self.tray_icon.clone(); + let icon_manager = self.icon_manager.clone(); + + thread::spawn(move || { + let sleep_interval = 10; + let mut update_counter = 0; + let mut animate_counter = 0; + let mut icon_index = 0; + let mut speed = 200; + + while !exit_flag.load(Ordering::Relaxed) { + thread::sleep(Duration::from_millis(sleep_interval)); + + let current_icon_name = SettingsManagerImpl::get_current_icon(); + let current_theme = SettingsManagerImpl::get_current_theme(); + let icons = match icon_manager.get_icon_set(¤t_icon_name, Some(current_theme)) + { + Some(icons) => icons, + None => { + eprintln!("Invalid icon name: {}", current_icon_name); + continue; + } + }; + + if animate_counter >= speed { + animate_counter = 0; + icon_index += 1; + icon_index %= icons.len(); + if let Ok(mut tray) = tray_icon.lock() { + if let Err(e) = tray.set_icon(&icons[icon_index]) { + eprintln!("set_icon error: {:?}", e); + } + } + } + animate_counter += sleep_interval; + + if update_counter >= 1000 { + update_counter = 0; + let usage = match CpuMonitorImpl::get_cpu_usage() { + Ok(usage) => usage, + Err(e) => { + eprintln!("Failed to get CPU usage: {}", e); + continue; + } + }; + speed = (200.0 / (usage / 5.0).clamp(1.0_f64, 20.0_f64)).round() as u64; + println!("CPU Usage: {:.2}% speed: {}", usage, speed); + + if let Ok(mut tray) = tray_icon.lock() { + if let Err(e) = tray.set_tooltip(&format!("CPU Usage: {:.2}%", usage)) { + eprintln!("set_tooltip error: {:?}", e); + } + } + } + update_counter += sleep_interval; + } + }); + } + + pub fn run(mut self) { + if let Some(receiver) = self.event_receiver.take() { + for event in receiver { + match event { + Events::Exit => { + self.exit_flag.store(true, Ordering::Relaxed); + self.shutdown(); + // For macOS, we need to trigger NSApplication termination + #[cfg(target_os = "macos")] + { + use objc2_app_kit::NSApplication; + use objc2_foundation::MainThreadMarker; + let app = NSApplication::sharedApplication(unsafe { + MainThreadMarker::new_unchecked() + }); + unsafe { app.terminate(None) }; + } + break; + } + Events::RunTaskmgr => { + if let Err(e) = SystemIntegrationImpl::open_system_monitor() { + eprintln!("Failed to open system monitor: {}", e); + } + } + Events::SetTheme(theme) => { + SettingsManagerImpl::set_current_theme(Some(theme)); + self.update_menu(); + } + Events::SetIcon(icon_name) => { + SettingsManagerImpl::set_current_icon(&icon_name); + self.update_menu(); + } + Events::ToggleRunOnStart => { + let current_state = SettingsManagerImpl::is_run_on_start_enabled(); + SettingsManagerImpl::set_run_on_start(!current_state); + self.update_menu(); + } + Events::ShowAboutDialog => { + let version = env!("CARGO_PKG_VERSION"); + let git_hash = option_env!("GIT_HASH").unwrap_or("N/A"); + let project_page = "https://github.com/bearice/RustCat"; + let message = format!( + "RustCat version {} (Git: {})\\nProject Page: {}", + version, git_hash, project_page + ); + + if let Err(e) = + SystemIntegrationImpl::show_dialog(&message, "About RustCat") + { + eprintln!("Failed to show about dialog: {}", e); + } + } + Events::ShowMenu => { + if let Ok(mut tray) = self.tray_icon.lock() { + if let Err(e) = tray.show_menu() { + eprintln!("Failed to show menu: {}", e); + } + } + } + } + } + } + } + + fn update_menu(&self) { + if let Ok(mut tray) = self.tray_icon.lock() { + if let Err(e) = tray.set_menu(&build_menu(&self.icon_manager)) { + eprintln!("Failed to update menu: {}", e); + } + } + } + + pub fn shutdown(&self) { + // Platform-specific shutdown logic can be added here + println!("Shutting down RustCat..."); + } +} diff --git a/src/cpu_usage.rs b/src/cpu_usage.rs deleted file mode 100644 index 6566f41..0000000 --- a/src/cpu_usage.rs +++ /dev/null @@ -1,36 +0,0 @@ -use std::io; - -use windows::{ - Win32::Foundation::FILETIME, - Win32::System::Threading::GetSystemTimes, -}; - -pub fn get_cpu_totals() -> io::Result<(f64, f64)> { - let mut idle_time = empty(); - let mut kernel_time = empty(); - let mut user_time = empty(); - unsafe { - GetSystemTimes(Some(&mut idle_time), Some(&mut kernel_time), Some(&mut user_time)) - .map_err(|e| io::Error::other(format!("Failed to get system times: {}", e)))?; - } - let idle_time = filetime_to_u64(idle_time) as f64; - let kernel_time = filetime_to_u64(kernel_time) as f64; - let user_time = filetime_to_u64(user_time) as f64; - let total_time = kernel_time + user_time; - Ok((total_time, idle_time)) -} - -/// Essentailly a no-op -#[inline(always)] -fn filetime_to_u64(f: FILETIME) -> u64 { - (f.dwHighDateTime as u64) << 32 | (f.dwLowDateTime as u64) -} - -/// Empty FILETIME -#[inline(always)] -fn empty() -> FILETIME { - FILETIME { - dwLowDateTime: 0, - dwHighDateTime: 0, - } -} diff --git a/src/events.rs b/src/events.rs index 0e7ce6b..b78f8ba 100644 --- a/src/events.rs +++ b/src/events.rs @@ -1,64 +1,70 @@ -use trayicon::MenuBuilder; -use crate::settings::{is_run_on_start_enabled, get_current_icon, get_current_theme}; -use crate::icon_manager::{Theme, IconManager}; - -#[derive(Clone, Eq, PartialEq, Debug)] -pub enum Events { - Exit, - SetTheme(Theme), - SetIcon(String), - RunTaskmgr, - ToggleRunOnStart, - ShowAboutDialog, - ShowMenu, -} - -pub fn build_menu(icon_manager: &IconManager) -> MenuBuilder { - let run_on_start_enabled = is_run_on_start_enabled(); - let current_icon = get_current_icon(); - let current_theme = get_current_theme(); - - let mut menu = MenuBuilder::new(); - - // Build theme submenu - only show if current icon supports themes - if icon_manager.supports_themes(¤t_icon) { - let available_themes = icon_manager.available_themes_for_icon(¤t_icon); - if !available_themes.is_empty() { - let mut theme_menu = MenuBuilder::new(); - for theme in available_themes { - let theme_name = match theme { - Theme::Dark => "&Dark", - Theme::Light => "&Light", - }; - let is_current = current_theme == theme; - theme_menu = theme_menu.checkable(theme_name, is_current, Events::SetTheme(theme)); - } - menu = menu.submenu("&Theme", theme_menu); - } - } - - // Build icon submenu - dynamically from available icons - let available_icons = icon_manager.available_icons(); - if available_icons.len() > 1 { - let mut icon_menu = MenuBuilder::new(); - for icon_name in available_icons { - let is_current = current_icon == icon_name; - // Capitalize first letter for display - let display_name = format!("&{}", icon_name.chars().next().unwrap().to_uppercase().collect::() + &icon_name[1..]); - icon_menu = icon_menu.checkable(&display_name, is_current, Events::SetIcon(icon_name)); - } - menu = menu.submenu("&Icon", icon_menu); - } - - menu - .separator() - .checkable( - "&Run on Start", - run_on_start_enabled, - Events::ToggleRunOnStart, - ) - .separator() - .item("&About", Events::ShowAboutDialog) - .separator() - .item("E&xit", Events::Exit) -} \ No newline at end of file +use crate::icon_manager::{IconManager, Theme}; +use crate::platform::{SettingsManager, SettingsManagerImpl}; +use trayicon::MenuBuilder; + +#[derive(Clone, Eq, PartialEq, Debug)] +pub enum Events { + Exit, + SetTheme(Theme), + SetIcon(String), + RunTaskmgr, + ToggleRunOnStart, + ShowAboutDialog, + ShowMenu, +} + +pub fn build_menu(icon_manager: &IconManager) -> MenuBuilder { + let run_on_start_enabled = SettingsManagerImpl::is_run_on_start_enabled(); + let current_icon = SettingsManagerImpl::get_current_icon(); + let current_theme = SettingsManagerImpl::get_current_theme(); + + let mut menu = MenuBuilder::new(); + + // Build theme submenu - only show if current icon supports themes + if icon_manager.supports_themes(¤t_icon) { + let available_themes = icon_manager.available_themes_for_icon(¤t_icon); + if !available_themes.is_empty() { + let mut theme_menu = MenuBuilder::new(); + for theme in available_themes { + let theme_name = match theme { + Theme::Dark => "Dark", + Theme::Light => "Light", + }; + let is_current = current_theme == theme; + theme_menu = theme_menu.checkable(theme_name, is_current, Events::SetTheme(theme)); + } + menu = menu.submenu("Theme", theme_menu); + } + } + + // Build icon submenu - dynamically from available icons + let available_icons = icon_manager.available_icons(); + if available_icons.len() > 1 { + let mut icon_menu = MenuBuilder::new(); + for icon_name in available_icons { + let is_current = current_icon == icon_name; + // Capitalize first letter for display + let display_name = icon_name + .chars() + .next() + .unwrap() + .to_uppercase() + .collect::() + + &icon_name[1..]; + icon_menu = icon_menu.checkable(&display_name, is_current, Events::SetIcon(icon_name)); + } + menu = menu.submenu("Icon", icon_menu); + } + + menu.separator() + .checkable( + "Run on Start", + run_on_start_enabled, + Events::ToggleRunOnStart, + ) + .separator() + .item("System Monitor", Events::RunTaskmgr) + .item("About", Events::ShowAboutDialog) + .separator() + .item("Exit", Events::Exit) +} diff --git a/src/icon_manager.rs b/src/icon_manager.rs index 8737a90..140a9a5 100644 --- a/src/icon_manager.rs +++ b/src/icon_manager.rs @@ -1,174 +1,205 @@ -use trayicon::Icon; -use std::collections::HashMap; -use flate2::read::GzDecoder; -use std::io::Read; - -#[allow(dead_code)] -mod icon_data { - pub struct IconGroupInfo { - pub offset: usize, - pub sizes: &'static [u32], - } - pub type IconData = HashMap<&'static str, HashMap<&'static str, IconGroupInfo>>; - include!(concat!(env!("OUT_DIR"), "/icon_data.rs")); -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum Theme { - Dark, - Light, -} - -impl Theme { - pub fn from_system() -> Self { - if crate::settings::is_dark_mode_enabled() { - Theme::Dark - } else { - Theme::Light - } - } -} - -impl std::fmt::Display for Theme { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Theme::Dark => write!(f, "dark"), - Theme::Light => write!(f, "light"), - } - } -} - -pub struct IconManager { - // Maps base icon name -> theme -> icon frames - icon_sets: HashMap>>, - // Maps base icon name -> whether it supports themes - theme_support: HashMap, -} - -impl IconManager { - pub fn new() -> Self { - Self { - icon_sets: HashMap::new(), - theme_support: HashMap::new(), - } - } - - pub fn load_icons() -> Result> { - let mut manager = Self::new(); - - // Decompress the single big chunk containing all icons - let mut decoder = GzDecoder::new(icon_data::ALL_ICONS_COMPRESSED); - let mut all_decompressed = Vec::new(); - decoder.read_to_end(&mut all_decompressed) - .map_err(|e| format!("Failed to decompress all icons: {}", e))?; - - // Leak the decompressed data to keep it alive for the lifetime of the program - let all_decompressed = Box::leak(all_decompressed.into_boxed_slice()); - - // Get icon metadata from build script generated module - let icon_metadata_map = icon_data::get_icon_metadata(); - - for (icon_name, theme_data) in icon_metadata_map { - // All current icons support themes - manager.theme_support.insert(icon_name.to_string(), true); - let mut themes_map = HashMap::new(); - - for (theme_str, group_info) in theme_data { - let theme = match theme_str { - "dark" => Theme::Dark, - "light" => Theme::Light, - _ => continue, // Skip unknown themes - }; - - // Extract this group's data from the big decompressed chunk - let mut icons = Vec::new(); - let mut current_offset = group_info.offset; - - for &size in group_info.sizes { - let size = size as usize; - if current_offset + size > all_decompressed.len() { - return Err(format!("Invalid icon offset/size data for {} {}", icon_name, theme_str).into()); - } - - let icon_data = &all_decompressed[current_offset..current_offset + size]; - - let icon = Icon::from_buffer(icon_data, None, None) - .map_err(|e| format!("Failed to create icon from buffer for {} {}: {}", icon_name, theme_str, e))?; - - icons.push(icon); - current_offset += size; - } - - themes_map.insert(theme, icons); - } - - manager.icon_sets.insert(icon_name.to_string(), themes_map); - } - - Ok(manager) - } - - pub fn get_icon_set(&self, icon_name: &str, theme: Option) -> Option<&Vec> { - let icon_map = self.icon_sets.get(icon_name)?; - - if let Some(theme) = theme { - // Specific theme requested - icon_map.get(&theme) - } else { - // Auto-detect theme or use first available - if self.supports_themes(icon_name) { - let system_theme = Theme::from_system(); - icon_map.get(&system_theme) - .or_else(|| icon_map.values().next()) // Fallback to any theme - } else { - icon_map.values().next() // Single theme icon - } - } - } - - pub fn supports_themes(&self, icon_name: &str) -> bool { - self.theme_support.get(icon_name).copied().unwrap_or(false) - } - - pub fn available_icons(&self) -> Vec { - let mut icons: Vec = self.icon_sets.keys().cloned().collect(); - icons.sort(); - icons - } - - pub fn available_themes_for_icon(&self, icon_name: &str) -> Vec { - if let Some(icon_map) = self.icon_sets.get(icon_name) { - let mut themes: Vec = icon_map.keys().copied().collect(); - themes.sort_by_key(|t| match t { - Theme::Dark => 0, - Theme::Light => 1, - }); - themes - } else { - vec![] - } - } - - // Migration support - convert old numeric IDs to string IDs - pub fn migrate_from_numeric_id(old_id: usize) -> String { - let is_cat = (old_id & 2) == 0; - - if is_cat { - "cat".to_string() - } else { - "parrot".to_string() - } - } - - pub fn get_theme_from_numeric_id(old_id: usize) -> Theme { - let is_dark = (old_id & 1) == 0; - if is_dark { Theme::Dark } else { Theme::Light } - } - -} - -impl Default for IconManager { - fn default() -> Self { - Self::new() - } -} \ No newline at end of file +use flate2::read::GzDecoder; +use std::collections::HashMap; +use std::io::Read; +use trayicon::Icon; + +#[allow(dead_code)] +mod icon_data { + pub struct IconGroupInfo { + pub offset: usize, + pub sizes: &'static [u32], + } + pub type IconData = HashMap<&'static str, HashMap<&'static str, IconGroupInfo>>; + include!(concat!(env!("OUT_DIR"), "/icon_data.rs")); +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Theme { + Dark, + Light, +} + +impl Theme { + pub fn from_system() -> Self { + use crate::platform::{SettingsManager, SettingsManagerImpl}; + if SettingsManagerImpl::is_dark_mode_enabled() { + Theme::Dark + } else { + Theme::Light + } + } +} + +impl std::fmt::Display for Theme { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Theme::Dark => write!(f, "dark"), + Theme::Light => write!(f, "light"), + } + } +} + +pub struct IconManager { + // Maps base icon name -> theme -> icon frames + icon_sets: HashMap>>, + // Maps base icon name -> whether it supports themes + theme_support: HashMap, +} + +impl IconManager { + pub fn new() -> Self { + Self { + icon_sets: HashMap::new(), + theme_support: HashMap::new(), + } + } + + pub fn load_icons() -> Result> { + let mut manager = Self::new(); + + // Decompress the single big chunk containing all icons + let mut decoder = GzDecoder::new(icon_data::ALL_ICONS_COMPRESSED); + let mut all_decompressed = Vec::new(); + decoder + .read_to_end(&mut all_decompressed) + .map_err(|e| format!("Failed to decompress all icons: {}", e))?; + + // Leak the decompressed data to keep it alive for the lifetime of the program + let all_decompressed = Box::leak(all_decompressed.into_boxed_slice()); + + // Get icon metadata from build script generated module + let icon_metadata_map = icon_data::get_icon_metadata(); + + for (icon_name, theme_data) in icon_metadata_map { + // All current icons support themes + manager.theme_support.insert(icon_name.to_string(), true); + let mut themes_map = HashMap::new(); + + for (theme_str, group_info) in theme_data { + let theme = match theme_str { + "dark" => Theme::Dark, + "light" => Theme::Light, + _ => continue, // Skip unknown themes + }; + + // Extract this group's data from the big decompressed chunk + let mut icons = Vec::new(); + let mut current_offset = group_info.offset; + + for &size in group_info.sizes { + let size = size as usize; + if current_offset + size > all_decompressed.len() { + return Err(format!( + "Invalid icon offset/size data for {} {}", + icon_name, theme_str + ) + .into()); + } + + let icon_data = &all_decompressed[current_offset..current_offset + size]; + + let icon = { + #[cfg(windows)] + { + Icon::from_buffer(icon_data, None, None).map_err(|e| { + format!( + "Failed to create icon from buffer for {} {}: {}", + icon_name, theme_str, e + ) + })? + } + #[cfg(target_os = "macos")] + { + // macOS tray icons should be smaller (16x16 is the standard size) + Icon::from_buffer(icon_data, Some(16), Some(16)).map_err(|e| { + format!( + "Failed to create icon from buffer for {} {}: {}", + icon_name, theme_str, e + ) + })? + } + }; + + icons.push(icon); + current_offset += size; + } + + themes_map.insert(theme, icons); + } + + manager.icon_sets.insert(icon_name.to_string(), themes_map); + } + + Ok(manager) + } + + pub fn get_icon_set(&self, icon_name: &str, theme: Option) -> Option<&Vec> { + let icon_map = self.icon_sets.get(icon_name)?; + + if let Some(theme) = theme { + // Specific theme requested + icon_map.get(&theme) + } else { + // Auto-detect theme or use first available + if self.supports_themes(icon_name) { + let system_theme = Theme::from_system(); + icon_map + .get(&system_theme) + .or_else(|| icon_map.values().next()) // Fallback to any theme + } else { + icon_map.values().next() // Single theme icon + } + } + } + + pub fn supports_themes(&self, icon_name: &str) -> bool { + self.theme_support.get(icon_name).copied().unwrap_or(false) + } + + pub fn available_icons(&self) -> Vec { + let mut icons: Vec = self.icon_sets.keys().cloned().collect(); + icons.sort(); + icons + } + + pub fn available_themes_for_icon(&self, icon_name: &str) -> Vec { + if let Some(icon_map) = self.icon_sets.get(icon_name) { + let mut themes: Vec = icon_map.keys().copied().collect(); + themes.sort_by_key(|t| match t { + Theme::Dark => 0, + Theme::Light => 1, + }); + themes + } else { + vec![] + } + } + + // Migration support - convert old numeric IDs to string IDs + #[allow(dead_code)] + pub fn migrate_from_numeric_id(old_id: usize) -> String { + let is_cat = (old_id & 2) == 0; + + if is_cat { + "cat".to_string() + } else { + "parrot".to_string() + } + } + + #[allow(dead_code)] + pub fn get_theme_from_numeric_id(old_id: usize) -> Theme { + let is_dark = (old_id & 1) == 0; + if is_dark { + Theme::Dark + } else { + Theme::Light + } + } +} + +impl Default for IconManager { + fn default() -> Self { + Self::new() + } +} diff --git a/src/main.rs b/src/main.rs index 433631d..6379995 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,44 +1,45 @@ -#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] - -use windows::Win32::UI::WindowsAndMessaging::MB_OK; - -mod app; -mod cpu_usage; -mod events; -mod icon_manager; -mod settings; -mod windows_api; - -use crate::{ - app::App, - icon_manager::IconManager, - settings::{get_current_icon, get_current_theme, migrate_legacy_settings}, - windows_api::safe_message_box, -}; - -fn main() { - // Migrate legacy settings if needed - migrate_legacy_settings(); - - // Load icons - let icon_manager = IconManager::load_icons().expect("Failed to load icons"); - - // Get current icon and theme - let icon_name = get_current_icon(); - let theme = get_current_theme(); - - std::panic::set_hook(Box::new(|e| { - let msg = format!("Panic: {}", e); - if let Err(err) = safe_message_box(&msg, "RustCat Error", MB_OK.0) { - eprintln!("Failed to show panic dialog: {}", err); - } - })); - - let app = App::new(icon_manager, &icon_name, Some(theme)).expect("Failed to create app"); - - app.start_animation_thread(); - - app.run_message_loop(); - - app.shutdown(); -} +#![cfg_attr(all(not(debug_assertions), windows), windows_subsystem = "windows")] + +mod app; +mod events; +mod icon_manager; +mod platform; + +use crate::{ + icon_manager::IconManager, + platform::{SettingsManager, SystemIntegration, SettingsManagerImpl, SystemIntegrationImpl}, +}; + + +#[cfg(target_os = "macos")] +use crate::platform::macos::app::MacosApp; +#[cfg(windows)] +use crate::platform::windows::app::WindowsApp; + +fn main() { + // Migrate legacy settings if needed + SettingsManagerImpl::migrate_legacy_settings(); + + // Load icons + let icon_manager = IconManager::load_icons().expect("Failed to load icons"); + + // Get current icon and theme + let icon_name = SettingsManagerImpl::get_current_icon(); + let theme = SettingsManagerImpl::get_current_theme(); + + std::panic::set_hook(Box::new(|e| { + let msg = format!("Panic: {}", e); + if let Err(err) = SystemIntegrationImpl::show_dialog(&msg, "RustCat Error") { + eprintln!("Failed to show panic dialog: {}", err); + } + })); + + #[cfg(target_os = "windows")] + let app = WindowsApp::new(icon_manager, &icon_name, Some(theme)).expect("Failed to create app"); + #[cfg(target_os = "macos")] + let app = MacosApp::new(icon_manager, &icon_name, Some(theme)).expect("Failed to create app"); + + app.start_animation_thread(); + + app.run(); +} diff --git a/src/platform/macos/app.rs b/src/platform/macos/app.rs new file mode 100644 index 0000000..2f869cc --- /dev/null +++ b/src/platform/macos/app.rs @@ -0,0 +1,46 @@ +use crate::app::App; +use crate::icon_manager::{IconManager, Theme}; +use objc2::rc::Retained; +use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy}; +use objc2_foundation::MainThreadMarker; +use std::sync::Arc; + +pub struct MacosApp { + app: Arc, + ns_app: Retained, +} + +impl MacosApp { + pub fn new( + icon_manager: IconManager, + initial_icon: &str, + initial_theme: Option, + ) -> Result> { + // Initialize NSApplication for macOS + let mtm = unsafe { MainThreadMarker::new_unchecked() }; + let ns_app = NSApplication::sharedApplication(mtm); + ns_app.setActivationPolicy(NSApplicationActivationPolicy::Accessory); + + let app = Arc::new(App::new(icon_manager, initial_icon, initial_theme)?); + Ok(MacosApp { app, ns_app }) + } + + pub fn start_animation_thread(&self) { + self.app.start_animation_thread(); + } + + pub fn run(self) { + // Start the app event handler in a separate thread + let app = Arc::try_unwrap(self.app).unwrap_or_else(|_| { + panic!("Failed to unwrap Arc - multiple references exist"); + }); + std::thread::spawn(move || { + app.run(); + }); + + // Run the macOS application main loop + unsafe { + self.ns_app.run(); + } + } +} diff --git a/src/platform/macos/cpu_usage.rs b/src/platform/macos/cpu_usage.rs new file mode 100644 index 0000000..51c738a --- /dev/null +++ b/src/platform/macos/cpu_usage.rs @@ -0,0 +1,78 @@ +use crate::platform::CpuMonitor; +use std::io; +use std::sync::Mutex; + +pub struct MacosCpuMonitor; + +static CPU_STATE: Mutex> = Mutex::new(None); + +// macOS system types +#[repr(C)] +struct HostCpuLoadInfo { + cpu_ticks: [u32; 4], // CPU_STATE_USER, CPU_STATE_SYSTEM, CPU_STATE_IDLE, CPU_STATE_NICE +} + +const CPU_STATE_USER: usize = 0; +const CPU_STATE_SYSTEM: usize = 1; +const CPU_STATE_IDLE: usize = 2; +const CPU_STATE_NICE: usize = 3; + +const HOST_CPU_LOAD_INFO: i32 = 3; +const HOST_CPU_LOAD_INFO_COUNT: u32 = 4; + +extern "C" { + fn host_statistics( + host_priv: u32, + flavor: i32, + host_info_out: *mut HostCpuLoadInfo, + host_info_outCnt: *mut u32, + ) -> i32; + + fn mach_host_self() -> u32; +} + +impl CpuMonitor for MacosCpuMonitor { + fn get_cpu_usage() -> io::Result { + let mut cpu_info = HostCpuLoadInfo { cpu_ticks: [0; 4] }; + let mut count = HOST_CPU_LOAD_INFO_COUNT; + + let result = unsafe { + host_statistics( + mach_host_self(), + HOST_CPU_LOAD_INFO, + &mut cpu_info as *mut HostCpuLoadInfo, + &mut count, + ) + }; + + if result != 0 { + return Err(io::Error::other(format!( + "Failed to get CPU statistics: {}", + result + ))); + } + + let user_ticks = cpu_info.cpu_ticks[CPU_STATE_USER] as f64; + let system_ticks = cpu_info.cpu_ticks[CPU_STATE_SYSTEM] as f64; + let idle_ticks = cpu_info.cpu_ticks[CPU_STATE_IDLE] as f64; + let nice_ticks = cpu_info.cpu_ticks[CPU_STATE_NICE] as f64; + + let total_ticks = user_ticks + system_ticks + idle_ticks + nice_ticks; + + let mut state = CPU_STATE.lock().unwrap(); + let usage = if let Some((prev_total, prev_idle)) = *state { + let total_diff = total_ticks - prev_total; + let idle_diff = idle_ticks - prev_idle; + if total_diff > 0.0 { + 100.0 - (idle_diff / total_diff * 100.0) + } else { + 0.0 + } + } else { + 0.0 // First call, return 0 usage + }; + + *state = Some((total_ticks, idle_ticks)); + Ok(usage) + } +} diff --git a/src/platform/macos/mod.rs b/src/platform/macos/mod.rs new file mode 100644 index 0000000..1e1b5c0 --- /dev/null +++ b/src/platform/macos/mod.rs @@ -0,0 +1,8 @@ +pub mod app; +pub mod cpu_usage; +pub mod settings; +pub mod system_integration; + +pub use cpu_usage::MacosCpuMonitor; +pub use settings::MacosSettingsManager; +pub use system_integration::MacosSystemIntegration; diff --git a/src/platform/macos/settings.rs b/src/platform/macos/settings.rs new file mode 100644 index 0000000..7f1453c --- /dev/null +++ b/src/platform/macos/settings.rs @@ -0,0 +1,140 @@ +use crate::icon_manager::Theme; +use crate::platform::SettingsManager; +use std::fs; +use std::path::PathBuf; +use std::process::Command; + +pub struct MacosSettingsManager; + +impl SettingsManager for MacosSettingsManager { + fn get_current_icon() -> String { + get_preference("IconName").unwrap_or_else(|| "cat".to_string()) + } + + fn set_current_icon(icon_name: &str) { + set_preference("IconName", icon_name); + } + + fn get_current_theme() -> Theme { + if let Some(theme_str) = get_preference("Theme") { + match theme_str.as_str() { + "dark" => Theme::Dark, + "light" => Theme::Light, + _ => Theme::from_system(), + } + } else { + Theme::from_system() + } + } + + fn set_current_theme(theme: Option) { + match theme { + Some(theme) => set_preference("Theme", &theme.to_string()), + None => remove_preference("Theme"), + } + } + + fn is_run_on_start_enabled() -> bool { + let plist_path = dirs::home_dir() + .unwrap_or_else(|| PathBuf::from("/tmp")) + .join("Library/LaunchAgents/com.bearice.rustcat.plist"); + plist_path.exists() + } + + fn set_run_on_start(enable: bool) { + let launch_agents_dir = dirs::home_dir() + .unwrap_or_else(|| PathBuf::from("/tmp")) + .join("Library/LaunchAgents"); + + if !launch_agents_dir.exists() { + if let Err(e) = fs::create_dir_all(&launch_agents_dir) { + eprintln!("Failed to create LaunchAgents directory: {}", e); + return; + } + } + + let plist_path = launch_agents_dir.join("com.bearice.rustcat.plist"); + + if enable { + if let Ok(exe_path) = std::env::current_exe() { + let plist_content = format!( + r#" + + + + Label + com.bearice.rustcat + ProgramArguments + + {} + + RunAtLoad + + KeepAlive + + LSUIElement + + +"#, + exe_path.display() + ); + + if let Err(e) = fs::write(&plist_path, plist_content) { + eprintln!("Failed to write launch agent plist: {}", e); + } + } + } else { + if let Err(e) = fs::remove_file(&plist_path) { + if e.kind() != std::io::ErrorKind::NotFound { + eprintln!("Failed to remove launch agent plist: {}", e); + } + } + } + } + + fn is_dark_mode_enabled() -> bool { + let output = Command::new("defaults") + .args(&["read", "-g", "AppleInterfaceStyle"]) + .output(); + + if let Ok(output) = output { + let result = String::from_utf8_lossy(&output.stdout); + result.trim() == "Dark" + } else { + false + } + } + + fn migrate_legacy_settings() { + // No legacy settings to migrate on macOS + } +} + +fn get_preference(key: &str) -> Option { + let output = Command::new("defaults") + .args(&["read", "com.bearice.rustcat", key]) + .output(); + + if let Ok(output) = output { + if output.status.success() { + let result = String::from_utf8_lossy(&output.stdout); + Some(result.trim().to_string()) + } else { + None + } + } else { + None + } +} + +fn set_preference(key: &str, value: &str) { + let _ = Command::new("defaults") + .args(&["write", "com.bearice.rustcat", key, value]) + .status(); +} + +fn remove_preference(key: &str) { + let _ = Command::new("defaults") + .args(&["delete", "com.bearice.rustcat", key]) + .status(); +} diff --git a/src/platform/macos/system_integration.rs b/src/platform/macos/system_integration.rs new file mode 100644 index 0000000..11e24a0 --- /dev/null +++ b/src/platform/macos/system_integration.rs @@ -0,0 +1,26 @@ +use crate::platform::SystemIntegration; +use std::process::Command; + +pub struct MacosSystemIntegration; + +impl SystemIntegration for MacosSystemIntegration { + fn show_dialog(message: &str, title: &str) -> Result<(), Box> { + Command::new("osascript") + .arg("-e") + .arg(&format!( + r#"display dialog "{}" with title "{}" buttons {{"OK"}} default button "OK""#, + message.replace("\"", "\\\""), + title.replace("\"", "\\\"") + )) + .spawn()?; + Ok(()) + } + + fn open_system_monitor() -> Result<(), Box> { + Command::new("open") + .arg("-a") + .arg("Activity Monitor") + .spawn()?; + Ok(()) + } +} diff --git a/src/platform/mod.rs b/src/platform/mod.rs new file mode 100644 index 0000000..e2abd33 --- /dev/null +++ b/src/platform/mod.rs @@ -0,0 +1,46 @@ +use std::io; + +#[cfg(target_os = "macos")] +pub mod macos; +#[cfg(windows)] +pub mod windows; + +/// Cross-platform CPU usage monitoring trait +pub trait CpuMonitor { + /// Returns CPU usage percentage as a float (0.0 to 100.0) + fn get_cpu_usage() -> io::Result; +} + +/// Cross-platform settings management trait +pub trait SettingsManager { + fn get_current_icon() -> String; + fn set_current_icon(icon_name: &str); + fn get_current_theme() -> crate::icon_manager::Theme; + fn set_current_theme(theme: Option); + fn is_run_on_start_enabled() -> bool; + fn set_run_on_start(enable: bool); + fn is_dark_mode_enabled() -> bool; + fn migrate_legacy_settings(); +} + +/// Cross-platform system integration trait +pub trait SystemIntegration { + fn show_dialog(message: &str, title: &str) -> Result<(), Box>; + fn open_system_monitor() -> Result<(), Box>; +} + +/// Platform-specific implementation type aliases +#[cfg(windows)] +pub type CpuMonitorImpl = windows::WindowsCpuMonitor; +#[cfg(target_os = "macos")] +pub type CpuMonitorImpl = macos::MacosCpuMonitor; + +#[cfg(windows)] +pub type SettingsManagerImpl = windows::WindowsSettingsManager; +#[cfg(target_os = "macos")] +pub type SettingsManagerImpl = macos::MacosSettingsManager; + +#[cfg(windows)] +pub type SystemIntegrationImpl = windows::WindowsSystemIntegration; +#[cfg(target_os = "macos")] +pub type SystemIntegrationImpl = macos::MacosSystemIntegration; diff --git a/src/platform/windows/app.rs b/src/platform/windows/app.rs new file mode 100644 index 0000000..12ef3c2 --- /dev/null +++ b/src/platform/windows/app.rs @@ -0,0 +1,30 @@ +use crate::app::App; +use crate::icon_manager::{IconManager, Theme}; +use crate::platform::{PlatformSettingsManager, SettingsManager}; + +pub struct WindowsApp { + app: App, +} + +impl WindowsApp { + pub fn new( + icon_manager: IconManager, + initial_icon: &str, + initial_theme: Option, + ) -> Result> { + let app = App::new(icon_manager, initial_icon, initial_theme)?; + Ok(WindowsApp { app }) + } + + pub fn start_animation_thread(&self) { + self.app.start_animation_thread(); + } + + pub fn run(self) { + self.app.run(); + } + + pub fn shutdown(&self) { + self.app.shutdown(); + } +} diff --git a/src/platform/windows/cpu_usage.rs b/src/platform/windows/cpu_usage.rs new file mode 100644 index 0000000..520d5e2 --- /dev/null +++ b/src/platform/windows/cpu_usage.rs @@ -0,0 +1,59 @@ +use crate::platform::CpuMonitor; +use std::io; +use std::sync::Mutex; +use windows::{Win32::Foundation::FILETIME, Win32::System::Threading::GetSystemTimes}; + +pub struct WindowsCpuMonitor; + +static CPU_STATE: Mutex> = Mutex::new(None); + +impl CpuMonitor for WindowsCpuMonitor { + fn get_cpu_usage() -> io::Result { + let mut idle_time = empty(); + let mut kernel_time = empty(); + let mut user_time = empty(); + unsafe { + GetSystemTimes( + Some(&mut idle_time), + Some(&mut kernel_time), + Some(&mut user_time), + ) + .map_err(|e| io::Error::other(format!("Failed to get system times: {}", e)))?; + } + let idle_time = filetime_to_u64(idle_time) as f64; + let kernel_time = filetime_to_u64(kernel_time) as f64; + let user_time = filetime_to_u64(user_time) as f64; + let total_time = kernel_time + user_time; + + let mut state = CPU_STATE.lock().unwrap(); + let usage = if let Some((prev_total, prev_idle)) = *state { + let total_diff = total_time - prev_total; + let idle_diff = idle_time - prev_idle; + if total_diff > 0.0 { + 100.0 - (idle_diff / total_diff * 100.0) + } else { + 0.0 + } + } else { + 0.0 // First call, return 0 usage + }; + + *state = Some((total_time, idle_time)); + Ok(usage) + } +} + +/// Essentailly a no-op +#[inline(always)] +fn filetime_to_u64(f: FILETIME) -> u64 { + (f.dwHighDateTime as u64) << 32 | (f.dwLowDateTime as u64) +} + +/// Empty FILETIME +#[inline(always)] +fn empty() -> FILETIME { + FILETIME { + dwLowDateTime: 0, + dwHighDateTime: 0, + } +} diff --git a/src/platform/windows/mod.rs b/src/platform/windows/mod.rs new file mode 100644 index 0000000..6518e71 --- /dev/null +++ b/src/platform/windows/mod.rs @@ -0,0 +1,8 @@ +pub mod app; +pub mod cpu_usage; +pub mod settings; +pub mod system_integration; + +pub use cpu_usage::WindowsCpuMonitor; +pub use settings::WindowsSettingsManager; +pub use system_integration::WindowsSystemIntegration; diff --git a/src/platform/windows/settings.rs b/src/platform/windows/settings.rs new file mode 100644 index 0000000..9bdc4e6 --- /dev/null +++ b/src/platform/windows/settings.rs @@ -0,0 +1,167 @@ +use crate::icon_manager::{IconManager, Theme}; +use crate::platform::SettingsManager; +use winreg::enums::*; +use winreg::RegKey; + +pub struct WindowsSettingsManager; + +impl SettingsManager for WindowsSettingsManager { + fn get_current_icon() -> String { + let key = RegKey::predef(HKEY_CURRENT_USER); + if let Ok(sub_key) = key.open_subkey_with_flags("Software\\RustCat", KEY_READ) { + if let Ok(icon_name) = sub_key.get_value::("IconName") { + return icon_name; + } + } + + // Default icon + "cat".to_string() + } + + fn set_current_icon(icon_name: &str) { + let key = RegKey::predef(HKEY_CURRENT_USER); + let sub_key = if let Ok(sub_key) = + key.open_subkey_with_flags("Software\\RustCat", KEY_WRITE | KEY_READ) + { + sub_key + } else { + key.create_subkey_with_flags("Software\\RustCat", KEY_WRITE | KEY_READ) + .expect("create_subkey_with_flags") + .0 + }; + + sub_key + .set_value("IconName", &icon_name) + .expect("set_value"); + } + + fn get_current_theme() -> Theme { + let key = RegKey::predef(HKEY_CURRENT_USER); + if let Ok(sub_key) = key.open_subkey_with_flags("Software\\RustCat", KEY_READ) { + if let Ok(theme_str) = sub_key.get_value::("Theme") { + match theme_str.as_str() { + "dark" => return Theme::Dark, + "light" => return Theme::Light, + _ => {} // Fall through to auto-detect + } + } + } + + // Auto-detect from system + Theme::from_system() + } + + fn set_current_theme(theme: Option) { + let key = RegKey::predef(HKEY_CURRENT_USER); + let sub_key = if let Ok(sub_key) = + key.open_subkey_with_flags("Software\\RustCat", KEY_WRITE | KEY_READ) + { + sub_key + } else { + key.create_subkey_with_flags("Software\\RustCat", KEY_WRITE | KEY_READ) + .expect("create_subkey_with_flags") + .0 + }; + + match theme { + Some(theme) => { + sub_key + .set_value("Theme", &theme.to_string()) + .expect("set_value"); + } + None => { + // Auto-detect - remove explicit preference + let _ = sub_key.delete_value("Theme"); // Ignore errors if doesn't exist + } + } + } + + fn is_run_on_start_enabled() -> bool { + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + if let Ok(run_key) = hkcu.open_subkey_with_flags( + "Software\\Microsoft\\Windows\\CurrentVersion\\Run", + KEY_READ, + ) { + if run_key.get_value::("RustCat").is_ok() { + return true; + } + } + false + } + + fn set_run_on_start(enable: bool) { + use std::env; + + const RUN_KEY_PATH: &str = "Software\\Microsoft\\Windows\\CurrentVersion\\Run"; + const VALUE_NAME: &str = "RustCat"; + + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + + match hkcu.open_subkey_with_flags(RUN_KEY_PATH, KEY_WRITE | KEY_READ) { + Ok(run_key) => { + if enable { + match env::current_exe() { + Ok(exe_path) => { + let exe_path_str = exe_path.to_string_lossy().to_string(); + if let Err(e) = run_key.set_value(VALUE_NAME, &exe_path_str) { + eprintln!("Failed to set registry value '{}': {}", VALUE_NAME, e); + } + } + Err(e) => { + eprintln!("Failed to get current executable path: {}", e); + } + } + } else if let Err(e) = run_key.delete_value(VALUE_NAME) { + eprintln!("Failed to delete registry value '{}' (this may be okay if it didn't exist): {}", VALUE_NAME, e); + } + } + Err(e) => { + eprintln!( + "Failed to open or create registry subkey '{}': {}", + RUN_KEY_PATH, e + ); + } + } + } + + fn is_dark_mode_enabled() -> bool { + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + if let Ok(subkey) = + hkcu.open_subkey("Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize") + { + if let Ok(dword) = subkey.get_value::("AppsUseLightTheme") { + dword == 0 + } else { + false + } + } else { + false + } + } + + fn migrate_legacy_settings() { + let key = RegKey::predef(HKEY_CURRENT_USER); + if let Ok(sub_key) = key.open_subkey_with_flags("Software\\RustCat", KEY_READ) { + // Check if migration is needed + if let Ok(old_id) = sub_key.get_value::("IconId") { + // Convert old numeric ID to new string-based system + let icon_name = IconManager::migrate_from_numeric_id(old_id as usize); + let theme = IconManager::get_theme_from_numeric_id(old_id as usize); + + // Save new settings + Self::set_current_icon(&icon_name); + Self::set_current_theme(Some(theme)); + + // Remove old numeric ID + if let Ok(write_key) = key.open_subkey_with_flags("Software\\RustCat", KEY_WRITE) { + let _ = write_key.delete_value("IconId"); + } + + println!( + "Migrated from legacy IconId {} to IconName: {}, Theme: {}", + old_id, icon_name, theme + ); + } + } + } +} diff --git a/src/platform/windows/system_integration.rs b/src/platform/windows/system_integration.rs new file mode 100644 index 0000000..679b3c9 --- /dev/null +++ b/src/platform/windows/system_integration.rs @@ -0,0 +1,36 @@ +use crate::platform::SystemIntegration; +use std::process::Command; +use windows::{core::*, Win32::Foundation::HWND, Win32::UI::WindowsAndMessaging::*}; + +pub struct WindowsSystemIntegration; + +impl SystemIntegration for WindowsSystemIntegration { + fn show_dialog(message: &str, title: &str) -> Result<(), Box> { + safe_message_box(message, title, MB_OK.0)?; + Ok(()) + } + + fn open_system_monitor() -> Result<(), Box> { + Command::new("taskmgr").spawn()?; + Ok(()) + } +} + +fn safe_message_box( + message: &str, + title: &str, + flags: u32, +) -> Result<(), Box> { + unsafe { + let result = MessageBoxW( + Some(HWND::default()), + &HSTRING::from(message), + &HSTRING::from(title), + MESSAGEBOX_STYLE(flags), + ); + if result.0 == 0 { + return Err("MessageBoxW failed".into()); + } + } + Ok(()) +} diff --git a/src/settings.rs b/src/settings.rs deleted file mode 100644 index 6b07143..0000000 --- a/src/settings.rs +++ /dev/null @@ -1,171 +0,0 @@ -use winreg::RegKey; -use winreg::enums::*; - -pub fn is_run_on_start_enabled() -> bool { - let hkcu = RegKey::predef(HKEY_CURRENT_USER); - if let Ok(run_key) = hkcu.open_subkey_with_flags( - "Software\\Microsoft\\Windows\\CurrentVersion\\Run", - KEY_READ, - ) { - if run_key.get_value::("RustCat").is_ok() { - return true; - } - } - false -} - -pub fn set_run_on_start(enable: bool) { - use std::env; - - const RUN_KEY_PATH: &str = "Software\\Microsoft\\Windows\\CurrentVersion\\Run"; - const VALUE_NAME: &str = "RustCat"; - - let hkcu = RegKey::predef(HKEY_CURRENT_USER); - - match hkcu.open_subkey_with_flags(RUN_KEY_PATH, KEY_WRITE | KEY_READ) { - Ok(run_key) => { - if enable { - match env::current_exe() { - Ok(exe_path) => { - let exe_path_str = exe_path.to_string_lossy().to_string(); - if let Err(e) = run_key.set_value(VALUE_NAME, &exe_path_str) { - eprintln!("Failed to set registry value '{}': {}", VALUE_NAME, e); - } - } - Err(e) => { - eprintln!("Failed to get current executable path: {}", e); - } - } - } else if let Err(e) = run_key.delete_value(VALUE_NAME) { - eprintln!("Failed to delete registry value '{}' (this may be okay if it didn't exist): {}", VALUE_NAME, e); - } - } - Err(e) => { - eprintln!( - "Failed to open or create registry subkey '{}': {}", - RUN_KEY_PATH, e - ); - } - } -} - -use crate::icon_manager::{IconManager, Theme}; - -// One-time migration function to convert old numeric IDs to string-based system -pub fn migrate_legacy_settings() { - let key = RegKey::predef(HKEY_CURRENT_USER); - if let Ok(sub_key) = key.open_subkey_with_flags("Software\\RustCat", KEY_READ) { - // Check if migration is needed - if let Ok(old_id) = sub_key.get_value::("IconId") { - // Convert old numeric ID to new string-based system - let icon_name = IconManager::migrate_from_numeric_id(old_id as usize); - let theme = IconManager::get_theme_from_numeric_id(old_id as usize); - - // Save new settings - set_current_icon(&icon_name); - set_current_theme(Some(theme)); - - // Remove old numeric ID - if let Ok(write_key) = key.open_subkey_with_flags("Software\\RustCat", KEY_WRITE) { - let _ = write_key.delete_value("IconId"); - } - - println!("Migrated from legacy IconId {} to IconName: {}, Theme: {}", old_id, icon_name, theme); - } - } -} - -// String-based icon ID functions -pub fn get_current_icon() -> String { - let key = RegKey::predef(HKEY_CURRENT_USER); - if let Ok(sub_key) = key.open_subkey_with_flags("Software\\RustCat", KEY_READ) { - if let Ok(icon_name) = sub_key.get_value::("IconName") { - return icon_name; - } - } - - // Default icon - "cat".to_string() -} - -pub fn set_current_icon(icon_name: &str) { - let key = RegKey::predef(HKEY_CURRENT_USER); - let sub_key = if let Ok(sub_key) = key.open_subkey_with_flags( - "Software\\RustCat", - KEY_WRITE | KEY_READ, - ) { - sub_key - } else { - key.create_subkey_with_flags( - "Software\\RustCat", - KEY_WRITE | KEY_READ, - ) - .expect("create_subkey_with_flags") - .0 - }; - - sub_key - .set_value("IconName", &icon_name) - .expect("set_value"); -} - -pub fn get_current_theme() -> Theme { - let key = RegKey::predef(HKEY_CURRENT_USER); - if let Ok(sub_key) = key.open_subkey_with_flags("Software\\RustCat", KEY_READ) { - if let Ok(theme_str) = sub_key.get_value::("Theme") { - match theme_str.as_str() { - "dark" => return Theme::Dark, - "light" => return Theme::Light, - _ => {} // Fall through to auto-detect - } - } - } - - // Auto-detect from system - Theme::from_system() -} - -pub fn set_current_theme(theme: Option) { - let key = RegKey::predef(HKEY_CURRENT_USER); - let sub_key = if let Ok(sub_key) = key.open_subkey_with_flags( - "Software\\RustCat", - KEY_WRITE | KEY_READ, - ) { - sub_key - } else { - key.create_subkey_with_flags( - "Software\\RustCat", - KEY_WRITE | KEY_READ, - ) - .expect("create_subkey_with_flags") - .0 - }; - - match theme { - Some(theme) => { - sub_key - .set_value("Theme", &theme.to_string()) - .expect("set_value"); - } - None => { - // Auto-detect - remove explicit preference - let _ = sub_key.delete_value("Theme"); // Ignore errors if doesn't exist - } - } -} - - -pub fn is_dark_mode_enabled() -> bool { - let hkcu = RegKey::predef(HKEY_CURRENT_USER); - if let Ok(subkey) = - hkcu.open_subkey("Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize") - { - if let Ok(dword) = subkey.get_value::("AppsUseLightTheme") { - dword == 0 - } else { - false - } - } else { - false - } -} \ No newline at end of file diff --git a/src/windows_api.rs b/src/windows_api.rs deleted file mode 100644 index b56a987..0000000 --- a/src/windows_api.rs +++ /dev/null @@ -1,52 +0,0 @@ -use windows::{ - core::*, - Win32::Foundation::HWND, - Win32::UI::Shell::*, - Win32::UI::WindowsAndMessaging::*, -}; - -pub fn safe_message_box(message: &str, title: &str, flags: u32) -> std::result::Result<(), Box> { - unsafe { - let result = MessageBoxW( - Some(HWND::default()), - &HSTRING::from(message), - &HSTRING::from(title), - MESSAGEBOX_STYLE(flags), - ); - if result.0 == 0 { - return Err("MessageBoxW failed".into()); - } - } - Ok(()) -} - -pub fn safe_shell_execute(file: &str) -> std::result::Result<(), Box> { - unsafe { - let ret = ShellExecuteW( - Some(HWND::default()), - None, - &HSTRING::from(file), - None, - None, - SW_SHOWNORMAL, - ); - if ret.0 as usize <= 32 { - return Err(format!("ShellExecute failed with code: {}", ret.0 as usize).into()); - } - } - Ok(()) -} - -pub fn safe_message_loop() -> std::result::Result<(), Box> { - unsafe { - let mut msg = std::mem::zeroed(); - let bret = GetMessageA(&mut msg, None, 0, 0); - if bret.as_bool() { - let _ = TranslateMessage(&msg); - DispatchMessageA(&msg); - Ok(()) - } else { - Err("GetMessageA returned 0 (WM_QUIT)".into()) - } - } -} \ No newline at end of file From 09463e460331b3aa4f709b8550643f488208b54c Mon Sep 17 00:00:00 2001 From: Bearice Ren Date: Wed, 9 Jul 2025 01:40:30 +0900 Subject: [PATCH 2/3] Add macOS build script and simplify CI workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Changes ### πŸ› οΈ macOS Build Script (`build_macos.sh`) - Complete automation of macOS build process - Multi-architecture support (Intel + Apple Silicon) - Universal binary creation with `lipo` - App bundle creation with proper structure - DMG generation for professional distribution - Icon creation from existing assets - Verbose output with progress indicators ### πŸš€ Simplified CI Workflow - Removed complex matrix strategy for single Windows target - Replaced complex universal build logic with simple script call - Cleaner job names: `build-windows` and `build-macos` - Reduced YAML complexity from ~80 to ~40 lines - Better maintainability and local testing support ### πŸ“ Updated .gitignore - Added comprehensive macOS build artifact exclusions - Covers app bundles, DMG files, universal binaries - Excludes system files like .DS_Store - Prevents accidental commit of build outputs The build process is now much simpler to understand and maintain, with all macOS-specific logic contained in a reusable shell script. πŸ€– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .DS_Store | Bin 6148 -> 6148 bytes .github/workflows/rust.yaml | 99 ++++++------------------------------ .gitignore | 28 +++++++++- build_macos.sh | 94 ++++++++++++++++++++++++++++++++++ 4 files changed, 137 insertions(+), 84 deletions(-) create mode 100755 build_macos.sh diff --git a/.DS_Store b/.DS_Store index 9687fc5087d617626227c224738dd0bbd1dd9dd3..ea422c6009187ff9ff99e3add02bb03ebbe3920a 100644 GIT binary patch delta 81 zcmZoMXffEJ#>^~u{_*7hOj07b`7SO=Ir&Kp3=A9_qoaxr@Ev!AN~B~ud?#AT*Oj07b`7SO=Ir&Kp3=AAEzGVI0!*|>fDv^R!V)A@uUM7wY On>R9tu`Fic5C8z|LmoW< diff --git a/.github/workflows/rust.yaml b/.github/workflows/rust.yaml index ca77cbc..2d0d0a3 100644 --- a/.github/workflows/rust.yaml +++ b/.github/workflows/rust.yaml @@ -11,21 +11,9 @@ on: jobs: #run build first to populate caches - build: - name: Build binary - strategy: - matrix: - include: - - os: windows-latest - target: x86_64-pc-windows-msvc - artifact: rust_cat.exe - - os: macos-latest - target: x86_64-apple-darwin - artifact: rust_cat - - os: macos-latest - target: aarch64-apple-darwin - artifact: rust_cat - runs-on: ${{ matrix.os }} + build-windows: + name: Build Windows binary + runs-on: windows-latest steps: - uses: actions/checkout@v4 - uses: actions/cache@v4 @@ -42,13 +30,10 @@ jobs: profile: minimal toolchain: stable override: true - target: ${{ matrix.target }} - - name: Install target - run: rustup target add ${{ matrix.target }} - uses: actions-rs/cargo@v1 with: command: build - args: --workspace --all-targets --all-features --release --target ${{ matrix.target }} + args: --workspace --all-targets --all-features --release @@ -60,91 +45,39 @@ jobs: echo ::set-output name=IS_RELEASE::${IS_RELEASE} - name: Artifact upload (Windows) - if: matrix.os == 'windows-latest' uses: actions/upload-artifact@master with: - name: rust_cat-${{ matrix.target }}.exe - path: target/${{ matrix.target }}/release/rust_cat.exe + name: rust_cat.exe + path: target/release/rust_cat.exe - - name: Artifact upload (macOS binary) - if: matrix.os == 'macos-latest' - uses: actions/upload-artifact@master - with: - name: rust_cat-${{ matrix.target }} - path: target/${{ matrix.target }}/release/rust_cat - name: Publish archives and packages (Windows) uses: softprops/action-gh-release@v1 - if: steps.is-release.outputs.IS_RELEASE && matrix.os == 'windows-latest' + if: steps.is-release.outputs.IS_RELEASE with: files: | - target/${{ matrix.target }}/release/rust_cat.exe + target/release/rust_cat.exe env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Publish archives and packages (macOS) - uses: softprops/action-gh-release@v1 - if: steps.is-release.outputs.IS_RELEASE && matrix.os == 'macos-latest' - with: - files: | - target/${{ matrix.target }}/release/rust_cat - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Create universal macOS app bundle - universal-macos: + build-macos: name: Create universal macOS app bundle runs-on: macos-latest - needs: build if: github.event_name != 'pull_request' || github.actor != 'dependabot[bot]' steps: - uses: actions/checkout@v4 - - name: Download Intel binaries - uses: actions/download-artifact@v3 - with: - name: rust_cat-x86_64-apple-darwin - path: ./x86_64/ - - - name: Download Apple Silicon binaries - uses: actions/download-artifact@v3 + - uses: actions-rs/toolchain@v1 with: - name: rust_cat-aarch64-apple-darwin - path: ./aarch64/ - - - name: Create universal binary - run: | - # Create universal binary using lipo - lipo -create -output rust_cat_universal ./x86_64/rust_cat ./aarch64/rust_cat - - # Verify the universal binary - lipo -info rust_cat_universal - file rust_cat_universal - - - name: Create universal app bundle - run: | - # Create app icon - ./build_app_icon.sh || true - - # Create universal app bundle - mkdir -p RustCat.app/Contents/MacOS - mkdir -p RustCat.app/Contents/Resources - - # Copy universal binary - cp rust_cat_universal RustCat.app/Contents/MacOS/rust_cat - cp Info.plist RustCat.app/Contents/ - - # Copy app icon if it was created - if [ -f "RustCat.icns" ]; then - cp RustCat.icns RustCat.app/Contents/Resources/AppIcon.icns - fi - - # Create zip archive - zip -r RustCat-universal.app.zip RustCat.app + profile: minimal + toolchain: stable + override: true - # Create DMG - ./create_dmg.sh RustCat.app RustCat-universal.dmg + - name: Run macOS build script + run: ./build_macos.sh - name: Check for release id: is-release @@ -178,7 +111,7 @@ jobs: # Auto-approve Dependabot PRs if build succeeds dependabot-auto-approve: runs-on: windows-latest - needs: build + needs: [build-windows, build-macos] if: github.actor == 'dependabot[bot]' && github.event_name == 'pull_request' steps: - name: Auto-approve PR diff --git a/.gitignore b/.gitignore index 98aacae..cf6daac 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,28 @@ /target -.claude \ No newline at end of file +.claude + +# macOS build artifacts +*.app +*.dmg +*.app.zip +*-universal.app +*-universal.dmg +*-universal.app.zip +rust_cat_universal +*.icns +*.iconset/ + +# macOS system files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Build artifacts +RustCat.app/ +RustCat-*.app/ +RustCat-*.dmg +RustCat-*.app.zip \ No newline at end of file diff --git a/build_macos.sh b/build_macos.sh new file mode 100755 index 0000000..e2104c9 --- /dev/null +++ b/build_macos.sh @@ -0,0 +1,94 @@ +#!/bin/bash + +# macOS build script for RustCat +# This script handles all macOS-specific build tasks including: +# - Building for both Intel and Apple Silicon +# - Creating universal binaries +# - Building app bundles +# - Creating DMG images + +set -e + +echo "🍎 Starting macOS build process..." + +# Configuration +APP_NAME="RustCat" +BUNDLE_ID="com.bearice.rustcat" +VERSION=$(grep '^version' Cargo.toml | sed 's/version = "\(.*\)"/\1/') + +# Build targets +INTEL_TARGET="x86_64-apple-darwin" +ARM_TARGET="aarch64-apple-darwin" + +# Ensure targets are installed +echo "πŸ“¦ Installing build targets..." +rustup target add $INTEL_TARGET +rustup target add $ARM_TARGET + +# Build for both architectures +echo "πŸ”¨ Building for Intel (x86_64)..." +cargo build --release --target $INTEL_TARGET + +echo "πŸ”¨ Building for Apple Silicon (aarch64)..." +cargo build --release --target $ARM_TARGET + +# Create universal binary +echo "πŸ”— Creating universal binary..." +lipo -create -output rust_cat_universal \ + target/$INTEL_TARGET/release/rust_cat \ + target/$ARM_TARGET/release/rust_cat + +# Verify universal binary +echo "βœ… Verifying universal binary..." +lipo -info rust_cat_universal +file rust_cat_universal + +# Create app icon +echo "🎨 Creating app icon..." +./build_app_icon.sh || echo "⚠️ Could not create app icon, continuing without it" + +# Create app bundle +echo "πŸ“± Creating app bundle..." +APP_BUNDLE="${APP_NAME}.app" +mkdir -p "$APP_BUNDLE/Contents/MacOS" +mkdir -p "$APP_BUNDLE/Contents/Resources" + +# Copy binary +cp rust_cat_universal "$APP_BUNDLE/Contents/MacOS/rust_cat" + +# Copy Info.plist +cp Info.plist "$APP_BUNDLE/Contents/" + +# Copy app icon if it exists +if [ -f "RustCat.icns" ]; then + cp RustCat.icns "$APP_BUNDLE/Contents/Resources/AppIcon.icns" + echo "βœ… App icon added" +else + echo "⚠️ No app icon found" +fi + +# Make binary executable +chmod +x "$APP_BUNDLE/Contents/MacOS/rust_cat" + +# Create ZIP archive +echo "πŸ“¦ Creating ZIP archive..." +zip -r "${APP_NAME}-universal.app.zip" "$APP_BUNDLE" + +# Create DMG +echo "πŸ’Ώ Creating DMG..." +./create_dmg.sh "$APP_BUNDLE" "${APP_NAME}-universal.dmg" + +# Clean up temporary files +echo "🧹 Cleaning up..." +rm -f rust_cat_universal +rm -f RustCat.icns +rm -rf RustCat.iconset + +echo "βœ… macOS build complete\!" +echo "" +echo "πŸ“¦ Created files:" +echo " - ${APP_NAME}-universal.app.zip (Universal app bundle)" +echo " - ${APP_NAME}-universal.dmg (DMG installer)" +echo "" +echo "πŸ” Universal binary info:" +lipo -info "$APP_BUNDLE/Contents/MacOS/rust_cat" From 35b4803feb23297c07ad8d4f29f4a1ca73853cd7 Mon Sep 17 00:00:00 2001 From: Bearice Ren Date: Wed, 9 Jul 2025 01:50:17 +0900 Subject: [PATCH 3/3] Fix Windows build errors on feature/macos-support branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove unused PlatformSettingsManager import from app.rs - Remove unused shutdown method from WindowsApp - Fix return type conflicts with windows crate's Result type - Fix string error conversion in MessageBoxW failure handling - Remove wildcard imports in system_integration.rs πŸ€– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/app.rs | 2 +- src/main.rs | 3 +- src/platform/macos/cpu_usage.rs | 2 +- src/platform/windows/app.rs | 55 ++++++++++------------ src/platform/windows/cpu_usage.rs | 4 +- src/platform/windows/system_integration.rs | 9 ++-- 6 files changed, 36 insertions(+), 39 deletions(-) diff --git a/src/app.rs b/src/app.rs index 5ddf20d..3ddf9ee 100644 --- a/src/app.rs +++ b/src/app.rs @@ -28,7 +28,7 @@ impl App { let icon_manager = Arc::new(icon_manager); let exit_flag = Arc::new(AtomicBool::new(false)); - let theme = initial_theme.unwrap_or_else(|| SettingsManagerImpl::get_current_theme()); + let theme = initial_theme.unwrap_or_else(SettingsManagerImpl::get_current_theme); let initial_icons = icon_manager .get_icon_set(initial_icon, Some(theme)) .ok_or("Invalid initial icon name")?; diff --git a/src/main.rs b/src/main.rs index 6379995..4150bd2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,10 +7,9 @@ mod platform; use crate::{ icon_manager::IconManager, - platform::{SettingsManager, SystemIntegration, SettingsManagerImpl, SystemIntegrationImpl}, + platform::{SettingsManager, SettingsManagerImpl, SystemIntegration, SystemIntegrationImpl}, }; - #[cfg(target_os = "macos")] use crate::platform::macos::app::MacosApp; #[cfg(windows)] diff --git a/src/platform/macos/cpu_usage.rs b/src/platform/macos/cpu_usage.rs index 51c738a..1c02c77 100644 --- a/src/platform/macos/cpu_usage.rs +++ b/src/platform/macos/cpu_usage.rs @@ -71,7 +71,7 @@ impl CpuMonitor for MacosCpuMonitor { } else { 0.0 // First call, return 0 usage }; - + *state = Some((total_ticks, idle_ticks)); Ok(usage) } diff --git a/src/platform/windows/app.rs b/src/platform/windows/app.rs index 12ef3c2..ddeb0bb 100644 --- a/src/platform/windows/app.rs +++ b/src/platform/windows/app.rs @@ -1,30 +1,25 @@ -use crate::app::App; -use crate::icon_manager::{IconManager, Theme}; -use crate::platform::{PlatformSettingsManager, SettingsManager}; - -pub struct WindowsApp { - app: App, -} - -impl WindowsApp { - pub fn new( - icon_manager: IconManager, - initial_icon: &str, - initial_theme: Option, - ) -> Result> { - let app = App::new(icon_manager, initial_icon, initial_theme)?; - Ok(WindowsApp { app }) - } - - pub fn start_animation_thread(&self) { - self.app.start_animation_thread(); - } - - pub fn run(self) { - self.app.run(); - } - - pub fn shutdown(&self) { - self.app.shutdown(); - } -} +use crate::app::App; +use crate::icon_manager::{IconManager, Theme}; + +pub struct WindowsApp { + app: App, +} + +impl WindowsApp { + pub fn new( + icon_manager: IconManager, + initial_icon: &str, + initial_theme: Option, + ) -> Result> { + let app = App::new(icon_manager, initial_icon, initial_theme)?; + Ok(WindowsApp { app }) + } + + pub fn start_animation_thread(&self) { + self.app.start_animation_thread(); + } + + pub fn run(self) { + self.app.run(); + } +} diff --git a/src/platform/windows/cpu_usage.rs b/src/platform/windows/cpu_usage.rs index 520d5e2..ed76c9b 100644 --- a/src/platform/windows/cpu_usage.rs +++ b/src/platform/windows/cpu_usage.rs @@ -24,7 +24,7 @@ impl CpuMonitor for WindowsCpuMonitor { let kernel_time = filetime_to_u64(kernel_time) as f64; let user_time = filetime_to_u64(user_time) as f64; let total_time = kernel_time + user_time; - + let mut state = CPU_STATE.lock().unwrap(); let usage = if let Some((prev_total, prev_idle)) = *state { let total_diff = total_time - prev_total; @@ -37,7 +37,7 @@ impl CpuMonitor for WindowsCpuMonitor { } else { 0.0 // First call, return 0 usage }; - + *state = Some((total_time, idle_time)); Ok(usage) } diff --git a/src/platform/windows/system_integration.rs b/src/platform/windows/system_integration.rs index 679b3c9..86adb9d 100644 --- a/src/platform/windows/system_integration.rs +++ b/src/platform/windows/system_integration.rs @@ -1,11 +1,14 @@ use crate::platform::SystemIntegration; use std::process::Command; -use windows::{core::*, Win32::Foundation::HWND, Win32::UI::WindowsAndMessaging::*}; +use windows::{core::HSTRING, Win32::{Foundation::HWND, UI::WindowsAndMessaging::{MessageBoxW, MB_OK, MESSAGEBOX_STYLE}}}; pub struct WindowsSystemIntegration; impl SystemIntegration for WindowsSystemIntegration { - fn show_dialog(message: &str, title: &str) -> Result<(), Box> { + fn show_dialog( + message: &str, + title: &str, + ) -> Result<(), Box> { safe_message_box(message, title, MB_OK.0)?; Ok(()) } @@ -29,7 +32,7 @@ fn safe_message_box( MESSAGEBOX_STYLE(flags), ); if result.0 == 0 { - return Err("MessageBoxW failed".into()); + return Err("MessageBoxW failed".to_string().into()); } } Ok(())