@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.
- package/ElizaosCapacitorWebsiteBlocker.podspec +17 -0
- package/android/build.gradle +51 -0
- package/android/src/main/AndroidManifest.xml +35 -0
- package/android/src/main/java/ai/eliza/plugins/websiteblocker/DnsPacketCodec.kt +170 -0
- package/android/src/main/java/ai/eliza/plugins/websiteblocker/WebsiteBlockerBootReceiver.kt +39 -0
- package/android/src/main/java/ai/eliza/plugins/websiteblocker/WebsiteBlockerPlugin.kt +277 -0
- package/android/src/main/java/ai/eliza/plugins/websiteblocker/WebsiteBlockerStateStore.kt +205 -0
- package/android/src/main/java/ai/eliza/plugins/websiteblocker/WebsiteBlockerVpnService.kt +326 -0
- package/dist/esm/definitions.d.ts +83 -0
- package/dist/esm/definitions.d.ts.map +1 -0
- package/dist/esm/definitions.js +2 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/index.d.ts +4 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +7 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/web.d.ts +18 -0
- package/dist/esm/web.d.ts.map +1 -0
- package/dist/esm/web.js +100 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/plugin.cjs.js +115 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +118 -0
- package/dist/plugin.js.map +1 -0
- package/ios/Sources/WebsiteBlockerPlugin/WebsiteBlockerPlugin.swift +235 -0
- package/ios/Sources/WebsiteBlockerPlugin/WebsiteBlockerShared.swift +294 -0
- package/package.json +77 -0
|
@@ -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
|
+
}
|