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