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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ Download artifacts from the GitHub Actions release workflow or from GitHub Relea

See [docs/RELEASES.md](docs/RELEASES.md) for artifact download and smoke-test steps.

The macOS release also includes an unsigned `Stringcast.app` wrapper. See [docs/MACOS_APP.md](docs/MACOS_APP.md) for build and testing notes.
The macOS release also includes an unsigned `Stringcast.app` bundle with a companion menu-bar item. See [docs/MACOS_APP.md](docs/MACOS_APP.md) for build and testing notes.

## Development

Expand Down
46 changes: 39 additions & 7 deletions docs/MACOS_APP.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# macOS App Wrapper

Stringcast can be packaged as an unsigned `.app` bundle around the Rust CLI binary. This gives macOS a stable app identity for permissions and Keychain prompts while the runtime still uses the existing CLI internally.
Stringcast can be packaged as an unsigned `.app` bundle where the Rust runtime is the app executable. This keeps the permission-sensitive keyboard listening and replacement work under `Stringcast.app` instead of a separate helper binary.

This is not a full menu-bar UI yet. Launching the app starts `stringcast run` in the background.
Launching the app starts the runtime and adds a `Stringcast` companion item to the macOS menu bar.

## Build Locally

Expand All @@ -13,6 +13,18 @@ cargo build --release
packaging/macos/build_app.sh
```

To include the app icon, save a square PNG at:

```text
packaging/macos/StringcastIcon.png
```

The packaging script converts it to `Contents/Resources/StringcastIcon.icns`. You can also use a different source path for local builds:

```bash
STRINGCAST_ICON=/path/to/icon.png packaging/macos/build_app.sh
```

The app is written to:

```text
Expand All @@ -25,7 +37,17 @@ Open it:
open dist/macos/Stringcast.app
```

Stop it:
Use the menu-bar item to:

- View status
- Request permissions
- Run an API test
- Open the config file
- Open logs
- Reveal the app executable
- Quit

Stop it from Terminal if needed:

```bash
pkill -f "Stringcast.app"
Expand All @@ -41,18 +63,28 @@ macOS may ask for:

If permissions were granted to a terminal binary before, macOS may ask again because `Stringcast.app` has a different app identity.

Grant permissions to `Stringcast.app`. The runtime now runs as the app executable:

- `dist/macos/Stringcast.app/Contents/MacOS/Stringcast`

If macOS still shows a stale `Stringcast` entry from an older local build, remove the old entry and add the rebuilt `Stringcast.app` again. To locate the current executable manually, use `Reveal App Executable` from the menu, or open Finder's Go to Folder dialog and paste:

```text
dist/macos/Stringcast.app/Contents/MacOS/
```

The app does not block startup on this permission check. If permissions are missing, Stringcast can still show as running, but keyboard listening or text replacement may not work until macOS grants the required permissions.

## Current Limitations

- The app is unsigned.
- There is no menu-bar UI yet.
- There is no visible quit control yet.
- Logs are not surfaced in an app window.
- There is no custom app icon yet.
- Logs open in Finder rather than an in-app viewer.
- Packaging does not create a DMG or installer yet.

## Next Packaging Steps

