diff --git a/StikJIT/StikJITApp.swift b/StikJIT/StikJITApp.swift index 1d5bbd98..618ee70c 100644 --- a/StikJIT/StikJITApp.swift +++ b/StikJIT/StikJITApp.swift @@ -85,16 +85,19 @@ class VPNLogger: ObservableObject { class TunnelManager: ObservableObject { @Published var tunnelStatus: TunnelStatus = .disconnected static var shared = TunnelManager() + + private let sharedDefaults = UserDefaults(suiteName: "group.com.stik.sj") + private let vpnStatusKey = "vpnStatus" private var vpnManager: NETunnelProviderManager? private var tunnelDeviceIp: String { - UserDefaults.standard.string(forKey: "TunnelDeviceIP") ?? "10.7.0.0" + sharedDefaults?.string(forKey: "TunnelDeviceIP") ?? "10.7.0.0" } private var tunnelFakeIp: String { - UserDefaults.standard.string(forKey: "TunnelFakeIP") ?? "10.7.0.1" + sharedDefaults?.string(forKey: "TunnelFakeIP") ?? "10.7.0.1" } private var tunnelSubnetMask: String { - UserDefaults.standard.string(forKey: "TunnelSubnetMask") ?? "255.255.255.0" + sharedDefaults?.string(forKey: "TunnelSubnetMask") ?? "255.255.255.0" } private var tunnelBundleId: String { Bundle.main.bundleIdentifier!.appending(".TunnelProv") @@ -107,8 +110,12 @@ class TunnelManager: ObservableObject { case disconnecting = "Disconnecting" case error = "Error" } - + private init() { + if let saved = sharedDefaults?.string(forKey: vpnStatusKey), + let status = TunnelStatus(rawValue: saved) { + tunnelStatus = status + } loadTunnelPreferences() NotificationCenter.default.addObserver(self, selector: #selector(statusDidChange(_:)), name: .NEVPNStatusDidChange, object: nil) } @@ -166,6 +173,7 @@ class TunnelManager: ObservableObject { @unknown default: self.tunnelStatus = .error } + self.sharedDefaults?.set(self.tunnelStatus.rawValue, forKey: vpnStatusKey) VPNLogger.shared.log("VPN status updated: \(self.tunnelStatus.rawValue)") } } @@ -240,6 +248,7 @@ class TunnelManager: ObservableObject { return } tunnelStatus = .connecting + sharedDefaults?.set(tunnelStatus.rawValue, forKey: vpnStatusKey) let options: [String: NSObject] = [ "TunnelDeviceIP": tunnelDeviceIp as NSObject, "TunnelFakeIP": tunnelFakeIp as NSObject, @@ -250,13 +259,15 @@ class TunnelManager: ObservableObject { VPNLogger.shared.log("Network tunnel start initiated") } catch { tunnelStatus = .error + sharedDefaults?.set(tunnelStatus.rawValue, forKey: vpnStatusKey) VPNLogger.shared.log("Failed to start tunnel: \(error.localizedDescription)") } } - + func stopVPN() { guard let manager = vpnManager else { return } tunnelStatus = .disconnecting + sharedDefaults?.set(tunnelStatus.rawValue, forKey: vpnStatusKey) manager.connection.stopVPNTunnel() VPNLogger.shared.log("Network tunnel stop initiated") } diff --git a/StikJIT/Views/SettingsView.swift b/StikJIT/Views/SettingsView.swift index 518ee678..60bf7cd3 100644 --- a/StikJIT/Views/SettingsView.swift +++ b/StikJIT/Views/SettingsView.swift @@ -31,6 +31,10 @@ struct SettingsView: View { @State private var showingConsoleLogsView = false @State private var showingDisplayView = false + + @AppStorage("TunnelDeviceIP", store: UserDefaults(suiteName: "group.com.stik.sj")) private var deviceIP: String = "10.7.0.0" + @AppStorage("TunnelFakeIP", store: UserDefaults(suiteName: "group.com.stik.sj")) private var fakeIP: String = "10.7.0.1" + @AppStorage("TunnelSubnetMask", store: UserDefaults(suiteName: "group.com.stik.sj")) private var subnetMask: String = "255.255.255.0" private var appVersion: String { let marketingVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" @@ -201,10 +205,10 @@ struct SettingsView: View { // Developer Disk Image section SettingsCard { - VStack(alignment: .leading, spacing: 20) { - Text("Developer Disk Image") - .font(.headline) - .foregroundColor(.primary) + VStack(alignment: .leading, spacing: 20) { + Text("Developer Disk Image") + .font(.headline) + .foregroundColor(.primary) .padding(.bottom, 4) // Status indicator with icon @@ -264,6 +268,34 @@ struct SettingsView: View { self.mounted = isMounted() } } + // VPN configuration section + SettingsCard { + VStack(alignment: .leading, spacing: 16) { + Text("VPN Configuration") + .font(.headline) + .foregroundColor(.primary) + + TextField("Device IP", text: $deviceIP) + .textFieldStyle(RoundedBorderTextFieldStyle()) + + TextField("Fake IP", text: $fakeIP) + .textFieldStyle(RoundedBorderTextFieldStyle()) + + TextField("Subnet Mask", text: $subnetMask) + .textFieldStyle(RoundedBorderTextFieldStyle()) + + Button(action: saveIPSettings) { + Text("Save") + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(accentColor) + .foregroundColor(accentColor.contrastText()) + .cornerRadius(12) + } + } + .padding(.vertical, 20) + .padding(.horizontal, 16) + } SettingsCard { VStack(alignment: .leading, spacing: 20) { Text("Behavior") @@ -611,6 +643,13 @@ struct SettingsView: View { } } } + + private func saveIPSettings() { + let defaults = UserDefaults(suiteName: "group.com.stik.sj") + defaults?.set(deviceIP, forKey: "TunnelDeviceIP") + defaults?.set(fakeIP, forKey: "TunnelFakeIP") + defaults?.set(subnetMask, forKey: "TunnelSubnetMask") + } } // MARK: - Helper Components diff --git a/StikVPN/StikVPNApp.swift b/StikVPN/StikVPNApp.swift new file mode 100644 index 00000000..d779a472 --- /dev/null +++ b/StikVPN/StikVPNApp.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct StikVPNApp: App { + var body: some Scene { + WindowGroup { + VPNMainView() + } + } +} diff --git a/StikVPN/Utilities/Color.swift b/StikVPN/Utilities/Color.swift new file mode 100644 index 00000000..d3e53fe8 --- /dev/null +++ b/StikVPN/Utilities/Color.swift @@ -0,0 +1,89 @@ +// +// Color.swift +// StikJIT +// +// Created by Stephen on 3/27/25. +// + + +import SwiftUI + +extension Color { + func toHex() -> String? { + let components = UIColor(self).cgColor.components + let r = Float(components?[0] ?? 0) + let g = Float(components?[1] ?? 0) + let b = Float(components?[2] ?? 0) + let hex = String(format: "%02lX%02lX%02lX", lroundf(r * 255), lroundf(g * 255), lroundf(b * 255)) + return "#" + hex + } + + init?(hex: String) { + if hex.isEmpty || hex.count < 2 { + return nil + } + + let r, g, b: CGFloat + + if hex.hasPrefix("#") && hex.count >= 7 { + let start = hex.index(hex.startIndex, offsetBy: 1) + let hexColor = String(hex[start...]) + + if hexColor.count == 6, let hexNumber = Int(hexColor, radix: 16) { + r = CGFloat((hexNumber & 0xff0000) >> 16) / 255 + g = CGFloat((hexNumber & 0x00ff00) >> 8) / 255 + b = CGFloat(hexNumber & 0x0000ff) / 255 + self.init(red: r, green: g, blue: b) + return + } + } + + return nil + } +} + +extension Color { + static let primaryBackground = Color.black + static let cardBackground = Color.white.opacity(0.2) + + // Instead of a static color, provide a function that gets the current accent color + static func dynamicAccentColor(opacity: Double = 0.8) -> Color { + let colorHex = UserDefaults.standard.string(forKey: "customAccentColor") ?? "" + if colorHex.isEmpty { + return Color.blue.opacity(opacity) + } else { + return (Color(hex: colorHex) ?? .blue).opacity(opacity) + } + } + + // For backward compatibility + static var cardBackground2: Color { + return dynamicAccentColor(opacity: 0.8) + } + + static let primaryText = Color.white + static let secondaryText = Color.white.opacity(0.7) + + // Determine if a color is dark or light to decide text color + func isDark() -> Bool { + let uiColor = UIColor(self) + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + + uiColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + + // Calculate perceived brightness using the formula for luminance + // 0.299*R + 0.587*G + 0.114*B + let brightness = (0.299 * red) + (0.587 * green) + (0.114 * blue) + + // Return true if the color is dark (brightness < 0.5) + return brightness < 0.5 + } + + // Returns black or white depending on the background brightness + func contrastText() -> Color { + return self.isDark() ? .white : .black + } +} diff --git a/StikVPN/Utilities/TunnelManager.swift b/StikVPN/Utilities/TunnelManager.swift new file mode 100644 index 00000000..24b6a41b --- /dev/null +++ b/StikVPN/Utilities/TunnelManager.swift @@ -0,0 +1,207 @@ +import SwiftUI +import NetworkExtension + +class VPNLogger: ObservableObject { + @Published var logs: [String] = [] + static let shared = VPNLogger() + private init() {} + + func log(_ message: Any, file: String = #file, function: String = #function, line: Int = #line) { + #if DEBUG + let fileName = (file as NSString).lastPathComponent + print("[\(fileName):\(line)] \(function): \(message)") + #endif + logs.append("\(message)") + } +} + +enum TunnelStatus: String { + case disconnected = "Disconnected" + case connecting = "Connecting" + case connected = "Connected" + case disconnecting = "Disconnecting" + case error = "Error" +} + +class TunnelManager: ObservableObject { + @Published var tunnelStatus: TunnelStatus = .disconnected + static let shared = TunnelManager() + + private let sharedDefaults = UserDefaults(suiteName: "group.com.stik.sj") + private let vpnStatusKey = "vpnStatus" + + private var vpnManager: NETunnelProviderManager? + private var tunnelDeviceIp: String { + sharedDefaults?.string(forKey: "TunnelDeviceIP") ?? "10.7.0.0" + } + private var tunnelFakeIp: String { + sharedDefaults?.string(forKey: "TunnelFakeIP") ?? "10.7.0.1" + } + private var tunnelSubnetMask: String { + sharedDefaults?.string(forKey: "TunnelSubnetMask") ?? "255.255.255.0" + } + private var tunnelBundleId: String { + Bundle.main.bundleIdentifier!.appending(".TunnelProv") + } + + private init() { + if let saved = sharedDefaults?.string(forKey: vpnStatusKey), + let status = TunnelStatus(rawValue: saved) { + tunnelStatus = status + } + loadTunnelPreferences() + NotificationCenter.default.addObserver(self, selector: #selector(statusDidChange(_:)), name: .NEVPNStatusDidChange, object: nil) + } + + private func loadTunnelPreferences() { + NETunnelProviderManager.loadAllFromPreferences { [weak self] managers, error in + guard let self = self else { return } + DispatchQueue.main.async { + if let error = error { + VPNLogger.shared.log("Error loading preferences: \(error.localizedDescription)") + self.tunnelStatus = .error + return + } + if let managers = managers, !managers.isEmpty { + for manager in managers { + if let proto = manager.protocolConfiguration as? NETunnelProviderProtocol, + proto.providerBundleIdentifier == self.tunnelBundleId { + self.vpnManager = manager + self.updateTunnelStatus(from: manager.connection.status) + VPNLogger.shared.log("Loaded existing tunnel configuration") + break + } + } + if self.vpnManager == nil, let firstManager = managers.first { + self.vpnManager = firstManager + self.updateTunnelStatus(from: firstManager.connection.status) + VPNLogger.shared.log("Using existing tunnel configuration") + } + } else { + VPNLogger.shared.log("No existing tunnel configuration found") + } + } + } + } + + @objc private func statusDidChange(_ notification: Notification) { + if let connection = notification.object as? NEVPNConnection { + updateTunnelStatus(from: connection.status) + } + } + + private func updateTunnelStatus(from connectionStatus: NEVPNStatus) { + DispatchQueue.main.async { + switch connectionStatus { + case .invalid, .disconnected: + self.tunnelStatus = .disconnected + case .connecting: + self.tunnelStatus = .connecting + case .connected: + self.tunnelStatus = .connected + case .disconnecting: + self.tunnelStatus = .disconnecting + case .reasserting: + self.tunnelStatus = .connecting + @unknown default: + self.tunnelStatus = .error + } + self.sharedDefaults?.set(self.tunnelStatus.rawValue, forKey: self.vpnStatusKey) + VPNLogger.shared.log("VPN status updated: \(self.tunnelStatus.rawValue)") + } + } + + private func createOrUpdateTunnelConfiguration(completion: @escaping (Bool) -> Void) { + NETunnelProviderManager.loadAllFromPreferences { [weak self] managers, error in + guard let self = self else { return completion(false) } + if let error = error { + VPNLogger.shared.log("Error loading preferences: \(error.localizedDescription)") + return completion(false) + } + + let manager: NETunnelProviderManager + if let existingManagers = managers, !existingManagers.isEmpty { + if let matchingManager = existingManagers.first(where: { + ($0.protocolConfiguration as? NETunnelProviderProtocol)?.providerBundleIdentifier == self.tunnelBundleId + }) { + manager = matchingManager + VPNLogger.shared.log("Updating existing tunnel configuration") + } else { + manager = existingManagers[0] + VPNLogger.shared.log("Using first available tunnel configuration") + } + } else { + manager = NETunnelProviderManager() + VPNLogger.shared.log("Creating new tunnel configuration") + } + + manager.localizedDescription = "StikVPN" + let proto = NETunnelProviderProtocol() + proto.providerBundleIdentifier = self.tunnelBundleId + proto.serverAddress = "StikVPN Local Tunnel" + manager.protocolConfiguration = proto + manager.isOnDemandEnabled = true + manager.isEnabled = true + + manager.saveToPreferences { [weak self] error in + guard let self = self else { return completion(false) } + DispatchQueue.main.async { + if let error = error { + VPNLogger.shared.log("Error saving tunnel configuration: \(error.localizedDescription)") + completion(false) + return + } + self.vpnManager = manager + VPNLogger.shared.log("Tunnel configuration saved successfully") + completion(true) + } + } + } + } + + func startVPN() { + if let manager = vpnManager { + startExistingVPN(manager: manager) + } else { + createOrUpdateTunnelConfiguration { [weak self] success in + guard let self = self, success else { return } + self.loadTunnelPreferences() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + if let manager = self.vpnManager { + self.startExistingVPN(manager: manager) + } + } + } + } + } + + private func startExistingVPN(manager: NETunnelProviderManager) { + guard tunnelStatus != .connected else { + VPNLogger.shared.log("Network tunnel is already connected") + return + } + tunnelStatus = .connecting + sharedDefaults?.set(tunnelStatus.rawValue, forKey: vpnStatusKey) + let options: [String: NSObject] = [ + "TunnelDeviceIP": tunnelDeviceIp as NSObject, + "TunnelFakeIP": tunnelFakeIp as NSObject, + "TunnelSubnetMask": tunnelSubnetMask as NSObject + ] + do { + try manager.connection.startVPNTunnel(options: options) + VPNLogger.shared.log("Network tunnel start initiated") + } catch { + tunnelStatus = .error + sharedDefaults?.set(tunnelStatus.rawValue, forKey: vpnStatusKey) + VPNLogger.shared.log("Failed to start tunnel: \(error.localizedDescription)") + } + } + + func stopVPN() { + guard let manager = vpnManager else { return } + tunnelStatus = .disconnecting + sharedDefaults?.set(tunnelStatus.rawValue, forKey: vpnStatusKey) + manager.connection.stopVPNTunnel() + VPNLogger.shared.log("Network tunnel stop initiated") + } +} diff --git a/StikVPN/Views/VPNMainView.swift b/StikVPN/Views/VPNMainView.swift new file mode 100644 index 00000000..030a2a13 --- /dev/null +++ b/StikVPN/Views/VPNMainView.swift @@ -0,0 +1,92 @@ +import SwiftUI +import NetworkExtension + +private let appGroupID = "group.com.stik.sj" + +struct VPNMainView: View { + @AppStorage("customAccentColor") private var customAccentColorHex: String = "" + @StateObject private var manager = TunnelManager.shared + + @AppStorage("TunnelDeviceIP", store: UserDefaults(suiteName: appGroupID)) private var deviceIP: String = "10.7.0.0" + @AppStorage("TunnelFakeIP", store: UserDefaults(suiteName: appGroupID)) private var fakeIP: String = "10.7.0.1" + @AppStorage("TunnelSubnetMask", store: UserDefaults(suiteName: appGroupID)) private var subnetMask: String = "255.255.255.0" + + private var accentColor: Color { + if customAccentColorHex.isEmpty { + return .blue + } else { + return Color(hex: customAccentColorHex) ?? .blue + } + } + + var body: some View { + VStack(spacing: 24) { + Text("StikVPN") + .font(.system(.largeTitle, design: .rounded).weight(.bold)) + Text("Status: \(manager.tunnelStatus.rawValue)") + .font(.system(.title3, design: .rounded)) + .foregroundColor(.secondary) + + HStack(spacing: 20) { + Button(action: startVPN) { + Label("Connect", systemImage: "link") + .frame(maxWidth: .infinity) + .padding() + .background(accentColor) + .foregroundColor(accentColor.contrastText()) + .cornerRadius(12) + } + Button(action: stopVPN) { + Label("Disconnect", systemImage: "link.badge.minus") + .frame(maxWidth: .infinity) + .padding() + .background(accentColor) + .foregroundColor(accentColor.contrastText()) + .cornerRadius(12) + } + } + .padding(.horizontal) + + VStack(spacing: 12) { + TextField("Device IP", text: $deviceIP) + .textFieldStyle(RoundedBorderTextFieldStyle()) + TextField("Fake IP", text: $fakeIP) + .textFieldStyle(RoundedBorderTextFieldStyle()) + TextField("Subnet Mask", text: $subnetMask) + .textFieldStyle(RoundedBorderTextFieldStyle()) + + Button(action: saveIPs) { + Text("Save") + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(accentColor) + .foregroundColor(accentColor.contrastText()) + .cornerRadius(12) + } + } + .padding(.top, 8) + } + .padding() + } + + private func startVPN() { + manager.startVPN() + } + + private func stopVPN() { + manager.stopVPN() + } + + private func saveIPs() { + let defaults = UserDefaults(suiteName: appGroupID) + defaults?.set(deviceIP, forKey: "TunnelDeviceIP") + defaults?.set(fakeIP, forKey: "TunnelFakeIP") + defaults?.set(subnetMask, forKey: "TunnelSubnetMask") + } +} + +struct VPNMainView_Previews: PreviewProvider { + static var previews: some View { + VPNMainView() + } +} diff --git a/TunnelProv/PacketTunnelProvider.swift b/TunnelProv/PacketTunnelProvider.swift index d6e6c174..5b5577b9 100644 --- a/TunnelProv/PacketTunnelProvider.swift +++ b/TunnelProv/PacketTunnelProvider.swift @@ -10,11 +10,29 @@ import NetworkExtension import Darwin +private let vpnStatusKey = "vpnStatus" +private let appGroupID = "group.com.stik.sj" + +enum TunnelStatus: String { + case disconnected = "Disconnected" + case connecting = "Connecting" + case connected = "Connected" + case disconnecting = "Disconnecting" + case error = "Error" +} + class PacketTunnelProvider: NEPacketTunnelProvider { override func startTunnel(options: [String: NSObject]?, completionHandler: @escaping (Error?) -> Void) { - let deviceIP = options?["TunnelDeviceIP"] as? String ?? "10.7.0.0" - let fakeIP = options?["TunnelFakeIP"] as? String ?? "10.7.0.1" - + let sharedDefaults = UserDefaults(suiteName: appGroupID) + let deviceIP = options?["TunnelDeviceIP"] as? String ?? + sharedDefaults?.string(forKey: "TunnelDeviceIP") ?? "10.7.0.0" + let fakeIP = options?["TunnelFakeIP"] as? String ?? + sharedDefaults?.string(forKey: "TunnelFakeIP") ?? "10.7.0.1" + let subnetMask = options?["TunnelSubnetMask"] as? String ?? + sharedDefaults?.string(forKey: "TunnelSubnetMask") ?? "255.255.255.0" + + sharedDefaults?.set(TunnelStatus.connecting.rawValue, forKey: vpnStatusKey) + let toNetwork: (String) -> UInt32 = { address in var addr = in_addr() inet_pton(AF_INET, address, &addr) @@ -23,13 +41,16 @@ class PacketTunnelProvider: NEPacketTunnelProvider { let deviceNet = toNetwork(deviceIP), fakeNet = toNetwork(fakeIP) let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: deviceIP) - let ipv4 = NEIPv4Settings(addresses: [deviceIP], subnetMasks: ["255.255.255.0"]) - ipv4.includedRoutes = [NEIPv4Route(destinationAddress: deviceIP, subnetMask: "255.255.255.0")] + let ipv4 = NEIPv4Settings(addresses: [deviceIP], subnetMasks: [subnetMask]) + ipv4.includedRoutes = [NEIPv4Route(destinationAddress: deviceIP, subnetMask: subnetMask)] ipv4.excludedRoutes = [NEIPv4Route.default()] settings.ipv4Settings = ipv4 setTunnelNetworkSettings(settings) { error in - guard error == nil else { return completionHandler(error) } + guard error == nil else { + sharedDefaults?.set(TunnelStatus.error.rawValue, forKey: vpnStatusKey) + return completionHandler(error) + } func process() { self.packetFlow.readPackets { packets, protocols in @@ -47,11 +68,14 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } } process() + sharedDefaults?.set(TunnelStatus.connected.rawValue, forKey: vpnStatusKey) completionHandler(nil) } } override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) { + let sharedDefaults = UserDefaults(suiteName: appGroupID) + sharedDefaults?.set(TunnelStatus.disconnected.rawValue, forKey: vpnStatusKey) completionHandler() } }