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