- Add an icon.
- Add a menu-bar process with Start/Stop/Quit controls.
- Add log/status display.
- Add code signing and notarization.
- Produce a DMG for end users.
2 changes: 2 additions & 0 deletions docs/RELEASES.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ Or launch the app wrapper:
open Stringcast.app
```

The app runs Stringcast as the app executable and adds a companion menu-bar item with status, request permissions, API test, config, logs, reveal executable, and quit actions.

macOS may ask for:

- Accessibility permission for keyboard automation.
Expand Down
2 changes: 2 additions & 0 deletions packaging/macos/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
<string>6.0</string>
<key>CFBundleName</key>
<string>Stringcast</string>
<key>CFBundleIconFile</key>
<string>StringcastIcon</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
Expand Down
7 changes: 0 additions & 7 deletions packaging/macos/Stringcast

This file was deleted.

Binary file added packaging/macos/StringcastIcon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
196 changes: 196 additions & 0 deletions packaging/macos/StringcastMenu.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import AppKit
import Darwin
import Foundation

final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
private var statusItem: NSStatusItem!
private let menu = NSMenu()
private let stateItem = NSMenuItem(title: "Stringcast: Running", action: nil, keyEquivalent: "")
private let runtimePid = Int32(ProcessInfo.processInfo.environment["STRINGCAST_APP_RUNTIME_PID"] ?? "")

private var executableURL: URL {
Bundle.main.bundleURL
.appendingPathComponent("Contents")
.appendingPathComponent("MacOS")
.appendingPathComponent("Stringcast")
}

func applicationDidFinishLaunching(_ notification: Notification) {
configureStatusItem()
}

private func configureStatusItem() {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
statusItem.button?.title = "Stringcast On"
statusItem.menu = menu

menu.delegate = self
stateItem.isEnabled = false
menu.addItem(stateItem)
menu.addItem(NSMenuItem.separator())

addMenuItem("Status", #selector(showStatus), "i")
addMenuItem("Request Permissions", #selector(requestPermissions), "r")
addMenuItem("Run API Test", #selector(runApiTest), "t")
addMenuItem("Open Config", #selector(openConfig), "o")
addMenuItem("Open Logs", #selector(openLogs), "l")
addMenuItem("Reveal App Executable", #selector(revealAppExecutable), "h")

menu.addItem(NSMenuItem.separator())
addMenuItem("Quit", #selector(quit), "q")
updateMenuState()
}

func menuWillOpen(_ menu: NSMenu) {
updateMenuState()
}

private func addMenuItem(_ title: String, _ action: Selector, _ keyEquivalent: String = "") {
let item = NSMenuItem(title: title, action: action, keyEquivalent: keyEquivalent)
item.target = self
menu.addItem(item)
}

@objc private func showStatus() {
runCommand(title: "Stringcast Status", arguments: ["status"])
}

@objc private func requestPermissions() {
DispatchQueue.global(qos: .userInitiated).async {
let result = self.commandOutput(arguments: ["request-permissions"])
DispatchQueue.main.async {
self.showPermissionAlert(message: result.output)
}
}
}

@objc private func runApiTest() {
runCommand(title: "Stringcast API Test", arguments: ["api-test"])
}

@objc private func openConfig() {
DispatchQueue.global(qos: .userInitiated).async {
let result = self.commandOutput(arguments: ["show-config"])
DispatchQueue.main.async {
guard result.exitCode == 0 else {
self.showAlert(title: "Could Not Find Config", message: result.output)
return
}

let path = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
NSWorkspace.shared.open(URL(fileURLWithPath: path))
}
}
}

@objc private func openLogs() {
NSWorkspace.shared.open(logDirectoryURL())
}

@objc private func revealAppExecutable() {
NSWorkspace.shared.activateFileViewerSelecting([executableURL])
}

@objc private func quit() {
if let runtimePid, runtimePid > 0 {
Darwin.kill(runtimePid, SIGTERM)
}
NSApp.terminate(nil)
}

private func runCommand(title: String, arguments: [String]) {
DispatchQueue.global(qos: .userInitiated).async {
let result = self.commandOutput(arguments: arguments)
DispatchQueue.main.async {
self.showAlert(title: title, message: result.output, isError: result.exitCode != 0)
}
}
}

private func commandOutput(arguments: [String]) -> (exitCode: Int32, output: String) {
let process = Process()
process.executableURL = executableURL
process.arguments = arguments

let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe

do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
return (process.terminationStatus, output?.isEmpty == false ? output! : "(no output)")
} catch {
return (1, error.localizedDescription)
}
}

private func updateMenuState() {
let running = runtimeIsRunning()
stateItem.title = running ? "Stringcast: Running" : "Stringcast: Stopped"
statusItem.button?.title = running ? "Stringcast On" : "Stringcast Off"
}

private func runtimeIsRunning() -> Bool {
guard let runtimePid, runtimePid > 0 else {
return false
}
return Darwin.kill(runtimePid, 0) == 0
}

private func logDirectoryURL() -> URL {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
let directory = base.appendingPathComponent("Stringcast", isDirectory: true)
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
return directory
}

private func showAlert(title: String, message: String, isError: Bool = false) {
let alert = NSAlert()
alert.messageText = title
alert.informativeText = message
alert.alertStyle = isError ? .warning : .informational
alert.addButton(withTitle: "OK")
alert.runModal()
}

private func showPermissionAlert(message: String) {
let alert = NSAlert()
alert.messageText = "Stringcast Needs Permissions"
alert.informativeText = """
\(message)

Grant permissions to Stringcast.app. The runtime now runs as the app executable:
\(executableURL.path)
"""
alert.alertStyle = .warning
alert.addButton(withTitle: "Open Accessibility")
alert.addButton(withTitle: "Open Input Monitoring")
alert.addButton(withTitle: "Reveal App Executable")
alert.addButton(withTitle: "OK")

let response = alert.runModal()
if response == .alertFirstButtonReturn {
openSystemSettings("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")
} else if response == .alertSecondButtonReturn {
openSystemSettings("x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent")
} else if response == .alertThirdButtonReturn {
revealAppExecutable()
}
}

private func openSystemSettings(_ urlString: String) {
guard let url = URL(string: urlString) else {
return
}
NSWorkspace.shared.open(url)
}
}

let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate
app.setActivationPolicy(.accessory)
app.run()
59 changes: 55 additions & 4 deletions packaging/macos/build_app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ if [ "${1:-}" = "--help" ]; then
cat <<'USAGE'
usage: packaging/macos/build_app.sh [release-binary-path] [output-dir]

Builds an unsigned Stringcast.app bundle around the Rust CLI binary.
Builds an unsigned Stringcast.app bundle where the Rust runtime is the app executable.

Defaults:
release-binary-path: target/release/stringcast
Expand All @@ -20,20 +20,71 @@ APP_DIR="$OUT_DIR/Stringcast.app"
CONTENTS_DIR="$APP_DIR/Contents"
MACOS_DIR="$CONTENTS_DIR/MacOS"
RESOURCES_DIR="$CONTENTS_DIR/Resources"
ICON_SOURCE=${STRINGCAST_ICON:-packaging/macos/StringcastIcon.png}

if [ ! -f "$BIN_PATH" ]; then
echo "missing binary: $BIN_PATH" >&2
echo "run: cargo build --release" >&2
exit 1
fi

if [ "$(uname -s)" != "Darwin" ]; then
echo "macOS app bundles can only be built on macOS" >&2
exit 1
fi

if ! command -v xcrun >/dev/null 2>&1; then
echo "missing xcrun; install Xcode command line tools" >&2
exit 1
fi

if ! xcrun --sdk macosx --find swiftc >/dev/null 2>&1; then
echo "missing swiftc; install Xcode command line tools" >&2
exit 1
fi

if ! command -v sips >/dev/null 2>&1; then
echo "missing sips; cannot generate app icon" >&2
exit 1
fi

if ! command -v iconutil >/dev/null 2>&1; then
echo "missing iconutil; cannot generate app icon" >&2
exit 1
fi

rm -rf "$APP_DIR"
mkdir -p "$MACOS_DIR" "$RESOURCES_DIR"

cp packaging/macos/Info.plist "$CONTENTS_DIR/Info.plist"
cp packaging/macos/Stringcast "$MACOS_DIR/Stringcast"
cp "$BIN_PATH" "$RESOURCES_DIR/stringcast"
cp "$BIN_PATH" "$MACOS_DIR/Stringcast"
xcrun --sdk macosx swiftc \
-module-cache-path "${TMPDIR:-/tmp}/stringcast-swift-module-cache" \
packaging/macos/StringcastMenu.swift \
-framework AppKit \
-o "$MACOS_DIR/StringcastMenu"

if [ -f "$ICON_SOURCE" ]; then
ICONSET_DIR="$OUT_DIR/StringcastIcon.iconset"
rm -rf "$ICONSET_DIR"
mkdir -p "$ICONSET_DIR"
sips -z 16 16 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_16x16.png" >/dev/null
sips -z 32 32 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_16x16@2x.png" >/dev/null
sips -z 32 32 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_32x32.png" >/dev/null
sips -z 64 64 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_32x32@2x.png" >/dev/null
sips -z 128 128 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_128x128.png" >/dev/null
sips -z 256 256 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_128x128@2x.png" >/dev/null
sips -z 256 256 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_256x256.png" >/dev/null
sips -z 512 512 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_256x256@2x.png" >/dev/null
sips -z 512 512 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_512x512.png" >/dev/null
sips -z 1024 1024 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_512x512@2x.png" >/dev/null
iconutil -c icns "$ICONSET_DIR" -o "$RESOURCES_DIR/StringcastIcon.icns"
rm -rf "$ICONSET_DIR"
else
echo "warning: icon source not found: $ICON_SOURCE" >&2
echo " save the app icon PNG there, or set STRINGCAST_ICON=/path/to/icon.png" >&2
fi

chmod +x "$MACOS_DIR/Stringcast" "$RESOURCES_DIR/stringcast"
chmod +x "$MACOS_DIR/Stringcast" "$MACOS_DIR/StringcastMenu"

echo "Built $APP_DIR"
Loading
Loading