diff --git a/Packages/App/Sources/CommonLibrary/CommonLibrary.swift b/Packages/App/Sources/CommonLibrary/CommonLibrary.swift
index d9cfefd61..a7bcb69c4 100644
--- a/Packages/App/Sources/CommonLibrary/CommonLibrary.swift
+++ b/Packages/App/Sources/CommonLibrary/CommonLibrary.swift
@@ -74,8 +74,8 @@ private extension CommonLibrary {
func configureShared() {
UserDefaults.appGroup.register(defaults: [
- AppPreference.logsPrivateData.key: false,
- AppPreference.dnsFallsBack.key: true
+ AppPreference.dnsFallsBack.key: true,
+ AppPreference.logsPrivateData.key: false
])
}
}
diff --git a/Packages/App/Sources/UILibrary/Domain/SystemAppearance.swift b/Packages/App/Sources/UILibrary/Domain/SystemAppearance.swift
new file mode 100644
index 000000000..f139450d2
--- /dev/null
+++ b/Packages/App/Sources/UILibrary/Domain/SystemAppearance.swift
@@ -0,0 +1,43 @@
+//
+// SystemAppearance.swift
+// Passepartout
+//
+// Created by Davide De Rosa on 2/17/25.
+// Copyright (c) 2025 Davide De Rosa. All rights reserved.
+//
+// https://github.com/passepartoutvpn
+//
+// This file is part of Passepartout.
+//
+// Passepartout is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Passepartout is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Passepartout. If not, see .
+//
+
+import Foundation
+import SwiftUI
+
+public enum SystemAppearance: String, RawRepresentable {
+ case light
+
+ case dark
+}
+
+extension Optional where Wrapped == SystemAppearance {
+ public var colorScheme: ColorScheme? {
+ switch self {
+ case .none: return nil
+ case .light: return .light
+ case .dark: return .dark
+ }
+ }
+}
diff --git a/Packages/App/Sources/UILibrary/Domain/UIPreference.swift b/Packages/App/Sources/UILibrary/Domain/UIPreference.swift
index 735248be4..7b0d6503e 100644
--- a/Packages/App/Sources/UILibrary/Domain/UIPreference.swift
+++ b/Packages/App/Sources/UILibrary/Domain/UIPreference.swift
@@ -39,6 +39,8 @@ public enum UIPreference: String, PreferenceProtocol {
case profilesLayout
+ case systemAppearance
+
public var key: String {
"UI.\(rawValue)"
}
diff --git a/Packages/App/Sources/UILibrary/L10n/SwiftGen+Strings.swift b/Packages/App/Sources/UILibrary/L10n/SwiftGen+Strings.swift
index 255b4834e..97ad3521c 100644
--- a/Packages/App/Sources/UILibrary/L10n/SwiftGen+Strings.swift
+++ b/Packages/App/Sources/UILibrary/L10n/SwiftGen+Strings.swift
@@ -93,6 +93,16 @@ public enum Strings {
/// Inactive
public static let inactive = Strings.tr("Localizable", "entities.tunnel_status.inactive", fallback: "Inactive")
}
+ public enum Ui {
+ public enum SystemAppearance {
+ /// Dark
+ public static let dark = Strings.tr("Localizable", "entities.ui.system_appearance.dark", fallback: "Dark")
+ /// Light
+ public static let light = Strings.tr("Localizable", "entities.ui.system_appearance.light", fallback: "Light")
+ /// System
+ public static let system = Strings.tr("Localizable", "entities.ui.system_appearance.system", fallback: "System")
+ }
+ }
}
public enum Errors {
public enum App {
@@ -844,6 +854,8 @@ public enum Strings {
public static let locksInBackground = Strings.tr("Localizable", "views.preferences.locks_in_background", fallback: "Lock in background")
/// Pin active profile
public static let pinsActiveProfile = Strings.tr("Localizable", "views.preferences.pins_active_profile", fallback: "Pin active profile")
+ /// Appearance
+ public static let systemAppearance = Strings.tr("Localizable", "views.preferences.system_appearance", fallback: "Appearance")
public enum DnsFallsBack {
/// Fall back to CloudFlare servers when the VPN does not provide DNS settings.
public static let footer = Strings.tr("Localizable", "views.preferences.dns_falls_back.footer", fallback: "Fall back to CloudFlare servers when the VPN does not provide DNS settings.")
diff --git a/Packages/App/Sources/UILibrary/L10n/UILibrary+L10n.swift b/Packages/App/Sources/UILibrary/L10n/UILibrary+L10n.swift
new file mode 100644
index 000000000..c5e56557e
--- /dev/null
+++ b/Packages/App/Sources/UILibrary/L10n/UILibrary+L10n.swift
@@ -0,0 +1,38 @@
+//
+// UILibrary+L10n.swift
+// Passepartout
+//
+// Created by Davide De Rosa on 2/17/25.
+// Copyright (c) 2025 Davide De Rosa. All rights reserved.
+//
+// https://github.com/passepartoutvpn
+//
+// This file is part of Passepartout.
+//
+// Passepartout is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Passepartout is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Passepartout. If not, see .
+//
+
+import CommonUtils
+import Foundation
+
+extension Optional: LocalizableEntity where Wrapped == SystemAppearance {
+ public var localizedDescription: String {
+ let V = Strings.Entities.Ui.SystemAppearance.self
+ switch self {
+ case .none: return V.system
+ case .light: return V.light
+ case .dark: return V.dark
+ }
+ }
+}
diff --git a/Packages/App/Sources/UILibrary/Resources/en.lproj/Localizable.strings b/Packages/App/Sources/UILibrary/Resources/en.lproj/Localizable.strings
index 4340878c3..e399386cd 100644
--- a/Packages/App/Sources/UILibrary/Resources/en.lproj/Localizable.strings
+++ b/Packages/App/Sources/UILibrary/Resources/en.lproj/Localizable.strings
@@ -98,6 +98,7 @@
"views.paywall.alerts.restricted.message" = "Some features are unavailable in this build.";
"views.paywall.alerts.pending.message" = "The purchase is pending external confirmation. The feature will be credited upon approval.";
+"views.preferences.system_appearance" = "Appearance";
"views.preferences.launches_on_login" = "Launch on login";
"views.preferences.launches_on_login.footer" = "Open the app in background after login.";
"views.preferences.keeps_in_menu" = "Keep in menu bar";
@@ -235,6 +236,12 @@
"entities.openvpn.otp_method.append" = "Append";
"entities.openvpn.otp_method.encode" = "Encode";
+// MARK: Entities (Library)
+
+"entities.ui.system_appearance.system" = "System";
+"entities.ui.system_appearance.light" = "Light";
+"entities.ui.system_appearance.dark" = "Dark";
+
// MARK: - Features
"features.appletv" = "%@";
diff --git a/Packages/App/Sources/UILibrary/Theme/UI/Theme+Modifiers.swift b/Packages/App/Sources/UILibrary/Theme/UI/Theme+Modifiers.swift
index e7503d621..5d51d765c 100644
--- a/Packages/App/Sources/UILibrary/Theme/UI/Theme+Modifiers.swift
+++ b/Packages/App/Sources/UILibrary/Theme/UI/Theme+Modifiers.swift
@@ -65,6 +65,10 @@ public enum ThemeModalSize: Hashable {
}
extension View {
+ public func themeAppearance(systemScheme: ColorScheme) -> some View {
+ modifier(ThemeAppearanceModifier(systemScheme: systemScheme))
+ }
+
public func themeModal(
isPresented: Binding,
options: ThemeModalOptions? = nil,
@@ -335,6 +339,12 @@ struct ThemeBooleanModalModifier: ViewModifier where Modal: View {
@EnvironmentObject
private var theme: Theme
+ @Environment(\.colorScheme)
+ private var colorScheme
+
+ @AppStorage(UIPreference.systemAppearance.key)
+ private var systemAppearance: SystemAppearance?
+
@Binding
var isPresented: Bool
@@ -358,6 +368,7 @@ struct ThemeBooleanModalModifier: ViewModifier where Modal: View {
#endif
.interactiveDismissDisabled(!options.isInteractive)
.themeLockScreen()
+ .themeAppearance(systemScheme: colorScheme)
}
}
}
@@ -367,6 +378,12 @@ struct ThemeItemModalModifier: ViewModifier where Modal: View, T: Iden
@EnvironmentObject
private var theme: Theme
+ @Environment(\.colorScheme)
+ private var colorScheme
+
+ @AppStorage(UIPreference.systemAppearance.key)
+ private var systemAppearance: SystemAppearance?
+
@Binding
var item: T?
@@ -390,6 +407,7 @@ struct ThemeItemModalModifier: ViewModifier where Modal: View, T: Iden
#endif
.interactiveDismissDisabled(!options.isInteractive)
.themeLockScreen()
+ .themeAppearance(systemScheme: colorScheme)
}
}
}
@@ -454,6 +472,19 @@ struct ThemeNavigationStackModifier: ViewModifier {
// MARK: - Content modifiers
+struct ThemeAppearanceModifier: ViewModifier {
+
+ @AppStorage(UIPreference.systemAppearance.key)
+ private var systemAppearance: SystemAppearance?
+
+ let systemScheme: ColorScheme
+
+ func body(content: Content) -> some View {
+ content
+ .preferredColorScheme(systemAppearance.colorScheme ?? systemScheme)
+ }
+}
+
struct ThemeManualInputModifier: ViewModifier {
}
diff --git a/Packages/App/Sources/UILibrary/Views/Preferences/PreferencesGroup.swift b/Packages/App/Sources/UILibrary/Views/Preferences/PreferencesGroup.swift
index a1982f0e1..be18b5799 100644
--- a/Packages/App/Sources/UILibrary/Views/Preferences/PreferencesGroup.swift
+++ b/Packages/App/Sources/UILibrary/Views/Preferences/PreferencesGroup.swift
@@ -32,6 +32,9 @@ import SwiftUI
public struct PreferencesGroup: View {
+ @AppStorage(UIPreference.systemAppearance.key)
+ private var systemAppearance: SystemAppearance?
+
#if os(iOS)
@AppStorage(UIPreference.locksInBackground.key)
private var locksInBackground = false
@@ -56,6 +59,7 @@ public struct PreferencesGroup: View {
}
public var body: some View {
+ systemAppearancePicker
#if os(iOS)
lockInBackgroundToggle
#elseif os(macOS)
@@ -69,6 +73,20 @@ public struct PreferencesGroup: View {
}
private extension PreferencesGroup {
+ static let systemAppearances: [SystemAppearance?] = [
+ nil,
+ .light,
+ .dark
+ ]
+
+ var systemAppearancePicker: some View {
+ Picker(Strings.Views.Preferences.systemAppearance, selection: $systemAppearance) {
+ ForEach(Self.systemAppearances, id: \.self) {
+ Text($0.localizedDescription)
+ }
+ }
+ }
+
#if os(iOS)
var lockInBackgroundToggle: some View {
Toggle(Strings.Views.Preferences.locksInBackground, isOn: $locksInBackground)
diff --git a/Passepartout/App/PassepartoutApp.swift b/Passepartout/App/PassepartoutApp.swift
index 0d28ed53d..8da8d42fd 100644
--- a/Passepartout/App/PassepartoutApp.swift
+++ b/Passepartout/App/PassepartoutApp.swift
@@ -36,6 +36,9 @@ import SwiftUI
@main
struct PassepartoutApp: App {
+ @Environment(\.colorScheme)
+ var colorScheme
+
#if os(iOS) || os(tvOS)
@UIApplicationDelegateAdaptor
diff --git a/Passepartout/App/Platforms/App+iOS.swift b/Passepartout/App/Platforms/App+iOS.swift
index 30abbad3f..f881b0799 100644
--- a/Passepartout/App/Platforms/App+iOS.swift
+++ b/Passepartout/App/Platforms/App+iOS.swift
@@ -52,6 +52,7 @@ extension PassepartoutApp {
.withEnvironment(from: context, theme: theme)
.environment(\.isUITesting, AppCommandLine.contains(.uiTesting))
.tint(.accentColor)
+ .themeAppearance(systemScheme: colorScheme)
}
}
}
diff --git a/Passepartout/App/Platforms/App+macOS.swift b/Passepartout/App/Platforms/App+macOS.swift
index 4481a057a..7ca82de42 100644
--- a/Passepartout/App/Platforms/App+macOS.swift
+++ b/Passepartout/App/Platforms/App+macOS.swift
@@ -64,6 +64,7 @@ extension PassepartoutApp {
.withEnvironment(from: context, theme: theme)
.environment(\.isUITesting, AppCommandLine.contains(.uiTesting))
.frame(minWidth: 600, minHeight: 400)
+ .themeAppearance(systemScheme: colorScheme)
}
.defaultSize(width: 600, height: 400)
@@ -72,6 +73,7 @@ extension PassepartoutApp {
.withEnvironment(from: context, theme: theme)
.environmentObject(settings)
.environment(\.isUITesting, AppCommandLine.contains(.uiTesting))
+ .themeAppearance(systemScheme: colorScheme)
}
.defaultSize(width: 500, height: 400)