Permissions-Policy Builder — Swift source
Build a Permissions-Policy header interactively. Control which browser features (camera, microphone, geolocation, etc.) your site can use.
This is the Swift implementation — the same logic the interactive tool runs, in a shareable, citable form.
// permissions-policy — Permissions-Policy header builder / parser.
//
// Language: Swift 5.9+ (Foundation only)
// Ported from src/lib/permissions-policy.ts
// display source — part of CosmoDev's polyglot tool pages
//
// The Permissions-Policy header is a comma-separated list of directives:
// Permissions-Policy: geolocation=(self), camera=(), microphone=*, usb=(https://a.example)
// Each directive maps a browser feature to an allowlist. An empty allowlist
// `()` disables the feature outright; `*` allows it everywhere; `self` limits
// it to the page's own origin; anything else is a space-separated origin list.
//
// A policy is modeled here as a map of feature -> allowlist array:
// ["geolocation": ["self"], "camera": [], "usb": ["https://a.example"]]
// - [] => camera=() (disabled)
// - ["*"] => microphone=* (every origin)
// - ["self"] => geolocation=(self)
// - [origins...] => usb=(https://a.example https://b.example)
// Features absent from the map are absent from the header (browser default).
import Foundation
// MARK: - Types
enum PrivacyImpact: String {
case high, medium, low
}
struct FeatureInfo {
/// The directive token used in the header, e.g. `geolocation`.
let name: String
let description: String
let privacyImpact: PrivacyImpact
/// What browsers do when the feature is absent from the policy.
let defaultBrowserBehavior: String
}
/// feature name -> allowlist tokens. `[]` = disabled, `["*"]` = all origins.
typealias FeatureMap = [String: [String]]
// MARK: - Feature catalog
let FEATURES: [FeatureInfo] = [
// --- high privacy impact ---
FeatureInfo(name: "camera", description: "Access the device camera for photos / video calls.", privacyImpact: .high, defaultBrowserBehavior: "Same-origin only; prompts the user."),
FeatureInfo(name: "microphone", description: "Capture audio from the device microphone.", privacyImpact: .high, defaultBrowserBehavior: "Same-origin only; prompts the user."),
FeatureInfo(name: "geolocation", description: "Read the precise GPS location of the visitor.", privacyImpact: .high, defaultBrowserBehavior: "Same-origin only; prompts the user."),
FeatureInfo(name: "display-capture", description: "Screen / window sharing via getDisplayMedia.", privacyImpact: .high, defaultBrowserBehavior: "Same-origin only; prompts the user."),
FeatureInfo(name: "idle-detection", description: "Detects when the user is away from the device — reveals usage patterns.", privacyImpact: .high, defaultBrowserBehavior: "Disabled; prompts the user."),
FeatureInfo(name: "serial", description: "Talk to serial devices (Arduinos, POS terminals) over a physical port.", privacyImpact: .high, defaultBrowserBehavior: "Disabled; prompts the user."),
FeatureInfo(name: "usb", description: "WebUSB — direct access to connected USB devices.", privacyImpact: .high, defaultBrowserBehavior: "Disabled; prompts the user."),
FeatureInfo(name: "hid", description: "Human Interface Devices — raw access to unusual keyboards, gamepads, sensors.", privacyImpact: .high, defaultBrowserBehavior: "Disabled; prompts the user."),
FeatureInfo(name: "xr-spatial-tracking", description: "Tracks head / hand position in WebXR sessions.", privacyImpact: .high, defaultBrowserBehavior: "Same-origin only; prompts the user."),
// --- medium privacy impact ---
FeatureInfo(name: "accelerometer", description: "Device motion sensor — can fingerprint and infer behaviour.", privacyImpact: .medium, defaultBrowserBehavior: "Same-origin only."),
FeatureInfo(name: "ambient-light-sensor", description: "Reads ambient light level around the device.", privacyImpact: .medium, defaultBrowserBehavior: "Same-origin only."),
FeatureInfo(name: "battery", description: "Battery Status API — a classic fingerprinting vector.", privacyImpact: .medium, defaultBrowserBehavior: "Same-origin only."),
FeatureInfo(name: "gyroscope", description: "Device orientation sensor — fingerprinting and behaviour inference.", privacyImpact: .medium, defaultBrowserBehavior: "Same-origin only."),
FeatureInfo(name: "magnetometer", description: "Compass readings — can leak details of the user's surroundings.", privacyImpact: .medium, defaultBrowserBehavior: "Same-origin only."),
FeatureInfo(name: "keyboard-map", description: "Reads the physical keyboard layout — a small but real fingerprint.", privacyImpact: .medium, defaultBrowserBehavior: "Same-origin only."),
FeatureInfo(name: "gamepad", description: "Enumerates connected controllers and their button state.", privacyImpact: .medium, defaultBrowserBehavior: "Same-origin only."),
FeatureInfo(name: "midi", description: "Web MIDI — access to attached music hardware.", privacyImpact: .medium, defaultBrowserBehavior: "Same-origin only; prompts the user."),
FeatureInfo(name: "payment", description: "Payment Request API — can expose stored payment handles.", privacyImpact: .medium, defaultBrowserBehavior: "Same-origin only."),
FeatureInfo(name: "publickey-credentials-get", description: "WebAuthn credential requests.", privacyImpact: .medium, defaultBrowserBehavior: "Same-origin only."),
FeatureInfo(name: "screen-wake-lock", description: "Keeps the screen awake — drains battery and signals intent.", privacyImpact: .medium, defaultBrowserBehavior: "Same-origin only."),
FeatureInfo(name: "speaker-selection", description: "Enumerates and switches audio output devices.", privacyImpact: .medium, defaultBrowserBehavior: "Same-origin only."),
FeatureInfo(name: "web-share", description: "Invokes the OS share sheet with chosen content.", privacyImpact: .medium, defaultBrowserBehavior: "Same-origin only."),
FeatureInfo(name: "encrypted-media", description: "DRM (EME) — playback identity can be correlated.", privacyImpact: .medium, defaultBrowserBehavior: "Same-origin only."),
FeatureInfo(name: "document-domain", description: "Let frames relax the same-origin policy via document.domain.", privacyImpact: .medium, defaultBrowserBehavior: "Allowed in same-origin pages; deprecated."),
// --- low privacy impact ---
FeatureInfo(name: "autoplay", description: "Autoplay media with/without sound — not a data leak, an annoyance knob.", privacyImpact: .low, defaultBrowserBehavior: "Muted autoplay allowed; audible blocked."),
FeatureInfo(name: "cross-origin-isolated", description: "COOP/COEP isolation for SharedArrayBuffer — hardens the page.", privacyImpact: .low, defaultBrowserBehavior: "Not isolated."),
FeatureInfo(name: "fullscreen", description: "Element.requestFullscreen().", privacyImpact: .low, defaultBrowserBehavior: "Same-origin only."),
FeatureInfo(name: "navigation-override", description: "Lets a frame intercept its own top-level navigations.", privacyImpact: .low, defaultBrowserBehavior: "Disabled."),
FeatureInfo(name: "picture-in-picture", description: "Floating always-on-top video window.", privacyImpact: .low, defaultBrowserBehavior: "Same-origin only."),
]
// MARK: - Pattern helpers
/// `^[a-z][a-z0-9-]*$` — a valid feature directive token.
func isValidFeatureName(_ name: String) -> Bool {
guard let first = name.first else { return false }
guard first.isASCII, first.isLowercase, first.isLetter else { return false }
return name.dropFirst().allSatisfy { c in
(c.isASCII && c.isLowercase && c.isLetter) || (c.isNumber && c.isASCII) || c == "-"
}
}
/// `^(https?:\/\/|https?:)[\w.-]+(:\d+)?$` (case-insensitive) — an origin token.
func isOriginToken(_ token: String) -> Bool {
var s = Substring(token)
let lowered = token.lowercased()
if lowered.hasPrefix("https://") { s = s.dropFirst(8) }
else if lowered.hasPrefix("http://") { s = s.dropFirst(7) }
else if lowered.hasPrefix("https:") { s = s.dropFirst(6) }
else if lowered.hasPrefix("http:") { s = s.dropFirst(5) }
else { return false }
// Optional :port
if let colon = s.lastIndex(of: ":") {
let port = s[s.index(after: colon)...]
guard !port.isEmpty, port.allSatisfy({ $0.isNumber && $0.isASCII }) else { return false }
s = s[..<colon]
}
guard !s.isEmpty else { return false }
return s.allSatisfy { c in
c == "." || c == "-" || c == "_" || (c.isASCII && (c.isLetter || c.isNumber))
}
}
// MARK: - Build / parse
func formatAllowlist(_ tokens: [String]) -> String {
if tokens.count == 1 && tokens[0] == "*" { return "*" }
return "(\(tokens.joined(separator: " ")))"
}
/// Assemble a Permissions-Policy header value from a feature map.
func buildPermissionsPolicy(_ features: FeatureMap) -> String {
features.keys
.sorted()
.map { name in "\(name)=\(formatAllowlist(features[name] ?? []))" }
.joined(separator: ", ")
}
/// Strip an optional `Permissions-Policy:` label from a pasted header line.
func stripHeaderLabel(_ header: String) -> String {
var text = header.trimmingCharacters(in: .whitespacesAndNewlines)
let lowered = text.lowercased()
if lowered.hasPrefix("permissions-policy") {
let after = text.dropFirst("permissions-policy".count)
if after.first == ":" {
// also drop any spaces between the colon and the value
text = String(after.drop(while: { $0 == ":" || $0 == " " || $0 == "\t" }))
}
}
return text
}
/**
Parse a Permissions-Policy header value back into a feature map.
Accepts an optional `Permissions-Policy:` prefix. Returns nil when the
syntax is malformed (bad directive, missing allowlist, bad token).
*/
func parsePermissionsPolicy(_ header: String) -> FeatureMap? {
let cleaned = stripHeaderLabel(header)
if cleaned.isEmpty { return nil }
var map: FeatureMap = [:]
for rawDirective in cleaned.split(separator: ",") {
let directive = rawDirective.trimmingCharacters(in: .whitespacesAndNewlines)
if directive.isEmpty { return nil }
guard let eq = directive.firstIndex(of: "="), eq != directive.startIndex else { return nil }
let name = String(directive[..<eq]).trimmingCharacters(in: .whitespaces)
let value = String(directive[directive.index(after: eq)...]).trimmingCharacters(in: .whitespaces)
if !isValidFeatureName(name) { return nil }
if value == "*" || value == "self" {
map[name] = [value]
continue
}
guard value.hasPrefix("("), value.hasSuffix(")") else { return nil }
let inner = String(value.dropFirst().dropLast()).trimmingCharacters(in: .whitespaces)
if inner.isEmpty {
map[name] = []
continue
}
let tokens = inner.split(whereSeparator: { $0 == " " || $0 == "\t" })
.map { token -> String in
let t = String(token)
return unquote(t)
}
for token in tokens {
if token != "self" && token != "*" && !isOriginToken(token) { return nil }
}
map[name] = tokens
}
return map
}
/// Strip one pair of surrounding double quotes, as browsers accept for tokens.
private func unquote(_ token: String) -> String {
guard token.count >= 2, token.hasPrefix("\""), token.hasSuffix("\"") else { return token }
return String(token.dropFirst().dropLast())
}
// MARK: - Validation
/// Syntax-check a header the same way parsePermissionsPolicy does, with messages.
func validatePermissionsPolicy(_ header: String) -> (valid: Bool, errors: [String]) {
var errors: [String] = []
let cleaned = stripHeaderLabel(header)
if cleaned.isEmpty { return (false, ["Header is empty."]) }
for (i, rawDirective) in cleaned.split(separator: ",", omittingEmptySubsequences: false).enumerated() {
let directive = rawDirective.trimmingCharacters(in: .whitespacesAndNewlines)
let where_ = "Directive \(i + 1)"
if directive.isEmpty {
errors.append("\(where_): empty (stray comma?).")
continue
}
guard let eq = directive.firstIndex(of: "="), eq != directive.startIndex else {
errors.append("\(where_): expected `feature=allowlist`, got `\(directive)`.")
continue
}
let name = String(directive[..<eq]).trimmingCharacters(in: .whitespaces)
let value = String(directive[directive.index(after: eq)...]).trimmingCharacters(in: .whitespaces)
if !isValidFeatureName(name) {
errors.append("\(where_): `\(name)` is not a valid feature name.")
continue
}
if value == "*" || value == "self" { continue }
guard value.hasPrefix("("), value.hasSuffix(")") else {
errors.append("\(where_): `\(name)` allowlist must be `*`, `self`, or `(...)` — got `\(value)`.")
continue
}
let inner = String(value.dropFirst().dropLast()).trimmingCharacters(in: .whitespaces)
if inner.isEmpty { continue } // () = disabled, valid
for raw in inner.split(whereSeparator: { $0 == " " || $0 == "\t" }) {
let token = unquote(String(raw))
if token != "self" && token != "*" && !isOriginToken(token) {
errors.append("\(where_): `\(name)` has an invalid allowlist token `\(raw)`.")
}
}
}
return (errors.isEmpty, errors)
}
// MARK: - Scoring
/**
Privacy score 0-100 for a policy: how much it locks down the catalog.
Each feature is weighted by privacy impact (high 3, medium 2, low 1).
Disabled `()` earns full credit, `self` or an origin list half, `*` or
"absent from the policy" none (the browser default stays in force).
*/
func privacyScore(_ features: FeatureMap) -> Int {
let weight: [PrivacyImpact: Double] = [.high: 3, .medium: 2, .low: 1]
var earned = 0.0
var possible = 0.0
for feature in FEATURES {
let w = weight[feature.privacyImpact] ?? 0
possible += w
if let allowlist = features[feature.name] {
if allowlist.isEmpty {
earned += w // () disabled
} else if !(allowlist.count == 1 && allowlist[0] == "*") {
earned += w / 2 // self / origins
}
}
}
return Int((earned / possible * 100).rounded())
}
Also available in 8 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →