mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-21 13:55:54 +00:00
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:
parent
2324be0706
commit
066d2882f9
13 changed files with 170 additions and 111 deletions
|
|
@ -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;
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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?
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import Foundation
|
||||
import os.log
|
||||
|
||||
@MainActor
|
||||
final class TTYForwardManager {
|
||||
static let shared = TTYForwardManager()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -2,6 +2,7 @@ import AppKit
|
|||
import SwiftUI
|
||||
|
||||
/// Window controller for the About window
|
||||
@MainActor
|
||||
final class AboutWindowController {
|
||||
static let shared = AboutWindowController()
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
105
VibeTunnel/Utilities/WindowSizeAnimator.swift
Normal file
105
VibeTunnel/Utilities/WindowSizeAnimator.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue