mirror of
https://github.com/saymrwulf/puncture.git
synced 2026-06-14 01:13:43 +00:00
287 lines
8.6 KiB
Go
287 lines
8.6 KiB
Go
//go:build darwin && cgo && desktop
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const defaultSimBundleID = "com.puncture.emergency"
|
|
|
|
type simctlDevice struct {
|
|
UDID string `json:"udid"`
|
|
Name string `json:"name"`
|
|
State string `json:"state"`
|
|
IsAvailable bool `json:"isAvailable"`
|
|
}
|
|
|
|
type simctlDevicesPayload struct {
|
|
Devices map[string][]simctlDevice `json:"devices"`
|
|
}
|
|
|
|
func maybeLaunchSimulatorCompanion(desktopProcess string) {
|
|
if !envBool("PUNCTURE_WITH_SIMULATOR", true) {
|
|
log.Printf("sim companion: disabled by PUNCTURE_WITH_SIMULATOR")
|
|
return
|
|
}
|
|
go func() {
|
|
if err := launchSimulatorCompanion(desktopProcess); err != nil {
|
|
log.Printf("sim companion: %v", err)
|
|
}
|
|
}()
|
|
}
|
|
|
|
func launchSimulatorCompanion(desktopProcess string) error {
|
|
appPath := resolveSimulatorCompanionAppPath()
|
|
if appPath == "" {
|
|
return errors.New("iOS simulator companion app not found; build the DMG again to bundle it")
|
|
}
|
|
bundleID := getenv("PUNCTURE_SIM_BUNDLE_ID", defaultSimBundleID)
|
|
preferred := getenv("PUNCTURE_SIM_DEVICE", "iPhone 17 Pro")
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Second)
|
|
defer cancel()
|
|
|
|
udid, name, err := pickSimulatorDevice(ctx, preferred)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
log.Printf("sim companion: using device %s (%s)", name, udid)
|
|
|
|
if _, err := runCommand(ctx, "open", "-a", "Simulator", "--args", "-CurrentDeviceUDID", udid); err != nil {
|
|
// Fallback for older Simulator CLI behavior.
|
|
if _, retryErr := runCommand(ctx, "open", "-a", "Simulator"); retryErr != nil {
|
|
return fmt.Errorf("failed to open Simulator: %w", retryErr)
|
|
}
|
|
}
|
|
if out, err := runCommand(ctx, "xcrun", "simctl", "boot", udid); err != nil {
|
|
if !strings.Contains(strings.ToLower(out), "booted") {
|
|
return fmt.Errorf("failed to boot simulator: %w", err)
|
|
}
|
|
}
|
|
if _, err := runCommand(ctx, "xcrun", "simctl", "bootstatus", udid, "-b"); err != nil {
|
|
return fmt.Errorf("failed waiting for simulator boot: %w", err)
|
|
}
|
|
if _, err := runCommand(ctx, "xcrun", "simctl", "install", udid, appPath); err != nil {
|
|
return fmt.Errorf("failed to install companion app into simulator: %w", err)
|
|
}
|
|
launchOut, err := runCommand(ctx, "xcrun", "simctl", "launch", udid, bundleID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to launch companion app (%s): %w", bundleID, err)
|
|
}
|
|
if strings.TrimSpace(launchOut) != "" {
|
|
log.Printf("sim companion: %s", strings.TrimSpace(launchOut))
|
|
}
|
|
|
|
// Give both apps time to draw their first windows before window arrangement.
|
|
go func() {
|
|
time.Sleep(1300 * time.Millisecond)
|
|
_ = arrangeDesktopWithSimulator(desktopProcess)
|
|
time.Sleep(1800 * time.Millisecond)
|
|
_ = arrangeDesktopWithSimulator(desktopProcess)
|
|
}()
|
|
return nil
|
|
}
|
|
|
|
func resolveSimulatorCompanionAppPath() string {
|
|
candidates := []string{}
|
|
if explicit := strings.TrimSpace(os.Getenv("PUNCTURE_IOS_SIM_APP")); explicit != "" {
|
|
candidates = append(candidates, explicit)
|
|
}
|
|
if exe, err := os.Executable(); err == nil {
|
|
candidates = append(candidates,
|
|
filepath.Clean(filepath.Join(filepath.Dir(exe), "..", "Resources", "EmergencyPuncture.app")),
|
|
filepath.Clean(filepath.Join(filepath.Dir(exe), "..", "..", "..", "ios", "build-ios", "Build", "Products", "Debug-iphonesimulator", "EmergencyPuncture.app")),
|
|
)
|
|
}
|
|
if cwd, err := os.Getwd(); err == nil {
|
|
candidates = append(candidates,
|
|
filepath.Join(cwd, "ios", "build-ios", "Build", "Products", "Debug-iphonesimulator", "EmergencyPuncture.app"),
|
|
filepath.Join(cwd, "..", "ios", "build-ios", "Build", "Products", "Debug-iphonesimulator", "EmergencyPuncture.app"),
|
|
filepath.Join(cwd, "goapp", "ios", "build-ios", "Build", "Products", "Debug-iphonesimulator", "EmergencyPuncture.app"),
|
|
)
|
|
}
|
|
seen := map[string]struct{}{}
|
|
for _, cand := range candidates {
|
|
if cand == "" {
|
|
continue
|
|
}
|
|
abs, err := filepath.Abs(cand)
|
|
if err == nil {
|
|
cand = abs
|
|
}
|
|
if _, ok := seen[cand]; ok {
|
|
continue
|
|
}
|
|
seen[cand] = struct{}{}
|
|
info, statErr := os.Stat(cand)
|
|
if statErr == nil && info.IsDir() {
|
|
return cand
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func pickSimulatorDevice(ctx context.Context, preferredName string) (string, string, error) {
|
|
out, err := runCommand(ctx, "xcrun", "simctl", "list", "devices", "available", "-j")
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("failed to query simulator devices: %w", err)
|
|
}
|
|
var payload simctlDevicesPayload
|
|
if err := json.Unmarshal([]byte(out), &payload); err != nil {
|
|
return "", "", fmt.Errorf("failed to parse simulator list: %w", err)
|
|
}
|
|
|
|
type candidate struct {
|
|
Runtime string
|
|
Device simctlDevice
|
|
}
|
|
candidates := make([]candidate, 0, 24)
|
|
runtimes := make([]string, 0, len(payload.Devices))
|
|
for runtime := range payload.Devices {
|
|
if strings.Contains(strings.ToLower(runtime), "ios") {
|
|
runtimes = append(runtimes, runtime)
|
|
}
|
|
}
|
|
sort.Slice(runtimes, func(i, j int) bool { return runtimes[i] > runtimes[j] })
|
|
for _, runtime := range runtimes {
|
|
devs := append([]simctlDevice(nil), payload.Devices[runtime]...)
|
|
sort.Slice(devs, func(i, j int) bool {
|
|
if devs[i].State == devs[j].State {
|
|
return devs[i].Name < devs[j].Name
|
|
}
|
|
return devs[i].State == "Booted"
|
|
})
|
|
for _, dev := range devs {
|
|
if !dev.IsAvailable || dev.UDID == "" || dev.Name == "" {
|
|
continue
|
|
}
|
|
candidates = append(candidates, candidate{Runtime: runtime, Device: dev})
|
|
}
|
|
}
|
|
if len(candidates) == 0 {
|
|
return "", "", errors.New("no available iOS simulator device found")
|
|
}
|
|
|
|
preferredName = strings.TrimSpace(preferredName)
|
|
lowerPreferred := strings.ToLower(preferredName)
|
|
pick := func(match func(candidate) bool) (candidate, bool) {
|
|
for _, c := range candidates {
|
|
if match(c) {
|
|
return c, true
|
|
}
|
|
}
|
|
return candidate{}, false
|
|
}
|
|
|
|
if preferredName != "" {
|
|
if c, ok := pick(func(c candidate) bool {
|
|
return strings.EqualFold(c.Device.Name, preferredName) && strings.EqualFold(c.Device.State, "Booted")
|
|
}); ok {
|
|
return c.Device.UDID, c.Device.Name, nil
|
|
}
|
|
if c, ok := pick(func(c candidate) bool {
|
|
return strings.EqualFold(c.Device.Name, preferredName)
|
|
}); ok {
|
|
return c.Device.UDID, c.Device.Name, nil
|
|
}
|
|
if c, ok := pick(func(c candidate) bool {
|
|
return strings.Contains(strings.ToLower(c.Device.Name), lowerPreferred)
|
|
}); ok {
|
|
return c.Device.UDID, c.Device.Name, nil
|
|
}
|
|
}
|
|
if c, ok := pick(func(c candidate) bool {
|
|
return strings.EqualFold(c.Device.State, "Booted") && strings.Contains(strings.ToLower(c.Device.Name), "iphone")
|
|
}); ok {
|
|
return c.Device.UDID, c.Device.Name, nil
|
|
}
|
|
if c, ok := pick(func(c candidate) bool {
|
|
return strings.Contains(strings.ToLower(c.Device.Name), "iphone")
|
|
}); ok {
|
|
return c.Device.UDID, c.Device.Name, nil
|
|
}
|
|
return candidates[0].Device.UDID, candidates[0].Device.Name, nil
|
|
}
|
|
|
|
func arrangeDesktopWithSimulator(desktopProcess string) error {
|
|
desktopProcess = strings.TrimSpace(desktopProcess)
|
|
if desktopProcess == "" {
|
|
desktopProcess = "Puncture"
|
|
}
|
|
desktopProcess = strings.ReplaceAll(desktopProcess, `"`, "")
|
|
script := fmt.Sprintf(`
|
|
set topInset to 28
|
|
tell application "Finder"
|
|
set b to bounds of window of desktop
|
|
end tell
|
|
set screenW to item 3 of b
|
|
set screenH to item 4 of b
|
|
set leftW to (screenW * 62 / 100)
|
|
tell application "System Events"
|
|
if exists process "%[1]s" then
|
|
tell process "%[1]s"
|
|
if (count of windows) > 0 then
|
|
set position of window 1 to {0, topInset}
|
|
set size of window 1 to {leftW, screenH - topInset}
|
|
end if
|
|
end tell
|
|
end if
|
|
if exists process "Simulator" then
|
|
tell process "Simulator"
|
|
if (count of windows) > 0 then
|
|
set position of window 1 to {leftW, topInset}
|
|
set size of window 1 to {screenW - leftW, screenH - topInset}
|
|
end if
|
|
end tell
|
|
end if
|
|
if exists process "%[1]s" then
|
|
set frontmost of process "%[1]s" to true
|
|
end if
|
|
end tell
|
|
`, desktopProcess)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
|
defer cancel()
|
|
if _, err := runCommand(ctx, "osascript", "-e", script); err != nil {
|
|
log.Printf("sim companion: could not auto-arrange windows (%v)", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func runCommand(ctx context.Context, name string, args ...string) (string, error) {
|
|
cmd := exec.CommandContext(ctx, name, args...)
|
|
out, err := cmd.CombinedOutput()
|
|
text := strings.TrimSpace(string(out))
|
|
if err != nil {
|
|
if text == "" {
|
|
return "", err
|
|
}
|
|
return text, fmt.Errorf("%w: %s", err, text)
|
|
}
|
|
return text, nil
|
|
}
|
|
|
|
func envBool(key string, fallback bool) bool {
|
|
v := strings.TrimSpace(strings.ToLower(os.Getenv(key)))
|
|
if v == "" {
|
|
return fallback
|
|
}
|
|
switch v {
|
|
case "1", "true", "yes", "y", "on":
|
|
return true
|
|
case "0", "false", "no", "n", "off":
|
|
return false
|
|
default:
|
|
return fallback
|
|
}
|
|
}
|