diff --git a/.go/progress.md b/.go/progress.md new file mode 100644 index 00000000..a3196fd9 --- /dev/null +++ b/.go/progress.md @@ -0,0 +1,78 @@ +# Go Run — 2026-03-16 + +## Summary +Clean Swift rewrite of SelfControl → Stone. **31 Swift files, ~3,650 lines.** All three targets compile with zero errors and zero warnings. + +Branch: `swift-rewrite` (3 commits ahead of master) + +## What was built + +### Project Infrastructure +- XcodeGen-based project with 3 targets: Stone (app), stonectld (daemon), stone-cli (CLI) +- Deployment target macOS 12.0, Swift 5.9 +- Info.plists with SMJobBless/SMAuthorizedClients for privileged helper +- ObjC bridging header for audit token access (1 .m file) + +### Common Layer (shared across all targets) +- `SCError` — error enum with localized descriptions +- `StoneConstants` — bundle IDs, sentinel strings, default preferences +- `BlockEntry` — hostname/IP parser with pf rule and hosts line generation +- `SCSchedule` — Codable recurring schedule model +- `SCDaemonProtocol` — @objc XPC protocol +- `SCSettings` — cross-process settings store (root-owned binary plist) +- `SCBlockUtilities` — block state checks +- `SCBlockFileReaderWriter` — .stone blocklist file I/O +- `SCFileWatcher` — FSEvents wrapper +- `SCMiscUtilities` — serial number, SHA1, utilities + +### Block Enforcement +- `PacketFilter` — pf rules via pfctl, anchor management, token persistence +- `HostFileBlocker` — /etc/hosts editing with sentinel markers +- `HostFileBlockerSet` — multi-hosts-file coordinator +- `BlockManager` — orchestrates PF + hosts, DNS resolution, subdomain expansion + +### XPC Communication +- `SCXPCAuthorization` — AuthorizationServices wrapper +- `SCXPCClient` — SMJobBless + NSXPCConnection lifecycle + +### Daemon +- `SCDaemon` — XPC listener, 1s checkup timer, 2min inactivity exit +- `SCDaemonXPC` — protocol implementation with auth validation +- `SCDaemonBlockMethods` — block start/checkup/integrity/update logic +- `SCHelperToolUtilities` — settings ↔ enforcement bridge + +### Schedule Manager +- `SCScheduleManager` — Codable CRUD with UserDefaults, launchd sync +- `LaunchAgentWriter` — plist generation, launchctl operations + +### CLI +- Full argument parsing: --blocklist, --enddate, --duration, --settings, --uid +- Legacy positional arg fallback, UserDefaults fallback +- XPC-based block start + +### App UI (all programmatic, no xibs) +- `AppController` — block start/stop flow, window lifecycle, notification observation +- `MainWindowController` — duration slider, start button, blocklist toggle +- `TimerWindowController` — countdown, add-to-block, extend time, dock badge +- `DomainListWindowController` — editable table, add/remove, quick-add +- `ScheduleListWindowController` — 5-column table, add/edit/remove with sheet +- `PreferencesWindowController` — General + Advanced tabs + +## What's NOT done yet +- No app icon / assets +- No localization (English only) +- Code signing not configured (needs your Apple Developer Team ID) +- No Sentry integration +- No "move to Applications" prompt +- No migration from SelfControl settings +- No unit tests +- UI is functional but not polished (no custom styling) + +## To test + +```bash +cd /Users/maxforsey/Code/selfcontrol +git checkout swift-rewrite +open Stone.xcodeproj +# Set signing team in Xcode, then Build & Run +``` diff --git a/App/AppController.swift b/App/AppController.swift new file mode 100644 index 00000000..3be8da2e --- /dev/null +++ b/App/AppController.swift @@ -0,0 +1,338 @@ +import Cocoa + +/// Central controller that manages block start/stop flow and window lifecycle. +final class AppController: NSObject, ObservableObject { + @Published var blockIsOn = false + @Published var addingBlock = false + + private let defaults = UserDefaults.standard + private let settings = SCSettings.shared + private let xpc = SCXPCClient() + private let refreshLock = NSLock() + + // Legacy AppKit window controllers (kept for timer window during block) + private var mainWindowController: MainWindowController? + private var timerWindowController: TimerWindowController? + + // MARK: - Setup + + func start() { + defaults.register(defaults: StoneConstants.defaultUserDefaults) + blockIsOn = SCBlockUtilities.anyBlockIsRunning() + observeNotifications() + } + + private func observeNotifications() { + DistributedNotificationCenter.default().addObserver( + self, + selector: #selector(handleConfigurationChanged), + name: NSNotification.Name(StoneConstants.configurationChangedNotification), + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleConfigurationChanged), + name: NSNotification.Name(StoneConstants.configurationChangedNotification), + object: nil + ) + } + + deinit { + DistributedNotificationCenter.default().removeObserver(self) + NotificationCenter.default.removeObserver(self) + } + + // MARK: - Notification Handling + + @objc private func handleConfigurationChanged() { + settings.synchronize() + + // Clean empty strings from defaults blocklist + let raw = defaults.stringArray(forKey: "Blocklist") ?? [] + defaults.set(SCMiscUtilities.cleanBlocklist(raw), forKey: "Blocklist") + + // Notify timer window + if let twc = timerWindowController { + DispatchQueue.main.async { twc.configurationChanged() } + } + + refreshUserInterface() + } + + // MARK: - UI Refresh + + func refreshUserInterface() { + if !Thread.isMainThread { + DispatchQueue.main.async { self.refreshUserInterface() } + return + } + + guard refreshLock.try() else { return } + defer { refreshLock.unlock() } + + blockIsOn = SCBlockUtilities.anyBlockIsRunning() + + // SwiftUI owns the main window. AppController only manages the + // timer window and block state — no AppKit window creation. + NSApp.dockTile.badgeLabel = blockIsOn ? "●" : nil + } + + // MARK: - Window Management + + func showInitialWindow() { + if SCBlockUtilities.anyBlockIsRunning() { + showTimerWindow() + NSApp.activate(ignoringOtherApps: true) + } else { + showMainWindow() + } + } + + func showMainWindow() { + if mainWindowController == nil { + mainWindowController = MainWindowController(appController: self) + } + guard let window = mainWindowController?.window else { + NSLog("AppController: Failed to create main window") + return + } + window.center() + mainWindowController?.showWindow(nil) + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + + private func showTimerWindow() { + if timerWindowController == nil { + timerWindowController = TimerWindowController(appController: self) + } + timerWindowController?.window?.center() + timerWindowController?.showWindow(nil) + timerWindowController?.window?.makeKeyAndOrderFront(nil) + } + + private func closeTimerWindow() { + timerWindowController?.close() + timerWindowController = nil + } + + // MARK: - Secondary Windows + + private var domainListWindowController: DomainListWindowController? + private var scheduleListWindowController: ScheduleListWindowController? + private var preferencesWindowController: PreferencesWindowController? + + func showDomainList() { + if domainListWindowController == nil { + domainListWindowController = DomainListWindowController() + } + domainListWindowController?.window?.center() + domainListWindowController?.showWindow(nil) + } + + func showSchedules() { + if scheduleListWindowController == nil { + scheduleListWindowController = ScheduleListWindowController() + } + scheduleListWindowController?.window?.center() + scheduleListWindowController?.showWindow(nil) + } + + func showPreferences() { + if preferencesWindowController == nil { + preferencesWindowController = PreferencesWindowController() + } + preferencesWindowController?.window?.center() + preferencesWindowController?.showWindow(nil) + } + + // MARK: - Start Block + + func startBlock() { + guard !SCBlockUtilities.anyBlockIsRunning() else { + showAlert(message: "A block is already running.", info: "Wait for the current block to end before starting a new one.") + return + } + + let blocklist = defaults.stringArray(forKey: "Blocklist") ?? [] + let isAllowlist = defaults.bool(forKey: "BlockAsWhitelist") + + if blocklist.isEmpty && !isAllowlist { + showAlert(message: "Blocklist is empty.", info: "Add at least one entry to your blocklist before starting a block.") + return + } + + let duration = defaults.integer(forKey: "BlockDuration") + if duration <= 0 { + return + } + + // Long block warning + if !showLongBlockWarningIfNeeded(duration: duration) { + return + } + + DispatchQueue.global(qos: .userInitiated).async { [self] in + installBlock() + } + } + + private func showLongBlockWarningIfNeeded(duration: Int) -> Bool { + let longThreshold = 2880 // 2 days + let firstTimeThreshold = 480 // 8 hours + let isFirstBlock = !defaults.bool(forKey: "FirstBlockStarted") + + let showWarning = duration >= longThreshold || (isFirstBlock && duration >= firstTimeThreshold) + guard showWarning else { return true } + + if defaults.bool(forKey: "SuppressLongBlockWarning") { + return true + } + + let alert = NSAlert() + alert.messageText = "That's a long block!" + alert.informativeText = "Remember that once you start the block, you can't turn it off until the timer expires in \(formattedDuration(minutes: duration)). Consider starting a shorter block first." + alert.addButton(withTitle: "Cancel") + alert.addButton(withTitle: "Start Block Anyway") + alert.showsSuppressionButton = true + + let response = alert.runModal() + if alert.suppressionButton?.state == .on { + defaults.set(true, forKey: "SuppressLongBlockWarning") + } + return response != .alertFirstButtonReturn + } + + // MARK: - Install Block + + private func installBlock() { + addingBlock = true + DispatchQueue.main.async { self.refreshUserInterface() } + + let blockDurationSecs = TimeInterval(max(defaults.integer(forKey: "BlockDuration") * 60, 0)) + let endDate = Date(timeIntervalSinceNow: blockDurationSecs) + let blocklist = defaults.stringArray(forKey: "Blocklist") ?? [] + let isAllowlist = defaults.bool(forKey: "BlockAsWhitelist") + + // Install daemon via SMJobBless and start block via XPC + xpc.installDaemon { [self] error in + if let error = error { + DispatchQueue.main.async { + self.showAlert(message: "Failed to install daemon.", info: error.localizedDescription) + self.addingBlock = false + self.refreshUserInterface() + } + return + } + + settings.synchronize() + + let blockSettings: [String: Any] = [ + "ClearCaches": defaults.bool(forKey: "ClearCaches"), + "AllowLocalNetworks": defaults.bool(forKey: "AllowLocalNetworks"), + "EvaluateCommonSubdomains": defaults.bool(forKey: "EvaluateCommonSubdomains"), + "IncludeLinkedDomains": defaults.bool(forKey: "IncludeLinkedDomains"), + "BlockSoundShouldPlay": defaults.bool(forKey: "BlockSoundShouldPlay"), + "BlockSound": defaults.integer(forKey: "BlockSound"), + "EnableErrorReporting": defaults.bool(forKey: "EnableErrorReporting"), + ] + + xpc.refreshConnectionAndRun { [self] in + xpc.startBlock( + controllingUID: getuid(), + blocklist: blocklist, + isAllowlist: isAllowlist, + endDate: endDate, + blockSettings: blockSettings + ) { error in + if let error = error { + DispatchQueue.main.async { + self.showAlert(message: "Failed to start block.", info: error.localizedDescription) + } + } else { + self.defaults.set(true, forKey: "FirstBlockStarted") + } + + self.settings.synchronize() + DispatchQueue.main.async { + self.addingBlock = false + self.blockIsOn = SCBlockUtilities.anyBlockIsRunning() + } + } + } + } + } + + // MARK: - Modify Running Block + + func addToBlocklist(_ entry: String) { + guard SCBlockUtilities.anyBlockIsRunning() else { return } + + let cleaned = SCMiscUtilities.cleanBlocklist([entry]) + guard !cleaned.isEmpty else { return } + + var list = defaults.stringArray(forKey: "Blocklist") ?? [] + for item in cleaned where !list.contains(item) { + list.append(item) + } + defaults.set(list, forKey: "Blocklist") + settings.synchronize() + + xpc.refreshConnectionAndRun { [self] in + xpc.updateBlocklist(list) { error in + if let error = error { + DispatchQueue.main.async { + self.showAlert(message: "Failed to update blocklist.", info: error.localizedDescription) + } + } + } + } + } + + func extendBlock(minutes: Int) { + guard SCBlockUtilities.anyBlockIsRunning(), minutes > 0 else { return } + + let maxLength = defaults.integer(forKey: "MaxBlockLength") + let capped = min(minutes, maxLength) + + guard let oldEnd = settings.value(for: "BlockEndDate") as? Date else { return } + let newEnd = oldEnd.addingTimeInterval(TimeInterval(capped * 60)) + + settings.synchronize() + + xpc.refreshConnectionAndRun { [self] in + guard !SCBlockUtilities.currentBlockIsExpired(), + oldEnd.timeIntervalSinceNow >= 1 else { return } + + xpc.updateBlockEndDate(newEnd) { error in + if let error = error { + DispatchQueue.main.async { + self.showAlert(message: "Failed to extend block.", info: error.localizedDescription) + } + } + } + } + } + + // MARK: - Helpers + + private func showAlert(message: String, info: String) { + let alert = NSAlert() + alert.messageText = message + alert.informativeText = info + alert.addButton(withTitle: "OK") + alert.runModal() + } + + func formattedDuration(minutes: Int) -> String { + let h = minutes / 60 + let m = minutes % 60 + if h > 0 && m > 0 { + return "\(h)h \(m)m" + } else if h > 0 { + return "\(h)h" + } else { + return "\(m)m" + } + } +} diff --git a/App/AppDelegate.swift b/App/AppDelegate.swift new file mode 100644 index 00000000..ce476473 --- /dev/null +++ b/App/AppDelegate.swift @@ -0,0 +1,14 @@ +import Cocoa + +class AppDelegate: NSObject, NSApplicationDelegate { + let appController = AppController() + + func applicationDidFinishLaunching(_ notification: Notification) { + // SwiftUI handles window creation via WindowGroup. + // AppDelegate is used via NSApplicationDelegateAdaptor for AppKit-specific needs. + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return false + } +} diff --git a/App/Stone-Info.plist b/App/Stone-Info.plist new file mode 100644 index 00000000..ff7cee63 --- /dev/null +++ b/App/Stone-Info.plist @@ -0,0 +1,33 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + AppIcon + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Stone + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSPrincipalClass + NSApplication + SMPrivilegedExecutables + + com.max4c.stonectld + identifier "com.max4c.stonectld" and anchor apple generic and certificate leaf[subject.OU] = "H9N9P29TX5" + + + diff --git a/App/Stone.entitlements b/App/Stone.entitlements new file mode 100644 index 00000000..6631ffa6 --- /dev/null +++ b/App/Stone.entitlements @@ -0,0 +1,6 @@ + + + + + + diff --git a/App/StoneApp.swift b/App/StoneApp.swift new file mode 100644 index 00000000..98b5d32e --- /dev/null +++ b/App/StoneApp.swift @@ -0,0 +1,17 @@ +import SwiftUI + +@main +struct StoneApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + + var body: some Scene { + WindowGroup { + MainView(appController: appDelegate.appController) + .frame(width: 420, height: 320) + .onAppear { + appDelegate.appController.start() + SCScheduleManager.shared.syncAllLaunchdAgents() + } + } + } +} diff --git a/App/UI/BlocklistEditorView.swift b/App/UI/BlocklistEditorView.swift new file mode 100644 index 00000000..1ae28676 --- /dev/null +++ b/App/UI/BlocklistEditorView.swift @@ -0,0 +1,104 @@ +import SwiftUI + +struct BlocklistEditorView: View { + @Binding var blocklist: [String] + let isAllowlist: Bool + let onSave: () -> Void + + @State private var newEntry = "" + @FocusState private var fieldFocused: Bool + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(spacing: 0) { + // Header + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 2) { + Text(isAllowlist ? "Allowlist" : "Blocklist") + .font(.system(size: 15, weight: .semibold)) + Text("\(blocklist.count) sites") + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.tertiary) + } + Spacer() + Button("Done") { + onSave() + dismiss() + } + .keyboardShortcut(.defaultAction) + } + .padding(.horizontal, 20) + .padding(.vertical, 16) + + Divider() + + // Add field + HStack(spacing: 8) { + Image(systemName: "plus.circle.fill") + .foregroundStyle(.secondary) + .font(.system(size: 14)) + + TextField("facebook.com", text: $newEntry) + .textFieldStyle(.plain) + .font(.system(size: 13, design: .monospaced)) + .focused($fieldFocused) + .onSubmit { addEntry() } + } + .padding(.horizontal, 20) + .padding(.vertical, 10) + .background(Color(nsColor: .controlBackgroundColor)) + + Divider() + + // List + if blocklist.isEmpty { + VStack(spacing: 6) { + Text("No sites yet") + .foregroundStyle(.secondary) + Text("Type a domain above and press Return") + .font(.caption) + .foregroundStyle(.tertiary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List { + ForEach(blocklist, id: \.self) { entry in + Text(entry) + .font(.system(size: 13, design: .monospaced)) + } + .onDelete { indices in + blocklist.remove(atOffsets: indices) + onSave() + } + } + } + + // Footer + if !blocklist.isEmpty { + Divider() + HStack { + Spacer() + Button("Remove All") { + blocklist.removeAll() + onSave() + } + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .buttonStyle(.plain) + } + .padding(.horizontal, 20) + .padding(.vertical, 10) + } + } + .frame(width: 400, height: 380) + .onAppear { fieldFocused = true } + } + + private func addEntry() { + let cleaned = newEntry.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !cleaned.isEmpty, !blocklist.contains(cleaned) else { return } + blocklist.append(cleaned) + newEntry = "" + onSave() + } +} diff --git a/App/UI/DomainListWindowController.swift b/App/UI/DomainListWindowController.swift new file mode 100644 index 00000000..588d4170 --- /dev/null +++ b/App/UI/DomainListWindowController.swift @@ -0,0 +1,185 @@ +import Cocoa + +final class DomainListWindowController: NSWindowController, NSTableViewDataSource, NSTableViewDelegate, NSTextFieldDelegate { + + private let defaults = UserDefaults.standard + private var domainList: [String] = [] + private let tableView = NSTableView() + private let quickAddField = NSTextField() + private let removeButton: NSButton + + init() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 480, height: 400), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false + ) + window.minSize = NSSize(width: 320, height: 250) + + removeButton = NSButton(title: "Remove", target: nil, action: nil) + + super.init(window: window) + + removeButton.target = self + removeButton.action = #selector(removeDomain(_:)) + + loadDomainList() + setupUI() + updateWindowTitle() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + // MARK: - Setup + + private func setupUI() { + guard let contentView = window?.contentView else { return } + + // Quick-add text field at top + quickAddField.frame = NSRect(x: 10, y: 366, width: 460, height: 24) + quickAddField.placeholderString = "Type a domain and press Enter to add" + quickAddField.autoresizingMask = [.width, .maxYMargin] + quickAddField.delegate = self + quickAddField.target = self + quickAddField.action = #selector(quickAdd(_:)) + contentView.addSubview(quickAddField) + + // Scroll view + table + let scrollView = NSScrollView(frame: NSRect(x: 0, y: 44, width: 480, height: 318)) + scrollView.hasVerticalScroller = true + scrollView.autoresizingMask = [.width, .height] + + let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("domain")) + column.title = "Domain" + column.isEditable = true + column.resizingMask = .autoresizingMask + tableView.addTableColumn(column) + tableView.headerView = nil + tableView.dataSource = self + tableView.delegate = self + tableView.rowHeight = 22 + + scrollView.documentView = tableView + contentView.addSubview(scrollView) + + // Button bar + let addButton = NSButton(title: "Add", target: self, action: #selector(addDomain(_:))) + addButton.frame = NSRect(x: 10, y: 10, width: 80, height: 24) + addButton.autoresizingMask = [.maxXMargin, .maxYMargin] + contentView.addSubview(addButton) + + removeButton.frame = NSRect(x: 100, y: 10, width: 80, height: 24) + removeButton.autoresizingMask = [.maxXMargin, .maxYMargin] + contentView.addSubview(removeButton) + } + + // MARK: - Data + + private func loadDomainList() { + domainList = defaults.stringArray(forKey: "Blocklist") ?? [] + } + + private func saveDomainList() { + defaults.set(domainList, forKey: "Blocklist") + // [Fix #8] Use DistributedNotificationCenter with the correct name + DistributedNotificationCenter.default().postNotificationName( + NSNotification.Name(StoneConstants.configurationChangedNotification), + object: nil + ) + } + + func updateWindowTitle() { + let isAllowlist = defaults.bool(forKey: "BlockAsWhitelist") + window?.title = isAllowlist ? "Domain Allowlist" : "Domain Blocklist" + } + + func refreshDomainList() { + let reload = { + self.window?.makeFirstResponder(nil) + self.loadDomainList() + self.tableView.reloadData() + } + if Thread.isMainThread { reload() } else { DispatchQueue.main.sync { reload() } } + } + + // MARK: - Window Lifecycle + + override func showWindow(_ sender: Any?) { + window?.makeKeyAndOrderFront(sender) + if domainList.isEmpty && !SCBlockUtilities.anyBlockIsRunning() { + addDomain(self) + } + updateWindowTitle() + } + + func windowWillClose(_ notification: Notification) { + saveDomainList() + } + + // MARK: - Actions + + @objc private func addDomain(_ sender: Any) { + domainList.append("") + saveDomainList() + tableView.reloadData() + let lastRow = domainList.count - 1 + tableView.selectRowIndexes(IndexSet(integer: lastRow), byExtendingSelection: false) + tableView.editColumn(0, row: lastRow, with: nil, select: true) + } + + @objc private func removeDomain(_ sender: Any) { + if SCBlockUtilities.anyBlockIsRunning() { return } + let selected = tableView.selectedRowIndexes + guard !selected.isEmpty else { return } + tableView.abortEditing() + for index in selected.sorted().reversed() { + guard index < domainList.count else { continue } + domainList.remove(at: index) + } + saveDomainList() + tableView.reloadData() + } + + @objc private func quickAdd(_ sender: NSTextField) { + let text = sender.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return } + if !domainList.contains(text) { + domainList.append(text) + saveDomainList() + tableView.reloadData() + } + sender.stringValue = "" + } + + // MARK: - NSTableViewDataSource + + func numberOfRows(in tableView: NSTableView) -> Int { + removeButton.isEnabled = !SCBlockUtilities.anyBlockIsRunning() + return domainList.count + } + + func tableView(_ tableView: NSTableView, objectValueFor tableColumn: NSTableColumn?, row: Int) -> Any? { + guard row >= 0, row < domainList.count else { return nil } + return domainList[row] + } + + func tableView(_ tableView: NSTableView, setObjectValue object: Any?, for tableColumn: NSTableColumn?, row: Int) { + guard row >= 0, row < domainList.count, let newValue = object as? String else { return } + let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + domainList.remove(at: row) + } else { + domainList[row] = trimmed + } + saveDomainList() + tableView.reloadData() + } + + // MARK: - NSTableViewDelegate + + func tableView(_ tableView: NSTableView, shouldEdit tableColumn: NSTableColumn?, row: Int) -> Bool { + return !SCBlockUtilities.anyBlockIsRunning() + } +} diff --git a/App/UI/MainView.swift b/App/UI/MainView.swift new file mode 100644 index 00000000..3b66396e --- /dev/null +++ b/App/UI/MainView.swift @@ -0,0 +1,119 @@ +import SwiftUI + +struct MainView: View { + @ObservedObject var appController: AppController + + @AppStorage("BlockDuration") private var blockDuration = 60 + @AppStorage("MaxBlockLength") private var maxBlockLength = 1440 + @AppStorage("BlockAsWhitelist") private var blockAsWhitelist = false + + @State private var showingBlocklist = false + @State private var showingSchedules = false + @State private var blocklist: [String] = [] + + var body: some View { + if appController.blockIsOn { + TimerView(appController: appController) + } else { + setupView + } + } + + private var setupView: some View { + VStack(spacing: 0) { + Spacer() + + VStack(spacing: 2) { + Text(formattedDuration) + .font(.system(size: 48, weight: .bold, design: .rounded)) + .monospacedDigit() + + Text("block duration") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.tertiary) + .textCase(.uppercase) + } + + Spacer().frame(height: 24) + + Slider(value: durationBinding, in: 1...Double(max(maxBlockLength, 1)), step: 1) + .frame(maxWidth: 280) + + Spacer().frame(height: 32) + + HStack(spacing: 16) { + Picker("", selection: $blockAsWhitelist) { + Text("Block").tag(false) + Text("Allow").tag(true) + } + .pickerStyle(.segmented) + .frame(width: 140) + + Text("\(blocklist.count) sites") + .font(.system(size: 12, design: .monospaced)) + .foregroundStyle(.secondary) + } + + Spacer().frame(height: 24) + + HStack(spacing: 10) { + Button(action: { showingBlocklist = true }) { + Label("Sites", systemImage: "list.bullet") + .font(.system(size: 12)) + } + + Button(action: { showingSchedules = true }) { + Label("Schedules", systemImage: "clock") + .font(.system(size: 12)) + } + } + + Spacer().frame(height: 20) + + Button(action: { appController.startBlock() }) { + Text("Start Block") + .font(.system(size: 13, weight: .semibold)) + .frame(maxWidth: 200) + .padding(.vertical, 8) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled((blocklist.isEmpty && !blockAsWhitelist) || appController.addingBlock) + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { loadBlocklist() } + .sheet(isPresented: $showingBlocklist) { + BlocklistEditorView(blocklist: $blocklist, isAllowlist: blockAsWhitelist) { + saveBlocklist() + } + } + .sheet(isPresented: $showingSchedules) { + ScheduleEditorView() + } + } + + private var durationBinding: Binding { + Binding( + get: { Double(blockDuration) }, + set: { blockDuration = Int($0) } + ) + } + + private var formattedDuration: String { + let h = blockDuration / 60 + let m = blockDuration % 60 + if h > 0 && m > 0 { return "\(h)h \(m)m" } + if h > 0 { return "\(h)h" } + return "\(m)m" + } + + private func loadBlocklist() { + blocklist = UserDefaults.standard.stringArray(forKey: "Blocklist") ?? [] + } + + private func saveBlocklist() { + UserDefaults.standard.set(blocklist, forKey: "Blocklist") + } +} diff --git a/App/UI/MainWindowController.swift b/App/UI/MainWindowController.swift new file mode 100644 index 00000000..742a9062 --- /dev/null +++ b/App/UI/MainWindowController.swift @@ -0,0 +1,180 @@ +import Cocoa + +/// Main window shown when no block is running. Programmatic Cocoa UI. +final class MainWindowController: NSWindowController { + private weak var appController: AppController? + private let defaults = UserDefaults.standard + + private var durationSlider: NSSlider! + private var durationLabel: NSTextField! + private var startButton: NSButton! + private var editBlocklistButton: NSButton! + private var schedulesButton: NSButton! + private var modeControl: NSSegmentedControl! + private var summaryLabel: NSTextField! + + init(appController: AppController) { + self.appController = appController + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 320), + styleMask: [.titled, .closable, .miniaturizable], + backing: .buffered, + defer: false + ) + window.title = "Stone" + window.isReleasedWhenClosed = false + + super.init(window: window) + buildUI() + updateControls(addingBlock: false) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + // MARK: - Build UI + + private func buildUI() { + guard let contentView = window?.contentView else { return } + contentView.wantsLayer = true + + let padding: CGFloat = 20 + var y: CGFloat = 280 + + // Title + let titleLabel = makeLabel("Stone", fontSize: 22, bold: true) + titleLabel.frame = NSRect(x: padding, y: y, width: 380, height: 30) + contentView.addSubview(titleLabel) + y -= 50 + + // Duration label + durationLabel = makeLabel("60 minutes", fontSize: 14, bold: false) + durationLabel.frame = NSRect(x: padding, y: y, width: 380, height: 20) + contentView.addSubview(durationLabel) + y -= 30 + + // Duration slider + durationSlider = NSSlider(value: Double(defaults.integer(forKey: "BlockDuration")), + minValue: 1, + maxValue: Double(defaults.integer(forKey: "MaxBlockLength")), + target: self, + action: #selector(sliderChanged(_:))) + durationSlider.frame = NSRect(x: padding, y: y, width: 380, height: 24) + contentView.addSubview(durationSlider) + y -= 40 + + // Blocklist/Allowlist toggle + modeControl = NSSegmentedControl(labels: ["Blocklist", "Allowlist"], trackingMode: .selectOne, target: self, action: #selector(modeChanged(_:))) + modeControl.frame = NSRect(x: padding, y: y, width: 200, height: 24) + modeControl.selectedSegment = defaults.bool(forKey: "BlockAsWhitelist") ? 1 : 0 + contentView.addSubview(modeControl) + y -= 30 + + // Summary label + summaryLabel = makeLabel("", fontSize: 12, bold: false) + summaryLabel.textColor = .secondaryLabelColor + summaryLabel.frame = NSRect(x: padding, y: y, width: 380, height: 18) + contentView.addSubview(summaryLabel) + y -= 40 + + // Start Block button + startButton = NSButton(title: "Start Block", target: self, action: #selector(startBlockClicked(_:))) + startButton.bezelStyle = .rounded + startButton.frame = NSRect(x: padding, y: y, width: 120, height: 32) + startButton.keyEquivalent = "\r" + contentView.addSubview(startButton) + + // Edit Blocklist button + editBlocklistButton = NSButton(title: "Edit Blocklist...", target: self, action: #selector(editBlocklistClicked(_:))) + editBlocklistButton.bezelStyle = .rounded + editBlocklistButton.frame = NSRect(x: 160, y: y, width: 130, height: 32) + contentView.addSubview(editBlocklistButton) + + // Schedules button + schedulesButton = NSButton(title: "Schedules...", target: self, action: #selector(schedulesClicked(_:))) + schedulesButton.bezelStyle = .rounded + schedulesButton.frame = NSRect(x: 305, y: y, width: 100, height: 32) + contentView.addSubview(schedulesButton) + + updateSliderDisplay() + updateSummary() + } + + // MARK: - Actions + + @objc private func sliderChanged(_ sender: NSSlider) { + defaults.set(sender.integerValue, forKey: "BlockDuration") + updateSliderDisplay() + } + + @objc private func modeChanged(_ sender: NSSegmentedControl) { + defaults.set(sender.selectedSegment == 1, forKey: "BlockAsWhitelist") + updateControls(addingBlock: false) + } + + @objc private func startBlockClicked(_ sender: NSButton) { + defaults.set(durationSlider.integerValue, forKey: "BlockDuration") + appController?.startBlock() + } + + @objc private func editBlocklistClicked(_ sender: NSButton) { + appController?.showDomainList() + } + + @objc private func schedulesClicked(_ sender: NSButton) { + appController?.showSchedules() + } + + // MARK: - Update Display + + func updateControls(addingBlock: Bool) { + let blocklist = defaults.stringArray(forKey: "Blocklist") ?? [] + let isAllowlist = defaults.bool(forKey: "BlockAsWhitelist") + let duration = defaults.integer(forKey: "BlockDuration") + + let canStart = duration > 0 && (!blocklist.isEmpty || isAllowlist) && !addingBlock + + startButton?.isEnabled = canStart + durationSlider?.isEnabled = !addingBlock + editBlocklistButton?.isEnabled = !addingBlock + startButton?.title = addingBlock ? "Starting Block..." : "Start Block" + + let listType = isAllowlist ? "Allowlist" : "Blocklist" + editBlocklistButton?.title = "Edit \(listType)..." + modeControl?.selectedSegment = isAllowlist ? 1 : 0 + + updateSliderDisplay() + updateSummary() + } + + private func updateSliderDisplay() { + guard let slider = durationSlider, let label = durationLabel else { return } + let minutes = slider.integerValue + if let ac = appController { + label.stringValue = ac.formattedDuration(minutes: minutes) + } else { + let h = minutes / 60 + let m = minutes % 60 + label.stringValue = h > 0 ? "\(h)h \(m)m" : "\(m)m" + } + } + + private func updateSummary() { + let blocklist = defaults.stringArray(forKey: "Blocklist") ?? [] + let isAllowlist = defaults.bool(forKey: "BlockAsWhitelist") + let type = isAllowlist ? "allowlist" : "blocklist" + summaryLabel?.stringValue = "\(blocklist.count) entries in \(type)" + } + + // MARK: - Helpers + + private func makeLabel(_ text: String, fontSize: CGFloat, bold: Bool) -> NSTextField { + let label = NSTextField(labelWithString: text) + label.font = bold ? NSFont.boldSystemFont(ofSize: fontSize) : NSFont.systemFont(ofSize: fontSize) + label.isEditable = false + label.isBezeled = false + label.drawsBackground = false + return label + } +} diff --git a/App/UI/PreferencesWindowController.swift b/App/UI/PreferencesWindowController.swift new file mode 100644 index 00000000..15fc706a --- /dev/null +++ b/App/UI/PreferencesWindowController.swift @@ -0,0 +1,167 @@ +import Cocoa + +final class PreferencesWindowController: NSWindowController { + + private let defaults = UserDefaults.standard + + init() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 450, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + window.title = "Preferences" + + super.init(window: window) + setupUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + // MARK: - Setup + + private func setupUI() { + guard let contentView = window?.contentView else { return } + + let tabView = NSTabView(frame: contentView.bounds) + tabView.autoresizingMask = [.width, .height] + + // General tab + let generalItem = NSTabViewItem(identifier: "general") + generalItem.label = "General" + generalItem.view = makeGeneralTab() + tabView.addTabViewItem(generalItem) + + // Advanced tab + let advancedItem = NSTabViewItem(identifier: "advanced") + advancedItem.label = "Advanced" + advancedItem.view = makeAdvancedTab() + tabView.addTabViewItem(advancedItem) + + contentView.addSubview(tabView) + } + + // MARK: - General Tab + + private func makeGeneralTab() -> NSView { + let view = NSView(frame: NSRect(x: 0, y: 0, width: 420, height: 260)) + var y: CGFloat = 220 + + // Play sound checkbox + let soundCheck = NSButton(checkboxWithTitle: "Play sound when block starts", target: self, action: #selector(toggleBlockSound(_:))) + soundCheck.frame = NSRect(x: 20, y: y, width: 300, height: 22) + soundCheck.state = defaults.bool(forKey: "BlockSoundShouldPlay") ? .on : .off + view.addSubview(soundCheck) + + // Sound picker + y -= 30 + let soundLabel = NSTextField(labelWithString: "Sound:") + soundLabel.frame = NSRect(x: 40, y: y, width: 50, height: 22) + view.addSubview(soundLabel) + + let soundPopup = NSPopUpButton(frame: NSRect(x: 94, y: y, width: 180, height: 24), pullsDown: false) + soundPopup.addItems(withTitles: StoneConstants.blockSoundNames) + let savedIndex = defaults.integer(forKey: "BlockSound") + if savedIndex >= 0 && savedIndex < StoneConstants.blockSoundNames.count { + soundPopup.selectItem(at: savedIndex) + } + soundPopup.target = self + soundPopup.action = #selector(changeBlockSound(_:)) + view.addSubview(soundPopup) + + // Badge icon checkbox + y -= 36 + let badgeCheck = NSButton(checkboxWithTitle: "Show icon badge when block is running", target: self, action: #selector(toggleBadgeIcon(_:))) + badgeCheck.frame = NSRect(x: 20, y: y, width: 300, height: 22) + badgeCheck.state = defaults.bool(forKey: "BadgeIconEnabled") ? .on : .off + view.addSubview(badgeCheck) + + // Max block length slider + y -= 40 + let sliderLabel = NSTextField(labelWithString: "Max block length:") + sliderLabel.frame = NSRect(x: 20, y: y, width: 120, height: 22) + view.addSubview(sliderLabel) + + let valueLabel = NSTextField(labelWithString: formatMinutes(defaults.integer(forKey: "MaxBlockLength"))) + valueLabel.frame = NSRect(x: 330, y: y, width: 80, height: 22) + valueLabel.tag = 999 + view.addSubview(valueLabel) + + y -= 24 + let slider = NSSlider(value: Double(defaults.integer(forKey: "MaxBlockLength")), + minValue: 1, maxValue: 1440, + target: self, action: #selector(changeMaxBlockLength(_:))) + slider.frame = NSRect(x: 20, y: y, width: 390, height: 22) + slider.tag = 998 + view.addSubview(slider) + + return view + } + + // MARK: - Advanced Tab + + private func makeAdvancedTab() -> NSView { + let view = NSView(frame: NSRect(x: 0, y: 0, width: 420, height: 260)) + var y: CGFloat = 220 + + let items: [(String, String)] = [ + ("Evaluate common subdomains", "EvaluateCommonSubdomains"), + ("Include linked domains", "IncludeLinkedDomains"), + ("Clear browser caches on block", "ClearCaches"), + ("Allow local network connections", "AllowLocalNetworks"), + ("Enable error reporting", "EnableErrorReporting"), + ] + + for (title, key) in items { + let checkbox = NSButton(checkboxWithTitle: title, target: self, action: #selector(toggleAdvancedOption(_:))) + checkbox.frame = NSRect(x: 20, y: y, width: 360, height: 22) + checkbox.state = defaults.bool(forKey: key) ? .on : .off + checkbox.identifier = NSUserInterfaceItemIdentifier(key) + view.addSubview(checkbox) + y -= 32 + } + + return view + } + + // MARK: - Actions + + @objc private func toggleBlockSound(_ sender: NSButton) { + defaults.set(sender.state == .on, forKey: "BlockSoundShouldPlay") + } + + @objc private func changeBlockSound(_ sender: NSPopUpButton) { + defaults.set(sender.indexOfSelectedItem, forKey: "BlockSound") + } + + @objc private func toggleBadgeIcon(_ sender: NSButton) { + defaults.set(sender.state == .on, forKey: "BadgeIconEnabled") + } + + @objc private func changeMaxBlockLength(_ sender: NSSlider) { + let minutes = sender.integerValue + defaults.set(minutes, forKey: "MaxBlockLength") + // Update the value label (sibling with tag 999) + if let label = sender.superview?.viewWithTag(999) as? NSTextField { + label.stringValue = formatMinutes(minutes) + } + } + + @objc private func toggleAdvancedOption(_ sender: NSButton) { + guard let key = sender.identifier?.rawValue else { return } + defaults.set(sender.state == .on, forKey: key) + } + + // MARK: - Helpers + + private func formatMinutes(_ minutes: Int) -> String { + if minutes >= 60 { + let h = minutes / 60 + let m = minutes % 60 + return m > 0 ? "\(h)h \(m)m" : "\(h)h" + } + return "\(minutes)m" + } +} diff --git a/App/UI/ScheduleEditorView.swift b/App/UI/ScheduleEditorView.swift new file mode 100644 index 00000000..e30c0646 --- /dev/null +++ b/App/UI/ScheduleEditorView.swift @@ -0,0 +1,334 @@ +import SwiftUI + +struct ScheduleEditorView: View { + @State private var schedules: [SCSchedule] = [] + @State private var showingAddSheet = false + @State private var editingSchedule: SCSchedule? + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(spacing: 0) { + // Header + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 2) { + Text("Schedules") + .font(.system(size: 15, weight: .semibold)) + Text("\(schedules.count) active") + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.tertiary) + } + Spacer() + Button("Done") { dismiss() } + .keyboardShortcut(.defaultAction) + } + .padding(.horizontal, 20) + .padding(.vertical, 16) + + Divider() + + if schedules.isEmpty { + VStack(spacing: 6) { + Image(systemName: "clock.badge.questionmark") + .font(.system(size: 28)) + .foregroundStyle(.tertiary) + Text("No schedules") + .foregroundStyle(.secondary) + Text("Automatically block sites on a recurring schedule") + .font(.caption) + .foregroundStyle(.tertiary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List { + ForEach(schedules) { schedule in + ScheduleRow(schedule: schedule, + onToggle: { toggleSchedule(schedule) }, + onEdit: { editingSchedule = schedule }) + } + .onDelete { indices in + for index in indices { + SCScheduleManager.shared.removeSchedule(schedules[index]) + } + SCScheduleManager.shared.syncAllLaunchdAgents() + loadSchedules() + } + } + } + + Divider() + + // Footer + HStack { + Button(action: { showingAddSheet = true }) { + Label("New Schedule", systemImage: "plus") + .font(.system(size: 12)) + } + Spacer() + } + .padding(.horizontal, 20) + .padding(.vertical, 10) + } + .frame(width: 460, height: 380) + .onAppear { loadSchedules() } + .sheet(isPresented: $showingAddSheet) { + ScheduleFormView(schedule: nil) { newSchedule in + SCScheduleManager.shared.addSchedule(newSchedule) + SCScheduleManager.shared.syncAllLaunchdAgents() + loadSchedules() + } + } + .sheet(item: $editingSchedule) { schedule in + ScheduleFormView(schedule: schedule) { updated in + SCScheduleManager.shared.updateSchedule(updated) + SCScheduleManager.shared.syncAllLaunchdAgents() + loadSchedules() + } + } + } + + private func loadSchedules() { + schedules = SCScheduleManager.shared.allSchedules() + } + + private func toggleSchedule(_ schedule: SCSchedule) { + var updated = schedule + updated.enabled.toggle() + SCScheduleManager.shared.updateSchedule(updated) + SCScheduleManager.shared.syncAllLaunchdAgents() + loadSchedules() + } +} + +// MARK: - Schedule Row + +struct ScheduleRow: View { + let schedule: SCSchedule + let onToggle: () -> Void + let onEdit: () -> Void + + var body: some View { + HStack(spacing: 12) { + // Status dot + Circle() + .fill(schedule.enabled ? Color.green : Color.gray.opacity(0.3)) + .frame(width: 8, height: 8) + + VStack(alignment: .leading, spacing: 1) { + Text(schedule.name.isEmpty ? "Untitled" : schedule.name) + .font(.system(size: 13, weight: .medium)) + .opacity(schedule.enabled ? 1 : 0.5) + + HStack(spacing: 6) { + Text(daysSummary) + Text("·") + Text(String(format: "%02d:%02d", schedule.hour, schedule.minute)) + .font(.system(size: 11, design: .monospaced)) + Text("·") + Text(durationText) + } + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .opacity(schedule.enabled ? 1 : 0.5) + } + + Spacer() + + Button(action: onToggle) { + Text(schedule.enabled ? "On" : "Off") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(schedule.enabled ? .green : .secondary) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(schedule.enabled ? Color.green.opacity(0.1) : Color.gray.opacity(0.1)) + ) + } + .buttonStyle(.plain) + + Button(action: onEdit) { + Image(systemName: "pencil") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + .padding(.vertical, 2) + } + + private var daysSummary: String { + if schedule.weekdays.isEmpty { return "Daily" } + if schedule.weekdays.count == 7 { return "Every day" } + let abbrevs = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] + let sorted = schedule.weekdays.sorted() + if sorted == [1, 2, 3, 4, 5] { return "Weekdays" } + if sorted == [0, 6] { return "Weekends" } + return sorted.compactMap { $0 < 7 ? abbrevs[$0] : nil }.joined(separator: ", ") + } + + private var durationText: String { + let h = schedule.durationMinutes / 60 + let m = schedule.durationMinutes % 60 + if h > 0 && m > 0 { return "\(h)h \(m)m" } + if h > 0 { return "\(h)h" } + return "\(m)m" + } +} + +// MARK: - Schedule Form + +struct ScheduleFormView: View { + let initialSchedule: SCSchedule? + let onSave: (SCSchedule) -> Void + + @State private var name = "" + @State private var selectedDays: Set = [] + @State private var hour = 9 + @State private var minute = 0 + @State private var durationMinutes = 60 + @State private var blocklistText = "" + @Environment(\.dismiss) private var dismiss + + private let dayLabels = ["S", "M", "T", "W", "T", "F", "S"] + + init(schedule: SCSchedule?, onSave: @escaping (SCSchedule) -> Void) { + self.initialSchedule = schedule + self.onSave = onSave + } + + var body: some View { + VStack(spacing: 0) { + // Header + Text(initialSchedule == nil ? "New Schedule" : "Edit Schedule") + .font(.system(size: 15, weight: .semibold)) + .padding(.top, 20) + .padding(.bottom, 16) + + // Form + VStack(alignment: .leading, spacing: 16) { + // Name + VStack(alignment: .leading, spacing: 4) { + Text("NAME") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.tertiary) + TextField("Morning focus", text: $name) + .textFieldStyle(.roundedBorder) + } + + // Days + VStack(alignment: .leading, spacing: 6) { + Text("DAYS") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.tertiary) + HStack(spacing: 4) { + ForEach(0..<7, id: \.self) { day in + Button(action: { toggleDay(day) }) { + Text(dayLabels[day]) + .font(.system(size: 11, weight: .semibold)) + .frame(width: 32, height: 28) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(selectedDays.contains(day) + ? Color.accentColor + : Color(nsColor: .controlBackgroundColor)) + ) + .foregroundStyle(selectedDays.contains(day) ? .white : .primary) + } + .buttonStyle(.plain) + } + } + } + + // Time + Duration + HStack(spacing: 20) { + VStack(alignment: .leading, spacing: 4) { + Text("TIME") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.tertiary) + HStack(spacing: 2) { + Stepper(String(format: "%02d", hour), value: $hour, in: 0...23) + Text(":") + .foregroundStyle(.tertiary) + Stepper(String(format: "%02d", minute), value: $minute, in: 0...59, step: 5) + } + .font(.system(size: 13, design: .monospaced)) + } + + VStack(alignment: .leading, spacing: 4) { + Text("DURATION") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.tertiary) + Stepper("\(durationMinutes) min", value: $durationMinutes, in: 1...1440, step: 15) + .font(.system(size: 13, design: .monospaced)) + } + } + + // Domains + VStack(alignment: .leading, spacing: 4) { + Text("DOMAINS") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.tertiary) + TextEditor(text: $blocklistText) + .font(.system(size: 12, design: .monospaced)) + .frame(height: 64) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) + ) + } + } + .padding(.horizontal, 24) + + Spacer() + + // Actions + HStack { + Button("Cancel") { dismiss() } + .keyboardShortcut(.cancelAction) + Spacer() + Button("Save") { save() } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + .disabled(name.isEmpty) + } + .padding(.horizontal, 24) + .padding(.bottom, 20) + } + .frame(width: 380, height: 440) + .onAppear { populateFromSchedule() } + } + + private func toggleDay(_ day: Int) { + if selectedDays.contains(day) { selectedDays.remove(day) } + else { selectedDays.insert(day) } + } + + private func populateFromSchedule() { + guard let s = initialSchedule else { return } + name = s.name + selectedDays = Set(s.weekdays) + hour = s.hour + minute = s.minute + durationMinutes = s.durationMinutes + blocklistText = s.blocklist.joined(separator: "\n") + } + + private func save() { + let domains = blocklistText + .components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespaces).lowercased() } + .filter { !$0.isEmpty } + + var schedule = initialSchedule ?? SCSchedule() + schedule.name = name + schedule.weekdays = selectedDays.sorted() + schedule.hour = hour + schedule.minute = minute + schedule.durationMinutes = durationMinutes + schedule.blocklist = domains + schedule.enabled = true + + onSave(schedule) + dismiss() + } +} diff --git a/App/UI/ScheduleListWindowController.swift b/App/UI/ScheduleListWindowController.swift new file mode 100644 index 00000000..cda1e3c7 --- /dev/null +++ b/App/UI/ScheduleListWindowController.swift @@ -0,0 +1,336 @@ +import Cocoa + +final class ScheduleListWindowController: NSWindowController, NSTableViewDataSource, NSTableViewDelegate { + + private let tableView = NSTableView() + private var schedules: [SCSchedule] = [] + + // Edit sheet controls + private var editSheet: NSWindow? + private var nameField: NSTextField? + private var dayCheckboxes: [NSButton] = [] + private var timePicker: NSDatePicker? + private var durationField: NSTextField? + private var blocklistField: NSTextField? + private var editingSchedule: SCSchedule? + + init() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 400), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false + ) + window.title = "Scheduled Blocks" + window.minSize = NSSize(width: 500, height: 300) + + super.init(window: window) + setupUI() + reloadSchedules() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + // MARK: - Setup + + private func setupUI() { + guard let contentView = window?.contentView else { return } + + let scrollView = NSScrollView(frame: NSRect(x: 0, y: 44, width: 640, height: 356)) + scrollView.hasVerticalScroller = true + scrollView.autoresizingMask = [.width, .height] + + tableView.dataSource = self + tableView.delegate = self + tableView.rowHeight = 24 + + let enabledCol = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("enabled")) + enabledCol.title = "On" + enabledCol.width = 30; enabledCol.minWidth = 30; enabledCol.maxWidth = 30 + tableView.addTableColumn(enabledCol) + + let nameCol = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("name")) + nameCol.title = "Name"; nameCol.width = 150 + tableView.addTableColumn(nameCol) + + let daysCol = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("days")) + daysCol.title = "Days"; daysCol.width = 180 + tableView.addTableColumn(daysCol) + + let timeCol = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("time")) + timeCol.title = "Time"; timeCol.width = 70 + tableView.addTableColumn(timeCol) + + let durationCol = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("duration")) + durationCol.title = "Duration"; durationCol.width = 80 + tableView.addTableColumn(durationCol) + + scrollView.documentView = tableView + contentView.addSubview(scrollView) + + let addButton = NSButton(title: "Add", target: self, action: #selector(addSchedule(_:))) + addButton.frame = NSRect(x: 10, y: 10, width: 80, height: 24) + addButton.autoresizingMask = [.maxXMargin, .maxYMargin] + contentView.addSubview(addButton) + + let editButton = NSButton(title: "Edit", target: self, action: #selector(editSchedule(_:))) + editButton.frame = NSRect(x: 100, y: 10, width: 80, height: 24) + editButton.autoresizingMask = [.maxXMargin, .maxYMargin] + contentView.addSubview(editButton) + + let removeButton = NSButton(title: "Remove", target: self, action: #selector(removeSchedule(_:))) + removeButton.frame = NSRect(x: 190, y: 10, width: 80, height: 24) + removeButton.autoresizingMask = [.maxXMargin, .maxYMargin] + contentView.addSubview(removeButton) + } + + private func reloadSchedules() { + schedules = SCScheduleManager.shared.allSchedules() + tableView.reloadData() + } + + // MARK: - Actions + + @objc private func addSchedule(_ sender: Any) { + editingSchedule = nil + showEditSheet() + } + + @objc private func editSchedule(_ sender: Any) { + let row = tableView.selectedRow + guard row >= 0, row < schedules.count else { return } + editingSchedule = schedules[row] + showEditSheet() + } + + @objc private func removeSchedule(_ sender: Any) { + let row = tableView.selectedRow + guard row >= 0, row < schedules.count else { return } + SCScheduleManager.shared.removeSchedule(schedules[row]) + SCScheduleManager.shared.syncAllLaunchdAgents() + reloadSchedules() + } + + // MARK: - NSTableViewDataSource + + func numberOfRows(in tableView: NSTableView) -> Int { + return schedules.count + } + + func tableView(_ tableView: NSTableView, objectValueFor tableColumn: NSTableColumn?, row: Int) -> Any? { + guard row >= 0, row < schedules.count, let ident = tableColumn?.identifier.rawValue else { return nil } + let schedule = schedules[row] + switch ident { + case "enabled": + return schedule.enabled + case "name": + return schedule.name + case "days": + return daysSummary(for: schedule) + case "time": + return String(format: "%02d:%02d", schedule.hour, schedule.minute) + case "duration": + if schedule.durationMinutes >= 60 { + let h = schedule.durationMinutes / 60 + let m = schedule.durationMinutes % 60 + return m > 0 ? "\(h)h \(m)m" : "\(h)h" + } + return "\(schedule.durationMinutes)m" + default: + return nil + } + } + + func tableView(_ tableView: NSTableView, setObjectValue object: Any?, for tableColumn: NSTableColumn?, row: Int) { + guard row >= 0, row < schedules.count, + tableColumn?.identifier.rawValue == "enabled", + let value = object as? Bool else { return } + var schedule = schedules[row] + schedule.enabled = value + SCScheduleManager.shared.updateSchedule(schedule) + SCScheduleManager.shared.syncAllLaunchdAgents() + reloadSchedules() + } + + // MARK: - NSTableViewDelegate + + func tableView(_ tableView: NSTableView, dataCellFor tableColumn: NSTableColumn?, row: Int) -> NSCell? { + guard tableColumn?.identifier.rawValue == "enabled" else { return nil } + let cell = NSButtonCell() + cell.setButtonType(.switch) + cell.title = "" + return cell + } + + // MARK: - Edit Sheet + + private func showEditSheet() { + let sheet = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 440, height: 320), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + sheet.title = editingSchedule != nil ? "Edit Schedule" : "New Schedule" + editSheet = sheet + + guard let content = sheet.contentView else { return } + + var y: CGFloat = 280 + let fieldX: CGFloat = 90 + let labelW: CGFloat = 80 + + // Name + addLabel("Name:", at: NSPoint(x: 10, y: y), in: content, width: labelW) + let nf = NSTextField(frame: NSRect(x: fieldX, y: y, width: 330, height: 22)) + content.addSubview(nf) + nameField = nf + + // Days + y -= 36 + addLabel("Days:", at: NSPoint(x: 10, y: y), in: content, width: labelW) + let dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] + var checkboxes: [NSButton] = [] + for i in 0..<7 { + let cb = NSButton(checkboxWithTitle: dayNames[i], target: nil, action: nil) + cb.frame = NSRect(x: fieldX + CGFloat(i) * 48, y: y, width: 46, height: 22) + cb.tag = 100 + i + content.addSubview(cb) + checkboxes.append(cb) + } + dayCheckboxes = checkboxes + + // Time + y -= 36 + addLabel("Time:", at: NSPoint(x: 10, y: y), in: content, width: labelW) + let tp = NSDatePicker(frame: NSRect(x: fieldX, y: y, width: 100, height: 22)) + tp.datePickerStyle = .textFieldAndStepper + tp.datePickerElements = .hourMinute + var comps = DateComponents() + comps.hour = 9; comps.minute = 0; comps.year = 2025; comps.month = 1; comps.day = 1 + tp.dateValue = Calendar.current.date(from: comps) ?? Date() + content.addSubview(tp) + timePicker = tp + + // Duration + y -= 36 + addLabel("Duration:", at: NSPoint(x: 10, y: y), in: content, width: labelW) + let df = NSTextField(frame: NSRect(x: fieldX, y: y, width: 80, height: 22)) + df.placeholderString = "minutes" + content.addSubview(df) + durationField = df + let minLabel = NSTextField(labelWithString: "minutes") + minLabel.frame = NSRect(x: fieldX + 86, y: y, width: 60, height: 22) + content.addSubview(minLabel) + + // Blocklist + y -= 36 + addLabel("Blocklist:", at: NSPoint(x: 10, y: y), in: content, width: labelW) + let bf = NSTextField(frame: NSRect(x: fieldX, y: y - 60, width: 330, height: 80)) + bf.placeholderString = "Enter domains, one per line (e.g. facebook.com)" + content.addSubview(bf) + blocklistField = bf + + // Buttons + let cancelBtn = NSButton(title: "Cancel", target: self, action: #selector(cancelEditSheet(_:))) + cancelBtn.frame = NSRect(x: 260, y: 10, width: 80, height: 30) + cancelBtn.keyEquivalent = "\u{1b}" + content.addSubview(cancelBtn) + + let saveBtn = NSButton(title: "Save", target: self, action: #selector(saveEditSheet(_:))) + saveBtn.frame = NSRect(x: 350, y: 10, width: 80, height: 30) + saveBtn.keyEquivalent = "\r" + content.addSubview(saveBtn) + + // Populate if editing + if let editing = editingSchedule { + nameField?.stringValue = editing.name + for day in editing.weekdays where day >= 0 && day < 7 { + dayCheckboxes[day].state = .on + } + var timeComps = DateComponents() + timeComps.hour = editing.hour; timeComps.minute = editing.minute + timeComps.year = 2025; timeComps.month = 1; timeComps.day = 1 + if let date = Calendar.current.date(from: timeComps) { + timePicker?.dateValue = date + } + durationField?.integerValue = editing.durationMinutes + blocklistField?.stringValue = editing.blocklist.joined(separator: "\n") + } else { + durationField?.integerValue = 60 + } + + window?.beginSheet(sheet, completionHandler: nil) + } + + @objc private func cancelEditSheet(_ sender: Any) { + guard let sheet = editSheet else { return } + window?.endSheet(sheet) + editSheet = nil + editingSchedule = nil + } + + @objc private func saveEditSheet(_ sender: Any) { + guard let sheet = editSheet else { return } + + var schedule = editingSchedule ?? SCSchedule() + schedule.name = nameField?.stringValue ?? "" + + var days: [Int] = [] + for i in 0..<7 { + if dayCheckboxes[i].state == .on { days.append(i) } + } + schedule.weekdays = days + + if let picker = timePicker { + let cal = Calendar.current + schedule.hour = cal.component(.hour, from: picker.dateValue) + schedule.minute = cal.component(.minute, from: picker.dateValue) + } + + schedule.durationMinutes = max(durationField?.integerValue ?? 1, 1) + + let raw = blocklistField?.stringValue ?? "" + schedule.blocklist = raw.components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + + if editingSchedule != nil { + SCScheduleManager.shared.updateSchedule(schedule) + } else { + schedule.enabled = true + SCScheduleManager.shared.addSchedule(schedule) + } + + SCScheduleManager.shared.syncAllLaunchdAgents() + + window?.endSheet(sheet) + editSheet = nil + editingSchedule = nil + reloadSchedules() + } + + // MARK: - Helpers + + private func addLabel(_ text: String, at origin: NSPoint, in view: NSView, width: CGFloat) { + let label = NSTextField(labelWithString: text) + label.frame = NSRect(x: origin.x, y: origin.y, width: width, height: 22) + label.alignment = .right + view.addSubview(label) + } + + private func daysSummary(for schedule: SCSchedule) -> String { + if schedule.weekdays.isEmpty { return "Daily" } + if schedule.weekdays.count == 7 { return "Every day" } + + let weekdaySet = Set(schedule.weekdays) + if weekdaySet == Set([1, 2, 3, 4, 5]) { return "Weekdays" } + if weekdaySet == Set([0, 6]) { return "Weekends" } + + let abbrevs = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] + return schedule.weekdays.sorted().compactMap { idx in + idx >= 0 && idx < 7 ? abbrevs[idx] : nil + }.joined(separator: ", ") + } +} diff --git a/App/UI/TimerView.swift b/App/UI/TimerView.swift new file mode 100644 index 00000000..e98957b1 --- /dev/null +++ b/App/UI/TimerView.swift @@ -0,0 +1,78 @@ +import SwiftUI + +struct TimerView: View { + @ObservedObject var appController: AppController + + @State private var timeRemaining: TimeInterval = 0 + @State private var timer: Timer? + + private var endDate: Date? { + SCSettings.shared.value(for: "BlockEndDate") as? Date + } + + var body: some View { + VStack(spacing: 0) { + Spacer() + + VStack(spacing: 4) { + Text(timeString) + .font(.system(size: 56, weight: .bold, design: .monospaced)) + .monospacedDigit() + + Text("remaining") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.tertiary) + .textCase(.uppercase) + } + + Spacer().frame(height: 16) + + if let end = endDate { + Text("Block ends at \(end, style: .time)") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { startTimer() } + .onDisappear { stopTimer() } + } + + private var timeString: String { + if timeRemaining <= 0 { return "00:00:00" } + let h = Int(timeRemaining) / 3600 + let m = (Int(timeRemaining) % 3600) / 60 + let s = Int(timeRemaining) % 60 + return String(format: "%02d:%02d:%02d", h, m, s) + } + + private func startTimer() { + updateTimeRemaining() + timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in + updateTimeRemaining() + } + } + + private func stopTimer() { + timer?.invalidate() + timer = nil + } + + private func updateTimeRemaining() { + guard let end = endDate else { + timeRemaining = 0 + return + } + timeRemaining = max(end.timeIntervalSinceNow, 0) + + if timeRemaining <= 0 { + // Block expired + stopTimer() + SCSettings.shared.setValue(false, for: "BlockIsRunning") + SCSettings.shared.synchronize() + appController.blockIsOn = false + } + } +} diff --git a/App/UI/TimerWindowController.swift b/App/UI/TimerWindowController.swift new file mode 100644 index 00000000..3f5b81c4 --- /dev/null +++ b/App/UI/TimerWindowController.swift @@ -0,0 +1,208 @@ +import Cocoa + +/// Window shown while a block is running, displaying the countdown timer. +final class TimerWindowController: NSWindowController { + private weak var appController: AppController? + private let settings = SCSettings.shared + private let defaults = UserDefaults.standard + + private var timerLabel: NSTextField! + private var addToBlockButton: NSButton! + private var extendButton: NSPopUpButton! + private var timer: Timer? + private var blockEndDate: Date? + + // Add-to-block sheet + private var addSheet: NSWindow? + private var addTextField: NSTextField? + + init(appController: AppController) { + self.appController = appController + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 340, height: 200), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.title = "Stone — Block Active" + window.center() + window.isReleasedWhenClosed = false + + super.init(window: window) + window.delegate = self + buildUI() + loadBlockEndDate() + startTimer() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + // MARK: - Build UI + + private func buildUI() { + guard let contentView = window?.contentView else { return } + contentView.wantsLayer = true + + // Timer label + timerLabel = NSTextField(labelWithString: "00:00:00") + timerLabel.font = NSFont.monospacedDigitSystemFont(ofSize: 48, weight: .medium) + timerLabel.alignment = .center + timerLabel.frame = NSRect(x: 20, y: 100, width: 300, height: 60) + contentView.addSubview(timerLabel) + + // Add to Block button + addToBlockButton = NSButton(title: "Add to Block...", target: self, action: #selector(addToBlockClicked(_:))) + addToBlockButton.bezelStyle = .rounded + addToBlockButton.frame = NSRect(x: 20, y: 30, width: 140, height: 32) + contentView.addSubview(addToBlockButton) + + // Extend Time popup + extendButton = NSPopUpButton(frame: NSRect(x: 180, y: 30, width: 140, height: 32), pullsDown: true) + extendButton.addItem(withTitle: "Extend Time") + extendButton.addItems(withTitles: ["+15 minutes", "+30 minutes", "+1 hour"]) + extendButton.target = self + extendButton.action = #selector(extendTimeSelected(_:)) + contentView.addSubview(extendButton) + + // Disable add-to-block if allowlist mode + let isAllowlist = settings.value(for: "ActiveBlockAsWhitelist") as? Bool ?? false + addToBlockButton.isEnabled = !isAllowlist + } + + // MARK: - Timer + + private func loadBlockEndDate() { + blockEndDate = settings.value(for: "BlockEndDate") as? Date + } + + private func startTimer() { + updateTimerDisplay() + timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + self?.updateTimerDisplay() + } + } + + private func updateTimerDisplay() { + guard let endDate = blockEndDate else { + timerLabel.stringValue = "Block not active" + return + } + + let remaining = Int(endDate.timeIntervalSinceNow) + + if remaining <= 0 { + timerLabel.stringValue = "Finishing..." + appController?.refreshUserInterface() + return + } + + let hours = remaining / 3600 + let minutes = (remaining % 3600) / 60 + let seconds = remaining % 60 + timerLabel.stringValue = String(format: "%02d:%02d:%02d", hours, minutes, seconds) + + // Badge the dock icon + if defaults.bool(forKey: "BadgeIconEnabled") { + let badgeMins = seconds > 0 && minutes < 59 ? minutes + 1 : minutes + NSApp.dockTile.badgeLabel = String(format: "%02d:%02d", hours, badgeMins) + } + } + + func blockEnded() { + timer?.invalidate() + timer = nil + timerLabel.stringValue = "Block not active" + NSApp.dockTile.badgeLabel = nil + } + + func configurationChanged() { + loadBlockEndDate() + updateTimerDisplay() + } + + // MARK: - Add to Block + + @objc private func addToBlockClicked(_ sender: NSButton) { + let sheet = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 300, height: 120), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + sheet.title = "Add to Block" + + let cv = sheet.contentView! + + let label = NSTextField(labelWithString: "Enter domain to block:") + label.frame = NSRect(x: 20, y: 80, width: 260, height: 20) + cv.addSubview(label) + + let textField = NSTextField(frame: NSRect(x: 20, y: 50, width: 260, height: 24)) + textField.placeholderString = "example.com" + cv.addSubview(textField) + addTextField = textField + + let cancelBtn = NSButton(title: "Cancel", target: self, action: #selector(cancelAddSheet(_:))) + cancelBtn.bezelStyle = .rounded + cancelBtn.frame = NSRect(x: 100, y: 12, width: 80, height: 32) + cancelBtn.keyEquivalent = "\u{1b}" // Escape + cv.addSubview(cancelBtn) + + let addBtn = NSButton(title: "Add", target: self, action: #selector(confirmAddSheet(_:))) + addBtn.bezelStyle = .rounded + addBtn.frame = NSRect(x: 190, y: 12, width: 80, height: 32) + addBtn.keyEquivalent = "\r" + cv.addSubview(addBtn) + + addSheet = sheet + + window?.beginSheet(sheet, completionHandler: nil) + } + + @objc private func cancelAddSheet(_ sender: Any) { + guard let sheet = addSheet else { return } + window?.endSheet(sheet) + addSheet = nil + addTextField = nil + } + + @objc private func confirmAddSheet(_ sender: Any) { + guard let sheet = addSheet, let text = addTextField?.stringValue, !text.isEmpty else { return } + appController?.addToBlocklist(text) + window?.endSheet(sheet) + addSheet = nil + addTextField = nil + } + + // MARK: - Extend Block + + @objc private func extendTimeSelected(_ sender: NSPopUpButton) { + let index = sender.indexOfSelectedItem + let minuteValues = [0, 15, 30, 60] // index 0 is the title + guard index > 0, index < minuteValues.count else { return } + appController?.extendBlock(minutes: minuteValues[index]) + // Reset popup to title + sender.selectItem(at: 0) + } + + // MARK: - Window Delegate + + override func close() { + timer?.invalidate() + timer = nil + super.close() + } +} + +// MARK: - NSWindowDelegate +extension TimerWindowController: NSWindowDelegate { + func windowShouldClose(_ sender: NSWindow) -> Bool { + // Cannot close while block is running + if SCBlockUtilities.anyBlockIsRunning() { + return false + } + return true + } +} diff --git a/AppController.h b/AppController.h index b613d7cf..8bca72da 100755 --- a/AppController.h +++ b/AppController.h @@ -20,12 +20,14 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -// Forward declaration to avoid compiler weirdness +// Forward declarations to avoid compiler weirdness @class TimerWindowController; +@class ScheduleListWindowController; #import #import "DomainListWindowController.h" #import "TimerWindowController.h" +#import "ScheduleListWindowController.h" #import #import #import @@ -51,6 +53,7 @@ NSUserDefaults* defaults_; SCSettings* settings_; NSLock* refreshUILock_; + ScheduleListWindowController* scheduleListWindowController_; BOOL blockIsOn; BOOL addingBlock; } @@ -125,6 +128,9 @@ // Changed property to manual accessor for pre-Leopard compatibility @property (nonatomic, readonly, strong) id initialWindow; +// Opens the schedule list window for managing recurring blocks. +- (IBAction)openScheduleList:(id)sender; + // opens the SelfControl FAQ in the default browser - (IBAction)openFAQ:(id)sender; diff --git a/AppController.m b/AppController.m index 242bdabf..dc78c338 100755 --- a/AppController.m +++ b/AppController.m @@ -32,6 +32,8 @@ #import "SCBlockFileReaderWriter.h" #import "SCUIUtilities.h" #import +#import "SCScheduleManager.h" +#import "ScheduleListWindowController.h" @interface AppController () {} @@ -418,7 +420,28 @@ - (void)applicationDidFinishLaunching:(NSNotification *)aNotification { blocklistTeaserLabel_.stringValue = [SCUIUtilities blockTeaserStringWithMaxLength: 60]; [self refreshUserInterface]; - + + // Sync recurring scheduled block launchd agents on launch + [[SCScheduleManager sharedManager] syncAllLaunchdAgents]; + + // Programmatically add a "Schedules..." menu item to the main menu. + // We do this in code rather than editing MainMenu.xib. + NSMenu *mainMenu = [NSApp mainMenu]; + // Find the SelfControl menu (first item after Apple menu, index 1) + if (mainMenu.itemArray.count > 1) { + NSMenu *appSubmenu = [mainMenu.itemArray[1] submenu]; + if (appSubmenu == nil) { + appSubmenu = [mainMenu.itemArray[0] submenu]; + } + NSMenuItem *schedulesItem = [[NSMenuItem alloc] initWithTitle:@"Schedules..." + action:@selector(openScheduleList:) + keyEquivalent:@""]; + schedulesItem.target = self; + // Insert before the last separator or at the end + [appSubmenu addItem:[NSMenuItem separatorItem]]; + [appSubmenu addItem:schedulesItem]; + } + NSOperatingSystemVersion minRequiredVersion = (NSOperatingSystemVersion){10,10,0}; // Yosemite NSString* minRequiredVersionString = @"10.10 (Yosemite)"; if (![[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion: minRequiredVersion]) { @@ -790,6 +813,14 @@ - (BOOL)application:(NSApplication*)theApplication openFile:(NSString*)filename return [self openSavedBlockFileAtURL: [NSURL fileURLWithPath: filename]]; } +- (IBAction)openScheduleList:(id)sender { + if (scheduleListWindowController_ == nil) { + scheduleListWindowController_ = [[ScheduleListWindowController alloc] init]; + } + [scheduleListWindowController_.window center]; + [scheduleListWindowController_ showWindow:self]; +} + - (IBAction)openFAQ:(id)sender { [SCSentry addBreadcrumb: @"Opened SelfControl FAQ" category:@"app"]; NSURL *url=[NSURL URLWithString: @"https://github.com/SelfControlApp/selfcontrol/wiki/FAQ#q-selfcontrols-timer-is-at-0000-and-i-cant-start-a-new-block-and-im-freaking-out"]; diff --git a/CLI/CLIMain.swift b/CLI/CLIMain.swift new file mode 100644 index 00000000..fba6b4b2 --- /dev/null +++ b/CLI/CLIMain.swift @@ -0,0 +1,206 @@ +import Foundation + +/// Entry point for the stone-cli command-line tool. +@main +struct CLIEntry { + static func main() { + let args = CommandLine.arguments + + guard args.count > 1 else { + printUsage() + exit(EXIT_SUCCESS) + } + + // Parse --uid if present + var controllingUID = getuid() + if let uidIdx = args.firstIndex(of: "--uid"), uidIdx + 1 < args.count, + let uid = UInt32(args[uidIdx + 1]) { + controllingUID = uid_t(uid) + } + + let command = args.first { !$0.hasPrefix("--") && $0 != args[0] && $0 != String(controllingUID) } + ?? args[1] + + switch command { + case "start", "--start", "--install": + handleStart(args: args, controllingUID: controllingUID) + + case "is-running", "--isrunning", "-r": + let isRunning = SCBlockUtilities.anyBlockIsRunning() + print(isRunning ? "YES" : "NO") + + case "print-settings", "--printsettings", "-p": + let settings = SCSettings.shared.dictionaryRepresentation() + print(settings) + + case "version", "--version", "-v": + print(StoneConstants.versionString) + + default: + printUsage() + } + } + + // MARK: - Start Command + + static func handleStart(args: [String], controllingUID: uid_t) { + if SCBlockUtilities.anyBlockIsRunning() { + NSLog("ERROR: Block is already running") + exit(74) // EX_CONFIG + } + + // Parse --blocklist + var blocklistPath: String? + if let idx = args.firstIndex(where: { $0 == "--blocklist" || $0 == "-b" }), + idx + 1 < args.count { + blocklistPath = args[idx + 1] + } + + // Parse --enddate and --duration (mutually exclusive) + var blockEndDate: Date? + let endDateStr = argValue(args, for: ["--enddate", "-d"]) + let durationStr = argValue(args, for: ["--duration"]) + + if endDateStr != nil && durationStr != nil { + NSLog("ERROR: --enddate and --duration are mutually exclusive.") + exit(64) // EX_USAGE + } + + if let d = durationStr, let minutes = Int(d), minutes > 0 { + blockEndDate = Date(timeIntervalSinceNow: TimeInterval(minutes * 60)) + } else if let e = endDateStr { + blockEndDate = ISO8601DateFormatter().date(from: e) + } + + // Legacy positional fallback: argv[3] = blocklist, argv[4] = enddate + if (blocklistPath == nil || blockEndDate == nil), + args.count > 4 { + blocklistPath = blocklistPath ?? args[3] + blockEndDate = blockEndDate ?? ISO8601DateFormatter().date(from: args[4]) + } + + var blocklist: [String] + var blockAsWhitelist = false + + if let path = blocklistPath, let endDate = blockEndDate, endDate.timeIntervalSinceNow >= 1 { + guard let props = SCBlockFileReaderWriter.readBlocklist(from: URL(fileURLWithPath: path)) else { + NSLog("ERROR: Block could not be read from file %@", path) + exit(74) + } + blocklist = props["Blocklist"] as? [String] ?? [] + blockAsWhitelist = props["BlockAsWhitelist"] as? Bool ?? false + } else { + // Fall back to UserDefaults + let defaults = UserDefaults.standard + defaults.register(defaults: StoneConstants.defaultUserDefaults) + blocklist = defaults.stringArray(forKey: "Blocklist") ?? [] + blockAsWhitelist = defaults.bool(forKey: "BlockAsWhitelist") + let durationSecs = max(defaults.integer(forKey: "BlockDuration") * 60, 0) + blockEndDate = Date(timeIntervalSinceNow: TimeInterval(durationSecs)) + } + + guard let endDate = blockEndDate, endDate.timeIntervalSinceNow >= 1 else { + NSLog("ERROR: Block end date is not in the future") + exit(74) + } + + guard !blocklist.isEmpty || blockAsWhitelist else { + NSLog("ERROR: Blocklist is empty") + exit(74) + } + + // Parse --settings (JSON block settings override) + var blockSettings = defaultBlockSettings() + if let settingsStr = argValue(args, for: ["--settings", "-s"]), + let data = settingsStr.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + for (key, value) in json { + blockSettings[key] = value + } + } + + // Start the block via XPC + let xpc = SCXPCClient() + let semaphore = DispatchSemaphore(value: 0) + + xpc.installDaemon { error in + if let error = error { + NSLog("ERROR: Failed to install daemon: %@", error.localizedDescription) + exit(70) // EX_SOFTWARE + } + + xpc.refreshConnectionAndRun { + xpc.startBlock( + controllingUID: UInt32(controllingUID), + blocklist: blocklist, + isAllowlist: blockAsWhitelist, + endDate: endDate, + blockSettings: blockSettings + ) { error in + if let error = error { + NSLog("ERROR: Daemon failed to start block: %@", error.localizedDescription) + exit(70) + } + NSLog("INFO: Block successfully added.") + semaphore.signal() + } + } + } + + if Thread.isMainThread { + while semaphore.wait(timeout: .now()) != .success { + RunLoop.current.run(mode: .default, before: Date()) + } + } else { + semaphore.wait() + } + } + + // MARK: - Helpers + + static func argValue(_ args: [String], for flags: [String]) -> String? { + for flag in flags { + if let idx = args.firstIndex(of: flag), idx + 1 < args.count { + return args[idx + 1] + } + } + return nil + } + + static func defaultBlockSettings() -> [String: Any] { + let defaults = UserDefaults.standard + defaults.register(defaults: StoneConstants.defaultUserDefaults) + return [ + "ClearCaches": defaults.bool(forKey: "ClearCaches"), + "AllowLocalNetworks": defaults.bool(forKey: "AllowLocalNetworks"), + "EvaluateCommonSubdomains": defaults.bool(forKey: "EvaluateCommonSubdomains"), + "IncludeLinkedDomains": defaults.bool(forKey: "IncludeLinkedDomains"), + "BlockSoundShouldPlay": defaults.bool(forKey: "BlockSoundShouldPlay"), + "BlockSound": defaults.integer(forKey: "BlockSound"), + "EnableErrorReporting": defaults.bool(forKey: "EnableErrorReporting"), + ] + } + + static func printUsage() { + print(""" + Stone CLI Tool v\(StoneConstants.versionString) + Usage: stone-cli [--uid ] [] + + Valid commands: + + start --> starts a Stone block + --blocklist + --enddate + --duration + --settings + + is-running --> prints YES if a Stone block is currently running, or NO otherwise + + print-settings --> prints the Stone settings being used for the active block + + version --> prints the version of the Stone CLI tool + + Example: stone-cli start --blocklist /path/to/blocklist.stone --duration 60 + """) + } +} diff --git a/CLI/stone-cli-Info.plist b/CLI/stone-cli-Info.plist new file mode 100644 index 00000000..c52b3311 --- /dev/null +++ b/CLI/stone-cli-Info.plist @@ -0,0 +1,16 @@ + + + + + CFBundleIdentifier + com.max4c.stone-cli + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + stone-cli + CFBundleVersion + 1 + CFBundleShortVersionString + 1.0.0 + + diff --git a/Common/Block/BlockManager.swift b/Common/Block/BlockManager.swift new file mode 100644 index 00000000..8b9df898 --- /dev/null +++ b/Common/Block/BlockManager.swift @@ -0,0 +1,158 @@ +import Foundation + +/// Orchestrates PacketFilter + HostFileBlockerSet to enforce a website block. +/// Handles DNS resolution, common subdomain expansion, and rule generation. +final class BlockManager { + private let isAllowlist: Bool + private let allowLocal: Bool + private let includeCommonSubdomains: Bool + private let includeLinkedDomains: Bool + private let packetFilter: PacketFilter + private let hostsBlocker: HostFileBlockerSet + private var isAppending = false + + init(isAllowlist: Bool, + allowLocal: Bool = true, + includeCommonSubdomains: Bool = true, + includeLinkedDomains: Bool = true) { + self.isAllowlist = isAllowlist + self.allowLocal = allowLocal + self.includeCommonSubdomains = includeCommonSubdomains + self.includeLinkedDomains = includeLinkedDomains + self.packetFilter = PacketFilter(isAllowlist: isAllowlist) + self.hostsBlocker = HostFileBlockerSet() + } + + // MARK: - Block Lifecycle + + func prepareToAddBlock() { + // Nothing to prepare — just reset state + } + + func addEntries(from strings: [String]) { + var allEntries: [String] = [] + + for string in strings { + let entry = BlockEntry(string: string) + allEntries.append(entry.hostname) + + // Add common subdomains if enabled + if includeCommonSubdomains && !entry.isIPAddress { + let subdomains = commonSubdomains(for: entry.hostname) + allEntries.append(contentsOf: subdomains) + } + } + + // Resolve DNS and add rules + for hostname in allEntries { + let entry = BlockEntry(string: hostname) + + if entry.isIPAddress { + // IP address — add directly to pf + packetFilter.addRule(ip: entry.hostname, port: entry.port ?? 0, maskLen: entry.maskLen ?? 0) + } else { + // Hostname — add to hosts file and resolve for pf + if !isAppending { + hostsBlocker.addEntry(hostname: entry.hostname) + } + + // Resolve hostname to IPs for pf rules + let ips = resolveHostname(entry.hostname) + for ip in ips { + packetFilter.addRule(ip: ip, port: entry.port ?? 0, maskLen: 0) + } + } + } + } + + func finalizeBlock() { + if !isAppending { + packetFilter.writeConfiguration() + packetFilter.startBlock() + hostsBlocker.writeNewFileContents() + } else { + packetFilter.finishAppending() + packetFilter.refreshPFRules() + } + } + + func clearBlock() -> Bool { + let pfResult = packetFilter.stopBlock(force: false) + let hostsResult = hostsBlocker.clearBlock() + return pfResult == 0 && hostsResult + } + + // MARK: - Append Mode (add entries to a running block) + + func enterAppendMode() { + isAppending = true + packetFilter.enterAppendMode() + } + + func finishAppending() { + isAppending = false + packetFilter.finishAppending() + } + + // MARK: - DNS Resolution + + private func resolveHostname(_ hostname: String) -> [String] { + var ips: [String] = [] + + let host = CFHostCreateWithName(nil, hostname as CFString).takeRetainedValue() + var resolved: DarwinBoolean = false + CFHostStartInfoResolution(host, .addresses, nil) + guard let addresses = CFHostGetAddressing(host, &resolved)?.takeUnretainedValue() as? [Data] else { + return ips + } + + for addrData in addresses { + addrData.withUnsafeBytes { rawPtr in + let sockaddr = rawPtr.baseAddress!.assumingMemoryBound(to: sockaddr.self) + var hostBuffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + if getnameinfo(sockaddr, socklen_t(addrData.count), + &hostBuffer, socklen_t(hostBuffer.count), + nil, 0, NI_NUMERICHOST) == 0 { + ips.append(String(cString: hostBuffer)) + } + } + } + + return ips + } + + // MARK: - Common Subdomains + + private func commonSubdomains(for hostname: String) -> [String] { + let prefixes = ["www.", "m.", "mobile."] + var result: [String] = [] + + for prefix in prefixes { + let sub = prefix + hostname + if sub != hostname { + result.append(sub) + } + } + + // Special handling for Google domains + if isGoogleDomain(hostname) { + result.append(contentsOf: googleSubdomains(for: hostname)) + } + + return result + } + + private func isGoogleDomain(_ hostname: String) -> Bool { + let googlePatterns = ["google.", "youtube.", "gmail.", "googleapis.", "gstatic.", "googlevideo."] + return googlePatterns.contains { hostname.contains($0) } + } + + private func googleSubdomains(for hostname: String) -> [String] { + // Google uses many regional and service subdomains + let extra = [ + "www.\(hostname)", "apis.\(hostname)", "ssl.\(hostname)", + "encrypted.\(hostname)", "clients1.\(hostname)" + ] + return extra + } +} diff --git a/Common/Block/HostFileBlocker.swift b/Common/Block/HostFileBlocker.swift new file mode 100644 index 00000000..1b724bf3 --- /dev/null +++ b/Common/Block/HostFileBlocker.swift @@ -0,0 +1,129 @@ +import Foundation + +/// Manages blocking entries in a hosts file using sentinel markers. +final class HostFileBlocker { + private let filePath: String + private var newEntries: [String] = [] + private let lock = NSLock() + + init(filePath: String = "/etc/hosts") { + self.filePath = filePath + } + + // MARK: - Entry Management + + func addEntry(hostname: String) { + lock.lock() + defer { lock.unlock() } + let clean = hostname.trimmingCharacters(in: .whitespacesAndNewlines) + guard !clean.isEmpty else { return } + newEntries.append("0.0.0.0\t\(clean)") + newEntries.append("::1\t\(clean)") + } + + func addEntries(_ hostnames: [String]) { + for h in hostnames { addEntry(hostname: h) } + } + + // MARK: - File Operations + + func writeNewFileContents() -> Bool { + guard !newEntries.isEmpty else { return true } + + guard var contents = try? String(contentsOfFile: filePath, encoding: .utf8) else { + NSLog("HostFileBlocker: Failed to read %@", filePath) + return false + } + + // Remove any existing block first + contents = removeBlockSection(from: contents) + + // Append new block + var block = "\n\(StoneConstants.hostsSentinelBegin)\n" + for entry in newEntries { + block += entry + "\n" + } + block += "\(StoneConstants.hostsSentinelEnd)\n" + + contents += block + + do { + try contents.write(toFile: filePath, atomically: true, encoding: .utf8) + return true + } catch { + NSLog("HostFileBlocker: Failed to write %@: %@", filePath, error.localizedDescription) + return false + } + } + + func clearBlock() -> Bool { + guard var contents = try? String(contentsOfFile: filePath, encoding: .utf8) else { + return false + } + + let cleaned = removeBlockSection(from: contents) + if cleaned == contents { return true } // nothing to remove + + do { + try cleaned.write(toFile: filePath, atomically: true, encoding: .utf8) + return true + } catch { + NSLog("HostFileBlocker: Failed to clear block in %@: %@", filePath, error.localizedDescription) + return false + } + } + + func isBlockActive() -> Bool { + guard let contents = try? String(contentsOfFile: filePath, encoding: .utf8) else { + return false + } + return contents.contains(StoneConstants.hostsSentinelBegin) + } + + // MARK: - Append Mode (for adding to a running block) + + func appendEntries(_ hostnames: [String]) -> Bool { + guard var contents = try? String(contentsOfFile: filePath, encoding: .utf8) else { + return false + } + + guard let endRange = contents.range(of: StoneConstants.hostsSentinelEnd) else { + return false + } + + var newLines = "" + for h in hostnames { + let clean = h.trimmingCharacters(in: .whitespacesAndNewlines) + guard !clean.isEmpty else { continue } + newLines += "0.0.0.0\t\(clean)\n" + newLines += "::1\t\(clean)\n" + } + + contents.insert(contentsOf: newLines, at: endRange.lowerBound) + + do { + try contents.write(toFile: filePath, atomically: true, encoding: .utf8) + return true + } catch { + return false + } + } + + // MARK: - Private + + private func removeBlockSection(from contents: String) -> String { + guard let beginRange = contents.range(of: StoneConstants.hostsSentinelBegin), + let endRange = contents.range(of: StoneConstants.hostsSentinelEnd) else { + return contents + } + + // Include the newline after END marker + let removeEnd = contents.index(after: endRange.upperBound) < contents.endIndex + ? contents.index(after: endRange.upperBound) + : endRange.upperBound + + var result = contents + result.removeSubrange(beginRange.lowerBound.. Bool { + var success = true + for blocker in blockers { + if !blocker.writeNewFileContents() { success = false } + } + return success + } + + func clearBlock() -> Bool { + var success = true + for blocker in blockers { + if !blocker.clearBlock() { success = false } + } + return success + } + + func isBlockActive() -> Bool { + for blocker in blockers { + if blocker.isBlockActive() { return true } + } + return false + } + + func appendEntries(_ hostnames: [String]) -> Bool { + var success = true + for blocker in blockers { + if !blocker.appendEntries(hostnames) { success = false } + } + return success + } +} diff --git a/Common/Block/PacketFilter.swift b/Common/Block/PacketFilter.swift new file mode 100644 index 00000000..6bc0fa55 --- /dev/null +++ b/Common/Block/PacketFilter.swift @@ -0,0 +1,266 @@ +import Foundation + +// MARK: - Protocol for testability + +protocol PFConfigurationStore { + var pfAnchorPath: String { get } + var pfConfPath: String { get } + var pfTokenPath: String { get } +} + +struct DefaultPFConfigurationStore: PFConfigurationStore { + var pfAnchorPath: String { "/etc/pf.anchors/\(StoneConstants.pfAnchorName)" } + var pfConfPath: String { "/etc/pf.conf" } + var pfTokenPath: String { "/etc/StonePFToken" } +} + +// MARK: - PacketFilter + +final class PacketFilter { + + private let isAllowlist: Bool + private let store: PFConfigurationStore + private var rules = "" + private var appendFileHandle: FileHandle? + private let lock = NSLock() + + private static let pfctlPath = "/sbin/pfctl" + + init(isAllowlist: Bool, store: PFConfigurationStore = DefaultPFConfigurationStore()) { + self.isAllowlist = isAllowlist + self.store = store + } + + // MARK: - Static checks + + static func blockFoundInPF(store: PFConfigurationStore = DefaultPFConfigurationStore()) -> Bool { + guard let contents = try? String(contentsOfFile: store.pfConfPath, encoding: .utf8) else { + return false + } + return contents.contains("anchor \"\(StoneConstants.pfAnchorName)\"") + } + + func containsStoneBlock() -> Bool { + guard let contents = try? String(contentsOfFile: store.pfConfPath, encoding: .utf8) else { + return false + } + return contents.contains(StoneConstants.pfAnchorName) + } + + // MARK: - Rule generation + + private func ruleStrings(ip: String?, port: Int, maskLen: Int) -> [String] { + var target = "from any to " + target += ip ?? "any" + if maskLen != 0 { + target += "/\(maskLen)" + } + if port != 0 { + target += " port \(port)" + } + + if isAllowlist { + return [ + "pass out proto tcp \(target)\n", + "pass out proto udp \(target)\n" + ] + } else { + return [ + "block return out proto tcp \(target)\n", + "block return out proto udp \(target)\n" + ] + } + } + + func addRule(ip: String?, port: Int, maskLen: Int) { + lock.lock() + defer { lock.unlock() } + + let strings = ruleStrings(ip: ip, port: port, maskLen: maskLen) + for rule in strings { + if let handle = appendFileHandle { + if let data = rule.data(using: .utf8) { + handle.write(data) + } + } else { + rules += rule + } + } + } + + // MARK: - Configuration writing + + private func blockHeader() -> String { + var header = """ + # Options + set block-policy drop + set fingerprints "/etc/pf.os" + set ruleset-optimization basic + set skip on lo0 + + # + # \(StoneConstants.pfAnchorName) ruleset for Stone blocks + #\n + """ + + if isAllowlist { + header += "block return out proto tcp from any to any\n" + header += "block return out proto udp from any to any\n\n" + } + + return header + } + + private func allowlistFooter() -> String { + return """ + pass out proto tcp from any to any port 53 + pass out proto udp from any to any port 53 + pass out proto udp from any to any port 123 + pass out proto udp from any to any port 67 + pass out proto tcp from any to any port 67 + pass out proto udp from any to any port 68 + pass out proto tcp from any to any port 68 + pass out proto udp from any to any port 5353 + pass out proto tcp from any to any port 5353\n + """ + } + + func writeConfiguration() { + var config = blockHeader() + config += rules + if isAllowlist { + config += allowlistFooter() + } + try? config.write(toFile: store.pfAnchorPath, atomically: true, encoding: .utf8) + } + + // MARK: - Append mode + + func enterAppendMode() { + if isAllowlist { + NSLog("WARNING: Can't append rules to allowlist blocks - ignoring") + return + } + + appendFileHandle = FileHandle(forWritingAtPath: store.pfAnchorPath) + guard appendFileHandle != nil else { + NSLog("ERROR: Failed to get handle for pf.anchors file while attempting to append rules") + return + } + appendFileHandle?.seekToEndOfFile() + } + + func finishAppending() { + try? appendFileHandle?.close() + appendFileHandle = nil + } + + // MARK: - pf.conf management + + func addStoneConfig() { + guard var pfConf = try? String(contentsOfFile: store.pfConfPath, encoding: .utf8) else { + return + } + + // [Fix #5] Check for the anchor name, not the path, to match containsStoneBlock() + if !pfConf.contains("anchor \"\(StoneConstants.pfAnchorName)\"") { + pfConf += "\n" + pfConf += "anchor \"\(StoneConstants.pfAnchorName)\"\n" + pfConf += "load anchor \"\(StoneConstants.pfAnchorName)\" from \"\(store.pfAnchorPath)\"\n" + } + + try? pfConf.write(toFile: store.pfConfPath, atomically: true, encoding: .utf8) + } + + // MARK: - Start / Stop + + @discardableResult + func startBlock() -> Int32 { + addStoneConfig() + writeConfiguration() + + let args = ["-E", "-f", store.pfConfPath, "-F", "states"] + let (status, output) = runPfctl(args) + + // Parse and save the token + let lines = output.components(separatedBy: "\n") + for line in lines { + if line.hasPrefix("Token : ") { + let token = String(line.dropFirst("Token : ".count)) + writePFToken(token) + break + } + } + + return status + } + + @discardableResult + func refreshPFRules() -> Int32 { + let args = ["-f", store.pfConfPath, "-F", "states"] + let (status, _) = runPfctl(args) + return status + } + + @discardableResult + func stopBlock(force: Bool) -> Int32 { + let token = readPFToken() + + // Clear anchor file + try? "".write(toFile: store.pfAnchorPath, atomically: true, encoding: .utf8) + + // Remove Stone lines from pf.conf + if let mainConf = try? String(contentsOfFile: store.pfConfPath, encoding: .utf8) { + let lines = mainConf.components(separatedBy: "\n") + var newConf = lines + .filter { !$0.contains(StoneConstants.pfAnchorName) } + .joined(separator: "\n") + newConf = newConf.trimmingCharacters(in: .whitespacesAndNewlines) + "\n" + try? newConf.write(toFile: store.pfConfPath, atomically: true, encoding: .utf8) + } + + let args: [String] + if let token = token, !token.isEmpty, !force { + args = ["-X", token, "-f", store.pfConfPath] + } else { + args = ["-d", "-f", store.pfConfPath] + } + + let (status, _) = runPfctl(args) + return status + } + + // MARK: - Token persistence + + private func writePFToken(_ token: String) { + try? token.write(toFile: store.pfTokenPath, atomically: true, encoding: .utf8) + } + + private func readPFToken() -> String? { + return try? String(contentsOfFile: store.pfTokenPath, encoding: .utf8) + } + + // MARK: - Process helper + + private func runPfctl(_ arguments: [String]) -> (Int32, String) { + let task = Process() + task.executableURL = URL(fileURLWithPath: PacketFilter.pfctlPath) + task.arguments = arguments + + let pipe = Pipe() + task.standardOutput = pipe + task.standardError = pipe + + do { + try task.run() + } catch { + NSLog("ERROR: Failed to launch pfctl: %@", error.localizedDescription) + return (-1, "") + } + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + task.waitUntilExit() + let output = String(data: data, encoding: .utf8) ?? "" + return (task.terminationStatus, output) + } +} diff --git a/Common/Errors/SCError.swift b/Common/Errors/SCError.swift new file mode 100644 index 00000000..436075fd --- /dev/null +++ b/Common/Errors/SCError.swift @@ -0,0 +1,44 @@ +import Foundation + +/// All Stone error codes, matching the original SelfControl error domain. +enum SCError: Int, LocalizedError { + case blockAlreadyRunning = 301 + case emptyBlocklist = 302 + case blockEndDateInPast = 303 + case authorizationFailed = 304 + case daemonInstallFailed = 305 + case daemonConnectionFailed = 306 + case blockNotRunning = 307 + case blockFileReadFailed = 308 + case blockFileWriteFailed = 309 + case settingsSyncFailed = 310 + case pfctlFailed = 311 + case hostsWriteFailed = 312 + case blockIntegrityFailed = 313 + case invalidBlocklistEntry = 314 + case updateEndDateInvalid = 315 + case updateEndDateTooFar = 316 + + var errorDescription: String? { + switch self { + case .blockAlreadyRunning: return "A block is already running." + case .emptyBlocklist: return "The blocklist is empty." + case .blockEndDateInPast: return "The block end date is in the past." + case .authorizationFailed: return "Authorization failed." + case .daemonInstallFailed: return "Failed to install the helper daemon." + case .daemonConnectionFailed: return "Failed to connect to the helper daemon." + case .blockNotRunning: return "No block is currently running." + case .blockFileReadFailed: return "Failed to read the blocklist file." + case .blockFileWriteFailed: return "Failed to write the blocklist file." + case .settingsSyncFailed: return "Failed to sync settings." + case .pfctlFailed: return "Failed to update packet filter rules." + case .hostsWriteFailed: return "Failed to update /etc/hosts." + case .blockIntegrityFailed: return "Block integrity check failed." + case .invalidBlocklistEntry: return "Invalid blocklist entry." + case .updateEndDateInvalid: return "New end date must be later than current end date." + case .updateEndDateTooFar: return "Cannot extend block by more than 24 hours." + } + } + + static let domain = "com.max4c.stone.error" +} diff --git a/Common/Model/BlockEntry.swift b/Common/Model/BlockEntry.swift new file mode 100644 index 00000000..78e72b34 --- /dev/null +++ b/Common/Model/BlockEntry.swift @@ -0,0 +1,85 @@ +import Foundation + +/// Represents a single blocklist entry: a hostname, IP, or IP range with optional port. +struct BlockEntry: Equatable, Hashable { + let hostname: String + let port: Int? + let maskLen: Int? + + /// Parse a blocklist string like "facebook.com", "10.0.0.0/8", or "example.com:443". + init(string: String) { + var working = string.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + + // Strip protocol prefixes + for prefix in ["http://", "https://", "ftp://"] { + if working.hasPrefix(prefix) { + working = String(working.dropFirst(prefix.count)) + break + } + } + + // Strip trailing path/query + if let slashIndex = working.firstIndex(of: "/") { + working = String(working[working.startIndex.. Void) + + /// Update the active blocklist (add entries to a running block). + func updateBlocklist(_ newBlocklist: [String], + authorization: Data, + reply: @escaping (Error?) -> Void) + + /// Extend the block end date (must be later than current, max +24h). + func updateBlockEndDate(_ newEndDate: Date, + authorization: Data, + reply: @escaping (Error?) -> Void) + + /// Get the daemon's version string. + func getVersion(reply: @escaping (String) -> Void) +} diff --git a/Common/Settings/SCSettings.swift b/Common/Settings/SCSettings.swift new file mode 100644 index 00000000..275a854c --- /dev/null +++ b/Common/Settings/SCSettings.swift @@ -0,0 +1,151 @@ +import Foundation + +/// Cross-process settings store backed by a root-owned binary plist. +/// The daemon is the primary writer; the app and CLI are read-only clients. +/// Changes propagate via DistributedNotificationCenter. +final class SCSettings { + static let shared = SCSettings() + + private var settings: [String: Any] = [:] + private var versionNumber: Int = 0 + private var lastUpdate: Date = .distantPast + private let filePath: String + private let isReadOnly: Bool + private var syncTimer: Timer? + private let lock = NSLock() + + init() { + self.filePath = SCMiscUtilities.settingsFilePath() + #if DEBUG + self.isReadOnly = false + #else + self.isReadOnly = geteuid() != 0 + #endif + loadFromDisk() + startObservingNotifications() + startSyncTimer() + } + + // MARK: - Public API + + func value(for key: String) -> Any? { + lock.lock() + defer { lock.unlock() } + return settings[key] + } + + func setValue(_ value: Any?, for key: String) { + guard !isReadOnly else { + NSLog("SCSettings: Ignoring write to '%@' (read-only mode)", key) + return + } + lock.lock() + versionNumber += 1 + lastUpdate = Date() + if let value = value { + settings[key] = value + } else { + settings.removeValue(forKey: key) + } + lock.unlock() + } + + func synchronize() { + if isReadOnly { + loadFromDisk() + } else { + writeToDisk() + } + } + + func dictionaryRepresentation() -> [String: Any] { + lock.lock() + defer { lock.unlock() } + return settings + } + + // MARK: - Disk I/O + + private func loadFromDisk() { + lock.lock() + defer { lock.unlock() } + + guard FileManager.default.fileExists(atPath: filePath), + let data = try? Data(contentsOf: URL(fileURLWithPath: filePath)), + let plist = try? PropertyListSerialization.propertyList( + from: data, options: .mutableContainersAndLeaves, format: nil + ) as? [String: Any] else { + return + } + + let diskVersion = plist["SettingsVersionNumber"] as? Int ?? 0 + let diskUpdate = plist["LastSettingsUpdate"] as? Date ?? .distantPast + + // Only apply disk values if they're newer + if diskVersion > versionNumber || (diskVersion == versionNumber && diskUpdate > lastUpdate) { + settings = plist + versionNumber = diskVersion + lastUpdate = diskUpdate + } + } + + private func writeToDisk() { + lock.lock() + var toWrite = settings + toWrite["SettingsVersionNumber"] = versionNumber + toWrite["LastSettingsUpdate"] = lastUpdate + lock.unlock() + + do { + let data = try PropertyListSerialization.data( + fromPropertyList: toWrite, format: .binary, options: 0 + ) + try data.write(to: URL(fileURLWithPath: filePath), options: .atomic) + + // Set file permissions: root-owned, world-readable + let fm = FileManager.default + try fm.setAttributes([ + .posixPermissions: 0o755, + .ownerAccountID: 0 + ], ofItemAtPath: filePath) + } catch { + NSLog("SCSettings: Failed to write settings: %@", error.localizedDescription) + } + + // Notify other processes + DistributedNotificationCenter.default().postNotificationName( + NSNotification.Name(StoneConstants.configurationChangedNotification), + object: nil + ) + } + + // MARK: - Cross-Process Sync + + private func startObservingNotifications() { + // [Fix #7] Observe on the main run loop so it fires in the daemon too + DistributedNotificationCenter.default().addObserver( + self, + selector: #selector(handleRemoteChange), + name: NSNotification.Name(StoneConstants.configurationChangedNotification), + object: nil + ) + } + + @objc private func handleRemoteChange(_ notification: Notification) { + loadFromDisk() + } + + private func startSyncTimer() { + // [Fix #7] Schedule on main run loop so it fires in the daemon + DispatchQueue.main.async { [weak self] in + self?.syncTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in + self?.synchronize() + } + } + } + + deinit { + syncTimer?.invalidate() + DistributedNotificationCenter.default().removeObserver(self) + } +} diff --git a/Common/Utilities/SCBlockFileReaderWriter.swift b/Common/Utilities/SCBlockFileReaderWriter.swift new file mode 100644 index 00000000..d7b813e9 --- /dev/null +++ b/Common/Utilities/SCBlockFileReaderWriter.swift @@ -0,0 +1,31 @@ +import Foundation + +/// Reads and writes .stone blocklist files (binary plist format). +enum SCBlockFileReaderWriter { + + /// Read a blocklist from a .stone file. Returns dict with "Blocklist" and "BlockAsWhitelist" keys. + static func readBlocklist(from url: URL) -> [String: Any]? { + guard let data = try? Data(contentsOf: url) else { return nil } + + guard let plist = try? PropertyListSerialization.propertyList( + from: data, options: [], format: nil + ) as? [String: Any] else { + return nil + } + + guard plist["Blocklist"] != nil else { return nil } + return plist + } + + /// Write a blocklist to a .stone file in binary plist format. + @discardableResult + static func writeBlocklist(to url: URL, blockInfo: [String: Any]) throws -> Bool { + let data = try PropertyListSerialization.data( + fromPropertyList: blockInfo, + format: .binary, + options: 0 + ) + try data.write(to: url, options: .atomic) + return true + } +} diff --git a/Common/Utilities/SCBlockUtilities.swift b/Common/Utilities/SCBlockUtilities.swift new file mode 100644 index 00000000..9f97677e --- /dev/null +++ b/Common/Utilities/SCBlockUtilities.swift @@ -0,0 +1,47 @@ +import Foundation + +/// Utility methods for checking block state. Used by all three targets. +enum SCBlockUtilities { + + /// Whether any block is currently running (checks the tamper-resistant settings file). + static func anyBlockIsRunning() -> Bool { + let settings = SCSettings.shared + return settings.value(for: "BlockIsRunning") as? Bool ?? false + } + + /// Whether the current block has expired. + static func currentBlockIsExpired() -> Bool { + guard let endDate = SCSettings.shared.value(for: "BlockEndDate") as? Date else { + return true + } + return endDate.timeIntervalSinceNow <= 0 + } + + /// Whether block enforcement rules exist on the system (pf anchor or hosts entries). + static func blockRulesFoundOnSystem() -> Bool { + // Check pf anchor + let pfAnchorPath = "/etc/pf.anchors/\(StoneConstants.pfAnchorName)" + if FileManager.default.fileExists(atPath: pfAnchorPath) { + return true + } + + // Check hosts file for our sentinel + if let hostsContent = try? String(contentsOfFile: "/etc/hosts", encoding: .utf8) { + if hostsContent.contains(StoneConstants.hostsSentinelBegin) { + return true + } + } + + return false + } + + /// Clear block state from settings (does not remove enforcement rules). + static func removeBlockFromSettings() { + let settings = SCSettings.shared + settings.setValue(false, for: "BlockIsRunning") + settings.setValue(nil, for: "BlockEndDate") + settings.setValue(nil, for: "ActiveBlocklist") + settings.setValue(nil, for: "ActiveBlockAsWhitelist") + settings.synchronize() + } +} diff --git a/Common/Utilities/SCFileWatcher.swift b/Common/Utilities/SCFileWatcher.swift new file mode 100644 index 00000000..9cf2312c --- /dev/null +++ b/Common/Utilities/SCFileWatcher.swift @@ -0,0 +1,61 @@ +import Foundation +import CoreServices + +/// Watches a file or directory for changes using FSEvents. +class SCFileWatcher { + private var stream: FSEventStreamRef? + private let path: String + private let callback: () -> Void + + init(path: String, callback: @escaping () -> Void) { + self.path = path + self.callback = callback + } + + func start() { + let pathsToWatch = [path] as CFArray + + // [Fix #6] Use passRetained to prevent use-after-free if watcher is + // deallocated before the stream is invalidated. + var context = FSEventStreamContext() + context.info = Unmanaged.passRetained(self).toOpaque() + context.release = { info in + guard let info = info else { return } + Unmanaged.fromOpaque(info).release() + } + + let flags: FSEventStreamCreateFlags = UInt32( + kFSEventStreamCreateFlagFileEvents | kFSEventStreamCreateFlagUseCFTypes + ) + + guard let stream = FSEventStreamCreate( + nil, + { _, info, _, _, _, _ in + guard let info = info else { return } + let watcher = Unmanaged.fromOpaque(info).takeUnretainedValue() + watcher.callback() + }, + &context, + pathsToWatch, + FSEventStreamEventId(kFSEventStreamEventIdSinceNow), + 1.0, // latency in seconds + flags + ) else { return } + + self.stream = stream + FSEventStreamScheduleWithRunLoop(stream, CFRunLoopGetCurrent(), CFRunLoopMode.defaultMode.rawValue) + FSEventStreamStart(stream) + } + + func stop() { + guard let stream = stream else { return } + FSEventStreamStop(stream) + FSEventStreamInvalidate(stream) + FSEventStreamRelease(stream) + self.stream = nil + } + + deinit { + stop() + } +} diff --git a/Common/Utilities/SCHelperToolUtilities.swift b/Common/Utilities/SCHelperToolUtilities.swift new file mode 100644 index 00000000..cc9a7e74 --- /dev/null +++ b/Common/Utilities/SCHelperToolUtilities.swift @@ -0,0 +1,100 @@ +import Foundation + +/// Bridge between settings and block enforcement. Used by the daemon. +enum SCHelperToolUtilities { + + /// Read block config from SCSettings and install enforcement rules. + static func installBlockRulesFromSettings() { + let settings = SCSettings.shared + guard let blocklist = settings.value(for: "ActiveBlocklist") as? [String] else { + NSLog("SCHelperToolUtilities: No active blocklist in settings") + return + } + + let isAllowlist = settings.value(for: "ActiveBlockAsWhitelist") as? Bool ?? false + let evalSubdomains = settings.value(for: "EvaluateCommonSubdomains") as? Bool ?? true + let linkedDomains = settings.value(for: "IncludeLinkedDomains") as? Bool ?? true + let allowLocal = settings.value(for: "AllowLocalNetworks") as? Bool ?? true + + let manager = BlockManager( + isAllowlist: isAllowlist, + allowLocal: allowLocal, + includeCommonSubdomains: evalSubdomains, + includeLinkedDomains: linkedDomains + ) + + manager.prepareToAddBlock() + manager.addEntries(from: blocklist) + manager.finalizeBlock() + } + + /// Remove all block enforcement and clear settings. + static func removeBlock() { + let settings = SCSettings.shared + let isAllowlist = settings.value(for: "ActiveBlockAsWhitelist") as? Bool ?? false + + let manager = BlockManager(isAllowlist: isAllowlist) + manager.clearBlock() + + if settings.value(for: "ClearCaches") as? Bool ?? true { + clearBrowserCaches() + clearOSDNSCache() + } + + SCBlockUtilities.removeBlockFromSettings() + sendConfigurationChangedNotification() + } + + /// Clear browser caches (Safari, Chrome, Firefox). + static func clearBrowserCaches() { + let fm = FileManager.default + + // [Fix #11] In the daemon (root), NSHomeDirectory() returns /var/root. + // Use the controlling UID from settings to find the actual user's home. + let home: String + if geteuid() == 0, + let uid = SCSettings.shared.value(for: "ControllingUID") as? UInt32, + uid > 0, + let pw = getpwuid(uid) { + home = String(cString: pw.pointee.pw_dir) + } else { + home = NSHomeDirectory() + } + + let cachePaths = [ + "\(home)/Library/Caches/com.apple.Safari", + "\(home)/Library/Caches/Google/Chrome", + "\(home)/Library/Caches/Firefox/Profiles", + ] + + for path in cachePaths { + if fm.fileExists(atPath: path) { + try? fm.removeItem(atPath: path) + } + } + } + + /// Flush the OS DNS cache. + static func clearOSDNSCache() { + runCommand("/usr/bin/dscacheutil", arguments: ["-flushcache"]) + runCommand("/usr/bin/killall", arguments: ["-HUP", "mDNSResponder"]) + } + + /// Post a configuration changed notification to all processes. + static func sendConfigurationChangedNotification() { + DistributedNotificationCenter.default().postNotificationName( + NSNotification.Name(StoneConstants.configurationChangedNotification), + object: nil + ) + } + + private static func runCommand(_ path: String, arguments: [String]) { + let task = Process() + task.executableURL = URL(fileURLWithPath: path) + task.arguments = arguments + task.standardOutput = FileHandle.nullDevice + task.standardError = FileHandle.nullDevice + try? task.run() + task.waitUntilExit() + } +} diff --git a/Common/Utilities/SCMiscUtilities.swift b/Common/Utilities/SCMiscUtilities.swift new file mode 100644 index 00000000..1f3aac7b --- /dev/null +++ b/Common/Utilities/SCMiscUtilities.swift @@ -0,0 +1,56 @@ +import Foundation +import CommonCrypto + +/// Miscellaneous utility functions used across the app. +enum SCMiscUtilities { + + /// Get the hardware serial number. + static func serialNumber() -> String? { + let platformExpert = IOServiceGetMatchingService( + kIOMainPortDefault, + IOServiceMatching("IOPlatformExpertDevice") + ) + guard platformExpert != 0 else { return nil } + defer { IOObjectRelease(platformExpert) } + + guard let serialRef = IORegistryEntryCreateCFProperty( + platformExpert, + "IOPlatformSerialNumber" as CFString, + kCFAllocatorDefault, 0 + ) else { return nil } + + return serialRef.takeUnretainedValue() as? String + } + + /// SHA1 hash of a string, returned as hex. + static func sha1Hex(_ input: String) -> String { + let data = Data(input.utf8) + var digest = [UInt8](repeating: 0, count: Int(CC_SHA1_DIGEST_LENGTH)) + data.withUnsafeBytes { CC_SHA1($0.baseAddress, CC_LONG(data.count), &digest) } + return digest.map { String(format: "%02x", $0) }.joined() + } + + /// The path to the tamper-resistant settings file for this machine. + static func settingsFilePath() -> String { + let serial = serialNumber() ?? "unknown" + let hash = sha1Hex("\(StoneConstants.settingsFilePrefix)\(serial)") + return "/usr/local/etc/.\(hash).plist" + } + + /// Clean a blocklist by trimming whitespace, removing empty entries and duplicates. + static func cleanBlocklist(_ entries: [String]) -> [String] { + var seen = Set() + return entries.compactMap { entry in + let cleaned = entry.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !cleaned.isEmpty, !seen.contains(cleaned) else { return nil } + seen.insert(cleaned) + return cleaned + } + } + + /// Whether the given error represents the user canceling an authorization dialog. + static func errorIsAuthCanceled(_ error: Error) -> Bool { + let nsError = error as NSError + return nsError.domain == NSOSStatusErrorDomain && nsError.code == errAuthorizationCanceled + } +} diff --git a/Common/XPC/SCXPCAuthorization.swift b/Common/XPC/SCXPCAuthorization.swift new file mode 100644 index 00000000..b6fb7856 --- /dev/null +++ b/Common/XPC/SCXPCAuthorization.swift @@ -0,0 +1,106 @@ +import Foundation +import Security + +/// Manages authorization rights for daemon XPC methods. +enum SCXPCAuthorization { + + // Right names for each daemon method + private static let rightStartBlock = "com.max4c.stone.startBlock" + private static let rightUpdateBlocklist = "com.max4c.stone.updateBlocklist" + private static let rightUpdateEndDate = "com.max4c.stone.updateBlockEndDate" + + /// Set up authorization rights in the policy database. + static func setupAuthorizationRights(_ authRef: AuthorizationRef) { + let rights: [(String, String)] = [ + (rightStartBlock, "Start a Stone block"), + (rightUpdateBlocklist, "Update the active blocklist"), + (rightUpdateEndDate, "Extend the block duration"), + ] + + for (right, description) in rights { + var err = AuthorizationRightGet(right, nil) + if err == errAuthorizationDenied { + // Right doesn't exist yet — create it requiring admin auth + let rightDefinition: [String: Any] = [ + "class": "user", + "comment": description, + "group": "admin", + "timeout": 300, + "shared": true, + ] + err = AuthorizationRightSet(authRef, right, rightDefinition as CFDictionary, description as CFString, nil, nil) + if err != errAuthorizationSuccess { + NSLog("SCXPCAuthorization: Failed to set right '%@': %d", right, err) + } + } + } + } + + /// Validate that the given authorization data grants the right for a command. + static func checkAuthorization(_ authData: Data, for commandName: String) throws { + let rightName = self.rightName(for: commandName) + + var authRef: AuthorizationRef? + let status = authData.withUnsafeBytes { rawPtr -> OSStatus in + guard let ptr = rawPtr.baseAddress else { return errAuthorizationInvalidRef } + var extForm = ptr.load(as: AuthorizationExternalForm.self) + return AuthorizationCreateFromExternalForm(&extForm, &authRef) + } + + guard status == errAuthorizationSuccess, let auth = authRef else { + throw SCError.authorizationFailed + } + defer { AuthorizationFree(auth, []) } + + var item = AuthorizationItem(name: rightName, valueLength: 0, value: nil, flags: 0) + var rights = AuthorizationRights(count: 1, items: &item) + // [Fix #9] Don't use .interactionAllowed — the daemon has no GUI session. + // The token was pre-authorized on the app side; just verify it here. + let flags: AuthorizationFlags = [.extendRights] + + let result = AuthorizationCopyRights(auth, &rights, nil, flags, nil) + guard result == errAuthorizationSuccess else { + throw SCError.authorizationFailed + } + } + + /// Create authorization data (external form) for sending with XPC calls. + static func createAuthorizationData() throws -> Data { + var authRef: AuthorizationRef? + var status = AuthorizationCreate(nil, nil, [], &authRef) + guard status == errAuthorizationSuccess, let auth = authRef else { + throw SCError.authorizationFailed + } + + // Pre-authorize with admin rights + let rightName = "com.max4c.stone.startBlock" + var item = AuthorizationItem(name: rightName, valueLength: 0, value: nil, flags: 0) + var rights = AuthorizationRights(count: 1, items: &item) + let flags: AuthorizationFlags = [.interactionAllowed, .extendRights, .preAuthorize] + + status = AuthorizationCopyRights(auth, &rights, nil, flags, nil) + guard status == errAuthorizationSuccess else { + throw SCError.authorizationFailed + } + + var extForm = AuthorizationExternalForm() + status = AuthorizationMakeExternalForm(auth, &extForm) + guard status == errAuthorizationSuccess else { + throw SCError.authorizationFailed + } + + return Data(bytes: &extForm, count: MemoryLayout.size) + } + + // [Fix #10] No default fallthrough — unknown commands fail authorization + private static func rightName(for commandName: String) -> String { + switch commandName { + case "startBlock": return rightStartBlock + case "updateBlocklist": return rightUpdateBlocklist + case "updateBlockEndDate": return rightUpdateEndDate + default: + NSLog("SCXPCAuthorization: Unknown command '%@' — denying", commandName) + return "com.max4c.stone.INVALID" + } + } +} diff --git a/Common/XPC/SCXPCClient.swift b/Common/XPC/SCXPCClient.swift new file mode 100644 index 00000000..499909a9 --- /dev/null +++ b/Common/XPC/SCXPCClient.swift @@ -0,0 +1,137 @@ +import Foundation +import ServiceManagement + +/// App-side XPC client that manages the connection to the privileged daemon. +final class SCXPCClient { + private var connection: NSXPCConnection? + private var authData: Data? + + // MARK: - Daemon Installation + + /// Install the privileged helper daemon via SMJobBless. + func installDaemon(reply: @escaping (Error?) -> Void) { + var authRef: AuthorizationRef? + var authItem = AuthorizationItem(name: kSMRightBlessPrivilegedHelper, valueLength: 0, value: nil, flags: 0) + var authRights = AuthorizationRights(count: 1, items: &authItem) + let flags: AuthorizationFlags = [.interactionAllowed, .extendRights, .preAuthorize] + + let status = AuthorizationCreate(&authRights, nil, flags, &authRef) + guard status == errAuthorizationSuccess else { + reply(SCError.authorizationFailed) + return + } + + var blessError: Unmanaged? + let success = SMJobBless(kSMDomainSystemLaunchd, StoneConstants.daemonIdentifier as CFString, authRef, &blessError) + + if success { + NSLog("SCXPCClient: Daemon installed successfully") + + if let auth = authRef { + SCXPCAuthorization.setupAuthorizationRights(auth) + } + } else { + let error = blessError?.takeRetainedValue() + NSLog("SCXPCClient: Failed to install daemon: %@", error.map { String(describing: $0) } ?? "unknown") + reply(SCError.daemonInstallFailed) + return + } + + // [Fix #1] Always create fresh auth data with pre-authorized rights, + // regardless of whether the daemon was just installed or already existed. + do { + authData = try SCXPCAuthorization.createAuthorizationData() + } catch { + NSLog("SCXPCClient: Failed to create authorization data: %@", error.localizedDescription) + reply(SCError.authorizationFailed) + return + } + + reply(nil) + } + + // MARK: - Connection Management + + /// [Fix #2] Invalidate existing connection, create a new one, then run block. + func refreshConnectionAndRun(_ block: @escaping () -> Void) { + if let oldConnection = connection { + connection = nil + oldConnection.invalidationHandler = { [weak self] in + self?.connectToHelperTool() + DispatchQueue.main.async { block() } + } + oldConnection.invalidate() + } else { + connectToHelperTool() + block() + } + } + + private func connectToHelperTool() { + let conn = NSXPCConnection(machServiceName: StoneConstants.machServiceName, options: .privileged) + conn.remoteObjectInterface = NSXPCInterface(with: SCDaemonProtocol.self) + + conn.invalidationHandler = { [weak self] in + NSLog("SCXPCClient: Connection invalidated") + self?.connection = nil + } + conn.interruptionHandler = { + NSLog("SCXPCClient: Connection interrupted") + } + + conn.resume() + connection = conn + } + + private func proxy() -> SCDaemonProtocol? { + if connection == nil { connectToHelperTool() } + return connection?.remoteObjectProxyWithErrorHandler { error in + NSLog("SCXPCClient: Remote proxy error: %@", error.localizedDescription) + } as? SCDaemonProtocol + } + + // MARK: - Block Operations + + func startBlock(controllingUID: UInt32, + blocklist: [String], + isAllowlist: Bool, + endDate: Date, + blockSettings: [String: Any], + reply: @escaping (Error?) -> Void) { + guard let p = proxy(), let auth = authData else { + reply(SCError.daemonConnectionFailed) + return + } + p.startBlock(controllingUID: controllingUID, + blocklist: blocklist, + isAllowlist: isAllowlist, + endDate: endDate, + blockSettings: blockSettings, + authorization: auth, + reply: reply) + } + + func updateBlocklist(_ newBlocklist: [String], reply: @escaping (Error?) -> Void) { + guard let p = proxy(), let auth = authData else { + reply(SCError.daemonConnectionFailed) + return + } + p.updateBlocklist(newBlocklist, authorization: auth, reply: reply) + } + + func updateBlockEndDate(_ newEndDate: Date, reply: @escaping (Error?) -> Void) { + guard let p = proxy(), let auth = authData else { + reply(SCError.daemonConnectionFailed) + return + } + p.updateBlockEndDate(newEndDate, authorization: auth, reply: reply) + } + + func getVersion(reply: @escaping (String) -> Void) { + guard let p = proxy() else { + reply("unknown") + return + } + p.getVersion(reply: reply) + } +} diff --git a/Daemon/AuditTokenBridge.h b/Daemon/AuditTokenBridge.h new file mode 100644 index 00000000..fa81ac7d --- /dev/null +++ b/Daemon/AuditTokenBridge.h @@ -0,0 +1,5 @@ +#import + +/// Exposes the private auditToken property on NSXPCConnection +/// so the daemon can validate the connecting client's code signature. +audit_token_t SCGetAuditToken(NSXPCConnection * _Nonnull connection); diff --git a/Daemon/AuditTokenBridge.m b/Daemon/AuditTokenBridge.m new file mode 100644 index 00000000..932f67be --- /dev/null +++ b/Daemon/AuditTokenBridge.m @@ -0,0 +1,9 @@ +#import "AuditTokenBridge.h" + +@interface NSXPCConnection (AuditToken) +@property (nonatomic, readonly) audit_token_t auditToken; +@end + +audit_token_t SCGetAuditToken(NSXPCConnection *connection) { + return connection.auditToken; +} diff --git a/Daemon/Daemon-Bridging-Header.h b/Daemon/Daemon-Bridging-Header.h new file mode 100644 index 00000000..0b52a64d --- /dev/null +++ b/Daemon/Daemon-Bridging-Header.h @@ -0,0 +1 @@ +#import "AuditTokenBridge.h" diff --git a/Daemon/DaemonMain.swift b/Daemon/DaemonMain.swift new file mode 100644 index 00000000..c7b4a521 --- /dev/null +++ b/Daemon/DaemonMain.swift @@ -0,0 +1,11 @@ +import Foundation + +/// Entry point for the privileged helper daemon (com.max4c.stonectld). +@main +struct DaemonEntry { + static func main() { + let daemon = SCDaemon.shared + daemon.start() + RunLoop.current.run() + } +} diff --git a/Daemon/SCDaemon.swift b/Daemon/SCDaemon.swift new file mode 100644 index 00000000..4d7ad286 --- /dev/null +++ b/Daemon/SCDaemon.swift @@ -0,0 +1,92 @@ +import Foundation + +/// The privileged helper daemon. Listens for XPC connections from the app/CLI, +/// manages block enforcement timers, and validates connecting clients. +final class SCDaemon: NSObject, NSXPCListenerDelegate { + static let shared = SCDaemon() + + private var listener: NSXPCListener? + private var checkupTimer: Timer? + private var inactivityTimer: Timer? + private var hostsWatcher: SCFileWatcher? + + override init() { + super.init() + } + + // MARK: - Lifecycle + + func start() { + NSLog("stonectld: Starting daemon...") + + // Create XPC listener on our mach service + listener = NSXPCListener(machServiceName: StoneConstants.machServiceName) + listener?.delegate = self + listener?.resume() + + // Start checkup timer (1 second interval) + checkupTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + self?.checkupBlock() + } + + // Watch /etc/hosts for tampering + hostsWatcher = SCFileWatcher(path: "/etc/hosts") { [weak self] in + self?.checkBlockIntegrity() + } + hostsWatcher?.start() + + // Start inactivity timer + resetInactivityTimer() + + NSLog("stonectld: Daemon started, listening on %@", StoneConstants.machServiceName) + } + + // MARK: - NSXPCListenerDelegate + + func listener(_ listener: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool { + // TODO: Validate client code signature via audit token + #if DEBUG + // In debug builds, accept all connections for easier testing + #else + // In release builds, validate the connecting client's code signature + // let auditToken = SCGetAuditToken(newConnection) + // TODO: Verify code signature matches com.max4c.stone + #endif + + let interface = NSXPCInterface(with: SCDaemonProtocol.self) + newConnection.exportedInterface = interface + newConnection.exportedObject = SCDaemonXPC() + newConnection.resume() + + resetInactivityTimer() + return true + } + + // MARK: - Block Checkup + + private func checkupBlock() { + SCDaemonBlockMethods.shared.checkupBlock() + } + + private func checkBlockIntegrity() { + SCDaemonBlockMethods.shared.checkBlockIntegrity() + } + + // MARK: - Inactivity + + private func resetInactivityTimer() { + inactivityTimer?.invalidate() + inactivityTimer = Timer.scheduledTimer(withTimeInterval: 120, repeats: false) { [weak self] _ in + self?.handleInactivity() + } + } + + private func handleInactivity() { + guard !SCBlockUtilities.anyBlockIsRunning() else { + resetInactivityTimer() + return + } + NSLog("stonectld: Idle for 2 minutes with no active block. Exiting.") + exit(EXIT_SUCCESS) + } +} diff --git a/Daemon/SCDaemonBlockMethods.swift b/Daemon/SCDaemonBlockMethods.swift new file mode 100644 index 00000000..dcc0b14d --- /dev/null +++ b/Daemon/SCDaemonBlockMethods.swift @@ -0,0 +1,173 @@ +import Foundation + +/// All block-related logic that runs in the daemon process. +/// Access is serialized via a lock to prevent concurrent modifications. +final class SCDaemonBlockMethods { + static let shared = SCDaemonBlockMethods() + + private let lock = NSLock() + private var checkupCount: Int = 0 + + // MARK: - Start Block + + func startBlock(controllingUID: uid_t, + blocklist: [String], + isAllowlist: Bool, + endDate: Date, + blockSettings: [String: Any]) throws { + guard lock.lock(before: Date(timeIntervalSinceNow: 5)) else { + NSLog("SCDaemonBlockMethods: Lock timeout on startBlock") + throw SCError.blockAlreadyRunning + } + defer { lock.unlock() } + + guard !SCBlockUtilities.anyBlockIsRunning() else { + throw SCError.blockAlreadyRunning + } + + guard !blocklist.isEmpty || isAllowlist else { + throw SCError.emptyBlocklist + } + + guard endDate.timeIntervalSinceNow >= 1 else { + throw SCError.blockEndDateInPast + } + + let settings = SCSettings.shared + + // Write block parameters to tamper-resistant settings + settings.setValue(blocklist, for: "ActiveBlocklist") + settings.setValue(isAllowlist, for: "ActiveBlockAsWhitelist") + settings.setValue(endDate, for: "BlockEndDate") + + // Write individual block settings + for (key, value) in blockSettings { + settings.setValue(value, for: key) + } + + // Install enforcement rules + SCHelperToolUtilities.installBlockRulesFromSettings() + + // Mark block as running + settings.setValue(true, for: "BlockIsRunning") + settings.synchronize() + + SCHelperToolUtilities.sendConfigurationChangedNotification() + + NSLog("SCDaemonBlockMethods: Block started with %d entries, ends %@", + blocklist.count, endDate as NSDate) + } + + // MARK: - Checkup (called every second by the daemon timer) + + func checkupBlock() { + // [Fix #3] Take the lock during checkup to prevent racing with startBlock. + // Use tryLock to avoid blocking the timer if startBlock holds the lock. + guard lock.try() else { return } + defer { lock.unlock() } + + checkupCount += 1 + let settings = SCSettings.shared + + let blockIsRunning = settings.value(for: "BlockIsRunning") as? Bool ?? false + let rulesOnSystem = SCBlockUtilities.blockRulesFoundOnSystem() + + if !blockIsRunning && rulesOnSystem { + // Tamper detected — block was removed from settings but rules remain + NSLog("SCDaemonBlockMethods: Tamper detected — removing orphaned rules") + SCHelperToolUtilities.removeBlock() + return + } + + if blockIsRunning && SCBlockUtilities.currentBlockIsExpired() { + // Block has expired — clean up + NSLog("SCDaemonBlockMethods: Block expired, removing") + SCHelperToolUtilities.removeBlock() + return + } + + // Every 15 seconds, verify block integrity + if blockIsRunning && checkupCount % 15 == 0 { + checkBlockIntegrity() + } + } + + // MARK: - Update Operations + + func updateBlocklist(_ newBlocklist: [String]) throws { + guard lock.lock(before: Date(timeIntervalSinceNow: 5)) else { return } + defer { lock.unlock() } + + guard SCBlockUtilities.anyBlockIsRunning() else { + throw SCError.blockNotRunning + } + + let settings = SCSettings.shared + let isAllowlist = settings.value(for: "ActiveBlockAsWhitelist") as? Bool ?? false + let evalSubdomains = settings.value(for: "EvaluateCommonSubdomains") as? Bool ?? true + let linkedDomains = settings.value(for: "IncludeLinkedDomains") as? Bool ?? true + let allowLocal = settings.value(for: "AllowLocalNetworks") as? Bool ?? true + + let manager = BlockManager( + isAllowlist: isAllowlist, + allowLocal: allowLocal, + includeCommonSubdomains: evalSubdomains, + includeLinkedDomains: linkedDomains + ) + manager.enterAppendMode() + manager.addEntries(from: newBlocklist) + manager.finalizeBlock() + + // Update the stored blocklist + var current = settings.value(for: "ActiveBlocklist") as? [String] ?? [] + current.append(contentsOf: newBlocklist) + settings.setValue(current, for: "ActiveBlocklist") + settings.synchronize() + + SCHelperToolUtilities.sendConfigurationChangedNotification() + } + + func updateBlockEndDate(_ newEndDate: Date) throws { + guard lock.lock(before: Date(timeIntervalSinceNow: 5)) else { return } + defer { lock.unlock() } + + guard SCBlockUtilities.anyBlockIsRunning() else { + throw SCError.blockNotRunning + } + + let settings = SCSettings.shared + guard let currentEnd = settings.value(for: "BlockEndDate") as? Date else { + throw SCError.blockNotRunning + } + + guard newEndDate > currentEnd else { + throw SCError.updateEndDateInvalid + } + + // Max extension: 24 hours beyond current end date + let maxEnd = currentEnd.addingTimeInterval(24 * 60 * 60) + guard newEndDate <= maxEnd else { + throw SCError.updateEndDateTooFar + } + + settings.setValue(newEndDate, for: "BlockEndDate") + settings.synchronize() + + SCHelperToolUtilities.sendConfigurationChangedNotification() + } + + // MARK: - Integrity Check + + func checkBlockIntegrity() { + guard SCBlockUtilities.anyBlockIsRunning() else { return } + + let pfPresent = PacketFilter.blockFoundInPF() + let hostsPresent = HostFileBlockerSet().isBlockActive() + + if !pfPresent || !hostsPresent { + NSLog("SCDaemonBlockMethods: Block integrity failed (pf=%d, hosts=%d), re-applying", + pfPresent ? 1 : 0, hostsPresent ? 1 : 0) + SCHelperToolUtilities.installBlockRulesFromSettings() + } + } +} diff --git a/Daemon/SCDaemonXPC.swift b/Daemon/SCDaemonXPC.swift new file mode 100644 index 00000000..2031d92b --- /dev/null +++ b/Daemon/SCDaemonXPC.swift @@ -0,0 +1,57 @@ +import Foundation + +/// Handles incoming XPC calls. Each connection gets its own instance. +/// Validates authorization, then delegates to SCDaemonBlockMethods. +final class SCDaemonXPC: NSObject, SCDaemonProtocol { + + func startBlock(controllingUID: UInt32, + blocklist: [String], + isAllowlist: Bool, + endDate: Date, + blockSettings: [String: Any], + authorization: Data, + reply: @escaping (Error?) -> Void) { + do { + try SCXPCAuthorization.checkAuthorization(authorization, for: "startBlock") + try SCDaemonBlockMethods.shared.startBlock( + controllingUID: uid_t(controllingUID), + blocklist: blocklist, + isAllowlist: isAllowlist, + endDate: endDate, + blockSettings: blockSettings + ) + reply(nil) + } catch { + NSLog("stonectld: startBlock failed: %@", error.localizedDescription) + reply(error) + } + } + + func updateBlocklist(_ newBlocklist: [String], + authorization: Data, + reply: @escaping (Error?) -> Void) { + do { + try SCXPCAuthorization.checkAuthorization(authorization, for: "updateBlocklist") + try SCDaemonBlockMethods.shared.updateBlocklist(newBlocklist) + reply(nil) + } catch { + reply(error) + } + } + + func updateBlockEndDate(_ newEndDate: Date, + authorization: Data, + reply: @escaping (Error?) -> Void) { + do { + try SCXPCAuthorization.checkAuthorization(authorization, for: "updateBlockEndDate") + try SCDaemonBlockMethods.shared.updateBlockEndDate(newEndDate) + reply(nil) + } catch { + reply(error) + } + } + + func getVersion(reply: @escaping (String) -> Void) { + reply(StoneConstants.versionString) + } +} diff --git a/Daemon/stonectld-Info.plist b/Daemon/stonectld-Info.plist new file mode 100644 index 00000000..5f8ca6b3 --- /dev/null +++ b/Daemon/stonectld-Info.plist @@ -0,0 +1,20 @@ + + + + + CFBundleIdentifier + com.max4c.stonectld + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + com.max4c.stonectld + CFBundleVersion + 1 + CFBundleShortVersionString + 1.0.0 + SMAuthorizedClients + + identifier "com.max4c.stone" and anchor apple generic and certificate leaf[subject.OU] = "H9N9P29TX5" + + + diff --git a/Daemon/stonectld-launchd.plist b/Daemon/stonectld-launchd.plist new file mode 100644 index 00000000..dd089eff --- /dev/null +++ b/Daemon/stonectld-launchd.plist @@ -0,0 +1,13 @@ + + + + + Label + com.max4c.stonectld + MachServices + + com.max4c.stonectld + + + + diff --git a/SCConstants.h b/SCConstants.h index 3758d999..8bfe4ce5 100644 --- a/SCConstants.h +++ b/SCConstants.h @@ -10,6 +10,7 @@ NS_ASSUME_NONNULL_BEGIN extern OSStatus const AUTH_CANCELLED_STATUS; +extern NSString *const kScheduledBlocks; @interface SCConstants : NSObject diff --git a/SCConstants.m b/SCConstants.m index 80def483..3787086c 100644 --- a/SCConstants.m +++ b/SCConstants.m @@ -8,6 +8,7 @@ #import "SCConstants.h" OSStatus const AUTH_CANCELLED_STATUS = -60006; +NSString *const kScheduledBlocks = @"ScheduledBlocks"; @implementation SCConstants @@ -65,7 +66,9 @@ @implementation SCConstants @"SuppressRestartFirefoxWarning": @NO, @"FirstBlockStarted": @NO, - @"V4MigrationComplete": @NO + @"V4MigrationComplete": @NO, + + @"ScheduledBlocks": @[] }; }); diff --git a/SCSchedule.h b/SCSchedule.h new file mode 100644 index 00000000..8ad6e494 --- /dev/null +++ b/SCSchedule.h @@ -0,0 +1,32 @@ +// +// SCSchedule.h +// SelfControl +// +// Model for a recurring scheduled block. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface SCSchedule : NSObject + +@property (nonatomic, copy) NSString *identifier; +@property (nonatomic, copy) NSString *name; +@property (nonatomic, copy) NSArray *weekdays; // 0=Sun through 6=Sat +@property (nonatomic, assign) NSInteger hour; +@property (nonatomic, assign) NSInteger minute; +@property (nonatomic, assign) NSInteger durationMinutes; +@property (nonatomic, copy) NSArray *blocklist; +@property (nonatomic, assign) BOOL enabled; + +- (instancetype)initWithDictionary:(NSDictionary *)dict; +- (NSDictionary *)dictionaryRepresentation; ++ (instancetype)scheduleFromDictionary:(NSDictionary *)dict; + +// Returns the launchd label for this schedule, e.g. org.eyebeam.SelfControl.schedule. +- (NSString *)launchdLabel; + +@end + +NS_ASSUME_NONNULL_END diff --git a/SCSchedule.m b/SCSchedule.m new file mode 100644 index 00000000..cac3e946 --- /dev/null +++ b/SCSchedule.m @@ -0,0 +1,61 @@ +// +// SCSchedule.m +// SelfControl +// +// Model for a recurring scheduled block. +// + +#import "SCSchedule.h" + +@implementation SCSchedule + +- (instancetype)init { + if (self = [super init]) { + _identifier = [[NSUUID UUID] UUIDString]; + _name = @""; + _weekdays = @[]; + _hour = 9; + _minute = 0; + _durationMinutes = 60; + _blocklist = @[]; + _enabled = YES; + } + return self; +} + +- (instancetype)initWithDictionary:(NSDictionary *)dict { + if (self = [super init]) { + _identifier = dict[@"identifier"] ?: [[NSUUID UUID] UUIDString]; + _name = dict[@"name"] ?: @""; + _weekdays = dict[@"weekdays"] ?: @[]; + _hour = [dict[@"hour"] integerValue]; + _minute = [dict[@"minute"] integerValue]; + _durationMinutes = [dict[@"durationMinutes"] integerValue]; + _blocklist = dict[@"blocklist"] ?: @[]; + _enabled = [dict[@"enabled"] boolValue]; + } + return self; +} + ++ (instancetype)scheduleFromDictionary:(NSDictionary *)dict { + return [[SCSchedule alloc] initWithDictionary:dict]; +} + +- (NSDictionary *)dictionaryRepresentation { + return @{ + @"identifier": self.identifier ?: @"", + @"name": self.name ?: @"", + @"weekdays": self.weekdays ?: @[], + @"hour": @(self.hour), + @"minute": @(self.minute), + @"durationMinutes": @(self.durationMinutes), + @"blocklist": self.blocklist ?: @[], + @"enabled": @(self.enabled) + }; +} + +- (NSString *)launchdLabel { + return [NSString stringWithFormat:@"org.eyebeam.SelfControl.schedule.%@", self.identifier]; +} + +@end diff --git a/SCScheduleManager.h b/SCScheduleManager.h new file mode 100644 index 00000000..7a52109c --- /dev/null +++ b/SCScheduleManager.h @@ -0,0 +1,27 @@ +// +// SCScheduleManager.h +// SelfControl +// +// Manages recurring scheduled blocks via launchd user agents. +// + +#import +#import "SCSchedule.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface SCScheduleManager : NSObject + ++ (instancetype)sharedManager; + +- (NSArray *)allSchedules; +- (void)addSchedule:(SCSchedule *)schedule; +- (void)removeSchedule:(SCSchedule *)schedule; +- (void)updateSchedule:(SCSchedule *)schedule; + +// Writes/removes launchd plists for all schedules and loads/unloads as needed. +- (void)syncAllLaunchdAgents; + +@end + +NS_ASSUME_NONNULL_END diff --git a/SCScheduleManager.m b/SCScheduleManager.m new file mode 100644 index 00000000..1bc738be --- /dev/null +++ b/SCScheduleManager.m @@ -0,0 +1,239 @@ +// +// SCScheduleManager.m +// SelfControl +// +// Manages recurring scheduled blocks via launchd user agents. +// + +#import "SCScheduleManager.h" +#import "SCBlockFileReaderWriter.h" + +static NSString *const kScheduledBlocks = @"ScheduledBlocks"; + +@implementation SCScheduleManager { + NSUserDefaults *defaults_; +} + ++ (instancetype)sharedManager { + static SCScheduleManager *instance = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + instance = [[SCScheduleManager alloc] init]; + }); + return instance; +} + +- (instancetype)init { + if (self = [super init]) { + defaults_ = [NSUserDefaults standardUserDefaults]; + } + return self; +} + +#pragma mark - Schedule CRUD + +- (NSArray *)allSchedules { + NSArray *dicts = [defaults_ arrayForKey:kScheduledBlocks]; + if (!dicts) return @[]; + + NSMutableArray *schedules = [NSMutableArray arrayWithCapacity:dicts.count]; + for (NSDictionary *dict in dicts) { + [schedules addObject:[SCSchedule scheduleFromDictionary:dict]]; + } + return [schedules copy]; +} + +- (void)saveSchedules:(NSArray *)schedules { + NSMutableArray *dicts = [NSMutableArray arrayWithCapacity:schedules.count]; + for (SCSchedule *s in schedules) { + [dicts addObject:[s dictionaryRepresentation]]; + } + [defaults_ setObject:dicts forKey:kScheduledBlocks]; + [defaults_ synchronize]; +} + +- (void)addSchedule:(SCSchedule *)schedule { + NSMutableArray *schedules = [[self allSchedules] mutableCopy]; + [schedules addObject:schedule]; + [self saveSchedules:schedules]; +} + +- (void)removeSchedule:(SCSchedule *)schedule { + NSMutableArray *schedules = [[self allSchedules] mutableCopy]; + NSUInteger idx = NSNotFound; + for (NSUInteger i = 0; i < schedules.count; i++) { + if ([schedules[i].identifier isEqualToString:schedule.identifier]) { + idx = i; + break; + } + } + if (idx != NSNotFound) { + [schedules removeObjectAtIndex:idx]; + } + [self saveSchedules:schedules]; +} + +- (void)updateSchedule:(SCSchedule *)schedule { + NSMutableArray *schedules = [[self allSchedules] mutableCopy]; + for (NSUInteger i = 0; i < schedules.count; i++) { + if ([schedules[i].identifier isEqualToString:schedule.identifier]) { + schedules[i] = schedule; + break; + } + } + [self saveSchedules:schedules]; +} + +#pragma mark - Launchd Agent Sync + +- (void)syncAllLaunchdAgents { + NSArray *schedules = [self allSchedules]; + NSFileManager *fm = [NSFileManager defaultManager]; + + NSString *launchAgentsDir = [NSHomeDirectory() stringByAppendingPathComponent:@"Library/LaunchAgents"]; + NSString *schedulesDir = [self schedulesDirectory]; + + // Ensure directories exist + [fm createDirectoryAtPath:launchAgentsDir withIntermediateDirectories:YES attributes:nil error:nil]; + [fm createDirectoryAtPath:schedulesDir withIntermediateDirectories:YES attributes:nil error:nil]; + + // Collect labels of all current schedules + NSMutableSet *activeLabels = [NSMutableSet set]; + for (SCSchedule *schedule in schedules) { + [activeLabels addObject:[schedule launchdLabel]]; + } + + // Remove stale plist files (schedules that were removed or disabled) + NSArray *existingPlists = [fm contentsOfDirectoryAtPath:launchAgentsDir error:nil]; + for (NSString *filename in existingPlists) { + if ([filename hasPrefix:@"org.eyebeam.SelfControl.schedule."] && [filename hasSuffix:@".plist"]) { + NSString *label = [filename stringByDeletingPathExtension]; + BOOL shouldExist = NO; + for (SCSchedule *schedule in schedules) { + if (schedule.enabled && [[schedule launchdLabel] isEqualToString:label]) { + shouldExist = YES; + break; + } + } + if (!shouldExist) { + NSString *plistPath = [launchAgentsDir stringByAppendingPathComponent:filename]; + [self unloadLaunchdPlist:plistPath]; + [fm removeItemAtPath:plistPath error:nil]; + // Also remove the blocklist file if it exists + NSString *blocklistLabel = [label stringByReplacingOccurrencesOfString:@"org.eyebeam.SelfControl.schedule." withString:@""]; + NSString *blocklistPath = [schedulesDir stringByAppendingPathComponent: + [NSString stringWithFormat:@"%@.selfcontrol", blocklistLabel]]; + [fm removeItemAtPath:blocklistPath error:nil]; + } + } + } + + // Write plists for all enabled schedules + for (SCSchedule *schedule in schedules) { + if (!schedule.enabled) continue; + + // Write blocklist file + NSString *blocklistPath = [schedulesDir stringByAppendingPathComponent: + [NSString stringWithFormat:@"%@.selfcontrol", schedule.identifier]]; + NSURL *blocklistURL = [NSURL fileURLWithPath:blocklistPath]; + NSError *writeErr = nil; + [SCBlockFileReaderWriter writeBlocklistToFileURL:blocklistURL + blockInfo:@{ + @"Blocklist": schedule.blocklist ?: @[], + @"BlockAsWhitelist": @NO + } + error:&writeErr]; + if (writeErr) { + NSLog(@"SCScheduleManager: Failed to write blocklist for schedule %@: %@", schedule.identifier, writeErr); + continue; + } + + // Build the launchd plist + NSDictionary *plist = [self launchdPlistForSchedule:schedule blocklistPath:blocklistPath]; + NSString *plistPath = [launchAgentsDir stringByAppendingPathComponent: + [NSString stringWithFormat:@"%@.plist", [schedule launchdLabel]]]; + + // Unload existing before overwriting + if ([fm fileExistsAtPath:plistPath]) { + [self unloadLaunchdPlist:plistPath]; + } + + // Write and load + [plist writeToFile:plistPath atomically:YES]; + [self loadLaunchdPlist:plistPath]; + } +} + +- (NSDictionary *)launchdPlistForSchedule:(SCSchedule *)schedule blocklistPath:(NSString *)blocklistPath { + // Path to selfcontrol-cli: bundled inside the app at Contents/MacOS/selfcontrol-cli + NSString *cliPath = [[NSBundle mainBundle] pathForAuxiliaryExecutable:@"selfcontrol-cli"]; + if (!cliPath) { + // Fallback: assume standard install location + cliPath = @"/Applications/SelfControl.app/Contents/MacOS/selfcontrol-cli"; + } + + NSArray *programArguments = @[ + cliPath, + @"start", + @"--duration", + [NSString stringWithFormat:@"%ld", (long)schedule.durationMinutes], + @"--blocklist", + blocklistPath + ]; + + // Build StartCalendarInterval: one entry per weekday + NSMutableArray *calendarIntervals = [NSMutableArray array]; + for (NSNumber *weekday in schedule.weekdays) { + // launchd uses 0=Sunday through 6=Saturday (same as our model, but launchd + // uses 7 for Sunday as well; we'll use 0 which is also valid) + [calendarIntervals addObject:@{ + @"Weekday": weekday, + @"Hour": @(schedule.hour), + @"Minute": @(schedule.minute) + }]; + } + + // If no weekdays specified (daily), use a single entry with just Hour/Minute + if (calendarIntervals.count == 0) { + [calendarIntervals addObject:@{ + @"Hour": @(schedule.hour), + @"Minute": @(schedule.minute) + }]; + } + + return @{ + @"Label": [schedule launchdLabel], + @"ProgramArguments": programArguments, + @"StartCalendarInterval": calendarIntervals, + @"RunAtLoad": @NO + }; +} + +- (NSString *)schedulesDirectory { + NSString *appSupport = [NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, YES) firstObject]; + return [appSupport stringByAppendingPathComponent:@"SelfControl/Schedules"]; +} + +#pragma mark - Launchctl Helpers + +- (void)loadLaunchdPlist:(NSString *)path { + NSTask *task = [[NSTask alloc] init]; + task.launchPath = @"/bin/launchctl"; + task.arguments = @[@"load", @"-w", path]; + [task launch]; + [task waitUntilExit]; + if (task.terminationStatus != 0) { + NSLog(@"SCScheduleManager: launchctl load failed for %@ (status %d)", path, task.terminationStatus); + } +} + +- (void)unloadLaunchdPlist:(NSString *)path { + NSTask *task = [[NSTask alloc] init]; + task.launchPath = @"/bin/launchctl"; + task.arguments = @[@"unload", @"-w", path]; + [task launch]; + [task waitUntilExit]; + // Don't log errors here - the job may already be unloaded +} + +@end diff --git a/ScheduleListWindowController.h b/ScheduleListWindowController.h new file mode 100644 index 00000000..71558b34 --- /dev/null +++ b/ScheduleListWindowController.h @@ -0,0 +1,16 @@ +// +// ScheduleListWindowController.h +// SelfControl +// +// Window controller for managing recurring scheduled blocks. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface ScheduleListWindowController : NSWindowController + +@end + +NS_ASSUME_NONNULL_END diff --git a/ScheduleListWindowController.m b/ScheduleListWindowController.m new file mode 100644 index 00000000..10a579c6 --- /dev/null +++ b/ScheduleListWindowController.m @@ -0,0 +1,386 @@ +// +// ScheduleListWindowController.m +// SelfControl +// +// Window controller for managing recurring scheduled blocks. +// + +#import "ScheduleListWindowController.h" +#import "SCScheduleManager.h" +#import "SCSchedule.h" + +// Tag constants for day checkboxes in the edit sheet +static const NSInteger kDayCheckboxTagBase = 100; + +@interface ScheduleListWindowController () +@property (nonatomic, strong) NSTableView *tableView; +@property (nonatomic, strong) NSMutableArray *schedules; + +// Edit sheet controls +@property (nonatomic, strong) NSWindow *editSheet; +@property (nonatomic, strong) NSTextField *nameField; +@property (nonatomic, strong) NSArray *dayCheckboxes; +@property (nonatomic, strong) NSDatePicker *timePicker; +@property (nonatomic, strong) NSTextField *durationField; +@property (nonatomic, strong) NSTextField *blocklistField; +@property (nonatomic, strong) SCSchedule *editingSchedule; // nil = adding new +@end + +@implementation ScheduleListWindowController + +- (instancetype)init { + NSWindow *window = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 640, 400) + styleMask:(NSWindowStyleMaskTitled | + NSWindowStyleMaskClosable | + NSWindowStyleMaskResizable) + backing:NSBackingStoreBuffered + defer:NO]; + window.title = @"Scheduled Blocks"; + window.minSize = NSMakeSize(500, 300); + + if (self = [super initWithWindow:window]) { + [self setupUI]; + [self reloadSchedules]; + } + return self; +} + +- (void)setupUI { + NSView *contentView = self.window.contentView; + + // Scroll view + table view + NSScrollView *scrollView = [[NSScrollView alloc] initWithFrame:NSMakeRect(0, 44, 640, 356)]; + scrollView.hasVerticalScroller = YES; + scrollView.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; + + _tableView = [[NSTableView alloc] initWithFrame:scrollView.bounds]; + _tableView.dataSource = self; + _tableView.delegate = self; + _tableView.rowHeight = 24; + + NSTableColumn *enabledCol = [[NSTableColumn alloc] initWithIdentifier:@"enabled"]; + enabledCol.title = @"On"; + enabledCol.width = 30; + enabledCol.minWidth = 30; + enabledCol.maxWidth = 30; + [_tableView addTableColumn:enabledCol]; + + NSTableColumn *nameCol = [[NSTableColumn alloc] initWithIdentifier:@"name"]; + nameCol.title = @"Name"; + nameCol.width = 150; + [_tableView addTableColumn:nameCol]; + + NSTableColumn *daysCol = [[NSTableColumn alloc] initWithIdentifier:@"days"]; + daysCol.title = @"Days"; + daysCol.width = 180; + [_tableView addTableColumn:daysCol]; + + NSTableColumn *timeCol = [[NSTableColumn alloc] initWithIdentifier:@"time"]; + timeCol.title = @"Time"; + timeCol.width = 70; + [_tableView addTableColumn:timeCol]; + + NSTableColumn *durationCol = [[NSTableColumn alloc] initWithIdentifier:@"duration"]; + durationCol.title = @"Duration"; + durationCol.width = 80; + [_tableView addTableColumn:durationCol]; + + scrollView.documentView = _tableView; + [contentView addSubview:scrollView]; + + // Button bar at bottom + NSButton *addButton = [NSButton buttonWithTitle:@"Add" target:self action:@selector(addSchedule:)]; + addButton.frame = NSMakeRect(10, 10, 80, 24); + addButton.autoresizingMask = NSViewMaxXMargin | NSViewMaxYMargin; + [contentView addSubview:addButton]; + + NSButton *editButton = [NSButton buttonWithTitle:@"Edit" target:self action:@selector(editSchedule:)]; + editButton.frame = NSMakeRect(100, 10, 80, 24); + editButton.autoresizingMask = NSViewMaxXMargin | NSViewMaxYMargin; + [contentView addSubview:editButton]; + + NSButton *removeButton = [NSButton buttonWithTitle:@"Remove" target:self action:@selector(removeSchedule:)]; + removeButton.frame = NSMakeRect(190, 10, 80, 24); + removeButton.autoresizingMask = NSViewMaxXMargin | NSViewMaxYMargin; + [contentView addSubview:removeButton]; +} + +- (void)reloadSchedules { + self.schedules = [[[SCScheduleManager sharedManager] allSchedules] mutableCopy]; + [self.tableView reloadData]; +} + +#pragma mark - Actions + +- (void)addSchedule:(id)sender { + self.editingSchedule = nil; + [self showEditSheet]; +} + +- (void)editSchedule:(id)sender { + NSInteger row = self.tableView.selectedRow; + if (row < 0 || (NSUInteger)row >= self.schedules.count) return; + self.editingSchedule = self.schedules[(NSUInteger)row]; + [self showEditSheet]; +} + +- (void)removeSchedule:(id)sender { + NSInteger row = self.tableView.selectedRow; + if (row < 0 || (NSUInteger)row >= self.schedules.count) return; + + SCSchedule *schedule = self.schedules[(NSUInteger)row]; + [[SCScheduleManager sharedManager] removeSchedule:schedule]; + [[SCScheduleManager sharedManager] syncAllLaunchdAgents]; + [self reloadSchedules]; +} + +#pragma mark - NSTableViewDataSource + +- (NSInteger)numberOfRowsInTableView:(NSTableView *)tableView { + return (NSInteger)self.schedules.count; +} + +- (id)tableView:(NSTableView *)tableView objectValueForTableColumn:(NSTableColumn *)tableColumn row:(NSInteger)row { + if (row < 0 || (NSUInteger)row >= self.schedules.count) return nil; + SCSchedule *schedule = self.schedules[(NSUInteger)row]; + NSString *ident = tableColumn.identifier; + + if ([ident isEqualToString:@"enabled"]) { + return @(schedule.enabled); + } else if ([ident isEqualToString:@"name"]) { + return schedule.name; + } else if ([ident isEqualToString:@"days"]) { + return [self daysSummaryForSchedule:schedule]; + } else if ([ident isEqualToString:@"time"]) { + return [NSString stringWithFormat:@"%02ld:%02ld", (long)schedule.hour, (long)schedule.minute]; + } else if ([ident isEqualToString:@"duration"]) { + if (schedule.durationMinutes >= 60) { + NSInteger hours = schedule.durationMinutes / 60; + NSInteger mins = schedule.durationMinutes % 60; + if (mins > 0) { + return [NSString stringWithFormat:@"%ldh %ldm", (long)hours, (long)mins]; + } + return [NSString stringWithFormat:@"%ldh", (long)hours]; + } + return [NSString stringWithFormat:@"%ldm", (long)schedule.durationMinutes]; + } + return nil; +} + +- (void)tableView:(NSTableView *)tableView setObjectValue:(id)object forTableColumn:(NSTableColumn *)tableColumn row:(NSInteger)row { + if (row < 0 || (NSUInteger)row >= self.schedules.count) return; + SCSchedule *schedule = self.schedules[(NSUInteger)row]; + + if ([tableColumn.identifier isEqualToString:@"enabled"]) { + schedule.enabled = [object boolValue]; + [[SCScheduleManager sharedManager] updateSchedule:schedule]; + [[SCScheduleManager sharedManager] syncAllLaunchdAgents]; + [self reloadSchedules]; + } +} + +#pragma mark - NSTableViewDelegate + +- (NSCell *)tableView:(NSTableView *)tableView dataCellForTableColumn:(NSTableColumn *)tableColumn row:(NSInteger)row { + if ([tableColumn.identifier isEqualToString:@"enabled"]) { + NSButtonCell *cell = [[NSButtonCell alloc] init]; + [cell setButtonType:NSButtonTypeSwitch]; + [cell setTitle:@""]; + return cell; + } + return nil; // use default text cell +} + +#pragma mark - Edit Sheet + +- (void)showEditSheet { + NSWindow *sheet = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 440, 320) + styleMask:(NSWindowStyleMaskTitled) + backing:NSBackingStoreBuffered + defer:NO]; + sheet.title = self.editingSchedule ? @"Edit Schedule" : @"New Schedule"; + self.editSheet = sheet; + + NSView *content = sheet.contentView; + CGFloat y = 280; + CGFloat labelW = 80; + CGFloat fieldX = 90; + + // Name + [self addLabel:@"Name:" at:NSMakePoint(10, y) inView:content width:labelW]; + _nameField = [[NSTextField alloc] initWithFrame:NSMakeRect(fieldX, y, 330, 22)]; + [content addSubview:_nameField]; + + // Days + y -= 36; + [self addLabel:@"Days:" at:NSMakePoint(10, y) inView:content width:labelW]; + NSArray *dayNames = @[@"Sun", @"Mon", @"Tue", @"Wed", @"Thu", @"Fri", @"Sat"]; + NSMutableArray *checkboxes = [NSMutableArray array]; + for (NSUInteger i = 0; i < 7; i++) { + NSButton *cb = [NSButton checkboxWithTitle:dayNames[i] target:nil action:nil]; + cb.frame = NSMakeRect(fieldX + (CGFloat)i * 48, y, 46, 22); + cb.tag = kDayCheckboxTagBase + (NSInteger)i; + [content addSubview:cb]; + [checkboxes addObject:cb]; + } + _dayCheckboxes = [checkboxes copy]; + + // Time + y -= 36; + [self addLabel:@"Time:" at:NSMakePoint(10, y) inView:content width:labelW]; + _timePicker = [[NSDatePicker alloc] initWithFrame:NSMakeRect(fieldX, y, 100, 22)]; + _timePicker.datePickerStyle = NSDatePickerStyleTextFieldAndStepper; + _timePicker.datePickerElements = NSDatePickerElementFlagHourMinute; + // Set a default date with the desired hour/minute + NSCalendar *cal = [NSCalendar currentCalendar]; + NSDateComponents *comps = [[NSDateComponents alloc] init]; + comps.hour = 9; + comps.minute = 0; + comps.year = 2025; + comps.month = 1; + comps.day = 1; + _timePicker.dateValue = [cal dateFromComponents:comps]; + [content addSubview:_timePicker]; + + // Duration + y -= 36; + [self addLabel:@"Duration:" at:NSMakePoint(10, y) inView:content width:labelW]; + _durationField = [[NSTextField alloc] initWithFrame:NSMakeRect(fieldX, y, 80, 22)]; + _durationField.placeholderString = @"minutes"; + [content addSubview:_durationField]; + NSTextField *minLabel = [NSTextField labelWithString:@"minutes"]; + minLabel.frame = NSMakeRect(fieldX + 86, y, 60, 22); + [content addSubview:minLabel]; + + // Blocklist + y -= 36; + [self addLabel:@"Blocklist:" at:NSMakePoint(10, y) inView:content width:labelW]; + _blocklistField = [[NSTextField alloc] initWithFrame:NSMakeRect(fieldX, y - 60, 330, 80)]; + _blocklistField.placeholderString = @"Enter domains, one per line (e.g. facebook.com)"; + [content addSubview:_blocklistField]; + + // Buttons + NSButton *cancelBtn = [NSButton buttonWithTitle:@"Cancel" target:self action:@selector(cancelEditSheet:)]; + cancelBtn.frame = NSMakeRect(260, 10, 80, 30); + cancelBtn.keyEquivalent = @"\033"; // Escape + [content addSubview:cancelBtn]; + + NSButton *saveBtn = [NSButton buttonWithTitle:@"Save" target:self action:@selector(saveEditSheet:)]; + saveBtn.frame = NSMakeRect(350, 10, 80, 30); + saveBtn.keyEquivalent = @"\r"; // Enter + [content addSubview:saveBtn]; + + // Populate if editing + if (self.editingSchedule) { + _nameField.stringValue = self.editingSchedule.name ?: @""; + for (NSNumber *day in self.editingSchedule.weekdays) { + NSInteger idx = [day integerValue]; + if (idx >= 0 && idx < 7) { + _dayCheckboxes[(NSUInteger)idx].state = NSControlStateValueOn; + } + } + NSDateComponents *timeComps = [[NSDateComponents alloc] init]; + timeComps.hour = self.editingSchedule.hour; + timeComps.minute = self.editingSchedule.minute; + timeComps.year = 2025; + timeComps.month = 1; + timeComps.day = 1; + _timePicker.dateValue = [cal dateFromComponents:timeComps]; + _durationField.integerValue = self.editingSchedule.durationMinutes; + _blocklistField.stringValue = [self.editingSchedule.blocklist componentsJoinedByString:@"\n"]; + } else { + _durationField.integerValue = 60; + } + + [self.window beginSheet:sheet completionHandler:nil]; +} + +- (void)addLabel:(NSString *)text at:(NSPoint)origin inView:(NSView *)view width:(CGFloat)width { + NSTextField *label = [NSTextField labelWithString:text]; + label.frame = NSMakeRect(origin.x, origin.y, width, 22); + label.alignment = NSTextAlignmentRight; + [view addSubview:label]; +} + +- (void)cancelEditSheet:(id)sender { + [self.window endSheet:self.editSheet]; + self.editSheet = nil; + self.editingSchedule = nil; +} + +- (void)saveEditSheet:(id)sender { + SCSchedule *schedule = self.editingSchedule ?: [[SCSchedule alloc] init]; + + schedule.name = _nameField.stringValue; + + // Collect selected weekdays + NSMutableArray *days = [NSMutableArray array]; + for (NSUInteger i = 0; i < 7; i++) { + if (_dayCheckboxes[i].state == NSControlStateValueOn) { + [days addObject:@(i)]; + } + } + schedule.weekdays = days; + + // Extract hour and minute from the date picker + NSCalendar *cal = [NSCalendar currentCalendar]; + NSDateComponents *comps = [cal components:(NSCalendarUnitHour | NSCalendarUnitMinute) fromDate:_timePicker.dateValue]; + schedule.hour = comps.hour; + schedule.minute = comps.minute; + + schedule.durationMinutes = MAX(_durationField.integerValue, 1); + + // Parse blocklist: split by newlines and commas, trim whitespace + NSString *raw = _blocklistField.stringValue; + NSMutableArray *entries = [NSMutableArray array]; + for (NSString *line in [raw componentsSeparatedByCharactersInSet:[NSCharacterSet newlineCharacterSet]]) { + NSString *trimmed = [line stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; + if (trimmed.length > 0) { + [entries addObject:trimmed]; + } + } + schedule.blocklist = entries; + + if (self.editingSchedule) { + [[SCScheduleManager sharedManager] updateSchedule:schedule]; + } else { + schedule.enabled = YES; + [[SCScheduleManager sharedManager] addSchedule:schedule]; + } + + [[SCScheduleManager sharedManager] syncAllLaunchdAgents]; + + [self.window endSheet:self.editSheet]; + self.editSheet = nil; + self.editingSchedule = nil; + [self reloadSchedules]; +} + +#pragma mark - Helpers + +- (NSString *)daysSummaryForSchedule:(SCSchedule *)schedule { + if (schedule.weekdays.count == 0) return @"Daily"; + if (schedule.weekdays.count == 7) return @"Every day"; + + // Check for weekdays (Mon-Fri) + NSSet *weekdaySet = [NSSet setWithArray:schedule.weekdays]; + NSSet *monFri = [NSSet setWithArray:@[@1, @2, @3, @4, @5]]; + if ([weekdaySet isEqualToSet:monFri]) return @"Weekdays"; + + NSSet *satSun = [NSSet setWithArray:@[@0, @6]]; + if ([weekdaySet isEqualToSet:satSun]) return @"Weekends"; + + NSArray *abbrevs = @[@"Sun", @"Mon", @"Tue", @"Wed", @"Thu", @"Fri", @"Sat"]; + NSMutableArray *names = [NSMutableArray array]; + // Sort weekdays for display + NSArray *sorted = [schedule.weekdays sortedArrayUsingSelector:@selector(compare:)]; + for (NSNumber *day in sorted) { + NSUInteger idx = [day unsignedIntegerValue]; + if (idx < 7) { + [names addObject:abbrevs[idx]]; + } + } + return [names componentsJoinedByString:@", "]; +} + +@end diff --git a/ScheduleManager/LaunchAgentWriter.swift b/ScheduleManager/LaunchAgentWriter.swift new file mode 100644 index 00000000..86fd2934 --- /dev/null +++ b/ScheduleManager/LaunchAgentWriter.swift @@ -0,0 +1,65 @@ +import Foundation + +/// Generates launchd plist dictionaries and manages launchctl operations. +enum LaunchAgentWriter { + + /// Generate a launchd plist for a recurring schedule. + static func plistForSchedule(_ schedule: SCSchedule, + blocklistPath: String, + cliPath: String) -> [String: Any] { + let programArguments: [String] = [ + cliPath, "start", + "--duration", "\(schedule.durationMinutes)", + "--blocklist", blocklistPath, + ] + + var calendarIntervals: [[String: Any]] = [] + if schedule.weekdays.isEmpty { + // Daily — just Hour + Minute + calendarIntervals.append([ + "Hour": schedule.hour, + "Minute": schedule.minute, + ]) + } else { + for weekday in schedule.weekdays { + calendarIntervals.append([ + "Weekday": weekday, + "Hour": schedule.hour, + "Minute": schedule.minute, + ]) + } + } + + return [ + "Label": schedule.launchdLabel, + "ProgramArguments": programArguments, + "StartCalendarInterval": calendarIntervals, + "RunAtLoad": false, + ] + } + + /// Load a launchd agent plist. + static func loadAgent(at path: String) { + let task = Process() + task.executableURL = URL(fileURLWithPath: "/bin/launchctl") + task.arguments = ["load", "-w", path] + task.standardOutput = FileHandle.nullDevice + task.standardError = FileHandle.nullDevice + try? task.run() + task.waitUntilExit() + if task.terminationStatus != 0 { + NSLog("LaunchAgentWriter: launchctl load failed for %@ (status %d)", path, task.terminationStatus) + } + } + + /// Unload a launchd agent plist. + static func unloadAgent(at path: String) { + let task = Process() + task.executableURL = URL(fileURLWithPath: "/bin/launchctl") + task.arguments = ["unload", "-w", path] + task.standardOutput = FileHandle.nullDevice + task.standardError = FileHandle.nullDevice + try? task.run() + task.waitUntilExit() + } +} diff --git a/ScheduleManager/SCScheduleManager.swift b/ScheduleManager/SCScheduleManager.swift new file mode 100644 index 00000000..0583b3c0 --- /dev/null +++ b/ScheduleManager/SCScheduleManager.swift @@ -0,0 +1,113 @@ +import Foundation + +/// Manages recurring scheduled blocks stored in UserDefaults +/// and synced to launchd user agents. +final class SCScheduleManager { + static let shared = SCScheduleManager() + + private let defaults = UserDefaults.standard + private let key = "ScheduledBlocks" + + // MARK: - CRUD + + func allSchedules() -> [SCSchedule] { + guard let data = defaults.data(forKey: key) else { return [] } + return (try? JSONDecoder().decode([SCSchedule].self, from: data)) ?? [] + } + + func addSchedule(_ schedule: SCSchedule) { + var schedules = allSchedules() + schedules.append(schedule) + saveSchedules(schedules) + } + + func removeSchedule(_ schedule: SCSchedule) { + var schedules = allSchedules() + schedules.removeAll { $0.id == schedule.id } + saveSchedules(schedules) + } + + func updateSchedule(_ schedule: SCSchedule) { + var schedules = allSchedules() + if let idx = schedules.firstIndex(where: { $0.id == schedule.id }) { + schedules[idx] = schedule + } + saveSchedules(schedules) + } + + private func saveSchedules(_ schedules: [SCSchedule]) { + guard let data = try? JSONEncoder().encode(schedules) else { return } + defaults.set(data, forKey: key) + } + + // MARK: - Launchd Sync + + func syncAllLaunchdAgents() { + let schedules = allSchedules() + let fm = FileManager.default + let launchAgentsDir = (NSHomeDirectory() as NSString).appendingPathComponent("Library/LaunchAgents") + let schedulesDir = self.schedulesDirectory() + + try? fm.createDirectory(atPath: launchAgentsDir, withIntermediateDirectories: true) + try? fm.createDirectory(atPath: schedulesDir, withIntermediateDirectories: true) + + // Collect enabled schedule labels + let enabledLabels = Set(schedules.filter(\.enabled).map(\.launchdLabel)) + + // Remove stale plists + if let existing = try? fm.contentsOfDirectory(atPath: launchAgentsDir) { + for filename in existing { + guard filename.hasPrefix(StoneConstants.scheduleLaunchdPrefix), + filename.hasSuffix(".plist") else { continue } + let label = (filename as NSString).deletingPathExtension + if !enabledLabels.contains(label) { + let path = (launchAgentsDir as NSString).appendingPathComponent(filename) + LaunchAgentWriter.unloadAgent(at: path) + try? fm.removeItem(atPath: path) + // Clean up blocklist file + let scheduleId = label.replacingOccurrences(of: "\(StoneConstants.scheduleLaunchdPrefix).", with: "") + let blocklistPath = (schedulesDir as NSString).appendingPathComponent("\(scheduleId).stone") + try? fm.removeItem(atPath: blocklistPath) + } + } + } + + // Write plists for enabled schedules + for schedule in schedules where schedule.enabled { + let blocklistPath = (schedulesDir as NSString).appendingPathComponent("\(schedule.id).stone") + let blocklistURL = URL(fileURLWithPath: blocklistPath) + + do { + try SCBlockFileReaderWriter.writeBlocklist( + to: blocklistURL, + blockInfo: [ + "Blocklist": schedule.blocklist, + "BlockAsWhitelist": false, + ] + ) + } catch { + NSLog("SCScheduleManager: Failed to write blocklist for %@: %@", schedule.id, error.localizedDescription) + continue + } + + let cliPath = Bundle.main.path(forAuxiliaryExecutable: "stone-cli") + ?? "/Applications/Stone.app/Contents/MacOS/stone-cli" + + let plist = LaunchAgentWriter.plistForSchedule(schedule, blocklistPath: blocklistPath, cliPath: cliPath) + let plistPath = (launchAgentsDir as NSString).appendingPathComponent("\(schedule.launchdLabel).plist") + + // Unload existing before overwriting + if fm.fileExists(atPath: plistPath) { + LaunchAgentWriter.unloadAgent(at: plistPath) + } + + (plist as NSDictionary).write(toFile: plistPath, atomically: true) + LaunchAgentWriter.loadAgent(at: plistPath) + } + } + + private func schedulesDirectory() -> String { + let appSupport = NSSearchPathForDirectoriesInDomains(.applicationSupportDirectory, .userDomainMask, true).first! + return (appSupport as NSString).appendingPathComponent("Stone/Schedules") + } +} diff --git a/SelfControl.xcodeproj/project.pbxproj b/SelfControl.xcodeproj/project.pbxproj index 2954aea5..2818ea9b 100644 --- a/SelfControl.xcodeproj/project.pbxproj +++ b/SelfControl.xcodeproj/project.pbxproj @@ -7,6 +7,9 @@ objects = { /* Begin PBXBuildFile section */ + AA00000200000001 /* SCSchedule.m in Sources */ = {isa = PBXBuildFile; fileRef = AA00000100000002 /* SCSchedule.m */; }; + AA00000200000002 /* SCScheduleManager.m in Sources */ = {isa = PBXBuildFile; fileRef = AA00000100000004 /* SCScheduleManager.m */; }; + AA00000200000003 /* ScheduleListWindowController.m in Sources */ = {isa = PBXBuildFile; fileRef = AA00000100000006 /* ScheduleListWindowController.m */; }; 5E6BEEBB5C6E29DADDB344CF /* libPods-selfcontrol-cli.a in Frameworks */ = {isa = PBXBuildFile; fileRef = C85A094F15E20A0DB58665A8 /* libPods-selfcontrol-cli.a */; }; 63BAC9E58A69B15D342B0E29 /* libPods-org.eyebeam.selfcontrold.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 8BF3973D41997900DF147B24 /* libPods-org.eyebeam.selfcontrold.a */; }; 8CA8987104D2956493D6AF6B /* Pods_SelfControl_SelfControlTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F41DEF1E3926B4CF3AE2B76C /* Pods_SelfControl_SelfControlTests.framework */; }; @@ -523,6 +526,12 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + AA00000100000001 /* SCSchedule.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SCSchedule.h; sourceTree = ""; }; + AA00000100000002 /* SCSchedule.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SCSchedule.m; sourceTree = ""; }; + AA00000100000003 /* SCScheduleManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SCScheduleManager.h; sourceTree = ""; }; + AA00000100000004 /* SCScheduleManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SCScheduleManager.m; sourceTree = ""; }; + AA00000100000005 /* ScheduleListWindowController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ScheduleListWindowController.h; sourceTree = ""; }; + AA00000100000006 /* ScheduleListWindowController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ScheduleListWindowController.m; sourceTree = ""; }; 1058C7A1FEA54F0111CA2CBB /* Cocoa.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Cocoa.framework; path = /System/Library/Frameworks/Cocoa.framework; sourceTree = ""; }; 29B97316FDCFA39411CA2CEA /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = ""; }; 29B97324FDCFA39411CA2CEA /* AppKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AppKit.framework; path = /System/Library/Frameworks/AppKit.framework; sourceTree = ""; }; @@ -1704,6 +1713,12 @@ CBD4848D19D768C90020F949 /* PreferencesAdvancedViewController.m */, CBB637210F3E296000EBD135 /* DomainListWindowController.h */, CBB637220F3E296000EBD135 /* DomainListWindowController.m */, + AA00000100000001 /* SCSchedule.h */, + AA00000100000002 /* SCSchedule.m */, + AA00000100000003 /* SCScheduleManager.h */, + AA00000100000004 /* SCScheduleManager.m */, + AA00000100000005 /* ScheduleListWindowController.h */, + AA00000100000006 /* ScheduleListWindowController.m */, CBEE50BF0F48C21F00F5DF1C /* TimerWindowController.h */, CBEE50C00F48C21F00F5DF1C /* TimerWindowController.m */, CBC2F8650F4674E300CF2A42 /* LaunchctlHelper.h */, @@ -3724,6 +3739,9 @@ CBD4848F19D768C90020F949 /* PreferencesAdvancedViewController.m in Sources */, CB81A9D025B7C269006956F7 /* SCBlockUtilities.m in Sources */, CBB7DEEA0F53313F00ABF3EA /* DomainListWindowController.m in Sources */, + AA00000200000001 /* SCSchedule.m in Sources */, + AA00000200000002 /* SCScheduleManager.m in Sources */, + AA00000200000003 /* ScheduleListWindowController.m in Sources */, CB953114262BC64F000C8309 /* SCDurationSlider.m in Sources */, CBF3B574217BADD7006D5F52 /* SCSettings.m in Sources */, CB25806616C237F10059C99A /* NSString+IPAddress.m in Sources */, diff --git a/Stone.xcodeproj/project.pbxproj b/Stone.xcodeproj/project.pbxproj new file mode 100644 index 00000000..fd47f5b8 --- /dev/null +++ b/Stone.xcodeproj/project.pbxproj @@ -0,0 +1,917 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 040A88FF896E666909FC8C44 /* SCHelperToolUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F284281E701208ECD7EBCE7 /* SCHelperToolUtilities.swift */; }; + 0D6CF09467365285F500641A /* StoneApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87DB2129F8A7C0A942ED0F90 /* StoneApp.swift */; }; + 0ED3392D9A12A2E8880ED2BA /* SCSchedule.swift in Sources */ = {isa = PBXBuildFile; fileRef = DED1399C417B39686EB99AD4 /* SCSchedule.swift */; }; + 106FFBC6D17CCC244FF24FEF /* SCMiscUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFACB0A8550E6FD043C2C3E1 /* SCMiscUtilities.swift */; }; + 117A8C434516A4012844B26B /* CLIMain.swift in Sources */ = {isa = PBXBuildFile; fileRef = A146DC953141CDB91A35B6F8 /* CLIMain.swift */; }; + 1505D0C4E9D97C2C8095A048 /* StoneConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EC10F58DCCA388EAE65D588 /* StoneConstants.swift */; }; + 18333F10096A9054D4D319E3 /* StoneConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EC10F58DCCA388EAE65D588 /* StoneConstants.swift */; }; + 1BEFE1899805F4645CD76520 /* SCBlockUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 569551479489BC531A33BEB7 /* SCBlockUtilities.swift */; }; + 1D7A2DEC40C56B4BA3EA8B6A /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 232FB9DA4ED5132949F91C14 /* AppDelegate.swift */; }; + 24EA5EF8600F76989822456D /* SCBlockFileReaderWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5970812CE66F46499078AE02 /* SCBlockFileReaderWriter.swift */; }; + 25E2105DC1DDA3F569872498 /* SCSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A044613CE99FFAB614D20EA0 /* SCSettings.swift */; }; + 278E6A08181EE2EBBE3A8F9F /* BlockEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B7698B13644DCEE090D8536 /* BlockEntry.swift */; }; + 294E88BFA0F896F86DD69EFC /* SCFileWatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDBB035AB421516579E60CC0 /* SCFileWatcher.swift */; }; + 2B8D76B0A3B122EB01865DA7 /* SCError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2ED6F8E16436AAE80A0B23FE /* SCError.swift */; }; + 31E737CB6266E78638D66D5A /* SCXPCClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DAB6759F20C23316048431E /* SCXPCClient.swift */; }; + 34427832FD6532C71DE07B58 /* PacketFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3D532AA35A5730F1E4EDF5D /* PacketFilter.swift */; }; + 3475125047C6FD631B8D91D1 /* SCXPCClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DAB6759F20C23316048431E /* SCXPCClient.swift */; }; + 34E13B558FA50E3047BDB490 /* BlockManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B32D03470CDDAACDE0FC7C0 /* BlockManager.swift */; }; + 37BB975D12BD86E29BD3D6B1 /* SCFileWatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDBB035AB421516579E60CC0 /* SCFileWatcher.swift */; }; + 3870CC537FE7C26B06C63C6F /* DomainListWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B6CF651375CFC460C717E91 /* DomainListWindowController.swift */; }; + 3A8975C5BE707AB29E13AAE1 /* SCDaemonBlockMethods.swift in Sources */ = {isa = PBXBuildFile; fileRef = A74D771F3252AE3956D9C3D5 /* SCDaemonBlockMethods.swift */; }; + 3AB4C7A8A4E735F07E6C095E /* ScheduleListWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D02C57DDDEEFE5CA8049A77 /* ScheduleListWindowController.swift */; }; + 419CA4823F8E6980DD937027 /* SCXPCAuthorization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFB0AFE764F88E0B7FBD806 /* SCXPCAuthorization.swift */; }; + 477E9916ADEF6848646C3320 /* SCXPCClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DAB6759F20C23316048431E /* SCXPCClient.swift */; }; + 4E88ED763599BC497531CEC7 /* SCDaemonXPC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 057C5BBC3302FF8EE61617EA /* SCDaemonXPC.swift */; }; + 5055D3900FC720534831662B /* SCSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A044613CE99FFAB614D20EA0 /* SCSettings.swift */; }; + 51F333ADA960DD1768FF5029 /* BlockManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B32D03470CDDAACDE0FC7C0 /* BlockManager.swift */; }; + 546A1A30070BB988713DFC5C /* SCSchedule.swift in Sources */ = {isa = PBXBuildFile; fileRef = DED1399C417B39686EB99AD4 /* SCSchedule.swift */; }; + 589BA90E5B9027B8A8390D42 /* SCHelperToolUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F284281E701208ECD7EBCE7 /* SCHelperToolUtilities.swift */; }; + 5B5D2ED27324A66270D42416 /* SCBlockUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 569551479489BC531A33BEB7 /* SCBlockUtilities.swift */; }; + 64EA6D95887D3AFFB78E6A88 /* SCXPCAuthorization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFB0AFE764F88E0B7FBD806 /* SCXPCAuthorization.swift */; }; + 69876C7DBD71CC1FD4F421DE /* selfcontrold-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = B278D52251F163FB6F6C5024 /* selfcontrold-Info.plist */; }; + 69887871491A8F0543A1A2FF /* SCError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2ED6F8E16436AAE80A0B23FE /* SCError.swift */; }; + 6FCC3875421881F9E624F060 /* SCDaemon.swift in Sources */ = {isa = PBXBuildFile; fileRef = F30EEF5DB4977AE0BE8C349C /* SCDaemon.swift */; }; + 748D71676E7172BB5D6500BF /* DaemonMain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 261228596B561E606AF0AE61 /* DaemonMain.swift */; }; + 798637F8EBAB0ED4885A8D22 /* TimerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0570BFDD7A5C59587B66094B /* TimerView.swift */; }; + 7B28026A528D1215E5BDF787 /* HostFileBlockerSet.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACCE1D3A846400EF79C0DD98 /* HostFileBlockerSet.swift */; }; + 7BC4288DD6EA269A4754C549 /* SCSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A044613CE99FFAB614D20EA0 /* SCSettings.swift */; }; + 7CB93BD6B5C774E87D3F571B /* HostFileBlockerSet.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACCE1D3A846400EF79C0DD98 /* HostFileBlockerSet.swift */; }; + 7D95A03028567BA5EFA15968 /* BlockManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B32D03470CDDAACDE0FC7C0 /* BlockManager.swift */; }; + 810FDBD7877DACDA53954155 /* stonectld-launchd.plist in Resources */ = {isa = PBXBuildFile; fileRef = FDA547682924A811CA0DC334 /* stonectld-launchd.plist */; }; + 84815EB64C09FEFDD9C77291 /* SCDaemonProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4AB71FFC84568F369668307 /* SCDaemonProtocol.swift */; }; + 86ED6D4184D72512453B6EF9 /* SCBlockFileReaderWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5970812CE66F46499078AE02 /* SCBlockFileReaderWriter.swift */; }; + 88E470BEA357FAAA338045DB /* AuditTokenBridge.m in Sources */ = {isa = PBXBuildFile; fileRef = F7F20A1F0BC042C079D0BFC8 /* AuditTokenBridge.m */; }; + 8AD91468D12501578312CE2C /* HostFileBlocker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 851832B4CC5063A4D705791C /* HostFileBlocker.swift */; }; + 8D126F42CC966C40EB4C9A92 /* PacketFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3D532AA35A5730F1E4EDF5D /* PacketFilter.swift */; }; + 8DE027BC2F7ECD35803A7E0F /* SCMiscUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFACB0A8550E6FD043C2C3E1 /* SCMiscUtilities.swift */; }; + 8E22A23323934A5D7EE4D1AB /* SCMiscUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFACB0A8550E6FD043C2C3E1 /* SCMiscUtilities.swift */; }; + 907E89F80D2B863C6D5AB7AC /* SCScheduleManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 168C56121028944D384946B1 /* SCScheduleManager.swift */; }; + 9DD1F0B230E6CF53A34A4907 /* TimerWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82185FB5301AF5C63A1C3BDB /* TimerWindowController.swift */; }; + AD8B4D075BABBC6C4DC98D6C /* HostFileBlocker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 851832B4CC5063A4D705791C /* HostFileBlocker.swift */; }; + ADD609E33123F092B55D4BB4 /* SCBlockUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 569551479489BC531A33BEB7 /* SCBlockUtilities.swift */; }; + AF1F6D9FE0D5D6511D15A6D9 /* SCDaemonProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4AB71FFC84568F369668307 /* SCDaemonProtocol.swift */; }; + B2DA1464C5D58E5FD8298319 /* SCError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2ED6F8E16436AAE80A0B23FE /* SCError.swift */; }; + B46E85D5661A50A68DB27231 /* AppController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D79D35D15F488E9B9DECA2F /* AppController.swift */; }; + C02EEB8F35329B0BEEEC136B /* SCSchedule.swift in Sources */ = {isa = PBXBuildFile; fileRef = DED1399C417B39686EB99AD4 /* SCSchedule.swift */; }; + C55EF9FDA24D1CA66D9AF22D /* ScheduleEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 096EC9AC36CCB4176B678026 /* ScheduleEditorView.swift */; }; + CAA072446AE9BF20D8D1CE1B /* SCXPCAuthorization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFB0AFE764F88E0B7FBD806 /* SCXPCAuthorization.swift */; }; + CAEDD32AF4DD2E8C840A6EC4 /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50398F5263612597916A8E29 /* MainView.swift */; }; + CC6B70DE885B0FA34D079580 /* HostFileBlocker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 851832B4CC5063A4D705791C /* HostFileBlocker.swift */; }; + CD87D586EF3F7FB3884641AF /* PacketFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3D532AA35A5730F1E4EDF5D /* PacketFilter.swift */; }; + D0161A5A3C18119313DF2A48 /* HostFileBlockerSet.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACCE1D3A846400EF79C0DD98 /* HostFileBlockerSet.swift */; }; + D25ACC5540E5D1B753327502 /* SCDaemonProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4AB71FFC84568F369668307 /* SCDaemonProtocol.swift */; }; + D576FCC33E7BFE4C7FDDBD85 /* BlocklistEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B40086D328DE6600FE7094C /* BlocklistEditorView.swift */; }; + D987C89596EFF402461E788F /* BlockEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B7698B13644DCEE090D8536 /* BlockEntry.swift */; }; + DFE5B44375F387C025FED2B9 /* LaunchAgentWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E68C4A9C57642218C792495 /* LaunchAgentWriter.swift */; }; + E5170D0B68AD145E1886A147 /* SCHelperToolUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F284281E701208ECD7EBCE7 /* SCHelperToolUtilities.swift */; }; + E75714E8838B21BD022D0EBB /* StoneConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EC10F58DCCA388EAE65D588 /* StoneConstants.swift */; }; + E7B86B6C4C85D6494183CDE7 /* SCBlockFileReaderWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5970812CE66F46499078AE02 /* SCBlockFileReaderWriter.swift */; }; + EDEA395D6C081E33587893D9 /* PreferencesWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3B69CC6C0BFA46FA3B5D6E7 /* PreferencesWindowController.swift */; }; + F087391F7573F522F2114ACF /* SCFileWatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDBB035AB421516579E60CC0 /* SCFileWatcher.swift */; }; + F0DBD53D5BB1C46385DDB328 /* BlockEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B7698B13644DCEE090D8536 /* BlockEntry.swift */; }; + FCCBCDF0C497C9A5AEC06740 /* MainWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 990FAD6FDA907CA9F17F300A /* MainWindowController.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + BB0669151D26FC2EC81CA122 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 8631BC5990162C10C0F2F9F7 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 9608672A1CB611C3899ECFE1; + remoteInfo = stonectld; + }; + C0386963B2AC9C91FD2D4CD9 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 8631BC5990162C10C0F2F9F7 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 4EF7DCE8DD80278647418D5A; + remoteInfo = "stone-cli"; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 048790817576E977828D84C2 /* stonectld */ = {isa = PBXFileReference; includeInIndex = 0; path = stonectld; sourceTree = BUILT_PRODUCTS_DIR; }; + 0508BAB39CA3480AFD649FA8 /* Daemon-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Daemon-Bridging-Header.h"; sourceTree = ""; }; + 0570BFDD7A5C59587B66094B /* TimerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerView.swift; sourceTree = ""; }; + 057C5BBC3302FF8EE61617EA /* SCDaemonXPC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SCDaemonXPC.swift; sourceTree = ""; }; + 096EC9AC36CCB4176B678026 /* ScheduleEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduleEditorView.swift; sourceTree = ""; }; + 0C6D45C45DE02C5347B040F4 /* stonectld-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "stonectld-Info.plist"; sourceTree = ""; }; + 123DE962F50955B8D07A576F /* Stone.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Stone.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 168C56121028944D384946B1 /* SCScheduleManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SCScheduleManager.swift; sourceTree = ""; }; + 1BEDC951BC669725F0BD9476 /* SCXPCClient.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SCXPCClient.m; sourceTree = ""; }; + 1EC10F58DCCA388EAE65D588 /* StoneConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoneConstants.swift; sourceTree = ""; }; + 232FB9DA4ED5132949F91C14 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 243011EE17C85EA874A61A4A /* SCXPCClient.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SCXPCClient.h; sourceTree = ""; }; + 2528103835D7841AC7D6D93C /* SCBlockUtilities.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SCBlockUtilities.h; sourceTree = ""; }; + 261228596B561E606AF0AE61 /* DaemonMain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DaemonMain.swift; sourceTree = ""; }; + 26B4B82DA0F5D5595F6293CE /* SCSentry.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SCSentry.m; sourceTree = ""; }; + 2B7698B13644DCEE090D8536 /* BlockEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockEntry.swift; sourceTree = ""; }; + 2CCF370D9DBC8842BCDEB00F /* SCMiscUtilities.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SCMiscUtilities.h; sourceTree = ""; }; + 2DFB0AFE764F88E0B7FBD806 /* SCXPCAuthorization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SCXPCAuthorization.swift; sourceTree = ""; }; + 2ED6F8E16436AAE80A0B23FE /* SCError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SCError.swift; sourceTree = ""; }; + 363AEBEB1B7EE3D1181B51A8 /* SCMiscUtilities.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SCMiscUtilities.m; sourceTree = ""; }; + 374DE5F3841F565820706831 /* SCXPCAuthorization.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SCXPCAuthorization.h; sourceTree = ""; }; + 3B6CF651375CFC460C717E91 /* DomainListWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainListWindowController.swift; sourceTree = ""; }; + 3D79D35D15F488E9B9DECA2F /* AppController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppController.swift; sourceTree = ""; }; + 4059E42EA649653FA3FB9C27 /* SCMigrationUtilities.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SCMigrationUtilities.h; sourceTree = ""; }; + 4095D9FBFF9211A9FB1BCA2D /* SCBlockFileReaderWriter.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SCBlockFileReaderWriter.h; sourceTree = ""; }; + 46E6F3563A98E77E296BD499 /* SCFileWatcher.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SCFileWatcher.m; sourceTree = ""; }; + 50398F5263612597916A8E29 /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = ""; }; + 53F5834162EDF5DBE631FE61 /* SCXPCAuthorization.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SCXPCAuthorization.m; sourceTree = ""; }; + 5652B8E9E1631B55F7672F1D /* SCErr.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SCErr.h; sourceTree = ""; }; + 569551479489BC531A33BEB7 /* SCBlockUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SCBlockUtilities.swift; sourceTree = ""; }; + 5970812CE66F46499078AE02 /* SCBlockFileReaderWriter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SCBlockFileReaderWriter.swift; sourceTree = ""; }; + 5D02C57DDDEEFE5CA8049A77 /* ScheduleListWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduleListWindowController.swift; sourceTree = ""; }; + 5DAB6759F20C23316048431E /* SCXPCClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SCXPCClient.swift; sourceTree = ""; }; + 5F284281E701208ECD7EBCE7 /* SCHelperToolUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SCHelperToolUtilities.swift; sourceTree = ""; }; + 6E68C4A9C57642218C792495 /* LaunchAgentWriter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAgentWriter.swift; sourceTree = ""; }; + 74F3B97CCDEF17E04640F7D2 /* SCFileWatcher.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SCFileWatcher.h; sourceTree = ""; }; + 7964AA928B13A6124B9E9F22 /* SCHelperToolUtilities.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SCHelperToolUtilities.h; sourceTree = ""; }; + 81B85B7204FBAA041AA22026 /* SCHelperToolUtilities.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SCHelperToolUtilities.m; sourceTree = ""; }; + 82185FB5301AF5C63A1C3BDB /* TimerWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerWindowController.swift; sourceTree = ""; }; + 842940A25301847E3B4E0745 /* SCSettings.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SCSettings.h; sourceTree = ""; }; + 851832B4CC5063A4D705791C /* HostFileBlocker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostFileBlocker.swift; sourceTree = ""; }; + 87DB2129F8A7C0A942ED0F90 /* StoneApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoneApp.swift; sourceTree = ""; }; + 8AD3C1F151F282715803D81A /* SCMigrationUtilities.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SCMigrationUtilities.m; sourceTree = ""; }; + 8B40086D328DE6600FE7094C /* BlocklistEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlocklistEditorView.swift; sourceTree = ""; }; + 990FAD6FDA907CA9F17F300A /* MainWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWindowController.swift; sourceTree = ""; }; + 9A682B3340DC84919B08145A /* stone-cli-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "stone-cli-Info.plist"; sourceTree = ""; }; + 9AB7136E59B2FA338645AE81 /* SCBlockUtilities.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SCBlockUtilities.m; sourceTree = ""; }; + 9B32D03470CDDAACDE0FC7C0 /* BlockManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockManager.swift; sourceTree = ""; }; + A044613CE99FFAB614D20EA0 /* SCSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SCSettings.swift; sourceTree = ""; }; + A146DC953141CDB91A35B6F8 /* CLIMain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIMain.swift; sourceTree = ""; }; + A4AB71FFC84568F369668307 /* SCDaemonProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SCDaemonProtocol.swift; sourceTree = ""; }; + A4FAE9DA8608684E15D4DFF5 /* SCSettings.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SCSettings.m; sourceTree = ""; }; + A74D771F3252AE3956D9C3D5 /* SCDaemonBlockMethods.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SCDaemonBlockMethods.swift; sourceTree = ""; }; + ACCE1D3A846400EF79C0DD98 /* HostFileBlockerSet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostFileBlockerSet.swift; sourceTree = ""; }; + B278D52251F163FB6F6C5024 /* selfcontrold-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "selfcontrold-Info.plist"; sourceTree = ""; }; + B3CD9D15E10F73F816C31880 /* DeprecationSilencers.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = DeprecationSilencers.h; sourceTree = ""; }; + B83D9D55954B90E64DE6C9B9 /* Stone-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Stone-Info.plist"; sourceTree = ""; }; + B90BC2D6A4F71CC656A0483F /* SCSentry.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SCSentry.h; sourceTree = ""; }; + BDB7743052430508FE2ED85B /* SCUtility.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SCUtility.h; sourceTree = ""; }; + C008F9968CD2891A1403669B /* SCBlockFileReaderWriter.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SCBlockFileReaderWriter.m; sourceTree = ""; }; + C3D532AA35A5730F1E4EDF5D /* PacketFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketFilter.swift; sourceTree = ""; }; + DED1399C417B39686EB99AD4 /* SCSchedule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SCSchedule.swift; sourceTree = ""; }; + E0221CFF6929F228A523C497 /* Stone.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Stone.entitlements; sourceTree = ""; }; + EA02F9A6D41568ED2F6BA7E3 /* stone-cli */ = {isa = PBXFileReference; includeInIndex = 0; path = "stone-cli"; sourceTree = BUILT_PRODUCTS_DIR; }; + EFACB0A8550E6FD043C2C3E1 /* SCMiscUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SCMiscUtilities.swift; sourceTree = ""; }; + F30EEF5DB4977AE0BE8C349C /* SCDaemon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SCDaemon.swift; sourceTree = ""; }; + F3B69CC6C0BFA46FA3B5D6E7 /* PreferencesWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesWindowController.swift; sourceTree = ""; }; + F4FA1FB17B16818C95688C22 /* SCErr.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SCErr.m; sourceTree = ""; }; + F7F20A1F0BC042C079D0BFC8 /* AuditTokenBridge.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AuditTokenBridge.m; sourceTree = ""; }; + F9B574D60FEBD95CF4E0CF33 /* AuditTokenBridge.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AuditTokenBridge.h; sourceTree = ""; }; + FDA547682924A811CA0DC334 /* stonectld-launchd.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "stonectld-launchd.plist"; sourceTree = ""; }; + FDBB035AB421516579E60CC0 /* SCFileWatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SCFileWatcher.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXGroup section */ + 087B7F199963E3FF9928A533 = { + isa = PBXGroup; + children = ( + 671292155A56CAFF50E6415C /* App */, + 68D2A09ECFA733093863D5CD /* CLI */, + F54276623912CE4E80222E1E /* Common */, + 469A196B1F7B4C5BB39BB5E4 /* Daemon */, + BD48DC24DA0B4613418334A5 /* ScheduleManager */, + 542797B83A462E80C83FBEF0 /* Products */, + ); + sourceTree = ""; + }; + 1CC73973318527A034B1F4CA /* Errors */ = { + isa = PBXGroup; + children = ( + 2ED6F8E16436AAE80A0B23FE /* SCError.swift */, + ); + path = Errors; + sourceTree = ""; + }; + 2EEF7B848C93C7443C587D4F /* Block */ = { + isa = PBXGroup; + children = ( + 9B32D03470CDDAACDE0FC7C0 /* BlockManager.swift */, + 851832B4CC5063A4D705791C /* HostFileBlocker.swift */, + ACCE1D3A846400EF79C0DD98 /* HostFileBlockerSet.swift */, + C3D532AA35A5730F1E4EDF5D /* PacketFilter.swift */, + ); + path = Block; + sourceTree = ""; + }; + 32BF39EF071779D528A17471 /* UI */ = { + isa = PBXGroup; + children = ( + 8B40086D328DE6600FE7094C /* BlocklistEditorView.swift */, + 3B6CF651375CFC460C717E91 /* DomainListWindowController.swift */, + 50398F5263612597916A8E29 /* MainView.swift */, + 990FAD6FDA907CA9F17F300A /* MainWindowController.swift */, + F3B69CC6C0BFA46FA3B5D6E7 /* PreferencesWindowController.swift */, + 096EC9AC36CCB4176B678026 /* ScheduleEditorView.swift */, + 5D02C57DDDEEFE5CA8049A77 /* ScheduleListWindowController.swift */, + 0570BFDD7A5C59587B66094B /* TimerView.swift */, + 82185FB5301AF5C63A1C3BDB /* TimerWindowController.swift */, + ); + path = UI; + sourceTree = ""; + }; + 469A196B1F7B4C5BB39BB5E4 /* Daemon */ = { + isa = PBXGroup; + children = ( + F9B574D60FEBD95CF4E0CF33 /* AuditTokenBridge.h */, + F7F20A1F0BC042C079D0BFC8 /* AuditTokenBridge.m */, + 0508BAB39CA3480AFD649FA8 /* Daemon-Bridging-Header.h */, + 261228596B561E606AF0AE61 /* DaemonMain.swift */, + F30EEF5DB4977AE0BE8C349C /* SCDaemon.swift */, + A74D771F3252AE3956D9C3D5 /* SCDaemonBlockMethods.swift */, + 057C5BBC3302FF8EE61617EA /* SCDaemonXPC.swift */, + B278D52251F163FB6F6C5024 /* selfcontrold-Info.plist */, + 0C6D45C45DE02C5347B040F4 /* stonectld-Info.plist */, + FDA547682924A811CA0DC334 /* stonectld-launchd.plist */, + ); + path = Daemon; + sourceTree = ""; + }; + 507CFEFB1DD408F5B73A9642 /* Utilities */ = { + isa = PBXGroup; + children = ( + 5970812CE66F46499078AE02 /* SCBlockFileReaderWriter.swift */, + 569551479489BC531A33BEB7 /* SCBlockUtilities.swift */, + FDBB035AB421516579E60CC0 /* SCFileWatcher.swift */, + 5F284281E701208ECD7EBCE7 /* SCHelperToolUtilities.swift */, + EFACB0A8550E6FD043C2C3E1 /* SCMiscUtilities.swift */, + ); + path = Utilities; + sourceTree = ""; + }; + 542797B83A462E80C83FBEF0 /* Products */ = { + isa = PBXGroup; + children = ( + EA02F9A6D41568ED2F6BA7E3 /* stone-cli */, + 123DE962F50955B8D07A576F /* Stone.app */, + 048790817576E977828D84C2 /* stonectld */, + ); + name = Products; + sourceTree = ""; + }; + 5BDEB8A50FFF383562C687F0 /* Resources */ = { + isa = PBXGroup; + children = ( + ); + path = Resources; + sourceTree = ""; + }; + 5BEEBC4B3C82B893DCD6FDCA /* Protocol */ = { + isa = PBXGroup; + children = ( + A4AB71FFC84568F369668307 /* SCDaemonProtocol.swift */, + ); + path = Protocol; + sourceTree = ""; + }; + 5DC9094F3DFAFD62D6783C96 /* Settings */ = { + isa = PBXGroup; + children = ( + A044613CE99FFAB614D20EA0 /* SCSettings.swift */, + ); + path = Settings; + sourceTree = ""; + }; + 631FD8FA37CA858EEA87759C /* XPC */ = { + isa = PBXGroup; + children = ( + 2DFB0AFE764F88E0B7FBD806 /* SCXPCAuthorization.swift */, + 5DAB6759F20C23316048431E /* SCXPCClient.swift */, + ); + path = XPC; + sourceTree = ""; + }; + 671292155A56CAFF50E6415C /* App */ = { + isa = PBXGroup; + children = ( + 3D79D35D15F488E9B9DECA2F /* AppController.swift */, + 232FB9DA4ED5132949F91C14 /* AppDelegate.swift */, + B83D9D55954B90E64DE6C9B9 /* Stone-Info.plist */, + E0221CFF6929F228A523C497 /* Stone.entitlements */, + 87DB2129F8A7C0A942ED0F90 /* StoneApp.swift */, + 5BDEB8A50FFF383562C687F0 /* Resources */, + 32BF39EF071779D528A17471 /* UI */, + ); + path = App; + sourceTree = ""; + }; + 68D2A09ECFA733093863D5CD /* CLI */ = { + isa = PBXGroup; + children = ( + A146DC953141CDB91A35B6F8 /* CLIMain.swift */, + 9A682B3340DC84919B08145A /* stone-cli-Info.plist */, + ); + path = CLI; + sourceTree = ""; + }; + 8D399B5FA509FC13FB039159 /* Model */ = { + isa = PBXGroup; + children = ( + 2B7698B13644DCEE090D8536 /* BlockEntry.swift */, + DED1399C417B39686EB99AD4 /* SCSchedule.swift */, + 1EC10F58DCCA388EAE65D588 /* StoneConstants.swift */, + ); + path = Model; + sourceTree = ""; + }; + BD48DC24DA0B4613418334A5 /* ScheduleManager */ = { + isa = PBXGroup; + children = ( + 6E68C4A9C57642218C792495 /* LaunchAgentWriter.swift */, + 168C56121028944D384946B1 /* SCScheduleManager.swift */, + ); + path = ScheduleManager; + sourceTree = ""; + }; + DC1E08AA328E1C12E7CB5236 /* Utility */ = { + isa = PBXGroup; + children = ( + 2528103835D7841AC7D6D93C /* SCBlockUtilities.h */, + 9AB7136E59B2FA338645AE81 /* SCBlockUtilities.m */, + 7964AA928B13A6124B9E9F22 /* SCHelperToolUtilities.h */, + 81B85B7204FBAA041AA22026 /* SCHelperToolUtilities.m */, + 4059E42EA649653FA3FB9C27 /* SCMigrationUtilities.h */, + 8AD3C1F151F282715803D81A /* SCMigrationUtilities.m */, + 2CCF370D9DBC8842BCDEB00F /* SCMiscUtilities.h */, + 363AEBEB1B7EE3D1181B51A8 /* SCMiscUtilities.m */, + BDB7743052430508FE2ED85B /* SCUtility.h */, + ); + path = Utility; + sourceTree = ""; + }; + F54276623912CE4E80222E1E /* Common */ = { + isa = PBXGroup; + children = ( + B3CD9D15E10F73F816C31880 /* DeprecationSilencers.h */, + 4095D9FBFF9211A9FB1BCA2D /* SCBlockFileReaderWriter.h */, + C008F9968CD2891A1403669B /* SCBlockFileReaderWriter.m */, + 5652B8E9E1631B55F7672F1D /* SCErr.h */, + F4FA1FB17B16818C95688C22 /* SCErr.m */, + 74F3B97CCDEF17E04640F7D2 /* SCFileWatcher.h */, + 46E6F3563A98E77E296BD499 /* SCFileWatcher.m */, + B90BC2D6A4F71CC656A0483F /* SCSentry.h */, + 26B4B82DA0F5D5595F6293CE /* SCSentry.m */, + 842940A25301847E3B4E0745 /* SCSettings.h */, + A4FAE9DA8608684E15D4DFF5 /* SCSettings.m */, + 374DE5F3841F565820706831 /* SCXPCAuthorization.h */, + 53F5834162EDF5DBE631FE61 /* SCXPCAuthorization.m */, + 243011EE17C85EA874A61A4A /* SCXPCClient.h */, + 1BEDC951BC669725F0BD9476 /* SCXPCClient.m */, + 2EEF7B848C93C7443C587D4F /* Block */, + 1CC73973318527A034B1F4CA /* Errors */, + 8D399B5FA509FC13FB039159 /* Model */, + 5BEEBC4B3C82B893DCD6FDCA /* Protocol */, + 5DC9094F3DFAFD62D6783C96 /* Settings */, + 507CFEFB1DD408F5B73A9642 /* Utilities */, + DC1E08AA328E1C12E7CB5236 /* Utility */, + 631FD8FA37CA858EEA87759C /* XPC */, + ); + path = Common; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 4EF7DCE8DD80278647418D5A /* stone-cli */ = { + isa = PBXNativeTarget; + buildConfigurationList = E9843ADB9693963C2975D443 /* Build configuration list for PBXNativeTarget "stone-cli" */; + buildPhases = ( + 5703D2B0AFF49A60F8BFFCBA /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = "stone-cli"; + packageProductDependencies = ( + ); + productName = "stone-cli"; + productReference = EA02F9A6D41568ED2F6BA7E3 /* stone-cli */; + productType = "com.apple.product-type.tool"; + }; + 9608672A1CB611C3899ECFE1 /* stonectld */ = { + isa = PBXNativeTarget; + buildConfigurationList = 12FBD430CC514D006BB378FE /* Build configuration list for PBXNativeTarget "stonectld" */; + buildPhases = ( + 1CD92A72950F4B110E36E00D /* Sources */, + 6F09F37A3238F049220C67A0 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = stonectld; + packageProductDependencies = ( + ); + productName = stonectld; + productReference = 048790817576E977828D84C2 /* stonectld */; + productType = "com.apple.product-type.tool"; + }; + A84044D94B3AAA75AFBC785A /* Stone */ = { + isa = PBXNativeTarget; + buildConfigurationList = DD91E3612950828C44233F30 /* Build configuration list for PBXNativeTarget "Stone" */; + buildPhases = ( + 259D1AF1552537C5641BC614 /* Sources */, + 1BDE74C22FB8DC52EC7812A6 /* Copy Daemon to LaunchServices */, + ); + buildRules = ( + ); + dependencies = ( + A87536CB7C7EADEA2E9EEB52 /* PBXTargetDependency */, + B4BB9287699396349F6AF333 /* PBXTargetDependency */, + ); + name = Stone; + packageProductDependencies = ( + ); + productName = Stone; + productReference = 123DE962F50955B8D07A576F /* Stone.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 8631BC5990162C10C0F2F9F7 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1500; + TargetAttributes = { + 4EF7DCE8DD80278647418D5A = { + DevelopmentTeam = H9N9P29TX5; + ProvisioningStyle = Automatic; + }; + 9608672A1CB611C3899ECFE1 = { + DevelopmentTeam = H9N9P29TX5; + ProvisioningStyle = Automatic; + }; + A84044D94B3AAA75AFBC785A = { + DevelopmentTeam = H9N9P29TX5; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 42020E3AC92EB5ACB3D6F263 /* Build configuration list for PBXProject "Stone" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = 087B7F199963E3FF9928A533; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + projectDirPath = ""; + projectRoot = ""; + targets = ( + A84044D94B3AAA75AFBC785A /* Stone */, + 4EF7DCE8DD80278647418D5A /* stone-cli */, + 9608672A1CB611C3899ECFE1 /* stonectld */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 6F09F37A3238F049220C67A0 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 69876C7DBD71CC1FD4F421DE /* selfcontrold-Info.plist in Resources */, + 810FDBD7877DACDA53954155 /* stonectld-launchd.plist in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 1BDE74C22FB8DC52EC7812A6 /* Copy Daemon to LaunchServices */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Copy Daemon to LaunchServices"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "mkdir -p \"${BUILT_PRODUCTS_DIR}/${FRAMEWORKS_FOLDER_PATH}/../Library/LaunchServices\"\ncp \"${BUILT_PRODUCTS_DIR}/com.max4c.stonectld\" \"${BUILT_PRODUCTS_DIR}/${FRAMEWORKS_FOLDER_PATH}/../Library/LaunchServices/\" 2>/dev/null || true\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 1CD92A72950F4B110E36E00D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 88E470BEA357FAAA338045DB /* AuditTokenBridge.m in Sources */, + F0DBD53D5BB1C46385DDB328 /* BlockEntry.swift in Sources */, + 34E13B558FA50E3047BDB490 /* BlockManager.swift in Sources */, + 748D71676E7172BB5D6500BF /* DaemonMain.swift in Sources */, + 8AD91468D12501578312CE2C /* HostFileBlocker.swift in Sources */, + D0161A5A3C18119313DF2A48 /* HostFileBlockerSet.swift in Sources */, + CD87D586EF3F7FB3884641AF /* PacketFilter.swift in Sources */, + E7B86B6C4C85D6494183CDE7 /* SCBlockFileReaderWriter.swift in Sources */, + 5B5D2ED27324A66270D42416 /* SCBlockUtilities.swift in Sources */, + 6FCC3875421881F9E624F060 /* SCDaemon.swift in Sources */, + 3A8975C5BE707AB29E13AAE1 /* SCDaemonBlockMethods.swift in Sources */, + D25ACC5540E5D1B753327502 /* SCDaemonProtocol.swift in Sources */, + 4E88ED763599BC497531CEC7 /* SCDaemonXPC.swift in Sources */, + B2DA1464C5D58E5FD8298319 /* SCError.swift in Sources */, + F087391F7573F522F2114ACF /* SCFileWatcher.swift in Sources */, + 589BA90E5B9027B8A8390D42 /* SCHelperToolUtilities.swift in Sources */, + 8DE027BC2F7ECD35803A7E0F /* SCMiscUtilities.swift in Sources */, + C02EEB8F35329B0BEEEC136B /* SCSchedule.swift in Sources */, + 25E2105DC1DDA3F569872498 /* SCSettings.swift in Sources */, + 419CA4823F8E6980DD937027 /* SCXPCAuthorization.swift in Sources */, + 3475125047C6FD631B8D91D1 /* SCXPCClient.swift in Sources */, + 1505D0C4E9D97C2C8095A048 /* StoneConstants.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 259D1AF1552537C5641BC614 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B46E85D5661A50A68DB27231 /* AppController.swift in Sources */, + 1D7A2DEC40C56B4BA3EA8B6A /* AppDelegate.swift in Sources */, + D987C89596EFF402461E788F /* BlockEntry.swift in Sources */, + 51F333ADA960DD1768FF5029 /* BlockManager.swift in Sources */, + D576FCC33E7BFE4C7FDDBD85 /* BlocklistEditorView.swift in Sources */, + 3870CC537FE7C26B06C63C6F /* DomainListWindowController.swift in Sources */, + CC6B70DE885B0FA34D079580 /* HostFileBlocker.swift in Sources */, + 7CB93BD6B5C774E87D3F571B /* HostFileBlockerSet.swift in Sources */, + DFE5B44375F387C025FED2B9 /* LaunchAgentWriter.swift in Sources */, + CAEDD32AF4DD2E8C840A6EC4 /* MainView.swift in Sources */, + FCCBCDF0C497C9A5AEC06740 /* MainWindowController.swift in Sources */, + 8D126F42CC966C40EB4C9A92 /* PacketFilter.swift in Sources */, + EDEA395D6C081E33587893D9 /* PreferencesWindowController.swift in Sources */, + 86ED6D4184D72512453B6EF9 /* SCBlockFileReaderWriter.swift in Sources */, + ADD609E33123F092B55D4BB4 /* SCBlockUtilities.swift in Sources */, + AF1F6D9FE0D5D6511D15A6D9 /* SCDaemonProtocol.swift in Sources */, + 69887871491A8F0543A1A2FF /* SCError.swift in Sources */, + 294E88BFA0F896F86DD69EFC /* SCFileWatcher.swift in Sources */, + E5170D0B68AD145E1886A147 /* SCHelperToolUtilities.swift in Sources */, + 8E22A23323934A5D7EE4D1AB /* SCMiscUtilities.swift in Sources */, + 0ED3392D9A12A2E8880ED2BA /* SCSchedule.swift in Sources */, + 907E89F80D2B863C6D5AB7AC /* SCScheduleManager.swift in Sources */, + 7BC4288DD6EA269A4754C549 /* SCSettings.swift in Sources */, + 64EA6D95887D3AFFB78E6A88 /* SCXPCAuthorization.swift in Sources */, + 31E737CB6266E78638D66D5A /* SCXPCClient.swift in Sources */, + C55EF9FDA24D1CA66D9AF22D /* ScheduleEditorView.swift in Sources */, + 3AB4C7A8A4E735F07E6C095E /* ScheduleListWindowController.swift in Sources */, + 0D6CF09467365285F500641A /* StoneApp.swift in Sources */, + 18333F10096A9054D4D319E3 /* StoneConstants.swift in Sources */, + 798637F8EBAB0ED4885A8D22 /* TimerView.swift in Sources */, + 9DD1F0B230E6CF53A34A4907 /* TimerWindowController.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 5703D2B0AFF49A60F8BFFCBA /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 278E6A08181EE2EBBE3A8F9F /* BlockEntry.swift in Sources */, + 7D95A03028567BA5EFA15968 /* BlockManager.swift in Sources */, + 117A8C434516A4012844B26B /* CLIMain.swift in Sources */, + AD8B4D075BABBC6C4DC98D6C /* HostFileBlocker.swift in Sources */, + 7B28026A528D1215E5BDF787 /* HostFileBlockerSet.swift in Sources */, + 34427832FD6532C71DE07B58 /* PacketFilter.swift in Sources */, + 24EA5EF8600F76989822456D /* SCBlockFileReaderWriter.swift in Sources */, + 1BEFE1899805F4645CD76520 /* SCBlockUtilities.swift in Sources */, + 84815EB64C09FEFDD9C77291 /* SCDaemonProtocol.swift in Sources */, + 2B8D76B0A3B122EB01865DA7 /* SCError.swift in Sources */, + 37BB975D12BD86E29BD3D6B1 /* SCFileWatcher.swift in Sources */, + 040A88FF896E666909FC8C44 /* SCHelperToolUtilities.swift in Sources */, + 106FFBC6D17CCC244FF24FEF /* SCMiscUtilities.swift in Sources */, + 546A1A30070BB988713DFC5C /* SCSchedule.swift in Sources */, + 5055D3900FC720534831662B /* SCSettings.swift in Sources */, + CAA072446AE9BF20D8D1CE1B /* SCXPCAuthorization.swift in Sources */, + 477E9916ADEF6848646C3320 /* SCXPCClient.swift in Sources */, + E75714E8838B21BD022D0EBB /* StoneConstants.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + A87536CB7C7EADEA2E9EEB52 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 9608672A1CB611C3899ECFE1 /* stonectld */; + targetProxy = BB0669151D26FC2EC81CA122 /* PBXContainerItemProxy */; + }; + B4BB9287699396349F6AF333 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 4EF7DCE8DD80278647418D5A /* stone-cli */; + targetProxy = C0386963B2AC9C91FD2D4CD9 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 0B565D7B650B81F5F9F2C653 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = H9N9P29TX5; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.9; + }; + name = Debug; + }; + 2B8D1184F8FAAD8B00D3589C /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + COMBINE_HIDPI_IMAGES = YES; + CREATE_INFOPLIST_SECTION_IN_BINARY = NO; + ENABLE_HARDENED_RUNTIME = YES; + INFOPLIST_FILE = "Daemon/stonectld-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + OTHER_LDFLAGS = ( + "-sectcreate", + __TEXT, + __info_plist, + "$(SRCROOT)/Daemon/stonectld-Info.plist", + "-sectcreate", + __TEXT, + __launchd_plist, + "$(SRCROOT)/Daemon/stonectld-launchd.plist", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.max4c.stonectld; + PRODUCT_NAME = com.max4c.stonectld; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SWIFT_OBJC_BRIDGING_HEADER = "Daemon/Daemon-Bridging-Header.h"; + }; + name = Release; + }; + 2F5E74367BCC4E70A9E9A3CB /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + COMBINE_HIDPI_IMAGES = YES; + ENABLE_HARDENED_RUNTIME = YES; + INFOPLIST_FILE = "CLI/stone-cli-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.max4c.stone-cli"; + PRODUCT_NAME = "stone-cli"; + SDKROOT = macosx; + SKIP_INSTALL = YES; + }; + name = Release; + }; + 316EB30D69653FB41DC1FB88 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = App/Stone.entitlements; + COMBINE_HIDPI_IMAGES = YES; + ENABLE_HARDENED_RUNTIME = YES; + INFOPLIST_FILE = "App/Stone-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = com.max4c.stone; + PRODUCT_NAME = Stone; + SDKROOT = macosx; + }; + name = Debug; + }; + 3CC92D64DE6E16659A648EB1 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + COMBINE_HIDPI_IMAGES = YES; + ENABLE_HARDENED_RUNTIME = YES; + INFOPLIST_FILE = "CLI/stone-cli-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.max4c.stone-cli"; + PRODUCT_NAME = "stone-cli"; + SDKROOT = macosx; + SKIP_INSTALL = YES; + }; + name = Debug; + }; + 816E6D4567388374C7F0A335 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = H9N9P29TX5; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.9; + }; + name = Release; + }; + 841AAC963D77EDF7A0D26F5F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + COMBINE_HIDPI_IMAGES = YES; + CREATE_INFOPLIST_SECTION_IN_BINARY = NO; + ENABLE_HARDENED_RUNTIME = YES; + INFOPLIST_FILE = "Daemon/stonectld-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + OTHER_LDFLAGS = ( + "-sectcreate", + __TEXT, + __info_plist, + "$(SRCROOT)/Daemon/stonectld-Info.plist", + "-sectcreate", + __TEXT, + __launchd_plist, + "$(SRCROOT)/Daemon/stonectld-launchd.plist", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.max4c.stonectld; + PRODUCT_NAME = com.max4c.stonectld; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SWIFT_OBJC_BRIDGING_HEADER = "Daemon/Daemon-Bridging-Header.h"; + }; + name = Debug; + }; + B2040455476B664A13157B2A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = App/Stone.entitlements; + COMBINE_HIDPI_IMAGES = YES; + ENABLE_HARDENED_RUNTIME = YES; + INFOPLIST_FILE = "App/Stone-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = com.max4c.stone; + PRODUCT_NAME = Stone; + SDKROOT = macosx; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 12FBD430CC514D006BB378FE /* Build configuration list for PBXNativeTarget "stonectld" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 841AAC963D77EDF7A0D26F5F /* Debug */, + 2B8D1184F8FAAD8B00D3589C /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 42020E3AC92EB5ACB3D6F263 /* Build configuration list for PBXProject "Stone" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 0B565D7B650B81F5F9F2C653 /* Debug */, + 816E6D4567388374C7F0A335 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + DD91E3612950828C44233F30 /* Build configuration list for PBXNativeTarget "Stone" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 316EB30D69653FB41DC1FB88 /* Debug */, + B2040455476B664A13157B2A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + E9843ADB9693963C2975D443 /* Build configuration list for PBXNativeTarget "stone-cli" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 3CC92D64DE6E16659A648EB1 /* Debug */, + 2F5E74367BCC4E70A9E9A3CB /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + }; + rootObject = 8631BC5990162C10C0F2F9F7 /* Project object */; +} diff --git a/Stone.xcodeproj/xcshareddata/xcschemes/Stone.xcscheme b/Stone.xcodeproj/xcshareddata/xcschemes/Stone.xcscheme new file mode 100644 index 00000000..fd6a0ed2 --- /dev/null +++ b/Stone.xcodeproj/xcshareddata/xcschemes/Stone.xcscheme @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Stone.xcodeproj/xcshareddata/xcschemes/stone-cli.xcscheme b/Stone.xcodeproj/xcshareddata/xcschemes/stone-cli.xcscheme new file mode 100644 index 00000000..8256aa53 --- /dev/null +++ b/Stone.xcodeproj/xcshareddata/xcschemes/stone-cli.xcscheme @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Stone.xcodeproj/xcshareddata/xcschemes/stonectld.xcscheme b/Stone.xcodeproj/xcshareddata/xcschemes/stonectld.xcscheme new file mode 100644 index 00000000..634ab183 --- /dev/null +++ b/Stone.xcodeproj/xcshareddata/xcschemes/stonectld.xcscheme @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/cli-main.m b/cli-main.m index 46958cca..d1af9954 100755 --- a/cli-main.m +++ b/cli-main.m @@ -39,12 +39,13 @@ int main(int argc, char* argv[]) { * startSig = [XPMArgumentSignature argumentSignatureWithFormat:@"[start --start --install]"], * blocklistSig = [XPMArgumentSignature argumentSignatureWithFormat:@"[--blocklist -b]="], * blockEndDateSig = [XPMArgumentSignature argumentSignatureWithFormat:@"[--enddate -d]="], + * blockDurationSig = [XPMArgumentSignature argumentSignatureWithFormat:@"[--duration]="], * blockSettingsSig = [XPMArgumentSignature argumentSignatureWithFormat:@"[--settings -s]="], * removeSig = [XPMArgumentSignature argumentSignatureWithFormat:@"[remove --remove]"], * printSettingsSig = [XPMArgumentSignature argumentSignatureWithFormat:@"[print-settings --printsettings -p]"], * isRunningSig = [XPMArgumentSignature argumentSignatureWithFormat:@"[is-running --isrunning -r]"], * versionSig = [XPMArgumentSignature argumentSignatureWithFormat:@"[version --version -v]"]; - NSArray * signatures = @[controllingUIDSig, startSig, blocklistSig, blockEndDateSig, blockSettingsSig, removeSig, printSettingsSig, isRunningSig, versionSig]; + NSArray * signatures = @[controllingUIDSig, startSig, blocklistSig, blockEndDateSig, blockDurationSig, blockSettingsSig, removeSig, printSettingsSig, isRunningSig, versionSig]; XPMArgumentPackage * arguments = [[NSProcessInfo processInfo] xpmargs_parseArgumentsWithSignatures:signatures]; // We'll need the controlling UID to know what settings to read @@ -94,7 +95,25 @@ int main(int argc, char* argv[]) { // 1) we can receive them as command-line arguments, including a path to a blocklist file // 2) we can read them from user defaults (for legacy support, don't encourage this) NSString* pathToBlocklistFile = [arguments firstObjectForSignature: blocklistSig]; - NSDate* blockEndDateArg = [[NSISO8601DateFormatter new] dateFromString: [arguments firstObjectForSignature: blockEndDateSig]]; + + NSString* endDateString = [arguments firstObjectForSignature: blockEndDateSig]; + NSString* durationString = [arguments firstObjectForSignature: blockDurationSig]; + if (endDateString != nil && durationString != nil) { + NSLog(@"ERROR: --enddate and --duration are mutually exclusive. Provide one or the other."); + exit(EX_USAGE); + } + + NSDate* blockEndDateArg = nil; + if (durationString != nil) { + int durationMinutes = [durationString intValue]; + if (durationMinutes <= 0) { + NSLog(@"ERROR: --duration must be a positive number of minutes."); + exit(EX_USAGE); + } + blockEndDateArg = [NSDate dateWithTimeIntervalSinceNow: durationMinutes * 60]; + } else if (endDateString != nil) { + blockEndDateArg = [[NSISO8601DateFormatter new] dateFromString: endDateString]; + } // if we didn't get a valid block end date in the future, try our next approach: legacy unlabeled arguments // this is for backwards compatibility. In SC pre-4.0, this used to be called as --install {uid} {pathToBlocklistFile} {blockEndDate} @@ -241,6 +260,7 @@ int main(int argc, char* argv[]) { printf("\n start --> starts a SelfControl block\n"); printf(" --blocklist \n"); printf(" --enddate \n"); + printf(" --duration \n"); printf(" --settings \n"); printf("\n is-running --> prints YES if a SelfControl block is currently running, or NO otherwise\n"); printf("\n print-settings --> prints the SelfControl settings being used for the active block (for debug purposes)\n"); @@ -248,6 +268,7 @@ int main(int argc, char* argv[]) { printf("\n"); printf("--uid argument MUST be specified and set to the controlling user ID if selfcontrol-cli is being run as root. Otherwise, it does not need to be set.\n\n"); printf("Example start command: selfcontrol-cli start --blocklist /path/to/blocklist.selfcontrol --enddate 2021-02-12T06:53:00Z\n"); + printf("Example with duration: selfcontrol-cli start --blocklist /path/to/blocklist.selfcontrol --duration 60\n"); } // final sync before we exit diff --git a/project.yml b/project.yml new file mode 100644 index 00000000..385df52c --- /dev/null +++ b/project.yml @@ -0,0 +1,126 @@ +name: Stone +options: + bundleIdPrefix: com.max4c + deploymentTarget: + macOS: "12.0" + xcodeVersion: "15.0" + generateEmptyDirectories: true + +settings: + base: + SWIFT_VERSION: "5.9" + MACOSX_DEPLOYMENT_TARGET: "12.0" + CLANG_ENABLE_OBJC_ARC: YES + DEVELOPMENT_TEAM: H9N9P29TX5 + CODE_SIGN_IDENTITY: "Apple Development" + CODE_SIGN_STYLE: Automatic + +fileGroups: + - Common + +targets: + Stone: + type: application + platform: macOS + sources: + - path: App + excludes: + - "**/*.m" + - path: Common + excludes: + - "**/*.m" + - "**/*.h" + - path: ScheduleManager + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.max4c.stone + INFOPLIST_FILE: App/Stone-Info.plist + CODE_SIGN_ENTITLEMENTS: App/Stone.entitlements + ENABLE_HARDENED_RUNTIME: YES + PRODUCT_NAME: Stone + COMBINE_HIDPI_IMAGES: YES + LD_RUNPATH_SEARCH_PATHS: "@executable_path/../Frameworks" + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + dependencies: + - target: stonectld + embed: false + copy: + destination: executables + subpath: ../Library/LaunchServices + - target: stone-cli + embed: false + copy: + destination: executables + postBuildScripts: + - name: "Copy Daemon to LaunchServices" + script: | + mkdir -p "${BUILT_PRODUCTS_DIR}/${FRAMEWORKS_FOLDER_PATH}/../Library/LaunchServices" + cp "${BUILT_PRODUCTS_DIR}/com.max4c.stonectld" "${BUILT_PRODUCTS_DIR}/${FRAMEWORKS_FOLDER_PATH}/../Library/LaunchServices/" 2>/dev/null || true + + stonectld: + type: tool + platform: macOS + sources: + - path: Daemon + excludes: + - "**/*.m" + - "**/*.h" + - "**/org.eyebeam.*" + - path: Daemon/AuditTokenBridge.h + - path: Daemon/AuditTokenBridge.m + - path: Daemon/Daemon-Bridging-Header.h + - path: Common + excludes: + - "**/*.m" + - "**/*.h" + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.max4c.stonectld + PRODUCT_NAME: com.max4c.stonectld + INFOPLIST_FILE: Daemon/stonectld-Info.plist + CREATE_INFOPLIST_SECTION_IN_BINARY: NO + SKIP_INSTALL: YES + ENABLE_HARDENED_RUNTIME: YES + SWIFT_OBJC_BRIDGING_HEADER: Daemon/Daemon-Bridging-Header.h + OTHER_LDFLAGS: + - "-sectcreate" + - "__TEXT" + - "__info_plist" + - "$(SRCROOT)/Daemon/stonectld-Info.plist" + - "-sectcreate" + - "__TEXT" + - "__launchd_plist" + - "$(SRCROOT)/Daemon/stonectld-launchd.plist" + scheme: + testTargets: [] + + stone-cli: + type: tool + platform: macOS + sources: + - path: CLI + excludes: + - "**/*.m" + - path: Common + excludes: + - "**/*.m" + - "**/*.h" + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.max4c.stone-cli + PRODUCT_NAME: stone-cli + INFOPLIST_FILE: CLI/stone-cli-Info.plist + SKIP_INSTALL: YES + ENABLE_HARDENED_RUNTIME: YES + scheme: + testTargets: [] + +schemes: + Stone: + build: + targets: + Stone: all + stonectld: all + stone-cli: all + run: + config: Debug