Add window size animation for Settings and build script for tty-fwd

- Implement smooth window size animations when switching Settings tabs
- Add Xcode build phase to automatically build tty-fwd universal binary
- Configure build script with proper input/output dependencies for efficiency
- Fix Settings window animation using NSViewAnimation API
- Update project configuration for optimized builds
This commit is contained in:
Peter Steinberger 2025-06-16 03:44:48 +02:00
parent 2324be0706
commit 066d2882f9
13 changed files with 170 additions and 111 deletions

View file

@ -126,6 +126,7 @@
788687ED2DFF4FCB00B22C15 /* Sources */,
788687EE2DFF4FCB00B22C15 /* Frameworks */,
788687EF2DFF4FCB00B22C15 /* Resources */,
A189466CB0AD49BEBE16B954 /* Build tty-fwd Universal Binary */,
);
buildRules = (
);
@ -261,6 +262,34 @@
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
A189466CB0AD49BEBE16B954 /* Build tty-fwd Universal Binary */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"$(SRCROOT)/tty-fwd/src/main.rs",
"$(SRCROOT)/tty-fwd/src/protocol.rs",
"$(SRCROOT)/tty-fwd/src/tty_spawn.rs",
"$(SRCROOT)/tty-fwd/src/utils.rs",
"$(SRCROOT)/tty-fwd/Cargo.toml",
"$(SRCROOT)/tty-fwd/build-universal.sh",
);
name = "Build tty-fwd Universal Binary";
outputFileListPaths = (
);
outputPaths = (
"$(BUILT_PRODUCTS_DIR)/$(CONTENTS_FOLDER_PATH)/Resources/tty-fwd",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "# Build tty-fwd universal binary\necho \"Building tty-fwd universal binary...\"\n\n# Get the project directory\nPROJECT_DIR=\"${SRCROOT}\"\nTTY_FWD_DIR=\"${PROJECT_DIR}/tty-fwd\"\nBUILD_SCRIPT=\"${TTY_FWD_DIR}/build-universal.sh\"\nSOURCE_BINARY=\"${TTY_FWD_DIR}/target/release/tty-fwd-universal\"\nDEST_BINARY=\"${BUILT_PRODUCTS_DIR}/${CONTENTS_FOLDER_PATH}/Resources/tty-fwd\"\n\n# Check if build script exists\nif [ ! -f \"${BUILD_SCRIPT}\" ]; then\n echo \"error: Build script not found at ${BUILD_SCRIPT}\"\n exit 1\nfi\n\n# Make build script executable\nchmod +x \"${BUILD_SCRIPT}\"\n\n# Change to tty-fwd directory and run build\ncd \"${TTY_FWD_DIR}\"\n./build-universal.sh\n\n# Check if build succeeded\nif [ ! -f \"${SOURCE_BINARY}\" ]; then\n echo \"error: Universal binary not found at ${SOURCE_BINARY}\"\n exit 1\nfi\n\n# Create Resources directory if it doesn't exist\nmkdir -p \"${BUILT_PRODUCTS_DIR}/${CONTENTS_FOLDER_PATH}/Resources\"\n\n# Copy the binary\ncp \"${SOURCE_BINARY}\" \"${DEST_BINARY}\"\nchmod +x \"${DEST_BINARY}\"\n\necho \"tty-fwd universal binary copied to ${DEST_BINARY}\"\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
788687ED2DFF4FCB00B22C15 /* Sources */ = {
isa = PBXSourcesBuildPhase;
@ -361,6 +390,8 @@
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_STRICT_CONCURRENCY = complete;
SWIFT_VERSION = 6.0;
};
name = Debug;
};
@ -418,6 +449,8 @@
SDKROOT = macosx;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_STRICT_CONCURRENCY = complete;
SWIFT_VERSION = 6.0;
};
name = Release;
};
@ -450,7 +483,6 @@
PRODUCT_BUNDLE_IDENTIFIER = sh.vibetunnel.vibetunnel;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
};
name = Debug;
};
@ -483,7 +515,6 @@
PRODUCT_BUNDLE_IDENTIFIER = sh.vibetunnel.vibetunnel;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
};
name = Release;
};
@ -501,7 +532,6 @@
PRODUCT_BUNDLE_IDENTIFIER = sh.vibetunnel.vibetunnelTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/VibeTunnel.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/VibeTunnel";
};
name = Debug;
@ -520,7 +550,6 @@
PRODUCT_BUNDLE_IDENTIFIER = sh.vibetunnel.vibetunnelTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/VibeTunnel.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/VibeTunnel";
};
name = Release;
@ -537,7 +566,6 @@
PRODUCT_BUNDLE_IDENTIFIER = com.steipete.VibeTunnelUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TEST_TARGET_NAME = VibeTunnel;
};
name = Debug;

