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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 143 additions & 28 deletions build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@
set -euo pipefail

ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

if [[ "$(uname -s)" != "Darwin" ]]; then
echo "build.sh packages the macOS development app only." >&2
echo "Use npm run tauri -- build on this platform." >&2
exit 1
fi
cd "$ROOT_DIR"

export PATH="$HOME/.local/bin:$HOME/Library/pnpm:$HOME/.cargo/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:${PATH:-}"
Expand All @@ -19,40 +25,149 @@ if ! command -v cargo >/dev/null 2>&1; then
exit 1
fi

APP_BUNDLE="$ROOT_DIR/src-tauri/target/release/bundle/macos/ide.app"
INSTALLED_APP="${IDE_INSTALLED_APP_PATH:-/Applications/ide.app}"
INSTALLED_APP_UPDATED=0
DEV_TAURI_CONFIG="$ROOT_DIR/src-tauri/tauri.dev.conf.json"
DEV_APP_BUNDLE="$ROOT_DIR/src-tauri/target/release/bundle/macos/ide-dev.app"
DEV_INSTALLED_APP_INPUT="${IDE_DEV_INSTALLED_APP_PATH:-$HOME/Applications/ide-dev.app}"
DEV_BUNDLE_IDENTIFIER="com.gordonbeeming.ide.dev"

