Initial: MiniTerm Mac-App-Builder (umgezogen aus claude-projects)
@@ -0,0 +1,2 @@
|
|||||||
|
.build/
|
||||||
|
*.app/
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "swift-argument-parser",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-argument-parser",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382",
|
||||||
|
"version" : "1.8.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swiftterm",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/migueldeicaza/SwiftTerm.git",
|
||||||
|
"state" : {
|
||||||
|
"branch" : "main",
|
||||||
|
"revision" : "24a68bcadc479d945c7ca32f21ac0a8ab895c690"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version" : 2
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// swift-tools-version:5.9
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "MiniTerm",
|
||||||
|
platforms: [
|
||||||
|
.macOS(.v13)
|
||||||
|
],
|
||||||
|
dependencies: [
|
||||||
|
// Pin to a tag instead of `branch` if you prefer reproducible builds,
|
||||||
|
// e.g. .package(url: "...", from: "1.2.0")
|
||||||
|
.package(url: "https://github.com/migueldeicaza/SwiftTerm.git", branch: "main")
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.executableTarget(
|
||||||
|
name: "MiniTerm",
|
||||||
|
dependencies: ["SwiftTerm"]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# MiniTerm — winzige eigenständige Terminal-Apps für macOS
|
||||||
|
|
||||||
|
Ein minimaler, auf **SwiftTerm** basierender Terminal-Emulator. Sinn der Sache:
|
||||||
|
jede erzeugte `.app` ist eine **eigene macOS-App mit eigener Bundle-ID** — also
|
||||||
|
**eigenes Dock-Icon und eigener ⌘-Tab-Eintrag**. Genau das, was sich unter macOS
|
||||||
|
mit `--class` (wie bei Alacritty/Ghostty unter Linux) *nicht* lösen lässt.
|
||||||
|
|
||||||
|
Jede App startet in einem konfigurierbaren **Arbeitsverzeichnis** und führt einen
|
||||||
|
konfigurierbaren **Befehl** aus (z. B. `claude`). Für Claude Code reicht das
|
||||||
|
locker — SwiftTerm ist ein vollwertiger xterm-256color-kompatibler Emulator.
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
- macOS 13+ und **Xcode** bzw. die Command Line Tools (`xcode-select --install`)
|
||||||
|
- Swift (kommt mit Xcode)
|
||||||
|
|
||||||
|
## Schnellstart
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd MiniTerm
|
||||||
|
chmod +x make-app.sh
|
||||||
|
|
||||||
|
./make-app.sh \
|
||||||
|
--name "Claude · Projekt A" \
|
||||||
|
--bundle-id com.deinname.claude.projekta \
|
||||||
|
--workdir ~/code/projekt-a \
|
||||||
|
--command "claude"
|
||||||
|
```
|
||||||
|
|
||||||
|
Beim ersten Lauf wird das Binary einmal gebaut (`swift build -c release`,
|
||||||
|
lädt SwiftTerm). Danach liegt die App in `~/Applications/Claude · Projekt A.app`
|
||||||
|
— per Doppelklick starten, Dock-Icon erscheint als eigene App.
|
||||||
|
|
||||||
|
## Mehrere Apps (eigene Icons/Configs)
|
||||||
|
|
||||||
|
Einfach pro App einmal aufrufen. Das Binary wird nur einmal kompiliert und
|
||||||
|
wiederverwendet:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./make-app.sh --name "Claude · Webshop" --bundle-id com.deinname.claude.webshop \
|
||||||
|
--workdir ~/code/webshop --command "claude" --icon ./icons/shop.icns
|
||||||
|
|
||||||
|
./make-app.sh --name "Claude · Infra" --bundle-id com.deinname.claude.infra \
|
||||||
|
--workdir ~/code/infra --command "claude" --icon ./icons/infra.icns
|
||||||
|
|
||||||
|
./make-app.sh --name "Scratch Shell" --bundle-id com.deinname.scratch \
|
||||||
|
--workdir ~ --command "" # leer = einfache Shell
|
||||||
|
```
|
||||||
|
|
||||||
|
## Optionen
|
||||||
|
|
||||||
|
| Flag | Pflicht | Bedeutung |
|
||||||
|
|----------------|---------|--------------------------------------------------------|
|
||||||
|
| `--name` | ja | Anzeigename + Dateiname der App |
|
||||||
|
| `--bundle-id` | ja | Eindeutige Bundle-ID (gibt die eigene App-Identität) |
|
||||||
|
| `--workdir` | nein | Startverzeichnis (`~` wird expandiert; leer = Home) |
|
||||||
|
| `--command` | nein | Befehl beim Start (leer = nur eine Login-Shell) |
|
||||||
|
| `--font-size` | nein | Schriftgröße (Default 13) |
|
||||||
|
| `--font` | nein | Schriftfamilie (Default Menlo; Fallback System-Mono) |
|
||||||
|
| `--icon` | nein | PNG/JPG (wird automatisch konvertiert) oder `.icns` |
|
||||||
|
| `--out` | nein | Zielordner (Default `~/Applications`) |
|
||||||
|
|
||||||
|
## Icon aus einem PNG
|
||||||
|
|
||||||
|
`--icon` nimmt direkt ein **PNG** (oder JPG) — das Skript erzeugt daraus
|
||||||
|
automatisch alle nötigen Größen und das `.icns` (via `sips`/`iconutil`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./make-app.sh --name "Claude · Infra" --bundle-id com.deinname.claude.infra \
|
||||||
|
--workdir ~/code/infra --command "claude" --icon ~/Downloads/infra.png
|
||||||
|
```
|
||||||
|
|
||||||
|
Am besten ein **quadratisches** Bild nehmen, sonst verzerrt `sips` es. Eine
|
||||||
|
fertige `.icns`-Datei kannst du weiterhin auch direkt angeben.
|
||||||
|
|
||||||
|
## Wie die Konfiguration funktioniert
|
||||||
|
|
||||||
|
Das kompilierte Binary ist für alle Apps identisch. Die Unterschiede stecken in
|
||||||
|
der `Info.plist` jedes Bundles (Schlüssel `MTCommand`, `MTWorkingDirectory`,
|
||||||
|
`MTWindowTitle`, `MTFontSize`, `MTFontName`). Das Programm liest sie beim Start aus
|
||||||
|
`Bundle.main`. So genügt ein Build für beliebig viele Apps.
|
||||||
|
|
||||||
|
## Hinweise
|
||||||
|
|
||||||
|
- Der Befehl läuft immer über eine **Login-Shell** (`zsh -l`), damit PATH stimmt
|
||||||
|
(Homebrew, `~/.local/bin` usw.) und `claude` gefunden wird.
|
||||||
|
- Nach Beenden des Befehls fällt das Fenster in eine normale Shell zurück
|
||||||
|
(`exec zsh -l`), schließt sich also nicht sofort.
|
||||||
|
- Die App ist **ad-hoc signiert**. Beim ersten Start ggf. Rechtsklick → „Öffnen",
|
||||||
|
falls Gatekeeper meckert.
|
||||||
|
- SwiftTerm-Version: im `Package.swift` auf `branch: "main"`. Für reproduzierbare
|
||||||
|
Builds dort auf einen festen Tag (`from: "..."`) umstellen.
|
||||||
|
|
||||||
|
## Tastatur
|
||||||
|
|
||||||
|
- **Enter** sendet wie gewohnt einen Wagenrücklauf (`\r`) → Eingabe absenden.
|
||||||
|
- **Shift+Enter** sendet einen Zeilenumbruch (`\n`) → für mehrzeilige Eingaben,
|
||||||
|
z. B. in Claude Code. Gilt auch für Enter auf dem Ziffernblock.
|
||||||
|
- ⌘C / ⌘V funktionieren über das Edit-Menü.
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import AppKit
|
||||||
|
import SwiftTerm
|
||||||
|
|
||||||
|
// MARK: - Configuration
|
||||||
|
//
|
||||||
|
// Every value is read from the app bundle's Info.plist, so a single compiled
|
||||||
|
// binary can power many different .app bundles (each with its own command,
|
||||||
|
// working directory, title and icon). See make-app.sh.
|
||||||
|
//
|
||||||
|
// MTCommand String Command to run, e.g. "claude". Empty = plain shell.
|
||||||
|
// MTWorkingDirectory String Directory to start in. "~" is expanded. Empty = home.
|
||||||
|
// MTFontSize Number Font point size (default 13).
|
||||||
|
// MTFontName String Font family name (default "Menlo"). Falls back to
|
||||||
|
// the system monospaced font if the name is missing.
|
||||||
|
// MTWindowTitle String Window title (default = CFBundleName).
|
||||||
|
//
|
||||||
|
struct AppConfig {
|
||||||
|
let command: String
|
||||||
|
let workingDirectory: String?
|
||||||
|
let fontSize: CGFloat
|
||||||
|
let fontName: String
|
||||||
|
let windowTitle: String
|
||||||
|
|
||||||
|
static func load() -> AppConfig {
|
||||||
|
let info = Bundle.main.infoDictionary ?? [:]
|
||||||
|
|
||||||
|
let cmd = ((info["MTCommand"] as? String) ?? "")
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
|
var workdir: String? = nil
|
||||||
|
if let raw = info["MTWorkingDirectory"] as? String,
|
||||||
|
!raw.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||||
|
workdir = (raw as NSString).expandingTildeInPath
|
||||||
|
}
|
||||||
|
|
||||||
|
let size = (info["MTFontSize"] as? NSNumber)?.doubleValue ?? 13.0
|
||||||
|
|
||||||
|
let fontName = ((info["MTFontName"] as? String) ?? "")
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
|
let title = (info["MTWindowTitle"] as? String)
|
||||||
|
?? (info["CFBundleName"] as? String)
|
||||||
|
?? "Terminal"
|
||||||
|
|
||||||
|
return AppConfig(command: cmd,
|
||||||
|
workingDirectory: workdir,
|
||||||
|
fontSize: CGFloat(size),
|
||||||
|
fontName: fontName.isEmpty ? "Menlo" : fontName,
|
||||||
|
windowTitle: title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - App delegate
|
||||||
|
final class AppDelegate: NSObject, NSApplicationDelegate, LocalProcessTerminalViewDelegate {
|
||||||
|
private var window: NSWindow!
|
||||||
|
private var terminalView: LocalProcessTerminalView!
|
||||||
|
private var keyMonitor: Any?
|
||||||
|
private let config = AppConfig.load()
|
||||||
|
|
||||||
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
|
let frame = NSRect(x: 0, y: 0, width: 900, height: 560)
|
||||||
|
|
||||||
|
terminalView = LocalProcessTerminalView(frame: frame)
|
||||||
|
terminalView.processDelegate = self
|
||||||
|
terminalView.font = resolvedFont()
|
||||||
|
|
||||||
|
installKeyMonitor()
|
||||||
|
|
||||||
|
window = NSWindow(
|
||||||
|
contentRect: frame,
|
||||||
|
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
||||||
|
backing: .buffered,
|
||||||
|
defer: false)
|
||||||
|
window.title = config.windowTitle
|
||||||
|
window.contentView = terminalView
|
||||||
|
window.setFrameAutosaveName("MiniTermMain")
|
||||||
|
window.center()
|
||||||
|
window.makeKeyAndOrderFront(nil)
|
||||||
|
window.makeFirstResponder(terminalView)
|
||||||
|
|
||||||
|
setupMenu()
|
||||||
|
launchProcess()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func launchProcess() {
|
||||||
|
let shell = userShell()
|
||||||
|
|
||||||
|
// We always go through a LOGIN shell (-l) so PATH is set up exactly like
|
||||||
|
// in a normal terminal (Homebrew, ~/.local/bin, etc.). Without this,
|
||||||
|
// tools like `claude` are often "command not found".
|
||||||
|
let args: [String]
|
||||||
|
if config.command.isEmpty {
|
||||||
|
args = ["-l"]
|
||||||
|
} else {
|
||||||
|
// Run the requested command, then hand control to an interactive
|
||||||
|
// login shell so the window stays open after the command exits.
|
||||||
|
args = ["-l", "-c", "\(config.command); exec \(shell) -l"]
|
||||||
|
}
|
||||||
|
|
||||||
|
let env = Terminal.getEnvironmentVariables(termName: "xterm-256color", trueColor: true)
|
||||||
|
|
||||||
|
terminalView.startProcess(
|
||||||
|
executable: shell,
|
||||||
|
args: args,
|
||||||
|
environment: env,
|
||||||
|
execName: nil,
|
||||||
|
currentDirectory: config.workingDirectory)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intercept Shift+Return (and Shift+Enter on the numpad) before it reaches
|
||||||
|
// the terminal and send a line feed (\n) instead of the normal carriage
|
||||||
|
// return (\r). This lets CLIs like Claude Code insert a newline in
|
||||||
|
// multi-line input while plain Enter still submits. We use a local event
|
||||||
|
// monitor because SwiftTerm's keyDown(with:) is `public`, not `open`, so it
|
||||||
|
// can't be overridden from outside the module.
|
||||||
|
private func installKeyMonitor() {
|
||||||
|
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
|
||||||
|
guard let self = self else { return event }
|
||||||
|
// Only act when our window is focused and the keyboard focus is in
|
||||||
|
// the terminal (the view itself or one of its subviews).
|
||||||
|
guard self.window.isKeyWindow else { return event }
|
||||||
|
if let responder = self.window.firstResponder as? NSView,
|
||||||
|
!(responder === self.terminalView || responder.isDescendant(of: self.terminalView)) {
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
let isReturn = (event.keyCode == 36 || event.keyCode == 76)
|
||||||
|
let mods = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
|
||||||
|
if isReturn && mods == .shift {
|
||||||
|
self.terminalView.send(txt: "\n")
|
||||||
|
return nil // consume the event
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the configured font, falling back gracefully so the app never
|
||||||
|
// launches with a broken/empty font if the name doesn't exist on the system.
|
||||||
|
private func resolvedFont() -> NSFont {
|
||||||
|
if let f = NSFont(name: config.fontName, size: config.fontSize) {
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
if let menlo = NSFont(name: "Menlo", size: config.fontSize) {
|
||||||
|
return menlo
|
||||||
|
}
|
||||||
|
return NSFont.monospacedSystemFont(ofSize: config.fontSize, weight: .regular)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func userShell() -> String {
|
||||||
|
if let shell = ProcessInfo.processInfo.environment["SHELL"], !shell.isEmpty {
|
||||||
|
return shell
|
||||||
|
}
|
||||||
|
return "/bin/zsh"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupMenu() {
|
||||||
|
let mainMenu = NSMenu()
|
||||||
|
|
||||||
|
// Application menu
|
||||||
|
let appItem = NSMenuItem()
|
||||||
|
mainMenu.addItem(appItem)
|
||||||
|
let appMenu = NSMenu()
|
||||||
|
let name = config.windowTitle
|
||||||
|
appMenu.addItem(withTitle: "Hide \(name)",
|
||||||
|
action: #selector(NSApplication.hide(_:)),
|
||||||
|
keyEquivalent: "h")
|
||||||
|
appMenu.addItem(NSMenuItem.separator())
|
||||||
|
appMenu.addItem(withTitle: "Quit \(name)",
|
||||||
|
action: #selector(NSApplication.terminate(_:)),
|
||||||
|
keyEquivalent: "q")
|
||||||
|
appItem.submenu = appMenu
|
||||||
|
|
||||||
|
// Edit menu (enables ⌘C / ⌘V in the terminal)
|
||||||
|
let editItem = NSMenuItem()
|
||||||
|
mainMenu.addItem(editItem)
|
||||||
|
let editMenu = NSMenu(title: "Edit")
|
||||||
|
editMenu.addItem(withTitle: "Copy",
|
||||||
|
action: #selector(NSText.copy(_:)),
|
||||||
|
keyEquivalent: "c")
|
||||||
|
editMenu.addItem(withTitle: "Paste",
|
||||||
|
action: #selector(NSText.paste(_:)),
|
||||||
|
keyEquivalent: "v")
|
||||||
|
editItem.submenu = editMenu
|
||||||
|
|
||||||
|
NSApp.mainMenu = mainMenu
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: LocalProcessTerminalViewDelegate
|
||||||
|
func sizeChanged(source: LocalProcessTerminalView, newCols: Int, newRows: Int) {}
|
||||||
|
|
||||||
|
func setTerminalTitle(source: LocalProcessTerminalView, title: String) {
|
||||||
|
// Keep a stable window title. Uncomment to follow the shell's title:
|
||||||
|
// window.title = title.isEmpty ? config.windowTitle : title
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {}
|
||||||
|
|
||||||
|
func processTerminated(source: TerminalView, exitCode: Int32?) {
|
||||||
|
NSApp.terminate(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Entry point
|
||||||
|
let app = NSApplication.shared
|
||||||
|
app.setActivationPolicy(.regular) // a real Dock app, not a background process
|
||||||
|
let delegate = AppDelegate()
|
||||||
|
app.delegate = delegate
|
||||||
|
app.activate(ignoringOtherApps: true)
|
||||||
|
app.run()
|
||||||
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 898 B |
|
After Width: | Height: | Size: 898 B |
|
After Width: | Height: | Size: 509 B |
|
After Width: | Height: | Size: 462 B |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 110 KiB |
@@ -0,0 +1,156 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# make-app.sh — build a standalone macOS .app from the MiniTerm binary.
|
||||||
|
#
|
||||||
|
# Each generated .app has its OWN bundle id, so macOS treats it as a separate
|
||||||
|
# application: separate Dock icon, separate ⌘-Tab entry. Run this script once
|
||||||
|
# per app you want.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./make-app.sh \
|
||||||
|
# --name "Claude · Projekt A" \
|
||||||
|
# --bundle-id com.yourname.claude.projekta \
|
||||||
|
# --workdir ~/code/project-a \
|
||||||
|
# --command "claude" \
|
||||||
|
# [--font-size 13] \
|
||||||
|
# [--font "JetBrains Mono"] \
|
||||||
|
# [--icon ./icons/projekta.png] \ # .png/.jpg (auto-converted) or .icns
|
||||||
|
# [--out ~/Applications]
|
||||||
|
#
|
||||||
|
# Build the binary fresh? The script runs `swift build -c release` automatically
|
||||||
|
# the first time and reuses the result for further apps.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ---- defaults -------------------------------------------------------------
|
||||||
|
NAME=""
|
||||||
|
BUNDLE_ID=""
|
||||||
|
WORKDIR=""
|
||||||
|
COMMAND=""
|
||||||
|
FONT_SIZE="13"
|
||||||
|
FONT_NAME="Menlo"
|
||||||
|
ICON=""
|
||||||
|
OUTDIR="$HOME/Applications"
|
||||||
|
|
||||||
|
# ---- parse args -----------------------------------------------------------
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--name) NAME="$2"; shift 2 ;;
|
||||||
|
--bundle-id) BUNDLE_ID="$2"; shift 2 ;;
|
||||||
|
--workdir) WORKDIR="$2"; shift 2 ;;
|
||||||
|
--command) COMMAND="$2"; shift 2 ;;
|
||||||
|
--font-size) FONT_SIZE="$2"; shift 2 ;;
|
||||||
|
--font) FONT_NAME="$2"; shift 2 ;;
|
||||||
|
--icon) ICON="$2"; shift 2 ;;
|
||||||
|
--out) OUTDIR="$2"; shift 2 ;;
|
||||||
|
-h|--help)
|
||||||
|
grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||||
|
*) echo "Unknown option: $1" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -z "$NAME" || -z "$BUNDLE_ID" ]]; then
|
||||||
|
echo "Error: --name and --bundle-id are required." >&2
|
||||||
|
echo "Run './make-app.sh --help' for usage." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
# ---- build binary once ----------------------------------------------------
|
||||||
|
BIN=".build/release/MiniTerm"
|
||||||
|
if [[ ! -f "$BIN" ]]; then
|
||||||
|
echo "==> Building MiniTerm (release)…"
|
||||||
|
swift build -c release
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Keep workdir portable across machines/users: store paths under the current
|
||||||
|
# home directory as a literal "~", so each app expands it per-user at runtime
|
||||||
|
# (Swift's expandingTildeInPath). Note the shell may have already expanded a "~"
|
||||||
|
# you typed on the command line into an absolute $HOME path, so we normalize that
|
||||||
|
# back to "~" too. Paths outside $HOME stay absolute (genuinely machine-specific).
|
||||||
|
case "$WORKDIR" in
|
||||||
|
"$HOME") WORKDIR="~" ;;
|
||||||
|
"$HOME"/*) WORKDIR="~/${WORKDIR#"$HOME"/}" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# ---- assemble the .app bundle ---------------------------------------------
|
||||||
|
APP="$OUTDIR/$NAME.app"
|
||||||
|
echo "==> Creating $APP"
|
||||||
|
rm -rf "$APP"
|
||||||
|
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
|
||||||
|
|
||||||
|
cp "$BIN" "$APP/Contents/MacOS/MiniTerm"
|
||||||
|
chmod +x "$APP/Contents/MacOS/MiniTerm"
|
||||||
|
|
||||||
|
# Build an .icns from a square source image (PNG/JPG/…) using macOS tools.
|
||||||
|
make_icns_from_image() {
|
||||||
|
local src="$1" dest="$2"
|
||||||
|
local iconset
|
||||||
|
iconset="$(mktemp -d)/icon.iconset"
|
||||||
|
mkdir -p "$iconset"
|
||||||
|
# All sizes Apple expects in an .iconset
|
||||||
|
local sizes=(16 32 128 256 512)
|
||||||
|
for s in "${sizes[@]}"; do
|
||||||
|
sips -z "$s" "$s" "$src" --out "$iconset/icon_${s}x${s}.png" >/dev/null
|
||||||
|
sips -z "$((s*2))" "$((s*2))" "$src" --out "$iconset/icon_${s}x${s}@2x.png" >/dev/null
|
||||||
|
done
|
||||||
|
iconutil -c icns "$iconset" -o "$dest"
|
||||||
|
rm -rf "$(dirname "$iconset")"
|
||||||
|
}
|
||||||
|
|
||||||
|
ICON_KEY=""
|
||||||
|
if [[ -n "$ICON" ]]; then
|
||||||
|
if [[ ! -f "$ICON" ]]; then
|
||||||
|
echo " (warning: --icon file not found: $ICON; skipping icon)" >&2
|
||||||
|
elif [[ "$ICON" == *.icns ]]; then
|
||||||
|
cp "$ICON" "$APP/Contents/Resources/AppIcon.icns"
|
||||||
|
ICON_KEY="<key>CFBundleIconFile</key><string>AppIcon</string>"
|
||||||
|
elif command -v sips >/dev/null 2>&1 && command -v iconutil >/dev/null 2>&1; then
|
||||||
|
echo "==> Converting $ICON to .icns…"
|
||||||
|
if make_icns_from_image "$ICON" "$APP/Contents/Resources/AppIcon.icns"; then
|
||||||
|
ICON_KEY="<key>CFBundleIconFile</key><string>AppIcon</string>"
|
||||||
|
else
|
||||||
|
echo " (warning: icon conversion failed; building without icon)" >&2
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " (warning: sips/iconutil not available; pass a .icns file instead)" >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat > "$APP/Contents/Info.plist" <<PLIST
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleName</key><string>${NAME}</string>
|
||||||
|
<key>CFBundleDisplayName</key><string>${NAME}</string>
|
||||||
|
<key>CFBundleExecutable</key><string>MiniTerm</string>
|
||||||
|
<key>CFBundleIdentifier</key><string>${BUNDLE_ID}</string>
|
||||||
|
<key>CFBundlePackageType</key><string>APPL</string>
|
||||||
|
<key>CFBundleVersion</key><string>1</string>
|
||||||
|
<key>CFBundleShortVersionString</key><string>1.0</string>
|
||||||
|
<key>LSMinimumSystemVersion</key><string>13.0</string>
|
||||||
|
<key>NSHighResolutionCapable</key><true/>
|
||||||
|
${ICON_KEY}
|
||||||
|
<key>MTCommand</key><string>${COMMAND}</string>
|
||||||
|
<key>MTWorkingDirectory</key><string>${WORKDIR}</string>
|
||||||
|
<key>MTWindowTitle</key><string>${NAME}</string>
|
||||||
|
<key>MTFontSize</key><integer>${FONT_SIZE}</integer>
|
||||||
|
<key>MTFontName</key><string>${FONT_NAME}</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
PLIST
|
||||||
|
|
||||||
|
# ---- ad-hoc codesign (helps on Apple Silicon / Gatekeeper) ----------------
|
||||||
|
if command -v codesign >/dev/null 2>&1; then
|
||||||
|
codesign --force --deep --sign - "$APP" >/dev/null 2>&1 \
|
||||||
|
&& echo "==> Ad-hoc signed." \
|
||||||
|
|| echo " (codesign skipped/failed — app still runs)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Done: $APP"
|
||||||
|
echo " Command : ${COMMAND:-<plain shell>}"
|
||||||
|
echo " Workdir : ${WORKDIR:-<home>}"
|
||||||
|
echo " Font : ${FONT_NAME} ${FONT_SIZE}pt"
|
||||||
|
After Width: | Height: | Size: 96 KiB |