diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 38139b9..590a473 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -18,6 +18,7 @@ A clear description of what went wrong. - Is it Electron-based? (yes/no, and how you know) **Output** +Remove any app names or other information you do not want to share publicly. Please include the detector version (`bash electron-detector.sh --version`). ``` paste the output of `./electron-detector.sh --json` or the terminal output here ``` diff --git a/.github/banner.svg b/.github/banner.svg index 139a6ec..c157458 100644 --- a/.github/banner.svg +++ b/.github/banner.svg @@ -1,34 +1,5 @@ - - - - - - - - - - - - - - - - - - - - - - - - Electron Detector - Find which of your Mac apps are secretly Electron - - - - - - + + + + +A SMALL TOOL FOR CURIOUS MAC PEOPLEElectron DetectorA little detective for your Mac.OPEN SOURCE / RUNS LOCALLY / MADE BY MACNATIVE \ No newline at end of file diff --git a/.github/preview.svg b/.github/preview.svg new file mode 100644 index 0000000..fced74e --- /dev/null +++ b/.github/preview.svg @@ -0,0 +1,41 @@ + + + +electron-detector.sh --demo + ╭────────────────────────────────────────────────────────────────╮ + │ Electron Detector for macOS v2.1.0 │ + │ Find out what is inside your Mac apps. │ + ╰────────────────────────────────────────────────────────────────╯ + + DEMO / fictional apps and sizes. Nothing scanned. + + ✓ Scan complete · 3 Electron apps found + 42 apps inspected · 7% have Electron markers + + ⚡ Electron apps · largest first + + App Disk size App version + ──────────────────────────────────────────────────────────────── + Example Editor 768.0 MiB 1.2.0 + Example Chat 512.0 MiB 4.0.1 + Example Notes 256.0 MiB 2.3.0 + ──────────────────────────────────────────────────────────────── + + 📦 Disk footprint + Detected app bundles: 1.5 GiB + Whole app bundles; excludes user data and caches. + Largest: Example Editor (50% of the detected footprint) + This is disk usage, not RAM usage or potential savings. + No marker found does not prove an app is native. + + 💡 Did you know? + Electron bundles Chromium and Node.js inside desktop apps. + These apps do not need Chrome installed to run. + Curious about actual RAM use? Check Activity Monitor. + + ✨ Find your next native Mac app + Explore alternatives by category → https://macnative.io/categories + Keep the apps you love. Try something new where it makes sense. + + Tip: --interactive for sharing and evidence; --json for export. + \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a0e7919..47abd73 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,38 +1,7 @@ # Contributing -Thanks for considering a contribution to Electron Detector. +Keep the scanner compatible with macOS `/bin/bash` 3.2 and system utilities. See [the technical reference](docs/reference.md) for the JSON contract and test commands. Add regression fixtures for detection, path handling, and output changes. Avoid performance claims that the scanner cannot measure, and keep all scan data local. A missing Electron marker is never proof of native architecture. -## Ways to help +When reporting issues, include macOS and script versions and the detection marker you expected. Review reports before posting: app names may reveal personal information. -- **New detection rules** — if you know of an app that hides its Electron internals differently, open a PR adding a check to `is_electron()` in `electron-detector.sh`, or open an issue with the app name/version and we'll add it. -- **Bug fixes** — if the script misbehaves on your setup, please include your macOS version and `bash --version`. -- **README / docs** — typo fixes and clarity improvements are always welcome. - -## Development - -The project is a single dependency-free Bash script, kept that way on purpose. - -```bash -git clone https://github.com/macnative/electron-detector.git -cd electron-detector -chmod +x electron-detector.sh -./electron-detector.sh -``` - -Before opening a PR: - -- Run [ShellCheck](https://www.shellcheck.net/) against `electron-detector.sh` (CI runs this automatically on every PR). -- Test both the default and `--json`/`--user`/`--quiet` flag combinations. -- Keep the script POSIX-friendly Bash — no external binaries beyond what macOS ships by default (`find`, `du`, `defaults`, `basename`). - -## Reporting an issue - -Open a [GitHub issue](https://github.com/macnative/electron-detector/issues) with: - -- Your macOS version -- The app you expected to be detected (or misdetected), with its version -- Output of `./electron-detector.sh --json` if relevant - -## Code of conduct - -Be respectful and constructive. This is a small tool built for fun and utility — let's keep it a pleasant place to contribute. +After changing the terminal output, run `python3 scripts/render-preview.py` on macOS to regenerate the README preview from `--demo`. Keep the example label visible. Never use a real user's installed apps in promotional assets. diff --git a/README.md b/README.md index bbf1504..e6fd7fc 100644 --- a/README.md +++ b/README.md @@ -1,190 +1,57 @@

- Electron Detector + Electron Detector — A little detective for your Mac.

-

- ShellCheck - MIT License - macOS - Bash 4+ - Stars - PRs Welcome -

- -

Which of your "native" Mac apps are secretly running a full Chromium browser?

- -

- One command tells you. No install, no dependencies, no tracking — just a shell script that reads your /Applications folder. -

- -

- curl -sL https://raw.githubusercontent.com/macnative/electron-detector/main/electron-detector.sh | bash -

