mirror of
https://github.com/tw93/Mole.git
synced 2026-02-04 15:39:42 +00:00
chore: remove Mac client code and sync gitignore with main
This commit is contained in:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -51,10 +51,9 @@ GEMINI.md
|
||||
# Go build artifacts (development)
|
||||
cmd/analyze/analyze
|
||||
cmd/status/status
|
||||
/status
|
||||
/analyze
|
||||
mole-analyze
|
||||
# Note: bin/analyze-go and bin/status-go are released binaries and should be tracked
|
||||
# Go binaries
|
||||
bin/analyze-go
|
||||
bin/status-go
|
||||
bin/analyze-darwin-*
|
||||
bin/status-darwin-*
|
||||
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AppListView: View {
|
||||
let apps: [AppItem]
|
||||
var onSelect: (AppItem) -> Void
|
||||
var onDismiss: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Header
|
||||
HStack {
|
||||
Text("Installed Apps")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.white)
|
||||
Spacer()
|
||||
Button(action: onDismiss) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.gray)
|
||||
.font(.title2)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.keyboardShortcut(.escape, modifiers: [])
|
||||
}
|
||||
.padding()
|
||||
.background(Color.black.opacity(0.8))
|
||||
|
||||
// List
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
ForEach(apps) { app in
|
||||
HStack {
|
||||
if let icon = app.icon {
|
||||
Image(nsImage: icon)
|
||||
.resizable()
|
||||
.frame(width: 32, height: 32)
|
||||
} else {
|
||||
Image(systemName: "app")
|
||||
.resizable()
|
||||
.foregroundStyle(.gray)
|
||||
.frame(width: 32, height: 32)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(app.name)
|
||||
.foregroundStyle(.white)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
|
||||
Text(app.size)
|
||||
.foregroundStyle(.white.opacity(0.6))
|
||||
.font(.system(size: 11, weight: .regular))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: { onSelect(app) }) {
|
||||
Text("Uninstall")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Capsule().fill(Color(red: 1.0, green: 0.3, blue: 0.1).opacity(0.8)))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
.background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.05)))
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
.padding(.top)
|
||||
.padding(.bottom, 40)
|
||||
}
|
||||
}
|
||||
.background(Color.black.opacity(0.95))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||
.shadow(radius: 20)
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
enum AppMode: Equatable {
|
||||
case cleaner
|
||||
case uninstaller
|
||||
case optimizer // New Mode
|
||||
|
||||
// Reverting to tuple format for compatibility with SceneView if needed, or stick to Color?
|
||||
// Let's stick to Color for now but ContentView might need adjustment if it expected tuple?
|
||||
// Wait, previous file had (Double, Double, Double).
|
||||
// If I change it to Color, I break SceneView if it uses the tuple.
|
||||
// Checking SceneView usage...
|
||||
// SceneView uses `appMode.themeColor` to set `material.diffuse.contents` fallback or logic.
|
||||
// SceneView expects `(Double, Double, Double)` in `activeColor` binding?
|
||||
// I should check SceneView signature.
|
||||
// For safety, I will keep themeColor as Tuple OR add a new property.
|
||||
// Let's start by fixing the compilation error (Markdown fences).
|
||||
|
||||
var themeColor: (Double, Double, Double) {
|
||||
switch self {
|
||||
case .cleaner: return (0.45, 0.12, 0.05) // Deep Mars
|
||||
case .uninstaller: return (0.35, 0.35, 0.4) // Deep Moon
|
||||
case .optimizer: return (0.0, 0.2, 0.8) // Neptune Blue (RGB values approx)
|
||||
}
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .cleaner: return "Cleaner"
|
||||
case .uninstaller: return "Uninstaller"
|
||||
case .optimizer: return "Optimizer"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
enum AppState: Equatable {
|
||||
case idle
|
||||
case scanning
|
||||
case results(size: String)
|
||||
case cleaning
|
||||
case done
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
class AuthContext: ObservableObject {
|
||||
static let shared = AuthContext()
|
||||
@Published var needsPassword = false
|
||||
private(set) var password: String?
|
||||
|
||||
init() {
|
||||
// Auto-login from Keychain
|
||||
if let saved = KeychainHelper.shared.load() {
|
||||
self.password = saved
|
||||
}
|
||||
}
|
||||
|
||||
func setPassword(_ pass: String) {
|
||||
self.password = pass
|
||||
self.needsPassword = false
|
||||
// Persist
|
||||
KeychainHelper.shared.save(pass)
|
||||
}
|
||||
|
||||
func clear() {
|
||||
self.password = nil
|
||||
// Remove persistence
|
||||
KeychainHelper.shared.delete()
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ConfettiView: View {
|
||||
var colors: [Color] = [.red, .blue, .green, .yellow]
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
ForEach(0..<100, id: \.self) { i in
|
||||
ConfettiPiece(
|
||||
color: colors.randomElement() ?? .white,
|
||||
angle: .degrees(Double(i) * 360 / 100)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ConfettiPiece: View {
|
||||
let color: Color
|
||||
let angle: Angle
|
||||
|
||||
@State private var offset: CGFloat = 0
|
||||
@State private var opacity: Double = 1
|
||||
@State private var scale: CGFloat = 0.1
|
||||
// Randomize properties per piece
|
||||
let size: CGFloat = CGFloat.random(in: 3...9)
|
||||
|
||||
var body: some View {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: size, height: size)
|
||||
.scaleEffect(scale)
|
||||
.offset(x: offset)
|
||||
.rotationEffect(angle)
|
||||
.opacity(opacity)
|
||||
.onAppear {
|
||||
let duration = Double.random(in: 1.0...2.5)
|
||||
withAnimation(.easeOut(duration: duration)) {
|
||||
offset = CGFloat.random(in: 100...350)
|
||||
scale = 1.0
|
||||
opacity = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,383 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@State private var appState: AppState = .idle
|
||||
@State private var appMode: AppMode = .cleaner // New Mode State
|
||||
@State private var logs: [String] = []
|
||||
@State private var showAppList = false
|
||||
@State private var showCelebration = false
|
||||
@State private var celebrationColors: [Color] = []
|
||||
@State private var celebrationMessage: String = ""
|
||||
@Namespace private var animationNamespace
|
||||
|
||||
// Connect to Real Logic
|
||||
@StateObject private var scanner = ScannerService()
|
||||
@StateObject private var uninstaller = UninstallerService()
|
||||
@StateObject private var optimizer = OptimizerService()
|
||||
@ObservedObject var authContext = AuthContext.shared
|
||||
|
||||
// Mercury (Cleaner) - Dark Industrial Gray
|
||||
let mercuryColor = Color(red: 0.15, green: 0.15, blue: 0.18)
|
||||
|
||||
// Mars (Uninstaller) - Deep Red
|
||||
let marsColor = Color(red: 0.25, green: 0.08, blue: 0.05)
|
||||
|
||||
// Earth (Optimizer) - Deep Blue
|
||||
let earthColor = Color(red: 0.05, green: 0.1, blue: 0.25)
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Dynamic Background
|
||||
Color.black.ignoresSafeArea()
|
||||
|
||||
RadialGradient(
|
||||
gradient: Gradient(colors: [
|
||||
appMode == .cleaner ? mercuryColor : (appMode == .uninstaller ? marsColor : earthColor),
|
||||
.black,
|
||||
]),
|
||||
center: .center,
|
||||
startRadius: 0,
|
||||
endRadius: 600
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
.animation(.easeInOut(duration: 0.5), value: appMode)
|
||||
|
||||
// Custom Top Tab Bar
|
||||
VStack {
|
||||
TopBarView(
|
||||
appMode: $appMode, animationNamespace: animationNamespace, authContext: authContext
|
||||
)
|
||||
.padding(.top, 20)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
VStack(spacing: -10) {
|
||||
Spacer()
|
||||
|
||||
// The Mole (Interactive) & Draggable
|
||||
// The Mole (Interactive) & Draggable
|
||||
MoleView(
|
||||
state: $appState,
|
||||
appMode: $appMode,
|
||||
isRunning: scanner.isScanning || scanner.isCleaning || optimizer.isOptimizing
|
||||
|| uninstaller.isUninstalling
|
||||
)
|
||||
.gesture(
|
||||
DragGesture()
|
||||
.onEnded { value in
|
||||
if value.translation.width < -50 {
|
||||
withAnimation { appMode = .uninstaller }
|
||||
} else if value.translation.width > 50 {
|
||||
withAnimation { appMode = .cleaner }
|
||||
}
|
||||
}
|
||||
)
|
||||
.onHover { inside in
|
||||
if inside {
|
||||
NSCursor.pointingHand.set()
|
||||
} else {
|
||||
NSCursor.arrow.set()
|
||||
}
|
||||
}
|
||||
.opacity(showAppList ? 0.0 : 1.0) // Hide when list is open
|
||||
.animation(.easeInOut, value: showAppList)
|
||||
.padding(.top, 75) // Visual centering adjustment
|
||||
|
||||
Spacer() // Dynamic spacing
|
||||
|
||||
// Status Area
|
||||
ZStack {
|
||||
// Logs overlay
|
||||
if case .scanning = appState, appMode == .cleaner {
|
||||
LogView(logs: logs)
|
||||
.transition(.opacity)
|
||||
} else if case .cleaning = appState, appMode == .cleaner {
|
||||
LogView(logs: logs)
|
||||
.transition(.opacity)
|
||||
} else if appMode == .optimizer && optimizer.isOptimizing {
|
||||
LogView(logs: logs)
|
||||
.transition(.opacity)
|
||||
} else if appMode == .uninstaller && uninstaller.isUninstalling {
|
||||
LogView(logs: logs)
|
||||
.transition(.opacity)
|
||||
} else if showAppList {
|
||||
// Showing App List? No status text needed or handled by overlay
|
||||
EmptyView()
|
||||
} else {
|
||||
// Action Button
|
||||
Button(action: {
|
||||
if appMode == .cleaner {
|
||||
startSmartClean()
|
||||
} else if appMode == .uninstaller {
|
||||
handleUninstallerAction()
|
||||
} else {
|
||||
handleOptimizerAction()
|
||||
}
|
||||
}) {
|
||||
HStack(spacing: 8) {
|
||||
if scanner.isScanning || scanner.isCleaning || optimizer.isOptimizing
|
||||
|| uninstaller.isUninstalling
|
||||
{
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.tint(.black)
|
||||
}
|
||||
|
||||
Text("Mole")
|
||||
.font(.system(size: 14, weight: .bold, design: .monospaced))
|
||||
}
|
||||
.frame(minWidth: 140)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color.white)
|
||||
.foregroundStyle(.black)
|
||||
.clipShape(Capsule())
|
||||
.shadow(color: .white.opacity(0.2), radius: 10, x: 0, y: 0)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(
|
||||
scanner.isScanning || scanner.isCleaning || optimizer.isOptimizing
|
||||
|| uninstaller.isUninstalling
|
||||
)
|
||||
.onHover { inside in
|
||||
if inside { NSCursor.pointingHand.set() } else { NSCursor.arrow.set() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 100)
|
||||
.padding(.bottom, 30) // Anchor to bottom
|
||||
}
|
||||
|
||||
// App List Overlay
|
||||
if showAppList {
|
||||
AppListView(
|
||||
apps: uninstaller.apps,
|
||||
onSelect: { app in
|
||||
handleUninstall(app)
|
||||
},
|
||||
onDismiss: {
|
||||
withAnimation { showAppList = false }
|
||||
}
|
||||
)
|
||||
.frame(width: 400, height: 550)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
.zIndex(10)
|
||||
}
|
||||
|
||||
if showCelebration {
|
||||
VStack(spacing: 8) {
|
||||
ConfettiView(colors: celebrationColors)
|
||||
.offset(y: -50)
|
||||
|
||||
VStack(spacing: 4) {
|
||||
Text("Success!")
|
||||
.font(.system(size: 24, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.shadow(radius: 5)
|
||||
if !celebrationMessage.isEmpty {
|
||||
Text(celebrationMessage)
|
||||
.font(.system(size: 14, weight: .medium, design: .monospaced))
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
.shadow(radius: 5)
|
||||
}
|
||||
}
|
||||
.offset(y: 105)
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
.zIndex(100)
|
||||
.transition(.scale.combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 600, minHeight: 500)
|
||||
.onChange(of: scanner.currentLog) {
|
||||
if !scanner.currentLog.isEmpty {
|
||||
withAnimation(.spring) {
|
||||
if appMode == .cleaner {
|
||||
logs.append(scanner.currentLog)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: optimizer.currentLog) {
|
||||
if !optimizer.currentLog.isEmpty {
|
||||
withAnimation(.spring) {
|
||||
if appMode == .optimizer {
|
||||
logs.append(optimizer.currentLog)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: uninstaller.currentLog) {
|
||||
if !uninstaller.currentLog.isEmpty {
|
||||
withAnimation(.spring) {
|
||||
if appMode == .uninstaller {
|
||||
logs.append(uninstaller.currentLog)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $authContext.needsPassword) {
|
||||
PasswordSheetView(onUnlock: {
|
||||
// Unlock success implies AuthContext.password is set.
|
||||
// Services will use it on next attempt.
|
||||
})
|
||||
}
|
||||
.onChange(of: appMode) {
|
||||
appState = .idle
|
||||
logs.removeAll()
|
||||
showAppList = false
|
||||
showCelebration = false
|
||||
scanner.reset()
|
||||
optimizer.reset()
|
||||
uninstaller.reset()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
func startSmartClean() {
|
||||
withAnimation {
|
||||
appState = .scanning
|
||||
logs.removeAll()
|
||||
showCelebration = false // Dismiss old success
|
||||
}
|
||||
|
||||
Task {
|
||||
await scanner.startScan()
|
||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||
|
||||
await MainActor.run {
|
||||
if scanner.totalSize > 0 {
|
||||
startCleaning()
|
||||
} else {
|
||||
withAnimation {
|
||||
appState = .idle
|
||||
logs.removeAll()
|
||||
}
|
||||
triggerCelebration([.white], message: "Already Clean")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func startCleaning() {
|
||||
withAnimation {
|
||||
appState = .cleaning
|
||||
logs.removeAll()
|
||||
showCelebration = false
|
||||
}
|
||||
|
||||
Task {
|
||||
let cleanedBytes = await scanner.cleanSystem()
|
||||
withAnimation {
|
||||
appState = .done
|
||||
}
|
||||
|
||||
let mb = Double(cleanedBytes) / 1024.0 / 1024.0
|
||||
let msg =
|
||||
mb > 1024
|
||||
? String(format: "Cleaned %.1f GB", mb / 1024.0) : String(format: "Cleaned %.0f MB", mb)
|
||||
|
||||
triggerCelebration([.orange, .red, .yellow, .white], message: msg)
|
||||
}
|
||||
}
|
||||
|
||||
func handleUninstallerAction() {
|
||||
withAnimation {
|
||||
showAppList = true
|
||||
showCelebration = false
|
||||
}
|
||||
Task { await uninstaller.scanApps() }
|
||||
}
|
||||
|
||||
func handleUninstall(_ app: AppItem) {
|
||||
withAnimation {
|
||||
showAppList = false
|
||||
logs.removeAll()
|
||||
showCelebration = false
|
||||
}
|
||||
|
||||
Task {
|
||||
await uninstaller.uninstall(app)
|
||||
triggerCelebration(
|
||||
[.red, .orange, .yellow, .green, .blue, .purple, .pink, .mint],
|
||||
message: "Uninstalled \(app.name)")
|
||||
}
|
||||
}
|
||||
|
||||
func handleOptimizerAction() {
|
||||
showCelebration = false // Immediate dismiss
|
||||
Task {
|
||||
await optimizer.optimize()
|
||||
triggerCelebration([.cyan, .blue, .purple, .mint, .white], message: "Optimized")
|
||||
}
|
||||
}
|
||||
|
||||
func triggerCelebration(_ colors: [Color], message: String = "") {
|
||||
celebrationColors = colors
|
||||
celebrationMessage = message
|
||||
withAnimation(.spring(response: 0.5, dampingFraction: 0.6)) { showCelebration = true }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
|
||||
withAnimation { showCelebration = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TopBarView: View {
|
||||
@Binding var appMode: AppMode
|
||||
var animationNamespace: Namespace.ID
|
||||
@ObservedObject var authContext: AuthContext
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
HStack(spacing: 0) {
|
||||
TabBarButton(mode: .cleaner, appMode: $appMode, namespace: animationNamespace)
|
||||
TabBarButton(mode: .uninstaller, appMode: $appMode, namespace: animationNamespace)
|
||||
TabBarButton(mode: .optimizer, appMode: $appMode, namespace: animationNamespace)
|
||||
}
|
||||
.padding(4)
|
||||
.background(Capsule().fill(.ultraThinMaterial).opacity(0.3))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TabBarButton: View {
|
||||
let mode: AppMode
|
||||
@Binding var appMode: AppMode
|
||||
var namespace: Namespace.ID
|
||||
|
||||
var title: String {
|
||||
switch mode {
|
||||
case .cleaner: return "clean"
|
||||
case .uninstaller: return "uninstall"
|
||||
case .optimizer: return "optimize"
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: {
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
|
||||
appMode = mode
|
||||
}
|
||||
}) {
|
||||
Text(title)
|
||||
.font(.system(size: 12, weight: .bold, design: .monospaced))
|
||||
.foregroundStyle(appMode == mode ? .black : .white.opacity(0.6))
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 16)
|
||||
.background(
|
||||
ZStack {
|
||||
if appMode == mode {
|
||||
Capsule()
|
||||
.fill(Color.white)
|
||||
.matchedGeometryEffect(id: "TabHighlight", in: namespace)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.onHover { inside in
|
||||
if inside { NSCursor.pointingHand.set() } else { NSCursor.arrow.set() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import AppKit
|
||||
|
||||
extension Bundle {
|
||||
/// Loads an image from the bundle's resources by name.
|
||||
///
|
||||
/// This method attempts to load an image with the given name by trying
|
||||
/// common image file extensions in order: PNG, JPG, and ICNS.
|
||||
///
|
||||
/// - Parameter name: The name of the image resource without extension.
|
||||
/// - Returns: An NSImage if found, nil otherwise.
|
||||
///
|
||||
/// - Note: Supported formats: PNG, JPG, ICNS
|
||||
func image(forResource name: String) -> NSImage? {
|
||||
if let url = url(forResource: name, withExtension: "png") { return NSImage(contentsOf: url) }
|
||||
if let url = url(forResource: name, withExtension: "jpg") { return NSImage(contentsOf: url) }
|
||||
if let url = url(forResource: name, withExtension: "icns") { return NSImage(contentsOf: url) }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
// Note: Switched to File-based storage to avoid repetitive System Keychain prompts
|
||||
// during development (Unsigned Binary). Files are set to 600 layout (User Only).
|
||||
class KeychainHelper {
|
||||
static let shared = KeychainHelper()
|
||||
|
||||
private var storeURL: URL {
|
||||
let home = FileManager.default.homeDirectoryForCurrentUser
|
||||
return home.appendingPathComponent(".mole").appendingPathComponent(".key")
|
||||
}
|
||||
|
||||
func save(_ data: String) {
|
||||
let url = storeURL
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
try data.write(to: url, atomically: true, encoding: .utf8)
|
||||
// Set permissions to User Read/Write Only (600) for security
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
|
||||
} catch {
|
||||
print("Failed to save credentials: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func load() -> String? {
|
||||
do {
|
||||
return try String(contentsOf: storeURL, encoding: .utf8)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func delete() {
|
||||
try? FileManager.default.removeItem(at: storeURL)
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LogView: View {
|
||||
let logs: [String]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .center, spacing: 6) {
|
||||
ForEach(Array(logs.suffix(3).enumerated()), id: \.offset) { index, log in
|
||||
Text(log)
|
||||
.font(.system(size: 12, weight: .regular, design: .monospaced))
|
||||
.foregroundStyle(.white.opacity(opacity(for: index, count: logs.suffix(3).count)))
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.frame(height: 60)
|
||||
.mask(
|
||||
LinearGradient(
|
||||
gradient: Gradient(stops: [
|
||||
.init(color: .clear, location: 0),
|
||||
.init(color: .black, location: 0.3),
|
||||
.init(color: .black, location: 1.0),
|
||||
]),
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.offset(y: -25)
|
||||
}
|
||||
|
||||
func opacity(for index: Int, count: Int) -> Double {
|
||||
// Newer items (higher index) are more opaque
|
||||
let normalizedIndex = Double(index) / Double(max(count - 1, 1))
|
||||
return 0.3 + (normalizedIndex * 0.7)
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
NSApp.setActivationPolicy(.regular)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
NSApp.windows.first?.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
}
|
||||
|
||||
@main
|
||||
struct MoleApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup("Mole") {
|
||||
ContentView()
|
||||
}
|
||||
.windowStyle(.hiddenTitleBar)
|
||||
.windowResizability(.contentSize)
|
||||
}
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
import GameplayKit
|
||||
import QuartzCore
|
||||
import SceneKit
|
||||
import SwiftUI
|
||||
|
||||
// A native 3D SceneKit View
|
||||
struct MoleSceneView: NSViewRepresentable {
|
||||
@Binding var state: AppState
|
||||
@Binding var rotationVelocity: CGSize // Interaction Input
|
||||
var activeColor: (Double, Double, Double) // (Red, Green, Blue)
|
||||
var appMode: AppMode // Pass the mode
|
||||
var isRunning: Bool // Fast spin trigger
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(self)
|
||||
}
|
||||
|
||||
func makeNSView(context: Context) -> SCNView {
|
||||
let scnView = SCNView()
|
||||
|
||||
// Scene Setup
|
||||
let scene = SCNScene()
|
||||
scnView.scene = scene
|
||||
scnView.backgroundColor = NSColor.clear
|
||||
scnView.delegate = context.coordinator
|
||||
scnView.isPlaying = true
|
||||
|
||||
// 1. The Planet (Sphere)
|
||||
let sphereGeo = SCNSphere(radius: 1.4)
|
||||
sphereGeo.segmentCount = 192
|
||||
|
||||
// Atmosphere Shader removed strictly based on user feedback (No "layer" wanted)
|
||||
// sphereGeo.shaderModifiers = nil
|
||||
|
||||
let sphereNode = SCNNode(geometry: sphereGeo)
|
||||
sphereNode.name = "molePlanet"
|
||||
|
||||
// Material
|
||||
let material = SCNMaterial()
|
||||
material.lightingModel = .physicallyBased
|
||||
material.diffuse.contents = NSColor.gray // Placeholder
|
||||
|
||||
sphereNode.geometry?.materials = [material]
|
||||
scene.rootNode.addChildNode(sphereNode)
|
||||
|
||||
// 2. Lighting
|
||||
// A. Main Sun
|
||||
let sunLight = SCNNode()
|
||||
sunLight.light = SCNLight()
|
||||
sunLight.light?.type = .omni
|
||||
sunLight.light?.color = NSColor(calibratedWhite: 1.0, alpha: 1.0)
|
||||
sunLight.light?.intensity = 1350 // Reduced from 1500 for less glare
|
||||
sunLight.position = SCNVector3(x: 8, y: 5, z: 12)
|
||||
sunLight.light?.castsShadow = true
|
||||
scene.rootNode.addChildNode(sunLight)
|
||||
|
||||
// B. Rim Light
|
||||
let rimLight = SCNNode()
|
||||
rimLight.name = "rimLight"
|
||||
rimLight.light = SCNLight()
|
||||
rimLight.light?.type = .spot
|
||||
rimLight.light?.color = NSColor(calibratedRed: 0.8, green: 0.8, blue: 1.0, alpha: 1.0)
|
||||
rimLight.light?.intensity = 600
|
||||
rimLight.position = SCNVector3(x: -6, y: 3, z: -6)
|
||||
rimLight.look(at: SCNVector3Zero)
|
||||
scene.rootNode.addChildNode(rimLight)
|
||||
|
||||
// C. Ambient
|
||||
let ambientLight = SCNNode()
|
||||
ambientLight.light = SCNLight()
|
||||
ambientLight.light?.type = .ambient
|
||||
ambientLight.light?.intensity = 300 // Lifted from 150 to soften shadows
|
||||
ambientLight.light?.color = NSColor(white: 0.2, alpha: 1.0)
|
||||
scene.rootNode.addChildNode(ambientLight)
|
||||
|
||||
// Camera
|
||||
let cameraNode = SCNNode()
|
||||
cameraNode.camera = SCNCamera()
|
||||
cameraNode.position = SCNVector3(x: 0, y: 0, z: 4)
|
||||
scene.rootNode.addChildNode(cameraNode)
|
||||
|
||||
scnView.antialiasingMode = .multisampling4X
|
||||
scnView.allowsCameraControl = false
|
||||
|
||||
return scnView
|
||||
}
|
||||
|
||||
func updateNSView(_ scnView: SCNView, context: Context) {
|
||||
context.coordinator.parent = self
|
||||
|
||||
guard let scene = scnView.scene,
|
||||
scene.rootNode.childNode(withName: "molePlanet", recursively: false) != nil
|
||||
else { return }
|
||||
// Only update if mode changed to prevent expensive texture reloads
|
||||
if context.coordinator.currentMode != appMode {
|
||||
context.coordinator.currentMode = appMode
|
||||
|
||||
if let scene = scnView.scene,
|
||||
let planet = scene.rootNode.childNode(withName: "molePlanet", recursively: true),
|
||||
let material = planet.geometry?.firstMaterial
|
||||
{
|
||||
var textureName = "mars"
|
||||
var constRoughness: Double? = 0.9
|
||||
var rimColor = NSColor(calibratedRed: 1.0, green: 0.5, blue: 0.3, alpha: 1.0)
|
||||
|
||||
switch appMode {
|
||||
case .cleaner:
|
||||
textureName = "mercury"
|
||||
constRoughness = 0.6
|
||||
rimColor = NSColor(calibratedRed: 0.8, green: 0.8, blue: 0.9, alpha: 1.0)
|
||||
case .uninstaller:
|
||||
textureName = "mars"
|
||||
constRoughness = 0.9
|
||||
rimColor = NSColor(calibratedRed: 1.0, green: 0.3, blue: 0.1, alpha: 1.0)
|
||||
case .optimizer:
|
||||
textureName = "earth"
|
||||
constRoughness = 0.4
|
||||
rimColor = NSColor(calibratedRed: 0.2, green: 0.6, blue: 1.0, alpha: 1.0)
|
||||
}
|
||||
|
||||
// Load Texture (Support PNG and JPG)
|
||||
let finalImage = Bundle.module.image(forResource: textureName)
|
||||
|
||||
if let image = finalImage {
|
||||
material.diffuse.contents = image
|
||||
material.normal.contents = image
|
||||
material.normal.intensity = 1.0
|
||||
|
||||
if let r = constRoughness {
|
||||
material.roughness.contents = r
|
||||
} else {
|
||||
material.roughness.contents = image
|
||||
}
|
||||
material.emission.contents = NSColor.black
|
||||
} else {
|
||||
material.diffuse.contents = NSColor.gray
|
||||
}
|
||||
|
||||
if let rimLight = scene.rootNode.childNode(withName: "rimLight", recursively: false) {
|
||||
SCNTransaction.begin()
|
||||
SCNTransaction.animationDuration = 0.5
|
||||
rimLight.light?.color = rimColor
|
||||
SCNTransaction.commit()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, SCNSceneRendererDelegate {
|
||||
var parent: MoleSceneView
|
||||
var currentMode: AppMode? // Track current mode to avoid reloading textures
|
||||
|
||||
init(_ parent: MoleSceneView) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
// ... rest of coordinator
|
||||
|
||||
func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) {
|
||||
guard
|
||||
let planet = renderer.scene?.rootNode.childNode(withName: "molePlanet", recursively: false)
|
||||
else { return }
|
||||
|
||||
// Auto Rotation Speed
|
||||
// Slower, majestic rotation
|
||||
// Auto Rotation Speed
|
||||
// Slower, majestic rotation normally. Fast when working.
|
||||
let baseRotation = parent.isRunning ? 0.12 : 0.006
|
||||
|
||||
// Drag Influence
|
||||
let dragInfluence = Double(parent.rotationVelocity.width) * 0.0005
|
||||
|
||||
// Vertical Tilt (X-Axis) + Slow Restore to 0
|
||||
let tiltInfluence = Double(parent.rotationVelocity.height) * 0.0005
|
||||
|
||||
// Apply Rotation
|
||||
planet.eulerAngles.y += CGFloat(baseRotation + dragInfluence)
|
||||
planet.eulerAngles.x += CGFloat(tiltInfluence)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import SceneKit
|
||||
import SwiftUI
|
||||
|
||||
struct MoleView: View {
|
||||
@Binding var state: AppState
|
||||
@Binding var appMode: AppMode // New binding
|
||||
var isRunning: Bool // Fast Spin Trigger
|
||||
|
||||
@State private var dragVelocity = CGSize.zero
|
||||
|
||||
// We hold a SceneKit scene instance to manipulate it directly if needed, or let the Representable handle it.
|
||||
// To enable "Drag to Spin", we pass gesture data to the representable.
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { proxy in
|
||||
let minDim = min(proxy.size.width, proxy.size.height)
|
||||
// Tiers: Small (Default) -> Medium -> Large
|
||||
let planetSize: CGFloat = {
|
||||
if minDim < 600 { return 320 } else if minDim < 900 { return 450 } else { return 580 }
|
||||
}()
|
||||
|
||||
ZStack {
|
||||
// Background Atmosphere (2D Glow)
|
||||
Circle()
|
||||
.fill(
|
||||
RadialGradient(
|
||||
gradient: Gradient(colors: [
|
||||
Color(hue: 0.6, saturation: 0.8, brightness: 0.6).opacity(0.3),
|
||||
Color.purple.opacity(0.1),
|
||||
.clear,
|
||||
]),
|
||||
center: .center,
|
||||
startRadius: planetSize * 0.25,
|
||||
endRadius: planetSize * 0.56
|
||||
)
|
||||
)
|
||||
.frame(width: planetSize * 0.94, height: planetSize * 0.94)
|
||||
.blur(radius: 20)
|
||||
|
||||
// The 3D Scene
|
||||
MoleSceneView(
|
||||
state: $state, rotationVelocity: $dragVelocity, activeColor: appMode.themeColor,
|
||||
appMode: appMode,
|
||||
isRunning: isRunning
|
||||
)
|
||||
.frame(width: planetSize, height: planetSize)
|
||||
.mask(Circle())
|
||||
.contentShape(Circle()) // Ensure interaction only happens on the circle
|
||||
.onHover { inside in
|
||||
if inside {
|
||||
NSCursor.openHand.set()
|
||||
} else {
|
||||
NSCursor.arrow.set()
|
||||
}
|
||||
}
|
||||
.gesture(
|
||||
DragGesture()
|
||||
.onChanged { gesture in
|
||||
// Pass simplified velocity/delta for the Scene to rotate
|
||||
dragVelocity = CGSize(
|
||||
width: gesture.translation.width, height: gesture.translation.height)
|
||||
NSCursor.closedHand.set() // Grabbing effect
|
||||
}
|
||||
.onEnded { _ in
|
||||
dragVelocity = .zero // Resume auto-spin (handled in view)
|
||||
NSCursor.openHand.set() // Release grab
|
||||
}
|
||||
)
|
||||
}
|
||||
.scaleEffect(state == .cleaning ? 0.95 : 1.0)
|
||||
.position(x: proxy.size.width / 2, y: proxy.size.height / 2)
|
||||
}
|
||||
.animation(.spring, value: state)
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
class OptimizerService: ObservableObject {
|
||||
@Published var isOptimizing = false
|
||||
@Published var statusMessage = ""
|
||||
@Published var currentLog = ""
|
||||
|
||||
func reset() {
|
||||
self.currentLog = ""
|
||||
self.statusMessage = ""
|
||||
self.isOptimizing = false
|
||||
}
|
||||
|
||||
func optimize() async {
|
||||
await MainActor.run {
|
||||
self.isOptimizing = true
|
||||
self.statusMessage = "Optimizing..." // Removed "Authenticating..."
|
||||
}
|
||||
|
||||
// Helper for Session Auth
|
||||
func runPrivileged(_ command: String) async throws {
|
||||
if let pw = AuthContext.shared.password {
|
||||
do {
|
||||
_ = try await ShellRunner.shared.runSudo(command, password: pw)
|
||||
return
|
||||
} catch {
|
||||
print("Optimizer privilege error: \(error)")
|
||||
// Only clear password if it's an authentication failure
|
||||
if case ShellError.authenticationFailed = error {
|
||||
await MainActor.run { AuthContext.shared.clear() }
|
||||
|
||||
await MainActor.run {
|
||||
AuthContext.shared.needsPassword = true
|
||||
self.statusMessage = "Password Incorrect"
|
||||
}
|
||||
|
||||
struct AuthRequired: Error, LocalizedError {
|
||||
var errorDescription: String? { "Authentication Failed" }
|
||||
}
|
||||
throw AuthRequired()
|
||||
} else {
|
||||
// Command failed but password likely correct.
|
||||
// Do NOT clear password. Propagate error.
|
||||
print("Non-auth error in optimizer: \(error)")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no password, prompt via Custom Sheet
|
||||
await MainActor.run {
|
||||
AuthContext.shared.needsPassword = true
|
||||
self.statusMessage = "Waiting for Password..."
|
||||
}
|
||||
|
||||
// Abort execution until user authorizes
|
||||
struct AuthRequired: Error, LocalizedError {
|
||||
var errorDescription: String? { "Authorization Required" }
|
||||
}
|
||||
throw AuthRequired()
|
||||
}
|
||||
|
||||
let steps: [(String, () async throws -> Void)] = [
|
||||
(
|
||||
"Flushing DNS Cache...",
|
||||
{
|
||||
// Use full paths for robustness
|
||||
let cmd = "/usr/bin/dscacheutil -flushcache; /usr/bin/killall -HUP mDNSResponder"
|
||||
try await runPrivileged(cmd)
|
||||
}
|
||||
),
|
||||
(
|
||||
"Purging Inactive Memory...",
|
||||
{
|
||||
try await runPrivileged("/usr/sbin/purge")
|
||||
}
|
||||
),
|
||||
(
|
||||
"Rebuilding Launch Services...",
|
||||
{
|
||||
let lsregister =
|
||||
"/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister"
|
||||
// Best effort
|
||||
_ = try? await ShellRunner.shared.run(
|
||||
lsregister,
|
||||
arguments: ["-kill", "-r", "-domain", "local", "-domain", "system", "-domain", "user"])
|
||||
}
|
||||
),
|
||||
(
|
||||
"Resetting QuickLook...",
|
||||
{
|
||||
_ = try? await ShellRunner.shared.run("/usr/bin/qlmanage", arguments: ["-r", "cache"])
|
||||
_ = try? await ShellRunner.shared.run("/usr/bin/qlmanage", arguments: ["-r"])
|
||||
}
|
||||
),
|
||||
(
|
||||
"Restarting Finder...",
|
||||
{
|
||||
_ = try? await ShellRunner.shared.run("/usr/bin/killall", arguments: ["Finder"])
|
||||
}
|
||||
),
|
||||
]
|
||||
|
||||
for (desc, action) in steps {
|
||||
await MainActor.run {
|
||||
self.statusMessage = desc
|
||||
self.currentLog = "Running: \(desc)"
|
||||
}
|
||||
|
||||
do {
|
||||
try await action()
|
||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
// If user cancels osascript dialog, it throws error -128
|
||||
if error.localizedDescription.contains("User canceled") || "\(error)".contains("-128") {
|
||||
self.currentLog = "Optimization Cancelled by User"
|
||||
} else {
|
||||
self.currentLog = "Error: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
self.isOptimizing = false
|
||||
self.statusMessage = "System Optimized"
|
||||
self.currentLog = "Optimization Complete"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
// swift-tools-version: 5.9
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "Mole",
|
||||
platforms: [
|
||||
.macOS(.v14)
|
||||
],
|
||||
products: [
|
||||
.executable(name: "Mole", targets: ["Mole"])
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(
|
||||
name: "Mole",
|
||||
path: ".",
|
||||
exclude: ["Package.swift", "package.sh"],
|
||||
resources: [
|
||||
.process("Resources")
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -1,210 +0,0 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - NSVisualEffectView bridge (Liquid Glass / blur)
|
||||
struct VisualEffectBlur: NSViewRepresentable {
|
||||
var material: NSVisualEffectView.Material = .hudWindow
|
||||
var blendingMode: NSVisualEffectView.BlendingMode = .withinWindow
|
||||
var state: NSVisualEffectView.State = .active
|
||||
|
||||
func makeNSView(context: Context) -> NSVisualEffectView {
|
||||
let v = NSVisualEffectView()
|
||||
v.material = material
|
||||
v.blendingMode = blendingMode
|
||||
v.state = state
|
||||
v.wantsLayer = true
|
||||
return v
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSVisualEffectView, context: Context) {
|
||||
nsView.material = material
|
||||
nsView.blendingMode = blendingMode
|
||||
nsView.state = state
|
||||
}
|
||||
}
|
||||
|
||||
struct PasswordSheetView: View {
|
||||
@State private var passwordInput = ""
|
||||
@Environment(\.presentationMode) var presentationMode
|
||||
var onUnlock: () -> Void
|
||||
@FocusState private var isFocused: Bool
|
||||
|
||||
private var accent: Color { Color.accentColor }
|
||||
|
||||
var body: some View {
|
||||
dialogCard
|
||||
.frame(width: 280)
|
||||
// Attempt to clear window background for true glass effect
|
||||
.background(ClearBackgroundView())
|
||||
}
|
||||
|
||||
private var dialogCard: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
// Icon
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
if let icon = Bundle.module.image(forResource: "mole") {
|
||||
Image(nsImage: icon)
|
||||
.resizable()
|
||||
.interpolation(.high)
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 48, height: 48)
|
||||
.shadow(radius: 2)
|
||||
} else {
|
||||
Image(nsImage: NSApp.applicationIconImage)
|
||||
.resizable()
|
||||
.interpolation(.high)
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 48, height: 48)
|
||||
.shadow(radius: 2)
|
||||
}
|
||||
}
|
||||
.padding(.leading, 6)
|
||||
|
||||
// Title + message
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text("Mole is trying to make changes.")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text("Enter your password to allow this.")
|
||||
.font(.system(size: 12, weight: .regular))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
// Fields
|
||||
VStack(spacing: 12) {
|
||||
// Username field
|
||||
HStack {
|
||||
Text(NSUserName())
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(.primary.opacity(0.85))
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.frame(height: 36)
|
||||
.background(fieldBackground)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.stroke(.white.opacity(0.10), lineWidth: 1)
|
||||
)
|
||||
|
||||
// Password field
|
||||
ZStack(alignment: .leading) {
|
||||
SecureField("", text: $passwordInput)
|
||||
.textFieldStyle(.plain)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.focused($isFocused)
|
||||
.padding(.horizontal, 10)
|
||||
.frame(height: 36)
|
||||
.onSubmit(submit)
|
||||
|
||||
if passwordInput.isEmpty {
|
||||
Text("Password")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(.primary.opacity(0.35))
|
||||
.padding(.leading, 10)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
.background(fieldBackground)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.stroke(.white.opacity(0.10), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.padding(.top, 2)
|
||||
|
||||
// Buttons
|
||||
HStack(spacing: 12) {
|
||||
Button("Cancel") {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(.primary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 32)
|
||||
.background(buttonGlassBackground)
|
||||
.clipShape(Capsule())
|
||||
.keyboardShortcut(.cancelAction)
|
||||
|
||||
Button("Allow") {
|
||||
submit()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 32)
|
||||
.background(Color(nsColor: .controlAccentColor))
|
||||
.clipShape(Capsule())
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(passwordInput.isEmpty)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
.padding(20)
|
||||
.background(glassBackground)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||
.stroke(.white.opacity(0.18), lineWidth: 1)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.22), radius: 30, x: 0, y: 18)
|
||||
.onAppear {
|
||||
isFocused = true
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
}
|
||||
|
||||
private var glassBackground: some View {
|
||||
ZStack {
|
||||
VisualEffectBlur(material: .hudWindow, blendingMode: .withinWindow, state: .active)
|
||||
LinearGradient(
|
||||
colors: [.white.opacity(0.55), .white.opacity(0.22), .black.opacity(0.08)],
|
||||
startPoint: .top, endPoint: .bottom
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var fieldBackground: some View {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(.black.opacity(0.10))
|
||||
.background(
|
||||
VisualEffectBlur(material: .sidebar, blendingMode: .withinWindow, state: .active)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.opacity(0.45)
|
||||
)
|
||||
}
|
||||
|
||||
private var buttonGlassBackground: some View {
|
||||
ZStack {
|
||||
VisualEffectBlur(material: .sidebar, blendingMode: .withinWindow, state: .active)
|
||||
LinearGradient(
|
||||
colors: [.white.opacity(0.25), .black.opacity(0.06)],
|
||||
startPoint: .top, endPoint: .bottom
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func submit() {
|
||||
guard !passwordInput.isEmpty else { return }
|
||||
AuthContext.shared.setPassword(passwordInput)
|
||||
onUnlock()
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to reset window background for clean glass effect in sheets
|
||||
struct ClearBackgroundView: NSViewRepresentable {
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
let view = NSView()
|
||||
DispatchQueue.main.async {
|
||||
view.window?.backgroundColor = .clear
|
||||
view.window?.isOpaque = false
|
||||
}
|
||||
return view
|
||||
}
|
||||
func updateNSView(_ nsView: NSView, context: Context) {}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 452 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 869 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1014 KiB |
Binary file not shown.
@@ -1,205 +0,0 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
class ScannerService: ObservableObject {
|
||||
@Published var currentLog: String = ""
|
||||
@Published var totalSize: Int64 = 0
|
||||
@Published var isScanning = false
|
||||
@Published var isCleaning = false
|
||||
@Published var scanFinished = false
|
||||
|
||||
// Reset State
|
||||
func reset() {
|
||||
self.currentLog = ""
|
||||
self.scanFinished = false
|
||||
self.isScanning = false
|
||||
self.isCleaning = false
|
||||
self.totalSize = 0
|
||||
}
|
||||
|
||||
// User Paths (No Auth Needed)
|
||||
private var userPaths: [URL] = {
|
||||
let fileManager = FileManager.default
|
||||
let home = fileManager.homeDirectoryForCurrentUser
|
||||
let library = home.appendingPathComponent("Library")
|
||||
|
||||
return [
|
||||
fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first,
|
||||
library.appendingPathComponent("Logs"),
|
||||
library.appendingPathComponent("Developer/Xcode/DerivedData"),
|
||||
library.appendingPathComponent("Developer/Xcode/Archives"),
|
||||
library.appendingPathComponent("Developer/Xcode/iOS DeviceSupport"),
|
||||
library.appendingPathComponent("Developer/CoreSimulator/Caches"),
|
||||
].compactMap { $0 }
|
||||
}()
|
||||
|
||||
// System Paths (Auth Needed)
|
||||
private var systemPaths: [URL] = [
|
||||
URL(fileURLWithPath: "/Library/Caches"),
|
||||
URL(fileURLWithPath: "/Library/Logs"),
|
||||
]
|
||||
|
||||
// Scan Function
|
||||
func startScan() async {
|
||||
await MainActor.run {
|
||||
self.isScanning = true
|
||||
self.scanFinished = false
|
||||
self.totalSize = 0
|
||||
}
|
||||
|
||||
var calculatedSize: Int64 = 0
|
||||
let fileManager = FileManager.default
|
||||
|
||||
let allPaths = userPaths + systemPaths
|
||||
|
||||
for url in allPaths {
|
||||
if !fileManager.fileExists(atPath: url.path) { continue }
|
||||
|
||||
await MainActor.run {
|
||||
self.currentLog = "Scanning \(url.lastPathComponent)..."
|
||||
}
|
||||
|
||||
// Enumeration (Skip permission errors silently)
|
||||
if let enumerator = fileManager.enumerator(
|
||||
at: url, includingPropertiesForKeys: [.fileSizeKey],
|
||||
options: [.skipsHiddenFiles, .skipsPackageDescendants])
|
||||
{
|
||||
var counter = 0
|
||||
while let fileURL = enumerator.nextObject() as? URL {
|
||||
counter += 1
|
||||
if counter % 200 == 0 {
|
||||
let p = self.truncatePath(fileURL.path)
|
||||
await MainActor.run { self.currentLog = p }
|
||||
try? await Task.sleep(nanoseconds: 2_000_000)
|
||||
}
|
||||
|
||||
do {
|
||||
let resourceValues = try fileURL.resourceValues(forKeys: [.fileSizeKey])
|
||||
if let fileSize = resourceValues.fileSize {
|
||||
calculatedSize += Int64(fileSize)
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let finalSize = calculatedSize
|
||||
await MainActor.run {
|
||||
self.totalSize = finalSize
|
||||
self.isScanning = false
|
||||
self.scanFinished = true
|
||||
self.currentLog = "Scan Complete"
|
||||
}
|
||||
}
|
||||
|
||||
// Clean Function
|
||||
func cleanSystem() async -> Int64 {
|
||||
let startTime = Date()
|
||||
await MainActor.run {
|
||||
self.isCleaning = true
|
||||
}
|
||||
|
||||
var cleanedSize: Int64 = 0
|
||||
let fileManager = FileManager.default
|
||||
|
||||
// 1. Clean User Paths (Direct FileManager)
|
||||
for url in userPaths {
|
||||
if !fileManager.fileExists(atPath: url.path) { continue }
|
||||
await MainActor.run { self.currentLog = "Cleaning \(url.lastPathComponent)..." }
|
||||
|
||||
do {
|
||||
let contents = try fileManager.contentsOfDirectory(
|
||||
at: url, includingPropertiesForKeys: [.fileSizeKey])
|
||||
for fileUrl in contents {
|
||||
if fileUrl.lastPathComponent == "." || fileUrl.lastPathComponent == ".." { continue }
|
||||
|
||||
if let res = try? fileUrl.resourceValues(forKeys: [.fileSizeKey]), let s = res.fileSize {
|
||||
cleanedSize += Int64(s)
|
||||
}
|
||||
|
||||
try? fileManager.removeItem(at: fileUrl)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// 2. Clean System Paths (Batch Admin Command)
|
||||
// We construct a command that deletes the *contents* of these directories
|
||||
var adminCommands: [String] = []
|
||||
for url in systemPaths {
|
||||
if fileManager.fileExists(atPath: url.path) {
|
||||
// Safe check: Only standard paths
|
||||
if url.path == "/Library/Caches" || url.path == "/Library/Logs" {
|
||||
adminCommands.append("rm -rf \"\(url.path)\"/*")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !adminCommands.isEmpty {
|
||||
await MainActor.run { self.currentLog = "Authorizing System Cleanup..." }
|
||||
let fullCommand = adminCommands.joined(separator: "; ")
|
||||
|
||||
if let sessionPw = AuthContext.shared.password {
|
||||
do {
|
||||
_ = try await ShellRunner.shared.runSudo(fullCommand, password: sessionPw)
|
||||
} catch {
|
||||
print("Sudo command error: \(error)")
|
||||
print("Session password failed: \(error)")
|
||||
|
||||
if case ShellError.authenticationFailed = error {
|
||||
await MainActor.run {
|
||||
AuthContext.shared.clear()
|
||||
AuthContext.shared.needsPassword = true
|
||||
self.currentLog = "Password Incorrect/Expired"
|
||||
self.isCleaning = false
|
||||
}
|
||||
return 0
|
||||
}
|
||||
// Ignore other errors (e.g. command execution failed)
|
||||
// but continue the flow or handle gracefully without clearing password
|
||||
print("Non-auth error in cleanup: \(error)")
|
||||
await MainActor.run {
|
||||
self.currentLog = "Error: \(error.localizedDescription)"
|
||||
self.isCleaning = false
|
||||
}
|
||||
return 0
|
||||
}
|
||||
} else {
|
||||
// No password yet -> Prompt via Sheet
|
||||
await MainActor.run {
|
||||
AuthContext.shared.needsPassword = true
|
||||
self.currentLog = "Requires Authorization"
|
||||
self.isCleaning = false
|
||||
}
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure minimum duration for UX
|
||||
let elapsed = Date().timeIntervalSince(startTime)
|
||||
if elapsed < 1.0 {
|
||||
try? await Task.sleep(nanoseconds: UInt64((1.0 - elapsed) * 1_000_000_000))
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
self.isCleaning = false
|
||||
self.scanFinished = false
|
||||
self.totalSize = 0
|
||||
self.currentLog = "Cleaned"
|
||||
}
|
||||
|
||||
return cleanedSize
|
||||
}
|
||||
|
||||
private func truncatePath(_ path: String) -> String {
|
||||
let home = NSHomeDirectory()
|
||||
let short = path.replacingOccurrences(of: home, with: "~")
|
||||
if short.count > 45 {
|
||||
let start = short.prefix(15)
|
||||
let end = short.suffix(25)
|
||||
return "\(start)...\(end)"
|
||||
}
|
||||
return short
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum ShellError: Error, LocalizedError {
|
||||
case commandFailed(output: String)
|
||||
case executionError(error: Error)
|
||||
case authenticationFailed
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .commandFailed(let output): return output
|
||||
case .executionError(let error): return error.localizedDescription
|
||||
case .authenticationFailed: return "Authentication failed - incorrect password"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ShellRunner {
|
||||
static let shared = ShellRunner()
|
||||
|
||||
private init() {}
|
||||
|
||||
/// Runs a shell command as the current user
|
||||
func run(_ command: String, arguments: [String] = []) async throws -> String {
|
||||
let process = Process()
|
||||
let pipe = Pipe()
|
||||
let errorPipe = Pipe()
|
||||
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
|
||||
process.arguments = [command] + arguments
|
||||
process.standardOutput = pipe
|
||||
process.standardError = errorPipe
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
process.terminationHandler = { process in
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let output = String(data: data, encoding: .utf8) ?? ""
|
||||
|
||||
// Also capture error output
|
||||
let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let errorOutput = String(data: errorData, encoding: .utf8) ?? ""
|
||||
|
||||
if process.terminationStatus == 0 {
|
||||
continuation.resume(returning: output)
|
||||
} else {
|
||||
// Combine stdout and stderr for debugging
|
||||
continuation.resume(
|
||||
throwing: ShellError.commandFailed(output: output + "\n" + errorOutput))
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
continuation.resume(throwing: ShellError.executionError(error: error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a full shell command string (e.g. involving pipes or multiple args)
|
||||
func runShell(_ command: String) async throws -> String {
|
||||
return try await run("bash", arguments: ["-c", command])
|
||||
}
|
||||
|
||||
/// Runs a command with Administrator privileges using AppleScript
|
||||
/// Note: This will trigger the system permission dialog
|
||||
func runAdmin(_ command: String) async throws -> String {
|
||||
// Escape quotes and backslashes for AppleScript string to prevent syntax errors
|
||||
let escapedCommand =
|
||||
command
|
||||
.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
.replacingOccurrences(of: "\"", with: "\\\"")
|
||||
|
||||
let appleScript = "do shell script \"\(escapedCommand)\" with administrator privileges"
|
||||
return try await run("osascript", arguments: ["-e", appleScript])
|
||||
}
|
||||
|
||||
/// Runs a command with sudo using a provided password (via stdin)
|
||||
func runSudo(_ command: String, password: String) async throws -> String {
|
||||
let process = Process()
|
||||
let pipe = Pipe()
|
||||
let errorPipe = Pipe()
|
||||
let inputPipe = Pipe()
|
||||
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/sudo")
|
||||
// -S reads password from stdin, -p '' disables the prompt string
|
||||
// We wrap the actual command in bash -c to handle complex strings
|
||||
process.arguments = ["-S", "-p", "", "bash", "-c", command]
|
||||
process.standardOutput = pipe
|
||||
process.standardError = errorPipe
|
||||
process.standardInput = inputPipe
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
process.terminationHandler = { process in
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let output = String(data: data, encoding: .utf8) ?? ""
|
||||
|
||||
let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let errorOutput = String(data: errorData, encoding: .utf8) ?? ""
|
||||
|
||||
if process.terminationStatus == 0 {
|
||||
continuation.resume(returning: output)
|
||||
} else {
|
||||
// Check for password failure
|
||||
let combined = (output + errorOutput).lowercased()
|
||||
if combined.contains("try again") || combined.contains("incorrect") {
|
||||
continuation.resume(throwing: ShellError.authenticationFailed)
|
||||
} else {
|
||||
continuation.resume(
|
||||
throwing: ShellError.commandFailed(output: errorOutput.isEmpty ? output : errorOutput)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
// Write password to stdin
|
||||
if let passData = (password + "\n").data(using: .utf8) {
|
||||
try? inputPipe.fileHandleForWriting.write(contentsOf: passData)
|
||||
try? inputPipe.fileHandleForWriting.close()
|
||||
}
|
||||
} catch {
|
||||
continuation.resume(throwing: ShellError.executionError(error: error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Escapes a string for safe use in bash commands
|
||||
private func bashEscape(_ str: String) -> String {
|
||||
// Use single quotes and escape any single quotes within the string
|
||||
let escaped = str.replacingOccurrences(of: "'", with: "'\"'\"'")
|
||||
return "'\(escaped)'"
|
||||
}
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
struct AppItem: Identifiable, Equatable {
|
||||
let id = UUID()
|
||||
let name: String
|
||||
let url: URL
|
||||
let icon: NSImage?
|
||||
let size: String
|
||||
}
|
||||
|
||||
class UninstallerService: ObservableObject {
|
||||
@Published var apps: [AppItem] = []
|
||||
@Published var isUninstalling = false
|
||||
@Published var currentLog = ""
|
||||
|
||||
func reset() {
|
||||
self.currentLog = ""
|
||||
self.isUninstalling = false
|
||||
}
|
||||
|
||||
init() {
|
||||
// Prefetch on launch
|
||||
Task {
|
||||
await scanApps()
|
||||
}
|
||||
}
|
||||
|
||||
func scanApps() async {
|
||||
// If we already have data, don't block.
|
||||
if !apps.isEmpty { return }
|
||||
|
||||
let fileManager = FileManager.default
|
||||
let appsDir = URL(fileURLWithPath: "/Applications")
|
||||
|
||||
do {
|
||||
let fileURLs = try fileManager.contentsOfDirectory(
|
||||
at: appsDir, includingPropertiesForKeys: nil, options: .skipsHiddenFiles)
|
||||
|
||||
// A. Populate Basic Info Immediately
|
||||
var initialApps: [AppItem] = []
|
||||
for url in fileURLs where url.pathExtension == "app" {
|
||||
let name = url.deletingPathExtension().lastPathComponent
|
||||
initialApps.append(AppItem(name: name, url: url, icon: nil, size: ""))
|
||||
}
|
||||
initialApps.sort { $0.name < $1.name }
|
||||
|
||||
await MainActor.run { [initialApps] in
|
||||
self.apps = initialApps
|
||||
}
|
||||
|
||||
// B. Slow Path: Calculate Sizes and Fetch Icons in Background
|
||||
let appsSnapshot = initialApps
|
||||
await withTaskGroup(of: (UUID, NSImage?, String).self) { group in
|
||||
for app in appsSnapshot {
|
||||
group.addTask { [app] in
|
||||
// Fetch Icon
|
||||
let icon = NSWorkspace.shared.icon(forFile: app.url.path)
|
||||
// Calculate Size
|
||||
let size = self.calculateSize(for: app.url)
|
||||
return (app.id, icon, size)
|
||||
}
|
||||
}
|
||||
|
||||
for await (id, icon, sizeStr) in group {
|
||||
await MainActor.run {
|
||||
if let index = self.apps.firstIndex(where: { $0.id == id }) {
|
||||
let old = self.apps[index]
|
||||
self.apps[index] = AppItem(name: old.name, url: old.url, icon: icon, size: sizeStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch {
|
||||
print("Error scanning apps: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func uninstall(_ app: AppItem) async {
|
||||
await MainActor.run {
|
||||
self.isUninstalling = true
|
||||
self.currentLog = "Analyzing \(app.name)..."
|
||||
}
|
||||
|
||||
let fileManager = FileManager.default
|
||||
|
||||
// 1. Get Bundle ID
|
||||
var bundleID: String?
|
||||
if let bundle = Bundle(url: app.url) {
|
||||
bundleID = bundle.bundleIdentifier
|
||||
}
|
||||
|
||||
// Fallback if Bundle init fails
|
||||
if bundleID == nil {
|
||||
let plistUrl = app.url.appendingPathComponent("Contents/Info.plist")
|
||||
if let data = try? Data(contentsOf: plistUrl),
|
||||
let plist = try? PropertyListSerialization.propertyList(
|
||||
from: data, options: [], format: nil) as? [String: Any]
|
||||
{
|
||||
bundleID = plist["CFBundleIdentifier"] as? String
|
||||
}
|
||||
}
|
||||
|
||||
var itemsToRemove: [URL] = []
|
||||
|
||||
// 2. Find Related Files
|
||||
if let bid = bundleID {
|
||||
let home = FileManager.default.homeDirectoryForCurrentUser
|
||||
let library = home.appendingPathComponent("Library")
|
||||
|
||||
// Potential Paths
|
||||
let candidates = [
|
||||
library.appendingPathComponent("Application Support/\(bid)"),
|
||||
library.appendingPathComponent("Caches/\(bid)"),
|
||||
library.appendingPathComponent("Preferences/\(bid).plist"),
|
||||
library.appendingPathComponent("Saved Application State/\(bid).savedState"),
|
||||
library.appendingPathComponent("Containers/\(bid)"),
|
||||
library.appendingPathComponent("WebKit/\(bid)"),
|
||||
library.appendingPathComponent("LaunchAgents/\(bid).plist"),
|
||||
library.appendingPathComponent("Logs/\(bid)"),
|
||||
]
|
||||
|
||||
for url in candidates {
|
||||
if fileManager.fileExists(atPath: url.path) {
|
||||
itemsToRemove.append(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add App itself
|
||||
itemsToRemove.append(app.url)
|
||||
|
||||
// 3. Remove Items
|
||||
for item in itemsToRemove {
|
||||
await MainActor.run {
|
||||
let path = item.path.replacingOccurrences(of: NSHomeDirectory(), with: "~")
|
||||
self.currentLog = "Removing \(path)..."
|
||||
}
|
||||
|
||||
do {
|
||||
try fileManager.trashItem(at: item, resultingItemURL: nil)
|
||||
try? await Task.sleep(nanoseconds: 100_000_000) // 0.1s for visual feedback
|
||||
} catch {
|
||||
print("Failed to trash \(item.path): \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
self.isUninstalling = false
|
||||
self.currentLog = "Uninstalled \(app.name)"
|
||||
if let idx = self.apps.firstIndex(of: app) {
|
||||
self.apps.remove(at: idx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func calculateSize(for url: URL) -> String {
|
||||
guard
|
||||
let enumerator = FileManager.default.enumerator(
|
||||
at: url, includingPropertiesForKeys: [.fileSizeKey])
|
||||
else { return "Unknown" }
|
||||
var totalSize: Int64 = 0
|
||||
for case let fileURL as URL in enumerator {
|
||||
do {
|
||||
let resourceValues = try fileURL.resourceValues(forKeys: [.fileSizeKey])
|
||||
if let fileSize = resourceValues.fileSize {
|
||||
totalSize += Int64(fileSize)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return ByteCountFormatter.string(fromByteCount: totalSize, countStyle: .file)
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Configuration
|
||||
APP_NAME="Mole"
|
||||
# Get the actual build path dynamically
|
||||
BUILD_PATH="$(swift build -c release --show-bin-path)/$APP_NAME"
|
||||
APP_BUNDLE="$APP_NAME.app"
|
||||
ICON_SOURCE="Resources/mole.icns"
|
||||
|
||||
echo "🚀 Building Release Binary..."
|
||||
swift build -c release
|
||||
|
||||
echo "📦 Creating App Bundle Structure..."
|
||||
rm -rf "$APP_BUNDLE"
|
||||
mkdir -p "$APP_BUNDLE/Contents/MacOS"
|
||||
mkdir -p "$APP_BUNDLE/Contents/Resources"
|
||||
|
||||
echo "📄 Copying Executable..."
|
||||
cp "$BUILD_PATH" "$APP_BUNDLE/Contents/MacOS/"
|
||||
|
||||
echo "📝 Generatign Info.plist..."
|
||||
cat <<EOF > "$APP_BUNDLE/Contents/Info.plist"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$APP_NAME</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.tw93.mole</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$APP_NAME</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>AppIcon</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>13.0</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
if [ -f "$ICON_SOURCE" ]; then
|
||||
echo "🎨 Copying App Icon from $ICON_SOURCE..."
|
||||
cp "$ICON_SOURCE" "$APP_BUNDLE/Contents/Resources/AppIcon.icns"
|
||||
echo "✅ App Icon set successfully."
|
||||
else
|
||||
echo "⚠️ Icon file not found at $ICON_SOURCE. App will use default icon."
|
||||
fi
|
||||
|
||||
# Remove xattr com.apple.quarantine to avoid warnings
|
||||
xattr -cr "$APP_BUNDLE"
|
||||
|
||||
echo "✅ App Packaged: $APP_BUNDLE"
|
||||
echo "👉 You can now move $APP_NAME.app to /Applications"
|
||||
Reference in New Issue
Block a user