WebBrowser/AdBlock/AdBlockManager.py

changeset 6028
859f6894eed9
parent 5726
e1dbd217214a
child 6048
82ad8ec9548c
equal deleted inserted replaced
6027:d056a536670e 6028:859f6894eed9
10 from __future__ import unicode_literals 10 from __future__ import unicode_literals
11 11
12 import os 12 import os
13 13
14 from PyQt5.QtCore import pyqtSignal, QObject, QUrl, QUrlQuery, QFile, \ 14 from PyQt5.QtCore import pyqtSignal, QObject, QUrl, QUrlQuery, QFile, \
15 QByteArray 15 QByteArray, QMutex, QMutexLocker
16 from PyQt5.QtWebEngineCore import QWebEngineUrlRequestInfo 16 from PyQt5.QtWebEngineCore import QWebEngineUrlRequestInfo
17 17
18 from E5Gui import E5MessageBox 18 from E5Gui import E5MessageBox
19 19
20 from .AdBlockSubscription import AdBlockSubscription 20 from .AdBlockSubscription import AdBlockSubscription
21 from .AdBlockUrlInterceptor import AdBlockUrlInterceptor 21 from .AdBlockUrlInterceptor import AdBlockUrlInterceptor
22 from .AdBlockMatcher import AdBlockMatcher
22 23
23 from Utilities.AutoSaver import AutoSaver 24 from Utilities.AutoSaver import AutoSaver
24 import Utilities 25 import Utilities
25 import Preferences 26 import Preferences
26 27
30 Class implementing the AdBlock manager. 31 Class implementing the AdBlock manager.
31 32
32 @signal rulesChanged() emitted after some rule has changed 33 @signal rulesChanged() emitted after some rule has changed
33 @signal requiredSubscriptionLoaded(subscription) emitted to indicate 34 @signal requiredSubscriptionLoaded(subscription) emitted to indicate
34 loading of a required subscription is finished (AdBlockSubscription) 35 loading of a required subscription is finished (AdBlockSubscription)
36 @signal enabledChanged(enabled) emitted to indicate a change of the
37 enabled state
35 """ 38 """
36 rulesChanged = pyqtSignal() 39 rulesChanged = pyqtSignal()
37 requiredSubscriptionLoaded = pyqtSignal(AdBlockSubscription) 40 requiredSubscriptionLoaded = pyqtSignal(AdBlockSubscription)
41 enabledChanged = pyqtSignal(bool)
38 42
39 def __init__(self, parent=None): 43 def __init__(self, parent=None):
40 """ 44 """
41 Constructor 45 Constructor
42 46
43 @param parent reference to the parent object (QObject) 47 @param parent reference to the parent object
48 @type QObject
44 """ 49 """
45 super(AdBlockManager, self).__init__(parent) 50 super(AdBlockManager, self).__init__(parent)
46 51
47 self.__loaded = False 52 self.__loaded = False
48 self.__subscriptionsLoaded = False 53 self.__subscriptionsLoaded = False
62 "https://easylist-downloads.adblockplus.org/easylist.txt&"\ 67 "https://easylist-downloads.adblockplus.org/easylist.txt&"\
63 "title=EasyList" 68 "title=EasyList"
64 self.__customSubscriptionUrlString = \ 69 self.__customSubscriptionUrlString = \
65 bytes(self.__customSubscriptionUrl().toEncoded()).decode() 70 bytes(self.__customSubscriptionUrl().toEncoded()).decode()
66 71
72 self.__mutex = QMutex()
73 self.__matcher = AdBlockMatcher(self)
74
67 self.rulesChanged.connect(self.__saveTimer.changeOccurred) 75 self.rulesChanged.connect(self.__saveTimer.changeOccurred)
68 self.rulesChanged.connect(self.__rulesChanged) 76 self.rulesChanged.connect(self.__rulesChanged)
69 77
70 self.__interceptor = AdBlockUrlInterceptor(self) 78 self.__interceptor = AdBlockUrlInterceptor(self)
71 79
77 """ 85 """
78 Private slot handling a change of the AdBlock rules. 86 Private slot handling a change of the AdBlock rules.
79 """ 87 """
80 from WebBrowser.WebBrowserWindow import WebBrowserWindow 88 from WebBrowser.WebBrowserWindow import WebBrowserWindow
81 WebBrowserWindow.mainWindow().reloadUserStyleSheet() 89 WebBrowserWindow.mainWindow().reloadUserStyleSheet()
90 self.__updateMatcher()
82 91
83 def close(self): 92 def close(self):
84 """ 93 """
85 Public method to close the open search engines manager. 94 Public method to close the open search engines manager.
86 """ 95 """
92 101
93 def isEnabled(self): 102 def isEnabled(self):
94 """ 103 """
95 Public method to check, if blocking ads is enabled. 104 Public method to check, if blocking ads is enabled.
96 105
97 @return flag indicating the enabled state (boolean) 106 @return flag indicating the enabled state
107 @rtype bool
98 """ 108 """
99 if not self.__loaded: 109 if not self.__loaded:
100 self.load() 110 self.load()
101 111
102 return self.__enabled 112 return self.__enabled
103 113
104 def setEnabled(self, enabled): 114 def setEnabled(self, enabled):
105 """ 115 """
106 Public slot to set the enabled state. 116 Public slot to set the enabled state.
107 117
108 @param enabled flag indicating the enabled state (boolean) 118 @param enabled flag indicating the enabled state
119 @type bool
109 """ 120 """
110 if self.isEnabled() == enabled: 121 if self.isEnabled() == enabled:
111 return 122 return
112 123
113 from WebBrowser.WebBrowserWindow import WebBrowserWindow 124 from WebBrowser.WebBrowserWindow import WebBrowserWindow
114 self.__enabled = enabled 125 self.__enabled = enabled
115 for mainWindow in WebBrowserWindow.mainWindows(): 126 for mainWindow in WebBrowserWindow.mainWindows():
116 mainWindow.adBlockIcon().setEnabled(enabled) 127 mainWindow.adBlockIcon().setEnabled(enabled)
117 if enabled: 128 if enabled:
118 self.__loadSubscriptions() 129 self.__loadSubscriptions()
130
119 self.rulesChanged.emit() 131 self.rulesChanged.emit()
132 self.enabledChanged.emit(enabled)
120 133
121 def block(self, info): 134 def block(self, info):
122 """ 135 """
123 Public method to check, if a request should be blocked. 136 Public method to check, if a request should be blocked.
124 137
125 @param info request info aobject 138 @param info request info object
126 @type QWebEngineUrlRequestInfo 139 @type QWebEngineUrlRequestInfo
127 @return flag indicating to block the request 140 @return flag indicating to block the request
128 @rtype bool 141 @rtype bool
129 """ 142 """
143 locker = QMutexLocker(self.__mutex) # __IGNORE_WARNING__
144
145 if not self.isEnabled():
146 return False
147
130 urlString = bytes(info.requestUrl().toEncoded()).decode().lower() 148 urlString = bytes(info.requestUrl().toEncoded()).decode().lower()
131 urlDomain = info.requestUrl().host().lower() 149 urlDomain = info.requestUrl().host().lower()
132 urlScheme = info.requestUrl().scheme().lower() 150 urlScheme = info.requestUrl().scheme().lower()
133 refererHost = info.firstPartyUrl().host().lower() 151
134 152 if not self.canRunOnScheme(urlScheme) or \
135 if not self.isEnabled() or not self.__canRunOnScheme(urlScheme): 153 not self.__canBeBlocked(info.firstPartyUrl()):
136 return False 154 return False
137 155
138 if self.isHostExcepted(urlDomain) or self.isHostExcepted(refererHost):
139 return False
140
141 res = False 156 res = False
142 157 blockedRule = self.__matcher.match(info, urlDomain, urlString)
143 for subscription in self.subscriptions(): 158
144 if subscription.isEnabled(): 159 if blockedRule:
145 if subscription.adBlockDisabledForUrl(info.requestUrl()): 160 res = True
146 continue 161 if info.resourceType() == \
147 162 QWebEngineUrlRequestInfo.ResourceTypeMainFrame:
148 blockedRule = subscription.match(info, urlDomain, urlString) 163 url = QUrl("eric:adblock")
149 if blockedRule: 164 query = QUrlQuery()
150 res = True 165 query.addQueryItem("rule", blockedRule.filter())
151 if info.resourceType() == \ 166 query.addQueryItem(
152 QWebEngineUrlRequestInfo.ResourceTypeMainFrame: 167 "subscription", blockedRule.subscription().title())
153 url = QUrl("eric:adblock") 168 url.setQuery(query)
154 query = QUrlQuery() 169 info.redirect(url)
155 query.addQueryItem("rule", blockedRule.filter()) 170 else:
156 query.addQueryItem( 171 info.block(True)
157 "subscription", blockedRule.subscription().title())
158 url.setQuery(query)
159 info.redirect(url)
160 res = False
161 else:
162 info.block(True)
163 break
164 172
165 return res 173 return res
166 174
167 def __canRunOnScheme(self, scheme): 175 def canRunOnScheme(self, scheme):
168 """ 176 """
169 Private method to check, if AdBlock can be performed on the scheme. 177 Public method to check, if AdBlock can be performed on the scheme.
170 178
171 @param scheme scheme to check (string) 179 @param scheme scheme to check
172 @return flag indicating, that AdBlock can be performed (boolean) 180 @type str
181 @return flag indicating, that AdBlock can be performed
182 @rtype bool
173 """ 183 """
174 return scheme not in ["data", "eric", "qthelp", "qrc", "file", "abp"] 184 return scheme not in ["data", "eric", "qthelp", "qrc", "file", "abp"]
175 185
176 def page(self): 186 def page(self):
177 """ 187 """
178 Public method to get a reference to the page block object. 188 Public method to get a reference to the page block object.
179 189
180 @return reference to the page block object (AdBlockPage) 190 @return reference to the page block object
191 @rtype AdBlockPage
181 """ 192 """
182 if self.__adBlockPage is None: 193 if self.__adBlockPage is None:
183 from .AdBlockPage import AdBlockPage 194 from .AdBlockPage import AdBlockPage
184 self.__adBlockPage = AdBlockPage(self) 195 self.__adBlockPage = AdBlockPage(self)
185 return self.__adBlockPage 196 return self.__adBlockPage
186 197
187 def __customSubscriptionLocation(self): 198 def __customSubscriptionLocation(self):
188 """ 199 """
189 Private method to generate the path for custom subscriptions. 200 Private method to generate the path for custom subscriptions.
190 201
191 @return URL for custom subscriptions (QUrl) 202 @return URL for custom subscriptions
203 @rtype QUrl
192 """ 204 """
193 dataDir = os.path.join(Utilities.getConfigDir(), "web_browser", 205 dataDir = os.path.join(Utilities.getConfigDir(), "web_browser",
194 "subscriptions") 206 "subscriptions")
195 if not os.path.exists(dataDir): 207 if not os.path.exists(dataDir):
196 os.makedirs(dataDir) 208 os.makedirs(dataDir)
199 211
200 def __customSubscriptionUrl(self): 212 def __customSubscriptionUrl(self):
201 """ 213 """
202 Private method to generate the URL for custom subscriptions. 214 Private method to generate the URL for custom subscriptions.
203 215
204 @return URL for custom subscriptions (QUrl) 216 @return URL for custom subscriptions
217 @rtype QUrl
205 """ 218 """
206 location = self.__customSubscriptionLocation() 219 location = self.__customSubscriptionLocation()
207 encodedUrl = bytes(location.toEncoded()).decode() 220 encodedUrl = bytes(location.toEncoded()).decode()
208 url = QUrl("abp:subscribe?location={0}&title={1}".format( 221 url = QUrl("abp:subscribe?location={0}&title={1}".format(
209 encodedUrl, self.tr("Custom Rules"))) 222 encodedUrl, self.tr("Custom Rules")))
211 224
212 def customRules(self): 225 def customRules(self):
213 """ 226 """
214 Public method to get a subscription for custom rules. 227 Public method to get a subscription for custom rules.
215 228
216 @return subscription object for custom rules (AdBlockSubscription) 229 @return subscription object for custom rules
230 @rtype AdBlockSubscription
217 """ 231 """
218 location = self.__customSubscriptionLocation() 232 location = self.__customSubscriptionLocation()
219 for subscription in self.__subscriptions: 233 for subscription in self.__subscriptions:
220 if subscription.location() == location: 234 if subscription.location() == location:
221 return subscription 235 return subscription
227 241
228 def subscriptions(self): 242 def subscriptions(self):
229 """ 243 """
230 Public method to get all subscriptions. 244 Public method to get all subscriptions.
231 245
232 @return list of subscriptions (list of AdBlockSubscription) 246 @return list of subscriptions
247 @rtype list of AdBlockSubscription
233 """ 248 """
234 if not self.__loaded: 249 if not self.__loaded:
235 self.load() 250 self.load()
236 251
237 return self.__subscriptions[:] 252 return self.__subscriptions[:]
238 253
239 def subscription(self, location): 254 def subscription(self, location):
240 """ 255 """
241 Public method to get a subscription based on its location. 256 Public method to get a subscription based on its location.
242 257
243 @param location location of the subscription to search for (string) 258 @param location location of the subscription to search for
244 @return subscription or None (AdBlockSubscription) 259 @type str
260 @return subscription or None
261 @rtype AdBlockSubscription
245 """ 262 """
246 if location != "": 263 if location != "":
247 for subscription in self.__subscriptions: 264 for subscription in self.__subscriptions:
248 if subscription.location().toString() == location: 265 if subscription.location().toString() == location:
249 return subscription 266 return subscription
260 def removeSubscription(self, subscription, emitSignal=True): 277 def removeSubscription(self, subscription, emitSignal=True):
261 """ 278 """
262 Public method to remove an AdBlock subscription. 279 Public method to remove an AdBlock subscription.
263 280
264 @param subscription AdBlock subscription to be removed 281 @param subscription AdBlock subscription to be removed
265 (AdBlockSubscription) 282 @type AdBlockSubscription
266 @param emitSignal flag indicating to send a signal (boolean) 283 @param emitSignal flag indicating to send a signal
284 @type bool
267 """ 285 """
268 if subscription is None: 286 if subscription is None:
269 return 287 return
270 288
271 if subscription.url().toString().startswith( 289 if subscription.url().toString().startswith(
318 WebBrowserWindow.adBlockManager()) 336 WebBrowserWindow.adBlockManager())
319 WebBrowserWindow.adBlockManager().addSubscription(subscription) 337 WebBrowserWindow.adBlockManager().addSubscription(subscription)
320 dlg.addSubscription(subscription, False) 338 dlg.addSubscription(subscription, False)
321 dlg.setFocus() 339 dlg.setFocus()
322 dlg.raise_() 340 dlg.raise_()
341
342 return res
323 343
324 def addSubscription(self, subscription): 344 def addSubscription(self, subscription):
325 """ 345 """
326 Public method to add an AdBlock subscription. 346 Public method to add an AdBlock subscription.
327 347
328 @param subscription AdBlock subscription to be added 348 @param subscription AdBlock subscription to be added
329 (AdBlockSubscription) 349 @type AdBlockSubscription
330 """ 350 """
331 if subscription is None: 351 if subscription is None:
332 return 352 return
333 353
334 self.__subscriptions.insert(-1, subscription) 354 self.__subscriptions.insert(-1, subscription)
408 self, 428 self,
409 subscription.startswith(self.__defaultSubscriptionUrlString)) 429 subscription.startswith(self.__defaultSubscriptionUrlString))
410 adBlockSubscription.rulesChanged.connect(self.rulesChanged) 430 adBlockSubscription.rulesChanged.connect(self.rulesChanged)
411 adBlockSubscription.changed.connect(self.rulesChanged) 431 adBlockSubscription.changed.connect(self.rulesChanged)
412 adBlockSubscription.enabledChanged.connect(self.rulesChanged) 432 adBlockSubscription.enabledChanged.connect(self.rulesChanged)
433 adBlockSubscription.rulesEnabledChanged.connect(
434 self.__updateMatcher)
435 adBlockSubscription.rulesEnabledChanged.connect(
436 self.__saveTimer.changeOccurred)
413 self.__subscriptions.append(adBlockSubscription) 437 self.__subscriptions.append(adBlockSubscription)
414 438
415 self.__subscriptionsLoaded = True 439 self.__subscriptionsLoaded = True
440
441 self.__updateMatcher()
416 442
417 def loadRequiredSubscription(self, location, title): 443 def loadRequiredSubscription(self, location, title):
418 """ 444 """
419 Public method to load a subscription required by another one. 445 Public method to load a subscription required by another one.
420 446
421 @param location location of the required subscription (string) 447 @param location location of the required subscription
422 @param title title of the required subscription (string) 448 @type str
449 @param title title of the required subscription
450 @type str
423 """ 451 """
424 # Step 1: check, if the subscription is in the list of subscriptions 452 # Step 1: check, if the subscription is in the list of subscriptions
425 urlString = "abp:subscribe?location={0}&title={1}".format( 453 urlString = "abp:subscribe?location={0}&title={1}".format(
426 location, title) 454 location, title)
427 for subscription in self.__subscriptions: 455 for subscription in self.__subscriptions:
438 def getRequiresSubscriptions(self, subscription): 466 def getRequiresSubscriptions(self, subscription):
439 """ 467 """
440 Public method to get a list of subscriptions, that require the given 468 Public method to get a list of subscriptions, that require the given
441 one. 469 one.
442 470
443 @param subscription subscription to check for (AdBlockSubscription) 471 @param subscription subscription to check for
444 @return list of subscription requiring the given one (list of 472 @type AdBlockSubscription
445 AdBlockSubscription) 473 @return list of subscription requiring the given one
474 @rtype list of AdBlockSubscription
446 """ 475 """
447 subscriptions = [] 476 subscriptions = []
448 location = subscription.location().toString() 477 location = subscription.location().toString()
449 for subscription in self.__subscriptions: 478 for subscription in self.__subscriptions:
450 if subscription.requiresLocation() == location: 479 if subscription.requiresLocation() == location:
454 483
455 def showDialog(self): 484 def showDialog(self):
456 """ 485 """
457 Public slot to show the AdBlock subscription management dialog. 486 Public slot to show the AdBlock subscription management dialog.
458 487
459 @return reference to the dialog (AdBlockDialog) 488 @return reference to the dialog
489 @rtype AdBlockDialog
460 """ 490 """
461 if self.__adBlockDialog is None: 491 if self.__adBlockDialog is None:
462 from .AdBlockDialog import AdBlockDialog 492 from .AdBlockDialog import AdBlockDialog
463 self.__adBlockDialog = AdBlockDialog(self) 493 self.__adBlockDialog = AdBlockDialog(self)
464 494
468 def elementHidingRules(self, url): 498 def elementHidingRules(self, url):
469 """ 499 """
470 Public method to get the element hiding rules. 500 Public method to get the element hiding rules.
471 501
472 502
473 @param url URL to get hiding rules for (QUrl) 503 @param url URL to get hiding rules for
474 @return element hiding rules (string) 504 @type QUrl
475 """ 505 @return element hiding rules
476 if not self.isEnabled() or not self.__canRunOnScheme(url.scheme()): 506 @rtype str
507 """
508 if not self.isEnabled() or \
509 not self.canRunOnScheme(url.scheme()) or \
510 not self.__canBeBlocked(url):
477 return "" 511 return ""
478 512
479 rules = "" 513 return self.__matcher.elementHidingRules()
480
481 for subscription in self.__subscriptions:
482 rules += subscription.elementHidingRules()
483
484 if rules:
485 # remove last ",
486 rules = rules[:-1]
487
488 return rules
489 514
490 def elementHidingRulesForDomain(self, url): 515 def elementHidingRulesForDomain(self, url):
491 """ 516 """
492 Public method to get the element hiding rules for a domain. 517 Public method to get the element hiding rules for a domain.
493 518
494 @param url URL to get hiding rules for (QUrl) 519 @param url URL to get hiding rules for
495 @return element hiding rules (string) 520 @type QUrl
496 """ 521 @return element hiding rules
497 if not self.isEnabled(): 522 @rtype str
523 """
524 if not self.isEnabled() or \
525 not self.canRunOnScheme(url.scheme()) or \
526 not self.__canBeBlocked(url):
498 return "" 527 return ""
499 528
500 rules = "" 529 return self.__matcher.elementHidingRulesForDomain(url.host())
501
502 for subscription in self.__subscriptions:
503 if subscription.elemHideDisabledForUrl(url):
504 continue
505
506 rules += subscription.elementHidingRulesForDomain(url.host())
507
508 if rules:
509 # remove last ","
510 rules = rules[:-1]
511
512 rules += "{display:none !important;}\n"
513
514 return rules
515 530
516 def exceptions(self): 531 def exceptions(self):
517 """ 532 """
518 Public method to get a list of excepted hosts. 533 Public method to get a list of excepted hosts.
519 534
520 @return list of excepted hosts (list of string) 535 @return list of excepted hosts
536 @rtype list of str
521 """ 537 """
522 return self.__exceptedHosts 538 return self.__exceptedHosts
523 539
524 def setExceptions(self, hosts): 540 def setExceptions(self, hosts):
525 """ 541 """
526 Public method to set the list of excepted hosts. 542 Public method to set the list of excepted hosts.
527 543
528 @param hosts list of excepted hosts (list of string) 544 @param hosts list of excepted hosts
545 @type list of str
529 """ 546 """
530 self.__exceptedHosts = [host.lower() for host in hosts] 547 self.__exceptedHosts = [host.lower() for host in hosts]
531 Preferences.setWebBrowser("AdBlockExceptions", self.__exceptedHosts) 548 Preferences.setWebBrowser("AdBlockExceptions", self.__exceptedHosts)
532 549
533 def addException(self, host): 550 def addException(self, host):
534 """ 551 """
535 Public method to add an exception. 552 Public method to add an exception.
536 553
537 @param host to be excepted (string) 554 @param host to be excepted
555 @type str
538 """ 556 """
539 host = host.lower() 557 host = host.lower()
540 if host and host not in self.__exceptedHosts: 558 if host and host not in self.__exceptedHosts:
541 self.__exceptedHosts.append(host) 559 self.__exceptedHosts.append(host)
542 Preferences.setWebBrowser( 560 Preferences.setWebBrowser(
544 562
545 def removeException(self, host): 563 def removeException(self, host):
546 """ 564 """
547 Public method to remove an exception. 565 Public method to remove an exception.
548 566
549 @param host to be removed from the list of exceptions (string) 567 @param host to be removed from the list of exceptions
568 @type str
550 """ 569 """
551 host = host.lower() 570 host = host.lower()
552 if host in self.__exceptedHosts: 571 if host in self.__exceptedHosts:
553 self.__exceptedHosts.remove(host) 572 self.__exceptedHosts.remove(host)
554 Preferences.setWebBrowser( 573 Preferences.setWebBrowser(
556 575
557 def isHostExcepted(self, host): 576 def isHostExcepted(self, host):
558 """ 577 """
559 Public slot to check, if a host is excepted. 578 Public slot to check, if a host is excepted.
560 579
561 @param host host to check (string) 580 @param host host to check
562 @return flag indicating an exception (boolean) 581 @type str
582 @return flag indicating an exception
583 @rtype bool
563 """ 584 """
564 host = host.lower() 585 host = host.lower()
565 return host in self.__exceptedHosts 586 return host in self.__exceptedHosts
566 587
567 def showExceptionsDialog(self): 588 def showExceptionsDialog(self):
568 """ 589 """
569 Public method to show the AdBlock Exceptions dialog. 590 Public method to show the AdBlock Exceptions dialog.
570 591
571 @return reference to the exceptions dialog (AdBlockExceptionsDialog) 592 @return reference to the exceptions dialog
593 @rtype AdBlockExceptionsDialog
572 """ 594 """
573 if self.__adBlockExceptionsDialog is None: 595 if self.__adBlockExceptionsDialog is None:
574 from .AdBlockExceptionsDialog import AdBlockExceptionsDialog 596 from .AdBlockExceptionsDialog import AdBlockExceptionsDialog
575 self.__adBlockExceptionsDialog = AdBlockExceptionsDialog() 597 self.__adBlockExceptionsDialog = AdBlockExceptionsDialog()
576 598
609 631
610 @return default subscription URL 632 @return default subscription URL
611 @rtype str 633 @rtype str
612 """ 634 """
613 return self.__defaultSubscriptionUrlString 635 return self.__defaultSubscriptionUrlString
636
637 def __updateMatcher(self):
638 """
639 Private slot to update the adblock matcher.
640 """
641 if self.__enabled:
642 self.__matcher.update()
643 else:
644 self.__matcher.clear()
645
646 def __canBeBlocked(self, url):
647 """
648 Private method to check, if the given URL could be blocked (i.e. is
649 not whitelisted).
650
651 @param url URL to be checked
652 @type QUrl
653 @return flag indicating that the given URL can be blocked
654 @rtype bool
655 """
656 return not self.__matcher.adBlockDisabledForUrl(url)

eric ide

mercurial