diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 37c7ac297..ad0489f20 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -81,6 +81,9 @@ jobs:
- name: Run Linter
run: yarn lint
+ - name: Test Safari compatibility
+ run: yarn build:safari && yarn test:safari
+
- name: Build App
env:
BRANCH: ${{ (inputs.branch != 'stable' && 'nightly') || '' }}
diff --git a/.gitignore b/.gitignore
index 173641977..47795b68a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,11 +11,15 @@ node_modules
dist
dist-ssr
dist-hosted
+safari-build
+test-results
+safari-project/**/7TV for Safari Extension/Resources/
*.local
# Editor directories and files
.idea
.DS_Store
+**/xcuserdata/
*.suo
*.ntvs*
*.njsproj
@@ -28,4 +32,3 @@ msg_types.json
crx/*
script/*.json
-
diff --git a/CHANGELOG-nightly.md b/CHANGELOG-nightly.md
index 72ddf7500..326dbd62e 100644
--- a/CHANGELOG-nightly.md
+++ b/CHANGELOG-nightly.md
@@ -1,3 +1,7 @@
+## Next
+
+- Added a native Safari Web Extension build for Twitch, Kick and YouTube
+
## 3.1.25.1000
- Fixed Kick Emote Menu position
diff --git a/README.md b/README.md
index 363c47b05..23d36b9a7 100644
--- a/README.md
+++ b/README.md
@@ -48,6 +48,22 @@
## Development
+### Safari
+
+The native Safari Web Extension wrapper uses the same Twitch, Kick and YouTube
+implementations as the other browser builds. On macOS with Xcode installed:
+
+```sh
+yarn build:safari
+yarn test:safari
+./script/build-safari-local.sh
+```
+
+See [SAFARI.md](SAFARI.md) for signing and installation instructions,
+[SAFARI-TESTING.md](SAFARI-TESTING.md) for the validation matrix, and
+[SECURITY-SAFARI.md](SECURITY-SAFARI.md) for the security model. A locally
+signed development build is not a notarized public binary.
+
### Building
- make deps
diff --git a/SAFARI-COMPATIBILITY.md b/SAFARI-COMPATIBILITY.md
new file mode 100644
index 000000000..191fdd620
--- /dev/null
+++ b/SAFARI-COMPATIBILITY.md
@@ -0,0 +1,71 @@
+# Safari compatibility inventory
+
+The Safari package now injects the unmodified upstream site implementation on
+all three officially supported origins using static manifest entries. This
+keeps the Safari delta small and makes upstream rebases reviewable.
+
+## Included upstream surfaces
+
+The entries below describe the upstream modules packaged by the Safari build;
+they are not all separate claims of completed live Safari validation.
+
+### Twitch
+
+- 7TV, FFZ and BTTV chat emotes
+- chat input and autocomplete
+- emote menu
+- settings
+- cosmetics, paints, badges and avatars
+- VOD chat
+- mod logs and moderation UI
+- custom commands
+- player controls and stream statistics
+- sidebar previews and hidden-element settings
+- automatic channel-point claims
+
+### Kick
+
+- 7TV chat emotes
+- chat input and autocomplete
+- emote menu, including native Kick sets
+- settings
+- cosmetics supported by upstream
+
+### YouTube
+
+- 7TV, FFZ and BTTV emotes in live chat
+- chat autocomplete
+
+## Intentional Safari differences
+
+- Extension-management compatibility scanning remains unavailable. Safari does
+ not expose Chromium's `management` permission, so 7TV cannot enumerate or
+ disable other extensions.
+- Platform access is static. Safari enables Twitch, Kick and YouTube in the
+ signed manifest instead of asking for optional hosts at runtime.
+- Extension self-update checks are advisory only. A local build is updated
+ by rebuilding and replacing the signed app.
+- Kick authentication is not counted as a Safari gap: its module is disabled in
+ the upstream source itself.
+- Commands that require third-party credentials, such as AudD `/song`, still
+ require their upstream configuration and are not enabled implicitly.
+
+## Validation status
+
+| Surface | Current evidence |
+| --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
+| Twitch core chat and 7TV emotes | Manually validated in Safari, including repeated page reloads |
+| Kick core chat and 7TV emotes | Manually validated in Safari during development |
+| YouTube live chat | Packaged and covered by manifest regression tests; live Safari validation is still pending |
+| Settings, autocomplete, emote menu, cosmetics and moderation features | Upstream modules are packaged; exhaustive Safari feature-by-feature validation is still pending |
+
+Automated tests cover the Safari manifest, registration behavior, message
+boundaries, worker loading, HTML sanitization, native entitlements and build
+artifacts. They do not replace live tests against each supported website.
+
+## Update rule
+
+For each upstream update, compare `origin/master`, resolve the small Safari
+build/permission delta, rebuild, run `verify:safari`, then run the live latency
+test once on Twitch and once on Kick. Do not copy features into a separate
+implementation when the upstream site module can be loaded unchanged.
diff --git a/SAFARI-TESTING.md b/SAFARI-TESTING.md
new file mode 100644
index 000000000..101c24eb0
--- /dev/null
+++ b/SAFARI-TESTING.md
@@ -0,0 +1,126 @@
+# Safari test protocol
+
+This protocol separates deterministic gates from tests that require a real
+Safari installation. Test artifacts are written under `test-results/` and are
+ignored by Git.
+
+## 1. Deterministic regression gate
+
+```sh
+npx --no-install yarn@1.22.22 verify:safari
+```
+
+This compiles the Safari variant, runs the source/package/native regression
+tests, validates the installed app when present, and audits dependencies.
+
+## 2. Real Safari smoke test
+
+First, check prerequisites without opening an automation session:
+
+```sh
+npx --no-install yarn@1.22.22 test:safari:live:check
+```
+
+Safari WebDriver is disabled by default. Enable it explicitly in Safari under
+Develop > Developer Settings > Allow remote automation. Then run:
+
+```sh
+npx --no-install yarn@1.22.22 test:safari:live -- \
+ --url https://www.twitch.tv/illojuan \
+ --reloads 2 \
+ --timeout 45
+```
+
+The test only permits HTTPS Twitch or Kick URLs. It waits for the extension marker,
+the single injected 7TV root, and the 7TV menu button. It opens the 7TV emote
+menu and requires at least one fully loaded image from a `*.7tv.app` host. It
+repeats those assertions after every reload and saves screenshots plus JSON.
+It also records navigation-to-injection, menu readiness, first real 7TV image,
+and p50/p95 timings for observed 7TV resources. It does not log in, send a chat
+message, or modify site data.
+
+The JSON also includes `pipelineMarks` for worker startup, channel lookup, the
+7TV/FFZ/BTTV set arrivals, and the moment all provider requests settle. This is
+enough to distinguish an API/set delay from an image-CDN delay before changing
+the cache or request ordering.
+
+Stress the reload lifecycle with:
+
+```sh
+npx --no-install yarn@1.22.22 test:safari:stress
+```
+
+## 3. A/B resource benchmark
+
+Use the same channel, video quality, window size, brightness, power source and
+duration for both runs. Close unrelated Safari windows. A 30-minute run is a
+useful first measurement; a two-hour run is better for memory growth.
+
+With 7TV disabled in Safari:
+
+```sh
+npx --no-install yarn@1.22.22 benchmark:safari -- sample \
+ --label disabled \
+ --duration 1800 \
+ --interval 5 \
+ --video-quality 1080p60 \
+ --brightness 50 \
+ --output test-results/safari-benchmark/disabled.json
+```
+
+Repeat with 7TV enabled and otherwise identical conditions:
+
+```sh
+npx --no-install yarn@1.22.22 benchmark:safari -- sample \
+ --label enabled \
+ --duration 1800 \
+ --interval 5 \
+ --video-quality 1080p60 \
+ --brightness 50 \
+ --output test-results/safari-benchmark/enabled.json
+```
+
+Compare the runs:
+
+```sh
+npx --no-install yarn@1.22.22 benchmark:safari -- compare \
+ --disabled test-results/safari-benchmark/disabled.json \
+ --enabled test-results/safari-benchmark/enabled.json
+```
+
+The comparison is informational until evidence-based limits are supplied. A
+gate can be introduced later with `--max-cpu-mean-delta`,
+`--max-rss-mean-delta`, and `--max-rss-slope-delta`. Do not invent thresholds
+before collecting a baseline on the target Mac.
+
+The sampler counts the Safari app and WebKit content/network processes that can
+be attributed to Safari's data container. Shared GPU services cannot be
+reliably assigned without privileged instrumentation, but their impact remains
+visible in the whole-machine battery result. This test does not attribute every
+sample to one JavaScript function. Use Instruments Time Profiler,
+Allocations/Leaks and Energy Log after a regression is detected.
+
+## 4. Clean install and release artifact
+
+Verify any built or downloaded app without installing it:
+
+```sh
+./script/verify-safari-release.sh "/absolute/path/7TV for Safari.app" development
+```
+
+For a public artifact, use `distribution`; that additionally requires
+Gatekeeper acceptance and a stapled notarization ticket. Run the distributed
+ZIP or DMG on a separate Mac or clean macOS user, not from Xcode's build
+directory.
+
+On a clean profile, record these scenarios:
+
+1. Fresh install and first Twitch load.
+2. Upgrade over a configured older build; settings must remain.
+3. Browser restart and Mac restart.
+4. Sleep/wake and network loss/recovery.
+5. Rollback to the prior compatible build.
+6. Uninstall; no active plug-in registration may remain.
+
+Use `tests/safari/compatibility-matrix.json` to record which Safari/macOS
+combinations passed. Public release requires every entry under `required`.
diff --git a/SAFARI.md b/SAFARI.md
new file mode 100644
index 000000000..dbd20beaa
--- /dev/null
+++ b/SAFARI.md
@@ -0,0 +1,50 @@
+# 7TV for Safari
+
+This project packages the official 7TV extension as a Safari Web Extension.
+The Safari build keeps the upstream site implementations and statically
+supports the same three sites:
+
+- site access: Twitch, Kick and YouTube
+- extension permission: local extension storage
+- no Chrome extension-management permission
+- no runtime registration; all three content-script matches are declared once
+- no remote worker override
+- no automatic download of build tools
+
+The wrapper app and extension use the App Sandbox and hardened runtime. The
+wrapper has no file-selection or outgoing-network entitlement. Extension pages
+use a restrictive content security policy.
+
+Build the locally signed macOS application with:
+
+```sh
+./script/build-safari-local.sh
+```
+
+The script checks the reviewed lockfile, audits runtime dependencies, compiles
+the Safari variant, signs it with an existing Apple Development identity,
+verifies the nested signature, and checks the final permissions. It does not
+install or replace the application automatically, and it does not register its
+temporary Xcode build with Launch Services.
+
+The result is a locally signed development build. A downloadable public release
+would additionally require a distribution certificate, notarization, an update
+design, and a separate release review. Local development signing does not
+require publishing through the Mac App Store.
+
+See `SECURITY-SAFARI.md` for the security model and remaining trust boundaries.
+See `SAFARI-TESTING.md` for the real-Safari, stress, release-package and A/B
+resource test protocol.
+
+Run the repeatable Safari regression gate with:
+
+```sh
+npx --no-install yarn@1.22.22 verify:safari
+```
+
+It verifies the compiled manifest, Safari compatibility regressions, worker and
+HTML boundaries, and native entitlements. When `SEVENTV_INSTALLED_APP` is set,
+it also verifies the installed signature, registration, and byte equality of
+critical resources. A release still needs one real-Safari smoke test:
+reload Twitch twice and verify that a known 7TV emote changes from text to an
+image after each reload.
diff --git a/SECURITY-SAFARI.md b/SECURITY-SAFARI.md
new file mode 100644
index 000000000..37b8fb72d
--- /dev/null
+++ b/SECURITY-SAFARI.md
@@ -0,0 +1,52 @@
+# Safari security notes
+
+## Scope
+
+This variant packages the official 7TV extension for Safari. It runs on Twitch,
+Kick and YouTube and retains 7TV's upstream emote and chat implementation for
+each site.
+
+## Enforced controls
+
+- The manifest grants only extension storage and static access to Twitch, Kick
+ and YouTube.
+- Safari does not dynamically register content scripts, preventing duplicate
+ registrations after service-worker restarts.
+- Messages reaching the extension background are accepted only from HTTPS
+ Twitch pages and validated before use.
+- Safari rejects all page-originated permission requests.
+- Closed-tab messaging errors are consumed and cannot break initialization.
+- The worker URL is always an extension URL; Twitch page storage cannot replace
+ it with an arbitrary URL.
+- Login popup messages require the exact 7TV origin and popup window.
+- Changelog HTML is sanitized before rendering.
+- Extension pages disallow remote scripts, objects, base rewriting, and framing.
+- The native wrapper does not log or echo extension messages.
+- Both native targets are sandboxed and use hardened runtime.
+- Dependency installation scripts are not run during the reviewed install, and
+ the runtime dependency audit has no known advisories.
+
+The upstream development toolchain is substantially older and its full audit
+contains known advisories in build and lint packages. Modernizing that toolchain
+is intentionally kept separate from the Safari compatibility change so it can
+be reviewed and tested independently. Do not expose the Vite development server
+to an untrusted network.
+
+## Remaining trust boundaries
+
+7TV must run page-world code inside Twitch to integrate with Twitch's chat UI.
+That means Twitch page code and 7TV page-world code share an execution origin.
+This is inherited from the upstream extension architecture, not a Safari-only
+permission. For passive emote viewing, no 7TV login token is required.
+
+A local app can be signed with an Apple Development certificate. Its nested
+signature is verifiable locally, but that is not equivalent to an App Store or
+notarized public distribution. Public binaries require a separate
+release-signing and notarization process.
+
+## Updating
+
+Treat every upstream update as new code. Review the upstream revision, retain
+the Safari permission assertions, install only from the lockfile, rerun the
+dependency audit, rebuild, and verify the final nested signature before
+replacing the installed app.
diff --git a/index.html b/index.html
index b7ccb124d..78591646f 100644
--- a/index.html
+++ b/index.html
@@ -4,25 +4,6 @@
-
-
-
-
-
-
-
diff --git a/manifest.config.ts b/manifest.config.ts
index 23a66043e..f14813148 100644
--- a/manifest.config.ts
+++ b/manifest.config.ts
@@ -10,12 +10,16 @@ interface ManifestOptions {
mv2?: boolean;
branch?: BranchName;
dev?: boolean;
+ safari?: boolean;
mozillaID?: string;
version: string;
}
export type BranchName = "nightly" | "dev";
+const DEFAULT_HOSTS = ["*://*.twitch.tv/*"];
+const SAFARI_HOSTS = ["*://*.twitch.tv/*", "*://*.kick.com/*", "*://*.youtube.com/*"];
+
export async function getManifest(opt: ManifestOptions): Promise {
const iconName = "".concat(opt.branch ? opt.branch + "-" : "", "icon-%s.png").replace(/\s+/g, "");
@@ -26,6 +30,7 @@ export async function getManifest(opt: ManifestOptions): Promise
+
+
+
+ com.apple.security.app-sandbox
+
+
+
diff --git a/safari-project/7TV for Safari/7TV for Safari Extension/Info.plist b/safari-project/7TV for Safari/7TV for Safari Extension/Info.plist
new file mode 100644
index 000000000..9ee504dc5
--- /dev/null
+++ b/safari-project/7TV for Safari/7TV for Safari Extension/Info.plist
@@ -0,0 +1,13 @@
+
+
+
+
+ NSExtension
+
+ NSExtensionPointIdentifier
+ com.apple.Safari.web-extension
+ NSExtensionPrincipalClass
+ $(PRODUCT_MODULE_NAME).SafariWebExtensionHandler
+
+
+
diff --git a/safari-project/7TV for Safari/7TV for Safari Extension/SafariWebExtensionHandler.swift b/safari-project/7TV for Safari/7TV for Safari Extension/SafariWebExtensionHandler.swift
new file mode 100644
index 000000000..edb3b1c15
--- /dev/null
+++ b/safari-project/7TV for Safari/7TV for Safari Extension/SafariWebExtensionHandler.swift
@@ -0,0 +1,22 @@
+//
+// SafariWebExtensionHandler.swift
+// 7TV for Safari Extension
+//
+import SafariServices
+
+class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling {
+
+ func beginRequest(with context: NSExtensionContext) {
+ // This Safari build does not expose native commands. Returning an empty
+ // response avoids logging or reflecting untrusted web-extension payloads.
+ let response = NSExtensionItem()
+ if #available(iOS 15.0, macOS 11.0, *) {
+ response.userInfo = [SFExtensionMessageKey: [:]]
+ } else {
+ response.userInfo = ["message": [:]]
+ }
+
+ context.completeRequest(returningItems: [ response ], completionHandler: nil)
+ }
+
+}
diff --git a/safari-project/7TV for Safari/7TV for Safari.xcodeproj/project.pbxproj b/safari-project/7TV for Safari/7TV for Safari.xcodeproj/project.pbxproj
new file mode 100644
index 000000000..65d6d9ca5
--- /dev/null
+++ b/safari-project/7TV for Safari/7TV for Safari.xcodeproj/project.pbxproj
@@ -0,0 +1,635 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 77;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 506BFEEB3069C36F0061DB8D /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 506BFEEA3069C36F0061DB8D /* AppDelegate.swift */; };
+ 506BFEEF3069C36F0061DB8D /* Main.html in Resources */ = {isa = PBXBuildFile; fileRef = 506BFEED3069C36F0061DB8D /* Main.html */; };
+ 506BFEF13069C36F0061DB8D /* Icon.png in Resources */ = {isa = PBXBuildFile; fileRef = 506BFEF03069C36F0061DB8D /* Icon.png */; };
+ 506BFEF33069C36F0061DB8D /* Style.css in Resources */ = {isa = PBXBuildFile; fileRef = 506BFEF23069C36F0061DB8D /* Style.css */; };
+ 506BFEF53069C36F0061DB8D /* Script.js in Resources */ = {isa = PBXBuildFile; fileRef = 506BFEF43069C36F0061DB8D /* Script.js */; };
+ 506BFEF73069C36F0061DB8D /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 506BFEF63069C36F0061DB8D /* ViewController.swift */; };
+ 506BFEFA3069C36F0061DB8D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 506BFEF83069C36F0061DB8D /* Main.storyboard */; };
+ 506BFEFC3069C3700061DB8D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 506BFEFB3069C3700061DB8D /* Assets.xcassets */; };
+ 506BFF033069C3700061DB8D /* 7TV for Safari Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 506BFF023069C3700061DB8D /* 7TV for Safari Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
+ 506BFF083069C3700061DB8D /* SafariWebExtensionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 506BFF073069C3700061DB8D /* SafariWebExtensionHandler.swift */; };
+ 506BFF1F3069C3740061DB8D /* index.html in Resources */ = {isa = PBXBuildFile; fileRef = 506BFF143069C3740061DB8D /* index.html */; };
+ 506BFF203069C3740061DB8D /* background.js in Resources */ = {isa = PBXBuildFile; fileRef = 506BFF153069C3740061DB8D /* background.js */; };
+ 506BFF213069C3740061DB8D /* worker.js in Resources */ = {isa = PBXBuildFile; fileRef = 506BFF163069C3740061DB8D /* worker.js */; };
+ 506BFF223069C3740061DB8D /* manifest.json in Resources */ = {isa = PBXBuildFile; fileRef = 506BFF173069C3740061DB8D /* manifest.json */; };
+ 506BFF233069C3740061DB8D /* site.js in Resources */ = {isa = PBXBuildFile; fileRef = 506BFF183069C3740061DB8D /* site.js */; };
+ 506BFF243069C3740061DB8D /* content.js in Resources */ = {isa = PBXBuildFile; fileRef = 506BFF193069C3740061DB8D /* content.js */; };
+ 506BFF253069C3740061DB8D /* icon in Resources */ = {isa = PBXBuildFile; fileRef = 506BFF1A3069C3740061DB8D /* icon */; };
+ 506BFF263069C3740061DB8D /* fonts in Resources */ = {isa = PBXBuildFile; fileRef = 506BFF1B3069C3740061DB8D /* fonts */; };
+ 506BFF273069C3740061DB8D /* assets in Resources */ = {isa = PBXBuildFile; fileRef = 506BFF1C3069C3740061DB8D /* assets */; };
+ 506BFF283069C3740061DB8D /* logo.svg in Resources */ = {isa = PBXBuildFile; fileRef = 506BFF1D3069C3740061DB8D /* logo.svg */; };
+ 506BFF293069C3740061DB8D /* src in Resources */ = {isa = PBXBuildFile; fileRef = 506BFF1E3069C3740061DB8D /* src */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXContainerItemProxy section */
+ 506BFF043069C3700061DB8D /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 506BFEDF3069C36F0061DB8D /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 506BFF013069C3700061DB8D;
+ remoteInfo = "7TV for Safari Extension";
+ };
+/* End PBXContainerItemProxy section */
+
+/* Begin PBXCopyFilesBuildPhase section */
+ 506BFF0F3069C3700061DB8D /* Embed Foundation Extensions */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "";
+ dstSubfolderSpec = 13;
+ files = (
+ 506BFF033069C3700061DB8D /* 7TV for Safari Extension.appex in Embed Foundation Extensions */,
+ );
+ name = "Embed Foundation Extensions";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXCopyFilesBuildPhase section */
+
+/* Begin PBXFileReference section */
+ 506BFEE73069C36F0061DB8D /* 7TV for Safari.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "7TV for Safari.app"; sourceTree = BUILT_PRODUCTS_DIR; };
+ 506BFEEA3069C36F0061DB8D /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
+ 506BFEEE3069C36F0061DB8D /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.html; name = Base; path = Base.lproj/Main.html; sourceTree = ""; };
+ 506BFEF03069C36F0061DB8D /* Icon.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = Icon.png; sourceTree = ""; };
+ 506BFEF23069C36F0061DB8D /* Style.css */ = {isa = PBXFileReference; lastKnownFileType = text.css; path = Style.css; sourceTree = ""; };
+ 506BFEF43069C36F0061DB8D /* Script.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = Script.js; sourceTree = ""; };
+ 506BFEF63069C36F0061DB8D /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; };
+ 506BFEF93069C36F0061DB8D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; };
+ 506BFEFB3069C3700061DB8D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
+ 506BFEFD3069C3700061DB8D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+ 506BFF023069C3700061DB8D /* 7TV for Safari Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "7TV for Safari Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
+ 506BFF073069C3700061DB8D /* SafariWebExtensionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariWebExtensionHandler.swift; sourceTree = ""; };
+ 506BFF093069C3700061DB8D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+ 506BFF143069C3740061DB8D /* index.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; name = index.html; path = Resources/index.html; sourceTree = ""; };
+ 506BFF153069C3740061DB8D /* background.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; name = background.js; path = Resources/background.js; sourceTree = ""; };
+ 506BFF163069C3740061DB8D /* worker.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; name = worker.js; path = Resources/worker.js; sourceTree = ""; };
+ 506BFF173069C3740061DB8D /* manifest.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; name = manifest.json; path = Resources/manifest.json; sourceTree = ""; };
+ 506BFF183069C3740061DB8D /* site.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; name = site.js; path = Resources/site.js; sourceTree = ""; };
+ 506BFF193069C3740061DB8D /* content.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; name = content.js; path = Resources/content.js; sourceTree = ""; };
+ 506BFF1A3069C3740061DB8D /* icon */ = {isa = PBXFileReference; lastKnownFileType = folder; name = icon; path = Resources/icon; sourceTree = ""; };
+ 506BFF1B3069C3740061DB8D /* fonts */ = {isa = PBXFileReference; lastKnownFileType = folder; name = fonts; path = Resources/fonts; sourceTree = ""; };
+ 506BFF1C3069C3740061DB8D /* assets */ = {isa = PBXFileReference; lastKnownFileType = folder; name = assets; path = Resources/assets; sourceTree = ""; };
+ 506BFF1D3069C3740061DB8D /* logo.svg */ = {isa = PBXFileReference; lastKnownFileType = text; name = logo.svg; path = Resources/logo.svg; sourceTree = ""; };
+ 506BFF1E3069C3740061DB8D /* src */ = {isa = PBXFileReference; lastKnownFileType = folder; name = src; path = Resources/src; sourceTree = ""; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 506BFEE43069C36F0061DB8D /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 506BFEFF3069C3700061DB8D /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 506BFEDE3069C36F0061DB8D = {
+ isa = PBXGroup;
+ children = (
+ 506BFEE93069C36F0061DB8D /* 7TV for Safari */,
+ 506BFF063069C3700061DB8D /* 7TV for Safari Extension */,
+ 506BFEE83069C36F0061DB8D /* Products */,
+ );
+ sourceTree = "";
+ };
+ 506BFEE83069C36F0061DB8D /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 506BFEE73069C36F0061DB8D /* 7TV for Safari.app */,
+ 506BFF023069C3700061DB8D /* 7TV for Safari Extension.appex */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+ 506BFEE93069C36F0061DB8D /* 7TV for Safari */ = {
+ isa = PBXGroup;
+ children = (
+ 506BFEEA3069C36F0061DB8D /* AppDelegate.swift */,
+ 506BFEF63069C36F0061DB8D /* ViewController.swift */,
+ 506BFEF83069C36F0061DB8D /* Main.storyboard */,
+ 506BFEFB3069C3700061DB8D /* Assets.xcassets */,
+ 506BFEFD3069C3700061DB8D /* Info.plist */,
+ 506BFEEC3069C36F0061DB8D /* Resources */,
+ );
+ path = "7TV for Safari";
+ sourceTree = "";
+ };
+ 506BFEEC3069C36F0061DB8D /* Resources */ = {
+ isa = PBXGroup;
+ children = (
+ 506BFEED3069C36F0061DB8D /* Main.html */,
+ 506BFEF03069C36F0061DB8D /* Icon.png */,
+ 506BFEF23069C36F0061DB8D /* Style.css */,
+ 506BFEF43069C36F0061DB8D /* Script.js */,
+ );
+ path = Resources;
+ sourceTree = "";
+ };
+ 506BFF063069C3700061DB8D /* 7TV for Safari Extension */ = {
+ isa = PBXGroup;
+ children = (
+ 506BFF133069C3740061DB8D /* Resources */,
+ 506BFF073069C3700061DB8D /* SafariWebExtensionHandler.swift */,
+ 506BFF093069C3700061DB8D /* Info.plist */,
+ );
+ path = "7TV for Safari Extension";
+ sourceTree = "";
+ };
+ 506BFF133069C3740061DB8D /* Resources */ = {
+ isa = PBXGroup;
+ children = (
+ 506BFF143069C3740061DB8D /* index.html */,
+ 506BFF153069C3740061DB8D /* background.js */,
+ 506BFF163069C3740061DB8D /* worker.js */,
+ 506BFF173069C3740061DB8D /* manifest.json */,
+ 506BFF183069C3740061DB8D /* site.js */,
+ 506BFF193069C3740061DB8D /* content.js */,
+ 506BFF1A3069C3740061DB8D /* icon */,
+ 506BFF1B3069C3740061DB8D /* fonts */,
+ 506BFF1C3069C3740061DB8D /* assets */,
+ 506BFF1D3069C3740061DB8D /* logo.svg */,
+ 506BFF1E3069C3740061DB8D /* src */,
+ );
+ name = Resources;
+ path = "7TV for Safari Extension";
+ sourceTree = SOURCE_ROOT;
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 506BFEE63069C36F0061DB8D /* 7TV for Safari */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 506BFF103069C3700061DB8D /* Build configuration list for PBXNativeTarget "7TV for Safari" */;
+ buildPhases = (
+ 506BFEE33069C36F0061DB8D /* Sources */,
+ 506BFEE43069C36F0061DB8D /* Frameworks */,
+ 506BFEE53069C36F0061DB8D /* Resources */,
+ 506BFF0F3069C3700061DB8D /* Embed Foundation Extensions */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ 506BFF053069C3700061DB8D /* PBXTargetDependency */,
+ );
+ name = "7TV for Safari";
+ packageProductDependencies = (
+ );
+ productName = "7TV for Safari";
+ productReference = 506BFEE73069C36F0061DB8D /* 7TV for Safari.app */;
+ productType = "com.apple.product-type.application";
+ };
+ 506BFF013069C3700061DB8D /* 7TV for Safari Extension */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 506BFF0C3069C3700061DB8D /* Build configuration list for PBXNativeTarget "7TV for Safari Extension" */;
+ buildPhases = (
+ 506BFEFE3069C3700061DB8D /* Sources */,
+ 506BFEFF3069C3700061DB8D /* Frameworks */,
+ 506BFF003069C3700061DB8D /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = "7TV for Safari Extension";
+ packageProductDependencies = (
+ );
+ productName = "7TV for Safari Extension";
+ productReference = 506BFF023069C3700061DB8D /* 7TV for Safari Extension.appex */;
+ productType = "com.apple.product-type.app-extension";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 506BFEDF3069C36F0061DB8D /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = 1;
+ LastSwiftUpdateCheck = 2610;
+ LastUpgradeCheck = 2610;
+ TargetAttributes = {
+ 506BFEE63069C36F0061DB8D = {
+ CreatedOnToolsVersion = 26.1.1;
+ };
+ 506BFF013069C3700061DB8D = {
+ CreatedOnToolsVersion = 26.1.1;
+ };
+ };
+ };
+ buildConfigurationList = 506BFEE23069C36F0061DB8D /* Build configuration list for PBXProject "7TV for Safari" */;
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = 506BFEDE3069C36F0061DB8D;
+ minimizedProjectReferenceProxies = 1;
+ preferredProjectObjectVersion = 77;
+ productRefGroup = 506BFEE83069C36F0061DB8D /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 506BFEE63069C36F0061DB8D /* 7TV for Safari */,
+ 506BFF013069C3700061DB8D /* 7TV for Safari Extension */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ 506BFEE53069C36F0061DB8D /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 506BFEF13069C36F0061DB8D /* Icon.png in Resources */,
+ 506BFEFA3069C36F0061DB8D /* Main.storyboard in Resources */,
+ 506BFEF53069C36F0061DB8D /* Script.js in Resources */,
+ 506BFEEF3069C36F0061DB8D /* Main.html in Resources */,
+ 506BFEFC3069C3700061DB8D /* Assets.xcassets in Resources */,
+ 506BFEF33069C36F0061DB8D /* Style.css in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 506BFF003069C3700061DB8D /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 506BFF243069C3740061DB8D /* content.js in Resources */,
+ 506BFF233069C3740061DB8D /* site.js in Resources */,
+ 506BFF253069C3740061DB8D /* icon in Resources */,
+ 506BFF273069C3740061DB8D /* assets in Resources */,
+ 506BFF223069C3740061DB8D /* manifest.json in Resources */,
+ 506BFF203069C3740061DB8D /* background.js in Resources */,
+ 506BFF213069C3740061DB8D /* worker.js in Resources */,
+ 506BFF293069C3740061DB8D /* src in Resources */,
+ 506BFF283069C3740061DB8D /* logo.svg in Resources */,
+ 506BFF1F3069C3740061DB8D /* index.html in Resources */,
+ 506BFF263069C3740061DB8D /* fonts in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 506BFEE33069C36F0061DB8D /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 506BFEF73069C36F0061DB8D /* ViewController.swift in Sources */,
+ 506BFEEB3069C36F0061DB8D /* AppDelegate.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 506BFEFE3069C3700061DB8D /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 506BFF083069C3700061DB8D /* SafariWebExtensionHandler.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin PBXTargetDependency section */
+ 506BFF053069C3700061DB8D /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 506BFF013069C3700061DB8D /* 7TV for Safari Extension */;
+ targetProxy = 506BFF043069C3700061DB8D /* PBXContainerItemProxy */;
+ };
+/* End PBXTargetDependency section */
+
+/* Begin PBXVariantGroup section */
+ 506BFEED3069C36F0061DB8D /* Main.html */ = {
+ isa = PBXVariantGroup;
+ children = (
+ 506BFEEE3069C36F0061DB8D /* Base */,
+ );
+ name = Main.html;
+ sourceTree = "";
+ };
+ 506BFEF83069C36F0061DB8D /* Main.storyboard */ = {
+ isa = PBXVariantGroup;
+ children = (
+ 506BFEF93069C36F0061DB8D /* Base */,
+ );
+ name = Main.storyboard;
+ sourceTree = "";
+ };
+/* End PBXVariantGroup section */
+
+/* Begin XCBuildConfiguration section */
+ 506BFF0A3069C3700061DB8D /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MACOSX_DEPLOYMENT_TARGET = 12.0;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = macosx;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ };
+ name = Debug;
+ };
+ 506BFF0B3069C3700061DB8D /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MACOSX_DEPLOYMENT_TARGET = 12.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ MTL_FAST_MATH = YES;
+ SDKROOT = macosx;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ };
+ name = Release;
+ };
+ 506BFF0D3069C3700061DB8D /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_ENTITLEMENTS = "7TV for Safari Extension/7TV for Safari Extension.entitlements";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ ENABLE_APP_SANDBOX = YES;
+ ENABLE_HARDENED_RUNTIME = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = "7TV for Safari Extension/Info.plist";
+ INFOPLIST_KEY_CFBundleDisplayName = "7TV for Safari Extension";
+ INFOPLIST_KEY_NSHumanReadableCopyright = "";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ "@executable_path/../../../../Frameworks",
+ );
+ MACOSX_DEPLOYMENT_TARGET = 12.0;
+ MARKETING_VERSION = 1.0;
+ OTHER_LDFLAGS = (
+ "-framework",
+ SafariServices,
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = app.seventv.safari.Extension;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SKIP_INSTALL = YES;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ };
+ name = Debug;
+ };
+ 506BFF0E3069C3700061DB8D /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_ENTITLEMENTS = "7TV for Safari Extension/7TV for Safari Extension.entitlements";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ ENABLE_APP_SANDBOX = YES;
+ ENABLE_HARDENED_RUNTIME = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = "7TV for Safari Extension/Info.plist";
+ INFOPLIST_KEY_CFBundleDisplayName = "7TV for Safari Extension";
+ INFOPLIST_KEY_NSHumanReadableCopyright = "";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ "@executable_path/../../../../Frameworks",
+ );
+ MACOSX_DEPLOYMENT_TARGET = 12.0;
+ MARKETING_VERSION = 1.0;
+ OTHER_LDFLAGS = (
+ "-framework",
+ SafariServices,
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = app.seventv.safari.Extension;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SKIP_INSTALL = YES;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ };
+ name = Release;
+ };
+ 506BFF113069C3700061DB8D /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_ENTITLEMENTS = "7TV for Safari/7TV for Safari.entitlements";
+ CODE_SIGN_STYLE = Automatic;
+ COMBINE_HIDPI_IMAGES = YES;
+ CURRENT_PROJECT_VERSION = 1;
+ ENABLE_APP_SANDBOX = YES;
+ ENABLE_HARDENED_RUNTIME = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = "7TV for Safari/Info.plist";
+ INFOPLIST_KEY_CFBundleDisplayName = "7TV for Safari";
+ INFOPLIST_KEY_NSHumanReadableCopyright = "";
+ INFOPLIST_KEY_NSMainStoryboardFile = Main;
+ INFOPLIST_KEY_NSPrincipalClass = NSApplication;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ OTHER_LDFLAGS = (
+ "-framework",
+ SafariServices,
+ "-framework",
+ WebKit,
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = app.seventv.safari;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ REGISTER_APP_GROUPS = YES;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ };
+ name = Debug;
+ };
+ 506BFF123069C3700061DB8D /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_ENTITLEMENTS = "7TV for Safari/7TV for Safari.entitlements";
+ CODE_SIGN_STYLE = Automatic;
+ COMBINE_HIDPI_IMAGES = YES;
+ CURRENT_PROJECT_VERSION = 1;
+ ENABLE_APP_SANDBOX = YES;
+ ENABLE_HARDENED_RUNTIME = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = "7TV for Safari/Info.plist";
+ INFOPLIST_KEY_CFBundleDisplayName = "7TV for Safari";
+ INFOPLIST_KEY_NSHumanReadableCopyright = "";
+ INFOPLIST_KEY_NSMainStoryboardFile = Main;
+ INFOPLIST_KEY_NSPrincipalClass = NSApplication;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ OTHER_LDFLAGS = (
+ "-framework",
+ SafariServices,
+ "-framework",
+ WebKit,
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = app.seventv.safari;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ REGISTER_APP_GROUPS = YES;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 506BFEE23069C36F0061DB8D /* Build configuration list for PBXProject "7TV for Safari" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 506BFF0A3069C3700061DB8D /* Debug */,
+ 506BFF0B3069C3700061DB8D /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 506BFF0C3069C3700061DB8D /* Build configuration list for PBXNativeTarget "7TV for Safari Extension" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 506BFF0D3069C3700061DB8D /* Debug */,
+ 506BFF0E3069C3700061DB8D /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 506BFF103069C3700061DB8D /* Build configuration list for PBXNativeTarget "7TV for Safari" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 506BFF113069C3700061DB8D /* Debug */,
+ 506BFF123069C3700061DB8D /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = 506BFEDF3069C36F0061DB8D /* Project object */;
+}
diff --git a/safari-project/7TV for Safari/7TV for Safari.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/safari-project/7TV for Safari/7TV for Safari.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 000000000..919434a62
--- /dev/null
+++ b/safari-project/7TV for Safari/7TV for Safari.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/safari-project/7TV for Safari/7TV for Safari/7TV for Safari.entitlements b/safari-project/7TV for Safari/7TV for Safari/7TV for Safari.entitlements
new file mode 100644
index 000000000..852fa1a47
--- /dev/null
+++ b/safari-project/7TV for Safari/7TV for Safari/7TV for Safari.entitlements
@@ -0,0 +1,8 @@
+
+
+
+
+ com.apple.security.app-sandbox
+
+
+
diff --git a/safari-project/7TV for Safari/7TV for Safari/AppDelegate.swift b/safari-project/7TV for Safari/7TV for Safari/AppDelegate.swift
new file mode 100644
index 000000000..7824bea7c
--- /dev/null
+++ b/safari-project/7TV for Safari/7TV for Safari/AppDelegate.swift
@@ -0,0 +1,18 @@
+//
+// AppDelegate.swift
+// 7TV for Safari
+//
+import Cocoa
+
+@main
+class AppDelegate: NSObject, NSApplicationDelegate {
+
+ func applicationDidFinishLaunching(_ notification: Notification) {
+ // Override point for customization after application launch.
+ }
+
+ func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
+ return true
+ }
+
+}
diff --git a/safari-project/7TV for Safari/7TV for Safari/Assets.xcassets/AccentColor.colorset/Contents.json b/safari-project/7TV for Safari/7TV for Safari/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 000000000..eb8789700
--- /dev/null
+++ b/safari-project/7TV for Safari/7TV for Safari/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,11 @@
+{
+ "colors" : [
+ {
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/safari-project/7TV for Safari/7TV for Safari/Assets.xcassets/AppIcon.appiconset/Contents.json b/safari-project/7TV for Safari/7TV for Safari/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 000000000..5c2eca5a7
--- /dev/null
+++ b/safari-project/7TV for Safari/7TV for Safari/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,68 @@
+{
+ "images" : [
+ {
+ "size" : "16x16",
+ "idiom" : "mac",
+ "filename" : "mac-icon-16@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "16x16",
+ "idiom" : "mac",
+ "filename" : "mac-icon-16@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "32x32",
+ "idiom" : "mac",
+ "filename" : "mac-icon-32@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "32x32",
+ "idiom" : "mac",
+ "filename" : "mac-icon-32@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "128x128",
+ "idiom" : "mac",
+ "filename" : "mac-icon-128@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "128x128",
+ "idiom" : "mac",
+ "filename" : "mac-icon-128@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "256x256",
+ "idiom" : "mac",
+ "filename" : "mac-icon-256@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "256x256",
+ "idiom" : "mac",
+ "filename" : "mac-icon-256@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "512x512",
+ "idiom" : "mac",
+ "filename" : "mac-icon-512@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "512x512",
+ "idiom" : "mac",
+ "filename" : "mac-icon-512@2x.png",
+ "scale" : "2x"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
\ No newline at end of file
diff --git a/safari-project/7TV for Safari/7TV for Safari/Assets.xcassets/AppIcon.appiconset/mac-icon-128@1x.png b/safari-project/7TV for Safari/7TV for Safari/Assets.xcassets/AppIcon.appiconset/mac-icon-128@1x.png
new file mode 100644
index 000000000..f72260d97
Binary files /dev/null and b/safari-project/7TV for Safari/7TV for Safari/Assets.xcassets/AppIcon.appiconset/mac-icon-128@1x.png differ
diff --git a/safari-project/7TV for Safari/7TV for Safari/Assets.xcassets/AppIcon.appiconset/mac-icon-128@2x.png b/safari-project/7TV for Safari/7TV for Safari/Assets.xcassets/AppIcon.appiconset/mac-icon-128@2x.png
new file mode 100644
index 000000000..b4edfe112
Binary files /dev/null and b/safari-project/7TV for Safari/7TV for Safari/Assets.xcassets/AppIcon.appiconset/mac-icon-128@2x.png differ
diff --git a/safari-project/7TV for Safari/7TV for Safari/Assets.xcassets/AppIcon.appiconset/mac-icon-16@1x.png b/safari-project/7TV for Safari/7TV for Safari/Assets.xcassets/AppIcon.appiconset/mac-icon-16@1x.png
new file mode 100644
index 000000000..8e978165c
Binary files /dev/null and b/safari-project/7TV for Safari/7TV for Safari/Assets.xcassets/AppIcon.appiconset/mac-icon-16@1x.png differ
diff --git a/safari-project/7TV for Safari/7TV for Safari/Assets.xcassets/AppIcon.appiconset/mac-icon-16@2x.png b/safari-project/7TV for Safari/7TV for Safari/Assets.xcassets/AppIcon.appiconset/mac-icon-16@2x.png
new file mode 100644
index 000000000..ed7d9bcf6
Binary files /dev/null and b/safari-project/7TV for Safari/7TV for Safari/Assets.xcassets/AppIcon.appiconset/mac-icon-16@2x.png differ
diff --git a/safari-project/7TV for Safari/7TV for Safari/Assets.xcassets/AppIcon.appiconset/mac-icon-256@1x.png b/safari-project/7TV for Safari/7TV for Safari/Assets.xcassets/AppIcon.appiconset/mac-icon-256@1x.png
new file mode 100644
index 000000000..b4edfe112
Binary files /dev/null and b/safari-project/7TV for Safari/7TV for Safari/Assets.xcassets/AppIcon.appiconset/mac-icon-256@1x.png differ
diff --git a/safari-project/7TV for Safari/7TV for Safari/Assets.xcassets/AppIcon.appiconset/mac-icon-256@2x.png b/safari-project/7TV for Safari/7TV for Safari/Assets.xcassets/AppIcon.appiconset/mac-icon-256@2x.png
new file mode 100644
index 000000000..2f847213a
Binary files /dev/null and b/safari-project/7TV for Safari/7TV for Safari/Assets.xcassets/AppIcon.appiconset/mac-icon-256@2x.png differ
diff --git a/safari-project/7TV for Safari/7TV for Safari/Assets.xcassets/AppIcon.appiconset/mac-icon-32@1x.png b/safari-project/7TV for Safari/7TV for Safari/Assets.xcassets/AppIcon.appiconset/mac-icon-32@1x.png
new file mode 100644
index 000000000..ed7d9bcf6
Binary files /dev/null and b/safari-project/7TV for Safari/7TV for Safari/Assets.xcassets/AppIcon.appiconset/mac-icon-32@1x.png differ
diff --git a/safari-project/7TV for Safari/7TV for Safari/Assets.xcassets/AppIcon.appiconset/mac-icon-32@2x.png b/safari-project/7TV for Safari/7TV for Safari/Assets.xcassets/AppIcon.appiconset/mac-icon-32@2x.png
new file mode 100644
index 000000000..18173ab12
Binary files /dev/null and b/safari-project/7TV for Safari/7TV for Safari/Assets.xcassets/AppIcon.appiconset/mac-icon-32@2x.png differ
diff --git a/safari-project/7TV for Safari/7TV for Safari/Assets.xcassets/AppIcon.appiconset/mac-icon-512@1x.png b/safari-project/7TV for Safari/7TV for Safari/Assets.xcassets/AppIcon.appiconset/mac-icon-512@1x.png
new file mode 100644
index 000000000..2f847213a
Binary files /dev/null and b/safari-project/7TV for Safari/7TV for Safari/Assets.xcassets/AppIcon.appiconset/mac-icon-512@1x.png differ
diff --git a/safari-project/7TV for Safari/7TV for Safari/Assets.xcassets/AppIcon.appiconset/mac-icon-512@2x.png b/safari-project/7TV for Safari/7TV for Safari/Assets.xcassets/AppIcon.appiconset/mac-icon-512@2x.png
new file mode 100644
index 000000000..d2a8df20a
Binary files /dev/null and b/safari-project/7TV for Safari/7TV for Safari/Assets.xcassets/AppIcon.appiconset/mac-icon-512@2x.png differ
diff --git a/safari-project/7TV for Safari/7TV for Safari/Assets.xcassets/Contents.json b/safari-project/7TV for Safari/7TV for Safari/Assets.xcassets/Contents.json
new file mode 100644
index 000000000..73c00596a
--- /dev/null
+++ b/safari-project/7TV for Safari/7TV for Safari/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/safari-project/7TV for Safari/7TV for Safari/Assets.xcassets/LargeIcon.imageset/Contents.json b/safari-project/7TV for Safari/7TV for Safari/Assets.xcassets/LargeIcon.imageset/Contents.json
new file mode 100644
index 000000000..a19a54922
--- /dev/null
+++ b/safari-project/7TV for Safari/7TV for Safari/Assets.xcassets/LargeIcon.imageset/Contents.json
@@ -0,0 +1,20 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/safari-project/7TV for Safari/7TV for Safari/Base.lproj/Main.storyboard b/safari-project/7TV for Safari/7TV for Safari/Base.lproj/Main.storyboard
new file mode 100644
index 000000000..bb41ecc26
--- /dev/null
+++ b/safari-project/7TV for Safari/7TV for Safari/Base.lproj/Main.storyboard
@@ -0,0 +1,124 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/safari-project/7TV for Safari/7TV for Safari/Info.plist b/safari-project/7TV for Safari/7TV for Safari/Info.plist
new file mode 100644
index 000000000..55d956d4f
--- /dev/null
+++ b/safari-project/7TV for Safari/7TV for Safari/Info.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ SFSafariWebExtensionConverterVersion
+ 26.1.1
+
+
diff --git a/safari-project/7TV for Safari/7TV for Safari/Resources/Base.lproj/Main.html b/safari-project/7TV for Safari/7TV for Safari/Resources/Base.lproj/Main.html
new file mode 100644
index 000000000..563d5ec70
--- /dev/null
+++ b/safari-project/7TV for Safari/7TV for Safari/Resources/Base.lproj/Main.html
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ You can turn on 7TV for Safari’s extension in Safari Extensions preferences.
+ 7TV for Safari’s extension is currently on. You can turn it off in Safari Extensions preferences.
+ 7TV for Safari’s extension is currently off. You can turn it on in Safari Extensions preferences.
+ Quit and Open Safari Extensions Preferences…
+
+
diff --git a/safari-project/7TV for Safari/7TV for Safari/Resources/Icon.png b/safari-project/7TV for Safari/7TV for Safari/Resources/Icon.png
new file mode 100644
index 000000000..6cd0cd20a
Binary files /dev/null and b/safari-project/7TV for Safari/7TV for Safari/Resources/Icon.png differ
diff --git a/safari-project/7TV for Safari/7TV for Safari/Resources/Script.js b/safari-project/7TV for Safari/7TV for Safari/Resources/Script.js
new file mode 100644
index 000000000..c3c8ad4ec
--- /dev/null
+++ b/safari-project/7TV for Safari/7TV for Safari/Resources/Script.js
@@ -0,0 +1,22 @@
+function show(enabled, useSettingsInsteadOfPreferences) {
+ if (useSettingsInsteadOfPreferences) {
+ document.getElementsByClassName('state-on')[0].innerText = "7TV for Safari’s extension is currently on. You can turn it off in the Extensions section of Safari Settings.";
+ document.getElementsByClassName('state-off')[0].innerText = "7TV for Safari’s extension is currently off. You can turn it on in the Extensions section of Safari Settings.";
+ document.getElementsByClassName('state-unknown')[0].innerText = "You can turn on 7TV for Safari’s extension in the Extensions section of Safari Settings.";
+ document.getElementsByClassName('open-preferences')[0].innerText = "Quit and Open Safari Settings…";
+ }
+
+ if (typeof enabled === "boolean") {
+ document.body.classList.toggle(`state-on`, enabled);
+ document.body.classList.toggle(`state-off`, !enabled);
+ } else {
+ document.body.classList.remove(`state-on`);
+ document.body.classList.remove(`state-off`);
+ }
+}
+
+function openPreferences() {
+ webkit.messageHandlers.controller.postMessage("open-preferences");
+}
+
+document.querySelector("button.open-preferences").addEventListener("click", openPreferences);
diff --git a/safari-project/7TV for Safari/7TV for Safari/Resources/Style.css b/safari-project/7TV for Safari/7TV for Safari/Resources/Style.css
new file mode 100644
index 000000000..cbde9e697
--- /dev/null
+++ b/safari-project/7TV for Safari/7TV for Safari/Resources/Style.css
@@ -0,0 +1,45 @@
+* {
+ -webkit-user-select: none;
+ -webkit-user-drag: none;
+ cursor: default;
+}
+
+:root {
+ color-scheme: light dark;
+
+ --spacing: 20px;
+}
+
+html {
+ height: 100%;
+}
+
+body {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-direction: column;
+
+ gap: var(--spacing);
+ margin: 0 calc(var(--spacing) * 2);
+ height: 100%;
+
+ font: -apple-system-short-body;
+ text-align: center;
+}
+
+body:not(.state-on, .state-off) :is(.state-on, .state-off) {
+ display: none;
+}
+
+body.state-on :is(.state-off, .state-unknown) {
+ display: none;
+}
+
+body.state-off :is(.state-on, .state-unknown) {
+ display: none;
+}
+
+button {
+ font-size: 1em;
+}
diff --git a/safari-project/7TV for Safari/7TV for Safari/ViewController.swift b/safari-project/7TV for Safari/7TV for Safari/ViewController.swift
new file mode 100644
index 000000000..dd4176e69
--- /dev/null
+++ b/safari-project/7TV for Safari/7TV for Safari/ViewController.swift
@@ -0,0 +1,54 @@
+//
+// ViewController.swift
+// 7TV for Safari
+//
+import Cocoa
+import SafariServices
+import WebKit
+
+let extensionBundleIdentifier = "\(Bundle.main.bundleIdentifier ?? "app.seventv.safari").Extension"
+
+class ViewController: NSViewController, WKNavigationDelegate, WKScriptMessageHandler {
+
+ @IBOutlet var webView: WKWebView!
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+
+ self.webView.navigationDelegate = self
+
+ self.webView.configuration.userContentController.add(self, name: "controller")
+
+ self.webView.loadFileURL(Bundle.main.url(forResource: "Main", withExtension: "html")!, allowingReadAccessTo: Bundle.main.resourceURL!)
+ }
+
+ func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
+ SFSafariExtensionManager.getStateOfSafariExtension(withIdentifier: extensionBundleIdentifier) { (state, error) in
+ guard let state = state, error == nil else {
+ // Insert code to inform the user that something went wrong.
+ return
+ }
+
+ DispatchQueue.main.async {
+ if #available(macOS 13, *) {
+ webView.evaluateJavaScript("show(\(state.isEnabled), true)")
+ } else {
+ webView.evaluateJavaScript("show(\(state.isEnabled), false)")
+ }
+ }
+ }
+ }
+
+ func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
+ guard let command = message.body as? String, command == "open-preferences" else {
+ return;
+ }
+
+ SFSafariApplication.showPreferencesForExtension(withIdentifier: extensionBundleIdentifier) { error in
+ DispatchQueue.main.async {
+ NSApplication.shared.terminate(nil)
+ }
+ }
+ }
+
+}
diff --git a/script/build-safari-local.sh b/script/build-safari-local.sh
new file mode 100755
index 000000000..a224b2ff8
--- /dev/null
+++ b/script/build-safari-local.sh
@@ -0,0 +1,65 @@
+#!/bin/zsh
+
+set -euo pipefail
+
+repo_dir="${0:A:h:h}"
+project_dir="$repo_dir/safari-project/7TV for Safari"
+resources_dir="$project_dir/7TV for Safari Extension/Resources"
+derived_data_dir=$(mktemp -d "${TMPDIR%/}/seven-tv-safari-build.XXXXXX")
+app_path="$derived_data_dir/Build/Products/Release/7TV for Safari.app"
+
+cd "$repo_dir"
+
+if ! npx --no-install yarn@1.22.22 --version >/dev/null 2>&1; then
+ print -u2 -- "Pinned Yarn 1.22.22 is not available locally. Refusing to download build tools implicitly."
+ exit 1
+fi
+
+if [[ ! -x node_modules/.bin/vite || ! -x node_modules/.bin/vue-tsc ]]; then
+ print -u2 -- "Dependencies are missing. Install the reviewed lockfile before building."
+ exit 1
+fi
+
+npx --no-install yarn@1.22.22 check --integrity --ignore-scripts
+npx --no-install yarn@1.22.22 audit --groups dependencies
+npx --no-install yarn@1.22.22 build:safari
+rsync -a --delete "$repo_dir/dist/" "$resources_dir/"
+xattr -cr "$project_dir"
+
+identity_line=$(security find-identity -v -p codesigning | awk '/Apple Development/ {print; exit}')
+signing_identity="${SEVENTV_SIGNING_IDENTITY:-$(print -r -- "$identity_line" | awk '{print $2}')}"
+team_id="${SEVENTV_TEAM_ID:-$(print -r -- "$identity_line" | sed -E 's/.*\(([A-Z0-9]+)\)".*/\1/')}"
+
+if [[ -z "$signing_identity" || -z "$team_id" || "$team_id" == "$identity_line" ]]; then
+ print -u2 -- "An Apple Development identity and team are required for a persistent Safari extension build."
+ exit 1
+fi
+
+DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \
+ xcodebuild \
+ -project "$project_dir/7TV for Safari.xcodeproj" \
+ -scheme "7TV for Safari" \
+ -configuration Release \
+ -derivedDataPath "$derived_data_dir" \
+ DEVELOPMENT_TEAM="$team_id" \
+ CODE_SIGN_STYLE=Manual \
+ CODE_SIGN_IDENTITY="$signing_identity" \
+ PROVISIONING_PROFILE_SPECIFIER= \
+ CODE_SIGN_INJECT_BASE_ENTITLEMENTS=NO \
+ REGISTER_WITH_LAUNCH_SERVICES=NO \
+ build
+
+codesign --verify --deep --strict --verbose=4 "$app_path"
+
+manifest="$app_path/Contents/PlugIns/7TV for Safari Extension.appex/Contents/Resources/manifest.json"
+node -e '
+const fs = require("fs");
+const manifest = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
+const expected = JSON.stringify(["storage"]);
+const hosts = JSON.stringify(["*://*.twitch.tv/*", "*://*.kick.com/*", "*://*.youtube.com/*"]);
+if (JSON.stringify(manifest.permissions) !== expected || JSON.stringify(manifest.host_permissions) !== hosts) {
+ throw new Error("Unexpected Safari extension permissions");
+}
+' "$manifest"
+
+print -r -- "$app_path"
diff --git a/script/verify-safari-release.sh b/script/verify-safari-release.sh
new file mode 100755
index 000000000..9cfb3e736
--- /dev/null
+++ b/script/verify-safari-release.sh
@@ -0,0 +1,53 @@
+#!/bin/zsh
+
+set -euo pipefail
+
+app_path="${1:-}"
+mode="${2:-development}"
+
+if [[ -z "$app_path" || ! -d "$app_path" || "$app_path" != *.app ]]; then
+ print -u2 -- "Usage: $0 /absolute/path/to/7TV\\ for\\ Safari.app [development|distribution]"
+ exit 64
+fi
+
+app_path="${app_path:A}"
+extension_path="$app_path/Contents/PlugIns/7TV for Safari Extension.appex"
+resources_path="$extension_path/Contents/Resources"
+entitlements_dir=$(mktemp -d "${TMPDIR%/}/seventv-entitlements.XXXXXX")
+host_entitlements="$entitlements_dir/host.plist"
+extension_entitlements="$entitlements_dir/extension.plist"
+trap '/bin/rm -rf -- "$entitlements_dir"' EXIT
+
+[[ -d "$extension_path" ]] || { print -u2 -- "Safari extension bundle is missing"; exit 1; }
+[[ -f "$resources_path/manifest.json" ]] || { print -u2 -- "Safari manifest is missing"; exit 1; }
+
+codesign --verify --deep --strict --verbose=4 "$app_path"
+codesign -d --entitlements :- "$app_path" >"$host_entitlements" 2>/dev/null
+codesign -d --entitlements :- "$extension_path" >"$extension_entitlements" 2>/dev/null
+
+node - "$resources_path/manifest.json" <<'NODE'
+const fs = require("fs");
+const manifest = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
+if (JSON.stringify(manifest.permissions) !== JSON.stringify(["storage"])) throw new Error("Unexpected permissions");
+const hosts = ["*://*.twitch.tv/*", "*://*.kick.com/*", "*://*.youtube.com/*"];
+if (JSON.stringify(manifest.host_permissions) !== JSON.stringify(hosts)) throw new Error("Unexpected hosts");
+if (manifest.optional_permissions || manifest.optional_host_permissions) throw new Error("Optional permissions are forbidden");
+NODE
+
+for entitlement_file in "$host_entitlements" "$extension_entitlements"; do
+ /usr/libexec/PlistBuddy -c 'Print :com.apple.security.app-sandbox' "$entitlement_file" | grep -qx true
+ if /usr/libexec/PlistBuddy -c 'Print :com.apple.security.network.client' "$entitlement_file" >/dev/null 2>&1; then
+ print -u2 -- "Unexpected outgoing-network entitlement in $entitlement_file"
+ exit 1
+ fi
+done
+
+if [[ "$mode" == "distribution" ]]; then
+ spctl --assess --type execute --verbose=4 "$app_path"
+ xcrun stapler validate "$app_path"
+elif [[ "$mode" != "development" ]]; then
+ print -u2 -- "Mode must be development or distribution"
+ exit 64
+fi
+
+print -r -- "Safari release verification passed for $app_path ($mode)"
diff --git a/src/app/options/views/Compat/Compat.vue b/src/app/options/views/Compat/Compat.vue
index 6e6023723..f9ecb428e 100644
--- a/src/app/options/views/Compat/Compat.vue
+++ b/src/app/options/views/Compat/Compat.vue
@@ -89,6 +89,8 @@ fetch(`${import.meta.env.VITE_APP_API}/config/${configName}`)
.then((r) => (config.value = r));
function requestManagement(): void {
+ if (import.meta.env.VITE_APP_SAFARI === "true") return;
+
chrome.permissions.request(
{
permissions: ["management"],
@@ -131,7 +133,7 @@ function getColorBorder(compat: SevenTV.ConfigCompat): string {
}
// check current status of management permission
-if (isExtensionContext && chrome && chrome.permissions) {
+if (import.meta.env.VITE_APP_SAFARI !== "true" && isExtensionContext && chrome && chrome.permissions) {
chrome.permissions.contains(
{
permissions: ["management"],
diff --git a/src/app/options/views/Onboarding/OnboardingPlatforms.vue b/src/app/options/views/Onboarding/OnboardingPlatforms.vue
index 9801dd36c..77cd9ede2 100644
--- a/src/app/options/views/Onboarding/OnboardingPlatforms.vue
+++ b/src/app/options/views/Onboarding/OnboardingPlatforms.vue
@@ -31,6 +31,11 @@ const ctx = useOnboarding("platforms");
onActivated(() => {
ctx.setLock(true, () => {
+ if (import.meta.env.VITE_APP_SAFARI === "true") {
+ ctx.setLock(false);
+ return true;
+ }
+
const selection = platforms.value.filter((p) => p.selected);
if (selection.length === 0) return false;
diff --git a/src/app/settings/SettingsViewProfile.vue b/src/app/settings/SettingsViewProfile.vue
index c0280477a..e4498840c 100644
--- a/src/app/settings/SettingsViewProfile.vue
+++ b/src/app/settings/SettingsViewProfile.vue
@@ -25,6 +25,7 @@ import { useConfig } from "@/composable/useSettings";
const site = import.meta.env.VITE_APP_SITE;
const src = site + "/extension/auth";
+const authOrigin = new URL(site).origin;
const actor = useActor();
const token = useConfig("app.7tv.token");
@@ -34,7 +35,7 @@ let w: Window | null = null;
let s: Pausable | null = null;
const listener = (ev: MessageEvent) => {
- if (!ev.data) return;
+ if (!ev.data || ev.origin !== authOrigin || ev.source !== w) return;
switch (ev.data.type) {
case "7tv-token":
@@ -62,7 +63,7 @@ function login() {
if (!w) return;
window.addEventListener("message", listener);
s = useIntervalFn(() => {
- w?.postMessage("7tv-token-request", "*");
+ w?.postMessage("7tv-token-request", authOrigin);
}, 100);
}
diff --git a/src/background/background.ts b/src/background/background.ts
index 29c19573a..0fc76ec61 100644
--- a/src/background/background.ts
+++ b/src/background/background.ts
@@ -63,9 +63,12 @@ chrome.tabs.onUpdated.addListener((_, i, t) => {
newTabs.delete(t.id);
});
-// Register content scripts
+// Register optional-platform content scripts. Safari declares all supported
+// sites statically in its manifest and does not request the scripting
+// permission, avoiding duplicate dynamic registrations after service-worker
+// restarts.
const activeTabs = new Set();
-if (!chrome.scripting) {
+if (import.meta.env.VITE_APP_SAFARI !== "true" && !chrome.scripting) {
chrome.tabs.onUpdated.addListener((tabId, i, t) => {
if (!i.status || !t.url) {
return undefined;
@@ -81,7 +84,7 @@ if (!chrome.scripting) {
});
}
});
-} else {
+} else if (import.meta.env.VITE_APP_SAFARI !== "true") {
chrome.scripting.registerContentScripts([
{
id: "seventv-youtube",
diff --git a/src/background/messaging.ts b/src/background/messaging.ts
index 05081547d..2d6cb9080 100644
--- a/src/background/messaging.ts
+++ b/src/background/messaging.ts
@@ -1,11 +1,30 @@
-// Handle messaging from downstream
+// Handle messaging from downstream. Treat every message as untrusted: the
+// Twitch page hosts the injected 7TV application and can access its page-world
+// communication channel.
let shouldReloadOnUpdate = false;
-chrome.runtime.onMessage.addListener((msg, _, reply) => {
+const ALLOWED_ORIGINS = new Set(["*://*.youtube.com/*", "*://*.kick.com/*", "*://*.7tv.app/*", "*://*.7tv.io/*"]);
+const ALLOWED_PERMISSIONS = new Set(["management"]);
+
+chrome.runtime.onMessage.addListener((msg: unknown, sender, reply) => {
+ if (!isTrustedSender(sender) || !isRecord(msg) || typeof msg.type !== "string") return false;
+
switch (msg.type) {
case "permission-request": {
+ if (import.meta.env.VITE_APP_SAFARI === "true" || !isPermissionRequest(msg.data)) {
+ reply({ granted: false, id: isRecord(msg.data) ? String(msg.data.id ?? "") : "" });
+ return false;
+ }
+
const { id, origins, permissions } = msg.data;
+ if (
+ origins.some((origin) => !ALLOWED_ORIGINS.has(origin)) ||
+ permissions.some((permission) => !ALLOWED_PERMISSIONS.has(permission))
+ ) {
+ reply({ granted: false, id });
+ return false;
+ }
chrome.permissions.request({ origins, permissions }, (granted) => {
reply({ granted, id });
@@ -17,61 +36,89 @@ chrome.runtime.onMessage.addListener((msg, _, reply) => {
data: { id },
});
});
- break;
+ return true;
}
case "update-check": {
- if (typeof chrome.runtime.requestUpdateCheck !== "function") return;
+ if (typeof chrome.runtime.requestUpdateCheck !== "function") {
+ reply({ status: "no_update", version: null });
+ return false;
+ }
shouldReloadOnUpdate = true;
- /* // sim:
- reply({
- status: "update_available",
- version: "3.0.0.12200",
- done: false,
- });
-
- setTimeout(() => {
- if (!shouldReloadOnUpdate) return;
-
- broadcastMessage("update-ready", {
- version: "3.0.0.12200",
- });
-
- setTimeout(() => chrome.runtime.reload(), 50);
- }, 1500);
- return;
- /// end sim */
-
chrome.runtime.requestUpdateCheck((status, details) => {
reply({
status,
version: details?.version ?? null,
});
});
-
- break;
+ return true;
}
+ default:
+ return false;
}
-
- return true;
});
-chrome.runtime.onUpdateAvailable.addListener((details) => {
- if (!shouldReloadOnUpdate) return;
+// Safari does not expose Chromium's extension-update event. Updates to this
+// Safari package arrive when the app is rebuilt, so the listener is
+// registered only in browsers that implement it.
+if (chrome.runtime.onUpdateAvailable) {
+ chrome.runtime.onUpdateAvailable.addListener((details) => {
+ if (!shouldReloadOnUpdate) return;
- // Notify page script to reload trigger a reload immediately
- broadcastMessage("update-ready", { version: details.version });
+ // Notify page script to reload trigger a reload immediately
+ broadcastMessage("update-ready", { version: details.version });
- // Reload extension after a tiny delay to allow the downstream message to be sent
- setTimeout(() => chrome.runtime.reload(), 50);
-});
+ // Reload extension after a tiny delay to allow the downstream message to be sent
+ setTimeout(() => chrome.runtime.reload(), 50);
+ });
+}
function broadcastMessage(type: string, data: unknown): void {
chrome.tabs.query({}, (tabs) => {
tabs.forEach((tab) => {
- if (!tab.id) return;
+ if (!tab.id || !isSupportedSiteURL(tab.url)) return;
- chrome.tabs.sendMessage(tab.id, { type, data });
+ chrome.tabs.sendMessage(tab.id, { type, data }, () => void chrome.runtime.lastError);
});
});
}
+
+function isTrustedSender(sender: chrome.runtime.MessageSender): boolean {
+ return isSupportedSiteURL(sender.url) && isSupportedSiteURL(sender.tab?.url);
+}
+
+function isSupportedSiteURL(value?: string): boolean {
+ if (!value) return false;
+
+ try {
+ const url = new URL(value);
+ return (
+ url.protocol === "https:" &&
+ ["twitch.tv", "kick.com", "youtube.com"].some(
+ (host) => url.hostname === host || url.hostname.endsWith(`.${host}`),
+ )
+ );
+ } catch {
+ return false;
+ }
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null;
+}
+
+function isPermissionRequest(value: unknown): value is { id: string; origins: string[]; permissions: string[] } {
+ if (!isRecord(value)) return false;
+
+ return (
+ typeof value.id === "string" &&
+ value.id.length > 0 &&
+ value.id.length <= 128 &&
+ Array.isArray(value.origins) &&
+ value.origins.length <= ALLOWED_ORIGINS.size &&
+ value.origins.every((origin) => typeof origin === "string") &&
+ Array.isArray(value.permissions) &&
+ value.permissions.length <= ALLOWED_PERMISSIONS.size &&
+ value.permissions.every((permission) => typeof permission === "string")
+ );
+}
diff --git a/src/background/sync.ts b/src/background/sync.ts
index c83e7aa0e..4d38fa831 100644
--- a/src/background/sync.ts
+++ b/src/background/sync.ts
@@ -26,10 +26,14 @@ chrome.tabs.onUpdated.addListener((id, i) => {
setTimeout(() => {
if (typeof id !== "number") return;
- chrome.tabs.sendMessage(id, {
- type: "settings-sync",
- data: { settings },
- });
+ chrome.tabs.sendMessage(
+ id,
+ {
+ type: "settings-sync",
+ data: { settings },
+ },
+ () => void chrome.runtime.lastError,
+ );
}, 5000);
});
diff --git a/src/composable/channel/useChannelContext.ts b/src/composable/channel/useChannelContext.ts
index 4c210299a..be783ea47 100644
--- a/src/composable/channel/useChannelContext.ts
+++ b/src/composable/channel/useChannelContext.ts
@@ -65,6 +65,10 @@ export class ChannelContext implements CurrentChannel {
}
async fetch(refetch = false) {
+ if (import.meta.env.VITE_APP_SAFARI === "true" && typeof performance?.mark === "function") {
+ performance.mark(`seventv:channel-${this.id}-fetch-start`);
+ }
+
sendMessage("STATE", {
channel: toRaw(this.base),
refetch: refetch,
diff --git a/src/composable/useWorker.ts b/src/composable/useWorker.ts
index c60b1f7de..b1b81b98a 100644
--- a/src/composable/useWorker.ts
+++ b/src/composable/useWorker.ts
@@ -1,4 +1,3 @@
-import { LOCAL_STORAGE_KEYS } from "@/common/Constant";
import { TypedEventListenerOrEventListenerObject } from "@/common/EventTarget";
import { Logger, log } from "@/common/Logger";
import { TypedWorkerMessage, WorkerMessage, WorkerMessageType } from "@/worker";
@@ -10,59 +9,30 @@ workerLog.setContextName("Worker/Pipe");
let worker: SharedWorker | null = null;
-type WorkerAddrMap = Record;
-
async function init(originURL: string): Promise {
let sw: SharedWorker;
+ markSafariPerformance("worker-init-start");
- // Check for existing url
- // If it exists, we'll connect to it
- const appVersion = import.meta.env.VITE_APP_VERSION;
- const workerAddrData: string | null = localStorage.getItem(LOCAL_STORAGE_KEYS.WORKER_ADDR);
- let workerAddr: WorkerAddrMap | null = null;
-
- try {
- workerAddr = workerAddrData ? JSON.parse(workerAddrData) : null;
- } catch (err) {
- log.error("Unable to parse worker address data", String(err));
- localStorage.removeItem(LOCAL_STORAGE_KEYS.WORKER_ADDR);
+ // The worker must always come from the packaged extension. Previously this
+ // accepted a URL cached in Twitch localStorage, which is controlled by the
+ // host page and could make 7TV execute an attacker-selected worker.
+ if (import.meta.env.VITE_APP_SAFARI === "true" && !isPackagedWorkerURL(originURL)) {
+ return Promise.reject("Refusing to load a worker outside the extension package");
}
- workerURL = typeof workerAddr === "object" && workerAddr !== null ? workerAddr[appVersion] : null;
-
- const ok =
- workerURL &&
- (await fetch(workerURL)
- .then((res) => res.ok)
- .catch(() => false));
-
- // Fetch worker data
- if (!ok) {
- // Get the offline URL passed by the loader
- workerURL = originURL;
- if (!workerURL) {
- log.error("Unable to find address to worker");
- }
-
- // Fetch worker data
- const data = await fetch(workerURL || "")
- .then((r) => r.blob())
- .catch((err) => {
- log.error("Unable to fetch worker data", err);
- });
- if (!data) return Promise.reject("There was an error fetching worker data");
-
- log.info("Received worker data", `(${data.size} bytes)`);
+ const data = await fetch(originURL)
+ .then((r) => {
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
+ return r.blob();
+ })
+ .catch((err) => {
+ log.error("Unable to fetch packaged worker data", err);
+ });
+ if (!data) return Promise.reject("There was an error fetching packaged worker data");
- // Create BLOB URL for worker & set it into local storage
- workerURL = URL.createObjectURL(data);
- localStorage.setItem(
- LOCAL_STORAGE_KEYS.WORKER_ADDR,
- JSON.stringify({ ...(workerAddr ?? {}), [appVersion]: workerURL }),
- );
- } else {
- log.info("Connecting to existing worker", `addr=${workerURL}`);
- }
+ log.info("Received packaged worker data", `(${data.size} bytes)`);
+ markSafariPerformance("worker-script-ready");
+ workerURL = URL.createObjectURL(data);
// Connect to worker
return new Promise((resolve, reject) => {
@@ -87,6 +57,20 @@ async function init(originURL: string): Promise {
});
}
+function isPackagedWorkerURL(value: string): boolean {
+ try {
+ const url = new URL(value);
+ return (
+ (url.protocol === "safari-web-extension:" || url.protocol === "chrome-extension:") &&
+ url.pathname === "/worker.js" &&
+ url.search === "" &&
+ url.hash === ""
+ );
+ } catch {
+ return false;
+ }
+}
+
function sendMessage(type: T, data: TypedWorkerMessage): void {
if (!worker) return;
@@ -127,6 +111,7 @@ function useHandlers(mp: MessagePort) {
switch (type) {
case "INIT": {
+ markSafariPerformance("worker-ready");
events.emit("ready", {});
break;
}
@@ -143,12 +128,19 @@ function useHandlers(mp: MessagePort) {
}
case "CHANNEL_FETCHED": {
const { channel } = data as TypedWorkerMessage<"CHANNEL_FETCHED">;
+ markSafariPerformance(`channel-${channel.id}-metadata-ready`);
events.emit("channel_fetched", channel);
break;
}
+ case "PROVIDER_SET_FETCHED": {
+ const result = data as TypedWorkerMessage<"PROVIDER_SET_FETCHED">;
+ markSafariPerformance(`channel-${result.channel.id}-${result.provider.toLowerCase()}-set-ready`);
+ break;
+ }
case "CHANNEL_SETS_FETCHED": {
const { channel } = data as TypedWorkerMessage<"CHANNEL_SETS_FETCHED">;
+ markSafariPerformance(`channel-${channel.id}-all-sets-settled`);
events.emit("channel_sets_fetched", channel);
break;
@@ -194,6 +186,12 @@ function useHandlers(mp: MessagePort) {
});
}
+function markSafariPerformance(name: string): void {
+ if (import.meta.env.VITE_APP_SAFARI !== "true" || typeof performance?.mark !== "function") return;
+
+ performance.mark(`seventv:${name}`);
+}
+
class WorkletTarget extends EventTarget {
constructor() {
super();
diff --git a/src/content/content.ts b/src/content/content.ts
index c9d35b0c1..e793c0ad3 100644
--- a/src/content/content.ts
+++ b/src/content/content.ts
@@ -56,8 +56,11 @@ const inject = () => {
// Listen for requests to set up an extension permission
bc.addEventListener("message", (ev) => {
+ if (!ev.data || typeof ev.data !== "object" || typeof ev.data.type !== "string") return;
+
switch (ev.data.type) {
case "seventv-create-permission-listener": {
+ if (import.meta.env.VITE_APP_SAFARI === "true" || !isPermissionRequestEvent(ev.data.data)) return;
const { selector, id, origins, permissions } = ev.data.data as PermissionRequestEvent;
const btn = document.querySelector(selector);
@@ -70,7 +73,7 @@ const inject = () => {
data: { id, origins, permissions },
},
{},
- (response: { id: string; granted: boolean }) => {
+ (response?: { id: string; granted: boolean }) => {
if (!response) return;
bc.postMessage({
@@ -85,7 +88,8 @@ const inject = () => {
case "seventv-update-check": {
chrome.runtime.sendMessage(
{ type: "update-check" },
- (response: { status: string; version: string }) => {
+ (response?: { status: string; version: string | null }) => {
+ if (!response) return;
bc.postMessage({
type: "seventv-update-check-result",
data: { status: response.status, version: response.version },
@@ -119,6 +123,22 @@ const inject = () => {
interface PermissionRequestEvent {
selector: string;
id: string;
- origins: [];
- permissions: [];
+ origins: string[];
+ permissions: string[];
+}
+
+function isPermissionRequestEvent(value: unknown): value is PermissionRequestEvent {
+ if (!value || typeof value !== "object") return false;
+
+ const request = value as Partial;
+ return (
+ typeof request.selector === "string" &&
+ /^\[data-seventv-permission-selector="[0-9a-f-]{36}"\]$/i.test(request.selector) &&
+ typeof request.id === "string" &&
+ /^[0-9a-f-]{36}$/i.test(request.id) &&
+ Array.isArray(request.origins) &&
+ request.origins.every((origin) => typeof origin === "string") &&
+ Array.isArray(request.permissions) &&
+ request.permissions.every((permission) => typeof permission === "string")
+ );
}
diff --git a/src/content/emoji.ts b/src/content/emoji.ts
index d8db600c6..b88f95dde 100644
--- a/src/content/emoji.ts
+++ b/src/content/emoji.ts
@@ -1,3 +1,5 @@
+import DOMPurify from "dompurify";
+
/**
* Inserts the emoji vectors into the DOM.
*/
@@ -18,7 +20,13 @@ export async function insertEmojiVectors(): Promise {
const element = document.createElement("div");
element.id = "emojis" + i;
- element.innerHTML = await data;
+ // These SVG sprites are packaged with the extension. Sanitize them anyway
+ // so a compromised or accidentally modified asset cannot inject active
+ // content into Twitch through this HTML sink.
+ element.innerHTML = DOMPurify.sanitize(await data, {
+ USE_PROFILES: { svg: true, svgFilters: true },
+ FORBID_TAGS: ["script", "foreignObject"],
+ });
container.appendChild(element);
}
diff --git a/src/site/global/Changelog.vue b/src/site/global/Changelog.vue
index 6b03c9cdb..d91133fda 100644
--- a/src/site/global/Changelog.vue
+++ b/src/site/global/Changelog.vue
@@ -18,6 +18,7 @@
import { inject, nextTick, ref, watchEffect } from "vue";
import { SITE_ASSETS_URL } from "@/common/Constant";
import Logo from "@/assets/svg/logos/Logo.vue";
+import DOMPurify from "dompurify";
import { marked } from "marked";
defineProps<{
@@ -32,7 +33,7 @@ const assetsBase = inject(SITE_ASSETS_URL, "");
const contentRef = ref();
watchEffect(() => {
- content.value = marked.parse(changelogRaw.value, {
+ const rendered = marked.parse(changelogRaw.value, {
walkTokens: (tok) => {
if (tok.type === "image" && tok.href.charAt(0) === "~" && assetsBase) {
tok.href = updateMediaHref(tok.href);
@@ -41,6 +42,10 @@ watchEffect(() => {
gfm: true,
breaks: true,
});
+ content.value = DOMPurify.sanitize(rendered as string, {
+ USE_PROFILES: { html: true },
+ FORBID_TAGS: ["form", "iframe", "object", "embed"],
+ });
nextTick(() => {
if (!contentRef.value) return;
diff --git a/src/site/site.app.ts b/src/site/site.app.ts
index 48ba6ded1..4deac76e4 100644
--- a/src/site/site.app.ts
+++ b/src/site/site.app.ts
@@ -75,10 +75,9 @@ app.provide("app-id", appID);
const extensionOrigin = scr?.getAttribute("extension_origin") ?? "";
seventv.hosted ??= seventv.remote;
-app.provide(
- SITE_WORKER_URL,
- seventv.hosted ? seventv.host_manifest?.worker_file ?? null : null ?? scr?.getAttribute("worker_url"),
-);
+const workerURL = seventv.hosted ? seventv.host_manifest?.worker_file : scr?.getAttribute("worker_url");
+if (!workerURL) throw new Error("7TV worker URL is missing");
+app.provide(SITE_WORKER_URL, workerURL);
app.provide(SITE_ASSETS_URL, extensionOrigin + "assets");
app.provide(SITE_EXT_OPTIONS_URL, extensionOrigin + "index.html");
diff --git a/src/types/vite-env.d.ts b/src/types/vite-env.d.ts
index 8d5249b56..b589ee16a 100644
--- a/src/types/vite-env.d.ts
+++ b/src/types/vite-env.d.ts
@@ -2,6 +2,10 @@
// eslint-disable-next-line prettier/prettier
import type { DefineComponent } from "vue";
+interface ImportMetaEnv {
+ readonly VITE_APP_SAFARI?: "true" | "false";
+}
+
declare module "*.vue" {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const component: DefineComponent, Record, any>;
diff --git a/src/worker/index.ts b/src/worker/index.ts
index b4f1836fa..d55f0255a 100644
--- a/src/worker/index.ts
+++ b/src/worker/index.ts
@@ -12,6 +12,7 @@ export enum workerMessageType {
CHANNEL_ACTIVE_CHATTER,
IDENTITY_FETCHED,
CHANNEL_FETCHED,
+ PROVIDER_SET_FETCHED,
CHANNEL_SETS_FETCHED,
CONFIG,
CLOSE,
@@ -40,6 +41,11 @@ export type TypedWorkerMessage = {
CHANNEL_FETCHED: {
channel: CurrentChannel;
};
+ PROVIDER_SET_FETCHED: {
+ channel: CurrentChannel;
+ provider: SevenTV.Provider;
+ set_id: string;
+ };
CHANNEL_SETS_FETCHED: {
channel: CurrentChannel;
};
diff --git a/src/worker/worker.http.ts b/src/worker/worker.http.ts
index ebeee1e3a..1bc554892 100644
--- a/src/worker/worker.http.ts
+++ b/src/worker/worker.http.ts
@@ -154,6 +154,12 @@ export class WorkerHttp {
channel.id,
);
}
+
+ port.postMessage("PROVIDER_SET_FETCHED", {
+ channel,
+ provider: set.provider ?? "7TV",
+ set_id: set.id,
+ });
};
// iterate results and store sets to DB
diff --git a/tests/safari/compatibility-matrix.json b/tests/safari/compatibility-matrix.json
new file mode 100644
index 000000000..c09d49a81
--- /dev/null
+++ b/tests/safari/compatibility-matrix.json
@@ -0,0 +1,35 @@
+{
+ "schemaVersion": 1,
+ "required": [
+ {
+ "name": "current-stable-clean-profile",
+ "browser": "Safari stable",
+ "macOS": "current",
+ "profile": "clean",
+ "tests": ["release-package", "live-smoke", "20-reload-stress", "two-hour-session", "sleep-wake"]
+ },
+ {
+ "name": "technology-preview-clean-profile",
+ "browser": "Safari Technology Preview",
+ "macOS": "current",
+ "profile": "clean",
+ "tests": ["live-smoke", "20-reload-stress"]
+ },
+ {
+ "name": "previous-supported-macos",
+ "browser": "Safari stable",
+ "macOS": "previous supported release",
+ "profile": "clean",
+ "tests": ["release-package", "live-smoke", "upgrade-preserves-settings"]
+ }
+ ],
+ "optional": [
+ {
+ "name": "extension-conflict-profile",
+ "browser": "Safari stable",
+ "macOS": "current",
+ "profile": "popular content blocker enabled",
+ "tests": ["live-smoke", "20-reload-stress"]
+ }
+ ]
+}
diff --git a/tests/safari/live-safari-smoke.mjs b/tests/safari/live-safari-smoke.mjs
new file mode 100755
index 000000000..f55faa8b4
--- /dev/null
+++ b/tests/safari/live-safari-smoke.mjs
@@ -0,0 +1,214 @@
+#!/usr/bin/env node
+
+import assert from "node:assert/strict";
+import { execFileSync } from "node:child_process";
+import { existsSync, mkdirSync, writeFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { pathToFileURL } from "node:url";
+import { startSafariDriver, waitForCondition } from "./support/webdriver-client.mjs";
+
+const installedApp = "/Applications/7TV for Safari.app";
+
+export function parseLiveArgs(argv) {
+ const options = {
+ check: false,
+ url: process.env.SEVENTV_TWITCH_URL ?? "https://www.twitch.tv/illojuan",
+ reloads: Number(process.env.SEVENTV_RELOADS ?? 2),
+ timeoutSeconds: Number(process.env.SEVENTV_TIMEOUT_SECONDS ?? 45),
+ artifacts: process.env.SEVENTV_ARTIFACTS ?? "test-results/safari-live",
+ };
+ for (let i = 0; i < argv.length; i++) {
+ const arg = argv[i];
+ if (arg === "--check") options.check = true;
+ else if (arg === "--url") options.url = argv[++i];
+ else if (arg === "--reloads") options.reloads = Number(argv[++i]);
+ else if (arg === "--timeout") options.timeoutSeconds = Number(argv[++i]);
+ else if (arg === "--artifacts") options.artifacts = argv[++i];
+ else throw new Error(`Unknown argument: ${arg}`);
+ }
+ const url = new URL(options.url);
+ assert.equal(url.protocol, "https:", "The live test requires HTTPS");
+ assert.ok(
+ ["twitch.tv", "kick.com"].some((host) => url.hostname === host || url.hostname.endsWith(`.${host}`)),
+ "The emote-menu test supports only Twitch or Kick URLs",
+ );
+ assert.ok(Number.isInteger(options.reloads) && options.reloads >= 0 && options.reloads <= 100);
+ assert.ok(Number.isFinite(options.timeoutSeconds) && options.timeoutSeconds >= 5);
+ return options;
+}
+
+export function checkLivePrerequisites() {
+ assert.equal(process.platform, "darwin", "The real Safari test must run on macOS");
+ assert.ok(existsSync("/usr/bin/safaridriver"), "safaridriver is missing");
+ assert.ok(existsSync(installedApp), `${installedApp} is not installed`);
+ execFileSync("codesign", ["--verify", "--deep", "--strict", installedApp], { stdio: "pipe" });
+ return {
+ platform: process.platform,
+ safaridriver: execFileSync("/usr/bin/safaridriver", ["--version"], { encoding: "utf8" }).trim(),
+ installedApp,
+ codeSignature: "valid",
+ };
+}
+
+const readinessScript = `
+const root = document.querySelector('#seventv-root');
+const injected = document.querySelector('script#seventv-extension');
+const buttons = [...document.querySelectorAll('.seventv-emote-menu-button')];
+return {
+ url: location.href,
+ marker: document.documentElement.dataset.seventvExtension || null,
+ rootCount: document.querySelectorAll('#seventv-root').length,
+ injectedScript: injected?.src || null,
+ buttonCount: buttons.length,
+ now: performance.now(),
+ platform: document.body.hasAttribute('seventv-kick') ? 'KICK' : (location.hostname.endsWith('twitch.tv') ? 'TWITCH' : 'UNKNOWN')
+};`;
+
+const openMenuScript = `
+const buttons = [...document.querySelectorAll('.seventv-emote-menu-button')];
+const button = buttons.find((item) => item.getClientRects().length > 0);
+if (!button) return false;
+button.click();
+return true;`;
+
+const emoteMenuScript = `
+const menu = document.querySelector('.seventv-emote-menu');
+const selected = menu?.querySelector('.seventv-emote-menu-provider-icon[selected] span')?.textContent?.trim() || null;
+const images = [...(menu?.querySelectorAll('img.seventv-chat-emote') || [])]
+ .filter((image) => image.complete && image.naturalWidth > 0 && image.naturalHeight > 0)
+ .map((image) => ({ alt: image.alt, src: image.currentSrc || image.src }))
+ .filter((image) => {
+ try { const host = new URL(image.src).hostname; return host === '7tv.app' || host.endsWith('.7tv.app'); }
+ catch { return false; }
+ });
+const resources = performance.getEntriesByType('resource')
+ .map((entry) => {
+ try {
+ const host = new URL(entry.name).hostname;
+ if (!(host === '7tv.app' || host.endsWith('.7tv.app') || host === '7tv.io' || host.endsWith('.7tv.io'))) return null;
+ return { name: entry.name, startTime: entry.startTime, duration: entry.duration, initiatorType: entry.initiatorType };
+ } catch { return null; }
+ })
+ .filter(Boolean);
+const marks = performance.getEntriesByType('mark')
+ .filter((entry) => entry.name.startsWith('seventv:'))
+ .map((entry) => ({ name: entry.name, startTime: entry.startTime }));
+return { now: performance.now(), menuOpen: !!menu, selectedProvider: selected, loaded7TVImages: images.length, sample: images.slice(0, 5), resources, marks };`;
+
+const select7TVProviderScript = `
+const menu = document.querySelector('.seventv-emote-menu');
+if (!menu) return false;
+const providers = [...menu.querySelectorAll('.seventv-emote-menu-provider-icon')];
+const sevenTV = providers.find((item) => item.textContent?.trim() === '7TV');
+if (!sevenTV) return false;
+if (!sevenTV.hasAttribute('selected')) sevenTV.click();
+return true;`;
+
+async function capture(client, artifacts, name) {
+ const base64 = await client.screenshot();
+ writeFileSync(resolve(artifacts, `${name}.png`), Buffer.from(base64, "base64"));
+}
+
+async function verifyPage(client, options, iteration) {
+ const timeoutMs = options.timeoutSeconds * 1_000;
+ const injected = await waitForCondition(
+ async () => await client.execute(readinessScript),
+ (state) =>
+ state?.marker === "legacy" &&
+ state.rootCount === 1 &&
+ state.injectedScript?.startsWith("safari-web-extension:"),
+ { timeoutMs, intervalMs: 500, label: `7TV injection after load ${iteration}` },
+ );
+ const ready = await waitForCondition(
+ async () => await client.execute(readinessScript),
+ (state) => state?.buttonCount > 0,
+ { timeoutMs, intervalMs: 500, label: `7TV emote-menu button after load ${iteration}` },
+ );
+ assert.equal(await client.execute(openMenuScript), true, "The visible 7TV emote-menu button was not clickable");
+ await waitForCondition(
+ async () => await client.execute(select7TVProviderScript),
+ (selected) => selected === true,
+ { timeoutMs, intervalMs: 250, label: `7TV provider tab after load ${iteration}` },
+ );
+ const menuOpenedAt = await client.execute("return performance.now();");
+ const emotes = await waitForCondition(
+ async () => await client.execute(emoteMenuScript),
+ (state) => state?.menuOpen && state.selectedProvider === "7TV" && state.loaded7TVImages > 0,
+ { timeoutMs, intervalMs: 500, label: `real 7TV emotes after load ${iteration}` },
+ );
+ await capture(client, options.artifacts, `load-${iteration}`);
+ const durations = emotes.resources.map((resource) => resource.duration).sort((a, b) => a - b);
+ const percentile = (fraction) => durations[Math.max(0, Math.ceil(durations.length * fraction) - 1)] ?? null;
+ return {
+ iteration,
+ platform: ready.platform,
+ timingsMs: {
+ navigationToInjection: injected.now,
+ navigationToMenuButton: ready.now,
+ navigationToFirst7TVImage: emotes.now,
+ menuOpenToFirst7TVImage: emotes.now - menuOpenedAt,
+ sevenTVResourceP50: percentile(0.5),
+ sevenTVResourceP95: percentile(0.95),
+ },
+ pipelineMarks: emotes.marks,
+ ready,
+ emotes,
+ };
+}
+
+export async function runLiveSmoke(options) {
+ const prerequisites = checkLivePrerequisites();
+ mkdirSync(resolve(options.artifacts), { recursive: true });
+ const driver = await startSafariDriver();
+ const observations = [];
+ try {
+ try {
+ await driver.client.createSession();
+ } catch (error) {
+ throw new Error(
+ `${error.message}\nEnable Safari > Develop > Developer Settings > Allow remote automation, then rerun.`,
+ );
+ }
+ await driver.client.navigate(options.url);
+ observations.push(await verifyPage(driver.client, options, 0));
+ for (let iteration = 1; iteration <= options.reloads; iteration++) {
+ await driver.client.refresh();
+ observations.push(await verifyPage(driver.client, options, iteration));
+ }
+ const report = {
+ status: "passed",
+ createdAt: new Date().toISOString(),
+ url: options.url,
+ reloads: options.reloads,
+ prerequisites,
+ observations,
+ };
+ writeFileSync(resolve(options.artifacts, "report.json"), `${JSON.stringify(report, null, 2)}\n`);
+ return report;
+ } catch (error) {
+ if (driver.client.sessionId) {
+ await capture(driver.client, options.artifacts, "failure").catch(() => {});
+ }
+ throw error;
+ } finally {
+ await driver.client.close().catch(() => {});
+ await driver.stop();
+ }
+}
+
+async function main() {
+ const options = parseLiveArgs(process.argv.slice(2));
+ if (options.check) {
+ console.log(JSON.stringify({ status: "ready", ...checkLivePrerequisites() }, null, 2));
+ return;
+ }
+ const report = await runLiveSmoke(options);
+ console.log(JSON.stringify(report, null, 2));
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
+ main().catch((error) => {
+ console.error(error.stack ?? error.message);
+ process.exitCode = 1;
+ });
+}
diff --git a/tests/safari/performance/safari-resource-benchmark.mjs b/tests/safari/performance/safari-resource-benchmark.mjs
new file mode 100755
index 000000000..e1badde7d
--- /dev/null
+++ b/tests/safari/performance/safari-resource-benchmark.mjs
@@ -0,0 +1,204 @@
+#!/usr/bin/env node
+
+import assert from "node:assert/strict";
+import { execFileSync } from "node:child_process";
+import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
+import { dirname, resolve } from "node:path";
+import { pathToFileURL } from "node:url";
+import { delay } from "../support/webdriver-client.mjs";
+
+export function percentile(values, fraction) {
+ assert.ok(values.length > 0);
+ const sorted = [...values].sort((a, b) => a - b);
+ return sorted[Math.ceil(fraction * sorted.length) - 1];
+}
+
+export function summarizeSamples(samples) {
+ assert.ok(samples.length > 0, "At least one sample is required");
+ const cpu = samples.map((sample) => sample.cpuPercent);
+ const rss = samples.map((sample) => sample.rssMB);
+ const mean = (values) => values.reduce((sum, value) => sum + value, 0) / values.length;
+ const hours = Math.max((samples.at(-1).timestampMs - samples[0].timestampMs) / 3_600_000, 1 / 3_600_000);
+ return {
+ sampleCount: samples.length,
+ cpuMeanPercent: mean(cpu),
+ cpuP95Percent: percentile(cpu, 0.95),
+ rssMeanMB: mean(rss),
+ rssP95MB: percentile(rss, 0.95),
+ rssStartMB: rss[0],
+ rssEndMB: rss.at(-1),
+ rssSlopeMBPerHour: (rss.at(-1) - rss[0]) / hours,
+ processCountMax: Math.max(...samples.map((sample) => sample.processCount)),
+ };
+}
+
+export function parseSafariProcesses(text, attributedWebKitPids = new Set()) {
+ return text
+ .split("\n")
+ .map((line) => line.trim())
+ .filter(Boolean)
+ .map((line) => {
+ const match = line.match(/^(\d+)\s+(\d+)\s+([\d.]+)\s+(\d+)\s+(.+)$/);
+ if (!match) return null;
+ return {
+ pid: Number(match[1]),
+ ppid: Number(match[2]),
+ cpuPercent: Number(match[3]),
+ rssKB: Number(match[4]),
+ command: match[5],
+ };
+ })
+ .filter(
+ (process) =>
+ process &&
+ (/Safari(?:\.app|Shared|SafeBrowsing|PlatformSupport)/.test(process.command) ||
+ attributedWebKitPids.has(process.pid)),
+ );
+}
+
+function attributedWebKitPids(text) {
+ const candidates = text
+ .split("\n")
+ .map((line) => line.trim().match(/^(\d+)\s+\d+\s+[\d.]+\s+\d+\s+(.+)$/))
+ .filter((match) => match && /com\.apple\.WebKit/.test(match[2]));
+ const pids = new Set();
+ for (const match of candidates) {
+ const pid = Number(match[1]);
+ try {
+ const files = execFileSync("lsof", ["-p", String(pid), "-Fn"], {
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "ignore"],
+ });
+ if (/\/Library\/Containers\/com\.apple\.Safari\//.test(files)) pids.add(pid);
+ } catch {
+ // The process may exit between ps and lsof. The next sample will rediscover it.
+ }
+ }
+ return pids;
+}
+
+function batterySnapshot() {
+ const raw = execFileSync("pmset", ["-g", "batt"], { encoding: "utf8" });
+ return {
+ percent: Number(raw.match(/(\d+)%/)?.[1] ?? NaN),
+ powerSource: raw.match(/Now drawing from '([^']+)'/)?.[1] ?? "unknown",
+ raw: raw.trim(),
+ };
+}
+
+function resourceSample() {
+ const raw = execFileSync("ps", ["-axo", "pid=,ppid=,%cpu=,rss=,comm="], { encoding: "utf8" });
+ const processes = parseSafariProcesses(raw, attributedWebKitPids(raw));
+ return {
+ timestampMs: Date.now(),
+ cpuPercent: processes.reduce((sum, process) => sum + process.cpuPercent, 0),
+ rssMB: processes.reduce((sum, process) => sum + process.rssKB, 0) / 1024,
+ processCount: processes.length,
+ };
+}
+
+function valueAfter(argv, name, fallback) {
+ const index = argv.indexOf(name);
+ return index === -1 ? fallback : argv[index + 1];
+}
+
+async function sampleCommand(argv) {
+ assert.equal(process.platform, "darwin", "Safari benchmarks require macOS");
+ const label = valueAfter(argv, "--label");
+ const output = valueAfter(argv, "--output");
+ const durationSeconds = Number(valueAfter(argv, "--duration", "900"));
+ const intervalSeconds = Number(valueAfter(argv, "--interval", "5"));
+ const url = valueAfter(argv, "--url", "https://www.twitch.tv/illojuan");
+ const videoQuality = valueAfter(argv, "--video-quality", "document-me");
+ const brightness = valueAfter(argv, "--brightness", "document-me");
+ assert.ok(["disabled", "enabled"].includes(label), "--label must be disabled or enabled");
+ assert.ok(output, "--output is required");
+ assert.ok(durationSeconds >= intervalSeconds && intervalSeconds >= 1);
+ const startedBattery = batterySnapshot();
+ const samples = [];
+ const deadline = Date.now() + durationSeconds * 1_000;
+ while (Date.now() <= deadline) {
+ samples.push(resourceSample());
+ if (Date.now() + intervalSeconds * 1_000 > deadline) break;
+ await delay(intervalSeconds * 1_000);
+ }
+ const endedBattery = batterySnapshot();
+ const report = {
+ schemaVersion: 1,
+ label,
+ createdAt: new Date().toISOString(),
+ conditions: { url, videoQuality, brightness, durationSeconds, intervalSeconds },
+ battery: { start: startedBattery, end: endedBattery },
+ summary: summarizeSamples(samples),
+ samples,
+ };
+ mkdirSync(dirname(resolve(output)), { recursive: true });
+ writeFileSync(resolve(output), `${JSON.stringify(report, null, 2)}\n`);
+ console.log(JSON.stringify(report.summary, null, 2));
+}
+
+export function compareReports(disabled, enabled) {
+ for (const field of ["url", "videoQuality", "brightness", "durationSeconds", "intervalSeconds"]) {
+ assert.deepEqual(enabled.conditions[field], disabled.conditions[field], `Condition differs: ${field}`);
+ }
+ const delta = (after, before) => after - before;
+ return {
+ cpuMeanDeltaPercentPoints: delta(enabled.summary.cpuMeanPercent, disabled.summary.cpuMeanPercent),
+ cpuP95DeltaPercentPoints: delta(enabled.summary.cpuP95Percent, disabled.summary.cpuP95Percent),
+ rssMeanDeltaMB: delta(enabled.summary.rssMeanMB, disabled.summary.rssMeanMB),
+ rssP95DeltaMB: delta(enabled.summary.rssP95MB, disabled.summary.rssP95MB),
+ rssSlopeDeltaMBPerHour: delta(
+ enabled.summary.rssSlopeMBPerHour,
+ disabled.summary.rssSlopeMBPerHour,
+ ),
+ batteryDeltaPercentagePoints: delta(
+ disabled.battery.start.percent - disabled.battery.end.percent,
+ enabled.battery.start.percent - enabled.battery.end.percent,
+ ) * -1,
+ };
+}
+
+function compareCommand(argv) {
+ const disabledPath = valueAfter(argv, "--disabled");
+ const enabledPath = valueAfter(argv, "--enabled");
+ const output = valueAfter(argv, "--output", "test-results/safari-benchmark/comparison.json");
+ assert.ok(disabledPath && enabledPath, "--disabled and --enabled are required");
+ const disabled = JSON.parse(readFileSync(resolve(disabledPath), "utf8"));
+ const enabled = JSON.parse(readFileSync(resolve(enabledPath), "utf8"));
+ assert.equal(disabled.label, "disabled");
+ assert.equal(enabled.label, "enabled");
+ const comparison = compareReports(disabled, enabled);
+ const limits = {
+ cpuMeanDeltaPercentPoints: Number(valueAfter(argv, "--max-cpu-mean-delta", "Infinity")),
+ rssMeanDeltaMB: Number(valueAfter(argv, "--max-rss-mean-delta", "Infinity")),
+ rssSlopeDeltaMBPerHour: Number(valueAfter(argv, "--max-rss-slope-delta", "Infinity")),
+ };
+ const violations = Object.entries(limits)
+ .filter(([, limit]) => Number.isFinite(limit))
+ .filter(([metric, limit]) => comparison[metric] > limit)
+ .map(([metric, limit]) => ({ metric, actual: comparison[metric], limit }));
+ const report = { status: violations.length ? "failed" : "passed", comparison, limits, violations };
+ mkdirSync(dirname(resolve(output)), { recursive: true });
+ writeFileSync(resolve(output), `${JSON.stringify(report, null, 2)}\n`);
+ console.log(JSON.stringify(report, null, 2));
+ if (violations.length) process.exitCode = 2;
+}
+
+async function main() {
+ const [command, ...argv] = process.argv.slice(2);
+ if (command === "sample") await sampleCommand(argv);
+ else if (command === "compare") compareCommand(argv);
+ else {
+ throw new Error(
+ "Usage: safari-resource-benchmark.mjs sample --label disabled|enabled --output FILE [conditions]\n" +
+ " or: safari-resource-benchmark.mjs compare --disabled FILE --enabled FILE [limits]",
+ );
+ }
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
+ main().catch((error) => {
+ console.error(error.stack ?? error.message);
+ process.exitCode = 1;
+ });
+}
diff --git a/tests/safari/safari-harness.test.mjs b/tests/safari/safari-harness.test.mjs
new file mode 100644
index 000000000..77236cd44
--- /dev/null
+++ b/tests/safari/safari-harness.test.mjs
@@ -0,0 +1,54 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { compareReports, parseSafariProcesses, percentile, summarizeSamples } from "./performance/safari-resource-benchmark.mjs";
+import { parseLiveArgs } from "./live-safari-smoke.mjs";
+
+test("live Safari options accept Twitch and Kick but reject unrelated navigation", () => {
+ assert.throws(() => parseLiveArgs(["--url", "https://example.com"]), /supports only Twitch or Kick/);
+ assert.equal(parseLiveArgs(["--url", "https://kick.com/xqc"]).url, "https://kick.com/xqc");
+ assert.throws(() => parseLiveArgs(["--reloads", "101"]));
+ assert.equal(parseLiveArgs(["--reloads", "20"]).reloads, 20);
+});
+
+test("Safari process samples include Safari and WebKit only", () => {
+ const processes = parseSafariProcesses(`
+101 1 12.5 102400 /Applications/Safari.app/Contents/MacOS/Safari
+102 101 4.0 51200 /System/Library/Frameworks/WebKit.framework/com.apple.WebKit.WebContent
+999 1 99.0 99999 /Applications/Other.app/Contents/MacOS/Other
+`, new Set([102]));
+ assert.equal(processes.length, 2);
+ assert.equal(processes.reduce((sum, process) => sum + process.cpuPercent, 0), 16.5);
+});
+
+test("resource summary and percentiles are deterministic", () => {
+ const samples = [
+ { timestampMs: 0, cpuPercent: 1, rssMB: 100, processCount: 2 },
+ { timestampMs: 1_800_000, cpuPercent: 3, rssMB: 110, processCount: 3 },
+ { timestampMs: 3_600_000, cpuPercent: 8, rssMB: 120, processCount: 4 },
+ ];
+ assert.equal(percentile([8, 1, 3], 0.95), 8);
+ assert.deepEqual(summarizeSamples(samples), {
+ sampleCount: 3,
+ cpuMeanPercent: 4,
+ cpuP95Percent: 8,
+ rssMeanMB: 110,
+ rssP95MB: 120,
+ rssStartMB: 100,
+ rssEndMB: 120,
+ rssSlopeMBPerHour: 20,
+ processCountMax: 4,
+ });
+});
+
+test("A/B comparison refuses different test conditions", () => {
+ const base = {
+ label: "disabled",
+ conditions: { url: "https://www.twitch.tv/illojuan", videoQuality: "1080p", brightness: "50", durationSeconds: 900, intervalSeconds: 5 },
+ battery: { start: { percent: 90 }, end: { percent: 85 } },
+ summary: { cpuMeanPercent: 10, cpuP95Percent: 20, rssMeanMB: 500, rssP95MB: 600, rssSlopeMBPerHour: 5 },
+ };
+ const enabled = structuredClone(base);
+ enabled.label = "enabled";
+ enabled.conditions.videoQuality = "720p";
+ assert.throws(() => compareReports(base, enabled), /Condition differs: videoQuality/);
+});
diff --git a/tests/safari/safari-regressions.test.mjs b/tests/safari/safari-regressions.test.mjs
new file mode 100644
index 000000000..c4a11d973
--- /dev/null
+++ b/tests/safari/safari-regressions.test.mjs
@@ -0,0 +1,145 @@
+import assert from "node:assert/strict";
+import { execFileSync } from "node:child_process";
+import { createHash } from "node:crypto";
+import { existsSync, readFileSync, readdirSync } from "node:fs";
+import { join, resolve } from "node:path";
+import test from "node:test";
+
+const root = resolve(import.meta.dirname, "../..");
+const dist = join(root, "dist");
+const installedApp = process.env.SEVENTV_INSTALLED_APP;
+const installedExtensionID = process.env.SEVENTV_INSTALLED_EXTENSION_ID ?? "app.seventv.safari.Extension";
+const installedResources = installedApp
+ ? join(installedApp, "Contents/PlugIns/7TV for Safari Extension.appex/Contents/Resources")
+ : null;
+
+function source(path) {
+ return readFileSync(join(root, path), "utf8");
+}
+
+function digest(path) {
+ return createHash("sha256").update(readFileSync(path)).digest("hex");
+}
+
+function walkFiles(dir) {
+ return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
+ const path = join(dir, entry.name);
+ return entry.isDirectory() ? walkFiles(path) : [path];
+ });
+}
+
+test("Safari manifest statically enables only the three upstream-supported sites", () => {
+ const manifest = JSON.parse(readFileSync(join(dist, "manifest.json"), "utf8"));
+ const supportedHosts = ["*://*.twitch.tv/*", "*://*.kick.com/*", "*://*.youtube.com/*"];
+
+ assert.deepEqual(manifest.permissions, ["storage"]);
+ assert.deepEqual(manifest.host_permissions, supportedHosts);
+ assert.equal(manifest.optional_permissions, undefined);
+ assert.equal(manifest.optional_host_permissions, undefined);
+ assert.deepEqual(manifest.content_scripts, [
+ { matches: supportedHosts, js: ["content.js"] },
+ ]);
+ assert.equal(
+ manifest.content_security_policy.extension_pages,
+ "script-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
+ );
+ assert.deepEqual(manifest.web_accessible_resources, [
+ {
+ resources: ["site.js", "worker.js", "index.html", "assets/*"],
+ matches: supportedHosts,
+ },
+ ]);
+ assert.equal(walkFiles(dist).some((path) => path.endsWith(".map")), false);
+});
+
+test("Safari service worker cannot repeat optional platform registration", () => {
+ const background = readFileSync(join(dist, "background.js"), "utf8");
+
+ assert.doesNotMatch(background, /registerContentScripts/);
+ assert.doesNotMatch(background, /seventv-youtube|seventv-kick/);
+ assert.doesNotMatch(background, /permissions\.contains/);
+});
+
+test("closed-tab messaging is handled without aborting initialization", () => {
+ assert.match(source("src/background/sync.ts"), /void chrome\.runtime\.lastError/);
+ assert.match(source("src/background/messaging.ts"), /void chrome\.runtime\.lastError/);
+});
+
+test("Safari performance timing covers worker, provider set and full-set settlement", () => {
+ const worker = source("src/composable/useWorker.ts");
+ const workerHTTP = source("src/worker/worker.http.ts");
+
+ assert.match(worker, /seventv:\$\{name\}/);
+ assert.match(worker, /worker-init-start/);
+ assert.match(worker, /PROVIDER_SET_FETCHED/);
+ assert.match(worker, /all-sets-settled/);
+ assert.match(workerHTTP, /postMessage\("PROVIDER_SET_FETCHED"/);
+});
+
+test("page-controlled storage cannot replace the packaged worker", () => {
+ const worker = source("src/composable/useWorker.ts");
+
+ assert.doesNotMatch(worker, /localStorage\s*\.(?:getItem|setItem|removeItem|clear)/);
+ assert.match(worker, /url\.pathname === "\/worker\.js"/);
+ assert.match(worker, /safari-web-extension:/);
+ assert.match(worker, /url\.search === ""/);
+ assert.match(worker, /url\.hash === ""/);
+});
+
+test("auth and HTML injection boundaries are guarded", () => {
+ const profile = source("src/app/settings/SettingsViewProfile.vue");
+
+ assert.match(profile, /ev\.origin !== authOrigin/);
+ assert.match(profile, /ev\.source !== w/);
+ assert.match(profile, /postMessage\("7tv-token-request", authOrigin\)/);
+ assert.match(source("src/site/global/Changelog.vue"), /DOMPurify\.sanitize/);
+ assert.match(source("src/content/emoji.ts"), /DOMPurify\.sanitize/);
+});
+
+test("native wrapper keeps only the sandbox entitlement", () => {
+ const project = source("safari-project/7TV for Safari/7TV for Safari.xcodeproj/project.pbxproj");
+ const buildScript = source("script/build-safari-local.sh");
+
+ assert.doesNotMatch(project, /ENABLE_USER_SELECTED_FILES/);
+ assert.doesNotMatch(project, /ENABLE_OUTGOING_NETWORK_CONNECTIONS/);
+ assert.match(
+ source("safari-project/7TV for Safari/7TV for Safari/7TV for Safari.entitlements"),
+ /com\.apple\.security\.app-sandbox/,
+ );
+ assert.match(
+ source(
+ "safari-project/7TV for Safari/7TV for Safari Extension/7TV for Safari Extension.entitlements",
+ ),
+ /com\.apple\.security\.app-sandbox/,
+ );
+ assert.match(buildScript, /REGISTER_WITH_LAUNCH_SERVICES=NO/);
+});
+
+test(
+ "installed app is signed, uniquely registered from Applications, and matches the build",
+ {
+ skip:
+ process.env.SEVENTV_SKIP_INSTALLED_CHECK === "1" ||
+ process.platform !== "darwin" ||
+ !installedApp ||
+ !existsSync(installedApp),
+ },
+ () => {
+ assert.ok(installedApp);
+ assert.ok(installedResources);
+ execFileSync("codesign", ["--verify", "--deep", "--strict", installedApp], { stdio: "pipe" });
+
+ const registration = execFileSync(
+ "pluginkit",
+ ["-mAvvv", "-i", installedExtensionID],
+ { encoding: "utf8" },
+ );
+ assert.match(registration, /Path = \/Applications\/7TV for Safari\.app\//);
+ assert.match(registration, /\(1 plug-in\)/);
+ assert.doesNotMatch(registration, /\/private\/var\/folders|\/tmp\//);
+
+ for (const name of ["manifest.json", "background.js", "content.js", "site.js", "worker.js"]) {
+ assert.equal(digest(join(dist, name)), digest(join(installedResources, name)), `${name} differs`);
+ }
+ },
+);
diff --git a/tests/safari/support/webdriver-client.mjs b/tests/safari/support/webdriver-client.mjs
new file mode 100644
index 000000000..c96486bdb
--- /dev/null
+++ b/tests/safari/support/webdriver-client.mjs
@@ -0,0 +1,137 @@
+import { spawn } from "node:child_process";
+import { createServer } from "node:net";
+
+export function delay(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+export async function getAvailablePort() {
+ return await new Promise((resolve, reject) => {
+ const server = createServer();
+ server.once("error", reject);
+ server.listen(0, "127.0.0.1", () => {
+ const address = server.address();
+ if (!address || typeof address === "string") {
+ server.close();
+ reject(new Error("Could not allocate a local WebDriver port"));
+ return;
+ }
+ server.close((error) => (error ? reject(error) : resolve(address.port)));
+ });
+ });
+}
+
+export class WebDriverClient {
+ constructor(endpoint) {
+ this.endpoint = endpoint.replace(/\/$/, "");
+ this.sessionId = null;
+ }
+
+ async request(method, path, body) {
+ const response = await fetch(`${this.endpoint}${path}`, {
+ method,
+ headers: body === undefined ? undefined : { "content-type": "application/json" },
+ body: body === undefined ? undefined : JSON.stringify(body),
+ });
+ const payload = await response.json().catch(() => ({}));
+ if (!response.ok || payload?.value?.error) {
+ const detail = payload?.value?.message ?? `${response.status} ${response.statusText}`;
+ throw new Error(`WebDriver ${method} ${path} failed: ${detail}`);
+ }
+ return payload.value;
+ }
+
+ async createSession() {
+ const value = await this.request("POST", "/session", {
+ capabilities: { alwaysMatch: { browserName: "safari" } },
+ });
+ this.sessionId = value.sessionId;
+ return value;
+ }
+
+ async command(method, path, body) {
+ if (!this.sessionId) throw new Error("WebDriver session has not been created");
+ return await this.request(method, `/session/${this.sessionId}${path}`, body);
+ }
+
+ async navigate(url) {
+ return await this.command("POST", "/url", { url });
+ }
+
+ async refresh() {
+ return await this.command("POST", "/refresh", {});
+ }
+
+ async execute(script, args = []) {
+ return await this.command("POST", "/execute/sync", { script, args });
+ }
+
+ async screenshot() {
+ return await this.command("GET", "/screenshot");
+ }
+
+ async close() {
+ if (!this.sessionId) return;
+ try {
+ await this.request("DELETE", `/session/${this.sessionId}`);
+ } finally {
+ this.sessionId = null;
+ }
+ }
+}
+
+export async function waitForCondition(probe, accept, { timeoutMs, intervalMs = 250, label }) {
+ const deadline = Date.now() + timeoutMs;
+ let latest;
+ let latestError;
+ while (Date.now() < deadline) {
+ try {
+ latest = await probe();
+ latestError = undefined;
+ if (accept(latest)) return latest;
+ } catch (error) {
+ latestError = error;
+ }
+ await delay(intervalMs);
+ }
+ const suffix = latestError
+ ? latestError.message
+ : `last observation: ${JSON.stringify(latest)}`;
+ throw new Error(`Timed out waiting for ${label}: ${suffix}`);
+}
+
+export async function startSafariDriver({ executable = "/usr/bin/safaridriver", timeoutMs = 10_000 } = {}) {
+ const port = await getAvailablePort();
+ const child = spawn(executable, ["--port", String(port)], {
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+ let diagnostics = "";
+ child.stdout.on("data", (chunk) => (diagnostics += chunk.toString()));
+ child.stderr.on("data", (chunk) => (diagnostics += chunk.toString()));
+
+ const client = new WebDriverClient(`http://127.0.0.1:${port}`);
+ try {
+ await waitForCondition(
+ async () => await client.request("GET", "/status"),
+ (value) => value?.ready === true,
+ { timeoutMs, label: "safaridriver readiness" },
+ );
+ } catch (error) {
+ child.kill("SIGTERM");
+ throw new Error(`${error.message}${diagnostics ? `\n${diagnostics.trim()}` : ""}`);
+ }
+
+ return {
+ client,
+ port,
+ diagnostics: () => diagnostics,
+ stop: async () => {
+ if (child.exitCode !== null) return;
+ child.kill("SIGTERM");
+ await Promise.race([
+ new Promise((resolve) => child.once("exit", resolve)),
+ delay(2_000).then(() => child.kill("SIGKILL")),
+ ]);
+ },
+ };
+}
diff --git a/vite.config.background.mts b/vite.config.background.mts
index 9ba95931f..141f7b9ca 100644
--- a/vite.config.background.mts
+++ b/vite.config.background.mts
@@ -13,6 +13,7 @@ export default defineConfig(() => {
VITE_APP_NAME: appName,
VITE_APP_VERSION: getFullVersion(isNightly),
VITE_APP_VERSION_BRANCH: process.env.BRANCH || "",
+ VITE_APP_SAFARI: process.env.SAFARI === "1" ? "true" : "false",
};
return {
diff --git a/vite.config.content.mts b/vite.config.content.mts
index 4d521b79e..fd7689583 100644
--- a/vite.config.content.mts
+++ b/vite.config.content.mts
@@ -15,6 +15,7 @@ export default defineConfig(() => {
VITE_APP_NAME: appName,
VITE_APP_VERSION: fullVersion,
VITE_APP_VERSION_BRANCH: process.env.BRANCH || "",
+ VITE_APP_SAFARI: process.env.SAFARI === "1" ? "true" : "false",
VITE_APP_STYLESHEET_NAME: `seventv.style.${fullVersion}.css`,
};
diff --git a/vite.config.mts b/vite.config.mts
index 5a441a307..124a177ed 100644
--- a/vite.config.mts
+++ b/vite.config.mts
@@ -41,6 +41,7 @@ export default defineConfig(() => {
VITE_APP_NAME: appName,
VITE_APP_VERSION: fullVersion,
VITE_APP_VERSION_BRANCH: (process.env.BRANCH as BranchName) || "",
+ VITE_APP_SAFARI: process.env.SAFARI === "1" ? "true" : "false",
VITE_APP_CHANGELOG: fs.readFileSync(
r(
{
@@ -139,6 +140,7 @@ export default defineConfig(() => {
const man = await getManifest({
version: getFullVersion(isNightly),
dev: isDev,
+ safari: !!process.env.SAFARI,
branch: process.env.BRANCH as BranchName,
mv2: isDev || !!process.env.MV2,
mozillaID: process.env.MOZILLA_ID,
diff --git a/yarn.lock b/yarn.lock
index 15cf146c6..23c8339b7 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3018,10 +3018,10 @@ muggle-string@^0.3.1:
resolved "https://registry.yarnpkg.com/muggle-string/-/muggle-string-0.3.1.tgz#e524312eb1728c63dd0b2ac49e3282e6ed85963a"
integrity sha512-ckmWDJjphvd/FvZawgygcUeQCxzvohjFO5RxTjj4eq8kw359gFF3E1brjfI+viLMxss5JrHTDRHZvu2/tuy0Qg==
-nanoid@^3.3.6:
- version "3.3.7"
- resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8"
- integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==
+nanoid@^3.3.16:
+ version "3.3.19"
+ resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.19.tgz#336d4aa4bcd4fb24d2cddede7ffeae40bec03f0a"
+ integrity sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==
nanoid@^5.0.3:
version "5.0.3"
@@ -3293,6 +3293,11 @@ picocolors@^1.0.0:
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c"
integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==
+picocolors@^1.1.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
+ integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==
+
picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
@@ -3368,23 +3373,14 @@ postcss-value-parser@^4.2.0:
resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514"
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
-postcss@^8.1.10, postcss@^8.4.28, postcss@^8.4.31:
- version "8.4.31"
- resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.31.tgz#92b451050a9f914da6755af352bdc0192508656d"
- integrity sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==
- dependencies:
- nanoid "^3.3.6"
- picocolors "^1.0.0"
- source-map-js "^1.0.2"
-
-postcss@^8.4.0, postcss@^8.4.13:
- version "8.4.27"
- resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.27.tgz#234d7e4b72e34ba5a92c29636734349e0d9c3057"
- integrity sha512-gY/ACJtJPSmUFPDCHtX78+01fHa64FaU4zaaWfuh1MhGJISufJAH4cun6k/8fwsHYeK4UQmENQK+tRLCFJE8JQ==
+postcss@8.5.23, postcss@^8.1.10, postcss@^8.4.0, postcss@^8.4.13, postcss@^8.4.28, postcss@^8.4.31:
+ version "8.5.23"
+ resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.23.tgz#3493550116f478487298301d2c2e8dc5a56e6594"
+ integrity sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==
dependencies:
- nanoid "^3.3.6"
- picocolors "^1.0.0"
- source-map-js "^1.0.2"
+ nanoid "^3.3.16"
+ picocolors "^1.1.1"
+ source-map-js "^1.2.1"
prelude-ls@^1.2.1:
version "1.2.1"
@@ -3694,6 +3690,11 @@ slice-ansi@^4.0.0:
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c"
integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==
+source-map-js@^1.2.1:
+ version "1.2.1"
+ resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"
+ integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
+
source-map-support@~0.5.20:
version "0.5.21"
resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f"