Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions MakLock.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
A10000000000000000000A01 /* IdleMonitorService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000A11 /* IdleMonitorService.swift */; };
A10000000000000000000B01 /* SleepWakeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000B11 /* SleepWakeService.swift */; };
A10000000000000000000C01 /* WatchProximityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000C11 /* WatchProximityService.swift */; };
A10000000000000000001301 /* ExternalDriveService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000001311 /* ExternalDriveService.swift */; };
A10000000000000000000D01 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000D11 /* OnboardingView.swift */; };
A10000000000000000000D02 /* OnboardingWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000D12 /* OnboardingWindow.swift */; };
A10000000000000000000E01 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000E11 /* AboutView.swift */; };
Expand Down Expand Up @@ -100,6 +101,7 @@
A10000000000000000000A11 /* IdleMonitorService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdleMonitorService.swift; sourceTree = "<group>"; };
A10000000000000000000B11 /* SleepWakeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SleepWakeService.swift; sourceTree = "<group>"; };
A10000000000000000000C11 /* WatchProximityService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchProximityService.swift; sourceTree = "<group>"; };
A10000000000000000001311 /* ExternalDriveService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExternalDriveService.swift; sourceTree = "<group>"; };
A10000000000000000000D11 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
A10000000000000000000D12 /* OnboardingWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingWindow.swift; sourceTree = "<group>"; };
A10000000000000000000E11 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -181,6 +183,7 @@
isa = PBXGroup;
children = (
A10000000000000000000611 /* AppMonitorService.swift */,
A10000000000000000001311 /* ExternalDriveService.swift */,
A10000000000000000000813 /* OverlayWindowService.swift */,
A10000000000000000000911 /* AuthenticationService.swift */,
A10000000000000000000A11 /* IdleMonitorService.swift */,
Expand Down Expand Up @@ -425,6 +428,7 @@
A10000000000000000000A01 /* IdleMonitorService.swift in Sources */,
A10000000000000000000B01 /* SleepWakeService.swift in Sources */,
A10000000000000000000C01 /* WatchProximityService.swift in Sources */,
A10000000000000000001301 /* ExternalDriveService.swift in Sources */,
A10000000000000000000D01 /* OnboardingView.swift in Sources */,
A10000000000000000000D02 /* OnboardingWindow.swift in Sources */,
A10000000000000000000E01 /* AboutView.swift in Sources */,
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Binary file not shown.
8 changes: 8 additions & 0 deletions MakLock/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
Self.sendUnlockNotification(appName: appName)
}

OverlayWindowService.shared.onCancelled = { [weak self] in
self?.menuBarController.iconState = .idle
}

