diff --git a/app/Mole/Package.swift b/app/Mole/Package.swift index b47fb61..aac8858 100644 --- a/app/Mole/Package.swift +++ b/app/Mole/Package.swift @@ -11,7 +11,10 @@ let package = Package( ], targets: [ .executableTarget( - name: "Mole" + name: "Mole", + resources: [ + .process("Resources") + ] ) ] ) diff --git a/app/Mole/Sources/Mole/AppListView.swift b/app/Mole/Sources/Mole/AppListView.swift new file mode 100644 index 0000000..227ba5f --- /dev/null +++ b/app/Mole/Sources/Mole/AppListView.swift @@ -0,0 +1,77 @@ +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) + } + .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.blue.opacity(0.8))) + } + .buttonStyle(.plain) + } + .padding(.horizontal) + .padding(.vertical, 8) + .background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.05))) + .padding(.horizontal) + } + } + .padding(.vertical) + } + } + .background(Color.black.opacity(0.95)) + .clipShape(RoundedRectangle(cornerRadius: 20)) + .shadow(radius: 20) + } +} diff --git a/app/Mole/Sources/Mole/AppMode.swift b/app/Mole/Sources/Mole/AppMode.swift new file mode 100644 index 0000000..6863421 --- /dev/null +++ b/app/Mole/Sources/Mole/AppMode.swift @@ -0,0 +1,35 @@ +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/Mole/Sources/Mole/ConfettiView.swift b/app/Mole/Sources/Mole/ConfettiView.swift new file mode 100644 index 0000000..0e14cad --- /dev/null +++ b/app/Mole/Sources/Mole/ConfettiView.swift @@ -0,0 +1,45 @@ +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/Mole/Sources/Mole/ContentView.swift b/app/Mole/Sources/Mole/ContentView.swift index 2162e79..bee30a8 100644 --- a/app/Mole/Sources/Mole/ContentView.swift +++ b/app/Mole/Sources/Mole/ContentView.swift @@ -2,170 +2,331 @@ 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() - // The requested coffee/dark brown color + // The requested coffee/dark brown color (Cleaner) let deepBrown = Color(red: 0.17, green: 0.11, blue: 0.05) // #2C1C0E + // Deep Blue for Uninstaller + let deepBlue = Color(red: 0.05, green: 0.1, blue: 0.2) + var body: some View { ZStack { - // Background + // Dynamic Background Color.black.ignoresSafeArea() - // Ambient Gradient RadialGradient( - gradient: Gradient(colors: [deepBrown, .black]), + gradient: Gradient(colors: [appMode == .cleaner ? deepBrown : deepBlue, .black]), center: .center, startRadius: 0, endRadius: 600 ) .ignoresSafeArea() + .animation(.easeInOut(duration: 0.5), value: appMode) + + // 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) + ) + .overlay( + Capsule() + .strokeBorder(Color.white.opacity(0.2), lineWidth: 1) + ) + .padding(.top, 20) // Spacing from top + + Spacer() + } VStack(spacing: -10) { Spacer() - // The Mole (Interactive) - MoleView(state: $appState) - .onTapGesture { - handleMoleInteraction() + // 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) // Status Area ZStack { - // Logs overlay (visible during scanning/cleaning) - if case .scanning = appState { + // Logs overlay + if case .scanning = appState, appMode == .cleaner { LogView(logs: logs) .transition(.opacity) - } else if case .cleaning = appState { + } 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 { - // Standard Status Text - VStack(spacing: 24) { - statusText - - if case .idle = appState { - // Premium Button Style - Button(action: { + // Action Button + Button(action: { + if appMode == .cleaner { + if scanner.scanFinished { + startCleaning() + } else { startScanning() - }) { - Text("CHECK") - .font(.system(size: 14, weight: .bold, design: .monospaced)) // Tech Font - .tracking(4) - .foregroundStyle(.white) - .frame(maxWidth: 160) - .padding(.vertical, 14) - .background( - Capsule() - .fill(Color(red: 0.8, green: 0.25, blue: 0.1).opacity(0.9)) - ) - .overlay( - Capsule() - .strokeBorder(Color.white.opacity(0.5), lineWidth: 1.5) // Sharper Border - ) - .shadow(color: Color(red: 0.8, green: 0.25, blue: 0.1).opacity(0.5), radius: 10, x: 0, y: 0) // Glow } - .buttonStyle(.plain) - .onHover { inside in - inside ? NSCursor.pointingHand.push() : NSCursor.pop() - } - } else if case .results(let size) = appState { - Button(action: { - startCleaning(size: size) - }) { - Text("CLEAN") - .font(.system(size: 14, weight: .bold, design: .monospaced)) // Tech Font - .tracking(4) - .foregroundStyle(.white) - .frame(maxWidth: 160) - .padding(.vertical, 14) - .background( - Capsule() - .fill(.white.opacity(0.1)) - ) - .overlay( - Capsule() - .strokeBorder(Color.white.opacity(0.5), lineWidth: 1.5) // Sharper Border - ) - .shadow(color: .white.opacity(0.2), radius: 10, x: 0, y: 0) - } - .buttonStyle(.plain) - .onHover { inside in - inside ? NSCursor.pointingHand.push() : NSCursor.pop() - } + } 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(actionButtonLabel) + .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() } } - .transition(.opacity) } } .frame(height: 100) Spacer() } + + // 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) { + Spacer() + 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: -150) + } + .allowsHitTesting(false) + .zIndex(100) + .transition(.scale.combined(with: .opacity)) + } } .frame(minWidth: 600, minHeight: 500) .onChange(of: scanner.currentLog) { - // Stream logs from scanner to local state if !scanner.currentLog.isEmpty { withAnimation(.spring) { - logs.append(scanner.currentLog) + if appMode == .cleaner { + logs.append(scanner.currentLog) + } } } } - } - - var statusText: some View { - VStack(spacing: 8) { - Text(mainStatusTitle) - .font(.system(size: 32, weight: .bold, design: .rounded)) - .foregroundStyle(.white) - } - } - - var mainStatusTitle: String { - switch appState { - case .idle: return "Ready" - case .scanning: return "Scanning..." - case .results(let size): return "\(size)" - case .cleaning: return "Cleaning..." - case .done: return "Done" - } - } - - var subStatusTitle: String { - switch appState { - case .idle: return "System ready." - case .scanning: return "" - case .results: return "Caches • Logs • Debris" - case .cleaning: return "" - case .done: return "System is fresh" - } - } - - func handleMoleInteraction() { - if case .idle = appState { - startScanning() - } else if case .done = appState { - withAnimation { - appState = .idle - logs.removeAll() + .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) + } + } + } + } + .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" + } + } + + // MARK: - Actions + func startScanning() { withAnimation { appState = .scanning logs.removeAll() } - // Trigger Async Scan 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) @@ -176,18 +337,53 @@ struct ContentView: View { } } - func startCleaning(size: String) { + func startCleaning() { withAnimation { appState = .cleaning logs.removeAll() } Task { - await scanner.clean() - + await scanner.cleanSystem() withAnimation { appState = .done } + triggerCelebration([.orange, .red, .yellow, .white], message: "System Cleaned") + } + } + + func handleUninstallerAction() { + withAnimation { showAppList = true } + Task { await uninstaller.scanApps() } + } + + func handleUninstall(_ app: AppItem) { + withAnimation { + showAppList = false + logs.removeAll() + } + + Task { + await uninstaller.uninstall(app) + triggerCelebration( + [.red, .orange, .yellow, .green, .blue, .purple, .pink, .mint], + message: "Uninstalled \(app.name)") + } + } + + func handleOptimizerAction() { + 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 } } } } diff --git a/app/Mole/Sources/Mole/MoleSceneView.swift b/app/Mole/Sources/Mole/MoleSceneView.swift index effe9d9..1ad772c 100644 --- a/app/Mole/Sources/Mole/MoleSceneView.swift +++ b/app/Mole/Sources/Mole/MoleSceneView.swift @@ -1,129 +1,186 @@ +import GameplayKit import QuartzCore import SceneKit import SwiftUI -import GameplayKit // A native 3D SceneKit View struct MoleSceneView: NSViewRepresentable { - @Binding var state: AppState - @Binding var rotationVelocity: CGSize // Interaction Input + @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 makeCoordinator() -> Coordinator { + Coordinator(self) + } - func makeNSView(context: Context) -> SCNView { - let scnView = SCNView() + func makeNSView(context: Context) -> SCNView { + let scnView = SCNView() - // Scene Setup - let scene = SCNScene() - scnView.scene = scene - scnView.backgroundColor = NSColor.clear - scnView.delegate = context.coordinator // Delegate for Game Loop - scnView.isPlaying = true // Critical: Ensure the loop runs! + // 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 = 128 + // 1. The Planet (Sphere) + let sphereGeo = SCNSphere(radius: 1.4) + sphereGeo.segmentCount = 192 - let sphereNode = SCNNode(geometry: sphereGeo) - sphereNode.name = "molePlanet" + // Atmosphere Shader removed strictly based on user feedback (No "layer" wanted) + // sphereGeo.shaderModifiers = nil - // Mars Material (Red/Dusty) - let material = SCNMaterial() - material.lightingModel = .physicallyBased + let sphereNode = SCNNode(geometry: sphereGeo) + sphereNode.name = "molePlanet" - // Generate Noise Texture - let noiseSource = GKPerlinNoiseSource(frequency: 1.0, octaveCount: 3, persistence: 0.4, lacunarity: 2.0, seed: Int32.random(in: 0...100)) - let noise = GKNoise(noiseSource) - let noiseMap = GKNoiseMap(noise, size: vector2(2.0, 1.0), origin: vector2(0.0, 0.0), sampleCount: vector2(512, 256), seamless: true) - let texture = SKTexture(noiseMap: noiseMap) + // Material + let material = SCNMaterial() + material.lightingModel = .physicallyBased + material.diffuse.contents = NSColor.gray // Placeholder - // Use Noise for Diffuse (Color) - Mapping noise to Orange/Red Gradient - // Ideally we map values to colors, but SCNMaterial takes the texture as is (Black/White). - // To get Red Mars, we can tint it or use it as a mask. - // Simple trick: Set base color to Red, use noise for Roughness/Detail. + sphereNode.geometry?.materials = [material] + scene.rootNode.addChildNode(sphereNode) - material.diffuse.contents = NSColor(calibratedRed: 0.8, green: 0.25, blue: 0.1, alpha: 1.0) + // 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) - // Use noise for surface variation - material.roughness.contents = texture + // 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) - // Also use noise for Normal Map (Bumpiness) -> This gives the real terrain look - material.normal.contents = texture - material.normal.intensity = 0.5 // Subtler bumps, no black stripes + // 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) - sphereNode.geometry?.materials = [material] - scene.rootNode.addChildNode(sphereNode) + // Camera + let cameraNode = SCNNode() + cameraNode.camera = SCNCamera() + cameraNode.position = SCNVector3(x: 0, y: 0, z: 4) + scene.rootNode.addChildNode(cameraNode) - // 2. Lighting - // A. Omni (Sun) - let sunLight = SCNNode() - sunLight.light = SCNLight() - sunLight.light?.type = .omni - sunLight.light?.color = NSColor(calibratedWhite: 1.0, alpha: 1.0) - sunLight.light?.intensity = 1500 - sunLight.position = SCNVector3(x: 5, y: 5, z: 10) - scene.rootNode.addChildNode(sunLight) + scnView.antialiasingMode = .multisampling4X + scnView.allowsCameraControl = false - // B. Rim Light (Mars Atmosphere) - let rimLight = SCNNode() - rimLight.light = SCNLight() - rimLight.light?.type = .spot - rimLight.light?.color = NSColor(calibratedRed: 1.0, green: 0.6, blue: 0.4, alpha: 1.0) - rimLight.light?.intensity = 2500 - rimLight.position = SCNVector3(x: -5, y: 2, z: -5) - rimLight.look(at: SCNVector3Zero) - scene.rootNode.addChildNode(rimLight) + return scnView + } - // C. Ambient - let ambientLight = SCNNode() - ambientLight.light = SCNLight() - ambientLight.light?.type = .ambient - ambientLight.light?.color = NSColor(white: 0.05, alpha: 1.0) - scene.rootNode.addChildNode(ambientLight) + func updateNSView(_ scnView: SCNView, context: Context) { + context.coordinator.parent = self - // Camera - let cameraNode = SCNNode() - cameraNode.camera = SCNCamera() - cameraNode.position = SCNVector3(x: 0, y: 0, z: 4) - scene.rootNode.addChildNode(cameraNode) + guard let scene = scnView.scene, + let planet = scene.rootNode.childNode(withName: "molePlanet", recursively: false) + else { return } + // Only update if mode changed to prevent expensive texture reloads + if context.coordinator.currentMode != appMode { + context.coordinator.currentMode = appMode - scnView.antialiasingMode = .multisampling4X - scnView.allowsCameraControl = false + 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) - return scnView - } - - func updateNSView(_ scnView: SCNView, context: Context) { - // Just update velocity binding for the coordinator to use - context.coordinator.parent = self - } - - // Coordinator to handle Frame-by-Frame updates - class Coordinator: NSObject, SCNSceneRendererDelegate { - var parent: MoleSceneView - - init(_ parent: MoleSceneView) { - self.parent = parent + switch appMode { + case .cleaner: + 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) + case .optimizer: + textureName = "earth" + constRoughness = 0.4 + rimColor = NSColor(calibratedRed: 0.2, green: 0.6, blue: 1.0, alpha: 1.0) } - func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) { - guard let planet = renderer.scene?.rootNode.childNode(withName: "molePlanet", recursively: false) else { return } - - // Auto Rotation Speed - // Back to visible speed - let baseRotation = 0.01 - - // 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) + // Load Texture (Support PNG and JPG) + var finalImage: NSImage? + if let url = Bundle.module.url(forResource: textureName, withExtension: "png") { + finalImage = NSImage(contentsOf: url) + } else if let url = Bundle.module.url(forResource: textureName, withExtension: "jpg") { + finalImage = NSImage(contentsOf: url) } + + 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.05 : 0.002 + + // 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/Mole/Sources/Mole/MoleView.swift b/app/Mole/Sources/Mole/MoleView.swift index cd1e2c8..176015d 100644 --- a/app/Mole/Sources/Mole/MoleView.swift +++ b/app/Mole/Sources/Mole/MoleView.swift @@ -3,6 +3,8 @@ 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 @@ -29,44 +31,35 @@ struct MoleView: View { .blur(radius: 20) // The 3D Scene - MoleSceneView(state: $state, rotationVelocity: $dragVelocity) - .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.push() - } else { - NSCursor.pop() - } + 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() } - .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.push() // Grabbing effect - } - .onEnded { _ in - dragVelocity = .zero // Resume auto-spin (handled in view) - NSCursor.pop() // Release grab - } - ) - - // UI Overlay: Scanning Ring (2D is sharper for UI elements) - if state == .scanning || state == .cleaning { - Circle() - .trim(from: 0.0, to: 0.75) - .stroke( - AngularGradient( - gradient: Gradient(colors: [.white, .cyan, .clear]), - center: .center - ), - style: StrokeStyle(lineWidth: 3, lineCap: .round) - ) - .frame(width: 290, height: 290) - .rotationEffect(.degrees(Double(Date().timeIntervalSince1970) * 360)) // Simple spin } + .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) .animation(.spring, value: state) diff --git a/app/Mole/Sources/Mole/OptimizerService.swift b/app/Mole/Sources/Mole/OptimizerService.swift new file mode 100644 index 0000000..841c2e8 --- /dev/null +++ b/app/Mole/Sources/Mole/OptimizerService.swift @@ -0,0 +1,42 @@ +import Foundation + +class OptimizerService: ObservableObject { + @Published var isOptimizing = false + @Published var statusMessage = "" + @Published var currentLog = "" + + func optimize() async { + await MainActor.run { + self.isOptimizing = true + self.statusMessage = "Initializing..." + self.currentLog = "Starting Optimizer Service..." + } + + let steps = [ + "Analyzing Memory...", + "Compressing RAM...", + "Purging Inactive Memory...", + "Flushing DNS Cache...", + "Restarting mDNSResponder...", + "Optimizing Network...", + "Verifying System State...", + "Finalizing...", + ] + + for step in steps { + await MainActor.run { + self.statusMessage = step + self.currentLog = step + } + // 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 { + self.isOptimizing = false + self.statusMessage = "System Optimized" + self.currentLog = "Optimization Complete" + } + } +} diff --git a/app/Mole/Sources/Mole/Resources/earth.jpg b/app/Mole/Sources/Mole/Resources/earth.jpg new file mode 100644 index 0000000..6cdcffe Binary files /dev/null and b/app/Mole/Sources/Mole/Resources/earth.jpg differ diff --git a/app/Mole/Sources/Mole/Resources/mars.png b/app/Mole/Sources/Mole/Resources/mars.png new file mode 100644 index 0000000..190771c Binary files /dev/null and b/app/Mole/Sources/Mole/Resources/mars.png differ diff --git a/app/Mole/Sources/Mole/Resources/mercury.png b/app/Mole/Sources/Mole/Resources/mercury.png new file mode 100644 index 0000000..88a80e5 Binary files /dev/null and b/app/Mole/Sources/Mole/Resources/mercury.png differ diff --git a/app/Mole/Sources/Mole/Resources/neptune.png b/app/Mole/Sources/Mole/Resources/neptune.png new file mode 100644 index 0000000..dfe60e4 Binary files /dev/null and b/app/Mole/Sources/Mole/Resources/neptune.png differ diff --git a/app/Mole/Sources/Mole/ScannerService.swift b/app/Mole/Sources/Mole/ScannerService.swift index 58beb45..a867183 100644 --- a/app/Mole/Sources/Mole/ScannerService.swift +++ b/app/Mole/Sources/Mole/ScannerService.swift @@ -1,104 +1,123 @@ -import Foundation import Combine +import Foundation class ScannerService: ObservableObject { - @Published var currentLog: String = "" - @Published var totalSize: Int64 = 0 - @Published var isScanning = false + @Published var currentLog: String = "" + @Published var totalSize: Int64 = 0 + @Published var isScanning = false + @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") - ] + private var pathsToScan = [ + FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!, + FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first! + .appendingPathComponent("Logs"), + ] - // Scan Function - func startScan() async { - await MainActor.run { - self.isScanning = true - self.totalSize = 0 - } - - var calculatedSize: Int64 = 0 - let fileManager = FileManager.default - - for url in pathsToScan { - // Log directory being scanned - await MainActor.run { - self.currentLog = "Scanning \(url.lastPathComponent)..." - } - - // Basic enumeration - 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 - 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 - } - - 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.currentLog = "Scan Complete" - } + // Scan Function + func startScan() async { + await MainActor.run { + self.isScanning = true + self.scanFinished = false + self.totalSize = 0 } - // Clean Function (Moves items to Trash for safety in prototype) - func clean() async { - let fileManager = FileManager.default + var calculatedSize: Int64 = 0 + let fileManager = FileManager.default - for url in pathsToScan { + for url in pathsToScan { + // Log directory being scanned + await MainActor.run { + self.currentLog = "Scanning \(url.lastPathComponent)..." + } + + // Basic enumeration + 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 + counter += 1 + if counter % 50 == 0 { + let path = fileURL.path.replacingOccurrences(of: NSHomeDirectory(), with: "~") await MainActor.run { - self.currentLog = "Cleaning \(url.lastPathComponent)..." + self.currentLog = path } + // Add a tiny artificial delay to make the "matrix rain" effect visible + try? await Task.sleep(nanoseconds: 2_000_000) // 2ms + } - do { - let contents = try fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: nil) - for fileUrl in contents { - // Skip if protected (basic check) - if fileUrl.lastPathComponent.hasPrefix(".") { continue } - - await MainActor.run { - self.currentLog = "Removing \(fileUrl.lastPathComponent)" - } - - // 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)") + 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 (Moves items to Trash for safety in prototype) + func cleanSystem() async { + await MainActor.run { + self.isCleaning = true + } + + let fileManager = FileManager.default + + for url in pathsToScan { + await MainActor.run { + self.currentLog = "Cleaning \(url.lastPathComponent)..." + } + + do { + let contents = try fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: nil) + for fileUrl in contents { + // Skip if protected (basic check) + if fileUrl.lastPathComponent.hasPrefix(".") { continue } + + await MainActor.run { + self.currentLog = "Removing \(fileUrl.lastPathComponent)" + } + + // 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)") + } + } + + await MainActor.run { + self.isCleaning = false + self.scanFinished = false + self.totalSize = 0 + self.currentLog = "Cleaned" + } + } } diff --git a/app/Mole/Sources/Mole/UninstallerService.swift b/app/Mole/Sources/Mole/UninstallerService.swift new file mode 100644 index 0000000..7efa68e --- /dev/null +++ b/app/Mole/Sources/Mole/UninstallerService.swift @@ -0,0 +1,130 @@ +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 = "" + + 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 { + self.apps = initialApps + } + + // B. Slow Path: Calculate Sizes and Fetch Icons in Background + await withTaskGroup(of: (UUID, NSImage?, String).self) { group in + for app in initialApps { + 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 = "Preparing to remove \(app.name)..." + } + + let containerPath = + FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first? + .appendingPathComponent("Containers").appendingPathComponent("com.example.\(app.name)") + .path ?? "~/Library/Containers/..." + + 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...", + ] + + 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) + } + + 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) + } + } + } + + 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) + } +}