+Find Electron apps on your Mac in a colorful table, with sizes, versions, and a few useful facts. ---- - -## Why this exists - -[Electron](https://www.electronjs.org/) lets developers ship one app for every platform by bundling a full copy of Chromium and Node.js inside it. That's great for developers — and rough on your Mac. Every Electron app you run is, under the hood, its own private browser tab: its own renderer process, its own ~100–300MB of idle RAM, its own hit to battery life. - -Most people have no idea how many of their everyday apps work this way. **Electron Detector** scans your installed apps and tells you, in a few seconds, exactly which ones are Electron — with sizes, versions, and a straight answer on whether it's worth switching to something native. - -## Quick start - -```bash -curl -sL https://raw.githubusercontent.com/macnative/electron-detector/main/electron-detector.sh | bash -``` +**Free & open source. No Homebrew, account, or admin password.** -That's it — no install, no config, nothing left behind. It's a read-only scan of `/Applications`; it doesn't modify anything on your system. +## Copy, paste, scan -## Example output +Open **Terminal** (⌘ Space → Terminal) and paste this whole block: +```sh +curl -fL https://raw.githubusercontent.com/macnative/electron-detector/main/electron-detector.sh -o electron-detector.sh && + bash electron-detector.sh --interactive ``` -╔════════════════════════════════════════════════════════════╗ -║ ⚡ Electron Detector for macOS ║ -║ Find which apps are secretly Electron-based ║ -╚════════════════════════════════════════════════════════════╝ -🔍 Scanning /Applications... +This downloads and runs the script. [Read the source first](electron-detector.sh) if you prefer. -[▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] Done! - -════════════════════════════════════════════════════════════ - -⚠️ Found 5 Electron app(s) out of 47 total apps - -⚡ Electron Apps Detected: - -App Name Size Version -──────────────────────────────────────────────────────────────── -Slack 165M 4.35.0 -Discord 156M 0.0.290 -Visual Studio Code 387M 1.85.0 -Postman 312M 10.20.0 -Notion 198M 2.2.3 - -════════════════════════════════════════════════════════════ - -📦 Resource Impact: - -⚠️ High Electron count detected! - Each Electron app runs a full Chromium browser - -════════════════════════════════════════════════════════════ - -🚀 Want native alternatives? - Check out: https://macnative.io - -✓ Native apps are: - • Faster and more responsive - • Use less RAM and battery - • Better macOS integration - • Smaller app size - -💡 Fun Fact: - Electron apps can use 100-300MB RAM even when idle - Native apps often use less than 50MB - -════════════════════════════════════════════════════════════ -``` +It scans `/Applications` and `~/Applications`, then lets you **copy a summary**, **inspect detection evidence**, or **browse native alternatives**. Scanning works offline and never changes your apps. -## Features - -- 🔎 Scans `/Applications` (and optionally `~/Applications`) for Electron apps -- ⚡ Detects the Electron runtime via its framework bundle, not just an app name guess -- 📊 Reports app name, disk size, and version for every match -- 🎨 Clean, colorful terminal output — with a real progress bar, not a spinner -- 🤖 `--json` mode for scripting, dashboards, or piping into `jq` -- 🧠 Zero dependencies: pure Bash, nothing to install -- 🔒 Read-only — it only inspects app bundles, it never modifies or deletes anything - -## Usage - -```bash -electron-detector.sh [options] - -Options: - -u, --user Also scan ~/Applications - -j, --json Output machine-readable JSON instead of the report - -q, --quiet Suppress the banner and tips, print results only - -v, --version Print the version number - -h, --help Show this help message -``` - -**Scan both system and user app directories:** - -```bash -./electron-detector.sh --user -``` - -**Get JSON for scripting:** - -```bash -./electron-detector.sh --json | jq '.electron_apps[].name' -``` - -## Manual installation - -```bash -git clone https://github.com/macnative/electron-detector.git -cd electron-detector -chmod +x electron-detector.sh -./electron-detector.sh -``` - -## How it works - -Electron Detector doesn't guess from an app's name or icon — it looks inside the `.app` bundle for the actual Electron runtime: - -1. **Electron Framework** — `Contents/Frameworks/Electron Framework.framework` -2. **Electron binary** — `Contents/MacOS/Electron` - -If either is present, the app is running on Electron — full stop. This is the same signal macOS itself uses internally to launch the app, so it's accurate even when an app hides its origins well. - -## Why it matters - -| | Electron apps | Native apps | -|---|---|---| -| Idle RAM | 100–300MB per app | Often under 50MB | -| Battery impact | Higher — runs a full Chromium instance | Lower — uses native frameworks | -| Launch time | Slower — boots a browser engine | Near-instant | -| Disk footprint | Large (bundles Chromium + Node.js) | Small | -| macOS integration | Approximated | Native | - -None of this means Electron apps are *bad* — it's a reasonable trade-off for developers shipping cross-platform software fast. But if you're stacking up a dozen of them, your battery and fans will notice. Once you know which apps are Electron, you can make that call for yourself. - -Found some? Browse [**macnative.io**](https://macnative.io) for a curated directory of native macOS replacements. - -## Requirements +

+ Example report showing a size-sorted app table, disk footprint, and useful facts. +

