mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-26 15:07:39 +00:00
new auth logic
This commit is contained in:
parent
62c6052faf
commit
288a3197d2
19 changed files with 595 additions and 923 deletions
|
|
@ -53,7 +53,7 @@ extension View {
|
|||
systemPermissionManager: SystemPermissionManager? = nil,
|
||||
terminalLauncher: TerminalLauncher? = nil
|
||||
)
|
||||
-> some View
|
||||
-> some View
|
||||
{
|
||||
self
|
||||
.environment(\.serverManager, serverManager ?? ServerManager.shared)
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ public enum UpdateChannel: String, CaseIterable, Codable, Sendable {
|
|||
/// Static URLs to ensure they're validated at compile time
|
||||
private static let stableAppcastURL: URL = {
|
||||
guard let url =
|
||||
URL(string: "https://stats.store/api/v1/appcast/appcast.xml")
|
||||
URL(string: "https://stats.store/api/v1/appcast/appcast.xml")
|
||||
else {
|
||||
fatalError("Invalid stable appcast URL - this should never happen with a hardcoded URL")
|
||||
}
|
||||
|
|
@ -50,9 +50,9 @@ public enum UpdateChannel: String, CaseIterable, Codable, Sendable {
|
|||
|
||||
private static let prereleaseAppcastURL: URL = {
|
||||
guard let url =
|
||||
URL(
|
||||
string: "https://stats.store/api/v1/appcast/appcast-prerelease.xml"
|
||||
)
|
||||
URL(
|
||||
string: "https://stats.store/api/v1/appcast/appcast-prerelease.xml"
|
||||
)
|
||||
else {
|
||||
fatalError("Invalid prerelease appcast URL - this should never happen with a hardcoded URL")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,18 +133,22 @@ final class BunServer {
|
|||
// Build the vibetunnel command with all arguments
|
||||
var vibetunnelArgs = "--port \(port) --bind \(bindAddress)"
|
||||
|
||||
// Add password flag if password protection is enabled
|
||||
if UserDefaults.standard.bool(forKey: "dashboardPasswordEnabled") && DashboardKeychain.shared.hasPassword() {
|
||||
logger.info("Password protection enabled, retrieving from keychain")
|
||||
if let password = DashboardKeychain.shared.getPassword() {
|
||||
// Escape the password for shell
|
||||
let escapedPassword = password.replacingOccurrences(of: "\"", with: "\\\"")
|
||||
.replacingOccurrences(of: "$", with: "\\$")
|
||||
.replacingOccurrences(of: "`", with: "\\`")
|
||||
.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
// Use password-only mode for better UX - users can enter any username
|
||||
vibetunnelArgs += " --password \"\(escapedPassword)\""
|
||||
}
|
||||
// Add authentication flags based on configuration
|
||||
let authMode = UserDefaults.standard.string(forKey: "authenticationMode") ?? "os"
|
||||
logger.info("Configuring authentication mode: \(authMode)")
|
||||
|
||||
switch authMode {
|
||||
case "none":
|
||||
vibetunnelArgs += " --no-auth"
|
||||
case "ssh":
|
||||
vibetunnelArgs += " --enable-ssh-keys --disallow-user-password"
|
||||
case "both":
|
||||
vibetunnelArgs += " --enable-ssh-keys"
|
||||
case "os":
|
||||
fallthrough
|
||||
default:
|
||||
// OS authentication is the default, no special flags needed
|
||||
break
|
||||
}
|
||||
|
||||
// Create wrapper to run vibetunnel with a parent death signal
|
||||
|
|
@ -480,7 +484,7 @@ final class BunServer {
|
|||
seconds: TimeInterval,
|
||||
operation: @escaping @Sendable () async -> T
|
||||
)
|
||||
async -> T?
|
||||
async -> T?
|
||||
{
|
||||
await withTaskGroup(of: T?.self) { group in
|
||||
group.addTask {
|
||||
|
|
|
|||
|
|
@ -20,33 +20,33 @@ final class DashboardKeychain {
|
|||
/// Get the dashboard password from keychain
|
||||
func getPassword() -> String? {
|
||||
#if DEBUG
|
||||
// In debug builds, skip keychain access to avoid authorization dialogs
|
||||
logger
|
||||
.info(
|
||||
"Debug mode: Skipping keychain password retrieval. Password will only persist during current app session."
|
||||
)
|
||||
return nil
|
||||
#else
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecReturnData as String: true
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
|
||||
guard status == errSecSuccess,
|
||||
let data = result as? Data,
|
||||
let password = String(data: data, encoding: .utf8)
|
||||
else {
|
||||
logger.debug("No password found in keychain")
|
||||
// In debug builds, skip keychain access to avoid authorization dialogs
|
||||
logger
|
||||
.info(
|
||||
"Debug mode: Skipping keychain password retrieval. Password will only persist during current app session."
|
||||
)
|
||||
return nil
|
||||
}
|
||||
#else
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecReturnData as String: true
|
||||
]
|
||||
|
||||
logger.debug("Password retrieved from keychain")
|
||||
return password
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
|
||||
guard status == errSecSuccess,
|
||||
let data = result as? Data,
|
||||
let password = String(data: data, encoding: .utf8)
|
||||
else {
|
||||
logger.debug("No password found in keychain")
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.debug("Password retrieved from keychain")
|
||||
return password
|
||||
#endif
|
||||
}
|
||||
|
||||
|
|
@ -103,12 +103,12 @@ final class DashboardKeychain {
|
|||
logger.info("Password \(success ? "saved to" : "failed to save to") keychain")
|
||||
|
||||
#if DEBUG
|
||||
if success {
|
||||
logger
|
||||
.info(
|
||||
"Debug mode: Password saved to keychain but will not persist across app restarts. The password will only be available during this session to avoid keychain authorization dialogs during development."
|
||||
)
|
||||
}
|
||||
if success {
|
||||
logger
|
||||
.info(
|
||||
"Debug mode: Password saved to keychain but will not persist across app restarts. The password will only be available during this session to avoid keychain authorization dialogs during development."
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
return success
|
||||
|
|
|
|||
|
|
@ -291,7 +291,7 @@ final class NgrokService: NgrokTunnelProtocol {
|
|||
seconds: TimeInterval,
|
||||
operation: @Sendable @escaping () async throws -> T
|
||||
)
|
||||
async throws -> T
|
||||
async throws -> T
|
||||
{
|
||||
try await withThrowingTaskGroup(of: T.self) { group in
|
||||
group.addTask {
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ class ServerManager {
|
|||
get {
|
||||
let mode = DashboardAccessMode(rawValue: UserDefaults.standard.string(forKey: "dashboardAccessMode") ?? ""
|
||||
) ??
|
||||
.localhost
|
||||
.localhost
|
||||
return mode.bindAddress
|
||||
}
|
||||
set {
|
||||
|
|
|
|||
|
|
@ -52,38 +52,38 @@ public final class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate {
|
|||
|
||||
// Initialize Sparkle with standard configuration
|
||||
#if DEBUG
|
||||
// In debug mode, start the updater for testing
|
||||
updaterController = SPUStandardUpdaterController(
|
||||
startingUpdater: true,
|
||||
updaterDelegate: self,
|
||||
userDriverDelegate: userDriverDelegate
|
||||
)
|
||||
// In debug mode, start the updater for testing
|
||||
updaterController = SPUStandardUpdaterController(
|
||||
startingUpdater: true,
|
||||
updaterDelegate: self,
|
||||
userDriverDelegate: userDriverDelegate
|
||||
)
|
||||
#else
|
||||
updaterController = SPUStandardUpdaterController(
|
||||
startingUpdater: true,
|
||||
updaterDelegate: self,
|
||||
userDriverDelegate: userDriverDelegate
|
||||
)
|
||||
updaterController = SPUStandardUpdaterController(
|
||||
startingUpdater: true,
|
||||
updaterDelegate: self,
|
||||
userDriverDelegate: userDriverDelegate
|
||||
)
|
||||
#endif
|
||||
|
||||
// Configure automatic updates
|
||||
if let updater = updaterController?.updater {
|
||||
#if DEBUG
|
||||
// Enable automatic checks in debug too
|
||||
updater.automaticallyChecksForUpdates = true
|
||||
updater.automaticallyDownloadsUpdates = false
|
||||
logger.info("Sparkle updater initialized in DEBUG mode - automatic updates enabled for testing")
|
||||
// Enable automatic checks in debug too
|
||||
updater.automaticallyChecksForUpdates = true
|
||||
updater.automaticallyDownloadsUpdates = false
|
||||
logger.info("Sparkle updater initialized in DEBUG mode - automatic updates enabled for testing")
|
||||
#else
|
||||
// Enable automatic checking for updates
|
||||
updater.automaticallyChecksForUpdates = true
|
||||
// Enable automatic checking for updates
|
||||
updater.automaticallyChecksForUpdates = true
|
||||
|
||||
// Enable automatic downloading of updates
|
||||
updater.automaticallyDownloadsUpdates = true
|
||||
// Enable automatic downloading of updates
|
||||
updater.automaticallyDownloadsUpdates = true
|
||||
|
||||
// Set update check interval to 24 hours
|
||||
updater.updateCheckInterval = 86_400
|
||||
// Set update check interval to 24 hours
|
||||
updater.updateCheckInterval = 86_400
|
||||
|
||||
logger.info("Sparkle updater initialized successfully with automatic downloads enabled")
|
||||
logger.info("Sparkle updater initialized successfully with automatic downloads enabled")
|
||||
#endif
|
||||
|
||||
// Start the updater for both debug and release builds
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ final class SparkleUserDriverDelegate: NSObject, @preconcurrency SPUStandardUser
|
|||
_ update: SUAppcastItem,
|
||||
andInImmediateFocus immediateFocus: Bool
|
||||
)
|
||||
-> Bool
|
||||
-> Bool
|
||||
{
|
||||
logger.info("Should handle showing update: \(update.displayVersionString), immediate: \(immediateFocus)")
|
||||
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ actor TerminalManager {
|
|||
seconds: TimeInterval,
|
||||
operation: @escaping @Sendable () async throws -> T
|
||||
)
|
||||
async throws -> T
|
||||
async throws -> T
|
||||
{
|
||||
try await withThrowingTaskGroup(of: T.self) { group in
|
||||
group.addTask {
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ final class WindowTracker {
|
|||
tabReference: String?,
|
||||
tabID: String?
|
||||
)
|
||||
-> WindowInfo?
|
||||
-> WindowInfo?
|
||||
{
|
||||
let allWindows = Self.getAllTerminalWindows()
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ extension View {
|
|||
error: Binding<Error?>,
|
||||
onDismiss: (() -> Void)? = nil
|
||||
)
|
||||
-> some View
|
||||
-> some View
|
||||
{
|
||||
modifier(ErrorAlertModifier(error: error, title: title, onDismiss: onDismiss))
|
||||
}
|
||||
|
|
@ -49,7 +49,7 @@ extension Task where Failure == Error {
|
|||
errorBinding: Binding<Error?>,
|
||||
operation: @escaping () async throws -> T
|
||||
)
|
||||
-> Task<T, Error>
|
||||
-> Task<T, Error>
|
||||
{
|
||||
Task<T, Error>(priority: priority) {
|
||||
do {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ extension View {
|
|||
horizontal: CGFloat = 16,
|
||||
vertical: CGFloat = 14
|
||||
)
|
||||
-> some View
|
||||
-> some View
|
||||
{
|
||||
self
|
||||
.padding(.horizontal, horizontal)
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ struct MenuBarView: View {
|
|||
// Show Tutorial
|
||||
Button(action: {
|
||||
#if !SWIFT_PACKAGE
|
||||
AppDelegate.showWelcomeScreen()
|
||||
AppDelegate.showWelcomeScreen()
|
||||
#endif
|
||||
}, label: {
|
||||
HStack {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -327,7 +327,7 @@ private struct DeveloperToolsSection: View {
|
|||
Spacer()
|
||||
Button("Show Welcome") {
|
||||
#if !SWIFT_PACKAGE
|
||||
AppDelegate.showWelcomeScreen()
|
||||
AppDelegate.showWelcomeScreen()
|
||||
#endif
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
|
|
|||
|
|
@ -1,43 +1,35 @@
|
|||
import SwiftUI
|
||||
|
||||
/// Fourth page explaining dashboard security and access protection.
|
||||
/// Fourth page explaining dashboard security and authentication.
|
||||
///
|
||||
/// This view allows users to set up password protection for their dashboard
|
||||
/// when accessing it over the network. It provides secure password entry
|
||||
/// with confirmation and validation.
|
||||
/// This view explains how the dashboard is protected using the system's
|
||||
/// built-in authentication. Users don't need to set up a password as
|
||||
/// authentication uses their macOS username and password by default.
|
||||
///
|
||||
/// ## Topics
|
||||
///
|
||||
/// ### Overview
|
||||
/// The dashboard protection page includes:
|
||||
/// - Password and confirmation fields
|
||||
/// - Password validation (minimum 6 characters)
|
||||
/// - Secure storage in keychain
|
||||
/// - Automatic network mode switching when password is set
|
||||
/// - Option to skip password protection
|
||||
/// - Explanation of OS-based authentication
|
||||
/// - Information about SSH key authentication option
|
||||
/// - Link to settings for authentication configuration
|
||||
///
|
||||
/// ### Security
|
||||
/// - Passwords are stored securely in the system keychain
|
||||
/// - Network access is automatically enabled when a password is set
|
||||
/// - Dashboard remains localhost-only without password
|
||||
/// - Uses macOS system authentication (PAM) by default
|
||||
/// - SSH key authentication available as an alternative
|
||||
/// - No separate password setup required
|
||||
struct ProtectDashboardPageView: View {
|
||||
@State private var password = ""
|
||||
@State private var confirmPassword = ""
|
||||
@State private var showError = false
|
||||
@State private var errorMessage = ""
|
||||
@State private var isPasswordSet = false
|
||||
|
||||
private let dashboardKeychain = DashboardKeychain.shared
|
||||
@State private var showingSettings = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 30) {
|
||||
VStack(spacing: 16) {
|
||||
Text("Protect Your Dashboard")
|
||||
Text("Dashboard Security")
|
||||
.font(.largeTitle)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
Text(
|
||||
"If you want to access your dashboard over the network, set a password now.\nOtherwise, it will only be accessible via localhost."
|
||||
"Your dashboard is protected using your macOS username and password.\nNo additional setup is required."
|
||||
)
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
|
|
@ -45,51 +37,61 @@ struct ProtectDashboardPageView: View {
|
|||
.frame(maxWidth: 480)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
// Password fields
|
||||
VStack(spacing: 12) {
|
||||
SecureField("Password", text: $password)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: 300)
|
||||
.onChange(of: password) { _, _ in
|
||||
// Reset password saved state when user starts typing
|
||||
if isPasswordSet {
|
||||
isPasswordSet = false
|
||||
}
|
||||
}
|
||||
// Authentication info
|
||||
VStack(spacing: 20) {
|
||||
// Security icon and explanation
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "lock.shield.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.accentColor)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
|
||||
SecureField("Confirm Password", text: $confirmPassword)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: 300)
|
||||
.onChange(of: confirmPassword) { _, _ in
|
||||
// Reset password saved state when user starts typing
|
||||
if isPasswordSet {
|
||||
isPasswordSet = false
|
||||
}
|
||||
}
|
||||
|
||||
if showError {
|
||||
Text(errorMessage)
|
||||
.font(.caption)
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
|
||||
if isPasswordSet {
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
Text("Password saved securely")
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Secure by Default")
|
||||
.font(.headline)
|
||||
Text("Access requires your macOS credentials")
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.font(.caption)
|
||||
} else {
|
||||
Button("Set Password") {
|
||||
setPassword()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(password.isEmpty)
|
||||
}
|
||||
.frame(maxWidth: 400)
|
||||
|
||||
Text("Leave empty to skip password protection")
|
||||
// Authentication methods
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Label {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("macOS Authentication")
|
||||
.font(.callout)
|
||||
.fontWeight(.medium)
|
||||
Text("Uses your system username and password")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
} icon: {
|
||||
Image(systemName: "person.badge.shield.checkmark.fill")
|
||||
.foregroundColor(.green)
|
||||
}
|
||||
|
||||
Label {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("SSH Key Authentication")
|
||||
.font(.callout)
|
||||
.fontWeight(.medium)
|
||||
Text("Available as an alternative in Settings")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
} icon: {
|
||||
Image(systemName: "key.fill")
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(NSColor.controlBackgroundColor))
|
||||
.cornerRadius(8)
|
||||
.frame(maxWidth: 400)
|
||||
|
||||
Text("You can configure authentication methods later in Settings")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
|
@ -98,42 +100,6 @@ struct ProtectDashboardPageView: View {
|
|||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
private func setPassword() {
|
||||
showError = false
|
||||
|
||||
guard !password.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
guard password == confirmPassword else {
|
||||
errorMessage = "Passwords do not match"
|
||||
showError = true
|
||||
return
|
||||
}
|
||||
|
||||
guard password.count >= 6 else {
|
||||
errorMessage = "Password must be at least 6 characters"
|
||||
showError = true
|
||||
return
|
||||
}
|
||||
|
||||
if dashboardKeychain.setPassword(password) {
|
||||
isPasswordSet = true
|
||||
UserDefaults.standard.set(true, forKey: "dashboardPasswordEnabled")
|
||||
|
||||
// When password is set for the first time, automatically switch to network mode
|
||||
let currentMode = DashboardAccessMode(rawValue: UserDefaults.standard
|
||||
.string(forKey: "dashboardAccessMode") ?? ""
|
||||
) ?? .localhost
|
||||
if currentMode == .localhost {
|
||||
UserDefaults.standard.set(DashboardAccessMode.network.rawValue, forKey: "dashboardAccessMode")
|
||||
}
|
||||
} else {
|
||||
errorMessage = "Failed to save password to keychain"
|
||||
showError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
|
|
|||
|
|
@ -172,8 +172,8 @@ final class ApplicationMover {
|
|||
logger.debug("ApplicationMover: hdiutil returned \(data.count) bytes")
|
||||
|
||||
guard let plist = try PropertyListSerialization
|
||||
.propertyList(from: data, options: [], format: nil) as? [String: Any],
|
||||
let images = plist["images"] as? [[String: Any]]
|
||||
.propertyList(from: data, options: [], format: nil) as? [String: Any],
|
||||
let images = plist["images"] as? [[String: Any]]
|
||||
else {
|
||||
logger.debug("ApplicationMover: No disk images found in hdiutil output")
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -383,7 +383,7 @@ final class TerminalLauncher {
|
|||
var runningTerminals: [Terminal] = []
|
||||
|
||||
for terminal in Terminal.allCases
|
||||
where runningApps.contains(where: { $0.bundleIdentifier == terminal.bundleIdentifier })
|
||||
where runningApps.contains(where: { $0.bundleIdentifier == terminal.bundleIdentifier })
|
||||
{
|
||||
runningTerminals.append(terminal)
|
||||
logger.debug("Detected running terminal: \(terminal.rawValue)")
|
||||
|
|
@ -416,7 +416,7 @@ final class TerminalLauncher {
|
|||
_ config: TerminalLaunchConfig,
|
||||
sessionId: String? = nil
|
||||
)
|
||||
throws -> TerminalLaunchResult
|
||||
throws -> TerminalLaunchResult
|
||||
{
|
||||
logger.debug("Launch config - command: \(config.command)")
|
||||
logger.debug("Launch config - fullCommand: \(config.fullCommand)")
|
||||
|
|
@ -519,7 +519,7 @@ final class TerminalLauncher {
|
|||
|
||||
if process.terminationStatus != 0 {
|
||||
throw TerminalLauncherError
|
||||
.processLaunchFailed("Process exited with status \(process.terminationStatus)")
|
||||
.processLaunchFailed("Process exited with status \(process.terminationStatus)")
|
||||
}
|
||||
} catch {
|
||||
logger.error("Failed to launch terminal: \(error.localizedDescription)")
|
||||
|
|
@ -676,7 +676,7 @@ final class TerminalLauncher {
|
|||
sessionId: String,
|
||||
vibetunnelPath: String? = nil
|
||||
)
|
||||
throws
|
||||
throws
|
||||
{
|
||||
// Expand tilde in working directory path
|
||||
let expandedWorkingDir = (workingDirectory as NSString).expandingTildeInPath
|
||||
|
|
@ -801,7 +801,7 @@ final class TerminalLauncher {
|
|||
workingDir: String,
|
||||
sessionId: String? = nil
|
||||
)
|
||||
-> String
|
||||
-> String
|
||||
{
|
||||
// Bun executable has fwd command built-in
|
||||
logger.info("Using Bun executable for session creation")
|
||||
|
|
|
|||
|
|
@ -21,81 +21,81 @@ struct VibeTunnelApp: App {
|
|||
|
||||
var body: some Scene {
|
||||
#if os(macOS)
|
||||
// Hidden WindowGroup to make Settings work in MenuBarExtra-only apps
|
||||
// This is a workaround for FB10184971
|
||||
WindowGroup("HiddenWindow") {
|
||||
HiddenWindowView()
|
||||
}
|
||||
.windowResizability(.contentSize)
|
||||
.defaultSize(width: 1, height: 1)
|
||||
.windowStyle(.hiddenTitleBar)
|
||||
// Hidden WindowGroup to make Settings work in MenuBarExtra-only apps
|
||||
// This is a workaround for FB10184971
|
||||
WindowGroup("HiddenWindow") {
|
||||
HiddenWindowView()
|
||||
}
|
||||
.windowResizability(.contentSize)
|
||||
.defaultSize(width: 1, height: 1)
|
||||
.windowStyle(.hiddenTitleBar)
|
||||
|
||||
// Welcome Window
|
||||
WindowGroup("Welcome", id: "welcome") {
|
||||
WelcomeView()
|
||||
.environment(sessionMonitor)
|
||||
.environment(serverManager)
|
||||
.environment(ngrokService)
|
||||
.environment(permissionManager)
|
||||
.environment(terminalLauncher)
|
||||
}
|
||||
.windowResizability(.contentSize)
|
||||
.defaultSize(width: 580, height: 480)
|
||||
.windowStyle(.hiddenTitleBar)
|
||||
|
||||
// Session Detail Window
|
||||
WindowGroup("Session Details", id: "session-detail", for: String.self) { $sessionId in
|
||||
if let sessionId,
|
||||
let session = sessionMonitor.sessions[sessionId]
|
||||
{
|
||||
SessionDetailView(session: session)
|
||||
// Welcome Window
|
||||
WindowGroup("Welcome", id: "welcome") {
|
||||
WelcomeView()
|
||||
.environment(sessionMonitor)
|
||||
.environment(serverManager)
|
||||
.environment(ngrokService)
|
||||
.environment(permissionManager)
|
||||
.environment(terminalLauncher)
|
||||
} else {
|
||||
Text("Session not found")
|
||||
.frame(width: 400, height: 300)
|
||||
}
|
||||
}
|
||||
.windowResizability(.contentSize)
|
||||
.windowResizability(.contentSize)
|
||||
.defaultSize(width: 580, height: 480)
|
||||
.windowStyle(.hiddenTitleBar)
|
||||
|
||||
Settings {
|
||||
SettingsView()
|
||||
.environment(sessionMonitor)
|
||||
.environment(serverManager)
|
||||
.environment(ngrokService)
|
||||
.environment(permissionManager)
|
||||
.environment(terminalLauncher)
|
||||
}
|
||||
.commands {
|
||||
CommandGroup(after: .appInfo) {
|
||||
Button("About VibeTunnel") {
|
||||
SettingsOpener.openSettings()
|
||||
// Navigate to About tab after settings opens
|
||||
Task {
|
||||
try? await Task.sleep(for: .milliseconds(100))
|
||||
NotificationCenter.default.post(
|
||||
name: .openSettingsTab,
|
||||
object: SettingsTab.about
|
||||
)
|
||||
// Session Detail Window
|
||||
WindowGroup("Session Details", id: "session-detail", for: String.self) { $sessionId in
|
||||
if let sessionId,
|
||||
let session = sessionMonitor.sessions[sessionId]
|
||||
{
|
||||
SessionDetailView(session: session)
|
||||
.environment(sessionMonitor)
|
||||
.environment(serverManager)
|
||||
.environment(ngrokService)
|
||||
.environment(permissionManager)
|
||||
.environment(terminalLauncher)
|
||||
} else {
|
||||
Text("Session not found")
|
||||
.frame(width: 400, height: 300)
|
||||
}
|
||||
}
|
||||
.windowResizability(.contentSize)
|
||||
|
||||
Settings {
|
||||
SettingsView()
|
||||
.environment(sessionMonitor)
|
||||
.environment(serverManager)
|
||||
.environment(ngrokService)
|
||||
.environment(permissionManager)
|
||||
.environment(terminalLauncher)
|
||||
}
|
||||
.commands {
|
||||
CommandGroup(after: .appInfo) {
|
||||
Button("About VibeTunnel") {
|
||||
SettingsOpener.openSettings()
|
||||
// Navigate to About tab after settings opens
|
||||
Task {
|
||||
try? await Task.sleep(for: .milliseconds(100))
|
||||
NotificationCenter.default.post(
|
||||
name: .openSettingsTab,
|
||||
object: SettingsTab.about
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MenuBarExtra {
|
||||
MenuBarView()
|
||||
.environment(sessionMonitor)
|
||||
.environment(serverManager)
|
||||
.environment(ngrokService)
|
||||
.environment(permissionManager)
|
||||
.environment(terminalLauncher)
|
||||
} label: {
|
||||
Image("menubar")
|
||||
.renderingMode(.template)
|
||||
}
|
||||
MenuBarExtra {
|
||||
MenuBarView()
|
||||
.environment(sessionMonitor)
|
||||
.environment(serverManager)
|
||||
.environment(ngrokService)
|
||||
.environment(permissionManager)
|
||||
.environment(terminalLauncher)
|
||||
} label: {
|
||||
Image("menubar")
|
||||
.renderingMode(.template)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
|
@ -121,25 +121,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
|
|||
NSClassFromString("XCTestCase") != nil
|
||||
let isRunningInPreview = processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
|
||||
#if DEBUG
|
||||
let isRunningInDebug = true
|
||||
let isRunningInDebug = true
|
||||
#else
|
||||
let isRunningInDebug = processInfo.environment["DYLD_INSERT_LIBRARIES"]?
|
||||
.contains("libMainThreadChecker.dylib") ?? false ||
|
||||
processInfo.environment["__XCODE_BUILT_PRODUCTS_DIR_PATHS"] != nil
|
||||
let isRunningInDebug = processInfo.environment["DYLD_INSERT_LIBRARIES"]?
|
||||
.contains("libMainThreadChecker.dylib") ?? false ||
|
||||
processInfo.environment["__XCODE_BUILT_PRODUCTS_DIR_PATHS"] != nil
|
||||
#endif
|
||||
|
||||
// Handle single instance check before doing anything else
|
||||
#if DEBUG
|
||||
// Skip single instance check in debug builds
|
||||
#else
|
||||
if !isRunningInPreview && !isRunningInTests && !isRunningInDebug {
|
||||
handleSingleInstanceCheck()
|
||||
registerForDistributedNotifications()
|
||||
if !isRunningInPreview && !isRunningInTests && !isRunningInDebug {
|
||||
handleSingleInstanceCheck()
|
||||
registerForDistributedNotifications()
|
||||
|
||||
// Check if app needs to be moved to Applications folder
|
||||
let applicationMover = ApplicationMover()
|
||||
applicationMover.checkAndOfferToMoveToApplications()
|
||||
}
|
||||
// Check if app needs to be moved to Applications folder
|
||||
let applicationMover = ApplicationMover()
|
||||
applicationMover.checkAndOfferToMoveToApplications()
|
||||
}
|
||||
#endif
|
||||
|
||||
// Initialize Sparkle updater manager
|
||||
|
|
@ -340,11 +340,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
|
|||
NSClassFromString("XCTestCase") != nil
|
||||
let isRunningInPreview = processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
|
||||
#if DEBUG
|
||||
let isRunningInDebug = true
|
||||
let isRunningInDebug = true
|
||||
#else
|
||||
let isRunningInDebug = processInfo.environment["DYLD_INSERT_LIBRARIES"]?
|
||||
.contains("libMainThreadChecker.dylib") ?? false ||
|
||||
processInfo.environment["__XCODE_BUILT_PRODUCTS_DIR_PATHS"] != nil
|
||||
let isRunningInDebug = processInfo.environment["DYLD_INSERT_LIBRARIES"]?
|
||||
.contains("libMainThreadChecker.dylib") ?? false ||
|
||||
processInfo.environment["__XCODE_BUILT_PRODUCTS_DIR_PATHS"] != nil
|
||||
#endif
|
||||
|
||||
// Skip cleanup during tests
|
||||
|
|
@ -374,13 +374,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
|
|||
#if DEBUG
|
||||
// Skip removing observer in debug builds
|
||||
#else
|
||||
if !isRunningInPreview && !isRunningInTests && !isRunningInDebug {
|
||||
DistributedNotificationCenter.default().removeObserver(
|
||||
self,
|
||||
name: Self.showSettingsNotification,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
if !isRunningInPreview && !isRunningInTests && !isRunningInDebug {
|
||||
DistributedNotificationCenter.default().removeObserver(
|
||||
self,
|
||||
name: Self.showSettingsNotification,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
// Remove update check notification observer
|
||||
|
|
|
|||
Loading…
Reference in a new issue