@elizaos/capacitor-websiteblocker 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,294 @@
1
+ import Foundation
2
+ import SafariServices
3
+
4
+ struct WebsiteBlockerStoredState: Codable {
5
+ let websites: [String]
6
+ let endsAtEpochMs: Double?
7
+ let requestedWebsites: [String]?
8
+ let blockedWebsites: [String]?
9
+ let allowedWebsites: [String]?
10
+ let matchMode: String?
11
+ }
12
+
13
+ enum WebsiteBlockerShared {
14
+ static var appGroupIdentifier: String {
15
+ "group.\(Bundle.main.bundleIdentifier ?? "ai.elizaos.app")"
16
+ }
17
+
18
+ static var contentBlockerIdentifier: String {
19
+ "\(Bundle.main.bundleIdentifier ?? "ai.elizaos.app").WebsiteBlockerContentExtension"
20
+ }
21
+
22
+ static let stateKey = "website_blocker_state_v1"
23
+ static let iso8601Formatter = ISO8601DateFormatter()
24
+ static let exactMatchMode = "exact"
25
+ static let subdomainMatchMode = "subdomain"
26
+ static let xTwitterBlockedWebsites = [
27
+ "x.com",
28
+ "www.x.com",
29
+ "mobile.x.com",
30
+ "twitter.com",
31
+ "www.twitter.com",
32
+ "mobile.twitter.com",
33
+ "t.co",
34
+ "abs.twimg.com",
35
+ "pbs.twimg.com",
36
+ "video.twimg.com",
37
+ "ton.twimg.com",
38
+ "platform.twitter.com",
39
+ "tweetdeck.twitter.com",
40
+ ]
41
+ static let xTwitterAllowedWebsites = [
42
+ "api.x.com",
43
+ "api.twitter.com",
44
+ ]
45
+ static let googleNewsBlockedWebsites = ["news.google.com"]
46
+ static let googleNewsAllowedWebsites = [
47
+ "accounts.google.com",
48
+ "oauth2.googleapis.com",
49
+ "openidconnect.googleapis.com",
50
+ "www.googleapis.com",
51
+ ]
52
+
53
+ static func normalizeHostname(_ value: String) -> String? {
54
+ let trimmed = value
55
+ .trimmingCharacters(in: .whitespacesAndNewlines)
56
+ .trimmingCharacters(in: CharacterSet(charactersIn: "."))
57
+ .lowercased()
58
+ guard !trimmed.isEmpty else {
59
+ return nil
60
+ }
61
+ guard trimmed.contains(".") else {
62
+ return nil
63
+ }
64
+ guard trimmed.range(of: "^[a-z0-9.-]+$", options: .regularExpression) != nil else {
65
+ return nil
66
+ }
67
+ guard !trimmed.hasPrefix("."), !trimmed.hasSuffix(".") else {
68
+ return nil
69
+ }
70
+ return trimmed
71
+ }
72
+
73
+ static func parseWebsites(explicit: [Any], text: String?) -> [String] {
74
+ var websites: [String] = []
75
+ for value in explicit {
76
+ if let hostname = value as? String,
77
+ let normalized = normalizeHostname(hostname) {
78
+ websites.append(normalized)
79
+ }
80
+ }
81
+
82
+ if let text, !text.isEmpty {
83
+ let parts = text.components(separatedBy: CharacterSet.whitespacesAndNewlines.union(CharacterSet(charactersIn: ",")))
84
+ for part in parts {
85
+ if let normalized = normalizeHostname(part) {
86
+ websites.append(normalized)
87
+ }
88
+ }
89
+ }
90
+
91
+ return Array(Set(websites)).sorted()
92
+ }
93
+
94
+ static func parseDurationMinutes(_ rawDuration: Any?) -> Int? {
95
+ switch rawDuration {
96
+ case let value as NSNumber:
97
+ let minutes = value.intValue
98
+ return minutes > 0 ? minutes : nil
99
+ case let value as String:
100
+ guard let minutes = Int(value), minutes > 0 else {
101
+ return nil
102
+ }
103
+ return minutes
104
+ default:
105
+ return nil
106
+ }
107
+ }
108
+
109
+ static func buildPolicy(for requestedWebsites: [String]) -> WebsiteBlockerStoredState {
110
+ let normalizedRequested = Array(Set(requestedWebsites.compactMap(normalizeHostname))).sorted()
111
+ var blockedWebsites = Set<String>()
112
+ var allowedWebsites = Set<String>()
113
+
114
+ for website in normalizedRequested {
115
+ blockedWebsites.insert(website)
116
+ if shouldAddWwwVariant(website) {
117
+ blockedWebsites.insert("www.\(website)")
118
+ }
119
+
120
+ if ["x.com", "twitter.com"].contains(website) {
121
+ blockedWebsites.formUnion(xTwitterBlockedWebsites)
122
+ allowedWebsites.formUnion(xTwitterAllowedWebsites)
123
+ } else if website == "news.google.com" {
124
+ blockedWebsites.formUnion(googleNewsBlockedWebsites)
125
+ allowedWebsites.formUnion(googleNewsAllowedWebsites)
126
+ }
127
+ }
128
+
129
+ return WebsiteBlockerStoredState(
130
+ websites: normalizedRequested,
131
+ endsAtEpochMs: nil,
132
+ requestedWebsites: normalizedRequested,
133
+ blockedWebsites: Array(blockedWebsites).sorted(),
134
+ allowedWebsites: Array(allowedWebsites).sorted(),
135
+ matchMode: exactMatchMode
136
+ )
137
+ }
138
+
139
+ static func sharedDefaults() -> UserDefaults? {
140
+ UserDefaults(suiteName: appGroupIdentifier)
141
+ }
142
+
143
+ static func loadState() -> WebsiteBlockerStoredState? {
144
+ guard let defaults = sharedDefaults(),
145
+ let data = defaults.data(forKey: stateKey) else {
146
+ return nil
147
+ }
148
+
149
+ guard var decoded = try? JSONDecoder().decode(WebsiteBlockerStoredState.self, from: data) else {
150
+ defaults.removeObject(forKey: stateKey)
151
+ return nil
152
+ }
153
+
154
+ if decoded.requestedWebsites == nil || decoded.blockedWebsites == nil || decoded.allowedWebsites == nil || decoded.matchMode == nil {
155
+ decoded = WebsiteBlockerStoredState(
156
+ websites: decoded.websites,
157
+ endsAtEpochMs: decoded.endsAtEpochMs,
158
+ requestedWebsites: decoded.websites,
159
+ blockedWebsites: decoded.websites,
160
+ allowedWebsites: [],
161
+ matchMode: exactMatchMode
162
+ )
163
+ }
164
+
165
+ if let endsAtEpochMs = decoded.endsAtEpochMs,
166
+ endsAtEpochMs <= Date().timeIntervalSince1970 * 1000 {
167
+ defaults.removeObject(forKey: stateKey)
168
+ return nil
169
+ }
170
+
171
+ if decoded.websites.isEmpty {
172
+ defaults.removeObject(forKey: stateKey)
173
+ return nil
174
+ }
175
+
176
+ return decoded
177
+ }
178
+
179
+ static func saveState(websites: [String], durationMinutes: Int?) throws -> WebsiteBlockerStoredState {
180
+ let policy = buildPolicy(for: websites)
181
+ let endsAtEpochMs = durationMinutes.map { Date().timeIntervalSince1970 * 1000 + Double($0 * 60_000) }
182
+ let state = WebsiteBlockerStoredState(
183
+ websites: policy.websites,
184
+ endsAtEpochMs: endsAtEpochMs,
185
+ requestedWebsites: policy.requestedWebsites,
186
+ blockedWebsites: policy.blockedWebsites,
187
+ allowedWebsites: policy.allowedWebsites,
188
+ matchMode: policy.matchMode
189
+ )
190
+
191
+ guard let defaults = sharedDefaults() else {
192
+ throw NSError(
193
+ domain: "ElizaWebsiteBlocker",
194
+ code: 1,
195
+ userInfo: [NSLocalizedDescriptionKey: "The iPhone website blocker app group is not available in this build."]
196
+ )
197
+ }
198
+
199
+ let encoded = try JSONEncoder().encode(state)
200
+ defaults.set(encoded, forKey: stateKey)
201
+ return state
202
+ }
203
+
204
+ static func clearState() {
205
+ sharedDefaults()?.removeObject(forKey: stateKey)
206
+ }
207
+
208
+ static func shouldAddWwwVariant(_ hostname: String) -> Bool {
209
+ let labels = hostname.split(separator: ".")
210
+ return labels.count == 2 && labels.first != "www"
211
+ }
212
+
213
+ static func endsAtString(for state: WebsiteBlockerStoredState?) -> String? {
214
+ guard let endsAtEpochMs = state?.endsAtEpochMs else {
215
+ return nil
216
+ }
217
+ return iso8601Formatter.string(from: Date(timeIntervalSince1970: endsAtEpochMs / 1000))
218
+ }
219
+
220
+ static func buildContentBlockerRules(for websites: [String]) -> [[String: Any]] {
221
+ let policy = buildPolicy(for: websites)
222
+ let blockedRules = policy.blockedWebsites?.map { website -> [String: Any] in
223
+ [
224
+ "trigger": [
225
+ "url-filter": "^https?://([A-Za-z0-9-]+\\\\.)*\(NSRegularExpression.escapedPattern(for: website))([/:?#]|$)",
226
+ ],
227
+ "action": [
228
+ "type": "block",
229
+ ],
230
+ ]
231
+ } ?? []
232
+ let allowRules = policy.allowedWebsites?.map { website -> [String: Any] in
233
+ [
234
+ "trigger": [
235
+ "url-filter": "^https?://([A-Za-z0-9-]+\\\\.)*\(NSRegularExpression.escapedPattern(for: website))([/:?#]|$)",
236
+ ],
237
+ "action": [
238
+ "type": "ignore-previous-rules",
239
+ ],
240
+ ]
241
+ } ?? []
242
+ return blockedRules + allowRules
243
+ }
244
+
245
+ static func writeRulesFile() throws -> URL {
246
+ let websites = loadState()?.websites ?? []
247
+ let rules = buildContentBlockerRules(for: websites)
248
+ let data = try JSONSerialization.data(withJSONObject: rules, options: [])
249
+ let url = FileManager.default.temporaryDirectory
250
+ .appendingPathComponent("eliza-website-blocker-rules", isDirectory: true)
251
+ .appendingPathComponent("blockerList.json")
252
+ try FileManager.default.createDirectory(
253
+ at: url.deletingLastPathComponent(),
254
+ withIntermediateDirectories: true,
255
+ attributes: nil
256
+ )
257
+ try data.write(to: url, options: .atomic)
258
+ return url
259
+ }
260
+
261
+ static func contentBlockerState() async throws -> SFContentBlockerState {
262
+ try await withCheckedThrowingContinuation { continuation in
263
+ SFContentBlockerManager.getStateOfContentBlocker(withIdentifier: contentBlockerIdentifier) { state, error in
264
+ if let error {
265
+ continuation.resume(throwing: error)
266
+ return
267
+ }
268
+ guard let state else {
269
+ continuation.resume(
270
+ throwing: NSError(
271
+ domain: "ElizaWebsiteBlocker",
272
+ code: 2,
273
+ userInfo: [NSLocalizedDescriptionKey: "Safari did not return the Website Blocker extension state."]
274
+ )
275
+ )
276
+ return
277
+ }
278
+ continuation.resume(returning: state)
279
+ }
280
+ }
281
+ }
282
+
283
+ static func reloadContentBlocker() async throws {
284
+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
285
+ SFContentBlockerManager.reloadContentBlocker(withIdentifier: contentBlockerIdentifier) { error in
286
+ if let error {
287
+ continuation.resume(throwing: error)
288
+ } else {
289
+ continuation.resume(returning: ())
290
+ }
291
+ }
292
+ }
293
+ }
294
+ }
package/package.json ADDED
@@ -0,0 +1,77 @@
1
+ {
2
+ "name": "@elizaos/capacitor-websiteblocker",
3
+ "version": "1.0.0",
4
+ "description": "Blocks websites through the Eliza runtime on desktop/web, uses native Android VPN DNS enforcement, and manages a native Safari content blocker on iPhone and iPad.",
5
+ "keywords": [
6
+ "website-blocker",
7
+ "vpn",
8
+ "dns",
9
+ "focus"
10
+ ],
11
+ "main": "./dist/plugin.cjs.js",
12
+ "module": "./dist/esm/index.js",
13
+ "types": "./dist/esm/index.d.ts",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/esm/index.d.ts",
17
+ "import": "./dist/esm/index.js",
18
+ "require": "./dist/plugin.cjs.js"
19
+ },
20
+ "./package.json": "./package.json"
21
+ },
22
+ "unpkg": "dist/plugin.js",
23
+ "files": [
24
+ "android/src/main/",
25
+ "android/build.gradle",
26
+ "dist/",
27
+ "ios/Sources/",
28
+ "*.podspec"
29
+ ],
30
+ "scripts": {
31
+ "build": "npm run clean && tsc && rollup -c rollup.config.mjs",
32
+ "clean": "rimraf ./dist",
33
+ "prepublishOnly": "npm run build"
34
+ },
35
+ "author": "elizaOS",
36
+ "license": "MIT",
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "https://github.com/elizaOS/eliza.git",
40
+ "directory": "apps/app/plugins/websiteblocker"
41
+ },
42
+ "capacitor": {
43
+ "ios": {
44
+ "src": "ios",
45
+ "podName": "ElizaosCapacitorWebsiteblocker"
46
+ },
47
+ "android": {
48
+ "src": "android"
49
+ }
50
+ },
51
+ "devDependencies": {
52
+ "@capacitor/cli": "^8.0.0",
53
+ "@capacitor/core": "^8.3.1",
54
+ "rimraf": "^6.0.0",
55
+ "rollup": "^4.60.2",
56
+ "typescript": "^6.0.0"
57
+ },
58
+ "peerDependencies": {
59
+ "@capacitor/core": "^8.3.1"
60
+ },
61
+ "publishConfig": {
62
+ "access": "public"
63
+ },
64
+ "elizaos": {
65
+ "platforms": [
66
+ "browser",
67
+ "android",
68
+ "ios"
69
+ ],
70
+ "runtime": "both",
71
+ "platformDetails": {
72
+ "browser": "Delegates website blocking to the connected Eliza runtime HTTP API when available",
73
+ "ios": "Native Safari content blocker managed by the Eliza app with shared block state and Settings-based enablement",
74
+ "android": "Native split-tunnel VPN DNS blocker with system-wide hostname blocking"
75
+ }
76
+ }
77
+ }