-- macOS with Bash (ships by default) -- Apps installed in `/Applications` or `~/Applications` +*Fictional apps and sizes. Preview it yourself with `bash electron-detector.sh --demo`.* -## Troubleshooting +## A few handy options -**"Permission denied" when running the script directly** -```bash -chmod +x electron-detector.sh -``` +After the first download, run `bash electron-detector.sh` with any of these: -**Not finding an app you know is Electron** -Some apps rename or relocate their internals. Open an issue with the app name and version and we'll add a detection rule. +| Option | What you get | +| --- | --- | +| `--interactive` | Sharing, clipboard, evidence, and alternatives menu | +| `--share` | A summary without app names or paths | +| `--json` | A structured report for your own tools | +| `--path '/your/folder'` | Scan a different app folder | +| `--quiet` | Results without the banner, facts, or promotion | +| `--demo` | Try the UI without scanning | -**Apps in unusual locations aren't scanned** -By design, only `/Applications` and (with `--user`) `~/Applications` are scanned. This is deliberate — the script only reads standard, expected locations. +Run `bash electron-detector.sh --help` for all options. -## Contributing +## What the results mean -Contributions are very welcome — new detection rules, bug fixes, and general polish all help. See [CONTRIBUTING.md](CONTRIBUTING.md) to get started, or just open an issue or PR. +The script looks for Electron's framework or executable inside each app. Renamed internals can be missed; **no match doesn't prove an app is native**. -## Credits +Sizes are **whole app bundles on disk**, excluding user data and caches—not RAM use or estimated savings. Nothing is uploaded or posted. Full reports include app names; `--share` leaves them out. -Electron Detector is built and maintained by [**macnative**](https://macnative.io) — a directory of fast, native, non-Electron alternatives to the apps cluttering your Mac. Visit [macnative.io](https://macnative.io) to find one for every Electron app this tool flags. +## Find something more Mac-like -## License +Made by **[MacNative](https://macnative.io)**. Curious about a different app? [Browse native alternatives by category →](https://macnative.io/categories) -[MIT](LICENSE) — free to use, modify, and share. +Keep the apps you love. Try something new where it makes sense. --- -

Made with ❤️ by macnative for people who like their Macs fast and their RAM free.

