diff --git a/.gitignore b/.gitignore index d87d753..254fd5a 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,10 @@ cmd/status/status /status mole-analyze # Note: bin/analyze-go and bin/status-go are released binaries and should be tracked + +# Swift / Xcode +.build/ +.swiftpm/ +*.xcodeproj +*.xcworkspace +DerivedData/ diff --git a/app/Mole/Package.swift b/app/Mole/Package.swift new file mode 100644 index 0000000..b47fb61 --- /dev/null +++ b/app/Mole/Package.swift @@ -0,0 +1,17 @@ +// 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" + ) + ] +) diff --git a/app/Mole/Sources/Mole/AppState.swift b/app/Mole/Sources/Mole/AppState.swift new file mode 100644 index 0000000..5fefdad --- /dev/null +++ b/app/Mole/Sources/Mole/AppState.swift @@ -0,0 +1,9 @@ +import SwiftUI + +enum AppState: Equatable { + case idle + case scanning + case results(size: String) + case cleaning + case done +} diff --git a/app/Mole/Sources/Mole/ContentView.swift b/app/Mole/Sources/Mole/ContentView.swift new file mode 100644 index 0000000..2162e79 --- /dev/null +++ b/app/Mole/Sources/Mole/ContentView.swift @@ -0,0 +1,193 @@ +import SwiftUI + +struct ContentView: View { + @State private var appState: AppState = .idle + @State private var logs: [String] = [] + + // Connect to Real Logic + @StateObject private var scanner = ScannerService() + + // The requested coffee/dark brown color + let deepBrown = Color(red: 0.17, green: 0.11, blue: 0.05) // #2C1C0E + + var body: some View { + ZStack { + // Background + Color.black.ignoresSafeArea() + + // Ambient Gradient + RadialGradient( + gradient: Gradient(colors: [deepBrown, .black]), + center: .center, + startRadius: 0, + endRadius: 600 + ) + .ignoresSafeArea() + + VStack(spacing: -10) { + Spacer() + + // The Mole (Interactive) + MoleView(state: $appState) + .onTapGesture { + handleMoleInteraction() + } + + // Status Area + ZStack { + // Logs overlay (visible during scanning/cleaning) + if case .scanning = appState { + LogView(logs: logs) + .transition(.opacity) + } else if case .cleaning = appState { + LogView(logs: logs) + .transition(.opacity) + } else { + // Standard Status Text + VStack(spacing: 24) { + statusText + + if case .idle = appState { + // Premium Button Style + Button(action: { + 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() + } + } + } + .transition(.opacity) + } + } + .frame(height: 100) + + Spacer() + } + } + .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) + } + } + } + } + + 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() + } + } + } + + 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) + + withAnimation { + appState = .results(size: sizeString) + } + } + } + + func startCleaning(size: String) { + withAnimation { + appState = .cleaning + logs.removeAll() + } + + Task { + await scanner.clean() + + withAnimation { + appState = .done + } + } + } +} diff --git a/app/Mole/Sources/Mole/LogView.swift b/app/Mole/Sources/Mole/LogView.swift new file mode 100644 index 0000000..1a2e295 --- /dev/null +++ b/app/Mole/Sources/Mole/LogView.swift @@ -0,0 +1,36 @@ +import SwiftUI + +struct LogView: View { + let logs: [String] + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + ForEach(Array(logs.suffix(5).enumerated()), id: \.offset) { index, log in + Text(log) + .font(.system(size: 12, weight: .regular, design: .monospaced)) + .foregroundStyle(.white.opacity(opacity(for: index, count: logs.suffix(5).count))) + .frame(maxWidth: .infinity, alignment: .center) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + .frame(height: 100) + .mask( + LinearGradient( + gradient: Gradient(stops: [ + .init(color: .clear, location: 0), + .init(color: .black, location: 0.2), + .init(color: .black, location: 0.8), + .init(color: .clear, location: 1.0) + ]), + startPoint: .top, + endPoint: .bottom + ) + ) + } + + func opacity(for index: Int, count: Int) -> Double { + // Newer items (higher index) are more opaque + let normalizedIndex = Double(index) / Double(max(count - 1, 1)) + return 0.3 + (normalizedIndex * 0.7) + } +} diff --git a/app/Mole/Sources/Mole/MoleApp.swift b/app/Mole/Sources/Mole/MoleApp.swift new file mode 100644 index 0000000..bce2002 --- /dev/null +++ b/app/Mole/Sources/Mole/MoleApp.swift @@ -0,0 +1,12 @@ +import SwiftUI + +@main +struct MoleApp: App { + var body: some Scene { + WindowGroup("Mole") { + ContentView() + } + .windowStyle(.hiddenTitleBar) + .windowResizability(.contentSize) + } +} diff --git a/app/Mole/Sources/Mole/MoleSceneView.swift b/app/Mole/Sources/Mole/MoleSceneView.swift new file mode 100644 index 0000000..effe9d9 --- /dev/null +++ b/app/Mole/Sources/Mole/MoleSceneView.swift @@ -0,0 +1,129 @@ +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 + + 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 // Delegate for Game Loop + scnView.isPlaying = true // Critical: Ensure the loop runs! + + // 1. The Planet (Sphere) + let sphereGeo = SCNSphere(radius: 1.4) + sphereGeo.segmentCount = 128 + + let sphereNode = SCNNode(geometry: sphereGeo) + sphereNode.name = "molePlanet" + + // Mars Material (Red/Dusty) + let material = SCNMaterial() + material.lightingModel = .physicallyBased + + // 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) + + // 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. + + material.diffuse.contents = NSColor(calibratedRed: 0.8, green: 0.25, blue: 0.1, alpha: 1.0) + + // Use noise for surface variation + material.roughness.contents = texture + + // 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 + + sphereNode.geometry?.materials = [material] + scene.rootNode.addChildNode(sphereNode) + + // 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) + + // 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) + + // 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) + + // 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) { + // 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 + } + + 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) + } + } +} diff --git a/app/Mole/Sources/Mole/MoleView.swift b/app/Mole/Sources/Mole/MoleView.swift new file mode 100644 index 0000000..cd1e2c8 --- /dev/null +++ b/app/Mole/Sources/Mole/MoleView.swift @@ -0,0 +1,74 @@ +import SceneKit +import SwiftUI + +struct MoleView: View { + @Binding var state: AppState + + @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 { + ZStack { + // Background Atmosphere (2D Glow remains for performance/look) + Circle() + .fill( + RadialGradient( + gradient: Gradient(colors: [ + Color(hue: 0.6, saturation: 0.8, brightness: 0.6).opacity(0.3), + Color.purple.opacity(0.1), + .clear, + ]), + center: .center, + startRadius: 80, + endRadius: 180 + ) + ) + .frame(width: 300, height: 300) + .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() + } + } + .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 + } + } + .scaleEffect(state == .cleaning ? 0.95 : 1.0) + .animation(.spring, value: state) + } +} diff --git a/app/Mole/Sources/Mole/ScannerService.swift b/app/Mole/Sources/Mole/ScannerService.swift new file mode 100644 index 0000000..58beb45 --- /dev/null +++ b/app/Mole/Sources/Mole/ScannerService.swift @@ -0,0 +1,104 @@ +import Foundation +import Combine + +class ScannerService: ObservableObject { + @Published var currentLog: String = "" + @Published var totalSize: Int64 = 0 + @Published var isScanning = false + + 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" + } + } + + // Clean Function (Moves items to Trash for safety in prototype) + func clean() async { + 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)") + } + } + } +}