urlwall 0.1.2__py3-none-any.whl

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.
urlwall/__init__.py ADDED
@@ -0,0 +1,179 @@
1
+ """
2
+ URL-Wall core logic.
3
+ """
4
+
5
+ import os
6
+ import re
7
+ import subprocess
8
+ import sys
9
+ import tempfile
10
+ import urllib.parse
11
+ from pathlib import Path
12
+
13
+ from urlwall import config
14
+
15
+ __all__ = [
16
+ "getCanonicalHost",
17
+ "isUrlAllowed",
18
+ "isWebURL",
19
+ "niceHost",
20
+ "openURL",
21
+ "unwrap",
22
+ "writeWarningHTML",
23
+ ]
24
+
25
+
26
+ def isWebURL(url):
27
+ """Check whether url is most likely an actual web URL."""
28
+ up = urllib.parse.urlparse(url)
29
+ return up.scheme in ("http", "https", "ftp") and up.netloc
30
+
31
+
32
+ def getCanonicalHost(url):
33
+ """Extract canonical host from a URL (no www., no trailing dot, no port)."""
34
+ host = url
35
+ if "/" in host:
36
+ purl = urllib.parse.urlparse(host)
37
+ host = purl.netloc
38
+ # Strip port if present
39
+ if ":" in host:
40
+ host = host.rsplit(":", 1)[0]
41
+ host = host.lower().strip(".")
42
+ host = host.removeprefix("www.")
43
+ return host
44
+
45
+
46
+ def niceHost(url):
47
+ """Human-readable host name for display."""
48
+ up = urllib.parse.urlparse(url)
49
+ host = up.netloc.lower()
50
+ host = host.removeprefix("www.")
51
+ if host.endswith(".safelinks.protection.outlook.com"):
52
+ host = "safelinks.outlook.com"
53
+ return host
54
+
55
+
56
+ def unwrap(url):
57
+ """Unwrap redirect chains (Cisco web proxy, Outlook safelinks, etc.).
58
+
59
+ Returns a list of all URLs encountered during unwrapping.
60
+ """
61
+ urls = []
62
+ while True:
63
+ urls.append(url)
64
+ up = urllib.parse.urlparse(url)
65
+ query = urllib.parse.parse_qs(up.query)
66
+
67
+ # Cisco web proxy: https://secure-web.cisco.com/...#...
68
+ if up.netloc == "secure-web.cisco.com":
69
+ m = re.match(r"^([^#]+)/(.+)$", url)
70
+ if m:
71
+ url1, url2 = m.groups()
72
+ url2 = urllib.parse.unquote(url2)
73
+ if isWebURL(url1) and isWebURL(url2):
74
+ url = url2
75
+ continue
76
+
77
+ # Common redirect query params
78
+ for q in ("url", "target", "rd"):
79
+ if q in query and query[q] and isWebURL(query[q][0]):
80
+ url = query[q][0]
81
+ break
82
+ else:
83
+ break
84
+
85
+ return urls
86
+
87
+
88
+ def isUrlAllowed(url, _seen=None, _depth=0):
89
+ """Check if a URL is allowed, unwrapping redirect chains and query params."""
90
+ if _seen is None:
91
+ _seen = set()
92
+ if _depth > 20 or url in _seen:
93
+ # Prevent infinite recursion from circular redirects
94
+ return False
95
+ _seen.add(url)
96
+
97
+ # First unwrap query params to find nested URLs
98
+ up = urllib.parse.urlparse(url)
99
+ query = urllib.parse.parse_qs(up.query)
100
+ for key in ("q", "url"):
101
+ for val in query.get(key, []):
102
+ if val.startswith(("http://", "https://")):
103
+ return isUrlAllowed(val, _seen, _depth + 1)
104
+
105
+ # Then check the canonical host
106
+ host = getCanonicalHost(url)
107
+ return config.getConfig().isAllowed(host)
108
+
109
+
110
+ def writeWarningHTML(urls):
111
+ """Generate a warning HTML page showing the URL chain.
112
+
113
+ Returns the path to a temp file containing the rendered page.
114
+ """
115
+ assert urls
116
+ url_pairs = [(niceHost(url), url) for url in urls]
117
+
118
+ # Read template from package resources (works when installed via pip)
119
+ template_path = Path(__file__).parent / "html" / "template.html"
120
+ text = template_path.read_text(encoding="utf-8")
121
+
122
+ bg_path = Path(__file__).parent / "html" / "bg.jpg"
123
+ bgurl = "file://" + str(bg_path.resolve())
124
+
125
+ # The final URL for the "Go to" button
126
+ final_url = url_pairs[-1][1]
127
+
128
+ for k, v in {
129
+ "backgroundimage": bgurl,
130
+ "URLS": "", # Old template uses JS to build the chain
131
+ "URL": final_url,
132
+ }.items():
133
+ text = text.replace("{{" + k + "}}", v)
134
+
135
+ fd, fn = tempfile.mkstemp(prefix="warning-", suffix=".html")
136
+ os.write(fd, text.encode("utf-8"))
137
+ os.close(fd)
138
+
139
+ return fn
140
+
141
+
142
+ def openURL(url):
143
+ """Open a URL through the URL-Wall gate."""
144
+ try:
145
+ # Normalize: ensure https scheme
146
+ if not url.startswith("http"):
147
+ if not url.startswith("//"):
148
+ url = "//" + url
149
+ url = "https:" + url
150
+
151
+ # Log
152
+ log_fn = config.getConfig().logFile
153
+ with open(log_fn, "a") as log:
154
+ log.write(f"{url}\n")
155
+
156
+ # Unwrap redirect chains
157
+ chain = unwrap(url)
158
+
159
+ # Check if any URL in the chain is allowed
160
+ allowed = False
161
+ for chain_url in chain:
162
+ if isUrlAllowed(chain_url):
163
+ allowed = True
164
+ break
165
+ if not allowed:
166
+ # Show warning page with the full chain
167
+ tfn = writeWarningHTML(chain)
168
+ url = tfn
169
+
170
+ browser = config.getConfig().getBrowser()
171
+ subprocess.call(["/usr/bin/open", "-a", browser, url])
172
+ except OSError as e:
173
+ # Log error but still open to avoid losing the URL entirely
174
+ print(f"URL-Wall error: {e}", file=sys.stderr)
175
+ import traceback
176
+
177
+ traceback.print_exc(file=sys.stderr)
178
+ # Open in default browser (not user's preferred) as a safe fallback
179
+ subprocess.call(["/usr/bin/open", url])
urlwall/__main__.py ADDED
@@ -0,0 +1,112 @@
1
+ """
2
+ URL-Wall CLI entry point.
3
+
4
+ Usage:
5
+ python -m urlwall [URL] # Open URL through the gate
6
+ python -m urlwall --allow-host-add URL # Add to allow list
7
+ python -m urlwall --allow-host-remove URL # Remove from allow list
8
+ python -m urlwall --also-subdomains # Combine with --allow-host-add
9
+ python -m urlwall --set-browser BROWSER # Set browser used after URL-Wall
10
+ python -m urlwall --use-as-default # Set URL-Wall as macOS default browser
11
+ python -m urlwall --install PATH # Install .app bundle
12
+ """
13
+
14
+ import argparse
15
+ import os
16
+ import sys
17
+
18
+ import urlwall
19
+ import urlwall.app
20
+ from urlwall import config
21
+
22
+
23
+ def main():
24
+ parser = argparse.ArgumentParser(prog="url-wall")
25
+ group = parser.add_mutually_exclusive_group()
26
+
27
+ group.add_argument(
28
+ "url",
29
+ metavar="URL",
30
+ nargs="?",
31
+ help="URL to open. If no URL and no flags, prints config.",
32
+ )
33
+ parser.add_argument("-v", "--verbose", action="store_true")
34
+ parser.add_argument(
35
+ "--install", metavar="PATH", help="Install URL-Wall as a .app bundle at PATH"
36
+ )
37
+
38
+ # Config updates
39
+ group.add_argument(
40
+ "--allow-host-add", "-a", metavar="URL", help="Add host to allow list"
41
+ )
42
+ group.add_argument(
43
+ "--allow-host-remove", metavar="URL", help="Remove host from allow list"
44
+ )
45
+ parser.add_argument(
46
+ "--also-subdomains",
47
+ action="store_true",
48
+ help="Make --allow-host-add also apply for subdomains",
49
+ )
50
+ group.add_argument(
51
+ "--set-browser", metavar="BROWSER", help="Set browser to use after URL-Wall"
52
+ )
53
+ group.add_argument(
54
+ "--use-as-default",
55
+ action="store_true",
56
+ help="Set URL-Wall as the macOS default browser",
57
+ )
58
+
59
+ args = parser.parse_args()
60
+
61
+ if args.install is not None:
62
+ app_path = os.path.expanduser(args.install)
63
+ if os.path.isdir(app_path):
64
+ print(
65
+ f"Error: {app_path} already exists. Remove it first.", file=sys.stderr
66
+ )
67
+ sys.exit(1)
68
+ urlwall.app.install(app_path)
69
+ print(f"URL-Wall installed at {app_path}")
70
+ return
71
+
72
+ cfg = config.getConfig()
73
+
74
+ if args.url is not None:
75
+ urlwall.openURL(args.url)
76
+ elif args.allow_host_add is not None:
77
+ url = args.allow_host_add
78
+ if args.also_subdomains:
79
+ if host := cfg.addAllowedHostSubdomains(url):
80
+ print(f"Added {host!r} incl. subdomains to allowed hosts")
81
+ else:
82
+ if host := cfg.addAllowedHost(url):
83
+ print(f"Added {host!r} to allowed hosts")
84
+ elif args.allow_host_remove is not None:
85
+ url = args.allow_host_remove
86
+ if host := cfg.removeAllowedHost(url):
87
+ print(f"Removed {host!r} from allowed hosts")
88
+ if host := cfg.removeAllowedHostSubdomains(url):
89
+ print(f"Removed {host!r} incl. subdomains from allowed hosts")
90
+ elif args.set_browser is not None:
91
+ browser = args.set_browser
92
+ if br := cfg.setBrowser(browser):
93
+ print(f"Browser set to {br!r}")
94
+ else:
95
+ print(f"Browser already set to {browser!r}")
96
+ elif args.use_as_default:
97
+ if cfg.setAsDefaultBrowser():
98
+ print("URL-Wall registered as default browser handler.")
99
+ else:
100
+ sys.exit(1)
101
+ else:
102
+ # Print current config summary
103
+ print(f"Browser: {cfg.getBrowser()}")
104
+ print(f"Allowed hosts: {', '.join(cfg.plist['hostsAllowed'])}")
105
+ if cfg.plist["hostWithSubdomainsAllowed"]:
106
+ print(
107
+ f"Allowed with subdomains: {', '.join(cfg.plist['hostWithSubdomainsAllowed'])}"
108
+ )
109
+
110
+
111
+ if __name__ == "__main__":
112
+ main()
@@ -0,0 +1,30 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>CFBundleIdentifier</key>
6
+ <string>com.github.svalgaard.url-wall</string>
7
+ <key>CFBundleName</key>
8
+ <string>url-wall</string>
9
+ <key>CFBundleURLTypes</key>
10
+ <array>
11
+ <dict>
12
+ <key>CFBundleURLName</key>
13
+ <string>Web site URL</string>
14
+ <key>CFBundleURLSchemes</key>
15
+ <array>
16
+ <string>http</string>
17
+ <string>https</string>
18
+ </array>
19
+ </dict>
20
+ <dict>
21
+ <key>CFBundleURLName</key>
22
+ <string>FTP site URL</string>
23
+ <key>CFBundleURLSchemes</key>
24
+ <array>
25
+ <string>ftp</string>
26
+ </array>
27
+ </dict>
28
+ </array>
29
+ </dict>
30
+ </plist>
@@ -0,0 +1,17 @@
1
+ --
2
+ -- AppleScript URL handler for URL-Wall
3
+ -- https://github.com/svalgaard/url-wall
4
+ --
5
+ -- https://developer.apple.com/library/archive/technotes/tn2065/
6
+ --
7
+
8
+ on open location theURL
9
+ set AppPath to POSIX path of (path to me as text)
10
+ set ScriptPath to AppPath & "/Contents/Resources/Assets/urlwall/cli.py"
11
+ try
12
+ do shell script "echo " & quoted form of theURL & " | python3 " & ScriptPath
13
+ on error
14
+ -- If Python fails, open directly in default browser
15
+ do shell script "open " & quoted form of theURL
16
+ end try
17
+ end open location
urlwall/app.py ADDED
@@ -0,0 +1,78 @@
1
+ """
2
+ Build a URL-Wall .app bundle from the AppleScript handler.
3
+
4
+ Usage:
5
+ ./url-wall install ~/Applications/URL-Wall.app
6
+ ./url-wall install -f ~/Applications/URL-Wall.app # force overwrite
7
+ """
8
+
9
+ import fnmatch
10
+ import os
11
+ import plistlib
12
+ import shutil
13
+ import subprocess
14
+
15
+ ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
16
+ APP_DIR = os.path.join(os.path.dirname(__file__), "app")
17
+
18
+
19
+ def _ignore_bundle(src, names):
20
+ """Ignore files/dirs that don't belong in the .app bundle."""
21
+ ignored = []
22
+ for pattern in (
23
+ "__pycache__",
24
+ "*.pyc",
25
+ ".git",
26
+ ".gitignore",
27
+ "tests",
28
+ "*.egg-info",
29
+ "dist",
30
+ "build",
31
+ ".venv",
32
+ "venv",
33
+ "*.egg",
34
+ ".github",
35
+ ".gitkeep",
36
+ ):
37
+ ignored.extend(fnmatch.filter(names, pattern))
38
+ return set(ignored)
39
+
40
+
41
+ def patchInfo(appPath):
42
+ """Patch the .app's Info.plist with URL scheme handlers."""
43
+ infofn = os.path.join(appPath, "Contents/Info.plist")
44
+ patchfn = os.path.join(APP_DIR, "Info-patch.plist")
45
+
46
+ if not os.path.isfile(infofn):
47
+ raise FileNotFoundError(f"Info.plist not found inside {appPath}")
48
+ if not os.path.isfile(patchfn):
49
+ raise FileNotFoundError(f"Info-patch.plist not found at {patchfn}")
50
+
51
+ with open(patchfn, "rb") as f:
52
+ patch = plistlib.load(f)
53
+ with open(infofn, "rb") as f:
54
+ info = plistlib.load(f)
55
+ info.update(patch)
56
+ with open(infofn, "wb") as f:
57
+ plistlib.dump(info, f)
58
+
59
+
60
+ def install(appPath):
61
+ """Build and install a URL-Wall .app bundle."""
62
+ assert appPath.endswith(".app")
63
+
64
+ scpt = os.path.join(APP_DIR, "url-wall.scpt")
65
+ if not os.path.isfile(scpt):
66
+ raise FileNotFoundError(f"AppleScript handler not found at {scpt}")
67
+
68
+ # Compile AppleScript into .app
69
+ subprocess.call(["/usr/bin/osacompile", "-o", appPath, scpt])
70
+
71
+ # Patch with URL scheme handlers
72
+ patchInfo(appPath)
73
+
74
+ # Copy project assets into the bundle (exclude build artifacts, vcs, tests)
75
+ assetsPath = os.path.join(appPath, "Contents/Resources/Assets")
76
+ if os.path.isdir(assetsPath):
77
+ shutil.rmtree(assetsPath)
78
+ shutil.copytree(ROOT, assetsPath, ignore=_ignore_bundle)
urlwall/cli.py ADDED
@@ -0,0 +1,28 @@
1
+ """
2
+ URL-Wall CLI wrapper.
3
+
4
+ This script is placed in the .app bundle and called by the AppleScript handler.
5
+ It ensures the urlwall package is importable from the bundle context.
6
+
7
+ When called with no arguments, reads the URL from stdin (piped by AppleScript).
8
+ When called with arguments, passes them through to __main__.py.
9
+ """
10
+
11
+ import os
12
+ import sys
13
+
14
+ # The .app bundle copies Assets/ at the same level as urlwall/
15
+ # So we need to add the Assets parent to sys.path
16
+ bundle_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
17
+ if bundle_root not in sys.path:
18
+ sys.path.insert(0, bundle_root)
19
+
20
+ from urlwall.__main__ import main
21
+
22
+ if __name__ == "__main__":
23
+ # If called with no args, read URL from stdin (piped by AppleScript)
24
+ if len(sys.argv) == 1 and not sys.stdin.isatty():
25
+ url = sys.stdin.read().strip()
26
+ if url:
27
+ sys.argv = ["urlwall", url]
28
+ main()
urlwall/config.py ADDED
@@ -0,0 +1,152 @@
1
+ """
2
+ URL-Wall configuration management.
3
+ """
4
+
5
+ import os
6
+ import plistlib
7
+ import socket
8
+ import subprocess
9
+ import sys
10
+
11
+ import urlwall
12
+
13
+ ME = "urlwall"
14
+ CONFIG_VERSION = "1.0"
15
+ CFG_ROOT = os.path.expanduser(f"~/.{ME}")
16
+ CONFIG_FN = os.path.join(CFG_ROOT, "setup.plist")
17
+ _config = None
18
+
19
+
20
+ class Config:
21
+ """Manages the URL-Wall configuration stored as a plist."""
22
+
23
+ def __init__(self, fn):
24
+ self.fn = fn
25
+ self.read()
26
+
27
+ # -- read/write setup --
28
+
29
+ def read(self):
30
+ if not os.path.isfile(self.fn):
31
+ self.setupDefault()
32
+ try:
33
+ with open(self.fn, "rb") as f:
34
+ self.plist = plistlib.load(f)
35
+ except (OSError, plistlib.InvalidFileException) as e:
36
+ print(f"Config read error: {e}", file=sys.stderr)
37
+ self.setupDefault()
38
+
39
+ def setupDefault(self):
40
+ self.plist = {
41
+ "configVersion": CONFIG_VERSION,
42
+ "defaultBrowser": "Safari.app",
43
+ "hostsAllowed": ["google.com"],
44
+ "hostWithSubdomainsAllowed": [],
45
+ }
46
+ self.write()
47
+
48
+ def write(self):
49
+ self.plist["hostsAllowed"].sort()
50
+ dn = os.path.dirname(self.fn)
51
+ if not os.path.isdir(dn):
52
+ os.makedirs(dn, exist_ok=True)
53
+ try:
54
+ with open(self.fn, "wb") as f:
55
+ plistlib.dump(self.plist, f)
56
+ except OSError as e:
57
+ print(f"Config write error: {e}", file=sys.stderr)
58
+
59
+ # -- browser --
60
+
61
+ def getBrowser(self):
62
+ return self.plist["defaultBrowser"]
63
+
64
+ def setBrowser(self, br):
65
+ curBrowser = self.getBrowser()
66
+ if br != curBrowser:
67
+ self.plist["defaultBrowser"] = br
68
+ self.write()
69
+ return br
70
+
71
+ def setAsDefaultBrowser(self):
72
+ """Set URL-Wall as macOS default browser for http/https/ftp."""
73
+ bundle_id = "com.github.svalgaard.url-wall"
74
+ schemes = ("http", "https", "ftp")
75
+ entries = " ".join(
76
+ f'{{CFBundleIdentifier="{bundle_id}",LSHandlerRoleViewer="{s}",LSHandlerContentClassName=""}}'
77
+ for s in schemes
78
+ )
79
+ cmd = f"defaults write com.apple.LaunchServices LSHandlers -array-add {entries}"
80
+ try:
81
+ subprocess.run(["bash", "-c", cmd], check=True)
82
+ print("Run `killall Finder` to apply the change.")
83
+ return True
84
+ except subprocess.CalledProcessError as e:
85
+ print(f"Error setting default browser: {e}", file=sys.stderr)
86
+ return False
87
+
88
+ # -- allowed hosts --
89
+
90
+ def addAllowedHost(self, host):
91
+ host = urlwall.getCanonicalHost(host)
92
+ if not host:
93
+ raise ValueError("Invalid host")
94
+ if host not in self.plist["hostsAllowed"]:
95
+ self.plist["hostsAllowed"].append(host)
96
+ self.write()
97
+ return host
98
+
99
+ def addAllowedHostSubdomains(self, host):
100
+ host = urlwall.getCanonicalHost(host)
101
+ if not host:
102
+ raise ValueError("Invalid host")
103
+ if host not in self.plist["hostWithSubdomainsAllowed"]:
104
+ self.plist["hostWithSubdomainsAllowed"].append(host)
105
+ self.write()
106
+ return host
107
+
108
+ def removeAllowedHost(self, host):
109
+ host = urlwall.getCanonicalHost(host)
110
+ if not host:
111
+ raise ValueError("Invalid host")
112
+ if host in self.plist["hostsAllowed"]:
113
+ self.plist["hostsAllowed"].remove(host)
114
+ self.write()
115
+ return host
116
+
117
+ def removeAllowedHostSubdomains(self, host):
118
+ host = urlwall.getCanonicalHost(host)
119
+ if not host:
120
+ raise ValueError("Invalid host")
121
+ if host in self.plist["hostWithSubdomainsAllowed"]:
122
+ self.plist["hostWithSubdomainsAllowed"].remove(host)
123
+ self.write()
124
+ return host
125
+
126
+ # -- allow check --
127
+
128
+ def isAllowed(self, url):
129
+ host = urlwall.getCanonicalHost(url)
130
+ if host in self.plist["hostsAllowed"]:
131
+ return True
132
+ if host in self.plist["hostWithSubdomainsAllowed"]:
133
+ return True
134
+ for p in self.plist["hostWithSubdomainsAllowed"]:
135
+ if host.endswith("." + p):
136
+ return True
137
+ return False
138
+
139
+ # -- log file (hostname-specific) --
140
+
141
+ @property
142
+ def logFile(self):
143
+ hostname = socket.gethostname().split(".")[0]
144
+ return os.path.join(CFG_ROOT, f"url-wall-{hostname}.log")
145
+
146
+
147
+ def getConfig(configFilename=CONFIG_FN):
148
+ """Return the singleton Config instance."""
149
+ global _config
150
+ if _config is None:
151
+ _config = Config(configFilename)
152
+ return _config
urlwall/html/bg.jpg ADDED
Binary file
@@ -0,0 +1,211 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="utf-8">
6
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
7
+ <meta name="viewport" content="width=device-width, initial-scale=1">
8
+
9
+ <title>Warning page</title>
10
+ <style>
11
+ * {
12
+ box-sizing: border-box;
13
+ }
14
+
15
+ body {
16
+ padding: 0;
17
+ margin: 0;
18
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
19
+ font-weight: 700;
20
+ }
21
+
22
+ #notfound {
23
+ position: relative;
24
+ height: 100vh;
25
+ }
26
+
27
+ #notfound .notfound-bg {
28
+ position: absolute;
29
+ width: 100%;
30
+ height: 100%;
31
+ background-image: url('{{backgroundimage}}');
32
+ background-size: cover;
33
+ }
34
+
35
+ #notfound .notfound-bg:after {
36
+ content: '';
37
+ position: absolute;
38
+ width: 100%;
39
+ height: 100%;
40
+ background-color: rgba(255, 0, 0, 0.3);
41
+ }
42
+
43
+ #notfound .notfound {
44
+ position: absolute;
45
+ left: 50%;
46
+ top: 50%;
47
+ transform: translate(-50%, -50%);
48
+ }
49
+
50
+ .notfound {
51
+ max-width: 700px;
52
+ width: 100%;
53
+ line-height: 1.4;
54
+ text-align: center;
55
+ }
56
+
57
+ .notfound .notfound-404 {
58
+ position: relative;
59
+ height: 120px;
60
+ }
61
+
62
+ .notfound .notfound-404 h1 {
63
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
64
+ position: absolute;
65
+ left: 50%;
66
+ top: 50%;
67
+ transform: translate(-50%, -50%);
68
+ font-size: 80px;
69
+ font-weight: 900;
70
+ margin: 0px;
71
+ color: #fff;
72
+ text-transform: uppercase;
73
+ letter-spacing: 4px;
74
+ }
75
+
76
+ .notfound h2 {
77
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
78
+ font-size: 20px;
79
+ font-weight: 700;
80
+ text-transform: uppercase;
81
+ color: #fff;
82
+ margin-top: 20px;
83
+ margin-bottom: 15px;
84
+ }
85
+
86
+ .notfound p {
87
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
88
+ font-size: 16px;
89
+ font-weight: 400;
90
+ color: #fff;
91
+ margin-top: 20px;
92
+ margin-bottom: 15px;
93
+ }
94
+
95
+ .notfound .home-btn,
96
+ .notfound .contact-btn {
97
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
98
+ display: inline-block;
99
+ font-weight: 700;
100
+ text-decoration: none;
101
+ background-color: transparent;
102
+ border: 2px solid transparent;
103
+ text-transform: uppercase;
104
+ padding: 10px 20px;
105
+ font-size: 16px;
106
+ border-radius: 40px;
107
+ margin: 5px;
108
+ transition: 0.2s all;
109
+ }
110
+
111
+ .notfound .home-btn:hover,
112
+ .notfound .contact-btn:hover {
113
+ opacity: 0.9;
114
+ }
115
+
116
+ .notfound .home-btn {
117
+ color: rgba(255, 0, 36, 0.7);
118
+ background: #fff;
119
+ }
120
+
121
+ .notfound .contact-btn {
122
+ border: 2px solid rgba(255, 255, 255, 0.9);
123
+ color: rgba(255, 255, 255, 0.9);
124
+ }
125
+
126
+ span.url {
127
+ overflow: hidden;
128
+ white-space: nowrap;
129
+ text-overflow: ellipsis;
130
+ display: block;
131
+ max-width: 600px;
132
+ margin: 0 auto;
133
+ }
134
+
135
+ @media only screen and (max-width: 767px) {
136
+ .notfound .notfound-404 h1 {
137
+ font-size: 60px;
138
+ }
139
+ }
140
+
141
+ @media only screen and (max-width: 480px) {
142
+ .notfound .notfound-404 {
143
+ height: 100px;
144
+ }
145
+
146
+ .notfound .notfound-404 h1 {
147
+ font-size: 40px;
148
+ }
149
+
150
+ .notfound h2 {
151
+ font-size: 16px;
152
+ }
153
+
154
+ .notfound .home-btn,
155
+ .notfound .contact-btn {
156
+ font-size: 14px;
157
+ }
158
+ }
159
+ </style>
160
+ </head>
161
+
162
+ <body>
163
+ <div id="notfound">
164
+ <div class="notfound-bg"></div>
165
+ <div class="notfound">
166
+ <div class="notfound-404">
167
+ <h1>⚠️&nbsp;Warning</h1>
168
+ </div>
169
+ <h2>Unknown domain in URL</h2>
170
+ <div id='content'>
171
+ <p>This page requires JavaScript to display the redirect chain.</p>
172
+ </div>
173
+ </div>
174
+ </div>
175
+ </body>
176
+ <script>
177
+ var div = document.getElementById('content');
178
+ div.innerHTML = '';
179
+ var url = '{{URL}}';
180
+ var host = '';
181
+ while (url) {
182
+ var urlo = new URL(url);
183
+ var host = urlo.hostname;
184
+ var urls = url.replace(host, '<b>' + host + '</b>');
185
+
186
+ next = urlo.searchParams.get('url') || urlo.searchParams.get('target') || urlo.searchParams.get('originalUrl');
187
+
188
+ if (!next && host === 'secure-web.cisco.com') {
189
+ next = decodeURIComponent(url.replace(/.*\//, ''));
190
+ }
191
+ if (!next && host.endsWith('.mcas.ms')) {
192
+ u = url;
193
+ u = u.replace(/\.mcas\.ms\//, '/');
194
+ u = u.replace(/\?McasTsid=[0-9]*/, '');
195
+ next = decodeURIComponent(u);
196
+ }
197
+ if (host.endsWith('.safelinks.protection.outlook.com')) {
198
+ host = 'safelinks.outlook.com';
199
+ }
200
+ if (host.startsWith('www.')) {
201
+ host = host.substring(4);
202
+ }
203
+
204
+ cls = next ? 'contact-btn' : 'home-btn';
205
+ div.innerHTML += '<p>Requested URL<br><span class="url">' + urls + '</span></p>';
206
+ div.innerHTML += '<a href="' + url + '" class="' + cls + '">Go to ' + host + '</a>';
207
+
208
+ url = next;
209
+ }
210
+ document.title = 'Warning: ' + host;
211
+ </script>
urlwall/py.typed ADDED
File without changes
@@ -0,0 +1,90 @@
1
+ Metadata-Version: 2.4
2
+ Name: urlwall
3
+ Version: 0.1.2
4
+ Summary: Interceptive browser for macOS — confirms unknown domains before opening
5
+ Author-email: Jens Svalgaard <github@svalgaard.net>
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/svalgaard/urlwall
8
+ Project-URL: Repository, https://github.com/svalgaard/urlwall
9
+ Project-URL: Issues, https://github.com/svalgaard/urlwall/issues
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Environment :: MacOS X
13
+ Classifier: Intended Audience :: End Users/Desktop
14
+ Classifier: License :: OSI Approved :: Apache Software License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Security
22
+ Requires-Python: >=3.9
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Provides-Extra: test
26
+ Requires-Dist: pytest; extra == "test"
27
+ Requires-Dist: pytest-cov; extra == "test"
28
+ Provides-Extra: lint
29
+ Requires-Dist: ruff; extra == "lint"
30
+ Dynamic: license-file
31
+
32
+ # URL-Wall — Interceptive Browser for macOS
33
+
34
+ Add a confirmation step when you click an http, https, or ftp
35
+ URL *outside your browser* to ensure you reach the domain you expect.
36
+
37
+ Known domains go directly. Unknown domains show a warning page where you
38
+ can acknowledge and proceed.
39
+
40
+ ## Installation
41
+
42
+ ```bash
43
+ # Build the .app bundle
44
+ python -m urlwall --install ~/Applications/URL-Wall.app
45
+
46
+ # Set as macOS default browser
47
+ python -m urlwall --use-as-default
48
+ # Then run: killall Finder
49
+ ```
50
+
51
+ ## CLI Usage
52
+
53
+ ```bash
54
+ # Open a URL through the gate
55
+ python -m urlwall https://example.com
56
+
57
+ # Add a domain to the allow list
58
+ python -m urlwall --allow-host-add example.com
59
+
60
+ # Add with subdomain support
61
+ python -m urlwall --allow-host-add example.com --also-subdomains
62
+
63
+ # Remove from allow list
64
+ python -m urlwall --allow-host-remove example.com
65
+
66
+ # Set the browser used after URL-Wall
67
+ python -m urlwall --set-browser "Google Chrome.app"
68
+
69
+ # Set URL-Wall as macOS default browser
70
+ python -m urlwall --use-as-default
71
+
72
+ # View current config
73
+ python -m urlwall
74
+ ```
75
+
76
+ ## How It Works
77
+
78
+ 1. The `install` command compiles an AppleScript handler into a `.app` bundle
79
+ 2. The `.app` is patched with URL scheme handlers (http, https, ftp)
80
+ 3. When macOS routes a URL to URL-Wall, the AppleScript calls `python -m urlwall <url>`
81
+ 4. The Python handler checks if the domain is allowed — if so, opens it directly
82
+ 5. If not, shows a warning page with the full redirect chain and a "Proceed" button
83
+
84
+ ## Configuration
85
+
86
+ Config is stored in `~/.urlwall/setup.plist`:
87
+
88
+ - `defaultBrowser` — app name (e.g., `Safari.app`)
89
+ - `hostsAllowed` — domains that open directly
90
+ - `hostWithSubdomainsAllowed` — domains + all subdomains
@@ -0,0 +1,16 @@
1
+ urlwall/__init__.py,sha256=K9NLvOeZhLkCcel-qg46ka29GBq-lM44ov3e9crbfp4,5066
2
+ urlwall/__main__.py,sha256=RVudeX1pMNnjHPwfyKG2Vz_KIvZD_Emj34cN8mqEZco,3739
3
+ urlwall/app.py,sha256=gfuU0LtYXshUQ4TQW-79CZHP9o1nO2T96M9x7cP-8sU,2228
4
+ urlwall/cli.py,sha256=e8SnnI91-7WQYb0e7Mw2_IJdY9ZNYupUnmCH9bb3rvg,900
5
+ urlwall/config.py,sha256=FVfcAqjgJjmhhMILMqUaXAOSB3-gjPFOGb5RRo4GUW0,4596
6
+ urlwall/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ urlwall/app/Info-patch.plist,sha256=GSFWWk1txxeKkB9T_1twR_E4dHTyfpnuNk6AWrBU0yE,730
8
+ urlwall/app/url-wall.scpt,sha256=y78SV6OIVDfHwgf5dnTkrx7bRowdiu2Cdw5lAF_tx4E,530
9
+ urlwall/html/bg.jpg,sha256=-9unC5KTAy_jd4KmFs8VVjYXNSb-S_ZjA0bZLg3NBQI,344663
10
+ urlwall/html/template.html,sha256=wDujCbeX3vBUGir9fbFiL1dopDV6ft-a_43xwrZt3w0,5067
11
+ urlwall-0.1.2.dist-info/licenses/LICENSE,sha256=rXNTfYv5HGthwWj0ydLs-ZAKc2SjPeubmLvPgNosGG4,11373
12
+ urlwall-0.1.2.dist-info/METADATA,sha256=-Qc6pX_nxnfDQnhEnTMemYhNE2jtZabOpaSg-4d3e30,2910
13
+ urlwall-0.1.2.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
14
+ urlwall-0.1.2.dist-info/entry_points.txt,sha256=S7JXhmWpm_QklIl1zxSxkcPdUSwmh-Fi07t32HxjU-A,51
15
+ urlwall-0.1.2.dist-info/top_level.txt,sha256=SKqDz7xbtrZ32DBs3WpUh7JMoqkfgfEaY3CpwuP2C_k,8
16
+ urlwall-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ url-wall = urlwall.__main__:main
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright 2026 Jens Svalgaard Kohrt <github@svalgaard.net>
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
@@ -0,0 +1 @@
1
+ urlwall