+[How detection works & JSON reference](docs/reference.md) · [Contributing](CONTRIBUTING.md) · [Report a bug](https://github.com/macnative/electron-detector/issues) · [MIT license](LICENSE) diff --git a/docs/reference.md b/docs/reference.md new file mode 100644 index 0000000..5b6232a --- /dev/null +++ b/docs/reference.md @@ -0,0 +1,94 @@ +# Technical reference + +[Back to the README](../README.md) + +## Make it yours + +| Option | What it does | +| --- | --- | +| `-i`, `--interactive` | Offers share, clipboard, explanation, and alternatives actions after scanning | +| `--path DIRECTORY` | Scans a custom folder; repeat for multiple folders; replaces the defaults | +| `-j`, `--json` | Emits structured data, with app names but no personal folder paths | +| `--share` | Prints a summary without app names or paths | +| `--demo` | Shows fictional results without scanning; works with the interactive menu | +| `-q`, `--quiet` | Omits the banner, tips, and MacNative promotion | +| `--no-color` | Disables colors; `NO_COLOR` is also respected | +| `-u`, `--user` | Compatibility flag; user apps are now included by default | +| `-v`, `--version` | Prints the version | +| `-h`, `--help` | Shows help | + +```sh +# Scan another app folder +bash electron-detector.sh --path '/Volumes/Work/Applications' + +# Save a report for your own tools +bash electron-detector.sh --json > electron-report.json + +# Just the findings +bash electron-detector.sh --quiet --no-color +``` + +`--interactive` needs terminal input and output. Use it after downloading, rather than piping the script into Bash. `--json` and `--share` are mutually exclusive; `--demo` cannot be combined with `--json` or `--path`. + +## Questions that come up + +**Does this tell me which apps are running right now?** +No. It inspects installed bundles. Use Activity Monitor for running processes, CPU, and memory. + +**Is the disk total space I can save?** +No. It's the measured size of the detected bundles, including the apps themselves. Replacement apps also use space, and user data is outside this measurement. + +**It missed an app.** +Try `--path` if it's outside the default folders. Renamed or relocated Electron internals can be missed. [Open an issue](https://github.com/macnative/electron-detector/issues) with the app version and expected marker; please leave personal paths out. + +**I see a partial-scan warning.** +Some folders or bundles couldn't be read. The results describe what was accessible. Try a smaller folder you can read; no admin access is required. + +**Can I remove it afterward?** +Yes. Delete the downloaded script and any report files you saved. There's no installer, background service, or auto-updater. + +## What a detection means + +The detector checks for either: + +- `Contents/Frameworks/Electron Framework.framework` +- `Contents/MacOS/Electron` + +These are runtime markers, not a benchmark or an executable signature check. Renamed or relocated frameworks can be missed, and a coincidentally named file can be a false positive. A bundle may include Electron for only part of its functionality. Other web wrappers are outside this tool's scope. **No Electron marker does not mean native.** + +Sizes are allocated bundle disk usage from `du -sk`, in KiB (displayed as MiB/GiB). They exclude user data and caches. APFS cloning, compression, and shared files can complicate totals. The report does not estimate RAM, battery life, speed, or space you would save by switching. Apps changing during a scan may yield partial measurements. Inaccessible folders are reported as warnings; an empty folder yields a valid zero-app report. Symlink cycles may also cause traversal warnings. + +[Electron embeds Chromium and Node.js](https://www.electronjs.org/docs/latest/) to support cross-platform desktop applications. This is the basis of the report’s “Did you know?” fact. Keep apps that work for you. If you want software with a more native Mac feel, [browse MacNative by category](https://macnative.io/categories). A native app is not necessarily a feature-for-feature replacement. + +## JSON schema v2 + +```json +{ + "schema_version": 2, + "detector_version": "2.1.0", + "total_apps": 1, + "electron_count": 1, + "total_size_kb": 409600, + "unknown_sizes": 0, + "scan_warnings": 0, + "electron_apps": [ + { "name": "Example", "size_kb": 409600, "version": "1.0", "evidence": "Electron Framework.framework" } + ] +} +``` + +`size_kb` is `null` when measurement fails. `total_size_kb` sums known sizes only. `total_apps` counts bundles with accessible Contents directories. `scan_warnings` counts traversal diagnostics and inaccessible bundles; it is not a count of missed apps. Version 2 replaces v1's string `size` with numeric `size_kb` and includes the user Applications folder by default. Errors go to stderr; JSON stdout stays machine-readable. The demo cannot export JSON, so fictional data cannot be mistaken for a real scan. Exit 0 means the scan completed (inspect warnings); 2 means invalid arguments or an unsupported platform; 1 means temporary workspace creation failed. + +## Contributing & tests + +Use macOS and `/bin/bash`, not just a newer Homebrew Bash. Runtime dependencies must ship with macOS. Dev tests use Python 3; ShellCheck is optional locally and runs in CI. + +```sh +/bin/bash -n electron-detector.sh +python3 tests/test_detector.py +shellcheck electron-detector.sh +``` + +New detection rules need fixtures for both true and false positives. Don't add telemetry or unmeasured performance claims. Please remove personal information before attaching reports to issues. + +[MIT license](../LICENSE). diff --git a/electron-detector.sh b/electron-detector.sh index f377483..79d855c 100755 --- a/electron-detector.sh +++ b/electron-detector.sh @@ -1,267 +1,258 @@ #!/bin/bash -# -# Electron Detector — find Electron-based apps on macOS -# https://github.com/macnative/electron-detector -# https://macnative.io -# -# Licensed under the MIT License (see LICENSE). - -VERSION="1.0.0" - -# --------------------------------------------------------------------------- -# Colors (disabled automatically when not attached to a terminal, or when -# NO_COLOR is set — see https://no-color.org) -# --------------------------------------------------------------------------- -if [[ -t 1 && -z "${NO_COLOR:-}" ]]; then - RED='\033[0;31m' - GREEN='\033[0;32m' - YELLOW='\033[1;33m' - BLUE='\033[0;34m' - PURPLE='\033[0;35m' - CYAN='\033[0;36m' - WHITE='\033[1;37m' - BOLD='\033[1m' - NC='\033[0m' -else - RED=''; GREEN=''; YELLOW=''; BLUE=''; PURPLE=''; CYAN=''; WHITE=''; BOLD=''; NC='' -fi - -ELECTRON="⚡" -NATIVE="✨" -SCAN="🔍" -WARN="⚠️" -CHECK="✓" -ROCKET="🚀" -PACKAGE="📦" - -SCAN_PATHS=("/Applications") -JSON_OUTPUT=false -QUIET=false - +# Electron Detector / MacNative — MIT licensed. Compatible with macOS Bash 3.2. +# No network calls during scanning. Never modifies application bundles. +VERSION="2.1.0" +export LC_ALL=en_US.UTF-8 +json=false quiet=false interactive=false share=false custom=false demo=false +roots=() apps=() names=() sizes=() versions=() evidence=() +count=0 total_kb=0 unknown_sizes=0 skipped=0 usage() { - cat <&2 - usage >&2 - exit 1 - ;; + --path) [ "$#" -ge 2 ] && [ -d "$2" ] || { printf 'Expected an existing directory after --path\n' >&2; exit 2; }; custom=true; roots+=("$2"); shift 2 ;; + -u|--user) shift ;; + -j|--json) json=true; shift ;; + -q|--quiet) quiet=true; shift ;; + -i|--interactive) interactive=true; shift ;; + --share) share=true; shift ;; + --demo) demo=true; shift ;; + --no-color) NO_COLOR=1; shift ;; + -v|--version) printf '%s\n' "$VERSION"; exit 0 ;; + -h|--help) usage; exit 0 ;; + *) printf 'Unknown option: %s\n' "$1" >&2; exit 2 ;; esac done - -# Function to check if app is Electron -is_electron() { - local app_path="$1" - - # Most reliable signal: the Electron Framework bundled inside the app - if [ -d "$app_path/Contents/Frameworks/Electron Framework.framework" ]; then - return 0 - fi - - # Fallback: an "Electron" binary sitting in the MacOS folder - if [ -f "$app_path/Contents/MacOS/Electron" ]; then - return 0 - fi - - return 1 +if $json && $share; then printf 'Choose either --json or --share.\n' >&2; exit 2; fi +if $demo && { $json || $custom; }; then printf -- '--demo cannot be combined with --json or --path.\n' >&2; exit 2; fi +if [ "$(uname -s)" != Darwin ]; then printf 'This detective needs macOS.\n' >&2; exit 2; fi +if $interactive && { $json || $share || [ ! -t 0 ] || [ ! -t 1 ]; }; then + printf -- '--interactive requires a terminal and cannot be combined with --json or --share.\n' >&2; exit 2 +fi +if ! $custom; then + roots=(/Applications) + [ ! -d "$HOME/Applications" ] || roots+=("$HOME/Applications") +fi +mint='' dim='' reset='' yellow='' purple='' green='' bold='' animate=false +if [ -t 1 ] && [ "${TERM:-dumb}" != dumb ]; then animate=true; fi +if [ -t 1 ] && [ -z "${NO_COLOR+x}" ] && [ "${TERM:-dumb}" != dumb ]; then + mint=$'\033[1;36m'; dim=$'\033[2m'; reset=$'\033[0m' + yellow=$'\033[1;33m'; purple=$'\033[0;35m'; green=$'\033[1;32m'; bold=$'\033[1m' +fi +# JSON string escaping includes every control byte; never interpolate names as code. +json_string() { + local value="$1" char n i + local LC_ALL=C + printf '"' + for ((i=0; i<${#value}; i++)); do + char="${value:i:1}" + case "$char" in + '"') printf '\\"' ;; \\) printf '\\\\' ;; + *) printf -v n '%d' "'$char" + n=$((n & 255)) + if [ "$n" -lt 32 ]; then printf '\\u%04x' "$n"; else printf '%s' "$char"; fi ;; + esac + done + printf '"' } - -get_app_size() { - du -sh "$1" 2>/dev/null | cut -f1 +safe_text() { printf '%s' "$1" | tr '[:cntrl:]' '?'; } +pretty_size() { + if [ "$1" -lt 0 ]; then printf 'unknown'; + elif [ "$1" -ge 1048576 ]; then awk -v n="$1" 'BEGIN {printf "%.1f GiB",n/1048576}'; + else awk -v n="$1" 'BEGIN {printf "%.1f MiB",n/1024}'; fi } - -get_app_version() { - defaults read "$1/Contents/Info.plist" CFBundleShortVersionString 2>/dev/null || echo "Unknown" +# A compact layout keeps the report readable in narrow terminal windows. +columns=80 +if $animate; then columns=$(tput cols 2>/dev/null) || columns=80; fi +case "$columns" in ''|*[!0-9]*) columns=80 ;; esac +compact=false; [ "$columns" -ge 68 ] || compact=true +rule() { printf '%s ────────────────────────────────────────────────────────────────%s\n' "$dim" "$reset"; } +progress() { + local done="$1" total="$2" b filled=0 pct=0 + [ "$total" -eq 0 ] || { filled=$((done*24/total)); pct=$((done*100/total)); } + printf '\r%s [' "$mint" + for ((b=0;b<24;b++)); do + if [ "$b" -lt "$filled" ]; then printf '▓'; else printf '░'; fi + done + printf '] %3d%% (%d/%d)%s' "$pct" "$done" "$total" "$reset" } - -if [[ "$JSON_OUTPUT" != true && "$QUIET" != true ]]; then - clear - echo "" - echo -e "${CYAN}╔════════════════════════════════════════════════════════════╗${NC}" - echo -e "${CYAN}║${NC} ${ELECTRON} ${BOLD}${WHITE}Electron Detector for macOS${NC}${CYAN} ║${NC}" - echo -e "${CYAN}║${NC} Find which apps are secretly Electron-based ${CYAN}║${NC}" - echo -e "${CYAN}╚════════════════════════════════════════════════════════════╝${NC}" - echo "" -fi - -apps=() -while IFS= read -r -d '' app; do - apps+=("$app") -done < <(find "${SCAN_PATHS[@]}" -maxdepth 2 -name "*.app" -print0 2>/dev/null) - -total_app_count=${#apps[@]} - -if [ "$total_app_count" -eq 0 ]; then - if [[ "$JSON_OUTPUT" == true ]]; then - echo '{"error":"no applications found","scanned_paths":[]}' +if ! $json && ! $quiet && ! $share; then + if $compact; then + printf '\n%s ⚡ Electron Detector%s\n Find out what is inside your Mac apps.\n\n' "$mint" "$reset" else - echo -e "${RED}No applications found in: ${SCAN_PATHS[*]}${NC}" + printf '\n%s ╭────────────────────────────────────────────────────────────────╮\n' "$mint" + printf ' │ %-50s %10s │\n' 'Electron Detector for macOS' "v$VERSION" + printf ' │ %-62s│\n' 'Find out what is inside your Mac apps.' + printf ' ╰────────────────────────────────────────────────────────────────╯%s\n\n' "$reset" fi - exit 1 + if $demo; then printf ' DEMO / fictional apps and sizes. Nothing scanned.\n\n'; + else printf ' 🔍 Scanning app folders. Everything stays on your Mac.\n\n'; fi fi - -if [[ "$JSON_OUTPUT" != true && "$QUIET" != true ]]; then - echo -e "${SCAN} ${BOLD}Scanning ${SCAN_PATHS[*]}...${NC}" - echo "" -fi - -electron_apps=() -electron_count=0 -current=0 -bar_length=50 - -for app in "${apps[@]}"; do - ((current++)) - - if [[ "$JSON_OUTPUT" != true && "$QUIET" != true ]]; then - percent=$((current * 100 / total_app_count)) - filled=$((current * bar_length / total_app_count)) - printf "\r${BLUE}[" - for ((i=0; i "$work/apps" 2> "$work/errors" +if [ -s "$work/errors" ]; then + skipped=$(wc -l < "$work/errors" | tr -d ' ') + printf 'Some folders could not be read (%s traversal warnings). Results may be incomplete.\n' "$skipped" >&2 fi - -# --------------------------------------------------------------------------- -# JSON output -# --------------------------------------------------------------------------- -if [[ "$JSON_OUTPUT" == true ]]; then - printf '{"total_apps":%d,"electron_count":%d,"electron_apps":[' "$total_app_count" "$electron_count" - first=true - for app_info in "${electron_apps[@]}"; do - IFS='|' read -r name size version <<< "$app_info" - $first || printf ',' - first=false - printf '{"name":"%s","size":"%s","version":"%s"}' \ - "${name//\"/\\\"}" "${size//\"/\\\"}" "${version//\"/\\\"}" +candidates=0 processed=0 +while IFS= read -r -d '' app; do candidates=$((candidates+1)); done < "$work/apps" +while IFS= read -r -d '' app; do + if ! $json && ! $quiet && ! $share && $animate; then progress "$processed" "$candidates"; fi + processed=$((processed+1)) + canonical=$(cd "$app" 2>/dev/null && pwd -P) || { skipped=$((skipped+1)); continue; } + duplicate=false + for seen in "${apps[@]}"; do [ "$seen" != "$canonical" ] || { duplicate=true; break; }; done + $duplicate && continue + apps+=("$canonical") + if [ ! -r "$app/Contents" ] || [ ! -x "$app/Contents" ]; then skipped=$((skipped+1)); continue; fi + count=$((count+1)) + signal='' + if [ -d "$app/Contents/Frameworks/Electron Framework.framework" ]; then signal='Electron Framework.framework'; + elif [ -f "$app/Contents/MacOS/Electron" ]; then signal='Contents/MacOS/Electron'; fi + [ -n "$signal" ] || continue + name=${app##*/}; name=${name%.app} + version=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$app/Contents/Info.plist" 2>/dev/null) || version='Unknown' + size=$(du -sk "$canonical" 2>/dev/null) && size=${size%%[[:space:]]*} || size=-1 + case "$size" in ''|*[!0-9-]*) size=-1 ;; esac + if [ "$size" -lt 0 ]; then unknown_sizes=$((unknown_sizes+1)); else total_kb=$((total_kb+size)); fi + # Insertion order: largest bundles first. Parallel arrays preserve arbitrary names. + pos=${#names[@]} + while [ "$pos" -gt 0 ] && [ "${sizes[$((pos-1))]}" -lt "$size" ]; do + names[$pos]=${names[$((pos-1))]}; sizes[$pos]=${sizes[$((pos-1))]} + versions[$pos]=${versions[$((pos-1))]}; evidence[$pos]=${evidence[$((pos-1))]} + pos=$((pos-1)) done - printf ']}\n' - exit 0 + names[$pos]=$name; sizes[$pos]=$size; versions[$pos]=$version; evidence[$pos]=$signal +done < "$work/apps" +if ! $json && ! $quiet && ! $share && $animate; then + progress "$processed" "$candidates"; printf '\n\n' fi - -# --------------------------------------------------------------------------- -# Human-readable report -# --------------------------------------------------------------------------- -echo -e "${CYAN}════════════════════════════════════════════════════════════${NC}" -echo "" - -if [ "$electron_count" -eq 0 ]; then - echo -e "${GREEN}${CHECK} ${BOLD}Congratulations!${NC}" - echo -e "${GREEN}No Electron apps detected on your Mac!${NC}" - echo "" - echo -e "${NATIVE} Your Mac is running pure native apps! ${NATIVE}" -else - echo -e "${RED}${WARN} ${BOLD}Found $electron_count Electron app(s) out of $total_app_count total apps${NC}" - echo "" - echo -e "${YELLOW}${ELECTRON} Electron Apps Detected:${NC}" - echo "" - - printf "${BOLD}%-40s %-12s %-12s${NC}\n" "App Name" "Size" "Version" - echo -e "${CYAN}────────────────────────────────────────────────────────────────${NC}" - - for app_info in "${electron_apps[@]}"; do - IFS='|' read -r name size version <<< "$app_info" - printf "${RED}%-40s${NC} ${YELLOW}%-12s${NC} ${PURPLE}%-12s${NC}\n" "$name" "$size" "$version" +fi +found=${#names[@]} +app_label=apps; [ "$found" -ne 1 ] || app_label=app +percent=0; [ "$count" -eq 0 ] || percent=$((found*100/count)) +summary() { + if $demo; then printf 'DEMO / fictional results; nothing scanned.\n'; fi + printf 'My Mac app census: %d of %d inspected apps have Electron markers (%d%%).\n' "$found" "$count" "$percent" + printf 'Detected bundles: %s on disk' "$(pretty_size "$total_kb")" + [ "$unknown_sizes" -eq 0 ] || printf ' + %d unmeasured' "$unknown_sizes" + printf '. Not a RAM or battery benchmark.\n' + [ "$skipped" -eq 0 ] || printf 'Partial scan: %d warnings.\n' "$skipped" + printf 'No marker found does not prove an app is native.\n' + printf 'Try it: https://github.com/macnative/electron-detector\n' + if ! $quiet; then printf 'Explore native alternatives: https://macnative.io/categories\n'; fi +} +if $json; then + printf '{"schema_version":2,"detector_version":"%s","total_apps":%d,"electron_count":%d,"total_size_kb":%d,"unknown_sizes":%d,"scan_warnings":%d,"electron_apps":[' "$VERSION" "$count" "$found" "$total_kb" "$unknown_sizes" "$skipped" + for ((i=0;i ' + IFS= read -r choice || break + case "$choice" in + s|S) printf '\n'; summary ;; + c|C) if summary | pbcopy; then printf ' Copied. App names and paths are left out.\n'; + else printf ' Clipboard unavailable. Choose s to print the summary.\n'; fi ;; + e|E) for ((i=0;i', + f'', + '', + 'electron-detector.sh --demo', +] +for i, line in enumerate(lines): + color = '#67d6ec' if any(t in line for t in ['╭', '│', '╰', '📦']) else '#dce5dc' + if any(t in line for t in ['⚡ Electron apps', '💡', 'DEMO /']): + color = '#e5ca83' + if any(t in line for t in ['✓', '✨']): + color = '#bce6ad' + if 'DEMO /' in line: + color = '#e5ca83' + svg.append(f'{html.escape(line)}') +svg.append('') +(root / '.github/preview.svg').write_text('\n'.join(svg)) diff --git a/tests/test_detector.py b/tests/test_detector.py new file mode 100644 index 0000000..bb6da3f --- /dev/null +++ b/tests/test_detector.py @@ -0,0 +1,253 @@ +"""Integration tests against real macOS utilities and the system Bash 3.2.""" +import json +import fcntl +import struct +import termios +import os +from pathlib import Path +import plistlib +import pty +import select +import time +import subprocess +import tempfile +import unittest + +SCRIPT = Path(__file__).resolve().parents[1] / 'electron-detector.sh' + +class DetectorTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + + def tearDown(self): + self.tmp.cleanup() + + def app(self, name, signal='framework', version='1.2.3', folder=None, payload=0): + app = (folder or self.root) / (name + '.app') + (app / 'Contents').mkdir(parents=True) + with (app / 'Contents/Info.plist').open('wb') as f: + plistlib.dump({'CFBundleShortVersionString': version}, f) + if signal == 'framework': + (app / 'Contents/Frameworks/Electron Framework.framework').mkdir(parents=True) + if signal == 'binary': + (app / 'Contents/MacOS').mkdir() + (app / 'Contents/MacOS/Electron').write_text('fixture') + if payload: + (app / 'Contents/payload').write_bytes(os.urandom(payload)) + return app + + def run_script(self, *args): + return subprocess.run(['/bin/bash', str(SCRIPT), '--path', str(self.root), *args], text=True, capture_output=True, timeout=30) + + def report(self, *args): + result = self.run_script('--json', *args) + self.assertEqual(result.returncode, 0, result.stderr) + return json.loads(result.stdout) + + def test_signals_and_nested_helpers(self): + outer = self.app('Electron app') + self.app('Helper', folder=outer / 'Contents') + self.app('Binary app', 'binary', folder=self.root / 'Tools' / 'Editors') + self.app('Plain app', 'none') + data = self.report() + self.assertEqual(data['total_apps'], 3) + self.assertEqual(data['electron_count'], 2) + self.assertEqual({a['name'] for a in data['electron_apps']}, {'Electron app', 'Binary app'}) + + def test_json_escapes_unusual_names_and_versions(self): + name = 'Café "quoted" \\ path|tab\tline\nname' + version = '1"\\\t\n2' + self.app(name, version=version) + data = self.report() + self.assertEqual(data['electron_apps'][0]['name'], name) + self.assertEqual(data['electron_apps'][0]['version'], version) + self.assertNotIn(str(self.root), json.dumps(data)) + + def test_deduplicate_overlapping_paths_and_symlink(self): + app = self.app('One') + (self.root / 'Alias.app').symlink_to(app, target_is_directory=True) + self.assertEqual(self.report('--path', str(self.root))['total_apps'], 1) + + def test_empty_is_valid_report(self): + data = self.report() + self.assertEqual(data['total_apps'], 0) + self.assertEqual(data['electron_apps'], []) + + def test_no_false_native_claim(self): + self.app('Another framework', 'none') + result = self.run_script() + self.assertIn('does not prove an app is native', result.stdout) + self.assertEqual(self.report()['electron_count'], 0) + + def test_sizes_are_sorted_and_totaled(self): + self.app('Small') + self.app('Large', payload=300000) + data = self.report() + self.assertEqual(data['electron_apps'][0]['name'], 'Large') + self.assertEqual(data['total_size_kb'], sum(a['size_kb'] for a in data['electron_apps'])) + + def test_share_omits_names_and_quiet_omits_promotion(self): + self.app('PrivateWorkApp') + shared = self.run_script('--share').stdout + self.assertNotIn('PrivateWorkApp', shared) + self.assertNotIn(str(self.root), shared) + self.assertIn('1 of 1', shared) + self.assertNotIn('macnative.io', self.run_script('--quiet').stdout) + + def test_missing_version_and_terminal_escape_sanitizing(self): + app = self.app('Escape\x1b[31m') + (app / 'Contents/Info.plist').unlink() + data = self.report() + self.assertEqual(data['electron_apps'][0]['version'], 'Unknown') + self.assertNotIn('\x1b', self.run_script('--quiet').stdout) + + def test_invalid_path_and_noninteractive_menu(self): + self.assertEqual(self.run_script('--path', '/does-not-exist').returncode, 2) + self.assertEqual(self.run_script('--interactive').returncode, 2) + self.assertEqual(self.run_script('--bogus').returncode, 2) + + + def test_progress_and_narrow_terminal(self): + self.app('Example') + for width in [90, 50]: + with self.subTest(width=width): + master, slave = pty.openpty() + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', 50, width, 0, 0)) + process = subprocess.Popen(['/bin/bash', str(SCRIPT), '--path', str(self.root)], + stdin=subprocess.DEVNULL, stdout=slave, stderr=slave, + env={**os.environ, 'TERM': 'xterm', 'COLUMNS': str(width)}) + os.close(slave) + output = b'' + deadline = time.monotonic() + 10 + try: + while time.monotonic() < deadline: + ready, _, _ = select.select([master], [], [], 0.1) + if ready: + try: + chunk = os.read(master, 65536) + except OSError: + break + if not chunk: + break + output += chunk + elif process.poll() is not None: + break + process.wait(timeout=2) + text = output.decode() + self.assertEqual(process.returncode, 0, text) + self.assertIn('100% (1/1)', text) + self.assertIn('Example', text) + self.assertEqual('App version' in text, width >= 68) + finally: + if process.poll() is None: + process.kill() + process.wait() + os.close(master) + + def test_table_facts_and_long_names(self): + name = 'A very long app name that must remain completely visible' + self.app(name) + self.app('日本語のアプリ') + result = self.run_script() + for text in [name, '日本語のアプリ', 'App version', 'Disk size', 'Did you know?', 'Largest:']: + self.assertIn(text, result.stdout) + self.assertNotIn('Did you know?', self.run_script('--quiet').stdout) + + def test_demo_is_explicit_and_does_not_scan(self): + self.app('PrivateWorkApp') + result = subprocess.run(['/bin/bash', str(SCRIPT), '--demo'], text=True, capture_output=True) + self.assertEqual(result.returncode, 0) + self.assertIn('DEMO / fictional apps and sizes. Nothing scanned.', result.stdout) + self.assertIn('Example Editor', result.stdout) + self.assertNotIn('PrivateWorkApp', result.stdout) + shared = subprocess.check_output(['/bin/bash', str(SCRIPT), '--demo', '--share'], text=True) + self.assertTrue(shared.startswith('DEMO /')) + + def test_conflicting_output_modes(self): + self.assertEqual(self.run_script('--json', '--share').returncode, 2) + self.assertEqual(self.run_script('--demo').returncode, 2) # --path supplied by helper + result = subprocess.run(['/bin/bash', str(SCRIPT), '--demo', '--json'], capture_output=True) + self.assertEqual(result.returncode, 2) + self.assertEqual(result.stdout, b'') + + def test_relative_root_starting_with_dash(self): + folder = self.root / '-apps' + self.app('Example', folder=folder) + result = subprocess.run(['/bin/bash', str(SCRIPT), '--path', '-apps', '--json'], + cwd=self.root, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(result.stdout)['electron_count'], 1) + + def test_unknown_size_is_not_zero(self): + self.app('Unreadable size') + commands = self.root / 'bin' + commands.mkdir() + du = commands / 'du' + du.write_text('#!/bin/sh\nexit 1\n') + du.chmod(0o755) + result = subprocess.run(['/bin/bash', str(SCRIPT), '--path', str(self.root), '--json'], + env={**os.environ, 'PATH': str(commands) + ':' + os.environ['PATH']}, + text=True, capture_output=True) + data = json.loads(result.stdout) + self.assertEqual(data['unknown_sizes'], 1) + self.assertIsNone(data['electron_apps'][0]['size_kb']) + self.assertEqual(data['total_size_kb'], 0) + + def test_symlink_loop_does_not_duplicate_apps(self): + self.app('Readable') + (self.root / 'loop').symlink_to(self.root, target_is_directory=True) + data = self.report() + self.assertEqual(data['electron_count'], 1) + self.assertEqual(data['total_apps'], 1) + + def test_interactive_menu_in_real_terminal(self): + commands = self.root / 'bin' + commands.mkdir() + for name, command in [('pbcopy', 'cat > "$DETECTOR_TEST_OUTPUT/clipboard"'), + ('open', 'printf "%s" "$1" > "$DETECTOR_TEST_OUTPUT/url"')]: + stub = commands / name + stub.write_text('#!/bin/sh\n' + command + '\n') + stub.chmod(0o755) + master, slave = pty.openpty() + process = subprocess.Popen(['/bin/bash', str(SCRIPT), '--demo', '--interactive', '--no-color'], + stdin=slave, stdout=slave, stderr=slave, + env={**os.environ, 'TERM': 'dumb', 'DETECTOR_TEST_OUTPUT': str(self.root), + 'PATH': str(commands) + ':' + os.environ['PATH']}) + os.close(slave) + output = b'' + sent = False + deadline = time.monotonic() + 10 + try: + while time.monotonic() < deadline: + ready, _, _ = select.select([master], [], [], 0.1) + if ready: + try: + chunk = os.read(master, 65536) + except OSError: + break + if not chunk: + break + output += chunk + if b' > ' in output and not sent: + os.write(master, b'e\ns\nc\na\nq\n') + sent = True + elif process.poll() is not None: + break + process.wait(timeout=2) + text = output.decode() + self.assertEqual(process.returncode, 0, text) + self.assertIn('The clues:', text) + self.assertIn('My Mac app census: 3 of 42', text) + self.assertNotIn('\x1b', text) + self.assertIn('3 of 42', (self.root / 'clipboard').read_text()) + self.assertNotIn('Example Editor', (self.root / 'clipboard').read_text()) + self.assertEqual((self.root / 'url').read_text(), 'https://macnative.io/categories') + finally: + if process.poll() is None: + process.kill() + process.wait() + os.close(master) + +if __name__ == '__main__': + unittest.main()