mirror of
https://github.com/tw93/Mole.git
synced 2026-02-16 21:04:11 +00:00
feat: introduce initial Mole application with 3D interactive planet, scanning, cleaning, and logging functionalities.
This commit is contained in:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -49,3 +49,10 @@ cmd/status/status
|
|||||||
/status
|
/status
|
||||||
mole-analyze
|
mole-analyze
|
||||||
# Note: bin/analyze-go and bin/status-go are released binaries and should be tracked
|
# Note: bin/analyze-go and bin/status-go are released binaries and should be tracked
|
||||||
|
|
||||||
|
# Swift / Xcode
|
||||||
|
.build/
|
||||||
|
.swiftpm/
|
||||||
|
*.xcodeproj
|
||||||
|
*.xcworkspace
|
||||||
|
DerivedData/
|
||||||
|
|||||||
17
app/Mole/Package.swift
Normal file
17
app/Mole/Package.swift
Normal file
@@ -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"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
9
app/Mole/Sources/Mole/AppState.swift
Normal file
9
app/Mole/Sources/Mole/AppState.swift
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
enum AppState: Equatable {
|
||||||
|
case idle
|
||||||
|
case scanning
|
||||||
|
case results(size: String)
|
||||||
|
case cleaning
|
||||||
|
case done
|
||||||
|
}
|
||||||
193
app/Mole/Sources/Mole/ContentView.swift
Normal file
193
app/Mole/Sources/Mole/ContentView.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
36
app/Mole/Sources/Mole/LogView.swift
Normal file
36
app/Mole/Sources/Mole/LogView.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
12
app/Mole/Sources/Mole/MoleApp.swift
Normal file
12
app/Mole/Sources/Mole/MoleApp.swift
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct MoleApp: App {
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup("Mole") {
|
||||||
|
ContentView()
|
||||||
|
}
|
||||||
|
.windowStyle(.hiddenTitleBar)
|
||||||
|
.windowResizability(.contentSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
129
app/Mole/Sources/Mole/MoleSceneView.swift
Normal file
129
app/Mole/Sources/Mole/MoleSceneView.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
74
app/Mole/Sources/Mole/MoleView.swift
Normal file
74
app/Mole/Sources/Mole/MoleView.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
104
app/Mole/Sources/Mole/ScannerService.swift
Normal file
104
app/Mole/Sources/Mole/ScannerService.swift
Normal file
@@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user