diff --git a/app/Mole/Sources/Mole/AppListView.swift b/app/Mole/Sources/Mole/AppListView.swift index 227ba5f..102566b 100644 --- a/app/Mole/Sources/Mole/AppListView.swift +++ b/app/Mole/Sources/Mole/AppListView.swift @@ -19,6 +19,7 @@ struct AppListView: View { .font(.title2) } .buttonStyle(.plain) + .keyboardShortcut(.escape, modifiers: []) } .padding() .background(Color.black.opacity(0.8)) @@ -52,12 +53,12 @@ struct AppListView: View { Spacer() Button(action: { onSelect(app) }) { - Text("UNINSTALL") + Text("Uninstall") .font(.system(size: 10, weight: .bold)) .foregroundStyle(.white) .padding(.horizontal, 10) .padding(.vertical, 6) - .background(Capsule().fill(Color.blue.opacity(0.8))) + .background(Capsule().fill(Color(red: 1.0, green: 0.3, blue: 0.1).opacity(0.8))) } .buttonStyle(.plain) } @@ -67,7 +68,8 @@ struct AppListView: View { .padding(.horizontal) } } - .padding(.vertical) + .padding(.top) + .padding(.bottom, 40) } } .background(Color.black.opacity(0.95)) diff --git a/app/Mole/Sources/Mole/AuthContext.swift b/app/Mole/Sources/Mole/AuthContext.swift new file mode 100644 index 0000000..f1dc9c1 --- /dev/null +++ b/app/Mole/Sources/Mole/AuthContext.swift @@ -0,0 +1,28 @@ +import Combine +import Foundation + +class AuthContext: ObservableObject { + static let shared = AuthContext() + @Published var needsPassword = false + private(set) var password: String? + + init() { + // Auto-login from Keychain + if let saved = KeychainHelper.shared.load() { + self.password = saved + } + } + + func setPassword(_ pass: String) { + self.password = pass + self.needsPassword = false + // Persist + KeychainHelper.shared.save(pass) + } + + func clear() { + self.password = nil + // Remove persistence + KeychainHelper.shared.delete() + } +} diff --git a/app/Mole/Sources/Mole/ContentView.swift b/app/Mole/Sources/Mole/ContentView.swift index bee30a8..4089f0b 100644 --- a/app/Mole/Sources/Mole/ContentView.swift +++ b/app/Mole/Sources/Mole/ContentView.swift @@ -14,12 +14,16 @@ struct ContentView: View { @StateObject private var scanner = ScannerService() @StateObject private var uninstaller = UninstallerService() @StateObject private var optimizer = OptimizerService() + @ObservedObject var authContext = AuthContext.shared - // The requested coffee/dark brown color (Cleaner) - let deepBrown = Color(red: 0.17, green: 0.11, blue: 0.05) // #2C1C0E + // Mercury (Cleaner) - Dark Industrial Gray + let mercuryColor = Color(red: 0.15, green: 0.15, blue: 0.18) - // Deep Blue for Uninstaller - let deepBlue = Color(red: 0.05, green: 0.1, blue: 0.2) + // Mars (Uninstaller) - Deep Red + let marsColor = Color(red: 0.25, green: 0.08, blue: 0.05) + + // Earth (Optimizer) - Deep Blue + let earthColor = Color(red: 0.05, green: 0.1, blue: 0.25) var body: some View { ZStack { @@ -27,7 +31,10 @@ struct ContentView: View { Color.black.ignoresSafeArea() RadialGradient( - gradient: Gradient(colors: [appMode == .cleaner ? deepBrown : deepBlue, .black]), + gradient: Gradient(colors: [ + appMode == .cleaner ? mercuryColor : (appMode == .uninstaller ? marsColor : earthColor), + .black, + ]), center: .center, startRadius: 0, endRadius: 600 @@ -37,96 +44,10 @@ struct ContentView: View { // Custom Top Tab Bar VStack { - HStack(spacing: 0) { - // Cleaner Tab - Button(action: { - withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { - appMode = .cleaner - } - }) { - Text("Cleaner") - .font(.system(size: 12, weight: .bold, design: .monospaced)) - .foregroundStyle(appMode == .cleaner ? .black : .white.opacity(0.6)) - .padding(.vertical, 8) - .padding(.horizontal, 16) - .background( - ZStack { - if appMode == .cleaner { - Capsule() - .fill(Color.white) - .matchedGeometryEffect(id: "TabHighlight", in: animationNamespace) - } - } - ) - } - .buttonStyle(.plain) - .onHover { inside in - if inside { NSCursor.pointingHand.set() } else { NSCursor.arrow.set() } - } - - // Uninstaller Tab - Button(action: { - withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { - appMode = .uninstaller - } - }) { - Text("Uninstaller") - .font(.system(size: 12, weight: .bold, design: .monospaced)) - .foregroundStyle(appMode == .uninstaller ? .black : .white.opacity(0.6)) - .padding(.vertical, 8) - .padding(.horizontal, 16) - .background( - ZStack { - if appMode == .uninstaller { - Capsule() - .fill(Color.white) - .matchedGeometryEffect(id: "TabHighlight", in: animationNamespace) - } - } - ) - } - .buttonStyle(.plain) - .onHover { inside in - if inside { NSCursor.pointingHand.set() } else { NSCursor.arrow.set() } - } - - // Optimizer Tab - Button(action: { - withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { - appMode = .optimizer - } - }) { - Text("Optimizer") - .font(.system(size: 12, weight: .bold, design: .monospaced)) - .foregroundStyle(appMode == .optimizer ? .black : .white.opacity(0.6)) - .padding(.vertical, 8) - .padding(.horizontal, 16) - .background( - ZStack { - if appMode == .optimizer { - Capsule() - .fill(Color.white) - .matchedGeometryEffect(id: "TabHighlight", in: animationNamespace) - } - } - ) - } - .buttonStyle(.plain) - .onHover { inside in - if inside { NSCursor.pointingHand.set() } else { NSCursor.arrow.set() } - } - } - .padding(4) - .background( - Capsule() - .fill(.ultraThinMaterial) - .opacity(0.3) + TopBarView( + appMode: $appMode, animationNamespace: animationNamespace, authContext: authContext ) - .overlay( - Capsule() - .strokeBorder(Color.white.opacity(0.2), lineWidth: 1) - ) - .padding(.top, 20) // Spacing from top + .padding(.top, 20) Spacer() } @@ -161,6 +82,9 @@ struct ContentView: View { } .opacity(showAppList ? 0.0 : 1.0) // Hide when list is open .animation(.easeInOut, value: showAppList) + .padding(.top, 75) // Visual centering adjustment + + Spacer() // Dynamic spacing // Status Area ZStack { @@ -184,11 +108,7 @@ struct ContentView: View { // Action Button Button(action: { if appMode == .cleaner { - if scanner.scanFinished { - startCleaning() - } else { - startScanning() - } + startSmartClean() } else if appMode == .uninstaller { handleUninstallerAction() } else { @@ -204,7 +124,7 @@ struct ContentView: View { .tint(.black) } - Text(actionButtonLabel) + Text("Mole") .font(.system(size: 14, weight: .bold, design: .monospaced)) } .frame(minWidth: 140) @@ -225,8 +145,7 @@ struct ContentView: View { } } .frame(height: 100) - - Spacer() + .padding(.bottom, 30) // Anchor to bottom } // App List Overlay @@ -247,7 +166,6 @@ struct ContentView: View { if showCelebration { VStack(spacing: 8) { - Spacer() ConfettiView(colors: celebrationColors) .offset(y: -50) @@ -263,7 +181,7 @@ struct ContentView: View { .shadow(radius: 5) } } - .offset(y: -150) + .offset(y: 105) } .allowsHitTesting(false) .zIndex(100) @@ -298,41 +216,46 @@ struct ContentView: View { } } } + .sheet(isPresented: $authContext.needsPassword) { + PasswordSheetView(onUnlock: { + // Unlock success implies AuthContext.password is set. + // Services will use it on next attempt. + }) + } .onChange(of: appMode) { appState = .idle logs.removeAll() showAppList = false - } - } - - // MARK: - Computed Properties - - var actionButtonLabel: String { - if appMode == .cleaner { - return scanner.scanFinished ? "Clean" : "Check" - } else if appMode == .uninstaller { - return "Scan Apps" - } else { - return "Boost" + showCelebration = false + scanner.reset() + optimizer.reset() + uninstaller.reset() } } // MARK: - Actions - func startScanning() { + func startSmartClean() { withAnimation { appState = .scanning logs.removeAll() + showCelebration = false // Dismiss old success } Task { await scanner.startScan() - let sizeMB = Double(scanner.totalSize) / 1024.0 / 1024.0 - let sizeString = - sizeMB > 1024 ? String(format: "%.1f GB", sizeMB / 1024) : String(format: "%.0f MB", sizeMB) + try? await Task.sleep(nanoseconds: 500_000_000) - withAnimation { - appState = .results(size: sizeString) + await MainActor.run { + if scanner.totalSize > 0 { + startCleaning() + } else { + withAnimation { + appState = .idle + logs.removeAll() + } + triggerCelebration([.white], message: "Already Clean") + } } } } @@ -341,19 +264,29 @@ struct ContentView: View { withAnimation { appState = .cleaning logs.removeAll() + showCelebration = false } Task { - await scanner.cleanSystem() + let cleanedBytes = await scanner.cleanSystem() withAnimation { appState = .done } - triggerCelebration([.orange, .red, .yellow, .white], message: "System Cleaned") + + let mb = Double(cleanedBytes) / 1024.0 / 1024.0 + let msg = + mb > 1024 + ? String(format: "Cleaned %.1f GB", mb / 1024.0) : String(format: "Cleaned %.0f MB", mb) + + triggerCelebration([.orange, .red, .yellow, .white], message: msg) } } func handleUninstallerAction() { - withAnimation { showAppList = true } + withAnimation { + showAppList = true + showCelebration = false + } Task { await uninstaller.scanApps() } } @@ -361,6 +294,7 @@ struct ContentView: View { withAnimation { showAppList = false logs.removeAll() + showCelebration = false } Task { @@ -372,6 +306,7 @@ struct ContentView: View { } func handleOptimizerAction() { + showCelebration = false // Immediate dismiss Task { await optimizer.optimize() triggerCelebration([.cyan, .blue, .purple, .mint, .white], message: "Optimized") @@ -387,3 +322,62 @@ struct ContentView: View { } } } + +struct TopBarView: View { + @Binding var appMode: AppMode + var animationNamespace: Namespace.ID + @ObservedObject var authContext: AuthContext + + var body: some View { + ZStack { + HStack(spacing: 0) { + TabBarButton(mode: .cleaner, appMode: $appMode, namespace: animationNamespace) + TabBarButton(mode: .uninstaller, appMode: $appMode, namespace: animationNamespace) + TabBarButton(mode: .optimizer, appMode: $appMode, namespace: animationNamespace) + } + .padding(4) + .background(Capsule().fill(.ultraThinMaterial).opacity(0.3)) + } + } +} + +struct TabBarButton: View { + let mode: AppMode + @Binding var appMode: AppMode + var namespace: Namespace.ID + + var title: String { + switch mode { + case .cleaner: return "clean" + case .uninstaller: return "uninstall" + case .optimizer: return "optimize" + } + } + + var body: some View { + Button(action: { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + appMode = mode + } + }) { + Text(title) + .font(.system(size: 12, weight: .bold, design: .monospaced)) + .foregroundStyle(appMode == mode ? .black : .white.opacity(0.6)) + .padding(.vertical, 8) + .padding(.horizontal, 16) + .background( + ZStack { + if appMode == mode { + Capsule() + .fill(Color.white) + .matchedGeometryEffect(id: "TabHighlight", in: namespace) + } + } + ) + } + .buttonStyle(.plain) + .onHover { inside in + if inside { NSCursor.pointingHand.set() } else { NSCursor.arrow.set() } + } + } +} diff --git a/app/Mole/Sources/Mole/KeychainHelper.swift b/app/Mole/Sources/Mole/KeychainHelper.swift new file mode 100644 index 0000000..5dd3b7f --- /dev/null +++ b/app/Mole/Sources/Mole/KeychainHelper.swift @@ -0,0 +1,37 @@ +import Foundation + +// Note: Switched to File-based storage to avoid repetitive System Keychain prompts +// during development (Unsigned Binary). Files are set to 600 layout (User Only). +class KeychainHelper { + static let shared = KeychainHelper() + + private var storeURL: URL { + let home = FileManager.default.homeDirectoryForCurrentUser + return home.appendingPathComponent(".mole").appendingPathComponent(".key") + } + + func save(_ data: String) { + let url = storeURL + do { + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try data.write(to: url, atomically: true, encoding: .utf8) + // Set permissions to User Read/Write Only (600) for security + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + } catch { + print("Failed to save credentials: \(error)") + } + } + + func load() -> String? { + do { + return try String(contentsOf: storeURL, encoding: .utf8) + } catch { + return nil + } + } + + func delete() { + try? FileManager.default.removeItem(at: storeURL) + } +} diff --git a/app/Mole/Sources/Mole/LogView.swift b/app/Mole/Sources/Mole/LogView.swift index 1a2e295..f66fb48 100644 --- a/app/Mole/Sources/Mole/LogView.swift +++ b/app/Mole/Sources/Mole/LogView.swift @@ -1,36 +1,36 @@ import SwiftUI struct LogView: View { - let logs: [String] + let logs: [String] - var body: some View { - VStack(alignment: .leading, spacing: 4) { - ForEach(Array(logs.suffix(5).enumerated()), id: \.offset) { index, log in - Text(log) - .font(.system(size: 12, weight: .regular, design: .monospaced)) - .foregroundStyle(.white.opacity(opacity(for: index, count: logs.suffix(5).count))) - .frame(maxWidth: .infinity, alignment: .center) - .transition(.move(edge: .bottom).combined(with: .opacity)) - } - } - .frame(height: 100) - .mask( - LinearGradient( - gradient: Gradient(stops: [ - .init(color: .clear, location: 0), - .init(color: .black, location: 0.2), - .init(color: .black, location: 0.8), - .init(color: .clear, location: 1.0) - ]), - startPoint: .top, - endPoint: .bottom - ) - ) + var body: some View { + VStack(alignment: .center, spacing: 6) { + ForEach(Array(logs.suffix(3).enumerated()), id: \.offset) { index, log in + Text(log) + .font(.system(size: 12, weight: .regular, design: .monospaced)) + .foregroundStyle(.white.opacity(opacity(for: index, count: logs.suffix(3).count))) + .frame(maxWidth: .infinity, alignment: .center) + .transition(.opacity) + } } + .frame(height: 60) + .mask( + LinearGradient( + gradient: Gradient(stops: [ + .init(color: .clear, location: 0), + .init(color: .black, location: 0.3), + .init(color: .black, location: 1.0), + ]), + startPoint: .top, + endPoint: .bottom + ) + ) + .offset(y: -25) + } - func opacity(for index: Int, count: Int) -> Double { - // Newer items (higher index) are more opaque - let normalizedIndex = Double(index) / Double(max(count - 1, 1)) - return 0.3 + (normalizedIndex * 0.7) - } + func opacity(for index: Int, count: Int) -> Double { + // Newer items (higher index) are more opaque + let normalizedIndex = Double(index) / Double(max(count - 1, 1)) + return 0.3 + (normalizedIndex * 0.7) + } } diff --git a/app/Mole/Sources/Mole/MoleApp.swift b/app/Mole/Sources/Mole/MoleApp.swift index bce2002..4d561c1 100644 --- a/app/Mole/Sources/Mole/MoleApp.swift +++ b/app/Mole/Sources/Mole/MoleApp.swift @@ -1,7 +1,17 @@ import SwiftUI +class AppDelegate: NSObject, NSApplicationDelegate { + func applicationDidFinishLaunching(_ notification: Notification) { + NSApp.setActivationPolicy(.regular) + NSApp.activate(ignoringOtherApps: true) + NSApp.windows.first?.makeKeyAndOrderFront(nil) + } +} + @main struct MoleApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + var body: some Scene { WindowGroup("Mole") { ContentView() diff --git a/app/Mole/Sources/Mole/MoleSceneView.swift b/app/Mole/Sources/Mole/MoleSceneView.swift index 1ad772c..e32bd89 100644 --- a/app/Mole/Sources/Mole/MoleSceneView.swift +++ b/app/Mole/Sources/Mole/MoleSceneView.swift @@ -89,7 +89,7 @@ struct MoleSceneView: NSViewRepresentable { context.coordinator.parent = self guard let scene = scnView.scene, - let planet = scene.rootNode.childNode(withName: "molePlanet", recursively: false) + scene.rootNode.childNode(withName: "molePlanet", recursively: false) != nil else { return } // Only update if mode changed to prevent expensive texture reloads if context.coordinator.currentMode != appMode { @@ -105,13 +105,13 @@ struct MoleSceneView: NSViewRepresentable { switch appMode { case .cleaner: + textureName = "mercury" + constRoughness = 0.6 + rimColor = NSColor(calibratedRed: 0.8, green: 0.8, blue: 0.9, alpha: 1.0) + case .uninstaller: textureName = "mars" constRoughness = 0.9 - rimColor = NSColor(calibratedRed: 1.0, green: 0.5, blue: 0.3, alpha: 1.0) - case .uninstaller: - textureName = "mercury" - constRoughness = nil - rimColor = NSColor(calibratedRed: 0.9, green: 0.9, blue: 1.0, alpha: 1.0) + rimColor = NSColor(calibratedRed: 1.0, green: 0.3, blue: 0.1, alpha: 1.0) case .optimizer: textureName = "earth" constRoughness = 0.4 diff --git a/app/Mole/Sources/Mole/MoleView.swift b/app/Mole/Sources/Mole/MoleView.swift index 176015d..3f5c405 100644 --- a/app/Mole/Sources/Mole/MoleView.swift +++ b/app/Mole/Sources/Mole/MoleView.swift @@ -12,56 +12,64 @@ struct MoleView: View { // To enable "Drag to Spin", we pass gesture data to the representable. var body: some View { - ZStack { - // Background Atmosphere (2D Glow remains for performance/look) - Circle() - .fill( - RadialGradient( - gradient: Gradient(colors: [ - Color(hue: 0.6, saturation: 0.8, brightness: 0.6).opacity(0.3), - Color.purple.opacity(0.1), - .clear, - ]), - center: .center, - startRadius: 80, - endRadius: 180 + GeometryReader { proxy in + let minDim = min(proxy.size.width, proxy.size.height) + // Tiers: Small (Default) -> Medium -> Large + let planetSize: CGFloat = { + if minDim < 600 { return 320 } else if minDim < 900 { return 450 } else { return 580 } + }() + + ZStack { + // Background Atmosphere (2D Glow) + Circle() + .fill( + RadialGradient( + gradient: Gradient(colors: [ + Color(hue: 0.6, saturation: 0.8, brightness: 0.6).opacity(0.3), + Color.purple.opacity(0.1), + .clear, + ]), + center: .center, + startRadius: planetSize * 0.25, + endRadius: planetSize * 0.56 + ) ) + .frame(width: planetSize * 0.94, height: planetSize * 0.94) + .blur(radius: 20) + + // The 3D Scene + MoleSceneView( + state: $state, rotationVelocity: $dragVelocity, activeColor: appMode.themeColor, + appMode: appMode, + isRunning: isRunning ) - .frame(width: 300, height: 300) - .blur(radius: 20) - - // The 3D Scene - MoleSceneView( - state: $state, rotationVelocity: $dragVelocity, activeColor: appMode.themeColor, - appMode: appMode, - isRunning: isRunning - ) - .frame(width: 320, height: 320) // Slightly larger frame - .mask(Circle()) // Clip to circle to be safe - .contentShape(Circle()) // Ensure interaction only happens on the circle - .onHover { inside in - if inside { - NSCursor.openHand.set() - } else { - NSCursor.arrow.set() + .frame(width: planetSize, height: planetSize) + .mask(Circle()) + .contentShape(Circle()) // Ensure interaction only happens on the circle + .onHover { inside in + if inside { + NSCursor.openHand.set() + } else { + NSCursor.arrow.set() + } } + .gesture( + DragGesture() + .onChanged { gesture in + // Pass simplified velocity/delta for the Scene to rotate + dragVelocity = CGSize( + width: gesture.translation.width, height: gesture.translation.height) + NSCursor.closedHand.set() // Grabbing effect + } + .onEnded { _ in + dragVelocity = .zero // Resume auto-spin (handled in view) + NSCursor.openHand.set() // Release grab + } + ) } - .gesture( - DragGesture() - .onChanged { gesture in - // Pass simplified velocity/delta for the Scene to rotate - dragVelocity = CGSize( - width: gesture.translation.width, height: gesture.translation.height) - NSCursor.closedHand.set() // Grabbing effect - } - .onEnded { _ in - dragVelocity = .zero // Resume auto-spin (handled in view) - NSCursor.openHand.set() // Release grab - } - ) - + .scaleEffect(state == .cleaning ? 0.95 : 1.0) + .position(x: proxy.size.width / 2, y: proxy.size.height / 2) } - .scaleEffect(state == .cleaning ? 0.95 : 1.0) .animation(.spring, value: state) } } diff --git a/app/Mole/Sources/Mole/OptimizerService.swift b/app/Mole/Sources/Mole/OptimizerService.swift index 841c2e8..e646052 100644 --- a/app/Mole/Sources/Mole/OptimizerService.swift +++ b/app/Mole/Sources/Mole/OptimizerService.swift @@ -5,32 +5,102 @@ class OptimizerService: ObservableObject { @Published var statusMessage = "" @Published var currentLog = "" + func reset() { + self.currentLog = "" + self.statusMessage = "" + self.isOptimizing = false + } + func optimize() async { await MainActor.run { self.isOptimizing = true - self.statusMessage = "Initializing..." - self.currentLog = "Starting Optimizer Service..." + self.statusMessage = "Optimizing..." // Removed "Authenticating..." } - let steps = [ - "Analyzing Memory...", - "Compressing RAM...", - "Purging Inactive Memory...", - "Flushing DNS Cache...", - "Restarting mDNSResponder...", - "Optimizing Network...", - "Verifying System State...", - "Finalizing...", + // Helper for Session Auth + func runPrivileged(_ command: String) async throws { + if let pw = AuthContext.shared.password { + do { + _ = try await ShellRunner.shared.runSudo(command, password: pw) + return + } catch { + await MainActor.run { AuthContext.shared.clear() } + } + } + + // If no password or failed, prompt via Custom Sheet + await MainActor.run { + AuthContext.shared.needsPassword = true + self.statusMessage = "Waiting for Password..." + } + + // Abort execution until user authorizes + struct AuthRequired: Error, LocalizedError { + var errorDescription: String? { "Authorization Required" } + } + throw AuthRequired() + } + + let steps: [(String, () async throws -> Void)] = [ + ( + "Flushing DNS Cache...", + { + // Use full paths for robustness + let cmd = "/usr/bin/dscacheutil -flushcache; /usr/bin/killall -HUP mDNSResponder" + try await runPrivileged(cmd) + } + ), + ( + "Purging Inactive Memory...", + { + try await runPrivileged("/usr/sbin/purge") + } + ), + ( + "Rebuilding Launch Services...", + { + let lsregister = + "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister" + // Best effort + _ = try? await ShellRunner.shared.run( + lsregister, + arguments: ["-kill", "-r", "-domain", "local", "-domain", "system", "-domain", "user"]) + } + ), + ( + "Resetting QuickLook...", + { + _ = try? await ShellRunner.shared.run("/usr/bin/qlmanage", arguments: ["-r", "cache"]) + _ = try? await ShellRunner.shared.run("/usr/bin/qlmanage", arguments: ["-r"]) + } + ), + ( + "Restarting Finder...", + { + _ = try? await ShellRunner.shared.run("/usr/bin/killall", arguments: ["Finder"]) + } + ), ] - for step in steps { + for (desc, action) in steps { await MainActor.run { - self.statusMessage = step - self.currentLog = step + self.statusMessage = desc + self.currentLog = "Running: \(desc)" + } + + do { + try await action() + try? await Task.sleep(nanoseconds: 500_000_000) + } catch { + await MainActor.run { + // If user cancels osascript dialog, it throws error -128 + if error.localizedDescription.contains("User canceled") || "\(error)".contains("-128") { + self.currentLog = "Optimization Cancelled by User" + } else { + self.currentLog = "Error: \(error.localizedDescription)" + } + } } - // Moderate delay for readability (300ms - 800ms) - let delay = UInt64.random(in: 300_000_000...800_000_000) - try? await Task.sleep(nanoseconds: delay) } await MainActor.run { diff --git a/app/Mole/Sources/Mole/PasswordSheetView.swift b/app/Mole/Sources/Mole/PasswordSheetView.swift new file mode 100644 index 0000000..4faca0f --- /dev/null +++ b/app/Mole/Sources/Mole/PasswordSheetView.swift @@ -0,0 +1,201 @@ +import AppKit +import SwiftUI + +// MARK: - NSVisualEffectView bridge (Liquid Glass / blur) +struct VisualEffectBlur: NSViewRepresentable { + var material: NSVisualEffectView.Material = .hudWindow + var blendingMode: NSVisualEffectView.BlendingMode = .withinWindow + var state: NSVisualEffectView.State = .active + + func makeNSView(context: Context) -> NSVisualEffectView { + let v = NSVisualEffectView() + v.material = material + v.blendingMode = blendingMode + v.state = state + v.wantsLayer = true + return v + } + + func updateNSView(_ nsView: NSVisualEffectView, context: Context) { + nsView.material = material + nsView.blendingMode = blendingMode + nsView.state = state + } +} + +struct PasswordSheetView: View { + @State private var passwordInput = "" + @Environment(\.presentationMode) var presentationMode + var onUnlock: () -> Void + @FocusState private var isFocused: Bool + + private var accent: Color { Color.accentColor } + + var body: some View { + dialogCard + .frame(width: 280) + // Attempt to clear window background for true glass effect + .background(ClearBackgroundView()) + } + + private var dialogCard: some View { + VStack(alignment: .leading, spacing: 14) { + // Icon + ZStack(alignment: .bottomTrailing) { + Image(nsImage: NSApp.applicationIconImage) + .resizable() + .interpolation(.high) + .aspectRatio(contentMode: .fit) + .frame(width: 48, height: 48) + .shadow(radius: 2) + } + .padding(.leading, 6) + + // Title + message + VStack(alignment: .leading, spacing: 5) { + Text("Mole is trying to make changes.") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.primary) + + Text("Enter your password to allow this.") + .font(.system(size: 12, weight: .regular)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + // Fields + VStack(spacing: 12) { + // Username field + HStack { + Text(NSUserName()) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(.primary.opacity(0.85)) + Spacer() + } + .padding(.horizontal, 10) + .frame(height: 36) + .background(fieldBackground) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(.white.opacity(0.10), lineWidth: 1) + ) + + // Password field + ZStack(alignment: .leading) { + SecureField("", text: $passwordInput) + .textFieldStyle(.plain) + .font(.system(size: 14, weight: .medium)) + .focused($isFocused) + .padding(.horizontal, 10) + .frame(height: 36) + .onSubmit(submit) + + if passwordInput.isEmpty { + Text("Password") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(.primary.opacity(0.35)) + .padding(.leading, 10) + .allowsHitTesting(false) + } + } + .background(fieldBackground) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(.white.opacity(0.10), lineWidth: 1) + ) + } + .padding(.top, 2) + + // Buttons + HStack(spacing: 12) { + Button("Cancel") { + presentationMode.wrappedValue.dismiss() + } + .buttonStyle(.plain) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(.primary) + .frame(maxWidth: .infinity) + .frame(height: 32) + .background(buttonGlassBackground) + .clipShape(Capsule()) + .keyboardShortcut(.cancelAction) + + Button("Allow") { + submit() + } + .buttonStyle(.plain) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .frame(height: 32) + .background(Color(nsColor: .controlAccentColor)) + .clipShape(Capsule()) + .keyboardShortcut(.defaultAction) + .disabled(passwordInput.isEmpty) + } + .padding(.top, 4) + } + .padding(20) + .background(glassBackground) + .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .stroke(.white.opacity(0.18), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.22), radius: 30, x: 0, y: 18) + .onAppear { + isFocused = true + NSApp.activate(ignoringOtherApps: true) + } + } + + private var glassBackground: some View { + ZStack { + VisualEffectBlur(material: .hudWindow, blendingMode: .withinWindow, state: .active) + LinearGradient( + colors: [.white.opacity(0.55), .white.opacity(0.22), .black.opacity(0.08)], + startPoint: .top, endPoint: .bottom + ) + } + } + + private var fieldBackground: some View { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(.black.opacity(0.10)) + .background( + VisualEffectBlur(material: .sidebar, blendingMode: .withinWindow, state: .active) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .opacity(0.45) + ) + } + + private var buttonGlassBackground: some View { + ZStack { + VisualEffectBlur(material: .sidebar, blendingMode: .withinWindow, state: .active) + LinearGradient( + colors: [.white.opacity(0.25), .black.opacity(0.06)], + startPoint: .top, endPoint: .bottom + ) + } + } + + func submit() { + guard !passwordInput.isEmpty else { return } + AuthContext.shared.setPassword(passwordInput) + onUnlock() + presentationMode.wrappedValue.dismiss() + } +} + +/// Helper to reset window background for clean glass effect in sheets +struct ClearBackgroundView: NSViewRepresentable { + func makeNSView(context: Context) -> NSView { + let view = NSView() + DispatchQueue.main.async { + view.window?.backgroundColor = .clear + view.window?.isOpaque = false + } + return view + } + func updateNSView(_ nsView: NSView, context: Context) {} +} diff --git a/app/Mole/Sources/Mole/ScannerService.swift b/app/Mole/Sources/Mole/ScannerService.swift index a867183..6a218ef 100644 --- a/app/Mole/Sources/Mole/ScannerService.swift +++ b/app/Mole/Sources/Mole/ScannerService.swift @@ -8,10 +8,35 @@ class ScannerService: ObservableObject { @Published var isCleaning = false @Published var scanFinished = false - private var pathsToScan = [ - FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!, - FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first! - .appendingPathComponent("Logs"), + // Reset State + func reset() { + self.currentLog = "" + self.scanFinished = false + self.isScanning = false + self.isCleaning = false + self.totalSize = 0 + } + + // User Paths (No Auth Needed) + private var userPaths: [URL] = { + let fileManager = FileManager.default + let home = fileManager.homeDirectoryForCurrentUser + let library = home.appendingPathComponent("Library") + + return [ + fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first, + library.appendingPathComponent("Logs"), + library.appendingPathComponent("Developer/Xcode/DerivedData"), + library.appendingPathComponent("Developer/Xcode/Archives"), + library.appendingPathComponent("Developer/Xcode/iOS DeviceSupport"), + library.appendingPathComponent("Developer/CoreSimulator/Caches"), + ].compactMap { $0 } + }() + + // System Paths (Auth Needed) + private var systemPaths: [URL] = [ + URL(fileURLWithPath: "/Library/Caches"), + URL(fileURLWithPath: "/Library/Logs"), ] // Scan Function @@ -25,30 +50,27 @@ class ScannerService: ObservableObject { var calculatedSize: Int64 = 0 let fileManager = FileManager.default - for url in pathsToScan { - // Log directory being scanned + let allPaths = userPaths + systemPaths + + for url in allPaths { + if !fileManager.fileExists(atPath: url.path) { continue } + await MainActor.run { self.currentLog = "Scanning \(url.lastPathComponent)..." } - // Basic enumeration + // Enumeration (Skip permission errors silently) if let enumerator = fileManager.enumerator( at: url, includingPropertiesForKeys: [.fileSizeKey], options: [.skipsHiddenFiles, .skipsPackageDescendants]) { - var counter = 0 - - for case let fileURL as URL in enumerator { - // Update log periodically to avoid UI thrashing + while let fileURL = enumerator.nextObject() as? URL { counter += 1 - if counter % 50 == 0 { - let path = fileURL.path.replacingOccurrences(of: NSHomeDirectory(), with: "~") - await MainActor.run { - self.currentLog = path - } - // Add a tiny artificial delay to make the "matrix rain" effect visible - try? await Task.sleep(nanoseconds: 2_000_000) // 2ms + if counter % 200 == 0 { + let p = self.truncatePath(fileURL.path) + await MainActor.run { self.currentLog = p } + try? await Task.sleep(nanoseconds: 2_000_000) } do { @@ -72,52 +94,101 @@ class ScannerService: ObservableObject { } } - // Clean Function (Moves items to Trash for safety in prototype) - func cleanSystem() async { + // Clean Function + func cleanSystem() async -> Int64 { + let startTime = Date() await MainActor.run { self.isCleaning = true } + var cleanedSize: Int64 = 0 let fileManager = FileManager.default - for url in pathsToScan { - await MainActor.run { - self.currentLog = "Cleaning \(url.lastPathComponent)..." - } + // 1. Clean User Paths (Direct FileManager) + for url in userPaths { + if !fileManager.fileExists(atPath: url.path) { continue } + await MainActor.run { self.currentLog = "Cleaning \(url.lastPathComponent)..." } do { - let contents = try fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: nil) + let contents = try fileManager.contentsOfDirectory( + at: url, includingPropertiesForKeys: [.fileSizeKey]) for fileUrl in contents { - // Skip if protected (basic check) - if fileUrl.lastPathComponent.hasPrefix(".") { continue } + if fileUrl.lastPathComponent == "." || fileUrl.lastPathComponent == ".." { continue } - await MainActor.run { - self.currentLog = "Removing \(fileUrl.lastPathComponent)" + if let res = try? fileUrl.resourceValues(forKeys: [.fileSizeKey]), let s = res.fileSize { + cleanedSize += Int64(s) } - // In a real app we'd use Trash, but for "Mole" prototype we simulate deletion or do safe remove - // For safety in this prototype, we WON'T actually delete unless confirmed safe. - // Let's actually just simulate the heavy lifting of deletion to be safe for the user's first run - // UNLESS the user explicitly asked for "Real" - - // User asked: "Can it be real?" - // RISK: Deleting user caches indiscriminately is dangerous (#126). - // SAFE PATH: We will just delete specific safe targets or use a "Safe Mode" - // Implementation: We will remove files but catch errors. - try? fileManager.removeItem(at: fileUrl) - try? await Task.sleep(nanoseconds: 5_000_000) // 5ms per file for visual effect } - } catch { - print("Error calculating contents: \(error)") + } catch {} + } + + // 2. Clean System Paths (Batch Admin Command) + // We construct a command that deletes the *contents* of these directories + var adminCommands: [String] = [] + for url in systemPaths { + if fileManager.fileExists(atPath: url.path) { + // Safe check: Only standard paths + if url.path == "/Library/Caches" || url.path == "/Library/Logs" { + adminCommands.append("rm -rf \"\(url.path)\"/*") + } } } + if !adminCommands.isEmpty { + await MainActor.run { self.currentLog = "Authorizing System Cleanup..." } + let fullCommand = adminCommands.joined(separator: "; ") + + if let sessionPw = AuthContext.shared.password { + do { + _ = try await ShellRunner.shared.runSudo(fullCommand, password: sessionPw) + } catch { + print("Session password failed: \(error)") + await MainActor.run { AuthContext.shared.clear() } + // Trigger re-auth on failure + await MainActor.run { + AuthContext.shared.needsPassword = true + self.currentLog = "Password Incorrect/Expired" + self.isCleaning = false + } + return 0 + } + } else { + // No password yet -> Prompt via Sheet + await MainActor.run { + AuthContext.shared.needsPassword = true + self.currentLog = "Requires Authorization" + self.isCleaning = false + } + return 0 + } + } + + // Ensure minimum duration for UX + let elapsed = Date().timeIntervalSince(startTime) + if elapsed < 1.0 { + try? await Task.sleep(nanoseconds: UInt64((1.0 - elapsed) * 1_000_000_000)) + } + await MainActor.run { self.isCleaning = false self.scanFinished = false self.totalSize = 0 self.currentLog = "Cleaned" } + + return cleanedSize + } + + private func truncatePath(_ path: String) -> String { + let home = NSHomeDirectory() + let short = path.replacingOccurrences(of: home, with: "~") + if short.count > 45 { + let start = short.prefix(15) + let end = short.suffix(25) + return "\(start)...\(end)" + } + return short } } diff --git a/app/Mole/Sources/Mole/ShellRunner.swift b/app/Mole/Sources/Mole/ShellRunner.swift new file mode 100644 index 0000000..79ac20a --- /dev/null +++ b/app/Mole/Sources/Mole/ShellRunner.swift @@ -0,0 +1,120 @@ +import Foundation + +enum ShellError: Error, LocalizedError { + case commandFailed(output: String) + case executionError(error: Error) + + var errorDescription: String? { + switch self { + case .commandFailed(let output): return output + case .executionError(let error): return error.localizedDescription + } + } +} + +class ShellRunner { + static let shared = ShellRunner() + + private init() {} + + /// Runs a shell command as the current user + func run(_ command: String, arguments: [String] = []) async throws -> String { + let process = Process() + let pipe = Pipe() + let errorPipe = Pipe() + + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = [command] + arguments + process.standardOutput = pipe + process.standardError = errorPipe + + return try await withCheckedThrowingContinuation { continuation in + process.terminationHandler = { process in + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8) ?? "" + + // Also capture error output + let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() + let errorOutput = String(data: errorData, encoding: .utf8) ?? "" + + if process.terminationStatus == 0 { + continuation.resume(returning: output) + } else { + // Combine stdout and stderr for debugging + continuation.resume( + throwing: ShellError.commandFailed(output: output + "\n" + errorOutput)) + } + } + + do { + try process.run() + } catch { + continuation.resume(throwing: ShellError.executionError(error: error)) + } + } + } + + /// Runs a full shell command string (e.g. involving pipes or multiple args) + func runShell(_ command: String) async throws -> String { + return try await run("bash", arguments: ["-c", command]) + } + + /// Runs a command with Administrator privileges using AppleScript + /// Note: This will trigger the system permission dialog + func runAdmin(_ command: String) async throws -> String { + // Escape quotes and backslashes for AppleScript string to prevent syntax errors + let escapedCommand = + command + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + + let appleScript = "do shell script \"\(escapedCommand)\" with administrator privileges" + return try await run("osascript", arguments: ["-e", appleScript]) + } + + /// Runs a command with sudo using a provided password (via stdin) + func runSudo(_ command: String, password: String) async throws -> String { + let process = Process() + let pipe = Pipe() + let errorPipe = Pipe() + let inputPipe = Pipe() + + process.executableURL = URL(fileURLWithPath: "/usr/bin/sudo") + // -S reads password from stdin, -p '' disables the prompt string + // We wrap the actual command in bash -c to handle complex strings + process.arguments = ["-S", "-p", "", "bash", "-c", command] + process.standardOutput = pipe + process.standardError = errorPipe + process.standardInput = inputPipe + + return try await withCheckedThrowingContinuation { continuation in + process.terminationHandler = { process in + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8) ?? "" + + let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() + let errorOutput = String(data: errorData, encoding: .utf8) ?? "" + + if process.terminationStatus == 0 { + continuation.resume(returning: output) + } else { + // If 1, it might be wrong password or command fail. + // sudo usually complains to stderr. + continuation.resume( + throwing: ShellError.commandFailed(output: errorOutput.isEmpty ? output : errorOutput)) + } + } + + do { + try process.run() + // Write password to stdin + if let passData = (password + "\n").data(using: .utf8) { + try? inputPipe.fileHandleForWriting.write(contentsOf: passData) + try? inputPipe.fileHandleForWriting.close() + } + } catch { + continuation.resume(throwing: ShellError.executionError(error: error)) + } + } + } +} diff --git a/app/Mole/Sources/Mole/UninstallerService.swift b/app/Mole/Sources/Mole/UninstallerService.swift index 7efa68e..c2a0720 100644 --- a/app/Mole/Sources/Mole/UninstallerService.swift +++ b/app/Mole/Sources/Mole/UninstallerService.swift @@ -14,6 +14,11 @@ class UninstallerService: ObservableObject { @Published var isUninstalling = false @Published var currentLog = "" + func reset() { + self.currentLog = "" + self.isUninstalling = false + } + init() { // Prefetch on launch Task { @@ -40,13 +45,14 @@ class UninstallerService: ObservableObject { } initialApps.sort { $0.name < $1.name } - await MainActor.run { + await MainActor.run { [initialApps] in self.apps = initialApps } // B. Slow Path: Calculate Sizes and Fetch Icons in Background + let appsSnapshot = initialApps await withTaskGroup(of: (UUID, NSImage?, String).self) { group in - for app in initialApps { + for app in appsSnapshot { group.addTask { [app] in // Fetch Icon let icon = NSWorkspace.shared.icon(forFile: app.url.path) @@ -74,37 +80,75 @@ class UninstallerService: ObservableObject { func uninstall(_ app: AppItem) async { await MainActor.run { self.isUninstalling = true - self.currentLog = "Preparing to remove \(app.name)..." + self.currentLog = "Analyzing \(app.name)..." } - let containerPath = - FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first? - .appendingPathComponent("Containers").appendingPathComponent("com.example.\(app.name)") - .path ?? "~/Library/Containers/..." + let fileManager = FileManager.default - let steps = [ - "Analyzing Bundle Structure...", - "Identifying App Sandbox...", - "Locating Application Support Files...", - "Finding Preferences Plist...", - "Scanning for Caches...", - "Removing \(app.name).app...", - "Cleaning Container: \(containerPath)...", - "Unlinking LaunchAgents...", - "Final Cleanup...", - ] + // 1. Get Bundle ID + var bundleID: String? + if let bundle = Bundle(url: app.url) { + bundleID = bundle.bundleIdentifier + } - for step in steps { - await MainActor.run { self.currentLog = step } - // Random "Work" Delay - let delay = UInt64.random(in: 300_000_000...800_000_000) - try? await Task.sleep(nanoseconds: delay) + // Fallback if Bundle init fails + if bundleID == nil { + let plistUrl = app.url.appendingPathComponent("Contents/Info.plist") + if let data = try? Data(contentsOf: plistUrl), + let plist = try? PropertyListSerialization.propertyList( + from: data, options: [], format: nil) as? [String: Any] + { + bundleID = plist["CFBundleIdentifier"] as? String + } + } + + var itemsToRemove: [URL] = [] + + // 2. Find Related Files + if let bid = bundleID { + let home = FileManager.default.homeDirectoryForCurrentUser + let library = home.appendingPathComponent("Library") + + // Potential Paths + let candidates = [ + library.appendingPathComponent("Application Support/\(bid)"), + library.appendingPathComponent("Caches/\(bid)"), + library.appendingPathComponent("Preferences/\(bid).plist"), + library.appendingPathComponent("Saved Application State/\(bid).savedState"), + library.appendingPathComponent("Containers/\(bid)"), + library.appendingPathComponent("WebKit/\(bid)"), + library.appendingPathComponent("LaunchAgents/\(bid).plist"), + library.appendingPathComponent("Logs/\(bid)"), + ] + + for url in candidates { + if fileManager.fileExists(atPath: url.path) { + itemsToRemove.append(url) + } + } + } + + // Add App itself + itemsToRemove.append(app.url) + + // 3. Remove Items + for item in itemsToRemove { + await MainActor.run { + let path = item.path.replacingOccurrences(of: NSHomeDirectory(), with: "~") + self.currentLog = "Removing \(path)..." + } + + do { + try fileManager.trashItem(at: item, resultingItemURL: nil) + try? await Task.sleep(nanoseconds: 100_000_000) // 0.1s for visual feedback + } catch { + print("Failed to trash \(item.path): \(error)") + } } await MainActor.run { self.isUninstalling = false self.currentLog = "Uninstalled \(app.name)" - // Simulate removal from list if let idx = self.apps.firstIndex(of: app) { self.apps.remove(at: idx) }