diff --git a/AppController.m b/AppController.m index 242bdabf..dc58f170 100755 --- a/AppController.m +++ b/AppController.m @@ -31,7 +31,6 @@ #import "SCXPCClient.h" #import "SCBlockFileReaderWriter.h" #import "SCUIUtilities.h" -#import @interface AppController () {} @@ -608,7 +607,15 @@ - (void)installBlock { // we're about to launch a helper tool which will read settings, so make sure the ones on disk are valid [self->settings_ synchronizeSettings]; [self->defaults_ synchronize]; - + NSDictionary *blockSettings = @{ + @"ClearCaches": [self->defaults_ valueForKey: @"ClearCaches"], + @"AllowLocalNetworks": [self->defaults_ valueForKey: @"AllowLocalNetworks"], + @"EvaluateCommonSubdomains": [self->defaults_ valueForKey: @"EvaluateCommonSubdomains"], + @"IncludeLinkedDomains": [self->defaults_ valueForKey: @"IncludeLinkedDomains"], + @"BlockSoundShouldPlay": [self->defaults_ valueForKey: @"BlockSoundShouldPlay"], + @"BlockSound": [self->defaults_ valueForKey: @"BlockSound"], + @"EnableErrorReporting": [self->defaults_ valueForKey: @"EnableErrorReporting"] + }; // ok, the new helper tool is installed! refresh the connection, then it's time to start the block [self.xpc refreshConnectionAndRun:^{ NSLog(@"Refreshed connection and ready to start block!"); @@ -616,15 +623,7 @@ - (void)installBlock { blocklist: [self->defaults_ arrayForKey: @"Blocklist"] isAllowlist: [self->defaults_ boolForKey: @"BlockAsWhitelist"] endDate: newBlockEndDate - blockSettings: @{ - @"ClearCaches": [self->defaults_ valueForKey: @"ClearCaches"], - @"AllowLocalNetworks": [self->defaults_ valueForKey: @"AllowLocalNetworks"], - @"EvaluateCommonSubdomains": [self->defaults_ valueForKey: @"EvaluateCommonSubdomains"], - @"IncludeLinkedDomains": [self->defaults_ valueForKey: @"IncludeLinkedDomains"], - @"BlockSoundShouldPlay": [self->defaults_ valueForKey: @"BlockSoundShouldPlay"], - @"BlockSound": [self->defaults_ valueForKey: @"BlockSound"], - @"EnableErrorReporting": [self->defaults_ valueForKey: @"EnableErrorReporting"] - } + blockSettings: blockSettings reply:^(NSError * _Nonnull error) { if (error != nil) { [SCUIUtilities presentError: error]; diff --git a/ButtonWithPopupMenu.m b/ButtonWithPopupMenu.m index 277b1db0..f5c768ea 100755 --- a/ButtonWithPopupMenu.m +++ b/ButtonWithPopupMenu.m @@ -54,4 +54,4 @@ - (void)dealloc { } -@end \ No newline at end of file +@end diff --git a/Common/SCSentry.m b/Common/SCSentry.m index 5b97b797..33b35337 100644 --- a/Common/SCSentry.m +++ b/Common/SCSentry.m @@ -9,7 +9,8 @@ #import "SCSettings.h" #ifndef TESTING -#import +//#import +//#import #endif @implementation SCSentry @@ -17,24 +18,24 @@ @implementation SCSentry //org.eyebeam.SelfControl + (void)startSentry:(NSString*)componentId { #ifndef TESTING - [SentrySDK startWithConfigureOptions:^(SentryOptions *options) { - options.dsn = @"https://58fbe7145368418998067f88896007b2@o504820.ingest.sentry.io/5592195"; - options.releaseName = [NSString stringWithFormat: @"%@%@", componentId, SELFCONTROL_VERSION_STRING]; - options.enableAutoSessionTracking = NO; - options.environment = @"dev"; - - // make sure no data leaves the device if error reporting isn't enabled - options.beforeSend = ^SentryEvent * _Nullable(SentryEvent * _Nonnull event) { - if ([SCSentry errorReportingEnabled]) { - return event; - } else { - return NULL; - } - }; - }]; - [SentrySDK configureScope:^(SentryScope * _Nonnull scope) { - [scope setTagValue: [[NSLocale currentLocale] localeIdentifier] forKey: @"localeId"]; - }]; +// [SentrySDK startWithConfigureOptions:^(SentryOptions *options) { +// options.dsn = @"https://58fbe7145368418998067f88896007b2@o504820.ingest.sentry.io/5592195"; +// options.releaseName = [NSString stringWithFormat: @"%@%@", componentId, SELFCONTROL_VERSION_STRING]; +// options.enableAutoSessionTracking = NO; +// options.environment = @"dev"; +// +// // make sure no data leaves the device if error reporting isn't enabled +// options.beforeSend = ^SentryEvent * _Nullable(SentryEvent * _Nonnull event) { +// if ([SCSentry errorReportingEnabled]) { +// return event; +// } else { +// return NULL; +// } +// }; +// }]; +// [SentrySDK configureScope:^(SentryScope * _Nonnull scope) { +// [scope setTagValue: [[NSLocale currentLocale] localeIdentifier] forKey: @"localeId"]; +// }]; #endif } @@ -113,19 +114,19 @@ + (void)updateDefaultsContext { [defaultsDict removeObjectForKey: @"SULastProfileSubmissionDate"]; #ifndef TESTING - [SentrySDK configureScope:^(SentryScope * _Nonnull scope) { - [scope setContextValue: defaultsDict forKey: @"NSUserDefaults"]; - }]; +// [SentrySDK configureScope:^(SentryScope * _Nonnull scope) { +// [scope setContextValue: defaultsDict forKey: @"NSUserDefaults"]; +// }]; #endif } + (void)addBreadcrumb:(NSString*)message category:(NSString*)category { #ifndef TESTING - SentryBreadcrumb* crumb = [[SentryBreadcrumb alloc] init]; - crumb.level = kSentryLevelInfo; - crumb.category = category; - crumb.message = message; - [SentrySDK addBreadcrumb: crumb]; +// SentryBreadcrumb* crumb = [[SentryBreadcrumb alloc] init]; +// crumb.level = kSentryLevelInfo; +// crumb.category = category; +// crumb.message = message; +// [SentrySDK addBreadcrumb: crumb]; #endif } @@ -147,7 +148,7 @@ + (void)captureError:(NSError*)error { [[SCSettings sharedSettings] updateSentryContext]; [SCSentry updateDefaultsContext]; #ifndef TESTING - [SentrySDK captureError: error]; +// [SentrySDK captureError: error]; #endif } @@ -170,11 +171,11 @@ + (void)captureMessage:(NSString*)message withScopeBlock:(nullable void (^)(Sent [SCSentry updateDefaultsContext]; #ifndef TESTING - if (block != nil) { - [SentrySDK captureMessage: message withScopeBlock: block]; - } else { - [SentrySDK captureMessage: message]; - } +// if (block != nil) { +// [SentrySDK captureMessage: message withScopeBlock: block]; +// } else { +// [SentrySDK captureMessage: message]; +// } #endif } diff --git a/Common/SCSettings.m b/Common/SCSettings.m index e0b35082..d8239a34 100644 --- a/Common/SCSettings.m +++ b/Common/SCSettings.m @@ -9,7 +9,7 @@ #import #ifndef TESTING -#import +//#import #endif float const SYNC_INTERVAL_SECS = 30; @@ -485,9 +485,9 @@ - (void)updateSentryContext { } #ifndef TESTING - [SentrySDK configureScope:^(SentryScope * _Nonnull scope) { - [scope setContextValue: dictCopy forKey: @"SCSettings"]; - }]; +// [SentrySDK configureScope:^(SentryScope * _Nonnull scope) { +// [scope setContextValue: dictCopy forKey: @"SCSettings"]; +// }]; #endif } diff --git a/Daemon/SCDaemon.m b/Daemon/SCDaemon.m index 3fd746a2..bd9760f2 100644 --- a/Daemon/SCDaemon.m +++ b/Daemon/SCDaemon.m @@ -124,6 +124,7 @@ - (void)startInactivityTimer { } }]; } + - (void)resetInactivityTimer { self.lastActivityDate = [NSDate date]; } @@ -146,6 +147,11 @@ - (void)dealloc { #pragma mark - NSXPCListenerDelegate - (BOOL)listener:(NSXPCListener *)listener shouldAcceptNewConnection:(NSXPCConnection *)newConnection { +//#if DEBUG +// // In Debug builds, skip code signature validation to ease development. +// NSLog(@"[DEBUG] Accepting XPC connection without signature validation."); +// [SCSentry addBreadcrumb:@"DEBUG: accepted XPC connection without validation" category:@"daemon"]; +//#else // There is a potential security issue / race condition with matching based on PID, so we use the (technically private) auditToken instead audit_token_t auditToken = newConnection.auditToken; NSDictionary* guestAttributes = @{ @@ -159,7 +165,7 @@ - (BOOL)listener:(NSXPCListener *)listener shouldAcceptNewConnection:(NSXPCConne SecRequirementRef isSelfControlApp; // versions before 4.0 didn't have hardened code signing, so aren't trustworthy to talk to the daemon // (plus the daemon didn't exist before 4.0 so there's really no reason they should want to run it!) - SecRequirementCreateWithString(CFSTR("anchor apple generic and (identifier \"org.eyebeam.SelfControl\" or identifier \"org.eyebeam.selfcontrol-cli\") and info [CFBundleVersion] >= \"407\" and (certificate leaf[field.1.2.840.113635.100.6.1.9] /* exists */ or certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = EG6ZYP3AQH)"), kSecCSDefaultFlags, &isSelfControlApp); + SecRequirementCreateWithString(CFSTR("anchor apple generic and (identifier \"org.eyebeam.SelfControl\" or identifier \"org.eyebeam.selfcontrol-cli\") and info [CFBundleVersion] >= \"407\""), kSecCSDefaultFlags, &isSelfControlApp); OSStatus clientValidityStatus = SecCodeCheckValidity(guest, kSecCSDefaultFlags, isSelfControlApp); CFRelease(guest); @@ -171,6 +177,7 @@ - (BOOL)listener:(NSXPCListener *)listener shouldAcceptNewConnection:(NSXPCConne [SCSentry captureError: error]; return NO; } +//#endif SCDaemonXPC* scdXPC = [[SCDaemonXPC alloc] init]; newConnection.exportedInterface = [NSXPCInterface interfaceWithProtocol: @protocol(SCDaemonProtocol)]; diff --git a/Daemon/SCDaemonXPC.m b/Daemon/SCDaemonXPC.m index ad9f09c1..2676c21a 100644 --- a/Daemon/SCDaemonXPC.m +++ b/Daemon/SCDaemonXPC.m @@ -71,7 +71,7 @@ - (void)getVersionWithReply:(void(^)(NSString * version))reply { NSLog(@"XPC method called: getVersionWithReply"); // We specifically don't check for authorization here. Everyone is always allowed to get // the version of the helper tool. - reply(SELFCONTROL_VERSION_STRING); + reply(@"4.0.2"); } @end diff --git a/Daemon/selfcontrold-Info.plist b/Daemon/selfcontrold-Info.plist index fc0522a2..9c146397 100755 --- a/Daemon/selfcontrold-Info.plist +++ b/Daemon/selfcontrold-Info.plist @@ -52,7 +52,7 @@ Free and open-source under the GPL. SMAuthorizedClients - anchor apple generic and (identifier "org.eyebeam.SelfControl" or identifier "org.eyebeam.selfcontrol-cli") and (certificate leaf[field.1.2.840.113635.100.6.1.9] /* exists */ or certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = EG6ZYP3AQH) + anchor apple generic and (identifier "org.eyebeam.SelfControl" or identifier "org.eyebeam.selfcontrol-cli") diff --git a/Info.plist b/Info.plist index 85a72114..7d80bf32 100755 --- a/Info.plist +++ b/Info.plist @@ -58,18 +58,18 @@ SMPrivilegedExecutables org.eyebeam.selfcontrold - anchor apple generic and identifier "org.eyebeam.selfcontrold" and (certificate leaf[field.1.2.840.113635.100.6.1.9] /* exists */ or certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = EG6ZYP3AQH) + anchor apple generic and identifier "org.eyebeam.selfcontrold" SUEnableAutomaticChecks SUEnableJavaScript + SUEnableSystemProfiling + SUFeedURL https://selfcontrolapp.com/appcast.xml SUPublicDSAKeyFile dsa_pub.pem - SUEnableSystemProfiling - SUSendProfileInfo diff --git a/Podfile b/Podfile index 1d715879..51468d78 100644 --- a/Podfile +++ b/Podfile @@ -1,6 +1,6 @@ source 'https://github.com/CocoaPods/Specs.git' -minVersion = '10.10' +minVersion = '11.5' platform :osx, minVersion @@ -11,10 +11,9 @@ plugin 'cocoapods-prune-localizations', { :localizations => supported_locales } target "SelfControl" do use_frameworks! :linkage => :static pod 'MASPreferences', '~> 1.1.4' - pod 'TransformerKit', '~> 1.1.1' pod 'FormatterKit/TimeIntervalFormatter', '~> 1.8.0' pod 'LetsMove', '~> 1.24' - pod 'Sentry', :git => 'https://github.com/getsentry/sentry-cocoa.git', :tag => '7.3.0' + pod 'Sentry' # Add test target target 'SelfControlTests' do @@ -24,19 +23,15 @@ end target "SelfControl Killer" do use_frameworks! :linkage => :static - pod 'Sentry', :git => 'https://github.com/getsentry/sentry-cocoa.git', :tag => '7.3.0' end # we can't use_frameworks on these because they're command-line tools # Sentry says we need use_frameworks, but they seem to work OK anyway? target "SCKillerHelper" do - pod 'Sentry', :git => 'https://github.com/getsentry/sentry-cocoa.git', :tag => '7.3.0' end target "selfcontrol-cli" do - pod 'Sentry', :git => 'https://github.com/getsentry/sentry-cocoa.git', :tag => '7.3.0' end target "org.eyebeam.selfcontrold" do - pod 'Sentry', :git => 'https://github.com/getsentry/sentry-cocoa.git', :tag => '7.3.0' end post_install do |pi| diff --git a/SCDurationSlider.m b/SCDurationSlider.m index 25e75376..14de541b 100644 --- a/SCDurationSlider.m +++ b/SCDurationSlider.m @@ -7,7 +7,6 @@ #import "SCDurationSlider.h" #import "SCTimeIntervalFormatter.h" -#import #define kValueTransformerName @"BlockDurationSliderTransformer" @@ -47,15 +46,17 @@ - (void)setMaxDuration:(NSInteger)maxDuration { } - (void)registerMinutesValueTransformer { - [NSValueTransformer registerValueTransformerWithName: kValueTransformerName - transformedValueClass: [NSNumber class] - returningTransformedValueWithBlock:^id _Nonnull(id _Nonnull value) { - // if it's not a number or convertable to one, IDK man - if (![value respondsToSelector: @selector(floatValue)]) return @0; - - long minutesValue = lroundf([value floatValue]); - return @(minutesValue); - }]; + +//SS: TODO: +// [NSValueTransformer registerValueTransformerWithName: kValueTransformerName +// transformedValueClass: [NSNumber class] +// returningTransformedValueWithBlock:^id _Nonnull(id _Nonnull value) { +// // if it's not a number or convertable to one, IDK man +// if (![value respondsToSelector: @selector(floatValue)]) return @0; +// +// long minutesValue = lroundf([value floatValue]); +// return @(minutesValue); +// }]; } - (NSInteger)durationValueMinutes { @@ -63,13 +64,13 @@ - (NSInteger)durationValueMinutes { } - (void)bindDurationToObject:(id)obj keyPath:(NSString*)keyPath { - [self bind: @"value" - toObject: obj - withKeyPath: keyPath - options: @{ - NSContinuouslyUpdatesValueBindingOption: @YES, - NSValueTransformerNameBindingOption: kValueTransformerName - }]; +// [self bind: @"value" +// toObject: obj +// withKeyPath: keyPath +// options: @{ +// NSContinuouslyUpdatesValueBindingOption: @YES, +// NSValueTransformerNameBindingOption: kValueTransformerName +// }]; } - (NSString*)durationDescription { diff --git a/Self-Control-Extension/SelfControl-Blocker.zip b/Self-Control-Extension/SelfControl-Blocker.zip new file mode 100644 index 00000000..a000c72c Binary files /dev/null and b/Self-Control-Extension/SelfControl-Blocker.zip differ diff --git a/Self-Control-Extension/SelfControl-Blocker/background.js b/Self-Control-Extension/SelfControl-Blocker/background.js new file mode 100644 index 00000000..ff6c1a58 --- /dev/null +++ b/Self-Control-Extension/SelfControl-Blocker/background.js @@ -0,0 +1,186 @@ +// Dynamic URL Blocker — Manifest V3 Service Worker + +// ---------------- HELPERS ---------------- + +const API_URL = "http://127.0.0.1:8532/chrome"; +const ALARM_NAME = "syncBlockedDomains"; +const REFRESH_SECONDS = 10; + + +function normalizeDomain(input) { + let domain = (input || "").trim().toLowerCase(); + domain = domain.replace(/^https?:\/\//, ""); + domain = domain.replace(/^www\./, ""); + domain = domain.split("/")[0]; + return domain.replace(/[^a-z0-9.\-]/g, ""); +} + +function escapeRegex(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function regexForDomain(domain) { + return `^https?:\\/\\/([a-z0-9-]+\\.)*${escapeRegex(domain)}(\\/|:|$)`; +} + +function hash32(str) { + let h = 5381; + for (let i = 0; i < str.length; i++) { + h = ((h << 5) + h) ^ str.charCodeAt(i); + h |= 0; + } + return (Math.abs(h) % 2000000000) + 1000; +} + +function ruleForDomain(domain) { + return { + id: hash32(domain), + priority: 1, + action: { type: "block" }, + condition: { + regexFilter: regexForDomain(domain), + resourceTypes: ["main_frame"] + } + }; +} + +// ---------------- SERVER FETCH ---------------- + +async function fetchBlockedDomains() { + try { + const res = await fetch(API_URL); + const data = await res.json(); + return data.blocked || []; + } catch (err) { + console.error("Fetch failed:", err); + return []; + } +} + +// ---------------- RULE MANAGEMENT ---------------- + +async function getCurrentRuleIds() { + const rules = await chrome.declarativeNetRequest.getDynamicRules(); + return new Set(rules.map(r => r.id)); +} + +async function applyRules(domains) { + + const desiredRules = domains.map(ruleForDomain); + const desiredIds = new Set(desiredRules.map(r => r.id)); + const currentIds = await getCurrentRuleIds(); + + const removeRuleIds = [...currentIds].filter(id => !desiredIds.has(id)); + const addRules = desiredRules.filter(r => !currentIds.has(r.id)); + + if (removeRuleIds.length || addRules.length) { + + await chrome.declarativeNetRequest.updateDynamicRules({ + removeRuleIds, + addRules + }); + + console.log("Rules updated", { + added: addRules.length, + removed: removeRuleIds.length + }); + } +} + +// ---------------- SYNC FUNCTION ---------------- + +async function syncBlockedDomains() { + + const domains = await fetchBlockedDomains(); + + await chrome.storage.local.set({ blockedSites: domains }); + + await applyRules(domains); + + console.log("Blocked domains synced:", domains); +} + +// ---------------- EXTENSION LIFECYCLE ---------------- + +// install +chrome.runtime.onInstalled.addListener(async () => { + + await syncBlockedDomains(); + + chrome.alarms.create(ALARM_NAME, { + periodInMinutes: 1 + }); + + console.log("Extension installed"); +}); +// fast refresh +setInterval(syncBlockedDomains, REFRESH_SECONDS * 1000); + +// browser startup +chrome.runtime.onStartup.addListener(syncBlockedDomains); + +// periodic refresh +chrome.alarms.onAlarm.addListener(alarm => { + if (alarm.name === ALARM_NAME) { + syncBlockedDomains(); + } +}); + +// ---------------- POPUP MESSAGE API ---------------- + +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + + (async () => { + + let domains = (await chrome.storage.local.get("blockedSites")).blockedSites || []; + + if (message.action === "list") { + sendResponse({ success: true, domains }); + return; + } + + if (message.action === "add") { + + const domain = normalizeDomain(message.site); + + if (!domain) { + sendResponse({ success: false, error: "Invalid domain" }); + return; + } + + if (!domains.includes(domain)) { + domains.push(domain); + await chrome.storage.local.set({ blockedSites: domains }); + await applyRules(domains); + } + + sendResponse({ success: true, domains }); + return; + } + + if (message.action === "remove") { + + const domain = normalizeDomain(message.site); + + domains = domains.filter(d => d !== domain); + + await chrome.storage.local.set({ blockedSites: domains }); + + await applyRules(domains); + + sendResponse({ success: true, domains }); + return; + } + + if (message.action === "refresh") { + await syncBlockedDomains(); + sendResponse({ success: true }); + return; + } + + sendResponse({ success: false }); + + })(); + + return true; +}); \ No newline at end of file diff --git a/Self-Control-Extension/SelfControl-Blocker/manifest.json b/Self-Control-Extension/SelfControl-Blocker/manifest.json new file mode 100644 index 00000000..be56f780 --- /dev/null +++ b/Self-Control-Extension/SelfControl-Blocker/manifest.json @@ -0,0 +1,23 @@ +{ + "manifest_version": 3, + "name": "SelfControl Blocker", + "version": "1.0.0", + "description": "Block websites by typing them into a popup. Uses dynamic Declarative Net Request rules.", + "permissions": [ + "declarativeNetRequest", + "storage", + "alarms" + ], + "host_permissions": [ + "http://localhost/*", + "http://127.0.0.1/*" + ], + "background": { + "service_worker": "background.js", + "type": "module" + }, + "action": { + "default_popup": "popup.html", + "default_title": "SelfControl Blocker" + } +} \ No newline at end of file diff --git a/Self-Control-Extension/SelfControl-Blocker/popup.css b/Self-Control-Extension/SelfControl-Blocker/popup.css new file mode 100644 index 00000000..d8567226 --- /dev/null +++ b/Self-Control-Extension/SelfControl-Blocker/popup.css @@ -0,0 +1,52 @@ + +body { + font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; + padding: 12px; + width: 320px; +} + +h2 { margin: 0 0 8px; font-size: 16px; } +h3 { margin: 12px 0 6px; font-size: 14px; } + +.row { display: flex; gap: 8px; } + +input { + flex: 1; + padding: 8px; + border: 1px solid #ddd; + border-radius: 8px; +} + +button { + padding: 8px 12px; + border: none; + border-radius: 8px; + background: #111827; + color: white; + cursor: pointer; +} + +button:hover { opacity: 0.9; } + +ul { list-style: none; padding: 0; margin: 0; } + +li { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 0; + border-bottom: 1px solid #f0f0f0; +} + +li .domain { font-size: 13px; } + +li button.remove { + background: transparent; + color: #ef4444; + border: 1px solid #ef4444; + border-radius: 6px; + padding: 2px 8px; +} + +.hint { color: #666; font-size: 12px; } +.error { color: #b91c1c; font-size: 12px; margin-top: 6px; } diff --git a/Self-Control-Extension/SelfControl-Blocker/popup.html b/Self-Control-Extension/SelfControl-Blocker/popup.html new file mode 100644 index 00000000..328a567b --- /dev/null +++ b/Self-Control-Extension/SelfControl-Blocker/popup.html @@ -0,0 +1,24 @@ +\ + + + + + SelfControl Blocker + + + + + +

Blocks the domain and all its subdomains.

+ +

Blocked Domains

+
    + + + + diff --git a/Self-Control-Extension/SelfControl-Blocker/popup.js b/Self-Control-Extension/SelfControl-Blocker/popup.js new file mode 100644 index 00000000..89c26a83 --- /dev/null +++ b/Self-Control-Extension/SelfControl-Blocker/popup.js @@ -0,0 +1,73 @@ + +const siteInput = document.getElementById("siteInput"); +const addBtn = document.getElementById("addBtn"); +const siteList = document.getElementById("siteList"); + +function normalizeDomain(input) { + let domain = (input || "").trim().toLowerCase(); + domain = domain.replace(/^https?:\/\//, ""); + domain = domain.replace(/^www\./, ""); + domain = domain.split("/")[0].split("?")[0].split("#")[0]; + domain = domain.replace(/[^a-z0-9\.\-]/g, ""); + return domain; +} + +function render(list) { + siteList.innerHTML = ""; + if (!list || list.length === 0) { + const li = document.createElement("li"); + li.innerHTML = 'No blocked domains yet.'; + siteList.appendChild(li); + return; + } + + list.forEach(site => { + const li = document.createElement("li"); + + const span = document.createElement("span"); + span.className = "domain"; + span.textContent = site; + + // const removeBtn = document.createElement("button"); + // removeBtn.className = "remove"; + // removeBtn.textContent = "Unblock"; + // removeBtn.onclick = () => { + // chrome.runtime.sendMessage({ action: "remove", site }, (resp) => { + // render(resp.domains || []); + // }); + // }; + + li.appendChild(span); + // li.appendChild(removeBtn); + siteList.appendChild(li); + }); +} + +function load() { + chrome.runtime.sendMessage({ action: "list" }, (resp) => { + render(resp.domains || []); + }); +} + +// addBtn.onclick = () => { +// const raw = siteInput.value; +// const site = normalizeDomain(raw); +// if (!site) { +// alert("Please enter a valid domain, e.g. example.com"); +// return; +// } +// chrome.runtime.sendMessage({ action: "add", site }, (resp) => { +// if (!resp?.success) { +// alert(resp?.error || "Failed to add."); +// return; +// } +// siteInput.value = ""; +// render(resp.domains || []); +// }); +// }; + +// siteInput.addEventListener("keydown", (e) => { +// if (e.key === "Enter") addBtn.click(); +// }); + +document.addEventListener("DOMContentLoaded", load); diff --git a/Self-Control-Extension/SelfControl/Safari Rules Extension/Base.lproj/SafariExtensionViewController.xib b/Self-Control-Extension/SelfControl/Safari Rules Extension/Base.lproj/SafariExtensionViewController.xib new file mode 100644 index 00000000..55fd3ce6 --- /dev/null +++ b/Self-Control-Extension/SelfControl/Safari Rules Extension/Base.lproj/SafariExtensionViewController.xib @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Self-Control-Extension/SelfControl/Safari Rules Extension/Info.plist b/Self-Control-Extension/SelfControl/Safari Rules Extension/Info.plist new file mode 100644 index 00000000..d43b31dc --- /dev/null +++ b/Self-Control-Extension/SelfControl/Safari Rules Extension/Info.plist @@ -0,0 +1,38 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.Safari.extension + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).SafariExtensionHandler + SFSafariContentScript + + + Script + script.js + + + SFSafariToolbarItem + + Action + Command + Identifier + Button + Image + ToolbarItemIcon.pdf + Label + Your Button + + SFSafariWebsiteAccess + + Level + All + + + NSHumanReadableDescription + This is test Safari Ext Extension. You should tell us what your extension does here. + + diff --git a/Self-Control-Extension/SelfControl/Safari Rules Extension/Resources/ToolbarItemIcon.pdf b/Self-Control-Extension/SelfControl/Safari Rules Extension/Resources/ToolbarItemIcon.pdf new file mode 100644 index 00000000..88405ddf Binary files /dev/null and b/Self-Control-Extension/SelfControl/Safari Rules Extension/Resources/ToolbarItemIcon.pdf differ diff --git a/Self-Control-Extension/SelfControl/Safari Rules Extension/Resources/blocked.html b/Self-Control-Extension/SelfControl/Safari Rules Extension/Resources/blocked.html new file mode 100644 index 00000000..9fcc266c --- /dev/null +++ b/Self-Control-Extension/SelfControl/Safari Rules Extension/Resources/blocked.html @@ -0,0 +1,33 @@ + + + + +Site Blocked + + + + +

    🚫 Site Blocked

    +

    This website has been blocked by your Safari content blocker.

    + + + + diff --git a/Self-Control-Extension/SelfControl/Safari Rules Extension/Resources/script.js b/Self-Control-Extension/SelfControl/Safari Rules Extension/Resources/script.js new file mode 100644 index 00000000..a5a91526 --- /dev/null +++ b/Self-Control-Extension/SelfControl/Safari Rules Extension/Resources/script.js @@ -0,0 +1,264 @@ +//safari.extension.dispatchMessage("load") +//document.addEventListener("DOMContentLoaded", function(event) { +// console.log("DOMContentLoaded"); +// safari.extension.dispatchMessage("[SC] 🔍] Hello World!"); +//}); + +// +//window.addEventListener("load", function () { +// console.log("Page fully loaded"); +// safari.extension.dispatchMessage("[SC] 🔍] Hello World! Load"); +//}); +//document.addEventListener("click", function (event) { +// safari.extension.dispatchMessage("PAGE_CLICKED", { +// url: window.location.href, +// x: event.clientX, +// y: event.clientY +// }); +//}); +//function notifySwift(data) { +// safari.extension.dispatchMessage("REQUEST_DETECTED", data); +//} +// +//// Example usage: +//notifySwift({ url: location.href, time: Date.now() }); +//safari.self.addEventListener("message", function(event) { +// if (event.name === "SWIFT_ACK") { +// console.log("[SC] 🔍] Swift acknowledged:", event.message); +// } +//}); +//(function() { +// +// function checkURL() { +// console.log("[SC] 🔍] Swift acknowledged:", location.href); +// +// if (location.href.match(/facebook\.com\/friends/)) { +// // Stop page loading immediately +// window.stop(); +// +// // Notify Swift +// safari.extension.dispatchMessage("BLOCK_FRIENDS_PAGE", { +// url: location.href +// }); +// } +// } +// +// // Run on load + on history navigation +// checkURL(); +// document.addEventListener("DOMContentLoaded", checkURL); +// +// // For SPA router (Facebook uses React navigation) +// const pushState = history.pushState; +// history.pushState = function() { +// pushState.apply(history, arguments); +// checkURL(); +// }; +// +//})(); +// Injected script for Safari App Extension +// Blocks navigation to facebook.com/friends by stopping load and notifying the extension. +//(function() { +// +// function isFriendsPath(href) { +// try { +// // Normalize and check path+hash+query +// const url = new URL(href, location.origin); +// // Check hostname contains facebook.com and path begins with /friends +// return /(^|\.)facebook\.com$/.test(url.hostname) && url.pathname.startsWith('/friends'); +// } catch (e) { +// return false; +// } +// } +// +// function notifySwift() { +// console.log("[SC] 🔍] notifySwift:"); +// +// try { +// // Stop network activity for this page +// if (typeof window.stop === 'function') { +// window.stop(); +// } +// +// // Dispatch message to Safari App Extension +// if (window.safari && safari.extension && safari.extension.dispatchMessage) { +// console.log("[SC] 🔍] notifySwift:if (window.safari"); +// +// safari.extension.dispatchMessage("BLOCK_FRIENDS_PAGE", { +// url: location.href, +// time: Date.now() +// }); +// } else { +// console.warn("[SC] 🔍] Safari extension messaging API unavailable"); +// } +// } catch (err) { +// console.error("[SC] 🔍] notifySwift error:", err); +// } +// } +// +// function checkURL() { +// if (isFriendsPath(location.href)) { +// notifySwift(); +// } +// } +// +// // initial check +// checkURL(); +// +// // handle regular page load +// document.addEventListener("DOMContentLoaded", checkURL); +// +// // handle SPA navigation (monkey-patch history) +// (function(history) { +// const pushState = history.pushState; +// history.pushState = function() { +// const result = pushState.apply(this, arguments); +// setTimeout(checkURL, 10); +// return result; +// }; +// const replaceState = history.replaceState; +// history.replaceState = function() { +// const result = replaceState.apply(this, arguments); +// setTimeout(checkURL, 10); +// return result; +// }; +// })(window.history); +// +// // handle popstate (back/forward) +// window.addEventListener('popstate', function() { +// setTimeout(checkURL, 10); +// }); +// +// // observe clicks on anchors that may bypass history +// document.addEventListener('click', function(e) { +// setTimeout(checkURL, 10); +// }, true); +// +// // for good measure, observe mutations — some SPAs do their own routing +// const observer = new MutationObserver(function() { +// checkURL(); +// }); +// observer.observe(document.documentElement || document.body, { childList: true, subtree: true }); +// +//})(); + +//(function () { +// +// function notifySwift() { +// try { +// if (typeof window.stop === "function") { +// window.stop(); +// } +// +// if ( +// window.safari && +// safari.extension && +// typeof safari.extension.dispatchMessage === "function" +// ) { +// safari.extension.dispatchMessage("PAGE_VISIT", { +// url: location.href, +// time: Date.now(), +// }); +// } +// } catch (err) { +// console.error("[SC] notifySwift error:", err); +// } +// } +// +// function onURLChange() { +// console.log("[SC] 🔍 onURLChange:", location.href); +// notifySwift(); +// } +// +// // Initial load +// onURLChange(); +// +// // Observe SPA navigation (pushState, replaceState) +// (function (history) { +// ["pushState", "replaceState"].forEach(function (method) { +// const original = history[method]; +// history[method] = function () { +// const result = original.apply(this, arguments); +// setTimeout(onURLChange, 10); +// return result; +// }; +// }); +// })(window.history); +// +// window.addEventListener("popstate", () => setTimeout(onURLChange, 10)); +// document.addEventListener("click", () => setTimeout(onURLChange, 10), true); +// +// // ✅ Wait for DOM before attaching MutationObserver +// function setupObserver() { +// const target = document.body; +// if (!target) { +// // If body not yet available, retry shortly +// console.warn("[SC] document.body not ready, retrying..."); +// return setTimeout(setupObserver, 50); +// } +// +// try { +// const observer = new MutationObserver(() => onURLChange()); +// observer.observe(target, { childList: true, subtree: true }); +// console.log("[SC] ✅ MutationObserver attached"); +// } catch (err) { +// console.error("[SC] MutationObserver error:", err); +// } +// } +// +// if (document.readyState === "loading") { +// document.addEventListener("DOMContentLoaded", setupObserver); +// } else { +// setupObserver(); +// } +// +//})(); + +//if (safari.self && safari.self.addEventListener) { +// safari.self.addEventListener("message", function(event) { +// if (event.name === "REDIRECT_BLOCKED_URL" && event.message && event.message.redirect) { +// console.log("[SC] 🔍] REDIRECT_BLOCKED_URL:"); +// +// location.replace(event.message.redirect); +// } +// }); +//} + +(function () { + + // Listen for messages from the Swift extension + safari.self.addEventListener("message", (event) => { + if (event.name === "REDIRECT_BLOCKED_URL") { + const redirectURL = event.message.redirect; + console.log("[SC] 🚫 Redirecting to:", redirectURL); + + try { + window.stop(); // stop current load +// location.href = redirectURL; // redirect + const blockedPage = safari.extension.baseURI + "blocked.html"; + window.location.href = blockedPage; + } catch (err) { + console.error("[SC] redirect error:", err); + } + } + }); + + // Send PAGE_VISIT message + function notifySwift() { + try { + if (window.safari && safari.extension && safari.extension.dispatchMessage) { + safari.extension.dispatchMessage("PAGE_VISIT", { + url: location.href, + time: Date.now(), + }); + } + } catch (err) { + console.error("[SC] notifySwift error:", err); + } + } + + // Observe navigation changes (simplified) + window.addEventListener("load", notifySwift); + window.addEventListener("popstate", notifySwift); + document.addEventListener("click", () => setTimeout(notifySwift, 10), true); + +})(); diff --git a/Self-Control-Extension/SelfControl/Safari Rules Extension/SafariExtensionHandler.swift b/Self-Control-Extension/SelfControl/Safari Rules Extension/SafariExtensionHandler.swift new file mode 100644 index 00000000..a0448c3d --- /dev/null +++ b/Self-Control-Extension/SelfControl/Safari Rules Extension/SafariExtensionHandler.swift @@ -0,0 +1,124 @@ +// +// SafariExtensionHandler.swift +// test Safari Ext Extension +// +// Created by Satendra Singh on 30/10/25. +// + +import SafariServices +import os.log + +class SafariExtensionHandler: SFSafariExtensionHandler { + let blockedPatterns: [String] = [ + "facebook.com/friends", + "facebook.com/marketplace", + "instagram.com/explore", + "youtube.com/feed/subscriptions" + ] + + let redirectURL = "https://selfcontrol-blocked.local/blocked" // 🟢 Your custom page + + + override func beginRequest(with context: NSExtensionContext) { + let request = context.inputItems.first as? NSExtensionItem + + let profile: UUID? + if #available(iOS 17.0, macOS 14.0, *) { + profile = request?.userInfo?[SFExtensionProfileKey] as? UUID + } else { + profile = request?.userInfo?["profile"] as? UUID + } + + os_log(.default, "[SC] 🔍] The extension received a request for profile: %{public}@", profile?.uuidString ?? "none") + } + + override func messageReceived(withName messageName: String, from page: SFSafariPage, userInfo: [String : Any]?) { + os_log("[SC] 🔍 The extension received a message: %{public}@", messageName) + + guard messageName == "PAGE_VISIT", + let urlString = userInfo?["url"] as? String else { return } + + os_log("[SC] 🔍 Received URL: %{public}@", urlString) + + // Match against blocked URLs + if blockedPatterns.contains(where: { urlString.contains($0) }) { + os_log("[SC] 🚫 Blocking and redirecting: %{public}@", urlString) + page.dispatchMessageToScript(withName: "REDIRECT_BLOCKED_URL", userInfo: ["redirect": redirectURL]) + } + } + + func messagexReceived(withName messageName: String, from page: SFSafariPage, userInfo: [String : Any]?) { + os_log(.default, "[SC] 🔍] The extension messageReceived: %{public}@", messageName) + +// if messageName == "BLOCK_FRIENDS_PAGE" { +// +// page.getContainingTab { tab in +// tab.navigate(to: URL(string: "https://www.facebook.com/")!) +// } +// } + +// page.getPropertiesWithCompletionHandler { properties in +// os_log(.default, "[SC] 🔍] The extension received a message (%@) from a script injected into (%@) with userInfo (%@)", messageName, String(describing: properties?.url), userInfo ?? [:]) +// } +// page.getPropertiesWithCompletionHandler { properties in +// print("[SC] 🔍] ✅ Message Name:", messageName) +// print("[SC] 🔍] 🌍 Page URL:", properties?.url?.absoluteString ?? "Unknown") +// print("[SC] 🔍] 📦 Data Received:", userInfo ?? [:]) +// } +// // Example: Send response back to JS +// page.dispatchMessageToScript(withName: "SWIFT_ACK", userInfo: ["received": true]) + if messageName == "BLOCK_FRIENDS_PAGE" { + // Redirect the tab away from /friends + page.getContainingTab { tab in + // Redirect target — change as needed + if let url = URL(string: "https://www.facebook.com/") { + tab.navigate(to: url) + } + } + } + + page.getPropertiesWithCompletionHandler { properties in + NSLog("The extension received a message (\(messageName)) from a script injected into (\(String(describing: properties?.url))) with userInfo (\(userInfo ?? [:]))") + if #available(macOS 10.14, *) { + os_log(.default, "[SC] 🔍] The extension received a message: %{public}@", properties?.url?.description ?? "-") + } else { + // Fallback on earlier versions + } + if properties?.url?.description.contains("facebook.com/friends") == true { + page.getContainingTab(completionHandler: { tab in + // Fetch the active page for this tab to access its URL via page properties. + tab.navigate(to: URL(string: "https://www.corebitss.com")!) +// tab.getActivePage { activePage in +// guard let activePage = activePage else { +// NSLog("The extension received a message (\(messageName)) but the tab has no active page. userInfo (\(userInfo ?? [:]))") +// if #available(macOS 10.14, *) { +// os_log(.default, "[SC] 🔍] The extension received a message but the tab has no active page.") +// } +// return +// } +// activePage.getPropertiesWithCompletionHandler { tabPageProperties in +// let tabURLDesc = tabPageProperties?.url?.description ?? "-" +// NSLog("The extension received a message (\(messageName)) from a script injected into (\(tabURLDesc)) with userInfo (\(userInfo ?? [:]))") +// if #available(macOS 10.14, *) { +// os_log(.default, "[SC] 🔍] The extension received a message: %{public}@", tabURLDesc) +// } +// } +// } + }) + } + } + } + + override func toolbarItemClicked(in window: SFSafariWindow) { + os_log(.default, "[SC] 🔍] The extension's toolbar item was clicked") + } + + override func validateToolbarItem(in window: SFSafariWindow, validationHandler: @escaping ((Bool, String) -> Void)) { + validationHandler(true, "") + } + + override func popoverViewController() -> SFSafariExtensionViewController { + return SafariExtensionViewController.shared + } + +} diff --git a/Self-Control-Extension/SelfControl/Safari Rules Extension/SafariExtensionViewController.swift b/Self-Control-Extension/SelfControl/Safari Rules Extension/SafariExtensionViewController.swift new file mode 100644 index 00000000..64669fba --- /dev/null +++ b/Self-Control-Extension/SelfControl/Safari Rules Extension/SafariExtensionViewController.swift @@ -0,0 +1,18 @@ +// +// SafariExtensionViewController.swift +// test Safari Ext Extension +// +// Created by Satendra Singh on 30/10/25. +// + +import SafariServices + +class SafariExtensionViewController: SFSafariExtensionViewController { + + static let shared: SafariExtensionViewController = { + let shared = SafariExtensionViewController() + shared.preferredContentSize = NSSize(width:320, height:240) + return shared + }() + +} diff --git a/Self-Control-Extension/SelfControl/SelfControl Rules Extension/Base.lproj/SafariExtensionViewController.xib b/Self-Control-Extension/SelfControl/SelfControl Rules Extension/Base.lproj/SafariExtensionViewController.xib new file mode 100644 index 00000000..f9bc2547 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl Rules Extension/Base.lproj/SafariExtensionViewController.xib @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Self-Control-Extension/SelfControl/SelfControl Rules Extension/ContentBlockerExtensionRequestHandler.swift b/Self-Control-Extension/SelfControl/SelfControl Rules Extension/ContentBlockerExtensionRequestHandler.swift new file mode 100644 index 00000000..132253a8 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl Rules Extension/ContentBlockerExtensionRequestHandler.swift @@ -0,0 +1,154 @@ +// +// ContentBlockerExtensionRequestHandler.swift +// SelfControl +// +// Created by Satendra Singh on 09/10/25. +// + +import os.log +import Foundation + +public enum ContentBlockerExtensionRequestHandler { + typealias BlockerFile = SafariExtensionConstants.SafariBlockerFile + + /// Handles content blocking extension request for rules. + /// + /// This method loads the content blocker rules JSON file from the shared container + /// and attaches it to the extension context to be used by Safari. + /// + /// - Parameters: + /// - context: The extension context that initiated the request. + /// - groupIdentifier: The app group identifier used to access the shared container. + public static func handleRequest(with context: NSExtensionContext, groupIdentifier: String) { + os_log(.info, "[SC] 🔍] Safari Start loading the content blocker, %{public}@", context.inputItems.description) + + // Get the shared container URL using the provided group identifier + guard + let appGroupURL = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: groupIdentifier + ) + else { + context.cancelRequest( + withError: createError(code: 1001, message: "Failed to access App Group container.") + ) + return + } + + // Construct the path to the shared blocker list file + let sharedFileURL = appGroupURL.appendingPathComponent(BlockerFile.fullName) + + // Determine which blocker list file to use + var blockerListFileURL = sharedFileURL + if !FileManager.default.fileExists(atPath: sharedFileURL.path) { + os_log(.info, "[SC] 🔍] Safari No blocker list file found. Using the default one.") + + // Fall back to the default blocker list included in the bundle + guard + let defaultURL = Bundle.main.url(forResource: BlockerFile.name, withExtension: BlockerFile.ext) + else { + context.cancelRequest( + withError: createError( + code: 1002, + message: "[SC] 🔍] Safari Failed to find default blocker list." + ) + ) + return + } + blockerListFileURL = defaultURL + } + + // Create an attachment with the blocker list file + guard let attachment = NSItemProvider(contentsOf: blockerListFileURL) else { + context.cancelRequest( + withError: createError(code: 1003, message: "Failed to create attachment.") + ) + return + } + + // Prepare and complete the extension request with the blocker list + let item = NSExtensionItem() + item.attachments = [attachment] +// item.attributedTitle = NSAttributedString(string: "Hellow world!") +// item.attributedContentText = NSAttributedString(string: "Hello Content of the world!") + + context.completeRequest( + returningItems: [item] + ) { _ in + os_log(.info, "[SC] 🔍] Safari Finished loading the content blocker") + } + } + + /// Creates an NSError with the specified code and message. + /// + /// - Parameters: + /// - code: The error code. + /// - message: The error message. + /// - Returns: An NSError object with the specified parameters. + private static func createError(code: Int, message: String) -> NSError { + return NSError( + domain: "extension request handler", + code: code, + userInfo: [NSLocalizedDescriptionKey: message] + ) + } + + public static func handleRequestList(groupIdentifier: String) -> [BlockRule]? { + os_log(.info, "[SC] 🔍] Safari Start loading the content blocker") + + // Get the shared container URL using the provided group identifier + guard + let appGroupURL = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: groupIdentifier + ) + else { + return nil + } + // Construct the path to the shared blocker list file + let sharedFileURL = appGroupURL.appendingPathComponent(BlockerFile.fullName) + + // Determine which blocker list file to use + var blockerListFileURL = sharedFileURL + if !FileManager.default.fileExists(atPath: sharedFileURL.path) { + os_log(.info, "[SC] 🔍] Safari No blocker list file found. Using the default one.") + + // Fall back to the default blocker list included in the bundle + guard + let defaultURL = Bundle.main.url(forResource: BlockerFile.name, withExtension: BlockerFile.ext) + else { + return nil + } + blockerListFileURL = defaultURL + } + + func handleBlockList(_ jsonData: Data) -> [BlockRule]? { + do { + let decoder = JSONDecoder() + let rules = try decoder.decode([BlockRule].self, from: jsonData) + NSLog("[SC] 🔍] Received Blocked URLs: \(rules)") + return rules + } catch { + NSLog("[SC] 🔍] Failed to decode block list: \(error)") + } + return nil + } + return handleBlockList(try! Data(contentsOf: blockerListFileURL)) + } +} + + +public struct BlockRule: Codable { + struct Trigger: Codable { + let urlFilter: String + + enum CodingKeys: String, CodingKey { + case urlFilter = "url-filter" + } + } + + struct Action: Codable { + let type: String + } + + let trigger: Trigger + let action: Action +} diff --git a/Self-Control-Extension/SelfControl/SelfControl Rules Extension/Info.plist b/Self-Control-Extension/SelfControl/SelfControl Rules Extension/Info.plist new file mode 100644 index 00000000..9daffcc2 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl Rules Extension/Info.plist @@ -0,0 +1,38 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.Safari.extension + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).SafariExtensionHandler + SFSafariContentScript + + + Script + script.js + + + SFSafariToolbarItem + + Action + Command + Identifier + Button + Image + ToolbarItemIcon.pdf + Label + Your Button + + SFSafariWebsiteAccess + + Level + All + + + NSHumanReadableDescription + This is SelfControl Rules Extension. You should tell us what your extension does here. + + diff --git a/Self-Control-Extension/SelfControl/SelfControl Rules Extension/Resources/ToolbarItemIcon.pdf b/Self-Control-Extension/SelfControl/SelfControl Rules Extension/Resources/ToolbarItemIcon.pdf new file mode 100644 index 00000000..88405ddf Binary files /dev/null and b/Self-Control-Extension/SelfControl/SelfControl Rules Extension/Resources/ToolbarItemIcon.pdf differ diff --git a/Self-Control-Extension/SelfControl/SelfControl Rules Extension/Resources/blocked.html b/Self-Control-Extension/SelfControl/SelfControl Rules Extension/Resources/blocked.html new file mode 100644 index 00000000..569d4915 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl Rules Extension/Resources/blocked.html @@ -0,0 +1,33 @@ + + + + +Site Blocked + + + + +

    🚫 SelfControl Blocked

    +

    This website has been blocked by Self Control content blocker as per policy.

    + + + + diff --git a/Self-Control-Extension/SelfControl/SelfControl Rules Extension/Resources/script.js b/Self-Control-Extension/SelfControl/SelfControl Rules Extension/Resources/script.js new file mode 100644 index 00000000..30bf6b53 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl Rules Extension/Resources/script.js @@ -0,0 +1,40 @@ +// Notify native extension that the content script is ready +safari.extension.dispatchMessage("ready"); + +(function () { + // Listen for messages from the Swift extension + safari.self.addEventListener("message", (event) => { + if (event.name === "REDIRECT_BLOCKED_URL") { + try { + // Attempt to stop loading immediately + if (document.readyState !== 'complete') { + window.stop(); + } + // Redirect to a local "blocked" page + const blockedPage = safari.extension.baseURI + "blocked.html"; + window.location.replace(blockedPage); + } catch (err) { + console.error("[SC] redirect error:", err); + } + } + }); + + // Send PAGE_VISIT message + function notifySwift() { + try { + if (window.safari && safari.extension && safari.extension.dispatchMessage) { + safari.extension.dispatchMessage("PAGE_VISIT", { + url: location.href, + time: Date.now(), + }); + } + } catch (err) { + console.error("[SC] notifySwift error:", err); + } + } + + // Notify on navigation events + window.addEventListener("load", notifySwift); + window.addEventListener("popstate", notifySwift); + document.addEventListener("click", () => setTimeout(notifySwift, 10), true); +})(); diff --git a/Self-Control-Extension/SelfControl/SelfControl Rules Extension/SafariExtensionConstants.swift b/Self-Control-Extension/SelfControl/SelfControl Rules Extension/SafariExtensionConstants.swift new file mode 100644 index 00000000..d37c7844 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl Rules Extension/SafariExtensionConstants.swift @@ -0,0 +1,32 @@ +// +// SafariExtensionConstants.swift +// SelfControl Rules Extension +// +// Created by Satendra Singh on 09/12/25. +// + +import Foundation + +struct SafariExtensionConstants { + static let identifier = "com.application.SelfControl.corebits.SelfControl-Safari-Extension" + static let appGroup = "group.com.application.SelfControl.corebits" + static let serviceURL = "http://127.0.0.1:\(servicePort)/safari" + static let servicePort: UInt16 = 8532 + + struct UserDefaultsKeys { + static let isExtensionEnabled = "enabled" + static let isExtensionReady = "ready" + } + + enum MessagesName: String { + case reloadList = "reloadList" + case pageVisit = "PAGE_VISIT" + case redirect = "REDIRECT_BLOCKED_URL" + } + + struct SafariBlockerFile { + static let name: String = "blockerList" + static let ext: String = "json" + static var fullName: String { name + "." + ext } + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControl Rules Extension/SafariExtensionHandler.swift b/Self-Control-Extension/SelfControl/SelfControl Rules Extension/SafariExtensionHandler.swift new file mode 100644 index 00000000..7e403372 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl Rules Extension/SafariExtensionHandler.swift @@ -0,0 +1,225 @@ +// +// SafariExtensionHandler.swift +// SelfControl Rules Extension +// +// Created by Satendra Singh on 16/11/25. +// + +import SafariServices +import os.log + +typealias Const = SafariExtensionConstants + +class SafariExtensionHandler: SFSafariExtensionHandler { + private let ping = ServerPing.shared + private let defaults = UserDefaults(suiteName: Const.appGroup) + var blockedPatterns: Set = [ + "facebook.com/friends", + "facebook.com/marketplace", + "instagram.com/explore", + "youtube.com/feed/subscriptions" + ] + + override init() { + super.init() + os_log(.default, "[SC] 🔍] The extension Initialized") + pingHostApp() + ping.start() + } + + deinit { + let defaults = UserDefaults(suiteName: Const.appGroup) +// defaults?.set(false, forKey: "ready") + defaults?.synchronize() + } + + let redirectURL = "https://selfcontrol-blocked.local/blocked" // 🟢 Your custom page +// private let appGroup = "group.com.application.SelfControl.corebits" + private var isEanbled: Bool = false + + override func beginRequest(with context: NSExtensionContext) { + let request = context.inputItems.first as? NSExtensionItem +// refreshBlockedURLs() + blockedPatterns = ping.blockedURL + let profile: UUID? + if #available(iOS 17.0, macOS 14.0, *) { + profile = request?.userInfo?[SFExtensionProfileKey] as? UUID + } else { + profile = request?.userInfo?["profile"] as? UUID + } + os_log(.default, "[SC] 🔍] The extension received a request for profile: %{public}@", profile?.uuidString ?? "none") + pingHostApp() + updateExtensionState() + } + + override func messageReceived(withName messageName: String, from page: SFSafariPage, userInfo: [String : Any]?) { + typealias MSG = SafariExtensionConstants.MessagesName + os_log("[SC] 🔍 The extension received a message: %{public}@", messageName) + if messageName == MSG.reloadList.rawValue { +// refreshBlockedURLs() + blockedPatterns = ping.blockedURL + } +// if messageName == "ready" { +// pingHostApp() +// } + +// if messageName == "enableService" { +// self.isEanbled = true +// } +// if messageName == "disableService" { +// self.isEanbled = false +// } + +// guard isEanbled else { return } + guard messageName == MSG.pageVisit.rawValue, + let urlString = userInfo?["url"] as? String else { return } + + os_log("[SC] 🔍 Received URL: %{public}@", urlString) + + // Match against blocked URLs + if ping.blockedURL.contains(where: { urlString.contains($0) }) { + os_log("[SC] 🚫 Blocking and redirecting: %{public}@", urlString) + page.dispatchMessageToScript(withName: "REDIRECT_BLOCKED_URL", userInfo: ["redirect": redirectURL]) + } else { + os_log("[SC] 🔍 Blocked URL: %{public}@", ping.blockedURL) + } + } + + private func pingHostApp() { + defaults?.set(true, forKey: Const.UserDefaultsKeys.isExtensionReady) + defaults?.synchronize() +// updateExtensionState() + } + + private func updateExtensionState() { +// defaults?.synchronize() + isEanbled = defaults?.bool(forKey: Const.UserDefaultsKeys.isExtensionEnabled) ?? false + os_log(.default, "[SC] 🔍] extension State: %{public}d", isEanbled) + } + + + +// private func refreshBlockedURLs() { +// let res = ContentBlockerExtensionRequestHandler.handleRequestList(groupIdentifier: Const.appGroup) +// os_log(.default, "[SC] 🔍] List of blocked URLs: %{public}@", res ?? "No data") +// let domains: [String] = res?.map({ $0.trigger.urlFilter }) ?? [] +// os_log(.default, "[SC] 🔍] List of blocked URLs: %{public}@", domains) +// blockedPatterns = domains +// } + + func messagexReceived(withName messageName: String, from page: SFSafariPage, userInfo: [String : Any]?) { + os_log(.default, "[SC] 🔍] The extension messageReceived: %{public}@", messageName) + +// if messageName == "BLOCK_FRIENDS_PAGE" { +// +// page.getContainingTab { tab in +// tab.navigate(to: URL(string: "https://www.facebook.com/")!) +// } +// } + +// page.getPropertiesWithCompletionHandler { properties in +// os_log(.default, "[SC] 🔍] The extension received a message (%@) from a script injected into (%@) with userInfo (%@)", messageName, String(describing: properties?.url), userInfo ?? [:]) +// } +// page.getPropertiesWithCompletionHandler { properties in +// print("[SC] 🔍] ✅ Message Name:", messageName) +// print("[SC] 🔍] 🌍 Page URL:", properties?.url?.absoluteString ?? "Unknown") +// print("[SC] 🔍] 📦 Data Received:", userInfo ?? [:]) +// } +// // Example: Send response back to JS +// page.dispatchMessageToScript(withName: "SWIFT_ACK", userInfo: ["received": true]) + if messageName == "BLOCK_FRIENDS_PAGE" { + // Redirect the tab away from /friends + page.getContainingTab { tab in + // Redirect target — change as needed + if let url = URL(string: "https://www.facebook.com/") { + tab.navigate(to: url) + } + } + } + + page.getPropertiesWithCompletionHandler { properties in + NSLog("The extension received a message (\(messageName)) from a script injected into (\(String(describing: properties?.url))) with userInfo (\(userInfo ?? [:]))") + if #available(macOS 10.14, *) { + os_log(.default, "[SC] 🔍] The extension received a message: %{public}@", properties?.url?.description ?? "-") + } else { + // Fallback on earlier versions + } + if properties?.url?.description.contains("facebook.com/friends") == true { + page.getContainingTab(completionHandler: { tab in + // Fetch the active page for this tab to access its URL via page properties. + tab.navigate(to: URL(string: "https://www.corebitss.com")!) +// tab.getActivePage { activePage in +// guard let activePage = activePage else { +// NSLog("The extension received a message (\(messageName)) but the tab has no active page. userInfo (\(userInfo ?? [:]))") +// if #available(macOS 10.14, *) { +// os_log(.default, "[SC] 🔍] The extension received a message but the tab has no active page.") +// } +// return +// } +// activePage.getPropertiesWithCompletionHandler { tabPageProperties in +// let tabURLDesc = tabPageProperties?.url?.description ?? "-" +// NSLog("The extension received a message (\(messageName)) from a script injected into (\(tabURLDesc)) with userInfo (\(userInfo ?? [:]))") +// if #available(macOS 10.14, *) { +// os_log(.default, "[SC] 🔍] The extension received a message: %{public}@", tabURLDesc) +// } +// } +// } + }) + } + } + } + + override func toolbarItemClicked(in window: SFSafariWindow) { + os_log(.default, "[SC] 🔍] The extension's toolbar item was clicked") + } + + override func validateToolbarItem(in window: SFSafariWindow, validationHandler: @escaping ((Bool, String) -> Void)) { + os_log(.default, "[SC] 🔍] validateToolbarItem") + validationHandler(true, "") +// updateExtensionState() + pingHostApp() + } + + override func popoverViewController() -> SFSafariExtensionViewController { + return SafariExtensionViewController.shared + } + + +// func startHeartbeat() { +// Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { _ in +// self.writeHeartbeat() +// } +// } +// +// func writeHeartbeat() { +// guard let url = FileManager.default +// .containerURL(forSecurityApplicationGroupIdentifier: appGroup)? +// .appendingPathComponent("heartbeat.json") else { return } +// +// let data: [String: Any] = [ +// "timestamp": Date().timeIntervalSince1970 +// ] +// +// try? (data as NSDictionary).write(to: url) +// } + + override func additionalRequestHeaders(for url: URL) async -> [String : String]? { + os_log(.default, "[SC] 🔍] Safari additionalRequestHeaders %{public}@", url.absoluteString) + return await super.additionalRequestHeaders(for: url) + } + + override func page(_ page: SFSafariPage, willNavigateTo url: URL?) { + os_log(.default, "[SC] 🔍] Safari page(_ page: SFSafariPage, willNavigateTo: %{public}@", url?.absoluteString ?? url?.path() ?? url?.debugDescription ?? "nil") + + if let urlString = url?.absoluteString, ping.blockedURL.contains(where: { urlString.contains($0) }) { + os_log("[SC] 🚫 Blocking and redirecting: %{public}@", urlString) + + Task { + await page.containingTab().navigate(to: URL(string: redirectURL)!) + } +// page.dispatchMessageToScript(withName: "REDIRECT_BLOCKED_URL", userInfo: ["redirect": redirectURL]) + } else { + os_log("[SC] 🔍 Blocked URL: %{public}@", ping.blockedURL) + } + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControl Rules Extension/SafariExtensionViewController.swift b/Self-Control-Extension/SelfControl/SelfControl Rules Extension/SafariExtensionViewController.swift new file mode 100644 index 00000000..bf137567 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl Rules Extension/SafariExtensionViewController.swift @@ -0,0 +1,18 @@ +// +// SafariExtensionViewController.swift +// SelfControl Rules Extension +// +// Created by Satendra Singh on 16/11/25. +// + +import SafariServices + +class SafariExtensionViewController: SFSafariExtensionViewController { + + static let shared: SafariExtensionViewController = { + let shared = SafariExtensionViewController() + shared.preferredContentSize = NSSize(width:320, height:240) + return shared + }() + +} diff --git a/Self-Control-Extension/SelfControl/SelfControl Rules Extension/SelfControl Rules Extension.entitlements b/Self-Control-Extension/SelfControl/SelfControl Rules Extension/SelfControl Rules Extension.entitlements new file mode 100644 index 00000000..ab2dad3b --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl Rules Extension/SelfControl Rules Extension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.application.SelfControl.corebits + + + diff --git a/Self-Control-Extension/SelfControl/SelfControl Rules Extension/ServerResponse.swift b/Self-Control-Extension/SelfControl/SelfControl Rules Extension/ServerResponse.swift new file mode 100644 index 00000000..9abdcd65 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl Rules Extension/ServerResponse.swift @@ -0,0 +1,59 @@ +// +// ServerResponse.swift +// SelfControl +// +// Created by Satendra Singh on 07/12/25. +// + + +import SafariServices +import os.log + +final class ServerPing { + var timer: Timer? + var blockedURL: Set = [] + static let shared = ServerPing() + + private init() {} + + func start() { + ping() + timer = Timer.scheduledTimer(timeInterval: 30, target: self, selector: #selector(ping), userInfo: nil, repeats: true) + } + + @objc private func ping() { + fetchDataForExtension() + } + + + private func fetchDataForExtension() { + let url = URL(string: Const.serviceURL)! + + URLSession.shared.dataTask(with: url) { data, _, error in + if let error = error { + os_log("[SC] Fetch failed: %{public}@", error.localizedDescription) + return + } + + guard let data = data else { + os_log(("[SC] Fetch data Nil")) + // completion([]) + return + } + os_log("[SC] Fetch data successful: %{public}@", String(decoding: data, as: UTF8.self)) + do { + let decoded = try JSONDecoder().decode(ServerResponse.self, from: data) + self.blockedURL = .init(decoded.blocked) +// completion(decoded.blocked) + } catch { + os_log(("[SC] Fetch failed: \(error.localizedDescription)")) +// completion([]) + } + + }.resume() + } +} + +struct ServerResponse: Codable { + let blocked: [String] +} diff --git a/Self-Control-Extension/SelfControl/SelfControl-Development-Setup.md b/Self-Control-Extension/SelfControl/SelfControl-Development-Setup.md new file mode 100644 index 00000000..4d3e9888 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl-Development-Setup.md @@ -0,0 +1,112 @@ +# SelfControl Development Setup Guide + +This document outlines the exact setup needed for developing the SelfControl app that uses NetworkExtension for filtering network traffic. + +## Prerequisites + +- Xcode 16.0 or later +- macOS (15.0) or later +- Apple Developer account with Network Extension entitlements +- Administrator privileges on your Mac + +## Development Setup Steps + +### 1. Disable System Integrity Protection (SIP) + +For NetworkExtension development, SIP needs to be disabled to allow proper installation and uninstallation of system extensions during development: + +1. Boot into Recovery Mode (restart holding Cmd+R) +2. Open Terminal from Utilities menu +3. Run: `csrutil disable` +4. Restart your Mac + +> ⚠️ **IMPORTANT**: Disabling SIP reduces your system's security. Only do this on a development machine, not on your primary production machine. + +### 2. Configure Post-Build Actions + +To ensure fresh installs of the system extension during development, add two critical post-build actions to your Xcode project: + +1. Open your project in Xcode +2. Select your main app target +3. Go to "Build Phases" +4. Add a new "Run Script" phase (click the + button) +5. Add the following script: + +```bash +# Uninstall the previous system extension first +systemextensionsctl uninstall A4L93BSQEG com.application.SelfControl.SelfControlExtension + +# Copy the app to /Applications +ditto "${BUILT_PRODUCTS_DIR}/${FULL_PRODUCT_NAME}" "/Applications/${FULL_PRODUCT_NAME}" +``` + +> Note: Replace `A4L93BSQEG` with your actual Team ID and `com.application.SelfControl.SelfControlExtension` with your actual extension bundle ID if different. + +### 3. Bundle Identifier Setup + +Ensure your bundle identifiers are set up correctly: + +- Main App: `com.application.SelfControl` +- Extension: `com.application.SelfControl.SelfControlExtension` + +### 4. Configure Info.plist for Network Extension + +In your Network Extension's Info.plist, ensure proper configuration: + +```xml +NetworkExtension + + NEMachServiceName + $(TeamIdentifierPrefix)com.application.SelfControl.SelfControlExtension + NEProviderClasses + + com.apple.networkextension.filter-data + $(PRODUCT_MODULE_NAME).FilterDataProvider + + +``` + +### 5. Development Workflow + +With this setup, your development workflow becomes: + +1. Make code changes in Xcode +2. Build the project (⌘B) + - This will automatically uninstall the previous extension + - Then copy the new build to /Applications +3. Launch the app from /Applications (not from Xcode) +4. Check Console.app for logs with the prefix `[EADBUG]` + +### 6. Troubleshooting + +If you encounter issues: + +- Verify the app is properly copied to `/Applications` +- Check that the system extension uninstall command is working correctly +- Inspect Console.app for any error messages +- Verify your Team ID is correct in the post-build script +- Ensure bundle identifiers match between the app, extension, and post-build script + +### 7. Re-enabling SIP After Development + +When you're done with development and ready to deploy: + +1. Boot into Recovery Mode +2. Run: `csrutil enable` +3. Restart your Mac + +## Known Limitations + +- Each build requires a fresh installation due to NetworkExtension constraints +- The app must run from /Applications to function properly +- With SIP disabled, your development machine has reduced security protections +- NetworkExtension development generally requires more manual steps than typical app development + +## Conclusion + +This setup automates the most tedious parts of NetworkExtension development by: +1. Ensuring old extensions are uninstalled before new ones are installed +2. Automatically placing the app in the required location (/Applications) +3. Allowing for rapid iteration despite NetworkExtension's constraints + +By following these specific steps, you should be able to develop your NetworkExtension-based app more efficiently. \ No newline at end of file diff --git a/Self-Control-Extension/SelfControl/SelfControl.xcodeproj/EventScheduler/EventScheduler.swift b/Self-Control-Extension/SelfControl/SelfControl.xcodeproj/EventScheduler/EventScheduler.swift new file mode 100644 index 00000000..debe22ad --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl.xcodeproj/EventScheduler/EventScheduler.swift @@ -0,0 +1,274 @@ +// +// EventScheduler.swift +// +// A simple, concurrency-safe scheduler for recurring events by weekday and time. +// It validates time ranges and prevents overlapping events on the same day. +// + +import Foundation + +public enum SchedulerError: Error, LocalizedError, Equatable { + case invalidTimeRange + case conflict(conflictingEvents: [Event]) + case eventNotFound + + public var errorDescription: String? { + switch self { + case .invalidTimeRange: + return "The start time must be earlier than the end time." + case .conflict(let conflicts): + let names = conflicts.map { $0.title }.joined(separator: ", ") + return conflicts.isEmpty + ? "The event conflicts with an existing event." + : "The event conflicts with the following events: \(names)" + case .eventNotFound: + return "The event could not be found." + } + } +} + +/// Weekday aligned with the user's Calendar (1 = Sunday in many locales). +/// We normalize to a fixed enumeration order (Monday...Sunday) for consistency. +public enum Weekday: Int, CaseIterable, Codable, Hashable, Sendable { + case monday = 2 + case tuesday = 3 + case wednesday = 4 + case thursday = 5 + case friday = 6 + case saturday = 7 + case sunday = 1 + + public var displayName: String { + switch self { + case .monday: return "Monday" + case .tuesday: return "Tuesday" + case .wednesday: return "Wednesday" + case .thursday: return "Thursday" + case .friday: return "Friday" + case .saturday: return "Saturday" + case .sunday: return "Sunday" + } + } + + /// Returns the Weekday for a given Date using the provided Calendar. + public static func from(date: Date, calendar: Calendar = .current) -> Weekday { + let weekdayNumber = calendar.component(.weekday, from: date) + return Weekday(rawValue: weekdayNumber) ?? .monday + } +} + +/// Represents a wall-clock time without a specific date or time zone. +/// Valid range: hour 0...23, minute 0...59 +public struct TimeOfDay: Codable, Hashable, Sendable, Comparable { + public let hour: Int + public let minute: Int + + public init(hour: Int, minute: Int) { + precondition((0...23).contains(hour), "Hour must be 0...23") + precondition((0...59).contains(minute), "Minute must be 0...59") + self.hour = hour + self.minute = minute + } + + public var minutesSinceMidnight: Int { + hour * 60 + minute + } + + public static func < (lhs: TimeOfDay, rhs: TimeOfDay) -> Bool { + lhs.minutesSinceMidnight < rhs.minutesSinceMidnight + } + + public func formatted(locale: Locale = .current) -> String { + var comps = DateComponents() + comps.hour = hour + comps.minute = minute + let cal = Calendar(identifier: .gregorian) + let date = cal.date(from: comps) ?? Date() + let formatter = DateFormatter() + formatter.locale = locale + formatter.timeStyle = .short + formatter.dateStyle = .none + return formatter.string(from: date) + } +} + +/// A recurring event that happens on one or more weekdays at a given time range. +public struct Event: Identifiable, Codable, Hashable, Sendable { + public let id: UUID + public var title: String + public var days: Set + public var startTime: TimeOfDay + public var endTime: TimeOfDay + public var userInfo: [String: String]? // Optional metadata + + public init( + id: UUID = UUID(), + title: String, + days: Set, + startTime: TimeOfDay, + endTime: TimeOfDay, + userInfo: [String: String]? = nil + ) throws { + guard startTime < endTime else { + throw SchedulerError.invalidTimeRange + } + self.id = id + self.title = title + self.days = days + self.startTime = startTime + self.endTime = endTime + self.userInfo = userInfo + } + + /// Returns true if this event occurs on the given weekday. + public func occurs(on day: Weekday) -> Bool { + days.contains(day) + } + + /// Overlap check with another event on a specific weekday. + /// Adjacent ranges (end == start) are not considered overlapping. + public func overlaps(with other: Event, on day: Weekday) -> Bool { + guard self.occurs(on: day), other.occurs(on: day) else { return false } + let aStart = self.startTime.minutesSinceMidnight + let aEnd = self.endTime.minutesSinceMidnight + let bStart = other.startTime.minutesSinceMidnight + let bEnd = other.endTime.minutesSinceMidnight + return aStart < bEnd && aEnd > bStart + } +} + +/// A concurrency-safe scheduler to add, update, and query recurring events by weekday and time. +public actor EventScheduler { + public init(calendar: Calendar = .current) { + self.calendar = calendar + } + + private let calendar: Calendar + private var eventsByID: [UUID: Event] = [:] + + // MARK: - CRUD + + /// Schedules a new event. Throws if the time range is invalid or conflicts with existing events. + @discardableResult + public func schedule( + title: String, + days: Set, + startTime: TimeOfDay, + endTime: TimeOfDay, + userInfo: [String: String]? = nil + ) throws -> Event { + let newEvent = try Event(title: title, days: days, startTime: startTime, endTime: endTime, userInfo: userInfo) + try assertNoConflicts(with: newEvent, excludingID: nil) + eventsByID[newEvent.id] = newEvent + return newEvent + } + + /// Updates an existing event. Throws if not found or if the updated event conflicts. + public func update( + id: UUID, + title: String? = nil, + days: Set? = nil, + startTime: TimeOfDay? = nil, + endTime: TimeOfDay? = nil, + userInfo: [String: String]? = nil + ) throws -> Event { + guard var existing = eventsByID[id] else { + throw SchedulerError.eventNotFound + } + + let newTitle = title ?? existing.title + let newDays = days ?? existing.days + let newStart = startTime ?? existing.startTime + let newEnd = endTime ?? existing.endTime + let newUserInfo = userInfo ?? existing.userInfo + + let updated = try Event(id: id, title: newTitle, days: newDays, startTime: newStart, endTime: newEnd, userInfo: newUserInfo) + try assertNoConflicts(with: updated, excludingID: id) + eventsByID[id] = updated + return updated + } + + /// Removes an event by ID. Returns the removed event or throws if not found. + @discardableResult + public func remove(id: UUID) throws -> Event { + guard let removed = eventsByID.removeValue(forKey: id) else { + throw SchedulerError.eventNotFound + } + return removed + } + + /// Returns all scheduled events sorted by title. + public func allEvents() -> [Event] { + eventsByID.values.sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending } + } + + /// Returns events that occur on the specified weekday, sorted by start time. + public func events(on day: Weekday) -> [Event] { + eventsByID.values + .filter { $0.occurs(on: day) } + .sorted { lhs, rhs in + if lhs.startTime == rhs.startTime { + return lhs.title.localizedCaseInsensitiveCompare(rhs.title) == .orderedAscending + } + return lhs.startTime < rhs.startTime + } + } + + // MARK: - Conflict Checking + + /// Returns any events that would conflict with the provided event. + public func conflicts(for event: Event, excludingID: UUID? = nil) -> [Event] { + eventsByID.values + .filter { existing in + if let exclude = excludingID, existing.id == exclude { return false } + // Only check days that overlap + let sharedDays = existing.days.intersection(event.days) + guard !sharedDays.isEmpty else { return false } + return sharedDays.contains { event.overlaps(with: existing, on: $0) } + } + .sorted { $0.startTime < $1.startTime } + } + + private func assertNoConflicts(with event: Event, excludingID: UUID?) throws { + let conflicts = conflicts(for: event, excludingID: excludingID) + if !conflicts.isEmpty { + throw SchedulerError.conflict(conflictingEvents: conflicts) + } + } + + // MARK: - Query Helpers + + /// Returns the next DateInterval this event will occur after the given date, if any. + /// This uses the scheduler's calendar to resolve the next occurrence in the current week or later. + public func nextOccurrence(of eventID: UUID, after date: Date = Date()) -> DateInterval? { + guard let event = eventsByID[eventID] else { return nil } + return nextOccurrence(of: event, after: date) + } + + /// Returns the next DateInterval an event will occur after the given date, if any. + public func nextOccurrence(of event: Event, after date: Date = Date()) -> DateInterval? { + // Search up to 8 weeks ahead to be safe for unusual calendars + for dayOffset in 0..<(7 * 8) { + guard let candidateDay = calendar.date(byAdding: .day, value: dayOffset, to: date) else { continue } + let weekday = Weekday.from(date: candidateDay, calendar: calendar) + guard event.occurs(on: weekday) else { continue } + + var comps = calendar.dateComponents([.year, .month, .day], from: candidateDay) + comps.hour = event.startTime.hour + comps.minute = event.startTime.minute + comps.second = 0 + let start = calendar.date(from: comps) + + comps.hour = event.endTime.hour + comps.minute = event.endTime.minute + let end = calendar.date(from: comps) + + if let start, let end, end > date { + // If the start is in the past but end is in the future, return the remaining portion. + let actualStart = max(start, date) + return DateInterval(start: actualStart, end: end) + } + } + return nil + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControl.xcodeproj/project.pbxproj b/Self-Control-Extension/SelfControl/SelfControl.xcodeproj/project.pbxproj new file mode 100644 index 00000000..bd8a0824 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl.xcodeproj/project.pbxproj @@ -0,0 +1,1142 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 77BA53092D9D5E2A00863476 /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 77BA53082D9D5E2A00863476 /* NetworkExtension.framework */; }; + 9A441CE32E36024E00A521CC /* com.application.SelfControl.corebits.network.systemextension in Embed System Extensions */ = {isa = PBXBuildFile; fileRef = 77BA53062D9D5E2A00863476 /* com.application.SelfControl.corebits.network.systemextension */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 9AA492AE2F56E94200452252 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 9AA492AD2F56E94200452252 /* Sparkle */; }; + 9AC9E59E2F04357E0004D39C /* org.eyebeam.selfcontrold in Copy Daemon Launch Service */ = {isa = PBXBuildFile; fileRef = 9AC9E4EA2F041D570004D39C /* org.eyebeam.selfcontrold */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + 9AD524CE2EC9F15500AAD5BF /* SelfControlWebExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 9AD524B12EC9B0EA00AAD5BF /* SelfControlWebExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 9AEB2C6E2FB249C00024E93A /* SelfControlBGService in CopyFiles */ = {isa = PBXBuildFile; fileRef = 9A33B1E02FAF2127002AAA0F /* SelfControlBGService */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 9A441CE12E36024300A521CC /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 77BA52CE2D9D5D7700863476 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 77BA53052D9D5E2A00863476; + remoteInfo = SelfControlExtension; + }; + 9AC9E59A2F0434F00004D39C /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 77BA52CE2D9D5D7700863476 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 9AC9E4E92F041D570004D39C; + remoteInfo = org.eyebeam.selfcontrold; + }; + 9AD524CF2EC9F15500AAD5BF /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 77BA52CE2D9D5D7700863476 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 9AD524B02EC9B0EA00AAD5BF; + remoteInfo = "SelfControl Rules Extension"; + }; + 9AEB2C6F2FB249CC0024E93A /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 77BA52CE2D9D5D7700863476 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 9A33B1DF2FAF2127002AAA0F; + remoteInfo = SelfControlBGService; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9A33B1DE2FAF2127002AAA0F /* CopyFiles */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = /usr/share/man/man1/; + dstSubfolderSpec = 0; + files = ( + ); + runOnlyForDeploymentPostprocessing = 1; + }; + 9A33B1E72FAF2153002AAA0F /* CopyFiles */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = Contents/Library/LaunchAgents; + dstSubfolderSpec = 1; + files = ( + 9AEB2C6E2FB249C00024E93A /* SelfControlBGService in CopyFiles */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 9AC9E4E82F041D570004D39C /* CopyFiles */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = /usr/share/man/man1/; + dstSubfolderSpec = 0; + files = ( + ); + runOnlyForDeploymentPostprocessing = 1; + }; + 9AC9E59D2F0435560004D39C /* Copy Daemon Launch Service */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = Contents/Library/LaunchServices; + dstSubfolderSpec = 1; + files = ( + 9AC9E59E2F04357E0004D39C /* org.eyebeam.selfcontrold in Copy Daemon Launch Service */, + ); + name = "Copy Daemon Launch Service"; + runOnlyForDeploymentPostprocessing = 0; + }; + 9AD524D12EC9F15500AAD5BF /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 9AD524CE2EC9F15500AAD5BF /* SelfControlWebExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; + 9AFC129E2E355671003B5455 /* Embed System Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(SYSTEM_EXTENSIONS_FOLDER_PATH)"; + dstSubfolderSpec = 16; + files = ( + 9A441CE32E36024E00A521CC /* com.application.SelfControl.corebits.network.systemextension in Embed System Extensions */, + ); + name = "Embed System Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 77BA52D62D9D5D7700863476 /* SelfControl.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SelfControl.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 77BA53062D9D5E2A00863476 /* com.application.SelfControl.corebits.network.systemextension */ = {isa = PBXFileReference; explicitFileType = "wrapper.system-extension"; includeInIndex = 0; path = com.application.SelfControl.corebits.network.systemextension; sourceTree = BUILT_PRODUCTS_DIR; }; + 77BA53082D9D5E2A00863476 /* NetworkExtension.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = NetworkExtension.framework; path = System/Library/Frameworks/NetworkExtension.framework; sourceTree = SDKROOT; }; + 9A33B1E02FAF2127002AAA0F /* SelfControlBGService */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = SelfControlBGService; sourceTree = BUILT_PRODUCTS_DIR; }; + 9A7FFA0B2E91950D007D4A0D /* Cocoa.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Cocoa.framework; path = System/Library/Frameworks/Cocoa.framework; sourceTree = SDKROOT; }; + 9AC9E4EA2F041D570004D39C /* org.eyebeam.selfcontrold */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = org.eyebeam.selfcontrold; sourceTree = BUILT_PRODUCTS_DIR; }; + 9AD524B12EC9B0EA00AAD5BF /* SelfControlWebExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SelfControlWebExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 77BA53142D9D5E2A00863476 /* Exceptions for "SelfControlNetworkExtension" folder in "SelfControlNetworkExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 77BA53052D9D5E2A00863476 /* SelfControlNetworkExtension */; + }; + 9A240F092FB042C900A06370 /* Exceptions for "SelfControlBGService" folder in "SelfControl" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + com.application.SelfControl.corebits.bgservice.plist, + Shared/AppToExtensionExtension.swift, + Shared/HelperServiceProtocol.swift, + ); + target = 77BA52D52D9D5D7700863476 /* SelfControl */; + }; + 9A33B1F52FAFA3FE002AAA0F /* Exceptions for "SelfControl" folder in "SelfControl" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Main/SelfControlBGServiceSupport/SelfControlServiceSupport.swift, + ); + target = 77BA52D52D9D5D7700863476 /* SelfControl */; + }; + 9A6873742E280E320054AD3F /* Exceptions for "SelfControl" folder in "SelfControlNetworkExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Main/Preferences/AppPreferences.swift, + ); + target = 77BA53052D9D5E2A00863476 /* SelfControlNetworkExtension */; + }; + 9A6873762E2814B30054AD3F /* Exceptions for "SelfControlNetworkExtension" folder in "SelfControl" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + BlockOrAllowList.swift, + "String+URL.swift", + ); + target = 77BA52D52D9D5D7700863476 /* SelfControl */; + }; + 9A9AA3AC2EE87BA000AF720F /* Exceptions for "SelfControl Rules Extension" folder in "SelfControl" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + SafariExtensionConstants.swift, + ); + target = 77BA52D52D9D5D7700863476 /* SelfControl */; + }; + 9AC9E5972F0422140004D39C /* Exceptions for "org.eyebeam.selfcontrold" folder in "SelfControl" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Common/SCErr.m, + Common/SCFileWatcher.m, + Common/SCSettings.m, + Common/SCXPCAuthorization.m, + Common/SCXPCClient.m, + Common/Utilities/SCConstants.m, + Common/Utilities/SCMiscUtilities.m, + org.eyebeam.selfcontrold.plist, + ); + target = 77BA52D52D9D5D7700863476 /* SelfControl */; + }; + 9ACDADBC2FB9DFB400013EC8 /* Exceptions for "SelfControlBGService" folder in "SelfControlNetworkExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Shared/AppToExtensionExtension.swift, + ); + target = 77BA53052D9D5E2A00863476 /* SelfControlNetworkExtension */; + }; + 9AD524C32EC9B0EA00AAD5BF /* Exceptions for "SelfControl Rules Extension" folder in "SelfControlWebExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 9AD524B02EC9B0EA00AAD5BF /* SelfControlWebExtension */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 77BA52D82D9D5D7700863476 /* SelfControl */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 9A33B1F52FAFA3FE002AAA0F /* Exceptions for "SelfControl" folder in "SelfControl" target */, + 9A6873742E280E320054AD3F /* Exceptions for "SelfControl" folder in "SelfControlNetworkExtension" target */, + ); + path = SelfControl; + sourceTree = ""; + }; + 77BA530A2D9D5E2A00863476 /* SelfControlNetworkExtension */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 9A6873762E2814B30054AD3F /* Exceptions for "SelfControlNetworkExtension" folder in "SelfControl" target */, + 77BA53142D9D5E2A00863476 /* Exceptions for "SelfControlNetworkExtension" folder in "SelfControlNetworkExtension" target */, + ); + path = SelfControlNetworkExtension; + sourceTree = ""; + }; + 9A33B1E12FAF2127002AAA0F /* SelfControlBGService */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 9A240F092FB042C900A06370 /* Exceptions for "SelfControlBGService" folder in "SelfControl" target */, + 9ACDADBC2FB9DFB400013EC8 /* Exceptions for "SelfControlBGService" folder in "SelfControlNetworkExtension" target */, + ); + path = SelfControlBGService; + sourceTree = ""; + }; + 9AC9E4EB2F041D570004D39C /* org.eyebeam.selfcontrold */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 9AC9E5972F0422140004D39C /* Exceptions for "org.eyebeam.selfcontrold" folder in "SelfControl" target */, + ); + path = org.eyebeam.selfcontrold; + sourceTree = ""; + }; + 9AD524B52EC9B0EA00AAD5BF /* SelfControl Rules Extension */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 9A9AA3AC2EE87BA000AF720F /* Exceptions for "SelfControl Rules Extension" folder in "SelfControl" target */, + 9AD524C32EC9B0EA00AAD5BF /* Exceptions for "SelfControl Rules Extension" folder in "SelfControlWebExtension" target */, + ); + path = "SelfControl Rules Extension"; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 77BA52D32D9D5D7700863476 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 9AA492AE2F56E94200452252 /* Sparkle in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 77BA53032D9D5E2A00863476 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 77BA53092D9D5E2A00863476 /* NetworkExtension.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 9A33B1DD2FAF2127002AAA0F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 9AC9E4E72F041D570004D39C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 9AD524AE2EC9B0EA00AAD5BF /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 77BA52CD2D9D5D7700863476 = { + isa = PBXGroup; + children = ( + 77BA52D82D9D5D7700863476 /* SelfControl */, + 77BA530A2D9D5E2A00863476 /* SelfControlNetworkExtension */, + 9AD524B52EC9B0EA00AAD5BF /* SelfControl Rules Extension */, + 9AC9E4EB2F041D570004D39C /* org.eyebeam.selfcontrold */, + 9A33B1E12FAF2127002AAA0F /* SelfControlBGService */, + 77BA53072D9D5E2A00863476 /* Frameworks */, + 77BA52D72D9D5D7700863476 /* Products */, + ); + sourceTree = ""; + }; + 77BA52D72D9D5D7700863476 /* Products */ = { + isa = PBXGroup; + children = ( + 77BA52D62D9D5D7700863476 /* SelfControl.app */, + 77BA53062D9D5E2A00863476 /* com.application.SelfControl.corebits.network.systemextension */, + 9AD524B12EC9B0EA00AAD5BF /* SelfControlWebExtension.appex */, + 9AC9E4EA2F041D570004D39C /* org.eyebeam.selfcontrold */, + 9A33B1E02FAF2127002AAA0F /* SelfControlBGService */, + ); + name = Products; + sourceTree = ""; + }; + 77BA53072D9D5E2A00863476 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 77BA53082D9D5E2A00863476 /* NetworkExtension.framework */, + 9A7FFA0B2E91950D007D4A0D /* Cocoa.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 77BA52D52D9D5D7700863476 /* SelfControl */ = { + isa = PBXNativeTarget; + buildConfigurationList = 77BA52E52D9D5D7A00863476 /* Build configuration list for PBXNativeTarget "SelfControl" */; + buildPhases = ( + 77BA52D22D9D5D7700863476 /* Sources */, + 77BA52D32D9D5D7700863476 /* Frameworks */, + 77BA52D42D9D5D7700863476 /* Resources */, + 9AFC129E2E355671003B5455 /* Embed System Extensions */, + 9AD524D12EC9F15500AAD5BF /* Embed Foundation Extensions */, + 9AC9E59D2F0435560004D39C /* Copy Daemon Launch Service */, + 9A33B1E72FAF2153002AAA0F /* CopyFiles */, + ); + buildRules = ( + ); + dependencies = ( + 9AEB2C702FB249CC0024E93A /* PBXTargetDependency */, + 9AC9E59B2F0434F00004D39C /* PBXTargetDependency */, + 9A441CE22E36024300A521CC /* PBXTargetDependency */, + 9AD524D02EC9F15500AAD5BF /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 77BA52D82D9D5D7700863476 /* SelfControl */, + ); + name = SelfControl; + packageProductDependencies = ( + 9AA492AD2F56E94200452252 /* Sparkle */, + ); + productName = SelfControl; + productReference = 77BA52D62D9D5D7700863476 /* SelfControl.app */; + productType = "com.apple.product-type.application"; + }; + 77BA53052D9D5E2A00863476 /* SelfControlNetworkExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 77BA53152D9D5E2A00863476 /* Build configuration list for PBXNativeTarget "SelfControlNetworkExtension" */; + buildPhases = ( + 77BA53022D9D5E2A00863476 /* Sources */, + 77BA53032D9D5E2A00863476 /* Frameworks */, + 77BA53042D9D5E2A00863476 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 77BA530A2D9D5E2A00863476 /* SelfControlNetworkExtension */, + ); + name = SelfControlNetworkExtension; + packageProductDependencies = ( + ); + productName = SelfControlExtension; + productReference = 77BA53062D9D5E2A00863476 /* com.application.SelfControl.corebits.network.systemextension */; + productType = "com.apple.product-type.system-extension"; + }; + 9A33B1DF2FAF2127002AAA0F /* SelfControlBGService */ = { + isa = PBXNativeTarget; + buildConfigurationList = 9A33B1E62FAF2127002AAA0F /* Build configuration list for PBXNativeTarget "SelfControlBGService" */; + buildPhases = ( + 9A33B1DC2FAF2127002AAA0F /* Sources */, + 9A33B1DD2FAF2127002AAA0F /* Frameworks */, + 9A33B1DE2FAF2127002AAA0F /* CopyFiles */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 9A33B1E12FAF2127002AAA0F /* SelfControlBGService */, + ); + name = SelfControlBGService; + packageProductDependencies = ( + ); + productName = SelfControlBGService; + productReference = 9A33B1E02FAF2127002AAA0F /* SelfControlBGService */; + productType = "com.apple.product-type.tool"; + }; + 9AC9E4E92F041D570004D39C /* org.eyebeam.selfcontrold */ = { + isa = PBXNativeTarget; + buildConfigurationList = 9AC9E4EE2F041D570004D39C /* Build configuration list for PBXNativeTarget "org.eyebeam.selfcontrold" */; + buildPhases = ( + 9AC9E4E62F041D570004D39C /* Sources */, + 9AC9E4E72F041D570004D39C /* Frameworks */, + 9AC9E4E82F041D570004D39C /* CopyFiles */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 9AC9E4EB2F041D570004D39C /* org.eyebeam.selfcontrold */, + ); + name = org.eyebeam.selfcontrold; + packageProductDependencies = ( + ); + productName = org.eyebeam.selfcontrold; + productReference = 9AC9E4EA2F041D570004D39C /* org.eyebeam.selfcontrold */; + productType = "com.apple.product-type.tool"; + }; + 9AD524B02EC9B0EA00AAD5BF /* SelfControlWebExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 9AD524C42EC9B0EA00AAD5BF /* Build configuration list for PBXNativeTarget "SelfControlWebExtension" */; + buildPhases = ( + 9AD524AD2EC9B0EA00AAD5BF /* Sources */, + 9AD524AE2EC9B0EA00AAD5BF /* Frameworks */, + 9AD524AF2EC9B0EA00AAD5BF /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 9AD524B52EC9B0EA00AAD5BF /* SelfControl Rules Extension */, + ); + name = SelfControlWebExtension; + packageProductDependencies = ( + ); + productName = "SelfControl Rules Extension"; + productReference = 9AD524B12EC9B0EA00AAD5BF /* SelfControlWebExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 77BA52CE2D9D5D7700863476 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2600; + LastUpgradeCheck = 1620; + TargetAttributes = { + 77BA52D52D9D5D7700863476 = { + CreatedOnToolsVersion = 16.2; + }; + 77BA53052D9D5E2A00863476 = { + CreatedOnToolsVersion = 16.2; + }; + 9A33B1DF2FAF2127002AAA0F = { + CreatedOnToolsVersion = 26.0; + }; + 9AC9E4E92F041D570004D39C = { + CreatedOnToolsVersion = 26.0; + }; + 9AD524B02EC9B0EA00AAD5BF = { + CreatedOnToolsVersion = 26.0; + }; + }; + }; + buildConfigurationList = 77BA52D12D9D5D7700863476 /* Build configuration list for PBXProject "SelfControl" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 77BA52CD2D9D5D7700863476; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + 9AA492AC2F56E94200452252 /* XCRemoteSwiftPackageReference "Sparkle" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = 77BA52D72D9D5D7700863476 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 77BA52D52D9D5D7700863476 /* SelfControl */, + 77BA53052D9D5E2A00863476 /* SelfControlNetworkExtension */, + 9AD524B02EC9B0EA00AAD5BF /* SelfControlWebExtension */, + 9AC9E4E92F041D570004D39C /* org.eyebeam.selfcontrold */, + 9A33B1DF2FAF2127002AAA0F /* SelfControlBGService */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 77BA52D42D9D5D7700863476 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 77BA53042D9D5E2A00863476 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 9AD524AF2EC9B0EA00AAD5BF /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 77BA52D22D9D5D7700863476 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 77BA53022D9D5E2A00863476 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 9A33B1DC2FAF2127002AAA0F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 9AC9E4E62F041D570004D39C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 9AD524AD2EC9B0EA00AAD5BF /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 9A441CE22E36024300A521CC /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 77BA53052D9D5E2A00863476 /* SelfControlNetworkExtension */; + targetProxy = 9A441CE12E36024300A521CC /* PBXContainerItemProxy */; + }; + 9AC9E59B2F0434F00004D39C /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 9AC9E4E92F041D570004D39C /* org.eyebeam.selfcontrold */; + targetProxy = 9AC9E59A2F0434F00004D39C /* PBXContainerItemProxy */; + }; + 9AD524D02EC9F15500AAD5BF /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 9AD524B02EC9B0EA00AAD5BF /* SelfControlWebExtension */; + targetProxy = 9AD524CF2EC9F15500AAD5BF /* PBXContainerItemProxy */; + }; + 9AEB2C702FB249CC0024E93A /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 9A33B1DF2FAF2127002AAA0F /* SelfControlBGService */; + targetProxy = 9AEB2C6F2FB249CC0024E93A /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 77BA52E32D9D5D7A00863476 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + 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; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + 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; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 15.1; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 77BA52E42D9D5D7A00863476 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + 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; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + 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; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 15.1; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + }; + name = Release; + }; + 77BA52E62D9D5D7A00863476 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = SelfControl/Main/SelfControl.entitlements; + CODE_SIGN_IDENTITY = "Developer ID Application"; + CODE_SIGN_STYLE = Manual; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 31711; + DEVELOPMENT_ASSET_PATHS = "\"SelfControl/Preview Content\""; + DEVELOPMENT_TEAM = X6FQ433AWK; + ENABLE_APP_SANDBOX = NO; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + GCC_PREFIX_HEADER = org.eyebeam.selfcontrold/SCDaemon_Prefix.pch; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = SelfControl/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 13.5; + MARKETING_VERSION = 1.9; + PRODUCT_BUNDLE_IDENTIFIER = com.application.SelfControl.corebits; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "Self Control app dev"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "SelfControl/Main/SCDaemonSupport/SelfControl-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 77BA52E72D9D5D7A00863476 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = SelfControl/Main/SelfControl.entitlements; + CODE_SIGN_IDENTITY = "Developer ID Application"; + CODE_SIGN_STYLE = Manual; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 31711; + DEVELOPMENT_ASSET_PATHS = "\"SelfControl/Preview Content\""; + DEVELOPMENT_TEAM = X6FQ433AWK; + ENABLE_APP_SANDBOX = NO; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + GCC_PREFIX_HEADER = org.eyebeam.selfcontrold/SCDaemon_Prefix.pch; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = SelfControl/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 13.5; + MARKETING_VERSION = 1.9; + PRODUCT_BUNDLE_IDENTIFIER = com.application.SelfControl.corebits; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "Self Control app dev"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "SelfControl/Main/SCDaemonSupport/SelfControl-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 77BA53162D9D5E2A00863476 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = SelfControlNetworkExtension/SelfControlExtension.entitlements; + CODE_SIGN_IDENTITY = "Developer ID Application"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 26210; + DEVELOPMENT_TEAM = X6FQ433AWK; + ENABLE_APP_SANDBOX = NO; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = SelfControlNetworkExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = SelfControlNetworkExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "DNS lookup"; + INFOPLIST_KEY_NSSystemExtensionUsageDescription = "testing extension"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 13.5; + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = "-lbsm"; + PRODUCT_BUNDLE_IDENTIFIER = com.application.SelfControl.corebits.network; + PRODUCT_NAME = "$(inherited)"; + PROVISIONING_PROFILE_SPECIFIER = "Self Control Dev ext"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 77BA53172D9D5E2A00863476 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = SelfControlNetworkExtension/SelfControlExtension.entitlements; + CODE_SIGN_IDENTITY = "Developer ID Application"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 26210; + DEVELOPMENT_TEAM = X6FQ433AWK; + ENABLE_APP_SANDBOX = NO; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = SelfControlNetworkExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = SelfControlNetworkExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "DNS lookup"; + INFOPLIST_KEY_NSSystemExtensionUsageDescription = "testing extension"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 13.5; + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = "-lbsm"; + PRODUCT_BUNDLE_IDENTIFIER = com.application.SelfControl.corebits.network; + PRODUCT_NAME = "$(inherited)"; + PROVISIONING_PROFILE_SPECIFIER = "Self Control Dev ext"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 9A33B1E42FAF2127002AAA0F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_IDENTITY = "Developer ID Application"; + CODE_SIGN_STYLE = Manual; + DEVELOPMENT_TEAM = X6FQ433AWK; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = SelfControlBGService/Info.plist; + MACOSX_DEPLOYMENT_TARGET = 13.5; + PRODUCT_BUNDLE_IDENTIFIER = com.application.SelfControl.corebits.bgservice; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SKIP_INSTALL = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 9A33B1E52FAF2127002AAA0F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_IDENTITY = "Developer ID Application"; + CODE_SIGN_STYLE = Manual; + DEVELOPMENT_TEAM = X6FQ433AWK; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = SelfControlBGService/Info.plist; + MACOSX_DEPLOYMENT_TARGET = 13.5; + PRODUCT_BUNDLE_IDENTIFIER = com.application.SelfControl.corebits.bgservice; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SKIP_INSTALL = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 9AC9E4EF2F041D570004D39C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + AUTOMATION_APPLE_EVENTS = NO; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 410; + DEVELOPMENT_TEAM = X6FQ433AWK; + ENABLE_HARDENED_RUNTIME = NO; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = NO; + ENABLE_RESOURCE_ACCESS_CAMERA = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = NO; + ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO; + GCC_PREFIX_HEADER = org.eyebeam.selfcontrold/SCDaemon_Prefix.pch; + INFOPLIST_FILE = "org.eyebeam.selfcontrold/selfcontrold-Info.plist"; + MACOSX_DEPLOYMENT_TARGET = 13.5; + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "-sectcreate", + __TEXT, + __info_plist, + "org.eyebeam.selfcontrold/selfcontrold-Info.plist", + "-sectcreate", + __TEXT, + __launchd_plist, + org.eyebeam.selfcontrold/org.eyebeam.selfcontrold.plist, + "$(inherited)", + ); + PRODUCT_BUNDLE_IDENTIFIER = org.eyebeam.selfcontrold; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO; + RUNTIME_EXCEPTION_ALLOW_JIT = NO; + RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = NO; + RUNTIME_EXCEPTION_DEBUGGING_TOOL = NO; + RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION = NO; + RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = NO; + SKIP_INSTALL = YES; + }; + name = Debug; + }; + 9AC9E4F02F041D570004D39C /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + AUTOMATION_APPLE_EVENTS = NO; + CODE_SIGN_IDENTITY = "Developer ID Application: CoreBits Software Solutions Private Limited (X6FQ433AWK)"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 410; + DEVELOPMENT_TEAM = X6FQ433AWK; + ENABLE_HARDENED_RUNTIME = NO; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = NO; + ENABLE_RESOURCE_ACCESS_CAMERA = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = NO; + ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO; + GCC_PREFIX_HEADER = org.eyebeam.selfcontrold/SCDaemon_Prefix.pch; + INFOPLIST_FILE = "org.eyebeam.selfcontrold/selfcontrold-Info.plist"; + MACOSX_DEPLOYMENT_TARGET = 13.5; + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "-sectcreate", + __TEXT, + __info_plist, + "org.eyebeam.selfcontrold/selfcontrold-Info.plist", + "-sectcreate", + __TEXT, + __launchd_plist, + org.eyebeam.selfcontrold/org.eyebeam.selfcontrold.plist, + "$(inherited)", + ); + PRODUCT_BUNDLE_IDENTIFIER = org.eyebeam.selfcontrold; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO; + RUNTIME_EXCEPTION_ALLOW_JIT = NO; + RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = NO; + RUNTIME_EXCEPTION_DEBUGGING_TOOL = NO; + RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION = NO; + RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = NO; + SKIP_INSTALL = YES; + }; + name = Release; + }; + 9AD524C52EC9B0EA00AAD5BF /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = "SelfControl Rules Extension/SelfControl Rules Extension.entitlements"; + CODE_SIGN_IDENTITY = "Developer ID Application"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 26210; + DEVELOPMENT_TEAM = X6FQ433AWK; + ENABLE_APP_SANDBOX = YES; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_INCOMING_NETWORK_CONNECTIONS = NO; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = NO; + ENABLE_RESOURCE_ACCESS_CAMERA = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = NO; + ENABLE_RESOURCE_ACCESS_PRINTING = NO; + ENABLE_RESOURCE_ACCESS_USB = NO; + ENABLE_USER_SELECTED_FILES = readonly; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "SelfControl Rules Extension/Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = SelfControlWebExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 13.5; + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "-framework", + SafariServices, + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.application.SelfControl.corebits.SelfControl-Safari-Extension"; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "SelfControl Dev Net Ext"; + REGISTER_APP_GROUPS = YES; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 9AD524C62EC9B0EA00AAD5BF /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = "SelfControl Rules Extension/SelfControl Rules Extension.entitlements"; + CODE_SIGN_IDENTITY = "Developer ID Application"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 26210; + DEVELOPMENT_TEAM = X6FQ433AWK; + "DEVELOPMENT_TEAM[sdk=macosx*]" = X6FQ433AWK; + ENABLE_APP_SANDBOX = YES; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_INCOMING_NETWORK_CONNECTIONS = NO; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = NO; + ENABLE_RESOURCE_ACCESS_CAMERA = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = NO; + ENABLE_RESOURCE_ACCESS_PRINTING = NO; + ENABLE_RESOURCE_ACCESS_USB = NO; + ENABLE_USER_SELECTED_FILES = readonly; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "SelfControl Rules Extension/Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = SelfControlWebExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 13.5; + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "-framework", + SafariServices, + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.application.SelfControl.corebits.SelfControl-Safari-Extension"; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "SelfControl Dev Net Ext"; + "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = "SelfControl Dev Net Ext"; + REGISTER_APP_GROUPS = YES; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 77BA52D12D9D5D7700863476 /* Build configuration list for PBXProject "SelfControl" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 77BA52E32D9D5D7A00863476 /* Debug */, + 77BA52E42D9D5D7A00863476 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 77BA52E52D9D5D7A00863476 /* Build configuration list for PBXNativeTarget "SelfControl" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 77BA52E62D9D5D7A00863476 /* Debug */, + 77BA52E72D9D5D7A00863476 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 77BA53152D9D5E2A00863476 /* Build configuration list for PBXNativeTarget "SelfControlNetworkExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 77BA53162D9D5E2A00863476 /* Debug */, + 77BA53172D9D5E2A00863476 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 9A33B1E62FAF2127002AAA0F /* Build configuration list for PBXNativeTarget "SelfControlBGService" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 9A33B1E42FAF2127002AAA0F /* Debug */, + 9A33B1E52FAF2127002AAA0F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 9AC9E4EE2F041D570004D39C /* Build configuration list for PBXNativeTarget "org.eyebeam.selfcontrold" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 9AC9E4EF2F041D570004D39C /* Debug */, + 9AC9E4F02F041D570004D39C /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 9AD524C42EC9B0EA00AAD5BF /* Build configuration list for PBXNativeTarget "SelfControlWebExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 9AD524C52EC9B0EA00AAD5BF /* Debug */, + 9AD524C62EC9B0EA00AAD5BF /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 9AA492AC2F56E94200452252 /* XCRemoteSwiftPackageReference "Sparkle" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/sparkle-project/Sparkle"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.9.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 9AA492AD2F56E94200452252 /* Sparkle */ = { + isa = XCSwiftPackageProductDependency; + package = 9AA492AC2F56E94200452252 /* XCRemoteSwiftPackageReference "Sparkle" */; + productName = Sparkle; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 77BA52CE2D9D5D7700863476 /* Project object */; +} diff --git a/Self-Control-Extension/SelfControl/SelfControl.xcodeproj/xcshareddata/xcschemes/SelfControl.xcscheme b/Self-Control-Extension/SelfControl/SelfControl.xcodeproj/xcshareddata/xcschemes/SelfControl.xcscheme new file mode 100644 index 00000000..754925a4 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl.xcodeproj/xcshareddata/xcschemes/SelfControl.xcscheme @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Self-Control-Extension/SelfControl/SelfControl.xcodeproj/xcshareddata/xcschemes/SelfControlBGLaunchService.xcscheme b/Self-Control-Extension/SelfControl/SelfControl.xcodeproj/xcshareddata/xcschemes/SelfControlBGLaunchService.xcscheme new file mode 100644 index 00000000..1024547b --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl.xcodeproj/xcshareddata/xcschemes/SelfControlBGLaunchService.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Self-Control-Extension/SelfControl/SelfControl.xcodeproj/xcshareddata/xcschemes/SelfControlExtension.xcscheme b/Self-Control-Extension/SelfControl/SelfControl.xcodeproj/xcshareddata/xcschemes/SelfControlExtension.xcscheme new file mode 100644 index 00000000..60e9eb61 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl.xcodeproj/xcshareddata/xcschemes/SelfControlExtension.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Self-Control-Extension/SelfControl/SelfControl.xcodeproj/xcshareddata/xcschemes/org.eyebeam.selfcontrold.xcscheme b/Self-Control-Extension/SelfControl/SelfControl.xcodeproj/xcshareddata/xcschemes/org.eyebeam.selfcontrold.xcscheme new file mode 100644 index 00000000..05067a7b --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl.xcodeproj/xcshareddata/xcschemes/org.eyebeam.selfcontrold.xcscheme @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Self-Control-Extension/SelfControl/SelfControl/Assets.xcassets/AccentColor.colorset/Contents.json b/Self-Control-Extension/SelfControl/SelfControl/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControl/Assets.xcassets/AppIcon.appiconset/Contents.json b/Self-Control-Extension/SelfControl/SelfControl/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..e09f0fd2 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,74 @@ +{ + "images" : [ + { + "filename" : "icon_1024.png", + "idiom" : "ios-marketing", + "size" : "1024x1024", + "scale" : "1x" + }, + { + "scale" : "1x", + "idiom" : "mac", + "size" : "16x16", + "filename" : "icon_16.png" + }, + { + "scale" : "2x", + "idiom" : "mac", + "size" : "16x16", + "filename" : "icon_32.png" + }, + { + "scale" : "1x", + "filename" : "icon_32.png", + "idiom" : "mac", + "size" : "32x32" + }, + { + "filename" : "icon_64.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "icon_128.png", + "scale" : "1x" + }, + { + "filename" : "icon_256.png", + "idiom" : "mac", + "size" : "128x128", + "scale" : "2x" + }, + { + "scale" : "1x", + "idiom" : "mac", + "filename" : "icon_256.png", + "size" : "256x256" + }, + { + "size" : "256x256", + "filename" : "icon_512.png", + "idiom" : "mac", + "scale" : "2x" + }, + { + "filename" : "icon_512.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Self-Control-Extension/SelfControl/SelfControl/Assets.xcassets/AppIcon.appiconset/icon_1024.png b/Self-Control-Extension/SelfControl/SelfControl/Assets.xcassets/AppIcon.appiconset/icon_1024.png new file mode 100644 index 00000000..3e2456d8 Binary files /dev/null and b/Self-Control-Extension/SelfControl/SelfControl/Assets.xcassets/AppIcon.appiconset/icon_1024.png differ diff --git a/Self-Control-Extension/SelfControl/SelfControl/Assets.xcassets/AppIcon.appiconset/icon_128.png b/Self-Control-Extension/SelfControl/SelfControl/Assets.xcassets/AppIcon.appiconset/icon_128.png new file mode 100644 index 00000000..f1019225 Binary files /dev/null and b/Self-Control-Extension/SelfControl/SelfControl/Assets.xcassets/AppIcon.appiconset/icon_128.png differ diff --git a/Self-Control-Extension/SelfControl/SelfControl/Assets.xcassets/AppIcon.appiconset/icon_16.png b/Self-Control-Extension/SelfControl/SelfControl/Assets.xcassets/AppIcon.appiconset/icon_16.png new file mode 100644 index 00000000..3a5c15e5 Binary files /dev/null and b/Self-Control-Extension/SelfControl/SelfControl/Assets.xcassets/AppIcon.appiconset/icon_16.png differ diff --git a/Self-Control-Extension/SelfControl/SelfControl/Assets.xcassets/AppIcon.appiconset/icon_256.png b/Self-Control-Extension/SelfControl/SelfControl/Assets.xcassets/AppIcon.appiconset/icon_256.png new file mode 100644 index 00000000..3816f3dd Binary files /dev/null and b/Self-Control-Extension/SelfControl/SelfControl/Assets.xcassets/AppIcon.appiconset/icon_256.png differ diff --git a/Self-Control-Extension/SelfControl/SelfControl/Assets.xcassets/AppIcon.appiconset/icon_32.png b/Self-Control-Extension/SelfControl/SelfControl/Assets.xcassets/AppIcon.appiconset/icon_32.png new file mode 100644 index 00000000..22804587 Binary files /dev/null and b/Self-Control-Extension/SelfControl/SelfControl/Assets.xcassets/AppIcon.appiconset/icon_32.png differ diff --git a/Self-Control-Extension/SelfControl/SelfControl/Assets.xcassets/AppIcon.appiconset/icon_512.png b/Self-Control-Extension/SelfControl/SelfControl/Assets.xcassets/AppIcon.appiconset/icon_512.png new file mode 100644 index 00000000..149ab511 Binary files /dev/null and b/Self-Control-Extension/SelfControl/SelfControl/Assets.xcassets/AppIcon.appiconset/icon_512.png differ diff --git a/Self-Control-Extension/SelfControl/SelfControl/Assets.xcassets/AppIcon.appiconset/icon_64.png b/Self-Control-Extension/SelfControl/SelfControl/Assets.xcassets/AppIcon.appiconset/icon_64.png new file mode 100644 index 00000000..05bd2d23 Binary files /dev/null and b/Self-Control-Extension/SelfControl/SelfControl/Assets.xcassets/AppIcon.appiconset/icon_64.png differ diff --git a/Self-Control-Extension/SelfControl/SelfControl/Assets.xcassets/Contents.json b/Self-Control-Extension/SelfControl/SelfControl/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControl/Assets.xcassets/Image.imageset/Contents.json b/Self-Control-Extension/SelfControl/SelfControl/Assets.xcassets/Image.imageset/Contents.json new file mode 100644 index 00000000..3dcbc3dd --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Assets.xcassets/Image.imageset/Contents.json @@ -0,0 +1,9 @@ +{ + "images" : [ + + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControl/Assets.xcassets/SelfControlIcon.icns b/Self-Control-Extension/SelfControl/SelfControl/Assets.xcassets/SelfControlIcon.icns new file mode 100755 index 00000000..d49e3035 Binary files /dev/null and b/Self-Control-Extension/SelfControl/SelfControl/Assets.xcassets/SelfControlIcon.icns differ diff --git a/Self-Control-Extension/SelfControl/SelfControl/Assets.xcassets/icon.imageset/Contents.json b/Self-Control-Extension/SelfControl/SelfControl/Assets.xcassets/icon.imageset/Contents.json new file mode 100644 index 00000000..28baef84 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Assets.xcassets/icon.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "SelfControlIcon 2.png", + "idiom" : "mac" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControl/Assets.xcassets/icon.imageset/SelfControlIcon 2.png b/Self-Control-Extension/SelfControl/SelfControl/Assets.xcassets/icon.imageset/SelfControlIcon 2.png new file mode 100644 index 00000000..067f7d99 Binary files /dev/null and b/Self-Control-Extension/SelfControl/SelfControl/Assets.xcassets/icon.imageset/SelfControlIcon 2.png differ diff --git a/Self-Control-Extension/SelfControl/SelfControl/Info.plist b/Self-Control-Extension/SelfControl/SelfControl/Info.plist new file mode 100644 index 00000000..346d80fc --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Info.plist @@ -0,0 +1,67 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.21 + CFBundleVersion + 21 + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + Copyright © 2026 SelfControl. All rights reserved. + NSLocalNetworkUsageDescription + DNS lookup + NSMainStoryboardFile + Main + NSPrincipalClass + NSApplication + NSSupportsAutomaticTermination + + NSSupportsSuddenTermination + + SMPrivilegedExecutables + + SelfControlBGLaunchService.app + identifier "org.eyebeam.selfcontrold" and anchor apple generic and certificate leaf[subject.OU] = "X6FQ433AWK" + org.eyebeam.selfcontrold + identifier "org.eyebeam.selfcontrold" and anchor apple generic and certificate leaf[subject.OU] = "X6FQ433AWK" + + SUEnableAutomaticChecks + + SUFeedURL + https://selfcontrolapp.com/appcast.xml + SUPublicDSAKeyFile + dsa_pub.pem + UTExportedTypeDeclarations + + + UTTypeIcons + + UTTypeIdentifier + scbulist + UTTypeTagSpecification + + public.filename-extension + + scbulist + + + + + + diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/AppDelegate.swift b/Self-Control-Extension/SelfControl/SelfControl/Main/AppDelegate.swift new file mode 100644 index 00000000..07030e4d --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/AppDelegate.swift @@ -0,0 +1,45 @@ +// +// AppDelegate.swift +// SelfControl +// +// Created by Satendra Singh on 11/01/26. +// + + +import Cocoa + +class AppDelegate: NSObject, NSApplicationDelegate { + + var onAppClose: (() -> Void)? + + func applicationDidFinishLaunching(_ notification: Notification) { + print("App did finish launching") + enableHelper() + AppMover.moveIfNeeded() + LocalNotificationManager.requestAuthorization() + } + + private func enableHelper() { + Task { + do { + try await HelperConnection.shared.installLoginItemIfNeeded() + HelperConnection.shared.connect() + } catch { + print("installLoginItemIfNeeded Error: \(error)") + } +// let error = +// print("Install Error: \(String(describing: error))") + } + } + + func applicationWillTerminate(_ notification: Notification) { + print("App will terminate") + onAppClose?() + // Save state, cleanup resources, stop services, etc. + } + + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + print("Should terminate") + return .terminateNow + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/AppDockManager.swift b/Self-Control-Extension/SelfControl/SelfControl/Main/AppDockManager.swift new file mode 100644 index 00000000..325c602e --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/AppDockManager.swift @@ -0,0 +1,75 @@ +// +// AppDockManager.swift +// SelfControl +// +// Created by Satendra Singh on 31/05/26. +// + +import Cocoa + +final class AppDockManager { + var dockTimer: Timer? + var timerFireDate: Date? + + func checAndStartShowDockTimer(endTime: Date) { + timerFireDate = endTime + if UserDefaults.standard.bool(forKey: "BadgeApplicationIcon") { + dockTimer = Timer.scheduledTimer(timeInterval: 1, target: self, selector: #selector(updateDockCountDown), userInfo: nil, repeats: true) + } else { + stopShowingCountDownInDock() + } + } + + func stopShowingCountDownInDock() { + dockTimer?.invalidate() + NSApp?.dockTile.badgeLabel = nil + } + + @objc private func updateDockCountDown() { + let blockEndingDate: Date = timerFireDate ?? .now + let blockingSecond: Int = Int(blockEndingDate.timeIntervalSinceNow) + var numSeconds: Int = Int(blockEndingDate.timeIntervalSinceNow) + var numMinutes: Int = blockingSecond + var numHours: Int = 0 + + numHours = numSeconds / 3600 + numSeconds %= 3600 + numMinutes = numSeconds / 60 + numSeconds %= 60 + if blockingSecond > 0 { + // Round up minutes when showing mm:ss style without seconds + var minutes = numMinutes + if numSeconds > 0 && minutes != 59 { minutes += 1 } + + let badgeString = String(format: "%02d:%02d", numHours, minutes) + print("Badge: \(badgeString)") + Task { @MainActor in + ensureDockIconVisible() + setDockBadge(badgeString) + } + } else { + // Clear the badge when not using badging + Task { @MainActor in + setDockBadge(nil) + + } + } + + @MainActor + func ensureDockIconVisible() { + let app = NSApplication.shared + if app.activationPolicy() != .regular { + _ = app.setActivationPolicy(.regular) + } + } + + + @MainActor + func setDockBadge(_ text: String?) { + // text: use nil to clear, non-empty string to show + let app = NSApplication.shared + app.dockTile.badgeLabel = (text?.isEmpty == false) ? text : nil + app.dockTile.display() + } + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/AppMover.swift b/Self-Control-Extension/SelfControl/SelfControl/Main/AppMover.swift new file mode 100644 index 00000000..95c7e499 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/AppMover.swift @@ -0,0 +1,215 @@ +// +// AppMover.swift +// SelfControl +// +// Created by Satendra Singh on 05/12/25. +// + +import Cocoa +import os.log +import Darwin + +final class AppMover { + + static func moveIfNeeded() { + let fm = FileManager.default + let originalBundleURL = Bundle.main.bundleURL + let bundleURL = (try? originalBundleURL.resolvingSymlinksInPath()) ?? originalBundleURL + + os_log("[SC] 🔍 AppMover.moveIfNeeded bundle: %@", log: OSLog.default, type: .info, bundleURL.path) + + // 1. If already in Applications (system or user), do nothing. + if isInApplicationsFolder(bundleURL) { + os_log("[SC] ✅ App is already in Applications, skipping move.", log: OSLog.default, type: .info) + return + } + + // 2. Determine preferred Applications destination. + guard let destinationAppDir = preferredApplicationsDirectory() else { + os_log("[SC] ⚠️ Could not determine Applications directory, skipping move.", log: OSLog.default, type: .error) + return + } + + // Ensure ~/Applications exists when chosen as destination. + if destinationAppDir.path.hasPrefix(fm.homeDirectoryForCurrentUser.appendingPathComponent("Applications").path) { + do { + try fm.createDirectory(at: destinationAppDir, withIntermediateDirectories: true, attributes: nil) + } catch { + os_log("[SC] ❌ Could not create user Applications directory: %@", log: OSLog.default, type: .error, error.localizedDescription) + return + } + } + + // 3. Prompt the user to move. + DispatchQueue.main.async { + let alert = NSAlert() + alert.messageText = "Move to Applications Folder?" + alert.informativeText = "Would you like to move this app to your Applications folder? It must quit and relaunch." + alert.addButton(withTitle: "Move to Applications Folder") + alert.addButton(withTitle: "Do Not Move") + + let response = alert.runModal() + if response == .alertFirstButtonReturn { + performMove(to: destinationAppDir, bundleURL: bundleURL) + } else { + os_log("[SC] 🚫 User chose not to move the app.", log: OSLog.default, type: .info) + } + } + } + + private static func performMove(to appDir: URL, bundleURL: URL) { + let fm = FileManager.default + os_log("[SC] 🔧 AppMover.performMove from: %@ → %@", log: OSLog.default, type: .info, bundleURL.path, appDir.path) + + // Destination path for the app + let destURL = appDir.appendingPathComponent(bundleURL.lastPathComponent, isDirectory: true) + + // If a copy already exists at destination, try to trash it to avoid conflicts. + if fm.fileExists(atPath: destURL.path) { + do { + var resultingURL: NSURL? + try fm.trashItem(at: destURL, resultingItemURL: &resultingURL) + os_log("[SC] 🗑️ Trashed existing app at destination.", log: OSLog.default, type: .info) + } catch { + os_log("[SC] ⚠️ Could not trash existing app. Attempting removal: %@", log: OSLog.default, type: .error, error.localizedDescription) + do { + try fm.removeItem(at: destURL) + } catch { + os_log("[SC] ❌ Could not remove existing app at destination: %@", log: OSLog.default, type: .error, error.localizedDescription) + return + } + } + } + + // Copy (not move) the running app bundle. Moving a running app may fail (e.g., from a read-only DMG). + do { + try fm.copyItem(at: bundleURL, to: destURL) + os_log("[SC] ✅ Copied app to Applications.", log: OSLog.default, type: .info) + } catch { + os_log("[SC] ❌ Error copying app to Applications: %@", log: OSLog.default, type: .error, error.localizedDescription) + return + } + + // Clear quarantine attribute on the copied app so Gatekeeper doesn't warn again. + removeQuarantineRecursively(at: destURL) + + // Attempt to remove (trash) the original copy now that the app has been copied. + attemptToRemoveOriginal(at: bundleURL) + + // Relaunch the copied app. + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: { + let config = NSWorkspace.OpenConfiguration() + NSWorkspace.shared.openApplication(at: destURL, configuration: config) { _, err in + if let err = err { + os_log("[SC] ❌ Failed to relaunch moved app: %@", log: OSLog.default, type: .error, err.localizedDescription) + } else { + os_log("[SC] 🚀 Relaunched moved app successfully.", log: OSLog.default, type: .info) + } + + // Terminate current instance regardless; the copy succeeded. + if NSApp != nil { + NSApp.terminate(nil) + } else { + exit(0) + } + } + }) + } + + private static func removeQuarantineAttribute(from url: URL) { + let path = url.path + let attrName = "com.apple.quarantine" + + // Check if attribute exists + let hasAttr = getxattr(path, attrName, nil, 0, 0, 0) >= 0 + if hasAttr { + // Attempt to remove the attribute; ignore failure. + let result = removexattr(path, attrName, 0) + if result == 0 { + os_log("[SC] 🧹 Removed quarantine attribute: %@", log: OSLog.default, type: .debug, path) + } else { + os_log("[SC] ⚠️ Failed to remove quarantine attribute: %@", log: OSLog.default, type: .debug, path) + } + } + } + + private static func removeQuarantineRecursively(at url: URL) { + let fm = FileManager.default + + // Walk the bundle contents + if let enumerator = fm.enumerator(at: url, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles]) { + for case let fileURL as URL in enumerator { + removeQuarantineAttribute(from: fileURL) + } + } + // Also clear on the root bundle directory + removeQuarantineAttribute(from: url) + } + + private static func attemptToRemoveOriginal(at sourceBundleURL: URL) { + let fm = FileManager.default + + // Never try to remove if already in Applications (safety) + if isInApplicationsFolder(sourceBundleURL) { + return + } + + // Prefer moving to Trash so user can recover; if that fails, try direct removal. + do { + var trashedURL: NSURL? + try fm.trashItem(at: sourceBundleURL, resultingItemURL: &trashedURL) + os_log("[SC] 🗑️ Trashed original app at: %@", log: OSLog.default, type: .info, sourceBundleURL.path) + } catch { + os_log("[SC] ⚠️ Could not trash original app (%@). Attempting direct removal.", log: OSLog.default, type: .error, error.localizedDescription) + do { + try fm.removeItem(at: sourceBundleURL) + os_log("[SC] ✅ Removed original app at: %@", log: OSLog.default, type: .info, sourceBundleURL.path) + } catch { + // This commonly fails if the app is on a read-only volume (e.g., a DMG). + os_log("[SC] ❌ Failed to remove original app: %@", log: OSLog.default, type: .error, error.localizedDescription) + } + } + } +} + +// MARK: - Helpers + +private extension AppMover { + static func preferredApplicationsDirectory() -> URL? { + let fm = FileManager.default + let systemApplications = URL(fileURLWithPath: "/Applications", isDirectory: true) + let userApplications = fm.homeDirectoryForCurrentUser.appendingPathComponent("Applications", isDirectory: true) + + // Prefer /Applications if writable, otherwise fallback to ~/Applications. + if fm.isWritableFile(atPath: systemApplications.path) { + return systemApplications + } else { + return userApplications + } + } + + static func isInApplicationsFolder(_ bundleURL: URL) -> Bool { + let fm = FileManager.default + let parent = bundleURL.deletingLastPathComponent() + + let systemApplications = URL(fileURLWithPath: "/Applications", isDirectory: true) + let userApplications = fm.homeDirectoryForCurrentUser.appendingPathComponent("Applications", isDirectory: true) + + // Compare after resolving symlinks for reliability + let resolvedParent = (try? parent.resolvingSymlinksInPath()) ?? parent + let resolvedSystem = (try? systemApplications.resolvingSymlinksInPath()) ?? systemApplications + let resolvedUser = (try? userApplications.resolvingSymlinksInPath()) ?? userApplications + + if resolvedParent == resolvedSystem || resolvedParent == resolvedUser { + return true + } + + // Additional guard: some volumes may present Applications differently; fall back to path prefix check. + let path = resolvedParent.path + if path == "/Applications" || path.hasSuffix("/Applications") { + return true + } + + return false + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/EventScheduler/EventScheduler.swift b/Self-Control-Extension/SelfControl/SelfControl/Main/EventScheduler/EventScheduler.swift new file mode 100644 index 00000000..128270a5 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/EventScheduler/EventScheduler.swift @@ -0,0 +1,184 @@ +// +// EventScheduler.swift +// SelfControl +// +// Created by Satendra Singh on 23/02/26. +// + +import Foundation + +// A simple, concurrency-safe scheduler for recurring events by SCSCWeekday and time. +// It validates time ranges and prevents overlapping events on the same day. + +public enum SchedulerError: Error, LocalizedError, Equatable { + case invalidTimeRange + case conflict(conflictingEvents: [Event]) + case eventNotFound + + public var errorDescription: String? { + switch self { + case .invalidTimeRange: + return "The start time must be earlier than the end time." + case .conflict(let conflicts): + let names = conflicts.map { $0.title }.joined(separator: ", ") + return conflicts.isEmpty + ? "The event conflicts with an existing event." + : "The event conflicts with the following events: \(names)" + case .eventNotFound: + return "The event could not be found." + } + } +} + +/// SCWeekday aligned with the user's Calendar (1 = Sunday in many locales). +/// We normalize to a fixed enumeration order (Monday...Sunday) for consistency. +public enum SCWeekday: Int, CaseIterable, Codable, Hashable, Sendable { + case monday = 2 + case tuesday = 3 + case wednesday = 4 + case thursday = 5 + case friday = 6 + case saturday = 7 + case sunday = 1 + + var letter: String { + switch self { + case .monday: return "M" + case .tuesday: return "T" + case .wednesday: return "W" + case .thursday: return "T" + case .friday: return "F" + case .saturday: return "S" + case .sunday: return "S" + } + } + + var fullName: String { + switch self { + case .monday: return "Monday" + case .tuesday: return "Tuesday" + case .wednesday: return "Wednesday" + case .thursday: return "Thursday" + case .friday: return "Friday" + case .saturday: return "Saturday" + case .sunday: return "Sunday" + } + } + + /// Returns the SCWeekday for a given Date using the provided Calendar. + public static func from(date: Date, calendar: Calendar = .current) -> SCWeekday { + let SCWeekdayNumber = calendar.component(.weekday, from: date) + return SCWeekday(rawValue: SCWeekdayNumber) ?? .monday + } +} + +/// Represents a wall-clock time without a specific date or time zone. +/// Valid range: hour 0...23, minute 0...59 +public struct TimeOfDay: Codable, Hashable, Sendable, Comparable { + public let hour: Int + public let minute: Int + + public init(hour: Int, minute: Int) { + precondition((0...23).contains(hour), "Hour must be 0...23") + precondition((0...59).contains(minute), "Minute must be 0...59") + self.hour = hour + self.minute = minute + } + + public var minutesSinceMidnight: Int { + hour * 60 + minute + } + + public static func < (lhs: TimeOfDay, rhs: TimeOfDay) -> Bool { + lhs.minutesSinceMidnight < rhs.minutesSinceMidnight + } + + + /// Total minutes since midnight (00:00). + var totalMinutesSinceMidnight: Int { + hour * 60 + minute + } + + /// Returns the number of minutes from `other` to `self`. + /// - Parameters: + /// - other: The starting time of day. + /// - wrapAroundMidnight: If true, negative differences will be wrapped + /// by adding 24 hours (1440 minutes). This is useful when you want the + /// forward difference within the same 24-hour cycle. + /// - Returns: The minute difference as an `Int`. + /// + /// Examples: + /// - 10:30.minutes(from: 09:15) == 75 + /// - 01:00.minutes(from: 23:30, + /// wrapAroundMidnight: true) == 90 + /// - 01:00.minutes(from: 23:30, wrapAroundMidnight: false) == -1410 + func minutes(from other: TimeOfDay, wrapAroundMidnight: Bool = false) -> Int { + let diff = self.totalMinutesSinceMidnight - other.totalMinutesSinceMidnight + guard wrapAroundMidnight, diff < 0 else { return diff } + return diff + 24 * 60 + } + + /// Convenience: minutes until another time (alias with reversed arguments). + /// Positive when `other` is later than `self` on the same day. + func minutes(until other: TimeOfDay, wrapAroundMidnight: Bool = false) -> Int { + other.minutes(from: self, wrapAroundMidnight: wrapAroundMidnight) + } + + public func formatted(locale: Locale = .current) -> String { + var comps = DateComponents() + comps.hour = hour + comps.minute = minute + let cal = Calendar(identifier: .gregorian) + let date = cal.date(from: comps) ?? Date() + let formatter = DateFormatter() + formatter.locale = locale + formatter.timeStyle = .short + formatter.dateStyle = .none + return formatter.string(from: date) + } +} + +/// A recurring event that happens on one or more SCWeekdays at a given time range. +public struct Event: Identifiable, Codable, Hashable, Sendable { + public let id: UUID + public var title: String = "" + public var days: Set + public var startTime: TimeOfDay + public var endTime: TimeOfDay + public var userInfo: [String: String]? // Optional metadata + + public init( + id: UUID = UUID(), + title: String = "", + days: Set, + startTime: TimeOfDay, + endTime: TimeOfDay, + userInfo: [String: String]? = nil + ) throws { + guard startTime < endTime else { + throw SchedulerError.invalidTimeRange + } + self.id = id + self.title = title + self.days = days + self.startTime = startTime + self.endTime = endTime + self.userInfo = userInfo + } + + /// Returns true if this event occurs on the given SCWeekday. + public func occurs(on day: SCWeekday) -> Bool { + days.contains(day) + } + + /// Overlap check with another event on a specific SCWeekday. + /// Adjacent ranges (end == start) are not considered overlapping. + public func overlaps(with other: Event, on day: SCWeekday) -> Bool { + guard self.occurs(on: day), other.occurs(on: day) else { return false } + let aStart = self.startTime.minutesSinceMidnight + let aEnd = self.endTime.minutesSinceMidnight + let bStart = other.startTime.minutesSinceMidnight + let bEnd = other.endTime.minutesSinceMidnight + return aStart < bEnd && aEnd > bStart + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/EventScheduler/EventSchedulerStore.swift b/Self-Control-Extension/SelfControl/SelfControl/Main/EventScheduler/EventSchedulerStore.swift new file mode 100644 index 00000000..e55198d9 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/EventScheduler/EventSchedulerStore.swift @@ -0,0 +1,100 @@ +// +// EventSchedulerStore.swift +// SelfControl +// +// Created by Satendra Singh on 25/02/26. +// + +struct EventSchedulerStore { + // Load schedules from UserDefaults + static func loadSchedules() -> [Schedule] { + if let data = UserDefaults.standard.data(forKey: "schedules"), + let decoded = try? JSONDecoder().decode([Schedule].self, from: data) { + return decoded + } else { + // Initialize with one empty schedule if no saved data + return [Schedule(timeSlots: [], enabledDays: [])] + } + } + + // Save schedules to UserDefaults + static func saveSchedules(schedules: [Schedule]) { + if let encoded = try? JSONEncoder().encode(schedules) { + HelperConnection.shared.saveSchedules(schedules: encoded) { result in + print("saveSchedules: To service: \(result)") + } + UserDefaults.standard.set(encoded, forKey: "schedules") + } + } +} + +// MARK: - Schedule +struct Schedule: Identifiable, Codable, Equatable { + let id: UUID + var timeSlots: [TimeSlot] + var enabledDays: Set + var isExpanded: Bool + var isEnabled: Bool + + init(id: UUID = UUID(), timeSlots: [TimeSlot] = [], enabledDays: Set = [], isExpanded: Bool = false, isEnabled: Bool = false) { + self.id = id + self.timeSlots = timeSlots + self.enabledDays = enabledDays + self.isExpanded = isExpanded + self.isEnabled = isEnabled + } + + var summaryString: String { + timeSlots.map { $0.displayString }.joined(separator: " ") + } + + var enabledDaysString: String { + SCWeekday.allCases.compactMap { enabledDays.contains($0) ? $0.letter : nil }.joined() + } +} + +// MARK: - Schedule Models +struct TimeSlot: Identifiable, Codable, Equatable { + let id: UUID + var startHour: Int + var startMinute: Int + var endHour: Int + var endMinute: Int + + init(id: UUID = UUID(), startHour: Int, startMinute: Int, endHour: Int, endMinute: Int) { + self.id = id + self.startHour = startHour + self.startMinute = startMinute + self.endHour = endHour + self.endMinute = endMinute + } + + // Helper to format hour in 12-hour format + private func formatHour12(_ hour24: Int) -> (hour: Int, isPM: Bool) { + if hour24 == 0 { + return (12, false) + } else if hour24 < 12 { + return (hour24, false) + } else if hour24 == 12 { + return (12, true) + } else { + return (hour24 - 12, true) + } + } + + var startTimeString: String { + let (hour12, isPM) = formatHour12(startHour) + let amPm = isPM ? "PM" : "AM" + return String(format: "%d:%02d %@", hour12, startMinute, amPm) + } + + var endTimeString: String { + let (hour12, isPM) = formatHour12(endHour) + let amPm = isPM ? "PM" : "AM" + return String(format: "%d:%02d %@", hour12, endMinute, amPm) + } + + var displayString: String { + "\(startTimeString)-\(endTimeString)" + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/FilterViewModel.swift b/Self-Control-Extension/SelfControl/SelfControl/Main/FilterViewModel.swift new file mode 100644 index 00000000..20c42633 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/FilterViewModel.swift @@ -0,0 +1,462 @@ +// +// FilterViewModel.swift +// SelfControl +// +// Created by Egzon Arifi on 02/04/2025. +// + +import SwiftUI +import NetworkExtension +import SystemExtensions +import os.log +import Cocoa +import Combine +import SafariServices + +enum SelfControlViewState: Equatable { + + static func == (lhs: SelfControlViewState, rhs: SelfControlViewState) -> Bool { + switch (lhs, rhs) { + case (.installNetworkExtension, .installNetworkExtension), + (.installSafariExtension, .installSafariExtension), + (.installChromeExtension, .installChromeExtension), + (.error, .error), + (.filter, .filter): + return true + default: + return false + } + } + + case installNetworkExtension + case installSafariExtension + case installChromeExtension + case error(Error) + case filter +} + +final class FilterViewModel: NSObject, ObservableObject, OSSystemExtensionRequestDelegate, ExtensionToApp { + @Published var status: Status = .stopped + let dockManager = AppDockManager() + @Published var viewState: SelfControlViewState = .installNetworkExtension + + @Published var isNetworkExtensionSkipped: Bool = false { + didSet { + self.viewState = .filter + } + } + + private var isSafariExtensionInstalled: Bool { AppPreferences.isSafariExtensionInstalled } + private var isChromeExtensionInstalled: Bool { AppPreferences.isChromeExtensionInstalled } + + @State private var domains = AppPreferences.getBlockedDomains() + private(set) var blockerStorage: BlockedURLStore? + private var cancellables = Set() + @Published var isActiveBlocking: Bool = false + lazy var selfControlDaemon = SSCDaemonHelper() + + // Timer to manage delayed actions based on `delay` (in minutes) + private var blockTimer: Timer? + var timerFireDate: Date? + var dockTimer :Timer? + // Date formatter used to log entries + lazy var dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + return formatter + }() + + // Observer for filter configuration changes + var observer: Any? + var extensionIdentifier: String? + + // Load the system extension bundle from the app’s Contents/Library/SystemExtensions folder. + lazy var extensionBundle: Bundle = { + let extensionsDirectoryURL = URL(fileURLWithPath: "Contents/Library/SystemExtensions", relativeTo: Bundle.main.bundleURL) + let extensionURLs: [URL] + do { + extensionURLs = try FileManager.default.contentsOfDirectory(at: extensionsDirectoryURL, + includingPropertiesForKeys: nil, + options: .skipsHiddenFiles) + } catch let error { + fatalError("Failed to get the contents of \(extensionsDirectoryURL.absoluteString): \(error.localizedDescription)") + } + guard let extensionURL = extensionURLs.first else { + fatalError("Failed to find any system extensions") + } + guard let extensionBundle = Bundle(url: extensionURL) else { + fatalError("Failed to create a bundle with URL \(extensionURL.absoluteString)") + } + return extensionBundle + }() + + override init() { + super.init() + onInit() + HelperConnection.shared.onExtensionStateChange = { (ext, state) in + if state == true { + switch ext { + case .safari: + print("SafariExtensionManager.shared.onChange++") + AppPreferences.setSafariExtensionInstalled() + Task { @MainActor in + self.updateSafariExtensionViewStatus() + } + case .chrome: + print("Chrome.shared.onChange++") + AppPreferences.setChromeExtensionInstalled() + Task { @MainActor in + self.updateChromeExtensionViewStatus() + } + } + } + } + self.extensionIdentifier = extensionBundle.bundleIdentifier + // Print status whenever it changes + $status + .sink { [weak self] newValue in + guard let self = self else { return } + print("[FilterViewModel] status changed to: \(newValue) (\(newValue.text))") + os_log("[SC] 🔍] status changed to: %{public}@ (%{public}@)", String(describing: newValue), newValue.text) + // Keep extension state in sync when status changes + self.refreshExtensionState() + } + .store(in: &cancellables) + Task { @MainActor in + self.blockerStorage = BlockedURLStore() + self.blockerStorage?.load() + } + } + + deinit { + if let observer = observer { + NotificationCenter.default.removeObserver(observer, name: .NEFilterConfigurationDidChange, object: NEFilterManager.shared()) + } + blockTimer?.invalidate() + blockTimer = nil + } + + func onInit() { + // On initialization load the filter configuration and register for changes. + Self.loadFilterConfiguration { success in + guard success else { + self.status = .stopped + self.viewState = .installNetworkExtension + self.refreshExtensionState() + return + } + self.updateStatus() + self.observer = NotificationCenter.default.addObserver(forName: .NEFilterConfigurationDidChange, + object: NEFilterManager.shared(), + queue: .main) { [weak self] _ in + self?.updateStatus() + self?.refreshExtensionState() + } + // Initial state refresh + self.refreshExtensionState() + } + } + + // MARK: - NetworkExtensionStateProviding + + func refreshExtensionState() { + let isNEEnabled = NEFilterManager.shared().isEnabled + Task { @MainActor in + NetworkExtensionState.shared.isEnabled = isNEEnabled + if isNEEnabled == true { //Reset + NetworkExtensionState.shared.isSafariExtensionEnabled = false + NetworkExtensionState.shared.isChromeExtensionEnabled = false + updateNetworkExtensionViewStatus() + } + } // We’ll query Safari extension state asynchronously for accuracy. + } + + @MainActor func updateNetworkExtensionViewStatus() { + if NetworkExtensionState.shared.isEnabled == true { + withAnimation(.easeInOut(duration: 3)) { + self.viewState = .installChromeExtension + updateChromeExtensionViewStatus() + } + } + } + + @MainActor func updateSafariExtensionViewStatus() { + if .installNetworkExtension == viewState { + print(".installNetworkExtension == viewState in updateSafariExtensionViewStatus") + return + } + + if isSafariExtensionInstalled == true { + print(" isSafariExtensionInstalled == true true in Safari") + self.viewState = .filter + } else { + withAnimation(.easeInOut(duration: 3)) { + print("viewState = .installChromeExtension true in Safari") + self.viewState = .installSafariExtension + } + } + } + + @MainActor func updateChromeExtensionViewStatus() { + if .installNetworkExtension == viewState { + print(".installNetworkExtension == viewState in Chrome") + return + } + + if isChromeExtensionInstalled == true { + print("if isChromeExtensionInstalled == true in Chrome") + + updateSafariExtensionViewStatus() + } else { + withAnimation(.easeInOut(duration: 3)) { + print("viewState = .installChromeExtension true in Chrome") + self.viewState = .installChromeExtension + } + } + } + + // MARK: - UI and Filter Management + + func setBlockedUrls(urls: [String]) { + HelperConnection.shared.send_setBlockedURLs(urls) + if status == .stopped { //If legacy blocking + if isActiveBlocking { //if is active blocking + updateLegacyBlockedList(newBlockedDomains: urls) + } + } + // State might change due to Safari integration + } + + func updateStatus() { + if NEFilterManager.shared().isEnabled { + registerWithProvider() + } else { + status = .stopped + } + // Keep extension state refreshed + refreshExtensionState() + } + + func logFlow(_ flowInfo: [String: String], at date: Date, userAllowed: Bool) { + guard let localPort = flowInfo[FlowInfoKey.localPort.rawValue], + let remoteAddress = flowInfo[FlowInfoKey.remoteAddress.rawValue] else { + return + } + let dateString = dateFormatter.string(from: date) + let message = "\(dateString) \(userAllowed ? "ALLOW" : "DENY") \(localPort) <-- \(remoteAddress)\n" + os_log("[SC] 🔍] %@", message) + } + + static func loadFilterConfiguration(completionHandler: @escaping (Bool) -> Void) { + NEFilterManager.shared().loadFromPreferences { loadError in + DispatchQueue.main.async { + var success = true + if let error = loadError { + os_log("[SC] 🔍] Failed to load the filter configuration: %@", error.localizedDescription) + success = false + } + completionHandler(success) + } + } + } + + func enableFilterConfiguration() { + let filterManager = NEFilterManager.shared() + guard !filterManager.isEnabled else { +// registerWithProvider() + return + } + Self.loadFilterConfiguration { success in + guard success else { + self.status = .stopped + self.refreshExtensionState() + return + } + if filterManager.providerConfiguration == nil { + let providerConfiguration = NEFilterProviderConfiguration() + providerConfiguration.filterSockets = true + providerConfiguration.filterPackets = false +// providerConfiguration.filterBrowsers = true + filterManager.providerConfiguration = providerConfiguration + if let appName = Bundle.main.infoDictionary?["CFBundleName"] as? String { + filterManager.localizedDescription = appName + } + } + filterManager.isEnabled = true + filterManager.saveToPreferences { saveError in + DispatchQueue.main.async { + if let error = saveError { + os_log("[SC] 🔍] Failed to save the filter configuration: %@", error.localizedDescription) + self.status = .stopped + self.refreshExtensionState() + return + } +// self.registerWithProvider() + } + } + } + } + + func registerWithProvider() { + self.refreshExtensionState() + print("+registerWithProvider") + } + + func activateExtension() { + // Start by activating the system extension. + guard let extensionIdentifier = extensionIdentifier else { + self.status = .stopped + self.refreshExtensionState() + return + } +// let request = OSSystemExtensionRequest.propertiesRequest(forExtensionWithIdentifier: extensionIdentifier, queue: .main) +// request.delegate = self +// OSSystemExtensionManager.shared.submitRequest(request) + let activationRequest = OSSystemExtensionRequest.activationRequest(forExtensionWithIdentifier: extensionIdentifier, queue: .main) + activationRequest.delegate = self + OSSystemExtensionManager.shared.submitRequest(activationRequest) + } + // MARK: - UI Event Handlers. + + func startFilter() { + status = .indeterminate + guard !NEFilterManager.shared().isEnabled else { + registerWithProvider() + return + } +// guard let extensionIdentifier = extensionBundle.bundleIdentifier else { +// status = .stopped +// return +// } + activateExtension() + } + + func checkUrlRequest(url: String) { + URLSession.shared.dataTask(with: URL(string: url)!) { (data, response, error) in + print("Response: \(String(describing: response))") + print("Data: \(String(describing: data))") + print("Error: \(String(describing: error))") + }.resume() + } + + func stopFilter() { + let filterManager = NEFilterManager.shared() + status = .indeterminate + guard filterManager.isEnabled else { + status = .stopped + refreshExtensionState() + return + } + Self.loadFilterConfiguration { success in + guard success else { + self.status = .running + self.refreshExtensionState() + return + } + // Disable the content filter configuration. + filterManager.isEnabled = false + filterManager.saveToPreferences { saveError in + DispatchQueue.main.async { + if let error = saveError { + os_log("[SC] 🔍] Failed to disable the filter configuration: %@", error.localizedDescription) + self.status = .running + self.refreshExtensionState() + return + } + self.status = .stopped + self.refreshExtensionState() + } + } + } + } + // MARK: - OSSystemExtensionRequestDelegate Methods + + func request(_ request: OSSystemExtensionRequest, didFinishWithResult result: OSSystemExtensionRequest.Result) { + guard result == .completed else { + os_log("[SC] 🔍] Unexpected result %d for system extension request", result.rawValue) + status = .stopped + refreshExtensionState() + return + } + enableFilterConfiguration() + } + + func request(_ request: OSSystemExtensionRequest, didFailWithError error: Error) { + os_log("[SC] 🔍] System extension request failed: %@", error.localizedDescription) + status = .stopped + refreshExtensionState() + } + + func requestNeedsUserApproval(_ request: OSSystemExtensionRequest) { + os_log("[SC] 🔍] Extension %@ requires user approval", request.identifier) + } + + func request(_ request: OSSystemExtensionRequest, + actionForReplacingExtension existing: OSSystemExtensionProperties, + withExtension ext: OSSystemExtensionProperties) -> OSSystemExtensionRequest.ReplacementAction { + os_log("[SC] 🔍] Replacing extension %@ version %@ with version %@", request.identifier, existing.bundleShortVersion, ext.bundleShortVersion) + return .replace + } + + func request(_ request: OSSystemExtensionRequest, foundProperties properties: [OSSystemExtensionProperties]) { + os_log("[SC] 🔍] foundProperties extension %@", properties) + } + + // MARK: - App Communication (Prompting the User) + + //TODO: Cleanup + @objc func promptUser(aboutFlow flowInfo: [String: String], responseHandler: @escaping (Bool) -> Void) { } + func didSetUrls() { } + + func startBlocking(endDate: Date) { + print("activateNetworkBlocking+++++") + isActiveBlocking = true + dockManager.checAndStartShowDockTimer(endTime: endDate) + } + + func stopBlocking() { + print("deactivateNetworkBlocking+++++") + if AppPreferences.showNotificationOnCompletion { + LocalNotificationManager.scheduleNotification() + } + dockManager.stopShowingCountDownInDock() + isActiveBlocking = false + } + + func extendBlockTimer(by minutes: Int) { + guard minutes != 0, let timer = blockTimer else { return } + let delta = TimeInterval(minutes * 60) + timer.fireDate = timer.fireDate.addingTimeInterval(delta) + // Optionally also track a blockEndDate if you show a countdown + timerFireDate = timer.fireDate + dockManager.dockTimer?.fireDate = timer.fireDate + dockManager.timerFireDate = timer.fireDate + os_log("[SC] 🔍] Timer fire date updated to %{public}@", timerFireDate?.description ?? "Empty time") + } + + func cancelTimer() { + if let fireDate = timerFireDate { + os_log("[SC] 🔍] Cancelling timer scheduled for %{public}@", fireDate as NSDate) + } + blockTimer?.invalidate() + blockTimer = nil + timerFireDate = nil + self.isActiveBlocking = false + } + + func installLegacyLaunched(futureDuration: Date) { + selfControlDaemon.install(blockedDomains: AppPreferences.getBlockedDomains(), time: futureDuration) + } + + func updateLegacyBlockedList(newBlockedDomains: [String]) { + selfControlDaemon.updateBlocklist(newBlockedDomains) + } + + @MainActor func saveAndupdateBlockList(_ newBlockedDomains: [BlockedURL]) { + blockerStorage?.set(newBlockedDomains) + let urls = newBlockedDomains.compactMap(\.urls) + let flattened: [String] = urls.flatMap { $0 } + AppPreferences.setBlockedDomains(flattened) + setBlockedUrls(urls: flattened) + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/ImportExportManager/ImportExportManager.swift b/Self-Control-Extension/SelfControl/SelfControl/Main/ImportExportManager/ImportExportManager.swift new file mode 100644 index 00000000..b6347178 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/ImportExportManager/ImportExportManager.swift @@ -0,0 +1,104 @@ +// +// ImportExportManager.swift +// SelfControl +// +// Created by Satendra Singh on 21/02/26. +// + +import Foundation +import AppKit +import UniformTypeIdentifiers + +// Define your app-specific UTType. +// Make sure this matches the exported UTI you declare in your Info.plist. + +extension UTType { + static let blockedURLList = UTType(exportedAs: "scbulist") +} + +struct ImportExportManager { + + @MainActor + static func exportBlockedUrls(blockedURLs: [BlockedURL]) { + // Require a single, concrete format. Here we use JSON for the payload, + // but the on-disk file type is your custom UTType. + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + + let data: Data + do { + data = try encoder.encode(blockedURLs) + } catch { + NSLog("Failed to encode blocked URLs: \(error.localizedDescription)") + return + } + + let panel = NSSavePanel() + panel.title = "Export Blocked URLs" + panel.nameFieldStringValue = "BlockedURLs.scbulist" // Use your app’s extension + if #available(macOS 11.0, *) { + panel.allowedContentTypes = [.blockedURLList] + } else { + // Fallback for older macOS: still restrict by extension + panel.allowedFileTypes = ["scbulist"] + } + panel.isExtensionHidden = false + panel.canCreateDirectories = true + + let response = panel.runModal() + guard response == .OK, let url = panel.url else { return } + + do { + try data.write(to: url, options: .atomic) + } catch { + NSLog("Failed to write blocked URLs to file: \(error.localizedDescription)") + } + } + + @MainActor + static func importBlockedUrls(presentingWindow: NSWindow? = nil) async throws -> [BlockedURL] { + let panel = NSOpenPanel() + panel.title = "Import Blocked URLs" + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.canChooseFiles = true + panel.resolvesAliases = true + panel.canCreateDirectories = false + if #available(macOS 11.0, *) { + panel.allowedContentTypes = [.blockedURLList] + } else { + // Fallback for older macOS: restrict by your app’s extension + panel.allowedFileTypes = ["scbulist"] + } + + let response: NSApplication.ModalResponse + if let window = presentingWindow { + response = await withCheckedContinuation { continuation in + panel.beginSheetModal(for: window) { result in + continuation.resume(returning: result) + } + } + } else { + response = panel.runModal() + } + + guard response == .OK, let url = panel.url else { + // User cancelled + throw CocoaError(.userCancelled) + } + + // Optional extra validation of the picked file’s type +// if #available(macOS 11.0, *), +// let type = try? url.resourceValues(forKeys: [.contentTypeKey]).contentType, +// type != .blockedURLList { +// throw NSError(domain: "ImportBlockedURLs", code: 1, userInfo: [ +// NSLocalizedDescriptionKey: "The selected file is not a valid Blocked URL List." +// ]) +// } + + let data = try Data(contentsOf: url) + let decoder = JSONDecoder() + let blocked = try decoder.decode([BlockedURL].self, from: data) + return blocked + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/NetworkExtensionState.swift b/Self-Control-Extension/SelfControl/SelfControl/Main/NetworkExtensionState.swift new file mode 100644 index 00000000..158cd1ef --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/NetworkExtensionState.swift @@ -0,0 +1,55 @@ +// +// NetworkExtensionState.swift +// SelfControl +// +// Created by Satendra Singh on 26/11/25. +// + +import Foundation +import Combine + +@MainActor +final class NetworkExtensionState: ObservableObject { + // MARK: - Published mutable properties + @Published var isEnabled: Bool + @Published var isSafariExtensionEnabled: Bool + @Published var isChromeExtensionEnabled: Bool +// @Published var isActive: Bool = false + + // Singleton instance, isolated to the MainActor + static let shared: NetworkExtensionState = NetworkExtensionState(isEnabled: false) + + // MARK: - Initializers + init(isEnabled: Bool, isSafariExtensionEnabled: Bool = false, isChromeExtensionEnabled: Bool = false) { + self.isEnabled = isEnabled + self.isSafariExtensionEnabled = isSafariExtensionEnabled + self.isChromeExtensionEnabled = isChromeExtensionEnabled + } + + // MARK: - Equatable + // Equatable conformance compares current values, not identity. + static func == (lhs: NetworkExtensionState, rhs: NetworkExtensionState) -> Bool { + lhs.isEnabled == rhs.isEnabled && + lhs.isSafariExtensionEnabled == rhs.isSafariExtensionEnabled && + lhs.isChromeExtensionEnabled == rhs.isChromeExtensionEnabled + } + + // MARK: - Focused setters (optional convenience) + func setIsEnabled(_ newValue: Bool) { + isEnabled = newValue + } + + func setSafariExtensionEnabled(_ newValue: Bool) { + isSafariExtensionEnabled = newValue + } + + func setChromeExtensionEnabled(_ newValue: Bool) { + isChromeExtensionEnabled = newValue + } + + func printAll() { + print("isEnabled: \(isEnabled)") + print("isSafariExtensionEnabled: \(isSafariExtensionEnabled)") + print("isChromeExtensionEnabled: \(isChromeExtensionEnabled)") + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/Preferences/AppPreferences.swift b/Self-Control-Extension/SelfControl/SelfControl/Main/Preferences/AppPreferences.swift new file mode 100644 index 00000000..cf58cefd --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/Preferences/AppPreferences.swift @@ -0,0 +1,93 @@ +// +// ProxyPreferences.swift +// SelfControl +// +// Created by Satendra Singh on 12/07/25. +// + + +import Foundation + +struct AppPreferences { +// static let appGroup = "X6FQ433AWK.com.application.SelfControl.Extension" + private static let blockedDomainsKey = "BlockedDomains" + private static let isSafariExtensionKey: String = "isSafariExtensionKey" + private static let isChromeExtensionKey: String = "isChromeExtensionKey" + private static let isSafariExtInstallKey: String = "isSafariExtInstallKey" + private static let isChromeExtInstallKey: String = "isChromeExtInstallKey" + static let playSoundOnCompletionkey: String = "playSoundOnCompletion" + private static let showNotificationOnCompletionkey: String = "showNotificationOnCompletion" + private static let verifyNetworkBeforeBlock: String = "VerifyInternetConnection" + + private static let defaults = UserDefaults.standard + static func getBlockedDomains() -> [String] { + return defaults.stringArray(forKey: blockedDomainsKey) ?? [] + } + + static func setBlockedDomains(_ domains: [String]) { + defaults.set(domains, forKey: blockedDomainsKey) + } + + static func setSafariExtensionState(_ isEnabled: Bool) { + defaults.set(isEnabled, forKey: isSafariExtensionKey) + } + + static func setChromeExtensionState(_ isEnabled: Bool) { + defaults.set(isEnabled, forKey: isChromeExtensionKey) + } + + static func safariExtensionState() { + defaults.value(forKey: isSafariExtensionKey) + + } + + static func chromeExtensionState() { + defaults.value(forKey: isChromeExtensionKey) + } + + static var isSafariExtensionInstalled: Bool { + return UserDefaults.standard.bool(forKey: isSafariExtInstallKey) + } + + static func setSafariExtensionInstalled() { + UserDefaults.standard.set(true, forKey: isSafariExtInstallKey) + } + + static var isChromeExtensionInstalled: Bool { + return UserDefaults.standard.bool(forKey: isChromeExtInstallKey) + } + + static var playSoundOnCompletion: Bool { + return UserDefaults.standard.bool(forKey: playSoundOnCompletionkey) + } + + static var showNotificationOnCompletion: Bool { + return UserDefaults.standard.bool(forKey: showNotificationOnCompletionkey) + } + + static func setChromeExtensionInstalled() { + UserDefaults.standard.set(true, forKey: isChromeExtInstallKey) + } + + static var showVerifyNetworkAlertBeforeBlock: Bool { + if let value = defaults.value(forKey: verifyNetworkBeforeBlock) as? Bool { + return value + } + return true + } + + static func setShowVerifyNetworkAlertBeforeBlock(_ isEnabled: Bool) { + defaults.set(isEnabled, forKey: verifyNetworkBeforeBlock) + } + + static func reset() { + UserDefaults.standard.removeObject(forKey: isChromeExtInstallKey) + UserDefaults.standard.removeObject(forKey: isSafariExtInstallKey) + UserDefaults.standard.removeObject(forKey: isSafariExtensionKey) + UserDefaults.standard.removeObject(forKey: isChromeExtensionKey) + } + + static var soundNames: [String] { + ["Basso", "Blow", "Bottle", "Frog", "Funk", "Glass", "Hero", "Morse", "Ping", "Pop", "Purr", "Sosumi", "Submarine", "Tink"] + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/Preferences/LocalNotificationManager.swift b/Self-Control-Extension/SelfControl/SelfControl/Main/Preferences/LocalNotificationManager.swift new file mode 100644 index 00000000..8e99d7c4 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/Preferences/LocalNotificationManager.swift @@ -0,0 +1,32 @@ +// +// LocalNotificationManager.swift +// SelfControl +// +// Created by Satendra Singh on 03/04/26. +// +import UserNotifications + +final class LocalNotificationManager { + class func requestAuthorization() { + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in + if let error = error { + print("Error requesting notification authorization: \(error)") + } + } + } + + class func scheduleNotification(title: String = "Your SelfControl block has ended!", body: String = "All sites are now accessible.", timeInterval: TimeInterval = 0.1) { + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.sound = .none + + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: timeInterval, repeats: false) + let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: trigger) + UNUserNotificationCenter.current().add(request) { error in + if let error = error { + print("Error scheduling notification: \(error)") + } + } + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/SCDaemonSupport/SCDaemonHelper.swift b/Self-Control-Extension/SelfControl/SelfControl/Main/SCDaemonSupport/SCDaemonHelper.swift new file mode 100644 index 00000000..305d1640 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/SCDaemonSupport/SCDaemonHelper.swift @@ -0,0 +1,67 @@ +// +// SCDaemonHelper.swift +// SelfControl +// +// Created by Satendra Singh on 22/12/25. +// + +import Foundation + +final class SSCDaemonHelper { + let xpc = SCXPCClient() + + init() { } + + func install(blockedDomains: [String], time: Date) { + xpc.installDaemon { (error: Error?) in + if let error = error { + NSLog("ERROR: Failed to install daemon with error \(error)") + exit(EX_SOFTWARE) + } else { + // NSTimeInterval blockDurationSecs = MAX([[self->defaults_ valueForKey: @"BlockDuration"] intValue] * 60, 0); + // NSDate* newBlockEndDate = [NSDate dateWithTimeIntervalSinceNow: blockDurationSecs]; +// let date = Date().addingTimeInterval(5*60) //add second + let blockSettingsFromDefaults: [String: Any] = [ + "ClearCaches": 1, + "AllowLocalNetworks": 1, + "EvaluateCommonSubdomains": 1, + "IncludeLinkedDomains": 1, + "BlockSoundShouldPlay": 0, + "BlockSound": 5, + "EnableErrorReporting": 1 + ] + let blockSettings = blockSettingsFromDefaults + // ok, the new helper tool is installed! refresh the connection, then it's time to start the block + self.xpc.refreshConnectionAndRun { + NSLog("Refreshed connection and ready to start block!") + self.xpc.startBlock( + withControllingUID: getuid(), + blocklist: blockedDomains, + isAllowlist: false, + end: time, + blockSettings: blockSettings + ) { (error: Error?) in + if let error = error { + NSLog("ERROR: Daemon failed to start block with error \(error)") + exit(EX_SOFTWARE) + } + + NSLog("INFO: Block successfully added.") + // installingBlockSema.signal() + } + } + } + } + } + + func connect() { + xpc.connectToHelperTool() + } + + func updateBlocklist(_ domains: [String]) { + xpc.updateBlocklist(domains) { error in + print("\(error.localizedDescription )") + } + } +} + diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/SCDaemonSupport/SelfControl-Bridging-Header.h b/Self-Control-Extension/SelfControl/SelfControl/Main/SCDaemonSupport/SelfControl-Bridging-Header.h new file mode 100644 index 00000000..2f252b0e --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/SCDaemonSupport/SelfControl-Bridging-Header.h @@ -0,0 +1,5 @@ +// +// Use this file to import your target's public headers that you would like to expose to Swift. +// + +#import "SCXPCClient.h" diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/SCDaemonSupport/Test.h b/Self-Control-Extension/SelfControl/SelfControl/Main/SCDaemonSupport/Test.h new file mode 100644 index 00000000..5c8077e0 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/SCDaemonSupport/Test.h @@ -0,0 +1,16 @@ +// +// Test.h +// SelfControl +// +// Created by Satendra Singh on 22/12/25. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface Test : NSObject + +@end + +NS_ASSUME_NONNULL_END diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/SCDaemonSupport/Test.m b/Self-Control-Extension/SelfControl/SelfControl/Main/SCDaemonSupport/Test.m new file mode 100644 index 00000000..93ca8540 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/SCDaemonSupport/Test.m @@ -0,0 +1,12 @@ +// +// Test.m +// SelfControl +// +// Created by Satendra Singh on 22/12/25. +// + +#import "Test.h" + +@implementation Test + +@end diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/SafariExtensionWebView.swift b/Self-Control-Extension/SelfControl/SelfControl/Main/SafariExtensionWebView.swift new file mode 100644 index 00000000..416e546f --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/SafariExtensionWebView.swift @@ -0,0 +1,117 @@ +// +// SafariExtensionWebView.swift +// SelfControl +// +// Created by Satendra Singh on 16/11/25. +// + +import SwiftUI +import WebKit +import SafariServices + +// A minimal NSViewRepresentable wrapper to show WKWebView in SwiftUI on macOS. +private struct WKWebViewRepresentable: NSViewRepresentable { + let webView: WKWebView + + func makeNSView(context: Context) -> WKWebView { + webView + } + + func updateNSView(_ nsView: WKWebView, context: Context) { + // No-op for now. You can update navigation or content here if needed. + } +} + +struct SafariExtensionWebView: View { + // Create a real WKWebView using WKWebViewConfiguration. + let vm = WebPageViewModel() + + var body: some View { + WKWebViewRepresentable(webView: vm.webView) + .frame(minHeight: 150) + .onAppear { + // Load something if desired, or leave it empty for now. + // Example: +// if let url = URL(string: "https://apple.com") { +// vm.webView.load(URLRequest(url: url)) +// } +// self.webView.navigationDelegate = self + +// self.webView.configuration.userContentController.add(self, name: "controller") + + self.vm.webView.loadFileURL(Bundle.main.url(forResource: "Main", withExtension: "html")!, allowingReadAccessTo: Bundle.main.resourceURL!) + } + } +} + +final class WebPageViewModel: NSObject, WKNavigationDelegate, WKScriptMessageHandler { + private let extensionManager = SafariExtensionManager.shared + + let webView: WKWebView = { + let configuration = WKWebViewConfiguration() + // Configure as needed (userContentController, preferences, etc.) + return WKWebView(frame: .zero, configuration: configuration) + }() + + override init() { + super.init() + self.webView.navigationDelegate = self + self.webView.configuration.userContentController.add(self, name: "controller") + } + + private let extensionIdentifier = SafariExtensionConstants.identifier + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + + SFSafariExtensionManager.getStateOfSafariExtension(withIdentifier: extensionIdentifier) { (state, error) in + if let error = error as NSError? { + print("⚠️ Safari State check error for \(self.extensionIdentifier): \(error.domain) code \(error.code) – \(error.localizedDescription)") + } else { + print("🔍 Safari Current state of \(self.extensionIdentifier):", state?.description ?? "Unknown") + print(state?.isEnabled ?? "Not sure") + if state?.isEnabled == false { + print("⚠️ Safari Extension not enabled in Safari. Please enable it in Settings → Safari → Extensions.") + } + } + + DispatchQueue.main.async { + if #available(macOS 13, *) { + webView.evaluateJavaScript("show(\(state?.isEnabled), true)") + } else { + webView.evaluateJavaScript("show(\(state?.isEnabled), false)") + } + self.updateSafariExtensionState(state: state?.isEnabled ?? false) + } + } + } + + + private func updateSafariExtensionState(state: Bool) { + AppPreferences.setSafariExtensionState(state) + if state == true { + updateBlocker() + } + } + + func updateBlocker() { + let urls = AppPreferences.getBlockedDomains() + print("URLS: \(urls)") + BlockListManager.updateSafariBlockList(blockedPaths: urls, appGroup: extensionIdentifier, extensionIdentifier: extensionIdentifier) + } + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + if (message.body as! String != "open-preferences") { + return; + } + + SFSafariApplication.showPreferencesForExtension(withIdentifier: extensionIdentifier) { error in + DispatchQueue.main.async { + NSApplication.shared.terminate(nil) + } + } + } +} + +#Preview { + SafariExtensionWebView() +} diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControl.entitlements b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControl.entitlements new file mode 100644 index 00000000..9cc108e5 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControl.entitlements @@ -0,0 +1,17 @@ + + + + + com.apple.developer.networking.networkextension + + content-filter-provider-systemextension + + com.apple.developer.system-extension.install + + com.apple.security.application-groups + + group.com.application.SelfControl.corebits + $(TeamIdentifierPrefix)com.application.SelfControl.corebits + + + diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlApp.swift b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlApp.swift new file mode 100644 index 00000000..150d9387 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlApp.swift @@ -0,0 +1,167 @@ +// +// SelfControlApp.swift +// SelfControl +// +// Created by Egzon Arifi on 02/04/2025. +// + +import SwiftUI + +@main +struct SelfControlApp: App { + @StateObject var viewModel = FilterViewModel() + @NSApplicationDelegateAdaptor(AppDelegate.self) + var appDelegate + var body: some Scene { + WindowGroup { + switch viewModel.viewState { + case .filter: + NewContentView() + .environmentObject(viewModel) // Inject the object into the environment + .background(WindowTitleBarHider()) + case .installNetworkExtension: + OnBoardingInstallNetworkExt() + .environmentObject(viewModel) // Inject the object into the environment + .frame(width: 520, height: 420) + + case .installSafariExtension: + OnBoardingInstallSafariExt() + .environmentObject(viewModel) // Inject the object into the environment + .frame(width: 520, height: 420) + + case .installChromeExtension: + OnBoardingInstallChromeExt() + .environmentObject(viewModel) // Inject the object into the environment + .frame(width: 520, height: 420) + + + default: + EmptyView() + } + } + .commands { + CommandGroup(replacing: .newItem) { } + CommandGroup(replacing: .appInfo) { + Button(Strings.AppMenu.aboutSelfControl) { + NotificationCenter.default.post(name: NSNotification.Name("ShowAboutScreen"), object: nil) + } + + Divider() + + Button(Strings.AppMenu.editSiteList) { + NotificationCenter.default.post(name: NSNotification.Name("ShowEditListScreen"), object: nil) + } + .keyboardShortcut("e", modifiers: .command) + + Button(Strings.AppMenu.editBlockSchedule) { + NotificationCenter.default.post(name: NSNotification.Name("ShowBlockScheduleScreen"), object: nil) + } + .keyboardShortcut("b", modifiers: .command) + + Button(Strings.AppMenu.moreSettings) { + NotificationCenter.default.post(name: NSNotification.Name("ShowAdvancedSettingsScreen"), object: nil) + } + .keyboardShortcut(",", modifiers: .command) + + + + Divider() + + Button(Strings.AppMenu.donate) { + if let url = URL(string: Strings.AppMenu.URLs.donate) { + NSWorkspace.shared.open(url) + } + } + } + CommandGroup(replacing: .help) { + Button(Strings.AppMenu.gettingStartedTips) { + NotificationCenter.default.post(name: NSNotification.Name("ShowTipsScreen"), object: nil) + } + .keyboardShortcut("t", modifiers: .command) + + Divider() + + Button(Strings.AppMenu.selfControlHelp) { + if let url = URL(string: Strings.AppMenu.URLs.help) { + NSWorkspace.shared.open(url) + } + } + .keyboardShortcut("?", modifiers: .command) + + Button(Strings.AppMenu.faq) { + if let url = URL(string: Strings.AppMenu.URLs.faq) { + NSWorkspace.shared.open(url) + } + } + } + } + } + + func resolveIPToHostname(ipAddress: String) -> String? { + let hostRef = CFHostCreateWithName(nil, ipAddress as CFString).takeRetainedValue() + + var resolved: DarwinBoolean = false + if CFHostStartInfoResolution(hostRef, .names, nil) { + if let names = CFHostGetNames(hostRef, &resolved)?.takeUnretainedValue() as NSArray?, + let hostname = names.firstObject as? String { + return hostname + } + } + return nil + } + + func reverseDNS(ipAddress: String) -> String? { + let hostRef = CFHostCreateWithAddress(nil, ipAddressToData(ipAddress) as CFData).takeRetainedValue() + var resolved: DarwinBoolean = false + if CFHostStartInfoResolution(hostRef, .names, nil), + let names = CFHostGetNames(hostRef, &resolved)?.takeUnretainedValue() as NSArray?, + let hostname = names.firstObject as? String { + return hostname + } + return nil + } + + private func ipAddressToData(_ ipAddress: String) -> Data { + var addr = in_addr() + inet_pton(AF_INET, ipAddress, &addr) + return Data(bytes: &addr, count: MemoryLayout.size) + } + + func reverseDNSUsingGetNameInfo(ipAddress: String) -> String? { + var addr = sockaddr_in() + addr.sin_len = UInt8(MemoryLayout.size) + addr.sin_family = sa_family_t(AF_INET) + inet_pton(AF_INET, ipAddress, &addr.sin_addr) + + var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + + // Precompute length so we don't read from `addr` inside the closure. + let addrLen = socklen_t(addr.sin_len) + + let result: Int32 = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { saPtr in + getnameinfo(saPtr, + addrLen, + &hostname, socklen_t(hostname.count), + nil, 0, + NI_NAMEREQD) + } + } + + guard result == 0 else { return nil } + return String(cString: hostname) + } +} + +extension AppOnboardingViewState { + init(state: SelfControlViewState) { + switch state { + case .installChromeExtension: + self = .installChromeExtension + case .installSafariExtension: + self = .installSafariExtension + default : + self = .installNetworkExtension + } + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlBGServiceSupport/HelperConnection+ReceivedMessages.swift b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlBGServiceSupport/HelperConnection+ReceivedMessages.swift new file mode 100644 index 00000000..53eb9dd0 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlBGServiceSupport/HelperConnection+ReceivedMessages.swift @@ -0,0 +1,24 @@ +// +// HelperConnection+ReceivedMessages.swift +// SelfControl +// +// Created by Satendra Singh on 19/05/26. +// + +extension HelperConnection { + func didEnableWebExtension(_ extensionTypeRawValue: String, state: Bool) { +// Task { @MainActor in + print("REceived didEnableWebExtension: \(extensionTypeRawValue), state: \(state)") + if let ext = WEBExtension(rawValue: extensionTypeRawValue) { + onExtensionStateChange?(ext, state) + } + } + + func didStartedBlocking(_ state: Bool, _ endDate: Date) -> Void { + if state == true { + let minutes: Double = Double(endDate.timeIntervalSinceNow / 60) + self.blockedStateHandler?(minutes) + } + print("REceived Blocked State: \(String(describing: state)), End Date: \(String(describing: endDate))") + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlBGServiceSupport/HelperConnection+SendMessages.swift b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlBGServiceSupport/HelperConnection+SendMessages.swift new file mode 100644 index 00000000..800a96d4 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlBGServiceSupport/HelperConnection+SendMessages.swift @@ -0,0 +1,38 @@ +// +// HelperConnection+SendMessages.swift +// SelfControl +// +// Created by Satendra Singh on 31/05/26. +// + +extension HelperConnection { + func send_startNetwrokBlocking(minutes: Int) { + bgProxyServiceConnection()?.startNetwrokBlocking(minutes: minutes) + } + + func send_stopNetworkBlocking() { + bgProxyServiceConnection()?.stopNetworkBlocking() + } + + func send_getBlockedStates() { + bgProxyServiceConnection()?.getBlockedStates { state, endDate in + if state == true, let endDate = endDate { + let minutes: Double = Double(endDate.timeIntervalSinceNow / 60) + self.blockedStateHandler?(minutes) + } + print("REceived Blocked State: \(String(describing: state)), End Date: \(String(describing: endDate))") + } + } + + func send_extendBlocking(minutes: Int) { + bgProxyServiceConnection()?.extendBlocking(minutes: minutes) + } + + func send_setPreference(key: String, value: Bool) { + bgProxyServiceConnection()?.setPreference(key: key, value: value) + } + + func send_setBlockedURLs(_ urls: [String]) { + bgProxyServiceConnection()?.setBlockedURLs(urls) + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlBGServiceSupport/HelperConnection.swift b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlBGServiceSupport/HelperConnection.swift new file mode 100644 index 00000000..c7c348d0 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlBGServiceSupport/HelperConnection.swift @@ -0,0 +1,167 @@ +// +// HelperConnection.swift +// SelfControl +// +// Created by Satendra Singh on 08/05/26. +// + +// MainApp/HelperConnection.swift +import Cocoa +import ServiceManagement + +enum HelperServiceConstants: String { + case bundleID = "com.application.SelfControl.corebits.bgservice" // HelperApp’s bundle identifier + case processName = "SelfControlBGService" + case machServiceName = "com.application.SelfControl.corebits.bgservice.xpc" +} + +final class HelperConnection: NSObject, HelperClientProtocol, NSSecureCoding { + static let shared = HelperConnection() + static var supportsSecureCoding: Bool = true + var onExtensionStateChange: ((WEBExtension, Bool) -> Void)? + var blockedStateHandler: ((Double) -> Void)? + private var connection: NSXPCConnection? + private var exportedConnection: NSXPCListenerEndpoint? + private let checkProcess = CheckProcess(bundleID: HelperServiceConstants.bundleID.rawValue, processName: HelperServiceConstants.processName.rawValue) + + private let stateQueue = DispatchQueue(label: "com.application.SelfControl.corebits.bgservice.connection", attributes: .concurrent) + + override init() { } + + required init?(coder: NSCoder) { + return nil + } + + func encode(with coder: NSCoder) { + + } + // MARK: - Public API + + func installLoginItemIfNeeded() async throws { + if !checkProcess.isRunning() { + do { + try LaunchAgentManager.install() + } catch { + throw error + } + } else { + print("BG App is already running") + } + } + + func uninstallLoginItem() throws { + let loginItem = SMAppService.loginItem(identifier: HelperServiceConstants.bundleID.rawValue) + try loginItem.unregister() + } + + func connect() { + stateQueue.sync(flags: .barrier) { + guard self.connection == nil else { return } + + let conn = NSXPCConnection(machServiceName: HelperServiceConstants.machServiceName.rawValue, options: []) + // Remote interface: the helper’s service protocol + conn.remoteObjectInterface = NSXPCInterface(with: HelperServiceProtocol.self) + // Exported interface: our client protocol to receive callbacks + let clientInterface = NSXPCInterface(with: HelperClientProtocol.self) + conn.exportedInterface = clientInterface + conn.exportedObject = self + // exportedConnection = listener.endpoint + + conn.interruptionHandler = { [weak self] in + print("Conn interruptionHandler") + // Temporary loss of connection; try to reconnect + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + self?.reconnect() + } + } + + conn.invalidationHandler = { [weak self] in + print("Conn invalidationHandler") + + // Connection invalidated; tear down and reconnect + self?.stateQueue.sync(flags: .barrier) { + self?.connection = nil + } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + self?.connect() + } + } + + conn.resume() + self.connection = conn + // Register for callbacks + bgProxyServiceConnection()?.registerClient() + } + } + + func disconnect() { + stateQueue.sync(flags: .barrier) { + self.connection?.invalidate() + self.connection = nil + } + } + + func saveSchedules(schedules: Data, reply: @escaping (Bool) -> Void) { + stateQueue.async(flags: .barrier) { + self.bgProxyServiceConnection()?.saveSchedules(schedules: schedules, reply: reply) + } + } + + func loadSchedules(reply: @escaping (_ schedules: Data) -> Void) { + //TODO: + } + + func currentStatus() async throws -> String { + try await withCheckedThrowingContinuation { cont in + self.withProxy(error: { cont.resume(throwing: $0) }) { proxy in + proxy.currentStatus { status in cont.resume(returning: status) } + } + } + } + + // MARK: - HelperClientProtocol (callbacks from helper) + + func didUpdateStatus(_ status: String) { + print("didUpdateStatus: \(status)") + // Update UI or post notifications as needed + NotificationCenter.default.post(name: .helperStatusChanged, object: status) + } + + func didEmitEvent(_ message: String) { + print("didEmitEvent: \(message)") + NotificationCenter.default.post(name: .helperEvent, object: message) + } + + // MARK: - Internal + + private func reconnect() { + disconnect() + connect() + } + + private func withProxy(error: ((Error) -> Void)? = nil, _ body: (HelperServiceProtocol) -> Void) { + var proxy: AnyObject? + stateQueue.sync { + proxy = self.connection?.remoteObjectProxyWithErrorHandler { err in + error?(err) + } as AnyObject? + } + if let proxy = proxy as? HelperServiceProtocol { + body(proxy) + } else { + error?(NSError(domain: "HelperConnection", code: 1, userInfo: [NSLocalizedDescriptionKey: "No XPC connection"])) + } + } + + func bgProxyServiceConnection() -> HelperServiceProtocol? { + var proxy: AnyObject? + proxy = self.connection?.remoteObjectProxyWithErrorHandler { err in + print("Conn Error:\(err)") } as AnyObject? + return proxy as? HelperServiceProtocol + } +} + +extension Notification.Name { + static let helperStatusChanged = Notification.Name("HelperStatusChanged") + static let helperEvent = Notification.Name("HelperEvent") +} diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlBGServiceSupport/LaunchedInstallManager/CheckProcess.swift b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlBGServiceSupport/LaunchedInstallManager/CheckProcess.swift new file mode 100644 index 00000000..8c7a7298 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlBGServiceSupport/LaunchedInstallManager/CheckProcess.swift @@ -0,0 +1,55 @@ +// +// CheckProcess.swift +// SelfControl +// +// Created by Satendra Singh on 21/05/26. +// + + +import AppKit + +struct CheckProcess { + + var bundleID: String + var processName: String + + func isRunning() -> Bool { + // Use the bundle identifier if possible (most reliable). + if isAppRunning(bundleIdentifier: bundleID) { + fputs("Installation aborted: \(bundleID) is currently running.\n", stderr) + return true + } + + // Optional: also guard by process name as a fallback. + if isProcessRunningByName(processName) { + fputs("Installation aborted: process '\(processName)' is currently running.\n", stderr) + return true + } + + // OK to proceed + return false + } + + private func isAppRunning(bundleIdentifier: String) -> Bool { + !NSRunningApplication.runningApplications(withBundleIdentifier: bundleIdentifier).isEmpty + } + + private func isProcessRunningByName(_ name: String) -> Bool { + // Uses /usr/bin/pgrep -x for exact match + let task = Process() + task.launchPath = "/usr/bin/pgrep" + task.arguments = ["-x", name] + + let pipe = Pipe() + task.standardOutput = pipe + task.standardError = Pipe() + + do { + try task.run() + task.waitUntilExit() + return task.terminationStatus == 0 + } catch { + return false + } + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlBGServiceSupport/LaunchedInstallManager/LaunchAgentManager.swift b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlBGServiceSupport/LaunchedInstallManager/LaunchAgentManager.swift new file mode 100644 index 00000000..ee91b74c --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlBGServiceSupport/LaunchedInstallManager/LaunchAgentManager.swift @@ -0,0 +1,108 @@ +// +// LaunchAgentManager.swift +// SelfControl +// +// Created by Satendra Singh on 13/05/26. +// + + +import Foundation + +final class LaunchAgentManager { + + static let label = "com.application.SelfControl.corebits.bgservice" + static let processName = "SelfControlBGService" + + static let plistName = "com.application.SelfControl.corebits.bgservice" + + static func install() throws { + + let fm = FileManager.default + + let sourcePlist = Bundle.main.url( + forResource: plistName, + withExtension: "plist" + )! + + let launchAgentsDir = fm.homeDirectoryForCurrentUser + .appendingPathComponent("Library/LaunchAgents") + + let destinationPlist = launchAgentsDir + .appendingPathComponent("\(plistName).plist") + + // Create LaunchAgents directory if needed + try fm.createDirectory( + at: launchAgentsDir, + withIntermediateDirectories: true + ) + + // Replace old plist + if fm.fileExists(atPath: destinationPlist.path) { + try fm.removeItem(at: destinationPlist) + } + + try fm.copyItem( + at: sourcePlist, + to: destinationPlist + ) + + // unload existing + try runLaunchctl([ + "unload", + destinationPlist.path + ]) + + // load new + try runLaunchctl([ + "load", + destinationPlist.path + ]) + + print("LaunchAgent installed") + } + + static func uninstall() throws { + + let fm = FileManager.default + + let plist = fm.homeDirectoryForCurrentUser + .appendingPathComponent( + "Library/LaunchAgents/\(plistName).plist" + ) + + try runLaunchctl([ + "unload", + plist.path + ]) + + try fm.removeItem(at: plist) + + print("LaunchAgent removed") + } + + @discardableResult + static func runLaunchctl(_ args: [String]) throws -> String { + + let process = Process() + + process.executableURL = URL( + fileURLWithPath: "/bin/launchctl" + ) + + process.arguments = args + + let pipe = Pipe() + + process.standardOutput = pipe + process.standardError = pipe + + try process.run() + + process.waitUntilExit() + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + + return String(data: data, encoding: .utf8) ?? "" + } +} + diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlBGServiceSupport/SelfControlServiceSupport.swift b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlBGServiceSupport/SelfControlServiceSupport.swift new file mode 100644 index 00000000..7155d55e --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlBGServiceSupport/SelfControlServiceSupport.swift @@ -0,0 +1,39 @@ +// +// SelfControlServiceSupport.swift +// SelfControl +// +// Created by Satendra Singh on 03/05/26. +// + +import Cocoa + +final class SelfControlServiceSupport: NSObject, NSXPCListenerDelegate { + // Use a reverse-DNS mach service name. This must match your helper’s Info.plist. + private let listener = NSXPCListener(machServiceName: AppToServiceMessagesConstants.bundleID) +// private let service = HelperService() + + func applicationDidFinishLaunching(_ notification: Notification) { + listener.delegate = self + listener.resume() + // Agent app has no UI; it just keeps running. + } + + func listener(_ listener: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool { + // Configure interfaces for bidirectional communication. + let exportedInterface = NSXPCInterface(with: AppToServiceMessages.self) + let clientInterface = NSXPCInterface(with: AppToServiceMessages.self) +// exportedInterface.setInterface(clientInterface, for: #selector(SelfControlServiceProtocol.registerClient(_:)), argumentIndex: 0, ofReply: false) + + newConnection.exportedInterface = exportedInterface +// newConnection.exportedObject = service + + newConnection.invalidationHandler = { [weak self] in + // Optionally clean up client references if you track them per-connection + _ = self // keep capture + } + newConnection.interruptionHandler = { /* handle temporary interruptions */ } + + newConnection.resume() + return true + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Components/CustomComponents.swift b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Components/CustomComponents.swift new file mode 100644 index 00000000..d22274d6 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Components/CustomComponents.swift @@ -0,0 +1,176 @@ +// +// CustomComponents.swift +// SelfControlUI +// +// Created on 2025-10-31. +// + +import SwiftUI +import AppKit + +// MARK: - Arrow Key TextField +struct ArrowKeyTextField: NSViewRepresentable { + @Binding var text: String + var onUpArrow: () -> Void + var onDownArrow: () -> Void + var onSubmit: () -> Void + var onFocusChange: ((Bool) -> Void)? = nil + + func makeNSView(context: Context) -> NSTextField { + let textField = ArrowKeyNSTextField() + textField.delegate = context.coordinator + textField.isBordered = false + textField.backgroundColor = .clear + textField.drawsBackground = false + textField.alignment = .right + textField.font = .systemFont(ofSize: 32, weight: .bold) + textField.textColor = NSColor(red: 0.95, green: 0.95, blue: 0.97, alpha: 1.0) + textField.onUpArrow = onUpArrow + textField.onDownArrow = onDownArrow + textField.onSubmit = onSubmit + textField.onFocusChange = onFocusChange + return textField + } + + func updateNSView(_ nsView: NSTextField, context: Context) { + if nsView.stringValue != text { + nsView.stringValue = text + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, NSTextFieldDelegate { + var parent: ArrowKeyTextField + + init(_ parent: ArrowKeyTextField) { + self.parent = parent + } + + func controlTextDidChange(_ obj: Notification) { + if let textField = obj.object as? NSTextField { + parent.text = textField.stringValue + } + } + + func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + if commandSelector == #selector(NSResponder.insertNewline(_:)) { + parent.onSubmit() + return true + } + return false + } + } +} + +class ArrowKeyNSTextField: NSTextField { + var onUpArrow: (() -> Void)? + var onDownArrow: (() -> Void)? + var onSubmit: (() -> Void)? + var onFocusChange: ((Bool) -> Void)? + + override func becomeFirstResponder() -> Bool { + let result = super.becomeFirstResponder() + if result { + onFocusChange?(true) + } + return result + } + + override func resignFirstResponder() -> Bool { + let result = super.resignFirstResponder() + if result { + onFocusChange?(false) + } + return result + } + + override func keyDown(with event: NSEvent) { + switch event.keyCode { + case 126: // Up arrow + onUpArrow?() + case 125: // Down arrow + onDownArrow?() + default: + super.keyDown(with: event) + } + } +} + +// MARK: - Checkbox Toggle Style +struct CheckboxToggleStyle: ToggleStyle { + func makeBody(configuration: Configuration) -> some View { + HStack { + Image(systemName: configuration.isOn ? "checkmark.square.fill" : "square") + .font(DesignSystem.font(size: DesignSystem.fontSizeHeading)) + .foregroundColor(configuration.isOn ? Color.white : Color(white: 0.5)) + .onTapGesture { + configuration.isOn.toggle() + } + + configuration.label + } + } +} + +// MARK: - Custom Switch Toggle Style +struct SwitchToggleStyle: ToggleStyle { + let tint: Color + @State private var isHovered = false + + init(tint: Color = Color(red: 0.25, green: 0.48, blue: 0.95)) { + self.tint = tint + } + + func makeBody(configuration: Configuration) -> some View { + HStack { + // Switch pill + ZStack(alignment: configuration.isOn ? .trailing : .leading) { + // Background pill + Capsule() + .fill(configuration.isOn ? Color.white : Color(white: 0.25)) + .frame(width: 44, height: 24) + .overlay( + Capsule() + .stroke(configuration.isOn ? DesignSystem.textPrimary.opacity(DesignSystem.opacityMedium) : DesignSystem.borderPrimary, lineWidth: 1) + ) + .shadow(color: DesignSystem.overlayBackground.opacity(DesignSystem.opacityMedium), radius: 2, x: 0, y: 1) + + // Thumb/knob - warm white when off, dark gray when on + Circle() + .fill(configuration.isOn ? Color(red: 0.2, green: 0.2, blue: 0.22) : Color(red: 0.96, green: 0.95, blue: 0.96)) + .frame(width: 20, height: 20) + .shadow(color: DesignSystem.overlayBackground.opacity(DesignSystem.opacityMedium), radius: 2, x: 0, y: 1) + .padding(DesignSystem.spacingXXSmall) + } + .animation(.spring(response: 0.25, dampingFraction: 0.75, blendDuration: 0), value: configuration.isOn) + .scaleEffect(isHovered ? 1.05 : 1.0) + .animation(.spring(response: 0.2, dampingFraction: 0.7, blendDuration: 0), value: isHovered) + .onTapGesture { + withAnimation(.spring(response: 0.25, dampingFraction: 0.75)) { + configuration.isOn.toggle() + } + } + .onHover { hovering in + isHovered = hovering + } + + configuration.label + } + } +} + +// MARK: - Hover Hand Cursor Modifier (macOS 11 compatible) +extension View { + func handCursorOnHover() -> some View { + self.onHover { hovering in + if hovering { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Components/TextPlaceholder.swift b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Components/TextPlaceholder.swift new file mode 100644 index 00000000..ac0dc8eb --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Components/TextPlaceholder.swift @@ -0,0 +1,81 @@ +// +// Untitled.swift +// SelfControl +// +// Created by Satendra Singh on 31/03/26. +// + +import SwiftUI + +// MARK: - Generic placeholder overlay (pure SwiftUI) + +public extension View { + /// Overlays a placeholder view on top of `self` when the provided condition is true. + /// This keeps taps going to the underlying field by disabling hit testing for the placeholder. + /// + /// Example: + /// TextField("", text: $text) + /// .placeholder(when: text.isEmpty, alignment: .leading) { + /// Text("Email") + /// .foregroundColor(.secondary) + /// .font(.body) + /// } + /// + /// - Parameters: + /// - shouldShow: When true, the placeholder is visible. + /// - alignment: Alignment for the placeholder inside the overlay. + /// - placeholder: The placeholder view builder. + /// - Returns: A view with the placeholder overlay applied. + func placeholder( + when shouldShow: Bool, + alignment: Alignment = .leading, + @ViewBuilder placeholder: () -> Placeholder + ) -> some View { + ZStack(alignment: alignment) { + if shouldShow { + placeholder() + .allowsHitTesting(false) // so taps focus the TextField + } + self + } + } + + /// Convenience for showing a simple `Text` placeholder with color and optional font, + /// using the same ZStack logic while keeping call sites concise. + /// + /// Example: + /// TextField("", text: $text) + /// .textPlaceholder("Email", + /// when: text.isEmpty, + /// color: .secondary, + /// font: .body, + /// verticalPadding: 8) + /// + /// - Parameters: + /// - title: The placeholder string to display. + /// - shouldShow: When true, the placeholder is visible. + /// - alignment: Alignment for the placeholder inside the overlay. + /// - color: Placeholder text color. + /// - font: Optional placeholder font. If nil, inherits from environment. + /// - verticalPadding: Optional vertical padding to match your field’s insets. + /// - Returns: A view with the placeholder overlay applied. + func textPlaceholder( + _ title: String, + when shouldShow: Bool, + alignment: Alignment = .leading, + color: Color = .secondary, + font: Font? = nil, + verticalPadding: CGFloat? = nil + ) -> some View { + placeholder(when: shouldShow, alignment: alignment) { + var text = Text(title).foregroundColor(color) + if let font { + text.font(font) + } + if let verticalPadding { + text.padding(.vertical, verticalPadding) + } + text + } + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Helpers/BlockedURLsHelpers.swift b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Helpers/BlockedURLsHelpers.swift new file mode 100644 index 00000000..84b46241 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Helpers/BlockedURLsHelpers.swift @@ -0,0 +1,117 @@ +// +// BlockedURLsHelpers.swift +// SelfControlUI +// +// Created on 2025-10-31. +// + +import Foundation + +// MARK: - Blocked URLs Helpers +struct BlockedURLsHelpers { + /// Get enabled URLs from the blocked URLs list + private static func enabledURLs(_ blockedURLs: [BlockedURL]) -> [BlockedURL] { + blockedURLs.filter { $0.isEnabled } + } + + /// Count active blocked domains (sites) + static func activeBlockedCount(_ blockedURLs: [BlockedURL]) -> Int { + enabledURLs(blockedURLs).count + } + + /// Count active paths across all enabled domains + static func activePathCount(_ blockedURLs: [BlockedURL]) -> Int { + enabledURLs(blockedURLs).reduce(0) { $0 + $1.paths.count } + } + + /// Format blocking count message with separate domain and path counts + static func blockingCountMessage(_ blockedURLs: [BlockedURL]) -> String { + let domainCount = activeBlockedCount(blockedURLs) + let pathCount = activePathCount(blockedURLs) + + if domainCount == 0 && pathCount == 0 { + return "0 \(Strings.BlocklistEditor.sites.lowercased())" + } + + var parts: [String] = [] + if domainCount > 0 { + parts.append(Strings.BlockedURLs.domainsText(domainCount)) + } + if pathCount > 0 { + parts.append(Strings.BlocklistEditor.pathCount(pathCount)) + } + + return parts.joined(separator: ", ") + } + + /// Generate unique identifier for blockedURLs state to force view updates + static func blockedURLsStateId(_ blockedURLs: [BlockedURL]) -> String { + let domainCount = activeBlockedCount(blockedURLs) + let pathCount = activePathCount(blockedURLs) + return "\(domainCount)_\(pathCount)_\(blockedURLs.count)" + } + + /// Format blocked sites text for display + static func blockedSitesText(_ blockedURLs: [BlockedURL]) -> String { + if blockedURLs.isEmpty { + return Strings.BlockedURLs.noSites + } + + let enabled = enabledURLs(blockedURLs) + + if enabled.isEmpty { + return Strings.BlockedURLs.allSitesDisabled + } + + // Count domains and paths separately + let domainCount = enabled.count + let totalPathCount = enabled.reduce(0) { $0 + $1.paths.count } + + // Only show path name if there's exactly 1 domain and exactly 1 path + if totalPathCount == 1 && domainCount == 1 { + if let url = enabled.first, let firstPath = url.paths.first { + // Remove leading slash if present for display + let displayPath = firstPath.hasPrefix("/") ? String(firstPath.dropFirst()) : firstPath + return "\(url.domain)/\(displayPath)" + } + } + + // Build domain count text + let domainText: String + if domainCount == 1 { + domainText = enabled.first!.domain + } else if domainCount != blockedURLs.count { + domainText = Strings.BlockedURLs.domainCount(domainCount, total: blockedURLs.count) + } else { + domainText = Strings.BlockedURLs.domainsText(domainCount) + } + + // Combine domain and path counts + if totalPathCount > 0 { + return "\(domainText), \(Strings.BlocklistEditor.pathCount(totalPathCount))" + } else { + return domainText + } + } + + /// Parse domain and path from input like "instagram.com/reels" + static func parseDomainAndPath(from input: String) -> (domain: String, path: String?) { + let cleaned = input.trimmingCharacters(in: .whitespaces).lowercased() + + // Check if input contains a path (has a / after the domain) + if let slashIndex = cleaned.firstIndex(of: "/") { + let domainPart = String(cleaned[.. Bool { + intensityLevel == .low || intensityLevel == .medium + } + + /// Check if stop confirmation is required based on intensity level + static func requiresStopConfirmation(intensityLevel: IntensityLevel) -> Bool { + intensityLevel == .medium + } +} + diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Helpers/IntensityHelpers.swift b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Helpers/IntensityHelpers.swift new file mode 100644 index 00000000..331376ca --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Helpers/IntensityHelpers.swift @@ -0,0 +1,32 @@ +// +// IntensityHelpers.swift +// SelfControlUI +// +// Created on 2025-10-31. +// + +import Foundation + +// MARK: - Intensity Helpers +struct IntensityHelpers { + /// Get next intensity level in the cycle + static func nextIntensity(current: IntensityLevel) -> IntensityLevel { + let allLevels = IntensityLevel.allCases + if let currentIndex = allLevels.firstIndex(of: current) { + let nextIndex = (currentIndex + 1) % allLevels.count + return allLevels[nextIndex] + } + return current + } + + /// Get previous intensity level in the cycle + static func previousIntensity(current: IntensityLevel) -> IntensityLevel { + let allLevels = IntensityLevel.allCases + if let currentIndex = allLevels.firstIndex(of: current) { + let previousIndex = (currentIndex - 1 + allLevels.count) % allLevels.count + return allLevels[previousIndex] + } + return current + } +} + diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Helpers/ScheduleBlockManager.swift b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Helpers/ScheduleBlockManager.swift new file mode 100644 index 00000000..93df0a72 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Helpers/ScheduleBlockManager.swift @@ -0,0 +1,39 @@ +// +// ScheduleBlockManager.swift +// SelfControl +// +// Created by Satendra Singh on 29/01/26. +// + +final class ScheduleBlockManager { + private var schedules: [Schedule] = [] + @MainActor + final class BlockedURLStore: ObservableObject { + + @Published + private(set) var schedules: [Schedule] = [] + + init() { + _ = loadSchedules() + } + + // Load schedules from UserDefaults + private func loadSchedules() -> [Schedule] { + if let data = UserDefaults.standard.data(forKey: "schedules"), + let decoded = try? JSONDecoder().decode([Schedule].self, from: data) { + schedules = decoded + } else { + // Initialize with one empty schedule if no saved data + schedules = [Schedule(timeSlots: [], enabledDays: [])] + } + return schedules + } + + // Save schedules to UserDefaults + private func saveSchedules(schedules: [Schedule]) { + if let encoded = try? JSONEncoder().encode(schedules) { + UserDefaults.standard.set(encoded, forKey: "schedules") + } + } + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Helpers/TimeHelpers.swift b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Helpers/TimeHelpers.swift new file mode 100644 index 00000000..4f7e1c27 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Helpers/TimeHelpers.swift @@ -0,0 +1,338 @@ +// +// TimeHelpers.swift +// SelfControlUI +// +// Created on 2025-10-31. +// + +import Foundation + +// MARK: - Time Formatting Helpers +struct TimeHelpers { + /// Format time display string from minutes, supporting easter egg mode with days + static func timeDisplay(minutes: Double, isEasterEggUnlocked: Bool, days: Int) -> String { + if isEasterEggUnlocked { + // Extract days, hours, and minutes + let daysInMinutes = days * 24 * 60 + let totalMins = Int(minutes) + let hoursAndMins = totalMins - daysInMinutes + let hours = max(0, hoursAndMins) / 60 + let mins = max(0, hoursAndMins) % 60 + + var parts: [String] = [] + if days > 0 { + parts.append("\(days)d") + } + if hours > 0 { + parts.append("\(hours)h") + } + if mins > 0 { + parts.append("\(mins)m") + } + return parts.isEmpty ? "0m" : parts.joined(separator: " ") + } + + let hours = Int(minutes) / 60 + let mins = Int(minutes) % 60 + + if hours > 0 { + return mins > 0 ? "\(hours)h \(mins)m" : "\(hours)h" + } + return "\(Int(minutes))m" + } + + /// Format countdown display string from remaining time + static func countdownDisplay(remainingTime: TimeInterval, hideSeconds: Bool) -> String { + let hours = Int(remainingTime) / 3600 + let mins = (Int(remainingTime) % 3600) / 60 + let secs = Int(remainingTime) % 60 + + if hideSeconds { + return String(format: "%02d:%02d", hours, mins) + } else { + return String(format: "%02d:%02d:%02d", hours, mins, secs) + } + } + + /// Format countdown time as mm:ss (for medium intensity stop confirmation) + static func formatCountdownTime(_ time: TimeInterval) -> String { + let minutes = Int(time) / 60 + let seconds = Int(time) % 60 + return String(format: "%02d:%02d", minutes, seconds) + } + + /// Extract time components from minutes for editing + static func extractTimeComponents(minutes: Double, isEasterEggUnlocked: Bool, days: Int) -> (days: Int, hours: Int, minutes: Int) { + if isEasterEggUnlocked { + let daysInMinutes = days * 24 * 60 + let totalMins = Int(minutes) + let hoursAndMins = totalMins - daysInMinutes + let hours = max(0, hoursAndMins) / 60 + let mins = max(0, hoursAndMins) % 60 + return (days: days, hours: hours, minutes: mins) + } + + let hours = Int(minutes) / 60 + let mins = Int(minutes) % 60 + return (days: 0, hours: hours, minutes: mins) + } + + // MARK: - Time Input Calculation Helpers + + /// Result structure for time calculations + struct TimeCalculationResult { + let days: Int + let daysInput: String + let hours: Int + let hoursInput: String + let minutes: Int + let minutesInput: String + let totalMinutes: Double + } + + /// Calculate minutes from hours and minutes inputs (non-easter egg mode) + static func calculateMinutesFromInputs(hoursInput: String, minutesInput: String, isEasterEggUnlocked: Bool) -> (hours: Int, hoursInput: String, minutes: Int, minutesInput: String, totalMinutes: Double) { + var hours = Int(hoursInput) ?? 0 + var mins = Int(minutesInput) ?? 0 + + if !isEasterEggUnlocked { + // For non-easter egg version, handle minutes overflow first + if mins >= 60 { + let additionalHours = mins / 60 + hours += additionalHours + mins = mins % 60 + } + + // Then clamp hours to 24 max + hours = min(24, max(0, hours)) + } + + let finalHoursInput = hours == 0 ? "0" : String(hours) + let finalMinutesInput = mins == 0 ? "0" : String(mins) + + let totalMinutes = Double(hours * 60 + mins) + let maxMinutes = isEasterEggUnlocked ? Double.greatestFiniteMagnitude : 1440 + let clampedMinutes = max(0, min(maxMinutes, totalMinutes)) + + return (hours: hours, hoursInput: finalHoursInput, minutes: mins, minutesInput: finalMinutesInput, totalMinutes: clampedMinutes) + } + + /// Calculate minutes from days, hours, and minutes inputs (easter egg mode) + static func calculateMinutesFromAllInputs(daysInput: String, hoursInput: String, minutesInput: String, currentDays: Int, isEasterEggUnlocked: Bool) -> TimeCalculationResult { + var inputDays = Int(daysInput) ?? currentDays + var inputHours = Int(hoursInput) ?? 0 + var inputMins = Int(minutesInput) ?? 0 + + if isEasterEggUnlocked { + // First, handle minutes overflow -> convert to hours + if inputMins >= 60 { + let additionalHours = inputMins / 60 + inputHours += additionalHours + inputMins = inputMins % 60 + } + + // Then, handle hours overflow -> convert to days + if inputHours >= 24 { + let additionalDays = inputHours / 24 + let newTotalDays = inputDays + additionalDays + + // If total would exceed 1000, cap at 1000 and keep excess in hours + if newTotalDays > 1000 { + let daysToAdd = 1000 - inputDays + inputDays = 1000 + inputHours = inputHours - (daysToAdd * 24) + } else { + inputDays = newTotalDays + inputHours = inputHours % 24 + } + } + + // Final clamp to ensure we never exceed 1000 + inputDays = min(1000, max(0, inputDays)) + inputHours = max(0, inputHours) + inputMins = max(0, min(59, inputMins)) + + let totalMinutes = Double(inputDays * 24 * 60 + inputHours * 60 + inputMins) + + return TimeCalculationResult( + days: inputDays, + daysInput: inputDays == 0 ? "0" : String(inputDays), + hours: inputHours, + hoursInput: inputHours == 0 ? "0" : String(inputHours), + minutes: inputMins, + minutesInput: inputMins == 0 ? "0" : String(inputMins), + totalMinutes: max(0, totalMinutes) + ) + } else { + // Fall back to non-easter egg calculation + let result = calculateMinutesFromInputs(hoursInput: hoursInput, minutesInput: minutesInput, isEasterEggUnlocked: false) + return TimeCalculationResult( + days: 0, + daysInput: "0", + hours: result.hours, + hoursInput: result.hoursInput, + minutes: result.minutes, + minutesInput: result.minutesInput, + totalMinutes: result.totalMinutes + ) + } + } + + /// Adjust days by amount (easter egg mode) + static func adjustDays(currentDaysInput: String, currentDays: Int, amount: Int) -> (days: Int, daysInput: String) { + let currentDays = Int(currentDaysInput) ?? currentDays + let newDays = min(1000, max(0, currentDays + amount)) + return (days: newDays, daysInput: newDays == 0 ? "0" : String(newDays)) + } + + /// Adjust hours by amount + static func adjustHours(daysInput: String, hoursInput: String, minutesInput: String, currentDays: Int, currentMinutes: Double, amount: Int, isEasterEggUnlocked: Bool) -> TimeCalculationResult { + if isEasterEggUnlocked { + var currentDays = Int(daysInput) ?? currentDays + var currentHours = Int(hoursInput) ?? (Int(currentMinutes) / 60 % 24) + + currentHours += amount + + // Auto-calculate days from hours + if currentHours >= 24 { + let additionalDays = currentHours / 24 + currentDays += additionalDays + currentHours = currentHours % 24 + } else if currentHours < 0 && currentDays > 0 { + // Borrow from days if hours go negative + let borrowDays = (-currentHours + 23) / 24 + currentDays = max(0, currentDays - borrowDays) + currentHours = currentHours + (borrowDays * 24) + } + + // Clamp days to max 1000 + currentDays = min(1000, max(0, currentDays)) + + return calculateMinutesFromAllInputs( + daysInput: currentDays == 0 ? "0" : String(currentDays), + hoursInput: currentHours == 0 ? "0" : String(max(0, currentHours)), + minutesInput: minutesInput, + currentDays: currentDays, + isEasterEggUnlocked: true + ) + } else { + // Original behavior for editing mode + let currentHours = Int(hoursInput) ?? 0 + let newHours = max(0, min(24, currentHours + amount)) + let result = calculateMinutesFromInputs( + hoursInput: String(newHours), + minutesInput: minutesInput, + isEasterEggUnlocked: false + ) + return TimeCalculationResult( + days: 0, + daysInput: "0", + hours: result.hours, + hoursInput: result.hoursInput, + minutes: result.minutes, + minutesInput: result.minutesInput, + totalMinutes: result.totalMinutes + ) + } + } + + /// Adjust minutes by amount + static func adjustMinutes(daysInput: String, hoursInput: String, minutesInput: String, currentDays: Int, currentMinutes: Double, amount: Int, isEasterEggUnlocked: Bool) -> TimeCalculationResult { + if isEasterEggUnlocked { + var currentDays = Int(daysInput) ?? currentDays + var currentHours = Int(hoursInput) ?? (Int(currentMinutes) / 60 % 24) + var currentMins = Int(minutesInput) ?? (Int(currentMinutes) % 60) + + currentMins += amount + var carryHours = 0 + + if currentMins >= 60 { + carryHours = currentMins / 60 + currentMins = currentMins % 60 + } else if currentMins < 0 { + carryHours = (currentMins - 59) / 60 + currentMins = ((currentMins % 60) + 60) % 60 + } + + currentHours += carryHours + + // Auto-calculate days from hours + if currentHours >= 24 { + let additionalDays = currentHours / 24 + currentDays += additionalDays + currentHours = currentHours % 24 + } else if currentHours < 0 && currentDays > 0 { + let borrowDays = (-currentHours + 23) / 24 + currentDays = max(0, currentDays - borrowDays) + currentHours = currentHours + (borrowDays * 24) + } + + // Clamp days to max 1000 + currentDays = min(1000, max(0, currentDays)) + + return TimeCalculationResult( + days: currentDays, + daysInput: currentDays == 0 ? "0" : String(currentDays), + hours: currentHours, + hoursInput: currentHours == 0 ? "0" : String(max(0, currentHours)), + minutes: currentMins, + minutesInput: currentMins == 0 ? "0" : String(max(0, currentMins)), + totalMinutes: Double(currentDays * 24 * 60 + currentHours * 60 + currentMins) + ) + } else { + // Original behavior for editing mode + let currentMins = Int(minutesInput) ?? 0 + var newMins = currentMins + amount + var carryHours = 0 + + if newMins >= 60 { + carryHours = 1 + newMins = 0 + } else if newMins < 0 { + carryHours = -1 + newMins = 59 + } + + // If there's a carry, adjust hours directly + var newHours = Int(hoursInput) ?? 0 + if carryHours != 0 { + newHours += carryHours + newHours = max(0, min(24, newHours)) + } + + // Calculate final result + let result = calculateMinutesFromInputs( + hoursInput: String(newHours), + minutesInput: String(newMins), + isEasterEggUnlocked: false + ) + return TimeCalculationResult( + days: 0, + daysInput: "0", + hours: result.hours, + hoursInput: result.hoursInput, + minutes: result.minutes, + minutesInput: result.minutesInput, + totalMinutes: result.totalMinutes + ) + } + } + + // MARK: - Date/Time Conversion Helpers + + /// Create a Date from hour and minute components + static func dateFromTime(hour: Int, minute: Int) -> Date { + var components = DateComponents() + components.hour = hour + components.minute = minute + return Calendar.current.date(from: components) ?? Date() + } + + /// Extract hour and minute from a Date + static func timeFromDate(_ date: Date) -> (hour: Int, minute: Int) { + let components = Calendar.current.dateComponents([.hour, .minute], from: date) + return (components.hour ?? 0, components.minute ?? 0) + } + +} + diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Models/AppModels.swift b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Models/AppModels.swift new file mode 100644 index 00000000..f6c48d9a --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Models/AppModels.swift @@ -0,0 +1,139 @@ +// +// AppModels.swift +// SelfControlUI +// +// Created on 2025-10-31. +// + +import Foundation + +// MARK: - Blocked URL Model +struct BlockedURL: Identifiable, Codable, Equatable { + let id: UUID + var domain: String + var paths: [String] + var isEnabled: Bool + var blockEntireDomain: Bool + + init(id: UUID = UUID(), domain: String, paths: [String] = [], isEnabled: Bool = true, blockEntireDomain: Bool = false) { + self.id = id + self.domain = domain + self.paths = paths + self.isEnabled = isEnabled + self.blockEntireDomain = blockEntireDomain + } + + var urls: [String]? { + if isEnabled == false { return nil } + if paths.count == 0 || blockEntireDomain { + return [domain] + } else { + return paths.map { path in + let path = path.hasPrefix("/") ? path : "/\(path)" + return "\(domain)\(path)" + } + } + } +} + +// MARK: - App Navigation +enum AppScreen: Equatable { + case main + case editList + case domainDetail(UUID) + case advancedSettings + case blockSchedule + case about + + static func == (lhs: AppScreen, rhs: AppScreen) -> Bool { + switch (lhs, rhs) { + case (.main, .main): + return true + case (.editList, .editList): + return true + case (.domainDetail(let lhsId), .domainDetail(let rhsId)): + return lhsId == rhsId + case (.advancedSettings, .advancedSettings): + return true + case (.blockSchedule, .blockSchedule): + return true + case (.about, .about): + return true + default: + return false + } + } +} + +// MARK: - Blocking Mode +enum BlockingMode: String { + case blocklist + case allowlist +} + +// MARK: - Intensity Level +enum IntensityLevel: String, Codable, CaseIterable { + case low = "low" + case medium = "medium" + case high = "high" + + var displayName: String { + switch self { + case .low: + return "Low" + case .medium: + return "Medium" + case .high: + return "High" + } + } + + var description: String { + switch self { + case .low: + return "Stop blocking anytime with one click." + case .medium: + return "Added friction with a 10 minute wait." + case .high: + return "No way to stop blocking early." + } + } +} + + +// MARK: - About Page Item +enum AboutItemType { + case text + case link +} + +struct AboutItem { + let type: AboutItemType + let text: String + let url: String? + + init(text: String) { + self.type = .text + self.text = text + self.url = nil + } + + init(text: String, url: String) { + self.type = .link + self.text = text + self.url = url + } +} + +// MARK: - Tip Model +struct Tip: Identifiable { + let id: UUID + let title: String + let description: String + + init(id: UUID = UUID(), title: String, description: String) { + self.id = id + self.title = title + self.description = description + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Models/BlockedURLStore.swift b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Models/BlockedURLStore.swift new file mode 100644 index 00000000..a60d0430 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Models/BlockedURLStore.swift @@ -0,0 +1,131 @@ +import Foundation + +// A store that persists [BlockedURL] in UserDefaults using JSON encoding. +@MainActor +final class BlockedURLStore: ObservableObject { + @Published private(set) var items: [BlockedURL] = [] + + private let defaults: UserDefaults + private let key: String + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + // If you need an app group, pass: UserDefaults(suiteName: "group.your.identifier")! + init( + key: String = "BlockedURLItems", + defaults: UserDefaults = .standard + ) { + self.key = key + self.defaults = defaults + load() + } + + func load() { + guard let data = defaults.data(forKey: key) else { + items = [] + return + } + do { + items = try decoder.decode([BlockedURL].self, from: data) + } catch { + #if DEBUG + print("Failed to decode [BlockedURL]: \(error)") + #endif + items = [] + } + } + + func save() { + do { + let data = try encoder.encode(items) + defaults.set(data, forKey: key) + } catch { + #if DEBUG + print("Failed to encode [BlockedURL]: \(error)") + #endif + } + } + + // Replace entire array + func set(_ newItems: [BlockedURL]) { + items = newItems + save() + } + + // Append a new entry + func append(_ item: BlockedURL) { + items.append(item) + save() + } + + // Insert or update by id + func upsert(_ item: BlockedURL) { + if let idx = items.firstIndex(where: { $0.id == item.id }) { + items[idx] = item + } else { + items.append(item) + } + save() + } + + // Remove by id + func remove(id: UUID) { + if let idx = items.firstIndex(where: { $0.id == id }) { + items.remove(at: idx) + save() + } + } + + // Remove via IndexSet (useful for List.onDelete) + func remove(at offsets: IndexSet) { + items.remove(atOffsets: offsets) + save() + } + + // Toggle enable/disable for a specific id + func setEnabled(_ isEnabled: Bool, for id: UUID) { + guard let idx = items.firstIndex(where: { $0.id == id }) else { return } + items[idx].isEnabled = isEnabled + save() + } + + // Convenience: check if a given URL string matches any enabled blocked entry. + // This compares against the computed urls strings of enabled items. + func isBlocked(urlString: String) -> Bool { + let normalized = Self.normalize(urlString: urlString) + for item in items where item.isEnabled { + guard let itemURLs = item.urls else { continue } + if itemURLs.contains(where: { Self.normalize(urlString: $0) == normalized }) { + return true + } + } + return false + } + + // Optional normalization helper for consistent comparisons. + private static func normalize(urlString: String) -> String { + // If it's a full URL, normalize via URLComponents; otherwise, lowercased trim. + if let url = URL(string: urlString), + var comps = URLComponents(url: url, resolvingAgainstBaseURL: false) { + + // Clear fragment and query + comps.fragment = nil + comps.query = nil + + // Remove trailing slash from path unless it's just "/" + var path = comps.path + if path.count > 1, path.hasSuffix("/") { + path.removeLast() + comps.path = path + } + + // Default scheme if missing + if comps.scheme == nil { + comps.scheme = "http" + } + + // Continue with your logic using `comps`... + } + return urlString.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/NewContentView.swift b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/NewContentView.swift new file mode 100644 index 00000000..f7df8706 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/NewContentView.swift @@ -0,0 +1,1542 @@ +// +// ContentView.swift +// SelfControlUI +// +// Created by Vidya Giri on 10/30/25. +// + +import SwiftUI +import AppKit + +struct NewContentView: View { + + @EnvironmentObject var viewModel: FilterViewModel + + @State private var minutes: Double = 11 + @State private var blockedURLs: [BlockedURL] = [] + @State private var currentScreen: AppScreen = .main + @State private var isBlocking = false + @State private var isHoveringButton = false + @State private var remainingTime: TimeInterval = 0 + @State private var timer: Timer? + @State private var focusedField: FocusField? + @State private var showingConfirmation = false + @AppStorage("dontShowConfirmation") private var dontShowConfirmation = false + @AppStorage("blockingMode") private var blockingMode: BlockingMode = .blocklist + @AppStorage("hideSeconds") private var hideSeconds = false + @AppStorage("hasSeenTips") private var hasSeenTips = false + @AppStorage("intensityLevel") private var intensityLevel: IntensityLevel = .low + @State private var showingTips = false + @State private var isHoveringIntensity = false + @State private var isEditingTime = false + @State private var hoursInput = "" + @State private var minutesInput = "" + @State private var isHoveringTime = false + @State private var isAdjustingTime = false + @State private var logoRotation: Double = 0 + @State private var isEasterEggUnlocked: Bool = false + @State private var days: Int = 1 + @State private var daysInput: String = "1" + @State private var logoDragOffset: CGSize = .zero + @State private var hasReachedEdge: Bool = false + @State private var isHoveringEditButton = false + @State private var isHoveringDoneButton = false + @State private var showingStopConfirmation = false + @State private var stopCountdownTime: TimeInterval = 600 + @State private var stopCountdownTimer: Timer? + @State private var isHoveringStopButton = false + @State private var isPressingStartButton = false + @State private var showingIntensityDropdown = false + @State private var showingExtendTimer = false + @State private var extensionMinutes: Double = 60 + @State private var isHoveringExtendTimer = false + @State private var isHoveringAddToList = false + @AppStorage("isScheduleActive") private var isScheduleActive = false + @State private var isHoveringSchedule = false + + enum FocusField: Hashable { + case slider + case timeField + case startButton + case editButton + case addToBlocklist + } + + var activeBlockedCount: Int { + BlockedURLsHelpers.activeBlockedCount(blockedURLs) + } + + var activePathCount: Int { + BlockedURLsHelpers.activePathCount(blockedURLs) + } + + // Format blocking count message with separate domain and path counts + var blockingCountMessage: String { + BlockedURLsHelpers.blockingCountMessage(blockedURLs) + } + + // Unique identifier for the blockedURLs state to force view updates + var blockedURLsStateId: String { + BlockedURLsHelpers.blockedURLsStateId(blockedURLs) + } + + var timeDisplay: String { + TimeHelpers.timeDisplay(minutes: minutes, isEasterEggUnlocked: isEasterEggUnlocked, days: days) + } + + var countdownDisplay: String { + TimeHelpers.countdownDisplay(remainingTime: remainingTime, hideSeconds: hideSeconds) + } + + @ViewBuilder + var currentScreenView: some View { + switch currentScreen { + case .main: + mainView + case .editList: + BlocklistEditorView( + blockedURLs: $blockedURLs, isBlockingMode: isBlocking, + blockingMode: $blockingMode, + currentScreen: $currentScreen, + showingTips: $showingTips + ) + case .domainDetail(let domainId): + DomainDetailView( + blockedURLs: $blockedURLs, + domainId: domainId, + isBlockingMode: isBlocking, + currentScreen: $currentScreen, + blockingMode: $blockingMode + ) + case .advancedSettings: + AdvancedSettingsView(currentScreen: $currentScreen, blockingMode: $blockingMode, isBlockingMode: isBlocking) + case .blockSchedule: + BlockScheduleView(currentScreen: $currentScreen, blockingMode: $blockingMode, isScheduleActive: $isScheduleActive) + case .about: + AboutView(currentScreen: $currentScreen) + } + } + + var body: some View { + currentScreenView + .frame(maxWidth: .infinity, maxHeight: .infinity) + .frame(minWidth: 520, minHeight: 420) + .id(blockedURLsStateId) // Force view refresh when blockedURLs changes + .alert(isPresented: $showingConfirmation) { + let messageText: Text = { + let countText = blockingCountMessage + return Text(Strings.MainScreen.startBlockingMessage(blockingMode: blockingMode, countText: countText, timeDisplay: timeDisplay)) + }() + return Alert( + title: Text(Strings.MainScreen.startBlockingTitle), + message: messageText, + primaryButton: .default(Text(Strings.MainScreen.startBlock), action: { startBlocking() }), + secondaryButton: .cancel(Text(Strings.Common.cancel)) + ) + } + .overlay( + Group { + if showingTips { + TipsOverlayView( + onDismiss: { + showingTips = false + hasSeenTips = true + } + ) + .zIndex(1000) + .allowsHitTesting(true) + } + if showingStopConfirmation { + StopConfirmationOverlayView( + countdownTime: stopCountdownTime, + onResume: { + resumeBlocking() + }, + onStopBlocking: { + stopBlocking() + } + ) + .zIndex(1000) + .allowsHitTesting(true) + } + if showingIntensityDropdown { + IntensityDropdownOverlay( + selectedLevel: $intensityLevel, + onDismiss: { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + showingIntensityDropdown = false + } + } + ) + .zIndex(999) + .allowsHitTesting(true) + } + if showingExtendTimer { + ExtendTimerOverlayView( + extensionMinutes: $extensionMinutes, + onConfirm: { minutes in + extendTimer(by: minutes) + HelperConnection.shared.send_extendBlocking(minutes: Int(minutes)) + viewModel.extendBlockTimer(by: Int(minutes)) + withAnimation(DesignSystem.animationNormal) { + showingExtendTimer = false + } + }, + onDismiss: { + withAnimation(DesignSystem.animationNormal) { + showingExtendTimer = false + } + } + ) + .zIndex(998) + .allowsHitTesting(true) + } + } + ) + .onAppear { + // Show tips on first launch only + if !hasSeenTips { + // Small delay to ensure view is fully loaded + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + showingTips = true + // Clear focus when tips modal shows + focusedField = nil + } + } else { + // Focus slider on startup + focusedField = .slider + } + blockedURLs = viewModel.blockerStorage?.items ?? [] + } + .onChange(of: showingTips) { isShowing in + // Clear focus when tips modal is shown + if isShowing { + focusedField = nil + } else { + // Set focus to slider after tips are dismissed (if not editing time) + if !isEditingTime { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + focusedField = .slider + } + } + } + } + .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("ShowAboutScreen"))) { _ in + currentScreen = .about + } + .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("ShowEditListScreen"))) { _ in + currentScreen = .editList + } + .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("ShowAdvancedSettingsScreen"))) { _ in + currentScreen = .advancedSettings + } + .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("ShowBlockScheduleScreen"))) { _ in + currentScreen = .blockSchedule + } + .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("ShowTipsScreen"))) { _ in + showingTips = true + } + } + + var mainView: some View { + ZStack { + // Dark gradient background + DesignSystem.backgroundGradient + .ignoresSafeArea(.all) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onTapGesture { + if showingIntensityDropdown { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + showingIntensityDropdown = false + } + } + } + + if isBlocking { + // Countdown mode - large timer display + VStack(spacing: 20) { + Spacer() + + // Logo + if let nsImage = NSImage(named: "icon") { + Image(nsImage: nsImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 64, height: 64) + } else { + Image(systemName: "exclamationmark.triangle.fill") + .font(DesignSystem.font(size: DesignSystem.fontSizeDisplayXXXL)) + .foregroundColor(DesignSystem.accentOrange) + } + + // Large countdown timer + Text(countdownDisplay) + .font(DesignSystem.font(size: DesignSystem.fontSizeDisplayHuge, weight: DesignSystem.fontWeightBold, design: .monospaced)) + .overlay( + LinearGradient( + gradient: Gradient(colors: [ + DesignSystem.textPrimary.opacity(DesignSystem.opacityAlmost), + DesignSystem.textPrimary.opacity(DesignSystem.opacityHigher) + ]), + startPoint: .top, + endPoint: .bottom + ) + .mask( + Text(countdownDisplay) + .font(DesignSystem.font(size: DesignSystem.fontSizeDisplayHuge, weight: DesignSystem.fontWeightBold, design: .monospaced)) + ) + ) + .shadow(color: DesignSystem.overlayBackground.opacity(DesignSystem.opacityHigh), radius: 2, x: 0, y: 2) + + // Blocking info + VStack(spacing: DesignSystem.spacingXSmall) { + if blockingMode == .blocklist { + if activeBlockedCount == 0 { + Text(Strings.MainScreen.noSitesBlocked) + .font(DesignSystem.font(size: DesignSystem.fontSizeLarge, weight: DesignSystem.fontWeightSemibold)) + .foregroundColor(DesignSystem.textSecondary) + Text(Strings.MainScreen.blocklistEmpty) + .font(DesignSystem.font(size: DesignSystem.fontSizeMedium)) + .foregroundColor(DesignSystem.textTertiary) + } else { + Text("\(Strings.MainScreen.blocking) \(blockingCountMessage)") + .font(DesignSystem.font(size: DesignSystem.fontSizeLarge, weight: DesignSystem.fontWeightSemibold)) + .foregroundColor(DesignSystem.textSecondary) + .onTapGesture { + currentScreen = .editList + } + } + } else { + if activeBlockedCount == 0 { + Text(Strings.MainScreen.noSitesAllowed) + .font(DesignSystem.font(size: DesignSystem.fontSizeLarge, weight: DesignSystem.fontWeightSemibold)) + .foregroundColor(DesignSystem.textSecondary) + Text(Strings.MainScreen.allWebsitesBlocked) + .font(DesignSystem.font(size: DesignSystem.fontSizeMedium)) + .foregroundColor(DesignSystem.textTertiary) + } else { + Text("\(Strings.MainScreen.allowing) \(blockingCountMessage)") + .font(DesignSystem.font(size: DesignSystem.fontSizeLarge, weight: DesignSystem.fontWeightSemibold)) + .foregroundColor(DesignSystem.textSecondary) + Text(Strings.MainScreen.allOtherSitesBlocked) + .font(DesignSystem.font(size: DesignSystem.fontSizeMedium)) + .foregroundColor(DesignSystem.textTertiary) + } + } + } + .padding(.bottom, DesignSystem.spacingMedium) + + // Add to list and Extend Timer buttons (chip style) + HStack(spacing: DesignSystem.spacingSmall) { + Button(action: { + showingExtendTimer = true + }) { + HStack(spacing: 4) { + Image(systemName: "clock") + .font(DesignSystem.font(size: DesignSystem.fontSizeBase)) + Text("Extend Timer") + .font(DesignSystem.font(size: DesignSystem.fontSizeBase, weight: DesignSystem.fontWeightMedium)) + } + .foregroundColor(.white) + .padding(.horizontal, DesignSystem.spacingSmall) + .padding(.vertical, DesignSystem.spacingXSmall) + .background( + RoundedRectangle(cornerRadius: DesignSystem.radiusMedium) + .fill(isHoveringExtendTimer ? DesignSystem.hoverBackground : Color.clear) + ) + .modifier(DesignSystem.buttonStyle(color: .white)) + } + .buttonStyle(.plain) + .focusable(false) + .onHover { hovering in + withAnimation(DesignSystem.animationFast) { + isHoveringExtendTimer = hovering + } + } + + Button(action: { + currentScreen = .editList + }) { + HStack(spacing: 4) { + Image(systemName: "plus.circle") + .font(DesignSystem.font(size: DesignSystem.fontSizeBase)) + Text(blockingMode == .blocklist ? Strings.MainScreen.addToBlocklist : Strings.MainScreen.addToAllowlist) + .font(DesignSystem.font(size: DesignSystem.fontSizeBase, weight: DesignSystem.fontWeightMedium)) + } + .foregroundColor(.white) + .padding(.horizontal, DesignSystem.spacingSmall) + .padding(.vertical, DesignSystem.spacingXSmall) + .background( + RoundedRectangle(cornerRadius: DesignSystem.radiusMedium) + .fill(isHoveringAddToList ? DesignSystem.hoverBackground : Color.clear) + ) + .modifier(DesignSystem.buttonStyle(color: .white)) + } + .buttonStyle(.plain) + .focusable(false) + .keyboardShortcut("a", modifiers: .command) + .onHover { hovering in + withAnimation(DesignSystem.animationFast) { + isHoveringAddToList = hovering + } + } + } + .padding(.bottom, DesignSystem.spacingSmall) + + // Stop Blocking button (only for low and medium intensity) + if intensityLevel == .low || intensityLevel == .medium { + Button(action: { + if intensityLevel == .low { + // Low intensity: stop immediately + stopBlocking() + } else { + // Medium intensity: show confirmation with countdown + showStopConfirmation() + } + }) { + Text(Strings.MainScreen.stopBlocking) + .font(DesignSystem.font(size: DesignSystem.fontSizeXLarge, weight: DesignSystem.fontWeightMedium)) + .foregroundColor(DesignSystem.textPrimary) + .frame(maxWidth: .infinity) + .frame(height: 44) + .contentShape(Rectangle()) + .background( + RoundedRectangle(cornerRadius: DesignSystem.radiusXXLarge) + .fill(isHoveringStopButton ? DesignSystem.hoverBackground : Color.clear) + ) + .modifier(DesignSystem.buttonStyle(color: .white)) + .clipShape(RoundedRectangle(cornerRadius: DesignSystem.radiusXXLarge)) + } + .buttonStyle(.plain) + .help(intensityLevel == .low ? Strings.Help.stopBlockingImmediately : Strings.Help.stopBlockingWithConfirmation) + .focusable(false) + .onHover { hovering in + withAnimation(DesignSystem.animationFast) { + isHoveringStopButton = hovering + } + } + .padding(.horizontal, DesignSystem.spacingLarge) + .padding(.bottom, DesignSystem.spacingXLargePlusMedium) + } else { + // Add spacer for high intensity to maintain consistent layout + Spacer() + .frame(height: DesignSystem.spacingXLargePlusMedium) + } + } + } else { + // Configuration mode + VStack(spacing: 0) { + // Logo at top (draggable easter egg) + GeometryReader { geometry in + HStack { + Spacer() + ZStack { + // Background view to prevent window dragging + NonDraggableBackgroundView() + .frame(width: 56, height: 56) + + Group { + if let nsImage = NSImage(named: "AppIcon") { + Image(nsImage: nsImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 48, height: 48) + } else { + Image(systemName: "shield.lefthalf.filled") + .font(DesignSystem.font(size: DesignSystem.fontSizeDisplayXL)) + .foregroundColor(DesignSystem.textPrimary) + } + } + .rotationEffect(.degrees(logoRotation)) + .offset(logoDragOffset) + .contentShape(Rectangle()) + .gesture( + isEasterEggUnlocked ? nil : DragGesture() + .onChanged { value in + let logoSize: CGFloat = 48 + let windowWidth = geometry.size.width + + // Get window bounds for vertical edge detection + let windowHeight: CGFloat + if let window = NSApplication.shared.windows.first { + windowHeight = window.contentView?.bounds.height ?? 450 + } else { + windowHeight = 450 // fallback + } + + // Calculate where logo center would be (starts at window center horizontally, near top vertically) + let centerX = windowWidth / 2 + let logoCenterX = centerX + value.translation.width + + // For vertical, logo starts near top (approximately 40-50px from top) + // Check if it reaches top or bottom edge + let approximateTopOffset: CGFloat = 50 + let logoCenterY = approximateTopOffset + value.translation.height + + // Check if logo reaches any edge + let halfLogo = logoSize / 2 + let reachedLeftEdge = logoCenterX - halfLogo <= 0 + let reachedRightEdge = logoCenterX + halfLogo >= windowWidth + let reachedTopEdge = logoCenterY - halfLogo <= 0 + let reachedBottomEdge = logoCenterY + halfLogo >= windowHeight + let reachedAnyEdge = reachedLeftEdge || reachedRightEdge || reachedTopEdge || reachedBottomEdge + + if reachedAnyEdge && !hasReachedEdge { + hasReachedEdge = true + } + + // Apply rubberbanding effect when dragging beyond edge + var newOffset = value.translation + + // Horizontal rubberbanding + if reachedLeftEdge { + let edgePosition = -centerX + halfLogo + let overshoot = abs(value.translation.width - edgePosition) + newOffset.width = edgePosition - overshoot * 0.3 + } else if reachedRightEdge { + let edgePosition = centerX - halfLogo + let overshoot = abs(value.translation.width - edgePosition) + newOffset.width = edgePosition + overshoot * 0.3 + } + + // Vertical rubberbanding + if reachedTopEdge { + let edgePosition = -approximateTopOffset + halfLogo + let overshoot = abs(value.translation.height - edgePosition) + newOffset.height = edgePosition - overshoot * 0.3 + } else if reachedBottomEdge { + let edgePosition = windowHeight - approximateTopOffset - halfLogo + let overshoot = abs(value.translation.height - edgePosition) + newOffset.height = edgePosition + overshoot * 0.3 + } + + logoDragOffset = newOffset + } + .onEnded { value in + guard !isEasterEggUnlocked else { return } + + // If we reached the edge, bounce back and unlock + if hasReachedEdge { + // Bounce back to center + withAnimation(.spring(response: 0.5, dampingFraction: 0.6)) { + logoDragOffset = .zero + } + + // After bounce back, spin and unlock + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + // Spin animation + withAnimation(.easeOut(duration: 1.5)) { + logoRotation = 1080 // 3 full spins + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + withAnimation(.spring(response: 0.6, dampingFraction: 0.7)) { + isEasterEggUnlocked = true + logoRotation = 0 + logoDragOffset = .zero + hasReachedEdge = false + // Initialize to 1 day when unlocked + days = 1 + daysInput = "1" + hoursInput = "0" + minutesInput = "0" + minutes = Double(days * 24 * 60) + } + } + } + } else { + // Normal drag end - bounce back to center + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + logoDragOffset = .zero + hasReachedEdge = false + } + } + } + ) + } + .frame(width: 56, height: 56) + Spacer() + } + .offset(y: -16) + .padding(.top, DesignSystem.spacingLarge) + .padding(.bottom, DesignSystem.spacingXXSmall) + } + .frame(height: 70) + + // Schedule indicator pill below logo + HStack { + Spacer() + Button(action: { + currentScreen = .blockSchedule + }) { + HStack(spacing: 4) { + Circle() + .fill(isScheduleActive ? Color.green.opacity(0.7) : Color.gray.opacity(0.5)) + .frame(width: 6, height: 6) + Text(isScheduleActive ? "Schedule On" : "Schedule Off") + .font(DesignSystem.font(size: DesignSystem.fontSizeXSmall)) + .foregroundColor(Color.gray.opacity(0.7)) + } + .padding(.horizontal, DesignSystem.spacingSmall) + .padding(.vertical, DesignSystem.spacingXSmall) + .background( + Capsule() + .stroke(Color.gray.opacity(isHoveringSchedule ? 0.4 : 0.25), lineWidth: 1) + .background( + Capsule() + .fill(isHoveringSchedule ? Color.gray.opacity(0.1) : Color.clear) + ) + ) + } + .buttonStyle(.plain) + .onHover { hovering in + withAnimation(DesignSystem.animationFast) { + isHoveringSchedule = hovering + } + } + Spacer() + } + .padding(.bottom, DesignSystem.spacingSmall) + + Spacer() + + // Main content with consistent spacing + VStack(spacing: DesignSystem.spacingMedium) { + // Time slider card + VStack(spacing: DesignSystem.spacingMedium) { + HStack(alignment: .top, spacing: DesignSystem.spacingXSmall) { + Image(systemName: "clock.fill") + .font(DesignSystem.font(size: DesignSystem.fontSizeTitle)) + .overlay( + LinearGradient( + gradient: Gradient(colors: [ + DesignSystem.textPrimary.opacity(DesignSystem.opacityAlmost), + DesignSystem.textPrimary.opacity(DesignSystem.opacityHigher) + ]), + startPoint: .top, + endPoint: .bottom + ) + .mask( + Image(systemName: "clock.fill") + .font(DesignSystem.font(size: DesignSystem.fontSizeTitle)) + ) + ) + .shadow(color: DesignSystem.overlayBackground.opacity(DesignSystem.opacityHigh), radius: 2, x: 0, y: 2) + .padding(.top, DesignSystem.spacingSmallMedium) + + if isEditingTime && !isEasterEggUnlocked { + HStack(spacing: 8) { + HStack(spacing: 6) { + // Hours + HStack(spacing: 2) { + ArrowKeyTextField( + text: $hoursInput, + onUpArrow: { + isAdjustingTime = true + adjustHours(by: 1) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + isAdjustingTime = false + } + }, + onDownArrow: { + isAdjustingTime = true + adjustHours(by: -1) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + isAdjustingTime = false + } + }, + onSubmit: { + updateMinutesFromInputs() + commitTimeEdit() + }, + onFocusChange: { hasFocus in + if !hasFocus { + updateMinutesFromInputs() + } + } + ) + .frame(width: max(40, CGFloat(hoursInput.count) * 20 + 10)) + + VStack(spacing: 2) { + Button(action: { + focusedField = nil + isAdjustingTime = true + adjustHours(by: 1) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + isAdjustingTime = false + } + }) { + Image(systemName: "chevron.up") + .font(DesignSystem.font(size: DesignSystem.fontSizeXSmall, weight: DesignSystem.fontWeightBold)) + .foregroundColor(DesignSystem.textPrimary) + .frame(width: 20, height: 16) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .focusable(false) + + Button(action: { + focusedField = nil + isAdjustingTime = true + adjustHours(by: -1) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + isAdjustingTime = false + } + }) { + Image(systemName: "chevron.down") + .font(DesignSystem.font(size: DesignSystem.fontSizeXSmall, weight: DesignSystem.fontWeightBold)) + .foregroundColor(DesignSystem.textPrimary) + .frame(width: 20, height: 16) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .focusable(false) + } + + Text(Strings.MainScreen.hours) + .font(DesignSystem.font(size: DesignSystem.fontSizeHeading, weight: DesignSystem.fontWeightMedium)) + .foregroundColor(DesignSystem.textSecondary) + .padding(.top, DesignSystem.spacingXSmall) + } + + // Minutes + HStack(spacing: 2) { + ArrowKeyTextField( + text: $minutesInput, + onUpArrow: { + isAdjustingTime = true + adjustMinutes(by: 1) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + isAdjustingTime = false + } + }, + onDownArrow: { + isAdjustingTime = true + adjustMinutes(by: -1) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + isAdjustingTime = false + } + }, + onSubmit: { + updateMinutesFromInputs() + commitTimeEdit() + }, + onFocusChange: { hasFocus in + if !hasFocus { + updateMinutesFromInputs() + } + } + ) + .frame(width: max(40, CGFloat(minutesInput.count) * 20 + 10)) + + VStack(spacing: 2) { + Button(action: { + focusedField = nil + isAdjustingTime = true + adjustMinutes(by: 1) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + isAdjustingTime = false + } + }) { + Image(systemName: "chevron.up") + .font(DesignSystem.font(size: DesignSystem.fontSizeXSmall, weight: DesignSystem.fontWeightBold)) + .foregroundColor(DesignSystem.textPrimary) + .frame(width: 20, height: 16) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .focusable(false) + + Button(action: { + focusedField = nil + isAdjustingTime = true + adjustMinutes(by: -1) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + isAdjustingTime = false + } + }) { + Image(systemName: "chevron.down") + .font(DesignSystem.font(size: DesignSystem.fontSizeXSmall, weight: DesignSystem.fontWeightBold)) + .foregroundColor(DesignSystem.textPrimary) + .frame(width: 20, height: 16) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .focusable(false) + } + + Text(Strings.MainScreen.minutes) + .font(DesignSystem.font(size: DesignSystem.fontSizeHeading, weight: DesignSystem.fontWeightMedium)) + .foregroundColor(DesignSystem.textSecondary) + .padding(.top, DesignSystem.spacingXSmall) + } + } + + // Done button + Button(action: { + commitTimeEdit() + }) { + Text(Strings.Common.done) + .font(DesignSystem.font(size: DesignSystem.fontSizeLarge, weight: DesignSystem.fontWeightMedium)) + .foregroundColor(DesignSystem.textPrimary) + .padding(.horizontal, DesignSystem.spacingMedium) + .padding(.vertical, DesignSystem.spacingSmall) + .background( + RoundedRectangle(cornerRadius: DesignSystem.radiusMedium) + .fill(isHoveringDoneButton ? DesignSystem.hoverBackground : Color.clear) + ) + .modifier(DesignSystem.buttonStyle()) + } + .buttonStyle(.plain) + .focusable(false) + .onHover { hovering in + withAnimation(DesignSystem.animationFast) { + isHoveringDoneButton = hovering + } + } + } + .frame(height: 42, alignment: .center) + } else if isEditingTime && isEasterEggUnlocked { + // Show days/hours/minutes editor when unlocked and editing + HStack(spacing: 8) { + HStack(spacing: 6) { + // Days + HStack(spacing: 2) { + ArrowKeyTextField( + text: $daysInput, + onUpArrow: { adjustDays(by: 1) }, + onDownArrow: { adjustDays(by: -1) }, + onSubmit: { updateMinutesFromAllInputs() }, + onFocusChange: { hasFocus in + if !hasFocus { + updateMinutesFromAllInputs() + } + } + ) + .frame(width: max(40, CGFloat(daysInput.count) * 20 + 10)) + + VStack(spacing: 2) { + Button(action: { adjustDays(by: 1) }) { + Image(systemName: "chevron.up") + .font(DesignSystem.font(size: DesignSystem.fontSizeXSmall, weight: DesignSystem.fontWeightBold)) + .foregroundColor(DesignSystem.textPrimary) + .frame(width: 20, height: 16) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .focusable(false) + + Button(action: { adjustDays(by: -1) }) { + Image(systemName: "chevron.down") + .font(DesignSystem.font(size: DesignSystem.fontSizeXSmall, weight: DesignSystem.fontWeightBold)) + .foregroundColor(DesignSystem.textPrimary) + .frame(width: 20, height: 16) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .focusable(false) + } + + Text(Strings.MainScreen.days) + .font(DesignSystem.font(size: DesignSystem.fontSizeHeading, weight: DesignSystem.fontWeightMedium)) + .foregroundColor(DesignSystem.textSecondary) + .padding(.top, DesignSystem.spacingXSmall) + } + + // Hours + HStack(spacing: 2) { + ArrowKeyTextField( + text: $hoursInput, + onUpArrow: { + isAdjustingTime = true + adjustHours(by: 1) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + isAdjustingTime = false + } + }, + onDownArrow: { + isAdjustingTime = true + adjustHours(by: -1) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + isAdjustingTime = false + } + }, + onSubmit: { updateMinutesFromAllInputs() }, + onFocusChange: { hasFocus in + if !hasFocus { + updateMinutesFromAllInputs() + } + } + ) + .frame(width: max(40, CGFloat(hoursInput.count) * 20 + 10)) + + VStack(spacing: 2) { + Button(action: { + focusedField = nil + isAdjustingTime = true + adjustHours(by: 1) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + isAdjustingTime = false + } + }) { + Image(systemName: "chevron.up") + .font(DesignSystem.font(size: DesignSystem.fontSizeXSmall, weight: DesignSystem.fontWeightBold)) + .foregroundColor(DesignSystem.textPrimary) + .frame(width: 20, height: 16) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .focusable(false) + + Button(action: { + focusedField = nil + isAdjustingTime = true + adjustHours(by: -1) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + isAdjustingTime = false + } + }) { + Image(systemName: "chevron.down") + .font(DesignSystem.font(size: DesignSystem.fontSizeXSmall, weight: DesignSystem.fontWeightBold)) + .foregroundColor(DesignSystem.textPrimary) + .frame(width: 20, height: 16) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .focusable(false) + } + + Text(Strings.MainScreen.hours) + .font(DesignSystem.font(size: DesignSystem.fontSizeHeading, weight: DesignSystem.fontWeightMedium)) + .foregroundColor(DesignSystem.textSecondary) + .padding(.top, DesignSystem.spacingXSmall) + } + + // Minutes + HStack(spacing: 2) { + ArrowKeyTextField( + text: $minutesInput, + onUpArrow: { + isAdjustingTime = true + adjustMinutes(by: 1) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + isAdjustingTime = false + } + }, + onDownArrow: { + isAdjustingTime = true + adjustMinutes(by: -1) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + isAdjustingTime = false + } + }, + onSubmit: { updateMinutesFromAllInputs() }, + onFocusChange: { hasFocus in + if !hasFocus { + updateMinutesFromAllInputs() + } + } + ) + .frame(width: max(40, CGFloat(minutesInput.count) * 20 + 10)) + + VStack(spacing: 2) { + Button(action: { + focusedField = nil + isAdjustingTime = true + adjustMinutes(by: 1) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + isAdjustingTime = false + } + }) { + Image(systemName: "chevron.up") + .font(DesignSystem.font(size: DesignSystem.fontSizeXSmall, weight: DesignSystem.fontWeightBold)) + .foregroundColor(DesignSystem.textPrimary) + .frame(width: 20, height: 16) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .focusable(false) + + Button(action: { + focusedField = nil + isAdjustingTime = true + adjustMinutes(by: -1) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + isAdjustingTime = false + } + }) { + Image(systemName: "chevron.down") + .font(DesignSystem.font(size: DesignSystem.fontSizeXSmall, weight: DesignSystem.fontWeightBold)) + .foregroundColor(DesignSystem.textPrimary) + .frame(width: 20, height: 16) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .focusable(false) + } + + Text(Strings.MainScreen.minutes) + .font(DesignSystem.font(size: DesignSystem.fontSizeHeading, weight: DesignSystem.fontWeightMedium)) + .foregroundColor(DesignSystem.textSecondary) + .padding(.top, DesignSystem.spacingXSmall) + } + + } + + // Done button + Button(action: { + commitTimeEdit() + }) { + Text(Strings.Common.done) + .font(DesignSystem.font(size: DesignSystem.fontSizeLarge, weight: DesignSystem.fontWeightMedium)) + .foregroundColor(DesignSystem.textPrimary) + .padding(.horizontal, DesignSystem.spacingMedium) + .padding(.vertical, DesignSystem.spacingSmall) + .background( + RoundedRectangle(cornerRadius: DesignSystem.radiusMedium) + .fill(isHoveringDoneButton ? DesignSystem.hoverBackground : Color.clear) + ) + .modifier(DesignSystem.buttonStyle()) + } + .buttonStyle(.plain) + .focusable(false) + .onHover { hovering in + withAnimation(DesignSystem.animationFast) { + isHoveringDoneButton = hovering + } + } + } + .frame(height: 42, alignment: .center) + } else { + // Display mode - show formatted time (works for both unlocked and locked) + Text(timeDisplay) + .font(DesignSystem.font(size: DesignSystem.fontSizeDisplayXL, weight: DesignSystem.fontWeightBold)) + .foregroundColor(DesignSystem.textPrimary) + .padding(.horizontal, DesignSystem.spacingMediumSmall) + .padding(.vertical, DesignSystem.spacingXSmall) + .background( + RoundedRectangle(cornerRadius: DesignSystem.radiusSmall) + .fill(isHoveringTime ? DesignSystem.textPrimary.opacity(DesignSystem.opacityMedium) : Color.clear) + .overlay( + RoundedRectangle(cornerRadius: DesignSystem.radiusSmall) + .stroke(isHoveringTime ? DesignSystem.hoverBorder : Color.clear, lineWidth: 1) + ) + ) + .frame(height: 42, alignment: .center) + .animation(DesignSystem.animationFast, value: isHoveringTime) + .onTapGesture { + startEditingTime() + } + .onHover { hovering in + isHoveringTime = hovering + if hovering { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + .help(Strings.Help.clickToEditTime) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + // Show slider only if easter egg is not unlocked + if !isEasterEggUnlocked { + // Custom styled slider (original, only shown when easter egg not unlocked) + VStack(spacing: DesignSystem.spacingXSmall) { + ZStack(alignment: .center) { + // Tick marks integrated into slider + GeometryReader { geometry in + HStack(spacing: 0) { + ForEach(0..<97) { index in + let minuteValue = index * 15 + if minuteValue <= 1440 { + VStack(spacing: 0) { + Rectangle() + .fill(DesignSystem.textPrimary.opacity(DesignSystem.opacitySubtle)) + .frame(width: 0.5, height: index % 4 == 0 ? 8 : 4) + } + .frame(maxWidth: .infinity) + } + } + } + } + .frame(height: 8) + .allowsHitTesting(false) + + Group { + Group { + if showingTips { + Slider(value: $minutes, in: 0...1440, step: 15) + .accentColor(.white) + .disabled(true) + .opacity(1.0) + .allowsHitTesting(false) + } else { + Slider(value: $minutes, in: 0...1440, step: 15) + .accentColor(.white) + .disabled(isEditingTime) + .opacity(isEditingTime ? 0.4 : 1.0) + .focusable(false) + } + } + } + } + + HStack { + Text(Strings.MainScreen.oneMinute) + .font(DesignSystem.font(size: DesignSystem.fontSizeSmall)) + .foregroundColor(DesignSystem.textTertiary) + Spacer() + Text(Strings.MainScreen.sixHours) + .font(DesignSystem.font(size: DesignSystem.fontSizeSmall)) + .foregroundColor(DesignSystem.textTertiary) + Spacer() + Text(Strings.MainScreen.twelveHours) + .font(DesignSystem.font(size: DesignSystem.fontSizeSmall)) + .foregroundColor(DesignSystem.textTertiary) + Spacer() + Text(Strings.MainScreen.eighteenHours) + .font(DesignSystem.font(size: DesignSystem.fontSizeSmall)) + .foregroundColor(DesignSystem.textTertiary) + Spacer() + Text(Strings.MainScreen.twentyFourHours) + .font(DesignSystem.font(size: DesignSystem.fontSizeSmall)) + .foregroundColor(DesignSystem.textTertiary) + } + } + } + } + .padding(DesignSystem.spacingXLarge) + .modifier(DesignSystem.cardStyle()) + .padding(.horizontal, DesignSystem.spacingLarge) + + // Site section with intensity integrated + VStack(spacing: DesignSystem.spacingMedium) { + HStack(spacing: DesignSystem.spacingSmall) { + Image(systemName: blockingMode == .blocklist ? "hand.raised.slash" : "hand.raised.fill") + .font(DesignSystem.font(size: DesignSystem.fontSizeXXLarge)) + .overlay( + LinearGradient( + gradient: Gradient(colors: [ + DesignSystem.textPrimary.opacity(DesignSystem.opacityAlmost), + DesignSystem.textPrimary.opacity(DesignSystem.opacityHigher) + ]), + startPoint: .top, + endPoint: .bottom + ) + .mask( + Image(systemName: blockingMode == .blocklist ? "hand.raised.slash" : "hand.raised.fill") + .font(DesignSystem.font(size: DesignSystem.fontSizeXXLarge)) + ) + ) + .shadow(color: DesignSystem.overlayBackground.opacity(DesignSystem.opacityHigh), radius: 2, x: 0, y: 2) + + VStack(alignment: .leading, spacing: 2) { + Text(blockingMode == .blocklist ? Strings.MainScreen.willBlock : Strings.MainScreen.willAllow) + .font(DesignSystem.font(size: DesignSystem.fontSizeXSmall, weight: DesignSystem.fontWeightMedium)) + .foregroundColor(blockingMode == .allowlist ? DesignSystem.textPrimary : DesignSystem.textTertiary) + .textCase(.uppercase) + Text(blockedSitesText) + .font(DesignSystem.font(size: DesignSystem.fontSizeLarge, weight: DesignSystem.fontWeightMedium)) + .foregroundColor(DesignSystem.textPrimary) + .lineLimit(1) + .padding(.top, DesignSystem.spacingXXSmall) + .padding(.bottom, DesignSystem.spacingXXSmall) + } + + Spacer() + + // Intensity selector (centered in middle, aligned with Will Block) + VStack(alignment: .center, spacing: 2) { + // Intensity label row (centered above dropdown) + Text(Strings.MainScreen.intensity) + .font(DesignSystem.font(size: DesignSystem.fontSizeXSmall, weight: DesignSystem.fontWeightMedium)) + .foregroundColor(DesignSystem.textTertiary) + .textCase(.uppercase) + + // Custom intensity dropdown button + Button(action: { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + showingIntensityDropdown.toggle() + } + }) { + HStack(spacing: 4) { + Text(intensityLevel.displayName) + .font(DesignSystem.font(size: DesignSystem.fontSizeLarge, weight: DesignSystem.fontWeightMedium)) + .foregroundColor(DesignSystem.textPrimary) + Image(systemName: "chevron.down") + .font(.system(size: 8, weight: DesignSystem.fontWeightSemibold)) + .foregroundColor(isHoveringIntensity ? DesignSystem.textPrimary : DesignSystem.textSecondary) + .rotationEffect(.degrees(showingIntensityDropdown ? 180 : 0)) + } + .padding(.horizontal, DesignSystem.spacingXSmall) + .padding(.vertical, DesignSystem.spacingXXSmallPlus) + .frame(minWidth: 100) + .background( + RoundedRectangle(cornerRadius: DesignSystem.radiusSmall) + .fill(isHoveringIntensity ? DesignSystem.textPrimary.opacity(DesignSystem.opacityMedium) : Color.clear) + .overlay( + RoundedRectangle(cornerRadius: DesignSystem.radiusSmall) + .stroke(isHoveringIntensity ? DesignSystem.hoverBorder : Color.clear, lineWidth: 1) + ) + ) + } + .buttonStyle(.plain) + .onHover { hovering in + withAnimation(DesignSystem.animationFast) { + isHoveringIntensity = hovering + } + } + } + + Spacer() + + // Edit button + Button(action: { + currentScreen = .editList + }) { + HStack(spacing: DesignSystem.spacingXSmall) { + Image(systemName: "list.bullet.rectangle") + .font(DesignSystem.font(size: DesignSystem.fontSizeMedium)) + Text(Strings.Common.edit) + .font(DesignSystem.font(size: DesignSystem.fontSizeLarge, weight: DesignSystem.fontWeightMedium)) + } + .foregroundColor(DesignSystem.textPrimary) + .padding(.horizontal, DesignSystem.spacingMedium) + .padding(.vertical, DesignSystem.spacingSmall) + .background( + RoundedRectangle(cornerRadius: DesignSystem.radiusMedium) + .fill(isHoveringEditButton ? DesignSystem.hoverBackground : Color.clear) + ) + .modifier(DesignSystem.buttonStyle()) + } + .buttonStyle(.plain) + .focusable(false) + .onHover { hovering in + withAnimation(DesignSystem.animationFast) { + isHoveringEditButton = hovering + } + } + .keyboardShortcut("e", modifiers: .command) + } + } + .padding(DesignSystem.spacingMedium) + .modifier(DesignSystem.cardStyle()) + .padding(.horizontal, DesignSystem.spacingLarge) + } + // End of main content VStack + .padding(.bottom, DesignSystem.spacingMedium) + + Spacer() + + // Start button at bottom + Button(action: { + if intensityLevel == .high { + showingConfirmation = true + } else { + startBlocking() + } + }) { + Text(Strings.MainScreen.startBlock) + .font(.system(size: 15, weight: .medium)) + .foregroundColor(Color(hex: "#4A4A4A")) + .frame(maxWidth: .infinity) + .frame(height: 44) + .contentShape(Rectangle()) + .background( + ZStack { + RoundedRectangle(cornerRadius: DesignSystem.radiusXXLarge) + .fill( + LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color(hex: "#F0F5F5"), location: 0.0), + .init(color: Color(hex: "#8A9A98"), location: 0.5), + .init(color: Color(hex: "#B5C9C5"), location: 1.0) + ]), + startPoint: .top, + endPoint: .bottom + ) + ) + + RoundedRectangle(cornerRadius: DesignSystem.radiusXXLarge) + .fill( + LinearGradient( + gradient: Gradient(colors: [ + Color(hex: "#E8EDED"), + Color(hex: "#A8B5B3") + ]), + startPoint: .top, + endPoint: .bottom + ) + ) + .padding(DesignSystem.spacingTiny) + + if isPressingStartButton { + RoundedRectangle(cornerRadius: DesignSystem.radiusXXLarge) + .fill(DesignSystem.overlayBackground.opacity(DesignSystem.opacityLow)) + .padding(DesignSystem.spacingTiny) + } + + // Metallic border - smoother blended gradient + RoundedRectangle(cornerRadius: DesignSystem.radiusXXLarge) + .stroke( + LinearGradient( + gradient: Gradient(stops: [ + .init(color: DesignSystem.textPrimary.opacity(DesignSystem.opacityHigh), location: 0.0), + .init(color: Color.gray.opacity(DesignSystem.opacityMedium), location: 0.2), + .init(color: Color.gray.opacity(DesignSystem.opacityMedium), location: 0.4), + .init(color: DesignSystem.overlayBackground.opacity(DesignSystem.opacityMedium), location: 0.6), + .init(color: Color.gray.opacity(DesignSystem.opacityMedium), location: 0.8), + .init(color: Color.gray.opacity(DesignSystem.opacityMedium), location: 1.0) + ]), + startPoint: .top, + endPoint: .bottom + ), + lineWidth: 1.5 + ) + } + ) + .scaleEffect(isHoveringButton ? 1.01 : 1.0) + .scaleEffect(isPressingStartButton ? 0.99 : 1.0) + } + .buttonStyle(.plain) + .help(Strings.Help.startBlocking) + .focusable(false) + .simultaneousGesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in + if !isPressingStartButton { + withAnimation(DesignSystem.animationFast) { + isPressingStartButton = true + } + } + } + .onEnded { _ in + withAnimation(DesignSystem.animationFast) { + isPressingStartButton = false + } + } + ) + .onHover { hovering in + withAnimation(DesignSystem.animationFast) { + isHoveringButton = hovering + } + } + .padding(.horizontal, DesignSystem.spacingLarge) + .padding(.bottom, DesignSystem.spacingXLargePlusMedium) + } + } + } + .onAppear { + print("onAppear main view") + HelperConnection.shared.blockedStateHandler = { minutes in + print("Already in blocked state, show remaining minutes:\(minutes)") + Task { @MainActor in + print("Already in blocked state, show remaining minutes:\(minutes)") + await showBlockingStateForReminingMinutes(minutes: minutes) + } + } + + HelperConnection.shared.send_getBlockedStates() + // Don't focus slider on startup - let tips modal show first if needed + // Focus will be set after tips are dismissed + } + } + + private func incrementIntensity() { + intensityLevel = IntensityHelpers.nextIntensity(current: intensityLevel) + } + + private func decrementIntensity() { + intensityLevel = IntensityHelpers.previousIntensity(current: intensityLevel) + } + + private func startBlocking() { + if AppPreferences.showVerifyNetworkAlertBeforeBlock { + if SCUIUtility.checkNetworkAndShowNetworkAlert() == false { + return + } + } + startBlocking(minutes: minutes) + viewModel.startBlocking(endDate: Date().addingTimeInterval(minutes * 60)) + } + + private func showBlockingStateForReminingMinutes(minutes: Double) { + withAnimation(DesignSystem.animationNormal) { + isBlocking = true +// viewModel.updateBlockList(newBlockedDomains: blockedURLs, time: minutes) + // Calculate total time including days if easter egg is unlocked + remainingTime = minutes * 60 + // Start countdown timer + if timer != nil { + timer?.invalidate() + timer = nil + } + timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in + if remainingTime > 0 { + remainingTime -= 1 + } else { + showNonBlockingState() + } + } + } + + } + + private func showNonBlockingState() { + viewModel.stopBlocking() + withAnimation(DesignSystem.animationNormal) { + isBlocking = true + } + withAnimation(DesignSystem.animationNormal) { + isBlocking = false + } + timer?.invalidate() + timer = nil + remainingTime = 0 + cancelStopCountdown() + } + + private func startBlocking(minutes: Double) { + HelperConnection.shared.send_startNetwrokBlocking(minutes: Int(minutes)) + } + + private func stopBlocking() { + showNonBlockingState() + HelperConnection.shared.send_stopNetworkBlocking() + viewModel.stopBlocking() + } + + private func showStopConfirmation() { + stopCountdownTime = 600 + showingStopConfirmation = true + + // Start the countdown timer + stopCountdownTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in + if stopCountdownTime > 0 { + stopCountdownTime -= 1 + } else { + // Countdown finished, just stop the timer (user must click Yes to stop) + stopCountdownTimer?.invalidate() + stopCountdownTimer = nil + } + } + } + + private func cancelStopCountdown() { + stopCountdownTimer?.invalidate() + stopCountdownTimer = nil + stopCountdownTime = 600 + showingStopConfirmation = false + } + + private func resumeBlocking() { + cancelStopCountdown() + } + + private func extendTimer(by minutes: Double) { + remainingTime += minutes * 60 + } + + private var blockedSitesText: String { + BlockedURLsHelpers.blockedSitesText(blockedURLs) + } + + private func startEditingTime() { + let components = TimeHelpers.extractTimeComponents(minutes: minutes, isEasterEggUnlocked: isEasterEggUnlocked, days: days) + daysInput = String(components.days) + hoursInput = String(components.hours) + minutesInput = String(components.minutes) + isEditingTime = true + focusedField = .timeField + } + + private func commitTimeEdit() { + // Update minutes from the current input values + if isEasterEggUnlocked { + updateMinutesFromAllInputs() + } else { + updateMinutesFromInputs() + } + isEditingTime = false + focusedField = nil + } + + private func updateMinutesFromInputs() { + let result = TimeHelpers.calculateMinutesFromInputs( + hoursInput: hoursInput, + minutesInput: minutesInput, + isEasterEggUnlocked: isEasterEggUnlocked + ) + hoursInput = result.hoursInput + minutesInput = result.minutesInput + minutes = result.totalMinutes + } + + private func updateMinutesFromAllInputs() { + let result = TimeHelpers.calculateMinutesFromAllInputs( + daysInput: daysInput, + hoursInput: hoursInput, + minutesInput: minutesInput, + currentDays: days, + isEasterEggUnlocked: isEasterEggUnlocked + ) + days = result.days + daysInput = result.daysInput + hoursInput = result.hoursInput + minutesInput = result.minutesInput + minutes = result.totalMinutes + } + + private func adjustDays(by amount: Int) { + if isEasterEggUnlocked { + let result = TimeHelpers.adjustDays( + currentDaysInput: daysInput, + currentDays: days, + amount: amount + ) + days = result.days + daysInput = result.daysInput + updateMinutesFromAllInputs() + } + } + + private func adjustHours(by amount: Int) { + let result = TimeHelpers.adjustHours( + daysInput: daysInput, + hoursInput: hoursInput, + minutesInput: minutesInput, + currentDays: days, + currentMinutes: minutes, + amount: amount, + isEasterEggUnlocked: isEasterEggUnlocked + ) + days = result.days + daysInput = result.daysInput + hoursInput = result.hoursInput + minutesInput = result.minutesInput + minutes = result.totalMinutes + } + + private func adjustMinutes(by amount: Int) { + let result = TimeHelpers.adjustMinutes( + daysInput: daysInput, + hoursInput: hoursInput, + minutesInput: minutesInput, + currentDays: days, + currentMinutes: minutes, + amount: amount, + isEasterEggUnlocked: isEasterEggUnlocked + ) + days = result.days + daysInput = result.daysInput + hoursInput = result.hoursInput + minutesInput = result.minutesInput + minutes = result.totalMinutes + } +} + +#Preview { + NewContentView() +} diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/NewViewUI.swift b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/NewViewUI.swift new file mode 100644 index 00000000..1d29c70e --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/NewViewUI.swift @@ -0,0 +1,107 @@ +// +// NewViewUI.swift +// SelfControl +// +// Created by Satendra Singh on 16/01/26. +// + +import SwiftUI + +struct NewViewUI { + static var body: some Scene { + let windowGroup = WindowGroup { + NewContentView() + .background(WindowTitleBarHider()) + } +// .commands { +// CommandGroup(replacing: .newItem) { } +// CommandGroup(replacing: .appInfo) { +// Button(Strings.AppMenu.aboutSelfControl) { +// NotificationCenter.default.post(name: NSNotification.Name("ShowAboutScreen"), object: nil) +// } +// +// Divider() +// +// Button(Strings.AppMenu.editSiteList) { +// NotificationCenter.default.post(name: NSNotification.Name("ShowEditListScreen"), object: nil) +// } +// .keyboardShortcut("e", modifiers: .command) +// +// Button(Strings.AppMenu.editBlockSchedule) { +// NotificationCenter.default.post(name: NSNotification.Name("ShowBlockScheduleScreen"), object: nil) +// } +// .keyboardShortcut("b", modifiers: .command) +// +// Button(Strings.AppMenu.moreSettings) { +// NotificationCenter.default.post(name: NSNotification.Name("ShowAdvancedSettingsScreen"), object: nil) +// } +// .keyboardShortcut(",", modifiers: .command) +// +// +// +// Divider() +// +// Button(Strings.AppMenu.donate) { +// if let url = URL(string: Strings.AppMenu.URLs.donate) { +// NSWorkspace.shared.open(url) +// } +// } +// } +// CommandGroup(replacing: .help) { +// Button(Strings.AppMenu.gettingStartedTips) { +// NotificationCenter.default.post(name: NSNotification.Name("ShowTipsScreen"), object: nil) +// } +// .keyboardShortcut("t", modifiers: .command) +// +// Divider() +// +// Button(Strings.AppMenu.selfControlHelp) { +// if let url = URL(string: Strings.AppMenu.URLs.help) { +// NSWorkspace.shared.open(url) +// } +// } +// .keyboardShortcut("?", modifiers: .command) +// +// Button(Strings.AppMenu.faq) { +// if let url = URL(string: Strings.AppMenu.URLs.faq) { +// NSWorkspace.shared.open(url) +// } +// } +// } +// } + + return windowGroup + } +} + +//// MARK: - Window Configuration Helper (macOS 11 compatible) +struct WindowTitleBarHider: NSViewRepresentable { + func makeNSView(context: Context) -> NSView { + let view = NSView() + DispatchQueue.main.async { + if let window = view.window { + configure(window: window) + } else if let window = NSApplication.shared.windows.first { + configure(window: window) + } + } + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + if let window = nsView.window { + configure(window: window) + } + } + + private func configure(window: NSWindow) { + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.isMovableByWindowBackground = true + window.styleMask.insert(.fullSizeContentView) + // Set a slightly smaller default content size + if window.contentView?.frame.size.width ?? 0 > 0 { + window.setContentSize(NSSize(width: 600, height: 450)) + } + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Onboarding/AppOnboardingView.swift b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Onboarding/AppOnboardingView.swift new file mode 100644 index 00000000..cf0d4160 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Onboarding/AppOnboardingView.swift @@ -0,0 +1,31 @@ +// +// AppOnboardingView.swift +// SelfControl +// +// Created by Satendra Singh on 01/02/26. +// + +import SwiftUI +enum AppOnboardingViewState { + case installNetworkExtension + case installSafariExtension + case installChromeExtension +} + +struct AppOnboardingView: View { + @State var state: AppOnboardingViewState = .installNetworkExtension + var body: some View { + switch state { + case .installNetworkExtension: + OnBoardingInstallNetworkExt() + case .installSafariExtension: + OnBoardingInstallSafariExt() + case .installChromeExtension: + OnBoardingInstallChromeExt() + } + } +} + +#Preview { + AppOnboardingView() +} diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Onboarding/ClickableLinkButton.swift b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Onboarding/ClickableLinkButton.swift new file mode 100644 index 00000000..5ce56a88 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Onboarding/ClickableLinkButton.swift @@ -0,0 +1,32 @@ +// +// ClickableLinkButton.swift +// SelfControl +// +// Created by Satendra Singh on 04/02/26. +// + +import SwiftUI + +struct ClickableLinkButton: View { + let message: String + var onTap: () -> Void + var body: some View { + Text("[\(message)](https://localhost)") + .environment(\.openURL, OpenURLAction { url in + handleURL(url) + return .handled + }) + } + + func handleURL(_ url: URL) { + print("Handled URL: \(url)") + onTap() + // Customize the action for the URL here + } +} + +#Preview { + ClickableLinkButton(message: "Skip and accept subpar blocking", onTap: { + + }) +} diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Onboarding/OnBoardingInstallChromeExt.swift b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Onboarding/OnBoardingInstallChromeExt.swift new file mode 100644 index 00000000..5c0236fb --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Onboarding/OnBoardingInstallChromeExt.swift @@ -0,0 +1,63 @@ +// +// OnBoardingInstallChromeExt.swift +// SelfControl +// +// Created by Satendra Singh on 08/02/26. +// + + +import SwiftUI + +struct OnBoardingInstallChromeExt: View { + @EnvironmentObject var viewModel: FilterViewModel + + var body: some View { + VStack { + Text("Welcome to SelfControl!") + .font(.largeTitle) + + Spacer() + + Text("SelfControl helps you focus by blocking your own access to distracting websites.") + .font(.title2) + + Spacer() + + Text("To get started, we'll need to prepare your computer so our blocks work properly.") + .font(.title2) + + Spacer() + OnboardingStepView(step: 2) + + Text("Install the SelfControl Chrome Extension so we can provide better blocking in Chrome. We never store, share, or analyze your data - this is used or blocking.") + .font(.title2) + + Spacer() + + Button("Install Chrome Extension") { + // +// if let url = URL(string: "chrome://extensions") { +// NSWorkspace.shared.open(url) +// } + let task = Process() + task.launchPath = "/usr/bin/open" + task.arguments = ["-a", "Google Chrome", "https://chromewebstore.google.com/detail/selfcontrol-blocker/lmpnckgcpefbmipnbfcickkaakpgbdhj"] + task.launch() + } + + Spacer() + ClickableLinkButton(message: "Skip and accept subpar blocking", onTap: { + print("Handle callback") + AppPreferences.setChromeExtensionInstalled() + viewModel.updateChromeExtensionViewStatus() + }) + Spacer() +// .tint(.blue) + } + .padding(20) + } +} + +#Preview { + OnBoardingInstallChromeExt() +} diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Onboarding/OnBoardingInstallNetworkExt.swift b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Onboarding/OnBoardingInstallNetworkExt.swift new file mode 100644 index 00000000..7f182444 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Onboarding/OnBoardingInstallNetworkExt.swift @@ -0,0 +1,52 @@ +// +// OnBoardingView1.swift +// SelfControl +// +// Created by Satendra Singh on 01/02/26. +// + +import SwiftUI + +struct OnBoardingInstallNetworkExt: View { + @EnvironmentObject var viewModel: FilterViewModel + + var body: some View { + VStack { + Text("Welcome to SelfControl!") + .font(.largeTitle) + + Spacer() + + Text("SelfControl helps you focus by blocking your own access to distracting websites.") + .font(.title2) + + Spacer() + + Text("To get started, we'll need t o prepare your computer so our blocks work properly.") + .font(.title2) + + Spacer() + OnboardingStepView(step: 1) + + Text("Install the SelfControl Network Extension so we can block network traffic in any application in your computer. We never store, share, or analyze your data - this is used or blocking only.") + .font(.title2) + + Spacer() + + Button("Install Network Extension") { + viewModel.activateExtension() + } + + Spacer() + ClickableLinkButton(message: "Skip and accept subpar blocking", onTap: { + print("Handle callback") + viewModel.isNetworkExtensionSkipped.toggle() + }) + } + .padding(20) + } +} + +#Preview { + OnBoardingInstallNetworkExt() +} diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Onboarding/OnBoardingInstallSafariExt.swift b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Onboarding/OnBoardingInstallSafariExt.swift new file mode 100644 index 00000000..295480f8 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Onboarding/OnBoardingInstallSafariExt.swift @@ -0,0 +1,78 @@ +// +// OnBoardingView2.swift +// SelfControl +// +// Created by Satendra Singh on 04/02/26. +// + +import SwiftUI +import SafariServices + +struct OnBoardingInstallSafariExt: View { + @EnvironmentObject var viewModel: FilterViewModel + + var body: some View { + VStack { + Text("Welcome to SelfControl!") + .font(.largeTitle) + + Spacer() + + Text("SelfControl helps you focus by blocking your own access to distracting websites.") + .font(.title2) + + Spacer() + + Text("To get started, we'll need to prepare your computer so our blocks work properly.") + .font(.title2) + + Spacer() + OnboardingStepView(step: 3) + + Text("Install the SelfControl Safari Extension so we can provide better blocking in Safari. We never store, share, or analyze your data — this is used for blocking only.") + .font(.title2) + + Spacer() + + Button("Install Safari Extension") { + // + enableExtension() + } + + Spacer() + ClickableLinkButton(message: "Skip and accept subpar blocking", onTap: { + print("Handle callback") + AppPreferences.setSafariExtensionInstalled() + viewModel.updateSafariExtensionViewStatus() + }) + Spacer() +// .tint(.blue) + } + .padding(20) + } + + func enableExtension() { + SFSafariApplication.showPreferencesForExtension(withIdentifier: SafariExtensionConstants.identifier) { error in + if let error = error { +// NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.Safari-Settings.extension")!) + NSLog("Error opening Safari preferences: \(error.localizedDescription)") + NSWorkspace.shared.openApplication( + at: URL(fileURLWithPath: "/Applications/Safari.app"), + configuration: NSWorkspace.OpenConfiguration(), + completionHandler: nil + ) + } + } + +// SFSafariApplication.showPreferencesForExtension(withIdentifier: "com.application.SelfControl.corebits.SelfControl-Safari-Extension") { (error) in +// if let error = error { +// NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.Safari-Settings.extension")!) +// NSLog("Error opening Safari preferences: \(error.localizedDescription)") +// } +// } + } +} + +#Preview { + OnBoardingInstallSafariExt() +} diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Onboarding/OnboardingStepView.swift b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Onboarding/OnboardingStepView.swift new file mode 100644 index 00000000..7a2ba000 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Onboarding/OnboardingStepView.swift @@ -0,0 +1,53 @@ +// +// OnboardingStepView.swift +// SelfControl +// +// Created by Satendra Singh on 01/02/26. +// + +import SwiftUI + +struct OnboardingStepView: View { + let step: Int + let totakSteps: Int = 3 + var body: some View { + HStack { + Spacer() + + Text("Step \(step) of \(totakSteps)") + .font(.title2) + + Spacer() + + PartialProgressBar(progress: Double(step) / Double(totakSteps)) + Spacer() + + } + .padding() + } +} + +struct PartialProgressBar: View { + var progress: CGFloat // value between 0.0 and 1.0 + var height: CGFloat = 12 + + var body: some View { + GeometryReader { geometry in + ZStack(alignment: .leading) { + // Background + Capsule() + .fill(Color.gray.opacity(0.3)) + + // Filled portion + Capsule() + .fill(Color.blue) + .frame(width: geometry.size.width * progress) + } + } + .frame(height: height) + } +} + +#Preview { + OnboardingStepView(step: 1) +} diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Utils/DesignSystem.swift b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Utils/DesignSystem.swift new file mode 100644 index 00000000..7e8be519 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Utils/DesignSystem.swift @@ -0,0 +1,177 @@ +// +// DesignSystem.swift +// SelfControlUI +// +// Created on 2025-10-31. +// + +import SwiftUI + +struct DesignSystem { + // MARK: - Corner Radii + static let radiusXSmall: CGFloat = 4 + static let radiusSmall: CGFloat = 8 + static let radiusMedium: CGFloat = 12 + static let radiusLarge: CGFloat = 16 + static let radiusXLarge: CGFloat = 20 + static let radiusXXLarge: CGFloat = 24 + + // MARK: - Spacing + static let spacingTiny: CGFloat = 1.5 + static let spacingXXSmall: CGFloat = 2 + static let spacingXXSmallPlus: CGFloat = 3 + static let spacingXSmall: CGFloat = 6 + static let spacingSmall: CGFloat = 10 + static let spacingSmallMedium: CGFloat = 11 + static let spacingMediumSmall: CGFloat = 12 + static let spacingMedium: CGFloat = 14 + static let spacingMediumLarge: CGFloat = 16 + static let spacingLarge: CGFloat = 18 + static let spacingLargeMinus: CGFloat = 16 + static let spacingXLarge: CGFloat = 24 + static let spacingXLargePlusMedium: CGFloat = 28 + static let spacingXXLarge: CGFloat = 32 + + // MARK: - Colors + // Base backgrounds + static let backgroundPrimary = Color(white: 0.15) + static let backgroundSecondary = Color(white: 0.12) + static let backgroundTertiary = Color(white: 0.2) + + // Border colors + static let borderPrimary = Color.white.opacity(0.1) + static let borderSecondary = Color.white.opacity(0.05) + + // Text colors + static let textPrimary = Color.white + static let textSecondary = Color(white: 0.7) + static let textTertiary = Color(white: 0.5) + static let textQuaternary = Color(white: 0.4) + + // Interactive states + static let hoverBackground = Color.white.opacity(0.2) + static let hoverBorder = Color.white.opacity(0.4) + + // Modal/Overlay backgrounds + static let overlayBackground = Color.black.opacity(0.6) + + // Disabled state colors + static let disabledBackground = Color(white: 0.4) + static let disabledText = Color(white: 0.4) + + // Accent colors + static let accentOrange = Color.orange + static let accentOrangeMuted = Color.orange.opacity(0.8) + static let accentPurple = Color.purple + static let accentPurpleMuted = Color.purple.opacity(0.8) + static let toggleTint = Color(red: 0.85, green: 0.95, blue: 1.0) + + // MARK: - Typography + static let fontSizeXSmall: CGFloat = 10 + static let fontSizeSmall: CGFloat = 11 + static let fontSizeBase: CGFloat = 12 + static let fontSizeMedium: CGFloat = 13 + static let fontSizeLarge: CGFloat = 14 + static let fontSizeXLarge: CGFloat = 15 + static let fontSizeXXLarge: CGFloat = 16 + static let fontSizeTitle: CGFloat = 18 + static let fontSizeHeading: CGFloat = 20 + static let fontSizeDisplay: CGFloat = 24 + static let fontSizeDisplayLarge: CGFloat = 28 + static let fontSizeDisplayXL: CGFloat = 32 + static let fontSizeDisplayXXL: CGFloat = 36 + static let fontSizeDisplayXXXL: CGFloat = 52 + static let fontSizeDisplayHuge: CGFloat = 64 + + // Font weights + static let fontWeightRegular = Font.Weight.regular + static let fontWeightMedium = Font.Weight.medium + static let fontWeightSemibold = Font.Weight.semibold + static let fontWeightBold = Font.Weight.bold + + // MARK: - Opacities + static let opacitySubtle: Double = 0.05 + static let opacityLow: Double = 0.1 + static let opacityMedium: Double = 0.2 + static let opacityHigh: Double = 0.4 + static let opacityHigher: Double = 0.6 + static let opacityAlmost: Double = 0.8 + static let opacityFull: Double = 1.0 + + // Semantic opacity aliases for common use cases + static let disabledOpacity: Double = 0.5 + + // MARK: - Animations + static let animationFast = Animation.easeInOut(duration: 0.2) + static let animationNormal = Animation.easeInOut(duration: 0.3) + + // MARK: - Gradients + static let backgroundGradient = LinearGradient( + gradient: Gradient(colors: [ + Color(red: 0.10, green: 0.10, blue: 0.12), + Color(red: 0.14, green: 0.14, blue: 0.17), + Color(red: 0.11, green: 0.11, blue: 0.14) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + + // MARK: - Mode Colors + static func modeColor(for mode: BlockingMode) -> Color { + mode == .blocklist ? Color(red: 0.25, green: 0.48, blue: 0.95) : Color(red: 0.9, green: 0.3, blue: 0.3) + } + + // Muted destructive color + static let destructiveColor = Color(red: 0.8, green: 0.3, blue: 0.3) + + // MARK: - Card Style Modifier + static func cardStyle() -> some ViewModifier { + CardStyleModifier() + } + + // MARK: - Button Style Modifier + static func buttonStyle(color: Color? = nil, isDestructive: Bool = false) -> some ViewModifier { + ButtonStyleModifier(color: color, isDestructive: isDestructive) + } + + // MARK: - Typography Helpers + static func font(size: CGFloat, weight: Font.Weight = .regular) -> Font { + .system(size: size, weight: weight) + } + + static func font(size: CGFloat, weight: Font.Weight, design: Font.Design = .default) -> Font { + .system(size: size, weight: weight, design: design) + } +} + +// MARK: - Style Modifiers +struct CardStyleModifier: ViewModifier { + func body(content: Content) -> some View { + content + .background( + RoundedRectangle(cornerRadius: DesignSystem.radiusLarge) + .fill(DesignSystem.backgroundPrimary) + .overlay( + RoundedRectangle(cornerRadius: DesignSystem.radiusLarge) + .stroke(DesignSystem.borderPrimary, lineWidth: 1) + ) + ) + } +} + +struct ButtonStyleModifier: ViewModifier { + let color: Color? + let isDestructive: Bool + + func body(content: Content) -> some View { + content + .background( + RoundedRectangle(cornerRadius: DesignSystem.radiusMedium) + .fill(isDestructive ? DesignSystem.destructiveColor.opacity(DesignSystem.opacityLow) : (color?.opacity(DesignSystem.opacityMedium) ?? DesignSystem.backgroundTertiary)) + .overlay( + RoundedRectangle(cornerRadius: DesignSystem.radiusMedium) + .stroke(isDestructive ? DesignSystem.destructiveColor.opacity(DesignSystem.opacityMedium) : (color?.opacity(DesignSystem.opacityMedium) ?? DesignSystem.borderPrimary), lineWidth: 1) + ) + ) + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Utils/DomainConstants.swift b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Utils/DomainConstants.swift new file mode 100644 index 00000000..d63c6e0e --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Utils/DomainConstants.swift @@ -0,0 +1,29 @@ +// +// Constants.swift +// SelfControlUI +// +// Created on 2025-10-31. +// + +import Foundation + +struct DomainConstants { + // MARK: - Suggested Sites + static let suggestedSites = [ + "instagram.com", + "tiktok.com", + "x.com", + "discord.com", + "linkedin.com", + "facebook.com", + "youtube.com", + "netflix.com", + "twitch.tv", + "reddit.com", + "hulu.com", + "amazon.com", + "chatgpt.com", + "claude.ai", + "perplexity.ai" + ] +} diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Utils/Strings.swift b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Utils/Strings.swift new file mode 100644 index 00000000..8ed75d2f --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Utils/Strings.swift @@ -0,0 +1,323 @@ +// +// Strings.swift +// SelfControlUI +// +// Created on 2025-10-31. +// + +import Foundation + +struct Strings { + // MARK: - Common + struct Common { + static let back = "Back" + static let cancel = "Cancel" + static let done = "Done" + static let delete = "Delete" + static let add = "Add" + static let edit = "Edit" + static let importText = "Import" + static let export = "Export" + static let selectAll = "Select All" + static let deselectAll = "Deselect All" + } + + // MARK: - Main Screen + struct MainScreen { + static let startBlock = "Start Block" + static let stopBlocking = "Stop Blocking" + static let intensity = "INTENSITY" + static let willBlock = "Will Block" + static let willAllow = "Will Allow (Blocks ALL Else)" + static let noSitesBlocked = "No sites blocked" + static let blocklistEmpty = "Blocklist is empty" + static let blocking = "Blocking" + static let noSitesAllowed = "No sites allowed" + static let allWebsitesBlocked = "All websites are blocked" + static let allowing = "Allowing" + static let allOtherSitesBlocked = "All other sites are blocked" + static let addToBlocklist = "Add to Blocklist" + static let addToAllowlist = "Add to Allowlist" + + // Time presets + static let oneMinute = "1 min" + static let sixHours = "6h" + static let twelveHours = "12h" + static let eighteenHours = "18h" + static let twentyFourHours = "24h" + + // Time units + static let hours = "h" + static let minutes = "m" + static let days = "d" + + // Confirmation alert + static let startBlockingTitle = "Start Blocking?" + static func startBlockingMessage(blockingMode: BlockingMode, countText: String, timeDisplay: String) -> String { + if blockingMode == .allowlist { + return "⚠️ ALLOWLIST MODE: You will block ALL websites EXCEPT \(countText) for \(timeDisplay).\n\nOnly sites on your list will be accessible. You won't be able to stop until the timer expires." + } else { + return "You will block \(countText) for \(timeDisplay). You won't be able to stop until the timer expires." + } + } + } + + // MARK: - Blocklist Editor + struct BlocklistEditor { + static let title = "Blocklist" + static let allowlist = "Allowlist" + static let allowlistModeActive = "ALLOWLIST MODE ACTIVE" + static let allowlistModeDescription = "Only sites on this list will be accessible. Everything else will be blocked." + static let quickAdd = "Quick Add" + static let suggested = "Suggested" + static let tips = "Tips" + static let bulkEdit = "Bulk Edit" + static let sites = "SITE" + static let sitesPlural = "SITES" + static let noBlockedSites = "No blocked sites yet" + static let noResultsFound = "No results found" + static let locked = "(locked)" + static let path = "path" + static let paths = "paths" + static let selected = "selected" + + // Placeholders + static let searchPlaceholder = "Search..." + static let addWebsitePlaceholder = "Add website..." + static let addWebsiteExamplePlaceholder = "Add website (e.g., facebook.com)" + static let searchBlockedSitesPlaceholder = "Search blocked sites" + + static func siteCount(_ count: Int) -> String { + "\(count) \(count == 1 ? sites : sitesPlural)" + } + static func pathCount(_ count: Int) -> String { + "\(count) \(count == 1 ? path : paths)" + } + static func selectedCount(_ count: Int) -> String { + "\(count) \(selected)" + } + + // Delete confirmation + static func deleteSiteTitle(_ count: Int) -> String { + "Delete \(count) Site\(count == 1 ? "" : "s")?" + } + static func deleteSiteMessage(_ count: Int) -> String { + "Are you sure you want to delete \(count == 1 ? "this site" : "these sites")? This action cannot be undone." + } + + // Add site confirmation + static let addToExistingBlockTitle = "Add to Existing Block?" + static func addToExistingBlockMessage(_ site: String, blockingMode: BlockingMode) -> String { + "Are you sure you want to add \"\(site)\" to your \(blockingMode == .blocklist ? "blocklist" : "allowlist") while a block is active?" + } + static let addSite = "Add Site" + } + + // MARK: - Domain Detail + struct DomainDetail { + static let siteSettings = "Site Settings" + static let blockingMode = "BLOCKING MODE" + static let blockEntireDomain = "Block Entire Domain" + static let specificPathsOnly = "Specific Paths Only" + static let blockEntireDomainDescription = "Blocks all pages on this domain" + static let specificPathsDescription = "Only blocks specific paths/subdomains you add below" + static let pathsAndSubdomains = "PATHS & SUBDOMAINS" + static let addPathOrSubdomain = "Add Path or Subdomain" + static let paths = "Paths" + static func pathsCount(_ count: Int) -> String { + "Paths (\(count))" + } + static let noPathsAdded = "No paths added yet" + static let deleteSite = "Delete Site" + static let managePathsDescription = "Manage specific paths and subdomains for this website" + + // Placeholders + static let addPathPlaceholder = "e.g., /explore, /reels" + static let addPathExamplePlaceholder = "e.g., /explore, /reels, or m.domain.com" + } + + // MARK: - Advanced Settings + struct AdvancedSettings { + static let title = "More Settings" + static let blocking = "BLOCKING" + static let general = "GENERAL" + static let networkAndCache = "NETWORK & CACHE" + static let displayAndTimer = "DISPLAY & TIMER" + static let mode = "MODE" + static let allowlistMode = "Allowlist Mode" + static let allowlistModeDescription = "Blocks ALL sites except those on the list" + + // Allowlist confirmation + static let enableAllowlistTitle = "⚠️ Enable Allowlist Mode?" + static let enableAllowlistMessage = "WARNING: Allowlist mode blocks EVERYTHING except sites on your list.\n\nOnly the sites you explicitly add will be accessible. All other websites, apps, and services will be blocked. Use this mode with caution." + static let enableAllowlist = "Enable Allowlist" + + // Setting titles + static let blockCommonSubdomains = "Block common subdomains" + static let highlightInvalidHosts = "Highlight invalid hosts" + static let allowlistLinkedSites = "Allowlist includes linked sites (slow)" + static let autoCheckUpdates = "Automatically check for updates" + static let autoSendErrorReports = "Automatically send anonymized error reports" + static let playSoundOnCompletion = "Play sound on completion" + static let showNotificationOnCompletion = "Show Notification on completion" + + static let timerFloatsOnTop = "Timer window should float on top" + static let showCountdownInDock = "Show countdown in Dock" + static let hideSecondsInCountdown = "Hide seconds in countdown" + static let verifyInternetConnection = "Verify internet connection" + static let clearBrowserCache = "Clear browser cache" + static let allowLocalNetworks = "Allow local networks" + } + + // MARK: - Block Schedule + struct BlockSchedule { + static let title = "Block Schedule" + static let noTimeSlots = "No time slots" + static let addTimeSlot = "Add Time Slot" + static let deleteSchedule = "Delete Schedule" + static let to = "to" + } + + // MARK: - About + struct About { + static let title = "About SelfControl" + static let appName = "SelfControl" + static let version = "Version 4.0.2 (410)" + static let freeAndOpenSource = "Free and open-source under the GPL." + + // Main website link + static let mainWebsite = AboutItem(text: "http://selfcontrolapp.com", url: "http://selfcontrolapp.com") + + // Credits sections as arrays (each array represents one line/row) + static let developers: [AboutItem] = [ + AboutItem(text: "Developed by"), + AboutItem(text: "Charlie Stigler", url: "https://github.com/cstigler"), + AboutItem(text: ","), + AboutItem(text: "Steve Lambert", url: "http://visitsteve.com"), + AboutItem(text: ", and you?") + ] + + static let iconAndContributorsLine1: [AboutItem] = [ + AboutItem(text: "Icon by"), + AboutItem(text: "Joseph Fusco", url: "http://josephfus.co/"), + AboutItem(text: ", contributions by all of") + ] + + static let iconAndContributorsLine2: [AboutItem] = [ + AboutItem(text: "these lovely folks", url: "https://github.com/SelfControlApp/selfcontrol/graphs/contributors"), + AboutItem(text: ", and translations by"), + AboutItem(text: "these troopers", url: "https://www.transifex.com/selfcontrol/selfcontrol/"), + AboutItem(text: ".") + ] + + static let errorReporting: [AboutItem] = [ + AboutItem(text: "Error reporting generously provided by"), + AboutItem(text: "Sentry", url: "https://sentry.io"), + AboutItem(text: ".") + ] + + static let license: [AboutItem] = [ + AboutItem(text: "Free Software created at"), + AboutItem(text: "Eyebeam", url: "http://eyebeam.org"), + AboutItem(text: "under the"), + AboutItem(text: "GNU GPL", url: "http://www.gnu.org/copyleft/gpl.html"), + AboutItem(text: ".") + ] + + static let sourceCode: [AboutItem] = [ + AboutItem(text: "Source code", url: "https://github.com/SelfControlApp/selfcontrol/"), + AboutItem(text: "is available on GitHub.") + ] + } + + // MARK: - Tips + struct Tips { + static let title = "Getting Started with SelfControl" + static let gotIt = "Got it" + + // Tips as an array + static let items: [Tip] = [ + Tip( + title: "1. Add Sites to Block", + description: "Click 'Edit' to add website domains or paths you want to block." + ), + Tip( + title: "2. Adjust Blocking Time and Intensity", + description: "Set how long you want to block sites and the intensity of the blocking." + ), + Tip( + title: "3. Start Blocking", + description: "Click 'Start Block' to begin." + ) + ] + } + + // MARK: - Stop Confirmation + struct StopConfirmation { + static let title = "Stop Blocking?" + static let seconds = "seconds" + static let yesStopBlocking = "Yes, Stop Blocking" + static let resumeBlocking = "Resume Blocking" + static func countdownMessage(_ countdownTime: TimeInterval) -> String { + if countdownTime > 0 { + let minutes = Int(countdownTime) / 60 + let seconds = Int(countdownTime) % 60 + if minutes > 0 { + return "Wait for \(minutes) minute\(minutes == 1 ? "" : "s") and \(seconds) second\(seconds == 1 ? "" : "s") before you can stop blocking" + } else { + return "Wait for \(seconds) second\(seconds == 1 ? "" : "s") before you can stop blocking" + } + } else { + return "Confirm below to stop blocking" + } + } + } + + // MARK: - Help Text + struct Help { + static let closeSearch = "Close search" + static let addSite = "Add site" + static let search = "Search" + static let showTips = "Show getting started tips" + static let cannotDisableDuringBlock = "Cannot disable sites while a block is active" + static let stopBlockingImmediately = "Stop blocking immediately" + static let stopBlockingWithConfirmation = "Stop blocking with confirmation" + static let clickToEditTime = "Click to edit time" + static let startBlocking = "Start blocking" + static let cannotChangeModeDuringBlock = "Cannot change blocking mode while a block is active" + } + + // MARK: - Blocked URLs Helpers + struct BlockedURLs { + static let noSites = "No sites" + static let allSitesDisabled = "All sites disabled" + static let domain = "domain" + static let domains = "domains" + static func domainCount(_ count: Int, total: Int) -> String { + "\(count)/\(total) domain\(count == 1 ? "" : "s")" + } + static func domainsText(_ count: Int) -> String { + "\(count) domain\(count == 1 ? "" : "s")" + } + } + + // MARK: - App Menu + struct AppMenu { + static let aboutSelfControl = "About SelfControl" + static let editSiteList = "Edit Block List" + static let editBlockSchedule = "Edit Block Schedule" + static let moreSettings = "More Settings" + static let donate = "Donate" + static let gettingStartedTips = "Getting Started Tips" + static let selfControlHelp = "SelfControl Help" + static let faq = "FAQ" + + // URLs + struct URLs { + static let donate = "https://selfcontrolapp.com/donate" + static let help = "https://github.com/SelfControlApp/selfcontrol/wiki/SelfControl-Support-Hub" + static let faq = "https://github.com/SelfControlApp/selfcontrol/wiki/FAQ" + } + } +} + diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Views/AboutView.swift b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Views/AboutView.swift new file mode 100644 index 00000000..45aeaffc --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Views/AboutView.swift @@ -0,0 +1,149 @@ +// +// AboutView.swift +// SelfControlUI +// +// Created on 2025-10-31. +// + +import SwiftUI +import AppKit + +// MARK: - About View +struct AboutView: View { + @Binding var currentScreen: AppScreen + + var body: some View { + ZStack { + // Dark gradient background + DesignSystem.backgroundGradient + .ignoresSafeArea() + + VStack(spacing: 0) { + // Header with back button + HStack(alignment: .center) { + Button(action: { + currentScreen = .main + }) { + HStack(spacing: 4) { + Image(systemName: "chevron.left") + .font(DesignSystem.font(size: DesignSystem.fontSizeLarge, weight: DesignSystem.fontWeightSemibold)) + Text(Strings.Common.back) + .font(DesignSystem.font(size: DesignSystem.fontSizeLarge, weight: DesignSystem.fontWeightMedium)) + } + .foregroundColor(DesignSystem.textPrimary) + } + .buttonStyle(.plain) + .keyboardShortcut(.cancelAction) + + Spacer() + + Text(Strings.About.title) + .font(DesignSystem.font(size: DesignSystem.fontSizeXXLarge, weight: DesignSystem.fontWeightSemibold)) + .foregroundColor(DesignSystem.textPrimary) + + Spacer() + + Text("") + .font(DesignSystem.font(size: DesignSystem.fontSizeBase, weight: DesignSystem.fontWeightMedium)) + .foregroundColor(DesignSystem.textSecondary) + .frame(width: 60, alignment: .trailing) + } + .padding(.horizontal, DesignSystem.spacingLarge) + .padding(.vertical, DesignSystem.spacingSmall) + + Divider() + .background(DesignSystem.borderPrimary) + + ScrollView { + VStack(spacing: DesignSystem.spacingSmall) { + // Icon and Title + VStack(spacing: 1) { + if let nsImage = NSImage(named: "AppIcon") { + Image(nsImage: nsImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 80, height: 80) + } + Text(Strings.About.appName) + .font(DesignSystem.font(size: DesignSystem.fontSizeDisplayLarge, weight: DesignSystem.fontWeightBold)) + .foregroundColor(DesignSystem.textPrimary) + + Text(Strings.About.version) + .font(DesignSystem.font(size: DesignSystem.fontSizeLarge)) + .foregroundColor(DesignSystem.textSecondary) + + AboutItemView(item: Strings.About.mainWebsite) + .font(DesignSystem.font(size: DesignSystem.fontSizeMedium)) + } + .padding(.top, DesignSystem.spacingLarge) + + // Credits section + VStack(spacing: DesignSystem.spacingSmall) { + // Developers + AboutItemsRow(items: Strings.About.developers) + + // Icon and contributors + AboutItemsRow(items: Strings.About.iconAndContributorsLine1) + AboutItemsRow(items: Strings.About.iconAndContributorsLine2) + + // Error reporting + AboutItemsRow(items: Strings.About.errorReporting) + + // License info + AboutItemsRow(items: Strings.About.license) + + // Source code + AboutItemsRow(items: Strings.About.sourceCode) + + // Footer + Text(Strings.About.freeAndOpenSource) + .font(DesignSystem.font(size: DesignSystem.fontSizeMedium)) + .foregroundColor(DesignSystem.textSecondary) + .multilineTextAlignment(.center) + } + } + .frame(maxWidth: .infinity) + .padding(.horizontal, DesignSystem.spacingLarge) + .padding(.bottom, DesignSystem.spacingLarge) + } + .frame(maxWidth: .infinity) + } + } + } +} + +// MARK: - About Item View +struct AboutItemView: View { + let item: AboutItem + + var body: some View { + switch item.type { + case .text: + Text(item.text) + .foregroundColor(DesignSystem.textPrimary) + case .link: + if let urlString = item.url, let url = URL(string: urlString) { + Link(item.text, destination: url) + } else { + Text(item.text) + .foregroundColor(DesignSystem.textPrimary) + } + } + } +} + +// MARK: - About Items Row +struct AboutItemsRow: View { + let items: [AboutItem] + + var body: some View { + HStack(spacing: 4) { + ForEach(Array(items.enumerated()), id: \.offset) { _, item in + AboutItemView(item: item) + } + } + .font(DesignSystem.font(size: DesignSystem.fontSizeMedium)) + .multilineTextAlignment(.center) + } +} + diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Views/AdvancedSettingsView.swift b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Views/AdvancedSettingsView.swift new file mode 100644 index 00000000..2505f162 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Views/AdvancedSettingsView.swift @@ -0,0 +1,264 @@ +// +// AdvancedSettingsView.swift +// SelfControlUI +// +// Created on 2025-10-31. +// + +import SwiftUI + +// MARK: - Advanced Settings View +struct AdvancedSettingsView: View { + @Binding var currentScreen: AppScreen + @Binding var blockingMode: BlockingMode + let isBlockingMode: Bool + + // Network Settings + @AppStorage("VerifyInternetConnection") private var verifyConnection = true + @AppStorage("clearCache") private var clearCache = true + @AppStorage("allowLocalNetworks") private var allowLocalNetworks = true + + // Blocking Settings + @AppStorage("blockCommonSubdomains") private var blockCommonSubdomains = true + @AppStorage("highlightInvalidHosts") private var highlightInvalidHosts = true + @AppStorage("allowlistLinkedSites") private var allowlistLinkedSites = true + + // General Settings + @AppStorage("autoCheckUpdates") private var autoCheckUpdates = true + @AppStorage("sendErrorReports") private var sendErrorReports = false + @AppStorage("playSoundOnCompletion") private var playSoundOnCompletion = true + @AppStorage("showNotificationOnCompletion") private var showNotificationOnCompletion = true + + @AppStorage("timerFloatsOnTop") private var timerFloatsOnTop = false + @AppStorage("BadgeApplicationIcon") private var showCountdownInDock = true + + // Display Settings + @AppStorage("hideSeconds") private var hideSeconds = false + + @State private var showingAllowlistWarning = false + + var body: some View { + ZStack { + // Dark gradient background + DesignSystem.backgroundGradient + .ignoresSafeArea() + + VStack(spacing: 0) { + // Compact header + HStack(alignment: .center) { + Button(action: { + currentScreen = .editList + }) { + HStack(spacing: 4) { + Image(systemName: "chevron.left") + .font(DesignSystem.font(size: DesignSystem.fontSizeLarge, weight: DesignSystem.fontWeightSemibold)) + Text(Strings.Common.back) + .font(DesignSystem.font(size: DesignSystem.fontSizeLarge, weight: DesignSystem.fontWeightMedium)) + } + .foregroundColor(.white) + } + .buttonStyle(.plain) + .keyboardShortcut(.cancelAction) + + Spacer() + + Text(Strings.AdvancedSettings.title) + .font(DesignSystem.font(size: DesignSystem.fontSizeXXLarge, weight: DesignSystem.fontWeightSemibold)) + .foregroundColor(DesignSystem.textPrimary) + + Spacer() + + Text("") + .font(DesignSystem.font(size: DesignSystem.fontSizeBase, weight: DesignSystem.fontWeightMedium)) + .foregroundColor(DesignSystem.textSecondary) + .frame(width: 60, alignment: .trailing) + } + .padding(.horizontal, DesignSystem.spacingLarge) + .padding(.vertical, DesignSystem.spacingSmall) + + Divider() + .background(DesignSystem.borderPrimary) + + ScrollView { + VStack(spacing: 10) { + // Blocking Settings + VStack(alignment: .leading, spacing: 8) { + Text(Strings.AdvancedSettings.blocking) + .font(DesignSystem.font(size: DesignSystem.fontSizeSmall, weight: DesignSystem.fontWeightSemibold)) + .foregroundColor(DesignSystem.textTertiary) + .tracking(1.2) + + VStack(spacing: 0) { + settingRow(title: Strings.AdvancedSettings.blockCommonSubdomains, isOn: $blockCommonSubdomains, showDivider: true) + settingRow(title: Strings.AdvancedSettings.highlightInvalidHosts, isOn: $highlightInvalidHosts, showDivider: true) + settingRow(title: Strings.AdvancedSettings.allowlistLinkedSites, isOn: $allowlistLinkedSites, showDivider: false) + } + } + .padding(DesignSystem.spacingMedium) + .background( + RoundedRectangle(cornerRadius: DesignSystem.radiusSmall) + .fill(DesignSystem.backgroundPrimary.opacity(DesignSystem.opacityHigher)) + ) + + // General Settings + VStack(alignment: .leading, spacing: 8) { + Text(Strings.AdvancedSettings.general) + .font(DesignSystem.font(size: DesignSystem.fontSizeSmall, weight: DesignSystem.fontWeightSemibold)) + .foregroundColor(DesignSystem.textTertiary) + .tracking(1.2) + + VStack(spacing: 0) { + settingRow(title: Strings.AdvancedSettings.autoCheckUpdates, isOn: $autoCheckUpdates, showDivider: true) +// settingRow(title: Strings.AdvancedSettings.autoSendErrorReports, isOn: $sendErrorReports, showDivider: true) + settingRow(title: Strings.AdvancedSettings.playSoundOnCompletion, isOn: $playSoundOnCompletion, showDivider: false) {value in + HelperConnection.shared.send_setPreference(key: AppPreferences.playSoundOnCompletionkey, value: value) + } + settingRow(title: Strings.AdvancedSettings.showNotificationOnCompletion, isOn: $showNotificationOnCompletion, showDivider: false) + } + } + .padding(DesignSystem.spacingMedium) + .background( + RoundedRectangle(cornerRadius: DesignSystem.radiusSmall) + .fill(DesignSystem.backgroundPrimary.opacity(DesignSystem.opacityHigher)) + ) + + // Network & Cache + VStack(alignment: .leading, spacing: 8) { + Text(Strings.AdvancedSettings.networkAndCache) + .font(DesignSystem.font(size: DesignSystem.fontSizeSmall, weight: DesignSystem.fontWeightSemibold)) + .foregroundColor(DesignSystem.textTertiary) + .tracking(1.2) + + VStack(spacing: 0) { + settingRow(title: Strings.AdvancedSettings.verifyInternetConnection, isOn: $verifyConnection, showDivider: true) +// settingRow(title: Strings.AdvancedSettings.clearBrowserCache, isOn: $clearCache, showDivider: true) + settingRow(title: Strings.AdvancedSettings.allowLocalNetworks, isOn: $allowLocalNetworks, showDivider: false) + } + } + .padding(DesignSystem.spacingMedium) + .background( + RoundedRectangle(cornerRadius: DesignSystem.radiusSmall) + .fill(DesignSystem.backgroundPrimary.opacity(DesignSystem.opacityHigher)) + ) + + // Display & Timer + VStack(alignment: .leading, spacing: 8) { + Text(Strings.AdvancedSettings.displayAndTimer) + .font(DesignSystem.font(size: DesignSystem.fontSizeSmall, weight: DesignSystem.fontWeightSemibold)) + .foregroundColor(DesignSystem.textTertiary) + .tracking(1.2) + + VStack(spacing: 0) { +// settingRow(title: Strings.AdvancedSettings.timerFloatsOnTop, isOn: $timerFloatsOnTop, showDivider: true) + settingRow(title: Strings.AdvancedSettings.showCountdownInDock, isOn: $showCountdownInDock, showDivider: true) +// settingRow(title: Strings.AdvancedSettings.hideSecondsInCountdown, isOn: $hideSeconds, showDivider: false) + } + } + .padding(DesignSystem.spacingMedium) + .background( + RoundedRectangle(cornerRadius: DesignSystem.radiusSmall) + .fill(DesignSystem.backgroundPrimary.opacity(DesignSystem.opacityHigher)) + ) + + // Mode Settings +// VStack(alignment: .leading, spacing: 8) { +// Text(Strings.AdvancedSettings.mode) +// .font(DesignSystem.font(size: DesignSystem.fontSizeSmall, weight: DesignSystem.fontWeightSemibold)) +// .foregroundColor(DesignSystem.textTertiary) +// .tracking(1.2) +// +// // Allowlist mode checkbox with warning +// VStack(alignment: .leading, spacing: 8) { +// Toggle(isOn: Binding( +// get: { blockingMode == .allowlist }, +// set: { newValue in +// if !isBlockingMode { +// if newValue { +// showingAllowlistWarning = true +// } else { +// withAnimation(DesignSystem.animationFast) { +// blockingMode = .blocklist +// } +// } +// } +// } +// )) { +// HStack(spacing: 8) { +// Image(systemName: "exclamationmark.shield.fill") +// .font(DesignSystem.font(size: DesignSystem.fontSizeLarge)) +// .foregroundColor(blockingMode == .allowlist ? Color.white : DesignSystem.textSecondary) +// VStack(alignment: .leading, spacing: 2) { +// Text(Strings.AdvancedSettings.allowlistMode) +// .font(DesignSystem.font(size: DesignSystem.fontSizeLarge, weight: DesignSystem.fontWeightMedium)) +// .foregroundColor(DesignSystem.textPrimary) +// Text(Strings.AdvancedSettings.allowlistModeDescription) +// .font(DesignSystem.font(size: DesignSystem.fontSizeSmall)) +// .foregroundColor(DesignSystem.textSecondary) +// } +// } +// } +// .toggleStyle(SwitchToggleStyle(tint: DesignSystem.toggleTint)) +// .disabled(isBlockingMode) +// .opacity(isBlockingMode ? DesignSystem.disabledOpacity : DesignSystem.opacityFull) +// .padding(DesignSystem.spacingMedium) +// .background( +// RoundedRectangle(cornerRadius: DesignSystem.radiusMedium) +// .fill(blockingMode == .allowlist ? DesignSystem.textPrimary.opacity(DesignSystem.opacityMedium) : DesignSystem.backgroundTertiary) +// .overlay( +// RoundedRectangle(cornerRadius: DesignSystem.radiusMedium) +// .stroke(blockingMode == .allowlist ? DesignSystem.hoverBorder.opacity(DesignSystem.opacityHigher) : DesignSystem.borderPrimary, lineWidth: 1) +// ) +// ) +// } +// } +// .padding(DesignSystem.spacingMedium) +// .background( +// RoundedRectangle(cornerRadius: DesignSystem.radiusSmall) +// .fill(DesignSystem.backgroundPrimary.opacity(DesignSystem.opacityHigher)) +// ) + } + .padding(DesignSystem.spacingLarge) + } + } + } + .alert(isPresented: $showingAllowlistWarning) { + Alert( + title: Text(Strings.AdvancedSettings.enableAllowlistTitle), + message: Text(Strings.AdvancedSettings.enableAllowlistMessage), + primaryButton: .default(Text(Strings.AdvancedSettings.enableAllowlist), action: { + withAnimation(DesignSystem.animationFast) { + blockingMode = .allowlist + } + }), + secondaryButton: .cancel(Text(Strings.Common.cancel)) + ) + } + } + + private func settingRow(title: String, isOn: Binding, showDivider: Bool, onChange:( (Bool) -> Void)? = nil) -> some View { + VStack(spacing: 0) { + HStack(alignment: .center) { + Toggle(isOn: isOn) { + Text(title) + .font(DesignSystem.font(size: DesignSystem.fontSizeMedium)) + .foregroundColor(DesignSystem.textPrimary) + .multilineTextAlignment(.leading) + }.onChange(of: isOn.wrappedValue) { _ in + onChange?(isOn.wrappedValue) + } + .toggleStyle(CheckboxToggleStyle()) + Spacer() + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, DesignSystem.spacingMediumSmall) + .padding(.vertical, DesignSystem.spacingSmall) + + if showDivider { + Divider() + .background(DesignSystem.borderPrimary) + .padding(.leading, 12) + } + } + } +} + diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Views/BlockScheduleView.swift b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Views/BlockScheduleView.swift new file mode 100644 index 00000000..80179ab1 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Views/BlockScheduleView.swift @@ -0,0 +1,483 @@ +// +// BlockScheduleView.swift +// SelfControlUI +// +// Created on 2025-10-31. +// + +import SwiftUI +import AppKit + +// MARK: - Block Schedule View +struct BlockScheduleView: View { + @Binding var currentScreen: AppScreen + @Binding var blockingMode: BlockingMode + @Binding var isScheduleActive: Bool + @EnvironmentObject var viewModel: FilterViewModel + + @State private var schedules: [Schedule] = [] + + // Helper to check if any schedule is active + private func updateScheduleStatus() { + isScheduleActive = schedules.contains { schedule in + schedule.isEnabled && + !schedule.timeSlots.isEmpty && + !schedule.enabledDays.isEmpty + } + } + + private func loadSchedules() { + schedules = EventSchedulerStore.loadSchedules() + } + + private func saveSchedules() { + EventSchedulerStore.saveSchedules(schedules: schedules) + } + + var body: some View { + ZStack { + // Dark gradient background + DesignSystem.backgroundGradient + .ignoresSafeArea() + + VStack(spacing: 0) { + // Header + HStack(alignment: .center) { + Button(action: { + currentScreen = .main + }) { + HStack(spacing: 4) { + Image(systemName: "chevron.left") + .font(DesignSystem.font(size: DesignSystem.fontSizeLarge, weight: DesignSystem.fontWeightSemibold)) + Text(Strings.Common.back) + .font(DesignSystem.font(size: DesignSystem.fontSizeLarge, weight: DesignSystem.fontWeightMedium)) + } + .foregroundColor(.white) + } + .buttonStyle(.plain) + .keyboardShortcut(.cancelAction) + + Spacer() + + Text(Strings.BlockSchedule.title) + .font(DesignSystem.font(size: DesignSystem.fontSizeXXLarge, weight: DesignSystem.fontWeightSemibold)) + .foregroundColor(DesignSystem.textPrimary) + + Spacer() + + // Add schedule button + Button(action: { + withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) { + schedules.append(Schedule(timeSlots: [], enabledDays: [])) + saveSchedules() + updateScheduleStatus() + } + }) { + Image(systemName: "plus") + .font(DesignSystem.font(size: DesignSystem.fontSizeLarge, weight: DesignSystem.fontWeightSemibold)) + .foregroundColor(.white) + } + .buttonStyle(.plain) + .frame(width: 60, alignment: .trailing) + } + .padding(.horizontal, DesignSystem.spacingLarge) + .padding(.vertical, DesignSystem.spacingSmall) + + Divider() + .background(DesignSystem.borderPrimary) + + ScrollView { + VStack(spacing: DesignSystem.spacingMedium) { + + // Schedule list + VStack(spacing: DesignSystem.spacingMedium) { + ForEach($schedules) { $schedule in + ScheduleRow( + schedule: $schedule, + blockingMode: blockingMode, + onDelete: { + withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) { + schedules.removeAll { $0.id == schedule.id } +// Task { +// saveSchedules() +// updateScheduleStatus() +// } + } + } + ) + .transition(.asymmetric( + insertion: .move(edge: .top).combined(with: .opacity), + removal: .move(edge: .bottom).combined(with: .opacity) + )) + } + + // Invisible spacer to anchor animations (always present) + Color.clear + .frame(height: 1) + .allowsHitTesting(false) + } + .padding(.horizontal, DesignSystem.spacingLarge) + } + .padding(.vertical, DesignSystem.spacingLarge) + } + } + } + .onChange(of: schedules) { _ in + saveSchedules() + updateScheduleStatus() + } + .onAppear { + loadSchedules() + updateScheduleStatus() + } + .onDisappear { + print("Schedule off-screen") + } + } +} + +// MARK: - Schedule Row +struct ScheduleRow: View { + @Binding var schedule: Schedule + let blockingMode: BlockingMode + let onDelete: () -> Void + + var body: some View { + VStack(spacing: 0) { + // Header row with time slots summary and weekdays + VStack(spacing: DesignSystem.spacingSmall) { + HStack(spacing: DesignSystem.spacingMedium) { + // Toggle switch to enable/disable schedule + Toggle("", isOn: $schedule.isEnabled) + .toggleStyle(SwitchToggleStyle(tint: Color(red: 0.85, green: 0.95, blue: 1.0))) + .labelsHidden() + + // Time slots summary or placeholder + if schedule.timeSlots.isEmpty { + Text(Strings.BlockSchedule.noTimeSlots) + .font(DesignSystem.font(size: DesignSystem.fontSizeBase)) + .foregroundColor(DesignSystem.textTertiary) + } else { + VStack(alignment: .leading, spacing: 4) { + ForEach(schedule.timeSlots) { slot in + Text(slot.displayString) + .font(DesignSystem.font(size: DesignSystem.fontSizeBase)) + .foregroundColor(DesignSystem.textSecondary) + } + } + } + + Spacer() + + // Weekday circles + HStack(spacing: 6) { + ForEach(SCWeekday.allCases, id: \.self) { weekday in + WeekdayCircle( + weekday: weekday, + isEnabled: schedule.enabledDays.contains(weekday), + blockingMode: blockingMode,// + onToggle: { + withAnimation(DesignSystem.animationFast) { + if schedule.enabledDays.contains(weekday) { + schedule.enabledDays.remove(weekday) + } else { + schedule.enabledDays.insert(weekday) + } + } + } + ) + } + } + + // Expand/collapse chevron (on the right) + Button(action: { + withAnimation(DesignSystem.animationFast) { + schedule.isExpanded.toggle() + // Auto-add first time slot when expanded and no slots exist + if schedule.isExpanded && schedule.timeSlots.isEmpty { + let defaultSlot = TimeSlot( + startHour: 9, + startMinute: 0, + endHour: 17, + endMinute: 0 + ) + schedule.timeSlots.append(defaultSlot) + } + } + }) { + Image(systemName: schedule.isExpanded ? "chevron.down" : "chevron.right") + .font(DesignSystem.font(size: DesignSystem.fontSizeBase, weight: DesignSystem.fontWeightSemibold)) + .foregroundColor(DesignSystem.textSecondary) + .frame(width: 20) + } + .buttonStyle(.plain) + } + .padding(.horizontal, DesignSystem.spacingMedium) + .padding(.vertical, DesignSystem.spacingMedium) + + // Expanded time slots + if schedule.isExpanded { + VStack(spacing: 4) { + ForEach(schedule.timeSlots) { slot in + if let index = schedule.timeSlots.firstIndex(where: { $0.id == slot.id }) { + TimeSlotRow( + timeSlot: $schedule.timeSlots[index], + blockingMode: blockingMode, + onDelete: { + withAnimation(DesignSystem.animationFast) { + schedule.timeSlots.removeAll { $0.id == slot.id } + } + }, + showDelete: schedule.timeSlots.count > 1 + ) + } + } + + // Add time slot button + Button(action: { + withAnimation(DesignSystem.animationFast) { + let lastEndHour = schedule.timeSlots.last?.endHour ?? 17 + let lastEndMinute = schedule.timeSlots.last?.endMinute ?? 0 + + let defaultSlot = TimeSlot( + startHour: schedule.timeSlots.isEmpty ? 9 : lastEndHour, + startMinute: schedule.timeSlots.isEmpty ? 0 : lastEndMinute, + endHour: schedule.timeSlots.isEmpty ? 17 : min(23, lastEndHour + 2), + endMinute: schedule.timeSlots.isEmpty ? 0 : lastEndMinute + ) + schedule.timeSlots.append(defaultSlot) + } + }) { + HStack(spacing: 6) { + Image(systemName: "plus.circle.fill") + .font(DesignSystem.font(size: DesignSystem.fontSizeBase)) + Text(Strings.BlockSchedule.addTimeSlot) + .font(DesignSystem.font(size: DesignSystem.fontSizeBase, weight: DesignSystem.fontWeightMedium)) + } + .foregroundColor(Color(white: 0.85)) + .padding(.horizontal, DesignSystem.spacingMedium) + .padding(.vertical, DesignSystem.spacingSmallMedium) + .background( + RoundedRectangle(cornerRadius: DesignSystem.radiusSmall) + .fill(DesignSystem.textPrimary.opacity(DesignSystem.opacityLow)) + .overlay( + RoundedRectangle(cornerRadius: DesignSystem.radiusSmall) + .stroke(DesignSystem.textPrimary.opacity(DesignSystem.opacityLow), lineWidth: 1) + ) + ) + } + .buttonStyle(.plain) + .padding(.vertical, DesignSystem.spacingSmallMedium) + + // Delete schedule button (chip style) + Button(action: onDelete) { + HStack(spacing: 6) { + Image(systemName: "trash.fill") + .font(DesignSystem.font(size: DesignSystem.fontSizeBase)) + Text(Strings.BlockSchedule.deleteSchedule) + .font(DesignSystem.font(size: DesignSystem.fontSizeBase, weight: DesignSystem.fontWeightMedium)) + } + .foregroundColor(DesignSystem.destructiveColor) + .padding(.horizontal, DesignSystem.spacingMedium) + .padding(.vertical, DesignSystem.spacingSmallMedium) + .background( + RoundedRectangle(cornerRadius: DesignSystem.radiusSmall) + .fill(DesignSystem.destructiveColor.opacity(DesignSystem.opacityLow)) + .overlay( + RoundedRectangle(cornerRadius: DesignSystem.radiusSmall) + .stroke(DesignSystem.destructiveColor.opacity(DesignSystem.opacityMedium), lineWidth: 1) + ) + ) + } + .buttonStyle(.plain) + .padding(.top, DesignSystem.spacingSmall) + } + .padding(.horizontal, DesignSystem.spacingMedium) + .padding(.bottom, DesignSystem.spacingMedium) + } + } + } + .modifier(DesignSystem.cardStyle()) + } +} + +// MARK: - Weekday Circle +struct WeekdayCircle: View { + let weekday: SCWeekday + let isEnabled: Bool + let blockingMode: BlockingMode + let onToggle: () -> Void + + var body: some View { + Button(action: onToggle) { + Text(weekday.letter) + .font(DesignSystem.font(size: DesignSystem.fontSizeSmall, weight: DesignSystem.fontWeightSemibold)) + .foregroundColor(isEnabled ? Color(red: 0.15, green: 0.15, blue: 0.17) : DesignSystem.textSecondary) + .frame(width: 28, height: 28) + .background( + Circle() + .fill(isEnabled ? DesignSystem.textPrimary : DesignSystem.backgroundSecondary) + .overlay( + Circle() + .stroke(isEnabled ? DesignSystem.textPrimary : DesignSystem.borderPrimary, lineWidth: 1) + ) + ) + } + .buttonStyle(.plain) + } +} + +// MARK: - Time Slot Row +struct TimeSlotRow: View { + @Binding var timeSlot: TimeSlot + let blockingMode: BlockingMode + let onDelete: () -> Void + let showDelete: Bool + + var body: some View { + HStack(spacing: 6) { + // Start time picker + DatePicker( + "", + selection: Binding( + get: { TimeHelpers.dateFromTime(hour: timeSlot.startHour, minute: timeSlot.startMinute) }, + set: { newDate in + let time = TimeHelpers.timeFromDate(newDate) + timeSlot.startHour = time.hour + timeSlot.startMinute = time.minute + } + ), + displayedComponents: .hourAndMinute + ) + .datePickerStyle(.compact) + .labelsHidden() + .styleDatePicker(cornerRadius: DesignSystem.radiusSmall, backgroundColor: DesignSystem.backgroundPrimary, borderColor: DesignSystem.borderPrimary) + .frame(width: 105) + + Text(Strings.BlockSchedule.to) + .font(DesignSystem.font(size: DesignSystem.fontSizeBase, weight: DesignSystem.fontWeightMedium)) + .foregroundColor(DesignSystem.textTertiary) + .padding(.horizontal, DesignSystem.spacingXXSmall) + + // End time picker + DatePicker( + "", + selection: Binding( + get: { TimeHelpers.dateFromTime(hour: timeSlot.endHour, minute: timeSlot.endMinute) }, + set: { newDate in + let time = TimeHelpers.timeFromDate(newDate) + timeSlot.endHour = time.hour + timeSlot.endMinute = time.minute + } + ), + displayedComponents: .hourAndMinute + ) + .datePickerStyle(.compact) + .labelsHidden() + .styleDatePicker(cornerRadius: DesignSystem.radiusSmall, backgroundColor: DesignSystem.backgroundPrimary, borderColor: DesignSystem.borderPrimary) + .frame(width: 105) + + Spacer() + + // Delete button + if showDelete { + Button(action: onDelete) { + Image(systemName: "trash.fill") + .font(DesignSystem.font(size: DesignSystem.fontSizeSmall)) + .foregroundColor(Color(white: 0.7)) + .padding(DesignSystem.spacingXSmall) + .background( + RoundedRectangle(cornerRadius: DesignSystem.radiusSmall) + .fill(DesignSystem.textPrimary.opacity(DesignSystem.opacityLow)) + .overlay( + RoundedRectangle(cornerRadius: DesignSystem.radiusSmall) + .stroke(DesignSystem.textPrimary.opacity(DesignSystem.opacityMedium), lineWidth: 1) + ) + ) + } + .buttonStyle(.plain) + } + } + .padding(.vertical, DesignSystem.spacingXSmall) + .padding(.horizontal, DesignSystem.spacingMedium) + } +} + +// MARK: - DatePicker Styler +struct DatePickerStyler: NSViewRepresentable { + let cornerRadius: CGFloat + let backgroundColor: NSColor + let borderColor: NSColor + + func makeNSView(context: Context) -> NSView { + let view = NSView() + view.wantsLayer = true + + DispatchQueue.main.async { + if let superview = view.superview { + self.styleDatePicker(in: superview) + } + } + + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + DispatchQueue.main.async { + if let superview = nsView.superview { + self.styleDatePicker(in: superview) + } + } + } + + private func styleDatePicker(in view: NSView) { + // Find the main container view (usually an NSStackView or similar) + if let stackView = view as? NSStackView { + stackView.wantsLayer = true + stackView.layer?.cornerRadius = cornerRadius + stackView.layer?.backgroundColor = backgroundColor.cgColor + stackView.layer?.borderWidth = 1 + stackView.layer?.borderColor = borderColor.cgColor + } + + // Style text fields + for subview in view.subviews { + if let textField = subview as? NSTextField { + textField.drawsBackground = false + textField.backgroundColor = .clear + textField.isBordered = false + textField.focusRingType = .none + } + + // Apply rounded corners to subviews + if subview.wantsLayer { + subview.layer?.cornerRadius = cornerRadius + subview.layer?.backgroundColor = backgroundColor.cgColor + } + + styleDatePicker(in: subview) + } + } +} + +extension Color { + var nsColor: NSColor { + if let cgColor = self.cgColor { + return NSColor(cgColor: cgColor) ?? NSColor(white: 0.5, alpha: 1.0) + } + return NSColor(white: 0.5, alpha: 1.0) + } +} + +extension View { + func styleDatePicker(cornerRadius: CGFloat, backgroundColor: Color, borderColor: Color) -> some View { + self.background( + DatePickerStyler( + cornerRadius: cornerRadius, + backgroundColor: backgroundColor.nsColor, + borderColor: borderColor.nsColor + ) + ) + } +} + diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Views/BlocklistEditorView.swift b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Views/BlocklistEditorView.swift new file mode 100644 index 00000000..38ba80c0 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Views/BlocklistEditorView.swift @@ -0,0 +1,1209 @@ +// +// BlocklistEditorView.swift +// SelfControlUI +// +// Created on 2025-10-31. +// + +import SwiftUI + +struct BlocklistEditorView: View { + @EnvironmentObject var viewModel: FilterViewModel + + @Binding var blockedURLs: [BlockedURL] { + didSet { + saveChanges() + } + } + let isBlockingMode: Bool + @Binding var blockingMode: BlockingMode + @Binding var currentScreen: AppScreen + @Binding var showingTips: Bool + + // Top-level UI state + @State private var newSite: String = "" + @State private var newPath: String = "" + @State private var searchText: String = "" + @AppStorage("showSuggestedSites") private var showSuggested: Bool = false + @State private var selectedDomain: UUID? = nil + @State private var focusedField: EditorFocusField? + @State private var showingAddSiteConfirmation = false + @State private var pendingSiteToAdd: String? = nil + + // Bulk-edit + selection + @State private var isBulkEditMode: Bool = false + @State private var selectedURLs: Set = [] + @State private var lastSelectedIndex: Int? = nil + + // Delete confirmation + @State private var showingDeleteConfirmation: Bool = false + + // Search/add UI + @State private var isSearchMode: Bool = false + + // Hover states + @State private var isHoveringSuggestedChip = false + @State private var isHoveringBulkEdit = false + @State private var isHoveringImport = false + @State private var isHoveringExport = false + @State private var isHoveringAdd = false + + // Suggested scroll + @State private var firstVisibleSuggestedID: String? + + enum EditorFocusField: Hashable { + case addWebsite + case search + case addPath + case doneButton + } + + // Suggested sites for quick adding + let suggestedSites = DomainConstants.suggestedSites + + // MARK: - Derived state + + var filteredURLs: [BlockedURL] { + if searchText.isEmpty { + return blockedURLs.sorted { $0.domain < $1.domain } + } + return blockedURLs + .filter { url in + url.domain.localizedCaseInsensitiveContains(searchText) || + url.paths.contains { $0.localizedCaseInsensitiveContains(searchText) } + } + .sorted { $0.domain < $1.domain } + } + + var totalCount: Int { + blockedURLs.count + blockedURLs.reduce(0) { $0 + $1.paths.count } + } + + var headerSubtitleText: String { + let enabledCount = blockedURLs.filter { $0.isEnabled }.count + let pathCount = blockedURLs.reduce(0) { $0 + $1.paths.count } + + if enabledCount != blockedURLs.count { + return "\(enabledCount)/\(blockedURLs.count) enabled" + (pathCount > 0 ? ", \(pathCount) paths" : "") + } else if pathCount > 0 { + return "\(blockedURLs.count) domain\(blockedURLs.count == 1 ? "" : "s"), \(pathCount) path\(pathCount == 1 ? "" : "s")" + } else { + return "\(blockedURLs.count) website\(blockedURLs.count == 1 ? "" : "s")" + } + } + + // MARK: - Body + + var body: some View { + ZStack { + DesignSystem.backgroundGradient + .ignoresSafeArea(.all) + .frame(maxWidth: .infinity, maxHeight: .infinity) + + VStack(spacing: 0) { + BlocklistHeader( + title: blockingMode == .blocklist ? Strings.BlocklistEditor.title : Strings.BlocklistEditor.allowlist, + onBack: { + selectedDomain = nil + currentScreen = .main + }, + onImport: { importurls() }, + onExport: { exporturls() }, + isBlockingMode: isBlockingMode, + isHoveringImport: $isHoveringImport, + isHoveringExport: $isHoveringExport + ) + .padding(.horizontal, DesignSystem.spacingLarge) + .padding(.vertical, DesignSystem.spacingSmall) + + Divider().background(DesignSystem.borderPrimary) + + if blockingMode == .allowlist { + AllowlistBanner() + .padding(.horizontal, DesignSystem.spacingLarge) + .padding(.top, DesignSystem.spacingMedium) + } + + AddSearchBar( + isSearchMode: $isSearchMode, + focusedField: $focusedField, + searchText: $searchText, + newSite: $newSite, + isHoveringAdd: $isHoveringAdd, + onAdd: { addWebsite() } + ) + .padding(.horizontal, DesignSystem.spacingLarge) + .padding(.vertical, DesignSystem.spacingSmall) + + Divider().background(DesignSystem.borderPrimary) + + DomainListSection( + filteredURLs: filteredURLs, + blockedURLs: $blockedURLs, + isBlockingMode: isBlockingMode, + isSearchMode: isSearchMode, + searchText: searchText, + showSuggested: $showSuggested, + suggestedSites: suggestedSites, + isHoveringSuggestedChip: $isHoveringSuggestedChip, + isBulkEditMode: $isBulkEditMode, + isHoveringBulkEdit: $isHoveringBulkEdit, + selectedURLs: $selectedURLs, + lastSelectedIndex: $lastSelectedIndex, + onToggleBulkEdit: toggleBulkEdit, + onSelectRow: handleRowSelection(urlId:index:isShiftPressed:), + onNavigateToDetail: { id in currentScreen = .domainDetail(id) }, + onRequestAddSuggested: handleAddSuggested(site:) + ) + .padding(DesignSystem.spacingXLarge) + + BottomActionBarView( + filteredURLs: filteredURLs, + selectedURLs: $selectedURLs, + isBulkEditMode: isBulkEditMode, + onToggleAllFiltered: toggleAllFilteredSelection, + onDelete: { showingDeleteConfirmation = true } + ) + } + + if showingDeleteConfirmation { + DeleteConfirmationModal( + count: selectedURLs.count, + onCancel: { showingDeleteConfirmation = false }, + onConfirm: { + deleteSelectedURLs() + showingDeleteConfirmation = false + } + ) + } + } + .alert( + Strings.BlocklistEditor.addToExistingBlockTitle, + isPresented: $showingAddSiteConfirmation, + presenting: pendingSiteToAdd + ) { site in + Button(Strings.BlocklistEditor.addSite) { + confirmAddWebsite() + } + Button(Strings.Common.cancel, role: .cancel) { + pendingSiteToAdd = nil + } + } message: { site in + Text(Strings.BlocklistEditor.addToExistingBlockMessage(site, blockingMode: blockingMode)) + } + + .onAppear { + // Reset to list view and focus on add website field when editor opens + selectedDomain = nil + focusedField = .addWebsite + } + } + + // MARK: - Actions + + func handleRowSelection(urlId: UUID, index: Int, isShiftPressed: Bool) { + handleCheckboxClick(for: urlId, at: index, isShiftPressed: isShiftPressed) + } + + func toggleBulkEdit() { + withAnimation { + isBulkEditMode.toggle() + if !isBulkEditMode { + selectedURLs.removeAll() + lastSelectedIndex = nil + } + } + } + + func toggleAllFilteredSelection() { + withAnimation(DesignSystem.animationFast) { + let filteredIDs = Set(filteredURLs.map { $0.id }) + let allFilteredSelected = !filteredURLs.isEmpty && filteredIDs.isSubset(of: selectedURLs) + + if allFilteredSelected { + for id in filteredIDs { + selectedURLs.remove(id) + } + } else { + for id in filteredIDs { + selectedURLs.insert(id) + } + } + } + } + + func handleAddSuggested(site: String) { + let isAlreadyBlocked = blockedURLs.contains { $0.domain == site } + guard !isAlreadyBlocked else { return } + + if isBlockingMode { + pendingSiteToAdd = site + DispatchQueue.main.async { + showingAddSiteConfirmation = true + } + } else { + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + blockedURLs.append(BlockedURL(domain: site)) + } + } + } + + func handleCheckboxClick(for urlId: UUID, at index: Int, isShiftPressed: Bool = false) { + if isShiftPressed, let lastIndex = lastSelectedIndex { + let startIndex = min(lastIndex, index) + let endIndex = max(lastIndex, index) + let range = startIndex...endIndex + + let isCurrentlySelected = selectedURLs.contains(urlId) + + for i in range { + let itemId = filteredURLs[i].id + if isCurrentlySelected { + selectedURLs.remove(itemId) + } else { + selectedURLs.insert(itemId) + } + } + } else { + if selectedURLs.contains(urlId) { + selectedURLs.remove(urlId) + } else { + selectedURLs.insert(urlId) + } + lastSelectedIndex = index + } + } + + func deleteSelectedURLs() { + withAnimation(DesignSystem.animationFast) { + blockedURLs = blockedURLs.filter { !selectedURLs.contains($0.id) } + selectedURLs.removeAll() + + if blockedURLs.isEmpty { + isBulkEditMode = false + } + } + } + + // MARK: - Domain/path editing + + private func addWebsite() { + let input = newSite.trimmingCharacters(in: .whitespaces) + if input.isEmpty { return } + + let (domain, path) = BlockedURLsHelpers.parseDomainAndPath(from: input) + if domain.isEmpty { return } + + var domainId: UUID + if let existingDomain = blockedURLs.first(where: { $0.domain == domain }) { + domainId = existingDomain.id + } else { + if isBlockingMode { + pendingSiteToAdd = input + showingAddSiteConfirmation = true + return + } else { + let newDomain = BlockedURL(domain: domain) + domainId = newDomain.id + withAnimation { + blockedURLs.append(newDomain) + } + } + } + + if let pathToAdd = path { + if let index = blockedURLs.firstIndex(where: { $0.id == domainId }) { + let formattedPath = pathToAdd.hasPrefix("/") || pathToAdd.contains(".") ? pathToAdd : "/\(pathToAdd)" + if !blockedURLs[index].paths.contains(formattedPath) { + withAnimation { + blockedURLs[index].paths.append(formattedPath) + saveChanges() + newSite = "" + currentScreen = .domainDetail(domainId) + } + return + } else { + withAnimation { + newSite = "" + currentScreen = .domainDetail(domainId) + } + return + } + } + } + + let domainExisted = blockedURLs.contains(where: { $0.id == domainId && $0.domain == domain }) + if !domainExisted { + withAnimation { + newSite = "" + currentScreen = .domainDetail(domainId) + } + } else { + newSite = "" + } + } + + private func confirmAddWebsite() { + guard let input = pendingSiteToAdd else { return } + let (domain, path) = BlockedURLsHelpers.parseDomainAndPath(from: input) + + if domain.isEmpty { + pendingSiteToAdd = nil + return + } + + var domainId: UUID + if let existingDomain = blockedURLs.first(where: { $0.domain == domain }) { + domainId = existingDomain.id + } else { + let newDomain = BlockedURL(domain: domain) + domainId = newDomain.id + withAnimation { + blockedURLs.append(newDomain) + } + } + + if let pathToAdd = path { + if let index = blockedURLs.firstIndex(where: { $0.id == domainId }) { + let formattedPath = pathToAdd.hasPrefix("/") || pathToAdd.contains(".") ? pathToAdd : "/\(pathToAdd)" + if !blockedURLs[index].paths.contains(formattedPath) { + withAnimation { + blockedURLs[index].paths.append(formattedPath) + saveChanges() + newSite = "" + pendingSiteToAdd = nil + currentScreen = .domainDetail(domainId) + } + return + } else { + withAnimation { + newSite = "" + pendingSiteToAdd = nil + currentScreen = .domainDetail(domainId) + } + return + } + } + } + + let domainExisted = blockedURLs.contains(where: { $0.id == domainId && $0.domain == domain }) + if !domainExisted { + withAnimation { + newSite = "" + pendingSiteToAdd = nil + currentScreen = .domainDetail(domainId) + } + } else { + withAnimation { + newSite = "" + pendingSiteToAdd = nil + } + } + } + + private func addPath(to urlId: UUID) { + let cleanPath = newPath.trimmingCharacters(in: .whitespaces) + if !cleanPath.isEmpty, let index = blockedURLs.firstIndex(where: { $0.id == urlId }) { + let formattedPath = cleanPath.hasPrefix("/") || cleanPath.contains(".") ? cleanPath : "/\(cleanPath)" + + if !blockedURLs[index].paths.contains(formattedPath) { + withAnimation { + blockedURLs[index].paths.append(formattedPath) + saveChanges() + newPath = "" + } + } + } + } + + private func saveChanges() { + viewModel.saveAndupdateBlockList(blockedURLs) + } +} + +// MARK: - Import/Export + +extension BlocklistEditorView { + func importurls() { + if let window = NSApplication.shared.keyWindow { + Task { @MainActor in + do { + let imported = try await ImportExportManager.importBlockedUrls(presentingWindow: window) + self.blockedURLs = imported + } catch { + NSLog("Import failed: \(error.localizedDescription)") + } + } + } else { + Task { @MainActor in + do { + let imported = try await ImportExportManager.importBlockedUrls() + self.blockedURLs = imported + } catch { + NSLog("Import failed: \(error.localizedDescription)") + } + } + } + } + + func exporturls() { + ImportExportManager.exportBlockedUrls(blockedURLs: self.blockedURLs) + } +} + +// MARK: - Subviews + +private struct BlocklistHeader: View { + let title: String + let onBack: () -> Void + let onImport: () -> Void + let onExport: () -> Void + let isBlockingMode: Bool + + @Binding var isHoveringImport: Bool + @Binding var isHoveringExport: Bool + + var body: some View { + HStack(alignment: .center) { + Button(action: onBack) { + HStack(spacing: 4) { + Image(systemName: "chevron.left") + .font(DesignSystem.font(size: DesignSystem.fontSizeLarge, weight: DesignSystem.fontWeightSemibold)) + Text(Strings.Common.back) + .font(DesignSystem.font(size: DesignSystem.fontSizeLarge, weight: DesignSystem.fontWeightMedium)) + } + .foregroundColor(DesignSystem.textPrimary) + } + .buttonStyle(.plain) + .keyboardShortcut(.cancelAction) + + Spacer() + + Button(action: onImport) { + HStack(spacing: 4) { + Image(systemName: "square.and.arrow.down") + .font(DesignSystem.font(size: DesignSystem.fontSizeBase)) + Text(Strings.Common.importText) + .font(DesignSystem.font(size: DesignSystem.fontSizeBase, weight: DesignSystem.fontWeightMedium)) + } + .foregroundColor(DesignSystem.textPrimary) + .padding(.horizontal, DesignSystem.spacingSmall) + .padding(.vertical, DesignSystem.spacingXSmall) + .background( + RoundedRectangle(cornerRadius: DesignSystem.radiusMedium) + .fill(isHoveringImport ? DesignSystem.hoverBackground : Color.clear) + ) + .modifier(DesignSystem.buttonStyle()) + } + .buttonStyle(.plain) + .focusable(false) + .disabled(isBlockingMode) + .onHover { hovering in + withAnimation(DesignSystem.animationFast) { + isHoveringImport = hovering + } + } + + Button(action: onExport) { + HStack(spacing: 4) { + Image(systemName: "square.and.arrow.up") + .font(DesignSystem.font(size: DesignSystem.fontSizeBase)) + Text(Strings.Common.export) + .font(DesignSystem.font(size: DesignSystem.fontSizeBase, weight: DesignSystem.fontWeightMedium)) + } + .foregroundColor(DesignSystem.textPrimary) + .padding(.horizontal, DesignSystem.spacingSmall) + .padding(.vertical, DesignSystem.spacingXSmall) + .background( + RoundedRectangle(cornerRadius: DesignSystem.radiusMedium) + .fill(isHoveringExport ? DesignSystem.hoverBackground : Color.clear) + ) + .modifier(DesignSystem.buttonStyle()) + } + .buttonStyle(.plain) + .focusable(false) + .onHover { hovering in + withAnimation(DesignSystem.animationFast) { + isHoveringExport = hovering + } + } + } + .overlay( + Text(title) + .font(DesignSystem.font(size: DesignSystem.fontSizeXXLarge, weight: DesignSystem.fontWeightSemibold)) + .foregroundColor(DesignSystem.textPrimary) + .frame(maxWidth: .infinity, alignment: .center) + ) + } +} + +private struct AllowlistBanner: View { + var body: some View { + HStack(spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill") + .font(DesignSystem.font(size: DesignSystem.fontSizeLarge)) + .foregroundColor(DesignSystem.textPrimary) + + VStack(alignment: .leading, spacing: 2) { + Text(Strings.BlocklistEditor.allowlistModeActive) + .font(DesignSystem.font(size: DesignSystem.fontSizeSmall, weight: DesignSystem.fontWeightBold)) + .foregroundColor(DesignSystem.textPrimary) + Text(Strings.BlocklistEditor.allowlistModeDescription) + .font(DesignSystem.font(size: DesignSystem.fontSizeSmall)) + .foregroundColor(DesignSystem.textSecondary) + } + + Spacer() + } + .padding(DesignSystem.spacingMedium) + .background( + RoundedRectangle(cornerRadius: DesignSystem.radiusMedium) + .fill(DesignSystem.textPrimary.opacity(DesignSystem.opacityMedium)) + .overlay( + RoundedRectangle(cornerRadius: DesignSystem.radiusMedium) + .stroke(DesignSystem.hoverBorder.opacity(DesignSystem.opacityHigher), lineWidth: 1) + ) + ) + } +} + +private struct AddSearchBar: View { + @Binding var isSearchMode: Bool + @Binding var focusedField: BlocklistEditorView.EditorFocusField? + @Binding var searchText: String + @Binding var newSite: String + @Binding var isHoveringAdd: Bool + + let onAdd: () -> Void + + var body: some View { + HStack(spacing: DesignSystem.spacingSmall) { + HStack(spacing: 8) { + Group { + if isSearchMode { + Image(systemName: "magnifyingglass") + .font(DesignSystem.font(size: DesignSystem.fontSizeLarge)) + .foregroundColor(DesignSystem.textPrimary) + } else { + Image(systemName: "plus.circle.fill") + .font(DesignSystem.font(size: DesignSystem.fontSizeLarge)) + .foregroundColor(DesignSystem.textPrimary) + } + } + .transition(.opacity.combined(with: .scale(scale: 0.8))) + + Group { + if isSearchMode { + TextField(Strings.BlocklistEditor.searchPlaceholder, text: $searchText) + .textFieldStyle(.plain) + .font(DesignSystem.font(size: DesignSystem.fontSizeMedium)) + .foregroundColor(DesignSystem.textPrimary) + .placeholder(when: searchText.isEmpty, alignment: .leading) { + Text(Strings.BlocklistEditor.searchPlaceholder) + .foregroundColor(DesignSystem.disabledText) + .font(DesignSystem.font(size: DesignSystem.fontSizeMedium)) + .padding(.vertical, 8) + } + } else { + TextField(Strings.BlocklistEditor.addWebsitePlaceholder, text: $newSite, onCommit: onAdd) + .textFieldStyle(.plain) + .font(DesignSystem.font(size: DesignSystem.fontSizeMedium)) + .foregroundColor(DesignSystem.textPrimary) + .placeholder(when: newSite.isEmpty, alignment: .leading) { + Text(Strings.BlocklistEditor.addWebsitePlaceholder) + .foregroundColor(DesignSystem.disabledText) + .font(DesignSystem.font(size: DesignSystem.fontSizeMedium)) + .padding(.vertical, 8) + } + } + } + .transition(.opacity.combined(with: .move(edge: .trailing))) + + if isSearchMode { + Spacer().frame(minWidth: 8) + } + + Group { + if isSearchMode { + Button(action: { + withAnimation(DesignSystem.animationNormal) { + isSearchMode = false + searchText = "" + focusedField = .addWebsite + } + }) { + Text(Strings.Common.cancel) + .font(DesignSystem.font(size: DesignSystem.fontSizeBase, weight: DesignSystem.fontWeightMedium)) + .foregroundColor(DesignSystem.textTertiary) + .padding(.horizontal, DesignSystem.spacingSmall) + .padding(.vertical, DesignSystem.spacingXSmall) + .background( + RoundedRectangle(cornerRadius: DesignSystem.radiusMedium) + .fill(isHoveringAdd ? DesignSystem.hoverBackground : Color.clear) + ) + .modifier(DesignSystem.buttonStyle()) + } + .buttonStyle(.plain) + .help(Strings.Help.closeSearch) + .onHover { hovering in + withAnimation(DesignSystem.animationFast) { + isHoveringAdd = hovering + } + } + } else { + Button(action: onAdd) { + Text(Strings.Common.add) + .font(DesignSystem.font(size: DesignSystem.fontSizeBase, weight: DesignSystem.fontWeightMedium)) + .foregroundColor(DesignSystem.textTertiary) + .padding(.horizontal, DesignSystem.spacingSmall) + .padding(.vertical, DesignSystem.spacingXSmall) + .background( + RoundedRectangle(cornerRadius: DesignSystem.radiusMedium) + .fill(isHoveringAdd ? DesignSystem.hoverBackground : Color.clear) + ) + .modifier(DesignSystem.buttonStyle()) + } + .buttonStyle(.plain) + .disabled(newSite.trimmingCharacters(in: .whitespaces).isEmpty) + .opacity(newSite.trimmingCharacters(in: .whitespaces).isEmpty ? DesignSystem.opacityHigh : DesignSystem.opacityFull) + .focusable(false) + .onHover { hovering in + withAnimation(DesignSystem.animationFast) { + isHoveringAdd = hovering + } + } + .help(Strings.Help.addSite) + } + } + .transition(.opacity.combined(with: .scale(scale: 0.9))) + } + .padding(.horizontal, DesignSystem.spacingSmall) + .padding(.vertical, DesignSystem.spacingSmallMedium) + .frame(height: 44) + .background( + RoundedRectangle(cornerRadius: DesignSystem.radiusMedium) + .fill(DesignSystem.backgroundPrimary) + .overlay( + RoundedRectangle(cornerRadius: DesignSystem.radiusMedium) + .stroke((isSearchMode && focusedField == .search) || (!isSearchMode && focusedField == .addWebsite) ? DesignSystem.textPrimary : DesignSystem.borderPrimary, lineWidth: 1) + ) + ) + .animation(DesignSystem.animationNormal, value: isSearchMode) + .animation(DesignSystem.animationNormal, value: searchText.isEmpty) + + Button(action: { + withAnimation(DesignSystem.animationNormal) { + isSearchMode.toggle() + if isSearchMode { + focusedField = .search + } else { + searchText = "" + focusedField = .addWebsite + } + } + }) { + Image(systemName: "magnifyingglass") + .font(DesignSystem.font(size: DesignSystem.fontSizeXXLarge)) + .foregroundColor(isSearchMode ? DesignSystem.textPrimary : DesignSystem.textTertiary) + .frame(width: 32, height: 32) + } + .buttonStyle(.plain) + .help(isSearchMode ? Strings.Help.closeSearch : Strings.Help.search) + .padding(.leading, DesignSystem.spacingXXSmall) + } + } +} + +private struct DomainListSection: View { + let filteredURLs: [BlockedURL] + @Binding var blockedURLs: [BlockedURL] + + let isBlockingMode: Bool + let isSearchMode: Bool + let searchText: String + + @Binding var showSuggested: Bool + let suggestedSites: [String] + @Binding var isHoveringSuggestedChip: Bool + + @Binding var isBulkEditMode: Bool + @Binding var isHoveringBulkEdit: Bool + @Binding var selectedURLs: Set + @Binding var lastSelectedIndex: Int? + + let onToggleBulkEdit: () -> Void + let onSelectRow: (_ urlId: UUID, _ index: Int, _ isShiftPressed: Bool) -> Void + let onNavigateToDetail: (UUID) -> Void + let onRequestAddSuggested: (String) -> Void + + var body: some View { + ScrollView { + VStack(spacing: 0) { + if !filteredURLs.isEmpty || (!suggestedSites.isEmpty && searchText.isEmpty && !isSearchMode) { + ZStack { + if !filteredURLs.isEmpty { + Text(Strings.BlocklistEditor.siteCount(blockedURLs.count)) + .font(DesignSystem.font(size: DesignSystem.fontSizeSmall, weight: DesignSystem.fontWeightSemibold)) + .foregroundColor(DesignSystem.textTertiary) + .tracking(1.2) + } + + HStack { + if !suggestedSites.isEmpty && searchText.isEmpty && !isSearchMode { + Button(action: { showSuggested.toggle() }) { + HStack(spacing: 4) { + Image(systemName: "lightbulb") + .font(DesignSystem.font(size: DesignSystem.fontSizeBase)) + Text(Strings.BlocklistEditor.suggested) + .font(DesignSystem.font(size: DesignSystem.fontSizeBase, weight: DesignSystem.fontWeightMedium)) + } + .foregroundColor(showSuggested ? DesignSystem.textTertiary : DesignSystem.textPrimary) + .padding(.horizontal, DesignSystem.spacingSmall) + .padding(.vertical, DesignSystem.spacingXSmall) + .background( + RoundedRectangle(cornerRadius: DesignSystem.radiusMedium) + .fill(isHoveringSuggestedChip ? DesignSystem.hoverBackground : Color.clear) + ) + .modifier(DesignSystem.buttonStyle()) + } + .buttonStyle(.plain) + .focusable(false) + .onHover { hovering in + withAnimation(DesignSystem.animationFast) { + isHoveringSuggestedChip = hovering + } + } + } + + Spacer() + + if !filteredURLs.isEmpty { + Button(action: onToggleBulkEdit) { + HStack(spacing: 4) { + Image(systemName: isBulkEditMode ? "checkmark.circle.fill" : "checkmark.circle") + .font(DesignSystem.font(size: DesignSystem.fontSizeBase)) + Text(isBulkEditMode ? Strings.Common.done : Strings.BlocklistEditor.bulkEdit) + .font(DesignSystem.font(size: DesignSystem.fontSizeBase, weight: DesignSystem.fontWeightMedium)) + } + .foregroundColor(DesignSystem.textPrimary) + .padding(.horizontal, DesignSystem.spacingSmall) + .padding(.vertical, DesignSystem.spacingXSmall) + .background( + RoundedRectangle(cornerRadius: DesignSystem.radiusMedium) + .fill(isHoveringBulkEdit ? DesignSystem.hoverBackground : Color.clear) + ) + .modifier(DesignSystem.buttonStyle()) + } + .buttonStyle(.plain) + .focusable(false) + .disabled(isBlockingMode) + .onHover { hovering in + withAnimation(DesignSystem.animationFast) { + isHoveringBulkEdit = hovering + } + } + } + } + .padding(.horizontal, DesignSystem.spacingLarge) + } + .frame(maxWidth: .infinity) + .padding(.top, DesignSystem.spacingMedium) + + if showSuggested && !suggestedSites.isEmpty && searchText.isEmpty && !isSearchMode { + SuggestedChipsScroll( + suggestedSites: suggestedSites, + blockedURLs: blockedURLs, + onTap: onRequestAddSuggested + ) + .padding(.top, DesignSystem.spacingSmall) + .padding(.bottom, DesignSystem.spacingSmall) + } + } + + LazyVStack(spacing: 6) { + if filteredURLs.isEmpty { + EmptyListView(isSearching: !searchText.isEmpty) + .frame(maxWidth: .infinity) + .padding(.top, DesignSystem.spacingXLargePlusMedium) + } else { + let enumeratedURLs = Array(filteredURLs.enumerated()) + ForEach(enumeratedURLs, id: \.element.id) { index, url in + DomainRow( + url: url, + index: index, + isBulkEditMode: isBulkEditMode, + isBlockingMode: isBlockingMode, + isSelected: selectedURLs.contains(url.id), + onToggleSelect: { id, idx in + let isShift = NSEvent.modifierFlags.contains(.shift) + onSelectRow(id, idx, isShift) + }, + onTapRow: { + if isBulkEditMode { + let isShift = NSEvent.modifierFlags.contains(.shift) + onSelectRow(url.id, index, isShift) + } else { + onNavigateToDetail(url.id) + } + }, + onToggleEnabled: { newValue in + guard let idx = blockedURLs.firstIndex(where: { $0.id == url.id }) else { return } + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + blockedURLs[idx].isEnabled = newValue + } + } + ) + } + } + } + } + } + } +} + +private struct SuggestedChipsScroll: View { + let suggestedSites: [String] + let blockedURLs: [BlockedURL] + let onTap: (String) -> Void + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach(Array(suggestedSites), id: \.self) { site in + let isAlreadyBlocked = blockedURLs.contains { $0.domain == site } + + Button(action: { onTap(site) }) { + HStack(spacing: 4) { + Text(site) + .font(DesignSystem.font(size: DesignSystem.fontSizeSmall)) + if isAlreadyBlocked { + Image(systemName: "checkmark.circle.fill") + .font(DesignSystem.font(size: DesignSystem.fontSizeXSmall)) + } + } + .foregroundColor(DesignSystem.textPrimary) + .padding(.horizontal, DesignSystem.spacingSmallMedium) + .padding(.vertical, DesignSystem.spacingXXSmall) + .background( + Capsule() + .fill(isAlreadyBlocked ? DesignSystem.hoverBackground : DesignSystem.backgroundTertiary) + .overlay( + Capsule() + .stroke(isAlreadyBlocked ? DesignSystem.textPrimary.opacity(DesignSystem.disabledOpacity) : Color.clear, lineWidth: 1) + ) + ) + } + .buttonStyle(.plain) + .disabled(isAlreadyBlocked) + } + } + .padding(.horizontal, DesignSystem.spacingLarge) + } + } +} + +private struct EmptyListView: View { + let isSearching: Bool + + var body: some View { + VStack(spacing: DesignSystem.spacingSmall) { + Image(systemName: isSearching ? "magnifyingglass" : "shield.slash") + .font(DesignSystem.font(size: DesignSystem.fontSizeDisplayXXL)) + .foregroundColor(DesignSystem.textPrimary.opacity(DesignSystem.opacityMedium)) + Text(isSearching ? Strings.BlocklistEditor.noResultsFound : Strings.BlocklistEditor.noBlockedSites) + .font(DesignSystem.font(size: DesignSystem.fontSizeXLarge)) + .foregroundColor(DesignSystem.textTertiary) + } + } +} + +private struct DomainRow: View { + let url: BlockedURL + let index: Int + let isBulkEditMode: Bool + let isBlockingMode: Bool + let isSelected: Bool + + let onToggleSelect: (_ id: UUID, _ index: Int) -> Void + let onTapRow: () -> Void + let onToggleEnabled: (_ newValue: Bool) -> Void + + var body: some View { + HStack(alignment: .center, spacing: DesignSystem.spacingSmall) { + Group { + if isBulkEditMode { + Button(action: { onToggleSelect(url.id, index) }) { + Image(systemName: isSelected ? "checkmark.square.fill" : "square") + .font(DesignSystem.font(size: DesignSystem.fontSizeTitle)) + .foregroundColor(isSelected ? DesignSystem.textPrimary : DesignSystem.textQuaternary) + .frame(width: 20, height: 20, alignment: .center) + } + .buttonStyle(.plain) + } else { + let isDisabledDuringBlock = isBlockingMode && url.isEnabled + Toggle("", isOn: Binding( + get: { url.isEnabled }, + set: { newValue in + if isBlockingMode && !newValue { + return + } + onToggleEnabled(newValue) + } + )) + .toggleStyle(SwitchToggleStyle(tint: Color(red: 0.85, green: 0.95, blue: 1.0))) + .labelsHidden() + .disabled(isDisabledDuringBlock) + .opacity(isDisabledDuringBlock ? DesignSystem.disabledOpacity : DesignSystem.opacityFull) + .help(isDisabledDuringBlock ? Strings.Help.cannotDisableDuringBlock : "") + .buttonStyle(.plain) + } + } + .frame(width: 44, height: 24, alignment: .center) + + Button(action: onTapRow) { + HStack(spacing: DesignSystem.spacingSmall) { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Text(url.domain) + .font(DesignSystem.font(size: DesignSystem.fontSizeBase, weight: DesignSystem.fontWeightMedium)) + .foregroundColor(url.isEnabled ? DesignSystem.textPrimary : DesignSystem.textTertiary) + + if isBlockingMode && url.isEnabled { + Image(systemName: "lock.fill") + .font(DesignSystem.font(size: DesignSystem.fontSizeXSmall)) + .foregroundColor(DesignSystem.accentOrangeMuted) + + Text(Strings.BlocklistEditor.locked) + .font(DesignSystem.font(size: DesignSystem.fontSizeXSmall, weight: DesignSystem.fontWeightRegular)) + .foregroundColor(DesignSystem.accentOrangeMuted) + } + } + + if !url.paths.isEmpty { + Text(Strings.BlocklistEditor.pathCount(url.paths.count)) + .font(DesignSystem.font(size: DesignSystem.fontSizeXSmall)) + .foregroundColor(DesignSystem.textTertiary) + } + } + + Spacer() + + Image(systemName: "chevron.right") + .font(DesignSystem.font(size: DesignSystem.fontSizeSmall, weight: DesignSystem.fontWeightSemibold)) + .foregroundColor(DesignSystem.textQuaternary) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + .frame(minHeight: 20) + .padding(.horizontal, DesignSystem.spacingSmall) + .padding(.vertical, DesignSystem.spacingXSmall) + .background( + RoundedRectangle(cornerRadius: DesignSystem.radiusSmall) + .fill(DesignSystem.backgroundPrimary.opacity(url.isEnabled ? 0.6 : 0.3)) + .overlay( + RoundedRectangle(cornerRadius: DesignSystem.radiusMedium) + .stroke(url.isEnabled ? DesignSystem.borderSecondary : DesignSystem.textPrimary.opacity(DesignSystem.opacitySubtle), lineWidth: 1) + ) + ) + .opacity(url.isEnabled ? DesignSystem.opacityFull : DesignSystem.opacityAlmost) + } +} + +private struct BottomActionBarView: View { + let filteredURLs: [BlockedURL] + @Binding var selectedURLs: Set + let isBulkEditMode: Bool + + let onToggleAllFiltered: () -> Void + let onDelete: () -> Void + + var body: some View { + Group { + if isBulkEditMode { + VStack(spacing: 0) { + Divider().background(DesignSystem.borderPrimary) + + ZStack { + HStack(spacing: DesignSystem.spacingMedium) { + Button(action: onToggleAllFiltered) { + let filteredIDs = Set(filteredURLs.map { $0.id }) + let allFilteredSelected = !filteredURLs.isEmpty && filteredIDs.isSubset(of: selectedURLs) + + HStack(spacing: 6) { + Image(systemName: allFilteredSelected ? "checkmark.square.fill" : "square") + .font(DesignSystem.font(size: DesignSystem.fontSizeLarge, weight: DesignSystem.fontWeightMedium)) + Text(allFilteredSelected ? Strings.Common.deselectAll : Strings.Common.selectAll) + .font(DesignSystem.font(size: DesignSystem.fontSizeMedium, weight: DesignSystem.fontWeightMedium)) + } + .foregroundColor(DesignSystem.textPrimary) + .padding(.horizontal, DesignSystem.spacingMedium) + .padding(.vertical, DesignSystem.spacingSmall) + .modifier(DesignSystem.buttonStyle()) + } + .buttonStyle(.plain) + .disabled(filteredURLs.isEmpty) + .opacity(filteredURLs.isEmpty ? DesignSystem.disabledOpacity : DesignSystem.opacityFull) + + Spacer() + + Button(action: onDelete) { + HStack(spacing: 6) { + Image(systemName: "trash") + .font(DesignSystem.font(size: DesignSystem.fontSizeLarge, weight: DesignSystem.fontWeightMedium)) + Text(Strings.Common.delete) + .font(DesignSystem.font(size: DesignSystem.fontSizeMedium, weight: DesignSystem.fontWeightMedium)) + } + .foregroundColor(DesignSystem.destructiveColor) + .padding(.horizontal, DesignSystem.spacingMedium) + .padding(.vertical, DesignSystem.spacingSmall) + .modifier(DesignSystem.buttonStyle(isDestructive: true)) + } + .buttonStyle(.plain) + .disabled(selectedURLs.isEmpty) + .opacity(selectedURLs.isEmpty ? DesignSystem.disabledOpacity : DesignSystem.opacityFull) + } + + if !selectedURLs.isEmpty { + Text(Strings.BlocklistEditor.selectedCount(selectedURLs.count)) + .font(DesignSystem.font(size: DesignSystem.fontSizeBase, weight: DesignSystem.fontWeightMedium)) + .foregroundColor(DesignSystem.textSecondary) + } + } + .padding(.horizontal, DesignSystem.spacingLarge) + .padding(.vertical, DesignSystem.spacingMedium) + .background( + ZStack(alignment: .top) { + Rectangle().fill(DesignSystem.backgroundPrimary) + Rectangle().fill(DesignSystem.borderPrimary).frame(height: 1) + } + ) + } + } else { + EmptyView() + } + } + } +} + +private struct DeleteConfirmationModal: View { + let count: Int + let onCancel: () -> Void + let onConfirm: () -> Void + + var body: some View { + DesignSystem.overlayBackground + .ignoresSafeArea() + .overlay( + VStack(spacing: DesignSystem.spacingMedium) { + VStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .font(DesignSystem.font(size: DesignSystem.fontSizeDisplay)) + .foregroundColor(DesignSystem.destructiveColor) + + Text(Strings.BlocklistEditor.deleteSiteTitle(count)) + .font(DesignSystem.font(size: DesignSystem.fontSizeLarge, weight: DesignSystem.fontWeightSemibold)) + .foregroundColor(DesignSystem.textPrimary) + + Text(Strings.BlocklistEditor.deleteSiteMessage(count)) + .font(DesignSystem.font(size: DesignSystem.fontSizeSmall)) + .foregroundColor(DesignSystem.textSecondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + + HStack(spacing: DesignSystem.spacingSmall) { + Button(action: onCancel) { + Text(Strings.Common.cancel) + .font(DesignSystem.font(size: DesignSystem.fontSizeBase, weight: DesignSystem.fontWeightMedium)) + .foregroundColor(DesignSystem.textPrimary) + .frame(width: 80) + .padding(.vertical, DesignSystem.spacingXSmall) + .background( + RoundedRectangle(cornerRadius: DesignSystem.radiusSmall) + .fill(DesignSystem.backgroundTertiary) + ) + } + .buttonStyle(.plain) + + Button(action: onConfirm) { + Text(Strings.Common.delete) + .font(DesignSystem.font(size: DesignSystem.fontSizeBase, weight: DesignSystem.fontWeightMedium)) + .foregroundColor(DesignSystem.textPrimary) + .frame(width: 80) + .padding(.vertical, DesignSystem.spacingXSmall) + .background( + RoundedRectangle(cornerRadius: DesignSystem.radiusSmall) + .fill(DesignSystem.destructiveColor) + ) + } + .buttonStyle(.plain) + } + } + .padding(DesignSystem.spacingMedium) + .frame(maxWidth: 320) + .background( + RoundedRectangle(cornerRadius: DesignSystem.radiusMedium) + .fill(DesignSystem.backgroundPrimary) + .overlay( + RoundedRectangle(cornerRadius: DesignSystem.radiusMedium) + .stroke(DesignSystem.borderPrimary, lineWidth: 1) + ) + ) + .shadow(color: DesignSystem.overlayBackground.opacity(DesignSystem.opacityHigh), radius: 15, x: 0, y: 5) + ) + .zIndex(999) + } +} + +// MARK: - Suggested chips geometry helpers (retained for potential reuse) + +private struct VisibleHItem: Equatable { + let id: String + let minX: CGFloat +} + +private struct FirstVisibleHPreferenceKey: PreferenceKey { + static var defaultValue: [VisibleHItem] = [] + static func reduce(value: inout [VisibleHItem], nextValue: () -> [VisibleHItem]) { + value.append(contentsOf: nextValue()) + } +} + +// MARK: - onChangeCompat + +extension View { + @ViewBuilder + func onChangeCompat( + of value: Value, + initial: Bool = false, + _ action: @escaping (_ oldValue: Value?, _ newValue: Value) -> Void + ) -> some View { + if #available(macOS 14.0, *) { + if initial { + self.onChange(of: value, initial: true) { old, new in + action(old, new) + } + } else { + self.onChange(of: value) { old, new in + action(old, new) + } + } + } else { + self + .onChange(of: value, perform: { new in + action(nil, new) + }) + .onAppear { + if initial { + action(nil, value) + } + } + } + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Views/DomainDetailView.swift b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Views/DomainDetailView.swift new file mode 100644 index 00000000..e6e6a3c1 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Views/DomainDetailView.swift @@ -0,0 +1,398 @@ +// +// DomainDetailView.swift +// SelfControlUI +// +// Created on 2025-10-31. +// + +import SwiftUI + +// MARK: - Domain Detail View +struct DomainDetailView: View { + @EnvironmentObject var viewModel: FilterViewModel + + @Binding var blockedURLs: [BlockedURL] { + didSet { + saveChanges() + } + } + + let domainId: UUID + let isBlockingMode: Bool + @Binding var currentScreen: AppScreen + @Binding var blockingMode: BlockingMode + @State private var newPath: String = "" + @State private var blockEntireDomain: Bool = true + @State private var isHoveringAdd: Bool = false + + var domain: BlockedURL? { + blockedURLs.first(where: { $0.id == domainId }) + } + + // Get the storage key for this domain's blocking mode preference + private var storageKey: String { + "blockEntireDomain_\(domainId.uuidString)" + } + + // Initialize blockEntireDomain from UserDefaults or based on domain state + private func initializeBlockEntireDomain() { + blockEntireDomain = UserDefaults.standard.bool(forKey: storageKey) + if let domain = domain { + // If domain has paths, always show paths tab (override stored preference) + if !domain.paths.isEmpty { + // Update stored preference to match + } else { + // No paths - check stored preference or default to true + if UserDefaults.standard.object(forKey: storageKey) != nil { + blockEntireDomain = UserDefaults.standard.bool(forKey: storageKey) + } else { + // No stored preference - default to true (block entire domain) + blockEntireDomain = true + saveBlockEntireDomainPreference() + } + } + } + } + + // Save the preference when it changes + private func saveBlockEntireDomainPreference() { + UserDefaults.standard.set(blockEntireDomain, forKey: storageKey) + if let index = blockedURLs.firstIndex(where: { $0.id == domainId }) { + blockedURLs[index].blockEntireDomain = blockEntireDomain + saveChanges() + } + } + + var body: some View { + ZStack { + DesignSystem.backgroundGradient + .ignoresSafeArea() + + if let domain = domain { + VStack(spacing: 0) { + // Header + HStack(alignment: .center) { + Button(action: { + currentScreen = .editList + }) { + HStack(spacing: DesignSystem.spacingXXSmall) { + Image(systemName: "chevron.left") + .font(DesignSystem.font(size: DesignSystem.fontSizeLarge, weight: DesignSystem.fontWeightSemibold)) + Text(Strings.Common.back) + .font(DesignSystem.font(size: DesignSystem.fontSizeLarge, weight: DesignSystem.fontWeightMedium)) + } + .foregroundColor(.white) + } + .buttonStyle(.plain) + .keyboardShortcut(.cancelAction) + + Spacer() + + Text(Strings.DomainDetail.siteSettings) + .font(DesignSystem.font(size: DesignSystem.fontSizeXXLarge, weight: DesignSystem.fontWeightSemibold)) + .foregroundColor(DesignSystem.textPrimary) + + Spacer() + + Text("") + .font(DesignSystem.font(size: DesignSystem.fontSizeBase, weight: DesignSystem.fontWeightMedium)) + .foregroundColor(DesignSystem.textSecondary) + .frame(width: 60, alignment: .trailing) + } + .padding(.horizontal, DesignSystem.spacingLarge) + .padding(.vertical, DesignSystem.spacingSmall) + + Divider() + .background(DesignSystem.textPrimary.opacity(DesignSystem.opacityLow)) + + ScrollView { + VStack(spacing: DesignSystem.spacingMedium) { + // Domain info + HStack(spacing: 8) { + Image(systemName: "globe") + .font(DesignSystem.font(size: DesignSystem.fontSizeXLarge)) + .foregroundColor(DesignSystem.textPrimary) + + Text(domain.domain) + .font(DesignSystem.font(size: DesignSystem.fontSizeXLarge, weight: DesignSystem.fontWeightSemibold)) + .foregroundColor(DesignSystem.textPrimary) + + Spacer() + } + .padding(DesignSystem.spacingSmall) + .background( + RoundedRectangle(cornerRadius: DesignSystem.radiusMedium) + .fill(DesignSystem.backgroundPrimary.opacity(DesignSystem.opacityHigher)) + .overlay( + RoundedRectangle(cornerRadius: DesignSystem.radiusMedium) + .stroke(DesignSystem.hoverBorder.opacity(DesignSystem.opacityHigher), lineWidth: 1) + ) + ) + + // Blocking mode selector + VStack(alignment: .leading, spacing: 10) { + Text(Strings.DomainDetail.blockingMode) + .font(DesignSystem.font(size: DesignSystem.fontSizeSmall, weight: DesignSystem.fontWeightSemibold)) + .foregroundColor(DesignSystem.textTertiary) + + HStack(spacing: 0) { + Button(action: { + if !isBlockingMode { + withAnimation(DesignSystem.animationFast) { + blockEntireDomain = true + saveBlockEntireDomainPreference() + } + } + }) { + HStack(spacing: 6) { + Image(systemName: "globe") + .font(DesignSystem.font(size: DesignSystem.fontSizeMedium)) + Text(Strings.DomainDetail.blockEntireDomain) + .font(DesignSystem.font(size: DesignSystem.fontSizeMedium, weight: DesignSystem.fontWeightMedium)) + } + .foregroundColor(blockEntireDomain ? DesignSystem.backgroundPrimary : DesignSystem.textSecondary) + .frame(maxWidth: .infinity) + .padding(.vertical, DesignSystem.spacingSmall) + .background(blockEntireDomain ? Color.white : Color.clear) + .clipShape(RoundedRectangle(cornerRadius: DesignSystem.radiusSmall)) + } + .buttonStyle(.plain) + .disabled(isBlockingMode) + .opacity(isBlockingMode ? DesignSystem.disabledOpacity : DesignSystem.opacityFull) + .help(isBlockingMode ? Strings.Help.cannotChangeModeDuringBlock : "") + + Button(action: { + if !isBlockingMode { + withAnimation(DesignSystem.animationFast) { + blockEntireDomain = false + saveBlockEntireDomainPreference() + } + } + }) { + HStack(spacing: 6) { + Image(systemName: "list.bullet") + .font(DesignSystem.font(size: DesignSystem.fontSizeMedium)) + Text(Strings.DomainDetail.specificPathsOnly) + .font(DesignSystem.font(size: DesignSystem.fontSizeMedium, weight: DesignSystem.fontWeightMedium)) + } + .foregroundColor(!blockEntireDomain ? DesignSystem.backgroundPrimary : DesignSystem.textSecondary) + .frame(maxWidth: .infinity) + .padding(.vertical, DesignSystem.spacingSmall) + .contentShape(Rectangle()) + .background(!blockEntireDomain ? Color.white : Color.clear) + .clipShape(RoundedRectangle(cornerRadius: DesignSystem.radiusSmall)) + } + .buttonStyle(.plain) + .disabled(isBlockingMode) + .opacity(isBlockingMode ? DesignSystem.disabledOpacity : DesignSystem.opacityFull) + .help(isBlockingMode ? Strings.Help.cannotChangeModeDuringBlock : "") + } + .background( + RoundedRectangle(cornerRadius: DesignSystem.radiusSmall) + .fill(DesignSystem.backgroundTertiary) + ) + + Text(blockEntireDomain ? Strings.DomainDetail.blockEntireDomainDescription : Strings.DomainDetail.specificPathsDescription) + .font(DesignSystem.font(size: DesignSystem.fontSizeBase)) + .foregroundColor(DesignSystem.textTertiary) + } + .padding(DesignSystem.spacingMedium) + .background( + RoundedRectangle(cornerRadius: DesignSystem.radiusMedium) + .fill(DesignSystem.backgroundPrimary.opacity(DesignSystem.opacityHigher)) + .overlay( + RoundedRectangle(cornerRadius: DesignSystem.radiusMedium) + .stroke(DesignSystem.borderPrimary, lineWidth: 1) + ) + ) + + // Paths section (only shown when not blocking entire domain) + if !blockEntireDomain { + VStack(alignment: .leading, spacing: DesignSystem.spacingMedium) { + Text(Strings.DomainDetail.pathsAndSubdomains) + .font(DesignSystem.font(size: DesignSystem.fontSizeSmall, weight: DesignSystem.fontWeightSemibold)) + .foregroundColor(DesignSystem.textTertiary) + + // Add path + HStack(spacing: 8) { + Image(systemName: "plus.circle.fill") + .font(DesignSystem.font(size: DesignSystem.fontSizeLarge)) + .foregroundColor(DesignSystem.textPrimary) + + ZStack(alignment: .leading) { + TextField("", text: $newPath, onCommit: { + addPath() + }) + .textFieldStyle(.plain) + .font(DesignSystem.font(size: DesignSystem.fontSizeMedium)) + .foregroundColor(DesignSystem.textPrimary) + .placeholder(when: newPath.isEmpty, alignment: .leading) { + Text(Strings.DomainDetail.addPathPlaceholder) + .foregroundColor(DesignSystem.disabledText) + .font(DesignSystem.font(size: DesignSystem.fontSizeMedium)) + .padding(.vertical, 8) // match your field’s vertical insets + } + } + Spacer() + .frame(minWidth: 8) + + Button(action: { + addPath() + }) { + Text(Strings.Common.add) + .font(DesignSystem.font(size: DesignSystem.fontSizeBase, weight: DesignSystem.fontWeightMedium)) + .foregroundColor(DesignSystem.textTertiary) + .padding(.horizontal, DesignSystem.spacingSmall) + .padding(.vertical, DesignSystem.spacingXSmall) + .background( + RoundedRectangle(cornerRadius: DesignSystem.radiusMedium) + .fill(isHoveringAdd ? DesignSystem.hoverBackground : Color.clear) + ) + .modifier(DesignSystem.buttonStyle()) + } + .buttonStyle(.plain) + .disabled(newPath.trimmingCharacters(in: .whitespaces).isEmpty) + .opacity(newPath.trimmingCharacters(in: .whitespaces).isEmpty ? DesignSystem.opacityHigh : DesignSystem.opacityFull) + .focusable(false) + .onHover { hovering in + withAnimation(DesignSystem.animationFast) { + isHoveringAdd = hovering + } + } + } + .padding(.horizontal, DesignSystem.spacingSmall) + .padding(.vertical, DesignSystem.spacingSmallMedium) + .frame(height: 44) + .background( + RoundedRectangle(cornerRadius: DesignSystem.radiusMedium) + .fill(DesignSystem.backgroundPrimary) + .overlay( + RoundedRectangle(cornerRadius: DesignSystem.radiusMedium) + .stroke(DesignSystem.borderPrimary, lineWidth: 1) + ) + ) + + // Paths list + if !domain.paths.isEmpty { + VStack(spacing: 6) { + ForEach(domain.paths, id: \.self) { path in + HStack(spacing: 10) { + Image(systemName: "doc.text") + .font(DesignSystem.font(size: DesignSystem.fontSizeMedium)) + .foregroundColor(DesignSystem.textPrimary.opacity(DesignSystem.opacityAlmost)) + + Text(path) + .font(DesignSystem.font(size: DesignSystem.fontSizeMedium)) + .foregroundColor(DesignSystem.textPrimary) + + Spacer() + + if !isBlockingMode { + Button(action: { + withAnimation { + if let index = blockedURLs.firstIndex(where: { $0.id == domainId }) { + blockedURLs[index].paths.removeAll { $0 == path } + saveChanges() + } + } + }) { + Image(systemName: "xmark.circle.fill") + .font(DesignSystem.font(size: DesignSystem.fontSizeXXLarge)) + .foregroundColor(DesignSystem.textQuaternary) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, DesignSystem.spacingMediumSmall) + .padding(.vertical, DesignSystem.spacingSmall) + .background( + RoundedRectangle(cornerRadius: DesignSystem.radiusSmall) + .fill(DesignSystem.backgroundSecondary.opacity(DesignSystem.opacityHigher)) + ) + } + } + } else { + Text(Strings.DomainDetail.noPathsAdded) + .font(DesignSystem.font(size: DesignSystem.fontSizeMedium)) + .foregroundColor(DesignSystem.textTertiary) + .frame(maxWidth: .infinity) + .padding(.vertical, DesignSystem.spacingMediumLarge) + } + } + .padding(DesignSystem.spacingMedium) + .background( + RoundedRectangle(cornerRadius: DesignSystem.radiusMedium) + .fill(DesignSystem.backgroundPrimary.opacity(DesignSystem.opacityHigher)) + .overlay( + RoundedRectangle(cornerRadius: DesignSystem.radiusMedium) + .stroke(DesignSystem.borderPrimary, lineWidth: 1) + ) + ) + } + + Spacer() + .frame(height: 20) + + // Delete button at bottom + if !isBlockingMode { + Button(action: { + withAnimation { + blockedURLs.removeAll { $0.id == domainId } + currentScreen = .editList + saveChanges() + } + }) { + HStack(spacing: 8) { + Image(systemName: "trash") + .font(.system(size: 14)) + Text(Strings.DomainDetail.deleteSite) + .font(.system(size: 14, weight: .medium)) + } + .foregroundColor(DesignSystem.textSecondary) + .frame(maxWidth: .infinity) + .padding(.vertical, DesignSystem.spacingMediumSmall) + .modifier(DesignSystem.buttonStyle(isDestructive: true)) + } + .buttonStyle(.plain) + } + } + .padding(DesignSystem.spacingLarge) + } + } + } + } + .onAppear { + initializeBlockEntireDomain() + } + .onChange(of: domain?.paths.count ?? 0) { pathCount in + // If paths are added while viewing, switch to paths tab + if pathCount > 0 && blockEntireDomain { + blockEntireDomain = false + saveBlockEntireDomainPreference() + } + } + } + + private func addPath() { + let cleanPath = newPath.trimmingCharacters(in: .whitespaces) + let formattedPath = cleanPath.hasPrefix("/") ? cleanPath : "/" + cleanPath + + guard !formattedPath.isEmpty else { return } + guard formattedPath.count > 1 else { return } + + if let index = blockedURLs.firstIndex(where: { $0.id == domainId }) { + if !blockedURLs[index].paths.contains(formattedPath) { + withAnimation { + blockedURLs[index].paths.append(formattedPath) + newPath = "" + saveChanges() + } + } + } + } + + private func saveChanges() { + viewModel.saveAndupdateBlockList(blockedURLs) + } +} + diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Views/ExtendTimerOverlayView.swift b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Views/ExtendTimerOverlayView.swift new file mode 100644 index 00000000..9993e9b8 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Views/ExtendTimerOverlayView.swift @@ -0,0 +1,331 @@ +// +// ExtendTimerOverlayView.swift +// SelfControlUI +// +// Created on 2026-01-08. +// + +import SwiftUI + +struct ExtendTimerOverlayView: View { + @Binding var extensionMinutes: Double + let onConfirm: (Double) -> Void + let onDismiss: () -> Void + @State private var isHoveringCancel = false + @State private var isHoveringConfirm = false + @State private var hoursInput: String = "0" + @State private var minutesInput: String = "0" + @State private var isAdjustingTime = false + + var timeDisplay: String { + let totalMinutes = Int(extensionMinutes) + let hours = totalMinutes / 60 + let minutes = totalMinutes % 60 + + if hours == 0 { + return "\(minutes)m" + } else if minutes == 0 { + return "\(hours)h" + } else { + return "\(hours)h \(minutes)m" + } + } + + private func updateMinutesFromInputs() { + let hours = Int(hoursInput.filter { $0.isNumber }) ?? 0 + let mins = Int(minutesInput.filter { $0.isNumber }) ?? 0 + + let clampedHours = max(0, min(24, hours)) + let clampedMinutes = max(0, min(59, mins)) + + hoursInput = String(clampedHours) + minutesInput = String(clampedMinutes) + + extensionMinutes = Double(clampedHours * 60 + clampedMinutes) + } + + private func adjustHours(by amount: Int) { + let currentHours = Int(hoursInput) ?? 0 + let newHours = max(0, min(24, currentHours + amount)) + hoursInput = String(newHours) + updateMinutesFromInputs() + } + + private func adjustMinutes(by amount: Int) { + let currentHours = Int(hoursInput) ?? 0 + let currentMins = Int(minutesInput) ?? 0 + var totalMinutes = currentHours * 60 + currentMins + amount + + totalMinutes = max(0, min(1440, totalMinutes)) + + let newHours = totalMinutes / 60 + let newMins = totalMinutes % 60 + + hoursInput = String(newHours) + minutesInput = String(newMins) + + extensionMinutes = Double(totalMinutes) + } + + var body: some View { + ZStack { + // Semi-transparent background + DesignSystem.overlayBackground + .ignoresSafeArea() + .onTapGesture { + onDismiss() + } + + // Modal content + VStack(spacing: DesignSystem.spacingMedium) { + // Header + VStack(spacing: 8) { + Image(systemName: "clock.badge.plus") + .font(DesignSystem.font(size: DesignSystem.fontSizeDisplay)) + .foregroundColor(DesignSystem.textPrimary) + + Text("Extend Timer") + .font(DesignSystem.font(size: DesignSystem.fontSizeHeading, weight: DesignSystem.fontWeightSemibold)) + .foregroundColor(DesignSystem.textPrimary) + + Text("Add more time to your current session") + .font(DesignSystem.font(size: DesignSystem.fontSizeSmall)) + .foregroundColor(DesignSystem.textSecondary) + .multilineTextAlignment(.center) + } + + // Timer input with arrow buttons + HStack(spacing: 6) { + Image(systemName: "clock.fill") + .font(DesignSystem.font(size: DesignSystem.fontSizeTitle)) + .overlay( + LinearGradient( + gradient: Gradient(colors: [ + DesignSystem.textPrimary.opacity(DesignSystem.opacityAlmost), + DesignSystem.textPrimary.opacity(DesignSystem.opacityHigher) + ]), + startPoint: .top, + endPoint: .bottom + ) + .mask( + Image(systemName: "clock.fill") + .font(DesignSystem.font(size: DesignSystem.fontSizeTitle)) + ) + ) + .shadow(color: DesignSystem.overlayBackground.opacity(DesignSystem.opacityHigh), radius: 2, x: 0, y: 2) + .padding(.top, DesignSystem.spacingSmallMedium) + + HStack(spacing: 6) { + // Hours + HStack(spacing: 2) { + ArrowKeyTextField( + text: $hoursInput, + onUpArrow: { + isAdjustingTime = true + adjustHours(by: 1) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + isAdjustingTime = false + } + }, + onDownArrow: { + isAdjustingTime = true + adjustHours(by: -1) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + isAdjustingTime = false + } + }, + onSubmit: { + updateMinutesFromInputs() + }, + onFocusChange: { hasFocus in + if !hasFocus { + updateMinutesFromInputs() + } + } + ) + .frame(width: max(40, CGFloat(hoursInput.count) * 20 + 10)) + + VStack(spacing: 2) { + Button(action: { + isAdjustingTime = true + adjustHours(by: 1) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + isAdjustingTime = false + } + }) { + Image(systemName: "chevron.up") + .font(DesignSystem.font(size: DesignSystem.fontSizeXSmall, weight: DesignSystem.fontWeightBold)) + .foregroundColor(DesignSystem.textPrimary) + .frame(width: 20, height: 16) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .focusable(false) + + Button(action: { + isAdjustingTime = true + adjustHours(by: -1) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + isAdjustingTime = false + } + }) { + Image(systemName: "chevron.down") + .font(DesignSystem.font(size: DesignSystem.fontSizeXSmall, weight: DesignSystem.fontWeightBold)) + .foregroundColor(DesignSystem.textPrimary) + .frame(width: 20, height: 16) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .focusable(false) + } + + Text(Strings.MainScreen.hours) + .font(DesignSystem.font(size: DesignSystem.fontSizeHeading, weight: DesignSystem.fontWeightMedium)) + .foregroundColor(DesignSystem.textSecondary) + .padding(.top, DesignSystem.spacingXSmall) + } + + // Minutes + HStack(spacing: 2) { + ArrowKeyTextField( + text: $minutesInput, + onUpArrow: { + isAdjustingTime = true + adjustMinutes(by: 1) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + isAdjustingTime = false + } + }, + onDownArrow: { + isAdjustingTime = true + adjustMinutes(by: -1) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + isAdjustingTime = false + } + }, + onSubmit: { + updateMinutesFromInputs() + }, + onFocusChange: { hasFocus in + if !hasFocus { + updateMinutesFromInputs() + } + } + ) + .frame(width: max(40, CGFloat(minutesInput.count) * 20 + 10)) + + VStack(spacing: 2) { + Button(action: { + isAdjustingTime = true + adjustMinutes(by: 1) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + isAdjustingTime = false + } + }) { + Image(systemName: "chevron.up") + .font(DesignSystem.font(size: DesignSystem.fontSizeXSmall, weight: DesignSystem.fontWeightBold)) + .foregroundColor(DesignSystem.textPrimary) + .frame(width: 20, height: 16) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .focusable(false) + + Button(action: { + isAdjustingTime = true + adjustMinutes(by: -1) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + isAdjustingTime = false + } + }) { + Image(systemName: "chevron.down") + .font(DesignSystem.font(size: DesignSystem.fontSizeXSmall, weight: DesignSystem.fontWeightBold)) + .foregroundColor(DesignSystem.textPrimary) + .frame(width: 20, height: 16) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .focusable(false) + } + + Text(Strings.MainScreen.minutes) + .font(DesignSystem.font(size: DesignSystem.fontSizeHeading, weight: DesignSystem.fontWeightMedium)) + .foregroundColor(DesignSystem.textSecondary) + .padding(.top, DesignSystem.spacingXSmall) + } + } + } + .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, DesignSystem.spacingSmall) + .padding(.bottom, DesignSystem.spacingMedium) + + // Action buttons + HStack(spacing: DesignSystem.spacingSmall) { + Button(action: { + onDismiss() + }) { + Text(Strings.Common.cancel) + .font(DesignSystem.font(size: DesignSystem.fontSizeBase, weight: DesignSystem.fontWeightMedium)) + .foregroundColor(DesignSystem.textPrimary) + .frame(maxWidth: .infinity) + .padding(.vertical, DesignSystem.spacingSmall) + .background( + RoundedRectangle(cornerRadius: DesignSystem.radiusMedium) + .fill(isHoveringCancel ? DesignSystem.hoverBackground : Color.clear) + ) + .modifier(DesignSystem.buttonStyle()) + } + .buttonStyle(.plain) + .onHover { hovering in + withAnimation(DesignSystem.animationFast) { + isHoveringCancel = hovering + } + } + + Button(action: { + onConfirm(extensionMinutes) + }) { + Text("Extend") + .font(DesignSystem.font(size: DesignSystem.fontSizeBase, weight: DesignSystem.fontWeightMedium)) + .foregroundColor(DesignSystem.textPrimary) + .frame(maxWidth: .infinity) + .padding(.vertical, DesignSystem.spacingSmall) + .background( + RoundedRectangle(cornerRadius: DesignSystem.radiusMedium) + .fill(isHoveringConfirm ? DesignSystem.hoverBackground : Color.clear) + ) + .modifier(DesignSystem.buttonStyle()) + } + .buttonStyle(.plain) + .disabled(extensionMinutes == 0) + .opacity(extensionMinutes == 0 ? DesignSystem.disabledOpacity : DesignSystem.opacityFull) + .onHover { hovering in + withAnimation(DesignSystem.animationFast) { + isHoveringConfirm = hovering + } + } + } + .padding(.top, DesignSystem.spacingSmall) + } + .padding(DesignSystem.spacingXLarge) + .frame(maxWidth: 400) + .background( + RoundedRectangle(cornerRadius: DesignSystem.radiusMedium) + .fill(DesignSystem.backgroundPrimary) + .overlay( + RoundedRectangle(cornerRadius: DesignSystem.radiusMedium) + .stroke(DesignSystem.borderPrimary, lineWidth: 1) + ) + ) + .shadow(color: DesignSystem.overlayBackground.opacity(DesignSystem.opacityHigh), radius: 15, x: 0, y: 5) + } + .onAppear { + // Initialize inputs from extensionMinutes + let totalMinutes = Int(extensionMinutes) + let hours = totalMinutes / 60 + let minutes = totalMinutes % 60 + hoursInput = String(hours) + minutesInput = String(minutes) + } + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Views/IntensityDropdownOverlay.swift b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Views/IntensityDropdownOverlay.swift new file mode 100644 index 00000000..7a94d4b0 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Views/IntensityDropdownOverlay.swift @@ -0,0 +1,92 @@ +// +// IntensityDropdownOverlay.swift +// SelfControlUI +// +// Created on 2025-10-31. +// + +import SwiftUI + +// MARK: - Intensity Dropdown Overlay +struct IntensityDropdownOverlay: View { + @Binding var selectedLevel: IntensityLevel + let onDismiss: () -> Void + @State private var hoveredLevel: IntensityLevel? + + var body: some View { + GeometryReader { geometry in + ZStack { + // Transparent background to catch taps + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + onDismiss() + } + + // Dropdown menu positioned below button + VStack(spacing: 6) { + ForEach(IntensityLevel.allCases, id: \.self) { level in + Button(action: { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + selectedLevel = level + onDismiss() + } + }) { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(level.displayName) + .font(DesignSystem.font(size: DesignSystem.fontSizeSmall, weight: DesignSystem.fontWeightSemibold)) + .foregroundColor(DesignSystem.textPrimary) + + Text(level.description) + .font(DesignSystem.font(size: DesignSystem.fontSizeSmall)) + .foregroundColor(DesignSystem.textSecondary) + .fixedSize(horizontal: false, vertical: true) + .lineLimit(2) + } + + Spacer() + + if selectedLevel == level { + Image(systemName: "checkmark") + .font(DesignSystem.font(size: DesignSystem.fontSizeSmall, weight: DesignSystem.fontWeightSemibold)) + .foregroundColor(DesignSystem.textPrimary) + .padding(.top, DesignSystem.spacingXXSmall) + } + } + .padding(.horizontal, DesignSystem.spacingMedium) + .padding(.vertical, DesignSystem.spacingMediumSmall) + .contentShape(Rectangle()) + .background( + RoundedRectangle(cornerRadius: DesignSystem.radiusSmall) + .fill(hoveredLevel == level ? DesignSystem.textPrimary.opacity(DesignSystem.opacityLow) : DesignSystem.textPrimary.opacity(DesignSystem.opacitySubtle)) + ) + } + .buttonStyle(.plain) + .onHover { hovering in + withAnimation(DesignSystem.animationFast) { + hoveredLevel = hovering ? level : nil + } + } + } + } + .padding(DesignSystem.spacingXSmall) + .frame(width: 300) + .background( + RoundedRectangle(cornerRadius: DesignSystem.radiusMedium) + .fill(DesignSystem.backgroundPrimary) + .overlay( + RoundedRectangle(cornerRadius: DesignSystem.radiusMedium) + .stroke(DesignSystem.borderPrimary, lineWidth: 1) + ) + ) + .shadow(color: DesignSystem.overlayBackground.opacity(DesignSystem.opacityHigh), radius: 16, x: 0, y: 8) + .position(x: geometry.size.width / 2, y: 180) + .transition(.asymmetric( + insertion: .scale(scale: 0.95, anchor: .top).combined(with: .opacity), + removal: .scale(scale: 0.95, anchor: .top).combined(with: .opacity) + )) + } + } + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Views/NonDraggableBackgroundView.swift b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Views/NonDraggableBackgroundView.swift new file mode 100644 index 00000000..ffc832eb --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Views/NonDraggableBackgroundView.swift @@ -0,0 +1,54 @@ +// +// NonDraggableBackgroundView.swift +// SelfControlUI +// +// Created on 2025-10-31. +// + +import SwiftUI +import AppKit + +// MARK: - Non-Draggable Background View +class NonDraggableNSView: NSView { + override var mouseDownCanMoveWindow: Bool { + return false + } +} + +struct NonDraggableBackgroundView: NSViewRepresentable { + func makeNSView(context: Context) -> NSView { + return NonDraggableNSView() + } + + func updateNSView(_ nsView: NSView, context: Context) { + // No update needed + } +} + +// MARK: - Color Extension for Hex Support +extension Color { + init(hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) + let a, r, g, b: UInt64 + switch hex.count { + case 3: // RGB (12-bit) + (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) + case 6: // RGB (24-bit) + (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) + case 8: // ARGB (32-bit) + (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + default: + (a, r, g, b) = (255, 0, 0, 0) + } + self.init( + .sRGB, + red: Double(r) / 255, + green: Double(g) / 255, + blue: Double(b) / 255, + opacity: Double(a) / 255 + ) + } +} + diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Views/StopConfirmationOverlayView.swift b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Views/StopConfirmationOverlayView.swift new file mode 100644 index 00000000..e01016a7 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Views/StopConfirmationOverlayView.swift @@ -0,0 +1,127 @@ +// +// StopConfirmationOverlayView.swift +// SelfControlUI +// +// Created on 2025-10-31. +// + +import SwiftUI + +// MARK: - Stop Confirmation Overlay View +struct StopConfirmationOverlayView: View { + let countdownTime: TimeInterval + let onResume: () -> Void + let onStopBlocking: () -> Void + @State private var isHoveringStop = false + @State private var isHoveringResume = false + + var body: some View { + ZStack { + // Semi-transparent background + DesignSystem.overlayBackground + .ignoresSafeArea() + + // Confirmation card + VStack(spacing: DesignSystem.spacingLarge) { + VStack(spacing: 12) { + // Title + Text(Strings.StopConfirmation.title) + .font(DesignSystem.font(size: DesignSystem.fontSizeLarge, weight: DesignSystem.fontWeightSemibold)) + .foregroundColor(DesignSystem.textPrimary) + + // Circular countdown loader + ZStack { + // Background circle + Circle() + .stroke( + DesignSystem.textPrimary.opacity(DesignSystem.opacityLow), + lineWidth: 8 + ) + .frame(width: 120, height: 120) + + // Progress circle (white) + Circle() + .trim(from: 0, to: CGFloat(countdownTime / 600.0)) + .stroke( + Color.white.opacity(DesignSystem.opacityAlmost), + style: StrokeStyle(lineWidth: 8, lineCap: .round) + ) + .frame(width: 120, height: 120) + .rotationEffect(.degrees(-90)) + .animation(.linear(duration: 1.0), value: countdownTime) + + // Countdown text (mm:ss format) + VStack(spacing: 2) { + Text(TimeHelpers.formatCountdownTime(countdownTime)) + .font(DesignSystem.font(size: DesignSystem.fontSizeDisplayXL, weight: DesignSystem.fontWeightBold, design: .monospaced)) + .foregroundColor(DesignSystem.textPrimary) + } + } + + // Message - single line with dynamic text + Text(Strings.StopConfirmation.countdownMessage(countdownTime)) + .font(DesignSystem.font(size: DesignSystem.fontSizeSmall)) + .foregroundColor(DesignSystem.textSecondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + + // Buttons + HStack(spacing: DesignSystem.spacingSmall) { + // Resume button (more prominent) + Button(action: onResume) { + Text(Strings.StopConfirmation.resumeBlocking) + .font(DesignSystem.font(size: DesignSystem.fontSizeBase, weight: DesignSystem.fontWeightMedium)) + .foregroundColor(DesignSystem.textPrimary) + .frame(maxWidth: .infinity) + .padding(.vertical, DesignSystem.spacingXSmall) + .background( + RoundedRectangle(cornerRadius: DesignSystem.radiusSmall) + .fill(isHoveringResume ? DesignSystem.hoverBackground : DesignSystem.textPrimary.opacity(DesignSystem.opacityMedium)) + ) + } + .buttonStyle(.plain) + .onHover { hovering in + withAnimation(DesignSystem.animationFast) { + isHoveringResume = hovering + } + } + + // Subtle Yes button + Button(action: onStopBlocking) { + Text(Strings.StopConfirmation.yesStopBlocking) + .font(DesignSystem.font(size: DesignSystem.fontSizeBase, weight: DesignSystem.fontWeightMedium)) + .foregroundColor(DesignSystem.textSecondary) + .frame(maxWidth: .infinity) + .padding(.vertical, DesignSystem.spacingXSmall) + .background( + RoundedRectangle(cornerRadius: DesignSystem.radiusSmall) + .fill(isHoveringStop ? DesignSystem.textPrimary.opacity(DesignSystem.opacityLow) : DesignSystem.backgroundTertiary) + ) + } + .buttonStyle(.plain) + .onHover { hovering in + withAnimation(DesignSystem.animationFast) { + isHoveringStop = hovering + } + } + .disabled(countdownTime > 0) + .opacity(countdownTime > 0 ? DesignSystem.disabledOpacity : DesignSystem.opacityFull) + } + } + .padding(DesignSystem.spacingLarge) + .frame(maxWidth: 340) + .background( + RoundedRectangle(cornerRadius: DesignSystem.radiusMedium) + .fill(DesignSystem.backgroundPrimary) + .overlay( + RoundedRectangle(cornerRadius: DesignSystem.radiusMedium) + .stroke(DesignSystem.borderPrimary, lineWidth: 1) + ) + ) + .shadow(color: DesignSystem.overlayBackground.opacity(DesignSystem.opacityHigh), radius: 15, x: 0, y: 5) + .offset(y: -20) + } + } +} + diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Views/TipsOverlayView.swift b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Views/TipsOverlayView.swift new file mode 100644 index 00000000..1181b51e --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/SelfControlUI/Views/TipsOverlayView.swift @@ -0,0 +1,94 @@ +// +// TipsOverlayView.swift +// SelfControlUI +// +// Created on 2025-10-31. +// + +import SwiftUI + +// MARK: - Tips Overlay View +struct TipsOverlayView: View { + let onDismiss: () -> Void + + var body: some View { + ZStack { + // Semi-transparent background + DesignSystem.overlayBackground + .ignoresSafeArea() + .onTapGesture { + onDismiss() + } + + // Tips card + VStack(alignment: .leading, spacing: DesignSystem.spacingLarge) { + VStack(spacing: 12) { + // Header + Text(Strings.Tips.title) + .font(DesignSystem.font(size: DesignSystem.fontSizeLarge, weight: DesignSystem.fontWeightSemibold)) + .foregroundColor(DesignSystem.textPrimary) + + + // Tips content + VStack(alignment: .leading, spacing: DesignSystem.spacingMedium) { + ForEach(Strings.Tips.items) { tip in + TipItem( + title: tip.title, + description: tip.description + ) + } + } + } + + // Dismiss button + HStack(spacing: DesignSystem.spacingSmall) { + Spacer() + Button(action: onDismiss) { + Text(Strings.Tips.gotIt) + .font(DesignSystem.font(size: DesignSystem.fontSizeBase, weight: DesignSystem.fontWeightMedium)) + .foregroundColor(.white) + .frame(width: 80) + .padding(.vertical, DesignSystem.spacingXSmall) + .background( + RoundedRectangle(cornerRadius: DesignSystem.radiusSmall) + .fill(DesignSystem.backgroundTertiary) + ) + } + .buttonStyle(.plain) + Spacer() + } + } + .padding(DesignSystem.spacingLarge) + .frame(maxWidth: 340) + .background( + RoundedRectangle(cornerRadius: DesignSystem.radiusMedium) + .fill(DesignSystem.backgroundPrimary) + .overlay( + RoundedRectangle(cornerRadius: DesignSystem.radiusMedium) + .stroke(DesignSystem.borderPrimary, lineWidth: 1) + ) + ) + .shadow(color: DesignSystem.overlayBackground.opacity(DesignSystem.opacityHigh), radius: 15, x: 0, y: 5) + } + } +} + +// MARK: - Tip Item +struct TipItem: View { + let title: String + let description: String + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(DesignSystem.font(size: DesignSystem.fontSizeSmall, weight: DesignSystem.fontWeightSemibold)) + .foregroundColor(DesignSystem.textPrimary) + + Text(description) + .font(DesignSystem.font(size: DesignSystem.fontSizeSmall)) + .foregroundColor(DesignSystem.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } +} + diff --git a/Self-Control-Extension/SelfControl/SelfControl/Main/Status.swift b/Self-Control-Extension/SelfControl/SelfControl/Main/Status.swift new file mode 100644 index 00000000..cb8c962d --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Main/Status.swift @@ -0,0 +1,39 @@ +// +// Status.swift +// SelfControl +// +// Created by Egzon Arifi on 02/04/2025. +// + +import Foundation +import SwiftUI + +enum Status { + case stopped + case indeterminate + case running +} + +extension Status { + var text: String { + switch self { + case .stopped: + return "Stopped" + case .indeterminate: + return "Indeterminate" + case .running: + return "Running" + } + } + + var color: Color { + switch self { + case .stopped: + return .red + case .indeterminate: + return .yellow + case .running: + return .green + } + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControl/Preview Content/Preview Assets.xcassets/Contents.json b/Self-Control-Extension/SelfControl/SelfControl/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControl/README.md b/Self-Control-Extension/SelfControl/SelfControl/README.md new file mode 100644 index 00000000..dae091d0 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/README.md @@ -0,0 +1,9 @@ +# SelfControlApp + +This directory contains the Swift-based macOS app for SelfControl. + +## Structure + +- `UI/` - User interface code +- `ViewControllers/` - View controllers +- `Resources/` - App-specific resources \ No newline at end of file diff --git a/Self-Control-Extension/SelfControl/SelfControl/Resources/Base.lproj/Main.html b/Self-Control-Extension/SelfControl/SelfControl/Resources/Base.lproj/Main.html new file mode 100644 index 00000000..c5968810 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Resources/Base.lproj/Main.html @@ -0,0 +1,19 @@ + + + + + + + + + + + + + test Safari Ext Icon +

    You can turn on test Safari Ext’s extension in Safari Extensions preferences.

    +

    test Safari Ext’s extension is currently on. You can turn it off in Safari Extensions preferences.

    +

    test Safari Ext’s extension is currently off. You can turn it on in Safari Extensions preferences.

    + + + diff --git a/Self-Control-Extension/SelfControl/SelfControl/Resources/Icon.png b/Self-Control-Extension/SelfControl/SelfControl/Resources/Icon.png new file mode 100644 index 00000000..423b491d Binary files /dev/null and b/Self-Control-Extension/SelfControl/SelfControl/Resources/Icon.png differ diff --git a/Self-Control-Extension/SelfControl/SelfControl/Resources/Script.js b/Self-Control-Extension/SelfControl/SelfControl/Resources/Script.js new file mode 100644 index 00000000..1f3d6705 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Resources/Script.js @@ -0,0 +1,22 @@ +function show(enabled, useSettingsInsteadOfPreferences) { + if (useSettingsInsteadOfPreferences) { + document.getElementsByClassName('state-on')[0].innerText = "test Safari Ext’s extension is currently on. You can turn it off in the Extensions section of Safari Settings."; + document.getElementsByClassName('state-off')[0].innerText = "test Safari Ext’s extension is currently off. You can turn it on in the Extensions section of Safari Settings."; + document.getElementsByClassName('state-unknown')[0].innerText = "You can turn on test Safari Ext’s extension in the Extensions section of Safari Settings."; + document.getElementsByClassName('open-preferences')[0].innerText = "Quit and Open Safari Settings…"; + } + + if (typeof enabled === "boolean") { + document.body.classList.toggle(`state-on`, enabled); + document.body.classList.toggle(`state-off`, !enabled); + } else { + document.body.classList.remove(`state-on`); + document.body.classList.remove(`state-off`); + } +} + +function openPreferences() { + webkit.messageHandlers.controller.postMessage("open-preferences"); +} + +document.querySelector("button.open-preferences").addEventListener("click", openPreferences); diff --git a/Self-Control-Extension/SelfControl/SelfControl/Resources/Style.css b/Self-Control-Extension/SelfControl/SelfControl/Resources/Style.css new file mode 100644 index 00000000..cbde9e69 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Resources/Style.css @@ -0,0 +1,45 @@ +* { + -webkit-user-select: none; + -webkit-user-drag: none; + cursor: default; +} + +:root { + color-scheme: light dark; + + --spacing: 20px; +} + +html { + height: 100%; +} + +body { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + + gap: var(--spacing); + margin: 0 calc(var(--spacing) * 2); + height: 100%; + + font: -apple-system-short-body; + text-align: center; +} + +body:not(.state-on, .state-off) :is(.state-on, .state-off) { + display: none; +} + +body.state-on :is(.state-off, .state-unknown) { + display: none; +} + +body.state-off :is(.state-on, .state-unknown) { + display: none; +} + +button { + font-size: 1em; +} diff --git a/Self-Control-Extension/SelfControl/SelfControl/Resources/en.lproj/Main.html b/Self-Control-Extension/SelfControl/SelfControl/Resources/en.lproj/Main.html new file mode 100644 index 00000000..c5968810 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Resources/en.lproj/Main.html @@ -0,0 +1,19 @@ + + + + + + + + + + + + + test Safari Ext Icon +

    You can turn on test Safari Ext’s extension in Safari Extensions preferences.

    +

    test Safari Ext’s extension is currently on. You can turn it off in Safari Extensions preferences.

    +

    test Safari Ext’s extension is currently off. You can turn it on in Safari Extensions preferences.

    + + + diff --git a/Self-Control-Extension/SelfControl/SelfControl/SCUIUtility.swift b/Self-Control-Extension/SelfControl/SelfControl/SCUIUtility.swift new file mode 100644 index 00000000..cb80b5c4 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/SCUIUtility.swift @@ -0,0 +1,22 @@ +// +// SCUIUtility.swift +// SelfControl +// +// Created by Satendra Singh on 28/04/26. +// +import AppKit + +final class SCUIUtility { + static func checkNetworkAndShowNetworkAlert() -> Bool { + + if !SCUtility.networkConnectionIsAvailable() { + let alert = NSAlert() + alert.messageText = NSLocalizedString("No network connection detected", comment: "No network connection detected message") + alert.informativeText = NSLocalizedString("A block cannot be started without a working network connection. You can override this setting in Preferences.", comment: "Message when network connection is unavailable") + alert.runModal() + alert.addButton(withTitle: NSLocalizedString("OK", comment: "OK button")) + return false + } + return true + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControl/SCUtility.swift b/Self-Control-Extension/SelfControl/SelfControl/SCUtility.swift new file mode 100644 index 00000000..b5f82e79 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/SCUtility.swift @@ -0,0 +1,21 @@ +// +// SCUtility.swift +// SelfControl +// +// Created by Satendra Singh on 26/04/26. +// + +import SystemConfiguration + +final class SCUtility { + + static func networkConnectionIsAvailable() -> Bool { + var flags = SCNetworkReachabilityFlags() + + guard let target = SCNetworkReachabilityCreateWithName(nil, "google.com"), + SCNetworkReachabilityGetFlags(target, &flags) else { + return false + } + return flags.contains(.reachable) && !flags.contains(.connectionRequired) + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControl/SafariExtensionSupport/BlockListManager.swift b/Self-Control-Extension/SelfControl/SelfControl/SafariExtensionSupport/BlockListManager.swift new file mode 100644 index 00000000..a5082142 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/SafariExtensionSupport/BlockListManager.swift @@ -0,0 +1,123 @@ +import Foundation +import SafariServices +import os.log + +typealias SafariConst = SafariExtensionConstants + +struct BlockRule: Codable { + struct Trigger: Codable { + let urlFilter: String + + enum CodingKeys: String, CodingKey { + case urlFilter = "url-filter" + } + } + + struct Action: Codable { + let type: String + } + + let trigger: Trigger + let action: Action +} + + +enum BlockListManager { + + static func updateSafariBlockList(blockedPaths: [String], appGroup: String, extensionIdentifier: String) { + var rules: [BlockRule] = [] + + for path in blockedPaths { +// let components = path.split(separator: "/", maxSplits: 1, omittingEmptySubsequences: false) +// let domain = String(components[0]) +// let pathPart = components.count == 2 ? String(components[1]) : "" +// +// let escapedDomain = NSRegularExpression.escapedPattern(for: domain) +// var pattern = "^https?://(www\\.)?" + escapedDomain +// if !pathPart.isEmpty { +// let escapedPath = NSRegularExpression.escapedPattern(for: pathPart) +// pattern += "/" + escapedPath +// } +// pattern += ".*" + +// rules.append(BlockRule( +// trigger: .init(urlFilter: SimpleRegexConverter.regexFromURL(path) ?? path), +// action: .init(type: "block") +// )) + rules.append(BlockRule( + trigger: .init(urlFilter: path), + action: .init(type: "block") + )) + } + + do { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted] + let data = try encoder.encode(rules) + + let fileManager = FileManager.default + guard let containerURL = fileManager.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else { + print("❌ App group container not found.") + return + } + + // Ensure folder exists before writing + try fileManager.createDirectory(at: containerURL, withIntermediateDirectories: true, attributes: nil) + + let fileURL = containerURL.appendingPathComponent(Constants.SAFARI_BLOCKER_FILE_NAME) + try data.write(to: fileURL, options: .atomic) + + print("✅ Wrote blockerList.json to: \(fileURL.path)") + +// if SafariExtensionManager.shared.isExtensionReady { //If Safari is ready mark it in the Network extension + SFSafariExtensionManager.getStateOfSafariExtension(withIdentifier: SafariConst.identifier) { state, error in + os_log("[SC] 🔍 Safari Extension State Error: %{public}@", error?.localizedDescription ?? "") + os_log("[SC] 🔍 Safari Extension State: %{public}d", state?.isEnabled ?? false) + print("Safari Extension State Error :\(String(describing: error))") + print("Safari Extension State :\(state?.isEnabled ?? false)") + Task { @MainActor in + if NetworkExtensionState.shared.isEnabled == true { + NetworkExtensionState.shared.isSafariExtensionEnabled = state?.isEnabled ?? false + NetworkExtensionState.shared.printAll() + } + } + } +// } + SFSafariApplication.dispatchMessage( + withName: SafariConst.MessagesName.reloadList.rawValue, + toExtensionWithIdentifier: SafariConst.identifier, + userInfo: ["changed": true]) { error in + os_log("[SC] 🔍 Safari Message toExtensionWithIdentifier: reloadList: %{public}@", error?.localizedDescription ?? "") + print("Safari Message toExtensionWithIdentifier: reloadList:", error) + if error == nil { + Task { @MainActor in + if NetworkExtensionState.shared.isEnabled == true && NetworkExtensionState.shared.isSafariExtensionEnabled == false { //possibly extension is ready now + NetworkExtensionState.shared.isSafariExtensionEnabled = true + NetworkExtensionState.shared.printAll() + } + } + } + } + } catch { + print("❌ Safari Error writing blocker file:", error) + } + } + + static func updateExtensionState() { + Task { @MainActor in + if NetworkExtensionState.shared.isEnabled == true { } + } + } + + static func activateSafariBlocking() { + updateExtensionState() + SafariExtensionManager.shared.enableExtension() + os_log("Safari Message toExtensionWithIdentifier activateSafariBlocking called:") + } + + static func deactivateSafariBlocking() { + updateExtensionState() + SafariExtensionManager.shared.disableExtension() + os_log("Safari Message toExtensionWithIdentifier deactivateSafariBlocking called:") + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControl/SafariExtensionSupport/Constants.swift b/Self-Control-Extension/SelfControl/SelfControl/SafariExtensionSupport/Constants.swift new file mode 100644 index 00000000..58dd76f3 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/SafariExtensionSupport/Constants.swift @@ -0,0 +1,13 @@ +// +// Constants.swift +// SelfControl +// +// Created by Satendra Singh on 09/10/25. +// + + + +enum Constants { + /// File name for the JSON file with Safari rules. + static let SAFARI_BLOCKER_FILE_NAME = "blockerList.json" +} diff --git a/Self-Control-Extension/SelfControl/SelfControl/SafariExtensionSupport/SafariExtensionManager.swift b/Self-Control-Extension/SelfControl/SelfControl/SafariExtensionSupport/SafariExtensionManager.swift new file mode 100644 index 00000000..d8b159b6 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/SafariExtensionSupport/SafariExtensionManager.swift @@ -0,0 +1,87 @@ +// +// SafariExtensionManager.swift +// SelfControl +// +// Created by Satendra Singh on 24/11/25. +// + +import Foundation +import os.log + +final class SafariExtensionManager: ObservableObject { + static let shared = SafariExtensionManager() + var onExtensionStateChange: (() -> Void)? + private var isReady: Bool = false + private var isEnabled = false + var lastUpdateReceivedTime: Date? { + didSet { + onExtensionStateChange?() + } + } + + private init() { + NotificationCenter.default.addObserver( + forName: UserDefaults.didChangeNotification, + object: UserDefaults(suiteName: SafariConst.appGroup), + queue: .main + ) { notification in + let shared = UserDefaults(suiteName: SafariConst.appGroup) + print("Received from extension:", shared?.string(forKey: "ready") ?? "") + if let value = shared?.bool(forKey: "ready") { + if value == true { + print("Ready now:") + if self.isReady == false { + self.onExtensionStateChange?() + self.isReady = true + } + } + } + } + registerForUpdates() + } + + var isExtensionReady: Bool { + guard let lastUpdateReceivedTime = lastUpdateReceivedTime else { return false } + // Consider the extension "ready" if we received an update within the last 35 seconds + return Date().timeIntervalSince(lastUpdateReceivedTime) <= 35 +// return UserDefaults(suiteName: appGroup)?.bool(forKey: "ready") == true + } + + func enableExtension() { + let defaults = UserDefaults(suiteName: SafariConst.appGroup) + defaults?.set(true, forKey: "enabled") + defaults?.synchronize() + } + + func disableExtension() { + let defaults = UserDefaults(suiteName: SafariConst.appGroup) + defaults?.set(false, forKey: "enabled") + defaults?.synchronize() + } + + func resetExtensionState() { + let defaults = UserDefaults(suiteName: SafariConst.appGroup) + defaults?.set(false, forKey: "enabled") + defaults?.set(false, forKey: "ready") + defaults?.synchronize() + } + + func registerForUpdates() { +// let path = (NSHomeDirectory() + "/Library/Containers/com.apple.Safari/Data/Library/Preferences/com.apple.Safari.Extensions.plist") +// +// let fileDescriptor = open(path, O_EVTONLY) +// let source = DispatchSource.makeFileSystemObjectSource( +// fileDescriptor: fileDescriptor, +// eventMask: .write, +// queue: DispatchQueue.global() +// ) +// +// source.setEventHandler { +// print("Safari extension plist changed → check enable state") +// // call getStateOfSafariExtension again here +// } +// source.resume() + + } +} + diff --git a/Self-Control-Extension/SelfControl/SelfControl/SafariExtensionSupport/SimpleRegexConverter.swift b/Self-Control-Extension/SelfControl/SelfControl/SafariExtensionSupport/SimpleRegexConverter.swift new file mode 100644 index 00000000..24ffd6cd --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/SafariExtensionSupport/SimpleRegexConverter.swift @@ -0,0 +1,103 @@ +// +// SimpleRegexConverter.swift +// SelfControl +// +// Created by Satendra Singh on 09/10/25. +// + +import Foundation + +final class SimpleRegexConverter { + /// Converts a full URL string into a regex domain pattern suitable for Safari content blocker rules. + /// Example: "https://www.facebook.com/friends" → "https?://(www\\.)?facebook\\.com/.*" + static func regexPattern(from urlString: String) -> String? { + guard let url = buildValidURL(from: urlString), + let host = url.host else { + return nil + } + + // Escape regex special characters in domain + let escapedHost = NSRegularExpression.escapedPattern(for: host) + + // Detect common "www" prefix and make it optional + let pattern: String + if host.hasPrefix("www.") { + let domainWithoutWWW = String(host.dropFirst(4)) + let escapedDomain = NSRegularExpression.escapedPattern(for: domainWithoutWWW) + pattern = "https?://(www\\.)?\(escapedDomain)/.*" + } else { + pattern = "https?://(www\\.)?\(escapedHost)/.*" + } + + return pattern + } + + static func buildValidURL(from path: String) -> URL? { + var trimmedPath = path.trimmingCharacters(in: .whitespacesAndNewlines) + + // If it's already a valid URL with scheme + if let url = URL(string: trimmedPath), url.scheme != nil { + return url + } + + // Add missing scheme (default to https) + if !trimmedPath.lowercased().hasPrefix("http://") && !trimmedPath.lowercased().hasPrefix("https://") { + trimmedPath = "https://" + trimmedPath + } + + // Try building again + guard var components = URLComponents(string: trimmedPath) else { + return nil + } + + // Fix missing host (e.g., if user entered "example.com/test") + if components.host == nil { + let parts = trimmedPath + .replacingOccurrences(of: "https://", with: "") + .replacingOccurrences(of: "http://", with: "") + .split(separator: "/", maxSplits: 1) + + if let hostPart = parts.first { + components.host = String(hostPart) + components.path = parts.count > 1 ? "/" + parts[1] : "" + } + } + + // Return final URL + return components.url + } + + + static func regexFromURL(_ input: String) -> String? { + var urlString = input.trimmingCharacters(in: .whitespacesAndNewlines) + + // Add a scheme if missing (needed for URL parsing) + if !urlString.lowercased().hasPrefix("http://") && + !urlString.lowercased().hasPrefix("https://") { + urlString = "https://" + urlString + } + + guard let url = URL(string: urlString), let host = url.host else { + return nil + } + + // Escape host and path for regex + let escapedHost = NSRegularExpression.escapedPattern(for: host) + let escapedPath = NSRegularExpression.escapedPattern(for: url.path) + + // Build regex: + // ^[^:]+://+([^.]+\.)*facebook\.com(/friends|[/:]|$) + // - Matches any scheme (http/https) + // - Allows any subdomain levels + // - Matches the path if given + var regex = #"^[^:]+://+([^.]+\.)*\#(escapedHost)"# + + if !escapedPath.isEmpty && escapedPath != "/" { + regex += escapedPath + } + + regex += #"([/:]|$)"# + + return regex + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControl/Sparkle/dsa_pub.pem b/Self-Control-Extension/SelfControl/SelfControl/Sparkle/dsa_pub.pem new file mode 100644 index 00000000..950143dc --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControl/Sparkle/dsa_pub.pem @@ -0,0 +1,36 @@ +-----BEGIN PUBLIC KEY----- +MIIGRzCCBDkGByqGSM44BAEwggQsAoICAQD7ZLyF2yKAuctR4LPKxlhWtZVJgCyz +YhUuEk/Rxj+3n4g+pc+ogRamWCxYMLfijMEXNNnc14tsZfgyVB2lGq2skuyrrEkq +W/m4glMhXJU80JYiZm2mteSztCG18zWJMq6XVw5MW3xFrceD8PevmtuXugqKhjPN +6a5qlavLUPzWiyuDcS34UgxgEGX6L4qv36h++EFUfAytEs/PaVEINf4Aq3B32k9k +I2aHOJathNoWA/GwtROJQ1biupKMJaDlkMBLlULKWJ6XyYqIbaVmgwOoxVvHN3O0 +pWRlP3qJaO8nG/qaLlzQU2vv26uRa4vTfmTfpRQbwCZVxg7rSvAkalJRcKw/beBg +5FIUiF5UhU9/q11TeK4yefPFMxKv6dkpnrVY8YGsiKul+bHP3n66pfVjcqACL0sb +KUfJPmyg/ass5d6L+nTJJMPuJFU6+AQxsigXQQQ4ijUVSmKVICrHumugUCUozHrz +X6UmZz4eY3BPSTX3hxxY6L43xVAYA8DMAKVSmc0k5B1p7QP1Du7uXTrP806ZbOtt +A0Rn8Zty+cuBJ8MH0t8sKJ1EDCHLqD5bInfAyg5kQ+DxOIiXKbVpcjGCTir0fgvh +Ex+ePJkZpFfXHwddEwlIYQCHHGiAytpGSTpql86fwGGOPIq1waImNwsdONDGUYDF +dVR7dNo0PBBPJwIhAOxFdosK/0KcNo+CQ0Iw6JpWJzqwTo6V+LUfiq2ZOvMFAoIC +AGFnEtGMNtaBe+xM4ihWyH1UBtlgjRfZpY6CCdcoFFzqujnHyt9k+3A2cSYwFr7w +TB+0BCtvkqyPgOOlsYSOCVJcwyBAVHxKjRr+QjyslQ7f6RFpjmE0vjCfuwcgTia8 +nqsXAQCkeD+Tenq9+nbYD8hTDyFD0XpoHHfgXDgFKZBGtNNA6738aGUfMxfUSHBz +HIOPvJcwVnl82gv+EJXZ3Bt07MX1ETGtTpuXqwnN8XhV/BiAK4jny+K0OgNGQKQf +T7DFtz+kq5MR9/urLhHrs1d7SEq1lL8EK1G+m75DMq94H9Z6VjYTYl1U6UAuORKU +r/Aq07YC7wyO0BX4JTSIQi+VzIR663Y2+tGcX07r7oEzUE9E3wIcMfSSjKYV+Keq +5bYHpz5VZC1MeE+9x+vrLvnt5v/8d344HABVtfEFWw1haMGbtmKIAYE5NnvzIn+O +iIvVdWUEbUdPNlHe2MTjU0EWk2GBmMSQdsiMPohf0DugKtfd5Flv+H9t8JC+pDMm +cNEcpCzOIvtun8FtYC8hftdFSUUL2y91ESZtUTAbxAd1RY+aWq5ySOFSL4Lr7+8p +LfYryIAep7AmE0DKTZXzebXSmptXhsafBa2CEJZa+86Gw3TtJe+ya97JXVRW6lVz +PKA1ypLoNbA2Rfu4xrczG3k895few/KA7m49x5p94KcjA4ICBgACggIBAOhmgPtB +tpW1QxiIbLqI952kUk0AnFjfLrWAo3VfS4rCxs4tltoKAORxi+rFn6h3PIvatmpy +jTXg1ZhICZ1rx/OP1/gbG+uCw4NF0Sro6+IhyYK65H6ZeM5/MUMYdpoSucrb7z44 +H3yi5xf/S+rKew+fnFDOwAFChtUgoRW9HzNJZpd/AvpVSPARlDeanYBwXAPGgKFL +o9DeJR1Soab/UdIlkPpjy1xTTVzrnkx15g5gaE0BWb93CVfDqvi7dRCr28FNvSIz +nmBJnqZiShw+WJ0RdG35tfeiQ51DQC/8j02vtAZ6ZVfug3q34+i1UxJj/QO+u42y +imDk2hAGXQAB3h8DfJevjNRngeGTRzV4jOHL98ha5cRQgKK5vqgQWMRb4bSuGwLc +bBjQDDwBbdbHc2IL0k1ff56pZ+tA7z3k6d2nKGjIy4CyTD1Mc4gROx4p4/xAL1tf +bhFbihFU0W+sIdBEllIyrVkk+NXXpQrBB0YRXK/Fy3gIk8yzFOAeiNc2NTF6kgt5 +AXAprRlexSX17hIUPi0V000mTX5GqNFMPmM26zxIXXSr0VApjLhslUarbtbSQ27a +qNAXGuDQkaZMs+jo29K/dQvLXKeyPHpGotfgvn3xsC1KnSiIgcm0z+EjLAuRVjqC +CDx3nE+2CbIFoCzJUlEdTgwaHp/vmUKxMmG8 +-----END PUBLIC KEY----- diff --git a/Self-Control-Extension/SelfControl/SelfControlBGService/CoreService/AppDelegate.swift b/Self-Control-Extension/SelfControl/SelfControlBGService/CoreService/AppDelegate.swift new file mode 100644 index 00000000..508b554f --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControlBGService/CoreService/AppDelegate.swift @@ -0,0 +1,54 @@ +// HelperApp/AppDelegate.swift +import Cocoa +//import Shared +import OSLog + +final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate { + // Use a reverse-DNS mach service name. This must match your helper’s Info.plist. + private let listener = NSXPCListener(machServiceName: "com.application.SelfControl.corebits.bgservice.xpc") + let service = HelperService() + static let shared = AppDelegate() + override init() { + print("AppDelegate: init") + os_log("[SC] 🔍] BG AppDelegate: init") + + super.init() + listener.delegate = self + listener.resume() + } + + func applicationDidFinishLaunching(_ notification: Notification) { +// listener.delegate = self +// listener.resume() + // Agent app has no UI; it just keeps running. + } + + func listener( + _ listener: NSXPCListener, + shouldAcceptNewConnection newConnection: NSXPCConnection + ) -> Bool { + + print("shouldAcceptNewConnection") + os_log("[SC] 🔍] BG shouldAcceptNewConnection") + BGFileLogger.info("shouldAcceptNewConnection") + let exportedInterface = + NSXPCInterface(with: HelperServiceProtocol.self) + + newConnection.exportedInterface = exportedInterface + newConnection.exportedObject = service + newConnection.remoteObjectInterface = NSXPCInterface(with: HelperClientProtocol.self) + newConnection.invalidationHandler = { + os_log("[SC] 🔍] BG newConnection.invalidationHandler") + BGFileLogger.error("newConnection.invalidationHandler") + } + + newConnection.interruptionHandler = { + os_log("[SC] 🔍] BG newConnection.interruptionHandler") + BGFileLogger.error("newConnection.interruptionHandler") + } + service.clientConnection = newConnection + newConnection.resume() + + return true + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControlBGService/CoreService/AppStateManager.swift b/Self-Control-Extension/SelfControl/SelfControlBGService/CoreService/AppStateManager.swift new file mode 100644 index 00000000..6ca9bd12 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControlBGService/CoreService/AppStateManager.swift @@ -0,0 +1,59 @@ +// +// AppStateManager.swift +// SelfControlBGService +// +// Created by Satendra Singh on 18/05/26. +// + +import Foundation +import os.log + +actor AppStateManager { + static let shared = AppStateManager() + var isBlockingEnabled: Bool = false + private var safariLastApiUpdateReceivedTime = Date() + private var isSafariExtensionRunning: Bool = false + private var isChromeExtensionRunning: Bool = false + + func handleApiRequest(path: ServicePath) { + switch path { + case .safari: + receivedSafariApiRequest() + case .chrome: + receivedChromeApiRequest() + } + } + + private func receivedSafariApiRequest() { + safariLastApiUpdateReceivedTime = Date() + guard !isSafariExtensionRunning else { return } + isSafariExtensionRunning = true + HelperService.send_didEnableWebExtension(WEBExtension.safari.rawValue, state: true) + } + + private func receivedChromeApiRequest() { + guard !isChromeExtensionRunning else { return } + isChromeExtensionRunning = true + HelperService.send_didEnableWebExtension(WEBExtension.chrome.rawValue, state: true) + } + + func activateContentBlocking() { + isBlockingEnabled = true + } + + func deactivateContentBlocking() { + isBlockingEnabled = false + os_log("[SC] 🔍] BG deactivateContentBlocking: %{public}d",isBlockingEnabled) + } + + func reset() { + safariLastApiUpdateReceivedTime = Date() + isSafariExtensionRunning = false + isChromeExtensionRunning = false + } + + func sendLatestNetworkExtensionStatus() { + HelperService.send_didEnableWebExtension(WEBExtension.safari.rawValue, state: isSafariExtensionRunning) + HelperService.send_didEnableWebExtension(WEBExtension.chrome.rawValue, state: isChromeExtensionRunning) + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControlBGService/CoreService/EventSchedulerRunnerController.swift b/Self-Control-Extension/SelfControl/SelfControlBGService/CoreService/EventSchedulerRunnerController.swift new file mode 100644 index 00000000..15f5a191 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControlBGService/CoreService/EventSchedulerRunnerController.swift @@ -0,0 +1,56 @@ +// +// EventSchedulerRunnerContainer.swift +// SelfControl +// +// Created by Satendra Singh on 28/05/26. +// + +final class EventSchedulerRunnerController { + private var eventRunner: EventSchedulerRunner? = nil + var eventRunnerHandler: EventSchedulerRunner.EventHandler? + + init(eventRunnerHandler: EventSchedulerRunner.EventHandler? = nil) { + self.eventRunnerHandler = eventRunnerHandler + startEventScheduler() + } + + func startEventScheduler() { + Task { + await self.eventRunner?.stop() + let scheduler = EventScheduler() + let events = HelperAppPreferences.loadSchedules() + var eventObjs: [Event] = [] + for event in events { + eventObjs.append(contentsOf: event.scheduledEvents) + } + for item in eventObjs { + _ = try? await scheduler.schedule(newEvent: item) + } + eventRunner = EventSchedulerRunner(scheduler: scheduler) + + await eventRunner?.addGlobalHandler { event in + await self.eventRunnerHandler?(event) + } + await eventRunner?.start() + } + } + + func stopEventScheduler() { + Task { + await self.eventRunner?.stop() + } + startEventScheduler() + } +} + +extension Schedule { + var scheduledEvents: [Event] { + var items: [Event] = [] + for slot in self.timeSlots { + if let event = try? Event(days: enabledDays, startTime: TimeOfDay(hour: slot.startHour, minute: slot.startMinute), endTime: TimeOfDay(hour: slot.endHour, minute: slot.endMinute)) { + items.append(event) + } + } + return items + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControlBGService/CoreService/FilterController.swift b/Self-Control-Extension/SelfControl/SelfControlBGService/CoreService/FilterController.swift new file mode 100644 index 00000000..573191d6 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControlBGService/CoreService/FilterController.swift @@ -0,0 +1,47 @@ +// +// FilterController.swift +// SelfControl +// +// Created by Satendra Singh on 26/05/26. +// + + +import NetworkExtension +import os.log + +final class FilterController { + static func restartFilter(completion: @escaping (Error?) -> Void) { + os_log("[SC] 🔍] BG restartFilter:") + NEFilterManager.shared().loadFromPreferences { error in + if let error = error { + completion(error) + return + } + + let manager = NEFilterManager.shared() + // If it’s not enabled, just enable it once. + guard manager.isEnabled else { + manager.isEnabled = true + manager.saveToPreferences { saveError in + completion(saveError) + } + return + } + + // Disable first + manager.isEnabled = false + manager.saveToPreferences { disableError in + if let disableError = disableError { + completion(disableError) + return + } + + // Re-enable + manager.isEnabled = true + manager.saveToPreferences { enableError in + completion(enableError) + } + } + } + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControlBGService/CoreService/HelperService+SendMessages.swift b/Self-Control-Extension/SelfControl/SelfControlBGService/CoreService/HelperService+SendMessages.swift new file mode 100644 index 00000000..de6cd4e9 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControlBGService/CoreService/HelperService+SendMessages.swift @@ -0,0 +1,23 @@ +// +// HelperService+Messages.swift +// SelfControl +// +// Created by Satendra Singh on 18/05/26. +// + +import Foundation + +extension HelperService { + static var proxyConnectionService: HelperClientProtocol? { + AppDelegate.shared.service.proxyConnectionService() + } + + static func send_didEnableWebExtension(_ extensionTypeRawValue: String, state: Bool) { + proxyConnectionService?.didEnableWebExtension(extensionTypeRawValue, state: state) + AppDelegate.shared.service.networkExtension.sendMessageToSetActiveBrowserExtension(extensionTypeRawValue, state: state) + } + + static func send_didStartedBlocking(_ state: Bool, _ endDate: Date) { + proxyConnectionService?.didStartedBlocking(state, endDate) + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControlBGService/CoreService/HelperService.swift b/Self-Control-Extension/SelfControl/SelfControlBGService/CoreService/HelperService.swift new file mode 100644 index 00000000..4b4bf206 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControlBGService/CoreService/HelperService.swift @@ -0,0 +1,206 @@ +// +// HelperService.swift +// SelfControl +// +// Created by Satendra Singh on 17/05/26. +// +import OSLog +import Foundation + +final class HelperService: NSObject, HelperServiceProtocol { + private var isMonitoring = false + private var clients = NSHashTable.weakObjects() + let queue = DispatchQueue(label: "com.application.SelfControl.corebits.bgservice.queue") + var clientConnection: NSXPCConnection? + let chromeService = ChromeExtensionRequestListner() + var blockedUrls: [String] = HelperAppPreferences.loadlockedUrls() ?? [] + private var timer: DelayTimerHandler? + private var eventSchedulerController: EventSchedulerRunnerController? + let networkExtension = IPCConnection.shared + + override init() { + super.init() + + eventSchedulerController = EventSchedulerRunnerController(eventRunnerHandler: { [weak self] event in + os_log("New Event schedule started: \(event.startTime.formatted()) - \(event.endTime.formatted())") + + let minutes = Double(event.endTime.minutes(from: event.startTime, wrapAroundMidnight: true)) + self?.startNetwrokBlocking(minutes: Int(minutes)) + }) + } + + func registerClient() { + os_log("[SC] 🔍] BG registerClient") + queue.async { [weak self] in + // Fix: Use Task to ensure actor-isolated method is called on its actor +// Task { @MainActor in +// await AppStateManager.shared.reset() +// } + // Continue other non-actor work here + print("Register Client received") + self?.blockedUrls = HelperAppPreferences.loadlockedUrls() ?? [] + self?.networkExtension.register(completionHandler: { [weak self] status in + // ... rest of your code ... + }) + + self?.chromeService.startListening() + self?.chromeService.blockeddomainFetcher = { [weak self] in + return self?.blockedUrls ?? [] + } + } + } + + func proxyConnectionService() -> HelperClientProtocol? { + var proxy: AnyObject? + guard let conn = clientConnection else { + os_log("[SC] 🔍] BG No client connection") + BGFileLogger.error("BG No client connection") + return nil + } + proxy = conn.remoteObjectProxyWithErrorHandler { err in + os_log("[SC] 🔍] BG Client proxy error:\(err)") + BGFileLogger.error("HelperService: proxyConnectionService: Error: \(err)") + } as AnyObject? + return proxy as? HelperClientProtocol + } + + func sendPing() { + os_log("[SC] 🔍] BG HELPER sendPing") + let schedules = HelperAppPreferences.loadSchedules() + var details :String = "" + for schedule in schedules { + os_log("[SC] 🔍] BG Schedule: \(schedule.summaryString)") + details.append(schedule.summaryString + "\n") + } + proxyConnectionService()?.didEmitEvent("Hello from helper: \(schedules.count), details: \(details)") + } + + func currentStatus(reply: @escaping (String) -> Void) { + queue.async { + reply(self.isMonitoring ? "running" : "stopped") + } + } + + private func broadcastStatus() { + let status = self.isMonitoring ? "running" : "stopped" + for client in self.clients.allObjects { + (client as? HelperClientProtocol)?.didUpdateStatus(status) + } + } + + private func broadcastEvent(_ message: String) { + for client in self.clients.allObjects { + (client as? HelperClientProtocol)?.didEmitEvent(message) + } + } + + func saveSchedules(schedules: Data, reply: @escaping (Bool) -> Void) { + queue.async { [weak self] in + HelperAppPreferences.saveSchedulesData(schedules: schedules) + self?.broadcastEvent("Schedules saved") + self?.sendPing() + self?.eventSchedulerController?.startEventScheduler() + reply(true) + } + } + + func loadSchedules(reply: @escaping (Data) -> Void) { + queue.async { + self.broadcastEvent("Schedules loaded") + reply(Data()) + } + } + + func setBlockedURLs(_ urls: [String]) { + os_log("[SC] 🔍] BG setBlockedURLs: %{public}@", urls) + queue.async(flags: .barrier) { + self.networkExtension.sendMessageToSetBlockingURLs(urls) + self.blockedUrls = urls + HelperAppPreferences.saveBlockedUrls(blockedUrls: urls) + } + } + + func startNetwrokBlocking(minutes: Int) { + os_log("[SC] 🔍] BG startNetwrokBlocking: %{public}d", minutes) + self.networkExtension.register(completionHandler: { [weak self] status in + os_log("[SC] 🔍] BG register NE status: \(status)") + self?.networkExtension.sendMessageToSetBlockingURLs(self?.blockedUrls ?? []) + Task { @MainActor in + await AppStateManager.shared.sendLatestNetworkExtensionStatus() + } + }) + os_log("[SC] 🔍] BG startNetwrokBlocking: %{public}@", blockedUrls) + + queue.async { [weak self] in + self?.startNetworkBlocking() + self?.timer = DelayTimerHandler(delay: Double(minutes), completionHandler: { [weak self] in + self?.stopNetworkBlocking() + }, cancelHandler: { [weak self] in + self?.stopNetworkBlocking() + }) + if let queue = self?.queue { + self?.timer?.timerQueue = queue + } + self?.timer?.startTimerWithSelectedDelay() + if let fireDate = self?.timer?.timerFireDate { + os_log("[SC] 🔍] BG register NE timer active, firing at: \(fireDate)") + HelperService.send_didStartedBlocking(true, fireDate) + } + } + } + + func stopNetworkBlocking() { + os_log("[SC] 🔍] BG stopNetworkBlocking") + timer?.cancelTimer() + queue.async { + Task { + _ = self.networkExtension.sendMessageToEnableNetworkExtension(false) + await AppStateManager.shared.deactivateContentBlocking() + Sound.checkAndPlay() + } + } + } + + func extendBlocking(minutes: Int) { + os_log("[SC] 🔍] BG extendBlocking: %{public}d", minutes) + timer?.extendBlocking(minutes: minutes) + } + + func setPreference(key: String, value: Bool) { + os_log("[SC] 🔍] BG extendBlocking: %{public}@:, %{public}d", key, value) + HelperAppPreferences.savePreference(key: key, value: value) + } + + func getBlockedStates(reply: @escaping (_ state: Bool, _ endDate: Date?) -> Void) { + queue.async { [weak self] in + Task { [weak self] in + let state = await AppStateManager.shared.isBlockingEnabled + if state { + if let endDate = self?.timer?.timerFireDate { + os_log("[SC] 🔍] BG getBlockedStates Timer active, firing at: \(endDate)") + reply(true, endDate) + } else { + os_log("[SC] 🔍] BG getBlockedStates Timer active, firing NIL") + reply(false, nil) + } + + } else { + os_log("[SC] 🔍] BG getBlockedStates Timer not running") + reply(false, nil) + } + } + } + } + + private func startNetworkBlocking() { + queue.async { + Task { + _ = self.networkExtension.sendMessageToEnableNetworkExtension(true) + await AppStateManager.shared.activateContentBlocking() + FilterController.restartFilter { result in + os_log("[SC] 🔍] BG FilterController.restartFilter: \(result)") + } + } + } + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControlBGService/Helpers/DelayTimerHandler.swift b/Self-Control-Extension/SelfControl/SelfControlBGService/Helpers/DelayTimerHandler.swift new file mode 100644 index 00000000..1b6cc4a6 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControlBGService/Helpers/DelayTimerHandler.swift @@ -0,0 +1,155 @@ +// +// DelayTimerHandler.swift +// SelfControl +// +// Created by Satendra Singh on 20/05/26. +// + + +import Foundation +import os.log + +final class DelayTimerHandler { + + private var blockTimer: DispatchSourceTimer? + var timerQueue = DispatchQueue( + label: "com.example.delaytimerhandler.timer", + qos: .utility + ) + + private(set) var timerFireDate: Date? + private(set) var isActiveBlocking: Bool = false + + var delay: Double // minutes + + var completionHandler: (() -> Void)? + var cancelHandler: (() -> Void)? + + init( + delay: Double, + completionHandler: (() -> Void)? = nil, + cancelHandler: (() -> Void)? = nil + ) { + self.delay = delay + self.completionHandler = completionHandler + self.cancelHandler = cancelHandler + } + + deinit { + cancelTimer() + } + + @discardableResult + func startTimerWithSelectedDelay() -> Bool { + + cancelEventHandler() // IMPORTANT + + let seconds = delay * 60.0 + + guard seconds > 30 else { + os_log("[SC] 🔍] BG Invalid delay: %f", seconds) + return false + } + + timerFireDate = Date().addingTimeInterval(seconds) + isActiveBlocking = true + + let timer = DispatchSource.makeTimerSource(queue: timerQueue) + + timer.schedule( + deadline: .now() + seconds, + repeating: .never, + leeway: .seconds(1) + ) + + timer.setEventHandler { [weak self] in + guard let self else { return } + + os_log("[SC] 🔍] BG Timer fired") + + // Prevent future usage immediately + let completion = self.completionHandler + + self.blockTimer?.setEventHandler {} + self.blockTimer?.cancel() + self.blockTimer = nil + + self.isActiveBlocking = false + self.timerFireDate = nil + + self.completionHandler = nil + self.cancelHandler = nil + + // Call once + completion?() + } + + blockTimer = timer + timer.resume() + + return true + } + + func extendBlocking(minutes: Int) { + guard minutes > 0 else { return } + timerQueue.async { [weak self] in + guard let self = self else { return } + guard self.isActiveBlocking, let timer = self.blockTimer else { + os_log("[SC] 🔍] BG extendBlocking ignored: no active timer") + return + } + + let additionalSeconds = Double(minutes) * 60.0 + let now = Date() + let currentFire = self.timerFireDate ?? now + let newFire = currentFire.addingTimeInterval(additionalSeconds) + + // Update model + self.timerFireDate = newFire + self.delay += Double(minutes) // keep start time consistent + + // Reschedule timer to the new deadline + let remaining = max(0, newFire.timeIntervalSince(now)) + os_log("[SC] 🔍] BG Extending blocking by %{public}d minutes (%{public}.0f s). New remaining: %.0f s", minutes, additionalSeconds, remaining) + BGFileLogger.info("[SC] 🔍] BG Extending blocking by \(minutes) minutes \(additionalSeconds). New remaining: \(remaining)") + timer.schedule(deadline: .now() + remaining, repeating: .never, leeway: .seconds(1)) + } + } + + private func cancelEventHandler() { + guard let timer = blockTimer else { return } + + os_log("[SC] 🔍] BG Cancelling timer") + + timer.setEventHandler {} + timer.cancel() + } + + func cancelTimer() { + + guard let timer = blockTimer else { return } + + os_log("[SC] 🔍] BG Cancelling timer") + + timer.setEventHandler {} + timer.cancel() + + blockTimer = nil + + isActiveBlocking = false + timerFireDate = nil + + cancelHandler?() + + completionHandler = nil + cancelHandler = nil + } + + var blockingEndTime: Date? { + timerFireDate + } + + var blockingStartTime: Date? { + timerFireDate?.addingTimeInterval(-(delay * 60.0)) + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControlBGService/Helpers/Sound.swift b/Self-Control-Extension/SelfControl/SelfControlBGService/Helpers/Sound.swift new file mode 100644 index 00000000..68e2b622 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControlBGService/Helpers/Sound.swift @@ -0,0 +1,50 @@ +// +// Sound.swift +// SelfControl +// +// Created by Satendra Singh on 31/05/26. +// + +import AppKit +import os.log + +public extension NSSound { + + enum Sound: String { + case basso = "Basso" + case blow = "Blow" + case bottle = "Bottle" + case frog = "Frog" + case funk = "Funk" + case glass = "Glass" + case hero = "Hero" + case morse = "Morse" + case ping = "Ping" + case pop = "Pop" + case purr = "Purr" + case sosumi = "Sosumi" + case submarine = "Submarine" + case tink = "Tink" + } + + var soundName: String? { + return self.name + } + + static func play(_ sound: Sound) { + NSSound(named: NSSound.Name(sound.rawValue))?.play() + } + + static func playDefaultSound() { + play(.basso) + } +} + +struct Sound { + static func checkAndPlay() { + os_log("[SC] 🔍] BG checkAndPlay: %{public}d",HelperAppPreferences.playSoundOnCompletion) + if HelperAppPreferences.playSoundOnCompletion { + NSSound.playDefaultSound() + } + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControlBGService/Info.plist b/Self-Control-Extension/SelfControl/SelfControlBGService/Info.plist new file mode 100755 index 00000000..525265a0 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControlBGService/Info.plist @@ -0,0 +1,44 @@ + + + + + CFBundleDevelopmentRegion + English + CFBundleDisplayName + SelfControlBGService + CFBundleExecutable + ${EXECUTABLE_NAME} + CFBundleIconFile + SelfControlIcon + CFBundleIdentifier + com.application.SelfControl.corebits.bgservice + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ${PRODUCT_NAME} + CFBundlePackageType + APPL + CFBundleShortVersionString + 4.0.2 + MachServices + + com.application.SelfControl.corebits.bgservice.xpc + + + CFBundleVersion + 410 + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSHumanReadableCopyright + Free and open-source under the GPL. + SMAuthorizedClients + + anchor apple generic and (identifier "org.eyebeam.SelfControl" or identifier "org.eyebeam.selfcontrol-cli" or identifier "com.application.SelfControl.corebits") + + + diff --git a/Self-Control-Extension/SelfControl/SelfControlBGService/Preferences/HelperAppPreferences.swift b/Self-Control-Extension/SelfControl/SelfControlBGService/Preferences/HelperAppPreferences.swift new file mode 100644 index 00000000..c04930d7 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControlBGService/Preferences/HelperAppPreferences.swift @@ -0,0 +1,127 @@ +// +// EventSchedulerStore.swift +// SelfControl +// +// Created by Satendra Singh on 25/02/26. +// + +import Foundation + +struct HelperAppPreferences { + enum Keys: String { + case playSoundOnCompletionkey = "playSoundOnCompletion" + } + + // Load schedules from UserDefaults + static func loadSchedules() -> [Schedule] { + if let data = UserDefaults.standard.data(forKey: "schedules"), + let decoded = try? JSONDecoder().decode([Schedule].self, from: data) { + return decoded + } else { + // Initialize with one empty schedule if no saved data + return [Schedule(timeSlots: [], enabledDays: [])] + } + } + + // Save schedules to UserDefaults + static func saveSchedules(schedules: [Schedule]) { + if let encoded = try? JSONEncoder().encode(schedules) { + saveSchedulesData(schedules: encoded) + } + } + + static func saveSchedulesData(schedules: Data) { + UserDefaults.standard.set(schedules, forKey: "schedules") + } + + static func saveBlockedUrls(blockedUrls: [String]) { + UserDefaults.standard.set(blockedUrls, forKey: "blockedUrls") + } + + static func loadlockedUrls() -> [String]? { + UserDefaults.standard.value(forKey: "blockedUrls") as? [String] + } + + static var playSoundOnCompletion: Bool { + return UserDefaults.standard.bool(forKey: Keys.playSoundOnCompletionkey.rawValue) + } + + static func setPlaySoundOnCompletion(_ value: Bool) { + UserDefaults.standard.set(value, forKey: Keys.playSoundOnCompletionkey.rawValue) + } + + static func savePreference(key: String, value: Bool) { + UserDefaults.standard.set(value, forKey: key) + } +} + +// MARK: - Schedule +struct Schedule: Identifiable, Codable, Equatable { + let id: UUID + var timeSlots: [TimeSlot] + var enabledDays: Set + var isExpanded: Bool + var isEnabled: Bool + + init(id: UUID = UUID(), timeSlots: [TimeSlot] = [], enabledDays: Set = [], isExpanded: Bool = false, isEnabled: Bool = false) { + self.id = id + self.timeSlots = timeSlots + self.enabledDays = enabledDays + self.isExpanded = isExpanded + self.isEnabled = isEnabled + } + + var summaryString: String { + timeSlots.map { $0.displayString }.joined(separator: " ") + } + + var enabledDaysString: String { + SCWeekday.allCases.compactMap { enabledDays.contains($0) ? $0.letter : nil }.joined() + } +} + +// MARK: - Schedule Models +struct TimeSlot: Identifiable, Codable, Equatable { + let id: UUID + var startHour: Int + var startMinute: Int + var endHour: Int + var endMinute: Int + + init(id: UUID = UUID(), startHour: Int, startMinute: Int, endHour: Int, endMinute: Int) { + self.id = id + self.startHour = startHour + self.startMinute = startMinute + self.endHour = endHour + self.endMinute = endMinute + } + + // Helper to format hour in 12-hour format + private func formatHour12(_ hour24: Int) -> (hour: Int, isPM: Bool) { + if hour24 == 0 { + return (12, false) + } else if hour24 < 12 { + return (hour24, false) + } else if hour24 == 12 { + return (12, true) + } else { + return (hour24 - 12, true) + } + } + + var startTimeString: String { + let (hour12, isPM) = formatHour12(startHour) + let amPm = isPM ? "PM" : "AM" + return String(format: "%d:%02d %@", hour12, startMinute, amPm) + } + + var endTimeString: String { + let (hour12, isPM) = formatHour12(endHour) + let amPm = isPM ? "PM" : "AM" + return String(format: "%d:%02d %@", hour12, endMinute, amPm) + } + + var displayString: String { + "\(startTimeString)-\(endTimeString)" + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControlBGService/SelfControl.entitlements b/Self-Control-Extension/SelfControl/SelfControlBGService/SelfControl.entitlements new file mode 100644 index 00000000..9cc108e5 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControlBGService/SelfControl.entitlements @@ -0,0 +1,17 @@ + + + + + com.apple.developer.networking.networkextension + + content-filter-provider-systemextension + + com.apple.developer.system-extension.install + + com.apple.security.application-groups + + group.com.application.SelfControl.corebits + $(TeamIdentifierPrefix)com.application.SelfControl.corebits + + + diff --git a/Self-Control-Extension/SelfControl/SelfControlBGService/Services/Chrome+SafariExtensionSupport/ChromeExtensionRequestListner.swift b/Self-Control-Extension/SelfControl/SelfControlBGService/Services/Chrome+SafariExtensionSupport/ChromeExtensionRequestListner.swift new file mode 100644 index 00000000..180df1df --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControlBGService/Services/Chrome+SafariExtensionSupport/ChromeExtensionRequestListner.swift @@ -0,0 +1,355 @@ +// +// PlistListner.swift +// SelfControlExtension +// +// Created by Satendra Singh on 16/08/25. +// + +import Foundation +import Network +import os.log + +enum ServicePath: String { + case chrome = "/chrome" + case safari = "/safari" +} + +final class ChromeExtensionRequestListner: NSObject { + private var isChromeStatusSetInExtension: Bool = false + private let queue = DispatchQueue(label: "com.yourcompany.ChromeExtensionRequestListner", qos: .userInitiated) + private(set) var listener: NWListener? + var blockeddomainFetcher: (() -> [String])? + var onExtensionStateChange: (() -> Void)? + static let servicePort: UInt16 = 8532 + + // MARK: - Public API + + func startListening() { + // Convert Int port to NWEndpoint.Port + guard let port = NWEndpoint.Port(rawValue: ChromeExtensionRequestListner.servicePort) else { + BGFileLogger.error("\(#function) invalid service port: : \(ChromeExtensionRequestListner.servicePort)") + return + } + + do { + let newListener = try NWListener(using: .tcp, on: port) + self.listener = newListener + + // Configure service parameters if needed (e.g., fast open, local interface, etc.) + newListener.newConnectionHandler = { [weak self] connection in + self?.handleNewConnection(connection) + } + + newListener.stateUpdateHandler = { [weak self] state in + self?.handleListenerStateUpdate(state) + } + + newListener.start(queue: queue) + } catch { + BGFileLogger.error("\(#function) failed to create NWListener: \(error.localizedDescription)") + } + } + + func stopListening() { + listener?.cancel() + listener = nil + } + + deinit { + stopListening() + } + + // MARK: - Listener State Handling + + private func handleListenerStateUpdate(_ state: NWListener.State) { + switch state { + case .setup: + os_log("[SC] 🔍] Listener setup") + BGFileLogger.info("Listener state: setup") + case .waiting(let error): + os_log("[SC] 🔍] Listener waiting: %{public}@", error.localizedDescription) + BGFileLogger.error("Listener waiting: \(error.localizedDescription)") + + // Optional: attempt a delayed restart if appropriate + scheduleRestartIfNeeded() + case .ready: + os_log("[SC] 🔍] Listener ready on port %{public}d", ChromeExtensionRequestListner.servicePort) + BGFileLogger.info("Listener state: ready on port \(ChromeExtensionRequestListner.servicePort)") + case .failed(let error): + os_log("[SC] 🔍] Listener failed: %{public}@", error.localizedDescription) + BGFileLogger.error("Listener failed: \(error.localizedDescription)") + + // Cancel and clear; optionally try to restart after a delay + listener?.cancel() + listener = nil + scheduleRestartIfNeeded() + case .cancelled: + os_log("[SC] 🔍] Listener cancelled") + BGFileLogger.info("Listener state: cancelled") + @unknown default: + os_log("[SC] 🔍] Listener unknown state") + BGFileLogger.error("Listener state: unknown") + } + } + + private func scheduleRestartIfNeeded() { + // If you want to throttle restarts, add a backoff strategy here. + // For now, try a simple delayed restart if listener is nil. + guard listener == nil else { return } + queue.asyncAfter(deadline: .now() + 2.0) { [weak self] in + guard let self else { return } + if self.listener == nil { + self.startListening() + } + } + } + + // MARK: - Connection Handling + + private func handleNewConnection(_ connection: NWConnection) { +// BGFileLogger.info("New connection from: \(connection.endpoint.debugDescription)") + os_log("[SC] 🔍] BG New connection from: %{public}@", connection.endpoint.debugDescription) + + connection.stateUpdateHandler = { [weak self] state in + self?.handleConnectionStateUpdate(connection, state: state) + } + + // Start the connection + connection.start(queue: queue) + + // Receive a single HTTP request (simple, non-streaming) + receiveHTTPRequest(on: connection) + } + + private func handleConnectionStateUpdate(_ connection: NWConnection, state: NWConnection.State) { + switch state { + case .setup: + BGFileLogger.info("Conn state: setup") + case .waiting(let error): + BGFileLogger.error("Conn state: waiting - \(error.localizedDescription)") + case .ready: + print("[SC] 🔍] BG Connection ready") +// os_log("[SC] 🔍] BG Connection ready") +// BGFileLogger.info("Conn state: ready") + case .failed(let error): + BGFileLogger.error("Conn state: failed - \(error.localizedDescription)") + connection.cancel() + case .cancelled: + print("[SC] 🔍] BG Connection cancelled") +// os_log("[SC] 🔍] BG Connection cancelled") +// BGFileLogger.info("Conn state: cancelled") + case .preparing: + print("[SC] 🔍] BG Connection preparing") +// os_log("[SC] 🔍] BG Connection preparing") + + @unknown default: + os_log("[SC] 🔍] BG Conn state: unknown") + + BGFileLogger.error("Conn state: unknown") + } + } + + private func receiveHTTPRequest(on connection: NWConnection) { + // Read up to some reasonable maximum for a small HTTP request + connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, isComplete, error in + guard let self else { return } + + if let error = error { + BGFileLogger.error("Receive error: \(error.localizedDescription)") + self.sendHTTPError(.internalServerError, to: connection) + connection.cancel() + return + } + + guard let data = data, !data.isEmpty else { + BGFileLogger.error("Received empty data") + self.sendHTTPError(.badRequest, to: connection) + connection.cancel() + return + } + + guard let requestString = String(data: data, encoding: .utf8) else { + BGFileLogger.error("Invalid UTF-8 in request") + self.sendHTTPError(.badRequest, to: connection) + connection.cancel() + return + } + + // Basic request parsing: we expect HTTP-like requests + // Example: "GET /chrome HTTP/1.1\r\n..." + let path = self.extractPath(from: requestString) + + // If the original helper exists, try to use it first + if let service = requestString.httpPathFromConnection() { + Task { + await AppStateManager.shared.handleApiRequest(path: service) + } + self.handleService(service, on: connection) + return + } + + // Fallback: handle based on parsed path +// if let path { +// Task { +// await AppStateManager.shared.handleApiRequest(path: path) +// } +// self.handlePath(path, on: connection) +// } + else { + BGFileLogger.error("Unable to parse path from request \(path)") + self.sendHTTPError(.notFound, to: connection) + } + + // If the peer indicated end-of-stream, we can clean up + if isComplete { + connection.cancel() + } + } + } + + // MARK: - Routing + + // If you have a specific enum for service paths, keep using it. + // Here we just switch on known strings as a fallback. + private func handleService(_ service: Any, on connection: NWConnection) { + // The original code suggests `service` is an enum with cases .chrome and .safari. + // We'll try to handle those names via String conversion for safety. + let serviceString = String(describing: service).lowercased() + switch serviceString { + case "chrome": + Task { [weak self] in + await self?.sendChromeBlockedUrls(connection: connection) + } + case "safari": + Task { [weak self] in + await self?.sendChromeBlockedUrls(connection: connection) + } + default: + // Unknown path + sendHTTPError(.notFound, to: connection) + } + } + + private func handlePath(_ path: String, on connection: NWConnection) { + switch path.lowercased() { + case "/chrome", "chrome": + Task { [weak self] in + await self?.sendChromeBlockedUrls(connection: connection) + } + case "/safari", "safari": + Task { [weak self] in + await self?.sendChromeBlockedUrls(connection: connection) + } + default: + sendHTTPError(.notFound, to: connection) + } + } + + // MARK: - Response Builders + + private func sendChromeBlockedUrls(connection: NWConnection) async { + let isBlockEnabled = await AppStateManager.shared.isBlockingEnabled + var blockedDomainList: [String] = self.blockeddomainFetcher?() ?? [] + + if isBlockEnabled == false { + blockedDomainList = [] + } + + let payload = ["blocked": blockedDomainList] + + do { + let jsonData = try JSONSerialization.data(withJSONObject: payload, options: []) + sendHTTPResponse(status: .ok, contentType: "application/json", body: jsonData, to: connection) + } catch { + BGFileLogger.error("\(#function) JSON encode error: \(error.localizedDescription)") + sendHTTPError(.internalServerError, to: connection) + } + } + + private func sendHTTPResponse(status: HTTPStatus, contentType: String, body: Data, to connection: NWConnection) { + let headers = [ + "Content-Type": contentType, + "Access-Control-Allow-Origin": "*", + "Content-Length": "\(body.count)" + ] + + let headerString = headers + .map { "\($0): \($1)" } + .joined(separator: "\r\n") + + let responseHead = "HTTP/1.1 \(status.code) \(status.reason)\r\n\(headerString)\r\n\r\n" + var responseData = Data(responseHead.utf8) + responseData.append(body) + + connection.send(content: responseData, contentContext: .finalMessage, isComplete: true, completion: .contentProcessed { error in + if let error = error { + BGFileLogger.error("Send response error: \(error.localizedDescription)") + } + // Close the connection after sending the response + connection.cancel() + }) + } + + private func sendHTTPError(_ status: HTTPStatus, to connection: NWConnection) { + let bodyDict = ["error": status.reason] + let bodyData = (try? JSONSerialization.data(withJSONObject: bodyDict, options: [])) ?? Data() + sendHTTPResponse(status: status, contentType: "application/json", body: bodyData, to: connection) + } + + // MARK: - Helpers + + private func extractPath(from request: String) -> String? { + // Very simple parser: first line "METHOD /path HTTP/1.1" + guard let firstLine = request.components(separatedBy: "\r\n").first else { + return nil + } + let components = firstLine.split(separator: " ") + guard components.count >= 2 else { + return nil + } + return String(components[1]) + } +} + +// MARK: - HTTPStatus + +private enum HTTPStatus { + case ok + case badRequest + case notFound + case internalServerError + + var code: Int { + switch self { + case .ok: return 200 + case .badRequest: return 400 + case .notFound: return 404 + case .internalServerError: return 500 + } + } + + var reason: String { + switch self { + case .ok: return "OK" + case .badRequest: return "Bad Request" + case .notFound: return "Not Found" + case .internalServerError: return "Internal Server Error" + } + } +} + +extension String { + func httpPathFromConnection() -> ServicePath? { + if let firstLine = components(separatedBy: "\r\n").first { + print("Request Line:", firstLine) + + let parts = firstLine.split(separator: " ") + if parts.count >= 2 { + let path = parts[1] + print("HTTP Path:", path) + return ServicePath(rawValue: String(path)) + } + } + return nil + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControlBGService/Services/EventScheduler/EventScheduler.swift b/Self-Control-Extension/SelfControl/SelfControlBGService/Services/EventScheduler/EventScheduler.swift new file mode 100644 index 00000000..c16efccd --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControlBGService/Services/EventScheduler/EventScheduler.swift @@ -0,0 +1,342 @@ +// +// EventScheduler.swift +// SelfControl +// +// Created by Satendra Singh on 23/02/26. +// + +import Foundation + + +// A simple, concurrency-safe scheduler for recurring events by SCSCWeekday and time. +// It validates time ranges and prevents overlapping events on the same day. + +public enum SchedulerError: Error, LocalizedError, Equatable { + case invalidTimeRange + case conflict(conflictingEvents: [Event]) + case eventNotFound + + public var errorDescription: String? { + switch self { + case .invalidTimeRange: + return "The start time must be earlier than the end time." + case .conflict(let conflicts): + let names = conflicts.map { $0.title }.joined(separator: ", ") + return conflicts.isEmpty + ? "The event conflicts with an existing event." + : "The event conflicts with the following events: \(names)" + case .eventNotFound: + return "The event could not be found." + } + } +} + +/// SCWeekday aligned with the user's Calendar (1 = Sunday in many locales). +/// We normalize to a fixed enumeration order (Monday...Sunday) for consistency. +public enum SCWeekday: Int, CaseIterable, Codable, Hashable, Sendable { + case monday = 2 + case tuesday = 3 + case wednesday = 4 + case thursday = 5 + case friday = 6 + case saturday = 7 + case sunday = 1 + +// public var displayName: String { +// switch self { +// case .monday: return "Monday" +// case .tuesday: return "Tuesday" +// case .wednesday: return "Wednesday" +// case .thursday: return "Thursday" +// case .friday: return "Friday" +// case .saturday: return "Saturday" +// case .sunday: return "Sunday" +// } +// } + + var letter: String { + switch self { + case .monday: return "M" + case .tuesday: return "T" + case .wednesday: return "W" + case .thursday: return "T" + case .friday: return "F" + case .saturday: return "S" + case .sunday: return "S" + } + } + + var fullName: String { + switch self { + case .monday: return "Monday" + case .tuesday: return "Tuesday" + case .wednesday: return "Wednesday" + case .thursday: return "Thursday" + case .friday: return "Friday" + case .saturday: return "Saturday" + case .sunday: return "Sunday" + } + } + + /// Returns the SCWeekday for a given Date using the provided Calendar. + public static func from(date: Date, calendar: Calendar = .current) -> SCWeekday { + let SCWeekdayNumber = calendar.component(.weekday, from: date) + return SCWeekday(rawValue: SCWeekdayNumber) ?? .monday + } +} + +/// Represents a wall-clock time without a specific date or time zone. +/// Valid range: hour 0...23, minute 0...59 +public struct TimeOfDay: Codable, Hashable, Sendable, Comparable { + public let hour: Int + public let minute: Int + + public init(hour: Int, minute: Int) { + precondition((0...23).contains(hour), "Hour must be 0...23") + precondition((0...59).contains(minute), "Minute must be 0...59") + self.hour = hour + self.minute = minute + } + + public var minutesSinceMidnight: Int { + hour * 60 + minute + } + + public static func < (lhs: TimeOfDay, rhs: TimeOfDay) -> Bool { + lhs.minutesSinceMidnight < rhs.minutesSinceMidnight + } + + + /// Total minutes since midnight (00:00). + var totalMinutesSinceMidnight: Int { + hour * 60 + minute + } + + /// Returns the number of minutes from `other` to `self`. + /// - Parameters: + /// - other: The starting time of day. + /// - wrapAroundMidnight: If true, negative differences will be wrapped + /// by adding 24 hours (1440 minutes). This is useful when you want the + /// forward difference within the same 24-hour cycle. + /// - Returns: The minute difference as an `Int`. + /// + /// Examples: + /// - 10:30.minutes(from: 09:15) == 75 + /// - 01:00.minutes(from: 23:30, + /// wrapAroundMidnight: true) == 90 + /// - 01:00.minutes(from: 23:30, wrapAroundMidnight: false) == -1410 + func minutes(from other: TimeOfDay, wrapAroundMidnight: Bool = false) -> Int { + let diff = self.totalMinutesSinceMidnight - other.totalMinutesSinceMidnight + guard wrapAroundMidnight, diff < 0 else { return diff } + return diff + 24 * 60 + } + + /// Convenience: minutes until another time (alias with reversed arguments). + /// Positive when `other` is later than `self` on the same day. + func minutes(until other: TimeOfDay, wrapAroundMidnight: Bool = false) -> Int { + other.minutes(from: self, wrapAroundMidnight: wrapAroundMidnight) + } + + public func formatted(locale: Locale = .current) -> String { + var comps = DateComponents() + comps.hour = hour + comps.minute = minute + let cal = Calendar(identifier: .gregorian) + let date = cal.date(from: comps) ?? Date() + let formatter = DateFormatter() + formatter.locale = locale + formatter.timeStyle = .short + formatter.dateStyle = .none + return formatter.string(from: date) + } +} + +/// A recurring event that happens on one or more SCWeekdays at a given time range. +public struct Event: Identifiable, Codable, Hashable, Sendable { + public let id: UUID + public var title: String = "" + public var days: Set + public var startTime: TimeOfDay + public var endTime: TimeOfDay + public var userInfo: [String: String]? // Optional metadata + + public init( + id: UUID = UUID(), + title: String = "", + days: Set, + startTime: TimeOfDay, + endTime: TimeOfDay, + userInfo: [String: String]? = nil + ) throws { + guard startTime < endTime else { + throw SchedulerError.invalidTimeRange + } + self.id = id + self.title = title + self.days = days + self.startTime = startTime + self.endTime = endTime + self.userInfo = userInfo + } + + /// Returns true if this event occurs on the given SCWeekday. + public func occurs(on day: SCWeekday) -> Bool { + days.contains(day) + } + + /// Overlap check with another event on a specific SCWeekday. + /// Adjacent ranges (end == start) are not considered overlapping. + public func overlaps(with other: Event, on day: SCWeekday) -> Bool { + guard self.occurs(on: day), other.occurs(on: day) else { return false } + let aStart = self.startTime.minutesSinceMidnight + let aEnd = self.endTime.minutesSinceMidnight + let bStart = other.startTime.minutesSinceMidnight + let bEnd = other.endTime.minutesSinceMidnight + return aStart < bEnd && aEnd > bStart + } +} + +/// A concurrency-safe scheduler to add, update, and query recurring events by SCWeekday and time. +public actor EventScheduler { + public init(calendar: Calendar = .current) { + self.calendar = calendar + } + + public let calendar: Calendar + private var eventsByID: [UUID: Event] = [:] + + // MARK: - CRUD + + /// Schedules a new event. Throws if the time range is invalid or conflicts with existing events. + @discardableResult + public func schedule( + title: String = "", + days: Set, + startTime: TimeOfDay, + endTime: TimeOfDay, + userInfo: [String: String]? = nil + ) throws -> Event { + let newEvent = try Event(title: title, days: days, startTime: startTime, endTime: endTime, userInfo: userInfo) + try assertNoConflicts(with: newEvent, excludingID: nil) + eventsByID[newEvent.id] = newEvent + return newEvent + } + + @discardableResult + public func schedule( + newEvent: Event + ) throws -> Event { + try assertNoConflicts(with: newEvent, excludingID: nil) + eventsByID[newEvent.id] = newEvent + return newEvent + } + + /// Updates an existing event. Throws if not found or if the updated event conflicts. + public func update( + id: UUID, + title: String? = nil, + days: Set? = nil, + startTime: TimeOfDay? = nil, + endTime: TimeOfDay? = nil, + userInfo: [String: String]? = nil + ) throws -> Event { + guard var existing = eventsByID[id] else { + throw SchedulerError.eventNotFound + } + + let newTitle = title ?? existing.title + let newDays = days ?? existing.days + let newStart = startTime ?? existing.startTime + let newEnd = endTime ?? existing.endTime + let newUserInfo = userInfo ?? existing.userInfo + + let updated = try Event(id: id, title: newTitle, days: newDays, startTime: newStart, endTime: newEnd, userInfo: newUserInfo) + try assertNoConflicts(with: updated, excludingID: id) + eventsByID[id] = updated + return updated + } + + /// Removes an event by ID. Returns the removed event or throws if not found. + @discardableResult + public func remove(id: UUID) throws -> Event { + guard let removed = eventsByID.removeValue(forKey: id) else { + throw SchedulerError.eventNotFound + } + return removed + } + + /// Returns all scheduled events sorted by title. + public func allEvents() -> [Event] { + eventsByID.values.sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending } + } + + /// Returns events that occur on the specified SCWeekday, sorted by start time. + public func events(on day: SCWeekday) -> [Event] { + eventsByID.values + .filter { $0.occurs(on: day) } + .sorted { lhs, rhs in + if lhs.startTime == rhs.startTime { + return lhs.title.localizedCaseInsensitiveCompare(rhs.title) == .orderedAscending + } + return lhs.startTime < rhs.startTime + } + } + + // MARK: - Conflict Checking + + /// Returns any events that would conflict with the provided event. + public func conflicts(for event: Event, excludingID: UUID? = nil) -> [Event] { + eventsByID.values + .filter { existing in + if let exclude = excludingID, existing.id == exclude { return false } + // Only check days that overlap + let sharedDays = existing.days.intersection(event.days) + guard !sharedDays.isEmpty else { return false } + return sharedDays.contains { event.overlaps(with: existing, on: $0) } + } + .sorted { $0.startTime < $1.startTime } + } + + private func assertNoConflicts(with event: Event, excludingID: UUID?) throws { + let conflicts = conflicts(for: event, excludingID: excludingID) + if !conflicts.isEmpty { + throw SchedulerError.conflict(conflictingEvents: conflicts) + } + } + + // MARK: - Query Helpers + + /// Returns the next DateInterval this event will occur after the given date, if any. + /// This uses the scheduler's calendar to resolve the next occurrence in the current week or later. + public func nextOccurrence(of eventID: UUID, after date: Date = Date()) -> DateInterval? { + guard let event = eventsByID[eventID] else { return nil } + return nextOccurrence(of: event, after: date) + } + + /// Returns the next DateInterval an event will occur after the given date, if any. + public func nextOccurrence(of event: Event, after date: Date = Date()) -> DateInterval? { + // Search up to 8 weeks ahead to be safe for unusual calendars + for dayOffset in 0..<(7 * 8) { + guard let candidateDay = calendar.date(byAdding: .day, value: dayOffset, to: date) else { continue } + let SCWeekday = SCWeekday.from(date: candidateDay, calendar: calendar) + guard event.occurs(on: SCWeekday) else { continue } + + var comps = calendar.dateComponents([.year, .month, .day], from: candidateDay) + comps.hour = event.startTime.hour + comps.minute = event.startTime.minute + comps.second = 0 + let start = calendar.date(from: comps) + + comps.hour = event.endTime.hour + comps.minute = event.endTime.minute + let end = calendar.date(from: comps) + + if let start, let end, end > date { + // If the start is in the past but end is in the future, return the remaining portion. + let actualStart = max(start, date) + return DateInterval(start: actualStart, end: end) + } + } + return nil + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControlBGService/Services/EventScheduler/EventSchedulerRunner.swift b/Self-Control-Extension/SelfControl/SelfControlBGService/Services/EventScheduler/EventSchedulerRunner.swift new file mode 100644 index 00000000..d9c5c4c4 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControlBGService/Services/EventScheduler/EventSchedulerRunner.swift @@ -0,0 +1,220 @@ +// +// EventSchedulerRunner.swift +// SelfControl +// +// Created by Satendra Singh on 23/02/26. +// + +import Foundation +import os.log + +// A firing engine that uses EventScheduler as storage and fires events at their scheduled times. +// This actor observes the scheduler and invokes registered handlers when events start. +// +public actor EventSchedulerRunner { + public typealias EventHandler = @Sendable (Event) async -> Void + + public init(scheduler: EventScheduler, fireTolerance: TimeInterval = 1.0) { + self.scheduler = scheduler + self.fireTolerance = fireTolerance + } + + private let scheduler: EventScheduler + private let fireTolerance: TimeInterval + +// private var timer: Timer? + private var timer: DispatchSourceTimer? + private let timerQueue = DispatchQueue(label: "com.yourcompany.EventSchedulerRunner.timer") + + private var perEventHandlers: [UUID: EventHandler] = [:] + private var globalHandlers: [UUID: EventHandler] = [:] // keyed by token + private var scheduledEvent: Event? + + deinit { + if let t = timer { + t.setEventHandler {} // break potential retain cycles + t.cancel() + timer = nil + } + scheduledEvent = nil + } + + // MARK: - Control + + public func start() async { +// os_log("[SC] 🔍] BG Event Scheduler is starting") + BGFileLogger.info("\(#function) Event Scheduler is starting") + guard timer == nil else { return } + await scheduleNextTimer() + } + + public func stop() { + // Your existing stop logic (e.g., cancel reschedule task, clear state) stays the same + + if let t = timer { + t.setEventHandler {} // break potential retain cycles + t.cancel() + timer = nil + } + scheduledEvent = nil + } + + /// Restart timer to quickly reflect changes (handlers or events). + private func rescheduleLoop() async { + await scheduleNextTimer() + } + + // MARK: - Handlers + + public func setHandler(for eventID: UUID, handler: EventHandler?) async { + if let handler { + perEventHandlers[eventID] = handler + } else { + perEventHandlers.removeValue(forKey: eventID) + } + await rescheduleLoop() + } + + @discardableResult + public func addGlobalHandler(_ handler: @escaping EventHandler) async -> UUID { + let token = UUID() + globalHandlers[token] = handler + + // await rescheduleLoop() + return token + } + + public func removeGlobalHandler(token: UUID) async { + globalHandlers.removeValue(forKey: token) + await rescheduleLoop() + } + + // MARK: - Timer scheduling + private func scheduleNextTimer() async { + // Cancel any existing timer first + if let t = timer { + t.setEventHandler { } + t.cancel() + timer = nil + } + + // Your existing logic to compute the next fire date and select the event: + // (Keep this unchanged if you already have it) + let now = Date() + let calendar = Calendar.autoupdatingCurrent + let events = await scheduler.allEvents() + + // If you already obtain events differently, keep your existing code here. + // Example (keep your own): + let (maybeNextDate, maybeEvent) = earliestNextStart(after: now, events: events, calendar: calendar) +// os_log("[SC] 🔍] BG scheduleNextTimer: %{public}@ ",maybeNextDate?.debugDescription ?? "nil") + BGFileLogger.info("\(#function) scheduleNextTimer:\(maybeNextDate?.debugDescription ?? "nil")") + guard let fireDate = maybeNextDate, let event = maybeEvent else { + scheduledEvent = nil + return + } + scheduledEvent = event + + // New: Create a one-shot DispatchSourceTimer + let t = DispatchSource.makeTimerSource(queue: timerQueue) + + // Convert your tolerance (seconds) into Dispatch leeway + let leeway = DispatchTimeInterval.nanoseconds(Int(fireTolerance * 1_000_000_000)) + + // Compute the deadline relative to now + let delta = fireDate.timeIntervalSinceNow + let deadline: DispatchTime = delta > 0 ? .now() + delta : .now() + + // One-shot timer: schedule once with leeway + t.schedule(deadline: deadline, leeway: leeway) + + // Hop back into the actor when it fires + t.setEventHandler { [weak self] in + guard let self else { return } + Task { await self.timerDidFire() } + } + timer = t + t.resume() + } + + private func timerDidFire() async { +// os_log("[SC] 🔍] BG timerDidFire: %{public}%@ ",scheduledEvent.debugDescription) + BGFileLogger.info("\(#function) timerDidFire:\(scheduledEvent.debugDescription)") + // Clear scheduled event if you do that today + + + // Cancel and clear the GCD timer (one-shot) + if let t = timer { + t.setEventHandler {} + t.cancel() + timer = nil + } + + let fireDate = Date() + let calendar = scheduler.calendar + let events = await scheduler.allEvents() + await fireEventsStarting(around: fireDate, events: events, calendar: calendar, tolerance: fireTolerance) + scheduledEvent = nil + // Schedule the next timer based on the latest data + await scheduleNextTimer() + } + + // MARK: - Event computations + private func earliestNextStart(after date: Date, events: [Event], calendar: Calendar) -> (Date?, Event?) { + var best: Date? + var bestEvent: Event? + for event in events { + if let start = nextStart(of: event, after: date, calendar: calendar) { + if let b = best { + if start < b { + best = start + bestEvent = event + } + } else { + best = start + bestEvent = event + } + } + } + return (best, bestEvent) + } + + private func nextStart(of event: Event, after date: Date, calendar: Calendar) -> Date? { + // Search up to 8 weeks ahead + for dayOffset in 0..<(7 * 8) { + guard let candidateDay = calendar.date(byAdding: .day, value: dayOffset, to: date) else { continue } + let weekday = SCWeekday.from(date: candidateDay, calendar: calendar) + guard event.occurs(on: weekday) else { continue } + + var comps = calendar.dateComponents([.year, .month, .day], from: candidateDay) + comps.hour = event.startTime.hour + comps.minute = event.startTime.minute + comps.second = 0 + if let start = calendar.date(from: comps), start > date { + return start + } + } + return nil + } + + private func fireEventsStarting(around date: Date, events: [Event], calendar: Calendar, tolerance: TimeInterval) async { + guard let firedEvent = scheduledEvent else { return } + + // Capture handlers to call outside the actor's critical path + let global = globalHandlers.values +// for event in starting { + var handlers: [EventHandler] = [] + if let h = perEventHandlers[firedEvent.id] { + handlers.append(h) + } + handlers.append(contentsOf: global) +// if handlers.isEmpty { continue } + + for handler in handlers { + Task.detached(priority: .userInitiated) { + await handler(firedEvent) + } + } +// } + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControlBGService/Services/NetworkExtensionService/IPCConnection.swift b/Self-Control-Extension/SelfControl/SelfControlBGService/Services/NetworkExtensionService/IPCConnection.swift new file mode 100644 index 00000000..d6da422d --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControlBGService/Services/NetworkExtensionService/IPCConnection.swift @@ -0,0 +1,195 @@ +/* + See the LICENSE.txt file for this sample’s licensing information. + + Abstract: + This file contains the implementation of the app <-> provider IPC connection + */ + +import Foundation +import os.log +import Network + +/// The IPCConnection class is used by both the app and the system extension to communicate with each other +class IPCConnection: NSObject, NSSecureCoding { + static var supportsSecureCoding: Bool = true + + override init() { + } + + required init?(coder: NSCoder) { + return nil + } + + func encode(with coder: NSCoder) { + + } + // MARK: Properties + + var listener: NSXPCListener? + var currentConnection: NSXPCConnection? + weak var delegate: ExtensionToApp? + static let shared = IPCConnection() + // Published extension state for UI/observers + + // MARK: Methods + + /// This method is called by the app to register with the provider running in the system extension. + func register(completionHandler: @escaping (Bool) -> Void) { + +// self.delegate = delegate +// os_log("[SC] 🔍] register(withExtension") + BGFileLogger.error("\(#function) ") + + guard currentConnection == nil else { +// os_log("[SC] 🔍] Already registered with the provider") + BGFileLogger.error("\(#function) Already registered with the provider") + completionHandler(true) + return + } + // Fix: Use Task to ensure actor-isolated method is called on its actor + Task { @MainActor in + await AppStateManager.shared.reset() + } + + let newConnection = NSXPCConnection(machServiceName: "X6FQ433AWK.com.application.SelfControl.corebits.network", options: []) + + // The exported object is the delegate. + newConnection.exportedInterface = NSXPCInterface(with: ExtensionToApp.self) + newConnection.exportedObject = self + newConnection.invalidationHandler = { [weak self] in + BGFileLogger.error("\(#function) invalidationHandler") + self?.currentConnection = nil + } + newConnection.interruptionHandler = { [weak self] in + BGFileLogger.error("\(#function) interruptionHandler") + self?.currentConnection = nil + } + // The remote object is the provider's IPCConnection instance. + newConnection.remoteObjectInterface = NSXPCInterface(with: AppToExtensionExtension.self) + + currentConnection = newConnection + newConnection.resume() + + guard let providerProxy = newConnection.remoteObjectProxyWithErrorHandler({ registerError in +// os_log("[SC] 🔍] Failed to register with the provider: %{public}@", registerError.localizedDescription) + BGFileLogger.error("\(#function) Failed to register with the provider: \(registerError.localizedDescription) ") + self.currentConnection?.invalidate() + self.currentConnection = nil + completionHandler(false) + }) as? AppToExtensionExtension else { + BGFileLogger.error("\(#function) Failed to create a remote object proxy ") +// os_log("[SC] 🔍] Failed to create a remote object proxy for the provider") + return + } + providerProxy.register(completionHandler) + } + + /** + This method is called by the provider to cause the app (if it is registered) to display a prompt to the user asking + for a decision about a connection. + */ + func promptUser(aboutFlow flowInfo: [String: String], responseHandler:@escaping (Bool) -> Void) -> Bool { + + guard let connection = currentConnection else { + BGFileLogger.error("\(#function) Cannot prompt user because the app isn't registered") +// os_log("[SC] 🔍] Cannot prompt user because the app isn't registered") + return false + } + + guard let appProxy = connection.remoteObjectProxyWithErrorHandler({ promptError in +// os_log("[SC] 🔍] Failed to prompt the user: %{public}@", promptError.localizedDescription) + BGFileLogger.error("\(#function) Failed to prompt the user: \(promptError.localizedDescription) ") + self.currentConnection = nil + responseHandler(true) + }) as? ExtensionToApp else { + BGFileLogger.error("\(#function) Failed to create a remote object proxy ") +// os_log("Failed to create a remote object proxy for the app") + return false + } + + appProxy.promptUser(aboutFlow: flowInfo, responseHandler: responseHandler) + + return true + } +} + +extension IPCConnection: NSXPCListenerDelegate { + + // MARK: NSXPCListenerDelegate + + func listener(_ listener: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool { + + // The exported object is this IPCConnection instance. + newConnection.exportedInterface = NSXPCInterface(with: AppToExtensionExtension.self) + newConnection.exportedObject = self + + // The remote object is the delegate of the app's IPCConnection instance. + newConnection.remoteObjectInterface = NSXPCInterface(with: ExtensionToApp.self) + + newConnection.invalidationHandler = { + self.currentConnection = nil + } + + newConnection.interruptionHandler = { + self.currentConnection = nil + } + + currentConnection = newConnection + newConnection.resume() + + return true + } + + func sendMessageToSetBlockingURLs(_ urls: [String]) { // os_log("[SC] 🔍] Enabling URL blocking") + BGFileLogger.info("\(#function) Enabling URL blocking") + guard let providerProxy = currentConnection?.remoteObjectProxyWithErrorHandler({ registerError in + os_log("[SC] 🔍] Failed to register with the provider: %{public}@", registerError.localizedDescription) + BGFileLogger.error("\(#function) Failed to register with the provider: \(registerError.localizedDescription)") + }) as? AppToExtensionExtension else { +// os_log("[SC] 🔍] Failed to create a remote object proxy for the provider") + BGFileLogger.error("\(#function) Failed to create a remote object proxy ") + return + } + providerProxy.setBlockedURLs(urls) + } + + func sendMessageToSetActiveBrowserExtension(_ extensionTypeRawValue: String, state: Bool) { +// os_log("[SC] 🔍] sendMessageToSetActiveBrowserExtension:\(extensionTypeRawValue), state:\(state)") + BGFileLogger.error("\(#function) Failed to create a remote object proxy \(extensionTypeRawValue), state:\(state)") + guard let providerProxy = currentConnection?.remoteObjectProxyWithErrorHandler({ registerError in + BGFileLogger.error("\(#function) \(registerError.localizedDescription)") +// os_log("[SC] 🔍] sendMessageToSetActiveBrowserExtension: %{public}@", registerError.localizedDescription) + }) as? AppToExtensionExtension else { + BGFileLogger.error("\(#function) Failed to create a remote object proxy ") +// os_log("[SC] 🔍] Failed to create a remote object proxy for the provider") + return + } + providerProxy.setActiveBrowserExtension(extensionTypeRawValue, state: state) + } + + func sendMessageToEnableNetworkExtension(_ enable: Bool) -> Bool { +// os_log("[SC] 🔍] sendMessageToEnableNetworkExtension state %{public}d", enable) + BGFileLogger.error("\(#function) \(enable)") + guard let providerProxy = currentConnection?.remoteObjectProxyWithErrorHandler({ registerError in +// os_log("[SC] 🔍] sendMessageToEnableNetworkExtension: %{public}@", registerError.localizedDescription) + BGFileLogger.error("\(#function) \(enable)") + }) as? AppToExtensionExtension else { + BGFileLogger.error("\(#function) Failed to create a remote object proxy") +// os_log("[SC] 🔍] Failed to create a remote object proxy for the provider") + return false + } + providerProxy.setEnableService(enable) + return true + } +} + +extension IPCConnection: ExtensionToApp { + + func promptUser(aboutFlow flowInfo: [String: String], responseHandler: @escaping (Bool) -> Void) { + os_log("[SC] 🔍] promptUser") + + } + func didSetUrls() { + os_log("[SC] 🔍] didSetUrls") + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControlBGService/Shared/AppToExtensionExtension.swift b/Self-Control-Extension/SelfControl/SelfControlBGService/Shared/AppToExtensionExtension.swift new file mode 100644 index 00000000..db484935 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControlBGService/Shared/AppToExtensionExtension.swift @@ -0,0 +1,33 @@ +// +// SafariExtensionMessages.swift +// SelfControl +// +// Created by Satendra Singh on 17/05/26. +// +import Foundation + +enum ActiveBrowserExtensios: String { + case safari + case chrome +} + + +enum FlowInfoKey: String { + case localPort + case remoteAddress +} + +/// App --> Provider IPC, to be implememted in Extension +/// // Use Objective-C bridgeable type for XPC surface +@objc public protocol AppToExtensionExtension { + func register(_ completionHandler: @escaping (Bool) -> Void) + func setBlockedURLs(_ urls: [String]) + func setActiveBrowserExtension(_ extensionTypeRawValue: String, state: Bool) //TODO: Remove once code is exported + func setEnableService(_ enable: Bool) +} + +/// Provider --> App IPC to Be implememted in the app +@objc public protocol ExtensionToApp { + func promptUser(aboutFlow flowInfo: [String: String], responseHandler: @escaping (Bool) -> Void) + func didSetUrls() +} diff --git a/Self-Control-Extension/SelfControl/SelfControlBGService/Shared/FileLogger.swift b/Self-Control-Extension/SelfControl/SelfControlBGService/Shared/FileLogger.swift new file mode 100644 index 00000000..ca0247e7 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControlBGService/Shared/FileLogger.swift @@ -0,0 +1,194 @@ +// +// FileLogger.swift +// SelfControl +// +// Created by Satendra Singh on 12/06/26. +// + +import Foundation +import OSLog + +//struct FileLogger { +// static let logger = Logger(subsystem: "com.application.SelfControl.corebits.bgservice", category: "service"); +//} + +public enum LogLevel: String { + case debug = "DEBUG" + case info = "INFO" + case notice = "NOTICE" + case warning = "WARNING" + case error = "ERROR" + case fault = "FAULT" +} + +private actor FileLogger { + public static let shared = FileLogger() + + private let logDirectoryURL: URL + private let logFileURL: URL + private let fileManager = FileManager.default + private var fileHandle: FileHandle? + private let dateFormatter: DateFormatter + private let maxFileSizeBytes: Int64 = 5 * 1024 * 1024 // 5 MB + private let maxRotatedFiles = 5 + + public init(directoryName: String = "Logs", fileName: String = "app.log") { + // Default to Application Support//Logs/app.log + let baseDir: URL + if let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first { + baseDir = appSupport + } else { + baseDir = URL(fileURLWithPath: NSTemporaryDirectory()) + } + + let bundleID = appName + (Bundle.main.bundleIdentifier ?? Bundle.main.bundleURL.lastPathComponent) + let appDir = baseDir.appendingPathComponent(bundleID, isDirectory: true) + + self.logDirectoryURL = appDir.appendingPathComponent(directoryName, isDirectory: true) + self.logFileURL = logDirectoryURL.appendingPathComponent(fileName) + os_log("[SC] 🔍] BG LOG Dir: %{public}@", log: OSLog.default, type: .debug, logDirectoryURL.path) + + self.dateFormatter = DateFormatter() + self.dateFormatter.locale = Locale(identifier: "en_US_POSIX") + self.dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSZ" + } + + // Call once at app start or before first log + public func start() async { + do { + try fileManager.createDirectory(at: logDirectoryURL, withIntermediateDirectories: true, attributes: nil) + if !fileManager.fileExists(atPath: logFileURL.path) { + fileManager.createFile(atPath: logFileURL.path, contents: nil, attributes: nil) + } + fileHandle = try FileHandle(forWritingTo: logFileURL) + try fileHandle?.seekToEnd() + } catch { + // As a fallback, ignore failures silently to avoid crashing logging + fileHandle = nil + } + } + + public func stop() async { + try? fileHandle?.close() + fileHandle = nil + } + + public func debug(_ message: @autoclosure () -> String) async { + await write(level: .debug, message()) + } + + public func info(_ message: @autoclosure () -> String) async { + await write(level: .info, message()) + } + + public func notice(_ message: @autoclosure () -> String) async { + await write(level: .notice, message()) + } + + public func warning(_ message: @autoclosure () -> String) async { + await write(level: .warning, message()) + } + + public func error(_ message: @autoclosure () -> String) async { + await write(level: .error, message()) + } + + public func fault(_ message: @autoclosure () -> String) async { + await write(level: .fault, message()) + } + + public func log(_ level: LogLevel, _ message: @autoclosure () -> String) async { + await write(level: level, message()) + } + + private func write(level: LogLevel, _ message: @autoclosure () -> String) async { + let timestamp = dateFormatter.string(from: Date()) + let line = "[\(timestamp)] [\(level.rawValue)] \(message())\n" + + // Ensure file is ready + if fileHandle == nil { + await start() + } + + // Attempt rotation when file too large + await rotateIfNeeded() + + guard let data = line.data(using: .utf8) else { return } + + do { + try fileHandle?.seekToEnd() + try fileHandle?.write(contentsOf: data) + } catch { + // If writing fails, try reopening once + do { + try fileHandle?.close() + fileHandle = try FileHandle(forWritingTo: logFileURL) + try fileHandle?.seekToEnd() + try fileHandle?.write(contentsOf: data) + } catch { + os_log("[SC] 🔍] File handler error: %{public}@", error.localizedDescription) + // Give up silently to avoid crashing the app + } + } + } + + private func rotateIfNeeded() async { + guard let attrs = try? fileManager.attributesOfItem(atPath: logFileURL.path), + let size = attrs[.size] as? NSNumber else { return } + + if size.int64Value < maxFileSizeBytes { return } + + // Close current file first + try? fileHandle?.close() + fileHandle = nil + + // Rotate existing files: app.log.(n) -> app.log.(n+1) + for index in stride(from: maxRotatedFiles - 1, through: 1, by: -1) { + let src = logFileURL.appendingPathExtension("\(index)") + let dst = logFileURL.appendingPathExtension("\(index + 1)") + if fileManager.fileExists(atPath: src.path) { + try? fileManager.removeItem(at: dst) + try? fileManager.moveItem(at: src, to: dst) + } + } + + // Move current app.log -> app.log.1 + let firstRotation = logFileURL.appendingPathExtension("1") + try? fileManager.removeItem(at: firstRotation) + try? fileManager.moveItem(at: logFileURL, to: firstRotation) + + // Create a new empty log file + fileManager.createFile(atPath: logFileURL.path, contents: nil, attributes: nil) + do { + fileHandle = try FileHandle(forWritingTo: logFileURL) + try fileHandle?.seekToEnd() + } catch { + fileHandle = nil + } + } + + // Convenience to get the current log file URL (for sharing or debug) + public func currentLogFileURL() -> URL { + logFileURL + } + + public func allLogFiles() -> [URL] { + var urls: [URL] = [logFileURL] + for i in 1...maxRotatedFiles { + let rotated = logFileURL.appendingPathExtension("\(i)") + if fileManager.fileExists(atPath: rotated.path) { + urls.append(rotated) + } + } + return urls + } +} + +struct BGFileLogger { + static func debug(_ message: String) { Task { await FileLogger.shared.debug(message) }} + static func info(_ message: String) { Task { await FileLogger.shared.info(message) } } + static func notice(_ message: String) { Task { await FileLogger.shared.notice(message) } } + static func warning(_ message: String) { Task { await FileLogger.shared.warning(message) } } + static func error(_ message: String) { Task { await FileLogger.shared.error(message)} } + static func fault(_ message: String) { Task { await FileLogger.shared.fault(message) } } +} diff --git a/Self-Control-Extension/SelfControl/SelfControlBGService/Shared/FileLoggerConsts.swift b/Self-Control-Extension/SelfControl/SelfControlBGService/Shared/FileLoggerConsts.swift new file mode 100644 index 00000000..50587a48 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControlBGService/Shared/FileLoggerConsts.swift @@ -0,0 +1,8 @@ +// +// FileLoggerConsts.swift +// SelfControl +// +// Created by Satendra Singh on 14/06/26. +// + +let appName = "SelfControlBGService" diff --git a/Self-Control-Extension/SelfControl/SelfControlBGService/Shared/HelperServiceProtocol.swift b/Self-Control-Extension/SelfControl/SelfControlBGService/Shared/HelperServiceProtocol.swift new file mode 100644 index 00000000..3b0c3c64 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControlBGService/Shared/HelperServiceProtocol.swift @@ -0,0 +1,37 @@ +// Shared/HelperServiceProtocol.swift +import Foundation + +// The client protocol allows the helper to send events to the UI app. +@objc public protocol HelperClientProtocol { + func didUpdateStatus(_ status: String) + func didEmitEvent(_ message: String) + func didEnableWebExtension(_ extensionTypeRawValue: String, state: Bool) + func didStartedBlocking(_ state: Bool, _ endDate: Date) +} + +// The service protocol defines the operations the UI app can call on the helper. +@objc public protocol HelperServiceProtocol { + // One-time registration so the helper can call back into the UI app. + func registerClient() + + func currentStatus(reply: @escaping (String) -> Void) + // Example command that produces an async result pushed back to the client + + //Schedule Save/load + func saveSchedules(schedules: Data, reply: @escaping (Bool) -> Void) + func loadSchedules(reply: @escaping (_ schedules: Data) -> Void) + + //Start blocking network + func startNetwrokBlocking(minutes: Int) + func stopNetworkBlocking() + func extendBlocking(minutes: Int) + func setPreference(key: String, value: Bool) + func setBlockedURLs(_ urls: [String]) + func getBlockedStates(reply: @escaping (_ state: Bool, _ endDate: Date?) -> Void) + //Save block url list +} + +enum WEBExtension: String { + case chrome + case safari +} diff --git a/Self-Control-Extension/SelfControl/SelfControlBGService/com.application.SelfControl.corebits.bgservice.plist b/Self-Control-Extension/SelfControl/SelfControlBGService/com.application.SelfControl.corebits.bgservice.plist new file mode 100644 index 00000000..749e3591 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControlBGService/com.application.SelfControl.corebits.bgservice.plist @@ -0,0 +1,25 @@ + + + + + Label + com.application.SelfControl.corebits.bgservice + RunAtLoad + + KeepAlive + + StandardOutPath + /tmp/helper.log + StandardErrorPath + /tmp/helper.error.log + ProgramArguments + + /Applications/SelfControl.app/Contents/Library/LaunchAgents/SelfControlBGService + + MachServices + + com.application.SelfControl.corebits.bgservice.xpc + + + + diff --git a/Self-Control-Extension/SelfControl/SelfControlBGService/main.swift b/Self-Control-Extension/SelfControl/SelfControlBGService/main.swift new file mode 100644 index 00000000..ab22b9b6 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControlBGService/main.swift @@ -0,0 +1,15 @@ +// +// main.swift +// SelfControlBGService +// +// Created by Satendra Singh on 09/05/26. +// + +import Foundation +import OSLog + +//os_log("[SC] 🔍] BG AshouldAcceptNewConnection") +BGFileLogger.info("\(#function) Hello, World!") +//print("Hello, World!") +let deletegate = AppDelegate.shared +RunLoop.current.run() diff --git a/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/BlockOrAllowList.swift b/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/BlockOrAllowList.swift new file mode 100644 index 00000000..5a1b0659 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/BlockOrAllowList.swift @@ -0,0 +1,147 @@ +// +// BlockOrAllowList.swift +// SelfControl +// +// Created by Satendra Singh on 24/09/25. +// + + +import Foundation +import os.log +import NetworkExtension + +class BlockOrAllowList: NSObject { + + // MARK: - Properties + + var items: Set +// var lastModified: Date? + + // Serial queue for thread safety (replace @synchronized) +// private let queue = DispatchQueue(label: "BlockOrAllowList.serial") + + // MARK: - Initializer + + init(items: [String]) { + self.items = Set(items) + super.init() + } + + // MARK: - Methods + +// var isRemote: Bool { +// return path.hasPrefix("http://") || path.hasPrefix("https://") +// } + +// func load(_ path: String) { +// queue.sync { +// self.path = path +// self.items.removeAll() +// +// guard !path.isEmpty else { +// os_log("no list specified...", log: .default, type: .debug) +// return +// } +// +// var listString: String? +// var error: Error? +// +// if isRemote { +// os_log("(re)loading (remote) list", log: .default, type: .debug) +// if let url = URL(string: path) { +// do { +// listString = try String(contentsOf: url, encoding: .utf8) +// } catch let e { +// error = e +// } +// } +// if let error = error { +// os_log("ERROR: failed to (re)load (remote) list, %{public}@ (error: %{public}@)", log: .default, type: .error, path, String(describing: error)) +// return +// } +// // (Re)load remote URL once a day +// DispatchQueue.global(qos: .background).asyncAfter(deadline: .now() + 24*60*60) { +// self.load(self.path) +// } +// } else { +// os_log("(re)loading (local) list, %{public}@", log: .default, type: .debug, path) +// do { +// listString = try String(contentsOfFile: path, encoding: .utf8) +// } catch let e { +// error = e +// } +// if let error = error { +// os_log("ERROR: failed to (re)load (local) list, %{public}@ (error: %{public}@)", log: .default, type: .error, path, String(describing: error)) +// return +// } +// if let attrs = try? FileManager.default.attributesOfItem(atPath: path), +// let fileModified = attrs[.modificationDate] as? Date { +// self.lastModified = fileModified +// } +// } +// +// if let listString = listString { +// let lines = listString.components(separatedBy: .newlines) +// let filtered = lines.map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } +// .filter { !$0.isEmpty && !$0.hasPrefix("#") } +// self.items = Set(filtered) +// os_log("SC] 🔍(re)loaded %lu list items", log: .default, type: .debug, self.items.count) +// } +// } +// } + + /// Check if flow matches item on block or allow list + func isMatch(_ flow: NEFilterSocketFlow) -> Bool { + // Only access properties and perform mutation in the queue (thread safety) +// return queue.sync { + + var endpointNames = Set() + + // Get remote endpoint host + if let url = flow.url, let urlString = url.absoluteString.lowercased() as String? { + endpointNames.insert(urlString) + } + if let url = flow.url, let host = url.host?.lowercased() { + endpointNames.insert(host) + } + if let remoteEndpoint = flow.remoteEndpoint as? NWHostEndpoint { + endpointNames.insert(remoteEndpoint.hostname.lowercased()) + } + + // macOS 11+ specific property + if #available(macOS 11, *) { + if let remoteHostname = flow.remoteHostname?.lowercased() { + endpointNames.insert(remoteHostname) + if remoteHostname.hasPrefix("www.") { + let noWWW = String(remoteHostname.dropFirst(4)) + endpointNames.insert(noWWW) + } + } + } + os_log("[SC] 🔍] BlockList endpoint names : %{public}@", log: OSLog.default, type: .info, endpointNames.debugDescription) + + // Find matches + let matches = items.intersection(endpointNames) + if !matches.isEmpty { + os_log("SC] 🔍 endpoint names %{public}@ matched the following list items %{public}@", log: .default, type: .debug, String(describing: endpointNames), String(describing: matches)) + return true + } + + return false +// } + } +} + +// MARK: - NEFilterSocketFlow Extensions + +//extension NEFilterSocketFlow { +// /// You must implement these extensions if your Objective-C code provides them! +// @objc public override var url: URL? { +// // Implement according to your codebase (category/extension in Objective-C) +// return nil // Placeholder +// } +// @objc var remoteHostname: String? { +// // Implement according to your codebase (category/extension in Objective-C) +// return nil // Placeholder +// } +//} diff --git a/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/Constants.swift b/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/Constants.swift new file mode 100644 index 00000000..115dff7d --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/Constants.swift @@ -0,0 +1,59 @@ +// +// Constants.swift +// SelfControl +// +// Created by Satendra Singh on 11/10/25. +// +import os.log + +enum Constants { + /// File name for the JSON file with Safari rules. + static let KEY_UUID = "uuid" + static let KEY_PROCESS_ID = "pid" + static let KEY_PROCESS_ARGS = "args" + static let KEY_PROCESS_NAME = "name" + static let KEY_PROCESS_PATH = "path" + static let KEY_INDEX = "index" + static let KEY_PATH = "paths" + static let KEY_CS_SIGNER = "signatureSigner" + static let KEY_CS_ID = "signatureIdentifier" + static let KEY_CS_INFO = "signingInfo" + static let KEY_CS_AUTHS = "signatureAuthorities" +} + +enum AllowedProcess: String { + case safari = "com.apple.Safari" + case chrome = "com.google.Chrome" + static var allCases: [AllowedProcess] = [.chrome, .safari] + static func isAllowed(_ bundleID: String) -> Bool { + allCases.contains(where: { $0.rawValue == bundleID }) + } + + static func isAllowedProcess(_ process: Process) -> Bool { + let ancestors: [String] = process.ancestors?.value(forKey: "name") as? [String] ?? [] + return allCases.contains(where: { $0.rawValue == process.bundleID }) || ancestors.contains(where: { $0.lowercased().contains("chrome") || $0.lowercased().contains("safari") }) + } + + static func isAllowedProcess(_ process: Process, isSafariExtensionActive: Bool = false, isChromeExtensionActive: Bool = false) -> Bool { + // Determine if the process is Safari or Chrome by bundle ID or ancestor name. + let ancestors: [String] = process.ancestors?.value(forKey: "name") as? [String] ?? [] + let isSafariProcess = + process.bundleID == AllowedProcess.safari.rawValue || + ancestors.contains(where: { $0.lowercased().contains("safari") }) + let isChromeProcess = + process.bundleID == AllowedProcess.chrome.rawValue || + ancestors.contains(where: { $0.lowercased().contains("chrome") }) + + // Allow only if the corresponding extension is active. +// os_log("[SC] 🔍] isSafariProcess: \(isSafariProcess), isChromeProcess: \(isChromeProcess)") + + if isSafariProcess { + return isSafariExtensionActive + } + if isChromeProcess { + return isChromeExtensionActive + } + return false + } + +} diff --git a/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/FilterDataProvider.swift b/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/FilterDataProvider.swift new file mode 100644 index 00000000..ed86cdcf --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/FilterDataProvider.swift @@ -0,0 +1,519 @@ +import NetworkExtension +import os.log +import dnssd + +/// FilterDataProvider is a NEFilterDataProvider subclass that intercepts flows and applies a test rule. +class FilterDataProvider: NEFilterDataProvider { + // MARK: - Properties +// private let listner = PlistListner() + /// A dictionary for storing flows related to the same process. + private var relatedFlows: [String: [NEFilterSocketFlow]] = [:] + + // MARK: - Initialization + + override init() { + os_log("[SC] 🔍 NE FilterDataProvider: init") + super.init() +// listner.startListening() + } + + // MARK: - Filter Lifecycle + static let localPort = "8888" + override func startFilter(completionHandler: @escaping (Error?) -> Void) { + os_log("[SC] 🔍 NE FilterDataProvider: Starting filter", log: OSLog.default, type: .info) + var blockedHosts = IPCConnection.shared.blockedUrls +// if blockedHosts.count == 0 { +// blockedHosts = ["https://www.corebitss.com"] +// } + + os_log("[SC] 🔍 NE Blocked blockedUrls: %{public}@", log: OSLog.default, type: .info, String(describing: blockedHosts)) + + // Ensure blockedHosts is an array of String + var filterRules: [NEFilterRule] = blockedHosts.compactMap { address in + guard let host = address as? String else { return nil } + // For hostname-based rules, you must use NENetworkRule with nil networks (matches all) + let networkRule = NENetworkRule( + remoteNetwork: nil, + remotePrefix: 0, + localNetwork: nil, + localPrefix: 0, + protocol: .any, + direction: .outbound + ) + return NEFilterRule(networkRule: networkRule, action: .filterData) + } + + // Ensure at least one rule exists as required by NEFilterSettings + if filterRules.count == 0 { + // Add a default rule (matches all traffic, for example, and allows it) + let defaultNetworkRule = NENetworkRule( + remoteNetwork: nil, + remotePrefix: 0, + localNetwork: nil, + localPrefix: 0, + protocol: .any, + direction: .any + ) + let defaultRule = NEFilterRule(networkRule: defaultNetworkRule, action: .filterData) + filterRules = [defaultRule] + } + + let filterSettings = NEFilterSettings(rules: filterRules, defaultAction: .allow) + + apply(filterSettings) { error in + if let error = error { + os_log("[SC] 🔍 NE Error applying filter settings: %{public}@", log: OSLog.default, type: .error, error.localizedDescription) + } + completionHandler(error) + } + } + + override func stopFilter(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) { + os_log("[SC] 🔍 NE] FilterDataProvider: Stopping filter with reason %d", log: OSLog.default, type: .info, reason.rawValue) + completionHandler() + } + + // MARK: - Flow Handlings + + + func reverseDNSLookup(ip: String) -> String? { + var hints = addrinfo(ai_flags: AI_NUMERICHOST, ai_family: AF_UNSPEC, + ai_socktype: SOCK_STREAM, ai_protocol: IPPROTO_TCP, + ai_addrlen: 0, ai_canonname: nil, + ai_addr: nil, ai_next: nil) + var res: UnsafeMutablePointer? + + if getaddrinfo(ip, nil, &hints, &res) == 0, let addr = res?.pointee.ai_addr { + var hostBuffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + if getnameinfo(addr, socklen_t(addr.pointee.sa_len), + &hostBuffer, socklen_t(hostBuffer.count), + nil, 0, NI_NAMEREQD) == 0 { + return String(cString: hostBuffer) + } + } + return nil + } + + func extractHost(from request: String) -> String? { + for line in request.split(separator: "\r\n") { + if line.lowercased().hasPrefix("host:") { + return line.replacingOccurrences(of: "Host:", with: "", options: .caseInsensitive) + .trimmingCharacters(in: .whitespaces) + } + } + return nil + } + + func extractPath(from request: String) -> String? { + guard let firstLine = request.split(separator: "\r\n").first else { return nil } + let comps = firstLine.split(separator: " ") + return comps.count > 1 ? String(comps[1]) : nil + } + + func extractSNI(fromTLSData data: Data) -> String? { + // TLS record starts with 0x16 (Handshake), version bytes, then length + guard data.count > 5, data[0] == 0x16 else { return nil } + + var offset = 5 // Skip record header + + // Verify handshake type = ClientHello (0x01) + guard data.count > offset, data[offset] == 0x01 else { return nil } + offset += 4 // Skip type + length (3 bytes) + + // Skip protocol version (2), random (32), session id length + session id + guard data.count > offset + 34 else { return nil } + offset += 34 + guard data.count > offset else { return nil } + + // Skip session ID + if offset < data.count { + let sessionIDLength = Int(data[offset]) + offset += 1 + sessionIDLength + } + + // Skip cipher suites + guard offset + 2 <= data.count else { return nil } + let cipherSuiteLength = Int(data[offset]) << 8 | Int(data[offset + 1]) + offset += 2 + cipherSuiteLength + + // Skip compression methods + guard offset < data.count else { return nil } + let compressionLength = Int(data[offset]) + offset += 1 + compressionLength + + // Skip extensions length + guard offset + 2 <= data.count else { return nil } + let extensionsLength = Int(data[offset]) << 8 | Int(data[offset + 1]) + offset += 2 + guard offset + extensionsLength <= data.count else { return nil } + + var extOffset = offset + while extOffset + 4 <= offset + extensionsLength { + let extType = Int(data[extOffset]) << 8 | Int(data[extOffset + 1]) + let extLen = Int(data[extOffset + 2]) << 8 | Int(data[extOffset + 3]) + extOffset += 4 + + // Extension type 0 = Server Name + if extType == 0 { + // Parse Server Name extension + var nameOffset = extOffset + 2 // skip list length + while nameOffset + 3 < extOffset + extLen { + let nameType = data[nameOffset] + let nameLen = Int(data[nameOffset + 1]) << 8 | Int(data[nameOffset + 2]) + nameOffset += 3 + if nameType == 0, nameOffset + nameLen <= data.count { + let nameData = data.subdata(in: nameOffset ..< nameOffset + nameLen) + return String(data: nameData, encoding: .utf8) + } + nameOffset += nameLen + } + } + + extOffset += extLen + } + return nil + } + + override func handleOutboundData( + from flow: NEFilterFlow, + readBytesStartOffset offset: Int, + readBytes data: Data + ) -> NEFilterDataVerdict { + var result: NEFilterDataVerdict = .allow() + + guard IPCConnection.shared.isServiceActive else { //Service is inactive + return result + } + + guard IPCConnection.shared.blockedUrls.count > 0 else { //No signinficant urls to block + return result + } + + if RequestSourceAppValidator.isAllowedHost(flow: flow) { + return result + } + + guard let socketFlow = flow as? NEFilterSocketFlow else { + os_log("[SC] 🔍 NE] Not a socket flow. Allowing.", log: OSLog.default, type: .info) + return result + } + if socketFlow.direction != .outbound { + os_log("[SC] 🔍 NE] Not a inbound socket flow. Allowing.", log: OSLog.default, type: .info) + return result + } + + // guard let socketFlow = flow as? NEFilterSocketFlow else { + // return .allow() + // } + let requestString = String(data: data, encoding: .utf8) + if let requestString { + os_log("[SC] 🔍 NE] data HTTP Request: %{public}@", requestString) + } + + // Try HTTP detection + if let requestString = requestString, + requestString.hasPrefix("GET ") || requestString.hasPrefix("POST ") { + + if let host = extractHost(from: requestString), + let path = extractPath(from: requestString) { + let urlString = "http://\(host)\(path)" + os_log("[SC] 🔍 NE] data HTTP Request: %{public}@", urlString) + } + + } else if let sni = extractSNI(fromTLSData: data) { + os_log("[SC] 🔍 NE] data TLS SNI Host: %{public}@", sni) + guard let hostDomain = TLDURLToDomain.getURLDomain(from: sni) else { + return result + } + os_log("[SC] 🔍 NE] data hostDomain: %{public}@", hostDomain) + + for url in IPCConnection.shared.blockedUrls { + if url.contains(hostDomain) { + os_log("[SC] 🔍 NE] data Blocking flow Host match SNI:%{public}@, host:%{public}@", sni, hostDomain) + result = .drop() + break + } + } + } + + return result + } + + // Called for each new flow. + override func handleNewFlow(_ flow: NEFilterFlow) -> NEFilterNewFlowVerdict { +// os_log("[SC] 🔍 NE] FilterDataProvider: handleNewFlow invoked", log: OSLog.default, type: .debug) + guard IPCConnection.shared.isServiceActive else { + return .allow() + } + // Provide peek sizes required by this overload +// os_log("[SC] 🔍 NE] FilterDataProvider: handleNewFlow invoked", log: OSLog.default, type: .debug) + guard IPCConnection.shared.blockedUrls.count > 0 else { //No signinficant urls to block + return .allow() + } + + if RequestSourceAppValidator.isAllowedHost(flow: flow) { + return .allow() + } + + guard let socketFlow = flow as? NEFilterSocketFlow else { + return .filterDataVerdict(withFilterInbound: false, + peekInboundBytes: 0, + filterOutbound: true, + peekOutboundBytes: Int.max) + +// return .allow() + } + + // Try to extract SNI hostname (HTTPS) + guard let hostname = socketFlow.remoteHostname else { + return .filterDataVerdict(withFilterInbound: false, + peekInboundBytes: 0, + filterOutbound: true, + peekOutboundBytes: Int.max) + } + + // Fallback to IP address if SNI is not present +// let remoteIP = (socketFlow.remoteEndpoint as? NWHostEndpoint)?.hostname ?? hostname + +// let host = hostname.isEmpty ? remoteIP : hostname + let lower = hostname.lowercased() + os_log("[SC] 🔍 NE] Host SNI host:%{public}@", lower) + guard let hostDomain = TLDURLToDomain.getURLDomain(from: lower) else { + return .filterDataVerdict(withFilterInbound: false, + peekInboundBytes: 0, + filterOutbound: true, + peekOutboundBytes: Int.max) + } + os_log("[SC] 🔍 NE] Host SNI domain:%{public}@", hostDomain) + for url in IPCConnection.shared.blockedUrls { + if url.contains(hostDomain) { + os_log("[SC] 🔍 NE] Blocking flow Host:%{public}@", hostDomain) + return .drop() + } + } + + os_log("[SC] 🔍 NE] Not a blocked domain. Allowing.", log: OSLog.default, type: .info) + return .allow() + + guard let socketFlow = flow as? NEFilterSocketFlow else { + os_log("[SC] 🔍 NE] Not a socket flow. Allowing.", log: OSLog.default, type: .info) + return .allow() + } + if socketFlow.direction != .outbound { + os_log("[SC] 🔍 NE] Not a inbound socket flow. Allowing.", log: OSLog.default, type: .info) + return .allow() + } + var remoteHost = "" + if let flowURL = flow.url { + remoteHost = flowURL.absoluteString + os_log("[SC] 🔍 NE] flow.url: \(remoteHost)") + } else { + if let remoteEndpoint = socketFlow.remoteEndpoint as? NWHostEndpoint { + remoteHost = remoteEndpoint.hostname + let port = remoteEndpoint.port + os_log("[SC] 🔍 NE] NWEndpoint to host: %{public}@, URL: %{public}@, url: %{public}@", log: OSLog.default, type: .debug, remoteHost, port, flow.url?.host() ?? "") + } + } + + + //todo: check host name, work on reverse rule if domain match and if path is nil or blocked, drop connection. +// +// if IPCConnection.shared.blockedList.isMatch(flow as! NEFilterSocketFlow) { +// os_log("[SC] 🔍 NE] Dropped", log: OSLog.default, type: .info) +// return .drop() +// } +// os_log("[SC] 🔍 NE] Allowed", log: OSLog.default, type: .info) +// +// return .allow() + + os_log("[SC] 🔍 NE] Flow from remote endpoint: %{public}@, URL: %{public}@", log: OSLog.default, type: .debug, socketFlow.remoteEndpoint.debugDescription, flow.url?.description ?? "nil") + + if remoteHost.isEmpty { //Unable to get host + return .allow() + } + if remoteHost.isValidIpAddress { + if IPCConnection.shared.blockedIPAddresses.contains(remoteHost) { + os_log("[SC] 🔍 NE] Blocking flow IP match: %{public}@", remoteHost) + return .drop() + } + if let host = ReverseDomainMapper.reverseDNSUsingGetNameInfo(ipAddress: remoteHost) { + os_log("[SC] 🔍 NE] Converted IP: %{public}@ to: %{public}@", remoteHost, host) + remoteHost = host + } else { //unable to convert, simple ignore + return .allow() + } + } else { + return .allow() + } + guard let hostDomain = TLDURLToDomain.getURLDomain(from: remoteHost) else { + return .allow() + } + for url in IPCConnection.shared.blockedUrls { + if url.contains(hostDomain) { + os_log("[SC] 🔍 NE] Blocking flow Host match %{public}@, %{public}@", remoteHost, hostDomain) + return .drop() + } + } + + return .allow() + + // Process the flow and decide a verdict. + let verdict = processEvent(for: socketFlow) + os_log("[SC] 🔍 NE] Verdict for flow: %{public}@", log: OSLog.default, type: .debug, verdict.debugDescription) + return verdict + } + +// override func handleInboundDataComplete(for flow: NEFilterFlow) -> NEFilterDataVerdict { +// if let socketFlow = flow as? NEFilterSocketFlow { +// if let data = socketFlow.readData { +// if let requestString = String(data: data, encoding: .utf8) { +// if requestString.hasPrefix("GET") || requestString.hasPrefix("POST") { +// print("Request: \(requestString)") +// // Parse the path here +// } +// } +// } +// } +// return .allow() +// } +// +// override func handleNewFlow(_ flow: NEFilterFlow) -> NEFilterNewFlowVerdict { +// return .filterDataVerdict(withFilterInbound: true, +// peekInboundBytes: 1024, +// filterOutbound: true, +// peekOutboundBytes: 1024) +// } + + + override func handleInboundData(from flow: NEFilterFlow, readBytesStartOffset offset: Int, readBytes: Data) -> NEFilterDataVerdict { + if let requestString = String(data: readBytes, encoding: .utf8) { + print("Inbound data: \(requestString)") + os_log("[SC] 🔍 NE] handleInboundData: %{public}@", requestString) + + if requestString.contains("facebook.com/friends") { + return .drop() + } + } + return .allow() + } + + override func displayMessage(_ message: String, completionHandler: @escaping (Bool) -> Void) { + return completionHandler(true) + } + +// override func handleOutboundData(from flow: NEFilterFlow, readBytesStartOffset offset: Int, readBytes: Data) -> NEFilterDataVerdict { +// if let urlString = flow.url?.absoluteString { +// os_log("[SC] 🔍 NE] handleOutboundData URL: %{public}@", urlString) +// if urlString.contains("facebook.com/friends") { +// return .drop() +// } +// } +// +// if let requestString = String(data: readBytes, encoding: .utf8) { +// print("Outbound data: \(requestString)") +// os_log("[SC] 🔍 NE] handleOutboundData: %{public}@", requestString) +// +// if requestString.contains("facebook.com/friends") { +// return .drop() +// } +// } +// return .allow() +// } + +// override func handleOutboundData(from flow: NEFilterFlow, +// readBytesStartOffset offset: Int, +// readBytes: Data, +// completionHandler: @escaping (NEFilterDataVerdict) -> Void) { +// +// if let requestString = String(data: readBytes, encoding: .utf8) { +// print("Outbound data: \(requestString)") +// +// if requestString.contains("facebook.com") { +// completionHandler(.drop()) +// return +// } +// } +// +// // If undecided, ask for more data +// completionHandler(.allow()) +// } + + ///ss Processes the flow and returns a verdict. + /// This is a simplified test rule that blocks flows destined for "example.com". + private func processEvent(for flow: NEFilterSocketFlow) -> NEFilterNewFlowVerdict { + guard let endpoint = flow.remoteEndpoint as? NWHostEndpoint else { + return .allow() + } + os_log("[SC] 🔍 NE] processEvent endpoint.hostname: %{public}@ ", endpoint.hostname) + os_log("[SC] 🔍 NE] processEvent remoteHostname: %{public}@ ", flow.remoteHostname ?? "NOTHING") + guard let host = flow.remoteHostname?.lowercased().domainString else { + os_log("[SC] 🔍 NE] processEvent No Host") + return .allow() + } +// os_log("[SC] 🔍 NE] This is localFlowEndpoint: %{public}@ ", flow.localFlowEndpoint?.debugDescription ?? "NOTHING") + let blockedHosts = IPCConnection.shared.blockedUrls + +// if host == "google.com" || host == "8.8.8.8" { + if blockedHosts.contains(host) { + os_log("[SC] 🔍 NE] processEvent: Blocking flow to processEvent %{public}@ ", host) + return .drop() +// return .allow() + } + // Optionally log other flows for debugging + os_log("[SC] 🔍 NE] processEvent: Allowing flow to %{public}@", log: OSLog.default, type: .info, host) + return .allow() + } + + + // MARK: - (Optional) Handling Related Flows & Alerts + + /// Adds a flow to a list of related flows for a given key. + private func addRelatedFlow(forKey key: String, flow: NEFilterSocketFlow) { + os_log("[SC] 🔍 NE] Adding related flow for key: %@", log: OSLog.default, type: .debug, key) + if relatedFlows[key] == nil { + + + relatedFlows[key] = [] + } + relatedFlows[key]?.append(flow) + } + + /// Processes related flows once a decision is made for a given key. + private func processRelatedFlows(forKey key: String) { + guard let flows = relatedFlows[key] else { + os_log("[SC] 🔍 NE] No related flows for key: %@", log: OSLog.default, type: .debug, key) + return + } + for flow in flows { + let verdict = processEvent(for: flow) + resumeFlow(flow, with: verdict) + } + relatedFlows[key] = nil + } + + /// A stub method for resuming a flow with a verdict. + private func resumeFlow(_ flow: NEFilterSocketFlow, with verdict: NEFilterNewFlowVerdict) { + // In a complete implementation, this would resume the paused flow with the provided verdict. + os_log("[SC] 🔍 NE] Resuming flow %@ with verdict %@", log: OSLog.default, type: .info, flow.debugDescription, verdict.debugDescription) + } + + /// A stub method to simulate alerting the user. + /// In a complete implementation, this might trigger an IPC to your app for user intervention. + private func alertUser(for flow: NEFilterSocketFlow) { + os_log("[SC] 🔍 NE] Alert: User decision needed for flow %@", log: OSLog.default, type: .info, flow.debugDescription) + } + +// func processFromflow(flow: NEFilterFlow) -> Process? { +// guard let auditTokenData = flow.sourceAppAuditToken else { +// return nil +// } +// +// // Convert NSData → audit_token_t +// var auditToken = auditTokenData.withUnsafeBytes { ptr -> audit_token_t in +// return ptr.load(as: audit_token_t.self) +// } +// return Process.init(&auditToken) +// } + } +//https://developer.chrome.com/docs/extensions/how-to/distribute/install-extensions + diff --git a/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/HostToIpMapping.swift b/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/HostToIpMapping.swift new file mode 100644 index 00000000..8857b4f0 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/HostToIpMapping.swift @@ -0,0 +1,82 @@ +// +// HostToIpMapping.swift +// SelfControl +// +// Created by Satendra Singh on 20/10/25. +// + +import Foundation + +class HostToIpMapping { + static func string(for addressData: Data) throws -> String { + var hostBuffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + + let result = addressData.withUnsafeBytes { (ptr: UnsafeRawBufferPointer) -> Int32 in + guard let addrPtr = ptr.baseAddress else { + return EINVAL + } + return getnameinfo( + addrPtr.assumingMemoryBound(to: sockaddr.self), + socklen_t(addressData.count), + &hostBuffer, + socklen_t(hostBuffer.count), + nil, + 0, + NI_NUMERICHOST + ) + } + + if result != 0 { + let message = String(cString: gai_strerror(result)) + throw NSError(domain: "HostToIpMappingError", code: Int(result), userInfo: [ + NSLocalizedDescriptionKey: message + ]) + } + + return String(cString: hostBuffer) + } + + static func ipAddresses(for domainName: String) -> [String] { + let startTime = Date() + + // ✅ Correct initialization + let cfHostOpt: CFHost? = CFHostCreateWithName(kCFAllocatorDefault, domainName as CFString).takeRetainedValue() + guard let cfHost = cfHostOpt else { + print("HostToIpMapping: Failed to create CFHost for \(domainName)") + return [] + } + + var streamError = CFStreamError() + let success = CFHostStartInfoResolution(cfHost, .addresses, &streamError) + + if !success || streamError.error != 0 { + print("HostToIpMapping: Warning: failed to resolve addresses for \(domainName) with stream error \(streamError.error)") + return [] + } + + // ✅ Use takeUnretainedValue safely + guard let addressArray = CFHostGetAddressing(cfHost, nil)? + .takeUnretainedValue() as? [Data] else { + print("HostToIpMapping: Warning: failed to resolve addresses for \(domainName)") + return [] + } + + var stringAddresses: [String] = [] + + for addrData in addressArray { + do { + let ip = try string(for: addrData) + stringAddresses.append(ip) + } catch { + print("HostToIpMapping: Warning: Failed to parse IP struct for \(domainName) with error: \(error.localizedDescription)") + } + } + + let elapsed = Date().timeIntervalSince(startTime) + if elapsed > 2.5 { + print("HostToIpMapping: Warning: took \(elapsed) seconds to resolve \(domainName)") + } + + return stringAddresses + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/IPCConnection.swift b/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/IPCConnection.swift new file mode 100644 index 00000000..fe7890e9 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/IPCConnection.swift @@ -0,0 +1,154 @@ +/* + See the LICENSE.txt file for this sample’s licensing information. + + Abstract: + This file contains the implementation of the app <-> provider IPC connection + */ + +import Foundation +import os.log +import Network + +/// The IPCConnection class is used by both the app and the system extension to communicate with each other +class IPCConnection: NSObject { + + // MARK: Properties + + var listener: NSXPCListener? + var currentConnection: NSXPCConnection? + weak var delegate: ExtensionToApp? + static let shared = IPCConnection() + var blockedUrls: [String] = [String]() + var blockedList = BlockOrAllowList(items: []) + var blockedIPAddresses: Set = [] + private(set) var isSafariExtensionEnable: Bool = false + private(set) var isGoogleChromeEnabled: Bool = false + // Published extension state for UI/observers + private(set) var isServiceActive: Bool = false + + // MARK: Methods + + /** + The NetworkExtension framework registers a Mach service with the name in the system extension's NEMachServiceName Info.plist key. + The Mach service name must be prefixed with one of the app groups in the system extension's com.apple.security.application-groups entitlement. + Any process in the same app group can use the Mach service to communicate with the system extension. + */ + private func extensionMachServiceName(from bundle: Bundle) -> String { + + guard let networkExtensionKeys = bundle.object(forInfoDictionaryKey: "NetworkExtension") as? [String: Any], + let machServiceName = networkExtensionKeys["NEMachServiceName"] as? String else { +// fatalError("Mach service name is missing from the Info.plist") + os_log("[SC] 🔍] Mach service name is missing from the Info.plist") + return "" + } + + return machServiceName + } + + //This method is called to start listing for the connections to communicate with app + func startListener() { + + let machServiceName = extensionMachServiceName(from: Bundle.main) + //X6FQ433AWK.com.application.SelfControl.corebits.network + os_log("[SC] 🔍] Starting XPC listener for mach service %{public}@", machServiceName) + + let newListener = NSXPCListener(machServiceName: machServiceName) + newListener.delegate = self + newListener.resume() + listener = newListener + } + + /// This method is called by the app to register with the provider running in the system extension. + func register(completionHandler: @escaping (Bool) -> Void) { } + + } + +extension IPCConnection: NSXPCListenerDelegate { + + // MARK: NSXPCListenerDelegate + + func listener(_ listener: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool { + os_log("[SC] 🔍] NE shouldAcceptNewConnection:" ) + // The exported object is this IPCConnection instance. + newConnection.exportedInterface = NSXPCInterface(with: AppToExtensionExtension.self) + newConnection.exportedObject = self + + // The remote object is the delegate of the app's IPCConnection instance. + newConnection.remoteObjectInterface = NSXPCInterface(with: ExtensionToApp.self) + + newConnection.invalidationHandler = { + os_log("[SC] 🔍] NE invalidationHandler:" ) + self.currentConnection = nil + } + + newConnection.interruptionHandler = { + os_log("[SC] 🔍] NE interruptionHandler:" ) + self.currentConnection = nil + } + currentConnection?.suspend() + currentConnection?.invalidate() + currentConnection = newConnection + newConnection.resume() + return true + } +} + + +extension IPCConnection: AppToExtensionExtension { + + func setEnableService(_ enable: Bool) { + os_log("[SC] 🔍] NE setEnableService: %{public}d", enable) + self.isServiceActive = enable + } + + func setBlockedIPAddresses(_ ips: [String]) { + blockedIPAddresses = Set(ips) + os_log("[SC] 🔍] NE setBlockedIPAddresses: %{public}@", blockedIPAddresses) + } + + func setBlockedURLs(_ urls: [String]) { + os_log("[SC] 🔍] NE Extension Received Blocking: %{public}@",urls) + blockedUrls = urls +// delegate?.didSetUrls() + blockedList = BlockOrAllowList(items: blockedUrls) + + guard let connection = currentConnection else { + os_log("[SC] 🔍] NE Cannot update blocked urls, app isn't registered") + return + } + + guard let appProxy = connection.remoteObjectProxyWithErrorHandler({ promptError in + os_log("[SC] 🔍] NE Failed to create a remote object proxy for the app: %{public}@", promptError.localizedDescription) +// self.currentConnection = nil +// responseHandler(true) + }) as? ExtensionToApp else { + os_log("[SC] 🔍] NE Failed to create a remote object proxy for the app") + return + } + appProxy.didSetUrls() + } + + func setActiveBrowserExtension(_ extensionTypeRawValue: String, state: Bool) { + // Map the raw value to the Swift enum if possible + os_log("[SC] 🔍] NE Received extension state to %{public}@", extensionTypeRawValue) + if let mapped = ActiveBrowserExtensios(rawValue: extensionTypeRawValue) { + switch mapped { + case .safari: + self.isSafariExtensionEnable = state + case .chrome: + self.isGoogleChromeEnabled = state + } + os_log("[SC] 🔍] NE Set browser extension state to %{public}@, state: %{public}d", extensionTypeRawValue, state) + } else { + os_log("[SC] 🔍] NE Unknown browser extension type %{public}@", extensionTypeRawValue) + } + } + + // MARK: ProviderCommunication + + func register(_ completionHandler: @escaping (Bool) -> Void) { + + os_log("[SC] 🔍] App registered") + completionHandler(true) + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/Info.plist b/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/Info.plist new file mode 100644 index 00000000..cf77feef --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/Info.plist @@ -0,0 +1,16 @@ + + + + + NetworkExtension + + NEMachServiceName + $(TeamIdentifierPrefix)com.application.SelfControl.corebits.network + NEProviderClasses + + com.apple.networkextension.filter-data + $(PRODUCT_MODULE_NAME).FilterDataProvider + + + + diff --git a/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/Process/Binary.swift b/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/Process/Binary.swift new file mode 100644 index 00000000..3397339e --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/Process/Binary.swift @@ -0,0 +1,175 @@ +// +// Binary.swift +// SelfControl +// +// Created by Satendra Singh on 10/10/25. +// + +import Foundation +import AppKit +import OSLog +import Security +import CoreServices // for MDItem + +@objcMembers +class Binary: NSObject { + + // MARK: - Properties (mirroring Binary.h) + + dynamic var path: String + dynamic var name: String = "" + dynamic var icon: NSImage = NSImage() + dynamic var attributes: NSDictionary? + dynamic var metadata: NSDictionary? + dynamic var bundle: Bundle? + dynamic var csInfo: NSMutableDictionary = NSMutableDictionary() + dynamic var sha256: NSMutableString = NSMutableString() + + // MARK: - Init + + init(_ path: String) { + self.path = (path as NSString).resolvingSymlinksInPath + super.init() + + // Try load app bundle (nil for non-apps) + self.getBundle() + + // Get name + self.getName() + + // File attributes + self.getAttributes() + + // Spotlight metadata + self.getMetadata() + } + + // MARK: - Bundle + + // Try load app bundle; nil for non-apps + func getBundle() { + // First try direct path + if let b = Bundle(path: self.path) { + self.bundle = b + return + } + // Else find dynamically + self.bundle = Utilities().findAppBundle(self.path) + } + + // MARK: - Name + + // Figure out binary's name via bundle CFBundleName or lastPathComponent + func getName() { + if let bundle = self.bundle, + let bundleName = bundle.infoDictionary?["CFBundleName"] as? String { + self.name = bundleName + } else { + self.name = (self.path as NSString).lastPathComponent + } + } + + // MARK: - Attributes + + func getAttributes() { + self.attributes = try? FileManager.default.attributesOfItem(atPath: self.path) as NSDictionary + } + + // MARK: - Spotlight metadata + + func getMetadata() { + // MDItemCreate wants CFString path + let cfPath = self.path as CFString + guard let mdItem = MDItemCreate(kCFAllocatorDefault, cfPath) else { + return + } + defer { } + + guard let attributeNames = MDItemCopyAttributeNames(mdItem) else { + return + } + defer { } + + if let attrs = MDItemCopyAttributes(mdItem, attributeNames) { + self.metadata = attrs as NSDictionary + } + } + + // MARK: - Icon + + // Get an icon for the process: app’s icon or system one + func getIcon() { + // Skip short/non-absolute paths if no bundle (system logs errors otherwise) + if !self.path.hasPrefix("/"), self.bundle == nil { + return + } + + // For apps, try CFBundleIconFile + if let bundle = self.bundle { + if let iconFile = bundle.infoDictionary?["CFBundleIconFile"] as? String { + let iconExt = (iconFile as NSString).pathExtension.isEmpty ? "icns" : (iconFile as NSString).pathExtension + let iconBase = (iconFile as NSString).deletingPathExtension + if let iconPath = bundle.path(forResource: iconBase, ofType: iconExt) { + if let img = NSImage(contentsOfFile: iconPath) { + self.icon = img + } + } + } + } + + // Fallback to workspace icon + if self.bundle == nil || self.icon.size == .zero { + self.icon = NSWorkspace.shared.icon(forFile: self.path) + } + + // Standard size 128x128 + self.icon.size = NSSize(width: 128, height: 128) + } + + // MARK: - Code signing (static) + + // You likely have this helper already, but defining its expected signature for clarity: + func extractSigningInfo(_ code: SecStaticCode?, _ path: String, _ flags: SecCSFlags) -> [String: Any]? { + var staticCode: SecStaticCode? + let url = URL(fileURLWithPath: path) as CFURL + let status = SecStaticCodeCreateWithPath(url, SecCSFlags(), &staticCode) + guard status == errSecSuccess, let codeRef = staticCode else { + return nil + } + + var signingInfo: CFDictionary? + let infoStatus = SecCodeCopySigningInformation(codeRef, flags, &signingInfo) + guard infoStatus == errSecSuccess, let info = signingInfo as? [String: Any] else { + return nil + } + return info + } + + // Your corrected function: + func generateSigningInfo(_ flags: SecCSFlags) { + // These constants are C macros in Security framework headers, not imported automatically: + let kSecCodeInfoStatus = "Status" // corresponds to kSecCodeInfoStatus in Security framework + let KEY_CS_STATUS = kSecCodeInfoStatus // for compatibility with your existing code + + // Instead of 'nil', explicitly pass an optional SecStaticCode? = nil + if let extracted = extractSigningInfo(nil as SecStaticCode?, self.path, flags), + let statusNum = extracted[KEY_CS_STATUS] as? NSNumber, + statusNum.intValue == Int(noErr) { + // Bridge [String: Any] to NSMutableDictionary + self.csInfo = NSMutableDictionary(dictionary: extracted) + } else { + // logging commented out in original code + } + } + + // MARK: - Description + + override var description: String { + return String(format: "name: %@\npath: %@\nattributes: %@\nsigning info: %@\nmetadata: %@", + self.name, + self.path, + self.attributes ?? [:], + self.csInfo, + self.metadata ?? [:]) + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/Process/Process.swift b/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/Process/Process.swift new file mode 100644 index 00000000..823bb5b2 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/Process/Process.swift @@ -0,0 +1,330 @@ +// +// Process.swift +// SelfControl +// +// Created by Satendra Singh on 10/10/25. +// + +import Foundation +import OSLog +import Security +import Darwin +import NetworkExtension + +@objcMembers +class Process: NSObject { + + // MARK: - Properties (mirroring Process.h) + + dynamic var pid: pid_t = -1 + dynamic var uid: uid_t = UInt32(bitPattern: -1) + dynamic var type: UInt16 = 0 + dynamic var exit: UInt32 = UInt32(bitPattern: -1) + dynamic var deleted: Bool = false + + dynamic var name: String? + dynamic var path: String? + dynamic var arguments: NSMutableArray? = NSMutableArray() + dynamic var ancestors: NSMutableArray? = NSMutableArray() + dynamic var csInfo: NSMutableDictionary? + dynamic var key: String = "" + dynamic var bundleID: String = "" + dynamic var binary: Binary = Binary.init("") + dynamic var timestamp: Date = Date() + + // MARK: - Init + + override init() { + super.init() + self.arguments = NSMutableArray() + self.ancestors = NSMutableArray() + self.timestamp = Date() + self.pid = -1 + self.uid = UInt32(bitPattern: -1) + self.exit = UInt32(bitPattern: -1) + } + + // Init with audit token pointer + // Matches -(id)init:(audit_token_t*)token + convenience init?(_ token: UnsafePointer) { + self.init() + + // Save pid + let tokenVal = token.pointee + let procPID = audit_token_to_pid(tokenVal) + self.pid = procPID + if self.pid == 0 { +// os_log_error(logHandle, "ERROR: 'audit_token_to_pid' returned NULL") + return nil + } + + // Get path via SecCode (also sets deleted flag) + self.getPath(token) + if (self.path ?? "").isEmpty { +// os_log_error(logHandle, "ERROR: failed to find path for process %d", self.pid) + return nil + } + + // Set name + if self.deleted != true { + self.name = Utilities().getProcessName(0, self.path!) + } else { + self.name = Utilities().getProcessName(self.pid, self.path!) + } + + // Get user + self.uid = audit_token_to_euid(tokenVal) + self.bundleID = Utilities().getBundleID(self.path ?? "") ?? "" + // Generate (dynamic) code information + self.generateSigningInfo(token) + + // Generate key + self.key = self.generateKey() + + // Init binary + self.binary = Binary(self.path ?? "") + + // PID specific logic: args and ancestors + self.getArgs() + self.ancestors = Utilities().generateProcessHierarchy(self.pid) + + // Verify pid-version (avoid pid reuse) + if let currentTokenNSData = Utilities().tokenForPid(self.pid), + currentTokenNSData.length == MemoryLayout.size { + let currentTokenData = Data(referencing: currentTokenNSData) + currentTokenData.withUnsafeBytes { (rawPtr: UnsafeRawBufferPointer) in + if let curPtr = rawPtr.baseAddress?.assumingMemoryBound(to: audit_token_t.self) { + let origVersion = audit_token_to_pidversion(tokenVal) + let curVersion = audit_token_to_pidversion(curPtr.pointee) + if origVersion != curVersion { +// os_log_error(logHandle, "ERROR: audit token mismatch ...pid re-used?") + self.arguments = nil + self.ancestors = nil + } + } + } + } + + // Process alive check + if Utilities().isAlive(self.pid) != true { +// os_log_error(logHandle, "ERROR: process (%d)%{public}@ has already exited", self.pid, self.path ?? "") + return nil + } + } + + // MARK: - Key generation + + // Matches -(NSString*)generateKey + func generateKey() -> String { + var id: String = "" + var signer: Int = 0 + enum Signer: Int { + case None = 0 + case Apple + case AppStore + case DevID + case AdHoc + } + + if let cs = self.csInfo { + if let v = cs[Constants.KEY_CS_SIGNER] as? NSNumber { + signer = v.intValue + } + + // Apple/App Store: just use cs id + if signer == Signer.Apple.rawValue || signer == Signer.AppStore.rawValue { + if let csid = cs[Constants.KEY_CS_ID] as? String, !csid.isEmpty { + id = csid + } + } + // Dev ID: use cs id + leaf signer + else if signer == Int(Signer.DevID.rawValue) { + if let csid = cs[Constants.KEY_CS_ID] as? String, !csid.isEmpty, + let auths = cs[Constants.KEY_CS_AUTHS] as? [Any], let first = auths.first as? String, !first.isEmpty { + id = "\(csid):\(first)" + } + } + } + + if id.isEmpty { + id = self.path ?? "" + } + +// os_log_debug(logHandle, "generated process key: %{public}@", id) + return id + } + + // MARK: - Path + + // Matches -(void)getPath:(audit_token_t*)token + func getPath(_ token: UnsafePointer) { + var status: OSStatus = errSecParam + var code: SecCode? + var staticCode: SecStaticCode? + var pathURL: CFURL? + + // SecCodeCopyGuestWithAttributes using audit token + let attrs: [String: Any] = [kSecGuestAttributeAudit as String: Data(bytes: token, count: MemoryLayout.size)] + status = SecCodeCopyGuestWithAttributes(nil, attrs as CFDictionary, SecCSFlags(), &code) + if status == errSecSuccess, let code = code { + // Convert dynamic code (SecCode) to static code (SecStaticCode) + status = SecCodeCopyStaticCode(code, SecCSFlags(), &staticCode) + if status == errSecSuccess, let staticCode = staticCode { + status = SecCodeCopyPath(staticCode, SecCSFlags(), &pathURL) + if status == errSecSuccess, let url = pathURL as NSURL? { + self.path = (url as URL).path + } else { +// os_log_error(logHandle, "ERROR: 'SecCodeCopyPath' failed with': %#x", status) + } + } else { +// os_log_error(logHandle, "ERROR: 'SecCodeCopyStaticCode' failed with': %#x", status) + } + } else { +// os_log_error(logHandle, "ERROR: 'SecCodeCopyGuestWithAttributes' failed with': %#x", status) + } + + // Deleted binary? + if status == OSStatus(kPOSIXErrorENOENT) { +// os_log_debug(logHandle, "process %d's binary appears to be deleted", self.pid) + self.deleted = true + } + + // Fallback + if pathURL == nil { + self.path = Utilities().getProcessPath(self.pid) + } + + if let p = self.path { + self.path = (p as NSString).resolvingSymlinksInPath + } + + // No CFRelease in Swift ARC; CoreFoundation objects are memory-managed automatically. + } + + // MARK: - Args + + // Matches -(void)getArgs + func getArgs() { + var mib: [Int32] = [CTL_KERN, KERN_ARGMAX, 0] + var systemMaxArgs: Int32 = 0 + var size = MemoryLayout.size(ofValue: systemMaxArgs) + + // Get system arg max + if sysctl(&mib, 2, &systemMaxArgs, &size, nil, 0) == -1 { + return + } + + guard systemMaxArgs > 0 else { return } + let bufSize = Int(systemMaxArgs) + let processArgs = UnsafeMutablePointer.allocate(capacity: bufSize) + defer { processArgs.deallocate() } + + mib = [CTL_KERN, KERN_PROCARGS2, Int32(self.pid)] + size = bufSize + + if sysctl(&mib, 3, processArgs, &size, nil, 0) == -1 { + return + } + + if size <= MemoryLayout.size { + return + } + + // numberOfArgs at start + var numberOfArgs: Int32 = 0 + memcpy(&numberOfArgs, processArgs, MemoryLayout.size) + + // parser after numberOfArgs + var parser = processArgs.advanced(by: MemoryLayout.size) + let endPtr = processArgs.advanced(by: size) + + // Skip executable path (NULL-terminated) + while parser < endPtr { + if parser.pointee == 0 { break } + parser = parser.advanced(by: 1) + } + if parser == endPtr { return } + + // Skip trailing NULLs to argv[0] + while parser < endPtr { + if parser.pointee != 0 { break } + parser = parser.advanced(by: 1) + } + if parser == endPtr { return } + + // Now parse args until count reached + var argStart = parser + let argsArray = NSMutableArray() + while parser < endPtr { + if parser.pointee == 0 { + if let arg = String(validatingUTF8: UnsafePointer(argStart)) { + argsArray.add(arg) + } + parser = parser.advanced(by: 1) + argStart = parser + if argsArray.count == Int(numberOfArgs) { + break + } + continue + } + parser = parser.advanced(by: 1) + } + + self.arguments = argsArray + } + + // MARK: - Code signing + + // Local key mapping for Security's kSecCodeInfoStatus + private let KEY_CS_STATUS = "Status" + + // Helper to extract signing info from an audit token (dynamic code) + private func extractSigningInfo(_ token: UnsafeMutablePointer, + _ requirement: SecRequirement?, + _ flags: SecCSFlags) -> NSMutableDictionary? { + // Build attributes with audit token + let attrs: [String: Any] = [kSecGuestAttributeAudit as String: + Data(bytes: token, count: MemoryLayout.size)] + var code: SecCode? + var status = SecCodeCopyGuestWithAttributes(nil, attrs as CFDictionary, flags, &code) + guard status == errSecSuccess, let codeRef = code else { + return nil + } + + // Convert SecCode (dynamic) to SecStaticCode before querying signing info + var staticCode: SecStaticCode? + status = SecCodeCopyStaticCode(codeRef, flags, &staticCode) + guard status == errSecSuccess, let staticCodeRef = staticCode else { + return nil + } + + var info: CFDictionary? + status = SecCodeCopySigningInformation(staticCodeRef, flags, &info) + guard status == errSecSuccess, let dict = info as? [String: Any] else { + return nil + } + return NSMutableDictionary(dictionary: dict) + } + + // Matches -(void)generateSigningInfo:(audit_token_t*)token + func generateSigningInfo(_ token: UnsafePointer) { + // Pass typed nil for requirement to avoid "nil requires a contextual type" + let requirement: SecRequirement? = nil + if let extracted = extractSigningInfo(UnsafeMutablePointer(mutating: token), requirement, SecCSFlags()) { + if let statusNum = extracted[KEY_CS_STATUS] as? NSNumber, statusNum.intValue == Int(noErr) { + self.csInfo = extracted + } else { +// os_log_error(logHandle, "ERROR: invalid code signing information for %{public}@: %{public}@", self.path ?? "", extracted) + } + } else { +// os_log_error(logHandle, "ERROR: failed to extract code signing information for %{public}@", self.path ?? "") + } + } + + // MARK: - Description + + override var description: String { + return String(format: "pid: %d\npath: %@\nuser: %d\nargs: %@\nancestors: %@\n signing info: %@\n binary:\n%@", self.pid, self.path ?? "nil", self.uid, self.arguments ?? [], self.ancestors ?? [], self.csInfo ?? [:], self.binary.description) + } +} + diff --git a/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/Process/SourceAppAuditTokenBuilder.swift b/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/Process/SourceAppAuditTokenBuilder.swift new file mode 100644 index 00000000..68b96414 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/Process/SourceAppAuditTokenBuilder.swift @@ -0,0 +1,61 @@ +// +// SourceAppAuditTokenBuilder.swift +// SelfControlExtension +// +// Created by Satendra Singh on 10/10/25. +// + +import NetworkExtension +import Security +import Foundation +import AppKit + +struct SourceAppAuditTokenBuilder { + + static func getAppInfo(from flow: NEFilterFlow) -> (bundleID: String?, appName: String?) { + guard let auditTokenData = flow.sourceAppAuditToken else { + return (nil, nil) + } + + // Convert NSData → audit_token_t + let auditToken = auditTokenData.withUnsafeBytes { ptr -> audit_token_t in + return ptr.load(as: audit_token_t.self) + } + + // MARK: 1. Extract PID from audit token + let pid = audit_token_to_pid(auditToken) + + // MARK: 2. Use SecTask to get bundle identifier + var bundleID: String? = nil + if let secTask = SecTaskCreateWithAuditToken(nil, auditToken) { + // macOS doesn’t define kSecEntitlementApplicationIdentifier constant, + // so use the raw entitlement string instead: + let entitlementKey = "application-identifier" as CFString + bundleID = SecTaskCopyValueForEntitlement(secTask, entitlementKey, nil) as? String + } + + // MARK: 3. Try to get app name from bundle ID + var appName: String? = nil + if let bundleID = bundleID, + let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) { + appName = appURL.deletingPathExtension().lastPathComponent + } + + // MARK: 4. Fallback: get executable name via proc_pidpath() + if appName == nil { + var pathBuffer = [CChar](repeating: 0, count: Int(PATH_MAX)) + let result = proc_pidpath(pid, &pathBuffer, UInt32(pathBuffer.count)) + if result > 0 { + let path = String(cString: pathBuffer) + appName = URL(fileURLWithPath: path).lastPathComponent + } + } + + return (bundleID, appName) + } + + // Helper: extract PID from audit_token_t + private static func audit_token_to_pid(_ token: audit_token_t) -> pid_t { + return pid_t(token.val.0) + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/Process/Utilities.swift b/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/Process/Utilities.swift new file mode 100644 index 00000000..05e8cd3c --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/Process/Utilities.swift @@ -0,0 +1,714 @@ +// +// File.swift +// SelfControl +// +// Created by Satendra Singh on 10/10/25. +// + + +// Swift port of utilities.m +// +// Note: This file relies on C system APIs. Ensure your bridging header +// imports the needed headers: libproc.h, sys/sysctl.h, CommonCrypto/CommonCrypto.h, +// mach/mach.h, Security/Security.h, Carbon/Carbon.h, CoreServices/CoreServices.h, CFNetwork/CFHost.h +// + +import AppKit +import Foundation +import OSLog +import SystemConfiguration +import Darwin +import CoreServices // for MDItem +import Security +import os.log +import os + +// Define C macro from libproc.h (not imported automatically into Swift) +private let PROC_PIDPATHINFO_MAXSIZE: Int = 4096 + +// MARK: - Globals + +final class Utilities { + // Provided elsewhere in project + + let logger = Logger(subsystem: "com.application.SelfControl.corebits.SelfControl-Safari-Extension", category: "network") + + // MARK: - Version / Bundle + + @objc + public func getAppVersion() -> String? { + return Bundle.main.infoDictionary?["CFBundleVersion"] as? String + } + + @objc + public func getBundleExecutable(_ appPath: String) -> String? { + guard let bundle = Bundle(path: appPath) else { + logger.error("ERROR: failed to load app bundle for %{public}\(appPath)") + return nil + } + return (bundle.executablePath as NSString?)?.resolvingSymlinksInPath + } + + // MARK: - Parent process (Carbon/deprecated) + + @objc + public func getRealParent(_ pid: pid_t) -> NSDictionary? { + let PROC_PIDPATHINFO_MAXSIZE = 4096 + + // Step 1: Get parent PID + var info = kinfo_proc() + var size = MemoryLayout.stride + var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid] + + guard sysctl(&mib, u_int(mib.count), &info, &size, nil, 0) == 0 else { + return nil + } + + let ppid = info.kp_eproc.e_ppid + if ppid == 0 { return nil } + + // Step 2: Get parent process path + var pathBuffer = [CChar](repeating: 0, count: Int(PROC_PIDPATHINFO_MAXSIZE)) + let result = proc_pidpath(ppid, &pathBuffer, UInt32(pathBuffer.count)) + let path = (result > 0) ? String(cString: pathBuffer) : nil + + // Step 3: Try to get bundle info via NSRunningApplication + var appName: String? = nil + var bundleID: String? = nil + + if let app = NSRunningApplication(processIdentifier: ppid) { + appName = app.localizedName + bundleID = app.bundleIdentifier + } + + // Step 4: Construct NSDictionary for compatibility + let dict: NSDictionary = [ + "ParentPID": NSNumber(value: ppid), + "ParentPath": path ?? "", + "ParentAppName": appName ?? "", + "ParentBundleID": bundleID ?? "" + ] + + return dict + } + + + // MARK: - Process hierarchy + + @objc + public func generateProcessHierarchy(_ child: pid_t) -> NSMutableArray { + let ancestors = NSMutableArray() + typealias RPIDFunc = @convention(c) (pid_t) -> pid_t + let rpidSym = dlsym(dlopen(nil, RTLD_NOW), "responsibility_get_pid_responsible_for_pid") + let getRPID = rpidSym.map { unsafeBitCast($0, to: RPIDFunc.self) } + + var currentPID = child + + while true { + let currentPath = getProcessPath(currentPID) ?? NSLocalizedString("unknown", comment: "unknown") + let currentName = getProcessName(0, currentPath) ?? NSLocalizedString("unknown", comment: "unknown") + ancestors.insert([ + Constants.KEY_PROCESS_ID: NSNumber(value: currentPID), + Constants.KEY_PROCESS_PATH: currentPath, + Constants.KEY_PROCESS_NAME: currentName + ], at: 0) + + var parentPID: pid_t = 0 + + if getuid() != 0 { + if let parent = getRealParent(currentPID), let p = parent["pid"] as? NSNumber { + parentPID = pid_t(truncating: p) + } + } + + if parentPID == 0, let getRPID = getRPID { + parentPID = getRPID(currentPID) + } + + if parentPID <= 0 || parentPID == currentPID { + parentPID = getParent(Int32(currentPID)) + } + + if parentPID <= 0 || parentPID == currentPID { + break + } + + currentPID = parentPID + } + + // add KEY_INDEX for UI + for i in 0.. String? { + var uid: uid_t = 0 + var gid: gid_t = 0 + + // explicitly type `nil` as `SCDynamicStore?` + if let cfUser = SCDynamicStoreCopyConsoleUser(nil as SCDynamicStore?, &uid, &gid) { + return cfUser as String + } + return nil + } + + // MARK: - Process name + + @objc + public func getProcessName(_ pid: pid_t, _ path: String) -> String? { + + let PROC_PIDPATHINFO_MAXSIZE = 4096 // actual constant value from libproc.h + + if pid != 0 { + var nameBuf = [CChar](repeating: 0, count: Int(PROC_PIDPATHINFO_MAXSIZE)) + let status = proc_name(pid, &nameBuf, UInt32(nameBuf.count)) + if status >= 0 { + return String(cString: nameBuf) + } + } + + if let bundle = findAppBundle(path), + let name = bundle.infoDictionary?["CFBundleName"] as? String { + return name + } + + return (path as NSString).lastPathComponent + } + + @objc + public func getBundleID(_ path: String) -> String? { + if let bundle = findAppBundle(path), + let name = bundle.infoDictionary?["CFBundleIdentifier"] as? String { + return name + } + return nil + } + + // MARK: - Find app bundle + + @objc + public func findAppBundle(_ path: String) -> Bundle? { + let standardized = ((path as NSString).standardizingPath as NSString).resolvingSymlinksInPath + var appPath: NSString = standardized as NSString + + while true { + if let bundle = Bundle(path: appPath as String) { + if bundle.bundlePath == standardized { return bundle } + if bundle.executablePath == standardized { return bundle } + } + + let next = appPath.deletingLastPathComponent as NSString + if next.length == 0 || next as String == "/" { break } + appPath = next + } + + return nil + } + + // MARK: - Process path + + @objc + public func getProcessPath(_ pid: pid_t) -> String? { + var buf = [CChar](repeating: 0, count: Int(PROC_PIDPATHINFO_MAXSIZE)) + let status = proc_pidpath(pid, &buf, UInt32(buf.count)) + if status > 0 { + return String(cString: buf) + } else { +// os_log_error(logHandle, "ERROR: for process %d, 'proc_pidpath' failed with %d (errno: %d)", pid, status, errno) + + var mib = [CTL_KERN, KERN_ARGMAX, 0] + var argMax: Int = 0 + var size = MemoryLayout.size(ofValue: argMax) + if sysctl(&mib, 2, &argMax, &size, nil, 0) == -1 { return nil } + + let argBuf = UnsafeMutablePointer.allocate(capacity: argMax) + defer { argBuf.deallocate() } + + mib = [CTL_KERN, KERN_PROCARGS2, Int32(pid)] + size = argMax + if sysctl(&mib, 3, argBuf, &size, nil, 0) == -1 { return nil } + if size <= MemoryLayout.size { return nil } + + // argv0 path follows int argc + let pathPtr = argBuf.advanced(by: MemoryLayout.size) + let p = String(cString: pathPtr) + + if p.hasPrefix("./") { + let trimmed = String(p.dropFirst(2)) + if let cwd = getProcessCWD(pid) { + return (cwd as NSString).appendingPathComponent(trimmed) + } + } + + return p + } + } + + // MARK: - CWD + + @objc + public func getProcessCWD(_ pid: pid_t) -> String? { + var vpi = proc_vnodepathinfo() + let status = withUnsafeMutablePointer(to: &vpi) { ptr -> Int32 in + return proc_pidinfo(pid, PROC_PIDVNODEPATHINFO, 0, ptr, Int32(MemoryLayout.size)) + } + if status > 0 { + return withUnsafePointer(to: vpi.pvi_cdir.vip_path) { ptr in + return String(cString: UnsafeRawPointer(ptr).assumingMemoryBound(to: CChar.self)) + } + } + return nil + } + + // MARK: - PIDs for path/user + + @objc + public func getProcessIDs(_ processPath: String, _ userID: Int32) -> NSMutableArray { + let result = NSMutableArray() + + let count = proc_listallpids(nil, 0) + guard count > 0 else { return result } + + let pids = UnsafeMutablePointer.allocate(capacity: Int(count)) + defer { pids.deallocate() } + + let status = proc_listallpids(pids, count * Int32(MemoryLayout.size)) + guard status >= 0 else { return result } + + var mib = [CTL_KERN, KERN_PROC, KERN_PROC_PID, 0] + var kproc = kinfo_proc() + let kprocSize = MemoryLayout.size + + for i in 0.. NSImage? { + // invalid path -> generic application icon + guard FileManager.default.fileExists(atPath: path) else { + let icon = NSWorkspace.shared.icon(forFileType: NSFileTypeForHFSTypeCode(OSType(kGenericApplicationIcon))) + icon.size = NSSize(width: 128, height: 128) + return icon + } + + if let bundle = findAppBundle(path) { + if let icon = NSWorkspace.shared.icon(forFile: bundle.bundlePath) as NSImage? { + return icon + } + + if let iconFile = bundle.infoDictionary?["CFBundleIconFile"] as? NSString { + var ext = iconFile.pathExtension + if ext.isEmpty { ext = "icns" } + if let iconPath = bundle.path(forResource: iconFile.deletingPathExtension, ofType: ext) { + return NSImage(contentsOfFile: iconPath) + } + } + } + + var icon = NSWorkspace.shared.icon(forFile: path) + // replace generic document icon with application icon + let documentIcon = NSWorkspace.shared.icon(forFileType: NSFileTypeForHFSTypeCode(OSType(kGenericDocumentIcon))) + if icon == documentIcon { + icon = NSWorkspace.shared.icon(forFileType: NSFileTypeForHFSTypeCode(OSType(kGenericApplicationIcon))) + } + icon.size = NSSize(width: 128, height: 128) + return icon + } + + // MARK: - Make modal + + @objc + public func makeModal(_ controller: NSWindowController) { + var window: NSWindow? + + for _ in 0..<20 { + DispatchQueue.main.sync { + window = controller.window + } + if window == nil { + Thread.sleep(forTimeInterval: 0.05) + continue + } + DispatchQueue.main.sync { + NSApplication.shared.runModal(for: controller.window!) + } + break + } + } + + // MARK: - Find processes by name + + @objc + public func findProcesses(_ processName: String) -> NSMutableArray { + let processes = NSMutableArray() + let count = proc_listpids(UInt32(PROC_ALL_PIDS), 0, nil, 0) + guard count > 0 else { return processes } + + let pids = UnsafeMutablePointer.allocate(capacity: Int(count)) + defer { pids.deallocate() } + + let status = proc_listpids(UInt32(PROC_ALL_PIDS), 0, pids, count * Int32(MemoryLayout.size)) + guard status >= 0 else { return processes } + + for i in 0.. Date? { +// os_log_debug(logHandle, "extracting 'kMDItemDateAdded' for %{public}@", file) +// +// let url: URL +// if let bundle = findAppBundle(file) { +// url = bundle.bundleURL +// } else { +// url = URL(fileURLWithPath: file) +// } +// +// guard let item = MDItemCreateWithURL(nil, url as CFURL) else { return nil } +// defer { CFRelease(item) } +// +// if let date = MDItemCopyAttribute(item, kMDItemDateAdded)?.takeRetainedValue() as? Date { +// os_log_debug(logHandle, "extacted date, %{public}@, for %{public}@", String(describing: date), file) +// return date +// } else { +// os_log_debug(logHandle, "'kMDItemDateAdded' is nil ...falling back to 'kMDItemFSCreationDate'") +// if let date = MDItemCopyAttribute(item, kMDItemFSCreationDate)?.takeRetainedValue() as? Date { +// os_log_debug(logHandle, "extacted date, %{public}@, for %{public}@", String(describing: date), file) +// return date +// } +// } +// return nil +// } + + // MARK: - Parent PID + + @objc + public func getParent(_ pid: Int32) -> pid_t { + var kproc = kinfo_proc() + var size = MemoryLayout.size + var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid] // 👈 consistent Int32s + let res = sysctl(&mib, u_int(mib.count), &kproc, &size, nil, 0) + if res == 0 && size != 0 { + let ppid = kproc.kp_eproc.e_ppid +// os_log_debug(logHandle, "extracted parent ID %d for process: %d", ppid, pid) + return ppid + } + return -1 + } + + // MARK: - Dark mode + + @objc + public func isDarkMode() -> Bool { + return UserDefaults.standard.string(forKey: "AppleInterfaceStyle") == "Dark" + } + + // MARK: - String default + + @objc + public func valueForStringItem(_ item: String?) -> String { + return item ?? NSLocalizedString("unknown", comment: "unknown") + } + + // MARK: - Alerts + + @objc + public func showAlert(_ style: NSAlert.Style, _ messageText: String, _ informativeText: String?, _ buttons: [String]) -> NSApplication.ModalResponse { + let alert = NSAlert() + alert.alertStyle = style + alert.messageText = messageText + if let info = informativeText { + alert.informativeText = info + } + for title in buttons { + alert.addButton(withTitle: title) + } + if let first = alert.buttons.first { + first.keyEquivalent = "\r" + } + + NSApp.setActivationPolicy(.regular) + if #available(macOS 14.0, *) { + NSApp.activate() + } else { + NSApp.activate(ignoringOtherApps: true) + } + NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) + + alert.window.makeKeyAndOrderFront(nil) + alert.window.center() + + let response = alert.runModal() +// (NSApp.delegate as? AppDelegate)?.setActivationPolicy() + return response + } + + // MARK: - Audit token for pid + + // Swift doesn't expose this C macro — we define it manually. + let TASK_AUDIT_TOKEN_COUNT = mach_msg_type_number_t(MemoryLayout.size / MemoryLayout.size) + + @objc + public func tokenForPid(_ pid: pid_t) -> NSData? { + var task: mach_port_t = 0 + var token = audit_token_t() + var size = TASK_AUDIT_TOKEN_COUNT + +// os_log_debug(logHandle, "retrieving audit token for %d", pid) + + var kr = task_name_for_pid(mach_task_self_, pid, &task) + guard kr == KERN_SUCCESS else { +// os_log_error(logHandle, "ERROR: 'task_name_for_pid' failed with %x", kr) + return nil + } + defer { mach_port_deallocate(mach_task_self_, task) } + + kr = withUnsafeMutablePointer(to: &token) { ptr in + ptr.withMemoryRebound(to: integer_t.self, capacity: Int(size)) { intPtr in + return task_info(task, task_flavor_t(TASK_AUDIT_TOKEN), intPtr, &size) + } + } + + guard kr == KERN_SUCCESS else { +// os_log_error(logHandle, "ERROR: 'task_info' failed with %x", kr) + return nil + } + +// os_log_debug(logHandle, "retrieved audit token") + return NSData(bytes: &token, length: MemoryLayout.size) + } + + // MARK: - Reverse DNS resolve + + @objc + public func resolveAddress(_ ipAddr: String) -> NSArray? { +// os_log_debug(logHandle, "(attempting to) reverse resolve %{public}@", ipAddr) + + var hints = addrinfo(ai_flags: AI_NUMERICHOST, ai_family: PF_UNSPEC, ai_socktype: SOCK_STREAM, ai_protocol: 0, ai_addrlen: 0, ai_canonname: nil, ai_addr: nil, ai_next: nil) + var res: UnsafeMutablePointer? + guard getaddrinfo(ipAddr, nil, &hints, &res) == 0, let result = res else { + return nil + } + defer { freeaddrinfo(result) } + + guard let addrData = CFDataCreate(nil, UnsafePointer(OpaquePointer(result.pointee.ai_addr)), CFIndex(result.pointee.ai_addrlen)) else { + return nil + } + defer { /*addrData*/ } + + let host = CFHostCreateWithAddress(kCFAllocatorDefault, addrData).takeRetainedValue() + var streamErr = CFStreamError() + guard CFHostStartInfoResolution(host, .names, &streamErr) else { + return nil + } + guard let names = CFHostGetNames(host, nil)?.takeUnretainedValue() as NSArray? else { + return nil + } + return names + } + + // MARK: - Process alive + + @objc + public func isAlive(_ processID: pid_t) -> Bool { + errno = 0 + _ = kill(processID, 0) + if errno == ESRCH { return false } + + var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, Int32(processID)] + var info = kinfo_proc() + var size = MemoryLayout.size + if sysctl(&mib, 4, &info, &size, nil, 0) == 0 { + // SZOMB check + if (UInt8(info.kp_proc.p_stat) & UInt8(SZOMB)) == UInt8(SZOMB) { + return false + } + } + return true + } + + // MARK: - Simulator app? + + @objc + public func isSimulatorApp(_ path: String) -> Bool { +// os_log_debug(logHandle, "checking if %{public}@ is a simulator application", path) + guard let bundle = findAppBundle(path) else { return false } + guard let platforms = bundle.infoDictionary?["CFBundleSupportedPlatforms"] as? [String], !platforms.isEmpty else { + return false + } +// os_log_debug(logHandle, "supported platforms: %{public}@", String(describing: platforms)) + let set = Set(platforms) + return set.isSubset(of: Set(["iPhoneSimulator", "AppleTVSimulator"])) + } + + // MARK: - Launched by user? + + @objc + public func launchedByUser() -> Bool { + guard let parent = getRealParent(getpid()) else { return false } + let bid = parent["CFBundleIdentifier"] as? NSString + if bid == "com.apple.dock" || bid == "com.apple.finder" || bid == "com.apple.Terminal" { + return true + } + return false + } + + // MARK: - Fade out + + @objc + public func fadeOut(_ window: NSWindow, _ duration: Float) { + NSAnimationContext.runAnimationGroup { ctx in + ctx.duration = TimeInterval(duration) + window.animator().alphaValue = 0.0 + } completionHandler: { + window.close() + } + } + + // MARK: - Code-signing info match + +// @objc +// public func matchesCSInfo(_ csInfo1: NSDictionary?, _ csInfo2: NSDictionary?) -> Bool { +// var status1 = -1, status2 = -1 +// var signer1 = -1, signer2 = -1 +// var id1: String?, id2: String? +// var auths1: [Any]?, auths2: [Any]? +// +// if let n = csInfo1?[KEY_CS_STATUS] as? NSNumber { status1 = n.intValue } +// if let n = csInfo2?[KEY_CS_STATUS] as? NSNumber { status2 = n.intValue } +// if status1 != status2 { +// os_log_error(logHandle, "ERROR: code signing mismatch (signing status): %{public}@ / %{public}@", String(describing: csInfo1), String(describing: csInfo2)) +// return false +// } +// +// if let n = csInfo1?[KEY_CS_SIGNER] as? NSNumber { signer1 = n.intValue } +// if let n = csInfo2?[KEY_CS_SIGNER] as? NSNumber { signer2 = n.intValue } +// if signer1 != signer2 { +// if (signer1 == Apple && signer2 == AppStore) || (signer1 == AppStore && signer2 == Apple) { +// os_log_error(logHandle, "ignoring case where Apple App moved to/from Mac App Store: %{public}@ / %{public}@", String(describing: csInfo1), String(describing: csInfo2)) +// } else { +// os_log_error(logHandle, "ERROR: code signing mismatch (signer): %{public}@ / %{public}@", String(describing: csInfo1), String(describing: csInfo2)) +// return false +// } +// } +// +// if let s = csInfo1?[KEY_CS_ID] as? String { id1 = s } +// if let s = csInfo2?[KEY_CS_ID] as? String { id2 = s } +// if (id1 != nil || id2 != nil), id1 != id2 { +// os_log_error(logHandle, "ERROR: code signing mismatch (signing ID): %{public}@ / %{public}@", String(describing: csInfo1), String(describing: csInfo2)) +// return false +// } +// +// if let a = csInfo1?[KEY_CS_AUTHS] as? [Any] { auths1 = a } +// if let a = csInfo2?[KEY_CS_AUTHS] as? [Any] { auths2 = a } +// if (auths1 != nil || auths2 != nil) && !(auths1 as NSArray? ?? []).isEqual(to: auths2 ?? []) { +// os_log_error(logHandle, "ERROR: code signing mismatch (signing auths): %{public}@ / %{public}@", String(describing: csInfo1), String(describing: csInfo2)) +// return false +// } +// +// return true +// } + + // MARK: - Escape JSON + + @objc + public func toEscapedJSON(_ input: String) -> String? { + do { + let data = try JSONSerialization.data(withJSONObject: input, options: .fragmentsAllowed) + return String(data: data, encoding: .utf8) + } catch { +// os_log_error(logHandle, "ERROR: failed to convert/escape %{public}@ to JSON (error: %{public}@)", input, error.localizedDescription) + return nil + } + } + + // MARK: - Absolute date from HH:mm (next 24h) + + @objc + public func absoluteDate(_ date: Date) -> Date { +// os_log_debug(logHandle, "function '%{public}s' invoked with %{public}@", #function, String(describing: date)) + + let now = Date() + let cal = Calendar.current + + let comps = cal.dateComponents([.hour, .minute], from: date) + var nowComps = cal.dateComponents([.year, .month, .day, .hour, .minute], from: now) + nowComps.hour = comps.hour + nowComps.minute = comps.minute + + var absDate = cal.date(from: nowComps) ?? now + if absDate < now { + absDate = cal.date(byAdding: .day, value: 1, to: absDate) ?? absDate + } + return absDate + } + + // MARK: - Internal volume? + + @objc + public func isInternalProcess(_ path: String) -> Bool { + var isInternal: AnyObject? + do { + var url = URL(fileURLWithPath: path) + try (url as NSURL).getResourceValue(&isInternal, forKey: URLResourceKey.volumeIsInternalKey) + return (isInternal as? NSNumber)?.boolValue ?? false + } catch { +// os_log_error(logHandle, "ERROR: 'getResourceValue'/'NSURLVolumeIsInternalKey' failed with %@", error.localizedDescription) + return false + } + } + + +} + diff --git a/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/README.md b/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/README.md new file mode 100644 index 00000000..234977c0 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/README.md @@ -0,0 +1,8 @@ +# SelfControlExtension + +This directory contains the NetworkExtension implementation for SelfControl, responsible for URL and domain filtering. + +## Structure + +- `FilterProvider/` - NetworkExtension filter provider implementation +- `Configuration/` - Extension configuration code \ No newline at end of file diff --git a/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/RequestSourceAppValidator.swift b/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/RequestSourceAppValidator.swift new file mode 100644 index 00000000..9e74863d --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/RequestSourceAppValidator.swift @@ -0,0 +1,38 @@ +// +// HostAppValidator.swift +// SelfControl +// +// Created by Satendra Singh on 29/11/25. +// + +import NetworkExtension +import OSLog + +struct RequestSourceAppValidator { + + static func isAllowedHost(flow: NEFilterFlow) -> Bool { + let process = processFromflow(flow: flow) +// os_log(" FilterDataProvider: appIdAndName %{public}@, %{public}@", log: OSLog.default, type: .debug, process?.name ?? "", process?.path ?? "") + os_log(" FilterDataProvider: app %{public}@", log: OSLog.default, type: .debug, process?.description ?? "") + let isSafariExtensionEnabled = IPCConnection.shared.isSafariExtensionEnable + let isChromeExtensionEnabled = IPCConnection.shared.isGoogleChromeEnabled + + if let process = process, AllowedProcess.isAllowedProcess(process, isSafariExtensionActive: isSafariExtensionEnabled, isChromeExtensionActive: isChromeExtensionEnabled) { +// os_log("[SC] 🔍] handleNewFlow: allowing as it is in allowed list.") + return true + } + return false + } + + static func processFromflow(flow: NEFilterFlow) -> Process? { + guard let auditTokenData = flow.sourceAppAuditToken else { + return nil + } + + // Convert NSData → audit_token_tdf + var auditToken = auditTokenData.withUnsafeBytes { ptr -> audit_token_t in + return ptr.load(as: audit_token_t.self) + } + return Process.init(&auditToken) + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/ReverseDomainMapper.swift b/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/ReverseDomainMapper.swift new file mode 100644 index 00000000..0610f54e --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/ReverseDomainMapper.swift @@ -0,0 +1,57 @@ +// +// ReverseDomainMapper.swift +// SelfControlExtension +// +// Created by Satendra Singh on 14/10/25. +// + +import Foundation + +final class ReverseDomainMapper { + static func reverseDNSUsingGetNameInfo(ipAddress: String) -> String? { + var addr = sockaddr_in() + addr.sin_len = UInt8(MemoryLayout.size) + addr.sin_family = sa_family_t(AF_INET) + inet_pton(AF_INET, ipAddress, &addr.sin_addr) + + var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + + // Precompute length so we don't read from `addr` inside the closure. + let addrLen = socklen_t(addr.sin_len) + + let result: Int32 = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { saPtr in + getnameinfo(saPtr, + addrLen, + &hostname, socklen_t(hostname.count), + nil, 0, + NI_NAMEREQD) + } + } + + guard result == 0 else { return nil } + return String(cString: hostname) + } +} + +enum Regex { + static let ipAddress = "^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$" + static let hostname = "^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\\-]*[a-zA-Z0-9])\\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\\-]*[A-Za-z0-9])$" +} + +extension String { + var isValidIpAddress: Bool { + return self.matches(pattern: Regex.ipAddress) + } + + var isValidHostname: Bool { + return self.matches(pattern: Regex.hostname) + } + + private func matches(pattern: String) -> Bool { + return self.range(of: pattern, + options: .regularExpression, + range: nil, + locale: nil) != nil + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/SelfControlExtension.entitlements b/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/SelfControlExtension.entitlements new file mode 100644 index 00000000..f6ded53d --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/SelfControlExtension.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.developer.networking.networkextension + + content-filter-provider-systemextension + + com.apple.security.application-groups + + $(TeamIdentifierPrefix)com.application.SelfControl.corebits + + + diff --git a/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/String+URL.swift b/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/String+URL.swift new file mode 100644 index 00000000..b2758df0 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/String+URL.swift @@ -0,0 +1,35 @@ +// +// String+URL.swift +// SelfControlExtension +// +// Created by Satendra Singh on 05/08/25. +// + +import Foundation + +extension String { + var domainString: String? { + Self.extractDomain(from: self) + } + + static func extractDomain(from urlString: String) -> String? { + var formattedURLString = urlString + if !formattedURLString.lowercased().hasPrefix("http://") && + !formattedURLString.lowercased().hasPrefix("https://") { + formattedURLString = "https://" + formattedURLString + } + + guard let url = URL(string: formattedURLString), + let host = url.host else { + return nil + } + + // Optional: extract root domain (e.g., "facebook.com" from "sub.facebook.com") + let components = host.components(separatedBy: ".") + if components.count >= 2 { + return components.suffix(2).joined(separator: ".") + } else { + return host + } + } +} diff --git a/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/TLDURLToDomain.swift b/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/TLDURLToDomain.swift new file mode 100644 index 00000000..f652a950 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/TLDURLToDomain.swift @@ -0,0 +1,49 @@ +// +// TLDURLToDomain.swift +// SelfControlExtension +// +// Created by Satendra Singh on 15/10/25. +// + +import Foundation + +final class TLDURLToDomain { + func domain(for tldURL: URL) -> String? { + return tldURL.host + } + + private func extractDomain(from url: URL) -> String? { + return url.host + } + + + func getDomain(from urlString: String) -> String? { + guard let url = URL(string: urlString) else { + print("Invalid URL string.") + return nil + } + + // Using URL.host + if let host = url.host { + return host + } + return nil + } + + static func getURLDomain(from input: String) -> String? { + // Normalize input to ensure it has a scheme + let formatted = input.contains("://") ? input : "https://\(input)" + guard let host = URL(string: formatted)?.host else { return nil } + + let parts = host.split(separator: ".") + guard parts.count >= 2 else { return host } + + // Return last two parts (e.g., facebook.com, google.co.uk) + if parts.count >= 3 && parts[parts.count - 2] == "co" { + return parts.suffix(3).joined(separator: ".") // handles e.g. google.co.uk + } else { + return parts.suffix(2).joined(separator: ".") + } + } + +} diff --git a/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/main.swift b/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/main.swift new file mode 100644 index 00000000..ffadae74 --- /dev/null +++ b/Self-Control-Extension/SelfControl/SelfControlNetworkExtension/main.swift @@ -0,0 +1,18 @@ +// +// main.swift +// SelfControlExtension +// +// Created by Egzon Arifi on 02/04/2025. +// + +import Foundation +import NetworkExtension +import os.log + +autoreleasepool { + os_log("[SC] 🔍] first light") + NEProvider.startSystemExtensionMode() + IPCConnection.shared.startListener() +} + +dispatchMain() diff --git a/Self-Control-Extension/SelfControl/Sparkle/dsa_pub.pem b/Self-Control-Extension/SelfControl/Sparkle/dsa_pub.pem new file mode 100644 index 00000000..950143dc --- /dev/null +++ b/Self-Control-Extension/SelfControl/Sparkle/dsa_pub.pem @@ -0,0 +1,36 @@ +-----BEGIN PUBLIC KEY----- +MIIGRzCCBDkGByqGSM44BAEwggQsAoICAQD7ZLyF2yKAuctR4LPKxlhWtZVJgCyz +YhUuEk/Rxj+3n4g+pc+ogRamWCxYMLfijMEXNNnc14tsZfgyVB2lGq2skuyrrEkq +W/m4glMhXJU80JYiZm2mteSztCG18zWJMq6XVw5MW3xFrceD8PevmtuXugqKhjPN +6a5qlavLUPzWiyuDcS34UgxgEGX6L4qv36h++EFUfAytEs/PaVEINf4Aq3B32k9k +I2aHOJathNoWA/GwtROJQ1biupKMJaDlkMBLlULKWJ6XyYqIbaVmgwOoxVvHN3O0 +pWRlP3qJaO8nG/qaLlzQU2vv26uRa4vTfmTfpRQbwCZVxg7rSvAkalJRcKw/beBg +5FIUiF5UhU9/q11TeK4yefPFMxKv6dkpnrVY8YGsiKul+bHP3n66pfVjcqACL0sb +KUfJPmyg/ass5d6L+nTJJMPuJFU6+AQxsigXQQQ4ijUVSmKVICrHumugUCUozHrz +X6UmZz4eY3BPSTX3hxxY6L43xVAYA8DMAKVSmc0k5B1p7QP1Du7uXTrP806ZbOtt +A0Rn8Zty+cuBJ8MH0t8sKJ1EDCHLqD5bInfAyg5kQ+DxOIiXKbVpcjGCTir0fgvh +Ex+ePJkZpFfXHwddEwlIYQCHHGiAytpGSTpql86fwGGOPIq1waImNwsdONDGUYDF +dVR7dNo0PBBPJwIhAOxFdosK/0KcNo+CQ0Iw6JpWJzqwTo6V+LUfiq2ZOvMFAoIC +AGFnEtGMNtaBe+xM4ihWyH1UBtlgjRfZpY6CCdcoFFzqujnHyt9k+3A2cSYwFr7w +TB+0BCtvkqyPgOOlsYSOCVJcwyBAVHxKjRr+QjyslQ7f6RFpjmE0vjCfuwcgTia8 +nqsXAQCkeD+Tenq9+nbYD8hTDyFD0XpoHHfgXDgFKZBGtNNA6738aGUfMxfUSHBz +HIOPvJcwVnl82gv+EJXZ3Bt07MX1ETGtTpuXqwnN8XhV/BiAK4jny+K0OgNGQKQf +T7DFtz+kq5MR9/urLhHrs1d7SEq1lL8EK1G+m75DMq94H9Z6VjYTYl1U6UAuORKU +r/Aq07YC7wyO0BX4JTSIQi+VzIR663Y2+tGcX07r7oEzUE9E3wIcMfSSjKYV+Keq +5bYHpz5VZC1MeE+9x+vrLvnt5v/8d344HABVtfEFWw1haMGbtmKIAYE5NnvzIn+O +iIvVdWUEbUdPNlHe2MTjU0EWk2GBmMSQdsiMPohf0DugKtfd5Flv+H9t8JC+pDMm +cNEcpCzOIvtun8FtYC8hftdFSUUL2y91ESZtUTAbxAd1RY+aWq5ySOFSL4Lr7+8p +LfYryIAep7AmE0DKTZXzebXSmptXhsafBa2CEJZa+86Gw3TtJe+ya97JXVRW6lVz +PKA1ypLoNbA2Rfu4xrczG3k895few/KA7m49x5p94KcjA4ICBgACggIBAOhmgPtB +tpW1QxiIbLqI952kUk0AnFjfLrWAo3VfS4rCxs4tltoKAORxi+rFn6h3PIvatmpy +jTXg1ZhICZ1rx/OP1/gbG+uCw4NF0Sro6+IhyYK65H6ZeM5/MUMYdpoSucrb7z44 +H3yi5xf/S+rKew+fnFDOwAFChtUgoRW9HzNJZpd/AvpVSPARlDeanYBwXAPGgKFL +o9DeJR1Soab/UdIlkPpjy1xTTVzrnkx15g5gaE0BWb93CVfDqvi7dRCr28FNvSIz +nmBJnqZiShw+WJ0RdG35tfeiQ51DQC/8j02vtAZ6ZVfug3q34+i1UxJj/QO+u42y +imDk2hAGXQAB3h8DfJevjNRngeGTRzV4jOHL98ha5cRQgKK5vqgQWMRb4bSuGwLc +bBjQDDwBbdbHc2IL0k1ff56pZ+tA7z3k6d2nKGjIy4CyTD1Mc4gROx4p4/xAL1tf +bhFbihFU0W+sIdBEllIyrVkk+NXXpQrBB0YRXK/Fy3gIk8yzFOAeiNc2NTF6kgt5 +AXAprRlexSX17hIUPi0V000mTX5GqNFMPmM26zxIXXSr0VApjLhslUarbtbSQ27a +qNAXGuDQkaZMs+jo29K/dQvLXKeyPHpGotfgvn3xsC1KnSiIgcm0z+EjLAuRVjqC +CDx3nE+2CbIFoCzJUlEdTgwaHp/vmUKxMmG8 +-----END PUBLIC KEY----- diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/AllowlistScraper.h b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/AllowlistScraper.h new file mode 100644 index 00000000..179dda9e --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/AllowlistScraper.h @@ -0,0 +1,17 @@ +// +// AllowlistScraper.h +// SelfControl +// +// Created by Charles Stigler on 10/7/14. +// +// + +#import + +@class SCBlockEntry; + +@interface AllowlistScraper : NSObject + ++ (NSSet*)relatedBlockEntries:(NSString*)domain; + +@end diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/AllowlistScraper.m b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/AllowlistScraper.m new file mode 100644 index 00000000..de35de52 --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/AllowlistScraper.m @@ -0,0 +1,73 @@ +// +// AllowlistScraper.m +// SelfControl +// +// Created by Charles Stigler on 10/7/14. +// +// + +#import "AllowlistScraper.h" +#import "SCBlockEntry.h" + +@implementation AllowlistScraper + ++ (NSSet*)relatedBlockEntries:(NSString*)domain; { + NSURL* rootURL = [NSURL URLWithString: [NSString stringWithFormat: @"http://%@", domain]]; + if (!rootURL) { + return nil; + } + + // stale data is OK + NSURLRequest* request = [NSURLRequest requestWithURL: rootURL cachePolicy: NSURLRequestReturnCacheDataElseLoad timeoutInterval: 5]; + NSURLResponse* response = nil; + NSError* error = nil; + NSData* data = [NSURLConnection sendSynchronousRequest: request returningResponse: &response error: &error]; + if (!response) { + return nil; + } + NSString* html = [[NSString alloc] initWithData: data encoding: NSUTF8StringEncoding]; + if (!html) { + return nil; + } + + // these sites are often featured in "share links" on a wide variety of websites + // we really don't want to add them to the allowlist, since they're also + // super distracting. So explicitly flag them to skip in this process + NSArray* neverAddSites = @[ + @"instagram.com", + @"www.instagram.com", + @"twitter.com", + @"www.twitter.com", + @"facebook.com", + @"www.facebook.com", + @"reddit.com", + @"www.reddit.com", + @"youtube.com", + @"www.youtube.com", + @"pinterest.com", + @"plus.google.com", + @"www.pinterest.com", + @"linkedin.com", + @"www.linkedin.com", + @"tumblr.com", + @"www.tumblr.com" + ]; + + NSDataDetector* dataDetector = [[NSDataDetector alloc] initWithTypes: NSTextCheckingTypeLink error: nil]; + NSCountedSet* relatedEntries = [NSCountedSet set]; + [dataDetector enumerateMatchesInString: html + options: kNilOptions + range: NSMakeRange(0, [html length]) + usingBlock:^(NSTextCheckingResult* result, NSMatchingFlags flags, BOOL* stop) { + if (([result.URL.scheme isEqualToString: @"http"] || [result.URL.scheme isEqualToString: @"https"]) + && [result.URL.host length] + && ![result.URL.host isEqualToString: rootURL.host] + && ![neverAddSites containsObject: result.URL.host]) { + [relatedEntries addObject: [SCBlockEntry entryWithHostname: result.URL.host]]; + } + }]; + + return relatedEntries; +} + +@end diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/BlockManager.h b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/BlockManager.h new file mode 100644 index 00000000..3aa31d2d --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/BlockManager.h @@ -0,0 +1,62 @@ +// +// BlockManager.h +// SelfControl +// +// Created by Charlie Stigler on 2/5/13. +// Copyright 2009 Eyebeam. + +// This file is part of SelfControl. +// +// SelfControl is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +#import +#import "PacketFilter.h" +#import "NSString+IPAddress.h" + +@class SCBlockEntry; +@class HostFileBlockerSet; + +@interface BlockManager : NSObject { + NSOperationQueue* opQueue; + PacketFilter* pf; + HostFileBlockerSet* hostBlockerSet; + BOOL hostsBlockingEnabled; + BOOL isAllowlist; + BOOL allowLocal; + BOOL includeCommonSubdomains; + BOOL includeLinkedDomains; + NSMutableSet* addedBlockEntries; +} + +- (BlockManager*)initAsAllowlist:(BOOL)allowlist; +- (BlockManager*)initAsAllowlist:(BOOL)allowlist allowLocal:(BOOL)local; +- (BlockManager*)initAsAllowlist:(BOOL)allowlist allowLocal:(BOOL)local includeCommonSubdomains:(BOOL)blockCommon; +- (BlockManager*)initAsAllowlist:(BOOL)allowlist allowLocal:(BOOL)local includeCommonSubdomains:(BOOL)blockCommon includeLinkedDomains:(BOOL)includeLinked; + +- (void)enterAppendMode; +- (void)finishAppending; +- (void)prepareToAddBlock; +- (void)finalizeBlock; +- (void)addBlockEntryFromString:(NSString*)entry; +- (void)addBlockEntry:(SCBlockEntry*)entry; +- (void)addBlockEntriesFromStrings:(NSArray*)blockList; +- (BOOL)clearBlock; +- (BOOL)forceClearBlock; +- (BOOL)blockIsActive; + +- (NSArray*)commonSubdomainsForHostName:(NSString*)hostName; ++ (NSArray*)ipAddressesForDomainName:(NSString*)domainName; +- (BOOL)domainIsGoogle:(NSString*)domainName; + +@end diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/BlockManager.m b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/BlockManager.m new file mode 100644 index 00000000..d7989f51 --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/BlockManager.m @@ -0,0 +1,531 @@ +// +// BlockManager.m +// SelfControl +// +// Created by Charles Stigler on 2/5/13. +// Copyright 2009 Eyebeam. + +// This file is part of SelfControl. +// +// SelfControl is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +#import "BlockManager.h" +#import "AllowlistScraper.h" +#import "SCBlockEntry.h" +#include +#include +#import "HostFileBlockerSet.h" + +@implementation BlockManager + +BOOL appendMode = NO; + +- (BlockManager*)init { + return [self initAsAllowlist: NO allowLocal: YES includeCommonSubdomains: YES]; +} + +- (BlockManager*)initAsAllowlist:(BOOL)allowlist { + return [self initAsAllowlist: allowlist allowLocal: YES includeCommonSubdomains: YES]; +} + +- (BlockManager*)initAsAllowlist:(BOOL)allowlist allowLocal:(BOOL)local { + return [self initAsAllowlist: allowlist allowLocal: local includeCommonSubdomains: YES]; +} +- (BlockManager*)initAsAllowlist:(BOOL)allowlist allowLocal:(BOOL)local includeCommonSubdomains:(BOOL)blockCommon { + return [self initAsAllowlist: allowlist allowLocal: local includeCommonSubdomains: blockCommon includeLinkedDomains: YES]; +} + +- (BlockManager*)initAsAllowlist:(BOOL)allowlist allowLocal:(BOOL)local includeCommonSubdomains:(BOOL)blockCommon includeLinkedDomains:(BOOL)includeLinked { + if(self = [super init]) { + opQueue = [[NSOperationQueue alloc] init]; + [opQueue setMaxConcurrentOperationCount: 35]; + + pf = [[PacketFilter alloc] initAsAllowlist: allowlist]; + hostBlockerSet = [[HostFileBlockerSet alloc] init]; + hostsBlockingEnabled = NO; + + isAllowlist = allowlist; + allowLocal = local; + includeCommonSubdomains = blockCommon; + includeLinkedDomains = includeLinked; + addedBlockEntries = [NSMutableSet set]; + } + + return self; +} + +- (void)prepareToAddBlock { + for (HostFileBlocker* blocker in hostBlockerSet.blockers) { + if([blocker containsSelfControlBlock]) { + [blocker removeSelfControlBlock]; + [blocker writeNewFileContents]; + } + } + + if(!isAllowlist && ![hostBlockerSet.defaultBlocker containsSelfControlBlock]) { + [hostBlockerSet createBackupHostsFile]; + [hostBlockerSet addSelfControlBlockHeader]; + hostsBlockingEnabled = YES; + } else { + hostsBlockingEnabled = NO; + } +} + +- (void)enterAppendMode { + if (isAllowlist) { + NSLog(@"ERROR: can't append to allowlist block"); + return; + } + if(![hostBlockerSet.defaultBlocker containsSelfControlBlock]) { + NSLog(@"ERROR: can't append to hosts block that doesn't yet exist"); + return; + } + + hostsBlockingEnabled = YES; + appendMode = YES; + [pf enterAppendMode]; +} +- (void)finishAppending { + NSLog(@"BlockManager: About to run operation queue for appending..."); + NSDate* startedRunning = [NSDate date]; + [opQueue waitUntilAllOperationsAreFinished]; + NSDate* finishedRunning = [NSDate date]; + NSTimeInterval runTime = [finishedRunning timeIntervalSinceDate: startedRunning]; + NSLog(@"BlockManager: Operation queue ran in %f seconds!", runTime); + + [hostBlockerSet writeNewFileContents]; + [pf finishAppending]; + [pf refreshPFRules]; + appendMode = NO; +} + +- (void)finalizeBlock { + NSLog(@"BlockManager: About to run operation queue..."); + NSDate* startedRunning = [NSDate date]; + [opQueue waitUntilAllOperationsAreFinished]; + NSDate* finishedRunning = [NSDate date]; + NSTimeInterval runTime = [finishedRunning timeIntervalSinceDate: startedRunning]; + NSLog(@"BlockManager: Operation queue ran in %f seconds!", runTime); + + if(hostsBlockingEnabled) { + [hostBlockerSet addSelfControlBlockFooter]; + [hostBlockerSet writeNewFileContents]; + } + + [pf startBlock]; +} + +- (void)enqueueBlockEntry:(SCBlockEntry*)entry { + NSBlockOperation* op = [NSBlockOperation blockOperationWithBlock:^{ + [self addBlockEntry: entry]; + }]; + [opQueue addOperation: op]; +} + +- (void)addBlockEntry:(SCBlockEntry*)entry { + // nil entries = something didn't parse right + if (entry == nil) return; + + // NSMutableSet is NOT thread-safe + @synchronized (addedBlockEntries) { + // don't try to block the same thing twice + if ([addedBlockEntries containsObject: entry]) { + return; + } + [addedBlockEntries addObject: entry]; + } + + BOOL isIP = [entry.hostname isValidIPAddress]; + BOOL isIPv4 = [entry.hostname isValidIPv4Address]; + + if([entry.hostname isEqualToString: @"*"]) { + [pf addRuleWithIP: nil port: entry.port maskLen: 0]; + } else if(isIPv4) { // current we do NOT do ipfw blocking for IPv6 + [pf addRuleWithIP: entry.hostname port: entry.port maskLen: entry.maskLen]; + } else if(!isIP) { // domain name + // Google requires special handling + if ([self domainIsGoogle: entry.hostname]) { + if (isAllowlist) { + // just add the whole Google IP range, it's way too error-prone to do an allowlist block of Google any other way + // last updated: 9/23/21 from https://www.gstatic.com/ipranges/goog.json + [self addGoogleIPsToPF]; + } + // for blocklist blocks, just skip blocking Google by IP + // because we'd end up blocking more than the user wants (i.e. Search/Mail) + // rely on the domain-level blocking instead + } else { + // non-Google domains just get looked up and blocked by IP + NSArray* addresses = [BlockManager ipAddressesForDomainName: entry.hostname]; + + for(NSUInteger i = 0; i < [addresses count]; i++) { + NSString* ip = addresses[i]; + + [pf addRuleWithIP: ip port: entry.port maskLen: entry.maskLen]; + } + } + } + + if(hostsBlockingEnabled && ![entry.hostname isEqualToString: @"*"] && !entry.port && !isIP) { + if (appendMode) { + [hostBlockerSet appendExistingBlockWithRuleForDomain: entry.hostname]; + } else { + [hostBlockerSet addRuleBlockingDomain: entry.hostname]; + } + } +} + +- (void)addBlockEntryFromString:(NSString*)entryString { + SCBlockEntry* entry = [SCBlockEntry entryFromString: entryString]; + + // nil means that we don't have anything valid to block in this entry + if (entry == nil) return; + + // enqueue new entries _before_ running this one, so they can happen in parallel + NSArray* relatedEntries = [self relatedBlockEntriesForEntry: entry]; + for (SCBlockEntry* relatedEntry in relatedEntries) { + [self enqueueBlockEntry: relatedEntry]; + } + + [self addBlockEntry: entry]; +} + +- (void)addBlockEntriesFromStrings:(NSArray*)blockList { + for(NSUInteger i = 0; i < [blockList count]; i++) { + NSBlockOperation* op = [NSBlockOperation blockOperationWithBlock:^{ + [self addBlockEntryFromString: blockList[i]]; + }]; + [opQueue addOperation: op]; + } +} + +- (BOOL)clearBlock { + [pf stopBlock: false]; + BOOL pfSuccess = ![pf containsSelfControlBlock]; + + [hostBlockerSet removeSelfControlBlock]; + BOOL hostSuccess = [hostBlockerSet writeNewFileContents]; + // Revert the host file blocker's file contents to disk so we can check + // whether or not it still contains the block (aka we messed up). + [hostBlockerSet revertFileContentsToDisk]; + hostSuccess = hostSuccess && ![hostBlockerSet containsSelfControlBlock]; + + BOOL clearedSuccessfully = hostSuccess && pfSuccess; + + if(clearedSuccessfully) + NSLog(@"INFO: Block successfully cleared."); + else { + if (!pfSuccess) { + NSLog(@"WARNING: Error clearing pf block. Tring to clear using force."); + [pf stopBlock: true]; + } + if (!hostSuccess) { + NSLog(@"WARNING: Error removing hostfile block. Attempting to restore host file backup."); + [hostBlockerSet restoreBackupHostsFile]; + } + + clearedSuccessfully = ![self blockIsActive]; + + if ([hostBlockerSet.defaultBlocker containsSelfControlBlock]) { + NSLog(@"ERROR: Host file backup could not be restored. This may result in a permanent block."); + } + if ([pf containsSelfControlBlock]) { + NSLog(@"ERROR: Firewall rules could not be cleared. This may result in a permanent block."); + } + if (clearedSuccessfully) { + NSLog(@"INFO: Firewall rules successfully cleared."); + } + } + + [hostBlockerSet deleteBackupHostsFile]; + + return clearedSuccessfully; +} + +- (BOOL)forceClearBlock { + [pf stopBlock: YES]; + BOOL pfSuccess = ![pf containsSelfControlBlock]; + + [hostBlockerSet removeSelfControlBlock]; + BOOL hostSuccess = [hostBlockerSet writeNewFileContents]; + // Revert the host file blocker's file contents to disk so we can check + // whether or not it still contains the block (aka we messed up). + [hostBlockerSet revertFileContentsToDisk]; + hostSuccess = hostSuccess && ![hostBlockerSet containsSelfControlBlock]; + + BOOL clearedSuccessfully = hostSuccess && pfSuccess; + + if(clearedSuccessfully) + NSLog(@"INFO: Block successfully cleared."); + else { + if (!pfSuccess) { + NSLog(@"ERROR: Error clearing pf block. This may result in a permanent block."); + } + if (!hostSuccess) { + NSLog(@"WARNING: Error removing hostfile block. Attempting to restore host file backup."); + [hostBlockerSet restoreBackupHostsFile]; + } + + clearedSuccessfully = ![self blockIsActive]; + + if ([hostBlockerSet.defaultBlocker containsSelfControlBlock]) { + NSLog(@"ERROR: Host file backup could not be restored. This may result in a permanent block."); + } + if (clearedSuccessfully) { + NSLog(@"INFO: Firewall rules successfully cleared."); + } + } + + return clearedSuccessfully; +} + +- (BOOL)blockIsActive { + return [hostBlockerSet.defaultBlocker containsSelfControlBlock] || [pf containsSelfControlBlock]; +} + +- (NSArray*)commonSubdomainsForHostName:(NSString*)hostName { + NSMutableSet* newHosts = [NSMutableSet set]; + + // If the domain ends in facebook.com... Special case for Facebook because + // users will often forget to block some of its many mirror subdomains that resolve + // to different IPs, i.e. hs.facebook.com. Thanks to Danielle for raising this issue. + if([hostName hasSuffix: @"facebook.com"]) { + // pulled list of facebook IP ranges from https://developers.facebook.com/docs/sharing/webmasters/crawler + // TODO: pull these automatically by running: + // whois -h whois.radb.net -- '-i origin AS32934' | grep ^route + // (looks like they now use 2 different AS numbers: https://www.facebook.com/peering/) + NSArray* facebookIPs = @[@"31.13.24.0/21", + @"31.13.64.0/18", + @"45.64.40.0/22", + @"66.220.144.0/20", + @"69.63.176.0/20", + @"69.171.224.0/19", + @"74.119.76.0/22", + @"102.132.96.0/20", + @"103.4.96.0/22", + @"129.134.0.0/16", + @"147.75.208.0/20", + @"157.240.0.0/16", + @"173.252.64.0/18", + @"179.60.192.0/22", + @"185.60.216.0/22", + @"185.89.216.0/22", + @"199.201.64.0/22", + @"204.15.20.0/22"]; + + [newHosts addObjectsFromArray: facebookIPs]; + } + if ([hostName hasSuffix: @"twitter.com"]) { + [newHosts addObject: @"api.twitter.com"]; + } + + if ([hostName hasSuffix: @"netflix.com"]) { + [newHosts addObject: @"assets.nflxext.com"]; + [newHosts addObject: @"codex.nflxext.com"]; + [newHosts addObject: @"nflxext.com"]; + } + + // Block the domain with no subdomains, if www.domain is blocked + if([hostName rangeOfString: @"www."].location == 0) { + [newHosts addObject: [hostName substringFromIndex: 4]]; + } else { // Or block www.domain otherwise + [newHosts addObject: [@"www." stringByAppendingString: hostName]]; + } + + return [newHosts allObjects]; +} + +// by Jakob Egger, taken from: https://eggerapps.at/blog/2014/hostname-lookups.html ++ (NSString*)stringForAddress:(NSData*)addressData error:(NSError**)outError { + char hbuf[NI_MAXHOST]; + int gai_error = getnameinfo(addressData.bytes, (socklen_t)addressData.length, hbuf, NI_MAXHOST, NULL, 0, NI_NUMERICHOST); + if (gai_error) { + if (outError) *outError = [NSError errorWithDomain:@"MyDomain" code:gai_error userInfo:@{NSLocalizedDescriptionKey:@(gai_strerror(gai_error))}]; + return nil; + } + return [NSString stringWithUTF8String:hbuf]; +} ++ (NSArray*)ipAddressesForDomainName:(NSString*)domainName { + if(domainName == nil) return @[]; + + NSDate* startedResolving = [NSDate date]; + CFHostRef cfHost = CFHostCreateWithName(kCFAllocatorDefault, (__bridge CFStringRef)domainName); + CFStreamError streamErr; + // TODO: call CFHostsScheduleWithRunLoop to put this on a background thread, so we can cancel/timeout early + CFHostStartInfoResolution(cfHost, kCFHostAddresses, &streamErr); + if (streamErr.error) { + NSLog(@"BlockManager: Warning: failed to resolve addresses for %@ with stream error", domainName); + CFRelease(cfHost); + return @[]; + } + + NSArray* addresses = (__bridge NSArray*)CFHostGetAddressing(cfHost, NULL); + + NSMutableArray* stringAddresses = [NSMutableArray array]; + if (addresses != NULL) { + for (NSData* addrData in addresses) { + NSError* parseErr; + NSString* ipStr = [BlockManager stringForAddress: addrData error: &parseErr]; + if (ipStr) { + [stringAddresses addObject: ipStr]; + } else { + NSLog(@"BlockManager: Warning: Failed to parse IP struct for domain %@ with error %@", domainName, parseErr); + } + } + } else { + NSLog(@"BlockManager: Warning: failed to resolve addresses for %@", domainName); + } + + // log slow resolutions + NSDate* finishedResolving = [NSDate date]; + NSTimeInterval resolutionTime = [finishedResolving timeIntervalSinceDate: startedResolving]; + if (resolutionTime > 2.5) { + NSLog(@"BlockManager: Warning: took %f seconds to resolve %@", resolutionTime, domainName); + } + + CFRelease(cfHost); + + return stringAddresses; +} + ++ (NSPredicate*)googleTesterPredicate { + static NSPredicate* pred = nil; + + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + NSString* googleRegex = @"^([a-z0-9]+\\.)*(google|youtube|picasa|sketchup|blogger|blogspot)\\.([a-z]{1,3})(\\.[a-z]{1,3})?$"; + pred = [NSPredicate + predicateWithFormat: @"SELF MATCHES %@", + googleRegex + ]; + }); + + return pred; +} +- (BOOL)domainIsGoogle:(NSString*)domainName { + return [[BlockManager googleTesterPredicate] evaluateWithObject: domainName]; +} + +- (NSArray*)relatedBlockEntriesForEntry:(SCBlockEntry*)entry { + // nil means that we don't have anything valid to block in this entry, therefore no related entries either + if (entry == nil) return @[]; + + NSMutableArray* relatedEntries = [NSMutableArray array]; + + if (isAllowlist && includeLinkedDomains && ![entry.hostname isValidIPAddress]) { + NSDate* startedScraping = [NSDate date]; + NSArray* scrapedEntries = [[AllowlistScraper relatedBlockEntries: entry.hostname] allObjects]; + NSDate* finishedScraping = [NSDate date]; + NSTimeInterval resolutionTime = [finishedScraping timeIntervalSinceDate: startedScraping]; + if (resolutionTime > 5.0) { + NSLog(@"BlockManager: Warning: allowlist scraper took %f seconds on %@", resolutionTime, entry.hostname); + } + [relatedEntries addObjectsFromArray: scrapedEntries]; + } + + if(![entry.hostname isValidIPAddress] && includeCommonSubdomains) { + NSArray* commonSubdomains = [self commonSubdomainsForHostName: entry.hostname]; + + for (NSString* subdomain in commonSubdomains) { + // we do not pull port, we leave the port number the same as we got it + SCBlockEntry* subdomainEntry = [SCBlockEntry entryFromString: subdomain]; + + if (subdomainEntry == nil) continue; + + [relatedEntries addObject: subdomainEntry]; + } + } + + return relatedEntries; +} + +- (void)addGoogleIPsToPF { + [pf addRuleWithIP: @"8.8.4.0" port: 0 maskLen: 24]; + [pf addRuleWithIP: @"8.34.208.0" port: 0 maskLen: 20]; + [pf addRuleWithIP: @"8.35.192.0" port: 0 maskLen: 20]; + [pf addRuleWithIP: @"23.236.48.0" port: 0 maskLen: 240]; + [pf addRuleWithIP: @"23.251.128.0" port: 0 maskLen: 19]; + [pf addRuleWithIP: @"34.64.0.0" port: 0 maskLen: 10]; + [pf addRuleWithIP: @"8.8.4.0" port: 0 maskLen: 24]; + [pf addRuleWithIP: @"8.8.4.0" port: 0 maskLen: 24]; + [pf addRuleWithIP: @"8.8.4.0" port: 0 maskLen: 24]; + [pf addRuleWithIP: @"8.8.4.0" port: 0 maskLen: 24]; + [pf addRuleWithIP: @"8.8.4.0" port: 0 maskLen: 24]; + [pf addRuleWithIP: @"34.128.0.0" port: 0 maskLen: 10]; + [pf addRuleWithIP: @"35.184.0.0" port: 0 maskLen: 13]; + [pf addRuleWithIP: @"35.192.0.0" port: 0 maskLen: 14]; + [pf addRuleWithIP: @"35.196.0.0" port: 0 maskLen: 15]; + [pf addRuleWithIP: @"35.198.0.0" port: 0 maskLen: 16]; + [pf addRuleWithIP: @"35.199.0.0" port: 0 maskLen: 17]; + [pf addRuleWithIP: @"35.199.128.0" port: 0 maskLen: 18]; + [pf addRuleWithIP: @"35.200.0.0" port: 0 maskLen: 13]; + [pf addRuleWithIP: @"35.208.0.0" port: 0 maskLen: 12]; + [pf addRuleWithIP: @"35.224.0.0" port: 0 maskLen: 12]; + [pf addRuleWithIP: @"35.240.0.0" port: 0 maskLen: 13]; + [pf addRuleWithIP: @"64.15.112.0" port: 0 maskLen: 20]; + [pf addRuleWithIP: @"64.233.160.0" port: 0 maskLen: 19]; + [pf addRuleWithIP: @"66.102.0.0" port: 0 maskLen: 20]; + [pf addRuleWithIP: @"66.249.64.0" port: 0 maskLen: 19]; + [pf addRuleWithIP: @"70.32.128.0" port: 0 maskLen: 19]; + [pf addRuleWithIP: @"72.14.192.0" port: 0 maskLen: 18]; + [pf addRuleWithIP: @"74.114.24.0" port: 0 maskLen: 21]; + [pf addRuleWithIP: @"74.125.0.0" port: 0 maskLen: 16]; + [pf addRuleWithIP: @"104.154.0.0" port: 0 maskLen: 16]; + [pf addRuleWithIP: @"104.196.0.0" port: 0 maskLen: 14]; + [pf addRuleWithIP: @"104.237.160.0" port: 0 maskLen: 19]; + [pf addRuleWithIP: @"107.167.160.0" port: 0 maskLen: 19]; + [pf addRuleWithIP: @"107.178.192.0" port: 0 maskLen: 18]; + [pf addRuleWithIP: @"108.59.80.0" port: 0 maskLen: 20]; + [pf addRuleWithIP: @"108.170.192.0" port: 0 maskLen: 18]; + [pf addRuleWithIP: @"108.177.0.0" port: 0 maskLen: 17]; + [pf addRuleWithIP: @"130.211.0.0" port: 0 maskLen: 16]; + [pf addRuleWithIP: @"136.112.0.0" port: 0 maskLen: 12]; + [pf addRuleWithIP: @"142.250.0.0" port: 0 maskLen: 15]; + [pf addRuleWithIP: @"146.148.0.0" port: 0 maskLen: 17]; + [pf addRuleWithIP: @"162.216.148.0" port: 0 maskLen: 22]; + [pf addRuleWithIP: @"162.222.176.0" port: 0 maskLen: 21]; + [pf addRuleWithIP: @"172.110.32.0" port: 0 maskLen: 21]; + [pf addRuleWithIP: @"172.217.0.0" port: 0 maskLen: 16]; + [pf addRuleWithIP: @"172.253.0.0" port: 0 maskLen: 16]; + [pf addRuleWithIP: @"173.194.0.0" port: 0 maskLen: 16]; + [pf addRuleWithIP: @"173.255.112.0" port: 0 maskLen: 20]; + [pf addRuleWithIP: @"192.158.28.0" port: 0 maskLen: 22]; + [pf addRuleWithIP: @"192.178.0.0" port: 0 maskLen: 15]; + [pf addRuleWithIP: @"193.186.4.0" port: 0 maskLen: 24]; + [pf addRuleWithIP: @"199.36.154.0" port: 0 maskLen: 23]; + [pf addRuleWithIP: @"199.36.156.0" port: 0 maskLen: 24]; + [pf addRuleWithIP: @"199.192.112.0" port: 0 maskLen: 22]; + [pf addRuleWithIP: @"199.223.232.0" port: 0 maskLen: 21]; + [pf addRuleWithIP: @"207.223.160.0" port: 0 maskLen: 20]; + [pf addRuleWithIP: @"208.65.152.0" port: 0 maskLen: 22]; + [pf addRuleWithIP: @"208.68.108.0" port: 0 maskLen: 22]; + [pf addRuleWithIP: @"208.81.188.0" port: 0 maskLen: 22]; + [pf addRuleWithIP: @"208.117.224.0" port: 0 maskLen: 19]; + [pf addRuleWithIP: @"209.85.128.0" port: 0 maskLen: 17]; + [pf addRuleWithIP: @"216.58.192.0" port: 0 maskLen: 19]; + [pf addRuleWithIP: @"216.73.80.0" port: 0 maskLen: 20]; + [pf addRuleWithIP: @"216.239.32.0" port: 0 maskLen: 19]; + [pf addRuleWithIP: @"2001:4860::" port: 0 maskLen: 32]; + [pf addRuleWithIP: @"2404:6800::" port: 0 maskLen: 32]; + [pf addRuleWithIP: @"2404:f340::" port: 0 maskLen: 32]; + [pf addRuleWithIP: @"2600:1900::" port: 0 maskLen: 28]; + [pf addRuleWithIP: @"2606:73c0::" port: 0 maskLen: 32]; + [pf addRuleWithIP: @"2607:f8b0::" port: 0 maskLen: 32]; + [pf addRuleWithIP: @"2620:11a:a000::" port: 0 maskLen: 40]; + [pf addRuleWithIP: @"2620:120:e000::" port: 0 maskLen: 40]; + [pf addRuleWithIP: @"2800:3f0::" port: 0 maskLen: 32]; + [pf addRuleWithIP: @"2a00:1450::" port: 0 maskLen: 32]; + [pf addRuleWithIP: @"2c0f:fb50::" port: 0 maskLen: 32]; +} + +@end diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/Common/NSString+IPAddress.h b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/Common/NSString+IPAddress.h new file mode 100644 index 00000000..36c52360 --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/Common/NSString+IPAddress.h @@ -0,0 +1,33 @@ +// +// NSString+IPAddress.h +// SelfControl +// +// Created by Charlie Stigler on 2/5/13. +// Copyright 2009 Eyebeam. + +// This file is part of SelfControl. +// +// SelfControl is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +#import +#include + +// methods to check whether the string is a valid IP address +@interface NSString (IPAddress) + +@property (nonatomic, getter=isValidIPv4Address, readonly) BOOL validIPv4Address; +@property (nonatomic, getter=isValidIPv6Address, readonly) BOOL validIPv6Address; +@property (nonatomic, getter=isValidIPAddress, readonly) BOOL validIPAddress; + +@end diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/Common/NSString+IPAddress.m b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/Common/NSString+IPAddress.m new file mode 100644 index 00000000..5b4b6aec --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/Common/NSString+IPAddress.m @@ -0,0 +1,49 @@ +// +// NSString+IPAddress.m +// SelfControl +// +// Created by Charlie Stigler on 2/5/13. +// Copyright 2009 Eyebeam. + +// This file is part of SelfControl. +// +// SelfControl is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +#import "NSString+IPAddress.h" + +@implementation NSString (IPAddress) + +// These methods adapted from code posted by Evan Schoenberg to Stack Overflow +// and licensed under the Attribution-ShareAlike 3.0 Unported license +// http://stackoverflow.com/questions/1679152/how-to-validate-an-ip-address-with-regular-expression-in-objective-c + +- (BOOL)isValidIPv4Address { + struct in_addr throwaway; + int success = inet_pton(AF_INET, [self UTF8String], &throwaway); + + return (success == 1); +} + +- (BOOL)isValidIPv6Address { + struct in6_addr throwaway; + int success = inet_pton(AF_INET6, [self UTF8String], &throwaway); + + return (success == 1); +} + +- (BOOL)isValidIPAddress { + return ([self isValidIPv4Address] || [self isValidIPv6Address]); +} + +@end diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/HostFileBlocker.h b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/HostFileBlocker.h new file mode 100755 index 00000000..eb47b3eb --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/HostFileBlocker.h @@ -0,0 +1,64 @@ +// +// HostFileBlocker.h +// SelfControl +// +// Created by Charlie Stigler on 4/28/09. +// Copyright 2009 Eyebeam. + +// This file is part of SelfControl. +// +// SelfControl is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +#import + +@protocol HostFileBlocker + +- (BOOL)deleteBackupHostsFile; + +- (void)revertFileContentsToDisk; + +- (BOOL)writeNewFileContents; + +- (void)addSelfControlBlockHeader; + +- (void)addSelfControlBlockFooter; + +- (BOOL)createBackupHostsFile; + +- (BOOL)restoreBackupHostsFile; + +- (void)addRuleBlockingDomain:(NSString*)domainName; +- (void)appendExistingBlockWithRuleForDomain:(NSString*)domainName; + +- (BOOL)containsSelfControlBlock; + +- (void)removeSelfControlBlock; + +@end + + +@interface HostFileBlocker : NSObject { + NSString* hostFilePath; + + NSLock* strLock; + NSMutableString* newFileContents; + NSStringEncoding stringEnc; + NSFileManager* fileMan; +} + +- (instancetype)initWithPath:(NSString*)path; + ++ (BOOL)blockFoundInHostsFile; + +@end diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/HostFileBlocker.m b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/HostFileBlocker.m new file mode 100755 index 00000000..d96f71a7 --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/HostFileBlocker.m @@ -0,0 +1,234 @@ +// +// HostFileBlocker.m +// SelfControl +// +// Created by Charlie Stigler on 4/28/09. +// Copyright 2009 Eyebeam. + +// This file is part of SelfControl. +// +// SelfControl is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +#import "HostFileBlocker.h" + +NSString* const kHostFileBlockerPath = @"/etc/hosts"; +NSString* const kHostFileBlockerSelfControlHeader = @"# BEGIN SELFCONTROL BLOCK"; +NSString* const kHostFileBlockerSelfControlFooter = @"# END SELFCONTROL BLOCK"; +NSString* const kDefaultHostsFileContents = @"##\n" +"# Host Database\n" +"#\n" +"# localhost is used to configure the loopback interface\n" +"# when the system is booting. Do not change this entry.\n" +"##\n" +"127.0.0.1 localhost\n" +"255.255.255.255 broadcasthost\n" +"::1 localhost\n" +"fe80::1%lo0 localhost\n\n"; + +@implementation HostFileBlocker + +- (instancetype)init { + return [self initWithPath: kHostFileBlockerPath]; +} +- (instancetype)initWithPath:(NSString*)path { + if(self = [super init]) { + hostFilePath = path; + fileMan = [[NSFileManager alloc] init]; + strLock = [[NSLock alloc] init]; + newFileContents = [NSMutableString stringWithContentsOfFile: hostFilePath usedEncoding: &stringEnc error: NULL]; + if(!newFileContents) { + // if we lost our hosts file, replace it with the OS X default + newFileContents = [NSMutableString stringWithString: kDefaultHostsFileContents]; + } + } + + return self; +} + ++ (BOOL)blockFoundInHostsFile { + // last try if we can't find a block anywhere: check the host file, and see if a block is in there + NSString* hostFileContents = [NSString stringWithContentsOfFile: kHostFileBlockerPath encoding: NSUTF8StringEncoding error: NULL]; + if(hostFileContents != nil && [hostFileContents rangeOfString: kHostFileBlockerSelfControlHeader].location != NSNotFound) { + return YES; + } + + return NO; +} + +- (void)revertFileContentsToDisk { + [strLock lock]; + + newFileContents = [NSMutableString stringWithContentsOfFile: hostFilePath usedEncoding: &stringEnc error: NULL]; + if(!newFileContents) { + newFileContents = [NSMutableString stringWithString: kDefaultHostsFileContents]; + } + + [strLock unlock]; +} + +- (BOOL)writeNewFileContents { + [strLock lock]; + + BOOL ret = [newFileContents writeToFile: hostFilePath atomically: YES encoding: stringEnc error: NULL]; + + [strLock unlock]; + return ret; +} + +- (NSString*)backupHostFilePath { + return [hostFilePath stringByAppendingPathExtension: @"bak"]; +} + +- (BOOL)createBackupHostsFile { + [self deleteBackupHostsFile]; + + if (![fileMan fileExistsAtPath: hostFilePath]) { + [kDefaultHostsFileContents writeToFile: hostFilePath atomically:true encoding: NSUTF8StringEncoding error: NULL]; + } + + if(![fileMan isReadableFileAtPath: hostFilePath] || [fileMan fileExistsAtPath: [self backupHostFilePath]]) { + return NO; + } + + return [fileMan copyItemAtPath: hostFilePath toPath: [self backupHostFilePath] error: nil]; +} + +- (BOOL)deleteBackupHostsFile { + if(![fileMan isDeletableFileAtPath: [self backupHostFilePath]]) + return NO; + + return [fileMan removeItemAtPath: [self backupHostFilePath] error: nil]; +} + +- (BOOL)restoreBackupHostsFile { + NSString* backupPath = [self backupHostFilePath]; + + if(![fileMan removeItemAtPath: hostFilePath error: nil]) + return NO; + if(![fileMan isReadableFileAtPath: backupPath] || ![fileMan moveItemAtPath: backupPath toPath: hostFilePath error: nil]) + return NO; + + return YES; +} + +- (void)addSelfControlBlockHeader { + [strLock lock]; + [newFileContents appendString: @"\n"]; + [newFileContents appendString: kHostFileBlockerSelfControlHeader]; + [newFileContents appendString: @"\n"]; + [strLock unlock]; +} + +- (void)addSelfControlBlockFooter { + [strLock lock]; + [newFileContents appendString: kHostFileBlockerSelfControlFooter]; + [newFileContents appendString: @"\n"]; + [strLock unlock]; +} + +- (NSArray*)ruleStringsToBlockDomain:(NSString*)domainName { + return @[ + [NSString stringWithFormat: @"0.0.0.0\t%@\n", domainName], + [NSString stringWithFormat: @"::\t%@\n", domainName] + ]; +} + +- (void)addRuleBlockingDomain:(NSString*)domainName { + [strLock lock]; + NSArray* ruleStrings = [self ruleStringsToBlockDomain: domainName]; + for (NSString* ruleString in ruleStrings) { + [newFileContents appendString: ruleString]; + } + [strLock unlock]; +} + +- (void)appendExistingBlockWithRuleForDomain:(NSString*)domainName { + [strLock lock]; + NSRange footerLocation = [newFileContents rangeOfString: kHostFileBlockerSelfControlFooter]; + if (footerLocation.location == NSNotFound) { + // we can't append if a block isn't in the file already! + NSLog(@"WARNING: can't append to host block because footer can't be found"); + } else { + // combine the rule strings and insert em all at once to make the math easier + NSArray* ruleStrings = [self ruleStringsToBlockDomain: domainName]; + + NSMutableString* combinedRuleString = [NSMutableString string]; + for (NSString* ruleString in ruleStrings) { + [combinedRuleString appendString: ruleString]; + } + + [newFileContents insertString: combinedRuleString atIndex: footerLocation.location]; + } + [strLock unlock]; +} + +- (BOOL)containsSelfControlBlock { + [strLock lock]; + + BOOL ret = ([newFileContents rangeOfString: kHostFileBlockerSelfControlHeader].location != NSNotFound); + + [strLock unlock]; + return ret; +} + +- (void)removeSelfControlBlock { + if(![self containsSelfControlBlock]) + return; + + [strLock lock]; + + NSRange startRange = [newFileContents rangeOfString: kHostFileBlockerSelfControlHeader]; + NSRange endRange = [newFileContents rangeOfString: kHostFileBlockerSelfControlFooter]; + + // generate a delete range that properly removes the block from the hosts file + NSUInteger deleteRangeStart = startRange.location; + NSUInteger deleteRangeLength; + + // there are usually newlines placed before/after the header/footer + // we should remove them if possible to keep the hosts file looking tidy + // only remove the previous character if we aren't at the start of the file (or we'll crash) + if (deleteRangeStart > 0) { + unichar prevChar = [newFileContents characterAtIndex: deleteRangeStart - 1]; + // if the previous character isn't a newline, don't delete it + if ([[NSCharacterSet newlineCharacterSet] characterIsMember: prevChar]) { + deleteRangeStart--; + } + } + + NSUInteger maxDeleteLength = [newFileContents length] - deleteRangeStart; + // if we lost the block footer somehow... well, crap, just delete everything below the header + // this isn't ideal and we might bork other stuff, but it's better than leaving the block on + if (endRange.location == NSNotFound) { + deleteRangeLength = maxDeleteLength; + } else { + deleteRangeLength = MIN(maxDeleteLength, (endRange.location + endRange.length) - deleteRangeStart); + + // as above, look at removing the excess newline if possible + if (deleteRangeLength < maxDeleteLength) { + unichar nextChar = [newFileContents characterAtIndex: deleteRangeStart + deleteRangeLength]; + // if the next character isn't a newline, don't delete it + if ([[NSCharacterSet newlineCharacterSet] characterIsMember: nextChar]) { + deleteRangeLength++; + } + } + } + + NSRange deleteRange = NSMakeRange(deleteRangeStart, deleteRangeLength); + + [newFileContents deleteCharactersInRange: deleteRange]; + + [strLock unlock]; +} + +@end diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/HostFileBlockerSet.h b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/HostFileBlockerSet.h new file mode 100644 index 00000000..249022d6 --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/HostFileBlockerSet.h @@ -0,0 +1,20 @@ +// +// HostFileBlockerSet.h +// SelfControl +// +// Created by Charlie Stigler on 3/7/21. +// + +#import +#import "HostFileBlocker.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface HostFileBlockerSet : NSObject + +@property (readonly) NSArray* blockers; +@property (readonly) HostFileBlocker* defaultBlocker; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/HostFileBlockerSet.m b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/HostFileBlockerSet.m new file mode 100644 index 00000000..0cc61b2e --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/HostFileBlockerSet.m @@ -0,0 +1,121 @@ +// +// HostFileBlockerSet.m +// SelfControl +// +// Created by Charlie Stigler on 3/7/21. +// + +#import "HostFileBlockerSet.h" + +@implementation HostFileBlockerSet + +- (instancetype)init { + return [self initWithCommonFiles]; +} +- (instancetype)initWithCommonFiles { + NSFileManager* fileMan = [NSFileManager defaultManager]; + NSArray* commonBackupHostFilePaths = @[ + // Juniper Pulse + @"/etc/pulse-hosts.bak", + @"/etc/jnpr-pulse-hosts.bak", + @"/etc/pulse.hosts.bak", + @"/etc/jnpr-nc-hosts.bak", + + // Cisco AnyConnect + @"/etc/hosts.ac" + ]; + + NSMutableArray* hostFileBlockers = [NSMutableArray arrayWithCapacity: commonBackupHostFilePaths.count + 1]; + + _defaultBlocker = [HostFileBlocker new]; + [hostFileBlockers addObject: _defaultBlocker]; + + for (NSString* path in commonBackupHostFilePaths) { + if ([fileMan isReadableFileAtPath: path]) { + NSLog(@"INFO: found backup VPN host file at %@", path); + HostFileBlocker* blocker = [[HostFileBlocker alloc] initWithPath: path]; + [hostFileBlockers addObject: blocker]; + } + } + + _blockers = hostFileBlockers; + + return self; +} + +- (BOOL)deleteBackupHostsFile { + BOOL ret = YES; + for (HostFileBlocker* blocker in self.blockers) { + ret = ret && [blocker deleteBackupHostsFile]; + } + return ret; +} + +- (void)revertFileContentsToDisk { + for (HostFileBlocker* blocker in self.blockers) { + [blocker revertFileContentsToDisk]; + } +} + +- (BOOL)writeNewFileContents { + BOOL ret = YES; + for (HostFileBlocker* blocker in self.blockers) { + ret = ret && [blocker writeNewFileContents]; + } + return ret; +} + +- (void)addSelfControlBlockHeader { + for (HostFileBlocker* blocker in self.blockers) { + [blocker addSelfControlBlockHeader]; + } +} + +- (void)addSelfControlBlockFooter { + for (HostFileBlocker* blocker in self.blockers) { + [blocker addSelfControlBlockFooter]; + } +} + +- (BOOL)createBackupHostsFile { + BOOL ret = YES; + for (HostFileBlocker* blocker in self.blockers) { + ret = ret && [blocker createBackupHostsFile]; + } + return ret; +} + +- (BOOL)restoreBackupHostsFile { + BOOL ret = YES; + for (HostFileBlocker* blocker in self.blockers) { + ret = ret && [blocker restoreBackupHostsFile]; + } + return ret; +} + +- (void)addRuleBlockingDomain:(NSString*)domainName { + for (HostFileBlocker* blocker in self.blockers) { + [blocker addRuleBlockingDomain: domainName]; + } +} +- (void)appendExistingBlockWithRuleForDomain:(NSString*)domainName { + for (HostFileBlocker* blocker in self.blockers) { + [blocker appendExistingBlockWithRuleForDomain: domainName]; + } +} + +- (BOOL)containsSelfControlBlock { + BOOL ret = NO; + for (HostFileBlocker* blocker in self.blockers) { + ret = ret || [blocker containsSelfControlBlock]; + } + return ret; +} + +- (void)removeSelfControlBlock { + for (HostFileBlocker* blocker in self.blockers) { + [blocker removeSelfControlBlock]; + } +} + +@end diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/HostImporter.h b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/HostImporter.h new file mode 100755 index 00000000..fb06c44f --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/HostImporter.h @@ -0,0 +1,54 @@ +// +// HostImporter.h +// SelfControl +// +// Created by Charlie Stigler on 2/16/09. +// Copyright 2009 Eyebeam. + +// This file is part of SelfControl. +// +// SelfControl is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + + +#import +#import "ThunderbirdPreferenceParser.h" + +// A small class to handle getting the mail hostnames from different mail +// programs (just Mail and Thunderbird currently). +@interface HostImporter : NSObject { +} + ++ (NSArray*)commonDistractingWebsites; ++ (NSArray*)newsAndPublications; + +// Returns an autoreleased instance of NSArray containing all incoming hostnames +// imported from the user's instance of Mail.app ++ (NSArray*)incomingMailHostnamesFromMail; + +// Returns an autoreleased instance of NSArray containing all outgoing hostnames +// imported from the user's instance of Mail.app ++ (NSArray*)outgoingMailHostnamesFromMail; + ++ (NSArray*)incomingMailHostnamesFromMailMate; ++ (NSArray*)outgoingMailHostnamesFromMailMate; + +// Returns an autoreleased instance of NSArray containing all incoming hostnames +// imported from the default profile of the user's instance of Thunderbird ++ (NSArray*)incomingMailHostnamesFromThunderbird; + +// Returns an autoreleased instance of NSArray containing all outgoing hostnames +// imported from the default profile of the user's instance of Thunderbird ++ (NSArray*)outgoingMailHostnamesFromThunderbird; + +@end \ No newline at end of file diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/HostImporter.m b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/HostImporter.m new file mode 100755 index 00000000..43b2475d --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/HostImporter.m @@ -0,0 +1,229 @@ +// +// HostImporter.m +// SelfControl +// +// Created by Charlie Stigler on 2/16/09. +// Copyright 2009 Eyebeam. + +// This file is part of SelfControl. +// +// SelfControl is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + + +#import "HostImporter.h" + +@implementation HostImporter + ++ (NSArray*)commonDistractingWebsites { + return @[ + @"facebook.com", + @"twitter.com", + @"reddit.com", + @"tumblr.com", + @"youtube.com", + @"9gag.com", + @"netflix.com", + @"hulu.com", + @"buzzfeed.com", + @"dailymotion.com", + @"collegehumor.com", + @"funnyordie.com", + @"vine.co", + @"pinterest.com", + @"stumbleupon.com", + @"instagram.com", + ]; +} ++ (NSArray*)newsAndPublications { + return @[ + @"cnn.com", + @"huffingtonpost.com", + @"foxnews.com", + @"nytimes.com", + @"bbc.com", + @"bbc.co.uk", + @"telegraph.co.uk", + @"news.google.com", + @"buzzfeed.com", + @"vice.com", + @"gawker.com", + @"tumblr.com", + @"forbes.com", + @"gothamist.com", + @"jezebel.com", + @"usatoday.com", + @"theonion.com", + @"news.yahoo.com", + @"washingtonpost.com", + @"wsj.com", + @"theguardian.com", + @"latimes.com", + @"nydailynews.com", + @"salon.com", + @"msnbc.com", + @"rt.com", + @"bloomberg.com", + @"aol.com", + @"drudgereport.com", + @"nationalgeographic.com", + @"vice.com", + @"nypost.com", + @"chicagotribune.com", + @"msn.com", + @"usnews.com", + ]; +} + ++ (NSArray*)incomingMailHostnamesFromMail { + NSMutableArray* hostnames = [NSMutableArray arrayWithCapacity: 10]; + NSString* sandboxedPreferences = [@"~/Library/Containers/com.apple.mail/Data/Library/Preferences/com.apple.mail" stringByExpandingTildeInPath]; + NSDictionary* defaults = [[NSUserDefaults standardUserDefaults] persistentDomainForName: sandboxedPreferences]; + NSArray* incomingAccounts = defaults[@"MailAccounts"]; + for(NSUInteger i = 0; i < [incomingAccounts count]; i++) { + NSMutableString* hostname = [incomingAccounts[i][@"Hostname"] mutableCopy]; + // The LocalAccountName account has no hostname, so trying to add it would cause an error + if(hostname != nil) { + // If it has a defined port number, add it to the host to block for added + // block specificity, so only incoming mail is blocked. + if([incomingAccounts[i][@"PortNumber"] length]) { + [hostname appendString: @":"]; + [hostname appendString: incomingAccounts[i][@"PortNumber"]]; + // If it doesn't have a defined port number, we'll go through and choose + // the default port for the type of account it is. + } else if([incomingAccounts[i][@"AccountType"] isEqual: @"POPAccount"]) { + if(incomingAccounts[i][@"SSLEnabled"]) { + [hostname appendString: @":995"]; + } else { + [hostname appendString: @":110"]; + } + } else if([incomingAccounts[i][@"AccountType"] isEqual: @"IMAPAccount"]) { + if(incomingAccounts[i][@"SSLEnabled"]) { + [hostname appendString: @":993"]; + } else { + [hostname appendString: @":143"]; + } + } + [hostnames addObject: hostname]; + } + } + + return hostnames; +} + ++ (NSArray*)outgoingMailHostnamesFromMail { + NSMutableArray* hostnames = [NSMutableArray arrayWithCapacity: 10]; + NSString* sandboxedPreferences = [@"~/Library/Containers/com.apple.mail/Data/Library/Preferences/com.apple.mail" stringByExpandingTildeInPath]; + NSDictionary* defaults = [[NSUserDefaults standardUserDefaults] persistentDomainForName: sandboxedPreferences]; + NSArray* outgoingAccounts = defaults[@"DeliveryAccounts"]; + for(NSUInteger i = 0; i < [outgoingAccounts count]; i++) { + NSMutableString* hostname = [outgoingAccounts[i][@"Hostname"] mutableCopy]; + if(hostname != nil) { + if([outgoingAccounts[i][@"PortNumber"] length]) { + [hostname appendString: @":"]; + [hostname appendString: outgoingAccounts[i][@"PortNumber"]]; + } else if (outgoingAccounts[i][@"SSLEnabled"]) { + // If it doesn't have a defined port number, we'll block all the default outoging ports + [hostnames addObject: [hostname stringByAppendingString: @":465"]]; + [hostname appendString: @":587"]; + } else { + [hostname appendString: @":25"]; + } + + [hostnames addObject: hostname]; + } + } + + return hostnames; +} + ++ (NSArray*)incomingMailHostnamesFromMailMate { + NSMutableArray* hostnames = [NSMutableArray arrayWithCapacity: 10]; + + NSString* sourcesPath = [@"~/Library/Application Support/MailMate/Sources.plist" stringByExpandingTildeInPath]; + NSData* plistData = [NSData dataWithContentsOfFile: sourcesPath]; + if (!plistData) return hostnames; + + NSDictionary* prefs = [NSPropertyListSerialization propertyListWithData: plistData options: NSPropertyListImmutable format: nil error: nil]; + if (!prefs) return hostnames; + + NSArray* accounts = prefs[@"sources"]; + for(NSUInteger i = 0; i < [accounts count]; i++) { + NSURL* serverURL = [NSURL URLWithString: accounts[i][@"serverURL"]]; + if (!serverURL) continue; + + NSMutableString* hostname = [serverURL.host mutableCopy]; + if (![hostname length]) continue; + + // If it has a defined port number, add it to the host to block for added + // block specificity, so only incoming mail is blocked. + if([accounts[i][@"port"] length]) { + [hostname appendString: @":"]; + [hostname appendString: accounts[i][@"port"]]; + } else if([serverURL.scheme isEqualToString: @"imap"]) { + [hostname appendString: @":993"]; + [hostnames addObject: [hostname stringByAppendingString: @":143"]]; + } else if([serverURL.scheme isEqualToString: @"pop"]) { + [hostname appendString: @":995"]; + [hostnames addObject: [hostname stringByAppendingString: @":110"]]; + } + + [hostnames addObject: hostname]; + } + + return hostnames; +} ++ (NSArray*)outgoingMailHostnamesFromMailMate { + NSMutableArray* hostnames = [NSMutableArray arrayWithCapacity: 10]; + + NSString* submissionPath = [@"~/Library/Application Support/MailMate/Submission.plist" stringByExpandingTildeInPath]; + NSData* plistData = [NSData dataWithContentsOfFile: submissionPath]; + if (!plistData) return hostnames; + + NSDictionary* prefs = [NSPropertyListSerialization propertyListWithData: plistData options: NSPropertyListImmutable format: nil error: nil]; + if (!prefs) return hostnames; + + NSArray* smtpServers = prefs[@"smtpServers"]; + for(NSUInteger i = 0; i < [smtpServers count]; i++) { + NSURL* serverURL = [NSURL URLWithString: smtpServers[i][@"serverURL"]]; + if (!serverURL) continue; + + NSMutableString* hostname = [serverURL.host mutableCopy]; + if (![hostname length]) continue; + + // If it has a defined port number, add it to the host to block for added + // block specificity, so only incoming mail is blocked. + if([smtpServers[i][@"port"] length]) { + [hostname appendString: @":"]; + [hostname appendString: smtpServers[i][@"port"]]; + } else { + [hostname appendString: @":25"]; + [hostnames addObject: [hostname stringByAppendingString: @":587"]]; + } + + [hostnames addObject: hostname]; + } + + return hostnames; +} + + ++ (NSArray*)incomingMailHostnamesFromThunderbird { + return [ThunderbirdPreferenceParser incomingHostnames]; +} + ++ (NSArray*)outgoingMailHostnamesFromThunderbird { + return [ThunderbirdPreferenceParser outgoingHostnames]; +} + +@end diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/PacketFilter.h b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/PacketFilter.h new file mode 100644 index 00000000..3b07bd6d --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/PacketFilter.h @@ -0,0 +1,33 @@ +// +// PacketFilter.h +// SelfControl +// +// Created by Charles Stigler on 6/29/14. +// +// + +#import + +@class SCBlockEntry; + +@interface PacketFilter : NSObject { + NSMutableString* rules; + BOOL isAllowlist; +} + ++ (BOOL)blockFoundInPF; + +- (PacketFilter*)initAsAllowlist: (BOOL)allowlist; +- (void)addBlockHeader:(NSMutableString*)configText; +- (void)addAllowlistFooter:(NSMutableString*)configText; +- (void)addRuleWithIP:(NSString*)ip port:(NSInteger)port maskLen:(NSInteger)maskLen; +- (void)writeConfiguration; +- (int)startBlock; +- (int)stopBlock:(BOOL)force; +- (void)addSelfControlConfig; +- (BOOL)containsSelfControlBlock; +- (void)enterAppendMode; +- (void)finishAppending; +- (int)refreshPFRules; + +@end diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/PacketFilter.m b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/PacketFilter.m new file mode 100644 index 00000000..6d6caa79 --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/PacketFilter.m @@ -0,0 +1,267 @@ +// +// PacketFilter.m +// SelfControl +// +// Created by Charles Stigler on 6/29/14. +// +// + +#import "PacketFilter.h" + +NSString* const kPfctlExecutablePath = @"/sbin/pfctl"; +NSString* const kPFConfPath = @"/etc/pf.conf"; +NSString* const kPFAnchorCommand = @"anchor \"org.eyebeam\""; + +@implementation PacketFilter + +NSFileHandle* appendFileHandle; + ++ (BOOL)blockFoundInPF { + // last try if we can't find a block anywhere: check the host file, and see if a block is in there + NSString* pfConfContents = [NSString stringWithContentsOfFile: kPFConfPath encoding: NSUTF8StringEncoding error: NULL]; + if(pfConfContents != nil && [pfConfContents rangeOfString: kPFAnchorCommand].location != NSNotFound) { + return YES; + } + + return NO; +} + +- (PacketFilter*)initAsAllowlist: (BOOL)allowlist { + if (self = [super init]) { + isAllowlist = allowlist; + rules = [NSMutableString stringWithCapacity: 1000]; + } + return self; +} + +- (void)addBlockHeader:(NSMutableString*)configText { + [configText appendString: @"# Options\n" + "set block-policy drop\n" + "set fingerprints \"/etc/pf.os\"\n" + "set ruleset-optimization basic\n" + "set skip on lo0\n" + "\n" + "#\n" + "# org.eyebeam ruleset for SelfControl blocks\n" + "#\n"]; + + if (isAllowlist) { + [configText appendString: @"block return out proto tcp from any to any\n" + "block return out proto udp from any to any\n" + "\n"]; + } +} +- (void)addAllowlistFooter:(NSMutableString*)configText { + [configText appendString: @"pass out proto tcp from any to any port 53\n"]; + [configText appendString: @"pass out proto udp from any to any port 53\n"]; + [configText appendString: @"pass out proto udp from any to any port 123\n"]; + [configText appendString: @"pass out proto udp from any to any port 67\n"]; + [configText appendString: @"pass out proto tcp from any to any port 67\n"]; + [configText appendString: @"pass out proto udp from any to any port 68\n"]; + [configText appendString: @"pass out proto tcp from any to any port 68\n"]; + [configText appendString: @"pass out proto udp from any to any port 5353\n"]; + [configText appendString: @"pass out proto tcp from any to any port 5353\n"]; +} + +- (NSArray*)ruleStringsForIP:(NSString*)ip port:(NSInteger)port maskLen:(NSInteger)maskLen { + NSMutableString* rule = [NSMutableString stringWithString: @"from any to "]; + + if (ip) { + [rule appendString: ip]; + } else { + [rule appendString: @"any"]; + } + + if (maskLen) { + [rule appendString: [NSString stringWithFormat: @"/%ld", (long)maskLen]]; + } + + if (port) { + [rule appendString: [NSString stringWithFormat: @" port %ld", (long)port]]; + } + + if (isAllowlist) { + return @[ + [NSString stringWithFormat: @"pass out proto tcp %@\n", rule], + [NSString stringWithFormat: @"pass out proto udp %@\n", rule] + ]; + } else { + return @[ + [NSString stringWithFormat: @"block return out proto tcp %@\n", rule], + [NSString stringWithFormat: @"block return out proto udp %@\n", rule] + ]; + } +} +- (void)addRuleWithIP:(NSString*)ip port:(NSInteger)port maskLen:(NSInteger)maskLen { + @synchronized(self) { + NSArray* ruleStrings = [self ruleStringsForIP: ip port: port maskLen: maskLen]; + for (NSString* ruleString in ruleStrings) { + if (appendFileHandle) { + [appendFileHandle writeData: [ruleString dataUsingEncoding:NSUTF8StringEncoding]]; + } else { + [rules appendString: ruleString]; + } + } + } +} + +- (void)writeConfiguration { + NSMutableString* filterConfiguration = [NSMutableString stringWithCapacity: 1000]; + + [self addBlockHeader: filterConfiguration]; + [filterConfiguration appendString: rules]; + + if (isAllowlist) { + [self addAllowlistFooter: filterConfiguration]; + } + + [filterConfiguration writeToFile: @"/etc/pf.anchors/org.eyebeam" atomically: true encoding: NSUTF8StringEncoding error: nil]; +} + +- (void)enterAppendMode { + if (isAllowlist) { + NSLog(@"WARNING: Can't append rules to allowlist blocks - ignoring"); + return; + } + + // open the file and prepare to write to the very bottom (no footer since it's not an allowlist) + appendFileHandle = [NSFileHandle fileHandleForWritingAtPath: @"/etc/pf.anchors/org.eyebeam"]; + if (!appendFileHandle) { + NSLog(@"ERROR: Failed to get handle for pf.anchors file while attempting to append rules"); + return; + } + + [appendFileHandle seekToEndOfFile]; +} +- (void)finishAppending { + [appendFileHandle closeFile]; + appendFileHandle = nil; +} + +- (void)appendRulesToCurrentBlockConfiguration:(NSArray*)newEntryDicts { + if (newEntryDicts.count < 1) return; + if (isAllowlist) { + NSLog(@"WARNING: Can't append rules to allowlist blocks - ignoring"); + return; + } + + // open the file and prepare to write to the very bottom (no footer since it's not an allowlist) + // NOTE FOR FUTURE: NSFileHandle can't append lines to the middle of the file anyway, + // would need to read in the whole thing + write out again + NSFileHandle* fileHandle = [NSFileHandle fileHandleForWritingAtPath: @"/etc/pf.anchors/org.eyebeam"]; + if (!fileHandle) { + NSLog(@"ERROR: Failed to get handle for pf.anchors file while attempting to append rules"); + return; + } + + [fileHandle seekToEndOfFile]; + for (NSDictionary* entryHostInfo in newEntryDicts) { + NSString* hostName = entryHostInfo[@"hostName"]; + int portNum = [entryHostInfo[@"port"] intValue]; + int maskLen = [entryHostInfo[@"maskLen"] intValue]; + + NSArray* ruleStrings = [self ruleStringsForIP: hostName port: portNum maskLen: maskLen]; + for (NSString* ruleString in ruleStrings) { + [fileHandle writeData: [ruleString dataUsingEncoding:NSUTF8StringEncoding]]; + } + } + [fileHandle closeFile]; +} + +- (int)startBlock { + [self addSelfControlConfig]; + [self writeConfiguration]; + + NSArray* args = [@"-E -f /etc/pf.conf -F states" componentsSeparatedByString: @" "]; + + NSTask* task = [[NSTask alloc] init]; + [task setLaunchPath: kPfctlExecutablePath]; + [task setArguments: args]; + + NSPipe* inPipe = [[NSPipe alloc] init]; + NSFileHandle* readHandle = [inPipe fileHandleForReading]; + [task setStandardOutput: inPipe]; + [task setStandardError: inPipe]; + + [task launch]; + NSString* pfctlOutput = [[NSString alloc] initWithData: [readHandle readDataToEndOfFile] encoding: NSUTF8StringEncoding]; + [readHandle closeFile]; + [task waitUntilExit]; + + NSArray* lines = [pfctlOutput componentsSeparatedByString: @"\n"]; + for (NSString* line in lines) { + if ([line hasPrefix: @"Token : "]) { + [self writePFToken: [line substringFromIndex: [@"Token : " length]] error: nil]; + break; + } + } + + return [task terminationStatus]; +} +- (int)refreshPFRules { + NSArray* args = [@"-f /etc/pf.conf -F states" componentsSeparatedByString: @" "]; + + NSTask* task = [[NSTask alloc] init]; + [task setLaunchPath: kPfctlExecutablePath]; + [task setArguments: args]; + [task launch]; + [task waitUntilExit]; + + return [task terminationStatus]; +} + +- (void)writePFToken:(NSString*)token error:(NSError**)error { + [token writeToFile: @"/etc/SelfControlPFToken" atomically: YES encoding: NSUTF8StringEncoding error: error]; +} +- (NSString*)readPFToken:(NSError**)error { + return [NSString stringWithContentsOfFile: @"/etc/SelfControlPFToken" encoding: NSUTF8StringEncoding error: error]; +} + +- (int)stopBlock:(BOOL)force { + NSError* err; + NSString* token = [self readPFToken: &err]; + + [@"" writeToFile: @"/etc/pf.anchors/org.eyebeam" atomically: true encoding: NSUTF8StringEncoding error: nil]; + NSString* mainConf = [NSString stringWithContentsOfFile: @"/etc/pf.conf" encoding: NSUTF8StringEncoding error: nil]; + NSArray* lines = [mainConf componentsSeparatedByString: @"\n"]; + NSMutableString* newConf = [NSMutableString stringWithCapacity: [mainConf length]]; + for (NSString* line in lines) { + if ([line rangeOfString: @"org.eyebeam"].location == NSNotFound) { + [newConf appendFormat: @"%@\n", line]; + } + } + newConf = [[newConf stringByTrimmingCharactersInSet: [NSCharacterSet whitespaceAndNewlineCharacterSet]] mutableCopy]; + [newConf appendString: @"\n"]; + [newConf writeToFile: @"/etc/pf.conf" atomically: true encoding: NSUTF8StringEncoding error: nil]; + + NSString* commandString; + if ([token length] && !force) { + commandString = [NSString stringWithFormat: @"-X %@ -f /etc/pf.conf", token]; + } else { + commandString = @"-d -f /etc/pf.conf"; + } + NSArray* args = [commandString componentsSeparatedByString: @" "]; + + NSTask* task = [NSTask launchedTaskWithLaunchPath: kPfctlExecutablePath arguments: args]; + [task waitUntilExit]; + return [task terminationStatus]; +} + +- (void)addSelfControlConfig { + NSMutableString* pfConf = [NSMutableString stringWithContentsOfFile: @"/etc/pf.conf" encoding: NSUTF8StringEncoding error: nil]; + + if ([pfConf rangeOfString: @"/etc/pf.anchors/org.eyebeam"].location == NSNotFound) { + [pfConf appendString: @"\n" + "anchor \"org.eyebeam\"\n" + "load anchor \"org.eyebeam\" from \"/etc/pf.anchors/org.eyebeam\"\n"]; + } + + [pfConf writeToFile: @"/etc/pf.conf" atomically: true encoding: NSUTF8StringEncoding error: nil]; +} + +- (BOOL)containsSelfControlBlock { + NSString* mainConf = [NSString stringWithContentsOfFile: @"/etc/pf.conf" encoding: NSUTF8StringEncoding error: nil]; + return mainConf != nil && [mainConf rangeOfString: @"org.eyebeam"].location != NSNotFound; +} + +@end diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/SCBlockEntry.h b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/SCBlockEntry.h new file mode 100644 index 00000000..3a6afd2a --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/SCBlockEntry.h @@ -0,0 +1,26 @@ +// +// SCBlockEntry.h +// SelfControl +// +// Created by Charlie Stigler on 1/20/21. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface SCBlockEntry : NSObject + +@property (nonatomic) NSString* hostname; +@property (nonatomic) NSInteger port; +@property (nonatomic) NSInteger maskLen; + ++ (instancetype)entryWithHostname:(NSString*)hostname; ++ (instancetype)entryWithHostname:(NSString*)hostname port:(NSInteger)port maskLen:(NSInteger)maskLen; ++ (instancetype)entryFromString:(NSString*)domainString; + +- (BOOL)isEqualToEntry:(SCBlockEntry*)otherEntry; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/SCBlockEntry.m b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/SCBlockEntry.m new file mode 100644 index 00000000..a7a49a97 --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/SCBlockEntry.m @@ -0,0 +1,124 @@ +// +// SCBlockEntry.m +// SelfControl +// +// Created by Charlie Stigler on 1/20/21. +// + +#import "SCBlockEntry.h" + +@implementation SCBlockEntry + +- (instancetype)init { + return [self initWithHostname: nil port: 0 maskLen: 0]; +} +- (instancetype)initWithHostname:(NSString*)hostname { + return [self initWithHostname: hostname port: 0 maskLen: 0]; +} +- (instancetype)initWithHostname:(NSString*)hostname port:(NSInteger)port maskLen:(NSInteger)maskLen { + if (self = [super init]) { + _hostname = hostname; + _port = port; + _maskLen = maskLen; + } + return self; +} + ++ (instancetype)entryWithHostname:(NSString*)hostname port:(NSInteger)port maskLen:(NSInteger)maskLen { + return [[SCBlockEntry alloc] initWithHostname: hostname port: port maskLen: maskLen]; +} + ++ (instancetype)entryWithHostname:(NSString*)hostname { + return [[SCBlockEntry alloc] initWithHostname: hostname]; +} + ++ (instancetype)entryFromString:(NSString*)hostString { + // don't do anything with blank hostnames, however they got on the list... + // otherwise they could end up screwing up the block + if (![[hostString stringByTrimmingCharactersInSet: [NSCharacterSet whitespaceAndNewlineCharacterSet]] length]) { + return nil; + } + + NSString* hostname; + // returning 0 for either of these values means "couldn't find it in the string" + int maskLen = 0; + int port = 0; + + NSArray* splitString = [hostString componentsSeparatedByString: @"/"]; + hostname = splitString[0]; + + NSString* stringToSearchForPort = splitString[0]; + + if([splitString count] >= 2) { + maskLen = [splitString[1] intValue]; + + // we expect the port number to come after the IP/masklen + stringToSearchForPort = splitString[1]; + } + + splitString = [stringToSearchForPort componentsSeparatedByString: @":"]; + + // only if hostName wasn't already split off by the maskLen + if([stringToSearchForPort isEqualToString: hostname]) { + hostname = splitString[0]; + } + + if([splitString count] >= 2) { + port = [splitString[1] intValue]; + } + + if([hostname isEqualToString: @""]) { + hostname = @"*"; + } + + // we won't block host * (everywhere) without a port number... it's just too likely to be mistaken. + // Use a allowlist if that's what you want! + if ([hostname isEqualToString: @"*"] && !port) { + return nil; + } + + return [SCBlockEntry entryWithHostname: hostname port: port maskLen: maskLen]; +} + +- (NSString*)description { + return [NSString stringWithFormat: @"[Entry: hostname = %@, port = %ld, maskLen = %ld]", self.hostname, (long)self.port, (long)self.maskLen]; +} + +// method implementations of isEqual, isEqualToEntry, and hash are based on this answer from StackOverflow: https://stackoverflow.com/q/254281 + +- (BOOL)isEqual:(id)other { + if (other == self) + return YES; + if (!other || ![other isKindOfClass: [self class]]) + return NO; + return [self isEqualToEntry: other]; +} + +- (BOOL)isEqualToEntry:(SCBlockEntry*)otherEntry { + if (otherEntry == nil) return NO; + if (self == otherEntry) return YES; + + if ([self.hostname isEqualToString: otherEntry.hostname] && self.port == otherEntry.port && self.maskLen == otherEntry.maskLen) { + return YES; + } else { + return NO; + } +} + +- (NSUInteger)hash { + NSUInteger prime = 31; + NSUInteger result = 1; + + if (self.hostname == nil) { + result = prime * result; + } else { + result = prime * result + [self.hostname hash]; + } + + result = prime * result + (NSUInteger)self.port; + result = prime * result + (NSUInteger)self.maskLen; + + return result; +} + +@end diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/ThunderbirdPreferenceParser.h b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/ThunderbirdPreferenceParser.h new file mode 100755 index 00000000..2b2f2571 --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/ThunderbirdPreferenceParser.h @@ -0,0 +1,62 @@ +// +// ThunderbirdPreferenceParser.h +// SelfControl +// +// Created by Charlie Stigler on 2/17/09. +// Copyright 2009 Eyebeam. + +// This file is part of SelfControl. +// +// SelfControl is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + + +#import + +// A class designed to provide all necessary methods for parsing the incoming +// and outgoing mail hostnames from Thunderbird configuration files, and made +// to be easily extensible in case further configuration information is required. +@interface ThunderbirdPreferenceParser : NSObject { +} + +// Returns an autoreleased instance of NSString containing the full, absolute +// path to the Thunderbird support folder (usually ~/Library/Thunderbird) ++ (NSString*)pathToSupportFolder; + +// Returns YES if Thunderbird is detected as being installed (opened at least +// once, and therefore preferences initialized) on the current system, or NO +// if Thunderbird's preferences were not found. ++ (BOOL)thunderbirdIsInstalled; + +// Returns an autoreleased instance of NSString containing the full, absolute +// path to the Thunderbird default profile folder, or the profile named "default" +// if no profile folder is marked as default, or the first profile if no folder +// is named "default". Returns nil if no profiles exist. ++ (NSString*)pathToDefaultProfile; + +// Returns an autoreleased instance of NSString containing the full, absolute +// path to the Thunderbird prefs.js for the default profile (see +// PathToDefaultProfile for details on how the "default profile" is determined) ++ (NSString*)pathToPrefsJsFile; + +// Returns an autoreleased instance of NSArray containing all incoming hostnames +// for the Thunderbird default profile (see PathToDefaultProfile for details on +// how the "default profile" is determined) ++ (NSArray*)incomingHostnames; + +// Returns an autoreleased instance of NSArray containing all outgoing hostnames +// for the Thunderbird default profile (see PathToDefaultProfile for details on +// how the "default profile" is determined) ++ (NSArray*)outgoingHostnames; + +@end \ No newline at end of file diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/ThunderbirdPreferenceParser.m b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/ThunderbirdPreferenceParser.m new file mode 100755 index 00000000..dc016430 --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Block Management/ThunderbirdPreferenceParser.m @@ -0,0 +1,207 @@ +// +// ThunderbirdPreferenceParser.m +// SelfControl +// +// Created by Charlie Stigler on 2/17/09. +// Copyright 2009 Eyebeam. + +// This file is part of SelfControl. +// +// SelfControl is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + + + +#import "ThunderbirdPreferenceParser.h" + +NSString* const kThunderbirdSupportFolderPath = @"~/Library/Thunderbird"; + +@implementation ThunderbirdPreferenceParser + ++ (NSString*)pathToSupportFolder { + return [kThunderbirdSupportFolderPath stringByExpandingTildeInPath]; +} + ++ (BOOL)thunderbirdIsInstalled { + NSString* profilesIniPath = [[self pathToSupportFolder] stringByAppendingPathComponent: @"profiles.ini"]; + return [[NSFileManager defaultManager] isReadableFileAtPath: profilesIniPath]; +} + ++ (NSString*)pathToDefaultProfile { + NSString* profilesIniPath = [[self pathToSupportFolder] stringByAppendingPathComponent: @"profiles.ini"]; + if(![[NSFileManager defaultManager] isReadableFileAtPath: profilesIniPath]) + return nil; + + NSString* profilesIniContents = [NSString stringWithContentsOfFile: profilesIniPath encoding: NSUTF8StringEncoding error: NULL]; + NSScanner* profilesIniScanner = [NSScanner scannerWithString: profilesIniContents]; + NSMutableArray* profiles = [NSMutableArray arrayWithCapacity: 1]; + + [profilesIniScanner scanUpToString: @"[Profile" intoString: NULL]; + [profilesIniScanner scanUpToCharactersFromSet: [NSCharacterSet newlineCharacterSet] + intoString: NULL]; + + if([profilesIniScanner isAtEnd]) + return nil; + + do { // for each profile... + NSString* subString; + NSMutableDictionary* profile = [NSMutableDictionary dictionaryWithCapacity: 4]; + [profilesIniScanner scanUpToString: @"\n\n" + intoString: &subString]; + + NSScanner* subStringScanner = [NSScanner scannerWithString: subString]; + + while(![subStringScanner isAtEnd]) { // scan for more attributes + NSString* key; + + // Read in the key and value + [subStringScanner scanUpToCharactersFromSet: [NSCharacterSet characterSetWithCharactersInString: @"="] + + intoString: &key]; + + [subStringScanner scanCharactersFromSet: [NSCharacterSet characterSetWithCharactersInString: @"="] + intoString: NULL]; + + // deal with each possible attribute + if([key isEqual: @"Name"]) { + NSString* profileName; + [subStringScanner scanUpToCharactersFromSet: [NSCharacterSet newlineCharacterSet] + intoString: &profileName]; + profile[@"Name"] = profileName; + } + else if([key isEqual: @"IsRelative"]) { + int isRelative = 1; + [subStringScanner scanInt: &isRelative]; + profile[@"IsRelative"] = @(isRelative); + } + else if([key isEqual: @"Default"]) { + int isDefault = 0; + [subStringScanner scanInt: &isDefault]; + profile[@"Default"] = @(isDefault); + } + else if([key isEqual: @"Path"]) { + NSString* path; + [subStringScanner scanUpToCharactersFromSet: [NSCharacterSet newlineCharacterSet] + intoString: &path]; + profile[@"Path"] = path; + } + } + + [profiles addObject: profile]; // add the profile into the array of profiles + } while(![profilesIniScanner isAtEnd]); + + NSDictionary* defaultProfile = nil; + + for(NSUInteger i = 0; i < [profiles count]; i++) { + NSDictionary* p = profiles[i]; + if([p[@"Default"] isEqual: @1]) { + defaultProfile = p; + break; + } + if([p[@"Name"] isEqual: @"default"]) + defaultProfile = p; + } + + if(defaultProfile == nil) { + defaultProfile = profiles[0]; + } + if(defaultProfile == nil) + return nil; + + NSString* pathToProfile = defaultProfile[@"Path"]; + if(pathToProfile == nil) + return nil; + + if([defaultProfile[@"IsRelative"] isEqual: @0]) + return [pathToProfile stringByStandardizingPath]; + else + return [[[self pathToSupportFolder] stringByAppendingPathComponent: pathToProfile] stringByStandardizingPath]; +} + ++ (NSString*)pathToPrefsJsFile { + NSString* pathToDefaultProfile = [self pathToDefaultProfile]; + if(pathToDefaultProfile == nil) + return nil; + return [pathToDefaultProfile stringByAppendingPathComponent: @"prefs.js"]; +} + ++ (NSArray*)incomingHostnames { + NSString* pathToPrefsJsFile = [self pathToPrefsJsFile]; + if(pathToPrefsJsFile == nil) + return @[]; + if(![[NSFileManager defaultManager] isReadableFileAtPath: pathToPrefsJsFile]) + return @[]; + + NSArray* prefsJsLines = [[NSString stringWithContentsOfFile: pathToPrefsJsFile encoding: NSUTF8StringEncoding error: NULL] componentsSeparatedByCharactersInSet: [NSCharacterSet newlineCharacterSet]]; + NSMutableArray* hostnames = [NSMutableArray arrayWithCapacity: 10]; + + for(NSUInteger i = 0; i < [prefsJsLines count]; i++) { + NSString* line = prefsJsLines[i]; + // All of the asterisks are necessary for globbing so that any amount of + // whitespace will work. + if([line isLike: @"*user_pref(*\"mail.server.server*.hostname\"*,*\"*\"*)*;*"]) { + NSArray* parts = [line componentsSeparatedByString: @"\""]; + // If the hostname is "Local Folders", it's a special Thunderbird thing, + // and obviously not something we can block. + if([parts count] >= 4 && ![parts[3] isEqual: @"Local Folders"]) { + [hostnames addObject: [parts[3] stringByAppendingString: @":110"]]; + } + } + else if([line isLike: @"*user_pref(*\"mail.server.server*.port\"*,*)*;*"]) { + NSArray* parts = [line componentsSeparatedByString: @","]; + parts = [parts[1] componentsSeparatedByString: @")"]; + int portNumber = [[parts[0] stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] intValue]; + NSString* alteredHost = [hostnames[([hostnames count] - 1)] componentsSeparatedByString: @":"][0]; + alteredHost = [alteredHost stringByAppendingFormat: @":%d", portNumber]; + hostnames[([hostnames count] - 1)] = alteredHost; + } + } + + return hostnames; +} + ++ (NSArray*)outgoingHostnames { + NSString* pathToPrefsJsFile = [self pathToPrefsJsFile]; + if(pathToPrefsJsFile == nil) + return @[]; + if(![[NSFileManager defaultManager] isReadableFileAtPath: pathToPrefsJsFile]) + return @[]; + + NSArray* prefsJsLines = [[NSString stringWithContentsOfFile: pathToPrefsJsFile encoding: NSUTF8StringEncoding error: NULL] componentsSeparatedByString: @"\n"]; + NSMutableArray* hostnames = [NSMutableArray arrayWithCapacity: 10]; + + for(NSUInteger i = 0; i < [prefsJsLines count]; i++) { + NSString* line = prefsJsLines[i]; + if([line isLike: @"*user_pref(*\"mail.smtpserver.smtp*.hostname\"*,*\"*\"*)*;*"]) { + NSArray* parts = [line componentsSeparatedByString: @"\""]; + if([parts count] >= 4 && ![parts[3] isEqual: @"Local Folders"]) { + [hostnames addObject: [parts[3] stringByAppendingString: @":25"]]; + } + } + // If there's a port number, add it to the last added hostname. Yes, it could + // technically be associated with a different hostname, but only if the user + // was manually editing the preferences file and changed it stupidly. + else if([line isLike: @"*user_pref(*\"mail.smtpserver.smtp*.port\"*,*)*;*"]) { + NSArray* parts = [line componentsSeparatedByString: @","]; + parts = [parts[1] componentsSeparatedByString: @")"]; + int portNumber = [[parts[0] stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] intValue]; + NSString* alteredHost = [hostnames[([hostnames count] - 1)] componentsSeparatedByString: @":"][0]; + alteredHost = [alteredHost stringByAppendingFormat: @":%d", portNumber]; + hostnames[([hostnames count] - 1)] = alteredHost; + } + } + + return hostnames; +} + +@end diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/DeprecationSilencers.h b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/DeprecationSilencers.h new file mode 100644 index 00000000..949b4df7 --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/DeprecationSilencers.h @@ -0,0 +1,23 @@ +// +// DeprecationSilencers.h +// SelfControl +// +// Created by Charlie Stigler on 1/19/21. +// + +#ifndef DeprecationSilencers_h +#define DeprecationSilencers_h + +// great idea taken from this guy: https://stackoverflow.com/a/26564750 + +#define SILENCE_DEPRECATION(expr) \ +do { \ +_Pragma("clang diagnostic push") \ +_Pragma("clang diagnostic ignored \"-Wdeprecated-declarations\"") \ +expr; \ +_Pragma("clang diagnostic pop") \ +} while(0) + +#define SILENCE_OSX10_10_DEPRECATION(expr) SILENCE_DEPRECATION(expr) + +#endif /* DeprecationSilencers_h */ diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/SCErr.h b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/SCErr.h new file mode 100644 index 00000000..5bce0a8f --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/SCErr.h @@ -0,0 +1,25 @@ +// +// SCErr.h +// SelfControl +// +// Created by Charlie Stigler on 1/13/21. +// + +#import + +// copied from StackOverflow answer by Wolfgang Schreurs: https://stackoverflow.com/a/14086231 +#define SC_ERROR_KEY(code) [NSString stringWithFormat:@"%ld", (long)code] +#define SC_ERROR_LOCALIZED_DESCRIPTION(code) NSLocalizedStringFromTable(SC_ERROR_KEY(code), @"SCError", nil) + +FOUNDATION_EXPORT NSString * _Nonnull const kSelfControlErrorDomain; + +NS_ASSUME_NONNULL_BEGIN + +@interface SCErr : NSObject + ++ (NSError*)errorWithCode:(NSInteger)errorCode subDescription:(NSString* _Nullable)subDescription; ++ (NSError*)errorWithCode:(NSInteger)errorCode; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/SCErr.m b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/SCErr.m new file mode 100644 index 00000000..069aa18e --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/SCErr.m @@ -0,0 +1,42 @@ +// +// SCErr.m +// SelfControl +// +// Created by Charlie Stigler on 1/13/21. +// + +#import "SCErr.h" + +NSString *const kSelfControlErrorDomain = @"SelfControlErrorDomain"; + +@implementation SCErr + ++ (NSError*)errorWithCode:(NSInteger)errorCode subDescription:(NSString * _Nullable )subDescription { + BOOL descriptionNotFound = NO; + NSString* description = SC_ERROR_LOCALIZED_DESCRIPTION(errorCode); + + // if we couldn't find a localized description key, that probably means + // we're in the daemon or somewhere else where .strings aren't available. + // flag that so the app can fill in the details later + if ([description isEqualToString: SC_ERROR_KEY(errorCode)]) { + description = [NSString stringWithFormat: @"SelfControl hit an unknown error with code %ld.", errorCode]; + descriptionNotFound = YES; + } + + if (subDescription != nil) { + description = [NSString stringWithFormat: description, subDescription]; + } + + return [NSError errorWithDomain: kSelfControlErrorDomain + code: errorCode + userInfo: @{ + NSLocalizedDescriptionKey: description, + @"SCDescriptionNotFound": @(descriptionNotFound) + }]; +} + ++ (NSError*)errorWithCode:(NSInteger)errorCode { + return [SCErr errorWithCode: errorCode subDescription: nil]; +} + +@end diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/SCFileWatcher.h b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/SCFileWatcher.h new file mode 100644 index 00000000..4ef12241 --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/SCFileWatcher.h @@ -0,0 +1,27 @@ +// +// SCFileWatcher.h +// SelfControl +// +// Created by Charlie Stigler on 3/20/21. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +typedef void (^SCFileWatcherCallback)(NSError* _Nullable error); + +@interface SCFileWatcher : NSObject + +@property (readonly) FSEventStreamRef eventStream; +@property (strong, readonly) SCFileWatcherCallback callbackBlock; +@property (strong, readonly) NSString* filePath; + ++ (instancetype)watcherWithFile:(NSString*)watchPath block:(void(^)(NSError* error))callbackBlock; +- (instancetype)initWithFile:(NSString*)watchPath block:(void(^)(NSError* error))callbackBlock; + +- (void)stopWatching; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/SCFileWatcher.m b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/SCFileWatcher.m new file mode 100644 index 00000000..4a26448e --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/SCFileWatcher.m @@ -0,0 +1,104 @@ +// +// SCFileWatcher.m +// SelfControl +// +// Created by Charlie Stigler on 3/20/21. +// + +#import "SCFileWatcher.h" +#include + +@implementation SCFileWatcher + +static void SCFileWatcherGlobalCallback( + ConstFSEventStreamRef streamRef, + void *callbackCtxInfo, + size_t numEvents, + void *eventPaths, // CFArrayRef + const FSEventStreamEventFlags eventFlags[], + const FSEventStreamEventId eventIds[]) +{ + NSArray* paths = (__bridge NSArray*)eventPaths; + SCFileWatcher* watcher = (__bridge SCFileWatcher*)callbackCtxInfo; + [watcher directoryWatcherTriggered: paths flags: eventFlags]; +} + +- (void)directoryWatcherTriggered:(NSArray*)eventPaths flags:(const FSEventStreamEventFlags[])eventFlags { + BOOL triggerFileWatcher = NO; + + for (unsigned int i = 0; i < eventPaths.count; i++) { + NSString* eventPath = [eventPaths[i] stringByStandardizingPath]; + + if ([eventPath isEqualToString: self.filePath]) { + triggerFileWatcher = YES; + } + } + + if (triggerFileWatcher) { + self.callbackBlock(nil); + } +} + ++ (instancetype)watcherWithFile:(NSString*)watchPath block:(void(^)(NSError* error))callbackBlock { + return [[SCFileWatcher new] initWithFile: watchPath block: callbackBlock]; +} + +- (instancetype)initWithFile:(NSString*)watchPath block:(void(^)(NSError* error))callbackBlock { + self = [super init]; + + NSFileManager* fileMan = [NSFileManager defaultManager]; + _filePath = [watchPath stringByStandardizingPath]; + BOOL isDirectory; + [fileMan fileExistsAtPath: self.filePath isDirectory: &isDirectory]; + + NSString* directoryPath; + if (isDirectory) { + directoryPath = self.filePath; + } else { + directoryPath = [self.filePath stringByDeletingLastPathComponent]; + } + + FSEventStreamContext callbackCtx; + callbackCtx.version = 0; + callbackCtx.info = (__bridge void *)self; + callbackCtx.retain = NULL; + callbackCtx.release = NULL; + callbackCtx.copyDescription = NULL; + + FSEventStreamRef eventStream = FSEventStreamCreate( + kCFAllocatorDefault, + &SCFileWatcherGlobalCallback, + &callbackCtx, // context + (__bridge CFArrayRef)@[directoryPath], + kFSEventStreamEventIdSinceNow, + 1.5, // seconds to throttle callbacks + kFSEventStreamCreateFlagUseCFTypes | kFSEventStreamCreateFlagMarkSelf | kFSEventStreamCreateFlagIgnoreSelf | kFSEventStreamCreateFlagFileEvents + ); + + FSEventStreamScheduleWithRunLoop(eventStream, + [[NSRunLoop currentRunLoop] getCFRunLoop], + kCFRunLoopDefaultMode); + if (!FSEventStreamStart(eventStream)) { + NSLog(@"WARNING: failed to start watching file %@", watchPath); + FSEventStreamUnscheduleFromRunLoop(eventStream, [[NSRunLoop currentRunLoop] getCFRunLoop], kCFRunLoopDefaultMode); + FSEventStreamInvalidate(eventStream); + FSEventStreamRelease(eventStream); + return nil; + } + + _eventStream = eventStream; + _callbackBlock = callbackBlock; + + return self; +} + +- (void)stopWatching { + FSEventStreamStop(self.eventStream); + FSEventStreamUnscheduleFromRunLoop(self.eventStream, [[NSRunLoop currentRunLoop] getCFRunLoop], kCFRunLoopDefaultMode); + FSEventStreamInvalidate(self.eventStream); + FSEventStreamRelease(self.eventStream); + + _eventStream = NULL; +} + +@end diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/SCSettings.h b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/SCSettings.h new file mode 100644 index 00000000..d1f9da3f --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/SCSettings.h @@ -0,0 +1,42 @@ +// +// SCLockFileUtilities.h +// SelfControl +// +// Created by Charles Stigler on 20/10/2018. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface SCSettings : NSObject + +@property (readonly) uid_t userId; +@property (readonly) NSDictionary* dictionaryRepresentation; +@property (nonatomic, getter=isReadOnly) BOOL readOnly; + +@property (class, nonatomic, readonly) NSString* settingsFileName; +@property (class, nonatomic, readonly) NSString* securedSettingsFilePath; + ++ (instancetype)sharedSettings; + +- (void)reloadSettings; +- (void)writeSettingsWithCompletion:(nullable void(^)(NSError* _Nullable))completionBlock; +- (void)writeSettings; +- (void)synchronizeSettingsWithCompletion:(nullable void(^)(NSError* _Nullable))completionBlock; +- (void)synchronizeSettings; +- (NSError*)syncSettingsAndWait:(NSInteger)timeoutSecs; + +- (void)setValue:(id)value forKey:(NSString*)key stopPropagation:(BOOL)stopPropagation; +- (void)setValue:(nullable id)value forKey:(NSString*)key; + +- (id)valueForKey:(NSString*)key; +- (BOOL)boolForKey:(NSString*)key; + +- (void)updateSentryContext; + +- (void)resetAllSettingsToDefaults; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/SCSettings.m b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/SCSettings.m new file mode 100644 index 00000000..8266028b --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/SCSettings.m @@ -0,0 +1,568 @@ +// +// SCLockFileUtilities.m +// SelfControl +// +// Created by Charles Stigler on 20/10/2018. +// + +#import "SCSettings.h" +#import + +#ifndef TESTING +//#import +#endif + +float const SYNC_INTERVAL_SECS = 30; +float const SYNC_LEEWAY_SECS = 30; +NSString* const SETTINGS_FILE_DIR = @"/usr/local/etc/"; + +@interface SCSettings () + +// Private vars +@property (readonly) NSMutableDictionary* settingsDict; +@property NSDate* lastSynchronizedWithDisk; +@property dispatch_source_t syncTimer; +@property dispatch_source_t debouncedChangeTimer; + +@end + +@implementation SCSettings + ++ (instancetype)sharedSettings { + static SCSettings* globalSettings = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + globalSettings = [SCSettings new]; + }); + return globalSettings; +} + +- (instancetype)init { + if (self = [super init]) { + // we will only write out settings if we have root permissions (i.e. the EUID is 0) + // otherwise, we won't/shouldn't have permissions to write to the settings file + // in practice, what this means is that the daemon writes settings, and the app/CLI only read + _readOnly = (geteuid() != 0); + + _settingsDict = nil; + + [[NSDistributedNotificationCenter defaultCenter] addObserver: self + selector: @selector(onSettingChanged:) + name: @"org.eyebeam.SelfControl.SCSettingsValueChanged" + object: nil + suspensionBehavior: NSNotificationSuspensionBehaviorDeliverImmediately]; + } + return self; +} + ++ (NSString*)settingsFileName { + static NSString* fileName = nil; + + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + fileName = [NSString stringWithFormat: @".%@.plist", [SCMiscUtilities sha1: [NSString stringWithFormat: @"SelfControlUserPreferences%@", [SCMiscUtilities getSerialNumber]]]]; + }); + + return fileName; +} ++ (NSString*)securedSettingsFilePath { + static NSString* filePath = nil; + + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + filePath = [NSString stringWithFormat: @"%@%@", SETTINGS_FILE_DIR, SCSettings.settingsFileName]; + }); + + return filePath; +} + +// NOTE: there should be a default setting for each valid setting, even if it's nil/zero/etc +- (NSDictionary*)defaultSettingsDict { + return @{ + @"BlockEndDate": [NSDate distantPast], + @"ActiveBlocklist": @[], + @"ActiveBlockAsWhitelist": @NO, + + @"BlockIsRunning": @NO, // tells us whether a block is actually running on the system (to the best of our knowledge) + @"TamperingDetected": @NO, + + // block settings + // the user sets these in defaults, then when a block is started they're copied over to settings + @"EvaluateCommonSubdomains": @YES, + @"IncludeLinkedDomains": @YES, + @"BlockSoundShouldPlay": @NO, + @"BlockSound": @5, + @"ClearCaches": @YES, + @"AllowLocalNetworks": @YES, + + @"EnableErrorReporting": @([SCMiscUtilities systemThirdPartyCrashReportingEnabled]), + + @"SettingsVersionNumber": @0, + @"LastSettingsUpdate": [NSDate distantPast] // special value that keeps track of when we last updated our settings + }; +} + +- (void)initializeSettingsDict { + // make sure we only load the settings dictionary once, even if called simultaneously from multiple threads + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + @synchronized (self) { + self->_settingsDict = [NSMutableDictionary dictionaryWithContentsOfFile: SCSettings.securedSettingsFilePath]; + + BOOL isTest = [[NSUserDefaults standardUserDefaults] boolForKey: @"isTest"]; + if (isTest) NSLog(@"Ignoring settings on disk because we're unit-testing"); + + // if we don't have a settings dictionary on disk yet, + // set it up with the default values (and migrate legacy settings also) + // also if we're running tests, just use the default dict + if (self->_settingsDict == nil || isTest) { + self->_settingsDict = [[self defaultSettingsDict] mutableCopy]; + + // write out our brand-new settings to disk! + if (!self.readOnly) { + [self writeSettings]; + } + // [SCSentry addBreadcrumb: @"Initialized SCSettings to default settings" category: @"settings"]; + } + + // we're now current with disk! + self->lastSynchronizedWithDisk = [NSDate date]; + + [self startSyncTimer]; + } + }); +} + +- (NSDictionary*)settingsDict { + if (_settingsDict == nil) { + [self initializeSettingsDict]; + } + return _settingsDict; +} + +- (NSDictionary*)dictionaryRepresentation { + NSMutableDictionary* dictCopy = [self.settingsDict mutableCopy]; + + // fill in any gaps with default values (like we did if they called valueForKey:) + for (NSString* key in [[self defaultSettingsDict] allKeys]) { + if (dictCopy[key] == nil) { + dictCopy[key] = [self defaultSettingsDict][key]; + } + } + + return dictCopy; +} + +// both reloadSettings and writeSettings are synchronized with the same object, so +// at any given time we are running a maximum of one of these methods, on one thread. +// we don't want to be reading the file on one thread and writing out two different versions +// on two other threads + +- (void)reloadSettings { + // if the settings dictionary hasn't been loaded the first time, do that instead of reloading + if (_settingsDict == nil) { + [self initializeSettingsDict]; + return; + } + + @synchronized (self) { + NSDictionary* settingsFromDisk = [NSDictionary dictionaryWithContentsOfFile: SCSettings.securedSettingsFilePath]; + + int diskSettingsVersion = [settingsFromDisk[@"SettingsVersionNumber"] intValue]; + int memorySettingsVersion = [[self valueForKey: @"SettingsVersionNumber"] intValue]; + NSDate* diskSettingsLastUpdated = settingsFromDisk[@"LastSettingsUpdate"]; + NSDate* memorySettingsLastUpdated = [self valueForKey: @"LastSettingsUpdate"]; + + // occasionally we can end up with timestamps from the future + // (usually because the user moved their system clock forward, then back again) + // it's a weird edge case and we should just fix that when we see it + if ([diskSettingsLastUpdated timeIntervalSinceNow] > 0) { + // we'll pretend the disk was written 1 second ago in this case to avoid weird edge conditions + diskSettingsLastUpdated = [[NSDate date] dateByAddingTimeInterval: 1.0]; + } + if ([memorySettingsLastUpdated timeIntervalSinceNow] > 0) { + memorySettingsLastUpdated = [NSDate date]; + [self setValue: memorySettingsLastUpdated forKey: @"LastSettingsUpdate"]; + } + + if (diskSettingsLastUpdated == nil) diskSettingsLastUpdated = [NSDate distantPast]; + + // try to decide which is more recent by version number, tiebreak by date + BOOL diskMoreRecentThanMemory = NO; + if (diskSettingsVersion == memorySettingsVersion) { + diskMoreRecentThanMemory = ([diskSettingsLastUpdated timeIntervalSinceDate: memorySettingsLastUpdated] > 0); + } else { + diskMoreRecentThanMemory = (diskSettingsVersion > memorySettingsVersion); + } + + if (diskMoreRecentThanMemory) { + _settingsDict = [settingsFromDisk mutableCopy]; + self.lastSynchronizedWithDisk = [NSDate date]; + NSLog(@"Newer SCSettings found on disk (version %d vs %d with time interval %f), updating...", diskSettingsVersion, memorySettingsVersion, [diskSettingsLastUpdated timeIntervalSinceDate: memorySettingsLastUpdated]); + // [SCSentry addBreadcrumb: @"Updated SCSettings to newer settings found on disk" category: @"settings"]; + } + } +} +- (void)writeSettingsWithCompletion:(nullable void(^)(NSError* _Nullable))completionBlock { + @synchronized (self) { + if (self.readOnly) { + NSLog(@"WARNING: Read-only SCSettings instance can't write out settings"); + NSError* err = [SCErr errorWithCode: 600]; + // [SCSentry captureError: err]; + if (completionBlock != nil) { + completionBlock(err); + } + return; + } + +#if TESTING + // no writing to disk during unit tests + NSLog(@"Would write settings to disk now (but no writing during unit tests)"); + if (completionBlock != nil) completionBlock(nil); + return; +#endif + + // don't spend time on the main thread writing out files - it's OK for this to happen without blocking other things + dispatch_sync(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + NSError* serializationErr; + NSData* plistData = [NSPropertyListSerialization dataWithPropertyList: self.settingsDict + format: NSPropertyListBinaryFormat_v1_0 + options: kNilOptions + error: &serializationErr]; + + if (plistData == nil) { + NSLog(@"NSPropertyListSerialization error: %@", serializationErr); + if (completionBlock != nil) completionBlock(serializationErr); + return; + } + + NSError* createDirectoryErr; + BOOL createDirectorySuccessful = [[NSFileManager defaultManager] createDirectoryAtURL: [NSURL fileURLWithPath: SETTINGS_FILE_DIR] + withIntermediateDirectories: YES + attributes: @{ + NSFileOwnerAccountID: [NSNumber numberWithUnsignedLong: 0], + NSFileGroupOwnerAccountID: [NSNumber numberWithUnsignedLong: 0], + NSFilePosixPermissions: [NSNumber numberWithShort: 0755] + } + error: &createDirectoryErr]; + if (!createDirectorySuccessful) { + NSLog(@"WARNING: Failed to create %@ folder to store SCSettings. Error was %@", SETTINGS_FILE_DIR, createDirectoryErr); + // [SCSentry addBreadcrumb: [NSString stringWithFormat: @"Failed to create directory for SCSettings with error %@", createDirectoryErr] category:@"settings"]; + } + + NSError* chmodDirectoryErr; + BOOL chmodDirectorySuccessful = [[NSFileManager defaultManager] + setAttributes: @{ + NSFilePosixPermissions: [NSNumber numberWithShort: 0755] + } + ofItemAtPath: SETTINGS_FILE_DIR + error: &chmodDirectoryErr]; + if (!chmodDirectorySuccessful) { + NSLog(@"WARNING: Failed to set permissions on %@ folder to store SCSettings. Error was %@", SETTINGS_FILE_DIR, chmodDirectoryErr); + // [SCSentry addBreadcrumb: [NSString stringWithFormat: @"Failed to set directory permissions for SCSettings with error %@", chmodDirectoryErr] category:@"settings"]; + } + + NSError* writeErr; + BOOL writeSuccessful = [plistData writeToFile: SCSettings.securedSettingsFilePath + options: NSDataWritingAtomic + error: &writeErr + ]; + + NSError* chmodErr; + BOOL chmodSuccessful = [[NSFileManager defaultManager] + setAttributes: @{ + NSFileOwnerAccountID: [NSNumber numberWithUnsignedLong: 0], + NSFileGroupOwnerAccountID: [NSNumber numberWithUnsignedLong: 0], + NSFilePosixPermissions: [NSNumber numberWithShort: 0755] + } + ofItemAtPath: SCSettings.securedSettingsFilePath + error: &chmodErr]; + + if (writeSuccessful) { + self.lastSynchronizedWithDisk = [NSDate date]; + } + + if (!writeSuccessful) { + NSLog(@"Failed to write secured settings to file %@", SCSettings.securedSettingsFilePath); + // [SCSentry captureError: writeErr]; + if (completionBlock != nil) completionBlock(writeErr); + } else if (!chmodSuccessful) { + NSLog(@"Failed to change secured settings file owner/permissions secured settings for file %@ with error %@", SCSettings.securedSettingsFilePath, chmodErr); + // [SCSentry captureError: chmodErr]; + if (completionBlock != nil) completionBlock(chmodErr); + } else { + // [SCSentry addBreadcrumb: @"Successfully wrote SCSettings out to file" category: @"settings"]; + if (completionBlock != nil) completionBlock(nil); + } + }); + } +} +- (void)writeSettings { + // by default, just log all errors + [self writeSettingsWithCompletion:^(NSError * _Nullable err) { + if (err != nil) { + NSLog(@"Error writing SCSettings: %@", err); + } + }]; +} +- (void)synchronizeSettingsWithCompletion:(nullable void (^)(NSError * _Nullable))completionBlock { + [self reloadSettings]; + + NSDate* lastSettingsUpdate = [self valueForKey: @"LastSettingsUpdate"]; + + // occasionally we can end up with timestamps from the future + // (usually because the user moved their system clock forward, then back again) + // it's a weird edge case and we should just fix that when we see it + if ([lastSettingsUpdate timeIntervalSinceNow] > 0) { + [self setValue: [NSDate date] forKey: @"LastSettingsUpdate"]; + } + + if ([lastSettingsUpdate timeIntervalSinceDate: self.lastSynchronizedWithDisk] > 0 && !self.readOnly) { + NSLog(@" --> Writing settings to disk (haven't been written since %@)", self.lastSynchronizedWithDisk); + [self writeSettingsWithCompletion: completionBlock]; + } else { + if(completionBlock != nil) { + // don't just run the callback asynchronously, since it makes this method harder to reason about + // (it'd sometimes call back synchronously and sometimes async) +// dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + completionBlock(nil); +// }); + } + } +} +- (void)synchronizeSettings { + [self synchronizeSettingsWithCompletion: nil]; +} + +- (NSError*)syncSettingsAndWait:(NSInteger)timeoutSecs { + dispatch_semaphore_t sema = dispatch_semaphore_create(0); + __block NSError* retErr = nil; + + // do this on another thread so it doesn't deadlock our semaphore + // (also dispatch_async ensures correct behavior even if synchronizeSettingsWithCompletion itself returns synchronously) + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + [self synchronizeSettingsWithCompletion:^(NSError* err) { + retErr = err; + + dispatch_semaphore_signal(sema); + }]; + }); + + if (dispatch_semaphore_wait(sema, dispatch_time(DISPATCH_TIME_NOW, (int64_t)timeoutSecs * (int64_t)NSEC_PER_SEC))) { + retErr = [SCErr errorWithCode: 601]; + } + + return retErr; +} + +- (void)setValue:(id)value forKey:(NSString*)key stopPropagation:(BOOL)stopPropagation { + // if we're a readonly instance, we generally shouldn't be allowing values to be set + // the only exception is receiving value updates (via notification) from other processes + // in which case stopPropagation will be true + if (self.readOnly && !stopPropagation) { + NSLog(@"WARNING: Read-only SCSettings instance can't update values (setting %@ to %@)", key, value); + return; + } + + // we can't store nils in a dictionary + // so we sneak around it + if (value == nil) { + value = [NSNull null]; + } + + // locking everything on self is kinda inefficient/unnecessary + // since it means we can only set one value at a time, and never when reading/writing from disk + // but it seems to be OK for now - we'll improve later + @synchronized (self) { + // if we're about to insert NSNull anyway, may as well just unset the value + if ([value isEqual: [NSNull null]]) { + [self.settingsDict removeObjectForKey: key]; + } else { + [self.settingsDict setValue: value forKey: key]; + } + + // record the update + int newVersionNumber = [[self valueForKey: @"SettingsVersionNumber"] intValue] + 1; + [self.settingsDict setValue: [NSNumber numberWithInt: newVersionNumber] forKey: @"SettingsVersionNumber"]; + [self.settingsDict setValue: [NSDate date] forKey: @"LastSettingsUpdate"]; + } + + // notify other instances (presumably in other processes) + // stopPropagation is a flag that stops one setting change from bouncing back and forth for ages + // between two processes. It indicates that the change started in another process + if (!stopPropagation) { + [[NSDistributedNotificationCenter defaultCenter] postNotificationName: @"org.eyebeam.SelfControl.SCSettingsValueChanged" + object: self.description + userInfo: @{ + @"key": key, + @"value": value, + @"versionNumber": self.settingsDict[@"SettingsVersionNumber"], + @"date": [NSDate date] + } + options: NSNotificationDeliverImmediately | NSNotificationPostToAllSessions + ]; + } +} + +- (void)setValue:(id)value forKey:(NSString*)key { + [self setValue: value forKey: key stopPropagation: NO]; +} + +- (id)valueForKey:(NSString*)key { + id value = [self.settingsDict valueForKey: key]; + + // when we get an NSNull we have to unwrap it and remember that means nil + if ([value isEqual: [NSNull null]]) { + value = nil; + } + + // if we don't have a value in our dictionary but we do have a default value, use that instead! + if (value == nil && [self defaultSettingsDict][key] != nil) { + value = [self defaultSettingsDict][key]; + } + + return value; +} +- (BOOL)boolForKey:(NSString*)key { + return [[self valueForKey: key] boolValue]; +} + +- (void)startSyncTimer { + if (self.syncTimer != nil) { + // we already have a timer, so no need to start another + return; + } + + // set up a timer so values get synchronized to disk on a regular basis + dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0); + self.syncTimer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue); + if (self.syncTimer) { + dispatch_source_set_timer(self.syncTimer, dispatch_time(DISPATCH_TIME_NOW, SYNC_INTERVAL_SECS * NSEC_PER_SEC), SYNC_INTERVAL_SECS * NSEC_PER_SEC, SYNC_LEEWAY_SECS * NSEC_PER_SEC); + dispatch_source_set_event_handler(self.syncTimer, ^{ + [self synchronizeSettings]; + }); + dispatch_resume(self.syncTimer); + } +} +- (void)cancelSyncTimers { + if (self.syncTimer != nil) { + dispatch_source_cancel(self.syncTimer); + self.syncTimer = nil; + } + + if (self.debouncedChangeTimer != nil) { + dispatch_source_cancel(self.debouncedChangeTimer); + self.debouncedChangeTimer = nil; + } +} + +- (void)updateSentryContext { + // make sure Sentry has the latest context in the event of a crash + + NSMutableDictionary* dictCopy = [self.settingsDict mutableCopy]; + + // fill in any gaps with default values (like we did if they called valueForKey:) + for (NSString* key in [[self defaultSettingsDict] allKeys]) { + if (dictCopy[key] == nil) { + dictCopy[key] = [self defaultSettingsDict][key]; + } + } + + // eliminate privacy-sensitive data (i.e. blocklist) + // but store the blocklist length as a useful piece of debug info + id activeBlocklist = dictCopy[@"ActiveBlocklist"]; + NSUInteger blocklistLength = (activeBlocklist == nil) ? 0 : ((NSArray*)activeBlocklist).count; + [dictCopy setObject: @(blocklistLength) forKey: @"ActiveBlocklistLength"]; + [dictCopy removeObjectForKey: @"Blocklist"]; + [dictCopy removeObjectForKey: @"ActiveBlocklist"]; + + // and serialize dates to string, since Sentry has a hard time with that + NSArray* dateKeys = @[@"BlockEndDate", @"LastSettingsUpdate"]; + for (NSString* dateKey in dateKeys) { + dictCopy[dateKey] = [NSDateFormatter localizedStringFromDate: dictCopy[dateKey] + dateStyle: NSDateFormatterShortStyle + timeStyle: NSDateFormatterFullStyle]; + } + +#ifndef TESTING +// [SentrySDK configureScope:^(SentryScope * _Nonnull scope) { +// [scope setContextValue: dictCopy forKey: @"SCSettings"]; +// }]; +#endif +} + +- (void)onSettingChanged:(NSNotification*)note { + // note.object is a string, so we can't just do a simple == to see if the object is self + // but if we check our description against it, that will do the same thing because description + // includes the memory address. Don't override description or this logic will break!! + if ([note.object isEqualToString: [self description]]) { + // we don't need to listen to our own notifications + return; + } + + if (note.userInfo[@"key"] == nil) { + // something's wrong - we don't have a key to set + return; + } + + // if this change happened before our latest update, it's kinda unclear what the end state should be + // so ignore it and just queue up a sync instead + int noteVersionNumber = [note.userInfo[@"versionNumber"] intValue]; + NSDate* noteSettingUpdated = note.userInfo[@"date"]; + int ourSettingsVersionNumber = [[self valueForKey: @"SettingsVersionNumber"] intValue]; + NSDate* ourSettingsLastUpdated = [self valueForKey: @"LastSettingsUpdate"]; + + // check by version number, tiebreak by last updated date + BOOL noteMoreRecentThanSettings = NO; + if (noteVersionNumber == ourSettingsVersionNumber) { + noteMoreRecentThanSettings = ([noteSettingUpdated timeIntervalSinceDate: ourSettingsLastUpdated] > 0); + } else { + noteMoreRecentThanSettings = (noteVersionNumber > ourSettingsVersionNumber); + } + + if (!noteMoreRecentThanSettings) { + NSLog(@"Ignoring setting change notification as %@ is older than %@", noteSettingUpdated, ourSettingsLastUpdated); + } else { + NSLog(@"Accepting propagated change (%@ --> %@) since version %d is newer than %d and/or %@ is newer than %@", note.userInfo[@"key"], note.userInfo[@"value"], noteVersionNumber, ourSettingsVersionNumber, noteSettingUpdated, ourSettingsLastUpdated); + + // mirror the change on our own instance - but don't propagate the change to avoid loopin + [self setValue: note.userInfo[@"value"] forKey: note.userInfo[@"key"] stopPropagation: YES]; + } + + // regardless of which is more recent, we should really go get the new deal from disk + // in the near future (but debounce so we don't do this a million times for rapid changes) + if (self.debouncedChangeTimer != nil) { + dispatch_source_cancel(self.debouncedChangeTimer); + self.debouncedChangeTimer = nil; + } + dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0); + double throttleSecs = 0.25f; + self.debouncedChangeTimer = [SCMiscUtilities createDebounceDispatchTimer: throttleSecs + queue: queue + block: ^{ + NSLog(@"Syncing settings due to propagated changes"); + [self synchronizeSettings]; + }]; +} + +- (void)resetAllSettingsToDefaults { + // we _basically_ just copy the default settings dict in, + // except we leave the settings version number and last settings update + // intact - that helps keep us in sync with any other instances + NSDictionary* defaultSettings = [self defaultSettingsDict]; + for (NSString* key in defaultSettings) { + if ([key isEqualToString: @"SettingsVersionNumber"] || [key isEqualToString: @"LastSettingsUpdate"]) { + continue; + } + + [self setValue: defaultSettings[key] forKey: key]; + } +} + +- (void)dealloc { + [self cancelSyncTimers]; +} + +@synthesize settingsDict = _settingsDict, lastSynchronizedWithDisk; + +@end diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/SCXPCAuthorization.h b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/SCXPCAuthorization.h new file mode 100644 index 00000000..893836f2 --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/SCXPCAuthorization.h @@ -0,0 +1,24 @@ +// +// SCXPCAuthorization.h +// SelfControl +// +// Created by Charlie Stigler on 1/4/21. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface SCXPCAuthorization : NSObject + ++ (NSError *)checkAuthorization:(NSData *)authData command:(SEL)command; + ++ (NSString *)authorizationRightForCommand:(SEL)command; + // For a given command selector, return the associated authorization right name. + ++ (void)setupAuthorizationRights:(AuthorizationRef)authRef; + // Set up the default authorization rights in the authorization database. + +@end + +NS_ASSUME_NONNULL_END diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/SCXPCAuthorization.m b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/SCXPCAuthorization.m new file mode 100644 index 00000000..a6907931 --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/SCXPCAuthorization.m @@ -0,0 +1,186 @@ +// +// SCXPCAuthorization.m +// SelfControl +// +// Created by Charlie Stigler on 1/4/21. +// + +#import "SCXPCAuthorization.h" + +@implementation SCXPCAuthorization + +// all of these methods (basically this whole file) copied from Apple's Even Better Authorization Sample code + +static NSString * kCommandKeyAuthRightName = @"authRightName"; +static NSString * kCommandKeyAuthRightDefault = @"authRightDefault"; +static NSString * kCommandKeyAuthRightDesc = @"authRightDescription"; + +static NSDictionary* kAuthorizationRuleAuthenticateAsAdmin2MinTimeout; + +// copied from Apple's Even Better Authorization Sample code ++ (NSError *)checkAuthorization:(NSData *)authData command:(SEL)command + // Check that the client denoted by authData is allowed to run the specified command. + // authData is expected to be an NSData with an AuthorizationExternalForm embedded inside. +{ + #pragma unused(authData) + AuthorizationRef authRef; + + assert(command != nil); + + authRef = NULL; + + // First check that authData looks reasonable. + if ( (authData == nil) || ([authData length] != sizeof(AuthorizationExternalForm)) ) { + return [NSError errorWithDomain:NSOSStatusErrorDomain code:paramErr userInfo:nil]; + } + + // Create an authorization ref from that the external form data contained within. + OSStatus extFormStatus = AuthorizationCreateFromExternalForm([authData bytes], &authRef); + if (extFormStatus != errAuthorizationSuccess) { + return [NSError errorWithDomain: NSOSStatusErrorDomain code: extFormStatus userInfo: nil]; + } + + // Authorize the right associated with the command. + + AuthorizationItem oneRight = { NULL, 0, NULL, 0 }; + AuthorizationRights rights = { 1, &oneRight }; + AuthorizationFlags flags = kAuthorizationFlagDefaults | kAuthorizationFlagExtendRights | kAuthorizationFlagInteractionAllowed; + + oneRight.name = [[SCXPCAuthorization authorizationRightForCommand:command] UTF8String]; + assert(oneRight.name != NULL); + + OSStatus authStatus = AuthorizationCopyRights( + authRef, + &rights, + kAuthorizationEmptyEnvironment, + flags, + NULL + ); + if (authRef != NULL) { + AuthorizationFree(authRef, 0); + } + + if (authStatus != errAuthorizationSuccess) { + return [NSError errorWithDomain: NSOSStatusErrorDomain code: authStatus userInfo: nil]; + } + + return nil; +} + + ++ (NSDictionary *)commandInfo +{ + static dispatch_once_t sOnceToken; + static NSDictionary * sCommandInfo; + + // static var needs to bre defined before first use + if (kAuthorizationRuleAuthenticateAsAdmin2MinTimeout == nil) { + kAuthorizationRuleAuthenticateAsAdmin2MinTimeout = @{ + @"class": @"user", + @"group": @"admin", + @"timeout": @(120), // 2 minutes + @"shared": @(YES), + @"version": @1 // not entirely sure what this does TBH + }; + } + + dispatch_once(&sOnceToken, ^{ + #pragma clang diagnostic ignored "-Wundeclared-selector" + + + NSDictionary* startBlockCommandInfo = @{ + kCommandKeyAuthRightName : @"com.application.SelfControl.corebits.startBlock", + kCommandKeyAuthRightDefault : kAuthorizationRuleAuthenticateAsAdmin2MinTimeout, + kCommandKeyAuthRightDesc : NSLocalizedString( + @"SelfControl needs your username and password to start the block.", + @"prompt shown when user is required to authorize to start block" + ) + }; + NSDictionary* modifyBlockCommandInfo = @{ + kCommandKeyAuthRightName : @"com.application.SelfControl.corebits.modifyBlock", + kCommandKeyAuthRightDefault : kAuthorizationRuleAuthenticateAsAdmin2MinTimeout, + kCommandKeyAuthRightDesc : NSLocalizedString( + @"SelfControl needs your username and password to modify the block", + @"prompt shown when user is required to authorize to modify their block" + ) + }; + + sCommandInfo = @{ + NSStringFromSelector(@selector(startBlockWithControllingUID:blocklist:isAllowlist:endDate:blockSettings:authorization:reply:)) : startBlockCommandInfo, + NSStringFromSelector(@selector(updateBlocklist:authorization:reply:)) : modifyBlockCommandInfo, + NSStringFromSelector(@selector(updateBlockEndDate:authorization:reply:)) : modifyBlockCommandInfo + #pragma clang diagnostic pop + }; + }); + return sCommandInfo; +} + ++ (void)enumerateRightsUsingBlock:(void (^)(NSString * authRightName, id authRightDefault, NSString * authRightDesc))block + // Calls the supplied block with information about each known authorization right.. +{ + [self.commandInfo enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) { + #pragma unused(key) + #pragma unused(stop) + NSDictionary * commandDict; + NSString * authRightName; + id authRightDefault; + NSString * authRightDesc; + + // If any of the following asserts fire it's likely that you've got a bug + // in sCommandInfo. + + commandDict = (NSDictionary *) obj; + assert([commandDict isKindOfClass:[NSDictionary class]]); + + authRightName = [commandDict objectForKey:kCommandKeyAuthRightName]; + assert([authRightName isKindOfClass:[NSString class]]); + + authRightDefault = [commandDict objectForKey:kCommandKeyAuthRightDefault]; + assert(authRightDefault != nil); + + authRightDesc = [commandDict objectForKey:kCommandKeyAuthRightDesc]; + assert([authRightDesc isKindOfClass:[NSString class]]); + + block(authRightName, authRightDefault, authRightDesc); + }]; +} + ++ (void)setupAuthorizationRights:(AuthorizationRef)authRef + // See comment in header. +{ + assert(authRef != NULL); + [SCXPCAuthorization enumerateRightsUsingBlock:^(NSString * authRightName, id authRightDefault, NSString * authRightDesc) { + OSStatus blockErr; + + // First get the right. If we get back errAuthorizationDenied that means there's + // no current definition, so we add our default one. + + blockErr = AuthorizationRightGet([authRightName UTF8String], NULL); + if (blockErr == errAuthorizationDenied) { + NSLog(@"setting auth right default for %@: %@", authRightName, authRightDefault); + blockErr = AuthorizationRightSet( + authRef, // authRef + [authRightName UTF8String], // rightName + (__bridge CFTypeRef) authRightDefault, // rightDefinition + (__bridge CFStringRef) authRightDesc, // descriptionKey + NULL, // bundle (NULL implies main bundle) + CFSTR("SCXPCAuthorization") // localeTableName + ); + assert(blockErr == errAuthorizationSuccess); + } else { + // A right already exists (err == noErr) or any other error occurs, we + // assume that it has been set up in advance by the system administrator or + // this is the second time we've run. Either way, there's nothing more for + // us to do. + } + }]; +} + ++ (NSString *)authorizationRightForCommand:(SEL)command + // See comment in header. +{ + return [self commandInfo][NSStringFromSelector(command)][kCommandKeyAuthRightName]; +} + + +@end diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/SCXPCClient.h b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/SCXPCClient.h new file mode 100644 index 00000000..dd9f06c3 --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/SCXPCClient.h @@ -0,0 +1,28 @@ +// +// SCAppXPC.h +// SelfControl +// +// Created by Charlie Stigler on 7/4/20. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface SCXPCClient : NSObject + +@property (readonly, getter=isConnected) BOOL connected; + +- (void)connectToHelperTool; +- (void)installDaemon:(void(^)(NSError*))callback; +- (void)refreshConnectionAndRun:(void(^)(void))callback; +- (void)connectAndExecuteCommandBlock:(void(^)(NSError *))commandBlock; + +- (void)getVersion:(void(^)(NSString* version, NSError* error))reply; +- (void)startBlockWithControllingUID:(uid_t)controllingUID blocklist:(NSArray*)blocklist isAllowlist:(BOOL)isAllowlist endDate:(NSDate*)endDate blockSettings:(NSDictionary*)blockSettings reply:(void(^)(NSError* error))reply; +- (void)updateBlocklist:(NSArray*)newBlocklist reply:(void(^)(NSError* error))reply; +- (void)updateBlockEndDate:(NSDate*)newEndDate reply:(void(^)(NSError* error))reply; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/SCXPCClient.m b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/SCXPCClient.m new file mode 100644 index 00000000..024f52fc --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/SCXPCClient.m @@ -0,0 +1,352 @@ +// +// SCAppXPC.m +// SelfControl +// +// Created by Charlie Stigler on 7/4/20. +// + +#import "SCXPCClient.h" +#import "SCDaemonProtocol.h" +#import +#import "SCXPCAuthorization.h" +#import "SCErr.h" + +@interface SCXPCClient () { + AuthorizationRef _authRef; +} + +@property (atomic, strong, readwrite) NSXPCConnection* daemonConnection; +@property (atomic, copy, readwrite) NSData* authorization; + +@end + +@implementation SCXPCClient + +- (void)setupAuthorization { + // this is mostly copied from Apple's Even Better Authorization Sample + + // Create our connection to the authorization system. + // + // If we can't create an authorization reference then the app is not going to be able + // to do anything requiring authorization. Generally this only happens when you launch + // the app in some wacky, and typically unsupported, way. + + // if we've already got an authorization session, no need to make another + if (self.authorization) { + return; + } + + AuthorizationRef authRef; + OSStatus errCode = AuthorizationCreate(NULL, kAuthorizationEmptyEnvironment, 0, &authRef); + if (errCode) { + NSError* err = [NSError errorWithDomain: NSOSStatusErrorDomain code: errCode userInfo: nil]; + NSLog(@"Failed to set up initial authorization with error %@", err); + // [SCSentry captureError: err]; + } else { + [self updateStoredAuthorization: authRef]; + } +} + +- (void)updateStoredAuthorization:(AuthorizationRef)authRef { + self->_authRef = authRef; + if (!self->_authRef) { + self.authorization = nil; + return; + } + + AuthorizationExternalForm extForm; + OSStatus errCode = AuthorizationMakeExternalForm(self->_authRef, &extForm); + if (errCode) { + NSError* err = [NSError errorWithDomain: NSOSStatusErrorDomain code: errCode userInfo: nil]; + NSLog(@"Failed to update stored authorization with error %@", err); + // [SCSentry captureError: err]; + } else { + self.authorization = [[NSData alloc] initWithBytes: &extForm length: sizeof(extForm)]; + } + + // If we successfully connected to Authorization Services, add definitions for our default + // rights (unless they're already in the database). + [SCXPCAuthorization setupAuthorizationRights: self->_authRef]; +} + +// Ensures that we're connected to our helper tool +// should only be called from the main thread +// Copied from Apple's EvenBetterAuthorizationSample +- (void)connectToHelperTool { + assert([NSThread isMainThread]); + NSLog(@"Connecting to helper tool, daemon connection is %@", self.daemonConnection); + [self setupAuthorization]; + + if (self.daemonConnection == nil) { + self.daemonConnection = [[NSXPCConnection alloc] initWithMachServiceName: @"org.eyebeam.selfcontrold" options: NSXPCConnectionPrivileged]; + self.daemonConnection.remoteObjectInterface = [NSXPCInterface interfaceWithProtocol:@protocol(SCDaemonProtocol)]; + #pragma clang diagnostic push + #pragma clang diagnostic ignored "-Warc-retain-cycles" + NSXPCConnection* connection = self.daemonConnection; + connection.invalidationHandler = ^{ + connection.invalidationHandler = connection.interruptionHandler = nil; + + if (connection == self.daemonConnection) { + if ([NSThread isMainThread]) { + self.daemonConnection = nil; + } else { + NSLog(@"About to dispatch_sync"); + dispatch_sync(dispatch_get_main_queue(), ^{ + self.daemonConnection = nil; + }); + } + NSLog(@"CONNECTION INVALIDATED"); + } + }; + // our interruption handler is just our invalidation handler, except we retry afterward + connection.interruptionHandler = ^{ + NSLog(@"Helper tool connection interrupted"); + if (connection.invalidationHandler != nil) { + connection.invalidationHandler(); + } + + // interruptions may have happened because the daemon crashed + // so wait a second and try to reconnect + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 1 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{ + NSLog(@"Retrying helper tool connection!"); + [self connectToHelperTool]; + }); + }; + + #pragma clang diagnostic pop + [self.daemonConnection resume]; + + NSLog(@"Started helper connection!"); + } +} + +- (BOOL)isConnected { + return (self.daemonConnection != nil); +} + +- (void)installDaemon:(void(^)(NSError*))callback { + // make sure authorization is set up (if we haven't connected yet) + [self setupAuthorization]; + + AuthorizationItem blessRight = { + kSMRightBlessPrivilegedHelper, 0, NULL, 0 + }; + AuthorizationItem startBlockRight = { + "com.application.SelfControl.corebits.startBlock", 0, NULL, 0 + }; + AuthorizationItem rightsArr[] = { blessRight, startBlockRight }; + + AuthorizationRights authRights; + authRights.count = 2; + authRights.items = rightsArr; + + AuthorizationFlags myFlags = kAuthorizationFlagDefaults | + kAuthorizationFlagExtendRights | + kAuthorizationFlagInteractionAllowed; + OSStatus status; + + status = AuthorizationCopyRights( + self->_authRef, + &authRights, + kAuthorizationEmptyEnvironment, + myFlags, + NULL + ); + + if(status) { + if (status == AUTH_CANCELLED_STATUS) { + callback([SCErr errorWithCode: 1]); + } else { + NSLog(@"ERROR: Failed to authorize installing selfcontrold with status %d.", status); + NSError* err = [SCErr errorWithCode: 501]; + // [SCSentry captureError: err]; + callback(err); + } + return; + } + + CFErrorRef cfError = NULL; + + // In some cases, SMJobBless will fail if we don't first remove the currently running daemon. + // Attempt removal, but log detailed error info if it fails. + SILENCE_OSX10_10_DEPRECATION( + SMJobRemove(kSMDomainSystemLaunchd, CFSTR("org.eyebeam.selfcontrold"), self->_authRef, YES, &cfError); + ); + if (cfError) { + NSError *removeErr = CFBridgingRelease(cfError); + NSLog(@"WARNING: Failed to remove existing selfcontrold daemon. Domain=%@ Code=%ld UserInfo=%@", + removeErr.domain, (long)removeErr.code, removeErr.userInfo); + // reset for next use + cfError = NULL; + } + + BOOL result = (BOOL)SMJobBless( + kSMDomainSystemLaunchd, + CFSTR("org.eyebeam.selfcontrold"), + self->_authRef, + &cfError); + + if(!result) { + NSError* error = CFBridgingRelease(cfError); + // Log the full error so we can diagnose configuration/signing issues. + NSLog(@"WARNING: Authorized installation of selfcontrold returned failure. Domain=%@ Code=%ld UserInfo=%@", + error.domain, (long)error.code, error.userInfo); + + NSError* err = [SCErr errorWithCode: 500 subDescription: error.localizedDescription]; + if (![SCMiscUtilities errorIsAuthCanceled: error]) { + // [SCSentry captureError: err]; + } + + callback(err); + return; + } else { + NSLog(@"Daemon installed successfully!"); + callback(nil); + } +} + +- (BOOL)connectionIsActive { + return (self.daemonConnection != nil); +} + +- (void)refreshConnectionAndRun:(void(^)(void))callback { + if (self.daemonConnection == nil) { + callback(); + return; + } + void (^standardInvalidationHandler)(void) = self.daemonConnection.invalidationHandler; + + __weak typeof(self) weakSelf = self; + self.daemonConnection.invalidationHandler = ^{ + __strong typeof(self) strongSelf = weakSelf; + if (standardInvalidationHandler != nil) { + standardInvalidationHandler(); + } + strongSelf.daemonConnection = nil; + callback(); + }; + + [self.daemonConnection performSelectorOnMainThread: @selector(invalidate) withObject: nil waitUntilDone: YES]; +} + +// Also copied from Apple's EvenBetterAuthorizationSample +// Connects to the helper tool and then executes the supplied command block on the +// main thread, passing it an error indicating if the connection was successful. +- (void)connectAndExecuteCommandBlock:(void(^)(NSError *))commandBlock { + [self performSelectorOnMainThread: @selector(connectToHelperTool) withObject:nil waitUntilDone: YES]; + commandBlock(nil); +} + +- (void)getVersion:(void(^)(NSString* version, NSError* error))reply { + [self connectAndExecuteCommandBlock:^(NSError * connectError) { + if (connectError != nil) { + NSLog(@"Failed to get daemon version with connection error: %@", connectError); + // [SCSentry captureError: connectError]; + reply(nil, connectError); + } else { + [[self.daemonConnection remoteObjectProxyWithErrorHandler:^(NSError * proxyError) { + NSLog(@"Failed to get daemon version with remote object proxy error: %@", proxyError); + // [SCSentry captureError: proxyError]; + reply(nil, proxyError); + }] getVersionWithReply:^(NSString * _Nonnull version) { + reply(version, nil); + }]; + } + }]; +} + +- (void)startBlockWithControllingUID:(uid_t)controllingUID blocklist:(NSArray*)blocklist isAllowlist:(BOOL)isAllowlist endDate:(NSDate*)endDate blockSettings:(NSDictionary*)blockSettings reply:(void(^)(NSError* error))reply { + [self connectAndExecuteCommandBlock:^(NSError * connectError) { + if (connectError != nil) { + // [SCSentry captureError: connectError]; + NSLog(@"Start block command failed with connection error: %@", connectError); + reply(connectError); + } else { + [[self.daemonConnection remoteObjectProxyWithErrorHandler:^(NSError * proxyError) { + NSLog(@"Start block command failed with remote object proxy error: %@", proxyError); + // [SCSentry captureError: proxyError]; + reply(proxyError); + }] startBlockWithControllingUID: controllingUID blocklist: blocklist isAllowlist:isAllowlist endDate:endDate blockSettings: blockSettings authorization: self.authorization reply:^(NSError* error) { + if (error != nil && ![SCMiscUtilities errorIsAuthCanceled: error]) { + NSLog(@"Start block failed with error = %@\n", error); + // [SCSentry captureError: error]; + } + reply(error); + }]; + } + }]; +} + +- (void)updateBlocklist:(NSArray*)newBlocklist reply:(void(^)(NSError* error))reply { + [self connectAndExecuteCommandBlock:^(NSError * connectError) { + if (connectError != nil) { + NSLog(@"Blocklist update failed with connection error: %@", connectError); + // [SCSentry captureError: connectError]; + reply(connectError); + } else { + [[self.daemonConnection remoteObjectProxyWithErrorHandler:^(NSError * proxyError) { + NSLog(@"Blocklist update command failed with remote object proxy error: %@", proxyError); + // [SCSentry captureError: proxyError]; + reply(proxyError); + }] updateBlocklist: newBlocklist authorization: self.authorization reply:^(NSError* error) { + if (error != nil && ![SCMiscUtilities errorIsAuthCanceled: error]) { + NSLog(@"Blocklist update failed with error = %@\n", error); + // [SCSentry captureError: error]; + } + reply(error); + }]; + } + }]; +} + +- (void)updateBlockEndDate:(NSDate*)newEndDate reply:(void(^)(NSError* error))reply { + [self connectAndExecuteCommandBlock:^(NSError * connectError) { + if (connectError != nil) { + NSLog(@"Block end date update failed with connection error: %@", connectError); + // [SCSentry captureError: connectError]; + reply(connectError); + } else { + [[self.daemonConnection remoteObjectProxyWithErrorHandler:^(NSError * proxyError) { + NSLog(@"Block end date update command failed with remote object proxy error: %@", proxyError); + // [SCSentry captureError: proxyError]; + reply(proxyError); + }] updateBlockEndDate: newEndDate authorization: self.authorization reply:^(NSError* error) { + if (error != nil && ![SCMiscUtilities errorIsAuthCanceled: error]) { + NSLog(@"Block end date update failed with error = %@\n", error); + // [SCSentry captureError: error]; + } + reply(error); + }]; + } + }]; +} + +- (NSString*)selfControlHelperToolPath { + static NSString* path; + + // Cache the path so it doesn't have to be searched for again. + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + NSBundle* thisBundle = [NSBundle mainBundle]; + path = [thisBundle.bundlePath stringByAppendingString: @"/Contents/Library/LaunchServices/org.eyebeam.selfcontrold"]; + }); + + return path; +} + +- (char*)selfControlHelperToolPathUTF8String { + static char* path; + + // Cache the converted path so it doesn't have to be converted again + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + path = malloc(512); + [[self selfControlHelperToolPath] getCString: path + maxLength: 512 + encoding: NSUTF8StringEncoding]; + }); + + return path; +} + +@end diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/Utilities/LaunchctlHelper.h b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/Utilities/LaunchctlHelper.h new file mode 100755 index 00000000..3dec6a77 --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/Utilities/LaunchctlHelper.h @@ -0,0 +1,35 @@ +// +// SCLaunchctlHelper.h +// SelfControl +// +// Created by Charlie Stigler on 2/13/09. +// Copyright 2009 Eyebeam. + +// This file is part of SelfControl. +// +// SelfControl is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +#import + +// A simple utility class to deal with launchd jobs by calling the launchctl +// command-line tool. Each method corresponds to a launchctl subcommand. +@interface LaunchctlHelper : NSObject { +} + +// Calls the launchctl command-line tool installed on all newer Mac OS X +// systems to unload a job from the launchd system, which was loaded from the +// plist at the given path. Returns the exit status code of launchctl. ++ (int)unloadLaunchdJobWithPlistAt:(NSString*)pathToLaunchdPlist; + +@end diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/Utilities/LaunchctlHelper.m b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/Utilities/LaunchctlHelper.m new file mode 100755 index 00000000..2269fb98 --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/Utilities/LaunchctlHelper.m @@ -0,0 +1,38 @@ +// +// SCLaunchctlHelper.m +// SelfControl +// +// Created by Charlie Stigler on 2/13/09. +// Copyright 2009 Eyebeam. + +// This file is part of SelfControl. +// +// SelfControl is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +#import "LaunchctlHelper.h" + +@implementation LaunchctlHelper + ++ (int)unloadLaunchdJobWithPlistAt:(NSString*)pathToLaunchdPlist { + NSTask* task = [[NSTask alloc] init]; + [task setLaunchPath: @"/bin/launchctl"]; + [task setArguments: @[@"unload", + @"-w", + pathToLaunchdPlist]]; + [task launch]; + [task waitUntilExit]; + return [task terminationStatus]; +} + +@end diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/Utilities/SCBlockUtilities.h b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/Utilities/SCBlockUtilities.h new file mode 100644 index 00000000..3c90ce89 --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/Utilities/SCBlockUtilities.h @@ -0,0 +1,27 @@ +// +// SCBlockUtilities.h +// SelfControl +// +// Created by Charlie Stigler on 1/19/21. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface SCBlockUtilities : NSObject + +// uses the below methods as well as filesystem checks to see if the block is REALLY running or not ++ (BOOL)anyBlockIsRunning; ++ (BOOL)modernBlockIsRunning; ++ (BOOL)legacyBlockIsRunning; + ++ (BOOL)currentBlockIsExpired; + ++ (BOOL)blockRulesFoundOnSystem; + ++ (void)removeBlockFromSettings; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/Utilities/SCBlockUtilities.m b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/Utilities/SCBlockUtilities.m new file mode 100644 index 00000000..ad0efde5 --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/Utilities/SCBlockUtilities.m @@ -0,0 +1,76 @@ +// +// SCBlockUtilities.m +// SelfControl +// +// Created by Charlie Stigler on 1/19/21. +// + +#import "SCBlockUtilities.h" +#import "HostFileBlocker.h" +#import "PacketFilter.h" + +@implementation SCBlockUtilities + ++ (BOOL)anyBlockIsRunning { + BOOL blockIsRunning = [SCBlockUtilities modernBlockIsRunning] || [SCBlockUtilities legacyBlockIsRunning]; + + return blockIsRunning; +} + ++ (BOOL)modernBlockIsRunning { + SCSettings* settings = [SCSettings sharedSettings]; + + return [settings boolForKey: @"BlockIsRunning"]; +} + ++ (BOOL)legacyBlockIsRunning { + // first see if there's a legacy settings file from v3.x + // which could be in any user's home folder +// NSError* homeDirErr = nil; +// NSArray* homeDirectoryURLs = [SCMiscUtilities allUserHomeDirectoryURLs: &homeDirErr]; +// if (homeDirectoryURLs != nil) { +// for (NSURL* homeDirURL in homeDirectoryURLs) { +// NSString* relativeSettingsPath = [NSString stringWithFormat: @"/Library/Preferences/%@", SCSettings.settingsFileName]; +// NSURL* settingsFileURL = [homeDirURL URLByAppendingPathComponent: relativeSettingsPath isDirectory: NO]; +// +// if ([SCMigrationUtilities legacyBlockIsRunningInSettingsFile: settingsFileURL]) { +// return YES; +// } +// } +// } +// +// // nope? OK, how about a lock file from pre-3.0? +// if ([SCMigrationUtilities legacyLockFileExists]) { +// return YES; +// } + + // we don't check defaults anymore, though pre-3.0 blocks did + // have data stored there. That should be covered by the lockfile anyway + + return NO; +} + +// returns YES if the block should have expired active based on the specified end time (i.e. the end time is in the past), or NO otherwise ++ (BOOL)currentBlockIsExpired { + // the block should be running if the end date hasn't arrived yet + SCSettings* settings = [SCSettings sharedSettings]; + if ([[settings valueForKey: @"BlockEndDate"] timeIntervalSinceNow] > 0) { + return NO; + } else { + return YES; + } +} + ++ (BOOL)blockRulesFoundOnSystem { + return [PacketFilter blockFoundInPF] || [HostFileBlocker blockFoundInHostsFile]; +} + ++ (void) removeBlockFromSettings { + SCSettings* settings = [SCSettings sharedSettings]; + [settings setValue: @NO forKey: @"BlockIsRunning"]; + [settings setValue: nil forKey: @"BlockEndDate"]; + [settings setValue: nil forKey: @"ActiveBlocklist"]; + [settings setValue: nil forKey: @"ActiveBlockAsWhitelist"]; +} + +@end diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/Utilities/SCConstants.h b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/Utilities/SCConstants.h new file mode 100644 index 00000000..3758d999 --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/Utilities/SCConstants.h @@ -0,0 +1,21 @@ +// +// SCConstants.h +// SelfControl +// +// Created by Charlie Stigler on 3/31/19. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +extern OSStatus const AUTH_CANCELLED_STATUS; + +@interface SCConstants : NSObject + +@property (class, readonly, nonatomic) NSArray* systemSoundNames; +@property (class, readonly, nonatomic) NSDictionary* const defaultUserDefaults; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/Utilities/SCConstants.m b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/Utilities/SCConstants.m new file mode 100644 index 00000000..79e07a07 --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/Utilities/SCConstants.m @@ -0,0 +1,75 @@ +// +// SCConstants.m +// SelfControl +// +// Created by Charlie Stigler on 3/31/19. +// + +#import "SCConstants.h" + +OSStatus const AUTH_CANCELLED_STATUS = -60006; + +@implementation SCConstants + ++ (NSArray*)systemSoundNames { + static NSArray* soundsArr = nil; + + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + soundsArr = @[@"Basso", + @"Blow", + @"Bottle", + @"Frog", + @"Funk", + @"Glass", + @"Hero", + @"Morse", + @"Ping", + @"Pop", + @"Purr", + @"Sosumi", + @"Submarine", + @"Tink"]; + }); + + return soundsArr; +} + ++ (NSDictionary*)defaultUserDefaults { + static NSDictionary* defaultDefaultsDict = nil; + + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + defaultDefaultsDict = @{ + @"Blocklist": @[], + @"BlockAsWhitelist": @NO, + @"HighlightInvalidHosts": @YES, + @"VerifyInternetConnection": @YES, + @"TimerWindowFloats": @NO, + @"BadgeApplicationIcon": @YES, + @"BlockDuration": @1, + @"MaxBlockLength": @1440, + @"WhitelistAlertSuppress": @NO, + @"GetStartedShown": @NO, + @"EvaluateCommonSubdomains": @YES, + @"IncludeLinkedDomains": @YES, + @"BlockSoundShouldPlay": @NO, + @"BlockSound": @5, + @"ClearCaches": @YES, + @"AllowLocalNetworks": @YES, + // if the user has checked the box to "send crash reports to third-party developers", we'll default Sentry on + // otherwise it defaults off, but we'll still prompt to ask them if we can send data +// @"EnableErrorReporting": @([SCMiscUtilities systemThirdPartyCrashReportingEnabled]), + @"ErrorReportingPromptDismissed": @NO, + @"SuppressLongBlockWarning": @NO, + @"SuppressRestartFirefoxWarning": @NO, + @"FirstBlockStarted": @NO, + + @"V4MigrationComplete": @NO + }; + }); + + return defaultDefaultsDict; +} + +@end diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/Utilities/SCHelperToolUtilities.h b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/Utilities/SCHelperToolUtilities.h new file mode 100644 index 00000000..3efe4a09 --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/Utilities/SCHelperToolUtilities.h @@ -0,0 +1,45 @@ +// +// SCHelperToolUtilities.h +// SelfControl +// +// Created by Charlie Stigler on 1/19/21. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +// Utility methods athat are only used by the helper tools +// (i.e. selfcontrold, selfcontrol-cli, and SCKillerHelper) +// note that this is NOT included in SCUtility.h currently! +@interface SCHelperToolUtilities : NSObject + +// Reads the domain block list from the settings for SelfControl, and adds deny +// rules for all of the IPs (or the A DNS record IPS for doamin names) to the +// ipfw firewall. ++ (void)installBlockRulesFromSettings; + +// calls SMJobRemove to unload the daemon from launchd +// (which also kills the running process, synchronously) ++ (void)unloadDaemonJob; + +// Checks the settings system to see whether the user wants their web browser +// caches cleared, and deletes the specific cache folders for a few common +// web browsers if it is required. ++ (void)clearCachesIfRequested; + +// Clear only the caches for browsers ++ (NSError*)clearBrowserCaches; + +// Clear only the OS-level DNS cache ++ (void)clearOSDNSCache; + +// Removes block via settings, host file rules and ipfw rules, +// deleting user caches if requested, and migrating legacy settings. ++ (void)removeBlock; + ++ (void)sendConfigurationChangedNotification; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/Utilities/SCHelperToolUtilities.m b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/Utilities/SCHelperToolUtilities.m new file mode 100644 index 00000000..d526eaf0 --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/Utilities/SCHelperToolUtilities.m @@ -0,0 +1,183 @@ +// +// SCHelperToolUtilities.m +// SelfControl +// +// Created by Charlie Stigler on 1/19/21. +// + +#import "SCHelperToolUtilities.h" +#import "BlockManager.h" +#import "SCBlockUtilities.h" + +#import + +@implementation SCHelperToolUtilities + ++ (void)installBlockRulesFromSettings { + SCSettings* settings = [SCSettings sharedSettings]; + BOOL shouldEvaluateCommonSubdomains = [settings boolForKey: @"EvaluateCommonSubdomains"]; + BOOL allowLocalNetworks = [settings boolForKey: @"AllowLocalNetworks"]; + BOOL includeLinkedDomains = [settings boolForKey: @"IncludeLinkedDomains"]; + + // get value for ActiveBlockAsWhitelist + BOOL blockAsAllowlist = [settings boolForKey: @"ActiveBlockAsWhitelist"]; + + BlockManager* blockManager = [[BlockManager alloc] initAsAllowlist: blockAsAllowlist allowLocal: allowLocalNetworks includeCommonSubdomains: shouldEvaluateCommonSubdomains includeLinkedDomains: includeLinkedDomains]; + + NSLog(@"About to run BlockManager commands"); + + [blockManager prepareToAddBlock]; + [blockManager addBlockEntriesFromStrings: [settings valueForKey: @"ActiveBlocklist"]]; + [blockManager finalizeBlock]; + +} + ++ (void)unloadDaemonJob { + NSLog(@"Unloading SelfControl daemon..."); + // [SCSentry addBreadcrumb: @"Daemon about to unload" category: @"daemon"]; + SCSettings* settings = [SCSettings sharedSettings]; + + // we're about to unload the launchd job + // this will kill this process, so we have to make sure + // all settings are synced before we unload + NSError* syncErr = [settings syncSettingsAndWait: 5.0]; + if (syncErr != nil) { + NSLog(@"WARNING: Sync failed or timed out with error %@ before unloading daemon job", syncErr); + // [SCSentry captureError: syncErr]; + } + + // uh-oh, looks like it's 5 seconds later and the sync hasn't completed yet. Bad news. + CFErrorRef cfError; + // this should block until the process is dead, so we should never get to the other side if it's successful + SILENCE_OSX10_10_DEPRECATION( + SMJobRemove(kSMDomainSystemLaunchd, CFSTR("org.eyebeam.selfcontrold"), NULL, YES, &cfError); + ); + if (cfError) { + NSLog(@"Failed to remove selfcontrold daemon with error %@", cfError); + } +} + ++ (void)clearCachesIfRequested { + SCSettings* settings = [SCSettings sharedSettings]; + if(![settings boolForKey: @"ClearCaches"]) { + return; + } + + NSError* err = [SCHelperToolUtilities clearBrowserCaches]; + if (err) { + NSLog(@"WARNING: Error clearing browser caches: %@", err); + // [SCSentry captureError: err]; + } + + [SCHelperToolUtilities clearOSDNSCache]; +} + ++ (NSError*)clearBrowserCaches { + NSFileManager* fileManager = [NSFileManager defaultManager]; + + NSError* homeDirErr = nil; + NSArray* homeDirectoryURLs = [SCMiscUtilities allUserHomeDirectoryURLs: &homeDirErr]; + if (homeDirectoryURLs == nil) return homeDirErr; + + NSArray* cacheDirPathComponents = @[ + // chrome + @"/Library/Caches/Google/Chrome/Default", + @"/Library/Caches/Google/Chrome/com.google.Chrome", + + // firefox + @"/Library/Caches/Firefox/Profiles", + + // safari + @"/Library/Caches/com.apple.Safari", + @"/Library/Containers/com.apple.Safari/Data/Library/Caches" // this one seems to fail due to permissions issues, but not sure how to fix + ]; + + + NSMutableArray* cacheDirURLs = [NSMutableArray arrayWithCapacity: cacheDirPathComponents.count * homeDirectoryURLs.count]; + for (NSURL* homeDirURL in homeDirectoryURLs) { + for (NSString* cacheDirPathComponent in cacheDirPathComponents) { + [cacheDirURLs addObject: [homeDirURL URLByAppendingPathComponent: cacheDirPathComponent isDirectory: YES]]; + } + } + + for (NSURL* cacheDirURL in cacheDirURLs) { + NSLog(@"Clearing browser cache folder %@", cacheDirURL); + // removeItemAtURL will return errors if the file doesn't exist + // so we don't track the errors - best effort is OK + [fileManager removeItemAtURL: cacheDirURL error: nil]; + } + + return nil; +} + ++ (void)clearOSDNSCache { + // no error checks - if it works it works! + NSTask* flushDsCacheUtil = [[NSTask alloc] init]; + [flushDsCacheUtil setLaunchPath: @"/usr/bin/dscacheutil"]; + [flushDsCacheUtil setArguments: @[@"-flushcache"]]; + [flushDsCacheUtil launch]; + [flushDsCacheUtil waitUntilExit]; + + NSTask* killResponder = [[NSTask alloc] init]; + [killResponder setLaunchPath: @"/usr/bin/killall"]; + [killResponder setArguments: @[@"-HUP", @"mDNSResponder"]]; + [killResponder launch]; + [killResponder waitUntilExit]; + + NSTask* killResponderHelper = [[NSTask alloc] init]; + [killResponderHelper setLaunchPath: @"/usr/bin/killall"]; + [killResponderHelper setArguments: @[@"mDNSResponderHelper"]]; + [killResponderHelper launch]; + [killResponderHelper waitUntilExit]; + + NSLog(@"Cleared OS DNS caches"); +} + ++ (void)playBlockEndSound { + SCSettings* settings = [SCSettings sharedSettings]; + if([settings boolForKey: @"BlockSoundShouldPlay"]) { + // Map the tags used in interface builder to the sound + NSArray* systemSoundNames = SCConstants.systemSoundNames; + NSSound* alertSound = [NSSound soundNamed: systemSoundNames[(NSUInteger)[[settings valueForKey: @"BlockSound"] intValue]]]; + if(!alertSound) + NSLog(@"WARNING: Alert sound not found."); + else { + [alertSound play]; + } + } +} + ++ (void)removeBlock { + [SCBlockUtilities removeBlockFromSettings]; + [[BlockManager new] clearBlock]; + + [SCHelperToolUtilities clearCachesIfRequested]; + + // play a sound letting + [SCHelperToolUtilities playBlockEndSound]; + + // always synchronize settings ASAP after removing a block to let everybody else know + // and wait until they're synced before we send the configuration change notification + // so the app has no chance of reading the data before we update it + NSError* syncErr = [[SCSettings sharedSettings] syncSettingsAndWait: 5.0]; + if (syncErr != nil) { + NSLog(@"WARNING: Sync failed or timed out with error %@ after removing block", syncErr); + // [SCSentry captureError: syncErr]; + } + + // let the main app know things have changed so it can update the UI! + [SCHelperToolUtilities sendConfigurationChangedNotification]; + + NSLog(@"INFO: Block cleared."); +} + ++ (void)sendConfigurationChangedNotification { + // if you don't include the NSNotificationPostToAllSessions option, + // it will not deliver when run by launchd (root) to the main app being run by the user + [[NSDistributedNotificationCenter defaultCenter] postNotificationName: @"SCConfigurationChangedNotification" + object: nil + userInfo: nil + options: NSNotificationDeliverImmediately | NSNotificationPostToAllSessions]; +} + +@end diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/Utilities/SCMiscUtilities.h b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/Utilities/SCMiscUtilities.h new file mode 100644 index 00000000..1658a467 --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/Utilities/SCMiscUtilities.h @@ -0,0 +1,33 @@ +// +// SCMiscUtilities.h +// SelfControl +// +// Created by Charles Stigler on 07/07/2018. +// + +#import + +// Holds utility methods for use throughout SelfControl + +@interface SCMiscUtilities : NSObject + ++ (dispatch_source_t)createDebounceDispatchTimer:(double) debounceTime queue:(dispatch_queue_t)queue block:(dispatch_block_t)block; + ++ (NSString *)getSerialNumber; ++ (NSString *)sha1:(NSString*)stringToHash; + ++ (BOOL)systemThirdPartyCrashReportingEnabled; + ++ (NSArray*)cleanBlocklistEntry:(NSString*)rawEntry; + ++ (NSArray*)cleanBlocklist:(NSArray*)blocklist; + ++ (NSDictionary*) defaultsDictForUser:(uid_t)controllingUID; + ++ (NSArray*)allUserHomeDirectoryURLs:(NSError**)errPtr; + ++ (BOOL)errorIsAuthCanceled:(NSError*)err; + ++ (NSString*)killerKeyForDate:(NSDate*)date; + +@end diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/Utilities/SCMiscUtilities.m b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/Utilities/SCMiscUtilities.m new file mode 100644 index 00000000..3b45490d --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/Common/Utilities/SCMiscUtilities.m @@ -0,0 +1,250 @@ +// +// SCMiscUtilities.m +// SelfControl +// +// Created by Charles Stigler on 07/07/2018. +// + +//#import "SCHelperToolUtilities.h" +//#import "SCSettings.h" +#import +#include + +@implementation SCMiscUtilities + +// copied from stevenojo's GitHub snippet: https://gist.github.com/stevenojo/e1dcc2b3e2fd4ed1f411eef88e254cb0 ++ (dispatch_source_t)createDebounceDispatchTimer:(double)debounceTime queue:(dispatch_queue_t)queue block:(dispatch_block_t)block { + dispatch_source_t timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue); + + if (timer) { + dispatch_source_set_timer(timer, dispatch_time(DISPATCH_TIME_NOW, debounceTime * NSEC_PER_SEC), DISPATCH_TIME_FOREVER, (1ull * NSEC_PER_SEC) / 10); + dispatch_source_set_event_handler(timer, block); + dispatch_resume(timer); + } + + return timer; +} + +// by Martin R et al on StackOverflow: https://stackoverflow.com/a/15451318 ++ (NSString *)getSerialNumber { + NSString *serial = nil; + io_service_t platformExpert = IOServiceGetMatchingService(kIOMainPortDefault, + IOServiceMatching("IOPlatformExpertDevice")); + if (platformExpert) { + CFTypeRef serialNumberAsCFString = + IORegistryEntryCreateCFProperty(platformExpert, + CFSTR(kIOPlatformSerialNumberKey), + kCFAllocatorDefault, 0); + if (serialNumberAsCFString) { + serial = CFBridgingRelease(serialNumberAsCFString); + } + + IOObjectRelease(platformExpert); + } + return serial; +} +// by hypercrypt et al on StackOverflow: https://stackoverflow.com/a/7571583 ++ (NSString *)sha1:(NSString*)stringToHash +{ + NSData *data = [stringToHash dataUsingEncoding:NSUTF8StringEncoding]; + uint8_t digest[CC_SHA1_DIGEST_LENGTH]; + + CC_SHA1(data.bytes, (CC_LONG)data.length, digest); + + NSMutableString *output = [NSMutableString stringWithCapacity:CC_SHA1_DIGEST_LENGTH * 2]; + + for (int i = 0; i < CC_SHA1_DIGEST_LENGTH; i++) + { + [output appendFormat:@"%02x", digest[i]]; + } + + return output; +} + ++ (BOOL)systemThirdPartyCrashReportingEnabled { + NSUserDefaults* appleCrashReporter = [NSUserDefaults standardUserDefaults]; + [appleCrashReporter addSuiteNamed: @"/Library/Application Support/CrashReporter/DiagnosticMessagesHistory.plist"]; + + return [appleCrashReporter boolForKey: @"ThirdPartyDataSubmit"]; +} + +// Standardize and clean up the input value so it'll block properly (and look good doing it) +// note that if the user entered line breaks, we'll split it into many entries, so this can return multiple +// cleaned entries in the NSArray it returns ++ (NSArray*) cleanBlocklistEntry:(NSString*)rawEntry { + if (rawEntry == nil) return @[]; + + // This'll remove whitespace and lowercase the string. + NSString* str = [[rawEntry stringByTrimmingCharactersInSet: [NSCharacterSet whitespaceAndNewlineCharacterSet]] lowercaseString]; + + // if there are newlines in the string, split it and process it as many strings + if([str rangeOfCharacterFromSet: [NSCharacterSet newlineCharacterSet]].location != NSNotFound) { + NSArray* splitEntries = [str componentsSeparatedByCharactersInSet: [NSCharacterSet newlineCharacterSet]]; + + NSMutableArray* returnArr = [NSMutableArray new]; + for (NSString* splitEntry in splitEntries) { + // recursion makes the rest of the code prettier + NSArray* cleanedSubEntries = [SCMiscUtilities cleanBlocklistEntry: splitEntry]; + [returnArr addObjectsFromArray: cleanedSubEntries]; + } + return returnArr; + } + + // if the user entered a scheme (https://, http://, etc) remove it. + // We only block hostnames so scheme is ignored anyway and it can gunk up the blocking + NSArray* separatedStr = [str componentsSeparatedByString: @"://"]; + str = [separatedStr lastObject]; + + // Remove URL login names/passwords (username:password@host) if a user tried to put that in + separatedStr = [str componentsSeparatedByString: @"@"]; + str = [separatedStr lastObject]; + + // now here's where it gets tricky. Besides just hostnames, we also support CIDR IP ranges, for example: 83.0.1.2/24 + // so we are gonna keep track of whether we might have received a valid CIDR IP range instead of hostname as we go... + // we also take port numbers, so keep track of whether we have one of those + int cidrMaskBits = -1; + int portNum = -1; + + // first pull off everything after a slash + // discard the end if it's just a path, but check to see if it might be our CIDR mask length + separatedStr = [str componentsSeparatedByString: @"/"]; + str = [separatedStr firstObject]; + + // if the part after a slash is an integer between 1 and 128, it could be our mask length + if (separatedStr.count > 1) { + int potentialMaskLen = [[separatedStr lastObject] intValue]; + if (potentialMaskLen > 0 && potentialMaskLen <= 128) cidrMaskBits = potentialMaskLen; + } + + // check for the port + separatedStr = [str componentsSeparatedByString: @":"]; + str = [separatedStr firstObject]; + + if (separatedStr.count > 1) { + int potentialPort = [[separatedStr lastObject] intValue]; + if (potentialPort > 0 && potentialPort <= 65535) { + portNum = potentialPort; + } + } + + // remove invalid characters from the hostname + // hostnames are 1-253 characters long, and can contain only a-z, A-Z, 0-9, -, and ., and maybe _ (mostly not but kinda) + // for some reason [NSCharacterSet URLHostAllowedCharacterSet] has tons of other characters that aren't actually valid + NSMutableCharacterSet* invalidHostnameChars = [[NSCharacterSet alphanumericCharacterSet] mutableCopy]; + [invalidHostnameChars addCharactersInString: @"-._"]; + [invalidHostnameChars invert]; + + NSMutableString* validCharsOnly = [NSMutableString stringWithCapacity: str.length]; + for (NSUInteger i = 0; i < str.length && i < 253; i++) { + unichar c = [str characterAtIndex: i]; + if (![invalidHostnameChars characterIsMember: c]) { + [validCharsOnly appendFormat: @"%C", c]; + } + } + str = validCharsOnly; + + // allow blocking an empty hostname IFF we're only blocking a single port number (i.e. :80) + // otherwise, empty hostname = nothing to do + if (str.length < 1 && portNum < 0) { + return @[]; + } + + NSString* maskString; + NSString* portString; + + // create a mask string if we have one + if (cidrMaskBits < 0) { + maskString = @""; + } else { + maskString = [NSString stringWithFormat: @"/%d", cidrMaskBits]; + } + + // create a port string if we have one + if (portNum < 0) { + portString = @""; + } else { + portString = [NSString stringWithFormat: @":%d", portNum]; + } + + // combine em together and you got something! + return @[[NSString stringWithFormat: @"%@%@%@", str, maskString, portString]]; +} + ++ (NSArray*)cleanBlocklist:(NSArray*)blocklist { + NSMutableArray* cleanedList = [NSMutableArray arrayWithCapacity: blocklist.count]; + + // for now, we just remove whitespace and then remove empty entries + // in the future, this method could do more thorough cleaning + for (NSString* blockString in blocklist) { + NSString* cleanedString = [blockString stringByTrimmingCharactersInSet: [NSCharacterSet whitespaceAndNewlineCharacterSet]]; + + if (cleanedString.length > 0) { + [cleanedList addObject: cleanedString]; + } + } + + return cleanedList; +} + ++ (NSDictionary*) defaultsDictForUser:(uid_t) controllingUID { + if (geteuid() != 0) { + // if we're not root, we can't just get defaults for some arbitrary user + return nil; + } + + // pull up the user's defaults in the old legacy way + // to do that, we have to seteuid to the controlling UID so NSUserDefaults thinks we're them + seteuid(controllingUID); + NSUserDefaults* defaults = [NSUserDefaults standardUserDefaults]; + [defaults addSuiteNamed: @"org.eyebeam.SelfControl"]; + [defaults registerDefaults: SCConstants.defaultUserDefaults]; + [defaults synchronize]; + NSDictionary* dictValue = [defaults dictionaryRepresentation]; + // reset the euid so nothing else gets funky + [NSUserDefaults resetStandardUserDefaults]; + seteuid(0); + + return dictValue; +} + ++ (BOOL)errorIsAuthCanceled:(NSError*)err { + if (err == nil) return NO; + + if ([err.domain isEqualToString: NSOSStatusErrorDomain] && err.code == AUTH_CANCELLED_STATUS) { + return YES; + } + if ([err.domain isEqualToString: kSelfControlErrorDomain] && err.code == 1) { + return YES; + } + + return NO; +} + ++ (NSArray*)allUserHomeDirectoryURLs:(NSError**)errPtr { + NSError* retErr = nil; + NSFileManager* fileManager = [NSFileManager defaultManager]; + NSURL* usersFolderURL = [NSURL fileURLWithPath: @"/Users"]; + NSArray* homeDirectoryURLs = [fileManager contentsOfDirectoryAtURL: usersFolderURL + includingPropertiesForKeys: @[NSURLPathKey, NSURLIsDirectoryKey, NSURLIsReadableKey] + options: NSDirectoryEnumerationSkipsHiddenFiles + error: &retErr]; + if (homeDirectoryURLs == nil || homeDirectoryURLs.count == 0) { + if (retErr != nil) { + *errPtr = retErr; + } else { + *errPtr = [SCErr errorWithCode: 700]; + } + +// [SCSentry captureError: *errPtr]; + + return nil; + } + + return homeDirectoryURLs; +} + ++ (NSString*)killerKeyForDate:(NSDate*)date { + return [SCMiscUtilities sha1: [NSString stringWithFormat: @"SelfControlKillerKey%@%@", [SCMiscUtilities getSerialNumber], [date descriptionWithLocale: nil]]]; +} + +@end diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/ConsoleLog.h b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/ConsoleLog.h new file mode 100644 index 00000000..bff468e6 --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/ConsoleLog.h @@ -0,0 +1,18 @@ +// +// ConsoleLog.h +// SelfControl +// +// Created by Satendra Singh on 25/12/25. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface ConsoleLog : NSObject + ++ (void)log:(NSString *)message; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/ConsoleLog.m b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/ConsoleLog.m new file mode 100644 index 00000000..807e98fb --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/ConsoleLog.m @@ -0,0 +1,17 @@ +// +// ConsoleLog.m +// SelfControl +// +// Created by Satendra Singh on 25/12/25. +// + +#import "ConsoleLog.h" +#include + +@implementation ConsoleLog + ++ (void)log:(NSString *)message { + os_log(OS_LOG_DEFAULT, "ConsoleLog: [SC] 🔍] %{public}@", message); +} + +@end diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/DaemonMain.m b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/DaemonMain.m new file mode 100644 index 00000000..ee0a60b4 --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/DaemonMain.m @@ -0,0 +1,32 @@ +// +// DaemonMain.m +// SelfControl +// +// Created by Charlie Stigler on 5/28/20. +// + +#import +#import "SCDaemon.h" + +// Entry point for the SelfControl daemon process (selfcontrold) +int main(int argc, const char *argv[]) { +// // [SCSentry startSentry: @"org.eyebeam.selfcontrold"]; + + // get the daemon object going + SCDaemon* daemon = [SCDaemon sharedDaemon]; + [daemon start]; + + NSLog(@"running forever"); + + // never gonna give you up, never gonna let you down, never gonna run around and desert you... + [[NSRunLoop currentRunLoop] run]; + + return 0; +} + + + + + + + diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/SCDaemon.h b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/SCDaemon.h new file mode 100644 index 00000000..1e857311 --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/SCDaemon.h @@ -0,0 +1,40 @@ +// +// SCDaemon.h +// SelfControl +// +// Created by Charlie Stigler on 5/28/20. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +// SCDaemon is the top-level class that runs the SelfControl +// daemon process (selfcontrold). It runs from DaemonMain. +@interface SCDaemon : NSObject + +// Singleton instance of SCDaemon ++ (instancetype)sharedDaemon; + + +// Starts the daemon tasks, including accepting XPC connections +// and running block checkup jobs if necessary +- (void)start; + +// Starts checking up on the block on a regular basis +// to make sure it hasn't expired, been tampered with, etc +// (and will remove it or fix it if so) +- (void)startCheckupTimer; + +// Stops the checkup timer (this should only be called if there's +// no block running, because we should have checkups going for all blocks) +- (void)stopCheckupTimer; + +// Lets the daemon know that there was recent activity +// so we can reset our inactivity timer. +// The daemon will die if goes for too long without activity. +- (void)resetInactivityTimer; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/SCDaemon.m b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/SCDaemon.m new file mode 100644 index 00000000..5c7e8434 --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/SCDaemon.m @@ -0,0 +1,195 @@ +// +// SCDaemon.m +// SelfControl +// +// Created by Charlie Stigler on 5/28/20. +// + +#import "SCDaemon.h" +#import "SCDaemonProtocol.h" +#import "SCDaemonXPC.h" +#import"SCDaemonBlockMethods.h" +#import "SCFileWatcher.h" +#import "SCBlockUtilities.h" +#import "SCHelperToolUtilities.h" + +static NSString* serviceName = @"org.eyebeam.selfcontrold"; +float const INACTIVITY_LIMIT_SECS = 60 * 2; // 2 minutes + +@interface NSXPCConnection(PrivateAuditToken) + +// This property exists, but it's private. Make it available: +@property (nonatomic, readonly) audit_token_t auditToken; + +@end + +@interface SCDaemon () + +@property (nonatomic, strong, readwrite) NSXPCListener* listener; +@property (strong, readwrite) NSTimer* checkupTimer; +@property (strong, readwrite) NSTimer* inactivityTimer; +@property (nonatomic, strong, readwrite) NSDate* lastActivityDate; + +@property (nonatomic, strong) SCFileWatcher* hostsFileWatcher; + +@end + +@implementation SCDaemon + ++ (instancetype)sharedDaemon { + static SCDaemon* daemon = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + daemon = [SCDaemon new]; + }); + return daemon; +} + +- (id) init { + _listener = [[NSXPCListener alloc] initWithMachServiceName: serviceName]; + _listener.delegate = self; + + return self; +} + +- (void)start { + [self.listener resume]; + + // if there's any evidence of a block (i.e. an official one running, + // OR just block remnants remaining in hosts), we should start + // running checkup regularly so the block gets found/removed + // at the proper time. + // we do NOT run checkup if there's no block, because it can result + // in the daemon actually unloading itself before the app has a chance + // to start the block + if ([SCBlockUtilities anyBlockIsRunning] || [SCBlockUtilities blockRulesFoundOnSystem]) { + [self startCheckupTimer]; + } + + [self startInactivityTimer]; + [self resetInactivityTimer]; + + self.hostsFileWatcher = [SCFileWatcher watcherWithFile: @"/etc/hosts" block:^(NSError * _Nonnull error) { + if ([SCBlockUtilities anyBlockIsRunning]) { + NSLog(@"INFO: hosts file changed, checking block integrity"); + [SCDaemonBlockMethods checkBlockIntegrity]; + } + }]; +} + +- (void)startCheckupTimer { + // this method must always be called on the main thread, so the timer will work properly + if (![NSThread isMainThread]) { + dispatch_sync(dispatch_get_main_queue(), ^{ + [self startCheckupTimer]; + }); + return; + } + + // if the timer's already running, don't stress it! + if (self.checkupTimer != nil) { + return; + } + + self.checkupTimer = [NSTimer scheduledTimerWithTimeInterval: 1 repeats: YES block:^(NSTimer * _Nonnull timer) { + [SCDaemonBlockMethods checkupBlock]; + }]; + + // run the first checkup immediately! + [SCDaemonBlockMethods checkupBlock]; +} +- (void)stopCheckupTimer { + if (self.checkupTimer == nil) { + return; + } + + [self.checkupTimer invalidate]; + self.checkupTimer = nil; +} + + +- (void)startInactivityTimer { + self.inactivityTimer = [NSTimer scheduledTimerWithTimeInterval: 15.0 repeats: YES block:^(NSTimer * _Nonnull timer) { + // we haven't had any activity in a while, the daemon appears to be idling + // so kill it to avoid the user having unnecessary processes running! + if ([[NSDate date] timeIntervalSinceDate: self.lastActivityDate] > INACTIVITY_LIMIT_SECS) { + // if we're inactive but also there's a block running, that's a bad thing + // start the checkups going again - unclear why they would've stopped + if ([SCBlockUtilities anyBlockIsRunning] || [SCBlockUtilities blockRulesFoundOnSystem]) { + [self startCheckupTimer]; + [SCDaemonBlockMethods checkupBlock]; + return; + } + + NSLog(@"Daemon inactive for more than %f seconds, exiting!", INACTIVITY_LIMIT_SECS); + [SCHelperToolUtilities unloadDaemonJob]; + } + }]; +} +- (void)resetInactivityTimer { + self.lastActivityDate = [NSDate date]; +} + +- (void)dealloc { + if (self.checkupTimer) { + [self.checkupTimer invalidate]; + self.checkupTimer = nil; + } + if (self.inactivityTimer) { + [self.inactivityTimer invalidate]; + self.inactivityTimer = nil; + } + if (self.hostsFileWatcher) { + [self.hostsFileWatcher stopWatching]; + self.hostsFileWatcher = nil; + } +} + +#pragma mark - NSXPCListenerDelegate + +- (BOOL)listener:(NSXPCListener *)listener shouldAcceptNewConnection:(NSXPCConnection *)newConnection { +#if DEBUG + // In Debug builds, skip code signature validation to ease development. + NSLog(@"[DEBUG] Accepting XPC connection without signature validation."); + // [SCSentry addBreadcrumb:@"DEBUG: accepted XPC connection without validation" category:@"daemon"]; +#else + // There is a potential security issue / race condition with matching based on PID, so we use the (technically private) auditToken instead + audit_token_t auditToken = newConnection.auditToken; + NSDictionary* guestAttributes = @{ + (id)kSecGuestAttributeAudit: [NSData dataWithBytes: &auditToken length: sizeof(audit_token_t)] + }; + SecCodeRef guest; + if (SecCodeCopyGuestWithAttributes(NULL, (__bridge CFDictionaryRef _Nullable)(guestAttributes), kSecCSDefaultFlags, &guest) != errSecSuccess) { + return NO; + } + + SecRequirementRef isSelfControlApp; + // versions before 4.0 didn't have hardened code signing, so aren't trustworthy to talk to the daemon + // (plus the daemon didn't exist before 4.0 so there's really no reason they should want to run it!) + SecRequirementCreateWithString(CFSTR("anchor apple generic and (identifier \"org.eyebeam.SelfControl\" or identifier \"org.eyebeam.selfcontrol-cli\" or identifier \"com.application.SelfControl.corebits\") and info [CFBundleVersion] >= \"10\""), kSecCSDefaultFlags, &isSelfControlApp); + OSStatus clientValidityStatus = SecCodeCheckValidity(guest, kSecCSDefaultFlags, isSelfControlApp); + + CFRelease(guest); + CFRelease(isSelfControlApp); + + if (clientValidityStatus) { + NSError* error = [NSError errorWithDomain: NSOSStatusErrorDomain code: clientValidityStatus userInfo: nil]; + NSLog(@"Rejecting XPC connection because of invalid client signing. Error was %@", error); + // [SCSentry captureError: error]; + return NO; + } +#endif + + SCDaemonXPC* scdXPC = [[SCDaemonXPC alloc] init]; + newConnection.exportedInterface = [NSXPCInterface interfaceWithProtocol: @protocol(SCDaemonProtocol)]; + newConnection.exportedObject = scdXPC; + + [newConnection resume]; + + NSLog(@"Accepted new connection!"); + // [SCSentry addBreadcrumb: @"Daemon accepted new connection" category: @"daemon"]; + + return YES; +} + +@end diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/SCDaemonBlockMethods.h b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/SCDaemonBlockMethods.h new file mode 100644 index 00000000..ca1ccf6d --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/SCDaemonBlockMethods.h @@ -0,0 +1,36 @@ +// +// SCDaemonBlockMethods.h +// org.eyebeam.selfcontrold +// +// Created by Charlie Stigler on 7/4/20. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +// Top-level logic for different methods run by the SelfControl daemon +// these logics can be run by XPC methods, or elsewhere +@interface SCDaemonBlockMethods : NSObject + +@property (class, readonly) NSLock* daemonMethodLock; + +// Starts a block ++ (void)startBlockWithControllingUID:(uid_t)controllingUID blocklist:(NSArray*)blocklist isAllowlist:(BOOL)isAllowlist endDate:(NSDate*)endDate blockSettings:(NSDictionary*)blockSettings authorization:(NSData *)authData reply:(void(^)(NSError* error))reply; + +// Checks whether the block is expired or compromised, and takes action to fix ++ (void)checkupBlock; + +// updates the blocklist for the currently running block +// (i.e. adds new sites to the list) ++ (void)updateBlocklist:(NSArray*)newBlocklist authorization:(NSData *)authData reply:(void(^)(NSError* error))reply; + +// updates the block end date for the currently running block +// (i.e. extends the block) ++ (void)updateBlockEndDate:(NSDate*)newEndDate authorization:(NSData *)authData reply:(void(^)(NSError* error))reply; + ++ (void)checkBlockIntegrity; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/SCDaemonBlockMethods.m b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/SCDaemonBlockMethods.m new file mode 100644 index 00000000..e8c9b2ea --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/SCDaemonBlockMethods.m @@ -0,0 +1,391 @@ +// +// SCDaemonBlockMethods.m +// org.eyebeam.selfcontrold +// +// Created by Charlie Stigler on 7/4/20. +// + +#import "SCDaemonBlockMethods.h" +#import "SCSettings.h" +#import "SCHelperToolUtilities.h" +#import "PacketFilter.h" +#import "BlockManager.h" +#import "SCDaemon.h" +#import "LaunchctlHelper.h" +#import "HostFileBlockerSet.h" +#import "SCBlockUtilities.h" +#import "SCErr.h" +//#import "SCMigrationUtilities.h" + +NSTimeInterval METHOD_LOCK_TIMEOUT = 5.0; +NSTimeInterval CHECKUP_LOCK_TIMEOUT = 0.5; // use a shorter lock timeout for checkups, because we'd prefer not to have tons pile up + +@implementation SCDaemonBlockMethods + ++ (NSLock*)daemonMethodLock { + static NSLock* lock = nil; + if (lock == nil) { + lock = [[NSLock alloc] init]; + } + return lock; +} + ++ (BOOL)lockOrTimeout:(void(^)(NSError* error))reply timeout:(NSTimeInterval)timeout { + // only run one request at a time, so we avoid weird situations like trying to run a checkup while we're starting a block + if (![self.daemonMethodLock lockBeforeDate: [NSDate dateWithTimeIntervalSinceNow: timeout]]) { + // if we couldn't get a lock within 10 seconds, something is weird + // but we probably shouldn't still run, because that's just unexpected at that point + // don't capture this error on Sentry because it's very usual for checkups to timeout + NSError* err = [SCErr errorWithCode: 300]; + NSLog(@"ERROR: Timed out acquiring request lock (after %f seconds)", timeout); + + if (reply != nil) { + reply(err); + } + return NO; + } + return YES; +} ++ (BOOL)lockOrTimeout:(void(^)(NSError* error))reply { + return [self lockOrTimeout: reply timeout: METHOD_LOCK_TIMEOUT]; +} + + ++ (void)startBlockWithControllingUID:(uid_t)controllingUID blocklist:(NSArray*)blocklist isAllowlist:(BOOL)isAllowlist endDate:(NSDate*)endDate blockSettings:(NSDictionary*)blockSettings authorization:(NSData *)authData reply:(void(^)(NSError* error))reply { + if (![SCDaemonBlockMethods lockOrTimeout: reply]) { + return; + } + + // we reset at the _end_ of every method, but we'll also reset at the _start_ here + // because startBlock can sometimes take a while, and it'd be a shame if the daemon killed itself + // before we were done + [[SCDaemon sharedDaemon] resetInactivityTimer]; + + // [SCSentry addBreadcrumb: @"Daemon method startBlock called" category: @"daemon"]; + + if ([SCBlockUtilities anyBlockIsRunning]) { + NSLog(@"ERROR: Can't start block since a block is already running"); + NSError* err = [SCErr errorWithCode: 301]; + // [SCSentry captureError: err]; + reply(err); + [self.daemonMethodLock unlock]; + return; + } + + // clear any legacy block information - no longer useful and could potentially confuse things + // but first, copy it over one more time (this should've already happened once in the app, but you never know) +// if ([SCMigrationUtilities legacySettingsFoundForUser: controllingUID]) { +// [SCMigrationUtilities copyLegacySettingsToDefaults: controllingUID]; +// [SCMigrationUtilities clearLegacySettingsForUser: controllingUID]; +// +// // if we had legacy settings, there's a small chance the old helper tool could still be around +// // make sure it's dead and gone +// [LaunchctlHelper unloadLaunchdJobWithPlistAt: @"/Library/LaunchDaemons/org.eyebeam.SelfControl.plist"]; +// } + + SCSettings* settings = [SCSettings sharedSettings]; + // update SCSettings with the blocklist and end date that've been requested + [settings setValue: blocklist forKey: @"ActiveBlocklist"]; + [settings setValue: @(isAllowlist) forKey: @"ActiveBlockAsWhitelist"]; + [settings setValue: endDate forKey: @"BlockEndDate"]; + + // update all the settings for the block, which we're basically just copying from defaults to settings + [settings setValue: blockSettings[@"ClearCaches"] forKey: @"ClearCaches"]; + [settings setValue: blockSettings[@"AllowLocalNetworks"] forKey: @"AllowLocalNetworks"]; + [settings setValue: blockSettings[@"EvaluateCommonSubdomains"] forKey: @"EvaluateCommonSubdomains"]; + [settings setValue: blockSettings[@"IncludeLinkedDomains"] forKey: @"IncludeLinkedDomains"]; + [settings setValue: blockSettings[@"BlockSoundShouldPlay"] forKey: @"BlockSoundShouldPlay"]; + [settings setValue: blockSettings[@"BlockSound"] forKey: @"BlockSound"]; + [settings setValue: blockSettings[@"EnableErrorReporting"] forKey: @"EnableErrorReporting"]; + + if(([blocklist count] <= 0 && !isAllowlist) || [SCBlockUtilities currentBlockIsExpired]) { + NSLog(@"ERROR: Blocklist is empty, or block end date is in the past"); + NSLog(@"Block End Date: %@ (%@), vs now is %@", [settings valueForKey: @"BlockEndDate"], [[settings valueForKey: @"BlockEndDate"] class], [NSDate date]); + NSError* err = [SCErr errorWithCode: 302]; + // [SCSentry captureError: err]; + reply(err); + [self.daemonMethodLock unlock]; + return; + } + + NSLog(@"Adding firewall rules..."); + [SCHelperToolUtilities installBlockRulesFromSettings]; + [settings setValue: @YES forKey: @"BlockIsRunning"]; + + NSError* syncErr = [settings syncSettingsAndWait: 5]; // synchronize ASAP since BlockIsRunning is a really important one + if (syncErr != nil) { + NSLog(@"WARNING: Sync failed or timed out with error %@ after starting block", syncErr); + // [SCSentry captureError: syncErr]; + } + + NSLog(@"Firewall rules added!"); + + [SCHelperToolUtilities sendConfigurationChangedNotification]; + + // Clear all caches if the user has the correct preference set, so + // that blocked pages are not loaded from a cache. + [SCHelperToolUtilities clearCachesIfRequested]; + + // [SCSentry addBreadcrumb: @"Daemon added block successfully" category: @"daemon"]; + NSLog(@"INFO: Block successfully added."); + reply(nil); + + [[SCDaemon sharedDaemon] resetInactivityTimer]; + [[SCDaemon sharedDaemon] startCheckupTimer]; + [self.daemonMethodLock unlock]; +} + ++ (void)updateBlocklist:(NSArray*)newBlocklist authorization:(NSData *)authData reply:(void(^)(NSError* error))reply { + if (![SCDaemonBlockMethods lockOrTimeout: reply]) { + return; + } + + // [SCSentry addBreadcrumb: @"Daemon method updateBlocklist called" category: @"daemon"]; + if ([SCBlockUtilities legacyBlockIsRunning]) { + NSLog(@"ERROR: Can't update blocklist because a legacy block is running"); + NSError* err = [SCErr errorWithCode: 303]; + // [SCSentry captureError: err]; + reply(err); + [self.daemonMethodLock unlock]; + return; + } + if (![SCBlockUtilities modernBlockIsRunning]) { + NSLog(@"ERROR: Can't update blocklist since block isn't running"); + NSError* err = [SCErr errorWithCode: 304]; + // [SCSentry captureError: err]; + reply(err); + [self.daemonMethodLock unlock]; + return; + } + + SCSettings* settings = [SCSettings sharedSettings]; + + if ([settings boolForKey: @"ActiveBlockAsWhitelist"]) { + NSLog(@"ERROR: Attempting to update active blocklist, but this is not possible with an allowlist block"); + NSError* err = [SCErr errorWithCode: 305]; + // [SCSentry captureError: err]; + reply(err); + [self.daemonMethodLock unlock]; + return; + } + + NSArray* activeBlocklist = [settings valueForKey: @"ActiveBlocklist"]; + NSMutableArray* added = [NSMutableArray arrayWithArray: newBlocklist]; + [added removeObjectsInArray: activeBlocklist]; + NSMutableArray* removed = [NSMutableArray arrayWithArray: activeBlocklist]; + [removed removeObjectsInArray: newBlocklist]; + + // throw a warning if something got removed for some reason, since we ignore them + if (removed.count > 0) { + NSLog(@"WARNING: Active blocklist has removed items; these will not be updated. Removed items are %@", removed); + } + + BlockManager* blockManager = [[BlockManager alloc] initAsAllowlist: [settings boolForKey: @"ActiveBlockAsWhitelist"] + allowLocal: [settings boolForKey: @"EvaluateCommonSubdomains"] + includeCommonSubdomains: [settings boolForKey: @"AllowLocalNetworks"] + includeLinkedDomains: [settings boolForKey: @"IncludeLinkedDomains"]]; + [blockManager enterAppendMode]; + [blockManager addBlockEntriesFromStrings: added]; + [blockManager finishAppending]; + + [settings setValue: newBlocklist forKey: @"ActiveBlocklist"]; + + // make sure everyone knows about our new list + NSError* syncErr = [settings syncSettingsAndWait: 5]; + if (syncErr != nil) { + NSLog(@"WARNING: Sync failed or timed out with error %@ after updating blocklist", syncErr); + // [SCSentry captureError: syncErr]; + } + + [SCHelperToolUtilities sendConfigurationChangedNotification]; + + // Clear all caches if the user has the correct preference set, so + // that blocked pages are not loaded from a cache. + [SCHelperToolUtilities clearCachesIfRequested]; + + // [SCSentry addBreadcrumb: @"Daemon updated blocklist successfully" category: @"daemon"]; + NSLog(@"INFO: Blocklist successfully updated."); + reply(nil); + + [[SCDaemon sharedDaemon] resetInactivityTimer]; + [self.daemonMethodLock unlock]; +} + ++ (void)updateBlockEndDate:(NSDate*)newEndDate authorization:(NSData *)authData reply:(void(^)(NSError* error))reply { + if (![SCDaemonBlockMethods lockOrTimeout: reply]) { + return; + } + + // [SCSentry addBreadcrumb: @"Daemon method updateBlockEndDate called" category: @"daemon"]; + + if ([SCBlockUtilities legacyBlockIsRunning]) { + NSLog(@"ERROR: Can't update block end date because a legacy block is running"); + NSError* err = [SCErr errorWithCode: 306]; + // [SCSentry captureError: err]; + reply(err); + [self.daemonMethodLock unlock]; + return; + } + if (![SCBlockUtilities modernBlockIsRunning]) { + NSLog(@"ERROR: Can't update block end date since block isn't running"); + NSError* err = [SCErr errorWithCode: 307]; + // [SCSentry captureError: err]; + reply(err); + [self.daemonMethodLock unlock]; + return; + } + + SCSettings* settings = [SCSettings sharedSettings]; + + // this can only be used to *extend* the block end date - not shorten it! + // and we also won't let them extend by more than 24 hours at a time, for safety... + // TODO: they should be able to extend up to MaxBlockLength minutes, right? + NSDate* currentEndDate = [settings valueForKey: @"BlockEndDate"]; + if ([newEndDate timeIntervalSinceDate: currentEndDate] < 0) { + NSLog(@"ERROR: Can't update block end date to an earlier date"); + NSError* err = [SCErr errorWithCode: 308]; + // [SCSentry captureError: err]; + reply(err); + [self.daemonMethodLock unlock]; + } + if ([newEndDate timeIntervalSinceDate: currentEndDate] > 86400) { // 86400 seconds = 1 day + NSLog(@"ERROR: Can't extend block end date by more than 1 day at a time"); + NSError* err = [SCErr errorWithCode: 309]; + // [SCSentry captureError: err]; + reply(err); + [self.daemonMethodLock unlock]; + } + + [settings setValue: newEndDate forKey: @"BlockEndDate"]; + + // make sure everyone knows about our new end date + NSError* syncErr = [settings syncSettingsAndWait: 5]; + if (syncErr != nil) { + NSLog(@"WARNING: Sync failed or timed out with error %@ after extending block", syncErr); + // [SCSentry captureError: syncErr]; + } + + [SCHelperToolUtilities sendConfigurationChangedNotification]; + + // [SCSentry addBreadcrumb: @"Daemon extended block successfully" category: @"daemon"]; + NSLog(@"INFO: Block successfully extended."); + reply(nil); + + [[SCDaemon sharedDaemon] resetInactivityTimer]; + [self.daemonMethodLock unlock]; +} + ++ (void)checkupBlock { + if (![SCDaemonBlockMethods lockOrTimeout: nil timeout: CHECKUP_LOCK_TIMEOUT]) { + return; + } + + // [SCSentry addBreadcrumb: @"Daemon method checkupBlock called" category: @"daemon"]; + + NSTimeInterval integrityCheckIntervalSecs = 15.0; + static NSDate* lastBlockIntegrityCheck; + if (lastBlockIntegrityCheck == nil) { + lastBlockIntegrityCheck = [NSDate distantPast]; + } + + BOOL shouldRunIntegrityCheck = NO; + if(![SCBlockUtilities anyBlockIsRunning]) { + // No block appears to be running at all in our settings. + // Most likely, the user removed it trying to get around the block. Boo! + // but for safety and to avoid permablocks (we no longer know when the block should end) + // we should clear the block now. + // but let them know that we noticed their (likely) cheating and we're not happy! + NSLog(@"INFO: Checkup ran, no active block found."); + + // [SCSentry captureMessage: @"Checkup ran and no active block found! Removing block, tampering suspected..."]; + + [SCHelperToolUtilities removeBlock]; + + [SCHelperToolUtilities sendConfigurationChangedNotification]; + + // Temporarily disabled the TamperingDetection flag because it was sometimes causing false positives + // (i.e. people having the background set repeatedly despite no attempts to cheat) + // We will try to bring this feature back once we can debug it + // GitHub issue: https://github.com/SelfControlApp/selfcontrol/issues/621 + // [settings setValue: @YES forKey: @"TamperingDetected"]; + // [settings synchronizeSettings]; + // + + // once the checkups stop, the daemon will clear itself in a while due to inactivity + [[SCDaemon sharedDaemon] stopCheckupTimer]; + } else if ([SCBlockUtilities currentBlockIsExpired]) { + NSLog(@"INFO: Checkup ran, block expired, removing block."); + + [SCHelperToolUtilities removeBlock]; + + [SCHelperToolUtilities sendConfigurationChangedNotification]; + + // [SCSentry addBreadcrumb: @"Daemon found and cleared expired block" category: @"daemon"]; + + // once the checkups stop, the daemon will clear itself in a while due to inactivity + [[SCDaemon sharedDaemon] stopCheckupTimer]; + } else if ([[NSDate date] timeIntervalSinceDate: lastBlockIntegrityCheck] > integrityCheckIntervalSecs) { + lastBlockIntegrityCheck = [NSDate date]; + // The block is still on. Every once in a while, we should + // check if anybody removed our rules, and if so + // re-add them. + shouldRunIntegrityCheck = YES; + } + + [[SCDaemon sharedDaemon] resetInactivityTimer]; + [self.daemonMethodLock unlock]; + + // if we need to run an integrity check, we need to do it at the very end after we give up our lock + // because checkBlockIntegrity requests its own lock, and we don't want it to deadlock + if (shouldRunIntegrityCheck) { + [SCDaemonBlockMethods checkBlockIntegrity]; + } +} + ++ (void)checkBlockIntegrity { + if (![SCDaemonBlockMethods lockOrTimeout: nil timeout: CHECKUP_LOCK_TIMEOUT]) { + return; + } + + // [SCSentry addBreadcrumb: @"Daemon method checkBlockIntegrity called" category: @"daemon"]; + + SCSettings* settings = [SCSettings sharedSettings]; + PacketFilter* pf = [[PacketFilter alloc] init]; + HostFileBlockerSet* hostFileBlockerSet = [[HostFileBlockerSet alloc] init]; + if(![pf containsSelfControlBlock] || (![settings boolForKey: @"ActiveBlockAsWhitelist"] && ![hostFileBlockerSet.defaultBlocker containsSelfControlBlock])) { + NSLog(@"INFO: Block is missing in PF or hosts, re-adding..."); + // The firewall is missing at least the block header. Let's clear everything + // before we re-add to make sure everything goes smoothly. + + [pf stopBlock: false]; + + [hostFileBlockerSet removeSelfControlBlock]; + BOOL success = [hostFileBlockerSet writeNewFileContents]; + // Revert the host file blocker's file contents to disk so we can check + // whether or not it still contains the block after our write (aka we messed up). + [hostFileBlockerSet revertFileContentsToDisk]; + if(!success || [hostFileBlockerSet.defaultBlocker containsSelfControlBlock]) { + NSLog(@"WARNING: Error removing host file block. Attempting to restore backup."); + + if([hostFileBlockerSet restoreBackupHostsFile]) + NSLog(@"INFO: Host file backup restored."); + else + NSLog(@"ERROR: Host file backup could not be restored. This may result in a permanent block."); + } + + // Get rid of the backup file since we're about to make a new one. + [hostFileBlockerSet deleteBackupHostsFile]; + + // Perform the re-add of the rules + [SCHelperToolUtilities installBlockRulesFromSettings]; + + [SCHelperToolUtilities clearCachesIfRequested]; + + // [SCSentry addBreadcrumb: @"Daemon found compromised block integrity and re-added rules" category: @"daemon"]; + NSLog(@"INFO: Integrity check ran; readded block rules."); + } else NSLog(@"INFO: Integrity check ran; no action needed."); + + [self.daemonMethodLock unlock]; +} + +@end diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/SCDaemonProtocol.h b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/SCDaemonProtocol.h new file mode 100644 index 00000000..07ccbdef --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/SCDaemonProtocol.h @@ -0,0 +1,28 @@ +// +// SCDaemonProtocol.h +// selfcontrold +// +// Created by Charlie Stigler on 5/30/20. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@protocol SCDaemonProtocol + +// XPC method to start block +- (void)startBlockWithControllingUID:(uid_t)controllingUID blocklist:(NSArray*)blocklist isAllowlist:(BOOL)isAllowlist endDate:(NSDate*)endDate blockSettings:(NSDictionary*)blockSettings authorization:(NSData *)authData reply:(void(^)(NSError* error))reply; + +// XPC method to add to blocklist +- (void)updateBlocklist:(NSArray*)newBlocklist authorization:(NSData *)authData reply:(void(^)(NSError* error))reply; + +// XPC method to extend block +- (void)updateBlockEndDate:(NSDate*)newEndDate authorization:(NSData *)authData reply:(void(^)(NSError* error))reply; + +// XPC method to get version of the installed daemon +- (void)getVersionWithReply:(void(^)(NSString * version))reply; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/SCDaemonXPC.h b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/SCDaemonXPC.h new file mode 100644 index 00000000..720d6acf --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/SCDaemonXPC.h @@ -0,0 +1,19 @@ +// +// SCDaemonXPC.h +// selfcontrold +// +// Created by Charlie Stigler on 5/30/20. +// + +#import +#import "SCDaemonProtocol.h" + +NS_ASSUME_NONNULL_BEGIN + +// Implementations for SC XPC methods +// (see SCDaemonProtocol for all method prototypes) +@interface SCDaemonXPC : NSObject + +@end + +NS_ASSUME_NONNULL_END diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/SCDaemonXPC.m b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/SCDaemonXPC.m new file mode 100644 index 00000000..6355dd5f --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/SCDaemonXPC.m @@ -0,0 +1,90 @@ +// +// SCDaemonXPC.m +// selfcontrold +// +// Created by Charlie Stigler on 5/30/20. +// + +#import "SCDaemonXPC.h" +#import "SCDaemonBlockMethods.h" +#import "SCXPCAuthorization.h" +#import "SCMiscUtilities.h" +#import "ConsoleLog.h" + +@implementation SCDaemonXPC + +- (void)startBlockWithControllingUID:(uid_t)controllingUID blocklist:(NSArray*)blocklist isAllowlist:(BOOL)isAllowlist endDate:(NSDate*)endDate blockSettings:(NSDictionary*)blockSettings authorization:(NSData *)authData reply:(void(^)(NSError* error))reply { + NSLog(@"SCDaemonXPC: XPC method called: startBlockWithControllingUID"); + [ConsoleLog log:[NSString stringWithFormat:@"SCDaemonXPC: XPC method called: startBlockWithControllingUID"]]; + + NSError* error = [SCXPCAuthorization checkAuthorization: authData command: _cmd]; + if (error != nil) { + if (![SCMiscUtilities errorIsAuthCanceled: error]) { + NSLog(@"SCDaemonXPC: ERROR: XPC authorization failed due to error %@", error); + // [SCSentry captureError: error]; + } + reply(error); + return; + } else { + NSLog(@"SCDaemonXPC: AUTHORIZATION ACCEPTED for startBlock with authData %@ and command %s", authData, sel_getName(_cmd)); + [ConsoleLog log:[NSString stringWithFormat:@"SCDaemonXPC: AUTHORIZATION ACCEPTED for startBlock with authData %@ and command %s", authData, sel_getName(_cmd)]]; + } + + [SCDaemonBlockMethods startBlockWithControllingUID: controllingUID blocklist: blocklist isAllowlist:isAllowlist endDate: endDate blockSettings:blockSettings authorization: authData reply: reply]; +} + +- (void)updateBlocklist:(NSArray*)newBlocklist authorization:(NSData *)authData reply:(void(^)(NSError* error))reply { + NSLog(@"SCDaemonXPC: XPC method called: updateBlocklist"); + [ConsoleLog log:[NSString stringWithFormat:@"SCDaemonXPC: XPC method called: updateBlocklist"]]; + + NSError* error = [SCXPCAuthorization checkAuthorization: authData command: _cmd]; + if (error != nil) { + if (![SCMiscUtilities errorIsAuthCanceled: error]) { + NSLog(@"SCDaemonXPC: ERROR: XPC authorization failed due to error %@", error); + [ConsoleLog log:[NSString stringWithFormat:@"SCDaemonXPC: ERROR: XPC authorization failed due to error %@", error]]; + // [SCSentry captureError: error]; + } + reply(error); + return; + } else { + NSLog(@"SCDaemonXPC: AUTHORIZATION ACCEPTED for updateBlocklist with authData %@ and command %s", authData, sel_getName(_cmd)); + [ConsoleLog log:[NSString stringWithFormat:@"SCDaemonXPC: AUTHORIZATION ACCEPTED for updateBlocklist with authData %@ and command %s", authData, sel_getName(_cmd)]]; + + } + + [SCDaemonBlockMethods updateBlocklist: newBlocklist authorization: authData reply: reply]; +} + +- (void)updateBlockEndDate:(NSDate*)newEndDate authorization:(NSData *)authData reply:(void(^)(NSError* error))reply { + NSLog(@"SCDaemonXPC: XPC method called: updateBlockEndDate"); + [ConsoleLog log:[NSString stringWithFormat:@"SCDaemonXPC: XPC method called: updateBlockEndDate"]]; + + NSError* error = [SCXPCAuthorization checkAuthorization: authData command: _cmd]; + if (error != nil) { + if (![SCMiscUtilities errorIsAuthCanceled: error]) { + NSLog(@"SCDaemonXPC: ERROR: XPC authorization failed due to error %@", error); + [ConsoleLog log:[NSString stringWithFormat:@"SCDaemonXPC: XPC method called: updateBlockEndDate: %@", error]]; + // [SCSentry captureError: error]; + } + reply(error); + return; + } else { + NSLog(@"SCDaemonXPC: AUTHORIZATION ACCEPTED for updateBlockENdDate with authData %@ and command %s", authData, sel_getName(_cmd)); + [ConsoleLog log:[NSString stringWithFormat:@"SCDaemonXPC: AUTHORIZATION ACCEPTED for updateBlockENdDate with authData %@ and command %s", authData, sel_getName(_cmd)]]; + } + + [SCDaemonBlockMethods updateBlockEndDate: newEndDate authorization: authData reply: reply]; +} + +// Part of the HelperToolProtocol. Returns the version number of the tool. Note that never +// requires authorization. +- (void)getVersionWithReply:(void(^)(NSString * version))reply { + NSLog(@"SCDaemonXPC: XPC method called: getVersionWithReply"); + [ConsoleLog log:[NSString stringWithFormat:@"SCDaemonXPC: XPC method called: getVersionWithReply"]]; + + // We specifically don't check for authorization here. Everyone is always allowed to get + // the version of the helper tool. + reply(@"410"); +} + +@end diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/SCDaemon_Prefix.pch b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/SCDaemon_Prefix.pch new file mode 100755 index 00000000..55dbe0cc --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/SCDaemon_Prefix.pch @@ -0,0 +1,21 @@ +// +// Prefix header for all source files of the 'SelfControl' target in the 'SelfControl' project +// + +#ifdef __OBJC__ + +#define SELFCONTROL_VERSION_STRING @"4.0.2" + +#import +#import + +#import "DeprecationSilencers.h" +//#import "SCUtility.h" +#import "SCErr.h" +#import "SCConstants.h" +//#import "SCSentry.h" +#import "SCSettings.h" +#import "SCMiscUtilities.h" +#import "SCHelperToolUtilities.h" + +#endif diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/org.eyebeam.selfcontrold.plist b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/org.eyebeam.selfcontrold.plist new file mode 100644 index 00000000..91dc9b7a --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/org.eyebeam.selfcontrold.plist @@ -0,0 +1,19 @@ + + + + + Label + org.eyebeam.selfcontrold + RunAtLoad + + MachServices + + org.eyebeam.selfcontrold + + + KeepAlive + + Nice + 5 + + diff --git a/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/selfcontrold-Info.plist b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/selfcontrold-Info.plist new file mode 100755 index 00000000..6b0e6667 --- /dev/null +++ b/Self-Control-Extension/SelfControl/org.eyebeam.selfcontrold/selfcontrold-Info.plist @@ -0,0 +1,58 @@ + + + + + CFBundleDevelopmentRegion + English + CFBundleDisplayName + selfcontrold + CFBundleDocumentTypes + + + CFBundleTypeExtensions + + selfcontrol + + CFBundleTypeIconFile + SelfControlBlocklist.icns + CFBundleTypeName + SelfControl Blocklist + CFBundleTypeRole + Editor + LSTypeIsPackage + + NSPersistentStoreTypeKey + Binary + + + CFBundleExecutable + ${EXECUTABLE_NAME} + CFBundleIconFile + SelfControlIcon + CFBundleIdentifier + org.eyebeam.selfcontrold + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ${PRODUCT_NAME} + CFBundlePackageType + APPL + CFBundleShortVersionString + 4.0.2 + CFBundleVersion + 410 + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSHumanReadableCopyright + Free and open-source under the GPL. + SMAuthorizedClients + + anchor apple generic and (identifier "org.eyebeam.SelfControl" or identifier "org.eyebeam.selfcontrol-cli" or identifier "com.application.SelfControl.corebits") + + + diff --git a/SelfControl.xcodeproj/project.pbxproj b/SelfControl.xcodeproj/project.pbxproj index 2954aea5..9faf2ff6 100644 --- a/SelfControl.xcodeproj/project.pbxproj +++ b/SelfControl.xcodeproj/project.pbxproj @@ -7,11 +7,13 @@ objects = { /* Begin PBXBuildFile section */ - 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 */; }; + 227499ED6DAAD0EA7A2B7EA7 /* Pods_SelfControl_SelfControlTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5F8FA3A9265EFA8D41713681 /* Pods_SelfControl_SelfControlTests.framework */; }; + 5688020A39CB3AB3A1B0C47F /* libPods-org.eyebeam.selfcontrold.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 0105F335A671C845ECC31F6E /* libPods-org.eyebeam.selfcontrold.a */; }; 8D11072D0486CEB800E47090 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 29B97316FDCFA39411CA2CEA /* main.m */; settings = {ATTRIBUTES = (); }; }; 8D11072F0486CEB800E47090 /* Cocoa.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1058C7A1FEA54F0111CA2CBB /* Cocoa.framework */; }; + AAB27795D2331ECB7849EA24 /* libPods-selfcontrol-cli.a in Frameworks */ = {isa = PBXBuildFile; fileRef = FB0F38FB83A87078C5AAAC1C /* libPods-selfcontrol-cli.a */; }; + C459756AE31AF2606147BF55 /* libPods-SCKillerHelper.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 69A8CCDD56C5466D78CFADA8 /* libPods-SCKillerHelper.a */; }; + C6CCCB5B721CF78E05886AF0 /* Pods_SelfControl_Killer.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 129E43BCECDC59A62307DAAB /* Pods_SelfControl_Killer.framework */; }; CB0385E119D77051004614B6 /* PreferencesAdvancedViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = CB0385DD19D77051004614B6 /* PreferencesAdvancedViewController.xib */; }; CB0385E219D77051004614B6 /* PreferencesGeneralViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = CB0385DF19D77051004614B6 /* PreferencesGeneralViewController.xib */; }; CB066F6C2652037E0076964D /* HostFileBlocker.m in Sources */ = {isa = PBXBuildFile; fileRef = CBB0AE290FA74566006229B3 /* HostFileBlocker.m */; }; @@ -38,7 +40,6 @@ CB1CA65125ABA5BB0084A551 /* SCXPCClient.m in Sources */ = {isa = PBXBuildFile; fileRef = CB62FC3A24B124B900ADBC40 /* SCXPCClient.m */; }; CB1CA65E25ABA6150084A551 /* SCXPCClient.m in Sources */ = {isa = PBXBuildFile; fileRef = CB62FC3A24B124B900ADBC40 /* SCXPCClient.m */; }; CB1CA66525ABA6240084A551 /* SCXPCAuthorization.m in Sources */ = {isa = PBXBuildFile; fileRef = CB69C4ED25A3FD8A0030CFCD /* SCXPCAuthorization.m */; }; - CB20C5D8245699D700B9D749 /* version-header.h in Sources */ = {isa = PBXBuildFile; fileRef = CB20C5D7245699D700B9D749 /* version-header.h */; }; CB21D0A825BA7B4400236680 /* PacketFilter.m in Sources */ = {isa = PBXBuildFile; fileRef = CBCA91111960D87300AFD20C /* PacketFilter.m */; }; CB21D0AE25BA7B4500236680 /* PacketFilter.m in Sources */ = {isa = PBXBuildFile; fileRef = CBCA91111960D87300AFD20C /* PacketFilter.m */; }; CB249FED19D782230087BBB6 /* SelfControlIcon.icns in Resources */ = {isa = PBXBuildFile; fileRef = CB249FEC19D782230087BBB6 /* SelfControlIcon.icns */; }; @@ -206,9 +207,7 @@ CBED7D9925ABB911003080D6 /* selfcontrol-cli in Copy Executable Helper Tools */ = {isa = PBXBuildFile; fileRef = CBA2AFD20F39EC12005AFEBE /* selfcontrol-cli */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; CBEE50C10F48C21F00F5DF1C /* TimerWindowController.m in Sources */ = {isa = PBXBuildFile; fileRef = CBEE50C00F48C21F00F5DF1C /* TimerWindowController.m */; }; CBF3B574217BADD7006D5F52 /* SCSettings.m in Sources */ = {isa = PBXBuildFile; fileRef = CBF3B573217BADD7006D5F52 /* SCSettings.m */; }; - D4EDD26C770910569C31D36F /* libPods-SCKillerHelper.a in Frameworks */ = {isa = PBXBuildFile; fileRef = AF899A50A17F0C6C8C6B84A2 /* libPods-SCKillerHelper.a */; }; - DC4DBA9148D8D67A11899C5E /* Pods_SelfControl.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6EBDE7B29D92764A409E4FDA /* Pods_SelfControl.framework */; }; - E263B809965135813A557CD5 /* Pods_SelfControl_Killer.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 86DAD6532C67CBE72E99084C /* Pods_SelfControl_Killer.framework */; }; + DDBB80A47678404455711991 /* Pods_SelfControl.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9A312616E5C8B89085B914A2 /* Pods_SelfControl.framework */; }; F5B8CBEE19EE21C30026F3A5 /* SCTimeIntervalFormatter.m in Sources */ = {isa = PBXBuildFile; fileRef = F5B8CBED19EE21C30026F3A5 /* SCTimeIntervalFormatter.m */; }; /* End PBXBuildFile section */ @@ -523,31 +522,33 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 0105F335A671C845ECC31F6E /* libPods-org.eyebeam.selfcontrold.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-org.eyebeam.selfcontrold.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 077F865138A2B4B27CACBBCA /* Pods-org.eyebeam.selfcontrold.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-org.eyebeam.selfcontrold.release.xcconfig"; path = "Pods/Target Support Files/Pods-org.eyebeam.selfcontrold/Pods-org.eyebeam.selfcontrold.release.xcconfig"; sourceTree = ""; }; 1058C7A1FEA54F0111CA2CBB /* Cocoa.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Cocoa.framework; path = /System/Library/Frameworks/Cocoa.framework; sourceTree = ""; }; + 129E43BCECDC59A62307DAAB /* Pods_SelfControl_Killer.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SelfControl_Killer.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 190AA87F8521B9AEC0B60360 /* Pods-selfcontrol-cli.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-selfcontrol-cli.debug.xcconfig"; path = "Pods/Target Support Files/Pods-selfcontrol-cli/Pods-selfcontrol-cli.debug.xcconfig"; 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 = ""; }; 29B97325FDCFA39411CA2CEA /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = /System/Library/Frameworks/Foundation.framework; sourceTree = ""; }; - 32B26CAAF2E2B648B3C0E892 /* Pods-SelfControl.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SelfControl.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SelfControl/Pods-SelfControl.debug.xcconfig"; sourceTree = ""; }; + 2C77669C20BA40116545D8F7 /* Pods-org.eyebeam.selfcontrold.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-org.eyebeam.selfcontrold.debug.xcconfig"; path = "Pods/Target Support Files/Pods-org.eyebeam.selfcontrold/Pods-org.eyebeam.selfcontrold.debug.xcconfig"; sourceTree = ""; }; 32CA4F630368D1EE00C91783 /* SelfControl_Prefix.pch */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SelfControl_Prefix.pch; sourceTree = ""; }; 3EF418F71CC7F7FA002D99E8 /* nl */ = {isa = PBXFileReference; fileEncoding = 10; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; 3EF418F81CC7F7FA002D99E8 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/InfoPlist.strings; sourceTree = ""; }; - 50CA92A5EB2C31792CA00641 /* Pods-SelfControl Killer.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SelfControl Killer.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SelfControl Killer/Pods-SelfControl Killer.debug.xcconfig"; sourceTree = ""; }; - 5FA850E53EB64C54A1404AEF /* Pods-SelfControl.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SelfControl.release.xcconfig"; path = "Pods/Target Support Files/Pods-SelfControl/Pods-SelfControl.release.xcconfig"; sourceTree = ""; }; + 5F8FA3A9265EFA8D41713681 /* Pods_SelfControl_SelfControlTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SelfControl_SelfControlTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 640D40CB16D6E962003034B3 /* it */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; 640D40CC16D6E962003034B3 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/InfoPlist.strings; sourceTree = ""; }; - 64682B3371BA0A5044028058 /* Pods-selfcontrol-cli.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-selfcontrol-cli.debug.xcconfig"; path = "Pods/Target Support Files/Pods-selfcontrol-cli/Pods-selfcontrol-cli.debug.xcconfig"; sourceTree = ""; }; - 69102D5CD4E9672D0EFBF25B /* Pods-selfcontrol-cli.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-selfcontrol-cli.release.xcconfig"; path = "Pods/Target Support Files/Pods-selfcontrol-cli/Pods-selfcontrol-cli.release.xcconfig"; sourceTree = ""; }; - 6EBDE7B29D92764A409E4FDA /* Pods_SelfControl.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SelfControl.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 77CFA479BA7C3ECBC15F96D7 /* Pods-org.eyebeam.selfcontrold.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-org.eyebeam.selfcontrold.debug.xcconfig"; path = "Pods/Target Support Files/Pods-org.eyebeam.selfcontrold/Pods-org.eyebeam.selfcontrold.debug.xcconfig"; sourceTree = ""; }; - 86DAD6532C67CBE72E99084C /* Pods_SelfControl_Killer.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SelfControl_Killer.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 8BF3973D41997900DF147B24 /* libPods-org.eyebeam.selfcontrold.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-org.eyebeam.selfcontrold.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 69A8CCDD56C5466D78CFADA8 /* libPods-SCKillerHelper.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-SCKillerHelper.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 776343E85A532B35974B7F0E /* Pods-SelfControl-SelfControlTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SelfControl-SelfControlTests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SelfControl-SelfControlTests/Pods-SelfControl-SelfControlTests.debug.xcconfig"; sourceTree = ""; }; + 861003B099EA6AC9C8943755 /* Pods-selfcontrol-cli.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-selfcontrol-cli.release.xcconfig"; path = "Pods/Target Support Files/Pods-selfcontrol-cli/Pods-selfcontrol-cli.release.xcconfig"; sourceTree = ""; }; 8D1107310486CEB800E47090 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 8D1107320486CEB800E47090 /* SelfControl.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SelfControl.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 8F7722215B94F9BC9D67EBC9 /* Pods-SCKillerHelper.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SCKillerHelper.release.xcconfig"; path = "Pods/Target Support Files/Pods-SCKillerHelper/Pods-SCKillerHelper.release.xcconfig"; sourceTree = ""; }; 949F9F8815B32EC6007B8B42 /* zh-Hans */ = {isa = PBXFileReference; fileEncoding = 10; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; 949F9F8D15B333A1007B8B42 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/InfoPlist.strings"; sourceTree = ""; }; - AF899A50A17F0C6C8C6B84A2 /* libPods-SCKillerHelper.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-SCKillerHelper.a"; sourceTree = BUILT_PRODUCTS_DIR; }; - C72025F2499F9F07E281CB50 /* Pods-SelfControl-SelfControlTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SelfControl-SelfControlTests.release.xcconfig"; path = "Pods/Target Support Files/Pods-SelfControl-SelfControlTests/Pods-SelfControl-SelfControlTests.release.xcconfig"; sourceTree = ""; }; - C85A094F15E20A0DB58665A8 /* libPods-selfcontrol-cli.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-selfcontrol-cli.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 9A312616E5C8B89085B914A2 /* Pods_SelfControl.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SelfControl.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + A0FB5A12B3019E1F2A7AC7A1 /* Pods-SelfControl.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SelfControl.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SelfControl/Pods-SelfControl.debug.xcconfig"; sourceTree = ""; }; + A910D97A726951AD77892BC9 /* Pods-SelfControl Killer.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SelfControl Killer.release.xcconfig"; path = "Pods/Target Support Files/Pods-SelfControl Killer/Pods-SelfControl Killer.release.xcconfig"; sourceTree = ""; }; + C4CDA695CAF296145B1A6098 /* Pods-SCKillerHelper.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SCKillerHelper.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SCKillerHelper/Pods-SCKillerHelper.debug.xcconfig"; sourceTree = ""; }; CB0EEF5D20FD8CE00024D27B /* SelfControlTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SelfControlTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; CB0EEF6120FD8CE00024D27B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; CB0EEF7720FE49020024D27B /* SCUtilityTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SCUtilityTests.m; sourceTree = ""; }; @@ -556,7 +557,6 @@ CB1465C325B0285300130D2E /* SCError.strings */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; path = SCError.strings; sourceTree = ""; }; CB1B44BD23B7F1ED00EBA087 /* cheater-background.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "cheater-background.png"; sourceTree = ""; }; CB1CA68425ABAA360084A551 /* selfcontrol-cli-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "selfcontrol-cli-Info.plist"; sourceTree = ""; }; - CB20C5D7245699D700B9D749 /* version-header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "version-header.h"; sourceTree = ""; }; CB249FEC19D782230087BBB6 /* SelfControlIcon.icns */ = {isa = PBXFileReference; lastKnownFileType = image.icns; path = SelfControlIcon.icns; sourceTree = ""; }; CB25806016C1FDBE0059C99A /* BlockManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = BlockManager.h; sourceTree = ""; }; CB25806116C1FDBE0059C99A /* BlockManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = BlockManager.m; sourceTree = ""; }; @@ -1431,7 +1431,6 @@ CBDB11792084236A0010397E /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/PreferencesGeneralViewController.strings; sourceTree = ""; }; CBDB117B2084236C0010397E /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/PreferencesGeneralViewController.strings"; sourceTree = ""; }; CBDB117C208423750010397E /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/PreferencesGeneralViewController.strings; sourceTree = ""; }; - CBDB117D208423770010397E /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/PreferencesGeneralViewController.strings; sourceTree = ""; }; CBDB117E208423790010397E /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/PreferencesGeneralViewController.strings"; sourceTree = ""; }; CBDB117F2084237A0010397E /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/PreferencesGeneralViewController.strings; sourceTree = ""; }; CBDB11802084237C0010397E /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/PreferencesGeneralViewController.strings; sourceTree = ""; }; @@ -1441,7 +1440,6 @@ CBDB1184208423850010397E /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/PreferencesAdvancedViewController.strings; sourceTree = ""; }; CBDB1185208423870010397E /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/PreferencesAdvancedViewController.strings; sourceTree = ""; }; CBDB1186208423880010397E /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/PreferencesAdvancedViewController.strings; sourceTree = ""; }; - CBDB1187208423890010397E /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/PreferencesAdvancedViewController.strings; sourceTree = ""; }; CBDB11882084238B0010397E /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/PreferencesAdvancedViewController.strings"; sourceTree = ""; }; CBDB11892084238C0010397E /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/PreferencesAdvancedViewController.strings; sourceTree = ""; }; CBDB118A2084238E0010397E /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/PreferencesAdvancedViewController.strings; sourceTree = ""; }; @@ -1463,14 +1461,12 @@ CBEE50C00F48C21F00F5DF1C /* TimerWindowController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TimerWindowController.m; sourceTree = ""; }; CBF3B572217BADD7006D5F52 /* SCSettings.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SCSettings.h; sourceTree = ""; }; CBF3B573217BADD7006D5F52 /* SCSettings.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SCSettings.m; sourceTree = ""; }; - D0BABA9759C378EE7C619F91 /* Pods-SCKillerHelper.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SCKillerHelper.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SCKillerHelper/Pods-SCKillerHelper.debug.xcconfig"; sourceTree = ""; }; - E1139D5A5B92C88FFF62718F /* Pods-SCKillerHelper.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SCKillerHelper.release.xcconfig"; path = "Pods/Target Support Files/Pods-SCKillerHelper/Pods-SCKillerHelper.release.xcconfig"; sourceTree = ""; }; - E3346B8A670C55C686428776 /* Pods-SelfControl-SelfControlTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SelfControl-SelfControlTests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SelfControl-SelfControlTests/Pods-SelfControl-SelfControlTests.debug.xcconfig"; sourceTree = ""; }; - EBDF1B23AA44B10313D79203 /* Pods-SelfControl Killer.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SelfControl Killer.release.xcconfig"; path = "Pods/Target Support Files/Pods-SelfControl Killer/Pods-SelfControl Killer.release.xcconfig"; sourceTree = ""; }; - F0874F1ABF369B21F1CEADC1 /* Pods-org.eyebeam.selfcontrold.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-org.eyebeam.selfcontrold.release.xcconfig"; path = "Pods/Target Support Files/Pods-org.eyebeam.selfcontrold/Pods-org.eyebeam.selfcontrold.release.xcconfig"; sourceTree = ""; }; - F41DEF1E3926B4CF3AE2B76C /* Pods_SelfControl_SelfControlTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SelfControl_SelfControlTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + D6B8103524C531EA735691E7 /* Pods-SelfControl-SelfControlTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SelfControl-SelfControlTests.release.xcconfig"; path = "Pods/Target Support Files/Pods-SelfControl-SelfControlTests/Pods-SelfControl-SelfControlTests.release.xcconfig"; sourceTree = ""; }; + E47C806863673411F32101A8 /* Pods-SelfControl.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SelfControl.release.xcconfig"; path = "Pods/Target Support Files/Pods-SelfControl/Pods-SelfControl.release.xcconfig"; sourceTree = ""; }; + E9BF454DB8270402C6914995 /* Pods-SelfControl Killer.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SelfControl Killer.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SelfControl Killer/Pods-SelfControl Killer.debug.xcconfig"; sourceTree = ""; }; F5B8CBEC19EE21C30026F3A5 /* SCTimeIntervalFormatter.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SCTimeIntervalFormatter.h; sourceTree = ""; }; F5B8CBED19EE21C30026F3A5 /* SCTimeIntervalFormatter.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SCTimeIntervalFormatter.m; sourceTree = ""; }; + FB0F38FB83A87078C5AAAC1C /* libPods-selfcontrol-cli.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-selfcontrol-cli.a"; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1483,7 +1479,7 @@ 8D11072F0486CEB800E47090 /* Cocoa.framework in Frameworks */, CB587E500F50FE8800C66A09 /* SystemConfiguration.framework in Frameworks */, CBB3FD7A0F53834B00244132 /* Security.framework in Frameworks */, - DC4DBA9148D8D67A11899C5E /* Pods_SelfControl.framework in Frameworks */, + DDBB80A47678404455711991 /* Pods_SelfControl.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1491,7 +1487,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 8CA8987104D2956493D6AF6B /* Pods_SelfControl_SelfControlTests.framework in Frameworks */, + 227499ED6DAAD0EA7A2B7EA7 /* Pods_SelfControl_SelfControlTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1504,7 +1500,7 @@ CBD2677011ED92DE00042CD8 /* CoreFoundation.framework in Frameworks */, CBD2677311ED92EF00042CD8 /* Foundation.framework in Frameworks */, CBD2677511ED92F800042CD8 /* Cocoa.framework in Frameworks */, - 5E6BEEBB5C6E29DADDB344CF /* libPods-selfcontrol-cli.a in Frameworks */, + AAB27795D2331ECB7849EA24 /* libPods-selfcontrol-cli.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1516,7 +1512,7 @@ CB74D1162480E506002B2079 /* Security.framework in Frameworks */, CB74D1182480E506002B2079 /* Foundation.framework in Frameworks */, CB74D1192480E506002B2079 /* Cocoa.framework in Frameworks */, - 63BAC9E58A69B15D342B0E29 /* libPods-org.eyebeam.selfcontrold.a in Frameworks */, + 5688020A39CB3AB3A1B0C47F /* libPods-org.eyebeam.selfcontrold.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1527,7 +1523,7 @@ CB32D2B121902DB300B8CD68 /* IOKit.framework in Frameworks */, CB9C813019CFBBC000CDCAE1 /* Security.framework in Frameworks */, CB9C812F19CFBBB900CDCAE1 /* Cocoa.framework in Frameworks */, - E263B809965135813A557CD5 /* Pods_SelfControl_Killer.framework in Frameworks */, + C6CCCB5B721CF78E05886AF0 /* Pods_SelfControl_Killer.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1539,7 +1535,7 @@ CB9C812C19CFBB8400CDCAE1 /* Cocoa.framework in Frameworks */, CB9C812A19CFBB8000CDCAE1 /* Foundation.framework in Frameworks */, CB9C812819CFBB7B00CDCAE1 /* Security.framework in Frameworks */, - D4EDD26C770910569C31D36F /* libPods-SCKillerHelper.a in Frameworks */, + C459756AE31AF2606147BF55 /* libPods-SCKillerHelper.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1584,7 +1580,6 @@ isa = PBXGroup; children = ( CBDFFF4B24A07DB300622CEE /* SelfControl.entitlements */, - CB20C5D7245699D700B9D749 /* version-header.h */, CBD4848519D7611F0020F949 /* Podfile */, CBD4848619D7611F0020F949 /* TemplateIcon2x.png */, CBD266C611ED82DB00042CD8 /* CLI */, @@ -1643,12 +1638,12 @@ CB9C812919CFBB8000CDCAE1 /* Foundation.framework */, 1058C7A0FEA54F0111CA2CBB /* Linked Frameworks */, 1058C7A2FEA54F0111CA2CBB /* Other Frameworks */, - AF899A50A17F0C6C8C6B84A2 /* libPods-SCKillerHelper.a */, - 6EBDE7B29D92764A409E4FDA /* Pods_SelfControl.framework */, - 86DAD6532C67CBE72E99084C /* Pods_SelfControl_Killer.framework */, - F41DEF1E3926B4CF3AE2B76C /* Pods_SelfControl_SelfControlTests.framework */, - 8BF3973D41997900DF147B24 /* libPods-org.eyebeam.selfcontrold.a */, - C85A094F15E20A0DB58665A8 /* libPods-selfcontrol-cli.a */, + 69A8CCDD56C5466D78CFADA8 /* libPods-SCKillerHelper.a */, + 9A312616E5C8B89085B914A2 /* Pods_SelfControl.framework */, + 129E43BCECDC59A62307DAAB /* Pods_SelfControl_Killer.framework */, + 5F8FA3A9265EFA8D41713681 /* Pods_SelfControl_SelfControlTests.framework */, + 0105F335A671C845ECC31F6E /* libPods-org.eyebeam.selfcontrold.a */, + FB0F38FB83A87078C5AAAC1C /* libPods-selfcontrol-cli.a */, ); name = Frameworks; sourceTree = ""; @@ -2856,18 +2851,18 @@ isa = PBXGroup; children = ( CBB672FF25D61437006E4BC9 /* ArgumentParser */, - D0BABA9759C378EE7C619F91 /* Pods-SCKillerHelper.debug.xcconfig */, - E1139D5A5B92C88FFF62718F /* Pods-SCKillerHelper.release.xcconfig */, - 32B26CAAF2E2B648B3C0E892 /* Pods-SelfControl.debug.xcconfig */, - 5FA850E53EB64C54A1404AEF /* Pods-SelfControl.release.xcconfig */, - 50CA92A5EB2C31792CA00641 /* Pods-SelfControl Killer.debug.xcconfig */, - EBDF1B23AA44B10313D79203 /* Pods-SelfControl Killer.release.xcconfig */, - E3346B8A670C55C686428776 /* Pods-SelfControl-SelfControlTests.debug.xcconfig */, - C72025F2499F9F07E281CB50 /* Pods-SelfControl-SelfControlTests.release.xcconfig */, - 77CFA479BA7C3ECBC15F96D7 /* Pods-org.eyebeam.selfcontrold.debug.xcconfig */, - F0874F1ABF369B21F1CEADC1 /* Pods-org.eyebeam.selfcontrold.release.xcconfig */, - 64682B3371BA0A5044028058 /* Pods-selfcontrol-cli.debug.xcconfig */, - 69102D5CD4E9672D0EFBF25B /* Pods-selfcontrol-cli.release.xcconfig */, + C4CDA695CAF296145B1A6098 /* Pods-SCKillerHelper.debug.xcconfig */, + 8F7722215B94F9BC9D67EBC9 /* Pods-SCKillerHelper.release.xcconfig */, + A0FB5A12B3019E1F2A7AC7A1 /* Pods-SelfControl.debug.xcconfig */, + E47C806863673411F32101A8 /* Pods-SelfControl.release.xcconfig */, + E9BF454DB8270402C6914995 /* Pods-SelfControl Killer.debug.xcconfig */, + A910D97A726951AD77892BC9 /* Pods-SelfControl Killer.release.xcconfig */, + 776343E85A532B35974B7F0E /* Pods-SelfControl-SelfControlTests.debug.xcconfig */, + D6B8103524C531EA735691E7 /* Pods-SelfControl-SelfControlTests.release.xcconfig */, + 2C77669C20BA40116545D8F7 /* Pods-org.eyebeam.selfcontrold.debug.xcconfig */, + 077F865138A2B4B27CACBBCA /* Pods-org.eyebeam.selfcontrold.release.xcconfig */, + 190AA87F8521B9AEC0B60360 /* Pods-selfcontrol-cli.debug.xcconfig */, + 861003B099EA6AC9C8943755 /* Pods-selfcontrol-cli.release.xcconfig */, ); name = Pods; sourceTree = ""; @@ -2904,7 +2899,7 @@ isa = PBXNativeTarget; buildConfigurationList = C01FCF4A08A954540054247B /* Build configuration list for PBXNativeTarget "SelfControl" */; buildPhases = ( - 54B25F59AEDB053782FAC10D /* [CP] Check Pods Manifest.lock */, + ED83FB631891EB03112D0E12 /* [CP] Check Pods Manifest.lock */, CB81A92E25B7B4B8006956F7 /* ShellScript */, 8D1107290486CEB800E47090 /* Resources */, 8D11072C0486CEB800E47090 /* Sources */, @@ -2913,7 +2908,7 @@ CBDFFF4624A044C900622CEE /* Copy Daemon Launch Service */, CB5E5FF81C3A5FD10038F331 /* ShellScript */, CB81AB2725B7EFA4006956F7 /* Embed Frameworks */, - BED0609C15A860C955A3552D /* [CP] Embed Pods Frameworks */, + 8D5294BD3A7C96234E1020B9 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -2929,12 +2924,12 @@ isa = PBXNativeTarget; buildConfigurationList = CB0EEF6420FD8CE00024D27B /* Build configuration list for PBXNativeTarget "SelfControlTests" */; buildPhases = ( - 2A27B423AFFABE56BC018570 /* [CP] Check Pods Manifest.lock */, + 45B13088DFEB2B358A409AEA /* [CP] Check Pods Manifest.lock */, CB841F05243A6D7F00274FDF /* Headers */, CB0EEF5920FD8CE00024D27B /* Sources */, CB0EEF5A20FD8CE00024D27B /* Frameworks */, CB0EEF5B20FD8CE00024D27B /* Resources */, - ED887BF54A55A00F42A6FD9C /* [CP] Embed Pods Frameworks */, + B8838D36E9932BFE90604211 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -2950,7 +2945,7 @@ isa = PBXNativeTarget; buildConfigurationList = CB74D11A2480E506002B2079 /* Build configuration list for PBXNativeTarget "org.eyebeam.selfcontrold" */; buildPhases = ( - D76E3B7390CC3DD10C32CD7F /* [CP] Check Pods Manifest.lock */, + 4B0AAF2464987231854A1AF3 /* [CP] Check Pods Manifest.lock */, CB74D1062480E506002B2079 /* ShellScript */, CB74D1072480E506002B2079 /* Sources */, CB74D1142480E506002B2079 /* Frameworks */, @@ -2968,12 +2963,11 @@ isa = PBXNativeTarget; buildConfigurationList = CB9C811119CFB79700CDCAE1 /* Build configuration list for PBXNativeTarget "SelfControl Killer" */; buildPhases = ( - C4455A3F0260679D1BD5962E /* [CP] Check Pods Manifest.lock */, + 840516A4AE7AFF18A0AAAF30 /* [CP] Check Pods Manifest.lock */, CB9C80F319CFB79700CDCAE1 /* Sources */, CB9C80F419CFB79700CDCAE1 /* Frameworks */, CB9C80F519CFB79700CDCAE1 /* Resources */, CB9C813119CFBBD300CDCAE1 /* Copy Helper Tools */, - AF328C5935C794FB31FCD1B5 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -2988,7 +2982,7 @@ isa = PBXNativeTarget; buildConfigurationList = CB9C811F19CFBA8500CDCAE1 /* Build configuration list for PBXNativeTarget "SCKillerHelper" */; buildPhases = ( - 69AC2704461942B09B953173 /* [CP] Check Pods Manifest.lock */, + A61E50D5FDB33A56935F8563 /* [CP] Check Pods Manifest.lock */, CB9C811719CFBA8500CDCAE1 /* Sources */, CB9C811819CFBA8500CDCAE1 /* Frameworks */, ); @@ -3005,7 +2999,7 @@ isa = PBXNativeTarget; buildConfigurationList = CBA2AFD60F39EC33005AFEBE /* Build configuration list for PBXNativeTarget "selfcontrol-cli" */; buildPhases = ( - 667CC580701BF2C2321C3B5D /* [CP] Check Pods Manifest.lock */, + 361D11319CEAD77BCA8D72EB /* [CP] Check Pods Manifest.lock */, CB20C5D6245696C500B9D749 /* ShellScript */, CBA2AFCF0F39EC12005AFEBE /* Sources */, CB54D4490F93E32B00AA22E9 /* Frameworks */, @@ -3028,9 +3022,9 @@ LastUpgradeCheck = 0930; TargetAttributes = { 8D1107260486CEB800E47090 = { - DevelopmentTeam = EG6ZYP3AQH; + DevelopmentTeam = KWW448CAK7; LastSwiftMigration = 1150; - ProvisioningStyle = Manual; + ProvisioningStyle = Automatic; SystemCapabilities = { com.apple.ApplicationGroups.Mac = { enabled = 1; @@ -3039,26 +3033,24 @@ }; CB0EEF5C20FD8CE00024D27B = { CreatedOnToolsVersion = 9.4.1; - DevelopmentTeam = EG6ZYP3AQH; - ProvisioningStyle = Automatic; + ProvisioningStyle = Manual; }; CB74D1052480E506002B2079 = { - DevelopmentTeam = EG6ZYP3AQH; - ProvisioningStyle = Manual; + DevelopmentTeam = KWW448CAK7; + ProvisioningStyle = Automatic; }; CB9C80F619CFB79700CDCAE1 = { CreatedOnToolsVersion = 6.0.1; - DevelopmentTeam = EG6ZYP3AQH; - ProvisioningStyle = Automatic; + ProvisioningStyle = Manual; }; CB9C811A19CFBA8500CDCAE1 = { CreatedOnToolsVersion = 6.0.1; - DevelopmentTeam = EG6ZYP3AQH; - ProvisioningStyle = Manual; + DevelopmentTeam = KWW448CAK7; + ProvisioningStyle = Automatic; }; CBA2AFD10F39EC12005AFEBE = { - DevelopmentTeam = EG6ZYP3AQH; - ProvisioningStyle = Manual; + DevelopmentTeam = KWW448CAK7; + ProvisioningStyle = Automatic; }; }; }; @@ -3426,7 +3418,7 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 2A27B423AFFABE56BC018570 /* [CP] Check Pods Manifest.lock */ = { + 361D11319CEAD77BCA8D72EB /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -3441,14 +3433,14 @@ outputFileListPaths = ( ); outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-SelfControl-SelfControlTests-checkManifestLockResult.txt", + "$(DERIVED_FILE_DIR)/Pods-selfcontrol-cli-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - 54B25F59AEDB053782FAC10D /* [CP] Check Pods Manifest.lock */ = { + 45B13088DFEB2B358A409AEA /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -3463,14 +3455,14 @@ outputFileListPaths = ( ); outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-SelfControl-checkManifestLockResult.txt", + "$(DERIVED_FILE_DIR)/Pods-SelfControl-SelfControlTests-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - 667CC580701BF2C2321C3B5D /* [CP] Check Pods Manifest.lock */ = { + 4B0AAF2464987231854A1AF3 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -3485,14 +3477,14 @@ outputFileListPaths = ( ); outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-selfcontrol-cli-checkManifestLockResult.txt", + "$(DERIVED_FILE_DIR)/Pods-org.eyebeam.selfcontrold-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - 69AC2704461942B09B953173 /* [CP] Check Pods Manifest.lock */ = { + 840516A4AE7AFF18A0AAAF30 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -3507,77 +3499,185 @@ outputFileListPaths = ( ); outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-SCKillerHelper-checkManifestLockResult.txt", + "$(DERIVED_FILE_DIR)/Pods-SelfControl Killer-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - AF328C5935C794FB31FCD1B5 /* [CP] Embed Pods Frameworks */ = { + 8D5294BD3A7C96234E1020B9 /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-SelfControl Killer/Pods-SelfControl Killer-frameworks.sh", - "${BUILT_PRODUCTS_DIR}/Sentry-framework/Sentry.framework", - ); - name = "[CP] Embed Pods Frameworks"; + "${PODS_ROOT}/Target Support Files/Pods-SelfControl/Pods-SelfControl-resources.sh", + "${PODS_ROOT}/FormatterKit/FormatterKit/FormatterKit.bundle", + "${PODS_ROOT}/LetsMove/Base.lproj", + "${PODS_ROOT}/LetsMove/ca.lproj", + "${PODS_ROOT}/LetsMove/cs.lproj", + "${PODS_ROOT}/LetsMove/da.lproj", + "${PODS_ROOT}/LetsMove/de.lproj", + "${PODS_ROOT}/LetsMove/el.lproj", + "${PODS_ROOT}/LetsMove/en.lproj", + "${PODS_ROOT}/LetsMove/es.lproj", + "${PODS_ROOT}/LetsMove/fr.lproj", + "${PODS_ROOT}/LetsMove/hu.lproj", + "${PODS_ROOT}/LetsMove/it.lproj", + "${PODS_ROOT}/LetsMove/ja.lproj", + "${PODS_ROOT}/LetsMove/ko.lproj", + "${PODS_ROOT}/LetsMove/mk.lproj", + "${PODS_ROOT}/LetsMove/nb.lproj", + "${PODS_ROOT}/LetsMove/nl.lproj", + "${PODS_ROOT}/LetsMove/pl.lproj", + "${PODS_ROOT}/LetsMove/pt.lproj", + "${PODS_ROOT}/LetsMove/pt_BR.lproj", + "${PODS_ROOT}/LetsMove/ru.lproj", + "${PODS_ROOT}/LetsMove/sk.lproj", + "${PODS_ROOT}/LetsMove/sr.lproj", + "${PODS_ROOT}/LetsMove/sv.lproj", + "${PODS_ROOT}/LetsMove/tr.lproj", + "${PODS_ROOT}/LetsMove/vi-VN.lproj", + "${PODS_ROOT}/LetsMove/zh_CN.lproj", + "${PODS_ROOT}/LetsMove/zh_TW.lproj", + "${BUILT_PRODUCTS_DIR}/MASPreferences/MASPreferences.framework//Users/satendrasingh/Library/Developer/Xcode/DerivedData/SelfControl-cgvpnsqlwaorbygdskhcomgsthde/Build/Products/Debug/MASPreferences/MASPreferences.framework/Versions/A/Resources/en.lproj/MASPreferencesWindow.nib", + "${PODS_CONFIGURATION_BUILD_DIR}/Sentry/Sentry.bundle", + ); + name = "[CP] Copy Pods Resources"; outputPaths = ( - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Sentry.framework", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FormatterKit.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Base.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ca.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/cs.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/da.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/de.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/el.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/en.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/es.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/fr.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/hu.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/it.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ja.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ko.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/mk.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/nb.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/nl.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/pl.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/pt.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/pt_BR.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ru.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/sk.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/sr.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/sv.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/tr.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/vi-VN.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/zh_CN.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/zh_TW.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/MASPreferencesWindow.nib", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Sentry.bundle", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-SelfControl Killer/Pods-SelfControl Killer-frameworks.sh\"\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-SelfControl/Pods-SelfControl-resources.sh\"\n"; showEnvVarsInLog = 0; }; - BED0609C15A860C955A3552D /* [CP] Embed Pods Frameworks */ = { + A61E50D5FDB33A56935F8563 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); + inputFileListPaths = ( + ); inputPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-SelfControl/Pods-SelfControl-frameworks.sh", - "${BUILT_PRODUCTS_DIR}/FormatterKit/FormatterKit.framework", - "${BUILT_PRODUCTS_DIR}/LetsMove/LetsMove.framework", - "${BUILT_PRODUCTS_DIR}/MASPreferences/MASPreferences.framework", - "${BUILT_PRODUCTS_DIR}/Sentry-framework/Sentry.framework", - "${BUILT_PRODUCTS_DIR}/TransformerKit/TransformerKit.framework", - ); - name = "[CP] Embed Pods Frameworks"; + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); outputPaths = ( - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FormatterKit.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/LetsMove.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MASPreferences.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Sentry.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/TransformerKit.framework", + "$(DERIVED_FILE_DIR)/Pods-SCKillerHelper-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-SelfControl/Pods-SelfControl-frameworks.sh\"\n"; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - C4455A3F0260679D1BD5962E /* [CP] Check Pods Manifest.lock */ = { + B8838D36E9932BFE90604211 /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); - inputFileListPaths = ( - ); inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); + "${PODS_ROOT}/Target Support Files/Pods-SelfControl-SelfControlTests/Pods-SelfControl-SelfControlTests-resources.sh", + "${PODS_ROOT}/FormatterKit/FormatterKit/FormatterKit.bundle", + "${PODS_ROOT}/LetsMove/Base.lproj", + "${PODS_ROOT}/LetsMove/ca.lproj", + "${PODS_ROOT}/LetsMove/cs.lproj", + "${PODS_ROOT}/LetsMove/da.lproj", + "${PODS_ROOT}/LetsMove/de.lproj", + "${PODS_ROOT}/LetsMove/el.lproj", + "${PODS_ROOT}/LetsMove/en.lproj", + "${PODS_ROOT}/LetsMove/es.lproj", + "${PODS_ROOT}/LetsMove/fr.lproj", + "${PODS_ROOT}/LetsMove/hu.lproj", + "${PODS_ROOT}/LetsMove/it.lproj", + "${PODS_ROOT}/LetsMove/ja.lproj", + "${PODS_ROOT}/LetsMove/ko.lproj", + "${PODS_ROOT}/LetsMove/mk.lproj", + "${PODS_ROOT}/LetsMove/nb.lproj", + "${PODS_ROOT}/LetsMove/nl.lproj", + "${PODS_ROOT}/LetsMove/pl.lproj", + "${PODS_ROOT}/LetsMove/pt.lproj", + "${PODS_ROOT}/LetsMove/pt_BR.lproj", + "${PODS_ROOT}/LetsMove/ru.lproj", + "${PODS_ROOT}/LetsMove/sk.lproj", + "${PODS_ROOT}/LetsMove/sr.lproj", + "${PODS_ROOT}/LetsMove/sv.lproj", + "${PODS_ROOT}/LetsMove/tr.lproj", + "${PODS_ROOT}/LetsMove/vi-VN.lproj", + "${PODS_ROOT}/LetsMove/zh_CN.lproj", + "${PODS_ROOT}/LetsMove/zh_TW.lproj", + "${BUILT_PRODUCTS_DIR}/MASPreferences/MASPreferences.framework/en.lproj/MASPreferencesWindow.nib", + "${PODS_CONFIGURATION_BUILD_DIR}/Sentry/Sentry.bundle", + ); + name = "[CP] Copy Pods Resources"; outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-SelfControl Killer-checkManifestLockResult.txt", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FormatterKit.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Base.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ca.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/cs.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/da.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/de.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/el.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/en.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/es.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/fr.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/hu.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/it.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ja.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ko.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/mk.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/nb.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/nl.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/pl.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/pt.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/pt_BR.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ru.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/sk.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/sr.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/sv.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/tr.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/vi-VN.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/zh_CN.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/zh_TW.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/MASPreferencesWindow.nib", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Sentry.bundle", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-SelfControl-SelfControlTests/Pods-SelfControl-SelfControlTests-resources.sh\"\n"; showEnvVarsInLog = 0; }; CB20C5D6245696C500B9D749 /* ShellScript */ = { @@ -3644,7 +3744,7 @@ shellPath = /bin/sh; shellScript = "# Type a script or drag a script file from your workspace to insert its path.\necho \"#define SELFCONTROL_VERSION_STRING @\\\"${MARKETING_VERSION}\\\"\" > \"${PROJECT_DIR}/version-header.h\"\n"; }; - D76E3B7390CC3DD10C32CD7F /* [CP] Check Pods Manifest.lock */ = { + ED83FB631891EB03112D0E12 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -3659,39 +3759,13 @@ outputFileListPaths = ( ); outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-org.eyebeam.selfcontrold-checkManifestLockResult.txt", + "$(DERIVED_FILE_DIR)/Pods-SelfControl-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - ED887BF54A55A00F42A6FD9C /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-SelfControl-SelfControlTests/Pods-SelfControl-SelfControlTests-frameworks.sh", - "${BUILT_PRODUCTS_DIR}/FormatterKit/FormatterKit.framework", - "${BUILT_PRODUCTS_DIR}/LetsMove/LetsMove.framework", - "${BUILT_PRODUCTS_DIR}/MASPreferences/MASPreferences.framework", - "${BUILT_PRODUCTS_DIR}/Sentry-framework/Sentry.framework", - "${BUILT_PRODUCTS_DIR}/TransformerKit/TransformerKit.framework", - ); - name = "[CP] Embed Pods Frameworks"; - outputPaths = ( - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FormatterKit.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/LetsMove.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MASPreferences.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Sentry.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/TransformerKit.framework", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-SelfControl-SelfControlTests/Pods-SelfControl-SelfControlTests-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -3855,7 +3929,6 @@ CBB67D5D25D6165B006E4BC9 /* NSDictionary+RubyDescription.m in Sources */, CBB67D5425D6165B006E4BC9 /* XPMArgsKonstants.m in Sources */, CBB67D5325D6165B006E4BC9 /* XPMMutableAttributedArray.m in Sources */, - CB20C5D8245699D700B9D749 /* version-header.h in Sources */, CBB67D5725D6165B006E4BC9 /* XPMCountedArgument.m in Sources */, CBA2AFD90F39EC46005AFEBE /* cli-main.m in Sources */, CBB67D5925D6165B006E4BC9 /* XPMArgumentPackage.m in Sources */, @@ -3900,7 +3973,6 @@ CBDB1184208423850010397E /* sv */, CBDB1185208423870010397E /* ja */, CBDB1186208423880010397E /* it */, - CBDB1187208423890010397E /* tr */, CBDB11882084238B0010397E /* pt-BR */, CBDB11892084238C0010397E /* ko */, CBDB118A2084238E0010397E /* nl */, @@ -3920,7 +3992,6 @@ CBDB11792084236A0010397E /* ja */, CBDB117B2084236C0010397E /* zh-Hans */, CBDB117C208423750010397E /* it */, - CBDB117D208423770010397E /* tr */, CBDB117E208423790010397E /* pt-BR */, CBDB117F2084237A0010397E /* ko */, CBDB11802084237C0010397E /* nl */, @@ -4052,15 +4123,17 @@ /* Begin XCBuildConfiguration section */ C01FCF4B08A954540054247B /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 32B26CAAF2E2B648B3C0E892 /* Pods-SelfControl.debug.xcconfig */; + baseConfigurationReference = A0FB5A12B3019E1F2A7AC7A1 /* Pods-SelfControl.debug.xcconfig */; buildSettings = { CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; - CODE_SIGN_IDENTITY = "Developer ID Application: Charles Stigler (EG6ZYP3AQH)"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 410; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = KWW448CAK7; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "$(SRCROOT)", @@ -4073,6 +4146,7 @@ INFOPLIST_FILE = Info.plist; INSTALL_PATH = "$(HOME)/Applications"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 11.5; PRODUCT_BUNDLE_IDENTIFIER = "org.eyebeam.${PRODUCT_NAME:identifier}"; PRODUCT_NAME = SelfControl; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -4085,7 +4159,7 @@ }; C01FCF4C08A954540054247B /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 5FA850E53EB64C54A1404AEF /* Pods-SelfControl.release.xcconfig */; + baseConfigurationReference = E47C806863673411F32101A8 /* Pods-SelfControl.release.xcconfig */; buildSettings = { CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; @@ -4104,6 +4178,7 @@ INFOPLIST_FILE = Info.plist; INSTALL_PATH = "$(HOME)/Applications"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 11.5; PRODUCT_BUNDLE_IDENTIFIER = "org.eyebeam.${PRODUCT_NAME:identifier}"; PRODUCT_NAME = SelfControl; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -4213,7 +4288,7 @@ }; CB0EEF6520FD8CE00024D27B /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = E3346B8A670C55C686428776 /* Pods-SelfControl-SelfControlTests.debug.xcconfig */; + baseConfigurationReference = 776343E85A532B35974B7F0E /* Pods-SelfControl-SelfControlTests.debug.xcconfig */; buildSettings = { CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; @@ -4226,8 +4301,12 @@ CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; + CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=macosx*]" = ""; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_OPTIMIZATION_LEVEL = 0; @@ -4244,12 +4323,14 @@ MTL_ENABLE_DEBUG_INFO = YES; PRODUCT_BUNDLE_IDENTIFIER = org.selfcontrolapp.SelfControlTests; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = ""; }; name = Debug; }; CB0EEF6620FD8CE00024D27B /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = C72025F2499F9F07E281CB50 /* Pods-SelfControl-SelfControlTests.release.xcconfig */; + baseConfigurationReference = D6B8103524C531EA735691E7 /* Pods-SelfControl-SelfControlTests.release.xcconfig */; buildSettings = { CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; @@ -4278,15 +4359,17 @@ }; CB74D11B2480E506002B2079 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 77CFA479BA7C3ECBC15F96D7 /* Pods-org.eyebeam.selfcontrold.debug.xcconfig */; + baseConfigurationReference = 2C77669C20BA40116545D8F7 /* Pods-org.eyebeam.selfcontrold.debug.xcconfig */; buildSettings = { CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; - CODE_SIGN_IDENTITY = "Developer ID Application: Charles Stigler (EG6ZYP3AQH)"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 410; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = KWW448CAK7; GCC_DYNAMIC_NO_PIC = NO; GCC_MODEL_TUNING = G5; GCC_NO_COMMON_BLOCKS = NO; @@ -4296,6 +4379,7 @@ GCC_VERSION = ""; INFOPLIST_FILE = "Daemon/selfcontrold-Info.plist"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks @loader_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 11.5; OTHER_LDFLAGS = ( "-sectcreate", __TEXT, @@ -4320,7 +4404,7 @@ }; CB74D11C2480E506002B2079 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = F0874F1ABF369B21F1CEADC1 /* Pods-org.eyebeam.selfcontrold.release.xcconfig */; + baseConfigurationReference = 077F865138A2B4B27CACBBCA /* Pods-org.eyebeam.selfcontrold.release.xcconfig */; buildSettings = { CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; @@ -4336,6 +4420,7 @@ GCC_VERSION = ""; INFOPLIST_FILE = "Daemon/selfcontrold-Info.plist"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks @loader_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 11.5; OTHER_LDFLAGS = ( "-sectcreate", __TEXT, @@ -4360,7 +4445,7 @@ }; CB9C811219CFB79700CDCAE1 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 50CA92A5EB2C31792CA00641 /* Pods-SelfControl Killer.debug.xcconfig */; + baseConfigurationReference = E9BF454DB8270402C6914995 /* Pods-SelfControl Killer.debug.xcconfig */; buildSettings = { CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; @@ -4375,9 +4460,12 @@ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - CODE_SIGN_STYLE = Automatic; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; + CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 410; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=macosx*]" = ""; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_DYNAMIC_NO_PIC = NO; @@ -4395,17 +4483,20 @@ GCC_WARN_UNUSED_FUNCTION = YES; INFOPLIST_FILE = "SelfControl Killer/Info.plist"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 11.5; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = "com.selfcontrolapp.SelfControl-Killer"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = ""; + SWIFT_VERSION = 4.0; }; name = Debug; }; CB9C811319CFB79700CDCAE1 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = EBDF1B23AA44B10313D79203 /* Pods-SelfControl Killer.release.xcconfig */; + baseConfigurationReference = A910D97A726951AD77892BC9 /* Pods-SelfControl Killer.release.xcconfig */; buildSettings = { CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; @@ -4434,16 +4525,18 @@ GCC_WARN_UNUSED_FUNCTION = YES; INFOPLIST_FILE = "SelfControl Killer/Info.plist"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 11.5; MTL_ENABLE_DEBUG_INFO = NO; PRODUCT_BUNDLE_IDENTIFIER = "com.selfcontrolapp.SelfControl-Killer"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 4.0; }; name = Release; }; CB9C812019CFBA8500CDCAE1 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = D0BABA9759C378EE7C619F91 /* Pods-SCKillerHelper.debug.xcconfig */; + baseConfigurationReference = C4CDA695CAF296145B1A6098 /* Pods-SCKillerHelper.debug.xcconfig */; buildSettings = { CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; @@ -4458,7 +4551,9 @@ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - CODE_SIGN_IDENTITY = "Developer ID Application: Charles Stigler (EG6ZYP3AQH)"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = KWW448CAK7; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_DYNAMIC_NO_PIC = NO; @@ -4475,18 +4570,20 @@ GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; + MACOSX_DEPLOYMENT_TARGET = 11.5; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE = ""; PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; + SWIFT_VERSION = 4.0; }; name = Debug; }; CB9C812119CFBA8500CDCAE1 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = E1139D5A5B92C88FFF62718F /* Pods-SCKillerHelper.release.xcconfig */; + baseConfigurationReference = 8F7722215B94F9BC9D67EBC9 /* Pods-SCKillerHelper.release.xcconfig */; buildSettings = { CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; @@ -4513,27 +4610,32 @@ GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; + MACOSX_DEPLOYMENT_TARGET = 11.5; MTL_ENABLE_DEBUG_INFO = NO; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE = ""; PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; + SWIFT_VERSION = 4.0; }; name = Release; }; CBA2AFD40F39EC14005AFEBE /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 64682B3371BA0A5044028058 /* Pods-selfcontrol-cli.debug.xcconfig */; + baseConfigurationReference = 190AA87F8521B9AEC0B60360 /* Pods-selfcontrol-cli.debug.xcconfig */; buildSettings = { CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; - CODE_SIGN_IDENTITY = "Developer ID Application: Charles Stigler (EG6ZYP3AQH)"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; CREATE_INFOPLIST_SECTION_IN_BINARY = YES; CURRENT_PROJECT_VERSION = 410; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = KWW448CAK7; FRAMEWORK_SEARCH_PATHS = ( - "$(PLATFORM_DIR)/Developer/Library/Frameworks\n\n$(PLATFORM_DIR)/Developer/Library/Frameworks\n\n", + "$(PLATFORM_DIR)/Developer/Library/Frameworks", + "$(PLATFORM_DIR)/Developer/Library/Frameworks", ); GCC_DYNAMIC_NO_PIC = NO; GCC_MODEL_TUNING = G5; @@ -4545,6 +4647,7 @@ INFOPLIST_FILE = "selfcontrol-cli-Info.plist"; INFOPLIST_PREPROCESS = YES; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks @loader_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 11.5; PRODUCT_BUNDLE_IDENTIFIER = "org.eyebeam.selfcontrol-cli"; PRODUCT_NAME = "selfcontrol-cli"; PROVISIONING_PROFILE = ""; @@ -4558,7 +4661,7 @@ }; CBA2AFD50F39EC14005AFEBE /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 69102D5CD4E9672D0EFBF25B /* Pods-selfcontrol-cli.release.xcconfig */; + baseConfigurationReference = 861003B099EA6AC9C8943755 /* Pods-selfcontrol-cli.release.xcconfig */; buildSettings = { CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; @@ -4568,7 +4671,8 @@ CURRENT_PROJECT_VERSION = 410; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; FRAMEWORK_SEARCH_PATHS = ( - "$(PLATFORM_DIR)/Developer/Library/Frameworks\n\n$(PLATFORM_DIR)/Developer/Library/Frameworks\n\n", + "$(PLATFORM_DIR)/Developer/Library/Frameworks", + "$(PLATFORM_DIR)/Developer/Library/Frameworks", ); GCC_MODEL_TUNING = G5; GCC_NO_COMMON_BLOCKS = NO; @@ -4578,6 +4682,7 @@ INFOPLIST_FILE = "selfcontrol-cli-Info.plist"; INFOPLIST_PREPROCESS = YES; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks @loader_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 11.5; PRODUCT_BUNDLE_IDENTIFIER = "org.eyebeam.selfcontrol-cli"; PRODUCT_NAME = "selfcontrol-cli"; PROVISIONING_PROFILE = ""; diff --git a/SelfControl.xcodeproj/xcshareddata/xcschemes/SCKillerHelper.xcscheme b/SelfControl.xcodeproj/xcshareddata/xcschemes/SCKillerHelper.xcscheme new file mode 100644 index 00000000..d960f986 --- /dev/null +++ b/SelfControl.xcodeproj/xcshareddata/xcschemes/SCKillerHelper.xcscheme @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SelfControl.xcodeproj/xcshareddata/xcschemes/SelfControl.xcscheme b/SelfControl.xcodeproj/xcshareddata/xcschemes/SelfControl.xcscheme new file mode 100644 index 00000000..16cc8d56 --- /dev/null +++ b/SelfControl.xcodeproj/xcshareddata/xcschemes/SelfControl.xcscheme @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SelfControl_Prefix.pch b/SelfControl_Prefix.pch index 0af9d99b..20c7d07a 100755 --- a/SelfControl_Prefix.pch +++ b/SelfControl_Prefix.pch @@ -4,10 +4,11 @@ #ifdef __OBJC__ +#define SELFCONTROL_VERSION_STRING @"4.0.2" + #import #import -#import "version-header.h" #import "DeprecationSilencers.h" #import "SCUtility.h" #import "SCErr.h" diff --git a/cli-main.m b/cli-main.m index 46958cca..2f59e902 100755 --- a/cli-main.m +++ b/cli-main.m @@ -232,10 +232,10 @@ int main(int argc, char* argv[]) { NSLog(@"%@", blockIsRunning ? @"YES" : @"NO"); } else if ([arguments booleanValueForSignature: versionSig]) { [SCSentry addBreadcrumb: @"CLI method --version called" category: @"cli"]; - NSLog(SELFCONTROL_VERSION_STRING); +// NSLog(SELFCONTROL_VERSION_STRING); } else { // help / usage message - printf("SelfControl CLI Tool v%s\n", [SELFCONTROL_VERSION_STRING UTF8String]); +// printf("SelfControl CLI Tool v%s\n", [SELFCONTROL_VERSION_STRING UTF8String]); printf("Usage: selfcontrol-cli [--uid ] []\n\n"); printf("Valid commands:\n"); printf("\n start --> starts a SelfControl block\n");