Skip to content

Commit 65457b9

Browse files
committed
Add elevation
1 parent fb5ed11 commit 65457b9

File tree

9 files changed

+572
-2
lines changed

9 files changed

+572
-2
lines changed
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import LocalAuthentication
2+
import Foundation
3+
import SwiftUI
4+
import Combine
5+
6+
func authenticateWithTouchIDOrPassword(completion: @escaping (Bool) -> Void) {
7+
let context = LAContext()
8+
var error: NSError?
9+
10+
if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) {
11+
// Try Touch ID/Face ID
12+
context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: "Authenticate to elevate privileges") { success, authError in
13+
if success {
14+
// Authentication successful
15+
DispatchQueue.main.async {
16+
completion(true)
17+
}
18+
} else {
19+
// Fallback to password
20+
authenticateWithPassword(completion: completion)
21+
}
22+
}
23+
} else {
24+
// Biometrics unavailable, fallback to password
25+
authenticateWithPassword(completion: completion)
26+
}
27+
}
28+
29+
func authenticateWithPassword(completion: @escaping (Bool) -> Void) {
30+
let context = LAContext()
31+
var error: NSError?
32+
33+
// Check if deviceOwnerAuthentication (password fallback) is available
34+
if context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) {
35+
context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: "Authenticate to elevate privileges") { success, authError in
36+
DispatchQueue.main.async {
37+
if success {
38+
// Authentication successful
39+
completion(true)
40+
} else {
41+
// Authentication failed
42+
completion(false)
43+
}
44+
}
45+
}
46+
} else {
47+
// Device owner authentication not available
48+
DispatchQueue.main.async {
49+
completion(false)
50+
}
51+
}
52+
}
53+
54+
func saveReasonToDisk(reason: String) {
55+
let fileManager = FileManager.default
56+
let appState = AppStateManager.shared
57+
58+
// Get the Application Support directory
59+
guard let appSupportDirectory = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
60+
Logger.shared.logError("Failed to locate Application Support directory.")
61+
return
62+
}
63+
64+
// Create a subdirectory for your app if needed
65+
let appDirectory = appSupportDirectory.appendingPathComponent("SupportCompanion")
66+
67+
do {
68+
// Ensure the directory exists
69+
if !fileManager.fileExists(atPath: appDirectory.path) {
70+
try fileManager.createDirectory(at: appDirectory, withIntermediateDirectories: true, attributes: nil)
71+
}
72+
} catch {
73+
Logger.shared.logError("Error creating app directory: \(error.localizedDescription)")
74+
return
75+
}
76+
77+
// Define the file URL
78+
let fileURL = appDirectory.appendingPathComponent("ElevationReasons.json")
79+
80+
// Debug: Log the file URL
81+
Logger.shared.logDebug("Saving reason to: \(fileURL.path)")
82+
83+
// Get the current date
84+
let dateFormatter = ISO8601DateFormatter()
85+
let currentDate = dateFormatter.string(from: Date())
86+
87+
// Create a dictionary to save
88+
let entry: [String: Any] = [
89+
"reason": reason,
90+
"date": currentDate,
91+
"user": NSUserName(),
92+
"host": Host.current().localizedName ?? "Unknown",
93+
"serial": appState.deviceInfoManager.deviceInfo?.serialNumber ?? "Unknown",
94+
"severity": appState.preferences.elevationSeverity
95+
]
96+
97+
var existingEntries: [[String: Any]] = []
98+
99+
// Read existing entries if the file exists
100+
if let data = try? Data(contentsOf: fileURL),
101+
let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [[String: String]] {
102+
existingEntries = json
103+
}
104+
105+
// Add the new entry
106+
existingEntries.append(entry)
107+
108+
// Save back to disk
109+
do {
110+
let data = try JSONSerialization.data(withJSONObject: existingEntries, options: [.prettyPrinted])
111+
try data.write(to: fileURL, options: .atomic) // Atomic ensures safe writes
112+
Logger.shared.logDebug("Reason saved successfully.")
113+
} catch {
114+
Logger.shared.logError("Error saving reason: \(error.localizedDescription)")
115+
}
116+
}
117+
118+
func sendReasonToWebhook(reason: String) {
119+
let dateFormatter = ISO8601DateFormatter()
120+
let appState = AppStateManager.shared
121+
122+
// Define the webhook URL
123+
let webhookURL = URL(string: appState.preferences.elevationWebhookURL)!
124+
125+
// Create a dictionary with the reason
126+
let payload: [String: Any] = [
127+
"reason": reason,
128+
"date": dateFormatter.string(from: Date()),
129+
"user": NSUserName(),
130+
"host": Host.current().localizedName ?? "Unknown",
131+
"serial": appState.deviceInfoManager.deviceInfo?.serialNumber ?? "Unknown",
132+
"severity":appState.preferences.elevationSeverity
133+
]
134+
135+
// Serialize the dictionary to JSON
136+
guard let jsonData = try? JSONSerialization.data(withJSONObject: payload) else {
137+
print("Failed to serialize JSON.")
138+
return
139+
}
140+
141+
// Create a POST request
142+
var request = URLRequest(url: webhookURL)
143+
request.httpMethod = "POST"
144+
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
145+
request.httpBody = jsonData
146+
147+
// Create a URLSession task
148+
let task = URLSession.shared.dataTask(with: request) { data, response, error in
149+
if let error = error {
150+
saveReasonToDisk(reason: reason)
151+
Logger.shared.logError("Failed to send reason to webhook: \(error.localizedDescription)")
152+
return
153+
}
154+
155+
if let response = response as? HTTPURLResponse {
156+
if response.statusCode == 200 || response.statusCode == 202 {
157+
print("Reason sent to webhook successfully.")
158+
} else {
159+
// Fallback to save to disk if webhook fails
160+
saveReasonToDisk(reason: reason)
161+
Logger.shared.logError("Failed to send reason to webhook. Status code: \(response.statusCode)")
162+
}
163+
}
164+
}
165+
166+
// Start the task
167+
task.resume()
168+
}

