From bb462c8826f5a191c292fa2ed740908c4b48be67 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Jun 2025 11:02:16 +0200 Subject: [PATCH] Better animations for welcome view --- .../Presentation/Views/AboutView.swift | 130 ++----------- .../Views/Settings/GeneralSettingsView.swift | 2 +- .../Views/Shared/GlowingAppIcon.swift | 175 ++++++++++++++++++ .../Welcome/AccessDashboardPageView.swift | 8 +- .../Welcome/ProtectDashboardPageView.swift | 8 +- .../Welcome/RequestPermissionsPageView.swift | 8 +- .../Welcome/SelectTerminalPageView.swift | 22 +-- .../Views/Welcome/VTCommandPageView.swift | 8 +- .../Views/Welcome/WelcomeContentView.swift | 34 ++++ .../Presentation/Views/WelcomeView.swift | 145 ++++++++------- .../Utilities/WelcomeWindowController.swift | 2 +- 11 files changed, 319 insertions(+), 223 deletions(-) create mode 100644 mac/VibeTunnel/Presentation/Views/Shared/GlowingAppIcon.swift create mode 100644 mac/VibeTunnel/Presentation/Views/Welcome/WelcomeContentView.swift diff --git a/mac/VibeTunnel/Presentation/Views/AboutView.swift b/mac/VibeTunnel/Presentation/Views/AboutView.swift index 7e73cecb..df4f2d67 100644 --- a/mac/VibeTunnel/Presentation/Views/AboutView.swift +++ b/mac/VibeTunnel/Presentation/Views/AboutView.swift @@ -37,8 +37,14 @@ struct AboutView: View { private var appInfoSection: some View { VStack(spacing: 16) { - InteractiveAppIcon() - .padding(.bottom, 20) + GlowingAppIcon( + size: 128, + enableFloating: true, + enableInteraction: true, + glowIntensity: 0.3, + action: openWebsite + ) + .padding(.bottom, 20) Text(appName) .font(.largeTitle) @@ -50,6 +56,12 @@ struct AboutView: View { } .padding(.top, 40) } + + @MainActor + private func openWebsite() { + guard let url = URL(string: "https://vibetunnel.sh") else { return } + NSWorkspace.shared.open(url) + } private var descriptionSection: some View { Text("Turn any browser into your terminal & command your agents on the go.") @@ -133,120 +145,6 @@ struct HoverableLink: View { } } -/// Interactive app icon component with shadow effects and website link. -/// -/// This component displays the VibeTunnel app icon with dynamic shadow effects that respond -/// to user interaction. It includes hover effects for visual feedback and opens the -/// VibeTunnel website when clicked. -struct InteractiveAppIcon: View { - @State private var isHovering = false - @State private var isPressed = false - @State private var floatingOffset: CGFloat = 0 - @Environment(\.colorScheme) - private var colorScheme - - var body: some View { - Button(action: openWebsite) { - ZStack { - // Glow effect layers (multiple shadows for a more intense glow) - Image(nsImage: NSApp.applicationIconImage) - .resizable() - .frame(width: 128, height: 128) - .clipShape(RoundedRectangle(cornerRadius: 22)) - .opacity(0.3) - .blur(radius: 20) - .scaleEffect(1.2) - .shadow(color: glowColor, radius: 30, x: 0, y: 0) - .allowsHitTesting(false) - - // Secondary glow layer - Image(nsImage: NSApp.applicationIconImage) - .resizable() - .frame(width: 128, height: 128) - .clipShape(RoundedRectangle(cornerRadius: 22)) - .opacity(0.2) - .blur(radius: 10) - .scaleEffect(1.1) - .shadow(color: glowColor, radius: 20, x: 0, y: 0) - .allowsHitTesting(false) - - // Main icon with shadow - Image(nsImage: NSApp.applicationIconImage) - .resizable() - .frame(width: 128, height: 128) - .clipShape(RoundedRectangle(cornerRadius: 22)) - .scaleEffect(isPressed ? 0.95 : (isHovering ? 1.05 : 1.0)) - .shadow( - color: shadowColor, - radius: shadowRadius, - x: 0, - y: shadowOffset - ) - .animation(.easeInOut(duration: 0.2), value: isHovering) - .animation(.easeInOut(duration: 0.1), value: isPressed) - } - } - .buttonStyle(PlainButtonStyle()) - .offset(y: floatingOffset) - .pointingHandCursor() - .onHover { hovering in - isHovering = hovering - } - .simultaneousGesture( - DragGesture(minimumDistance: 0) - .onChanged { _ in - isPressed = true - } - .onEnded { _ in - isPressed = false - } - ) - .onAppear { - startFloatingAnimation() - } - } - - private var glowColor: Color { - if colorScheme == .dark { - // Greenish-gold glow for dark mode - Color(red: 0.6, green: 0.8, blue: 0.4).opacity(isHovering ? 0.8 : 0.6) - } else { - // Softer golden glow for light mode - Color(red: 0.8, green: 0.7, blue: 0.3).opacity(isHovering ? 0.6 : 0.4) - } - } - - private var shadowColor: Color { - if colorScheme == .dark { - .black.opacity(isHovering ? 0.8 : 0.6) - } else { - .black.opacity(isHovering ? 0.4 : 0.3) - } - } - - private var shadowRadius: CGFloat { - isHovering ? 25 : 15 - } - - private var shadowOffset: CGFloat { - isHovering ? 10 : 6 - } - - private func startFloatingAnimation() { - withAnimation( - Animation.easeInOut(duration: 3.0) - .repeatForever(autoreverses: true) - ) { - floatingOffset = -8 - } - } - - @MainActor - private func openWebsite() { - guard let url = URL(string: "https://vibetunnel.sh") else { return } - NSWorkspace.shared.open(url) - } -} // MARK: - Preview diff --git a/mac/VibeTunnel/Presentation/Views/Settings/GeneralSettingsView.swift b/mac/VibeTunnel/Presentation/Views/Settings/GeneralSettingsView.swift index 1bbb821a..be441fbd 100644 --- a/mac/VibeTunnel/Presentation/Views/Settings/GeneralSettingsView.swift +++ b/mac/VibeTunnel/Presentation/Views/Settings/GeneralSettingsView.swift @@ -231,7 +231,7 @@ private struct PermissionsSection: View { .foregroundColor(.green) } else { Text( - "Terminals can be controlled without permissions, however new sessions won't load." + "Terminals can be captured without permissions, however new sessions won't load." ) .font(.caption) .frame(maxWidth: .infinity) diff --git a/mac/VibeTunnel/Presentation/Views/Shared/GlowingAppIcon.swift b/mac/VibeTunnel/Presentation/Views/Shared/GlowingAppIcon.swift new file mode 100644 index 00000000..841e2ff4 --- /dev/null +++ b/mac/VibeTunnel/Presentation/Views/Shared/GlowingAppIcon.swift @@ -0,0 +1,175 @@ +import SwiftUI + +/// Shared glowing app icon component with configurable animation and effects. +/// +/// This component displays the VibeTunnel app icon with customizable glow effects, +/// floating animation, and interactive behaviors. It can be used in both the Welcome +/// and About views with different configurations. +struct GlowingAppIcon: View { + // Configuration + let size: CGFloat + let enableFloating: Bool + let enableInteraction: Bool + let glowIntensity: Double + let action: (() -> Void)? + + // State + @State private var isHovering = false + @State private var isPressed = false + @State private var breathingScale: CGFloat = 1.0 + @State private var breathingPhase: CGFloat = 0 + @Environment(\.colorScheme) private var colorScheme + + init( + size: CGFloat = 128, + enableFloating: Bool = true, + enableInteraction: Bool = true, + glowIntensity: Double = 0.3, + action: (() -> Void)? = nil + ) { + self.size = size + self.enableFloating = enableFloating + self.enableInteraction = enableInteraction + self.glowIntensity = glowIntensity + self.action = action + } + + var body: some View { + Group { + if enableInteraction { + Button(action: { action?() }) { + iconContent + } + .buttonStyle(PlainButtonStyle()) + .pointingHandCursor() + .onHover { hovering in + isHovering = hovering + } + .simultaneousGesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in isPressed = true } + .onEnded { _ in isPressed = false } + ) + } else { + iconContent + } + } + .scaleEffect(breathingScale) + .onAppear { + if enableFloating { + startBreathingAnimation() + } + } + } + + private var iconContent: some View { + ZStack { + // Subtle glow effect that changes with breathing + Image(nsImage: NSImage(named: "AppIcon") ?? NSImage()) + .resizable() + .frame(width: size, height: size) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) + .opacity(dynamicGlowOpacity) + .blur(radius: dynamicGlowBlur) + .scaleEffect(1.15) + .allowsHitTesting(false) + + // Main icon with shadow + Image(nsImage: NSImage(named: "AppIcon") ?? NSImage()) + .resizable() + .frame(width: size, height: size) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) + .scaleEffect(iconScale) + .shadow( + color: dynamicShadowColor, + radius: dynamicShadowRadius, + x: 0, + y: dynamicShadowOffset + ) + .animation(.easeInOut(duration: 0.2), value: isHovering) + .animation(.easeInOut(duration: 0.1), value: isPressed) + } + } + + private var cornerRadius: CGFloat { + size * 0.172 // Maintains the same corner radius ratio as original (22/128) + } + + private var iconScale: CGFloat { + if !enableInteraction { return 1.0 } + return isPressed ? 0.95 : (isHovering ? 1.05 : 1.0) + } + + // Dynamic properties that change with breathing + private var dynamicGlowOpacity: Double { + let baseOpacity = glowIntensity * 0.5 + // Glow gets stronger when "coming forward" (breathingPhase > 0) + return baseOpacity + (breathingPhase * glowIntensity * 0.3) + } + + private var dynamicGlowBlur: CGFloat { + // Blur increases when coming forward for a softer, larger glow + return 15 + (breathingPhase * 5) + } + + private var dynamicShadowColor: Color { + let baseOpacity = colorScheme == .dark ? 0.4 : 0.2 + let hoverOpacity = colorScheme == .dark ? 0.6 : 0.3 + let opacity = isHovering ? hoverOpacity : baseOpacity + // Shadow gets stronger when coming forward + let breathingOpacity = opacity + (breathingPhase * 0.1) + return .black.opacity(breathingOpacity) + } + + private var dynamicShadowRadius: CGFloat { + let baseRadius = size * 0.117 + let hoverMultiplier: CGFloat = enableInteraction && isHovering ? 1.5 : 1.0 + // Shadow gets softer/larger when coming forward + let breathingMultiplier = 1.0 + (breathingPhase * 0.2) + return baseRadius * hoverMultiplier * breathingMultiplier + } + + private var dynamicShadowOffset: CGFloat { + let baseOffset = size * 0.047 + let hoverMultiplier: CGFloat = enableInteraction && isHovering ? 1.5 : 1.0 + // Shadow moves down more when coming forward + let breathingMultiplier = 1.0 + (breathingPhase * 0.3) + return baseOffset * hoverMultiplier * breathingMultiplier + } + + private func startBreathingAnimation() { + withAnimation( + Animation.easeInOut(duration: 4.0) + .repeatForever(autoreverses: true) + ) { + breathingScale = 1.04 // Very subtle scale change + breathingPhase = 1.0 // Used to calculate dynamic effects + } + } +} + +// MARK: - Preview + +#Preview("Glowing App Icons") { + VStack(spacing: 40) { + // Welcome style - larger, subtle floating + GlowingAppIcon( + size: 156, + enableFloating: true, + enableInteraction: false, + glowIntensity: 0.3 + ) + + // About style - smaller, interactive + GlowingAppIcon( + size: 128, + enableFloating: true, + enableInteraction: true, + glowIntensity: 0.3 + ) { + print("Icon clicked!") + } + } + .padding() + .frame(width: 400, height: 600) +} \ No newline at end of file diff --git a/mac/VibeTunnel/Presentation/Views/Welcome/AccessDashboardPageView.swift b/mac/VibeTunnel/Presentation/Views/Welcome/AccessDashboardPageView.swift index be6ccb95..4d36b944 100644 --- a/mac/VibeTunnel/Presentation/Views/Welcome/AccessDashboardPageView.swift +++ b/mac/VibeTunnel/Presentation/Views/Welcome/AccessDashboardPageView.swift @@ -27,12 +27,6 @@ struct AccessDashboardPageView: View { var body: some View { VStack(spacing: 30) { - // App icon - Image(nsImage: NSImage(named: "AppIcon") ?? NSImage()) - .resizable() - .frame(width: 156, height: 156) - .shadow(radius: 10) - VStack(spacing: 16) { Text("Accessing Your Dashboard") .font(.largeTitle) @@ -89,8 +83,8 @@ struct AccessDashboardPageView: View { CreditLink(name: "@steipete", url: "https://steipete.me") } } + Spacer() } - .frame(maxWidth: .infinity, maxHeight: .infinity) .padding() } } diff --git a/mac/VibeTunnel/Presentation/Views/Welcome/ProtectDashboardPageView.swift b/mac/VibeTunnel/Presentation/Views/Welcome/ProtectDashboardPageView.swift index 4566e918..ecc8865f 100644 --- a/mac/VibeTunnel/Presentation/Views/Welcome/ProtectDashboardPageView.swift +++ b/mac/VibeTunnel/Presentation/Views/Welcome/ProtectDashboardPageView.swift @@ -31,12 +31,6 @@ struct ProtectDashboardPageView: View { var body: some View { VStack(spacing: 30) { - // App icon - Image(nsImage: NSImage(named: "AppIcon") ?? NSImage()) - .resizable() - .frame(width: 156, height: 156) - .shadow(radius: 10) - VStack(spacing: 16) { Text("Protect Your Dashboard") .font(.largeTitle) @@ -100,8 +94,8 @@ struct ProtectDashboardPageView: View { .foregroundColor(.secondary) } } + Spacer() } - .frame(maxWidth: .infinity, maxHeight: .infinity) .padding() } diff --git a/mac/VibeTunnel/Presentation/Views/Welcome/RequestPermissionsPageView.swift b/mac/VibeTunnel/Presentation/Views/Welcome/RequestPermissionsPageView.swift index 978595b3..ba4f3a2f 100644 --- a/mac/VibeTunnel/Presentation/Views/Welcome/RequestPermissionsPageView.swift +++ b/mac/VibeTunnel/Presentation/Views/Welcome/RequestPermissionsPageView.swift @@ -42,12 +42,6 @@ struct RequestPermissionsPageView: View { var body: some View { VStack(spacing: 30) { - // App icon - Image(nsImage: NSImage(named: "AppIcon") ?? NSImage()) - .resizable() - .frame(width: 156, height: 156) - .shadow(radius: 10) - VStack(spacing: 16) { Text("Request Permissions") .font(.largeTitle) @@ -105,8 +99,8 @@ struct RequestPermissionsPageView: View { } } } + Spacer() } - .frame(maxWidth: .infinity, maxHeight: .infinity) .padding() .task { // Check permissions before first render to avoid UI flashing diff --git a/mac/VibeTunnel/Presentation/Views/Welcome/SelectTerminalPageView.swift b/mac/VibeTunnel/Presentation/Views/Welcome/SelectTerminalPageView.swift index 6c668b68..ecb7bdff 100644 --- a/mac/VibeTunnel/Presentation/Views/Welcome/SelectTerminalPageView.swift +++ b/mac/VibeTunnel/Presentation/Views/Welcome/SelectTerminalPageView.swift @@ -22,23 +22,19 @@ struct SelectTerminalPageView: View { var body: some View { VStack(spacing: 30) { - // App icon - Image(nsImage: NSImage(named: "AppIcon") ?? NSImage()) - .resizable() - .frame(width: 156, height: 156) - .shadow(radius: 10) - VStack(spacing: 16) { Text("Select Terminal") .font(.largeTitle) .fontWeight(.semibold) - Text("VibeTunnel can spawn new sessions and open a terminal for you.\nThis will require permissions.") - .font(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: 480) - .fixedSize(horizontal: false, vertical: true) + Text( + "VibeTunnel can spawn new sessions and open a terminal for you.\nSelect your preferred Terminal and test permissions." + ) + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 480) + .fixedSize(horizontal: false, vertical: true) // Terminal selector and test button VStack(spacing: 16) { @@ -66,8 +62,8 @@ struct SelectTerminalPageView: View { .frame(width: 200) } } + Spacer() } - .frame(maxWidth: .infinity, maxHeight: .infinity) .padding() .alert(errorTitle, isPresented: $showingError) { Button("OK") {} diff --git a/mac/VibeTunnel/Presentation/Views/Welcome/VTCommandPageView.swift b/mac/VibeTunnel/Presentation/Views/Welcome/VTCommandPageView.swift index d6e09e6e..9b17295c 100644 --- a/mac/VibeTunnel/Presentation/Views/Welcome/VTCommandPageView.swift +++ b/mac/VibeTunnel/Presentation/Views/Welcome/VTCommandPageView.swift @@ -23,12 +23,6 @@ struct VTCommandPageView: View { var body: some View { VStack(spacing: 30) { - // App icon - Image(nsImage: NSImage(named: "AppIcon") ?? NSImage()) - .resizable() - .frame(width: 156, height: 156) - .shadow(radius: 10) - VStack(spacing: 16) { Text("Capturing Terminal Apps") .font(.largeTitle) @@ -88,8 +82,8 @@ struct VTCommandPageView: View { } } } + Spacer() } - .frame(maxWidth: .infinity, maxHeight: .infinity) .padding() .onAppear { // Check installation status synchronously on appear diff --git a/mac/VibeTunnel/Presentation/Views/Welcome/WelcomeContentView.swift b/mac/VibeTunnel/Presentation/Views/Welcome/WelcomeContentView.swift new file mode 100644 index 00000000..61cbad31 --- /dev/null +++ b/mac/VibeTunnel/Presentation/Views/Welcome/WelcomeContentView.swift @@ -0,0 +1,34 @@ +import SwiftUI + +/// Content-only version of the welcome page for use in the scrolling view. +/// +/// This view displays only the textual content of the welcome page, +/// excluding the app icon which is shown in the fixed header. +struct WelcomeContentView: View { + var body: some View { + VStack(spacing: 30) { + VStack(spacing: 16) { + Text("Welcome to VibeTunnel") + .font(.largeTitle) + .fontWeight(.semibold) + + Text("Turn any browser into your terminal. Command your agents on the go.") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 480) + } + + Text( + "You'll be quickly guided through the basics of VibeTunnel.\nThis screen can always be opened from the settings." + ) + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + + Spacer() + } + .padding() + } +} \ No newline at end of file diff --git a/mac/VibeTunnel/Presentation/Views/WelcomeView.swift b/mac/VibeTunnel/Presentation/Views/WelcomeView.swift index 347df3bb..19f0b365 100644 --- a/mac/VibeTunnel/Presentation/Views/WelcomeView.swift +++ b/mac/VibeTunnel/Presentation/Views/WelcomeView.swift @@ -25,53 +25,89 @@ struct WelcomeView: View { private var welcomeVersion = 0 @State private var cliInstaller = CLIInstaller() @State private var permissionManager = SystemPermissionManager.shared + + private let pageWidth: CGFloat = 640 + private let contentHeight: CGFloat = 468 // Total height minus navigation area var body: some View { VStack(spacing: 0) { - // Custom page view implementation for macOS - ZStack { - // Page 1: Welcome - if currentPage == 0 { - WelcomePageView() - .transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading))) - } - - // Page 2: VT Command - if currentPage == 1 { + // Fixed header with animated app icon + GlowingAppIcon( + size: 156, + enableFloating: true, + enableInteraction: false, + glowIntensity: 0.3 + ) + .padding(.top, 40) + .padding(.bottom, 20) // Add padding below icon + .frame(height: 240) + + // Scrollable content area + GeometryReader { geometry in + HStack(spacing: 0) { + // Page 1: Welcome content (without icon) + WelcomeContentView() + .frame(width: pageWidth) + + // Page 2: VT Command VTCommandPageView(cliInstaller: cliInstaller) - .transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading))) - } - - // Page 3: Request Permissions - if currentPage == 2 { + .frame(width: pageWidth) + + // Page 3: Request Permissions RequestPermissionsPageView() - .transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading))) - } - - // Page 4: Select Terminal - if currentPage == 3 { + .frame(width: pageWidth) + + // Page 4: Select Terminal SelectTerminalPageView() - .transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading))) - } - - // Page 5: Protect Your Dashboard - if currentPage == 4 { + .frame(width: pageWidth) + + // Page 5: Protect Your Dashboard ProtectDashboardPageView() - .transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading))) - } - - // Page 6: Accessing Dashboard - if currentPage == 5 { + .frame(width: pageWidth) + + // Page 6: Accessing Dashboard AccessDashboardPageView() - .transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading))) + .frame(width: pageWidth) } + .offset(x: CGFloat(-currentPage) * pageWidth) + .animation(.interactiveSpring(response: 0.5, dampingFraction: 0.86, blendDuration: 0.25), value: currentPage) } - .frame(maxHeight: .infinity) - .animation(.easeInOut, value: currentPage) + .frame(height: 260) // Total height (560) - header (240) - navigation (60) + .clipped() - // Custom page indicators and navigation - Fixed height container - VStack(spacing: 0) { - // Page indicators + // Navigation bar with dots and buttons in same row + HStack(spacing: 20) { + // Back button - only visible when not on first page + // Back button with consistent space reservation + ZStack(alignment: .leading) { + // Invisible placeholder that's always there + Button(action: {}) { + Label("Back", systemImage: "chevron.left") + .labelStyle(.iconOnly) + } + .buttonStyle(.plain) + .opacity(0) + .disabled(true) + + // Actual back button when needed + if currentPage > 0 { + Button(action: handleBackAction) { + Label("Back", systemImage: "chevron.left") + .labelStyle(.iconOnly) + } + .buttonStyle(.plain) + .foregroundColor(.secondary) + .opacity(0.7) + .pointingHandCursor() + .help("Go back to previous page") + .transition(.opacity.combined(with: .scale(scale: 0.8))) + } + } + .frame(minWidth: 80, alignment: .leading) // Same width as Next button, left-aligned + + Spacer() + + // Page indicators centered HStack(spacing: 8) { ForEach(0..<6) { index in Button { @@ -87,37 +123,18 @@ struct WelcomeView: View { .pointingHandCursor() } } - .frame(height: 32) // Fixed height for indicator area + + Spacer() - // Navigation buttons - HStack { - // Back button - only visible when not on first page - if currentPage > 0 { - Button(action: handleBackAction) { - Label("Back", systemImage: "chevron.left") - .labelStyle(.iconOnly) - } - .buttonStyle(.plain) - .foregroundColor(.secondary) - .opacity(0.7) - .pointingHandCursor() - .help("Go back to previous page") - .transition(.opacity.combined(with: .scale(scale: 0.8))) - } - - Spacer() - - Button(action: handleNextAction) { - Text(buttonTitle) - .frame(minWidth: 80) - } - .keyboardShortcut(.return) - .buttonStyle(.borderedProminent) + Button(action: handleNextAction) { + Text(buttonTitle) + .frame(minWidth: 80) } - .padding(.horizontal, 20) - .frame(height: 60) // Fixed height for button area + .keyboardShortcut(.return) + .buttonStyle(.borderedProminent) } - .frame(height: 92) // Total fixed height: 32 + 60 + .padding(.horizontal, 20) + .frame(height: 60) } .frame(width: 640, height: 560) .background(Color(NSColor.windowBackgroundColor)) diff --git a/mac/VibeTunnel/Utilities/WelcomeWindowController.swift b/mac/VibeTunnel/Utilities/WelcomeWindowController.swift index 3467f445..ba9e58d8 100644 --- a/mac/VibeTunnel/Utilities/WelcomeWindowController.swift +++ b/mac/VibeTunnel/Utilities/WelcomeWindowController.swift @@ -17,7 +17,7 @@ final class WelcomeWindowController: NSWindowController, NSWindowDelegate { let hostingController = NSHostingController(rootView: welcomeView) let window = NSWindow(contentViewController: hostingController) - window.title = "" + window.title = "Welcome to VibeTunnel" window.styleMask = [.titled, .closable, .fullSizeContentView] window.titlebarAppearsTransparent = true window.titleVisibility = .hidden