View file

@ -22,7 +22,7 @@ public struct TunnelSession: Identifiable, Codable, Sendable {
}
/// Request to create a new terminal session
public struct CreateSessionRequest: Codable {
public struct CreateSessionRequest: Codable, Sendable {
public let workingDirectory: String?
public let environment: [String: String]?
public let shell: String?
@ -35,7 +35,7 @@ public struct CreateSessionRequest: Codable {
}
/// Response after creating a session
public struct CreateSessionResponse: Codable {
public struct CreateSessionResponse: Codable, Sendable {
public let sessionId: String
public let createdAt: Date
@ -46,7 +46,7 @@ public struct CreateSessionResponse: Codable {
}
/// Command execution request
public struct CommandRequest: Codable {
public struct CommandRequest: Codable, Sendable {
public let sessionId: String
public let command: String
public let args: [String]?
@ -61,7 +61,7 @@ public struct CommandRequest: Codable {
}
/// Command execution response
public struct CommandResponse: Codable {
public struct CommandResponse: Codable, Sendable {
public let sessionId: String
public let output: String?
public let error: String?
@ -84,7 +84,7 @@ public struct CommandResponse: Codable {
}
/// Session information
public struct SessionInfo: Codable {
public struct SessionInfo: Codable, Sendable {
public let id: String
public let createdAt: Date
public let lastActivity: Date
@ -99,7 +99,7 @@ public struct SessionInfo: Codable {
}
/// List sessions response
public struct ListSessionsResponse: Codable {
public struct ListSessionsResponse: Codable, Sendable {
public let sessions: [SessionInfo]
public init(sessions: [SessionInfo]) {
@ -134,7 +134,7 @@ extension TunnelSession {
}
/// Request to create a new session
public struct CreateRequest: Codable {
public struct CreateRequest: Codable, Sendable {
public let clientInfo: ClientInfo?
public init(clientInfo: ClientInfo? = nil) {
@ -143,7 +143,7 @@ extension TunnelSession {
}
/// Response after creating a session
public struct CreateResponse: Codable {
public struct CreateResponse: Codable, Sendable {
public let id: String
public let session: TunnelSession
@ -154,7 +154,7 @@ extension TunnelSession {
}
/// Request to execute a command
public struct ExecuteCommandRequest: Codable {
public struct ExecuteCommandRequest: Codable, Sendable {
public let sessionId: String
public let command: String
public let environment: [String: String]?
@ -174,7 +174,7 @@ extension TunnelSession {
}
/// Response from command execution
public struct ExecuteCommandResponse: Codable {
public struct ExecuteCommandResponse: Codable, Sendable {
public let exitCode: Int32
public let stdout: String
public let stderr: String
@ -187,7 +187,7 @@ extension TunnelSession {
}
/// Health check response
public struct HealthResponse: Codable {
public struct HealthResponse: Codable, Sendable {
public let status: String
public let timestamp: Date
public let sessions: Int
@ -202,7 +202,7 @@ extension TunnelSession {
}
/// List sessions response
public struct ListResponse: Codable {
public struct ListResponse: Codable, Sendable {
public let sessions: [TunnelSession]
public init(sessions: [TunnelSession]) {
@ -211,7 +211,7 @@ extension TunnelSession {
}
/// Error response from server
public struct ErrorResponse: Codable {
public struct ErrorResponse: Codable, Sendable {
public let error: String
public let code: String?

View file

@ -6,6 +6,7 @@ import UserNotifications
/// Stub implementation of SparkleUpdaterManager
/// TODO: Add Sparkle dependency through Xcode Package Manager and restore full implementation
@available(macOS 10.15, *)
@MainActor
public final class SparkleUpdaterManager: NSObject {
public static let shared = SparkleUpdaterManager()

View file

@ -1,6 +1,7 @@
import Foundation
import os.log
@MainActor
final class TTYForwardManager {
static let shared = TTYForwardManager()

View file

@ -109,9 +109,12 @@ public final class TunnelServer {
}
// Sessions endpoint - calls tty-fwd --list-sessions
router.get("/sessions") { _, _ -> Response in
let ttyManager = TTYForwardManager.shared
guard let process = ttyManager.createTTYForwardProcess(with: ["--list-sessions"]) else {
router.get("/sessions") { _, _ async -> Response in
let process = await MainActor.run {
TTYForwardManager.shared.createTTYForwardProcess(with: ["--list-sessions"])
}
guard let process = process else {
self.logger.error("Failed to create tty-fwd process")
let errorJson = "{\"error\": \"tty-fwd binary not found\"}"
var buffer = ByteBuffer()
@ -259,4 +262,4 @@ public final class TunnelServer {
}
return false
}
}
}

View file

@ -71,7 +71,7 @@ struct SettingsView: View {
.tag(SettingsTab.about)
}
.frame(width: contentSize.width, height: contentSize.height)
.animatedWindowContainer(size: contentSize)
.animatedWindowSizing(size: contentSize)
.onReceive(NotificationCenter.default.publisher(for: .openSettingsTab)) { notification in
if let tab = notification.object as? SettingsTab {
selectedTab = tab

View file

@ -11,4 +11,8 @@
// Default values (can be overridden in Local.xcconfig)
// These will be used if Local.xcconfig doesn't exist or doesn't define them
DEVELOPMENT_TEAM = $(inherited)
CODE_SIGN_STYLE = $(inherited)
CODE_SIGN_STYLE = $(inherited)
// Swift version and concurrency settings
SWIFT_VERSION = 6.0
SWIFT_STRICT_CONCURRENCY = complete

View file

@ -2,6 +2,7 @@ import AppKit
import SwiftUI
/// Window controller for the About window
@MainActor
final class AboutWindowController {
static let shared = AboutWindowController()

View file

@ -1,86 +0,0 @@
import SwiftUI
import AppKit
/// A view that enables animated window resizing for Settings windows
struct AnimatedWindowContainer<Content: View>: NSViewRepresentable {
let content: Content
let targetSize: CGSize
class Coordinator: NSObject {
var lastSize: CGSize = .zero
weak var window: NSWindow?
var isAnimating = false
func animateWindowResize(to newSize: CGSize) {
guard let window = window,
newSize != lastSize,
!isAnimating else { return }
lastSize = newSize
isAnimating = true
// Calculate the new frame maintaining the window's top-left position
var newFrame = window.frame
let heightDifference = newSize.height - newFrame.height
newFrame.size = newSize
newFrame.origin.y -= heightDifference // Keep top edge in place
// Animate the window frame change
NSAnimationContext.runAnimationGroup({ context in
context.duration = 0.25
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
window.animator().setFrame(newFrame, display: true)
}, completionHandler: {
self.isAnimating = false
})
}
}
func makeCoordinator() -> Coordinator {
Coordinator()
}
func makeNSView(context: Context) -> NSView {
let view = NSView()
view.wantsLayer = true
view.layer?.backgroundColor = NSColor.clear.cgColor
// Use async to ensure window is available
DispatchQueue.main.async {
if let window = view.window {
context.coordinator.window = window
// Disable automatic window resizing
window.styleMask.remove(.resizable)
// Set initial size
context.coordinator.lastSize = window.frame.size
}
}
return view
}
func updateNSView(_ nsView: NSView, context: Context) {
// Ensure we have a window
if context.coordinator.window == nil, let window = nsView.window {
context.coordinator.window = window
window.styleMask.remove(.resizable)
context.coordinator.lastSize = window.frame.size
}
// Animate to the new size
context.coordinator.animateWindowResize(to: targetSize)
}
}
/// Extension to make it easy to use with any SwiftUI view
extension View {
func animatedWindowContainer(size: CGSize) -> some View {
background(
AnimatedWindowContainer(
content: Color.clear,
targetSize: size
)
.allowsHitTesting(false)
)
}
}

View file

@ -0,0 +1,105 @@
import SwiftUI
import AppKit
/// A custom window size animator that works with SwiftUI Settings windows
@MainActor
final class WindowSizeAnimator: ObservableObject {
static let shared = WindowSizeAnimator()
private weak var window: NSWindow?
private var animator: NSViewAnimation?
private init() {}
/// Find and store reference to the settings window
func captureSettingsWindow() {
// Try multiple strategies to find the window
if let window = NSApp.windows.first(where: { window in
// Check if it's a settings-like window
window.isVisible &&
window.level == .normal &&
!window.isKind(of: NSPanel.self) &&
window.canBecomeKey &&
(window.title.isEmpty || window.title.contains("VibeTunnel") ||
window.title.lowercased().contains("settings") ||
window.title.lowercased().contains("preferences"))
}) {
self.window = window
// Disable user resizing
window.styleMask.remove(.resizable)
}
}
/// Animate window to new size using NSViewAnimation
func animateWindowSize(to newSize: CGSize, duration: TimeInterval = 0.25) {
guard let window = window else {
// Try to capture window if we haven't yet
captureSettingsWindow()
guard self.window != nil else { return }
animateWindowSize(to: newSize, duration: duration)
return
}
// Cancel any existing animation
animator?.stop()
// Calculate new frame keeping top-left corner fixed
var newFrame = window.frame
let heightDifference = newSize.height - newFrame.height
newFrame.size = newSize
newFrame.origin.y -= heightDifference
// Create animation dictionary
let windowDict: [NSViewAnimation.Key: Any] = [
.target: window,
.startFrame: window.frame,
.endFrame: newFrame
]
// Create and configure animation
let animation = NSViewAnimation(viewAnimations: [windowDict])
animation.animationBlockingMode = .nonblocking
animation.animationCurve = .easeInOut
animation.duration = duration
// Store animator reference
self.animator = animation
// Start animation
animation.start()
}
}
/// A view modifier that captures the window and enables animated resizing
struct AnimatedWindowSizing: ViewModifier {
let size: CGSize
@StateObject private var animator = WindowSizeAnimator.shared
func body(content: Content) -> some View {
content
.onAppear {
// Capture window after a delay to ensure it's created
Task {
try? await Task.sleep(for: .milliseconds(100))
await MainActor.run {
animator.captureSettingsWindow()
// Set initial size without animation
if let window = NSApp.keyWindow {
var frame = window.frame
frame.size = size
window.setFrame(frame, display: true)
}
}
}
}
.onChange(of: size) { _, newSize in
animator.animateWindowSize(to: newSize)
}
}
}
extension View {
func animatedWindowSizing(size: CGSize) -> some View {
modifier(AnimatedWindowSizing(size: size))
}
}

View file

@ -130,7 +130,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
DistributedNotificationCenter.default().post(name: Self.showSettingsNotification, object: nil)
// Show alert that another instance is running
DispatchQueue.main.async {
Task { @MainActor in
let alert = NSAlert()
alert.messageText = "VibeTunnel is already running"
alert
@ -202,6 +202,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
/// Shows the About section in the Settings window
@MainActor
private func showAboutInSettings() {
NSApp.openSettings()
Task {

View file

@ -158,6 +158,7 @@ struct MenuButtonStyle: ButtonStyle {
// MARK: - Helper Functions
/// Shows the About section in the Settings window
@MainActor
private func showAboutInSettings() {
NSApp.openSettings()
Task {