SupportCompanion/ViewModels/AppStateManager.swift

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ class AppStateManager: ObservableObject {
1616
lazy var applicationsInfoManager = ApplicationsInfoManager(appState: self)
1717
lazy var pendingIntuneUpdatesManager = PendingIntuneUpdatesManager(appState: self)
1818
lazy var evergreenInfoManager = EvergreenInfoManager(appState: self)
19-
19+
lazy var elevationManager = ElevationManager(appState: self)
2020
@Published var isRefreshing: Bool = false
2121
@Published var deviceInfoManager = DeviceInfoManager.shared
2222
@Published var storageInfoManager = StorageInfoManager.shared
@@ -36,8 +36,10 @@ class AppStateManager: ObservableObject {
3636
@Published var storageUsageColor: Color = Color(NSColor.controlAccentColor)
3737
@Published var JsonCards: [JsonCard] = []
3838
@Published var catalogs: [String] = []
39+
@Published var isDemotionActive: Bool = false
40+
@Published var timeToDemote: TimeInterval = 0
3941

40-
private var cancellables = Set<AnyCancellable>()
42+
private var cancellables: Set<AnyCancellable> = Set<AnyCancellable>()
4143
var showWindowCallback: (() -> Void)?
4244

4345
func startBackgroundTasks() {
@@ -92,6 +94,16 @@ class AppStateManager: ObservableObject {
9294
}
9395
.store(in: &cancellables)
9496
}
97+
98+
func startDemotionTimer(duration: TimeInterval) {
99+
elevationManager.startDemotionTimer(duration: duration) { [weak self] remainingTime in
100+
DispatchQueue.main.async {
101+
guard let self = self else { return }
102+
self.timeToDemote = remainingTime
103+
self.isDemotionActive = remainingTime > 0
104+
}
105+
}
106+
}
95107

96108
@MainActor
97109
func refreshAll() {
@@ -103,6 +115,7 @@ class AppStateManager: ObservableObject {
103115
group.addTask { self.mdmInfoManager.refresh() }
104116
group.addTask { self.systemUpdatesManager.refresh() }
105117
group.addTask { self.batteryInfoManager.refresh() }
118+
group.addTask { self.userInfoManager.refresh() }
106119
}
107120
self.isRefreshing = false
108121
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import Foundation
2+
import Combine
3+
import SwiftUI
4+
5+
class ElevationManager {
6+
@State private var elevationReason = ""
7+
private var appState: AppStateManager
8+
private var cancellable: AnyCancellable?
9+
private var timerPublisher: AnyPublisher<Date, Never>?
10+
private var onTimeUpdate: ((Double) -> Void)?
11+
12+
static let shared = ElevationManager(appState: AppStateManager.shared)
13+
14+
init(appState: AppStateManager) {
15+
self.appState = AppStateManager.shared
16+
}
17+
18+
func elevatePrivileges(completion: @escaping (Bool) -> Void) {
19+
authenticateWithTouchIDOrPassword { success in
20+
guard success else {
21+
completion(false)
22+
return
23+
}
24+
let command = "/usr/sbin/dseditgroup"
25+
let arguments = ["-o", "edit", "-a", NSUserName(), "-t", "user", "admin"]
26+
Task {
27+
do {
28+
_ = try await ExecutionService.executeCommandPrivileged(command, arguments: arguments)
29+
// Update isAdmin status
30+
UserInfoManager.shared.updateUserInfo()
31+
completion(true)
32+
}
33+
catch {
34+
Logger.shared.logError("Failed to elevate privileges: \(error.localizedDescription)")
35+
completion(false)
36+
}
37+
}
38+
}
39+
}
40+
41+
func demotePrivileges(completion: @escaping (Bool) -> Void) {
42+
let command = "/usr/sbin/dseditgroup"
43+
let arguments = ["-o", "edit", "-d", NSUserName(), "-t", "user", "admin"]
44+
Task {
45+
do {
46+
_ = try await ExecutionService.executeCommandPrivileged(command, arguments: arguments)
47+
// Update isAdmin status
48+
UserInfoManager.shared.updateUserInfo()
49+
UserDefaults.standard.removeObject(forKey: "PrivilegeDemotionEndTime")
50+
completion(true)
51+
}
52+
catch {
53+
Logger.shared.logError("Failed to demote privileges: \(error.localizedDescription)")
54+
completion(false)
55+
}
56+
}
57+
}
58+
59+
func startDemotionTimer(duration: TimeInterval, onUpdate: @escaping (Double) -> Void) {
60+
Logger.shared.logDebug("Starting demotion timer with duration: \(duration)")
61+
stopDemotionTimer() // Ensure any existing timer is stopped
62+
63+
persistDemotionState(endTime: Date().addingTimeInterval(duration))
64+
65+
NotificationService(appState: self.appState).sendNotification(
66+
message: "Privliged session started. You will be demoted in \(duration.formattedTimeUnit()).",
67+
notificationType: .generic
68+
)
69+
70+
var remainingTime = duration
71+
self.onTimeUpdate = onUpdate
72+
73+
// Create a Combine Timer Publisher
74+
timerPublisher = Timer.publish(every: 1, on: .main, in: .common)
75+
.autoconnect()
76+
.eraseToAnyPublisher()
77+
78+
// Subscribe to timer updates
79+
cancellable = timerPublisher?.sink { [weak self] _ in
80+
guard let self = self else { return }
81+
82+
if remainingTime > 0 {
83+
remainingTime -= 1
84+
var timeToDemote = appState.timeToDemote
85+
timeToDemote -= 1
86+
// If half the time has passed, notify the user
87+
if remainingTime == duration / 2 {
88+
NotificationService(appState: self.appState).sendNotification(
89+
message: "Your elevated privileges will be demoted in \(timeToDemote.formattedTimeUnit()).",
90+
notificationType: .generic
91+
)
92+
}
93+
self.onTimeUpdate?(remainingTime)
94+
} else {
95+
self.stopDemotionTimer()
96+
self.demotePrivileges { success in
97+
guard success else {
98+
Logger.shared.logDebug("Failed to demote privileges.")
99+
return
100+
}
101+
NotificationService(appState: self.appState).sendNotification(
102+
message: "Your elevated privileges have been demoted.",
103+
notificationType: .generic
104+
)
105+
}
106+
Logger.shared.logDebug("Demotion timer expired. Privileges demoted.")
107+
}
108+
}
109+
}
110+
111+
/// Stops the timer
112+
func stopDemotionTimer() {
113+
cancellable?.cancel()
114+
cancellable = nil
115+
onTimeUpdate?(0) // Notify remaining time is 0
116+
}
117+
118+
func handleElevation(reason: String) {
119+
Logger.shared.logDebug("Handling elevation for reason: \(reason)")
120+
// Authenticate and elevate privileges
121+
self.elevatePrivileges { success in
122+
guard success else {
123+
Logger.shared.logDebug("Authentication failed. Unable to elevate privileges.")
124+
return
125+
}
126+
Logger.shared.logDebug("Authentication successful. Privileges elevated.")
127+
if self.appState.preferences.requireReasonForElevation {
128+
if !self.appState.preferences.elevationWebhookURL.isEmpty {
129+
sendReasonToWebhook(reason: reason)
130+
} else {
131+
saveReasonToDisk(reason: reason)
132+
}
133+
}
134+
// Start the timer
135+
self.appState.startDemotionTimer(duration: self.appState.preferences.maxElevationTime)
136+
}
137+
}
138+
139+
func persistDemotionState(endTime: Date) {
140+
UserDefaults.standard.set(endTime, forKey: "PrivilegeDemotionEndTime")
141+
UserDefaults.standard.synchronize()
142+
}
143+
144+
func loadPersistedDemotionState() -> Date? {
145+
return UserDefaults.standard.object(forKey: "PrivilegeDemotionEndTime") as? Date
146+
}
147+
}

0 commit comments

Comments
 (0)