show welcome dialog on update + lint

This commit is contained in:
Peter Steinberger 2025-06-18 12:55:20 +02:00
parent d9d134ff2b
commit daf455ec9a
17 changed files with 445 additions and 369 deletions

View file

@ -0,0 +1,13 @@
import Foundation
/// Central location for app-wide constants and configuration values
enum AppConstants {
/// Current version of the welcome dialog
/// Increment this when significant changes require re-showing the welcome flow
static let currentWelcomeVersion = 2
/// UserDefaults keys
enum UserDefaultsKeys {
static let welcomeVersion = "welcomeVersion"
}
}

View file

@ -1,6 +1,6 @@
import Foundation
import AppKit
import ApplicationServices
import Foundation
import OSLog
/// Manages Accessibility permissions required for sending keystrokes.
@ -39,7 +39,9 @@ final class AccessibilityPermissionManager {
logger.info("Accessibility permission dialog triggered")
// After a short delay, also open System Settings as a fallback
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") {
if let url =
URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")
{
NSWorkspace.shared.open(url)
}
}

View file

@ -1,5 +1,5 @@
import Foundation
@preconcurrency import AppKit
import Foundation
import OSLog
/// Sendable wrapper for NSAppleEventDescriptor
@ -49,7 +49,7 @@ final class AppleScriptExecutor {
let result = scriptObject.executeAndReturnError(&error)
if let error = error {
if let error {
let errorMessage = error["NSAppleScriptErrorMessage"] as? String ?? "Unknown error"
let errorNumber = error["NSAppleScriptErrorNumber"] as? Int
@ -69,7 +69,7 @@ final class AppleScriptExecutor {
DispatchQueue.main.sync {
do {
result = .success(try execute(script, timeout: timeout))
result = try .success(execute(script, timeout: timeout))
} catch {
result = .failure(error)
}
@ -147,7 +147,7 @@ final class AppleScriptExecutor {
let result = scriptObject.executeAndReturnError(&error)
if let error = error {
if let error {
let errorMessage = error["NSAppleScriptErrorMessage"] as? String ?? "Unknown error"
let errorNumber = error["NSAppleScriptErrorNumber"] as? Int
@ -225,17 +225,17 @@ enum AppleScriptError: LocalizedError {
var errorDescription: String? {
switch self {
case .scriptCreationFailed:
return "Failed to create AppleScript object"
"Failed to create AppleScript object"
case .executionFailed(let message, let errorCode):
if let code = errorCode {
return "AppleScript error \(code): \(message)"
"AppleScript error \(code): \(message)"
} else {
return "AppleScript error: \(message)"
"AppleScript error: \(message)"
}
case .permissionDenied:
return "AppleScript permission denied. Please grant permission in System Settings."
"AppleScript permission denied. Please grant permission in System Settings."
case .timeout:
return "AppleScript execution timed out"
"AppleScript execution timed out"
}
}
@ -246,13 +246,13 @@ enum AppleScriptError: LocalizedError {
case .executionFailed(_, let errorCode):
if let code = errorCode {
switch code {
case -1743:
case -1_743:
return "User permission is required to control other applications."
case -1728:
case -1_728:
return "The application is not running or cannot be controlled."
case -1708:
case -1_708:
return "The event was not handled by the target application."
case -2741:
case -2_741:
return "AppleScript syntax error - check for unescaped quotes or invalid identifiers."
default:
return nil
@ -268,11 +268,11 @@ enum AppleScriptError: LocalizedError {
var isPermissionError: Bool {
switch self {
case .permissionDenied:
return true
true
case .executionFailed(_, let errorCode):
return errorCode == -1743
errorCode == -1_743
default:
return false
false
}
}

View file

@ -1,5 +1,5 @@
import Foundation
import AppKit
import Foundation
import OSLog
/// Manages AppleScript automation permissions for VibeTunnel.

View file

@ -21,7 +21,10 @@ final class DashboardKeychain {
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.")
logger
.info(
"Debug mode: Skipping keychain password retrieval. Password will only persist during current app session."
)
return nil
#else
let query: [String: Any] = [
@ -101,7 +104,10 @@ final class DashboardKeychain {
#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.")
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

View file

@ -131,7 +131,7 @@ final class TerminalSpawnService: @unchecked Sendable {
defer { close(clientSocket) }
// Read request data
var buffer = [UInt8](repeating: 0, count: 65536)
var buffer = [UInt8](repeating: 0, count: 65_536)
let bytesRead = recv(clientSocket, &buffer, buffer.count, 0)
guard bytesRead > 0 else {

View file

@ -716,7 +716,7 @@ public final class TunnelServer {
let command: [String]
let workingDir: String?
let term: String?
let spawn_terminal: Bool?
let spawnTerminal: Bool?
}
let sessionRequest = try JSONDecoder().decode(CreateSessionRequest.self, from: requestData)
@ -726,11 +726,14 @@ public final class TunnelServer {
}
// Handle terminal spawning if requested
if sessionRequest.spawn_terminal ?? false {
if sessionRequest.spawnTerminal ?? false {
logger.info("Spawn terminal requested for command: \(sessionRequest.command.joined(separator: " "))")
let sessionId = UUID().uuidString
let workingDir = resolvePath(sessionRequest.workingDir ?? "~/", fallback: FileManager.default.homeDirectoryForCurrentUser.path)
let workingDir = resolvePath(
sessionRequest.workingDir ?? "~/",
fallback: FileManager.default.homeDirectoryForCurrentUser.path
)
_ = sessionRequest.command.joined(separator: " ")
// Connect to the terminal spawn service via Unix socket
@ -739,7 +742,10 @@ public final class TunnelServer {
guard socketFd >= 0 else {
logger.error("Failed to create socket")
return errorResponse(message: "Failed to create socket for terminal spawning", status: .internalServerError)
return errorResponse(
message: "Failed to create socket for terminal spawning",
status: .internalServerError
)
}
defer { close(socketFd) }
@ -778,18 +784,31 @@ public final class TunnelServer {
do {
let requestData = try JSONEncoder().encode(spawnRequest)
let sendResult = send(socketFd, requestData.withUnsafeBytes { $0.baseAddress }, requestData.count, 0)
let sendResult = send(
socketFd,
requestData.withUnsafeBytes { $0.baseAddress },
requestData.count,
0
)
guard sendResult == requestData.count else {
throw NSError(domain: "TerminalSpawn", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to send complete request"])
throw NSError(
domain: "TerminalSpawn",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Failed to send complete request"]
)
}
// Read the response
var responseBuffer = [UInt8](repeating: 0, count: 4096)
var responseBuffer = [UInt8](repeating: 0, count: 4_096)
let bytesRead = recv(socketFd, &responseBuffer, responseBuffer.count, 0)
guard bytesRead > 0 else {
throw NSError(domain: "TerminalSpawn", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to read response"])
throw NSError(
domain: "TerminalSpawn",
code: 2,
userInfo: [NSLocalizedDescriptionKey: "Failed to read response"]
)
}
let responseData = Data(bytes: responseBuffer, count: bytesRead)
@ -827,11 +846,17 @@ public final class TunnelServer {
} else {
let errorMsg = spawnResponse.error ?? "Unknown error"
logger.error("Failed to spawn terminal: \(errorMsg)")
return errorResponse(message: "Failed to spawn terminal: \(errorMsg)", status: .internalServerError)
return errorResponse(
message: "Failed to spawn terminal: \(errorMsg)",
status: .internalServerError
)
}
} catch {
logger.error("Failed to communicate with terminal spawn service: \(error)")
return errorResponse(message: "Failed to spawn terminal: \(error.localizedDescription)", status: .internalServerError)
return errorResponse(
message: "Failed to spawn terminal: \(error.localizedDescription)",
status: .internalServerError
)
}
}

View file

@ -171,28 +171,35 @@ private struct TerminalPreferenceSection: View {
switch terminalError {
case .appleScriptPermissionDenied:
errorTitle = "Permission Denied"
errorMessage = "VibeTunnel needs permission to control terminal applications.\n\nPlease grant Automation permission in System Settings > Privacy & Security > Automation."
errorMessage =
"VibeTunnel needs permission to control terminal applications.\n\nPlease grant Automation permission in System Settings > Privacy & Security > Automation."
case .accessibilityPermissionDenied:
errorTitle = "Accessibility Permission Required"
errorMessage = "VibeTunnel needs Accessibility permission to send keystrokes to \(Terminal(rawValue: preferredTerminal)?.displayName ?? "terminal").\n\nPlease grant permission in System Settings > Privacy & Security > Accessibility."
errorMessage =
"VibeTunnel needs Accessibility permission to send keystrokes to \(Terminal(rawValue: preferredTerminal)?.displayName ?? "terminal").\n\nPlease grant permission in System Settings > Privacy & Security > Accessibility."
case .terminalNotFound:
errorTitle = "Terminal Not Found"
errorMessage = "The selected terminal application could not be found. Please select a different terminal."
errorMessage =
"The selected terminal application could not be found. Please select a different terminal."
case .appleScriptExecutionFailed(let details, let errorCode):
if let code = errorCode {
switch code {
case -1_743:
errorTitle = "Permission Denied"
errorMessage = "VibeTunnel needs permission to control terminal applications.\n\nPlease grant Automation permission in System Settings > Privacy & Security > Automation."
errorMessage =
"VibeTunnel needs permission to control terminal applications.\n\nPlease grant Automation permission in System Settings > Privacy & Security > Automation."
case -1_728:
errorTitle = "Terminal Not Available"
errorMessage = "The terminal application is not running or cannot be controlled.\n\nDetails: \(details)"
errorMessage =
"The terminal application is not running or cannot be controlled.\n\nDetails: \(details)"
case -1_708:
errorTitle = "Terminal Communication Error"
errorMessage = "The terminal did not respond to the command.\n\nDetails: \(details)"
case -25211:
errorMessage =
"The terminal did not respond to the command.\n\nDetails: \(details)"
case -25_211:
errorTitle = "Accessibility Permission Required"
errorMessage = "System Events requires Accessibility permission to send keystrokes.\n\nPlease grant permission in System Settings > Privacy & Security > Accessibility."
errorMessage =
"System Events requires Accessibility permission to send keystrokes.\n\nPlease grant permission in System Settings > Privacy & Security > Accessibility."
default:
errorTitle = "Terminal Launch Failed"
errorMessage = "AppleScript error \(code): \(details)"
@ -232,10 +239,12 @@ private struct TerminalPreferenceSection: View {
.multilineTextAlignment(.center)
}
.alert(errorTitle, isPresented: $showingError) {
Button("OK") { }
Button("OK") {}
if errorTitle == "Permission Denied" {
Button("Open System Settings") {
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation") {
if let url =
URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation")
{
NSWorkspace.shared.open(url)
}
}

View file

@ -822,4 +822,3 @@ private struct NgrokErrorView: View {
}
}
}

View file

@ -369,7 +369,9 @@ private struct ServerSection: View {
Text("HTTP Server")
.font(.headline)
} footer: {
Text("The HTTP server provides REST API endpoints for terminal session management. Choose between the built-in Swift Hummingbird server or the Rust tty-fwd binary.")
Text(
"The HTTP server provides REST API endpoints for terminal session management. Choose between the built-in Swift Hummingbird server or the Rust tty-fwd binary."
)
.font(.caption)
.frame(maxWidth: .infinity)
.multilineTextAlignment(.center)
@ -609,4 +611,3 @@ private struct DeveloperToolsSection: View {
}
}
}

View file

@ -1,5 +1,5 @@
import SwiftUI
import Combine
import SwiftUI
/// General settings tab for basic app preferences
struct GeneralSettingsView: View {
@ -210,7 +210,9 @@ private struct PermissionsSection: View {
Text("Permissions")
.font(.headline)
} footer: {
Text("Terminal automation is required for all terminals. Accessibility is only needed for terminals that simulate keyboard input.")
Text(
"Terminal automation is required for all terminals. Accessibility is only needed for terminals that simulate keyboard input."
)
.font(.caption)
.frame(maxWidth: .infinity)
.multilineTextAlignment(.center)

View file

@ -10,8 +10,8 @@ struct WelcomeView: View {
@State private var currentPage = 0
@Environment(\.dismiss)
private var dismiss
@AppStorage("hasSeenWelcome")
private var hasSeenWelcome = false
@AppStorage(AppConstants.UserDefaultsKeys.welcomeVersion)
private var welcomeVersion = 0
@State private var cliInstaller = CLIInstaller()
@StateObject private var permissionManager = AppleScriptPermissionManager.shared
@ -107,7 +107,7 @@ struct WelcomeView: View {
}
} else {
// Finish action - open Settings
hasSeenWelcome = true
welcomeVersion = AppConstants.currentWelcomeVersion
dismiss()
SettingsOpener.openSettings()
}

View file

@ -1,8 +1,8 @@
import AppKit
import Foundation
import Observation
import SwiftUI
import os.log
import SwiftUI
/// Service responsible for creating symlinks to command line tools with sudo authentication.
///

View file

@ -88,7 +88,9 @@ enum SettingsOpener {
guard let window = notification.object as? NSWindow else { return }
Task { @MainActor in
guard window.title.contains("Settings") || window.identifier?.rawValue.contains(settingsWindowIdentifier) == true else {
guard window.title.contains("Settings") || window.identifier?.rawValue
.contains(settingsWindowIdentifier) == true
else {
return
}
@ -108,7 +110,7 @@ enum SettingsOpener {
/// Finds the settings window using multiple detection methods
static func findSettingsWindow() -> NSWindow? {
// Try multiple methods to find the window
return NSApp.windows.first { window in
NSApp.windows.first { window in
// Check by identifier
if window.identifier?.rawValue == settingsWindowIdentifier {
return true
@ -117,13 +119,16 @@ enum SettingsOpener {
// Check by title
if window.isVisible && window.styleMask.contains(.titled) &&
(window.title.localizedCaseInsensitiveContains("settings") ||
window.title.localizedCaseInsensitiveContains("preferences")) {
window.title.localizedCaseInsensitiveContains("preferences")
)
{
return true
}
// Check by content view controller type
if let contentVC = window.contentViewController,
String(describing: type(of: contentVC)).contains("Settings") {
String(describing: type(of: contentVC)).contains("Settings")
{
return true
}
@ -131,8 +136,6 @@ enum SettingsOpener {
}
}
/// Opens the Settings window and navigates to a specific tab
static func openSettingsTab(_ tab: SettingsTab) {
openSettings()
@ -168,4 +171,3 @@ struct HiddenWindowView: View {
extension Notification.Name {
static let openSettingsRequest = Notification.Name("openSettingsRequest")
}

View file

@ -1,7 +1,7 @@
import AppKit
import Foundation
import SwiftUI
import os.log
import SwiftUI
/// Terminal launch configuration
struct TerminalLaunchConfig {
@ -10,7 +10,7 @@ struct TerminalLaunchConfig {
let terminal: Terminal
var fullCommand: String {
guard let workingDirectory = workingDirectory else {
guard let workingDirectory else {
return command
}
let escapedDir = workingDirectory.replacingOccurrences(of: "\"", with: "\\\"")
@ -85,13 +85,13 @@ enum Terminal: String, CaseIterable {
/// Priority for auto-detection (higher is better, based on popularity)
var detectionPriority: Int {
switch self {
case .terminal: return 100 // Highest - macOS default, most popular
case .iTerm2: return 95 // Very popular among developers
case .warp: return 85 // Popular modern terminal
case .ghostty: return 80 // New but gaining popularity
case .alacritty: return 70 // Popular among power users
case .wezterm: return 60 // Less common but powerful
case .hyper: return 50 // Less popular Electron-based
case .terminal: 100 // Highest - macOS default, most popular
case .iTerm2: 95 // Very popular among developers
case .warp: 85 // Popular modern terminal
case .ghostty: 80 // New but gaining popularity
case .alacritty: 70 // Popular among power users
case .wezterm: 60 // Less common but powerful
case .hyper: 50 // Less popular Electron-based
}
}
@ -155,51 +155,51 @@ enum Terminal: String, CaseIterable {
switch self {
case .terminal:
// Use unified AppleScript approach for consistency
return .appleScript(script: unifiedAppleScript(for: config))
.appleScript(script: unifiedAppleScript(for: config))
case .iTerm2:
// Use unified AppleScript approach for consistency
return .appleScript(script: unifiedAppleScript(for: config))
.appleScript(script: unifiedAppleScript(for: config))
case .ghostty:
// Use unified AppleScript approach
return .appleScript(script: unifiedAppleScript(for: config))
.appleScript(script: unifiedAppleScript(for: config))
case .alacritty:
// Use unified AppleScript approach for consistency
return .appleScript(script: unifiedAppleScript(for: config))
.appleScript(script: unifiedAppleScript(for: config))
case .warp:
// Use unified AppleScript approach
return .appleScript(script: unifiedAppleScript(for: config))
.appleScript(script: unifiedAppleScript(for: config))
case .hyper:
// Use unified AppleScript approach
return .appleScript(script: unifiedAppleScript(for: config))
.appleScript(script: unifiedAppleScript(for: config))
case .wezterm:
// Use unified AppleScript approach for consistency
return .appleScript(script: unifiedAppleScript(for: config))
.appleScript(script: unifiedAppleScript(for: config))
}
}
/// Process name for AppleScript typing
var processName: String {
switch self {
case .terminal: return "Terminal"
case .iTerm2: return "iTerm"
case .ghostty: return "Ghostty"
case .warp: return "Warp"
case .alacritty: return "Alacritty"
case .hyper: return "Hyper"
case .wezterm: return "WezTerm"
case .terminal: "Terminal"
case .iTerm2: "iTerm"
case .ghostty: "Ghostty"
case .warp: "Warp"
case .alacritty: "Alacritty"
case .hyper: "Hyper"
case .wezterm: "WezTerm"
}
}
/// Whether this terminal requires keystroke-based input (needs Accessibility permission)
var requiresKeystrokeInput: Bool {
// All terminals now use keystroke-based input
return true
true
}
}
@ -217,19 +217,19 @@ enum TerminalLauncherError: LocalizedError {
var errorDescription: String? {
switch self {
case .terminalNotFound:
return "Selected terminal application not found"
"Selected terminal application not found"
case .appleScriptPermissionDenied:
return "AppleScript permission denied. Please grant permission in System Settings."
"AppleScript permission denied. Please grant permission in System Settings."
case .accessibilityPermissionDenied:
return "Accessibility permission required to send keystrokes. Please grant permission in System Settings."
"Accessibility permission required to send keystrokes. Please grant permission in System Settings."
case .appleScriptExecutionFailed(let message, let errorCode):
if let code = errorCode {
return "AppleScript error \(code): \(message)"
"AppleScript error \(code): \(message)"
} else {
return "AppleScript error: \(message)"
"AppleScript error: \(message)"
}
case .processLaunchFailed(let message):
return "Failed to launch process: \(message)"
"Failed to launch process: \(message)"
}
}
@ -248,7 +248,7 @@ enum TerminalLauncherError: LocalizedError {
return "The application is not running or cannot be controlled."
case -1_708:
return "The event was not handled by the target application."
case -25211:
case -25_211:
return "Accessibility permission is required to send keystrokes."
default:
return nil
@ -281,14 +281,12 @@ final class TerminalLauncher {
logger.info("TerminalLauncher initialized successfully")
}
func launchCommand(_ command: String) throws {
let terminal = getValidTerminal()
let config = TerminalLaunchConfig(command: command, workingDirectory: nil, terminal: terminal)
try launchWithConfig(config)
}
func verifyPreferredTerminal() {
let terminal = Terminal(rawValue: preferredTerminal) ?? .terminal
if !terminal.isInstalled {
@ -313,7 +311,10 @@ final class TerminalLauncher {
let installedTerminals = Terminal.installed.filter { $0 != .terminal }
if let bestTerminal = installedTerminals.max(by: { $0.detectionPriority < $1.detectionPriority }) {
preferredTerminal = bestTerminal.rawValue
logger.info("No running terminals found, set preferred terminal to most popular installed: \(bestTerminal.rawValue)")
logger
.info(
"No running terminals found, set preferred terminal to most popular installed: \(bestTerminal.rawValue)"
)
}
}
}
@ -344,7 +345,10 @@ final class TerminalLauncher {
if actualTerminal != terminal {
// Update preference to fallback
preferredTerminal = actualTerminal.rawValue
logger.warning("Preferred terminal \(terminal.rawValue) not installed, falling back to \(actualTerminal.rawValue)")
logger
.warning(
"Preferred terminal \(terminal.rawValue) not installed, falling back to \(actualTerminal.rawValue)"
)
}
return actualTerminal
@ -414,7 +418,8 @@ final class TerminalLauncher {
process.waitUntilExit()
if process.terminationStatus != 0 {
throw TerminalLauncherError.processLaunchFailed("Process exited with status \(process.terminationStatus)")
throw TerminalLauncherError
.processLaunchFailed("Process exited with status \(process.terminationStatus)")
}
} catch {
logger.error("Failed to launch terminal: \(error.localizedDescription)")
@ -436,15 +441,19 @@ final class TerminalLauncher {
} catch let error as AppleScriptError {
// Check if this is a permission error
if case .executionFailed(_, let errorCode) = error,
let code = errorCode {
let code = errorCode
{
switch code {
case -25211, -1719:
case -25_211, -1_719:
// These error codes indicate accessibility permission issues
throw TerminalLauncherError.accessibilityPermissionDenied
case -2741:
case -2_741:
// This is a syntax error: "Expected end of line but found identifier"
// It usually means the AppleScript has unescaped quotes or other syntax issues
throw TerminalLauncherError.appleScriptExecutionFailed("AppleScript syntax error - likely unescaped quotes in command", errorCode: code)
throw TerminalLauncherError.appleScriptExecutionFailed(
"AppleScript syntax error - likely unescaped quotes in command",
errorCode: code
)
default:
break
}
@ -457,11 +466,9 @@ final class TerminalLauncher {
}
}
// MARK: - Terminal Session Launching
func launchTerminalSession(workingDirectory: String, command: String, sessionId: String) throws {
// Find tty-fwd binary path
let ttyFwdPath = findTTYFwdBinary()
@ -473,7 +480,8 @@ final class TerminalLauncher {
// Construct the full command with cd && tty-fwd && exit pattern
// tty-fwd will use TTY_SESSION_ID from environment or generate one
let fullCommand = "cd \"\(escapedWorkingDir)\" && TTY_SESSION_ID=\"\(sessionId)\" \(ttyFwdPath) -- \(command) && exit"
let fullCommand =
"cd \"\(escapedWorkingDir)\" && TTY_SESSION_ID=\"\(sessionId)\" \(ttyFwdPath) -- \(command) && exit"
// Get the preferred terminal or fallback
let terminal = getValidTerminal()
@ -488,7 +496,14 @@ final class TerminalLauncher {
}
/// Optimized terminal session launching that receives pre-formatted command from Rust
func launchOptimizedTerminalSession(workingDirectory: String, command: String, sessionId: String, ttyFwdPath: String? = nil) throws {
func launchOptimizedTerminalSession(
workingDirectory: String,
command: String,
sessionId: String,
ttyFwdPath: String? = nil
)
throws
{
// Expand tilde in working directory path
let expandedWorkingDir = (workingDirectory as NSString).expandingTildeInPath

View file

@ -103,9 +103,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
let showInDock = UserDefaults.standard.bool(forKey: "showInDock")
NSApp.setActivationPolicy(showInDock ? .regular : .accessory)
// Show welcome screen on first launch
let hasSeenWelcome = UserDefaults.standard.bool(forKey: "hasSeenWelcome")
if !hasSeenWelcome && !isRunningInTests && !isRunningInPreview {
// Show welcome screen when version changes
let storedWelcomeVersion = UserDefaults.standard.integer(forKey: AppConstants.UserDefaultsKeys.welcomeVersion)
// Show welcome if version is different from current
if storedWelcomeVersion < AppConstants.currentWelcomeVersion && !isRunningInTests && !isRunningInPreview {
showWelcomeScreen()
}