// Start Watch proximity BEFORE app monitoring so Watch has time to connect.
// This prevents false overlay triggers on app restart.
if Defaults.shared.appSettings.useWatchUnlock {
Expand Down Expand Up @@ -126,6 +130,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {

var showedOverlay = false
for app in apps {
guard AppMonitorService.shared.shouldLockAppUnderCurrentConditions(app.bundleIdentifier) else {
continue
}

if app.autoClose && !SafetyManager.isBlacklisted(app.bundleIdentifier) {
if let running = NSWorkspace.shared.runningApplications.first(where: {
$0.bundleIdentifier == app.bundleIdentifier
Expand Down
34 changes: 34 additions & 0 deletions MakLock/Core/Services/AppMonitorService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ final class AppMonitorService: ObservableObject {
guard !authenticatedApps.contains(bundleID) else { continue }
guard !pendingLockBundleIDs.contains(bundleID) else { continue }
guard !OverlayWindowService.shared.isShowing else { continue }
guard shouldLockAppUnderCurrentConditions(bundleID) else { continue }

NSLog("[MakLock] Found running protected app: %@ (%@)", protectedApp.name, bundleID)
detectedApp = protectedApp
Expand Down Expand Up @@ -143,11 +144,41 @@ final class AppMonitorService: ObservableObject {
authenticatedApps.remove(bundleIdentifier)
}

/// Clear pending lock state for a specific app.
/// Used when user dismisses overlay without authenticating.
func clearPendingLock(for bundleIdentifier: String) {
pendingLockBundleIDs.remove(bundleIdentifier)
}

/// Check if an app is currently authenticated.
func isAuthenticated(_ bundleIdentifier: String) -> Bool {
authenticatedApps.contains(bundleIdentifier)
}

/// Returns whether lock logic should currently apply to this app.
/// This enforces optional "lock only when selected SSD is disconnected" behavior.
func shouldLockAppUnderCurrentConditions(_ bundleIdentifier: String) -> Bool {
let settings = Defaults.shared.appSettings
guard settings.useExternalSSDCondition else { return true }

let selectedAppBundleIDs = Set(settings.effectiveSsdConditionAppBundleIdentifiers)
guard !selectedAppBundleIDs.isEmpty else {
return true
}

// SSD condition is per-app. Non-selected protected apps use default locking.
guard selectedAppBundleIDs.contains(bundleIdentifier) else { return true }

// If selected apps have no SSD configured yet, keep default locking behavior.
guard let selectedVolumeUUID = settings.ssdConditionVolumeUUID,
!selectedVolumeUUID.isEmpty else {
return true
}

let isSelectedVolumeConnected = ExternalDriveService.shared.isVolumeConnected(uuid: selectedVolumeUUID)
return !isSelectedVolumeConnected
}

/// Check if an app has any normal-level windows (layer 0).
/// Returns false when an app was Cmd+Q'd but its process stayed alive.
private func appHasWindows(_ app: NSRunningApplication) -> Bool {
Expand Down Expand Up @@ -183,6 +214,9 @@ final class AppMonitorService: ObservableObject {
let settings = Defaults.shared.appSettings
guard settings.isProtectionEnabled else { return }

// Apply optional external SSD condition gating.
guard shouldLockAppUnderCurrentConditions(bundleID) else { return }

// Skip if app is already authenticated in this session
guard !authenticatedApps.contains(bundleID) else { return }

Expand Down
52 changes: 52 additions & 0 deletions MakLock/Core/Services/ExternalDriveService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import Foundation

/// Represents a currently mounted external volume.
struct ExternalVolume: Identifiable, Hashable {
var id: String { uuid }

let uuid: String
let name: String
let mountPath: String
}

/// Enumerates mounted external volumes and checks their connection status.
final class ExternalDriveService {
static let shared = ExternalDriveService()

private init() {}

private let resourceKeys: Set<URLResourceKey> = [
.volumeUUIDStringKey,
.volumeNameKey,
.volumeLocalizedNameKey,
.volumeIsInternalKey,
.volumeIsLocalKey
]

func listMountedExternalVolumes() -> [ExternalVolume] {
guard let urls = FileManager.default.mountedVolumeURLs(
includingResourceValuesForKeys: Array(resourceKeys),
options: [.skipHiddenVolumes]
) else {
return []
}

return urls.compactMap { url in
guard let values = try? url.resourceValues(forKeys: resourceKeys) else { return nil }
guard (values.volumeIsLocal ?? true) else { return nil }
guard (values.volumeIsInternal ?? true) == false else { return nil }
guard let uuid = values.volumeUUIDString, !uuid.isEmpty else { return nil }

let name = values.volumeLocalizedName ?? values.volumeName ?? url.lastPathComponent
return ExternalVolume(uuid: uuid, name: name, mountPath: url.path)
}
.sorted { lhs, rhs in
lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
}
}

func isVolumeConnected(uuid: String) -> Bool {
guard !uuid.isEmpty else { return false }
return listMountedExternalVolumes().contains(where: { $0.uuid == uuid })
}
}
50 changes: 50 additions & 0 deletions MakLock/Core/Services/OverlayWindowService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ final class OverlayWindowService {
/// Passes the name of the unlocked app.
var onUnlocked: ((String) -> Void)?

/// Callback when overlay is dismissed without unlocking (user chose Go Back).
var onCancelled: (() -> Void)?

private init() {
// Observe screen configuration changes (connect/disconnect monitors)
NotificationCenter.default.addObserver(
Expand Down Expand Up @@ -73,6 +76,29 @@ final class OverlayWindowService {
hide()
}

/// Dismiss overlay without authenticating and return user away from the locked app.
func cancelAndReturn() {
stopTimeoutTimer()

// Cancel any in-progress Touch ID evaluation
AuthenticationService.shared.cancelAuthentication()

let bundleID = currentApp?.bundleIdentifier

overlayWindows.forEach { $0.close() }
overlayWindows.removeAll()
currentApp = nil

if let bundleID {
AppMonitorService.shared.clearPendingLock(for: bundleID)
hideRunningApp(bundleIdentifier: bundleID)
}

activateFinder()
onCancelled?()
NSLog("[MakLock] Overlay dismissed via Go Back")
}

/// Whether an overlay is currently displayed.
var isShowing: Bool {
!overlayWindows.isEmpty
Expand Down Expand Up @@ -137,6 +163,9 @@ final class OverlayWindowService {
let name = self?.currentApp?.name ?? "app"
self?.hide()
self?.onUnlocked?(name)
},
onGoBack: { [weak self] in
self?.cancelAndReturn()
}
)
window.contentView = NSHostingView(rootView: overlayView)
Expand All @@ -163,6 +192,9 @@ final class OverlayWindowService {
let name = self?.currentApp?.name ?? "app"
self?.hide()
self?.onUnlocked?(name)
},
onGoBack: { [weak self] in
self?.cancelAndReturn()
}
)

Expand All @@ -186,6 +218,24 @@ final class OverlayWindowService {
NSLog("[MakLock] Activated app: %@", bundleIdentifier)
}

private func hideRunningApp(bundleIdentifier: String) {
guard let app = NSWorkspace.shared.runningApplications.first(where: { $0.bundleIdentifier == bundleIdentifier }) else {
return
}
app.hide()
}

private func activateFinder() {
if let finder = NSWorkspace.shared.runningApplications.first(where: {
$0.bundleIdentifier == "com.apple.finder"
}) {
finder.activate()
return
}

NSWorkspace.shared.open(URL(fileURLWithPath: NSHomeDirectory()))
}

// MARK: - Timeout

private func startTimeoutTimer() {
Expand Down
73 changes: 73 additions & 0 deletions MakLock/Models/AppSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,77 @@ struct AppSettings: Codable {

/// Launch MakLock at login.
var launchAtLogin: Bool = false

/// Enable conditional locking based on external SSD presence.
/// When enabled, MakLock only locks one selected protected app,
/// and only when the selected external SSD is not connected.
var useExternalSSDCondition: Bool = false

/// Bundle identifier of the app controlled by the SSD condition.
/// Deprecated legacy single-app key kept for migration.
var ssdConditionAppBundleIdentifier: String?

/// Bundle identifiers of apps controlled by the SSD condition.
var ssdConditionAppBundleIdentifiers: [String] = []

/// UUID of the external SSD volume used as the lock condition.
var ssdConditionVolumeUUID: String?

/// Last known display name of the selected SSD (for UI context).
var ssdConditionVolumeName: String?

enum CodingKeys: String, CodingKey {
case isProtectionEnabled
case lockOnSleep
case lockOnIdle
case idleTimeoutMinutes
case requireAuthOnLaunch
case requireAuthOnActivate
case useWatchUnlock
case watchRssiThreshold
case inactiveCloseMinutes
case launchAtLogin
case useExternalSSDCondition
case ssdConditionAppBundleIdentifier
case ssdConditionAppBundleIdentifiers
case ssdConditionVolumeUUID
case ssdConditionVolumeName
}

init() {}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
isProtectionEnabled = try container.decodeIfPresent(Bool.self, forKey: .isProtectionEnabled) ?? true
lockOnSleep = try container.decodeIfPresent(Bool.self, forKey: .lockOnSleep) ?? true
lockOnIdle = try container.decodeIfPresent(Bool.self, forKey: .lockOnIdle) ?? false
idleTimeoutMinutes = try container.decodeIfPresent(Int.self, forKey: .idleTimeoutMinutes) ?? 5
requireAuthOnLaunch = try container.decodeIfPresent(Bool.self, forKey: .requireAuthOnLaunch) ?? true
requireAuthOnActivate = try container.decodeIfPresent(Bool.self, forKey: .requireAuthOnActivate) ?? false
useWatchUnlock = try container.decodeIfPresent(Bool.self, forKey: .useWatchUnlock) ?? false
watchRssiThreshold = try container.decodeIfPresent(Int.self, forKey: .watchRssiThreshold) ?? -70
inactiveCloseMinutes = try container.decodeIfPresent(Int.self, forKey: .inactiveCloseMinutes) ?? 15
launchAtLogin = try container.decodeIfPresent(Bool.self, forKey: .launchAtLogin) ?? false
useExternalSSDCondition = try container.decodeIfPresent(Bool.self, forKey: .useExternalSSDCondition) ?? false
ssdConditionAppBundleIdentifier = try container.decodeIfPresent(String.self, forKey: .ssdConditionAppBundleIdentifier)
ssdConditionAppBundleIdentifiers = try container.decodeIfPresent([String].self, forKey: .ssdConditionAppBundleIdentifiers) ?? []
if ssdConditionAppBundleIdentifiers.isEmpty,
let legacyBundleID = ssdConditionAppBundleIdentifier,
!legacyBundleID.isEmpty {
ssdConditionAppBundleIdentifiers = [legacyBundleID]
}
ssdConditionVolumeUUID = try container.decodeIfPresent(String.self, forKey: .ssdConditionVolumeUUID)
ssdConditionVolumeName = try container.decodeIfPresent(String.self, forKey: .ssdConditionVolumeName)
}

/// Effective SSD-conditioned app selection, including legacy fallback.
var effectiveSsdConditionAppBundleIdentifiers: [String] {
if !ssdConditionAppBundleIdentifiers.isEmpty {
return Array(Set(ssdConditionAppBundleIdentifiers))
}
if let legacyBundleID = ssdConditionAppBundleIdentifier, !legacyBundleID.isEmpty {
return [legacyBundleID]
}
return []
}
}
9 changes: 8 additions & 1 deletion MakLock/UI/LockOverlay/LockOverlayView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ struct LockOverlayView: View {
let bundleIdentifier: String
let isPrimary: Bool
let onDismiss: () -> Void
let onGoBack: () -> Void

@State private var isVisible = false
@State private var showPasswordInput = false
Expand Down Expand Up @@ -34,7 +35,7 @@ struct LockOverlayView: View {
onDismiss()
},
onCancel: {
showPasswordInput = false
onGoBack()
}
)
.transition(.opacity)
Expand Down Expand Up @@ -79,6 +80,12 @@ struct LockOverlayView: View {
}
}

Button("Go Back") {
onGoBack()
}
.font(MakLockTypography.caption)
.foregroundColor(MakLockColors.textSecondary)

// Dev mode skip button
#if DEBUG
Button("Skip (Dev)") {
Expand Down
Loading