resolve_dev_install_parent() {
local parent="$1"
local suffix=""

while [[ ! -d "$parent" ]]; do
if [[ -e "$parent" || -L "$parent" ]]; then
echo "Development app parent contains a non-directory component: $parent" >&2
return 1
fi

local component next_parent
component="$(basename "$parent")"
next_parent="$(dirname "$parent")"
if [[ "$component" == "." || "$component" == ".." || "$next_parent" == "$parent" ]]; then
echo "Development app parent could not be resolved safely: $1" >&2
return 1
fi
suffix="/$component$suffix"
parent="$next_parent"
done

local resolved_parent
resolved_parent="$(cd -P "$parent" && pwd)"
printf '%s%s\n' "$resolved_parent" "$suffix"
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

resolve_dev_install_path() {
local app_path="$1"

if [[ "$app_path" != /* || "$(basename "$app_path")" != "ide-dev.app" ]]; then
echo "Development app install path must be an absolute ide-dev.app bundle: $app_path" >&2
return 1
fi

case "/${app_path#/}/" in
*/./* | */../* | *//* )
echo "Development app install path must not contain dot or empty segments: $app_path" >&2
return 1
;;
esac

if [[ -L "$app_path" ]]; then
echo "Development app install path must not be a symbolic link: $app_path" >&2
return 1
fi

local resolved_parent
resolved_parent="$(resolve_dev_install_parent "$(dirname "$app_path")")" || return 1

case "$resolved_parent" in
/ | /Applications | /Applications/* | /System/Volumes/Data/Applications | /System/Volumes/Data/Applications/*)
echo "Refusing to install a development app under $resolved_parent" >&2
return 1
;;
esac
Comment thread
GordonBeeming marked this conversation as resolved.

printf '%s/ide-dev.app\n' "$resolved_parent"
}

resolve_dev_signing_identity() {
if [[ -n "${IDE_DEV_SIGNING_IDENTITY:-}" ]]; then
printf '%s\n' "$IDE_DEV_SIGNING_IDENTITY"
return 0
fi

if command -v security >/dev/null 2>&1; then
local identity
identity="$(security find-identity -v -p codesigning 2>/dev/null | sed -nE 's/.*"([^\"]*Apple Development:[^\"]*)".*/\1/p' | sed -n '1p')"
if [[ -n "$identity" ]]; then
printf '%s\n' "$identity"
return 0
fi
fi

printf '%s\n' "-"
}

verify_dev_app_signature() {
local app_path="$1"
if ! codesign --verify --deep --strict "$app_path"; then
echo "Development app signature verification failed: $app_path" >&2
exit 1
fi
}

sign_dev_app() {
local app_path="$1"
local signing_identity

if ! command -v codesign >/dev/null 2>&1; then
echo "codesign is required to package the macOS development app." >&2
exit 1
fi

signing_identity="$(resolve_dev_signing_identity)"
echo "Signing development app with: $signing_identity"

npm run tauri -- build --bundles app
codesign --force --deep --sign "$signing_identity" "$app_path"
codesign --force --sign "$signing_identity" --identifier "$DEV_BUNDLE_IDENTIFIER" "$app_path"
verify_dev_app_signature "$app_path"
}

if [[ -d "$(dirname "$INSTALLED_APP")" && -w "$(dirname "$INSTALLED_APP")" ]]; then
ditto "$APP_BUNDLE" "$INSTALLED_APP"
touch "$INSTALLED_APP"
install_dev_app() {
local source_app="$1"
local installed_app="$2"

mkdir -p "$(dirname "$installed_app")"
rm -rf "$installed_app"
ditto "$source_app" "$installed_app"
touch "$installed_app"
verify_dev_app_signature "$installed_app"
if command -v mdimport >/dev/null 2>&1; then
mdimport "$INSTALLED_APP" >/dev/null 2>&1 || true
mdimport "$installed_app" >/dev/null 2>&1 || true
fi
INSTALLED_APP_UPDATED=1
else
echo
echo "WARNING: /Applications app was not updated."
echo "Skipped app install because $(dirname "$INSTALLED_APP") is not writable:"
echo " $INSTALLED_APP"
echo "Run ./dev-install.sh to install the rebuilt app with a macOS admin prompt."
}

if [[ ! -f "$DEV_TAURI_CONFIG" ]]; then
echo "Development Tauri config was not found: $DEV_TAURI_CONFIG" >&2
exit 1
fi

CLI_APP_BUNDLE="$APP_BUNDLE"
if [[ "$INSTALLED_APP_UPDATED" -eq 1 ]]; then
CLI_APP_BUNDLE="$INSTALLED_APP"
DEV_INSTALLED_APP="$(resolve_dev_install_path "$DEV_INSTALLED_APP_INPUT")"

npm run tauri -- build --bundles app --config "$DEV_TAURI_CONFIG"

if [[ ! -d "$DEV_APP_BUNDLE" ]]; then
echo "Packaged development app was not found after build:" >&2
echo " $DEV_APP_BUNDLE" >&2
exit 1
fi
IDE_CLI_APP_BUNDLE_PATH="$CLI_APP_BUNDLE" "$ROOT_DIR/scripts/install-cli-command.sh"

sign_dev_app "$DEV_APP_BUNDLE"
install_dev_app "$DEV_APP_BUNDLE" "$DEV_INSTALLED_APP"

"$ROOT_DIR/scripts/install-cli-command.sh"

echo
echo "Packaged app:"
echo " $APP_BUNDLE"
if [[ "$INSTALLED_APP_UPDATED" -eq 1 ]]; then
echo "Installed app:"
echo " $INSTALLED_APP"
else
echo "Installed app:"
echo " not updated"
fi
echo "Packaged development app:"
echo " $DEV_APP_BUNDLE"
echo "Installed development app:"
echo " $DEV_INSTALLED_APP"
80 changes: 3 additions & 77 deletions dev-install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,85 +2,11 @@
set -euo pipefail

ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
APP_BUNDLE="$ROOT_DIR/src-tauri/target/release/bundle/macos/ide.app"
INSTALLED_APP="${IDE_INSTALLED_APP_PATH:-/Applications/ide.app}"

if [[ "$(uname -s)" != "Darwin" ]]; then
echo "dev-install.sh currently installs the packaged macOS app only." >&2
echo "Use ./build.sh to build the local package on this platform." >&2
echo "dev-install.sh currently installs the packaged macOS development app only." >&2
echo "Use npm run tauri -- build to build the local package on this platform." >&2
exit 1
Comment thread
GordonBeeming marked this conversation as resolved.
fi

"$ROOT_DIR/build.sh"

if [[ ! -d "$APP_BUNDLE" ]]; then
echo "Packaged app was not found after build:" >&2
echo " $APP_BUNDLE" >&2
exit 1
fi

escape_applescript_string() {
sed 's/\\/\\\\/g; s/"/\\"/g'
}

shell_quote() {
printf "%q" "$1"
}

refresh_app_indexes() {
local app_path="$1"
touch "$app_path" || true
if command -v mdimport >/dev/null 2>&1; then
mdimport "$app_path" >/dev/null 2>&1 || true
fi
if [[ -x /System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister ]]; then
/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister \
-f "$app_path" >/dev/null 2>&1 || true
fi
}

quit_running_app() {
osascript -e 'tell application id "com.gordonbeeming.ide" to quit' >/dev/null 2>&1 || true
}

copy_app_directly() {
local source_app="$1"
local installed_app="$2"
mkdir -p "$(dirname "$installed_app")"
rm -rf "$installed_app"
ditto "$source_app" "$installed_app"
}

copy_app_with_admin_prompt() {
local source_app="$1"
local installed_app="$2"
local command escaped_command
command="mkdir -p $(shell_quote "$(dirname "$installed_app")") && rm -rf $(shell_quote "$installed_app") && ditto $(shell_quote "$source_app") $(shell_quote "$installed_app") && touch $(shell_quote "$installed_app")"
escaped_command="$(printf "%s" "$command" | escape_applescript_string)"

osascript <<OSA
display dialog "Install the freshly built ide.app into Applications? macOS will ask for permission if needed." buttons {"Cancel", "Install"} default button "Install"
do shell script "$escaped_command" with administrator privileges
OSA
}

echo "Preparing to install:"
echo " $APP_BUNDLE"
echo "to:"
echo " $INSTALLED_APP"

quit_running_app

if [[ -d "$(dirname "$INSTALLED_APP")" && -w "$(dirname "$INSTALLED_APP")" ]]; then
copy_app_directly "$APP_BUNDLE" "$INSTALLED_APP"
else
copy_app_with_admin_prompt "$APP_BUNDLE" "$INSTALLED_APP"
fi

refresh_app_indexes "$INSTALLED_APP"

open -R "$INSTALLED_APP"

echo "Installed app:"
echo " $INSTALLED_APP"
echo "If Spotlight still shows the old icon, give Spotlight a moment or relaunch Finder/Dock; macOS caches app icons aggressively."
exec "$ROOT_DIR/build.sh"
38 changes: 25 additions & 13 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Run the desktop app from the repository root:
./run.sh
```

The script checks for `npm` and `cargo`, installs Node dependencies when `node_modules` is missing, then starts Tauri dev mode.
The script checks for `npm` and `cargo`, installs Node dependencies when `node_modules` is missing, then starts the `ide-dev` Tauri app. Development uses its own app-data directory and loopback port `17878`, so it does not stop, replace, or reuse the production app.

Pass a file or folder path to open that target as the launch workspace:

Expand All @@ -32,37 +32,49 @@ Pass a file or folder path to open that target as the launch workspace:

Folder targets become the workspace root. File targets open their parent folder as the workspace and then open the file as a persistent tab.

When a file or folder target is supplied and an ide instance is already reachable on the loopback API, `run.sh` authenticates with the persisted app-local bearer token and hands the target to `/api/open-path` instead of starting another dev instance.
When a file or folder target is supplied and an `ide-dev` instance is already reachable on its loopback API, `run.sh` authenticates with the persisted app-local bearer token and hands the target to `/api/open-path` instead of starting another development instance.

When no target is supplied and an ide instance is already reachable, `run.sh` stops it and starts the dev build in its place: a graceful quit through AppleScript first, then a force-kill if the app is still holding the port after ~10 seconds. Plain `./run.sh` always means "run the code in this working copy", so a running release build (or a stale dev instance) can't block it. Without the stop, the single-instance plugin would swallow the launch.
When no target is supplied, `run.sh` replaces a reachable `ide-dev` instance only when this checkout owns its listener. If another process owns the port, it refuses rather than interfering. Plain `./run.sh` always means "run the code in this working copy"; production stays running if it is open.

Install shell commands:
Install the production app through Homebrew:

```bash
brew install --cask gordonbeeming/tap/ide
```

Homebrew owns `/Applications/ide.app`, bundle identifier `com.gordonbeeming.ide`, and loopback port `17877`. Local development scripts never replace that bundle.

Package and install the development app:

```bash
./build.sh
```

The build script runs `npm run tauri -- build --bundles app`, then installs command launchers. It builds the macOS `.app` bundle only and skips DMG creation because the local CLI command does not need an installer image. If `/Applications` is writable, it replaces `/Applications/ide.app`; otherwise run the interactive developer installer:
`build.sh` uses the development Tauri overlay to create `ide-dev.app`, then replaces only `$HOME/Applications/ide-dev.app`. Set `IDE_DEV_INSTALLED_APP_PATH` to an isolated absolute path ending in `ide-dev.app` when testing the installer. The script rejects the production bundle and broad paths before it replaces an existing development bundle.

Before copying the app, `build.sh` signs nested code and the bundle with `IDE_DEV_SIGNING_IDENTITY` when it is set. Otherwise it uses the first local Apple Development identity, or ad-hoc signing when none is available, then runs `codesign --verify --deep --strict`. This is local development signing only; it does not use Developer ID signing or notarization.

`dev-install.sh` remains as a compatibility wrapper and delegates directly to `build.sh`:

```bash
./dev-install.sh
```

`dev-install.sh` runs the build, prompts for the `/Applications/ide.app` replacement when admin permission is needed, refreshes Spotlight/Launch Services metadata where possible, and reveals the installed app in Finder. macOS caches app icons, so quit/reopen ide and give Spotlight a moment if the previous icon is still visible.
There is one install-path check and copy policy, both owned by `build.sh`.

The installer writes `ide` as a small macOS `open` launcher for the packaged app bundle and links `ide-dev` to `run.sh`. Use `ide .` when the packaged app should open or focus a workspace window without holding the terminal; use bare `ide` to focus a running app or open the last context; and use `ide-dev .` when the repository dev runner should manage Node dependencies, Vite, stale dev ports, and running-app handoff behavior. Set `IDE_CLI_APP_BUNDLE_PATH=/path/to/ide.app` when installing if the command should target a different packaged bundle.
The installer writes `ide` as a small macOS launcher pinned to Homebrew's `/Applications/ide.app` and links `ide-dev` to `run.sh`. Use `ide .` to open a production workspace without holding the terminal. Use bare `ide` to focus production or reopen its last context. Use `ide-dev .` when the repository development runner should manage Node dependencies, Vite, stale development ports, and development-app handoff.

`ide browse <path>` opens a repo in the default browser instead of a native window. If no instance is running yet, it starts one in the background, HTTP server up but no window on screen, and gives the target repo its own hidden session. If an instance is already running, it reuses it the same way, so several repos can be browsed at once without any of them showing a window or stealing focus. Closing a visible ide window doesn't stop a background browse session; only quitting the app (⌘Q) does.
`ide browse <path>` uses the production app and opens a repo in the default browser instead of a native window. If no production instance is running yet, it starts one in the background, HTTP server up but no window on screen, and gives the target repo its own hidden session. If an instance is already running, it reuses it the same way, so several repos can be browsed at once without any of them showing a window or stealing focus. Closing a visible ide window doesn't stop a background browse session; only quitting the app (⌘Q) does.

Install the macOS Finder Quick Action:

```bash
./scripts/install-macos-finder-quick-action.sh
```

The service appears under Finder's Quick Actions menu as `Open in ide`. It hands the selected file or folder to an already-running app through the loopback open-path endpoint when possible, otherwise it starts the local dev app in the background. Launcher logs are written to `~/Library/Logs/ide/finder-open.log`.
The service appears under Finder's Quick Actions menu as `Open in ide`. It hands the selected file or folder to a running production app through the loopback open-path endpoint when possible; otherwise it opens `/Applications/ide.app` with that target. Install the production app through Homebrew first. Launcher logs are written to `~/Library/Logs/ide/finder-open.log`.

`npm run finder:check` validates the generated Quick Action and runner in a temporary directory. It verifies that the service registers for files and folders, emits a valid plist/workflow, and hands targets to `/api/open-path` with the local bearer token before `run-tests.sh` moves on to browser smoke tests.
`npm run finder:check` validates the generated Quick Action and runner in a temporary directory. It verifies that the service registers for files and folders, emits a valid plist/workflow, hands targets to the production `/api/open-path` endpoint with its bearer token, and falls back to `/Applications/ide.app`.

Packaged builds declare Tauri `bundle.fileAssociations` for common text, code, web, config, and .NET project files. Tauri emits `RunEvent::Opened` when the OS opens a file with the app; the Rust backend converts those file URLs into launch targets and opens or focuses a workspace window inside the existing app process. The packaged CLI uses the same single-process path instead of `open -n`, so multiple workspace windows group under one Dock icon.

Expand Down Expand Up @@ -97,13 +109,13 @@ npm run tauri:dev

`npm run budget` checks the production `dist/` output after `npm run build`. Current raw-size limits are 600 KB for startup JavaScript, 80 KB for startup CSS, and 90 KB for the lazy editor chunk. These are deliberately above the current app size, but low enough to catch accidental heavy runtime dependencies.

`npm run finder:check` runs the macOS Finder Quick Action installer against temporary service/support directories, lints the generated workflow on macOS, and checks that the runner uses the authenticated loopback open-path handoff. It does not touch the real `~/Library/Services` directory.
`npm run finder:check` runs the macOS Finder Quick Action installer against temporary service/support directories, lints the generated workflow on macOS, and checks that the runner uses the production loopback handoff or opens `/Applications/ide.app`. It does not touch the real `~/Library/Services` directory.

`npm run launch:check` validates the local launch runners. It checks that `run.sh` and the generated Finder runner both use the authenticated loopback open-path handoff before attempting to start or reuse a dev server.
`npm run launch:check` validates the local launch runners. It checks that `run.sh` hands paths to `ide-dev` on its authenticated development loopback endpoint, and that the generated `ide` launcher remains pinned to the Homebrew production app.

`npm run menu:check` validates the native menu contract. It checks that every declared Tauri menu item has a Rust route, every expected native event is emitted by Rust, and every emitted app/menu event has a React listener.

`npm run tauri:check` validates the Tauri bundle metadata that affects native daily-driver behavior. It checks the developer-tool category, file association shape, required common editor extensions, text-only MIME types, duplicate extensions, and rejects obvious binary/media/archive associations.
`npm run tauri:check` validates the Tauri bundle metadata that affects native daily-driver behavior. It keeps the production identifier and lowercase product name in place, checks the development overlay's distinct identifier and name, and verifies the production file associations still contain only supported text formats.

`npm run smoke` starts Vite on a local ephemeral port, mocks the loopback API, and drives the real app shell through a local Chromium-family browser in light and dark mode. It covers collapsed search controls, command palette execution, workspace filtering, content search, opening a file, clean-save button state, and shell/editor theme alignment for both the empty editor canvas and the loaded CodeMirror canvas. The theme check asserts matching computed colors, expected light/dark luminance, and the actual painted editor center color, then temporarily forces the opposite editor-region theme class to prove the editor background still inherits from the shell. Set `IDE_SMOKE_BROWSER=/path/to/browser` if the script cannot find Chrome, Chromium, or Edge.

Expand Down
Loading
Loading