diff --git a/.gitignore b/.gitignore index 41c2d27..68a947f 100644 --- a/.gitignore +++ b/.gitignore @@ -51,10 +51,9 @@ GEMINI.md # Go build artifacts (development) cmd/analyze/analyze cmd/status/status -/status -/analyze -mole-analyze -# Note: bin/analyze-go and bin/status-go are released binaries and should be tracked +# Go binaries +bin/analyze-go +bin/status-go bin/analyze-darwin-* bin/status-darwin-* diff --git a/app/AppListView.swift b/app/AppListView.swift deleted file mode 100644 index 102566b..0000000 --- a/app/AppListView.swift +++ /dev/null @@ -1,79 +0,0 @@ -import SwiftUI - -struct AppListView: View { - let apps: [AppItem] - var onSelect: (AppItem) -> Void - var onDismiss: () -> Void - - var body: some View { - VStack(spacing: 0) { - // Header - HStack { - Text("Installed Apps") - .font(.headline) - .foregroundStyle(.white) - Spacer() - Button(action: onDismiss) { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.gray) - .font(.title2) - } - .buttonStyle(.plain) - .keyboardShortcut(.escape, modifiers: []) - } - .padding() - .background(Color.black.opacity(0.8)) - - // List - ScrollView { - LazyVStack(spacing: 12) { - ForEach(apps) { app in - HStack { - if let icon = app.icon { - Image(nsImage: icon) - .resizable() - .frame(width: 32, height: 32) - } else { - Image(systemName: "app") - .resizable() - .foregroundStyle(.gray) - .frame(width: 32, height: 32) - } - - VStack(alignment: .leading, spacing: 2) { - Text(app.name) - .foregroundStyle(.white) - .font(.system(size: 14, weight: .medium)) - - Text(app.size) - .foregroundStyle(.white.opacity(0.6)) - .font(.system(size: 11, weight: .regular)) - } - - Spacer() - - Button(action: { onSelect(app) }) { - Text("Uninstall") - .font(.system(size: 10, weight: .bold)) - .foregroundStyle(.white) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(Capsule().fill(Color(red: 1.0, green: 0.3, blue: 0.1).opacity(0.8))) - } - .buttonStyle(.plain) - } - .padding(.horizontal) - .padding(.vertical, 8) - .background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.05))) - .padding(.horizontal) - } - } - .padding(.top) - .padding(.bottom, 40) - } - } - .background(Color.black.opacity(0.95)) - .clipShape(RoundedRectangle(cornerRadius: 20)) - .shadow(radius: 20) - } -} diff --git a/app/AppMode.swift b/app/AppMode.swift deleted file mode 100644 index 6863421..0000000 --- a/app/AppMode.swift +++ /dev/null @@ -1,35 +0,0 @@ -import Foundation -import SwiftUI - -enum AppMode: Equatable { - case cleaner - case uninstaller - case optimizer // New Mode - - // Reverting to tuple format for compatibility with SceneView if needed, or stick to Color? - // Let's stick to Color for now but ContentView might need adjustment if it expected tuple? - // Wait, previous file had (Double, Double, Double). - // If I change it to Color, I break SceneView if it uses the tuple. - // Checking SceneView usage... - // SceneView uses `appMode.themeColor` to set `material.diffuse.contents` fallback or logic. - // SceneView expects `(Double, Double, Double)` in `activeColor` binding? - // I should check SceneView signature. - // For safety, I will keep themeColor as Tuple OR add a new property. - // Let's start by fixing the compilation error (Markdown fences). - - var themeColor: (Double, Double, Double) { - switch self { - case .cleaner: return (0.45, 0.12, 0.05) // Deep Mars - case .uninstaller: return (0.35, 0.35, 0.4) // Deep Moon - case .optimizer: return (0.0, 0.2, 0.8) // Neptune Blue (RGB values approx) - } - } - - var title: String { - switch self { - case .cleaner: return "Cleaner" - case .uninstaller: return "Uninstaller" - case .optimizer: return "Optimizer" - } - } -} diff --git a/app/AppState.swift b/app/AppState.swift deleted file mode 100644 index 5fefdad..0000000 --- a/app/AppState.swift +++ /dev/null @@ -1,9 +0,0 @@ -import SwiftUI - -enum AppState: Equatable { - case idle - case scanning - case results(size: String) - case cleaning - case done -} diff --git a/app/AuthContext.swift b/app/AuthContext.swift deleted file mode 100644 index f1dc9c1..0000000 --- a/app/AuthContext.swift +++ /dev/null @@ -1,28 +0,0 @@ -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/ConfettiView.swift b/app/ConfettiView.swift deleted file mode 100644 index 0e14cad..0000000 --- a/app/ConfettiView.swift +++ /dev/null @@ -1,45 +0,0 @@ -import SwiftUI - -struct ConfettiView: View { - var colors: [Color] = [.red, .blue, .green, .yellow] - - var body: some View { - ZStack { - ForEach(0..<100, id: \.self) { i in - ConfettiPiece( - color: colors.randomElement() ?? .white, - angle: .degrees(Double(i) * 360 / 100) - ) - } - } - } -} - -struct ConfettiPiece: View { - let color: Color - let angle: Angle - - @State private var offset: CGFloat = 0 - @State private var opacity: Double = 1 - @State private var scale: CGFloat = 0.1 - // Randomize properties per piece - let size: CGFloat = CGFloat.random(in: 3...9) - - var body: some View { - Circle() - .fill(color) - .frame(width: size, height: size) - .scaleEffect(scale) - .offset(x: offset) - .rotationEffect(angle) - .opacity(opacity) - .onAppear { - let duration = Double.random(in: 1.0...2.5) - withAnimation(.easeOut(duration: duration)) { - offset = CGFloat.random(in: 100...350) - scale = 1.0 - opacity = 0 - } - } - } -} diff --git a/app/ContentView.swift b/app/ContentView.swift deleted file mode 100644 index 4089f0b..0000000 --- a/app/ContentView.swift +++ /dev/null @@ -1,383 +0,0 @@ -import SwiftUI - -struct ContentView: View { - @State private var appState: AppState = .idle - @State private var appMode: AppMode = .cleaner // New Mode State - @State private var logs: [String] = [] - @State private var showAppList = false - @State private var showCelebration = false - @State private var celebrationColors: [Color] = [] - @State private var celebrationMessage: String = "" - @Namespace private var animationNamespace - - // Connect to Real Logic - @StateObject private var scanner = ScannerService() - @StateObject private var uninstaller = UninstallerService() - @StateObject private var optimizer = OptimizerService() - @ObservedObject var authContext = AuthContext.shared - - // Mercury (Cleaner) - Dark Industrial Gray - let mercuryColor = Color(red: 0.15, green: 0.15, blue: 0.18) - - // 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 { - // Dynamic Background - Color.black.ignoresSafeArea() - - RadialGradient( - gradient: Gradient(colors: [ - appMode == .cleaner ? mercuryColor : (appMode == .uninstaller ? marsColor : earthColor), - .black, - ]), - center: .center, - startRadius: 0, - endRadius: 600 - ) - .ignoresSafeArea() - .animation(.easeInOut(duration: 0.5), value: appMode) - - // Custom Top Tab Bar - VStack { - TopBarView( - appMode: $appMode, animationNamespace: animationNamespace, authContext: authContext - ) - .padding(.top, 20) - - Spacer() - } - - VStack(spacing: -10) { - Spacer() - - // The Mole (Interactive) & Draggable - // The Mole (Interactive) & Draggable - MoleView( - state: $appState, - appMode: $appMode, - isRunning: scanner.isScanning || scanner.isCleaning || optimizer.isOptimizing - || uninstaller.isUninstalling - ) - .gesture( - DragGesture() - .onEnded { value in - if value.translation.width < -50 { - withAnimation { appMode = .uninstaller } - } else if value.translation.width > 50 { - withAnimation { appMode = .cleaner } - } - } - ) - .onHover { inside in - if inside { - NSCursor.pointingHand.set() - } else { - NSCursor.arrow.set() - } - } - .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 { - // Logs overlay - if case .scanning = appState, appMode == .cleaner { - LogView(logs: logs) - .transition(.opacity) - } else if case .cleaning = appState, appMode == .cleaner { - LogView(logs: logs) - .transition(.opacity) - } else if appMode == .optimizer && optimizer.isOptimizing { - LogView(logs: logs) - .transition(.opacity) - } else if appMode == .uninstaller && uninstaller.isUninstalling { - LogView(logs: logs) - .transition(.opacity) - } else if showAppList { - // Showing App List? No status text needed or handled by overlay - EmptyView() - } else { - // Action Button - Button(action: { - if appMode == .cleaner { - startSmartClean() - } else if appMode == .uninstaller { - handleUninstallerAction() - } else { - handleOptimizerAction() - } - }) { - HStack(spacing: 8) { - if scanner.isScanning || scanner.isCleaning || optimizer.isOptimizing - || uninstaller.isUninstalling - { - ProgressView() - .controlSize(.small) - .tint(.black) - } - - Text("Mole") - .font(.system(size: 14, weight: .bold, design: .monospaced)) - } - .frame(minWidth: 140) - .padding(.vertical, 12) - .background(Color.white) - .foregroundStyle(.black) - .clipShape(Capsule()) - .shadow(color: .white.opacity(0.2), radius: 10, x: 0, y: 0) - } - .buttonStyle(.plain) - .disabled( - scanner.isScanning || scanner.isCleaning || optimizer.isOptimizing - || uninstaller.isUninstalling - ) - .onHover { inside in - if inside { NSCursor.pointingHand.set() } else { NSCursor.arrow.set() } - } - } - } - .frame(height: 100) - .padding(.bottom, 30) // Anchor to bottom - } - - // App List Overlay - if showAppList { - AppListView( - apps: uninstaller.apps, - onSelect: { app in - handleUninstall(app) - }, - onDismiss: { - withAnimation { showAppList = false } - } - ) - .frame(width: 400, height: 550) - .transition(.move(edge: .bottom).combined(with: .opacity)) - .zIndex(10) - } - - if showCelebration { - VStack(spacing: 8) { - ConfettiView(colors: celebrationColors) - .offset(y: -50) - - VStack(spacing: 4) { - Text("Success!") - .font(.system(size: 24, weight: .bold, design: .rounded)) - .foregroundStyle(.white) - .shadow(radius: 5) - if !celebrationMessage.isEmpty { - Text(celebrationMessage) - .font(.system(size: 14, weight: .medium, design: .monospaced)) - .foregroundStyle(.white.opacity(0.9)) - .shadow(radius: 5) - } - } - .offset(y: 105) - } - .allowsHitTesting(false) - .zIndex(100) - .transition(.scale.combined(with: .opacity)) - } - } - .frame(minWidth: 600, minHeight: 500) - .onChange(of: scanner.currentLog) { - if !scanner.currentLog.isEmpty { - withAnimation(.spring) { - if appMode == .cleaner { - logs.append(scanner.currentLog) - } - } - } - } - .onChange(of: optimizer.currentLog) { - if !optimizer.currentLog.isEmpty { - withAnimation(.spring) { - if appMode == .optimizer { - logs.append(optimizer.currentLog) - } - } - } - } - .onChange(of: uninstaller.currentLog) { - if !uninstaller.currentLog.isEmpty { - withAnimation(.spring) { - if appMode == .uninstaller { - logs.append(uninstaller.currentLog) - } - } - } - } - .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 - showCelebration = false - scanner.reset() - optimizer.reset() - uninstaller.reset() - } - } - - // MARK: - Actions - - func startSmartClean() { - withAnimation { - appState = .scanning - logs.removeAll() - showCelebration = false // Dismiss old success - } - - Task { - await scanner.startScan() - try? await Task.sleep(nanoseconds: 500_000_000) - - await MainActor.run { - if scanner.totalSize > 0 { - startCleaning() - } else { - withAnimation { - appState = .idle - logs.removeAll() - } - triggerCelebration([.white], message: "Already Clean") - } - } - } - } - - func startCleaning() { - withAnimation { - appState = .cleaning - logs.removeAll() - showCelebration = false - } - - Task { - let cleanedBytes = await scanner.cleanSystem() - withAnimation { - appState = .done - } - - 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 - showCelebration = false - } - Task { await uninstaller.scanApps() } - } - - func handleUninstall(_ app: AppItem) { - withAnimation { - showAppList = false - logs.removeAll() - showCelebration = false - } - - Task { - await uninstaller.uninstall(app) - triggerCelebration( - [.red, .orange, .yellow, .green, .blue, .purple, .pink, .mint], - message: "Uninstalled \(app.name)") - } - } - - func handleOptimizerAction() { - showCelebration = false // Immediate dismiss - Task { - await optimizer.optimize() - triggerCelebration([.cyan, .blue, .purple, .mint, .white], message: "Optimized") - } - } - - func triggerCelebration(_ colors: [Color], message: String = "") { - celebrationColors = colors - celebrationMessage = message - withAnimation(.spring(response: 0.5, dampingFraction: 0.6)) { showCelebration = true } - DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { - withAnimation { showCelebration = false } - } - } -} - -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/Extensions.swift b/app/Extensions.swift deleted file mode 100644 index 4d89614..0000000 --- a/app/Extensions.swift +++ /dev/null @@ -1,19 +0,0 @@ -import AppKit - -extension Bundle { - /// Loads an image from the bundle's resources by name. - /// - /// This method attempts to load an image with the given name by trying - /// common image file extensions in order: PNG, JPG, and ICNS. - /// - /// - Parameter name: The name of the image resource without extension. - /// - Returns: An NSImage if found, nil otherwise. - /// - /// - Note: Supported formats: PNG, JPG, ICNS - func image(forResource name: String) -> NSImage? { - if let url = url(forResource: name, withExtension: "png") { return NSImage(contentsOf: url) } - if let url = url(forResource: name, withExtension: "jpg") { return NSImage(contentsOf: url) } - if let url = url(forResource: name, withExtension: "icns") { return NSImage(contentsOf: url) } - return nil - } -} diff --git a/app/KeychainHelper.swift b/app/KeychainHelper.swift deleted file mode 100644 index 5dd3b7f..0000000 --- a/app/KeychainHelper.swift +++ /dev/null @@ -1,37 +0,0 @@ -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/LogView.swift b/app/LogView.swift deleted file mode 100644 index f66fb48..0000000 --- a/app/LogView.swift +++ /dev/null @@ -1,36 +0,0 @@ -import SwiftUI - -struct LogView: View { - let logs: [String] - - 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) - } -} diff --git a/app/MoleApp.swift b/app/MoleApp.swift deleted file mode 100644 index 4d561c1..0000000 --- a/app/MoleApp.swift +++ /dev/null @@ -1,22 +0,0 @@ -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() - } - .windowStyle(.hiddenTitleBar) - .windowResizability(.contentSize) - } -} diff --git a/app/MoleSceneView.swift b/app/MoleSceneView.swift deleted file mode 100644 index 7f52bc7..0000000 --- a/app/MoleSceneView.swift +++ /dev/null @@ -1,181 +0,0 @@ -import GameplayKit -import QuartzCore -import SceneKit -import SwiftUI - -// A native 3D SceneKit View -struct MoleSceneView: NSViewRepresentable { - @Binding var state: AppState - @Binding var rotationVelocity: CGSize // Interaction Input - var activeColor: (Double, Double, Double) // (Red, Green, Blue) - var appMode: AppMode // Pass the mode - var isRunning: Bool // Fast spin trigger - - func makeCoordinator() -> Coordinator { - Coordinator(self) - } - - func makeNSView(context: Context) -> SCNView { - let scnView = SCNView() - - // Scene Setup - let scene = SCNScene() - scnView.scene = scene - scnView.backgroundColor = NSColor.clear - scnView.delegate = context.coordinator - scnView.isPlaying = true - - // 1. The Planet (Sphere) - let sphereGeo = SCNSphere(radius: 1.4) - sphereGeo.segmentCount = 192 - - // Atmosphere Shader removed strictly based on user feedback (No "layer" wanted) - // sphereGeo.shaderModifiers = nil - - let sphereNode = SCNNode(geometry: sphereGeo) - sphereNode.name = "molePlanet" - - // Material - let material = SCNMaterial() - material.lightingModel = .physicallyBased - material.diffuse.contents = NSColor.gray // Placeholder - - sphereNode.geometry?.materials = [material] - scene.rootNode.addChildNode(sphereNode) - - // 2. Lighting - // A. Main Sun - let sunLight = SCNNode() - sunLight.light = SCNLight() - sunLight.light?.type = .omni - sunLight.light?.color = NSColor(calibratedWhite: 1.0, alpha: 1.0) - sunLight.light?.intensity = 1350 // Reduced from 1500 for less glare - sunLight.position = SCNVector3(x: 8, y: 5, z: 12) - sunLight.light?.castsShadow = true - scene.rootNode.addChildNode(sunLight) - - // B. Rim Light - let rimLight = SCNNode() - rimLight.name = "rimLight" - rimLight.light = SCNLight() - rimLight.light?.type = .spot - rimLight.light?.color = NSColor(calibratedRed: 0.8, green: 0.8, blue: 1.0, alpha: 1.0) - rimLight.light?.intensity = 600 - rimLight.position = SCNVector3(x: -6, y: 3, z: -6) - rimLight.look(at: SCNVector3Zero) - scene.rootNode.addChildNode(rimLight) - - // C. Ambient - let ambientLight = SCNNode() - ambientLight.light = SCNLight() - ambientLight.light?.type = .ambient - ambientLight.light?.intensity = 300 // Lifted from 150 to soften shadows - ambientLight.light?.color = NSColor(white: 0.2, alpha: 1.0) - scene.rootNode.addChildNode(ambientLight) - - // Camera - let cameraNode = SCNNode() - cameraNode.camera = SCNCamera() - cameraNode.position = SCNVector3(x: 0, y: 0, z: 4) - scene.rootNode.addChildNode(cameraNode) - - scnView.antialiasingMode = .multisampling4X - scnView.allowsCameraControl = false - - return scnView - } - - func updateNSView(_ scnView: SCNView, context: Context) { - context.coordinator.parent = self - - guard let scene = scnView.scene, - 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 { - context.coordinator.currentMode = appMode - - if let scene = scnView.scene, - let planet = scene.rootNode.childNode(withName: "molePlanet", recursively: true), - let material = planet.geometry?.firstMaterial - { - var textureName = "mars" - var constRoughness: Double? = 0.9 - var rimColor = NSColor(calibratedRed: 1.0, green: 0.5, blue: 0.3, alpha: 1.0) - - 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.3, blue: 0.1, alpha: 1.0) - case .optimizer: - textureName = "earth" - constRoughness = 0.4 - rimColor = NSColor(calibratedRed: 0.2, green: 0.6, blue: 1.0, alpha: 1.0) - } - - // Load Texture (Support PNG and JPG) - let finalImage = Bundle.module.image(forResource: textureName) - - if let image = finalImage { - material.diffuse.contents = image - material.normal.contents = image - material.normal.intensity = 1.0 - - if let r = constRoughness { - material.roughness.contents = r - } else { - material.roughness.contents = image - } - material.emission.contents = NSColor.black - } else { - material.diffuse.contents = NSColor.gray - } - - if let rimLight = scene.rootNode.childNode(withName: "rimLight", recursively: false) { - SCNTransaction.begin() - SCNTransaction.animationDuration = 0.5 - rimLight.light?.color = rimColor - SCNTransaction.commit() - } - } - } - } - - class Coordinator: NSObject, SCNSceneRendererDelegate { - var parent: MoleSceneView - var currentMode: AppMode? // Track current mode to avoid reloading textures - - init(_ parent: MoleSceneView) { - self.parent = parent - } - - // ... rest of coordinator - - func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) { - guard - let planet = renderer.scene?.rootNode.childNode(withName: "molePlanet", recursively: false) - else { return } - - // Auto Rotation Speed - // Slower, majestic rotation - // Auto Rotation Speed - // Slower, majestic rotation normally. Fast when working. - let baseRotation = parent.isRunning ? 0.12 : 0.006 - - // Drag Influence - let dragInfluence = Double(parent.rotationVelocity.width) * 0.0005 - - // Vertical Tilt (X-Axis) + Slow Restore to 0 - let tiltInfluence = Double(parent.rotationVelocity.height) * 0.0005 - - // Apply Rotation - planet.eulerAngles.y += CGFloat(baseRotation + dragInfluence) - planet.eulerAngles.x += CGFloat(tiltInfluence) - } - } -} diff --git a/app/MoleView.swift b/app/MoleView.swift deleted file mode 100644 index 3f5c405..0000000 --- a/app/MoleView.swift +++ /dev/null @@ -1,75 +0,0 @@ -import SceneKit -import SwiftUI - -struct MoleView: View { - @Binding var state: AppState - @Binding var appMode: AppMode // New binding - var isRunning: Bool // Fast Spin Trigger - - @State private var dragVelocity = CGSize.zero - - // We hold a SceneKit scene instance to manipulate it directly if needed, or let the Representable handle it. - // To enable "Drag to Spin", we pass gesture data to the representable. - - var body: some View { - 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: 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 - } - ) - } - .scaleEffect(state == .cleaning ? 0.95 : 1.0) - .position(x: proxy.size.width / 2, y: proxy.size.height / 2) - } - .animation(.spring, value: state) - } -} diff --git a/app/OptimizerService.swift b/app/OptimizerService.swift deleted file mode 100644 index 470c969..0000000 --- a/app/OptimizerService.swift +++ /dev/null @@ -1,131 +0,0 @@ -import Foundation - -class OptimizerService: ObservableObject { - @Published var isOptimizing = false - @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 = "Optimizing..." // Removed "Authenticating..." - } - - // 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 { - print("Optimizer privilege error: \(error)") - // Only clear password if it's an authentication failure - if case ShellError.authenticationFailed = error { - await MainActor.run { AuthContext.shared.clear() } - - await MainActor.run { - AuthContext.shared.needsPassword = true - self.statusMessage = "Password Incorrect" - } - - struct AuthRequired: Error, LocalizedError { - var errorDescription: String? { "Authentication Failed" } - } - throw AuthRequired() - } else { - // Command failed but password likely correct. - // Do NOT clear password. Propagate error. - print("Non-auth error in optimizer: \(error)") - throw error - } - } - } - - // If no password, 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 (desc, action) in steps { - await MainActor.run { - 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)" - } - } - } - } - - await MainActor.run { - self.isOptimizing = false - self.statusMessage = "System Optimized" - self.currentLog = "Optimization Complete" - } - } -} diff --git a/app/Package.swift b/app/Package.swift deleted file mode 100644 index 69b7e32..0000000 --- a/app/Package.swift +++ /dev/null @@ -1,22 +0,0 @@ -// swift-tools-version: 5.9 -import PackageDescription - -let package = Package( - name: "Mole", - platforms: [ - .macOS(.v14) - ], - products: [ - .executable(name: "Mole", targets: ["Mole"]) - ], - targets: [ - .executableTarget( - name: "Mole", - path: ".", - exclude: ["Package.swift", "package.sh"], - resources: [ - .process("Resources") - ] - ) - ] -) diff --git a/app/PasswordSheetView.swift b/app/PasswordSheetView.swift deleted file mode 100644 index 758f95e..0000000 --- a/app/PasswordSheetView.swift +++ /dev/null @@ -1,210 +0,0 @@ -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) { - if let icon = Bundle.module.image(forResource: "mole") { - Image(nsImage: icon) - .resizable() - .interpolation(.high) - .aspectRatio(contentMode: .fit) - .frame(width: 48, height: 48) - .shadow(radius: 2) - } else { - 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/Resources/earth.jpg b/app/Resources/earth.jpg deleted file mode 100644 index 6cdcffe..0000000 Binary files a/app/Resources/earth.jpg and /dev/null differ diff --git a/app/Resources/mars.png b/app/Resources/mars.png deleted file mode 100644 index 190771c..0000000 Binary files a/app/Resources/mars.png and /dev/null differ diff --git a/app/Resources/mercury.png b/app/Resources/mercury.png deleted file mode 100644 index 88a80e5..0000000 Binary files a/app/Resources/mercury.png and /dev/null differ diff --git a/app/Resources/mole.icns b/app/Resources/mole.icns deleted file mode 100644 index c8b2154..0000000 Binary files a/app/Resources/mole.icns and /dev/null differ diff --git a/app/ScannerService.swift b/app/ScannerService.swift deleted file mode 100644 index e62e89b..0000000 --- a/app/ScannerService.swift +++ /dev/null @@ -1,205 +0,0 @@ -import Combine -import Foundation - -class ScannerService: ObservableObject { - @Published var currentLog: String = "" - @Published var totalSize: Int64 = 0 - @Published var isScanning = false - @Published var isCleaning = false - @Published var scanFinished = false - - // 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 - func startScan() async { - await MainActor.run { - self.isScanning = true - self.scanFinished = false - self.totalSize = 0 - } - - var calculatedSize: Int64 = 0 - let fileManager = FileManager.default - - let allPaths = userPaths + systemPaths - - for url in allPaths { - if !fileManager.fileExists(atPath: url.path) { continue } - - await MainActor.run { - self.currentLog = "Scanning \(url.lastPathComponent)..." - } - - // Enumeration (Skip permission errors silently) - if let enumerator = fileManager.enumerator( - at: url, includingPropertiesForKeys: [.fileSizeKey], - options: [.skipsHiddenFiles, .skipsPackageDescendants]) - { - var counter = 0 - while let fileURL = enumerator.nextObject() as? URL { - counter += 1 - 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 { - let resourceValues = try fileURL.resourceValues(forKeys: [.fileSizeKey]) - if let fileSize = resourceValues.fileSize { - calculatedSize += Int64(fileSize) - } - } catch { - continue - } - } - } - } - - let finalSize = calculatedSize - await MainActor.run { - self.totalSize = finalSize - self.isScanning = false - self.scanFinished = true - self.currentLog = "Scan Complete" - } - } - - // Clean Function - func cleanSystem() async -> Int64 { - let startTime = Date() - await MainActor.run { - self.isCleaning = true - } - - var cleanedSize: Int64 = 0 - let fileManager = FileManager.default - - // 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: [.fileSizeKey]) - for fileUrl in contents { - if fileUrl.lastPathComponent == "." || fileUrl.lastPathComponent == ".." { continue } - - if let res = try? fileUrl.resourceValues(forKeys: [.fileSizeKey]), let s = res.fileSize { - cleanedSize += Int64(s) - } - - try? fileManager.removeItem(at: fileUrl) - } - } 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("Sudo command error: \(error)") - print("Session password failed: \(error)") - - if case ShellError.authenticationFailed = error { - await MainActor.run { - AuthContext.shared.clear() - AuthContext.shared.needsPassword = true - self.currentLog = "Password Incorrect/Expired" - self.isCleaning = false - } - return 0 - } - // Ignore other errors (e.g. command execution failed) - // but continue the flow or handle gracefully without clearing password - print("Non-auth error in cleanup: \(error)") - await MainActor.run { - self.currentLog = "Error: \(error.localizedDescription)" - 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/ShellRunner.swift b/app/ShellRunner.swift deleted file mode 100644 index 0c01421..0000000 --- a/app/ShellRunner.swift +++ /dev/null @@ -1,134 +0,0 @@ -import Foundation - -enum ShellError: Error, LocalizedError { - case commandFailed(output: String) - case executionError(error: Error) - case authenticationFailed - - var errorDescription: String? { - switch self { - case .commandFailed(let output): return output - case .executionError(let error): return error.localizedDescription - case .authenticationFailed: return "Authentication failed - incorrect password" - } - } -} - -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 { - // Check for password failure - let combined = (output + errorOutput).lowercased() - if combined.contains("try again") || combined.contains("incorrect") { - continuation.resume(throwing: ShellError.authenticationFailed) - } else { - 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)) - } - } - } - - /// Escapes a string for safe use in bash commands - private func bashEscape(_ str: String) -> String { - // Use single quotes and escape any single quotes within the string - let escaped = str.replacingOccurrences(of: "'", with: "'\"'\"'") - return "'\(escaped)'" - } -} diff --git a/app/UninstallerService.swift b/app/UninstallerService.swift deleted file mode 100644 index c2a0720..0000000 --- a/app/UninstallerService.swift +++ /dev/null @@ -1,174 +0,0 @@ -import AppKit -import Foundation - -struct AppItem: Identifiable, Equatable { - let id = UUID() - let name: String - let url: URL - let icon: NSImage? - let size: String -} - -class UninstallerService: ObservableObject { - @Published var apps: [AppItem] = [] - @Published var isUninstalling = false - @Published var currentLog = "" - - func reset() { - self.currentLog = "" - self.isUninstalling = false - } - - init() { - // Prefetch on launch - Task { - await scanApps() - } - } - - func scanApps() async { - // If we already have data, don't block. - if !apps.isEmpty { return } - - let fileManager = FileManager.default - let appsDir = URL(fileURLWithPath: "/Applications") - - do { - let fileURLs = try fileManager.contentsOfDirectory( - at: appsDir, includingPropertiesForKeys: nil, options: .skipsHiddenFiles) - - // A. Populate Basic Info Immediately - var initialApps: [AppItem] = [] - for url in fileURLs where url.pathExtension == "app" { - let name = url.deletingPathExtension().lastPathComponent - initialApps.append(AppItem(name: name, url: url, icon: nil, size: "")) - } - initialApps.sort { $0.name < $1.name } - - 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 appsSnapshot { - group.addTask { [app] in - // Fetch Icon - let icon = NSWorkspace.shared.icon(forFile: app.url.path) - // Calculate Size - let size = self.calculateSize(for: app.url) - return (app.id, icon, size) - } - } - - for await (id, icon, sizeStr) in group { - await MainActor.run { - if let index = self.apps.firstIndex(where: { $0.id == id }) { - let old = self.apps[index] - self.apps[index] = AppItem(name: old.name, url: old.url, icon: icon, size: sizeStr) - } - } - } - } - - } catch { - print("Error scanning apps: \(error)") - } - } - - func uninstall(_ app: AppItem) async { - await MainActor.run { - self.isUninstalling = true - self.currentLog = "Analyzing \(app.name)..." - } - - let fileManager = FileManager.default - - // 1. Get Bundle ID - var bundleID: String? - if let bundle = Bundle(url: app.url) { - bundleID = bundle.bundleIdentifier - } - - // 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)" - if let idx = self.apps.firstIndex(of: app) { - self.apps.remove(at: idx) - } - } - } - - private func calculateSize(for url: URL) -> String { - guard - let enumerator = FileManager.default.enumerator( - at: url, includingPropertiesForKeys: [.fileSizeKey]) - else { return "Unknown" } - var totalSize: Int64 = 0 - for case let fileURL as URL in enumerator { - do { - let resourceValues = try fileURL.resourceValues(forKeys: [.fileSizeKey]) - if let fileSize = resourceValues.fileSize { - totalSize += Int64(fileSize) - } - } catch {} - } - return ByteCountFormatter.string(fromByteCount: totalSize, countStyle: .file) - } -} diff --git a/app/package.sh b/app/package.sh deleted file mode 100755 index 65634a6..0000000 --- a/app/package.sh +++ /dev/null @@ -1,60 +0,0 @@ -#!/bin/bash -set -e - -# Configuration -APP_NAME="Mole" -# Get the actual build path dynamically -BUILD_PATH="$(swift build -c release --show-bin-path)/$APP_NAME" -APP_BUNDLE="$APP_NAME.app" -ICON_SOURCE="Resources/mole.icns" - -echo "🚀 Building Release Binary..." -swift build -c release - -echo "📦 Creating App Bundle Structure..." -rm -rf "$APP_BUNDLE" -mkdir -p "$APP_BUNDLE/Contents/MacOS" -mkdir -p "$APP_BUNDLE/Contents/Resources" - -echo "📄 Copying Executable..." -cp "$BUILD_PATH" "$APP_BUNDLE/Contents/MacOS/" - -echo "📝 Generatign Info.plist..." -cat < "$APP_BUNDLE/Contents/Info.plist" - - - - - CFBundleExecutable - $APP_NAME - CFBundleIdentifier - com.tw93.mole - CFBundleName - $APP_NAME - CFBundleIconFile - AppIcon - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 - LSMinimumSystemVersion - 13.0 - NSHighResolutionCapable - - - -EOF - -if [ -f "$ICON_SOURCE" ]; then - echo "🎨 Copying App Icon from $ICON_SOURCE..." - cp "$ICON_SOURCE" "$APP_BUNDLE/Contents/Resources/AppIcon.icns" - echo "✅ App Icon set successfully." -else - echo "⚠️ Icon file not found at $ICON_SOURCE. App will use default icon." -fi - -# Remove xattr com.apple.quarantine to avoid warnings -xattr -cr "$APP_BUNDLE" - -echo "✅ App Packaged: $APP_BUNDLE" -echo "👉 You can now move $APP_NAME.app to /Applications"